superlocalmemory 4.0.5 → 4.0.7

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 (71) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +8 -9
  3. package/package.json +3 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/pyproject.toml +1 -1
  25. package/src/superlocalmemory/__init__.py +1 -1
  26. package/src/superlocalmemory/access/rbac.py +106 -0
  27. package/src/superlocalmemory/brain/truth.py +80 -10
  28. package/src/superlocalmemory/cli/__main__.py +17 -0
  29. package/src/superlocalmemory/cli/commands.py +28 -3
  30. package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
  31. package/src/superlocalmemory/cli/gdpr_io.py +109 -0
  32. package/src/superlocalmemory/cli/main.py +85 -0
  33. package/src/superlocalmemory/cli/summary_cmd.py +195 -0
  34. package/src/superlocalmemory/code_graph/bridge/entity_resolver.py +26 -0
  35. package/src/superlocalmemory/code_graph/bridge/event_listeners.py +14 -3
  36. package/src/superlocalmemory/code_graph/bridge/maintenance.py +206 -0
  37. package/src/superlocalmemory/code_graph/config.py +65 -1
  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/engine_wiring.py +5 -1
  44. package/src/superlocalmemory/core/fact_consolidator.py +24 -1
  45. package/src/superlocalmemory/core/maintenance.py +93 -1
  46. package/src/superlocalmemory/core/recall_worker.py +33 -12
  47. package/src/superlocalmemory/infra/backup.py +138 -0
  48. package/src/superlocalmemory/infra/backup_obligations.py +423 -0
  49. package/src/superlocalmemory/learning/engagement.py +165 -0
  50. package/src/superlocalmemory/mcp/tools_code_graph.py +78 -7
  51. package/src/superlocalmemory/mcp/tools_v3.py +20 -6
  52. package/src/superlocalmemory/retrieval/engine.py +21 -0
  53. package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
  54. package/src/superlocalmemory/server/routes/brain.py +283 -15
  55. package/src/superlocalmemory/server/routes/learning.py +13 -25
  56. package/src/superlocalmemory/server/routes/memories.py +61 -0
  57. package/src/superlocalmemory/server/routes/v3_api.py +171 -60
  58. package/src/superlocalmemory/storage/database.py +36 -0
  59. package/src/superlocalmemory/storage/models.py +12 -4
  60. package/src/superlocalmemory/storage/schema_code_graph.py +44 -1
  61. package/src/superlocalmemory/summaries/__init__.py +37 -0
  62. package/src/superlocalmemory/summaries/base.py +108 -0
  63. package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
  64. package/src/superlocalmemory/summaries/project_work_log.py +424 -0
  65. package/src/superlocalmemory/summaries/session_summary.py +307 -0
  66. package/src/superlocalmemory/ui/css/design-system.css +76 -1
  67. package/src/superlocalmemory/ui/index.html +29 -12
  68. package/src/superlocalmemory/ui/js/fact-detail.js +61 -0
  69. package/src/superlocalmemory/ui/js/od-agents.js +49 -5
  70. package/src/superlocalmemory/ui/js/od-brain.js +257 -77
  71. package/src/superlocalmemory/ui/js/od-graph.js +147 -6
@@ -238,18 +238,39 @@ def _handle_update_memory(
238
238
  content: str,
239
239
  source_agent_id: str = "system",
240
240
  ) -> dict:
