Compare commits

..

No commits in common. "main" and "0.82.1" have entirely different histories.
main ... 0.82.1

54 changed files with 1118 additions and 2706 deletions

View file

@ -1,20 +0,0 @@
{
"name": "haiku-rag",
"interface": {
"displayName": "haiku.rag"
},
"plugins": [
{
"name": "haiku-rag",
"source": {
"source": "local",
"path": "./plugins/haiku-rag"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}

View file

@ -1,14 +0,0 @@
{
"name": "haiku-rag",
"description": "The haiku.rag knowledge base as Claude Code tools and a skill.",
"owner": {
"name": "Yiorgis Gozadinos"
},
"plugins": [
{
"name": "haiku-rag",
"source": "./plugins/haiku-rag",
"description": "Search, read and analyze your haiku.rag knowledge base from Claude Code."
}
]
}

View file

@ -2,73 +2,6 @@
## [Unreleased] ## [Unreleased]
### Added
- Claude Code and Codex plugin under `plugins/haiku-rag/`: two client manifests
sharing the server configuration and the `haiku-rag` Agent Skill.
- MCP tool `execute_code(code, filter, sources)`: runs a program in the
analysis sandbox over the selected documents and returns what it printed;
one sandbox per call.
- In the analysis sandbox, `search()` results carry `chunk_meta`,
`list_documents()` rows and `metadata.json` carry the document `metadata`,
and `/documents/{id}/chunks.jsonl` lists chunk ids with their metadata.
`recovery_hint` in `haiku.rag.sandbox`.
- MCP tools `get_document_outline` (heading tree with page numbers) and
`get_document_section` (one section's text, subsections included), built
on `document_items`. `build_toc` in `haiku.rag.context`.
- MCP server `instructions`, `version`, and read-only `ToolAnnotations` on
every tool; every parameter carries a description. `filter` on
`search_documents` and `search_documents_by_image`. `DocumentInfo.metadata`.
### Changed
- `pydantic-monty>=0.0.23`. The analysis sandbox gains `collections`,
`itertools`, `functools`, `dataclasses`, function decorators and
`str.format`.
- `fastmcp>=4.0.2,<5.0.0`, on MCP Python SDK 2. The MCP server answers both the
session-based and the sessionless (2026-07-28) protocol.
- Default models are `ollama:qwen3.8`: `ModelConfig`, `qa.model`,
`processing.title_model` (was `ollama:gpt-oss`) and
`processing.conversion_options.picture_description.model` (was
`ollama:ministral-3`). Run `ollama pull qwen3.8`.
- `qa.model.vision` defaults to `true`, matching `qwen3.8`. Set it `false` when
pointing `qa.model` at a text-only model.
- `enable_thinking` on `provider: ollama` maps to `reasoning_effort` for every
model, not only `gpt-oss`: `false` sends `none`, `true` sends `high`.
`gpt-oss` keeps `low` for `false`.
- `processing.conversion_options.picture_description.model` defaults to
`enable_thinking: false`, and the field now reaches the VLM: docling's
picture-description request carries `reasoning_effort` in `params`.
- MCP `search_documents` and `search_documents_by_image` expand results to
their section (`HaikuRAG.expand_context`) and return the agent rendering
as text (rank, `Document ID`, `Collection` over several databases, title,
headings, the matched chunk's metadata, passage) and pictures as
`ImageContent` blocks, with no structured content.
`SearchResult.format_for_agent(include_document_id=, include_chunk_meta=)`;
`collect_pictures` in `haiku.rag.tools.search`.
- MCP tools raise on failure, with the error's message; an empty result no
longer doubles as an error.
- `haiku-rag mcp` covers the configured `lancedb.databases` set. `sources` on
`search_documents`, `search_documents_by_image` and `execute_code`; `source`
on `get_document`; an unknown name is a tool error. `DocumentInfo.source`.
### Fixed
- `toc.json` `item_range` in the analysis sandbox is a line slice into
`items.jsonl`, as documented; it held item positions.
- Past `analysis.code_timeout` a sandbox program starts no further host call.
Files served from memory and in-code `search()` / `list_documents()` were
not checked against the deadline.
### Removed
- MCP tools `ask_question` and `analyze`.
- `format_citations` in `haiku.rag.utils`; `format_citations_rich` stays.
- MCP write tools `add_document_from_file`, `add_document_from_url`,
`add_document_from_text` and `delete_document`. The server opens the
database read-only; ingest with `haiku-rag add`, `add-src`, `delete` or
`haiku-ingester`. `create_mcp_server` loses `read_only`.
## [0.82.1] - 2026-09-03 ## [0.82.1] - 2026-09-03
### Fixed ### Fixed

View file

@ -16,7 +16,7 @@ Built on [LanceDB](https://lancedb.com/), [Pydantic AI](https://ai.pydantic.dev/
- **Hybrid search** — Vector + full-text with Reciprocal Rank Fusion - **Hybrid search** — Vector + full-text with Reciprocal Rank Fusion
- **Multimodal & cross-modal search** — Multimodal embedders (vLLM, VoyageAI, Cohere) put picture vectors in the same space as text; supports text-as-query → figure hits and image-as-query - **Multimodal & cross-modal search** — Multimodal embedders (vLLM, VoyageAI, Cohere) put picture vectors in the same space as text; supports text-as-query → figure hits and image-as-query
- **Question answering** — RAG capability with citations (page numbers, section headings) - **Question answering** — RAG capability with citations (page numbers, section headings)
- **Vision QA** — Vision-capable models receive figure bytes alongside chunk text; attach your own images to questions in `ask`, `analyze` and the chat TUI - **Vision QA** — Vision-capable models receive figure bytes alongside chunk text; attach your own images to questions in `ask`, `analyze`, MCP, and the chat TUI
- **Reranking** — local cross-encoders, Cohere, Zero Entropy, or vLLM - **Reranking** — local cross-encoders, Cohere, Zero Entropy, or vLLM
- **Analysis capability** — Complex analytical tasks via sandboxed Python code execution (aggregation, computation, multi-document analysis) - **Analysis capability** — Complex analytical tasks via sandboxed Python code execution (aggregation, computation, multi-document analysis)
- **Evidence compaction** — Optional capability that replaces earlier questions' search results on the request with the evidence they cited, so long conversations stop resending everything they retrieved - **Evidence compaction** — Optional capability that replaces earlier questions' search results on the request with the evidence they cited, so long conversations stop resending everything they retrieved
@ -110,26 +110,12 @@ For direct agent composition, see the [capabilities documentation](https://ggoza
## MCP Server ## MCP Server
Use with AI assistants like Claude Code, Codex, and Claude Desktop: Use with AI assistants like Claude Desktop:
```bash ```bash
haiku-rag mcp --stdio haiku-rag mcp --stdio
``` ```
In Claude Code, install the plugin, which registers the server and a skill:
```bash
claude plugin marketplace add ggozad/haiku.rag
claude plugin install haiku-rag
```
In Codex, install the same plugin from its marketplace:
```bash
codex plugin marketplace add ggozad/haiku.rag
codex plugin add haiku-rag@haiku-rag
```
Add to your Claude Desktop configuration: Add to your Claude Desktop configuration:
```json ```json
@ -143,7 +129,7 @@ Add to your Claude Desktop configuration:
} }
``` ```
Provides search, document reading, and analysis tools directly in your AI assistant. Provides tools for document management, search, QA, and analysis directly in your AI assistant.
## Examples ## Examples

View file

@ -40,4 +40,4 @@ EXPOSE 8001 8765
# Default command: read-only MCP server. The companion ingester service is # Default command: read-only MCP server. The companion ingester service is
# launched via docker-compose against the same image. # launched via docker-compose against the same image.
CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "mcp", "--port", "8001"] CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "--read-only", "mcp", "--port", "8001"]

View file

@ -39,4 +39,4 @@ EXPOSE 8001 8765
# Default command: read-only MCP server. The companion ingester service is # Default command: read-only MCP server. The companion ingester service is
# launched via docker-compose against the same image. # launched via docker-compose against the same image.
CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "mcp", "--port", "8001"] CMD ["haiku-rag", "--config", "/app/haiku.rag.yaml", "--read-only", "mcp", "--port", "8001"]

View file

@ -16,7 +16,7 @@ When `qa.max_searches` or `analysis.max_executions` runs out, the exhausted tool
| `analysis_execute_code(code)` | Run Python against the virtual document filesystem. | | `analysis_execute_code(code)` | Run Python against the virtual document filesystem. |
| `analysis_cite(chunk_ids)` | Register retrieved or filesystem-derived chunk IDs. | | `analysis_cite(chunk_ids)` | Register retrieved or filesystem-derived chunk IDs. |
The sandbox exposes documents under `/documents/{document_id}/` with `metadata.json`, `content.txt`, `items.jsonl`, `chunks.jsonl` (chunk ids with their metadata) and `toc.json`. In code, `await search()` results carry `chunk_meta` and `await list_documents()` rows carry `metadata`. The interpreter's limits and the per-call budgets are listed under [MCP, Code](../mcp.md#code). The sandbox exposes documents under `/documents/{document_id}/` with `metadata.json`, `content.txt`, `items.jsonl`, and `toc.json`.
## Compose an agent ## Compose an agent

View file

@ -24,7 +24,7 @@ The `haiku-rag` CLI provides complete document management functionality.
haiku-rag add -h haiku-rag add -h
``` ```
With `lancedb.databases` configured, `search`, `ask`, `analyze`, `chat`, and `mcp` use the full set by default. Select one database for other commands with `--db-name` or `--db`. `settings`, `init-config`, and `download-models` do not open a database. See [Multiple Databases](configuration/storage.md#multiple-databases). With `lancedb.databases` configured, `search`, `ask`, `analyze`, and `chat` use the full set by default. Select one database for other commands with `--db-name` or `--db`. `settings`, `init-config`, and `download-models` do not open a database. See [Multiple Databases](configuration/storage.md#multiple-databases).
## Document Management ## Document Management
@ -477,6 +477,9 @@ haiku-rag mcp --port 9000
# Bind to all interfaces (containers, trusted LAN) # Bind to all interfaces (containers, trusted LAN)
haiku-rag mcp --host 0.0.0.0 haiku-rag mcp --host 0.0.0.0
# Read-only mode (no write tools)
haiku-rag --read-only mcp
``` ```
See [MCP](mcp.md) for details. For continuous document ingestion See [MCP](mcp.md) for details. For continuous document ingestion

View file

@ -61,7 +61,7 @@ embeddings:
qa: qa:
model: model:
provider: ollama provider: ollama
name: qwen3.8 name: gpt-oss
enable_thinking: true enable_thinking: true
``` ```
@ -105,7 +105,7 @@ reranking:
qa: qa:
model: model:
provider: ollama provider: ollama
name: qwen3.8 name: gpt-oss
enable_thinking: true enable_thinking: true
temperature: 0.3 temperature: 0.3
max_searches: 5 max_searches: 5
@ -135,7 +135,7 @@ processing:
auto_title: false # Auto-generate titles on ingestion auto_title: false # Auto-generate titles on ingestion
title_model: title_model:
provider: ollama provider: ollama
name: qwen3.8 name: gpt-oss
enable_thinking: false enable_thinking: false
temperature: 0.3 temperature: 0.3
max_tokens: 100 max_tokens: 100

View file

@ -30,7 +30,7 @@ processing:
auto_title: false # Auto-generate titles on ingestion auto_title: false # Auto-generate titles on ingestion
title_model: # LLM for title generation (fallback) title_model: # LLM for title generation (fallback)
provider: ollama provider: ollama
name: qwen3.8 name: gpt-oss
enable_thinking: false enable_thinking: false
# Conversion options (works with both local and remote converters) # Conversion options (works with both local and remote converters)
@ -54,7 +54,7 @@ processing:
picture_description: picture_description:
model: model:
provider: ollama provider: ollama
name: qwen3.8 name: ministral-3
pictures: image # none | description | image pictures: image # none | description | image
``` ```
@ -270,7 +270,7 @@ processing:
picture_description: # only consulted when pictures == "description" picture_description: # only consulted when pictures == "description"
model: model:
provider: ollama # any OpenAI-compatible /v1/chat/completions provider provider: ollama # any OpenAI-compatible /v1/chat/completions provider
name: qwen3.8 name: ministral-3
timeout: 90 timeout: 90
max_tokens: 200 max_tokens: 200
``` ```
@ -294,7 +294,7 @@ Three independent settings drive ingest, retrieval, and QA:
|---|---|---| |---|---|---|
| `processing.pictures` | Generate and/or describe pictures at ingest? | `none` / `description` / `image` (default) | | `processing.pictures` | Generate and/or describe pictures at ingest? | `none` / `description` / `image` (default) |
| `embeddings.model.multimodal` | Can the embedder index image content? | `false` (default, text-only) / `true` (supported on `vllm`, `voyageai`, `cohere`) | | `embeddings.model.multimodal` | Can the embedder index image content? | `false` (default, text-only) / `true` (supported on `vllm`, `voyageai`, `cohere`) |
| `qa.model.vision` | Can the QA model interpret images? | `false` / `true` (default) | | `qa.model.vision` | Can the QA model interpret images? | `false` (default) / `true` |
The Embedder column below is driven by `embeddings.model.multimodal`, not the provider name — a vision-capable model under a text-only configuration still indexes no images, and an image-only document then produces zero chunks. See [Multimodal embedders](providers.md#multimodal-embedders). The Embedder column below is driven by `embeddings.model.multimodal`, not the provider name — a vision-capable model under a text-only configuration still indexes no images, and an image-only document then produces zero chunks. See [Multimodal embedders](providers.md#multimodal-embedders).
@ -313,7 +313,7 @@ The Embedder column below is driven by `embeddings.model.multimodal`, not the pr
- `qa.model.vision: false` — text chunks only (descriptions, when present, answer figure questions in prose). - `qa.model.vision: false` — text chunks only (descriptions, when present, answer figure questions in prose).
- `qa.model.vision: true` — text chunks + raw picture bytes via `BinaryContent`. The model reads figures directly. Requires `pictures != none` so the bytes exist. - `qa.model.vision: true` — text chunks + raw picture bytes via `BinaryContent`. The model reads figures directly. Requires `pictures != none` so the bytes exist.
`qa.model.vision` is independent of ingestion. Flipping it never requires reingesting. It declares what the model can read: the default `qwen3.8` is vision-capable, so the default is `true`. Set it `false` when pointing `qa.model` at a text-only model, where `true` causes silent acceptance and confabulation on Ollama and a 400 on OpenAI. `qa.model.vision` is independent of ingestion. Flipping it never requires reingesting. Setting `vision: true` against a text-only model causes silent acceptance and confabulation on Ollama and a 400 on OpenAI. Default `false` is the safe choice.
**Recommended combinations:** **Recommended combinations:**
@ -368,7 +368,7 @@ processing:
auto_title: true auto_title: true
title_model: title_model:
provider: ollama provider: ollama
name: qwen3.8 name: gpt-oss
enable_thinking: false enable_thinking: false
``` ```

View file

@ -15,7 +15,7 @@ Configure model behavior for the `qa` and `analysis` capabilities. These setting
qa: qa:
model: model:
provider: ollama provider: ollama
name: qwen3.8 name: gpt-oss
temperature: 0.3 temperature: 0.3
max_tokens: 500 max_tokens: 500
``` ```
@ -79,7 +79,7 @@ See the [Pydantic AI thinking documentation](https://ai.pydantic.dev/thinking/)
- **Google**: Gemini models with thinking support - **Google**: Gemini models with thinking support
- **Groq**: Models with reasoning capabilities - **Groq**: Models with reasoning capabilities
- **Bedrock**: Claude, Qwen, and `gpt-oss` models. Bedrock Converse does not serve the proprietary OpenAI models, so configuring one raises an error. Reach those through `provider: bedrock-mantle`. - **Bedrock**: Claude, Qwen, and `gpt-oss` models. Bedrock Converse does not serve the proprietary OpenAI models, so configuring one raises an error. Reach those through `provider: bedrock-mantle`.
- **Ollama**: Any model with a thinking capability. `enable_thinking` maps to `reasoning_effort`: `false` sends `none` (`low` for `gpt-oss`, whose template has no `none` level), `true` sends `high`. - **Ollama**: Models supporting reasoning (gpt-oss, etc.)
- **vLLM**: Models with a pydantic-ai reasoning profile (gpt-oss). Qwen3, Gemma, and similar templates ignore the OpenAI `reasoning_effort` that `enable_thinking` translates to — use [`extra_body`](#raw-provider-pass-through) to drive them. - **vLLM**: Models with a pydantic-ai reasoning profile (gpt-oss). Qwen3, Gemma, and similar templates ignore the OpenAI `reasoning_effort` that `enable_thinking` translates to — use [`extra_body`](#raw-provider-pass-through) to drive them.
- **LM Studio**: Models supporting reasoning (gpt-oss, etc.) - **LM Studio**: Models supporting reasoning (gpt-oss, etc.)
@ -311,7 +311,7 @@ Configure which LLM provider to use for question answering. Any provider and mod
qa: qa:
model: model:
provider: ollama provider: ollama
name: qwen3.8 name: gpt-oss
``` ```
The Ollama base URL can be configured via the `OLLAMA_BASE_URL` environment variable, config file, or defaults to `http://localhost:11434`: The Ollama base URL can be configured via the `OLLAMA_BASE_URL` environment variable, config file, or defaults to `http://localhost:11434`:

View file

@ -20,16 +20,16 @@ Context expansion is automatic and section-aware. For structured documents (with
## Question Answering Configuration ## Question Answering Configuration
Configure the RAG capability (used by `client.ask` and `haiku-rag ask`): Configure the RAG capability (used by `client.ask`, `haiku-rag ask`, and the MCP `ask_question` tool):
```yaml ```yaml
qa: qa:
model: model:
provider: ollama provider: ollama
name: qwen3.8 name: gpt-oss
enable_thinking: true enable_thinking: true
temperature: 0.3 # Default: 0.3 temperature: 0.3 # Default: 0.3
vision: true # Set false for text-only models vision: false # Set true for vision-capable models
max_searches: 5 # Maximum search units per question max_searches: 5 # Maximum search units per question
``` ```
@ -50,13 +50,13 @@ analysis:
provider: anthropic provider: anthropic
name: claude-sonnet-4-20250514 name: claude-sonnet-4-20250514
temperature: 0.0 # Default: 0.0 (deterministic for code generation) temperature: 0.0 # Default: 0.0 (deterministic for code generation)
code_timeout: 60.0 # Per call: compute stops, no read or search starts past it code_timeout: 60.0 # Max seconds a call may spend reading documents
max_output_chars: 50000 # Truncate output after this many chars max_output_chars: 50000 # Truncate output after this many chars
max_executions: 15 # Max execute_code calls per question max_executions: 15 # Max execute_code calls per question
``` ```
- **model**: LLM configuration (see [Providers](providers.md#model-settings)). When unset, falls back to `qa.model`. - **model**: LLM configuration (see [Providers](providers.md#model-settings)). When unset, falls back to `qa.model`.
- **code_timeout**: Seconds a single `execute_code` call has (default: 60). Past it the sandbox starts no further host call, a document read or an in-code `search()` / `list_documents()`; one already running finishes. Code that computes without host calls is killed by the worker watchdog at the same limit. `code_timeout * max_executions` is the cumulative ceiling across all calls in one question. - **code_timeout**: Seconds a single `execute_code` call may spend reading documents (default: 60). The sandbox refuses further reads past this point. Code that computes without reading is killed by the worker watchdog at the same limit. `code_timeout * max_executions` is the cumulative ceiling across all calls in one question.
- **max_output_chars**: Truncate code output after this many characters (default: 50000) - **max_output_chars**: Truncate code output after this many characters (default: 50000)
- **max_executions**: Maximum `execute_code` calls per question before the capability is told to answer from what it has (default: 15) - **max_executions**: Maximum `execute_code` calls per question before the capability is told to answer from what it has (default: 15)

View file

@ -196,7 +196,7 @@ writing process per database URI, any number of read-only consumers.
The recommended layout for production is "different buckets, same account, separate IAM roles per process": The recommended layout for production is "different buckets, same account, separate IAM roles per process":
- **Ingestion process** — IAM role with `s3:Get/List` on the documents bucket and `s3:Get/Put/Delete` on the LanceDB bucket. Runs `haiku-ingester serve` (with `ingester.sources[type=s3]` pointing at the documents bucket). Exactly one such process per LanceDB URI. - **Ingestion process** — IAM role with `s3:Get/List` on the documents bucket and `s3:Get/Put/Delete` on the LanceDB bucket. Runs `haiku-ingester serve` (with `ingester.sources[type=s3]` pointing at the documents bucket). Exactly one such process per LanceDB URI.
- **Consumer processes** (1..N) — IAM role with `s3:Get/List` on the LanceDB bucket only. Run `haiku-rag mcp`, the chat TUI, etc. They never see the documents bucket. - **Consumer processes** (1..N) — IAM role with `s3:Get/List` on the LanceDB bucket only. Run `haiku-rag --read-only mcp`, the chat TUI, etc. They never see the documents bucket.
Each process picks up its own credentials from the AWS default chain (env vars, IAM instance role, AWS profile), so no credentials are hard-coded in the configuration files. Each process picks up its own credentials from the AWS default chain (env vars, IAM instance role, AWS profile), so no credentials are hard-coded in the configuration files.
@ -281,9 +281,9 @@ Conversion, chunking, and title generation do not access a database and remain a
Commands use database sets as follows: Commands use database sets as follows:
- **Set-capable**: `search`, `ask`, `analyze`, `chat`, and `mcp` use the full configured set, or the single database selected by `--db-name`. - **Set-capable**: `search`, `ask`, `analyze`, and `chat` use the full configured set, or the single database selected by `--db-name`.
- **Config-only**: `settings`, `init-config`, and `download-models` do not open a database. - **Config-only**: `settings`, `init-config`, and `download-models` do not open a database.
- **Single-database**: everything else — document writes, `rebuild`, `vacuum`, `migrate`, `init`, `info`, `history`, `tag`, `doctor`, `list`, `inspect`, and `visualize` — works on one database, selected with the global `--db-name` option. - **Single-database**: everything else — document writes, `rebuild`, `vacuum`, `migrate`, `init`, `info`, `history`, `tag`, `doctor`, `list`, `inspect`, `visualize`, and `mcp` — works on one database, selected with the global `--db-name` option.
```bash ```bash
haiku-rag search "query" # every configured database haiku-rag search "query" # every configured database

View file

@ -19,71 +19,15 @@ haiku-rag mcp --host 0.0.0.0 --port 8001
# stdio transport (for Claude Desktop) # stdio transport (for Claude Desktop)
haiku-rag mcp --stdio haiku-rag mcp --stdio
# Read-only mode (excludes write tools)
haiku-rag --read-only mcp --stdio
``` ```
`--host` defaults to `127.0.0.1` (loopback only). Bind to `0.0.0.0` only `--host` defaults to `127.0.0.1` (loopback only). Bind to `0.0.0.0` only
when you want the MCP server reachable from outside the local machine — when you want the MCP server reachable from outside the local machine —
e.g. inside a Docker container with port mapping, or on a trusted LAN. e.g. inside a Docker container with port mapping, or on a trusted LAN.
The server opens the database read-only. Ingestion goes through the CLI **Read-only mode:** When `--read-only` is specified, write tools (`add_document_from_file`, `add_document_from_url`, `add_document_from_text`, `delete_document`) are not registered. Only search and query tools remain available.
(`haiku-rag add`, `add-src`, `delete`) or [`haiku-ingester`](ingester.md).
## Collections
With several databases in `lancedb.databases`, the server covers all of
them, as `haiku-rag search` does. Results and documents name theirs in
`source`. `sources` on `search_documents`, `search_documents_by_image`
and `execute_code` restricts a call to a subset; `source` on `get_document` names the database holding the
document. A name the server does not cover is an error.
`haiku-rag --db-name NAME mcp` serves one. See
[Multiple Databases](configuration/storage.md#multiple-databases).
## Claude Code
The repository ships a plugin that registers the server and a skill telling
Claude when and how to use it:
```bash
claude plugin marketplace add ggozad/haiku.rag
claude plugin install haiku-rag
```
The plugin runs `haiku-rag mcp --stdio`, so `haiku-rag` must be on the PATH
and the configuration decides the database. The skill pre-approves every tool
and is also invocable as `/haiku-rag`. To register the server without the
plugin:
```bash
claude mcp add haiku-rag -- haiku-rag mcp --stdio
```
The skill works with that registration too: copy `plugins/haiku-rag/skills/haiku-rag`
into `~/.claude/skills/` and change the tool prefix in its `allowed-tools` from
`mcp__plugin_haiku-rag_haiku-rag__` to `mcp__haiku-rag__`.
## Codex
The repository's Codex plugin registers the server and installs the same Agent
Skill:
```bash
codex plugin marketplace add ggozad/haiku.rag
codex plugin add haiku-rag@haiku-rag
```
The plugin runs `haiku-rag mcp --stdio`, so `haiku-rag` must be on the PATH.
Invoke the skill as `$haiku-rag`. Codex can also select it automatically from
its description. To register the server without the plugin:
```bash
codex mcp add haiku-rag -- haiku-rag mcp --stdio
```
The skill works with that registration too: copy
`plugins/haiku-rag/skills/haiku-rag` into `~/.agents/skills/`.
The `allowed-tools` field supplies Claude Code's tool pre-approval and may be
ignored by other Agent Skills clients. Codex configures MCP tool approvals
separately in `config.toml`.
## Claude Desktop Integration ## Claude Desktop Integration
@ -113,95 +57,63 @@ With a custom database path:
} }
``` ```
After restarting Claude Desktop, you can ask Claude to search your documents or answer questions using your knowledge base. After restarting Claude Desktop, you can ask Claude to search your documents, add new content, or answer questions using your knowledge base.
## Tools ## Available Tools
Every tool is read-only and says so in its annotations. Each parameter carries ### Document Management
a description in the tool schema, so the listing below names them without
repeating it.
| Tool | Registered | Parameters | - **`add_document_from_file`** - Add documents from local file paths
|---|---|---| - `file_path` (required): Path to the file
| `search_documents` | always | `query`, `limit`, `include_images`, `filter`, `sources` | - `metadata` (optional): Key-value metadata
| `search_documents_by_image` | multimodal embedder only | `image_base64`, `limit`, `include_images`, `filter`, `sources` | - `title` (optional): Human-readable title
| `get_document` | always | `document_id`, `source` |
| `get_document_outline` | always | `document_id`, `source` |
| `get_document_section` | always | `document_id`, `section_id`, `source` |
| `list_documents` | always | `limit`, `offset`, `filter` |
| `execute_code` | always | `code`, `filter`, `sources` |
`search_documents` runs hybrid search, vector and full-text. Its text content - **`add_document_from_url`** - Add documents from URLs
is the rendering the in-process agents read: results best first, each with its - `url` (required): URL to fetch
rank, `Document ID`, `Collection` when the server covers several, the document - `metadata` (optional): Key-value metadata
title, section headings, the matched chunk's metadata when it has any, and the - `title` (optional): Human-readable title
passage expanded to its section the way the agents get it
(`search.max_context_chars` caps it). Pictures in the results follow as
image blocks, one per distinct picture, each preceded by a line naming its
result; `include_images: false` leaves them out. Search results carry no
structured content, so every client shows the model the same text and
images. Scores are not comparable across
queries or search types, so rank is the signal. `search_documents_by_image`
embeds the query image and searches by vector similarity alone.
`get_document` returns a document whole, in reading order. For a long one, - **`add_document_from_text`** - Add documents from raw text content
`get_document_outline` returns the heading tree with page numbers and - `content` (required): Text content
`get_document_section` the text of one section, subsections included; a - `uri` (optional): URI identifier
node's `id` in the outline is the `section_id`. A document without headings - `metadata` (optional): Key-value metadata
has an empty outline. `list_documents` returns titles, URIs and metadata, - `title` (optional): Human-readable title
which is how a client learns what a filter can match.
### Code - **`get_document`** - Retrieve a document by ID
- `document_id` (required): The document ID
`execute_code` runs a Python program in the sandbox of the - **`list_documents`** - List documents with pagination and filtering
[analysis capability](capabilities/analysis.md), over the documents `filter` - `limit` (optional): Maximum number to return
and `sources` select, and returns what it printed. The program reads - `offset` (optional): Number to skip
`/documents/{document_id}/` (`metadata.json`, `content.txt`, `items.jsonl`, - `filter` (optional): SQL WHERE clause for filtering
`chunks.jsonl`, `toc.json`) and can `await search()` and
`await list_documents()`; the tool description spells out the fields and the
patterns that matter. Each call is one program: nothing carries over between
calls, and the sandbox is created and closed per call. A failing program is a
tool error carrying the interpreter's message and any output printed before
it. No model runs on the server. Claude Code moves a call still running after
about two minutes to a background task.
The interpreter is [Monty](https://github.com/pydantic/monty), a Python subset. - **`delete_document`** - Delete a document by ID
Useful modules include `json`, `re`, `math`, `pathlib`, `datetime`, - `document_id` (required): The document ID
`collections`, `itertools`, `functools` and `dataclasses`. Absent, and often
reached for: `decimal` and `statistics`. No generator functions, class
inheritance or `match` statements, and a file object cannot be iterated. Files are read-only, and
there is no network and no filesystem
beyond `/documents`. `analysis.code_timeout` is the call's budget: compute is
stopped at it, and past it no further host call starts, a file read or an
in-code search alike, though one already running finishes.
`analysis.max_output_chars` bounds the output.
### Filters ### Search
`filter` is a SQL WHERE clause over the document columns `id`, `uri`, `title`, - **`search_documents`** - Search using hybrid search (vector + full-text)
`metadata`, `created_at`, `updated_at`. `metadata` is a JSON string, so match - `query` (required): Search query
its keys with LIKE: - `limit` (optional): Maximum results (uses config default if not specified)
- `include_images` (optional, default `true`): Attach base64-encoded picture bytes to picture-labeled results
```sql - **`search_documents_by_image`** - Search using an image as the query (registered only when the configured embedder supports images)
metadata LIKE '%"author": "Smith"%' - `image_base64` (required): Base64-encoded image (PNG/JPEG bytes)
uri LIKE '%.pdf' - `limit` (optional): Maximum results
title = 'Q3 report' - `include_images` (optional, default `true`)
```
### Errors ### Question Answering
A failure is an MCP error carrying its message, never an empty result: a - **`ask_question`** - Ask questions about your documents
document or section id that matches nothing, a collection the server does not - `question` (required): The question to ask
cover, a filter the query engine rejects, invalid base64, a program that fails - `cite` (optional): Include source citations (default: false)
in `execute_code` with the error it hit, and anything unexpected with its own - `images_base64` (optional): Base64-encoded images attached to the question (requires a vision-capable QA model)
message.
### Instructions - **`analyze`** - Answer complex analytical questions via code execution
- `question` (required): The question to answer
The server publishes `instructions` describing the knowledge base: what it - `filter` (optional): SQL WHERE clause to restrict document access
holds, when to reach for it, the collection names when it covers several, and - `images_base64` (optional): Base64-encoded images attached to the question (requires a vision-capable analysis model)
`prompts.domain_preamble` when set. Claude Code and Codex show them to the - Best for aggregation, computation, and multi-document analysis
model. Claude Desktop does not, so every tool description stands on its own.
## Continuous ingestion ## Continuous ingestion

View file

@ -12,7 +12,7 @@ You also need [Ollama](https://ollama.com/) for the default embedding and answer
```bash ```bash
ollama pull qwen3-embedding:4b ollama pull qwen3-embedding:4b
ollama pull qwen3.8 ollama pull gpt-oss
``` ```
!!! note "Prefer OpenAI?" !!! note "Prefer OpenAI?"

View file

@ -49,7 +49,7 @@ datasets and judge:
```bash ```bash
evaluations run hotpotqa --target rag-capability evaluations run hotpotqa --target rag-capability
evaluations run hotpotqa --target analysis-capability --capability-model ollama:qwen3.8 evaluations run hotpotqa --target analysis-capability --capability-model ollama:gpt-oss
``` ```
`--capability-model "provider:name"` overrides the capability model independently from `--capability-model "provider:name"` overrides the capability model independently from

View file

@ -103,6 +103,7 @@ services:
"haiku-rag", "haiku-rag",
"--config", "--config",
"/app/haiku.rag.yaml", "/app/haiku.rag.yaml",
"--read-only",
"mcp", "mcp",
"--host", "--host",
"0.0.0.0", "0.0.0.0",

View file

@ -934,7 +934,7 @@ class HaikuRAGApp:
# The resolved scope: a path overrides a configured URI, and a derived # The resolved scope: a path overrides a configured URI, and a derived
# single-database configuration drops the name results and citations # single-database configuration drops the name results and citations
# carry. # carry.
server = _mcp_server_covering(self.scope, self.config) server = _mcp_server_covering(self.scope, self.config, self.read_only)
try: try:
if transport == "stdio": if transport == "stdio":
await server.run_stdio_async() await server.run_stdio_async()

View file

@ -19,7 +19,7 @@ from haiku.rag.capabilities._base import (
) )
from haiku.rag.capabilities._tools import merge_results from haiku.rag.capabilities._tools import merge_results
from haiku.rag.config.models import AppConfig from haiku.rag.config.models import AppConfig
from haiku.rag.sandbox import AnalysisContext, Sandbox, recovery_hint from haiku.rag.sandbox import AnalysisContext, Sandbox
STATE_NAMESPACE = "analysis" STATE_NAMESPACE = "analysis"
_CAPABILITY_ID = "haiku-rag-analysis" _CAPABILITY_ID = "haiku-rag-analysis"
@ -49,6 +49,21 @@ def multiple_collections_instructions() -> str:
return _multiple_collections_path.read_text().rstrip() return _multiple_collections_path.read_text().rstrip()
def _recovery_hint(stderr: str) -> str:
"""Name the workaround for sandbox limits models trip over repeatedly.
The instructions already say file objects are not iterable, and models write
``for line in open(...)`` regardless. Carrying the fix in the error gives
them something to act on for the retry.
"""
if "TextIOWrapper" in stderr and "not iterable" in stderr:
return (
"\n\nHint: file objects cannot be iterated here. Read lines with "
'.readlines() or .read().split("\\n").'
)
return ""
@dataclass @dataclass
class AnalysisCapability(RAGCapabilityBase[AnalysisState]): class AnalysisCapability(RAGCapabilityBase[AnalysisState]):
"""Deferred capability for sandboxed computation over a RAG corpus.""" """Deferred capability for sandboxed computation over a RAG corpus."""
@ -124,7 +139,7 @@ class AnalysisCapability(RAGCapabilityBase[AnalysisState]):
) )
if not result.success: if not result.success:
raise ToolFailed( raise ToolFailed(
f"{result.stderr}{recovery_hint(result.stderr)}" f"{result.stderr}{_recovery_hint(result.stderr)}"
f"\n\nOutput: {result.stdout}" f"\n\nOutput: {result.stdout}"
) )
return result.stdout or "No output." return result.stdout or "No output."

View file

@ -13,11 +13,11 @@ You can mix the two. The rule: always call `analysis_cite` before answering —
Execute Python code in a sandboxed interpreter. Variables persist between calls — you can build state incrementally. Use `print()` to output results. Execute Python code in a sandboxed interpreter. Variables persist between calls — you can build state incrementally. Use `print()` to output results.
Inside the code, these functions are available (use `await`): Inside the code, these functions are available (use `await`):
- `await search(query, limit=10)` → list of dicts with keys: chunk_id, content, document_id, document_title, document_uri, score, page_numbers, headings, doc_item_refs, labels, picture_refs (subset of doc_item_refs labeled `picture`), chunk_meta (the matched chunk's stored metadata, custom keys included) - `await search(query, limit=10)` → list of dicts with keys: chunk_id, content, document_id, document_title, document_uri, score, page_numbers, headings, doc_item_refs, labels, picture_refs (subset of doc_item_refs labeled `picture`)
- `await list_documents()` → list of dicts with keys: id, title, uri, created_at, metadata - `await list_documents()` → list of dicts with keys: id, title, uri, created_at
Useful modules include `json`, `re`, `math`, `pathlib`, `datetime`, `collections`, `itertools`, `functools` and `dataclasses`. `decimal` and `statistics` do not exist. Available modules: `json`, `re`, `math`, `pathlib`
Not supported: class inheritance and metaclasses, generators/yield, match statements, iterating a file object (`for line in f`) Not supported: class inheritance and metaclasses, generators/yield, match statements, decorators, `collections`, iterating a file object (`for line in f`)
### analysis_search ### analysis_search
Search the knowledge base directly (outside code execution). Each result has a `Type:` (paragraph, table, code, list_item, picture). When the Type is `picture`, the corresponding figure may also be attached to the tool response as an image alongside the text — use it directly to answer questions about figures, diagrams, charts, screenshots. Search the knowledge base directly (outside code execution). Each result has a `Type:` (paragraph, table, code, list_item, picture). When the Type is `picture`, the corresponding figure may also be attached to the tool response as an image alongside the text — use it directly to answer questions about figures, diagrams, charts, screenshots.
@ -39,17 +39,16 @@ All documents are mounted as a virtual filesystem at `/documents/`:
``` ```
/documents/{document_id}/ /documents/{document_id}/
metadata.json # {"id", "title", "uri", "created_at", "metadata"} metadata.json # {"id", "title", "uri", "created_at"}
content.txt # Full document text content.txt # Full document text
items.jsonl # Structured items (one JSON object per line) items.jsonl # Structured items (one JSON object per line)
chunks.jsonl # Chunks in order with their metadata (one JSON object per line)
toc.json # Section tree derived from heading_level toc.json # Section tree derived from heading_level
``` ```
`{document_id}` is an internal identifier, not the user-facing `uri` (filename, URL, etc.). When you only know a document by its URI or title, use `await list_documents()` to enumerate ids and match against `uri` / `title` — that's a single call to the host. Iterating `/documents/` and reading every `metadata.json` works too but is much slower on portal-scale corpora. `{document_id}` is an internal identifier, not the user-facing `uri` (filename, URL, etc.). When you only know a document by its URI or title, use `await list_documents()` to enumerate ids and match against `uri` / `title` — that's a single call to the host. Iterating `/documents/` and reading every `metadata.json` works too but is much slower on portal-scale corpora.
### Reading files ### Reading files
Read with `Path.read_text()` or `open()` (including `with` blocks); file objects support `.read()`, `.readline()`, and `.readlines()`. A file object cannot be iterated, so read line-wise with `.readlines()` or `.read().split("\n")` instead of `for line in f`. Files are read-only; writing raises `PermissionError`. There is no network. A call has a time limit, named in the error when it is hit, and output past a size is cut with an `... (output truncated)` marker. Read with `Path.read_text()` or `open()` (including `with` blocks); file objects support `.read()`, `.readline()`, and `.readlines()`. A file object cannot be iterated, so read line-wise with `.readlines()` or `.read().split("\n")` instead of `for line in f`. Files are read-only; writing raises `PermissionError`.
```python ```python
from pathlib import Path from pathlib import Path
@ -71,7 +70,7 @@ for line in Path(f'/documents/{doc_id}/items.jsonl').read_text().strip().split("
``` ```
### metadata.json ### metadata.json
Document metadata: `id`, `title`, `uri`, `created_at`, and `metadata`, the keys stored with the document. Document metadata: `id`, `title`, `uri`, `created_at`.
### content.txt ### content.txt
Full text content. Use for regex or keyword search across a whole document. Full text content. Use for regex or keyword search across a whole document.
@ -87,9 +86,6 @@ Each row carries:
- `chunk_ids`: chunks that contain this item — pass to `analysis_cite()` to ground an answer that read this item directly - `chunk_ids`: chunks that contain this item — pass to `analysis_cite()` to ground an answer that read this item directly
- `heading_level`: H-level for `section_header` rows; `0` on non-header rows - `heading_level`: H-level for `section_header` rows; `0` on non-header rows
### chunks.jsonl
The document's chunks in order, one JSON object per line: `chunk_id` and `metadata`, the chunk's stored metadata (`doc_item_refs`, `headings`, `labels`, `page_numbers`, and any custom keys such as paragraph or footnote numbers). To read by chunk metadata, keep the matching rows and take the `items.jsonl` rows whose `chunk_ids` name them.
### toc.json ### toc.json
Section tree derived from `heading_level`: `{"doc_id", "title", "tree": [...]}` where each node has `{self_ref, level, title, page_numbers, item_range: [start, end_exclusive], chunk_ids, children}`. `item_range` is a line slice into `items.jsonl``items[start:end]`. `chunk_ids` aggregates the citable chunks across all items in the section — pass directly to `analysis_cite()` to ground a section-scoped answer without a corpus-wide `search()` call. `tree: []` for docs with no headers. Section tree derived from `heading_level`: `{"doc_id", "title", "tree": [...]}` where each node has `{self_ref, level, title, page_numbers, item_range: [start, end_exclusive], chunk_ids, children}`. `item_range` is a line slice into `items.jsonl``items[start:end]`. `chunk_ids` aggregates the citable chunks across all items in the section — pass directly to `analysis_cite()` to ground a section-scoped answer without a corpus-wide `search()` call. `tree: []` for docs with no headers.
@ -116,6 +112,6 @@ You MUST call `analysis_cite` before producing your final answer, every time, wi
- Use `print()` to output results — the output is your only feedback - Use `print()` to output results — the output is your only feedback
- When you write code, execute it — don't describe what code would do. But not every question needs code; simple lookups are best answered by `analysis_search → analysis_cite`. - When you write code, execute it — don't describe what code would do. But not every question needs code; simple lookups are best answered by `analysis_search → analysis_cite`.
- Use `await` for all async functions inside `analysis_execute_code` (`search`, `list_documents`) - Use `await` for all async functions inside `analysis_execute_code` (`search`, `list_documents`)
- Read files with `Path.read_text()` or `open()`/`with`. For lines use `.readlines()` or `.read().split("\n")`, never `for line in f`. - Read files with `Path.read_text()` or `open()`/`with`. For lines use `.readlines()` or `.read().split("\n")`, never `for line in f`. The `collections` module is unavailable.
- Do NOT include chunk IDs or UUIDs in your answer text — your answer should read naturally. Use the `analysis_cite` tool separately to register citations. `cite{...}` markdown-style inline references do nothing; only an actual `analysis_cite` tool call registers a citation. - Do NOT include chunk IDs or UUIDs in your answer text — your answer should read naturally. Use the `analysis_cite` tool separately to register citations. `cite{...}` markdown-style inline references do nothing; only an actual `analysis_cite` tool call registers a citation.
- **Before you write your final answer, invoke the `analysis_cite` tool with the supporting chunk_ids, or with an empty list if there are none.** This is the last tool call before answering, every time. - **Before you write your final answer, invoke the `analysis_cite` tool with the supporting chunk_ids, or with an empty list if there are none.** This is the last tool call before answering, every time.

View file

@ -886,7 +886,7 @@ def mcp(
), ),
) -> None: ) -> None:
"""Run the MCP server.""" """Run the MCP server."""
app = create_app(db, covers_set=True) app = create_app(db)
transport = "stdio" if stdio else None transport = "stdio" if stdio else None

View file

@ -40,7 +40,7 @@ class ModelConfig(ConfigModel):
""" """
provider: str = "ollama" provider: str = "ollama"
name: str = "qwen3.8" name: str = "gpt-oss"
base_url: str | None = None base_url: str | None = None
api_key: str | None = None api_key: str | None = None
@ -165,10 +165,9 @@ class QAConfig(ConfigModel):
model: ModelConfig = Field( model: ModelConfig = Field(
default_factory=lambda: ModelConfig( default_factory=lambda: ModelConfig(
provider="ollama", provider="ollama",
name="qwen3.8", name="gpt-oss",
enable_thinking=True, enable_thinking=True,
temperature=0.3, temperature=0.3,
vision=True,
) )
) )
max_searches: int = Field(default=5, ge=0) max_searches: int = Field(default=5, ge=0)
@ -217,8 +216,7 @@ class PictureDescriptionConfig(ConfigModel):
model: ModelConfig = Field( model: ModelConfig = Field(
default_factory=lambda: ModelConfig( default_factory=lambda: ModelConfig(
provider="ollama", provider="ollama",
name="qwen3.8", name="ministral-3",
enable_thinking=False,
temperature=0.0, temperature=0.0,
) )
) )
@ -305,7 +303,7 @@ class ProcessingConfig(ConfigModel):
title_model: ModelConfig = Field( title_model: ModelConfig = Field(
default_factory=lambda: ModelConfig( default_factory=lambda: ModelConfig(
provider="ollama", provider="ollama",
name="qwen3.8", name="gpt-oss",
enable_thinking=False, enable_thinking=False,
temperature=0.3, temperature=0.3,
max_tokens=100, max_tokens=100,

View file

@ -32,8 +32,6 @@ In both cases:
- Results without doc_item_refs pass through unexpanded - Results without doc_item_refs pass through unexpanded
""" """
from typing import Any
from haiku.rag.store.models.chunk import SearchResult from haiku.rag.store.models.chunk import SearchResult
from haiku.rag.store.models.document_item import DocumentItem from haiku.rag.store.models.document_item import DocumentItem
@ -490,77 +488,3 @@ def expand_with_items(
final_results.append(built) final_results.append(built)
return final_results + passthrough return final_results + passthrough
def build_toc(
items: list["DocumentItem"],
chunk_index: dict[str, list[str]],
) -> list[dict[str, Any]]:
"""Build a nested section tree from items in position order.
Each ``section_header`` with ``heading_level > 0`` becomes a node. Nesting
follows the explicit levels: a header pops the stack until the top is at
a strictly shallower level, then becomes a child of that top (or a root).
``item_range = [start, end_exclusive]`` indexes the position-ordered item
list, which is the line numbering of the sandbox's ``items.jsonl``: ``start``
is the header's index and ``end_exclusive`` the index of the next header
whose level is the same or shallower (the next sibling or ancestor that
ends this section), or the item count if no such header exists. Indices,
not positions: positions may have gaps.
``chunk_ids`` aggregates the chunks covered by all items in the section's
``item_range`` (deduped, order preserved). Pass directly to ``cite()`` to
ground a section-scoped answer without a corpus-wide ``search()`` call.
Items without a section_header label (or with ``heading_level == 0``) are
skipped. When all section_headers carry the same level the output is a
flat sibling list (see docling-project/docling#2121 for an upstream case
where every PDF section_header is emitted at level=1).
"""
# Defensive: every consumer is supposed to pass items in position order,
# but the end_exclusive lookahead below silently miscomputes section
# boundaries if it's not — better to sort once than trust the caller.
items = sorted(items, key=lambda i: i.position)
header_indices = [
idx
for idx, i in enumerate(items)
if i.label == "section_header" and i.heading_level > 0
]
if not header_indices:
return []
ends: list[int] = []
for n, idx in enumerate(header_indices):
end = len(items)
for later in header_indices[n + 1 :]:
if items[later].heading_level <= items[idx].heading_level:
end = later
break
ends.append(end)
roots: list[dict[str, Any]] = []
stack: list[tuple[int, dict[str, Any]]] = []
for idx, end in zip(header_indices, ends, strict=True):
h = items[idx]
seen: set[str] = set()
chunk_ids: list[str] = []
for item in items[idx:end]:
for cid in chunk_index.get(item.self_ref, []):
if cid not in seen:
seen.add(cid)
chunk_ids.append(cid)
node: dict[str, Any] = {
"self_ref": h.self_ref,
"level": h.heading_level,
"title": h.text,
"page_numbers": list(h.page_numbers),
"item_range": [idx, end],
"chunk_ids": chunk_ids,
"children": [],
}
while stack and stack[-1][0] >= h.heading_level:
stack.pop()
(stack[-1][1]["children"] if stack else roots).append(node)
stack.append((h.heading_level, node))
return roots

View file

@ -42,20 +42,6 @@ def vlm_api_headers(model: "ModelConfig") -> dict[str, str]:
return {} return {}
def vlm_api_params(model: "ModelConfig", max_tokens: int) -> dict[str, object]:
"""Request body fields docling posts alongside the picture."""
from haiku.rag.utils import reasoning_effort
params: dict[str, object] = {
"model": model.name,
"max_completion_tokens": max_tokens,
}
effort = reasoning_effort(model)
if effort is not None:
params["reasoning_effort"] = effort
return params
class DocumentConverter(ABC): class DocumentConverter(ABC):
"""Abstract base class for document converters. """Abstract base class for document converters.

View file

@ -12,7 +12,6 @@ from haiku.rag.config import AppConfig
from haiku.rag.converters.base import ( from haiku.rag.converters.base import (
DocumentConverter, DocumentConverter,
vlm_api_headers, vlm_api_headers,
vlm_api_params,
vlm_api_url, vlm_api_url,
) )
from haiku.rag.converters.text_utils import TextFileHandler, docling_safe_name from haiku.rag.converters.text_utils import TextFileHandler, docling_safe_name
@ -154,7 +153,10 @@ class DoclingLocalConverter(DocumentConverter):
pipeline_options.picture_description_options = PictureDescriptionApiOptions( pipeline_options.picture_description_options = PictureDescriptionApiOptions(
url=AnyUrl(vlm_api_url(self.config, pic_desc.model)), url=AnyUrl(vlm_api_url(self.config, pic_desc.model)),
headers=vlm_api_headers(pic_desc.model), headers=vlm_api_headers(pic_desc.model),
params=vlm_api_params(pic_desc.model, pic_desc.max_tokens), params=dict(
model=pic_desc.model.name,
max_completion_tokens=pic_desc.max_tokens,
),
prompt=self.config.prompts.picture_description, prompt=self.config.prompts.picture_description,
timeout=pic_desc.timeout, timeout=pic_desc.timeout,
) )

View file

@ -9,7 +9,6 @@ from haiku.rag.config import AppConfig
from haiku.rag.converters.base import ( from haiku.rag.converters.base import (
DocumentConverter, DocumentConverter,
vlm_api_headers, vlm_api_headers,
vlm_api_params,
vlm_api_url, vlm_api_url,
) )
from haiku.rag.converters.text_utils import TextFileHandler, docling_safe_name from haiku.rag.converters.text_utils import TextFileHandler, docling_safe_name
@ -111,7 +110,10 @@ class DoclingServeConverter(DocumentConverter):
picture_description_api = { picture_description_api = {
"url": vlm_api_url(self.config, pic_desc.model), "url": vlm_api_url(self.config, pic_desc.model),
"headers": vlm_api_headers(pic_desc.model), "headers": vlm_api_headers(pic_desc.model),
"params": vlm_api_params(pic_desc.model, pic_desc.max_tokens), "params": {
"model": pic_desc.model.name,
"max_completion_tokens": pic_desc.max_tokens,
},
"prompt": prompt, "prompt": prompt,
"timeout": pic_desc.timeout, "timeout": pic_desc.timeout,
} }

View file

@ -1,168 +1,66 @@
import asyncio import asyncio
import base64
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import AsyncExitStack, asynccontextmanager from contextlib import AsyncExitStack, asynccontextmanager
from importlib import metadata
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Annotated from typing import TYPE_CHECKING, Any
from fastmcp import FastMCP from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.tools import ToolResult
from mcp.types import ContentBlock, ImageContent, TextContent, ToolAnnotations
from pydantic import Field
from haiku.rag.client import HaikuRAG from haiku.rag.client import HaikuRAG
from haiku.rag.config import AppConfig, get_config from haiku.rag.config import AppConfig, get_config
from haiku.rag.context import build_toc
from haiku.rag.sandbox import AnalysisContext, Sandbox, recovery_hint
from haiku.rag.store.models import Document, SearchResult from haiku.rag.store.models import Document, SearchResult
from haiku.rag.store.schema import DocumentMetaRecord from haiku.rag.tools.document import DocumentInfo
from haiku.rag.tools.document import DocumentInfo, DocumentSection, OutlineNode from haiku.rag.utils import format_citations
from haiku.rag.tools.search import collect_pictures
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Any
from haiku.rag.client.scope import DatabaseScope from haiku.rag.client.scope import DatabaseScope
from haiku.rag.store.models.document_item import DocumentItem
_FILTER_COLUMNS = ", ".join(DocumentMetaRecord.model_fields)
Filter = Annotated[
str | None,
Field(
description=(
f"SQL WHERE clause over the document columns {_FILTER_COLUMNS}, "
"restricting which documents are used. `metadata` is a JSON string, "
'so match its keys with LIKE: metadata LIKE \'%"author": "Smith"%\'. '
"Also uri LIKE '%.pdf', title = 'Q3 report'."
)
),
]
Sources = Annotated[
list[str] | None,
Field(description="Collections to use, by name. All of them by default."),
]
def _read_only(title: str) -> ToolAnnotations: def _decode_images(images_base64: list[str] | None) -> list[bytes] | None:
return ToolAnnotations(title=title, read_only_hint=True, open_world_hint=False) if not images_base64:
def _decode_image(image_base64: str) -> bytes:
try:
return base64.b64decode(image_base64, validate=True)
except ValueError as e:
# binascii.Error for characters outside the alphabet or bad padding,
# ValueError itself for non-ASCII input.
raise ToolError("Invalid base64 image") from e
def _instructions(scope: "DatabaseScope", config: AppConfig) -> str:
"""What the server is for, naming no tools: the client has every tool's
description from the listing."""
lines = [
"haiku-rag is the user's knowledge base: documents they ingested, "
"searchable by meaning and keyword, readable whole or section by section, "
"or computed across with code."
]
lines.append(
"Use it whenever a question could be answered from those documents, "
"before answering from memory, and say when it had nothing relevant."
)
if scope.covers_multiple:
lines.append(
f"It holds several collections: {', '.join(scope.names)}. Results "
"name theirs in `source`; pass `sources` to use a subset."
)
if config.prompts.domain_preamble:
lines.append(config.prompts.domain_preamble)
return "\n".join(lines)
def _search_result(results: list[SearchResult], covers_multiple: bool) -> ToolResult:
"""Results as the in-process agents read them, plus the matched chunk's
metadata, then each distinct picture as an image block labelled with its
result. No structured content: a client given both shows the model the
JSON and drops the text, or shows both."""
total = len(results)
text = "\n\n".join(
result.format_for_agent(
rank=rank,
total=total,
include_collection=covers_multiple,
include_document_id=True,
include_chunk_meta=True,
)
for rank, result in enumerate(results, 1)
)
content: list[ContentBlock] = [
TextContent(type="text", text=text or "No results found.")
]
pictures, _ = collect_pictures(results)
for source, chunk_id, self_ref, picture in pictures:
collection = f" in {source}" if covers_multiple and source else ""
content.append(
TextContent(
type="text",
text=f"Picture {self_ref} of search result [{chunk_id}]{collection}",
)
)
content.append(
ImageContent(
type="image",
data=base64.b64encode(picture.data).decode("ascii"),
mime_type="image/png",
)
)
return ToolResult(content=content)
def _node(toc: "dict[str, Any]") -> OutlineNode:
return OutlineNode(
id=toc["self_ref"],
title=toc["title"],
level=toc["level"],
page_numbers=toc["page_numbers"],
children=[_node(child) for child in toc["children"]],
)
def _find(toc: list["dict[str, Any]"], section_id: str) -> "dict[str, Any] | None":
for node in toc:
if node["self_ref"] == section_id:
return node
found = _find(node["children"], section_id)
if found is not None:
return found
return None return None
import base64
return [base64.b64decode(b64, validate=True) for b64 in images_base64]
def create_mcp_server( def create_mcp_server(
db_path: Path | None = None, config: AppConfig | None = None db_path: Path | None = None,
config: AppConfig | None = None,
read_only: bool = False,
) -> FastMCP: ) -> FastMCP:
"""Create an MCP server over the databases the configuration places. """Create an MCP server over one database.
Args: Args:
db_path: Path to the database file, where `config` places none; or db_path: Path to the database file, where `config` places none; or
None to serve the databases the configuration places. Beside None to serve the database the configuration places. Beside
`lancedb.databases` a path raises `AmbiguousDatabaseError`. `lancedb.databases` a path raises `AmbiguousDatabaseError`.
config: Configuration to use. config: Configuration to use.
read_only: If True, write tools (add_document_*, delete_document) are not registered.
""" """
from haiku.rag.client.scope import DatabaseScope from haiku.rag.client.scope import DatabaseScope
config = config if config is not None else get_config() config = config if config is not None else get_config()
return _covering(DatabaseScope.resolve(config, database_path=db_path), config) return _covering(
DatabaseScope.resolve(config, database_path=db_path), config, read_only
)
def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP: def _covering(scope: "DatabaseScope", config: AppConfig, read_only: bool) -> FastMCP:
"""An MCP server over databases someone already resolved. """An MCP server over databases someone already resolved.
Internal, as ``HaikuRAG._covering`` is: the public factory takes a path and Internal, as ``HaikuRAG._covering`` is: the public factory takes a path and
resolves it, which is its own job. A caller that resolved already passes the resolves it, which is its own job. A caller that resolved already passes the
scope, so the configured name survives, which results carry as ``source``. scope, so the configured name survives, which results and citations carry as
``source``.
""" """
from haiku.rag.store.exceptions import AmbiguousDatabaseError
if scope.covers_multiple:
raise AmbiguousDatabaseError(
"an MCP server serves one database, and this scope covers "
f"{', '.join(scope.names)}; name the one to serve"
)
client: HaikuRAG | None = None client: HaikuRAG | None = None
stack = AsyncExitStack() stack = AsyncExitStack()
client_lock = asyncio.Lock() client_lock = asyncio.Lock()
@ -178,7 +76,7 @@ def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
async with client_lock: async with client_lock:
if client is None: if client is None:
client = await stack.enter_async_context( client = await stack.enter_async_context(
HaikuRAG._covering(scope, config, read_only=True) HaikuRAG._covering(scope, config, read_only=read_only)
) )
return client return client
@ -197,53 +95,90 @@ def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
finally: finally:
client = None client = None
# Explicit: the setting is also read from the environment, and the contract mcp = FastMCP("haiku-rag", lifespan=lifespan)
# is that every failure reaches the client with its message.
mcp = FastMCP(
"haiku-rag",
instructions=_instructions(scope, config),
version=metadata.version("haiku.rag-slim"),
lifespan=lifespan,
mask_error_details=False,
)
@mcp.tool(annotations=_read_only("Search documents")) # Write tools - only registered when not in read-only mode
async def search_documents( if not read_only:
query: str,
limit: int | None = None,
include_images: bool = True,
filter: Filter = None,
sources: Sources = None,
) -> ToolResult:
"""Search the knowledge base by meaning and keyword.
Use this first for any question the documents might answer; it needs @mcp.tool()
no model and is the cheapest call. Results come best first, each with async def add_document_from_file(
its rank, `Document ID`, `Collection` when the server covers several, file_path: str,
the document title, section headings, the matched chunk's metadata metadata: dict[str, Any] | None = None,
when it has any, and the matching passage expanded to its section; title: str | None = None,
pass the id and collection to the document tools. Pictures in the ) -> str | None:
results follow as images, each labelled with its result. Ranks, not scores, """Add a document to the RAG system from a file path."""
are the signal: scores are not comparable across queries. If nothing try:
relevant comes back, rephrase once or narrow with `filter` before
concluding the material is absent.
Args:
query: What to look for, in natural language or keywords.
limit: How many results to return; the server's configured default
when omitted.
include_images: Return the pictures in the results as images.
False for a smaller response.
"""
rag = await _client() rag = await _client()
results = await rag.search( result = await rag.create_document_from_source(
query, Path(file_path), title=title, metadata=metadata or {}
limit=limit,
filter=filter,
include_images=include_images,
sources=sources,
) )
return _search_result(await rag.expand_context(results), rag.covers_multiple) # Handle both single document and list of documents (directories)
if isinstance(result, list):
return result[0].id if result else None
return result.id
except Exception:
return None
@mcp.tool()
async def add_document_from_url(
url: str, metadata: dict[str, Any] | None = None, title: str | None = None
) -> str | None:
"""Add a document to the RAG system from a URL."""
try:
rag = await _client()
result = await rag.create_document_from_source(
url, title=title, metadata=metadata or {}
)
# Handle both single document and list of documents
if isinstance(result, list):
return result[0].id if result else None
return result.id
except Exception:
return None
@mcp.tool()
async def add_document_from_text(
content: str,
uri: str | None = None,
metadata: dict[str, Any] | None = None,
title: str | None = None,
) -> str | None:
"""Add a document to the RAG system from text content."""
try:
rag = await _client()
document = await rag.create_document(
content, uri, title=title, metadata=metadata or {}
)
return document.id
except Exception:
return None
@mcp.tool()
async def delete_document(document_id: str) -> bool:
"""Delete a document by its ID."""
try:
rag = await _client()
return await rag.delete_document(document_id)
except Exception:
return False
# Read tools - always registered
@mcp.tool()
async def search_documents(
query: str, limit: int | None = None, include_images: bool = True
) -> list[SearchResult]:
"""Search the RAG system for documents using hybrid search (vector similarity + full-text search).
When include_images is True (default) and a picture-labeled chunk is
in the result set, ``SearchResult.image_data`` carries base64-encoded
PNG bytes keyed by self_ref. Set to False to omit the bytes from the
response (smaller JSON payload for plain-text consumers).
"""
try:
rag = await _client()
return await rag.search(query, limit=limit, include_images=include_images)
except Exception:
return []
# Image-as-query tool, only registered when the configured embedder # Image-as-query tool, only registered when the configured embedder
# supports image embeddings. Probed at server-build time when no Store is # supports image embeddings. Probed at server-build time when no Store is
@ -253,208 +188,123 @@ def _covering(scope: "DatabaseScope", config: AppConfig) -> FastMCP:
if get_embedder(config).supports_images: if get_embedder(config).supports_images:
@mcp.tool(annotations=_read_only("Search documents by image")) @mcp.tool()
async def search_documents_by_image( async def search_documents_by_image(
image_base64: str, image_base64: str,
limit: int | None = None, limit: int | None = None,
include_images: bool = True, include_images: bool = True,
filter: Filter = None, ) -> list[SearchResult]:
sources: Sources = None, """Search the RAG system using an image as the query.
) -> ToolResult:
"""Search the knowledge base with an image as the query.
Use this when the question is about a picture rather than words. ``image_base64`` is a base64-encoded image (PNG/JPEG bytes). The
The image is embedded and matched against document text and image is embedded via the configured multimodal embedder and the
figures by vector similarity alone. Results have the shape of chunks table is searched vector-only. ``include_images`` controls
`search_documents` results. whether picture bytes are attached to picture-labeled results.
Args:
image_base64: The query image, PNG or JPEG bytes as base64.
limit: How many results to return; the server's configured
default when omitted.
include_images: Return the pictures in the results as images.
False for a smaller response.
""" """
raw = _decode_image(image_base64) import base64
try:
raw = base64.b64decode(image_base64)
except Exception:
return []
try:
rag = await _client() rag = await _client()
results = await rag.search( return await rag.search(raw, limit=limit, include_images=include_images)
raw, except Exception:
limit=limit, return []
filter=filter,
include_images=include_images,
sources=sources,
)
return _search_result(
await rag.expand_context(results), rag.covers_multiple
)
@mcp.tool(annotations=_read_only("Get document")) @mcp.tool()
async def get_document(document_id: str, source: str | None = None) -> Document: async def get_document(document_id: str) -> Document | None:
"""Read one document whole, in reading order. """Get a document by its ID."""
try:
Use this after a search when a passage is not enough. Returns the
document's content, title, uri and metadata. Ids come from search
results and `list_documents`.
Args:
document_id: The document's id.
source: The collection holding it. Without one every collection
is asked.
"""
rag = await _client() rag = await _client()
document = await rag.get_document_by_id(document_id, source) return await rag.get_document_by_id(document_id)
if document is None: except Exception:
raise ToolError(f"No document with id {document_id!r}") return None
return document
async def _items_of(document_id: str, source: str | None) -> list["DocumentItem"]: @mcp.tool()
"""A document's items in reading order, from the database holding it."""
rag = await _client()
document = await rag.get_document_by_id(document_id, source)
if document is None:
raise ToolError(f"No document with id {document_id!r}")
owner = await rag.reader_for(source or document.source)
assert owner is not None, "a stored document names its database"
return await owner.document_item_repository.get_all_items(document_id)
@mcp.tool(annotations=_read_only("Document outline"))
async def get_document_outline(
document_id: str, source: str | None = None
) -> list[OutlineNode]:
"""The heading tree of a document, with page numbers.
Use this on a long document to see its structure before reading, then
pass a node's `id` to `get_document_section`. Returns the headings
nested by level; an empty list means the document has no headings,
so read it with `get_document`.
Args:
document_id: The document's id.
source: The collection holding it. Without one every collection
is asked.
"""
return [
_node(toc) for toc in build_toc(await _items_of(document_id, source), {})
]
@mcp.tool(annotations=_read_only("Document section"))
async def get_document_section(
document_id: str, section_id: str, source: str | None = None
) -> DocumentSection:
"""The text of one section of a document, subsections included.
Use this to read a part of a long document instead of the whole.
`section_id` is a node `id` from `get_document_outline`. Returns the
section's heading, page numbers and text in reading order, up to the
next heading of the same or a higher level.
Args:
document_id: The document's id.
section_id: The `id` of a node in the document's outline.
source: The collection holding it. Without one every collection
is asked.
"""
items = await _items_of(document_id, source)
node = _find(build_toc(items, {}), section_id)
if node is None:
raise ToolError(f"No section {section_id!r} in document {document_id!r}")
start, end = node["item_range"]
ordered = sorted(items, key=lambda item: item.position)
return DocumentSection(
id=node["self_ref"],
title=node["title"],
page_numbers=node["page_numbers"],
content="\n\n".join(item.text for item in ordered[start:end] if item.text),
)
@mcp.tool(annotations=_read_only("List documents"))
async def list_documents( async def list_documents(
limit: int | None = None, limit: int | None = None,
offset: int | None = None, offset: int | None = None,
filter: Filter = None, filter: str | None = None,
) -> list[DocumentInfo]: ) -> list[DocumentInfo]:
"""List what the knowledge base holds. """List all documents with optional pagination and filtering.
Use this to see which documents exist, their titles, URIs and
metadata, and so what a `filter` can match. Not a search: it returns
no passages.
Args: Args:
limit: How many documents to return. limit: Maximum number of documents to return.
offset: How many documents to skip, for paging. offset: Number of documents to skip.
filter: Optional SQL WHERE clause to filter documents.
""" """
try:
rag = await _client() rag = await _client()
documents = await rag.list_documents(limit, offset, filter) documents = await rag.list_documents(limit, offset, filter)
return [ return [
DocumentInfo( DocumentInfo(
id=doc.id, id=doc.id,
title=doc.title or "Untitled", title=doc.title or "Untitled",
uri=doc.uri or "", uri=doc.uri or "",
created=doc.created_at.strftime("%Y-%m-%d"), created=doc.created_at.strftime("%Y-%m-%d"),
source=doc.source,
metadata=doc.metadata,
) )
for doc in documents for doc in documents
] ]
except Exception:
return []
@mcp.tool(annotations=_read_only("Run code over the documents")) @mcp.tool()
async def execute_code( async def ask_question(
code: str, filter: Filter = None, sources: Sources = None question: str,
cite: bool = False,
images_base64: list[str] | None = None,
) -> str: ) -> str:
"""Run a Python program over the documents and return what it printed. """Ask a question using the QA agent.
Use this when the answer is a count, an aggregate, a comparison across
many documents, a lookup by document or chunk metadata, or a pattern
over whole documents: whatever a search cannot rank. The program runs
in a sandboxed interpreter on the server. Each call is one program,
nothing carries over between calls, and `print` is the only output.
Inside the program, `/documents/{document_id}/` holds `metadata.json`
(id, title, uri, created_at, metadata), `content.txt` (the whole text),
`items.jsonl` (one item per line: self_ref, label, text, page_numbers,
heading_level, chunk_ids), `chunks.jsonl` (one chunk per line: chunk_id,
metadata) and `toc.json` (`doc_id`, `title`, `tree`; each node has
self_ref, level, title, page_numbers, item_range as a slice into
items.jsonl, chunk_ids and children; an empty tree means no headings).
Read files with `Path.read_text()` or `open()`; a file object cannot be
iterated, use `.readlines()`. `await search(query, limit=10)` returns
dicts with chunk_id, content, document_id, document_title, document_uri,
source, score, page_numbers, headings, doc_item_refs, labels,
picture_refs (the doc_item_refs that are pictures) and chunk_meta.
`await list_documents()` returns dicts with id, title, uri, created_at,
source and metadata. Both see the documents `filter` and `sources`
select. Useful modules include json, re, math, pathlib, datetime,
collections, itertools, functools and dataclasses; decimal and
statistics do not exist. No generator functions, match statements or
class inheritance.
Files are read-only, there is no network, a call has a time limit named
in the error when it is hit, and output past a size is truncated.
Map a title or URI to a document id with one `list_documents()` call
rather than reading every `metadata.json`. The files carry no `source`,
so over several collections group by the `source` of `list_documents()`
rows. For a known document's structure read its `toc.json` before
searching: `search()` ranks across every document. A hit's
`doc_item_refs` are `self_ref` values in `items.jsonl`, which places it
in its section. `chunk_ids` on items and `chunk_id` in `chunks.jsonl`
join the two files; they are not citations.
Args: Args:
code: The program. Use `await` on search and list_documents. question: The question to ask.
cite: Whether to include citations in the response.
images_base64: Base64-encoded images attached to the question
(requires a vision-capable QA model).
Returns:
The answer as a string.
""" """
rag = await _client()
sandbox = Sandbox._covering(
scope, config, AnalysisContext(filter=filter, sources=sources), rag=rag
)
try: try:
result = await sandbox.execute(code) images = _decode_images(images_base64)
finally: rag = await _client()
await sandbox.close() answer, citations = await rag.ask(question, images=images)
if not result.success: if cite and citations:
raise ToolError( answer += "\n\n" + format_citations(citations)
f"{result.stderr}{recovery_hint(result.stderr)}" return answer
f"\n\nOutput: {result.stdout}" except Exception as e:
) return f"Error answering question: {e!s}"
return result.stdout or "No output."
@mcp.tool()
async def analyze(
question: str,
filter: str | None = None,
images_base64: list[str] | None = None,
) -> str:
"""Answer complex questions using the analysis capability.
Use this for questions requiring computation, aggregation, or
structural traversal across documents. The capability can write and
execute Python code in a sandboxed interpreter.
Args:
question: The question to answer.
filter: Optional SQL WHERE clause to filter documents.
images_base64: Base64-encoded images attached to the question
(requires a vision-capable analysis model).
Returns:
The answer as a string.
"""
try:
images = _decode_images(images_base64)
rag = await _client()
result = await rag.analyze(question, filter=filter, images=images)
return result.answer
except Exception as e:
return f"Error running analysis capability: {e!s}"
return mcp return mcp

View file

@ -1,11 +1,10 @@
from haiku.rag.sandbox.dependencies import AnalysisContext from haiku.rag.sandbox.dependencies import AnalysisContext
from haiku.rag.sandbox.models import AnalysisResult from haiku.rag.sandbox.models import AnalysisResult
from haiku.rag.sandbox.sandbox import Sandbox, SandboxResult, recovery_hint from haiku.rag.sandbox.sandbox import Sandbox, SandboxResult
__all__ = [ __all__ = [
"AnalysisContext", "AnalysisContext",
"AnalysisResult", "AnalysisResult",
"Sandbox", "Sandbox",
"SandboxResult", "SandboxResult",
"recovery_hint",
] ]

View file

@ -17,9 +17,8 @@ from pydantic_monty import (
) )
from haiku.rag.config.models import AppConfig from haiku.rag.config.models import AppConfig
from haiku.rag.context import build_toc
from haiku.rag.sandbox.dependencies import AnalysisContext from haiku.rag.sandbox.dependencies import AnalysisContext
from haiku.rag.store.models.chunk import Chunk, SearchResult from haiku.rag.store.models.chunk import SearchResult
from haiku.rag.store.models.document_item import PICTURE_REF_PREFIX, DocumentItem from haiku.rag.store.models.document_item import PICTURE_REF_PREFIX, DocumentItem
from haiku.rag.utils import gather_all from haiku.rag.utils import gather_all
@ -30,9 +29,6 @@ if TYPE_CHECKING:
from haiku.rag.client.scope import DatabaseScope from haiku.rag.client.scope import DatabaseScope
_MAX_HOST_CALLS = 10_000_000
@dataclass @dataclass
class SandboxResult: class SandboxResult:
"""Result of executing code in the sandbox.""" """Result of executing code in the sandbox."""
@ -42,19 +38,79 @@ class SandboxResult:
success: bool success: bool
def recovery_hint(stderr: str) -> str: def _build_toc(
"""Name the workaround for sandbox limits models trip over repeatedly. items: list["DocumentItem"],
chunk_index: dict[str, list[str]],
) -> list[dict[str, Any]]:
"""Build a nested section tree from items in position order.
The instructions already say file objects are not iterable, and models write Each ``section_header`` with ``heading_level > 0`` becomes a node. Nesting
``for line in open(...)`` regardless. Carrying the fix in the error gives follows the explicit levels: a header pops the stack until the top is at
them something to act on for the retry. a strictly shallower level, then becomes a child of that top (or a root).
``item_range = [position, end_exclusive]`` where ``end_exclusive`` is the
position of the next header whose level is the same or shallower (i.e.
the next sibling or ancestor that ends this section), or the total item
count if no such header exists.
``chunk_ids`` aggregates the chunks covered by all items in the section's
``item_range`` (deduped, order preserved). Pass directly to ``cite()`` to
ground a section-scoped answer without a corpus-wide ``search()`` call.
Items without a section_header label (or with ``heading_level == 0``) are
skipped. When all section_headers carry the same level the output is a
flat sibling list (see docling-project/docling#2121 for an upstream case
where every PDF section_header is emitted at level=1).
""" """
if "TextIOWrapper" in stderr and "not iterable" in stderr: # Defensive: every consumer is supposed to pass items in position order,
return ( # but the end_exclusive lookahead below silently miscomputes section
"\n\nHint: file objects cannot be iterated here. Read lines with " # boundaries if it's not — better to sort once than trust the caller.
'.readlines() or .read().split("\\n").' items = sorted(items, key=lambda i: i.position)
) headers: list[DocumentItem] = [
return "" i for i in items if i.label == "section_header" and i.heading_level > 0
]
if not headers:
return []
total = max((i.position for i in items), default=-1) + 1
items_by_position: dict[int, DocumentItem] = {i.position: i for i in items}
ends: list[int] = []
for idx, h in enumerate(headers):
end = total
for j in range(idx + 1, len(headers)):
if headers[j].heading_level <= h.heading_level:
end = headers[j].position
break
ends.append(end)
roots: list[dict[str, Any]] = []
stack: list[tuple[int, dict[str, Any]]] = []
for h, end in zip(headers, ends, strict=True):
seen: set[str] = set()
chunk_ids: list[str] = []
for pos in range(h.position, end):
item = items_by_position.get(pos)
if item is None:
continue
for cid in chunk_index.get(item.self_ref, []):
if cid not in seen:
seen.add(cid)
chunk_ids.append(cid)
node: dict[str, Any] = {
"self_ref": h.self_ref,
"level": h.heading_level,
"title": h.text,
"page_numbers": list(h.page_numbers),
"item_range": [h.position, end],
"chunk_ids": chunk_ids,
"children": [],
}
while stack and stack[-1][0] >= h.heading_level:
stack.pop()
(stack[-1][1]["children"] if stack else roots).append(node)
stack.append((h.heading_level, node))
return roots
class Sandbox: class Sandbox:
@ -64,8 +120,7 @@ class Sandbox:
The interpreter runs in a subprocess worker checked out of an ``AsyncMonty`` The interpreter runs in a subprocess worker checked out of an ``AsyncMonty``
pool. External functions (search, list_documents) are called by Monty code pool. External functions (search, list_documents) are called by Monty code
using ``await`` and resolved asynchronously on the host. Documents are using ``await`` and resolved asynchronously on the host. Documents are
exposed via a virtual filesystem at ``/documents/{id}/``: ``metadata.json``, exposed via a virtual filesystem at ``/documents/{id}/``.
``content.txt``, ``items.jsonl``, ``chunks.jsonl`` and ``toc.json``.
The session persists across ``execute()`` calls within the same Sandbox The session persists across ``execute()`` calls within the same Sandbox
instance variables carry over. Call ``close()`` to return the worker to instance variables carry over. Call ``close()`` to return the worker to
@ -95,7 +150,6 @@ class Sandbox:
_doc_items: dict[str, list["DocumentItem"]] _doc_items: dict[str, list["DocumentItem"]]
_doc_chunk_index: dict[str, dict[str, list[str]]] _doc_chunk_index: dict[str, dict[str, list[str]]]
_items_jsonl_cache: dict[str, str] _items_jsonl_cache: dict[str, str]
_chunks_jsonl_cache: dict[str, str]
_toc_json_cache: dict[str, str] _toc_json_cache: dict[str, str]
_opened: "HaikuRAG | None" _opened: "HaikuRAG | None"
_pool: AsyncMonty | None _pool: AsyncMonty | None
@ -162,7 +216,6 @@ class Sandbox:
self._doc_items = {} self._doc_items = {}
self._doc_chunk_index = {} self._doc_chunk_index = {}
self._items_jsonl_cache = {} self._items_jsonl_cache = {}
self._chunks_jsonl_cache = {}
self._toc_json_cache = {} self._toc_json_cache = {}
self._pool = None self._pool = None
self._session = None self._session = None
@ -275,42 +328,13 @@ class Sandbox:
assert self._loop is not None, ( assert self._loop is not None, (
"VFS reads happen during execute(); the loop must be captured first." "VFS reads happen during execute(); the loop must be captured first."
) )
if self._past_deadline(): if self._deadline is not None and self._loop.time() > self._deadline:
coro.close() coro.close()
raise self._time_limit() raise TimeoutError(
return asyncio.run_coroutine_threadsafe(coro, self._loop).result() "time limit exceeded: no further document reads after "
def _past_deadline(self) -> bool:
return (
self._deadline is not None
and self._loop is not None
and self._loop.time() > self._deadline
)
def _time_limit(self) -> TimeoutError:
return TimeoutError(
"time limit exceeded: no further document reads or calls after "
f"{self._config.analysis.code_timeout}s" f"{self._config.analysis.code_timeout}s"
) )
return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
def _check_deadline(self) -> None:
"""Refuse a host call once the call's time is up.
Monty's watchdog counts only time the worker spends computing, so every
host call, a file served from memory and an in-code search included,
checks the deadline before it runs.
"""
if self._past_deadline():
raise self._time_limit()
def _timed(
self, read: Callable[["PurePosixPath"], str]
) -> Callable[["PurePosixPath"], str]:
def call(path: "PurePosixPath") -> str:
self._check_deadline()
return read(path)
return call
async def _discard_session(self) -> None: async def _discard_session(self) -> None:
"""Drop a session whose worker is gone. """Drop a session whose worker is gone.
@ -347,7 +371,6 @@ class Sandbox:
context = self._context context = self._context
async def search(query: str, limit: int = 10) -> list[dict[str, Any]]: async def search(query: str, limit: int = 10) -> list[dict[str, Any]]:
self._check_deadline()
# Picture bytes are deliberately not attached to in-code search # Picture bytes are deliberately not attached to in-code search
# results: the Monty interpreter has no PIL/base64/hashlib, so the # results: the Monty interpreter has no PIL/base64/hashlib, so the
# agent's Python can't do anything with them. The driving model # agent's Python can't do anything with them. The driving model
@ -381,13 +404,11 @@ class Sandbox:
"doc_item_refs": r.doc_item_refs, "doc_item_refs": r.doc_item_refs,
"labels": r.labels, "labels": r.labels,
"picture_refs": picture_refs, "picture_refs": picture_refs,
"chunk_meta": r.chunk_meta,
} }
) )
return out return out
async def list_documents() -> list[dict[str, Any]]: async def list_documents() -> list[dict[str, Any]]:
self._check_deadline()
docs, _ = await self._documents() docs, _ = await self._documents()
return [ return [
{ {
@ -396,7 +417,6 @@ class Sandbox:
"uri": d.uri, "uri": d.uri,
"created_at": str(d.created_at), "created_at": str(d.created_at),
"source": d.source, "source": d.source,
"metadata": d.metadata,
} }
for d in docs for d in docs
] ]
@ -413,7 +433,6 @@ class Sandbox:
- metadata.json: CallbackFile (eager, small) - metadata.json: CallbackFile (eager, small)
- content.txt: CallbackFile (lazy, can be large) - content.txt: CallbackFile (lazy, can be large)
- items.jsonl: CallbackFile (lazy, bulk-cached) - items.jsonl: CallbackFile (lazy, bulk-cached)
- chunks.jsonl: CallbackFile (lazy, bulk-cached)
- toc.json: CallbackFile (lazy, bulk-cached) - toc.json: CallbackFile (lazy, bulk-cached)
""" """
files: list[CallbackFile] = [] files: list[CallbackFile] = []
@ -488,31 +507,6 @@ class Sandbox:
return read_items return read_items
def _make_chunks_reader(
did: str,
) -> Callable[["PurePosixPath"], str]:
def read_chunks(_path: "PurePosixPath") -> str:
cached = sandbox._chunks_jsonl_cache.get(did)
if cached is not None:
return cached
async def _fetch() -> list[Chunk]:
async with sandbox._connection(sandbox._owners.get(did)) as rag:
return await rag.chunk_repository.get_by_document_id(did)
chunks = sandbox._run_on_loop(_fetch())
jsonl = "\n".join(
json.dumps(
{"chunk_id": chunk.id, "metadata": chunk.metadata},
ensure_ascii=False,
)
for chunk in chunks
)
sandbox._chunks_jsonl_cache[did] = jsonl
return jsonl
return read_chunks
def _make_toc_reader( def _make_toc_reader(
did: str, did: str,
) -> Callable[["PurePosixPath"], str]: ) -> Callable[["PurePosixPath"], str]:
@ -526,7 +520,7 @@ class Sandbox:
{ {
"doc_id": did, "doc_id": did,
"title": doc_titles.get(did), "title": doc_titles.get(did),
"tree": build_toc(items, chunk_index), "tree": _build_toc(items, chunk_index),
}, },
ensure_ascii=False, ensure_ascii=False,
) )
@ -547,7 +541,6 @@ class Sandbox:
"title": doc.title, "title": doc.title,
"uri": doc.uri, "uri": doc.uri,
"created_at": str(doc.created_at), "created_at": str(doc.created_at),
"metadata": doc.metadata,
}, },
ensure_ascii=False, ensure_ascii=False,
) )
@ -557,7 +550,7 @@ class Sandbox:
files.append( files.append(
CallbackFile( CallbackFile(
f"{doc_dir}/metadata.json", f"{doc_dir}/metadata.json",
read=self._timed(lambda _path, text=metadata: text), read=lambda _path, text=metadata: text,
write=_deny_write, write=_deny_write,
) )
) )
@ -578,21 +571,14 @@ class Sandbox:
files.append( files.append(
CallbackFile( CallbackFile(
f"{doc_dir}/content.txt", f"{doc_dir}/content.txt",
read=self._timed(_make_content_reader(doc_id)), read=_make_content_reader(doc_id),
write=_deny_write, write=_deny_write,
) )
) )
files.append( files.append(
CallbackFile( CallbackFile(
f"{doc_dir}/items.jsonl", f"{doc_dir}/items.jsonl",
read=self._timed(_make_items_reader(doc_id)), read=_make_items_reader(doc_id),
write=_deny_write,
)
)
files.append(
CallbackFile(
f"{doc_dir}/chunks.jsonl",
read=self._timed(_make_chunks_reader(doc_id)),
write=_deny_write, write=_deny_write,
) )
) )
@ -603,7 +589,7 @@ class Sandbox:
files.append( files.append(
CallbackFile( CallbackFile(
f"{doc_dir}/toc.json", f"{doc_dir}/toc.json",
read=self._timed(_make_toc_reader(doc_id)), read=_make_toc_reader(doc_id),
write=_deny_write, write=_deny_write,
) )
) )
@ -615,19 +601,12 @@ class Sandbox:
Monty spends ``max_duration_secs`` across the session's whole life, and Monty spends ``max_duration_secs`` across the session's whole life, and
the session is reused so variables persist between calls: the budget the session is reused so variables persist between calls: the budget
covers the whole run. ``code_timeout`` is enforced per call elsewhere: past covers the whole run. ``code_timeout`` is enforced per call elsewhere: the read
its deadline no further host call starts (``_check_deadline``), and the deadline in ``_run_on_loop`` bounds a call that reads, and the pool's
pool's ``request_timeout`` bounds compute. ``request_timeout`` bounds one that computes.
``max_suspensions`` counts host callbacks per session, document reads
included, defaults to 1000 and cannot be disabled. The time budgets are
the governors here, so it is set where no program reaches it.
""" """
analysis = self._config.analysis analysis = self._config.analysis
return { return {"max_duration_secs": analysis.code_timeout * analysis.max_executions}
"max_duration_secs": analysis.code_timeout * analysis.max_executions,
"max_suspensions": _MAX_HOST_CALLS,
}
async def _ensure_initialized(self) -> tuple[AsyncMontySession, OSAccess]: async def _ensure_initialized(self) -> tuple[AsyncMontySession, OSAccess]:
"""Check out a worker session and build the VFS on first use.""" """Check out a worker session and build the VFS on first use."""

View file

@ -1,4 +1,3 @@
import json
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING, Literal
from pydantic import BaseModel, PrivateAttr from pydantic import BaseModel, PrivateAttr
@ -144,9 +143,8 @@ class SearchResult(BaseModel):
consumers (UIs). Never part of ``format_for_agent`` output. consumers (UIs). Never part of ``format_for_agent`` output.
``chunk_meta`` is the anchor chunk's unparsed ``Chunk.metadata`` and does not ``chunk_meta`` is the anchor chunk's unparsed ``Chunk.metadata`` and does not
include the metadata of any other chunks merged with it. Left out of include the metadata of any other chunks merged with it. Never part of
``format_for_agent`` output unless ``include_chunk_meta`` asks for its custom ``format_for_agent`` output.
keys.
``source`` names the database a result came from: the name from ``source`` names the database a result came from: the name from
``lancedb.databases`` or a path's stem, never a path or URI, so a location ``lancedb.databases`` or a path's stem, never a path or URI, so a location
@ -204,8 +202,6 @@ class SearchResult(BaseModel):
total: int | None = None, total: int | None = None,
*, *,
include_collection: bool = False, include_collection: bool = False,
include_document_id: bool = False,
include_chunk_meta: bool = False,
) -> str: ) -> str:
"""Format this search result for inclusion in agent context. """Format this search result for inclusion in agent context.
@ -219,11 +215,7 @@ class SearchResult(BaseModel):
`include_collection` is the caller's decision, not this result's: a `include_collection` is the caller's decision, not this result's: a
search spanning one collection has nothing to distinguish, whether or search spanning one collection has nothing to distinguish, whether or
not that collection is named. `include_document_id` is for a reader not that collection is named.
that will fetch the document by id from the text alone.
`include_chunk_meta` renders the metadata stored with the matched
chunk beyond haiku.rag's own structural keys; on an expanded result it
locates the hit, not the whole passage.
""" """
if rank is not None and total is not None: if rank is not None and total is not None:
parts = [f"[{self.chunk_id}] [rank {rank} of {total}]"] parts = [f"[{self.chunk_id}] [rank {rank} of {total}]"]
@ -232,9 +224,6 @@ class SearchResult(BaseModel):
else: else:
parts = [f"[{self.chunk_id}] (score: {self.score:.2f})"] parts = [f"[{self.chunk_id}] (score: {self.score:.2f})"]
if include_document_id and self.document_id:
parts.append(f"Document ID: {self.document_id}")
if include_collection and self.source: if include_collection and self.source:
parts.append(f"Collection: {self.source}") parts.append(f"Collection: {self.source}")
@ -253,16 +242,6 @@ class SearchResult(BaseModel):
if primary_label: if primary_label:
parts.append(f"Type: {primary_label}") parts.append(f"Type: {primary_label}")
if include_chunk_meta:
custom = {
key: value
for key, value in self.chunk_meta.items()
if key not in ChunkMetadata.model_fields
}
if custom:
rendered = json.dumps(custom, ensure_ascii=False, sort_keys=True)
parts.append(f"Matched chunk metadata: {rendered}")
# Surface picture captions when present. Order matches the binary # Surface picture captions when present. Order matches the binary
# attachments emitted by build_image_content_from_results, so the model # attachments emitted by build_image_content_from_results, so the model
# can correlate caption ↔ attached image by position (BinaryContent # can correlate caption ↔ attached image by position (BinaryContent

