superlocalmemory 3.7.8 → 3.8.0

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 (260) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/ATTRIBUTION.md +1 -3
  3. package/CHANGELOG.md +69 -0
  4. package/README.md +199 -29
  5. package/package.json +4 -2
  6. package/plugin/.claude-plugin/plugin.json +2 -2
  7. package/plugin/CLAUDE.md +8 -8
  8. package/plugin/agents/slm-governance-advisor.md +80 -0
  9. package/plugin/agents/slm-loop-runner.md +71 -0
  10. package/plugin/agents/slm-memory-advisor.md +10 -5
  11. package/plugin/agents/slm-optimize-advisor.md +9 -3
  12. package/plugin/commands/slm-loop.md +31 -0
  13. package/plugin/hooks/hooks.json +79 -0
  14. package/plugin/requirements.txt +1 -1
  15. package/plugin/scripts/slm-launch +46 -7
  16. package/plugin/settings.json +9 -0
  17. package/plugin/skills/slm-cache/SKILL.md +9 -1
  18. package/plugin/skills/slm-compress/SKILL.md +8 -1
  19. package/plugin/skills/slm-governance/SKILL.md +248 -0
  20. package/plugin/skills/slm-graph/SKILL.md +17 -3
  21. package/plugin/skills/slm-loop/SKILL.md +99 -0
  22. package/plugin/skills/slm-mesh/SKILL.md +282 -0
  23. package/plugin/skills/slm-profile/SKILL.md +148 -0
  24. package/plugin/skills/slm-recall/SKILL.md +46 -10
  25. package/plugin/skills/slm-remember/SKILL.md +48 -1
  26. package/plugin/skills/slm-scope/SKILL.md +176 -0
  27. package/plugin/skills/slm-session/SKILL.md +24 -1
  28. package/plugin/skills/slm-status/SKILL.md +18 -1
  29. package/plugin-src/agents/slm-governance-advisor.md +80 -0
  30. package/plugin-src/agents/slm-loop-runner.md +71 -0
  31. package/plugin-src/agents/slm-memory-advisor.md +10 -5
  32. package/plugin-src/agents/slm-optimize-advisor.md +9 -3
  33. package/plugin-src/commands/slm-loop.md +31 -0
  34. package/plugin-src/hooks/hooks.json +79 -0
  35. package/plugin-src/manifest.json +7 -2
  36. package/plugin-src/requirements.txt +1 -1
  37. package/plugin-src/rules/AGENTS.md +57 -18
  38. package/plugin-src/rules/CLAUDE.md.fragment +8 -8
  39. package/plugin-src/scripts/slm-launch +46 -7
  40. package/plugin-src/settings.json +9 -0
  41. package/plugin-src/skills/slm-cache/SKILL.md +9 -1
  42. package/plugin-src/skills/slm-compress/SKILL.md +8 -1
  43. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  44. package/plugin-src/skills/slm-graph/SKILL.md +17 -3
  45. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  46. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  47. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  48. package/plugin-src/skills/slm-recall/SKILL.md +46 -10
  49. package/plugin-src/skills/slm-remember/SKILL.md +48 -1
  50. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  51. package/plugin-src/skills/slm-session/SKILL.md +24 -1
  52. package/plugin-src/skills/slm-status/SKILL.md +18 -1
  53. package/pyproject.toml +1 -1
  54. package/scripts/postinstall/validation.js +2 -0
  55. package/scripts/postinstall-interactive.js +74 -2
  56. package/src/superlocalmemory/__init__.py +1 -1
  57. package/src/superlocalmemory/access/__init__.py +3 -0
  58. package/src/superlocalmemory/access/rbac.py +477 -0
  59. package/src/superlocalmemory/cli/commands.py +94 -10
  60. package/src/superlocalmemory/cli/compress_cmd.py +17 -7
  61. package/src/superlocalmemory/cli/loop_cmd.py +192 -0
  62. package/src/superlocalmemory/cli/main.py +39 -4
  63. package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
  64. package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
  65. package/src/superlocalmemory/cli/pending_store.py +49 -13
  66. package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
  67. package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
  68. package/src/superlocalmemory/cli/setup_wizard.py +22 -13
  69. package/src/superlocalmemory/compliance/audit.py +6 -0
  70. package/src/superlocalmemory/compliance/gdpr.py +128 -138
  71. package/src/superlocalmemory/compliance/retention.py +176 -45
  72. package/src/superlocalmemory/core/backend_orchestrator.py +5 -43
  73. package/src/superlocalmemory/core/community_summary.py +267 -0
  74. package/src/superlocalmemory/core/config.py +216 -3
  75. package/src/superlocalmemory/core/consolidation_engine.py +95 -22
  76. package/src/superlocalmemory/core/context_cache.py +61 -18
  77. package/src/superlocalmemory/core/embedding_worker.py +17 -2
  78. package/src/superlocalmemory/core/embeddings.py +12 -1
  79. package/src/superlocalmemory/core/engine.py +17 -1
  80. package/src/superlocalmemory/core/engine_ingestion.py +29 -0
  81. package/src/superlocalmemory/core/engine_wiring.py +13 -0
  82. package/src/superlocalmemory/core/entity_community.py +178 -0
  83. package/src/superlocalmemory/core/graph_analyzer.py +39 -2
  84. package/src/superlocalmemory/core/graph_pruner.py +13 -8
  85. package/src/superlocalmemory/core/key_expander.py +138 -0
  86. package/src/superlocalmemory/core/maintenance.py +23 -0
  87. package/src/superlocalmemory/core/modes.py +1 -1
  88. package/src/superlocalmemory/core/mutations.py +2 -2
  89. package/src/superlocalmemory/core/pii.py +105 -0
  90. package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
  91. package/src/superlocalmemory/core/recall_pipeline.py +2 -0
  92. package/src/superlocalmemory/core/recall_worker.py +20 -6
  93. package/src/superlocalmemory/core/scale_engine.py +60 -1
  94. package/src/superlocalmemory/core/security_primitives.py +40 -2
  95. package/src/superlocalmemory/core/store_pipeline.py +35 -11
  96. package/src/superlocalmemory/core/worker_pool.py +21 -6
  97. package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
  98. package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
  99. package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
  100. package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
  101. package/src/superlocalmemory/evolution/evolution_store.py +122 -45
  102. package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
  103. package/src/superlocalmemory/evolution/model_selection.py +160 -0
  104. package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
  105. package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
  106. package/src/superlocalmemory/evolution/triggers.py +22 -13
  107. package/src/superlocalmemory/graph/cozo_backend.py +43 -20
  108. package/src/superlocalmemory/hooks/adapter_base.py +5 -1
  109. package/src/superlocalmemory/hooks/auto_recall.py +13 -1
  110. package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
  111. package/src/superlocalmemory/hooks/codex_assets.py +64 -5
  112. package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
  113. package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
  114. package/src/superlocalmemory/hooks/portable_kit.py +114 -1
  115. package/src/superlocalmemory/infra/backup.py +12 -1
  116. package/src/superlocalmemory/infra/daemon_identity.py +40 -4
  117. package/src/superlocalmemory/infra/data_root.py +43 -4
  118. package/src/superlocalmemory/infra/event_bus.py +107 -24
  119. package/src/superlocalmemory/infra/rate_limiter.py +93 -0
  120. package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
  121. package/src/superlocalmemory/ingestion/credentials.py +1 -1
  122. package/src/superlocalmemory/learning/cross_project.py +28 -19
  123. package/src/superlocalmemory/learning/reward_proxy.py +42 -9
  124. package/src/superlocalmemory/loops/__init__.py +56 -0
  125. package/src/superlocalmemory/loops/budget.py +58 -0
  126. package/src/superlocalmemory/loops/engine.py +164 -0
  127. package/src/superlocalmemory/loops/ledger.py +243 -0
  128. package/src/superlocalmemory/loops/models.py +152 -0
  129. package/src/superlocalmemory/loops/rules.py +52 -0
  130. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  131. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  132. package/src/superlocalmemory/mcp/profiles.py +103 -0
  133. package/src/superlocalmemory/mcp/server.py +21 -49
  134. package/src/superlocalmemory/mcp/tools_active.py +4 -7
  135. package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
  136. package/src/superlocalmemory/mcp/tools_core.py +8 -1
  137. package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
  138. package/src/superlocalmemory/mcp/tools_loops.py +300 -0
  139. package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
  140. package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
  141. package/src/superlocalmemory/mesh/broker.py +237 -129
  142. package/src/superlocalmemory/mesh/remote_sync.py +50 -8
  143. package/src/superlocalmemory/optimize/NOTICE +1 -6
  144. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
  145. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
  146. package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
  147. package/src/superlocalmemory/optimize/compress/align.py +32 -26
  148. package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
  149. package/src/superlocalmemory/optimize/compress/router.py +105 -22
  150. package/src/superlocalmemory/optimize/config/defaults.py +1 -1
  151. package/src/superlocalmemory/optimize/config/schema.py +87 -4
  152. package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
  153. package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
  154. package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
  155. package/src/superlocalmemory/optimize/storage/db.py +38 -9
  156. package/src/superlocalmemory/optimize/storage/schema.py +10 -0
  157. package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
  158. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  159. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
  160. package/src/superlocalmemory/retrieval/engine.py +168 -26
  161. package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
  162. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
  163. package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
  164. package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
  165. package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
  166. package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
  167. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
  168. package/src/superlocalmemory/retrieval/time_window.py +181 -0
  169. package/src/superlocalmemory/server/api.py +4 -4
  170. package/src/superlocalmemory/server/profile_runtime.py +125 -8
  171. package/src/superlocalmemory/server/rbac_enforce.py +142 -0
  172. package/src/superlocalmemory/server/recall_health.py +24 -3
  173. package/src/superlocalmemory/server/recall_serializer.py +19 -1
  174. package/src/superlocalmemory/server/routes/abstraction.py +115 -0
  175. package/src/superlocalmemory/server/routes/agents.py +128 -38
  176. package/src/superlocalmemory/server/routes/backup.py +34 -10
  177. package/src/superlocalmemory/server/routes/behavioral.py +13 -12
  178. package/src/superlocalmemory/server/routes/brain.py +21 -5
  179. package/src/superlocalmemory/server/routes/chat.py +10 -5
  180. package/src/superlocalmemory/server/routes/compliance.py +171 -21
  181. package/src/superlocalmemory/server/routes/config_api.py +436 -0
  182. package/src/superlocalmemory/server/routes/data_io.py +30 -8
  183. package/src/superlocalmemory/server/routes/entity.py +9 -4
  184. package/src/superlocalmemory/server/routes/events.py +24 -8
  185. package/src/superlocalmemory/server/routes/evolution.py +135 -17
  186. package/src/superlocalmemory/server/routes/helpers.py +16 -1
  187. package/src/superlocalmemory/server/routes/ingest.py +7 -4
  188. package/src/superlocalmemory/server/routes/insights.py +3 -3
  189. package/src/superlocalmemory/server/routes/learning.py +14 -14
  190. package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
  191. package/src/superlocalmemory/server/routes/memories.py +182 -57
  192. package/src/superlocalmemory/server/routes/mesh.py +95 -15
  193. package/src/superlocalmemory/server/routes/optimize.py +33 -1
  194. package/src/superlocalmemory/server/routes/prewarm.py +2 -0
  195. package/src/superlocalmemory/server/routes/profiles.py +63 -17
  196. package/src/superlocalmemory/server/routes/ratelimit.py +124 -0
  197. package/src/superlocalmemory/server/routes/rbac.py +367 -0
  198. package/src/superlocalmemory/server/routes/stats.py +13 -6
  199. package/src/superlocalmemory/server/routes/tiers.py +11 -9
  200. package/src/superlocalmemory/server/routes/v3_api.py +183 -69
  201. package/src/superlocalmemory/server/routes/ws.py +5 -2
  202. package/src/superlocalmemory/server/security_middleware.py +12 -5
  203. package/src/superlocalmemory/server/ui.py +20 -5
  204. package/src/superlocalmemory/server/unified_daemon.py +384 -56
  205. package/src/superlocalmemory/server/write_identity.py +38 -8
  206. package/src/superlocalmemory/storage/database.py +265 -53
  207. package/src/superlocalmemory/storage/migration_runner.py +53 -0
  208. package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
  209. package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
  210. package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
  211. package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
  212. package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
  213. package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
  214. package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
  215. package/src/superlocalmemory/storage/models.py +4 -0
  216. package/src/superlocalmemory/storage/schema.py +87 -0
  217. package/src/superlocalmemory/storage/schema_v343.py +24 -12
  218. package/src/superlocalmemory/trust/gate.py +49 -8
  219. package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
  220. package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
  221. package/src/superlocalmemory/ui/css/design-system.css +621 -0
  222. package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
  223. package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
  224. package/src/superlocalmemory/ui/favicon.svg +35 -4
  225. package/src/superlocalmemory/ui/index.html +306 -173
  226. package/src/superlocalmemory/ui/js/brain.js +5 -20
  227. package/src/superlocalmemory/ui/js/core.js +47 -31
  228. package/src/superlocalmemory/ui/js/dashboard.js +314 -63
  229. package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
  230. package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
  231. package/src/superlocalmemory/ui/js/math-health.js +1 -1
  232. package/src/superlocalmemory/ui/js/memories.js +15 -4
  233. package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
  234. package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
  235. package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
  236. package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
  237. package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
  238. package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
  239. package/src/superlocalmemory/ui/js/od-agents.js +544 -0
  240. package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
  241. package/src/superlocalmemory/ui/js/od-backup.js +780 -0
  242. package/src/superlocalmemory/ui/js/od-brain.js +779 -0
  243. package/src/superlocalmemory/ui/js/od-entities.js +579 -0
  244. package/src/superlocalmemory/ui/js/od-graph.js +593 -0
  245. package/src/superlocalmemory/ui/js/od-health.js +539 -0
  246. package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
  247. package/src/superlocalmemory/ui/js/od-memories.js +887 -0
  248. package/src/superlocalmemory/ui/js/od-mesh.js +539 -0
  249. package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
  250. package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
  251. package/src/superlocalmemory/ui/js/od-settings.js +1053 -0
  252. package/src/superlocalmemory/ui/js/od-shell.js +593 -0
  253. package/src/superlocalmemory/ui/js/od-skills.js +573 -0
  254. package/src/superlocalmemory/ui/js/od-team.js +258 -0
  255. package/src/superlocalmemory/ui/js/profiles.js +159 -46
  256. package/src/superlocalmemory/ui/js/settings.js +2 -2
  257. package/src/superlocalmemory/ui/js/timeline.js +34 -5
  258. package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
  259. package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
  260. package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
