superlocalmemory 3.8.1 → 3.8.3

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 (67) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +2 -2
  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 +2 -2
  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 +3 -5
  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 +2 -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 +3 -5
  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/scripts/postinstall.js +7 -1
  33. package/src/superlocalmemory/__init__.py +1 -1
  34. package/src/superlocalmemory/cli/commands.py +360 -2
  35. package/src/superlocalmemory/cli/main.py +62 -3
  36. package/src/superlocalmemory/cli/setup_wizard.py +142 -16
  37. package/src/superlocalmemory/core/component_healer.py +144 -0
  38. package/src/superlocalmemory/core/component_registry.py +487 -0
  39. package/src/superlocalmemory/core/config.py +21 -0
  40. package/src/superlocalmemory/core/embeddings.py +14 -1
  41. package/src/superlocalmemory/core/engine.py +9 -5
  42. package/src/superlocalmemory/core/ingestion_command.py +36 -16
  43. package/src/superlocalmemory/core/maintenance.py +43 -0
  44. package/src/superlocalmemory/core/maintenance_scheduler.py +28 -0
  45. package/src/superlocalmemory/core/recall_pipeline.py +39 -3
  46. package/src/superlocalmemory/core/store_pipeline.py +42 -0
  47. package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
  48. package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
  49. package/src/superlocalmemory/mcp/tools_active.py +1 -1
  50. package/src/superlocalmemory/mcp/tools_core.py +17 -2
  51. package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
  52. package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
  53. package/src/superlocalmemory/server/routes/behavioral.py +6 -2
  54. package/src/superlocalmemory/server/routes/learning.py +13 -3
  55. package/src/superlocalmemory/server/routes/memories.py +80 -26
  56. package/src/superlocalmemory/server/routes/v3_api.py +120 -0
  57. package/src/superlocalmemory/server/unified_daemon.py +349 -10
  58. package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
  59. package/src/superlocalmemory/ui/index.html +3 -2
  60. package/src/superlocalmemory/ui/js/core.js +6 -1
  61. package/src/superlocalmemory/ui/js/od-components.js +147 -0
  62. package/src/superlocalmemory/ui/js/od-entities.js +43 -0
  63. package/src/superlocalmemory/ui/js/od-graph.js +35 -0
  64. package/src/superlocalmemory/ui/js/od-health.js +18 -0
  65. package/src/superlocalmemory/ui/js/od-memories.js +37 -0
  66. package/src/superlocalmemory/ui/js/od-operations.js +36 -0
  67. package/src/superlocalmemory/ui/js/od-settings.js +72 -3