View file

@ -27,27 +27,6 @@ class DocumentInfo(BaseModel):
title: str title: str
uri: str uri: str
created: str created: str
source: str | None = None
metadata: dict = {}
class OutlineNode(BaseModel):
"""A heading in a document's outline. `id` is the heading item's self_ref."""
id: str
title: str
level: int
page_numbers: list[int] = []
children: list["OutlineNode"] = []
class DocumentSection(BaseModel):
"""One section's text in reading order, subsections included."""
id: str
title: str
page_numbers: list[int] = []
content: str
class DocumentListResponse(BaseModel): class DocumentListResponse(BaseModel):

View file

@ -52,16 +52,31 @@ def decode_picture(data: bytes, self_ref: str) -> BinaryContent | None:
return BinaryContent(data=data, media_type="image/png", identifier=self_ref) return BinaryContent(data=data, media_type="image/png", identifier=self_ref)
def collect_pictures( def build_image_content_from_results(
results: list[SearchResult], exclude: AbstractSet[PictureKey] = frozenset() results: list[SearchResult],
) -> tuple[list[tuple[str | None, str | None, str, BinaryContent]], set[PictureKey]]: include_collection: bool = False,
"""Every distinct, decodable picture attached to ``results``, in order. exclude: AbstractSet[PictureKey] = frozenset(),
) -> tuple[list[str | BinaryContent], set[PictureKey]]:
"""Decode and validate picture bytes attached to search results, labelled.
Returns ``(source, chunk_id, self_ref, picture)`` per picture and the Returns the labelled content and the ``PictureKey`` of every picture it
``PictureKey`` of each. Dedup keyed on ``PictureKey`` so the same picture in emitted. Dedup keyed on ``PictureKey`` so the same picture in
different chunks is emitted once, and a copy in another collection is its different chunks is sent once, and a copy in another collection is its
own; ``exclude`` seeds that dedup with pictures already sent. Pictures that own; ``exclude`` seeds that dedup with pictures already sent. Pictures that fail
fail ``PIL.Image.verify()`` are skipped. ``PIL.Image.verify()`` are skipped the model adapter renders one
vision placeholder per ``BinaryContent``, so emitting one for an
image the server can't decode leaves the processor with an
off-by-one count.
Every picture is preceded by a line naming the result it belongs to.
``ToolReturn.content`` reaches the model as a user-role message, so
retrieved pictures are otherwise indistinguishable from ones the user
attached, and models narrate them as part of the question: unlabelled,
gemma4-26b answered about a figure from an unrelated document, and with a
single note ahead of the batch it still called them "images in the prompt".
The label also names the chunk to cite for a figure, which
``BinaryContent.identifier`` cannot do it does not survive serialization
to the vision API.
""" """
collected: list[tuple[str | None, str | None, str, BinaryContent]] = [] collected: list[tuple[str | None, str | None, str, BinaryContent]] = []
seen: set[PictureKey] = set(exclude) seen: set[PictureKey] = set(exclude)
@ -79,33 +94,7 @@ def collect_pictures(
collected.append((result.source, result.chunk_id, self_ref, picture)) collected.append((result.source, result.chunk_id, self_ref, picture))
seen.add(key) seen.add(key)
emitted.add(key) emitted.add(key)
return collected, emitted
def build_image_content_from_results(
results: list[SearchResult],
include_collection: bool = False,
exclude: AbstractSet[PictureKey] = frozenset(),
) -> tuple[list[str | BinaryContent], set[PictureKey]]:
"""Decode and validate picture bytes attached to search results, labelled.
Returns the labelled content and the ``PictureKey`` of every picture it
emitted, as ``collect_pictures`` decides them. An undecodable picture is
skipped because the model adapter renders one vision placeholder per
``BinaryContent``, so emitting one for an image the server can't decode
leaves the processor with an off-by-one count.
Every picture is preceded by a line naming the result it belongs to.
``ToolReturn.content`` reaches the model as a user-role message, so
retrieved pictures are otherwise indistinguishable from ones the user
attached, and models narrate them as part of the question: unlabelled,
gemma4-26b answered about a figure from an unrelated document, and with a
single note ahead of the batch it still called them "images in the prompt".
The label also names the chunk to cite for a figure, which
``BinaryContent.identifier`` cannot do it does not survive serialization
to the vision API.
"""
collected, emitted = collect_pictures(results, exclude)
content: list[str | BinaryContent] = [] content: list[str | BinaryContent] = []
total = len(collected) total = len(collected)
for position, (source, chunk_id, self_ref, picture) in enumerate(collected, 1): for position, (source, chunk_id, self_ref, picture) in enumerate(collected, 1):

View file

@ -4,7 +4,7 @@ import sys
from collections.abc import Awaitable from collections.abc import Awaitable
from importlib import metadata from importlib import metadata
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, NoReturn, cast from typing import TYPE_CHECKING, Any, NoReturn, cast
from packaging.version import Version, parse from packaging.version import Version, parse
@ -41,7 +41,7 @@ def parse_model_option(value: str) -> "ModelConfig":
parts = value.split(":", 1) parts = value.split(":", 1)
if len(parts) != 2 or not parts[0] or not parts[1]: if len(parts) != 2 or not parts[0] or not parts[1]:
raise ValueError( raise ValueError(
f"Invalid model format '{value}'. Expected 'provider:name' (e.g. 'ollama:qwen3.8')." f"Invalid model format '{value}'. Expected 'provider:name' (e.g. 'ollama:gpt-oss')."
) )
return ModelConfig(provider=parts[0], name=parts[1]) return ModelConfig(provider=parts[0], name=parts[1])
@ -182,20 +182,6 @@ _OPENAI_COMPAT_PROFILE: "OpenAIModelProfile" = {
} }
def reasoning_effort(
model_config: "ModelConfig",
) -> Literal["none", "low", "high"] | None:
"""OpenAI `reasoning_effort` for a model config, or None when unset.
"low" is gpt-oss's floor; its template rejects "none".
"""
if model_config.enable_thinking is None:
return None
if model_config.enable_thinking:
return "high"
return "low" if model_config.name == "gpt-oss" else "none"
def get_model( def get_model(
model_config: "ModelConfig", model_config: "ModelConfig",
app_config: "AppConfig | None" = None, app_config: "AppConfig | None" = None,
@ -227,9 +213,12 @@ def get_model(
if provider == "ollama": if provider == "ollama":
model_settings = None model_settings = None
effort = reasoning_effort(model_config) # Apply thinking control for gpt-oss
if effort is not None: if model == "gpt-oss" and model_config.enable_thinking is not None:
model_settings = OpenAIChatModelSettings(openai_reasoning_effort=effort) if model_config.enable_thinking is False:
model_settings = OpenAIChatModelSettings(openai_reasoning_effort="low")
else:
model_settings = OpenAIChatModelSettings(openai_reasoning_effort="high")
model_settings = apply_common_settings( model_settings = apply_common_settings(
model_settings, model_config, map_thinking=False model_settings, model_config, map_thinking=False
@ -393,6 +382,43 @@ def _citation_label(c: "Citation") -> str:
return c.document_title or c.document_uri return c.document_title or c.document_uri
def format_citations(citations: "list[Citation]") -> str:
"""Format citations as plain text with preserved formatting.
Used by things like the MCP server where Rich renderables are not available.
Pictures referenced by the chunk are surfaced as ``[Figure: <ref>]`` markers.
"""
if not citations:
return ""
lines = ["## Citations\n"]
for i, c in enumerate(citations):
idx = c.index if c.index is not None else (i + 1)
title = c.document_title or c.document_uri
header = f"[{idx}] {title}"
location_parts = []
pages = _citation_pages(c)
if pages:
location_parts.append(pages)
section = _citation_section(c)
if section:
location_parts.append(f"Section: {section}")
source = c.document_uri
if location_parts:
source += f" - {', '.join(location_parts)}"
lines.append(f"{header} {source}")
for ref in c.picture_refs:
lines.append(f"[Figure: {ref}]")
lines.append(c.content)
lines.append("")
return "\n".join(lines)
def truncated(text: str, limit: int) -> str: def truncated(text: str, limit: int) -> str:
"""The first `limit` characters of `text`, with `…` appended when anything """The first `limit` characters of `text`, with `…` appended when anything
was dropped. A cut result is `limit` characters plus the mark.""" was dropped. A cut result is `limit` characters plus the mark."""

View file

@ -40,12 +40,12 @@ dependencies = [
"docling-core>=2.82.0,<3.0.0", "docling-core>=2.82.0,<3.0.0",
"httpx>=0.28.1", "httpx>=0.28.1",
"jinja2>=3.1.0", "jinja2>=3.1.0",
"fastmcp>=4.0.2,<5.0.0", "fastmcp>=3.3.0",
"lancedb==0.37.1", "lancedb==0.37.1",
"pathspec>=1.0.4", "pathspec>=1.0.4",
"pydantic>=2.12.5", "pydantic>=2.12.5",
"pydantic-ai-slim[openai,logfire,ag-ui]>=2.18.0,<3.0.0", "pydantic-ai-slim[openai,logfire,ag-ui]>=2.18.0,<3.0.0",
"pydantic-monty>=0.0.23", "pydantic-monty>=0.0.19",
"pypdfium2>=5.0", "pypdfium2>=5.0",
"python-dotenv>=1.2.2", "python-dotenv>=1.2.2",
"pyyaml>=6.0.3", "pyyaml>=6.0.3",

View file

@ -1,12 +0,0 @@
{
"name": "haiku-rag",
"version": "0.82.1",
"description": "Search, read and analyze your haiku.rag knowledge base from Claude Code.",
"author": {
"name": "Yiorgis Gozadinos",
"email": "ggozadinos@gmail.com"
},
"homepage": "https://ggozad.github.io/haiku.rag/mcp/",
"repository": "https://github.com/ggozad/haiku.rag",
"license": "MIT"
}

View file

@ -1,26 +0,0 @@
{
"name": "haiku-rag",
"version": "0.82.1",
"description": "Search, read and analyze your haiku.rag knowledge base from Codex.",
"author": {
"name": "Yiorgis Gozadinos",
"email": "ggozadinos@gmail.com",
"url": "https://github.com/ggozad"
},
"homepage": "https://ggozad.github.io/haiku.rag/mcp/",
"repository": "https://github.com/ggozad/haiku.rag",
"license": "MIT",
"keywords": ["rag", "knowledge-base", "search", "documents", "mcp"],
"skills": "./skills/",
"mcpServers": "./.mcp.json",
"interface": {
"displayName": "haiku.rag",
"shortDescription": "Search and analyze your haiku.rag knowledge base",
"longDescription": "Search, read, and compute over documents in your local haiku.rag knowledge base through MCP tools.",
"developerName": "Yiorgis Gozadinos",
"category": "Productivity",
"capabilities": ["Interactive", "Read"],
"websiteURL": "https://ggozad.github.io/haiku.rag/",
"defaultPrompt": "Search my haiku.rag knowledge base and cite the relevant documents."
}
}

View file

@ -1,8 +0,0 @@
{
"mcpServers": {
"haiku-rag": {
"command": "haiku-rag",
"args": ["mcp", "--stdio"]
}
}
}

View file

@ -1,77 +0,0 @@
---
name: haiku-rag
description: Search, read and compute over the user's haiku.rag knowledge base
through the haiku-rag MCP tools. Use whenever a request could be answered
from the user's ingested documents, when asked to find, look up, check or
cite something in their documents or knowledge base, or when the question is
about the user's own material rather than general knowledge.
compatibility: Requires the haiku-rag MCP server to be registered in the client.
allowed-tools:
- mcp__plugin_haiku-rag_haiku-rag__search_documents
- mcp__plugin_haiku-rag_haiku-rag__search_documents_by_image
- mcp__plugin_haiku-rag_haiku-rag__get_document
- mcp__plugin_haiku-rag_haiku-rag__get_document_outline
- mcp__plugin_haiku-rag_haiku-rag__get_document_section
- mcp__plugin_haiku-rag_haiku-rag__list_documents
- mcp__plugin_haiku-rag_haiku-rag__execute_code
---
# Working with the knowledge base
Check the knowledge base before answering from memory whenever the question
could be about the user's documents. Say so when it has nothing relevant.
## Find
`search_documents` is the first call. Results come best first with the document
title, section headings, the matched chunk's metadata when it has any, and the
passage in its section. Pictures in the results arrive as images: answer
figure questions from them. `filter` restricts which documents are searched,
`limit` how many results come back. If it misses, rephrase once or narrow with
a filter before concluding the material is not there. When the question is
about an image rather than words and the server offers
`search_documents_by_image`, it takes the image as the query.
## Read
Every search result shows its `Document ID` (and `Collection` when there are
several); pass them to the read tools. `get_document` returns a document's
whole text in reading order. For a long one, `get_document_outline` gives the
heading tree with page numbers and `get_document_section` the text of one
section, subsections included.
## Compute
`execute_code` runs a Python program on the server over the same documents.
Under `/documents/{id}/` each has `metadata.json`, `content.txt`, `items.jsonl`,
`chunks.jsonl` and `toc.json`, and the program can `await search(query)` and
`await list_documents()`. Write code when the answer is a count, an aggregate, a
comparison across many documents, a lookup by document or chunk metadata, or a
pattern over whole documents: whatever search cannot rank. Each call is one
program and variables do not carry over, so gather, compute and `print` a
compact result in the same program. `filter` and `sources` select the documents
it sees. For a known document's structure read its `toc.json` first; `search()`
ranks across every document. Map a title or URI to an id with one
`list_documents()` call rather than reading every `metadata.json`; the files
carry no `source`, so over several collections group by its rows. Answer and
cite from what it printed.
## Explore
`list_documents` shows what is stored: titles, URIs and metadata. It is how you
learn what a filter can match.
## Filters
A SQL WHERE clause over the document columns `id`, `uri`, `title`,
`created_at`, `updated_at`, `metadata`. `metadata` is a JSON string, so match
it with LIKE: `metadata LIKE '%"author": "Smith"%'`. Also `uri LIKE '%.pdf'`,
`title = 'Q3 report'`.
## Results and citations
Rank is the signal; scores are not comparable across queries and are never
confidence. Cite the document title or URI, the section heading and page
numbers when present, and the matched chunk's metadata when it carries locators
such as paragraph or footnote numbers. When results carry `source`, the server
covers several collections: name it, and pass `sources` to search a subset.

View file

@ -2,8 +2,7 @@
""" """
Version bumping script for haiku.rag workspace. Version bumping script for haiku.rag workspace.
Updates version in all pyproject.toml files, both plugin manifests, and Updates version in all pyproject.toml files and CHANGELOG.md.
CHANGELOG.md.
""" """
import re import re
@ -55,19 +54,6 @@ def update_example_dependencies(file_path: Path, new_version: str) -> None:
print(f"✓ Updated example dependencies in {file_path.relative_to(Path.cwd())}") print(f"✓ Updated example dependencies in {file_path.relative_to(Path.cwd())}")
def update_plugin_version(file_path: Path, new_version: str) -> None:
"""Update the version in a plugin manifest."""
content = file_path.read_text()
updated = re.sub(
r'^(\s*"version": )"[^"]+"',
rf'\1"{new_version}"',
content,
flags=re.MULTILINE,
)
file_path.write_text(updated)
print(f"✓ Updated {file_path.relative_to(Path.cwd())}")
def update_changelog(changelog_path: Path, new_version: str) -> None: def update_changelog(changelog_path: Path, new_version: str) -> None:
"""Update CHANGELOG.md with new version.""" """Update CHANGELOG.md with new version."""
content = changelog_path.read_text() content = changelog_path.read_text()
@ -136,16 +122,10 @@ def main():
root / "app" / "backend" / "pyproject.toml", root / "app" / "backend" / "pyproject.toml",
] ]
plugin_files = [
root / "plugins" / "haiku-rag" / ".claude-plugin" / "plugin.json",
root / "plugins" / "haiku-rag" / ".codex-plugin" / "plugin.json",
]
changelog_file = root / "CHANGELOG.md" changelog_file = root / "CHANGELOG.md"
# Check all files exist # Check all files exist
for file in ( for file in pyproject_files + example_pyproject_files + [changelog_file]:
pyproject_files + example_pyproject_files + plugin_files + [changelog_file]
):
if not file.exists(): if not file.exists():
print(f"Error: {file} not found") print(f"Error: {file} not found")
sys.exit(1) sys.exit(1)
@ -175,9 +155,6 @@ def main():
for file in example_pyproject_files: for file in example_pyproject_files:
update_example_dependencies(file, new_version) update_example_dependencies(file, new_version)
for file in plugin_files:
update_plugin_version(file, new_version)
# Update CHANGELOG.md # Update CHANGELOG.md
update_changelog(changelog_file, new_version) update_changelog(changelog_file, new_version)

View file

@ -21,12 +21,6 @@ embeddings:
""") """)
os.environ["HAIKU_RAG_CONFIG_PATH"] = str(_test_config_path) os.environ["HAIKU_RAG_CONFIG_PATH"] = str(_test_config_path)
# telemetry.configure() passes send_to_logfire="if-token-present" explicitly,
# which beats LOGFIRE_SEND_TO_LOGFIRE, so no token must resolve: drop the
# environment variable and point credentials discovery at an empty directory.
os.environ.pop("LOGFIRE_TOKEN", None)
os.environ["LOGFIRE_CREDENTIALS_DIR"] = tempfile.mkdtemp()
import pydantic_ai.models # noqa: E402 import pydantic_ai.models # noqa: E402
import pytest # noqa: E402 import pytest # noqa: E402
import yaml # noqa: E402 import yaml # noqa: E402
@ -108,7 +102,7 @@ def temp_yaml_config(tmp_path, monkeypatch):
"vector_dim": 2560, "vector_dim": 2560,
} }
}, },
"qa": {"model": {"provider": "ollama", "name": "qwen3.8"}}, "qa": {"model": {"provider": "ollama", "name": "gpt-oss"}},
} }
with open(config_file, "w") as f: with open(config_file, "w") as f:

