superlocalmemory 3.8.7 → 3.8.9

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 (48) hide show
  1. package/CHANGELOG.md +49 -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 +128 -13
  34. package/src/superlocalmemory/core/engine.py +6 -1
  35. package/src/superlocalmemory/core/engine_ingestion.py +5 -0
  36. package/src/superlocalmemory/core/ingestion_command.py +36 -0
  37. package/src/superlocalmemory/core/materialization_control.py +20 -0
  38. package/src/superlocalmemory/core/ollama_embedder.py +5 -0
  39. package/src/superlocalmemory/core/recall_gate.py +63 -4
  40. package/src/superlocalmemory/core/recall_pipeline.py +40 -0
  41. package/src/superlocalmemory/core/store_pipeline.py +10 -0
  42. package/src/superlocalmemory/encoding/scene_builder.py +105 -15
  43. package/src/superlocalmemory/mcp/tools_core.py +16 -7
  44. package/src/superlocalmemory/retrieval/entity_channel.py +201 -56
  45. package/src/superlocalmemory/retrieval/vector_store.py +238 -123
  46. package/src/superlocalmemory/server/recall_health.py +3 -1
  47. package/src/superlocalmemory/server/unified_daemon.py +106 -16
  48. package/src/superlocalmemory/storage/embedding_migrator.py +88 -60
@@ -90,6 +90,8 @@ class OllamaEmbedder:
90
90
  """
91
91
  if not text or not text.strip():
92
92
  raise ValueError("Cannot embed empty text")
93
+ from superlocalmemory.core.recall_gate import wait_for_foreground_idle
94
+ wait_for_foreground_idle()
93
95
 
94
96
  # V3.3.27: Check cache first
95
97
  cache_key = text.strip()
@@ -119,6 +121,9 @@ class OllamaEmbedder:
119
121
  """
120
122
  if not texts:
121
123
  raise ValueError("Cannot embed empty batch")
124
+ from superlocalmemory.core.recall_gate import is_background_work
125
+ if is_background_work():
126
+ return [self.embed(text) for text in texts]
122
127
 
123
128
  # V3.3.27: Split into cached and uncached
124
129
  results: list[list[float] | None] = [None] * len(texts)
@@ -14,23 +14,82 @@ recall.
14
14
  from __future__ import annotations
15
15
 
16
16
  import threading
17
+ from contextlib import contextmanager
18
+ from typing import Callable, Iterator
17
19
 
18
- _lock = threading.Lock()
20
+ _condition = threading.Condition(threading.Lock())
19
21
  _active = 0
22
+ _work_context = threading.local()
20
23
 
21
24
 
22
25
  def begin_recall() -> None:
23
26
  global _active
24
- with _lock:
27
+ with _condition:
25
28
  _active += 1
26
29
 
27
30
 
28
31
  def end_recall() -> None:
29
32
  global _active
30
- with _lock:
33
+ with _condition:
31
34
  _active = max(0, _active - 1)
35
+ if _active == 0:
36
+ _condition.notify_all()
32
37
 
33
38
 
34
39
  def in_flight() -> int:
35
- with _lock:
40
+ with _condition:
36
41
  return _active
