Give the write path a CLI and an MCP face

remember / supersede / forget now reach the one Python writer from a
terminal, from MCP, and from HTTP. The server, not the caller, decides
who owns a memory.

- cli: remember, supersede, forget. --holder falls back to
  settings.default_holder until the holder registry lands. Timestamps are
  parsed once with one clear message.
- ingestion: import pymupdf as fitz. The deprecated fitz import printed a
  warning to stdout, so every CLI command's JSON answer was unparseable.
- mcp: remember, supersede_memory and forget_memory forward to the Python
  writer over HTTP (ELY_EYE_API, default http://127.0.0.1:8765). SQLite
  stays read-only to the Go side, so the write rules live in one place.
- mcp reads follow schema v2: atoms.time became ingested_at, and reads go
  through v_atoms_current, so a superseded memory stops being findable.
  holder, origin, scenario and event_time now show up in fetch_atom and
  search_atoms.
- scripts/mcp_write_smoke.sh proves the loop end to end: remember, search,
  supersede, forget, then not found.

Evidence: 92 pytest passed; go build, go vet and go test clean; mypy clean
on the 10 touched source files with a fresh cache; ruff clean except a
pre-existing B008 at cli.py:292, identical at HEAD.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-07 15:09:17 -04:00
1 parent 181f6bf57e
commit 3d792417cf
20 files changed
+1425 -33

No files matched your search

+51
View File
@@ -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()
+79 -12
View File
@@ -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()
+1
View File
@@ -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"
+60
View File
@@ -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])
+11 -5
View File
@@ -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
+219
View File
@@ -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
+47 -1
View File
@@ -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
+69
View File
@@ -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"]
+108
View File
@@ -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"] == "钥匙在玄关抽屉"
+182
View File
@@ -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")
+84
View File
@@ -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
}
@@ -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)
}
}
+15 -4
View File
@@ -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")
+3 -1
View File
@@ -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,
+40 -2
View File
@@ -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"`
+5 -2
View File
@@ -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") {
@@ -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
}
@@ -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 "<no text>"
}
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)
}
}
+18 -6
View File
@@ -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,
+118
View File
@@ -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