superlocalmemory 3.8.7 → 3.8.8

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 (43) hide show
  1. package/CHANGELOG.md +34 -1
  2. package/README.md +3 -3
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +1 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/core/embeddings.py +73 -11
  34. package/src/superlocalmemory/core/engine.py +6 -1
  35. package/src/superlocalmemory/core/ollama_embedder.py +5 -0
  36. package/src/superlocalmemory/core/recall_gate.py +39 -4
  37. package/src/superlocalmemory/core/recall_pipeline.py +40 -0
  38. package/src/superlocalmemory/encoding/scene_builder.py +105 -15
  39. package/src/superlocalmemory/retrieval/entity_channel.py +201 -56
  40. package/src/superlocalmemory/retrieval/vector_store.py +238 -123
  41. package/src/superlocalmemory/server/recall_health.py +3 -1
  42. package/src/superlocalmemory/server/unified_daemon.py +104 -16
  43. package/src/superlocalmemory/storage/embedding_migrator.py +88 -60
@@ -1383,10 +1383,6 @@ async def lifespan(application: FastAPI):
1383
1383
  application, new_config, mode_change=mode_change,
1384
1384
  )
1385
1385
  )
1386
- # v3.4.38: Wire module-level _engine for the pending materializer.
1387
- global _engine, _profile_runtime
1388
- _profile_runtime = profile_runtime
1389
- _engine = engine
1390
1386
  logger.info("Unified daemon: MemoryEngine initialized (mode=%s)", config.mode.value)
1391
1387
 
1392
1388
  # v3.5.0: Backend Orchestrator — CozoDB (graph) + LanceDB (vector) backends.
@@ -1631,8 +1627,13 @@ async def lifespan(application: FastAPI):
1631
1627
  ]
1632
1628
  if not with_emb:
1633
1629
  continue
1634
- if vs.count(pid) >= int(len(with_emb) * 0.98):
1635
- continue # already complete — no-op
1630
+ indexed_ids = vs.indexed_fact_ids(pid)
1631
+ missing = [
1632
+ item for item in with_emb
1633
+ if item[0] not in indexed_ids
1634
+ ]
1635
+ if not missing:
1636
+ continue # every metadata pointer has a vec0 payload
1636
1637
  # Fix: route each upsert through db._lock with cooperative
1637
1638
  # yield between facts. Previously called
1638
1639
  # vs.rebuild_from_facts() which opens its own sqlite3
@@ -1653,8 +1654,8 @@ async def lifespan(application: FastAPI):
1653
1654
  _pause = max(0.0, float(
1654
1655
  _selfheal_os.environ.get("SLM_SELFHEAL_BATCH_PAUSE_S", "0.05")))
1655
1656
  n = 0
1656
- for _i in range(0, len(with_emb), _batch):
1657
- _chunk = with_emb[_i:_i + _batch]
1657
+ for _i in range(0, len(missing), _batch):
1658
+ _chunk = missing[_i:_i + _batch]
1658
1659
  with db._lock:
1659
1660
  for _fact_id, _profile_id, _embedding in _chunk:
1660
1661
  try:
@@ -1670,8 +1671,8 @@ async def lifespan(application: FastAPI):
1670
1671
  if _pause > 0:
1671
1672
  _t.sleep(_pause)
1672
1673
  logger.info(
1673
- "VS backfill[%s]: indexed %d of %d embedded facts",
1674
- pid, n, len(with_emb),
1674
+ "VS backfill[%s]: repaired %d of %d missing vectors",
1675
+ pid, n, len(missing),
1675
1676
  )
1676
1677
  except Exception as exc:
1677
1678
  logger.warning("Vector store backfill failed (non-fatal): %s", exc)
@@ -2132,6 +2133,15 @@ async def lifespan(application: FastAPI):
2132
2133
  _mcp_lifespan_exc,
2133
2134
  )
2134
2135
 
2136
+ # Publish the resident engine to the pending materializer only after
2137
+ # every synchronous startup writer has finished. Publishing it beside
2138
+ # engine.initialize() allowed a stranded ingestion operation to race
2139
+ # BackendOrchestrator status writes while the daemon was still inside
2140
+ # lifespan, so /health could never become reachable.
2141
+ global _engine, _profile_runtime
2142
+ _profile_runtime = profile_runtime
2143
+ _engine = engine
2144
+
2135
2145
  # Uvicorn enters this lifespan only after it has bound the listener.
