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,286 @@
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 | https://varunpratap.com
4
+
5
+ """LWW remote state convergence for the SLM mesh (3c-1).
6
+
7
+ Convergence guarantee
8
+ ---------------------
9
+ Leaderless, pull-based, deterministic Last-Writer-Wins. Every node that
10
+ exchanges deltas converges on the same ``(value, revision, node_id)`` for
11
+ every key.
12
+
13
+ This is NOT linearizable consensus. Concurrent writes can interleave; the
14
+ winner is chosen deterministically by a TOTAL ORDER on ``(revision: int,
15
+ node_id: str)``. Same revision → higher ``node_id`` wins (lexicographic).
16
+ Because the order is total, every node independently computes the same
17
+ winner given the same set of writes.
18
+
19
+ Backward compatibility
20
+ ----------------------
21
+ ``set_state`` in the broker never sets ``origin_node``; rows it writes have
22
+ ``origin_node = ''`` (the column default added here). At merge and
23
+ serialization time ``''`` is interpreted as the LOCAL node_id, so existing
24
+ rows participate correctly in the LWW protocol without any change to
25
+ ``broker.py``.
26
+
27
+ Author: Varun Pratap Bhardwaj
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import logging
33
+ import sqlite3
34
+ from typing import Any
35
+
36
+ from superlocalmemory.mesh.node_identity import get_node_id
37
+
38
+ logger = logging.getLogger("superlocalmemory.mesh.state_sync")
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Schema migration (additive, idempotent)
42
+ # ---------------------------------------------------------------------------
43
+
44
+ _ALTER_ORIGIN_NODE = (
45
+ "ALTER TABLE mesh_state ADD COLUMN origin_node TEXT NOT NULL DEFAULT ''"
46
+ )
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # StateSyncer
51
+ # ---------------------------------------------------------------------------
52
+
53
+
54
+ class StateSyncer:
55
+ """Pull-based LWW convergence helper for the ``mesh_state`` table.
56
+
57
+ Designed to be instantiated on-demand (route handler, sync loop); all DB
58
+ connections are short-lived. Every operation is fail-soft: errors are
59
+ logged and the caller never receives an unhandled exception from here.
60
+ """
61
+
62
+ def __init__(self, broker: Any) -> None:
63
+ self._broker = broker
64
+ self._db_path: str = str(broker._db_path)
65
+ # Resolved once; stable for the lifetime of this instance.
66
+ self._node_id: str = get_node_id(self._db_path)
67
+ self._ensure_origin_node_column()
68
+
69
+ # ------------------------------------------------------------------
70
+ # Schema bootstrap
71
+ # ------------------------------------------------------------------
72
+
73
+ def _ensure_origin_node_column(self) -> None:
74
+ """Add ``origin_node`` to ``mesh_state``; no-op if already present."""
75
+ try:
76
+ conn = sqlite3.connect(self._db_path, timeout=5.0)
77
+ try:
78
+ conn.execute(_ALTER_ORIGIN_NODE)
79
+ conn.commit()
80
+ except sqlite3.OperationalError as exc:
81
+ if "duplicate column" in str(exc).lower():
82
+ pass # Expected on second startup / upgrade
83
+ else:
84
+ logger.error(
85
+ "StateSyncer._ensure_origin_node_column: unexpected "
86
+ "OperationalError at %s: %s",
87
+ self._db_path, exc,
88
+ )
89
+ finally:
90
+ conn.close()
91
+ except sqlite3.Error as exc:
92
+ logger.error(
93
+ "StateSyncer._ensure_origin_node_column: DB error at %s: %s",
94
+ self._db_path, exc,
95
+ )
96
+
97
+ # ------------------------------------------------------------------
98
+ # Internal helpers
99
+ # ------------------------------------------------------------------
100
+
101
+ def _effective_node(self, stored_origin: str) -> str:
102
+ """Resolve ``origin_node=''`` (BC rows) to this node's id at call time."""
103
+ return stored_origin if stored_origin else self._node_id
104
+
105
+ def _open_conn(self) -> sqlite3.Connection:
106
+ conn = sqlite3.connect(self._db_path, timeout=5.0)
107
+ conn.row_factory = sqlite3.Row
108
+ return conn
109
+
110
+ # ------------------------------------------------------------------
111
+ # Public API
112
+ # ------------------------------------------------------------------
113
+
114
+ def local_delta(
115
+ self,
116
+ profile_id: str = "default",
117
+ since_revision: int = 0,
118
+ ) -> list[dict]:
119
+ """Return rows with ``revision > since_revision`` for a profile.
120
+
121
+ The ``node_id`` field in each entry reflects the *effective* node:
122
+ the stored ``origin_node`` when non-empty, or the local node_id for
123
+ BC rows written by the broker (``origin_node = ''``).
124
+
125
+ Returns ``[]`` on any DB error (fail-soft).
126
+ """
127
+ try:
128
+ conn = self._open_conn()
129
+ try:
130
+ rows = conn.execute(
131
+ "SELECT key, value, set_by, updated_at,"
132
+ " COALESCE(revision, 0) AS revision,"
133
+ " COALESCE(origin_node, '') AS origin_node"
134
+ " FROM mesh_state"
135
+ " WHERE profile_id = ? AND COALESCE(revision, 0) > ?",
136
+ (profile_id, int(since_revision)),
137
+ ).fetchall()
138
+ return [
139
+ {
140
+ "key": row["key"],
141
+ "value": row["value"],
142
+ "set_by": row["set_by"],
143
+ "updated_at": row["updated_at"],
144
+ "revision": int(row["revision"]),
145
+ "node_id": self._effective_node(row["origin_node"]),
146
+ }
147
+ for row in rows
148
+ ]
149
+ finally:
150
+ conn.close()
151
+ except sqlite3.Error as exc:
152
+ logger.error(
153
+ "StateSyncer.local_delta: DB error at %s: %s",
154
+ self._db_path, exc,
155
+ )
156
+ return []
157
+
158
+ def merge_remote(
159
+ self,
160
+ profile_id: str,
161
+ remote_entries: list[dict],
162
+ ) -> dict:
163
+ """Merge remote delta entries using deterministic LWW.
164
+
165
+ For each entry ``{key, value, set_by, updated_at, revision, node_id}``:
166
+
167
+ * If ``(remote_rev, remote_node) > (local_rev, local_node)`` (total
168
+ order): UPSERT the local row. The winning ``revision`` is preserved
169
+ as-is — never incremented — so re-merging the same delta is a no-op
170
+ (idempotent convergence).
171
+ * Otherwise: do nothing.
172
+
173
+ Returns ``{"applied": N, "skipped": M}``. Errors per-entry are
174
+ logged and counted as skipped; they never propagate to the caller.
175
+ """
176
+ applied = 0
177
+ skipped = 0
178
+ for entry in remote_entries:
179
+ # Compute a safe log key BEFORE the try so a non-dict entry can
180
+ # never make the error handler itself raise (audit P0/P2 — the
181
+ # handler previously called entry.get() on possibly-non-dict).
182
+ entry_key = entry.get("key") if isinstance(entry, dict) else repr(entry)
183
+ try:
184
+ if self._merge_one(profile_id, entry):
185
+ applied += 1
186
+ else:
187
+ skipped += 1
188
+ except sqlite3.Error as exc:
189
+ logger.error(
190
+ "StateSyncer.merge_remote: DB error on key=%r: %s",
191
+ entry_key, exc,
192
+ )
193
+ skipped += 1
194
+ except (KeyError, TypeError, ValueError, AttributeError) as exc:
195
+ logger.error(
196
+ "StateSyncer.merge_remote: malformed entry key=%r: %s",
197
+ entry_key, exc,
198
+ )
199
+ skipped += 1
200
+ return {"applied": applied, "skipped": skipped}
201
+
202
+ def _merge_one(self, profile_id: str, entry: dict) -> bool:
203
+ """Apply one remote entry if it wins the LWW comparison.
204
+
205
+ Returns ``True`` if the local row was updated; ``False`` if local
206
+ won or the comparison was a tie (same revision, same node_id).
207
+
208
+ CRITICAL: ``revision`` is never incremented here. Preserving the
209
+ winning revision is what makes convergence idempotent — a second
210
+ merge of the same delta computes the identical comparison result and
211
+ takes no action.
212
+ """
213
+ # Audit P0/P2: a non-dict entry must never crash the merge.
214
+ if not isinstance(entry, dict):
215
+ return False
216
+ key = str(entry["key"])
217
+ # Guard: cast revision to int to prevent string lexicographic miscompare
218
+ # (e.g. "10" < "9" as strings but 10 > 9 as ints).
219
+ remote_rev: int = int(entry["revision"])
220
+ remote_node: str = str(entry.get("node_id", "")).strip()
221
+ # Audit P1: an empty node_id would be stored as origin_node='' and then
222
+ # re-exported under THIS node's id, rewriting provenance and corrupting
223
+ # the tie-break. Refuse an entry without a real origin.
224
+ if not remote_node:
225
+ return False
226
+ # Audit P2: never persist a NULL value as the literal string "None".
227
+ if entry.get("value") is None:
228
+ return False
229
+
230
+ conn = self._open_conn()
231
+ # Audit P1 (TOCTOU): the read-compare-write MUST be atomic against a
232
+ # concurrent broker.set_state on the same key. Without a write lock, a
233
+ # stale local read lets a lower remote revision overwrite a fresher
234
+ # local write (losing local-highest-revision). BEGIN IMMEDIATE takes the
235
+ # write lock for the whole critical section.
236
+ conn.isolation_level = None # manual transaction control
237
+ try:
238
+ conn.execute("BEGIN IMMEDIATE")
239
+ row = conn.execute(
240
+ "SELECT COALESCE(revision, 0) AS revision,"
241
+ " COALESCE(origin_node, '') AS origin_node"
242
+ " FROM mesh_state"
243
+ " WHERE profile_id = ? AND key = ?",
244
+ (profile_id, key),
245
+ ).fetchone()
246
+
247
+ if row is None:
248
+ local_rev: int = 0
249
+ local_node: str = "" # No row — treat as the absolute minimum
250
+ else:
251
+ local_rev = int(row["revision"])
252
+ local_node = self._effective_node(row["origin_node"])
253
+
254
+ # Total order: compare revision (int) first; break ties by node_id (str lex).
255
+ # A strict > means ties-to-local (same rev, same node) → do nothing (idempotent).
256
+ remote_wins: bool = (remote_rev, remote_node) > (local_rev, local_node)
257
+ if not remote_wins:
258
+ conn.execute("ROLLBACK")
259
+ return False
260
+
261
+ # UPSERT: set origin_node = remote_node so subsequent merges of the
262
+ # same delta compute the same winner without re-resolving local node_id.
263
+ conn.execute(
264
+ "INSERT INTO mesh_state"
265
+ " (profile_id, key, value, set_by, updated_at, revision, origin_node)"
266
+ " VALUES (?, ?, ?, ?, ?, ?, ?)"
267
+ " ON CONFLICT(profile_id, key) DO UPDATE SET"
268
+ " value = excluded.value,"
269
+ " set_by = excluded.set_by,"
270
+ " updated_at = excluded.updated_at,"
271
+ " revision = excluded.revision,"
272
+ " origin_node = excluded.origin_node",
273
+ (
274
+ profile_id,
275
+ key,
276
+ str(entry["value"]),
277
+ str(entry["set_by"]),
278
+ str(entry["updated_at"]),
279
+ remote_rev,
280
+ remote_node,
281
+ ),
282
+ )
283
+ conn.execute("COMMIT")
284
+ return True
285
+ finally:
286
+ conn.close()
@@ -13,10 +13,12 @@ and self._version. get() acquires a read-lock; save() acquires a write-lock.
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
+ import atexit
16
17
  import json