@@ -19,6 +19,15 @@ from datetime import UTC, datetime
19
19
 
20
20
  logger = logging.getLogger(__name__)
21
21
 
22
+ # Friendly export keys → canonical table names (stable Art.20 export contract).
23
+ _EXPORT_ALIASES = {
24
+ "facts": "atomic_facts",
25
+ "entities": "canonical_entities",
26
+ "edges": "graph_edges",
27
+ "feedback": "feedback_records",
28
+ "scenes": "memory_scenes",
29
+ }
30
+
22
31
 
23
32
  class GDPRCompliance:
24
33
  """GDPR compliance operations for memory data.
@@ -30,170 +39,146 @@ class GDPRCompliance:
30
39
  - Audit Trail: Log all data operations
31
40
  """
32
41
 
42
+ # Tables that carry a profile_id column but are NOT tenant memory to be
43
+ # erased/exported wholesale. `profiles` is the tenant record (handled
44
+ # separately, deleted last).
45
+ _NON_MEMORY_SCOPED = frozenset({"profiles"})
46
+
33
47
  def __init__(self, db) -> None:
34
48
  self._db = db
35
49
 
50
+ def _profile_scoped_tables(self) -> list[str]:
51
+ """Every table carrying a ``profile_id`` column — discovered live from
52
+ the schema so a newly-added table can never be silently missed by
53
+ export or erasure (the class of bug that breaks GDPR completeness)."""
54
+ try:
55
+ names = [
56
+ dict(r)["name"]
57
+ for r in self._db.execute(
58
+ "SELECT name FROM sqlite_master WHERE type='table'"
59
+ )
60
+ ]
61
+ except Exception:
62
+ return []
63
+ out: list[str] = []
64
+ for t in names:
65
+ if t.startswith("sqlite_") or t in self._NON_MEMORY_SCOPED:
66
+ continue
67
+ try:
68
+ cols = {dict(c)["name"] for c in self._db.execute(f"PRAGMA table_info({t})")}
69
+ except Exception:
70
+ continue
71
+ if "profile_id" in cols:
72
+ out.append(t)
73
+ return out
74
+
36
75
  # -- Right to Access (Art. 15) -----------------------------------------
