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.
- package/CHANGELOG.md +34 -1
- package/README.md +3 -3
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/core/embeddings.py +73 -11
- package/src/superlocalmemory/core/engine.py +6 -1
- package/src/superlocalmemory/core/ollama_embedder.py +5 -0
- package/src/superlocalmemory/core/recall_gate.py +39 -4
- package/src/superlocalmemory/core/recall_pipeline.py +40 -0
- package/src/superlocalmemory/encoding/scene_builder.py +105 -15
- package/src/superlocalmemory/retrieval/entity_channel.py +201 -56
- package/src/superlocalmemory/retrieval/vector_store.py +238 -123
- package/src/superlocalmemory/server/recall_health.py +3 -1
- package/src/superlocalmemory/server/unified_daemon.py +104 -16
- 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
|
-
|
|
1635
|
-
|
|
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(
|
|
1657
|
-
_chunk =
|
|
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]:
|
|
1674
|
-
pid, n, len(
|
|
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":
|
|
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":
|
|
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(
|
|
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
|
-
|
|
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=
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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],
|
|
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,
|
|
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`` ←
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
text
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
#
|
|
399
|
-
#
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|