View file

@ -1054,23 +1054,16 @@ async def test_breaker_isolates_sources(client, jobs, sync):
for _ in range(10): for _ in range(10):
pool._breaker_for("bad").record_failure() pool._breaker_for("bad").record_failure()
async def _good_jobs_drained():
while True:
done = await jobs.list_jobs(status=JobStatus.SUCCEEDED, limit=50)
if len(done) == 3:
return done
await asyncio.sleep(0.02)
await pool.start() await pool.start()
try: try:
succeeded = await asyncio.wait_for(_good_jobs_drained(), timeout=5.0) await asyncio.sleep(0.2)
succeeded = await jobs.list_jobs(status=JobStatus.SUCCEEDED, limit=50)
queued = await jobs.list_jobs(status=JobStatus.QUEUED, limit=50) queued = await jobs.list_jobs(status=JobStatus.QUEUED, limit=50)
finally: finally:
await pool.stop() await pool.stop()
assert {j.uri for j in succeeded} == {"g0", "g1", "g2"} assert {j.uri for j in succeeded} == {"g0", "g1", "g2"}
assert {j.uri for j in queued} == {"b0", "b1", "b2"} assert {j.uri for j in queued} == {"b0", "b1", "b2"}
assert [j.attempts for j in queued] == [0, 0, 0]
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -18,18 +18,6 @@ def vcr_cassette_dir():
class TestSandboxBasics: class TestSandboxBasics:
"""Test basic sandbox functionality.""" """Test basic sandbox functionality."""
@pytest.mark.asyncio
async def test_the_documented_modules_import(self, sandbox):
"""The modules the instructions and the MCP description promise."""
result = await sandbox.execute(
"import json, re, math, pathlib, datetime\n"
"import collections, itertools, functools, dataclasses\n"
"print(collections.Counter('aab').most_common(1),"
" list(itertools.islice(itertools.count(), 2)))"
)
assert result.success, result.stderr
assert "[('a', 2)] [0, 1]" in result.stdout
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_execute_simple_code(self, sandbox): async def test_execute_simple_code(self, sandbox):
"""Test executing simple code in the sandbox.""" """Test executing simple code in the sandbox."""
@ -124,41 +112,6 @@ class TestSandboxListDocuments:
assert "Test Document" in result.stdout assert "Test Document" in result.stdout
assert temp_db_path.stem in result.stdout assert temp_db_path.stem in result.stdout
@pytest.mark.asyncio
async def test_list_documents_carries_metadata(self, temp_db_path):
"""Rows carry the document's metadata, so a corpus-wide pass over it is
one call rather than a file read per document."""
from docling_core.types.doc.document import DoclingDocument
from docling_core.types.doc.labels import DocItemLabel
config = AppConfig()
docling = DoclingDocument(name="d")
docling.add_text(label=DocItemLabel.TEXT, text="Test content")
async with HaikuRAG(temp_db_path, create=True) as client:
await client.import_document(
docling,
[
Chunk(
content="Test content",
embedding=[0.1] * config.embeddings.model.vector_dim,
order=0,
)
],
uri="test://doc1",
title="Test Document",
metadata={"author": "Ada"},
)
sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext())
try:
result = await sb.execute(
"docs = await list_documents()\nprint(docs[0]['metadata']['author'])"
)
finally:
await sb.close()
assert result.success, result.stderr
assert "Ada" in result.stdout
class TestSandboxSearch: class TestSandboxSearch:
"""Test search function in sandbox.""" """Test search function in sandbox."""
@ -235,51 +188,6 @@ class TestSandboxSearch:
assert "str" in result.stdout assert "str" in result.stdout
assert "True" in result.stdout assert "True" in result.stdout
@pytest.mark.asyncio
async def test_search_returns_the_matched_chunks_metadata(
self, temp_db_path, monkeypatch
):
"""Results carry the stored metadata of the chunk that matched, custom
keys included."""
from docling_core.types.doc.document import DoclingDocument
from docling_core.types.doc.labels import DocItemLabel
from haiku.rag.embeddings import EmbedderWrapper
config = AppConfig()
dim = config.embeddings.model.vector_dim
async def embed_query(self, text):
return [0.1] * dim
monkeypatch.setattr(EmbedderWrapper, "embed_query", embed_query)
docling = DoclingDocument(name="d")
docling.add_text(label=DocItemLabel.TEXT, text="Paragraph fourteen.")
async with HaikuRAG(temp_db_path, create=True) as client:
await client.import_document(
docling,
[
Chunk(
content="Paragraph fourteen.",
embedding=[0.1] * dim,
order=0,
metadata={"para_no": "14"},
)
],
uri="test://paras",
)
sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext())
try:
result = await sb.execute(
"results = await search('fourteen', limit=1)\n"
"print(results[0]['chunk_meta']['para_no'])"
)
finally:
await sb.close()
assert result.success, result.stderr
assert "14" in result.stdout
class TestSandboxExternalFunctionEdgeCases: class TestSandboxExternalFunctionEdgeCases:
"""Test edge cases in external function dispatch.""" """Test edge cases in external function dispatch."""
@ -332,23 +240,6 @@ class TestSandboxExternalFunctionEdgeCases:
assert not result.success assert not result.success
assert "external error" in result.stderr assert "external error" in result.stderr
@pytest.mark.asyncio
async def test_a_failing_search_keeps_its_message_for_the_program(
self, sandbox, monkeypatch
):
"""A host-side failure inside search() reaches the program with its
message, which the agent reads to repair its code."""
async def boom(self, *args, **kwargs):
raise ValueError("failed at /secret/path")
monkeypatch.setattr(HaikuRAG, "search", boom)
result = await sandbox.execute("await search('hello')")
assert not result.success
assert "ValueError: failed at /secret/path" in result.stderr
class TestSandboxOutputTruncation: class TestSandboxOutputTruncation:
"""Test output truncation behavior.""" """Test output truncation behavior."""
@ -421,14 +312,13 @@ class TestSandboxVFS:
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.vcr() @pytest.mark.vcr()
async def test_metadata_json(self, temp_db_path): async def test_metadata_json(self, temp_db_path):
"""metadata.json contains document title, uri and stored metadata.""" """metadata.json contains document title and uri."""
config = AppConfig() config = AppConfig()
async with HaikuRAG(temp_db_path, create=True) as client: async with HaikuRAG(temp_db_path, create=True) as client:
doc = await client.create_document( doc = await client.create_document(
content="Test content", content="Test content",
uri="test://doc1", uri="test://doc1",
title="Test Document", title="Test Document",
metadata={"author": "Ada"},
) )
context = AnalysisContext() context = AnalysisContext()
@ -438,13 +328,11 @@ class TestSandboxVFS:
"import json\n" "import json\n"
f"meta = json.loads(Path('/documents/{doc.id}/metadata.json').read_text())\n" f"meta = json.loads(Path('/documents/{doc.id}/metadata.json').read_text())\n"
"print(meta['title'])\n" "print(meta['title'])\n"
"print(meta['uri'])\n" "print(meta['uri'])"
"print(meta['metadata']['author'])"
) )
assert result.success, result.stderr assert result.success
assert "Test Document" in result.stdout assert "Test Document" in result.stdout
assert "test://doc1" in result.stdout assert "test://doc1" in result.stdout
assert "Ada" in result.stdout
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.vcr() @pytest.mark.vcr()
@ -498,59 +386,6 @@ class TestSandboxVFS:
assert result.success assert result.success
assert result.stdout.count("True") == 6 assert result.stdout.count("True") == 6
@pytest.mark.asyncio
async def test_chunks_jsonl(self, temp_db_path):
"""chunks.jsonl lists a document's chunks in order with their stored
metadata; a chunk found by its metadata leads to its items through
their chunk_ids."""
from docling_core.types.doc.document import DoclingDocument
from docling_core.types.doc.labels import DocItemLabel
config = AppConfig()
dim = config.embeddings.model.vector_dim
docling = DoclingDocument(name="d")
docling.add_text(label=DocItemLabel.TEXT, text="Paragraph thirteen.")
docling.add_text(label=DocItemLabel.TEXT, text="Paragraph fourteen.")
async with HaikuRAG(temp_db_path, create=True) as client:
doc = await client.import_document(
docling,
[
Chunk(
content="Paragraph thirteen.",
embedding=[0.1] * dim,
order=0,
metadata={"para_no": "13", "doc_item_refs": ["#/texts/0"]},
),
Chunk(
content="Paragraph fourteen.",
embedding=[0.1] * dim,
order=1,
metadata={"para_no": "14", "doc_item_refs": ["#/texts/1"]},
),
],
uri="test://paras",
)
sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext())
try:
result = await sb.execute(
"from pathlib import Path\n"
"import json\n"
f"root = Path('/documents/{doc.id}')\n"
"def rows(name):\n"
" return [json.loads(l) for l in (root / name).read_text().strip().split('\\n')]\n"
"chunks = rows('chunks.jsonl')\n"
"print(len(chunks))\n"
"hit = [c for c in chunks if c['metadata'].get('para_no') == '14']\n"
"print(len(hit))\n"
"items = rows('items.jsonl')\n"
"print([i['text'] for i in items if hit[0]['chunk_id'] in i['chunk_ids']])"
)
finally:
await sb.close()
assert result.success, result.stderr
assert result.stdout.splitlines() == ["2", "1", "['Paragraph fourteen.']"]
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.vcr() @pytest.mark.vcr()
async def test_open_read(self, temp_db_path): async def test_open_read(self, temp_db_path):
@ -598,8 +433,7 @@ class TestSandboxVFS:
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize( @pytest.mark.parametrize(
"filename", "filename", ["content.txt", "items.jsonl", "toc.json", "metadata.json"]
["content.txt", "items.jsonl", "chunks.jsonl", "toc.json", "metadata.json"],
) )
async def test_write_denied_for_every_document_file(self, temp_db_path, filename): async def test_write_denied_for_every_document_file(self, temp_db_path, filename):
"""Every file in the document VFS is read-only, metadata.json included.""" """Every file in the document VFS is read-only, metadata.json included."""
@ -963,61 +797,6 @@ class TestSandboxReadDeadline:
cannot check its duration budget while one is in flight. The sandbox cannot check its duration budget while one is in flight. The sandbox
enforces the budget itself, before each read.""" enforces the budget itself, before each read."""
@pytest.mark.asyncio
async def test_the_deadline_covers_reads_from_memory_and_in_code_calls(
self, temp_db_path, monkeypatch
):
"""Once a call's time is up, a file served from memory and an in-code
listing are refused like a database read. A slow first read spends the
budget; the watchdog does not count time spent waiting on the host."""
from docling_core.types.doc.document import DoclingDocument
from docling_core.types.doc.labels import DocItemLabel
config = AppConfig()
config.analysis.code_timeout = 1.0
docling = DoclingDocument(name="d")
docling.add_text(label=DocItemLabel.TEXT, text="Foxes and dogs.")
async with HaikuRAG(temp_db_path, create=True) as client:
doc = await client.import_document(
docling,
[
Chunk(
content="Foxes and dogs.",
embedding=[0.1] * config.embeddings.model.vector_dim,
order=0,
)
],
uri="test://deadline-paths",
)
repository = type(client.document_repository)
async def slow_content(self, *args, **kwargs):
await asyncio.sleep(1.3)
return "body"
monkeypatch.setattr(repository, "get_content", slow_content)
sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext())
try:
result = await sb.execute(
"from pathlib import Path\n"
f"root = Path('/documents/{doc.id}')\n"
"print(len((root / 'content.txt').read_text()))\n"
"try:\n"
" (root / 'metadata.json').read_text()\n"
" print('static: read')\n"
"except Exception as e:\n"
" print('static:', type(e).__name__)\n"
"await list_documents()\n"
"print('listed')"
)
finally:
await sb.close()
assert "static: TimeoutError" in result.stdout
assert "listed" not in result.stdout
assert not result.success
assert "time limit exceeded" in result.stderr
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_read_after_deadline_raises_without_scheduling(self, sandbox): async def test_read_after_deadline_raises_without_scheduling(self, sandbox):
"""A read attempted past the deadline fails instead of querying.""" """A read attempted past the deadline fails instead of querying."""
@ -1046,51 +825,7 @@ class TestSandboxReadDeadline:
sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext()) sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext())
limits = sb._session_limits() assert sb._session_limits() == {"max_duration_secs": 15.0}
assert limits["max_duration_secs"] == 15.0
cap = limits["max_suspensions"]
assert cap is not None
assert cap >= 1_000_000
@pytest.mark.asyncio
async def test_a_program_may_read_more_than_a_thousand_times(self, temp_db_path):
"""Monty caps host callbacks per checkout at 1000 unless told otherwise;
a corpus-wide pass over documents reads far more than that."""
from docling_core.types.doc.document import DoclingDocument
from docling_core.types.doc.labels import DocItemLabel
config = AppConfig()
docling = DoclingDocument(name="d")
docling.add_text(label=DocItemLabel.TEXT, text="Foxes and dogs.")
async with HaikuRAG(temp_db_path, create=True) as client:
doc = await client.import_document(
docling,
[
Chunk(
content="Foxes and dogs.",
embedding=[0.1] * config.embeddings.model.vector_dim,
order=0,
)
],
uri="test://many-reads",
)
sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext())
try:
result = await sb.execute(
"from pathlib import Path\n"
f"p = Path('/documents/{doc.id}/content.txt')\n"
"n = 0\n"
"for i in range(1100):\n"
" n += len(p.read_text())\n"
"print(n)"
)
finally:
await sb.close()
assert result.success, result.stderr
assert result.stdout.strip() == str(1100 * len("Foxes and dogs."))
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_refused_read_fails_the_execution(self, temp_db_path, monkeypatch): async def test_refused_read_fails_the_execution(self, temp_db_path, monkeypatch):