42
+
43
+
44
+ @contextmanager
45
+ def background_work(
46
+ *,
47
+ preempt_requested: Callable[[], bool] | None = None,
48
+ ) -> Iterator[None]:
49
+ """Mark best-effort work that must yield shared inference to recall.
50
+
51
+ The marker is thread-local because materialization, health probes, and
52
+ interactive handlers all share one resident engine and one embedder.
53
+ Nested callers restore the previous marker on exit. A daemon-owned
54
+ materializer may also provide a preemption callback for a profile/runtime
55
+ reconfigure. Inference clients use that callback to cut a bounded
56
+ background request short instead of holding the transition drain lease.
57
+ """
58
+ previous = bool(getattr(_work_context, "background", False))
59
+ previous_preempt = getattr(_work_context, "preempt_requested", None)
60
+ _work_context.background = True
61
+ _work_context.preempt_requested = (
62
+ preempt_requested if preempt_requested is not None else previous_preempt
63
+ )
64
+ try:
65
+ yield
66
+ finally:
67
+ _work_context.background = previous
68
+ _work_context.preempt_requested = previous_preempt
69
+
70
+
71
+ def is_background_work() -> bool:
72
+ """Return whether the current thread is running best-effort work."""
73
+ return bool(getattr(_work_context, "background", False))
74
+
75
+
76
+ def background_preempt_requested() -> bool:
77
+ """Return whether daemon-owned background work must release its lease."""
78
+ callback = getattr(_work_context, "preempt_requested", None)
79
+ if not callable(callback):
80
+ return False
81
+ try:
82
+ return bool(callback())
83
+ except Exception:
84
+ # A status read is advisory. It must not crash a materializer or turn
85
+ # a valid recall into an ingestion failure.
86
+ return False
87
+
88
+
89
+ def wait_for_foreground_idle() -> None:
90
+ """Block background inference while an interactive recall is active."""
91
+ if not is_background_work():
92
+ return
93
+ with _condition:
94
+ while _active > 0:
95
+ _condition.wait(timeout=0.1)
@@ -100,6 +100,45 @@ def _apply_markers_to_response(response: RecallResponse) -> None:
100
100
  r.marker = _emit_marker(r.fact.fact_id)
101
101
 
102
102
 
103
+ def _preserve_exact_lexical_evidence(
104
+ response: RecallResponse,
105
+ query: str,
106
+ ) -> None:
107
+ """Keep a deterministic exact BM25 hit ahead of learned refinements.
108
+
109
+ Adaptive and bandit ranking are valuable for ambiguous candidates, but
110
+ they must not demote a fact containing the caller's exact query behind
111
+ semantically similar noise. This guard runs after every learned layer and
112
+ changes only ordering; it does not introduce or bypass evidence.
113
+ """
114
+ normalized_query = " ".join(query.casefold().split())
115
+ if len(normalized_query) < 3 or len(response.results) < 2:
116
+ return
117
+ exact = [
118
+ result
119
+ for result in response.results
120
+ if (
121
+ float((result.channel_scores or {}).get("bm25", 0.0) or 0.0) > 0.0
122
+ and normalized_query
123
+ in " ".join(result.fact.content.casefold().split())
124
+ )
125
+ ]
126
+ if not exact:
127
+ return
128
+ strongest = max(
129
+ exact,
130
+ key=lambda result: float(
131
+ (result.channel_scores or {}).get("bm25", 0.0) or 0.0,
132
+ ),
133
+ )
134
+ if response.results[0] is strongest:
135
+ return
136
+ response.results = [
137
+ strongest,
138
+ *(result for result in response.results if result is not strongest),
139
+ ]
140
+
141
+
103
142
  # ---------------------------------------------------------------------------
104
143
  # Stage 8 SB-1 — feed shadow_router from recall-settled signals.
105
144
  #
