superlocalmemory 3.8.13 → 4.0.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 (212) hide show
  1. package/ATTRIBUTION.md +4 -4
  2. package/CHANGELOG.md +113 -121
  3. package/README.md +65 -63
  4. package/docs/pi-dev-integration.md +1 -1
  5. package/package.json +6 -1
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/CLAUDE.md +3 -3
  8. package/plugin/agents/slm-governance-advisor.md +1 -1
  9. package/plugin/agents/slm-loop-runner.md +1 -1
  10. package/plugin/agents/slm-memory-advisor.md +1 -1
  11. package/plugin/agents/slm-optimize-advisor.md +1 -1
  12. package/plugin/requirements.txt +1 -1
  13. package/plugin/skills/slm-cache/SKILL.md +1 -1
  14. package/plugin/skills/slm-compress/SKILL.md +1 -1
  15. package/plugin/skills/slm-governance/SKILL.md +1 -1
  16. package/plugin/skills/slm-graph/SKILL.md +1 -1
  17. package/plugin/skills/slm-loop/SKILL.md +1 -1
  18. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  19. package/plugin/skills/slm-profile/SKILL.md +1 -1
  20. package/plugin/skills/slm-recall/SKILL.md +1 -1
  21. package/plugin/skills/slm-remember/SKILL.md +1 -1
  22. package/plugin/skills/slm-scope/SKILL.md +1 -1
  23. package/plugin/skills/slm-session/SKILL.md +1 -1
  24. package/plugin/skills/slm-status/SKILL.md +1 -1
  25. package/plugin-src/rules/AGENTS.md +1 -1
  26. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  29. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  31. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  32. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  33. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  36. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  38. package/pyproject.toml +11 -4
  39. package/src/superlocalmemory/__init__.py +1 -1
  40. package/src/superlocalmemory/cli/commands.py +125 -11
  41. package/src/superlocalmemory/cli/daemon.py +5 -1
  42. package/src/superlocalmemory/cli/main.py +35 -2
  43. package/src/superlocalmemory/cli/ops_cmd.py +281 -0
  44. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  45. package/src/superlocalmemory/compliance/audit.py +65 -0
  46. package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
  47. package/src/superlocalmemory/compliance/gdpr.py +416 -20
  48. package/src/superlocalmemory/compliance/retention.py +74 -22
  49. package/src/superlocalmemory/compliance/scheduler.py +78 -9
  50. package/src/superlocalmemory/core/actor_context.py +166 -0
  51. package/src/superlocalmemory/core/admission.py +549 -0
  52. package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
  53. package/src/superlocalmemory/core/config.py +202 -24
  54. package/src/superlocalmemory/core/consolidation_engine.py +13 -13
  55. package/src/superlocalmemory/core/context_cache.py +28 -0
  56. package/src/superlocalmemory/core/embeddings.py +64 -2
  57. package/src/superlocalmemory/core/engine.py +7 -2
  58. package/src/superlocalmemory/core/engine_ingestion.py +65 -3
  59. package/src/superlocalmemory/core/engine_wiring.py +36 -9
  60. package/src/superlocalmemory/core/ingest_policy.py +38 -0
  61. package/src/superlocalmemory/core/maintenance.py +255 -0
  62. package/src/superlocalmemory/core/modes.py +40 -13
  63. package/src/superlocalmemory/core/mutations.py +437 -44
  64. package/src/superlocalmemory/core/operation_policy.py +92 -0
  65. package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
  66. package/src/superlocalmemory/core/operation_request.py +127 -0
  67. package/src/superlocalmemory/core/ops_remediation.py +542 -0
  68. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  69. package/src/superlocalmemory/core/remember_runtime.py +202 -4
  70. package/src/superlocalmemory/core/remote_mode.py +20 -5
  71. package/src/superlocalmemory/core/store_pipeline.py +150 -0
  72. package/src/superlocalmemory/core/topic_signature.py +19 -4
  73. package/src/superlocalmemory/core/transactions/__init__.py +78 -0
  74. package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
  75. package/src/superlocalmemory/core/transactions/erasure.py +825 -0
  76. package/src/superlocalmemory/core/transactions/manifest.py +255 -0
  77. package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
  78. package/src/superlocalmemory/core/transactions/obligations.py +272 -0
  79. package/src/superlocalmemory/core/transactions/owners.py +114 -0
  80. package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
  81. package/src/superlocalmemory/core/transactions/service.py +330 -0
  82. package/src/superlocalmemory/core/worker_pool.py +33 -5
  83. package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
  84. package/src/superlocalmemory/encoding/emotional.py +75 -14
  85. package/src/superlocalmemory/encoding/scene_builder.py +115 -13
  86. package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
  87. package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
  88. package/src/superlocalmemory/evolution/evolution_store.py +244 -4
  89. package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
  90. package/src/superlocalmemory/evolution/model_selection.py +18 -3
  91. package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
  92. package/src/superlocalmemory/evolution/skill_activator.py +270 -0
  93. package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
  94. package/src/superlocalmemory/evolution/types.py +30 -8
  95. package/src/superlocalmemory/graph/cozo_backend.py +17 -9
  96. package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
  97. package/src/superlocalmemory/hooks/auto_recall.py +64 -30
  98. package/src/superlocalmemory/hooks/codex_assets.py +14 -1
  99. package/src/superlocalmemory/infra/backup.py +434 -7
  100. package/src/superlocalmemory/infra/process_reaper.py +18 -0
  101. package/src/superlocalmemory/infra/self_heal.py +401 -0
  102. package/src/superlocalmemory/learning/feedback.py +52 -9
  103. package/src/superlocalmemory/loops/engine.py +10 -0
  104. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  105. package/src/superlocalmemory/mcp/http_transport.py +30 -331
  106. package/src/superlocalmemory/mcp/profiles.py +5 -0
  107. package/src/superlocalmemory/mcp/resources.py +8 -0
  108. package/src/superlocalmemory/mcp/server.py +51 -4
  109. package/src/superlocalmemory/mcp/shared.py +19 -0
  110. package/src/superlocalmemory/mcp/tools_active.py +25 -4
  111. package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
  112. package/src/superlocalmemory/mcp/tools_context.py +50 -8
  113. package/src/superlocalmemory/mcp/tools_core.py +69 -21
  114. package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
  115. package/src/superlocalmemory/mcp/tools_learning.py +21 -10
  116. package/src/superlocalmemory/mcp/tools_loops.py +29 -18
  117. package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
  118. package/src/superlocalmemory/mcp/tools_ops.py +115 -0
  119. package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
  120. package/src/superlocalmemory/mcp/tools_v28.py +10 -3
  121. package/src/superlocalmemory/mcp/tools_v3.py +34 -14
  122. package/src/superlocalmemory/mcp/tools_v33.py +18 -33
  123. package/src/superlocalmemory/mesh/broker.py +124 -46
  124. package/src/superlocalmemory/mesh/broker_security.py +470 -0
  125. package/src/superlocalmemory/mesh/discovery.py +365 -0
  126. package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
  127. package/src/superlocalmemory/mesh/node_identity.py +97 -0
  128. package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
  129. package/src/superlocalmemory/mesh/remote_sync.py +511 -28
  130. package/src/superlocalmemory/mesh/state_sync.py +286 -0
  131. package/src/superlocalmemory/optimize/config/store.py +45 -0
  132. package/src/superlocalmemory/parameterization/cross_project.py +12 -0
  133. package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
  134. package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
  135. package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
  136. package/src/superlocalmemory/retrieval/ann_index.py +5 -0
  137. package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
  138. package/src/superlocalmemory/retrieval/engine.py +19 -4
  139. package/src/superlocalmemory/retrieval/fusion.py +4 -1
  140. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
  141. package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
  142. package/src/superlocalmemory/retrieval/reranker.py +32 -1
  143. package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
  144. package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
  145. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
  146. package/src/superlocalmemory/retrieval/vector_store.py +214 -8
  147. package/src/superlocalmemory/server/api.py +5 -5
  148. package/src/superlocalmemory/server/egress_policy.py +258 -0
  149. package/src/superlocalmemory/server/rbac_enforce.py +32 -0
  150. package/src/superlocalmemory/server/route_mutations.py +20 -0
  151. package/src/superlocalmemory/server/routes/compliance.py +153 -7
  152. package/src/superlocalmemory/server/routes/data_io.py +43 -2
  153. package/src/superlocalmemory/server/routes/events.py +15 -0
  154. package/src/superlocalmemory/server/routes/memories.py +56 -3
  155. package/src/superlocalmemory/server/routes/mesh.py +82 -1
  156. package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
  157. package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
  158. package/src/superlocalmemory/server/routes/v3_api.py +50 -27
  159. package/src/superlocalmemory/server/routes/ws.py +86 -0
  160. package/src/superlocalmemory/server/ui.py +6 -6
  161. package/src/superlocalmemory/server/unified_daemon.py +942 -119
  162. package/src/superlocalmemory/storage/_migration_internals.py +568 -0
  163. package/src/superlocalmemory/storage/_schema_version.py +110 -0
  164. package/src/superlocalmemory/storage/database.py +329 -24
  165. package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
  166. package/src/superlocalmemory/storage/erasure_fence.py +45 -0
  167. package/src/superlocalmemory/storage/generation_fence.py +63 -0
  168. package/src/superlocalmemory/storage/migration_runner.py +140 -417
  169. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
  170. package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
  171. package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
  172. package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
  173. package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
  174. package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
  175. package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
  176. package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
  177. package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
  178. package/src/superlocalmemory/storage/schema.py +67 -0
  179. package/src/superlocalmemory/storage/write_coordinator.py +125 -0
  180. package/src/superlocalmemory/trust/scorer.py +28 -4
  181. package/src/superlocalmemory/ui/index.html +14 -3
  182. package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
  183. package/src/superlocalmemory/ui/js/brain.js +6 -4
  184. package/src/superlocalmemory/ui/js/compliance.js +66 -12
  185. package/src/superlocalmemory/ui/js/dashboard.js +13 -3
  186. package/src/superlocalmemory/ui/js/feedback.js +8 -2
  187. package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
  188. package/src/superlocalmemory/ui/js/modal.js +272 -5
  189. package/src/superlocalmemory/ui/js/od-backup.js +9 -2
  190. package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
  191. package/src/superlocalmemory/ui/js/od-operations.js +154 -23
  192. package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
  193. package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
  194. package/src/superlocalmemory/ui/js/od-team.js +9 -2
  195. package/src/superlocalmemory/ui/js/optimize.js +13 -16
  196. package/src/superlocalmemory/ui/js/profiles.js +7 -3
  197. package/src/superlocalmemory/ui/js/settings.js +7 -1
  198. package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
  199. package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
  200. package/src/superlocalmemory/cli/post_install.py +0 -114
  201. package/src/superlocalmemory/core/clock_monitor.py +0 -45
  202. package/src/superlocalmemory/core/db_pool.py +0 -80
  203. package/src/superlocalmemory/core/error_catalog.py +0 -113
  204. package/src/superlocalmemory/core/loop_watchdog.py +0 -56
  205. package/src/superlocalmemory/core/priority_queue.py +0 -61
  206. package/src/superlocalmemory/core/pruning_engine.py +0 -216
  207. package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
  208. package/src/superlocalmemory/core/slmignore.py +0 -125
  209. package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
  210. package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
  211. package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
  212. package/src/superlocalmemory/storage/access_control.py +0 -182
