superlocalmemory 4.0.4 → 4.0.6

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +23 -14
  3. package/ide/configs/codex-mcp.toml +2 -2
  4. package/package.json +3 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.mcp.json +1 -0
  7. package/plugin/CLAUDE.md +3 -3
  8. package/plugin/agents/slm-governance-advisor.md +1 -1
  9. package/plugin/agents/slm-loop-runner.md +1 -1
  10. package/plugin/agents/slm-memory-advisor.md +1 -1
  11. package/plugin/agents/slm-optimize-advisor.md +1 -1
  12. package/plugin/requirements.txt +1 -1
  13. package/plugin/skills/slm-cache/SKILL.md +1 -1
  14. package/plugin/skills/slm-compress/SKILL.md +1 -1
  15. package/plugin/skills/slm-governance/SKILL.md +1 -1
  16. package/plugin/skills/slm-graph/SKILL.md +3 -2
  17. package/plugin/skills/slm-loop/SKILL.md +1 -1
  18. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  19. package/plugin/skills/slm-profile/SKILL.md +2 -1
  20. package/plugin/skills/slm-recall/SKILL.md +1 -1
  21. package/plugin/skills/slm-remember/SKILL.md +1 -1
  22. package/plugin/skills/slm-scope/SKILL.md +1 -1
  23. package/plugin/skills/slm-session/SKILL.md +1 -1
  24. package/plugin/skills/slm-status/SKILL.md +1 -1
  25. package/plugin-src/rules/AGENTS.md +6 -5
  26. package/plugin-src/skills/slm-graph/SKILL.md +2 -1
  27. package/plugin-src/skills/slm-profile/SKILL.md +1 -0
  28. package/pyproject.toml +1 -1
  29. package/src/superlocalmemory/__init__.py +1 -1
  30. package/src/superlocalmemory/access/rbac.py +106 -0
  31. package/src/superlocalmemory/brain/__init__.py +5 -0
  32. package/src/superlocalmemory/brain/truth.py +418 -0
  33. package/src/superlocalmemory/cli/__main__.py +17 -0
  34. package/src/superlocalmemory/cli/commands.py +96 -28
  35. package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
  36. package/src/superlocalmemory/cli/gdpr_io.py +109 -0
  37. package/src/superlocalmemory/cli/main.py +88 -0
  38. package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
  39. package/src/superlocalmemory/code_graph/graph_store.py +180 -3
  40. package/src/superlocalmemory/code_graph/parser.py +280 -100
  41. package/src/superlocalmemory/compliance/gdpr.py +358 -0
  42. package/src/superlocalmemory/core/config.py +44 -1
  43. package/src/superlocalmemory/core/context_cache.py +58 -1
  44. package/src/superlocalmemory/core/engine_wiring.py +5 -1
  45. package/src/superlocalmemory/core/maintenance.py +43 -1
  46. package/src/superlocalmemory/core/mutations.py +155 -25
  47. package/src/superlocalmemory/core/recall_pipeline.py +6 -10
  48. package/src/superlocalmemory/core/recall_worker.py +33 -12
  49. package/src/superlocalmemory/core/remember_runtime.py +271 -2
  50. package/src/superlocalmemory/core/store_pipeline.py +100 -38
  51. package/src/superlocalmemory/encoding/consolidator.py +17 -47
  52. package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
  53. package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
  54. package/src/superlocalmemory/infra/backup.py +138 -0
  55. package/src/superlocalmemory/infra/backup_obligations.py +423 -0
  56. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +4 -3
  57. package/src/superlocalmemory/learning/engagement.py +165 -0
  58. package/src/superlocalmemory/mcp/profiles.py +19 -7
  59. package/src/superlocalmemory/mcp/server.py +4 -2
  60. package/src/superlocalmemory/mcp/tools_brain.py +54 -10
  61. package/src/superlocalmemory/mcp/tools_code_graph.py +31 -4
  62. package/src/superlocalmemory/mcp/tools_core.py +88 -3
  63. package/src/superlocalmemory/mcp/tools_v3.py +20 -6
  64. package/src/superlocalmemory/retrieval/engine.py +28 -10
  65. package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
  66. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
  67. package/src/superlocalmemory/server/routes/brain.py +297 -14
  68. package/src/superlocalmemory/server/routes/learning.py +13 -25
  69. package/src/superlocalmemory/server/routes/memories.py +129 -3
  70. package/src/superlocalmemory/server/routes/v3_api.py +171 -60
  71. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  72. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  73. package/src/superlocalmemory/storage/correction_cases.py +670 -0
  74. package/src/superlocalmemory/storage/database.py +230 -24
  75. package/src/superlocalmemory/storage/migration_runner.py +7 -0
  76. package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
  77. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
  78. package/src/superlocalmemory/storage/models.py +12 -4
  79. package/src/superlocalmemory/storage/write_coordinator.py +4 -0
  80. package/src/superlocalmemory/summaries/__init__.py +37 -0
  81. package/src/superlocalmemory/summaries/base.py +108 -0
  82. package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
  83. package/src/superlocalmemory/summaries/project_work_log.py +424 -0
  84. package/src/superlocalmemory/summaries/session_summary.py +307 -0
  85. package/src/superlocalmemory/ui/css/design-system.css +76 -1
  86. package/src/superlocalmemory/ui/index.html +28 -11
  87. package/src/superlocalmemory/ui/js/brain.js +43 -7
  88. package/src/superlocalmemory/ui/js/od-agents.js +49 -5
  89. package/src/superlocalmemory/ui/js/od-brain.js +280 -84
  90. package/src/superlocalmemory/ui/js/od-graph.js +147 -6