17
18
  import logging
18
19
  import os
19
20
  import threading
21
+ import weakref
20
22
  from pathlib import Path
21
23
  from typing import Any, Callable
22
24
 
@@ -29,6 +31,43 @@ logger = logging.getLogger(__name__)
29
31
  _DEFAULT_CONFIG_PATH = DynamicStatePath("optimize.json")
30
32
  _POLL_INTERVAL_SECONDS: float = 2.0
31
33
 
34
+ # ---- watchdog shutdown safety -------------------------------------------
35
+ # Each started watchdog runs in a daemon thread. At interpreter shutdown CPython
36
+ # kills daemon threads abruptly; on macOS, with the ``watchdog`` fsevents C
37
+ # extension also loaded in-process, a daemon thread still executing while the
38
+ # interpreter finalizes can race the teardown and SIGSEGV. We track every store
39
+ # that has a live watchdog and stop+join them from an ``atexit`` hook — atexit
40
+ # callbacks run BEFORE daemon threads are terminated, so no watchdog thread is
41
+ # still alive when CPython finalizes.
42
+ _active_stores: "weakref.WeakSet[ConfigStore]" = weakref.WeakSet()
43
+ _registry_lock = threading.Lock()
44
+ _atexit_registered = False
45
+
46
+
47
+ def _register_active_store(store: "ConfigStore") -> None:
48
+ """Track a store with a running watchdog; install the atexit hook once."""
49
+ global _atexit_registered
50
+ with _registry_lock:
51
+ _active_stores.add(store)
52
+ if not _atexit_registered:
53
+ atexit.register(_stop_all_watchdogs)
54
+ _atexit_registered = True
55
+
56
+
57
+ def _stop_all_watchdogs() -> None:
58
+ """Stop+join every registered watchdog. Idempotent; safe at interpreter exit.
59
+
60
+ Also invoked by the test harness at session finish (see tests/conftest.py)
61
+ so no leaked watchdog thread survives into interpreter finalization.
62
+ """
63
+ with _registry_lock:
64
+ stores = list(_active_stores)
65
+ for store in stores:
66
+ try:
67
+ store.stop_watchdog()
68
+ except Exception:
69
+ pass
70
+
32
71
 