View file

@ -16,7 +16,6 @@ import pytest
from haiku.rag.client import HaikuRAG from haiku.rag.client import HaikuRAG
from haiku.rag.config.models import AppConfig from haiku.rag.config.models import AppConfig
from haiku.rag.sandbox import AnalysisContext, Sandbox from haiku.rag.sandbox import AnalysisContext, Sandbox
from haiku.rag.store.models.chunk import Chunk
from haiku.rag.store.models.document import Document from haiku.rag.store.models.document import Document
from haiku.rag.store.models.document_item import DocumentItem from haiku.rag.store.models.document_item import DocumentItem
@ -435,66 +434,6 @@ class TestVfsReadPaths:
"nope", "nope",
) )
async def test_chunks_jsonl_lists_chunks_in_order_with_their_metadata(
self, temp_db_path
):
"""One row per chunk, in chunk order, carrying the stored metadata as
is; the second read of a document is served from the sandbox's cache."""
config = AppConfig()
dim = config.embeddings.model.vector_dim
async with HaikuRAG(temp_db_path, create=True) as client:
doc_id = await _empty_doc(client, uri="test://paras", title="Paras")
for order, para_no in enumerate(["13", "14"]):
await client.chunk_repository.create(
Chunk(
document_id=doc_id,
content=f"Paragraph {para_no}.",
embedding=[0.1] * dim,
order=order,
metadata={"para_no": para_no, "doc_item_refs": []},
)
)
sandbox = Sandbox(temp_db_path, config, AnalysisContext())
first = await _read_vfs_text(sandbox, f"/documents/{doc_id}/chunks.jsonl")
rows = [json.loads(line) for line in first.split("\n")]
assert [row["metadata"]["para_no"] for row in rows] == ["13", "14"]
assert all(set(row) == {"chunk_id", "metadata"} for row in rows)
assert rows[0]["metadata"] == {"para_no": "13", "doc_item_refs": []}
assert sandbox._chunks_jsonl_cache[doc_id] == first
assert (
await _read_vfs_text(sandbox, f"/documents/{doc_id}/chunks.jsonl") == first
)
async def test_item_range_is_a_line_slice_into_items_jsonl(self, temp_db_path):
"""`item_range` indexes lines of items.jsonl, as documented, not item
positions: a gap in positions must not pull the next heading into a
section."""
async with HaikuRAG(temp_db_path, create=True) as client:
doc_id = await _empty_doc(client, uri="test://slice", title="Slice")
items = [
_header(doc_id, 0, 1, "Intro"),
_para(doc_id, 1),
_header(doc_id, 3, 1, "Methods"),
_para(doc_id, 4),
]
await client.document_item_repository.create_items(doc_id, items)
sandbox = Sandbox(temp_db_path, AppConfig(), AnalysisContext())
toc = await _read_toc(sandbox, doc_id)
raw = await _read_vfs_text(sandbox, f"/documents/{doc_id}/items.jsonl")
lines = raw.split("\n")
intro, methods = toc["tree"]
assert intro["item_range"] == [0, 2]
assert methods["item_range"] == [2, 4]
start, end = intro["item_range"]
assert [json.loads(line)["self_ref"] for line in lines[start:end]] == [
"#/texts/0",
"#/texts/1",
]
async def test_toc_skips_gaps_in_item_positions(self, temp_db_path): async def test_toc_skips_gaps_in_item_positions(self, temp_db_path):
"""Positions need not be contiguous — a heading's span may cover """Positions need not be contiguous — a heading's span may cover
positions that carry no item.""" positions that carry no item."""