37
76
 
38
77
  def export_profile_data(self, profile_id: str) -> dict:
39
- """Export ALL data for a profile in machine-readable format.
40
-
41
- Returns a dict containing all memories, facts, entities,
42
- edges, trust scores, feedback, and behavioral patterns.
43
- """
78
+ """Export ALL data for a profile in machine-readable format (Art. 15 /
79
+ Art. 20). Covers every profile-scoped table discovered from the schema,
80
+ plus the profile record itself."""
44
81
  self._audit("export", "profile", profile_id, "Full data export")
45
82
 
46
83
  data: dict = {"profile_id": profile_id, "exported_at": _now()}
84
+ for table in self._profile_scoped_tables():
85
+ try:
86
+ rows = self._db.execute(
87
+ f"SELECT * FROM {table} WHERE profile_id = ?", (profile_id,)
88
+ )
89
+ data[table] = [dict(r) for r in rows]
90
+ except Exception as exc: # pragma: no cover — defensive per-table
91
+ logger.warning("export: table %s skipped: %s", table, exc)
92
+
93
+ # Profile record itself (the tenant metadata).
94
+ try:
95
+ rows = self._db.execute(
96
+ "SELECT * FROM profiles WHERE profile_id = ?", (profile_id,)
97
+ )
98
+ data["profile_record"] = [dict(r) for r in rows]
99
+ except Exception:
100
+ data["profile_record"] = []
47
101
 
