diff --git a/.claude/skills/daem0nmcp-protocol/SKILL.md b/.claude/skills/daem0nmcp-protocol/SKILL.md index a4994b4..fdc38c7 100644 --- a/.claude/skills/daem0nmcp-protocol/SKILL.md +++ b/.claude/skills/daem0nmcp-protocol/SKILL.md @@ -1,6 +1,6 @@ --- name: daem0nmcp-protocol -description: Enforce the Daem0n v7 scoped session, exact preflight, replay-safe memory, and outcome protocol +description: Enforce the Daem0n v7 guided session, inline counsel, replay-safe memory, and outcome protocol --- # Daem0n v7 Protocol @@ -24,13 +24,9 @@ Do not treat a substring or an older workflow name as a match. ## Required sequence -1. Establish the authenticated workspace session: +The first Daem0n call in a session briefs automatically; the compact brief is returned in `meta.covenant.auto_brief`. Call `session_brief` for the full brief. - ```text - mcp__daem0nmcp__session_brief(workspace_id="") - ``` - -2. Recall relevant context when needed: +Recall relevant context when needed: ```text mcp__daem0nmcp__memory_recall( @@ -40,33 +36,22 @@ Do not treat a substring or an older workflow name as a match. ) ``` -3. Before a protected call, request a token bound to its exact arguments: +Call `memory_store` (or any protected tool) directly. If it returns `COUNSEL_REQUIRED`, read `error.counsel` (guidance and reasons), then retry exactly `error.remedy`. `memory_preflight` remains available for planning a change in advance. - ```text - mcp__daem0nmcp__memory_preflight( - workspace_id="", - target_tool="memory_store", - target_arguments={ - "record_type": "decision", - "content": "Use append-only events", - "idempotency_key": "decision-events-0001" - } - ) - ``` +Use `daem0n_tools_search(query)`, then `daem0n_tool_call(workspace_id, tool, arguments)`. -4. Execute that exact request with the returned token: +Store a durable decision directly: ```text mcp__daem0nmcp__memory_store( workspace_id="", record_type="decision", content="Use append-only events", - idempotency_key="decision-events-0001", - preflight_token="" + idempotency_key="decision-events-0001" ) ``` -5. After verification, record the outcome: +After verification, record the outcome: ```text mcp__daem0nmcp__memory_record_outcome( @@ -86,6 +71,11 @@ was lost. - A `preflight_token` authorizes only the exact workspace, tool, arguments, principal, and session for which it was issued. +- Tokens are single-use and valid for 300 seconds. +- Defaults are `DAEM0NMCP_COVENANT_MODE=guided` and `DAEM0NMCP_TOOL_SURFACE=core`. + Set `DAEM0NMCP_COVENANT_MODE=strict` for explicit `session_brief`, exact + `memory_preflight`, and token-bearing writes; `DAEM0NMCP_TOOL_SURFACE=full` + lists all registered tools. - Never infer identity from headers, network address, client information, or `_client_meta`. - Treat `must_not` guidance as a hard constraint. diff --git a/.claude/skills/openspec-daem0n-bridge/SKILL.md b/.claude/skills/openspec-daem0n-bridge/SKILL.md index 74d0749..fb870cd 100644 --- a/.claude/skills/openspec-daem0n-bridge/SKILL.md +++ b/.claude/skills/openspec-daem0n-bridge/SKILL.md @@ -38,15 +38,16 @@ instead of copying an older invocation into this workflow. ## Session gate -Before inspecting or synchronizing OpenSpec state, establish the scoped v7 -session: +The first Daem0n call in a session briefs automatically; the compact brief is returned in `meta.covenant.auto_brief`. Call `session_brief` for the full brief. -```text -mcp__daem0nmcp__session_brief( - workspace_id="", - focus_areas=["OpenSpec", "specifications", "proposal outcomes"] -) -``` +Call `memory_store` (or any protected tool) directly. If it returns `COUNSEL_REQUIRED`, read `error.counsel` (guidance and reasons), then retry exactly `error.remedy`. `memory_preflight` remains available for planning a change in advance. + +Use `daem0n_tools_search(query)`, then `daem0n_tool_call(workspace_id, tool, arguments)`. + +Tokens remain exact-argument, single-use, and valid for 300 seconds. Defaults +are guided/core. `DAEM0NMCP_COVENANT_MODE=strict` requires explicit +`session_brief`, exact `memory_preflight`, and token-bearing writes; +`DAEM0NMCP_TOOL_SURFACE=full` lists every registered tool. Respect the returned warnings, failed outcomes, applicable rules, and next steps. If the tool is unavailable, continue with OpenSpec file work only; do @@ -102,26 +103,7 @@ mcp__daem0nmcp__memory_recall( } ``` -6. Preflight those exact fields, excluding only `workspace_id` and the future - token: - -```text -mcp__daem0nmcp__memory_preflight( - workspace_id="", - target_tool="memory_store", - target_arguments={ - "record_type": "pattern", - "content": "", - "rationale": "OpenSpec specification is the source of truth", - "tags": ["openspec", "spec", ""], - "relative_file_path": "openspec/specs//spec.md", - "idempotency_key": "openspec-spec-" - }, - description="Store the reviewed OpenSpec summary" -) -``` - -7. If guidance permits, make the exact protected call: +6. Call `memory_store` directly with the reviewed fields: ```text mcp__daem0nmcp__memory_store( @@ -131,8 +113,7 @@ mcp__daem0nmcp__memory_store( rationale="OpenSpec specification is the source of truth", tags=["openspec", "spec", ""], relative_file_path="openspec/specs//spec.md", - idempotency_key="openspec-spec-", - preflight_token="" + idempotency_key="openspec-spec-" ) ``` @@ -202,9 +183,9 @@ mcp__daem0nmcp__memory_record_outcome( Use `worked=false` when the implementation failed, was reverted, or did not meet the archived specification. State the observed failure precisely. -5. Store any new reusable learning only through a fresh, exact - `memory_preflight` plus `memory_store` pair. Do not convert speculation into - a learning. +5. Store new reusable learning through direct `memory_store`; on + `COUNSEL_REQUIRED`, review `error.counsel` and retry exactly `error.remedy`. + Do not convert speculation into a learning. ## Read-only context resources diff --git a/.claude/skills/summon_daem0n/SKILL.md b/.claude/skills/summon_daem0n/SKILL.md index b9a7c77..9053fee 100644 --- a/.claude/skills/summon_daem0n/SKILL.md +++ b/.claude/skills/summon_daem0n/SKILL.md @@ -35,8 +35,8 @@ Use the opaque ID assigned to the configured project root: ```text mcp__daem0nmcp__system_health(workspace_id="") -mcp__daem0nmcp__session_brief(workspace_id="") ``` +Call `session_brief` only when you want the full briefing. Do not send project paths, request headers, IP addresses, client information, or `_client_meta` as identity. The transport supplies identity and the server @@ -61,11 +61,17 @@ The four bounded JSON resources are: ## 5. Explain the write protocol -Before `memory_store` or another protected operation, call `memory_preflight` -with that exact target and its exact arguments. Reuse those arguments with the -returned token, include a stable `idempotency_key`, and retain the returned -`record_id`. Later call `memory_record_outcome` with another stable -idempotency key. +The first Daem0n call in a session briefs automatically; the compact brief is returned in `meta.covenant.auto_brief`. Call `session_brief` for the full brief. + +Call `memory_store` (or any protected tool) directly. If it returns `COUNSEL_REQUIRED`, read `error.counsel` (guidance and reasons), then retry exactly `error.remedy`. `memory_preflight` remains available for planning a change in advance. + +Use `daem0n_tools_search(query)`, then `daem0n_tool_call(workspace_id, tool, arguments)`. + +Include a stable `idempotency_key`, retain `record_id`, and record verified +results using `memory_record_outcome` with another stable key. Tokens remain +exact-argument, single-use, and valid for 300 seconds. Defaults are guided/core; +`DAEM0NMCP_COVENANT_MODE=strict` requires explicit briefing and preflight, +and `DAEM0NMCP_TOOL_SURFACE=full` lists every registered tool. For migrations from v6, use the generated mapping at [`docs/v6-to-v7-tools.json`](../../../docs/v6-to-v7-tools.json). Do not invent diff --git a/.gitattributes b/.gitattributes index cd63310..90c2aef 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,5 @@ tests/fixtures/retrieval/events.jsonl text eol=crlf tests/fixtures/retrieval/qdrant_fake.py text eol=crlf tests/fixtures/retrieval/queries.jsonl text eol=crlf tests/fixtures/retrieval/records.jsonl text eol=crlf + +autoresearch.sh text eol=lf diff --git a/.gitignore b/.gitignore index c000674..a2662ad 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,8 @@ logs/ # Worktrees .worktrees/ +.claude/worktrees/ +.omp/ CLAUDE.md .planning/ diff --git a/.opencode/commands/commune.md b/.opencode/commands/commune.md index 27a2781..eca9810 100644 --- a/.opencode/commands/commune.md +++ b/.opencode/commands/commune.md @@ -2,8 +2,10 @@ description: Start the Daem0n v7 session for a registered workspace --- -Call `daem0nmcp_session_brief(workspace_id="$ARGUMENTS")` immediately. The -argument must be the configured opaque `workspace_id`, not a path. +The first Daem0n call establishes the scoped session and briefs automatically +in `meta.covenant.auto_brief`. For the full brief, call +`daem0nmcp_session_brief(workspace_id="$ARGUMENTS")`. +The argument must be the configured opaque `workspace_id`, not a path. After receiving the briefing results: 1. Report the session status, active warnings, and recent activity summary diff --git a/.opencode/commands/counsel.md b/.opencode/commands/counsel.md index d8cd22f..697330a 100644 --- a/.opencode/commands/counsel.md +++ b/.opencode/commands/counsel.md @@ -1,9 +1,9 @@ --- -description: Request an exact Daem0n v7 preflight capability +description: Plan a protected change in advance with Daem0n v7 counsel --- -Parse `$ARGUMENTS` as the intended protected v7 tool and its arguments, then -call: +`memory_preflight` is optional advance planning in guided mode. Parse +`$ARGUMENTS` as the intended protected v7 tool and its arguments, then call: ```text daem0nmcp_memory_preflight( diff --git a/.opencode/commands/inscribe.md b/.opencode/commands/inscribe.md index 1359bb2..a92434e 100644 --- a/.opencode/commands/inscribe.md +++ b/.opencode/commands/inscribe.md @@ -12,20 +12,22 @@ Choose `record_type` from the content: - "warning" for things to avoid - "learning" for lessons from experience -Create one stable idempotency key. Call `daem0nmcp_memory_preflight` with -`target_tool="memory_store"` and the exact target arguments, then call: +Create one stable idempotency key and call `memory_store` directly: ```text daem0nmcp_memory_store( workspace_id="", record_type="", content="$ARGUMENTS", - rationale="", - idempotency_key="", - preflight_token="" + idempotency_key="" ) ``` +If `COUNSEL_REQUIRED`, review `error.counsel` (guidance and reasons), then retry +exactly `error.remedy`, preserving the idempotency key. `memory_preflight` +remains available for planning a change in advance. The first Daem0n call +briefs automatically in `meta.covenant.auto_brief`; `session_brief` gives the full brief. + Report the returned `record_id`. After verification, use `daem0nmcp_memory_record_outcome` with that ID and a separate stable idempotency key. diff --git a/.opencode/commands/recall.md b/.opencode/commands/recall.md index e372a59..fc9ca72 100644 --- a/.opencode/commands/recall.md +++ b/.opencode/commands/recall.md @@ -4,6 +4,10 @@ description: Retrieve bounded memories from a Daem0n v7 workspace Call `daem0nmcp_memory_recall(workspace_id="", query="$ARGUMENTS", limit=10)` immediately. +The first Daem0n call briefs automatically in `meta.covenant.auto_brief`; +call `session_brief` for the full brief. For other capabilities, use +`daem0n_tools_search(query)`, then `daem0n_tool_call(workspace_id, tool, arguments)`. + Present the retrieved memories in a clear format: - Show each record's content, type, and creation time - Highlight any warnings or failed approaches diff --git a/.opencode/plugins/daem0n.ts b/.opencode/plugins/daem0n.ts index a562bf3..37b36d5 100644 --- a/.opencode/plugins/daem0n.ts +++ b/.opencode/plugins/daem0n.ts @@ -3,7 +3,7 @@ * * Mirrors the 5-hook discipline from Claude Code's hooks system: * 1. System prompt injection (covenant rules in every LLM call) - * 2. Pre-edit enforcement (preflight token required) + * 2. Pre-edit reminders (gateway file recall) * 3. Pre-bash enforcement (must_not rule checking) * 4. Post-edit suggestions (informational, never blocks) * 5. Session lifecycle events (best-effort, never blocks) @@ -21,24 +21,25 @@ import type { Plugin } from "@opencode-ai/plugin"; const COVENANT_RULES_FULL = ` ## The Daem0n v7 Covenant -This project is bound to Daem0n for persistent AI memory. When daem0nmcp tools -are available, use the exact workspace-scoped v7 tools. The core names are -session_brief, memory_preflight, memory_recall, memory_store, -memory_record_outcome, and system_health. +This project is bound to Daem0n for persistent AI memory. Use exact workspace-scoped +v7 tools: session_brief, memory_preflight, memory_recall, memory_store, +memory_record_outcome, system_health, daem0n_tools_search, and daem0n_tool_call. -### 1. SESSION START (Non-Negotiable) -IMMEDIATELY call: -daem0nmcp_session_brief(workspace_id="") +### 1. BRIEF +The first Daem0n call in a session briefs automatically; the compact brief is returned in meta.covenant.auto_brief. Call session_brief for the full brief. -Use daem0nmcp_memory_recall(workspace_id="", query="...", limit=10) -for relevant history. Before a protected operation call: -daem0nmcp_memory_preflight(workspace_id="", target_tool="", target_arguments={}) -Respect warnings, failed approaches, and must_not constraints. A preflight token -is valid only for the exact workspace, principal, session, tool, and arguments. +### 2. WRITE +Call memory_store (or any protected tool) directly. If it returns COUNSEL_REQUIRED, read error.counsel (guidance and reasons), then retry exactly error.remedy. memory_preflight remains available for planning a change in advance. +Use bounded memory_recall for relevant history. Respect warnings, failures and +must_not constraints. Tokens remain exact-argument, single-use capabilities +valid for 300 seconds, bound to workspace, principal, session, tool and arguments. +Use a stable idempotency_key for memory_store and save its record_id. -### 3. AFTER MAKING DECISIONS -Call daem0nmcp_memory_store with the same target arguments, a stable -idempotency_key, and the returned preflight_token. Save its record_id. +### 3. OTHER CAPABILITIES +Use daem0n_tools_search(query), then daem0n_tool_call(workspace_id, tool, arguments). +Defaults: DAEM0NMCP_COVENANT_MODE=guided and DAEM0NMCP_TOOL_SURFACE=core. +DAEM0NMCP_COVENANT_MODE=strict requires explicit session_brief, exact +memory_preflight, and token-bearing writes; DAEM0NMCP_TOOL_SURFACE=full lists all tools. ### 4. AFTER IMPLEMENTATION Call: daem0nmcp_memory_record_outcome(workspace_id="", record_id="", outcome_text="...", worked=true|false, idempotency_key="") @@ -51,15 +52,16 @@ at /mcp. Migration mapping: docs/v6-to-v7-tools.json. `; const COVENANT_RULES_SIMPLIFIED = ` -## Memory Protocol (Required Steps) +## Memory Protocol -This project uses Daem0n for persistent AI memory. Follow these 4 steps: - -1. START: daem0nmcp_session_brief(workspace_id="") -2. RECALL: daem0nmcp_memory_recall(workspace_id="", query="...", limit=10) -3. PREFLIGHT: daem0nmcp_memory_preflight(workspace_id="", target_tool="memory_store", target_arguments={}) -4. STORE: daem0nmcp_memory_store(workspace_id="", record_type="decision", content="...", idempotency_key="", preflight_token="") -5. OUTCOME: daem0nmcp_memory_record_outcome(workspace_id="", record_id="", outcome_text="...", worked=true|false, idempotency_key="") +The first Daem0n call in a session briefs automatically; the compact brief is returned in meta.covenant.auto_brief. Call session_brief for the full brief. +Call memory_store (or any protected tool) directly. If it returns COUNSEL_REQUIRED, read error.counsel (guidance and reasons), then retry exactly error.remedy. memory_preflight remains available for planning a change in advance. +Use daem0n_tools_search(query), then daem0n_tool_call(workspace_id, tool, arguments). +RECALL: daem0nmcp_memory_recall(workspace_id="", query="...", limit=10) +STORE: daem0nmcp_memory_store(workspace_id="", record_type="decision", content="...", idempotency_key="") +OUTCOME: daem0nmcp_memory_record_outcome(workspace_id="", record_id="", outcome_text="...", worked=true|false, idempotency_key="") +Tokens remain exact-argument, single-use, and valid for 300 seconds. +Strict ceremony: DAEM0NMCP_COVENANT_MODE=strict. Full listing: DAEM0NMCP_TOOL_SURFACE=full. Rules: - Never use paths as workspace selectors. diff --git a/AGENTS.md b/AGENTS.md index 7396711..0593355 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,19 +61,14 @@ the same tool in any of these exact forms: If none of those forms is available, proceed without Daem0n. Do not guess a legacy tool name. -### 1. Start the scoped session +### 1. Brief automatically -Immediately call: - -```text -daem0nmcp_session_brief(workspace_id="") -``` +The first Daem0n call in a session briefs automatically; the compact brief is returned in `meta.covenant.auto_brief`. Call `session_brief` for the full brief. The server-issued session and authenticated transport identity establish the -scope. Headers, IP addresses, client information, and `_client_meta` are not -identity inputs. +scope. Headers, IP addresses and client information are not identity inputs. -### 2. Recall and preflight before protected work +### 2. Recall, direct writes, and advance planning Use bounded recall when you need relevant history: @@ -81,24 +76,18 @@ Use bounded recall when you need relevant history: daem0nmcp_memory_recall(workspace_id="", query="authentication", limit=10) ``` -Before a protected tool, request counsel for that exact tool and its exact -arguments (excluding `workspace_id` and `preflight_token`): +Call `memory_store` (or any protected tool) directly. If it returns `COUNSEL_REQUIRED`, read `error.counsel` (guidance and reasons), then retry exactly `error.remedy`. `memory_preflight` remains available for planning a change in advance. -```text -daem0nmcp_memory_preflight( - workspace_id="", - target_tool="memory_store", - target_arguments={ - "record_type": "decision", - "content": "Use signed session cookies", - "idempotency_key": "decision-auth-cookie-0001" - }, - description="Record the authentication decision" -) -``` +Respect warnings, failed approaches, and `must_not` guidance. Challenge tokens +remain exact-argument, single-use capabilities valid for 300 seconds and bound +to workspace, principal, session, and tool. + +Use `daem0n_tools_search(query)`, then `daem0n_tool_call(workspace_id, tool, arguments)`. -Respect warnings, failed approaches, and `must_not` guidance. Use the returned -`preflight_token` only with the exact protected request it authorizes. +Defaults are `DAEM0NMCP_COVENANT_MODE=guided` and `DAEM0NMCP_TOOL_SURFACE=core`. +Set `DAEM0NMCP_COVENANT_MODE=strict` for explicit `session_brief` then exact +`memory_preflight` and token-bearing writes; `DAEM0NMCP_TOOL_SURFACE=full` +lists all registered tools. ### 3. Store durable decisions replay-safely @@ -108,8 +97,7 @@ daem0nmcp_memory_store( record_type="decision", content="Use signed session cookies", rationale="Avoid server-side session state", - idempotency_key="decision-auth-cookie-0001", - preflight_token="" + idempotency_key="decision-auth-cookie-0001" ) ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 302a52a..bf7c9f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,38 @@ ### Added - Reproducible v7 core foundation with opt-in dependency profiles and lazy capability reporting. - `uninstall-claude-hooks` strips the pairing env keys from the project's `.claude/settings.local.json` (the project is `--project-path` or the current directory, and only a project the installer paired is touched); `--remove-credentials` also deletes its local edit-bridge credential and socket directories. +- Schema 33 projects explicit `informed_by` memory provenance and verification-weighted outcomes. Existing native outcomes are backfilled during upgrade; legacy imports do not invent learning signals. `memory_store` and `memory_record_outcome` accept the new evidence fields without changing idempotency hashes for unchanged requests. +- Shadow-first utility ranking with immediate or discounted provenance credit, repository code-binding validity and successful-outcome rebinding, and intent-aware evidence retention across direct and linked-workspace recall. +- Optional Python 3.11+ `late-interaction` profile for lazy ColBERT/MaxSim reranking. Missing profiles report unavailable instead of falling back to embedding reranking. +- Graded coding-memory evaluation with deterministic lexical fixtures and optional real-provider arms, reporting ranking quality, validity, retained procedure facts, tokens and latency without changing the frozen retrieval benchmark. +- Canonical offline `bash autoresearch.sh` harness reports coding-memory nDCG and deterministic quality metrics using the existing fixed lexical workload, plus real MCP tool-list and guided, strict and destructive-challenge call counts. ### Changed -- Claude Code hooks remind instead of block. `pre_edit` adds a one-line `memory_recall_file`/`memory_preflight` reminder to the model's context and never denies an edit; `stop` reads its stdin event, shows suggestions as a `systemMessage` and never keeps the agent running. +- Guided covenant defaults: the first gated call attaches a compact automatic brief; benign protected calls proceed with inline counsel, while relevant risk and every destructive operation return guidance and an exact retry capability. `DAEM0NMCP_COVENANT_MODE=strict` restores explicit ceremony; tokens retain exact binding, single use and 300-second expiry. +- Core-plus-gateway listing defaults to nine tools, with all 77 still registered and callable. `daem0n_tools_search` discovers hidden schemas and `daem0n_tool_call` relays typed user text, bounded argument diagnostics and callable challenge remedies; `DAEM0NMCP_TOOL_SURFACE=full` lists every tool. +- Discovery reads return retryable `DATABASE_IN_USE` for SQLite contention, including lexical catch-up checks, and healthy graph-generation advances during recall. Invalid schemas, corrupt projections and permanent retrieval failures remain non-retryable. +- Intent-aware evidence retention matches full or boundary-suffix paths and qualified symbols without promoting unrelated same-named evidence. Query path spellings normalize Windows separators and repeated slashes while preserving explicit `./` root scope; bare names remain broader. +- Lexical recall ranks content/rationale matches ahead of tag-assisted matches within the existing query ladder. Content-tier ranking excludes tag frequency while metadata-only discovery and original diagnostic BM25 scores remain unchanged; no index rebuild is required. +- Code-binding validity re-reads bounded source content, detecting same-size timestamp-preserving edits. Recall batches share resolved-file reads under a 32 MiB raw-byte budget (plus one overflow probe); excess remains honestly `unverifiable`, and parsed source caching is capped at 32 MiB and 256 files. +- Symbol-binding fingerprints include AST-owned decorators, including multiline Python wrappers and TypeScript decorator/comment siblings, without changing code-navigation spans or projection identities. Older decorated bindings conservatively require revalidation once. +- Citation-lookalike text is neutralized in federated excerpts and revalidation labels, preventing forged context citations and direct-recall denial while preserving canonical content and raw binding paths. +- Retained federated evidence and citation manifests export channel arrays in canonical order, eliminating hash-seed-dependent payload drift across server restarts. +- Explore-intent shadow retention reuses the identical primary composition in direct and federated recall, avoiding duplicate packing while preserving evidence and diagnostics. +- Symbol-bound writes distinguish unavailable or failed parsing (`CAPABILITY_DEGRADED`, retryable) from invalid symbol references (`INVALID_ARGUMENT`), preserving atomic capture and replay-safe outcome rebinding. +- Symbol bindings prefer exact qualified identities and reject ambiguous short names instead of hashing unrelated definitions together; existing aliases that become ambiguous are `unverifiable`. +- Binding checks report oversized or environmentally inaccessible source as `unverifiable`, not inferred changed content. Definite missing files, directory replacements and workspace escapes still require revalidation; environmental capture failures are retryable capability errors. +- Public retrieval envelopes enforce exact ordered citation-marker parity with their manifests, rejecting missing, repeated, reordered and unbacked citations instead of accepting forged rendered context. +- Intent-aware context packing applies source-specific excerpt limits consistently to structured outcomes and each procedure step, preventing long stale facts from bypassing body limits while preserving step counts and no-intent/explore behavior. +- Unchanged binding sources reuse cache-owned bytes within each recall batch after exact verification, avoiding repeated full-file comparisons without skipping subsequent-recall reads or path checks. +- Binding validation shares parsed symbol spans and line offsets across symbols in an unchanged file, retaining exact fingerprints and parser-availability checks. Root-scoped caches prevent package-initializer qualifications leaking between nested workspaces. +- Store and outcome binding capture share one fresh bounded source snapshot per resolved file, preventing mixed-version symbol sets and duplicate reads/parses while preserving per-reference containment checks and later-call freshness. +- Gateway calls sanitize malformed adapter/service responses as `INTERNAL_ERROR` while preserving `INVALID_ARGUMENT` for caller input errors, single-pass input validation and user-authored path text. +- Discovery contention detection uses SQLite BUSY/LOCKED codes with a narrow code-less fallback, avoiding retryable errors for unrelated failures mentioning “busy” or “locked”. +- Explore recall now labels enabled retention `RETENTION_NOT_APPLICABLE`, preserving its baseline evidence and token usage rather than implying alternate packing. +- Schema 33 upgrades recover missing derived learning parents from complete validated canonical replay before strict backfill, preserving transactional rollback and fail-closed authority validation. Malformed record payloads surface the schema-upgrade error contract instead of raw key/type exceptions. +- Entity recall preserves retryable lexical catchup when a generation publishes between retrieval and readiness inspection, avoiding terminal capability errors for evidence that becomes available immediately after the query. Same-generation indexed misses remain terminal. +- Local dense projection cleanup closes staging collection persistence before deleting it, preventing leaked SQLite handles on failed or cancelled builds. +- Claude Code hooks remind instead of block. `pre_edit` adds a one-line gateway file-recall reminder; `stop` suggests direct replay-safe memory calls with counsel-challenge retry guidance. Neither hook writes memory, denies an edit, or keeps the agent running. - OpenCode edits are no longer gated either: the OpenCode plugin runs the same `pre_edit` hook, which now always allows the edit. The plugin does not yet show the reminder; that rework comes with the edit-bridge removal. - `install-claude-hooks` and `uninstall-claude-hooks` refuse to touch a `~/.claude/settings.json` that is not valid JSON, instead of replacing it. - The manual `hooks/daem0n_*.py` scripts are deprecated stubs that print a notice and exit 0, and `hooks/settings.json.example` is removed. If you installed them by hand, re-run `python -m daem0nmcp.cli install-claude-hooks`; it also removes the old entries. diff --git a/README.md b/README.md index 216a6d5..5154b63 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,20 @@ ## v7 Protocol Cutover -The public v7 MCP surface is strict and workspace-scoped. Begin with -`session_brief`, use `memory_recall` for bounded context, obtain an exact -`memory_preflight` capability before protected work, write decisions through -replay-safe `memory_store`, and finish with `memory_record_outcome`. Use -`system_health` for diagnostics. +The public v7 MCP surface is guided and workspace-scoped: + +1. The first Daem0n call in a session briefs automatically; the compact brief is returned in `meta.covenant.auto_brief`. Call `session_brief` for the full brief. +2. Call `memory_store` (or any protected tool) directly. If it returns `COUNSEL_REQUIRED`, read `error.counsel` (guidance and reasons), then retry exactly `error.remedy`. `memory_preflight` remains available for planning a change in advance. +3. Use `daem0n_tools_search(query)`, then `daem0n_tool_call(workspace_id, tool, arguments)`. + +The default listing contains nine tools; all 77 remain registered and callable. +Set `DAEM0NMCP_TOOL_SURFACE=full` to list every tool, or +`DAEM0NMCP_COVENANT_MODE=strict` to require explicit briefing and preflight. +Tokens remain exact-argument bound, single-use, and valid for 300 seconds. +Destructive operations always require a counsel challenge. Linked workspaces +still require their own explicit `session_brief`; task submission stays strict. +Run long-running targets directly as MCP tasks, using the full listing if +needed; the gateway does not submit tasks. Use `system_health` for diagnostics. Each scoped call uses an opaque `workspace_id`. The supported transports are stdio and Streamable HTTP at `/mcp`. Read-only warnings, failures, rules, and @@ -39,13 +48,13 @@ pip install . After v7 is published, `pip install daem0nmcp` will install the small core. Add only the feature profiles the deployment needs: `tasks`, `local`, `graph`, -`apps`, `models-local`, `models-hosted`, `agency-e2b`, or `observability`. +`apps`, `models-local`, `late-interaction`, `models-hosted`, `agency-e2b`, or `observability`. For example, `pip install "daem0nmcp[apps,graph]"`. The development and compatibility profiles (`dev` and `tracing`) are not required by a normal MCP -server. `models-local` is optional and requires Python 3.11 or newer. Its -secure ONNX runtime dependency has no CPython 3.10 wheel. On Python 3.10, the -resolver omits this profile's dependencies and the runtime reports the -Python-version remediation while core installation remains supported. +server. `models-local` and `late-interaction` are optional and require Python +3.11 or newer. Their secure ONNX runtime dependency has no CPython 3.10 wheel. +On Python 3.10, the resolver omits these profiles' dependencies and the runtime +reports Python-version remediation while core installation remains supported. The server owns workspace registration. Set `DAEM0NMCP_PROJECT_ROOT` to the default workspace and, when needed, `DAEM0NMCP_WORKSPACE_ROOTS` to the allowed @@ -132,8 +141,8 @@ python -m daem0nmcp.cli uninstall-claude-hooks # Remove | Hook | Purpose | |------|---------| -| `session_start` | Suggest scoped `session_brief` at session dawn | -| `pre_edit` | Fail closed with exact `memory_preflight` guidance | +| `session_start` | Explain automatic briefing and optional full `session_brief` | +| `pre_edit` | Remind agents to recall file guidance through `daem0n_tool_call` | | `pre_bash` | Rule enforcement on bash commands | | `post_edit` | Suggest replay-safe memory calls for significant changes | | `stop` | Suggest memory and outcome calls without writing directly | @@ -376,17 +385,20 @@ For OpenCode setup, see the [OpenCode Integration](#opencode-integration) sectio ## v7 MCP API -The v7 public API uses strict, workspace-scoped tools rather than action -dispatch. The core protocol surface is: +The v7 public API uses strict input schemas and opaque workspace scope. The +default core-plus-gateway listing is: | Tool | Purpose | |---|---| -| `session_brief` | Establish the server-issued session scope and return a bounded briefing | -| `memory_preflight` | Recall guidance and issue a capability for one exact protected request | +| `session_brief` | Return the full briefing; the first gated call already briefs automatically | +| `memory_preflight` | Plan ahead with guidance and a capability for one exact protected request | | `memory_recall` | Retrieve bounded, evidence-bearing records | | `memory_store` | Append one replay-safe memory record | | `memory_record_outcome` | Append a verified success or failure outcome | | `system_health` | Return bounded service and projection diagnostics | +| `edit_preflight` | Admit native edit-bridge requests | +| `daem0n_tools_search` | Discover other registered tools and their input schemas | +| `daem0n_tool_call` | Call a discovered tool and relay its guidance or result | The read-only JSON resources are: @@ -518,6 +530,8 @@ Recognize only the exact canonical name or its host prefix: | `memory_store` | `mcp__daem0nmcp__memory_store` | `daem0nmcp_memory_store` | | `memory_record_outcome` | `mcp__daem0nmcp__memory_record_outcome` | `daem0nmcp_memory_record_outcome` | | `system_health` | `mcp__daem0nmcp__system_health` | `daem0nmcp_system_health` | +| `daem0n_tools_search` | `mcp__daem0nmcp__daem0n_tools_search` | `daem0nmcp_daem0n_tools_search` | +| `daem0n_tool_call` | `mcp__daem0nmcp__daem0n_tool_call` | `daem0nmcp_daem0n_tool_call` | Host metadata is never an identity source. Authentication comes from the transport, and every scoped request is resolved through `workspace_id`. @@ -526,6 +540,9 @@ transport, and every scoped request is resolved through `workspace_id`. ### Session Start +The first gated call returns a compact automatic brief. Request the full brief +explicitly when needed: + ```text session_brief( workspace_id="", @@ -546,24 +563,7 @@ memory_recall( ### Store a Memory -First request a capability bound to the exact target arguments: - -```text -memory_preflight( - workspace_id="", - target_tool="memory_store", - target_arguments={ - "record_type": "decision", - "content": "Use signed session cookies", - "rationale": "Avoid server-side session state", - "tags": ["auth", "architecture"], - "relative_file_path": "src/auth/session.py", - "idempotency_key": "decision-auth-session-0001" - } -) -``` - -Then use the same arguments and returned token: +Call directly; no preflight is needed for a benign write in guided mode: ```text memory_store( @@ -573,11 +573,15 @@ memory_store( rationale="Avoid server-side session state", tags=["auth", "architecture"], relative_file_path="src/auth/session.py", - idempotency_key="decision-auth-session-0001", - preflight_token="" + idempotency_key="decision-auth-session-0001" ) ``` +If the response is `COUNSEL_REQUIRED`, review `error.counsel`, then call +`error.remedy.tool` with exactly `error.remedy.arguments`. The remedy already +includes its pre-minted token. `memory_preflight` remains available for advance +planning and is required in strict mode. Keep the idempotency key on retries. + ### Track Outcomes ```text @@ -595,11 +599,10 @@ memory_record_outcome( The recommended workflow for AI agents: ```text -session_brief +first gated call (automatic brief) -> memory_recall (when relevant) - -> memory_preflight (exact protected tool + exact arguments) - -> protected tool with preflight_token - -> memory_store (for durable decisions, replay-safe) + -> protected tool directly + -> review error.counsel and retry error.remedy (only when challenged) -> memory_record_outcome (after verification, replay-safe) ``` @@ -624,12 +627,12 @@ Daem0n keeps its own state in `~/.daem0nmcp/`). Other hooks in that file are kep | Event (matcher) | Hook | What it does | Input | Reminds only | |-----------------|------|--------------|-------|--------------| -| `SessionStart` | `session_start` | Adds a line to the model's context asking it to call `session_brief` with this workspace's `workspace_id` | `CLAUDE_PROJECT_DIR` env | Yes | -| `PreToolUse` (`Edit\|Write\|NotebookEdit`) | `pre_edit` | Inside a Daem0n project, adds a one-line `additionalContext` reminder to call `memory_recall_file` for the file and `memory_preflight` for the change; silent elsewhere | stdin event | Yes | +| `SessionStart` | `session_start` | Explains that the first Daem0n call establishes the session and brief; `session_brief` returns the full brief | `CLAUDE_PROJECT_DIR` env | Yes | +| `PreToolUse` (`Edit\|Write\|NotebookEdit`) | `pre_edit` | Inside a Daem0n project, adds a one-line `additionalContext` reminder to call `daem0n_tool_call` targeting `memory_recall_file`; silent elsewhere | stdin event | Yes | | `PreToolUse` (`Bash`) | `pre_bash` | Checks the command against Daem0n rules. Currently inert: it reads a `TOOL_INPUT` env var that Claude Code does not set | `TOOL_INPUT` env | Yes (always exits 0) | | `PostToolUse` (`mcp__.*__edit_preflight`) | `post_edit_preflight` | Edit-bridge plumbing: stages an `edit_preflight` receipt for a bridge edit request. Nothing in Claude Code creates those requests any more, so it is a no-op | stdin event | Yes | | `PostToolUse` (`Edit\|Write\|NotebookEdit`) | `post_edit` | Edit-bridge plumbing: reports a bridge-approved edit as a capture candidate. Only loads the bridge when the project is paired; a no-op in Claude Code today | stdin event | Yes | -| `Stop`, `SubagentStop` | `stop` | When the transcript shows a finished task with no recorded outcome, shows you suggested `memory_preflight`/`memory_store`/`memory_record_outcome` calls to ask Claude for, as a `systemMessage`. Never writes memory | stdin event (`transcript_path`) | Yes | +| `Stop`, `SubagentStop` | `stop` | When the transcript shows a finished task with no recorded outcome, suggests direct `memory_store` and `memory_record_outcome` calls with counsel-challenge retry guidance as a `systemMessage`. Never writes memory | stdin event (`transcript_path`) | Yes | No hook blocks a tool call or keeps the agent running: every hook exits 0 and none returns a `permissionDecision` or `decision`. @@ -1063,6 +1066,78 @@ python -m daem0nmcp.cli index Supports Python, TypeScript, JavaScript, Go, Rust, Java, C, C++, C#, Ruby, PHP via tree-sitter. +## Outcome-informed memory + +`memory_store.informed_by` and `memory_record_outcome.informed_by` accept up to +32 existing record IDs in the same workspace. They record explicit provenance; +recall remains read-only. Outcomes may include +`verification={"kind": "test", "command": "pytest", "exit_code": 0}`; verification +is caller-reported evidence, not a command executed by the server. + +Schema 33 derives learning signals and provenance from canonical events. The +v7 upgrader restores missing derived record parents only after complete canonical +authority replay succeeds, then backfills learning evidence in the same +transaction. Invalid authority fails closed; failed upgrades leave the active +database and pointer unchanged. + +Utility ranking defaults to **shadow**: it reports estimates and diagnostics +without changing retrieval order. Shadow still computes those estimates and can +add database work and query latency; use `off` to skip that computation. Opt in +to ranking changes with: + +```bash +DAEM0NMCP_RETRIEVAL_UTILITY_MODE=apply +DAEM0NMCP_RETRIEVAL_UTILITY_CREDIT=trace +``` + +`single_step` uses immediate outcome credit; `trace` also propagates bounded, +discounted credit through explicit provenance. The default ranking weight is +0.1. Set `DAEM0NMCP_RETRIEVAL_UTILITY_MODE=off` to disable computation. Failed +decisions remain warnings rather than being demoted by their own failures. + +### Bind guidance to repository code + +`memory_store.code_refs` accepts up to 16 +`{"relative_file_path": "src/x.py", "qualified_name": "f"}` references. +Omit `qualified_name` to bind the whole file. Qualified names select a specific +symbol; a bare name binds every matching declaration in that file. Fingerprints +ignore CRLF/LF differences, and symbol bindings ignore edits to sibling symbols. +The server manages `context.code_bindings`; callers cannot set it themselves. + +Recall reports `applicability` (`current`, `needs_revalidation`, or +`unverifiable`) and `changed_bindings`. Parser unavailability is not proof that +code changed. The default validity mode is `shadow`; set +`DAEM0NMCP_MEMORY_VALIDITY_MODE=apply` to include revalidation labels in context, +or `off` to skip checks. After checking changed code, record a successful +outcome with `rebind_code=true` to refresh existing bindings. + +### Keep task-specific evidence under a context budget + +`memory_recall` accepts an optional `intent`: `explore`, `implement`, `debug`, +or `review`. Implement/debug retention reserves room for procedures and matching +code bindings, with shorter ordinary excerpts; review also bounds stale guidance. +The default `DAEM0NMCP_RETRIEVAL_RETENTION_MODE=shadow` computes retention diagnostics +without changing context or ranking. Set it to `apply` to change evidence packing, +or `off` to skip computation. Without an intent, context packing is unchanged. +These settings also apply to linked-workspace recall. + +### Optional ColBERT reranking + +Install `pip install "daem0nmcp[late-interaction]"` and set: + +```bash +DAEM0NMCP_RETRIEVAL_RERANK_ENABLED=true +DAEM0NMCP_RETRIEVAL_RERANKER=late_interaction +``` + +Request `memory_recall(..., rerank=true)`. The default model is +`colbert-ir/colbertv2.0`, configured by +`DAEM0NMCP_RETRIEVAL_LATE_INTERACTION_MODEL`. It loads lazily and may download +weights on first use; warm the model before measuring latency. Cold downloads +can exceed the provider timeout (`DAEM0NMCP_QDRANT_TIMEOUT_SECONDS`) and report +`RERANKER_FAILED` while preserving the original order. An unavailable profile +reports `RERANKER_UNAVAILABLE`; it never silently substitutes embedding reranking. + ## Troubleshooting ### MCP Tools Not Available in Claude Session @@ -1138,6 +1213,39 @@ python -m daem0nmcp.server python start_server.py --port 9876 ``` +### Graded coding-memory evaluation + +```bash +python -m benchmarks.coding_memory_eval --mode lexical_only --topics 24 --seed 20261004 --output coding-eval.json +# Optional real-provider comparison (install local, models-local, graph and late-interaction): +python -m benchmarks.coding_memory_eval --mode fully_enabled --topics 24 --seed 20261004 --output coding-eval-full.json +``` + +This isolated synthetic workload exercises outcome reuse, multi-hop provenance, +symbol/file validity and budgeted procedure retention through the production +writer and recall service. The lexical mode requires no model downloads. +Insertion order and event times are fixed; latency measurements are wall-clock +and excluded from deterministic comparisons. The fixture uses an exclusively +created deterministic temporary directory; concurrent identical runs fail +closed rather than sharing storage. + +Reports expose all arm settings. Utility weight is **0.2 in the evaluation** +(the product default stays 0.1). Retention distractors are same-category +background procedure drafts with task tags: this prevents category diversity +from automatically rescuing the actionable procedure and tests actual packing +under a 256-token budget. These synthetic gains are not a competitor comparison. +The release-frozen retrieval corpus and its digest remain unchanged. + +`bash autoresearch.sh` runs the same offline lexical workload with 24 topics, +seed 20261004 and a fixed event clock, emitting `METRIC coding_memory_ndcg` +(higher is better) plus ranking, retention, validity and token metrics. A fresh +offline MCP workspace also reports `listed_tool_count`, `guided_write_calls`, +`strict_explicit_write_calls` and `destructive_challenge_calls`. The strict +workload separately verifies rejection of an unbriefed direct write before +measuring the explicit briefing/preflight/write flow; destructive call counts +exclude the selection preview. It uses the project virtual environment and +excludes wall-clock latency. + ## Support If Daem0nMCP has been useful to you, consider supporting its development: diff --git a/Summon_Daem0n.md b/Summon_Daem0n.md index c8a0431..eda0add 100644 --- a/Summon_Daem0n.md +++ b/Summon_Daem0n.md @@ -62,14 +62,9 @@ mcp__daem0nmcp__system_health( ## 3. Begin every scoped session -Use the configured, opaque workspace selector exactly as issued. The first -scoped call is: +Use the configured, opaque workspace selector exactly as issued. -```text -mcp__daem0nmcp__session_brief( - workspace_id="" -) -``` +The first Daem0n call in a session briefs automatically; the compact brief is returned in `meta.covenant.auto_brief`. Call `session_brief` for the full brief. The server-issued session and authenticated transport establish scope. Request headers, addresses, client descriptions, and arbitrary caller-supplied metadata @@ -91,28 +86,18 @@ mcp__daem0nmcp__memory_recall( Treat returned evidence as counsel. Respect `must_not`, warnings, and failed approaches before protected work. -## 5. Preflight the exact protected request +## 5. Direct protected calls and advance planning -Before `memory_store`, call `memory_preflight` for that exact tool and its exact -arguments. Exclude only `workspace_id` and `preflight_token` from -`target_arguments`: +Call `memory_store` (or any protected tool) directly. If it returns `COUNSEL_REQUIRED`, read `error.counsel` (guidance and reasons), then retry exactly `error.remedy`. `memory_preflight` remains available for planning a change in advance. -```text -mcp__daem0nmcp__memory_preflight( - workspace_id="", - target_tool="memory_store", - target_arguments={ - "record_type": "decision", - "content": "Use signed session cookies", - "rationale": "Avoid shared server-side session state", - "idempotency_key": "decision-auth-cookie-0001" - }, - description="Record the authentication decision" -) -``` +Use `daem0n_tools_search(query)`, then `daem0n_tool_call(workspace_id, tool, arguments)`. -Use the returned `preflight_token` only once with the exact request it -authorizes. If the arguments change, request a new preflight. +Tokens remain exact-argument, single-use capabilities valid for 300 seconds, +bound to workspace, principal, session, and tool. Respect all returned guidance. +Defaults are `DAEM0NMCP_COVENANT_MODE=guided` and `DAEM0NMCP_TOOL_SURFACE=core`. +Set `DAEM0NMCP_COVENANT_MODE=strict` for explicit `session_brief`, exact +`memory_preflight`, and token-bearing writes; `DAEM0NMCP_TOOL_SURFACE=full` +lists all registered tools. ## 6. Store durable knowledge replay-safely @@ -125,8 +110,7 @@ mcp__daem0nmcp__memory_store( record_type="decision", content="Use signed session cookies", rationale="Avoid shared server-side session state", - idempotency_key="decision-auth-cookie-0001", - preflight_token="" + idempotency_key="decision-auth-cookie-0001" ) ``` @@ -179,12 +163,12 @@ them writes Daem0n memory. | Event (matcher) | Hook | What it does | Input | Reminds only | |-----------------|------|--------------|-------|--------------| -| `SessionStart` | `session_start` | Adds a line to the model's context asking it to call `session_brief` with this workspace's `workspace_id` | `CLAUDE_PROJECT_DIR` env | Yes | -| `PreToolUse` (`Edit\|Write\|NotebookEdit`) | `pre_edit` | Inside a Daem0n project, adds a one-line `additionalContext` reminder to call `memory_recall_file` for the file and `memory_preflight` for the change; silent elsewhere | stdin event | Yes | +| `SessionStart` | `session_start` | Explains automatic session briefing and optional `session_brief` for the full brief | `CLAUDE_PROJECT_DIR` env | Yes | +| `PreToolUse` (`Edit\|Write\|NotebookEdit`) | `pre_edit` | Adds a one-line `additionalContext` reminder to call `memory_recall_file` through `daem0n_tool_call` for past decisions and warnings; silent outside a Daem0n project | stdin event | Yes | | `PreToolUse` (`Bash`) | `pre_bash` | Checks the command against Daem0n rules. Currently inert: it reads a `TOOL_INPUT` env var that Claude Code does not set | `TOOL_INPUT` env | Yes (always exits 0) | | `PostToolUse` (`mcp__.*__edit_preflight`) | `post_edit_preflight` | Edit-bridge plumbing: stages an `edit_preflight` receipt for a bridge edit request. Nothing in Claude Code creates those requests any more, so it is a no-op | stdin event | Yes | | `PostToolUse` (`Edit\|Write\|NotebookEdit`) | `post_edit` | Edit-bridge plumbing: reports a bridge-approved edit as a capture candidate. Only loads the bridge when the project is paired; a no-op in Claude Code today | stdin event | Yes | -| `Stop`, `SubagentStop` | `stop` | When the transcript shows a finished task with no recorded outcome, shows you suggested `memory_preflight`/`memory_store`/`memory_record_outcome` calls to ask Claude for, as a `systemMessage`. Never writes memory | stdin event (`transcript_path`) | Yes | +| `Stop`, `SubagentStop` | `stop` | Suggests direct `memory_store` calls with challenge-retry guidance and `memory_record_outcome` calls as a `systemMessage`. Never writes memory | stdin event (`transcript_path`) | Yes | No hook blocks a tool call or keeps the agent running: every hook exits 0 and none returns a `permissionDecision` or `decision`. @@ -201,6 +185,6 @@ The generated source of truth for renamed or split v6 capabilities is [`docs/v6-to-v7-tools.json`](docs/v6-to-v7-tools.json). Consult that mapping instead of copying an older invocation into a prompt, hook, or skill. -The complete ritual is therefore: `session_brief`, bounded `memory_recall`, -exact `memory_preflight`, replay-safe `memory_store`, and verified -`memory_record_outcome`, with `system_health` available for diagnostics. +The guided ritual is automatic briefing, bounded `memory_recall`, direct +replay-safe `memory_store` with challenge retries when needed, and verified +`memory_record_outcome`; `memory_preflight` is available for advance planning. diff --git a/Summon_Daem0n_OpenCode.md b/Summon_Daem0n_OpenCode.md index 913c245..681a861 100644 --- a/Summon_Daem0n_OpenCode.md +++ b/Summon_Daem0n_OpenCode.md @@ -51,13 +51,7 @@ name. ## Run the v7 ritual -Start the scoped session immediately: - -```text -daem0nmcp_session_brief( - workspace_id="" -) -``` +The first Daem0n call in a session briefs automatically; the compact brief is returned in `meta.covenant.auto_brief`. Call `session_brief` for the full brief. Recall bounded history when it is relevant: @@ -69,25 +63,17 @@ daem0nmcp_memory_recall( ) ``` -Before a protected call, preflight its exact effective arguments. For a durable -decision: +Call `memory_store` (or any protected tool) directly. If it returns `COUNSEL_REQUIRED`, read `error.counsel` (guidance and reasons), then retry exactly `error.remedy`. `memory_preflight` remains available for planning a change in advance. -```text -daem0nmcp_memory_preflight( - workspace_id="", - target_tool="memory_store", - target_arguments={ - "record_type": "decision", - "content": "Use signed session cookies", - "rationale": "Avoid shared server-side session state", - "idempotency_key": "decision-auth-cookie-0001" - }, - description="Record the authentication decision" -) -``` +Use `daem0n_tools_search(query)`, then `daem0n_tool_call(workspace_id, tool, arguments)`. + +Tokens remain exact-argument, single-use capabilities valid for 300 seconds, +bound to workspace, principal, session, and tool. Defaults are guided covenant +and core tool listing. Set `DAEM0NMCP_COVENANT_MODE=strict` for explicit +`session_brief`, exact `memory_preflight`, and token-bearing writes; +`DAEM0NMCP_TOOL_SURFACE=full` lists all tools. -Respect all returned warnings, failed approaches, and `must_not` guidance. Use -the returned token only with the exact protected request: +For a durable decision: ```text daem0nmcp_memory_store( @@ -95,8 +81,7 @@ daem0nmcp_memory_store( record_type="decision", content="Use signed session cookies", rationale="Avoid shared server-side session state", - idempotency_key="decision-auth-cookie-0001", - preflight_token="" + idempotency_key="decision-auth-cookie-0001" ) ``` @@ -150,6 +135,6 @@ The generated migration source of truth is [`docs/v6-to-v7-tools.json`](docs/v6-to-v7-tools.json). Use it whenever an old guide or prompt names a capability that moved or split in v7. -The complete ritual is `session_brief`, bounded `memory_recall`, exact -`memory_preflight`, replay-safe `memory_store`, and verified -`memory_record_outcome`; use `system_health` when the ritual cannot proceed. +The guided ritual is automatic briefing, bounded recall, direct replay-safe +`memory_store` with challenge retries when needed, and verified +`memory_record_outcome`; `memory_preflight` is optional advance planning. diff --git a/autoresearch.sh b/autoresearch.sh new file mode 100755 index 0000000..c59edc6 --- /dev/null +++ b/autoresearch.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +cd -- "$(dirname -- "${BASH_SOURCE[0]}")" +export PYTHONHASHSEED=0 +export HF_HUB_OFFLINE=1 +export TRANSFORMERS_OFFLINE=1 +export HF_DATASETS_OFFLINE=1 +if [[ -x .venv/Scripts/python.exe ]]; then + python=.venv/Scripts/python.exe +elif [[ -x .venv/bin/python ]]; then + python=.venv/bin/python +else + printf '%s\n' 'A project virtual environment is required.' >&2 + exit 1 +fi +exec "$python" -m benchmarks.autoresearch_benchmark diff --git a/benchmarks/autoresearch_benchmark.py b/benchmarks/autoresearch_benchmark.py new file mode 100644 index 0000000..3767b17 --- /dev/null +++ b/benchmarks/autoresearch_benchmark.py @@ -0,0 +1,183 @@ +"""Offline autoresearch entrypoint for the existing coding-memory workload.""" + +from __future__ import annotations + +import asyncio +import math +import tempfile +from pathlib import Path +from typing import Any + +from fastmcp import Client + +from benchmarks.coding_memory_eval import _settings, run_evaluation +from daem0nmcp.api.v7.production import create_v7_server +from daem0nmcp.database import DatabaseManager +from daem0nmcp.workspace import WorkspaceRegistry + + +async def _ergonomics_metrics() -> dict[str, int]: + metrics: dict[str, int] = {} + for mode in ("guided", "strict"): + with tempfile.TemporaryDirectory( + prefix=f"daem0nmcp-ergonomics-{mode}-" + ) as root: + workspace_root = Path(root) / "workspace" + storage = workspace_root / ".daem0nmcp" / "storage" + storage.mkdir(parents=True) + manager = DatabaseManager(str(storage)) + try: + await manager.init_db() + finally: + await manager.close() + workspace = WorkspaceRegistry( + [workspace_root], default_root=workspace_root + ).default + settings = _settings( + workspace, + {"covenant_mode": mode, "tool_surface": "core", "dream_enabled": False}, + ) + environment = { + "DAEM0NMCP_LOCAL_ENABLED": "false", + "DAEM0NMCP_MODELS_LOCAL_ENABLED": "false", + "DAEM0NMCP_GRAPH_ENABLED": "false", + "DAEM0NMCP_LATE_INTERACTION_ENABLED": "false", + } + server = create_v7_server("stdio", settings=settings, environ=environment) + scope = {"workspace_id": workspace.workspace_id} + arguments = { + **scope, + "record_type": "decision", + "content": "Keep benchmark memory isolated and replay-safe.", + "idempotency_key": "ergonomics-first-write", + } + if mode == "strict": + async with Client(server) as probe: + rejected = await probe.call_tool( + "memory_store", arguments, raise_on_error=False + ) + envelope = rejected.structured_content + if ( + envelope is None + or envelope["ok"] + or envelope["error"]["code"] != "COMMUNION_REQUIRED" + or envelope["error"]["remedy"]["tool"] != "session_brief" + ): + raise RuntimeError( + "Strict direct write did not require briefing" + ) + server = create_v7_server( + "stdio", settings=settings, environ=environment + ) + calls = 0 + async with Client(server) as client: + + async def invoke(tool: str, values: dict[str, Any]) -> dict[str, Any]: + nonlocal calls + calls += 1 + result = await client.call_tool(tool, values, raise_on_error=False) + envelope = result.structured_content + if envelope is None: + raise RuntimeError(f"Missing benchmark response: {tool}") + return envelope + + if mode == "guided": + metrics["listed_tool_count"] = len(await client.list_tools()) + else: + brief = await invoke("session_brief", scope) + if not brief["ok"]: + raise RuntimeError("Strict benchmark briefing failed") + counsel = await invoke( + "memory_preflight", + { + **scope, + "target_tool": "memory_store", + "target_arguments": { + key: value + for key, value in arguments.items() + if key != "workspace_id" + }, + }, + ) + if not counsel["ok"]: + raise RuntimeError("Strict benchmark counsel failed") + arguments["preflight_token"] = counsel["data"]["preflight_token"] + stored = await invoke("memory_store", arguments) + if not stored["ok"]: + raise RuntimeError(f"{mode} benchmark write failed") + if mode == "guided" and not stored["meta"]["covenant"]["auto_brief"]: + raise RuntimeError("Guided benchmark did not brief automatically") + metrics[ + "guided_write_calls" + if mode == "guided" + else "strict_explicit_write_calls" + ] = calls + if mode == "guided": + preview = await invoke( + "daem0n_tool_call", + {**scope, "tool": "memory_prune_preview", "arguments": {}}, + ) + if not preview["ok"]: + raise RuntimeError("Destructive benchmark preview failed") + start = calls + challenge = await invoke( + "daem0n_tool_call", + { + **scope, + "tool": "memory_prune", + "arguments": { + "selection_token": preview["data"]["data"][ + "selection_token" + ] + }, + }, + ) + if ( + challenge["ok"] + or challenge["error"]["code"] != "COUNSEL_REQUIRED" + or "DESTRUCTIVE_OPERATION" + not in challenge["error"]["counsel"]["reasons"] + ): + raise RuntimeError("Destructive benchmark did not escalate") + remedy = challenge["error"]["remedy"] + retried = await invoke(remedy["tool"], remedy["arguments"]) + if not retried["ok"]: + raise RuntimeError("Exact destructive benchmark retry failed") + metrics["destructive_challenge_calls"] = calls - start + expected = { + "listed_tool_count": 9, + "guided_write_calls": 1, + "strict_explicit_write_calls": 3, + "destructive_challenge_calls": 2, + } + if metrics != expected: + raise RuntimeError( + f"Ergonomics contract changed: {metrics!r}, expected {expected!r}" + ) + return metrics + + +async def main() -> None: + report = await run_evaluation(mode="lexical_only", topics=24, seed=20261004) + baseline = report["arms"]["baseline"] + enhanced = report["arms"]["all_apply"] + metrics = { + "coding_memory_ndcg": enhanced["ndcg_at_10"]["overall"], + "baseline_ndcg": baseline["ndcg_at_10"]["overall"], + "outcome_reuse_ndcg": enhanced["ndcg_at_10"]["outcome_reuse"], + "provenance_chain_ndcg": enhanced["ndcg_at_10"]["provenance_chain"], + "required_fact_retention": enhanced["retention"]["required_fact_retention"], + "stale_flag_recall": enhanced["validity"]["stale_flag_recall"], + "false_flag_rate": enhanced["validity"]["false_flag_rate"], + "rendered_tokens_mean": enhanced["tokens"]["rendered_mean"], + } + metrics.update(await _ergonomics_metrics()) + for name, value in metrics.items(): + if not isinstance(value, (int, float)) or not math.isfinite(value): + raise RuntimeError(f"Invalid benchmark metric: {name}={value!r}") + for name, value in metrics.items(): + print(f"METRIC {name}={value:.12f}", flush=True) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/benchmarks/coding_memory_eval.py b/benchmarks/coding_memory_eval.py new file mode 100644 index 0000000..18f7453 --- /dev/null +++ b/benchmarks/coding_memory_eval.py @@ -0,0 +1,628 @@ +"""Graded coding-memory evaluation using canonical writes and production recall.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import math +import random +import shutil +import sqlite3 +import tempfile +from contextlib import closing +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from pathlib import Path +from time import perf_counter_ns +from typing import Any, Literal + +from benchmarks.retrieval_benchmark import calculate_ranking_metrics +from daem0nmcp.api.v7.discovery_operations import default_code_indexer_factory +from daem0nmcp.api.v7.models import RetrievalData +from daem0nmcp.api.v7.pinned import MemoryOutcomeCommand, MemoryStoreCommand +from daem0nmcp.api.v7.runtime_services import ( + SQLiteMemoryEventWriter, + Task8RecallService, +) +from daem0nmcp.capabilities import CapabilityRegistry +from daem0nmcp.config import Settings +from daem0nmcp.migrations.schema import MIGRATIONS +from daem0nmcp.retrieval.runtime import drain_projection_jobs +from daem0nmcp.retrieval.types import RetrievalQuery +from daem0nmcp.schema_version import CURRENT_SCHEMA_VERSION +from daem0nmcp.storage_activation import ActiveDatabasePointer, write_active_pointer +from daem0nmcp.workspace import Workspace, WorkspaceRegistry + +Mode = Literal["lexical_only", "fully_enabled"] +_START = datetime(2026, 1, 1, tzinfo=timezone.utc) +_RETENTION_NOTE_TAG_COUNT = 16 +_UTILITY_WEIGHT = 0.2 + + +@dataclass(frozen=True) +class _Store: + key: str + content: str + record_type: str = "observation" + informed_by: tuple[str, ...] = () + steps: tuple[str, ...] = () + code_refs: tuple[tuple[str, str | None], ...] = () + tags: tuple[str, ...] = () + + +@dataclass(frozen=True) +class _Outcome: + key: str + record_key: str + worked: bool + + +@dataclass(frozen=True) +class _Query: + key: str + family: str + text: str + relevant: tuple[tuple[str, int], ...] + required_fact: str | None = None + + +@dataclass +class _Corpus: + writes: list[_Store | _Outcome] = field(default_factory=list) + queries: list[_Query] = field(default_factory=list) + bindings: dict[str, tuple[bool, bool]] = field(default_factory=dict) + + +class _Clock: + def __init__(self) -> None: + self.value = _START + + def __call__(self) -> datetime: + value = self.value + self.value += timedelta(seconds=1) + return value + + +def _workspace(temporary: Path) -> tuple[Workspace, Path]: + root = temporary / "workspace" + root.mkdir() + storage = root / ".daem0nmcp" / "storage" + storage.mkdir(parents=True) + database = storage / "daem0nmcp.db" + migrations = {version: statements for version, _, statements in MIGRATIONS} + with closing(sqlite3.connect(database)) as connection: + connection.execute("PRAGMA foreign_keys=ON") + connection.execute("CREATE TABLE schema_version (version INTEGER PRIMARY KEY)") + for version in range(16, CURRENT_SCHEMA_VERSION + 1): + for statement in migrations[version]: + connection.execute(statement) + connection.execute("INSERT INTO schema_version VALUES (?)", (version,)) + connection.commit() + write_active_pointer( + storage, ActiveDatabasePointer(7, 1, database.name, None, None) + ) + return WorkspaceRegistry([root], default_root=root).default, database + + +def _corpus(root: Path, topics: int, symbols_available: bool) -> _Corpus: + corpus = _Corpus() + (root / "src").mkdir() + (root / "docs").mkdir() + for t in range(topics): + component = ( + "cache", + "session", + "webhook", + "scheduler", + "parser", + "migration", + "exporter", + "indexer", + "uploader", + "notifier", + "throttle", + "ledger", + )[t % 12] + problem = ("timeout", "deadlock", "overflow", "drift", "leak", "race")[ + (t // 12) % 6 + ] + bad, good = f"reuse-{t}-bad", f"reuse-{t}-good" + corpus.writes.extend( + ( + _Store( + bad, + f"{component} {problem} fix: retry the {component} {problem} fix with a longer timeout.", + "decision", + ), + _Store( + good, + f"{component} {problem} fix: invalidate the stale entry before the write completes.", + "decision", + ), + ) + ) + for worked, source in ((True, good), (False, bad)): + for k in range(3): + key = f"reuse-{t}-{worked}-{k}" + corpus.writes.append( + _Store( + key, + f"Applied guidance for ticket {t}-{k}", + "decision", + (source,), + ) + ) + corpus.writes.append(_Outcome(f"{key}-outcome", key, worked)) + corpus.queries.append( + _Query( + f"reuse-{t}", + "outcome_reuse", + f"{component} {problem} fix", + ((good, 3), (bad, 1)), + ) + ) + + component = ("router", "catalog", "billing", "gateway", "renderer", "sandbox")[ + t % 6 + ] + problem = ("quota", "encoding", "latency", "rollback")[(t // 6) % 4] + background, distractor, learning = ( + f"chain-{t}-background", + f"chain-{t}-draft", + f"chain-{t}-learning", + ) + corpus.writes.extend( + ( + _Store( + background, + f"{component} {problem} background: the {component} owner documented the {problem} limits.", + ), + _Store( + distractor, + f"{component} {problem} background {component} {problem} background notes from an unrelated draft.", + ), + _Store( + learning, f"Summary for ticket chain-{t}", "learning", (background,) + ), + ) + ) + for k in range(3): + key = f"chain-{t}-{k}" + corpus.writes.append( + _Store(key, f"Shipped change chain-{t}-{k}", "decision", (learning,)) + ) + corpus.writes.append(_Outcome(f"{key}-outcome", key, True)) + corpus.queries.append( + _Query( + f"chain-{t}", + "provenance_chain", + f"{component} {problem} background", + ((background, 3), (distractor, 1)), + ) + ) + + source = f"src/topic_{t}.py" + document = f"docs/topic_{t}.md" + (root / source).write_text( + f"def handle_topic_{t}(value):\n return value + {t}\n\n\ndef helper_topic_{t}(value):\n return value * {t}\n", + encoding="utf-8", + newline="\n", + ) + (root / document).write_text( + f"topic {t} runbook\n", encoding="utf-8", newline="\n" + ) + if symbols_available: + key = f"validity-{t}-symbol" + corpus.writes.append( + _Store( + key, + f"handle_topic_{t} handler update", + code_refs=((source, f"handle_topic_{t}"),), + ) + ) + corpus.bindings[key] = (t % 2 == 0, True) + corpus.queries.append( + _Query(key, "validity", f"handle_topic_{t} handler update", ((key, 3),)) + ) + key = f"validity-{t}-file" + corpus.writes.append( + _Store(key, f"topic {t} runbook pointer", code_refs=((document, None),)) + ) + corpus.bindings[key] = (t % 3 == 0, False) + corpus.queries.append( + _Query(key, "validity", f"topic {t} runbook pointer", ((key, 3),)) + ) + + component = ("release", "canary", "schemas", "toggles", "bundles", "workers")[ + t % 6 + ] + procedure = f"retention-{t}-procedure" + steps = ( + f"freeze {component} writes", + f"apply {component} rollout", + f"verify {component} health", + ) + corpus.writes.append( + _Store(procedure, f"deploy {component} rollout", "procedure", steps=steps) + ) + for k in range(4): + # Task tags stress weighted BM25; same-category background drafts + # prevent category diversity from trivially rescuing the runbook. + # Empty steps are valid canonical input, not actionable guidance. + tags = tuple( + f"deploy-{component}-rollout-task-{k}-{index}" + for index in range(_RETENTION_NOTE_TAG_COUNT) + ) + corpus.writes.append( + _Store( + f"retention-{t}-note-{k}", + f"deploy {component} rollout deploy {component} rollout " + + "background detail " * 60, + "procedure", + tags=tags, + ) + ) + corpus.queries.append( + _Query( + f"retention-{t}", + "retention", + f"deploy {component} rollout", + ((procedure, 3),), + steps[0], + ) + ) + return corpus + + +async def _seed( + writer: SQLiteMemoryEventWriter, workspace: Workspace, corpus: _Corpus, seed: int +) -> dict[str, str]: + pending = list(corpus.writes) + random.Random(seed).shuffle(pending) + record_ids: dict[str, str] = {} + while pending: + remaining: list[_Store | _Outcome] = [] + for item in pending: + dependencies = ( + item.informed_by if isinstance(item, _Store) else (item.record_key,) + ) + if any(key not in record_ids for key in dependencies): + remaining.append(item) + continue + if isinstance(item, _Store): + result = await writer.store( + workspace, + MemoryStoreCommand( + record_type=item.record_type, + content=item.content, + rationale=None, + context={}, + tags=item.tags, + relative_file_path=None, + happened_at=None, + procedure_steps=item.steps, + idempotency_key=f"coding-eval-{item.key}", + informed_by=tuple(record_ids[key] for key in item.informed_by), + code_refs=item.code_refs, + ), + ) + record_ids[item.key] = result.record.record_id + else: + await writer.record_outcome( + workspace, + MemoryOutcomeCommand( + record_id=record_ids[item.record_key], + outcome_text="Verified episode outcome", + worked=item.worked, + happened_at=None, + idempotency_key=f"coding-eval-{item.key}", + verification={ + "kind": "test", + "exit_code": 0 if item.worked else 1, + }, + ), + ) + if len(remaining) == len(pending): + raise RuntimeError( + "coding evaluation corpus contains unresolved dependencies" + ) + pending = remaining + return record_ids + + +def _edit_bindings(root: Path, topics: int) -> None: + for t in range(topics): + path = root / "src" / f"topic_{t}.py" + source = path.read_text(encoding="utf-8") + source = ( + source.replace(f"+ {t}", f"- {t}") + if t % 2 == 0 + else source.replace(f"* {t}", f"** {t}") + ) + path.write_text(source, encoding="utf-8", newline="\n") + if t % 3 == 0: + document = root / "docs" / f"topic_{t}.md" + document.write_text( + document.read_text(encoding="utf-8") + "updated runbook\n", + encoding="utf-8", + newline="\n", + ) + + +def _arms(mode: Mode, statuses: dict[str, str]) -> dict[str, dict[str, Any]]: + defaults: dict[str, Any] = { + "retrieval_utility_mode": "off", + "retrieval_utility_credit": "trace", + "retrieval_utility_weight": _UTILITY_WEIGHT, + "memory_validity_mode": "off", + "retrieval_retention_mode": "off", + "retrieval_rerank_enabled": False, + "retrieval_reranker": "embedding", + } + overrides: dict[str, dict[str, Any]] = { + "baseline": {}, + "utility_single": { + "retrieval_utility_mode": "apply", + "retrieval_utility_credit": "single_step", + }, + "utility_trace": {"retrieval_utility_mode": "apply"}, + "validity_apply": {"memory_validity_mode": "apply"}, + "retention_apply": {"retrieval_retention_mode": "apply"}, + "all_apply": { + "retrieval_utility_mode": "apply", + "memory_validity_mode": "apply", + "retrieval_retention_mode": "apply", + }, + } + if mode == "fully_enabled": + overrides["rerank_embedding"] = {"retrieval_rerank_enabled": True} + if statuses.get("late-interaction") == "ready": + overrides["rerank_late_interaction"] = { + "retrieval_rerank_enabled": True, + "retrieval_reranker": "late_interaction", + } + return {name: defaults | override for name, override in overrides.items()} + + +def _settings(workspace: Workspace, overrides: dict[str, Any]) -> Settings: + # Ignore ambient tool/server settings, especially remote Qdrant endpoints. + defaults = { + name: item.get_default(call_default_factory=True) + for name, item in Settings.model_fields.items() + } + return Settings( + _env_file=None, + **( + defaults + | { + "project_root": str(workspace.root), + "workspace_roots": [str(workspace.root)], + "qdrant_path": str( + workspace.root / ".daem0nmcp" / "storage" / "qdrant" + ), + } + | overrides + ), + ) + + +def _flag_metrics(observations: list[tuple[bool, bool]]) -> dict[str, float | None]: + stale = [flag for expected, flag in observations if expected] + current = [flag for expected, flag in observations if not expected] + return { + "stale_flag_recall": sum(stale) / len(stale) if stale else None, + "false_flag_rate": sum(current) / len(current) if current else None, + } + + +def _percentile(values: list[float], quantile: float) -> float: + ordered = sorted(values) + position = (len(ordered) - 1) * quantile + low, high = math.floor(position), math.ceil(position) + return ordered[low] + (ordered[high] - ordered[low]) * (position - low) + + +def _metrics( + corpus: _Corpus, + record_ids: dict[str, str], + results: dict[str, RetrievalData], + latencies: list[float], + symbols_available: bool, +) -> dict[str, Any]: + queries = [ + { + "query_id": query.key, + "expected_relevant": [ + {"record_id": record_ids[key], "grade": grade} + for key, grade in query.relevant + ], + } + for query in corpus.queries + ] + ranking_results = { + key: {"returned_record_ids": [item.record.record_id for item in result.items]} + for key, result in results.items() + } + overall = calculate_ranking_metrics(queries, ranking_results) + ndcg = {"overall": overall["ndcg_at_10"]} + for family in ("outcome_reuse", "provenance_chain"): + keys = {query.key for query in corpus.queries if query.family == family} + selected = [query for query in queries if query["query_id"] in keys] + ndcg[family] = calculate_ranking_metrics( + selected, {key: ranking_results[key] for key in keys} + )["ndcg_at_10"] + bindings = {record_ids[key]: value for key, value in corpus.bindings.items()} + flags: list[tuple[bool, bool]] = [] + symbol_flags: list[tuple[bool, bool]] = [] + for query in corpus.queries: + if query.family != "validity": + continue + for item in results[query.key].items: + binding = bindings.get(item.record.record_id) + if binding is None or item.applicability is None: + continue + observation = (binding[0], item.applicability == "needs_revalidation") + flags.append(observation) + if binding[1]: + symbol_flags.append(observation) + validity: dict[str, Any] = _flag_metrics(flags) + validity["symbol"] = _flag_metrics(symbol_flags) if symbols_available else None + retention = [query for query in corpus.queries if query.family == "retention"] + retained = sum( + query.required_fact in (results[query.key].rendered_context or "") + for query in retention + ) + return { + "ndcg_at_10": ndcg, + "mrr_at_10": {"overall": overall["mrr_at_10"]}, + "validity": validity, + "retention": {"required_fact_retention": retained / len(retention)}, + "tokens": { + "rendered_mean": sum( + result.token_usage.rendered for result in results.values() + ) + / len(results) + }, + "latency_ms": { + "p50": _percentile(latencies, 0.5), + "p95": _percentile(latencies, 0.95), + }, + } + + +async def run_evaluation( + *, mode: Mode = "lexical_only", topics: int = 24, seed: int = 20261004 +) -> dict[str, Any]: + if mode not in {"lexical_only", "fully_enabled"}: + raise ValueError("unknown coding evaluation mode") + if isinstance(topics, bool) or not isinstance(topics, int) or topics < 1: + raise ValueError("topics must be a positive integer") + if isinstance(seed, bool) or not isinstance(seed, int): + raise ValueError("seed must be an integer") + statuses = ( + { + "local": "disabled", + "models-local": "disabled", + "graph": "disabled", + "late-interaction": "disabled", + } + if mode == "lexical_only" + else { + name: value["status"] for name, value in CapabilityRegistry().all().items() + } + ) + arms = _arms(mode, statuses) + symbols_available = bool( + getattr(default_code_indexer_factory(), "available", False) + ) + # Stable registry-derived IDs also stabilize real provider score ties. An + # exclusive mkdir fails closed rather than touching any existing fixture. + temporary = ( + Path(tempfile.gettempdir()) / f"daem0nmcp-coding-eval-{mode}-{topics}-{seed}" + ) + temporary.mkdir() + try: + workspace, database = _workspace(temporary) + corpus = _corpus(workspace.root, topics, symbols_available) + clock = _Clock() + writer = SQLiteMemoryEventWriter( + clock=clock, projection_scheduler=lambda path: None + ) + try: + record_ids = await _seed(writer, workspace, corpus, seed) + finally: + writer.close() + projection_settings = _settings(workspace, arms["baseline"]) + while await drain_projection_jobs( + database, + config=projection_settings, + max_jobs=100, + include_optional=True, + capability_statuses=statuses, + ): + pass + _edit_bindings(workspace.root, topics) + report: dict[str, Any] = { + "metadata": { + "mode": mode, + "topics": topics, + "seed": seed, + "schema_version": CURRENT_SCHEMA_VERSION, + "capability_statuses": statuses, + "symbol_bindings_available": symbols_available, + "retention_note_tag_count": _RETENTION_NOTE_TAG_COUNT, + "retention_background_note_record_type": "procedure", + "retention_stress_reason": "same-category background drafts prevent category-diversity rescue of actionable steps", + "utility_weight": _UTILITY_WEIGHT, + "arm_overrides": arms, + "workspace_identity": "exclusive_deterministic_scratch_registry", + }, + "arms": {}, + } + snapshot = clock.value + for name, overrides in arms.items(): + service = Task8RecallService( + config=_settings(workspace, overrides), capability_statuses=statuses + ) + results: dict[str, RetrievalData] = {} + latencies: list[float] = [] + try: + for query in corpus.queries: + started = perf_counter_ns() + results[query.key] = await service.retrieve( + workspace, + RetrievalQuery( + workspace_id=workspace.workspace_id, + text=query.text, + as_of_valid_time=snapshot, + as_of_transaction_time=snapshot, + token_budget=256 if query.family == "retention" else 2400, + intent="implement" if query.family == "retention" else None, + rerank=overrides["retrieval_rerank_enabled"], + ), + frozenset(), + ) + latencies.append((perf_counter_ns() - started) / 1_000_000) + finally: + service.close() + report["arms"][name] = _metrics( + corpus, record_ids, results, latencies, symbols_available + ) + return report + finally: + shutil.rmtree(temporary) + + +def _positive_topics(value: str) -> int: + topics = int(value) + if topics < 1: + raise argparse.ArgumentTypeError("topics must be positive") + return topics + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--mode", choices=("lexical_only", "fully_enabled"), default="lexical_only" + ) + parser.add_argument("--topics", type=_positive_topics, default=24) + parser.add_argument("--seed", type=int, default=20261004) + parser.add_argument("--output", type=Path, required=True) + arguments = parser.parse_args(argv) + report = asyncio.run( + run_evaluation( + mode=arguments.mode, topics=arguments.topics, seed=arguments.seed + ) + ) + arguments.output.parent.mkdir(parents=True, exist_ok=True) + arguments.output.write_text( + json.dumps(report, sort_keys=True, indent=2, allow_nan=False) + "\n", + encoding="utf-8", + newline="\n", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/daem0nmcp/api/v7/application.py b/daem0nmcp/api/v7/application.py index 75a76f3..33c207f 100644 --- a/daem0nmcp/api/v7/application.py +++ b/daem0nmcp/api/v7/application.py @@ -15,6 +15,7 @@ from ...covenant import CovenantGate, InvocationScope from ...workspace import Workspace +from .ceremony import CovenantCeremony from .errors import DATABASE_IN_USE_RETRY_AFTER_MS, ErrorCode from .models import CapabilityState from .policy import V7_TOOL_LEVELS @@ -64,6 +65,7 @@ class V7ApplicationDependencies: scope_provider: Callable[[], InvocationScope | None] operations: Mapping[str, ToolOperation] response_factory: ResponseFactory + ceremony: CovenantCeremony | None = None _VIOLATION_MESSAGES: Mapping[str, str] = MappingProxyType( @@ -85,8 +87,6 @@ class V7ApplicationDependencies: } ) -_DURABLE_VALIDATION_TOKEN = "durable.task.validation" - async def _resolve(value: object) -> object: if inspect.isawaitable(value): @@ -146,18 +146,18 @@ def handler(self, tool_name: str) -> Callable[..., Any]: raise ValueError("unknown v7 tool") from exc async def invoke(**arguments: Any) -> object: - # Durable admission persists only normalized arguments and an - # authorization receipt; it deliberately never persists the - # bearer preflight. Recognize the exact worker-installed context - # before adding a syntax-only value for strict model validation. - # A caller-supplied value always follows the ordinary gate below. + # Durable admission persists credential-free normalized arguments. + # Only the exact worker-installed context may bypass the gate. candidate_arguments = dict(arguments) trusted_durable = ( "preflight_token" not in candidate_arguments and is_durable_task_execution(tool_name, candidate_arguments) ) - if trusted_durable and "preflight_token" in input_model.model_fields: - candidate_arguments["preflight_token"] = _DURABLE_VALIDATION_TOKEN + if durable_task_execution_var.get() is not None and not trusted_durable: + return self._dependencies.response_factory.begin(None).failure( + ErrorCode.INVALID_ARGUMENT, + "Request arguments are invalid.", + ) try: validated = input_model.model_validate(candidate_arguments) except (TypeError, ValueError, ValidationError): @@ -196,6 +196,11 @@ async def invoke(**arguments: Any) -> object: durable_execution = trusted_durable and is_durable_task_execution( tool_name, effective ) + if durable_task_execution_var.get() is not None and not durable_execution: + return response.failure( + ErrorCode.INVALID_ARGUMENT, + "Request arguments are invalid.", + ) if durable_execution: execution = durable_task_execution_var.get() if execution is None or not execution.transport_session_id: @@ -228,15 +233,54 @@ async def invoke(**arguments: Any) -> object: token = effective.get("preflight_token") operation = self._dependencies.operations.get(tool_name) admission_only = task_admission_only_var.get() - violation = None + violation: Mapping[str, Any] | None = None if not durable_execution: - violation = self._dependencies.covenant_gate.authorize( - tool_name, - effective, - scope, - preflight_token=token if isinstance(token, str) else None, - consume_capability=(operation is not None and not admission_only), - ) + ceremony = self._dependencies.ceremony + if ceremony is None: + violation = self._dependencies.covenant_gate.authorize( + tool_name, + effective, + scope, + preflight_token=token if isinstance(token, str) else None, + consume_capability=( + operation is not None and not admission_only + ), + ) + else: + outcome = await ceremony.admit( + tool_name, + effective, + workspace, + scope, + preflight_token=token if isinstance(token, str) else None, + consume_capability=( + operation is not None and not admission_only + ), + counsel_mode=( + "off" + if operation is None + else "challenge" + if admission_only + else "inline" + ), + ) + response = response.with_covenant(outcome.notice) + violation = outcome.violation + if outcome.challenge is not None: + return response.failure( + ErrorCode.COUNSEL_REQUIRED, + "Review error.counsel, then retry exactly error.remedy.", + remedy_tool=tool_name, + remedy_arguments={ + **{ + k: v + for k, v in effective.items() + if k != "preflight_token" + }, + "preflight_token": outcome.challenge.preflight_token, + }, + counsel=outcome.challenge, + ) if violation is not None: code = str(violation.get("violation", "INTERNAL_ERROR")) if code not in _VIOLATION_MESSAGES: diff --git a/daem0nmcp/api/v7/ceremony.py b/daem0nmcp/api/v7/ceremony.py new file mode 100644 index 0000000..a227ddc --- /dev/null +++ b/daem0nmcp/api/v7/ceremony.py @@ -0,0 +1,287 @@ +"""Guided admission composed from the exact-argument Covenant gate.""" + +from __future__ import annotations + +import asyncio +import inspect +import re +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Literal, Protocol + +from ...covenant import CovenantGate, CovenantLevel, InvocationScope +from ...workspace import Workspace +from .models import ( + CounselChallenge, + CounselReason, + CovenantBrief, + CovenantNotice, + InlineCounsel, + PreflightGuidance, +) + +RECOVERABLE_TOKEN_CODES = frozenset( + {"TOKEN_EXPIRED", "TOKEN_ARGUMENT_MISMATCH", "TOKEN_REPLAYED"} +) +CounselMode = Literal["inline", "challenge", "off"] +_CONTENT_TOKEN = re.compile(r"[a-z0-9][a-z0-9_-]{3,79}") +_TOKEN_COMPONENT = re.compile(r"[_-]") +_COUNSEL_STOPWORDS = frozenset( + { + "memory", + "memories", + "store", + "stored", + "record", + "records", + "decision", + "decisions", + "pattern", + "patterns", + "learning", + "warning", + "warnings", + "failed", + "failure", + "worked", + "outcome", + "this", + "that", + "with", + "from", + "into", + "when", + "then", + "should", + "would", + "could", + "have", + "been", + "will", + "must", + "because", + "about", + "using", + "used", + } +) + + +class ArgumentNormalizer(Protocol): + def __call__( + self, + operation: str, + arguments: Mapping[str, Any] | None, + workspace: str, + ) -> dict[str, Any]: ... + + +class PreflightService(Protocol): + def guidance( + self, + workspace: Workspace, + target_tool: str, + normalized_arguments: Mapping[str, Any], + description: str | None, + ) -> object | Awaitable[object]: ... + + +@dataclass(frozen=True, slots=True) +class CeremonyOutcome: + violation: Mapping[str, Any] | None + notice: CovenantNotice | None + challenge: CounselChallenge | None + + +def _content_tokens(value: object) -> frozenset[str]: + tokens: set[str] = set() + + def collect(item: object) -> None: + if isinstance(item, str): + for match in _CONTENT_TOKEN.findall(item.casefold()): + tokens.add(match) + tokens.update( + part for part in _TOKEN_COMPONENT.split(match) if len(part) >= 4 + ) + elif isinstance(item, Mapping): + for child in item.values(): + collect(child) + elif isinstance(item, (list, tuple)): + for child in item: + collect(child) + + collect(value) + return frozenset(tokens - _COUNSEL_STOPWORDS) + + +def counsel_reasons( + level: CovenantLevel, + guidance: PreflightGuidance, + normalized_arguments: Mapping[str, Any], +) -> list[CounselReason]: + reasons: list[CounselReason] = [] + + def add(reason: CounselReason) -> None: + if reason not in reasons: + reasons.append(reason) + + if level is CovenantLevel.DESTRUCTIVE: + add("DESTRUCTIVE_OPERATION") + for rule in guidance.rules: + if rule.must_not: + add("RULE_MUST_NOT") + if rule.ask_first: + add("RULE_ASK_FIRST") + if rule.warnings: + add("RULE_WARNING") + argument_tokens = _content_tokens( + { + key: value + for key, value in normalized_arguments.items() + if key not in {"idempotency_key", "workspace_id", "preflight_token"} + } + ) + path = normalized_arguments.get("relative_file_path") + for record in guidance.records: + related = isinstance(path, str) and path == record.relative_file_path + if not related: + record_tokens = _content_tokens( + [record.excerpt, list(record.tags), record.relative_file_path] + ) + related = len(argument_tokens & record_tokens) >= 2 + if related: + add( + "RELATED_WARNING" + if record.record_type == "warning" + else "RELATED_FAILURE" + ) + return reasons + + +class CovenantCeremony: + def __init__( + self, + *, + covenant_gate: CovenantGate, + argument_normalizer: ArgumentNormalizer, + preflight_service: PreflightService, + brief_reader: Callable[[Workspace], Awaitable[Mapping[str, object]]], + ) -> None: + self._gate = covenant_gate + self._normalizer = argument_normalizer + self._preflight = preflight_service + self._brief_reader = brief_reader + + async def admit( + self, + tool_name: str, + arguments: Mapping[str, Any], + workspace: Workspace, + scope: InvocationScope | None, + *, + preflight_token: str | None, + consume_capability: bool, + counsel_mode: CounselMode, + ) -> CeremonyOutcome: + def authorize(token: str | None) -> Mapping[str, Any] | None: + return self._gate.authorize( + tool_name, + arguments, + scope, + preflight_token=token, + consume_capability=consume_capability, + ) + + def code(violation: Mapping[str, Any] | None) -> object: + return violation.get("violation") if violation is not None else None + + token = preflight_token + violation = authorize(token) + notice: CovenantNotice | None = None + if code(violation) in RECOVERABLE_TOKEN_CODES: + token = None + violation = authorize(None) + if code(violation) == "COMMUNION_REQUIRED" and scope is not None: + try: + brief = CovenantBrief.model_validate( + await self._brief_reader(workspace) + ) + except asyncio.CancelledError: + raise + except Exception: + return CeremonyOutcome(violation, notice, None) + self._gate.record_briefing(scope) + notice = CovenantNotice(auto_brief=brief) + violation = authorize(token) + if code(violation) in RECOVERABLE_TOKEN_CODES: + token = None + violation = authorize(None) + if ( + code(violation) in {"COUNSEL_REQUIRED", "TOKEN_MISSING"} + and scope is not None + and counsel_mode != "off" + ): + call_args = { + key: value + for key, value in arguments.items() + if key != "preflight_token" + } + try: + level = self._gate.policy.resolve(tool_name, call_args) + normalized = self._normalizer( + tool_name, call_args, scope.canonical_workspace + ) + raw_guidance = self._preflight.guidance( + workspace, tool_name, normalized, None + ) + if inspect.isawaitable(raw_guidance): + raw_guidance = await raw_guidance + guidance = PreflightGuidance.model_validate(raw_guidance) + new_token = self._gate.issue_preflight(scope, tool_name, call_args) + expires_at = datetime.fromtimestamp( + self._gate.authority.verify(new_token)["exp"], timezone.utc + ) + except asyncio.CancelledError: + raise + except Exception: + return CeremonyOutcome(violation, notice, None) + reasons = counsel_reasons(level, guidance, normalized) + if counsel_mode == "challenge" and not reasons: + reasons = ["TASK_ADMISSION"] + if not reasons and counsel_mode == "inline": + violation = authorize(new_token) + if violation is None: + notice = CovenantNotice( + auto_brief=notice.auto_brief if notice is not None else None, + counsel=InlineCounsel( + target_tool=tool_name, must_do=guidance.must_do[:20] + ), + ) + return CeremonyOutcome(violation, notice, None) + return CeremonyOutcome( + { + "status": "blocked", + "violation": "COUNSEL_REQUIRED", + "operation": tool_name, + }, + notice, + CounselChallenge( + guidance=guidance, + preflight_token=new_token, + expires_at=expires_at, + reasons=reasons, + ), + ) + return CeremonyOutcome(violation, notice, None) + + +__all__ = [ + "ArgumentNormalizer", + "CeremonyOutcome", + "CounselMode", + "CovenantCeremony", + "PreflightService", + "RECOVERABLE_TOKEN_CODES", + "counsel_reasons", +] diff --git a/daem0nmcp/api/v7/composition.py b/daem0nmcp/api/v7/composition.py index d1d8cf3..02a91ce 100644 --- a/daem0nmcp/api/v7/composition.py +++ b/daem0nmcp/api/v7/composition.py @@ -4,12 +4,17 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import Any +from typing import Any, Literal from ...covenant import CovenantGate from .application import V7ApplicationDependencies, V7ToolRouter from .factory import build_v7_manifest, combine_handler_maps from .fastmcp import build_fastmcp_server +from .gateway_operations import ( + CORE_LISTED_TOOLS, + GATEWAY_TOOL_NAMES, + build_gateway_operations, +) from .middleware import ( ResourceCommunionAuthorizer, TransportMode, @@ -34,15 +39,19 @@ class V7Surface: resource_handlers: ResourceHandlers manifest: V7Manifest middleware: tuple[object, ...] + listed_tools: frozenset[str] | None def build_server(self, **options: Any) -> Any: """Create a fresh FastMCP instance from this immutable surface.""" if "middleware" in options: raise ValueError("surface middleware cannot be replaced") + if "listed_tools" in options: + raise ValueError("surface listed tools cannot be replaced") return build_fastmcp_server( self.manifest, middleware=self.middleware, + listed_tools=self.listed_tools, **options, ) @@ -61,6 +70,7 @@ def build_v7_surface( session_id_factory: Callable[[], str] | None = None, allow_unauthenticated_loopback: bool = False, activity_callback: Callable[[Any, bool], None] | None = None, + tool_surface: Literal["core", "full"] = "full", ) -> V7Surface: """Compose one exact surface without importing legacy decorator modules.""" @@ -90,11 +100,13 @@ def build_v7_surface( scope_provider=pinned_dependencies.scope_provider, operations=operations, response_factory=pinned_dependencies.response_factory, + ceremony=pinned_dependencies.ceremony, ) ) handlers = combine_handler_maps( build_pinned_handlers(pinned_dependencies), - router.handlers(exclude=PINNED_HANDLER_NAMES), + router.handlers(exclude=PINNED_HANDLER_NAMES | GATEWAY_TOOL_NAMES), + build_gateway_operations(pinned_dependencies.response_factory), ) resource_handlers = ResourceHandlers( ResourceDependencies( @@ -127,6 +139,7 @@ def build_v7_surface( resource_handlers=resource_handlers, manifest=manifest, middleware=middleware, + listed_tools=CORE_LISTED_TOOLS if tool_surface == "core" else None, ) diff --git a/daem0nmcp/api/v7/discovery_operations.py b/daem0nmcp/api/v7/discovery_operations.py index d345d1f..426837a 100644 --- a/daem0nmcp/api/v7/discovery_operations.py +++ b/daem0nmcp/api/v7/discovery_operations.py @@ -49,7 +49,7 @@ validate_index_patterns, ) from .application import AdmittedRequest -from .errors import STABLE_ERROR_CODE_SET +from .errors import STABLE_ERROR_CODE_SET, is_database_busy from .models import ( CapabilityState, Page, @@ -408,7 +408,7 @@ def entities() -> object: return entities() -def _default_code_indexer_factory() -> object: +def default_code_indexer_factory() -> object: from ...code_indexer import LANGUAGE_CONFIG, TreeSitterIndexer return _StrictTreeSitterProducer(TreeSitterIndexer(), LANGUAGE_CONFIG) @@ -425,7 +425,7 @@ class DiscoveryOperationDependencies: cursor_secret: bytes = field(default_factory=lambda: secrets.token_bytes(32)) worker_pool: WorkerPool = field(default_factory=_default_worker_pool) recall_service: _RecallService | None = None - code_indexer_factory: Callable[[], object] = _default_code_indexer_factory + code_indexer_factory: Callable[[], object] = default_code_indexer_factory capability_statuses: Mapping[str, str] = field(default_factory=dict) def __post_init__(self) -> None: @@ -511,8 +511,8 @@ def _verify_database_connection(connection: sqlite3.Connection) -> None: "SELECT name FROM sqlite_master WHERE type='table'" ) } - except Exception: - raise DiscoveryOperationError("CAPABILITY_DEGRADED") from None + except Exception as error: + raise _translate_error(error) from None if not versions.issuperset( REQUIRED_V7_SCHEMA_VERSIONS ) or not _REQUIRED_TABLES.issubset(tables): @@ -538,10 +538,10 @@ def _open_database(path: Path) -> sqlite3.Connection: if connection is not None: connection.close() raise - except Exception: + except Exception as error: if connection is not None: connection.close() - raise DiscoveryOperationError("CAPABILITY_DEGRADED") from None + raise _translate_error(error) from None def _open_writable_database(path: Path) -> sqlite3.Connection: @@ -561,10 +561,10 @@ def _open_writable_database(path: Path) -> sqlite3.Connection: if connection is not None: connection.close() raise - except Exception: + except Exception as error: if connection is not None: connection.close() - raise DiscoveryOperationError("CAPABILITY_DEGRADED") from None + raise _translate_error(error) from None def _datetime_us(value: object) -> int: @@ -710,6 +710,18 @@ def _translate_error(error: Exception) -> DiscoveryOperationError: code = getattr(error, "code", None) if isinstance(code, str) and code in STABLE_ERROR_CODE_SET: return DiscoveryOperationError(code) + if isinstance(error, sqlite3.OperationalError) and ( + is_database_busy(error) + or ( + not isinstance(getattr(error, "sqlite_errorcode", None), int) + and re.fullmatch( + r"database is busy|database (?:table|schema) is locked: .+", + str(error).casefold(), + ) + is not None + ) + ): + return DiscoveryOperationError("DATABASE_IN_USE") return DiscoveryOperationError("CAPABILITY_DEGRADED") @@ -1923,6 +1935,7 @@ def reader(connection: sqlite3.Connection) -> CommunityDetail: @dataclass(frozen=True, slots=True) class _EntitySelection: generation: int + lexical_generation: int | None entity_id: str name: str record_ids: tuple[str, ...] @@ -2013,8 +2026,19 @@ def reader(connection: sqlite3.Connection) -> _EntitySelection: ).fetchall() truncated = len(members) > request.limit selected = tuple(str(row[0]) for row in members[: request.limit]) + lexical = connection.execute( + "SELECT generation FROM projection_manifests WHERE workspace_id=? " + "AND projection_name='lexical' AND status='active'", + (workspace.workspace_id,), + ).fetchone() + lexical_generation = lexical[0] if lexical is not None else None + if lexical_generation is not None and ( + not isinstance(lexical_generation, int) or lexical_generation < 1 + ): + raise DiscoveryOperationError("CAPABILITY_DEGRADED") return _EntitySelection( generation=manifest.generation, + lexical_generation=lexical_generation, entity_id=entity_id, name=str(rows[0]["name"]), record_ids=selected, @@ -2040,13 +2064,14 @@ def _lexical_catchup_pending_sync( dependencies: DiscoveryOperationDependencies, workspace: Workspace, record_ids: tuple[str, ...], + lexical_generation: int | None, ) -> bool: - """Return whether the lexical projection still owes these records. + """Return whether an incomplete recall can progress on a retry. - True only when the records are absent from the active lexical generation - *and* a rebuild job is queued or running, i.e. a retry can actually make - progress. A dead-lettered rebuild, a permanently unavailable index, or a - record that simply does not match the entity name is not retryable. + Missing records require a queued or running rebuild. Records indexed by a + generation published after the selection snapshot also permit one retry. + Same-generation indexed misses, dead-lettered rebuilds and permanently + unavailable indexes remain terminal. """ if not record_ids: @@ -2060,6 +2085,12 @@ def reader(connection: sqlite3.Connection) -> bool: ).fetchone() indexed = 0 if active is not None: + if ( + not isinstance(active[0], int) + or active[0] < 1 + or (lexical_generation is not None and active[0] < lexical_generation) + ): + raise DiscoveryOperationError("CAPABILITY_DEGRADED") placeholders = ",".join("?" for _ in record_ids) indexed = int( connection.execute( @@ -2069,7 +2100,7 @@ def reader(connection: sqlite3.Connection) -> bool: ).fetchone()[0] ) if indexed >= len(record_ids): - return False + return lexical_generation is None or active[0] > lexical_generation return ( connection.execute( "SELECT 1 FROM background_jobs WHERE workspace_id=? " @@ -2082,7 +2113,10 @@ def reader(connection: sqlite3.Connection) -> bool: try: return bool(_read_snapshot(dependencies, workspace, reader)) - except Exception: + except Exception as error: + translated = _translate_error(error) + if translated.code == "DATABASE_IN_USE": + raise translated from None return False @@ -2094,7 +2128,11 @@ def _generation_is_current_sync( def reader(connection: sqlite3.Connection) -> None: manifest = _active_projection(connection, workspace.workspace_id, "graph") if manifest.generation != generation: - raise DiscoveryOperationError("CAPABILITY_DEGRADED") + raise DiscoveryOperationError( + "DATABASE_IN_USE" + if manifest.generation > generation + else "CAPABILITY_DEGRADED" + ) _read_snapshot(dependencies, workspace, reader) @@ -2138,12 +2176,17 @@ async def _memory_recall_entity( for record_id in selection.record_ids if record_id not in indexed ) - # Only a projection that has genuinely not caught up yet is worth - # retrying. A permanently unavailable or dead-lettered lexical - # index stays a terminal CAPABILITY_DEGRADED. + # A pending rebuild or a lexical publication since selection can + # make an incomplete retrieval succeed on retry. An unchanged + # index or a dead-lettered rebuild remains terminal. pending = await _run_blocking( dependencies, - lambda: _lexical_catchup_pending_sync(dependencies, workspace, missing), + lambda: _lexical_catchup_pending_sync( + dependencies, + workspace, + missing, + selection.lexical_generation, + ), ) raise DiscoveryOperationError( "DATABASE_IN_USE" if pending else "CAPABILITY_DEGRADED" diff --git a/daem0nmcp/api/v7/fastmcp.py b/daem0nmcp/api/v7/fastmcp.py index 50d2245..b35a5b8 100644 --- a/daem0nmcp/api/v7/fastmcp.py +++ b/daem0nmcp/api/v7/fastmcp.py @@ -9,14 +9,20 @@ from collections.abc import Mapping from importlib import metadata from types import MethodType -from typing import Annotated, Any +from typing import Annotated, Any, cast -from pydantic import Field, TypeAdapter +from pydantic import Field, TypeAdapter, ValidationError +from pydantic_core import InitErrorDetails from ... import __version__ from ...covenant import invocation_scope_var from .errors import ErrorCode -from .middleware import V7InvocationMiddleware +from .gateway_operations import ( + GATEWAY_TOOL_NAMES, + _ToolArgumentValidationError, + bind_gateway_handlers, +) +from .middleware import ListedToolsMiddleware, V7InvocationMiddleware from .registry import ToolSpec, V7Manifest from .responses import ResponseFactory from .task_dispatcher import DurableTaskDispatcher, TaskDispatcherError, TaskView @@ -169,7 +175,14 @@ def _tool_adapter( foreground_policy: ForegroundExecutionPolicy | None = None, ): async def invoke(**arguments: Any) -> dict[str, Any]: - request = spec.input_model.model_validate(arguments) + try: + request = spec.input_model.model_validate(arguments) + except ValidationError as exc: + raise _ToolArgumentValidationError.from_exception_data( + exc.title, + cast(list[InitErrorDetails], exc.errors(include_url=False)), + hide_input=True, + ) from exc effective_arguments = request.model_dump(mode="json") @@ -554,6 +567,7 @@ def build_fastmcp_server( task_dispatcher: DurableTaskDispatcher | None = None, auth: Any | None = None, middleware: tuple[Any, ...] = (), + listed_tools: frozenset[str] | None = None, lifespan: Any | None = None, sync_timeout_seconds: int | float = 15, foreground_policies: Mapping[str, ForegroundExecutionPolicy] = ( @@ -578,6 +592,10 @@ def build_fastmcp_server( raise FastMCPCompatibilityError( "owned task support requires exactly one durable dispatcher" ) + if listed_tools is not None and not listed_tools.issubset( + spec.name for spec in manifest.tools + ): + raise FastMCPCompatibilityError("listed tools must be registered") optional_names = { spec.name for spec in manifest.tools if spec.task_mode == "optional" @@ -621,6 +639,33 @@ def build_fastmcp_server( ) for item in middleware: server.add_middleware(item) + if listed_tools is not None: + server.add_middleware(ListedToolsMiddleware(listed_tools)) + + adapters = { + spec.name: _tool_adapter( + spec, + tasks_enabled=tasks_enabled, + sync_timeout_seconds=validated_sync_timeout, + foreground_policy=configured_policies.get(spec.name), + ) + for spec in manifest.tools + if spec.name not in GATEWAY_TOOL_NAMES + } + gateway = bind_gateway_handlers( + manifest=manifest, + adapters=adapters, + listed_tools=listed_tools, + response_factory=ResponseFactory(), + ) + for spec in manifest.tools: + if spec.name in GATEWAY_TOOL_NAMES: + adapters[spec.name] = _tool_adapter( + spec.replace(handler=gateway[spec.name]), + tasks_enabled=tasks_enabled, + sync_timeout_seconds=validated_sync_timeout, + foreground_policy=configured_policies.get(spec.name), + ) for spec in manifest.tools: registration: dict[str, Any] = { @@ -633,14 +678,7 @@ def build_fastmcp_server( "output_schema": spec.output_schema, } registration["task"] = False - registered = server.tool(**registration)( - _tool_adapter( - spec, - tasks_enabled=tasks_enabled, - sync_timeout_seconds=validated_sync_timeout, - foreground_policy=configured_policies.get(spec.name), - ) - ) + registered = server.tool(**registration)(adapters[spec.name]) if spec.task_mode == "optional" and tasks_enabled: import mcp.types diff --git a/daem0nmcp/api/v7/federated_retrieval.py b/daem0nmcp/api/v7/federated_retrieval.py index 1b50431..42a8b95 100644 --- a/daem0nmcp/api/v7/federated_retrieval.py +++ b/daem0nmcp/api/v7/federated_retrieval.py @@ -9,11 +9,22 @@ from contextlib import suppress from dataclasses import dataclass, replace from pathlib import Path +from time import perf_counter_ns from typing import Literal from ...event_store import sha256_json +from ...retrieval.composer import ( + EvidenceComposer, + RetentionPolicy, + SelectedEvidence, + _normalize_evidence_text, + query_identifiers, +) from ...retrieval.runtime import CoreTokenizer -from ...retrieval.types import RetrievalQuery +from ...retrieval.types import ( + EvidenceRef as InternalEvidenceRef, +) +from ...retrieval.types import FusedCandidate, RetrievalQuery from ...storage_activation import DatabaseFileLock from .models import ( CitationManifestEntry, @@ -136,8 +147,18 @@ class FederatedCandidate: record: RecordSummary content: str channels: tuple[str, ...] - status: str + status: Literal["current", "superseded"] evidence_refs: tuple[EvidenceRef, ...] + applicability: Literal["current", "needs_revalidation", "unverifiable"] | None = ( + None + ) + changed_bindings: tuple[str, ...] = () + utility: float | None = None + procedure_steps: tuple[str, ...] = () + code_bindings: tuple[tuple[str, str | None], ...] = () + outcome: str | None = None + outcome_failed: bool = False + superseded_by_version_id: str | None = None @dataclass(frozen=True, slots=True) @@ -290,13 +311,14 @@ def _fit_excerpt( prior_lines: Sequence[str], token_budget: int, tokenizer: CoreTokenizer, + applicability_label: str = "", ) -> tuple[str, int] | None: - normalized = " ".join(content.split())[:8000].strip() + normalized = _normalize_evidence_text(content)[:8000].strip() if not normalized: return None def rendered(prefix: str) -> tuple[str, int]: - line = f"{citation} {prefix}" + line = f"{citation} {prefix}" + applicability_label text = "\n".join((*prior_lines, line)) return line, tokenizer.count_tokens(text) @@ -321,26 +343,196 @@ def rendered(prefix: str) -> tuple[str, int]: return fitted +def _compose_retained( + ranked: Sequence[tuple[float, str, str, FederatedCandidate]], + query: RetrievalQuery, + retention: RetentionPolicy, + label_applicability: bool, +) -> RetrievalData: + composer = EvidenceComposer(tokenizer=CoreTokenizer()) + sources = [] + originals = {} + highest = ranked[0][0] if ranked else 1.0 + for index, (score, _workspace_id, _record_id, item) in enumerate(ranked, 1): + refs = tuple( + InternalEvidenceRef( + record_id=ref.record_id, + event_id=ref.event_id, + content_hash=ref.content_hash, + version_id=ref.version_id, + relation_path=tuple(ref.relation_path), + provider=ref.provider, + ) + for ref in item.evidence_refs + ) + candidate = FusedCandidate( + evidence=refs[0], + evidence_refs=refs, + score=score / highest, + channels=frozenset(item.channels), + channel_ranks=tuple((channel, index) for channel in sorted(item.channels)), + manifest_generations=tuple( + (channel, None) for channel in sorted(item.channels) + ), + ) + source = SelectedEvidence( + candidate=candidate, + content=item.content, + category=item.record.record_type, + status=item.status, + superseded_by_version_id=item.superseded_by_version_id, + procedure_steps=item.procedure_steps, + code_bindings=item.code_bindings, + outcome=item.outcome, + outcome_failed=item.outcome_failed, + utility=item.utility, + applicability=item.applicability, + changed_bindings=item.changed_bindings, + ) + sources.append(source) + originals[id(source)] = item + ordered = sorted( + sources, key=lambda source: not composer._priority(source, retention) + )[: query.limit] + retained_ids = {id(source) for source in ordered} + sources = [source for source in sources if id(source) in retained_ids] + composition = composer.compose( + sources, + token_budget=query.token_budget, + retention=retention, + label_applicability=label_applicability, + ) + by_ref: dict[InternalEvidenceRef, list[FederatedCandidate]] = {} + for source in sources: + by_ref.setdefault(source.candidate.evidence, []).append(originals[id(source)]) + items = [] + manifest = [] + for evidence in composition.items: + original = by_ref[evidence.evidence_refs[0]].pop(0) + evidence_item = EvidenceItem( + citation=evidence.citation, + record=original.record, + bounded_excerpt=evidence.excerpt, + channels=sorted(evidence.channels), + score=evidence.score, + status=evidence.status, + evidence_refs=list(original.evidence_refs), + utility=evidence.utility, + applicability=evidence.applicability, + changed_bindings=list(evidence.changed_bindings), + ) + items.append(evidence_item) + manifest.append( + CitationManifestEntry( + citation=evidence_item.citation, + evidence_refs=evidence_item.evidence_refs, + channels=evidence_item.channels, + ) + ) + context = composition.context + return RetrievalData( + items=items, + rendered_context=context.text, + citation_manifest=manifest, + abstained=not items, + abstention_reason=None if items else "NO_POLICY_VALID_EVIDENCE", + token_usage=TokenUsage( + budget=query.token_budget, + requested=min(1_000_000, context.requested_tokens), + selected=context.selected_tokens, + rendered=context.rendered_tokens, + dropped=min(1_000_000, context.dropped_tokens), + ), + ) + + def compose_federated_results( - results: Mapping[str, FederatedSourceResult], query: RetrievalQuery + results: Mapping[str, FederatedSourceResult], + query: RetrievalQuery, + *, + label_applicability: bool = False, + retention_mode: Literal["off", "shadow", "apply"] = "off", ) -> RetrievalData: """Fuse all authenticated candidates and compose the context exactly once.""" ranked: list[tuple[float, str, str, FederatedCandidate]] = [] for workspace_id in sorted(results): - result = results[workspace_id] - for rank, item in enumerate(result.candidates, 1): + source_result = results[workspace_id] + for rank, item in enumerate(source_result.candidates, 1): ranked.append( (1.0 / (60.0 + rank), workspace_id, item.record.record_id, item) ) ranked.sort(key=lambda value: (-value[0], value[1], value[2])) + if query.intent is not None and retention_mode != "off": + started = perf_counter_ns() + retention_failed = False + try: + retained = ( + compose_federated_results( + results, query, label_applicability=label_applicability + ) + if query.intent == "explore" + else _compose_retained( + ranked, + query, + RetentionPolicy(query.intent, query_identifiers(query.text)), + label_applicability, + ) + ) + except Exception: + if retention_mode == "apply": + raise + retention_failed = True + retained = None + result = ( + retained + if retained is not None + and (retention_mode == "apply" or query.intent == "explore") + else compose_federated_results( + results, query, label_applicability=label_applicability + ) + ) + return result.model_copy( + update={ + "provider_diagnostics": [ + *_merged_diagnostics(results), + ProviderDiagnostic( + provider="retention", + status="degraded" if retention_failed else "ready", + manifest_generation=None, + elapsed_ms=(perf_counter_ns() - started) / 1_000_000, + reason=( + "RETENTION_FAILED" + if retention_failed + else "RETENTION_NOT_APPLICABLE" + if query.intent == "explore" + else "RETENTION_APPLIED" + if retention_mode == "apply" + else "RETENTION_SHADOW" + ), + returned_count=len(retained.items) + if retained is not None + else 0, + ), + ], + } + ) tokenizer = CoreTokenizer() selected: list[EvidenceItem] = [] manifest: list[CitationManifestEntry] = [] lines: list[str] = [] rendered_tokens = 0 + + def applicability_label(item: FederatedCandidate) -> str: + if label_applicability and item.applicability == "needs_revalidation": + return "\nNeeds revalidation: " + "; ".join( + _normalize_evidence_text(path) for path in item.changed_bindings + ) + return "" + requested_lines = [ - f"[E{index}] {' '.join(item.content.split())[:8000].strip()}" + f"[E{index}] {_normalize_evidence_text(item.content)[:8000].strip()}" + + applicability_label(item) for index, (_score, _workspace, _record, item) in enumerate(ranked, 1) ] requested = min(1_000_000, tokenizer.count_tokens("\n".join(requested_lines))) @@ -363,11 +555,12 @@ def compose_federated_results( prior_lines=lines, token_budget=max(0, query.token_budget - reserved_tokens), tokenizer=tokenizer, + applicability_label=applicability_label(item), ) if fitted is None: continue line, rendered_tokens = fitted - excerpt = line[len(citation) + 1 :] + excerpt = line[len(citation) + 1 :].split("\n", 1)[0] copied = EvidenceItem.model_validate( { "citation": citation, @@ -377,6 +570,9 @@ def compose_federated_results( "score": score / highest, "status": item.status, "evidence_refs": list(item.evidence_refs), + "applicability": item.applicability, + "changed_bindings": list(item.changed_bindings), + "utility": item.utility, } ) selected.append(copied) diff --git a/daem0nmcp/api/v7/gateway_operations.py b/daem0nmcp/api/v7/gateway_operations.py new file mode 100644 index 0000000..3498a56 --- /dev/null +++ b/daem0nmcp/api/v7/gateway_operations.py @@ -0,0 +1,208 @@ +"""Gateway registration for inspectable, framework-neutral v7 surfaces.""" + +from __future__ import annotations + +import re +from collections.abc import Awaitable, Callable, Mapping +from typing import Any + +from pydantic import ValidationError + +from .models import ( + ApiError, + ApiResponse, + ErrorCode, + ErrorRemedy, + FieldError, + ResponseMeta, +) +from .registry import PINNED_TOOL_NAMES, V7Manifest +from .responses import ResponseFactory +from .tools import TOOL_INPUT_MODELS, ToolCallData, ToolDescriptor, ToolsSearchData + +GATEWAY_TOOL_NAMES = frozenset({"daem0n_tools_search", "daem0n_tool_call"}) +CORE_LISTED_TOOLS = PINNED_TOOL_NAMES | {"edit_preflight"} | GATEWAY_TOOL_NAMES +_DIAGNOSTIC_FIELD = re.compile(r"[A-Za-z_][A-Za-z0-9_.\[\]-]*") + + +class _ToolArgumentValidationError(ValidationError): + """Identify validation failures at the target adapter's input boundary.""" + + +def _argument_field(location: tuple[object, ...]) -> str: + field = "arguments" + for part in location: + candidate = f"{field}.{part}" + if len(candidate) > 256 or _DIAGNOSTIC_FIELD.fullmatch(candidate) is None: + break + field = candidate + return field + + +def build_gateway_operations( + response_factory: ResponseFactory, +) -> Mapping[str, Callable[..., object]]: + """Register inspectable placeholders; the MCP server binds live handlers.""" + + async def tools_search(**arguments: Any) -> ApiResponse[Any]: + return response_factory.begin(None).failure( + ErrorCode.CAPABILITY_DISABLED, + "The tool gateway is available only on an MCP server.", + ) + + async def tool_call(**arguments: Any) -> ApiResponse[Any]: + return response_factory.begin(None).failure( + ErrorCode.CAPABILITY_DISABLED, + "The tool gateway is available only on an MCP server.", + ) + + return { + "daem0n_tools_search": tools_search, + "daem0n_tool_call": tool_call, + } + + +def bind_gateway_handlers( + *, + manifest: V7Manifest, + adapters: Mapping[str, Callable[..., Awaitable[dict[str, Any]]]], + listed_tools: frozenset[str] | None, + response_factory: ResponseFactory, +) -> dict[str, Callable[..., Awaitable[ApiResponse[Any]]]]: + """Bind discovery and single-pass invocation to this server's adapters.""" + from .factory import build_tool_search_index + + index = build_tool_search_index(manifest) + specs = {spec.name: spec for spec in manifest.tools} + + async def tools_search(*, query: str, limit: int = 5) -> ApiResponse[Any]: + response = response_factory.begin(None) + matches = [ + match + for match in index.search(query, top_k=limit + 2) + if match.name not in GATEWAY_TOOL_NAMES + ][:limit] + descriptors = [] + for match in matches: + spec = specs[match.name] + descriptors.append( + ToolDescriptor( + name=spec.name, + description=spec.description, + category=spec.category, + covenant=spec.covenant.value, + read_only=spec.annotations["readOnlyHint"], + listed=listed_tools is None or spec.name in listed_tools, + input_schema=spec.input_schema, + ) + ) + return response.success(ToolsSearchData(tools=descriptors)) + + async def tool_call( + *, workspace_id: str, tool: str, arguments: dict[str, Any] + ) -> ApiResponse[Any]: + response = response_factory.begin(workspace_id) + if tool in GATEWAY_TOOL_NAMES or tool not in adapters: + return response.failure( + ErrorCode.INVALID_ARGUMENT, + "Unknown tool; use daem0n_tools_search.", + field_errors=[ + FieldError( + field="tool", + code="UNKNOWN_TOOL", + message="Unknown or non-callable tool.", + ) + ], + ) + if "workspace_id" in arguments: + return response.failure( + ErrorCode.INVALID_ARGUMENT, + "Pass workspace_id at the top level.", + field_errors=[ + FieldError( + field="arguments.workspace_id", + code="WORKSPACE_ID_TOP_LEVEL", + message="Pass workspace_id at the top level.", + ) + ], + ) + target = dict(arguments) + if "workspace_id" in TOOL_INPUT_MODELS[tool].model_fields: + target["workspace_id"] = workspace_id + try: + result = await adapters[tool](**target) + except _ToolArgumentValidationError as exc: + field_errors = [ + FieldError( + field=_argument_field(error["loc"]), + code="INVALID_VALUE", + message="Invalid value.", + ) + for error in exc.errors()[:50] + ] + return response.failure( + ErrorCode.INVALID_ARGUMENT, + "Arguments do not match the tool's input schema; " + "see daem0n_tools_search.", + field_errors=field_errors, + ) + except Exception as exc: + return response.internal_error(exc) + + try: + ok = result["ok"] + if not isinstance(ok, bool): + raise ValueError("adapter response ok must be boolean") + if ok: + if result.get("data") is None or result.get("error") is not None: + raise ValueError("adapter response must contain only data") + elif result.get("data") is not None or result.get("error") is None: + raise ValueError("adapter response must contain only error") + meta = ResponseMeta.model_validate(result["meta"]) + response = response.with_covenant(meta.covenant) + if ok: + return response.success( + ToolCallData(tool=tool, data=result["data"]), + warnings=meta.warnings, + capability_states=meta.capability_states, + ) + error = ApiError.model_validate(result["error"]) + remedy = error.remedy + if ( + remedy is not None + and listed_tools is not None + and remedy.tool not in listed_tools + ): + remedy = ErrorRemedy( + tool="daem0n_tool_call", + arguments={ + "workspace_id": workspace_id, + "tool": remedy.tool, + "arguments": { + key: value + for key, value in remedy.arguments.items() + if key != "workspace_id" + }, + }, + ) + return response.relay_failure( + error, + remedy=remedy, + warnings=meta.warnings, + capability_states=meta.capability_states, + ) + except Exception as exc: + return response.internal_error(exc) + + return { + "daem0n_tools_search": tools_search, + "daem0n_tool_call": tool_call, + } + + +__all__ = [ + "CORE_LISTED_TOOLS", + "GATEWAY_TOOL_NAMES", + "bind_gateway_handlers", + "build_gateway_operations", +] diff --git a/daem0nmcp/api/v7/intelligence_operations.py b/daem0nmcp/api/v7/intelligence_operations.py index cdd4644..70799ee 100644 --- a/daem0nmcp/api/v7/intelligence_operations.py +++ b/daem0nmcp/api/v7/intelligence_operations.py @@ -34,11 +34,11 @@ from .models import ( EvidenceRef, RecordSummary, + RuleView, parse_wire_datetime, stored_relative_path, ) from .public_ids import PublicObjectIdNotFound, PublicObjectIdRepository -from .resources import RuleView from .runtime_protocols import ActiveStorageResolver, WorkerPool from .runtime_services import WorkspaceStorageResolver from .tools import ( diff --git a/daem0nmcp/api/v7/middleware.py b/daem0nmcp/api/v7/middleware.py index 7b8c2e7..cdd7d76 100644 --- a/daem0nmcp/api/v7/middleware.py +++ b/daem0nmcp/api/v7/middleware.py @@ -55,7 +55,7 @@ class _BaseResourceError(RuntimeError): TransportMode = Literal["stdio", "streamable-http"] RESOURCE_SUFFIXES = frozenset({"warnings", "failures", "rules", "active-context"}) -WORKSPACE_OPTIONAL_TOOLS = frozenset({"system_health"}) +WORKSPACE_OPTIONAL_TOOLS = frozenset({"system_health", "daem0n_tools_search"}) _WORKSPACE_ID_ADAPTER = TypeAdapter(WorkspaceId) _ADMISSION_FAILURE = object() @@ -132,6 +132,20 @@ def _resource_workspace_id(uri: object) -> WorkspaceId: return _WORKSPACE_ID_ADAPTER.validate_python(segments[1], strict=True) +class ListedToolsMiddleware(_MiddlewareBase): + """Filter discovery without changing tool resolution or invocation.""" + + def __init__(self, listed_tools: frozenset[str]) -> None: + if FASTMCP_MIDDLEWARE_AVAILABLE: + super().__init__() + self._listed = listed_tools + + async def on_list_tools( + self, context: Any, call_next: Callable[[Any], Awaitable[Any]] + ) -> Any: + return [tool for tool in await call_next(context) if tool.name in self._listed] + + class V7InvocationMiddleware(_MiddlewareBase): """Install a transport-authenticated scope around v7 tools and resources.""" @@ -517,6 +531,7 @@ def authorize(self, *, workspace: Workspace, resource_uri: str) -> None: __all__ = [ "FASTMCP_MIDDLEWARE_AVAILABLE", + "ListedToolsMiddleware", "RESOURCE_SUFFIXES", "ResourceAuthorizationError", "ResourceCommunionAuthorizer", diff --git a/daem0nmcp/api/v7/models.py b/daem0nmcp/api/v7/models.py index e1a44aa..633abdf 100644 --- a/daem0nmcp/api/v7/models.py +++ b/daem0nmcp/api/v7/models.py @@ -55,6 +55,7 @@ _POSIX_ABSOLUTE_PATH = re.compile(r"(? bool: @@ -94,6 +95,24 @@ def __repr__(self) -> str: # both directions, so anything accepted on input can always be read back. UserText = Annotated[str, _USER_TEXT] +BoundedText = Annotated[ + UserText, + StringConstraints(strict=True, min_length=1, max_length=2000), +] +UserMediumText = Annotated[ + UserText, + StringConstraints(strict=True, min_length=1, max_length=2000), +] +PreflightToken = Annotated[ + str, + StringConstraints( + strict=True, + min_length=16, + max_length=8192, + pattern=r"^[A-Za-z0-9._~-]+$", + ), +] + def _carries(annotation: object, marker: _Exempt) -> bool: return annotation is marker or any( @@ -510,6 +529,141 @@ def model_validate_json( return cls.model_validate(decoded, **kwargs) +class RecordSummary(WireModel): + """The bounded public view of a stored record. + + It is only ever built from storage, never parsed from a caller, so it + clips rows that predate v7's bounds -- v6 tags had no length, uniqueness + or count limit, and v6 content could be empty -- rather than denying every + read of a migrated workspace. A v7-written record satisfies those three, + because every stored tag input is ``UniqueTags`` and content has its own + minimum. + + The fourth clip is not a v6-only repair: a record's creation carries its + valid time, which a backdated or future ``happened_at`` legitimately puts + on either side of the transaction time it was written at, so the summary + shows the earlier of the two rather than refusing the record. + + ``relative_file_path`` is a containment rule rather than a bound, so + it stays refused here; a stored value is normalized by + ``stored_relative_path`` where the row is read. + """ + + record_id: RecordId + record_type: RecordType + excerpt: Annotated[ + UserText, + StringConstraints(strict=True, min_length=1, max_length=4000), + ] + tags: Annotated[list[Tag], AfterValidator(_unique_strings)] = Field( + default_factory=list, + max_length=32, + ) + relative_file_path: RelativePath | None = None + current_status: RecordStatus + content_hash: ContentHash + created_at: AwareDateTime + updated_at: AwareDateTime + + @model_validator(mode="before") + @classmethod + def clip_stored_record(cls, data: object) -> object: + if not isinstance(data, Mapping): + return data + values = dict(data) + tags = values.get("tags") + if isinstance(tags, (list, tuple)): + clipped: list[str] = [] + for tag in tags: + if not isinstance(tag, str) or not tag: + continue + tag = tag[:80] + if tag not in clipped: + clipped.append(tag) + values["tags"] = clipped[:32] + excerpt = values.get("excerpt") + if isinstance(excerpt, str): + values["excerpt"] = excerpt[:4000] or MIGRATED_EMPTY_CONTENT + # A record's creation carries its valid time, which may be backdated + # or in the future relative to the transaction time it was written at, + # and v6 kept the two independently. The summary shows the earlier of + # the two as the creation rather than refusing the record. + try: + created = parse_wire_datetime(values["created_at"]) + updated = parse_wire_datetime(values["updated_at"]) + except (KeyError, TypeError, ValueError): + return values + if updated < created: + values["created_at"] = updated + return values + + +class RuleView(WireModel): + """Bounded public representation of one workspace rule.""" + + rule_id: RuleId + trigger: BoundedText + must_do: list[BoundedText] = Field(default_factory=list, max_length=50) + must_not: list[BoundedText] = Field(default_factory=list, max_length=50) + ask_first: list[BoundedText] = Field(default_factory=list, max_length=50) + warnings: list[BoundedText] = Field(default_factory=list, max_length=50) + priority: Annotated[int, Field(strict=True, ge=-1000, le=1000)] + enabled: Annotated[bool, Field(strict=True)] + created_at: AwareDateTime + + +class PreflightGuidance(WireModel): + records: list[RecordSummary] = Field(default_factory=list, max_length=20) + rules: list[RuleView] = Field(default_factory=list, max_length=20) + must_do: list[UserMediumText] = Field(default_factory=list, max_length=50) + must_not: list[UserMediumText] = Field(default_factory=list, max_length=50) + ask_first: list[UserMediumText] = Field(default_factory=list, max_length=50) + warnings: list[UserMediumText] = Field(default_factory=list, max_length=50) + + +CounselReason = Literal[ + "DESTRUCTIVE_OPERATION", + "RULE_MUST_NOT", + "RULE_ASK_FIRST", + "RULE_WARNING", + "RELATED_FAILURE", + "RELATED_WARNING", + "TASK_ADMISSION", +] + + +class CovenantBrief(WireModel): + briefed_at: UtcDateTime + workspace_statistics: CountMap + warnings: list[RecordSummary] = Field(default_factory=list, max_length=5) + failed_records: list[RecordSummary] = Field(default_factory=list, max_length=5) + must_not: list[BoundedText] = Field(default_factory=list, max_length=20) + ask_first: list[BoundedText] = Field(default_factory=list, max_length=20) + + +class InlineCounsel(WireModel): + target_tool: ToolName + must_do: list[BoundedText] = Field(default_factory=list, max_length=20) + + +class CovenantNotice(WireModel): + auto_brief: CovenantBrief | None = None + counsel: InlineCounsel | None = None + + @model_validator(mode="after") + def require_notice(self) -> CovenantNotice: + if self.auto_brief is None and self.counsel is None: + raise ValueError("a covenant notice requires a brief or counsel") + return self + + +class CounselChallenge(WireModel): + guidance: PreflightGuidance + preflight_token: PreflightToken + expires_at: UtcDateTime + reasons: list[CounselReason] = Field(min_length=1, max_length=7) + + class ApiWarning(WireModel): code: UpperSnakeCode message: SanitizedMessage @@ -548,6 +702,7 @@ class ApiError(WireModel): retry_after_ms: Annotated[int, Field(ge=0, le=86_400_000)] | None = None field_errors: list[FieldError] = Field(default_factory=list, max_length=50) remedy: ErrorRemedy | None = None + counsel: CounselChallenge | None = None correlation_id: RequestId @model_validator(mode="after") @@ -558,10 +713,17 @@ def protect_internal_details(self) -> ApiError: or self.retry_after_ms is not None or self.field_errors or self.remedy is not None + or self.counsel is not None ): raise ValueError("INTERNAL_ERROR cannot carry caller-visible diagnostics") return self + @model_validator(mode="after") + def validate_counsel_code(self) -> ApiError: + if self.counsel is not None and self.code != ErrorCode.COUNSEL_REQUIRED: + raise ValueError("counsel requires COUNSEL_REQUIRED") + return self + class CapabilityState(WireModel): name: Annotated[ @@ -588,6 +750,7 @@ class ResponseMeta(WireModel): default_factory=list, max_length=64, ) + covenant: CovenantNotice | None = None def _api_response_schema(schema: dict[str, Any]) -> None: @@ -666,75 +829,6 @@ def stored_relative_path(value: object) -> str | None: return None -class RecordSummary(WireModel): - """The bounded public view of a stored record. - - It is only ever built from storage, never parsed from a caller, so it - clips rows that predate v7's bounds -- v6 tags had no length, uniqueness - or count limit, and v6 content could be empty -- rather than denying every - read of a migrated workspace. A v7-written record satisfies those three, - because every stored tag input is ``UniqueTags`` and content has its own - minimum. - - The fourth clip is not a v6-only repair: a record's creation carries its - valid time, which a backdated or future ``happened_at`` legitimately puts - on either side of the transaction time it was written at, so the summary - shows the earlier of the two rather than refusing the record. - - ``relative_file_path`` is a containment rule rather than a bound, so - it stays refused here; a stored value is normalized by - ``stored_relative_path`` where the row is read. - """ - - record_id: RecordId - record_type: RecordType - excerpt: Annotated[ - UserText, - StringConstraints(strict=True, min_length=1, max_length=4000), - ] - tags: Annotated[list[Tag], AfterValidator(_unique_strings)] = Field( - default_factory=list, - max_length=32, - ) - relative_file_path: RelativePath | None = None - current_status: RecordStatus - content_hash: ContentHash - created_at: AwareDateTime - updated_at: AwareDateTime - - @model_validator(mode="before") - @classmethod - def clip_stored_record(cls, data: object) -> object: - if not isinstance(data, Mapping): - return data - values = dict(data) - tags = values.get("tags") - if isinstance(tags, (list, tuple)): - clipped: list[str] = [] - for tag in tags: - if not isinstance(tag, str) or not tag: - continue - tag = tag[:80] - if tag not in clipped: - clipped.append(tag) - values["tags"] = clipped[:32] - excerpt = values.get("excerpt") - if isinstance(excerpt, str): - values["excerpt"] = excerpt[:4000] or MIGRATED_EMPTY_CONTENT - # A record's creation carries its valid time, which may be backdated - # or in the future relative to the transaction time it was written at, - # and v6 kept the two independently. The summary shows the earlier of - # the two as the creation rather than refusing the record. - try: - created = parse_wire_datetime(values["created_at"]) - updated = parse_wire_datetime(values["updated_at"]) - except (KeyError, TypeError, ValueError): - return values - if updated < created: - values["created_at"] = updated - return values - - class EvidenceRef(WireModel): origin_workspace_id: WorkspaceId | None = None record_id: RecordId @@ -759,6 +853,13 @@ class EvidenceItem(WireModel): score: Annotated[float, Field(ge=0, allow_inf_nan=False)] status: EvidenceStatus evidence_refs: list[EvidenceRef] = Field(min_length=1, max_length=32) + utility: Annotated[float, Field(ge=0, le=1, allow_inf_nan=False)] | None = None + applicability: Literal["current", "needs_revalidation", "unverifiable"] | None = ( + None + ) + changed_bindings: list[ + Annotated[str, StringConstraints(strict=True, min_length=1, max_length=1300)] + ] = Field(default_factory=list, max_length=16) @model_validator(mode="after") def validate_providers(self) -> EvidenceItem: @@ -861,6 +962,8 @@ def validate_abstention(self) -> RetrievalData: set(item_citations) ): raise ValueError("selected evidence must match the citation manifest") + if manifest_citations != _CITATION_IN_TEXT.findall(self.rendered_context): + raise ValueError("rendered context must match the citation manifest") return self @@ -892,6 +995,16 @@ class Preview(WireModel): __all__ = [ + "BoundedText", + "CounselChallenge", + "CounselReason", + "CovenantBrief", + "CovenantNotice", + "InlineCounsel", + "PreflightGuidance", + "PreflightToken", + "RuleView", + "UserMediumText", "ActiveContextId", "ApiError", "ApiFieldError", diff --git a/daem0nmcp/api/v7/pinned.py b/daem0nmcp/api/v7/pinned.py index 43a01e4..62d46d4 100644 --- a/daem0nmcp/api/v7/pinned.py +++ b/daem0nmcp/api/v7/pinned.py @@ -10,7 +10,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from types import MappingProxyType -from typing import Any, Protocol +from typing import Any, Literal, Protocol from pydantic import ValidationError @@ -23,6 +23,7 @@ from ...event_store import AppendedEvent, EventStreamConflict from ...retrieval import RetrievalQuery from ...workspace import Workspace +from .ceremony import ArgumentNormalizer, CovenantCeremony, PreflightService from .errors import ( DATABASE_IN_USE_RETRY_AFTER_MS, MIGRATION_REQUIRED_MESSAGE, @@ -32,6 +33,7 @@ ApiResponse, ApiWarning, CapabilityState, + PreflightGuidance, RecordSummary, RetrievalData, ) @@ -54,7 +56,6 @@ MemoryStoreOutput, OutcomeData, PreflightData, - PreflightGuidance, SessionBriefData, SessionBriefInput, SessionBriefOutput, @@ -238,15 +239,6 @@ class PinnedWorkspaceResolver(Protocol): def resolve(self, workspace_id: str) -> Workspace | Awaitable[Workspace]: ... -class ArgumentNormalizer(Protocol): - def __call__( - self, - operation: str, - arguments: Mapping[str, Any] | None, - workspace: str, - ) -> dict[str, Any]: ... - - class BriefingService(Protocol): def assemble( self, @@ -255,16 +247,6 @@ def assemble( ) -> object | Awaitable[object]: ... -class PreflightService(Protocol): - def guidance( - self, - workspace: Workspace, - target_tool: str, - normalized_arguments: Mapping[str, Any], - description: str | None, - ) -> object | Awaitable[object]: ... - - class RecallService(Protocol): def retrieve( self, @@ -299,6 +281,7 @@ class PinnedDependencies: response_factory: ResponseFactory = field(default_factory=ResponseFactory) scope_provider: Callable[[], InvocationScope | None] = invocation_scope_var.get clock: Callable[[], datetime] = _utc_now + ceremony: CovenantCeremony | None = None @dataclass(frozen=True, slots=True) @@ -314,6 +297,8 @@ class MemoryStoreCommand: happened_at: datetime | None procedure_steps: tuple[str, ...] idempotency_key: str + informed_by: tuple[str, ...] = () + code_refs: tuple[tuple[str, str | None], ...] = () @dataclass(frozen=True, slots=True) @@ -334,6 +319,9 @@ class MemoryOutcomeCommand: worked: bool happened_at: datetime | None idempotency_key: str + informed_by: tuple[str, ...] = () + verification: Mapping[str, object] | None = None + rebind_code: bool = False @dataclass(frozen=True, slots=True) @@ -509,11 +497,25 @@ async def memory_preflight( return failure assert scope is not None call_arguments = request.model_dump() - violation = self._dependencies.covenant_gate.authorize( - "memory_preflight", - call_arguments, - scope, - ) + violation: Mapping[str, Any] | None + if self._dependencies.ceremony is None: + violation = self._dependencies.covenant_gate.authorize( + "memory_preflight", + call_arguments, + scope, + ) + else: + outcome = await self._dependencies.ceremony.admit( + "memory_preflight", + call_arguments, + workspace, + scope, + preflight_token=None, + consume_capability=True, + counsel_mode="off", + ) + response = response.with_covenant(outcome.notice) + violation = outcome.violation if violation is not None: code = violation.get("violation") if code == ErrorCode.COMMUNION_REQUIRED.value: @@ -620,6 +622,7 @@ async def memory_recall( include_archived: bool = False, token_budget: int = 2400, rerank: bool = False, + intent: Literal["explore", "implement", "debug", "review"] | None = None, ) -> MemoryRecallOutput: payload: dict[str, object] = { "workspace_id": workspace_id, @@ -635,6 +638,7 @@ async def memory_recall( "include_archived": include_archived, "token_budget": token_budget, "rerank": rerank, + "intent": intent, } if linked_workspace_ids is not None: payload["linked_workspace_ids"] = linked_workspace_ids @@ -676,11 +680,25 @@ async def memory_recall( if failure is not None: return failure assert scope is not None - violation = self._dependencies.covenant_gate.authorize( - "memory_recall", - effective, - scope, - ) + violation: Mapping[str, Any] | None + if durable_execution or self._dependencies.ceremony is None: + violation = self._dependencies.covenant_gate.authorize( + "memory_recall", + effective, + scope, + ) + else: + outcome = await self._dependencies.ceremony.admit( + "memory_recall", + effective, + workspace, + scope, + preflight_token=None, + consume_capability=True, + counsel_mode="off", + ) + response = response.with_covenant(outcome.notice) + violation = outcome.violation if violation is not None: code = violation.get("violation") if code == ErrorCode.COMMUNION_REQUIRED.value: @@ -727,6 +745,7 @@ async def memory_recall( include_archived=request.include_archived, token_budget=request.token_budget, rerank=request.rerank, + intent=request.intent, ) linked_ids = frozenset(request.linked_workspace_ids) if linked_ids: @@ -799,8 +818,10 @@ async def memory_store( relative_file_path: str | None = None, happened_at: datetime | None = None, procedure_steps: list[str] | None = None, + informed_by: list[str] | None = None, + code_refs: list[dict[str, object]] | None = None, idempotency_key: str, - preflight_token: str, + preflight_token: str | None = None, ) -> MemoryStoreOutput: payload: dict[str, object] = { "workspace_id": workspace_id, @@ -818,6 +839,10 @@ async def memory_store( payload["tags"] = tags if procedure_steps is not None: payload["procedure_steps"] = procedure_steps + if informed_by is not None: + payload["informed_by"] = informed_by + if code_refs is not None: + payload["code_refs"] = code_refs request = MemoryStoreInput.model_validate(payload) response = self._dependencies.response_factory.begin(request.workspace_id) workspace, failure = await self._resolve_workspace( @@ -839,12 +864,37 @@ async def memory_store( exclude={"workspace_id", "preflight_token"}, ), } - violation = self._dependencies.covenant_gate.authorize( - "memory_store", - request.model_dump(), - scope, - preflight_token=request.preflight_token, - ) + violation: Mapping[str, Any] | None + if self._dependencies.ceremony is None: + violation = self._dependencies.covenant_gate.authorize( + "memory_store", + request.model_dump(), + scope, + preflight_token=request.preflight_token, + ) + else: + outcome = await self._dependencies.ceremony.admit( + "memory_store", + request.model_dump(), + workspace, + scope, + preflight_token=request.preflight_token, + consume_capability=True, + counsel_mode="inline", + ) + response = response.with_covenant(outcome.notice) + violation = outcome.violation + if outcome.challenge is not None: + return response.failure( + ErrorCode.COUNSEL_REQUIRED, + "Review error.counsel, then retry exactly error.remedy.", + remedy_tool="memory_store", + remedy_arguments={ + **request.model_dump(mode="json", exclude={"preflight_token"}), + "preflight_token": outcome.challenge.preflight_token, + }, + counsel=outcome.challenge, + ) if violation is not None: code = violation.get("violation") try: @@ -866,6 +916,9 @@ async def memory_store( }: remedy_tool = "memory_preflight" remedy_arguments = preflight_remedy + elif stable_code is ErrorCode.COMMUNION_REQUIRED: + remedy_tool = "session_brief" + remedy_arguments = {"workspace_id": request.workspace_id} return response.failure( stable_code, "The preflight capability was rejected.", @@ -882,6 +935,11 @@ async def memory_store( happened_at=request.happened_at, procedure_steps=tuple(request.procedure_steps), idempotency_key=request.idempotency_key, + informed_by=tuple(request.informed_by), + code_refs=tuple( + (ref.relative_file_path, ref.qualified_name) + for ref in request.code_refs + ), ) try: stored_value = self._dependencies.memory_event_writer.store( @@ -915,8 +973,14 @@ async def memory_store( ) except Exception as exc: if getattr(exc, "code", None) == ErrorCode.DATABASE_IN_USE.value: - # authorize() already spent the token, so a retry needs a new - # one; the idempotency key keeps the retry replay-safe. + if self._dependencies.ceremony is not None: + return response.failure( + ErrorCode.DATABASE_IN_USE, + "The workspace database is currently in use. Retry with " + "the same idempotency_key.", + retryable=True, + retry_after_ms=DATABASE_IN_USE_RETRY_AFTER_MS, + ) return response.failure( ErrorCode.DATABASE_IN_USE, "The workspace database is currently in use. Request a new " @@ -938,18 +1002,24 @@ async def memory_record_outcome( outcome_text: str, worked: bool, happened_at: datetime | None = None, + informed_by: list[str] | None = None, + verification: dict[str, object] | None = None, + rebind_code: bool = False, idempotency_key: str, ) -> MemoryRecordOutcomeOutput: - request = MemoryRecordOutcomeInput.model_validate( - { - "workspace_id": workspace_id, - "record_id": record_id, - "outcome_text": outcome_text, - "worked": worked, - "happened_at": happened_at, - "idempotency_key": idempotency_key, - } - ) + payload: dict[str, object] = { + "workspace_id": workspace_id, + "record_id": record_id, + "outcome_text": outcome_text, + "worked": worked, + "happened_at": happened_at, + "verification": verification, + "rebind_code": rebind_code, + "idempotency_key": idempotency_key, + } + if informed_by is not None: + payload["informed_by"] = informed_by + request = MemoryRecordOutcomeInput.model_validate(payload) response = self._dependencies.response_factory.begin(request.workspace_id) workspace, failure = await self._resolve_workspace( request.workspace_id, @@ -962,11 +1032,25 @@ async def memory_record_outcome( if failure is not None: return failure assert scope is not None - violation = self._dependencies.covenant_gate.authorize( - "memory_record_outcome", - request.model_dump(), - scope, - ) + violation: Mapping[str, Any] | None + if self._dependencies.ceremony is None: + violation = self._dependencies.covenant_gate.authorize( + "memory_record_outcome", + request.model_dump(), + scope, + ) + else: + outcome = await self._dependencies.ceremony.admit( + "memory_record_outcome", + request.model_dump(), + workspace, + scope, + preflight_token=None, + consume_capability=True, + counsel_mode="off", + ) + response = response.with_covenant(outcome.notice) + violation = outcome.violation if violation is not None: code = violation.get("violation") if code == ErrorCode.COMMUNION_REQUIRED.value: @@ -983,6 +1067,13 @@ async def memory_record_outcome( worked=request.worked, happened_at=request.happened_at, idempotency_key=request.idempotency_key, + informed_by=tuple(request.informed_by), + verification=( + request.verification.model_dump(exclude_none=True) + if request.verification is not None + else None + ), + rebind_code=request.rebind_code, ) try: recorded_value = self._dependencies.memory_event_writer.record_outcome( @@ -1103,7 +1194,6 @@ def build_pinned_handlers( __all__ = [ - "ArgumentNormalizer", "BriefingService", "HealthService", "IdempotencyConflict", @@ -1114,7 +1204,6 @@ def build_pinned_handlers( "PinnedDependencies", "PinnedHandlers", "PinnedWorkspaceResolver", - "PreflightService", "RecallService", "RecordedOutcome", "StoredMemory", diff --git a/daem0nmcp/api/v7/policy.py b/daem0nmcp/api/v7/policy.py index 03f3d88..b1fee7c 100644 --- a/daem0nmcp/api/v7/policy.py +++ b/daem0nmcp/api/v7/policy.py @@ -26,6 +26,8 @@ def _levels(level: CovenantLevel, *tool_names: str) -> dict[str, CovenantLevel]: "session_brief", "system_health", "covenant_status", + "daem0n_tools_search", + "daem0n_tool_call", ), **_levels( CovenantLevel.COMMUNION, @@ -147,7 +149,6 @@ class V7ArgumentNormalizer: """Validate one v7 input model and return capability-bound arguments.""" _EXCLUDED = frozenset({"workspace_id", "preflight_token"}) - _PREFLIGHT_PLACEHOLDER = "capability-validation-placeholder" def __init__(self, input_models: Mapping[str, type[BaseModel]]) -> None: copied = dict(input_models) @@ -176,9 +177,6 @@ def __call__( except KeyError as exc: raise UnknownCovenantOperation(operation) from exc supplied = dict(arguments or {}) - fields = model.model_fields - if "preflight_token" in fields and "preflight_token" not in supplied: - supplied["preflight_token"] = self._PREFLIGHT_PLACEHOLDER try: validated = model.model_validate(supplied) normalized = validated.model_dump(mode="json", exclude=set(self._EXCLUDED)) diff --git a/daem0nmcp/api/v7/production.py b/daem0nmcp/api/v7/production.py index 5285ccb..7b29e15 100644 --- a/daem0nmcp/api/v7/production.py +++ b/daem0nmcp/api/v7/production.py @@ -43,6 +43,7 @@ ) from ...workspace import Workspace, WorkspaceRegistry from ...workspace_access import WorkspaceAccessPolicy +from .ceremony import CovenantCeremony from .code_entity_operations import ( CodeEntityOperationDependencies, build_code_entity_operations, @@ -82,7 +83,7 @@ MaintenanceOperationDependencies, build_maintenance_operations, ) -from .models import CapabilityState, RecordSummary, WireModel +from .models import CapabilityState, RecordSummary, RuleView, WireModel from .opaque_capabilities import OpaqueCapabilityAuthority from .operations import CoreOperationDependencies, build_core_operations from .pinned import PinnedDependencies @@ -104,7 +105,6 @@ ResourceReader, ResourceReadRequest, ResourceRow, - RuleView, ) from .responses import ResponseFactory from .rule_trigger_operations import ( @@ -497,6 +497,77 @@ def _unique_text(values: list[str], *, limit: int) -> list[str]: return result +def _compact_brief_reader(readers: ResourceRepositoryReaders): + async def read(workspace: Workspace) -> dict[str, object]: + try: + _active_database(workspace) + except ProductionConfigurationError as exc: + raise RuntimeServiceError(exc.code) from None + except Exception: + raise RuntimeServiceError("ACTIVE_V7_UNAVAILABLE") from None + snapshot_reader = readers.briefing_snapshot_reader + if snapshot_reader is not None: + snapshot = await snapshot_reader( + workspace, + warning_limit=5, + failure_limit=5, + rule_limit=50, + active_context_limit=1, + include_git_changes=False, + ) + warnings = _public_items(snapshot.warnings, RecordSummary) + failures = _public_items(snapshot.failures, RecordSummary) + rules = _public_items(snapshot.rules, RuleView) + statistics = dict(snapshot.workspace_statistics) + else: + warnings = await _read_items( + readers.warning_reader, + workspace, + ResourceReadRequest("warnings", 5, "updated_at_desc"), + RecordSummary, + ) + failures = await _read_items( + readers.failure_reader, + workspace, + ResourceReadRequest("failures", 5, "updated_at_desc"), + RecordSummary, + ) + rules = await _read_items( + readers.rule_reader, + workspace, + ResourceReadRequest("rules", 50, "priority_desc", enabled_only=True), + RuleView, + ) + active_context = await _read_items( + readers.active_context_reader, + workspace, + ResourceReadRequest("active_context", 1, "priority_desc"), + ActiveContextItem, + ) + statistics = { + "warnings": len(warnings), + "failed_outcomes": len(failures), + "rules": len(rules), + "active_context": len(active_context), + } + return { + "briefed_at": datetime.now(timezone.utc), + "workspace_statistics": statistics, + "warnings": warnings, + "failed_records": failures, + "must_not": _unique_text( + [value for rule in rules for value in rule.must_not], + limit=20, + ), + "ask_first": _unique_text( + [value for rule in rules for value in rule.ask_first], + limit=20, + ), + } + + return read + + def _briefing_reader(readers: ResourceRepositoryReaders): async def read( workspace: Workspace, request: SessionBriefInput @@ -892,6 +963,17 @@ def schedule_capture_projection(path: Path) -> None: dreaming_provider=dreaming.health, runtime_diagnostics_provider=runtime_health.inspect, ) + preflight_service = BasicPreflightService(reader=_guidance_reader(resource_readers)) + ceremony = ( + CovenantCeremony( + covenant_gate=gate, + argument_normalizer=normalizer, + preflight_service=preflight_service, + brief_reader=_compact_brief_reader(resource_readers), + ) + if loaded_settings.covenant_mode == "guided" + else None + ) pinned = PinnedDependencies( workspace_resolver=registry, covenant_gate=gate, @@ -899,13 +981,12 @@ def schedule_capture_projection(path: Path) -> None: briefing_service=BasicBriefingService( reader=_briefing_reader(resource_readers) ), - preflight_service=BasicPreflightService( - reader=_guidance_reader(resource_readers) - ), + preflight_service=preflight_service, recall_service=recall, memory_event_writer=writer, health_service=health, response_factory=ResponseFactory(), + ceremony=ceremony, ) record_dependencies = RecordOperationDependencies( storage_resolver=storage_resolver, @@ -1005,6 +1086,7 @@ def authorize_bridge_workspace( rule_reader=resource_readers.rule_reader, active_context_reader=resource_readers.active_context_reader, transport_mode=transport_mode, + tool_surface=loaded_settings.tool_surface, process_principal=_local_authority_principal(loaded_settings), allow_unauthenticated_loopback=( transport_mode == "streamable-http" and auth is None diff --git a/daem0nmcp/api/v7/resource_repository.py b/daem0nmcp/api/v7/resource_repository.py index 4626698..4bcbc22 100644 --- a/daem0nmcp/api/v7/resource_repository.py +++ b/daem0nmcp/api/v7/resource_repository.py @@ -32,7 +32,7 @@ from ...storage_activation import DatabaseFileLock, ResolvedActiveDatabase from ...workspace import Workspace, normalize_resolved_path from .errors import is_database_busy -from .models import RecordSummary, stored_relative_path +from .models import RecordSummary, RuleView, stored_relative_path from .public_ids import PublicObjectIdRepository from .resources import ( RESOURCE_FETCH_LIMIT, @@ -40,10 +40,60 @@ ResourceReader, ResourceReadRequest, ResourceRow, - RuleView, ) +def read_git_output_sync(workspace: Workspace, arguments: list[str]) -> bytes | None: + creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) + if os.name == "nt": + creation_flags |= 0x00000004 # CREATE_SUSPENDED: contain before execution + try: + # File capture avoids waiting on pipes inherited by Git descendants. + with tempfile.TemporaryFile() as output: + process = subprocess.Popen( + [ + "git", + "--no-optional-locks", + "-c", + "core.fsmonitor=false", + "-C", + str(workspace.root), + *arguments, + ], + stdin=subprocess.DEVNULL, + stdout=output, + stderr=subprocess.DEVNULL, + creationflags=creation_flags, + start_new_session=(os.name != "nt"), + env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"}, + ) + job = None + try: + job = _WindowsKillOnCloseJob(process) + job.resume(process.pid) + try: + process.wait(timeout=2.0) + except subprocess.TimeoutExpired: + return None + output.seek(0) + raw = output.read(_MAX_GIT_OUTPUT_BYTES + 1) + finally: + if job is not None: + job.close() + if os.name != "nt": + # Even a successfully exited Git can leave descendants. + with suppress(ProcessLookupError): + getattr(os, "killpg")(process.pid, 9) # noqa: B009 - absent in Windows stubs + if process.poll() is None: + process.kill() + process.wait(timeout=0.5) + except (OSError, subprocess.SubprocessError): + return None + if process.returncode != 0 or len(raw) > _MAX_GIT_OUTPUT_BYTES: + return None + return raw + + class _WindowsKillOnCloseJob: """Own a Windows process tree without an optional pywin32 dependency.""" @@ -635,69 +685,11 @@ def _read_briefing_snapshot_sync( stale_projection_count=stale_projection_count, ) - @staticmethod - def _read_git_output_sync( - workspace: Workspace, arguments: list[str] - ) -> bytes | None: - creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) - if os.name == "nt": - creation_flags |= 0x00000004 # CREATE_SUSPENDED: contain before execution - try: - # A descendant may inherit captured pipe handles. On Windows, - # captured-pipe timeout cleanup then waits indefinitely for - # those pipes to close even after Git has been killed. A temporary - # file keeps both process waiting and output consumption bounded. - with tempfile.TemporaryFile() as output: - process = subprocess.Popen( - [ - "git", - "--no-optional-locks", - "-c", - "core.fsmonitor=false", - "-C", - str(workspace.root), - *arguments, - ], - stdin=subprocess.DEVNULL, - stdout=output, - stderr=subprocess.DEVNULL, - creationflags=creation_flags, - start_new_session=(os.name != "nt"), - env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"}, - ) - job = None - try: - job = _WindowsKillOnCloseJob(process) - job.resume(process.pid) - try: - process.wait(timeout=2.0) - except subprocess.TimeoutExpired: - return None - output.seek(0) - raw = output.read(_MAX_GIT_OUTPUT_BYTES + 1) - finally: - if job is not None: - job.close() - if os.name != "nt": - # Even a successfully exited Git can leave descendants. - with suppress(ProcessLookupError): - getattr(os, "killpg")(process.pid, 9) # noqa: B009 - absent in Windows stubs - if process.poll() is None: - process.kill() - process.wait(timeout=0.5) - except (OSError, subprocess.SubprocessError): - return None - if process.returncode != 0 or len(raw) > _MAX_GIT_OUTPUT_BYTES: - return None - return raw - @staticmethod def _read_git_changes_sync(workspace: Workspace) -> list[object]: # Porcelain paths are repository-relative even when Git runs from a # nested workspace. Filter at Git and again before exposing public paths. - prefix_raw = SQLiteResourceRepository._read_git_output_sync( - workspace, ["rev-parse", "--show-prefix"] - ) + prefix_raw = read_git_output_sync(workspace, ["rev-parse", "--show-prefix"]) if prefix_raw is None: return [] try: @@ -713,7 +705,7 @@ def _read_git_changes_sync(workspace: Workspace) -> list[object]: or not SQLiteResourceRepository._safe_relative_git_path(prefix[:-1]) ): return [] - raw = SQLiteResourceRepository._read_git_output_sync( + raw = read_git_output_sync( workspace, ["status", "--porcelain=v1", "-z", "--untracked-files=normal", "--", "."], ) diff --git a/daem0nmcp/api/v7/resources.py b/daem0nmcp/api/v7/resources.py index b488586..c03ccf3 100644 --- a/daem0nmcp/api/v7/resources.py +++ b/daem0nmcp/api/v7/resources.py @@ -22,7 +22,7 @@ ActiveContextId, AwareDateTime, RecordSummary, - RuleId, + RuleView, UserText, UtcDateTime, WireModel, @@ -50,12 +50,6 @@ _UTC_DATETIME_ADAPTER = TypeAdapter(UtcDateTime) _ACCESS_FAILURE = object() -# Rule text is user-authored and may mention paths. -BoundedText = Annotated[ - UserText, - StringConstraints(strict=True, min_length=1, max_length=2000), -] - class ResourceAccessError(RuntimeError): """The only caller-visible resource read failure. @@ -71,20 +65,6 @@ def __init__(self) -> None: super().__init__("Resource unavailable") -class RuleView(WireModel): - """Bounded public representation of one workspace rule.""" - - rule_id: RuleId - trigger: BoundedText - must_do: list[BoundedText] = Field(default_factory=list, max_length=50) - must_not: list[BoundedText] = Field(default_factory=list, max_length=50) - ask_first: list[BoundedText] = Field(default_factory=list, max_length=50) - warnings: list[BoundedText] = Field(default_factory=list, max_length=50) - priority: Annotated[int, Field(strict=True, ge=-1000, le=1000)] - enabled: Annotated[bool, Field(strict=True)] - created_at: AwareDateTime - - class ActiveContextItem(WireModel): """One always-hot record without a legacy row ID or canonical path.""" @@ -503,7 +483,6 @@ def build_resource_specs(handlers: ResourceHandlers) -> tuple[ResourceSpec, ...] "ResourceReader", "ResourceRow", "RuleResourceDocument", - "RuleView", "WARNING_RESOURCE_URI_TEMPLATE", "WarningResourceDocument", "WorkspaceResolver", diff --git a/daem0nmcp/api/v7/responses.py b/daem0nmcp/api/v7/responses.py index b913108..995fcf4 100644 --- a/daem0nmcp/api/v7/responses.py +++ b/daem0nmcp/api/v7/responses.py @@ -8,7 +8,7 @@ import sys import traceback from collections.abc import Callable, Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime, timezone from typing import Any, TypeVar @@ -20,6 +20,8 @@ ApiResponse, ApiWarning, CapabilityState, + CounselChallenge, + CovenantNotice, ErrorRemedy, FieldError, ResponseMeta, @@ -91,6 +93,10 @@ class ResponseContext: request_id: str started_at: datetime _clock: Callable[[], datetime] + covenant: CovenantNotice | None = None + + def with_covenant(self, notice: CovenantNotice | None) -> ResponseContext: + return replace(self, covenant=notice) def _meta( self, @@ -107,6 +113,7 @@ def _meta( duration_ms=min(elapsed, 86_400_000), warnings=list(warnings), capability_states=list(capability_states), + covenant=self.covenant, ) def success( @@ -136,6 +143,7 @@ def failure( field_errors: Sequence[FieldError] = (), remedy_tool: str | None = None, remedy_arguments: Mapping[str, Any] | None = None, + counsel: CounselChallenge | None = None, warnings: Sequence[ApiWarning] = (), capability_states: Sequence[CapabilityState] = (), ) -> ApiResponse[Any]: @@ -161,6 +169,7 @@ def failure( retry_after_ms=retry_after_ms, field_errors=list(field_errors), remedy=remedy, + counsel=counsel, correlation_id=self.request_id, ), meta=self._meta( @@ -169,6 +178,27 @@ def failure( ), ) + def relay_failure( + self, + error: ApiError, + *, + remedy: ErrorRemedy | None, + warnings: Sequence[ApiWarning] = (), + capability_states: Sequence[CapabilityState] = (), + ) -> ApiResponse[Any]: + return self.failure( + error.code, + error.message, + retryable=error.retryable, + retry_after_ms=error.retry_after_ms, + field_errors=error.field_errors, + remedy_tool=remedy.tool if remedy is not None else None, + remedy_arguments=remedy.arguments if remedy is not None else None, + counsel=error.counsel, + warnings=warnings, + capability_states=capability_states, + ) + def internal_error(self, error: BaseException | None = None) -> ApiResponse[Any]: """Return the one deliberately opaque internal failure envelope. diff --git a/daem0nmcp/api/v7/rule_trigger_operations.py b/daem0nmcp/api/v7/rule_trigger_operations.py index bbc7ac5..3f7a0e5 100644 --- a/daem0nmcp/api/v7/rule_trigger_operations.py +++ b/daem0nmcp/api/v7/rule_trigger_operations.py @@ -48,12 +48,11 @@ from ...workspace import Workspace, WorkspaceRegistry from .application import AdmittedRequest from .errors import STABLE_ERROR_CODE_SET -from .models import MutationReceipt, Page, RecordSummary, RetrievalData +from .models import MutationReceipt, Page, RecordSummary, RetrievalData, RuleView from .public_ids import ( PublicObjectIdNotFound, PublicObjectIdRepository, ) -from .resources import RuleView from .runtime_services import WorkspaceStorageResolver from .tasks import await_task_terminal from .tools import RuleCheckData, TriggerMatch, TriggerMatchData, TriggerView diff --git a/daem0nmcp/api/v7/runtime_services.py b/daem0nmcp/api/v7/runtime_services.py index 68cb407..9f95c10 100644 --- a/daem0nmcp/api/v7/runtime_services.py +++ b/daem0nmcp/api/v7/runtime_services.py @@ -23,6 +23,12 @@ from ... import __version__ from ...bounded_workers import BoundedWorkerBusyError, BoundedWorkerPool +from ...code_bindings import ( + CodeBindingError, + CodeBindingUnavailableError, + binding_refs_from_context, + capture_bindings, +) from ...event_store import ( AppendedEvent, EventCommand, @@ -61,6 +67,7 @@ from .models import ( CapabilityState, CitationManifestEntry, + PreflightGuidance, RecordSummary, RetrievalData, TokenUsage, @@ -85,7 +92,6 @@ from .tasks import await_task_terminal from .tools import ( HealthData, - PreflightGuidance, SessionBriefData, SessionBriefInput, ) @@ -516,6 +522,23 @@ def _record_state(row: sqlite3.Row) -> dict[str, Any]: } +def _validate_informed_by( + connection: sqlite3.Connection, + workspace_id: str, + informed_by: tuple[str, ...], +) -> None: + if not informed_by: + return + placeholders = ",".join("?" for _ in informed_by) + count = connection.execute( + "SELECT COUNT(*) FROM memory_records WHERE workspace_id=? " + f"AND record_id IN ({placeholders})", + (workspace_id, *informed_by), + ).fetchone()[0] + if count != len(informed_by): + raise RuntimeServiceError("INVALID_ARGUMENT") + + class ProjectionScheduler(Protocol): def __call__(self, database_path: Path) -> object: ... @@ -642,25 +665,34 @@ def _store_sync( else _datetime_us(command.happened_at) ) context = dict(command.context) + if command.code_refs: + try: + bindings = capture_bindings(workspace, command.code_refs) + except CodeBindingUnavailableError as exc: + raise RuntimeServiceError("CAPABILITY_DEGRADED") from exc + except CodeBindingError as exc: + raise RuntimeServiceError("INVALID_ARGUMENT") from exc + context["code_bindings"] = [binding.to_json() for binding in bindings] if command.record_type == "procedure": steps = list(command.procedure_steps) if "steps" in context and context["steps"] != steps: raise RuntimeServiceError("INVALID_ARGUMENT") context["steps"] = steps - request_hash = sha256_json( - { - "record_type": command.record_type, - "content": command.content, - "rationale": command.rationale, - "context": dict(command.context), - "tags": list(command.tags), - "relative_file_path": command.relative_file_path, - "happened_at_us": ( - None if command.happened_at is None else happened_at_us - ), - "procedure_steps": list(command.procedure_steps), - } - ) + request_fields: dict[str, object] = { + "record_type": command.record_type, + "content": command.content, + "rationale": command.rationale, + "context": dict(command.context), + "tags": list(command.tags), + "relative_file_path": command.relative_file_path, + "happened_at_us": (None if command.happened_at is None else happened_at_us), + "procedure_steps": list(command.procedure_steps), + } + if command.informed_by: + request_fields["informed_by"] = sorted(command.informed_by) + if command.code_refs: + request_fields["code_refs"] = [list(ref) for ref in command.code_refs] + request_hash = sha256_json(request_fields) record_id = deterministic_id( "mem", "memory-store", @@ -698,6 +730,8 @@ def _store_sync( "record": record, "idempotency_request_hash": request_hash, } + if command.informed_by: + payload["provenance"] = {"informed_by": sorted(command.informed_by)} with self._storage_resolver.locked_active(workspace) as active: connection = _open_database(active.path, writable=True) try: @@ -713,6 +747,11 @@ def _store_sync( request_hash=request_hash, ) if existing is None: + if record_id in command.informed_by: + raise RuntimeServiceError("INVALID_ARGUMENT") + _validate_informed_by( + connection, workspace.workspace_id, command.informed_by + ) event = EventStore( connection, assume_transaction=True ).append_and_project( @@ -785,6 +824,10 @@ def _record_outcome_sync( command: MemoryOutcomeCommand, cancelled: threading.Event, ) -> tuple[RecordedOutcome, Path, bool]: + if command.record_id in command.informed_by or ( + command.rebind_code and command.worked is not True + ): + raise RuntimeServiceError("INVALID_ARGUMENT") recorded_at_us = self._now_us() if cancelled.is_set(): raise _WorkerCancelledError() @@ -793,16 +836,19 @@ def _record_outcome_sync( if command.happened_at is None else _datetime_us(command.happened_at) ) - request_hash = sha256_json( - { - "record_id": command.record_id, - "outcome_text": command.outcome_text, - "worked": command.worked, - "happened_at_us": ( - None if command.happened_at is None else happened_at_us - ), - } - ) + request_fields: dict[str, object] = { + "record_id": command.record_id, + "outcome_text": command.outcome_text, + "worked": command.worked, + "happened_at_us": (None if command.happened_at is None else happened_at_us), + } + if command.informed_by: + request_fields["informed_by"] = sorted(command.informed_by) + if command.verification is not None: + request_fields["verification"] = dict(command.verification) + if command.rebind_code: + request_fields["rebind_code"] = True + request_hash = sha256_json(request_fields) correlation = _idempotency_correlation( workspace.workspace_id, "memory-record-outcome", @@ -826,13 +872,35 @@ def _record_outcome_sync( request_hash=request_hash, ) if existing is None: + _validate_informed_by( + connection, workspace.workspace_id, command.informed_by + ) record = _record_state(row) + if command.rebind_code: + try: + refs = binding_refs_from_context(record["context"]) + if not refs: + raise CodeBindingError("record has no code bindings") + bindings = capture_bindings(workspace, refs) + except CodeBindingUnavailableError as exc: + raise RuntimeServiceError("CAPABILITY_DEGRADED") from exc + except CodeBindingError as exc: + raise RuntimeServiceError("INVALID_ARGUMENT") from exc + record["context"]["code_bindings"] = [ + binding.to_json() for binding in bindings + ] record["outcome"] = command.outcome_text record["worked"] = command.worked payload = { "record": record, "idempotency_request_hash": request_hash, } + if command.informed_by: + payload["provenance"] = { + "informed_by": sorted(command.informed_by) + } + if command.verification is not None: + payload["verification"] = dict(command.verification) event = EventStore( connection, assume_transaction=True ).append_and_project( @@ -921,8 +989,16 @@ def _default_retrieval_service_factory( "retrieval_graph_max_depth", "retrieval_rerank_candidate_limit", "retrieval_rerank_enabled", + "retrieval_reranker", + "retrieval_late_interaction_model", "retrieval_rrf_weights", "rrf_k", + "retrieval_utility_mode", + "retrieval_utility_weight", + "retrieval_utility_credit", + "retrieval_utility_candidate_limit", + "memory_validity_mode", + "retrieval_retention_mode", ) @@ -1116,7 +1192,7 @@ async def retrieve( retrieve = getattr(entry.service, "retrieve", None) if not callable(retrieve): raise RuntimeServiceError("RETRIEVAL_UNAVAILABLE") - value = retrieve(query) + value = retrieve(query, workspace_root=workspace.root) result = await value if inspect.isawaitable(value) else value if not isinstance(result, RetrievalResult): raise RuntimeServiceError("RETRIEVAL_FAILED") @@ -1144,7 +1220,7 @@ async def _retrieve_source( retrieve = getattr(entry.service, "retrieve_candidates", None) if not callable(retrieve): raise RuntimeServiceError("RETRIEVAL_UNAVAILABLE") - value = retrieve(query) + value = retrieve(query, workspace_root=workspace.root) result = await value if inspect.isawaitable(value) else value if not isinstance(result, RetrievalCandidateResult): raise RuntimeServiceError("RETRIEVAL_FAILED") @@ -1248,7 +1324,19 @@ async def retrieve_one( value = authorizer(selected) if inspect.isawaitable(value): await value - hydrated = compose_federated_results(dict(pairs), query) + hydrated = compose_federated_results( + dict(pairs), + query, + label_applicability=( + getattr( + self._effective_config(), "memory_validity_mode", "off" + ) + == "apply" + ), + retention_mode=getattr( + self._effective_config(), "retrieval_retention_mode", "off" + ), + ) finally: guard.release() except asyncio.TimeoutError as exc: @@ -1356,6 +1444,14 @@ def _hydrate_candidates( channels=tuple(sorted(selected.candidate.channels)), status=selected.status, evidence_refs=public_refs, + applicability=selected.applicability, + changed_bindings=selected.changed_bindings, + utility=selected.utility, + procedure_steps=selected.procedure_steps, + code_bindings=selected.code_bindings, + outcome=selected.outcome, + outcome_failed=selected.outcome_failed, + superseded_by_version_id=selected.superseded_by_version_id, ) ) connection.commit() @@ -1488,6 +1584,9 @@ def _hydrate( score=item.score, status=item.status, evidence_refs=public_refs, + utility=item.utility, + applicability=item.applicability, + changed_bindings=list(item.changed_bindings), ) ) diff --git a/daem0nmcp/api/v7/tools.py b/daem0nmcp/api/v7/tools.py index 4c48ab9..5efb4be 100644 --- a/daem0nmcp/api/v7/tools.py +++ b/daem0nmcp/api/v7/tools.py @@ -41,6 +41,8 @@ JsonObject, MutationReceipt, Page, + PreflightGuidance, + PreflightToken, Preview, RecordId, RecordSummary, @@ -49,22 +51,25 @@ RelativePath, RetrievalData, RuleId, + RuleView, SelectionToken, Tag, ToolName, TriggerId, UniqueTags, UserJsonObject, + UserMediumText, UserText, UtcDateTime, VersionId, WireModel, WorkspaceId, + _unique_strings, contains_absolute_filesystem_path, ) from .policy import V7_TOOL_LEVELS, V7ArgumentNormalizer from .registry import PINNED_TOOL_NAMES, ManifestError, ToolSpec -from .resources import ActiveContextItem, RuleView +from .resources import ActiveContextItem NameText = Annotated[ str, @@ -83,10 +88,6 @@ UserText, StringConstraints(strict=True, min_length=1, max_length=256), ] -UserMediumText = Annotated[ - UserText, - StringConstraints(strict=True, min_length=1, max_length=2000), -] UserLongText = Annotated[ UserText, StringConstraints(strict=True, min_length=1, max_length=100_000), @@ -96,12 +97,15 @@ def _record_visibility(context: dict[str, object]) -> dict[str, object]: + if "code_bindings" in context: + raise ValueError("context.code_bindings is server-managed") if context.get("visibility", "workspace") not in get_args(RecordVisibility): raise ValueError("context.visibility must be workspace, private or shared") return context RecordContext = Annotated[ContextJsonObject, AfterValidator(_record_visibility)] +InformedByRecordIds = Annotated[list[RecordId], AfterValidator(_unique_strings)] IdempotencyKey = Annotated[ str, StringConstraints( @@ -111,15 +115,6 @@ def _record_visibility(context: dict[str, object]) -> dict[str, object]: pattern=r"^[A-Za-z0-9][A-Za-z0-9._~-]*$", ), ] -PreflightToken = Annotated[ - str, - StringConstraints( - strict=True, - min_length=16, - max_length=8192, - pattern=r"^[A-Za-z0-9._~-]+$", - ), -] EditRequestId = Annotated[ str, StringConstraints( @@ -392,15 +387,6 @@ class SessionBriefData(WireModel): ) -class PreflightGuidance(WireModel): - records: list[RecordSummary] = Field(default_factory=list, max_length=20) - rules: list[RuleView] = Field(default_factory=list, max_length=20) - must_do: list[UserMediumText] = Field(default_factory=list, max_length=50) - must_not: list[UserMediumText] = Field(default_factory=list, max_length=50) - ask_first: list[UserMediumText] = Field(default_factory=list, max_length=50) - warnings: list[UserMediumText] = Field(default_factory=list, max_length=50) - - class PreflightData(WireModel): guidance: PreflightGuidance preflight_token: PreflightToken | None = None @@ -1061,6 +1047,36 @@ class DecisionDebateData(WireModel): evidence_refs: list[EvidenceRef] = Field(default_factory=list, max_length=200) +class ToolDescriptor(WireModel): + name: ToolName + description: MediumText + category: NameText + covenant: Literal["exempt", "communion", "counsel", "destructive"] + read_only: bool + listed: bool + input_schema: JsonObject + + +class ToolsSearchInput(WireModel): + query: UserShortText + limit: Annotated[int, Field(ge=1, le=10)] = 5 + + +class ToolsSearchData(WireModel): + tools: list[ToolDescriptor] = Field(max_length=10) + + +class ToolCallInput(WireModel): + workspace_id: WorkspaceId + tool: ToolName + arguments: UserJsonObject = Field(default_factory=dict) + + +class ToolCallData(WireModel): + tool: ToolName + data: UserJsonObject + + class SessionBriefInput(WireModel): workspace_id: WorkspaceId focus_areas: list[UserShortText] = Field(default_factory=list, max_length=10) @@ -1099,6 +1115,7 @@ class MemoryRecallInput(WireModel): include_archived: bool = False token_budget: Annotated[int, Field(ge=256, le=16_000)] = 2400 rerank: bool = False + intent: Literal["explore", "implement", "debug", "review"] | None = None @model_validator(mode="after") def validate_candidate_limit(self) -> MemoryRecallInput: @@ -1107,6 +1124,27 @@ def validate_candidate_limit(self) -> MemoryRecallInput: return self +class OutcomeVerification(WireModel): + kind: Literal["test", "command", "review", "self_report"] + command: UserMediumText | None = None + exit_code: Annotated[int, Field(ge=-2_147_483_648, le=2_147_483_647)] | None = None + + @model_validator(mode="after") + def validate_command_evidence(self) -> OutcomeVerification: + if self.kind in {"review", "self_report"} and ( + self.command is not None or self.exit_code is not None + ): + raise ValueError( + "review and self_report verification cannot carry command evidence" + ) + return self + + +class CodeRef(WireModel): + relative_file_path: RelativePath + qualified_name: UserNameText | None = None + + class MemoryStoreInput(WireModel): workspace_id: WorkspaceId record_type: RecordType @@ -1123,8 +1161,10 @@ class MemoryStoreInput(WireModel): relative_file_path: RelativePath | None = None happened_at: AwareDateTime | None = None procedure_steps: list[UserMediumText] = Field(default_factory=list, max_length=100) + informed_by: InformedByRecordIds = Field(default_factory=list, max_length=32) + code_refs: list[CodeRef] = Field(default_factory=list, max_length=16) idempotency_key: IdempotencyKey - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None @model_validator(mode="after") def validate_procedure_steps(self) -> MemoryStoreInput: @@ -1157,7 +1197,7 @@ class MemoryCapturePromoteInput(WireModel): context: RecordContext = Field(default_factory=dict) tags: UniqueTags = Field(default_factory=list, max_length=16) idempotency_key: IdempotencyKey - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class MemoryRecordOutcomeInput(WireModel): @@ -1169,6 +1209,9 @@ class MemoryRecordOutcomeInput(WireModel): ] worked: bool happened_at: AwareDateTime | None = None + informed_by: InformedByRecordIds = Field(default_factory=list, max_length=32) + verification: OutcomeVerification | None = None + rebind_code: bool = False idempotency_key: IdempotencyKey @@ -1290,7 +1333,7 @@ class MemoryStoreBatchInput(WireModel): workspace_id: WorkspaceId records: list[MemoryCreate] = Field(min_length=1, max_length=100) idempotency_key: IdempotencyKey - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class MemoryLinkInput(WireModel): @@ -1301,7 +1344,7 @@ class MemoryLinkInput(WireModel): description: OptionalUserMediumText = None confidence: Annotated[float, Field(ge=0, le=1, allow_inf_nan=False)] = 1.0 idempotency_key: IdempotencyKey - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None @model_validator(mode="after") def validate_endpoints(self) -> MemoryLinkInput: @@ -1313,14 +1356,14 @@ def validate_endpoints(self) -> MemoryLinkInput: class MemoryUnlinkInput(WireModel): workspace_id: WorkspaceId relationship_id: RelationshipId - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class MemoryPinSetInput(WireModel): workspace_id: WorkspaceId record_id: RecordId pinned: bool - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class ActiveContextAddInput(WireModel): @@ -1329,19 +1372,19 @@ class ActiveContextAddInput(WireModel): reason: OptionalUserMediumText = None priority: Annotated[int, Field(ge=-100, le=100)] = 0 expires_at: AwareDateTime | None = None - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class ActiveContextRemoveInput(WireModel): workspace_id: WorkspaceId active_context_id: ActiveContextId - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class ActiveContextClearInput(WireModel): workspace_id: WorkspaceId selection_token: SelectionToken - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class DocumentIngestUrlInput(WireModel): @@ -1350,7 +1393,7 @@ class DocumentIngestUrlInput(WireModel): topic: UserMediumText chunk_size: Annotated[int, Field(ge=256, le=16_000)] = 2000 idempotency_key: IdempotencyKey - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class MemoryVerifyInput(WireModel): @@ -1371,7 +1414,7 @@ class SandboxExecutePythonInput(WireModel): StringConstraints(strict=True, min_length=1, max_length=100_000), ] timeout_seconds: Annotated[int, Field(ge=1, le=60)] = 30 - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class CodeIndexInput(WireModel): @@ -1425,7 +1468,7 @@ class CodeTodosScanAndStoreInput(WireModel): limit: Annotated[int, Field(ge=1, le=500)] = 100 record_type: Literal["warning"] = "warning" idempotency_key: IdempotencyKey - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class CodeRefactorProposeInput(WireModel): @@ -1443,7 +1486,7 @@ class RuleCreateInput(WireModel): warnings: list[UserMediumText] = Field(default_factory=list, max_length=50) priority: Annotated[int, Field(ge=-1000, le=1000)] = 0 idempotency_key: IdempotencyKey - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class RulePatch(WireModel): @@ -1466,7 +1509,7 @@ class RuleUpdateInput(WireModel): workspace_id: WorkspaceId rule_id: RuleId patch: RulePatch - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class RuleListInput(WireModel): @@ -1484,7 +1527,7 @@ class ContextTriggerCreateInput(WireModel): categories: RecordTypeSet | None = None enabled: bool = True idempotency_key: IdempotencyKey - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class ContextTriggerListInput(WireModel): @@ -1497,7 +1540,7 @@ class ContextTriggerListInput(WireModel): class ContextTriggerDeleteInput(WireModel): workspace_id: WorkspaceId trigger_id: TriggerId - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class MemoryRelatedInput(WireModel): @@ -1558,7 +1601,7 @@ class CommunityRebuildInput(WireModel): min_community_size: Annotated[int, Field(ge=2, le=1000)] = 2 resolution: Annotated[float, Field(gt=0, le=100, allow_inf_nan=False)] = 1.0 idempotency_key: IdempotencyKey - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class EntityListInput(WireModel): @@ -1572,7 +1615,7 @@ class EntityBackfillInput(WireModel): workspace_id: WorkspaceId force: bool = False idempotency_key: IdempotencyKey - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class EntityEvolutionTraceInput(WireModel): @@ -1613,14 +1656,14 @@ class MemoryPrunePreviewInput(WireModel): class MemoryPruneInput(MemoryPrunePreviewInput): selection_token: SelectionToken - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class MemoryArchiveSetInput(WireModel): workspace_id: WorkspaceId record_id: RecordId archived: bool - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class MemoryDuplicatesPreviewInput(WireModel): @@ -1630,7 +1673,7 @@ class MemoryDuplicatesPreviewInput(WireModel): class MemoryDuplicatesCleanupInput(MemoryDuplicatesPreviewInput): selection_token: SelectionToken - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class MemoryCompactionPreviewInput(WireModel): @@ -1652,7 +1695,7 @@ class MemoryCompactionPreviewInput(WireModel): class MemoryCompactInput(MemoryCompactionPreviewInput): selection_token: SelectionToken idempotency_key: IdempotencyKey - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class ProjectionRebuildInput(WireModel): @@ -1687,7 +1730,7 @@ class WorkspaceImportInput(WireModel): finalize: bool = True merge: bool = True idempotency_key: IdempotencyKey - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None @model_validator(mode="after") def validate_import_action(self) -> WorkspaceImportInput: @@ -1705,7 +1748,7 @@ class WorkspaceLinkInput(WireModel): linked_workspace_id: WorkspaceId relationship: WorkspaceRelationship = "related" label: OptionalUserMediumText = None - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None @model_validator(mode="after") def validate_link(self) -> WorkspaceLinkInput: @@ -1717,7 +1760,7 @@ def validate_link(self) -> WorkspaceLinkInput: class WorkspaceUnlinkInput(WireModel): workspace_id: WorkspaceId linked_workspace_id: WorkspaceId - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class WorkspaceLinksListInput(WireModel): @@ -1740,7 +1783,7 @@ def validate_sources(self) -> WorkspaceConsolidationPreviewInput: class WorkspaceConsolidateInput(WorkspaceConsolidationPreviewInput): idempotency_key: IdempotencyKey selection_token: SelectionToken - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class WorkspaceConsolidateAndArchiveSourcesInput(WorkspaceConsolidateInput): @@ -1754,7 +1797,7 @@ class DreamDuplicatesPreviewInput(WireModel): class DreamDuplicatesPurgeInput(WireModel): workspace_id: WorkspaceId selection_token: SelectionToken - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class DecisionSimulateInput(WireModel): @@ -1781,7 +1824,7 @@ class DecisionDebateInput(WireModel): ] max_rounds: Annotated[int, Field(ge=1, le=20)] = 5 idempotency_key: IdempotencyKey - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None SessionBriefOutput = ApiResponse[SessionBriefData] @@ -1794,6 +1837,8 @@ class DecisionDebateInput(WireModel): _TOOL_MODELS: Mapping[str, tuple[type[WireModel], type[WireModel]]] = MappingProxyType( { + "daem0n_tools_search": (ToolsSearchInput, ToolsSearchData), + "daem0n_tool_call": (ToolCallInput, ToolCallData), "active_context_add": (ActiveContextAddInput, ActiveContextItem), "active_context_clear": ( ActiveContextClearInput, @@ -2005,6 +2050,7 @@ def build_argument_normalizer() -> V7ArgumentNormalizer: _READ_ONLY_TOOLS = frozenset( { + "daem0n_tools_search", "session_brief", "memory_preflight", "memory_capture_list", @@ -2104,7 +2150,9 @@ def build_argument_normalizer() -> V7ArgumentNormalizer: } ), "edit": frozenset({"edit_preflight"}), - "system": frozenset({"system_health"}), + "system": frozenset( + {"system_health", "daem0n_tools_search", "daem0n_tool_call"} + ), "context": frozenset( { "active_context_list", @@ -2204,6 +2252,8 @@ def _category_by_tool() -> Mapping[str, str]: } V7_NATIVE_TOOL_NAMES = frozenset( { + "daem0n_tools_search", + "daem0n_tool_call", "edit_preflight", "memory_capture_list", "memory_capture_promote", @@ -2308,11 +2358,13 @@ def build_tool_specs( __all__ = [ "ActiveContextPage", "CodeImpactAnalyzeInput", + "CodeRef", "DecisionDebateInput", "DreamingStatus", "DreamingStrategyStatus", "EntityEvolutionTraceInput", "HealthData", + "InformedByRecordIds", "MemoryPreflightInput", "MemoryPreflightOutput", "MemoryRecallEntityInput", @@ -2324,6 +2376,7 @@ def build_tool_specs( "MemoryStoreInput", "MemoryStoreOutput", "OutcomeData", + "OutcomeVerification", "PreflightData", "PROTECTED_TOOL_NAMES", "ProtectedToolName", @@ -2332,6 +2385,11 @@ def build_tool_specs( "SessionBriefOutput", "SystemHealthInput", "SystemHealthOutput", + "ToolCallData", + "ToolCallInput", + "ToolDescriptor", + "ToolsSearchData", + "ToolsSearchInput", "TOOL_DATA_MODELS", "TOOL_INPUT_MODELS", "V7_NATIVE_TOOL_NAMES", diff --git a/daem0nmcp/capabilities.py b/daem0nmcp/capabilities.py index 71e8a5e..b123517 100644 --- a/daem0nmcp/capabilities.py +++ b/daem0nmcp/capabilities.py @@ -59,6 +59,7 @@ def environment_key(self) -> str: "models-local": CapabilityProfile( "models-local", ("sentence-transformers", "onnxruntime", "numpy", "llmlingua") ), + "late-interaction": CapabilityProfile("late-interaction", ("fastembed", "numpy")), "models-hosted": CapabilityProfile("models-hosted", ("tiktoken",)), "agency-e2b": CapabilityProfile("agency-e2b", ("e2b-code-interpreter",)), "observability": CapabilityProfile( @@ -121,7 +122,7 @@ def get(self, name: str) -> dict[str, Any]: return self._disabled(profile) # An unset variable means "on when installed": a profile that cannot run # is simply off, while an explicit request for it is degraded. - if name == "models-local" and sys.version_info < (3, 11): + if name in {"models-local", "late-interaction"} and sys.version_info < (3, 11): capability = self._python_version_unavailable(profile) if configured == "auto": capability["status"] = "disabled" diff --git a/daem0nmcp/claude_hooks/pre_edit.py b/daem0nmcp/claude_hooks/pre_edit.py index b182b89..87e4d6b 100644 --- a/daem0nmcp/claude_hooks/pre_edit.py +++ b/daem0nmcp/claude_hooks/pre_edit.py @@ -25,15 +25,11 @@ def reminder(event: dict) -> str | None: else None ) path = relative_project_path(project, raw, event.get("cwd")) - recall = ( - f"memory_recall_file(relative_file_path={json.dumps(path)})" - if path - else "memory_recall_file" - ) - text = ( - f"Daem0n: before this edit, call {recall} for past decisions and " - "warnings, and memory_preflight for the exact change." - ) + recall = 'daem0n_tool_call(tool="memory_recall_file"' + if path: + recall += f', arguments={{"relative_file_path": {json.dumps(path)}}}' + recall += ")" + text = f"Daem0n: before this edit, call {recall} for past decisions and warnings." return json.dumps( { "hookSpecificOutput": { diff --git a/daem0nmcp/claude_hooks/session_start.py b/daem0nmcp/claude_hooks/session_start.py index 972698c..eda2c8b 100644 --- a/daem0nmcp/claude_hooks/session_start.py +++ b/daem0nmcp/claude_hooks/session_start.py @@ -104,9 +104,11 @@ def main() -> None: sys.exit(0) workspace_id = _workspace_id(project_path) succeed( - "[Daem0n] IMPORTANT: Before responding to the user, call " + "[Daem0n] IMPORTANT: The first Daem0n call establishes this invocation's " + "v7 session and brief automatically (meta.covenant.auto_brief). " + f'Use workspace_id="{workspace_id}". Call ' f'mcp__daem0nmcp__session_brief(workspace_id="{workspace_id}") ' - "to establish this invocation's v7 session scope." + "for the full brief." ) diff --git a/daem0nmcp/claude_hooks/stop.py b/daem0nmcp/claude_hooks/stop.py index 609836f..249f408 100644 --- a/daem0nmcp/claude_hooks/stop.py +++ b/daem0nmcp/claude_hooks/stop.py @@ -268,7 +268,6 @@ def _memory_store_suggestion( } if relative_path is not None: target_arguments["relative_file_path"] = relative_path - encoded = json.dumps(target_arguments, ensure_ascii=True, separators=(",", ":")) store_fields = [ f'workspace_id="{workspace_id}"', f"record_type={json.dumps(target_arguments['record_type'])}", @@ -277,20 +276,11 @@ def _memory_store_suggestion( ] if relative_path is not None: store_fields.append(f"relative_file_path={json.dumps(relative_path)}") - store_fields.extend( - ( - f'idempotency_key="{target_arguments["idempotency_key"]}"', - 'preflight_token=""', - ) - ) + store_fields.append(f'idempotency_key="{target_arguments["idempotency_key"]}"') return "\n".join( ( - ( - " - mcp__daem0nmcp__memory_preflight(" - f'workspace_id="{workspace_id}", target_tool="memory_store", ' - f"target_arguments={encoded})" - ), - " mcp__daem0nmcp__memory_store(" + ", ".join(store_fields) + ")", + " - mcp__daem0nmcp__memory_store(" + ", ".join(store_fields) + ")", + " (if COUNSEL_REQUIRED: review error.counsel, then retry error.remedy)", ) ) @@ -372,8 +362,10 @@ async def analyse_and_remember( message=( "Daem0n: this task looks finished and no outcome was recorded. " "The hook wrote nothing. To keep a durable decision, ask Claude to " - "run memory_preflight and then memory_store for it. Once the result " - "is known, ask Claude to call mcp__daem0nmcp__memory_record_outcome(" + "call memory_store directly with a stable idempotency_key. " + "If COUNSEL_REQUIRED: review error.counsel, then retry exactly " + "error.remedy. Once the result is known, ask Claude to call " + "mcp__daem0nmcp__memory_record_outcome(" f'workspace_id="{workspace_id}", record_id="", ' 'outcome_text="", worked=true, ' f'idempotency_key="{outcome_key}").' diff --git a/daem0nmcp/code_bindings.py b/daem0nmcp/code_bindings.py new file mode 100644 index 0000000..d0accf7 --- /dev/null +++ b/daem0nmcp/code_bindings.py @@ -0,0 +1,475 @@ +"""Bounded repository fingerprints for server-managed memory code bindings.""" + +from __future__ import annotations + +import hashlib +import re +from collections import OrderedDict +from collections.abc import Iterable, Iterator, Mapping, Sequence +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from pathlib import Path, PureWindowsPath +from stat import S_ISDIR +from threading import Lock +from typing import Literal, Protocol, runtime_checkable + +from .workspace import IndexPathError, Workspace, resolve_index_file + +_MAX_BINDING_FILE_BYTES = 5 * 1024 * 1024 +_MAX_BINDING_BATCH_BYTES = 32 * 1024 * 1024 +_MAX_CACHED_SOURCE_BYTES = 32 * 1024 * 1024 +_FINGERPRINT = re.compile(r"[0-9a-f]{64}\Z") +_HEAD = re.compile(r"[0-9a-f]{40,64}\Z") + + +class CodeBindingError(ValueError): + """A binding cannot be captured or its stored representation is invalid.""" + + +class _UnverifiableBindingError(CodeBindingError): + pass + + +class CodeBindingUnavailableError(_UnverifiableBindingError): + """The server cannot currently verify a code binding.""" + + +class CodeBindingReferenceError(_UnverifiableBindingError): + """The requested code binding reference is unsupported.""" + + +@runtime_checkable +class _BindingCodeIndexer(Protocol): + @property + def available(self) -> bool: ... + + def get_supported_extensions(self) -> Iterable[str]: ... + + def index_source_strict( + self, path: Path, root: Path, source: bytes + ) -> Iterable[Mapping[str, object]]: ... + + +@dataclass(frozen=True, slots=True) +class CodeBinding: + relative_file_path: str + qualified_name: str | None + fingerprint: str + head_commit: str | None + + def to_json(self) -> dict[str, str | None]: + return { + "relative_file_path": self.relative_file_path, + "qualified_name": self.qualified_name, + "fingerprint": self.fingerprint, + "head_commit": self.head_commit, + } + + +@dataclass(frozen=True, slots=True) +class BindingEvaluation: + applicability: Literal["current", "needs_revalidation", "unverifiable"] + changed: tuple[str, ...] + + +def _validate_ref(relative: object, name: object) -> tuple[str, str | None]: + if ( + not isinstance(relative, str) + or not relative + or "\x00" in relative + or Path(relative).is_absolute() + or PureWindowsPath(relative).drive + or PureWindowsPath(relative).root + or ".." in relative.replace("\\", "/").split("/") + or (name is not None and (not isinstance(name, str) or not name.strip())) + ): + raise CodeBindingError("code binding reference is invalid") + return relative, name + + +def _bindings_from_context(context: Mapping[str, object]) -> tuple[CodeBinding, ...]: + if not isinstance(context, Mapping): + raise CodeBindingError("code bindings context is invalid") + if "code_bindings" not in context: + return () + raw = context["code_bindings"] + if not isinstance(raw, list) or not 1 <= len(raw) <= 16: + raise CodeBindingError("code bindings context is invalid") + bindings = [] + for value in raw: + if not isinstance(value, Mapping) or set(value) != { + "relative_file_path", + "qualified_name", + "fingerprint", + "head_commit", + }: + raise CodeBindingError("code bindings context is invalid") + relative, name = _validate_ref( + value["relative_file_path"], value["qualified_name"] + ) + fingerprint = value["fingerprint"] + head = value["head_commit"] + if ( + not isinstance(fingerprint, str) + or _FINGERPRINT.fullmatch(fingerprint) is None + or ( + head is not None + and (not isinstance(head, str) or _HEAD.fullmatch(head) is None) + ) + ): + raise CodeBindingError("code bindings context is invalid") + bindings.append(CodeBinding(relative, name, fingerprint, head)) + return tuple(bindings) + + +def binding_refs_from_context( + context: Mapping[str, object], +) -> tuple[tuple[str, str | None], ...]: + return tuple( + (binding.relative_file_path, binding.qualified_name) + for binding in _bindings_from_context(context) + ) + + +@dataclass(frozen=True, slots=True) +class _SymbolSpan: + qualified_name: object + name: object + navigation_start: object + start: object + end: object + + +@dataclass(slots=True) +class _FileSnapshot: + source: bytes + fingerprints: dict[tuple[Path, str | None], str] = field(default_factory=dict) + symbols: dict[Path, tuple[_SymbolSpan, ...]] = field(default_factory=dict) + line_offsets: tuple[int, ...] | None = None + + +@dataclass(slots=True) +class _ReadBatch: + remaining_bytes: int + sources: dict[Path, bytes] = field(default_factory=dict) + + +def _file_error(message: str, error: BaseException) -> CodeBindingError: + cause: BaseException | None = error + while cause is not None: + if isinstance( + cause, (FileNotFoundError, NotADirectoryError, IsADirectoryError) + ): + return CodeBindingError(message) + if isinstance(cause, OSError): + return CodeBindingUnavailableError(message) + cause = cause.__cause__ + return CodeBindingError(message) + + +def _read_source(path: Path, batch: _ReadBatch | None = None) -> bytes: + remaining = batch.remaining_bytes if batch is not None else None + if remaining == 0: + raise CodeBindingUnavailableError("binding read budget is exhausted") + limit = ( + min(_MAX_BINDING_FILE_BYTES, remaining) + if remaining is not None + else _MAX_BINDING_FILE_BYTES + ) + try: + with path.open("rb") as source_file: + source = source_file.read(limit) + overflow = source_file.read(1) if len(source) == limit else b"" + except OSError as exc: + error = _file_error("bound file cannot be read", exc) + if isinstance(error, CodeBindingUnavailableError): + try: + mode = path.stat().st_mode + except OSError: + pass + else: + if S_ISDIR(mode): + error = CodeBindingError("bound file is a directory") + raise error from exc + raw_bytes = len(source) + len(overflow) + if batch is not None: + batch.remaining_bytes = max(0, batch.remaining_bytes - raw_bytes) + if raw_bytes > _MAX_BINDING_FILE_BYTES: + raise CodeBindingReferenceError("bound file exceeds size limit") + if remaining is not None and raw_bytes > remaining: + raise CodeBindingUnavailableError("binding read budget is exhausted") + return source.replace(b"\r\n", b"\n") + + +def _line_offsets(source: bytes) -> tuple[int, ...]: + offsets = [0] + start = 0 + while (end := source.find(b"\n", start)) != -1: + start = end + 1 + offsets.append(start) + return tuple(offsets) + + +def _fingerprint( + path: Path, + root: Path, + source: bytes, + name: str | None, + *, + snapshot: _FileSnapshot | None = None, +) -> str: + if name is None: + return hashlib.sha256(source).hexdigest() + from .api.v7.discovery_operations import default_code_indexer_factory + + try: + producer = default_code_indexer_factory() + if not isinstance(producer, _BindingCodeIndexer) or not producer.available: + raise CodeBindingUnavailableError("code parser is unavailable") + supported = producer.get_supported_extensions() + if path.suffix.lower() not in supported: + raise CodeBindingReferenceError("bound symbol extension is unsupported") + entities = snapshot.symbols.get(root) if snapshot is not None else None + if entities is None: + spans = [] + for entity in producer.index_source_strict(path, root, source): + if not isinstance(entity, Mapping): + raise CodeBindingUnavailableError( + "code parser returned invalid entities" + ) + navigation_start = entity.get("line_start") + spans.append( + _SymbolSpan( + entity.get("qualified_name"), + entity.get("name"), + navigation_start, + entity.get("binding_line_start", navigation_start), + entity.get("line_end"), + ) + ) + entities = tuple(spans) + offsets = snapshot.line_offsets if snapshot is not None else None + if offsets is None: + offsets = _line_offsets(source) + exact = [] + aliases = [] + for entity in entities: + if entity.qualified_name == name: + exact.append(entity) + elif entity.name == name: + aliases.append(entity) + selected = exact or aliases + if not exact and aliases: + identities = set() + for entity in aliases: + identity = entity.qualified_name + if not isinstance(identity, str) or not identity.strip(): + raise CodeBindingUnavailableError( + "code parser returned no usable symbol identity" + ) + identities.add(identity) + if len(identities) != 1: + raise CodeBindingReferenceError("bound symbol reference is ambiguous") + chunks = [] + for entity in selected: + navigation_start, end = entity.navigation_start, entity.end + start = entity.start + if ( + not isinstance(start, int) + or isinstance(start, bool) + or not isinstance(navigation_start, int) + or isinstance(navigation_start, bool) + or not isinstance(end, int) + or isinstance(end, bool) + or not 1 <= start <= navigation_start <= end <= len(offsets) + ): + raise CodeBindingUnavailableError( + "code parser returned invalid line bounds" + ) + stop = offsets[end] - 1 if end < len(offsets) else len(source) + chunks.append(source[offsets[start - 1] : stop]) + except _UnverifiableBindingError: + raise + except Exception as exc: + raise CodeBindingUnavailableError("code parser failed") from exc + if not chunks: + raise CodeBindingError("bound symbol is missing") + fingerprint = hashlib.sha256(b"\n".join(chunks)).hexdigest() + if snapshot is not None: + snapshot.symbols[root] = entities + snapshot.line_offsets = offsets + return fingerprint + + +def capture_bindings( + workspace: Workspace, refs: Sequence[tuple[str, str | None]] +) -> tuple[CodeBinding, ...]: + """Capture a fresh first-read source view per resolved file in input order. + + References are validated and resolved individually, even for shared files. + Sources and parsed spans live only for this call; different files and HEAD + are not an atomic repository snapshot. + """ + from .api.v7.resource_repository import read_git_output_sync + + if len(refs) > 16: + raise CodeBindingError("too many code bindings") + fingerprints = [] + snapshots: dict[Path, _FileSnapshot] = {} + for ref in refs: + if not isinstance(ref, (tuple, list)) or len(ref) != 2: + raise CodeBindingError("code binding reference is invalid") + relative, name = _validate_ref(*ref) + try: + path = resolve_index_file(workspace.root, workspace.root / relative) + except (IndexPathError, OSError) as exc: + raise _file_error( + "bound file is missing or outside the workspace", exc + ) from exc + snapshot = snapshots.get(path) + if snapshot is None: + snapshot = _FileSnapshot(_read_source(path)) + snapshots[path] = snapshot + fingerprint = _fingerprint( + path, workspace.root, snapshot.source, name, snapshot=snapshot + ) + fingerprints.append((relative, name, fingerprint)) + head_raw = read_git_output_sync(workspace, ["rev-parse", "HEAD"]) + head = head_raw.decode("ascii", errors="replace").strip() if head_raw else None + if head is not None and _HEAD.fullmatch(head) is None: + head = None + return tuple( + CodeBinding(relative, name, fingerprint, head) + for relative, name, fingerprint in fingerprints + ) + + +class BindingEvaluator: + """Reuse parsed snapshots capped by entries and normalized source bytes. + + The source-byte cap is not a total heap cap for parsers or fingerprints. + """ + + def __init__(self) -> None: + self._lock = Lock() + self._cache: OrderedDict[Path, _FileSnapshot] = OrderedDict() + self._cached_source_bytes = 0 + self._batch_context: ContextVar[_ReadBatch | None] = ContextVar( + "binding_read_batch", default=None + ) + + @contextmanager + def read_batch( + self, *, budget_bytes: int = _MAX_BINDING_BATCH_BYTES + ) -> Iterator[None]: + """Memoize each resolved source within this context, not atomically. + + Every evaluation still resolves and stats the file before using its + memoized bytes. Later content edits become visible in the next batch or + an unbatched evaluation; this is not an atomic filesystem snapshot. + The internal budget counts raw bytes before newline normalization, + including failed size/budget probes, but not failed OSError reads. + Reads deliver at most the budget plus one overflow-probe byte in total; + memo hits are free and stat-based refusals preserve the remaining budget. + """ + if ( + not isinstance(budget_bytes, int) + or isinstance(budget_bytes, bool) + or budget_bytes < 0 + ): + raise ValueError("binding read budget must be a nonnegative integer") + if self._batch_context.get() is not None: + raise RuntimeError("binding read batch is already active") + token = self._batch_context.set(_ReadBatch(budget_bytes)) + try: + yield + finally: + self._batch_context.reset(token) + + def _current_fingerprint(self, root: Path, binding: CodeBinding) -> str: + try: + path = resolve_index_file(root, root / binding.relative_file_path) + stat = path.stat() + except (IndexPathError, OSError) as exc: + raise _file_error("bound file is missing or inaccessible", exc) from exc + if S_ISDIR(stat.st_mode): + raise CodeBindingError("bound file is a directory") + if stat.st_size > _MAX_BINDING_FILE_BYTES: + raise CodeBindingReferenceError("bound file exceeds size limit") + batch = self._batch_context.get() + source = batch.sources.get(path) if batch is not None else None + if source is None: + if batch is not None and stat.st_size > batch.remaining_bytes: + raise CodeBindingUnavailableError("binding read budget is exhausted") + source = _read_source(path, batch) + if batch is not None: + batch.sources[path] = source + with self._lock: + snapshot = self._cache.get(path) + if snapshot is None or snapshot.source != source: + if snapshot is not None: + self._cached_source_bytes -= len(snapshot.source) + snapshot = _FileSnapshot(source) + self._cache[path] = snapshot + self._cached_source_bytes += len(source) + if batch is not None: + batch.sources[path] = snapshot.source + self._cache.move_to_end(path) + while ( + len(self._cache) > 256 + or self._cached_source_bytes > _MAX_CACHED_SOURCE_BYTES + ): + _, evicted = self._cache.popitem(last=False) + self._cached_source_bytes -= len(evicted.source) + key = (root, binding.qualified_name) + if binding.qualified_name is not None and key in snapshot.fingerprints: + from .api.v7.discovery_operations import default_code_indexer_factory + + try: + if not getattr(default_code_indexer_factory(), "available", False): + raise CodeBindingUnavailableError("code parser is unavailable") + except _UnverifiableBindingError: + raise + except Exception as exc: + raise CodeBindingUnavailableError("code parser failed") from exc + if key not in snapshot.fingerprints: + snapshot.fingerprints[key] = _fingerprint( + path, + root, + snapshot.source, + binding.qualified_name, + snapshot=snapshot, + ) + return snapshot.fingerprints[key] + + def evaluate( + self, root: Path, context: Mapping[str, object] + ) -> BindingEvaluation | None: + bindings = _bindings_from_context(context) + if not bindings: + return None + changed = [] + unverifiable = False + for binding in bindings: + label = binding.relative_file_path + if binding.qualified_name is not None: + label += "::" + binding.qualified_name + try: + fingerprint = self._current_fingerprint(root, binding) + except _UnverifiableBindingError: + unverifiable = True + continue + except CodeBindingError: + changed.append(label) + continue + if fingerprint != binding.fingerprint: + changed.append(label) + applicability: Literal["current", "needs_revalidation", "unverifiable"] + if changed: + applicability = "needs_revalidation" + elif unverifiable: + applicability = "unverifiable" + else: + applicability = "current" + return BindingEvaluation(applicability, tuple(dict.fromkeys(changed))) diff --git a/daem0nmcp/code_indexer.py b/daem0nmcp/code_indexer.py index 5ae5e73..5707141 100644 --- a/daem0nmcp/code_indexer.py +++ b/daem0nmcp/code_indexer.py @@ -432,11 +432,29 @@ def _extract_entities( def_node, source, lang, file_path ), "line_start": def_node.start_point[0] + 1, # 1-indexed + "binding_line_start": self._binding_line_start(def_node, source), "line_end": def_node.end_point[0] + 1, "signature": signature, "docstring": docstring, } + def _binding_line_start(self, node, source: bytes) -> int: + parent = node.parent + if parent is not None and parent.type == "decorated_definition": + return parent.start_point[0] + 1 + + start = node.start_point[0] + 1 + current = node + previous = current.prev_named_sibling + while previous is not None and previous.type in {"decorator", "comment"}: + if source[previous.end_byte : current.start_byte].strip(): + break + if previous.type == "decorator": + start = previous.start_point[0] + 1 + current = previous + previous = current.prev_named_sibling + return start + def _is_descendant(self, ancestor, node) -> bool: """Check if node is a descendant of ancestor.""" current = node @@ -565,6 +583,7 @@ def _walk_tree_fallback( node, source, lang, file_path ), "line_start": node.start_point[0] + 1, + "binding_line_start": self._binding_line_start(node, source), "line_end": node.end_point[0] + 1, "signature": self._extract_signature(node, source), "docstring": None, diff --git a/daem0nmcp/config.py b/daem0nmcp/config.py index 4fa7632..e1f941f 100644 --- a/daem0nmcp/config.py +++ b/daem0nmcp/config.py @@ -10,6 +10,7 @@ import shutil from collections.abc import Mapping from pathlib import Path +from typing import Literal from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -38,6 +39,8 @@ class Settings(BaseSettings): # Server log_level: str = "INFO" ui_rendering_enabled: bool = True + tool_surface: Literal["core", "full"] = "core" + covenant_mode: Literal["guided", "strict"] = "guided" sync_timeout_seconds: float = Field( default=15.0, ge=1.0, le=60.0, allow_inf_nan=False ) @@ -112,6 +115,14 @@ def validate_sync_deadline(cls, value): retrieval_graph_max_branching: int = Field(default=50, ge=1, le=100) retrieval_rerank_enabled: bool = False retrieval_rerank_candidate_limit: int = Field(default=25, ge=1, le=1000) + retrieval_reranker: Literal["embedding", "late_interaction"] = "embedding" + retrieval_late_interaction_model: str = "colbert-ir/colbertv2.0" + retrieval_utility_mode: Literal["off", "shadow", "apply"] = "shadow" + retrieval_utility_weight: float = Field(default=0.1, ge=0.0, le=1.0) + retrieval_utility_credit: Literal["single_step", "trace"] = "trace" + retrieval_utility_candidate_limit: int = Field(default=25, ge=1, le=200) + memory_validity_mode: Literal["off", "shadow", "apply"] = "shadow" + retrieval_retention_mode: Literal["off", "shadow", "apply"] = "shadow" # File Watcher (Phase 1: Proactive Layer) watcher_enabled: bool = False # Enable file watcher daemon diff --git a/daem0nmcp/database.py b/daem0nmcp/database.py index e325b34..5ad0b59 100644 --- a/daem0nmcp/database.py +++ b/daem0nmcp/database.py @@ -462,6 +462,8 @@ def _validate_database(self, *, format_version: int) -> None: "memory_events", "memory_records", "memory_fact_versions", + "memory_provenance_edges", + "memory_outcome_signals", "memory_relationship_versions", "projection_manifests", "enrichment_decisions", diff --git a/daem0nmcp/event_store.py b/daem0nmcp/event_store.py index e7328b6..acd11a1 100644 --- a/daem0nmcp/event_store.py +++ b/daem0nmcp/event_store.py @@ -799,6 +799,163 @@ def _project_memory( """, values, ) + if command.event_type in {"memory.created", "memory.outcome_recorded"}: + self._project_provenance(command, payload, event_id) + + @staticmethod + def _is_source_provenance( + command: EventCommand, payload: dict[str, Any], provenance: dict[str, Any] + ) -> bool: + if command.event_type != "memory.created": + return False + if ( + command.actor_type == "import" + and command.actor_id == "workspace-consolidation" + and set(provenance) + == { + "consolidation_run_id", + "source_workspace_id", + "source_record_id", + "source_event_id", + "source_state_hash", + } + and all(isinstance(value, str) for value in provenance.values()) + ): + return ( + provenance["consolidation_run_id"] == command.correlation_id + and re.fullmatch(r"con_[0-9a-f]{64}", command.correlation_id or "") + is not None + and re.fullmatch(r"ws_[0-9a-f]{24}", provenance["source_workspace_id"]) + is not None + and re.fullmatch(r"mem_[0-9a-f]{64}", provenance["source_record_id"]) + is not None + and re.fullmatch(r"evt_[0-9a-f]{64}", provenance["source_event_id"]) + is not None + and _SHA256_RE.fullmatch(provenance["source_state_hash"]) is not None + ) + if ( + command.actor_type == "client" + and payload.get("semantic_namespace") == "document-ingest-url" + and {"url", "content_hash"}.issubset(provenance) + and set(provenance) + <= {"url", "content_hash", "content_type", "topic", "chunk_size"} + ): + return ( + isinstance(provenance["url"], str) + and provenance["url"].startswith("https://") + and isinstance(provenance["content_hash"], str) + and _SHA256_RE.fullmatch(provenance["content_hash"]) is not None + and all( + name not in provenance or isinstance(provenance[name], str) + for name in ("content_type", "topic") + ) + and ( + "chunk_size" not in provenance + or ( + _plain_int(provenance["chunk_size"]) + and 256 <= provenance["chunk_size"] <= 16_000 + ) + ) + ) + return False + + def _project_provenance( + self, + command: EventCommand, + payload: dict[str, Any], + event_id: str, + ) -> None: + record = payload.get("record") + if not isinstance(record, dict): + raise ValueError("memory event requires a supported record object") + informed_by: list[str] = [] + if "provenance" in payload: + provenance = payload["provenance"] + if not isinstance(provenance, dict): + raise ValueError("memory provenance is invalid") + if not self._is_source_provenance(command, payload, provenance): + if set(provenance) != {"informed_by"}: + raise ValueError("memory provenance is invalid") + sources = provenance["informed_by"] + if ( + not isinstance(sources, list) + or not 1 <= len(sources) <= 32 + or any( + not isinstance(source, str) + or re.fullmatch(r"mem_[0-9a-f]{64}", source) is None + or source == command.stream_id + for source in sources + ) + or len(set(sources)) != len(sources) + ): + raise ValueError("memory provenance is invalid") + informed_by = sources + + verification = payload.get("verification") + if "verification" in payload: + if ( + not isinstance(verification, dict) + or not {"kind", "command", "exit_code"}.issuperset(verification) + or not isinstance(verification.get("kind"), str) + or verification.get("kind") + not in {"test", "command", "review", "self_report"} + ): + raise ValueError("memory verification is invalid") + evidence_command = verification.get("command") + exit_code = verification.get("exit_code") + if ( + (evidence_command is not None and not isinstance(evidence_command, str)) + or ( + exit_code is not None + and ( + not _plain_int(exit_code) + or not -2_147_483_648 <= exit_code <= 2_147_483_647 + ) + ) + or ( + verification["kind"] in {"review", "self_report"} + and (evidence_command is not None or exit_code is not None) + ) + ): + raise ValueError("memory verification is invalid") + + edge_kind = "store" if command.event_type == "memory.created" else "outcome" + for source in informed_by: + self.connection.execute( + """ + INSERT INTO memory_provenance_edges ( + workspace_id, event_id, record_id, informed_by_record_id, + edge_kind, recorded_at_us + ) VALUES (?,?,?,?,?,?) + """, + ( + command.workspace_id, + event_id, + command.stream_id, + source, + edge_kind, + command.recorded_at_us, + ), + ) + worked = record.get("worked") + if command.event_type == "memory.outcome_recorded" and isinstance(worked, bool): + from .retrieval.utility import verification_weight + + self.connection.execute( + """ + INSERT INTO memory_outcome_signals ( + workspace_id, event_id, record_id, reward, weight, recorded_at_us + ) VALUES (?,?,?,?,?,?) + """, + ( + command.workspace_id, + event_id, + command.stream_id, + 1.0 if worked else 0.0, + verification_weight(worked, verification), + command.recorded_at_us, + ), + ) def _project_fact(self, command, payload, event_id, stream_version) -> None: fact = payload.get("fact") diff --git a/daem0nmcp/migrations/schema.py b/daem0nmcp/migrations/schema.py index 115efd7..3c8d68b 100644 --- a/daem0nmcp/migrations/schema.py +++ b/daem0nmcp/migrations/schema.py @@ -10,6 +10,7 @@ import os import sqlite3 import time +from collections.abc import Callable from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -2598,6 +2599,61 @@ """, ], ), + ( + 33, + "Persist memory provenance and outcome evidence", + [ + """ + CREATE TABLE IF NOT EXISTS memory_provenance_edges ( + workspace_id TEXT NOT NULL + CONSTRAINT ck_provenance_workspace CHECK( + substr(workspace_id,1,3)='ws_' + ), + event_id TEXT NOT NULL REFERENCES memory_events(event_id) + ON DELETE RESTRICT, + record_id TEXT NOT NULL REFERENCES memory_records(record_id) + ON DELETE RESTRICT, + informed_by_record_id TEXT NOT NULL + CONSTRAINT ck_provenance_source CHECK( + length(informed_by_record_id)=68 + AND substr(informed_by_record_id,1,4)='mem_' + ), + edge_kind TEXT NOT NULL + CONSTRAINT ck_provenance_kind CHECK( + edge_kind IN ('store','outcome') + ), + recorded_at_us INTEGER NOT NULL, + CONSTRAINT pk_memory_provenance PRIMARY KEY( + workspace_id,event_id,informed_by_record_id + ) + ) WITHOUT ROWID + """, + "CREATE INDEX IF NOT EXISTS idx_provenance_source " + "ON memory_provenance_edges(workspace_id,informed_by_record_id," + "edge_kind,recorded_at_us)", + "CREATE INDEX IF NOT EXISTS idx_provenance_record " + "ON memory_provenance_edges(workspace_id,record_id)", + """ + CREATE TABLE IF NOT EXISTS memory_outcome_signals ( + workspace_id TEXT NOT NULL + CONSTRAINT ck_outcome_signal_workspace CHECK( + substr(workspace_id,1,3)='ws_' + ), + event_id TEXT NOT NULL PRIMARY KEY REFERENCES memory_events(event_id) + ON DELETE RESTRICT, + record_id TEXT NOT NULL REFERENCES memory_records(record_id) + ON DELETE RESTRICT, + reward REAL NOT NULL + CONSTRAINT ck_outcome_signal_reward CHECK(reward IN (0.0,1.0)), + weight REAL NOT NULL + CONSTRAINT ck_outcome_signal_weight CHECK(weight > 0 AND weight <= 1), + recorded_at_us INTEGER NOT NULL + ) WITHOUT ROWID + """, + "CREATE INDEX IF NOT EXISTS idx_outcome_signals_record " + "ON memory_outcome_signals(workspace_id,record_id,recorded_at_us)", + ], + ), ] if MIGRATIONS[-1][0] != CURRENT_SCHEMA_VERSION: # pragma: no cover - import guard @@ -3206,11 +3262,40 @@ def _has_retained_public_rows(connection: sqlite3.Connection) -> bool: return False +def _backfill_memory_learning_evidence(connection: sqlite3.Connection) -> None: + from daem0nmcp.event_store import EventCommand, EventStore, parse_canonical_json + + store = EventStore(connection, assume_transaction=True) + for row in connection.execute( + "SELECT event_id,workspace_id,stream_id,event_type,occurred_at_us," + "recorded_at_us,actor_type,payload_json,actor_id,correlation_id " + "FROM memory_events " + "WHERE stream_kind='memory' " + "AND event_type IN ('memory.created','memory.outcome_recorded') " + "ORDER BY recorded_at_us,event_id" + ): + payload = parse_canonical_json(row[7]) + command = EventCommand( + workspace_id=row[1], + stream_id=row[2], + stream_kind="memory", + event_type=row[3], + occurred_at_us=row[4], + recorded_at_us=row[5], + actor_type=row[6], + actor_id=row[8], + correlation_id=row[9], + payload=payload, + ) + store._project_provenance(command, payload, row[0]) + + def run_migrations( db_path: str, *, workspace_id: str | None = None, maximum_version: int | None = None, + before_memory_learning_backfill: Callable[[sqlite3.Connection], None] | None = None, ) -> tuple[int, list[str]]: """ Run all pending migrations on the database. @@ -3219,6 +3304,8 @@ def run_migrations( db_path: Path to the SQLite database. maximum_version: Optional inclusive migration ceiling for a retained format-specific maintenance path. + before_memory_learning_backfill: Optional transaction-local preparation + of learning-evidence parents before the strict v33 backfill. Returns: Tuple of (migrations_run, list of descriptions) @@ -3293,6 +3380,10 @@ def run_migrations( backfill_retained_public_object_ids(conn, workspace_id) if version >= 21 and workspace_id is not None: backfill_retained_governance(conn, workspace_id) + if version == 33: + if before_memory_learning_backfill is not None: + before_memory_learning_backfill(conn) + _backfill_memory_learning_evidence(conn) # Record migration conn.execute( diff --git a/daem0nmcp/migrations/v7.py b/daem0nmcp/migrations/v7.py index 3835716..503402d 100644 --- a/daem0nmcp/migrations/v7.py +++ b/daem0nmcp/migrations/v7.py @@ -110,6 +110,8 @@ "memory_events", "memory_records", "memory_fact_versions", + "memory_provenance_edges", + "memory_outcome_signals", "memory_relationship_versions", "projection_manifests", "enrichment_decisions", diff --git a/daem0nmcp/migrations/v7_schema_upgrade.py b/daem0nmcp/migrations/v7_schema_upgrade.py index fa133a0..e25114c 100644 --- a/daem0nmcp/migrations/v7_schema_upgrade.py +++ b/daem0nmcp/migrations/v7_schema_upgrade.py @@ -693,6 +693,64 @@ def _verify_published_candidate(candidate: Path, workspace_id: str) -> dict[str, return result +def _restore_missing_learning_parents( + connection: sqlite3.Connection, workspace_id: str, scratch_root: Path +) -> int: + from ..verification_v7 import _all_ok, _initialize_replay, _replay_authority + + missing = connection.execute( + "SELECT DISTINCT event.workspace_id,event.stream_id " + "FROM memory_events event LEFT JOIN memory_records record " + "ON record.record_id=event.stream_id " + "AND record.workspace_id=event.workspace_id " + "WHERE event.stream_kind='memory' " + "AND event.event_type IN ('memory.created','memory.outcome_recorded') " + "AND record.record_id IS NULL" + ).fetchall() + if not missing: + return 0 + with tempfile.TemporaryDirectory( + prefix="daem0nmcp-learning-parents-", dir=scratch_root + ) as raw: + replay = _initialize_replay(Path(raw) / "replay.db", workspace_id) + try: + row_factory = connection.row_factory + connection.row_factory = sqlite3.Row + try: + report = _replay_authority(connection, replay) + finally: + connection.row_factory = row_factory + if not _all_ok(report["checks"]): + raise MigrationV7Error( + "SCHEMA_UPGRADE_AUTHORITY_INVALID", + "canonical authority could not restore learning-evidence parents", + ) + columns = [ + str(row[1]) + for row in connection.execute('PRAGMA table_info("memory_records")') + ] + quoted = ",".join(f'"{column}"' for column in columns) + placeholders = ",".join("?" for _ in columns) + for record_workspace_id, record_id in missing: + row = replay.execute( + f"SELECT {quoted} FROM memory_records " + "WHERE workspace_id=? AND record_id=?", + (record_workspace_id, record_id), + ).fetchone() + if row is None: + raise MigrationV7Error( + "SCHEMA_UPGRADE_AUTHORITY_INVALID", + "learning-evidence parent is absent from canonical replay", + ) + connection.execute( + f"INSERT INTO memory_records ({quoted}) VALUES ({placeholders})", + tuple(row), + ) + finally: + replay.close() + return len(missing) + + def _verify_and_repair_candidate(candidate: Path, workspace_id: str) -> dict[str, Any]: from ..verification_v7 import ( _refresh_manifests, @@ -959,8 +1017,21 @@ def _apply( _sqlite_backup(snapshot, candidate_partial) _require_regular(candidate_partial, run_dir) self._fault("schema_upgrade_after_candidate_copy", migration_run_id=run_id) + + def restore_missing_learning_parents(connection: sqlite3.Connection) -> None: + restored = _restore_missing_learning_parents( + connection, workspace_id, run_dir + ) + if restored: + self._fault( + "schema_upgrade_after_learning_parent_restore", + migration_run_id=run_id, + ) + applied_count, applied = run_migrations( - str(candidate_partial), workspace_id=workspace_id + str(candidate_partial), + workspace_id=workspace_id, + before_memory_learning_backfill=restore_missing_learning_parents, ) self._checkpoint(candidate_partial) candidate_inventory = inventory_database(candidate_partial) diff --git a/daem0nmcp/models.py b/daem0nmcp/models.py index a7cc4e9..c6c594f 100644 --- a/daem0nmcp/models.py +++ b/daem0nmcp/models.py @@ -1395,6 +1395,84 @@ class RecordOutcomeView(Base): ) +class MemoryProvenanceEdge(Base): + __tablename__ = "memory_provenance_edges" + + workspace_id = Column(String, nullable=False) + event_id = Column( + String(68), + ForeignKey("memory_events.event_id", ondelete="RESTRICT"), + nullable=False, + ) + record_id = Column( + String(68), + ForeignKey("memory_records.record_id", ondelete="RESTRICT"), + nullable=False, + ) + informed_by_record_id = Column(String(68), nullable=False) + edge_kind = Column(String, nullable=False) + recorded_at_us = Column(Integer, nullable=False) + + __table_args__ = ( + PrimaryKeyConstraint( + "workspace_id", + "event_id", + "informed_by_record_id", + name="pk_memory_provenance", + ), + CheckConstraint( + "substr(workspace_id,1,3)='ws_'", name="ck_provenance_workspace" + ), + CheckConstraint( + "length(informed_by_record_id)=68 " + "AND substr(informed_by_record_id,1,4)='mem_'", + name="ck_provenance_source", + ), + CheckConstraint("edge_kind IN ('store','outcome')", name="ck_provenance_kind"), + Index( + "idx_provenance_source", + "workspace_id", + "informed_by_record_id", + "edge_kind", + "recorded_at_us", + ), + Index("idx_provenance_record", "workspace_id", "record_id"), + {"sqlite_with_rowid": False}, + ) + + +class MemoryOutcomeSignal(Base): + __tablename__ = "memory_outcome_signals" + + workspace_id = Column(String, nullable=False) + event_id = Column( + String(68), + ForeignKey("memory_events.event_id", ondelete="RESTRICT"), + primary_key=True, + nullable=False, + ) + record_id = Column( + String(68), + ForeignKey("memory_records.record_id", ondelete="RESTRICT"), + nullable=False, + ) + reward = Column(Float, nullable=False) + weight = Column(Float, nullable=False) + recorded_at_us = Column(Integer, nullable=False) + + __table_args__ = ( + CheckConstraint( + "substr(workspace_id,1,3)='ws_'", name="ck_outcome_signal_workspace" + ), + CheckConstraint("reward IN (0.0,1.0)", name="ck_outcome_signal_reward"), + CheckConstraint("weight > 0 AND weight <= 1", name="ck_outcome_signal_weight"), + Index( + "idx_outcome_signals_record", "workspace_id", "record_id", "recorded_at_us" + ), + {"sqlite_with_rowid": False}, + ) + + class DenseProjectionRef(Base): __tablename__ = "dense_projection_refs" diff --git a/daem0nmcp/opencode_assets/commands/commune.md b/daem0nmcp/opencode_assets/commands/commune.md index 27a2781..eca9810 100644 --- a/daem0nmcp/opencode_assets/commands/commune.md +++ b/daem0nmcp/opencode_assets/commands/commune.md @@ -2,8 +2,10 @@ description: Start the Daem0n v7 session for a registered workspace --- -Call `daem0nmcp_session_brief(workspace_id="$ARGUMENTS")` immediately. The -argument must be the configured opaque `workspace_id`, not a path. +The first Daem0n call establishes the scoped session and briefs automatically +in `meta.covenant.auto_brief`. For the full brief, call +`daem0nmcp_session_brief(workspace_id="$ARGUMENTS")`. +The argument must be the configured opaque `workspace_id`, not a path. After receiving the briefing results: 1. Report the session status, active warnings, and recent activity summary diff --git a/daem0nmcp/opencode_assets/commands/counsel.md b/daem0nmcp/opencode_assets/commands/counsel.md index d8cd22f..697330a 100644 --- a/daem0nmcp/opencode_assets/commands/counsel.md +++ b/daem0nmcp/opencode_assets/commands/counsel.md @@ -1,9 +1,9 @@ --- -description: Request an exact Daem0n v7 preflight capability +description: Plan a protected change in advance with Daem0n v7 counsel --- -Parse `$ARGUMENTS` as the intended protected v7 tool and its arguments, then -call: +`memory_preflight` is optional advance planning in guided mode. Parse +`$ARGUMENTS` as the intended protected v7 tool and its arguments, then call: ```text daem0nmcp_memory_preflight( diff --git a/daem0nmcp/opencode_assets/commands/inscribe.md b/daem0nmcp/opencode_assets/commands/inscribe.md index 1359bb2..a92434e 100644 --- a/daem0nmcp/opencode_assets/commands/inscribe.md +++ b/daem0nmcp/opencode_assets/commands/inscribe.md @@ -12,20 +12,22 @@ Choose `record_type` from the content: - "warning" for things to avoid - "learning" for lessons from experience -Create one stable idempotency key. Call `daem0nmcp_memory_preflight` with -`target_tool="memory_store"` and the exact target arguments, then call: +Create one stable idempotency key and call `memory_store` directly: ```text daem0nmcp_memory_store( workspace_id="", record_type="", content="$ARGUMENTS", - rationale="", - idempotency_key="", - preflight_token="" + idempotency_key="" ) ``` +If `COUNSEL_REQUIRED`, review `error.counsel` (guidance and reasons), then retry +exactly `error.remedy`, preserving the idempotency key. `memory_preflight` +remains available for planning a change in advance. The first Daem0n call +briefs automatically in `meta.covenant.auto_brief`; `session_brief` gives the full brief. + Report the returned `record_id`. After verification, use `daem0nmcp_memory_record_outcome` with that ID and a separate stable idempotency key. diff --git a/daem0nmcp/opencode_assets/commands/recall.md b/daem0nmcp/opencode_assets/commands/recall.md index e372a59..fc9ca72 100644 --- a/daem0nmcp/opencode_assets/commands/recall.md +++ b/daem0nmcp/opencode_assets/commands/recall.md @@ -4,6 +4,10 @@ description: Retrieve bounded memories from a Daem0n v7 workspace Call `daem0nmcp_memory_recall(workspace_id="", query="$ARGUMENTS", limit=10)` immediately. +The first Daem0n call briefs automatically in `meta.covenant.auto_brief`; +call `session_brief` for the full brief. For other capabilities, use +`daem0n_tools_search(query)`, then `daem0n_tool_call(workspace_id, tool, arguments)`. + Present the retrieved memories in a clear format: - Show each record's content, type, and creation time - Highlight any warnings or failed approaches diff --git a/daem0nmcp/opencode_assets/daem0n.ts b/daem0nmcp/opencode_assets/daem0n.ts index a562bf3..37b36d5 100644 --- a/daem0nmcp/opencode_assets/daem0n.ts +++ b/daem0nmcp/opencode_assets/daem0n.ts @@ -3,7 +3,7 @@ * * Mirrors the 5-hook discipline from Claude Code's hooks system: * 1. System prompt injection (covenant rules in every LLM call) - * 2. Pre-edit enforcement (preflight token required) + * 2. Pre-edit reminders (gateway file recall) * 3. Pre-bash enforcement (must_not rule checking) * 4. Post-edit suggestions (informational, never blocks) * 5. Session lifecycle events (best-effort, never blocks) @@ -21,24 +21,25 @@ import type { Plugin } from "@opencode-ai/plugin"; const COVENANT_RULES_FULL = ` ## The Daem0n v7 Covenant -This project is bound to Daem0n for persistent AI memory. When daem0nmcp tools -are available, use the exact workspace-scoped v7 tools. The core names are -session_brief, memory_preflight, memory_recall, memory_store, -memory_record_outcome, and system_health. +This project is bound to Daem0n for persistent AI memory. Use exact workspace-scoped +v7 tools: session_brief, memory_preflight, memory_recall, memory_store, +memory_record_outcome, system_health, daem0n_tools_search, and daem0n_tool_call. -### 1. SESSION START (Non-Negotiable) -IMMEDIATELY call: -daem0nmcp_session_brief(workspace_id="") +### 1. BRIEF +The first Daem0n call in a session briefs automatically; the compact brief is returned in meta.covenant.auto_brief. Call session_brief for the full brief. -Use daem0nmcp_memory_recall(workspace_id="", query="...", limit=10) -for relevant history. Before a protected operation call: -daem0nmcp_memory_preflight(workspace_id="", target_tool="", target_arguments={}) -Respect warnings, failed approaches, and must_not constraints. A preflight token -is valid only for the exact workspace, principal, session, tool, and arguments. +### 2. WRITE +Call memory_store (or any protected tool) directly. If it returns COUNSEL_REQUIRED, read error.counsel (guidance and reasons), then retry exactly error.remedy. memory_preflight remains available for planning a change in advance. +Use bounded memory_recall for relevant history. Respect warnings, failures and +must_not constraints. Tokens remain exact-argument, single-use capabilities +valid for 300 seconds, bound to workspace, principal, session, tool and arguments. +Use a stable idempotency_key for memory_store and save its record_id. -### 3. AFTER MAKING DECISIONS -Call daem0nmcp_memory_store with the same target arguments, a stable -idempotency_key, and the returned preflight_token. Save its record_id. +### 3. OTHER CAPABILITIES +Use daem0n_tools_search(query), then daem0n_tool_call(workspace_id, tool, arguments). +Defaults: DAEM0NMCP_COVENANT_MODE=guided and DAEM0NMCP_TOOL_SURFACE=core. +DAEM0NMCP_COVENANT_MODE=strict requires explicit session_brief, exact +memory_preflight, and token-bearing writes; DAEM0NMCP_TOOL_SURFACE=full lists all tools. ### 4. AFTER IMPLEMENTATION Call: daem0nmcp_memory_record_outcome(workspace_id="", record_id="", outcome_text="...", worked=true|false, idempotency_key="") @@ -51,15 +52,16 @@ at /mcp. Migration mapping: docs/v6-to-v7-tools.json. `; const COVENANT_RULES_SIMPLIFIED = ` -## Memory Protocol (Required Steps) +## Memory Protocol -This project uses Daem0n for persistent AI memory. Follow these 4 steps: - -1. START: daem0nmcp_session_brief(workspace_id="") -2. RECALL: daem0nmcp_memory_recall(workspace_id="", query="...", limit=10) -3. PREFLIGHT: daem0nmcp_memory_preflight(workspace_id="", target_tool="memory_store", target_arguments={}) -4. STORE: daem0nmcp_memory_store(workspace_id="", record_type="decision", content="...", idempotency_key="", preflight_token="") -5. OUTCOME: daem0nmcp_memory_record_outcome(workspace_id="", record_id="", outcome_text="...", worked=true|false, idempotency_key="") +The first Daem0n call in a session briefs automatically; the compact brief is returned in meta.covenant.auto_brief. Call session_brief for the full brief. +Call memory_store (or any protected tool) directly. If it returns COUNSEL_REQUIRED, read error.counsel (guidance and reasons), then retry exactly error.remedy. memory_preflight remains available for planning a change in advance. +Use daem0n_tools_search(query), then daem0n_tool_call(workspace_id, tool, arguments). +RECALL: daem0nmcp_memory_recall(workspace_id="", query="...", limit=10) +STORE: daem0nmcp_memory_store(workspace_id="", record_type="decision", content="...", idempotency_key="") +OUTCOME: daem0nmcp_memory_record_outcome(workspace_id="", record_id="", outcome_text="...", worked=true|false, idempotency_key="") +Tokens remain exact-argument, single-use, and valid for 300 seconds. +Strict ceremony: DAEM0NMCP_COVENANT_MODE=strict. Full listing: DAEM0NMCP_TOOL_SURFACE=full. Rules: - Never use paths as workspace selectors. diff --git a/daem0nmcp/retrieval/composer.py b/daem0nmcp/retrieval/composer.py index c410615..cd67ea1 100644 --- a/daem0nmcp/retrieval/composer.py +++ b/daem0nmcp/retrieval/composer.py @@ -10,8 +10,9 @@ import asyncio import math import re -from collections.abc import Iterable -from dataclasses import dataclass, replace +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, field, replace +from pathlib import PurePosixPath from typing import Literal, Protocol from ..bounded_workers import BoundedWorkerBusyError, BoundedWorkerPool @@ -22,6 +23,7 @@ ContextPackage, EvidenceItem, FusedCandidate, + _finite_nonnegative, _legacy_metadata, _opaque, ) @@ -92,6 +94,13 @@ class SelectedEvidence: outcome: str | None = None outcome_failed: bool = False procedure_steps: tuple[str, ...] = () + utility: float | None = None + code_bindings: tuple[tuple[str, str | None], ...] = () + binding_context: Mapping[str, object] = field(default_factory=dict) + applicability: Literal["current", "needs_revalidation", "unverifiable"] | None = ( + None + ) + changed_bindings: tuple[str, ...] = () def __post_init__(self) -> None: if not isinstance(self.candidate, FusedCandidate): @@ -101,6 +110,22 @@ def __post_init__(self) -> None: if not isinstance(value, str) or not value.strip(): raise ValueError(f"{field_name} must be a non-empty string") _legacy_metadata(self.rationale, self.tags, self.worked) + if self.utility is not None: + utility = _finite_nonnegative(self.utility, "utility") + if utility > 1: + raise ValueError("utility must be between 0 and 1") + object.__setattr__(self, "utility", utility) + if self.applicability not in { + None, + "current", + "needs_revalidation", + "unverifiable", + }: + raise ValueError("applicability is invalid") + if not isinstance(self.changed_bindings, tuple) or not all( + isinstance(binding, str) and binding for binding in self.changed_bindings + ): + raise ValueError("changed_bindings must contain non-empty strings") if self.status not in {"current", "superseded"}: raise ValueError("status is invalid") if (self.status == "superseded") != (self.superseded_by_version_id is not None): @@ -129,6 +154,28 @@ def priority(self) -> bool: return self.category.casefold() == "warning" or self.outcome_failed +@dataclass(frozen=True, slots=True) +class RetentionPolicy: + intent: Literal["explore", "implement", "debug", "review"] + identifiers: frozenset[str] + + +def query_identifiers(text: str) -> frozenset[str]: + identifiers: set[str] = set() + for token in re.split(r"[^\w./\\-]+", text): + token = token.replace("\\", "/") + if "/" in token and not token.endswith("/"): + rooted = token.startswith("./") + token = PurePosixPath(token).as_posix() + if rooted: + token = "./" + token + if any(character in token for character in "_./") or any( + character.isupper() for character in token[1:] + ): + identifiers.add(token) + return frozenset(identifiers) + + @dataclass(frozen=True, slots=True) class CompositionResult: """Selected evidence items and their exact rendered context.""" @@ -220,6 +267,8 @@ async def compose_async( selected: Iterable[SelectedEvidence], *, token_budget: int, + retention: RetentionPolicy | None = None, + label_applicability: bool = False, ) -> CompositionResult: """Compose without letting an optional compressor block the event loop.""" @@ -233,7 +282,12 @@ async def compose_async( try: return await asyncio.wait_for( self._worker_pool.run( - lambda: self.compose(sources, token_budget=token_budget) + lambda: self.compose( + sources, + token_budget=token_budget, + retention=retention, + label_applicability=label_applicability, + ) ), timeout=primary_timeout, ) @@ -250,6 +304,8 @@ async def compose_async( lambda: fallback.compose( sources, token_budget=token_budget, + retention=retention, + label_applicability=label_applicability, ) ), timeout=_DETERMINISTIC_COMPOSE_TIMEOUT_SECONDS, @@ -288,6 +344,8 @@ def compose( selected: Iterable[SelectedEvidence], *, token_budget: int, + retention: RetentionPolicy | None = None, + label_applicability: bool = False, ) -> CompositionResult: """Render only supplied post-policy evidence under *token_budget*.""" @@ -318,22 +376,39 @@ def compose( f"[E{index}]", source, self._full_excerpt(source), + label_applicability=label_applicability, ) for index, source in enumerate(ordered, 1) ) requested_tokens = self._count(requested_text) - priority = [source for source in ordered if source.priority] - ordinary = [source for source in ordered if not source.priority] + priority = [source for source in ordered if self._priority(source, retention)] + ordinary = [ + source for source in ordered if not self._priority(source, retention) + ] + fraction = ( + {"explore": 0.15, "implement": 0.30, "debug": 0.35, "review": 0.20}[ + retention.intent + ] + if retention is not None + else 0.15 + ) priority_reserve = min( token_budget, - max(128, math.ceil(token_budget * 0.15)), + max(128, math.ceil(token_budget * fraction)), ) prepared: list[_PreparedItem] = [] drop_reasons: list[str] = [] deferred_priority: list[SelectedEvidence] = [] for source in priority: - item = self._fit(source, prepared, priority_reserve, drop_reasons) + item = self._fit( + source, + prepared, + priority_reserve, + drop_reasons, + label_applicability, + retention, + ) if item is None: deferred_priority.append(source) else: @@ -342,13 +417,27 @@ def compose( # The reservation is a floor, not a ceiling: unused total budget may # hold additional warnings before ordinary evidence is considered. for source in deferred_priority: - item = self._fit(source, prepared, token_budget, drop_reasons) + item = self._fit( + source, + prepared, + token_budget, + drop_reasons, + label_applicability, + retention, + ) if item is not None: prepared.append(item) else: self._append_reason(drop_reasons, "TOKEN_BUDGET") for source in ordinary: - item = self._fit(source, prepared, token_budget, drop_reasons) + item = self._fit( + source, + prepared, + token_budget, + drop_reasons, + label_applicability, + retention, + ) if item is not None: prepared.append(item) else: @@ -356,7 +445,7 @@ def compose( source_order = {id(source): index for index, source in enumerate(ordered)} prepared.sort(key=lambda item: source_order[id(item.source)]) - prepared = self._resequence(prepared) + prepared = self._resequence(prepared, label_applicability, retention) text = "\n\n".join(item.block for item in prepared) rendered_tokens = self._count(text) @@ -369,6 +458,7 @@ def compose( excerpt_start = cursor + item.block.index("\n") + 1 excerpt_end = excerpt_start + len(item.excerpt) source = item.source + fact_limit = self._excerpt_limit(source, retention) relation_paths = self._relation_paths(source.candidate) evidence_item = EvidenceItem( citation=item.marker, @@ -384,16 +474,19 @@ def compose( worked=source.worked, superseded_by_version_id=source.superseded_by_version_id, outcome=( - self._bounded(source.outcome) + self._bounded(source.outcome, fact_limit) if source.outcome is not None else None ), outcome_failed=source.outcome_failed, procedure_steps=tuple( - self._bounded(step) for step in source.procedure_steps + self._bounded(step, fact_limit) for step in source.procedure_steps ), relation_path=relation_paths[0] if relation_paths else (), relation_paths=relation_paths, + utility=source.utility, + applicability=source.applicability, + changed_bindings=source.changed_bindings, ) items.append(evidence_item) citations.append( @@ -420,13 +513,24 @@ def compose( ) return CompositionResult(items=tuple(items), context=context) - def _resequence(self, prepared: list[_PreparedItem]) -> list[_PreparedItem]: + def _resequence( + self, + prepared: list[_PreparedItem], + label_applicability: bool = False, + retention: RetentionPolicy | None = None, + ) -> list[_PreparedItem]: """Assign contiguous markers after restoring caller-selected order.""" resequenced: list[_PreparedItem] = [] for index, item in enumerate(prepared, 1): marker = f"[E{index}]" - block = self._render_block(marker, item.source, item.excerpt) + block = self._render_block( + marker, + item.source, + item.excerpt, + label_applicability=label_applicability, + fact_limit=self._excerpt_limit(item.source, retention), + ) resequenced.append( replace( item, @@ -437,18 +541,63 @@ def _resequence(self, prepared: list[_PreparedItem]) -> list[_PreparedItem]: ) return resequenced + @staticmethod + def _priority(source: SelectedEvidence, retention: RetentionPolicy | None) -> bool: + if source.priority: + return True + if retention is None or retention.intent not in {"implement", "debug"}: + return False + return bool(source.procedure_steps) or any( + relative == identifier.removeprefix("./") + or (not identifier.startswith("./") and relative.endswith("/" + identifier)) + or ( + qualified_name is not None + and ( + qualified_name == identifier + or qualified_name.endswith("." + identifier) + ) + ) + for relative, qualified_name in source.code_bindings + for identifier in retention.identifiers + ) + + def _excerpt_limit( + self, source: SelectedEvidence, retention: RetentionPolicy | None + ) -> int: + if retention is None or retention.intent == "explore": + return self._max_excerpt_chars + if ( + source.status == "superseded" + or source.applicability == "needs_revalidation" + ): + return min(300, self._max_excerpt_chars) + if retention.intent in {"implement", "debug"} and not self._priority( + source, retention + ): + return self._max_excerpt_chars // 2 + return self._max_excerpt_chars + def _fit( self, source: SelectedEvidence, prepared: list[_PreparedItem], budget: int, drop_reasons: list[str], + label_applicability: bool = False, + retention: RetentionPolicy | None = None, ) -> _PreparedItem | None: marker = f"[E{len(prepared) + 1}]" full_excerpt = self._full_excerpt(source) - excerpt = self._bounded(full_excerpt) + fact_limit = self._excerpt_limit(source, retention) + excerpt = self._bounded(full_excerpt, fact_limit) truncated = excerpt != full_excerpt - block = self._render_block(marker, source, excerpt) + block = self._render_block( + marker, + source, + excerpt, + label_applicability=label_applicability, + fact_limit=fact_limit, + ) if self._combined_count(prepared, block) > budget: compressed = self._compress_excerpt( source, @@ -458,7 +607,13 @@ def _fit( drop_reasons=drop_reasons, ) if compressed is not None: - compressed_block = self._render_block(marker, source, compressed) + compressed_block = self._render_block( + marker, + source, + compressed, + label_applicability=label_applicability, + fact_limit=fact_limit, + ) if self._combined_count(prepared, compressed_block) <= budget: excerpt = compressed block = compressed_block @@ -470,10 +625,18 @@ def _fit( excerpt, prepared, budget, + label_applicability, + fact_limit, ) if excerpt is None: return None - block = self._render_block(marker, source, excerpt) + block = self._render_block( + marker, + source, + excerpt, + label_applicability=label_applicability, + fact_limit=fact_limit, + ) truncated = True if truncated: self._append_reason(drop_reasons, "ITEM_TRUNCATED") @@ -541,6 +704,8 @@ def _largest_fitting_excerpt( excerpt: str, prepared: list[_PreparedItem], budget: int, + label_applicability: bool = False, + fact_limit: int | None = None, ) -> str | None: low = 1 high = len(excerpt) @@ -550,7 +715,13 @@ def _largest_fitting_excerpt( candidate = excerpt[:midpoint].rstrip() if not candidate: candidate = excerpt[:1] - block = self._render_block(marker, source, candidate) + block = self._render_block( + marker, + source, + candidate, + label_applicability=label_applicability, + fact_limit=fact_limit, + ) if self._combined_count(prepared, block) <= budget: best = candidate low = midpoint + 1 @@ -570,6 +741,9 @@ def _render_block( marker: str, source: SelectedEvidence, excerpt: str, + *, + label_applicability: bool = False, + fact_limit: int | None = None, ) -> str: channels = ",".join(sorted(source.candidate.channels)) lines = [ @@ -579,13 +753,22 @@ def _render_block( ), excerpt, ] + if label_applicability and source.applicability == "needs_revalidation": + lines.append( + "Needs revalidation: " + + "; ".join(self._clean(path) for path in source.changed_bindings) + ) if source.outcome is not None: outcome_label = "Failed outcome" if source.outcome_failed else "Outcome" - lines.append(f"{outcome_label}: {self._bounded(source.outcome)}") + lines.append( + f"{outcome_label}: {self._bounded(source.outcome, fact_limit)}" + ) if source.procedure_steps: lines.append( "Steps: " - + " | ".join(self._bounded(step) for step in source.procedure_steps) + + " | ".join( + self._bounded(step, fact_limit) for step in source.procedure_steps + ) ) relation_paths = self._relation_paths(source.candidate) if relation_paths: @@ -625,11 +808,12 @@ def _count(self, text: str) -> int: raise ValueError("tokenizer must return a non-negative integer") return value - def _bounded(self, value: str) -> str: + def _bounded(self, value: str, max_chars: int | None = None) -> str: cleaned = self._clean(value) - if len(cleaned) <= self._max_excerpt_chars: + limit = self._max_excerpt_chars if max_chars is None else max_chars + if len(cleaned) <= limit: return cleaned - return cleaned[: self._max_excerpt_chars].rstrip() + return cleaned[:limit].rstrip() @staticmethod def _clean(value: str) -> str: diff --git a/daem0nmcp/retrieval/dense_projection.py b/daem0nmcp/retrieval/dense_projection.py index 11ca99c..2932190 100644 --- a/daem0nmcp/retrieval/dense_projection.py +++ b/daem0nmcp/retrieval/dense_projection.py @@ -727,7 +727,7 @@ def _cleanup_staging( try: client = self._get_client() if self._collection_exists(client, collection_name): - client.delete_collection(collection_name) + self._delete_collection(client, collection_name) except Exception: pass @@ -1119,6 +1119,22 @@ def _models_for_client(self, client: object) -> object | None: def _collection_exists(client: object, collection_name: str) -> bool: return qdrant_collection_exists(client, collection_name) + @staticmethod + def _delete_collection(client: object, collection_name: str) -> None: + if type(client).__module__.startswith("qdrant_client"): + from qdrant_client import QdrantClient + from qdrant_client.local.qdrant_local import QdrantLocal + + if isinstance(client, QdrantClient) and isinstance( + client._client, QdrantLocal + ): + collection = client._client.collections.get(collection_name) + if collection is not None: + # Local deletion drops the collection without closing SQLite; + # client.close() cannot reach that persistence afterward. + collection.close() + client.delete_collection(collection_name) + def _qdrant_vector_config(self, client: object) -> object: models = self._models_for_client(client) if models is None: @@ -1167,7 +1183,7 @@ def _create_collection(self, collection_name: str) -> None: exists = self._collection_exists(client, collection_name) self._check_cancelled() if exists: - client.delete_collection(collection_name) + self._delete_collection(client, collection_name) self._check_cancelled() client.create_collection( collection_name=collection_name, diff --git a/daem0nmcp/retrieval/late_interaction.py b/daem0nmcp/retrieval/late_interaction.py new file mode 100644 index 0000000..9053f72 --- /dev/null +++ b/daem0nmcp/retrieval/late_interaction.py @@ -0,0 +1,112 @@ +"""Optional token-level MaxSim reranking of policy-approved evidence.""" + +from __future__ import annotations + +import threading +from collections.abc import Callable +from typing import Any + +from ..bounded_workers import BoundedWorkerPool +from .composer import SelectedEvidence +from .types import FusedCandidate, RetrievalQuery + +_LATE_INTERACTION_WORKERS = BoundedWorkerPool( + max_workers=2, + thread_name_prefix="daem0nmcp-late-interaction", +) + + +class LateInteractionReranker: + """Lazily load one late-interaction model and run inference off-loop.""" + + def __init__( + self, + *, + model_name: str, + model_factory: Callable[[str], object] | None = None, + worker_pool: BoundedWorkerPool | None = None, + ) -> None: + if not isinstance(model_name, str) or not model_name.strip(): + raise ValueError("model_name must be a non-empty string") + if model_factory is not None and not callable(model_factory): + raise ValueError("model_factory must be callable") + if worker_pool is not None and not isinstance(worker_pool, BoundedWorkerPool): + raise ValueError("worker_pool must be a BoundedWorkerPool") + self._model_name = model_name + self._model_factory = model_factory + self._model: Any = None + self._model_lock = threading.Lock() + self._worker_pool = worker_pool or _LATE_INTERACTION_WORKERS + + def _load_model(self) -> Any: + with self._model_lock: + if self._model is None: + if self._model_factory is None: + from fastembed import LateInteractionTextEmbedding + + self._model = LateInteractionTextEmbedding( + model_name=self._model_name, + ) + else: + self._model = self._model_factory(self._model_name) + return self._model + + async def rerank( + self, + query: RetrievalQuery, + candidates: tuple[SelectedEvidence, ...], + ) -> tuple[FusedCandidate, ...]: + if not isinstance(query, RetrievalQuery): + raise ValueError("query must be a RetrievalQuery") + if not isinstance(candidates, tuple) or not all( + isinstance(candidate, SelectedEvidence) for candidate in candidates + ): + raise ValueError("candidates must contain SelectedEvidence") + if not candidates: + return () + return await self._worker_pool.run( + lambda: self._rerank_sync(query.text, candidates) + ) + + def _rerank_sync( + self, + text: str, + candidates: tuple[SelectedEvidence, ...], + ) -> tuple[FusedCandidate, ...]: + import numpy as np + + model = self._load_model() + queries = list(model.query_embed([text])) + documents = list(model.embed([candidate.content for candidate in candidates])) + if len(queries) != 1 or len(documents) != len(candidates): + raise RuntimeError("RERANKER_VECTOR_INVALID") + query_tokens = _normalized_tokens(queries[0]) + scored: list[tuple[float, int]] = [] + for index, document in enumerate(documents): + document_tokens = _normalized_tokens(document) + if query_tokens.shape[1] != document_tokens.shape[1]: + raise RuntimeError("RERANKER_VECTOR_INVALID") + score = float((query_tokens @ document_tokens.T).max(axis=1).sum()) + if not np.isfinite(score): + raise RuntimeError("RERANKER_VECTOR_INVALID") + scored.append((score, index)) + scored.sort(key=lambda item: (-item[0], item[1])) + return tuple(candidates[index].candidate for _, index in scored) + + +def _normalized_tokens(value: object) -> Any: + import numpy as np + + try: + tokens = np.asarray(value, dtype=np.float64) + except (OverflowError, TypeError, ValueError) as exc: + raise RuntimeError("RERANKER_VECTOR_INVALID") from exc + if tokens.ndim != 2 or 0 in tokens.shape or not np.isfinite(tokens).all(): + raise RuntimeError("RERANKER_VECTOR_INVALID") + scales = np.max(np.abs(tokens), axis=1, keepdims=True) + tokens = np.divide(tokens, scales, out=np.zeros_like(tokens), where=scales > 0) + norms = np.linalg.norm(tokens, axis=1, keepdims=True) + return np.divide(tokens, norms, out=tokens, where=norms > 0) + + +__all__ = ["LateInteractionReranker"] diff --git a/daem0nmcp/retrieval/providers.py b/daem0nmcp/retrieval/providers.py index b6eb53e..ea12295 100644 --- a/daem0nmcp/retrieval/providers.py +++ b/daem0nmcp/retrieval/providers.py @@ -265,7 +265,11 @@ def _search_connection( {expected_table}, {content_weight}, {rationale_weight}, {tags_weight} ) AS score, - snippet({expected_table}, 0, '', '', ' … ', 18) + snippet({expected_table}, 0, '', '', ' … ', 18), + "{expected_table}".rowid IN ( + SELECT rowid FROM "{expected_table}" + WHERE "{expected_table}" MATCH ? + ) AS content_match FROM "{expected_table}" JOIN ( SELECT document_rowid,record_id,content_hash, @@ -276,10 +280,16 @@ def _search_connection( ) AS document ON document.document_rowid="{expected_table}".rowid WHERE "{expected_table}" MATCH ? - ORDER BY score ASC, document.record_id ASC + ORDER BY content_match DESC, + CASE WHEN content_match THEN bm25( + {expected_table}, {content_weight}, + {rationale_weight}, 0 + ) ELSE score END ASC, + document.record_id ASC LIMIT ? """, ( + "{content rationale}: (" + fts_query + ")", query.workspace_id, generation, fts_query, diff --git a/daem0nmcp/retrieval/repository.py b/daem0nmcp/retrieval/repository.py index ae0aa0c..b6c7825 100644 --- a/daem0nmcp/retrieval/repository.py +++ b/daem0nmcp/retrieval/repository.py @@ -21,7 +21,8 @@ from pathlib import Path from ..bounded_workers import BoundedWorkerBusyError, BoundedWorkerPool -from ..event_store import canonical_json_bytes, sha256_json +from ..code_bindings import binding_refs_from_context +from ..event_store import canonical_json_bytes, memory_content_hash, sha256_json from .composer import SelectedEvidence from .policy import PolicyRecord, apply_retrieval_policy from .specialized_contract import ( @@ -29,6 +30,7 @@ specialized_manifest_matches_contract, ) from .types import EvidenceRef, FusedCandidate, RetrievalQuery, _aware_datetime +from .utility import MAX_DEPTH, MAX_DESCENDANTS, UtilityContribution _HASH = re.compile(r"^[0-9a-f]{64}$") _SUPPORTED_CHANNELS = frozenset( @@ -41,6 +43,8 @@ "enrichment_decisions", "memory_events", "memory_fact_versions", + "memory_provenance_edges", + "memory_outcome_signals", "memory_records", "memory_relationship_versions", "projection_manifests", @@ -326,6 +330,23 @@ async def load_selected_evidence( "EVIDENCE_CONTENT_UNAVAILABLE", ) + async def load_utility_contributions( + self, + workspace_id: str, + record_ids: tuple[str, ...], + transaction_at_us: int, + ) -> dict[str, tuple[UtilityContribution, ...]]: + record_values = tuple(dict.fromkeys(record_ids)) + if not record_values: + return {} + return await self._run_with_verified_root( + workspace_id, + lambda: self._load_utility_contributions_sync( + workspace_id, record_values, transaction_at_us + ), + "UTILITY_STATE_UNAVAILABLE", + ) + async def _run_with_verified_root( self, workspace_id: str, @@ -441,6 +462,103 @@ def _open_connection(self) -> sqlite3.Connection: raise return connection + def _load_utility_contributions_sync( + self, + workspace_id: str, + record_ids: tuple[str, ...], + transaction_at_us: int, + ) -> dict[str, tuple[UtilityContribution, ...]]: + connection = self._open_connection() + try: + revision = self._begin_read_snapshot(connection) + self._event_root(connection, workspace_id, revision) + result: dict[str, tuple[UtilityContribution, ...]] = {} + for record_id in record_ids: + depths = {record_id: 0} + frontier = (record_id,) + for depth in range(1, MAX_DEPTH + 1): + remaining = MAX_DESCENDANTS - (len(depths) - 1) + if not frontier or remaining == 0: + break + frontier_slots = ",".join("?" for _ in frontier) + visited_slots = ",".join("?" for _ in depths) + rows = connection.execute( + "SELECT DISTINCT record_id FROM memory_provenance_edges " + "WHERE workspace_id=? AND edge_kind='store' " + f"AND informed_by_record_id IN ({frontier_slots}) " + f"AND record_id NOT IN ({visited_slots}) " + "AND recorded_at_us<=? ORDER BY record_id LIMIT ?", + ( + workspace_id, + *frontier, + *depths, + transaction_at_us, + remaining, + ), + ).fetchall() + frontier = tuple(str(row[0]) for row in rows) + depths.update((descendant, depth) for descendant in frontier) + + contributions: dict[str, UtilityContribution] = {} + record_slots = ",".join("?" for _ in depths) + rows = connection.execute( + "SELECT recorded_at_us,event_id,record_id,reward,weight " + "FROM memory_outcome_signals WHERE workspace_id=? " + f"AND record_id IN ({record_slots}) AND recorded_at_us<=?", + (workspace_id, *depths, transaction_at_us), + ).fetchall() + for row in rows: + depth = depths[str(row[2])] + if depth == 0 and float(row[3]) != 1.0: + continue + contribution = UtilityContribution( + recorded_at_us=int(row[0]), + event_id=str(row[1]), + depth=depth, + reward=float(row[3]), + weight=float(row[4]), + ) + contributions[contribution.event_id] = contribution + + rows = connection.execute( + "SELECT signals.recorded_at_us,signals.event_id," + "signals.reward,signals.weight " + "FROM memory_provenance_edges AS edges " + "JOIN memory_outcome_signals AS signals " + "ON signals.event_id=edges.event_id " + "AND signals.workspace_id=edges.workspace_id " + "WHERE edges.workspace_id=? AND edges.edge_kind='outcome' " + "AND edges.informed_by_record_id=? " + "AND edges.recorded_at_us<=? AND signals.recorded_at_us<=?", + ( + workspace_id, + record_id, + transaction_at_us, + transaction_at_us, + ), + ).fetchall() + for row in rows: + contribution = UtilityContribution( + recorded_at_us=int(row[0]), + event_id=str(row[1]), + depth=1, + reward=float(row[2]), + weight=float(row[3]), + ) + previous = contributions.get(contribution.event_id) + if previous is None or contribution.depth < previous.depth: + contributions[contribution.event_id] = contribution + result[record_id] = tuple( + sorted( + contributions.values(), + key=lambda item: (item.recorded_at_us, item.event_id), + ) + ) + connection.rollback() + return result + finally: + connection.close() + def _load_policy_records_sync( self, query: RetrievalQuery, @@ -619,7 +737,7 @@ def _hydrate_selected( ) -> SelectedEvidence: record = connection.execute( "SELECT content,record_type,content_hash,source_event_id," - "outcome,worked,rationale,tags_json FROM memory_records " + "outcome,worked,rationale,tags_json,context_json FROM memory_records " "WHERE workspace_id=? AND record_id=? AND deleted_at_us IS NULL", (query.workspace_id, candidate.record_id), ).fetchone() @@ -645,6 +763,9 @@ def _hydrate_selected( rationale = record[6] try: tags = json.loads(str(record[7])) + context = json.loads(str(record[8])) + if not isinstance(context, dict): + raise ValueError("record context must be an object") except (TypeError, ValueError, RecursionError) as exc: raise RetrievalRepositoryError("EVIDENCE_CONTENT_UNAVAILABLE") from exc if ( @@ -677,13 +798,25 @@ def _hydrate_selected( superseded_by = None transaction_at_us = _datetime_us(query.as_of_transaction_time or snapshot_time) - procedure_steps = self._selected_procedure_steps( - connection, - query, - candidate.record_id, - transaction_at_us, - root, - manifests, + procedure_steps = ( + self._canonical_procedure_steps( + connection, + query, + candidate.record_id, + str(record[3]), + state.content_hash, + transaction_at_us, + context, + ) + if state.category == "procedure" and "steps" in context + else self._selected_procedure_steps( + connection, + query, + candidate.record_id, + transaction_at_us, + root, + manifests, + ) ) outcome, outcome_failed, worked = self._selected_outcome( connection, @@ -702,6 +835,8 @@ def _hydrate_selected( rationale=rationale, tags=tuple(tags), worked=worked, + code_bindings=binding_refs_from_context(context), + binding_context=context, status=status, superseded_by_version_id=superseded_by, outcome=outcome, @@ -758,6 +893,49 @@ def _optional_manifest( except RetrievalRepositoryError: return None + def _canonical_procedure_steps( + self, + connection: sqlite3.Connection, + query: RetrievalQuery, + record_id: str, + source_event_id: str, + content_hash: str, + transaction_at_us: int, + context: Mapping[str, object], + ) -> tuple[str, ...]: + event = self._source_event(connection, query.workspace_id, source_event_id) + if event.stream_id != record_id or event.stream_kind != "memory": + raise RetrievalRepositoryError("EVIDENCE_CONTENT_UNAVAILABLE") + if event.recorded_at_us > transaction_at_us: + return () + row = connection.execute( + "SELECT payload_json,payload_hash FROM memory_events " + "WHERE workspace_id=? AND event_id=?", + (query.workspace_id, source_event_id), + ).fetchone() + try: + if row is None or not isinstance(row[0], str): + raise ValueError("procedure source payload is unavailable") + payload = json.loads(row[0]) + if hashlib.sha256(row[0].encode("utf-8")).hexdigest() != _safe_hash(row[1]): + raise ValueError("procedure source payload hash differs") + record = payload.get("record") if isinstance(payload, dict) else None + if ( + not isinstance(record, dict) + or record.get("record_type") != "procedure" + or record.get("context", {}) != context + or memory_content_hash(record) != content_hash + ): + raise ValueError("procedure source state differs") + steps = context.get("steps", []) + if not isinstance(steps, list) or not all( + isinstance(step, str) and step.strip() for step in steps + ): + raise ValueError("procedure steps are invalid") + return tuple(steps) + except (TypeError, ValueError, RecursionError) as exc: + raise RetrievalRepositoryError("EVIDENCE_CONTENT_UNAVAILABLE") from exc + def _selected_procedure_steps( self, connection: sqlite3.Connection, diff --git a/daem0nmcp/retrieval/runtime.py b/daem0nmcp/retrieval/runtime.py index 6f115e9..cc00e06 100644 --- a/daem0nmcp/retrieval/runtime.py +++ b/daem0nmcp/retrieval/runtime.py @@ -21,6 +21,7 @@ from ..bounded_workers import BoundedWorkerBusyError, BoundedWorkerPool from .composer import EvidenceComposer from .jobs import ProjectionJobRun, ProjectionJobRunner +from .late_interaction import LateInteractionReranker from .planner import RetrievalPlanner from .providers import DenseProvider, LexicalProvider from .repository import SQLiteRetrievalRepository, sqlite_read_connection_factory @@ -483,11 +484,17 @@ def create_retrieval_service( max_branching=config.retrieval_graph_max_branching, ) reranker = None - if config.retrieval_rerank_enabled and dense_ready: - reranker = EmbeddingSimilarityReranker( - query_encoder=query_encoder, - document_encoder=document_encoder, - ) + if config.retrieval_rerank_enabled: + if config.retrieval_reranker == "late_interaction": + if _profile_ready(statuses, "late-interaction"): + reranker = LateInteractionReranker( + model_name=config.retrieval_late_interaction_model, + ) + elif dense_ready: + reranker = EmbeddingSimilarityReranker( + query_encoder=query_encoder, + document_encoder=document_encoder, + ) return RetrievalService( providers=providers, repository=SQLiteRetrievalRepository( @@ -507,6 +514,12 @@ def create_retrieval_service( provider_timeout_seconds=timeout, weights=config.retrieval_rrf_weights, rrf_k=config.rrf_k, + utility_mode=config.retrieval_utility_mode, + utility_weight=config.retrieval_utility_weight, + utility_credit=config.retrieval_utility_credit, + utility_candidate_limit=config.retrieval_utility_candidate_limit, + validity_mode=config.memory_validity_mode, + retention_mode=config.retrieval_retention_mode, ) diff --git a/daem0nmcp/retrieval/service.py b/daem0nmcp/retrieval/service.py index 84f7b34..d6b028b 100644 --- a/daem0nmcp/retrieval/service.py +++ b/daem0nmcp/retrieval/service.py @@ -13,14 +13,19 @@ from collections.abc import Iterable, Mapping from dataclasses import dataclass, replace from datetime import datetime, timezone +from pathlib import Path from time import perf_counter_ns from types import MappingProxyType -from typing import Protocol +from typing import Literal, Protocol, runtime_checkable +from ..bounded_workers import BoundedWorkerPool +from ..code_bindings import BindingEvaluator from .composer import ( CompositionResult, + RetentionPolicy, SelectedEvidence, _normalize_evidence_text, + query_identifiers, ) from .fusion import ( DEFAULT_RRF_K, @@ -40,6 +45,11 @@ RetrievalResult, _aware_datetime, ) +from .utility import UtilityContribution, UtilityEstimate, fold_utility, utility_signal + +_VALIDITY_WORKERS = BoundedWorkerPool( + max_workers=2, thread_name_prefix="daem0nmcp-validity" +) _PROVIDER_ORDER = ( "lexical", @@ -52,6 +62,18 @@ _SUPPORTED_PROVIDERS = frozenset(_PROVIDER_ORDER) +@runtime_checkable +class UtilityContributionRepository(Protocol): + """Optional outcome-learning reads supported by canonical repositories.""" + + async def load_utility_contributions( + self, + workspace_id: str, + record_ids: tuple[str, ...], + transaction_at_us: int, + ) -> Mapping[str, tuple[UtilityContribution, ...]]: ... + + class RetrievalRepository(Protocol): """Canonical metadata and content reads for one retrieval snapshot.""" @@ -104,6 +126,8 @@ async def compose_async( selected: Iterable[SelectedEvidence], *, token_budget: int, + retention: RetentionPolicy | None = None, + label_applicability: bool = False, ) -> CompositionResult: """Compose under a total bounded-worker/timeout policy.""" ... @@ -185,6 +209,12 @@ def __init__( provider_timeout_seconds: float = 10.0, weights: Mapping[str, float] = DEFAULT_RRF_WEIGHTS, rrf_k: int = DEFAULT_RRF_K, + utility_mode: Literal["off", "shadow", "apply"] = "off", + utility_weight: float = 0.1, + utility_credit: Literal["single_step", "trace"] = "trace", + utility_candidate_limit: int = 25, + validity_mode: Literal["off", "shadow", "apply"] = "off", + retention_mode: Literal["off", "shadow", "apply"] = "off", ) -> None: self._providers = self._validate_providers(providers) if not callable( @@ -203,6 +233,37 @@ def __init__( raise ValueError("reranker must provide rerank") if not isinstance(rerank_enabled, bool): raise ValueError("rerank_enabled must be boolean") + if utility_mode not in {"off", "shadow", "apply"}: + raise ValueError("utility_mode is invalid") + if utility_credit not in {"single_step", "trace"}: + raise ValueError("utility_credit is invalid") + if ( + isinstance(utility_weight, bool) + or not isinstance(utility_weight, (int, float)) + or not math.isfinite(utility_weight) + or not 0 <= utility_weight <= 1 + ): + raise ValueError("utility_weight must be finite and between 0 and 1") + utility_candidate_limit = _positive_integer( + utility_candidate_limit, "utility_candidate_limit" + ) + if utility_candidate_limit > 200: + raise ValueError("utility_candidate_limit must not exceed 200") + if utility_mode != "off" and not callable( + getattr(repository, "load_utility_contributions", None) + ): + raise ValueError("repository must provide utility contribution reads") + self._utility_mode = utility_mode + self._utility_weight = float(utility_weight) + self._utility_credit = utility_credit + self._utility_candidate_limit = utility_candidate_limit + if validity_mode not in {"off", "shadow", "apply"}: + raise ValueError("validity_mode is invalid") + self._validity_mode = validity_mode + self._binding_evaluator = BindingEvaluator() + if retention_mode not in {"off", "shadow", "apply"}: + raise ValueError("retention_mode is invalid") + self._retention_mode = retention_mode validated_weights = _validated_weights(weights) missing_weights = set(self._providers).difference(validated_weights) @@ -263,10 +324,14 @@ def _validate_providers( validated[name] = provider return validated - async def retrieve(self, query: RetrievalQuery) -> RetrievalResult: + async def retrieve( + self, query: RetrievalQuery, *, workspace_root: Path | None = None + ) -> RetrievalResult: """Retrieve, authorize, select, and compose evidence for *query*.""" - candidates = await self.retrieve_candidates(query) + candidates = await self.retrieve_candidates( + query, workspace_root=workspace_root + ) if candidates.abstained: return RetrievalResult( providers=candidates.providers, @@ -275,10 +340,19 @@ async def retrieve(self, query: RetrievalQuery) -> RetrievalResult: abstained=True, reason=candidates.reason, ) + diagnostics = list(candidates.providers) + retention = ( + RetentionPolicy(query.intent, query_identifiers(query.text)) + if query.intent is not None and self._retention_mode != "off" + else None + ) try: + started = perf_counter_ns() composition = await self._composer.compose_async( candidates.selected, token_budget=query.token_budget, + retention=retention if self._retention_mode == "apply" else None, + label_applicability=self._validity_mode == "apply", ) if not isinstance(composition, CompositionResult): raise ValueError("composer returned an invalid result") @@ -288,10 +362,43 @@ async def retrieve(self, query: RetrievalQuery) -> RetrievalResult: ): raise ValueError("composer changed the validated token budget") items = self._validated_composed_items(composition, candidates.selected) + if retention is not None: + retained = composition + retention_failed = False + if self._retention_mode == "shadow" and retention.intent != "explore": + try: + retained = await self._composer.compose_async( + candidates.selected, + token_budget=query.token_budget, + retention=retention, + label_applicability=self._validity_mode == "apply", + ) + if not isinstance(retained, CompositionResult): + raise ValueError("composer returned an invalid result") + except Exception: + retention_failed = True + diagnostics.append( + ProviderDiagnostic( + provider="retention", + status="degraded" if retention_failed else "ready", + manifest_generation=None, + elapsed_ms=(perf_counter_ns() - started) / 1_000_000, + reason=( + "RETENTION_FAILED" + if retention_failed + else "RETENTION_NOT_APPLICABLE" + if retention.intent == "explore" + else "RETENTION_APPLIED" + if self._retention_mode == "apply" + else "RETENTION_SHADOW" + ), + returned_count=0 if retention_failed else len(retained.items), + ) + ) except Exception: return self._abstention( "COMPOSITION_FAILED", - diagnostics=candidates.providers, + diagnostics=diagnostics, rejection_counts=candidates.policy_rejection_counts, ) if not items: @@ -302,19 +409,19 @@ async def retrieve(self, query: RetrievalQuery) -> RetrievalResult: ) return self._abstention( reason, - diagnostics=candidates.providers, + diagnostics=diagnostics, rejection_counts=candidates.policy_rejection_counts, ) return RetrievalResult( items=items, context=composition.context, - providers=candidates.providers, + providers=tuple(diagnostics), weights=candidates.weights, policy_rejection_counts=candidates.policy_rejection_counts, ) async def retrieve_candidates( - self, query: RetrievalQuery + self, query: RetrievalQuery, *, workspace_root: Path | None = None ) -> RetrievalCandidateResult: """Retrieve authenticated policy-valid evidence without composing it.""" @@ -456,6 +563,15 @@ async def retrieve_candidates( ) diagnostics.append(reranker_diagnostic) + utility_estimates: dict[str, UtilityEstimate] = {} + if self._utility_mode != "off": + ( + ordered_candidates, + utility_estimates, + diagnostic, + ) = await self._apply_utility(query, ordered_candidates, snapshot_time) + diagnostics.append(diagnostic) + diverse_candidates = self._select_diverse_candidates( ordered_candidates, policy_records, @@ -480,6 +596,21 @@ async def retrieve_candidates( diverse = tuple( content_cache[_identity(candidate)] for candidate in diverse_candidates ) + diverse = tuple( + replace( + source, + utility=( + estimate.q_single + if self._utility_credit == "single_step" + else estimate.q_trace + ), + ) + if (estimate := utility_estimates.get(source.candidate.record_id)) + is not None + and estimate.evidence_count > 0 + else source + for source in diverse + ) except Exception: return self._candidate_abstention( "EVIDENCE_CONTENT_UNAVAILABLE", @@ -487,6 +618,14 @@ async def retrieve_candidates( rejection_counts=policy_result.rejection_counts, ) + if ( + self._validity_mode != "off" + and workspace_root is not None + and any(source.code_bindings for source in diverse) + ): + diverse, diagnostic = await self._evaluate_bindings(workspace_root, diverse) + diagnostics.append(diagnostic) + return RetrievalCandidateResult( selected=diverse, providers=tuple(diagnostics), @@ -494,6 +633,133 @@ async def retrieve_candidates( policy_rejection_counts=policy_result.rejection_counts, ) + async def _evaluate_bindings( + self, root: Path, selected: tuple[SelectedEvidence, ...] + ) -> tuple[tuple[SelectedEvidence, ...], ProviderDiagnostic]: + started = perf_counter_ns() + + def evaluate() -> tuple[SelectedEvidence, ...]: + evaluated = [] + with self._binding_evaluator.read_batch(): + for source in selected: + evaluation = ( + self._binding_evaluator.evaluate(root, source.binding_context) + if source.code_bindings + else None + ) + evaluated.append( + replace( + source, + applicability=evaluation.applicability, + changed_bindings=evaluation.changed, + ) + if evaluation is not None + else source + ) + return tuple(evaluated) + + try: + evaluated = await _VALIDITY_WORKERS.run(evaluate) + except Exception: + return selected, ProviderDiagnostic( + provider="validity", + status="degraded", + manifest_generation=None, + elapsed_ms=(perf_counter_ns() - started) / 1_000_000, + reason="VALIDITY_FAILED", + returned_count=0, + ) + return evaluated, ProviderDiagnostic( + provider="validity", + status="ready", + manifest_generation=None, + elapsed_ms=(perf_counter_ns() - started) / 1_000_000, + reason="VALIDITY_APPLIED" + if self._validity_mode == "apply" + else "VALIDITY_SHADOW", + returned_count=sum( + source.applicability == "needs_revalidation" for source in evaluated + ), + ) + + async def _apply_utility( + self, + query: RetrievalQuery, + ordered: tuple[FusedCandidate, ...], + snapshot_time: datetime, + ) -> tuple[ + tuple[FusedCandidate, ...], dict[str, UtilityEstimate], ProviderDiagnostic + ]: + started = perf_counter_ns() + try: + head = ordered[: self._utility_candidate_limit] + transaction_time = query.as_of_transaction_time or snapshot_time + delta = transaction_time.astimezone(timezone.utc) - datetime( + 1970, 1, 1, tzinfo=timezone.utc + ) + transaction_at_us = ( + delta.days * 86_400 + delta.seconds + ) * 1_000_000 + delta.microseconds + if not isinstance(self._repository, UtilityContributionRepository): + raise TypeError("repository must provide utility contribution reads") + contributions = await self._repository.load_utility_contributions( + query.workspace_id, + tuple(candidate.record_id for candidate in head), + transaction_at_us, + ) + estimates = { + candidate.record_id: fold_utility( + contributions.get(candidate.record_id, ()) + ) + for candidate in head + } + ranked = sorted( + enumerate(head), + key=lambda pair: ( + -( + 61 / (61 + pair[0]) + + self._utility_weight + * utility_signal( + estimates[pair[1].record_id], self._utility_credit + ) + ), + pair[0], + ), + ) + sorted_head = tuple(candidate for _, candidate in ranked) + if self._utility_mode == "apply": + result = sorted_head + ordered[len(head) :] + reason = "UTILITY_APPLIED" + else: + result = ordered + reason = ( + "UTILITY_SHADOW_REORDER" + if sorted_head != head + else "UTILITY_SHADOW_SAME" + ) + status = "ready" + returned_count = sum( + estimates[candidate.record_id].evidence_count > 0 for candidate in head + ) + except Exception: + result = ordered + estimates = {} + status = "degraded" + reason = "UTILITY_FAILED" + returned_count = 0 + return ( + result, + estimates, + ProviderDiagnostic( + provider="utility", + status=status, + manifest_generation=None, + elapsed_ms=(perf_counter_ns() - started) / 1_000_000, + reason=reason, + returned_count=returned_count, + ), + ) + async def _invoke_provider( self, name: str, @@ -748,6 +1014,9 @@ def _validated_composed_items( or item.rationale != source.rationale or item.tags != source.tags or item.worked != source.worked + or item.utility != source.utility + or item.applicability != source.applicability + or item.changed_bindings != source.changed_bindings or item.status != source.status or item.superseded_by_version_id != source.superseded_by_version_id or not outcome_matches diff --git a/daem0nmcp/retrieval/types.py b/daem0nmcp/retrieval/types.py index 6ae8a8e..9d61604 100644 --- a/daem0nmcp/retrieval/types.py +++ b/daem0nmcp/retrieval/types.py @@ -165,6 +165,7 @@ class RetrievalQuery: include_archived: bool = False token_budget: int = 2400 rerank: bool = False + intent: Literal["explore", "implement", "debug", "review"] | None = None def __post_init__(self) -> None: _opaque(self.workspace_id, _WORKSPACE_ID, "workspace_id") @@ -185,6 +186,8 @@ def __post_init__(self) -> None: ) _aware_datetime(self.as_of_valid_time, "as_of_valid_time") _aware_datetime(self.as_of_transaction_time, "as_of_transaction_time") + if self.intent not in {None, "explore", "implement", "debug", "review"}: + raise ValueError("intent is invalid") _string_filter(self.categories, "categories") _string_filter(self.tags, "tags") _string_filter(self.record_ids, "record_ids", pattern=_RECORD_ID) @@ -486,6 +489,11 @@ class EvidenceItem: procedure_steps: tuple[str, ...] = () relation_path: tuple[str, ...] = () relation_paths: tuple[tuple[str, ...], ...] = () + utility: float | None = None + applicability: Literal["current", "needs_revalidation", "unverifiable"] | None = ( + None + ) + changed_bindings: tuple[str, ...] = () def __post_init__(self) -> None: if ( @@ -500,6 +508,22 @@ def __post_init__(self) -> None: if self.status not in {"current", "superseded"}: raise ValueError("evidence status is invalid") object.__setattr__(self, "score", _finite_nonnegative(self.score, "score")) + if self.utility is not None: + utility = _finite_nonnegative(self.utility, "utility") + if utility > 1: + raise ValueError("utility must be between 0 and 1") + object.__setattr__(self, "utility", utility) + if self.applicability not in { + None, + "current", + "needs_revalidation", + "unverifiable", + }: + raise ValueError("applicability is invalid") + if not isinstance(self.changed_bindings, tuple) or not all( + isinstance(binding, str) and binding for binding in self.changed_bindings + ): + raise ValueError("changed_bindings must contain non-empty strings") if not isinstance(self.channels, frozenset) or not self.channels: raise ValueError("channels must be a non-empty frozenset") for channel in self.channels: diff --git a/daem0nmcp/retrieval/utility.py b/daem0nmcp/retrieval/utility.py new file mode 100644 index 0000000..5b79981 --- /dev/null +++ b/daem0nmcp/retrieval/utility.py @@ -0,0 +1,72 @@ +"""Deterministic outcome evidence weighting and provenance credit folding.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from typing import Literal + +ALPHA = 0.3 +GAMMA = 0.8 +LAMBDA = 0.7 +MAX_DEPTH = 4 +MAX_DESCENDANTS = 64 + + +@dataclass(frozen=True, slots=True) +class UtilityContribution: + recorded_at_us: int + event_id: str + depth: int + reward: float + weight: float + + +@dataclass(frozen=True, slots=True) +class UtilityEstimate: + q_single: float + q_trace: float + evidence_count: int + + +def verification_weight( + worked: bool, verification: Mapping[str, object] | None +) -> float: + if verification is None: + return 0.5 + kind = verification.get("kind") + if kind in {"test", "command"}: + exit_code = verification.get("exit_code") + if exit_code is None: + return 0.75 + return 1.0 if (exit_code == 0) == worked else 0.25 + if kind == "review": + return 0.75 + return 0.5 + + +def fold_utility(contributions: Iterable[UtilityContribution]) -> UtilityEstimate: + q_single = q_trace = 0.5 + evidence_count = 0 + for contribution in sorted( + contributions, key=lambda item: (item.recorded_at_us, item.event_id) + ): + if contribution.depth <= 1: + q_single += ALPHA * contribution.weight * (contribution.reward - q_single) + factor = ( + 1.0 + if contribution.depth <= 1 + else (GAMMA * LAMBDA) ** (contribution.depth - 1) + ) + q_trace += ( + ALPHA * contribution.weight * factor * (contribution.reward - q_trace) + ) + evidence_count += 1 + return UtilityEstimate(round(q_single, 6), round(q_trace, 6), evidence_count) + + +def utility_signal( + estimate: UtilityEstimate, credit: Literal["single_step", "trace"] +) -> float: + q = estimate.q_single if credit == "single_step" else estimate.q_trace + return (q - 0.5) * 2 * min(1.0, estimate.evidence_count / 3) diff --git a/daem0nmcp/schema_version.py b/daem0nmcp/schema_version.py index e38d0db..581846c 100644 --- a/daem0nmcp/schema_version.py +++ b/daem0nmcp/schema_version.py @@ -1,6 +1,6 @@ """Dependency-free architecture-format 7 schema compatibility floor.""" -CURRENT_SCHEMA_VERSION = 32 +CURRENT_SCHEMA_VERSION = 33 REQUIRED_V7_SCHEMA_VERSIONS = frozenset(range(16, CURRENT_SCHEMA_VERSION + 1)) diff --git a/daem0nmcp/storage_activation.py b/daem0nmcp/storage_activation.py index bb0dd4e..34b927f 100644 --- a/daem0nmcp/storage_activation.py +++ b/daem0nmcp/storage_activation.py @@ -42,6 +42,8 @@ "memory_events", "memory_records", "memory_fact_versions", + "memory_provenance_edges", + "memory_outcome_signals", "memory_relationship_versions", "projection_manifests", "enrichment_decisions", @@ -276,7 +278,7 @@ def has_canonical_v7_state(database_path: str | os.PathLike[str]) -> bool: "SELECT name FROM sqlite_master WHERE type='table'" ) } - if "schema_version" not in tables or not tables >= _V7_FORMAT_TABLES: + if "schema_version" not in tables: return False version = int( connection.execute( @@ -285,6 +287,14 @@ def has_canonical_v7_state(database_path: str | os.PathLike[str]) -> bool: ) if version < _V7_FORMAT_MIN_SCHEMA_VERSION: return False + required_tables = _V7_FORMAT_TABLES + if version < 33: + required_tables = required_tables - { + "memory_provenance_edges", + "memory_outcome_signals", + } + if not tables >= required_tables: + return False return any( connection.execute(f'SELECT 1 FROM "{table}" LIMIT 1').fetchone() is not None diff --git a/daem0nmcp/verification_v7.py b/daem0nmcp/verification_v7.py index ea8c56a..515b3a2 100644 --- a/daem0nmcp/verification_v7.py +++ b/daem0nmcp/verification_v7.py @@ -50,6 +50,8 @@ _DERIVED_TABLES = ( "memory_records", "memory_fact_versions", + "memory_provenance_edges", + "memory_outcome_signals", "memory_relationship_versions", "governance_rules", "governance_context_triggers", @@ -68,6 +70,8 @@ "legacy_id_map", "memory_events", "memory_fact_versions", + "memory_provenance_edges", + "memory_outcome_signals", "memory_records", "memory_relationship_versions", "projection_manifests", @@ -888,6 +892,54 @@ def _verify_manifests(connection: sqlite3.Connection) -> tuple[int, int, int]: return total, stale, sum(int(value[1]) for value in latest_local.values()) +def _replay_authority( + source: sqlite3.Connection, replay: sqlite3.Connection +) -> dict[str, Any]: + checks: dict[str, dict[str, Any]] = {} + authority = {"memory_events": 0, "governance_events": 0, "federation_events": 0} + roots: dict[str, tuple[int, str]] = {} + authority_roots: dict[str, tuple[int, str]] = {} + try: + authority["memory_events"], roots = _memory_replay(source, replay) + checks["memory_events"] = _check(True, count=authority["memory_events"]) + except Exception as exc: + checks["memory_events"] = _check(False, error=type(exc).__name__) + try: + authority["governance_events"] = _governance_replay(source, replay) + checks["governance_events"] = _check(True, count=authority["governance_events"]) + except Exception as exc: + checks["governance_events"] = _check(False, error=type(exc).__name__) + try: + authority["federation_events"] = _verify_federation(source) + checks["federation_events"] = _check(True, count=authority["federation_events"]) + except Exception as exc: + checks["federation_events"] = _check(False, error=type(exc).__name__) + try: + authority_roots = { + "memory_events": _ledger_root(source, "memory_events"), + "governance_events": _ledger_root(source, "governance_events"), + "federation_events": _ledger_root(source, "workspace_link_events"), + } + except (TypeError, ValueError): + authority_roots = {} + try: + count = _verify_sequence(source) + checks["session_update_sequence"] = _check(True, count=count) + except Exception as exc: + checks["session_update_sequence"] = _check(False, error=type(exc).__name__) + try: + count = _verify_mappings(source, replay) + checks["migration_mappings"] = _check(True, count=count) + except Exception as exc: + checks["migration_mappings"] = _check(False, error=type(exc).__name__) + return { + "checks": checks, + "authority": authority, + "roots": roots, + "authority_roots": authority_roots, + } + + def _verify_database( path: Path, workspace_id: str, replay_path: Path ) -> dict[str, Any]: @@ -930,43 +982,11 @@ def _verify_database( foreign_key_violations=len(foreign), ) replay = _initialize_replay(replay_path, workspace_id) - try: - authority["memory_events"], roots = _memory_replay(source, replay) - checks["memory_events"] = _check(True, count=authority["memory_events"]) - except Exception as exc: - checks["memory_events"] = _check(False, error=type(exc).__name__) - try: - authority["governance_events"] = _governance_replay(source, replay) - checks["governance_events"] = _check( - True, count=authority["governance_events"] - ) - except Exception as exc: - checks["governance_events"] = _check(False, error=type(exc).__name__) - try: - authority["federation_events"] = _verify_federation(source) - checks["federation_events"] = _check( - True, count=authority["federation_events"] - ) - except Exception as exc: - checks["federation_events"] = _check(False, error=type(exc).__name__) - try: - authority_roots = { - "memory_events": _ledger_root(source, "memory_events"), - "governance_events": _ledger_root(source, "governance_events"), - "federation_events": _ledger_root(source, "workspace_link_events"), - } - except (TypeError, ValueError): - authority_roots = {} - try: - count = _verify_sequence(source) - checks["session_update_sequence"] = _check(True, count=count) - except Exception as exc: - checks["session_update_sequence"] = _check(False, error=type(exc).__name__) - try: - count = _verify_mappings(source, replay) - checks["migration_mappings"] = _check(True, count=count) - except Exception as exc: - checks["migration_mappings"] = _check(False, error=type(exc).__name__) + replayed = _replay_authority(source, replay) + checks.update(replayed["checks"]) + authority = replayed["authority"] + roots = replayed["roots"] + authority_roots = replayed["authority_roots"] try: manifest_count, stale, local_stale = _verify_manifests(source) checks["projection_manifests"] = _check( diff --git a/docs/index.html b/docs/index.html index 4fde516..39873e8 100644 --- a/docs/index.html +++ b/docs/index.html @@ -716,7 +716,7 @@

Enforcement Wards

The Silent Scribe

-

Read-only hooks whisper exact v7 calls as you work. Protected writes remain scoped, preflighted, and replay-safe.

+

Read-only hooks whisper exact v7 calls as you work. Protected writes remain scoped and replay-safe, with inline counsel and risk challenges.

The Coven Bond

@@ -724,7 +724,7 @@

The Coven Bond

Unified Workflows

-

The v7 ritual is explicit: brief, recall, preflight, store, and record the verified outcome with opaque workspace scope.

+

The guided v7 ritual is automatic briefing, relevant recall, direct writes with counsel challenges when needed, and verified outcomes.

Visions of the Void

@@ -825,13 +825,15 @@

The Covenant

Every scoped call uses the opaque workspace_id issued for the workspace.

-
SESSION DAWN       session_brief(workspace_id="<opaque-id>")
+
FULL BRIEF (OPTIONAL) session_brief(workspace_id="<opaque-id>")
 RELEVANT HISTORY   memory_recall(workspace_id="<opaque-id>", query="topic", limit=10)
-BEFORE A WRITE    memory_preflight(workspace_id="<opaque-id>", target_tool="memory_store", target_arguments={...})
-DURABLE DECISION  memory_store(..., idempotency_key="stable-key", preflight_token="<token>")
+ADVANCE PLANNING   memory_preflight(workspace_id="<opaque-id>", target_tool="memory_store", target_arguments={...})
+DURABLE DECISION  memory_store(..., idempotency_key="stable-key")
 VERIFIED RESULT  memory_record_outcome(..., worked=true, idempotency_key="stable-outcome-key")
 DIAGNOSTICS      system_health(workspace_id="<opaque-id>")
+

The first Daem0n call briefs automatically in meta.covenant.auto_brief; session_brief returns the full brief. Call protected tools directly. On COUNSEL_REQUIRED, read error.counsel, then retry exactly error.remedy. memory_preflight remains available for advance planning.

+

Use daem0n_tools_search(query), then daem0n_tool_call(workspace_id, tool, arguments) for other capabilities. Tokens remain exact-argument, single-use, and valid for 300 seconds. Defaults are guided/core; DAEM0NMCP_COVENANT_MODE=strict restores explicit briefing and preflight, and DAEM0NMCP_TOOL_SURFACE=full lists all tools.

Connect over stdio or Streamable HTTP at /mcp. Read-only context is available at:

diff --git a/docs/multi-repo-setup.md b/docs/multi-repo-setup.md index 1f7e453..ac4a9b4 100644 --- a/docs/multi-repo-setup.md +++ b/docs/multi-repo-setup.md @@ -18,10 +18,10 @@ access policy: └── client/ ``` -Start the session and query the shared record stream with the parent ID: +The first call automatically briefs the parent workspace. Query its shared +record stream; call `session_brief` only for the full brief: ```text -session_brief(workspace_id="ws_000000000000000000000001") memory_recall( workspace_id="ws_000000000000000000000001", query="authentication across backend and client", @@ -39,40 +39,38 @@ authorization, export, or archival: /workspace/client/ -> ws_000000000000000000000003 ``` -`workspace_link` is protected. Preflight its exact arguments first: +Use `daem0n_tools_search(query="link workspaces")` to discover `workspace_link`, +then call it through the gateway: ```text -memory_preflight( +daem0n_tool_call( workspace_id="ws_000000000000000000000002", - target_tool="workspace_link", - target_arguments={ + tool="workspace_link", + arguments={ "linked_workspace_id": "ws_000000000000000000000003", "relationship": "same-project" } ) -workspace_link( - workspace_id="ws_000000000000000000000002", - linked_workspace_id="ws_000000000000000000000003", - relationship="same-project", - preflight_token="" -) ``` +On `COUNSEL_REQUIRED`, review `error.counsel` and retry exactly `error.remedy`. +`memory_preflight` remains optional advance planning. Each federated linked +workspace still needs its own explicit `session_brief` before linked recall. + Linked recall remains explicit: provide authorized `linked_workspace_ids` to `memory_recall`. The server resolves every ID before reading and does not infer workspace scope from paths. ## Consolidating registered workspaces -Consolidation appends canonical v7 events to the target workspace. It is a -protected replay-safe write, so use the same exact arguments for preflight and -reuse the idempotency key on retry: +Consolidation appends canonical v7 events to the target workspace. Discover it +with `daem0n_tools_search`, then make the protected replay-safe call directly: ```text -memory_preflight( +daem0n_tool_call( workspace_id="ws_000000000000000000000001", - target_tool="workspace_consolidate", - target_arguments={ + tool="workspace_consolidate", + arguments={ "source_workspace_ids": [ "ws_000000000000000000000002", "ws_000000000000000000000003" @@ -80,17 +78,14 @@ memory_preflight( "idempotency_key": "consolidate-product-2026-0001" } ) -workspace_consolidate( - workspace_id="ws_000000000000000000000001", - source_workspace_ids=[ - "ws_000000000000000000000002", - "ws_000000000000000000000003" - ], - idempotency_key="consolidate-product-2026-0001", - preflight_token="" -) ``` +On `COUNSEL_REQUIRED`, read `error.counsel`, then retry exactly `error.remedy`; +reuse the idempotency key. Tokens remain exact-argument, single-use, and valid +for 300 seconds. Defaults are guided/core. `DAEM0NMCP_COVENANT_MODE=strict` +requires explicit briefing and preflight; `DAEM0NMCP_TOOL_SURFACE=full` lists +all tools. + Use `workspace_consolidate_and_archive_sources` only when source archival is intentional and separately authorized. Verify the target with `system_health` and bounded recall before archiving anything. diff --git a/docs/release/v7/inventory.md b/docs/release/v7/inventory.md index cc683e0..471194c 100644 --- a/docs/release/v7/inventory.md +++ b/docs/release/v7/inventory.md @@ -2,7 +2,7 @@ Generated by `python scripts/v7_release_inventory.py --write`. Registration is not acceptance; only an `accepted` requirement with explicit evidence metadata can be accepted. -Production entry point: `daem0nmcp.server:main`. Registered tools: **75**. Resource templates: **10**. v6 mappings: **64**. Atomic requirements: **572**. +Production entry point: `daem0nmcp.server:main`. Registered tools: **77**. Resource templates: **10**. v6 mappings: **64**. Atomic requirements: **586**. ## Release ledger @@ -24,81 +24,83 @@ Production entry point: `daem0nmcp.server:main`. Registered tools: **75**. Resou | Tool | Owner/package | Operation entry point | Input branches (`*` required) | Production acceptance/evidence | Status | | --- | --- | --- | --- | --- | -| `active_context_add` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.local_state_operations:build_local_state_operations` | `expires_at` ([tool.active_context_add.argument.expires_at]), `preflight_token`* ([tool.active_context_add.argument.preflight_token]), `priority` ([tool.active_context_add.argument.priority]), `reason` ([tool.active_context_add.argument.reason]), `record_id`* ([tool.active_context_add.argument.record_id]), `workspace_id`* ([tool.active_context_add.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of active_context_add; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `active_context_clear` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.local_state_operations:build_local_state_operations` | `preflight_token`* ([tool.active_context_clear.argument.preflight_token]), `selection_token`* ([tool.active_context_clear.argument.selection_token]), `workspace_id`* ([tool.active_context_clear.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of active_context_clear; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `active_context_add` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.local_state_operations:build_local_state_operations` | `expires_at` ([tool.active_context_add.argument.expires_at]), `preflight_token` ([tool.active_context_add.argument.preflight_token]), `priority` ([tool.active_context_add.argument.priority]), `reason` ([tool.active_context_add.argument.reason]), `record_id`* ([tool.active_context_add.argument.record_id]), `workspace_id`* ([tool.active_context_add.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of active_context_add; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `active_context_clear` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.local_state_operations:build_local_state_operations` | `preflight_token` ([tool.active_context_clear.argument.preflight_token]), `selection_token`* ([tool.active_context_clear.argument.selection_token]), `workspace_id`* ([tool.active_context_clear.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of active_context_clear; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `active_context_list` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.local_state_operations:build_local_state_operations` | `cursor` ([tool.active_context_list.argument.cursor]), `limit` ([tool.active_context_list.argument.limit]), `workspace_id`* ([tool.active_context_list.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of active_context_list; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `active_context_remove` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.local_state_operations:build_local_state_operations` | `active_context_id`* ([tool.active_context_remove.argument.active_context_id]), `preflight_token`* ([tool.active_context_remove.argument.preflight_token]), `workspace_id`* ([tool.active_context_remove.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of active_context_remove; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `active_context_remove` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.local_state_operations:build_local_state_operations` | `active_context_id`* ([tool.active_context_remove.argument.active_context_id]), `preflight_token` ([tool.active_context_remove.argument.preflight_token]), `workspace_id`* ([tool.active_context_remove.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of active_context_remove; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `code_impact_analyze` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.code_entity_operations:build_code_entity_operations` | `code_entity_id` ([tool.code_impact_analyze.argument.code_entity_id]), `max_depth` ([tool.code_impact_analyze.argument.max_depth]), `qualified_name` ([tool.code_impact_analyze.argument.qualified_name]), `workspace_id`* ([tool.code_impact_analyze.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of code_impact_analyze; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `code_index` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.discovery_operations:build_discovery_operations` | `force` ([tool.code_index.argument.force]), `patterns` ([tool.code_index.argument.patterns]), `relative_root` ([tool.code_index.argument.relative_root]), `workspace_id`* ([tool.code_index.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of code_index; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `code_refactor_propose` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.utility_operations:build_utility_operations` | `objective` ([tool.code_refactor_propose.argument.objective]), `relative_file_path`* ([tool.code_refactor_propose.argument.relative_file_path]), `workspace_id`* ([tool.code_refactor_propose.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of code_refactor_propose; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `code_search` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.discovery_operations:build_discovery_operations` | `cursor` ([tool.code_search.argument.cursor]), `entity_kinds` ([tool.code_search.argument.entity_kinds]), `limit` ([tool.code_search.argument.limit]), `query`* ([tool.code_search.argument.query]), `workspace_id`* ([tool.code_search.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of code_search; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `code_todos_scan` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.utility_operations:build_utility_operations` | `cursor` ([tool.code_todos_scan.argument.cursor]), `limit` ([tool.code_todos_scan.argument.limit]), `relative_root` ([tool.code_todos_scan.argument.relative_root]), `types` ([tool.code_todos_scan.argument.types]), `workspace_id`* ([tool.code_todos_scan.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of code_todos_scan; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `code_todos_scan_and_store` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.code_entity_operations:build_code_entity_operations` | `cursor` ([tool.code_todos_scan_and_store.argument.cursor]), `idempotency_key`* ([tool.code_todos_scan_and_store.argument.idempotency_key]), `limit` ([tool.code_todos_scan_and_store.argument.limit]), `preflight_token`* ([tool.code_todos_scan_and_store.argument.preflight_token]), `record_type` ([tool.code_todos_scan_and_store.argument.record_type]), `relative_root` ([tool.code_todos_scan_and_store.argument.relative_root]), `types` ([tool.code_todos_scan_and_store.argument.types]), `workspace_id`* ([tool.code_todos_scan_and_store.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of code_todos_scan_and_store; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `code_todos_scan_and_store` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.code_entity_operations:build_code_entity_operations` | `cursor` ([tool.code_todos_scan_and_store.argument.cursor]), `idempotency_key`* ([tool.code_todos_scan_and_store.argument.idempotency_key]), `limit` ([tool.code_todos_scan_and_store.argument.limit]), `preflight_token` ([tool.code_todos_scan_and_store.argument.preflight_token]), `record_type` ([tool.code_todos_scan_and_store.argument.record_type]), `relative_root` ([tool.code_todos_scan_and_store.argument.relative_root]), `types` ([tool.code_todos_scan_and_store.argument.types]), `workspace_id`* ([tool.code_todos_scan_and_store.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of code_todos_scan_and_store; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `community_get` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.discovery_operations:build_discovery_operations` | `community_id`* ([tool.community_get.argument.community_id]), `cursor` ([tool.community_get.argument.cursor]), `include_members` ([tool.community_get.argument.include_members]), `limit` ([tool.community_get.argument.limit]), `workspace_id`* ([tool.community_get.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of community_get; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `community_list` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.discovery_operations:build_discovery_operations` | `cursor` ([tool.community_list.argument.cursor]), `level` ([tool.community_list.argument.level]), `limit` ([tool.community_list.argument.limit]), `parent_community_id` ([tool.community_list.argument.parent_community_id]), `workspace_id`* ([tool.community_list.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of community_list; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `community_rebuild` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.graph_operations:build_graph_operations` | `idempotency_key`* ([tool.community_rebuild.argument.idempotency_key]), `min_community_size` ([tool.community_rebuild.argument.min_community_size]), `preflight_token`* ([tool.community_rebuild.argument.preflight_token]), `resolution` ([tool.community_rebuild.argument.resolution]), `workspace_id`* ([tool.community_rebuild.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of community_rebuild; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `community_rebuild` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.graph_operations:build_graph_operations` | `idempotency_key`* ([tool.community_rebuild.argument.idempotency_key]), `min_community_size` ([tool.community_rebuild.argument.min_community_size]), `preflight_token` ([tool.community_rebuild.argument.preflight_token]), `resolution` ([tool.community_rebuild.argument.resolution]), `workspace_id`* ([tool.community_rebuild.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of community_rebuild; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `context_compress` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.utility_operations:build_utility_operations` | `content_type` ([tool.context_compress.argument.content_type]), `preserve_code` ([tool.context_compress.argument.preserve_code]), `rate` ([tool.context_compress.argument.rate]), `text`* ([tool.context_compress.argument.text]), `workspace_id`* ([tool.context_compress.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of context_compress; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `context_trigger_create` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.rule_trigger_operations:build_rule_trigger_operations` | `categories` ([tool.context_trigger_create.argument.categories]), `enabled` ([tool.context_trigger_create.argument.enabled]), `idempotency_key`* ([tool.context_trigger_create.argument.idempotency_key]), `pattern`* ([tool.context_trigger_create.argument.pattern]), `preflight_token`* ([tool.context_trigger_create.argument.preflight_token]), `recall_query`* ([tool.context_trigger_create.argument.recall_query]), `trigger_type`* ([tool.context_trigger_create.argument.trigger_type]), `workspace_id`* ([tool.context_trigger_create.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of context_trigger_create; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `context_trigger_delete` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.rule_trigger_operations:build_rule_trigger_operations` | `preflight_token`* ([tool.context_trigger_delete.argument.preflight_token]), `trigger_id`* ([tool.context_trigger_delete.argument.trigger_id]), `workspace_id`* ([tool.context_trigger_delete.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of context_trigger_delete; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `context_trigger_create` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.rule_trigger_operations:build_rule_trigger_operations` | `categories` ([tool.context_trigger_create.argument.categories]), `enabled` ([tool.context_trigger_create.argument.enabled]), `idempotency_key`* ([tool.context_trigger_create.argument.idempotency_key]), `pattern`* ([tool.context_trigger_create.argument.pattern]), `preflight_token` ([tool.context_trigger_create.argument.preflight_token]), `recall_query`* ([tool.context_trigger_create.argument.recall_query]), `trigger_type`* ([tool.context_trigger_create.argument.trigger_type]), `workspace_id`* ([tool.context_trigger_create.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of context_trigger_create; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `context_trigger_delete` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.rule_trigger_operations:build_rule_trigger_operations` | `preflight_token` ([tool.context_trigger_delete.argument.preflight_token]), `trigger_id`* ([tool.context_trigger_delete.argument.trigger_id]), `workspace_id`* ([tool.context_trigger_delete.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of context_trigger_delete; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `context_trigger_list` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.rule_trigger_operations:build_rule_trigger_operations` | `active_only` ([tool.context_trigger_list.argument.active_only]), `cursor` ([tool.context_trigger_list.argument.cursor]), `limit` ([tool.context_trigger_list.argument.limit]), `workspace_id`* ([tool.context_trigger_list.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of context_trigger_list; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `context_triggers_match` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.rule_trigger_operations:build_rule_trigger_operations` | `entities` ([tool.context_triggers_match.argument.entities]), `limit` ([tool.context_triggers_match.argument.limit]), `relative_file_path` ([tool.context_triggers_match.argument.relative_file_path]), `tags` ([tool.context_triggers_match.argument.tags]), `workspace_id`* ([tool.context_triggers_match.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of context_triggers_match; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `covenant_status` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.operations:build_core_operations` | `workspace_id`* ([tool.covenant_status.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of covenant_status; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `decision_debate` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.intelligence_operations:build_intelligence_operations` | `advocate_position`* ([tool.decision_debate.argument.advocate_position]), `challenger_position`* ([tool.decision_debate.argument.challenger_position]), `idempotency_key`* ([tool.decision_debate.argument.idempotency_key]), `max_rounds` ([tool.decision_debate.argument.max_rounds]), `preflight_token`* ([tool.decision_debate.argument.preflight_token]), `topic`* ([tool.decision_debate.argument.topic]), `workspace_id`* ([tool.decision_debate.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of decision_debate; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `daem0n_tool_call` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.gateway_operations:build_gateway_operations` | `arguments` ([tool.daem0n_tool_call.argument.arguments]), `tool`* ([tool.daem0n_tool_call.argument.tool]), `workspace_id`* ([tool.daem0n_tool_call.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of daem0n_tool_call; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `daem0n_tools_search` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.gateway_operations:build_gateway_operations` | `limit` ([tool.daem0n_tools_search.argument.limit]), `query`* ([tool.daem0n_tools_search.argument.query]) | Enabled supported profile completes production stdio and HTTP calls of daem0n_tools_search; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `decision_debate` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.intelligence_operations:build_intelligence_operations` | `advocate_position`* ([tool.decision_debate.argument.advocate_position]), `challenger_position`* ([tool.decision_debate.argument.challenger_position]), `idempotency_key`* ([tool.decision_debate.argument.idempotency_key]), `max_rounds` ([tool.decision_debate.argument.max_rounds]), `preflight_token` ([tool.decision_debate.argument.preflight_token]), `topic`* ([tool.decision_debate.argument.topic]), `workspace_id`* ([tool.decision_debate.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of decision_debate; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `decision_simulate` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.intelligence_operations:build_intelligence_operations` | `as_of_transaction_time` ([tool.decision_simulate.argument.as_of_transaction_time]), `record_id`* ([tool.decision_simulate.argument.record_id]), `workspace_id`* ([tool.decision_simulate.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of decision_simulate; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `document_ingest_url` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.external_operations:build_external_operations` | `chunk_size` ([tool.document_ingest_url.argument.chunk_size]), `idempotency_key`* ([tool.document_ingest_url.argument.idempotency_key]), `preflight_token`* ([tool.document_ingest_url.argument.preflight_token]), `topic`* ([tool.document_ingest_url.argument.topic]), `url`* ([tool.document_ingest_url.argument.url]), `workspace_id`* ([tool.document_ingest_url.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of document_ingest_url; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `document_ingest_url` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.external_operations:build_external_operations` | `chunk_size` ([tool.document_ingest_url.argument.chunk_size]), `idempotency_key`* ([tool.document_ingest_url.argument.idempotency_key]), `preflight_token` ([tool.document_ingest_url.argument.preflight_token]), `topic`* ([tool.document_ingest_url.argument.topic]), `url`* ([tool.document_ingest_url.argument.url]), `workspace_id`* ([tool.document_ingest_url.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of document_ingest_url; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `dream_duplicates_preview` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.maintenance_operations:build_maintenance_operations` | `workspace_id`* ([tool.dream_duplicates_preview.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of dream_duplicates_preview; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `dream_duplicates_purge` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.maintenance_operations:build_maintenance_operations` | `preflight_token`* ([tool.dream_duplicates_purge.argument.preflight_token]), `selection_token`* ([tool.dream_duplicates_purge.argument.selection_token]), `workspace_id`* ([tool.dream_duplicates_purge.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of dream_duplicates_purge; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `dream_duplicates_purge` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.maintenance_operations:build_maintenance_operations` | `preflight_token` ([tool.dream_duplicates_purge.argument.preflight_token]), `selection_token`* ([tool.dream_duplicates_purge.argument.selection_token]), `workspace_id`* ([tool.dream_duplicates_purge.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of dream_duplicates_purge; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `edit_preflight` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.edit_capture_operations:build_edit_capture_operations` | `description`* ([tool.edit_preflight.argument.description]), `edit_request_id`* ([tool.edit_preflight.argument.edit_request_id]), `workspace_id`* ([tool.edit_preflight.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of edit_preflight; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `entity_backfill` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.graph_operations:build_graph_operations` | `force` ([tool.entity_backfill.argument.force]), `idempotency_key`* ([tool.entity_backfill.argument.idempotency_key]), `preflight_token`* ([tool.entity_backfill.argument.preflight_token]), `workspace_id`* ([tool.entity_backfill.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of entity_backfill; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `entity_backfill` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.graph_operations:build_graph_operations` | `force` ([tool.entity_backfill.argument.force]), `idempotency_key`* ([tool.entity_backfill.argument.idempotency_key]), `preflight_token` ([tool.entity_backfill.argument.preflight_token]), `workspace_id`* ([tool.entity_backfill.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of entity_backfill; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `entity_evolution_trace` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.code_entity_operations:build_code_entity_operations` | `entity_id` ([tool.entity_evolution_trace.argument.entity_id]), `entity_name` ([tool.entity_evolution_trace.argument.entity_name]), `entity_type` ([tool.entity_evolution_trace.argument.entity_type]), `include_invalidated` ([tool.entity_evolution_trace.argument.include_invalidated]), `workspace_id`* ([tool.entity_evolution_trace.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of entity_evolution_trace; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `entity_list` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.discovery_operations:build_discovery_operations` | `cursor` ([tool.entity_list.argument.cursor]), `entity_type` ([tool.entity_list.argument.entity_type]), `limit` ([tool.entity_list.argument.limit]), `workspace_id`* ([tool.entity_list.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of entity_list; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `knowledge_graph_get` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.relationship_operations:build_relationship_operations` | `include_orphans` ([tool.knowledge_graph_get.argument.include_orphans]), `max_nodes` ([tool.knowledge_graph_get.argument.max_nodes]), `query` ([tool.knowledge_graph_get.argument.query]), `record_ids` ([tool.knowledge_graph_get.argument.record_ids]), `workspace_id`* ([tool.knowledge_graph_get.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of knowledge_graph_get; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `knowledge_graph_render` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.relationship_operations:build_relationship_operations` | `format` ([tool.knowledge_graph_render.argument.format]), `include_orphans` ([tool.knowledge_graph_render.argument.include_orphans]), `max_nodes` ([tool.knowledge_graph_render.argument.max_nodes]), `query` ([tool.knowledge_graph_render.argument.query]), `record_ids` ([tool.knowledge_graph_render.argument.record_ids]), `workspace_id`* ([tool.knowledge_graph_render.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of knowledge_graph_render; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `knowledge_graph_stats` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.discovery_operations:build_discovery_operations` | `workspace_id`* ([tool.knowledge_graph_stats.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of knowledge_graph_stats; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `memory_archive_set` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.record_operations:build_record_operations` | `archived`* ([tool.memory_archive_set.argument.archived]), `preflight_token`* ([tool.memory_archive_set.argument.preflight_token]), `record_id`* ([tool.memory_archive_set.argument.record_id]), `workspace_id`* ([tool.memory_archive_set.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_archive_set; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `memory_archive_set` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.record_operations:build_record_operations` | `archived`* ([tool.memory_archive_set.argument.archived]), `preflight_token` ([tool.memory_archive_set.argument.preflight_token]), `record_id`* ([tool.memory_archive_set.argument.record_id]), `workspace_id`* ([tool.memory_archive_set.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_archive_set; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `memory_at_time_get` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.operations:build_core_operations` | `record_id`* ([tool.memory_at_time_get.argument.record_id]), `transaction_time` ([tool.memory_at_time_get.argument.transaction_time]), `valid_time`* ([tool.memory_at_time_get.argument.valid_time]), `workspace_id`* ([tool.memory_at_time_get.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_at_time_get; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `memory_capture_list` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.edit_capture_operations:build_edit_capture_operations` | `cursor` ([tool.memory_capture_list.argument.cursor]), `limit` ([tool.memory_capture_list.argument.limit]), `workspace_id`* ([tool.memory_capture_list.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_capture_list; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `memory_capture_promote` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.edit_capture_operations:build_edit_capture_operations` | `candidate_id`* ([tool.memory_capture_promote.argument.candidate_id]), `content`* ([tool.memory_capture_promote.argument.content]), `context` ([tool.memory_capture_promote.argument.context]), `idempotency_key`* ([tool.memory_capture_promote.argument.idempotency_key]), `preflight_token`* ([tool.memory_capture_promote.argument.preflight_token]), `rationale` ([tool.memory_capture_promote.argument.rationale]), `record_type`* ([tool.memory_capture_promote.argument.record_type]), `tags` ([tool.memory_capture_promote.argument.tags]), `workspace_id`* ([tool.memory_capture_promote.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_capture_promote; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `memory_capture_promote` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.edit_capture_operations:build_edit_capture_operations` | `candidate_id`* ([tool.memory_capture_promote.argument.candidate_id]), `content`* ([tool.memory_capture_promote.argument.content]), `context` ([tool.memory_capture_promote.argument.context]), `idempotency_key`* ([tool.memory_capture_promote.argument.idempotency_key]), `preflight_token` ([tool.memory_capture_promote.argument.preflight_token]), `rationale` ([tool.memory_capture_promote.argument.rationale]), `record_type`* ([tool.memory_capture_promote.argument.record_type]), `tags` ([tool.memory_capture_promote.argument.tags]), `workspace_id`* ([tool.memory_capture_promote.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_capture_promote; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `memory_chain_trace` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.relationship_operations:build_relationship_operations` | `end_record_id`* ([tool.memory_chain_trace.argument.end_record_id]), `max_depth` ([tool.memory_chain_trace.argument.max_depth]), `start_record_id`* ([tool.memory_chain_trace.argument.start_record_id]), `workspace_id`* ([tool.memory_chain_trace.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_chain_trace; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `memory_compact` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.maintenance_operations:build_maintenance_operations` | `idempotency_key`* ([tool.memory_compact.argument.idempotency_key]), `limit` ([tool.memory_compact.argument.limit]), `preflight_token`* ([tool.memory_compact.argument.preflight_token]), `query` ([tool.memory_compact.argument.query]), `selection_token`* ([tool.memory_compact.argument.selection_token]), `summary`* ([tool.memory_compact.argument.summary]), `workspace_id`* ([tool.memory_compact.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_compact; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `memory_compact` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.maintenance_operations:build_maintenance_operations` | `idempotency_key`* ([tool.memory_compact.argument.idempotency_key]), `limit` ([tool.memory_compact.argument.limit]), `preflight_token` ([tool.memory_compact.argument.preflight_token]), `query` ([tool.memory_compact.argument.query]), `selection_token`* ([tool.memory_compact.argument.selection_token]), `summary`* ([tool.memory_compact.argument.summary]), `workspace_id`* ([tool.memory_compact.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_compact; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `memory_compaction_preview` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.maintenance_operations:build_maintenance_operations` | `limit` ([tool.memory_compaction_preview.argument.limit]), `query` ([tool.memory_compaction_preview.argument.query]), `summary`* ([tool.memory_compaction_preview.argument.summary]), `workspace_id`* ([tool.memory_compaction_preview.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_compaction_preview; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `memory_duplicates_cleanup` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.maintenance_operations:build_maintenance_operations` | `merge_duplicates` ([tool.memory_duplicates_cleanup.argument.merge_duplicates]), `preflight_token`* ([tool.memory_duplicates_cleanup.argument.preflight_token]), `selection_token`* ([tool.memory_duplicates_cleanup.argument.selection_token]), `workspace_id`* ([tool.memory_duplicates_cleanup.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_duplicates_cleanup; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `memory_duplicates_cleanup` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.maintenance_operations:build_maintenance_operations` | `merge_duplicates` ([tool.memory_duplicates_cleanup.argument.merge_duplicates]), `preflight_token` ([tool.memory_duplicates_cleanup.argument.preflight_token]), `selection_token`* ([tool.memory_duplicates_cleanup.argument.selection_token]), `workspace_id`* ([tool.memory_duplicates_cleanup.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_duplicates_cleanup; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `memory_duplicates_preview` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.maintenance_operations:build_maintenance_operations` | `merge_duplicates` ([tool.memory_duplicates_preview.argument.merge_duplicates]), `workspace_id`* ([tool.memory_duplicates_preview.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_duplicates_preview; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `memory_link` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.relationship_operations:build_relationship_operations` | `confidence` ([tool.memory_link.argument.confidence]), `description` ([tool.memory_link.argument.description]), `idempotency_key`* ([tool.memory_link.argument.idempotency_key]), `preflight_token`* ([tool.memory_link.argument.preflight_token]), `relationship_type`* ([tool.memory_link.argument.relationship_type]), `source_record_id`* ([tool.memory_link.argument.source_record_id]), `target_record_id`* ([tool.memory_link.argument.target_record_id]), `workspace_id`* ([tool.memory_link.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_link; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `memory_pin_set` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.record_operations:build_record_operations` | `pinned`* ([tool.memory_pin_set.argument.pinned]), `preflight_token`* ([tool.memory_pin_set.argument.preflight_token]), `record_id`* ([tool.memory_pin_set.argument.record_id]), `workspace_id`* ([tool.memory_pin_set.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_pin_set; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `memory_link` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.relationship_operations:build_relationship_operations` | `confidence` ([tool.memory_link.argument.confidence]), `description` ([tool.memory_link.argument.description]), `idempotency_key`* ([tool.memory_link.argument.idempotency_key]), `preflight_token` ([tool.memory_link.argument.preflight_token]), `relationship_type`* ([tool.memory_link.argument.relationship_type]), `source_record_id`* ([tool.memory_link.argument.source_record_id]), `target_record_id`* ([tool.memory_link.argument.target_record_id]), `workspace_id`* ([tool.memory_link.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_link; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `memory_pin_set` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.record_operations:build_record_operations` | `pinned`* ([tool.memory_pin_set.argument.pinned]), `preflight_token` ([tool.memory_pin_set.argument.preflight_token]), `record_id`* ([tool.memory_pin_set.argument.record_id]), `workspace_id`* ([tool.memory_pin_set.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_pin_set; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `memory_preflight` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.pinned:build_pinned_handlers` | `description` ([tool.memory_preflight.argument.description]), `target_arguments`* ([tool.memory_preflight.argument.target_arguments]), `target_tool`* ([tool.memory_preflight.argument.target_tool]), `workspace_id`* ([tool.memory_preflight.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_preflight; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `memory_prune` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.maintenance_operations:build_maintenance_operations` | `categories` ([tool.memory_prune.argument.categories]), `min_recall_count` ([tool.memory_prune.argument.min_recall_count]), `older_than_days` ([tool.memory_prune.argument.older_than_days]), `preflight_token`* ([tool.memory_prune.argument.preflight_token]), `protect_successful` ([tool.memory_prune.argument.protect_successful]), `selection_token`* ([tool.memory_prune.argument.selection_token]), `workspace_id`* ([tool.memory_prune.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_prune; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `memory_prune` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.maintenance_operations:build_maintenance_operations` | `categories` ([tool.memory_prune.argument.categories]), `min_recall_count` ([tool.memory_prune.argument.min_recall_count]), `older_than_days` ([tool.memory_prune.argument.older_than_days]), `preflight_token` ([tool.memory_prune.argument.preflight_token]), `protect_successful` ([tool.memory_prune.argument.protect_successful]), `selection_token`* ([tool.memory_prune.argument.selection_token]), `workspace_id`* ([tool.memory_prune.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_prune; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `memory_prune_preview` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.maintenance_operations:build_maintenance_operations` | `categories` ([tool.memory_prune_preview.argument.categories]), `min_recall_count` ([tool.memory_prune_preview.argument.min_recall_count]), `older_than_days` ([tool.memory_prune_preview.argument.older_than_days]), `protect_successful` ([tool.memory_prune_preview.argument.protect_successful]), `workspace_id`* ([tool.memory_prune_preview.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_prune_preview; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `memory_recall` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.pinned:build_pinned_handlers` | `as_of_transaction_time` ([tool.memory_recall.argument.as_of_transaction_time]), `as_of_valid_time` ([tool.memory_recall.argument.as_of_valid_time]), `candidate_limit` ([tool.memory_recall.argument.candidate_limit]), `categories` ([tool.memory_recall.argument.categories]), `include_archived` ([tool.memory_recall.argument.include_archived]), `include_invalidated` ([tool.memory_recall.argument.include_invalidated]), `limit` ([tool.memory_recall.argument.limit]), `linked_workspace_ids` ([tool.memory_recall.argument.linked_workspace_ids]), `query`* ([tool.memory_recall.argument.query]), `record_ids` ([tool.memory_recall.argument.record_ids]), `rerank` ([tool.memory_recall.argument.rerank]), `tags` ([tool.memory_recall.argument.tags]), `token_budget` ([tool.memory_recall.argument.token_budget]), `workspace_id`* ([tool.memory_recall.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_recall; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `memory_recall` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.pinned:build_pinned_handlers` | `as_of_transaction_time` ([tool.memory_recall.argument.as_of_transaction_time]), `as_of_valid_time` ([tool.memory_recall.argument.as_of_valid_time]), `candidate_limit` ([tool.memory_recall.argument.candidate_limit]), `categories` ([tool.memory_recall.argument.categories]), `include_archived` ([tool.memory_recall.argument.include_archived]), `include_invalidated` ([tool.memory_recall.argument.include_invalidated]), `intent` ([tool.memory_recall.argument.intent]), `limit` ([tool.memory_recall.argument.limit]), `linked_workspace_ids` ([tool.memory_recall.argument.linked_workspace_ids]), `query`* ([tool.memory_recall.argument.query]), `record_ids` ([tool.memory_recall.argument.record_ids]), `rerank` ([tool.memory_recall.argument.rerank]), `tags` ([tool.memory_recall.argument.tags]), `token_budget` ([tool.memory_recall.argument.token_budget]), `workspace_id`* ([tool.memory_recall.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_recall; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `memory_recall_entity` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.discovery_operations:build_discovery_operations` | `cursor` ([tool.memory_recall_entity.argument.cursor]), `entity_id` ([tool.memory_recall_entity.argument.entity_id]), `entity_name` ([tool.memory_recall_entity.argument.entity_name]), `entity_type` ([tool.memory_recall_entity.argument.entity_type]), `limit` ([tool.memory_recall_entity.argument.limit]), `workspace_id`* ([tool.memory_recall_entity.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_recall_entity; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `memory_recall_file` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.record_operations:build_record_operations` | `cursor` ([tool.memory_recall_file.argument.cursor]), `limit` ([tool.memory_recall_file.argument.limit]), `relative_file_path`* ([tool.memory_recall_file.argument.relative_file_path]), `workspace_id`* ([tool.memory_recall_file.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_recall_file; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `memory_recall_hierarchical` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.intelligence_operations:build_intelligence_operations` | `include_members` ([tool.memory_recall_hierarchical.argument.include_members]), `limit` ([tool.memory_recall_hierarchical.argument.limit]), `query`* ([tool.memory_recall_hierarchical.argument.query]), `workspace_id`* ([tool.memory_recall_hierarchical.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_recall_hierarchical; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `memory_record_outcome` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.pinned:build_pinned_handlers` | `happened_at` ([tool.memory_record_outcome.argument.happened_at]), `idempotency_key`* ([tool.memory_record_outcome.argument.idempotency_key]), `outcome_text`* ([tool.memory_record_outcome.argument.outcome_text]), `record_id`* ([tool.memory_record_outcome.argument.record_id]), `worked`* ([tool.memory_record_outcome.argument.worked]), `workspace_id`* ([tool.memory_record_outcome.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_record_outcome; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `memory_record_outcome` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.pinned:build_pinned_handlers` | `happened_at` ([tool.memory_record_outcome.argument.happened_at]), `idempotency_key`* ([tool.memory_record_outcome.argument.idempotency_key]), `informed_by` ([tool.memory_record_outcome.argument.informed_by]), `outcome_text`* ([tool.memory_record_outcome.argument.outcome_text]), `rebind_code` ([tool.memory_record_outcome.argument.rebind_code]), `record_id`* ([tool.memory_record_outcome.argument.record_id]), `verification` ([tool.memory_record_outcome.argument.verification]), `worked`* ([tool.memory_record_outcome.argument.worked]), `workspace_id`* ([tool.memory_record_outcome.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_record_outcome; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `memory_related` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.relationship_operations:build_relationship_operations` | `direction` ([tool.memory_related.argument.direction]), `max_depth` ([tool.memory_related.argument.max_depth]), `record_id`* ([tool.memory_related.argument.record_id]), `relationship_types` ([tool.memory_related.argument.relationship_types]), `workspace_id`* ([tool.memory_related.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_related; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `memory_search_text` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.record_operations:build_record_operations` | `cursor` ([tool.memory_search_text.argument.cursor]), `highlight` ([tool.memory_search_text.argument.highlight]), `include_metadata` ([tool.memory_search_text.argument.include_metadata]), `limit` ([tool.memory_search_text.argument.limit]), `query`* ([tool.memory_search_text.argument.query]), `workspace_id`* ([tool.memory_search_text.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_search_text; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `memory_store` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.pinned:build_pinned_handlers` | `content`* ([tool.memory_store.argument.content]), `context` ([tool.memory_store.argument.context]), `happened_at` ([tool.memory_store.argument.happened_at]), `idempotency_key`* ([tool.memory_store.argument.idempotency_key]), `preflight_token`* ([tool.memory_store.argument.preflight_token]), `procedure_steps` ([tool.memory_store.argument.procedure_steps]), `rationale` ([tool.memory_store.argument.rationale]), `record_type`* ([tool.memory_store.argument.record_type]), `relative_file_path` ([tool.memory_store.argument.relative_file_path]), `tags` ([tool.memory_store.argument.tags]), `workspace_id`* ([tool.memory_store.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_store; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `memory_store_batch` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.record_operations:build_record_operations` | `idempotency_key`* ([tool.memory_store_batch.argument.idempotency_key]), `preflight_token`* ([tool.memory_store_batch.argument.preflight_token]), `records`* ([tool.memory_store_batch.argument.records]), `workspace_id`* ([tool.memory_store_batch.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_store_batch; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `memory_unlink` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.relationship_operations:build_relationship_operations` | `preflight_token`* ([tool.memory_unlink.argument.preflight_token]), `relationship_id`* ([tool.memory_unlink.argument.relationship_id]), `workspace_id`* ([tool.memory_unlink.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_unlink; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `memory_store` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.pinned:build_pinned_handlers` | `code_refs` ([tool.memory_store.argument.code_refs]), `content`* ([tool.memory_store.argument.content]), `context` ([tool.memory_store.argument.context]), `happened_at` ([tool.memory_store.argument.happened_at]), `idempotency_key`* ([tool.memory_store.argument.idempotency_key]), `informed_by` ([tool.memory_store.argument.informed_by]), `preflight_token` ([tool.memory_store.argument.preflight_token]), `procedure_steps` ([tool.memory_store.argument.procedure_steps]), `rationale` ([tool.memory_store.argument.rationale]), `record_type`* ([tool.memory_store.argument.record_type]), `relative_file_path` ([tool.memory_store.argument.relative_file_path]), `tags` ([tool.memory_store.argument.tags]), `workspace_id`* ([tool.memory_store.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_store; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `memory_store_batch` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.record_operations:build_record_operations` | `idempotency_key`* ([tool.memory_store_batch.argument.idempotency_key]), `preflight_token` ([tool.memory_store_batch.argument.preflight_token]), `records`* ([tool.memory_store_batch.argument.records]), `workspace_id`* ([tool.memory_store_batch.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_store_batch; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `memory_unlink` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.relationship_operations:build_relationship_operations` | `preflight_token` ([tool.memory_unlink.argument.preflight_token]), `relationship_id`* ([tool.memory_unlink.argument.relationship_id]), `workspace_id`* ([tool.memory_unlink.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_unlink; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `memory_verify` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.intelligence_operations:build_intelligence_operations` | `as_of_transaction_time` ([tool.memory_verify.argument.as_of_transaction_time]), `as_of_valid_time` ([tool.memory_verify.argument.as_of_valid_time]), `categories` ([tool.memory_verify.argument.categories]), `text`* ([tool.memory_verify.argument.text]), `workspace_id`* ([tool.memory_verify.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_verify; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `memory_versions_list` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.operations:build_core_operations` | `cursor` ([tool.memory_versions_list.argument.cursor]), `limit` ([tool.memory_versions_list.argument.limit]), `record_id`* ([tool.memory_versions_list.argument.record_id]), `workspace_id`* ([tool.memory_versions_list.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of memory_versions_list; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `projection_rebuild` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.operations:build_core_operations` | `force` ([tool.projection_rebuild.argument.force]), `projection`* ([tool.projection_rebuild.argument.projection]), `workspace_id`* ([tool.projection_rebuild.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of projection_rebuild; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `rule_check` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.rule_trigger_operations:build_rule_trigger_operations` | `context` ([tool.rule_check.argument.context]), `proposed_action`* ([tool.rule_check.argument.proposed_action]), `workspace_id`* ([tool.rule_check.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of rule_check; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `rule_create` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.rule_trigger_operations:build_rule_trigger_operations` | `ask_first` ([tool.rule_create.argument.ask_first]), `idempotency_key`* ([tool.rule_create.argument.idempotency_key]), `must_do` ([tool.rule_create.argument.must_do]), `must_not` ([tool.rule_create.argument.must_not]), `preflight_token`* ([tool.rule_create.argument.preflight_token]), `priority` ([tool.rule_create.argument.priority]), `trigger`* ([tool.rule_create.argument.trigger]), `warnings` ([tool.rule_create.argument.warnings]), `workspace_id`* ([tool.rule_create.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of rule_create; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `rule_create` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.rule_trigger_operations:build_rule_trigger_operations` | `ask_first` ([tool.rule_create.argument.ask_first]), `idempotency_key`* ([tool.rule_create.argument.idempotency_key]), `must_do` ([tool.rule_create.argument.must_do]), `must_not` ([tool.rule_create.argument.must_not]), `preflight_token` ([tool.rule_create.argument.preflight_token]), `priority` ([tool.rule_create.argument.priority]), `trigger`* ([tool.rule_create.argument.trigger]), `warnings` ([tool.rule_create.argument.warnings]), `workspace_id`* ([tool.rule_create.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of rule_create; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `rule_evolution_analyze` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.intelligence_operations:build_intelligence_operations` | `rule_id` ([tool.rule_evolution_analyze.argument.rule_id]), `workspace_id`* ([tool.rule_evolution_analyze.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of rule_evolution_analyze; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `rule_list` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.rule_trigger_operations:build_rule_trigger_operations` | `cursor` ([tool.rule_list.argument.cursor]), `enabled_only` ([tool.rule_list.argument.enabled_only]), `limit` ([tool.rule_list.argument.limit]), `workspace_id`* ([tool.rule_list.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of rule_list; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `rule_update` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.rule_trigger_operations:build_rule_trigger_operations` | `patch`* ([tool.rule_update.argument.patch]), `preflight_token`* ([tool.rule_update.argument.preflight_token]), `rule_id`* ([tool.rule_update.argument.rule_id]), `workspace_id`* ([tool.rule_update.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of rule_update; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `sandbox_execute_python` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.external_operations:build_external_operations` | `code`* ([tool.sandbox_execute_python.argument.code]), `preflight_token`* ([tool.sandbox_execute_python.argument.preflight_token]), `timeout_seconds` ([tool.sandbox_execute_python.argument.timeout_seconds]), `workspace_id`* ([tool.sandbox_execute_python.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of sandbox_execute_python; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `rule_update` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.rule_trigger_operations:build_rule_trigger_operations` | `patch`* ([tool.rule_update.argument.patch]), `preflight_token` ([tool.rule_update.argument.preflight_token]), `rule_id`* ([tool.rule_update.argument.rule_id]), `workspace_id`* ([tool.rule_update.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of rule_update; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `sandbox_execute_python` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.external_operations:build_external_operations` | `code`* ([tool.sandbox_execute_python.argument.code]), `preflight_token` ([tool.sandbox_execute_python.argument.preflight_token]), `timeout_seconds` ([tool.sandbox_execute_python.argument.timeout_seconds]), `workspace_id`* ([tool.sandbox_execute_python.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of sandbox_execute_python; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `session_brief` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.pinned:build_pinned_handlers` | `failure_limit` ([tool.session_brief.argument.failure_limit]), `focus_areas` ([tool.session_brief.argument.focus_areas]), `warning_limit` ([tool.session_brief.argument.warning_limit]), `workspace_id`* ([tool.session_brief.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of session_brief; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `session_updates_get` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.record_operations:build_record_operations` | `after_cursor` ([tool.session_updates_get.argument.after_cursor]), `since` ([tool.session_updates_get.argument.since]), `wait_seconds` ([tool.session_updates_get.argument.wait_seconds]), `workspace_id`* ([tool.session_updates_get.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of session_updates_get; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `system_health` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.pinned:build_pinned_handlers` | `include_components` ([tool.system_health.argument.include_components]), `workspace_id` ([tool.system_health.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of system_health; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `workspace_consolidate` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.consolidation_operations:build_consolidation_operations` | `idempotency_key`* ([tool.workspace_consolidate.argument.idempotency_key]), `preflight_token`* ([tool.workspace_consolidate.argument.preflight_token]), `selection_token`* ([tool.workspace_consolidate.argument.selection_token]), `source_workspace_ids`* ([tool.workspace_consolidate.argument.source_workspace_ids]), `workspace_id`* ([tool.workspace_consolidate.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of workspace_consolidate; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `workspace_consolidate_and_archive_sources` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.consolidation_operations:build_consolidation_operations` | `idempotency_key`* ([tool.workspace_consolidate_and_archive_sources.argument.idempotency_key]), `preflight_token`* ([tool.workspace_consolidate_and_archive_sources.argument.preflight_token]), `selection_token`* ([tool.workspace_consolidate_and_archive_sources.argument.selection_token]), `source_workspace_ids`* ([tool.workspace_consolidate_and_archive_sources.argument.source_workspace_ids]), `workspace_id`* ([tool.workspace_consolidate_and_archive_sources.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of workspace_consolidate_and_archive_sources; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `workspace_consolidate` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.consolidation_operations:build_consolidation_operations` | `idempotency_key`* ([tool.workspace_consolidate.argument.idempotency_key]), `preflight_token` ([tool.workspace_consolidate.argument.preflight_token]), `selection_token`* ([tool.workspace_consolidate.argument.selection_token]), `source_workspace_ids`* ([tool.workspace_consolidate.argument.source_workspace_ids]), `workspace_id`* ([tool.workspace_consolidate.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of workspace_consolidate; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `workspace_consolidate_and_archive_sources` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.consolidation_operations:build_consolidation_operations` | `idempotency_key`* ([tool.workspace_consolidate_and_archive_sources.argument.idempotency_key]), `preflight_token` ([tool.workspace_consolidate_and_archive_sources.argument.preflight_token]), `selection_token`* ([tool.workspace_consolidate_and_archive_sources.argument.selection_token]), `source_workspace_ids`* ([tool.workspace_consolidate_and_archive_sources.argument.source_workspace_ids]), `workspace_id`* ([tool.workspace_consolidate_and_archive_sources.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of workspace_consolidate_and_archive_sources; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `workspace_consolidation_preview` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.consolidation_operations:build_consolidation_operations` | `source_workspace_ids`* ([tool.workspace_consolidation_preview.argument.source_workspace_ids]), `workspace_id`* ([tool.workspace_consolidation_preview.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of workspace_consolidation_preview; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `workspace_export` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.operations:build_core_operations` | `cursor` ([tool.workspace_export.argument.cursor]), `export_session_id` ([tool.workspace_export.argument.export_session_id]), `include_legacy_projection` ([tool.workspace_export.argument.include_legacy_projection]), `include_vectors` ([tool.workspace_export.argument.include_vectors]), `page_byte_limit` ([tool.workspace_export.argument.page_byte_limit]), `page_index` ([tool.workspace_export.argument.page_index]), `workspace_id`* ([tool.workspace_export.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of workspace_export; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `workspace_import` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.operations:build_core_operations` | `bundle` ([tool.workspace_import.argument.bundle]), `finalize` ([tool.workspace_import.argument.finalize]), `idempotency_key`* ([tool.workspace_import.argument.idempotency_key]), `import_session_id` ([tool.workspace_import.argument.import_session_id]), `merge` ([tool.workspace_import.argument.merge]), `preflight_token`* ([tool.workspace_import.argument.preflight_token]), `workspace_id`* ([tool.workspace_import.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of workspace_import; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `workspace_link` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.federation_operations:build_federation_operations` | `label` ([tool.workspace_link.argument.label]), `linked_workspace_id`* ([tool.workspace_link.argument.linked_workspace_id]), `preflight_token`* ([tool.workspace_link.argument.preflight_token]), `relationship` ([tool.workspace_link.argument.relationship]), `workspace_id`* ([tool.workspace_link.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of workspace_link; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `workspace_import` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.operations:build_core_operations` | `bundle` ([tool.workspace_import.argument.bundle]), `finalize` ([tool.workspace_import.argument.finalize]), `idempotency_key`* ([tool.workspace_import.argument.idempotency_key]), `import_session_id` ([tool.workspace_import.argument.import_session_id]), `merge` ([tool.workspace_import.argument.merge]), `preflight_token` ([tool.workspace_import.argument.preflight_token]), `workspace_id`* ([tool.workspace_import.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of workspace_import; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `workspace_link` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.federation_operations:build_federation_operations` | `label` ([tool.workspace_link.argument.label]), `linked_workspace_id`* ([tool.workspace_link.argument.linked_workspace_id]), `preflight_token` ([tool.workspace_link.argument.preflight_token]), `relationship` ([tool.workspace_link.argument.relationship]), `workspace_id`* ([tool.workspace_link.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of workspace_link; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | | `workspace_links_list` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.federation_operations:build_federation_operations` | `cursor` ([tool.workspace_links_list.argument.cursor]), `limit` ([tool.workspace_links_list.argument.limit]), `workspace_id`* ([tool.workspace_links_list.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of workspace_links_list; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | -| `workspace_unlink` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.federation_operations:build_federation_operations` | `linked_workspace_id`* ([tool.workspace_unlink.argument.linked_workspace_id]), `preflight_token`* ([tool.workspace_unlink.argument.preflight_token]), `workspace_id`* ([tool.workspace_unlink.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of workspace_unlink; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | +| `workspace_unlink` | api/v7/production / `daem0nmcp.api.v7` | `daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.federation_operations:build_federation_operations` | `linked_workspace_id`* ([tool.workspace_unlink.argument.linked_workspace_id]), `preflight_token` ([tool.workspace_unlink.argument.preflight_token]), `workspace_id`* ([tool.workspace_unlink.argument.workspace_id]) | Enabled supported profile completes production stdio and HTTP calls of workspace_unlink; assert its documented success result and side effect. Disabled-profile remediation is tracked separately. — pending evidence | pending | ## Resources, hooks, CLI, and profiles @@ -154,6 +156,7 @@ Production entry point: `daem0nmcp.server:main`. Registered tools: **75**. Resou | dependency-profile | `apps` | `optional-dependency:apps` | Install profile apps and exercise its advertised capability. — pending evidence | pending | | dependency-profile | `dev` | `optional-dependency:dev` | Install profile dev and exercise its advertised capability. — pending evidence | pending | | dependency-profile | `graph` | `optional-dependency:graph` | Install profile graph and exercise its advertised capability. — pending evidence | pending | +| dependency-profile | `late-interaction` | `optional-dependency:late-interaction` | Install profile late-interaction and exercise its advertised capability. — pending evidence | pending | | dependency-profile | `local` | `optional-dependency:local` | Install profile local and exercise its advertised capability. — pending evidence | pending | | dependency-profile | `models-hosted` | `optional-dependency:models-hosted` | Install profile models-hosted and exercise its advertised capability. — pending evidence | pending | | dependency-profile | `models-local` | `optional-dependency:models-local` | Install profile models-local and exercise its advertised capability. — pending evidence | pending | diff --git a/docs/release/v7/requirements.json b/docs/release/v7/requirements.json index 8b452d4..dd69672 100644 --- a/docs/release/v7/requirements.json +++ b/docs/release/v7/requirements.json @@ -21,7 +21,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for active_context_add, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for active_context_add, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['active_context_add']", "evidence": [], "id": "tool.active_context_add.argument.preflight_token", @@ -81,7 +81,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for active_context_clear, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for active_context_clear, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['active_context_clear']", "evidence": [], "id": "tool.active_context_clear.argument.preflight_token", @@ -171,7 +171,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for active_context_remove, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for active_context_remove, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['active_context_remove']", "evidence": [], "id": "tool.active_context_remove.argument.preflight_token", @@ -491,7 +491,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for code_todos_scan_and_store, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for code_todos_scan_and_store, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['code_todos_scan_and_store']", "evidence": [], "id": "tool.code_todos_scan_and_store.argument.preflight_token", @@ -691,7 +691,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for community_rebuild, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for community_rebuild, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['community_rebuild']", "evidence": [], "id": "tool.community_rebuild.argument.preflight_token", @@ -831,7 +831,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for context_trigger_create, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for context_trigger_create, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['context_trigger_create']", "evidence": [], "id": "tool.context_trigger_create.argument.preflight_token", @@ -881,7 +881,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for context_trigger_delete, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for context_trigger_delete, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['context_trigger_delete']", "evidence": [], "id": "tool.context_trigger_delete.argument.preflight_token", @@ -1040,6 +1040,76 @@ "package": "daem0nmcp.api.v7.models", "status": "pending" }, + { + "acceptance": "Enabled supported profile completes production stdio and HTTP calls of daem0n_tool_call; assert its documented success result and side effect. Disabled-profile remediation is tracked separately.", + "entry_point": "daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.gateway_operations:build_gateway_operations", + "evidence": [], + "id": "tool.daem0n_tool_call.execute", + "kind": "tool-execution", + "owner": "api/v7/production", + "package": "daem0nmcp.api.v7", + "status": "pending" + }, + { + "acceptance": "Enabled supported profile exercises a present and omitted optional arguments branch for daem0n_tool_call, asserts the requested documented result or side effect, and rejects an invalid branch.", + "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['daem0n_tool_call']", + "evidence": [], + "id": "tool.daem0n_tool_call.argument.arguments", + "kind": "tool-argument", + "owner": "api/v7/production", + "package": "daem0nmcp.api.v7.models", + "status": "pending" + }, + { + "acceptance": "Enabled supported profile exercises a required tool branch for daem0n_tool_call, asserts the requested documented result or side effect, and rejects an invalid branch.", + "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['daem0n_tool_call']", + "evidence": [], + "id": "tool.daem0n_tool_call.argument.tool", + "kind": "tool-argument", + "owner": "api/v7/production", + "package": "daem0nmcp.api.v7.models", + "status": "pending" + }, + { + "acceptance": "Enabled supported profile exercises a required workspace_id branch for daem0n_tool_call, asserts the requested documented result or side effect, and rejects an invalid branch.", + "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['daem0n_tool_call']", + "evidence": [], + "id": "tool.daem0n_tool_call.argument.workspace_id", + "kind": "tool-argument", + "owner": "api/v7/production", + "package": "daem0nmcp.api.v7.models", + "status": "pending" + }, + { + "acceptance": "Enabled supported profile completes production stdio and HTTP calls of daem0n_tools_search; assert its documented success result and side effect. Disabled-profile remediation is tracked separately.", + "entry_point": "daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.gateway_operations:build_gateway_operations", + "evidence": [], + "id": "tool.daem0n_tools_search.execute", + "kind": "tool-execution", + "owner": "api/v7/production", + "package": "daem0nmcp.api.v7", + "status": "pending" + }, + { + "acceptance": "Enabled supported profile exercises a present and omitted optional limit branch for daem0n_tools_search, asserts the requested documented result or side effect, and rejects an invalid branch.", + "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['daem0n_tools_search']", + "evidence": [], + "id": "tool.daem0n_tools_search.argument.limit", + "kind": "tool-argument", + "owner": "api/v7/production", + "package": "daem0nmcp.api.v7.models", + "status": "pending" + }, + { + "acceptance": "Enabled supported profile exercises a required query branch for daem0n_tools_search, asserts the requested documented result or side effect, and rejects an invalid branch.", + "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['daem0n_tools_search']", + "evidence": [], + "id": "tool.daem0n_tools_search.argument.query", + "kind": "tool-argument", + "owner": "api/v7/production", + "package": "daem0nmcp.api.v7.models", + "status": "pending" + }, { "acceptance": "Enabled supported profile completes production stdio and HTTP calls of decision_debate; assert its documented success result and side effect. Disabled-profile remediation is tracked separately.", "entry_point": "daem0nmcp.api.v7.production:create_v7_server -> daem0nmcp.api.v7.intelligence_operations:build_intelligence_operations", @@ -1091,7 +1161,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for decision_debate, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for decision_debate, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['decision_debate']", "evidence": [], "id": "tool.decision_debate.argument.preflight_token", @@ -1191,7 +1261,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for document_ingest_url, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for document_ingest_url, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['document_ingest_url']", "evidence": [], "id": "tool.document_ingest_url.argument.preflight_token", @@ -1261,7 +1331,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for dream_duplicates_purge, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for dream_duplicates_purge, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['dream_duplicates_purge']", "evidence": [], "id": "tool.dream_duplicates_purge.argument.preflight_token", @@ -1361,7 +1431,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for entity_backfill, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for entity_backfill, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['entity_backfill']", "evidence": [], "id": "tool.entity_backfill.argument.preflight_token", @@ -1661,7 +1731,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for memory_archive_set, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for memory_archive_set, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_archive_set']", "evidence": [], "id": "tool.memory_archive_set.argument.preflight_token", @@ -1831,7 +1901,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for memory_capture_promote, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for memory_capture_promote, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_capture_promote']", "evidence": [], "id": "tool.memory_capture_promote.argument.preflight_token", @@ -1961,7 +2031,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for memory_compact, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for memory_compact, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_compact']", "evidence": [], "id": "tool.memory_compact.argument.preflight_token", @@ -2081,7 +2151,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for memory_duplicates_cleanup, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for memory_duplicates_cleanup, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_duplicates_cleanup']", "evidence": [], "id": "tool.memory_duplicates_cleanup.argument.preflight_token", @@ -2181,7 +2251,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for memory_link, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for memory_link, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_link']", "evidence": [], "id": "tool.memory_link.argument.preflight_token", @@ -2251,7 +2321,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for memory_pin_set, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for memory_pin_set, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_pin_set']", "evidence": [], "id": "tool.memory_pin_set.argument.preflight_token", @@ -2371,7 +2441,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for memory_prune, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for memory_prune, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_prune']", "evidence": [], "id": "tool.memory_prune.argument.preflight_token", @@ -2540,6 +2610,16 @@ "package": "daem0nmcp.api.v7.models", "status": "pending" }, + { + "acceptance": "Enabled supported profile exercises a present and omitted optional intent branch for memory_recall, asserts the requested documented result or side effect, and rejects an invalid branch.", + "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_recall']", + "evidence": [], + "id": "tool.memory_recall.argument.intent", + "kind": "tool-argument", + "owner": "api/v7/production", + "package": "daem0nmcp.api.v7.models", + "status": "pending" + }, { "acceptance": "Enabled supported profile exercises a present and omitted optional limit branch for memory_recall, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_recall']", @@ -2820,6 +2900,16 @@ "package": "daem0nmcp.api.v7.models", "status": "pending" }, + { + "acceptance": "Enabled supported profile exercises a present and omitted optional informed_by branch for memory_record_outcome, asserts the requested documented result or side effect, and rejects an invalid branch.", + "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_record_outcome']", + "evidence": [], + "id": "tool.memory_record_outcome.argument.informed_by", + "kind": "tool-argument", + "owner": "api/v7/production", + "package": "daem0nmcp.api.v7.models", + "status": "pending" + }, { "acceptance": "Enabled supported profile exercises a required outcome_text branch for memory_record_outcome, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_record_outcome']", @@ -2830,6 +2920,16 @@ "package": "daem0nmcp.api.v7.models", "status": "pending" }, + { + "acceptance": "Enabled supported profile exercises a present and omitted optional rebind_code branch for memory_record_outcome, asserts the requested documented result or side effect, and rejects an invalid branch.", + "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_record_outcome']", + "evidence": [], + "id": "tool.memory_record_outcome.argument.rebind_code", + "kind": "tool-argument", + "owner": "api/v7/production", + "package": "daem0nmcp.api.v7.models", + "status": "pending" + }, { "acceptance": "Enabled supported profile exercises a required record_id branch for memory_record_outcome, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_record_outcome']", @@ -2840,6 +2940,16 @@ "package": "daem0nmcp.api.v7.models", "status": "pending" }, + { + "acceptance": "Enabled supported profile exercises a present and omitted optional verification branch for memory_record_outcome, asserts the requested documented result or side effect, and rejects an invalid branch.", + "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_record_outcome']", + "evidence": [], + "id": "tool.memory_record_outcome.argument.verification", + "kind": "tool-argument", + "owner": "api/v7/production", + "package": "daem0nmcp.api.v7.models", + "status": "pending" + }, { "acceptance": "Enabled supported profile exercises a required worked branch for memory_record_outcome, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_record_outcome']", @@ -3000,6 +3110,16 @@ "package": "daem0nmcp.api.v7", "status": "pending" }, + { + "acceptance": "Enabled supported profile exercises a present and omitted optional code_refs branch for memory_store, asserts the requested documented result or side effect, and rejects an invalid branch.", + "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_store']", + "evidence": [], + "id": "tool.memory_store.argument.code_refs", + "kind": "tool-argument", + "owner": "api/v7/production", + "package": "daem0nmcp.api.v7.models", + "status": "pending" + }, { "acceptance": "Enabled supported profile exercises a required content branch for memory_store, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_store']", @@ -3041,7 +3161,17 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for memory_store, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional informed_by branch for memory_store, asserts the requested documented result or side effect, and rejects an invalid branch.", + "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_store']", + "evidence": [], + "id": "tool.memory_store.argument.informed_by", + "kind": "tool-argument", + "owner": "api/v7/production", + "package": "daem0nmcp.api.v7.models", + "status": "pending" + }, + { + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for memory_store, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_store']", "evidence": [], "id": "tool.memory_store.argument.preflight_token", @@ -3131,7 +3261,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for memory_store_batch, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for memory_store_batch, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_store_batch']", "evidence": [], "id": "tool.memory_store_batch.argument.preflight_token", @@ -3171,7 +3301,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for memory_unlink, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for memory_unlink, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['memory_unlink']", "evidence": [], "id": "tool.memory_unlink.argument.preflight_token", @@ -3441,7 +3571,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for rule_create, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for rule_create, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['rule_create']", "evidence": [], "id": "tool.rule_create.argument.preflight_token", @@ -3591,7 +3721,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for rule_update, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for rule_update, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['rule_update']", "evidence": [], "id": "tool.rule_update.argument.preflight_token", @@ -3641,7 +3771,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for sandbox_execute_python, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for sandbox_execute_python, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['sandbox_execute_python']", "evidence": [], "id": "tool.sandbox_execute_python.argument.preflight_token", @@ -3821,7 +3951,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for workspace_consolidate, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for workspace_consolidate, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['workspace_consolidate']", "evidence": [], "id": "tool.workspace_consolidate.argument.preflight_token", @@ -3881,7 +4011,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for workspace_consolidate_and_archive_sources, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for workspace_consolidate_and_archive_sources, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['workspace_consolidate_and_archive_sources']", "evidence": [], "id": "tool.workspace_consolidate_and_archive_sources.argument.preflight_token", @@ -4091,7 +4221,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for workspace_import, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for workspace_import, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['workspace_import']", "evidence": [], "id": "tool.workspace_import.argument.preflight_token", @@ -4141,7 +4271,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for workspace_link, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for workspace_link, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['workspace_link']", "evidence": [], "id": "tool.workspace_link.argument.preflight_token", @@ -4231,7 +4361,7 @@ "status": "pending" }, { - "acceptance": "Enabled supported profile exercises a required preflight_token branch for workspace_unlink, asserts the requested documented result or side effect, and rejects an invalid branch.", + "acceptance": "Enabled supported profile exercises a present and omitted optional preflight_token branch for workspace_unlink, asserts the requested documented result or side effect, and rejects an invalid branch.", "entry_point": "daem0nmcp.api.v7.tools:TOOL_INPUT_MODELS['workspace_unlink']", "evidence": [], "id": "tool.workspace_unlink.argument.preflight_token", @@ -4600,6 +4730,16 @@ "package": "pyproject.toml", "status": "pending" }, + { + "acceptance": "Install profile late-interaction and exercise its advertised capability.", + "entry_point": "optional-dependency:late-interaction", + "evidence": [], + "id": "profile.late-interaction", + "kind": "dependency-profile", + "owner": "packaging", + "package": "pyproject.toml", + "status": "pending" + }, { "acceptance": "Install profile local and exercise its advertised capability.", "entry_point": "optional-dependency:local", @@ -5728,6 +5868,7 @@ "apps", "dev", "graph", + "late-interaction", "local", "models-hosted", "models-local", @@ -5754,11 +5895,11 @@ "tests/test_legacy_vector_migration_guards.py" ], "observed_surface": { - "atomic_requirements": 572, + "atomic_requirements": 586, "cli_commands": 21, - "dependency_profiles": 10, + "dependency_profiles": 11, "hooks": 15, - "registered_tools": 75, + "registered_tools": 77, "resource_templates": 10 }, "release": "v7", diff --git a/docs/v7-operations.md b/docs/v7-operations.md index b344dfa..a4ee8f2 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -3,8 +3,9 @@ This guide describes the current development surface. It is not a release certificate: the P0–P10 ledger remains pending in [the release inventory](release/v7/inventory.md). The source of truth for -input and output shapes is the live MCP `tools/list` schema; the inventory is -generated with `python scripts/v7_release_inventory.py --write`. +input and output shapes is the MCP schema: use `daem0n_tools_search` for hidden +tools, or set `DAEM0NMCP_TOOL_SURFACE=full` for a complete `tools/list`. The +inventory is generated with `python scripts/v7_release_inventory.py --write`. ## Install and configure the server @@ -107,20 +108,43 @@ strict JSON body limits, and origin policy as three HTTP middleware layers. The six pinned tools are a startup and diagnostics set, not the whole schema: `session_brief`, `memory_preflight`, `memory_recall`, `memory_store`, -`memory_record_outcome`, and `system_health`. They belong to the full 75-tool -registry shown below. +`memory_record_outcome`, and `system_health`. They belong to the full 77-tool +registry shown below. By default, nine tools are listed: these six, +`edit_preflight`, `daem0n_tools_search`, and `daem0n_tool_call`. All registered +tools remain callable; `DAEM0NMCP_TOOL_SURFACE=full` lists every tool. -Start with the workspace identifier supplied by the operator as described above: +The first gated call briefs automatically and returns the compact brief in +`meta.covenant.auto_brief`. Call `session_brief` for a full or focused brief, +using the workspace identifier supplied by the operator: ```text session_brief(workspace_id="ws_", focus_areas=["authentication"]) memory_recall(workspace_id="ws_", query="authentication", limit=10) ``` -For a protected write, preflight matches the normalized target arguments -exactly. Do not include `workspace_id` or `preflight_token` inside -`target_arguments`; do include every other supplied argument, including the -stable idempotency key. +In the default guided mode, call a protected tool directly: + +```text +memory_store( + workspace_id="ws_", + record_type="decision", + content="Use signed session cookies", + rationale="Avoid server-side session state", + idempotency_key="decision-auth-cookie-0001" +) +``` + +Benign calls proceed with inline counsel. If the response is +`COUNSEL_REQUIRED`, read `error.counsel` and retry exactly `error.remedy`. +Relevant risk and every destructive operation require this explicit retry. +Discover other capabilities with `daem0n_tools_search(query)` and invoke them +with `daem0n_tool_call(workspace_id, tool, arguments)`. + +`DAEM0NMCP_COVENANT_MODE=strict` restores explicit briefing and preflight. +`memory_preflight` also remains available for advance planning in guided mode. +Preflight matches the normalized target arguments exactly. Do not include +`workspace_id` or `preflight_token` inside `target_arguments`; do include every +other supplied argument, including the stable idempotency key: ```text memory_preflight( @@ -151,6 +175,100 @@ idempotency key. For authorized federation, `memory_recall` accepts `linked_workspace_ids`; returned evidence remains attributed to its source workspace. +Lexical recall first ranks records whose content or rationale satisfy the +selected full-text expression, then tag-assisted matches. Content-tier ordering +uses the existing content/rationale BM25 weights without a tag contribution; +tag-assisted matches use ordinary weighted BM25. Record IDs break ties. Original +weighted scores remain diagnostics, not a guarantee of rank order. Tags remain +searchable, and the precision-first query ladder remains unchanged; no projection +rebuild is required for this query-side ordering change. +Direct and linked-workspace recall expose provider channels in canonical sorted +order, including retained contexts, so equivalent evidence does not acquire +different channel-array order after a server restart. + +Intent-aware context packing is staged with +`DAEM0NMCP_RETRIEVAL_RETENTION_MODE=shadow` by default. Set it to `apply` to +reserve evidence capacity for `implement` and `debug` requests. A query naming +`src/http/router.ts`, `http/router.ts`, or `router.ts` can retain its bound +evidence. Path and qualified-symbol queries preserve the supplied scope rather +than promoting same-named files or symbols elsewhere. Bare names remain +intentionally broader. This affects context selection, not stored-record +retention or authorization. +Query path tokens normalize Windows separators and repeated slashes. An explicit +`./` remains workspace-root scoped: `./router.ts` does not match +`src/http/router.ts`, while the bare `router.ts` may. These query-text aliases +do not relax normalized POSIX path arguments or workspace containment. +Explore requests retain baseline packing in every retention mode; shadow mode +reuses that identical composition instead of packing the same evidence twice. +Enabled retention reports `RETENTION_NOT_APPLICABLE` for explore rather than +implying an alternate packing was applied. Other intents still compute their +distinct shadow variant. +Structured outcomes and each procedure step use the same source-aware excerpt +limit as body text, including the reduced limit for stale evidence. Step counts +and short facts remain unchanged; longer facts are prefix excerpts, not complete +procedures. Full record views retain the original facts. No-intent and explore +packing remain unchanged. + +Entity-backed recall can report retryable `DATABASE_IN_USE` while lexical +evidence catches up, including when a newer generation publishes between +retrieval and readiness inspection. Retry after the reported delay; a genuine +indexed miss in the same generation remains a terminal capability failure. + +Repository code-binding checks validate current bounded source content rather +than trusting file size and modification time. Parsed fingerprints are reused +only when normalized source bytes match, so timestamp-preserving edits still +produce `needs_revalidation`. + +Each recall shares successfully read files within its worker-local validation +batch; the next recall reads them afresh. After exact byte verification, unchanged +files share the cached byte object, avoiding repeated full-file comparisons +inside that batch. The batch permits 32 MiB of raw source bytes plus one +overflow-detection byte. Unread bindings beyond that budget are +`unverifiable`, not asserted current or changed. Oversized files and environmental +access failures are also `unverifiable`: inability to read does not prove a +content change. Missing files, directory substitutions, and workspace escapes +remain `needs_revalidation`. Parsed source caching holds at most 32 MiB of +normalized bytes and 256 files; this is not a total-heap limit. The batch is not +an atomic filesystem snapshot, a trusted test receipt, or proof that recorded +guidance remains correct. +Unchanged files also share one parsed symbol-span set per workspace root, rather +than reparsing for every symbol. Qualified-identity and fingerprint caches remain +root scoped, including package initializers; cached symbols still require an +available parser. Edits replace the snapshot before fingerprints are reused. + +Citation-like strings stored inside evidence or binding filenames are displayed +with neutralized brackets, not interpreted as manifest citations. Direct and +linked-workspace recall apply the same presentation rule; canonical content and +typed `changed_bindings` paths remain unchanged for follow-up operations. +Public retrieval envelopes also require exactly one rendered marker per manifest +entry in the same order. Missing, repeated, reordered or unbacked markers fail +validation rather than becoming an apparently valid cited response. + +Symbol fingerprints include decorators owned by the parsed definition, including +multiline Python decorators and TypeScript decorators separated by comments. +Navigation spans and projection identities remain unchanged. Previously stored +decorated-symbol fingerprints may require revalidation once after this change; +review the guidance before recording a successful outcome with `rebind_code`. + +Symbol capture fails with retryable `CAPABILITY_DEGRADED` when the parser is +unavailable, fails, returns unusable entities, or source access is temporarily +unavailable; failed captures write no memory or outcome events. Parser failure +can include syntactically invalid source. Repair the source or restore the +capability/access before obtaining fresh counsel and retrying with the same +idempotency key; an immediate retry cannot fix unchanged malformed source. +Missing symbols, unsupported symbol-file extensions, and sources above the +5 MiB raw-file limit remain `INVALID_ARGUMENT`. +Fully qualified symbol identities take precedence. A short name is accepted only +when it identifies one canonical symbol; ambiguous names are `INVALID_ARGUMENT`, +not a binding to every same-named definition. If an existing short binding +becomes ambiguous, recall reports `unverifiable` until it is rebound explicitly. +Multiple definition spans belonging to the same qualified identity remain grouped. +Each store or outcome rebind captures one fresh source view per resolved file, +sharing it across that request's references. This prevents mixed versions of one +file inside a binding set; the next capture reads afresh. Every reference still +validates workspace containment. The existing 16-reference and 5 MiB raw-file +limits remain; this is not an atomic snapshot of multiple files or Git HEAD. + ## Resources and diagnostics The four bounded data resources require an authorized workspace session: @@ -164,9 +282,10 @@ Six UI resources expose dashboard shells: `ui://daem0n/briefing`, `ui://daem0n/community`, `ui://daem0n/covenant`, `ui://daem0n/graph`, `ui://daem0n/search`, and `ui://daem0n/test`. -A useful diagnostic walk-through is: call `session_brief`; read warnings and -failures; call bounded `memory_recall`; use an exact preflight before a write; -record its outcome; then call `system_health(workspace_id="ws_")`. +A useful diagnostic walk-through is: read the automatic brief (or request the +full `session_brief`); read warnings and failures; call bounded `memory_recall`; +call a protected write directly and follow any counsel challenge; record its +outcome; then call `system_health(workspace_id="ws_")`. `system_health` reports the enabled services and capability states. Missing profiles are remediation signals, not successful execution. @@ -294,12 +413,12 @@ remind only: | Event (matcher) | Hook | What it does | Input | Reminds only | |-----------------|------|--------------|-------|--------------| -| `SessionStart` | `session_start` | Adds a line to the model's context asking it to call `session_brief` with this workspace's `workspace_id` | `CLAUDE_PROJECT_DIR` env | Yes | -| `PreToolUse` (`Edit\|Write\|NotebookEdit`) | `pre_edit` | Inside a Daem0n project, adds a one-line `additionalContext` reminder to call `memory_recall_file` for the file and `memory_preflight` for the change; silent elsewhere | stdin event | Yes | +| `SessionStart` | `session_start` | Explains that the first gated call briefs automatically and `session_brief` returns the full brief | `CLAUDE_PROJECT_DIR` env | Yes | +| `PreToolUse` (`Edit\|Write\|NotebookEdit`) | `pre_edit` | Inside a Daem0n project, adds a one-line `additionalContext` reminder to use `daem0n_tool_call` for `memory_recall_file`; silent elsewhere | stdin event | Yes | | `PreToolUse` (`Bash`) | `pre_bash` | Checks the command against Daem0n rules. Currently inert: it reads a `TOOL_INPUT` env var that Claude Code does not set | `TOOL_INPUT` env | Yes (always exits 0) | | `PostToolUse` (`mcp__.*__edit_preflight`) | `post_edit_preflight` | Edit-bridge plumbing: stages an `edit_preflight` receipt for a bridge edit request. Nothing in Claude Code creates those requests any more, so it is a no-op | stdin event | Yes | | `PostToolUse` (`Edit\|Write\|NotebookEdit`) | `post_edit` | Edit-bridge plumbing: reports a bridge-approved edit as a capture candidate. Only loads the bridge when the project is paired; a no-op in Claude Code today | stdin event | Yes | -| `Stop`, `SubagentStop` | `stop` | When the transcript shows a finished task with no recorded outcome, shows you suggested `memory_preflight`/`memory_store`/`memory_record_outcome` calls to ask Claude for, as a `systemMessage`. Never writes memory | stdin event (`transcript_path`) | Yes | +| `Stop`, `SubagentStop` | `stop` | When the transcript shows a finished task with no recorded outcome, suggests direct replay-safe `memory_store`/`memory_record_outcome` calls and counsel-challenge retry guidance as a `systemMessage`. Never writes memory | stdin event (`transcript_path`) | Yes | No hook blocks a tool call or keeps the agent running: every hook exits 0 and none returns a `permissionDecision` or `decision`. @@ -448,6 +567,8 @@ come from `tools/list` and the generated release inventory. | `context_trigger_list` | rules | communion | | `context_triggers_match` | context | communion | | `covenant_status` | covenant | exempt | +| `daem0n_tool_call` | system | exempt | +| `daem0n_tools_search` | system | exempt | | `decision_debate` | cognitive | counsel | | `decision_simulate` | cognitive | communion | | `document_ingest_url` | external | counsel | diff --git a/pyproject.toml b/pyproject.toml index e906eb6..a0cb5db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,11 @@ models-local = [ "numpy>=1.24.0; python_version >= '3.11'", "llmlingua>=0.2.2; python_version >= '3.11'", ] +late-interaction = [ + # Secure ONNX runtime wheels start at CPython 3.11. + "fastembed>=0.8.1; python_version >= '3.11'", + "numpy>=1.26; python_version >= '3.11'", +] models-hosted = [ "tiktoken>=0.5.0", ] diff --git a/tests/api_v7/test_application.py b/tests/api_v7/test_application.py index d20677d..71d586b 100644 --- a/tests/api_v7/test_application.py +++ b/tests/api_v7/test_application.py @@ -73,8 +73,7 @@ async def test_communion_blocks_before_operation_then_allows_after_brief( called: list[str] = [] async def operation(*, workspace: Workspace, request: object) -> object: - from daem0nmcp.api.v7.models import Page - from daem0nmcp.api.v7.resources import RuleView + from daem0nmcp.api.v7.models import Page, RuleView called.append(workspace.workspace_id) del request diff --git a/tests/api_v7/test_ceremony.py b/tests/api_v7/test_ceremony.py new file mode 100644 index 0000000..3b20eaf --- /dev/null +++ b/tests/api_v7/test_ceremony.py @@ -0,0 +1,554 @@ +"""Guided admission retains the real gate's binding and single-use semantics.""" + +from __future__ import annotations + +import asyncio +import tempfile +import unittest +from collections.abc import Mapping +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from unittest.mock import patch + +from daem0nmcp.api.v7.ceremony import ( + CounselMode, + CovenantCeremony, + _content_tokens, + counsel_reasons, +) +from daem0nmcp.api.v7.models import PreflightGuidance, RecordSummary, RuleView +from daem0nmcp.api.v7.policy import V7_COVENANT_POLICY +from daem0nmcp.covenant import ( + CapabilityAuthority, + CovenantGate, + CovenantLevel, + CovenantStateCapacityError, + CovenantStateStore, + InvocationScope, +) +from daem0nmcp.workspace import Workspace + +NOW = datetime(2026, 10, 4, tzinfo=timezone.utc) + + +def normalize( + operation: str, arguments: Mapping[str, Any] | None, workspace: str +) -> dict[str, Any]: + del operation, workspace + return { + key: value + for key, value in (arguments or {}).items() + if key not in {"workspace_id", "preflight_token"} + } + + +def rule(**constraints: Any) -> RuleView: + return RuleView( + rule_id="rule_" + "a" * 64, + trigger="authentication", + priority=10, + enabled=True, + created_at=NOW, + **constraints, + ) + + +def record( + excerpt: str, *, warning: bool = False, path: str | None = None +) -> RecordSummary: + return RecordSummary( + record_id="mem_" + "a" * 64, + record_type="warning" if warning else "decision", + excerpt=excerpt, + relative_file_path=path, + current_status="current", + content_hash="b" * 64, + created_at=NOW, + updated_at=NOW, + ) + + +class GuidanceService: + def __init__(self) -> None: + self.value: object = PreflightGuidance() + self.calls: list[tuple[str, Mapping[str, Any], str | None]] = [] + + def guidance( + self, + workspace: Workspace, + target_tool: str, + normalized_arguments: Mapping[str, Any], + description: str | None, + ) -> object: + del workspace + self.calls.append((target_tool, normalized_arguments, description)) + return self.value + + +class CeremonyTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.now = 1_000 + self.workspace = Workspace("ws_" + "a" * 24, Path(self.temp.name).resolve()) + self.scope = InvocationScope("principal", "session", str(self.workspace.root)) + self.store = CovenantStateStore(clock=lambda: self.now, ttl_seconds=300) + self.authority = CapabilityAuthority( + secret=b"test-covenant-key-is-at-least-32-bytes!!", + kid="test", + clock=lambda: self.now, + ttl_seconds=300, + ) + self.gate = CovenantGate( + state_store=self.store, + authority=self.authority, + policy=V7_COVENANT_POLICY, + argument_normalizer=normalize, + ) + self.service = GuidanceService() + self.brief_calls = 0 + self.args = { + "workspace_id": self.workspace.workspace_id, + "content": "authentication session rotation", + "record_type": "decision", + "idempotency_key": "ceremony-test", + } + + async def brief_reader(workspace: Workspace) -> Mapping[str, object]: + self.assertEqual(self.workspace, workspace) + self.brief_calls += 1 + return {"briefed_at": NOW, "workspace_statistics": {"records": 3}} + + self.ceremony = CovenantCeremony( + covenant_gate=self.gate, + argument_normalizer=normalize, + preflight_service=self.service, + brief_reader=brief_reader, + ) + + async def admit( + self, + tool: str = "memory_store", + *, + token: str | None = None, + mode: CounselMode = "inline", + consume: bool = True, + scope: InvocationScope | None = None, + ): + return await self.ceremony.admit( + tool, + self.args, + self.workspace, + scope or self.scope, + preflight_token=token, + consume_capability=consume, + counsel_mode=mode, + ) + + async def test_first_communion_call_briefs_and_admits(self) -> None: + outcome = await self.admit("memory_recall", mode="off") + self.assertIsNone(outcome.violation) + self.assertIsNone(outcome.challenge) + self.assertEqual(3, outcome.notice.auto_brief.workspace_statistics["records"]) + self.assertTrue(self.store.is_briefed(self.scope)) + self.assertIsNone((await self.admit("memory_recall")).notice) + self.assertEqual(1, self.brief_calls) + + async def test_brief_read_and_validation_fail_closed(self) -> None: + original = self.gate.authorize("memory_store", self.args, self.scope) + for effect in (RuntimeError("unavailable"), {"briefed_at": "invalid"}): + with self.subTest(effect=effect): + + async def failed_reader(workspace, effect=effect): + if isinstance(effect, Exception): + raise effect + return effect + + with patch.object(self.ceremony, "_brief_reader", failed_reader): + outcome = await self.admit() + self.assertEqual(original, outcome.violation) + self.assertIsNone(outcome.notice) + self.assertFalse(self.store.is_briefed(self.scope)) + + async def test_benign_counsel_echoes_bounded_must_do_and_consumes(self) -> None: + self.service.value = PreflightGuidance( + rules=[rule(must_do=["Run targeted checks."])], + must_do=[f"Instruction {index}" for index in range(25)], + warnings=["Global warnings are not risk evidence."], + ) + issued: list[str] = [] + real_issue = self.gate.issue_preflight + + def issue(*args): + token = real_issue(*args) + issued.append(token) + return token + + with patch.object(self.gate, "issue_preflight", issue): + outcome = await self.admit() + self.assertIsNone(outcome.violation) + self.assertEqual(20, len(outcome.notice.counsel.must_do)) + self.assertEqual("memory_store", outcome.notice.counsel.target_tool) + self.assertIsNotNone(outcome.notice.auto_brief) + self.assertEqual(1, len(issued)) + self.assertEqual( + "TOKEN_REPLAYED", + self.gate.authorize( + "memory_store", self.args, self.scope, preflight_token=issued[0] + )["violation"], + ) + digest = self.gate.fingerprint("memory_store", self.args, self.scope) + self.assertFalse( + self.store.has_matching_grant(self.scope, "memory_store", digest) + ) + self.assertEqual( + ("memory_store", normalize("memory_store", self.args, ""), None), + self.service.calls[0], + ) + + async def test_rule_challenge_retry_once_and_replay_refresh(self) -> None: + self.service.value = PreflightGuidance( + rules=[rule(must_not=["Do not disable authentication."])] + ) + first = await self.admit() + self.assertEqual(["RULE_MUST_NOT"], first.challenge.reasons) + token = first.challenge.preflight_token + self.assertEqual( + datetime.fromtimestamp(self.now + 300, timezone.utc), + first.challenge.expires_at, + ) + self.assertIsNone((await self.admit(token=token)).violation) + self.assertEqual( + "TOKEN_REPLAYED", + self.gate.authorize( + "memory_store", self.args, self.scope, preflight_token=token + )["violation"], + ) + third = await self.admit(token=token) + self.assertEqual(["RULE_MUST_NOT"], third.challenge.reasons) + self.assertNotEqual(token, third.challenge.preflight_token) + + async def test_destructive_always_challenges(self) -> None: + outcome = await self.admit("memory_prune") + self.assertEqual(["DESTRUCTIVE_OPERATION"], outcome.challenge.reasons) + + async def test_related_records_escalate_only_content_or_equal_path(self) -> None: + cases = [ + ( + record("authentication rotation was unsuccessful"), + {}, + ["RELATED_FAILURE"], + ), + ( + record("authentication rotation warning", warning=True), + {}, + ["RELATED_WARNING"], + ), + ( + record("content relative_file_path record_type failed memory warning"), + {}, + [], + ), + (record("authentication only"), {}, []), + ( + record("entirely unrelated", path="src/auth.py"), + {"relative_file_path": "src/auth.py"}, + ["RELATED_FAILURE"], + ), + ( + record("ceremony-test workspace"), + {"idempotency_key": "ceremony-test workspace"}, + [], + ), + ] + for item, extra, expected in cases: + with self.subTest(excerpt=item.excerpt): + self.service.value = PreflightGuidance(records=[item]) + self.args.update(extra) + outcome = await self.admit() + if expected: + self.assertEqual(expected, outcome.challenge.reasons) + else: + self.assertIsNone(outcome.challenge) + self.assertIsNone(outcome.violation) + self.args.pop("relative_file_path", None) + + async def test_unrecoverable_tokens_return_original_without_counsel(self) -> None: + self.gate.record_briefing(self.scope) + token = self.gate.issue_preflight(self.scope, "memory_store", self.args) + other = InvocationScope("other", "session", str(self.workspace.root)) + self.gate.record_briefing(other) + for supplied, scope, tool, expected in ( + (token, other, "memory_store", "TOKEN_SCOPE_MISMATCH"), + (token + "x", self.scope, "memory_store", "TOKEN_TAMPERED"), + (token, self.scope, "rule_create", "TOKEN_OPERATION_MISMATCH"), + ("{}", self.scope, "memory_store", "TOKEN_LEGACY_UNSUPPORTED"), + ): + with self.subTest(expected=expected): + original = self.gate.authorize( + tool, self.args, scope, preflight_token=supplied + ) + outcome = await self.admit(tool, token=supplied, scope=scope) + self.assertEqual(expected, outcome.violation["violation"]) + self.assertEqual(original, outcome.violation) + self.assertIsNone(outcome.challenge) + self.assertEqual([], self.service.calls) + + async def test_expired_and_argument_mismatch_tokens_recompute_counsel(self) -> None: + self.gate.record_briefing(self.scope) + self.service.value = PreflightGuidance( + rules=[rule(ask_first=["Confirm rotation."])] + ) + token = self.gate.issue_preflight(self.scope, "memory_store", self.args) + self.args["content"] = "different content" + self.assertEqual( + "TOKEN_ARGUMENT_MISMATCH", + self.gate.authorize( + "memory_store", self.args, self.scope, preflight_token=token + )["violation"], + ) + mismatch = await self.admit(token=token) + self.assertEqual(["RULE_ASK_FIRST"], mismatch.challenge.reasons) + self.now += 301 + self.assertEqual( + "TOKEN_EXPIRED", + self.gate.authorize( + "memory_store", + self.args, + self.scope, + preflight_token=mismatch.challenge.preflight_token, + )["violation"], + ) + expired = await self.admit(token=mismatch.challenge.preflight_token) + self.assertIsNotNone(expired.notice.auto_brief) + self.assertEqual(["RULE_ASK_FIRST"], expired.challenge.reasons) + + async def test_challenge_mode_mints_without_consumption_and_off_stays_strict( + self, + ) -> None: + outcome = await self.admit(mode="challenge", consume=False) + self.assertEqual(["TASK_ADMISSION"], outcome.challenge.reasons) + token = outcome.challenge.preflight_token + self.assertIsNone( + self.gate.authorize( + "memory_store", + self.args, + self.scope, + preflight_token=token, + consume_capability=False, + ) + ) + self.assertIsNone( + self.gate.authorize( + "memory_store", self.args, self.scope, preflight_token=token + ) + ) + calls = len(self.service.calls) + original = self.gate.authorize("memory_store", self.args, self.scope) + off = await self.admit(mode="off") + self.assertEqual(original, off.violation) + self.assertEqual("COUNSEL_REQUIRED", off.violation["violation"]) + self.assertEqual(calls, len(self.service.calls)) + + async def test_token_missing_is_counseled(self) -> None: + self.gate.record_briefing(self.scope) + self.gate.issue_preflight(self.scope, "memory_store", self.args) + self.assertEqual( + "TOKEN_MISSING", + self.gate.authorize("memory_store", self.args, self.scope)["violation"], + ) + self.assertIsNone((await self.admit()).violation) + + async def test_counsel_failures_retain_violation_and_auto_brief(self) -> None: + for stage, effect in ( + ("guidance", RuntimeError("unavailable")), + ("guidance", {"unexpected": True}), + ("issue_preflight", CovenantStateCapacityError("full")), + ("verify", RuntimeError("unavailable")), + ): + with self.subTest(stage=stage): + owner = ( + self.service + if stage == "guidance" + else self.authority + if stage == "verify" + else self.gate + ) + options = ( + {"side_effect": effect} + if isinstance(effect, Exception) + else {"return_value": effect} + ) + with patch.object(owner, stage, **options): + outcome = await self.admit() + self.assertIn( + outcome.violation["violation"], + {"COUNSEL_REQUIRED", "TOKEN_MISSING"}, + ) + self.assertIsNone(outcome.challenge) + self.assertTrue(self.store.is_briefed(self.scope)) + if stage == "guidance" and isinstance(effect, Exception): + self.assertIsNotNone(outcome.notice.auto_brief) + + async def test_async_guidance_and_cancellation_propagate(self) -> None: + async def guidance(*args): + return PreflightGuidance() + + with patch.object(self.service, "guidance", guidance): + self.assertIsNone((await self.admit()).violation) + + async def cancelled_guidance(*args): + raise asyncio.CancelledError + + with ( + patch.object(self.service, "guidance", cancelled_guidance), + self.assertRaises(asyncio.CancelledError), + ): + await self.admit() + for owner, method in ( + (self.service, "guidance"), + (self.gate, "issue_preflight"), + ): + with ( + patch.object(owner, method, side_effect=asyncio.CancelledError), + self.assertRaises(asyncio.CancelledError), + ): + await self.admit() + + async def cancelled(workspace): + raise asyncio.CancelledError + + self.scope = InvocationScope( + "principal", "new-session", str(self.workspace.root) + ) + with ( + patch.object(self.ceremony, "_brief_reader", cancelled), + self.assertRaises(asyncio.CancelledError), + ): + await self.admit() + self.assertFalse(self.store.is_briefed(self.scope)) + + async def test_missing_scope_remains_fail_closed(self) -> None: + outcome = await self.ceremony.admit( + "memory_store", + self.args, + self.workspace, + None, + preflight_token=None, + consume_capability=True, + counsel_mode="inline", + ) + self.assertEqual("IDENTITY_UNAVAILABLE", outcome.violation["violation"]) + self.assertIsNone(outcome.notice) + self.assertEqual([], self.service.calls) + + async def test_normalization_failure_retains_original_violation(self) -> None: + self.gate.record_briefing(self.scope) + original = self.gate.authorize("memory_store", self.args, self.scope) + with patch.object( + self.ceremony, "_normalizer", side_effect=ValueError("invalid") + ): + outcome = await self.admit() + self.assertEqual(original, outcome.violation) + self.assertIsNone(outcome.challenge) + self.assertEqual([], self.service.calls) + + async def test_inline_admission_only_preserves_internal_grant(self) -> None: + outcome = await self.admit(consume=False) + self.assertIsNone(outcome.violation) + digest = self.gate.fingerprint("memory_store", self.args, self.scope) + self.assertTrue( + self.store.has_matching_grant(self.scope, "memory_store", digest) + ) + + async def test_post_brief_scope_mismatch_is_not_recovered(self) -> None: + self.gate.record_briefing(self.scope) + token = self.gate.issue_preflight(self.scope, "memory_store", self.args) + self.scope = InvocationScope( + "principal", "new-session", str(self.workspace.root) + ) + self.args["content"] = "changed authentication rotation" + outcome = await self.admit(token=token) + self.assertIsNotNone(outcome.notice.auto_brief) + self.assertEqual("TOKEN_SCOPE_MISMATCH", outcome.violation["violation"]) + self.assertEqual([], self.service.calls) + + async def test_post_brief_argument_mismatch_recomputes_counsel(self) -> None: + self.gate.record_briefing(self.scope) + token = self.gate.issue_preflight(self.scope, "memory_store", self.args) + self.args["content"] = "changed authentication rotation" + real_is_briefed = self.store.is_briefed + checks = 0 + + def is_briefed(scope): + nonlocal checks + checks += 1 + return checks > 1 and real_is_briefed(scope) + + with patch.object(self.store, "is_briefed", is_briefed): + outcome = await self.admit(token=token) + self.assertIsNone(outcome.violation) + self.assertIsNotNone(outcome.notice.auto_brief) + self.assertIsNotNone(outcome.notice.counsel) + + async def test_preflight_argument_is_not_normalized_or_bound(self) -> None: + self.args["preflight_token"] = "ignored-stale-token" + outcome = await self.admit() + self.assertIsNone(outcome.violation) + self.assertNotIn("preflight_token", self.service.calls[0][1]) + + +class ContentRiskTests(unittest.TestCase): + def test_tokens_ignore_keys_and_nonstrings_and_split_components(self) -> None: + self.assertEqual( + frozenset( + { + "authentication-session", + "authentication", + "session", + "rotate_cache", + "rotate", + "cache", + } + ), + _content_tokens( + { + "private_key": [ + "AUTHENTICATION-session", + ("rotate_cache", None, 1234), + ], + "failed": "memory warning with", + } + ), + ) + + def test_reasons_are_ordered_and_deduplicated(self) -> None: + constraints = rule( + must_not=["Never bypass."], + ask_first=["Ask first."], + warnings=["Take care."], + ) + guidance = PreflightGuidance( + rules=[constraints, constraints], + records=[ + record("authentication rotation"), + record("authentication rotation", warning=True), + record("authentication rotation"), + ], + ) + self.assertEqual( + [ + "DESTRUCTIVE_OPERATION", + "RULE_MUST_NOT", + "RULE_ASK_FIRST", + "RULE_WARNING", + "RELATED_FAILURE", + "RELATED_WARNING", + ], + counsel_reasons( + CovenantLevel.DESTRUCTIVE, + guidance, + {"content": "authentication rotation"}, + ), + ) diff --git a/tests/api_v7/test_composition.py b/tests/api_v7/test_composition.py index dd3d054..669d078 100644 --- a/tests/api_v7/test_composition.py +++ b/tests/api_v7/test_composition.py @@ -163,7 +163,7 @@ def test_surface_rejects_pinned_operation_bypass_and_builds_fresh_server( ) self.assertIsNot(first, second) self.assertEqual(len(first.middlewares), 1) - self.assertEqual(len(first.tools), 75) + self.assertEqual(len(first.tools), 77) self.assertEqual(len(first.resources), 10) diff --git a/tests/api_v7/test_discovery_operations.py b/tests/api_v7/test_discovery_operations.py index 452ccc8..1c0411d 100644 --- a/tests/api_v7/test_discovery_operations.py +++ b/tests/api_v7/test_discovery_operations.py @@ -87,6 +87,46 @@ def _operations(self, **changes: object): self.dependencies = DiscoveryOperationDependencies(**options) return build_discovery_operations(self.dependencies) + def _handler(self, tool_name: str, **changes: object): + from daem0nmcp.api.v7.application import ( + V7ApplicationDependencies, + V7ToolRouter, + ) + from daem0nmcp.api.v7.policy import V7_COVENANT_POLICY + from daem0nmcp.api.v7.responses import ResponseFactory + from daem0nmcp.api.v7.tools import build_argument_normalizer + from daem0nmcp.covenant import ( + CapabilityAuthority, + CovenantGate, + CovenantStateStore, + InvocationScope, + ) + from daem0nmcp.workspace import WorkspaceRegistry + + gate = CovenantGate( + state_store=CovenantStateStore(clock=lambda: 1_000), + authority=CapabilityAuthority( + secret=b"d" * 32, + kid="discovery-test", + clock=lambda: 1_000, + ), + policy=V7_COVENANT_POLICY, + argument_normalizer=build_argument_normalizer(), + ) + scope = InvocationScope("principal", "session", str(self.root)) + gate.record_briefing(scope) + return V7ToolRouter( + V7ApplicationDependencies( + workspace_resolver=WorkspaceRegistry( + [self.root], default_root=self.root + ), + covenant_gate=gate, + scope_provider=lambda: scope, + operations=self._operations(**changes), + response_factory=ResponseFactory(), + ) + ).handler(tool_name) + def _append_memory( self, connection: sqlite3.Connection, @@ -202,16 +242,22 @@ def _append_record_ref( ) ) - def _activate_graph(self) -> None: + def _activate_graph(self, *, content_prefix: str = "") -> None: from daem0nmcp.retrieval.specialized_projection import ( SpecializedProjectionBuilder, ) with closing(sqlite3.connect(self.database)) as connection: connection.execute("PRAGMA foreign_keys=ON") - first = self._append_memory(connection, "a", "First record.") - second = self._append_memory(connection, "b", "Second record.") - third = self._append_memory(connection, "c", "Third record.") + first = self._append_memory( + connection, "a", content_prefix + "First record." + ) + second = self._append_memory( + connection, "b", content_prefix + "Second record." + ) + third = self._append_memory( + connection, "c", content_prefix + "Third record." + ) self._append_relationship(connection, first, second) self._append_record_ref(connection, second, third) SpecializedProjectionBuilder(connection, clock_us=lambda: 900).rebuild( @@ -219,7 +265,7 @@ def _activate_graph(self) -> None: ) connection.commit() - def _activate_discovery(self) -> dict[str, object]: + def _activate_discovery(self, *, content_prefix: str = "") -> dict[str, object]: from daem0nmcp.discovery_projection import ( CodeEntityProjectionSeed, CommunityProjectionSeed, @@ -228,7 +274,7 @@ def _activate_discovery(self) -> dict[str, object]: EntityRecordSeed, ) - self._activate_graph() + self._activate_graph(content_prefix=content_prefix) first = "mem_" + "a" * 64 second = "mem_" + "b" * 64 third = "mem_" + "c" * 64 @@ -1030,10 +1076,7 @@ async def test_stale_community_id_is_distinct_from_not_found(self) -> None: ) self.assertEqual("STALE_PROJECTION_ID", raised.exception.code) - async def test_memory_recall_entity_hydrates_exact_members_through_task8( - self, - ) -> None: - """Entity membership selects IDs; Task 8 authenticates the returned records.""" + def _entity_retrieval_result(self, query, *, record_ids=None): from daem0nmcp.api.v7.models import ( CitationManifestEntry, EvidenceItem, @@ -1044,7 +1087,7 @@ async def test_memory_recall_entity_hydrates_exact_members_through_task8( TokenUsage, ) - self._activate_discovery() + selected = sorted(query.record_ids) if record_ids is None else record_ids records: dict[str, RecordSummary] = {} with closing(sqlite3.connect(self.database)) as connection: connection.row_factory = sqlite3.Row @@ -1070,6 +1113,65 @@ async def test_memory_recall_entity_hydrates_exact_members_through_task8( ), ) + items = [] + manifest = [] + for index, record_id in enumerate(selected, 1): + record = records[record_id] + citation = f"[E{index}]" + ref = EvidenceRef( + record_id=record_id, + event_id="evt_" + ("a" if index == 1 else "b") * 64, + content_hash=record.content_hash, + provider="lexical", + ) + items.append( + EvidenceItem( + citation=citation, + record=record, + bounded_excerpt=record.excerpt, + channels=["lexical"], + score=1.0, + status="current", + evidence_refs=[ref], + ) + ) + manifest.append( + CitationManifestEntry( + citation=citation, + evidence_refs=[ref], + channels=["lexical"], + ) + ) + return RetrievalData( + items=items, + rendered_context="\n".join( + f"{item.citation} {item.bounded_excerpt}" for item in items + ), + citation_manifest=manifest, + provider_diagnostics=[ + ProviderDiagnostic( + provider="lexical", + status="ready", + manifest_generation=1, + elapsed_ms=0.0, + returned_count=len(items), + ) + ], + abstained=False, + token_usage=TokenUsage( + budget=query.token_budget, + requested=len(items), + selected=len(items), + rendered=0, + dropped=0, + ), + ) + + async def test_memory_recall_entity_hydrates_exact_members_through_task8( + self, + ) -> None: + """Entity membership selects IDs; Task 8 authenticates the returned records.""" + self._activate_discovery() test_case = self class RecallService: @@ -1080,57 +1182,7 @@ async def retrieve(self, workspace, query, linked_workspace_ids): test_case.assertEqual(test_case.workspace, workspace) test_case.assertEqual(frozenset(), linked_workspace_ids) self.queries.append(query) - items = [] - manifest = [] - for index, record_id in enumerate(sorted(query.record_ids), 1): - record = records[record_id] - citation = f"[E{index}]" - ref = EvidenceRef( - record_id=record_id, - event_id="evt_" + ("a" if index == 1 else "b") * 64, - content_hash=record.content_hash, - provider="lexical", - ) - items.append( - EvidenceItem( - citation=citation, - record=record, - bounded_excerpt=record.excerpt, - channels=["lexical"], - score=1.0, - status="current", - evidence_refs=[ref], - ) - ) - manifest.append( - CitationManifestEntry( - citation=citation, - evidence_refs=[ref], - channels=["lexical"], - ) - ) - return RetrievalData( - items=items, - rendered_context="Authenticated entity records", - citation_manifest=manifest, - provider_diagnostics=[ - ProviderDiagnostic( - provider="lexical", - status="ready", - manifest_generation=1, - elapsed_ms=0.0, - returned_count=len(items), - ) - ], - abstained=False, - token_usage=TokenUsage( - budget=query.token_budget, - requested=len(items), - selected=len(items), - rendered=0, - dropped=0, - ), - ) + return test_case._entity_retrieval_result(query) recall = RecallService() operation = self._operations(recall_service=recall)["memory_recall_entity"] @@ -1162,11 +1214,387 @@ async def retrieve(self, workspace, query, linked_workspace_ids): self.assertEqual(1, len(second.items)) self.assertNotEqual(first.items[0].record_id, second.items[0].record_id) + async def test_sqlite_contention_is_retryable_through_router(self) -> None: + self._activate_discovery() + handler = self._handler("entity_list") + arguments = {"workspace_id": self.workspace.workspace_id} + + with closing(sqlite3.connect(self.database)) as connection: + self.assertEqual( + "delete", connection.execute("PRAGMA journal_mode=DELETE").fetchone()[0] + ) + connection.execute("BEGIN EXCLUSIVE") + try: + response = await handler(**arguments) + finally: + connection.rollback() + + self.assertFalse(response.ok) + self.assertEqual("DATABASE_IN_USE", response.error.code) + self.assertTrue(response.error.retryable) + self.assertEqual(250, response.error.retry_after_ms) + self.assertIsNone(response.error.remedy) + self.assertNotIn(str(self.root), response.model_dump_json()) + retried = await handler(**arguments) + self.assertTrue(retried.ok) + self.assertEqual(2, len(retried.data.items)) + + async def test_missing_tables_with_contention_words_are_not_retryable(self) -> None: + with closing(sqlite3.connect(self.database)) as connection: + connection.execute("DROP TABLE schema_version") + connection.commit() + + handler = self._handler("entity_list") + for table in ("busy_records", "locked_cache", "ordinary_missing"): + with ( + self.subTest(table=table), + closing(sqlite3.connect(self.database)) as connection, + ): + connection.execute( + f"CREATE VIEW schema_version AS SELECT version FROM {table}" + ) + connection.commit() + try: + with self.assertRaises(sqlite3.OperationalError) as raised: + connection.execute("SELECT version FROM schema_version") + self.assertIn(table, str(raised.exception)) + code = getattr(raised.exception, "sqlite_errorcode", None) + if code is not None: + self.assertEqual(1, code) + + response = await handler(workspace_id=self.workspace.workspace_id) + self.assertFalse(response.ok) + self.assertEqual("CAPABILITY_DEGRADED", response.error.code) + self.assertFalse(response.error.retryable) + self.assertIsNone(response.error.retry_after_ms) + self.assertIsNone(response.error.remedy) + finally: + connection.execute("DROP VIEW schema_version") + connection.commit() + + def test_sqlite_contention_message_fallback_is_canonical_only(self) -> None: + from daem0nmcp.api.v7.discovery_operations import _translate_error + + for message in ( + "database is locked", + "database is busy", + "database table is locked", + "database schema is locked", + "database table is locked: memory_records", + "database schema is locked: main", + "DATABASE IS LOCKED", + ): + with self.subTest(message=message): + error = sqlite3.OperationalError(message) + self.assertIsNone(getattr(error, "sqlite_errorcode", None)) + self.assertEqual("DATABASE_IN_USE", _translate_error(error).code) + + for message in ( + "no such table: busy_records", + "no such table: locked_cache", + "database is locked unexpectedly", + "busy", + "locked", + ): + with self.subTest(message=message): + self.assertEqual( + "CAPABILITY_DEGRADED", + _translate_error(sqlite3.OperationalError(message)).code, + ) + + def test_contention_translation_preserves_typed_and_stable_errors(self) -> None: + from daem0nmcp.api.v7.discovery_operations import ( + DiscoveryOperationError, + _translate_error, + ) + + error = DiscoveryOperationError("NOT_FOUND") + self.assertIs(error, _translate_error(error)) + classified = sqlite3.OperationalError("database is locked") + classified.code = "CAPABILITY_DEGRADED" + self.assertEqual("CAPABILITY_DEGRADED", _translate_error(classified).code) + + async def test_nonbusy_sqlite_failure_is_not_retryable_through_router(self) -> None: + self._activate_discovery() + with closing(sqlite3.connect(self.database)) as connection: + connection.execute("DROP TABLE schema_version") + connection.commit() + + response = await self._handler("entity_list")( + workspace_id=self.workspace.workspace_id + ) + self.assertFalse(response.ok) + self.assertEqual("CAPABILITY_DEGRADED", response.error.code) + self.assertFalse(response.error.retryable) + self.assertIsNone(response.error.retry_after_ms) + self.assertIsNone(response.error.remedy) + + async def test_sqlite_contention_during_lexical_catchup_is_retryable(self) -> None: + fixture = self._activate_discovery() + test_case = self + with closing(sqlite3.connect(self.database)) as connection: + self.assertEqual( + "delete", connection.execute("PRAGMA journal_mode=DELETE").fetchone()[0] + ) + + class RecallService: + contended = True + + async def retrieve(self, workspace, query, linked_workspace_ids): + result = test_case._entity_retrieval_result( + query, + record_ids=( + sorted(query.record_ids)[:1] if self.contended else None + ), + ) + if self.contended: + connection.execute("BEGIN EXCLUSIVE") + return result + + recall = RecallService() + handler = self._handler("memory_recall_entity", recall_service=recall) + arguments = { + "workspace_id": self.workspace.workspace_id, + "entity_name": "Authentication", + } + try: + response = await handler(**arguments) + finally: + connection.rollback() + + self.assertFalse(response.ok) + self.assertEqual("DATABASE_IN_USE", response.error.code) + self.assertTrue(response.error.retryable) + self.assertEqual(250, response.error.retry_after_ms) + self.assertIsNone(response.error.remedy) + + recall.contended = False + retried = await handler(**arguments) + self.assertTrue(retried.ok) + self.assertEqual( + set(fixture["records"][:2]), + {record.record_id for record in retried.data.items}, + ) + + async def test_first_lexical_publication_after_real_retrieval_is_retryable( + self, + ) -> None: + from daem0nmcp.api.v7.runtime_services import Task8RecallService + from daem0nmcp.config import Settings + from daem0nmcp.retrieval.projections import LexicalProjectionBuilder + + fixture = self._activate_discovery(content_prefix="Authentication ") + recall = Task8RecallService( + config=Settings(retrieval_rerank_enabled=False), + capability_statuses={}, + ) + self.addCleanup(recall.close) + test_case = self + + class PublishAfterRealRecall: + published = False + + async def retrieve(self, workspace, query, linked_workspace_ids): + result = await recall.retrieve(workspace, query, linked_workspace_ids) + if not self.published: + test_case.assertFalse(result.items) + with closing(sqlite3.connect(test_case.database)) as connection: + connection.execute("PRAGMA foreign_keys=ON") + LexicalProjectionBuilder(connection).rebuild( + workspace.workspace_id + ) + connection.commit() + self.published = True + return result + + handler = self._handler( + "memory_recall_entity", recall_service=PublishAfterRealRecall() + ) + arguments = { + "workspace_id": self.workspace.workspace_id, + "entity_name": "Authentication", + } + response = await handler(**arguments) + self.assertFalse(response.ok) + self.assertEqual("DATABASE_IN_USE", response.error.code) + self.assertTrue(response.error.retryable) + self.assertEqual(250, response.error.retry_after_ms) + self.assertIsNone(response.error.remedy) + + retried = await handler(**arguments) + self.assertTrue(retried.ok) + self.assertEqual( + set(fixture["records"][:2]), + {record.record_id for record in retried.data.items}, + ) + + async def test_nonbusy_lexical_catchup_failure_is_not_retryable(self) -> None: + self._activate_discovery() + test_case = self + + class RecallService: + async def retrieve(self, workspace, query, linked_workspace_ids): + result = test_case._entity_retrieval_result( + query, record_ids=sorted(query.record_ids)[:1] + ) + with closing(sqlite3.connect(test_case.database)) as connection: + connection.execute("DROP TABLE background_jobs") + connection.commit() + return result + + response = await self._handler( + "memory_recall_entity", recall_service=RecallService() + )( + workspace_id=self.workspace.workspace_id, + entity_name="Authentication", + ) + self.assertFalse(response.ok) + self.assertEqual("CAPABILITY_DEGRADED", response.error.code) + self.assertFalse(response.error.retryable) + self.assertIsNone(response.error.retry_after_ms) + + async def test_graph_generation_advance_during_retrieval_is_retryable(self) -> None: + from daem0nmcp.discovery_projection import ( + DiscoveryProjectionBuilder, + EntityProjectionSeed, + EntityRecordSeed, + ) + from daem0nmcp.retrieval.specialized_projection import ( + SpecializedProjectionBuilder, + ) + + fixture = self._activate_discovery() + test_case = self + + class RecallService: + def __init__(self): + self.generations = [] + + async def retrieve(self, workspace, query, linked_workspace_ids): + result = test_case._entity_retrieval_result(query) + if not self.generations: + with closing(sqlite3.connect(test_case.database)) as connection: + connection.execute("PRAGMA foreign_keys=ON") + previous = connection.execute( + "SELECT generation FROM projection_manifests " + "WHERE workspace_id=? AND projection_name='graph' " + "AND status='active'", + (workspace.workspace_id,), + ).fetchone()[0] + built = SpecializedProjectionBuilder(connection).rebuild( + workspace.workspace_id, + "graph", + force=True, + candidate_populator=lambda generation: ( + DiscoveryProjectionBuilder(connection).populate_graph( + workspace.workspace_id, + entities=( + EntityProjectionSeed( + name="Authentication", + entity_type="concept", + records=( + EntityRecordSeed( + fixture["records"][0], 2 + ), + EntityRecordSeed( + fixture["records"][1], 1 + ), + ), + ), + ), + communities=(), + generation=generation, + ) + ), + ) + self.generations = [previous, built.generation] + return result + + recall = RecallService() + handler = self._handler("memory_recall_entity", recall_service=recall) + arguments = { + "workspace_id": self.workspace.workspace_id, + "entity_name": "Authentication", + } + response = await handler(**arguments) + self.assertFalse(response.ok) + self.assertGreater(recall.generations[1], recall.generations[0]) + self.assertEqual("DATABASE_IN_USE", response.error.code) + self.assertTrue(response.error.retryable) + self.assertEqual(250, response.error.retry_after_ms) + self.assertIsNone(response.error.remedy) + + retried = await handler(**arguments) + self.assertTrue(retried.ok) + self.assertEqual( + set(fixture["records"][:2]), + {record.record_id for record in retried.data.items}, + ) + + async def test_invalid_graph_after_retrieval_is_not_retryable(self) -> None: + self._activate_discovery() + test_case = self + + class RecallService: + async def retrieve(self, workspace, query, linked_workspace_ids): + result = test_case._entity_retrieval_result(query) + with closing(sqlite3.connect(test_case.database)) as connection: + connection.execute( + "UPDATE projection_manifests SET row_count=row_count+1 " + "WHERE workspace_id=? AND projection_name='graph' " + "AND status='active'", + (workspace.workspace_id,), + ) + connection.commit() + return result + + response = await self._handler( + "memory_recall_entity", recall_service=RecallService() + )( + workspace_id=self.workspace.workspace_id, + entity_name="Authentication", + ) + self.assertFalse(response.ok) + self.assertEqual("CAPABILITY_DEGRADED", response.error.code) + self.assertFalse(response.error.retryable) + self.assertIsNone(response.error.retry_after_ms) + + async def test_invalid_entity_retrieval_is_not_retryable(self) -> None: + self._activate_discovery() + test_case = self + + class RecallService: + failure = "duplicate" + + async def retrieve(self, workspace, query, linked_workspace_ids): + if self.failure == "sqlite": + with closing(sqlite3.connect(test_case.database)) as connection: + connection.execute("SELECT * FROM nonexistent_retrieval_table") + if self.failure == "unknown": + raise RuntimeError("unexpected retrieval failure") + selected = sorted(query.record_ids) + return test_case._entity_retrieval_result( + query, record_ids=[*selected, selected[0]] + ) + + recall = RecallService() + handler = self._handler("memory_recall_entity", recall_service=recall) + for failure in ("duplicate", "sqlite", "unknown"): + with self.subTest(failure=failure): + recall.failure = failure + response = await handler( + workspace_id=self.workspace.workspace_id, + entity_name="Authentication", + ) + self.assertFalse(response.ok) + self.assertEqual("CAPABILITY_DEGRADED", response.error.code) + self.assertFalse(response.error.retryable) + self.assertIsNone(response.error.retry_after_ms) + async def test_memory_recall_entity_retries_only_a_pending_catch_up( self, ) -> None: """Only a projection that still owes the records is worth retrying.""" - from daem0nmcp.api.v7.discovery_operations import DiscoveryOperationError from daem0nmcp.api.v7.models import ( ProviderDiagnostic, RetrievalData, @@ -1202,23 +1630,22 @@ async def retrieve(self, workspace, query, linked_workspace_ids): ), ) - operation = self._operations(recall_service=RecallService())[ - "memory_recall_entity" - ] - request = _request( - "memory_recall_entity", - workspace_id=self.workspace.workspace_id, - entity_name="Authentication", - ) + handler = self._handler("memory_recall_entity", recall_service=RecallService()) + arguments = { + "workspace_id": self.workspace.workspace_id, + "entity_name": "Authentication", + } - async def code() -> str: - with self.assertRaises(DiscoveryOperationError) as raised: - await operation(workspace=self.workspace, request=request) - return raised.exception.code + async def code(*, retryable: bool = False) -> str: + response = await handler(**arguments) + self.assertFalse(response.ok) + self.assertEqual(retryable, response.error.retryable) + self.assertEqual(250 if retryable else None, response.error.retry_after_ms) + return response.error.code # The records are not in the lexical projection yet and its rebuild # job is queued: a retry can make progress. - self.assertEqual("DATABASE_IN_USE", await code()) + self.assertEqual("DATABASE_IN_USE", await code(retryable=True)) # The rebuild dead-lettered, so nothing will catch up on its own. with closing(sqlite3.connect(self.database)) as connection: diff --git a/tests/api_v7/test_docs_hooks.py b/tests/api_v7/test_docs_hooks.py index 3fef514..e34e75a 100644 --- a/tests/api_v7/test_docs_hooks.py +++ b/tests/api_v7/test_docs_hooks.py @@ -124,6 +124,33 @@ def _run_root_hook( class ProtocolGoldenTests(unittest.TestCase): + def test_guided_contract_is_injected_into_active_clients(self) -> None: + sentences = ( + "The first Daem0n call in a session briefs automatically; the compact " + "brief is returned in meta.covenant.auto_brief. Call session_brief " + "for the full brief.", + "Call memory_store (or any protected tool) directly. If it returns " + "COUNSEL_REQUIRED, read error.counsel (guidance and reasons), then " + "retry exactly error.remedy. memory_preflight remains available for " + "planning a change in advance.", + "Use daem0n_tools_search(query), then " + "daem0n_tool_call(workspace_id, tool, arguments).", + ) + paths = ( + ROOT / "AGENTS.md", + ROOT / "Summon_Daem0n.md", + ROOT / "Summon_Daem0n_OpenCode.md", + ROOT / ".opencode" / "plugins" / "daem0n.ts", + ROOT / ".claude" / "skills" / "daem0nmcp-protocol" / "SKILL.md", + ROOT / ".claude" / "skills" / "summon_daem0n" / "SKILL.md", + ROOT / ".claude" / "skills" / "openspec-daem0n-bridge" / "SKILL.md", + ) + for path in paths: + text = _read(path).replace("`", "") + with self.subTest(path=path.relative_to(ROOT)): + for sentence in sentences: + self.assertIn(sentence, text) + def test_maintained_surfaces_have_no_executable_v6_rituals(self) -> None: offenders: list[str] = [] for path in _all_protocol_files(): @@ -290,7 +317,12 @@ async def test_stop_suggests_replay_safe_v7_calls_without_writing(self) -> None: r"workspace_id=[\"']ws_[a-f0-9]{24}[\"']", ) self.assertIn("idempotency_key", result.message) - self.assertIn("preflight_token", result.message) + self.assertNotIn("preflight_token=", result.message) + self.assertNotIn("memory_preflight(", result.message) + self.assertIn( + "if COUNSEL_REQUIRED: review error.counsel, then retry error.remedy", + result.message, + ) self.assertFalse((project / ".daem0nmcp" / "storage").exists()) def test_pre_edit_reminds_without_blocking(self) -> None: @@ -314,9 +346,10 @@ def test_pre_edit_reminds_without_blocking(self) -> None: output = json.loads(reminder(event) or "{}")["hookSpecificOutput"] self.assertNotIn("permissionDecision", output) - self.assertIn("memory_preflight", output["additionalContext"]) + self.assertNotIn("memory_preflight", output["additionalContext"]) self.assertIn( - 'memory_recall_file(relative_file_path="server.py")', + 'daem0n_tool_call(tool="memory_recall_file", ' + 'arguments={"relative_file_path": "server.py"})', output["additionalContext"], ) self.assertFalse((project / ".daem0nmcp" / "storage").exists()) diff --git a/tests/api_v7/test_factory.py b/tests/api_v7/test_factory.py index dceca76..e83e5ad 100644 --- a/tests/api_v7/test_factory.py +++ b/tests/api_v7/test_factory.py @@ -40,7 +40,7 @@ def test_factory_builds_one_exact_immutable_manifest_and_search_index(self) -> N self.assertEqual(len(manifest.resources), 10) index = build_tool_search_index(manifest) - self.assertEqual(len(index), 75) + self.assertEqual(len(index), 77) self.assertEqual(index.search("store durable memory")[0].name, "memory_store") self.assertEqual(set(index.document_ids), set(V7_TOOL_LEVELS)) diff --git a/tests/api_v7/test_fastmcp_adapter.py b/tests/api_v7/test_fastmcp_adapter.py index 7029d29..79c960d 100644 --- a/tests/api_v7/test_fastmcp_adapter.py +++ b/tests/api_v7/test_fastmcp_adapter.py @@ -10,7 +10,7 @@ from typing import Annotated from unittest.mock import patch -from pydantic import BaseModel, ConfigDict, Field, TypeAdapter +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError from daem0nmcp import __version__ from daem0nmcp.covenant import CovenantLevel @@ -261,6 +261,39 @@ async def handler(**arguments): spec.input_schema, ) + def test_adapter_input_error_retains_validation_details(self) -> None: + from daem0nmcp.api.v7.fastmcp import _tool_adapter + from daem0nmcp.api.v7.gateway_operations import _ToolArgumentValidationError + + adapter = _tool_adapter( + self._manifest(with_resource=False).tools[0], + tasks_enabled=False, + sync_timeout_seconds=5.0, + ) + arguments = {"workspace_id": "workspace", "limit": 0} + with self.assertRaises(ValidationError) as original: + _Input.model_validate(arguments) + with self.assertRaises(ValidationError) as raised: + asyncio.run(adapter(**arguments)) + self.assertIsInstance(raised.exception, _ToolArgumentValidationError) + self.assertEqual(original.exception.errors(), raised.exception.errors()) + + def test_adapter_output_error_is_not_an_input_error(self) -> None: + from daem0nmcp.api.v7.fastmcp import _tool_adapter + from daem0nmcp.api.v7.gateway_operations import _ToolArgumentValidationError + + async def malformed_handler(**arguments): + return {"value": None} + + adapter = _tool_adapter( + self._manifest(with_resource=False, handler=malformed_handler).tools[0], + tasks_enabled=False, + sync_timeout_seconds=5.0, + ) + with self.assertRaises(ValidationError) as raised: + asyncio.run(adapter(workspace_id="workspace")) + self.assertNotIsInstance(raised.exception, _ToolArgumentValidationError) + def test_framework_debug_logging_redacts_preflight_handles(self) -> None: from daem0nmcp.api.v7.fastmcp import build_fastmcp_server diff --git a/tests/api_v7/test_federated_retrieval.py b/tests/api_v7/test_federated_retrieval.py index 19e1ae9..c208b7c 100644 --- a/tests/api_v7/test_federated_retrieval.py +++ b/tests/api_v7/test_federated_retrieval.py @@ -2,10 +2,12 @@ from __future__ import annotations +import re import sqlite3 import tempfile import threading import unittest +from dataclasses import replace from datetime import datetime, timezone from pathlib import Path @@ -25,6 +27,34 @@ def _query(workspace_id: str, **overrides): class FederatedRetrievalTests(unittest.TestCase): + def test_retained_multichannel_evidence_has_canonical_channel_order(self) -> None: + from daem0nmcp.api.v7.federated_retrieval import compose_federated_results + from daem0nmcp.retrieval.types import RetrievalQuery + + workspace_id = "ws_" + "a" * 24 + source = self._source( + workspace_id, + ("1",), + content="Preserve fencing while replacing a snapshot.", + ) + source = replace( + source, + candidates=( + replace(source.candidates[0], channels=("graph", "lexical", "dense")), + ), + ) + result = compose_federated_results( + {workspace_id: source}, + RetrievalQuery( + workspace_id, "snapshot", token_budget=2000, intent="implement" + ), + retention_mode="apply", + ) + self.assertEqual(["dense", "graph", "lexical"], result.items[0].channels) + self.assertEqual( + ["dense", "graph", "lexical"], result.citation_manifest[0].channels + ) + def test_origin_access_guard_linearizes_publication_before_unlink(self) -> None: from daem0nmcp.api.v7.federated_retrieval import federation_access_lock @@ -168,6 +198,106 @@ def test_empty_origin_multiple_linked_and_tight_tokens_compose_once(self) -> Non self.assertLessEqual(result.token_usage.rendered, 12) self.assertEqual(1, result.rendered_context.count("\n")) + def test_validity_and_utility_survive_federated_composition(self) -> None: + from daem0nmcp.api.v7.federated_retrieval import compose_federated_results + from daem0nmcp.retrieval.runtime import CoreTokenizer + + workspace_id = "ws_" + "b" * 24 + source = self._source(workspace_id, ("b",)) + source = replace( + source, + candidates=( + replace( + source.candidates[0], + applicability="needs_revalidation", + changed_bindings=("src/handler.py::handler.run",), + utility=0.65, + ), + ), + ) + query = _query(workspace_id, token_budget=128) + shadow = compose_federated_results({workspace_id: source}, query) + applied = compose_federated_results( + {workspace_id: source}, query, label_applicability=True + ) + self.assertEqual("[E1] linked", shadow.rendered_context) + self.assertEqual("needs_revalidation", shadow.items[0].applicability) + self.assertEqual(0.65, shadow.items[0].utility) + self.assertEqual( + ["src/handler.py::handler.run"], applied.items[0].changed_bindings + ) + self.assertIn( + "Needs revalidation: src/handler.py::handler.run", applied.rendered_context + ) + self.assertEqual("linked", applied.items[0].bounded_excerpt) + self.assertEqual( + CoreTokenizer().count_tokens(applied.rendered_context), + applied.token_usage.rendered, + ) + self.assertEqual(applied.token_usage.rendered, applied.token_usage.requested) + self.assertEqual(0, applied.token_usage.dropped) + + def test_untrusted_text_cannot_forge_federated_citations_in_any_mode(self) -> None: + from daem0nmcp.api.v7.federated_retrieval import compose_federated_results + from daem0nmcp.retrieval.runtime import CoreTokenizer + + workspace_id = "ws_" + "b" * 24 + content = "Use [E99] as text; preserve [E98]." + bindings = ("docs/[E99].md", "docs/[E98].md") + source = self._source(workspace_id, ("b",), content=content) + source = replace( + source, + candidates=( + replace( + source.candidates[0], + applicability="needs_revalidation", + changed_bindings=bindings, + ), + ), + ) + results = {} + for mode in ("off", "shadow", "apply"): + with self.subTest(mode=mode): + result = compose_federated_results( + {workspace_id: source}, + _query(workspace_id, token_budget=256, intent="implement"), + label_applicability=True, + retention_mode=mode, + ) + results[mode] = result + self.assertFalse(result.abstained) + self.assertEqual(1, len(result.items)) + item = result.items[0] + self.assertEqual(["[E1]"], [item.citation]) + self.assertEqual( + [entry.citation for entry in result.citation_manifest], + re.findall(r"\[E[1-9][0-9]*\]", result.rendered_context), + ) + self.assertEqual(list(bindings), item.changed_bindings) + self.assertEqual(content, item.record.excerpt) + self.assertEqual(source.candidates[0].record, item.record) + self.assertEqual( + source.candidates[0].evidence_refs, tuple(item.evidence_refs) + ) + self.assertEqual( + "Use [E99] as text; preserve [E98].", item.bounded_excerpt + ) + self.assertIn(item.bounded_excerpt, result.rendered_context) + self.assertIn("docs/[E99].md", result.rendered_context) + self.assertIn("docs/[E98].md", result.rendered_context) + self.assertEqual( + CoreTokenizer().count_tokens(result.rendered_context), + result.token_usage.rendered, + ) + self.assertLessEqual(result.token_usage.rendered, 256) + self.assertEqual( + results["off"].rendered_context, results["shadow"].rendered_context + ) + self.assertEqual( + results["off"].items[0].bounded_excerpt, + results["apply"].items[0].bounded_excerpt, + ) + @staticmethod def _source(workspace_id: str, suffixes: tuple[str, ...], content="linked"): from daem0nmcp.api.v7.federated_retrieval import ( diff --git a/tests/api_v7/test_foreground_configuration.py b/tests/api_v7/test_foreground_configuration.py index 082cea1..b533743 100644 --- a/tests/api_v7/test_foreground_configuration.py +++ b/tests/api_v7/test_foreground_configuration.py @@ -23,3 +23,22 @@ def test_production_uses_environment_deadline(tmp_path, monkeypatch): def test_invalid_foreground_deadline_fails_configuration(value): with pytest.raises(ValidationError): Settings(sync_timeout_seconds=value, _env_file=None) + + +def test_ergonomics_settings_defaults_and_environment(monkeypatch): + monkeypatch.delenv("DAEM0NMCP_TOOL_SURFACE", raising=False) + monkeypatch.delenv("DAEM0NMCP_COVENANT_MODE", raising=False) + settings = Settings(_env_file=None) + assert settings.tool_surface == "core" + assert settings.covenant_mode == "guided" + monkeypatch.setenv("DAEM0NMCP_TOOL_SURFACE", "full") + monkeypatch.setenv("DAEM0NMCP_COVENANT_MODE", "strict") + configured = Settings(_env_file=None) + assert configured.tool_surface == "full" + assert configured.covenant_mode == "strict" + + +@pytest.mark.parametrize("field", ["tool_surface", "covenant_mode"]) +def test_invalid_ergonomics_setting_is_rejected(field): + with pytest.raises(ValidationError): + Settings(**{field: "unsupported"}, _env_file=None) diff --git a/tests/api_v7/test_gateway_operations.py b/tests/api_v7/test_gateway_operations.py new file mode 100644 index 0000000..0be2480 --- /dev/null +++ b/tests/api_v7/test_gateway_operations.py @@ -0,0 +1,473 @@ +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import AsyncMock, patch + +from fastmcp import Client + +from daem0nmcp.api.v7.fastmcp import _tool_adapter +from daem0nmcp.api.v7.gateway_operations import ( + CORE_LISTED_TOOLS, + GATEWAY_TOOL_NAMES, + bind_gateway_handlers, +) +from daem0nmcp.api.v7.models import ApiResponse, ErrorCode, FieldError +from daem0nmcp.api.v7.policy import V7_TOOL_LEVELS +from daem0nmcp.api.v7.production import build_production_surface, create_v7_server +from daem0nmcp.api.v7.responses import ResponseFactory +from daem0nmcp.config import Settings +from tests.api_v7.process_client import initialize_workspaces + + +class GatewayOperationsTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + environment = patch.dict(os.environ, {"DAEM0NMCP_PROJECT_ROOT": str(self.root)}) + environment.start() + self.addCleanup(environment.stop) + [self.workspace] = await initialize_workspaces((self.root,)) + self.settings = Settings( + project_root=str(self.root), + workspace_roots=[str(self.root)], + tool_surface="core", + covenant_mode="guided", + dream_enabled=False, + ) + self.surface = build_production_surface( + "stdio", settings=self.settings, environ={} + ) + self.server = self.surface.build_server() + self.scope = {"workspace_id": self.workspace.workspace_id} + + async def test_core_lists_nine_and_full_lists_all_registered_tools(self) -> None: + self.assertEqual(9, len(CORE_LISTED_TOOLS)) + async with Client(self.server) as client: + self.assertEqual( + CORE_LISTED_TOOLS, {tool.name for tool in await client.list_tools()} + ) + full = build_production_surface( + "stdio", + settings=self.settings.model_copy(update={"tool_surface": "full"}), + environ={}, + ).build_server() + async with Client(full) as client: + names = {tool.name for tool in await client.list_tools()} + self.assertEqual(77, len(names)) + self.assertEqual(set(V7_TOOL_LEVELS), names) + + async def test_search_describes_hidden_pruning_tools(self) -> None: + async with Client(self.server) as client: + result = await client.call_tool( + "daem0n_tools_search", {"query": "prune old memories"} + ) + envelope = result.structured_content + self.assertTrue(envelope["ok"], envelope) + descriptors = {tool["name"]: tool for tool in envelope["data"]["tools"]} + self.assertTrue(GATEWAY_TOOL_NAMES.isdisjoint(descriptors)) + for name, covenant in ( + ("memory_prune_preview", "communion"), + ("memory_prune", "destructive"), + ): + with self.subTest(tool=name): + descriptor = descriptors[name] + self.assertEqual(covenant, descriptor["covenant"]) + self.assertFalse(descriptor["listed"]) + self.assertEqual( + name == "memory_prune_preview", descriptor["read_only"] + ) + self.assertIn( + "workspace_id", descriptor["input_schema"]["required"] + ) + empty = await client.call_tool( + "daem0n_tools_search", {"query": "zzzzunmatchedcapability"} + ) + self.assertEqual([], empty.structured_content["data"]["tools"]) + + async def test_gateway_search_text_and_direct_hidden_call(self) -> None: + async with Client(self.server) as client: + result = await client.call_tool( + "daem0n_tool_call", + { + **self.scope, + "tool": "memory_search_text", + "arguments": {"query": "x"}, + }, + ) + envelope = result.structured_content + self.assertTrue(envelope["ok"], envelope) + self.assertEqual("memory_search_text", envelope["data"]["tool"]) + self.assertIsNotNone(envelope["meta"]["covenant"]["auto_brief"]) + direct = await client.call_tool( + "memory_search_text", {**self.scope, "query": "x"} + ) + self.assertTrue(direct.structured_content["ok"], direct.structured_content) + self.assertEqual( + envelope["data"]["data"], direct.structured_content["data"] + ) + + async def test_gateway_preserves_stored_user_text_with_host_path(self) -> None: + content = r"hostpathroundtrip documents C:\proj\x.py as user-authored text." + server = create_v7_server("stdio", settings=self.settings, environ={}) + async with Client(server) as client: + stored = await client.call_tool( + "memory_store", + { + **self.scope, + "record_type": "decision", + "content": content, + "idempotency_key": "gateway-user-path-roundtrip", + }, + ) + stored_envelope = stored.structured_content + self.assertTrue(stored_envelope["ok"], stored_envelope) + arguments = {"query": "hostpathroundtrip"} + direct = await client.call_tool( + "memory_search_text", {**self.scope, **arguments} + ) + direct_envelope = direct.structured_content + self.assertTrue(direct_envelope["ok"], direct_envelope) + gateway = await client.call_tool( + "daem0n_tool_call", + { + **self.scope, + "tool": "memory_search_text", + "arguments": arguments, + }, + ) + envelope = gateway.structured_content + self.assertTrue(envelope["ok"], envelope) + self.assertEqual(direct_envelope["data"], envelope["data"]["data"]) + hits = envelope["data"]["data"]["items"] + self.assertEqual(1, len(hits)) + self.assertEqual(content, hits[0]["bounded_excerpt"]) + self.assertEqual( + stored_envelope["data"]["record"]["record_id"], + hits[0]["record"]["record_id"], + ) + + async def test_unknown_recursion_and_nested_workspace_are_rejected(self) -> None: + async with Client(self.server) as client: + cases = [ + ("not_a_registered_tool", {}, "tool", "UNKNOWN_TOOL"), + ("daem0n_tool_call", {}, "tool", "UNKNOWN_TOOL"), + ("daem0n_tools_search", {}, "tool", "UNKNOWN_TOOL"), + ( + "memory_search_text", + {**self.scope, "query": "x"}, + "arguments.workspace_id", + "WORKSPACE_ID_TOP_LEVEL", + ), + ("memory_search_text", {}, "arguments.query", "INVALID_VALUE"), + ( + "memory_search_text", + {"query": "x", "limit": 0}, + "arguments.limit", + "INVALID_VALUE", + ), + ] + for tool, arguments, field, code in cases: + with self.subTest(tool=tool, arguments=arguments): + result = await client.call_tool( + "daem0n_tool_call", + {**self.scope, "tool": tool, "arguments": arguments}, + raise_on_error=False, + ) + envelope = result.structured_content + self.assertFalse(envelope["ok"], envelope) + self.assertEqual("INVALID_ARGUMENT", envelope["error"]["code"]) + self.assertEqual( + field, envelope["error"]["field_errors"][0]["field"] + ) + self.assertEqual(code, envelope["error"]["field_errors"][0]["code"]) + + async def test_unsafe_extra_argument_keys_return_sanitized_field_errors( + self, + ) -> None: + async with Client(self.server) as client: + for key in ( + "plain neutral words", + r"C:\private\host-location.py", + str(self.root / "private-location.py"), + "/health", + "a" * 256, + ): + with self.subTest(key=key): + result = await client.call_tool( + "daem0n_tool_call", + { + **self.scope, + "tool": "memory_search_text", + "arguments": {"query": "x", key: "unexpected"}, + }, + raise_on_error=False, + ) + envelope = result.structured_content + self.assertFalse(envelope["ok"], envelope) + self.assertEqual("INVALID_ARGUMENT", envelope["error"]["code"]) + self.assertTrue(envelope["error"]["field_errors"]) + for error in envelope["error"]["field_errors"]: + diagnostic = FieldError.model_validate(error) + self.assertEqual("arguments", diagnostic.field) + self.assertEqual("INVALID_VALUE", diagnostic.code) + self.assertNotIn(json.dumps(key)[1:-1], json.dumps(envelope)) + + async def test_destructive_challenge_retries_exact_surface_remedy(self) -> None: + full_server = build_production_surface( + "stdio", + settings=self.settings.model_copy(update={"tool_surface": "full"}), + environ={}, + ).build_server() + selection = { + "older_than_days": 30, + "categories": ["decision"], + "min_recall_count": 2, + "protect_successful": False, + } + for surface, server in (("core", self.server), ("full", full_server)): + with self.subTest(surface=surface): + async with Client(server) as client: + preview = await client.call_tool( + "daem0n_tool_call", + { + **self.scope, + "tool": "memory_prune_preview", + "arguments": selection, + }, + ) + preview_envelope = preview.structured_content + self.assertTrue(preview_envelope["ok"], preview_envelope) + arguments = { + **selection, + "selection_token": preview_envelope["data"]["data"][ + "selection_token" + ], + } + self.assertNotIn("preflight_token", arguments) + challenged = await client.call_tool( + "daem0n_tool_call", + {**self.scope, "tool": "memory_prune", "arguments": arguments}, + raise_on_error=False, + ) + envelope = challenged.structured_content + self.assertFalse(envelope["ok"], envelope) + self.assertEqual("COUNSEL_REQUIRED", envelope["error"]["code"]) + counsel = envelope["error"]["counsel"] + self.assertTrue(counsel["preflight_token"]) + self.assertIn("DESTRUCTIVE_OPERATION", counsel["reasons"]) + self.assertEqual( + envelope["meta"]["request_id"], + envelope["error"]["correlation_id"], + ) + remedy = envelope["error"]["remedy"] + target_arguments = { + **arguments, + "preflight_token": counsel["preflight_token"], + } + if surface == "core": + self.assertEqual( + { + "tool": "daem0n_tool_call", + "arguments": { + **self.scope, + "tool": "memory_prune", + "arguments": target_arguments, + }, + }, + remedy, + ) + else: + self.assertEqual( + { + "tool": "memory_prune", + "arguments": {**self.scope, **target_arguments}, + }, + remedy, + ) + retried = await client.call_tool( + remedy["tool"], remedy["arguments"] + ) + self.assertTrue( + retried.structured_content["ok"], retried.structured_content + ) + if surface == "core": + self.assertEqual( + "memory_prune", retried.structured_content["data"]["tool"] + ) + + async def test_binder_sanitizes_adapter_exceptions(self) -> None: + adapter = AsyncMock() + handlers = bind_gateway_handlers( + manifest=self.surface.manifest, + adapters={"memory_search_text": adapter}, + listed_tools=CORE_LISTED_TOOLS, + response_factory=ResponseFactory(), + ) + for exception, expected in ( + (TypeError("private argument detail"), ErrorCode.INTERNAL_ERROR), + (RuntimeError("private runtime detail"), ErrorCode.INTERNAL_ERROR), + (ValueError("private runtime detail"), ErrorCode.INTERNAL_ERROR), + ): + with self.subTest(exception=type(exception).__name__): + adapter.side_effect = exception + with patch("daem0nmcp.api.v7.responses._LOGGER"): + result = await handlers["daem0n_tool_call"]( + **self.scope, + tool="memory_search_text", + arguments={"query": "x"}, + ) + self.assertFalse(result.ok) + self.assertEqual(expected, result.error.code) + self.assertFalse(result.error.retryable) + self.assertIsNone(result.error.remedy) + self.assertEqual(result.meta.request_id, result.error.correlation_id) + self.assertEqual([], result.error.field_errors) + self.assertNotIn("private", result.model_dump_json()) + + async def test_binder_returns_internal_envelopes_for_malformed_output(self) -> None: + valid = ( + ResponseFactory() + .begin(self.workspace.workspace_id) + .success({"items": []}) + .model_dump(mode="json") + ) + failure = ( + ResponseFactory() + .begin(self.workspace.workspace_id) + .failure(ErrorCode.INVALID_ARGUMENT, "Invalid value.") + .model_dump(mode="json") + ) + cases = { + "missing_meta": { + key: value for key, value in valid.items() if key != "meta" + }, + "invalid_meta": {**valid, "meta": {"request_id": "private-output-detail"}}, + "missing_data": { + key: value for key, value in valid.items() if key != "data" + }, + "null_data": {**valid, "data": None}, + "invalid_data": {**valid, "data": ["private-output-detail"]}, + "invalid_json_data": { + **valid, + "data": {"private-output-detail": float("nan")}, + }, + "missing_ok": {key: value for key, value in valid.items() if key != "ok"}, + "invalid_ok": {**valid, "ok": "true"}, + "missing_error": { + key: value for key, value in failure.items() if key != "error" + }, + "invalid_error": {**failure, "error": {"code": "private-output-detail"}}, + "conflicting_branches": {**valid, "error": failure["error"]}, + } + for name, malformed in cases.items(): + with self.subTest(case=name): + + async def adapter(_malformed=malformed, **arguments): + return _malformed + + handlers = bind_gateway_handlers( + manifest=self.surface.manifest, + adapters={"memory_search_text": adapter}, + listed_tools=CORE_LISTED_TOOLS, + response_factory=ResponseFactory(), + ) + with patch("daem0nmcp.api.v7.responses._LOGGER"): + result = await handlers["daem0n_tool_call"]( + **self.scope, + tool="memory_search_text", + arguments={"query": "x"}, + ) + envelope = ApiResponse.model_validate_json(result.model_dump_json()) + self.assertFalse(envelope.ok) + self.assertEqual(ErrorCode.INTERNAL_ERROR, envelope.error.code) + self.assertEqual([], envelope.error.field_errors) + self.assertIsNone(envelope.error.remedy) + self.assertFalse(envelope.error.retryable) + self.assertEqual( + envelope.meta.request_id, envelope.error.correlation_id + ) + self.assertNotIn("private-output-detail", result.model_dump_json()) + + async def test_real_target_adapter_output_validation_is_internal(self) -> None: + async def malformed_handler(**arguments): + return {"ok": True, "data": {"private-output-detail": None}} + + spec = next( + spec + for spec in self.surface.manifest.tools + if spec.name == "memory_search_text" + ).replace(handler=malformed_handler) + adapter = _tool_adapter(spec, tasks_enabled=False, sync_timeout_seconds=5.0) + handlers = bind_gateway_handlers( + manifest=self.surface.manifest, + adapters={"memory_search_text": adapter}, + listed_tools=CORE_LISTED_TOOLS, + response_factory=ResponseFactory(), + ) + with patch("daem0nmcp.api.v7.responses._LOGGER"): + result = await handlers["daem0n_tool_call"]( + **self.scope, + tool="memory_search_text", + arguments={"query": "x"}, + ) + self.assertFalse(result.ok) + self.assertEqual(ErrorCode.INTERNAL_ERROR, result.error.code) + self.assertEqual([], result.error.field_errors) + self.assertNotIn("private-output-detail", result.model_dump_json()) + + async def failing_service(**arguments): + spec.output_model.model_validate( + {"ok": True, "data": {"private-output-detail": None}} + ) + + handlers = bind_gateway_handlers( + manifest=self.surface.manifest, + adapters={ + "memory_search_text": _tool_adapter( + spec.replace(handler=failing_service), + tasks_enabled=False, + sync_timeout_seconds=5.0, + ) + }, + listed_tools=CORE_LISTED_TOOLS, + response_factory=ResponseFactory(), + ) + with patch("daem0nmcp.api.v7.responses._LOGGER"): + service_result = await handlers["daem0n_tool_call"]( + **self.scope, + tool="memory_search_text", + arguments={"query": "x"}, + ) + self.assertFalse(service_result.ok) + self.assertEqual(ErrorCode.INTERNAL_ERROR, service_result.error.code) + self.assertEqual([], service_result.error.field_errors) + self.assertNotIn("private-output-detail", service_result.model_dump_json()) + + async def test_invalid_arguments_never_invoke_target_adapter(self) -> None: + async def handler(**arguments): + self.fail("Invalid caller arguments reached the target handler.") + + spec = next( + spec + for spec in self.surface.manifest.tools + if spec.name == "memory_search_text" + ).replace(handler=handler) + adapter = _tool_adapter(spec, tasks_enabled=False, sync_timeout_seconds=5.0) + + handlers = bind_gateway_handlers( + manifest=self.surface.manifest, + adapters={"memory_search_text": adapter}, + listed_tools=CORE_LISTED_TOOLS, + response_factory=ResponseFactory(), + ) + result = await handlers["daem0n_tool_call"]( + **self.scope, tool="memory_search_text", arguments={} + ) + self.assertFalse(result.ok) + self.assertEqual(ErrorCode.INVALID_ARGUMENT, result.error.code) + self.assertEqual("arguments.query", result.error.field_errors[0].field) diff --git a/tests/api_v7/test_git_briefing_deadline.py b/tests/api_v7/test_git_briefing_deadline.py index 0d7b6e2..47abf27 100644 --- a/tests/api_v7/test_git_briefing_deadline.py +++ b/tests/api_v7/test_git_briefing_deadline.py @@ -37,7 +37,8 @@ def test_git_output_filters_unexpected_paths_even_with_scoped_pathspec( ): results = iter([b"nested/\n", b" M sibling/private.txt\0 M nested/inside.txt\0"]) monkeypatch.setattr( - SQLiteResourceRepository, "_read_git_output_sync", lambda *_: next(results) + "daem0nmcp.api.v7.resource_repository.read_git_output_sync", + lambda *_: next(results), ) workspace = WorkspaceRegistry([tmp_path], default_root=tmp_path).default assert SQLiteResourceRepository._read_git_changes_sync(workspace) == [ diff --git a/tests/api_v7/test_memory_learning_writes.py b/tests/api_v7/test_memory_learning_writes.py new file mode 100644 index 0000000..1adb72e --- /dev/null +++ b/tests/api_v7/test_memory_learning_writes.py @@ -0,0 +1,805 @@ +from __future__ import annotations + +import json +import sqlite3 +import subprocess +import sys +import tempfile +import unittest +from contextlib import closing +from dataclasses import replace +from datetime import timedelta +from pathlib import Path +from unittest.mock import patch + +from pydantic import ValidationError + +from daem0nmcp.api.v7.discovery_operations import default_code_indexer_factory +from daem0nmcp.api.v7.pinned import ( + IdempotencyConflict, + MemoryOutcomeCommand, + MemoryStoreCommand, +) +from daem0nmcp.api.v7.runtime_services import ( + RuntimeServiceError, + SQLiteMemoryEventWriter, + Task8RecallService, +) +from daem0nmcp.api.v7.tools import ( + CodeRef, + MemoryCapturePromoteInput, + MemoryCreate, + MemoryRecordOutcomeInput, + MemoryStoreInput, + OutcomeVerification, +) +from daem0nmcp.config import Settings +from daem0nmcp.event_store import ( + EventCommand, + EventStore, + deterministic_id, + export_event_bundle, + import_event_bundle, +) +from daem0nmcp.retrieval.runtime import drain_projection_jobs +from daem0nmcp.retrieval.types import RetrievalQuery + +from .test_local_state_operations import NOW, _apply_v7_schema, _Fixture + +LEGACY_STORE_HASH = "57a7c1ddd4407236b7f99754d9530754b7ca5b0a206ac0b6c4f7c9994f83b808" +LEGACY_OUTCOME_HASH = "9e727cd277356ecc6e381f8eb2ab3b539dc57e4eef0ee12870b55c185e372549" + + +def _store_command(**changes: object) -> MemoryStoreCommand: + values = { + "record_type": "decision", + "content": "Use canonical events.", + "rationale": None, + "context": {}, + "tags": (), + "relative_file_path": None, + "happened_at": None, + "procedure_steps": (), + "idempotency_key": "learning-store-0001", + } + values.update(changes) + return MemoryStoreCommand(**values) + + +def _outcome_command(record_id: str, **changes: object) -> MemoryOutcomeCommand: + values = { + "record_id": record_id, + "outcome_text": "The change worked.", + "worked": True, + "happened_at": None, + "idempotency_key": "learning-outcome-0001", + } + values.update(changes) + return MemoryOutcomeCommand(**values) + + +class LearningInputTests(unittest.TestCase): + def setUp(self) -> None: + self.store = { + "workspace_id": "ws_" + "a" * 24, + "record_type": "decision", + "content": "Keep useful evidence.", + "idempotency_key": "learning-input-0001", + "preflight_token": "t" * 32, + } + self.outcome = { + "workspace_id": self.store["workspace_id"], + "record_id": "mem_" + "1" * 64, + "outcome_text": "Verified the change.", + "worked": True, + "idempotency_key": "learning-input-0002", + } + + def test_informed_by_requires_unique_bounded_record_ids(self) -> None: + for model, arguments in ( + (MemoryStoreInput, self.store), + (MemoryRecordOutcomeInput, self.outcome), + ): + for ids in ( + ["mem_" + "2" * 64] * 2, + ["mem_" + "A" * 64], + ["not-a-record"], + [f"mem_{index:064x}" for index in range(33)], + ): + with ( + self.subTest(model=model.__name__, ids=ids), + self.assertRaises(ValidationError), + ): + model.model_validate({**arguments, "informed_by": ids}) + request = model.model_validate( + {**arguments, "informed_by": ["mem_" + "2" * 64]} + ) + self.assertEqual(["mem_" + "2" * 64], request.informed_by) + + def test_code_bindings_context_is_server_managed_on_every_creation_input( + self, + ) -> None: + cases = ( + (MemoryStoreInput, self.store), + (MemoryCreate, {"record_type": "decision", "content": "Imported note."}), + ( + MemoryCapturePromoteInput, + { + **self.store, + "candidate_id": "cap_" + "1" * 64, + }, + ), + ) + for model, arguments in cases: + with ( + self.subTest(model=model.__name__), + self.assertRaisesRegex( + ValidationError, "context.code_bindings is server-managed" + ), + ): + model.model_validate({**arguments, "context": {"code_bindings": []}}) + + def test_verification_rejects_unrecognized_or_inconsistent_evidence(self) -> None: + invalid = ( + {"kind": "unknown"}, + {"kind": "test", "extra": True}, + {"kind": "test", "exit_code": 2_147_483_648}, + {"kind": "command", "exit_code": -2_147_483_649}, + {"kind": "review", "command": "pytest"}, + {"kind": "review", "exit_code": 0}, + {"kind": "self_report", "command": "pytest"}, + {"kind": "self_report", "exit_code": 1}, + ) + for evidence in invalid: + with self.subTest(evidence=evidence), self.assertRaises(ValidationError): + OutcomeVerification.model_validate(evidence) + for kind in ("test", "command", "review", "self_report"): + evidence = OutcomeVerification(kind=kind) + self.assertEqual({"kind": kind}, evidence.model_dump(exclude_none=True)) + for exit_code in (-2_147_483_648, 2_147_483_647): + OutcomeVerification(kind="command", command="pytest", exit_code=exit_code) + + def test_code_refs_follow_relative_path_policy_and_limit(self) -> None: + for path in ("../outside.py", "/outside.py", "C:\\outside.py"): + with self.subTest(path=path), self.assertRaises(ValidationError): + CodeRef(relative_file_path=path) + ref = { + "relative_file_path": "src/runtime.py", + "qualified_name": "Runtime.start", + } + request = MemoryStoreInput.model_validate({**self.store, "code_refs": [ref]}) + self.assertEqual("Runtime.start", request.code_refs[0].qualified_name) + with self.assertRaises(ValidationError): + MemoryStoreInput.model_validate({**self.store, "code_refs": [ref] * 17}) + + +class MemoryLearningWriteTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.fixture = _Fixture(Path(self.temporary.name)) + self.writer = SQLiteMemoryEventWriter( + clock=lambda: NOW, + projection_scheduler=lambda _path: None, + max_workers=1, + ) + self.addCleanup(self.writer.close) + + def _payloads(self) -> list[dict[str, object]]: + with closing(sqlite3.connect(self.fixture.database)) as connection: + return [ + json.loads(row[0]) + for row in connection.execute( + "SELECT payload_json FROM memory_events ORDER BY stream_id,stream_version" + ) + ] + + async def test_unknown_and_foreign_provenance_are_rejected_without_events( + self, + ) -> None: + target_id = self.fixture.add_record(1) + foreign_id = "mem_" + "2" * 64 + with closing(sqlite3.connect(self.fixture.database)) as connection: + record = json.loads( + connection.execute("SELECT payload_json FROM memory_events").fetchone()[ + 0 + ] + )["record"] + EventStore(connection).append_and_project( + EventCommand( + workspace_id="ws_" + "f" * 24, + stream_id=foreign_id, + stream_kind="memory", + event_type="memory.created", + occurred_at_us=1, + recorded_at_us=1, + actor_type="client", + payload={"record": record}, + ) + ) + connection.commit() + for source in ("mem_" + "9" * 64, foreign_id): + with self.subTest(source=source): + with self.assertRaisesRegex(RuntimeServiceError, "INVALID_ARGUMENT"): + await self.writer.store( + self.fixture.workspace, _store_command(informed_by=(source,)) + ) + with self.assertRaisesRegex(RuntimeServiceError, "INVALID_ARGUMENT"): + await self.writer.record_outcome( + self.fixture.workspace, + _outcome_command(target_id, informed_by=(source,)), + ) + self.assertEqual(2, len(self._payloads())) + + async def test_self_provenance_is_rejected_for_store_and_outcome(self) -> None: + record_id = self.fixture.add_record(1) + with self.assertRaisesRegex(RuntimeServiceError, "INVALID_ARGUMENT"): + await self.writer.record_outcome( + self.fixture.workspace, + _outcome_command(record_id, informed_by=(record_id,)), + ) + command = _store_command() + self_id = deterministic_id( + "mem", + "memory-store", + self.fixture.workspace.workspace_id, + command.idempotency_key, + ) + with self.assertRaisesRegex(RuntimeServiceError, "INVALID_ARGUMENT"): + await self.writer.store( + self.fixture.workspace, replace(command, informed_by=(self_id,)) + ) + self.assertEqual(1, len(self._payloads())) + + async def test_missing_code_ref_and_invalid_rebind_are_rejected(self) -> None: + with self.assertRaisesRegex(RuntimeServiceError, "INVALID_ARGUMENT"): + await self.writer.store( + self.fixture.workspace, + _store_command(code_refs=(("src/missing.py", None),)), + ) + record_id = self.fixture.add_record(1) + for worked in (False, True): + with ( + self.subTest(worked=worked), + self.assertRaisesRegex(RuntimeServiceError, "INVALID_ARGUMENT"), + ): + await self.writer.record_outcome( + self.fixture.workspace, + _outcome_command(record_id, worked=worked, rebind_code=True), + ) + self.assertEqual(1, len(self._payloads())) + + async def test_environmental_code_ref_failure_is_atomic_and_restorable( + self, + ) -> None: + first = self.fixture.root / "first.txt" + blocked = self.fixture.root / "blocked.txt" + first.write_bytes(b"first") + blocked.write_bytes(b"unchanged") + command = _store_command(code_refs=(("first.txt", None), ("blocked.txt", None))) + original_open = type(blocked).open + + def refuse(path, *args, **kwargs): + if path == blocked: + raise PermissionError("temporary sharing violation") + return original_open(path, *args, **kwargs) + + with ( + patch.object(type(blocked), "open", refuse), + self.assertRaisesRegex(RuntimeServiceError, "CAPABILITY_DEGRADED"), + ): + await self.writer.store(self.fixture.workspace, command) + self.assertEqual([], self._payloads()) + stored = await self.writer.store(self.fixture.workspace, command) + before = self._payloads() + with ( + patch.object(type(blocked), "open", refuse), + self.assertRaisesRegex(RuntimeServiceError, "CAPABILITY_DEGRADED"), + ): + await self.writer.record_outcome( + self.fixture.workspace, + _outcome_command(stored.record.record_id, rebind_code=True), + ) + self.assertEqual(before, self._payloads()) + await self.writer.record_outcome( + self.fixture.workspace, + _outcome_command(stored.record.record_id, rebind_code=True), + ) + + async def test_oversized_code_ref_capture_is_invalid_and_atomic(self) -> None: + first = self.fixture.root / "first.txt" + large = self.fixture.root / "large.txt" + first.write_bytes(b"ok") + large.write_bytes(b"x\r\n" * 4) + with ( + patch("daem0nmcp.code_bindings._MAX_BINDING_FILE_BYTES", 8), + self.assertRaisesRegex(RuntimeServiceError, "INVALID_ARGUMENT"), + ): + await self.writer.store( + self.fixture.workspace, + _store_command(code_refs=(("first.txt", None), ("large.txt", None))), + ) + self.assertEqual([], self._payloads()) + + async def test_later_missing_shared_source_symbol_is_atomic_and_restorable( + self, + ) -> None: + if not default_code_indexer_factory().available: + self.skipTest("code parser is unavailable") + source = self.fixture.root / "handler.py" + first = "def first():\n return 1\n" + both = first + "\ndef second():\n return 2\n" + command = _store_command( + code_refs=(("handler.py", "first"), ("./handler.py", "second")) + ) + + def records(): + with closing(sqlite3.connect(self.fixture.database)) as connection: + return connection.execute( + "SELECT * FROM memory_records ORDER BY record_id" + ).fetchall() + + source.write_text(first, encoding="utf-8") + before_records = records() + with self.assertRaisesRegex(RuntimeServiceError, "INVALID_ARGUMENT"): + await self.writer.store(self.fixture.workspace, command) + self.assertEqual([], self._payloads()) + self.assertEqual(before_records, records()) + source.write_text(both, encoding="utf-8") + stored = await self.writer.store(self.fixture.workspace, command) + before_events = self._payloads() + before_records = records() + source.write_text(first, encoding="utf-8") + outcome = _outcome_command(stored.record.record_id, rebind_code=True) + with self.assertRaisesRegex(RuntimeServiceError, "INVALID_ARGUMENT"): + await self.writer.record_outcome(self.fixture.workspace, outcome) + self.assertEqual(before_events, self._payloads()) + self.assertEqual(before_records, records()) + source.write_text(both.replace("return 2", "return 3"), encoding="utf-8") + await self.writer.record_outcome(self.fixture.workspace, outcome) + self.assertEqual(2, len(self._payloads())) + + async def _assert_missing_parser_writes(self) -> None: + self.assertFalse(default_code_indexer_factory().available) + source = self.fixture.root / "handler.py" + source.write_text("def handler():\n return 1\n", encoding="utf-8") + with self.assertRaisesRegex(RuntimeServiceError, "CAPABILITY_DEGRADED"): + await self.writer.store( + self.fixture.workspace, + _store_command(code_refs=(("handler.py", "handler"),)), + ) + self.assertEqual([], self._payloads()) + stored = await self.writer.store( + self.fixture.workspace, + _store_command( + context={ + "code_bindings": [ + { + "relative_file_path": "handler.py", + "qualified_name": "handler", + "fingerprint": "0" * 64, + "head_commit": None, + } + ] + } + ), + ) + before = self._payloads() + with self.assertRaisesRegex(RuntimeServiceError, "CAPABILITY_DEGRADED"): + await self.writer.record_outcome( + self.fixture.workspace, + _outcome_command(stored.record.record_id, rebind_code=True), + ) + self.assertEqual(before, self._payloads()) + with closing(sqlite3.connect(self.fixture.database)) as connection: + self.assertEqual( + (None, None), + connection.execute( + "SELECT outcome, worked FROM memory_records WHERE record_id=?", + (stored.record.record_id,), + ).fetchone(), + ) + + def test_package_absence_capture_and_rebind_fail_without_events(self) -> None: + script = ( + "import sys; sys.modules['tree_sitter_language_pack'] = None\n" + "import unittest\n" + "from pathlib import Path\n" + "sys.path.insert(0, str(Path.cwd() / 'tests'))\n" + "from api_v7.test_memory_learning_writes import " + "MemoryLearningWriteTests\n" + "suite = unittest.TestSuite([MemoryLearningWriteTests(" + "'_assert_missing_parser_writes')])\n" + "result = unittest.TextTestRunner().run(suite)\n" + "sys.exit(0 if result.wasSuccessful() else 1)\n" + ) + result = subprocess.run( + [sys.executable, "-c", script], + cwd=Path(__file__).resolve().parents[2], + capture_output=True, + text=True, + timeout=60, + check=False, + ) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + + async def test_real_parser_failure_and_reference_errors_are_atomic(self) -> None: + if not default_code_indexer_factory().available: + self.skipTest("code parser is unavailable") + source = self.fixture.root / "handler.py" + source.write_text("def handler():\n return 1\n", encoding="utf-8") + stored = await self.writer.store( + self.fixture.workspace, + _store_command(code_refs=(("handler.py", "handler"),)), + ) + before = self._payloads() + cases = ( + ("handler.py", "def handler(:\n", "CAPABILITY_DEGRADED"), + ("handler.py", "def other():\n return 1\n", "INVALID_ARGUMENT"), + ("handler.md", "# handler\n", "INVALID_ARGUMENT"), + ( + "handler.py", + "class A:\n def handler(self):\n return 1\n\n" + "class B:\n def handler(self):\n return 2\n", + "INVALID_ARGUMENT", + ), + ) + for index, (relative, content, code) in enumerate(cases): + with self.subTest(relative=relative, content=content): + target = self.fixture.root / relative + target.write_text(content, encoding="utf-8") + with self.assertRaisesRegex(RuntimeServiceError, code): + await self.writer.store( + self.fixture.workspace, + _store_command( + idempotency_key=f"capture-failure-{index:04d}", + code_refs=(("handler.py", None), (relative, "handler")), + ), + ) + self.assertEqual(before, self._payloads()) + if relative == "handler.py": + with self.assertRaisesRegex(RuntimeServiceError, code): + await self.writer.record_outcome( + self.fixture.workspace, + _outcome_command(stored.record.record_id, rebind_code=True), + ) + self.assertEqual(before, self._payloads()) + source.write_text("def handler():\n return 2\n", encoding="utf-8") + await self.writer.record_outcome( + self.fixture.workspace, + _outcome_command(stored.record.record_id, rebind_code=True), + ) + self.assertEqual(2, len(self._payloads())) + + async def test_unsupported_symbol_rebind_is_a_client_error(self) -> None: + if not default_code_indexer_factory().available: + self.skipTest("code parser is unavailable") + source = self.fixture.root / "handler.md" + source.write_text("# handler\n", encoding="utf-8") + stored = await self.writer.store( + self.fixture.workspace, + _store_command( + context={ + "code_bindings": [ + { + "relative_file_path": "handler.md", + "qualified_name": "handler", + "fingerprint": "0" * 64, + "head_commit": None, + } + ] + } + ), + ) + before = self._payloads() + with self.assertRaisesRegex(RuntimeServiceError, "INVALID_ARGUMENT"): + await self.writer.record_outcome( + self.fixture.workspace, + _outcome_command(stored.record.record_id, rebind_code=True), + ) + self.assertEqual(before, self._payloads()) + + async def _recall_binding(self, mode: str): + config = Settings( + memory_validity_mode=mode, + retrieval_utility_mode="off", + retrieval_rerank_enabled=False, + ) + statuses = { + "local": "disabled", + "models-local": "disabled", + "graph": "disabled", + } + while await drain_projection_jobs( + self.fixture.database, + config=config, + max_jobs=100, + include_optional=False, + capability_statuses=statuses, + ): + pass + adapter = Task8RecallService(config=config, capability_statuses=statuses) + try: + return await adapter.retrieve( + self.fixture.workspace, + RetrievalQuery( + self.fixture.workspace.workspace_id, "bound handler guidance" + ), + frozenset(), + ) + finally: + adapter.close() + + async def _assert_binding_rebind(self, qualified_name: str | None) -> None: + source = self.fixture.root / "handler.py" + source.write_text( + "def handler(value):\n return value + 1\n", encoding="utf-8" + ) + stored = await self.writer.store( + self.fixture.workspace, + _store_command( + content="bound handler guidance", + code_refs=(("handler.py", qualified_name),), + ), + ) + bindings = self._payloads()[0]["record"]["context"]["code_bindings"] + self.assertEqual("handler.py", bindings[0]["relative_file_path"]) + original = await self._recall_binding("shadow") + self.assertEqual("current", original.items[0].applicability) + source.write_text( + "def handler(value):\n return value - 22\n", encoding="utf-8" + ) + shadow = await self._recall_binding("shadow") + expected = "handler.py" + (f"::{qualified_name}" if qualified_name else "") + self.assertEqual("needs_revalidation", shadow.items[0].applicability) + self.assertEqual([expected], shadow.items[0].changed_bindings) + self.assertEqual(original.rendered_context, shadow.rendered_context) + self.assertIn( + "VALIDITY_SHADOW", [d.reason for d in shadow.provider_diagnostics] + ) + disabled = await self._recall_binding("off") + self.assertIsNone(disabled.items[0].applicability) + self.assertEqual(original.rendered_context, disabled.rendered_context) + with patch( + "daem0nmcp.retrieval.service.BindingEvaluator.evaluate", + side_effect=RuntimeError("binding evaluation failed"), + ): + degraded = await self._recall_binding("apply") + self.assertIsNone(degraded.items[0].applicability) + self.assertEqual(original.rendered_context, degraded.rendered_context) + diagnostic = next( + d for d in degraded.provider_diagnostics if d.provider == "validity" + ) + self.assertEqual("degraded", diagnostic.status) + self.assertEqual("VALIDITY_FAILED", diagnostic.reason) + self.assertEqual(0, diagnostic.returned_count) + applied = await self._recall_binding("apply") + self.assertIn("Needs revalidation: " + expected, applied.rendered_context) + self.assertIn( + "VALIDITY_APPLIED", [d.reason for d in applied.provider_diagnostics] + ) + with self.assertRaisesRegex(RuntimeServiceError, "INVALID_ARGUMENT"): + await self.writer.record_outcome( + self.fixture.workspace, + _outcome_command( + stored.record.record_id, worked=False, rebind_code=True + ), + ) + await self.writer.record_outcome( + self.fixture.workspace, + _outcome_command(stored.record.record_id, rebind_code=True), + ) + refreshed = await self._recall_binding("apply") + self.assertEqual("current", refreshed.items[0].applicability) + self.assertEqual([], refreshed.items[0].changed_bindings) + self.assertNotIn("Needs revalidation:", refreshed.rendered_context) + self.assertNotEqual( + bindings[0]["fingerprint"], + self._payloads()[-1]["record"]["context"]["code_bindings"][0][ + "fingerprint" + ], + ) + + async def test_file_binding_recall_and_rebind(self) -> None: + await self._assert_binding_rebind(None) + + async def test_symbol_binding_recall_and_rebind(self) -> None: + if not default_code_indexer_factory().available: + self.skipTest("code parser is unavailable") + await self._assert_binding_rebind("handler.handler") + + async def test_bare_symbol_binding_recall_and_rebind(self) -> None: + if not default_code_indexer_factory().available: + self.skipTest("code parser is unavailable") + await self._assert_binding_rebind("handler") + + async def test_preexisting_store_hash_replays_with_default_new_fields(self) -> None: + source_id = self.fixture.add_record(1) + command = _store_command() + workspace_id = self.fixture.workspace.workspace_id + record_id = deterministic_id( + "mem", "memory-store", workspace_id, command.idempotency_key + ) + with closing(sqlite3.connect(self.fixture.database)) as connection: + original = json.loads( + connection.execute( + "SELECT payload_json FROM memory_events WHERE stream_id=?", + (source_id,), + ).fetchone()[0] + )["record"] + original.update( + content=command.content, + rationale=None, + context={}, + tags=[], + file_path_relative=None, + ) + event = EventStore(connection).append_and_project( + EventCommand( + workspace_id=workspace_id, + stream_id=record_id, + stream_kind="memory", + event_type="memory.created", + occurred_at_us=1, + recorded_at_us=1, + actor_type="client", + payload={ + "record": original, + "idempotency_request_hash": LEGACY_STORE_HASH, + }, + correlation_id=deterministic_id( + "job", + "memory-store-idempotency", + workspace_id, + command.idempotency_key, + ), + ) + ) + connection.commit() + replay = await self.writer.store(self.fixture.workspace, command) + self.assertTrue(replay.idempotent_replay) + self.assertEqual(event, replay.event) + self.assertEqual(2, len(self._payloads())) + + async def test_preexisting_outcome_hash_replays_with_default_new_fields( + self, + ) -> None: + record_id = self.fixture.add_record(1) + command = _outcome_command(record_id) + workspace_id = self.fixture.workspace.workspace_id + with closing(sqlite3.connect(self.fixture.database)) as connection: + record = json.loads( + connection.execute("SELECT payload_json FROM memory_events").fetchone()[ + 0 + ] + )["record"] + record.update(outcome=command.outcome_text, worked=command.worked) + event = EventStore(connection).append_and_project( + EventCommand( + workspace_id=workspace_id, + stream_id=record_id, + stream_kind="memory", + event_type="memory.outcome_recorded", + occurred_at_us=2, + recorded_at_us=2, + actor_type="client", + payload={ + "record": record, + "idempotency_request_hash": LEGACY_OUTCOME_HASH, + }, + correlation_id=deterministic_id( + "job", + "memory-record-outcome-idempotency", + workspace_id, + command.idempotency_key, + ), + ) + ) + connection.commit() + replay = await self.writer.record_outcome(self.fixture.workspace, command) + self.assertTrue(replay.idempotent_replay) + self.assertEqual(event, replay.event) + self.assertEqual(2, len(self._payloads())) + + async def test_provenance_and_verification_are_canonical_and_hash_bound( + self, + ) -> None: + sources = (self.fixture.add_record(2), self.fixture.add_record(1)) + command = _store_command(informed_by=sources) + stored = await self.writer.store(self.fixture.workspace, command) + replay = await self.writer.store( + self.fixture.workspace, + replace(command, informed_by=tuple(reversed(sources))), + ) + self.assertTrue(replay.idempotent_replay) + self.assertEqual(stored.event, replay.event) + with self.assertRaises(IdempotencyConflict): + await self.writer.store( + self.fixture.workspace, replace(command, informed_by=sources[:1]) + ) + outcome = _outcome_command( + stored.record.record_id, + informed_by=sources, + verification={"kind": "test", "command": "pytest", "exit_code": 0}, + ) + recorded = await self.writer.record_outcome(self.fixture.workspace, outcome) + replayed = await self.writer.record_outcome( + self.fixture.workspace, + replace(outcome, informed_by=tuple(reversed(sources))), + ) + self.assertTrue(replayed.idempotent_replay) + self.assertEqual(recorded.event, replayed.event) + for changed in ( + replace(outcome, informed_by=sources[:1]), + replace(outcome, verification=None), + replace(outcome, verification={"kind": "review"}), + ): + with self.assertRaises(IdempotencyConflict): + await self.writer.record_outcome(self.fixture.workspace, changed) + with closing(sqlite3.connect(self.fixture.database)) as connection: + for event in (stored.event, recorded.event): + payload = json.loads( + connection.execute( + "SELECT payload_json FROM memory_events WHERE event_id=?", + (event.event_id,), + ).fetchone()[0] + ) + self.assertEqual( + {"informed_by": sorted(sources)}, payload["provenance"] + ) + self.assertEqual(dict(outcome.verification), payload["verification"]) + signal = connection.execute( + "SELECT reward,weight FROM memory_outcome_signals WHERE event_id=?", + (recorded.event.event_id,), + ).fetchone() + self.assertEqual((1.0, 1.0), signal) + + async def test_bundle_replay_reproduces_learning_projections_exactly(self) -> None: + source_id = self.fixture.add_record(1) + child = await self.writer.store( + self.fixture.workspace, _store_command(informed_by=(source_id,)) + ) + for index, (worked, evidence) in enumerate( + ((True, {"kind": "test", "exit_code": 0}), (False, {"kind": "review"})), 1 + ): + await self.writer.record_outcome( + self.fixture.workspace, + _outcome_command( + child.record.record_id, + worked=worked, + informed_by=(source_id,), + verification=evidence, + idempotency_key=f"learning-outcome-{index:04d}", + happened_at=NOW + timedelta(seconds=index), + ), + ) + with ( + closing(sqlite3.connect(self.fixture.database)) as source, + closing(sqlite3.connect(":memory:")) as target, + ): + source.row_factory = sqlite3.Row + target.row_factory = sqlite3.Row + target.execute("PRAGMA foreign_keys=ON") + _apply_v7_schema(target) + bundle = export_event_bundle(source, self.fixture.workspace.workspace_id) + imported = import_event_bundle( + target, bundle, self.fixture.workspace.workspace_id + ) + target.commit() + self.assertEqual(4, imported.events_imported) + for table, order in ( + ("memory_provenance_edges", "event_id,informed_by_record_id"), + ("memory_outcome_signals", "event_id"), + ): + query = f"SELECT * FROM {table} ORDER BY {order}" + expected = [tuple(row) for row in source.execute(query)] + self.assertTrue(expected) + self.assertEqual( + expected, [tuple(row) for row in target.execute(query)] + ) + self.assertIsNone(target.execute("PRAGMA foreign_key_check").fetchone()) + replay = import_event_bundle( + target, bundle, self.fixture.workspace.workspace_id + ) + self.assertEqual(0, replay.events_imported) + self.assertEqual(4, replay.events_existing) diff --git a/tests/api_v7/test_models.py b/tests/api_v7/test_models.py index 4d2e78a..f68c3ab 100644 --- a/tests/api_v7/test_models.py +++ b/tests/api_v7/test_models.py @@ -141,6 +141,110 @@ def test_internal_error_cannot_carry_diagnostic_details(self) -> None: models.ApiError(**values) +class CovenantModelTests(unittest.TestCase): + def test_notice_requires_content_and_preserves_user_text(self) -> None: + _, models = _load(self) + with self.assertRaises(ValidationError): + models.CovenantNotice() + brief = models.CovenantBrief(briefed_at=NOW, workspace_statistics={}) + self.assertEqual([], brief.warnings) + self.assertEqual([], brief.failed_records) + record = models.RecordSummary( + record_id=RECORD_ID, + record_type="warning", + excerpt="Review the change.", + current_status="current", + content_hash="a" * 64, + created_at=NOW, + updated_at=NOW, + ) + for field in ("warnings", "failed_records"): + with self.subTest(field=field), self.assertRaises(ValidationError): + models.CovenantBrief( + briefed_at=NOW, workspace_statistics={}, **{field: [record] * 6} + ) + counsel = models.InlineCounsel( + target_tool="memory_store", must_do=["Inspect C:/project/file.py."] + ) + notice = models.CovenantNotice(auto_brief=brief, counsel=counsel) + self.assertEqual(counsel, notice.counsel) + for field in ("must_not", "ask_first"): + with self.subTest(field=field), self.assertRaises(ValidationError): + models.CovenantBrief( + briefed_at=NOW, workspace_statistics={}, **{field: ["text"] * 21} + ) + with self.assertRaises(ValidationError): + models.InlineCounsel(target_tool="memory_store", must_do=["text"] * 21) + with self.assertRaises(ValidationError): + models.InlineCounsel(target_tool="memory_store", must_do=["x" * 2001]) + + def test_challenge_bounds_and_error_code_binding(self) -> None: + errors, models = _load(self) + values = { + "guidance": models.PreflightGuidance(), + "preflight_token": "a" * 16, + "expires_at": NOW, + "reasons": ["RULE_MUST_NOT"], + } + challenge = models.CounselChallenge(**values) + for override in ( + {"reasons": []}, + {"reasons": ["RULE_WARNING"] * 8}, + {"reasons": ["UNKNOWN_REASON"]}, + {"preflight_token": "short"}, + {"preflight_token": "a" * 8193}, + {"expires_at": NOW.replace(tzinfo=None)}, + ): + with self.subTest(override=override), self.assertRaises(ValidationError): + models.CounselChallenge(**{**values, **override}) + common = { + "retryable": False, + "correlation_id": REQUEST_ID, + "counsel": challenge, + } + accepted = models.ApiError( + code=errors.ErrorCode.COUNSEL_REQUIRED, message="Review counsel.", **common + ) + self.assertEqual(challenge, accepted.counsel) + with self.assertRaisesRegex( + ValidationError, "counsel requires COUNSEL_REQUIRED" + ): + models.ApiError(code="NOT_FOUND", message="Not found.", **common) + with self.assertRaisesRegex(ValidationError, "INTERNAL_ERROR cannot carry"): + models.ApiError( + code="INTERNAL_ERROR", message=errors.INTERNAL_ERROR_MESSAGE, **common + ) + + def test_envelope_property_sets_include_optional_covenant_fields(self) -> None: + _, models = _load(self) + self.assertEqual( + set(models.ResponseMeta.model_json_schema()["properties"]), + { + "request_id", + "workspace_id", + "started_at", + "duration_ms", + "warnings", + "capability_states", + "covenant", + }, + ) + self.assertEqual( + set(models.ApiError.model_json_schema()["properties"]), + { + "code", + "message", + "retryable", + "retry_after_ms", + "field_errors", + "remedy", + "correlation_id", + "counsel", + }, + ) + self.assertIsNone(_meta(models).covenant) + + class PrimitiveBoundaryTests(unittest.TestCase): def test_public_ids_are_exact_lowercase_opaque_strings(self) -> None: _, models = _load(self) @@ -459,6 +563,65 @@ def test_retrieval_data_contains_selected_evidence_not_raw_candidates(self) -> N self.assertEqual(RECORD_ID, dumped["items"][0]["record"]["record_id"]) for forbidden in ("raw_score", "vector", "prompt", "candidate_text"): self.assertNotIn(forbidden, json.dumps(dumped, sort_keys=True)) + self.assertEqual( + retrieval, models.RetrievalData.model_validate_json(json.dumps(dumped)) + ) + for context in ( + "[E1] Selected evidence. [E99] Forged evidence.", + "Selected evidence without a citation.", + "[E1] Selected evidence. [E1] Repeated citation.", + ): + with self.subTest(context=context), self.assertRaises(ValidationError): + models.RetrievalData.model_validate_json( + json.dumps({**dumped, "rendered_context": context}) + ) + + neutralized_context = "[E1] Source text mentions [E99]." + neutralized = models.RetrievalData.model_validate_json( + json.dumps({**dumped, "rendered_context": neutralized_context}) + ) + self.assertEqual(neutralized_context, neutralized.rendered_context) + + second_item = {**dumped["items"][0], "citation": "[E2]"} + second_manifest = {**dumped["citation_manifest"][0], "citation": "[E2]"} + multiple = { + **dumped, + "items": [dumped["items"][0], second_item], + "citation_manifest": [dumped["citation_manifest"][0], second_manifest], + "rendered_context": "[E1] First evidence.\n[E2] Second evidence.", + } + self.assertEqual( + ["[E1]", "[E2]"], + [ + entry.citation + for entry in models.RetrievalData.model_validate_json( + json.dumps(multiple) + ).citation_manifest + ], + ) + with self.assertRaises(ValidationError): + models.RetrievalData.model_validate_json( + json.dumps( + { + **multiple, + "rendered_context": "[E2] Second evidence.\n[E1] First evidence.", + } + ) + ) + with self.assertRaises(ValidationError): + models.RetrievalData.model_validate_json( + json.dumps( + { + **multiple, + "items": [dumped["items"][0], dumped["items"][0]], + "citation_manifest": [ + dumped["citation_manifest"][0], + dumped["citation_manifest"][0], + ], + "rendered_context": "[E1] First evidence.\n[E1] Duplicate.", + } + ) + ) with self.assertRaises(ValidationError): models.RetrievalData( diff --git a/tests/api_v7/test_path_policy.py b/tests/api_v7/test_path_policy.py index e68af7c..bb21e62 100644 --- a/tests/api_v7/test_path_policy.py +++ b/tests/api_v7/test_path_policy.py @@ -66,6 +66,7 @@ GovernanceEventCommand, GovernanceEventStore, ) +from daem0nmcp.retrieval.projections import LexicalProjectionBuilder from daem0nmcp.retrieval.runtime import await_projection_job_drains from daem0nmcp.retrieval.specialized_projection import SpecializedProjectionBuilder from daem0nmcp.storage_activation import resolve_active_database @@ -150,6 +151,16 @@ def test_user_text_input_fields_accept_paths(text): assert validated.content == text +@pytest.mark.parametrize("text", [NEUTRAL, *PATH_CONTENT, *SEEDS]) +def test_code_ref_qualified_name_accepts_paths(text): + request = MemoryStoreInput.model_validate( + _store_input( + code_refs=[{"relative_file_path": "src/owned.py", "qualified_name": text}] + ) + ) + assert request.code_refs[0].qualified_name == text + + def test_server_generated_fields_reject_host_paths(): with pytest.raises(ValidationError): DiagnosticSummary(code="CODE_INDEX_SKIPPED", message="C:\\x") @@ -468,6 +479,7 @@ def _insert_rows(root: Path, workspace_id: str, texts: tuple[str, ...]) -> list[ ) ) connection.commit() + LexicalProjectionBuilder(connection).rebuild(workspace_id) SpecializedProjectionBuilder(connection).rebuild(workspace_id, "graph") DiscoveryProjectionBuilder(connection).populate_graph( workspace_id, @@ -489,13 +501,14 @@ def _insert_rows(root: Path, workspace_id: str, texts: tuple[str, ...]) -> list[ ), ), ) - # The graph rebuild queued by the events above just ran here; retire - # it so a later drain cannot swap the inserted entities for a new - # generation mid-test. + # Both projections are complete before reads start. Retire their queued + # rebuilds so lexical catch-up cannot race entity recall, or replace the + # graph generation containing the explicitly inserted entities. connection.execute( "UPDATE background_jobs SET status='succeeded',updated_at_us=?," "finished_at_us=? WHERE workspace_id=? AND status='queued' " - "AND idempotency_key='active-projection:graph'", + "AND idempotency_key IN " + "('active-projection:graph','active-projection:lexical')", (_now_us(), _now_us(), workspace_id), ) connection.commit() @@ -508,6 +521,7 @@ def _server(roots: tuple[Path, ...]): settings=Settings( project_root=str(roots[0]), workspace_roots=[str(root) for root in roots], + tool_surface="full", dream_enabled=False, ), environ=PROFILE_ENVIRONMENT, @@ -603,7 +617,12 @@ async def ok(tool: str, arguments: dict[str, Any]) -> dict[str, Any]: # The ingest URL is fetched before the topic is read, and tests have no # network, so every call stops at the URL. The topic's input type is # covered by test_ingest_topic_accepts_paths. -FETCH_FIRST = frozenset({"document_ingest_url.topic"}) +# CodeRef.qualified_name selects an existing symbol after its local file is +# fetched; arbitrary text seeds do not necessarily name a symbol in that file. +# Its wire path policy is covered by test_code_ref_qualified_name_accepts_paths. +FETCH_FIRST = frozenset( + {"document_ingest_url.topic", "memory_store.code_refs.0.qualified_name"} +) def _mentions_str(annotation: object) -> bool: @@ -690,6 +709,8 @@ def _seeded(tool: str, base: dict[str, Any], path: tuple, kind: object, text: st ) if name == "procedure_steps": target["record_type"] = "procedure" + if (tool, path) == ("memory_record_outcome", ("verification", "command")): + target["kind"] = "command" if (tool, path) == ("memory_preflight", ("target_arguments",)): # Seed the target tool's own argument, not the envelope. target["target_tool"] = "memory_store" @@ -699,10 +720,35 @@ def _seeded(tool: str, base: dict[str, Any], path: tuple, kind: object, text: st "idempotency_key": "mech-preflight-target-0001", } return arguments + if (tool, path) == ("daem0n_tool_call", ("arguments",)): + # Gateway payloads follow the selected target's schema, not a context bag. + target["tool"] = "memory_search_text" + target["arguments"] = {"query": text} + return arguments target[name] = _seed_value(kind, text) return arguments +@pytest.mark.parametrize("text", [NEUTRAL, *SEEDS]) +def test_nested_verification_command_seed_keeps_required_evidence_kind(text): + arguments = _seeded( + "memory_record_outcome", + { + "workspace_id": WS, + "record_id": "mem_" + "1" * 64, + "outcome_text": "Verified the change.", + "worked": True, + "idempotency_key": "path-evidence-0001", + }, + ("verification", "command"), + "str", + text, + ) + request = TOOL_INPUT_MODELS["memory_record_outcome"].model_validate(arguments) + assert request.verification.kind == "command" + assert request.verification.command == text + + def _fresh_keys(arguments: dict[str, Any], label: str) -> dict[str, Any]: if "idempotency_key" in arguments: digest = hashlib.sha256(label.encode()).hexdigest()[:40] @@ -732,6 +778,8 @@ async def _seed_free_text(invoke, workspace_id: str, tool: str, arguments: dict) labels = [] for path, kind in _free_text_paths(TOOL_INPUT_MODELS[tool]): label = f"{tool}.{'.'.join(map(str, path))}" + if (tool, path) == ("daem0n_tool_call", ("arguments",)): + label = "daem0n_tool_call.arguments.query" if label in FETCH_FIRST: continue labels.append(label) @@ -766,7 +814,7 @@ async def _read_everything(invoke, workspace_id: str, seed) -> dict[str, str]: if _is_write(tool) or tool in CROSS_WORKSPACE_TOOLS: continue plain = _fresh_keys(_own_read_arguments(arguments), f"{tool}-{len(codes)}") - codes[tool] = _code(await invoke(tool, {"workspace_id": workspace_id, **plain})) + codes[tool] = _code(await _target_call(invoke, workspace_id, tool, plain)) return codes @@ -876,6 +924,9 @@ async def test_every_free_text_field_accepts_paths_and_every_read_survives(tmp_p for tool, arguments in sorted(writes.items()): seeded += await _seed_free_text(invoke, a, tool, arguments) + # Finish admitted writer drains before installing the manual projections. + await await_projection_job_drains() + # Rows as a migrated v6 database or a pre-fix write left them. record_ids = _insert_rows(a_root, a, SEEDS) @@ -946,6 +997,7 @@ async def test_every_free_text_field_accepts_paths_and_every_read_survives(tmp_p "memory_store.procedure_steps", "memory_store_batch.records.0.content", "memory_preflight.target_arguments", + "daem0n_tool_call.arguments.query", "entity_evolution_trace.entity_name", "rule_update.patch.must_do", "workspace_link.label", diff --git a/tests/api_v7/test_pinned_handlers.py b/tests/api_v7/test_pinned_handlers.py index 3bb5f98..eee6c88 100644 --- a/tests/api_v7/test_pinned_handlers.py +++ b/tests/api_v7/test_pinned_handlers.py @@ -77,7 +77,7 @@ async def guidance( normalized_arguments: object, description: str | None, ) -> object: - from daem0nmcp.api.v7.tools import PreflightGuidance + from daem0nmcp.api.v7.models import PreflightGuidance expected = { "record_type": "decision", @@ -88,6 +88,8 @@ async def guidance( "relative_file_path": None, "happened_at": None, "procedure_steps": [], + "informed_by": [], + "code_refs": [], "idempotency_key": "decision-0001", } if ( @@ -166,6 +168,7 @@ async def retrieve( or query.candidate_limit != 21 or query.categories != frozenset({"decision"}) or query.tags != frozenset({"release"}) + or query.intent != "implement" or linked_workspace_ids != frozenset() ): raise AssertionError("recall request was not normalized exactly") @@ -200,6 +203,8 @@ async def store(self, workspace: Workspace, command: object) -> object: "happened_at": NOW, "procedure_steps": (), "idempotency_key": "decision-1001", + "informed_by": ("mem_" + "3" * 64,), + "code_refs": (("src/runtime.py", "Runtime.start"),), } actual = {name: getattr(command, name) for name in expected} if ( @@ -268,6 +273,9 @@ async def record_outcome(self, workspace: Workspace, command: object) -> object: "worked": True, "happened_at": NOW, "idempotency_key": "outcome-1001", + "informed_by": ("mem_" + "3" * 64,), + "verification": {"kind": "test", "exit_code": 0}, + "rebind_code": False, } actual = {name: getattr(command, name) for name in expected} if ( @@ -675,6 +683,7 @@ def test_every_workspace_bound_handler_envelopes_resolution_failure(self) -> Non "include_archived": False, "token_budget": 2400, "rerank": False, + "intent": None, }, ), ( @@ -829,6 +838,7 @@ def test_every_non_health_handler_requires_transport_identity_first(self) -> Non "include_archived": False, "token_budget": 2400, "rerank": False, + "intent": None, }, ), ( @@ -1140,8 +1150,9 @@ def test_preflight_rejects_target_arguments_that_do_not_match_the_model( self.assertEqual(gate.state_store.status(scope)["active_capabilities"], 0) def test_description_only_preflight_returns_guidance_without_a_token(self) -> None: + from daem0nmcp.api.v7.models import PreflightGuidance from daem0nmcp.api.v7.pinned import build_pinned_handlers - from daem0nmcp.api.v7.tools import MemoryPreflightOutput, PreflightGuidance + from daem0nmcp.api.v7.tools import MemoryPreflightOutput workspace = Workspace(WORKSPACE_ID, Path.cwd()) scope = InvocationScope("principal", "session", str(workspace.root)) @@ -1290,6 +1301,7 @@ def test_recall_passes_a_normalized_task8_query_and_envelopes_the_result( include_archived=False, token_budget=512, rerank=False, + intent="implement", ) ) except (AssertionError, NotImplementedError): @@ -1582,6 +1594,13 @@ def test_store_consumes_admission_and_passes_only_a_sanitized_event_command( "relative_file_path": "daem0nmcp/event_store.py", "happened_at": NOW, "procedure_steps": [], + "informed_by": ["mem_" + "3" * 64], + "code_refs": [ + { + "relative_file_path": "src/runtime.py", + "qualified_name": "Runtime.start", + } + ], "idempotency_key": "decision-1001", } token = gate.issue_preflight(scope, "memory_store", target) @@ -1764,6 +1783,8 @@ def test_record_outcome_uses_a_sanitized_task7_event_receipt(self) -> None: outcome_text="The rollout stayed healthy.", worked=True, happened_at=NOW, + informed_by=["mem_" + "3" * 64], + verification={"kind": "test", "exit_code": 0}, idempotency_key="outcome-1001", ) diff --git a/tests/api_v7/test_policy.py b/tests/api_v7/test_policy.py index e589510..ca3a441 100644 --- a/tests/api_v7/test_policy.py +++ b/tests/api_v7/test_policy.py @@ -26,6 +26,8 @@ GRANULAR_TOOLS = frozenset( { + "daem0n_tools_search", + "daem0n_tool_call", "active_context_list", "context_triggers_match", "session_updates_get", diff --git a/tests/api_v7/test_process_client.py b/tests/api_v7/test_process_client.py index cb24ee5..c64a848 100644 --- a/tests/api_v7/test_process_client.py +++ b/tests/api_v7/test_process_client.py @@ -92,9 +92,9 @@ async def test_production_ritual_and_restart(initialized_workspace, transport): "memory_record_outcome", "system_health", } <= names - denied = await call(session, "memory_recall", recall_arguments) - assert not denied["ok"] - assert denied["error"]["code"] == "COMMUNION_REQUIRED" + recalled = await call(session, "memory_recall", recall_arguments) + assert recalled["ok"], recalled + assert recalled["meta"]["covenant"]["auto_brief"] is not None await succeed(session, "session_brief", scope) health = await succeed( session, "system_health", {**scope, "include_components": True} @@ -163,8 +163,9 @@ async def test_production_ritual_and_restart(initialized_workspace, transport): health = await succeed(session, "system_health", scope) assert health["storage_format_version"] == 7 async with process_client(workspace.root, transport) as restarted: - denied = await call(restarted, "memory_recall", recall_arguments) - assert denied["error"]["code"] == "COMMUNION_REQUIRED" + recalled = await call(restarted, "memory_recall", recall_arguments) + assert recalled["ok"], recalled + assert recalled["meta"]["covenant"]["auto_brief"] is not None await succeed(restarted, "session_brief", scope) # A restarted server republishes the lexical generation in the # background, so the first recall after it can still read the diff --git a/tests/api_v7/test_process_workspace_isolation_sweep.py b/tests/api_v7/test_process_workspace_isolation_sweep.py index c180ca2..af23294 100644 --- a/tests/api_v7/test_process_workspace_isolation_sweep.py +++ b/tests/api_v7/test_process_workspace_isolation_sweep.py @@ -52,7 +52,11 @@ RuleResourceDocument, WarningResourceDocument, ) -from daem0nmcp.api.v7.tools import TOOL_DATA_MODELS, DiagnosticSummary +from daem0nmcp.api.v7.tools import ( + TOOL_DATA_MODELS, + TOOL_INPUT_MODELS, + DiagnosticSummary, +) from daem0nmcp.config import Settings from daem0nmcp.covenant import CovenantLevel from daem0nmcp.storage_activation import resolve_active_database @@ -71,6 +75,7 @@ APPS_AVAILABLE = importlib.util.find_spec("tree_sitter_language_pack") is not None PROFILE_ENVIRONMENT = { "DAEM0NMCP_PROFILE": "core", + "DAEM0NMCP_TOOL_SURFACE": "full", "DAEM0NMCP_APPS_ENABLED": "true", "DAEM0NMCP_GRAPH_ENABLED": "true", } @@ -246,7 +251,10 @@ async def _protected(invoke: Invoke, workspace_id: str, tool: str, arguments: di async def _target_call(invoke: Invoke, workspace_id: str, tool: str, arguments: dict): if _is_protected(tool): return await _protected(invoke, workspace_id, tool, arguments) - return await invoke(tool, {"workspace_id": workspace_id, **arguments}) + target = dict(arguments) + if "workspace_id" in TOOL_INPUT_MODELS[tool].model_fields: + target["workspace_id"] = workspace_id + return await invoke(tool, target) async def _seed_workspace_a(invoke: Invoke, a: str, b: str) -> Seed: @@ -379,6 +387,11 @@ def _sweep_arguments(seed: Seed) -> dict[str, dict[str, Any]]: cursor = seed.token("cursor") now = datetime.now(timezone.utc).isoformat() return { + "daem0n_tools_search": {"query": "memory versions"}, + "daem0n_tool_call": { + "tool": "memory_versions_list", + "arguments": {"record_id": record}, + }, "active_context_add": {"record_id": record, "reason": "sweep"}, "active_context_clear": { "selection_token": seed.token("active_context_selection") @@ -544,6 +557,8 @@ def _sweep_arguments(seed: Seed) -> dict[str, dict[str, Any]]: # without A's cursor or session (when there is one), then the target's own # arguments (OWN_VARIANTS). B and the copied-storage C behave identically. EXPECTED: dict[str, tuple[str, ...]] = { + "daem0n_tools_search": ("ok",), + "daem0n_tool_call": ("NOT_FOUND",), "active_context_add": ("NOT_FOUND",), "active_context_clear": ("TOKEN_TAMPERED",), "active_context_list": ( @@ -842,6 +857,7 @@ def server(roots): settings=Settings( project_root=str(a_root), workspace_roots=[str(root) for root in roots], + tool_surface="full", dream_enabled=False, ), environ=PROFILE_ENVIRONMENT, @@ -943,7 +959,10 @@ async def _jwt_pass(tmp_path, a_root, b_root, a, b, seed) -> None: a_root, "streamable-http", workspace_roots=(a_root, b_root), - environment_overrides=jwt_environment(issuer_url, policy_path), + environment_overrides={ + **jwt_environment(issuer_url, policy_path), + "DAEM0NMCP_TOOL_SURFACE": "full", + }, http_headers={"Authorization": "Bearer " + token()}, ) as session: await succeed(session, "session_brief", {"workspace_id": a}) @@ -961,6 +980,24 @@ def assert_denied(tool, denied): **_cross_arguments(seed.token("consolidation"), a), } for tool, arguments in sorted(targets.items()): + if "workspace_id" not in TOOL_INPUT_MODELS[tool].model_fields: + assert tool == "daem0n_tools_search" + result = await call(session, tool, arguments) + assert result["ok"], result + for value in _strings(result): + assert CANARY not in value, (tool, value) + for secret in (*seed.texts, *seed.ids.values(), a, b): + assert secret not in value, (tool, secret, value) + needles = { + str(path).lower() + for base in (a_root, b_root, Path.home()) + for path in (base, base.resolve()) + } + needles |= {Path(n).as_posix().lower() for n in needles} + assert not _host_path_leaks( + _response_model(tool), result, needles + ), (tool, result) + continue if _is_protected(tool): arguments = {**arguments, "preflight_token": placeholder_token} denied = await call(session, tool, {"workspace_id": b, **arguments}) diff --git a/tests/api_v7/test_production.py b/tests/api_v7/test_production.py index 8543d66..9505618 100644 --- a/tests/api_v7/test_production.py +++ b/tests/api_v7/test_production.py @@ -47,7 +47,7 @@ def test_stdio_surface_is_exact_and_uses_one_runtime_authority(self) -> None: ) self.assertEqual(set(surface.handlers), set(V7_TOOL_LEVELS)) - self.assertEqual(len(surface.manifest.tools), 75) + self.assertEqual(len(surface.manifest.tools), 77) self.assertEqual(len(surface.manifest.resources), 10) self.assertEqual( surface.workspace_resolver.default.root, @@ -167,19 +167,27 @@ def test_full_manifest_rejects_missing_or_unexpected_resources(self) -> None: def test_every_supported_operation_is_wired_not_placeholdered(self) -> None: from daem0nmcp.api.v7 import production + from daem0nmcp.api.v7.gateway_operations import GATEWAY_TOOL_NAMES from daem0nmcp.api.v7.pinned import PINNED_HANDLER_NAMES from daem0nmcp.api.v7.policy import V7_TOOL_LEVELS remaining_unimplemented: set[str] = set() expected = ( - set(V7_TOOL_LEVELS) - set(PINNED_HANDLER_NAMES) - remaining_unimplemented + set(V7_TOOL_LEVELS) + - set(PINNED_HANDLER_NAMES) + - set(GATEWAY_TOOL_NAMES) + - remaining_unimplemented ) observed: set[str] = set() original = production.build_v7_surface def capture(**kwargs): observed.update(kwargs["operations"]) - return original(**kwargs) + surface = original(**kwargs) + self.assertEqual( + set(surface.handlers) & GATEWAY_TOOL_NAMES, set(GATEWAY_TOOL_NAMES) + ) + return surface with patch( "daem0nmcp.api.v7.production.build_v7_surface", @@ -364,13 +372,13 @@ async def snapshot(_workspace, **_limits): ) def test_preflight_uses_one_snapshot_and_filters_failures_by_target(self) -> None: - from daem0nmcp.api.v7.models import RecordSummary + from daem0nmcp.api.v7.models import RecordSummary, RuleView from daem0nmcp.api.v7.production import _guidance_reader from daem0nmcp.api.v7.resource_repository import ( ResourceRepositoryReaders, ResourceRepositorySnapshot, ) - from daem0nmcp.api.v7.resources import ResourceRow, RuleView + from daem0nmcp.api.v7.resources import ResourceRow from daem0nmcp.workspace import Workspace now = datetime(2026, 1, 1, tzinfo=timezone.utc) diff --git a/tests/api_v7/test_resources.py b/tests/api_v7/test_resources.py index b490fb6..f70ae48 100644 --- a/tests/api_v7/test_resources.py +++ b/tests/api_v7/test_resources.py @@ -116,9 +116,9 @@ def _dependencies( class ResourceModelTests(unittest.TestCase): def test_documents_are_strict_json_objects_bounded_to_fifty_items(self) -> None: + from daem0nmcp.api.v7.models import RuleView from daem0nmcp.api.v7.resources import ( ActiveContextItem, - RuleView, WarningResourceDocument, ) @@ -291,11 +291,11 @@ async def test_failures_use_their_own_exact_authorization_scope(self) -> None: async def test_rules_and_active_context_return_only_highest_priority_active_items( self, ) -> None: + from daem0nmcp.api.v7.models import RuleView from daem0nmcp.api.v7.resources import ( ActiveContextItem, ResourceHandlers, ResourceRow, - RuleView, ) def rule(index: int, priority: int, *, enabled: bool = True) -> RuleView: diff --git a/tests/api_v7/test_responses.py b/tests/api_v7/test_responses.py index 6699685..b8d925a 100644 --- a/tests/api_v7/test_responses.py +++ b/tests/api_v7/test_responses.py @@ -57,6 +57,75 @@ def test_internal_error_cannot_echo_private_exception_details(self) -> None: self.assertNotIn("private", encoded) self.assertNotIn("secret-token", encoded) + def test_covenant_context_and_relay_preserve_public_diagnostics(self) -> None: + from daem0nmcp.api.v7.models import ( + ApiWarning, + CapabilityState, + CounselChallenge, + CovenantNotice, + ErrorRemedy, + FieldError, + InlineCounsel, + PreflightGuidance, + ) + from daem0nmcp.api.v7.responses import ResponseFactory + + factory = ResponseFactory( + clock=_Clock(), request_id=lambda: "req_gateway_request" + ) + original = factory.begin(None) + notice = CovenantNotice(counsel=InlineCounsel(target_tool="memory_store")) + context = original.with_covenant(notice) + self.assertIsNone(original.covenant) + self.assertEqual(original.request_id, context.request_id) + self.assertEqual(original.started_at, context.started_at) + self.assertEqual(notice, context.success(_Data(value="ok")).meta.covenant) + challenge = CounselChallenge( + guidance=PreflightGuidance(must_not=["Avoid unsafe changes."]), + preflight_token="a" * 16, + expires_at=datetime(2026, 1, 2, tzinfo=timezone.utc), + reasons=["RULE_MUST_NOT"], + ) + target = ( + ResponseFactory(clock=_Clock(), request_id=lambda: "req_target_request") + .begin(None) + .failure( + ErrorCode.COUNSEL_REQUIRED, + "Review counsel.", + retryable=True, + retry_after_ms=100, + field_errors=[ + FieldError(field="content", code="RISK", message="Review.") + ], + remedy_tool="memory_store", + remedy_arguments={"preflight_token": challenge.preflight_token}, + counsel=challenge, + ) + ) + remedy = ErrorRemedy( + tool="daem0n_tool_call", arguments={"tool": "memory_store"} + ) + warning = ApiWarning(code="DEGRADED", message="Provider unavailable.") + capability = CapabilityState(name="dense", status="disabled") + relayed = context.relay_failure( + target.error, + remedy=remedy, + warnings=[warning], + capability_states=[capability], + ) + self.assertEqual("req_gateway_request", relayed.error.correlation_id) + self.assertEqual(remedy, relayed.error.remedy) + self.assertEqual(challenge, relayed.error.counsel) + self.assertEqual(target.error.code, relayed.error.code) + self.assertEqual(target.error.message, relayed.error.message) + self.assertTrue(relayed.error.retryable) + self.assertEqual(100, relayed.error.retry_after_ms) + self.assertEqual(target.error.field_errors, relayed.error.field_errors) + self.assertEqual(notice, relayed.meta.covenant) + self.assertEqual([warning], relayed.meta.warnings) + self.assertEqual([capability], relayed.meta.capability_states) + self.assertIsNone(context.with_covenant(None).covenant) + def test_unknown_error_code_is_rejected(self) -> None: from daem0nmcp.api.v7.responses import ResponseFactory diff --git a/tests/api_v7/test_rule_trigger_operations.py b/tests/api_v7/test_rule_trigger_operations.py index f430263..853d030 100644 --- a/tests/api_v7/test_rule_trigger_operations.py +++ b/tests/api_v7/test_rule_trigger_operations.py @@ -254,7 +254,7 @@ async def test_registry_is_exact_immutable_and_keyword_only(self) -> None: async def test_rule_create_is_opaque_atomic_and_replay_safe(self) -> None: """A replay must not create a second row or expose its integer key.""" - from daem0nmcp.api.v7.resources import RuleView + from daem0nmcp.api.v7.models import RuleView from daem0nmcp.api.v7.rule_trigger_operations import ( RuleTriggerOperationError, ) diff --git a/tests/api_v7/test_runtime_services.py b/tests/api_v7/test_runtime_services.py index 70ee4fb..54800c7 100644 --- a/tests/api_v7/test_runtime_services.py +++ b/tests/api_v7/test_runtime_services.py @@ -451,8 +451,9 @@ async def test_recall_maps_task8_after_canonical_reauthentication( class Service: @staticmethod - async def retrieve(query): + async def retrieve(query, *, workspace_root=None): self.assertEqual(self.workspace.workspace_id, query.workspace_id) + self.assertEqual(self.workspace.root, workspace_root) return expected opened: list[Path] = [] @@ -507,14 +508,21 @@ class Service: def __init__(self): self.closed = 0 - async def retrieve(self, _query): + async def retrieve(self, _query, *, workspace_root=None): return expected def close(self): self.closed += 1 resolver = Resolver() - config = SimpleNamespace(profile="first") + config = SimpleNamespace( + retrieval_utility_mode="shadow", + retrieval_utility_weight=0.1, + retrieval_utility_credit="trace", + retrieval_utility_candidate_limit=25, + memory_validity_mode="shadow", + retrieval_retention_mode="shadow", + ) created: list[Service] = [] def factory(_path): @@ -539,14 +547,19 @@ def factory(_path): self.assertEqual(2, len(created)) self.assertEqual(1, created[0].closed) - config.profile = "second" + config.retrieval_utility_mode = "apply" await adapter.retrieve(self.workspace, query, frozenset()) self.assertEqual(3, len(created)) self.assertEqual(1, created[1].closed) + + config.retrieval_retention_mode = "apply" + await adapter.retrieve(self.workspace, query, frozenset()) + self.assertEqual(4, len(created)) + self.assertEqual(1, created[2].closed) finally: adapter.close() - self.assertEqual([1, 1, 1], [service.closed for service in created]) + self.assertEqual([1, 1, 1, 1], [service.closed for service in created]) async def test_default_hydration_capacity_admits_four_concurrent_recalls( self, @@ -558,7 +571,7 @@ async def test_default_hydration_capacity_admits_four_concurrent_recalls( expected = self._retrieval_result(stored) class Service: - async def retrieve(self, _query): + async def retrieve(self, _query, *, workspace_root=None): return expected adapter = Task8RecallService(service_factory=lambda _path: Service()) @@ -598,7 +611,7 @@ async def test_hydration_over_capacity_is_retryable_unavailable(self) -> None: expected = self._retrieval_result(stored) class Service: - async def retrieve(self, _query): + async def retrieve(self, _query, *, workspace_root=None): return expected adapter = Task8RecallService( @@ -659,7 +672,7 @@ def __init__(self, generation): self.generation = generation self.closed = 0 - async def retrieve(self, _query): + async def retrieve(self, _query, *, workspace_root=None): if self.generation == 1: started.set() await release.wait() @@ -756,7 +769,7 @@ class Service: def __init__(self, provider): self.provider = provider - async def retrieve(self, _query): + async def retrieve(self, _query, *, workspace_root=None): return expected def close(self): @@ -808,7 +821,7 @@ async def test_recall_rejects_tamper_blank_provider_and_raw_path( async def run(result): class Service: - async def retrieve(self, _query): + async def retrieve(self, _query, *, workspace_root=None): return result adapter = Task8RecallService( @@ -856,7 +869,7 @@ async def test_recall_rejects_federation_and_preserves_abstention( from daem0nmcp.retrieval import ProviderDiagnostic, RetrievalResult class Service: - async def retrieve(self, _query): + async def retrieve(self, _query, *, workspace_root=None): return RetrievalResult( providers=( ProviderDiagnostic( diff --git a/tests/api_v7/test_tool_manifest.py b/tests/api_v7/test_tool_manifest.py index 7e24413..1419483 100644 --- a/tests/api_v7/test_tool_manifest.py +++ b/tests/api_v7/test_tool_manifest.py @@ -55,6 +55,7 @@ READ_ONLY_TOOLS = frozenset( { + "daem0n_tools_search", "session_brief", "memory_capture_list", "memory_preflight", @@ -148,6 +149,7 @@ "include_archived", "token_budget", "rerank", + "intent", }, "memory_store": { "workspace_id", @@ -159,6 +161,8 @@ "relative_file_path", "happened_at", "procedure_steps", + "informed_by", + "code_refs", "idempotency_key", "preflight_token", }, @@ -168,9 +172,14 @@ "outcome_text", "worked", "happened_at", + "informed_by", + "verification", + "rebind_code", "idempotency_key", }, "system_health": {"workspace_id", "include_components"}, + "daem0n_tools_search": {"query", "limit"}, + "daem0n_tool_call": {"workspace_id", "tool", "arguments"}, } @@ -253,7 +262,7 @@ def test_manifest_is_exactly_the_policy_and_mapping_replacement_set(self) -> Non names = [spec.name for spec in specs] mapped_names = {name for row in V6_TO_V7_MAPPINGS for name in row.new_tools} - self.assertEqual(len(names), 75) + self.assertEqual(len(names), 77) self.assertEqual(len(names), len(set(names))) self.assertEqual(set(names), set(V7_TOOL_LEVELS)) self.assertEqual(set(names), mapped_names | V7_NATIVE_TOOL_NAMES) @@ -327,11 +336,36 @@ def test_all_models_are_closed_typed_and_have_envelope_outputs(self) -> None: _schema_property_names(output_schema), {"api_version", "ok", "data", "error", "meta"}, ) + self.assertEqual( + _schema_property_names(output_schema["$defs"]["ResponseMeta"]), + { + "request_id", + "workspace_id", + "started_at", + "duration_ms", + "warnings", + "capability_states", + "covenant", + }, + ) + self.assertEqual( + _schema_property_names(output_schema["$defs"]["ApiError"]), + { + "code", + "message", + "retryable", + "retry_after_ms", + "field_errors", + "remedy", + "correlation_id", + "counsel", + }, + ) encoded = str(input_schema).lower() self.assertNotIn("project_path", encoded) self.assertNotIn("'action'", encoded) - def test_six_pinned_inputs_have_exact_top_level_parameters(self) -> None: + def test_pinned_and_gateway_inputs_have_exact_top_level_parameters(self) -> None: # Catches nested request objects and accidental public signature drift. from daem0nmcp.api.v7.tools import build_tool_specs @@ -342,6 +376,24 @@ def test_six_pinned_inputs_have_exact_top_level_parameters(self) -> None: _schema_property_names(by_name[name].input_schema), expected ) + def test_gateway_classification_and_required_fields_are_exact(self) -> None: + from daem0nmcp.api.v7.tools import build_tool_specs + from daem0nmcp.covenant import CovenantLevel + + by_name = {spec.name: spec for spec in build_tool_specs(_handler_map())} + required_fields = { + "daem0n_tools_search": {"query"}, + "daem0n_tool_call": {"workspace_id", "tool"}, + } + for name, required in required_fields.items(): + with self.subTest(tool=name): + spec = by_name[name] + self.assertIn(name, V7_NATIVE_TOOL_NAMES) + self.assertIs(spec.covenant, CovenantLevel.EXEMPT) + self.assertEqual(spec.category, "system") + self.assertFalse(spec.pinned) + self.assertEqual(set(spec.input_schema["required"]), required) + def test_pinned_defaults_bounds_and_cross_field_rules_are_strict(self) -> None: # Catches coercion, unsafe paths, unbounded recall, and procedure leakage. from daem0nmcp.api.v7.tools import ( @@ -393,6 +445,41 @@ def test_pinned_defaults_bounds_and_cross_field_rules_are_strict(self) -> None: with self.assertRaises(ValidationError): MemoryStoreInput(**common, record_id="mem_" + "1" * 64) + def test_protected_tokens_are_optional_but_keep_value_bounds(self) -> None: + from daem0nmcp.api.v7.policy import V7ArgumentNormalizer + from daem0nmcp.api.v7.tools import TOOL_INPUT_MODELS, MemoryStoreInput + + for name, model in TOOL_INPUT_MODELS.items(): + token_field = model.model_fields.get("preflight_token") + if token_field is None: + continue + with self.subTest(tool=name): + self.assertFalse(token_field.is_required()) + self.assertIsNone(token_field.default) + self.assertNotIn( + "preflight_token", model.model_json_schema().get("required", []) + ) + common = { + "workspace_id": "ws_" + "a" * 24, + "record_type": "decision", + "content": "Use the event store.", + "idempotency_key": "decision-0001", + } + self.assertIsNone(MemoryStoreInput(**common).preflight_token) + self.assertIsNone( + MemoryStoreInput(**common, preflight_token=None).preflight_token + ) + normalizer = V7ArgumentNormalizer({"memory_store": MemoryStoreInput}) + expected = normalizer("memory_store", common, ".") + for token in (None, "capability-token-0001"): + self.assertEqual( + normalizer("memory_store", {**common, "preflight_token": token}, "."), + expected, + ) + for invalid in ("short", "a" * 8193, 123): + with self.subTest(token=invalid), self.assertRaises(ValidationError): + MemoryStoreInput(**common, preflight_token=invalid) + def test_exactly_one_selector_models_reject_zero_or_two_selectors(self) -> None: # Catches ambiguous entity/code lookups that could cross object scopes. from daem0nmcp.api.v7.tools import ( diff --git a/tests/test_backward_compat.py b/tests/test_backward_compat.py index c4afb91..9e449ce 100644 --- a/tests/test_backward_compat.py +++ b/tests/test_backward_compat.py @@ -8,13 +8,17 @@ class TestDeprecatedToolsRemovedFromMCP: @pytest.mark.asyncio async def test_exact_v7_tools_exposed(self): + from daem0nmcp.api.v7.gateway_operations import CORE_LISTED_TOOLS from daem0nmcp.api.v7.policy import V7_TOOL_LEVELS from daem0nmcp.server import mcp tools = {t.name for t in await mcp.list_tools()} - assert tools == set(V7_TOOL_LEVELS) - assert len(tools) == 75 - assert tools.isdisjoint( + assert tools == set(CORE_LISTED_TOOLS) + assert len(tools) == 9 + registered = {(await mcp.get_tool(name)).name for name in V7_TOOL_LEVELS} + assert registered == set(V7_TOOL_LEVELS) + assert len(registered) == 77 + assert registered.isdisjoint( { "commune", "consult", diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index ef5712c..c2ce470 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -307,6 +307,20 @@ def test_unset_models_local_is_disabled_on_unsupported_python(self): self.assertEqual(capability["status"], "disabled") self.assertEqual(capability["remediation"]["action"], "upgrade_python") + def test_late_interaction_requires_supported_python(self): + from daem0nmcp.capabilities import CapabilityRegistry + + with patch("daem0nmcp.capabilities.sys.version_info", (3, 10, 21)): + for environ, status in ( + ({}, "disabled"), + ({"DAEM0NMCP_LATE_INTERACTION_ENABLED": "true"}, "degraded"), + ): + capability = CapabilityRegistry( + environ=environ, module_available=lambda _: True + ).get("late-interaction") + self.assertEqual(status, capability["status"]) + self.assertEqual("upgrade_python", capability["remediation"]["action"]) + def test_invalid_enablement_value_is_failed_with_structured_remediation(self): """An invalid profile setting is a configuration failure, not a missing extra.""" from daem0nmcp.capabilities import CapabilityRegistry @@ -349,6 +363,7 @@ def test_profile_probes_cover_each_advertised_extra_dependency(self): from daem0nmcp.capabilities import PROFILES, CapabilityRegistry expected = { + "late-interaction": {"fastembed", "numpy"}, "graph": { "networkx", "python-igraph", @@ -380,7 +395,8 @@ def test_profile_probes_cover_each_advertised_extra_dependency(self): ), ) - self.assertEqual(registry.get(profile_name)["status"], "ready") + with patch("daem0nmcp.capabilities.sys.version_info", (3, 11, 0)): + self.assertEqual(registry.get(profile_name)["status"], "ready") self.assertEqual(set(probes), modules) diff --git a/tests/test_claude_hooks_pre_edit.py b/tests/test_claude_hooks_pre_edit.py index c6fc1cf..cddf998 100644 --- a/tests/test_claude_hooks_pre_edit.py +++ b/tests/test_claude_hooks_pre_edit.py @@ -78,10 +78,10 @@ def test_daem0n_project_exits_zero_with_reminder(tmp_path, project): output = json.loads(result.stdout)["hookSpecificOutput"] assert output["hookEventName"] == "PreToolUse" assert "permissionDecision" not in output - assert "memory_preflight" in output["additionalContext"] + assert "memory_preflight" not in output["additionalContext"] assert ( - 'memory_recall_file(relative_file_path="src/app.py")' - in output["additionalContext"] + 'daem0n_tool_call(tool="memory_recall_file", ' + 'arguments={"relative_file_path": "src/app.py"})' in output["additionalContext"] ) assert "\n" not in output["additionalContext"] @@ -91,7 +91,7 @@ def test_subdirectory_cwd_uses_the_enclosing_project(tmp_path, project): sub.mkdir() result = _pre_edit(_write_event(sub, "app.py"), tmp_path) assert result.returncode == 0 - assert 'relative_file_path="src/app.py"' in _context(result) + assert '"relative_file_path": "src/app.py"' in _context(result) def test_falls_back_to_claude_project_dir(tmp_path, project): @@ -100,7 +100,7 @@ def test_falls_back_to_claude_project_dir(tmp_path, project): event = _write_event(elsewhere, str(project / "notes.md")) result = _pre_edit(event, tmp_path, env={"CLAUDE_PROJECT_DIR": str(project)}) assert result.returncode == 0 - assert 'relative_file_path="notes.md"' in _context(result) + assert '"relative_file_path": "notes.md"' in _context(result) def test_home_directory_global_state_is_not_a_project(tmp_path): @@ -114,6 +114,8 @@ def test_path_outside_project_and_malformed_input_still_exit_zero(tmp_path, proj outside = _pre_edit(_write_event(project, str(tmp_path / "x.py")), tmp_path) assert outside.returncode == 0 assert "relative_file_path" not in _context(outside) + assert 'daem0n_tool_call(tool="memory_recall_file")' in _context(outside) + assert "arguments=" not in _context(outside) garbage = _pre_edit("not json", tmp_path) assert garbage.returncode == 0 @@ -123,7 +125,7 @@ def test_path_outside_project_and_malformed_input_still_exit_zero(tmp_path, proj def test_file_name_is_json_escaped_in_the_reminder(tmp_path, project): result = _pre_edit(_write_event(project, 'a"b).py'), tmp_path) assert result.returncode == 0 - assert 'relative_file_path="a\\"b).py"' in _context(result) + assert '"relative_file_path": "a\\"b).py"' in _context(result) @pytest.mark.parametrize( diff --git a/tests/test_claude_hooks_session_start.py b/tests/test_claude_hooks_session_start.py index dc0cd58..0a6c566 100644 --- a/tests/test_claude_hooks_session_start.py +++ b/tests/test_claude_hooks_session_start.py @@ -77,6 +77,10 @@ def test_main_outputs_v7_session_brief_call(tmp_path, monkeypatch, capsys): assert "[Daem0n] IMPORTANT" in captured.out assert "mcp__daem0nmcp__session_brief" in captured.out assert 'workspace_id="ws_' in captured.out + assert "first Daem0n call establishes" in captured.out + assert "meta.covenant.auto_brief" in captured.out + assert "for the full brief" in captured.out + assert "Before responding" not in captured.out assert str(tmp_path) not in captured.out diff --git a/tests/test_claude_hooks_stop.py b/tests/test_claude_hooks_stop.py index 41627eb..a0c09a0 100644 --- a/tests/test_claude_hooks_stop.py +++ b/tests/test_claude_hooks_stop.py @@ -73,7 +73,13 @@ async def test_suggests_decisions_without_writing_memory(tmp_project): result = await analyse_and_remember(str(tmp_project), messages, state) assert "The hook wrote nothing" in result.message assert "ask Claude" in result.message - assert "mcp__daem0nmcp__memory_preflight" in result.message + assert "memory_preflight" not in result.message + assert "preflight_token=" not in result.message + assert "idempotency_key=" in result.message + assert ( + "if COUNSEL_REQUIRED: review error.counsel, then retry error.remedy" + in result.message + ) assert "mcp__daem0nmcp__memory_store" in result.message # The standalone hook must never mutate storage behind the MCP boundary. @@ -97,6 +103,10 @@ async def test_anti_loop_prevents_spam(tmp_project): # First call produces output and mutates state r1 = await analyse_and_remember(str(tmp_project), messages, state) assert r1.message != "" + assert "call memory_store directly with a stable idempotency_key" in r1.message + assert "review error.counsel, then retry exactly error.remedy" in r1.message + assert "memory_preflight" not in r1.message + assert "preflight_token=" not in r1.message # Second call still produces output (count is now 1) r2 = await analyse_and_remember(str(tmp_project), messages, state) diff --git a/tests/test_code_bindings.py b/tests/test_code_bindings.py new file mode 100644 index 0000000..2763e6e --- /dev/null +++ b/tests/test_code_bindings.py @@ -0,0 +1,1100 @@ +"""Repository binding capture and validity semantics.""" + +from __future__ import annotations + +import hashlib +import os +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +from types import SimpleNamespace + +import pytest + +from daem0nmcp import code_bindings, code_indexer +from daem0nmcp.api.v7 import discovery_operations +from daem0nmcp.code_bindings import ( + BindingEvaluator, + CodeBindingError, + CodeBindingReferenceError, + CodeBindingUnavailableError, + binding_refs_from_context, + capture_bindings, +) +from daem0nmcp.workspace import WorkspaceRegistry + + +@pytest.fixture +def workspace(tmp_path, monkeypatch): + monkeypatch.setattr( + "daem0nmcp.api.v7.resource_repository.read_git_output_sync", lambda *_: None + ) + return WorkspaceRegistry([tmp_path], default_root=tmp_path).default + + +def context_for(workspace, refs): + return { + "code_bindings": [ + binding.to_json() for binding in capture_bindings(workspace, refs) + ] + } + + +def edit(path, text): + previous = path.stat().st_mtime_ns + path.write_text(text, encoding="utf-8") + os.utime(path, ns=(previous + 1_000_000, previous + 1_000_000)) + + +def require_parser(): + if not discovery_operations.default_code_indexer_factory().available: + pytest.skip("tree-sitter parser unavailable") + + +@pytest.mark.parametrize("whole_file_first", [False, True]) +def test_capture_shared_source_is_consistent_and_next_capture_is_fresh( + workspace, monkeypatch, whole_file_first +): + require_parser() + path = workspace.root / "sample.py" + target_spans = ( + "@overload\ndef target(value: int) -> int: ...", + "@overload\ndef target(value: str) -> str: ...", + "@route('original')\ndef target(value):\n return value", + ) + sibling_span = "@route('original')\ndef sibling():\n return 2" + original = ( + "from typing import overload\n\n" + + "\n\n".join((*target_spans, sibling_span)) + + "\n" + ).encode() + replacement = ( + original.replace(b"original", b"changed") + .replace(b"value: int", b"value: float") + .replace(b"return value", b"return value + 1") + .replace(b"return 2", b"return 200") + ) + refs = [ + ("sample.py", "sample.target"), + ("./sample.py", "sibling"), + ("sample.py", None), + ("./sample.py", "target"), + ("sample.py", "sample.sibling"), + ] + if whole_file_first: + refs.insert(0, refs.pop(2)) + path.write_bytes(original) + old_bindings = capture_bindings(workspace, refs) + expected = { + None: hashlib.sha256(original).hexdigest(), + "target": hashlib.sha256("\n".join(target_spans).encode()).hexdigest(), + "sibling": hashlib.sha256(sibling_span.encode()).hexdigest(), + } + assert [binding.fingerprint for binding in old_bindings] == [ + expected[name.rsplit(".", 1)[-1] if name else None] for _, name in refs + ] + path.write_bytes(replacement) + new_bindings = capture_bindings(workspace, refs) + assert all( + old.fingerprint != new.fingerprint + for old, new in zip(old_bindings, new_bindings, strict=True) + ) + path.write_bytes(original) + producer_type = type(discovery_operations.default_code_indexer_factory()) + parse = producer_type.index_source_strict + pending_mutation = True + + def parse_then_replace(producer, file_path, root, source): + nonlocal pending_mutation + entities = tuple(parse(producer, file_path, root, source)) + if pending_mutation: + pending_mutation = False + path.write_bytes(replacement) + return entities + + monkeypatch.setattr(producer_type, "index_source_strict", parse_then_replace) + captured = capture_bindings(workspace, refs) + assert path.read_bytes() == replacement + assert [(b.relative_file_path, b.qualified_name) for b in captured] == refs + assert captured == old_bindings + assert capture_bindings(workspace, refs) == new_bindings + + +def test_capture_shared_sources_preserve_workspace_symbol_identities(workspace): + require_parser() + nested_root = workspace.root / "sub" + nested_root.mkdir() + nested = WorkspaceRegistry([nested_root], default_root=nested_root).default + path = nested_root / "__init__.py" + path.write_bytes( + b"@route('original')\ndef handler():\n return 1\n\n" + b"def sibling():\n return 2\n" + ) + parent = capture_bindings( + workspace, + [ + ("sub/__init__.py", "sub.handler"), + ("sub/./__init__.py", "handler"), + ("sub/__init__.py", "sub.sibling"), + ("sub/__init__.py", None), + ], + ) + child = capture_bindings( + nested, + [ + ("__init__.py", "handler"), + ("./__init__.py", "handler"), + ("__init__.py", "sibling"), + ("__init__.py", None), + ], + ) + assert [b.fingerprint for b in parent] == [b.fingerprint for b in child] + with pytest.raises(CodeBindingError, match="missing"): + capture_bindings( + nested, [("__init__.py", "handler"), ("__init__.py", "sub.handler")] + ) + + +@pytest.mark.parametrize("invalid_name", [False, True]) +def test_capture_validates_and_resolves_later_shared_source_ref( + workspace, monkeypatch, invalid_name +): + require_parser() + path = workspace.root / "sample.py" + path.write_bytes(b"def target():\n return 1\n") + producer_type = type(discovery_operations.default_code_indexer_factory()) + parse = producer_type.index_source_strict + + def parse_then_remove(producer, file_path, root, source): + entities = tuple(parse(producer, file_path, root, source)) + path.unlink() + return entities + + monkeypatch.setattr(producer_type, "index_source_strict", parse_then_remove) + later_name = " " if invalid_name else "target" + message = "reference is invalid" if invalid_name else "missing or outside" + with pytest.raises(CodeBindingError, match=message): + capture_bindings( + workspace, [("sample.py", "target"), ("./sample.py", later_name)] + ) + + +def test_file_binding_detects_edit_and_deletion(workspace): + path = workspace.root / "notes.md" + path.write_bytes(b"original\r\n") + context = context_for(workspace, [("notes.md", None)]) + assert ( + context["code_bindings"][0]["fingerprint"] + == hashlib.sha256(b"original\n").hexdigest() + ) + assert binding_refs_from_context(context) == (("notes.md", None),) + evaluator = BindingEvaluator() + assert evaluator.evaluate(workspace.root, context).applicability == "current" + edit(path, "original\n") + assert evaluator.evaluate(workspace.root, context).applicability == "current" + edit(path, "modified\n") + result = evaluator.evaluate(workspace.root, context) + assert result.applicability == "needs_revalidation" + assert result.changed == ("notes.md",) + path.unlink() + assert evaluator.evaluate(workspace.root, context).changed == ("notes.md",) + + +@pytest.mark.parametrize("qualified_name", [None, "settings.target"]) +def test_binding_detects_content_changes_with_preserved_metadata( + workspace, qualified_name +): + if qualified_name is not None: + require_parser() + path = workspace.root / "settings.py" + original = b"def target():\n return 1\n" + path.write_bytes(original) + context = context_for(workspace, [("settings.py", qualified_name)]) + evaluator = BindingEvaluator() + assert evaluator.evaluate(workspace.root, context).applicability == "current" + metadata = path.stat() + path.write_bytes(original.replace(b"1", b"9")) + os.utime(path, ns=(metadata.st_atime_ns, metadata.st_mtime_ns)) + assert path.stat().st_size == metadata.st_size + assert path.stat().st_mtime_ns == metadata.st_mtime_ns + result = evaluator.evaluate(workspace.root, context) + assert result.applicability == "needs_revalidation" + label = "settings.py" + if qualified_name is not None: + label += "::" + qualified_name + assert result.changed == (label,) + path.write_bytes(original) + os.utime(path, ns=(metadata.st_atime_ns, metadata.st_mtime_ns)) + assert evaluator.evaluate(workspace.root, context).applicability == "current" + + +def test_symbol_binding_distinguishes_sibling_and_bound_edit(workspace): + require_parser() + path = workspace.root / "sample.py" + original = ( + "def target(value):\n return value + 1\n\n" + "def sibling(value):\n return value * 2\n" + ) + path.write_text(original, encoding="utf-8") + context = context_for(workspace, [("sample.py", "target")]) + evaluator = BindingEvaluator() + assert evaluator.evaluate(workspace.root, context).applicability == "current" + edit(path, original.replace("* 2", "* 3")) + assert evaluator.evaluate(workspace.root, context).applicability == "current" + edit(path, original.replace("+ 1", "+ 2")) + result = evaluator.evaluate(workspace.root, context) + assert result.applicability == "needs_revalidation" + assert result.changed == ("sample.py::target",) + edit(path, "def sibling(value):\n return value * 2\n") + assert evaluator.evaluate(workspace.root, context).changed == ("sample.py::target",) + + +def test_ambiguous_alias_is_rejected_and_qualified_binding_is_isolated(workspace): + require_parser() + path = workspace.root / "dup.py" + original = ( + "class A:\n def target(self):\n return 1\n\n" + "class B:\n def target(self):\n return 2\n" + ) + path.write_text(original, encoding="utf-8") + with pytest.raises(CodeBindingReferenceError, match="ambiguous"): + capture_bindings(workspace, [("dup.py", "target")]) + context = context_for(workspace, [("dup.py", "dup.A.target")]) + evaluator = BindingEvaluator() + edit(path, original.replace("return 2", "return 3")) + assert evaluator.evaluate(workspace.root, context).applicability == "current" + edit(path, original.replace("return 1", "return 4")) + result = evaluator.evaluate(workspace.root, context) + assert result.applicability == "needs_revalidation" + assert result.changed == ("dup.py::dup.A.target",) + + +def test_unique_alias_becoming_ambiguous_is_unverifiable(workspace): + require_parser() + path = workspace.root / "dup.py" + original = "class A:\n def target(self):\n return 1\n" + path.write_text(original, encoding="utf-8") + context = context_for(workspace, [("dup.py", "target")]) + assert ( + BindingEvaluator().evaluate(workspace.root, context).applicability == "current" + ) + edit(path, original + "\nclass B:\n def target(self):\n return 2\n") + result = BindingEvaluator().evaluate(workspace.root, context) + assert result.applicability == "unverifiable" + assert result.changed == () + + +@pytest.mark.parametrize("name", ["target", "sample.target"]) +def test_same_identity_overload_spans_remain_grouped(workspace, name): + require_parser() + path = workspace.root / "sample.py" + original = ( + "from typing import overload\n\n" + "@overload\ndef target(value: int) -> int: ...\n\n" + "@overload\ndef target(value: str) -> str: ...\n\n" + "def target(value):\n return value\n" + ) + path.write_text(original, encoding="utf-8") + context = context_for(workspace, [("sample.py", name)]) + evaluator = BindingEvaluator() + assert evaluator.evaluate(workspace.root, context).applicability == "current" + edit(path, original.replace("value: int", "value: float")) + result = evaluator.evaluate(workspace.root, context) + assert result.applicability == "needs_revalidation" + assert result.changed == (f"sample.py::{name}",) + + +def test_exact_identity_takes_precedence_over_short_name_match(workspace): + require_parser() + path = workspace.root / "__init__.py" + original = ( + "def target():\n return 1\n\n" + "class A:\n def target(self):\n return 2\n" + ) + path.write_text(original, encoding="utf-8") + context = context_for(workspace, [("__init__.py", "target")]) + evaluator = BindingEvaluator() + edit(path, original.replace("return 2", "return 3")) + assert evaluator.evaluate(workspace.root, context).applicability == "current" + edit(path, original.replace("return 1", "return 4")) + assert ( + evaluator.evaluate(workspace.root, context).applicability + == "needs_revalidation" + ) + + +@pytest.mark.parametrize("nested_first", [False, True]) +def test_symbol_snapshots_isolate_nested_workspace_identities(workspace, nested_first): + require_parser() + nested_root = workspace.root / "sub" + nested_root.mkdir() + nested = WorkspaceRegistry([nested_root], default_root=nested_root).default + path = nested_root / "__init__.py" + original = ( + "@route('original')\ndef handler():\n return 1\n\n" + "def sibling():\n return 2\n" + ) + path.write_text(original, encoding="utf-8") + parent_bound = context_for(workspace, [("sub/__init__.py", "sub.handler")]) + nested_bound = context_for(nested, [("__init__.py", "handler")]) + parent_sibling = context_for(workspace, [("sub/__init__.py", "sub.sibling")]) + nested_sibling = context_for(nested, [("__init__.py", "sibling")]) + assert ( + parent_bound["code_bindings"][0]["fingerprint"] + == nested_bound["code_bindings"][0]["fingerprint"] + ) + evaluations = [ + (workspace.root, parent_bound, parent_sibling), + (nested.root, nested_bound, nested_sibling), + ] + if nested_first: + evaluations.reverse() + evaluator = BindingEvaluator() + with evaluator.read_batch(): + for root, bound, sibling in evaluations: + assert evaluator.evaluate(root, bound).applicability == "current" + assert evaluator.evaluate(root, sibling).applicability == "current" + wrong_root_identity = { + "code_bindings": [ + { + **parent_bound["code_bindings"][0], + "relative_file_path": "__init__.py", + } + ] + } + result = evaluator.evaluate(nested.root, wrong_root_identity) + assert result.applicability == "needs_revalidation" + assert result.changed == ("__init__.py::sub.handler",) + for root, bound, sibling in evaluations: + assert evaluator.evaluate(root, bound).applicability == "current" + assert evaluator.evaluate(root, sibling).applicability == "current" + edit(path, original.replace("return 2", "return 3")) + with evaluator.read_batch(): + for root, bound, sibling in evaluations: + assert evaluator.evaluate(root, bound).applicability == "current" + result = evaluator.evaluate(root, sibling) + assert result.applicability == "needs_revalidation" + binding = sibling["code_bindings"][0] + assert result.changed == ( + f"{binding['relative_file_path']}::{binding['qualified_name']}", + ) + edit(path, original.replace("@route('original')", "@route('changed')")) + with evaluator.read_batch(): + for root, bound, sibling in evaluations: + result = evaluator.evaluate(root, bound) + assert result.applicability == "needs_revalidation" + binding = bound["code_bindings"][0] + assert result.changed == ( + f"{binding['relative_file_path']}::{binding['qualified_name']}", + ) + assert evaluator.evaluate(root, sibling).applicability == "current" + edit(path, original) + with evaluator.read_batch(): + for root, bound, sibling in evaluations: + assert evaluator.evaluate(root, bound).applicability == "current" + assert evaluator.evaluate(root, sibling).applicability == "current" + + +@pytest.mark.parametrize("identity", [None, "", " ", 12]) +def test_alias_without_usable_parser_identity_is_unverifiable( + workspace, monkeypatch, identity +): + require_parser() + path = workspace.root / "sample.py" + path.write_text("def target():\n return 1\n", encoding="utf-8") + context = context_for(workspace, [("sample.py", "target")]) + producer = discovery_operations.default_code_indexer_factory() + + class MissingIdentityProducer: + available = True + + def get_supported_extensions(self): + return producer.get_supported_extensions() + + def index_source_strict(self, *args): + return [ + {**entity, "qualified_name": identity} + for entity in producer.index_source_strict(*args) + ] + + monkeypatch.setattr( + discovery_operations, "default_code_indexer_factory", MissingIdentityProducer + ) + with pytest.raises(CodeBindingUnavailableError, match="usable symbol identity"): + capture_bindings(workspace, [("sample.py", "target")]) + result = BindingEvaluator().evaluate(workspace.root, context) + assert result.applicability == "unverifiable" + assert result.changed == () + + +@pytest.mark.parametrize("fallback", [False, True]) +@pytest.mark.parametrize("short_name", [False, True]) +@pytest.mark.parametrize("kind", ["method", "function", "class"]) +@pytest.mark.parametrize("mutation", ["add", "change", "remove", "multiline"]) +def test_python_decorator_changes_revalidate_binding( + workspace, monkeypatch, fallback, short_name, kind, mutation +): + require_parser() + if fallback: + monkeypatch.delitem(code_indexer.ENTITY_QUERIES, "python") + path = workspace.root / "sample.py" + if kind == "method": + prefix, indent = "class Service:\n", " " + definition = "def target(value):\n return value\n" + qualified_name = "sample.Service.target" + elif kind == "function": + prefix, indent = "", "" + definition = "def target(value):\n return value\n" + qualified_name = "sample.target" + else: + prefix, indent = "", "" + definition = "class target:\n value = 1\n" + qualified_name = "sample.target" + before = "" if mutation == "add" else f"{indent}@route('old')\n" + if mutation == "multiline": + before = f"{indent}@route(\n{indent} 'old',\n{indent})\n" + after = { + "add": f"{indent}@route('new')\n", + "change": f"{indent}@route('new')\n", + "remove": "", + "multiline": f"{indent}@route(\n{indent} 'new',\n{indent})\n", + }[mutation] + original = prefix + before + indent + definition + path.write_text(original, encoding="utf-8") + name = "target" if short_name else qualified_name + context = context_for(workspace, [("sample.py", name)]) + evaluator = BindingEvaluator() + assert evaluator.evaluate(workspace.root, context).applicability == "current" + edit(path, prefix + after + indent + definition) + result = evaluator.evaluate(workspace.root, context) + assert result.applicability == "needs_revalidation" + assert result.changed == (f"sample.py::{name}",) + + +@pytest.mark.parametrize("fallback", [False, True]) +def test_python_sibling_decorator_does_not_revalidate_binding( + workspace, monkeypatch, fallback +): + require_parser() + if fallback: + monkeypatch.delitem(code_indexer.ENTITY_QUERIES, "python") + path = workspace.root / "sample.py" + original = ( + "class Service:\n" + " @staticmethod\n" + " def target(value):\n" + " return value\n" + " @route('old')\n" + " def sibling(value):\n" + " return value\n" + ) + path.write_text(original, encoding="utf-8") + context = context_for(workspace, [("sample.py", "sample.Service.target")]) + evaluator = BindingEvaluator() + edit(path, original.replace("@route('old')", "@route('new')")) + assert evaluator.evaluate(workspace.root, context).applicability == "current" + edit(path, original.replace("@staticmethod", "@classmethod")) + assert ( + evaluator.evaluate(workspace.root, context).applicability + == "needs_revalidation" + ) + + +@pytest.mark.parametrize("fallback", [False, True]) +def test_typescript_decorator_owns_intervening_comment( + workspace, monkeypatch, fallback +): + require_parser() + if fallback: + monkeypatch.delitem(code_indexer.ENTITY_QUERIES, "typescript") + path = workspace.root / "sample.ts" + original = ( + "class Service {\n" + " @route('old')\n" + " // attached comment\n" + " target() { return 1; }\n" + " // ordinary comment containing @route('old')\n" + " sibling() { return 2; }\n" + "}\n" + ) + path.write_text(original, encoding="utf-8") + bound = context_for(workspace, [("sample.ts", "sample.Service.target")]) + ordinary = context_for(workspace, [("sample.ts", "sample.Service.sibling")]) + evaluator = BindingEvaluator() + edit(path, original.replace(" @route('old')", " @route('new')")) + assert ( + evaluator.evaluate(workspace.root, bound).applicability == "needs_revalidation" + ) + assert evaluator.evaluate(workspace.root, ordinary).applicability == "current" + edit(path, original.replace("attached comment", "changed attached comment")) + assert ( + evaluator.evaluate(workspace.root, bound).applicability == "needs_revalidation" + ) + edit(path, original.replace("ordinary comment", "changed ordinary comment")) + assert evaluator.evaluate(workspace.root, bound).applicability == "current" + assert evaluator.evaluate(workspace.root, ordinary).applicability == "current" + + +@pytest.mark.parametrize("extension", ["py", "ts"]) +def test_query_and_fallback_bindings_are_interchangeable( + workspace, monkeypatch, extension +): + require_parser() + path = workspace.root / f"sample.{extension}" + if extension == "py": + language = "python" + original = ( + "class Service:\n" + " @route(\n" + " 'old',\n" + " )\n" + " def target(self):\n" + " return 1\n" + ) + else: + language = "typescript" + original = ( + "class Service {\n" + " @route('old')\n" + " // attached comment\n" + " target() { return 1; }\n" + "}\n" + ) + path.write_text(original, encoding="utf-8") + refs = [(path.name, "sample.Service.target")] + query_context = context_for(workspace, refs) + monkeypatch.delitem(code_indexer.ENTITY_QUERIES, language) + fallback_context = context_for(workspace, refs) + assert fallback_context == query_context + evaluator = BindingEvaluator() + assert evaluator.evaluate(workspace.root, query_context).applicability == "current" + edit(path, original.replace("'old'", "'new'")) + for context in (query_context, fallback_context): + assert ( + evaluator.evaluate(workspace.root, context).applicability + == "needs_revalidation" + ) + + +@pytest.mark.parametrize( + "bounds", + [ + {"binding_line_start": None}, + {"binding_line_start": True}, + {"binding_line_start": "1"}, + {"binding_line_start": 1.0}, + {"binding_line_start": 0}, + {"binding_line_start": 3}, + {"line_start": True}, + {"line_end": True}, + {"line_end": 100}, + ], +) +def test_invalid_binding_span_is_unverifiable(workspace, monkeypatch, bounds): + require_parser() + path = workspace.root / "sample.py" + path.write_text("@route('old')\ndef target():\n return 1\n", encoding="utf-8") + context = context_for(workspace, [("sample.py", "sample.target")]) + producer = discovery_operations.default_code_indexer_factory() + + class InvalidSpanProducer: + available = True + + def get_supported_extensions(self): + return producer.get_supported_extensions() + + def index_source_strict(self, *args): + return [ + {**entity, **bounds} for entity in producer.index_source_strict(*args) + ] + + monkeypatch.setattr( + discovery_operations, "default_code_indexer_factory", InvalidSpanProducer + ) + result = BindingEvaluator().evaluate(workspace.root, context) + assert result.applicability == "unverifiable" + assert result.changed == () + with pytest.raises(CodeBindingError): + capture_bindings(workspace, [("sample.py", "sample.target")]) + + +def test_legacy_decorated_binding_requires_revalidation(workspace, monkeypatch): + require_parser() + path = workspace.root / "sample.py" + path.write_text("@route('old')\ndef target():\n return 1\n", encoding="utf-8") + producer = discovery_operations.default_code_indexer_factory() + + class NavigationSpanProducer: + available = True + + def get_supported_extensions(self): + return producer.get_supported_extensions() + + def index_source_strict(self, *args): + return [ + { + key: value + for key, value in entity.items() + if key != "binding_line_start" + } + for entity in producer.index_source_strict(*args) + ] + + with monkeypatch.context() as patch: + patch.setattr( + discovery_operations, "default_code_indexer_factory", NavigationSpanProducer + ) + context = context_for(workspace, [("sample.py", "sample.target")]) + assert ( + BindingEvaluator().evaluate(workspace.root, context).applicability + == "current" + ) + assert ( + BindingEvaluator().evaluate(workspace.root, context).applicability + == "needs_revalidation" + ) + + +def test_capture_rejects_workspace_escape(workspace): + with pytest.raises(CodeBindingError): + capture_bindings(workspace, [("../outside.py", None)]) + + +def test_capture_rejects_oversized_file(workspace): + path = workspace.root / "large.txt" + with path.open("wb") as output: + output.truncate(5 * 1024 * 1024 + 1) + with pytest.raises(CodeBindingReferenceError, match="size limit"): + capture_bindings(workspace, [("large.txt", None)]) + + +def test_oversized_newlines_do_not_prove_normalized_content_changed( + workspace, monkeypatch +): + monkeypatch.setattr(code_bindings, "_MAX_BINDING_FILE_BYTES", 8) + path = workspace.root / "notes.txt" + original = b"x\n" * 4 + path.write_bytes(original) + context = context_for(workspace, [("notes.txt", None)]) + evaluator = BindingEvaluator() + assert evaluator.evaluate(workspace.root, context).applicability == "current" + expanded = original.replace(b"\n", b"\r\n") + assert ( + hashlib.sha256(expanded.replace(b"\r\n", b"\n")).hexdigest() + == (context["code_bindings"][0]["fingerprint"]) + ) + path.write_bytes(expanded) + result = evaluator.evaluate(workspace.root, context) + assert result.applicability == "unverifiable" + assert result.changed == () + with pytest.raises(CodeBindingReferenceError, match="size limit"): + capture_bindings(workspace, [("notes.txt", None)]) + path.write_bytes(original) + assert evaluator.evaluate(workspace.root, context).applicability == "current" + + +@pytest.mark.parametrize("operation", ["resolve", "stat", "open"]) +@pytest.mark.parametrize("error_type", [PermissionError, OSError]) +def test_environmental_file_fault_is_unverifiable_and_restores_current( + workspace, monkeypatch, operation, error_type +): + path = workspace.root / "notes.txt" + path.write_bytes(b"unchanged") + changed = workspace.root / "changed.txt" + changed.write_bytes(b"before") + context = context_for(workspace, [("notes.txt", None)]) + mixed = context_for(workspace, [("notes.txt", None), ("changed.txt", None)]) + changed.write_bytes(b"after") + evaluator = BindingEvaluator() + assert evaluator.evaluate(workspace.root, context).applicability == "current" + original = getattr(type(path), operation) + + def refuse(self, *args, **kwargs): + if self == path: + raise error_type("temporary filesystem fault") + return original(self, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(type(path), operation, refuse) + result = evaluator.evaluate(workspace.root, context) + assert result.applicability == "unverifiable" + assert result.changed == () + result = evaluator.evaluate(workspace.root, mixed) + assert result.applicability == "needs_revalidation" + assert result.changed == ("changed.txt",) + if operation != "stat": + with pytest.raises(CodeBindingUnavailableError): + capture_bindings(workspace, [("notes.txt", None)]) + else: + assert capture_bindings(workspace, [("notes.txt", None)]) + assert evaluator.evaluate(workspace.root, context).applicability == "current" + + +@pytest.mark.parametrize("operation", ["resolve", "stat"]) +def test_batch_memo_does_not_hide_environmental_fault( + workspace, monkeypatch, operation +): + path = workspace.root / "notes.txt" + path.write_bytes(b"notes") + context = context_for(workspace, [("notes.txt", None)]) + evaluator = BindingEvaluator() + original = getattr(type(path), operation) + + def refuse(self, *args, **kwargs): + if self == path: + raise PermissionError("temporary filesystem fault") + return original(self, *args, **kwargs) + + with evaluator.read_batch(budget_bytes=5): + assert evaluator.evaluate(workspace.root, context).applicability == "current" + with monkeypatch.context() as patch: + patch.setattr(type(path), operation, refuse) + result = evaluator.evaluate(workspace.root, context) + assert result.applicability == "unverifiable" + assert result.changed == () + assert evaluator.evaluate(workspace.root, context).applicability == "current" + + +@pytest.mark.parametrize( + "error_type", [FileNotFoundError, NotADirectoryError, IsADirectoryError] +) +@pytest.mark.parametrize("operation", ["resolve", "stat", "open"]) +def test_definite_path_fault_remains_changed( + workspace, monkeypatch, error_type, operation +): + path = workspace.root / "notes.txt" + path.write_bytes(b"notes") + context = context_for(workspace, [("notes.txt", None)]) + original = getattr(type(path), operation) + + def refuse(self, *args, **kwargs): + if self == path: + raise error_type("definite path fault") + return original(self, *args, **kwargs) + + monkeypatch.setattr(type(path), operation, refuse) + result = BindingEvaluator().evaluate(workspace.root, context) + assert result.applicability == "needs_revalidation" + assert result.changed == ("notes.txt",) + if operation != "stat": + with pytest.raises(CodeBindingError) as caught: + capture_bindings(workspace, [("notes.txt", None)]) + assert type(caught.value) is CodeBindingError + else: + assert capture_bindings(workspace, [("notes.txt", None)]) + + +@pytest.mark.parametrize("mutation", ["deleted", "directory", "escape"]) +def test_definite_filesystem_mutation_remains_changed( + workspace, tmp_path_factory, mutation +): + path = workspace.root / "notes.txt" + path.write_bytes(b"notes") + context = context_for(workspace, [("notes.txt", None)]) + path.unlink() + if mutation == "directory": + path.mkdir() + elif mutation == "escape": + outside = tmp_path_factory.mktemp("outside-binding") / "notes.txt" + outside.write_bytes(b"notes") + try: + path.symlink_to(outside) + except OSError: + pytest.skip("symlink creation unavailable") + result = BindingEvaluator().evaluate(workspace.root, context) + assert result.applicability == "needs_revalidation" + assert result.changed == ("notes.txt",) + with pytest.raises(CodeBindingError) as caught: + capture_bindings(workspace, [("notes.txt", None)]) + assert type(caught.value) is CodeBindingError + + +def test_unavailable_parser_is_unverifiable(workspace, monkeypatch): + require_parser() + path = workspace.root / "sample.py" + path.write_text( + "def target():\n return 1\n\ndef sibling():\n return 2\n", + encoding="utf-8", + ) + context = context_for(workspace, [("sample.py", "sample.target")]) + sibling = context_for(workspace, [("sample.py", "sample.sibling")]) + evaluator = BindingEvaluator() + assert evaluator.evaluate(workspace.root, context).applicability == "current" + monkeypatch.setattr( + discovery_operations, + "default_code_indexer_factory", + lambda: SimpleNamespace(available=False), + ) + for bound in (context, sibling): + result = evaluator.evaluate(workspace.root, bound) + assert result.applicability == "unverifiable" + assert result.changed == () + with pytest.raises(CodeBindingError): + capture_bindings(workspace, [("sample.py", "sample.target")]) + + +def test_parse_failure_is_unverifiable(workspace): + require_parser() + path = workspace.root / "sample.py" + path.write_text("def target():\n return 1\n", encoding="utf-8") + context = context_for(workspace, [("sample.py", "sample.target")]) + edit(path, "def target(:\n") + assert ( + BindingEvaluator().evaluate(workspace.root, context).applicability + == "unverifiable" + ) + + +@pytest.mark.parametrize( + "raw", [None, {}, [], [None], [{"relative_file_path": "sample.py"}]] +) +def test_malformed_binding_context_is_rejected(workspace, raw): + context = {"code_bindings": raw} + with pytest.raises(CodeBindingError): + binding_refs_from_context(context) + with pytest.raises(CodeBindingError): + BindingEvaluator().evaluate(workspace.root, context) + + +def test_missing_file_takes_precedence_over_unverifiable(workspace, monkeypatch): + require_parser() + (workspace.root / "sample.py").write_text( + "def target():\n return 1\n", encoding="utf-8" + ) + (workspace.root / "notes.md").write_text("notes", encoding="utf-8") + context = context_for( + workspace, [("sample.py", "sample.target"), ("notes.md", None)] + ) + (workspace.root / "notes.md").unlink() + monkeypatch.setattr( + discovery_operations, + "default_code_indexer_factory", + lambda: SimpleNamespace(available=False), + ) + result = BindingEvaluator().evaluate(workspace.root, context) + assert result.applicability == "needs_revalidation" + assert result.changed == ("notes.md",) + + +def test_git_head_metadata(workspace, monkeypatch): + path = workspace.root / "notes.md" + path.write_text("notes", encoding="utf-8") + head = "a" * 40 + monkeypatch.setattr( + "daem0nmcp.api.v7.resource_repository.read_git_output_sync", + lambda *_: head.encode() + b"\n", + ) + assert capture_bindings(workspace, [("notes.md", None)])[0].head_commit == head + monkeypatch.setattr( + "daem0nmcp.api.v7.resource_repository.read_git_output_sync", + lambda *_: b"invalid", + ) + assert capture_bindings(workspace, [("notes.md", None)])[0].head_commit is None + + +def test_no_bindings_returns_none(workspace): + assert BindingEvaluator().evaluate(workspace.root, {}) is None + + +@pytest.mark.parametrize("qualified_name", [None, "settings.target"]) +def test_read_batch_refreshes_content_after_preserved_metadata_edit( + workspace, qualified_name +): + if qualified_name is not None: + require_parser() + path = workspace.root / "settings.py" + original = b"def target():\n return 1\n" + path.write_bytes(original) + context = context_for(workspace, [("settings.py", qualified_name)]) + evaluator = BindingEvaluator() + with evaluator.read_batch(): + assert evaluator.evaluate(workspace.root, context).applicability == "current" + metadata = path.stat() + path.write_bytes(original.replace(b"1", b"9")) + os.utime(path, ns=(metadata.st_atime_ns, metadata.st_mtime_ns)) + assert evaluator.evaluate(workspace.root, context).applicability == "current" + label = "settings.py" + if qualified_name is not None: + label += "::" + qualified_name + result = evaluator.evaluate(workspace.root, context) + assert result.applicability == "needs_revalidation" + assert result.changed == (label,) + with evaluator.read_batch(): + assert ( + evaluator.evaluate(workspace.root, context).applicability + == "needs_revalidation" + ) + + +@pytest.mark.parametrize("mutation", ["missing", "oversized"]) +def test_read_batch_rechecks_file_constraints_before_memo(workspace, mutation): + path = workspace.root / "notes.md" + path.write_bytes(b"notes") + context = context_for(workspace, [("notes.md", None)]) + evaluator = BindingEvaluator() + with evaluator.read_batch(budget_bytes=5): + assert evaluator.evaluate(workspace.root, context).applicability == "current" + if mutation == "missing": + path.unlink() + else: + with path.open("wb") as output: + output.truncate(5 * 1024 * 1024 + 1) + result = evaluator.evaluate(workspace.root, context) + if mutation == "missing": + assert result.applicability == "needs_revalidation" + assert result.changed == ("notes.md",) + else: + assert result.applicability == "unverifiable" + assert result.changed == () + + +def test_zero_read_budget_preserves_missing_binding_precedence(workspace): + (workspace.root / "notes.md").write_bytes(b"notes") + (workspace.root / "gone.md").write_bytes(b"gone") + context = context_for(workspace, [("notes.md", None)]) + mixed = context_for(workspace, [("notes.md", None), ("gone.md", None)]) + (workspace.root / "gone.md").unlink() + evaluator = BindingEvaluator() + with evaluator.read_batch(budget_bytes=0): + result = evaluator.evaluate(workspace.root, context) + assert result.applicability == "unverifiable" + assert result.changed == () + result = evaluator.evaluate(workspace.root, mixed) + assert result.applicability == "needs_revalidation" + assert result.changed == ("gone.md",) + + +def test_exact_raw_byte_budget_allows_eof_and_free_memo_hits(workspace): + source = b"notes\r\n" + (workspace.root / "notes.md").write_bytes(source) + (workspace.root / "small.md").write_bytes(b"x") + (workspace.root / "empty.md").write_bytes(b"") + context = context_for(workspace, [("notes.md", None)]) + small = context_for(workspace, [("small.md", None)]) + empty = context_for(workspace, [("empty.md", None)]) + evaluator = BindingEvaluator() + with evaluator.read_batch(budget_bytes=len(source)): + assert evaluator.evaluate(workspace.root, context).applicability == "current" + assert evaluator.evaluate(workspace.root, context).applicability == "current" + for refused in (small, empty): + result = evaluator.evaluate(workspace.root, refused) + assert result.applicability == "unverifiable" + assert result.changed == () + with evaluator.read_batch(budget_bytes=1): + assert evaluator.evaluate(workspace.root, empty).applicability == "current" + assert evaluator.evaluate(workspace.root, small).applicability == "current" + assert evaluator.evaluate(workspace.root, empty).applicability == "current" + + +def test_stat_budget_refusal_leaves_room_for_later_small_files(workspace): + (workspace.root / "large.md").write_bytes(b"too large") + (workspace.root / "small.md").write_bytes(b"ok") + large = context_for(workspace, [("large.md", None)]) + small = context_for(workspace, [("small.md", None)]) + evaluator = BindingEvaluator() + with evaluator.read_batch(budget_bytes=2): + result = evaluator.evaluate(workspace.root, large) + assert result.applicability == "unverifiable" + assert result.changed == () + assert evaluator.evaluate(workspace.root, small).applicability == "current" + + +@pytest.mark.parametrize("grow_past_file_limit", [False, True]) +def test_file_growth_during_read_never_verifies_partial_source( + workspace, monkeypatch, grow_past_file_limit +): + path = workspace.root / "notes.md" + path.write_bytes(b"abc") + (workspace.root / "small.md").write_bytes(b"x") + context = context_for(workspace, [("notes.md", None)]) + small = context_for(workspace, [("small.md", None)]) + budget = 5 * 1024 * 1024 if grow_past_file_limit else 3 + original_open = type(path).open + grown = False + + def grow_before_read(self, mode="r", *args, **kwargs): + nonlocal grown + if self == path and mode == "rb" and not grown: + grown = True + self.write_bytes(b"abc" + b"x" * (budget - 2)) + return original_open(self, mode, *args, **kwargs) + + monkeypatch.setattr(type(path), "open", grow_before_read) + evaluator = BindingEvaluator() + with evaluator.read_batch(budget_bytes=budget): + result = evaluator.evaluate(workspace.root, context) + assert result.applicability == "unverifiable" + assert result.changed == () + assert evaluator.evaluate(workspace.root, small).applicability == "unverifiable" + result = evaluator.evaluate(workspace.root, context) + if grow_past_file_limit: + assert result.applicability == "unverifiable" + assert result.changed == () + else: + assert result.applicability == "needs_revalidation" + assert result.changed == ("notes.md",) + with evaluator.read_batch(budget_bytes=1): + assert evaluator.evaluate(workspace.root, small).applicability == "current" + + +@pytest.mark.parametrize("budget", [-1, True, False, 1.5, "1", None]) +def test_read_batch_rejects_invalid_budgets(workspace, budget): + evaluator = BindingEvaluator() + with ( + pytest.raises(ValueError, match="nonnegative integer"), + evaluator.read_batch(budget_bytes=budget), + ): + pass + + +def test_read_batch_rejects_nesting_and_resets_after_exception(workspace): + path = workspace.root / "notes.md" + path.write_bytes(b"old") + context = context_for(workspace, [("notes.md", None)]) + evaluator = BindingEvaluator() + with ( + pytest.raises(RuntimeError, match="abort batch"), + evaluator.read_batch(budget_bytes=3), + ): + assert evaluator.evaluate(workspace.root, context).applicability == "current" + with ( + pytest.raises(RuntimeError, match="already active"), + evaluator.read_batch(), + ): + pass + path.write_bytes(b"new") + assert evaluator.evaluate(workspace.root, context).applicability == "current" + raise RuntimeError("abort batch") + assert ( + evaluator.evaluate(workspace.root, context).applicability + == "needs_revalidation" + ) + with evaluator.read_batch(budget_bytes=0): + assert ( + evaluator.evaluate(workspace.root, context).applicability == "unverifiable" + ) + + +def test_read_batches_are_instance_local(workspace): + (workspace.root / "notes.md").write_bytes(b"abc") + context = context_for(workspace, [("notes.md", None)]) + first, second = BindingEvaluator(), BindingEvaluator() + with first.read_batch(budget_bytes=0), second.read_batch(budget_bytes=3): + assert first.evaluate(workspace.root, context).applicability == "unverifiable" + assert second.evaluate(workspace.root, context).applicability == "current" + + +def test_concurrent_read_batches_keep_independent_budgets(workspace): + (workspace.root / "notes.md").write_bytes(b"abc") + context = context_for(workspace, [("notes.md", None)]) + evaluator = BindingEvaluator() + barrier = Barrier(2) + + def evaluate_with_budget(budget): + with evaluator.read_batch(budget_bytes=budget): + barrier.wait(timeout=10) + return evaluator.evaluate(workspace.root, context) + + with ThreadPoolExecutor(max_workers=2) as workers: + results = list(workers.map(evaluate_with_budget, [3, 0])) + assert [result.applicability for result in results] == ["current", "unverifiable"] + assert all(result.changed == () for result in results) diff --git a/tests/test_code_indexer.py b/tests/test_code_indexer.py index dc3386d..871bc6b 100644 --- a/tests/test_code_indexer.py +++ b/tests/test_code_indexer.py @@ -139,6 +139,18 @@ class Logger { shutil.rmtree(temp_dir) +@pytest.fixture +async def db_manager(temp_project): + from daem0nmcp.database import DatabaseManager + + db = DatabaseManager(str(temp_project / ".daem0nmcp" / "storage")) + try: + await db.init_db() + yield db + finally: + await db.close() + + class TestTreeSitterIndexer: """Tests for TreeSitterIndexer.""" @@ -319,16 +331,6 @@ def test_extract_signature(self, temp_project): class TestCodeIndexManager: """Tests for CodeIndexManager.""" - @pytest.fixture - async def db_manager(self, temp_project): - """Create a database manager for testing.""" - from daem0nmcp.database import DatabaseManager - - db = DatabaseManager(str(temp_project / ".daem0nmcp" / "storage")) - await db.init_db() - yield db - await db.close() - @pytest.mark.asyncio async def test_index_project(self, temp_project, db_manager): """Test indexing an entire project.""" @@ -589,20 +591,6 @@ def test_cpp_extensions(self): class TestCodeEntityModel: """Tests for the CodeEntity model (no tree-sitter required).""" - @pytest.fixture - async def db_manager(self): - """Create a database manager for testing.""" - import tempfile - - from daem0nmcp.database import DatabaseManager - - temp_dir = Path(tempfile.mkdtemp()) - db = DatabaseManager(str(temp_dir / ".daem0nmcp" / "storage")) - await db.init_db() - yield db - await db.close() - shutil.rmtree(temp_dir, ignore_errors=True) - @pytest.mark.asyncio async def test_create_code_entity(self, db_manager): """Test creating a CodeEntity.""" @@ -638,20 +626,6 @@ async def test_create_code_entity(self, db_manager): class TestMemoryCodeRefModel: """Tests for the MemoryCodeRef model (no tree-sitter required).""" - @pytest.fixture - async def db_manager(self): - """Create a database manager for testing.""" - import tempfile - - from daem0nmcp.database import DatabaseManager - - temp_dir = Path(tempfile.mkdtemp()) - db = DatabaseManager(str(temp_dir / ".daem0nmcp" / "storage")) - await db.init_db() - yield db - await db.close() - shutil.rmtree(temp_dir, ignore_errors=True) - @pytest.mark.asyncio async def test_create_memory_code_ref(self, db_manager): """Test creating a MemoryCodeRef.""" diff --git a/tests/test_coding_memory_eval.py b/tests/test_coding_memory_eval.py new file mode 100644 index 0000000..fb65e6f --- /dev/null +++ b/tests/test_coding_memory_eval.py @@ -0,0 +1,155 @@ +"""Production-path coding-memory evaluation headroom and deterministic reports.""" + +from __future__ import annotations + +import asyncio +import copy +import math +import tempfile +import unittest +from pathlib import Path + +from benchmarks.coding_memory_eval import run_evaluation +from daem0nmcp.api.v7.discovery_operations import default_code_indexer_factory +from daem0nmcp.schema_version import CURRENT_SCHEMA_VERSION + + +class CodingMemoryEvaluationTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + async def evaluate_twice(): + first = await run_evaluation(mode="lexical_only", topics=6, seed=20261004) + second = await run_evaluation(mode="lexical_only", topics=6, seed=20261004) + return first, second + + cls.first, cls.second = asyncio.run(evaluate_twice()) + + def test_non_latency_report_is_identical_across_real_runs(self): + first, second = copy.deepcopy(self.first), copy.deepcopy(self.second) + for report in (first, second): + for arm in report["arms"].values(): + del arm["latency_ms"] + self.assertEqual(first, second) + + def test_baseline_has_graded_outcome_headroom(self): + self.assertLess( + self.first["arms"]["baseline"]["ndcg_at_10"]["outcome_reuse"], 0.9 + ) + + def test_verified_outcomes_improve_reuse(self): + arms = self.first["arms"] + self.assertGreaterEqual( + arms["utility_trace"]["ndcg_at_10"]["outcome_reuse"], + arms["baseline"]["ndcg_at_10"]["outcome_reuse"] + 0.05, + ) + + def test_trace_credit_improves_over_single_step_on_chains(self): + arms = self.first["arms"] + self.assertGreater( + arms["utility_trace"]["ndcg_at_10"]["provenance_chain"], + arms["utility_single"]["ndcg_at_10"]["provenance_chain"], + ) + + def test_validity_detects_file_edits_even_without_symbol_parsing(self): + validity = self.first["arms"]["validity_apply"]["validity"] + self.assertEqual(1.0, validity["stale_flag_recall"]) + self.assertEqual(0.0, validity["false_flag_rate"]) + baseline = self.first["arms"]["baseline"]["validity"] + self.assertIsNone(baseline["stale_flag_recall"]) + self.assertIsNone(baseline["false_flag_rate"]) + if not getattr(default_code_indexer_factory(), "available", False): + self.assertIsNone(validity["symbol"]) + + def test_validity_detects_bound_symbols_not_sibling_edits(self): + if not getattr(default_code_indexer_factory(), "available", False): + self.skipTest( + "tree-sitter grammars unavailable; file bindings still evaluated" + ) + validity = self.first["arms"]["validity_apply"]["validity"] + self.assertEqual(1.0, validity["stale_flag_recall"]) + self.assertEqual(0.0, validity["false_flag_rate"]) + self.assertEqual( + {"stale_flag_recall": 1.0, "false_flag_rate": 0.0}, validity["symbol"] + ) + + def test_all_arms_and_metadata_are_reported(self): + self.assertEqual( + { + "baseline", + "utility_single", + "utility_trace", + "validity_apply", + "retention_apply", + "all_apply", + }, + set(self.first["arms"]), + ) + metadata = self.first["metadata"] + self.assertEqual("lexical_only", metadata["mode"]) + self.assertEqual(6, metadata["topics"]) + self.assertEqual(20261004, metadata["seed"]) + self.assertEqual(CURRENT_SCHEMA_VERSION, metadata["schema_version"]) + self.assertEqual( + { + "local": "disabled", + "models-local": "disabled", + "graph": "disabled", + "late-interaction": "disabled", + }, + metadata["capability_statuses"], + ) + self.assertEqual(16, metadata["retention_note_tag_count"]) + self.assertEqual("procedure", metadata["retention_background_note_record_type"]) + self.assertIn("category-diversity", metadata["retention_stress_reason"]) + self.assertEqual(0.2, metadata["utility_weight"]) + self.assertEqual(set(self.first["arms"]), set(metadata["arm_overrides"])) + for name, arm in self.first["arms"].items(): + with self.subTest(arm=name): + self.assertEqual( + { + "ndcg_at_10", + "mrr_at_10", + "validity", + "retention", + "tokens", + "latency_ms", + }, + set(arm), + ) + self.assertEqual( + {"outcome_reuse", "provenance_chain", "overall"}, + set(arm["ndcg_at_10"]), + ) + for metric in ( + *arm["ndcg_at_10"].values(), + arm["mrr_at_10"]["overall"], + arm["retention"]["required_fact_retention"], + ): + self.assertGreaterEqual(metric, 0.0) + self.assertLessEqual(metric, 1.0) + self.assertTrue(math.isfinite(arm["tokens"]["rendered_mean"])) + self.assertGreater(arm["tokens"]["rendered_mean"], 0) + self.assertGreaterEqual(arm["latency_ms"]["p50"], 0) + self.assertGreaterEqual( + arm["latency_ms"]["p95"], arm["latency_ms"]["p50"] + ) + self.assertTrue(math.isfinite(arm["latency_ms"]["p95"])) + + def test_exclusively_owned_workspace_is_removed_after_runs(self): + scratch = ( + Path(tempfile.gettempdir()) + / "daem0nmcp-coding-eval-lexical_only-6-20261004" + ) + self.assertFalse(scratch.exists()) + + +class CodingMemoryEvaluationInputTests(unittest.IsolatedAsyncioTestCase): + async def test_invalid_parameters_fail_before_creating_workspace(self): + for arguments in ( + {"mode": "scripted"}, + {"topics": 0}, + {"topics": True}, + {"seed": True}, + ): + with self.subTest(arguments=arguments), self.assertRaises(ValueError): + await run_evaluation(**arguments) diff --git a/tests/test_event_bundle.py b/tests/test_event_bundle.py index 18637a2..2e7a23b 100644 --- a/tests/test_event_bundle.py +++ b/tests/test_event_bundle.py @@ -10,7 +10,7 @@ from pathlib import Path -def _migration_16_statements(): +def _event_schema_statements(): path = ( Path(__file__).resolve().parents[1] / "daem0nmcp" / "migrations" / "schema.py" ) @@ -18,7 +18,12 @@ def _migration_16_statements(): assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - return next(item[2] for item in module.MIGRATIONS if item[0] == 16) + return [ + statement + for version, _description, statements in module.MIGRATIONS + if version in {16, 33} + for statement in statements + ] class EventBundleTests(unittest.TestCase): @@ -28,7 +33,7 @@ def _connection(self) -> sqlite3.Connection: connection = sqlite3.connect(":memory:") connection.row_factory = sqlite3.Row connection.execute("PRAGMA foreign_keys=ON") - for statement in _migration_16_statements(): + for statement in _event_schema_statements(): connection.execute(statement) connection.commit() return connection diff --git a/tests/test_event_store.py b/tests/test_event_store.py index 6e3066a..3746f35 100644 --- a/tests/test_event_store.py +++ b/tests/test_event_store.py @@ -127,7 +127,7 @@ def test_noncanonical_python_values_are_rejected(self) -> None: canonical_json_bytes(value) -def _migration_16_statements(): +def _event_projection_statements(): path = ( Path(__file__).resolve().parents[1] / "daem0nmcp" / "migrations" / "schema.py" ) @@ -135,7 +135,12 @@ def _migration_16_statements(): assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - return next(item[2] for item in module.MIGRATIONS if item[0] == 16) + return [ + statement + for version, _description, statements in module.MIGRATIONS + if version in {16, 33} + for statement in statements + ] class SQLiteEventStoreTests(unittest.TestCase): @@ -147,7 +152,7 @@ def setUp(self) -> None: self.connection = sqlite3.connect(":memory:") self.connection.row_factory = sqlite3.Row self.connection.execute("PRAGMA foreign_keys=ON") - for statement in _migration_16_statements(): + for statement in _event_projection_statements(): self.connection.execute(statement) self.connection.commit() @@ -183,6 +188,83 @@ def _state(self, content="Use SQLite", *, outcome=None, worked=None): "deleted_at_us": None, } + def test_established_source_provenance_does_not_create_learning_edges(self): + command_type, store_type = self._api() + store = store_type(self.connection) + run_id = "con_" + "a" * 64 + commands = ( + command_type( + workspace_id=self.workspace_id, + stream_id="mem_" + "a" * 64, + stream_kind="memory", + event_type="memory.created", + occurred_at_us=1, + recorded_at_us=1, + actor_type="import", + actor_id="workspace-consolidation", + correlation_id=run_id, + payload={ + "record": self._state(worked=True), + "provenance": { + "consolidation_run_id": run_id, + "source_workspace_id": "ws_" + "b" * 24, + "source_record_id": "mem_" + "b" * 64, + "source_event_id": "evt_" + "c" * 64, + "source_state_hash": "d" * 64, + }, + }, + ), + command_type( + workspace_id=self.workspace_id, + stream_id="mem_" + "b" * 64, + stream_kind="memory", + event_type="memory.created", + occurred_at_us=2, + recorded_at_us=2, + actor_type="client", + payload={ + "record": self._state(), + "semantic_namespace": "document-ingest-url", + "provenance": { + "url": "https://example.test/document", + "content_hash": "a" * 64, + "content_type": "text/plain", + "topic": "source evidence", + "chunk_size": 2000, + }, + }, + ), + ) + for command in commands: + store.append_and_project(command) + for table in ("memory_provenance_edges", "memory_outcome_signals"): + self.assertEqual( + 0, + self.connection.execute(f"SELECT count(*) FROM {table}").fetchone()[0], + ) + for command in commands: + invalid = replace( + command, + stream_id="mem_" + "e" * 64, + payload={ + **command.payload, + "provenance": { + **command.payload["provenance"], + "informed_by": ["mem_" + "f" * 64], + }, + }, + ) + with self.assertRaisesRegex(ValueError, "memory provenance is invalid"): + store.append_and_project(invalid) + with self.assertRaisesRegex(ValueError, "memory provenance is invalid"): + store.append_and_project( + replace( + commands[0], + stream_id="mem_" + "f" * 64, + actor_id="unknown-importer", + ) + ) + def test_append_projects_contiguous_hash_chained_memory_state(self): """Two commands become immutable versions 1/2 and one current record.""" command_type, store_type = self._api() @@ -644,7 +726,7 @@ def test_concurrent_default_appends_serialize_without_stream_gap(self): with tempfile.TemporaryDirectory() as raw: path = Path(raw) / "events.db" setup = sqlite3.connect(path) - for statement in _migration_16_statements(): + for statement in _event_projection_statements(): setup.execute(statement) setup.commit() setup.close() diff --git a/tests/test_late_interaction_rerank.py b/tests/test_late_interaction_rerank.py new file mode 100644 index 0000000..e8665b5 --- /dev/null +++ b/tests/test_late_interaction_rerank.py @@ -0,0 +1,235 @@ +"""Offline contracts for optional token-level late-interaction reranking.""" + +from __future__ import annotations + +import asyncio +import sqlite3 +import tempfile +import threading +import unittest +from importlib.util import find_spec +from pathlib import Path +from unittest.mock import patch + +from daem0nmcp.retrieval.late_interaction import LateInteractionReranker +from daem0nmcp.retrieval.types import RetrievalQuery +from tests.test_retrieval_composer import _source +from tests.test_retrieval_runtime import ( + WORKSPACE_ID, + _append, + _apply_retrieval_schema, + _settings, +) + + +class TokenModel: + def __init__(self, documents: list[object]) -> None: + self.documents = documents + self.thread_ids: list[int] = [] + self.queries: list[object] = [] + self.contents: list[object] = [] + + def query_embed(self, texts: list[str]): + self.thread_ids.append(threading.get_ident()) + self.queries.append(texts) + yield [[3.0, 0.0], [0.0, 5.0]] + + def embed(self, contents: list[str]): + self.thread_ids.append(threading.get_ident()) + self.contents.append(contents) + yield from self.documents + + +@unittest.skipUnless(find_spec("numpy"), "late-interaction numpy extra unavailable") +class LateInteractionTests(unittest.IsolatedAsyncioTestCase): + def query(self) -> RetrievalQuery: + return RetrievalQuery( + workspace_id="ws_0123456789abcdef01234567", + text="query", + rerank=True, + ) + + async def test_maxsim_normalizes_tokens_preserves_ties_and_loads_once(self): + model = TokenModel( + [ + [[1.0, 0.0]], + [[100.0, 0.0], [0.0, 0.01]], + [[0.0, 1.0], [1.0, 0.0]], + ] + ) + loads: list[tuple[str, int]] = [] + + def factory(name: str) -> TokenModel: + loads.append((name, threading.get_ident())) + return model + + reranker = LateInteractionReranker(model_name="fake", model_factory=factory) + original = tuple( + _source(str(index), f"document {index}", score=1.0) for index in range(1, 4) + ) + self.assertEqual([], loads) + self.assertEqual((), await reranker.rerank(self.query(), ())) + self.assertEqual([], loads) + loop_thread = threading.get_ident() + results = await asyncio.gather( + reranker.rerank(self.query(), original), + reranker.rerank(self.query(), original), + ) + expected = tuple(original[index].candidate for index in (1, 2, 0)) + self.assertEqual([expected, expected], results) + self.assertEqual(1, len(loads)) + self.assertEqual("fake", loads[0][0]) + self.assertNotEqual(loop_thread, loads[0][1]) + self.assertTrue(all(t != loop_thread for t in model.thread_ids)) + self.assertEqual([["query"], ["query"]], model.queries) + self.assertEqual([[item.content for item in original]] * 2, model.contents) + + async def test_invalid_token_matrices_and_counts_raise(self): + original = (_source("1", "document", score=1.0),) + for documents in ( + [[[float("nan"), 0.0]]], + [[[float("inf"), 0.0]]], + [[[1.0, 0.0, 0.0]]], + [[1.0, 0.0]], + [[]], + [], + ): + with self.subTest(documents=documents): + model = TokenModel(documents) + reranker = LateInteractionReranker( + model_name="fake", model_factory=lambda _, model=model: model + ) + with self.assertRaisesRegex(RuntimeError, "RERANKER_VECTOR_INVALID"): + await reranker.rerank(self.query(), original) + + async def test_invalid_query_tokens_raise(self): + class InvalidQueryModel(TokenModel): + def query_embed(self, texts: list[str]): + yield [[float("nan"), 0.0]] + + reranker = LateInteractionReranker( + model_name="fake", + model_factory=lambda _: InvalidQueryModel([[[1.0, 0.0]]]), + ) + with self.assertRaisesRegex(RuntimeError, "RERANKER_VECTOR_INVALID"): + await reranker.rerank(self.query(), (_source("1", "document", score=1.0),)) + + +class LateInteractionFactoryTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + from daem0nmcp.retrieval.projections import LexicalProjectionBuilder + + self.temporary = tempfile.TemporaryDirectory() + self.path = Path(self.temporary.name) / "memory.db" + connection = sqlite3.connect(self.path) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys=ON") + _apply_retrieval_schema(connection) + _append(connection, "1", "query first document", 100) + _append(connection, "2", "query second document", 200) + LexicalProjectionBuilder(connection, clock_us=lambda: 300).rebuild(WORKSPACE_ID) + connection.commit() + connection.close() + self.services = [] + + def tearDown(self) -> None: + for service in self.services: + service.close() + self.temporary.cleanup() + + async def test_missing_profile_never_falls_back_to_ready_embedding(self): + from daem0nmcp.retrieval.runtime import create_retrieval_service + + class OfflineEncoder: + def __init__(self) -> None: + self.calls: list[str] = [] + + def encode(self, text: str) -> list[float]: + self.calls.append(text) + return [1.0, 0.0] + + config = _settings() + config.retrieval_rerank_enabled = True + config.retrieval_reranker = "late_interaction" + for status in ("disabled", "degraded", "failed"): + encoder = OfflineEncoder() + with ( + self.subTest(status=status), + patch( + "daem0nmcp.retrieval.runtime._embedding_encoder", + return_value=encoder, + ), + ): + service = create_retrieval_service( + self.path, + config=config, + capability_statuses={ + "local": "ready", + "models-local": "ready", + "graph": "disabled", + "late-interaction": status, + }, + ) + self.services.append(service) + result = await service.retrieve( + RetrievalQuery(workspace_id=WORKSPACE_ID, text="query", rerank=True) + ) + self.assertFalse(result.abstained) + diagnostic = next( + item for item in result.providers if item.provider == "reranker" + ) + self.assertEqual("unavailable", diagnostic.status) + self.assertEqual("RERANKER_UNAVAILABLE", diagnostic.reason) + self.assertEqual([], encoder.calls) + + @unittest.skipUnless( + find_spec("numpy") and find_spec("fastembed"), + "late-interaction extra unavailable", + ) + async def test_ready_profile_reranks_real_recall_and_loads_only_when_requested( + self, + ): + from daem0nmcp.retrieval.runtime import create_retrieval_service + + config = _settings() + config.retrieval_rerank_enabled = True + config.retrieval_reranker = "late_interaction" + model = TokenModel([[[1.0, 0.0]], [[1.0, 0.0], [0.0, 1.0]]]) + loads: list[str] = [] + + def factory(*, model_name: str) -> TokenModel: + loads.append(model_name) + return model + + with patch("fastembed.LateInteractionTextEmbedding", side_effect=factory): + service = create_retrieval_service( + self.path, + config=config, + capability_statuses={ + "local": "disabled", + "models-local": "disabled", + "graph": "disabled", + "late-interaction": "ready", + }, + ) + self.services.append(service) + baseline = await service.retrieve( + RetrievalQuery(workspace_id=WORKSPACE_ID, text="query", rerank=False) + ) + self.assertFalse(baseline.abstained) + self.assertEqual([], loads) + result = await service.retrieve( + RetrievalQuery(workspace_id=WORKSPACE_ID, text="query", rerank=True) + ) + self.assertFalse(result.abstained) + self.assertEqual(["colbert-ir/colbertv2.0"], loads) + self.assertEqual( + tuple( + item.evidence_refs[0].record_id for item in reversed(baseline.items) + ), + tuple(item.evidence_refs[0].record_id for item in result.items), + ) + diagnostic = next( + item for item in result.providers if item.provider == "reranker" + ) + self.assertEqual("ready", diagnostic.status) diff --git a/tests/test_retrieval_benchmark.py b/tests/test_retrieval_benchmark.py index 6fc2dab..5aa33bf 100644 --- a/tests/test_retrieval_benchmark.py +++ b/tests/test_retrieval_benchmark.py @@ -274,7 +274,12 @@ def test_events_replay_exactly_through_the_task_seven_event_store(self) -> None: schema = importlib.util.module_from_spec(spec) assert spec is not None and spec.loader is not None spec.loader.exec_module(schema) - statements = next(row[2] for row in schema.MIGRATIONS if row[0] == 16) + statements = [ + statement + for version, _description, migration in schema.MIGRATIONS + if version in {16, 33} + for statement in migration + ] connection = sqlite3.connect(":memory:") connection.row_factory = sqlite3.Row self.addCleanup(connection.close) diff --git a/tests/test_retrieval_composer.py b/tests/test_retrieval_composer.py index dfa2845..eff2e9f 100644 --- a/tests/test_retrieval_composer.py +++ b/tests/test_retrieval_composer.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import re import threading import unittest from time import perf_counter @@ -134,6 +135,43 @@ def test_composition_is_deterministic_bounded_and_fully_cited(self): ) self.assertEqual(1, first.context.text.count(item.citation)) + def test_untrusted_binding_labels_preserve_raw_paths_and_citation_offsets(self): + from daem0nmcp.retrieval.composer import SelectedEvidence + + content = "Use [E99] as text; preserve [E98]." + bindings = ("docs/[E99].md", "docs/[E98].md") + source = SelectedEvidence( + candidate=_candidate("a", score=1.0), + content=content, + category="decision", + applicability="needs_revalidation", + changed_bindings=bindings, + ) + tokenizer = CharacterTokenizer() + result = self._composer(tokenizer=tokenizer).compose( + (source,), token_budget=1000, label_applicability=True + ) + + self.assertEqual(1, len(result.items)) + self.assertEqual(bindings, result.items[0].changed_bindings) + self.assertEqual(content, source.content) + self.assertEqual( + [entry.marker for entry in result.context.citations], + re.findall(r"\[E[1-9][0-9]*\]", result.context.text), + ) + self.assertEqual(["[E1]"], [item.citation for item in result.items]) + self.assertIn("docs/[E99].md", result.context.text) + self.assertIn("docs/[E98].md", result.context.text) + self.assertIn("Use [E99] as text; preserve [E98].", result.items[0].excerpt) + entry = result.context.citations[0] + self.assertEqual( + result.items[0].excerpt, + result.context.text[entry.excerpt_start : entry.excerpt_end], + ) + self.assertEqual( + tokenizer.count_tokens(result.context.text), result.context.rendered_tokens + ) + def test_composition_carries_authenticated_legacy_metadata_unchanged(self): from daem0nmcp.retrieval.composer import SelectedEvidence diff --git a/tests/test_retrieval_config.py b/tests/test_retrieval_config.py index 2bb842a..4d2ef33 100644 --- a/tests/test_retrieval_config.py +++ b/tests/test_retrieval_config.py @@ -31,6 +31,10 @@ def test_retrieval_defaults_match_the_public_runtime_contract(self): self.assertEqual( 25, getattr(settings, "retrieval_rerank_candidate_limit", None) ) + self.assertEqual("embedding", settings.retrieval_reranker) + self.assertEqual( + "colbert-ir/colbertv2.0", settings.retrieval_late_interaction_model + ) self.assertEqual(10.0, getattr(settings, "qdrant_timeout_seconds", None)) self.assertEqual( "daem0nmcp", getattr(settings, "qdrant_collection_prefix", None) @@ -59,6 +63,14 @@ def test_retrieval_numeric_settings_reject_unsafe_bounds(self): with self.subTest(values=values), self.assertRaises(ValueError): Settings(_env_file=None, **values) + def test_reranker_selection_is_closed(self): + from daem0nmcp.config import Settings + + settings = Settings(_env_file=None, retrieval_reranker="late_interaction") + self.assertEqual("late_interaction", settings.retrieval_reranker) + with self.assertRaises(ValueError): + Settings(_env_file=None, retrieval_reranker="unknown") + def test_rrf_weights_and_collection_prefix_are_closed_and_bounded(self): from daem0nmcp.config import Settings diff --git a/tests/test_retrieval_dense_projection.py b/tests/test_retrieval_dense_projection.py index 649ba76..04c233e 100644 --- a/tests/test_retrieval_dense_projection.py +++ b/tests/test_retrieval_dense_projection.py @@ -7,6 +7,7 @@ import hashlib import importlib.util import json +import shutil import sqlite3 import tempfile import threading @@ -208,7 +209,7 @@ def setUp(self) -> None: self.addCleanup(self.connection.close) self.connection.row_factory = sqlite3.Row self.connection.execute("PRAGMA foreign_keys=ON") - for version in (16, 17, 18, 31, 32): + for version in (16, 17, 18, 31, 32, 33): _apply_migration(self.connection, version) self.connection.commit() self.client = FakeQdrantClient() @@ -623,6 +624,123 @@ def test_real_local_qdrant_normalization_preserves_valid_projection(self): finally: client.close() + def _assert_failed_local_staging_releases_files(self, *, cancelled: bool) -> None: + from daem0nmcp.retrieval.dense_projection import ( + DenseProjectionBuilder, + DenseProjectionBuildError, + ) + + cancellation = threading.Event() + + class InterruptedEncoder(DeterministicEncoder): + def encode(self, text: str) -> list[float]: + if cancelled: + cancellation.set() + return super().encode(text) + raise RuntimeError("encoding failed after staging collection creation") + + self._append("a", "prior active local evidence") + qdrant_path = self.database_path.parent / "local-qdrant" + builder = DenseProjectionBuilder( + self.connection, + provider_key="local", + model_id="deterministic-test-model", + dimension=3, + encoder=self.encoder, + qdrant_path=qdrant_path, + cancelled=cancellation.is_set, + ) + self.addCleanup(builder.close) + try: + active = builder.rebuild(WORKSPACE_ID) + self._append("b", "new evidence interrupts staging") + builder.encoder = InterruptedEncoder() + with self.assertRaises(DenseProjectionBuildError) as raised: + builder.rebuild(WORKSPACE_ID) + self.assertEqual( + "PROJECTION_CANCELLED" if cancelled else "DENSE_BUILD_FAILED", + raised.exception.code, + ) + client = builder.client + self.assertEqual( + [active.collection_name], + [item.name for item in client.get_collections().collections], + ) + self.assertEqual( + 1, + client.count(collection_name=active.collection_name, exact=True).count, + ) + finally: + builder.close() + shutil.rmtree(qdrant_path) + self.assertFalse(qdrant_path.exists()) + + @unittest.skipUnless( + importlib.util.find_spec("qdrant_client"), + "optional local Qdrant is unavailable", + ) + def test_failed_local_staging_allows_immediate_directory_cleanup(self): + self._assert_failed_local_staging_releases_files(cancelled=False) + + @unittest.skipUnless( + importlib.util.find_spec("qdrant_client"), + "optional local Qdrant is unavailable", + ) + def test_cancelled_local_staging_allows_immediate_directory_cleanup(self): + self._assert_failed_local_staging_releases_files(cancelled=True) + + @unittest.skipUnless( + importlib.util.find_spec("qdrant_client"), + "optional local Qdrant is unavailable", + ) + def test_replacing_local_orphan_staging_discards_its_persisted_points(self): + from qdrant_client import QdrantClient, models + + from daem0nmcp.retrieval.dense_projection import DenseProjectionBuilder + + self._append("c", "canonical retry evidence") + qdrant_path = self.database_path.parent / "orphan-qdrant" + client = QdrantClient(path=str(qdrant_path)) + self.addCleanup(client.close) + builder = DenseProjectionBuilder( + self.connection, + provider_key="local", + model_id="deterministic-test-model", + dimension=3, + encoder=self.encoder, + client=client, + ) + try: + staging = builder.rebuild(WORKSPACE_ID, dry_run=True).collection_name + client.create_collection( + staging, + vectors_config=models.VectorParams( + size=3, distance=models.Distance.COSINE + ), + ) + client.upsert( + staging, + points=[ + models.PointStruct( + id="00000000-0000-0000-0000-000000000001", + vector=[1.0, 0.0, 0.0], + ) + ], + wait=True, + ) + result = builder.rebuild(WORKSPACE_ID) + self.assertEqual("active", result.status) + self.assertEqual(1, result.row_count) + self.assertEqual( + 1, + client.count(collection_name=result.collection_name, exact=True).count, + ) + finally: + builder.close() + client.close() + shutil.rmtree(qdrant_path) + self.assertFalse(qdrant_path.exists()) + def test_cosine_rounding_does_not_admit_corrupt_or_malformed_vectors(self): from daem0nmcp.retrieval.vector_validation import cosine_vectors_match @@ -2012,7 +2130,7 @@ def test_gc_reconciliation_fairly_converges_across_bounded_restart_slices() -> N path = Path(temporary) / "fair-gc.sqlite3" connection = sqlite3.connect(path) connection.execute("PRAGMA foreign_keys=ON") - for version in (16, 17, 18, 31, 32): + for version in (16, 17, 18, 31, 32, 33): _apply_migration(connection, version) for workspace_number in range(17): workspace_id = f"ws_{workspace_number:024x}" diff --git a/tests/test_retrieval_legacy_compat.py b/tests/test_retrieval_legacy_compat.py index e9de785..1e8ffd6 100644 --- a/tests/test_retrieval_legacy_compat.py +++ b/tests/test_retrieval_legacy_compat.py @@ -86,6 +86,14 @@ def _memory_method_subject(*method_names: str) -> type: retrieval_candidate_limit=50, retrieval_token_budget=512, retrieval_rerank_enabled=False, + retrieval_reranker="embedding", + retrieval_late_interaction_model="colbert-ir/colbertv2.0", + retrieval_utility_mode="off", + retrieval_utility_weight=0.1, + retrieval_utility_credit="trace", + retrieval_utility_candidate_limit=25, + memory_validity_mode="shadow", + retrieval_retention_mode="shadow", ), "timezone": timezone, "_normalize_file_path": lambda path, _project: (path, path), diff --git a/tests/test_retrieval_lexical.py b/tests/test_retrieval_lexical.py index 616ec64..e0cd82c 100644 --- a/tests/test_retrieval_lexical.py +++ b/tests/test_retrieval_lexical.py @@ -170,6 +170,7 @@ def setUp(self) -> None: _apply_migration(self.connection, 16) _apply_migration(self.connection, 17) _apply_migration(self.connection, 18) + _apply_migration(self.connection, 33) self.connection.commit() def tearDown(self) -> None: @@ -376,6 +377,172 @@ async def test_multi_term_queries_prefer_complete_matches_before_or_fallback(sel [candidate.evidence.record_id for candidate in result.candidates], ) + async def test_content_and_rationale_matches_precede_tag_only_matches(self): + from daem0nmcp.retrieval.projections import LexicalProjectionBuilder + from daem0nmcp.retrieval.providers import LexicalProvider + from daem0nmcp.retrieval.types import RetrievalQuery + + tag_only = self._append_record( + "a", + "Operational notes", + "Scheduled maintenance", + ["checkpoint journal recovery " * 8, "catalogue"], + ) + body = self._append_record( + "b", + "Checkpoint journal recovery " + "Operational details " * 20, + "Restore durable state", + [], + ) + rationale = self._append_record( + "c", + "Restore durable state", + "Checkpoint journal recovery " + "Operational details " * 20, + [], + ) + LexicalProjectionBuilder(self.connection).rebuild(WORKSPACE_ID) + self.connection.commit() + provider = LexicalProvider(self.connection) + query = RetrievalQuery( + workspace_id=WORKSPACE_ID, text="checkpoint journal recovery" + ) + + result = await provider.search(query, 10) + + self.assertEqual("ready", result.status) + self.assertEqual( + {body, rationale}, + {candidate.evidence.record_id for candidate in result.candidates[:2]}, + ) + self.assertEqual(tag_only, result.candidates[2].evidence.record_id) + bounded = await provider.search(query, 2) + self.assertEqual(result.candidates[:2], bounded.candidates) + + tags = await provider.search( + RetrievalQuery(workspace_id=WORKSPACE_ID, text="catalogue"), 10 + ) + self.assertEqual("ready", tags.status) + self.assertEqual( + [tag_only], + [candidate.evidence.record_id for candidate in tags.candidates], + ) + + async def test_body_matches_rank_without_tag_amplification(self): + from daem0nmcp.retrieval.lexical_config import ( + LEXICAL_BM25_WEIGHTS, + lexical_fts_table_name, + ) + from daem0nmcp.retrieval.projections import LexicalProjectionBuilder + from daem0nmcp.retrieval.providers import LexicalProvider + from daem0nmcp.retrieval.types import RetrievalQuery + + amplifier = self._append_record( + "a", + "Rotate signing keys draft inventory for review.", + "", + ["rotate signing keys " * 16], + ) + useful = self._append_record( + "b", + "Rotate signing keys and invalidate active sessions.", + "", + [], + ) + LexicalProjectionBuilder(self.connection).rebuild(WORKSPACE_ID) + self.connection.commit() + + result = await LexicalProvider(self.connection).search( + RetrievalQuery(workspace_id=WORKSPACE_ID, text="rotate signing keys"), + 10, + ) + + self.assertEqual("ready", result.status) + self.assertEqual( + [useful, amplifier], + [candidate.evidence.record_id for candidate in result.candidates], + ) + self.assertEqual([1, 2], [candidate.rank for candidate in result.candidates]) + self.assertGreater( + result.candidates[1].raw_score, result.candidates[0].raw_score + ) + table = lexical_fts_table_name(WORKSPACE_ID, result.manifest_generation) + content_weight, rationale_weight, tags_weight = LEXICAL_BM25_WEIGHTS + diagnostics = dict( + self.connection.execute( + f""" + SELECT document.record_id, + bm25("{table}", {content_weight}, + {rationale_weight}, {tags_weight}) + FROM "{table}" + JOIN retrieval_documents AS document + ON document.document_rowid="{table}".rowid + WHERE document.workspace_id=? AND document.projection_generation=? + AND "{table}" MATCH ? + """, + ( + WORKSPACE_ID, + result.manifest_generation, + '"rotate" "signing" "keys"', + ), + ).fetchall() + ) + for candidate in result.candidates: + self.assertEqual( + max(0.0, -diagnostics[candidate.evidence.record_id]), + candidate.raw_score, + ) + + async def test_tag_only_matches_keep_weighted_bm25_order(self): + from daem0nmcp.retrieval.projections import LexicalProjectionBuilder + from daem0nmcp.retrieval.providers import LexicalProvider + from daem0nmcp.retrieval.types import RetrievalQuery + + ordinary = self._append_record("a", "Snapshot", "", ["retention"]) + amplified = self._append_record("b", "Snapshot", "", ["retention " * 16]) + LexicalProjectionBuilder(self.connection).rebuild(WORKSPACE_ID) + self.connection.commit() + + result = await LexicalProvider(self.connection).search( + RetrievalQuery(workspace_id=WORKSPACE_ID, text="retention"), + 10, + ) + + self.assertEqual("ready", result.status) + self.assertEqual( + [amplified, ordinary], + [candidate.evidence.record_id for candidate in result.candidates], + ) + self.assertGreater( + result.candidates[0].raw_score, result.candidates[1].raw_score + ) + + async def test_content_tier_requires_the_complete_winning_expression(self): + from daem0nmcp.retrieval.projections import LexicalProjectionBuilder + from daem0nmcp.retrieval.providers import LexicalProvider + from daem0nmcp.retrieval.types import RetrievalQuery + + mixed = self._append_record("a", "Snapshot", "Maintenance", ["retention"]) + split = self._append_record("b", "Snapshot", "Retention", []) + tied = self._append_record("c", "Snapshot", "Retention", []) + partial = self._append_record("d", "Snapshot", "Maintenance", []) + LexicalProjectionBuilder(self.connection).rebuild(WORKSPACE_ID) + self.connection.commit() + + result = await LexicalProvider(self.connection).search( + RetrievalQuery(workspace_id=WORKSPACE_ID, text="snapshot retention"), + 10, + ) + + self.assertEqual("ready", result.status) + self.assertEqual( + [split, tied, mixed], + [candidate.evidence.record_id for candidate in result.candidates], + ) + self.assertNotIn( + partial, + [candidate.evidence.record_id for candidate in result.candidates], + ) + async def test_or_fallback_ignores_question_scaffolding(self): """Generic question words must not create lexical ranking noise.""" @@ -1136,7 +1303,7 @@ async def test_build_snapshot_starts_before_canonical_record_read(self): try: first.execute("PRAGMA foreign_keys=ON") second.execute("PRAGMA foreign_keys=ON") - for version in (16, 17, 18): + for version in (16, 17, 18, 33): _apply_migration(first, version) first.commit() EventStore(first).append_and_project( diff --git a/tests/test_retrieval_onnx_encoder.py b/tests/test_retrieval_onnx_encoder.py index 5b36e61..b5ffe94 100644 --- a/tests/test_retrieval_onnx_encoder.py +++ b/tests/test_retrieval_onnx_encoder.py @@ -132,7 +132,9 @@ def test_local_pooled_graph_and_token_only_fallback_never_use_network(self): ) onnx.save( helper.make_model( - graph, opset_imports=[helper.make_operatorsetid("", 18)] + graph, + opset_imports=[helper.make_operatorsetid("", 18)], + ir_version=8, ), root / "onnx" / "model_quantized.onnx", ) @@ -158,7 +160,9 @@ def test_local_pooled_graph_and_token_only_fallback_never_use_network(self): ) onnx.save( helper.make_model( - token_graph, opset_imports=[helper.make_operatorsetid("", 18)] + token_graph, + opset_imports=[helper.make_operatorsetid("", 18)], + ir_version=8, ), root / "onnx" / "model_quantized.onnx", ) diff --git a/tests/test_retrieval_projection_growth.py b/tests/test_retrieval_projection_growth.py index 64014fb..e943c77 100644 --- a/tests/test_retrieval_projection_growth.py +++ b/tests/test_retrieval_projection_growth.py @@ -71,7 +71,7 @@ def setUp(self) -> None: # seeding rebuilds fast on slow CI disks. self.connection.execute("PRAGMA synchronous=OFF") for version, _description, statements in MIGRATIONS: - if 16 <= version <= 18: + if version in (16, 17, 18, 33): for statement in statements: self.connection.execute(statement) self.connection.commit() diff --git a/tests/test_retrieval_repository.py b/tests/test_retrieval_repository.py index 0c1b98a..85b0bf5 100644 --- a/tests/test_retrieval_repository.py +++ b/tests/test_retrieval_repository.py @@ -197,7 +197,7 @@ def __init__(self) -> None: self.repositories: list[SQLiteRetrievalRepository] = [] self.connection = sqlite3.connect(self.database_path) self.connection.execute("PRAGMA foreign_keys=ON") - for statement in _migration_statements(16, 17, 18, 30): + for statement in _migration_statements(16, 17, 18, 30, 33): self.connection.execute(statement) self._insert_events() self._insert_records() @@ -2423,3 +2423,208 @@ async def test_secondary_procedure_ref_is_authenticated_without_secondary_text( self.assertEqual("Rotate credentials safely.", selected[0].content) self.assertNotIn(secondary_step, selected[0].procedure_steps) self.assertEqual(merged[0].evidence_refs, selected[0].candidate.evidence_refs) + + +class SQLiteRetrievalRepositoryUtilityTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.fixture = _CanonicalFixture() + self.event_number = 1000 + self.records: set[str] = set() + + def tearDown(self) -> None: + self.fixture.close() + + def record(self, number: int, workspace_id: str = WORKSPACE_ID) -> str: + record_id = f"mem_{number:064x}" + if record_id not in self.records: + self.fixture._insert_record( + record_id=record_id, + workspace_id=workspace_id, + record_type="decision", + content="Utility test decision.", + content_hash=_hash("1"), + source_event_id=_event_id("a"), + stream_version=1, + created_offset=0, + updated_offset=0, + ) + self.records.add(record_id) + return record_id + + def event(self, record_id: str, workspace_id: str, offset: int) -> str: + self.event_number += 1 + event_id = f"evt_{self.event_number:064x}" + self.fixture.connection.execute( + """ + INSERT INTO memory_events ( + event_id,workspace_id,stream_id,stream_kind,stream_version, + event_type,event_schema_version,occurred_at_us,recorded_at_us, + actor_type,actor_id,causation_event_id,correlation_id, + payload_json,payload_hash,previous_event_hash,event_hash + ) VALUES (?,?,?,'memory',?,'memory.test',1,?,?,'system',NULL, + NULL,NULL,'{}',?,NULL,?) + """, + ( + event_id, + workspace_id, + record_id, + self.event_number, + BASE_US + offset, + BASE_US + offset, + hashlib.sha256(b"{}").hexdigest(), + f"{self.event_number:064x}", + ), + ) + return event_id + + def edge( + self, + source: str, + descendant: str, + *, + kind: str = "store", + offset: int = 10, + workspace_id: str = WORKSPACE_ID, + event_id: str | None = None, + ) -> str: + event_id = event_id or self.event(descendant, workspace_id, offset) + self.fixture.connection.execute( + "INSERT INTO memory_provenance_edges VALUES (?,?,?,?,?,?)", + ( + workspace_id, + event_id, + descendant, + source, + kind, + BASE_US + offset, + ), + ) + return event_id + + def signal( + self, + record_id: str, + reward: float, + *, + offset: int = 20, + workspace_id: str = WORKSPACE_ID, + weight: float = 0.75, + ) -> str: + event_id = self.event(record_id, workspace_id, offset) + self.fixture.connection.execute( + "INSERT INTO memory_outcome_signals VALUES (?,?,?,?,?,?)", + (workspace_id, event_id, record_id, reward, weight, BASE_US + offset), + ) + return event_id + + async def load(self, *record_ids: str, offset: int = 100): + self.fixture.connection.commit() + return await self.fixture.repository().load_utility_contributions( + WORKSPACE_ID, record_ids, BASE_US + offset + ) + + async def test_direct_success_descendant_failure_and_outcome_edge_credit(self): + root, child, grandchild, unrelated = ( + self.record(number) for number in range(10, 14) + ) + self.edge(root, child) + self.edge(child, grandchild) + direct_success = self.signal(root, 1.0, offset=30, weight=1.0) + self.signal(root, 0.0) + child_failure = self.signal(child, 0.0) + grandchild_success = self.signal(grandchild, 1.0) + edge_failure = self.signal(unrelated, 0.0) + self.edge(root, unrelated, kind="outcome", event_id=edge_failure) + result = (await self.load(root))[root] + self.assertEqual( + { + direct_success: (0, 1.0, 1.0), + child_failure: (1, 0.0, 0.75), + grandchild_success: (2, 1.0, 0.75), + edge_failure: (1, 0.0, 0.75), + }, + {item.event_id: (item.depth, item.reward, item.weight) for item in result}, + ) + self.assertEqual( + sorted((item.recorded_at_us, item.event_id) for item in result), + [(item.recorded_at_us, item.event_id) for item in result], + ) + self.assertEqual({}, await self.load()) + + async def test_workspace_and_both_edge_and_signal_time_boundaries(self): + root, child, future_child, outsider = ( + self.record(number) for number in range(20, 24) + ) + foreign = self.record(24, OTHER_WORKSPACE_ID) + self.edge(root, child, offset=100) + self.edge(root, future_child, offset=101) + self.edge(root, foreign, workspace_id=OTHER_WORKSPACE_ID) + included = self.signal(child, 1.0, offset=100) + self.signal(child, 0.0, offset=101) + self.signal(future_child, 1.0) + self.signal(foreign, 1.0, workspace_id=OTHER_WORKSPACE_ID) + late_edge = self.signal(outsider, 1.0) + self.edge(root, outsider, kind="outcome", offset=101, event_id=late_edge) + late_signal = self.signal(outsider, 1.0, offset=101) + self.edge(root, outsider, kind="outcome", event_id=late_signal) + self.assertEqual( + [included], [item.event_id for item in (await self.load(root))[root]] + ) + self.assertEqual((), (await self.load(root, offset=99))[root]) + + async def test_cycles_multiple_paths_and_outcome_edges_use_minimum_depth(self): + root, child, grandchild = (self.record(number) for number in range(30, 33)) + self.edge(root, child) + self.edge(child, grandchild) + self.edge(grandchild, root) + self.edge(root, grandchild) + success = self.signal(root, 1.0) + self.edge(root, root, kind="outcome", event_id=success) + shared = self.signal(grandchild, 0.0) + self.edge(root, grandchild, kind="outcome", event_id=shared) + result = (await self.load(root, child))[root] + self.assertEqual( + {success: 0, shared: 1}, {item.event_id: item.depth for item in result} + ) + + async def test_outcome_edge_shortens_depth_two_signal(self): + root, child, grandchild = (self.record(number) for number in range(40, 43)) + self.edge(root, child) + self.edge(child, grandchild) + shared = self.signal(grandchild, 0.0) + self.edge(root, grandchild, kind="outcome", event_id=shared) + result = (await self.load(root))[root] + self.assertEqual( + [(shared, 1)], [(item.event_id, item.depth) for item in result] + ) + + async def test_descendant_depth_is_limited_to_four(self): + chain = [self.record(number) for number in range(50, 56)] + signals = [] + for parent, child in zip(chain, chain[1:], strict=False): + self.edge(parent, child) + signals.append(self.signal(child, 1.0)) + result = (await self.load(chain[0]))[chain[0]] + self.assertEqual( + dict(zip(signals[:4], range(1, 5), strict=True)), + {item.event_id: item.depth for item in result}, + ) + + async def test_descendant_count_is_bounded_with_deterministic_traversal(self): + root = self.record(100) + children = [self.record(number) for number in range(101, 166)] + signals = {} + for child in reversed(children): + self.edge(root, child) + self.edge(root, child) + signals[child] = self.signal(child, 1.0) + extra = self.record(166) + self.edge(children[0], extra) + self.signal(extra, 1.0) + result = (await self.load(root))[root] + self.assertEqual(64, len(result)) + self.assertEqual( + {signals[child] for child in children[:64]}, + {item.event_id for item in result}, + ) + self.assertTrue(all(item.depth == 1 for item in result)) diff --git a/tests/test_retrieval_retention.py b/tests/test_retrieval_retention.py new file mode 100644 index 0000000..fc708b0 --- /dev/null +++ b/tests/test_retrieval_retention.py @@ -0,0 +1,901 @@ +"""Task-conditioned evidence packing, shadow isolation, and source transport.""" + +from __future__ import annotations + +import unittest +from contextlib import closing +from dataclasses import replace + +from daem0nmcp.retrieval.composer import ( + EvidenceComposer, + RetentionPolicy, + query_identifiers, +) +from daem0nmcp.retrieval.runtime import CoreTokenizer +from daem0nmcp.retrieval.service import RetrievalService +from daem0nmcp.retrieval.types import RetrievalQuery +from tests.api_v7.test_runtime_services import _RuntimeServiceFixtures +from tests.test_retrieval_composer import WordTokenizer, _source +from tests.test_retrieval_service import ( + WORKSPACE_ID, + CanonicalRepository, + StaticProvider, + _candidate, + _provider_result, +) + + +def _sources(): + return tuple( + _source(digit, "background detail " * 150, score=1.0) for digit in "1234" + ) + ( + _source( + "5", + "deploy release rollout", + score=0.5, + procedure_steps=( + "freeze release writes", + "apply release rollout", + "verify release health", + ), + ), + ) + + +class RetentionComposerTests(unittest.TestCase): + def test_path_and_symbol_queries_preserve_bound_evidence_under_budget(self): + composer = EvidenceComposer(tokenizer=WordTokenizer()) + background = tuple( + _source(digit, "background detail " * 150, score=1.0) for digit in "1234" + ) + bound = replace( + _source( + "5", "Acquire a lease before replacing the routing snapshot.", score=0.1 + ), + code_bindings=(("src/http/router.ts", "pkg.Router.apply_routes"),), + ) + cases = ( + ("src/http/router.ts", True), + ("./src/http/router.ts", True), + ("src//http/router.ts", True), + (r"src\http\router.ts", True), + ("http/router.ts", True), + ("router.ts", True), + ("pkg.Router.apply_routes", True), + ("Router.apply_routes", True), + ("apply_routes", True), + ("src/http/other.ts", False), + ("src/admin/router.ts", False), + ("./src/admin/router.ts", False), + ("src//admin/router.ts", False), + (r"src\admin\router.ts", False), + ("admin/router.ts", False), + ("other.Router.apply_routes", False), + ("unrelated.Symbol", False), + ("./router.ts", False), + ("src/", False), + (r"src\http" + "\\", False), + ) + for intent in ("implement", "debug"): + for identifier, expected in cases: + with self.subTest(intent=intent, identifier=identifier): + result = composer.compose( + background + (bound,), + token_budget=256, + retention=RetentionPolicy( + intent, query_identifiers("Inspect " + identifier) + ), + ) + retained_ids = { + item.evidence_refs[0].record_id for item in result.items + } + self.assertEqual( + expected, bound.candidate.record_id in retained_ids + ) + self.assertLessEqual(result.context.rendered_tokens, 256) + + def test_implement_retains_late_procedure_under_256_token_budget(self): + composer = EvidenceComposer(tokenizer=WordTokenizer()) + baseline = composer.compose(_sources(), token_budget=256) + applied = composer.compose( + _sources(), + token_budget=256, + retention=RetentionPolicy("implement", frozenset()), + ) + self.assertNotIn("freeze release writes", baseline.context.text) + self.assertIn("freeze release writes", applied.context.text) + self.assertLessEqual(applied.context.rendered_tokens, 256) + ids = [item.evidence_refs[0].record_id[-1] for item in applied.items] + self.assertEqual(sorted(ids), ids) + self.assertEqual("5", ids[-1]) + self.assertEqual( + [f"[E{index}]" for index in range(1, len(ids) + 1)], + [item.citation for item in applied.items], + ) + + def test_none_and_explore_keep_exact_legacy_composition(self): + composer = EvidenceComposer(tokenizer=WordTokenizer()) + baseline = composer.compose(_sources(), token_budget=256) + for retention in (None, RetentionPolicy("explore", frozenset({"release"}))): + self.assertEqual( + baseline, + composer.compose(_sources(), token_budget=256, retention=retention), + ) + + def test_binding_priorities_and_ordinary_excerpt_caps(self): + composer = EvidenceComposer(tokenizer=WordTokenizer()) + source = replace( + _sources()[0], + code_bindings=(("src/cache.py", "pkg.Cache.handle_call"),), + ) + for intent in ("implement", "debug"): + for identifier in ("cache.py", "handle_call"): + policy = RetentionPolicy(intent, frozenset({identifier})) + item = composer.compose( + (source,), token_budget=4000, retention=policy + ).items[0] + self.assertEqual(1200, len(item.excerpt)) + ordinary = composer.compose( + (source,), + token_budget=4000, + retention=RetentionPolicy(intent, frozenset({"unrelated"})), + ).items[0] + self.assertLessEqual(len(ordinary.excerpt), 600) + for stale in ( + replace( + source, + applicability="needs_revalidation", + changed_bindings=("src/cache.py",), + ), + replace( + source, status="superseded", superseded_by_version_id="fact_" + "a" * 64 + ), + ): + for intent in ("implement", "debug", "review"): + item = composer.compose( + (stale,), + token_budget=4000, + retention=RetentionPolicy(intent, frozenset({"cache.py"})), + ).items[0] + self.assertLessEqual(len(item.excerpt), 300) + + def test_structured_facts_share_source_caps_and_rendered_exports(self): + composer = EvidenceComposer(tokenizer=CoreTokenizer(), max_excerpt_chars=800) + long_fact = "recovery detail " * 130 + source = replace( + _source( + "1", + long_fact, + score=1.0, + outcome=long_fact, + outcome_failed=True, + procedure_steps=(long_fact, "verify health"), + ), + code_bindings=(("src/cache.py", "pkg.Cache.apply"),), + ) + cases = ( + source, + replace(source, procedure_steps=(), outcome_failed=False), + replace( + source, + status="superseded", + superseded_by_version_id="fact_" + "a" * 64, + ), + replace( + source, + applicability="needs_revalidation", + changed_bindings=("src/cache.py",), + ), + ) + baseline = composer.compose((source,), token_budget=4000) + for retention in (None, RetentionPolicy("explore", frozenset())): + self.assertEqual( + baseline, + composer.compose((source,), token_budget=4000, retention=retention), + ) + lengths = [] + for selected in cases: + result = composer.compose( + (selected,), + token_budget=4000, + retention=RetentionPolicy("implement", frozenset()), + ) + item = result.items[0] + lengths.append(len(item.outcome)) + self.assertEqual(item.excerpt, item.outcome) + self.assertTrue(long_fact.startswith(item.outcome)) + self.assertEqual(selected.outcome_failed, item.outcome_failed) + label = "Failed outcome" if selected.outcome_failed else "Outcome" + self.assertIn(label + ": " + item.outcome, result.context.text) + self.assertEqual(len(selected.procedure_steps), len(item.procedure_steps)) + if selected.procedure_steps: + self.assertEqual(item.outcome, item.procedure_steps[0]) + self.assertEqual("verify health", item.procedure_steps[1]) + self.assertIn( + "Steps: " + " | ".join(item.procedure_steps), result.context.text + ) + self.assertEqual( + composer.compose( + (selected,), token_budget=4000 + ).context.requested_tokens, + result.context.requested_tokens, + ) + self.assertEqual( + result.context.requested_tokens - result.context.rendered_tokens, + result.context.dropped_tokens, + ) + self.assertEqual(len(baseline.items[0].outcome), lengths[0]) + self.assertLess(lengths[1], lengths[0]) + self.assertLess(lengths[2], lengths[1]) + self.assertEqual(lengths[2], lengths[3]) + matched = composer.compose( + (cases[1],), + token_budget=4000, + retention=RetentionPolicy("implement", frozenset({"cache.py"})), + ) + self.assertEqual(baseline.items[0].outcome, matched.items[0].outcome) + + def test_core_tokenizer_retains_stale_warning_and_current_evidence(self): + tokenizer = CoreTokenizer() + composer = EvidenceComposer(tokenizer=tokenizer) + warning = replace( + _source( + "1", + "obsolete recovery guidance " * 100, + score=1.0, + category="warning", + outcome="recovery failed " * 100, + outcome_failed=True, + procedure_steps=("inspect recovery health " * 90, "verify health"), + ), + status="superseded", + superseded_by_version_id="fact_" + "a" * 64, + ) + current = tuple( + _source(digit, "current routing evidence", score=0.9) for digit in "234" + ) + selected = (warning,) + current + baseline = composer.compose(selected, token_budget=256) + result = composer.compose( + selected, + token_budget=256, + retention=RetentionPolicy("implement", frozenset()), + ) + self.assertEqual( + [source.candidate.record_id for source in selected], + [item.evidence_refs[0].record_id for item in result.items], + ) + self.assertNotIn( + warning.candidate.record_id, + [item.evidence_refs[0].record_id for item in baseline.items], + ) + stale = result.items[0] + self.assertLessEqual(len(stale.outcome), 300) + self.assertLessEqual(len(stale.procedure_steps[0]), 300) + self.assertEqual("verify health", stale.procedure_steps[1]) + self.assertIn("Failed outcome: " + stale.outcome, result.context.text) + self.assertIn( + "Steps: " + " | ".join(stale.procedure_steps), result.context.text + ) + self.assertEqual( + tokenizer.count_tokens(result.context.text), result.context.rendered_tokens + ) + self.assertLessEqual(result.context.rendered_tokens, 256) + self.assertEqual( + [f"[E{index}]" for index in range(1, 5)], + [item.citation for item in result.items], + ) + for item, citation in zip(result.items, result.context.citations, strict=True): + self.assertEqual(item.citation, citation.marker) + self.assertEqual( + item.excerpt, + result.context.text[citation.excerpt_start : citation.excerpt_end], + ) + + def test_short_structured_facts_are_unchanged(self): + source = _source( + "1", + "brief guidance", + score=1.0, + outcome="recovery failed", + outcome_failed=True, + procedure_steps=("inspect lease", "verify health"), + ) + for retention in ( + None, + RetentionPolicy("explore", frozenset()), + RetentionPolicy("implement", frozenset()), + RetentionPolicy("debug", frozenset()), + RetentionPolicy("review", frozenset()), + ): + with self.subTest(retention=retention): + result = EvidenceComposer(tokenizer=CoreTokenizer()).compose( + (source,), token_budget=256, retention=retention + ) + self.assertEqual(source.outcome, result.items[0].outcome) + self.assertEqual( + source.procedure_steps, result.items[0].procedure_steps + ) + self.assertTrue(result.items[0].outcome_failed) + + def test_intent_priority_reservation_fractions(self): + warning = _source("a", "x " * 1000, score=1.0, category="warning") + composer = EvidenceComposer(tokenizer=WordTokenizer(), max_excerpt_chars=4000) + for intent, reserve in ( + ("explore", 154), + ("implement", 308), + ("debug", 359), + ("review", 205), + ): + with self.subTest(intent=intent): + result = composer.compose( + (warning,), + token_budget=1024, + retention=RetentionPolicy(intent, frozenset()), + ) + self.assertEqual(reserve, result.context.rendered_tokens) + + def test_retention_preserves_applicability_labels(self): + source = replace( + _sources()[-1], + applicability="needs_revalidation", + changed_bindings=("src/cache.py::handle_call",), + ) + result = EvidenceComposer(tokenizer=WordTokenizer()).compose( + (source,), + token_budget=256, + retention=RetentionPolicy("implement", frozenset()), + label_applicability=True, + ) + self.assertIn( + "Needs revalidation: src/cache.py::handle_call", result.context.text + ) + self.assertIn("freeze release writes", result.context.text) + + +class RetentionServiceTests(unittest.IsolatedAsyncioTestCase): + def _service(self, mode): + sources = _sources() + repository = CanonicalRepository( + contents={source.candidate.record_id: source.content for source in sources}, + selected_changes={ + sources[-1].candidate.record_id: { + "procedure_steps": sources[-1].procedure_steps + } + }, + ) + provider = StaticProvider( + "lexical", + _provider_result( + "lexical", + *( + _candidate(digit, "lexical", index) + for index, digit in enumerate("12345", 1) + ), + ), + [], + ) + return RetrievalService( + providers={"lexical": provider}, + repository=repository, + composer=EvidenceComposer(tokenizer=WordTokenizer()), + retention_mode=mode, + ) + + async def test_service_accepts_prefix_capped_facts_without_losing_steps(self): + long_fact = "recovery detail " * 130 + sources = ( + _source( + "1", + long_fact, + score=1.0, + category="warning", + outcome=long_fact, + outcome_failed=True, + procedure_steps=(long_fact, "verify health"), + ), + _source("2", long_fact, score=0.9, outcome=long_fact), + ) + repository = CanonicalRepository( + contents={source.candidate.record_id: source.content for source in sources}, + changes={sources[0].candidate.record_id: {"category": "warning"}}, + selected_changes={ + sources[0].candidate.record_id: { + "category": "warning", + "applicability": "needs_revalidation", + "changed_bindings": ("src/cache.py",), + "outcome": long_fact, + "outcome_failed": True, + "procedure_steps": sources[0].procedure_steps, + }, + sources[1].candidate.record_id: {"outcome": long_fact}, + }, + ) + provider = StaticProvider( + "lexical", + _provider_result( + "lexical", + replace(_candidate("1", "lexical", 1), highlights=()), + replace(_candidate("2", "lexical", 2), highlights=()), + ), + [], + ) + service = RetrievalService( + providers={"lexical": provider}, + repository=repository, + composer=EvidenceComposer(tokenizer=CoreTokenizer()), + retention_mode="apply", + ) + result = await service.retrieve( + RetrievalQuery( + workspace_id=WORKSPACE_ID, + text="recovery detail", + limit=2, + token_budget=4000, + intent="implement", + ) + ) + self.assertFalse(result.abstained) + self.assertEqual(2, len(result.items)) + by_id = {item.evidence_refs[0].record_id: item for item in result.items} + stale = by_id[sources[0].candidate.record_id] + ordinary = by_id[sources[1].candidate.record_id] + self.assertEqual(stale.excerpt, stale.outcome) + self.assertEqual(stale.outcome, stale.procedure_steps[0]) + self.assertEqual("verify health", stale.procedure_steps[1]) + self.assertTrue(stale.outcome_failed) + self.assertEqual(ordinary.excerpt, ordinary.outcome) + self.assertLess(len(stale.outcome), len(ordinary.outcome)) + for item in result.items: + self.assertTrue(long_fact.startswith(item.outcome)) + label = "Failed outcome" if item.outcome_failed else "Outcome" + self.assertIn(label + ": " + item.outcome, result.context.text) + + async def test_shadow_is_byte_identical_and_apply_retains_procedure(self): + query = RetrievalQuery( + workspace_id=WORKSPACE_ID, + text="deploy release rollout", + limit=5, + token_budget=256, + intent="implement", + ) + baseline = await self._service("off").retrieve(query) + shadow = await self._service("shadow").retrieve(query) + applied = await self._service("apply").retrieve(query) + self.assertFalse(baseline.abstained) + self.assertEqual(baseline.items, shadow.items) + self.assertEqual(baseline.context, shadow.context) + self.assertNotIn("freeze release writes", baseline.context.text) + self.assertIn("freeze release writes", applied.context.text) + self.assertEqual("RETENTION_SHADOW", shadow.providers[-1].reason) + self.assertEqual("RETENTION_APPLIED", applied.providers[-1].reason) + self.assertEqual(len(applied.items), shadow.providers[-1].returned_count) + + async def test_intent_diagnostics_preserve_explore_baseline(self): + query = RetrievalQuery( + workspace_id=WORKSPACE_ID, + text="deploy release rollout", + limit=5, + token_budget=256, + ) + baseline = await self._service("off").retrieve(query) + self.assertFalse(baseline.abstained) + self.assertTrue(baseline.items) + for intent in ("explore", "implement", "debug"): + intent_query = replace(query, intent=intent) + off = await self._service("off").retrieve(intent_query) + self.assertEqual(baseline.items, off.items) + self.assertEqual(baseline.context, off.context) + self.assertFalse( + any(item.provider == "retention" for item in off.providers) + ) + for mode in ("shadow", "apply"): + with self.subTest(intent=intent, mode=mode): + result = await self._service(mode).retrieve(intent_query) + self.assertFalse(result.abstained) + diagnostic = result.providers[-1] + self.assertEqual("retention", diagnostic.provider) + self.assertEqual("ready", diagnostic.status) + self.assertIsNone(diagnostic.manifest_generation) + self.assertGreaterEqual(diagnostic.elapsed_ms, 0) + self.assertEqual( + "RETENTION_NOT_APPLICABLE" + if intent == "explore" + else "RETENTION_APPLIED" + if mode == "apply" + else "RETENTION_SHADOW", + diagnostic.reason, + ) + if intent == "explore" or mode == "shadow": + self.assertEqual(baseline.items, result.items) + self.assertEqual(baseline.context, result.context) + if intent == "explore" or mode == "apply": + self.assertEqual(len(result.items), diagnostic.returned_count) + self.assertLessEqual(result.context.rendered_tokens, 256) + + async def test_none_does_not_compute_or_change_context(self): + query = RetrievalQuery( + workspace_id=WORKSPACE_ID, text="deploy", limit=5, token_budget=256 + ) + baseline = await self._service("off").retrieve(query) + for mode in ("shadow", "apply"): + result = await self._service(mode).retrieve(query) + self.assertEqual(baseline.context, result.context) + self.assertEqual(baseline.items, result.items) + self.assertFalse( + any(item.provider == "retention" for item in result.providers) + ) + + def test_query_and_mode_validation(self): + with self.assertRaisesRegex(ValueError, "intent"): + RetrievalQuery(workspace_id=WORKSPACE_ID, text="deploy", intent="plan") + with self.assertRaisesRegex(ValueError, "retention_mode"): + self._service("enabled") + + +class RetentionWireTests(_RuntimeServiceFixtures, unittest.IsolatedAsyncioTestCase): + async def test_real_source_transport_preserves_procedures_bindings_and_intent(self): + import sqlite3 + + from daem0nmcp.api.v7.federated_retrieval import compose_federated_results + from daem0nmcp.api.v7.runtime_services import ( + Task8RecallService, + WorkspaceStorageResolver, + ) + from daem0nmcp.config import Settings + from daem0nmcp.retrieval.runtime import drain_projection_jobs + + (self.root / "runbook.txt").write_text("release runbook\n", encoding="utf-8") + stored = await self._writer().store( + self.workspace, + self._store_command( + record_type="procedure", + content="deploy release rollout", + procedure_steps=("freeze release writes",), + code_refs=(("runbook.txt", None),), + ), + ) + settings = Settings( + retrieval_utility_mode="off", retrieval_retention_mode="apply" + ) + statuses = { + "local": "disabled", + "models-local": "disabled", + "graph": "disabled", + } + while await drain_projection_jobs( + self.database, + config=settings, + max_jobs=100, + include_optional=True, + capability_statuses=statuses, + ): + pass + with closing(sqlite3.connect(self.database)) as connection, connection: + connection.execute( + "DELETE FROM projection_manifests WHERE workspace_id=? " + "AND projection_name='procedure'", + (self.workspace.workspace_id,), + ) + service = Task8RecallService(config=settings, capability_statuses=statuses) + query = RetrievalQuery( + workspace_id=self.workspace.workspace_id, + text="deploy release rollout", + token_budget=256, + intent="implement", + ) + try: + with WorkspaceStorageResolver().locked_active(self.workspace) as active: + source = await service._retrieve_source(self.workspace, active, query) + direct = await service.retrieve(self.workspace, query, frozenset()) + finally: + service.close() + self.assertEqual(1, len(source.candidates)) + candidate = source.candidates[0] + self.assertEqual(("freeze release writes",), candidate.procedure_steps) + self.assertEqual((("runbook.txt", None),), candidate.code_bindings) + self.assertEqual(stored.record.record_id, candidate.record.record_id) + self.assertNotIn("procedure", candidate.channels) + baseline = compose_federated_results( + {self.workspace.workspace_id: source}, query + ) + shadow = compose_federated_results( + {self.workspace.workspace_id: source}, + query, + retention_mode="shadow", + ) + applied = compose_federated_results( + {self.workspace.workspace_id: source}, + query, + retention_mode="apply", + ) + self.assertEqual(baseline.items, shadow.items) + self.assertEqual(baseline.rendered_context, shadow.rendered_context) + self.assertEqual(baseline.token_usage, shadow.token_usage) + self.assertIn("freeze release writes", applied.rendered_context) + self.assertIn("freeze release writes", direct.rendered_context) + self.assertEqual("RETENTION_APPLIED", direct.provider_diagnostics[-1].reason) + self.assertEqual("RETENTION_SHADOW", shadow.provider_diagnostics[-1].reason) + self.assertEqual( + self.workspace.workspace_id, + applied.items[0].evidence_refs[0].origin_workspace_id, + ) + + def test_wire_intent_validation(self): + from pydantic import ValidationError + + from daem0nmcp.api.v7.tools import MemoryRecallInput + + with self.assertRaises(ValidationError): + MemoryRecallInput( + workspace_id=self.workspace.workspace_id, query="deploy", intent="plan" + ) + + +class FederatedRetentionTests(unittest.TestCase): + def test_late_procedure_retention_and_shadow_context_isolation(self): + from daem0nmcp.api.v7.federated_retrieval import ( + FederatedSourceResult, + compose_federated_results, + sliced_queries, + ) + from tests.api_v7.test_federated_retrieval import FederatedRetrievalTests + + notes = FederatedRetrievalTests._source( + WORKSPACE_ID, + ("1", "2", "3", "4"), + content="background detail " * 150, + ) + procedure = FederatedRetrievalTests._source( + WORKSPACE_ID, + ("5",), + content="deploy release rollout", + ).candidates[0] + procedure = replace(procedure, procedure_steps=("freeze release writes",)) + results = { + WORKSPACE_ID: FederatedSourceResult( + candidates=(*notes.candidates, procedure) + ), + } + query = RetrievalQuery( + workspace_id=WORKSPACE_ID, + text="deploy release rollout", + limit=5, + token_budget=256, + intent="implement", + ) + baseline = compose_federated_results(results, query) + shadow = compose_federated_results(results, query, retention_mode="shadow") + applied = compose_federated_results(results, query, retention_mode="apply") + self.assertEqual(baseline.items, shadow.items) + self.assertEqual(baseline.rendered_context, shadow.rendered_context) + self.assertEqual(baseline.token_usage, shadow.token_usage) + self.assertNotIn("freeze release writes", baseline.rendered_context) + self.assertIn("freeze release writes", applied.rendered_context) + ids = [item.record.record_id[-1] for item in applied.items] + self.assertEqual(sorted(ids), ids) + self.assertLessEqual(applied.token_usage.rendered, 256) + self.assertEqual( + len(applied.items), shadow.provider_diagnostics[-1].returned_count + ) + single = compose_federated_results( + results, + replace(query, limit=1), + retention_mode="apply", + ) + self.assertEqual("5", single.items[0].record.record_id[-1]) + for intent in (None, "explore", "implement", "debug"): + intent_query = replace(query, intent=intent) + off = compose_federated_results(results, intent_query) + self.assertEqual(baseline.items, off.items) + self.assertEqual(baseline.rendered_context, off.rendered_context) + self.assertEqual(baseline.token_usage, off.token_usage) + self.assertFalse( + any(item.provider == "retention" for item in off.provider_diagnostics) + ) + for mode in ("shadow", "apply"): + with self.subTest(intent=intent, mode=mode): + result = compose_federated_results( + results, intent_query, retention_mode=mode + ) + if intent in (None, "explore") or mode == "shadow": + self.assertEqual(baseline.items, result.items) + self.assertEqual( + baseline.rendered_context, result.rendered_context + ) + self.assertEqual(baseline.token_usage, result.token_usage) + self.assertLessEqual(result.token_usage.rendered, 256) + if intent is None: + self.assertFalse( + any( + item.provider == "retention" + for item in result.provider_diagnostics + ) + ) + continue + diagnostic = result.provider_diagnostics[-1] + self.assertEqual("retention", diagnostic.provider) + self.assertEqual("ready", diagnostic.status) + self.assertIsNone(diagnostic.manifest_generation) + self.assertGreaterEqual(diagnostic.elapsed_ms, 0) + self.assertEqual( + "RETENTION_NOT_APPLICABLE" + if intent == "explore" + else "RETENTION_APPLIED" + if mode == "apply" + else "RETENTION_SHADOW", + diagnostic.reason, + ) + if intent == "explore" or mode == "apply": + self.assertEqual(len(result.items), diagnostic.returned_count) + slices = sliced_queries(query, WORKSPACE_ID, ["ws_" + "b" * 24]) + self.assertTrue(all(item.intent == "implement" for item in slices.values())) + + def test_shadow_policy_failure_does_not_change_federated_context(self): + from unittest.mock import patch + + from daem0nmcp.api.v7.federated_retrieval import compose_federated_results + from tests.api_v7.test_federated_retrieval import FederatedRetrievalTests + + results = {WORKSPACE_ID: FederatedRetrievalTests._source(WORKSPACE_ID, ("1",))} + query = RetrievalQuery( + workspace_id=WORKSPACE_ID, text="deploy", intent="implement" + ) + baseline = compose_federated_results(results, query) + with patch( + "daem0nmcp.api.v7.federated_retrieval._compose_retained", + side_effect=RuntimeError("private failure"), + ): + shadow = compose_federated_results(results, query, retention_mode="shadow") + self.assertEqual(baseline.items, shadow.items) + self.assertEqual(baseline.rendered_context, shadow.rendered_context) + self.assertEqual("RETENTION_FAILED", shadow.provider_diagnostics[-1].reason) + self.assertEqual("degraded", shadow.provider_diagnostics[-1].status) + self.assertEqual(0, shadow.provider_diagnostics[-1].returned_count) + + +class RetentionShadowFailureTests(unittest.IsolatedAsyncioTestCase): + _service = RetentionServiceTests._service + + async def test_shadow_policy_failure_keeps_baseline_context(self): + service = self._service("shadow") + delegate = service._composer + + class FailingRetentionComposer: + async def compose_async( + self, + selected, + *, + token_budget, + retention=None, + label_applicability=False, + ): + if retention is not None: + raise RuntimeError("private failure") + return await delegate.compose_async( + selected, + token_budget=token_budget, + retention=retention, + label_applicability=label_applicability, + ) + + service._composer = FailingRetentionComposer() + query = RetrievalQuery( + workspace_id=WORKSPACE_ID, + text="deploy", + limit=5, + token_budget=256, + intent="implement", + ) + baseline = await self._service("off").retrieve(query) + shadow = await service.retrieve(query) + self.assertEqual(baseline.items, shadow.items) + self.assertEqual(baseline.context, shadow.context) + self.assertEqual("RETENTION_FAILED", shadow.providers[-1].reason) + self.assertEqual("degraded", shadow.providers[-1].status) + self.assertEqual(0, shadow.providers[-1].returned_count) + + +class CanonicalProcedureHydrationTests( + _RuntimeServiceFixtures, + unittest.IsolatedAsyncioTestCase, +): + async def test_canonical_steps_enforce_source_context_and_transaction_cutoff(self): + import json + import sqlite3 + from datetime import timedelta + + from daem0nmcp.config import Settings + from daem0nmcp.retrieval.repository import ( + RetrievalRepositoryError, + SQLiteRetrievalRepository, + ) + from daem0nmcp.retrieval.runtime import ( + create_retrieval_service, + drain_projection_jobs, + ) + from tests.api_v7.test_runtime_services import WORKED_AT + + stored = await self._writer().store( + self.workspace, + self._store_command( + record_type="procedure", + content="deploy release rollout", + procedure_steps=("freeze release writes",), + ), + ) + config = Settings( + retrieval_utility_mode="off", + memory_validity_mode="off", + retrieval_retention_mode="off", + ) + statuses = { + "local": "disabled", + "models-local": "disabled", + "graph": "disabled", + } + while await drain_projection_jobs( + self.database, + config=config, + max_jobs=100, + include_optional=True, + capability_statuses=statuses, + ): + pass + service = create_retrieval_service( + self.database, + config=config, + capability_statuses=statuses, + ) + self.health_services.append(service) + query = RetrievalQuery(workspace_id=self.workspace.workspace_id, text="deploy") + candidates = await service.retrieve_candidates(query) + self.assertFalse(candidates.abstained) + self.assertEqual(1, len(candidates.selected)) + candidate = candidates.selected[0].candidate + repository = SQLiteRetrievalRepository(self.database) + self.health_services.append(repository) + snapshot = WORKED_AT + timedelta(days=1) + evidence = await repository.load_selected_evidence( + query, + (candidate,), + snapshot_time=snapshot, + ) + self.assertEqual(("freeze release writes",), evidence[0].procedure_steps) + with self.assertRaisesRegex( + RetrievalRepositoryError, + "EVIDENCE_CONTENT_UNAVAILABLE", + ): + await repository.load_selected_evidence( + replace( + query, as_of_transaction_time=WORKED_AT - timedelta(microseconds=1) + ), + (candidate,), + snapshot_time=snapshot, + ) + with closing(sqlite3.connect(self.database)) as connection, connection: + context_text = connection.execute( + "SELECT context_json FROM memory_records " + "WHERE workspace_id=? AND record_id=?", + (self.workspace.workspace_id, stored.record.record_id), + ).fetchone()[0] + context = json.loads(context_text) + context["steps"] = ["fabricated instruction"] + connection.execute( + "UPDATE memory_records SET context_json=? " + "WHERE workspace_id=? AND record_id=?", + ( + json.dumps(context), + self.workspace.workspace_id, + stored.record.record_id, + ), + ) + with self.assertRaisesRegex( + RetrievalRepositoryError, + "EVIDENCE_CONTENT_UNAVAILABLE", + ): + await repository.load_selected_evidence( + query, + (candidate,), + snapshot_time=snapshot, + ) diff --git a/tests/test_retrieval_runtime.py b/tests/test_retrieval_runtime.py index 3a537ba..2882a08 100644 --- a/tests/test_retrieval_runtime.py +++ b/tests/test_retrieval_runtime.py @@ -26,7 +26,7 @@ def _apply_retrieval_schema(connection: sqlite3.Connection) -> None: "CREATE TABLE schema_version(version INTEGER PRIMARY KEY, applied_at TEXT)" ) for version, _description, statements in MIGRATIONS: - if version < 16 or version > 18: + if version not in (16, 17, 18, 33): continue for statement in statements: connection.execute(statement) @@ -98,6 +98,14 @@ def _settings() -> SimpleNamespace: retrieval_graph_max_depth=2, retrieval_rerank_candidate_limit=25, retrieval_rerank_enabled=False, + retrieval_reranker="embedding", + retrieval_late_interaction_model="colbert-ir/colbertv2.0", + retrieval_utility_mode="shadow", + retrieval_utility_weight=0.1, + retrieval_utility_credit="trace", + retrieval_utility_candidate_limit=25, + memory_validity_mode="shadow", + retrieval_retention_mode="shadow", retrieval_rrf_weights={ "lexical": 1.0, "dense": 1.0, @@ -155,6 +163,15 @@ async def test_factory_runs_real_lexical_policy_repository_and_composer(self): self.assertEqual(self.first_id, result.items[0].evidence_refs[0].record_id) self.assertEqual("lexical", result.providers[0].provider) self.assertIn("durable runtime baseline", result.context.text) + self.assertEqual( + ("utility", "ready", "UTILITY_SHADOW_SAME", 0), + ( + result.providers[-1].provider, + result.providers[-1].status, + result.providers[-1].reason, + result.providers[-1].returned_count, + ), + ) async def test_new_write_is_recalled_from_stale_active_lexical_delta(self): from daem0nmcp.retrieval.runtime import create_retrieval_service @@ -217,6 +234,38 @@ async def test_factory_does_not_assemble_disabled_optional_profiles(self): self.assertNotIn("graph", service._providers) embedding_encoder.assert_not_called() + async def test_missing_late_interaction_reports_unavailable_without_fallback(self): + from daem0nmcp.retrieval.runtime import create_retrieval_service + from daem0nmcp.retrieval.types import RetrievalQuery + + config = _settings() + config.retrieval_rerank_enabled = True + config.retrieval_reranker = "late_interaction" + service = create_retrieval_service( + self.path, + config=config, + capability_statuses={ + "local": "disabled", + "models-local": "disabled", + "graph": "disabled", + "late-interaction": "disabled", + }, + ) + self.services.append(service) + result = await service.retrieve( + RetrievalQuery( + workspace_id=WORKSPACE_ID, + text="durable runtime", + rerank=True, + ) + ) + self.assertIsNone(service._reranker) + diagnostic = next( + item for item in result.providers if item.provider == "reranker" + ) + self.assertEqual("unavailable", diagnostic.status) + self.assertEqual("RERANKER_UNAVAILABLE", diagnostic.reason) + async def test_durable_lexical_job_is_drained_off_loop_and_refreshes_search(self): connection = sqlite3.connect(self.path) second_id = _append(connection, "2", "novel queued projection term", 300) diff --git a/tests/test_retrieval_service.py b/tests/test_retrieval_service.py index f3c50b5..9fa14d6 100644 --- a/tests/test_retrieval_service.py +++ b/tests/test_retrieval_service.py @@ -128,13 +128,22 @@ def __init__(self): def compose(self, selected, *, token_budget): raise AssertionError("RetrievalService must not compose on the event loop") - async def compose_async(self, selected, *, token_budget): + async def compose_async( + self, selected, *, token_budget, retention=None, label_applicability=False + ): self.async_calls += 1 - return self.delegate.compose(selected, token_budget=token_budget) + return self.delegate.compose( + selected, + token_budget=token_budget, + retention=retention, + label_applicability=label_applicability, + ) class UnavailableComposer: - async def compose_async(self, selected, *, token_budget): + async def compose_async( + self, selected, *, token_budget, retention=None, label_applicability=False + ): from daem0nmcp.retrieval.composer import CompositionResult from daem0nmcp.retrieval.types import ContextPackage @@ -154,7 +163,9 @@ async def compose_async(self, selected, *, token_budget): class ForbiddenComposer: - async def compose_async(self, selected, *, token_budget): + async def compose_async( + self, selected, *, token_budget, retention=None, label_applicability=False + ): raise AssertionError("candidate retrieval must not compose context") @@ -162,10 +173,17 @@ class ProvenanceTamperingComposer: def __init__(self): self.delegate = AsyncComposer() - async def compose_async(self, selected, *, token_budget): + async def compose_async( + self, selected, *, token_budget, retention=None, label_applicability=False + ): from daem0nmcp.retrieval.composer import CompositionResult - result = await self.delegate.compose_async(selected, token_budget=token_budget) + result = await self.delegate.compose_async( + selected, + token_budget=token_budget, + retention=retention, + label_applicability=label_applicability, + ) return CompositionResult( items=(replace(result.items[0], score=999.0),), context=result.context, @@ -176,10 +194,17 @@ class OutcomeFlagTamperingComposer: def __init__(self): self.delegate = AsyncComposer() - async def compose_async(self, selected, *, token_budget): + async def compose_async( + self, selected, *, token_budget, retention=None, label_applicability=False + ): from daem0nmcp.retrieval.composer import CompositionResult - result = await self.delegate.compose_async(selected, token_budget=token_budget) + result = await self.delegate.compose_async( + selected, + token_budget=token_budget, + retention=retention, + label_applicability=label_applicability, + ) return CompositionResult( items=(replace(result.items[0], outcome_failed=True),), context=result.context, @@ -190,10 +215,17 @@ class StructuredFieldTamperingComposer: def __init__(self): self.delegate = AsyncComposer() - async def compose_async(self, selected, *, token_budget): + async def compose_async( + self, selected, *, token_budget, retention=None, label_applicability=False + ): from daem0nmcp.retrieval.composer import CompositionResult - result = await self.delegate.compose_async(selected, token_budget=token_budget) + result = await self.delegate.compose_async( + selected, + token_budget=token_budget, + retention=retention, + label_applicability=label_applicability, + ) return CompositionResult( items=( replace( @@ -210,10 +242,17 @@ class LegacyMetadataTamperingComposer: def __init__(self): self.delegate = AsyncComposer() - async def compose_async(self, selected, *, token_budget): + async def compose_async( + self, selected, *, token_budget, retention=None, label_applicability=False + ): from daem0nmcp.retrieval.composer import CompositionResult - result = await self.delegate.compose_async(selected, token_budget=token_budget) + result = await self.delegate.compose_async( + selected, + token_budget=token_budget, + retention=retention, + label_applicability=label_applicability, + ) return CompositionResult( items=( replace( @@ -231,18 +270,32 @@ class BudgetTamperingComposer: def __init__(self): self.delegate = AsyncComposer() - async def compose_async(self, selected, *, token_budget): - return await self.delegate.compose_async(selected, token_budget=1000) + async def compose_async( + self, selected, *, token_budget, retention=None, label_applicability=False + ): + return await self.delegate.compose_async( + selected, + token_budget=1000, + retention=retention, + label_applicability=label_applicability, + ) class ExcerptTamperingComposer: def __init__(self): self.delegate = AsyncComposer() - async def compose_async(self, selected, *, token_budget): + async def compose_async( + self, selected, *, token_budget, retention=None, label_applicability=False + ): from daem0nmcp.retrieval.composer import CompositionResult - result = await self.delegate.compose_async(selected, token_budget=token_budget) + result = await self.delegate.compose_async( + selected, + token_budget=token_budget, + retention=retention, + label_applicability=label_applicability, + ) return CompositionResult( items=( replace( @@ -259,10 +312,17 @@ class OrderTamperingComposer: def __init__(self): self.delegate = AsyncComposer() - async def compose_async(self, selected, *, token_budget): + async def compose_async( + self, selected, *, token_budget, retention=None, label_applicability=False + ): from daem0nmcp.retrieval.composer import CompositionResult - result = await self.delegate.compose_async(selected, token_budget=token_budget) + result = await self.delegate.compose_async( + selected, + token_budget=token_budget, + retention=retention, + label_applicability=label_applicability, + ) return CompositionResult( items=tuple(reversed(result.items)), context=replace( diff --git a/tests/test_retrieval_specialized_projection.py b/tests/test_retrieval_specialized_projection.py index 7a2e249..86d0ca9 100644 --- a/tests/test_retrieval_specialized_projection.py +++ b/tests/test_retrieval_specialized_projection.py @@ -40,7 +40,7 @@ def setUp(self) -> None: self.connection = sqlite3.connect(self.database_path) self.connection.row_factory = sqlite3.Row self.connection.execute("PRAGMA foreign_keys=ON") - for version in (16, 17, 18): + for version in (16, 17, 18, 33): _apply_migration(self.connection, version) self.connection.commit() self._sequence = 0 diff --git a/tests/test_retrieval_utility.py b/tests/test_retrieval_utility.py new file mode 100644 index 0000000..8a4049d --- /dev/null +++ b/tests/test_retrieval_utility.py @@ -0,0 +1,405 @@ +"""Deterministic outcome evidence weighting and credit folding tests.""" + +from __future__ import annotations + +import unittest +from dataclasses import replace +from datetime import datetime, timezone + +from tests.api_v7.test_runtime_services import _RuntimeServiceFixtures +from tests.test_retrieval_service import ( + SNAPSHOT, + CanonicalRepository, + ReversingReranker, + StaticProvider, + _candidate, + _provider_result, + _query, + _record_id, + _service, +) + + +class VerificationWeightTests(unittest.TestCase): + def test_absent_and_self_report_have_half_weight(self): + from daem0nmcp.retrieval.utility import verification_weight + + for worked in (False, True): + self.assertEqual(0.5, verification_weight(worked, None)) + self.assertEqual(0.5, verification_weight(worked, {"kind": "self_report"})) + + def test_review_has_three_quarter_weight(self): + from daem0nmcp.retrieval.utility import verification_weight + + for worked in (False, True): + self.assertEqual(0.75, verification_weight(worked, {"kind": "review"})) + + def test_command_evidence_covers_matching_and_conflicting_results(self): + from daem0nmcp.retrieval.utility import verification_weight + + for kind in ("test", "command"): + for worked in (False, True): + for verification in ({"kind": kind}, {"kind": kind, "exit_code": None}): + self.assertEqual(0.75, verification_weight(worked, verification)) + for exit_code in (0, 1, -1): + with self.subTest(kind=kind, worked=worked, exit_code=exit_code): + self.assertEqual( + 1.0 if (exit_code == 0) == worked else 0.25, + verification_weight( + worked, {"kind": kind, "exit_code": exit_code} + ), + ) + + +class UtilityFoldingTests(unittest.TestCase): + def test_empty_evidence_is_neutral(self): + from daem0nmcp.retrieval.utility import ( + UtilityEstimate, + fold_utility, + utility_signal, + ) + + estimate = fold_utility(()) + self.assertEqual(UtilityEstimate(0.5, 0.5, 0), estimate) + for credit in ("single_step", "trace"): + self.assertEqual(0.0, utility_signal(estimate, credit)) + + def test_direct_and_first_hop_evidence_updates_both_estimates(self): + from daem0nmcp.retrieval.utility import UtilityContribution, fold_utility + + for depth in (0, 1): + with self.subTest(depth=depth): + estimate = fold_utility( + (UtilityContribution(1, "evt_a", depth, 1.0, 0.5),) + ) + self.assertEqual(0.575, estimate.q_single) + self.assertEqual(0.575, estimate.q_trace) + self.assertEqual(1, estimate.evidence_count) + + def test_second_hop_has_trace_factor_of_point_five_six(self): + from daem0nmcp.retrieval.utility import UtilityContribution, fold_utility + + estimate = fold_utility((UtilityContribution(1, "evt_a", 2, 1.0, 1.0),)) + self.assertEqual(0.5, estimate.q_single) + self.assertEqual(0.584, estimate.q_trace) + self.assertEqual(1, estimate.evidence_count) + + def test_deeper_trace_evidence_decays_and_rounds_at_the_end(self): + from daem0nmcp.retrieval.utility import UtilityContribution, fold_utility + + estimate = fold_utility((UtilityContribution(1, "evt_a", 4, 0.0, 0.75),)) + self.assertEqual(0.5, estimate.q_single) + self.assertEqual(round(0.5 + 0.3 * 0.75 * 0.56**3 * -0.5, 6), estimate.q_trace) + + def test_fold_orders_by_time_then_event_id_not_input_order(self): + from daem0nmcp.retrieval.utility import UtilityContribution, fold_utility + + contributions = ( + UtilityContribution(2, "evt_c", 0, 1.0, 1.0), + UtilityContribution(1, "evt_b", 1, 0.0, 1.0), + UtilityContribution(1, "evt_a", 0, 1.0, 1.0), + ) + estimate = fold_utility(contributions) + self.assertEqual(estimate, fold_utility(reversed(contributions))) + self.assertEqual(0.6185, estimate.q_single) + self.assertEqual(0.6185, estimate.q_trace) + self.assertEqual(3, estimate.evidence_count) + + def test_signal_uses_selected_credit_and_evidence_support(self): + from daem0nmcp.retrieval.utility import UtilityEstimate, utility_signal + + self.assertAlmostEqual( + 0.1, utility_signal(UtilityEstimate(0.65, 0.5, 1), "single_step") + ) + self.assertEqual(0.0, utility_signal(UtilityEstimate(0.65, 0.5, 1), "trace")) + self.assertEqual( + 1.0, utility_signal(UtilityEstimate(1.0, 0.0, 3), "single_step") + ) + self.assertEqual(-1.0, utility_signal(UtilityEstimate(1.0, 0.0, 3), "trace")) + self.assertEqual(1.0, utility_signal(UtilityEstimate(1.0, 1.0, 10), "trace")) + + +class UtilityRepository(CanonicalRepository): + def __init__(self, contributions=None, *, fail=False): + super().__init__() + self.contributions = contributions or {} + self.fail = fail + self.utility_calls = [] + + async def load_utility_contributions( + self, workspace_id, record_ids, transaction_at_us + ): + self.utility_calls.append((workspace_id, record_ids, transaction_at_us)) + if self.fail: + raise RuntimeError("private outcome database detail") + return self.contributions + + +class UtilityStageTests(unittest.IsolatedAsyncioTestCase): + def _service(self, repository, **changes): + return _service( + providers={ + "lexical": StaticProvider( + "lexical", + _provider_result( + "lexical", + _candidate("1", "lexical", 1), + _candidate("2", "lexical", 2), + _candidate("3", "lexical", 3), + ), + [], + ), + }, + repository=repository, + **changes, + ) + + def _positive_evidence(self, depth=1): + from daem0nmcp.retrieval.utility import UtilityContribution + + return { + _record_id("2"): tuple( + UtilityContribution(index, f"evt_{index}", depth, 1.0, 1.0) + for index in range(3) + ) + } + + async def test_shadow_keeps_order_and_context_but_attaches_raw_utility(self): + repository = UtilityRepository(self._positive_evidence()) + baseline = await self._service(repository).retrieve(_query(limit=3)) + shadow = await self._service(repository, utility_mode="shadow").retrieve( + _query(limit=3) + ) + self.assertEqual( + tuple(item.evidence_refs for item in baseline.items), + tuple(item.evidence_refs for item in shadow.items), + ) + self.assertEqual(baseline.context, shadow.context) + self.assertEqual(0.8285, shadow.items[1].utility) + self.assertIsNone(shadow.items[0].utility) + diagnostic = shadow.providers[-1] + self.assertEqual( + ("utility", "ready", "UTILITY_SHADOW_REORDER", 1), + ( + diagnostic.provider, + diagnostic.status, + diagnostic.reason, + diagnostic.returned_count, + ), + ) + self.assertIsNone(diagnostic.manifest_generation) + + async def test_apply_reorders_before_selection_and_preserves_tail(self): + repository = UtilityRepository(self._positive_evidence()) + result = await self._service( + repository, utility_mode="apply", utility_candidate_limit=2 + ).retrieve_candidates(_query(limit=3)) + self.assertEqual( + ("2", "1", "3"), + tuple(source.candidate.record_id[-1] for source in result.selected), + ) + self.assertEqual(0.8285, result.selected[0].utility) + self.assertEqual("UTILITY_APPLIED", result.providers[-1].reason) + self.assertEqual( + (_record_id("1"), _record_id("2")), repository.utility_calls[0][1] + ) + + async def test_exception_degrades_and_keeps_original_order(self): + result = await self._service( + UtilityRepository(fail=True), utility_mode="apply" + ).retrieve(_query(limit=3)) + self.assertFalse(result.abstained) + self.assertEqual( + ("1", "2", "3"), + tuple(item.evidence_refs[0].record_id[-1] for item in result.items), + ) + self.assertTrue(all(item.utility is None for item in result.items)) + diagnostic = result.providers[-1] + self.assertEqual("degraded", diagnostic.status) + self.assertEqual("UTILITY_FAILED", diagnostic.reason) + self.assertEqual(0, diagnostic.returned_count) + self.assertNotIn("private outcome", repr(result)) + + async def test_no_evidence_reports_shadow_same_and_leaves_utility_unset(self): + result = await self._service( + UtilityRepository(), utility_mode="shadow" + ).retrieve(_query()) + self.assertEqual("UTILITY_SHADOW_SAME", result.providers[-1].reason) + self.assertEqual(0, result.providers[-1].returned_count) + self.assertTrue(all(item.utility is None for item in result.items)) + + async def test_transaction_cutoff_uses_explicit_time_or_snapshot(self): + for cutoff in (None, datetime(2026, 1, 2, tzinfo=timezone.utc)): + repository = UtilityRepository() + query = _query(as_of_transaction_time=cutoff) + await self._service(repository, utility_mode="shadow").retrieve_candidates( + query + ) + delta = (cutoff or SNAPSHOT) - datetime(1970, 1, 1, tzinfo=timezone.utc) + expected = ( + delta.days * 86400 + delta.seconds + ) * 1000000 + delta.microseconds + self.assertEqual( + (query.workspace_id, tuple(_record_id(d) for d in "123"), expected), + repository.utility_calls[0], + ) + + async def test_credit_selects_raw_single_or_trace_estimate(self): + from daem0nmcp.retrieval.utility import fold_utility + + contributions = self._positive_evidence(depth=2) + estimate = fold_utility(contributions[_record_id("2")]) + for credit, expected in ( + ("single_step", estimate.q_single), + ("trace", estimate.q_trace), + ): + result = await self._service( + UtilityRepository(contributions), + utility_mode="shadow", + utility_credit=credit, + ).retrieve(_query()) + self.assertEqual(expected, result.items[1].utility) + + async def test_utility_runs_after_reranker(self): + repository = UtilityRepository() + result = await self._service( + repository, + utility_mode="shadow", + reranker=ReversingReranker(), + rerank_enabled=True, + ).retrieve_candidates(_query(rerank=True)) + self.assertEqual( + tuple(_record_id(d) for d in "321"), repository.utility_calls[0][1] + ) + self.assertEqual( + ("reranker", "utility"), + tuple(diagnostic.provider for diagnostic in result.providers[-2:]), + ) + + def test_constructor_validates_modes_weight_limit_and_repository(self): + for changes in ( + {"utility_mode": "invalid"}, + {"utility_credit": "invalid"}, + {"utility_weight": -0.1}, + {"utility_weight": 1.1}, + {"utility_weight": float("nan")}, + {"utility_weight": float("inf")}, + {"utility_weight": True}, + {"utility_candidate_limit": 0}, + {"utility_candidate_limit": 201}, + {"utility_candidate_limit": True}, + {"utility_candidate_limit": 1.5}, + ): + with self.subTest(changes=changes), self.assertRaises(ValueError): + self._service(UtilityRepository(), **changes) + with self.assertRaises(ValueError): + self._service(CanonicalRepository(), utility_mode="shadow") + self._service(CanonicalRepository()) + for weight in (0.0, 1.0): + self._service(UtilityRepository(), utility_weight=weight) + + async def test_selected_and_composed_utility_validate_finite_unit_interval(self): + selected = ( + await self._service(UtilityRepository()).retrieve_candidates(_query()) + ).selected[0] + item = (await self._service(UtilityRepository()).retrieve(_query())).items[0] + for value in (-0.1, 1.1, float("nan"), float("inf"), True): + for evidence in (selected, item): + with self.subTest(value=value), self.assertRaises(ValueError): + replace(evidence, utility=value) + + +class UtilityWireTests(_RuntimeServiceFixtures, unittest.IsolatedAsyncioTestCase): + async def test_production_shadow_reports_utility_on_wire(self): + from daem0nmcp.api.v7.pinned import MemoryOutcomeCommand + from daem0nmcp.api.v7.runtime_services import Task8RecallService + from daem0nmcp.config import Settings + from daem0nmcp.retrieval.runtime import drain_projection_jobs + from daem0nmcp.retrieval.types import RetrievalQuery + + writer = self._writer() + stored = await writer.store(self.workspace, self._store_command()) + await writer.record_outcome( + self.workspace, + MemoryOutcomeCommand( + record_id=stored.record.record_id, + outcome_text="Canonical tests passed.", + worked=True, + happened_at=None, + idempotency_key="utility-wire-outcome", + verification={"kind": "test", "exit_code": 0}, + ), + ) + config = Settings() + statuses = { + "local": "disabled", + "models-local": "disabled", + "graph": "disabled", + } + while await drain_projection_jobs( + self.database, + config=config, + max_jobs=100, + include_optional=True, + capability_statuses=statuses, + ): + pass + service = Task8RecallService( + config=config, + capability_statuses=statuses, + max_workers=1, + ) + try: + result = await service.retrieve( + self.workspace, + RetrievalQuery( + workspace_id=self.workspace.workspace_id, + text="canonical", + ), + frozenset(), + ) + finally: + service.close() + self.assertFalse(result.abstained) + self.assertEqual(0.65, result.items[0].utility) + self.assertEqual(0.65, result.model_dump()["items"][0]["utility"]) + diagnostic = result.provider_diagnostics[-1] + self.assertEqual( + ("utility", "ready", "UTILITY_SHADOW_SAME", 1), + ( + diagnostic.provider, + diagnostic.status, + diagnostic.reason, + diagnostic.returned_count, + ), + ) + + def test_utility_settings_participate_in_service_cache_fingerprint(self): + from daem0nmcp.api.v7.runtime_services import ( + _RETRIEVAL_CONFIG_FIELDS, + _retrieval_config_fingerprint, + ) + from daem0nmcp.config import Settings + + config = Settings() + self.assertEqual("shadow", config.retrieval_utility_mode) + for field in ( + "retrieval_utility_mode", + "retrieval_utility_weight", + "retrieval_utility_credit", + "retrieval_utility_candidate_limit", + ): + self.assertIn(field, _RETRIEVAL_CONFIG_FIELDS) + baseline = _retrieval_config_fingerprint(config, {}) + for field, value in ( + ("retrieval_utility_mode", "apply"), + ("retrieval_utility_weight", 0.2), + ("retrieval_utility_credit", "single_step"), + ("retrieval_utility_candidate_limit", 10), + ): + self.assertNotEqual( + baseline, + _retrieval_config_fingerprint( + config.model_copy(update={field: value}), {} + ), + ) diff --git a/tests/test_v7_schema_upgrade.py b/tests/test_v7_schema_upgrade.py index c157cf1..eaf4c80 100644 --- a/tests/test_v7_schema_upgrade.py +++ b/tests/test_v7_schema_upgrade.py @@ -7,6 +7,7 @@ import sqlite3 import subprocess import sys +from contextlib import closing from pathlib import Path from types import SimpleNamespace from unittest import mock @@ -113,6 +114,86 @@ def _fixture( return storage, database, registry +def _orphaned_learning_fixture(root: Path, *, deleted: bool = False): + storage, database, registry = _fixture(root, maximum_version=33, with_event=False) + expected = {} + orphan_ids = [] + with closing(sqlite3.connect(database)) as connection: + connection.execute("PRAGMA foreign_keys=ON") + store = EventStore(connection) + for index, workspace_id in enumerate( + (registry.default.workspace_id, "ws_" + "b" * 24), start=1 + ): + record_id = "mem_" + str(index) * 64 + source_id = "mem_" + str(index + 2) * 64 + orphan_ids.append((record_id,)) + store.append_and_project( + EventCommand( + workspace_id=workspace_id, + stream_id=source_id, + stream_kind="memory", + event_type="memory.created", + occurred_at_us=10 + index, + recorded_at_us=10 + index, + actor_type="user", + payload={"record": _state("preserved source")}, + ) + ) + final = _state("latest canonical state") + final.update(worked=False, outcome="latest state differs from outcome") + events = [ + ("memory.created", _state("original state"), 20 + index), + ( + "memory.outcome_recorded", + {**_state("outcome state"), "worked": True, "outcome": "worked"}, + 40 + index, + ), + ("memory.updated", final, 30 + index), + ] + if deleted and index == 1: + events.append( + ( + "memory.deleted", + {**final, "deleted_at_us": 50 + index}, + 50 + index, + ) + ) + for version, (event_type, record, timestamp) in enumerate(events, start=1): + payload = {"record": record} + if event_type in {"memory.created", "memory.outcome_recorded"}: + payload["provenance"] = {"informed_by": [source_id]} + store.append_and_project( + EventCommand( + workspace_id=workspace_id, + stream_id=record_id, + stream_kind="memory", + event_type=event_type, + occurred_at_us=timestamp, + recorded_at_us=timestamp, + actor_type="user", + expected_stream_version=version, + payload=payload, + ) + ) + connection.commit() + for table in ( + "memory_records", + "memory_provenance_edges", + "memory_outcome_signals", + ): + expected[table] = connection.execute( + f"SELECT * FROM {table} ORDER BY 1,2,3" + ).fetchall() + connection.execute("DROP TABLE memory_provenance_edges") + connection.execute("DROP TABLE memory_outcome_signals") + connection.execute("DELETE FROM schema_version WHERE version=33") + connection.executemany( + "DELETE FROM memory_records WHERE record_id=?", orphan_ids + ) + connection.commit() + return storage, database, registry, expected + + def _authority_rows(path: Path) -> dict[str, list[tuple[object, ...]]]: connection = sqlite3.connect(path) try: @@ -222,6 +303,471 @@ def test_dry_run_and_apply_upgrade_retained_v7_without_changing_source(tmp_path: assert source.read_bytes() == source_bytes +def test_upgrade_backfills_native_outcomes_without_inventing_legacy_evidence( + tmp_path: Path, +): + import hashlib + + from daem0nmcp.event_store import ( + canonical_json_bytes, + deterministic_id, + export_event_bundle, + import_event_bundle, + sha256_json, + ) + + storage, source, registry = _fixture(tmp_path, maximum_version=32) + workspace_id = registry.default.workspace_id + expected_signals = [] + with sqlite3.connect(source) as connection: + store = EventStore(connection) + with mock.patch.object(EventStore, "_project_provenance"): + for timestamp, worked in enumerate((True, False, None), start=20): + state = _state() + state.update(worked=worked, outcome="historical outcome") + appended = store.append_and_project( + EventCommand( + workspace_id=workspace_id, + stream_id=MEMORY_ID, + stream_kind="memory", + event_type="memory.outcome_recorded", + occurred_at_us=timestamp, + recorded_at_us=timestamp, + actor_type="user", + payload={"record": state}, + ) + ) + if worked is not None: + expected_signals.append( + ( + workspace_id, + appended.event_id, + MEMORY_ID, + float(worked), + 0.5, + timestamp, + ) + ) + run_id = "con_" + "a" * 64 + store.append_and_project( + EventCommand( + workspace_id=workspace_id, + stream_id="mem_" + "3" * 64, + stream_kind="memory", + event_type="memory.created", + occurred_at_us=25, + recorded_at_us=25, + actor_type="import", + actor_id="workspace-consolidation", + correlation_id=run_id, + payload={ + "record": _state("historical consolidation"), + "provenance": { + "consolidation_run_id": run_id, + "source_workspace_id": "ws_" + "b" * 24, + "source_record_id": "mem_" + "b" * 64, + "source_event_id": "evt_" + "c" * 64, + "source_state_hash": "d" * 64, + }, + }, + ) + ) + store.append_and_project( + EventCommand( + workspace_id=workspace_id, + stream_id="mem_" + "4" * 64, + stream_kind="memory", + event_type="memory.created", + occurred_at_us=26, + recorded_at_us=26, + actor_type="client", + payload={ + "record": _state("historical document"), + "semantic_namespace": "document-ingest-url", + "provenance": { + "url": "https://example.test/document", + "content_hash": "a" * 64, + }, + }, + ) + ) + connection.execute( + "CREATE TABLE memories (id INTEGER PRIMARY KEY,category TEXT," + "content TEXT,outcome TEXT,worked INTEGER)" + ) + connection.execute( + "INSERT INTO memories VALUES (7,'decision'," + "'imported historical decision','legacy success',1)" + ) + connection.row_factory = sqlite3.Row + retained = connection.execute("SELECT * FROM memories WHERE id=7").fetchone() + legacy = migration_v7_module._lossless_row(retained, "memories") + legacy_state = migration_v7_module._memory_state(retained) + legacy_run_id = "mig_" + "e" * 64 + legacy_record_id = deterministic_id( + "mem", "memory", workspace_id, "legacy", "memories", "7" + ) + connection.execute( + "INSERT INTO v7_migration_runs (migration_run_id,workspace_id," + "source_db_sha256,source_schema_version,source_format_version," + "target_format_version,status,snapshot_name,candidate_name," + "source_inventory_json,created_at_us,updated_at_us) " + "VALUES (?,?,?,15,6,7,'active','source.snapshot.db'," + "'legacy.candidate.db',?,30,30)", + ( + legacy_run_id, + workspace_id, + "e" * 64, + canonical_json_bytes( + {"tables": {"memories": 1, "facts": 0, "memory_relationships": 0}} + ).decode("utf-8"), + ), + ) + source_hash = sha256_json(legacy) + empty_hash = hashlib.sha256(b"").hexdigest() + for table in ("memories", "facts", "memory_relationships"): + imported = int(table == "memories") + connection.execute( + "INSERT INTO v7_migration_checkpoints VALUES (?,?,?,?,?,1,30)", + ( + legacy_run_id, + table, + "7" if imported else None, + imported, + sha256_json([empty_hash, source_hash]) if imported else empty_hash, + ), + ) + legacy_event = store.append_and_project( + EventCommand( + workspace_id=workspace_id, + stream_id=legacy_record_id, + stream_kind="memory", + event_type="legacy.memory_state_imported", + occurred_at_us=30, + recorded_at_us=30, + actor_type="migration", + correlation_id=legacy_run_id, + payload={"legacy": legacy, "record": legacy_state}, + ) + ) + connection.execute( + "INSERT INTO legacy_id_map VALUES (?,?,?,?,?,?,?,?)", + ( + legacy_run_id, + "memories", + "7", + workspace_id, + "memory", + legacy_record_id, + source_hash, + legacy_event.event_id, + ), + ) + authority = _authority_rows(source) + + MigrationV7Service(registry).apply(tmp_path) + active = resolve_active_database(storage) + assert _authority_rows(active.path) == authority + with sqlite3.connect(active.path) as connection: + signals = connection.execute( + "SELECT workspace_id,event_id,record_id,reward,weight,recorded_at_us " + "FROM memory_outcome_signals ORDER BY recorded_at_us,event_id" + ).fetchall() + assert signals == expected_signals + assert connection.execute( + "SELECT COUNT(*) FROM memory_provenance_edges" + ).fetchone() == (0,) + bundle = export_event_bundle(connection, workspace_id) + + with sqlite3.connect(":memory:") as replay: + replay.execute("PRAGMA foreign_keys=ON") + for version, _description, statements in MIGRATIONS: + if version in REQUIRED_V7_SCHEMA_VERSIONS: + for statement in statements: + replay.execute(statement) + import_event_bundle(replay, bundle, workspace_id) + assert ( + replay.execute( + "SELECT workspace_id,event_id,record_id,reward,weight,recorded_at_us " + "FROM memory_outcome_signals ORDER BY recorded_at_us,event_id" + ).fetchall() + == signals + ) + assert replay.execute( + "SELECT COUNT(*) FROM memory_provenance_edges" + ).fetchone() == (0,) + + +@pytest.mark.parametrize("payload", [{}, {"record": None}, {"record": []}]) +def test_upgrade_rejects_invalid_memory_record_without_publishing_schema( + tmp_path: Path, payload: dict[str, object] +): + from daem0nmcp.event_store import canonical_json_bytes, sha256_json + + storage, source, registry = _fixture(tmp_path, maximum_version=32) + workspace_id = registry.default.workspace_id + with closing(sqlite3.connect(source)) as connection: + previous_hash = connection.execute( + "SELECT event_hash FROM memory_events WHERE stream_id=?", (MEMORY_ID,) + ).fetchone()[0] + envelope = { + "workspace_id": workspace_id, + "stream_id": MEMORY_ID, + "stream_kind": "memory", + "stream_version": 2, + "event_type": "memory.created", + "event_schema_version": 1, + "occurred_at_us": 12, + "recorded_at_us": 12, + "actor_type": "user", + "actor_id": None, + "causation_event_id": None, + "correlation_id": None, + "payload_hash": sha256_json(payload), + "previous_event_hash": previous_hash, + } + event_hash = sha256_json(envelope) + columns = ( + "event_id", + *envelope, + "payload_json", + "event_hash", + ) + values = ( + "evt_" + event_hash, + *envelope.values(), + canonical_json_bytes(payload).decode("utf-8"), + event_hash, + ) + with closing(sqlite3.connect(source)) as connection: + connection.execute( + f"INSERT INTO memory_events ({','.join(columns)}) " + f"VALUES ({','.join('?' for _ in columns)})", + values, + ) + connection.commit() + source_bytes = source.read_bytes() + pointer_bytes = (storage / "active-db.json").read_bytes() + + with pytest.raises(MigrationV7Error) as raised: + MigrationV7Service(registry).apply(tmp_path) + + assert raised.value.code == "SCHEMA_UPGRADE_FAILED" + assert isinstance(raised.value.__cause__, ValueError) + assert source.read_bytes() == source_bytes + assert (storage / "active-db.json").read_bytes() == pointer_bytes + candidate = next((storage / "migrations").rglob("candidate.db.partial")) + assert max(_schema_versions(candidate)) == 32 + with closing(sqlite3.connect(candidate)) as connection: + assert ( + connection.execute( + "SELECT name FROM sqlite_master WHERE type='table' " + "AND name IN ('memory_provenance_edges','memory_outcome_signals')" + ).fetchall() + == [] + ) + + +@pytest.mark.parametrize("deleted", [False, True]) +def test_upgrade_restores_missing_learning_parents_from_complete_replay( + tmp_path: Path, deleted: bool +): + from daem0nmcp.verification_v7 import verify_v7 + + storage, source, registry, expected = _orphaned_learning_fixture( + tmp_path, deleted=deleted + ) + source_bytes = source.read_bytes() + authority = _authority_rows(source) + + MigrationV7Service(registry).apply(tmp_path) + + active = resolve_active_database(storage) + assert active.generation == 2 + assert source.read_bytes() == source_bytes + assert _authority_rows(active.path) == authority + with closing(sqlite3.connect(active.path)) as connection: + for table, rows in expected.items(): + assert ( + connection.execute(f"SELECT * FROM {table} ORDER BY 1,2,3").fetchall() + == rows + ) + assert connection.execute("PRAGMA foreign_key_check").fetchone() is None + report = verify_v7(storage, registry.default.workspace_id) + assert report["status"] == "verified" + assert report["checks"]["canonical_replay"]["ok"] + + +@pytest.mark.parametrize( + "corruption", + ["memory_hash", "memory_gap", "workspace", "sequence", "governance", "federation"], +) +def test_learning_parent_recovery_requires_complete_valid_authority( + tmp_path: Path, corruption: str +): + from daem0nmcp.event_store import GovernanceEventCommand, GovernanceEventStore + + storage, source, registry, _expected = _orphaned_learning_fixture(tmp_path) + workspace_id = registry.default.workspace_id + with closing(sqlite3.connect(source)) as connection: + if corruption == "federation": + connection.execute( + "INSERT INTO workspace_link_events VALUES (?,?,?,?,?,?,?,?,?,?,?)", + ( + "evt_" + "f" * 64, + workspace_id, + "ws_" + "c" * 24, + 1, + "workspace.linked", + "related", + "invalid hash", + 8, + 8, + None, + "f" * 64, + ), + ) + else: + if corruption == "governance": + rule_id = "rule_" + "a" * 64 + GovernanceEventStore(connection).append_and_project( + GovernanceEventCommand( + workspace_id=workspace_id, + stream_id=rule_id, + stream_kind="rule", + event_type="rule.created", + occurred_at_us=9, + recorded_at_us=9, + actor_type="user", + payload={ + "rule_id": rule_id, + "trigger": "parent recovery", + "must_do": [], + "must_not": [], + "ask_first": [], + "warnings": [], + "priority": 0, + "enabled": True, + "created_at_us": 9, + "updated_at_us": 9, + }, + ) + ) + trigger = "governance_events_no_update" + sql = "UPDATE governance_events SET payload_json='{}'" + parameters = () + elif corruption == "sequence": + trigger = "session_update_sequence_no_delete" + sql = ( + "DELETE FROM session_update_sequence WHERE event_id IN " + "(SELECT event_id FROM memory_events WHERE stream_id=?)" + ) + parameters = (MEMORY_ID,) + elif corruption == "memory_gap": + trigger = "memory_events_no_delete" + sql = "DELETE FROM memory_events WHERE stream_id=? AND stream_version=1" + parameters = (MEMORY_ID,) + else: + trigger = "memory_events_no_update" + assignment = ( + "payload_json='{}'" + if corruption == "memory_hash" + else "workspace_id='ws_" + "c" * 24 + "'" + ) + sql = ( + f"UPDATE memory_events SET {assignment} " + "WHERE stream_id=? AND stream_version=1" + ) + parameters = (MEMORY_ID,) + trigger_sql = connection.execute( + "SELECT sql FROM sqlite_master WHERE type='trigger' AND name=?", + (trigger,), + ).fetchone()[0] + connection.execute(f'DROP TRIGGER "{trigger}"') + connection.execute(sql, parameters) + connection.execute(trigger_sql) + connection.commit() + source_bytes = source.read_bytes() + pointer_bytes = (storage / "active-db.json").read_bytes() + + with pytest.raises(MigrationV7Error) as raised: + MigrationV7Service(registry).apply(tmp_path) + + assert raised.value.code == "SCHEMA_UPGRADE_AUTHORITY_INVALID" + assert source.read_bytes() == source_bytes + assert (storage / "active-db.json").read_bytes() == pointer_bytes + candidate = next((storage / "migrations").rglob("candidate.db.partial")) + assert max(_schema_versions(candidate)) == 32 + with closing(sqlite3.connect(candidate)) as connection: + assert ( + connection.execute( + "SELECT record_id FROM memory_records WHERE record_id=?", (MEMORY_ID,) + ).fetchone() + is None + ) + assert ( + connection.execute( + "SELECT name FROM sqlite_master WHERE type='table' " + "AND name IN ('memory_provenance_edges','memory_outcome_signals')" + ).fetchall() + == [] + ) + + +def test_learning_parent_recovery_interruption_rolls_back_and_retries(tmp_path: Path): + storage, source, registry, expected = _orphaned_learning_fixture( + tmp_path, deleted=True + ) + source_bytes = source.read_bytes() + pointer_bytes = (storage / "active-db.json").read_bytes() + + def interrupt(stage, _details): + if stage == "schema_upgrade_after_learning_parent_restore": + raise MigrationInterrupted(stage) + + with pytest.raises(MigrationInterrupted): + MigrationV7Service(registry, fault_injector=interrupt).apply(tmp_path) + + assert source.read_bytes() == source_bytes + assert (storage / "active-db.json").read_bytes() == pointer_bytes + candidate = next((storage / "migrations").rglob("candidate.db.partial")) + assert max(_schema_versions(candidate)) == 32 + with closing(sqlite3.connect(candidate)) as connection: + assert ( + connection.execute( + "SELECT record_id FROM memory_records WHERE record_id=?", (MEMORY_ID,) + ).fetchone() + is None + ) + assert ( + connection.execute( + "SELECT name FROM sqlite_master WHERE type='table' " + "AND name IN ('memory_provenance_edges','memory_outcome_signals')" + ).fetchall() + == [] + ) + + MigrationV7Service(registry).apply(tmp_path) + + assert source.read_bytes() == source_bytes + active = resolve_active_database(storage) + assert active.generation == 2 + with closing(sqlite3.connect(active.path)) as connection: + for table, rows in expected.items(): + assert ( + connection.execute(f"SELECT * FROM {table} ORDER BY 1,2,3").fetchall() + == rows + ) + + +def test_pointerless_schema_32_retains_canonical_v7_recognition(tmp_path: Path): + from daem0nmcp.storage_activation import has_canonical_v7_state + + storage, database, _registry = _fixture(tmp_path, maximum_version=32) + (storage / "active-db.json").unlink() + assert has_canonical_v7_state(database) + + def test_upgrade_from_earliest_supported_v7_schema(tmp_path: Path): earliest = min(REQUIRED_V7_SCHEMA_VERSIONS) storage, source, registry = _fixture( diff --git a/tests/test_workflow_tools.py b/tests/test_workflow_tools.py index 7ba0236..3ff8874 100644 --- a/tests/test_workflow_tools.py +++ b/tests/test_workflow_tools.py @@ -9,16 +9,17 @@ class TestV7ToolsRegistered: @pytest.fixture async def tool_names(self): """Get all registered tool names from the MCP server.""" + from daem0nmcp.api.v7.policy import V7_TOOL_LEVELS from daem0nmcp.server import mcp - tools = await mcp.list_tools() + tools = [await mcp.get_tool(name) for name in V7_TOOL_LEVELS] return {t.name for t in tools} async def test_exact_v7_tool_set_registered(self, tool_names): from daem0nmcp.api.v7.policy import V7_TOOL_LEVELS assert tool_names == set(V7_TOOL_LEVELS) - assert len(tool_names) == 75 + assert len(tool_names) == 77 async def test_retired_workflow_routers_are_absent(self, tool_names): retired = { diff --git a/uv.lock b/uv.lock index 6de1c86..f1a0d0e 100644 --- a/uv.lock +++ b/uv.lock @@ -709,6 +709,10 @@ graph = [ { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "python-igraph" }, ] +late-interaction = [ + { name = "fastembed", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] local = [ { name = "qdrant-client" }, { name = "rank-bm25" }, @@ -741,6 +745,7 @@ requires-dist = [ { name = "aiosqlite", specifier = ">=0.19.0" }, { name = "beautifulsoup4", marker = "extra == 'apps'", specifier = ">=4.12.0" }, { name = "e2b-code-interpreter", marker = "extra == 'agency-e2b'", specifier = "==2.10.0" }, + { name = "fastembed", marker = "python_full_version >= '3.11' and extra == 'late-interaction'", specifier = ">=0.8.1" }, { name = "fastmcp", specifier = "==3.4.7" }, { name = "fastmcp", extras = ["tasks"], marker = "extra == 'tasks'", specifier = "==3.4.7" }, { name = "greenlet", specifier = ">=3.0.0" }, @@ -752,6 +757,7 @@ requires-dist = [ { name = "llmlingua", marker = "python_full_version >= '3.11' and extra == 'models-local'", specifier = ">=0.2.2" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==2.3.1" }, { name = "networkx", marker = "extra == 'graph'", specifier = ">=3.0" }, + { name = "numpy", marker = "python_full_version >= '3.11' and extra == 'late-interaction'", specifier = ">=1.26" }, { name = "numpy", marker = "python_full_version >= '3.11' and extra == 'models-local'", specifier = ">=1.24.0" }, { name = "onnxruntime", marker = "python_full_version >= '3.11' and extra == 'models-local'", specifier = ">=1.24.1" }, { name = "opentelemetry-api", marker = "extra == 'observability'", specifier = ">=1.20.0" }, @@ -777,7 +783,7 @@ requires-dist = [ { name = "tree-sitter-language-pack", marker = "extra == 'apps'", specifier = ">=0.13.0" }, { name = "watchdog", marker = "extra == 'apps'", specifier = ">=3.0.0" }, ] -provides-extras = ["tasks", "local", "graph", "apps", "models-local", "models-hosted", "agency-e2b", "observability", "dev", "tracing"] +provides-extras = ["tasks", "local", "graph", "apps", "models-local", "late-interaction", "models-hosted", "agency-e2b", "observability", "dev", "tracing"] [[package]] name = "distlib" @@ -886,6 +892,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "fastembed" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "loguru" }, + { name = "mmh3" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, + { name = "onnxruntime" }, + { name = "pillow" }, + { name = "py-rust-stemmers" }, + { name = "requests" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/84/09d221078b6e2e2169fab52506dc2e0a6c7a9a4f0699000e9b9df55b42b2/fastembed-0.8.1.tar.gz", hash = "sha256:ad815f14873dce918f755a99f16929a1384775f91ca8a14645993a165b5f65ba", size = 82392, upload-time = "2026-09-22T20:01:24.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/76/209f9f4c421853f729176c5a6ca99a9cd92b3b593390a20f9414fd333ad7/fastembed-0.8.1-py3-none-any.whl", hash = "sha256:b4f4043080af36ee820d22d2d3034df635c1a8b9764e5d2a642bc7aeb1d0e749", size = 129755, upload-time = "2026-09-22T20:01:22.789Z" }, +] + [[package]] name = "fastmcp" version = "3.4.7" @@ -1763,6 +1790,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/3e/221fe46a3338f2babdb2082ee42df88fcaa8ea0e639e832cbb1b93c5923a/llmlingua-0.2.2-py3-none-any.whl", hash = "sha256:da55137efe0db78063b3395396efe8a0dcfe4ae5a09aea0d503c34b7bf1d800c", size = 30536, upload-time = "2024-04-09T08:21:55.428Z" }, ] +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -1944,6 +1984,173 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/ff/bda40387b5c5c64254595f4d81a12351770856acc5de4e6d43606a31f161/ml_dtypes-0.6.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f6cb525101b6b903779188c1e9e9490c343b455ab822883e02cf01e5547338d2", size = 572666, upload-time = "2026-08-13T14:14:38.993Z" }, ] +[[package]] +name = "mmh3" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/3c/eb1d82a87c504259dac5ce1c7de7587b68ffac841b55d23f8ea2c9df8422/mmh3-5.3.1.tar.gz", hash = "sha256:bd86d0c86b52332319d981d03781ff77811a29db544a69902dc06b5506bb3e19", size = 33354, upload-time = "2026-09-30T17:38:09.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/a6/fcd38b51cd78f1195c262605f2235c3c473180421fa2383e29edde5f0b65/mmh3-5.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a0df74f1d6e1ee35561487e6ecb1d05e37a5d424cc74a2addff7181a0b7a3460", size = 55354, upload-time = "2026-09-30T17:34:35.133Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e0/668a15dae1052062c8dac04a0b8a118876db12e0be813b7900456a0a1448/mmh3-5.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cbe3391ba8e58f06cdc93c2bcbffaf1bd339571b95a6f7ae5784c5cf6285a138", size = 39943, upload-time = "2026-09-30T17:34:36.462Z" }, + { url = "https://files.pythonhosted.org/packages/5c/19/b4282fe32d7e6852c3f79acf2a5075de7e2ac04efdc3f071a83e033a9dea/mmh3-5.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5640fc2f14406b7015aece7fba97868ae4017dc8c66c9d010ac725d77f0b7921", size = 39583, upload-time = "2026-09-30T17:34:37.823Z" }, + { url = "https://files.pythonhosted.org/packages/63/16/e4ad2cebff9d63d3b1ae93d9f570a76a2d603b86e764c051eb7d07ff778a/mmh3-5.3.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eb19747a88d045411121e64b9ad2f6ca1e255cd11df40725decf80ed4cd031c0", size = 95122, upload-time = "2026-09-30T17:34:39.135Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/4a85c58961a398f48d60d1d0b9758072d3a6af173febba1a11145c1017ff/mmh3-5.3.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e11db3320f138efc34d18b3797f6c66f41bc465c722fa27ea6952e488a82181", size = 100998, upload-time = "2026-09-30T17:34:40.376Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ae/3e35c36b2e4e5049ca982ad4f524d7c2d128d3e42db017fda8e3fb068abe/mmh3-5.3.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61faab401216923fdc943ec7e68f51c50100e9e30d9662ba64d91125bb0a08ea", size = 103792, upload-time = "2026-09-30T17:34:41.854Z" }, + { url = "https://files.pythonhosted.org/packages/89/75/b4d60ba44d3a0bc612e04ec02df14e46227d1d1fcdef284a11456d6cddaf/mmh3-5.3.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:64e9a4922f78352a7730900175557d8ad51fb45f6ea3308720734c9c2978a0fb", size = 110677, upload-time = "2026-09-30T17:34:43.035Z" }, + { url = "https://files.pythonhosted.org/packages/6c/06/2863a02fd23786185893498853903289eca202fe67500d716ff57a28fbc0/mmh3-5.3.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fc205e9f78224db7239d163b9b7cac35596aa615665135b0105beb8080e9ebc5", size = 118065, upload-time = "2026-09-30T17:34:44.545Z" }, + { url = "https://files.pythonhosted.org/packages/96/45/0c69f0652de49764120b7fca80ce9411755d1a70a47967c4fcfa71de2a5d/mmh3-5.3.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:292c1770e96fa583d6a4c5b38229ab52231794f8c2aafc279eda83449f306357", size = 101518, upload-time = "2026-09-30T17:34:45.856Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/51054cd93ebd8480328105c955d4001c07f67efd0aaa56852dbb1fec5119/mmh3-5.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:12f63cecc74757adefe0c15655cd607898c068cc4f13f1d6944fe644ab6d68e5", size = 101498, upload-time = "2026-09-30T17:34:47Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/4d6cb28d14c41db2c3aa511b4c70863287d56fe5504a4ef71a1c8e5e6e93/mmh3-5.3.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:57e06d35e8db0a8570c21d8f6681056aa84eb3bcb2bf4b886e4adb9d9be9e325", size = 96478, upload-time = "2026-09-30T17:34:48.159Z" }, + { url = "https://files.pythonhosted.org/packages/65/0d/008beb87bf1ef7af57f79e73ee50eb9a4bb7e1ed8ae75675672555b091d8/mmh3-5.3.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4c782a60a248eb0dcf776560d3226e1cf0681eaac2a118c0168822be932f3b9a", size = 110086, upload-time = "2026-09-30T17:34:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/4a/70/e45069746f6a5c5172af11639a6e080f51a2e0cd130e1c9cf48e3d4e78fe/mmh3-5.3.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:8477b3b4e9714f1acd15fdc8923f59482068a1c1ec8629867d8c500825c3d9c1", size = 99299, upload-time = "2026-09-30T17:34:50.791Z" }, + { url = "https://files.pythonhosted.org/packages/74/29/5aee09721348c42de73b624a04133b172dd344717803d6dda59e9e86d518/mmh3-5.3.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:20fac8667cd63ca88bf8b3e6fc97c5e1188872e7b51ccd936cc8a40411cad089", size = 111647, upload-time = "2026-09-30T17:34:51.957Z" }, + { url = "https://files.pythonhosted.org/packages/90/db/81b388f3bb58c69c5229e2159612de2793f4fec8ea86a207d6fea9a87dfc/mmh3-5.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c130eced1b89220777013fcd16b3a8f7d4ee6055877a5c9ef552b4694d1dce99", size = 99881, upload-time = "2026-09-30T17:34:53.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/b1/5a14d6c7fea4cceb6b0f2a9f184d9284fb40897c460af97a5ec6b8a33c3d/mmh3-5.3.1-cp310-cp310-win32.whl", hash = "sha256:68508d90c3c690cc3f603bbb0162f3749c30e470fdd55caaef130153f40e475e", size = 40318, upload-time = "2026-09-30T17:34:54.88Z" }, + { url = "https://files.pythonhosted.org/packages/26/f1/7864702203a83f931568125516ea1ed68b6dec0068d00b1e97cec24e4766/mmh3-5.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:c2d626c36cb3a91dcc244e235b9963df5b1be3274aae7d08f73de9126d2205ab", size = 41705, upload-time = "2026-09-30T17:34:56.155Z" }, + { url = "https://files.pythonhosted.org/packages/a9/42/d39170dc3ee7dcf0c1b1ef2bbfed07308f2954ecee78d3b5691971f192df/mmh3-5.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5375a10f50463bedbd858b8d876d3f56cdab3fd3fe6a0bfeb189d77e0b60ace9", size = 40089, upload-time = "2026-09-30T17:34:57.286Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2f/b6f34c372d68835ca89cb9d6f5c5166577472e13dae769d411496a106111/mmh3-5.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7e24c455cc6a4f30267a96c4f1fef85bfebffec5515ba6724e0ba88ac6baabaa", size = 55355, upload-time = "2026-09-30T17:34:58.391Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/dc370ebb3b7c056821f7093748dd23de096779cbec63f8825266c0f42aec/mmh3-5.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:86cee07b7e2767f2ea221f5a04a08e4dbd35363897231115a16365ca24641c57", size = 39950, upload-time = "2026-09-30T17:34:59.505Z" }, + { url = "https://files.pythonhosted.org/packages/10/e9/f4c14e0ab768c4e2ba21cb0b212eedeb645459f3b5a353561a11f6f4a045/mmh3-5.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b07fe9ce79bf9b53b1117c0d4c03744eb4060526d82b39e64972e739467a5134", size = 39583, upload-time = "2026-09-30T17:35:00.617Z" }, + { url = "https://files.pythonhosted.org/packages/a7/89/dd694aae910d97d33f2559737baa07d99f2f48903c0df8025d297f5f26dc/mmh3-5.3.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:594b2ca6cbb84323a2539ad1790af3c324e36e6a95e2a012f7145af101952ad5", size = 97033, upload-time = "2026-09-30T17:35:01.704Z" }, + { url = "https://files.pythonhosted.org/packages/56/a7/1345f0a2f3babd780d9f01fc934559938c05ef009459c5c8385112acf3e4/mmh3-5.3.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cee9fc91b4e9a8991fc1c7a67324a15a51b861f483d41a1d4eb58146b78ad53a", size = 102862, upload-time = "2026-09-30T17:35:02.983Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/bff20849193dcb3661f49008b8d131bbbd338b7b8ae9b35301440f5d8896/mmh3-5.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d503c1d7782719f79dc8523e889d4ab49bc478777a04ea2b2ac70eb3eb8ac616", size = 105875, upload-time = "2026-09-30T17:35:04.214Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9d/c2c0674b047bf215ca1b2f5028290f03341af1f03913953548ea99deac8c/mmh3-5.3.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7927f2849b3800a245047ed6d95abbf23054737b563eb95dbc66736a98461a30", size = 112749, upload-time = "2026-09-30T17:35:05.639Z" }, + { url = "https://files.pythonhosted.org/packages/40/0f/cfd248294ee7def93e85a217485308504418507223741ac15f57f1b0a1c1/mmh3-5.3.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b5214d98820ceca0269de89fb827c7693c4f3a886629001a4c0389b1afdb19e", size = 120347, upload-time = "2026-09-30T17:35:06.876Z" }, + { url = "https://files.pythonhosted.org/packages/29/65/a308ee33ed5d815bcf71b990700167c3b6d2c16ed2e27b2d445db1245b0e/mmh3-5.3.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e95534bade32a4296ac31b628c62190284fe2ea1bd172f4e644e1d619f18846", size = 103100, upload-time = "2026-09-30T17:35:08.125Z" }, + { url = "https://files.pythonhosted.org/packages/76/5e/4150e3c33be634f85eb432817e3e31128972f9f9e412d9ced53ba786d2d9/mmh3-5.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aeaa11d54483e54d9bcc745f683545c08e5fc9bfd53c33b8cb3f617b935b1dc0", size = 98880, upload-time = "2026-09-30T17:35:09.441Z" }, + { url = "https://files.pythonhosted.org/packages/64/5c/59d6d7dd1df7e0a360792cdb5d20eae04120e1a2ab102ee4ebe21ea8e1df/mmh3-5.3.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6aab19861fe9ced1fba8cb15a08a37bc196b8df29a77abf9ebca67cab058e18a", size = 98291, upload-time = "2026-09-30T17:35:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/99/54/dcbd456a0e91d97ecc0dc1419585ceb253cc38e11ad209d11e2f061b820e/mmh3-5.3.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:40d07e293886613395aad9cee111afdb4ffb318b2f3d99bfb495aaf5f448887f", size = 106050, upload-time = "2026-09-30T17:35:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/96/8eb7e23698924956895dcd5f5f4bc1ba5d3b8ea8bf94e0a94a50eb484a73/mmh3-5.3.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:d41ccf36d7de86b3c5944eadad2477919de0d30f92072fe0f494608440123da6", size = 100971, upload-time = "2026-09-30T17:35:13.272Z" }, + { url = "https://files.pythonhosted.org/packages/cc/64/7c9d0e77abb9a96e57c9d0de2e482cd41b95a29262c4b23d6a94ef0ea129/mmh3-5.3.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8c22ee90aac1c78cbd0fcfe6421c8db3d37d3281e6fcba4e4bbbaea374a35706", size = 109548, upload-time = "2026-09-30T17:35:14.526Z" }, + { url = "https://files.pythonhosted.org/packages/ab/7f/d6b458d65d70dc156a2f071ad9714138f1bb5cb7ec599392f1e1a36ebbfd/mmh3-5.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63655f88877717661f10662db70d017a421ca31c559e7c25d9dde58cf03a09f1", size = 97023, upload-time = "2026-09-30T17:35:16.046Z" }, + { url = "https://files.pythonhosted.org/packages/ee/30/d9615d459a8574b6d94acf7a391daa79d133054d424e30a7e3cb27cf2eff/mmh3-5.3.1-cp311-cp311-win32.whl", hash = "sha256:992c6539eaba38d940a1afcb5099186a041235338b99feafec6b631fd2ac4370", size = 40312, upload-time = "2026-09-30T17:35:17.286Z" }, + { url = "https://files.pythonhosted.org/packages/c2/30/467a1d50dc29a98556e1ae33d939f6ba85bc67a67da0c5012320ed1279f1/mmh3-5.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:dbb93d9ce4ce756952329aa4c583c54140f95dabe226f2b6937b17cb1ab4d817", size = 41702, upload-time = "2026-09-30T17:35:18.403Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4c/b1e26ddfdf84d46a37ff5895eb8f06fc3fc568835ffed266fc139a9757f5/mmh3-5.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:ce84a0f9f076f516016b92a2b9b60517076ccd8af056bc7536ac2a6fdc381efc", size = 40088, upload-time = "2026-09-30T17:35:20.113Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2a/01734f735587e44b110fa7c44d3fa2fcd59db1cec2aea5ce0eb3002ebde6/mmh3-5.3.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6ca2e4296573e67fbf4e4a52af029e6f8f7c947ec275fcc584f07d46d5149a13", size = 55355, upload-time = "2026-09-30T17:35:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d9/4087514f8edc559d9f4a5e1cee258c18cab40e13a1ad4abba5f08c17a184/mmh3-5.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d0a3b185866b964b5c8c60cd644cabf6bd01509a38a29ba74c5bd34088e12b89", size = 39960, upload-time = "2026-09-30T17:35:22.735Z" }, + { url = "https://files.pythonhosted.org/packages/c2/85/31af9d6b280f04164eb493c0b2e716f8a8d681b0d2e0b6e5a5bbfd3fccc5/mmh3-5.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd928feed4a6f28ea8d2b48c1a41eb5a35fb62cfd1e0f06c3335378cc59b6c4d", size = 39563, upload-time = "2026-09-30T17:35:24.037Z" }, + { url = "https://files.pythonhosted.org/packages/74/8b/bb4f0da4a0f8a117e01cb9ef90039b754ab25eaf591a964155c1d2fae133/mmh3-5.3.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:34744ba81a0111010e72639c5f677ca89393ba7950540596e856dd1ed8b2a5d9", size = 97087, upload-time = "2026-09-30T17:35:25.15Z" }, + { url = "https://files.pythonhosted.org/packages/d5/20/f2f5877cb22ee3c26c52f4be8737c7cb94e23e6a5e47bf05654b189ed0af/mmh3-5.3.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b037280edc7a609a987fa7661a1132a3f6d721f46b299ed5f9f641b35ab415a", size = 103108, upload-time = "2026-09-30T17:35:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c3/00480ddfd4e00213089c4a50801b685e344086948d8d0075e6533dd81979/mmh3-5.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a48db69e7e7d40d18c24519a11b7d7d21a8b6b4af60fd2194d9ee514fa4354c", size = 106002, upload-time = "2026-09-30T17:35:27.705Z" }, + { url = "https://files.pythonhosted.org/packages/9a/10/f84fe70878ad89e059066a9977ff9f36116eac58f2480dd1046e4daef638/mmh3-5.3.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:334f2d7273bfc2ffd85f9b1a75d39d59da3158da94ca42a4e273120fcfc25edf", size = 112838, upload-time = "2026-09-30T17:35:29.006Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f5/a37d77d505a4a1598dae775b965c226de014af5c389f5c2bfa505bb8e159/mmh3-5.3.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b78173b6b9bc69a8a455c63b380d892add205efe531e8206ef71d600324048bb", size = 120486, upload-time = "2026-09-30T17:35:30.227Z" }, + { url = "https://files.pythonhosted.org/packages/12/c0/93581e98cd76df75962fbf8f2be9a9dc6d4e1c63dc6dc2b85598bb1f513e/mmh3-5.3.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b1e950308111308f54c12d12a223bbc2882b75b59892858463a16508b375fc56", size = 103111, upload-time = "2026-09-30T17:35:31.704Z" }, + { url = "https://files.pythonhosted.org/packages/f5/17/3480de8e4bb66f7019bb02fc7454e28d721a31e0dfce6b2dc6f72973e871/mmh3-5.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:010dcd7406c2f77b978beaadfeb7a01d4f7868ce862f6273b1c37bd902267394", size = 98890, upload-time = "2026-09-30T17:35:32.936Z" }, + { url = "https://files.pythonhosted.org/packages/d0/6b/d5a0287c85ef2284a88c0150d6d3262f034390cee82854844f8bede1ad90/mmh3-5.3.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4b87fe04af53cc9c2492f90a6d0287f52c94fef517df3de75c18346e0e119682", size = 98368, upload-time = "2026-09-30T17:35:34.502Z" }, + { url = "https://files.pythonhosted.org/packages/17/95/19efb8536b7cde6cd46abdc3d1c38354233b14288549b56434589e9b3fca/mmh3-5.3.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a847c3d57c64a76af48ed4f4e9abe8d3d966c577de1e39258595e5b38ddf6eb3", size = 106240, upload-time = "2026-09-30T17:35:35.763Z" }, + { url = "https://files.pythonhosted.org/packages/c6/03/9a715610de3f9350de45b2933221947460e3c421909801f65b0e96bf14ae/mmh3-5.3.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:814a69f39a3a3106eee1b870acb5ff09c436334a5a962df380c22988528cebd5", size = 100977, upload-time = "2026-09-30T17:35:37.108Z" }, + { url = "https://files.pythonhosted.org/packages/d9/6a/0f889bfbc7abcde5ec08eaff2381c093f7d00db63ca3071ab705bfb372c2/mmh3-5.3.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d6c9a5cc1c19257b135874fe67b7ffcefad1eb7babd09ba9a2a4d9fb576e1a6a", size = 109754, upload-time = "2026-09-30T17:35:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/37/7b/e3b441543a0e86f8635b6007ef8b7101442a7c90fae345a43538cd85c36c/mmh3-5.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8ffd80966acfaf4f786699ce37b75121c8252bb65636c0ba2f0cd9c6bb276de", size = 97303, upload-time = "2026-09-30T17:35:39.679Z" }, + { url = "https://files.pythonhosted.org/packages/06/03/bbb91c0c094e7131fb5f622ff5a079a25c125b92c7ece2ac8b3e38e1992d/mmh3-5.3.1-cp312-cp312-win32.whl", hash = "sha256:d3a3b8afadf1196566750aed853dd91e358447b8c1f39ce8625aabf590e3e686", size = 40340, upload-time = "2026-09-30T17:35:40.91Z" }, + { url = "https://files.pythonhosted.org/packages/8a/94/41c97ce26200a1a9242d159c2c5499844ec4a688fd4e69df044044f9e012/mmh3-5.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:b69e9f1d9c960236106f22bad1b3bf0a1110971decb0c1554d591c699e39a970", size = 41735, upload-time = "2026-09-30T17:35:41.972Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d8/5b173bb7b4682dd9e707a523ce24a91234f25deb02d2790f02a1f5ddc2d6/mmh3-5.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:cd7e7e54d8f90076059a15c3751e16b46211398af76a48db9e82143375f3a86c", size = 40098, upload-time = "2026-09-30T17:35:43.069Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4c/c6faef1d29aa00a1f71d3a109547c86b029835b55ad19482dc625c98011c/mmh3-5.3.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4b2b6d135aafc93a666056ae87cf11dce93e11a3ee9b938d46076d93074699bf", size = 40911, upload-time = "2026-09-30T17:35:44.698Z" }, + { url = "https://files.pythonhosted.org/packages/7b/23/a35e5090c3685c3bd22f07586c4efa428710ad1404d6cf3fd47ad654e711/mmh3-5.3.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:98c6373ec81d4e74305d8d13d5de3aacf0e53e78dcb4a43dd74f6f3ff8452967", size = 42024, upload-time = "2026-09-30T17:35:45.814Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/350d214e1a37e5c2c92182750c06c671348346d52eb455bceaa861801349/mmh3-5.3.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:bf65874ed7c948281719632b6960f4eb572aa33e1a093a6a1d31bf064b0e540d", size = 38668, upload-time = "2026-09-30T17:35:46.993Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/0a3d4e54549fd8edd6fa54cef0529dea7316666066f3bfa23c810d0c2b5e/mmh3-5.3.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d1f3f35b97adfcf4545a4def9e0fb17e61eed8a06c29137a02829a67232e1588", size = 39344, upload-time = "2026-09-30T17:35:48.097Z" }, + { url = "https://files.pythonhosted.org/packages/80/b8/e96e8da1b8d52f62c15a8acb33cfd180778c18d71ed63e30a2085e35cf9c/mmh3-5.3.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:39bbc0665b064e63a0e64e9efab9a97a1f0535b0e1ffd8e43e23aef82e41ca21", size = 39852, upload-time = "2026-09-30T17:35:49.227Z" }, + { url = "https://files.pythonhosted.org/packages/3e/28/c657ba46881ba84b2e1d260545c141b0794bb579c981fd71a0f7e5c15a73/mmh3-5.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5cc32468caf0071882c3682b9ab04f45d756231059b4e36cccc94eb972f8c192", size = 55334, upload-time = "2026-09-30T17:35:50.704Z" }, + { url = "https://files.pythonhosted.org/packages/12/5b/cbff42a3248d0869a940eebef0eefe7feb948f6eeed2f242f0098e2892a0/mmh3-5.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8cb9941e2613b22ed4901faa29338c134194b2dec501023e6433b7e62161e329", size = 39946, upload-time = "2026-09-30T17:35:51.944Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0a/67d5082ad1fe184c4c928ca6be61d775947590863d52de0a9d9aa7d525b2/mmh3-5.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c25a6d4b6ff31d801ff6f1ad5ce003271bceabf21c3e9ffcf04a47774354e956", size = 39561, upload-time = "2026-09-30T17:35:53.331Z" }, + { url = "https://files.pythonhosted.org/packages/de/2c/948789af3824e81621f01183c1a2a017229bd7884e243641628682ea9ea5/mmh3-5.3.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9ba38fef5eeed0668a27f8b5a002a5e30c789dd11fa058495b307f76226a4662", size = 97120, upload-time = "2026-09-30T17:35:54.528Z" }, + { url = "https://files.pythonhosted.org/packages/7f/46/88420e1561f1a5cda72e23581b9cbbde336cc4a4a8deb1259559bf57b8d2/mmh3-5.3.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:931d9d86c66f306e91414e95509af05e5c79bfcbac78218c2ee55c9734000053", size = 103129, upload-time = "2026-09-30T17:35:55.78Z" }, + { url = "https://files.pythonhosted.org/packages/bb/97/064d5c9eed7afe9b2087c164ab4b11a9d4cd0cb1d8d826804df72d7e0e17/mmh3-5.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae367d0cf6cb40f3ec60ebdb572022f3cc875bcf4c661d345f3dbf24571e7aa3", size = 106044, upload-time = "2026-09-30T17:35:57.277Z" }, + { url = "https://files.pythonhosted.org/packages/39/b4/c4e968be21d55aead9ef78b6ac6fc0e4a455cfbbaff9ef62bcdaee40b26a/mmh3-5.3.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:59dab80b8124998406c168ddc9d6cbcede1c117dd0aed3db80e16e43ad71ef82", size = 112890, upload-time = "2026-09-30T17:35:58.596Z" }, + { url = "https://files.pythonhosted.org/packages/17/e9/b3f3da18b38bd08d39eb24c142ba3e9217b8975d3ca8468143dd3c63aafc/mmh3-5.3.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803ba415427118e00ffccefbacc41b03df8ac60403cd9cf2dfd461ef56072002", size = 120520, upload-time = "2026-09-30T17:35:59.924Z" }, + { url = "https://files.pythonhosted.org/packages/fe/03/c7dc6eb152425dd2fba09b299a186be37bd53910a28531ab12f475d9bf99/mmh3-5.3.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d856a44ef94204820338b0e3312c02a6a4df8040ad06102c050d5005dbc601c", size = 103089, upload-time = "2026-09-30T17:36:01.266Z" }, + { url = "https://files.pythonhosted.org/packages/25/c5/1192cf2db35390b0ca1f54eae2699c62235fce57992eda605e675af06b9f/mmh3-5.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bf1fa41b7587477c86ffe4e69b854ef688e243f9feb97eebc08666031bde71e0", size = 98905, upload-time = "2026-09-30T17:36:02.545Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/9af0d1f08e3e03cd8b52e5d53fd3be74345993c0d6a7cf61b02e454c4daa/mmh3-5.3.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:86c1593ebec4bd8a7b1e0f28fce5f220e5bc0b2d5f9ba48c34224c04d9f63b8f", size = 98457, upload-time = "2026-09-30T17:36:04.006Z" }, + { url = "https://files.pythonhosted.org/packages/b1/66/ab879d60e7f2cd69e69a7f46613108d9d904c29acaaa1adb345a3a479fcd/mmh3-5.3.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:427f2ba51baf54ce25f32beb6edd2db70bdc95ac9746067eea0a2b2ca484fd10", size = 106276, upload-time = "2026-09-30T17:36:05.366Z" }, + { url = "https://files.pythonhosted.org/packages/cc/58/cd805eabd1fc01ad36861d3cbf4eb25df822bb0e72c8ee8b3ffd47c71225/mmh3-5.3.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:01159489255615d4be76a9ebb07cb0c9b0345f544197aa64ab18a7cfa5579a28", size = 100971, upload-time = "2026-09-30T17:36:06.74Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d0/20d98b665deca070ce5e19df678d476ddab9652462f1e5bf636fc82265a2/mmh3-5.3.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24627cb76ff1e7870a07d7520cf5f3099b1767390236e46d38656dbea5cc6ad0", size = 109795, upload-time = "2026-09-30T17:36:08.14Z" }, + { url = "https://files.pythonhosted.org/packages/09/51/be441d264a38c390582b3b3382f629e66e10847cbcaa60f560d517237b1b/mmh3-5.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d83f27143c8ae4e78781306ce002ee466219d334346609d0d7f675c8664aef4", size = 97326, upload-time = "2026-09-30T17:36:09.548Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c8/240446abf409338e93d9c8c2e31b47133a08306cbd4346abb0064736f20c/mmh3-5.3.1-cp313-cp313-win32.whl", hash = "sha256:4836a024fe923605d85049f887aacca98add969c8d4932aed5d0d3884cdaa682", size = 40331, upload-time = "2026-09-30T17:36:10.898Z" }, + { url = "https://files.pythonhosted.org/packages/06/5b/b63154d3d8d3dab42a6713df71da40c2952c4e640973a70ebd897df5508c/mmh3-5.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:6759c43a90729a963ab5503779e07cd000c372ebd2d80196f78da2bf2d4101f1", size = 41754, upload-time = "2026-09-30T17:36:12.084Z" }, + { url = "https://files.pythonhosted.org/packages/be/67/b03f7b39d5f22cbfe72e6d73374c820829a69813a7489ba2a4a8d252391b/mmh3-5.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:78219f6b1cf27872295dd4548e862f317b48ef1e1b2c9e0143069ac3a8b822d7", size = 40103, upload-time = "2026-09-30T17:36:13.425Z" }, + { url = "https://files.pythonhosted.org/packages/32/b6/815e83303e366cc831e81820d92304d5e84068a6c5cd96d65b6fadf91c7b/mmh3-5.3.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:cabd413b4d6017b5117112a1036e3f980aa9a75ab345e48b4d2eb73b1bfad99c", size = 40908, upload-time = "2026-09-30T17:36:14.591Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fc/10a374e021d7a531e668535a3defaea9ad3f92fe6b16c747fe560389f5cf/mmh3-5.3.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:2ca9402d9dc406f62094c271602629a6ab8b853d8052b89e8ee991dd122ce36c", size = 42038, upload-time = "2026-09-30T17:36:15.753Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/8427a3deeb561a32ab3c71e2fc91569d1ca4569d14dcc71605fe04c29b03/mmh3-5.3.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2cb13fc23e8c3a3a2f59327d4456b7edb9a3d3f5b6936ca3aab3ace9e3675f50", size = 38674, upload-time = "2026-09-30T17:36:16.933Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6a/ca4ddc0cd6dd07030211c39f1863a164b357adf740d6a8713bbaff3189c9/mmh3-5.3.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:92292c047b82624ef972e10e54a1cec0c7651cbe86be7d346353456e682a58f4", size = 39361, upload-time = "2026-09-30T17:36:18.125Z" }, + { url = "https://files.pythonhosted.org/packages/0e/36/e4c1ef8bc22ccbad4967e8a637fe12dbcb740616a023bf50a3e551963d9c/mmh3-5.3.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8f4626de7b5bcf922eb66f1d02eb4f62f6dc99e4f2b1519dccfd9e89fcf8ba8f", size = 39866, upload-time = "2026-09-30T17:36:19.404Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/880d51fa8368727ad428368b062a28dd735ac913392eb7d5760a7c0c8915/mmh3-5.3.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:71fb7fd092f2b4e3af00579a715dbe3c25bd8ee295acc56c9c60003a7c8cfd98", size = 55364, upload-time = "2026-09-30T17:36:20.723Z" }, + { url = "https://files.pythonhosted.org/packages/87/33/f1902c23f6a25c198d18045d6aee389b9442c68134d2b05ec26345b6f33d/mmh3-5.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1033b4943bdf401f9c402ed517fb64ece6307bc22cff2a83bc7e5b85f731eed5", size = 40023, upload-time = "2026-09-30T17:36:22.026Z" }, + { url = "https://files.pythonhosted.org/packages/24/30/c279c95e3dadbec8c389306cf08468bb68d43cdd7e26fe6107bcd00f157f/mmh3-5.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:741d1201ccc7716ec61140096dca084590da2eaf6c62b09b64813b6234ac58af", size = 39570, upload-time = "2026-09-30T17:36:23.222Z" }, + { url = "https://files.pythonhosted.org/packages/72/3b/c748fb11c98b3c3fa48249d8d36e7dbeb57669b3ce767f51c94d43a6cde9/mmh3-5.3.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb6a472fb487e37556344fd2a4894eb5eb1896cb2eb03536e8f56dce2c5423f1", size = 97115, upload-time = "2026-09-30T17:36:24.487Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/65eb32da2c7a7e03bf91f1c3ca6e6e2c8004c64515828a3fd5b6ce51669d/mmh3-5.3.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fd3bbb5dc3c3a045605c1a618bfccb2de6d65ae89b99ff9a7f54556cf81175b7", size = 103119, upload-time = "2026-09-30T17:36:25.807Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ca/eb318655aa79059ab7af2b658adba17eda27d0be0f49703e7b0e6086b72d/mmh3-5.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66297ad16d75ffa14335ca23a40c23deed09fa0d832341fda29ad60a8a2a91ab", size = 106064, upload-time = "2026-09-30T17:36:27.162Z" }, + { url = "https://files.pythonhosted.org/packages/59/ee/491e1fec15a83fbf152e482e7f9c91c738bced77457f0f64e6f5792206e8/mmh3-5.3.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7418304490de50c985a95416b62f1acac616d9bc9b259622072b487093f926d", size = 112935, upload-time = "2026-09-30T17:36:28.555Z" }, + { url = "https://files.pythonhosted.org/packages/5d/44/24f2bb97fc731ae4bf171588a9c2732a76309f4fb4366fa62ff3b1943262/mmh3-5.3.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aeb4ac9626c89c9d093930abecd3cea40e6eb7f21fc60870b8c3495748158624", size = 120504, upload-time = "2026-09-30T17:36:29.872Z" }, + { url = "https://files.pythonhosted.org/packages/a8/43/e64fb48dbb9bfd5295d87b4b9a0b95a41139b6b623d7535ef864af98d75a/mmh3-5.3.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5e7eee7a8174ac340530bfd3500086972452a86adc8eec7d879501bcbef7b2b7", size = 103219, upload-time = "2026-09-30T17:36:31.317Z" }, + { url = "https://files.pythonhosted.org/packages/c3/08/261419201b69dede3368b1e4ff92183f603e32c565e1124d59de73c14043/mmh3-5.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ee868a903f387a192bb6fe2205d709156ab7dc5b2dda6fe75d2cfbba9b6a0e26", size = 99012, upload-time = "2026-09-30T17:36:32.693Z" }, + { url = "https://files.pythonhosted.org/packages/34/c6/6ef12522aa92d722c8f756719c870667a3d3aacb1563008e4f7331ee3de5/mmh3-5.3.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a212a14648107bb83c55ece4a85bfe0671fbba81059e20d48e25456eeea1fcb0", size = 98475, upload-time = "2026-09-30T17:36:34.14Z" }, + { url = "https://files.pythonhosted.org/packages/55/f4/7a89df34615cefcf705d66f54ba26f00425fbae5ed4f61f1ba86b2372648/mmh3-5.3.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:de8fb9ee364d7ad62cf6e6807b77937e1ec8e69396a46aa36b322aa772e774ed", size = 106299, upload-time = "2026-09-30T17:36:35.497Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/4cd526b1d7c424485d770b7ffc2976452087f68e8639421e27da93dd5baa/mmh3-5.3.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d25d161d01b428cd4a12a4489e62595989f826ca780197844f806cc984dacde5", size = 101112, upload-time = "2026-09-30T17:36:36.989Z" }, + { url = "https://files.pythonhosted.org/packages/23/f0/a245308671c6ce430e1347b6ea100def44599d808f1d841dd24f399ec5e0/mmh3-5.3.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:1769d9a4f4a54a383ff65ac79bd85b76f17b2f1f9a71bc57b46df9a556970ec8", size = 109779, upload-time = "2026-09-30T17:36:38.385Z" }, + { url = "https://files.pythonhosted.org/packages/48/24/869fbd94037a046ae7a286eacaf4108a48ce497dd087b0c5b4f980774773/mmh3-5.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5e06d41da4c6b2fad157db2d524e1134f0077d7bfaa645c114b7968433ea1b5b", size = 97327, upload-time = "2026-09-30T17:36:39.83Z" }, + { url = "https://files.pythonhosted.org/packages/c9/01/8c629d29655dd670819b236fafc484ec4f9b7711333fbd35c983edfa3941/mmh3-5.3.1-cp314-cp314-win32.whl", hash = "sha256:f86a308bd396fa69013c360abf98111e9d0fa534a7d75b9613ca4145fa63bff4", size = 40873, upload-time = "2026-09-30T17:36:41.194Z" }, + { url = "https://files.pythonhosted.org/packages/11/44/cf8e89595f64a2666db42b8bfa02e98c53244b7741cf6cd6b742901c0c73/mmh3-5.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:156152ea77713eecffed175e4e384aa47bf24fedcf1a9f30773dd2737a8b8c76", size = 42299, upload-time = "2026-09-30T17:36:42.623Z" }, + { url = "https://files.pythonhosted.org/packages/fb/68/3b001dacbf9f6a5e3a4837fa870efff34504ca21b9aae0df78bf3ebe7957/mmh3-5.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:41082b86c3f24f41e1e8af97c80724ff7c23ebc33dff7e37d7e3b1caa4eaf183", size = 40652, upload-time = "2026-09-30T17:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1c/8d/b9778e43fd4332124ef07c194a84cae8bf9e424e42400b0919fb4974c62d/mmh3-5.3.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7cd757dbf0f177555c1544c37aec5b1230cf5f4d1b79e900357684437608c1d4", size = 57052, upload-time = "2026-09-30T17:36:45.884Z" }, + { url = "https://files.pythonhosted.org/packages/66/b8/5cdbf15f818dae35ed95e09f3a182d24f35acdda441c1140bfcbe61b4ac3/mmh3-5.3.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:be5dab268537f7db00cca56a07b1301060351fd8ab41d6b3fc5cddd3bd207448", size = 40826, upload-time = "2026-09-30T17:36:47.496Z" }, + { url = "https://files.pythonhosted.org/packages/65/37/93b48f89a8173d8a0aa48567e5fff51569c9620854766bfa782214351d3a/mmh3-5.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:588c41c36378be5b62da340279300ad6e93d819edaeca0a89e38f1fc8a5b683d", size = 40344, upload-time = "2026-09-30T17:36:48.796Z" }, + { url = "https://files.pythonhosted.org/packages/c3/13/ee07da9f987e61a5cf95b3bfe49f59d9b95ee0e086243527248aa9d54912/mmh3-5.3.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4274ed59160e51c970553b6e3d28fdcf8ddb833c924cd305619ab06dda265c19", size = 109471, upload-time = "2026-09-30T17:36:50.42Z" }, + { url = "https://files.pythonhosted.org/packages/43/fd/58ad751dc961f38af8ea16594b8cfb2afeaaaa9fa2fe814bfeec9689d33c/mmh3-5.3.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c5f055fec5901cfbd6951d2ff52352bfdb08e0f9acafae1b235aa835f4d80f9", size = 117238, upload-time = "2026-09-30T17:36:51.836Z" }, + { url = "https://files.pythonhosted.org/packages/76/c3/e43c6abc3ea4e91da55391a2b59c95834f61972e1b6a3e003a2f1a2b08fd/mmh3-5.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7670edd6751f21d7bd038b45f4eb671fc8d6441b0088ee01d25f5a23424a87c2", size = 120236, upload-time = "2026-09-30T17:36:53.269Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f2/397bc3d1e934443c0b621392fa56fdeaee1974e167e1411b887fbc223316/mmh3-5.3.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7a029b0d8a273dd746d7cabc13134fb6f08d866ed83d7619a7aa4fad9ae9946", size = 125811, upload-time = "2026-09-30T17:36:54.696Z" }, + { url = "https://files.pythonhosted.org/packages/1d/43/d7e4f9ed76d20114ad97d6d7b11d2c99a148208279cfeeab34408b68e936/mmh3-5.3.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b890e9fe7330f104dbda1b6a8dcd0a28e948db08eb9faf3001bc55d164402ff8", size = 135028, upload-time = "2026-09-30T17:36:56.42Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3b/6184b0de23d841e1cb1f36f7f93c8157065c70ec4072b18d5d84d6cccb90/mmh3-5.3.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23ab950642fd0c29a7187a9073e70a203135282b91fd257153de8a8efc87fdbe", size = 113452, upload-time = "2026-09-30T17:36:57.947Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/3ff78b891c5b1251ddeab97b4c6e97fdda7a16e160892dace74578b26a55/mmh3-5.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:325e990de3fb60b0ee460be96096ec7cb0775d16f4a2ad20b613111d3cc608f9", size = 109896, upload-time = "2026-09-30T17:36:59.66Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c0/664278780e7ee5758717674f55a6d7447a3e0b862fe57678422431155729/mmh3-5.3.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:1936c40c171979cf230ccf9c9acad51dbd2b01de233a4829e307e4c71331f765", size = 110985, upload-time = "2026-09-30T17:37:00.924Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/127ce3d2815ab311ef03fed126bd46533f79cef4639636b78b2d6682c99b/mmh3-5.3.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:196a15b6dbe96ed77e03887ea338e0df0278576143a9e66404074b6d9b10eb2c", size = 116708, upload-time = "2026-09-30T17:37:02.36Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ac/962e120a6642f6eca9686b101b07e889466b13caef34524720d511448ac6/mmh3-5.3.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:21e68810f51f6e9b96da10073b8efdafefbd9f71ef644a9541372b8de0c02137", size = 112004, upload-time = "2026-09-30T17:37:03.737Z" }, + { url = "https://files.pythonhosted.org/packages/5c/54/82829ed9ab1272416bc87b923d51cd364de1fa05e83a81278030599efd97/mmh3-5.3.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:1c61932e7c9f9e6fad2b6cfa325d600792546028b2efa05a66e0c0a8ca5be1b6", size = 122863, upload-time = "2026-09-30T17:37:05.112Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/55889a172c4b2aeab7220955a9765a5cf8ef81b183627c0a85a952689d5e/mmh3-5.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:de2074dbcc822f26f97aa83c6fba6ff54998e68b834f45a2a35775cb27b48cea", size = 110027, upload-time = "2026-09-30T17:37:06.409Z" }, + { url = "https://files.pythonhosted.org/packages/35/83/558d9c034a568f0edeee91b4f770d99eeee41300155e2be0eefef2466fcb/mmh3-5.3.1-cp314-cp314t-win32.whl", hash = "sha256:0fe225c870d34d08a0cebdddcb1c1062ecfb9655f1b844a76817ea17bcbc8316", size = 41326, upload-time = "2026-09-30T17:37:07.607Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ed/e76d25bcc95b3b8ef8df663c99542056c9cd393bd70a6723e5d4f2e13b1d/mmh3-5.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:52fbda3c48e74f7c533964d91610e512e1971d1e88a264e1024f710b3a990af8", size = 43270, upload-time = "2026-09-30T17:37:08.827Z" }, + { url = "https://files.pythonhosted.org/packages/73/29/efd48025b974c588a5da623abc5916d3caba073fa7f658826c42dd673435/mmh3-5.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d6d03f2e97225476a4ebacb7b26bdf58847f2879ed6f51c9aec296dd17a40537", size = 41270, upload-time = "2026-09-30T17:37:10.545Z" }, + { url = "https://files.pythonhosted.org/packages/74/44/7346139e65dcd6c0fb1fd810095311f3c83bfad406875db80b905dfe2795/mmh3-5.3.1-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:8e9be3c05553ff265064c364e2f75c38a0a768c5e77378e68bba3cbdddbb3534", size = 40908, upload-time = "2026-09-30T17:37:11.848Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/f310775faa92f81a8e4d33a57f8871df049d343b9ad85ebf9df65f2c32fa/mmh3-5.3.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:409ab810e88d94152e0e17f184c5c0a0cc9d5cbc35d0064117977360cdfaa6d5", size = 42038, upload-time = "2026-09-30T17:37:13.191Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f5/5297ec53642c1f3783c8e9fdaec247b8a154189a4270591f34233caf25dd/mmh3-5.3.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:a564dcbacf2fa7fbab47a061c69dd8e21882de7a4e7a0d5c129c3e4853f528d0", size = 38709, upload-time = "2026-09-30T17:37:15.061Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f1/51085537195290833f46be8f6b3dd2985ee4d15490eb18b537057216076a/mmh3-5.3.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e7618907f87ce29af7da06e014f9acabb90de2dbe37b4c4a4c06ae6d2166caab", size = 39416, upload-time = "2026-09-30T17:37:16.444Z" }, + { url = "https://files.pythonhosted.org/packages/b0/cd/5a749d76cc15fee759a8fa0febd939167476db22165b27bb0eb82239e187/mmh3-5.3.1-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:50a62eec3de8a3f608f5531901b7d7106231a5cb6cd294bb5f7fbe9e89e71b7b", size = 39867, upload-time = "2026-09-30T17:37:17.808Z" }, + { url = "https://files.pythonhosted.org/packages/59/43/e0c704b08d4adbf22cd9e698f492fb7d4ad7e3a1c4aa5497d0b0da4d7027/mmh3-5.3.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:d227fe27ac054c3999793a6e79cb70d8698d575cbedff216656757b4bdf2513b", size = 55425, upload-time = "2026-09-30T17:37:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/5a/59/ef17c22d2de4dce1a32e443e1de7a3cb8789f60459a45c130ba9794e67ab/mmh3-5.3.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c97deaabf7d9d49c56433c4c52292fd03425c29589a4b71d3d5f905285ed4038", size = 40029, upload-time = "2026-09-30T17:37:20.64Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a1/93c1f18d5105493f4e46259080441d6b7fde971d628fdc7965b586937440/mmh3-5.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c6badf50b4fe0e001a2ab5f6a4347aad3d10ba182a3ef64eacfb8a6cb581393c", size = 39628, upload-time = "2026-09-30T17:37:22.029Z" }, + { url = "https://files.pythonhosted.org/packages/0f/b7/ce9c92967bb98c69856ed1c3a8af1b05e1a14fc44f9ff28a7f6a59a17b10/mmh3-5.3.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:08d59963e361381b8052f57f48607c695e4ecf15e1fedcd28e003fd7a189579b", size = 98463, upload-time = "2026-09-30T17:37:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/69/a5/43e7fcca7b892649d060717c595095a80d6468afbb57bd554ec347fff766/mmh3-5.3.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:714e150e76987dedef08ee370c597c8aabc11b8ac7796bc43876a0b8830e1ffa", size = 103974, upload-time = "2026-09-30T17:37:25.591Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b7/0573655c5b8cc2440ff6113f3dbdd7c196561612059720d20fa745b7b1e5/mmh3-5.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2fe7c4fb48c7b69877ae28a1fe4df4e1bfb53fad467673c100035f0d4210d3e", size = 106431, upload-time = "2026-09-30T17:37:26.908Z" }, + { url = "https://files.pythonhosted.org/packages/b2/de/60b68e00b8675cd16117a3429342acdcafcc4ab26a061db9dfc8bfa36f71/mmh3-5.3.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a209b121065c821358e965f481eba1a0cde6172d9d982bae6bf7ad026498ad6e", size = 113141, upload-time = "2026-09-30T17:37:28.334Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b1/78be5bda72dcf0e2bc9920ae26b801e101eb68c7c0b62e75d1c301b6adbb/mmh3-5.3.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd0e2f3571fcf0b434929d28a2fc61e90215a03aa65368b70ca1faf89e36da25", size = 121217, upload-time = "2026-09-30T17:37:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/dc/22/36c326cf3ed6350fdd9f9bd5261ff05e6c9d37fff2b9944f7d6c06054791/mmh3-5.3.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2e6bd1b4757ee488283e035755da12da831509a2ff40fdd9733b0f34e9072da", size = 104071, upload-time = "2026-09-30T17:37:31.176Z" }, + { url = "https://files.pythonhosted.org/packages/41/30/70cb86cfde918fbcda9df7d6424963691a1978fef781b898d9c4d854c3d8/mmh3-5.3.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:53dde4944acc0be5198dfd0a1b2654db0b88ae82640f8299bf0f0414e075643d", size = 99434, upload-time = "2026-09-30T17:37:32.524Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ab/f0b914fce03026c1c697a26b5cdfb012368f64fb1592744acfd191c76b07/mmh3-5.3.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:37d39bcc4a554d3f4c9868287686a0b342857ea6df01e532fa0fb722654be253", size = 99432, upload-time = "2026-09-30T17:37:33.954Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c7/2673b90301ae8ebacc0785bd3a17ad47273ba170adff0398abf0505690a9/mmh3-5.3.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:b66b40b78a4dd5757ec2ce449db0d505f24ec6a901bffd6667dd60df0eb27a9e", size = 106446, upload-time = "2026-09-30T17:37:35.338Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/3d35d6a5cf606129c456f5fa250c5155f2c9b3e680e99243e13a9649f5a8/mmh3-5.3.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:429453058c769d9cffae36fcaa352241ca59f66aed41aa021bc66565213977f5", size = 102116, upload-time = "2026-09-30T17:37:36.838Z" }, + { url = "https://files.pythonhosted.org/packages/48/0d/73cc09401c50a3f04a79330bda8cabbac05ebd2655e8de785f3356339e42/mmh3-5.3.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:3b475f5b5a5f813a5f7c35f257b7c72b7a6f3da16fd564e8466c26bcbd4cacd7", size = 110094, upload-time = "2026-09-30T17:37:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6c/95deb09624751abce5e60b119d0539314ec2b3536b54fc771a6d918322e9/mmh3-5.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9184d09183b74a78815358c9dc5cc24cb94556779c279f6f2ce698da6a1766a8", size = 98065, upload-time = "2026-09-30T17:37:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/b748aa5ab7a389b3720164f207edb2ec2d7eb21785ad7ec9aa9d55685bdc/mmh3-5.3.1-cp315-cp315-win32.whl", hash = "sha256:0cf5a30de9df754c6977bb90637510ce1c9a2039d4402e1226cf584ba82c367f", size = 40877, upload-time = "2026-09-30T17:37:41.048Z" }, + { url = "https://files.pythonhosted.org/packages/63/15/47f2945f6d4f23c5cf21937a6a818c33875ff0d52578437419d29712df6d/mmh3-5.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:0d7953b08712fb5bb894757568d62692db2e7b235c921eab6471c728ad51a728", size = 42294, upload-time = "2026-09-30T17:37:42.481Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/5f349380e9cc4722eef51bd47da78fe7e71408a7cc670cce435eac0e1451/mmh3-5.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:5e837386acd3387d67c6821b7fa584a197748ae74e336de15d9f731fb3d9c1e4", size = 40663, upload-time = "2026-09-30T17:37:43.709Z" }, + { url = "https://files.pythonhosted.org/packages/82/0c/1303c58d814ad868815a3a88f8e3719970c4b5acba549f556369d8b82a36/mmh3-5.3.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:245665a94be009e874a9d290ddebb67a253805a2b4d87c2e6e042e16a3d1c298", size = 57090, upload-time = "2026-09-30T17:37:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/73/cf/ae0b095688d03c6c1ebc2f9319d393b80e7ef6fb896a47b8cc4b8ac3bd80/mmh3-5.3.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f4539cf0d522eb19c88c30a52ef4d1625840391dde7e07b3f69481b7c61cb6cc", size = 40831, upload-time = "2026-09-30T17:37:46.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/c9/f2e2863e06f63024159cded330e4dc3df544179e45b7b31fc53071d010c4/mmh3-5.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:63a072d514e1762b823cb1129f826289839dd7d71d56cc81eb72223aeb730ce5", size = 40374, upload-time = "2026-09-30T17:37:47.905Z" }, + { url = "https://files.pythonhosted.org/packages/17/4c/1ccd1eadf3caa62b074005c31ad423a5d982f6b35e81305ea9ade8865a94/mmh3-5.3.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9358751ae6cd260e3595662e1503fa8791f31dfc69bfe3034c55a278cc5ab78b", size = 108164, upload-time = "2026-09-30T17:37:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/f7/82/13305aaab0528f64204377db1ae4c5886a8b4bf1a3a032406a9d8d3c7798/mmh3-5.3.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6e9936eb3cef7e4fe7dc0c7fe51039da7ce12dc691b291ea4dc6d8ed28b9b990", size = 114119, upload-time = "2026-09-30T17:37:50.737Z" }, + { url = "https://files.pythonhosted.org/packages/4e/fc/97ab3b905aa27e2bd1886fc0c4ab5e6b05db697f2168300928d6b2184786/mmh3-5.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc25cc785743cdfc10b3f671fde6bea760689518372ffd355bda03f9c07dc9ec", size = 117054, upload-time = "2026-09-30T17:37:52.331Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d7/911384e5a69b817074afa6a8e236a875e601c2b856f6ba520208f357401f/mmh3-5.3.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e885b5f0102899227456a6568585295e599f5f90872933a62274746e94c244dd", size = 122287, upload-time = "2026-09-30T17:37:53.683Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/ec962c0bf2b720c2e1d8d2410e25f699df421ac8f77bbf4463068f103bc0/mmh3-5.3.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fc7a4ab919a66b9db72c1425d610f69a8480dce22aa4c6ad2218e4acf41f8c01", size = 130866, upload-time = "2026-09-30T17:37:54.951Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a9/5dff431289844d190484a8dbb734320e1a19781a29c37372510e1d816799/mmh3-5.3.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bd3b13c6ab9c851c6e74a06fde305c9661d985ceadf91ed60507a27ee1ab4cb", size = 110756, upload-time = "2026-09-30T17:37:56.459Z" }, + { url = "https://files.pythonhosted.org/packages/a0/74/15099e020e8d02e674078b1bdedf0db61f7f447dd684f004d59f5b250361/mmh3-5.3.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:bc3605b2db1dfcea3d0ea481ed32ac798753dd68e942fe3859374021d1668edb", size = 107650, upload-time = "2026-09-30T17:37:57.891Z" }, + { url = "https://files.pythonhosted.org/packages/7a/cb/f00da63de039f9891914d2969bae2d590a5f410c8a9ee93fe6e5ad7d35db/mmh3-5.3.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:ea031c00ffbfc38e54cc070426ec2d136e573cbae24cc34e7ee558399ac71ce0", size = 108729, upload-time = "2026-09-30T17:37:59.229Z" }, + { url = "https://files.pythonhosted.org/packages/17/7d/d6b33bb23a1c56d8af2571eaac999d526a09ac59e6eca35603d74bfe2c1a/mmh3-5.3.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:b4a40cd05113233d30b3035054f9d4939e25466bb63e75d158f116b030618e55", size = 114172, upload-time = "2026-09-30T17:38:00.596Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/bc670057ebd529792feebd6f8e6ac5463df3c8c9d8a4fd1530327d71a74f/mmh3-5.3.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1c6593adfb734776dd93bd16b30512211be5d9a96aec0925b4a3f081edb6ad91", size = 109063, upload-time = "2026-09-30T17:38:01.947Z" }, + { url = "https://files.pythonhosted.org/packages/63/02/c9229c198ce251bbcdfd3ee96d35c711fc3dcb5d05b9330c55b2a63b5db7/mmh3-5.3.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:d3883cbc3da65076bf0972e7b76f4c972d9fe5810757030cd01c29cbe0ccde41", size = 119758, upload-time = "2026-09-30T17:38:03.264Z" }, + { url = "https://files.pythonhosted.org/packages/01/b9/25cde1ae2118d1962fb68bdf399d2676a242216a18cc9e89a8b34df19ba0/mmh3-5.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:5b5d7b1141ad7ad0091199f8440f3034ce775ea8c470a418bf6e2c371de08ec5", size = 107392, upload-time = "2026-09-30T17:38:04.611Z" }, + { url = "https://files.pythonhosted.org/packages/3b/93/ce57c57a62c97c4bceaafb4df29e5bfdc827f3cea08b27444dcf3f55ecb6/mmh3-5.3.1-cp315-cp315t-win32.whl", hash = "sha256:2d76a78ab3af4be19d31840ca4f69b429e602e719d0b351f20d96304dbfb155e", size = 41534, upload-time = "2026-09-30T17:38:05.901Z" }, + { url = "https://files.pythonhosted.org/packages/63/ea/fee223d57ef44e743e71216805b873fd452b1de034a5a6bf78011deb7e9d/mmh3-5.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:4c061c1072dc2f32ef7e6a57a92da3de217dc47114b2857da31b34bc79ac795b", size = 43019, upload-time = "2026-09-30T17:38:07.154Z" }, + { url = "https://files.pythonhosted.org/packages/c2/6f/727d4255c2ca4957f87400852449f22f148b4278529701c67c6c65dadafb/mmh3-5.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c90b3503675892e7496bec63e2e18e413798a062e5d54e6cf220c2bf0bb7fe69", size = 41323, upload-time = "2026-09-30T17:38:08.405Z" }, +] + [[package]] name = "more-itertools" version = "10.8.0" @@ -2416,36 +2623,39 @@ wheels = [ [[package]] name = "onnxruntime" -version = "1.24.1" +version = "1.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "flatbuffers" }, { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, { name = "packaging" }, { name = "protobuf" }, - { name = "sympy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/88/d9757c62a0f96b5193f8d447a141eefd14498c404cc5caf1a6f3233cf102/onnxruntime-1.24.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:79b3119ab9f4f3817062e6dbe7f4a44937de93905e3a31ba34313d18cb49e7be", size = 17212018, upload-time = "2026-02-05T17:32:13.986Z" }, - { url = "https://files.pythonhosted.org/packages/7b/61/b3305c39144e19dbe8791802076b29b4b592b09de03d0e340c1314bfd408/onnxruntime-1.24.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86bc43e922b1f581b3de26a3dc402149c70e5542fceb5bec6b3a85542dbeb164", size = 15018703, upload-time = "2026-02-05T17:30:53.846Z" }, - { url = "https://files.pythonhosted.org/packages/94/d6/d273b75fe7825ea3feed321dd540aef33d8a1380ddd8ac3bb70a8ed000fe/onnxruntime-1.24.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1cabe71ca14dcfbf812d312aab0a704507ac909c137ee6e89e4908755d0fc60e", size = 17096352, upload-time = "2026-02-05T17:31:29.057Z" }, - { url = "https://files.pythonhosted.org/packages/21/3f/0616101a3938bfe2918ea60b581a9bbba61ffc255c63388abb0885f7ce18/onnxruntime-1.24.1-cp311-cp311-win_amd64.whl", hash = "sha256:3273c330f5802b64b4103e87b5bbc334c0355fff1b8935d8910b0004ce2f20c8", size = 12493235, upload-time = "2026-02-05T17:32:04.451Z" }, - { url = "https://files.pythonhosted.org/packages/c8/30/437de870e4e1c6d237a2ca5e11f54153531270cb5c745c475d6e3d5c5dcf/onnxruntime-1.24.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7307aab9e2e879c0171f37e0eb2808a5b4aec7ba899bb17c5f0cedfc301a8ac2", size = 17211043, upload-time = "2026-02-05T17:32:16.909Z" }, - { url = "https://files.pythonhosted.org/packages/21/60/004401cd86525101ad8aa9eec301327426555d7a77fac89fd991c3c7aae6/onnxruntime-1.24.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:780add442ce2d4175fafb6f3102cdc94243acffa3ab16eacc03dd627cc7b1b54", size = 15016224, upload-time = "2026-02-05T17:30:56.791Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a1/43ad01b806a1821d1d6f98725edffcdbad54856775643718e9124a09bfbe/onnxruntime-1.24.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6119526eda12613f0d0498e2ae59563c247c370c9cef74c2fc93133dde157", size = 17098191, upload-time = "2026-02-05T17:31:31.87Z" }, - { url = "https://files.pythonhosted.org/packages/ff/37/5beb65270864037d5c8fb25cfe6b23c48b618d1f4d06022d425cbf29bd9c/onnxruntime-1.24.1-cp312-cp312-win_amd64.whl", hash = "sha256:df0af2f1cfcfff9094971c7eb1d1dfae7ccf81af197493c4dc4643e4342c0946", size = 12493108, upload-time = "2026-02-05T17:32:07.076Z" }, - { url = "https://files.pythonhosted.org/packages/95/77/7172ecfcbdabd92f338e694f38c325f6fab29a38fa0a8c3d1c85b9f4617c/onnxruntime-1.24.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:82e367770e8fba8a87ba9f4c04bb527e6d4d7204540f1390f202c27a3b759fb4", size = 17211381, upload-time = "2026-02-05T17:31:09.601Z" }, - { url = "https://files.pythonhosted.org/packages/79/5b/532a0d75b93bbd0da0e108b986097ebe164b84fbecfdf2ddbf7c8a3a2e83/onnxruntime-1.24.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1099f3629832580fedf415cfce2462a56cc9ca2b560d6300c24558e2ac049134", size = 15016000, upload-time = "2026-02-05T17:31:00.116Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b5/40606c7bce0702975a077bc6668cd072cd77695fc5c0b3fcf59bdb1fe65e/onnxruntime-1.24.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6361dda4270f3939a625670bd67ae0982a49b7f923207450e28433abc9c3a83b", size = 17097637, upload-time = "2026-02-05T17:31:34.787Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a0/9e8f7933796b466241b934585723c700d8fb6bde2de856e65335193d7c93/onnxruntime-1.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:bd1e4aefe73b6b99aa303cd72562ab6de3cccb09088100f8ad1c974be13079c7", size = 12492467, upload-time = "2026-02-05T17:32:09.834Z" }, - { url = "https://files.pythonhosted.org/packages/fb/8a/ee07d86e35035f9fed42497af76435f5a613d4e8b6c537ea0f8ef9fa85da/onnxruntime-1.24.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88a2b54dca00c90fca6303eedf13d49b5b4191d031372c2e85f5cffe4d86b79e", size = 15025407, upload-time = "2026-02-05T17:31:02.251Z" }, - { url = "https://files.pythonhosted.org/packages/fd/9e/ab3e1dda4b126313d240e1aaa87792ddb1f5ba6d03ca2f093a7c4af8c323/onnxruntime-1.24.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2dfbba602da840615ed5b431facda4b3a43b5d8276cf9e0dbf13d842df105838", size = 17099810, upload-time = "2026-02-05T17:31:37.537Z" }, - { url = "https://files.pythonhosted.org/packages/87/23/167d964414cee2af9c72af323b28d2c4cb35beed855c830a23f198265c79/onnxruntime-1.24.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:890c503ca187bc883c3aa72c53f2a604ec8e8444bdd1bf6ac243ec6d5e085202", size = 17214004, upload-time = "2026-02-05T17:31:11.917Z" }, - { url = "https://files.pythonhosted.org/packages/b4/24/6e5558fdd51027d6830cf411bc003ae12c64054826382e2fab89e99486a0/onnxruntime-1.24.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da1b84b3bdeec543120df169e5e62a1445bf732fc2c7fb036c2f8a4090455e8", size = 15017034, upload-time = "2026-02-05T17:31:04.331Z" }, - { url = "https://files.pythonhosted.org/packages/91/d4/3cb1c9eaae1103265ed7eb00a3eaeb0d9ba51dc88edc398b7071c9553bed/onnxruntime-1.24.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:557753ec345efa227c6a65139f3d29c76330fcbd54cc10dd1b64232ebb939c13", size = 17097531, upload-time = "2026-02-05T17:31:40.303Z" }, - { url = "https://files.pythonhosted.org/packages/0f/da/4522b199c12db7c5b46aaf265ee0d741abe65ea912f6c0aaa2cc18a4654d/onnxruntime-1.24.1-cp314-cp314-win_amd64.whl", hash = "sha256:ea4942104805e868f3ddddfa1fbb58b04503a534d489ab2d1452bbfa345c78c2", size = 12795556, upload-time = "2026-02-05T17:32:11.886Z" }, - { url = "https://files.pythonhosted.org/packages/a1/53/3b8969417276b061ff04502ccdca9db4652d397abbeb06c9f6ae05cec9ca/onnxruntime-1.24.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea8963a99e0f10489acdf00ef3383c3232b7e44aa497b063c63be140530d9f85", size = 15025434, upload-time = "2026-02-05T17:31:06.942Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a2/cfcf009eb38d90cc628c087b6506b3dfe1263387f3cbbf8d272af4fef957/onnxruntime-1.24.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34488aa760fb5c2e6d06a7ca9241124eb914a6a06f70936a14c669d1b3df9598", size = 17099815, upload-time = "2026-02-05T17:31:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/73/b6/e2fa6dfc8c3cd19639d452c3120fb0e02f5176b4e24ecabb24b5362be725/onnxruntime-1.30.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f36c87ec504f4f328e968ee322e4ba9682abe66280b3425625afcbed5333fed7", size = 21533037, upload-time = "2026-09-10T16:31:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/4f/1a/4d197d2676a866f6239cd2d06a95b6db40d47dc29d5bec9fb1493a13dd94/onnxruntime-1.30.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bac3aee7b40acd805659fd81a2acd6096312d5b0f014c97ddfe75fb4c1644627", size = 21332697, upload-time = "2026-09-10T16:31:08.118Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2e/4c96278a99140d0307ccda6fd51e6e9d1ca7c9f6fdd1fa96ff375acfc713/onnxruntime-1.30.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fd54b314ea385bcecac69ab431f020ba503e3878dad4ebb645fec5a24b041242", size = 23561046, upload-time = "2026-09-10T16:31:11.494Z" }, + { url = "https://files.pythonhosted.org/packages/66/68/b8aaade8c1883f569228f4b57201cce922bb7d4af6f977d619520a733430/onnxruntime-1.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:0edd0145a6e3fce8a1276491dc784d615e3c58bcb952c9b4e5c876d5c6a12ad7", size = 14309136, upload-time = "2026-09-10T16:31:14.181Z" }, + { url = "https://files.pythonhosted.org/packages/96/f1/fbda6fb08ac4db6c5c6f7c3e128e4d8fb398b52fe2fc3282c45cadedd18b/onnxruntime-1.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:16506cfa5d218850f2b46e0edce1b952121f2f84f8e0074dafe15f4787f0c8fb", size = 14167408, upload-time = "2026-09-10T16:31:16.505Z" }, + { url = "https://files.pythonhosted.org/packages/31/6f/48169f2e62b405bff5053cbd1d73fb5ce41ef7ecd13bb3bfcc191e689b8a/onnxruntime-1.30.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:001ed726c9bd5e2bc92faade7d37d889e9606a350b7d5529f0227df2e3bb57fd", size = 21544867, upload-time = "2026-09-10T16:31:19.876Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/cbc5b8f91963689fdd622f463508c01d0aa95d3f944747b1e0b1eb2160b8/onnxruntime-1.30.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6c32a000d5139a38ba9349030b0032e3331acb559d596b22738d9d2b343a2b83", size = 21345202, upload-time = "2026-09-10T16:31:23.361Z" }, + { url = "https://files.pythonhosted.org/packages/34/35/e7f862dbacbc99fadd9b14a614e49c99bf0f35fd9927a82f096e3de33531/onnxruntime-1.30.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fa688e7891a6aa206636fe7372e27ee75fd17713289f6b4fc7b190e0a7de9328", size = 23585654, upload-time = "2026-09-10T16:31:26.65Z" }, + { url = "https://files.pythonhosted.org/packages/a6/13/0f1699f6de549c9324bc9112a2a85b14c517904cd11b562a654643b755a1/onnxruntime-1.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3501472571f1b1eee50e017851e7929f5ea37312d2d8c2494a19e8fc58b4a38", size = 14311470, upload-time = "2026-09-10T16:31:31.273Z" }, + { url = "https://files.pythonhosted.org/packages/a2/17/02b13e5f51461f0453b18ab854e2d0bc1b6ec353241b05a2ee6b79e35d87/onnxruntime-1.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:dc4c706f1935ebb62356e6a095b047859badd854482c40560888e95c328ed262", size = 14175072, upload-time = "2026-09-10T16:31:34.541Z" }, + { url = "https://files.pythonhosted.org/packages/f0/75/508454c5d01f31641dabc597fe559594c931a520a2673031179319d0afd8/onnxruntime-1.30.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:05e4fc41711d1f4abd19a9124b5be7a65a506cb2670a7f14b16162ef13c58134", size = 21544990, upload-time = "2026-09-10T16:31:37.685Z" }, + { url = "https://files.pythonhosted.org/packages/89/06/e603c71f43f4fe3fd156a053af79cbed6e27a2c649f0988a67d97fedd39f/onnxruntime-1.30.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5327cf6aa15a02bad805fac8bd6882a62571e8b72f6f2938a8f37e6bd1966ce9", size = 21344996, upload-time = "2026-09-10T16:31:40.915Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/ede48ab5dc54907a2999362777f541e132639fb06628ded1932058aa8a36/onnxruntime-1.30.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:86f940afc801ea9681a4da8af84fbe95e1d9ea7d80903952cc1bfad54faad38f", size = 23585560, upload-time = "2026-09-10T16:31:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/3c/dd/c57c529dbc6dd55eca24b12cfbeab1b6a690de72083824eca689085f55b0/onnxruntime-1.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:4b63041bd623a9a9ac5e353948436c6fa7f43edd12d6b4a4ebc340bca959ba93", size = 14311378, upload-time = "2026-09-10T16:31:48.034Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/ef00b45b911e7f2115273a24ed1b43e73c8c6c9ec17f9bfa13f8492581f8/onnxruntime-1.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:c389b6887fc95e0fcb80e89b156bc2cb18e662c29df55e9326fe64140e7d7b4f", size = 14175134, upload-time = "2026-09-10T16:31:50.799Z" }, + { url = "https://files.pythonhosted.org/packages/92/0a/284fd6fe701c9a8aff39dbc119c37582ca40e9cff1ea05425a7cc8606a02/onnxruntime-1.30.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5224ba2b00284cb1c48b3edcd303c109245d66df9ec1b861858fd6de672a38e", size = 21349453, upload-time = "2026-09-10T16:31:53.898Z" }, + { url = "https://files.pythonhosted.org/packages/d6/72/4f4466f8fa1ec267a9ef5e2f3bd175c203d3da0facbdf4049dc93abbe91c/onnxruntime-1.30.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:3f9e002417f1e3bbb31ed43dafa4f22ca1b2b68832244fdee192fcaa1ae19bca", size = 23579837, upload-time = "2026-09-10T16:31:56.82Z" }, + { url = "https://files.pythonhosted.org/packages/6a/03/05c9a9234688757d2876ddecf80bba908561ee11debf125bc1a427ae6f48/onnxruntime-1.30.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8b6169c16a48429890d2f4a0c774ebf54dfe9066a998514aad0518a16d398547", size = 21545313, upload-time = "2026-09-10T16:31:59.654Z" }, + { url = "https://files.pythonhosted.org/packages/c6/bc/1069e58b24779ba9d2fd479db5ecb3a15a6f49b585107c898819c0789558/onnxruntime-1.30.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:d2184fddb6798136e7c478244391ca82443f5c757f59f15bb9e5ad2da5e03175", size = 21346830, upload-time = "2026-09-10T16:32:02.275Z" }, + { url = "https://files.pythonhosted.org/packages/f1/38/8138eed225c5bc6ddfc05879ecac7dacc63c34b9b6f99be72839c1f6dc49/onnxruntime-1.30.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:8b611d24db2954545ce6bd9acd4670183cb368e4642450de7a9ab6474eb374ec", size = 23586333, upload-time = "2026-09-10T16:32:04.996Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0a/748b86000fbf9518b5dfc5bdc1b924eeaca976203740fd9d30649e568ee6/onnxruntime-1.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bdd1752a8502ac1a7ccc6e878d16db6943df574d54ed7f01058a6806ae05be4", size = 14675319, upload-time = "2026-09-10T16:32:07.5Z" }, + { url = "https://files.pythonhosted.org/packages/2f/db/db8e4c0cf6f70f1311060560630fd21763648e01dfbba5195a2592c052ef/onnxruntime-1.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:83d543843cbd352cfa9996a6c7b92f8a480f114a18f95ff2a1acaf6921e85b6d", size = 14569360, upload-time = "2026-09-10T16:32:09.896Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/ec0a9d656e39b43887c378a5388b20c3b1e1ee43bded84580b0936466694/onnxruntime-1.30.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:265de607ba6f9814264e1d5d413fa7069d70f48e3049b5e460b3b04bdfcef294", size = 21348433, upload-time = "2026-09-10T16:32:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/91/f0/40f74b7c00077e1e25627067ed98a70df1fef5c0e21b82849190d312554e/onnxruntime-1.30.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:67ad7f03433b6462c627d0f555dece80e6a26bc71e8542ced35cebd32142d1b7", size = 23579340, upload-time = "2026-09-10T16:32:15.532Z" }, ] [[package]] @@ -2772,6 +2982,100 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + [[package]] name = "platformdirs" version = "4.5.1" @@ -2955,6 +3259,68 @@ redis = [ { name = "redis" }, ] +[[package]] +name = "py-rust-stemmers" +version = "0.1.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/c1/9763f9fb1cd73f9c317a83feeed6e0d4af320c6bbddab47b4a94f3a47d0c/py_rust_stemmers-0.1.8.tar.gz", hash = "sha256:6b0f6f48bc54d607aed802de872fcd5a71bae969a6760976dc78ce55e8eaf3da", size = 9732, upload-time = "2026-05-22T11:00:24.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/d6/28285b1c6fb9e6689a78135659679f637edc7395a2b994f48123094f1c99/py_rust_stemmers-0.1.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:36b952ce65a794faf15553b8f5b60431483c2d5bec00bc6982bf490e727250f9", size = 290828, upload-time = "2026-05-22T10:59:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/42/da/cfe72e8213390079be9db139ec3b2f9e810f33e0d1f5fc0ebe30effd608e/py_rust_stemmers-0.1.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3bef8062d28251b465299cc676de7c11dde003858caf2c2b5c14de7298dc63db", size = 276052, upload-time = "2026-05-22T10:59:20.715Z" }, + { url = "https://files.pythonhosted.org/packages/e5/81/2a670bf588cf255698d3c5133c13ce8d5e018c6c0bf6ac64b77abc897999/py_rust_stemmers-0.1.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af749b3b9f6531342250dd05854c0ae93e01f79b0049a8769012e0b50e9aba5b", size = 314770, upload-time = "2026-05-22T10:59:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/45b5fba9c25b00f4ae17ae81a54a4555b0466f5c8d774465591b11dd9745/py_rust_stemmers-0.1.8-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:45d0c42346f8e5d04b86a0b0f895bb15c53788bf551e7fad36be1dad093e856f", size = 319086, upload-time = "2026-05-22T10:59:22.866Z" }, + { url = "https://files.pythonhosted.org/packages/ba/9b/fcc7f3e0b01b570b646478b16461d9934b39eae4f34009c104a2428aa631/py_rust_stemmers-0.1.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:342b6cc9eb833f102d86e146ee71bccb3c1ed1e8320db8e6553cc81b716b1b14", size = 320186, upload-time = "2026-05-22T10:59:23.91Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a406c7fada4fc8281dd01a389efb15c9cbe81e07afbd70e089e6b6574020/py_rust_stemmers-0.1.8-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:25bb9b0b6b8d79b32c151c7f5f94af9af9aea201ca8736e6f117c841b017f028", size = 320502, upload-time = "2026-05-22T10:59:24.903Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/da7228d7f68d156b3d690c355eed98438f0e9564f04cb5bccef66189c4f7/py_rust_stemmers-0.1.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dab8a862fa8e4c9e715848e9d64c317229d7a2c37238cd1c73237b85d655ab7e", size = 492445, upload-time = "2026-05-22T10:59:26.318Z" }, + { url = "https://files.pythonhosted.org/packages/e4/87/fa4b5dba78e1e5597419f1cdad25139165031cdf63adff96fbb3e01b0e17/py_rust_stemmers-0.1.8-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:da0326c913070d5f3fabd56393ca4118167bb0b13c2932a77c7a1b31f85f651a", size = 595744, upload-time = "2026-05-22T10:59:27.585Z" }, + { url = "https://files.pythonhosted.org/packages/ff/84/e1212e47f7db3d468c9c4555f85594019a15b948a614e60b190adf9c477a/py_rust_stemmers-0.1.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0f1d2135974bbbea2c15087a7d8cec8697338b2a748c9694c92943775f4d6c14", size = 538125, upload-time = "2026-05-22T10:59:28.92Z" }, + { url = "https://files.pythonhosted.org/packages/1c/af/af00e6b00f0aa2bc3c164615af362b962cc79d2ddedf53d0e9e92920c425/py_rust_stemmers-0.1.8-cp310-cp310-win_amd64.whl", hash = "sha256:22d037a82920bed8fccbec62cf5ef47d821ac3966a3d098fa48a2053397ea6b7", size = 208538, upload-time = "2026-05-22T10:59:30.403Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5b/fcc991636129fb2840fd1c7560112798046f26fa085b7a377382d50d2679/py_rust_stemmers-0.1.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4b1159a38a198eabeabd908015f9425c4220b61b42c6603c58870481ff2b50bb", size = 290471, upload-time = "2026-05-22T10:59:32.033Z" }, + { url = "https://files.pythonhosted.org/packages/48/0a/c88c9a7b5c94acc1175a33964637aff9cf8fa4c2e595846ab1df04c1f0bf/py_rust_stemmers-0.1.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1686fc009869ff8bcc1d5a305f071eeb8c3b3612a9827bcadd4e61fdb5727179", size = 275775, upload-time = "2026-05-22T10:59:32.979Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e2/e685cd31655a1ac56ebe0d571d221c199b1971eb5a2fdad88c889dc25983/py_rust_stemmers-0.1.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:769f37882905da2311cb720681b112eb70a4e6bd56fb424d473427b5379c8396", size = 314523, upload-time = "2026-05-22T10:59:34.436Z" }, + { url = "https://files.pythonhosted.org/packages/65/93/a6c0f30109c259199ac171cb6a0c69addefdba454ee0a8d51bb94e767c11/py_rust_stemmers-0.1.8-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3007ad4ec51e0c352ae410234a24a9ac75fab0c1e06c585fbac9fcced69385f8", size = 318808, upload-time = "2026-05-22T10:59:35.719Z" }, + { url = "https://files.pythonhosted.org/packages/59/87/ecaffed03e4b78d35ffb44740ca779e57d9f49d7d764f3f56b633b1e1c8c/py_rust_stemmers-0.1.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a1e11d22a240318dc917266eb3c85919455b6ea834445b95997712d9ede6b93", size = 319990, upload-time = "2026-05-22T10:59:36.84Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0d/2976bb288240e25110be687e6be5ecb0623a17f667f186e07033e429985f/py_rust_stemmers-0.1.8-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:08c258deab6d994551a92e9468ce88e58f97e636e73d9c5763978a57d7675a13", size = 320291, upload-time = "2026-05-22T10:59:38.263Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fb/7b1a93f63600633b2c741714f0f6024b2caff54e5aed77c5f6e0be384947/py_rust_stemmers-0.1.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eee4af7ada2ce9cb3ec59ffe8458148c3933a86507d816bf954ee506a0e45b61", size = 492171, upload-time = "2026-05-22T10:59:39.537Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3b/8e829e709542f928beb0613f4dffca4797a817f740c1be07eabd11bd2db4/py_rust_stemmers-0.1.8-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f16deb1557b8253d8c11693047bec4ed67d6b09ae0f84c8b896ea03ac2fc8925", size = 595398, upload-time = "2026-05-22T10:59:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/27/8b/b3972f0fc14e6bfc602a9260a1747742aaf86737ad57872998b085a2f1aa/py_rust_stemmers-0.1.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:870afb2d1d4731bd2d74b715b34439b29734e4dc94c55342096f07669f7f9fa0", size = 537820, upload-time = "2026-05-22T10:59:42.307Z" }, + { url = "https://files.pythonhosted.org/packages/0e/90/54c2949cc4fef544810305526e0fd658e2bc87abcc046283379a7044abec/py_rust_stemmers-0.1.8-cp311-cp311-win_amd64.whl", hash = "sha256:13b25ce65509ff7e37725bd38c62704f32ae0604ac0899f43c8cce41d5543212", size = 208396, upload-time = "2026-05-22T10:59:43.335Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6a/39080bc8f4a441a35378c0faeeb834fb27974997f40d51342574e70f9662/py_rust_stemmers-0.1.8-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6a9a4b8733d0b307bd0879ab7e321aa8a0bfd054a75a5cb23c647df5ca7d17c3", size = 290230, upload-time = "2026-05-22T10:59:44.551Z" }, + { url = "https://files.pythonhosted.org/packages/73/15/ae60b9010924adac465f418822d9c514690aba6846edd67b6e2b5c227745/py_rust_stemmers-0.1.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:51d0042d2a92ef0f7048bfc06b6c2a02306af31ea47f09d24b34e4b7e63c4e80", size = 275449, upload-time = "2026-05-22T10:59:45.547Z" }, + { url = "https://files.pythonhosted.org/packages/ec/7c/94be8b932179823d66e0d2be03a94706132a7d16a640d5e5710de1cb1b8f/py_rust_stemmers-0.1.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d3d34094b9b6078a8ea6fe1c7044e5fd32f14e76c94818c5008f49ae075f08", size = 316676, upload-time = "2026-05-22T10:59:46.522Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a4/8bd5c9f31207136830457d819e3f98bb21c54c0cdc40d6f1845ce4efdf7c/py_rust_stemmers-0.1.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:40c86be90cee4a709ad84fde4db7f11ca44d65630a56b77ec86fe84c23adfc09", size = 319458, upload-time = "2026-05-22T10:59:47.914Z" }, + { url = "https://files.pythonhosted.org/packages/f9/95/95da2b353b164a3a2b8a1c799866a58060693be4f1dc21065663dc67dc17/py_rust_stemmers-0.1.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:515884bcfb47b10335146648f276930d0c1201ae5e8b7b400fb46d8ea05c0ec2", size = 323541, upload-time = "2026-05-22T10:59:48.894Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ce/f34403b68808519dfa3220e1d94a40f26d5025f27e28893e2388ab9cfde5/py_rust_stemmers-0.1.8-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fa42f5f8feb694aaaa869eedf477fcaf66f67a192cd64d94302d06920c33864a", size = 323873, upload-time = "2026-05-22T10:59:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/57/01/fb8527f6474d576975415405c985a97260e0403829e062103d334230b7d2/py_rust_stemmers-0.1.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2e86ad68fe297a6652f0f0390625ea81858b6f27862fd4c5ee1214bf5af29b9d", size = 494761, upload-time = "2026-05-22T10:59:51.021Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ac/73816237dbec20a7299abf901e2f7b6061d238754e033b48e423603f5336/py_rust_stemmers-0.1.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4b90fc81411943b114e8eb4988a876ba3b12bd2d20741559803eddc4131575dc", size = 596141, upload-time = "2026-05-22T10:59:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/52/0a/dd48debf386a206ee1c6ad75a0827eac89428441291c90d98bc3803fccf1/py_rust_stemmers-0.1.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56cc2c2df742fa6529285b7d204720f34b7da789ed78eb578442f93c6de97d89", size = 541633, upload-time = "2026-05-22T10:59:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/ca/ebb707ab280636b8f46d040ccb051d1a9ddbc1f1ca2d90cdba626872f405/py_rust_stemmers-0.1.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd967eea2f808a1e73aa71ecccef0f4925a4cca4eb02ced94057afe3303153ef", size = 212134, upload-time = "2026-05-22T10:59:54.245Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/f078f3930311e7b6154ccdf9166c4e30a416c7d199e136b5f09265d58a35/py_rust_stemmers-0.1.8-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5bd15b89203ecd886960e237124d1aa6e55498d76418c36c967d3b12168d43dc", size = 290427, upload-time = "2026-05-22T10:59:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/c9/46/21d784a3f1db6a23051ffd5826d8ee667d26a64587c1cfbda0443ed87fff/py_rust_stemmers-0.1.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c92733b020534470ca5a0d7fe8b85c85622ff383d4f37fec75a1c677aa84921", size = 275628, upload-time = "2026-05-22T10:59:56.687Z" }, + { url = "https://files.pythonhosted.org/packages/57/d5/701c73a4f6a7fecfd96a6588f0cafe98d6b0acde93adf8a2e45535f3d1d5/py_rust_stemmers-0.1.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ab605a86c950ba7e8ab1392cf91296c0bec3084babb897a4aecf90a10c82395", size = 316656, upload-time = "2026-05-22T10:59:57.67Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0d/c58fe98153cfdb6abf4dfb6ac335c923000d4af4e736080c3a3045b7aea7/py_rust_stemmers-0.1.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:21ed8055cec1f78d666afad8ffd7a51775ba419d2c615b8a1df7b32ca7f33e2b", size = 319377, upload-time = "2026-05-22T10:59:58.664Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d7/e60d04849e90aa3ad457211cc4999c30401f433341f9a5588c12b81f9877/py_rust_stemmers-0.1.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae773e1d01e9aa328d175f461475d0cd7074a82bfcc71de6dc5765e51f1cc9f7", size = 323719, upload-time = "2026-05-22T10:59:59.845Z" }, + { url = "https://files.pythonhosted.org/packages/6a/48/c0e4fb955db784cc354e0756354602f7043ff4c10fcbd9d901a2f8fe3239/py_rust_stemmers-0.1.8-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5cc8fab9d0f1b274a26935a632362b8278f03e81b65e8b8644d5ca3f62a5a1a4", size = 324110, upload-time = "2026-05-22T11:00:01.26Z" }, + { url = "https://files.pythonhosted.org/packages/48/eb/981b26baff37cf7a26ee206763cc4d2fb3e1db8f0f86ec030074431fae05/py_rust_stemmers-0.1.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:35570098da02eb439afcd7270a12bf850bbe874b85cb912e0fb2d87a6e703920", size = 494645, upload-time = "2026-05-22T11:00:02.737Z" }, + { url = "https://files.pythonhosted.org/packages/6d/af/f16e805b7aefc2257b192b83a89300c8360b0fdffd3dfefa92dee4ec9b15/py_rust_stemmers-0.1.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0a68745d4b3c7f5abc778ca967e8711df6154873abcfe4e62a6631fa2363cc32", size = 596124, upload-time = "2026-05-22T11:00:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/e7a2c940ba00e0792ae346aed5e755d51d37cf6d6853f6b141e5380e285d/py_rust_stemmers-0.1.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7cc0cc0b8eb45d2158c28ea43e2f338c110aad63052ad3bd00bc7446a595e12f", size = 541771, upload-time = "2026-05-22T11:00:06.081Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a0/dd7c5fc6ade6d2a2a49e49937f06f2d488511454e8ab1b313d277ee8c3b1/py_rust_stemmers-0.1.8-cp313-cp313-win_amd64.whl", hash = "sha256:15af4e12e1288de2e5241eec375afc6ad6be4c125a28ca010599d9f92db23f01", size = 212438, upload-time = "2026-05-22T11:00:07.244Z" }, + { url = "https://files.pythonhosted.org/packages/b0/7e/f4346adfd44acbd7eaedcbd7d21b7f40ec9712e6c699e71fddad8dae6f8d/py_rust_stemmers-0.1.8-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:526b58958c6ffa36c4a805326cfb624ecbd665d16ba435027dbed0bcbcaa09d2", size = 290379, upload-time = "2026-05-22T11:00:08.192Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d8/988fc3f5dc0dbbd4bf5909f50ff953ab55ee8b5f79a835d00e57847d3123/py_rust_stemmers-0.1.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2b607f0b270951fb66479baf4b68716cc63a981585cbd898b0b6b5c359efde7e", size = 275458, upload-time = "2026-05-22T11:00:09.522Z" }, + { url = "https://files.pythonhosted.org/packages/f4/94/e04c8b6a8364bca1b368785cef143755dd2d1ffe74df8f8b47b075bb1043/py_rust_stemmers-0.1.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b0327b151ab8a338fb54fdac114ba34394327fc1e2c4c425ad1caf2013e5de3", size = 314711, upload-time = "2026-05-22T11:00:10.878Z" }, + { url = "https://files.pythonhosted.org/packages/4f/cb/f59f9a80caa099cb6625a46c9a8e6e7e80bb3ed284f17e80245c8240a66e/py_rust_stemmers-0.1.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dadd0e369703817fc7026987b3093f461f9f58d8dde74e689d546184bc8f3451", size = 319370, upload-time = "2026-05-22T11:00:11.961Z" }, + { url = "https://files.pythonhosted.org/packages/06/59/8211cd0f56e53f7770debd9a78de37985fb5662ae66e3b7b380f4c79888b/py_rust_stemmers-0.1.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:245e2c61c52e073341893a9682cd1396b61047154548aee30bb1af3d8ed4b4cc", size = 321373, upload-time = "2026-05-22T11:00:13.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/72/fe33e614c114264d1ba54d39da4b5a4abeb6aedd0d26e5a8fd0637d6ddba/py_rust_stemmers-0.1.8-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:451ee1c02a3f5cf1e161b46ba9032cdda4ba10a8b03ff9ee61c1d34d42a0bc81", size = 321707, upload-time = "2026-05-22T11:00:14.177Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/3cd18902fe2fa54557d3fe9132552256372d381c7aca71346163055d78b1/py_rust_stemmers-0.1.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d396dd25c473c1bc4248c79cd223f4b36356b55a124652f015c6a001547f81ac", size = 492457, upload-time = "2026-05-22T11:00:15.245Z" }, + { url = "https://files.pythonhosted.org/packages/90/d7/32c6d3995e7036b73683389de2771f4dbbf40de192b7efe73c2528ee1eb5/py_rust_stemmers-0.1.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:479c77c32d8be692f3cfcde7e19273f02ac81d6f45c6aef49887ef95cab7abbb", size = 596085, upload-time = "2026-05-22T11:00:16.404Z" }, + { url = "https://files.pythonhosted.org/packages/00/8c/e68fa5d862ea6a27fced3535c25ea4eaa26ba1ce00dfef5841924c74b167/py_rust_stemmers-0.1.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c786235275c5c2abb7f206b8236aee3ca0bc53c7497daf7fb7b01d3491469547", size = 539747, upload-time = "2026-05-22T11:00:17.414Z" }, + { url = "https://files.pythonhosted.org/packages/44/48/aa584cf3772e01231641c95dc1aa73327a7d986c562639d78d0013733acf/py_rust_stemmers-0.1.8-cp314-cp314-win_amd64.whl", hash = "sha256:931d13570962b093417e5443a9d1bd63d73fa239ebb81e5b1d346663571403e4", size = 209636, upload-time = "2026-05-22T11:00:18.662Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8c/7c6d581412a6f33d316e72a8f3442ae0c61a7b6190ca30e1a06ee17ea234/py_rust_stemmers-0.1.8-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c03f51280d5d72f7f9b07101ad248845279dc1c82c47a74149303d25937464b7", size = 290748, upload-time = "2026-05-22T11:00:19.794Z" }, + { url = "https://files.pythonhosted.org/packages/76/fe/04436ffe3aa4c02a40500835fc1a80d52375c738aa7ef66ebe0c4ccc2900/py_rust_stemmers-0.1.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:234fdcb58f4d907877ed03c9358668a149b5a66d096abcf43c324a4f5697d36d", size = 276111, upload-time = "2026-05-22T11:00:21.026Z" }, + { url = "https://files.pythonhosted.org/packages/45/24/6b32c86dd4eecdc309bfe6c15529a11e90b1e2c7af015366498c14e925f7/py_rust_stemmers-0.1.8-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dca0ae40715238582d6f1824b61d09ea3982359a061b69798ab5732b3ba0d4c5", size = 314816, upload-time = "2026-05-22T11:00:22.207Z" }, + { url = "https://files.pythonhosted.org/packages/22/78/3bf351dbcc7f51eb03a506c0bcf8aead8b1401cf26aaa1328968471531aa/py_rust_stemmers-0.1.8-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfc185b599e646a0e39d11df3f5e6d15edefb110496601556385d33b55fed5de", size = 320180, upload-time = "2026-05-22T11:00:23.387Z" }, +] + [[package]] name = "pycparser" version = "2.23" @@ -4826,6 +5192,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] + [[package]] name = "xxhash" version = "3.6.0"