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
@@ -16,18 +16,33 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import hashlib
20
+ import itertools
19
21
  import json
20
22
  import logging
21
23
  import os as _os
24
+ import sqlite3
25
+ import tempfile
22
26
  import time
23
27
  from pathlib import Path
24
28
  from typing import TYPE_CHECKING, Any
25
29
 
30
+ import numpy as np
31
+
26
32
  if TYPE_CHECKING:
27
33
  from superlocalmemory.core.config import SLMConfig
28
34
 
29
35
  logger = logging.getLogger(__name__)
30
36
 
37
+
38
+ class EmbeddingMigrationAborted(RuntimeError):
39
+ """Raised when embedding migration aborts; old signature stays active.
40
+
41
+ Distinct from a no-op return of ``0`` (no embedder / no facts). Callers
42
+ must treat this as failure, not success.
43
+ """
44
+
45
+
31
46
  # ---------------------------------------------------------------------------
32
47
  # Backfill constants
33
48
  # ---------------------------------------------------------------------------
@@ -60,6 +75,155 @@ _NO_MODEL = ""
60
75
  _REINDEX_BATCH_SIZE = 50
61
76
 
62
77
 
78
+ def _activate_staged_vectors(
79
+ config: SLMConfig,
80
+ db: Any,
81
+ stage_path: Path,
82
+ expected_count: int,
83
+ ) -> None:
84
+ """Atomically replace canonical and sqlite-vec embeddings from a shadow DB."""
85
+ db_path = getattr(db, "db_path", None)
86
+ if not isinstance(db_path, (str, Path)):
87
+ raise RuntimeError("embedding migration requires an authoritative db_path")
88
+
89
+ import sqlite_vec
90
+
91
+ from superlocalmemory.storage.write_lock import get_write_lock
92
+
93
+ db_path = Path(db_path)
94
+ with get_write_lock(db_path), sqlite3.connect(stage_path) as stage:
95
+ conn = sqlite3.connect(db_path)
96
+ try:
97
+ conn.execute("PRAGMA busy_timeout=10000")
98
+ conn.enable_load_extension(True)
99
+ sqlite_vec.load(conn)
100
+ conn.enable_load_extension(False)
101
+ conn.execute("BEGIN IMMEDIATE")
102
+ canonical = conn.execute(
103
+ "SELECT fact_id, profile_id, content "
104
+ "FROM atomic_facts ORDER BY fact_id"
105
+ )
106
+ staged = stage.execute(
107
+ "SELECT fact_id, profile_id, content_hash "
108
+ "FROM staged_embeddings ORDER BY fact_id"
109
+ )
110
+ for current, shadow in itertools.zip_longest(canonical, staged):
111
+ if current is None or shadow is None:
112
+ raise RuntimeError("canonical fact set changed during migration")
113
+ current_key = (str(current[0]), str(current[1]))
114
+ shadow_key = (str(shadow[0]), str(shadow[1]))
115
+ current_hash = hashlib.sha256(str(current[2]).encode("utf-8")).hexdigest()
116
+ if current_key != shadow_key or current_hash != str(shadow[2]):
117
+ raise RuntimeError(
118
+ f"canonical fact changed during migration: {current_key[0]}"
119
+ )
120
+ conn.execute("DROP TABLE IF EXISTS embedding_metadata")
121
+ conn.execute("DROP TABLE IF EXISTS vector_row_map")
122
+ conn.execute("DROP TABLE IF EXISTS fact_embeddings")
123
+ conn.execute(
124
+ "CREATE VIRTUAL TABLE fact_embeddings USING vec0("
125
+ "profile_id TEXT PARTITION KEY, "
126
+ f"embedding float[{config.embedding.dimension}] distance_metric=cosine)"
127
+ )
128
+ conn.execute(
129
+ "CREATE TABLE embedding_metadata ("
130
+ "vec_rowid INTEGER PRIMARY KEY, fact_id TEXT NOT NULL UNIQUE, "
131
+ "profile_id TEXT NOT NULL DEFAULT 'default', "
132
+ "model_name TEXT NOT NULL DEFAULT '', "
133
+ "dimension INTEGER NOT NULL DEFAULT 768, "
134
+ "created_at TEXT NOT NULL DEFAULT (datetime('now')))"
135
+ )
136
+ conn.execute(
137
+ "CREATE INDEX idx_embmeta_fact ON embedding_metadata (fact_id)"
138
+ )
139
+ conn.execute(
140
+ "CREATE INDEX idx_embmeta_profile ON embedding_metadata (profile_id)"
141
+ )
142
+ conn.execute(
143
+ "CREATE TABLE vector_row_map ("
144
+ "fact_id TEXT NOT NULL PRIMARY KEY, profile_id TEXT NOT NULL, "
145
+ "vec_rowid INTEGER NOT NULL)"
146
+ )
147
+ conn.execute(
148
+ "CREATE INDEX idx_vector_row_map_profile "
149
+ "ON vector_row_map (profile_id)"
150
+ )
151
+
152
+ first_probe: tuple[bytes, str] | None = None
153
+ activated = 0
154
+ for rowid, (fact_id, profile_id, embedding_json) in enumerate(
155
+ stage.execute(
156
+ "SELECT fact_id, profile_id, embedding "
157
+ "FROM staged_embeddings ORDER BY fact_id"
158
+ ),
159
+ start=1,
160
+ ):
161
+ vector = json.loads(embedding_json)
162
+ vec_bytes = np.asarray(vector, dtype=np.float32).tobytes()
163
+ conn.execute(
164
+ "INSERT INTO fact_embeddings(rowid, profile_id, embedding) "
165
+ "VALUES (?, ?, ?)",
166
+ (rowid, profile_id, vec_bytes),
167
+ )
168
+ conn.execute(
169
+ "INSERT INTO embedding_metadata "
170
+ "(vec_rowid, fact_id, profile_id, model_name, dimension) "
171
+ "VALUES (?, ?, ?, ?, ?)",
172
+ (
173
+ rowid,
174
+ fact_id,
175
+ profile_id,
176
+ config.embedding.model_name,
177
+ config.embedding.dimension,
178
+ ),
179
+ )
180
+ conn.execute(
181
+ "INSERT INTO vector_row_map (fact_id, profile_id, vec_rowid) "
182
+ "VALUES (?, ?, ?)",
183
+ (fact_id, profile_id, rowid),
184
+ )
185
+ updated = conn.execute(
186
+ "UPDATE atomic_facts SET embedding = ? "
187
+ "WHERE fact_id = ? AND profile_id = ?",
188
+ (embedding_json, fact_id, profile_id),
189
+ )
190
+ if updated.rowcount != 1:
191
+ raise RuntimeError(
192
+ f"canonical fact changed during migration: {fact_id}"
193
+ )
194
+ if first_probe is None:
195
+ first_probe = (vec_bytes, profile_id)
196
+ activated += 1
197
+
198
+ counts = [
199
+ int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
200
+ for table in (
201
+ "fact_embeddings",
202
+ "embedding_metadata",
203
+ "vector_row_map",
204
+ )
205
+ ]
206
+ if activated != expected_count or any(c != expected_count for c in counts):
207
+ raise RuntimeError(
208
+ f"vector activation incomplete: activated={activated}, "
209
+ f"projection_counts={counts}, expected={expected_count}"
210
+ )
211
+ if first_probe is not None:
212
+ probe = conn.execute(
213
+ "SELECT rowid FROM fact_embeddings "
214
+ "WHERE embedding MATCH ? AND profile_id = ? AND k = 1",
215
+ (first_probe[0], first_probe[1]),
216
+ ).fetchone()
217
+ if probe is None:
218
+ raise RuntimeError("post-activation KNN probe returned no row")
219
+ conn.commit()
220
+ except Exception:
221
+ conn.rollback()
222
+ raise
223
+ finally:
224
+ conn.close()
225
+
226
+
63
227
  def _model_signature(config: SLMConfig) -> str:
