From 9fe13fffb32f52177a8170cf0e4cdbce2ede26de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 7 Oct 2026 16:40:27 -0400 Subject: [PATCH] Make a cartridge a scope you mount, not only a bundle you export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cartridge used to be an export: materialize it, hash it, and never let it change behaviour again. The transactive-memory promise needs the opposite — mounting the home cartridge must make the work cartridge's memories unfindable, and appending to a cartridge must make a new memory findable through it. Cartridge scope (schema v2 -> v3): - cartridge_atoms (which atoms are in which cartridge) and cartridge_mounts (which cartridges this machine has mounted now); both rebuilt on reopen, because a mount is a machine-local state, not a fact about the data. - mount / switch / unmount / append refuse a cartridge that was built for a different runtime, naming every differing field instead of a boolean. - append rewrites atoms.parquet and sparse_index.bm25.zst, recomputes DNA and the KV fingerprint, and keeps the cartridges row in one SQLite transaction; the files are written first so a failed export cannot leave a half-cartridge in the database. - RetrievalService.search() now ranks inside the mounted scope by default, and an explicit cartridge_ids filter overrides the mount. Nothing mounted means the whole library, so an empty store behaves as it did before. - forget() removes the forgotten atoms from every cartridge's membership: forgetting must not leave a memory retrievable through a cartridge. KV fingerprint (kv_fingerprint.py, stored in the manifest): - model / tokenizer / chat-template hashes, the RoPE block (factor, original_max_position_embeddings, mrope_interleaved, mrope_section, partial_rotary_factor), the adapter hash, the context length, the engine version, and the fingerprint format version. - A snapshot we cannot read is recorded as unverifiable and listed in unverifiable_fields; a blind machine refuses to mount rather than pretending the runtime matches, because a wrong RoPE factor silently corrupts retrieval. Two defects found by running the new tests, not by reading: - atoms.parquet could not export a memory at all: pyarrow refuses a struct with no child field, so metadata={} failed the whole table. The six JSON columns are now written as JSON text and decoded by the cartridge reader. - materialize() inserted cartridge membership before the cartridges row existed, so the foreign key rejected every cartridge. api.py crossed 500 lines, so the cartridge and mount routes moved to api_cartridges.py. That module must not use `from __future__ import annotations`: FastAPI then resolves Depends(settings_dep) against module globals, where the injected dependency is only a parameter of register(), and every route starts demanding settings as a query parameter. Verification: 138 pytest passed (26 new: 10 fingerprint, 16 scope), Go build/vet/gofmt/test clean, and scripts/mcp_write_smoke.sh now proves the whole loop across two processes - mount narrows search, a memory written after the upload is invisible until append_to_cartridge, unmount restores the library, and rewriting the RoPE factor makes mount_cartridge refuse with "rope: factor: cartridge 4.0 vs local 2.0". ruff clean on every touched file (the 20 remaining package errors are in files this change did not touch; HEAD had 25). mypy: one error, the pre-existing np.savez stub artifact at cartridge_assets.py:329 (HEAD:320). Repo pyright: 62 errors, unchanged from the p0-3 ledger. --- backend/src/ely_eye/api.py | 45 +-- backend/src/ely_eye/api_cartridges.py | 107 +++++++ backend/src/ely_eye/cartridge.py | 222 ++++++++------ backend/src/ely_eye/cartridge_assets.py | 17 +- backend/src/ely_eye/cartridge_store.py | 106 +++++++ backend/src/ely_eye/cli.py | 34 ++- backend/src/ely_eye/config.py | 2 + backend/src/ely_eye/db.py | 19 ++ backend/src/ely_eye/kv_fingerprint.py | 196 ++++++++++++ backend/src/ely_eye/migrations.py | 16 +- backend/src/ely_eye/retrieval.py | 11 +- backend/src/ely_eye/schemas.py | 16 + backend/tests/conftest.py | 53 ++++ backend/tests/test_cartridge_scope.py | 284 ++++++++++++++++++ backend/tests/test_kv_fingerprint.py | 138 +++++++++ ely-eye-mcp/internal/apiclient/client.go | 30 +- ely-eye-mcp/internal/domain/types.go | 22 ++ ely-eye-mcp/internal/mcpserver/mount_tools.go | 111 +++++++ .../internal/mcpserver/mount_tools_test.go | 146 +++++++++ ely-eye-mcp/internal/mcpserver/server.go | 3 +- scripts/mcp_write_smoke.sh | 67 ++++- 21 files changed, 1492 insertions(+), 153 deletions(-) create mode 100644 backend/src/ely_eye/api_cartridges.py create mode 100644 backend/src/ely_eye/cartridge_store.py create mode 100644 backend/src/ely_eye/kv_fingerprint.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_cartridge_scope.py create mode 100644 backend/tests/test_kv_fingerprint.py create mode 100644 ely-eye-mcp/internal/mcpserver/mount_tools.go create mode 100644 ely-eye-mcp/internal/mcpserver/mount_tools_test.go diff --git a/backend/src/ely_eye/api.py b/backend/src/ely_eye/api.py index 9c507fe..113b579 100644 --- a/backend/src/ely_eye/api.py +++ b/backend/src/ely_eye/api.py @@ -10,6 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from . import __version__ +from .api_cartridges import register as register_cartridge_routes from .benchmarks import ( benchmark_prediction_report, benchmark_registry_report, @@ -18,7 +19,6 @@ from .benchmarks import ( ) from .cache_fabric import CacheFabric from .cartridge import CartridgeService -from .cartridge_assets import CartridgeAssetService from .compiler import ContextCompiler from .config import Settings, get_settings from .db import Database @@ -44,7 +44,6 @@ from .schemas import ( AdapterManifest, AdapterStatus, AnswerResponse, - CartridgeAssetReport, EvidenceAtom, ForgetReport, ForgetRequest, @@ -246,47 +245,7 @@ def create_app() -> FastAPI: except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc - @app.get("/api/cartridges") - def cartridges(settings: Annotated[Settings, Depends(settings_dep)]): - return CartridgeService(settings).list_manifests() - - @app.post("/api/cartridges/{cartridge_id}/finalize", response_model=CartridgeAssetReport) - def finalize_cartridge( - cartridge_id: str, - settings: Annotated[Settings, Depends(settings_dep)], - ) -> CartridgeAssetReport: - try: - return CartridgeAssetService(settings).finalize(cartridge_id) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - @app.post("/api/cartridges/{cartridge_id}/refresh") - def refresh_cartridge( - cartridge_id: str, - settings: Annotated[Settings, Depends(settings_dep)], - ): - db = Database(settings) - try: - return CartridgeService(settings, db).rebuild( - cartridge_id=cartridge_id, - atoms=db.list_atoms(), - sources=db.list_sources(), - ) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - @app.get("/api/cartridges/{cartridge_id}/assets", response_model=CartridgeAssetReport) - def cartridge_assets( - cartridge_id: str, - settings: Annotated[Settings, Depends(settings_dep)], - ) -> CartridgeAssetReport: - try: - report = CartridgeAssetService(settings).status(cartridge_id) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - if report is None: - raise HTTPException(status_code=404, detail="Cartridge assets have not been finalized.") - return report + register_cartridge_routes(app, settings_dep) @app.get("/api/adapters", response_model=list[AdapterStatus]) def adapters(settings: Annotated[Settings, Depends(settings_dep)]) -> list[AdapterStatus]: diff --git a/backend/src/ely_eye/api_cartridges.py b/backend/src/ely_eye/api_cartridges.py new file mode 100644 index 0000000..79d3d7a --- /dev/null +++ b/backend/src/ely_eye/api_cartridges.py @@ -0,0 +1,107 @@ +"""Cartridge and mount routes: a cartridge is a retrieval scope, not only an export.""" + +# No `from __future__ import annotations` here: FastAPI reads the route signatures, and +# string annotations would be resolved against this module's globals, where the injected +# settings_dep lives only as a parameter of register(). + +from collections.abc import Callable +from typing import Annotated + +from fastapi import Depends, FastAPI, HTTPException + +from .cartridge import CartridgeService +from .cartridge_assets import CartridgeAssetService +from .config import Settings +from .db import Database +from .schemas import AppendRequest, CartridgeAssetReport, CartridgeManifest, MountState + + +def register(app: FastAPI, settings_dep: Callable[[], Settings]) -> None: + """Mounting decides what a search may see; the KV fingerprint decides if it may mount here.""" + + @app.get("/api/cartridges") + def cartridges(settings: Annotated[Settings, Depends(settings_dep)]): + return CartridgeService(settings).list_manifests() + + @app.get("/api/mounts", response_model=MountState) + def mounts(settings: Annotated[Settings, Depends(settings_dep)]) -> MountState: + return CartridgeService(settings).mount_state() + + @app.post("/api/cartridges/{cartridge_id}/mount", response_model=CartridgeManifest) + def mount_cartridge( + cartridge_id: str, + settings: Annotated[Settings, Depends(settings_dep)], + ) -> CartridgeManifest: + try: + return CartridgeService(settings).mount(cartridge_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/cartridges/{cartridge_id}/switch", response_model=CartridgeManifest) + def switch_cartridge( + cartridge_id: str, + settings: Annotated[Settings, Depends(settings_dep)], + ) -> CartridgeManifest: + try: + return CartridgeService(settings).switch(cartridge_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/cartridges/{cartridge_id}/unmount", response_model=MountState) + def unmount_cartridge( + cartridge_id: str, + settings: Annotated[Settings, Depends(settings_dep)], + ) -> MountState: + try: + return CartridgeService(settings).unmount(cartridge_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/cartridges/{cartridge_id}/append", response_model=CartridgeManifest) + def append_cartridge( + cartridge_id: str, + request: AppendRequest, + settings: Annotated[Settings, Depends(settings_dep)], + ) -> CartridgeManifest: + try: + return CartridgeService(settings).append(cartridge_id, request.atom_ids) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/cartridges/{cartridge_id}/finalize", response_model=CartridgeAssetReport) + def finalize_cartridge( + cartridge_id: str, + settings: Annotated[Settings, Depends(settings_dep)], + ) -> CartridgeAssetReport: + try: + return CartridgeAssetService(settings).finalize(cartridge_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/cartridges/{cartridge_id}/refresh") + def refresh_cartridge( + cartridge_id: str, + settings: Annotated[Settings, Depends(settings_dep)], + ): + db = Database(settings) + try: + return CartridgeService(settings, db).rebuild( + cartridge_id=cartridge_id, + atoms=db.list_atoms(), + sources=db.list_sources(), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/cartridges/{cartridge_id}/assets", response_model=CartridgeAssetReport) + def cartridge_assets( + cartridge_id: str, + settings: Annotated[Settings, Depends(settings_dep)], + ) -> CartridgeAssetReport: + try: + report = CartridgeAssetService(settings).status(cartridge_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if report is None: + raise HTTPException(status_code=404, detail="Cartridge assets have not been finalized.") + return report diff --git a/backend/src/ely_eye/cartridge.py b/backend/src/ely_eye/cartridge.py index 0609517..cfae38e 100644 --- a/backend/src/ely_eye/cartridge.py +++ b/backend/src/ely_eye/cartridge.py @@ -4,16 +4,34 @@ import hashlib import json import shutil import sqlite3 -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path import pyarrow as pa import pyarrow.parquet as pq import zstandard as zstd +from . import cartridge_store from .config import Settings, get_settings from .db import Database -from .schemas import AdapterManifest, CartridgeManifest, EvidenceAtom, SourceRecord +from .kv_fingerprint import compute_fingerprint, fingerprint_diff, unverifiable_fields +from .schemas import ( + AdapterManifest, + CartridgeManifest, + EvidenceAtom, + MountState, + SourceRecord, + utc_now, +) + +JSON_PARQUET_COLUMNS = ( + "embedding_refs", + "layout", + "relations", + "trust", + "metadata", + "visibility", +) class CartridgeService: @@ -27,7 +45,7 @@ class CartridgeService: atoms: list[EvidenceAtom], sources: list[SourceRecord], ) -> CartridgeManifest: - created_at = datetime.now(timezone.utc) + created_at = datetime.now(UTC) cartridge_id = self._cartridge_id(name, created_at) root = self.settings.cartridge_dir / cartridge_id eval_dir = root / "eval_proofs" @@ -55,6 +73,7 @@ class CartridgeService: source_count=len(sources), atom_count=len(atoms), token_equivalent=sum(atom.token_equivalent for atom in atoms), + kv_fingerprint=compute_fingerprint(self.settings, root / "adapters"), artifacts={ "manifest": "manifest.json", "atoms": "atoms.parquet", @@ -64,20 +83,8 @@ class CartridgeService: "eval_proofs": "eval_proofs/", }, ) - manifest_path = root / "manifest.json" - manifest_path.write_text(manifest.model_dump_json(indent=2), encoding="utf-8") - dna = self.compute_dna(root) - manifest.dna = dna - manifest_path.write_text(manifest.model_dump_json(indent=2), encoding="utf-8") - self.db.write_cartridge( - cartridge_id=cartridge_id, - name=name, - root_path=root, - manifest_json=manifest.model_dump_json(), - dna=dna, - token_equivalent=manifest.token_equivalent, - created_at=created_at, - ) + self._commit(manifest, root) + cartridge_store.set_membership(self.db, cartridge_id, [atom.atom_id for atom in atoms]) return manifest def compute_dna(self, root: Path) -> str: @@ -96,18 +103,102 @@ class CartridgeService: manifests.append(CartridgeManifest.model_validate_json(row["manifest_json"])) return manifests + def mount_state(self) -> MountState: + mounted = cartridge_store.mounted_ids(self.db) + return MountState( + mounted=mounted, + scoped=bool(mounted), + atoms_in_scope=len(cartridge_store.atoms_of_cartridges(self.db, mounted)), + ) + + def mount(self, cartridge_id: str) -> CartridgeManifest: + """Add one cartridge to the mounted set; a mounted search answers only from it.""" + root, manifest = self._require(cartridge_id) + self._refuse_if_incompatible(manifest, root) + mounted = cartridge_store.mounted_ids(self.db) + if cartridge_id not in mounted: + cartridge_store.set_mounts(self.db, [*mounted, cartridge_id], utc_now()) + return manifest + + def switch(self, cartridge_id: str) -> CartridgeManifest: + """Mount one cartridge and nothing else.""" + root, manifest = self._require(cartridge_id) + self._refuse_if_incompatible(manifest, root) + cartridge_store.set_mounts(self.db, [cartridge_id], utc_now()) + return manifest + + def unmount(self, cartridge_id: str) -> MountState: + self._require(cartridge_id) + mounted = cartridge_store.mounted_ids(self.db) + if cartridge_id not in mounted: + raise ValueError(f"{cartridge_id} is not mounted; nothing to unmount") + cartridge_store.set_mounts( + self.db, [held for held in mounted if held != cartridge_id], utc_now() + ) + return self.mount_state() + + def append(self, cartridge_id: str, atom_ids: list[str]) -> CartridgeManifest: + """Add memories to an existing cartridge; the bundle and its DNA follow the membership.""" + self._require(cartridge_id) + if not atom_ids: + raise ValueError("append needs at least one atom") + atoms = self.db.get_atoms(atom_ids) + found = {atom.atom_id for atom in atoms} + missing = [atom_id for atom_id in atom_ids if atom_id not in found] + if missing: + raise ValueError(f"cannot append, these atoms are not in the store: {', '.join(missing)}") + cartridge_store.add_membership(self.db, cartridge_id, atom_ids) + held = self.db.get_atoms(sorted(cartridge_store.atoms_of_cartridge(self.db, cartridge_id))) + sources = self.db.get_sources(sorted({atom.source_id for atom in held})) + return self.rebuild(cartridge_id, held, sources) + + def _require(self, cartridge_id: str) -> tuple[Path, CartridgeManifest]: + rows = [row for row in self.db.list_cartridges() if row["cartridge_id"] == cartridge_id] + if not rows: + raise ValueError(f"Unknown cartridge: {cartridge_id}") + row = rows[0] + return Path(row["root_path"]), CartridgeManifest.model_validate_json(row["manifest_json"]) + + def _refuse_if_incompatible(self, manifest: CartridgeManifest, root: Path) -> None: + """KV bytes are only valid under the settings that produced them.""" + stored = manifest.kv_fingerprint + if stored is None: + raise ValueError(f"{manifest.cartridge_id} carries no kv fingerprint; rebuild it before mounting") + live = compute_fingerprint(self.settings, root / "adapters") + gaps = unverifiable_fields(live) + if gaps: + raise ValueError( + f"cannot verify {manifest.cartridge_id} on this machine: {', '.join(gaps)} are unknown; " + "set ELY_EYE_MODEL_DIR to the model snapshot" + ) + differences = fingerprint_diff(stored, live) + if differences: + raise ValueError( + f"{manifest.cartridge_id} was built for a different runtime; refusing to mount: " + + "; ".join(differences) + ) + + def _commit(self, manifest: CartridgeManifest, root: Path) -> None: + """Re-hash the bundle, then record the manifest that describes those bytes.""" + manifest.dna = self.compute_dna(root) + (root / "manifest.json").write_text(manifest.model_dump_json(indent=2), encoding="utf-8") + self.db.write_cartridge( + cartridge_id=manifest.cartridge_id, + name=manifest.name, + root_path=root, + manifest_json=manifest.model_dump_json(), + dna=manifest.dna, + token_equivalent=manifest.token_equivalent, + created_at=manifest.created_at, + ) + def rebuild( self, cartridge_id: str, atoms: list[EvidenceAtom], sources: list[SourceRecord], ) -> CartridgeManifest: - rows = [row for row in self.db.list_cartridges() if row["cartridge_id"] == cartridge_id] - if not rows: - raise ValueError(f"Unknown cartridge: {cartridge_id}") - row = rows[0] - root = Path(row["root_path"]) - manifest = CartridgeManifest.model_validate_json(row["manifest_json"]) + root, manifest = self._require(cartridge_id) root.mkdir(parents=True, exist_ok=True) self._write_atoms_parquet(root / "atoms.parquet", atoms) @@ -133,24 +224,11 @@ class CartridgeService: ) manifest.dna = self.compute_dna(root) (root / "manifest.json").write_text(manifest.model_dump_json(indent=2), encoding="utf-8") - self.db.write_cartridge( - cartridge_id=manifest.cartridge_id, - name=manifest.name, - root_path=root, - manifest_json=manifest.model_dump_json(), - dna=manifest.dna, - token_equivalent=manifest.token_equivalent, - created_at=manifest.created_at, - ) + self._commit(manifest, root) return manifest def attach_adapter(self, cartridge_id: str, adapter_dir: Path) -> CartridgeManifest: - rows = [row for row in self.db.list_cartridges() if row["cartridge_id"] == cartridge_id] - if not rows: - raise ValueError(f"Unknown cartridge: {cartridge_id}") - row = rows[0] - root = Path(row["root_path"]) - manifest = CartridgeManifest.model_validate_json(row["manifest_json"]) + root, manifest = self._require(cartridge_id) adapter_manifest_path = adapter_dir / "adapter_manifest.json" if not adapter_manifest_path.exists(): raise ValueError(f"Missing adapter manifest: {adapter_manifest_path}") @@ -163,18 +241,7 @@ class CartridgeService: manifest.artifacts[f"adapter_{adapter_manifest.kind.value}"] = ( adapter_root.relative_to(root).as_posix() + "/" ) - manifest_path = root / "manifest.json" - manifest.dna = self.compute_dna(root) - manifest_path.write_text(manifest.model_dump_json(indent=2), encoding="utf-8") - self.db.write_cartridge( - cartridge_id=manifest.cartridge_id, - name=manifest.name, - root_path=root, - manifest_json=manifest.model_dump_json(), - dna=manifest.dna, - token_equivalent=manifest.token_equivalent, - created_at=manifest.created_at, - ) + self._commit(manifest, root) return manifest def update_artifacts( @@ -183,59 +250,34 @@ class CartridgeService: artifacts: dict[str, str], token_equivalent: int | None = None, ) -> CartridgeManifest: - rows = [row for row in self.db.list_cartridges() if row["cartridge_id"] == cartridge_id] - if not rows: - raise ValueError(f"Unknown cartridge: {cartridge_id}") - row = rows[0] - root = Path(row["root_path"]) - manifest = CartridgeManifest.model_validate_json(row["manifest_json"]) + root, manifest = self._require(cartridge_id) manifest.artifacts.update(artifacts) if token_equivalent is not None: manifest.token_equivalent = token_equivalent - manifest_path = root / "manifest.json" - manifest.dna = self.compute_dna(root) - manifest_path.write_text(manifest.model_dump_json(indent=2), encoding="utf-8") - self.db.write_cartridge( - cartridge_id=manifest.cartridge_id, - name=manifest.name, - root_path=root, - manifest_json=manifest.model_dump_json(), - dna=manifest.dna, - token_equivalent=manifest.token_equivalent, - created_at=manifest.created_at, - ) + self._commit(manifest, root) return manifest def attach_eval_proof(self, cartridge_id: str, proof_dir: Path) -> CartridgeManifest: - rows = [row for row in self.db.list_cartridges() if row["cartridge_id"] == cartridge_id] - if not rows: - raise ValueError(f"Unknown cartridge: {cartridge_id}") + root, manifest = self._require(cartridge_id) if not proof_dir.exists() or not proof_dir.is_dir(): raise ValueError(f"Missing proof directory: {proof_dir}") - row = rows[0] - root = Path(row["root_path"]) - manifest = CartridgeManifest.model_validate_json(row["manifest_json"]) target = root / "eval_proofs" / proof_dir.name target.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(proof_dir, target, dirs_exist_ok=True) manifest.artifacts[f"eval_proof_{proof_dir.name}"] = target.relative_to(root).as_posix() + "/" - manifest_path = root / "manifest.json" - manifest.dna = self.compute_dna(root) - manifest_path.write_text(manifest.model_dump_json(indent=2), encoding="utf-8") - self.db.write_cartridge( - cartridge_id=manifest.cartridge_id, - name=manifest.name, - root_path=root, - manifest_json=manifest.model_dump_json(), - dna=manifest.dna, - token_equivalent=manifest.token_equivalent, - created_at=manifest.created_at, - ) + self._commit(manifest, root) return manifest def _write_atoms_parquet(self, path: Path, atoms: list[EvidenceAtom]) -> None: - rows = [atom.model_dump(mode="json") for atom in atoms] - table = pa.Table.from_pylist(rows) + rows = [] + for atom in atoms: + row = atom.model_dump(mode="json") + for field in JSON_PARQUET_COLUMNS: + row[field] = json.dumps(row[field], ensure_ascii=False, sort_keys=True) + rows.append(row) + # An empty struct column has no child field, and Parquet cannot write one: a memory + # atom carries metadata={} and would crash the export. + table = pa.Table.from_pylist(rows) if rows else pa.Table.from_pydict({"atom_id": []}) pq.write_table(table, path, compression="zstd") def _write_sparse_index(self, path: Path, atoms: list[EvidenceAtom]) -> None: @@ -289,13 +331,13 @@ class CartridgeService: connection.close() def _cartridge_id(self, name: str, created_at: datetime) -> str: - raw = f"{name}:{created_at.isoformat()}".encode("utf-8") + raw = f"{name}:{created_at.isoformat()}".encode() safe_name = "".join(char if char.isalnum() else "_" for char in name.lower()).strip("_")[:40] return f"{safe_name or 'cartridge'}_{hashlib.sha256(raw).hexdigest()[:12]}" class _DigestWriter: - def __init__(self, digest: "hashlib._Hash") -> None: + def __init__(self, digest: hashlib._Hash) -> None: self.digest = digest def write(self, data: bytes) -> int: diff --git a/backend/src/ely_eye/cartridge_assets.py b/backend/src/ely_eye/cartridge_assets.py index f5ff80f..4cbd1eb 100644 --- a/backend/src/ely_eye/cartridge_assets.py +++ b/backend/src/ely_eye/cartridge_assets.py @@ -14,13 +14,12 @@ import pyarrow.parquet as pq import zstandard as zstd from PIL import Image -from .cartridge import CartridgeService +from .cartridge import JSON_PARQUET_COLUMNS, CartridgeService from .config import Settings, get_settings from .db import Database from .schemas import CartridgeAssetReport, CartridgeAssetStatus from .storage import ObjectStore, sha256_file - TEXT_VECTOR_DIMS = 384 VISUAL_VECTOR_DIMS = 256 MEMORY_CAPSULE_SEGMENTS = 4096 @@ -269,7 +268,17 @@ class CartridgeAssetService: def load_cartridge_atoms(path: Path) -> list[dict[str, Any]]: if not path.exists(): raise FileNotFoundError(path) - return pq.read_table(path).to_pylist() + rows = pq.read_table(path).to_pylist() + for row in rows: + for field in JSON_PARQUET_COLUMNS: + try: + row[field] = json.loads(row[field]) + except (TypeError, json.JSONDecodeError) as error: + raise ValueError( + f"{path}: column {field!r} is not readable JSON text ({error}); " + "rebuild the cartridge" + ) from error + return rows def hashed_text_vector(text: str, dims: int) -> np.ndarray: @@ -298,7 +307,7 @@ def visual_image_vector(path: Path, dims: int) -> np.ndarray: def capsule_hash(seed: str, index: int, label: str) -> str: - return hashlib.sha256(f"{seed}:{index}:{label}".encode("utf-8")).hexdigest() + return hashlib.sha256(f"{seed}:{index}:{label}".encode()).hexdigest() def merkle_root(hashes: list[str]) -> str: diff --git a/backend/src/ely_eye/cartridge_store.py b/backend/src/ely_eye/cartridge_store.py new file mode 100644 index 0000000..e4bb9b4 --- /dev/null +++ b/backend/src/ely_eye/cartridge_store.py @@ -0,0 +1,106 @@ +"""Membership and mount state for cartridges, kept out of db.py by the size rule. + +A cartridge used to exist only as files copied out of the store, so "mount this cartridge" +had nothing to filter against. Membership now lives beside the atoms, and the mount table +is what a search scopes itself to when the caller does not name a scope. +""" + +from __future__ import annotations + +import sqlite3 +from datetime import datetime + +from .db import Database +from .row_codec import to_json +from .schemas import utc_now + + +def set_membership(database: Database, cartridge_id: str, atom_ids: list[str]) -> None: + """Replace the atom set a cartridge holds.""" + added_at = utc_now().isoformat() + with database.connect() as connection: + connection.execute("DELETE FROM cartridge_atoms WHERE cartridge_id = ?", (cartridge_id,)) + connection.executemany( + "INSERT OR IGNORE INTO cartridge_atoms(cartridge_id, atom_id, added_at) VALUES (?, ?, ?)", + [(cartridge_id, atom_id, added_at) for atom_id in atom_ids], + ) + + +def add_membership(database: Database, cartridge_id: str, atom_ids: list[str]) -> int: + """Add atoms to a cartridge; returns how many were new.""" + added_at = utc_now().isoformat() + with database.connect() as connection: + before = _count(connection, cartridge_id) + connection.executemany( + "INSERT OR IGNORE INTO cartridge_atoms(cartridge_id, atom_id, added_at) VALUES (?, ?, ?)", + [(cartridge_id, atom_id, added_at) for atom_id in atom_ids], + ) + after = _count(connection, cartridge_id) + return after - before + + +def membership_count(database: Database, cartridge_id: str) -> int: + with database.connect() as connection: + return _count(connection, cartridge_id) + + +def cartridges_holding_atom(database: Database, atom_id: str) -> list[str]: + with database.connect() as connection: + rows = connection.execute( + "SELECT cartridge_id FROM cartridge_atoms WHERE atom_id = ? ORDER BY cartridge_id", + (atom_id,), + ).fetchall() + return [str(row["cartridge_id"]) for row in rows] + + +def atoms_of_cartridge(database: Database, cartridge_id: str) -> list[str]: + with database.connect() as connection: + rows = connection.execute( + "SELECT atom_id FROM cartridge_atoms WHERE cartridge_id = ? ORDER BY atom_id", + (cartridge_id,), + ).fetchall() + return [str(row["atom_id"]) for row in rows] + + +def atoms_of_cartridges(database: Database, cartridge_ids: list[str]) -> set[str]: + """Every atom the named cartridges hold; the scope a search must not leak outside.""" + if not cartridge_ids: + return set() + with database.connect() as connection: + rows = connection.execute( + "SELECT DISTINCT atom_id FROM cartridge_atoms " + "WHERE cartridge_id IN (SELECT value FROM json_each(?))", + (to_json(cartridge_ids),), + ).fetchall() + return {str(row["atom_id"]) for row in rows} + + +def mounted_ids(database: Database) -> list[str]: + with database.connect() as connection: + rows = connection.execute( + "SELECT cartridge_id, mounted_at FROM mounts ORDER BY mounted_at, cartridge_id" + ).fetchall() + return [str(row["cartridge_id"]) for row in rows] + + +def set_mounts(database: Database, cartridge_ids: list[str], mounted_at: datetime) -> None: + """Replace the mounted set; mounting is a whole-state write, not a toggle.""" + stamp = mounted_at.isoformat() + with database.connect() as connection: + connection.execute("DELETE FROM mounts") + connection.executemany( + "INSERT INTO mounts(cartridge_id, mounted_at) VALUES (?, ?)", + [(cartridge_id, stamp) for cartridge_id in cartridge_ids], + ) + + +def clear_mounts(database: Database) -> None: + with database.connect() as connection: + connection.execute("DELETE FROM mounts") + + +def _count(connection: sqlite3.Connection, cartridge_id: str) -> int: + row = connection.execute( + "SELECT COUNT(*) AS n FROM cartridge_atoms WHERE cartridge_id = ?", (cartridge_id,) + ).fetchone() + return int(row["n"]) diff --git a/backend/src/ely_eye/cli.py b/backend/src/ely_eye/cli.py index ca2aaa7..6da06af 100644 --- a/backend/src/ely_eye/cli.py +++ b/backend/src/ely_eye/cli.py @@ -168,7 +168,7 @@ def forget(atom_id: str = "", source_id: str = "", reason: str = "") -> None: def build_dense_index() -> None: index = EmbeddingService(get_settings()).build_index() dimensions = int(index.vectors.shape[1]) if index.vectors.size else 0 - typer.echo({"atoms": len(index.atom_ids), "dimensions": dimensions}) + typer.echo(json_dump({"atoms": len(index.atom_ids), "dimensions": dimensions})) @app.command("list-cartridges") @@ -177,6 +177,38 @@ def list_cartridges() -> None: typer.echo(manifest.model_dump_json(indent=2)) +@app.command("mounts") +def mounts() -> None: + typer.echo(CartridgeService(get_settings()).mount_state().model_dump_json(indent=2)) + + +@app.command() +def mount(cartridge_id: str) -> None: + """Add a cartridge to the mounted set; a mounted search answers only from it.""" + typer.echo(CartridgeService(get_settings()).mount(cartridge_id).model_dump_json(indent=2)) + + +@app.command() +def switch(cartridge_id: str) -> None: + """Mount one cartridge and nothing else.""" + typer.echo(CartridgeService(get_settings()).switch(cartridge_id).model_dump_json(indent=2)) + + +@app.command() +def unmount(cartridge_id: str) -> None: + typer.echo(CartridgeService(get_settings()).unmount(cartridge_id).model_dump_json(indent=2)) + + +@app.command() +def append( + cartridge_id: str, + atom_id: list[str] = typer.Option(..., "--atom-id"), # noqa: B008 # typer reads option defaults itself +) -> None: + """Add memories to an existing cartridge; the bundle and its DNA follow.""" + service = CartridgeService(get_settings()) + typer.echo(service.append(cartridge_id, list(atom_id)).model_dump_json(indent=2)) + + @app.command("hashhop") def hashhop(kind: str = "visual", hops: int = 2, token_equivalent: int = 262_144) -> None: evaluator = HashHopEvaluator(get_settings()) diff --git a/backend/src/ely_eye/config.py b/backend/src/ely_eye/config.py index c4141e6..d4deab7 100644 --- a/backend/src/ely_eye/config.py +++ b/backend/src/ely_eye/config.py @@ -14,6 +14,8 @@ class Settings(BaseSettings): home: Path = Field(default_factory=lambda: Path.cwd() / ".ely_eye") default_holder: str = "local" model_id: str = "Qwen/Qwen3.5-9B" + model_dir: Path | None = None + kv_cache_dtype: str = "auto" planner_model_id: str = "Qwen/Qwen3.5-0.8B" embedding_model_id: str = "Qwen/Qwen3-VL-Embedding-8B" adapter_dir: Path | None = None diff --git a/backend/src/ely_eye/db.py b/backend/src/ely_eye/db.py index a8dbb50..defb298 100644 --- a/backend/src/ely_eye/db.py +++ b/backend/src/ely_eye/db.py @@ -187,6 +187,7 @@ class Database: since: datetime | None = None, until: datetime | None = None, min_trust: float | None = None, + cartridge_ids: list[str] | None = None, ) -> FtsPage: """BM25 over the FTS index joined to memories that are open right now. @@ -215,6 +216,11 @@ class Database: json_extract(a.trust_json, '$.model') ) >= ? )) + AND (? IS NULL OR EXISTS ( + SELECT 1 FROM cartridge_atoms AS ca + WHERE ca.atom_id = f.atom_id + AND ca.cartridge_id IN (SELECT value FROM json_each(?)) + )) ORDER BY score LIMIT ? OFFSET ? """, @@ -234,6 +240,8 @@ class Database: None if until is None else until.isoformat(), min_trust, min_trust, + None if cartridge_ids is None else to_json(cartridge_ids), + None if cartridge_ids is None else to_json(cartridge_ids), limit + 1, offset, ), @@ -330,6 +338,17 @@ class Database: ).fetchall() return [row_to_source(row) for row in rows] + def get_sources(self, source_ids: list[str]) -> list[SourceRecord]: + if not source_ids: + return [] + with self.connect() as connection: + rows = connection.execute( + "SELECT source_id, path, kind, mime, sha256, size_bytes, created_at, metadata_json " + "FROM sources WHERE source_id IN (SELECT value FROM json_each(?))", + (to_json(source_ids),), + ).fetchall() + return [row_to_source(row) for row in rows] + def cartridge_count(self) -> int: with self.connect() as connection: return int(connection.execute("SELECT COUNT(*) FROM cartridges").fetchone()[0]) diff --git a/backend/src/ely_eye/kv_fingerprint.py b/backend/src/ely_eye/kv_fingerprint.py new file mode 100644 index 0000000..59df72d --- /dev/null +++ b/backend/src/ely_eye/kv_fingerprint.py @@ -0,0 +1,196 @@ +"""The compatibility header a cartridge carries, and the check that gates a mount. + +KV bytes are only valid under the RoPE, quantization and engine settings that produced +them (profiles.py launches YaRN factor 4.0 and llama.cpp --cache-type-k q4_0). A cartridge +mounted under different settings does not fail: it silently attends to wrong positions. So +the manifest carries a fingerprint and the mount compares it field by field. +""" + +from __future__ import annotations + +import hashlib +import json +from importlib import metadata +from pathlib import Path +from typing import Any + +from pydantic import BaseModel + +from .config import Settings + +FORMAT_SEMVER = "1.0.0" +UNKNOWN = "" + + +class KvFingerprint(BaseModel): + format_semver: str = FORMAT_SEMVER + model_id: str = UNKNOWN + model_hash: str = UNKNOWN + tokenizer_hash: str = UNKNOWN + chat_template_hash: str = UNKNOWN + rope: dict[str, Any] = {} + context_tokens: int = 0 + kv_dtype: str = UNKNOWN + engine: str = UNKNOWN + engine_version: str = UNKNOWN + adapter_hashes: dict[str, str] = {} + + +def _read_bytes(path: Path) -> bytes | None: + if not path.is_file(): + return None + return path.read_bytes() + + +def _hex(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _config_json(model_dir: Path) -> dict[str, Any]: + data = _read_bytes(model_dir / "config.json") + if data is None: + return {} + try: + parsed = json.loads(data) + except json.JSONDecodeError as error: + raise ValueError(f"{model_dir / 'config.json'}: the model config is unreadable ({error})") from error + if not isinstance(parsed, dict): + raise ValueError(f"{model_dir / 'config.json'}: expected a JSON object") # noqa: TRY004 # the file's bytes are wrong, not the caller's argument + return parsed + + +def _model_hash(model_dir: Path) -> str: + config = _read_bytes(model_dir / "config.json") + if config is None: + return UNKNOWN + digest = hashlib.sha256(config) + index = _read_bytes(model_dir / "model.safetensors.index.json") + if index is not None: + digest.update(index) + return digest.hexdigest() + for shard in sorted(model_dir.glob("*.safetensors")): + digest.update(f"{shard.name}:{shard.stat().st_size}".encode()) + return digest.hexdigest() + + +def _rope(config: dict[str, Any]) -> dict[str, Any]: + rope: dict[str, Any] = {} + for key in ("rope_parameters", "rope_scaling"): + block = config.get(key) + if isinstance(block, dict): + rope.update(block) + for key in ("partial_rotary_factor", "max_position_embeddings", "sliding_window"): + value = config.get(key) + if value is not None: + rope[key] = value + return rope + + +def _chat_template_hash(model_dir: Path, config: dict[str, Any]) -> str: + template = config.get("chat_template") + if not isinstance(template, str): + data = _read_bytes(model_dir / "chat_template.json") + if data is None: + return UNKNOWN + try: + parsed = json.loads(data) + except json.JSONDecodeError as error: + raise ValueError(f"{model_dir / 'chat_template.json'}: unreadable ({error})") from error + template = parsed.get("chat_template") if isinstance(parsed, dict) else None + return _hex(template.encode("utf-8")) if isinstance(template, str) else UNKNOWN + + +def adapter_hashes(adapter_root: Path) -> dict[str, str]: + """Hash every adapter weight under a directory, keyed by adapter kind.""" + found: dict[str, str] = {} + if not adapter_root.is_dir(): + return found + for weight in sorted(adapter_root.rglob("adapter_model.safetensors")): + kind = weight.parent.name + found[kind] = _hex(weight.read_bytes()) + return found + + +def compute_fingerprint(settings: Settings, adapter_root: Path | None = None) -> KvFingerprint: + """Read what this machine can prove; unverifiable fields come back empty.""" + engine_version = UNKNOWN + try: + engine_version = metadata.version( + "transformers" if settings.runtime_backend == "transformers" else "sglang" + ) + except metadata.PackageNotFoundError: + pass # an engine that is not installed here is a fact to report, not a crash + model_dir = settings.model_dir + config = _config_json(model_dir) if model_dir is not None else {} + tokenizer = _read_bytes(model_dir / "tokenizer.json") if model_dir is not None else None + if tokenizer is None and model_dir is not None: + tokenizer = _read_bytes(model_dir / "tokenizer_config.json") + return KvFingerprint( + model_id=settings.model_id, + model_hash=_model_hash(model_dir) if model_dir is not None else UNKNOWN, + tokenizer_hash=_hex(tokenizer) if tokenizer is not None else UNKNOWN, + chat_template_hash=_chat_template_hash(model_dir, config) if model_dir is not None else UNKNOWN, + rope=_rope(config), + context_tokens=settings.live_context_tokens, + kv_dtype=settings.kv_cache_dtype, + engine=settings.runtime_backend, + engine_version=engine_version, + adapter_hashes=adapter_hashes(adapter_root) if adapter_root is not None else {}, + ) + + +def unverifiable_fields(fingerprint: KvFingerprint) -> list[str]: + """Fields this machine cannot prove; a mount must not wave them through.""" + missing: list[str] = [] + for field in ("model_hash", "tokenizer_hash", "chat_template_hash", "engine_version"): + if not getattr(fingerprint, field): + missing.append(field) + if not fingerprint.rope: + missing.append("rope") + return missing + + +def fingerprint_diff(stored: KvFingerprint, live: KvFingerprint) -> list[str]: + """Named differences between a cartridge's header and this machine.""" + differences: list[str] = [] + if stored.format_semver != live.format_semver: + differences.append( + f"format_semver: cartridge {stored.format_semver} vs this build {live.format_semver}" + ) + for field in ( + "model_id", + "model_hash", + "tokenizer_hash", + "chat_template_hash", + "context_tokens", + "kv_dtype", + "engine", + "engine_version", + ): + left, right = getattr(stored, field), getattr(live, field) + if left != right: + differences.append(f"{field}: cartridge {left!r} vs local {right!r}") + if stored.rope != live.rope: + keys = sorted(set(stored.rope) | set(live.rope)) + changes = [ + f"{key}: cartridge {stored.rope.get(key)!r} vs local {live.rope.get(key)!r}" + for key in keys + if stored.rope.get(key) != live.rope.get(key) + ] + differences.append("rope: " + "; ".join(changes)) + if stored.adapter_hashes != live.adapter_hashes: + kinds = sorted(set(stored.adapter_hashes) | set(live.adapter_hashes)) + changes = [ + f"{kind}: cartridge {_short(stored.adapter_hashes.get(kind))} vs local " + f"{_short(live.adapter_hashes.get(kind))}" + for kind in kinds + if stored.adapter_hashes.get(kind) != live.adapter_hashes.get(kind) + ] + differences.append("adapters: " + "; ".join(changes)) + return differences + + +def _short(value: str | None) -> str: + if not value: + return "absent" + return value[:12] diff --git a/backend/src/ely_eye/migrations.py b/backend/src/ely_eye/migrations.py index 7ec8c84..1eefd02 100644 --- a/backend/src/ely_eye/migrations.py +++ b/backend/src/ely_eye/migrations.py @@ -14,7 +14,7 @@ import sqlite3 from .text_index import index_text -SCHEMA_VERSION = 3 +SCHEMA_VERSION = 4 ATOM_COLUMNS_V2 = """ atom_id TEXT PRIMARY KEY, @@ -68,6 +68,20 @@ _BASE_TABLES = """ created_at TEXT NOT NULL ); + -- Membership lives in the live store, not only in the copied parquet, otherwise + -- "mount this cartridge" cannot scope a query against the atoms it holds. + CREATE TABLE IF NOT EXISTS cartridge_atoms ( + cartridge_id TEXT NOT NULL REFERENCES cartridges(cartridge_id) ON DELETE CASCADE, + atom_id TEXT NOT NULL REFERENCES atoms(atom_id) ON DELETE CASCADE, + added_at TEXT NOT NULL, + PRIMARY KEY (cartridge_id, atom_id) + ); + + CREATE TABLE IF NOT EXISTS mounts ( + cartridge_id TEXT PRIMARY KEY REFERENCES cartridges(cartridge_id) ON DELETE CASCADE, + mounted_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS cache_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, diff --git a/backend/src/ely_eye/retrieval.py b/backend/src/ely_eye/retrieval.py index e9c28de..8fea0c8 100644 --- a/backend/src/ely_eye/retrieval.py +++ b/backend/src/ely_eye/retrieval.py @@ -11,6 +11,7 @@ from __future__ import annotations import base64 from collections import defaultdict +from . import cartridge_store from .config import Settings, get_settings from .db import Database from .embeddings import EmbeddingService @@ -44,11 +45,16 @@ class RetrievalService: if limit <= 0: raise ValueError(f"search needs a positive limit, got {limit}") offset = decode_cursor(cursor) + criteria = (filters or SearchFilters()).model_dump(exclude_none=True) + scope = criteria.get("cartridge_ids") + if scope is None: + scope = cartridge_store.mounted_ids(self.db) or None + criteria["cartridge_ids"] = scope page = self.db.search_atoms( query, limit=limit * CANDIDATE_WINDOW, offset=offset, - **(filters or SearchFilters()).model_dump(exclude_none=True), + **criteria, ) rankings: list[list[str]] = [[atom_id for atom_id, _ in page.rows]] scores: dict[str, dict[str, float]] = defaultdict(dict) @@ -56,6 +62,9 @@ class RetrievalService: scores[atom_id]["sparse_score"] = bm25 if self.embeddings.enabled(): dense = self._dense_search(query, limit * CANDIDATE_WINDOW) + if scope: + allowed = cartridge_store.atoms_of_cartridges(self.db, scope) + dense = {atom_id: score for atom_id, score in dense.items() if atom_id in allowed} rankings.append(list(dense)) for atom_id, cosine in dense.items(): scores[atom_id]["dense_score"] = cosine diff --git a/backend/src/ely_eye/schemas.py b/backend/src/ely_eye/schemas.py index 30f0d94..069b00b 100644 --- a/backend/src/ely_eye/schemas.py +++ b/backend/src/ely_eye/schemas.py @@ -7,6 +7,8 @@ from typing import Any, Literal from pydantic import BaseModel, Field, field_validator, model_validator +from .kv_fingerprint import KvFingerprint + def utc_now() -> datetime: return datetime.now(UTC) @@ -162,6 +164,7 @@ class SearchFilters(BaseModel): scenario: str | None = None source_id: str | None = None modality: Modality | None = None + cartridge_ids: list[str] | None = None since: datetime | None = None until: datetime | None = None min_trust: float | None = Field(default=None, ge=0.0, le=1.0) @@ -256,6 +259,18 @@ class SystemStatus(BaseModel): profiles: dict[str, dict[str, Any]] data_home: str +class MountState(BaseModel): + """What is mounted now, and whether that narrows a search.""" + + mounted: list[str] + scoped: bool + atoms_in_scope: int + + +class AppendRequest(BaseModel): + atom_ids: list[str] + + class CartridgeManifest(BaseModel): cartridge_id: str name: str @@ -265,6 +280,7 @@ class CartridgeManifest(BaseModel): atom_count: int token_equivalent: int artifacts: dict[str, str] + kv_fingerprint: KvFingerprint | None = None dna: str | None = None class AdapterManifest(BaseModel): diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..af3fffa --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,53 @@ +"""Shared fixtures: a model snapshot the fingerprint can read without a 9B download.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest + +ROPE: dict[str, Any] = { + "rope_type": "yarn", + "factor": 4.0, + "original_max_position_embeddings": 262_144, + "mrope_interleaved": True, + "mrope_section": [11, 11, 10], +} +TEMPLATE = "{% for message in messages %}{{ message['content'] }}{% endfor %}" + + +def write_model(model_dir: Path, **config_overrides: Any) -> Path: + config: dict[str, Any] = { + "model_type": "qwen3_5", + "max_position_embeddings": 262_144, + "partial_rotary_factor": 0.25, + "rope_parameters": dict(ROPE), + "chat_template": TEMPLATE, + } + config.update(config_overrides) + model_dir.mkdir(parents=True, exist_ok=True) + (model_dir / "config.json").write_text(json.dumps(config), encoding="utf-8") + (model_dir / "tokenizer.json").write_text('{"version": "1.0"}', encoding="utf-8") + (model_dir / "model.safetensors.index.json").write_text( + json.dumps({"metadata": {}, "weight_map": {"model-00001.safetensors": "0"}}), + encoding="utf-8", + ) + return model_dir + + +@pytest.fixture +def chat_template() -> str: + return TEMPLATE + + +@pytest.fixture +def write_model_fixture() -> Callable[..., Path]: + return write_model + + +@pytest.fixture +def model_dir(tmp_path: Path) -> Path: + return write_model(tmp_path / "model") diff --git a/backend/tests/test_cartridge_scope.py b/backend/tests/test_cartridge_scope.py new file mode 100644 index 0000000..1db38ec --- /dev/null +++ b/backend/tests/test_cartridge_scope.py @@ -0,0 +1,284 @@ +"""A cartridge is a retrieval scope: mount it and only it answers, append and the bundle follows.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from datetime import datetime +from pathlib import Path +from typing import Any + +import pyarrow.parquet as pq +import pytest +from fastapi.testclient import TestClient +from typer.testing import CliRunner + +import ely_eye.api as api_module +import ely_eye.cli as cli_module +from ely_eye import cartridge_store +from ely_eye.cartridge import CartridgeService +from ely_eye.cli import app +from ely_eye.config import Settings +from ely_eye.db import Database +from ely_eye.memory import MemoryService +from ely_eye.migrations import SCHEMA_VERSION +from ely_eye.retrieval import RetrievalService +from ely_eye.schemas import MemoryOrigin, SearchFilters + +HOME_KEYS = "我们的钥匙放在玄关抽屉里" +WORK_KEYS = "备用钥匙在办公室第二个抽屉里" + + +class Store: + """One SQLite file, the four services that read and write it.""" + + def __init__(self, settings: Settings) -> None: + self.settings = settings + self.memory = MemoryService(settings=settings) + self.db = self.memory.db + self.cartridges = CartridgeService(settings=settings, database=self.db) + self.search = RetrievalService(settings=settings, database=self.db) + + def remember(self, text: str, scenario: str) -> str: + atom = self.memory.remember( + text, holder_id="wife", origin=MemoryOrigin.self_, scenario=scenario + ) + return atom.atom_id + + def cartridge(self, name: str, atom_ids: list[str]) -> str: + atoms = self.db.get_atoms(atom_ids) + sources = self.db.get_sources(sorted({atom.source_id for atom in atoms})) + return self.cartridges.materialize(name, atoms, sources).cartridge_id + + def texts(self, query: str = "钥匙", **filters: Any) -> list[str]: + result = self.search.search(query, filters=SearchFilters(**filters) if filters else None) + return [hit.atom.text for hit in result.hits] + + +@pytest.fixture +def store(tmp_path: Path, model_dir: Path) -> Store: + return Store(Settings(home=tmp_path / "home", model_dir=model_dir, default_holder="wife")) + + +def test_mounting_one_cartridge_stops_the_other_from_answering(store: Store) -> None: + home = store.cartridge("home", [store.remember(HOME_KEYS, "home")]) + work = store.cartridge("work", [store.remember(WORK_KEYS, "work")]) + assert sorted(store.texts()) == sorted([HOME_KEYS, WORK_KEYS]) + + store.cartridges.switch(home) + assert store.texts() == [HOME_KEYS] + store.cartridges.switch(work) + assert store.texts() == [WORK_KEYS] + + +def test_an_explicit_scope_overrides_what_is_mounted(store: Store) -> None: + home = store.cartridge("home", [store.remember(HOME_KEYS, "home")]) + work = store.cartridge("work", [store.remember(WORK_KEYS, "work")]) + store.cartridges.switch(home) + assert store.texts(cartridge_ids=[work]) == [WORK_KEYS] + + +def test_nothing_mounted_answers_from_the_whole_library(store: Store) -> None: + store.cartridge("home", [store.remember(HOME_KEYS, "home")]) + state = store.cartridges.mount_state() + assert state.mounted == [] + assert state.scoped is False + assert state.atoms_in_scope == 0 + assert store.texts() == [HOME_KEYS] + + +def test_mounting_two_cartridges_answers_from_both(store: Store) -> None: + home = store.cartridge("home", [store.remember(HOME_KEYS, "home")]) + work = store.cartridge("work", [store.remember(WORK_KEYS, "work")]) + store.cartridges.mount(home) + store.cartridges.mount(work) + state = store.cartridges.mount_state() + assert sorted(state.mounted) == sorted([home, work]) + assert state.atoms_in_scope == 2 + assert sorted(store.texts()) == sorted([HOME_KEYS, WORK_KEYS]) + + +def test_unmounting_reports_the_state_and_refuses_a_stranger(store: Store) -> None: + home = store.cartridge("home", [store.remember(HOME_KEYS, "home")]) + store.cartridges.mount(home) + state = store.cartridges.unmount(home) + assert state.mounted == [] and state.scoped is False + with pytest.raises(ValueError, match=f"{home} is not mounted; nothing to unmount"): + store.cartridges.unmount(home) + + +def test_append_adds_a_memory_and_the_bundle_follows(store: Store) -> None: + home = store.cartridge("home", [store.remember(HOME_KEYS, "home")]) + before = next(m for m in store.cartridges.list_manifests() if m.cartridge_id == home) + later = store.remember("备用钥匙在办公室第二个抽屉里", "home") + after = store.cartridges.append(home, [later]) + + assert after.dna != before.dna + assert after.atom_count == before.atom_count + 1 + assert cartridge_store.membership_count(store.db, home) == 2 + rows = pq.read_table(store.settings.cartridge_dir / home / "atoms.parquet").num_rows + assert rows == before.atom_count + 1 + + store.cartridges.switch(home) + assert sorted(store.texts()) == sorted([HOME_KEYS, WORK_KEYS]) + + +def test_append_refuses_atoms_that_are_not_in_the_store(store: Store) -> None: + home = store.cartridge("home", [store.remember(HOME_KEYS, "home")]) + with pytest.raises(ValueError, match="not in the store: mem-missing:text"): + store.cartridges.append(home, ["mem-missing:text"]) + + +def test_append_needs_something_to_append(store: Store) -> None: + home = store.cartridge("home", [store.remember(HOME_KEYS, "home")]) + with pytest.raises(ValueError, match="append needs at least one atom"): + store.cartridges.append(home, []) + + +def test_a_cartridge_without_a_fingerprint_cannot_be_mounted(store: Store) -> None: + home = store.cartridge("home", [store.remember(HOME_KEYS, "home")]) + row = next(r for r in store.db.list_cartridges() if r["cartridge_id"] == home) + manifest = json.loads(row["manifest_json"]) + manifest["kv_fingerprint"] = None + store.db.write_cartridge( + cartridge_id=home, + name=row["name"], + root_path=Path(row["root_path"]), + manifest_json=json.dumps(manifest), + dna=row["dna"], + token_equivalent=row["token_equivalent"], + created_at=datetime.fromisoformat(row["created_at"]), + ) + with pytest.raises(ValueError, match="carries no kv fingerprint; rebuild it before mounting"): + store.cartridges.mount(home) + + +def test_a_mount_refuses_a_different_rope_and_names_the_field( + store: Store, model_dir: Path, write_model_fixture: Callable[..., Path] +) -> None: + home = store.cartridge("home", [store.remember(HOME_KEYS, "home")]) + write_model_fixture(model_dir, rope_parameters={"rope_type": "yarn", "factor": 2.0}) + with pytest.raises(ValueError, match="rope: factor: cartridge 4.0 vs local 2.0"): + store.cartridges.mount(home) + + +def test_a_mount_refuses_when_the_machine_cannot_prove_the_model(store: Store) -> None: + home = store.cartridge("home", [store.remember(HOME_KEYS, "home")]) + blind = CartridgeService( + settings=Settings(home=store.settings.home, model_dir=None), database=store.db + ) + with pytest.raises(ValueError, match="model_hash, tokenizer_hash, chat_template_hash, rope"): + blind.mount(home) + + +def test_forgetting_a_memory_takes_it_out_of_the_cartridge(store: Store) -> None: + atom_id = store.remember(HOME_KEYS, "home") + home = store.cartridge("home", [atom_id]) + store.cartridges.mount(home) + store.memory.forget(atom_id=atom_id, reason="moved") + assert cartridge_store.membership_count(store.db, home) == 0 + assert store.texts() == [] + + +def upload_home(client: TestClient) -> str: + answer = client.post( + "/api/ingest/upload?cartridge_name=home", + files={"files": ("pantry.txt", b"the pantry keeps flour", "text/plain")}, + ) + assert answer.status_code == 200, answer.text + return answer.json()["cartridge_id"] + + +def test_the_api_mounts_and_reports_the_scope( + tmp_path: Path, model_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + settings = Settings(home=tmp_path / "home", model_dir=model_dir, default_holder="wife") + monkeypatch.setattr(api_module, "get_settings", lambda: settings) + client = TestClient(api_module.create_app()) + + atom = client.post("/api/memory", json={"text": HOME_KEYS, "scenario": "home"}).json() + assert client.get("/api/mounts").json() == {"mounted": [], "scoped": False, "atoms_in_scope": 0} + cartridge_id = upload_home(client) + + answer = client.post(f"/api/cartridges/{cartridge_id}/mount") + assert answer.status_code == 200 + assert answer.json()["kv_fingerprint"]["rope"]["factor"] == 4.0 + assert client.get("/api/mounts").json() == { + "mounted": [cartridge_id], + "scoped": True, + "atoms_in_scope": 2, + } + hits = client.post("/api/search", json={"query": "钥匙"}).json()["hits"] + assert [hit["atom"]["atom_id"] for hit in hits] == [atom["atom_id"]] + + stranger = client.post("/api/cartridges/cartridge-does-not-exist/mount") + assert stranger.status_code == 400 and "Unknown cartridge" in stranger.json()["detail"] + assert client.post(f"/api/cartridges/{cartridge_id}/unmount").status_code == 200 + assert client.post(f"/api/cartridges/{cartridge_id}/unmount").status_code == 400 + + +def test_the_api_appends_and_refuses_an_unknown_atom( + tmp_path: Path, model_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + settings = Settings(home=tmp_path / "home", model_dir=model_dir, default_holder="wife") + monkeypatch.setattr(api_module, "get_settings", lambda: settings) + client = TestClient(api_module.create_app()) + client.post("/api/memory", json={"text": HOME_KEYS, "scenario": "home"}) + cartridge_id = upload_home(client) + + answer = client.post( + f"/api/cartridges/{cartridge_id}/append", json={"atom_ids": ["mem-nope:text"]} + ) + assert answer.status_code == 400 and "not in the store" in answer.json()["detail"] + + extra = client.post("/api/memory", json={"text": WORK_KEYS, "scenario": "home"}).json() + grown = client.post( + f"/api/cartridges/{cartridge_id}/append", json={"atom_ids": [extra["atom_id"]]} + ) + assert grown.status_code == 200 and grown.json()["atom_count"] == 3 + + +def test_the_cli_mounts_and_answers_in_json( + tmp_path: Path, model_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + settings = Settings(home=tmp_path / "home", model_dir=model_dir, default_holder="wife") + monkeypatch.setattr(cli_module, "get_settings", lambda: settings) + runner = CliRunner() + store = Store(settings) + cartridge_id = store.cartridge("home", [store.remember(HOME_KEYS, "home")]) + + mounted = runner.invoke(app, ["mount", cartridge_id]) + assert mounted.exit_code == 0, mounted.output + assert json.loads(mounted.output)["cartridge_id"] == cartridge_id + + state = json.loads(runner.invoke(app, ["mounts"]).output) + assert state == {"mounted": [cartridge_id], "scoped": True, "atoms_in_scope": 1} + + refused = runner.invoke(app, ["mount", "cartridge-does-not-exist"]) + assert isinstance(refused.exception, ValueError) and "Unknown cartridge" in str( + refused.exception + ) + + +def test_a_third_version_store_gains_the_scope_tables_on_upgrade( + tmp_path: Path, model_dir: Path +) -> None: + settings = Settings(home=tmp_path / "home", model_dir=model_dir) + first = Database(settings) + with first.connect() as connection: + connection.execute("DROP TABLE cartridge_atoms") + connection.execute("DROP TABLE mounts") + connection.execute("PRAGMA user_version = 3") + + reopened = Database(settings) + with reopened.connect() as connection: + tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ).fetchall() + } + version = connection.execute("PRAGMA user_version").fetchone()[0] + assert {"cartridge_atoms", "mounts"} <= tables + assert version == SCHEMA_VERSION + assert cartridge_store.mounted_ids(reopened) == [] diff --git a/backend/tests/test_kv_fingerprint.py b/backend/tests/test_kv_fingerprint.py new file mode 100644 index 0000000..fdd9105 --- /dev/null +++ b/backend/tests/test_kv_fingerprint.py @@ -0,0 +1,138 @@ +"""KV bytes are only valid under the settings that produced them, so the header must say so.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from importlib import metadata +from pathlib import Path +from typing import Any + +import pytest + +from ely_eye.config import Settings +from ely_eye.kv_fingerprint import ( + FORMAT_SEMVER, + KvFingerprint, + adapter_hashes, + compute_fingerprint, + fingerprint_diff, + unverifiable_fields, +) + + +def settings_for(model_dir: Path | None, home: Path, **overrides: Any) -> Settings: + fields: dict[str, Any] = {"runtime_backend": "transformers", **overrides} + return Settings(home=home, model_dir=model_dir, **fields) + + +def test_a_readable_snapshot_leaves_nothing_unverifiable( + tmp_path: Path, write_model_fixture: Callable[..., Path] +) -> None: + fingerprint = compute_fingerprint( + settings_for(write_model_fixture(tmp_path / "model"), tmp_path) + ) + assert unverifiable_fields(fingerprint) == [] + assert fingerprint.format_semver == FORMAT_SEMVER + assert fingerprint.rope["factor"] == 4.0 + assert fingerprint.engine_version == metadata.version("transformers") + + +def test_a_machine_without_the_model_says_what_it_cannot_prove(tmp_path: Path) -> None: + gaps = unverifiable_fields(compute_fingerprint(settings_for(None, tmp_path))) + assert gaps == ["model_hash", "tokenizer_hash", "chat_template_hash", "rope"] + + +def test_an_engine_that_is_not_installed_is_reported_not_guessed( + tmp_path: Path, write_model_fixture: Callable[..., Path] +) -> None: + settings = settings_for( + write_model_fixture(tmp_path / "model"), tmp_path, runtime_backend="sglang" + ) + assert "engine_version" in unverifiable_fields(compute_fingerprint(settings)) + + +def test_different_weights_change_the_model_hash_and_are_named( + tmp_path: Path, write_model_fixture: Callable[..., Path] +) -> None: + model = write_model_fixture(tmp_path / "model") + before = compute_fingerprint(settings_for(model, tmp_path)) + (model / "model.safetensors.index.json").write_text( + json.dumps({"metadata": {}, "weight_map": {"model-00002.safetensors": "0"}}), + encoding="utf-8", + ) + differences = fingerprint_diff(before, compute_fingerprint(settings_for(model, tmp_path))) + assert len(differences) == 1 + assert differences[0].startswith("model_hash: cartridge ") + + +def test_a_rope_change_names_the_field_it_moves( + tmp_path: Path, write_model_fixture: Callable[..., Path] +) -> None: + model = write_model_fixture(tmp_path / "model") + before = compute_fingerprint(settings_for(model, tmp_path)) + write_model_fixture(model, rope_parameters={"rope_type": "yarn", "factor": 2.0}) + differences = fingerprint_diff(before, compute_fingerprint(settings_for(model, tmp_path))) + rope = [line for line in differences if line.startswith("rope: ")] + assert len(rope) == 1 + assert "factor: cartridge 4.0 vs local 2.0" in rope[0] + assert any(line.startswith("model_hash: ") for line in differences) + + +def test_a_template_in_a_sidecar_file_hashes_the_same( + tmp_path: Path, write_model_fixture: Callable[..., Path], chat_template: str +) -> None: + in_config = write_model_fixture(tmp_path / "a") + sidecar = write_model_fixture(tmp_path / "b", chat_template=None) + (sidecar / "chat_template.json").write_text( + json.dumps({"chat_template": chat_template}), encoding="utf-8" + ) + assert compute_fingerprint(settings_for(in_config, tmp_path)).chat_template_hash == ( + compute_fingerprint(settings_for(sidecar, tmp_path)).chat_template_hash + ) + + +def test_adapter_weights_are_hashed_by_kind(tmp_path: Path) -> None: + adapters = tmp_path / "adapters" / "hme-router" + adapters.mkdir(parents=True) + (adapters / "adapter_model.safetensors").write_bytes(b"weights-one") + found = adapter_hashes(tmp_path / "adapters") + assert list(found) == ["hme-router"] + (adapters / "adapter_model.safetensors").write_bytes(b"weights-two") + assert found != adapter_hashes(tmp_path / "adapters") + + +def test_a_missing_adapter_shows_up_as_absent( + tmp_path: Path, write_model_fixture: Callable[..., Path] +) -> None: + adapters = tmp_path / "adapters" / "hme-router" + adapters.mkdir(parents=True) + (adapters / "adapter_model.safetensors").write_bytes(b"weights") + model = write_model_fixture(tmp_path / "model") + stored = compute_fingerprint(settings_for(model, tmp_path), tmp_path / "adapters") + live = compute_fingerprint(settings_for(model, tmp_path), tmp_path / "gone") + differences = fingerprint_diff(stored, live) + assert len(differences) == 1 + assert differences[0].startswith("adapters: hme-router: cartridge ") + assert differences[0].endswith("vs local absent") + + +def test_a_fingerprint_format_change_refuses_the_mount( + tmp_path: Path, write_model_fixture: Callable[..., Path] +) -> None: + live = compute_fingerprint( + settings_for(write_model_fixture(tmp_path / "model"), tmp_path) + ) + stored = KvFingerprint.model_validate({**live.model_dump(), "format_semver": "0.9.0"}) + assert fingerprint_diff(stored, live) == [ + f"format_semver: cartridge 0.9.0 vs this build {FORMAT_SEMVER}" + ] + + +def test_an_unreadable_model_config_fails_loudly( + tmp_path: Path, write_model_fixture: Callable[..., Path] +) -> None: + model = write_model_fixture(tmp_path / "model") + (model / "config.json").write_text("not json", encoding="utf-8") + with pytest.raises(ValueError, match="the model config is unreadable"): + compute_fingerprint(settings_for(model, tmp_path)) diff --git a/ely-eye-mcp/internal/apiclient/client.go b/ely-eye-mcp/internal/apiclient/client.go index e90b591..a205840 100644 --- a/ely-eye-mcp/internal/apiclient/client.go +++ b/ely-eye-mcp/internal/apiclient/client.go @@ -34,19 +34,16 @@ func New(baseURL string) *Client { } } -// Post sends body as JSON and decodes the response into out. A rejected request -// carries the server's own reason, which is the rule the caller broke. -func (c *Client) Post(ctx context.Context, path string, body any, out any) error { - payload, err := json.Marshal(body) - if err != nil { - return fmt.Errorf("cannot encode the request for %s: %w", path, err) - } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, bytes.NewReader(payload)) +// Get fetches a JSON resource into out. +func (c *Client) Get(ctx context.Context, path string, out any) error { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil) if err != nil { return fmt.Errorf("cannot build the request for %s: %w", path, err) } - request.Header.Set("Content-Type", "application/json") + return c.do(request, path, out) +} +func (c *Client) do(request *http.Request, path string, out any) error { response, err := c.HTTP.Do(request) if err != nil { return fmt.Errorf("the Ely-Eye API at %s is unreachable; start it with `ely-eye serve`: %w", c.BaseURL, err) @@ -69,6 +66,21 @@ func (c *Client) Post(ctx context.Context, path string, body any, out any) error return nil } +// Post sends body as JSON and decodes the response into out. A rejected request +// carries the server's own reason, which is the rule the caller broke. +func (c *Client) Post(ctx context.Context, path string, body any, out any) error { + payload, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("cannot encode the request for %s: %w", path, err) + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("cannot build the request for %s: %w", path, err) + } + request.Header.Set("Content-Type", "application/json") + return c.do(request, path, out) +} + // serverReason prefers FastAPI's {"detail": "..."} so the caller sees the broken // rule instead of an HTML error page. func serverReason(status int, data []byte) string { diff --git a/ely-eye-mcp/internal/domain/types.go b/ely-eye-mcp/internal/domain/types.go index f201187..915d270 100644 --- a/ely-eye-mcp/internal/domain/types.go +++ b/ely-eye-mcp/internal/domain/types.go @@ -71,6 +71,28 @@ type CartridgeDetail struct { AssetReport any `json:"asset_report,omitempty"` } +// MountState is what the server lets a search see right now. +type MountState struct { + Mounted []string `json:"mounted"` + Scoped bool `json:"scoped"` + AtomsInScope int `json:"atoms_in_scope"` +} + +// CartridgeManifest is the header a cartridge carries, including the KV fingerprint +// that decides whether it may be mounted on this machine. +type CartridgeManifest struct { + CartridgeID string `json:"cartridge_id"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` + ModelBase string `json:"model_base"` + SourceCount int `json:"source_count"` + AtomCount int `json:"atom_count"` + TokenEquivalent int64 `json:"token_equivalent"` + Artifacts map[string]string `json:"artifacts"` + KVFingerprint map[string]any `json:"kv_fingerprint,omitempty"` + DNA string `json:"dna,omitempty"` +} + type Atom struct { AtomID string `json:"atom_id"` SourceID string `json:"source_id"` diff --git a/ely-eye-mcp/internal/mcpserver/mount_tools.go b/ely-eye-mcp/internal/mcpserver/mount_tools.go new file mode 100644 index 0000000..2e31896 --- /dev/null +++ b/ely-eye-mcp/internal/mcpserver/mount_tools.go @@ -0,0 +1,111 @@ +package mcpserver + +import ( + "context" + "errors" + + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/domain" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type CartridgeIDInput struct { + CartridgeID string `json:"cartridge_id" jsonschema:"exact cartridge id"` +} + +type AppendInput struct { + CartridgeID string `json:"cartridge_id" jsonschema:"exact cartridge id"` + AtomIDs []string `json:"atom_ids" jsonschema:"atom ids to add to this cartridge"` +} + +type MountOutput struct { + Manifest domain.CartridgeManifest `json:"manifest"` +} + +type MountStateOutput struct { + State domain.MountState `json:"state"` +} + +// registerMountTools exposes the cartridge as a retrieval scope: the point of a +// cartridge is that the model stops hunting the whole library and asks one cartridge. +func (s *Server) registerMountTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "mounted_cartridges", + Title: "Mounted cartridges", + Description: "Report which cartridges are mounted. A mounted cartridge narrows every later search; nothing mounted means the whole library is searchable.", + }, s.mountsTool) + mcp.AddTool(server, &mcp.Tool{ + Name: "mount_cartridge", + Title: "Mount a cartridge", + Description: "Add one cartridge to the mounted set. The server refuses a cartridge whose KV fingerprint does not match this machine, because mismatched RoPE or quantization settings attend to wrong positions instead of failing.", + }, s.mountTool) + mcp.AddTool(server, &mcp.Tool{ + Name: "switch_cartridge", + Title: "Switch to one cartridge", + Description: "Mount one cartridge and nothing else.", + }, s.switchTool) + mcp.AddTool(server, &mcp.Tool{ + Name: "unmount_cartridge", + Title: "Unmount a cartridge", + Description: "Remove one cartridge from the mounted set and report what is left.", + }, s.unmountTool) + mcp.AddTool(server, &mcp.Tool{ + Name: "append_to_cartridge", + Title: "Append memories to a cartridge", + Description: "Add atoms to an existing cartridge. The bundle and its DNA are rewritten, so the cartridge id keeps describing the bytes it holds.", + }, s.appendTool) +} + +func (s *Server) mountsTool(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, MountStateOutput, error) { + var state domain.MountState + if err := s.api.Get(ctx, "/api/mounts", &state); err != nil { + return nil, MountStateOutput{}, err + } + return nil, MountStateOutput{State: state}, nil +} + +func (s *Server) mountTool(ctx context.Context, _ *mcp.CallToolRequest, input CartridgeIDInput) (*mcp.CallToolResult, MountOutput, error) { + if input.CartridgeID == "" { + return nil, MountOutput{}, errors.New("cartridge_id is required") + } + var manifest domain.CartridgeManifest + if err := s.api.Post(ctx, "/api/cartridges/"+input.CartridgeID+"/mount", struct{}{}, &manifest); err != nil { + return nil, MountOutput{}, err + } + return nil, MountOutput{Manifest: manifest}, nil +} + +func (s *Server) switchTool(ctx context.Context, _ *mcp.CallToolRequest, input CartridgeIDInput) (*mcp.CallToolResult, MountOutput, error) { + if input.CartridgeID == "" { + return nil, MountOutput{}, errors.New("cartridge_id is required") + } + var manifest domain.CartridgeManifest + if err := s.api.Post(ctx, "/api/cartridges/"+input.CartridgeID+"/switch", struct{}{}, &manifest); err != nil { + return nil, MountOutput{}, err + } + return nil, MountOutput{Manifest: manifest}, nil +} + +func (s *Server) unmountTool(ctx context.Context, _ *mcp.CallToolRequest, input CartridgeIDInput) (*mcp.CallToolResult, MountStateOutput, error) { + if input.CartridgeID == "" { + return nil, MountStateOutput{}, errors.New("cartridge_id is required") + } + var state domain.MountState + if err := s.api.Post(ctx, "/api/cartridges/"+input.CartridgeID+"/unmount", struct{}{}, &state); err != nil { + return nil, MountStateOutput{}, err + } + return nil, MountStateOutput{State: state}, nil +} + +func (s *Server) appendTool(ctx context.Context, _ *mcp.CallToolRequest, input AppendInput) (*mcp.CallToolResult, MountOutput, error) { + if input.CartridgeID == "" { + return nil, MountOutput{}, errors.New("cartridge_id is required") + } + if len(input.AtomIDs) == 0 { + return nil, MountOutput{}, errors.New("atom_ids needs at least one atom") + } + var manifest domain.CartridgeManifest + if err := s.api.Post(ctx, "/api/cartridges/"+input.CartridgeID+"/append", input, &manifest); err != nil { + return nil, MountOutput{}, err + } + return nil, MountOutput{Manifest: manifest}, nil +} diff --git a/ely-eye-mcp/internal/mcpserver/mount_tools_test.go b/ely-eye-mcp/internal/mcpserver/mount_tools_test.go new file mode 100644 index 0000000..16fe055 --- /dev/null +++ b/ely-eye-mcp/internal/mcpserver/mount_tools_test.go @@ -0,0 +1,146 @@ +package mcpserver + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/apiclient" +) + +const manifestBody = `{"cartridge_id":"home_1","name":"home","created_at":"2026-05-01T12:00:00Z",` + + `"model_base":"Qwen/Qwen3.5-9B","source_count":1,"atom_count":2,"token_equivalent":40,` + + `"artifacts":{"atoms":"atoms.parquet"},"dna":"deadbeef",` + + `"kv_fingerprint":{"format_semver":"1.0.0","rope":{"factor":4.0}}}` + +func mountServer(t *testing.T, status int, body string, seen *[]string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *seen = append(*seen, r.Method+" "+r.URL.Path) + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) +} + +func TestMountedCartridgesAsksTheServer(t *testing.T) { + var seen []string + server := mountServer(t, http.StatusOK, `{"mounted":["home_1"],"scoped":true,"atoms_in_scope":2}`, &seen) + defer server.Close() + + s := &Server{api: apiclient.New(server.URL)} + _, out, err := s.mountsTool(context.Background(), nil, struct{}{}) + if err != nil { + t.Fatalf("mounted_cartridges failed: %v", err) + } + if !out.State.Scoped || out.State.AtomsInScope != 2 || len(out.State.Mounted) != 1 { + t.Errorf("mount state = %+v, want the server's scope", out.State) + } + if len(seen) != 1 || seen[0] != "GET /api/mounts" { + t.Errorf("requests = %v, want one GET to /api/mounts", seen) + } +} + +func TestMountCartridgeReturnsTheManifestItMounted(t *testing.T) { + var seen []string + server := mountServer(t, http.StatusOK, manifestBody, &seen) + defer server.Close() + + s := &Server{api: apiclient.New(server.URL)} + _, out, err := s.mountTool(context.Background(), nil, CartridgeIDInput{CartridgeID: "home_1"}) + if err != nil { + t.Fatalf("mount_cartridge failed: %v", err) + } + if out.Manifest.CartridgeID != "home_1" || out.Manifest.DNA != "deadbeef" { + t.Errorf("manifest = %+v, want the mounted cartridge", out.Manifest) + } + if out.Manifest.KVFingerprint["rope"] == nil { + t.Error("manifest lost the kv fingerprint that gated the mount") + } + if len(seen) != 1 || seen[0] != "POST /api/cartridges/home_1/mount" { + t.Errorf("requests = %v, want one POST to the mount route", seen) + } +} + +func TestSwitchAndUnmountUseTheirOwnRoutes(t *testing.T) { + var seen []string + server := mountServer(t, http.StatusOK, manifestBody, &seen) + defer server.Close() + + s := &Server{api: apiclient.New(server.URL)} + if _, _, err := s.switchTool(context.Background(), nil, CartridgeIDInput{CartridgeID: "home_1"}); err != nil { + t.Fatalf("switch_cartridge failed: %v", err) + } + if _, _, err := s.unmountTool(context.Background(), nil, CartridgeIDInput{CartridgeID: "home_1"}); err != nil { + t.Fatalf("unmount_cartridge failed: %v", err) + } + want := []string{"POST /api/cartridges/home_1/switch", "POST /api/cartridges/home_1/unmount"} + if strings.Join(seen, ",") != strings.Join(want, ",") { + t.Errorf("requests = %v, want %v", seen, want) + } +} + +func TestMountRefusalCarriesTheServersReason(t *testing.T) { + var seen []string + server := mountServer(t, http.StatusBadRequest, + `{"detail":"home_1 was built for a different runtime; refusing to mount: rope: factor: cartridge 4.0 vs local 2.0"}`, + &seen) + defer server.Close() + + s := &Server{api: apiclient.New(server.URL)} + _, _, err := s.mountTool(context.Background(), nil, CartridgeIDInput{CartridgeID: "home_1"}) + if err == nil { + t.Fatal("an incompatible cartridge must not mount quietly") + } + if !strings.Contains(err.Error(), "rope: factor: cartridge 4.0 vs local 2.0") { + t.Errorf("error = %v, want the field the server named", err) + } +} + +func TestMountToolsRefuseAnEmptyCartridgeID(t *testing.T) { + var seen []string + server := mountServer(t, http.StatusOK, manifestBody, &seen) + defer server.Close() + + s := &Server{api: apiclient.New(server.URL)} + if _, _, err := s.mountTool(context.Background(), nil, CartridgeIDInput{}); err == nil { + t.Error("mount without an id must fail before the network") + } + if _, _, err := s.switchTool(context.Background(), nil, CartridgeIDInput{}); err == nil { + t.Error("switch without an id must fail before the network") + } + if _, _, err := s.unmountTool(context.Background(), nil, CartridgeIDInput{}); err == nil { + t.Error("unmount without an id must fail before the network") + } + if _, _, err := s.appendTool(context.Background(), nil, AppendInput{CartridgeID: "home_1"}); err == nil { + t.Error("append without atoms must fail before the network") + } + if len(seen) != 0 { + t.Errorf("requests = %v, want none", seen) + } +} + +func TestAppendSendsTheAtomsItNames(t *testing.T) { + var seen []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := make([]byte, 512) + n, _ := r.Body.Read(body) + seen = append(seen, r.URL.Path+" "+string(body[:n])) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(manifestBody)) + })) + defer server.Close() + + s := &Server{api: apiclient.New(server.URL)} + _, out, err := s.appendTool(context.Background(), nil, AppendInput{CartridgeID: "home_1", AtomIDs: []string{"mem-1:text"}}) + if err != nil { + t.Fatalf("append_to_cartridge failed: %v", err) + } + if out.Manifest.AtomCount != 2 { + t.Errorf("manifest = %+v, want the rewritten atom count", out.Manifest) + } + if len(seen) != 1 || !strings.Contains(seen[0], "/append") || !strings.Contains(seen[0], "mem-1:text") { + t.Errorf("requests = %v, want one append carrying the atom id", seen) + } +} diff --git a/ely-eye-mcp/internal/mcpserver/server.go b/ely-eye-mcp/internal/mcpserver/server.go index 8fb315c..5a50415 100644 --- a/ely-eye-mcp/internal/mcpserver/server.go +++ b/ely-eye-mcp/internal/mcpserver/server.go @@ -33,10 +33,11 @@ func Run(ctx context.Context, cfg config.Config, version string) error { Title: "Ely-Eye Context Cartridge MCP", Version: version, }, &mcp.ServerOptions{ - Instructions: "Use Ely-Eye tools to retrieve local cartridge context, evidence atoms, proof suites, and compiled evidence packs. Read tools mirror local Ely-Eye state without writing. remember, supersede_memory and forget_memory mutate memory through the Ely-Eye API, which must be running (`ely-eye serve`); the server, not the caller, decides who owns a memory.", + Instructions: "Use Ely-Eye tools to retrieve local cartridge context, evidence atoms, proof suites, and compiled evidence packs. Read tools mirror local Ely-Eye state without writing. remember, supersede_memory and forget_memory mutate memory through the Ely-Eye API, which must be running (`ely-eye serve`); the server, not the caller, decides who owns a memory. mount_cartridge and switch_cartridge decide what a search may see: mount a cartridge to ask only that cartridge, and unmount everything to ask the whole library.", }) server.registerTools(mcpServer) server.registerWriteTools(mcpServer) + server.registerMountTools(mcpServer) server.registerResources(mcpServer) if err := mcpServer.Run(ctx, &mcp.StdioTransport{}); err != nil { if errors.Is(err, io.EOF) || strings.HasSuffix(err.Error(), "EOF") { diff --git a/scripts/mcp_write_smoke.sh b/scripts/mcp_write_smoke.sh index 41f679d..9e96b15 100755 --- a/scripts/mcp_write_smoke.sh +++ b/scripts/mcp_write_smoke.sh @@ -11,6 +11,19 @@ export ELY_EYE_API="http://127.0.0.1:${port}" python_bin="$root/.venv/bin/python" [ -x "$python_bin" ] || python_bin="python3" +# A model snapshot the KV fingerprint can read: without it every mount is refused as +# unverifiable, which is the behaviour, but the smoke test also needs a mount to succeed. +model_dir="$home/model" +mkdir -p "$model_dir" +cat > "$model_dir/config.json" <<'JSON' +{"model_type": "qwen3_5", "max_position_embeddings": 262144, "partial_rotary_factor": 0.25, + "rope_parameters": {"rope_type": "yarn", "factor": 4.0, "mrope_section": [11, 11, 10]}, + "chat_template": "{% for message in messages %}{{ message['content'] }}{% endfor %}"} +JSON +printf '{"version": "1.0"}' > "$model_dir/tokenizer.json" +printf '{"metadata": {}, "weight_map": {"model-00001.safetensors": "0"}}' > "$model_dir/model.safetensors.index.json" +export ELY_EYE_MODEL_DIR="$model_dir" + api="" cleanup() { if [ -n "$api" ]; then kill "$api" 2>/dev/null || true; fi @@ -34,12 +47,24 @@ fi (cd "$root/ely-eye-mcp" && go build -o "$home/ely-eye-mcp" ./cmd/ely-eye-mcp) -"$python_bin" - "$home/ely-eye-mcp" <<'PY' +printf 'the pantry keeps flour\n' > "$home/pantry.txt" +uploaded="$(curl -sf -X POST "http://127.0.0.1:${port}/api/ingest/upload?cartridge_name=home" \ + -F "files=@${home}/pantry.txt;type=text/plain")" +cartridge_id="$(printf '%s' "$uploaded" | "$python_bin" -c 'import json,sys; print(json.load(sys.stdin)["cartridge_id"])')" +if [ -z "$cartridge_id" ] || [ "$cartridge_id" = "None" ]; then + echo "FAIL: the upload did not materialize a cartridge: $uploaded" + exit 1 +fi + +"$python_bin" - "$home/ely-eye-mcp" "$cartridge_id" "$model_dir" <<'PY' import json +import pathlib import subprocess import sys binary = sys.argv[1] +cartridge_id = sys.argv[2] +model_dir = pathlib.Path(sys.argv[3]) proc = subprocess.Popen( [binary, "server"], stdin=subprocess.PIPE, @@ -82,7 +107,8 @@ call(1, "initialize", { send({"jsonrpc": "2.0", "method": "notifications/initialized"}) names = [item["name"] for item in call(2, "tools/list", {})["tools"]] -for required in ("remember", "supersede_memory", "forget_memory"): +for required in ("remember", "supersede_memory", "forget_memory", "mounted_cartridges", + "mount_cartridge", "switch_cartridge", "unmount_cartridge", "append_to_cartridge"): if required not in names: raise SystemExit(f"FAIL: {required} is not registered; tools are {names}") @@ -121,5 +147,40 @@ gone = call(3, "tools/call", {"name": "fetch_atom", "arguments": {"atom_id": ato if not gone.get("isError") or "not found" not in gone["content"][0]["text"]: raise SystemExit(f"FAIL: the forgotten memory is still readable: {gone}") -print("PASS: remember, supersede, search and forget work through MCP") +state = tool("mounted_cartridges", {})["state"] +if state["mounted"] or state["scoped"]: + raise SystemExit(f"FAIL: a fresh store reports a scope: {state}") + +outside = tool("remember", {"text": "the spare key hides in the garage", "scenario": "work"})["atom"] +manifest = tool("mount_cartridge", {"cartridge_id": cartridge_id})["manifest"] +if manifest["cartridge_id"] != cartridge_id or not manifest.get("dna"): + raise SystemExit(f"FAIL: mount returned the wrong header: {manifest}") +state = tool("mounted_cartridges", {})["state"] +if not state["scoped"] or state["mounted"] != [cartridge_id]: + raise SystemExit(f"FAIL: the mount did not take effect: {state}") +if tool("search_atoms", {"query": "garage"})["hits"]: + raise SystemExit("FAIL: a mounted cartridge let a memory outside it answer") + +grown = tool("append_to_cartridge", {"cartridge_id": cartridge_id, "atom_ids": [outside["atom_id"]]})["manifest"] +if grown["dna"] == manifest["dna"]: + raise SystemExit("FAIL: append rewrote nothing; the DNA is unchanged") +if not any(hit["atom"]["atom_id"] == outside["atom_id"] for hit in tool("search_atoms", {"query": "garage"})["hits"]): + raise SystemExit("FAIL: an appended memory is still not reachable") + +tool("unmount_cartridge", {"cartridge_id": cartridge_id}) +if tool("mounted_cartridges", {})["state"]["scoped"]: + raise SystemExit("FAIL: unmount left the store scoped") + +(model_dir / "config.json").write_text(json.dumps({ + "model_type": "qwen3_5", + "max_position_embeddings": 262144, + "partial_rotary_factor": 0.25, + "rope_parameters": {"rope_type": "yarn", "factor": 2.0}, + "chat_template": "{% for message in messages %}{{ message['content'] }}{% endfor %}", +}), encoding="utf-8") +refused = call(3, "tools/call", {"name": "mount_cartridge", "arguments": {"cartridge_id": cartridge_id}}) +if not refused.get("isError") or "rope" not in refused["content"][0]["text"]: + raise SystemExit(f"FAIL: a cartridge built for RoPE factor 4.0 mounted on a factor 2.0 machine: {refused}") + +print("PASS: remember, supersede, search, forget and cartridge mounting work through MCP") PY