48
- # Memories
49
- rows = self._db.execute(
50
- "SELECT * FROM memories WHERE profile_id = ?", (profile_id,)
51
- )
52
- data["memories"] = [dict(r) for r in rows]
53
-
54
- # Facts
55
- rows = self._db.execute(
56
- "SELECT * FROM atomic_facts WHERE profile_id = ?", (profile_id,)
57
- )
58
- data["facts"] = [dict(r) for r in rows]
59
-
60
- # Entities
61
- rows = self._db.execute(
62
- "SELECT * FROM canonical_entities WHERE profile_id = ?", (profile_id,)
63
- )
64
- data["entities"] = [dict(r) for r in rows]
65
-
66
- # Graph edges
67
- rows = self._db.execute(
68
- "SELECT * FROM graph_edges WHERE profile_id = ?", (profile_id,)
69
- )
70
- data["edges"] = [dict(r) for r in rows]
71
-
72
- # Trust scores
73
- rows = self._db.execute(
74
- "SELECT * FROM trust_scores WHERE profile_id = ?", (profile_id,)
75
- )
76
- data["trust_scores"] = [dict(r) for r in rows]
77
-
78
- # Feedback
79
- rows = self._db.execute(
80
- "SELECT * FROM feedback_records WHERE profile_id = ?", (profile_id,)
81
- )
82
- data["feedback"] = [dict(r) for r in rows]
83
-
84
- # Entity profiles
85
- rows = self._db.execute(
86
- "SELECT * FROM entity_profiles WHERE profile_id = ?", (profile_id,)
87
- )
88
- data["entity_profiles"] = [dict(r) for r in rows]
89
-
90
- # Memory scenes
91
- rows = self._db.execute(
92
- "SELECT * FROM memory_scenes WHERE profile_id = ?", (profile_id,)
93
- )
94
- data["scenes"] = [dict(r) for r in rows]
95
-
96
- # Temporal events
97
- rows = self._db.execute(
98
- "SELECT * FROM temporal_events WHERE profile_id = ?", (profile_id,)
99
- )
100
- data["temporal_events"] = [dict(r) for r in rows]
101
-
102
- # Consolidation log
103
- rows = self._db.execute(
104
- "SELECT * FROM consolidation_log WHERE profile_id = ?", (profile_id,)
105
- )
106
- data["consolidation_log"] = [dict(r) for r in rows]
107
-
108
- # Behavioral patterns
109
- rows = self._db.execute(
110
- "SELECT * FROM behavioral_patterns WHERE profile_id = ?", (profile_id,)
111
- )
112
- data["behavioral_patterns"] = [dict(r) for r in rows]
113
-
114
- # Action outcomes
115
- rows = self._db.execute(
116
- "SELECT * FROM action_outcomes WHERE profile_id = ?", (profile_id,)
117
- )
118
- data["action_outcomes"] = [dict(r) for r in rows]
119
-
120
- # Compliance audit trail
121
- rows = self._db.execute(
122
- "SELECT * FROM compliance_audit WHERE profile_id = ?", (profile_id,)
123
- )
124
- data["compliance_audit"] = [dict(r) for r in rows]
125
-
126
- # Provenance (data lineage — EU AI Act Art. 10)
127
- rows = self._db.execute(
128
- "SELECT * FROM provenance WHERE profile_id = ?", (profile_id,)
129
- )
130
- data["provenance"] = [dict(r) for r in rows]
131
-
132
- # Entity aliases (indirect PII via entity relationships)
133
- rows = self._db.execute(
134
- "SELECT ea.* FROM entity_aliases ea "
135
- "JOIN canonical_entities ce ON ea.entity_id = ce.entity_id "
136
- "WHERE ce.profile_id = ?", (profile_id,)
137
- )
138
- data["entity_aliases"] = [dict(r) for r in rows]
139
-
140
- # Profile record itself
141
- rows = self._db.execute(
142
- "SELECT * FROM profiles WHERE profile_id = ?", (profile_id,)
143
- )
144
- data["profile_record"] = [dict(r) for r in rows]
145
-
102
+ # total_items counts the canonical (table-name) keys only, before
103
+ # friendly aliases are added, so it is not double-counted.
146
104
  data["total_items"] = sum(
147
105
  len(v) for v in data.values() if isinstance(v, list)
148
106
  )