33
72
  class ConfigStore:
34
73
  """Manages optimize.json with hot-reload capability."""
@@ -125,6 +164,10 @@ class ConfigStore:
125
164
  daemon=True,
126
165
  )
127
166
  self._watchdog_thread.start()
167
+ # Register outside self._lock — _register_active_store takes the module
168
+ # registry lock, and the idempotent early-return above already covers a
169
+ # re-start (the store stays registered from the first start).
170
+ _register_active_store(self)
128
171
 
129
172
  def stop_watchdog(self) -> None:
130
173
  """Signal the watchdog thread to stop and join it (timeout=5s)."""
@@ -134,6 +177,8 @@ class ConfigStore:
134
177
  t.join(timeout=5.0)
135
178
  with self._lock:
136
179
  self._watchdog_thread = None
180
+ with _registry_lock:
181
+ _active_stores.discard(self)
137
182
 
138
183
  def version(self) -> int:
139
184
  with self._lock:
@@ -0,0 +1,12 @@
1
+ """Cross-project preference aggregation for the parameterization subsystem.
2
+
3
+ The aggregator implementation lives in
4
+ :mod:`superlocalmemory.learning.cross_project`. It is re-exported here so the
5
+ parameterization namespace — where the pattern extractor consumes it — resolves
6
+ to the same class instead of a missing module.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from superlocalmemory.learning.cross_project import CrossProjectAggregator
11
+
12
+ __all__ = ["CrossProjectAggregator"]
@@ -77,24 +77,26 @@ class PromptInjector:
77
77
 
78
78
  templates: list[SoftPromptTemplate] = []
79
79
  for row in rows:
80
- raw_ids = row.get("source_pattern_ids", "[]")
80
+ # DatabaseManager returns sqlite3.Row which lacks .get() — convert to dict.
81
+ r = dict(row)
82
+ raw_ids = r.get("source_pattern_ids", "[]")
81
83
  try:
82
84
  source_ids = json.loads(raw_ids) if isinstance(raw_ids, str) else raw_ids
83
85
  except (json.JSONDecodeError, TypeError):
84
86
  source_ids = []
85
87
 
86
88
  templates.append(SoftPromptTemplate(
87
- prompt_id=row["prompt_id"],
88
- profile_id=row.get("profile_id", profile_id),
89
- category=row["category"],
90
- content=row["content"],
89
+ prompt_id=r["prompt_id"],
90
+ profile_id=r.get("profile_id", profile_id),
91
+ category=r["category"],
92
+ content=r["content"],
91
93
  source_pattern_ids=source_ids,
92
- confidence=row["confidence"],
93
- effectiveness=row.get("effectiveness", 0.5),
94
- token_count=row.get("token_count", 0),
95
- retention_score=row.get("retention_score", 1.0),
96
- active=bool(row.get("active", 1)),
97
- version=row.get("version", 1),
94
+ confidence=r["confidence"],
95
+ effectiveness=r.get("effectiveness", 0.5),
96
+ token_count=r.get("token_count", 0),
97
+ retention_score=r.get("retention_score", 1.0),
98
+ active=bool(r.get("active", 1)),
99
+ version=r.get("version", 1),
98
100
  ))
99
101
 
100
102
  # Budget enforcement
@@ -45,12 +45,13 @@ class PromptLifecycleManager:
45
45
  def __init__(
46
46
  self,
47
47
  db: DatabaseManager,
48
- ebbinghaus: EbbinghausCurve,
49
48
  config: ParameterizationConfig,
49
+ *,
50
+ ebbinghaus: EbbinghausCurve | None = None,
50
51
  ) -> None:
51
52
  self._db = db
52
- self._ebbinghaus = ebbinghaus
53
53
  self._config = config
54
+ self._ebbinghaus = ebbinghaus
54
55
 
55
56
  # ------------------------------------------------------------------
56
57
  # Effectiveness tracking
@@ -157,6 +158,11 @@ class PromptLifecycleManager:
157
158
  s_raw = 2.0 * effectiveness * version
158
159
  s_prompt = max(_PROMPT_STRENGTH_FLOOR, s_raw)
159
160
 
161
+ if self._ebbinghaus is None:
162
+ from superlocalmemory.math.ebbinghaus import EbbinghausCurve
163
+ from superlocalmemory.core.config import ForgettingConfig
164
+ self._ebbinghaus = EbbinghausCurve(ForgettingConfig())
165
+
160
166
  retention = self._ebbinghaus.retention(hours, s_prompt)
161
167
  return retention
162
168
 
@@ -0,0 +1,17 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Workflow miner re-export for the parameterization namespace.
6
+
7
+ The canonical implementation lives in
8
+ :mod:`superlocalmemory.learning.workflows`. This thin module re-exports
9
+ ``WorkflowMiner`` so that code in the parameterization subsystem can import
10
+ from the same namespace as the pattern extractor's dependency declaration.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from superlocalmemory.learning.workflows import WorkflowMiner
16
+
17
+ __all__ = ["WorkflowMiner"]
@@ -58,6 +58,11 @@ class ANNIndex:
58
58
  """Embedding dimension this index was created for."""
59
59
  return self._dim
60
60
 
61
+ def contains(self, fact_id: str) -> bool:
62
+ """Return True if the index currently holds this fact."""
63
+ with self._lock:
64
+ return fact_id in self._id_to_idx
65
+
61
66
  # ------------------------------------------------------------------
62
67
  # Mutation
63
68
  # ------------------------------------------------------------------
@@ -221,11 +221,19 @@ class BM25Channel:
221
221
  include_shared=include_shared,
222
222
  prefix="af",
223
223
  )
224
+ # Archived facts are not live; never surface them in keyword recall.
225
+ # Guarded on column presence: the archive column is added by a deferred
226
+ # migration and may be absent on an unmigrated database.
227
+ archive_clause = (
228
+ " AND COALESCE(af.archive_status, 'live') != 'archived'"
229
+ if self._db._has_archive_status()
230
+ else ""
231
+ )
224
232
  sql = (
225
233
  "SELECT af.fact_id AS fact_id, bm25(atomic_facts_fts) AS rank "
226
234
  "FROM atomic_facts_fts "
227
235
  "JOIN atomic_facts af ON af.rowid = atomic_facts_fts.rowid "
228
- f"WHERE atomic_facts_fts MATCH ? AND {where} "
236
+ f"WHERE atomic_facts_fts MATCH ? AND {where}{archive_clause} "
229
237
  "ORDER BY rank LIMIT ?"
230
238
  )
231
239
  rows = self._db.execute(sql, (match_expr, *params, int(top_k)))
@@ -248,7 +256,7 @@ class BM25Channel:
248
256
  "SELECT af.fact_id AS fact_id, bm25(fact_expansion_fts) AS rank "
249
257
  "FROM fact_expansion_fts "
250
258
  "JOIN atomic_facts af ON af.fact_id = fact_expansion_fts.fact_id "
251
- f"WHERE fact_expansion_fts MATCH ? AND {where} "
259
+ f"WHERE fact_expansion_fts MATCH ? AND {where}{archive_clause} "
252
260
  "ORDER BY rank LIMIT ?"
253
261
  )
254
262
  for r in self._db.execute(exp_sql, (match_expr, *params, int(top_k))):
@@ -336,6 +344,45 @@ class BM25Channel:
336
344
  scored.sort(key=lambda x: x[1], reverse=True)
337
345
  return scored[:top_k]
338
346
 
347
+ def update_fact(self, fact_id: str, new_content: str, profile_id: str) -> None:
348
+ """Replace a fact's representation in the live index and persist new tokens.
349
+
350
+ Removes any existing in-memory entry for the fact_id first so the index
351
+ holds each fact exactly once after the call. Delegates to remove_fact +
352
+ add so the two operations stay in sync.
353
+
354
+ Args:
355
+ fact_id: Fact whose content has changed.
356
+ new_content: Updated text to index.
357
+ profile_id: Owner profile (used for DB token persistence).
358
+ """
359
+ self.remove_fact(fact_id)
360
+ self.add(fact_id, new_content, profile_id)
361
+
362
+ def remove_fact(self, fact_id: str) -> None:
363
+ """Remove a fact from the live in-memory index.
364
+
365
+ Idempotent: a no-op when the fact is not in the index. Does NOT touch
366
+ the persistent ``bm25_tokens`` DB table — the caller is responsible for
367
+ that (so a delete path can clean up storage independently).
368
+
369
+ Args:
370
+ fact_id: Fact to evict from the in-memory corpus.
371
+ """
372
+ if fact_id not in self._fact_id_set:
373
+ return
374
+ try:
375
+ idx = self._fact_ids.index(fact_id)
376
+ del self._corpus[idx]
377
+ del self._fact_ids[idx]
378
+ raw_texts = getattr(self, "_raw_texts", [])
379
+ if idx < len(raw_texts):
380
+ del raw_texts[idx]
381
+ self._fact_id_set.discard(fact_id)
382
+ self._dirty = True
383
+ except (ValueError, IndexError):
384
+ self._fact_id_set.discard(fact_id)
385
+
339
386
  def clear(self) -> None:
340
387
  """Clear the in-memory index (does NOT delete DB tokens)."""
341
388
  self._corpus = []
@@ -27,12 +27,12 @@ from typing import TYPE_CHECKING, Any, Protocol
27
27
 
28
28
  from superlocalmemory.core.config import ChannelWeights, RetrievalConfig
29
29
  from superlocalmemory.retrieval.fusion import FusionResult, weighted_rrf
30
+ from superlocalmemory.retrieval.strategy import QueryStrategy, QueryStrategyClassifier
30
31
  from superlocalmemory.retrieval.time_window import (
31
32
  in_window,
32
33
  infer_window_from_query,
33
34
  parse_window,
34
35
  )
35
- from superlocalmemory.retrieval.strategy import QueryStrategy, QueryStrategyClassifier
36
36
  from superlocalmemory.storage.models import (
37
37
  AtomicFact,
38
38
  Mode,
@@ -155,6 +155,7 @@ class RetrievalEngine:
155
155
  include_global: bool = False,
156
156
  include_shared: bool = False,
157
157
  window: str | tuple[str, str] | None = None,
158
+ as_of: str | None = None,
158
159
  ) -> RecallResponse:
159
160
  """Full retrieval pipeline: strategy -> channels -> RRF -> rerank.
