Make a cartridge a scope you mount, not only a bundle you export
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.
This commit is contained in:
1 parent
674c88e84e
commit
9fe13fffb3
21 files changed
+1492
-153
No files matched your search
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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]
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
@@ -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) == []
|
||||
@@ -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))
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user