@@ -854,6 +893,7 @@ def run_recall(
854
893
  except Exception as exc:
855
894
  logger.debug("Ranking pipeline skipped: %s", exc)
856
895
 
896
+ _preserve_exact_lexical_evidence(response, query)
857
897
  _mark("learning+ranking")
858
898
  # Deliberately no trust, Fisher, retention, lifecycle, popularity, or graph
859
899
  # mutation here. Those state transitions require a separately authenticated
@@ -34,6 +34,14 @@ logger = logging.getLogger(__name__)
34
34
  _INIT_LANGEVIN_RADIUS = 0.05
35
35
 
36
36
 
37
+ def _reraise_materialization_deferral(exc: Exception) -> None:
38
+ """Keep explicit runtime preemption out of best-effort fallbacks."""
39
+ from superlocalmemory.core.materialization_control import MaterializationDeferred
40
+
41
+ if isinstance(exc, MaterializationDeferred):
42
+ raise exc
43
+
44
+
37
45
  def _ingestion_effect_id(operation_id: str, *parts: object) -> str:
38
46
  """Return a stable ID for a relational effect owned by one ingestion."""
39
47
  if not operation_id:
@@ -247,6 +255,7 @@ def _upsert_fact_vectors(fact, profile_id, ann_index, vector_store, embedder=Non
247
255
  try:
248
256
  fact.embedding = embedder.embed(fact.content)
249
257
  except Exception as _emb_exc: # pragma: no cover - defensive
258
+ _reraise_materialization_deferral(_emb_exc)
250
259
  logger.debug("on-demand embed failed for %s: %s", fact.fact_id, _emb_exc)
251
260
  return
252
261
  if not getattr(fact, "embedding", None):
@@ -429,6 +438,7 @@ def run_store(
429
438
  )
430
439
  extraction_complete = facts is not None
431
440
  except Exception as _extract_exc:
441
+ _reraise_materialization_deferral(_extract_exc)
432
442
  # P0-1 (remember-write-04): an extractor EXCEPTION (transient LLM/embed
433
443
  # backend error) must NOT orphan the already-committed memory. The None
434
444
  # guard below only handled a None *return*, not a raise. Treat a raise
@@ -38,6 +38,8 @@ class SceneBuilder:
38
38
  def __init__(self, db, embedder=None) -> None:
39
39
  self._db = db
40
40
  self._embedder = embedder
41
+ # Key by scene ID, never theme. Themes are deliberately non-unique,
42
+ # while eligibility and durable anchor membership are scene-specific.
41
43
  self._scene_embeddings_cache: dict[str, list[float]] = {}
42
44
 
43
45
  def assign_to_scene(
@@ -53,8 +55,12 @@ class SceneBuilder:
53
55
  if self._embedder is None:
54
56
  return self._create_scene(new_fact, profile_id)
55
57
 
56
- # Always compute fact embedding first needed for comparisons
57
- fact_emb = self._embedder.embed(new_fact.content)
58
+ # Canonical ingestion already embeds the fact before scene assignment.
59
+ # Reuse that vector so scene clustering does not issue a duplicate model
60
+ # request for every remembered fact.
61
+ fact_emb = new_fact.embedding
62
+ if fact_emb is None:
63
+ fact_emb = self._embedder.embed(new_fact.content)
58
64
 
59
65
  # v3.4.38: Defensive None guard. embedder.embed() returns None when
60
66
  # the embedding worker is unavailable (timeout, crash). Without this
@@ -69,30 +75,58 @@ class SceneBuilder:
69
75
  if not scenes:
70
76
  return self._create_scene(new_fact, profile_id)
71
77
 
78
+ live_scene_embeddings = self._load_live_scene_embeddings(profile_id)
79
+ live_scene_ids = set(live_scene_embeddings)
80
+ self._scene_embeddings_cache.update({
81
+ scene_id: embedding
82
+ for scene_id, embedding in live_scene_embeddings.items()
83
+ if embedding is not None
84
+ })
85
+ # Old consolidation/deletion paths left scene rows whose fact IDs no
86
+ # longer exist. They are not evidence and must not trigger thousands of
87
+ # replacement model calls after restart. A cache hit cannot prove that
88
+ # a scene still has a surviving fact, so eligibility is always derived
89
+ # from the current database state.
90
+ scenes = [
91
+ scene for scene in scenes if scene.scene_id in live_scene_ids
92
+ ]
93
+ if not scenes:
94
+ return self._create_scene(new_fact, profile_id)
95
+
72
96
  # Find best matching scene
73
97
  best_scene: MemoryScene | None = None
74
98
  best_sim = -1.0
75
99
 
76
- # V3.3.27: Batch-embed all uncached scene themes in ONE call.
100
+ # A scene's theme is derived from its first (anchor) fact. On daemon
101
+ # restart the in-memory cache is empty, but the anchor embeddings remain
102
+ # durable in atomic_facts. Prime from those vectors before calling the
103
+ # model; otherwise a mature database re-embeds thousands of themes and
104
+ # repeatedly recycles the shared foreground worker.
105
+ # V3.3.27: Batch-embed all still-uncached scene themes in ONE call.
77
106
  # Previously: 200+ individual embed() calls per fact (30s on Mode B).
78
107
  # Now: 1 batch call for all uncached themes, then cache hits for the rest.
79
- uncached_themes = [s.theme for s in scenes if s.theme not in self._scene_embeddings_cache]
80
- if uncached_themes and hasattr(self._embedder, 'embed_batch'):
108
+ uncached_scenes = [
109
+ scene for scene in scenes
110
+ if scene.scene_id not in self._scene_embeddings_cache
111
+ ]
112
+ if uncached_scenes and hasattr(self._embedder, 'embed_batch'):
81
113
  try:
82
- batch_embs = self._embedder.embed_batch(uncached_themes)
83
- for theme, emb in zip(uncached_themes, batch_embs):
114
+ batch_embs = self._embedder.embed_batch(
115
+ [scene.theme for scene in uncached_scenes]
116
+ )
117
+ for scene, emb in zip(uncached_scenes, batch_embs):
84
118
  if emb is not None:
85
- self._scene_embeddings_cache[theme] = emb
119
+ self._scene_embeddings_cache[scene.scene_id] = emb
86
120
  except Exception:
87
121
  pass # Fall through to individual embeds below
88
122
 
89
123
  for scene in scenes:
90
- if scene.theme in self._scene_embeddings_cache:
91
- theme_emb = self._scene_embeddings_cache[scene.theme]
124
+ if scene.scene_id in self._scene_embeddings_cache:
125
+ theme_emb = self._scene_embeddings_cache[scene.scene_id]
92
126
  else:
93
127
  theme_emb = self._embedder.embed(scene.theme)
94
128
  if theme_emb is not None:
95
- self._scene_embeddings_cache[scene.theme] = theme_emb
129
+ self._scene_embeddings_cache[scene.scene_id] = theme_emb
96
130
  if theme_emb is None:
97
131
  continue
98
132
  sim = _cosine(fact_emb, theme_emb)
@@ -130,10 +164,6 @@ class SceneBuilder:
130
164
  comparisons in assign_to_scene.
131
165
  """
132
166
  theme = fact.content[:200]
133
- # Pre-compute theme embedding for future comparisons
134
- if self._embedder is not None:
135
- self._scene_embeddings_cache[theme] = self._embedder.embed(theme)
136
-
137
167
  scene = MemoryScene(
138
168
  profile_id=profile_id,
139
169
  theme=theme,
@@ -142,6 +172,14 @@ class SceneBuilder:
142
172
  created_at=datetime.now(UTC).isoformat(),
143
173
  last_updated=datetime.now(UTC).isoformat(),
144
174
  )
175
+ # Pre-compute theme embedding for future comparisons. The canonical fact
176
+ # vector represents this exact theme and avoids a duplicate model call.
177
+ if self._embedder is not None:
178
+ theme_embedding = fact.embedding
179
+ if theme_embedding is None:
180
+ theme_embedding = self._embedder.embed(theme)
181
+ if theme_embedding is not None:
182
+ self._scene_embeddings_cache[scene.scene_id] = theme_embedding
145
183
  self._save_scene(scene)
146
184
  return scene
147
185
 
@@ -171,6 +209,58 @@ class SceneBuilder:
171
209
  )
172
210
  return [self._row_to_scene(dict(r)) for r in rows]
173
211
 
212
+ def _load_live_scene_embeddings(
213
+ self,
214
+ profile_id: str,
215
+ ) -> dict[str, list[float] | None]:
216
+ """Load one durable anchor embedding for every live scene.
217
+
218
+ ``json_each`` resolves the first still-existing fact in each scene, so
219
+ scenes whose original anchor was consolidated away can still reuse a
220
+ surviving member. The result also identifies fully stale scene rows,
221
+ which are ignored by assignment instead of being re-embedded.
222
+ """
223
+ try:
224
+ rows = self._db.execute(
225
+ """
226
+ WITH live_scene_facts AS (
227
+ SELECT
228
+ ms.scene_id,
229
+ ms.theme,
230
+ af.embedding,
231
+ ROW_NUMBER() OVER (
232
+ PARTITION BY ms.scene_id
233
+ ORDER BY CAST(member.key AS INTEGER)
234
+ ) AS member_rank
235
+ FROM memory_scenes AS ms
236
+ JOIN json_each(ms.fact_ids_json) AS member
237
+ JOIN atomic_facts AS af
238
+ ON af.fact_id = member.value
239
+ AND af.profile_id = ms.profile_id
240
+ WHERE ms.profile_id = ?
241
+ )
242
+ SELECT scene_id, embedding
243
+ FROM live_scene_facts
244
+ WHERE member_rank = 1
245
+ """,
246
+ (profile_id,),
247
+ )
248
+ except Exception:
249
+ return {}
250
+
251
+ result: dict[str, list[float] | None] = {}
252
+ for row in rows:
253
+ data = dict(row)
254
+ raw_embedding = data.get("embedding")
255
+ embedding = None
256
+ if raw_embedding:
257
+ try:
258
+ embedding = json.loads(raw_embedding)
259
+ except (TypeError, ValueError, json.JSONDecodeError):
260
+ embedding = None
261
+ result[str(data["scene_id"])] = embedding
262
+ return result
263
+
174
264
  def _save_scene(self, scene: MemoryScene) -> None:
175
265
  """Upsert scene to DB."""
176
266
  self._db.execute(
@@ -282,7 +282,6 @@ def register_core_tools(server, get_engine: Callable) -> None:
282
282
  import asyncio
283
283
  try:
284
284
  from superlocalmemory.mcp._daemon_proxy import choose_pool
285
- pool = choose_pool()
286
285
  # S9-DASH-10: priority for session_id, so engagement
287
286
  # signals land on the right pending_outcome:
288
287
  # 1. Explicit ``session_id`` tool-call argument.
@@ -322,15 +321,25 @@ def register_core_tools(server, get_engine: Callable) -> None:
322
321
  pass
323
322
  if not effective_sid:
324
323
  effective_sid = f"mcp:{agent_id}"
325
- # V3.3.19: Run in thread pool to avoid blocking MCP event loop.
324
+ # Resolve the daemon proxy inside the worker too. ``choose_pool``
325
+ # verifies daemon ownership through a synchronous /health request;
326
+ # when this tool is served by the daemon's mounted HTTP MCP app,
327
+ # resolving it on Uvicorn's event-loop thread makes that loop wait
328
+ # on its own health response forever. Stdio did not exhibit this
329
+ # because its MCP process is external to the daemon.
330
+ #
326
331
  # V3.4.26: WorkerPool now concurrent — parallel calls no longer
327
332
  # block behind a single threading.Lock. See worker_pool.py.
333
+ def _recall_via_daemon_pool():
334
+ pool = choose_pool()
335
+ return pool.recall(
336
+ query, limit=limit, session_id=effective_sid,
337
+ fast=fast, include_global=include_global,
338
+ include_shared=include_shared, window=window or None,
339
+ )
340
+
328
341
  result = await asyncio.to_thread(
329
- pool.recall, query, limit=limit, session_id=effective_sid,
330
- fast=fast,
331
- include_global=include_global,
332
- include_shared=include_shared,
333
- window=window or None,
342
+ _recall_via_daemon_pool,
334
343
  )
335
344
  if result.get("ok"):
336
345
  return {