@@ -0,0 +1,597 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import json
8
+ from typing import Any
9
+
10
+ from superlocalmemory.core.transactions.erasure import ErasureService
11
+ from superlocalmemory.core.transactions.owners import (
12
+ OperationContext,
13
+ OwnerErasureProof,
14
+ OwnerHealth,
15
+ OwnerResult,
16
+ )
17
+ from superlocalmemory.core.transactions.service import MemoryTransactionService
18
+
19
+ REQUIRED_ADMISSION_OWNERS: tuple[str, ...] = ("bm25", "temporal", "vector")
20
+
21
+
22
+ def _scope_checksum(owner: str, fingerprints: dict[str, str]) -> str:
23
+ parts = [f"{fact_id}={fingerprints[fact_id]}" for fact_id in sorted(fingerprints)]
24
+ payload = owner + "|" + "|".join(parts)
25
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
26
+
27
+
28
+ def _erasure_checksum(
29
+ owner: str, targets: tuple[str, ...], residue: set[str],
30
+ ) -> str:
31
+ payload = "\0".join([
32
+ owner,
33
+ "targets=" + ",".join(sorted(set(targets))),
34
+ "residue=" + ",".join(sorted(residue)),
35
+ ])
36
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
37
+
38
+
39
+ def _fingerprint(*parts: str) -> str:
40
+ return hashlib.sha256("\0".join(parts).encode("utf-8")).hexdigest()
41
+
42
+
43
+ def _placeholders(count: int) -> str:
44
+ return ",".join("?" for _ in range(count))
45
+
46
+
47
+ def _row_fact_id(row: Any) -> str | None:
48
+ try:
49
+ return dict(row)["fact_id"]
50
+ except (KeyError, ValueError, TypeError):
51
+ pass
52
+ try:
53
+ return row["fact_id"]
54
+ except Exception: # noqa: BLE001
55
+ pass
56
+ try:
57
+ return row[0]
58
+ except Exception: # noqa: BLE001
59
+ return None
60
+
61
+
62
+ def _db_table_exists(db: Any, name: str) -> bool:
63
+ try:
64
+ rows = db.execute(
65
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
66
+ (name,),
67
+ )
68
+ except Exception: # noqa: BLE001
69
+ return False
70
+ return bool(list(rows))
71
+
72
+
73
+ def _present_fact_ids(rows: Any, targets: set[str]) -> set[str]:
74
+ present: set[str] = set()
75
+ unreadable = False
76
+ for row in rows:
77
+ fid = _row_fact_id(row)
78
+ if fid is None:
79
+ unreadable = True
80
+ continue
81
+ present.add(fid)
82
+ if unreadable:
83
+ return set(targets)
84
+ return present & targets
85
+
86
+
87
+ class _FactScopedOwner:
88
+ _name: str
89
+
90
+ def __init__(self, db: Any) -> None:
91
+ self._db = db
92
+
93
+ @property
94
+ def name(self) -> str:
95
+ return self._name
96
+
97
+ def _required(self, context: OperationContext) -> set[str]:
98
+ return set(context.fact_ids) - self._tombstoned(context)
99
+
100
+ def _tombstoned(self, context: OperationContext) -> set[str]:
101
+ if not context.fact_ids:
102
+ return set()
103
+ try:
104
+ rows = self._db.execute(
105
+ f"SELECT fact_id FROM projection_tombstones WHERE profile_id = ? "
106
+ f"AND fact_id IN ({_placeholders(len(context.fact_ids))})",
107
+ (context.profile_id, *context.fact_ids),
108
+ )
109
+ except Exception:
110
+ return set()
111
+ return {fid for fid in (_row_fact_id(row) for row in rows) if fid is not None}
112
+
113
+ def _fingerprints(self, context: OperationContext) -> dict[str, str]:
114
+ raise NotImplementedError
115
+
116
+ def _physical_present(self, context: OperationContext) -> set[str]:
117
+ raise NotImplementedError
118
+
119
+ def _heal(self, context: OperationContext, fact_id: str) -> bool:
120
+ return False
121
+
122
+ def _remove(self, context: OperationContext, fact_id: str) -> None:
123
+ raise NotImplementedError
124
+
125
+ def _result(self, context: OperationContext) -> OwnerResult:
126
+ required = self._required(context)
127
+ fingerprints = {
128
+ fact_id: fp
129
+ for fact_id, fp in self._fingerprints(context).items()
130
+ if fact_id in required
131
+ }
132
+ missing = required - set(fingerprints)
133
+ return OwnerResult(
134
+ owner=self._name,
135
+ ok=not missing,
136
+ checksum=_scope_checksum(self._name, fingerprints),
137
+ detail={} if not missing else {"missing": sorted(missing)},
138
+ )
139
+
140
+ def verify(self, context: OperationContext) -> OwnerResult:
141
+ return self._result(context)
142
+
143
+ def prove_erased(self, context: OperationContext) -> OwnerErasureProof:
144
+ residue = self._physical_present(context) & set(context.fact_ids)
145
+ detail = {"residue": sorted(residue)} if residue else {}
146
+ return OwnerErasureProof(
147
+ owner=self._name,
148
+ erased=not residue,
149
+ checksum=_erasure_checksum(self._name, context.fact_ids, residue),
150
+ detail=detail,
151
+ )
152
+
153
+ def apply(self, context: OperationContext) -> OwnerResult:
154
+ required = self._required(context)
155
+ current = set(self._fingerprints(context))
156
+ errors: list[str] = []
157
+ for fact_id in sorted(required - current):
158
+ try:
159
+ self._heal(context, fact_id)
160
+ except Exception as exc: # noqa: BLE001
161
+ errors.append(f"{fact_id}: {type(exc).__name__}")
162
+ result = self._result(context)
163
+ if errors:
164
+ return OwnerResult(
165
+ owner=self._name,
166
+ ok=result.ok,
167
+ checksum=result.checksum,
168
+ detail={**dict(result.detail), "errors": errors},
169
+ )
170
+ return result
171
+
172
+ def compensate(self, context: OperationContext) -> OwnerResult:
173
+ return self._delete_all(context, phase="compensate")
174
+
175
+ def erase(self, context: OperationContext) -> OwnerErasureProof:
176
+ result = self._delete_all(context, phase="erase")
177
+ residue = self._physical_present(context) & set(context.fact_ids)
178
+ detail: dict[str, Any] = {} if result.ok else dict(result.detail)
179
+ if residue:
180
+ detail = {**detail, "residue": sorted(residue)}
181
+ return OwnerErasureProof(
182
+ owner=self._name,
183
+ erased=not residue,
184
+ checksum=_erasure_checksum(self._name, context.fact_ids, residue),
185
+ detail=detail,
186
+ )
187
+
188
+ def _delete_all(self, context: OperationContext, *, phase: str) -> OwnerResult:
189
+ errors: list[str] = []
190
+ for fact_id in context.fact_ids:
191
+ try:
192
+ self._remove(context, fact_id)
193
+ except Exception as exc: # noqa: BLE001
194
+ errors.append(f"{fact_id}: {type(exc).__name__}")
195
+ return OwnerResult(
196
+ owner=self._name,
197
+ ok=not errors,
198
+ detail={} if not errors else {"phase": phase, "errors": errors},
199
+ )
200
+
201
+ def health(self) -> OwnerHealth:
202
+ return OwnerHealth(owner=self._name, healthy=True)
203
+
204
+ def _fact_content(self, context: OperationContext, fact_id: str) -> str | None:
205
+ rows = list(self._db.execute(
206
+ "SELECT content FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
207
+ (fact_id, context.profile_id),
208
+ ))
209
+ if not rows:
210
+ return None
211
+ try:
212
+ return dict(rows[0])["content"]
213
+ except (KeyError, ValueError, TypeError):
214
+ try:
215
+ return rows[0]["content"]
216
+ except Exception: # noqa: BLE001
217
+ return None
218
+
219
+
220
+ class Bm25Owner(_FactScopedOwner):
221
+ _name = "bm25"
222
+
223
+ def __init__(self, db: Any, *, retrieval: Any = None) -> None:
224
+ super().__init__(db)
225
+ self._retrieval = retrieval
226
+
227
+ def _fingerprints(self, context: OperationContext) -> dict[str, str]:
228
+ if not context.fact_ids:
229
+ return {}
230
+ from superlocalmemory.retrieval.bm25_channel import tokenize
231
+
232
+ rows = self._db.execute(
233
+ f"SELECT fact_id, tokens FROM bm25_tokens WHERE profile_id = ? "
234
+ f"AND fact_id IN ({_placeholders(len(context.fact_ids))})",
235
+ (context.profile_id, *context.fact_ids),
236
+ )
237
+ stored: dict[str, list[str]] = {}
238
+ for row in rows:
239
+ record = dict(row)
240
+ fid = record.get("fact_id")
241
+ if fid is None:
242
+ continue
243
+ try:
244
+ tokens = json.loads(record["tokens"])
245
+ except (TypeError, KeyError, json.JSONDecodeError):
246
+ continue
247
+ if not isinstance(tokens, list) or not tokens:
248
+ continue
249
+ if not all(isinstance(token, str) for token in tokens):
250
+ continue
251
+ stored[fid] = tokens
252
+ result: dict[str, str] = {}
253
+ for fact_id, tokens in stored.items():
254
+ content = self._fact_content(context, fact_id)
255
+ if content is None:
256
+ continue
257
+ expected = sorted(tokenize(content))
258
+ if not expected or sorted(tokens) != expected:
259
+ continue
260
+ result[fact_id] = _fingerprint(
261
+ "bm25", json.dumps(expected, separators=(",", ":"))
262
+ )
263
+ return result
264
+
265
+ def _physical_present(self, context: OperationContext) -> set[str]:
266
+ if not context.fact_ids:
267
+ return set()
268
+ rows = self._db.execute(
269
+ f"SELECT fact_id FROM bm25_tokens WHERE profile_id = ? "
270
+ f"AND fact_id IN ({_placeholders(len(context.fact_ids))})",
271
+ (context.profile_id, *context.fact_ids),
272
+ )
273
+ return _present_fact_ids(rows, set(context.fact_ids))
274
+
275
+ def _heal(self, context: OperationContext, fact_id: str) -> bool:
276
+ bm25 = getattr(self._retrieval, "_bm25", None)
277
+ content = self._fact_content(context, fact_id)
278
+ if bm25 is None or content is None:
279
+ return False
280
+ if hasattr(bm25, "update_fact"):
281
+ bm25.update_fact(fact_id, content, context.profile_id)
282
+ return True
283
+ if hasattr(bm25, "add") and hasattr(bm25, "remove_fact"):
284
+ bm25.remove_fact(fact_id)
285
+ bm25.add(fact_id, content, context.profile_id)
286
+ return True
287
+ return False
288
+
289
+ def _remove(self, context: OperationContext, fact_id: str) -> None:
290
+ self._db.delete_bm25_tokens_for_fact(fact_id)
291
+ bm25 = getattr(self._retrieval, "_bm25", None)
292
+ if bm25 is not None and hasattr(bm25, "remove_fact"):
293
+ bm25.remove_fact(fact_id)
294
+
295
+
296
+ class TemporalOwner(_FactScopedOwner):
297
+ _name = "temporal"
298
+
299
+ def _fingerprints(self, context: OperationContext) -> dict[str, str]:
300
+ if not context.fact_ids:
301
+ return {}
302
+ rows = self._db.execute(
303
+ f"SELECT fact_id, valid_from, valid_until FROM fact_temporal_validity "
304
+ f"WHERE profile_id = ? AND fact_id IN ({_placeholders(len(context.fact_ids))})",
305
+ (context.profile_id, *context.fact_ids),
306
+ )
307
+ result: dict[str, str] = {}
308
+ for row in rows:
309
+ record = dict(row)
310
+ fid = record.get("fact_id")
311
+ if fid is None or record.get("valid_from") is None:
312
+ continue
313
+ result[fid] = _fingerprint(
314
+ "temporal",
315
+ str(record["valid_from"]),
316
+ str(record.get("valid_until") or ""),
317
+ )
318
+ return result
319
+
320
+ def _physical_present(self, context: OperationContext) -> set[str]:
321
+ if not context.fact_ids:
322
+ return set()
323
+ rows = self._db.execute(
324
+ f"SELECT fact_id FROM fact_temporal_validity WHERE profile_id = ? "
325
+ f"AND fact_id IN ({_placeholders(len(context.fact_ids))})",
326
+ (context.profile_id, *context.fact_ids),
327
+ )
328
+ return _present_fact_ids(rows, set(context.fact_ids))
329
+
330
+ def _heal(self, context: OperationContext, fact_id: str) -> bool:
331
+ rows = self._db.execute(
332
+ "SELECT created_at FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
333
+ (fact_id, context.profile_id),
334
+ )
335
+ if not rows:
336
+ return False
337
+ valid_from = dict(rows[0]).get("created_at")
338
+ if not valid_from:
339
+ return False
340
+ self._db.store_temporal_validity(fact_id, context.profile_id, valid_from)
341
+ return True
342
+
343
+ def _remove(self, context: OperationContext, fact_id: str) -> None:
344
+ self._db.delete_temporal_validity(fact_id)
345
+
346
+
347
+ class VectorOwner(_FactScopedOwner):
348
+ _name = "vector"
349
+
350
+ def __init__(
351
+ self, db: Any, *, vector_store: Any = None, ann_index: Any = None,
352
+ ) -> None:
353
+ super().__init__(db)
354
+ self._vector_store = vector_store
355
+ self._ann_index = ann_index
356
+
357
+ def _store_available(self) -> bool:
358
+ return self._vector_store is not None and bool(
359
+ getattr(self._vector_store, "available", False)
360
+ )
361
+
362
+ def _embedded_fact_ids(self, context: OperationContext) -> set[str]:
363
+ """Query which facts have embeddings, regardless of store availability.
364
+
365
+ Raises whatever exception the DB raises — callers that need fail-closed
366
+ behaviour must handle (or propagate) the exception. Do NOT swallow
367
+ here: a silent empty-set return would let verify() produce a vacuous
368
+ NOT_APPLICABLE, masking a real DB fault as "no obligation".
369
+ """
370
+ if not context.fact_ids:
371
+ return set()
372
+ rows = self._db.execute(
373
+ f"SELECT fact_id FROM atomic_facts WHERE profile_id = ? "
374
+ f"AND embedding IS NOT NULL AND embedding != '' "
375
+ f"AND fact_id IN ({_placeholders(len(context.fact_ids))})",
376
+ (context.profile_id, *context.fact_ids),
377
+ )
378
+ found = {fid for fid in (_row_fact_id(row) for row in rows) if fid is not None}
379
+ return found - self._tombstoned(context)
380
+
381
+ def _required(self, context: OperationContext) -> set[str]:
382
+ if not context.fact_ids:
383
+ return set()
384
+ embedded = self._embedded_fact_ids(context)
385
+ if not embedded or not self._store_available():
386
+ return set()
387
+ return embedded
388
+
389
+ def verify(self, context: OperationContext) -> OwnerResult:
390
+ """Return NOT_APPLICABLE (ok=True) when no facts have embeddings.
391
+
392
+ Return REQUIRED_UNAVAILABLE (ok=False, detail.required_unavailable=True)
393
+ when embedded facts exist but the vector store is unavailable, OR when
394
+ the embedding DB query itself fails (fail-closed: unknown state is never
395
+ treated as "no obligation").
396
+
397
+ Return normal fingerprint-based result when the store is available.
398
+ """
399
+ try:
400
+ embedded = self._embedded_fact_ids(context)
401
+ except Exception: # noqa: BLE001
402
+ # DB query failure — we cannot determine whether embeddings exist.
403
+ # Fail closed: surface as REQUIRED_UNAVAILABLE so the obligation is
404
+ # recorded as unverified rather than silently waived.
405
+ return OwnerResult(
406
+ owner=self._name,
407
+ ok=False,
408
+ detail={"required_unavailable": True, "db_query_failed": True},
409
+ )
410
+ if not embedded:
411
+ return OwnerResult(
412
+ owner=self._name,
413
+ ok=True,
414
+ checksum=_scope_checksum(self._name, {}),
415
+ detail={"not_applicable": True},
416
+ )
417
+ if not self._store_available():
418
+ return OwnerResult(
419
+ owner=self._name,
420
+ ok=False,
421
+ detail={"required_unavailable": True, "fact_count": len(embedded)},
422
+ )
423
+ return self._result(context)
424
+
425
+ def _fingerprints(self, context: OperationContext) -> dict[str, str]:
426
+ if not context.fact_ids or not self._store_available():
427
+ return {}
428
+ indexed = self._vector_store.indexed_fact_ids(context.profile_id)
429
+ candidates = [fid for fid in context.fact_ids if fid in indexed]
430
+ if not candidates:
431
+ return {}
432
+ rows = self._db.execute(
433
+ f"SELECT fact_id, embedding FROM atomic_facts WHERE profile_id = ? "
434
+ f"AND fact_id IN ({_placeholders(len(candidates))})",
435
+ (context.profile_id, *candidates),
436
+ )
437
+ result: dict[str, str] = {}
438
+ for row in rows:
439
+ record = dict(row)
440
+ fid = record.get("fact_id")
441
+ embedding = record.get("embedding")
442
+ if fid is None or not embedding:
443
+ continue
444
+ result[fid] = _fingerprint("vector", str(embedding))
445
+ return result
446
+
447
+ def _table_present(
448
+ self, table: str, context: OperationContext, targets: set[str],
449
+ ) -> set[str]:
450
+ if not _db_table_exists(self._db, table):
451
+ return set()
452
+ try:
453
+ rows = self._db.execute(
454
+ f"SELECT fact_id FROM {table} WHERE profile_id = ? "
455
+ f"AND fact_id IN ({_placeholders(len(context.fact_ids))})",
456
+ (context.profile_id, *context.fact_ids),
457
+ )
458
+ except Exception: # noqa: BLE001
459
+ return set(targets)
460
+ return _present_fact_ids(rows, targets)
461
+
462
+ def _physical_present(self, context: OperationContext) -> set[str]:
463
+ if not context.fact_ids:
464
+ return set()
465
+ targets = set(context.fact_ids)
466
+ present: set[str] = set()
467
+ present |= self._table_present("embedding_metadata", context, targets)
468
+ present |= self._table_present("vector_row_map", context, targets)
469
+ store = self._vector_store
470
+ if store is not None and hasattr(store, "raw_vector_present"):
471
+ for fid in targets - present:
472
+ try:
473
+ if store.raw_vector_present(fid):
474
+ present.add(fid)
475
+ except Exception: # noqa: BLE001
476
+ present.add(fid)
477
+ ann = self._ann_index
478
+ if ann is not None and hasattr(ann, "contains"):
479
+ for fid in targets - present:
480
+ try:
481
+ if ann.contains(fid):
482
+ present.add(fid)
483
+ except Exception: # noqa: BLE001
484
+ present.add(fid)
485
+ return present
486
+
487
+ def _heal(self, context: OperationContext, fact_id: str) -> bool:
488
+ if not self._store_available():
489
+ return False
490
+ rows = self._db.execute(
491
+ "SELECT embedding FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
492
+ (fact_id, context.profile_id),
493
+ )
494
+ if not rows:
495
+ return False
496
+ raw = dict(rows[0]).get("embedding")
497
+ if not raw:
498
+ return False
499
+ try:
500
+ embedding = json.loads(raw)
501
+ except (TypeError, json.JSONDecodeError):
502
+ return False
503
+ if not isinstance(embedding, list) or not embedding:
504
+ return False
505
+ ok = self._vector_store.upsert(
506
+ fact_id=fact_id, profile_id=context.profile_id, embedding=embedding,
507
+ )
508
+ if ok and self._ann_index is not None and hasattr(self._ann_index, "add"):
509
+ try:
510
+ self._ann_index.add(fact_id, embedding)
511
+ except Exception: # noqa: BLE001
512
+ pass
513
+ return bool(ok)
514
+
515
+ def _remove(self, context: OperationContext, fact_id: str) -> None:
516
+ if self._vector_store is not None and hasattr(self._vector_store, "delete"):
517
+ self._vector_store.delete(fact_id)
518
+ else:
519
+ self._db.execute(
520
+ "DELETE FROM embedding_metadata WHERE fact_id = ?", (fact_id,)
521
+ )
522
+ if self._ann_index is not None and hasattr(self._ann_index, "remove"):
523
+ self._ann_index.remove(fact_id)
524
+
525
+
526
+ def _admission_owners(engine: Any) -> dict[str, Any]:
527
+ db = engine._db
528
+ retrieval = getattr(engine, "_retrieval_engine", None)
529
+ return {
530
+ "bm25": Bm25Owner(db, retrieval=retrieval),
531
+ "temporal": TemporalOwner(db),
532
+ "vector": VectorOwner(
533
+ db,
534
+ vector_store=getattr(engine, "_vector_store", None),
535
+ ann_index=getattr(engine, "_ann_index", None),
536
+ ),
537
+ }
538
+
539
+
540
+ def build_transaction_service(engine: Any) -> MemoryTransactionService:
541
+ return MemoryTransactionService(_admission_owners(engine))
542
+
543
+
544
+ def build_erasure_service(engine: Any) -> ErasureService:
545
+ audit_logger = _audit_chain_logger()
546
+ return ErasureService(_admission_owners(engine), audit_logger=audit_logger)
547
+
548
+
549
+ def build_erasure_service_for_db(db: Any, engine: Any = None) -> ErasureService:
550
+ """Build an ErasureService given a db wrapper and an optional engine.
551
+
552
+ Used by GDPR erasure paths that have a db handle but may not have an
553
+ engine reference (e.g. entity erase from GDPRCompliance).
554
+ """
555
+ audit_logger = _audit_chain_logger()
556
+ owners: dict[str, Any] = {
557
+ "bm25": Bm25Owner(db),
558
+ "temporal": TemporalOwner(db),
559
+ "vector": VectorOwner(
560
+ db,
561
+ vector_store=getattr(engine, "_vector_store", None),
562
+ ann_index=getattr(engine, "_ann_index", None),
563
+ ),
564
+ }
565
+ return ErasureService(owners, audit_logger=audit_logger)
566
+
567
+
568
+ def _audit_chain_logger() -> Any:
569
+ def _log(event: dict[str, Any]) -> None:
570
+ from superlocalmemory.compliance.audit import AuditChain
571
+ from superlocalmemory.infra.data_root import state_path
572
+
573
+ AuditChain(str(state_path("audit_chain.db"))).log(
574
+ "projection_erase",
575
+ agent_id=str(event.get("requested_by", "")),
576
+ profile_id=str(event.get("profile_id", "")),
577
+ content_hash=str(event.get("audit_hash", "")),
578
+ metadata={
579
+ "erasure_id": event.get("erasure_id", ""),
580
+ "subject_type": event.get("subject_type", ""),
581
+ "subject_id": event.get("subject_id", ""),
582
+ "state": event.get("state", ""),
583
+ },
584
+ )
585
+
586
+ return _log
587
+
588
+
589
+ __all__ = [
590
+ "REQUIRED_ADMISSION_OWNERS",
591
+ "Bm25Owner",
592
+ "TemporalOwner",
593
+ "VectorOwner",
594
+ "build_erasure_service",
595
+ "build_erasure_service_for_db",
596
+ "build_transaction_service",
597
+ ]