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")