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:
1 parent
181f6bf57e
commit
3d792417cf
20 files changed
+1425
-33
No files matched your search
@@ -35,6 +35,7 @@ from .evaluation_schemas import (
|
|||||||
)
|
)
|
||||||
from .evidence_graph import EvidenceGraphService
|
from .evidence_graph import EvidenceGraphService
|
||||||
from .ingestion import IngestionService
|
from .ingestion import IngestionService
|
||||||
|
from .memory import MemoryService
|
||||||
from .profiles import runtime_profile_report
|
from .profiles import runtime_profile_report
|
||||||
from .research_modules import ResearchModuleService
|
from .research_modules import ResearchModuleService
|
||||||
from .runtime import QwenRuntime, RuntimeUnavailableError
|
from .runtime import QwenRuntime, RuntimeUnavailableError
|
||||||
@@ -43,10 +44,16 @@ from .schemas import (
|
|||||||
AdapterStatus,
|
AdapterStatus,
|
||||||
AnswerResponse,
|
AnswerResponse,
|
||||||
CartridgeAssetReport,
|
CartridgeAssetReport,
|
||||||
|
EvidenceAtom,
|
||||||
|
ForgetReport,
|
||||||
|
ForgetRequest,
|
||||||
|
MemoryOrigin,
|
||||||
MemoryStatus,
|
MemoryStatus,
|
||||||
PathIngestRequest,
|
PathIngestRequest,
|
||||||
|
RememberRequest,
|
||||||
RuntimeProfile,
|
RuntimeProfile,
|
||||||
RuntimeProfileReport,
|
RuntimeProfileReport,
|
||||||
|
SupersedeRequest,
|
||||||
SystemStatus,
|
SystemStatus,
|
||||||
UploadIngestRequest,
|
UploadIngestRequest,
|
||||||
)
|
)
|
||||||
@@ -183,6 +190,50 @@ def create_app() -> FastAPI:
|
|||||||
runtime_backend=generation.backend,
|
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")
|
@app.get("/api/cartridges")
|
||||||
def cartridges(settings: Annotated[Settings, Depends(settings_dep)]):
|
def cartridges(settings: Annotated[Settings, Depends(settings_dep)]):
|
||||||
return CartridgeService(settings).list_manifests()
|
return CartridgeService(settings).list_manifests()
|
||||||
|
|||||||
+79
-12
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
@@ -20,10 +21,11 @@ from .db import Database
|
|||||||
from .embeddings import EmbeddingService
|
from .embeddings import EmbeddingService
|
||||||
from .evals import HashHopEvaluator, PRDProofSuite
|
from .evals import HashHopEvaluator, PRDProofSuite
|
||||||
from .ingestion import IngestionService
|
from .ingestion import IngestionService
|
||||||
|
from .memory import MemoryService
|
||||||
from .profiles import runtime_profile_report
|
from .profiles import runtime_profile_report
|
||||||
from .research_modules import ResearchModuleService
|
from .research_modules import ResearchModuleService
|
||||||
from .runtime import QwenRuntime
|
from .runtime import QwenRuntime
|
||||||
from .schemas import TrainingAdapterKind
|
from .schemas import MemoryOrigin, TimePrecision, TrainingAdapterKind
|
||||||
from .training import TrainingService
|
from .training import TrainingService
|
||||||
|
|
||||||
app = typer.Typer(no_args_is_help=True)
|
app = typer.Typer(no_args_is_help=True)
|
||||||
@@ -46,17 +48,19 @@ def status() -> None:
|
|||||||
runtime = QwenRuntime(settings).status()
|
runtime = QwenRuntime(settings).status()
|
||||||
db = Database(settings)
|
db = Database(settings)
|
||||||
typer.echo(
|
typer.echo(
|
||||||
{
|
json_dump(
|
||||||
"runtime": runtime.model_dump(mode="json"),
|
{
|
||||||
"atoms": db.atom_count(),
|
"runtime": runtime.model_dump(mode="json"),
|
||||||
"token_equivalent": db.token_equivalent_sum(),
|
"atoms": db.atom_count(),
|
||||||
"library_token_equivalent": max(
|
"token_equivalent": db.token_equivalent_sum(),
|
||||||
db.token_equivalent_sum(),
|
"library_token_equivalent": max(
|
||||||
db.cartridge_token_equivalent_sum(),
|
db.token_equivalent_sum(),
|
||||||
),
|
db.cartridge_token_equivalent_sum(),
|
||||||
"cartridges": db.cartridge_count(),
|
),
|
||||||
"data_home": str(settings.home),
|
"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))
|
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")
|
@app.command("build-dense-index")
|
||||||
def build_dense_index() -> None:
|
def build_dense_index() -> None:
|
||||||
index = EmbeddingService(get_settings()).build_index()
|
index = EmbeddingService(get_settings()).build_index()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class Settings(BaseSettings):
|
|||||||
model_config = SettingsConfigDict(env_prefix="ELY_EYE_", env_file=".env", extra="ignore")
|
model_config = SettingsConfigDict(env_prefix="ELY_EYE_", env_file=".env", extra="ignore")
|
||||||
|
|
||||||
home: Path = Field(default_factory=lambda: Path.cwd() / ".ely_eye")
|
home: Path = Field(default_factory=lambda: Path.cwd() / ".ely_eye")
|
||||||
|
default_holder: str = "local"
|
||||||
model_id: str = "Qwen/Qwen3.5-9B"
|
model_id: str = "Qwen/Qwen3.5-9B"
|
||||||
planner_model_id: str = "Qwen/Qwen3.5-0.8B"
|
planner_model_id: str = "Qwen/Qwen3.5-0.8B"
|
||||||
embedding_model_id: str = "Qwen/Qwen3-VL-Embedding-8B"
|
embedding_model_id: str = "Qwen/Qwen3-VL-Embedding-8B"
|
||||||
|
|||||||
@@ -221,6 +221,66 @@ class Database:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return [(row["atom_id"], float(-row["score"])) for row in rows]
|
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:
|
def atom_count(self) -> int:
|
||||||
with self.connect() as connection:
|
with self.connect() as connection:
|
||||||
return int(connection.execute("SELECT COUNT(*) FROM atoms").fetchone()[0])
|
return int(connection.execute("SELECT COUNT(*) FROM atoms").fetchone()[0])
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ import re
|
|||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import fitz
|
|
||||||
import cv2
|
import cv2
|
||||||
|
import pymupdf as fitz
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from .config import Settings, get_settings
|
from .config import Settings, get_settings
|
||||||
@@ -26,8 +25,14 @@ from .schemas import (
|
|||||||
SourceRecord,
|
SourceRecord,
|
||||||
TrustScores,
|
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 = {
|
SUPPORTED_TEXT_EXTENSIONS = {
|
||||||
".txt",
|
".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]]:
|
def repository_call_graph(code_entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
edges: list[dict[str, Any]] = []
|
edges: list[dict[str, Any]] = []
|
||||||
for entry in code_entries:
|
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", []):
|
for call in symbols.get("calls", []):
|
||||||
if not isinstance(call, dict):
|
if not isinstance(call, dict):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,7 +5,7 @@ from enum import StrEnum
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
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:
|
def utc_now() -> datetime:
|
||||||
@@ -116,6 +116,16 @@ class EvidenceAtom(BaseModel):
|
|||||||
raise ValueError(f"{self.atom_id}: an atom cannot supersede itself")
|
raise ValueError(f"{self.atom_id}: an atom cannot supersede itself")
|
||||||
return self
|
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):
|
class SourceRecord(BaseModel):
|
||||||
source_id: str
|
source_id: str
|
||||||
path: str
|
path: str
|
||||||
@@ -378,6 +388,42 @@ class RuntimeProfileReport(BaseModel):
|
|||||||
profiles: list[RuntimeLaunchProfile]
|
profiles: list[RuntimeLaunchProfile]
|
||||||
validations: list[RuntimeProfileValidation] = Field(default_factory=list)
|
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):
|
class UploadIngestRequest(BaseModel):
|
||||||
cartridge_name: str | None = None
|
cartridge_name: str | None = None
|
||||||
dense_embeddings: bool | None = None
|
dense_embeddings: bool | None = None
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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"] == "钥匙在玄关抽屉"
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,12 +11,14 @@ type Config struct {
|
|||||||
ElyEyeHome string `json:"ely_eye_home"`
|
ElyEyeHome string `json:"ely_eye_home"`
|
||||||
Database string `json:"database"`
|
Database string `json:"database"`
|
||||||
Binary string `json:"binary"`
|
Binary string `json:"binary"`
|
||||||
|
APIBaseURL string `json:"api_base_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve locates the Ely-Eye data home and its SQLite database. The server is
|
// Resolve locates the Ely-Eye data home and its SQLite database. Reads mirror
|
||||||
// a read-only mirror of any local .ely_eye store, so identity is anchored on the
|
// any local .ely_eye store directly, so identity is anchored on the database
|
||||||
// database itself rather than on Ely-Eye repository layout. This lets the MCP
|
// itself rather than on Ely-Eye repository layout. This lets the MCP server be
|
||||||
// server be deployed for any project that owns an .ely_eye/data/ely_eye.sqlite.
|
// 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) {
|
func Resolve(projectRoot string, elyEyeHome string, binary string) (Config, error) {
|
||||||
home, err := resolveHome(projectRoot, elyEyeHome)
|
home, err := resolveHome(projectRoot, elyEyeHome)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -48,9 +50,18 @@ func Resolve(projectRoot string, elyEyeHome string, binary string) (Config, erro
|
|||||||
ElyEyeHome: home,
|
ElyEyeHome: home,
|
||||||
Database: db,
|
Database: db,
|
||||||
Binary: binary,
|
Binary: binary,
|
||||||
|
APIBaseURL: apiBaseURL(),
|
||||||
}, nil
|
}, 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) {
|
func resolveHome(projectRoot string, elyEyeHome string) (string, error) {
|
||||||
if elyEyeHome == "" {
|
if elyEyeHome == "" {
|
||||||
elyEyeHome = os.Getenv("ELY_EYE_HOME")
|
elyEyeHome = os.Getenv("ELY_EYE_HOME")
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ func summarizeAtom(atom domain.Atom, maxRunes int) domain.AtomSummary {
|
|||||||
SourceID: atom.SourceID,
|
SourceID: atom.SourceID,
|
||||||
Modality: atom.Modality,
|
Modality: atom.Modality,
|
||||||
Source: atom.Source,
|
Source: atom.Source,
|
||||||
Time: atom.Time,
|
IngestedAt: atom.IngestedAt,
|
||||||
|
HolderID: atom.HolderID,
|
||||||
|
Scenario: atom.Scenario,
|
||||||
TextPreview: trimRunes(atom.Text, maxRunes),
|
TextPreview: trimRunes(atom.Text, maxRunes),
|
||||||
ImageRef: atom.ImageRef,
|
ImageRef: atom.ImageRef,
|
||||||
TokenEquivalent: atom.TokenEquivalent,
|
TokenEquivalent: atom.TokenEquivalent,
|
||||||
|
|||||||
@@ -1,5 +1,37 @@
|
|||||||
package domain
|
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 {
|
type CacheLayer struct {
|
||||||
Layer string `json:"layer"`
|
Layer string `json:"layer"`
|
||||||
Entries int `json:"entries"`
|
Entries int `json:"entries"`
|
||||||
@@ -44,7 +76,11 @@ type Atom struct {
|
|||||||
SourceID string `json:"source_id"`
|
SourceID string `json:"source_id"`
|
||||||
Modality string `json:"modality"`
|
Modality string `json:"modality"`
|
||||||
Source string `json:"source"`
|
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"`
|
Text string `json:"text"`
|
||||||
ImageRef string `json:"image_ref,omitempty"`
|
ImageRef string `json:"image_ref,omitempty"`
|
||||||
Layout any `json:"layout,omitempty"`
|
Layout any `json:"layout,omitempty"`
|
||||||
@@ -65,7 +101,9 @@ type AtomSummary struct {
|
|||||||
SourceID string `json:"source_id"`
|
SourceID string `json:"source_id"`
|
||||||
Modality string `json:"modality"`
|
Modality string `json:"modality"`
|
||||||
Source string `json:"source"`
|
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"`
|
TextPreview string `json:"text_preview"`
|
||||||
ImageRef string `json:"image_ref,omitempty"`
|
ImageRef string `json:"image_ref,omitempty"`
|
||||||
TokenEquivalent int64 `json:"token_equivalent"`
|
TokenEquivalent int64 `json:"token_equivalent"`
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"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/config"
|
||||||
"github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/store"
|
"github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/store"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
type Server struct {
|
type Server struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
store *store.Store
|
store *store.Store
|
||||||
|
api *apiclient.Client
|
||||||
version string
|
version string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,15 +27,16 @@ func Run(ctx context.Context, cfg config.Config, version string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer db.Close()
|
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{
|
mcpServer := mcp.NewServer(&mcp.Implementation{
|
||||||
Name: "ely-eye",
|
Name: "ely-eye",
|
||||||
Title: "Ely-Eye Context Cartridge MCP",
|
Title: "Ely-Eye Context Cartridge MCP",
|
||||||
Version: version,
|
Version: version,
|
||||||
}, &mcp.ServerOptions{
|
}, &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.registerTools(mcpServer)
|
||||||
|
server.registerWriteTools(mcpServer)
|
||||||
server.registerResources(mcpServer)
|
server.registerResources(mcpServer)
|
||||||
if err := mcpServer.Run(ctx, &mcp.StdioTransport{}); err != nil {
|
if err := mcpServer.Run(ctx, &mcp.StdioTransport{}); err != nil {
|
||||||
if errors.Is(err, io.EOF) || strings.HasSuffix(err.Error(), "EOF") {
|
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,9 +18,11 @@ func (s *Store) FetchAtom(ctx context.Context, atomID string) (domain.Atom, erro
|
|||||||
return domain.Atom{}, errors.New("atom_id is required")
|
return domain.Atom{}, errors.New("atom_id is required")
|
||||||
}
|
}
|
||||||
row := s.db.QueryRowContext(ctx, `
|
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
|
COALESCE(layout_json, ''), relations_json, trust_json, token_equivalent, metadata_json
|
||||||
FROM atoms
|
FROM v_atoms_current
|
||||||
WHERE atom_id = ?`, atomID)
|
WHERE atom_id = ?`, atomID)
|
||||||
atom, err := scanAtom(row)
|
atom, err := scanAtom(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -48,12 +50,14 @@ func (s *Store) SearchAtoms(ctx context.Context, query string, modality string,
|
|||||||
}
|
}
|
||||||
args = append(args, limit)
|
args = append(args, limit)
|
||||||
rows, err := s.db.QueryContext(ctx, `
|
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, ''),
|
COALESCE(a.image_ref, ''), COALESCE(a.layout_json, ''),
|
||||||
a.relations_json, a.trust_json, a.token_equivalent, a.metadata_json,
|
a.relations_json, a.trust_json, a.token_equivalent, a.metadata_json,
|
||||||
bm25(atom_fts) AS score
|
bm25(atom_fts) AS score
|
||||||
FROM atom_fts
|
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+`
|
WHERE atom_fts MATCH ? `+filter+`
|
||||||
ORDER BY score
|
ORDER BY score
|
||||||
LIMIT ?`, args...)
|
LIMIT ?`, args...)
|
||||||
@@ -104,7 +108,11 @@ func scanAtom(scanner atomScanner) (domain.Atom, error) {
|
|||||||
&atom.SourceID,
|
&atom.SourceID,
|
||||||
&atom.Modality,
|
&atom.Modality,
|
||||||
&atom.Source,
|
&atom.Source,
|
||||||
&atom.Time,
|
&atom.IngestedAt,
|
||||||
|
&atom.HolderID,
|
||||||
|
&atom.Origin,
|
||||||
|
&atom.Scenario,
|
||||||
|
&atom.EventTime,
|
||||||
&atom.Text,
|
&atom.Text,
|
||||||
&atom.ImageRef,
|
&atom.ImageRef,
|
||||||
&layout,
|
&layout,
|
||||||
@@ -147,7 +155,11 @@ func scanAtomHit(scanner atomScanner) (domain.Atom, float64, error) {
|
|||||||
&atom.SourceID,
|
&atom.SourceID,
|
||||||
&atom.Modality,
|
&atom.Modality,
|
||||||
&atom.Source,
|
&atom.Source,
|
||||||
&atom.Time,
|
&atom.IngestedAt,
|
||||||
|
&atom.HolderID,
|
||||||
|
&atom.Origin,
|
||||||
|
&atom.Scenario,
|
||||||
|
&atom.EventTime,
|
||||||
&atom.Text,
|
&atom.Text,
|
||||||
&atom.ImageRef,
|
&atom.ImageRef,
|
||||||
&layout,
|
&layout,
|
||||||
|
|||||||
Executable
+118
@@ -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
|
||||||
Reference in new issue
Block a user