@@ -0,0 +1,487 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Central component / dependency capability registry (v3.8.2).
6
+
7
+ Single source of truth for "what SLM needs, and whether it is present" —
8
+ consumed by the self-heal thread (``server.unified_daemon._self_heal``),
9
+ ``slm doctor``, the ``GET /api/v3/components`` route, and the dashboard
10
+ System-Health panel.
11
+
12
+ Design principles
13
+ -----------------
14
+ * **Cheap, side-effect-free probes.** Probing NEVER loads torch into this
15
+ process and NEVER downloads anything: it uses import-spec checks, a
16
+ HuggingFace-cache lookup, and a short Ollama HTTP ping. Repair (model
17
+ download / pip install) is the self-heal thread's job — see
18
+ ``core.component_healer`` and ``unified_daemon._self_heal`` Step 0/0.5.
19
+ * **Honest, actionable reporting.** Every component carries a status plus,
20
+ when missing, a plain-language fix command and whether SLM can auto-fix
21
+ it (``auto_fixable``). A non-technical user reads the report; the daemon
22
+ acts on ``auto_fixable`` items on their behalf.
23
+ * **Live repair visibility.** A tiny thread-safe transient overlay lets the
24
+ self-heal thread mark a component ``retrying`` while a download/install is
25
+ in flight, so the dashboard shows progress. Overlays are advisory and are
26
+ cleared as soon as a fresh probe confirms the component is present.
27
+ * **Immutability.** :class:`Component` is a frozen dataclass; snapshots
28
+ return freshly-built lists/dicts and are never mutated in place.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import importlib.util
34
+ import threading
35
+ import time
36
+ from dataclasses import dataclass, replace
37
+ from pathlib import Path
38
+ from typing import Any
39
+
40
+ # --------------------------------------------------------------------------
41
+ # Status + category vocabulary
42
+ # --------------------------------------------------------------------------
43
+
44
+ STATUS_OK = "ok" # present and usable
45
+ STATUS_MISSING = "missing" # absent — recall/feature degraded until fixed
46
+ STATUS_DEGRADED = "degraded" # present but not fully functional / unverifiable
47
+ STATUS_RETRYING = "retrying" # self-heal is actively downloading/installing
48
+
49
+ CATEGORY_REQUIRED = "required" # core recall breaks without it
50
+ CATEGORY_RECOMMENDED = "recommended" # semantic quality degrades without it
51
+ CATEGORY_OPTIONAL = "optional" # only used by an opt-in feature
52
+
53
+ # Model repo ids — kept in sync with cli.setup_wizard (single definition here
54
+ # would be ideal, but importing the wizard pulls its CLI surface; these three
55
+ # strings are stable and asserted equal by tests).
56
+ _EMBED_MODEL = "nomic-ai/nomic-embed-text-v1.5"
57
+ _RERANKER_MODEL = "cross-encoder/ms-marco-MiniLM-L-12-v2"
58
+ _COMPRESSOR_MODEL = "microsoft/llmlingua-2-xlm-roberta-large-meetingbank"
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Component:
63
+ """One capability SLM depends on, and its current health.
64
+
65
+ Frozen: probes build new instances; the transient overlay uses
66
+ :func:`dataclasses.replace` to derive a modified copy rather than
67
+ mutating shared state.
68
+ """
69
+
70
+ key: str
71
+ label: str
72
+ category: str
73
+ status: str
74
+ detail: str = ""
75
+ fix_cmd: str = ""
76
+ auto_fixable: bool = False
77
+ last_checked: float = 0.0
78
+
79
+ def as_dict(self) -> dict[str, Any]:
80
+ return {
81
+ "key": self.key,
82
+ "label": self.label,
83
+ "category": self.category,
84
+ "status": self.status,
85
+ "detail": self.detail,
86
+ "fix_cmd": self.fix_cmd,
87
+ "auto_fixable": self.auto_fixable,
88
+ "last_checked": self.last_checked,
89
+ }
90
+
91
+
92
+ # --------------------------------------------------------------------------
93
+ # Environment helpers
94
+ # --------------------------------------------------------------------------
95
+
96
+ def pip_is_user_writable() -> bool:
97
+ """True when a plain ``pip install`` into this interpreter is expected to
98
+ succeed — i.e. the interpreter is NOT PEP-668 externally-managed.
99
+
100
+ Mirrors the doctor's PEP-668 check. Used to decide whether SLM may
101
+ auto-``pip install`` a pure-python component (sqlite-vec) on the user's
102
+ behalf, or must instead hand them a pipx/uv fix command.
103
+ """
104
+ try:
105
+ import sysconfig
106
+
107
+ stdlib = sysconfig.get_path("stdlib")
108
+ if not stdlib:
109
+ return True
110
+ return not (Path(stdlib) / "EXTERNALLY-MANAGED").exists()
111
+ except Exception:
112
+ # Unknown → be conservative and do NOT auto-install.
113
+ return False
114
+
115
+
116
+ def _module_present(module: str) -> bool:
117
+ """True if ``module`` can be imported, WITHOUT importing it.
118
+
119
+ ``find_spec`` only resolves the loader; it never executes the module, so
120
+ this stays cheap and never pulls torch/lancedb/etc. into the daemon.
121
+ """
122
+ try:
123
+ return importlib.util.find_spec(module) is not None
124
+ except Exception:
125
+ # A broken/partial install can raise inside find_spec — treat as absent.
126
+ return False
127
+
128
+
129
+ def _hf_model_cached(repo_id: str) -> bool | None:
130
+ """Heuristic: is ``repo_id`` already in the local HuggingFace cache?
131
+
132
+ Returns True/False, or None when it cannot be determined (huggingface_hub
133
+ not installed). Checks for the model's ``config.json`` — present for every
134
+ HF model — without loading torch/sentence-transformers. This is a fast
135
+ presence heuristic; the authoritative load-test lives in the healer's real
136
+ download call, which no-ops when the model is genuinely present.
137
+ """
138
+ try:
139
+ from huggingface_hub import try_to_load_from_cache
140
+ except Exception:
141
+ return None
142
+ try:
143
+ hit = try_to_load_from_cache(repo_id, "config.json")
144
+ except Exception:
145
+ return None
146
+ # try_to_load_from_cache returns a filesystem path (str) only when the file
147
+ # is cached; None when unknown, or a _CACHED_NO_EXIST sentinel object when
148
+ # known-absent. Testing for str is version-proof across huggingface_hub
149
+ # releases (the sentinel's import location has moved between versions).
150
+ return isinstance(hit, str)
151
+
152
+
153
+ def _embedding_is_remote(config: Any) -> bool:
154
+ """True when embeddings come from a remote endpoint (no local model needed).
155
+
156
+ Delegates to the wizard's canonical detector; conservative False on any
157
+ import/attribute error so a local embedder is still probed.
158
+ """
159
+ try:
160
+ from superlocalmemory.cli.setup_wizard import _embedding_is_remote as _r
161
+
162
+ return bool(_r(config))
163
+ except Exception:
164
+ return False
165
+
166
+
167
+ # --------------------------------------------------------------------------
168
+ # Individual probes — each returns a Component (never raises)
169
+ # --------------------------------------------------------------------------
170
+
171
+ def probe_python() -> Component:
172
+ import sys
173
+
174
+ v = sys.version_info
175
+ ok = v >= (3, 11)
176
+ return Component(
177
+ key="python",
178
+ label="Python runtime",
179
+ category=CATEGORY_REQUIRED,
180
+ status=STATUS_OK if ok else STATUS_MISSING,
181
+ detail=f"{v.major}.{v.minor}.{v.micro}" + ("" if ok else " (need >= 3.11)"),
182
+ fix_cmd="" if ok else "Install Python 3.11+ from https://python.org/downloads/",
183
+ auto_fixable=False,
184
+ last_checked=time.time(),
185
+ )
186
+
187
+
188
+ def probe_search_deps() -> Component:
189
+ """sentence-transformers / torch / scikit-learn — semantic recall channel."""
190
+ missing = [m for m in ("sentence_transformers", "torch", "sklearn")
191
+ if not _module_present(m)]
192
+ ok = not missing
193
+ return Component(
194
+ key="search_deps",
195
+ label="Semantic search dependencies",
196
+ category=CATEGORY_RECOMMENDED,
197
+ status=STATUS_OK if ok else STATUS_MISSING,
198
+ detail="sentence-transformers, torch, scikit-learn"
199
+ if ok else "missing: " + ", ".join(missing),
200
+ fix_cmd="" if ok else "pip install 'superlocalmemory[search]'",
201
+ # NOT background-auto-installed: torch is ~2GB — a silent multi-GB
202
+ # download is a surprise the user must consent to. Fix command guides
203
+ # them; the daemon does not pull it unasked.
204
+ auto_fixable=False,
205
+ last_checked=time.time(),
206
+ )
207
+
208
+
209
+ def _probe_hf_model(key: str, label: str, repo_id: str, *,
210
+ category: str, auto_fixable: bool,
211
+ fix_cmd: str) -> Component:
212
+ cached = _hf_model_cached(repo_id)
213
+ if cached is True:
214
+ status, detail, fix = STATUS_OK, repo_id, ""
215
+ elif cached is False:
216
+ status, detail, fix = STATUS_MISSING, f"{repo_id} not in local cache", fix_cmd
217
+ else:
218
+ # huggingface_hub unavailable → can't verify; report degraded, not a
219
+ # false "missing" alarm.
220
+ status, detail, fix = (
221
+ STATUS_DEGRADED, "cannot verify (huggingface_hub unavailable)", "",
222
+ )
223
+ # Downloading model weights only helps when the runtime that loads them
224
+ # (sentence-transformers/torch) is already present. If search deps are
225
+ # missing, the fix is to install those first — not to pull weights.
226
+ can_auto = (
227
+ auto_fixable
228
+ and status == STATUS_MISSING
229
+ and _module_present("sentence_transformers")
230
+ )
231
+ return Component(
232
+ key=key, label=label, category=category,
233
+ status=status, detail=detail, fix_cmd=fix,
234
+ auto_fixable=can_auto,
235
+ last_checked=time.time(),
236
+ )
237
+
238
+
239
+ def probe_embedder_model(config: Any = None) -> Component:
240
+ if config is not None and _embedding_is_remote(config):
241
+ return Component(
242
+ key="embedder_model", label="Embedding model",
243
+ category=CATEGORY_REQUIRED, status=STATUS_OK,
244
+ detail="remote embedding endpoint (no local model required)",
245
+ last_checked=time.time(),
246
+ )
247
+ return _probe_hf_model(
248
+ "embedder_model", "Embedding model", _EMBED_MODEL,
249
+ category=CATEGORY_REQUIRED, auto_fixable=True,
250
+ fix_cmd="slm doctor --fix (or: slm warmup)",
251
+ )
252
+
253
+
254
+ def probe_reranker_model(config: Any = None) -> Component:
255
+ # Only auto-heal / flag the reranker when the user actually has it enabled.
256
+ enabled = True
257
+ try:
258
+ enabled = bool(getattr(config.retrieval, "use_cross_encoder", True)) \
259
+ if config is not None else True
260
+ except Exception:
261
+ enabled = True
262
+ comp = _probe_hf_model(
263
+ "reranker_model", "Reranker model", _RERANKER_MODEL,
264
+ category=CATEGORY_RECOMMENDED if enabled else CATEGORY_OPTIONAL,
265
+ auto_fixable=enabled,
266
+ fix_cmd="slm doctor --fix",
267
+ )
268
+ if not enabled and comp.status == STATUS_MISSING:
269
+ # Not enabled → absent is expected, not a problem.
270
+ return replace(comp, status=STATUS_OK,
271
+ detail="disabled (retrieval.use_cross_encoder=false)",
272
+ fix_cmd="", auto_fixable=False)
273
+ return comp
274
+
275
+
276
+ def probe_compressor_model() -> Component:
277
+ """LLMLingua-2 prose compressor (~560MB).
278
+
279
+ NEVER auto-downloaded by self-heal: it is large and only used by opt-in
280
+ aggressive optimize compression, where it lazy-downloads on first real
281
+ use. Reported as optional so the dashboard shows it without alarm.
282
+ """
283
+ comp = _probe_hf_model(
284
+ "compressor_model", "Prose compression model", _COMPRESSOR_MODEL,
285
+ category=CATEGORY_OPTIONAL, auto_fixable=False,
286
+ fix_cmd="Downloads automatically on first use of aggressive compression",
287
+ )
288
+ if comp.status == STATUS_MISSING:
289
+ return replace(comp, detail=comp.detail + " (lazy — downloads on first use)")
290
+ return comp
291
+
292
+
293
+ def probe_sqlite_vec() -> Component:
294
+ present = _module_present("sqlite_vec")
295
+ writable = pip_is_user_writable()
296
+ if present:
297
+ status, detail, fix, auto = STATUS_OK, "sqlite-vec installed", "", False
298
+ elif writable:
299
+ status, detail, fix, auto = (
300
+ STATUS_MISSING, "sqlite-vec not installed (vector index)",
301
+ "slm doctor --fix (or: pip install sqlite-vec)", True,
302
+ )
303
+ else:
304
+ status, detail, fix, auto = (
305
+ STATUS_MISSING, "sqlite-vec not installed; interpreter externally managed",
306
+ "pipx inject superlocalmemory sqlite-vec (or use a uv/venv install)",
307
+ False,
308
+ )
309
+ return Component(
310
+ key="sqlite_vec", label="Vector index (sqlite-vec)",
311
+ category=CATEGORY_RECOMMENDED, status=status, detail=detail,
312
+ fix_cmd=fix, auto_fixable=auto, last_checked=time.time(),
313
+ )
314
+
315
+
316
+ def _probe_optional_pkg(key: str, label: str, module: str, pip_name: str,
317
+ note: str) -> Component:
318
+ present = _module_present(module)
319
+ return Component(
320
+ key=key, label=label, category=CATEGORY_OPTIONAL,
321
+ status=STATUS_OK if present else STATUS_MISSING,
322
+ detail=f"{pip_name} installed" if present else f"{note}",
323
+ fix_cmd="" if present else f"pip install {pip_name}",
324
+ auto_fixable=False, # opt-in scale/feature backends — staged consent.
325
+ last_checked=time.time(),
326
+ )
327
+
328
+
329
+ def probe_lancedb() -> Component:
330
+ return _probe_optional_pkg(
331
+ "lancedb", "LanceDB scale backend", "lancedb", "lancedb",
332
+ "not installed (optional large-scale vector backend)",
333
+ )
334
+
335
+
336
+ def probe_cozo() -> Component:
337
+ return _probe_optional_pkg(
338
+ "cozo", "CozoDB graph backend", "pycozo", "pycozo",
339
+ "not installed (optional large-scale graph backend)",
340
+ )
341
+
342
+
343
+ def probe_llmlingua() -> Component:
344
+ return _probe_optional_pkg(
345
+ "llmlingua", "LLMLingua compressor lib", "llmlingua", "llmlingua",
346
+ "not installed (optional prose compression)",
347
+ )
348
+
349
+
350
+ def probe_ollama(config: Any = None) -> Component:
351
+ """Ollama reachability — only meaningful in Mode B.
352
+
353
+ Never auto-fixed: installing the Ollama binary or pulling a model is a
354
+ surprise network/disk action, so it is always a manual fix command.
355
+ """
356
+ mode = ""
357
+ api_base = "http://localhost:11434"
358
+ model = ""
359
+ try:
360
+ if config is not None:
361
+ mode = getattr(getattr(config, "mode", None), "value", "") or ""
362
+ api_base = getattr(getattr(config, "llm", None), "api_base", api_base) or api_base
363
+ model = getattr(getattr(config, "llm", None), "model", "") or ""
364
+ except Exception:
365
+ pass
366
+ if mode != "b":
367
+ return Component(
368
+ key="ollama", label="Ollama (local LLM)",
369
+ category=CATEGORY_OPTIONAL, status=STATUS_OK,
370
+ detail="not in use (Mode B only)", last_checked=time.time(),
371
+ )
372
+ try:
373
+ import httpx
374
+
375
+ resp = httpx.get(f"{api_base}/api/tags", timeout=5.0)
376
+ if resp.status_code == 200:
377
+ names = [m.get("name", "").split(":")[0]
378
+ for m in resp.json().get("models", [])]
379
+ if model and model.split(":")[0] not in names:
380
+ return Component(
381
+ key="ollama", label="Ollama (local LLM)",
382
+ category=CATEGORY_RECOMMENDED, status=STATUS_DEGRADED,
383
+ detail=f"running but '{model}' not pulled",
384
+ fix_cmd=f"ollama pull {model}", auto_fixable=False,
385
+ last_checked=time.time(),
386
+ )
387
+ return Component(
388
+ key="ollama", label="Ollama (local LLM)",
389
+ category=CATEGORY_RECOMMENDED, status=STATUS_OK,
390
+ detail=f"running, {len(names)} models", last_checked=time.time(),
391
+ )
392
+ status_detail = f"HTTP {resp.status_code}"
393
+ except Exception:
394
+ status_detail = f"not reachable at {api_base}"
395
+ return Component(
396
+ key="ollama", label="Ollama (local LLM)",
397
+ category=CATEGORY_RECOMMENDED, status=STATUS_MISSING,
398
+ detail=status_detail,
399
+ fix_cmd="Install/start Ollama: https://ollama.com then: brew services start ollama",
400
+ auto_fixable=False, last_checked=time.time(),
401
+ )
402
+
403
+
404
+ # --------------------------------------------------------------------------
405
+ # Transient overlay — live self-heal progress
406
+ # --------------------------------------------------------------------------
407
+
408
+ _transient_lock = threading.Lock()
409
+ _transient: dict[str, tuple[str, str]] = {} # key -> (status, detail)
410
+
411
+
412
+ def mark_transient(key: str, status: str, detail: str = "") -> None:
413
+ """Mark a component's live repair state (e.g. RETRYING while downloading)."""
414
+ with _transient_lock:
415
+ _transient[key] = (status, detail)
416
+
417
+
418
+ def clear_transient(key: str) -> None:
419
+ with _transient_lock:
420
+ _transient.pop(key, None)
421
+
422
+
423
+ def _apply_transient(comp: Component) -> Component:
424
+ with _transient_lock:
425
+ override = _transient.get(comp.key)
426
+ if override is None:
427
+ return comp
428
+ status, detail = override
429
+ # A confirmed-present component supersedes a stale "retrying" marker.
430
+ if comp.status == STATUS_OK:
431
+ return comp
432
+ return replace(comp, status=status, detail=detail or comp.detail)
433
+
434
+
435
+ # --------------------------------------------------------------------------
436
+ # Aggregation
437
+ # --------------------------------------------------------------------------
438
+
439
+ def probe_all(config: Any = None) -> list[Component]:
440
+ """Probe every component once and return a fresh list (transient overlaid)."""
441
+ probes = [
442
+ probe_python(),
443
+ probe_search_deps(),
444
+ probe_embedder_model(config),
445
+ probe_reranker_model(config),
446
+ probe_compressor_model(),
447
+ probe_sqlite_vec(),
448
+ probe_llmlingua(),
449
+ probe_lancedb(),
450
+ probe_cozo(),
451
+ probe_ollama(config),
452
+ ]
453
+ return [_apply_transient(c) for c in probes]
454
+
455
+
456
+ def auto_fixable_missing(config: Any = None) -> list[Component]:
457
+ """The subset SLM may repair without asking — drives self-heal + doctor --fix."""
458
+ return [c for c in probe_all(config)
459
+ if c.status == STATUS_MISSING and c.auto_fixable]
460
+
461
+
462
+ def snapshot(config: Any = None) -> dict[str, Any]:
463
+ """Full registry snapshot for /api/v3/components, doctor --json, dashboard."""
464
+ comps = probe_all(config)
465
+ counts = {STATUS_OK: 0, STATUS_MISSING: 0,
466
+ STATUS_DEGRADED: 0, STATUS_RETRYING: 0}
467
+ for c in comps:
468
+ counts[c.status] = counts.get(c.status, 0) + 1
469
+ missing_needed = [c for c in comps
470
+ if c.status == STATUS_MISSING
471
+ and c.category in (CATEGORY_REQUIRED, CATEGORY_RECOMMENDED)]
472
+ healthy = not missing_needed and counts[STATUS_RETRYING] == 0
473
+ return {
474
+ "components": [c.as_dict() for c in comps],
475
+ "summary": {
476
+ "ok": counts[STATUS_OK],
477
+ "missing": counts[STATUS_MISSING],
478
+ "degraded": counts[STATUS_DEGRADED],
479
+ "retrying": counts[STATUS_RETRYING],
480
+ "auto_fixable_missing": sum(
481
+ 1 for c in comps
482
+ if c.status == STATUS_MISSING and c.auto_fixable
483
+ ),
484
+ "healthy": healthy,
485
+ },
486
+ "generated_at": time.time(),
487
+ }
@@ -273,6 +273,27 @@ class RetrievalConfig:
273
273
  agentic_max_rounds: int = 3