241
- """Update a fact after capability-derived authorization."""
242
- engine = _get_engine()
243
- from superlocalmemory.core.engine_ingestion import local_trusted_actor_id
244
- from superlocalmemory.core.mutations import update_fact_authorized
245
-
246
- return update_fact_authorized(
247
- engine,
248
- fact_id,
249
- content,
250
- trusted_actor_id=local_trusted_actor_id("recall-worker"),
251
- source_agent_id=source_agent_id,
252
- )
241
+ """Update a fact after capability-derived authorization.
242
+
243
+ CORRECTIONS CANNOT BE PERFORMED FROM THIS WORKER, AND THAT IS BY DESIGN.
244
+
245
+ Since corrections became a review-gated lifecycle (M042) rather than an
246
+ in-place edit, update_fact_authorized() requires a canonical correction
247
+ writer. That writer is the daemon's single-writer mutation boundary
248
+ (CanonicalRememberRuntime, owned by unified_daemon and handed to the HTTP
249
+ route). A worker subprocess cannot own it: CanonicalRememberRuntime.ready
250
+ requires a live worker of its own, so constructing one here would nest
251
+ worker processes and hand a second writer the ownership context that is
252
+ supposed to be exclusive.
253
+
254
+ Previously this called through anyway, and mutations.py answered
255
+ "canonical correction writer is temporarily unavailable" with
256
+ retryable=True. Nothing about it was temporary — with the daemon down that
257
+ path can NEVER succeed, so a caller could retry forever. This is the
258
+ honest, non-retryable answer with the actual remedy: the daemon-backed
259
+ route (used automatically whenever the daemon is running) does support
260
+ corrections.
261
+ """
262
+ return {
263
+ "ok": False,
264
+ "retryable": False,
265
+ "error": (
266
+ "Corrections are review-gated and require the SuperLocalMemory "
267
+ "daemon, which owns the single correction writer. Start it with "
268
+ "`slm serve start` and retry — updates route through the daemon "
269
+ "automatically when it is running."
270
+ ),
271
+ "remedy": "slm serve start",
272
+ "fact_id": fact_id,
273
+ }
253
274
 
254
275
 
255
276
  def _handle_summarize(texts: list[str], mode: str) -> dict:
@@ -25,6 +25,10 @@ from datetime import datetime, timedelta, timezone
25
25
  from pathlib import Path
26
26
  from typing import Dict, Generator, List, Optional
27
27
 
28
+ from superlocalmemory.infra.backup_obligations import (
29
+ BackupObligationStore,
30
+ erase_profile_from_snapshot,
31
+ )
28
32
  from superlocalmemory.infra.data_root import DynamicStatePath, canonical_data_root
29
33
 
30
34
  logger = logging.getLogger("superlocalmemory.backup")
@@ -387,6 +391,21 @@ class BackupCoordinator:
387
391
  # Phase C succeeded — remove pre-restore snapshots.
388
392
  self._cleanup_pre_restore_snapshots(pre_restore_map, pre_restore_lance)
389
393
 
394
+ # Phase D — GDPR obligation replay (invariant: no restore may resurrect
395
+ # erased personal data). Must run AFTER live files are in place and
396
+ # pre-restore snapshots are removed so there is no window where the
397
+ # erased data could be read. A replay failure raises immediately; the
398
+ # caller receives BackupRestoreError and must treat the restore as
399
+ # failed until an operator manually remediates.
400
+ if backup_set_dir is not None:
401
+ _replay_obligations_after_restore(
402
+ data_root=self._base_dir,
403
+ snapshot_key=str(backup_set_dir),
404
+ restored_db_paths=[
405
+ self._base_dir / e.store_name for e in manifest.stores
406
+ ],
407
+ )
408
+
390
409
  # ------------------------------------------------------------------
391
410
  # Internal helpers (factored out for subclass testability)
392
411
  # ------------------------------------------------------------------
@@ -727,6 +746,24 @@ class BackupManager:
727
746
  src.close()
728
747
 
729
748
  logger.info("Restored: %s -> %s", filename, target.name)
749
+
750
+ # GDPR obligation replay — prevent a restore from resurrecting
751
+ # previously erased personal data. Failure is fatal: return False
752
+ # so the caller knows the restore is not clean.
753
+ try:
754
+ _replay_obligations_after_restore(
755
+ data_root=self.db_path.parent,
756
+ snapshot_key=str(backup_path),
757
+ restored_db_paths=[target],
758
+ )
759
+ except BackupRestoreError as exc:
760
+ logger.error(
761
+ "Restore obligation replay failed for %s: %s — "
762
+ "restore is NOT clean; erased data may be present",
763
+ filename, exc,
764
+ )
765
+ return False
766
+
730
767
  return True
731
768
 
732
769
  except Exception as exc:
@@ -798,3 +835,104 @@ class BackupManager:
798
835
  "total_size_mb": round(sum(b["size_mb"] for b in backups), 2),