64
228
  """Derive a deterministic signature from the active embedding config.
65
229
 
@@ -160,26 +324,28 @@ def run_embedding_migration(
160
324
  db: Any,
161
325
  embedder: Any,
162
326
  ) -> int:
163
- """Re-embed all facts with the current model. Returns count re-embedded.
164
-
165
- Processes facts in batches to avoid memory spikes. Updates the
166
- embedding_metadata table and vector store for each fact.
327
+ """Stage and atomically activate embeddings for the current model.
167
328
 
168
- This is idempotent can be interrupted and resumed safely.
329
+ Embedding is performed in bounded batches into a temporary shadow store.
330
+ Canonical rows are changed only after every target fact has a valid vector;
331
+ the activation itself runs in one database transaction. A failed batch,
332
+ malformed vector set, or database write therefore leaves both the old
333
+ embeddings and the old model signature active.
169
334
  """
170
335
  if embedder is None:
171
336
  logger.warning("No embedder available. Skipping re-indexing.")
172
337
  return 0
173
338
 
174
339
  current_sig = _model_signature(config)
175
- profile_id = config.active_profile
176
-
177
- # Get all fact IDs that need re-embedding (all facts for the profile).
340
+ # Embedding configuration is database-wide. Rebuild every profile so one
341
+ # vec0 table never contains vectors from mixed model spaces.
178
342
  rows = db.execute(
179
- "SELECT fact_id, content FROM atomic_facts WHERE profile_id = ? ORDER BY created_at",
180
- (profile_id,),
343
+ "SELECT fact_id, profile_id, content FROM atomic_facts ORDER BY created_at",
181
344
  )