160
161
 
@@ -165,6 +166,11 @@ class RetrievalEngine:
165
166
  V3.4.40 (2026-05-09): ``extra_disabled_channels`` allows callers to
166
167
  skip specific channels for a single recall (e.g. SpreadingActivation
167
168
  for the ``--fast`` CLI flag) without mutating shared config.
169
+
170
+ ``as_of``: Optional ISO 8601 datetime string. When set, the bi-temporal
171
+ validity filter treats facts as seen from that point in time —
172
+ not-yet-valid and already-expired facts are demoted. Default ``None``
173
+ leaves all existing behaviour unchanged.
168
174
  """
169
175
  t0 = time.monotonic()
170
176
  # NOTE: extra_disabled_channels is passed as an explicit local argument
@@ -212,6 +218,7 @@ class RetrievalEngine:
212
218
  query, profile_id, strat,
213
219
  extra_disabled_channels=extra_disabled_channels,
214
220
  include_global=include_global, include_shared=include_shared,
221
+ as_of=as_of,
215
222
  )
216
223
  _em("run_channels")
217
224
  if profile_hits:
@@ -765,6 +772,7 @@ class RetrievalEngine:
765
772
  extra_disabled_channels: set[str] | None = None,
766
773
  include_global: bool = False,
767
774
  include_shared: bool = False,
775
+ as_of: str | None = None,
768
776
  ) -> dict[str, list[tuple[str, float]]]:
769
777
  """Run active retrieval channels.
