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,377 @@
1
+ """EngineGateway — the console's write path over a single lean_memory.Memory.
2
+
3
+ Wraps every engine write in a bounded SQLITE_BUSY retry (the engine sets no
4
+ busy_timeout, §6), serializes per-namespace writes with an asyncio.Lock,
5
+ detects supersession by reading fact.superseded_by on the returned ids while
6
+ still holding the lock (§5), and records add/search events. Event-recording
7
+ never masks the operation's own result (§5 failure contract).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import sqlite3
14
+ import time
15
+ from concurrent.futures import ThreadPoolExecutor
16
+ from dataclasses import dataclass, field
17
+ from typing import Callable, TypeVar
18
+
19
+ from lean_memory import Memory
20
+ from lean_memory.maintain import mcp_support
21
+ from lean_memory.maintain.cli import _report_to_dict as _maint_report_to_dict
22
+
23
+ from .config import ConsoleConfig, is_reserved_namespace, ns_db_path
24
+ from .events import EventLog
25
+
26
+ T = TypeVar("T")
27
+
28
+
29
+ def _maintenance_report_to_dict(namespace: str, report, *, apply: bool) -> dict:
30
+ """The maintenance run summary, shaped identically to the core MCP surface.
31
+
32
+ Reuses the core CLI's RunReport→dict projection so the console tool's return is
33
+ byte-for-byte the same shape as core `memory_maintenance_run` (tool-name and
34
+ return parity across all three surfaces, §6.3). auto_only is always False from the
35
+ gateway path — the console never runs the auto-only preview mode."""
36
+ return _maint_report_to_dict(namespace, report, apply=apply, auto_only=False)
37
+
38
+
39
+ def _build_memory(config: ConsoleConfig) -> Memory:
40
+ """Build the console's Memory, opportunistically upgrading to real backends.
41
+
42
+ Mirror of the core engine's own wiring in
43
+ ``lean_memory.mcp_server._build_memory`` (src/lean_memory/mcp_server.py,
44
+ ~lines 34-82); kept as a mirror (not a private-symbol import) exactly as the
45
+ namespace sanitizer is mirrored in config.py. Two INDEPENDENT upgrades, each
46
+ guarded by its own import so either can succeed without the other:
47
+ * ``models`` (sentence_transformers) → real embedder + reranker.
48
+ * ``extract`` (gliner2) → Gliner2Generator for extraction.
49
+ Anything not installed falls back to lean-memory's deterministic offline
50
+ stubs, so the console always runs.
51
+
52
+ ``config.models`` gates the whole upgrade:
53
+ * ``"stub"`` → force the deterministic offline stubs (tests/CI, and the
54
+ console's own default behavior on a stub-only install).
55
+ * ``"auto"`` → upgrade each backend whose optional extra is importable.
56
+ """
57
+ if config.models == "stub":
58
+ return Memory(root=config.data_root)
59
+
60
+ kwargs: dict = {}
61
+
62
+ try:
63
+ import sentence_transformers # noqa: F401
64
+
65
+ from lean_memory.embed.sentence_transformer import (
66
+ SentenceTransformerEmbedder,
67
+ )
68
+ from lean_memory.retrieve.rerank import CrossEncoderReranker
69
+
70
+ kwargs["embedder"] = SentenceTransformerEmbedder()
71
+ kwargs["reranker"] = CrossEncoderReranker()
72
+ except ImportError:
73
+ # `models` extra not installed — keep the deterministic stub backends.
74
+ pass
75
+
76
+ try:
77
+ import gliner2 # noqa: F401
78
+
79
+ from lean_memory.extract.gliner_extractor import Gliner2Generator
80
+
81
+ kwargs["generator"] = Gliner2Generator()
82
+ except ImportError:
83
+ # `extract` extra not installed — keep the stub candidate generator.
84
+ pass
85
+
86
+ return Memory(root=config.data_root, **kwargs)
87
+
88
+
89
+ def resolved_models_mode(config: ConsoleConfig) -> str:
90
+ """Report whether the built Memory uses real or stub retrieval backends.
91
+
92
+ "stub" is forced when config.models == "stub"; otherwise "real" only when
93
+ the [models] extra (sentence_transformers) is importable — matching the
94
+ embedder/reranker upgrade in _build_memory. This is the resolved value the
95
+ whoami view surfaces so the UI can warn that semantic scores are stubbed.
96
+ """
97
+ if config.models == "stub":
98
+ return "stub"
99
+ try:
100
+ import sentence_transformers # noqa: F401
101
+ except ImportError:
102
+ return "stub"
103
+ return "real"
104
+
105
+
106
+ @dataclass
107
+ class AddResult:
108
+ fact_ids: list = field(default_factory=list)
109
+ superseded_fact_ids: list = field(default_factory=list)
110
+ superseded_count: int = 0
111
+ duration_ms: float = 0.0
112
+
113
+
114
+ @dataclass
115
+ class SearchResult:
116
+ hits: list = field(default_factory=list)
117
+ duration_ms: float = 0.0
118
+
119
+
120
+ def retry_busy(fn: Callable[[], T], attempts: int = 3) -> T:
121
+ """Call fn, retrying on SQLITE_BUSY / 'database is locked'.
122
+
123
+ Catches sqlite3.OperationalError whose message contains 'locked' or
124
+ 'busy'; sleeps 0.05 * 2**i between attempts; re-raises after the last.
125
+ Any other error propagates immediately.
126
+ """
127
+ last: Exception | None = None
128
+ for i in range(attempts):
129
+ try:
130
+ return fn()
131
+ except sqlite3.OperationalError as exc:
132
+ msg = str(exc).lower()
133
+ if "locked" not in msg and "busy" not in msg:
134
+ raise
135
+ last = exc
136
+ if i < attempts - 1:
137
+ time.sleep(0.05 * (2 ** i))
138
+ assert last is not None
139
+ raise last
140
+
141
+
142
+ class EngineGateway:
143
+ def __init__(self, config: ConsoleConfig, event_log: EventLog) -> None:
144
+ self._config = config
145
+ self._events = event_log
146
+ # models="stub" forces the deterministic offline backends; models="auto"
147
+ # opportunistically upgrades to the real embedder/reranker (and GLiNER2
148
+ # extractor) when the optional extras are importable — see _build_memory.
149
+ self._memory = _build_memory(config)
150
+ self._locks: dict[str, asyncio.Lock] = {}
151
+ # A single dedicated worker thread owns every engine call. The engine's
152
+ # per-namespace SqliteStore connections are opened with the sqlite3
153
+ # default check_same_thread=True, so the thread that first touches a
154
+ # namespace (via add/search) must be the same thread that later closes
155
+ # it. asyncio.to_thread's shared pool rotates workers and never lands on
156
+ # the main thread, so store connections opened on a pool worker cannot be
157
+ # closed from a synchronous main-thread close() — pinning to one worker
158
+ # keeps create/use/close thread-consistent.
159
+ self._pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="lm-engine")
160
+
161
+ async def _run(self, fn: Callable[[], T]) -> T:
162
+ loop = asyncio.get_running_loop()
163
+ return await loop.run_in_executor(self._pool, fn)
164
+
165
+ def _lock(self, namespace: str) -> asyncio.Lock:
166
+ if namespace not in self._locks:
167
+ self._locks[namespace] = asyncio.Lock()
168
+ return self._locks[namespace]
169
+
170
+ def _detect_superseded(self, namespace: str, fact_ids: list) -> list:
171
+ """SELECT id FROM fact WHERE superseded_by IN (<my returned ids>).
172
+
173
+ Read-only connection to the namespace DB; scoped to my add's ids so a
174
+ concurrent writer's supersessions cannot leak in (§5). Never raises —
175
+ a read failure degrades supersession reporting to empty, not an error.
176
+ """
177
+ if not fact_ids:
178
+ return []
179
+ path = ns_db_path(self._config.data_root, namespace)
180
+ try:
181
+ con = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
182
+ except sqlite3.OperationalError:
183
+ return []
184
+ try:
185
+ con.row_factory = sqlite3.Row
186
+ placeholders = ",".join("?" * len(fact_ids))
187
+ rows = con.execute(
188
+ f"SELECT id FROM fact WHERE superseded_by IN ({placeholders})",
189
+ list(fact_ids),
190
+ ).fetchall()
191
+ return [r["id"] for r in rows]
192
+ except sqlite3.Error:
193
+ return []
194
+ finally:
195
+ con.close()
196
+
197
+ async def add(
198
+ self,
199
+ namespace: str,
200
+ text: str,
201
+ source: str = "user",
202
+ t_ref: int | None = None,
203
+ ) -> AddResult:
204
+ if is_reserved_namespace(namespace):
205
+ raise ValueError(f"reserved namespace rejected: {namespace!r}")
206
+ start = time.perf_counter()
207
+ async with self._lock(namespace):
208
+ fact_ids = await self._run(
209
+ lambda: retry_busy(
210
+ lambda: self._memory.add(
211
+ namespace, text, t_ref=t_ref, source=source
212
+ )
213
+ )
214
+ )
215
+ superseded = self._detect_superseded(namespace, fact_ids)
216
+ duration_ms = (time.perf_counter() - start) * 1000.0
217
+ result = AddResult(
218
+ fact_ids=list(fact_ids),
219
+ superseded_fact_ids=superseded,
220
+ superseded_count=len(superseded),
221
+ duration_ms=duration_ms,
222
+ )
223
+ self._events.record(
224
+ namespace,
225
+ "add",
226
+ duration_ms,
227
+ {
228
+ "episode_text_chars": len(text),
229
+ "source": source,
230
+ "t_ref": t_ref,
231
+ "fact_ids": result.fact_ids,
232
+ "fact_count": len(result.fact_ids),
233
+ "superseded_fact_ids": result.superseded_fact_ids,
234
+ "superseded_count": result.superseded_count,
235
+ },
236
+ )
237
+ return result
238
+
239
+ async def search(
240
+ self,
241
+ namespace: str,
242
+ query: str,
243
+ k: int = 5,
244
+ latest_only: bool = True,
245
+ origin: str = "agent",
246
+ ) -> SearchResult:
247
+ start = time.perf_counter()
248
+ retrieved = await self._run(
249
+ lambda: retry_busy(
250
+ lambda: self._memory.search(
251
+ namespace, query, k=k, is_latest_only=latest_only
252
+ )
253
+ )
254
+ )
255
+ duration_ms = (time.perf_counter() - start) * 1000.0
256
+ hits = [
257
+ {
258
+ "fact_id": rf.fact.id,
259
+ "fact_text": rf.fact.fact_text,
260
+ "final_score": rf.final_score,
261
+ "relevance": rf.relevance,
262
+ "recency": rf.recency,
263
+ "importance": rf.importance,
264
+ "dense_rank": rf.dense_rank,
265
+ "sparse_rank": rf.sparse_rank,
266
+ "rrf_score": rf.rrf_score,
267
+ }
268
+ for rf in retrieved
269
+ ]
270
+ self._events.record(
271
+ namespace,
272
+ "search",
273
+ duration_ms,
274
+ {
275
+ "query": query,
276
+ "k": k,
277
+ "latest_only": latest_only,
278
+ "origin": origin,
279
+ "hits": hits,
280
+ },
281
+ )
282
+ return SearchResult(hits=hits, duration_ms=duration_ms)
283
+
284
+ # ── sleep-time maintenance (design spec §8, §6.3) ──────────────────────────
285
+ # Four public methods mirroring add/search EXACTLY: retry_busy inside the single
286
+ # worker thread, under the per-namespace asyncio lock. Both console MCP surfaces
287
+ # (observe_mcp.py stdio + routes/mcp.py HTTP) reach the engine ONLY through these.
288
+ async def maintain(
289
+ self, namespace: str, *, apply: bool = False
290
+ ) -> dict:
291
+ """Run one maintenance pass on `namespace` (§6.3). DRY-RUN by default.
292
+
293
+ Lock consequence (§8): a console-invoked maintain() holds this namespace's
294
+ gateway lock for the whole run, so it blocks live add/search on that namespace
295
+ until the run returns. The underlying MaintenanceRunner works in SHORT per-batch
296
+ commits (it releases the SQLite write lock between batches), so this is
297
+ tolerable without any chunking logic here — we keep the wrapper simple and let
298
+ the runner's own batch cadence bound the stall. Returns the run summary dict.
299
+ """
300
+ if is_reserved_namespace(namespace):
301
+ raise ValueError(f"reserved namespace rejected: {namespace!r}")
302
+ async with self._lock(namespace):
303
+ report = await self._run(
304
+ lambda: retry_busy(
305
+ lambda: self._memory.maintain(
306
+ namespace, apply=apply, trigger="console"
307
+ )
308
+ )
309
+ )
310
+ return _maintenance_report_to_dict(namespace, report, apply=apply)
311
+
312
+ async def maintenance_status(self, namespace: str) -> dict:
313
+ """The namespace's maintenance ledger — runs + pending proposals (§6.3).
314
+
315
+ A pure ledger read via the model-free ``mcp_support.read_status`` against this
316
+ gateway's data root, shaped IDENTICALLY to the core server's status tool. No
317
+ lock and no worker thread: it opens its own read-only connection and never
318
+ touches the serving store, so it cannot contend with a live add/search."""
319
+ return await self._run(
320
+ lambda: retry_busy(
321
+ lambda: mcp_support.read_status(
322
+ self._config.data_root, namespace
323
+ )
324
+ )
325
+ )
326
+
327
+ async def review_queue(
328
+ self, namespace: str, *, kind: str | None = None, limit: int = 20
329
+ ) -> list:
330
+ """Pending proposals grouped by entity, with evidence (§6.3). Read-shaped but
331
+ routed through the write worker + lock because review_queue lazily EXPIRES
332
+ overdue proposals (a write) — it must not race a concurrent add on the file."""
333
+ async with self._lock(namespace):
334
+ return await self._run(
335
+ lambda: retry_busy(
336
+ lambda: self._memory.review_queue(
337
+ namespace, kind=kind, limit=limit
338
+ )
339
+ )
340
+ )
341
+
342
+ async def decide(
343
+ self,
344
+ namespace: str,
345
+ proposal_id: str,
346
+ decision: str,
347
+ *,
348
+ edited_text: str | None = None,
349
+ ) -> dict:
350
+ """Decide a proposal: approve | reject | edit | promote (§6.3). Applies on
351
+ approve through the lifecycle, all under the per-namespace lock + retry_busy."""
352
+ async with self._lock(namespace):
353
+ return await self._run(
354
+ lambda: retry_busy(
355
+ lambda: self._memory.decide(
356
+ proposal_id, decision, namespace=namespace,
357
+ edited_text=edited_text, decided_by="console",
358
+ )
359
+ )
360
+ )
361
+
362
+ async def promote(self, namespace: str, fact_id: str) -> dict:
363
+ """Explicitly promote a fact back to the hot tier (§4.4). Explicit-only —
364
+ there is no automatic promotion anywhere. Lock + retry_busy like the rest."""
365
+ async with self._lock(namespace):
366
+ return await self._run(
367
+ lambda: retry_busy(
368
+ lambda: self._memory.promote(fact_id, namespace=namespace)
369
+ )
370
+ )
371
+
372
+ def close(self) -> None:
373
+ # Close the engine on the same dedicated worker that opened its store
374
+ # connections (sqlite3 forbids cross-thread use), then retire the pool.
375
+ # Synchronous by contract — callers close without an event loop.
376
+ self._pool.submit(self._memory.close).result()
377
+ self._pool.shutdown(wait=True)
@@ -0,0 +1,133 @@
1
+ """The _events.db sidecar (spec §5): schema, event recording, atomic retention,
2
+ and read helpers. Console-owned (the engine never touches this file).
3
+
4
+ All connections set busy_timeout=5000 because the engine sets none (spec §6).
5
+ record() NEVER raises — recording an event must not mask the operation's own
6
+ result; on any failure it degrades to a logged warning and returns None.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import sqlite3
14
+ import threading
15
+ import time
16
+ from pathlib import Path
17
+
18
+ CAP = 10_000
19
+
20
+ _log = logging.getLogger("lean_memory_console.events")
21
+
22
+ _SCHEMA = """
23
+ CREATE TABLE IF NOT EXISTS event (
24
+ id INTEGER PRIMARY KEY,
25
+ namespace TEXT,
26
+ ts INTEGER,
27
+ kind TEXT CHECK(kind IN ('add','search')),
28
+ duration_ms REAL,
29
+ payload TEXT
30
+ );
31
+ CREATE INDEX IF NOT EXISTS ix_event_ns_ts ON event(namespace, ts);
32
+ """
33
+
34
+ _PRUNE_SQL = (
35
+ "DELETE FROM event WHERE namespace=? AND id NOT IN ("
36
+ "SELECT id FROM event WHERE namespace=? ORDER BY ts DESC, id DESC LIMIT 10000)"
37
+ )
38
+
39
+
40
+ class EventLog:
41
+ def __init__(self, data_root: Path) -> None:
42
+ self.path = Path(data_root) / "_events.db"
43
+ self._lock = threading.Lock()
44
+ self._db = sqlite3.connect(str(self.path), check_same_thread=False)
45
+ self._db.row_factory = sqlite3.Row
46
+ self._db.execute("PRAGMA journal_mode=WAL")
47
+ self._db.execute("PRAGMA busy_timeout=5000")
48
+ self._db.execute("PRAGMA foreign_keys=ON")
49
+ self._db.executescript(_SCHEMA)
50
+ self._db.commit()
51
+
52
+ def record(self, namespace: str, kind: str, duration_ms: float, payload: dict) -> None:
53
+ """Insert one event, then prune if this namespace is over CAP. Never raises."""
54
+ try:
55
+ ts = int(time.time() * 1000)
56
+ blob = json.dumps(payload)
57
+ with self._lock:
58
+ self._db.execute(
59
+ "INSERT INTO event(namespace, ts, kind, duration_ms, payload) "
60
+ "VALUES (?,?,?,?,?)",
61
+ (namespace, ts, kind, duration_ms, blob),
62
+ )
63
+ count = self._db.execute(
64
+ "SELECT COUNT(*) FROM event WHERE namespace=?", (namespace,)
65
+ ).fetchone()[0]
66
+ if count > CAP:
67
+ self._db.execute(_PRUNE_SQL, (namespace, namespace))
68
+ self._db.commit()
69
+ except Exception as exc: # noqa: BLE001 — must never mask the caller's result
70
+ _log.warning("event record failed (%s): %s", kind, exc)
71
+ return None
72
+ return None
73
+
74
+ def list_events(
75
+ self, namespace: str, kind: str | None = None, page: int = 1, page_size: int = 50
76
+ ) -> dict:
77
+ page = max(page, 1)
78
+ page_size = min(max(page_size, 1), 200)
79
+ where = "WHERE namespace=?"
80
+ args: list = [namespace]
81
+ if kind is not None:
82
+ where += " AND kind=?"
83
+ args.append(kind)
84
+ with self._lock:
85
+ total = self._db.execute(
86
+ f"SELECT COUNT(*) FROM event {where}", args
87
+ ).fetchone()[0]
88
+ rows = self._db.execute(
89
+ f"SELECT id, namespace, ts, kind, duration_ms, payload FROM event "
90
+ f"{where} ORDER BY ts DESC, id DESC LIMIT ? OFFSET ?",
91
+ (*args, page_size, (page - 1) * page_size),
92
+ ).fetchall()
93
+ items = []
94
+ for r in rows:
95
+ items.append(
96
+ {
97
+ "id": r["id"],
98
+ "namespace": r["namespace"],
99
+ "ts": r["ts"],
100
+ "kind": r["kind"],
101
+ "duration_ms": r["duration_ms"],
102
+ "payload": json.loads(r["payload"]) if r["payload"] else {},
103
+ }
104
+ )
105
+ return {"items": items, "page": page, "page_size": page_size, "total": total}
106
+
107
+ def activity_summary(self, namespace: str, days: int = 7) -> dict:
108
+ """Adds/searches in the window, EXCLUDING payload origin == 'ui' (spec §7).
109
+ The window bound is applied via ts; earliest_ts is over all stored events."""
110
+ since = int(time.time() * 1000) - days * 86_400_000
111
+ with self._lock:
112
+ rows = self._db.execute(
113
+ "SELECT kind, payload FROM event WHERE namespace=? AND ts >= ?",
114
+ (namespace, since),
115
+ ).fetchall()
116
+ earliest = self._db.execute(
117
+ "SELECT MIN(ts) FROM event WHERE namespace=?", (namespace,)
118
+ ).fetchone()[0]
119
+ adds = 0
120
+ searches = 0
121
+ for r in rows:
122
+ payload = json.loads(r["payload"]) if r["payload"] else {}
123
+ if payload.get("origin") == "ui":
124
+ continue
125
+ if r["kind"] == "add":
126
+ adds += 1
127
+ elif r["kind"] == "search":
128
+ searches += 1
129
+ return {"adds": adds, "searches": searches, "earliest_ts": earliest}
130
+
131
+ def close(self) -> None:
132
+ with self._lock:
133
+ self._db.close()