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.
- package/CHANGELOG.md +90 -0
- package/README.md +23 -14
- package/ide/configs/codex-mcp.toml +2 -2
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -0
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +3 -2
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +2 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +6 -5
- package/plugin-src/skills/slm-graph/SKILL.md +2 -1
- package/plugin-src/skills/slm-profile/SKILL.md +1 -0
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +106 -0
- package/src/superlocalmemory/brain/__init__.py +5 -0
- package/src/superlocalmemory/brain/truth.py +418 -0
- package/src/superlocalmemory/cli/__main__.py +17 -0
- package/src/superlocalmemory/cli/commands.py +96 -28
- package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
- package/src/superlocalmemory/cli/gdpr_io.py +109 -0
- package/src/superlocalmemory/cli/main.py +88 -0
- package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
- package/src/superlocalmemory/code_graph/graph_store.py +180 -3
- package/src/superlocalmemory/code_graph/parser.py +280 -100
- package/src/superlocalmemory/compliance/gdpr.py +358 -0
- package/src/superlocalmemory/core/config.py +44 -1
- package/src/superlocalmemory/core/context_cache.py +58 -1
- package/src/superlocalmemory/core/engine_wiring.py +5 -1
- package/src/superlocalmemory/core/maintenance.py +43 -1
- package/src/superlocalmemory/core/mutations.py +155 -25
- package/src/superlocalmemory/core/recall_pipeline.py +6 -10
- package/src/superlocalmemory/core/recall_worker.py +33 -12
- package/src/superlocalmemory/core/remember_runtime.py +271 -2
- package/src/superlocalmemory/core/store_pipeline.py +100 -38
- package/src/superlocalmemory/encoding/consolidator.py +17 -47
- package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
- package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
- package/src/superlocalmemory/infra/backup.py +138 -0
- package/src/superlocalmemory/infra/backup_obligations.py +423 -0
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +4 -3
- package/src/superlocalmemory/learning/engagement.py +165 -0
- package/src/superlocalmemory/mcp/profiles.py +19 -7
- package/src/superlocalmemory/mcp/server.py +4 -2
- package/src/superlocalmemory/mcp/tools_brain.py +54 -10
- package/src/superlocalmemory/mcp/tools_code_graph.py +31 -4
- package/src/superlocalmemory/mcp/tools_core.py +88 -3
- package/src/superlocalmemory/mcp/tools_v3.py +20 -6
- package/src/superlocalmemory/retrieval/engine.py +28 -10
- package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
- package/src/superlocalmemory/server/routes/brain.py +297 -14
- package/src/superlocalmemory/server/routes/learning.py +13 -25
- package/src/superlocalmemory/server/routes/memories.py +129 -3
- package/src/superlocalmemory/server/routes/v3_api.py +171 -60
- package/src/superlocalmemory/storage/_migration_internals.py +4 -0
- package/src/superlocalmemory/storage/_schema_version.py +2 -2
- package/src/superlocalmemory/storage/correction_cases.py +670 -0
- package/src/superlocalmemory/storage/database.py +230 -24
- package/src/superlocalmemory/storage/migration_runner.py +7 -0
- package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
- package/src/superlocalmemory/storage/models.py +12 -4
- package/src/superlocalmemory/storage/write_coordinator.py +4 -0
- package/src/superlocalmemory/summaries/__init__.py +37 -0
- package/src/superlocalmemory/summaries/base.py +108 -0
- package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
- package/src/superlocalmemory/summaries/project_work_log.py +424 -0
- package/src/superlocalmemory/summaries/session_summary.py +307 -0
- package/src/superlocalmemory/ui/css/design-system.css +76 -1
- package/src/superlocalmemory/ui/index.html +28 -11
- package/src/superlocalmemory/ui/js/brain.js +43 -7
- package/src/superlocalmemory/ui/js/od-agents.js +49 -5
- package/src/superlocalmemory/ui/js/od-brain.js +280 -84
- 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
|
-
|
|
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.
|
|
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
|
+
}
|