@@ -0,0 +1,423 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V4 | https://qualixar.com
4
+
5
+ """Backup erasure obligation ledger — GDPR Art.17 backup residue tracking.
6
+
7
+ After Art.17 erasure the live stores are clean, but rotating backup snapshots
8
+ still hold the erased personal data. This module tracks ``outstanding
9
+ obligations`` — one per (profile_id, snapshot_path) pair — so that:
10
+
11
+ 1. The erasure receipt accurately declares completeness as FALSE while any
12
+ snapshot still contains the data (C1 gap closure).
13
+
14
+ 2. A restore that would resurrect erased data is intercepted and the profile
15
+ data re-erased from the restored store before any caller can read it
16
+ (restore-replay invariant).
17
+
18
+ 3. Obligations age out automatically when a snapshot exceeds the configured
19
+ retention window, keeping the ledger finite.
20
+
21
+ IMPORTANT: backup_obligations.db is intentionally NOT in MANAGED_DATABASES
22
+ and is therefore never backed up itself. This prevents a restore from loading
23
+ stale obligation state.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ import sqlite3
30
+ import time
31
+ import uuid
32
+ from pathlib import Path
33
+
34
+ logger = logging.getLogger("superlocalmemory.infra.backup_obligations")
35
+
36
+ _OBLIGATION_DB_NAME = "backup_obligations.db"
37
+
38
+ _DDL = """
39
+ CREATE TABLE IF NOT EXISTS backup_obligations (
40
+ obligation_id TEXT PRIMARY KEY,
41
+ profile_id TEXT NOT NULL,
42
+ erasure_id TEXT NOT NULL,
43
+ snapshot_path TEXT NOT NULL,
44
+ snapshot_epoch INTEGER NOT NULL,
45
+ retention_days INTEGER NOT NULL DEFAULT 90,
46
+ recorded_at REAL NOT NULL,
47
+ status TEXT NOT NULL DEFAULT 'pending',
48
+ discharged_at REAL,
49
+ discharge_reason TEXT
50
+ );
51
+ CREATE INDEX IF NOT EXISTS idx_bko_profile
52
+ ON backup_obligations(profile_id, status);
53
+ CREATE INDEX IF NOT EXISTS idx_bko_snapshot
54
+ ON backup_obligations(snapshot_path, status);
55
+ CREATE INDEX IF NOT EXISTS idx_bko_erasure
56
+ ON backup_obligations(erasure_id);
57
+ """
58
+
59
+
60
+ class BackupObligationStore:
61
+ """Persistent ledger of outstanding erasure obligations against snapshots.
62
+
63
+ An obligation is created for each backup snapshot that was found to contain
64
+ data for a profile that has since been Art.17-erased from the live stores.
65
+ The obligation is discharged when:
66
+ * The snapshot ages past the configured retention window (auto-discharge).
67
+ * The snapshot is restored and the profile data is re-erased during
68
+ restore-replay (explicit discharge).
69
+
70
+ The store lives at ``<data_root>/backup_obligations.db``. It is a plain
71
+ SQLite file that is NOT in MANAGED_DATABASES and therefore never appears
72
+ inside a backup set.
73
+ """
74
+
75
+ def __init__(self, data_root: Path) -> None:
76
+ self._db_path = Path(data_root) / _OBLIGATION_DB_NAME
77
+ self._ensure_schema()
78
+
79
+ # ------------------------------------------------------------------
80
+ # Schema
81
+ # ------------------------------------------------------------------
82
+
83
+ def _connect(self) -> sqlite3.Connection:
84
+ conn = sqlite3.connect(str(self._db_path))
85
+ conn.row_factory = sqlite3.Row
86
+ conn.execute("PRAGMA journal_mode=WAL")
87
+ conn.execute("PRAGMA foreign_keys=ON")
88
+ conn.execute("PRAGMA synchronous=NORMAL")
89
+ return conn
90
+
91
+ def _ensure_schema(self) -> None:
92
+ try:
93
+ with self._connect() as conn:
94
+ for stmt in _DDL.strip().split(";"):
95
+ stmt = stmt.strip()
96
+ if stmt:
97
+ conn.execute(stmt)
98
+ conn.commit()
99
+ except Exception as exc: # noqa: BLE001
100
+ logger.warning("BackupObligationStore: schema init failed: %s", exc)
101
+
102
+ # ------------------------------------------------------------------
103
+ # Write path
104
+ # ------------------------------------------------------------------
105
+
106
+ def record(
107
+ self,
108
+ profile_id: str,
109
+ erasure_id: str,
110
+ snapshot_path: str,
111
+ snapshot_epoch: int,
112
+ retention_days: int = 90,
113
+ ) -> str:
114
+ """Record one outstanding obligation. Returns the obligation_id."""
115
+ oid = uuid.uuid4().hex
116
+ try:
117
+ with self._connect() as conn:
118
+ conn.execute(
119
+ "INSERT OR IGNORE INTO backup_obligations "
120
+ "(obligation_id, profile_id, erasure_id, snapshot_path, "
121
+ " snapshot_epoch, retention_days, recorded_at, status) "
122
+ "VALUES (?, ?, ?, ?, ?, ?, ?, 'pending')",
123
+ (
124
+ oid, profile_id, erasure_id, str(snapshot_path),
125
+ int(snapshot_epoch), int(retention_days), time.time(),
126
+ ),
127
+ )
128
+ conn.commit()
129
+ except Exception as exc: # noqa: BLE001
130
+ logger.warning("BackupObligationStore.record failed: %s", exc)
131
+ return oid
132
+
133
+ def discharge(self, obligation_id: str, reason: str) -> None:
134
+ """Mark a single obligation as discharged."""
135
+ try:
136
+ with self._connect() as conn:
137
+ conn.execute(
138
+ "UPDATE backup_obligations "
139
+ "SET status='discharged', discharged_at=?, discharge_reason=? "
140
+ "WHERE obligation_id=?",
141
+ (time.time(), reason, obligation_id),
142
+ )
143
+ conn.commit()
144
+ except Exception as exc: # noqa: BLE001
145
+ logger.warning("BackupObligationStore.discharge failed: %s", exc)
146
+
147
+ def discharge_for_snapshot(self, snapshot_path: str, reason: str) -> int:
148
+ """Discharge ALL pending obligations for a snapshot path. Returns count."""
149
+ try:
150
+ with self._connect() as conn:
151
+ cur = conn.execute(
152
+ "UPDATE backup_obligations "
153
+ "SET status='discharged', discharged_at=?, discharge_reason=? "
154
+ "WHERE snapshot_path=? AND status='pending'",
155
+ (time.time(), reason, str(snapshot_path)),
156
+ )
157
+ conn.commit()
158
+ return cur.rowcount
159
+ except Exception as exc: # noqa: BLE001
160
+ logger.warning("BackupObligationStore.discharge_for_snapshot: %s", exc)
161
+ return 0
162
+
163
+ # ------------------------------------------------------------------
164
+ # Read path
165
+ # ------------------------------------------------------------------
166
+
167
+ def _discharge_aged_out(self) -> int:
168
+ """Auto-discharge obligations whose snapshot has aged past retention."""
169
+ try:
170
+ with self._connect() as conn:
171
+ now = time.time()
172
+ cur = conn.execute(
173
+ "UPDATE backup_obligations "
174
+ "SET status='discharged', discharged_at=?, "
175
+ " discharge_reason='snapshot_aged_out' "
176
+ "WHERE status='pending' "
177
+ " AND (snapshot_epoch + retention_days * 86400) < ?",
178
+ (now, now),
179
+ )
180
+ conn.commit()
181
+ return cur.rowcount
182
+ except Exception as exc: # noqa: BLE001
183
+ logger.warning("BackupObligationStore._discharge_aged_out: %s", exc)
184
+ return 0
185
+
186
+ def count_pending(self, profile_id: str) -> int:
187
+ """Count pending obligations for *profile_id* (auto-discharges aged-out first).
188
+
189
+ Fail-closed: if the store is unreadable, returns 1 to block completeness.
190
+ """
191
+ self._discharge_aged_out()
192
+ try:
193
+ with self._connect() as conn:
194
+ row = conn.execute(
195
+ "SELECT COUNT(*) FROM backup_obligations "
196
+ "WHERE profile_id=? AND status='pending'",
197
+ (profile_id,),
198
+ ).fetchone()
199
+ return int(row[0]) if row else 0
200
+ except Exception as exc: # noqa: BLE001
201
+ logger.warning(
202
+ "BackupObligationStore.count_pending failed: %s — "
203
+ "returning 1 to block completeness claim",
204
+ exc,
205
+ )
206
+ return 1 # fail-closed
207
+
208
+ def list_pending_for_snapshot(self, snapshot_path: str) -> list[dict]:
209
+ """Return all pending obligations for *snapshot_path*."""
210
+ try:
211
+ with self._connect() as conn:
212
+ rows = conn.execute(
213
+ "SELECT * FROM backup_obligations "
214
+ "WHERE snapshot_path=? AND status='pending'",
215
+ (str(snapshot_path),),
216
+ ).fetchall()
217
+ return [dict(r) for r in rows]
218
+ except Exception as exc: # noqa: BLE001
219
+ logger.warning("BackupObligationStore.list_pending_for_snapshot: %s", exc)
220
+ return []
221
+
222
+ def list_pending_for_profile(self, profile_id: str) -> list[dict]:
223
+ """Return all pending obligations for *profile_id* (auto-discharges first)."""
224
+ self._discharge_aged_out()
225
+ try:
226
+ with self._connect() as conn:
227
+ rows = conn.execute(
228
+ "SELECT * FROM backup_obligations "
229
+ "WHERE profile_id=? AND status='pending'",
230
+ (profile_id,),
231
+ ).fetchall()
232
+ return [dict(r) for r in rows]
233
+ except Exception as exc: # noqa: BLE001
234
+ logger.warning("BackupObligationStore.list_pending_for_profile: %s", exc)
235
+ return []
236
+
237
+
238
+ # ---------------------------------------------------------------------------
239
+ # Snapshot helpers (used by gdpr.py and backup.py)
240
+ # ---------------------------------------------------------------------------
241
+
242
+
243
+ def scan_backup_snapshots_for_profile(
244
+ backup_dir: Path,
245
+ profile_id: str,
246
+ ) -> list[tuple[str, int]]:
247
+ """Scan *backup_dir* for snapshots containing *profile_id*'s data.
248
+
249
+ Returns a list of (snapshot_path_str, epoch) tuples. Handles both legacy
250
+ per-file backups (``*.db`` files) and new-style BackupCoordinator sets
251
+ (``backup_XXXX/`` directories containing a ``manifest.json``).
252
+ """
253
+ backup_dir = Path(backup_dir)
254
+ results: list[tuple[str, int]] = []
255
+
256
+ if not backup_dir.exists():
257
+ return results
258
+
259
+ # New-style backup sets: backup_XXXX/ directories with manifest.json.
260
+ # Record obligation at the set-directory level (one obligation per set).
261
+ for candidate in sorted(backup_dir.iterdir()):
262
+ if not candidate.is_dir() or not (candidate / "manifest.json").exists():
263
+ continue
264
+ hit = False
265
+ for db_file in candidate.glob("*.db"):
266
+ if _snapshot_db_contains_profile(db_file, profile_id):
267
+ hit = True
268
+ break
269
+ if hit:
270
+ epoch = int(candidate.stat().st_mtime)
271
+ results.append((str(candidate), epoch))
272
+
273
+ # Legacy per-file backups: ``memory-YYYYMMDD-HHMMSS.db``, etc.
274
+ for db_file in sorted(backup_dir.glob("*.db")):
275
+ if db_file.name.startswith("."):
276
+ continue
277
+ if _snapshot_db_contains_profile(db_file, profile_id):
278
+ epoch = int(db_file.stat().st_mtime)
279
+ results.append((str(db_file), epoch))
280
+
281
+ return results
282
+
283
+
284
+ def _snapshot_db_contains_profile(db_path: Path, profile_id: str) -> bool:
285
+ """Return True if the SQLite file at *db_path* contains rows for *profile_id*.
286
+
287
+ Opens read-only (immutable) to avoid side-effects. Returns False on any
288
+ error so an unreadable snapshot does not block erasure; caller logs the
289
+ warning separately.
290
+ """
291
+ try:
292
+ uri = f"file:{db_path}?mode=ro"
293
+ conn = sqlite3.connect(uri, uri=True, timeout=5)
294
+ try:
295
+ tables = {
296
+ row[0]
297
+ for row in conn.execute(
298
+ "SELECT name FROM sqlite_master WHERE type='table'"
299
+ ).fetchall()
300
+ }
301
+ for table in ("profiles", "atomic_facts", "memories", "graph_nodes"):
302
+ if table not in tables:
303
+ continue
304
+ cols = {
305
+ row[1]
306
+ for row in conn.execute(f"PRAGMA table_info({table})").fetchall()
307
+ }
308
+ if "profile_id" not in cols:
309
+ # graph_nodes has no profile_id — flag unconditionally as personal
310
+ if table == "graph_nodes":
311
+ row = conn.execute(
312
+ "SELECT 1 FROM graph_nodes LIMIT 1"
313
+ ).fetchone()
314
+ if row is not None:
315
+ return True
316
+ continue
317
+ row = conn.execute(
318
+ f"SELECT 1 FROM {table} WHERE profile_id=? LIMIT 1",
319
+ (profile_id,),
320
+ ).fetchone()
321
+ if row is not None:
322
+ return True
323
+ finally:
324
+ conn.close()
325
+ except Exception as exc: # noqa: BLE001
326
+ logger.warning(
327
+ "_snapshot_db_contains_profile: error reading %s: %s — treating as clean",
328
+ db_path.name, exc,
329
+ )
330
+ return False
331
+
332
+
333
+ def erase_profile_from_snapshot(db_path: Path, profile_id: str) -> dict[str, int]:
334
+ """Delete all *profile_id* rows from a snapshot SQLite file.
335
+
336
+ Used during restore-replay. Raises on any fatal error so the caller can
337
+ refuse to mark the restore complete when personal data cannot be purged.
338
+ """
339
+ deleted: dict[str, int] = {}
340
+ # isolation_level=None → autocommit so VACUUM can run outside any transaction.
341
+ conn = sqlite3.connect(str(db_path))
342
+ conn.isolation_level = None
343
+ try:
344
+ conn.execute("PRAGMA foreign_keys=OFF")
345
+ tables = [
346
+ row[0]
347
+ for row in conn.execute(
348
+ "SELECT name FROM sqlite_master WHERE type='table'"
349
+ ).fetchall()
350
+ if not row[0].startswith("sqlite_")
351
+ ]
352
+ conn.execute("BEGIN")
353
+ for table in tables:
354
+ cols = {
355
+ row[1]
356
+ for row in conn.execute(f"PRAGMA table_info({table})").fetchall()
357
+ }
358
+ if "profile_id" not in cols:
359
+ continue
360
+ cur = conn.execute(
361
+ f"DELETE FROM {table} WHERE profile_id=?", (profile_id,)
362
+ )
363
+ if cur.rowcount:
364
+ deleted[table] = cur.rowcount
365
+ conn.execute("PRAGMA foreign_keys=ON")
366
+ conn.execute("COMMIT")
367
+ # VACUUM must run in autocommit (no active transaction).
368
+ conn.execute("VACUUM")
369
+ except Exception:
370
+ try:
371
+ conn.execute("ROLLBACK")
372
+ except Exception: # noqa: BLE001
373
+ pass
374
+ conn.close()
375
+ raise
376
+ else:
377
+ conn.close()
378
+ return deleted
379
+
380
+
381
+ def erase_code_graph_from_snapshot(db_path: Path) -> dict[str, int]:
382
+ """Wipe all tables from a code_graph.db snapshot (no profile_id column).
383
+
384
+ The code graph is installation-level personal data (repo paths, symbol
385
+ names). On erasure or restore-replay the entire graph is cleared.
386
+ """
387
+ deleted: dict[str, int] = {}
388
+ if not db_path.exists():
389
+ return deleted
390
+ conn = sqlite3.connect(str(db_path))
391
+ conn.isolation_level = None # autocommit so VACUUM runs outside any transaction
392
+ try:
393
+ conn.execute("PRAGMA foreign_keys=OFF")
394
+ tables = [
395
+ row[0]
396
+ for row in conn.execute(
397
+ "SELECT name FROM sqlite_master WHERE type='table' "
398
+ "AND name NOT LIKE 'sqlite_%'"
399
+ ).fetchall()
400
+ ]
401
+ conn.execute("BEGIN")
402
+ for table in tables:
403
+ # Skip FTS virtual tables — deleting the base table handles them
404
+ if table.endswith(("_fts", "_fts_data", "_fts_idx", "_fts_content",
405
+ "_fts_docsize", "_fts_config")):
406
+ continue
407
+ cur = conn.execute(f"DELETE FROM {table}")
408
+ if cur.rowcount:
409
+ deleted[table] = cur.rowcount
410
+ conn.execute("PRAGMA foreign_keys=ON")
411
+ conn.execute("COMMIT")
412
+ # VACUUM must run in autocommit (no active transaction).
413
+ conn.execute("VACUUM")
414
+ except Exception:
415
+ try:
416
+ conn.execute("ROLLBACK")
417
+ except Exception: # noqa: BLE001
418
+ pass
419
+ conn.close()
420
+ raise
421
+ else:
422
+ conn.close()
423
+ return deleted
@@ -9,7 +9,6 @@ import shutil
9
9
  import stat
10
10
  from collections.abc import Awaitable, Callable
11
11
  from copy import deepcopy
12
- from datetime import timedelta
13
12
  from pathlib import Path
14
13
  from typing import Any
15
14
 
@@ -134,14 +133,16 @@ async def observe_from_stdio(*, command: str, cwd: str, profile_id: str) -> list
134
133
  async with ClientSession(
135
134
  read,
136
135
  write,
137
- read_timeout_seconds=timedelta(seconds=_OBSERVATION_TIMEOUT_SECONDS),
136
+ # MCP 2.x passes this directly to AnyIO's timeout machinery,
137
+ # which accepts a numeric duration rather than timedelta.
138
+ read_timeout_seconds=_OBSERVATION_TIMEOUT_SECONDS,
138
139
  ) as session:
139
140
  async def observe() -> list[dict[str, Any]]:
140
141
  await session.initialize()
141
142
 
142
143
  async def call(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
143
144
  result = await session.call_tool(name, arguments)
144
- if result.isError:
145
+ if result.is_error:
145
146
  raise BridgeUnavailable(
146
147
  "bounded-loops rejected the observation request"
147
148
  )
@@ -320,3 +320,168 @@ class EngagementTracker:
320
320
  if raw <= 0:
321
321
  return 0.0
322
322
  return raw / (raw + 20.0)
323
+
324
+
325
+ # ---------------------------------------------------------------------------
326
+ # Derive-on-read engagement — zero hot-path cost (Invariants I1, I3)
327
+ # ---------------------------------------------------------------------------
328
+
329
+ def derive_engagement_from_dbs(
330
+ memory_db_path: "Path | str",
331
+ learning_db_path: "Path | str",
332
+ profile_id: str,
333
+ ) -> "Dict[str, Any]":
334
+ """Derive engagement metrics from tables that already exist — no writes.
335
+
336
+ Source tables
337
+ -------------
338
+ memory.db : atomic_facts
339
+ store_count — live facts (lifecycle in active/warm/cold)
340
+ days_active — distinct calendar days with at least one live fact
341
+ recent_7d — facts created in the last 7 days (drives health)
342
+ learning.db : learning_signals
343
+ recall_count — COUNT(DISTINCT query) for the profile.
344
+ This is a proxy: one distinct query = one recall
345
+ session. If the same query was run N times, it
346
+ counts as 1. The table is populated by the
347
+ post_tool_outcome_hook when Claude Code surfaces
348
+ recall results; it may be empty in environments
349
+ without that hook, in which case recall_count = 0
350
+ while store_count still reflects real activity.
351
+
352
+ Invariant compliance
353
+ --------------------
354
+ I1 — zero writes, no lock acquisition on the recall/remember hot path.
355
+ I3 — no new table or row is ever created; bounded by the existing
356
+ lifecycle-management and retention systems (atomic_facts rows age
357
+ through active → warm → cold → archived via Langevin dynamics;
358
+ learning_signals rows are pruned by the retention sweep).
359
+ I6 — every field carries a named source.
360
+
361
+ health_status (plain language, non-technical)
362
+ ----------------------------------------------
363
+ "ACTIVE" — 10 or more memories added in the last 7 days
364
+ "WARM" — 3–9 memories added in the last 7 days
365
+ "COLD" — 1–2 memories added in the last 7 days
366
+ "INACTIVE" — no new memories in the last 7 days
367
+ """
368
+ memory_db_path = Path(str(memory_db_path))
369
+ learning_db_path = Path(str(learning_db_path))
370
+
371
+ store_count: int = 0
372
+ days_active: int = 0
373
+ recent_7d: int = 0
374
+ recall_count: int = 0
375
+
376
+ # ── memory.db : atomic_facts ──────────────────────────────────────────
377
+ if memory_db_path.exists():
378
+ try:
379
+ # read-only URI; never creates the file, never acquires write lock
380
+ _uri = f"{memory_db_path.resolve().as_uri()}?mode=ro"
381
+ _conn = sqlite3.connect(_uri, uri=True, timeout=1.0)
382
+ _conn.execute("PRAGMA query_only=ON")
383
+ try:
384
+ _tables = {
385
+ r[0]
386
+ for r in _conn.execute(
387
+ "SELECT name FROM sqlite_master WHERE type='table'"
388
+ ).fetchall()
389
+ }
390
+ if "atomic_facts" in _tables:
391
+ # Only live facts (archived = soft-deleted / forgotten).
392
+ # CRIT-fix-3: lifecycle filter avoids counting deleted facts
393
+ # as "stored"; a profile with 1 000 facts all archived
394
+ # should not report store_count=1000 to a non-technical user.
395
+ _r = _conn.execute(
396
+ "SELECT COUNT(*) FROM atomic_facts "
397
+ "WHERE profile_id=? AND lifecycle IN ('active','warm','cold')",
398
+ (profile_id,),
399
+ ).fetchone()
400
+ store_count = _r[0] if _r else 0
401
+
402
+ _r = _conn.execute(
403
+ "SELECT COUNT(DISTINCT SUBSTR(created_at, 1, 10)) "
404
+ "FROM atomic_facts "
405
+ "WHERE profile_id=? AND lifecycle IN ('active','warm','cold')",
406
+ (profile_id,),
407
+ ).fetchone()
408
+ days_active = _r[0] if _r else 0
409
+
410
+ _r = _conn.execute(
411
+ "SELECT COUNT(*) FROM atomic_facts "
412
+ "WHERE profile_id=? "
413
+ "AND lifecycle IN ('active','warm','cold') "
414
+ "AND created_at >= datetime('now', '-7 days')",
415
+ (profile_id,),
416
+ ).fetchone()
417
+ recent_7d = _r[0] if _r else 0
418
+ finally:
419
+ _conn.close()
420
+ except Exception:
421
+ # Any failure (locked, missing, corrupt) → keep zeros;
422
+ # health stays INACTIVE which is the honest fallback.
423
+ pass
424
+
425
+ # ── learning.db : learning_signals (recall proxy) ─────────────────────
426
+ if learning_db_path.exists():
427
+ try:
428
+ _uri = f"{learning_db_path.resolve().as_uri()}?mode=ro"
429
+ _conn = sqlite3.connect(_uri, uri=True, timeout=1.0)
430
+ _conn.execute("PRAGMA query_only=ON")
431
+ try:
432
+ _tables = {
433
+ r[0]
434
+ for r in _conn.execute(
435
+ "SELECT name FROM sqlite_master WHERE type='table'"
436
+ ).fetchall()
437
+ }
438
+ if "learning_signals" in _tables:
439
+ # CRIT-fix-1: label is "distinct queries" not raw row count.
440
+ # One query that surfaces 10 facts writes 10 rows; COUNT(*)
441
+ # would inflate the number by 10×. COUNT(DISTINCT query)
442
+ # approximates "sessions where you asked for something"
443
+ # which is the intent of recall_count for non-technical users.
444
+ _r = _conn.execute(
445
+ "SELECT COUNT(DISTINCT query) FROM learning_signals "
446
+ "WHERE profile_id=?",
447
+ (profile_id,),
448
+ ).fetchone()
449
+ recall_count = _r[0] if _r else 0
450
+ finally:
451
+ _conn.close()
452
+ except Exception:
453
+ pass
454
+
455
+ # ── health derivation (plain language) ───────────────────────────────
456
+ if recent_7d >= _ACTIVE_THRESHOLD:
457
+ health_status = "ACTIVE"
458
+ elif recent_7d >= _WARM_THRESHOLD:
459
+ health_status = "WARM"
460
+ elif recent_7d >= 1:
461
+ health_status = "COLD"
462
+ else:
463
+ health_status = "INACTIVE"
464
+
465
+ total_events = store_count # recalls add to proxy separately via recall_count
466
+ memories_per_day = (
467
+ round(store_count / days_active, 1) if days_active > 0 else 0
468
+ )
469
+ raw = (
470
+ 0.4 * recall_count
471
+ + 0.3 * store_count
472
+ + 0.1 * days_active
473
+ )
474
+ score = (raw / (raw + 20.0)) if raw > 0 else 0.0
475
+
476
+ return {
477
+ "health_status": health_status,
478
+ "days_active": days_active,
479
+ "memories_per_day": memories_per_day,
480
+ "total_events": total_events,
481
+ "recall_count": recall_count,
482
+ "store_count": store_count,
483
+ "session_count": 0, # not derivable without dedicated writes
484
+ "engagement_score": round(score, 4),
485
+ # I6 provenance — every figure is traceable to a named source table
486
+ "source": "memory.db:atomic_facts,learning.db:learning_signals",
487
+ }