View file

@ -31,6 +31,10 @@ def client():
@pytest.fixture @pytest.fixture
def app(tmp_path, client, monkeypatch): def app(tmp_path, client, monkeypatch):
class StubHaikuRAG: class StubHaikuRAG:
# run_mcp passes db_path positionally; every other caller uses kwargs.
def __init__(self, *args, **kwargs):
pass
@classmethod @classmethod
def _covering(cls, *args, **kwargs): def _covering(cls, *args, **kwargs):
return cls() return cls()

View file

@ -1,41 +0,0 @@
import importlib.util
import json
from pathlib import Path
_spec = importlib.util.spec_from_file_location(
"bump_version", Path(__file__).resolve().parents[1] / "scripts" / "bump_version.py"
)
assert _spec is not None and _spec.loader is not None
bump_version = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(bump_version)
def test_update_plugin_version_rewrites_only_the_version_field(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
manifest = tmp_path / "plugin.json"
manifest.write_text(
'{\n "name": "haiku-rag",\n "version": "0.1.0",\n "license": "MIT"\n}\n'
)
bump_version.update_plugin_version(manifest, "0.2.0")
assert json.loads(manifest.read_text()) == {
"name": "haiku-rag",
"version": "0.2.0",
"license": "MIT",
}
assert manifest.read_text().endswith("}\n")
def test_the_shipped_plugin_manifests_carry_the_package_version():
root = Path(__file__).resolve().parents[1]
package_version = bump_version.get_current_version(
root / "haiku_rag_slim" / "pyproject.toml"
)
for client in ("claude", "codex"):
manifest = json.loads(
(
root / "plugins" / "haiku-rag" / f".{client}-plugin" / "plugin.json"
).read_text()
)
assert manifest["version"] == package_version

View file

@ -264,37 +264,6 @@ def test_search_result_format_for_agent_omits_chunk_meta():
assert "para_no" not in formatted assert "para_no" not in formatted
def test_search_result_format_for_agent_chunk_meta_is_opt_in():
"""A caller that asks sees the chunk's own metadata, never the structural
keys haiku.rag stores beside it."""
result = SearchResult(
content="Some content.",
score=0.9,
chunk_id="chunk-1",
chunk_meta={
"para_no": "12",
"doc_item_refs": ["#/texts/0"],
"page_numbers": [1],
"headings": ["Intro"],
"labels": ["paragraph"],
},
)
opted = result.format_for_agent(rank=1, total=1, include_chunk_meta=True)
assert "para_no" in opted
assert "12" in opted
assert "doc_item_refs" not in opted
assert "#/texts/0" not in opted
structural_only = result.model_copy(
update={"chunk_meta": {"doc_item_refs": ["#/texts/0"], "page_numbers": [1]}}
)
assert structural_only.format_for_agent(
rank=1, total=1, include_chunk_meta=True
) == structural_only.format_for_agent(rank=1, total=1)
def test_search_result_format_for_agent_omits_document_meta(): def test_search_result_format_for_agent_omits_document_meta():
"""Document metadata is UI plumbing, never shown to the model.""" """Document metadata is UI plumbing, never shown to the model."""
result = SearchResult( result = SearchResult(
@ -445,17 +414,6 @@ def test_search_result_format_for_agent_source_line(fields, expected_source):
assert expected_source in result.format_for_agent() assert expected_source in result.format_for_agent()
def test_search_result_format_for_agent_document_id_is_opt_in():
"""The capabilities' rendering is unchanged; only a caller that asks gets
the id it will fetch the document by."""
result = SearchResult(content="x", score=0.5, chunk_id="c1", document_id="doc-1")
assert "Document ID" not in result.format_for_agent(rank=1, total=1)
assert "Document ID: doc-1" in result.format_for_agent(
rank=1, total=1, include_document_id=True
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"labels,expected", "labels,expected",
[ [

View file

@ -1050,21 +1050,6 @@ def test_mcp_without_stdio_leaves_the_transport_unset(app_stub):
assert app_stub.run_mcp.call_args.kwargs["transport"] is None assert app_stub.run_mcp.call_args.kwargs["transport"] is None
def test_mcp_covers_the_configured_set(monkeypatch):
seen = {}
def create_app(db=None, *, covers_set=False):
seen["covers_set"] = covers_set
return AsyncMock()
monkeypatch.setattr("haiku.rag.cli.create_app", create_app)
result = runner.invoke(cli, ["mcp", "--stdio"])
assert result.exit_code == 0, result.output
assert seen["covers_set"] is True
def test_version_flag_prints_the_version(): def test_version_flag_prints_the_version():
result = runner.invoke(cli, ["--version"]) result = runner.invoke(cli, ["--version"])

View file

@ -15,11 +15,7 @@ from docling_core.types.doc.document import DoclingDocument
from haiku.rag.config import AppConfig from haiku.rag.config import AppConfig
from haiku.rag.config.models import ModelConfig from haiku.rag.config.models import ModelConfig
from haiku.rag.converters import docling_local, get_converter from haiku.rag.converters import docling_local, get_converter
from haiku.rag.converters.base import ( from haiku.rag.converters.base import vlm_api_headers, vlm_api_url
vlm_api_headers,
vlm_api_params,
vlm_api_url,
)
from haiku.rag.converters.docling_local import DoclingLocalConverter from haiku.rag.converters.docling_local import DoclingLocalConverter
from haiku.rag.converters.docling_serve import DoclingServeConverter from haiku.rag.converters.docling_serve import DoclingServeConverter
from haiku.rag.converters.text_utils import TextFileHandler, docling_safe_name from haiku.rag.converters.text_utils import TextFileHandler, docling_safe_name
@ -54,32 +50,6 @@ class TestVlmApiUrl:
vlm_api_url(AppConfig(), ModelConfig(provider="unsupported", name="test")) vlm_api_url(AppConfig(), ModelConfig(provider="unsupported", name="test"))
class TestVlmApiParams:
"""Request body docling posts alongside the picture."""
def test_thinking_unset_sends_no_effort(self):
params = vlm_api_params(ModelConfig(provider="ollama", name="qwen3.8"), 200)
assert params == {"model": "qwen3.8", "max_completion_tokens": 200}
def test_thinking_off_sends_none(self):
params = vlm_api_params(
ModelConfig(provider="ollama", name="qwen3.8", enable_thinking=False), 200
)
assert params["reasoning_effort"] == "none"
def test_thinking_off_sends_gpt_oss_floor(self):
params = vlm_api_params(
ModelConfig(provider="ollama", name="gpt-oss", enable_thinking=False), 200
)
assert params["reasoning_effort"] == "low"
def test_thinking_on_sends_high(self):
params = vlm_api_params(
ModelConfig(provider="ollama", name="qwen3.8", enable_thinking=True), 200
)
assert params["reasoning_effort"] == "high"
class TestVlmApiHeaders: class TestVlmApiHeaders:
"""Auth headers for picture-description VLM models.""" """Auth headers for picture-description VLM models."""
@ -878,7 +848,7 @@ class TestDoclingLocalConverter:
""" """
from haiku.rag.converters.pdf_split import convert_pdf_with_splitting from haiku.rag.converters.pdf_split import convert_pdf_with_splitting
pdf_path = Path(__file__).parent / "data" / "doclaynet.pdf" pdf_path = Path("tests/data/doclaynet.pdf")
config.processing.conversion_options.do_ocr = False config.processing.conversion_options.do_ocr = False
converter = DoclingLocalConverter(config) converter = DoclingLocalConverter(config)
@ -1019,7 +989,7 @@ class TestDoclingLocalConverter:
pic_desc = config.processing.conversion_options.picture_description pic_desc = config.processing.conversion_options.picture_description
assert config.processing.pictures == "image" assert config.processing.pictures == "image"
assert pic_desc.model.provider == "ollama" assert pic_desc.model.provider == "ollama"
assert pic_desc.model.name == "qwen3.8" assert pic_desc.model.name == "ministral-3"
assert pic_desc.timeout == 90 assert pic_desc.timeout == 90
assert pic_desc.max_tokens == 200 assert pic_desc.max_tokens == 200
# Default prompt is in PromptsConfig # Default prompt is in PromptsConfig

View file

@ -163,7 +163,7 @@ def _stub_provider_probe(monkeypatch):
{ {
"models": [ "models": [
{"name": "test"}, {"name": "test"},
{"name": "qwen3.8:latest"}, {"name": "gpt-oss:latest"},
{"name": "qwen3-embedding:4b"}, {"name": "qwen3-embedding:4b"},
] ]
}, },
@ -751,11 +751,14 @@ def test_api_key_not_required_when_config_supplies_it():
def test_active_models_includes_picture_description_when_enabled(): def test_active_models_includes_picture_description_when_enabled():
base = _active_models(AppConfig()) config = AppConfig(processing=ProcessingConfig(pictures="description"))
with_pictures = _active_models( names = [model.name for model in _active_models(config)]
AppConfig(processing=ProcessingConfig(pictures="description")) assert "ministral-3" in names
)
assert len(with_pictures) == len(base) + 1
def test_active_models_excludes_picture_description_by_default():
names = [model.name for model in _active_models(AppConfig())]
assert "ministral-3" not in names
def test_active_models_includes_title_model_when_auto_title(): def test_active_models_includes_title_model_when_auto_title():
@ -843,7 +846,7 @@ def test_provider_targets_default_groups_ollama_models():
assert len(targets) == 1 assert len(targets) == 1
entry = next(iter(targets.values())) entry = next(iter(targets.values()))
assert entry["kind"] == "ollama" assert entry["kind"] == "ollama"
assert {"qwen3-embedding:4b", "qwen3.8"} <= entry["models"] assert {"qwen3-embedding:4b", "gpt-oss"} <= entry["models"]
def test_provider_targets_includes_docling_serve(): def test_provider_targets_includes_docling_serve():
@ -919,7 +922,7 @@ async def test_provider_check_ok_when_models_present(monkeypatch):
{ {
"models": [ "models": [
{"name": "qwen3-embedding:4b"}, {"name": "qwen3-embedding:4b"},
{"name": "qwen3.8:latest"}, {"name": "gpt-oss:latest"},
] ]
}, },
) )
@ -957,7 +960,7 @@ async def test_provider_check_fails_when_unreachable(monkeypatch):
async def test_provider_check_reports_local_provider(monkeypatch): async def test_provider_check_reports_local_provider(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
"haiku.rag.doctor._probe_endpoint", "haiku.rag.doctor._probe_endpoint",
_fake_probe((True, None, {"models": [{"name": "qwen3.8:latest"}]})), _fake_probe((True, None, {"models": [{"name": "gpt-oss:latest"}]})),
) )
config = AppConfig( config = AppConfig(
embeddings=EmbeddingsConfig( embeddings=EmbeddingsConfig(
@ -978,7 +981,7 @@ async def test_run_doctor_includes_provider_results(temp_db_path, monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
"haiku.rag.doctor._probe_endpoint", "haiku.rag.doctor._probe_endpoint",
_fake_probe( _fake_probe(
(True, None, {"models": [{"name": "test"}, {"name": "qwen3.8:latest"}]}) (True, None, {"models": [{"name": "test"}, {"name": "gpt-oss:latest"}]})
), ),
) )
report = await run_doctor(_config(), temp_db_path, {}) report = await run_doctor(_config(), temp_db_path, {})

View file

@ -77,8 +77,8 @@ async def test_download_models_ollama_pulls_models(mock_to_thread):
async for progress in download_models(get_config()): async for progress in download_models(get_config()):
events.append(progress) events.append(progress)
# Default config has embeddings=qwen3-embedding:4b, qa=qwen3.8 # Default config has embeddings=qwen3-embedding:4b, qa=gpt-oss
ollama_models = {"qwen3.8", "qwen3-embedding:4b"} ollama_models = {"gpt-oss", "qwen3-embedding:4b"}
ollama_events = [e for e in events if e.model in ollama_models] ollama_events = [e for e in events if e.model in ollama_models]
pulling_events = [e for e in ollama_events if e.status == "pulling"] pulling_events = [e for e in ollama_events if e.status == "pulling"]
done_events = [e for e in ollama_events if e.status == "done"] done_events = [e for e in ollama_events if e.status == "done"]
@ -108,7 +108,7 @@ async def test_download_models_no_ollama_models(mock_to_thread):
models = {e.model for e in events} models = {e.model for e in events}
assert "qwen3-embedding:4b" not in models assert "qwen3-embedding:4b" not in models
assert "qwen3.8" not in models assert "gpt-oss" not in models
@pytest.mark.parametrize( @pytest.mark.parametrize(

File diff suppressed because it is too large Load diff

View file

@ -837,7 +837,7 @@ async def test_ingest_emits_picture_chunks_with_multimodal_embedder(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_search_tool_skips_binary_content_when_qa_model_is_text_only(): async def test_search_tool_skips_binary_content_when_qa_model_is_text_only():
"""The agent search tool must NOT attach picture bytes when the QA model """The agent search tool must NOT attach picture bytes when the QA model
is text-only (``qa.model.vision = False``). Sending image is text-only (``qa.model.vision = False``, the default). Sending image
parts to a text-only model would cause it to hallucinate confidently parts to a text-only model would cause it to hallucinate confidently
Ollama silently accepts the bytes and the model guesses.""" Ollama silently accepts the bytes and the model guesses."""
@ -856,7 +856,8 @@ async def test_search_tool_skips_binary_content_when_qa_model_is_text_only():
fake_client.expand_context = AsyncMock(return_value=[picture_result]) fake_client.expand_context = AsyncMock(return_value=[picture_result])
config = AppConfig() config = AppConfig()
config.qa.model.vision = False # vision defaults to False; assert anyway so the test reads explicitly.
assert config.qa.model.vision is False
toolset = create_search_toolset(config, expand_context=False) toolset = create_search_toolset(config, expand_context=False)
func = toolset.tools["search"].function func = toolset.tools["search"].function

View file

@ -154,14 +154,6 @@ Emoji test: 🚀 ✅ 📝"""
{"provider": "ollama", "name": "gpt-oss", "enable_thinking": True}, {"provider": "ollama", "name": "gpt-oss", "enable_thinking": True},
{"openai_reasoning_effort": "high"}, {"openai_reasoning_effort": "high"},
), ),
(
{"provider": "ollama", "name": "qwen3.8", "enable_thinking": False},
{"openai_reasoning_effort": "none"},
),
(
{"provider": "ollama", "name": "qwen3.8", "enable_thinking": True},
{"openai_reasoning_effort": "high"},
),
( (
{ {
"provider": "ollama", "provider": "ollama",
@ -196,8 +188,6 @@ Emoji test: 🚀 ✅ 📝"""
"ollama", "ollama",
"ollama_thinking_off", "ollama_thinking_off",
"ollama_thinking_on", "ollama_thinking_on",
"ollama_other_thinking_off",
"ollama_other_thinking_on",
"ollama_with_settings", "ollama_with_settings",
"openai", "openai",
"openai_reasoning_thinking_on", "openai_reasoning_thinking_on",
@ -662,6 +652,117 @@ def test_format_bytes():
assert format_bytes(1125899906842624) == "1.0 PB" assert format_bytes(1125899906842624) == "1.0 PB"
# --- format_citations tests ---
def test_format_citations_empty():
from haiku.rag.utils import format_citations
assert format_citations([]) == ""
def test_format_citations_with_citation():
from haiku.rag.store.models.citation import Citation
from haiku.rag.utils import format_citations
citation = Citation(
document_id="doc1",
chunk_id="chunk1",
document_uri="test://doc",
document_title="Test Doc",
content="Some content",
page_numbers=[1],
headings=["Intro"],
)
result = format_citations([citation])
assert "[1] Test Doc" in result
assert "doc1" not in result
assert "chunk1" not in result
assert "test://doc" in result
assert "p. 1" in result
assert "Section: Intro" in result
assert "Some content" in result
def test_format_citations_multiple_pages():
from haiku.rag.store.models.citation import Citation
from haiku.rag.utils import format_citations
citation = Citation(
document_id="doc1",
chunk_id="chunk1",
document_uri="test://doc",
content="Content",
page_numbers=[1, 2, 3],
)
result = format_citations([citation])
assert "[1] test://doc" in result
assert "pp. 1-3" in result
# No title: the URI stands in, and the document id never leaks.
assert "doc1" not in result
def test_format_citations_with_index():
from haiku.rag.store.models.citation import Citation
from haiku.rag.utils import format_citations
citation = Citation(
index=5,
document_id="doc1",
chunk_id="chunk1",
document_uri="test://doc",
document_title="Test Doc",
content="Content",
)
result = format_citations([citation])
assert "[5] Test Doc" in result
def test_format_citations_sequential_indices():
from haiku.rag.store.models.citation import Citation
from haiku.rag.utils import format_citations
citations = [
Citation(
document_id="doc1",
chunk_id="chunk1",
document_uri="test://doc1",
document_title="First",
content="Content 1",
),
Citation(
document_id="doc2",
chunk_id="chunk2",
document_uri="test://doc2",
document_title="Second",
content="Content 2",
),
]
result = format_citations(citations)
assert "[1] First" in result
assert "[2] Second" in result
# --- format_citations tests (pictures) ---
def test_format_citations_picture_refs_render_as_markers():
from haiku.rag.store.models.citation import Citation
from haiku.rag.utils import format_citations
citation = Citation(
document_id="doc1",
chunk_id="chunk1",
document_uri="test://doc",
document_title="Test Doc",
content="text body",
picture_refs=["#/pictures/0", "#/pictures/3"],
)
result = format_citations([citation])
assert "[Figure: #/pictures/0]" in result
assert "[Figure: #/pictures/3]" in result
# --- format_citations_rich tests --- # --- format_citations_rich tests ---
@ -702,22 +803,6 @@ async def test_format_citations_rich_header_and_footer():
assert "chunk: chunk-uuid-1" in output assert "chunk: chunk-uuid-1" in output
async def test_format_citations_rich_names_a_single_page():
from haiku.rag.store.models.citation import Citation
from haiku.rag.utils import format_citations_rich
citation = Citation(
document_id="doc1",
chunk_id="chunk1",
document_uri="test://doc",
content="Body",
page_numbers=[3],
)
output = _render_rich(await format_citations_rich([citation]))
assert "p. 3" in output
assert "pp." not in output
async def test_format_citations_rich_names_the_database_when_federating(): async def test_format_citations_rich_names_the_database_when_federating():
"""Across databases, a citation has to say which one it came from.""" """Across databases, a citation has to say which one it came from."""
from unittest.mock import AsyncMock from unittest.mock import AsyncMock

204
uv.lock
View file

@ -1209,22 +1209,21 @@ wheels = [
[[package]] [[package]]
name = "fastmcp" name = "fastmcp"
version = "4.0.2" version = "3.3.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "fastmcp-slim", extra = ["client", "server"] }, { name = "fastmcp-slim", extra = ["client", "server"] },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/37/1c/981a1854f91a08872f4b8b9a627d5d751cafc1340d29b21b747f0b520b0a/fastmcp-4.0.2.tar.gz", hash = "sha256:60d5c5ead3b6a117bfada5c0f95fe5c1aba53d1577079ecbdf42eeff0cd9b931", size = 42306015, upload-time = "2026-09-02T23:28:08.386Z" } sdist = { url = "https://files.pythonhosted.org/packages/3b/a9/5c5a01b6abd5346bf60b97cfd29e4a86661940c27dd562bfcda07fd03519/fastmcp-3.3.1.tar.gz", hash = "sha256:979362ea557de42a5f40342563c7e4b236bcc8e7cd192715f50030695d1a71cd", size = 28681699, upload-time = "2026-05-15T15:50:39.673Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/3f/b97cfb92e0d6db8232c67c258117cd0dd9def86c8b472270bd7196d5cd9d/fastmcp-4.0.2-py3-none-any.whl", hash = "sha256:9075e64a94634ad660971ed14374c87be06f2a16a921028ca87987e6aa2f3bfa", size = 8078, upload-time = "2026-09-02T23:28:03.777Z" }, { url = "https://files.pythonhosted.org/packages/9f/11/6b1bdada6ccfe647d615ae63f9106f8136aec17971e9361546af01c7d38e/fastmcp-3.3.1-py3-none-any.whl", hash = "sha256:862440c5c4d281363a5995eee59d77f0f7cac1f18869038729cecf03b02fc522", size = 7903, upload-time = "2026-05-15T15:50:36.424Z" },
] ]
[[package]] [[package]]
name = "fastmcp-slim" name = "fastmcp-slim"
version = "4.0.2" version = "3.3.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "mcp-types" },
{ name = "platformdirs" }, { name = "platformdirs" },
{ name = "pydantic", extra = ["email"] }, { name = "pydantic", extra = ["email"] },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
@ -1232,28 +1231,26 @@ dependencies = [
{ name = "rich" }, { name = "rich" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/9c/7d/c2597734e3a0859d62c9d8f6f35067d1e296537512e280db3af19204be64/fastmcp_slim-4.0.2.tar.gz", hash = "sha256:86b99bdcb872b52d964c79bc6d43ce79f40ed5538b589d102792b4a7cf3947f4", size = 684052, upload-time = "2026-09-02T23:27:39.868Z" } sdist = { url = "https://files.pythonhosted.org/packages/d1/a0/627103e517e1d0d6f1eec633d5662d13e776f01b45ad188e4f5f7478b438/fastmcp_slim-3.3.1.tar.gz", hash = "sha256:0957835fc59452e143ab2f4b7836d2d2df9b2d9958408edc79ba8b56232b2a88", size = 567007, upload-time = "2026-05-15T15:50:10.426Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/c0/c022eba3a25ebb56111de1b5f76fbfca81925def58880464263582acfcd8/fastmcp_slim-4.0.2-py3-none-any.whl", hash = "sha256:6bd5b5885628f73263fa2247ea1d26e4a514499a6e079ee3e340cd03a7fe5ed8", size = 858100, upload-time = "2026-09-02T23:27:38.459Z" }, { url = "https://files.pythonhosted.org/packages/7a/ee/97047f4cc2d7b1d46670d08d8ad01a96e7a748cc01c0b4b351ad8eddbc7a/fastmcp_slim-3.3.1-py3-none-any.whl", hash = "sha256:6cf1c2d77e3adb0d409d6825ed6b0b2a999062973e00b8eea03bd48bf9b4c043", size = 738644, upload-time = "2026-05-15T15:50:08.336Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
client = [ client = [
{ name = "authlib" }, { name = "authlib" },
{ name = "exceptiongroup" }, { name = "exceptiongroup" },
{ name = "httpx2" }, { name = "httpx" },
{ name = "mcp" }, { name = "mcp" },
{ name = "opentelemetry-api" }, { name = "opentelemetry-api" },
{ name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
{ name = "starlette" },
] ]
server = [ server = [
{ name = "authlib" }, { name = "authlib" },
{ name = "cyclopts" }, { name = "cyclopts" },
{ name = "exceptiongroup" }, { name = "exceptiongroup" },
{ name = "griffelib" }, { name = "griffelib" },
{ name = "httpx2" }, { name = "httpx" },
{ name = "joserfc" },
{ name = "jsonref" }, { name = "jsonref" },
{ name = "jsonschema-path" }, { name = "jsonschema-path" },
{ name = "mcp" }, { name = "mcp" },
@ -1264,7 +1261,6 @@ server = [
{ name = "pyperclip" }, { name = "pyperclip" },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "starlette" },
{ name = "uncalled-for" }, { name = "uncalled-for" },
{ name = "uvicorn" }, { name = "uvicorn" },
{ name = "watchfiles" }, { name = "watchfiles" },
@ -1759,7 +1755,7 @@ requires-dist = [
{ name = "docling", marker = "extra == 'docling'", specifier = ">=2.102.2,<3.0.0" }, { name = "docling", marker = "extra == 'docling'", specifier = ">=2.102.2,<3.0.0" },
{ name = "docling-core", specifier = ">=2.82.0,<3.0.0" }, { name = "docling-core", specifier = ">=2.82.0,<3.0.0" },
{ name = "fastapi", marker = "extra == 'ingester'", specifier = ">=0.125" }, { name = "fastapi", marker = "extra == 'ingester'", specifier = ">=0.125" },
{ name = "fastmcp", specifier = ">=4.0.2,<5.0.0" }, { name = "fastmcp", specifier = ">=3.3.0" },
{ name = "haiku-rag-slim", extras = ["s3"], marker = "extra == 'ingester'", editable = "haiku_rag_slim" }, { name = "haiku-rag-slim", extras = ["s3"], marker = "extra == 'ingester'", editable = "haiku_rag_slim" },
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "jinja2", specifier = ">=3.1.0" }, { name = "jinja2", specifier = ">=3.1.0" },
@ -1776,7 +1772,7 @@ requires-dist = [
{ name = "pydantic-ai-slim", extras = ["mistral"], marker = "extra == 'mistral'" }, { name = "pydantic-ai-slim", extras = ["mistral"], marker = "extra == 'mistral'" },
{ name = "pydantic-ai-slim", extras = ["openai", "logfire", "ag-ui"], specifier = ">=2.18.0,<3.0.0" }, { name = "pydantic-ai-slim", extras = ["openai", "logfire", "ag-ui"], specifier = ">=2.18.0,<3.0.0" },
{ name = "pydantic-ai-slim", extras = ["voyageai"], marker = "extra == 'voyageai'" }, { name = "pydantic-ai-slim", extras = ["voyageai"], marker = "extra == 'voyageai'" },
{ name = "pydantic-monty", specifier = ">=0.0.23" }, { name = "pydantic-monty", specifier = ">=0.0.19" },
{ name = "pypdfium2", specifier = ">=5.0" }, { name = "pypdfium2", specifier = ">=5.0" },
{ name = "python-dotenv", specifier = ">=1.2.2" }, { name = "python-dotenv", specifier = ">=1.2.2" },
{ name = "pyyaml", specifier = ">=6.0.3" }, { name = "pyyaml", specifier = ">=6.0.3" },
@ -1891,6 +1887,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
] ]
[[package]]
name = "httpx-sse"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
]
[[package]] [[package]]
name = "httpx2" name = "httpx2"
version = "2.8.0" version = "2.8.0"
@ -2585,15 +2590,15 @@ wheels = [
[[package]] [[package]]
name = "mcp" name = "mcp"
version = "2.1.1" version = "1.28.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio" },
{ name = "httpx2" }, { name = "httpx" },
{ name = "httpx-sse" },
{ name = "jsonschema" }, { name = "jsonschema" },
{ name = "mcp-types" },
{ name = "opentelemetry-api" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] }, { name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "pywin32", marker = "sys_platform == 'win32'" },
@ -2603,22 +2608,9 @@ dependencies = [
{ name = "typing-inspection" }, { name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/d4/6e/21fb8e5d579dbe21d96ea4d5034200d46d8bdf2261053b5bd041f3c2f612/mcp-2.1.1.tar.gz", hash = "sha256:50b7ba1ebbe117008ea7bdd288234043e69c20b403d6851d19661e6d431a75ef", size = 3984589, upload-time = "2026-08-25T16:14:02.376Z" } sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/50/af/8644cc5fa26a59afd2df2e98eeb19e72926887fa4b7441aba4ff661140db/mcp-2.1.1-py3-none-any.whl", hash = "sha256:1c6c31c5d6471c58db76af3af8af67f46d11d01f0a59077d0a308cbdb3d3e915", size = 357912, upload-time = "2026-08-25T16:13:59.024Z" }, { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" },
]
[[package]]
name = "mcp-types"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/dd/1c4417dc0b722c23a1669032d5f044e41170fe5d4773b488a50fcce98c32/mcp_types-2.1.1.tar.gz", hash = "sha256:77dcbe48fba73cca71a673f2646a5f037a017b7a0a07ac89cec1113028890eda", size = 66674, upload-time = "2026-08-25T16:14:03.861Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/d0/242e63c510f4a17381f55b1549a3f94f5687a0595984febd2b6f87a687a0/mcp_types-2.1.1-py3-none-any.whl", hash = "sha256:26f9f7f03f2a5730717a5b98e2ab7eb640ac352d05a00cdc725c311864778295", size = 69656, upload-time = "2026-08-25T16:14:00.667Z" },
] ]
[[package]] [[package]]
@ -3991,82 +3983,94 @@ wheels = [
[[package]] [[package]]
name = "pydantic-monty" name = "pydantic-monty"
version = "0.0.23" version = "0.0.19"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic-monty-client" },
{ name = "pydantic-monty-runtime" }, { name = "pydantic-monty-runtime" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/01/cd7927f51500a13c1661e2db6488f6ff668194c4378d0fb218928892aaba/pydantic_monty-0.0.23.tar.gz", hash = "sha256:ee674b81ed12f81cfbe0db210fe5e803c754d0682634331931f4d70d5742058d", size = 6713, upload-time = "2026-09-05T19:26:03.24Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/b3/a259a600df30a54ee9e576c90bb7cb6c2b2d4750774c91748922cc202c5e/pydantic_monty-0.0.23-py3-none-any.whl", hash = "sha256:cddcf7d4d7dd163b56e4411f450ea0244a7205988c77cd85441ec89fedaab91a", size = 6302, upload-time = "2026-09-05T19:24:01.829Z" },
]
[[package]]
name = "pydantic-monty-client"
version = "0.0.23"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/29/e9/dcbf9a90ccd195be0db5257c2234d002d551d47f9d8987543eddd3095812/pydantic_monty_client-0.0.23.tar.gz", hash = "sha256:8b31f75afebb60c0416c869c8d6667a75e84b0f05b7a0b44e51b77ef98f294d7", size = 1983536, upload-time = "2026-09-05T19:26:04.167Z" } sdist = { url = "https://files.pythonhosted.org/packages/51/f8/c04df414086488834d102ab85cf8172c114108f842fb142ac92a490213fd/pydantic_monty-0.0.19.tar.gz", hash = "sha256:f3f9e256058b4085349dd4ad347795d5203a8310e9eaad9a2bff93890ac5b86d", size = 1484032, upload-time = "2026-07-24T10:00:13.194Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f3/4e4975daee5ac4a86e95b08124e304cf7af774df50891b28479bb2c28ede/pydantic_monty_client-0.0.23-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7f309a03e75c418b34e469e547171059ebd1ebcd11971ed129b6905e2a7c057f", size = 3930126, upload-time = "2026-09-05T19:24:38.269Z" }, { url = "https://files.pythonhosted.org/packages/36/bf/152bbb3315dfa46d4e4aae71779230e50c67a34d859a3470fd75c01b795c/pydantic_monty-0.0.19-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b073e64edfd62cca918d792d6fe559512472f981f949e53a7aec673201f5f554", size = 2492733, upload-time = "2026-07-24T09:56:49.612Z" },
{ url = "https://files.pythonhosted.org/packages/6e/23/927d73209b509db9604606d88aaff90f56474bd9bdcfe9f39a873bfc945c/pydantic_monty_client-0.0.23-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e3efb7e2af60e17c16f3baa49ea3a2e2b90c88610666f294591b9819d3ccc69", size = 3702556, upload-time = "2026-09-05T19:24:39.81Z" }, { url = "https://files.pythonhosted.org/packages/46/16/9d37f1bf94c47c593a06ecb918bb64a2c8a38af24d6fa2b0d0e031938edf/pydantic_monty-0.0.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5825ae6c40270166f0f31b6e62d59b0fe47201d12b1be5842efc22d3b7dadcee", size = 2234610, upload-time = "2026-07-24T09:56:51.257Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c9/bfb5847d79e8bdeefbeea531b90f09e2c5f156bc361730975171a7e93146/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f065e8286378f26d29ceaeb2fb10712f202f45656967706675f93b3a17b99f78", size = 3728660, upload-time = "2026-09-05T19:24:41.129Z" }, { url = "https://files.pythonhosted.org/packages/82/4f/31732f4b9c2b6574eb564e420593b9be3f80d92440211970fab23e882bc5/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5b6821c0035ff2c02cf0f37823fb905890f7238ce923bbc3ca937740f9554836", size = 2303116, upload-time = "2026-07-24T09:56:52.581Z" },
{ url = "https://files.pythonhosted.org/packages/13/9c/50af7e5a67876cb7c421401ff5f3885ae4fbd60324fea3c25e5fd31926af/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_armv7l.whl", hash = "sha256:d3e353536016174f9e0aca289f7d3a81a07ac7574e2a610902152e0ebf5feb5a", size = 3425561, upload-time = "2026-09-05T19:24:42.539Z" }, { url = "https://files.pythonhosted.org/packages/21/2c/c21e6179361100dd8b9ad410df775d176a29e0b85f2ffc3144fd29840ee9/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_armv7l.whl", hash = "sha256:5fb4514d99a10e304237a82baeb6072e934ce8462dcd5fb5c3fea0ee0ef16aeb", size = 2007947, upload-time = "2026-07-24T09:56:54.006Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e3/1d1a53f72576c190ea2f1a6c0fb5eeb722bea6901df8ddfba294ff49b08b/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:ef186bfe5344a84515cd51dc9ee5914084b1d616fad2409328d8edfbfb4d5ac3", size = 3633369, upload-time = "2026-09-05T19:24:44.21Z" }, { url = "https://files.pythonhosted.org/packages/7b/f0/ad64f4894334499f689bdce7e5b5dda6b680d98989424092dcbf21666564/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:2b0f154ac2e6450befa337a99a8519e2f1195cc59f88bd73d780067ace0c4c97", size = 2151468, upload-time = "2026-07-24T09:56:55.626Z" },
{ url = "https://files.pythonhosted.org/packages/6f/fb/c5987eeff60e9d736a720ef1c6f3dadee39619f596517d8983a99ce4873a/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:7062196b773cc8b956b0dbece42203eb493270e0707e37447801255ad26f065e", size = 3843321, upload-time = "2026-09-05T19:24:45.55Z" }, { url = "https://files.pythonhosted.org/packages/2a/97/4ff5f9170e4865ec28fb152bc6ebaa8bd1873e695ee98af2103607ba42e8/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:6fcb4e3a312397432f7c5a0aa04a765670d9f37f4e23f37cb580b3faa2f218b1", size = 2300164, upload-time = "2026-07-24T09:56:57.318Z" },
{ url = "https://files.pythonhosted.org/packages/19/43/cc1e93c74103a225dc39c30966ff3bec9973be0bbb338b080a55e4653bda/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:15ddde16a1398c601a1bb791e5c3a796ce83222b79ecb21bd7070e52aa007257", size = 3735663, upload-time = "2026-09-05T19:24:46.866Z" }, { url = "https://files.pythonhosted.org/packages/1a/b1/c9bfb6708bc5d9f6b040db46156c6e3f16de68ab22f07e2de4f25782414b/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:955d308171ba0260f75d2dede18c46d9732647f94b8f7fba3076bb539f155dc8", size = 2164984, upload-time = "2026-07-24T09:56:58.66Z" },
{ url = "https://files.pythonhosted.org/packages/42/09/b7103e26fe6103e217c281f6581b918a2455f5a1600fc197b82010953b19/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b41dbca54254850a5ab68bed4c75992d6eeacd283265f7445c4ae705e29fed5a", size = 4006344, upload-time = "2026-09-05T19:24:48.17Z" }, { url = "https://files.pythonhosted.org/packages/35/2d/8c1492216632f53e229cb2886c7fd09613d5990f4856f93f7ae0364da9bc/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:3d7e1a6cc1977b24e01b5322888a5ba3f05b112a04a1646ba51f2c34c562a3d9", size = 2357823, upload-time = "2026-07-24T09:57:00.048Z" },
{ url = "https://files.pythonhosted.org/packages/9f/71/6b21d3d52dcb1cdbeaa593baed170d2454a61bf5aad2692e6e3b3136c5f8/pydantic_monty_client-0.0.23-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1081ab9038004739ede448249d0b1c3d409fb8641ee9bad9ab5347d2bb415600", size = 3896737, upload-time = "2026-09-05T19:24:50.226Z" }, { url = "https://files.pythonhosted.org/packages/9b/cc/ae4adaaf343de00748fc3598402c1523e48db7094a9c1e0fa26177699440/pydantic_monty-0.0.19-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4904785a34f71f59aa1eb6dc081bafd09e4caa2f028cd379aa3aa846b8a1c27d", size = 2497387, upload-time = "2026-07-24T09:57:01.686Z" },
{ url = "https://files.pythonhosted.org/packages/02/df/6a4ed64e3c21389b7e277a7fc98df1021767f9e27edd487b9c5cb1cc4448/pydantic_monty_client-0.0.23-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:182798245ed7ba503c49c9cf5f313876df9b81f57fcbfdf12efa1aa25be0fcd1", size = 4199695, upload-time = "2026-09-05T19:24:51.844Z" }, { url = "https://files.pythonhosted.org/packages/ec/09/eaef87ed6cbffed9c720dd3cb850e748b0aab11f5ec1ecffb56250973f7d/pydantic_monty-0.0.19-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ce15a5326e24cf7045ec9c1456ddb92abd09df91708771c3ae5e72d8e6374c81", size = 2738391, upload-time = "2026-07-24T09:57:03.3Z" },
{ url = "https://files.pythonhosted.org/packages/98/a7/5f166faeff1af270c1d139c90792fe9a2edf00b3c5e16d5014feb29c8d18/pydantic_monty_client-0.0.23-cp312-cp312-win32.whl", hash = "sha256:4d6354723ba6165d3856eac1439d2abed9d231730069c858219456e866a3bc16", size = 3288615, upload-time = "2026-09-05T19:24:53.371Z" }, { url = "https://files.pythonhosted.org/packages/63/af/58be6fd6ea87e27bd57435013ca1f63d645a0c990c2f23708bcdd048af24/pydantic_monty-0.0.19-cp312-cp312-win32.whl", hash = "sha256:600eb259415e8b2dfef4be38d030c945b3fbb4fb85e727cb97131e7329ab017f", size = 1908274, upload-time = "2026-07-24T09:57:05.023Z" },
{ url = "https://files.pythonhosted.org/packages/99/d9/ff24b9dd6d00b65724962f21f10c5844492d1c8d101d86e601155f6c0425/pydantic_monty_client-0.0.23-cp312-cp312-win_amd64.whl", hash = "sha256:099173c188a063ebecd79ea5a2268d06f0031ec93dbaee03b62f4386e024f5dc", size = 3927504, upload-time = "2026-09-05T19:24:54.748Z" }, { url = "https://files.pythonhosted.org/packages/20/b7/1cb54e43113cb69c40fb765cfee3be1c222d81153b432131f50508569aee/pydantic_monty-0.0.19-cp312-cp312-win_amd64.whl", hash = "sha256:2c98b1c99994f92ab487a762b107067ab70036f64231e05aa3f6d2b16018688e", size = 2111335, upload-time = "2026-07-24T09:57:06.614Z" },
{ url = "https://files.pythonhosted.org/packages/cf/75/ca3594de58d97c1f34a5c8c6965a3acc69a73e7e616c8b99b8f6fc221c55/pydantic_monty_client-0.0.23-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:80fc4d09ade86f9d527dd79d8ab6f26e510d687c962ca0d499b391905f6efd9e", size = 3934648, upload-time = "2026-09-05T19:24:56.682Z" }, { url = "https://files.pythonhosted.org/packages/24/17/0926da051f34ccaa45bf528777dc99e5ea611669cdd7b715be1e086c1fe0/pydantic_monty-0.0.19-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:c01fb1162cf87dbf145b875450eabfde6b35b26f27ed63468398cb4c37732064", size = 2496669, upload-time = "2026-07-24T09:57:07.982Z" },
{ url = "https://files.pythonhosted.org/packages/f4/a2/ee39d943b7eb874a91df0fa0175c8153509322772bc5abace77f5e1763c1/pydantic_monty_client-0.0.23-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48f6cbb37c7a56dce5e1c4000e286447d5fb51dcf1e1cf5b69185922c43965e6", size = 3703648, upload-time = "2026-09-05T19:24:58.334Z" }, { url = "https://files.pythonhosted.org/packages/fd/ba/c38f79e24971b4d2205ee156df6e5c6ccdcc720152f54a956cc26e7488b0/pydantic_monty-0.0.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e97dc97de32410003fb5517b72ab47799b4546a3ab48a75e60260cf73734da76", size = 2234599, upload-time = "2026-07-24T09:57:09.458Z" },
{ url = "https://files.pythonhosted.org/packages/07/2b/67bc41a8a6c178bd840e1e6115a7af3b0289ba7b1f7b4d8f39a23d0abd43/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9b71eb5edd68ef9de22064dd5cf28feca283b5aef8f0ff00bb1308deba5e56bd", size = 3730495, upload-time = "2026-09-05T19:25:00.1Z" }, { url = "https://files.pythonhosted.org/packages/5a/89/fb2ed1677ad2c3e2801aa119fdc280d1c64f3480e1015811e0942c9a7d20/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:06318e6add780f66259067829ab16062ce7a556dba0884372a881881b4a7c3bf", size = 2305696, upload-time = "2026-07-24T09:57:10.97Z" },
{ url = "https://files.pythonhosted.org/packages/a5/2a/0d94df69fef37fd3d4fbc0330e7f8442eb9f1bcb7f2ece69f90ef0b349b6/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_armv7l.whl", hash = "sha256:2a0aa01d8c1321b610b1c0633b7ffcf9dcbb7ecaa79e5bd6d355483b23440231", size = 3425587, upload-time = "2026-09-05T19:25:01.593Z" }, { url = "https://files.pythonhosted.org/packages/24/ec/e36ff1c2c97f46420d57680199e7ae2e1eb306d2cfda22be2448e53e7484/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_armv7l.whl", hash = "sha256:5a765a36141bbeb074d89b424d5488bed4e358c603c72606cc9d81ee44d83de2", size = 2007600, upload-time = "2026-07-24T09:57:12.333Z" },
{ url = "https://files.pythonhosted.org/packages/d5/4f/61f1f175987a3ad595ee31b883ca1521f7defe3c733b7eb11f228b608a36/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:bfea6ada82fb43308833852980b147edca3a8de48bc2ce4e7cb08c6f1e13144a", size = 3633833, upload-time = "2026-09-05T19:25:03.397Z" }, { url = "https://files.pythonhosted.org/packages/da/88/b47670d3e28f99dc2f4c2686dc42233843dce1a607f3d3cc8a387f3b9b74/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:48f5ebc048779c854f993834586ba372df3b65500d1ed7f147023abc29aeb2c4", size = 2151382, upload-time = "2026-07-24T09:57:13.8Z" },
{ url = "https://files.pythonhosted.org/packages/1b/71/173b79e38316b878534fb268bcb461730301ca2e7d656f71599f3be4cdb8/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:4ca25b02433751eb0735badd7004543f5eb799a7afc7354d0b0caeadc2e754d2", size = 3845772, upload-time = "2026-09-05T19:25:04.92Z" }, { url = "https://files.pythonhosted.org/packages/9f/18/559d71fc66a769c22f6b2a8470515aa6e69a141ab617900fe28a806080b7/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2eb7502f93d8bb568d255fccca95e3b9daca05bb674b9c5323fcba14d088932c", size = 2302643, upload-time = "2026-07-24T09:57:15.42Z" },
{ url = "https://files.pythonhosted.org/packages/d1/78/66707709d9d11222e04efd934486f22b1c44bdf4a62954516cfc746c5845/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:a4a7b40bb0d48b513a72977ccd9192d585c85bb470447a80a0a128a258d4c2c2", size = 3738982, upload-time = "2026-09-05T19:25:06.322Z" }, { url = "https://files.pythonhosted.org/packages/6f/b4/171be42e2ec211bd01fe4be7b6de9ab0c346081edc33830f9f9b781341ab/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:608994600a839dd863940ac84810c48d378390a4fea8c77e5145feb84037e610", size = 2169103, upload-time = "2026-07-24T09:57:16.805Z" },
{ url = "https://files.pythonhosted.org/packages/91/ac/d3ffac7ea991b490271df273213765408df4dec6943fed99ff36db1f0642/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6acfe4ffe75aac0b89f13ea1a527f388fc0d09643bca25d7c78f59b4fed5be25", size = 4011071, upload-time = "2026-09-05T19:25:07.746Z" }, { url = "https://files.pythonhosted.org/packages/84/ce/8c47253c8f3f528f0fa2d87dde85623dc3f211189a372e2d69cb6ad896b1/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:13a652f9dffa6d25ea458fec83108f60f29682caf42cecef91955b5b8cb04365", size = 2358155, upload-time = "2026-07-24T09:57:18.51Z" },
{ url = "https://files.pythonhosted.org/packages/fa/26/c6599214e286e55eb7dcc8b43617b74878fe4d55f69b8e0d12abb0576019/pydantic_monty_client-0.0.23-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:538a8edd9afcc3461fe9b744beda644d1daca60fc76423e695795de57d939d4f", size = 3899476, upload-time = "2026-09-05T19:25:09.499Z" }, { url = "https://files.pythonhosted.org/packages/97/4e/239107b83bae3a20e4f5424f6c6f3f88bae1fd1e734603c298167985f8be/pydantic_monty-0.0.19-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6167c966db7d8d2fe03940f8da596de6dcd184dcf5cf6209f0a1a9af8792550d", size = 2500858, upload-time = "2026-07-24T09:57:19.878Z" },
{ url = "https://files.pythonhosted.org/packages/4b/4d/e3179f513cb4e112d6acd1fafff1217eaf5567ea351f00dc61dd92a7d8ae/pydantic_monty_client-0.0.23-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ef72181626512a8b7c3c6365d60e416c57bef219ee68f98475f7ec7c59db0db0", size = 4203793, upload-time = "2026-09-05T19:25:11.253Z" }, { url = "https://files.pythonhosted.org/packages/48/55/02a7059f20e7198e511ac0b88a3957a2c01b8991326359b7c1bb590a09b8/pydantic_monty-0.0.19-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3102b44307d04f41897ae51d4daeab4fc9b5bf84a78c036781b488876ef998c3", size = 2742212, upload-time = "2026-07-24T09:57:21.296Z" },
{ url = "https://files.pythonhosted.org/packages/54/68/2889ff38031ab76eababba29eec92c7d4e9f23a84a58aad306f4609219d8/pydantic_monty_client-0.0.23-cp313-cp313-win32.whl", hash = "sha256:3509dce955db0b7ccbf8a2b458d6562e289295281b7910841e42a1de656a59dc", size = 3288733, upload-time = "2026-09-05T19:25:12.559Z" }, { url = "https://files.pythonhosted.org/packages/a8/28/b632fe0e8eeba3f2b4dbec6ebd4c9b569c20e9597007f2d0fbbf04101307/pydantic_monty-0.0.19-cp313-cp313-win32.whl", hash = "sha256:e43da52776796a894f40533a7e5a322e98d9aaf7d8f6fbb7dc21a0de60a93f41", size = 1908553, upload-time = "2026-07-24T09:57:22.765Z" },
{ url = "https://files.pythonhosted.org/packages/bd/f6/e8344f4b3c4d0b8b37fee2b3d27676ae8ad35c5ef731c6ba48ae4012448c/pydantic_monty_client-0.0.23-cp313-cp313-win_amd64.whl", hash = "sha256:c661a74a80158460d633ac5510ccc5c80f77eb3c6c51a54a71d1d9a6108a6fdb", size = 3931122, upload-time = "2026-09-05T19:25:13.992Z" }, { url = "https://files.pythonhosted.org/packages/f7/d3/b90872f017871339ceb03e70fa4915ef8682128a476a66adffedfff874d8/pydantic_monty-0.0.19-cp313-cp313-win_amd64.whl", hash = "sha256:fd8c195875f8f44d55bc7d1d53c4e43248b184b3ac803d8131c92d4cc05a1aef", size = 2111345, upload-time = "2026-07-24T09:57:24.351Z" },
{ url = "https://files.pythonhosted.org/packages/96/73/f4f36e465afd5346737a46b59c50e50ec133b9f05a0ba7f3adab0c5e8ab4/pydantic_monty_client-0.0.23-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:28bbf56a7f48dda2acf1cc11807daa50af8cc94e34e1c57fac5d16f99b3e7392", size = 3935351, upload-time = "2026-09-05T19:25:15.497Z" }, { url = "https://files.pythonhosted.org/packages/eb/11/c2aed55502bfc9620837312f0e2fca7a3d4bc959824a66d81b72144d7256/pydantic_monty-0.0.19-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:2692dc4452937cf2200afd257297e9ac3ccff122b80aa7a69935e8275d684193", size = 2497017, upload-time = "2026-07-24T09:57:25.836Z" },
{ url = "https://files.pythonhosted.org/packages/ec/ab/0f6f9e9018c2107c9f0bf036167cadc2d1e7edf8275727c9333311f0f8d5/pydantic_monty_client-0.0.23-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff363c8a2afb25d6e716f2a67a6b350aaa864a278b0ba60a390231d4b351940b", size = 3704385, upload-time = "2026-09-05T19:25:16.944Z" }, { url = "https://files.pythonhosted.org/packages/f5/d0/d4b44a81c71308109cfa642a24b803057615ca1609530c5e66c376780efe/pydantic_monty-0.0.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5a4717f829b35c4bc5d9f6f52a52d19b90729bd494bcb69133bd4c0afcab9c76", size = 2247468, upload-time = "2026-07-24T09:57:27.501Z" },
{ url = "https://files.pythonhosted.org/packages/54/71/2a0eded398b705c65a6d56cb8139c9fce4d878e01594570c98574734818a/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:94b056d3ee44ff3fa39be0790debba41f65fcb7db9d26461f039a01e49349c0a", size = 3730307, upload-time = "2026-09-05T19:25:18.505Z" }, { url = "https://files.pythonhosted.org/packages/a9/89/52848dce3acbb1d58df34496db3c4718814cc39f6db01a5025bfdc12b530/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:99f7282213ad6ebf7daf149a88d1517e6328afbf6780180512b9de62f30d292b", size = 2306181, upload-time = "2026-07-24T09:57:29.157Z" },
{ url = "https://files.pythonhosted.org/packages/9f/c8/ba9fba363a8c96ef4635cf26f3d6d3715e7663823d166676bdf8f5bc06a3/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_armv7l.whl", hash = "sha256:f94396bdd59f74918871307fb7f63f4ed852ab770a91a70ed29d159b63823e97", size = 3426378, upload-time = "2026-09-05T19:25:19.958Z" }, { url = "https://files.pythonhosted.org/packages/90/05/f7791c79c7be2240c43a9287196ed49034f773e3f4158492f028e486ebc2/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_armv7l.whl", hash = "sha256:978788b1c56fa0c49927e0a35151f0a635ef2f8d947d16915541ff864d2112f5", size = 2008738, upload-time = "2026-07-24T09:57:30.423Z" },
{ url = "https://files.pythonhosted.org/packages/72/6f/73d07f0b6f2608e155e121c573b4c5f42fa9dba672f4a5b827045ead3bd9/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:fbc150afe82ab299ceb80a9c308224f3d10ac6a1d98cc9214c24baede8fc42d5", size = 3635275, upload-time = "2026-09-05T19:25:21.652Z" }, { url = "https://files.pythonhosted.org/packages/93/a1/d714258eeb2583acaab2035834acc54ac6baa65bac456f897d901bc0c03a/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:8b821cac39deeb1abb2994d5a65f117ce26e55c586d0f570d425ff469ff48e3c", size = 2152275, upload-time = "2026-07-24T09:57:31.725Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e6/6a812e7bce36c48d87d84d0cda46002c86bcda8aa5fdf18667d48174815a/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:a080f9e5427b3223189c456337ec7866cb51c2033592c89f289ef457153800a8", size = 3846543, upload-time = "2026-09-05T19:25:23.174Z" }, { url = "https://files.pythonhosted.org/packages/2a/e5/cdb1fa761e992a489b07a17adb80c21bf99eabd1286ca62f9e73acda1f4b/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:b43c4ffa5651f0eca97458dc673d7952064ae5eb5b836d23967a7d41483bb8f4", size = 2303533, upload-time = "2026-07-24T09:57:33.131Z" },
{ url = "https://files.pythonhosted.org/packages/2b/06/19788b96be88fe6f58783b7a24225fb3129de7d4807c0837ae50c67172d6/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:ccae280613d9e15f8344cf82cda25d24dc7b598e191c4549c5f3118f327659b4", size = 3741406, upload-time = "2026-09-05T19:25:24.837Z" }, { url = "https://files.pythonhosted.org/packages/db/9b/e6685cf82521e68e0dcb94e0c97a1a20410b1266fbce7008c0caa3484039/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:4054610601358943a3dd740a8d59a6812cc682e94d6903cb72baadae1ef5d2ac", size = 2169585, upload-time = "2026-07-24T09:57:34.511Z" },
{ url = "https://files.pythonhosted.org/packages/96/e4/306c0715579eb2ecf63166470535951d11907b162ac8d54cbdcea8eb479a/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:f41b870c7ce2e0b852ae50355749b47d92452ced6f166d32ee9df5da714356a2", size = 4011087, upload-time = "2026-09-05T19:25:26.483Z" }, { url = "https://files.pythonhosted.org/packages/df/f4/6f031a628d3de72d95bedbb18292ccf998d9a422aff58eedf37347a0b367/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:0b2a34c320968a3cef3d933737e99c932f13c55667315128f366afdaeea2be04", size = 2375171, upload-time = "2026-07-24T09:57:35.907Z" },
{ url = "https://files.pythonhosted.org/packages/7e/9d/07e939e83c4b223a0a5941664c2447dcd24c0d3c11c78721889d02d16443/pydantic_monty_client-0.0.23-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2d48db758abafcf45cdbfd710f998a6b0af991267587831498739ac11fd7e3b5", size = 3899694, upload-time = "2026-09-05T19:25:27.818Z" }, { url = "https://files.pythonhosted.org/packages/0d/d3/4367bccdf2c06a977c0d5ddf816190d570a07199f011034df16d51c84723/pydantic_monty-0.0.19-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ffaf950f4284bb193a54f18fa4e3e8c225b5b52265813abffe492074e90c65fb", size = 2501475, upload-time = "2026-07-24T09:57:37.872Z" },
{ url = "https://files.pythonhosted.org/packages/bf/b7/fdbf223f919b2b8ffdd2df79912b3eec1db274656486534b1ffd84b8dbc1/pydantic_monty_client-0.0.23-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7fedc8d482e9338e8e97d2d433cf82b10f671d98416b427a52ab034978731e3d", size = 4204183, upload-time = "2026-09-05T19:25:29.279Z" }, { url = "https://files.pythonhosted.org/packages/0b/0e/7a0e1d5c016848afc9a8605aa4f16e6960d68806e775865b986aacaf8f88/pydantic_monty-0.0.19-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:bb1e5ee6762f9494bfb0f4cc316ad3e9a8c7917e3c984a23eb2e2322d401e5cb", size = 2742547, upload-time = "2026-07-24T09:57:39.284Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6d/b231969ef89209c02b172fddb6182f328b56f89b6c494b238cd923c50766/pydantic_monty_client-0.0.23-cp314-cp314-win32.whl", hash = "sha256:3bc5e57df0e44057b97614149ea749438f3e3cb48c93c9a06f0ffc753824a28d", size = 3289691, upload-time = "2026-09-05T19:25:30.833Z" }, { url = "https://files.pythonhosted.org/packages/42/f0/92f77cf088f1df72a5dbab109c8c0e82f7ddeb18e65835a8dffcf967bb4a/pydantic_monty-0.0.19-cp314-cp314-win32.whl", hash = "sha256:b68ef6503b39f2f014162e3d8e7f48b9722a43ceb7b6d7199dc6d545f4c37b34", size = 1907832, upload-time = "2026-07-24T09:57:40.982Z" },
{ url = "https://files.pythonhosted.org/packages/1d/5f/5e681044f43f6e3fd42dffc84548d86c0a15b06486f29194eca54caa1b8e/pydantic_monty_client-0.0.23-cp314-cp314-win_amd64.whl", hash = "sha256:e2b664edc793fda985f7fb6a03fddfe536fd6de4b7dba0765282938a883f4c51", size = 3929684, upload-time = "2026-09-05T19:25:32.378Z" }, { url = "https://files.pythonhosted.org/packages/cc/3e/85e1a914f81659ea25c34916fdef27fcda2b00323dafb76cf2a5321f7c11/pydantic_monty-0.0.19-cp314-cp314-win_amd64.whl", hash = "sha256:14ef37b43c5bf90966ca51bcad0fae892a3c2546cd151fadc039e0a25ca74073", size = 2123187, upload-time = "2026-07-24T09:57:42.352Z" },
] ]
[[package]] [[package]]
name = "pydantic-monty-runtime" name = "pydantic-monty-runtime"
version = "0.0.23" version = "0.0.19"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/1d/c1139f1f82460046d505d7a6871d4d22f9e36cdab967807487f6a70cb51f/pydantic_monty_runtime-0.0.23.tar.gz", hash = "sha256:d181f557cd3d19ee826459dea234a2b440e2b6de093e66384629843959146071", size = 1727575, upload-time = "2026-09-05T19:26:05.353Z" } sdist = { url = "https://files.pythonhosted.org/packages/e2/29/e44460fd934584ddecc6b8a8acddbc0605e86ea9d027910acdbf526c8f14/pydantic_monty_runtime-0.0.19.tar.gz", hash = "sha256:717e11349d7234575750cec881ac390961de02a40bd09f875dc5e097705b896b", size = 1322628, upload-time = "2026-07-24T10:00:14.955Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/97/d1575be31396cf662ea8419d427c58279378a2e08b674d436ed4990751ea/pydantic_monty_runtime-0.0.23-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c8968491ae44648c11cf075c0f5d798d4aa7f7e4a0d0754add0b74d725c075c", size = 9828049, upload-time = "2026-09-05T19:25:34.446Z" }, { url = "https://files.pythonhosted.org/packages/fd/11/a6f4e12982b2232b9036db334fbcfecbacf46b9acaf311f9c3110e431c53/pydantic_monty_runtime-0.0.19-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:be34905548e31237fc683f5a34986489c127727e8f65481e8c87c4ad0b3a4dc2", size = 9449108, upload-time = "2026-07-24T09:58:44.394Z" },
{ url = "https://files.pythonhosted.org/packages/15/09/01ae8472d860223618cd61a4656d8ff44519e1bd9e320ee8698dbb206aca/pydantic_monty_runtime-0.0.23-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c60e44cbef9cfdc9bd5e53631650490e66890f57e8c6bea5611dae502d8374d4", size = 11548563, upload-time = "2026-09-05T19:25:37.189Z" }, { url = "https://files.pythonhosted.org/packages/c3/a0/1e720b1bba457c6a1ab4fca20c571ae058238c7df3e1892b5efebad05a8b/pydantic_monty_runtime-0.0.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0f402f20672c1e25d4915de7e97023519461b9ffbedacd08c427d40daa77bdd6", size = 9735875, upload-time = "2026-07-24T09:58:46.952Z" },
{ url = "https://files.pythonhosted.org/packages/fe/30/ebf796ec2236b15cb9f5541e3a940536f37de41bd2561989985aa92fb396/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4bc24603678107b7f5b2b24a5946ca1e103379b2289589212c86959993a052b4", size = 11935977, upload-time = "2026-09-05T19:25:39.862Z" }, { url = "https://files.pythonhosted.org/packages/51/97/4439b312d9463881630dd9d998d2c8fe2b8ef457b451202c71816e25688c/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9ec0d25dfbe65b9a5dfe77ff86353e95a86774d786a1d496206163f828f072fd", size = 9171496, upload-time = "2026-07-24T09:58:49.504Z" },
{ url = "https://files.pythonhosted.org/packages/c2/40/8ed68e144f80c9d14e0532d631ffd78ee6a0ae8d7d3272ac037d5d703266/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_armv7l.whl", hash = "sha256:7c68422e06c5aae8b52f9835ee949256db0f261301515e4443d6647f249cf789", size = 10180097, upload-time = "2026-09-05T19:25:42.298Z" }, { url = "https://files.pythonhosted.org/packages/1e/67/fa7b99afe1917b89eec3b4b6375f468c76eeea8227dd48361b7f2db90d97/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_armv7l.whl", hash = "sha256:7ecc2f8eeabf6483908db4cc69b7d4c156a95675dcbdda2a7540a22ed904bfb2", size = 9565495, upload-time = "2026-07-24T09:58:51.913Z" },
{ url = "https://files.pythonhosted.org/packages/1c/f3/ea3632cebb754b81fa63c01b84c89299bba936c670fa519f3ff363166bcc/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_i686.whl", hash = "sha256:b8a049a554bff6ba7e553c1ae4b6290e554b6a1695bc1d47b1b03db485f7fb91", size = 10515148, upload-time = "2026-09-05T19:25:44.576Z" }, { url = "https://files.pythonhosted.org/packages/1b/a6/83a9dceb8d9f5dffd9a082b60590bb61b0ac48dca19aa02847ebbab1ad46/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:fc1e508b9dc2cab64e27004d0f4ce44c7e1d255e85bafba390884fa07d696319", size = 10199598, upload-time = "2026-07-24T09:58:54.394Z" },
{ url = "https://files.pythonhosted.org/packages/e8/a9/6a43d9d9f01f441ecd6ec6c31d9b8d45b143a37b9368f582b27378e50ec7/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_ppc64le.whl", hash = "sha256:9cea174ed1a56888bb58975feff34b0475504ab3831b3b1dc3189397d5bcccc3", size = 10746650, upload-time = "2026-09-05T19:25:46.823Z" }, { url = "https://files.pythonhosted.org/packages/c6/5e/bafd9dfa9a9a0d26b9882053aaea2280d791225c1e3b33b4b48f23fd5f92/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:9e3f6e366f62e54d7bd0fb995f007f42d23c26eee560d57c04d75d5adde3b45c", size = 10355698, upload-time = "2026-07-24T09:58:56.713Z" },
{ url = "https://files.pythonhosted.org/packages/18/d0/dc2c2e066ce4f619f34a98b44c9c84fb398095421808c9dd6ac12463aea2/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_s390x.whl", hash = "sha256:28bb59cde9de0cec0d3d5d72029851757249c682a6747251f61e8cdece5995f7", size = 11092334, upload-time = "2026-09-05T19:25:49.314Z" }, { url = "https://files.pythonhosted.org/packages/0a/00/ef55cdc9f5ade20475622bb8dba617efce00ef9da2b94b36a76172edadce/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:2ddf4f2d1d063f330bb54b2dc00f6d5bcfbdd3defdc5988b856d350f62160e26", size = 10197859, upload-time = "2026-07-24T09:58:59.158Z" },
{ url = "https://files.pythonhosted.org/packages/3b/82/6af8a19dd357d63e783e9647ef81d47db2f48c13e96558d25484cd0dec91/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:721d2d1455482c929c934412e61ecf56673b88a2c0c049e041295cf543ff1412", size = 12371608, upload-time = "2026-09-05T19:25:51.663Z" }, { url = "https://files.pythonhosted.org/packages/a8/67/d1c359658458cf97296fda4359bfc71de8c9f968fb3db68eb7ce00136d43/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ce2c9550c30a5c94b63dfe578243d0823511feeecb020723f5140f9737505410", size = 10661715, upload-time = "2026-07-24T09:59:01.576Z" },
{ url = "https://files.pythonhosted.org/packages/f7/53/2939bce8c8a688771777ee2301851a712744d809d752c1c8dcb5d6a52574/pydantic_monty_runtime-0.0.23-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:f0183e589ba18de4fac8a675fe0f93049baa293d5409eb8d39c639dc24af943f", size = 9593312, upload-time = "2026-09-05T19:25:53.8Z" }, { url = "https://files.pythonhosted.org/packages/8f/1f/9841d93f956bfbd0bbbac75edf42bb3b13b5bad7d25b09d9a221b5e54d71/pydantic_monty_runtime-0.0.19-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:05563e178b6be783de088abe4a25cef9cfbc04811f7c1e1873860252e711edac", size = 9143212, upload-time = "2026-07-24T09:59:04.019Z" },
{ url = "https://files.pythonhosted.org/packages/e6/f9/180d9acbea18d8050c190280ef591cb52d05e4d0e6d026cc6b7b2613510c/pydantic_monty_runtime-0.0.23-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:cbf56c19712c12072f6607d8e2323941441c9343734dcb07d2d2b4ce2dd98bf3", size = 10133520, upload-time = "2026-09-05T19:25:56.797Z" }, { url = "https://files.pythonhosted.org/packages/ce/ce/0cd3643cc5051456b7baad6f16d85fb1d05daefd0556e4c82ea8f22cc9a2/pydantic_monty_runtime-0.0.19-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:de74aa4df47147d104bccdac4b39c1f3fd543091be3fd0156f77eeea3e483bc3", size = 9731590, upload-time = "2026-07-24T09:59:06.323Z" },
{ url = "https://files.pythonhosted.org/packages/e1/00/dcd69bd9fcc57c7a6ecfcd3b2199f4598853edb14d504f481dcda41b17cd/pydantic_monty_runtime-0.0.23-py3-none-win32.whl", hash = "sha256:854f58a472397f34588a22eb813f2acd65efc0bcb966ef1fe173c32366209d15", size = 9639882, upload-time = "2026-09-05T19:25:58.978Z" }, { url = "https://files.pythonhosted.org/packages/ab/5a/69f4eabec2538df364242395ab3ef77b30a124a0e4b461231589651a1e97/pydantic_monty_runtime-0.0.19-cp312-cp312-win32.whl", hash = "sha256:5208056d9e23d951768ba4b94df3caf7fd84bfe951f68ec4a1803eb03377bbeb", size = 9227834, upload-time = "2026-07-24T09:59:08.694Z" },
{ url = "https://files.pythonhosted.org/packages/e6/c0/3f7c604ac93ec47a36a3733d6e7717b7096f0989f669d4133bd3e85f3375/pydantic_monty_runtime-0.0.23-py3-none-win_amd64.whl", hash = "sha256:742375f494e298a4f96933ac3694a0fe34c9cddd9b666669bc193f348bbed0b8", size = 10404629, upload-time = "2026-09-05T19:26:01.143Z" }, { url = "https://files.pythonhosted.org/packages/c7/4b/221a21f477aef0c488cbe1467111b0988658bc4a42cfc6b404201bc432af/pydantic_monty_runtime-0.0.19-cp312-cp312-win_amd64.whl", hash = "sha256:e4bba0c6024a3a8bd8c8a8cba25233a19cf686218e97afb4c059aa0c625a4b8b", size = 10941520, upload-time = "2026-07-24T09:59:10.959Z" },
{ url = "https://files.pythonhosted.org/packages/f2/a8/1ba497c35eca33273f2144b8e78d94832cc33aec5f69d8bef56968a61933/pydantic_monty_runtime-0.0.19-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:84dd041652581335503af7c60ee7362a462d946a62e8c4a44a939984034d0d25", size = 9449108, upload-time = "2026-07-24T09:59:13.497Z" },
{ url = "https://files.pythonhosted.org/packages/2d/6e/08c05c9729a35d6c9831bfd57d535bd1257f601d15b62936c27f1c0cfb47/pydantic_monty_runtime-0.0.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:263ab33dce2008ca6c83b4013b0280a98a1991d4071c55413d00b539940fe8aa", size = 9735874, upload-time = "2026-07-24T09:59:15.918Z" },
{ url = "https://files.pythonhosted.org/packages/c0/64/63fbdb4c069ceb2af6261fe5923864b5be309799087f16a5dccc96141c12/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:64f56d48097d8a158125534b20e8b22003c5e727082a303907da8e61aae0c7e3", size = 9171498, upload-time = "2026-07-24T09:59:18.799Z" },
{ url = "https://files.pythonhosted.org/packages/1d/cf/82390fe7f0e1100662e6cac7a1c0de716ea4bf851a53aa2b0ef324fc4e3e/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_armv7l.whl", hash = "sha256:a4d80fb4598524cd5039a596f8e6900adb2a5a2da66d012a3734a2b441ad06c3", size = 9565494, upload-time = "2026-07-24T09:59:21.232Z" },
{ url = "https://files.pythonhosted.org/packages/23/37/caa8bf32ba0c0e8ce31ef2773b1a1f60d688e0237cd396e48bbef9f7161f/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:1c5cd0b140c765772e0606a7047fbf95cc49d62d0d8b79b4f520dae0e38b3ba7", size = 10199597, upload-time = "2026-07-24T09:59:23.702Z" },
{ url = "https://files.pythonhosted.org/packages/48/25/ae9bb52518b2ce8fe7509d6cad4f4916b4458b748fecb180bef2d78165e9/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:7d80975c6e792088285f49a91e26de483ef95e5bffc4939b02d4c5ea1059749b", size = 10355699, upload-time = "2026-07-24T09:59:26.389Z" },
{ url = "https://files.pythonhosted.org/packages/a7/38/0875b1239b8ea57e4ea6de421cd240fc5a88c02e381f88d858f00439b1e9/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:f826cb911f78fe4fddf09fa3f38518484041908d08de927fa7dd134c002a2c77", size = 10197858, upload-time = "2026-07-24T09:59:28.889Z" },
{ url = "https://files.pythonhosted.org/packages/11/d4/9365473fe31e53a6dc4d6fea406d5d8f3f03a9b7d84d1f29a9e6d664c862/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:fdf56c6cd8c6163737d1ce284f9acb00feff77b7e952f041dc281b94336c4fc9", size = 10661715, upload-time = "2026-07-24T09:59:31.498Z" },
{ url = "https://files.pythonhosted.org/packages/20/f9/8d85b8f77d4006a8d80517a0781c49e6ca026f68747f801b63298e64197e/pydantic_monty_runtime-0.0.19-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a675f165773af0a2e0209dd96014aa9e613115cdc6dad297a7bb96cf87f83315", size = 9143210, upload-time = "2026-07-24T09:59:33.843Z" },
{ url = "https://files.pythonhosted.org/packages/58/53/ded73742ee9fa2160c711141c28ea2ee083f073019e89724049c5e67cd84/pydantic_monty_runtime-0.0.19-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9e42ab0287c0497d9d5529e8a53dd2f63f9d1f92f6592170baab894ea9956da6", size = 9731590, upload-time = "2026-07-24T09:59:36.642Z" },
{ url = "https://files.pythonhosted.org/packages/4b/06/67be8320dc592b8c4caa8c4c1544c8ddf2760029d46843d078aa5a4cdb14/pydantic_monty_runtime-0.0.19-cp313-cp313-win32.whl", hash = "sha256:1f5ff1b9585e648304096705045fb6bd90d43b561568b0d373265e8d201b1234", size = 9227833, upload-time = "2026-07-24T09:59:39.022Z" },
{ url = "https://files.pythonhosted.org/packages/7b/f4/bab34897974d83640f8773f03d2001142bc13e80e517bfce8c8c4a57157e/pydantic_monty_runtime-0.0.19-cp313-cp313-win_amd64.whl", hash = "sha256:7181a2153ff257fe34109685148167b6e8219d4acfe8f346115b58152fd26aa8", size = 10941519, upload-time = "2026-07-24T09:59:41.538Z" },
{ url = "https://files.pythonhosted.org/packages/63/8d/f46ac4778b2ac64183607bc63ecc783f16ca9981de30eb8ec9aa5e7132cd/pydantic_monty_runtime-0.0.19-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4cc92139b476469c0d7e5caa147d38c2929de39bf1724cb22bbab80297823963", size = 9449107, upload-time = "2026-07-24T09:59:44.139Z" },
{ url = "https://files.pythonhosted.org/packages/a5/14/34a7bb4630d1bac0d055049568ecc888a87954c176428bde5764a7ff6ed7/pydantic_monty_runtime-0.0.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a97e00bd46305f34a85f2e2e3ac4c92dbd3340e7af694aafb192ff58c4fb40e", size = 9735874, upload-time = "2026-07-24T09:59:46.546Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/977de0b258c0858f52b23bd32afbfdf8a8c4614daff5d0b7d5c86332ce6e/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:46ad28b1b3c41113c9e89373da18bcc883a24da371d4eeb9b32d3d194286f1a5", size = 9171497, upload-time = "2026-07-24T09:59:48.768Z" },
{ url = "https://files.pythonhosted.org/packages/39/39/4374200ee4b938fc8b5026056f13bb677e15796bca1323a16210738431ad/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_armv7l.whl", hash = "sha256:dc61844187a32c2f9c2b69846c5d55679eb838b4f7e495b4d03509f89fbf41f9", size = 9565495, upload-time = "2026-07-24T09:59:51.149Z" },
{ url = "https://files.pythonhosted.org/packages/04/55/c4ee4b0a10610359e09cad9d327db19095914a3bf427fb3d8164ec2bcae0/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:b23b52a79ee6be0a943e4b8e5d996e6c489a37b23a337838164f22c9e8ed11a7", size = 10199598, upload-time = "2026-07-24T09:59:53.619Z" },
{ url = "https://files.pythonhosted.org/packages/87/62/cc9df084e9f930bbb2873b6dd832b377f76071aec309b1545589d818fd90/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:484496817f5f238c42aaea8a1945b545a17d8ef2a21e9e81799d55e481d25485", size = 10355699, upload-time = "2026-07-24T09:59:56.043Z" },
{ url = "https://files.pythonhosted.org/packages/c8/cc/bb13e6f655fcaee340032ad6a3cd1524957d0fa471ddc7e27bdb1f4c240b/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:c3e7cbad58ae9bd3402581faa7d54d719f9c140dc1888f5c9439d45bff528ea1", size = 10197859, upload-time = "2026-07-24T09:59:58.481Z" },
{ url = "https://files.pythonhosted.org/packages/ba/c3/c532715987383668ee835337e1485f51585bc8bf189f033370a05eff17f1/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5534067dc7ffdae809293da95d0e95b6d8481f4c88aff59385e19f466ba3c0f0", size = 10661715, upload-time = "2026-07-24T10:00:01.163Z" },
{ url = "https://files.pythonhosted.org/packages/ed/6c/9a0ab28a061efc184398a0b4db34e459572bb2316cf766f0d6ce65b475e3/pydantic_monty_runtime-0.0.19-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c1ae32b06a4456ab223bafa54d29a349066d625d09063c28ba14ee9019f9b7c2", size = 9143211, upload-time = "2026-07-24T10:00:03.856Z" },
{ url = "https://files.pythonhosted.org/packages/23/5f/af5b3e6395834572975d98f4d1a00a57ee8029bf68ca5732347550f32b35/pydantic_monty_runtime-0.0.19-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:497cf8c3f30992f9aafc8707084eaffe2391b7b5dec067d04d5715f9a562c56b", size = 9731591, upload-time = "2026-07-24T10:00:06.351Z" },
{ url = "https://files.pythonhosted.org/packages/1a/98/96797fd269342cfdb49f03c22fd9a05ef91f711089081c4b3861b9c520e2/pydantic_monty_runtime-0.0.19-cp314-cp314-win32.whl", hash = "sha256:942feb948df8edb61ae7ba6ae77dc655e6985be06d72eef886562fe573ba3086", size = 9227832, upload-time = "2026-07-24T10:00:08.708Z" },
{ url = "https://files.pythonhosted.org/packages/39/fc/02d15281c8e00b48df9af8f75a4fe06f3f8f33ef6a910507a45a19f2b61b/pydantic_monty_runtime-0.0.19-cp314-cp314-win_amd64.whl", hash = "sha256:91d93339c70483ed9256b3b15e3375f6597ae65be280f9b89ba9ca0355f95f54", size = 10941519, upload-time = "2026-07-24T10:00:11.226Z" },
] ]
[[package]] [[package]]
@ -5539,11 +5543,11 @@ wheels = [
[[package]] [[package]]
name = "uncalled-for" name = "uncalled-for"
version = "0.4.0" version = "0.2.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/5a/92ce0b3ea5481915f55da994c2c2c5f7a3c09949afde196ee89f8ab961aa/uncalled_for-0.4.0.tar.gz", hash = "sha256:335b95bd2422332ec210d518f314a16e4c640921c39fc8bf2ad095bd3538f4af", size = 56979, upload-time = "2026-08-10T14:51:46.247Z" } sdist = { url = "https://files.pythonhosted.org/packages/02/7c/b5b7d8136f872e3f13b0584e576886de0489d7213a12de6bebf29ff6ebfc/uncalled_for-0.2.0.tar.gz", hash = "sha256:b4f8fdbcec328c5a113807d653e041c5094473dd4afa7c34599ace69ccb7e69f", size = 49488, upload-time = "2026-02-27T17:40:58.137Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/40/97cec87c077eb3291fc7905e6633e08b7ca593c57d30238444bcb6bb3d53/uncalled_for-0.4.0-py3-none-any.whl", hash = "sha256:16c4bb3337532e4bd5569adc192285976f3ad5305402256d34c67a12b5c968bd", size = 15502, upload-time = "2026-08-10T14:51:45.068Z" }, { url = "https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl", hash = "sha256:2c0bd338faff5f930918f79e7eb9ff48290df2cb05fcc0b40a7f334e55d4d85f", size = 11351, upload-time = "2026-02-27T17:40:56.804Z" },
] ]
[[package]] [[package]]