2136
2146
  # Publishing ``ready`` here prevents a failed competing process from
2137
2147
  # overwriting the live daemon descriptor before it owns the port.
@@ -3143,6 +3153,21 @@ def _register_daemon_routes(application: FastAPI) -> None:
3143
3153
  recall_health = {"recall_healthy": None}
3144
3154
  # Non-blocking peek: report status without forcing a re-init.
3145
3155
  engine = getattr(application.state, "engine", None)
3156
+ embedding_ready = bool(_embedding_warm)
3157
+ if engine is not None:
3158
+ embedder = getattr(engine, "_embedder", None)
3159
+ if embedder is None:
3160
+ retrieval_engine = getattr(engine, "_retrieval_engine", None)
3161
+ embedder = (
3162
+ getattr(retrieval_engine, "_embedder", None)
3163
+ if retrieval_engine is not None
3164
+ else None
3165
+ )
3166
+ if embedder is not None and hasattr(embedder, "is_warm"):
3167
+ try:
3168
+ embedding_ready = bool(embedder.is_warm)
3169
+ except Exception:
3170
+ embedding_ready = False
3146
3171
  migration_result = getattr(application.state, "migration_result", None)
3147
3172
  migration_failures = list(
3148
3173
  (migration_result or {}).get("failed", []) or []
@@ -3163,7 +3188,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
3163
3188
  writer_runtime is not None
3164
3189
  and getattr(writer_runtime, "ready", False)
3165
3190
  ),
3166
- "embedding": bool(_embedding_warm),
3191
+ "embedding": embedding_ready,
3167
3192
  "recall_health": recall_health.get("recall_healthy") is True,
3168
3193
  "migration_failures": migration_failures,
3169
3194
  }
@@ -3225,7 +3250,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
3225
3250
  "version": getattr(application, 'version', 'unknown'),
3226
3251
  # v3.4.52: clients can poll this to wait for embedding model
3227
3252
  # readiness before issuing recall calls.
3228
- "embedding_warm": _embedding_warm,
3253
+ "embedding_warm": embedding_ready,
3229
3254
  # v3.6.8: True iff the semantic channel actually fired on the last
3230
3255
  # health probe; includes self-heal counters.
3231
3256
  "recall_health": recall_health,
@@ -3976,6 +4001,10 @@ _materializer_stop = threading.Event()
3976
4001
  _materializer_thread: threading.Thread | None = None
3977
4002
 
3978
4003
 
4004
+ class _PendingProfileMismatchError(RuntimeError):
4005
+ """A legacy pending row no longer matches the admitted profile lease."""
4006
+
4007
+
3979
4008
  def _materializer_actor_id() -> str:
3980
4009
  """Return the process-owned actor identity used by background writes."""
3981
4010
  descriptor = _ACTIVE_DAEMON_DESCRIPTOR
@@ -3986,7 +4015,13 @@ def _materializer_actor_id() -> str:
3986
4015
  return f"daemon-capability:{descriptor.capability_fingerprint}"
3987
4016
 
3988
4017
 
3989
- def _run_materializer_operation(runtime, engine_supplier, operation):
4018
+ def _run_materializer_operation(
4019
+ runtime,
4020
+ engine_supplier,
4021
+ operation,
4022
+ *,
4023
+ expected_profile_id: str | None = None,
4024
+ ):
3990
4025
  """Run one bounded background unit against an admitted engine snapshot.
3991
4026
 
3992
4027
  Cooperative preemption: if a profile transition is already in progress,
@@ -3997,15 +4032,36 @@ def _run_materializer_operation(runtime, engine_supplier, operation):
3997
4032
  """
3998
4033
  # Writer-priority: don't acquire a new lease when a transition is draining.
3999
4034
  if runtime is not None and runtime.transitioning:
4035
+ if expected_profile_id is not None:
4036
+ raise _PendingProfileMismatchError(
4037
+ "pending materialization deferred during profile transition"
4038
+ )
4000
4039
  return None