274
274
  agentic_confidence_threshold: float = 0.3
275
275
 
276
+ # v3.8.2: Client-driven agentic (the recall spine's global flag).
277
+ # The agent hot path (CLI / MCP / plugins) is consumed by a frontier
278
+ # LLM (Claude Code, Copilot, Codex, …) that reformulates multi-hop /
279
+ # low-confidence queries far better than the local Ollama model. So the
280
+ # hot path DELEGATES the agentic reformulation loop to that calling LLM:
281
+ # it returns fast local retrieval (all six channels + reranker) plus the
282
+ # confidence signals (no_confident_match / answer_confidence / abstained),
283
+ # and never spends an internal LLM round unless a caller asks for it.
284
+ # True (default) → hot path skips the internal agentic round
285
+ # (equivalent to fast=True) and the client drives
286
+ # refinement. Consistent ~1.5-2s recall, no LLM tail.
287
+ # False → hot path always runs the internal agentic round
288
+ # when a query looks multi-hop / low-confidence
289
+ # (equivalent to fast=False) — for deployments with
290
+ # no smart client in front of SLM.
291
+ # This ONLY sets the default when a caller does not pass ``fast``
292
+ # explicitly. The dashboard human path (search-all → cluster summary)
293
+ # passes fast=False directly and always keeps internal synthesis.
294
+ # Env kill-switch: SLM_HOT_PATH_INTERNAL_AGENTIC=1 forces internal-on.
295
+ client_driven_agentic: bool = True
296
+
276
297
  # Spreading activation
