lean-memory-console 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,321 @@
1
+ """Read-only enumeration SQL over the engine's per-namespace DBs (spec §7),
2
+ plus the fail-loud schema/sanitizer tripwires (spec §13).
3
+
4
+ Connections open with file:...?mode=ro ALWAYS; immutable=1 is a per-request
5
+ fallback tried ONLY after mode=ro raises OperationalError (spec §7). Column
6
+ names below are verbatim from the installed lean_memory store/schema.py.
7
+
8
+ Task 4 extends this module with list_facts/get_fact/list_episodes/
9
+ get_episode/list_entities.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import importlib.resources
16
+ import sqlite3
17
+ from pathlib import Path
18
+
19
+ from .config import sanitize_namespace
20
+
21
+
22
+ def open_ro(path: Path) -> sqlite3.Connection:
23
+ """Read-only engine connection. mode=ro first; immutable=1 only on
24
+ OperationalError (genuinely read-only media / error-14 path, spec §7)."""
25
+ uri = f"file:{path}?mode=ro"
26
+ try:
27
+ conn = sqlite3.connect(uri, uri=True)
28
+ except sqlite3.OperationalError:
29
+ conn = sqlite3.connect(f"file:{path}?immutable=1", uri=True)
30
+ conn.row_factory = sqlite3.Row
31
+ conn.execute("PRAGMA busy_timeout=5000")
32
+ return conn
33
+
34
+
35
+ def _fts_query(text: str) -> str:
36
+ """OR-query of QUOTED alnum terms (mirrors engine sqlite_store._fts_query so
37
+ the console's text filter matches the same tokens the engine indexes).
38
+
39
+ Quoting matters: FTS5 treats bare AND/OR/NOT/NEAR as operators, so an
40
+ unquoted query with such a token raises a syntax error. A quoted term is an
41
+ inert string literal, still matched case-insensitively by the tokenizer;
42
+ terms are alnum-only after the scrub, so no embedded quote can break out."""
43
+ terms = [
44
+ t for t in "".join(c if c.isalnum() else " " for c in text).split() if t
45
+ ]
46
+ if not terms:
47
+ return '""'
48
+ return " OR ".join(f'"{t}"' for t in terms)
49
+
50
+
51
+ def list_namespaces(data_root: Path, event_log) -> list[dict]:
52
+ """Discover *.db (skipping _*.db), returning per-namespace counts.
53
+ Bare array (unpaginated), ordered by total facts DESC then name (spec §7)."""
54
+ data_root = Path(data_root)
55
+ out: list[dict] = []
56
+ for db_path in sorted(data_root.glob("*.db")):
57
+ if db_path.name.startswith("_"):
58
+ continue
59
+ name = db_path.stem
60
+ conn = open_ro(db_path)
61
+ try:
62
+ facts_latest = conn.execute(
63
+ "SELECT COUNT(*) FROM fact WHERE is_latest=1"
64
+ ).fetchone()[0]
65
+ facts_retired = conn.execute(
66
+ "SELECT COUNT(*) FROM fact WHERE is_latest=0"
67
+ ).fetchone()[0]
68
+ entities = conn.execute("SELECT COUNT(*) FROM entity").fetchone()[0]
69
+ episodes = conn.execute("SELECT COUNT(*) FROM episode").fetchone()[0]
70
+ chains = conn.execute(
71
+ "SELECT COUNT(*) FROM fact WHERE superseded_by IS NOT NULL"
72
+ ).fetchone()[0]
73
+ top_predicates = [
74
+ {"predicate": r["predicate"], "count": r["n"]}
75
+ for r in conn.execute(
76
+ "SELECT predicate, COUNT(*) AS n FROM fact "
77
+ "GROUP BY predicate ORDER BY n DESC, predicate LIMIT 5"
78
+ ).fetchall()
79
+ ]
80
+ finally:
81
+ conn.close()
82
+ file_size = db_path.stat().st_size
83
+ activity = event_log.activity_summary(name)
84
+ out.append(
85
+ {
86
+ "name": name,
87
+ "facts_latest": facts_latest,
88
+ "facts_retired": facts_retired,
89
+ "entities": entities,
90
+ "episodes": episodes,
91
+ "chains": chains,
92
+ "file_size": file_size,
93
+ "top_predicates": top_predicates,
94
+ "activity": activity,
95
+ }
96
+ )
97
+ out.sort(key=lambda n: (-(n["facts_latest"] + n["facts_retired"]), n["name"]))
98
+ return out
99
+
100
+
101
+ def _digest_lines(text: str, predicate) -> str:
102
+ lines = [ln for ln in text.splitlines() if predicate(ln)]
103
+ return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
104
+
105
+
106
+ def compute_engine_schema_fingerprint() -> str:
107
+ """sha256 over the CREATE lines of the INSTALLED lean_memory store/schema.py
108
+ (importlib.resources, never a checked-in copy) — the §13 tripwire."""
109
+ text = (
110
+ importlib.resources.files("lean_memory.store")
111
+ .joinpath("schema.py")
112
+ .read_text(encoding="utf-8")
113
+ )
114
+ return _digest_lines(text, lambda ln: "create" in ln.lower())
115
+
116
+
117
+ def compute_sanitizer_fingerprint() -> str:
118
+ """sha256 over memory.py's sanitizer lines (_SAFE_NS / the 'or "default"'
119
+ fallback) — guards the config.py mirror against engine drift (§13)."""
120
+ text = (
121
+ importlib.resources.files("lean_memory")
122
+ .joinpath("memory.py")
123
+ .read_text(encoding="utf-8")
124
+ )
125
+ return _digest_lines(
126
+ text, lambda ln: "_SAFE_NS" in ln or 'or "default"' in ln
127
+ )
128
+
129
+
130
+ def _paginate(page: int, page_size: int) -> tuple[int, int, int]:
131
+ page = max(page, 1)
132
+ page_size = min(max(page_size, 1), 200)
133
+ return page, page_size, (page - 1) * page_size
134
+
135
+
136
+ def list_facts(
137
+ db_path: Path,
138
+ latest_only: bool = True,
139
+ predicate: str | None = None,
140
+ entity: str | None = None,
141
+ min_salience: float | None = None,
142
+ q: str | None = None,
143
+ page: int = 1,
144
+ page_size: int = 50,
145
+ ) -> dict:
146
+ """Filterable fact list. Rows carry subject = entity.name (joined via
147
+ fact.subject_id). q is an FTS filter over fact_fts (distinct from search).
148
+ Order created_at DESC, id DESC; total is post-filter (spec §7)."""
149
+ page, page_size, offset = _paginate(page, page_size)
150
+ where = ["1=1"]
151
+ args: list = []
152
+ if latest_only:
153
+ where.append("f.is_latest = 1")
154
+ if predicate is not None:
155
+ where.append("f.predicate = ?")
156
+ args.append(predicate)
157
+ if entity is not None:
158
+ where.append("LOWER(e.name) = LOWER(?)")
159
+ args.append(entity)
160
+ if min_salience is not None:
161
+ where.append("f.salience >= ?")
162
+ args.append(min_salience)
163
+ if q is not None:
164
+ where.append(
165
+ "f.id IN (SELECT fact_id FROM fact_fts WHERE fact_fts MATCH ?)"
166
+ )
167
+ args.append(_fts_query(q))
168
+ clause = " AND ".join(where)
169
+
170
+ conn = open_ro(db_path)
171
+ try:
172
+ total = conn.execute(
173
+ f"SELECT COUNT(*) FROM fact f "
174
+ f"JOIN entity e ON f.subject_id = e.id WHERE {clause}",
175
+ args,
176
+ ).fetchone()[0]
177
+ rows = conn.execute(
178
+ f"SELECT f.*, e.name AS subject FROM fact f "
179
+ f"JOIN entity e ON f.subject_id = e.id WHERE {clause} "
180
+ f"ORDER BY f.created_at DESC, f.id DESC LIMIT ? OFFSET ?",
181
+ (*args, page_size, offset),
182
+ ).fetchall()
183
+ finally:
184
+ conn.close()
185
+ return {
186
+ "items": [dict(r) for r in rows],
187
+ "page": page,
188
+ "page_size": page_size,
189
+ "total": total,
190
+ }
191
+
192
+
193
+ def get_fact(db_path: Path, fact_id) -> dict | None:
194
+ """Full fact row + subject name + supersession chain (oldest->newest,
195
+ walked both directions) + source episode (spec §7)."""
196
+ conn = open_ro(db_path)
197
+ try:
198
+ row = conn.execute(
199
+ "SELECT f.*, e.name AS subject FROM fact f "
200
+ "JOIN entity e ON f.subject_id = e.id WHERE f.id = ?",
201
+ (fact_id,),
202
+ ).fetchone()
203
+ if row is None:
204
+ return None
205
+ out = dict(row)
206
+
207
+ # Walk backward: follow superseded_by chains that point AT this fact
208
+ # (older facts whose superseded_by == current id), oldest first.
209
+ backward: list[dict] = []
210
+ cur = out["id"]
211
+ while True:
212
+ prev = conn.execute(
213
+ "SELECT * FROM fact WHERE superseded_by = ?", (cur,)
214
+ ).fetchone()
215
+ if prev is None:
216
+ break
217
+ backward.append(dict(prev))
218
+ cur = prev["id"]
219
+ backward.reverse() # oldest -> ... -> just-before-current
220
+
221
+ # Walk forward: follow this fact's superseded_by pointer to newer facts.
222
+ forward: list[dict] = []
223
+ cur_row = dict(row)
224
+ while cur_row.get("superseded_by"):
225
+ nxt = conn.execute(
226
+ "SELECT * FROM fact WHERE id = ?", (cur_row["superseded_by"],)
227
+ ).fetchone()
228
+ if nxt is None:
229
+ break
230
+ forward.append(dict(nxt))
231
+ cur_row = dict(nxt)
232
+
233
+ chain = backward + [dict(row)] + forward
234
+ out["chain"] = chain
235
+
236
+ episode = conn.execute(
237
+ "SELECT * FROM episode WHERE id = ?", (out["episode_id"],)
238
+ ).fetchone()
239
+ out["episode"] = dict(episode) if episode is not None else None
240
+ finally:
241
+ conn.close()
242
+ return out
243
+
244
+
245
+ def list_episodes(db_path: Path, page: int = 1, page_size: int = 50) -> dict:
246
+ """Episodes ordered t_ref DESC (spec §7)."""
247
+ page, page_size, offset = _paginate(page, page_size)
248
+ conn = open_ro(db_path)
249
+ try:
250
+ total = conn.execute("SELECT COUNT(*) FROM episode").fetchone()[0]
251
+ rows = conn.execute(
252
+ "SELECT * FROM episode ORDER BY t_ref DESC, id DESC LIMIT ? OFFSET ?",
253
+ (page_size, offset),
254
+ ).fetchall()
255
+ finally:
256
+ conn.close()
257
+ return {
258
+ "items": [dict(r) for r in rows],
259
+ "page": page,
260
+ "page_size": page_size,
261
+ "total": total,
262
+ }
263
+
264
+
265
+ def get_episode(db_path: Path, episode_id) -> dict | None:
266
+ """One episode + its extracted facts (episode_id match)."""
267
+ conn = open_ro(db_path)
268
+ try:
269
+ row = conn.execute(
270
+ "SELECT * FROM episode WHERE id = ?", (episode_id,)
271
+ ).fetchone()
272
+ if row is None:
273
+ return None
274
+ out = dict(row)
275
+ facts = conn.execute(
276
+ "SELECT f.*, e.name AS subject FROM fact f "
277
+ "JOIN entity e ON f.subject_id = e.id WHERE f.episode_id = ? "
278
+ "ORDER BY f.created_at, f.id",
279
+ (episode_id,),
280
+ ).fetchall()
281
+ out["facts"] = [dict(r) for r in facts]
282
+ finally:
283
+ conn.close()
284
+ return out
285
+
286
+
287
+ def list_entities(db_path: Path, page: int = 1, page_size: int = 50) -> dict:
288
+ """Entity names + fact_count (as subject), ordered fact_count DESC then name."""
289
+ page, page_size, offset = _paginate(page, page_size)
290
+ conn = open_ro(db_path)
291
+ try:
292
+ total = conn.execute("SELECT COUNT(*) FROM entity").fetchone()[0]
293
+ rows = conn.execute(
294
+ "SELECT e.id AS id, e.name AS name, e.type AS type, "
295
+ "COUNT(f.id) AS fact_count "
296
+ "FROM entity e LEFT JOIN fact f ON f.subject_id = e.id "
297
+ "GROUP BY e.id, e.name, e.type "
298
+ "ORDER BY fact_count DESC, e.name LIMIT ? OFFSET ?",
299
+ (page_size, offset),
300
+ ).fetchall()
301
+ finally:
302
+ conn.close()
303
+ return {
304
+ "items": [dict(r) for r in rows],
305
+ "page": page,
306
+ "page_size": page_size,
307
+ "total": total,
308
+ }
309
+
310
+
311
+ # Filled once from the first run's printed digests (Step 5), then a test pins
312
+ # equality so engine drift turns the suite red.
313
+ EXPECTED_SCHEMA_FINGERPRINT = (
314
+ "a6c7f41188a196929ff4e7c257a181c100af9f0f7091aa93d428a9a7c0eec8ef"
315
+ )
316
+ EXPECTED_SANITIZER_FINGERPRINT = (
317
+ # Bumped for WP10a Task 6: Memory._maintenance_store() reuses the identical
318
+ # `_SAFE_NS.sub("_", namespace) or "default"` expression (memory.py:298),
319
+ # adding a third matching line; sanitizer semantics unchanged, mirror valid.
320
+ "973f203a76ab1b535ae8e81dcb830145c92bf481cf4c815b5fbca0044e5d044c"
321
+ )
@@ -0,0 +1,137 @@
1
+ """Shared MCP tool registration for the console's two surfaces (design spec §6.3).
2
+
3
+ The console ships the SAME memory + maintenance tools on both its stdio server
4
+ (``observe_mcp.py`` — what the plugin runs) and its Docker HTTP mount
5
+ (``routes/mcp.py``). Registering them once here, against a passed-in FastMCP + the
6
+ gateway, keeps tool names, signatures, and return shapes IDENTICAL across the two —
7
+ and identical to the core stdio server (``lean_memory.mcp_server``), which is the §6.3
8
+ requirement and the v0.1.3 manifest-parity lesson.
9
+
10
+ Every tool reaches the engine ONLY through ``EngineGateway`` (spec §1.3.8): the four
11
+ maintenance methods (``maintain``/``review_queue``/``decide``/``promote``) each wrap
12
+ ``retry_busy`` + the per-namespace asyncio lock + the single worker thread, exactly
13
+ like ``add``/``search``.
14
+
15
+ The review-workflow PROMPT lives on the stdio server only (``register_review_prompt``)
16
+ — MCP prompt surfacing is a stdio-client capability; the plugin command file is the
17
+ portable path for everyone else (§6.4).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from typing import Any
24
+
25
+ from mcp.server.fastmcp import FastMCP
26
+
27
+ from .engine import EngineGateway
28
+
29
+ # The prompt text: the client agent runs THIS workflow. It MUST NOT decide any
30
+ # proposal without an explicit user verdict, and batch verbs ("approve all exact
31
+ # dedups") map only to explicit user statements (§6.4). Kept as a module constant so
32
+ # the stdio prompt and any future surface share one canonical wording.
33
+ REVIEW_PROMPT_TEXT = """\
34
+ You are helping the user review staged sleep-time memory-maintenance proposals for a \
35
+ namespace. Maintenance has already run and STAGED judgment calls (near-duplicate \
36
+ merges, summaries, evictions) as *proposals*; nothing has changed in memory yet. Your \
37
+ job is to walk the user through them and record ONLY the decisions the user explicitly \
38
+ makes.
39
+
40
+ HARD RULE — you may NOT decide any proposal on your own. Every approve / reject / edit \
41
+ / promote MUST come from an explicit user verdict in this conversation. If the user has \
42
+ not stated a verdict for an item, leave it pending. A batch verb (e.g. "approve all \
43
+ exact dedups", "reject every eviction") is valid ONLY when the user says it in those \
44
+ words — never infer a batch decision from a single example or from silence. Silence is \
45
+ not consent; unreviewed proposals expire on their own.
46
+
47
+ Workflow:
48
+ 1. Call `memory_review_queue(namespace=..., limit=...)` to fetch the pending queue. It \
49
+ comes grouped by subject entity, each proposal carrying its evidence payload.
50
+ 2. Present the proposals to the user, batched by entity and kind, showing the evidence \
51
+ (the before/after texts, cosine for near-dups, source facts for summaries, the \
52
+ value signals for evictions). Keep it scannable.
53
+ 3. Collect the user's EXPLICIT verdicts. Ask when a verdict is missing or ambiguous. Do \
54
+ not proceed on an item the user has not ruled on.
55
+ 4. For each item the user decided, call \
56
+ `memory_review_decide(namespace=..., proposal_id=..., decision=..., edited_text=...)` \
57
+ with decision in {approve, reject, edit, promote}. `edited_text` applies only to a \
58
+ summarize proposal the user chose to reword before approving.
59
+ 5. Summarize what was applied, what was rejected, and what remains pending (untouched, \
60
+ still awaiting the user).
61
+ """
62
+
63
+
64
+ def register_maintenance_tools(mcp: FastMCP, gateway: EngineGateway) -> None:
65
+ """Register the four §6.3 maintenance tools on `mcp`, routed through `gateway`.
66
+
67
+ Identical names/signatures to the core stdio server: memory_maintenance_run
68
+ (dry-run default), memory_maintenance_status, memory_review_queue,
69
+ memory_review_decide.
70
+ """
71
+
72
+ @mcp.tool()
73
+ async def memory_maintenance_run(
74
+ namespace: str, apply: bool = False
75
+ ) -> dict[str, Any]:
76
+ """Run one sleep-time maintenance pass (§6.3). DRY-RUN by default (apply=False):
77
+ computes the would-do report with zero writes. apply=True runs the auto band
78
+ and stages proposals. Symmetric with the CLI. NOTE: the LM_MAINT_AUTO
79
+ auto-spawn path runs `--apply --auto-only` (auto band only, no proposals) —
80
+ only interactive apply=True grows the review queue. Returns the run summary."""
81
+ return await gateway.maintain(namespace, apply=apply)
82
+
83
+ @mcp.tool()
84
+ async def memory_maintenance_status(namespace: str) -> dict[str, Any]:
85
+ """Report the namespace's maintenance ledger — runs + pending proposals (§6.3).
86
+
87
+ A pure model-free ledger read, shaped identically to the core server's status
88
+ tool ({namespace, runs, pending_proposals, last_run}). Use it to see whether
89
+ maintenance is due and how many proposals wait."""
90
+ return await gateway.maintenance_status(namespace)
91
+
92
+ @mcp.tool()
93
+ async def memory_review_queue(
94
+ namespace: str, kind: str | None = None, limit: int = 20
95
+ ) -> dict[str, Any]:
96
+ """List pending maintenance proposals, grouped by entity, with evidence (§6.3).
97
+
98
+ `kind` filters to 'dedup_near' | 'summarize' | 'evict'. Each proposal includes
99
+ its parsed evidence payload so the reviewer sees exactly what would change."""
100
+ groups = await gateway.review_queue(namespace, kind=kind, limit=limit)
101
+ # Round-trip through JSON so numpy/None edge values serialize cleanly, matching
102
+ # the core server's json.dumps(..., default=str) shape.
103
+ return json.loads(json.dumps({"groups": groups}, default=str))
104
+
105
+ @mcp.tool()
106
+ async def memory_review_decide(
107
+ namespace: str,
108
+ proposal_id: str,
109
+ decision: str,
110
+ edited_text: str | None = None,
111
+ ) -> dict[str, Any]:
112
+ """Decide a maintenance proposal: approve | reject | edit | promote (§6.3).
113
+
114
+ approve applies at decide-time with apply-time re-validation; reject leaves the
115
+ spine byte-identical; edit (summarize only) approves the human-edited text;
116
+ promote (evict only) rejects the eviction and lifts the fact to the hot tier."""
117
+ result = await gateway.decide(
118
+ namespace, proposal_id, decision, edited_text=edited_text
119
+ )
120
+ return json.loads(json.dumps(result, default=str))
121
+
122
+
123
+ def register_review_prompt(mcp: FastMCP) -> None:
124
+ """Register the `review-memory-maintenance` prompt on a stdio FastMCP (§6.4).
125
+
126
+ The prompt hands the client agent the review workflow but FORBIDS it from deciding
127
+ without an explicit user verdict. Stdio-only: prompt surfacing is a stdio-client
128
+ capability; the plugin command file is the portable path (§6.4)."""
129
+
130
+ @mcp.prompt(name="review-memory-maintenance")
131
+ def review_memory_maintenance(namespace: str = "") -> str:
132
+ """Walk the user through staged memory-maintenance proposals and record only the
133
+ decisions the user explicitly makes."""
134
+ header = (
135
+ f"Namespace to review: {namespace}\n\n" if namespace else ""
136
+ )
137
+ return header + REVIEW_PROMPT_TEXT
@@ -0,0 +1,73 @@
1
+ """Observing MCP wrapper — stdio server that writes through EngineGateway.
2
+
3
+ A deliberate superset of the core stdio server: memory_add gains source/t_ref
4
+ and a structured return; memory_clear is intentionally absent (no deletion
5
+ surface, §6). Parity is with the Memory API, not the core tool signatures.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from mcp.server.fastmcp import FastMCP
13
+
14
+ from .config import ConsoleConfig
15
+ from .engine import EngineGateway
16
+ from .events import EventLog
17
+ from .mcp_tools import register_maintenance_tools, register_review_prompt
18
+
19
+
20
+ def build_mcp(gateway: EngineGateway) -> FastMCP:
21
+ mcp = FastMCP("lean-memory-console")
22
+
23
+ @mcp.tool()
24
+ async def memory_add(
25
+ namespace: str,
26
+ text: str,
27
+ source: str = "user",
28
+ t_ref: int | None = None,
29
+ ) -> dict[str, Any]:
30
+ """Ingest text into the namespace's memory (observing wrapper).
31
+
32
+ Returns the new fact ids and how many prior facts were superseded.
33
+ """
34
+ res = await gateway.add(namespace, text, source=source, t_ref=t_ref)
35
+ return {
36
+ "fact_ids": res.fact_ids,
37
+ "superseded_count": res.superseded_count,
38
+ }
39
+
40
+ @mcp.tool()
41
+ async def memory_search(namespace: str, query: str, k: int = 5) -> dict[str, Any]:
42
+ """Search a namespace's memory; returns top-k fact texts + scores.
43
+
44
+ Always latest-only (the latest_only flag is REST-only, §6).
45
+ """
46
+ res = await gateway.search(
47
+ namespace, query, k=k, latest_only=True, origin="agent"
48
+ )
49
+ return {
50
+ "hits": [
51
+ {"fact_text": h["fact_text"], "final_score": h["final_score"]}
52
+ for h in res.hits
53
+ ]
54
+ }
55
+
56
+ # The four sleep-time maintenance tools, identical to the core + HTTP surfaces
57
+ # (§6.3), plus the stdio-only review-workflow prompt (§6.4).
58
+ register_maintenance_tools(mcp, gateway)
59
+ register_review_prompt(mcp)
60
+
61
+ return mcp
62
+
63
+
64
+ def run_stdio(config: ConsoleConfig) -> None:
65
+ """Build the gateway + wrapper for `config` and serve over stdio."""
66
+ event_log = EventLog(config.data_root)
67
+ gateway = EngineGateway(config, event_log)
68
+ mcp = build_mcp(gateway)
69
+ try:
70
+ mcp.run() # blocks on stdio until the client disconnects
71
+ finally:
72
+ gateway.close()
73
+ event_log.close()
File without changes
@@ -0,0 +1,53 @@
1
+ """/v1/* — the REST data plane mirror (Docker mode, non-MCP agents)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, HTTPException, Request
6
+ from pydantic import BaseModel, Field
7
+
8
+ from ..config import is_reserved_namespace
9
+
10
+
11
+ class MemoryBody(BaseModel):
12
+ text: str
13
+ source: str = "user"
14
+ t_ref: int | None = None
15
+
16
+
17
+ class SearchBody(BaseModel):
18
+ query: str
19
+ k: int = Field(default=5, ge=1, le=200)
20
+ latest_only: bool = True
21
+
22
+
23
+ def build_data_router() -> APIRouter:
24
+ router = APIRouter(prefix="/v1")
25
+
26
+ @router.post("/{namespace}/memories")
27
+ async def add_memory(request: Request, namespace: str, body: MemoryBody):
28
+ if is_reserved_namespace(namespace):
29
+ raise HTTPException(status_code=404, detail="unknown namespace")
30
+ gateway = request.app.state.gateway
31
+ res = await gateway.add(
32
+ namespace, body.text, source=body.source, t_ref=body.t_ref
33
+ )
34
+ return {
35
+ "fact_ids": res.fact_ids,
36
+ "superseded_count": res.superseded_count,
37
+ }
38
+
39
+ @router.post("/{namespace}/search")
40
+ async def search_memory(request: Request, namespace: str, body: SearchBody):
41
+ if is_reserved_namespace(namespace):
42
+ raise HTTPException(status_code=404, detail="unknown namespace")
43
+ gateway = request.app.state.gateway
44
+ res = await gateway.search(
45
+ namespace,
46
+ body.query,
47
+ k=body.k,
48
+ latest_only=body.latest_only,
49
+ origin="agent",
50
+ )
51
+ return {"hits": res.hits, "duration_ms": res.duration_ms}
52
+
53
+ return router