149
107
 
108
+ # Backward-compatible friendly aliases for the well-known keys (stable
109
+ # export contract) — they reference the same lists, not copies.
110
+ for friendly, table in _EXPORT_ALIASES.items():
111
+ if table in data:
112
+ data[friendly] = data[table]
113
+
150
114
  logger.info("Exported %d items for profile '%s'", data["total_items"], profile_id)
151
115
  return data
152
116
 
153
117
  # -- Right to Erasure (Art. 17) ----------------------------------------
154
118
 
155
119
  def forget_profile(self, profile_id: str) -> dict:
156
- """Delete ALL data for a profile (right to be forgotten).
120
+ """Delete ALL data for a profile (right to be forgotten, Art. 17).
157
121
 
158
- CASCADE deletes handle most cleanup via foreign keys.
159
- Returns counts of deleted items.
122
+ Erases every profile-scoped table discovered from the live schema, so a
123
+ newly-added table is covered automatically. The erasure is recorded in
124
+ the tamper-proof audit chain BEFORE any deletion (Art. 5(2)
125
+ accountability) — the in-DB compliance_audit row is itself erased, so
126
+ the chain in a separate DB is the durable evidence.
160
127
  """
161
128
  if profile_id == "default":
162
129
  raise ValueError("Cannot delete the default profile via GDPR erasure. "
163
130
  "Use profile deletion instead.")
164
131
 
132
+ # 1) Durable, tamper-evident record FIRST — survives the erasure.
133
+ try:
134
+ from superlocalmemory.compliance.audit import AuditChain
135
+ from superlocalmemory.infra.data_root import state_path
136
+ AuditChain(str(state_path("audit_chain.db"))).log(
137
+ "gdpr_erase", agent_id="gdpr", profile_id=profile_id,
138
+ metadata={"basis": "GDPR Art.17 right-to-erasure"},
139
+ )
140
+ except Exception as exc:
141
+ logger.warning("GDPR erase: audit-chain log failed: %s", exc)
165
142
  self._audit("delete", "profile", profile_id, "GDPR erasure request")
166
143
 
167
144
  counts: dict[str, int] = {}
168
- tables = [
169
- "compliance_audit", "action_outcomes", "behavioral_patterns",
170
- "feedback_records", "trust_scores", "provenance",
171
- "consolidation_log", "graph_edges", "temporal_events",
172
- "memory_scenes", "entity_profiles", "bm25_tokens",
173
- "atomic_facts", "memories", "canonical_entities",
174
- ]
145
+ tables = self._profile_scoped_tables()
146
+ # Pass 1 — count every table BEFORE any deletion, so a CASCADE that
147
+ # removes a child (e.g. atomic_facts via memories) does not zero the
148
+ # attribution. Completeness is independent of this.
175
149
  for table in tables:
176
- rows = self._db.execute(
177
- f"SELECT COUNT(*) AS c FROM {table} WHERE profile_id = ?",
178
- (profile_id,),
179
- )
180
- counts[table] = int(dict(rows[0])["c"]) if rows else 0
181
- self._db.execute(
182
- f"DELETE FROM {table} WHERE profile_id = ?", (profile_id,)
183
- )
184
-
185
- # Delete entity aliases (orphan PII via entity relationships)
186
- self._db.execute(
187
- "DELETE FROM entity_aliases WHERE entity_id IN "
188
- "(SELECT entity_id FROM canonical_entities WHERE profile_id = ?)",
189
- (profile_id,),
190
- )
191
-
192
- # Delete profile itself
193
- self._db.execute(
194
- "DELETE FROM profiles WHERE profile_id = ?", (profile_id,)
195
- )
196
- counts["profiles"] = 1
150
+ try:
151
+ rows = self._db.execute(
152
+ f"SELECT COUNT(*) AS c FROM {table} WHERE profile_id = ?",
153
+ (profile_id,),
154
+ )
155
+ counts[table] = int(dict(rows[0])["c"]) if rows else 0
156
+ except Exception as exc: # pragma: no cover
157
+ logger.warning("GDPR erase: count %s failed: %s", table, exc)
158
+ counts[table] = 0
159
+ # Pass 2 full-tenant wipe with FK enforcement OFF so table order is
160
+ # irrelevant (every profile row in every table goes). FTS shadow rows
161
+ # are still removed by the base-table delete triggers.
162
+ try:
163
+ self._db.execute("PRAGMA foreign_keys=OFF")
164
+ except Exception:
165
+ pass
166
+ try:
167
+ for table in tables:
168
+ try:
169
+ self._db.execute(
170
+ f"DELETE FROM {table} WHERE profile_id = ?", (profile_id,)
171
+ )
172
+ except Exception as exc: # pragma: no cover — defensive per-table
173
+ logger.warning("GDPR erase: delete %s failed: %s", table, exc)
174
+ # Delete the profile record itself.
175
+ self._db.execute("DELETE FROM profiles WHERE profile_id = ?", (profile_id,))
176
+ counts["profiles"] = 1
177
+ finally:
178
+ try:
179
+ self._db.execute("PRAGMA foreign_keys=ON")
180
+ except Exception:
181
+ pass
197
182
 
198
183
  # Erase learning database (separate DB file)
199
184
  try:
@@ -211,7 +196,7 @@ class GDPRCompliance:
211
196
  except Exception:
212
197
  pass
213
198
 
214
- logger.info("GDPR erasure for '%s': %s", profile_id, counts)
199
+ logger.info("GDPR erasure for '%s': %d tables, %s", profile_id, len(tables), counts)
215
200
  return counts
216
201
 
217
202
  def forget_entity(self, entity_name: str, profile_id: str) -> dict:
@@ -254,9 +239,14 @@ class GDPRCompliance:
254
239
  (eid, profile_id),
255
240
  )
256
241
 
257
- # Delete aliases + entity
258
- self._db.execute("DELETE FROM entity_aliases WHERE entity_id = ?", (eid,))
259
- self._db.execute("DELETE FROM canonical_entities WHERE entity_id = ?", (eid,))
242
+ # Delete aliases + entity (profile-scoped — entity_id is UUID-global but
243
+ # keep the tenant predicate for consistent Art.17 isolation).
244
+ self._db.execute(
245
+ "DELETE FROM entity_aliases WHERE entity_id = ? AND profile_id = ?",
246
+ (eid, profile_id))
247
+ self._db.execute(
248
+ "DELETE FROM canonical_entities WHERE entity_id = ? AND profile_id = ?",
249
+ (eid, profile_id))
260
250
  counts["entity"] = 1
261
251
 
262
252
  logger.info("Entity erasure '%s' in '%s': %s", entity_name, profile_id, counts)
@@ -13,6 +13,7 @@ independently of the main memory lifecycle system.
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
+ import json
16
17
  import logging
17
18
  import sqlite3
18
19
  from datetime import datetime, timezone
@@ -27,11 +28,24 @@ CREATE TABLE IF NOT EXISTS retention_rules (
27
28
  rule_name TEXT NOT NULL,
28
29
  days INTEGER NOT NULL,
29
30
  description TEXT DEFAULT '',
31
+ framework TEXT NOT NULL DEFAULT 'custom',
32
+ action TEXT NOT NULL DEFAULT 'archive',
33
+ applies_to TEXT NOT NULL DEFAULT '{}',
30
34
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
31
35
  UNIQUE(profile_id, rule_name)
32
36
  )
33
37
  """