277
298
  spreading_activation_decay: float = 0.7
278
299
  spreading_activation_threshold: float = 0.1
@@ -652,9 +652,22 @@ class EmbeddingService:
652
652
  release_embedding_lock()
653
653
 
654
654
  def _reset_idle_timer(self) -> None:
655
- """Reset the configurable worker-idle timer."""
655
+ """Reset the configurable worker-idle timer.
656
+
657
+ F5 fix: opportunistically evict the worker mid-window when memory
658
+ pressure is detected (``_check_memory_pressure`` returns False).
659
+ This prevents the idle-timeout window from holding a worker alive
660
+ while system memory is exhausted.
661
+ """
656
662
  if self._idle_timer is not None:
657
663
  self._idle_timer.cancel()
664
+ if not self._check_memory_pressure():
665
+ # Pressure detected — kill worker immediately; do not schedule a
666
+ # new idle timer so no further embedding work is attempted until
667
+ # the next explicit request reloads the worker.
668
+ self._kill_worker()
669
+ self._idle_timer = None
670
+ return
658
671
  self._idle_timer = threading.Timer(
659
672
  _IDLE_TIMEOUT_SECONDS, self.unload,
660
673
  )
@@ -369,6 +369,7 @@ class MemoryEngine:
369
369
  from superlocalmemory.core.maintenance_scheduler import MaintenanceScheduler