799
836
  "backups": backups,
800
837
  }
838
+
839
+
840
+ # ---------------------------------------------------------------------------
841
+ # GDPR obligation replay — module-level so both backup classes can call it
842
+ # ---------------------------------------------------------------------------
843
+
844
+
845
+ def _replay_obligations_after_restore(
846
+ data_root: Path,
847
+ snapshot_key: str,
848
+ restored_db_paths: list[Path],
849
+ ) -> None:
850
+ """Re-apply pending erasure obligations to freshly restored database files.
851
+
852
+ This function is the enforcement point for the restore-replay invariant:
853
+ no restore may resurface data that was Art.17-erased from the live stores.
854
+
855
+ ``snapshot_key`` is the string path that was recorded as the obligation's
856
+ ``snapshot_path`` when the obligation was created (typically the backup-set
857
+ directory for new-style backups, or the per-file `.db` path for legacy
858
+ backups).
859
+
860
+ Raises:
861
+ BackupRestoreError: if any obligation cannot be replayed. The caller
862
+ must treat the restore as failed and surface this to the operator.
863
+ """
864
+ store = BackupObligationStore(data_root)
865
+ obligations = store.list_pending_for_snapshot(snapshot_key)
866
+
867
+ # Belt-and-suspenders: even when path matching fails (e.g. backup moved),
868
+ # detect erased profiles by scanning the restored DB and checking whether
869
+ # any of the profiles present have pending obligations.
870
+ if not obligations:
871
+ for db_path in restored_db_paths:
872
+ if not db_path.exists():
873
+ continue
874
+ try:
875
+ import sqlite3 as _sq3
876
+ conn = _sq3.connect(str(db_path))
877
+ try:
878
+ tables = {
879
+ r[0] for r in
880
+ conn.execute(
881
+ "SELECT name FROM sqlite_master WHERE type='table'"
882
+ ).fetchall()
883
+ }
884
+ for tbl in ("profiles", "atomic_facts"):
885
+ if tbl not in tables:
886
+ continue
887
+ cols = {
888
+ r[1] for r in
889
+ conn.execute(f"PRAGMA table_info({tbl})").fetchall()
890
+ }
891
+ if "profile_id" not in cols:
892
+ continue
893
+ for (pid,) in conn.execute(
894
+ f"SELECT DISTINCT profile_id FROM {tbl}"
895
+ ).fetchall():
896
+ if pid:
897
+ obligations.extend(
898
+ store.list_pending_for_profile(pid)
899
+ )
900
+ finally:
901
+ conn.close()
902
+ except Exception as exc: # noqa: BLE001
903
+ logger.warning(
904
+ "_replay_obligations_after_restore: scan failed for %s: %s",
905
+ db_path.name, exc,
906
+ )
907
+
908
+ if not obligations:
909
+ return
910
+
911
+ profile_ids = {o["profile_id"] for o in obligations}
912
+
913
+ for profile_id in profile_ids:
914
+ for db_path in restored_db_paths:
915
+ if not db_path.exists():
916
+ continue
917
+ try:
918
+ deleted = erase_profile_from_snapshot(db_path, profile_id)
919
+ if deleted:
920
+ logger.info(
921
+ "Restore replay: erased profile %r from %s: %s",
922
+ profile_id, db_path.name, deleted,
923
+ )
924
+ except Exception as exc: # noqa: BLE001
925
+ raise BackupRestoreError(
926
+ f"Erasure obligation replay failed for profile {profile_id!r} "
927
+ f"in {db_path.name}: {exc}. "
928
+ "The restored database may contain previously erased personal "
929
+ "data. Manual remediation required."
930
+ ) from exc
931
+
932
+ # Discharge obligations for this specific snapshot so they do not block
933
+ # the completeness flag for future erasures of the same profile.
934
+ discharged = store.discharge_for_snapshot(snapshot_key, "replayed_on_restore")
935
+ logger.info(
936
+ "Obligation replay: discharged %d obligations for snapshot %s",
937
+ discharged, snapshot_key,
938
+ )
@@ -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