34
38
 
39
+ # Terminal lifecycle zone each action moves an expired fact into. 'notify'
40
+ # changes nothing — it only surfaces the count so an operator can act.
41
+ # atomic_facts.lifecycle CHECK allows only active/warm/cold/archived, so both
42
+ # retention actions land in the 'archived' lifecycle zone. 'tombstone' is
43
+ # additionally flagged purgeable via archive_status so a purge job can find it —
44
+ # this avoids a risky rebuild of the (large) atomic_facts CHECK constraint.
45
+ _ACTION_LIFECYCLE = {"archive": "archived", "tombstone": "archived"}
46
+ _TOMBSTONE_ACTIONS = frozenset({"tombstone"})
47
+ _VALID_ACTIONS = ("archive", "tombstone", "notify")
48
+
35
49
  _FACTS_TABLE_CHECK = """
36
50
  SELECT name FROM sqlite_master
37
51
  WHERE type='table' AND name='atomic_facts'
@@ -54,8 +68,22 @@ class RetentionEngine:
54
68
  # ------------------------------------------------------------------
55
69
 
56
70
  def _ensure_table(self) -> None:
57
- """Create the retention_rules table if it does not exist."""
71
+ """Create the retention_rules table, self-migrating older schemas.
72
+
73
+ Pre-GDPR-model tables had only (rule_name, days, description). Add the
74
+ framework/action/applies_to columns in place so existing rules keep
75
+ working (defaults: framework='custom', action='archive').
76
+ """
58
77
  self._db.execute(_RETENTION_RULES_TABLE)
78
+ cols = {r[1] for r in self._db.execute(
79
+ "PRAGMA table_info(retention_rules)").fetchall()}
80
+ for col, ddl in (
81
+ ("framework", "framework TEXT NOT NULL DEFAULT 'custom'"),
82
+ ("action", "action TEXT NOT NULL DEFAULT 'archive'"),
83
+ ("applies_to", "applies_to TEXT NOT NULL DEFAULT '{}'"),
84
+ ):
85
+ if col not in cols:
86
+ self._db.execute(f"ALTER TABLE retention_rules ADD COLUMN {ddl}")
59
87
  self._db.commit()
60
88
 
61
89
  def _has_facts_table(self) -> bool:
@@ -128,29 +156,89 @@ class RetentionEngine:
128
156
  )
129
157
 
130
158
  def get_rules(self, profile_id: str) -> list[dict[str, Any]]:
131
- """Get all retention rules for a profile.
159
+ """Get all retention rules for a profile (full GDPR shape)."""
160
+ rows = self._db.execute(
161
+ "SELECT id, rule_name, days, description, framework, action, "
162
+ "applies_to, created_at FROM retention_rules "
163
+ "WHERE profile_id = ? ORDER BY id",
164
+ (profile_id,),
165
+ ).fetchall()
166
+ out: list[dict[str, Any]] = []
167
+ for r in rows:
168
+ try:
169
+ applies = json.loads(r[6]) if r[6] else {}
170
+ except (ValueError, TypeError):
171
+ applies = {}
172
+ out.append({
173
+ "id": r[0],
174
+ "name": r[1],
175
+ "rule_name": r[1], # legacy alias
176
+ "retention_days": r[2],
177
+ "days": r[2], # legacy alias
178
+ "description": r[3],
179
+ "framework": r[4],
180
+ "action": r[5],
181
+ "applies_to": applies,
182
+ "created_at": r[7],
183
+ })
184
+ return out
132
185
 