182
- facts = [(dict(r)["fact_id"], dict(r)["content"]) for r in rows]
345
+ facts = [
346
+ (dict(r)["fact_id"], dict(r)["profile_id"], dict(r)["content"])
347
+ for r in rows
348
+ ]
183
349
  total = len(facts)
184
350
 
185
351
  if total == 0:
@@ -193,54 +359,83 @@ def run_embedding_migration(
193
359
  _REINDEX_BATCH_SIZE,
194
360
  )
195
361
 
196
- reindexed = 0
197
- for i in range(0, total, _REINDEX_BATCH_SIZE):
198
- batch = facts[i : i + _REINDEX_BATCH_SIZE]
199
- texts = [content for _, content in batch]
200
- fact_ids = [fid for fid, _ in batch]
201
-
202
- try:
203
- vectors = embedder.embed_batch(texts)
204
- except Exception as exc:
205
- logger.error(
206
- "Re-embedding batch %d-%d failed: %s. Stopping migration.",
207
- i,
208
- i + len(batch),
209
- exc,
210
- )
211
- break
212
-
213
- for j, (fid, vec) in enumerate(zip(fact_ids, vectors)):
214
- if vec is None:
215
- continue
216
- # Update embedding in the database (embedding column on atomic_facts).
217
- try:
218
- embedding_json = json.dumps(vec)
219
- db.execute(
220
- "UPDATE atomic_facts SET embedding = ? WHERE fact_id = ?",
221
- (embedding_json, fid),
222
- )
223
- # Update embedding_metadata with new model name.
224
- db.execute(
225
- "UPDATE embedding_metadata SET model_name = ? WHERE fact_id = ?",
226
- (config.embedding.model_name, fid),
362
+ config.base_dir.mkdir(parents=True, exist_ok=True)
363
+ try:
364
+ with tempfile.TemporaryDirectory(
365
+ prefix="embedding-migration-",
366
+ dir=config.base_dir,
367
+ ) as stage_dir:
368
+ stage_path = Path(stage_dir) / "shadow.sqlite3"
369
+ with sqlite3.connect(stage_path) as stage:
370
+ stage.execute(
371
+ "CREATE TABLE staged_embeddings ("
372
+ "fact_id TEXT PRIMARY KEY, profile_id TEXT NOT NULL, "
373
+ "content_hash TEXT NOT NULL, embedding TEXT NOT NULL)"
227
374
  )
228
- reindexed += 1
229
- except Exception as exc:
230
- logger.warning(
231
- "Failed to update embedding for fact %s: %s",
232
- fid[:16],
233
- exc,
375
+ for i in range(0, total, _REINDEX_BATCH_SIZE):
376
+ batch = facts[i : i + _REINDEX_BATCH_SIZE]
377
+ texts = [content for _, _, content in batch]
378
+ fact_ids = [fid for fid, _, _ in batch]
379
+ profile_ids = [pid for _, pid, _ in batch]
380
+ vectors = list(embedder.embed_batch(texts))
381
+ if len(vectors) != len(batch):
382
+ raise ValueError(
383
+ "embedder returned "
384
+ f"{len(vectors)} vectors for {len(batch)} facts"
385
+ )
386
+ staged_rows: list[tuple[str, str, str, str]] = []
387
+ for fid, profile_id, content, vec in zip(
388
+ fact_ids, profile_ids, texts, vectors, strict=True
389
+ ):
390
+ if vec is None or len(vec) != config.embedding.dimension:
391
+ raise ValueError(
392
+ f"invalid embedding for fact {fid[:16]}: "
393
+ f"expected dimension {config.embedding.dimension}"
394
+ )
395
+ staged_rows.append(
396
+ (
397
+ fid,
398
+ profile_id,
399
+ hashlib.sha256(content.encode("utf-8")).hexdigest(),
400
+ json.dumps([float(value) for value in vec]),
401
+ )
402
+ )
403
+ stage.executemany(
404
+ "INSERT INTO staged_embeddings "
405
+ "(fact_id, profile_id, content_hash, embedding) "
406
+ "VALUES (?, ?, ?, ?)",
407
+ staged_rows,
408
+ )
409
+ stage.commit()
410
+
411
+ staged_count = int(
412
+ stage.execute("SELECT COUNT(*) FROM staged_embeddings").fetchone()[0]
234
413
  )
414
+ if staged_count != total:
415
+ raise ValueError(
416
+ f"shadow migration incomplete: {staged_count}/{total} facts"
417
+ )
418
+
419
+ _activate_staged_vectors(config, db, stage_path, total)
420
+ except Exception as exc:
421
+ logger.error(
422
+ "Embedding migration aborted; previous embedding space remains active: %s",
423
+ exc,
424
+ )
425
+ # Do NOT write a new signature — old embedding space stays active.
426
+ # Raise so callers can distinguish abort from no-op (return 0).
427
+ raise EmbeddingMigrationAborted(
428
+ "Embedding migration aborted; previous embedding space remains "
429
+ f"active: {exc}"
430
+ ) from exc
235
431
 
236
- # Update stored signature after successful migration.
237
432
  _write_stored_signature(config.base_dir, current_sig)
238
433
  logger.info(
239
434
  "Embedding migration complete: %d/%d facts re-embedded.",
240
- reindexed,
435
+ total,
241
436
  total,
242
437
  )
243
- return reindexed
438
+ return total
244
439
 
245
440
 
246
441
  # ---------------------------------------------------------------------------
@@ -0,0 +1,45 @@
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 threading
7
+ import time
8
+
9
+ _TTL_SECONDS = 300.0
10
+
11
+ _lock = threading.RLock()
12
+ _marks: dict[tuple[str, str], float] = {}
13
+
14
+
15
+ def mark_erasing(profile_id: str, fact_id: str) -> None:
16
+ now = time.time()
17
+ with _lock:
18
+ _marks[(profile_id, fact_id)] = now
19
+ _prune(now)
20
+
21
+
22
+ def clear_erasing(profile_id: str, fact_id: str) -> None:
23
+ with _lock:
24
+ _marks.pop((profile_id, fact_id), None)
25
+
26
+
27
+ def is_erasing(profile_id: str, fact_id: str) -> bool:
28
+ now = time.time()
29
+ with _lock:
30
+ ts = _marks.get((profile_id, fact_id))
31
+ if ts is None:
32
+ return False
33
+ if now - ts > _TTL_SECONDS:
34
+ _marks.pop((profile_id, fact_id), None)
35
+ return False
36
+ return True
37
+
38
+
39
+ def _prune(now: float) -> None:
40
+ expired = [key for key, ts in _marks.items() if now - ts > _TTL_SECONDS]
41
+ for key in expired:
42
+ _marks.pop(key, None)
43
+
44
+
45
+ __all__ = ["mark_erasing", "clear_erasing", "is_erasing"]
@@ -0,0 +1,63 @@
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 threading
7
+ import time
8
+
9
+ _TTL_SECONDS = 300.0
10
+ # Sentinel epoch that can never equal a real generation (which is >= 0), so a
11
+ # conflicted key is always rejected by the fence.
12
+ _CONFLICT_EPOCH = -1
13
+
14
+ _lock = threading.RLock()
15
+ _epochs: dict[tuple[str, str], tuple[int, float]] = {}
16
+
17
+
18
+ def record_admission_epoch(profile_id: str, idempotency_key: str, epoch: int) -> None:
19
+ if not idempotency_key:
20
+ return
21
+ now = time.time()
22
+ with _lock:
23
+ _prune(now)
24
+ key = (profile_id, idempotency_key)
25
+ existing = _epochs.get(key)
26
+ if existing is not None and existing[0] not in (epoch, _CONFLICT_EPOCH):
27
+ # Two concurrent admits recorded different epochs for one key: fail
28
+ # closed so neither can satisfy the fence — both retry against the
29
+ # current binding rather than letting a stale epoch slip through.
30
+ _epochs[key] = (_CONFLICT_EPOCH, now)
31
+ else:
32
+ _epochs.setdefault(key, (epoch, now))
33
+
34
+
35
+ def admitted_epoch(profile_id: str, idempotency_key: str) -> int | None:
36
+ if not idempotency_key:
37
+ return None
38
+ now = time.time()
39
+ with _lock:
40
+ entry = _epochs.get((profile_id, idempotency_key))
41
+ if entry is None:
42
+ return None
43
+ epoch, ts = entry
44
+ if now - ts > _TTL_SECONDS:
45
+ _epochs.pop((profile_id, idempotency_key), None)
46
+ return None
47
+ return epoch
48
+
49
+
50
+ def clear_admission_epoch(profile_id: str, idempotency_key: str) -> None:
51
+ if not idempotency_key:
52
+ return
53
+ with _lock:
54
+ _epochs.pop((profile_id, idempotency_key), None)
55
+
56
+
57
+ def _prune(now: float) -> None:
58
+ expired = [key for key, (_, ts) in _epochs.items() if now - ts > _TTL_SECONDS]
59
+ for key in expired:
60
+ _epochs.pop(key, None)
61
+
62
+
63
+ __all__ = ["record_admission_epoch", "admitted_epoch", "clear_admission_epoch"]