370
370
  self._maintenance_scheduler = MaintenanceScheduler(
371
371
  self._db, self._config, self._profile_id,
372
+ embedder=self._embedder, # v3.8.2: periodic NULL-embedding self-heal
372
373
  )
373
374
  self._maintenance_scheduler.start()
374
375
  except Exception as exc:
@@ -612,7 +613,7 @@ class MemoryEngine:
612
613
  mode: Mode | None = None, limit: int = CANONICAL_RECALL_LIMIT,
613
614
  agent_id: str = "unknown",
614
615
  session_id: str | None = None,
615
- fast: bool = False,
616
+ fast: bool | None = None,
616
617
  *,
617
618
  include_global: bool | None = None,
618
619
  include_shared: bool | None = None,
@@ -627,10 +628,13 @@ class MemoryEngine:
627
628
  ``put_nowait`` and the actual ``pending_outcomes`` INSERT runs
628
629
  on a background worker.
629
630
 
630
- ``fast=True`` is the latency-bounded path: it retains all six local
631
- retrieval channels but skips remote agentic verification. Use
632
- ``fast=False`` only when maximum multi-round quality matters more than
633
- response time.
631
+ ``fast`` controls only the internal agentic verification round; all six
632
+ local retrieval channels + reranker run regardless. ``fast=None`` (the
633
+ default) resolves to the client-driven-agentic policy: the agent hot
634
+ path skips the internal round (equivalent to ``fast=True``) and lets the
635
+ calling LLM drive query refinement using the returned confidence signals.
636
+ Pass ``fast=False`` to force the internal agentic round when no smart
637
+ client sits in front of SLM. See ``recall_pipeline.resolve_hot_path_fast``.
634
638
 