133
- Args:
134
- profile_id: Profile to query rules for.
186
+ # ------------------------------------------------------------------
187
+ # GDPR-model API (used by the compliance route + dashboard)
188
+ # ------------------------------------------------------------------
135
189
 
136
- Returns:
137
- List of rule dicts with keys: rule_name, days, description.
190
+ def create_rule(
191
+ self,
192
+ *,
193
+ name: str,
194
+ framework: str,
195
+ retention_days: int,
196
+ action: str,
197
+ applies_to: dict | None,
198
+ profile_id: str,
199
+ ) -> int:
200
+ """Create a retention rule and return its row id.
201
+
202
+ ``action`` is one of archive | tombstone | notify. Raises ValueError on
203
+ an invalid action so the route returns a clear 4xx rather than storing
204
+ an unenforceable rule.
138
205
  """
206
+ if action not in _VALID_ACTIONS:
207
+ raise ValueError(f"action must be one of {_VALID_ACTIONS}")
208
+ cur = self._db.execute(
209
+ "INSERT OR REPLACE INTO retention_rules "
210
+ "(profile_id, rule_name, days, description, framework, action, applies_to) "
211
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
212
+ (profile_id, name, int(retention_days), "", framework, action,
213
+ json.dumps(applies_to or {})),
214
+ )
215
+ self._db.commit()
216
+ logger.info(
217
+ "Created retention rule '%s' (%dd, %s/%s) for profile '%s'",
218
+ name, retention_days, framework, action, profile_id,
219
+ )
220
+ return int(cur.lastrowid or 0)
221
+
222
+ def list_rules(self, profile_id: str | None = None) -> list[dict[str, Any]]:
223
+ """List rules for one profile, or all rules when profile_id is None."""
224
+ if profile_id is not None:
225
+ return self.get_rules(profile_id)
139
226
  rows = self._db.execute(
140
- "SELECT rule_name, days, description, created_at "
141
- "FROM retention_rules WHERE profile_id = ? "
142
- "ORDER BY id",
143
- (profile_id,),
227
+ "SELECT DISTINCT profile_id FROM retention_rules"
144
228
  ).fetchall()
145
- return [
146
- {
147
- "rule_name": r[0],
148
- "days": r[1],
149
- "description": r[2],
150
- "created_at": r[3],
151
- }
152
- for r in rows
153
- ]
229
+ out: list[dict[str, Any]] = []
230
+ for r in rows:
231
+ out.extend(self.get_rules(r[0]))
232
+ return out
233
+
234
+ def delete_rule(self, profile_id: str, name: str) -> bool:
235
+ """Delete a rule by (profile_id, name). Returns True if a row was removed."""
236
+ cur = self._db.execute(
237
+ "DELETE FROM retention_rules WHERE profile_id = ? AND rule_name = ?",
238
+ (profile_id, name),
239
+ )
240
+ self._db.commit()
241
+ return cur.rowcount > 0
154
242
 
155
243
  # ------------------------------------------------------------------
156
244
  # Expiration detection
@@ -174,14 +262,14 @@ class RetentionEngine:
174
262
  return []
175
263
 
176
264
  # Use the shortest retention period (most restrictive)
177
- min_days = min(r["days"] for r in rules)
265
+ min_days = min(r["retention_days"] for r in rules)
178
266
 
179
267
  if not self._has_facts_table():
180
268
  return []
181
269
 
270
+ # atomic_facts is keyed by fact_id (TEXT hex), NOT an integer id.
182
271
  rows = self._db.execute(
183
- "SELECT id, created_at FROM atomic_facts "
184
- "WHERE profile_id = ?",
272
+ "SELECT fact_id, created_at FROM atomic_facts WHERE profile_id = ?",
185
273
  (profile_id,),
186
274
  ).fetchall()
187
275
 
@@ -199,34 +287,77 @@ class RetentionEngine:
199
287
  # ------------------------------------------------------------------
200
288
 
201
289
  def enforce(self, profile_id: str) -> dict[str, Any]:
202
- """Enforce retention rules delete expired facts.
290
+ """Enforce every retention rule for a profile, honoring each action.
203
291
 
204
- Finds all expired facts for the profile and deletes them.
292
+ For each rule, facts in the profile older than its retention_days are
293
+ moved to the rule's terminal lifecycle zone:
294
+ * archive → lifecycle 'archived' (retained, hidden from recall)
295
+ * tombstone → lifecycle 'tombstoned' (soft-deleted, purgeable)
296
+ * notify → unchanged (only counted, so an operator can act)
205
297
 
206
- Args:
207
- profile_id: Profile to enforce rules on.
298
+ GDPR-safe: soft-state transitions, never a raw DELETE — erasure is an
299
+ explicit separate operation. All updates are scoped to ``profile_id``
300
+ and keyed by ``fact_id`` (TEXT), fixing the prior ``id``/int() bug that
301
+ made enforcement crash outright.
208
302
 
