From 4d8594c99bfaa6b2b5b80d2998db36198e4ebe95 Mon Sep 17 00:00:00 2001 From: Devil Date: Sun, 4 Oct 2026 12:37:57 -0400 Subject: [PATCH 01/25] chore: ignore Claude agent worktrees and omp local data --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) 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/ From cb74022e70a10dc4f8e4c5b8b9aaa874b3f5e349 Mon Sep 17 00:00:00 2001 From: Devil Date: Sun, 4 Oct 2026 20:28:45 -0400 Subject: [PATCH 02/25] feat: add outcome learning and repository-aware memory retrieval --- CHANGELOG.md | 5 + README.md | 97 ++- benchmarks/coding_memory_eval.py | 628 ++++++++++++++++++ daem0nmcp/api/v7/discovery_operations.py | 4 +- daem0nmcp/api/v7/federated_retrieval.py | 204 +++++- daem0nmcp/api/v7/models.py | 7 + daem0nmcp/api/v7/pinned.py | 54 +- daem0nmcp/api/v7/resource_repository.py | 113 ++-- daem0nmcp/api/v7/runtime_services.py | 148 ++++- daem0nmcp/api/v7/tools.py | 34 + daem0nmcp/capabilities.py | 3 +- daem0nmcp/code_bindings.py | 275 ++++++++ daem0nmcp/config.py | 9 + daem0nmcp/database.py | 2 + daem0nmcp/event_store.py | 154 +++++ daem0nmcp/migrations/schema.py | 85 +++ daem0nmcp/migrations/v7.py | 2 + daem0nmcp/models.py | 78 +++ daem0nmcp/retrieval/composer.py | 184 ++++- daem0nmcp/retrieval/dense_projection.py | 20 +- daem0nmcp/retrieval/late_interaction.py | 112 ++++ daem0nmcp/retrieval/repository.py | 196 +++++- daem0nmcp/retrieval/runtime.py | 23 +- daem0nmcp/retrieval/service.py | 280 +++++++- daem0nmcp/retrieval/types.py | 24 + daem0nmcp/retrieval/utility.py | 72 ++ daem0nmcp/schema_version.py | 2 +- daem0nmcp/storage_activation.py | 12 +- daem0nmcp/verification_v7.py | 4 + docs/release/v7/inventory.md | 9 +- docs/release/v7/requirements.json | 75 ++- pyproject.toml | 5 + tests/api_v7/test_federated_retrieval.py | 40 ++ tests/api_v7/test_git_briefing_deadline.py | 3 +- tests/api_v7/test_memory_learning_writes.py | 573 ++++++++++++++++ tests/api_v7/test_path_policy.py | 53 +- tests/api_v7/test_pinned_handlers.py | 20 + tests/api_v7/test_runtime_services.py | 35 +- tests/api_v7/test_tool_manifest.py | 6 + tests/test_capabilities.py | 18 +- tests/test_code_bindings.py | 182 +++++ tests/test_code_indexer.py | 50 +- tests/test_coding_memory_eval.py | 162 +++++ tests/test_event_bundle.py | 11 +- tests/test_event_store.py | 90 ++- tests/test_late_interaction_rerank.py | 235 +++++++ tests/test_retrieval_benchmark.py | 7 +- tests/test_retrieval_config.py | 12 + tests/test_retrieval_dense_projection.py | 122 +++- tests/test_retrieval_legacy_compat.py | 8 + tests/test_retrieval_lexical.py | 3 +- tests/test_retrieval_onnx_encoder.py | 8 +- tests/test_retrieval_projection_growth.py | 2 +- tests/test_retrieval_repository.py | 207 +++++- tests/test_retrieval_retention.py | 562 ++++++++++++++++ tests/test_retrieval_runtime.py | 51 +- tests/test_retrieval_service.py | 96 ++- .../test_retrieval_specialized_projection.py | 2 +- tests/test_retrieval_utility.py | 405 +++++++++++ tests/test_v7_schema_upgrade.py | 203 ++++++ uv.lock | 421 +++++++++++- 61 files changed, 6229 insertions(+), 278 deletions(-) create mode 100644 benchmarks/coding_memory_eval.py create mode 100644 daem0nmcp/code_bindings.py create mode 100644 daem0nmcp/retrieval/late_interaction.py create mode 100644 daem0nmcp/retrieval/utility.py create mode 100644 tests/api_v7/test_memory_learning_writes.py create mode 100644 tests/test_code_bindings.py create mode 100644 tests/test_coding_memory_eval.py create mode 100644 tests/test_late_interaction_rerank.py create mode 100644 tests/test_retrieval_retention.py create mode 100644 tests/test_retrieval_utility.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 302a52a..4e59f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,13 @@ ### 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. ### Changed +- 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 `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. - 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. diff --git a/README.md b/README.md index 216a6d5..5ac7db1 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,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 @@ -1063,6 +1063,70 @@ 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. + +Utility ranking defaults to **shadow**: it reports estimates and diagnostics +without changing retrieval order. Opt in 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 +1202,29 @@ 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. + ## Support If Daem0nMCP has been useful to you, consider supporting its development: 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/discovery_operations.py b/daem0nmcp/api/v7/discovery_operations.py index d345d1f..702ba8a 100644 --- a/daem0nmcp/api/v7/discovery_operations.py +++ b/daem0nmcp/api/v7/discovery_operations.py @@ -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: diff --git a/daem0nmcp/api/v7/federated_retrieval.py b/daem0nmcp/api/v7/federated_retrieval.py index 1b50431..b98ad1b 100644 --- a/daem0nmcp/api/v7/federated_retrieval.py +++ b/daem0nmcp/api/v7/federated_retrieval.py @@ -9,11 +9,21 @@ 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, + 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 +146,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 +310,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() 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 +342,191 @@ 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=list(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 retention_mode == "apply" and retained is not None + 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_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(item.changed_bindings) + return "" + requested_lines = [ f"[E{index}] {' '.join(item.content.split())[: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 +549,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 +564,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/models.py b/daem0nmcp/api/v7/models.py index e1a44aa..1e66f2e 100644 --- a/daem0nmcp/api/v7/models.py +++ b/daem0nmcp/api/v7/models.py @@ -759,6 +759,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: diff --git a/daem0nmcp/api/v7/pinned.py b/daem0nmcp/api/v7/pinned.py index 43a01e4..727e402 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 @@ -314,6 +314,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 +336,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) @@ -620,6 +625,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 +641,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 @@ -727,6 +734,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,6 +807,8 @@ 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, ) -> MemoryStoreOutput: @@ -818,6 +828,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( @@ -882,6 +896,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( @@ -938,18 +957,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, @@ -983,6 +1008,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( diff --git a/daem0nmcp/api/v7/resource_repository.py b/daem0nmcp/api/v7/resource_repository.py index 4626698..0b6d276 100644 --- a/daem0nmcp/api/v7/resource_repository.py +++ b/daem0nmcp/api/v7/resource_repository.py @@ -44,6 +44,57 @@ ) +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 +686,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 +706,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/runtime_services.py b/daem0nmcp/api/v7/runtime_services.py index 68cb407..03dfacf 100644 --- a/daem0nmcp/api/v7/runtime_services.py +++ b/daem0nmcp/api/v7/runtime_services.py @@ -23,6 +23,11 @@ from ... import __version__ from ...bounded_workers import BoundedWorkerBusyError, BoundedWorkerPool +from ...code_bindings import ( + CodeBindingError, + binding_refs_from_context, + capture_bindings, +) from ...event_store import ( AppendedEvent, EventCommand, @@ -516,6 +521,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 +664,32 @@ 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 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 +727,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 +744,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 +821,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 +833,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 +869,33 @@ 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 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 +984,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 +1187,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 +1215,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 +1319,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 +1439,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 +1579,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..5f3e42c 100644 --- a/daem0nmcp/api/v7/tools.py +++ b/daem0nmcp/api/v7/tools.py @@ -60,6 +60,7 @@ VersionId, WireModel, WorkspaceId, + _unique_strings, contains_absolute_filesystem_path, ) from .policy import V7_TOOL_LEVELS, V7ArgumentNormalizer @@ -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( @@ -1099,6 +1103,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 +1112,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,6 +1149,8 @@ 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 @@ -1169,6 +1197,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 @@ -2308,11 +2339,13 @@ def build_tool_specs( __all__ = [ "ActiveContextPage", "CodeImpactAnalyzeInput", + "CodeRef", "DecisionDebateInput", "DreamingStatus", "DreamingStrategyStatus", "EntityEvolutionTraceInput", "HealthData", + "InformedByRecordIds", "MemoryPreflightInput", "MemoryPreflightOutput", "MemoryRecallEntityInput", @@ -2324,6 +2357,7 @@ def build_tool_specs( "MemoryStoreInput", "MemoryStoreOutput", "OutcomeData", + "OutcomeVerification", "PreflightData", "PROTECTED_TOOL_NAMES", "ProtectedToolName", 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/code_bindings.py b/daem0nmcp/code_bindings.py new file mode 100644 index 0000000..5c785ef --- /dev/null +++ b/daem0nmcp/code_bindings.py @@ -0,0 +1,275 @@ +"""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, Mapping, Sequence +from dataclasses import dataclass, field +from pathlib import Path, PureWindowsPath +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 +_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 + + +@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(slots=True) +class _FileSnapshot: + source: bytes + fingerprints: dict[str | None, str] = field(default_factory=dict) + + +def _read_source(path: Path) -> bytes: + try: + with path.open("rb") as source_file: + source = source_file.read(_MAX_BINDING_FILE_BYTES + 1) + except OSError as exc: + raise CodeBindingError("bound file cannot be read") from exc + if len(source) > _MAX_BINDING_FILE_BYTES: + raise CodeBindingError("bound file exceeds size limit") + return source.replace(b"\r\n", b"\n") + + +def _fingerprint(path: Path, root: Path, source: bytes, name: str | 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 _UnverifiableBindingError("code parser is unavailable") + supported = producer.get_supported_extensions() + if path.suffix.lower() not in supported: + raise _UnverifiableBindingError("bound symbol extension is unsupported") + entities = producer.index_source_strict(path, root, source) + lines = source.split(b"\n") + chunks = [] + for entity in entities: + if not isinstance(entity, Mapping): + raise _UnverifiableBindingError("code parser returned invalid entities") + if name not in (entity.get("qualified_name"), entity.get("name")): + continue + start, end = entity.get("line_start"), entity.get("line_end") + if ( + not isinstance(start, int) + or not isinstance(end, int) + or not 1 <= start <= end <= len(lines) + ): + raise _UnverifiableBindingError( + "code parser returned invalid line bounds" + ) + chunks.append(b"\n".join(lines[start - 1 : end])) + except _UnverifiableBindingError: + raise + except Exception as exc: + raise _UnverifiableBindingError("code parser failed") from exc + if not chunks: + raise CodeBindingError("bound symbol is missing") + return hashlib.sha256(b"\n".join(chunks)).hexdigest() + + +def capture_bindings( + workspace: Workspace, refs: Sequence[tuple[str, str | None]] +) -> tuple[CodeBinding, ...]: + from .api.v7.resource_repository import read_git_output_sync + + if len(refs) > 16: + raise CodeBindingError("too many code bindings") + fingerprints = [] + 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 as exc: + raise CodeBindingError( + "bound file is missing or outside the workspace" + ) from exc + fingerprint = _fingerprint(path, workspace.root, _read_source(path), name) + 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 bounded source snapshots only while their filesystem identity agrees.""" + + def __init__(self) -> None: + self._lock = Lock() + self._cache: OrderedDict[tuple[Path, int, int], _FileSnapshot] = OrderedDict() + + 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 CodeBindingError("bound file is missing or inaccessible") from exc + if stat.st_size > _MAX_BINDING_FILE_BYTES: + raise CodeBindingError("bound file exceeds size limit") + key = (path, stat.st_mtime_ns, stat.st_size) + with self._lock: + snapshot = self._cache.get(key) + if snapshot is None: + snapshot = _FileSnapshot(_read_source(path)) + self._cache[key] = snapshot + if len(self._cache) > 256: + self._cache.popitem(last=False) + self._cache.move_to_end(key) + if ( + binding.qualified_name is not None + and binding.qualified_name in snapshot.fingerprints + ): + from .api.v7.discovery_operations import default_code_indexer_factory + + if not getattr(default_code_indexer_factory(), "available", False): + raise _UnverifiableBindingError("code parser is unavailable") + if binding.qualified_name not in snapshot.fingerprints: + snapshot.fingerprints[binding.qualified_name] = _fingerprint( + path, root, snapshot.source, binding.qualified_name + ) + return snapshot.fingerprints[binding.qualified_name] + + 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/config.py b/daem0nmcp/config.py index 4fa7632..d3fa5f3 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 @@ -112,6 +113,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..96161e8 100644 --- a/daem0nmcp/event_store.py +++ b/daem0nmcp/event_store.py @@ -799,6 +799,160 @@ 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: + 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 = payload["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..4b1bb76 100644 --- a/daem0nmcp/migrations/schema.py +++ b/daem0nmcp/migrations/schema.py @@ -2598,6 +2598,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,6 +3261,34 @@ 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, *, @@ -3293,6 +3376,8 @@ 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: + _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/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/retrieval/composer.py b/daem0nmcp/retrieval/composer.py index c410615..80c56a8 100644 --- a/daem0nmcp/retrieval/composer.py +++ b/daem0nmcp/retrieval/composer.py @@ -10,8 +10,8 @@ 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 typing import Literal, Protocol from ..bounded_workers import BoundedWorkerBusyError, BoundedWorkerPool @@ -22,6 +22,7 @@ ContextPackage, EvidenceItem, FusedCandidate, + _finite_nonnegative, _legacy_metadata, _opaque, ) @@ -92,6 +93,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 +109,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 +153,26 @@ 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): + if any(character in token for character in "_./") or any( + character.isupper() for character in token[1:] + ): + identifiers.add(token) + if "." in token or "/" in token: + segment = re.split(r"[./]", token)[-1] + if segment: + identifiers.add(segment) + return frozenset(identifiers) + + @dataclass(frozen=True, slots=True) class CompositionResult: """Selected evidence items and their exact rendered context.""" @@ -220,6 +264,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 +279,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 +301,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 +341,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 +373,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 +414,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 +442,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) text = "\n\n".join(item.block for item in prepared) rendered_tokens = self._count(text) @@ -394,6 +480,9 @@ def compose( ), 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 +509,20 @@ 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 + ) -> 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, + ) resequenced.append( replace( item, @@ -437,18 +533,53 @@ 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.rsplit("/", 1)[-1] in retention.identifiers + or ( + qualified_name is not None + and qualified_name.rsplit(".", 1)[-1] in retention.identifiers + ) + for relative, qualified_name in source.code_bindings + ) + + 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) + excerpt = self._bounded(full_excerpt, self._excerpt_limit(source, retention)) truncated = excerpt != full_excerpt - block = self._render_block(marker, source, excerpt) + block = self._render_block( + marker, source, excerpt, label_applicability=label_applicability + ) if self._combined_count(prepared, block) > budget: compressed = self._compress_excerpt( source, @@ -458,7 +589,9 @@ 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 + ) if self._combined_count(prepared, compressed_block) <= budget: excerpt = compressed block = compressed_block @@ -470,10 +603,13 @@ def _fit( excerpt, prepared, budget, + label_applicability, ) if excerpt is None: return None - block = self._render_block(marker, source, excerpt) + block = self._render_block( + marker, source, excerpt, label_applicability=label_applicability + ) truncated = True if truncated: self._append_reason(drop_reasons, "ITEM_TRUNCATED") @@ -541,6 +677,7 @@ def _largest_fitting_excerpt( excerpt: str, prepared: list[_PreparedItem], budget: int, + label_applicability: bool = False, ) -> str | None: low = 1 high = len(excerpt) @@ -550,7 +687,9 @@ 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 + ) if self._combined_count(prepared, block) <= budget: best = candidate low = midpoint + 1 @@ -570,6 +709,8 @@ def _render_block( marker: str, source: SelectedEvidence, excerpt: str, + *, + label_applicability: bool = False, ) -> str: channels = ",".join(sorted(source.candidate.channels)) lines = [ @@ -579,6 +720,8 @@ def _render_block( ), excerpt, ] + if label_applicability and source.applicability == "needs_revalidation": + lines.append("Needs revalidation: " + "; ".join(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)}") @@ -625,11 +768,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/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..df48cb7 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,41 @@ 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": + 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_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 +407,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 +561,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 +594,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 +616,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 +631,132 @@ 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 = [] + 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 +1011,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..136b609 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", diff --git a/docs/release/v7/inventory.md b/docs/release/v7/inventory.md index cc683e0..0fa57b1 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: **75**. Resource templates: **10**. v6 mappings: **64**. Atomic requirements: **579**. ## Release ledger @@ -69,14 +69,14 @@ Production entry point: `daem0nmcp.server:main`. Registered tools: **75**. Resou | `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_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` | 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 | @@ -154,6 +154,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..fb97263 100644 --- a/docs/release/v7/requirements.json +++ b/docs/release/v7/requirements.json @@ -2540,6 +2540,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 +2830,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 +2850,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 +2870,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 +3040,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']", @@ -3040,6 +3090,16 @@ "package": "daem0nmcp.api.v7.models", "status": "pending" }, + { + "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 required 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']", @@ -4600,6 +4660,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 +5798,7 @@ "apps", "dev", "graph", + "late-interaction", "local", "models-hosted", "models-local", @@ -5754,9 +5825,9 @@ "tests/test_legacy_vector_migration_guards.py" ], "observed_surface": { - "atomic_requirements": 572, + "atomic_requirements": 579, "cli_commands": 21, - "dependency_profiles": 10, + "dependency_profiles": 11, "hooks": 15, "registered_tools": 75, "resource_templates": 10 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_federated_retrieval.py b/tests/api_v7/test_federated_retrieval.py index 19e1ae9..d413f4c 100644 --- a/tests/api_v7/test_federated_retrieval.py +++ b/tests/api_v7/test_federated_retrieval.py @@ -6,6 +6,7 @@ import tempfile import threading import unittest +from dataclasses import replace from datetime import datetime, timezone from pathlib import Path @@ -168,6 +169,45 @@ 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) + @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_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..35e48f6 --- /dev/null +++ b/tests/api_v7/test_memory_learning_writes.py @@ -0,0 +1,573 @@ +from __future__ import annotations + +import json +import sqlite3 +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 _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_path_policy.py b/tests/api_v7/test_path_policy.py index e68af7c..57d9db4 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() @@ -603,7 +616,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 +708,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" @@ -703,6 +723,26 @@ def _seeded(tool: str, base: dict[str, Any], path: tuple, kind: object, text: st 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] @@ -876,6 +916,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) diff --git a/tests/api_v7/test_pinned_handlers.py b/tests/api_v7/test_pinned_handlers.py index 3bb5f98..e9ff1dc 100644 --- a/tests/api_v7/test_pinned_handlers.py +++ b/tests/api_v7/test_pinned_handlers.py @@ -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, }, ), ( @@ -1290,6 +1300,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 +1593,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 +1782,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_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..7c5dc43 100644 --- a/tests/api_v7/test_tool_manifest.py +++ b/tests/api_v7/test_tool_manifest.py @@ -148,6 +148,7 @@ "include_archived", "token_budget", "rerank", + "intent", }, "memory_store": { "workspace_id", @@ -159,6 +160,8 @@ "relative_file_path", "happened_at", "procedure_steps", + "informed_by", + "code_refs", "idempotency_key", "preflight_token", }, @@ -168,6 +171,9 @@ "outcome_text", "worked", "happened_at", + "informed_by", + "verification", + "rebind_code", "idempotency_key", }, "system_health": {"workspace_id", "include_components"}, 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_code_bindings.py b/tests/test_code_bindings.py new file mode 100644 index 0000000..2523267 --- /dev/null +++ b/tests/test_code_bindings.py @@ -0,0 +1,182 @@ +"""Repository binding capture and validity semantics.""" + +from __future__ import annotations + +import hashlib +import os +from types import SimpleNamespace + +import pytest + +from daem0nmcp.api.v7 import discovery_operations +from daem0nmcp.code_bindings import ( + BindingEvaluator, + CodeBindingError, + 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") + + +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",) + + +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_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(CodeBindingError, match="size limit"): + capture_bindings(workspace, [("large.txt", None)]) + + +def test_unavailable_parser_is_unverifiable(workspace, monkeypatch): + 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")]) + evaluator = BindingEvaluator() + assert evaluator.evaluate(workspace.root, context).applicability == "current" + monkeypatch.setattr( + discovery_operations, + "default_code_indexer_factory", + lambda: SimpleNamespace(available=False), + ) + result = evaluator.evaluate(workspace.root, context) + 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 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..3e9e38c --- /dev/null +++ b/tests/test_coding_memory_eval.py @@ -0,0 +1,162 @@ +"""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_retention_keeps_more_required_procedure_facts(self): + arms = self.first["arms"] + self.assertGreater( + arms["retention_apply"]["retention"]["required_fact_retention"], + arms["baseline"]["retention"]["required_fact_retention"], + ) + + 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_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..ecc905d 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: @@ -1136,7 +1137,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..d0df8b0 --- /dev/null +++ b/tests/test_retrieval_retention.py @@ -0,0 +1,562 @@ +"""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.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_identifiers_extract_dotted_snake_case_paths_and_camel_case(self): + self.assertEqual( + frozenset( + { + "pkg.handle_call", + "handle_call", + "src/cache.py", + "py", + "snake_case", + "camelCase", + } + ), + query_identifiers( + "inspect pkg.handle_call src/cache.py snake_case camelCase plain Upper" + ), + ) + self.assertEqual(frozenset(), query_identifiers("ordinary words Upper")) + self.assertEqual(frozenset({"src/"}), query_identifiers("src/")) + + 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_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_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_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"): + unchanged = compose_federated_results( + results, + replace(query, intent=intent), + retention_mode="apply", + ) + self.assertEqual(baseline.items, unchanged.items) + self.assertEqual(baseline.rendered_context, unchanged.rendered_context) + 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..332faca 100644 --- a/tests/test_v7_schema_upgrade.py +++ b/tests/test_v7_schema_upgrade.py @@ -222,6 +222,209 @@ 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,) + + +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/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" From d0464b0912ef38b034eba7515bf812a6cd9e1cba Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 00:54:33 -0400 Subject: [PATCH 03/25] feat(mcp): restore guided covenant and core gateway ergonomics --- .claude/skills/daem0nmcp-protocol/SKILL.md | 36 +- .../skills/openspec-daem0n-bridge/SKILL.md | 47 +- .claude/skills/summon_daem0n/SKILL.md | 18 +- .gitattributes | 2 + .opencode/commands/commune.md | 6 +- .opencode/commands/counsel.md | 6 +- .opencode/commands/inscribe.md | 12 +- .opencode/commands/recall.md | 4 + .opencode/plugins/daem0n.ts | 50 +- AGENTS.md | 42 +- CHANGELOG.md | 6 +- README.md | 84 +-- Summon_Daem0n.md | 52 +- Summon_Daem0n_OpenCode.md | 43 +- autoresearch.sh | 16 + benchmarks/autoresearch_benchmark.py | 33 ++ daem0nmcp/api/v7/application.py | 78 ++- daem0nmcp/api/v7/ceremony.py | 287 +++++++++ daem0nmcp/api/v7/composition.py | 17 +- daem0nmcp/api/v7/fastmcp.py | 44 +- daem0nmcp/api/v7/gateway_operations.py | 203 +++++++ daem0nmcp/api/v7/intelligence_operations.py | 2 +- daem0nmcp/api/v7/middleware.py | 17 +- daem0nmcp/api/v7/models.py | 241 +++++--- daem0nmcp/api/v7/pinned.py | 149 +++-- daem0nmcp/api/v7/policy.py | 6 +- daem0nmcp/api/v7/production.py | 92 ++- daem0nmcp/api/v7/resource_repository.py | 3 +- daem0nmcp/api/v7/resources.py | 23 +- daem0nmcp/api/v7/responses.py | 32 +- daem0nmcp/api/v7/rule_trigger_operations.py | 3 +- daem0nmcp/api/v7/runtime_services.py | 2 +- daem0nmcp/api/v7/tools.py | 128 ++-- daem0nmcp/claude_hooks/pre_edit.py | 14 +- daem0nmcp/claude_hooks/session_start.py | 6 +- daem0nmcp/claude_hooks/stop.py | 22 +- daem0nmcp/config.py | 2 + daem0nmcp/opencode_assets/commands/commune.md | 6 +- daem0nmcp/opencode_assets/commands/counsel.md | 6 +- .../opencode_assets/commands/inscribe.md | 12 +- daem0nmcp/opencode_assets/commands/recall.md | 4 + daem0nmcp/opencode_assets/daem0n.ts | 50 +- docs/index.html | 12 +- docs/multi-repo-setup.md | 49 +- docs/release/v7/inventory.md | 62 +- docs/release/v7/requirements.json | 132 ++++- tests/api_v7/test_application.py | 3 +- tests/api_v7/test_ceremony.py | 554 ++++++++++++++++++ tests/api_v7/test_composition.py | 2 +- tests/api_v7/test_docs_hooks.py | 39 +- tests/api_v7/test_factory.py | 2 +- tests/api_v7/test_foreground_configuration.py | 19 + tests/api_v7/test_gateway_operations.py | 287 +++++++++ tests/api_v7/test_models.py | 104 ++++ tests/api_v7/test_path_policy.py | 11 +- tests/api_v7/test_pinned_handlers.py | 5 +- tests/api_v7/test_policy.py | 2 + tests/api_v7/test_process_client.py | 11 +- .../test_process_workspace_isolation_sweep.py | 43 +- tests/api_v7/test_production.py | 18 +- tests/api_v7/test_resources.py | 4 +- tests/api_v7/test_responses.py | 69 +++ tests/api_v7/test_rule_trigger_operations.py | 2 +- tests/api_v7/test_tool_manifest.py | 85 ++- tests/test_backward_compat.py | 10 +- tests/test_claude_hooks_pre_edit.py | 14 +- tests/test_claude_hooks_session_start.py | 4 + tests/test_claude_hooks_stop.py | 12 +- tests/test_workflow_tools.py | 5 +- 69 files changed, 2814 insertions(+), 652 deletions(-) create mode 100755 autoresearch.sh create mode 100644 benchmarks/autoresearch_benchmark.py create mode 100644 daem0nmcp/api/v7/ceremony.py create mode 100644 daem0nmcp/api/v7/gateway_operations.py create mode 100644 tests/api_v7/test_ceremony.py create mode 100644 tests/api_v7/test_gateway_operations.py 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/.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 4e59f45..06e2ec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,14 @@ - 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. ### Changed +- 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 results and callable challenge remedies; `DAEM0NMCP_TOOL_SURFACE=full` lists every tool. +- Gateway results preserve legitimate user text, including host-looking paths, after the target's typed output validation. Invalid argument diagnostics omit unsafe field names rather than breaking the public error envelope. - 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 `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. +- 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 5ac7db1..69f9679 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 @@ -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`. @@ -1225,6 +1228,11 @@ 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. 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..296288e --- /dev/null +++ b/benchmarks/autoresearch_benchmark.py @@ -0,0 +1,33 @@ +"""Offline autoresearch entrypoint for the existing coding-memory workload.""" + +from __future__ import annotations + +import asyncio +import math + +from benchmarks.coding_memory_eval import run_evaluation + + +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"], + } + 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/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/fastmcp.py b/daem0nmcp/api/v7/fastmcp.py index 50d2245..3689fb4 100644 --- a/daem0nmcp/api/v7/fastmcp.py +++ b/daem0nmcp/api/v7/fastmcp.py @@ -16,7 +16,8 @@ from ... import __version__ from ...covenant import invocation_scope_var from .errors import ErrorCode -from .middleware import V7InvocationMiddleware +from .gateway_operations import GATEWAY_TOOL_NAMES, bind_gateway_handlers +from .middleware import ListedToolsMiddleware, V7InvocationMiddleware from .registry import ToolSpec, V7Manifest from .responses import ResponseFactory from .task_dispatcher import DurableTaskDispatcher, TaskDispatcherError, TaskView @@ -554,6 +555,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 +580,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 +627,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 +666,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/gateway_operations.py b/daem0nmcp/api/v7/gateway_operations.py new file mode 100644 index 0000000..5f56919 --- /dev/null +++ b/daem0nmcp/api/v7/gateway_operations.py @@ -0,0 +1,203 @@ +"""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_.\[\]-]*") + + +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 (ValidationError, TypeError) as exc: + field_errors = ( + [ + FieldError( + field=_argument_field(error["loc"]), + code="INVALID_VALUE", + message="Invalid value.", + ) + for error in exc.errors()[:50] + ] + if isinstance(exc, ValidationError) + else [ + FieldError( + field="arguments", + code="INVALID_VALUE", + message="Invalid value.", + ) + ] + ) + 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) + + meta = ResponseMeta.model_validate(result["meta"]) + response = response.with_covenant(meta.covenant) + if result["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, + ) + + 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 1e66f2e..22630f1 100644 --- a/daem0nmcp/api/v7/models.py +++ b/daem0nmcp/api/v7/models.py @@ -94,6 +94,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 +528,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 +701,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 +712,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 +749,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 +828,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 @@ -899,6 +992,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 727e402..62d46d4 100644 --- a/daem0nmcp/api/v7/pinned.py +++ b/daem0nmcp/api/v7/pinned.py @@ -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) @@ -514,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: @@ -683,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: @@ -810,7 +821,7 @@ async def memory_store( 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, @@ -853,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: @@ -880,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.", @@ -934,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 " @@ -987,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: @@ -1135,7 +1194,6 @@ def build_pinned_handlers( __all__ = [ - "ArgumentNormalizer", "BriefingService", "HealthService", "IdempotencyConflict", @@ -1146,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 0b6d276..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,7 +40,6 @@ ResourceReader, ResourceReadRequest, ResourceRow, - RuleView, ) 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 03dfacf..4886b95 100644 --- a/daem0nmcp/api/v7/runtime_services.py +++ b/daem0nmcp/api/v7/runtime_services.py @@ -66,6 +66,7 @@ from .models import ( CapabilityState, CitationManifestEntry, + PreflightGuidance, RecordSummary, RetrievalData, TokenUsage, @@ -90,7 +91,6 @@ from .tasks import await_task_terminal from .tools import ( HealthData, - PreflightGuidance, SessionBriefData, SessionBriefInput, ) diff --git a/daem0nmcp/api/v7/tools.py b/daem0nmcp/api/v7/tools.py index 5f3e42c..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,12 +51,14 @@ RelativePath, RetrievalData, RuleId, + RuleView, SelectionToken, Tag, ToolName, TriggerId, UniqueTags, UserJsonObject, + UserMediumText, UserText, UtcDateTime, VersionId, @@ -65,7 +69,7 @@ ) 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, @@ -84,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), @@ -115,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( @@ -396,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 @@ -1065,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) @@ -1152,7 +1164,7 @@ class MemoryStoreInput(WireModel): 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: @@ -1185,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): @@ -1321,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): @@ -1332,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: @@ -1344,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): @@ -1360,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): @@ -1381,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): @@ -1402,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): @@ -1456,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): @@ -1474,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): @@ -1497,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): @@ -1515,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): @@ -1528,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): @@ -1589,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): @@ -1603,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): @@ -1644,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): @@ -1661,7 +1673,7 @@ class MemoryDuplicatesPreviewInput(WireModel): class MemoryDuplicatesCleanupInput(MemoryDuplicatesPreviewInput): selection_token: SelectionToken - preflight_token: PreflightToken + preflight_token: PreflightToken | None = None class MemoryCompactionPreviewInput(WireModel): @@ -1683,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): @@ -1718,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: @@ -1736,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: @@ -1748,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): @@ -1771,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): @@ -1785,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): @@ -1812,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] @@ -1825,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, @@ -2036,6 +2050,7 @@ def build_argument_normalizer() -> V7ArgumentNormalizer: _READ_ONLY_TOOLS = frozenset( { + "daem0n_tools_search", "session_brief", "memory_preflight", "memory_capture_list", @@ -2135,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", @@ -2235,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", @@ -2366,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/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/config.py b/daem0nmcp/config.py index d3fa5f3..e1f941f 100644 --- a/daem0nmcp/config.py +++ b/daem0nmcp/config.py @@ -39,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 ) 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/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 0fa57b1..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: **579**. +Production entry point: `daem0nmcp.server:main`. Registered tools: **77**. Resource templates: **10**. v6 mappings: **64**. Atomic requirements: **586**. ## Release ledger @@ -24,50 +24,52 @@ 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]), `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 | @@ -76,29 +78,29 @@ Production entry point: `daem0nmcp.server:main`. Registered tools: **75**. Resou | `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` | `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_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 diff --git a/docs/release/v7/requirements.json b/docs/release/v7/requirements.json index fb97263..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", @@ -3101,7 +3171,7 @@ "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 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", @@ -3191,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", @@ -3231,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", @@ -3501,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", @@ -3651,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", @@ -3701,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", @@ -3881,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", @@ -3941,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", @@ -4151,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", @@ -4201,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", @@ -4291,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", @@ -5825,11 +5895,11 @@ "tests/test_legacy_vector_migration_guards.py" ], "observed_surface": { - "atomic_requirements": 579, + "atomic_requirements": 586, "cli_commands": 21, "dependency_profiles": 11, "hooks": 15, - "registered_tools": 75, + "registered_tools": 77, "resource_templates": 10 }, "release": "v7", 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_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_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..e54c6b1 --- /dev/null +++ b/tests/api_v7/test_gateway_operations.py @@ -0,0 +1,287 @@ +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.gateway_operations import ( + CORE_LISTED_TOOLS, + GATEWAY_TOOL_NAMES, + bind_gateway_handlers, +) +from daem0nmcp.api.v7.models import 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", + ): + 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_hidden_destructive_challenge_retries_exact_gateway_remedy( + self, + ) -> None: + async with Client(self.server) as client: + preview = await client.call_tool( + "daem0n_tool_call", + {**self.scope, "tool": "memory_prune_preview", "arguments": {}}, + ) + preview_envelope = preview.structured_content + self.assertTrue(preview_envelope["ok"], preview_envelope) + arguments = { + "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"] + self.assertEqual("daem0n_tool_call", remedy["tool"]) + self.assertEqual( + self.workspace.workspace_id, remedy["arguments"]["workspace_id"] + ) + self.assertEqual("memory_prune", remedy["arguments"]["tool"]) + self.assertNotIn("workspace_id", remedy["arguments"]["arguments"]) + self.assertEqual( + counsel["preflight_token"], + remedy["arguments"]["arguments"]["preflight_token"], + ) + retried = await client.call_tool(remedy["tool"], remedy["arguments"]) + self.assertTrue( + retried.structured_content["ok"], retried.structured_content + ) + 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.INVALID_ARGUMENT), + (RuntimeError("private runtime detail"), ErrorCode.INTERNAL_ERROR), + ): + with self.subTest(exception=type(exception).__name__): + adapter.reset_mock() + 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"}, + ) + adapter.assert_awaited_once_with(**self.scope, query="x") + self.assertFalse(result.ok) + self.assertEqual(expected, result.error.code) + self.assertNotIn("private", result.model_dump_json()) diff --git a/tests/api_v7/test_models.py b/tests/api_v7/test_models.py index 4d2e78a..8d30763 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) diff --git a/tests/api_v7/test_path_policy.py b/tests/api_v7/test_path_policy.py index 57d9db4..bb21e62 100644 --- a/tests/api_v7/test_path_policy.py +++ b/tests/api_v7/test_path_policy.py @@ -521,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, @@ -719,6 +720,11 @@ 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 @@ -772,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) @@ -806,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 @@ -989,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 e9ff1dc..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", @@ -1150,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)) 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_tool_manifest.py b/tests/api_v7/test_tool_manifest.py index 7c5dc43..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", @@ -177,6 +178,8 @@ "idempotency_key", }, "system_health": {"workspace_id", "include_components"}, + "daem0n_tools_search": {"query", "limit"}, + "daem0n_tool_call": {"workspace_id", "tool", "arguments"}, } @@ -259,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) @@ -333,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 @@ -348,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 ( @@ -399,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_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_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 = { From 4462fd6be9df53f5681da3319c6c71fc7766904d Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 08:11:43 -0400 Subject: [PATCH 04/25] fix(v7): preserve discovery retries and tighten verification --- CHANGELOG.md | 6 +- README.md | 9 +- benchmarks/autoresearch_benchmark.py | 152 +++++++- daem0nmcp/api/v7/discovery_operations.py | 27 +- docs/v7-operations.md | 57 ++- tests/api_v7/test_discovery_operations.py | 432 ++++++++++++++++++---- tests/api_v7/test_gateway_operations.py | 146 +++++--- 7 files changed, 684 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06e2ec3..648eb7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,12 @@ - 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. +- 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 - 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 results and callable challenge remedies; `DAEM0NMCP_TOOL_SURFACE=full` lists every tool. -- Gateway results preserve legitimate user text, including host-looking paths, after the target's typed output validation. Invalid argument diagnostics omit unsafe field names rather than breaking the public error envelope. +- 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. - 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. diff --git a/README.md b/README.md index 69f9679..e2f0577 100644 --- a/README.md +++ b/README.md @@ -1230,8 +1230,13 @@ 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. It -uses the project virtual environment and excludes wall-clock latency. +(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 diff --git a/benchmarks/autoresearch_benchmark.py b/benchmarks/autoresearch_benchmark.py index 296288e..3767b17 100644 --- a/benchmarks/autoresearch_benchmark.py +++ b/benchmarks/autoresearch_benchmark.py @@ -4,8 +4,157 @@ import asyncio import math +import tempfile +from pathlib import Path +from typing import Any -from benchmarks.coding_memory_eval import run_evaluation +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: @@ -22,6 +171,7 @@ async def main() -> None: "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}") diff --git a/daem0nmcp/api/v7/discovery_operations.py b/daem0nmcp/api/v7/discovery_operations.py index 702ba8a..99885fe 100644 --- a/daem0nmcp/api/v7/discovery_operations.py +++ b/daem0nmcp/api/v7/discovery_operations.py @@ -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,10 @@ 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 any( + word in str(error).casefold() for word in ("busy", "locked") + ): + return DiscoveryOperationError("DATABASE_IN_USE") return DiscoveryOperationError("CAPABILITY_DEGRADED") @@ -2082,7 +2086,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 +2101,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) diff --git a/docs/v7-operations.md b/docs/v7-operations.md index b344dfa..e2e08ee 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( @@ -164,9 +188,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 +319,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 +473,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/tests/api_v7/test_discovery_operations.py b/tests/api_v7/test_discovery_operations.py index 452ccc8..c420f80 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, @@ -1030,10 +1070,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 +1081,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 +1107,63 @@ 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="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, + ), + ) + + 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 +1174,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 +1206,260 @@ 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_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_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 +1495,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_gateway_operations.py b/tests/api_v7/test_gateway_operations.py index e54c6b1..4ae245b 100644 --- a/tests/api_v7/test_gateway_operations.py +++ b/tests/api_v7/test_gateway_operations.py @@ -194,6 +194,7 @@ async def test_unsafe_extra_argument_keys_return_sanitized_field_errors( 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( @@ -215,50 +216,88 @@ async def test_unsafe_extra_argument_keys_return_sanitized_field_errors( self.assertEqual("INVALID_VALUE", diagnostic.code) self.assertNotIn(json.dumps(key)[1:-1], json.dumps(envelope)) - async def test_hidden_destructive_challenge_retries_exact_gateway_remedy( - self, - ) -> None: - async with Client(self.server) as client: - preview = await client.call_tool( - "daem0n_tool_call", - {**self.scope, "tool": "memory_prune_preview", "arguments": {}}, - ) - preview_envelope = preview.structured_content - self.assertTrue(preview_envelope["ok"], preview_envelope) - arguments = { - "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"] - self.assertEqual("daem0n_tool_call", remedy["tool"]) - self.assertEqual( - self.workspace.workspace_id, remedy["arguments"]["workspace_id"] - ) - self.assertEqual("memory_prune", remedy["arguments"]["tool"]) - self.assertNotIn("workspace_id", remedy["arguments"]["arguments"]) - self.assertEqual( - counsel["preflight_token"], - remedy["arguments"]["arguments"]["preflight_token"], - ) - retried = await client.call_tool(remedy["tool"], remedy["arguments"]) - self.assertTrue( - retried.structured_content["ok"], retried.structured_content - ) - self.assertEqual("memory_prune", retried.structured_content["data"]["tool"]) + 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() @@ -271,9 +310,9 @@ async def test_binder_sanitizes_adapter_exceptions(self) -> None: for exception, expected in ( (TypeError("private argument detail"), ErrorCode.INVALID_ARGUMENT), (RuntimeError("private runtime detail"), ErrorCode.INTERNAL_ERROR), + (ValueError("private runtime detail"), ErrorCode.INTERNAL_ERROR), ): with self.subTest(exception=type(exception).__name__): - adapter.reset_mock() adapter.side_effect = exception with patch("daem0nmcp.api.v7.responses._LOGGER"): result = await handlers["daem0n_tool_call"]( @@ -281,7 +320,22 @@ async def test_binder_sanitizes_adapter_exceptions(self) -> None: tool="memory_search_text", arguments={"query": "x"}, ) - adapter.assert_awaited_once_with(**self.scope, 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) + if expected == ErrorCode.INVALID_ARGUMENT: + self.assertEqual( + [ + FieldError( + field="arguments", + code="INVALID_VALUE", + message="Invalid value.", + ) + ], + result.error.field_errors, + ) + else: + self.assertEqual([], result.error.field_errors) self.assertNotIn("private", result.model_dump_json()) From 1cbe4da7bd7ab887a0bb2235175505f0c0f932f6 Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 10:08:03 -0400 Subject: [PATCH 05/25] fix: preserve filename affinity for qualified path queries; real composition smoke failed before/passed after under 256-token budget, unrelated paths stay unprioritized; 38 tests/28 subtests and scoped Ruff pass; frozen nDCG unchanged, no quality-gain claim Result: {"status":"keep","coding_memory_ndcg":0.821533827904,"baseline_ndcg":0.671913456093,"outcome_reuse_ndcg":1,"provenance_chain_ndcg":1,"required_fact_retention":1,"stale_flag_recall":1,"false_flag_rate":0,"rendered_tokens_mean":95.733333333333,"listed_tool_count":9,"guided_write_calls":1,"destructive_challenge_calls":2,"strict_explicit_write_calls":3} --- CHANGELOG.md | 1 + daem0nmcp/retrieval/composer.py | 4 ++- docs/v7-operations.md | 7 +++++ tests/test_retrieval_retention.py | 51 +++++++++++++++++++++---------- 4 files changed, 46 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 648eb7f..0c9acda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - 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 preserves a queried path's filename, so `implement` and `debug` recall reserve context for code-bound evidence whether the query names `src/http/router.ts` or `router.ts`. - 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. diff --git a/daem0nmcp/retrieval/composer.py b/daem0nmcp/retrieval/composer.py index 80c56a8..cac6a63 100644 --- a/daem0nmcp/retrieval/composer.py +++ b/daem0nmcp/retrieval/composer.py @@ -167,9 +167,11 @@ def query_identifiers(text: str) -> frozenset[str]: ): identifiers.add(token) if "." in token or "/" in token: - segment = re.split(r"[./]", token)[-1] + segment = token.rsplit("/", 1)[-1] if segment: identifiers.add(segment) + if "." in segment: + identifiers.add(segment.rsplit(".", 1)[-1]) return frozenset(identifiers) diff --git a/docs/v7-operations.md b/docs/v7-operations.md index e2e08ee..f9ded54 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -175,6 +175,13 @@ idempotency key. For authorized federation, `memory_recall` accepts `linked_workspace_ids`; returned evidence remains attributed to its source workspace. +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` retains the same filename affinity as `router.ts`; qualified +symbols also retain their final name. Unrelated paths do not gain that priority. +This affects context selection, not stored-record retention or authorization. + ## Resources and diagnostics The four bounded data resources require an authorized workspace session: diff --git a/tests/test_retrieval_retention.py b/tests/test_retrieval_retention.py index d0df8b0..627bb0a 100644 --- a/tests/test_retrieval_retention.py +++ b/tests/test_retrieval_retention.py @@ -42,24 +42,43 @@ def _sources(): class RetentionComposerTests(unittest.TestCase): - def test_identifiers_extract_dotted_snake_case_paths_and_camel_case(self): - self.assertEqual( - frozenset( - { - "pkg.handle_call", - "handle_call", - "src/cache.py", - "py", - "snake_case", - "camelCase", - } - ), - query_identifiers( - "inspect pkg.handle_call src/cache.py snake_case camelCase plain Upper" + 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), + ("router.ts", True), + ("pkg.Router.apply_routes", True), + ("apply_routes", True), + ("src/http/other.ts", False), + ("unrelated.Symbol", False), + ("src/", False), ) - self.assertEqual(frozenset(), query_identifiers("ordinary words Upper")) - self.assertEqual(frozenset({"src/"}), query_identifiers("src/")) + 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()) From 2e22addb1d708e1c4aec778e4470af91dc719ddb Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 10:15:42 -0400 Subject: [PATCH 06/25] fix: validate binding snapshot content instead of trusting mtime/size; same-size timestamp-preserving edit was falsely current before and needs_revalidation after. File and symbol regression covers edit/restoration, 16 focused tests and scoped Ruff pass. Frozen ranking metrics unchanged. Result: {"status":"keep","coding_memory_ndcg":0.821533827904,"baseline_ndcg":0.671913456093,"outcome_reuse_ndcg":1,"provenance_chain_ndcg":1,"required_fact_retention":1,"stale_flag_recall":1,"false_flag_rate":0,"rendered_tokens_mean":95.733333333333,"listed_tool_count":9,"guided_write_calls":1,"destructive_challenge_calls":2,"strict_explicit_write_calls":3} --- CHANGELOG.md | 1 + daem0nmcp/code_bindings.py | 16 ++++++++-------- docs/v7-operations.md | 6 ++++++ tests/test_code_bindings.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c9acda..66a5d9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - 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 preserves a queried path's filename, so `implement` and `debug` recall reserve context for code-bound evidence whether the query names `src/http/router.ts` or `router.ts`. +- Code-binding validity checks re-read bounded source content before reusing parsed snapshots, detecting same-size edits even when file timestamps are preserved. - 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. diff --git a/daem0nmcp/code_bindings.py b/daem0nmcp/code_bindings.py index 5c785ef..c929a5f 100644 --- a/daem0nmcp/code_bindings.py +++ b/daem0nmcp/code_bindings.py @@ -206,11 +206,11 @@ def capture_bindings( class BindingEvaluator: - """Reuse bounded source snapshots only while their filesystem identity agrees.""" + """Reuse bounded parsed snapshots only while their source content agrees.""" def __init__(self) -> None: self._lock = Lock() - self._cache: OrderedDict[tuple[Path, int, int], _FileSnapshot] = OrderedDict() + self._cache: OrderedDict[Path, _FileSnapshot] = OrderedDict() def _current_fingerprint(self, root: Path, binding: CodeBinding) -> str: try: @@ -220,15 +220,15 @@ def _current_fingerprint(self, root: Path, binding: CodeBinding) -> str: raise CodeBindingError("bound file is missing or inaccessible") from exc if stat.st_size > _MAX_BINDING_FILE_BYTES: raise CodeBindingError("bound file exceeds size limit") - key = (path, stat.st_mtime_ns, stat.st_size) + source = _read_source(path) with self._lock: - snapshot = self._cache.get(key) - if snapshot is None: - snapshot = _FileSnapshot(_read_source(path)) - self._cache[key] = snapshot + snapshot = self._cache.get(path) + if snapshot is None or snapshot.source != source: + snapshot = _FileSnapshot(source) + self._cache[path] = snapshot if len(self._cache) > 256: self._cache.popitem(last=False) - self._cache.move_to_end(key) + self._cache.move_to_end(path) if ( binding.qualified_name is not None and binding.qualified_name in snapshot.fingerprints diff --git a/docs/v7-operations.md b/docs/v7-operations.md index f9ded54..5fb635c 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -182,6 +182,12 @@ reserve evidence capacity for `implement` and `debug` requests. A query naming symbols also retain their final name. Unrelated paths do not gain that priority. This affects context selection, not stored-record retention or authorization. +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`. This detects a changed binding; it is not a +trusted test receipt or proof that the recorded guidance remains correct. + ## Resources and diagnostics The four bounded data resources require an authorized workspace session: diff --git a/tests/test_code_bindings.py b/tests/test_code_bindings.py index 2523267..b9e4328 100644 --- a/tests/test_code_bindings.py +++ b/tests/test_code_bindings.py @@ -66,6 +66,34 @@ def test_file_binding_detects_edit_and_deletion(workspace): 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" From 0185f2489a69263944360b4f4371a749a207516c Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 10:24:48 -0400 Subject: [PATCH 07/25] fix: preserve supplied path/symbol scope using boundary-suffix affinity; full-path queries no longer promote other directories sharing the filename, or unrelated same-named symbols. Actual 256-token composition before retained wrong and right; after excludes wrong while retaining right for full/partial path and qualified symbol. 38 tests/38 subtests, scoped Ruff pass; canonical quality unchanged. Result: {"status":"keep","coding_memory_ndcg":0.821533827904,"baseline_ndcg":0.671913456093,"outcome_reuse_ndcg":1,"provenance_chain_ndcg":1,"required_fact_retention":1,"stale_flag_recall":1,"false_flag_rate":0,"rendered_tokens_mean":95.733333333333,"listed_tool_count":9,"guided_write_calls":1,"destructive_challenge_calls":2,"strict_explicit_write_calls":3} --- CHANGELOG.md | 2 +- daem0nmcp/retrieval/composer.py | 15 +++++++-------- docs/v7-operations.md | 8 +++++--- tests/test_retrieval_retention.py | 5 +++++ 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66a5d9c..e9f9d00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ - 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 preserves a queried path's filename, so `implement` and `debug` recall reserve context for code-bound evidence whether the query names `src/http/router.ts` or `router.ts`. +- Intent-aware evidence retention matches full or boundary-suffix paths and qualified symbols, preserving scope instead of promoting unrelated same-named files or symbols; bare filename and symbol queries remain supported. - Code-binding validity checks re-read bounded source content before reusing parsed snapshots, detecting same-size edits even when file timestamps are preserved. - 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. diff --git a/daem0nmcp/retrieval/composer.py b/daem0nmcp/retrieval/composer.py index cac6a63..05ddec9 100644 --- a/daem0nmcp/retrieval/composer.py +++ b/daem0nmcp/retrieval/composer.py @@ -166,12 +166,6 @@ def query_identifiers(text: str) -> frozenset[str]: character.isupper() for character in token[1:] ): identifiers.add(token) - if "." in token or "/" in token: - segment = token.rsplit("/", 1)[-1] - if segment: - identifiers.add(segment) - if "." in segment: - identifiers.add(segment.rsplit(".", 1)[-1]) return frozenset(identifiers) @@ -542,12 +536,17 @@ def _priority(source: SelectedEvidence, retention: RetentionPolicy | None) -> bo if retention is None or retention.intent not in {"implement", "debug"}: return False return bool(source.procedure_steps) or any( - relative.rsplit("/", 1)[-1] in retention.identifiers + relative == identifier + or relative.endswith("/" + identifier) or ( qualified_name is not None - and qualified_name.rsplit(".", 1)[-1] in retention.identifiers + 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( diff --git a/docs/v7-operations.md b/docs/v7-operations.md index 5fb635c..a086c41 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -178,9 +178,11 @@ workspace. 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` retains the same filename affinity as `router.ts`; qualified -symbols also retain their final name. Unrelated paths do not gain that priority. -This affects context selection, not stored-record retention or authorization. +`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. Repository code-binding checks validate current bounded source content rather than trusting file size and modification time. Parsed fingerprints are reused diff --git a/tests/test_retrieval_retention.py b/tests/test_retrieval_retention.py index 627bb0a..93dcf97 100644 --- a/tests/test_retrieval_retention.py +++ b/tests/test_retrieval_retention.py @@ -55,10 +55,15 @@ def test_path_and_symbol_queries_preserve_bound_evidence_under_budget(self): ) cases = ( ("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), + ("admin/router.ts", False), + ("other.Router.apply_routes", False), ("unrelated.Symbol", False), ("src/", False), ) From 2ab660b9e5647ca4ba19c79d97f7943c7b61451e Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 10:33:46 -0400 Subject: [PATCH 08/25] perf: reuse byte-identical explore shadow composition in direct/federated recall. Real before/after committed-source smoke preserved exact output digests and diagnostics; federated synchronous calls fell3->2 (one fewer baseline composition), non-explore shadow retained distinct variant. 50tests/53subtests and scoped Ruff pass. Primary metric unchanged; frozen workload has no explore-intent requests. Result: {"status":"keep","coding_memory_ndcg":0.821533827904,"baseline_ndcg":0.671913456093,"outcome_reuse_ndcg":1,"provenance_chain_ndcg":1,"required_fact_retention":1,"stale_flag_recall":1,"false_flag_rate":0,"rendered_tokens_mean":95.733333333333,"listed_tool_count":9,"guided_write_calls":1,"destructive_challenge_calls":2,"strict_explicit_write_calls":3} --- CHANGELOG.md | 1 + daem0nmcp/api/v7/federated_retrieval.py | 3 ++- daem0nmcp/retrieval/service.py | 2 +- docs/v7-operations.md | 3 +++ 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f9d00..9324494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - 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, preserving scope instead of promoting unrelated same-named files or symbols; bare filename and symbol queries remain supported. - Code-binding validity checks re-read bounded source content before reusing parsed snapshots, detecting same-size edits even when file timestamps are preserved. +- Explore-intent shadow retention reuses the identical primary composition in direct and federated recall, avoiding duplicate packing while preserving evidence and diagnostics. - 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. diff --git a/daem0nmcp/api/v7/federated_retrieval.py b/daem0nmcp/api/v7/federated_retrieval.py index b98ad1b..c627988 100644 --- a/daem0nmcp/api/v7/federated_retrieval.py +++ b/daem0nmcp/api/v7/federated_retrieval.py @@ -485,7 +485,8 @@ def compose_federated_results( retained = None result = ( retained - if retention_mode == "apply" and retained is not None + if retained is not None + and (retention_mode == "apply" or query.intent == "explore") else compose_federated_results( results, query, label_applicability=label_applicability ) diff --git a/daem0nmcp/retrieval/service.py b/daem0nmcp/retrieval/service.py index df48cb7..1c33f47 100644 --- a/daem0nmcp/retrieval/service.py +++ b/daem0nmcp/retrieval/service.py @@ -365,7 +365,7 @@ async def retrieve( if retention is not None: retained = composition retention_failed = False - if self._retention_mode == "shadow": + if self._retention_mode == "shadow" and retention.intent != "explore": try: retained = await self._composer.compose_async( candidates.selected, diff --git a/docs/v7-operations.md b/docs/v7-operations.md index a086c41..cdd7a9e 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -183,6 +183,9 @@ 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. +Explore requests retain baseline packing in every retention mode; shadow mode +reuses that identical composition instead of packing the same evidence twice. +Other intents still compute their distinct shadow variant. Repository code-binding checks validate current bounded source content rather than trusting file size and modification time. Parsed fingerprints are reused From 96d1a1c5c60054f03661f64648dfbfbbd66e8050 Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 10:57:22 -0400 Subject: [PATCH 09/25] perf: batch resolved binding reads with raw-byte budget and bounded source cache. Actual12contexts/1,050,000B readcalls12->1 (single-sample19.22ms->4.00ms, not guaranteed speedup); actualSQLite recall two distinct records shares one read and nextrecall detects restoredmtime edit withfresh read. Budget0/exactEOF/CRLF/partialrefusal/growthprobe/cacheeviction smokes pass.91affectedtests/79subtests plus13batchtests andscopedRuffpass. Qualityunchanged. Result: {"status":"keep","coding_memory_ndcg":0.821533827904,"baseline_ndcg":0.671913456093,"outcome_reuse_ndcg":1,"provenance_chain_ndcg":1,"required_fact_retention":1,"stale_flag_recall":1,"false_flag_rate":0,"rendered_tokens_mean":95.733333333333,"listed_tool_count":9,"guided_write_calls":1,"destructive_challenge_calls":2,"strict_explicit_write_calls":3} --- CHANGELOG.md | 2 +- daem0nmcp/code_bindings.py | 89 ++++++++++++-- daem0nmcp/retrieval/service.py | 29 ++--- docs/v7-operations.md | 12 +- tests/test_code_bindings.py | 204 +++++++++++++++++++++++++++++++++ 5 files changed, 311 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9324494..45f2702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ - 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, preserving scope instead of promoting unrelated same-named files or symbols; bare filename and symbol queries remain supported. -- Code-binding validity checks re-read bounded source content before reusing parsed snapshots, detecting same-size edits even when file timestamps are preserved. +- 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. - Explore-intent shadow retention reuses the identical primary composition in direct and federated recall, avoiding duplicate packing while preserving evidence and diagnostics. - 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. diff --git a/daem0nmcp/code_bindings.py b/daem0nmcp/code_bindings.py index c929a5f..d99613e 100644 --- a/daem0nmcp/code_bindings.py +++ b/daem0nmcp/code_bindings.py @@ -5,7 +5,9 @@ import hashlib import re from collections import OrderedDict -from collections.abc import Iterable, Mapping, Sequence +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 threading import Lock @@ -14,6 +16,8 @@ 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") @@ -125,14 +129,34 @@ class _FileSnapshot: fingerprints: dict[str | None, str] = field(default_factory=dict) -def _read_source(path: Path) -> bytes: +@dataclass(slots=True) +class _ReadBatch: + remaining_bytes: int + sources: dict[Path, bytes] = field(default_factory=dict) + + +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 _UnverifiableBindingError("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(_MAX_BINDING_FILE_BYTES + 1) + source = source_file.read(limit) + overflow = source_file.read(1) if len(source) == limit else b"" except OSError as exc: raise CodeBindingError("bound file cannot be read") from exc - if len(source) > _MAX_BINDING_FILE_BYTES: + 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 CodeBindingError("bound file exceeds size limit") + if remaining is not None and raw_bytes > remaining: + raise _UnverifiableBindingError("binding read budget is exhausted") return source.replace(b"\r\n", b"\n") @@ -206,11 +230,46 @@ def capture_bindings( class BindingEvaluator: - """Reuse bounded parsed snapshots only while their source content agrees.""" + """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: @@ -220,15 +279,29 @@ def _current_fingerprint(self, root: Path, binding: CodeBinding) -> str: raise CodeBindingError("bound file is missing or inaccessible") from exc if stat.st_size > _MAX_BINDING_FILE_BYTES: raise CodeBindingError("bound file exceeds size limit") - source = _read_source(path) + 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 _UnverifiableBindingError("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 - if len(self._cache) > 256: - self._cache.popitem(last=False) + self._cached_source_bytes += len(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) if ( binding.qualified_name is not None and binding.qualified_name in snapshot.fingerprints diff --git a/daem0nmcp/retrieval/service.py b/daem0nmcp/retrieval/service.py index 1c33f47..dc48a78 100644 --- a/daem0nmcp/retrieval/service.py +++ b/daem0nmcp/retrieval/service.py @@ -638,21 +638,22 @@ async def _evaluate_bindings( def evaluate() -> tuple[SelectedEvidence, ...]: evaluated = [] - 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, + 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 ) - if evaluation is not None - else source - ) return tuple(evaluated) try: diff --git a/docs/v7-operations.md b/docs/v7-operations.md index cdd7a9e..a6420eb 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -190,8 +190,16 @@ Other intents still compute their distinct shadow variant. 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`. This detects a changed binding; it is not a -trusted test receipt or proof that the recorded guidance remains correct. +produce `needs_revalidation`. + +Each recall shares successfully read files within its worker-local validation +batch; the next recall reads them afresh. 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. Missing and oversized files +retain their existing `needs_revalidation` classification. 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. ## Resources and diagnostics diff --git a/tests/test_code_bindings.py b/tests/test_code_bindings.py index b9e4328..db7544c 100644 --- a/tests/test_code_bindings.py +++ b/tests/test_code_bindings.py @@ -4,6 +4,8 @@ import hashlib import os +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier from types import SimpleNamespace import pytest @@ -208,3 +210,205 @@ def test_git_head_metadata(workspace, monkeypatch): 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) + assert result.applicability == "needs_revalidation" + assert result.changed == ("notes.md",) + + +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) + if grow_past_file_limit: + assert result.applicability == "needs_revalidation" + assert result.changed == ("notes.md",) + else: + assert result.applicability == "unverifiable" + assert result.changed == () + assert evaluator.evaluate(workspace.root, small).applicability == "unverifiable" + assert ( + evaluator.evaluate(workspace.root, context).applicability + == "needs_revalidation" + ) + 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) From 09412c6b2788511ff0ec6f5f50e7688d5476bf8d Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 11:09:06 -0400 Subject: [PATCH 10/25] fix: normalize relative query-path separators/dot/repeatedslashes while retainingexplicit./rootanchor. Actual256-token composition previouslylost ./ anddoubleslash targets andbackslash falselypromotedotherdir; nowaliases retainrightonly. ./router.ts remainsroot-only(notnestedrouter.ts), barefilename staysbroader.38tests/54subtests, scopedRuffpass; frozenqualityunchanged. Result: {"status":"keep","coding_memory_ndcg":0.821533827904,"baseline_ndcg":0.671913456093,"outcome_reuse_ndcg":1,"provenance_chain_ndcg":1,"required_fact_retention":1,"stale_flag_recall":1,"false_flag_rate":0,"rendered_tokens_mean":95.733333333333,"listed_tool_count":9,"guided_write_calls":1,"destructive_challenge_calls":2,"strict_explicit_write_calls":3} --- CHANGELOG.md | 2 +- daem0nmcp/retrieval/composer.py | 13 ++++++++++--- docs/v7-operations.md | 4 ++++ tests/test_retrieval_retention.py | 8 ++++++++ 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f2702..29c398d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ - 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, preserving scope instead of promoting unrelated same-named files or symbols; bare filename and symbol queries remain supported. +- 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. - 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. - Explore-intent shadow retention reuses the identical primary composition in direct and federated recall, avoiding duplicate packing while preserving evidence and diagnostics. - Local dense projection cleanup closes staging collection persistence before deleting it, preventing leaked SQLite handles on failed or cancelled builds. diff --git a/daem0nmcp/retrieval/composer.py b/daem0nmcp/retrieval/composer.py index 05ddec9..b6af877 100644 --- a/daem0nmcp/retrieval/composer.py +++ b/daem0nmcp/retrieval/composer.py @@ -12,6 +12,7 @@ import re 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 @@ -161,7 +162,13 @@ class RetentionPolicy: def query_identifiers(text: str) -> frozenset[str]: identifiers: set[str] = set() - for token in re.split(r"[^\w./-]+", text): + 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:] ): @@ -536,8 +543,8 @@ def _priority(source: SelectedEvidence, retention: RetentionPolicy | None) -> bo if retention is None or retention.intent not in {"implement", "debug"}: return False return bool(source.procedure_steps) or any( - relative == identifier - or relative.endswith("/" + identifier) + relative == identifier.removeprefix("./") + or (not identifier.startswith("./") and relative.endswith("/" + identifier)) or ( qualified_name is not None and ( diff --git a/docs/v7-operations.md b/docs/v7-operations.md index a6420eb..90f6f7c 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -183,6 +183,10 @@ 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. Other intents still compute their distinct shadow variant. diff --git a/tests/test_retrieval_retention.py b/tests/test_retrieval_retention.py index 93dcf97..74f0eb9 100644 --- a/tests/test_retrieval_retention.py +++ b/tests/test_retrieval_retention.py @@ -55,6 +55,9 @@ def test_path_and_symbol_queries_preserve_bound_evidence_under_budget(self): ) 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), @@ -62,10 +65,15 @@ def test_path_and_symbol_queries_preserve_bound_evidence_under_budget(self): ("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: From e60ea1695051cd0234f1ff26a4af7e92bc410e2d Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 11:23:34 -0400 Subject: [PATCH 11/25] fix: include AST-owned decorator envelopes in symbol fingerprints via optionalinternalmetadata, preserve navigation/projectionidentity. Actualstaticmethod->classmethod changed runtimepayload->TypeError butbinding remainedcurrentbefore; afterneeds_revalidation. Producer nav/relationshipsidentical.112binding/learningtests,81index/discoverytests+1skip, scopedRuffgreen. Frozenqualityunchanged; olderdecoratedbindings conservativelyrevalidateonce. Result: {"status":"keep","coding_memory_ndcg":0.821533827904,"baseline_ndcg":0.671913456093,"outcome_reuse_ndcg":1,"provenance_chain_ndcg":1,"required_fact_retention":1,"stale_flag_recall":1,"false_flag_rate":0,"rendered_tokens_mean":95.733333333333,"listed_tool_count":9,"guided_write_calls":1,"destructive_challenge_calls":2,"strict_explicit_write_calls":3} --- CHANGELOG.md | 1 + daem0nmcp/code_bindings.py | 9 +- daem0nmcp/code_indexer.py | 19 +++ docs/v7-operations.md | 6 + tests/test_code_bindings.py | 229 ++++++++++++++++++++++++++++++++++++ 5 files changed, 262 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29c398d..758f3b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - 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. - 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. - Explore-intent shadow retention reuses the identical primary composition in direct and federated recall, avoiding duplicate packing while preserving evidence and diagnostics. - 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. diff --git a/daem0nmcp/code_bindings.py b/daem0nmcp/code_bindings.py index d99613e..80b71fe 100644 --- a/daem0nmcp/code_bindings.py +++ b/daem0nmcp/code_bindings.py @@ -180,11 +180,16 @@ def _fingerprint(path: Path, root: Path, source: bytes, name: str | None) -> str raise _UnverifiableBindingError("code parser returned invalid entities") if name not in (entity.get("qualified_name"), entity.get("name")): continue - start, end = entity.get("line_start"), entity.get("line_end") + navigation_start, end = entity.get("line_start"), entity.get("line_end") + start = entity.get("binding_line_start", navigation_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 not 1 <= start <= end <= len(lines) + or isinstance(end, bool) + or not 1 <= start <= navigation_start <= end <= len(lines) ): raise _UnverifiableBindingError( "code parser returned invalid line bounds" 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/docs/v7-operations.md b/docs/v7-operations.md index 90f6f7c..24139d0 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -205,6 +205,12 @@ 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. +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`. + ## Resources and diagnostics The four bounded data resources require an authorized workspace session: diff --git a/tests/test_code_bindings.py b/tests/test_code_bindings.py index db7544c..3231e42 100644 --- a/tests/test_code_bindings.py +++ b/tests/test_code_bindings.py @@ -10,6 +10,7 @@ import pytest +from daem0nmcp import code_indexer from daem0nmcp.api.v7 import discovery_operations from daem0nmcp.code_bindings import ( BindingEvaluator, @@ -117,6 +118,234 @@ def test_symbol_binding_distinguishes_sibling_and_bound_edit(workspace): assert evaluator.evaluate(workspace.root, context).changed == ("sample.py::target",) +@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)]) From 58d103c53a73c170588ad5cd8d04a99c2f7ff543 Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 11:33:12 -0400 Subject: [PATCH 12/25] =?UTF-8?q?fix:=20normalizecitationlookalikes=20onmi?= =?UTF-8?q?sseddirect/federatedrenderingfields.=20Actualbefore:=20docs/[E9?= =?UTF-8?q?9].md=20labeldenieddirectcontext;=20federationshippedASCII[E99]?= =?UTF-8?q?=20withmanifest[E1]only.=20After:=20directreturnscontext,label/?= =?UTF-8?q?contentfullwidth=EF=BC=BBE99=EF=BC=BD,=20manifestonlyreal[E1].?= =?UTF-8?q?=20Rawtypedpaths/canonicalrecords=20preserved,=20offsets/tokenc?= =?UTF-8?q?ountregressionspass.40tests57subtests,=20scopedRuffgreen;=20qua?= =?UTF-8?q?lityunchanged.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Result: {"status":"keep","coding_memory_ndcg":0.821533827904,"baseline_ndcg":0.671913456093,"outcome_reuse_ndcg":1,"provenance_chain_ndcg":1,"required_fact_retention":1,"stale_flag_recall":1,"false_flag_rate":0,"rendered_tokens_mean":95.733333333333,"listed_tool_count":9,"guided_write_calls":1,"destructive_challenge_calls":2,"strict_explicit_write_calls":3} --- CHANGELOG.md | 1 + daem0nmcp/api/v7/federated_retrieval.py | 9 ++-- daem0nmcp/retrieval/composer.py | 5 +- docs/v7-operations.md | 5 ++ tests/api_v7/test_federated_retrieval.py | 62 ++++++++++++++++++++++++ tests/test_retrieval_composer.py | 38 +++++++++++++++ 6 files changed, 116 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 758f3b7..f73aa86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - 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. - 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. - Explore-intent shadow retention reuses the identical primary composition in direct and federated recall, avoiding duplicate packing while preserving evidence and diagnostics. - 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. diff --git a/daem0nmcp/api/v7/federated_retrieval.py b/daem0nmcp/api/v7/federated_retrieval.py index c627988..71736b6 100644 --- a/daem0nmcp/api/v7/federated_retrieval.py +++ b/daem0nmcp/api/v7/federated_retrieval.py @@ -17,6 +17,7 @@ EvidenceComposer, RetentionPolicy, SelectedEvidence, + _normalize_evidence_text, query_identifiers, ) from ...retrieval.runtime import CoreTokenizer @@ -312,7 +313,7 @@ def _fit_excerpt( 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 @@ -522,11 +523,13 @@ def compose_federated_results( def applicability_label(item: FederatedCandidate) -> str: if label_applicability and item.applicability == "needs_revalidation": - return "\nNeeds revalidation: " + "; ".join(item.changed_bindings) + 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) ] diff --git a/daem0nmcp/retrieval/composer.py b/daem0nmcp/retrieval/composer.py index b6af877..a7b471e 100644 --- a/daem0nmcp/retrieval/composer.py +++ b/daem0nmcp/retrieval/composer.py @@ -729,7 +729,10 @@ def _render_block( excerpt, ] if label_applicability and source.applicability == "needs_revalidation": - lines.append("Needs revalidation: " + "; ".join(source.changed_bindings)) + 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)}") diff --git a/docs/v7-operations.md b/docs/v7-operations.md index 24139d0..be2fa15 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -205,6 +205,11 @@ 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. +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. + 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 diff --git a/tests/api_v7/test_federated_retrieval.py b/tests/api_v7/test_federated_retrieval.py index d413f4c..32d838e 100644 --- a/tests/api_v7/test_federated_retrieval.py +++ b/tests/api_v7/test_federated_retrieval.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import sqlite3 import tempfile import threading @@ -208,6 +209,67 @@ def test_validity_and_utility_survive_federated_composition(self) -> None: 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/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 From dd44020e8947a72d34b3419918cb79d0c71ce9c0 Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 11:46:01 -0400 Subject: [PATCH 13/25] =?UTF-8?q?feat:=20query-sidecontent/rationale=20tie?= =?UTF-8?q?r=20beforetag-assistedmatches,=20fullqueryBM25unchanged/noindex?= =?UTF-8?q?rebuild.=20Actualheldoutprovider:=20meaningfulbody1=20outranks1?= =?UTF-8?q?6-tagdraft2,=20body6/rationale5=20outrankmixed4;=20tag-only3=20?= =?UTF-8?q?andties7/8preserved,=20allrawscoresbit-identical.54existing/new?= =?UTF-8?q?tests24subtests,=20Ruffgreen.=20FrozenprimarydidNOTimprove?= =?UTF-8?q?=E2=80=94earlier.85predictionnotobserved=20andnotclaimed.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Result: {"status":"keep","coding_memory_ndcg":0.821533827904,"baseline_ndcg":0.671913456093,"outcome_reuse_ndcg":1,"provenance_chain_ndcg":1,"required_fact_retention":1,"stale_flag_recall":1,"false_flag_rate":0,"rendered_tokens_mean":95.733333333333,"listed_tool_count":9,"guided_write_calls":1,"destructive_challenge_calls":2,"strict_explicit_write_calls":3} --- CHANGELOG.md | 1 + daem0nmcp/retrieval/providers.py | 9 +++- docs/v7-operations.md | 6 +++ tests/test_retrieval_lexical.py | 84 ++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f73aa86..452e278 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - 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, resisting tag repetition without changing BM25 scores, metadata discovery, index configuration or rebuild requirements. - 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. diff --git a/daem0nmcp/retrieval/providers.py b/daem0nmcp/retrieval/providers.py index b6eb53e..b21c754 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,11 @@ 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, score ASC, document.record_id ASC LIMIT ? """, ( + "{content rationale}: (" + fts_query + ")", query.workspace_id, generation, fts_query, diff --git a/docs/v7-operations.md b/docs/v7-operations.md index be2fa15..0e2c24f 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -175,6 +175,12 @@ 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. Ordinary BM25 and +record-ID tie-breaking still order each group. Tags remain searchable, and the +existing precision-first query ladder remains unchanged; no projection rebuild +is required for this query-side ordering change. + 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 diff --git a/tests/test_retrieval_lexical.py b/tests/test_retrieval_lexical.py index ecc905d..da2965a 100644 --- a/tests/test_retrieval_lexical.py +++ b/tests/test_retrieval_lexical.py @@ -377,6 +377,90 @@ 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) + self.assertEqual( + sorted( + result.candidates[:2], + key=lambda item: (-item.raw_score, item.evidence.record_id), + ), + list(result.candidates[:2]), + ) + 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_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.""" From 2624241cd97caf537f8e123792c8565dc7eb257c Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 11:51:58 -0400 Subject: [PATCH 14/25] fix: sortretainedfederatedchannels canonically. ActualseparatePythonhashseeds1/2/3 beforeproduceddifferentitem/manifestchannelarrays; afterallbyte-identicaldense,graph,lexical.24tests47subtests, scopedRuffgreen. Primarymetricsunchanged; canonicallexical-onlyseed0harnessdoesnotcovermultiproviderrestartdrift. Result: {"status":"keep","coding_memory_ndcg":0.821533827904,"baseline_ndcg":0.671913456093,"outcome_reuse_ndcg":1,"provenance_chain_ndcg":1,"required_fact_retention":1,"stale_flag_recall":1,"false_flag_rate":0,"rendered_tokens_mean":95.733333333333,"listed_tool_count":9,"guided_write_calls":1,"destructive_challenge_calls":2,"strict_explicit_write_calls":3} --- CHANGELOG.md | 1 + daem0nmcp/api/v7/federated_retrieval.py | 2 +- docs/v7-operations.md | 3 +++ tests/api_v7/test_federated_retrieval.py | 28 ++++++++++++++++++++++++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 452e278..08837ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - 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. - 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. diff --git a/daem0nmcp/api/v7/federated_retrieval.py b/daem0nmcp/api/v7/federated_retrieval.py index 71736b6..7240633 100644 --- a/daem0nmcp/api/v7/federated_retrieval.py +++ b/daem0nmcp/api/v7/federated_retrieval.py @@ -413,7 +413,7 @@ def _compose_retained( citation=evidence.citation, record=original.record, bounded_excerpt=evidence.excerpt, - channels=list(evidence.channels), + channels=sorted(evidence.channels), score=evidence.score, status=evidence.status, evidence_refs=list(original.evidence_refs), diff --git a/docs/v7-operations.md b/docs/v7-operations.md index 0e2c24f..78eb6ac 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -180,6 +180,9 @@ selected full-text expression, then tag-assisted matches. Ordinary BM25 and record-ID tie-breaking still order each group. Tags remain searchable, and the existing 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 diff --git a/tests/api_v7/test_federated_retrieval.py b/tests/api_v7/test_federated_retrieval.py index 32d838e..c208b7c 100644 --- a/tests/api_v7/test_federated_retrieval.py +++ b/tests/api_v7/test_federated_retrieval.py @@ -27,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 From 6058d81e61def29cbba71e951473b4be8a8e695e Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 12:07:12 -0400 Subject: [PATCH 15/25] Parser absence/failure and unusable entities now map symbol-bound writes to existing retryable CAPABILITY_DEGRADED, while unsupported extensions/missing symbols remain INVALID_ARGUMENT. Real absent-package store smoke changed INVALID_ARGUMENT to CAPABILITY_DEGRADED and production public envelope retryable=True. Atomic store/rebind regressions: 115 passed, 29 subtests; scoped Ruff clean after formatter. Frozen nDCG unchanged; kept for independently verified correctness, not ranking gain. Result: {"status":"keep","coding_memory_ndcg":0.821533827904,"baseline_ndcg":0.671913456093,"outcome_reuse_ndcg":1,"provenance_chain_ndcg":1,"required_fact_retention":1,"stale_flag_recall":1,"false_flag_rate":0,"rendered_tokens_mean":95.733333333333,"listed_tool_count":9,"guided_write_calls":1,"destructive_challenge_calls":2,"strict_explicit_write_calls":3} --- CHANGELOG.md | 1 + daem0nmcp/api/v7/runtime_services.py | 5 + daem0nmcp/code_bindings.py | 35 +++-- docs/v7-operations.md | 6 + tests/api_v7/test_memory_learning_writes.py | 136 ++++++++++++++++++++ 5 files changed, 173 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08837ea..1769c3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - 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. - 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. diff --git a/daem0nmcp/api/v7/runtime_services.py b/daem0nmcp/api/v7/runtime_services.py index 4886b95..9f95c10 100644 --- a/daem0nmcp/api/v7/runtime_services.py +++ b/daem0nmcp/api/v7/runtime_services.py @@ -25,6 +25,7 @@ from ...bounded_workers import BoundedWorkerBusyError, BoundedWorkerPool from ...code_bindings import ( CodeBindingError, + CodeBindingUnavailableError, binding_refs_from_context, capture_bindings, ) @@ -667,6 +668,8 @@ def _store_sync( 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] @@ -879,6 +882,8 @@ def _record_outcome_sync( 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"] = [ diff --git a/daem0nmcp/code_bindings.py b/daem0nmcp/code_bindings.py index 80b71fe..535371c 100644 --- a/daem0nmcp/code_bindings.py +++ b/daem0nmcp/code_bindings.py @@ -30,6 +30,14 @@ class _UnverifiableBindingError(CodeBindingError): pass +class CodeBindingUnavailableError(_UnverifiableBindingError): + """The server cannot currently verify a symbol binding.""" + + +class CodeBindingReferenceError(_UnverifiableBindingError): + """The requested symbol reference is unsupported.""" + + @runtime_checkable class _BindingCodeIndexer(Protocol): @property @@ -138,7 +146,7 @@ class _ReadBatch: 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 _UnverifiableBindingError("binding read budget is exhausted") + raise CodeBindingUnavailableError("binding read budget is exhausted") limit = ( min(_MAX_BINDING_FILE_BYTES, remaining) if remaining is not None @@ -156,7 +164,7 @@ def _read_source(path: Path, batch: _ReadBatch | None = None) -> bytes: if raw_bytes > _MAX_BINDING_FILE_BYTES: raise CodeBindingError("bound file exceeds size limit") if remaining is not None and raw_bytes > remaining: - raise _UnverifiableBindingError("binding read budget is exhausted") + raise CodeBindingUnavailableError("binding read budget is exhausted") return source.replace(b"\r\n", b"\n") @@ -168,16 +176,18 @@ def _fingerprint(path: Path, root: Path, source: bytes, name: str | None) -> str try: producer = default_code_indexer_factory() if not isinstance(producer, _BindingCodeIndexer) or not producer.available: - raise _UnverifiableBindingError("code parser is unavailable") + raise CodeBindingUnavailableError("code parser is unavailable") supported = producer.get_supported_extensions() if path.suffix.lower() not in supported: - raise _UnverifiableBindingError("bound symbol extension is unsupported") + raise CodeBindingReferenceError("bound symbol extension is unsupported") entities = producer.index_source_strict(path, root, source) lines = source.split(b"\n") chunks = [] for entity in entities: if not isinstance(entity, Mapping): - raise _UnverifiableBindingError("code parser returned invalid entities") + raise CodeBindingUnavailableError( + "code parser returned invalid entities" + ) if name not in (entity.get("qualified_name"), entity.get("name")): continue navigation_start, end = entity.get("line_start"), entity.get("line_end") @@ -191,14 +201,14 @@ def _fingerprint(path: Path, root: Path, source: bytes, name: str | None) -> str or isinstance(end, bool) or not 1 <= start <= navigation_start <= end <= len(lines) ): - raise _UnverifiableBindingError( + raise CodeBindingUnavailableError( "code parser returned invalid line bounds" ) chunks.append(b"\n".join(lines[start - 1 : end])) except _UnverifiableBindingError: raise except Exception as exc: - raise _UnverifiableBindingError("code parser failed") from exc + raise CodeBindingUnavailableError("code parser failed") from exc if not chunks: raise CodeBindingError("bound symbol is missing") return hashlib.sha256(b"\n".join(chunks)).hexdigest() @@ -288,7 +298,7 @@ def _current_fingerprint(self, root: Path, binding: CodeBinding) -> str: 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 _UnverifiableBindingError("binding read budget is exhausted") + raise CodeBindingUnavailableError("binding read budget is exhausted") source = _read_source(path, batch) if batch is not None: batch.sources[path] = source @@ -313,8 +323,13 @@ def _current_fingerprint(self, root: Path, binding: CodeBinding) -> str: ): from .api.v7.discovery_operations import default_code_indexer_factory - if not getattr(default_code_indexer_factory(), "available", False): - raise _UnverifiableBindingError("code parser is unavailable") + 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 binding.qualified_name not in snapshot.fingerprints: snapshot.fingerprints[binding.qualified_name] = _fingerprint( path, root, snapshot.source, binding.qualified_name diff --git a/docs/v7-operations.md b/docs/v7-operations.md index 78eb6ac..f5ef40b 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -225,6 +225,12 @@ 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, or returns unusable entities; failed captures write no memory +or outcome events. Restore the parsing capability before obtaining fresh counsel +and retrying with the same idempotency key. Missing symbols and unsupported +symbol-file extensions remain `INVALID_ARGUMENT`, requiring a corrected request. + ## Resources and diagnostics The four bounded data resources require an authorized workspace session: diff --git a/tests/api_v7/test_memory_learning_writes.py b/tests/api_v7/test_memory_learning_writes.py index 35e48f6..fe5397b 100644 --- a/tests/api_v7/test_memory_learning_writes.py +++ b/tests/api_v7/test_memory_learning_writes.py @@ -2,6 +2,8 @@ import json import sqlite3 +import subprocess +import sys import tempfile import unittest from contextlib import closing @@ -267,6 +269,140 @@ async def test_missing_code_ref_and_invalid_rebind_are_rejected(self) -> None: ) self.assertEqual(1, 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"), + ) + 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, From 3104a02ebfca05ecf3dde4b89ff8254f4089c6dd Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 12:18:05 -0400 Subject: [PATCH 16/25] Content/rationale tier uses existing field weights with tag contribution zero; tag-assisted tier and original diagnostic BM25 preserved. Real held-out both-body-match case reverses spam ahead of useful evidence while exact score mappings stay identical. Frozen harness nDCG .821533827904 -> .85, baseline .671913456093 -> .721913456093; all other reported metrics unchanged. 55tests/24subtests, scoped Ruff green; frozen fixtures/goldens untouched. Removed incidental strict retention-arm superiority assertion after better baseline also achieved fact retention1; no replacement or weakened golden. Result: {"status":"keep","coding_memory_ndcg":0.85,"baseline_ndcg":0.721913456093,"outcome_reuse_ndcg":1,"provenance_chain_ndcg":1,"required_fact_retention":1,"stale_flag_recall":1,"false_flag_rate":0,"rendered_tokens_mean":95.733333333333,"listed_tool_count":9,"guided_write_calls":1,"destructive_challenge_calls":2,"strict_explicit_write_calls":3} --- CHANGELOG.md | 2 +- daem0nmcp/retrieval/providers.py | 7 ++- docs/v7-operations.md | 10 ++-- tests/test_coding_memory_eval.py | 7 --- tests/test_retrieval_lexical.py | 96 +++++++++++++++++++++++++++++--- 5 files changed, 102 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1769c3b..c07ef8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ - 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, resisting tag repetition without changing BM25 scores, metadata discovery, index configuration or rebuild requirements. +- 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. diff --git a/daem0nmcp/retrieval/providers.py b/daem0nmcp/retrieval/providers.py index b21c754..ea12295 100644 --- a/daem0nmcp/retrieval/providers.py +++ b/daem0nmcp/retrieval/providers.py @@ -280,7 +280,12 @@ def _search_connection( ) AS document ON document.document_rowid="{expected_table}".rowid WHERE "{expected_table}" MATCH ? - ORDER BY content_match DESC, 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 ? """, ( diff --git a/docs/v7-operations.md b/docs/v7-operations.md index f5ef40b..14a6e41 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -176,10 +176,12 @@ idempotency key. For authorized federation, `memory_recall` accepts workspace. Lexical recall first ranks records whose content or rationale satisfy the -selected full-text expression, then tag-assisted matches. Ordinary BM25 and -record-ID tie-breaking still order each group. Tags remain searchable, and the -existing precision-first query ladder remains unchanged; no projection rebuild -is required for this query-side ordering change. +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. diff --git a/tests/test_coding_memory_eval.py b/tests/test_coding_memory_eval.py index 3e9e38c..fb65e6f 100644 --- a/tests/test_coding_memory_eval.py +++ b/tests/test_coding_memory_eval.py @@ -72,13 +72,6 @@ def test_validity_detects_bound_symbols_not_sibling_edits(self): {"stale_flag_recall": 1.0, "false_flag_rate": 0.0}, validity["symbol"] ) - def test_retention_keeps_more_required_procedure_facts(self): - arms = self.first["arms"] - self.assertGreater( - arms["retention_apply"]["retention"]["required_fact_retention"], - arms["baseline"]["retention"]["required_fact_retention"], - ) - def test_all_arms_and_metadata_are_reported(self): self.assertEqual( { diff --git a/tests/test_retrieval_lexical.py b/tests/test_retrieval_lexical.py index da2965a..e0cd82c 100644 --- a/tests/test_retrieval_lexical.py +++ b/tests/test_retrieval_lexical.py @@ -415,13 +415,6 @@ async def test_content_and_rationale_matches_precede_tag_only_matches(self): {candidate.evidence.record_id for candidate in result.candidates[:2]}, ) self.assertEqual(tag_only, result.candidates[2].evidence.record_id) - self.assertEqual( - sorted( - result.candidates[:2], - key=lambda item: (-item.raw_score, item.evidence.record_id), - ), - list(result.candidates[:2]), - ) bounded = await provider.search(query, 2) self.assertEqual(result.candidates[:2], bounded.candidates) @@ -434,6 +427,95 @@ async def test_content_and_rationale_matches_precede_tag_only_matches(self): [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 d1a00b1f42930ffa22b97ee8f55b0ec08971a8fb Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 12:23:32 -0400 Subject: [PATCH 17/25] Exact qualified identities precede unique short aliases; unrelated same-name definitions no longer silently aggregate. Real parser before ambiguous A.target/B.target capture succeeded, after rejects CodeBindingReferenceError; qualified sibling edit stays current. Existing alias becoming ambiguous is honestly unverifiable, same-identity spans preserved; store/rebind reject atomically. 124tests/30subtests plus scoped Ruff clean after formatting. Frozen nDCG .85 and all secondary metrics unchanged; kept for independent correctness. Result: {"status":"keep","coding_memory_ndcg":0.85,"baseline_ndcg":0.721913456093,"outcome_reuse_ndcg":1,"provenance_chain_ndcg":1,"required_fact_retention":1,"stale_flag_recall":1,"false_flag_rate":0,"rendered_tokens_mean":95.733333333333,"listed_tool_count":9,"guided_write_calls":1,"destructive_challenge_calls":2,"strict_explicit_write_calls":3} --- CHANGELOG.md | 1 + daem0nmcp/code_bindings.py | 23 ++++- docs/v7-operations.md | 5 + tests/api_v7/test_memory_learning_writes.py | 6 ++ tests/test_code_bindings.py | 108 ++++++++++++++++++++ 5 files changed, 140 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c07ef8b..d01be3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - 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`. - 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. diff --git a/daem0nmcp/code_bindings.py b/daem0nmcp/code_bindings.py index 535371c..6d37a34 100644 --- a/daem0nmcp/code_bindings.py +++ b/daem0nmcp/code_bindings.py @@ -182,14 +182,31 @@ def _fingerprint(path: Path, root: Path, source: bytes, name: str | None) -> str raise CodeBindingReferenceError("bound symbol extension is unsupported") entities = producer.index_source_strict(path, root, source) lines = source.split(b"\n") - chunks = [] + exact = [] + aliases = [] for entity in entities: if not isinstance(entity, Mapping): raise CodeBindingUnavailableError( "code parser returned invalid entities" ) - if name not in (entity.get("qualified_name"), entity.get("name")): - continue + if entity.get("qualified_name") == name: + exact.append(entity) + elif entity.get("name") == name: + aliases.append(entity) + selected = exact or aliases + if not exact and aliases: + identities = set() + for entity in aliases: + identity = entity.get("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.get("line_start"), entity.get("line_end") start = entity.get("binding_line_start", navigation_start) if ( diff --git a/docs/v7-operations.md b/docs/v7-operations.md index 14a6e41..19541b0 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -232,6 +232,11 @@ unavailable, fails, or returns unusable entities; failed captures write no memor or outcome events. Restore the parsing capability before obtaining fresh counsel and retrying with the same idempotency key. Missing symbols and unsupported symbol-file extensions remain `INVALID_ARGUMENT`, requiring a corrected request. +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. ## Resources and diagnostics diff --git a/tests/api_v7/test_memory_learning_writes.py b/tests/api_v7/test_memory_learning_writes.py index fe5397b..a33f306 100644 --- a/tests/api_v7/test_memory_learning_writes.py +++ b/tests/api_v7/test_memory_learning_writes.py @@ -347,6 +347,12 @@ async def test_real_parser_failure_and_reference_errors_are_atomic(self) -> None ("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): diff --git a/tests/test_code_bindings.py b/tests/test_code_bindings.py index 3231e42..ce83b26 100644 --- a/tests/test_code_bindings.py +++ b/tests/test_code_bindings.py @@ -15,6 +15,8 @@ from daem0nmcp.code_bindings import ( BindingEvaluator, CodeBindingError, + CodeBindingReferenceError, + CodeBindingUnavailableError, binding_refs_from_context, capture_bindings, ) @@ -118,6 +120,112 @@ def test_symbol_binding_distinguishes_sibling_and_bound_edit(workspace): 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("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"]) From d53fed1c33fb232d6967f9208123b3dc8e7b5652 Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 12:33:47 -0400 Subject: [PATCH 18/25] Oversize refuses are unverifiable (capture INVALID_ARGUMENT); environmental resolve/stat/read failures unverifiable (capture CAPABILITY_DEGRADED); definite missing/directory/escape remain changed. Actual normalized-identical LF3.6MB->CRLF5.4MB now unverifiable, notfalsechanged. Actual Windows exclusivefilelock now unverifiable/Unavailable capture, restoredsamefile current. Corrected Windows directory PermissionError distinction using observedmode; no extra nominalcapturestat. 146tests/30subtests,1platformskip; scopedRuffgreen afterformatter. Frozen metric .85 unchanged. Result: {"status":"keep","coding_memory_ndcg":0.85,"baseline_ndcg":0.721913456093,"outcome_reuse_ndcg":1,"provenance_chain_ndcg":1,"required_fact_retention":1,"stale_flag_recall":1,"false_flag_rate":0,"rendered_tokens_mean":95.733333333333,"listed_tool_count":9,"guided_write_calls":1,"destructive_challenge_calls":2,"strict_explicit_write_calls":3} --- CHANGELOG.md | 1 + daem0nmcp/code_bindings.py | 43 ++++- docs/v7-operations.md | 21 ++- tests/api_v7/test_memory_learning_writes.py | 52 ++++++ tests/test_code_bindings.py | 172 ++++++++++++++++++-- 5 files changed, 257 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d01be3c..f39b24d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - 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. - 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. diff --git a/daem0nmcp/code_bindings.py b/daem0nmcp/code_bindings.py index 6d37a34..2392b0c 100644 --- a/daem0nmcp/code_bindings.py +++ b/daem0nmcp/code_bindings.py @@ -10,6 +10,7 @@ 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 @@ -31,11 +32,11 @@ class _UnverifiableBindingError(CodeBindingError): class CodeBindingUnavailableError(_UnverifiableBindingError): - """The server cannot currently verify a symbol binding.""" + """The server cannot currently verify a code binding.""" class CodeBindingReferenceError(_UnverifiableBindingError): - """The requested symbol reference is unsupported.""" + """The requested code binding reference is unsupported.""" @runtime_checkable @@ -143,6 +144,19 @@ class _ReadBatch: 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: @@ -157,12 +171,21 @@ def _read_source(path: Path, batch: _ReadBatch | None = None) -> bytes: source = source_file.read(limit) overflow = source_file.read(1) if len(source) == limit else b"" except OSError as exc: - raise CodeBindingError("bound file cannot be read") from 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 CodeBindingError("bound file exceeds size limit") + 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") @@ -245,9 +268,9 @@ def capture_bindings( relative, name = _validate_ref(*ref) try: path = resolve_index_file(workspace.root, workspace.root / relative) - except IndexPathError as exc: - raise CodeBindingError( - "bound file is missing or outside the workspace" + except (IndexPathError, OSError) as exc: + raise _file_error( + "bound file is missing or outside the workspace", exc ) from exc fingerprint = _fingerprint(path, workspace.root, _read_source(path), name) fingerprints.append((relative, name, fingerprint)) @@ -308,9 +331,11 @@ def _current_fingerprint(self, root: Path, binding: CodeBinding) -> str: path = resolve_index_file(root, root / binding.relative_file_path) stat = path.stat() except (IndexPathError, OSError) as exc: - raise CodeBindingError("bound file is missing or inaccessible") from 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 CodeBindingError("bound file exceeds size limit") + 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: diff --git a/docs/v7-operations.md b/docs/v7-operations.md index 19541b0..6c3e42c 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -210,11 +210,13 @@ produce `needs_revalidation`. Each recall shares successfully read files within its worker-local validation batch; the next recall reads them afresh. 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. Missing and oversized files -retain their existing `needs_revalidation` classification. 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. +`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. Citation-like strings stored inside evidence or binding filenames are displayed with neutralized brackets, not interpreted as manifest citations. Direct and @@ -228,10 +230,11 @@ 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, or returns unusable entities; failed captures write no memory -or outcome events. Restore the parsing capability before obtaining fresh counsel -and retrying with the same idempotency key. Missing symbols and unsupported -symbol-file extensions remain `INVALID_ARGUMENT`, requiring a corrected request. +unavailable, fails, returns unusable entities, or source access is temporarily +unavailable; failed captures write no memory or outcome events. Restore the +capability or source access before obtaining fresh counsel and retrying with the +same idempotency key. 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 diff --git a/tests/api_v7/test_memory_learning_writes.py b/tests/api_v7/test_memory_learning_writes.py index a33f306..1b0d4f5 100644 --- a/tests/api_v7/test_memory_learning_writes.py +++ b/tests/api_v7/test_memory_learning_writes.py @@ -269,6 +269,58 @@ async def test_missing_code_ref_and_invalid_rebind_are_rejected(self) -> None: ) 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 _assert_missing_parser_writes(self) -> None: self.assertFalse(default_code_indexer_factory().available) source = self.fixture.root / "handler.py" diff --git a/tests/test_code_bindings.py b/tests/test_code_bindings.py index ce83b26..6d2ace3 100644 --- a/tests/test_code_bindings.py +++ b/tests/test_code_bindings.py @@ -10,7 +10,7 @@ import pytest -from daem0nmcp import code_indexer +from daem0nmcp import code_bindings, code_indexer from daem0nmcp.api.v7 import discovery_operations from daem0nmcp.code_bindings import ( BindingEvaluator, @@ -463,10 +463,151 @@ 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(CodeBindingError, match="size limit"): + 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" @@ -593,8 +734,12 @@ def test_read_batch_rechecks_file_constraints_before_memo(workspace, mutation): with path.open("wb") as output: output.truncate(5 * 1024 * 1024 + 1) result = evaluator.evaluate(workspace.root, context) - assert result.applicability == "needs_revalidation" - assert result.changed == ("notes.md",) + 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): @@ -672,17 +817,16 @@ def grow_before_read(self, mode="r", *args, **kwargs): evaluator = BindingEvaluator() with evaluator.read_batch(budget_bytes=budget): result = evaluator.evaluate(workspace.root, context) - if grow_past_file_limit: - assert result.applicability == "needs_revalidation" - assert result.changed == ("notes.md",) - else: - assert result.applicability == "unverifiable" - assert result.changed == () + assert result.applicability == "unverifiable" + assert result.changed == () assert evaluator.evaluate(workspace.root, small).applicability == "unverifiable" - assert ( - evaluator.evaluate(workspace.root, context).applicability - == "needs_revalidation" - ) + 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" From 8a517c02f468a9e1337739f978a2946b42a2f820 Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 12:40:40 -0400 Subject: [PATCH 19/25] Public RetrievalData now enforces same exact ordered/multiplicity marker parity as internal context. Actual production federated JSON+unbackedE99 acceptedbefore, rejectsValidationError after; real directSQLite recall still current->changed correctly acrosscalls. Fixed one inconsistent handcrafteddiscoveryfixture ratherthanaccommodatinguncitedtext. 78tests/95subtests, scopedRuffclean. Frozenmetric.85 allsecondariesunchanged; independentlyverified correctness. Result: {"status":"keep","coding_memory_ndcg":0.85,"baseline_ndcg":0.721913456093,"outcome_reuse_ndcg":1,"provenance_chain_ndcg":1,"required_fact_retention":1,"stale_flag_recall":1,"false_flag_rate":0,"rendered_tokens_mean":95.733333333333,"listed_tool_count":9,"guided_write_calls":1,"destructive_challenge_calls":2,"strict_explicit_write_calls":3} --- CHANGELOG.md | 1 + daem0nmcp/api/v7/models.py | 3 ++ docs/v7-operations.md | 3 ++ tests/api_v7/test_discovery_operations.py | 4 +- tests/api_v7/test_models.py | 59 +++++++++++++++++++++++ 5 files changed, 69 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f39b24d..5f5a12d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - 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. - 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. diff --git a/daem0nmcp/api/v7/models.py b/daem0nmcp/api/v7/models.py index 22630f1..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: @@ -961,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 diff --git a/docs/v7-operations.md b/docs/v7-operations.md index 6c3e42c..e1e02c5 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -222,6 +222,9 @@ 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. diff --git a/tests/api_v7/test_discovery_operations.py b/tests/api_v7/test_discovery_operations.py index c420f80..c571c2e 100644 --- a/tests/api_v7/test_discovery_operations.py +++ b/tests/api_v7/test_discovery_operations.py @@ -1138,7 +1138,9 @@ def _entity_retrieval_result(self, query, *, record_ids=None): ) return RetrievalData( items=items, - rendered_context="Authenticated entity records", + rendered_context="\n".join( + f"{item.citation} {item.bounded_excerpt}" for item in items + ), citation_manifest=manifest, provider_diagnostics=[ ProviderDiagnostic( diff --git a/tests/api_v7/test_models.py b/tests/api_v7/test_models.py index 8d30763..f68c3ab 100644 --- a/tests/api_v7/test_models.py +++ b/tests/api_v7/test_models.py @@ -563,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( From 36afc1a732cb05b48180a539d6f420848b280801 Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 12:50:45 -0400 Subject: [PATCH 20/25] Outcome and each step honor existing source-aware limits consistently during fit/resequence/export; requested-token baseline stays pre-policy, count/flags/prefixverification preserved. ActualCoreTokenizer256budget now retainsstalewarning+3currentitems194tokens vswarningomittedbefore;600budget same4items212vs476tokens. None/explore unchanged. 91tests/103subtests, scopedRuffclean afterstrictzip/formatter. Frozen .85 andallsecondarymetrics unchanged; independentpackingbenefit. Result: {"status":"keep","coding_memory_ndcg":0.85,"baseline_ndcg":0.721913456093,"outcome_reuse_ndcg":1,"provenance_chain_ndcg":1,"required_fact_retention":1,"stale_flag_recall":1,"false_flag_rate":0,"rendered_tokens_mean":95.733333333333,"listed_tool_count":9,"guided_write_calls":1,"destructive_challenge_calls":2,"strict_explicit_write_calls":3} --- CHANGELOG.md | 1 + daem0nmcp/retrieval/composer.py | 51 +++++-- docs/v7-operations.md | 5 + tests/test_retrieval_retention.py | 229 ++++++++++++++++++++++++++++++ 4 files changed, 275 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f5a12d..01041d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - 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. - 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. diff --git a/daem0nmcp/retrieval/composer.py b/daem0nmcp/retrieval/composer.py index a7b471e..cd67ea1 100644 --- a/daem0nmcp/retrieval/composer.py +++ b/daem0nmcp/retrieval/composer.py @@ -445,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, label_applicability) + prepared = self._resequence(prepared, label_applicability, retention) text = "\n\n".join(item.block for item in prepared) rendered_tokens = self._count(text) @@ -458,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, @@ -473,13 +474,13 @@ 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, @@ -513,7 +514,10 @@ def compose( return CompositionResult(items=tuple(items), context=context) def _resequence( - self, prepared: list[_PreparedItem], label_applicability: bool = False + self, + prepared: list[_PreparedItem], + label_applicability: bool = False, + retention: RetentionPolicy | None = None, ) -> list[_PreparedItem]: """Assign contiguous markers after restoring caller-selected order.""" @@ -525,6 +529,7 @@ def _resequence( item.source, item.excerpt, label_applicability=label_applicability, + fact_limit=self._excerpt_limit(item.source, retention), ) resequenced.append( replace( @@ -583,10 +588,15 @@ def _fit( ) -> _PreparedItem | None: marker = f"[E{len(prepared) + 1}]" full_excerpt = self._full_excerpt(source) - excerpt = self._bounded(full_excerpt, self._excerpt_limit(source, retention)) + 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, label_applicability=label_applicability + marker, + source, + excerpt, + label_applicability=label_applicability, + fact_limit=fact_limit, ) if self._combined_count(prepared, block) > budget: compressed = self._compress_excerpt( @@ -598,7 +608,11 @@ def _fit( ) if compressed is not None: compressed_block = self._render_block( - marker, source, compressed, label_applicability=label_applicability + marker, + source, + compressed, + label_applicability=label_applicability, + fact_limit=fact_limit, ) if self._combined_count(prepared, compressed_block) <= budget: excerpt = compressed @@ -612,11 +626,16 @@ def _fit( prepared, budget, label_applicability, + fact_limit, ) if excerpt is None: return None block = self._render_block( - marker, source, excerpt, label_applicability=label_applicability + marker, + source, + excerpt, + label_applicability=label_applicability, + fact_limit=fact_limit, ) truncated = True if truncated: @@ -686,6 +705,7 @@ def _largest_fitting_excerpt( prepared: list[_PreparedItem], budget: int, label_applicability: bool = False, + fact_limit: int | None = None, ) -> str | None: low = 1 high = len(excerpt) @@ -696,7 +716,11 @@ def _largest_fitting_excerpt( if not candidate: candidate = excerpt[:1] block = self._render_block( - marker, source, candidate, label_applicability=label_applicability + marker, + source, + candidate, + label_applicability=label_applicability, + fact_limit=fact_limit, ) if self._combined_count(prepared, block) <= budget: best = candidate @@ -719,6 +743,7 @@ def _render_block( excerpt: str, *, label_applicability: bool = False, + fact_limit: int | None = None, ) -> str: channels = ",".join(sorted(source.candidate.channels)) lines = [ @@ -735,11 +760,15 @@ def _render_block( ) 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: diff --git a/docs/v7-operations.md b/docs/v7-operations.md index e1e02c5..bd76733 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -201,6 +201,11 @@ 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. 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. Repository code-binding checks validate current bounded source content rather than trusting file size and modification time. Parsed fingerprints are reused diff --git a/tests/test_retrieval_retention.py b/tests/test_retrieval_retention.py index 74f0eb9..4729e1f 100644 --- a/tests/test_retrieval_retention.py +++ b/tests/test_retrieval_retention.py @@ -11,6 +11,7 @@ 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 @@ -158,6 +159,165 @@ def test_binding_priorities_and_ordinary_excerpt_caps(self): ).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) @@ -222,6 +382,75 @@ def _service(self, mode): 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, From ba5fe3d479990f5c1e1ef0eb7e1f95685321e4da Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 12:52:52 -0400 Subject: [PATCH 21/25] Two-line memo canonicalization to exact-verified cache-owned immutable bytes avoids repeated memcmp and duplicatebyteownership, retainingresolve/stat/budget/freshread rules. Real4.5MB warmfile120evaluations/batch7samplemedian45.596->24.7523ms(localdescriptivemeasurement,notlatencyguarantee). Nextbatchedit needsrevalidation; realSQLite2recordrecall1readcurrent thennextcall1freshreadbothchanged.155tests/25subtests1skip, scopedRuffgreen. Canonical .85/secondariesunchanged; independentperformancebenefit. Result: {"status":"keep","coding_memory_ndcg":0.85,"baseline_ndcg":0.721913456093,"outcome_reuse_ndcg":1,"provenance_chain_ndcg":1,"required_fact_retention":1,"stale_flag_recall":1,"false_flag_rate":0,"rendered_tokens_mean":95.733333333333,"listed_tool_count":9,"guided_write_calls":1,"destructive_challenge_calls":2,"strict_explicit_write_calls":3} --- CHANGELOG.md | 1 + daem0nmcp/code_bindings.py | 2 ++ docs/v7-operations.md | 6 ++++-- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01041d8..0190d34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - 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. - 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. diff --git a/daem0nmcp/code_bindings.py b/daem0nmcp/code_bindings.py index 2392b0c..11ad19c 100644 --- a/daem0nmcp/code_bindings.py +++ b/daem0nmcp/code_bindings.py @@ -352,6 +352,8 @@ def _current_fingerprint(self, root: Path, binding: CodeBinding) -> str: 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 diff --git a/docs/v7-operations.md b/docs/v7-operations.md index bd76733..1fc2af2 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -213,8 +213,10 @@ 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. The batch permits 32 MiB of raw source -bytes plus one overflow-detection byte. Unread bindings beyond that budget are +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 From 67bc8b941484204e90a0d85cdaaa51c85e624edf Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 13:04:16 -0400 Subject: [PATCH 22/25] Exactsource snapshots cache minimal frozen symbolspans/root and sharedlineoffsets; root+namefingerprintkeys close rootlessqualificationcache. Actual12symbols strictparses12->1/read1, all12hashes EXACTequal; onlyeditedsymbolchanged. Actual__init__ nestedrootvalidrefsallcurrent; invalidouterqualificationininnerroot currentBEFORE ->needsrevalidationAFTER. Capturestill12parses/12reads intentionallyseparaterun20.183tests/55subtests1skip, scopedRuffgreen afterformatter. Frozen .85/secondarymetrics unchanged; independentcompute/correctnessbenefit. Result: {"status":"keep","coding_memory_ndcg":0.85,"baseline_ndcg":0.721913456093,"outcome_reuse_ndcg":1,"provenance_chain_ndcg":1,"required_fact_retention":1,"stale_flag_recall":1,"false_flag_rate":0,"rendered_tokens_mean":95.733333333333,"listed_tool_count":9,"guided_write_calls":1,"destructive_challenge_calls":2,"strict_explicit_write_calls":3} --- CHANGELOG.md | 1 + daem0nmcp/code_bindings.py | 98 ++++++++++++++++++++++++++++--------- docs/v7-operations.md | 4 ++ tests/test_code_bindings.py | 85 ++++++++++++++++++++++++++++++-- 4 files changed, 160 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0190d34..622a4e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - 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. - 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. diff --git a/daem0nmcp/code_bindings.py b/daem0nmcp/code_bindings.py index 11ad19c..d9c4293 100644 --- a/daem0nmcp/code_bindings.py +++ b/daem0nmcp/code_bindings.py @@ -132,10 +132,21 @@ def binding_refs_from_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[str | None, str] = field(default_factory=dict) + 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) @@ -191,7 +202,23 @@ def _read_source(path: Path, batch: _ReadBatch | None = None) -> bytes: return source.replace(b"\r\n", b"\n") -def _fingerprint(path: Path, root: Path, source: bytes, name: str | None) -> str: +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 @@ -203,24 +230,40 @@ def _fingerprint(path: Path, root: Path, source: bytes, name: str | None) -> str supported = producer.get_supported_extensions() if path.suffix.lower() not in supported: raise CodeBindingReferenceError("bound symbol extension is unsupported") - entities = producer.index_source_strict(path, root, source) - lines = source.split(b"\n") + 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 not isinstance(entity, Mapping): - raise CodeBindingUnavailableError( - "code parser returned invalid entities" - ) - if entity.get("qualified_name") == name: + if entity.qualified_name == name: exact.append(entity) - elif entity.get("name") == name: + elif entity.name == name: aliases.append(entity) selected = exact or aliases if not exact and aliases: identities = set() for entity in aliases: - identity = entity.get("qualified_name") + identity = entity.qualified_name if not isinstance(identity, str) or not identity.strip(): raise CodeBindingUnavailableError( "code parser returned no usable symbol identity" @@ -230,8 +273,8 @@ def _fingerprint(path: Path, root: Path, source: bytes, name: str | None) -> str raise CodeBindingReferenceError("bound symbol reference is ambiguous") chunks = [] for entity in selected: - navigation_start, end = entity.get("line_start"), entity.get("line_end") - start = entity.get("binding_line_start", navigation_start) + navigation_start, end = entity.navigation_start, entity.end + start = entity.start if ( not isinstance(start, int) or isinstance(start, bool) @@ -239,19 +282,24 @@ def _fingerprint(path: Path, root: Path, source: bytes, name: str | None) -> str or isinstance(navigation_start, bool) or not isinstance(end, int) or isinstance(end, bool) - or not 1 <= start <= navigation_start <= end <= len(lines) + or not 1 <= start <= navigation_start <= end <= len(offsets) ): raise CodeBindingUnavailableError( "code parser returned invalid line bounds" ) - chunks.append(b"\n".join(lines[start - 1 : end])) + 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") - return hashlib.sha256(b"\n".join(chunks)).hexdigest() + 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( @@ -361,10 +409,8 @@ def _current_fingerprint(self, root: Path, binding: CodeBinding) -> str: ): _, evicted = self._cache.popitem(last=False) self._cached_source_bytes -= len(evicted.source) - if ( - binding.qualified_name is not None - and binding.qualified_name in snapshot.fingerprints - ): + 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: @@ -374,11 +420,15 @@ def _current_fingerprint(self, root: Path, binding: CodeBinding) -> str: raise except Exception as exc: raise CodeBindingUnavailableError("code parser failed") from exc - if binding.qualified_name not in snapshot.fingerprints: - snapshot.fingerprints[binding.qualified_name] = _fingerprint( - path, root, snapshot.source, binding.qualified_name + if key not in snapshot.fingerprints: + snapshot.fingerprints[key] = _fingerprint( + path, + root, + snapshot.source, + binding.qualified_name, + snapshot=snapshot, ) - return snapshot.fingerprints[binding.qualified_name] + return snapshot.fingerprints[key] def evaluate( self, root: Path, context: Mapping[str, object] diff --git a/docs/v7-operations.md b/docs/v7-operations.md index 1fc2af2..3e9c8d9 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -224,6 +224,10 @@ 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 diff --git a/tests/test_code_bindings.py b/tests/test_code_bindings.py index 6d2ace3..215b05a 100644 --- a/tests/test_code_bindings.py +++ b/tests/test_code_bindings.py @@ -194,6 +194,78 @@ def test_exact_identity_takes_precedence_over_short_name_match(workspace): ) +@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 @@ -611,8 +683,12 @@ def test_definite_filesystem_mutation_remains_changed( def test_unavailable_parser_is_unverifiable(workspace, monkeypatch): require_parser() path = workspace.root / "sample.py" - path.write_text("def target():\n return 1\n", encoding="utf-8") + 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( @@ -620,9 +696,10 @@ def test_unavailable_parser_is_unverifiable(workspace, monkeypatch): "default_code_indexer_factory", lambda: SimpleNamespace(available=False), ) - result = evaluator.evaluate(workspace.root, context) - assert result.applicability == "unverifiable" - assert result.changed == () + 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")]) From cfb59a655a79d89c89ae4fb0c814e046901ebb07 Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 13:36:41 -0400 Subject: [PATCH 23/25] Capture-local resolvedfile snapshots prevent mixed-source symbolsets, preserveeveryrefvalidation/containment andfreshlatercalls. Realmutationafterfirstactualparse before mixedold/new ->after BOTHoriginalSHAs,filealreadyreplacement; real12symbolcapture reads/parses12->1 andallSHAs EXACTsame. Targeted154tests30subtests1skip; FULLintegrated3305passed66skip506warnings1627subtests. FullRuffcheck+format488files,mypy48APIfilesplatformLinux,releaseinventory77tools/10resources/586requirementsPASS. Finalfrozenprimary.85 vsbaseline.821533827904 +3.5%,allothermetricsexceptreferencebaselineunchanged. Cap20reached,no pushes. Result: {"status":"keep","coding_memory_ndcg":0.85,"baseline_ndcg":0.721913456093,"outcome_reuse_ndcg":1,"provenance_chain_ndcg":1,"required_fact_retention":1,"stale_flag_recall":1,"false_flag_rate":0,"rendered_tokens_mean":95.733333333333,"listed_tool_count":9,"guided_write_calls":1,"destructive_challenge_calls":2,"strict_explicit_write_calls":3} --- CHANGELOG.md | 1 + daem0nmcp/code_bindings.py | 15 ++- docs/v7-operations.md | 5 + tests/api_v7/test_memory_learning_writes.py | 38 ++++++ tests/test_code_bindings.py | 128 ++++++++++++++++++++ 5 files changed, 186 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 622a4e9..0faf762 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - 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. - 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. diff --git a/daem0nmcp/code_bindings.py b/daem0nmcp/code_bindings.py index d9c4293..d0accf7 100644 --- a/daem0nmcp/code_bindings.py +++ b/daem0nmcp/code_bindings.py @@ -305,11 +305,18 @@ def _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") @@ -320,7 +327,13 @@ def capture_bindings( raise _file_error( "bound file is missing or outside the workspace", exc ) from exc - fingerprint = _fingerprint(path, workspace.root, _read_source(path), name) + 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 diff --git a/docs/v7-operations.md b/docs/v7-operations.md index 3e9c8d9..52d9f10 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -254,6 +254,11 @@ 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 diff --git a/tests/api_v7/test_memory_learning_writes.py b/tests/api_v7/test_memory_learning_writes.py index 1b0d4f5..1adb72e 100644 --- a/tests/api_v7/test_memory_learning_writes.py +++ b/tests/api_v7/test_memory_learning_writes.py @@ -321,6 +321,44 @@ async def test_oversized_code_ref_capture_is_invalid_and_atomic(self) -> 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" diff --git a/tests/test_code_bindings.py b/tests/test_code_bindings.py index 215b05a..2763e6e 100644 --- a/tests/test_code_bindings.py +++ b/tests/test_code_bindings.py @@ -50,6 +50,134 @@ def require_parser(): 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") From cd7eb8bb09ae5c483663f59dbdde773e419c9883 Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 15:18:20 -0400 Subject: [PATCH 24/25] fix: address v7 gateway and migration review findings Sanitize malformed gateway output without revalidating caller input, distinguish SQLite contention by error code, and report explore retention as not applicable. Restore missing derived learning parents from validated full canonical replay before transactional schema 33 backfill; preserve strict authority validation and rollback. Add consumer regressions and clarify operator costs and retry remediation. --- CHANGELOG.md | 4 + README.md | 10 +- daem0nmcp/api/v7/discovery_operations.py | 14 +- daem0nmcp/api/v7/fastmcp.py | 20 +- daem0nmcp/api/v7/federated_retrieval.py | 2 + daem0nmcp/api/v7/gateway_operations.py | 103 +++---- daem0nmcp/event_store.py | 5 +- daem0nmcp/migrations/schema.py | 6 + daem0nmcp/migrations/v7_schema_upgrade.py | 73 ++++- daem0nmcp/retrieval/service.py | 2 + daem0nmcp/verification_v7.py | 90 +++--- docs/v7-operations.md | 14 +- tests/api_v7/test_discovery_operations.py | 75 +++++ tests/api_v7/test_fastmcp_adapter.py | 35 ++- tests/api_v7/test_gateway_operations.py | 162 +++++++++- tests/test_retrieval_retention.py | 92 +++++- tests/test_v7_schema_upgrade.py | 343 ++++++++++++++++++++++ 17 files changed, 926 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0faf762..c21d1c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ - 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. - 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. diff --git a/README.md b/README.md index e2f0577..5154b63 100644 --- a/README.md +++ b/README.md @@ -1074,8 +1074,16 @@ 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. Opt in with: +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 diff --git a/daem0nmcp/api/v7/discovery_operations.py b/daem0nmcp/api/v7/discovery_operations.py index 99885fe..5b07ccb 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, @@ -710,8 +710,16 @@ 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 any( - word in str(error).casefold() for word in ("busy", "locked") + 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") diff --git a/daem0nmcp/api/v7/fastmcp.py b/daem0nmcp/api/v7/fastmcp.py index 3689fb4..b35a5b8 100644 --- a/daem0nmcp/api/v7/fastmcp.py +++ b/daem0nmcp/api/v7/fastmcp.py @@ -9,14 +9,19 @@ 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 .gateway_operations import GATEWAY_TOOL_NAMES, bind_gateway_handlers +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 @@ -170,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") diff --git a/daem0nmcp/api/v7/federated_retrieval.py b/daem0nmcp/api/v7/federated_retrieval.py index 7240633..42a8b95 100644 --- a/daem0nmcp/api/v7/federated_retrieval.py +++ b/daem0nmcp/api/v7/federated_retrieval.py @@ -504,6 +504,8 @@ def compose_federated_results( reason=( "RETENTION_FAILED" if retention_failed + else "RETENTION_NOT_APPLICABLE" + if query.intent == "explore" else "RETENTION_APPLIED" if retention_mode == "apply" else "RETENTION_SHADOW" diff --git a/daem0nmcp/api/v7/gateway_operations.py b/daem0nmcp/api/v7/gateway_operations.py index 5f56919..3498a56 100644 --- a/daem0nmcp/api/v7/gateway_operations.py +++ b/daem0nmcp/api/v7/gateway_operations.py @@ -25,6 +25,10 @@ _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: @@ -127,25 +131,15 @@ async def tool_call( target["workspace_id"] = workspace_id try: result = await adapters[tool](**target) - except (ValidationError, TypeError) as exc: - field_errors = ( - [ - FieldError( - field=_argument_field(error["loc"]), - code="INVALID_VALUE", - message="Invalid value.", - ) - for error in exc.errors()[:50] - ] - if isinstance(exc, ValidationError) - else [ - FieldError( - field="arguments", - code="INVALID_VALUE", - message="Invalid value.", - ) - ] - ) + 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; " @@ -155,39 +149,50 @@ async def tool_call( except Exception as exc: return response.internal_error(exc) - meta = ResponseMeta.model_validate(result["meta"]) - response = response.with_covenant(meta.covenant) - if result["ok"]: - return response.success( - ToolCallData(tool=tool, data=result["data"]), + 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, ) - 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, diff --git a/daem0nmcp/event_store.py b/daem0nmcp/event_store.py index 96161e8..acd11a1 100644 --- a/daem0nmcp/event_store.py +++ b/daem0nmcp/event_store.py @@ -865,6 +865,9 @@ def _project_provenance( 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"] @@ -934,7 +937,7 @@ def _project_provenance( command.recorded_at_us, ), ) - worked = payload["record"].get("worked") + worked = record.get("worked") if command.event_type == "memory.outcome_recorded" and isinstance(worked, bool): from .retrieval.utility import verification_weight diff --git a/daem0nmcp/migrations/schema.py b/daem0nmcp/migrations/schema.py index 4b1bb76..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 @@ -3294,6 +3295,7 @@ def run_migrations( *, 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. @@ -3302,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) @@ -3377,6 +3381,8 @@ def run_migrations( 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 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/retrieval/service.py b/daem0nmcp/retrieval/service.py index dc48a78..d6b028b 100644 --- a/daem0nmcp/retrieval/service.py +++ b/daem0nmcp/retrieval/service.py @@ -386,6 +386,8 @@ async def retrieve( 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" diff --git a/daem0nmcp/verification_v7.py b/daem0nmcp/verification_v7.py index 136b609..515b3a2 100644 --- a/daem0nmcp/verification_v7.py +++ b/daem0nmcp/verification_v7.py @@ -892,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]: @@ -934,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/v7-operations.md b/docs/v7-operations.md index 52d9f10..6869b44 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -200,7 +200,9 @@ Query path tokens normalize Windows separators and repeated slashes. An explicit 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. -Other intents still compute their distinct shadow variant. +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 @@ -245,10 +247,12 @@ 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. Restore the -capability or source access before obtaining fresh counsel and retrying with the -same idempotency key. Missing symbols, unsupported symbol-file extensions, and -sources above the 5 MiB raw-file limit remain `INVALID_ARGUMENT`. +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 diff --git a/tests/api_v7/test_discovery_operations.py b/tests/api_v7/test_discovery_operations.py index c571c2e..dcbe542 100644 --- a/tests/api_v7/test_discovery_operations.py +++ b/tests/api_v7/test_discovery_operations.py @@ -1233,6 +1233,81 @@ async def test_sqlite_contention_is_retryable_through_router(self) -> None: 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: 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_gateway_operations.py b/tests/api_v7/test_gateway_operations.py index 4ae245b..0be2480 100644 --- a/tests/api_v7/test_gateway_operations.py +++ b/tests/api_v7/test_gateway_operations.py @@ -9,12 +9,13 @@ 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 ErrorCode, FieldError +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 @@ -308,7 +309,7 @@ async def test_binder_sanitizes_adapter_exceptions(self) -> None: response_factory=ResponseFactory(), ) for exception, expected in ( - (TypeError("private argument detail"), ErrorCode.INVALID_ARGUMENT), + (TypeError("private argument detail"), ErrorCode.INTERNAL_ERROR), (RuntimeError("private runtime detail"), ErrorCode.INTERNAL_ERROR), (ValueError("private runtime detail"), ErrorCode.INTERNAL_ERROR), ): @@ -325,17 +326,148 @@ async def test_binder_sanitizes_adapter_exceptions(self) -> None: self.assertFalse(result.error.retryable) self.assertIsNone(result.error.remedy) self.assertEqual(result.meta.request_id, result.error.correlation_id) - if expected == ErrorCode.INVALID_ARGUMENT: - self.assertEqual( - [ - FieldError( - field="arguments", - code="INVALID_VALUE", - message="Invalid value.", - ) - ], - result.error.field_errors, - ) - else: - self.assertEqual([], result.error.field_errors) + 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/test_retrieval_retention.py b/tests/test_retrieval_retention.py index 4729e1f..fc708b0 100644 --- a/tests/test_retrieval_retention.py +++ b/tests/test_retrieval_retention.py @@ -471,6 +471,48 @@ async def test_shadow_is_byte_identical_and_apply_retains_procedure(self): 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 @@ -642,14 +684,50 @@ def test_late_procedure_retention_and_shadow_context_isolation(self): retention_mode="apply", ) self.assertEqual("5", single.items[0].record.record_id[-1]) - for intent in (None, "explore"): - unchanged = compose_federated_results( - results, - replace(query, intent=intent), - retention_mode="apply", + 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) ) - self.assertEqual(baseline.items, unchanged.items) - self.assertEqual(baseline.rendered_context, unchanged.rendered_context) + 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())) diff --git a/tests/test_v7_schema_upgrade.py b/tests/test_v7_schema_upgrade.py index 332faca..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: @@ -417,6 +498,268 @@ def test_upgrade_backfills_native_outcomes_without_inventing_legacy_evidence( ).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 From b38753fa1eec13b8910c9f1b43dadfca9532cad1 Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 5 Oct 2026 16:15:06 -0400 Subject: [PATCH 25/25] fix: preserve retryable recall across lexical publication Bind entity-selection readiness to its observed lexical generation so first publication between retrieval and inspection cannot erase a transient miss. Keep same-generation indexed misses terminal, preserve SQLite classification, and cover the real retrieval/publication/identical-retry transition. --- CHANGELOG.md | 1 + daem0nmcp/api/v7/discovery_operations.py | 44 ++++++++++---- docs/v7-operations.md | 5 ++ tests/api_v7/test_discovery_operations.py | 70 +++++++++++++++++++++-- 4 files changed, 104 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c21d1c0..bf7c9f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ - 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. diff --git a/daem0nmcp/api/v7/discovery_operations.py b/daem0nmcp/api/v7/discovery_operations.py index 5b07ccb..426837a 100644 --- a/daem0nmcp/api/v7/discovery_operations.py +++ b/daem0nmcp/api/v7/discovery_operations.py @@ -1935,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, ...] @@ -2025,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, @@ -2052,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: @@ -2072,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( @@ -2081,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=? " @@ -2157,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/docs/v7-operations.md b/docs/v7-operations.md index 6869b44..a4ee8f2 100644 --- a/docs/v7-operations.md +++ b/docs/v7-operations.md @@ -209,6 +209,11 @@ 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 diff --git a/tests/api_v7/test_discovery_operations.py b/tests/api_v7/test_discovery_operations.py index dcbe542..1c0411d 100644 --- a/tests/api_v7/test_discovery_operations.py +++ b/tests/api_v7/test_discovery_operations.py @@ -242,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( @@ -259,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, @@ -268,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 @@ -1370,6 +1376,58 @@ async def retrieve(self, workspace, query, linked_workspace_ids): {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