4001
- with runtime.operation():
4040
+ with runtime.operation() as snapshot:
4041
+ if (
4042
+ expected_profile_id is not None
4043
+ and snapshot.profile_id != expected_profile_id
4044
+ ):
4045
+ raise _PendingProfileMismatchError(
4046
+ "pending profile changed before materializer admission"
4047
+ )
4002
4048
  # Resolve the engine only after admission. A concurrent mode/provider
4003
4049
  # reconfiguration may have replaced the module-level engine while this
4004
4050
  # worker was waiting at the transition barrier.
4005
4051
  engine = engine_supplier()
4006
4052
  if engine is None:
4007
4053
  return None
4008
- return operation(engine)
4054
+ engine_profile_id = getattr(engine, "_profile_id", None)
4055
+ if (
4056
+ expected_profile_id is not None
4057
+ and engine_profile_id != expected_profile_id
4058
+ ):
4059
+ raise _PendingProfileMismatchError(
4060
+ "resident engine does not match pending profile"
4061
+ )
4062
+ from superlocalmemory.core.recall_gate import background_work
4063
+ with background_work():
4064
+ return operation(engine)
4009
4065
 
4010
4066
 
4011
4067
  def _materialize_ingestion_one_pass(
@@ -4021,6 +4077,19 @@ def _materialize_ingestion_one_pass(
4021
4077
  if _recalls_in_flight() > 0:
4022
4078
  return 0, 0
4023
4079
 
4080
+ # A local sentence-transformers cold start can take minutes. Remember's
4081
+ # queryable projection is already durable, so defer enrichment until the
4082
+ # daemon warmup/health monitor has proved the worker ready. This preserves
4083
+ # every enrichment layer while preventing a background cold load from
4084
+ # monopolizing the same worker needed by foreground recall.
4085
+ embedder = getattr(engine, "_embedder", None)
4086
+ if embedder is not None and hasattr(embedder, "is_warm"):
4087
+ try:
4088
+ if not bool(embedder.is_warm):
4089
+ return 0, 0
4090
+ except Exception:
4091
+ return 0, 0
4092
+
4024
4093
  from superlocalmemory.core.engine_ingestion import build_engine_ingestion_command
4025
4094
  from superlocalmemory.core.ingestion_command import IngestionState
4026
4095
 
@@ -4081,6 +4150,12 @@ def _materialize_legacy_pending_item(engine, item: dict) -> str:
4081
4150
  IngestionState,
4082
4151
  )
4083
4152
 
4153
+ expected_profile_id = str(item.get("profile_id") or "default")
4154
+ if getattr(engine, "_profile_id", None) != expected_profile_id:
4155
+ raise _PendingProfileMismatchError(
4156
+ "legacy pending item does not match resident engine profile"
4157
+ )
4158
+
4084
4159
  metadata_value = item.get("metadata") or "{}"
4085
4160
  try:
4086
4161
  metadata = (
@@ -4101,7 +4176,7 @@ def _materialize_legacy_pending_item(engine, item: dict) -> str:
4101
4176
  command = build_engine_ingestion_command(engine)
4102
4177
  receipt = command.submit(IngestionRequest(
4103
4178
  content=item["content"],
4104
- profile_id=engine._profile_id,
4179
+ profile_id=expected_profile_id,
4105
4180
  source_type=source_type,
4106
4181
  idempotency_key=idempotency_key,
4107
4182
  metadata=metadata,
@@ -4185,12 +4260,16 @@ def _start_pending_materializer() -> None:
4185
4260
  time.sleep(0.5)
4186
4261
  waits += 1
4187
4262
  try:
4263
+ pending_profile_id = str(
4264
+ item.get("profile_id") or "default"
4265
+ )
4188
4266
  operation_id = _run_materializer_operation(
4189
4267
  runtime,
4190
4268
  lambda: _ud._engine,
4191
4269
  lambda admitted_engine: _materialize_legacy_pending_item(
4192
4270
  admitted_engine, item,
4193
4271
  ),
4272
+ expected_profile_id=pending_profile_id,
4194
4273
  )
4195
4274
  if operation_id is None:
4196
4275
  raise RuntimeError("resident engine became unavailable")
@@ -4205,6 +4284,15 @@ def _start_pending_materializer() -> None:
4205
4284
  },
4206
4285
  source_agent="materializer",
4207
4286
  )
4287
+ except _PendingProfileMismatchError:
4288
+ # A profile transition committed after this row was
4289
+ # fetched but before it obtained an operation lease.
4290
+ # Leave it pending, without consuming a retry, so the
4291
+ # owning profile can safely drain it later.
4292
+ logger.debug(
4293
+ "Pending %d deferred after profile switch",
4294
+ item["id"],
4295
+ )
4208
4296
  except Exception as exc:
4209
4297
  logger.warning(
4210
4298
  "Pending %d failed: %s", item["id"], exc,
@@ -41,9 +41,7 @@ _BACKFILL_BATCH_SIZE = 50
41
41
  #: acquisition window. Tunable via SLM_SELFHEAL_WRITE_DELAY_S; set to 0 to
42
42
  #: disable (only do this on single-user dev databases with no concurrency).
43
43
  #: Default 5 ms is imperceptible for humans but visible to the OS scheduler.
44
- _SELFHEAL_WRITE_DELAY_S: float = float(
45
- _os.environ.get("SLM_SELFHEAL_WRITE_DELAY_S", "0.005")
46
- )
44
+ _SELFHEAL_WRITE_DELAY_S: float = float(_os.environ.get("SLM_SELFHEAL_WRITE_DELAY_S", "0.005"))
47
45
 
48
46
  #: Max characters embedded per fact during backfill. The embedding model
49
47
  #: (nomic-embed-text-v1.5) truncates at ~8192 tokens anyway, but a raw
@@ -144,13 +142,15 @@ def check_embedding_migration(config: SLMConfig) -> bool:
144
142
  _write_stored_signature(config.base_dir, current_sig)
145
143
  logger.info(
146
144
  "Embedding signature normalized (no re-embed): %s ~= %s",
147
- stored_sig, current_sig,
145
+ stored_sig,
146
+ current_sig,
148
147
  )
149
148
  return False
150
149
 
151
150
  logger.warning(
152
151
  "Embedding model changed: %s -> %s. Re-indexing required.",
153
- stored_sig, current_sig,
152
+ stored_sig,
153
+ current_sig,
154
154
  )
155
155
  return True
156
156
 
@@ -176,8 +176,7 @@ def run_embedding_migration(
176
176
 
177
177
  # Get all fact IDs that need re-embedding (all facts for the profile).
178
178
  rows = db.execute(
179
- "SELECT fact_id, content FROM atomic_facts "
180
- "WHERE profile_id = ? ORDER BY created_at",
179
+ "SELECT fact_id, content FROM atomic_facts WHERE profile_id = ? ORDER BY created_at",
181
180
  (profile_id,),
182
181
  )
183
182
  facts = [(dict(r)["fact_id"], dict(r)["content"]) for r in rows]
@@ -189,7 +188,9 @@ def run_embedding_migration(
189
188
 
190
189
  logger.info(
191
190
  "Re-embedding %d facts with model %s (batch_size=%d)",
192
- total, current_sig, _REINDEX_BATCH_SIZE,
191
+ total,
192
+ current_sig,
193
+ _REINDEX_BATCH_SIZE,
193
194
  )
194
195
 
195
196
  reindexed = 0
@@ -203,7 +204,9 @@ def run_embedding_migration(
203
204
  except Exception as exc:
204
205
  logger.error(
205
206
  "Re-embedding batch %d-%d failed: %s. Stopping migration.",
206
- i, i + len(batch), exc,
207
+ i,
208
+ i + len(batch),
209
+ exc,
207
210
  )
208
211
  break
209
212
 
@@ -219,22 +222,23 @@ def run_embedding_migration(
219
222
  )
220
223
  # Update embedding_metadata with new model name.
221
224
  db.execute(
222
- "UPDATE embedding_metadata SET model_name = ? "
223
- "WHERE fact_id = ?",
225
+ "UPDATE embedding_metadata SET model_name = ? WHERE fact_id = ?",
224
226
  (config.embedding.model_name, fid),
225
227
  )
226
228
  reindexed += 1
227
229
  except Exception as exc:
228
230
  logger.warning(
229
231
  "Failed to update embedding for fact %s: %s",
230
- fid[:16], exc,
232
+ fid[:16],
233
+ exc,
231
234
  )
232
235
 
233
236
  # Update stored signature after successful migration.
234
237
  _write_stored_signature(config.base_dir, current_sig)
235
238
  logger.info(
236
239
  "Embedding migration complete: %d/%d facts re-embedded.",
237
- reindexed, total,
240
+ reindexed,
241
+ total,
238
242
  )
239
243
  return reindexed
240
244
 
@@ -243,6 +247,7 @@ def run_embedding_migration(
243
247
  # Backfill: embed facts that were NEVER embedded (embedding IS NULL)
244
248
  # ---------------------------------------------------------------------------
245
249
 
250
+
246
251
  def _count_null_embeddings(
247
252
  db: Any,
248
253
  profile_id: str,
@@ -255,8 +260,7 @@ def _count_null_embeddings(
255
260
  )
256
261
  else:
257
262
  rows = db.execute(
258
- "SELECT count(*) AS c FROM atomic_facts "
259
- "WHERE embedding IS NULL AND profile_id = ?",
263
+ "SELECT count(*) AS c FROM atomic_facts WHERE embedding IS NULL AND profile_id = ?",
260
264
  (profile_id,),
261
265
  )
262
266
  return int(rows[0]["c"]) if rows else 0
@@ -282,7 +286,7 @@ def backfill_missing_embeddings(
282
286
 
283
287
  Writes mirror :func:`run_embedding_migration` exactly:
284
288
  * ``atomic_facts.embedding`` ← ``json.dumps(vector)``
285
- * ``embedding_metadata`` ← upserted row with current model name + dimension
289
+ * sqlite-vec + ``embedding_metadata`` ← one atomic projection pair
286
290
 
287
291
  Args:
288
292
  config: Active SLMConfig (provides profile_id, model name, dimension).
@@ -309,9 +313,7 @@ def backfill_missing_embeddings(
309
313
  profile_id = config.active_profile
310
314
 
311
315
  if embedder is None:
312
- logger.warning(
313
- "backfill_missing_embeddings: no embedder available — skipping."
314
- )
316
+ logger.warning("backfill_missing_embeddings: no embedder available — skipping.")
315
317
  return {"scanned": 0, "embedded": 0, "remaining_null": 0}
316
318
 
317
319
  # ------------------------------------------------------------------
@@ -330,8 +332,7 @@ def backfill_missing_embeddings(
330
332
  )
331
333
 
332
334
  facts: list[tuple[str, str, str]] = [
333
- (dict(r)["fact_id"], dict(r)["content"], dict(r)["profile_id"])
334
- for r in rows
335
+ (dict(r)["fact_id"], dict(r)["content"], dict(r)["profile_id"]) for r in rows
335
336
  ]
336
337
  scanned = len(facts)
337
338
 
@@ -345,6 +346,24 @@ def backfill_missing_embeddings(
345
346
  current_model = config.embedding.model_name
346
347
  current_dim = config.embedding.dimension
347
348
  embedded = 0
349
+ vector_store = None
350
+ try:
351
+ from superlocalmemory.retrieval.vector_store import (
352
+ VectorStore,
353
+ VectorStoreConfig,
354
+ )
355
+
356
+ db_path = getattr(db, "db_path", None)
357
+ if db_path is not None:
358
+ vector_store = VectorStore(
359
+ db_path,
360
+ VectorStoreConfig(
361
+ dimension=current_dim,
362
+ model_name=current_model,
363
+ ),
364
+ )
365
+ except Exception as exc:
366
+ logger.debug("backfill: vector store unavailable: %s", exc)
348
367
 
349
368
  # ------------------------------------------------------------------
350
369
  # 2. Batch embed and write back
@@ -357,36 +376,39 @@ def backfill_missing_embeddings(
357
376
  fact_ids = [fid for fid, _, _ in batch]
358
377
  prof_ids = [pid for _, _, pid in batch]
359
378
 
360
- # Attempt batch embed; fall back to per-fact on batch failure.
361
- try:
362
- vectors: list[Any] = embedder.embed_batch(texts)
363
- except Exception as exc:
364
- logger.warning(
365
- "backfill: batch embed failed for facts %d-%d: %s — "
366
- "retrying per-fact.",
367
- batch_start,
368
- batch_start + len(batch),
369
- exc,
370
- )
371
- vectors = []
372
- for text in texts:
373
- try:
374
- vec = embedder.embed(text)
375
- vectors.append(vec)
376
- except Exception as per_fact_exc:
377
- logger.warning(
378
- "backfill: per-fact embed failed for '%s...': %s",
379
- text[:40],
380
- per_fact_exc,
381
- )
382
- vectors.append(None)
379
+ # Attempt batch embed; fall back to per-fact on batch failure. Mark the
380
+ # whole inference burst as background work so the shared embedding
381
+ # service yields between items if a recall begins after the daemon's
382
+ # initial in-flight check.
383
+ from superlocalmemory.core.recall_gate import background_work
384
+
385
+ with background_work():
386
+ try:
387
+ vectors: list[Any] = embedder.embed_batch(texts)
388
+ except Exception as exc:
389
+ logger.warning(
390
+ "backfill: batch embed failed for facts %d-%d: %s — retrying per-fact.",
391
+ batch_start,
392
+ batch_start + len(batch),
393
+ exc,
394
+ )
395
+ vectors = []
396
+ for text in texts:
397
+ try:
398
+ vec = embedder.embed(text)
399
+ vectors.append(vec)
400
+ except Exception as per_fact_exc:
401
+ logger.warning(
402
+ "backfill: per-fact embed failed for '%s...': %s",
403
+ text[:40],
404
+ per_fact_exc,
405
+ )
406
+ vectors.append(None)
383
407
 
384
408
  # Write each successfully-embedded fact back to the DB.
385
409
  for fid, vec, pid in zip(fact_ids, vectors, prof_ids):
386
410
  if vec is None:
387
- logger.warning(
388
- "backfill: null vector for fact %s — skipping.", fid[:16]
389
- )
411
+ logger.warning("backfill: null vector for fact %s — skipping.", fid[:16])
390
412
  continue
391
413
  try:
392
414
  embedding_json = json.dumps(vec)
@@ -395,16 +417,24 @@ def backfill_missing_embeddings(
395
417
  "UPDATE atomic_facts SET embedding = ? WHERE fact_id = ?",
396
418
  (embedding_json, fid),
397
419
  )
398
- # Upsert embedding_metadata. NULL-embedding facts have no row
399
- # here yet, so we INSERT; if a row somehow exists, update it.
400
- db.execute(
401
- "INSERT INTO embedding_metadata"
402
- " (fact_id, profile_id, model_name, dimension)"
403
- " VALUES (?, ?, ?, ?)"
404
- " ON CONFLICT(fact_id) DO UPDATE SET"
405
- " model_name = excluded.model_name",
406
- (fid, pid, current_model, current_dim),
407
- )
420
+ # Metadata is not an independent record: it is the pointer to
421
+ # a sqlite-vec row. Creating it before the vector payload leaves
422
+ # semantic recall permanently blind while reporting success.
423
+ # VectorStore owns the atomic pair and repairs legacy orphans.
424
+ if (
425
+ vector_store is not None
426
+ and getattr(vector_store, "available", False)
427
+ and not vector_store.upsert(
428
+ fid,
429
+ pid,
430
+ vec,
431
+ model_name=current_model,
432
+ )
433
+ ):
434
+ logger.warning(
435
+ "backfill: vector projection failed for fact %s",
436
+ fid[:16],
437
+ )
408
438
  embedded += 1
409
439
  # Cooperative yield: release db._lock briefly so concurrent
410
440
  # user writes can acquire it between facts. Without this,
@@ -414,9 +444,7 @@ def backfill_missing_embeddings(
414
444
  if _SELFHEAL_WRITE_DELAY_S > 0:
415
445
  time.sleep(_SELFHEAL_WRITE_DELAY_S)
416
446
  except Exception as exc:
417
- logger.warning(
418
- "backfill: failed to write fact %s: %s", fid[:16], exc
419
- )
447
+ logger.warning("backfill: failed to write fact %s: %s", fid[:16], exc)
420
448
 
421
449
  # ------------------------------------------------------------------
422
450
  # 3. Count remaining NULLs (accounts for the limit; tells caller how