209
- Returns:
210
- Dict with keys: deleted_count, expired_ids, profile_id.
303
+ Returns per-action counts + the affected fact ids.
211
304
  """
212
- expired_ids = self.get_expired_facts(profile_id)
213
- deleted_count = 0
305
+ rules = self.get_rules(profile_id)
306
+ result: dict[str, Any] = {
307
+ "profile_id": profile_id,
308
+ "archived": 0, "tombstoned": 0, "notified": 0,
309
+ "deleted_count": 0, # legacy alias (== tombstoned)
310
+ "affected_ids": [],
311
+ }
312
+ if not rules or not self._has_facts_table():
313
+ return result
314
+
315
+ rows = self._db.execute(
316
+ "SELECT fact_id, created_at FROM atomic_facts WHERE profile_id = ?",
317
+ (profile_id,),
318
+ ).fetchall()
214
319
 
215
- if expired_ids and self._has_facts_table():
216
- placeholders = ",".join("?" for _ in expired_ids)
320
+ affected: set[str] = set()
321
+ for rule in rules:
322
+ action = rule.get("action", "archive")
323
+ days = rule.get("retention_days", rule.get("days", 0))
324
+ expired = [
325
+ str(r[0]) for r in rows
326
+ if r[1] and self._age_in_days(r[1]) > days
327
+ ]
328
+ if not expired:
329
+ continue
330
+ if action == "notify":
331
+ result["notified"] += len(expired)
332
+ affected.update(expired)
333
+ continue
334
+ zone = _ACTION_LIFECYCLE.get(action)
335
+ if zone is None:
336
+ continue
337
+ placeholders = ",".join("?" for _ in expired)
217
338
  self._db.execute(
218
- f"DELETE FROM atomic_facts WHERE id IN ({placeholders})",
219
- [int(fid) for fid in expired_ids],
220
- )
221
- self._db.commit()
222
- deleted_count = len(expired_ids)
223
- logger.info(
224
- "Retention enforcement: deleted %d facts from profile '%s'",
225
- deleted_count, profile_id,
339
+ f"UPDATE atomic_facts SET lifecycle = ? "
340
+ f"WHERE profile_id = ? AND fact_id IN ({placeholders})",
341
+ [zone, profile_id, *expired],
226
342
  )
343
+ if action in _TOMBSTONE_ACTIONS:
344
+ # Flag purgeable without violating the lifecycle CHECK.
345
+ try:
346
+ self._db.execute(
347
+ f"UPDATE atomic_facts SET archive_status = 'tombstoned' "
348
+ f"WHERE profile_id = ? AND fact_id IN ({placeholders})",
349
+ [profile_id, *expired],
350
+ )
351
+ except Exception:
352
+ pass
353
+ result["archived" if action == "archive" else "tombstoned"] += len(expired)
354
+ affected.update(expired)
227
355
 
228
- return {
229
- "profile_id": profile_id,
230
- "deleted_count": deleted_count,
231
- "expired_ids": expired_ids,
232
- }
356
+ self._db.commit()
357
+ result["affected_ids"] = sorted(affected)
358
+ result["deleted_count"] = result["tombstoned"] # legacy alias
359
+ logger.info(
360
+ "Retention enforcement for '%s': archived=%d tombstoned=%d notified=%d",
361
+ profile_id, result["archived"], result["tombstoned"], result["notified"],
362
+ )
363
+ return result
@@ -17,8 +17,6 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
17
17
  from __future__ import annotations
18
18
 
19
19
  import logging
20
- import sqlite3
21
- import threading
22
20
  from pathlib import Path
23
21
  from typing import TYPE_CHECKING, Any
24
22
 
@@ -366,47 +364,11 @@ class BackendOrchestrator:
366
364
  logger.warning("LanceDB init failed: %s", exc)
367
365
  self._lancedb = None
368
366
 
369
- # ------------------------------------------------------------------
370
- # Internal: Migration
371
- # ------------------------------------------------------------------
372
-
373
- def _migrate_cozo(self) -> None:
374
- self._update_status("cozo", "migrating")
375
-
376
- def _run():
377
- conn = sqlite3.connect(str(self._data_dir / "memory.db"))
378
- conn.execute("PRAGMA journal_mode=WAL")
379
- conn.execute("PRAGMA query_only=ON") # F-07: read-only in migration thread
380
- try:
381
- count = self._cozo.bulk_import_from_sqlite(conn)
382
- self._update_status("cozo", "active", count)
383
- logger.info("CozoDB migration complete: %d edges", count)
384
- except Exception as exc:
385
- logger.error("CozoDB migration failed: %s", exc)
386
- self._update_status("cozo", "failed", error=str(exc))
387
- finally:
388
- conn.close()
389
-
390
- threading.Thread(target=_run, daemon=True).start()
391
-
392
- def _migrate_lancedb(self) -> None:
393
- self._update_status("lancedb", "migrating")
394
-
395
- def _run():
396
- conn = sqlite3.connect(str(self._data_dir / "memory.db"))
397
- conn.execute("PRAGMA journal_mode=WAL")
398
- conn.execute("PRAGMA query_only=ON")
399
- try:
400
- count = self._lancedb.bulk_import_from_sqlite(conn)
401
- self._update_status("lancedb", "active", count)
402
- logger.info("LanceDB migration complete: %d vectors", count)
403
- except Exception as exc:
404
- logger.error("LanceDB migration failed: %s", exc)
405
- self._update_status("lancedb", "failed", error=str(exc))
406
- finally:
407
- conn.close()
408
-
409
- threading.Thread(target=_run, daemon=True).start()
367
+ # v3.7.9 (scale MEDIUM-2): _migrate_cozo/_migrate_lancedb were dead code —
368
+ # never called anywhere — and bypassed the staged prepare→verify→promote
369
+ # safety envelope (no fingerprint, no parity, no backup). Removed so a future
370
+ # caller cannot re-import outside the lifecycle. Emergency re-imports must go
371
+ # through the Scale Engine lifecycle.
410
372
 
411
373
  # ------------------------------------------------------------------
412
374
  # Internal: Status