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.
- lean_memory_console/__init__.py +3 -0
- lean_memory_console/app.py +142 -0
- lean_memory_console/cli.py +134 -0
- lean_memory_console/config.py +121 -0
- lean_memory_console/deploy/__init__.py +0 -0
- lean_memory_console/deploy/docker-compose.yml +36 -0
- lean_memory_console/engine.py +377 -0
- lean_memory_console/events.py +133 -0
- lean_memory_console/inspect_sql.py +321 -0
- lean_memory_console/mcp_tools.py +137 -0
- lean_memory_console/observe_mcp.py +73 -0
- lean_memory_console/routes/__init__.py +0 -0
- lean_memory_console/routes/data.py +53 -0
- lean_memory_console/routes/mcp.py +186 -0
- lean_memory_console/routes/review.py +115 -0
- lean_memory_console/routes/views.py +153 -0
- lean_memory_console-0.2.0.dist-info/METADATA +14 -0
- lean_memory_console-0.2.0.dist-info/RECORD +20 -0
- lean_memory_console-0.2.0.dist-info/WHEEL +4 -0
- lean_memory_console-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -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()
|