635
639
  Multi-scope: ``include_global`` / ``include_shared`` control which
636
640
  scopes participate in retrieval. ``None`` (the default) means "use the
@@ -21,8 +21,12 @@ from dataclasses import dataclass, field
21
21
  from enum import Enum
22
22
  from typing import Any, Callable
23
23
 
24
+ import logging
25
+
24
26
  from superlocalmemory.storage.database import DatabaseManager
25
27
 
28
+ logger = logging.getLogger("superlocalmemory.ingestion_command")
29
+
26
30
  _MATERIALIZATION_LOCKS = tuple(threading.RLock() for _ in range(64))
27
31
  _MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS = 10
28
32
 
@@ -545,18 +549,31 @@ class IngestionCommand:
545
549
  interval = min(30.0, max(0.1, self._lease_seconds / 3.0))
546
550
 
547
551
  def heartbeat() -> None:
548
- while not stop.wait(interval):
549
- try:
550
- renewed = self.repository.renew_enriching_lease(
551
- operation_id,
552
- owner=self._owner,
553
- lease_seconds=self._lease_seconds,
554
- )
555
- except sqlite3.Error:
556
- continue
557
- if not renewed:
558
- lost.set()
559
- return
552
+ # F9 fix: outer broad catch ensures lost.set() is always called
553
+ # when the heartbeat thread dies for any reason (not just sqlite3.Error).
554
+ # Without this, an AttributeError or unexpected exception would kill
555
+ # the thread silently, leaving lost=False while the lease has expired.
556
+ try:
557
+ while not stop.wait(interval):
558
+ try:
559
+ renewed = self.repository.renew_enriching_lease(
560
+ operation_id,
561
+ owner=self._owner,
562
+ lease_seconds=self._lease_seconds,
563
+ )
564
+ except sqlite3.Error:
565
+ continue
566
+ if not renewed:
567
+ lost.set()
568
+ return
569
+ except Exception:
570
+ logger.warning(
571
+ "heartbeat thread died unexpectedly for operation %s — "
572
+ "signalling lease lost",
573
+ operation_id,
574
+ exc_info=True,
575
+ )
576
+ lost.set()
560
577
 
561
578
  thread = threading.Thread(
562
579
  target=heartbeat,
@@ -569,10 +586,13 @@ class IngestionCommand:
569
586
  finally:
570
587
  stop.set()
571
588
  thread.join(timeout=max(1.0, interval * 2))
572
- if lost.is_set():
573
- raise LeaseLost(
574
- f"ingestion lease lost for operation {operation_id}"
575
- )
589
+ # F7 fix: check inside finally so LeaseLost wins even when callback
590
+ # raised. Without this, the original exception would propagate and
591
+ # the LeaseLost signal would be masked.
592
+ if lost.is_set():
593
+ raise LeaseLost(
594
+ f"ingestion lease lost for operation {operation_id}"
595
+ )
576
596
  return result
577
597
 
578
598
  def submit(self, request: IngestionRequest) -> IngestionOperation: