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
@@ -17,9 +17,10 @@ from __future__ import annotations
17
17
  import logging
18
18
  import sqlite3
19
19
  import threading
20
+ from contextlib import contextmanager
20
21
  from dataclasses import dataclass
21
22
  from pathlib import Path
22
- from typing import Any
23
+ from typing import Generator
23
24
 
24
25
  import numpy as np
25
26
 
@@ -31,6 +32,7 @@ logger = logging.getLogger(__name__)
31
32
  @dataclass(frozen=True) # Rule 10
32
33
  class VectorStoreConfig:
33
34
  """Configuration for VectorStore."""
35
+
34
36
  dimension: int = 768
35
37
  binary_quantization_threshold: int = 100_000 # L4 fix
36
38
  model_name: str = "nomic-embed-text-v1.5"
@@ -78,6 +80,7 @@ class VectorStore:
78
80
  """
79
81
  try:
80
82
  import sqlite_vec # noqa: F401
83
+
81
84
  conn = self._connect()
82
85
  conn.close()
83
86
  return True
@@ -112,6 +115,26 @@ class VectorStore:
112
115
  conn.enable_load_extension(False)
113
116
  return conn
114
117
 
118
+ @contextmanager
119
+ def _managed_connection(self) -> Generator[sqlite3.Connection, None, None]:
120
+ """Close every sqlite-vec connection, rolling back abandoned writes.
121
+
122
+ sqlite-vec can reject a commit after its virtual-table shadow rows have
123
+ already opened a SQLite write transaction. A fail-soft caller must not
124
+ return while that connection still owns the WAL writer lock: the next
125
+ canonical/BM25 write would then wait behind an unreachable transaction.
126
+ """
127
+ conn = self._connect()
128
+ try:
129
+ yield conn
130
+ finally:
131
+ if conn.in_transaction:
132
+ try:
133
+ conn.rollback()
134
+ except sqlite3.Error:
135
+ pass
136
+ conn.close()
137
+
115
138
  # -- Table creation -----------------------------------------------------
116
139
 
117
140
  def _ensure_vec0_table(self) -> None:
@@ -134,21 +157,18 @@ class VectorStore:
134
157
  ")"
135
158
  )
136
159
  meta_idx_fact = (
137
- "CREATE INDEX IF NOT EXISTS idx_embmeta_fact "
138
- "ON embedding_metadata (fact_id)"
160
+ "CREATE INDEX IF NOT EXISTS idx_embmeta_fact ON embedding_metadata (fact_id)"
139
161
  )
140
162
  meta_idx_profile = (
141
- "CREATE INDEX IF NOT EXISTS idx_embmeta_profile "
142
- "ON embedding_metadata (profile_id)"
163
+ "CREATE INDEX IF NOT EXISTS idx_embmeta_profile ON embedding_metadata (profile_id)"
143
164
  )
144
165
  try:
145
- conn = self._connect()
146
- conn.execute(vec0_ddl)
147
- conn.execute(meta_ddl)
148
- conn.execute(meta_idx_fact)
149
- conn.execute(meta_idx_profile)
150
- conn.commit()
151
- conn.close()
166
+ with self._managed_connection() as conn:
167
+ conn.execute(vec0_ddl)
168
+ conn.execute(meta_ddl)
169
+ conn.execute(meta_idx_fact)
170
+ conn.execute(meta_idx_profile)
171
+ conn.commit()
152
172
  except Exception as exc:
153
173
  logger.debug("vec0 table creation failed: %s", exc)
154
174
  self._available = False
@@ -180,7 +200,8 @@ class VectorStore:
180
200
  if len(embedding) != self._config.dimension:
181
201
  logger.debug(
182
202
  "Dimension mismatch: got %d, expected %d",
183
- len(embedding), self._config.dimension,
203
+ len(embedding),
204
+ self._config.dimension,
184
205
  )
185
206
  return False
186
207
 
@@ -196,46 +217,91 @@ class VectorStore:
196
217
  # with db._lock: vs.upsert()
197
218
  # simply re-enters the RLock (same thread — always safe).
198
219
  _wl = get_write_lock(self._db_path)
199
- with _wl: # OUTER: serialises all memory.db writers
220
+ with _wl: # OUTER: serialises all memory.db writers
200
221
  with self._lock: # INNER: VectorStore per-instance state
201
222
  try:
202
- conn = self._connect()
203
- # Check if fact_id already exists in metadata
204
- row = conn.execute(
205
- "SELECT vec_rowid FROM embedding_metadata "
206
- "WHERE fact_id = ?",
207
- (fact_id,),
208
- ).fetchone()
209
-
210
- if row is not None:
211
- # UPDATE existing
212
- rowid = row["vec_rowid"]
213
- conn.execute(
214
- "UPDATE fact_embeddings SET embedding = ? "
215
- "WHERE rowid = ?",
216
- (vec_bytes, rowid),
217
- )
218
- else:
219
- # INSERT new
220
- conn.execute(
221
- "INSERT INTO fact_embeddings(profile_id, embedding) "
222
- "VALUES (?, ?)",
223
- (profile_id, vec_bytes),
224
- )
225
- rowid = conn.execute(
226
- "SELECT last_insert_rowid()"
227
- ).fetchone()[0]
228
- conn.execute(
229
- "INSERT INTO embedding_metadata "
230
- "(vec_rowid, fact_id, profile_id, model_name, dimension) "
231
- "VALUES (?, ?, ?, ?, ?)",
232
- (rowid, fact_id, profile_id,
233
- model_name or self._config.model_name,
234
- self._config.dimension),
235
- )
236
-
237
- conn.commit()
238
- conn.close()
223
+ with self._managed_connection() as conn:
224
+ # Reserve SQLite's cross-process writer before reading
225
+ # either side of the row-id allocator. The Python
226
+ # RLocks above coordinate threads only; BEGIN IMMEDIATE
227
+ # plus the connection's bounded busy_timeout serializes
228
+ # independent MCP/agent processes as well.
229
+ conn.execute("BEGIN IMMEDIATE")
230
+ # Check if fact_id already exists in metadata
231
+ row = conn.execute(
232
+ "SELECT vec_rowid, profile_id "
233
+ "FROM embedding_metadata "
234
+ "WHERE fact_id = ?",
235
+ (fact_id,),
236
+ ).fetchone()
237
+
238
+ if row is not None:
239
+ rowid = row["vec_rowid"]
240
+ vector_row = conn.execute(
241
+ "SELECT profile_id FROM fact_embeddings WHERE rowid = ?",
242
+ (rowid,),
243
+ ).fetchone()
244
+ pair_matches_profile = (
245
+ vector_row is not None
246
+ and str(row["profile_id"]) == profile_id
247
+ and str(vector_row["profile_id"]) == profile_id
248
+ )
249
+ if pair_matches_profile:
250
+ conn.execute(
251
+ "UPDATE fact_embeddings SET embedding = ? WHERE rowid = ?",
252
+ (vec_bytes, rowid),
253
+ )
254
+ else:
255
+ # Older self-heal code could insert metadata
256
+ # before sqlite-vec, or row-id drift could point
257
+ # metadata at another profile's vector. Neither
258
+ # is a valid projection pair. Remove only the
259
+ # stale pointer and rebuild at a fresh rowid;
260
+ # never overwrite the other profile's payload.
261
+ conn.execute(
262
+ "DELETE FROM embedding_metadata WHERE fact_id = ?",
263
+ (fact_id,),
264
+ )
265
+ row = None
266
+
267
+ if row is None:
268
+ # Allocate from both sides of the projection pair.
269
+ # Mature databases can contain orphaned vec0 rows
270
+ # or metadata rows after older fail-soft releases.
271
+ # sqlite-vec's implicit last_insert_rowid() only
272
+ # considers the virtual table, so it can reuse a
273
+ # rowid that is still owned by embedding_metadata
274
+ # and make every later projection fail UNIQUE.
275
+ rowid = conn.execute(
276
+ "SELECT COALESCE(MAX(candidate), 0) + 1 "
277
+ "FROM ("
278
+ "SELECT MAX(rowid) AS candidate "
279
+ "FROM fact_embeddings "
280
+ "UNION ALL "
281
+ "SELECT MAX(vec_rowid) AS candidate "
282
+ "FROM embedding_metadata"
283
+ ")"
284
+ ).fetchone()[0]
285
+ conn.execute(
286
+ "INSERT INTO fact_embeddings"
287
+ "(rowid, profile_id, embedding) "
288
+ "VALUES (?, ?, ?)",
289
+ (rowid, profile_id, vec_bytes),
290
+ )
291
+ conn.execute(
292
+ "INSERT INTO embedding_metadata "
293
+ "(vec_rowid, fact_id, profile_id, model_name, dimension) "
294
+ "VALUES (?, ?, ?, ?, ?)",
295
+ (
296
+ rowid,
297
+ fact_id,
298
+ profile_id,
299
+ model_name or self._config.model_name,
300
+ self._config.dimension,
301
+ ),
302
+ )
303
+
304
+ conn.commit()
239
305
  return True
240
306
  except Exception as exc:
241
307
  logger.debug("upsert failed for fact_id=%s: %s", fact_id, exc)
@@ -261,48 +327,64 @@ class VectorStore:
261
327
  vec_bytes = self._serialize_f32(query_embedding)
262
328
 
263
329
  try:
264
- conn = self._connect()
265
-
266
- if profile_id is not None:
267
- rows = conn.execute(
268
- "SELECT rowid, distance "
269
- "FROM fact_embeddings "
270
- "WHERE embedding MATCH ? "
271
- "AND profile_id = ? "
272
- "AND k = ?",
273
- (vec_bytes, profile_id, top_k),
274
- ).fetchall()
275
- else:
330
+ with self._managed_connection() as conn:
331
+ if top_k <= 0:
332
+ return []
333
+ if profile_id is not None:
334
+ sql = (
335
+ "SELECT fe.rowid, fe.distance, em.fact_id "
336
+ "FROM fact_embeddings AS fe "
337
+ "JOIN embedding_metadata AS em "
338
+ "ON em.vec_rowid = fe.rowid "
339
+ "AND em.profile_id = fe.profile_id "
340
+ "WHERE fe.embedding MATCH ? "
341
+ "AND fe.profile_id = ? "
342
+ "AND fe.k = ?"
343
+ )
344
+ base_params: tuple[object, ...] = (vec_bytes, profile_id)
345
+ count_sql = "SELECT COUNT(*) AS c FROM fact_embeddings WHERE profile_id = ?"
346
+ count_params: tuple[object, ...] = (profile_id,)
347
+ else:
348
+ sql = (
349
+ "SELECT fe.rowid, fe.distance, em.fact_id "
350
+ "FROM fact_embeddings AS fe "
351
+ "JOIN embedding_metadata AS em "
352
+ "ON em.vec_rowid = fe.rowid "
353
+ "AND em.profile_id = fe.profile_id "
354
+ "WHERE fe.embedding MATCH ? "
355
+ "AND fe.k = ?"
356
+ )
357
+ base_params = (vec_bytes,)
358
+ count_sql = "SELECT COUNT(*) AS c FROM fact_embeddings"
359
+ count_params = ()
360
+
361
+ # vec0 applies k before the relational join. A legacy orphan
362
+ # can therefore occupy a nearest-neighbour slot and then be
363
+ # discarded by the profile-safe join. Expand only when that
364
+ # happens, doubling until top_k valid pairs are found or the
365
+ # profile's vector population is exhausted.
366
+ search_k = top_k
276
367
  rows = conn.execute(
277
- "SELECT rowid, distance "
278
- "FROM fact_embeddings "
279
- "WHERE embedding MATCH ? "
280
- "AND k = ?",
281
- (vec_bytes, top_k),
368
+ sql,
369
+ (*base_params, search_k),
282
370
  ).fetchall()
283
-
284
- if not rows:
285
- conn.close()
286
- return []
287
-
288
- # Map rowids -> fact_ids via embedding_metadata
289
- rowids = [r["rowid"] for r in rows]
290
- dist_map = {r["rowid"]: r["distance"] for r in rows}
291
-
292
- placeholders = ",".join("?" for _ in rowids)
293
- meta_rows = conn.execute(
294
- f"SELECT vec_rowid, fact_id FROM embedding_metadata "
295
- f"WHERE vec_rowid IN ({placeholders})",
296
- rowids,
297
- ).fetchall()
298
-
299
- conn.close()
371
+ if len(rows) < top_k:
372
+ count_row = conn.execute(
373
+ count_sql,
374
+ count_params,
375
+ ).fetchone()
376
+ total_vectors = int(count_row["c"]) if count_row else 0
377
+ while len(rows) < top_k and search_k < total_vectors:
378
+ search_k = min(total_vectors, max(search_k + 1, search_k * 2))
379
+ rows = conn.execute(
380
+ sql,
381
+ (*base_params, search_k),
382
+ ).fetchall()
300
383
 
301
384
  results: list[tuple[str, float]] = []
302
- for mr in meta_rows:
303
- rid = mr["vec_rowid"]
304
- fid = str(mr["fact_id"])
305
- similarity = max(0.0, 1.0 - dist_map[rid])
385
+ for row in rows[:top_k]:
386
+ fid = str(row["fact_id"])
387
+ similarity = max(0.0, 1.0 - row["distance"])
306
388
  results.append((fid, similarity))
307
389
 
308
390
  results.sort(key=lambda x: x[1], reverse=True)
@@ -322,38 +404,44 @@ class VectorStore:
322
404
  return False
323
405
 
324
406
  _wl = get_write_lock(self._db_path)
325
- with _wl: # OUTER: process-level write serialisation
407
+ with _wl: # OUTER: process-level write serialisation
326
408
  with self._lock: # INNER: VectorStore per-instance state
327
409
  try:
328
- conn = self._connect()
329
- row = conn.execute(
330
- "SELECT vec_rowid FROM embedding_metadata "
331
- "WHERE fact_id = ?",
332
- (fact_id,),
333
- ).fetchone()
410
+ with self._managed_connection() as conn:
411
+ row = conn.execute(
412
+ "SELECT vec_rowid, profile_id "
413
+ "FROM embedding_metadata "
414
+ "WHERE fact_id = ?",
415
+ (fact_id,),
416
+ ).fetchone()
334
417
 
335
- if row is None:
336
- conn.close()
337
- return False
418
+ if row is None:
419
+ return False
338
420
 
339
- rowid = row["vec_rowid"]
340
- conn.execute(
341
- "DELETE FROM fact_embeddings WHERE rowid = ?",
342
- (rowid,),
343
- )
344
- conn.execute(
345
- "DELETE FROM embedding_metadata WHERE vec_rowid = ?",
346
- (rowid,),
347
- )
348
- conn.commit()
349
- conn.close()
421
+ rowid = row["vec_rowid"]
422
+ vector_row = conn.execute(
423
+ "SELECT profile_id FROM fact_embeddings WHERE rowid = ?",
424
+ (rowid,),
425
+ ).fetchone()
426
+ if vector_row is not None and str(vector_row["profile_id"]) == str(
427
+ row["profile_id"]
428
+ ):
429
+ conn.execute(
430
+ "DELETE FROM fact_embeddings WHERE rowid = ?",
431
+ (rowid,),
432
+ )
433
+ conn.execute(
434
+ "DELETE FROM embedding_metadata WHERE vec_rowid = ?",
435
+ (rowid,),
436
+ )
437
+ conn.commit()
350
438
  return True
351
439
  except Exception as exc:
352
440
  logger.debug("delete failed for fact_id=%s: %s", fact_id, exc)
353
441
  return False
354
442
 
355
443
  def count(self, profile_id: str | None = None) -> int:
356
- """Count vectors in the store.
444
+ """Count complete metadata/vector pairs in the store.
357
445
 
358
446
  Returns 0 if unavailable.
359
447
  """
@@ -361,23 +449,50 @@ class VectorStore:
361
449
  return 0
362
450
 
363
451
  try:
364
- conn = self._connect()
365
- if profile_id is not None:
366
- row = conn.execute(
367
- "SELECT COUNT(*) AS c FROM embedding_metadata "
368
- "WHERE profile_id = ?",
369
- (profile_id,),
370
- ).fetchone()
371
- else:
372
- row = conn.execute(
373
- "SELECT COUNT(*) AS c FROM embedding_metadata",
374
- ).fetchone()
375
- conn.close()
452
+ with self._managed_connection() as conn:
453
+ if profile_id is not None:
454
+ row = conn.execute(
455
+ "SELECT COUNT(*) AS c "
456
+ "FROM embedding_metadata em "
457
+ "JOIN fact_embeddings fe "
458
+ "ON fe.rowid = em.vec_rowid "
459
+ "AND fe.profile_id = em.profile_id "
460
+ "WHERE em.profile_id = ?",
461
+ (profile_id,),
462
+ ).fetchone()
463
+ else:
464
+ row = conn.execute(
465
+ "SELECT COUNT(*) AS c "
466
+ "FROM embedding_metadata em "
467
+ "JOIN fact_embeddings fe "
468
+ "ON fe.rowid = em.vec_rowid "
469
+ "AND fe.profile_id = em.profile_id",
470
+ ).fetchone()
376
471
  return int(row["c"]) if row else 0
377
472
  except Exception as exc:
378
473
  logger.debug("count failed: %s", exc)
379
474
  return 0
380
475
 
476
+ def indexed_fact_ids(self, profile_id: str) -> set[str]:
477
+ """Return fact IDs backed by both metadata and a vec0 payload."""
478
+ if not self._available:
479
+ return set()
480
+ try:
481
+ with self._managed_connection() as conn:
482
+ rows = conn.execute(
483
+ "SELECT em.fact_id "
484
+ "FROM embedding_metadata em "
485
+ "JOIN fact_embeddings fe "
486
+ "ON fe.rowid = em.vec_rowid "
487
+ "AND fe.profile_id = em.profile_id "
488
+ "WHERE em.profile_id = ?",
489
+ (profile_id,),
490
+ ).fetchall()
491
+ return {str(row["fact_id"]) for row in rows}
492
+ except Exception as exc:
493
+ logger.debug("indexed_fact_ids failed: %s", exc)
494
+ return set()
495
+
381
496
  def rebuild_from_facts(
382
497
  self,
383
498
  facts: list[tuple[str, str, list[float]]],
@@ -153,7 +153,9 @@ def run_health_tick(engine, state: RecallHealth, *, probe: str = DEFAULT_PROBE,
153
153
  # fast=True: a health probe must release its operation lease well
154
154
  # within the 5s profile-switch drain window (fast=False is 2-10s and
155
155
  # would make every profile switch time out while a tick is in flight).
156
- resp = engine.recall(probe, limit=3, fast=True)
156
+ from superlocalmemory.core.recall_gate import background_work
157
+ with background_work():
158
+ resp = engine.recall(probe, limit=3, fast=True)
157
159
  except Exception as exc:
158
160
  state.healthy = False
159
161
  state.consecutive_failures += 1