diff --git a/backend/src/ely_eye/api.py b/backend/src/ely_eye/api.py index 0ac71a9..50fd7d7 100644 --- a/backend/src/ely_eye/api.py +++ b/backend/src/ely_eye/api.py @@ -35,6 +35,7 @@ from .evaluation_schemas import ( ) from .evidence_graph import EvidenceGraphService from .ingestion import IngestionService +from .memory import MemoryService from .profiles import runtime_profile_report from .research_modules import ResearchModuleService from .runtime import QwenRuntime, RuntimeUnavailableError @@ -43,10 +44,16 @@ from .schemas import ( AdapterStatus, AnswerResponse, CartridgeAssetReport, + EvidenceAtom, + ForgetReport, + ForgetRequest, + MemoryOrigin, MemoryStatus, PathIngestRequest, + RememberRequest, RuntimeProfile, RuntimeProfileReport, + SupersedeRequest, SystemStatus, UploadIngestRequest, ) @@ -183,6 +190,50 @@ def create_app() -> FastAPI: runtime_backend=generation.backend, ) + @app.post("/api/memory", response_model=EvidenceAtom) + def remember( + request: RememberRequest, settings: Annotated[Settings, Depends(settings_dep)] + ) -> EvidenceAtom: + try: + return MemoryService(settings).remember( + request.text, + holder_id=settings.default_holder, + origin=MemoryOrigin.self_, + scenario=request.scenario, + event_time=request.event_time, + event_time_precision=request.event_time_precision, + event_time_source=request.event_time_source, + retention_until=request.retention_until, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + @app.post("/api/memory/supersede", response_model=EvidenceAtom) + def supersede( + request: SupersedeRequest, settings: Annotated[Settings, Depends(settings_dep)] + ) -> EvidenceAtom: + try: + return MemoryService(settings).supersede( + request.atom_id, + text=request.text, + holder_id=settings.default_holder, + scenario=request.scenario, + event_time=request.event_time, + event_time_precision=request.event_time_precision, + event_time_source=request.event_time_source, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + @app.post("/api/memory/forget", response_model=ForgetReport) + def forget(request: ForgetRequest, settings: Annotated[Settings, Depends(settings_dep)]) -> ForgetReport: + try: + return MemoryService(settings).forget( + atom_id=request.atom_id, source_id=request.source_id, reason=request.reason + ) + 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() diff --git a/backend/src/ely_eye/cli.py b/backend/src/ely_eye/cli.py index 2a2f3a9..fdd1510 100644 --- a/backend/src/ely_eye/cli.py +++ b/backend/src/ely_eye/cli.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime from pathlib import Path import typer @@ -20,10 +21,11 @@ from .db import Database from .embeddings import EmbeddingService from .evals import HashHopEvaluator, PRDProofSuite from .ingestion import IngestionService +from .memory import MemoryService from .profiles import runtime_profile_report from .research_modules import ResearchModuleService from .runtime import QwenRuntime -from .schemas import TrainingAdapterKind +from .schemas import MemoryOrigin, TimePrecision, TrainingAdapterKind from .training import TrainingService app = typer.Typer(no_args_is_help=True) @@ -46,17 +48,19 @@ def status() -> None: runtime = QwenRuntime(settings).status() db = Database(settings) typer.echo( - { - "runtime": runtime.model_dump(mode="json"), - "atoms": db.atom_count(), - "token_equivalent": db.token_equivalent_sum(), - "library_token_equivalent": max( - db.token_equivalent_sum(), - db.cartridge_token_equivalent_sum(), - ), - "cartridges": db.cartridge_count(), - "data_home": str(settings.home), - } + json_dump( + { + "runtime": runtime.model_dump(mode="json"), + "atoms": db.atom_count(), + "token_equivalent": db.token_equivalent_sum(), + "library_token_equivalent": max( + db.token_equivalent_sum(), + db.cartridge_token_equivalent_sum(), + ), + "cartridges": db.cartridge_count(), + "data_home": str(settings.home), + } + ) ) @@ -66,6 +70,69 @@ def ingest(path: Path, cartridge_name: str | None = None) -> None: typer.echo(result.model_dump_json(indent=2)) +def _moment(value: str, option: str) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(value) + except ValueError as error: + raise ValueError(f"{option} {value!r} is not an ISO timestamp: {error}") from error + + +@app.command() +def remember( + text: str, + holder: str = typer.Option("", "--holder", help="who owns this memory"), + origin: MemoryOrigin = MemoryOrigin.self_, + scenario: str = "", + event_time: str = typer.Option("", "--event-time", help="when it was true, ISO with offset"), + precision: TimePrecision = TimePrecision.unknown, + retention_until: str = typer.Option("", "--retention-until"), +) -> None: + settings = get_settings() + atom = MemoryService(settings).remember( + text, + holder_id=holder or settings.default_holder, + origin=origin, + scenario=scenario, + event_time=_moment(event_time, "--event-time"), + event_time_precision=precision, + retention_until=_moment(retention_until, "--retention-until"), + ) + typer.echo(atom.model_dump_json(indent=2)) + + +@app.command() +def supersede( + atom_id: str, + text: str, + holder: str = typer.Option("", "--holder"), + scenario: str = "", + event_time: str = typer.Option("", "--event-time"), + precision: TimePrecision = TimePrecision.unknown, +) -> None: + settings = get_settings() + atom = MemoryService(settings).supersede( + atom_id, + text=text, + holder_id=holder or settings.default_holder, + scenario=scenario, + event_time=_moment(event_time, "--event-time"), + event_time_precision=precision, + ) + typer.echo(atom.model_dump_json(indent=2)) + + +@app.command() +def forget(atom_id: str = "", source_id: str = "", reason: str = "") -> None: + report = MemoryService(get_settings()).forget( + atom_id=atom_id or None, + source_id=source_id or None, + reason=reason, + ) + typer.echo(report.model_dump_json(indent=2)) + + @app.command("build-dense-index") def build_dense_index() -> None: index = EmbeddingService(get_settings()).build_index() diff --git a/backend/src/ely_eye/config.py b/backend/src/ely_eye/config.py index a1956e3..c4141e6 100644 --- a/backend/src/ely_eye/config.py +++ b/backend/src/ely_eye/config.py @@ -12,6 +12,7 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_prefix="ELY_EYE_", env_file=".env", extra="ignore") home: Path = Field(default_factory=lambda: Path.cwd() / ".ely_eye") + default_holder: str = "local" model_id: str = "Qwen/Qwen3.5-9B" planner_model_id: str = "Qwen/Qwen3.5-0.8B" embedding_model_id: str = "Qwen/Qwen3-VL-Embedding-8B" diff --git a/backend/src/ely_eye/db.py b/backend/src/ely_eye/db.py index 18e78ba..91a504b 100644 --- a/backend/src/ely_eye/db.py +++ b/backend/src/ely_eye/db.py @@ -221,6 +221,66 @@ class Database: ).fetchall() return [(row["atom_id"], float(-row["score"])) for row in rows] + def source_id_of_atom(self, atom_id: str) -> str | None: + with self.connect() as connection: + row = connection.execute("SELECT source_id FROM atoms WHERE atom_id = ?", (atom_id,)).fetchone() + return None if row is None else str(row["source_id"]) + + def atoms_of_source(self, source_id: str) -> list[EvidenceAtom]: + with self.connect() as connection: + rows = connection.execute( + "SELECT * FROM atoms WHERE source_id = ? ORDER BY atom_id", (source_id,) + ).fetchall() + return [self._row_to_atom(row) for row in rows] + + def close_atoms(self, atom_ids: list[str], closed_at: datetime) -> int: + """Stop treating these memories as current; the rows stay for time travel.""" + if not atom_ids: + return 0 + with self.connect() as connection: + cursor = connection.execute( + "UPDATE atoms SET valid_to = ? " + "WHERE valid_to IS NULL AND atom_id IN (SELECT value FROM json_each(?))", + (closed_at.isoformat(), _json(atom_ids)), + ) + return int(cursor.rowcount) + + def delete_atoms(self, atom_ids: list[str]) -> int: + if not atom_ids: + return 0 + with self.connect() as connection: + connection.execute( + "DELETE FROM atom_fts WHERE atom_id IN (SELECT value FROM json_each(?))", (_json(atom_ids),) + ) + cursor = connection.execute( + "DELETE FROM atoms WHERE atom_id IN (SELECT value FROM json_each(?))", (_json(atom_ids),) + ) + return int(cursor.rowcount) + + def delete_edges_touching(self, atom_ids: list[str]) -> int: + if not atom_ids: + return 0 + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM evidence_edges " + "WHERE from_atom_id IN (SELECT value FROM json_each(?)) " + "OR to_atom_id IN (SELECT value FROM json_each(?))", + (_json(atom_ids), _json(atom_ids)), + ) + return int(cursor.rowcount) + + def clear_cache_events(self, reason: str) -> int: + """Empty the cache and leave one trace row proving why it is empty.""" + with self.connect() as connection: + cursor = connection.execute("DELETE FROM cache_events") + cleared = int(cursor.rowcount) + connection.execute( + "INSERT INTO cache_events(ts, layer, cache_key, event, bytes_estimate, metadata_json) " + "VALUES (?, 'memory', ?, 'invalidated', 0, ?)", + (utc_now().isoformat(), reason, _json({"entries_cleared": cleared})), + ) + return cleared + def atom_count(self) -> int: with self.connect() as connection: return int(connection.execute("SELECT COUNT(*) FROM atoms").fetchone()[0]) diff --git a/backend/src/ely_eye/ingestion.py b/backend/src/ely_eye/ingestion.py index 4062973..bb58113 100644 --- a/backend/src/ely_eye/ingestion.py +++ b/backend/src/ely_eye/ingestion.py @@ -7,12 +7,11 @@ import re import subprocess import tempfile from dataclasses import dataclass -from datetime import datetime, timezone from pathlib import Path from typing import Any -import fitz import cv2 +import pymupdf as fitz from PIL import Image from .config import Settings, get_settings @@ -26,8 +25,14 @@ from .schemas import ( SourceRecord, TrustScores, ) -from .storage import ObjectStore, guess_mime, safe_source_id, sha256_bytes, sha256_file, token_equivalent - +from .storage import ( + ObjectStore, + guess_mime, + safe_source_id, + sha256_bytes, + sha256_file, + token_equivalent, +) SUPPORTED_TEXT_EXTENSIONS = { ".txt", @@ -440,7 +445,8 @@ def code_context_entry(root: Path, path: Path) -> dict[str, Any]: def repository_call_graph(code_entries: list[dict[str, Any]]) -> list[dict[str, Any]]: edges: list[dict[str, Any]] = [] for entry in code_entries: - symbols = entry.get("symbols") if isinstance(entry.get("symbols"), dict) else {} + declared = entry.get("symbols") + symbols = declared if isinstance(declared, dict) else {} for call in symbols.get("calls", []): if not isinstance(call, dict): continue diff --git a/backend/src/ely_eye/memory.py b/backend/src/ely_eye/memory.py new file mode 100644 index 0000000..3c85cc2 --- /dev/null +++ b/backend/src/ely_eye/memory.py @@ -0,0 +1,219 @@ +"""The single writer for memory: remember, supersede, forget. + +Ingestion turns artefacts into atoms. This module turns statements into memories, and it +is the only place a memory is created or destroyed, so provenance is stamped here and no +caller can claim to be somebody else. +""" + +from __future__ import annotations + +import hashlib +import json +from datetime import datetime +from pathlib import Path +from typing import Any + +from .config import Settings, get_settings +from .db import Database +from .schemas import ( + EvidenceAtom, + ForgetReport, + MemoryOrigin, + Modality, + SourceRecord, + TimePrecision, + TrustScores, + Visibility, + utc_now, +) +from .storage import token_equivalent + +# Trust scores describe extraction fidelity, not truthfulness: a remembered sentence is +# stored verbatim, so the parser is fully trusted and origin carries the epistemic weight. +MEMORY_TRUST = TrustScores(parser=1.0, ocr=0.0, model=0.0) + + +class MemoryService: + def __init__(self, settings: Settings | None = None, database: Database | None = None) -> None: + self.settings = settings or get_settings() + self.db = database or Database(self.settings) + + def remember( + self, + text: str, + *, + holder_id: str, + origin: MemoryOrigin, + scenario: str = "", + event_time: datetime | None = None, + event_time_precision: TimePrecision = TimePrecision.unknown, + event_time_source: str = "", + visibility: Visibility | None = None, + supersedes: list[str] | None = None, + valid_from: datetime | None = None, + retention_until: datetime | None = None, + ) -> EvidenceAtom: + """Store one statement as a memory owned by a holder. + + Repeating the same sentence as the same holder updates the memory instead of + duplicating it; two holders holding the same sentence keep two memories. + """ + statement = text.strip() + if not statement: + raise ValueError("remember needs text; an empty memory will be found by nobody") + if not holder_id.strip(): + raise ValueError("remember needs a holder; a memory nobody owns cannot be referred to") + if event_time is not None and event_time_precision is TimePrecision.unknown: + raise ValueError( + f"event_time {event_time.isoformat()} needs its precision; " + "a day stored as an instant will be read as a moment" + ) + + moment = utc_now() + digest = hashlib.sha256(f"{holder_id}\n{statement}".encode()).hexdigest() + source_id = f"mem-{digest[:16]}" + self.db.upsert_sources( + [ + SourceRecord( + source_id=source_id, + path=f"memory://{holder_id}/{scenario or 'general'}", + kind="memory", + mime="text/plain", + sha256=hashlib.sha256(statement.encode("utf-8")).hexdigest(), + size_bytes=len(statement.encode("utf-8")), + created_at=moment, + metadata={"holder_id": holder_id, "origin": origin.value}, + ) + ] + ) + atom = EvidenceAtom( + atom_id=f"{source_id}:text", + modality=Modality.text, + source_id=source_id, + source=f"memory://{holder_id}/{scenario or 'general'}", + ingested_at=moment, + text=statement, + trust=MEMORY_TRUST, + token_equivalent=token_equivalent(statement), + holder_id=holder_id, + origin=origin, + scenario=scenario, + event_time=event_time, + event_time_precision=event_time_precision, + event_time_source=event_time_source, + supersedes=list(supersedes or []), + valid_from=valid_from, + visibility=visibility or Visibility(), + retention_until=retention_until, + ) + self.db.upsert_atoms([atom]) + return atom + + def supersede( + self, + atom_id: str, + *, + text: str, + holder_id: str, + origin: MemoryOrigin = MemoryOrigin.self_, + scenario: str = "", + event_time: datetime | None = None, + event_time_precision: TimePrecision = TimePrecision.unknown, + event_time_source: str = "", + ) -> EvidenceAtom: + """Close an old memory and record the new one as its replacement. + + Both rows stay: the new atom answers what is true now, the closed one answers what + was true when asked, and a system that forgets the second cannot explain itself. + """ + previous = self.db.get_atoms([atom_id]) + if not previous: + raise ValueError(f"cannot supersede {atom_id}: no such atom") + if previous[0].valid_to is not None: + raise ValueError( + f"{atom_id} was already closed at {previous[0].valid_to.isoformat()}; " + "superseding a closed memory hides which replacement is current" + ) + # A correction takes effect when it is asserted: the old memory stops being current + # and the new one starts there, so the pair never claims to be true at the same time. + moment = event_time or utc_now() + replacement = self.remember( + text, + holder_id=holder_id, + origin=origin, + scenario=scenario or previous[0].scenario, + event_time=event_time, + event_time_precision=event_time_precision, + event_time_source=event_time_source, + supersedes=[atom_id], + valid_from=moment, + ) + self.db.close_atoms([atom_id], moment) + return replacement + + def forget( + self, + *, + atom_id: str | None = None, + source_id: str | None = None, + reason: str = "", + ) -> ForgetReport: + """Delete a memory and everything derived from it. + + The cascade is the whole point: an answer cached from a forgotten memory is still + on screen, and a vector index that still carries it will keep surfacing it. + """ + if (atom_id is None) == (source_id is None): + raise ValueError("forget takes exactly one of atom_id or source_id") + target_source = source_id or self.db.source_id_of_atom(str(atom_id)) + if target_source is None: + raise ValueError(f"nothing to forget: no atom {atom_id}") + doomed = self.db.atoms_of_source(target_source) + if not doomed: + raise ValueError(f"nothing to forget: source {target_source} holds no atom") + + doomed_ids = [atom.atom_id for atom in doomed] + edges_deleted = self.db.delete_edges_touching(doomed_ids) + atoms_deleted = self.db.delete_atoms(doomed_ids) + cache_cleared = self.db.clear_cache_events(f"forget:{target_source}") + vectors_dropped = self._drop_vector_index() + return ForgetReport( + source_id=target_source, + atom_ids=doomed_ids, + atoms_deleted=atoms_deleted, + edges_deleted=edges_deleted, + cache_entries_cleared=cache_cleared, + vector_index_dropped=vectors_dropped, + affected_cartridges=self._cartridges_holding(target_source), + reason=reason, + ) + + def _drop_vector_index(self) -> bool: + """The dense index is derived data; a stale one keeps answering with deleted text.""" + dropped = False + for path in (self.settings.cache_dir / "dense_index.npy", self.settings.cache_dir / "dense_index_atoms.json"): + if path.exists(): + path.unlink() + dropped = True + return dropped + + def _cartridges_holding(self, source_id: str) -> list[dict[str, Any]]: + """Name the cartridges that exported this source, so they can be rebuilt. + + An exported cartridge is an immutable artefact on disk; rewriting it here would + silently change a DNA hash somebody already quoted. + """ + affected: list[dict[str, Any]] = [] + for cartridge in self.db.list_cartridges(): + sources_path = Path(str(cartridge["root_path"])) / "sources.json" + try: + payload = json.loads(sources_path.read_text(encoding="utf-8")) + except FileNotFoundError: + affected.append({"cartridge_id": cartridge["cartridge_id"], "contains_source": "unknown"}) + continue + except (OSError, json.JSONDecodeError) as error: + raise ValueError(f"{sources_path}: the cartridge source list is unreadable ({error})") from error + held = any(str(row.get("source_id")) == source_id for row in payload) + if held: + affected.append({"cartridge_id": cartridge["cartridge_id"], "contains_source": True}) + return affected diff --git a/backend/src/ely_eye/schemas.py b/backend/src/ely_eye/schemas.py index 8150a79..a77a765 100644 --- a/backend/src/ely_eye/schemas.py +++ b/backend/src/ely_eye/schemas.py @@ -5,7 +5,7 @@ from enum import StrEnum from pathlib import Path from typing import Any, Literal -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator def utc_now() -> datetime: @@ -116,6 +116,16 @@ class EvidenceAtom(BaseModel): raise ValueError(f"{self.atom_id}: an atom cannot supersede itself") return self + @field_validator("ingested_at", "event_time", "event_time_end", "valid_from", "valid_to", "retention_until") + @classmethod + def _times_are_aware(cls, value: datetime | None) -> datetime | None: + if value is not None and value.tzinfo is None: + raise ValueError( + "memory timestamps need an explicit offset, e.g. 2026-05-01T12:00:00+08:00; " + "a naive time cannot be compared with one that has an offset" + ) + return value + class SourceRecord(BaseModel): source_id: str path: str @@ -378,6 +388,42 @@ class RuntimeProfileReport(BaseModel): profiles: list[RuntimeLaunchProfile] validations: list[RuntimeProfileValidation] = Field(default_factory=list) +class ForgetReport(BaseModel): + """What a forget actually removed, including the derived copies that have to go with it.""" + + source_id: str + atom_ids: list[str] + atoms_deleted: int + edges_deleted: int + cache_entries_cleared: int + vector_index_dropped: bool + affected_cartridges: list[dict[str, Any]] + reason: str + + +class RememberRequest(BaseModel): + """A memory as a caller states it. Holder, origin and ingestion time are stamped by the + server, so they are absent here on purpose: a client cannot claim to be somebody else. + """ + + text: str + scenario: str = "" + event_time: datetime | None = None + event_time_precision: TimePrecision = TimePrecision.unknown + event_time_source: str = "" + retention_until: datetime | None = None + + +class SupersedeRequest(RememberRequest): + atom_id: str + + +class ForgetRequest(BaseModel): + atom_id: str | None = None + source_id: str | None = None + reason: str = "" + + class UploadIngestRequest(BaseModel): cartridge_name: str | None = None dense_embeddings: bool | None = None diff --git a/backend/tests/test_memory_api.py b/backend/tests/test_memory_api.py new file mode 100644 index 0000000..d8f4b41 --- /dev/null +++ b/backend/tests/test_memory_api.py @@ -0,0 +1,69 @@ +"""The HTTP face stamps provenance itself: a client cannot say who owns a memory.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +import ely_eye.api as api_module +from ely_eye.config import Settings + +KEYS = "我们的钥匙放在玄关抽屉里" + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: + settings = Settings(home=tmp_path, default_holder="wife") + monkeypatch.setattr(api_module, "get_settings", lambda: settings) + return TestClient(api_module.create_app()) + + +def test_post_memory_stamps_the_holder_the_server_knows(client: TestClient) -> None: + response = client.post("/api/memory", json={"text": KEYS, "scenario": "home"}) + assert response.status_code == 200 + atom = response.json() + assert atom["holder_id"] == "wife" + assert atom["origin"] == "self" + assert atom["scenario"] == "home" + assert atom["ingested_at"], "ingestion time is stamped, never supplied" + + +def test_a_client_cannot_claim_another_holder(client: TestClient) -> None: + response = client.post( + "/api/memory", + json={"text": KEYS, "holder_id": "attacker", "origin": "third_party", "ingested_at": "2020-01-01T00:00:00+00:00"}, + ) + assert response.status_code == 200 + atom = response.json() + assert atom["holder_id"] == "wife", "the request body has no holder field to forge" + assert atom["origin"] == "self" + + +def test_an_empty_memory_is_rejected_with_its_reason(client: TestClient) -> None: + response = client.post("/api/memory", json={"text": " "}) + assert response.status_code == 422 + assert "needs text" in response.json()["detail"] + + +def test_supersede_then_forget_reports_what_it_removed(client: TestClient) -> None: + old = client.post("/api/memory", json={"text": KEYS, "scenario": "home"}).json() + new = client.post( + "/api/memory/supersede", + json={"atom_id": old["atom_id"], "text": "钥匙现在在书房第二个抽屉", "scenario": "home"}, + ).json() + assert new["supersedes"] == [old["atom_id"]] + + forgotten = client.post("/api/memory/forget", json={"atom_id": old["atom_id"], "reason": "moved"}) + assert forgotten.status_code == 200 + report = forgotten.json() + assert report["atoms_deleted"] == 1 + assert report["atom_ids"] == [old["atom_id"]] + assert report["reason"] == "moved" + + +def test_a_forget_that_names_nothing_is_rejected(client: TestClient) -> None: + response = client.post("/api/memory/forget", json={"reason": "vague"}) + assert response.status_code == 422 + assert "exactly one" in response.json()["detail"] diff --git a/backend/tests/test_memory_cli.py b/backend/tests/test_memory_cli.py new file mode 100644 index 0000000..2810c17 --- /dev/null +++ b/backend/tests/test_memory_cli.py @@ -0,0 +1,108 @@ +"""The CLI face of the write path: a person at a terminal can hand a memory to +a named holder, replace it, and take it back, and every answer is JSON.""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest +from typer.testing import CliRunner + +from ely_eye import cli as cli_module +from ely_eye.cli import app +from ely_eye.config import Settings +from ely_eye.db import Database + +runner = CliRunner() + + +@pytest.fixture() +def db(tmp_path, monkeypatch) -> Database: + settings = Settings(home=tmp_path, default_holder="husband") + monkeypatch.setattr(cli_module, "get_settings", lambda: settings) + return Database(settings) + + +def run(*args: str) -> Any: + result = runner.invoke(app, list(args)) + assert result.exit_code == 0, result.output + return json.loads(result.output) + + +def test_remember_stamps_the_default_holder_when_none_is_given(db: Database) -> None: + atom = run("remember", "钥匙在玄关抽屉") + assert atom["holder_id"] == "husband" + assert atom["origin"] == "self" + assert atom["source"] == "memory://husband/general" + assert db.atom_count() == 1 + + +def test_remember_carries_the_holder_scenario_and_event_time(db: Database) -> None: + atom = run( + "remember", + "结婚纪念日是五月一日", + "--holder", + "wife", + "--scenario", + "home", + "--event-time", + "2026-05-01T00:00:00+08:00", + "--precision", + "day", + ) + assert atom["holder_id"] == "wife" + assert atom["scenario"] == "home" + assert atom["event_time"].startswith("2026-05-01T00:00:00+08:00") + assert atom["event_time_precision"] == "day" + + +def test_remember_refuses_an_event_time_without_an_offset(db: Database) -> None: + result = runner.invoke( + app, + ["remember", "昨天下的雨", "--event-time", "2026-05-01T12:00:00", "--precision", "day"], + ) + assert result.exit_code != 0 + assert "memory timestamps need an explicit offset" in str(result.exception) + + +def test_remember_refuses_a_malformed_timestamp(db: Database) -> None: + result = runner.invoke(app, ["remember", "昨天下的雨", "--event-time", "yesterday"]) + assert result.exit_code != 0 + assert "--event-time 'yesterday' is not an ISO timestamp" in str(result.exception) + + +def test_supersede_links_the_replacement_and_closes_the_original(db: Database) -> None: + old = run("remember", "钥匙在玄关抽屉", "--holder", "wife") + new = run("supersede", old["atom_id"], "钥匙在客厅茶几", "--holder", "wife") + assert new["supersedes"] == [old["atom_id"]] + assert new["valid_from"] is not None + stored = db.get_atoms([old["atom_id"]])[0] + assert stored.valid_to is not None + assert db.atom_count() == 2 + + +def test_supersede_names_the_missing_atom(db: Database) -> None: + result = runner.invoke(app, ["supersede", "nope:text", "新的说法"]) + assert result.exit_code != 0 + assert "cannot supersede nope:text" in str(result.exception) + + +def test_forget_reports_what_it_removed(db: Database) -> None: + atom = run("remember", "钥匙在玄关抽屉", "--holder", "wife") + report = run("forget", "--source-id", atom["source_id"], "--reason", "moved house") + assert report["atoms_deleted"] == 1 + assert report["reason"] == "moved house" + assert db.atom_count() == 0 + + +def test_forget_refuses_to_guess_between_two_ids(db: Database) -> None: + result = runner.invoke(app, ["forget", "--atom-id", "a:text", "--source-id", "b"]) + assert result.exit_code != 0 + assert "exactly one of atom_id or source_id" in str(result.exception) + + +def test_the_cli_answers_in_json_a_machine_can_read(db: Database) -> None: + result = runner.invoke(app, ["remember", "钥匙在玄关抽屉", "--holder", "wife"]) + assert result.exit_code == 0 + assert json.loads(result.output)["text"] == "钥匙在玄关抽屉" diff --git a/backend/tests/test_memory_write.py b/backend/tests/test_memory_write.py new file mode 100644 index 0000000..62f78e4 --- /dev/null +++ b/backend/tests/test_memory_write.py @@ -0,0 +1,182 @@ +"""remember / supersede / forget are one writer, and forgetting has to reach what it derived.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest + +from ely_eye.config import Settings +from ely_eye.db import Database +from ely_eye.memory import MemoryService +from ely_eye.schemas import ( + EvidenceAtom, + MemoryOrigin, + Modality, + SourceRecord, + TimePrecision, + TrustScores, +) + +KEYS = "我们的钥匙放在玄关抽屉里" +NOW = datetime(2026, 5, 1, 12, 0, tzinfo=UTC) + + +@pytest.fixture +def service(tmp_path: Path) -> MemoryService: + return MemoryService(settings=Settings(home=tmp_path)) + + +def _seed(database: Database, atom: EvidenceAtom) -> None: + database.upsert_sources( + [ + SourceRecord( + source_id=atom.source_id, + path="/tmp/seed.txt", + kind="text", + mime="text/plain", + sha256="0" * 64, + size_bytes=4, + created_at=NOW, + ) + ] + ) + database.upsert_atoms([atom]) + + +def _seed_atom(atom_id: str, source_id: str) -> EvidenceAtom: + return EvidenceAtom( + atom_id=atom_id, + modality=Modality.text, + source_id=source_id, + source="seed", + ingested_at=NOW, + text=KEYS, + trust=TrustScores(parser=1.0), + token_equivalent=6, + holder_id="wife", + ) + + +def test_remember_stamps_the_holder_and_refuses_an_ownerless_memory(service: MemoryService) -> None: + atom = service.remember(KEYS, holder_id="wife", origin=MemoryOrigin.self_, scenario="home") + assert atom.holder_id == "wife" + assert atom.origin is MemoryOrigin.self_ + assert atom.source == "memory://wife/home" + assert atom.atom_id.startswith("mem-") + assert atom.ingested_at <= datetime.now(UTC) + assert atom.valid_from is None, "when the statement became true is unknown, not 'now'" + with pytest.raises(ValueError, match="needs text"): + service.remember(" ", holder_id="wife", origin=MemoryOrigin.self_) + with pytest.raises(ValueError, match="needs a holder"): + service.remember(KEYS, holder_id=" ", origin=MemoryOrigin.self_) + + +def test_the_same_holder_remembering_the_same_sentence_updates_one_memory(service: MemoryService) -> None: + service.remember(KEYS, holder_id="wife", origin=MemoryOrigin.self_, scenario="home") + service.remember(KEYS, holder_id="wife", origin=MemoryOrigin.self_, scenario="home") + service.remember(KEYS, holder_id="husband", origin=MemoryOrigin.self_, scenario="home") + assert service.db.atom_count() == 2, "two holders holding one sentence is two memories" + + +def test_an_event_time_without_its_precision_is_refused(service: MemoryService) -> None: + with pytest.raises(ValueError, match="needs its precision"): + service.remember( + "去年三月我们搬到这里", + holder_id="wife", + origin=MemoryOrigin.self_, + event_time=datetime(2025, 3, 1, tzinfo=UTC), + ) + + +def test_supersede_closes_the_old_memory_and_keeps_both_rows(service: MemoryService) -> None: + old = service.remember(KEYS, holder_id="wife", origin=MemoryOrigin.self_, scenario="home") + new = service.supersede( + old.atom_id, + text="钥匙现在在书房第二个抽屉", + holder_id="wife", + scenario="home", + event_time=NOW, + event_time_precision=TimePrecision.day, + event_time_source="declared", + ) + assert new.supersedes == [old.atom_id] + assert service.db.atom_count() == 2, "the closed memory stays so time travel can answer" + stored_old = service.db.get_atoms([old.atom_id])[0] + assert stored_old.valid_to is not None + with service.db.connect() as connection: + current = {row["atom_id"] for row in connection.execute("SELECT atom_id FROM v_atoms_current")} + assert current == {new.atom_id} + with pytest.raises(ValueError, match="already closed"): + service.supersede(old.atom_id, text="again", holder_id="wife") + with pytest.raises(ValueError, match="no such atom"): + service.supersede("mem-missing:text", text="anything", holder_id="wife") + + +def test_time_travel_reads_the_memory_that_was_true_then(service: MemoryService) -> None: + old = service.remember(KEYS, holder_id="wife", origin=MemoryOrigin.self_, scenario="home") + before = datetime.now(UTC) - timedelta(seconds=1) + new = service.supersede(old.atom_id, text="钥匙现在在书房第二个抽屉", holder_id="wife") + assert {atom.atom_id for atom in service.db.atoms_as_of(before)} == {old.atom_id} + assert {atom.atom_id for atom in service.db.atoms_as_of(datetime.now(UTC) + timedelta(seconds=1))} == { + new.atom_id + } + + +def test_forget_reaches_the_row_the_index_the_edges_the_cache_and_the_vectors( + service: MemoryService, +) -> None: + atom = _seed_atom("seed-1:text", "seed-1") + _seed(service.db, atom) + service.db.write_evidence_edges( + [{"from_atom_id": "seed-1:text", "to_atom_id": "other", "relation": "cites", "confidence": 1.0}] + ) + service.db.log_cache_event(layer="compiler", cache_key="k", event="hit", bytes_estimate=10) + vectors = service.settings.cache_dir / "dense_index.npy" + vectors.write_bytes(b"stale") + (service.settings.cache_dir / "dense_index_atoms.json").write_text("[]", encoding="utf-8") + + report = service.forget(atom_id="seed-1:text", reason="the drawer is empty") + + assert report.atoms_deleted == 1 + assert report.edges_deleted == 1 + assert report.cache_entries_cleared == 1 + assert report.vector_index_dropped is True + assert service.db.atom_count() == 0 + with service.db.connect() as connection: + assert connection.execute("SELECT COUNT(*) FROM atom_fts").fetchone()[0] == 0 + assert connection.execute("SELECT COUNT(*) FROM evidence_edges").fetchone()[0] == 0 + trace = connection.execute("SELECT layer, event FROM cache_events").fetchall() + assert [(row["layer"], row["event"]) for row in trace] == [("memory", "invalidated")] + assert not vectors.exists() + + +def test_forget_names_the_cartridge_that_exported_the_source(service: MemoryService) -> None: + atom = _seed_atom("seed-1:text", "seed-1") + _seed(service.db, atom) + cartridge_root = service.settings.cartridge_dir / "cart-1" + cartridge_root.mkdir(parents=True) + (cartridge_root / "sources.json").write_text( + '[{"source_id": "seed-1"}, {"source_id": "elsewhere"}]', encoding="utf-8" + ) + service.db.write_cartridge( + cartridge_id="cart-1", + name="one", + root_path=cartridge_root, + manifest_json="{}", + dna="dna", + token_equivalent=6, + created_at=NOW, + ) + report = service.forget(source_id="seed-1") + assert report.affected_cartridges == [{"cartridge_id": "cart-1", "contains_source": True}] + + +def test_forget_refuses_a_request_that_names_nothing(service: MemoryService) -> None: + with pytest.raises(ValueError, match="exactly one"): + service.forget() + with pytest.raises(ValueError, match="exactly one"): + service.forget(atom_id="a", source_id="b") + with pytest.raises(ValueError, match="nothing to forget"): + service.forget(atom_id="nope:text") diff --git a/ely-eye-mcp/internal/apiclient/client.go b/ely-eye-mcp/internal/apiclient/client.go new file mode 100644 index 0000000..4910571 --- /dev/null +++ b/ely-eye-mcp/internal/apiclient/client.go @@ -0,0 +1,84 @@ +// Package apiclient talks to a running Ely-Eye API server. The Python service +// owns every write rule, so the MCP server forwards mutations instead of +// duplicating them against SQLite. +package apiclient + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const maxErrorBody = 4096 + +type Client struct { + BaseURL string + HTTP *http.Client +} + +func New(baseURL string) *Client { + if strings.TrimSpace(baseURL) == "" { + baseURL = "http://127.0.0.1:8765" + } + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + HTTP: &http.Client{Timeout: 30 * time.Second}, + } +} + +// 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") + + 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) + } + defer response.Body.Close() + + data, err := io.ReadAll(io.LimitReader(response.Body, maxErrorBody)) + if err != nil { + return fmt.Errorf("%s returned an unreadable body: %w", path, err) + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return errors.New(path + " was rejected: " + serverReason(response.StatusCode, data)) + } + if out == nil { + return nil + } + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("%s returned JSON that does not decode: %w", path, err) + } + return nil +} + +// 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 { + var envelope struct { + Detail string `json:"detail"` + } + if err := json.Unmarshal(data, &envelope); err == nil && strings.TrimSpace(envelope.Detail) != "" { + return envelope.Detail + } + trimmed := strings.TrimSpace(string(data)) + if trimmed == "" { + return fmt.Sprintf("HTTP %d with no reason", status) + } + return trimmed +} diff --git a/ely-eye-mcp/internal/apiclient/client_test.go b/ely-eye-mcp/internal/apiclient/client_test.go new file mode 100644 index 0000000..773b61b --- /dev/null +++ b/ely-eye-mcp/internal/apiclient/client_test.go @@ -0,0 +1,87 @@ +package apiclient + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestPostSendsJSONAndDecodesTheAnswer(t *testing.T) { + var gotPath string + var gotBody map[string]any + var gotType string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotType = r.Header.Get("Content-Type") + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &gotBody) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"atom_id":"mem-1:text","holder_id":"wife"}`)) + })) + defer server.Close() + + var out struct { + AtomID string `json:"atom_id"` + HolderID string `json:"holder_id"` + } + client := New(server.URL) + err := client.Post(context.Background(), "/api/memory", map[string]any{"text": "钥匙在玄关"}, &out) + if err != nil { + t.Fatalf("Post failed: %v", err) + } + if gotPath != "/api/memory" { + t.Errorf("path = %q, want /api/memory", gotPath) + } + if gotType != "application/json" { + t.Errorf("content type = %q, want application/json", gotType) + } + if gotBody["text"] != "钥匙在玄关" { + t.Errorf("body = %v, want the statement to survive the trip", gotBody) + } + if out.AtomID != "mem-1:text" || out.HolderID != "wife" { + t.Errorf("decoded = %+v, want the server's stamped atom", out) + } +} + +func TestRejectedWriteCarriesTheServersReason(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"detail":"remember needs a holder; a memory nobody owns cannot be referred to"}`)) + })) + defer server.Close() + + err := New(server.URL).Post(context.Background(), "/api/memory", map[string]any{}, &struct{}{}) + if err == nil { + t.Fatal("a rejected write must not look like success") + } + if !strings.Contains(err.Error(), "remember needs a holder") { + t.Errorf("error = %v, want the rule the caller broke", err) + } +} + +func TestUnreachableAPIPointsAtTheServerThatShouldBeRunning(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + url := server.URL + server.Close() + + err := New(url).Post(context.Background(), "/api/memory", map[string]any{}, &struct{}{}) + if err == nil { + t.Fatal("an unreachable API must fail") + } + if !strings.Contains(err.Error(), "ely-eye serve") { + t.Errorf("error = %v, want the fix the operator can run", err) + } +} + +func TestNewDefaultsToLocalServe(t *testing.T) { + if got := New("").BaseURL; got != "http://127.0.0.1:8765" { + t.Errorf("BaseURL = %q, want the default serve address", got) + } + if got := New("http://127.0.0.1:9000/").BaseURL; got != "http://127.0.0.1:9000" { + t.Errorf("BaseURL = %q, want the trailing slash trimmed", got) + } +} diff --git a/ely-eye-mcp/internal/config/config.go b/ely-eye-mcp/internal/config/config.go index 5bf61de..78194af 100644 --- a/ely-eye-mcp/internal/config/config.go +++ b/ely-eye-mcp/internal/config/config.go @@ -11,12 +11,14 @@ type Config struct { ElyEyeHome string `json:"ely_eye_home"` Database string `json:"database"` Binary string `json:"binary"` + APIBaseURL string `json:"api_base_url"` } -// Resolve locates the Ely-Eye data home and its SQLite database. The server is -// a read-only mirror of any local .ely_eye store, so identity is anchored on the -// database itself rather than on Ely-Eye repository layout. This lets the MCP -// server be deployed for any project that owns an .ely_eye/data/ely_eye.sqlite. +// Resolve locates the Ely-Eye data home and its SQLite database. Reads mirror +// any local .ely_eye store directly, so identity is anchored on the database +// itself rather than on Ely-Eye repository layout. This lets the MCP server be +// deployed for any project that owns an .ely_eye/data/ely_eye.sqlite. Writes go +// through the Ely-Eye API, which owns the memory rules. func Resolve(projectRoot string, elyEyeHome string, binary string) (Config, error) { home, err := resolveHome(projectRoot, elyEyeHome) if err != nil { @@ -48,9 +50,18 @@ func Resolve(projectRoot string, elyEyeHome string, binary string) (Config, erro ElyEyeHome: home, Database: db, Binary: binary, + APIBaseURL: apiBaseURL(), }, nil } +// apiBaseURL points at the Ely-Eye API that owns every write rule. +func apiBaseURL() string { + if fromEnv := os.Getenv("ELY_EYE_API"); fromEnv != "" { + return fromEnv + } + return "http://127.0.0.1:8765" +} + func resolveHome(projectRoot string, elyEyeHome string) (string, error) { if elyEyeHome == "" { elyEyeHome = os.Getenv("ELY_EYE_HOME") diff --git a/ely-eye-mcp/internal/contextpack/compiler.go b/ely-eye-mcp/internal/contextpack/compiler.go index b03f10b..cfad884 100644 --- a/ely-eye-mcp/internal/contextpack/compiler.go +++ b/ely-eye-mcp/internal/contextpack/compiler.go @@ -77,7 +77,9 @@ func summarizeAtom(atom domain.Atom, maxRunes int) domain.AtomSummary { SourceID: atom.SourceID, Modality: atom.Modality, Source: atom.Source, - Time: atom.Time, + IngestedAt: atom.IngestedAt, + HolderID: atom.HolderID, + Scenario: atom.Scenario, TextPreview: trimRunes(atom.Text, maxRunes), ImageRef: atom.ImageRef, TokenEquivalent: atom.TokenEquivalent, diff --git a/ely-eye-mcp/internal/domain/types.go b/ely-eye-mcp/internal/domain/types.go index 67f1b79..77b62f0 100644 --- a/ely-eye-mcp/internal/domain/types.go +++ b/ely-eye-mcp/internal/domain/types.go @@ -1,5 +1,37 @@ package domain +// Memory mirrors the Evidence Atom the Ely-Eye API returns for a write. The +// server stamps holder, origin and ingestion time, so the MCP side never +// invents them. +type Memory struct { + AtomID string `json:"atom_id"` + SourceID string `json:"source_id"` + Source string `json:"source"` + HolderID string `json:"holder_id"` + Origin string `json:"origin"` + Scenario string `json:"scenario"` + Text string `json:"text"` + IngestedAt string `json:"ingested_at"` + EventTime string `json:"event_time,omitempty"` + EventTimePrecision string `json:"event_time_precision,omitempty"` + ValidFrom string `json:"valid_from,omitempty"` + ValidTo string `json:"valid_to,omitempty"` + RetentionUntil string `json:"retention_until,omitempty"` + Supersedes []string `json:"supersedes"` + TokenEquivalent int64 `json:"token_equivalent"` +} + +type ForgetReport struct { + SourceID string `json:"source_id"` + AtomIDs []string `json:"atom_ids"` + AtomsDeleted int `json:"atoms_deleted"` + EdgesDeleted int `json:"edges_deleted"` + CacheEntriesCleared int `json:"cache_entries_cleared"` + VectorIndexDropped bool `json:"vector_index_dropped"` + AffectedCartridges []any `json:"affected_cartridges"` + Reason string `json:"reason"` +} + type CacheLayer struct { Layer string `json:"layer"` Entries int `json:"entries"` @@ -44,7 +76,11 @@ type Atom struct { SourceID string `json:"source_id"` Modality string `json:"modality"` Source string `json:"source"` - Time string `json:"time"` + IngestedAt string `json:"ingested_at"` + HolderID string `json:"holder_id,omitempty"` + Origin string `json:"origin,omitempty"` + Scenario string `json:"scenario,omitempty"` + EventTime string `json:"event_time,omitempty"` Text string `json:"text"` ImageRef string `json:"image_ref,omitempty"` Layout any `json:"layout,omitempty"` @@ -65,7 +101,9 @@ type AtomSummary struct { SourceID string `json:"source_id"` Modality string `json:"modality"` Source string `json:"source"` - Time string `json:"time"` + IngestedAt string `json:"ingested_at"` + HolderID string `json:"holder_id,omitempty"` + Scenario string `json:"scenario,omitempty"` TextPreview string `json:"text_preview"` ImageRef string `json:"image_ref,omitempty"` TokenEquivalent int64 `json:"token_equivalent"` diff --git a/ely-eye-mcp/internal/mcpserver/server.go b/ely-eye-mcp/internal/mcpserver/server.go index 52743c9..8fb315c 100644 --- a/ely-eye-mcp/internal/mcpserver/server.go +++ b/ely-eye-mcp/internal/mcpserver/server.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/apiclient" "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/config" "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/store" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -16,6 +17,7 @@ import ( type Server struct { cfg config.Config store *store.Store + api *apiclient.Client version string } @@ -25,15 +27,16 @@ func Run(ctx context.Context, cfg config.Config, version string) error { return err } defer db.Close() - server := &Server{cfg: cfg, store: db, version: version} + server := &Server{cfg: cfg, store: db, api: apiclient.New(cfg.APIBaseURL), version: version} mcpServer := mcp.NewServer(&mcp.Implementation{ Name: "ely-eye", 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. Tools read local Ely-Eye state and do not mutate the production database.", + 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.", }) server.registerTools(mcpServer) + server.registerWriteTools(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/ely-eye-mcp/internal/mcpserver/write_tools.go b/ely-eye-mcp/internal/mcpserver/write_tools.go new file mode 100644 index 0000000..59b3e27 --- /dev/null +++ b/ely-eye-mcp/internal/mcpserver/write_tools.go @@ -0,0 +1,93 @@ +package mcpserver + +import ( + "context" + "errors" + + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/domain" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type RememberInput struct { + Text string `json:"text" jsonschema:"the statement to remember, kept verbatim"` + Scenario string `json:"scenario,omitempty" jsonschema:"optional context label such as home or work"` + EventTime string `json:"event_time,omitempty" jsonschema:"when it was true, ISO 8601 with offset, e.g. 2026-05-01T12:00:00+08:00"` + EventTimePrecision string `json:"event_time_precision,omitempty" jsonschema:"instant, minute, hour, day, month, or year; required with event_time"` + RetentionUntil string `json:"retention_until,omitempty" jsonschema:"ISO 8601 with offset; the memory stops being retrievable after this"` +} + +type SupersedeInput struct { + AtomID string `json:"atom_id" jsonschema:"exact Evidence Atom id being replaced"` + Text string `json:"text" jsonschema:"the newer statement, kept verbatim"` + Scenario string `json:"scenario,omitempty" jsonschema:"optional context label such as home or work"` + EventTime string `json:"event_time,omitempty" jsonschema:"when the new statement became true, ISO 8601 with offset"` + EventTimePrecision string `json:"event_time_precision,omitempty" jsonschema:"instant, minute, hour, day, month, or year; required with event_time"` +} + +type ForgetInput struct { + AtomID string `json:"atom_id,omitempty" jsonschema:"exact Evidence Atom id to forget"` + SourceID string `json:"source_id,omitempty" jsonschema:"exact source id to forget, removing every atom of that source"` + Reason string `json:"reason,omitempty" jsonschema:"why this memory is gone"` +} + +type RememberOutput struct { + Atom domain.Memory `json:"atom"` +} + +type ForgetOutput struct { + Report domain.ForgetReport `json:"report"` +} + +func (s *Server) registerWriteTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "remember", + Title: "Remember", + Description: "Store a new memory. The Ely-Eye server stamps the holder, origin and ingestion time, so a caller cannot claim to be somebody else.", + }, s.rememberTool) + mcp.AddTool(server, &mcp.Tool{ + Name: "supersede_memory", + Title: "Supersede a memory", + Description: "Replace a memory with a newer statement. The old one stops being retrievable but keeps its record, so the timeline stays readable.", + }, s.supersedeTool) + mcp.AddTool(server, &mcp.Tool{ + Name: "forget_memory", + Title: "Forget a memory", + Description: "Delete a memory by atom_id or source_id and drop the derived indexes that would keep surfacing it.", + }, s.forgetTool) +} + +func (s *Server) rememberTool(ctx context.Context, _ *mcp.CallToolRequest, input RememberInput) (*mcp.CallToolResult, RememberOutput, error) { + if input.Text == "" { + return nil, RememberOutput{}, errors.New("text is required") + } + var atom domain.Memory + if err := s.api.Post(ctx, "/api/memory", input, &atom); err != nil { + return nil, RememberOutput{}, err + } + return nil, RememberOutput{Atom: atom}, nil +} + +func (s *Server) supersedeTool(ctx context.Context, _ *mcp.CallToolRequest, input SupersedeInput) (*mcp.CallToolResult, RememberOutput, error) { + if input.AtomID == "" { + return nil, RememberOutput{}, errors.New("atom_id is required") + } + if input.Text == "" { + return nil, RememberOutput{}, errors.New("text is required") + } + var atom domain.Memory + if err := s.api.Post(ctx, "/api/memory/supersede", input, &atom); err != nil { + return nil, RememberOutput{}, err + } + return nil, RememberOutput{Atom: atom}, nil +} + +func (s *Server) forgetTool(ctx context.Context, _ *mcp.CallToolRequest, input ForgetInput) (*mcp.CallToolResult, ForgetOutput, error) { + if (input.AtomID == "") == (input.SourceID == "") { + return nil, ForgetOutput{}, errors.New("forget takes exactly one of atom_id or source_id") + } + var report domain.ForgetReport + if err := s.api.Post(ctx, "/api/memory/forget", input, &report); err != nil { + return nil, ForgetOutput{}, err + } + return nil, ForgetOutput{Report: report}, nil +} diff --git a/ely-eye-mcp/internal/mcpserver/write_tools_test.go b/ely-eye-mcp/internal/mcpserver/write_tools_test.go new file mode 100644 index 0000000..8968f99 --- /dev/null +++ b/ely-eye-mcp/internal/mcpserver/write_tools_test.go @@ -0,0 +1,135 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/apiclient" +) + +func recordingServer(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) { + payload, _ := io.ReadAll(r.Body) + var decoded map[string]any + _ = json.Unmarshal(payload, &decoded) + *seen = append(*seen, r.URL.Path+" "+string(payload)+" "+func() string { + if decoded["text"] == nil { + return "" + } + return decoded["text"].(string) + }()) + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) +} + +func TestRememberSendsTheStatementAndReturnsTheStampedMemory(t *testing.T) { + var seen []string + server := recordingServer(t, http.StatusCreated, + `{"atom_id":"mem-1:text","source_id":"mem-1","holder_id":"husband","origin":"self","text":"钥匙在玄关抽屉"}`, &seen) + defer server.Close() + + s := &Server{api: apiclient.New(server.URL)} + _, out, err := s.rememberTool(context.Background(), nil, RememberInput{Text: "钥匙在玄关抽屉", Scenario: "home"}) + if err != nil { + t.Fatalf("remember failed: %v", err) + } + if out.Atom.HolderID != "husband" || out.Atom.Origin != "self" { + t.Errorf("stamped memory = %+v, want the server's holder and origin", out.Atom) + } + if len(seen) != 1 || !strings.Contains(seen[0], "/api/memory") { + t.Fatalf("requests = %v, want one POST to /api/memory", seen) + } + if !strings.Contains(seen[0], "钥匙在玄关抽屉") { + t.Errorf("request = %s, want the statement verbatim", seen[0]) + } +} + +func TestRememberRefusesAnEmptyStatement(t *testing.T) { + var seen []string + server := recordingServer(t, http.StatusCreated, `{}`, &seen) + defer server.Close() + + if _, _, err := (&Server{api: apiclient.New(server.URL)}).rememberTool(context.Background(), nil, RememberInput{}); err == nil { + t.Fatal("an empty memory must not be stored") + } + if len(seen) != 0 { + t.Errorf("requests = %v, want the empty write rejected before the network", seen) + } +} + +func TestSupersedeNamesTheAtomItReplaces(t *testing.T) { + var seen []string + server := recordingServer(t, http.StatusOK, + `{"atom_id":"mem-2:text","supersedes":["mem-1:text"],"valid_from":"2026-05-02T00:00:00+00:00"}`, &seen) + defer server.Close() + + s := &Server{api: apiclient.New(server.URL)} + _, out, err := s.supersedeTool(context.Background(), nil, SupersedeInput{AtomID: "mem-1:text", Text: "钥匙在客厅茶几"}) + if err != nil { + t.Fatalf("supersede failed: %v", err) + } + if len(out.Atom.Supersedes) != 1 || out.Atom.Supersedes[0] != "mem-1:text" { + t.Errorf("supersedes = %v, want the replaced atom", out.Atom.Supersedes) + } + if !strings.Contains(seen[0], "/api/memory/supersede") { + t.Errorf("request = %s, want the supersede route", seen[0]) + } +} + +func TestSupersedeRefusesAMissingAtomID(t *testing.T) { + var seen []string + server := recordingServer(t, http.StatusOK, `{}`, &seen) + defer server.Close() + + if _, _, err := (&Server{api: apiclient.New(server.URL)}).supersedeTool(context.Background(), nil, SupersedeInput{Text: "新的说法"}); err == nil { + t.Fatal("supersede must name the atom it replaces") + } + if len(seen) != 0 { + t.Errorf("requests = %v, want no write attempted", seen) + } +} + +func TestForgetTakesExactlyOneID(t *testing.T) { + var seen []string + server := recordingServer(t, http.StatusOK, `{"source_id":"mem-1","atoms_deleted":1}`, &seen) + defer server.Close() + + s := &Server{api: apiclient.New(server.URL)} + for _, input := range []ForgetInput{{}, {AtomID: "a:text", SourceID: "b"}} { + if _, _, err := s.forgetTool(context.Background(), nil, input); err == nil { + t.Errorf("input %+v accepted, want exactly one id", input) + } + } + _, out, err := s.forgetTool(context.Background(), nil, ForgetInput{SourceID: "mem-1", Reason: "moved house"}) + if err != nil { + t.Fatalf("forget failed: %v", err) + } + if out.Report.AtomsDeleted != 1 { + t.Errorf("report = %+v, want the deleted count back", out.Report) + } + if len(seen) != 1 || !strings.Contains(seen[0], "/api/memory/forget") { + t.Errorf("requests = %v, want one POST to the forget route", seen) + } +} + +func TestARejectedWriteReachesTheCallerAsAnError(t *testing.T) { + var seen []string + server := recordingServer(t, http.StatusUnprocessableEntity, + `{"detail":"mem-1:text was already closed"}`, &seen) + defer server.Close() + + _, _, err := (&Server{api: apiclient.New(server.URL)}).supersedeTool(context.Background(), nil, SupersedeInput{AtomID: "mem-1:text", Text: "再说一次"}) + if err == nil { + t.Fatal("a closed memory must not supersede silently") + } + if !strings.Contains(err.Error(), "already closed") { + t.Errorf("error = %v, want the server's reason", err) + } +} diff --git a/ely-eye-mcp/internal/store/atoms.go b/ely-eye-mcp/internal/store/atoms.go index 579bde1..30eb7e9 100644 --- a/ely-eye-mcp/internal/store/atoms.go +++ b/ely-eye-mcp/internal/store/atoms.go @@ -18,9 +18,11 @@ func (s *Store) FetchAtom(ctx context.Context, atomID string) (domain.Atom, erro return domain.Atom{}, errors.New("atom_id is required") } row := s.db.QueryRowContext(ctx, ` - SELECT atom_id, source_id, modality, source, time, text, COALESCE(image_ref, ''), + SELECT atom_id, source_id, modality, source, ingested_at, + COALESCE(holder_id, ''), COALESCE(origin, ''), COALESCE(scenario, ''), + COALESCE(event_time, ''), text, COALESCE(image_ref, ''), COALESCE(layout_json, ''), relations_json, trust_json, token_equivalent, metadata_json - FROM atoms + FROM v_atoms_current WHERE atom_id = ?`, atomID) atom, err := scanAtom(row) if err != nil { @@ -48,12 +50,14 @@ func (s *Store) SearchAtoms(ctx context.Context, query string, modality string, } args = append(args, limit) rows, err := s.db.QueryContext(ctx, ` - SELECT a.atom_id, a.source_id, a.modality, a.source, a.time, a.text, + SELECT a.atom_id, a.source_id, a.modality, a.source, a.ingested_at, + COALESCE(a.holder_id, ''), COALESCE(a.origin, ''), COALESCE(a.scenario, ''), + COALESCE(a.event_time, ''), a.text, COALESCE(a.image_ref, ''), COALESCE(a.layout_json, ''), a.relations_json, a.trust_json, a.token_equivalent, a.metadata_json, bm25(atom_fts) AS score FROM atom_fts - JOIN atoms a ON a.atom_id = atom_fts.atom_id + JOIN v_atoms_current a ON a.atom_id = atom_fts.atom_id WHERE atom_fts MATCH ? `+filter+` ORDER BY score LIMIT ?`, args...) @@ -104,7 +108,11 @@ func scanAtom(scanner atomScanner) (domain.Atom, error) { &atom.SourceID, &atom.Modality, &atom.Source, - &atom.Time, + &atom.IngestedAt, + &atom.HolderID, + &atom.Origin, + &atom.Scenario, + &atom.EventTime, &atom.Text, &atom.ImageRef, &layout, @@ -147,7 +155,11 @@ func scanAtomHit(scanner atomScanner) (domain.Atom, float64, error) { &atom.SourceID, &atom.Modality, &atom.Source, - &atom.Time, + &atom.IngestedAt, + &atom.HolderID, + &atom.Origin, + &atom.Scenario, + &atom.EventTime, &atom.Text, &atom.ImageRef, &layout, diff --git a/scripts/mcp_write_smoke.sh b/scripts/mcp_write_smoke.sh new file mode 100755 index 0000000..d0b41fe --- /dev/null +++ b/scripts/mcp_write_smoke.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Prove the MCP write tools reach the Python writer over HTTP, end to end. +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +home="$(mktemp -d)" +port="${PORT:-8799}" +export ELY_EYE_HOME="$home" +export ELY_EYE_API="http://127.0.0.1:${port}" + +python_bin="$root/.venv/bin/python" +[ -x "$python_bin" ] || python_bin="python3" + +api="" +cleanup() { + if [ -n "$api" ]; then kill "$api" 2>/dev/null || true; fi + rm -rf "$home" +} +trap cleanup EXIT + +"$python_bin" -m ely_eye.cli serve --port "$port" >"$home/api.log" 2>&1 & +api=$! + +ready=0 +for _ in $(seq 1 80); do + if curl -sf "http://127.0.0.1:${port}/api/status" >/dev/null 2>&1; then ready=1; break; fi + sleep 0.5 +done +if [ "$ready" -ne 1 ]; then + echo "FAIL: the Ely-Eye API did not start" + cat "$home/api.log" + exit 1 +fi + +(cd "$root/ely-eye-mcp" && go build -o "$home/ely-eye-mcp" ./cmd/ely-eye-mcp) + +"$python_bin" - "$home/ely-eye-mcp" <<'PY' +import json +import subprocess +import sys + +binary = sys.argv[1] +proc = subprocess.Popen( + [binary, "server"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, +) + + +def send(message): + proc.stdin.write(json.dumps(message, ensure_ascii=False) + "\n") + proc.stdin.flush() + + +def call(id_, method, params): + send({"jsonrpc": "2.0", "id": id_, "method": method, "params": params}) + while True: + line = proc.stdout.readline() + if not line: + raise SystemExit(f"FAIL: the MCP server closed the pipe at request {id_}") + answer = json.loads(line) + if answer.get("id") == id_: + if "error" in answer: + raise SystemExit(f"FAIL: {method} returned {answer['error']}") + return answer["result"] + + +def tool(name, arguments): + result = call(3, "tools/call", {"name": name, "arguments": arguments}) + if result.get("isError"): + raise SystemExit(f"FAIL: {name} returned an error: {result['content']}") + return result["structuredContent"] + + +call(1, "initialize", { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "ely-eye-smoke", "version": "0"}, +}) +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"): + if required not in names: + raise SystemExit(f"FAIL: {required} is not registered; tools are {names}") + +statement = "keys are in the hall drawer" +remembered = tool("remember", {"text": statement, "scenario": "home"})["atom"] +if not remembered["holder_id"]: + raise SystemExit(f"FAIL: the server stamped no holder: {remembered}") +atom_id = remembered["atom_id"] + +hits = tool("search_atoms", {"query": statement})["hits"] +if not hits or hits[0]["atom"]["atom_id"] != atom_id: + raise SystemExit(f"FAIL: the new memory is not retrievable: {hits}") +if hits[0]["atom"].get("scenario") != "home": + raise SystemExit(f"FAIL: the search hit lost the scenario: {hits[0]['atom']}") + +replacement = "keys are on the coffee table" +newer = tool("supersede_memory", {"atom_id": atom_id, "text": replacement})["atom"] +if newer["supersedes"] != [atom_id]: + raise SystemExit(f"FAIL: the replacement does not point back: {newer}") + +after = tool("search_atoms", {"query": statement})["hits"] +if any(hit["atom"]["atom_id"] == atom_id for hit in after): + raise SystemExit(f"FAIL: the superseded memory is still retrievable: {after}") + +report = tool("forget_memory", {"atom_id": atom_id})["report"] +if report["atoms_deleted"] != 1: + raise SystemExit(f"FAIL: forget deleted {report['atoms_deleted']} atoms, want 1") +gone = call(3, "tools/call", {"name": "fetch_atom", "arguments": {"atom_id": atom_id}}) +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") +PY