770
778
 
@@ -885,7 +893,10 @@ class RetrievalEngine:
885
893
  )
886
894
  for name, fut in futures.items():
887
895
  if fut in pending:
888
- logger.warning("Channel %s exceeded %.1fs latency budget", name, channel_timeout_seconds)
896
+ logger.warning(
897
+ "Channel %s exceeded %.1fs latency budget",
898
+ name, channel_timeout_seconds,
899
+ )
889
900
  continue
890
901
  try:
891
902
  ch_name, result = fut.result()
@@ -894,11 +905,15 @@ class RetrievalEngine:
894
905
  except Exception as exc:
895
906
  logger.warning("Channel %s failed: %s", name, exc)
896
907
 
897
- # Apply registered post-retrieval filters (forgetting filter, etc.)
908
+ # Apply registered post-retrieval filters (forgetting filter, etc.).
909
+ # Pass as_of in context dict when set so the bi-temporal validity filter
910
+ # can perform point-in-time demotion. None context preserves the existing
911
+ # behaviour for all callers that don't use time-travel recall.
912
+ _filter_context = {"as_of": as_of} if as_of is not None else None
898
913
  if hasattr(self, '_registry') and self._registry._filters:
899
914
  for fn in self._registry._filters:
900
915
  try:
901
- out = fn(out, profile_id, None)
916
+ out = fn(out, profile_id, _filter_context)
902
917
  except Exception as exc:
903
918
  logger.warning("Post-retrieval filter failed: %s", exc)
904
919
 
@@ -74,5 +74,8 @@ def weighted_rrf(
74
74
  fused += w / (k + rank)
75
75
  results.append(FusionResult(fid, fused, ch_ranks, ch_scores))
76
76
 
77
- results.sort(key=lambda r: r.fused_score, reverse=True)
77
+ # Deterministic total order: fused score descending, ties broken by fact_id.
78
+ # Sorting on score alone left tied results in set-iteration order, which
79
+ # varies with the process hash seed.
80
+ results.sort(key=lambda r: (-r.fused_score, r.fact_id))
78
81
  return results