superlocalmemory 3.8.0 → 3.8.2

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 (134) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +32 -120
  3. package/package.json +9 -2
  4. package/plugin/.claude-plugin/plugin.json +1 -2
  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 +2 -1
  32. package/scripts/postinstall.js +7 -1
  33. package/src/superlocalmemory/__init__.py +1 -1
  34. package/src/superlocalmemory/cli/commands.py +494 -9
  35. package/src/superlocalmemory/cli/daemon.py +7 -0
  36. package/src/superlocalmemory/cli/loop_cmd.py +2 -7
  37. package/src/superlocalmemory/cli/main.py +72 -7
  38. package/src/superlocalmemory/cli/setup_wizard.py +142 -16
  39. package/src/superlocalmemory/cli/version_banner.py +17 -3
  40. package/src/superlocalmemory/core/backend_orchestrator.py +18 -16
  41. package/src/superlocalmemory/core/component_healer.py +144 -0
  42. package/src/superlocalmemory/core/component_registry.py +487 -0
  43. package/src/superlocalmemory/core/config.py +21 -0
  44. package/src/superlocalmemory/core/embedding_worker.py +4 -5
  45. package/src/superlocalmemory/core/embeddings.py +132 -45
  46. package/src/superlocalmemory/core/engine.py +29 -22
  47. package/src/superlocalmemory/core/engine_ingestion.py +332 -45
  48. package/src/superlocalmemory/core/ingestion_command.py +154 -25
  49. package/src/superlocalmemory/core/injection.py +12 -7
  50. package/src/superlocalmemory/core/maintenance.py +43 -0
  51. package/src/superlocalmemory/core/maintenance_scheduler.py +44 -6
  52. package/src/superlocalmemory/core/recall_pipeline.py +42 -4
  53. package/src/superlocalmemory/core/store_pipeline.py +195 -20
  54. package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
  55. package/src/superlocalmemory/hooks/portable_kit.py +34 -2
  56. package/src/superlocalmemory/learning/model_rollback.py +3 -0
  57. package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
  58. package/src/superlocalmemory/learning/reward.py +50 -0
  59. package/src/superlocalmemory/learning/source_quality.py +523 -1
  60. package/src/superlocalmemory/loops/ledger.py +25 -5
  61. package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
  62. package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
  63. package/src/superlocalmemory/mcp/server.py +11 -30
  64. package/src/superlocalmemory/mcp/tools_active.py +1 -1
  65. package/src/superlocalmemory/mcp/tools_core.py +21 -5
  66. package/src/superlocalmemory/mcp/tools_learning.py +2 -2
  67. package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
  68. package/src/superlocalmemory/retrieval/engine.py +53 -21
  69. package/src/superlocalmemory/retrieval/reranker.py +3 -4
  70. package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
  71. package/src/superlocalmemory/server/config_file.py +90 -0
  72. package/src/superlocalmemory/server/origin.py +50 -0
  73. package/src/superlocalmemory/server/routes/backup.py +293 -70
  74. package/src/superlocalmemory/server/routes/behavioral.py +342 -61
  75. package/src/superlocalmemory/server/routes/brain.py +57 -16
  76. package/src/superlocalmemory/server/routes/config_api.py +84 -82
  77. package/src/superlocalmemory/server/routes/entity.py +100 -23
  78. package/src/superlocalmemory/server/routes/evolution.py +103 -100
  79. package/src/superlocalmemory/server/routes/learning.py +286 -105
  80. package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
  81. package/src/superlocalmemory/server/routes/memories.py +8 -3
  82. package/src/superlocalmemory/server/routes/mesh.py +121 -32
  83. package/src/superlocalmemory/server/routes/ratelimit.py +33 -25
  84. package/src/superlocalmemory/server/routes/stats.py +93 -155
  85. package/src/superlocalmemory/server/routes/token.py +3 -13
  86. package/src/superlocalmemory/server/routes/v3_api.py +184 -20
  87. package/src/superlocalmemory/server/unified_daemon.py +732 -41
  88. package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
  89. package/src/superlocalmemory/storage/migration_runner.py +79 -1
  90. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
  91. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
  92. package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
  93. package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
  94. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  95. package/src/superlocalmemory/storage/schema.py +49 -1
  96. package/src/superlocalmemory/storage/schema_v32.py +2 -0
  97. package/src/superlocalmemory/storage/schema_v347.py +4 -0
  98. package/src/superlocalmemory/ui/index.html +6 -8
  99. package/src/superlocalmemory/ui/js/core.js +52 -9
  100. package/src/superlocalmemory/ui/js/dashboard.js +169 -82
  101. package/src/superlocalmemory/ui/js/od-backup.js +156 -65
  102. package/src/superlocalmemory/ui/js/od-brain.js +88 -51
  103. package/src/superlocalmemory/ui/js/od-components.js +147 -0
  104. package/src/superlocalmemory/ui/js/od-entities.js +65 -22
  105. package/src/superlocalmemory/ui/js/od-graph.js +46 -4
  106. package/src/superlocalmemory/ui/js/od-health.js +18 -0
  107. package/src/superlocalmemory/ui/js/od-memories.js +84 -5
  108. package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
  109. package/src/superlocalmemory/ui/js/od-operations.js +36 -0
  110. package/src/superlocalmemory/ui/js/od-settings.js +186 -63
  111. package/src/superlocalmemory/ui/js/od-shell.js +249 -33
  112. package/src/superlocalmemory/ui/js/od-skills.js +44 -17
  113. package/src/superlocalmemory/ui/js/settings.js +15 -1
  114. package/plugin-src/.mcp.json +0 -12
  115. package/plugin-src/agents/slm-governance-advisor.md +0 -80
  116. package/plugin-src/agents/slm-loop-runner.md +0 -71
  117. package/plugin-src/agents/slm-memory-advisor.md +0 -49
  118. package/plugin-src/agents/slm-optimize-advisor.md +0 -44
  119. package/plugin-src/commands/slm-loop.md +0 -31
  120. package/plugin-src/hooks/.gitkeep +0 -0
  121. package/plugin-src/hooks/hooks.json +0 -102
  122. package/plugin-src/manifest.json +0 -30
  123. package/plugin-src/requirements.txt +0 -1
  124. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  125. package/plugin-src/scripts/ensure-venv.bat +0 -122
  126. package/plugin-src/scripts/ensure-venv.sh +0 -105
  127. package/plugin-src/scripts/slm-launch +0 -62
  128. package/plugin-src/scripts/slm-launch.bat +0 -23
  129. package/plugin-src/settings.json +0 -25
  130. package/plugin-src/skills/slm-governance/SKILL.md +0 -248
  131. package/plugin-src/skills/slm-loop/SKILL.md +0 -99
  132. package/plugin-src/skills/slm-mesh/SKILL.md +0 -282
  133. package/plugin-src/skills/slm-profile/SKILL.md +0 -148
  134. package/plugin-src/skills/slm-scope/SKILL.md +0 -176
@@ -21,9 +21,14 @@ 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))
31
+ _MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS = 10
27
32
 
28
33
 
29
34
  def _materialization_lock(operation_id: str) -> threading.RLock:
@@ -51,6 +56,10 @@ class OperationInProgress(RuntimeError):
51
56
  """Another live lease owner is materializing this operation."""
52
57
 
53
58
 
59
+ class LeaseLost(OperationInProgress):
60
+ """The materializer no longer owns its durable operation lease."""
61
+
62
+
54
63
  def _canonical_json(value: Any) -> str:
55
64
  return json.dumps(value, sort_keys=True, separators=(",", ":"))
56
65
 
@@ -284,12 +293,19 @@ class IngestionOperationRepository:
284
293
  self._from_row(row)
285
294
  for row in self.db.execute(
286
295
  "SELECT * FROM ingestion_operations "
287
- "WHERE (state='queryable' AND created_at <= "
296
+ "WHERE attempt_count < ? AND ("
297
+ "(state='queryable' AND created_at <= "
288
298
  "strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) "
289
299
  "OR (state='failed' AND next_retry_at <= ?) "
290
- "OR (state='enriching' AND lease_expires_at <= ?) "
300
+ "OR (state='enriching' AND lease_expires_at <= ?)) "
291
301
  "ORDER BY created_at, rowid LIMIT ?",
292
- (grace_modifier, now, now, max(1, int(limit))),
302
+ (
303
+ _MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS,
304
+ grace_modifier,
305
+ now,
306
+ now,
307
+ max(1, int(limit)),
308
+ ),
293
309
  )
294
310
  ]
295
311
 
@@ -415,6 +431,27 @@ class IngestionOperationRepository:
415
431
  )
416
432
  return operation
417
433
 
434
+ def renew_enriching_lease(
435
+ self,
436
+ operation_id: str,
437
+ *,
438
+ owner: str,
439
+ lease_seconds: float,
440
+ ) -> bool:
441
+ """Extend a live lease only while the same owner still holds it."""
442
+ rows = self.db.execute(
443
+ "UPDATE ingestion_operations SET lease_expires_at=?, "
444
+ "updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') "
445
+ "WHERE operation_id=? AND state='enriching' AND lease_owner=? "
446
+ "RETURNING operation_id",
447
+ (
448
+ time.time() + max(1.0, float(lease_seconds)),
449
+ operation_id,
450
+ owner,
451
+ ),
452
+ )
453
+ return bool(rows)
454
+
418
455
  def finish_enriching(
419
456
  self,
420
457
  operation_id: str,
@@ -469,6 +506,7 @@ class MaterializationResult:
469
506
 
470
507
  fact_ids: tuple[str, ...]
471
508
  derivation_state: dict[str, bool]
509
+ last_error: str = ""
472
510
 
473
511
 
474
512
  QueryableWriter = Callable[[IngestionRequest, str], list[str]]
@@ -500,6 +538,63 @@ class IngestionCommand:
500
538
  self._lease_seconds = max(1.0, float(lease_seconds))
501
539
  self._owner = f"ingestion-worker:{uuid.uuid4().hex}"
502
540
 
541
+ def _run_with_lease_heartbeat(
542
+ self,
543
+ operation_id: str,
544
+ callback: Callable[[], Any],
545
+ ) -> Any:
546
+ """Run slow work while periodically renewing its owner-bound lease."""
547
+ stop = threading.Event()
548
+ lost = threading.Event()
549
+ interval = min(30.0, max(0.1, self._lease_seconds / 3.0))
550
+
551
+ def heartbeat() -> None:
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()
577
+
578
+ thread = threading.Thread(
579
+ target=heartbeat,
580
+ name=f"ingestion-lease-heartbeat:{operation_id[:8]}",
581
+ daemon=True,
582
+ )
583
+ thread.start()
584
+ try:
585
+ result = callback()
586
+ finally:
587
+ stop.set()
588
+ thread.join(timeout=max(1.0, interval * 2))
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
+ )
596
+ return result
597
+
503
598
  def submit(self, request: IngestionRequest) -> IngestionOperation:
504
599
  operation, _created = self.submit_with_status(request)
505
600
  return operation
@@ -548,27 +643,41 @@ class IngestionCommand:
548
643
  )
549
644
  if enriching.state is IngestionState.COMPLETE:
550
645
  return enriching
551
- if enriching.final_fact_ids:
646
+ if (
647
+ enriching.final_fact_ids
648
+ and all(enriching.derivation_state.values())
649
+ ):
552
650
  return self._project_and_complete(enriching)
553
651
  try:
652
+ # Materialization is a durable saga, not one long SQLite
653
+ # transaction. Extractors, embedders, and local model calls can
654
+ # take minutes on a mature installation; keeping a write
655
+ # transaction open across that work blocks every interactive
656
+ # remember/update/delete and makes the dashboard appear dead.
657
+ #
658
+ # The materializer commits its relational checkpoints in short
659
+ # database operations. The operation lease and derivation state
660
+ # remain the recovery boundary, and only the final state-machine
661
+ # checkpoint is grouped atomically below.
662
+ materialized = self._run_with_lease_heartbeat(
663
+ operation_id,
664
+ lambda: self._materializer(enriching),
665
+ )
666
+ if isinstance(materialized, MaterializationResult):
667
+ fact_ids = tuple(materialized.fact_ids)
668
+ derivation_state = dict(materialized.derivation_state)
669
+ materialization_error = materialized.last_error
670
+ else:
671
+ fact_ids = tuple(materialized)
672
+ derivation_state = {"materializer": True}
673
+ materialization_error = ""
674
+ if not fact_ids:
675
+ raise RuntimeError("materialization produced no final facts")
676
+ incomplete = sorted(
677
+ name for name, complete in derivation_state.items()
678
+ if not complete
679
+ )
554
680
  with self.repository.db.transaction():
555
- materialized = self._materializer(enriching)
556
- if isinstance(materialized, MaterializationResult):
557
- fact_ids = tuple(materialized.fact_ids)
558
- derivation_state = dict(materialized.derivation_state)
559
- else:
560
- fact_ids = tuple(materialized)
561
- derivation_state = {"materializer": True}
562
- if not fact_ids:
563
- raise RuntimeError("materialization produced no final facts")
564
- incomplete = sorted(
565
- name for name, complete in derivation_state.items()
566
- if not complete
567
- )
568
- if incomplete:
569
- raise RuntimeError(
570
- "incomplete derivation stages: " + ", ".join(incomplete)
571
- )
572
681
  checkpointed = self.repository.checkpoint_enriching(
573
682
  operation_id,
574
683
  final_fact_ids=fact_ids,
@@ -577,6 +686,21 @@ class IngestionCommand:
577
686
  lease_owner=self._owner,
578
687
  lease_seconds=self._lease_seconds,
579
688
  )
689
+ if materialization_error or incomplete:
690
+ error = materialization_error or (
691
+ "incomplete derivation stages: " + ", ".join(incomplete)
692
+ )
693
+ return self.repository.finish_enriching(
694
+ operation_id,
695
+ owner=self._owner,
696
+ target=IngestionState.FAILED,
697
+ final_fact_ids=fact_ids,
698
+ derivation_version=self._derivation_version,
699
+ derivation_state=derivation_state,
700
+ last_error=error,
701
+ )
702
+ except LeaseLost:
703
+ raise
580
704
  except Exception as exc:
581
705
  return self.repository.finish_enriching(
582
706
  operation_id,
@@ -598,10 +722,13 @@ class IngestionCommand:
598
722
  owner=self._owner,
599
723
  lease_seconds=self._lease_seconds,
600
724
  )
601
- projection_state = (
602
- dict(self._projector(operation))
603
- if self._projector is not None
604
- else {}
725
+ projection_state = self._run_with_lease_heartbeat(
726
+ operation.operation_id,
727
+ lambda: (
728
+ dict(self._projector(operation))
729
+ if self._projector is not None
730
+ else {}
731
+ ),
605
732
  )
606
733
  combined = {**operation.derivation_state, **projection_state}
607
734
  incomplete = sorted(
@@ -619,6 +746,8 @@ class IngestionCommand:
619
746
  derivation_version=self._derivation_version,
620
747
  derivation_state=combined,
621
748
  )
749
+ except LeaseLost:
750
+ raise
622
751
  except Exception as exc:
623
752
  return self.repository.finish_enriching(
624
753
  operation.operation_id,
@@ -128,20 +128,25 @@ def _load_injection_config():
128
128
 
129
129
 
130
130
  def estimate_tokens(text: str) -> int:
131
- """chars/4 heuristic. Optional tiktoken if installed (best-effort).
132
-
133
- tiktoken is an optional dependency (pip install superlocalmemory[injection]).
134
- Falls back to chars/4 if tiktoken is not installed or raises any error.
135
- All exception types (MemoryError, SystemError etc.) are subclasses of
136
- Exception in Python 3.11, so the bare except Exception covers all paths.
131
+ """Estimate context tokens without loading native code by default.
132
+
133
+ The stable default is the deterministic ``chars / 4`` heuristic. Loading
134
+ the optional native ``tiktoken`` extension in a long-lived daemon that also
135
+ hosts torch/scipy workers can terminate the whole process on a native fault;
136
+ Python exception handling cannot recover from SIGBUS/SIGSEGV. Users who
137
+ explicitly need exact OpenAI token counts can opt in with
138
+ ``SLM_INJECTION_EXACT_TOKENS=1``.
137
139
  """
138
140
  if not text:
139
141
  return 0
142
+ heuristic = max(1, len(text) // 4)
143
+ if os.environ.get("SLM_INJECTION_EXACT_TOKENS", "0") != "1":
144
+ return heuristic
140
145
  try:
141
146
  import tiktoken
142
147
  return len(tiktoken.get_encoding("cl100k_base").encode(text))
143
148
  except Exception:
144
- return max(1, len(text) // 4)
149
+ return heuristic
145
150
 
146
151
 
147
152
  def resolve_budget(mode: str, cfg) -> int:
@@ -109,6 +109,7 @@ def run_maintenance(
109
109
  db: DatabaseManager,
110
110
  config: SLMConfig,
111
111
  profile_id: str = "default",
112
+ embedder: object | None = None,
112
113
  ) -> dict[str, int]:
113
114
  """Run background maintenance on mathematical layers.
114
115
 
@@ -116,6 +117,11 @@ def run_maintenance(
116
117
  db: Database manager.
117
118
  config: Full SLM configuration.
118
119
  profile_id: Scope to this profile.
120
+ embedder: Optional embedder for self-healing NULL-embedding backfill.
121
+ When provided and NULL embeddings exist, up to 100 facts are
122
+ embedded per maintenance pass so the DB converges over time.
123
+ Pass ``None`` (default) to skip the backfill — existing callers
124
+ are unaffected.
119
125
 
120
126
  Returns:
121
127
  Dict of counts: langevin_updated, sheaf_checked, etc.
@@ -128,6 +134,7 @@ def run_maintenance(
128
134
  "entity_summaries_consolidated": 0, # V3.4.40
129
135
  "orphan_metadata_gc": 0, # v3.6.4 (P1-3)
130
136
  "expansion_backfilled": 0, # T3b
137
+ "embeddings_backfilled": 0, # v3.8.x NULL-embedding self-heal
131
138
  }
132
139
 
133
140
  # P1-3 (embeddings-vector-02): sweep orphaned embedding_metadata left by
@@ -138,6 +145,42 @@ def run_maintenance(
138
145
  except Exception as exc: # pragma: no cover - defensive
139
146
  logger.debug("orphan metadata GC skipped: %s", exc)
140
147
 
148
+ # v3.8.x: self-healing NULL-embedding backfill. Facts stored while the
149
+ # embedder was unavailable end up with NULL embedding and are invisible to
150
+ # semantic recall. When an embedder is available, embed up to 100 facts
151
+ # per maintenance pass so the DB converges without blocking the caller.
152
+ if embedder is not None:
153
+ try:
154
+ from superlocalmemory.storage.embedding_migrator import (
155
+ backfill_missing_embeddings,
156
+ )
157
+
158
+ # Guard: skip entirely when nothing needs backfilling.
159
+ null_rows = db.execute(
160
+ "SELECT count(*) AS c FROM atomic_facts "
161
+ "WHERE embedding IS NULL AND profile_id = ?",
162
+ (profile_id,),
163
+ )
164
+ null_count = int(null_rows[0]["c"]) if null_rows else 0
165
+ if null_count > 0:
166
+ result = backfill_missing_embeddings(
167
+ config,
168
+ db,
169
+ embedder,
170
+ batch_size=50,
171
+ limit=100,
172
+ )
173
+ counts["embeddings_backfilled"] = result["embedded"]
174
+ if result["embedded"] > 0:
175
+ logger.info(
176
+ "Maintenance embedding backfill: %d facts embedded, "
177
+ "%d remaining.",
178
+ result["embedded"],
179
+ result["remaining_null"],
180
+ )
181
+ except Exception as exc:
182
+ logger.debug("embedding backfill skipped during maintenance: %s", exc)
183
+
141
184
  facts = db.get_all_facts(profile_id)
142
185
  if not facts:
143
186
  return counts
@@ -8,7 +8,8 @@ V3.3.13: Periodically triggers Langevin/Ebbinghaus/Sheaf maintenance
8
8
  so users don't need to call run_maintenance manually.
9
9
 
10
10
  Configurable interval via ForgettingConfig.scheduler_interval_minutes.
11
- Defaults to 30 min. Disabled during benchmarks (no config.forgetting.enabled).
11
+ Defaults to 30 min. Optional forgetting/math work follows
12
+ ``config.forgetting.enabled``; tier evaluation and bounded housekeeping do not.
12
13
 
13
14
  Part of Qualixar | Author: Varun Pratap Bhardwaj
14
15
  License: AGPL-3.0-or-later
@@ -39,10 +40,16 @@ class MaintenanceScheduler:
39
40
  db: DatabaseManager,
40
41
  config: SLMConfig,
41
42
  profile_id: str = "default",
43
+ embedder: object | None = None,
42
44
  ) -> None:
43
45
  self._db = db
44
46
  self._config = config
45
47
  self._profile_id = profile_id
48
+ # v3.8.2 self-heal: when provided, periodic maintenance backfills
49
+ # NULL embeddings so a DB stays fully queryable over time even if
50
+ # facts were stored while the embedder was unavailable. Runs
51
+ # independently of forgetting.enabled (see _run).
52
+ self._embedder = embedder
46
53
  self._timer: threading.Timer | None = None
47
54
  self._running = False
48
55
  self._interval = config.forgetting.scheduler_interval_minutes * 60.0
@@ -78,13 +85,44 @@ class MaintenanceScheduler:
78
85
  """Execute maintenance + auto-backup check, then schedule next run."""
79
86
  if not self._running:
80
87
  return
81
- for profile_id in self._profile_ids():
88
+ # v3.8.2 self-heal: bounded NULL-embedding backfill runs every cycle
89
+ # INDEPENDENTLY of forgetting.enabled and across ALL profiles — a fact
90
+ # stored while the embedder was down must become queryable again without
91
+ # the user touching anything. Idempotent + bounded (200/pass) so it
92
+ # converges quietly and is a no-op once coverage is complete.
93
+ if self._embedder is not None:
82
94
  try:
83
- from superlocalmemory.core.maintenance import run_maintenance
84
- counts = run_maintenance(self._db, self._config, profile_id)
85
- logger.info("Scheduled maintenance complete for %s: %s", profile_id, counts)
95
+ from superlocalmemory.storage.embedding_migrator import (
96
+ backfill_missing_embeddings,
97
+ )
98
+ r = backfill_missing_embeddings(
99
+ self._config, self._db, self._embedder,
100
+ limit=50, all_profiles=True,
101
+ )
102
+ if r.get("embedded"):
103
+ logger.info(
104
+ "Self-heal backfill: %d embedded, %d remaining",
105
+ r["embedded"], r["remaining_null"],
106
+ )
86
107
  except Exception as exc:
87
- logger.warning("Scheduled maintenance failed for %s: %s", profile_id, exc)
108
+ logger.debug("Self-heal backfill skipped: %s", exc)
109
+
110
+ for profile_id in self._profile_ids():
111
+ if self._config.forgetting.enabled:
112
+ try:
113
+ from superlocalmemory.core.maintenance import run_maintenance
114
+ counts = run_maintenance(self._db, self._config, profile_id)
115
+ logger.info(
116
+ "Scheduled maintenance complete for %s: %s",
117
+ profile_id,
118
+ counts,
119
+ )
120
+ except Exception as exc:
121
+ logger.warning(
122
+ "Scheduled maintenance failed for %s: %s",
123
+ profile_id,
124
+ exc,
125
+ )
88
126
 
89
127
  # V3.4.11: Graph pruning (remove orphan edges)
90
128
  try:
@@ -604,6 +604,33 @@ def apply_v2_bandit_ensemble(
604
604
  # run_recall (was MemoryEngine.recall)
605
605
  # ---------------------------------------------------------------------------
606
606
 
607
+ def resolve_hot_path_fast(fast: bool | None, config: "SLMConfig") -> bool:
608
+ """Resolve the recall ``fast`` flag when a caller leaves it unset (None).
609
+
610
+ v3.8.2 client-driven agentic: the agent hot path (CLI / MCP / plugins) is
611
+ consumed by a frontier LLM (Claude Code, Copilot, Codex, …) that reformulates
612
+ multi-hop / low-confidence queries far better than the local Ollama model.
613
+ So an unset ``fast`` defaults to True — skip the internal agentic round and
614
+ let the calling LLM drive refinement — whenever ``retrieval.client_driven_agentic``
615
+ is on (the ship default). An explicit ``True``/``False`` from the caller
616
+ always wins (the dashboard search path passes ``True`` for a snappy list;
617
+ a no-smart-client deployment can pass ``False``). Env override
618
+ ``SLM_HOT_PATH_INTERNAL_AGENTIC=1`` forces internal-agentic-on globally.
619
+
620
+ This is the single resolution point: every recall path (HTTP, MCP, CLI,
621
+ in-process adapter) funnels through ``run_recall`` and calls this, so the
622
+ client-driven default is consistent everywhere by construction.
623
+ """
624
+ if fast is not None:
625
+ return bool(fast)
626
+ import os
627
+ rc = getattr(config, "retrieval", None)
628
+ client_driven = bool(getattr(rc, "client_driven_agentic", True))
629
+ if os.environ.get("SLM_HOT_PATH_INTERNAL_AGENTIC") == "1":
630
+ client_driven = False
631
+ return client_driven
632
+
633
+
607
634
  def run_recall(
608
635
  query: str,
609
636
  profile_id: str,
@@ -620,7 +647,7 @@ def run_recall(
620
647
  hooks: HookRegistry,
621
648
  access_log: Any = None,
622
649
  auto_linker: Any = None,
623
- fast: bool = False,
650
+ fast: bool | None = None,
624
651
  include_global: bool = False,
625
652
  include_shared: bool = False,
626
653
  window: str | tuple[str, str] | None = None,
@@ -632,8 +659,11 @@ def run_recall(
632
659
 
633
660
  Pipeline: retrieval -> agentic sufficiency (if configured) -> post-recall updates.
634
661
 
635
- ``fast=True`` skips spreading activation and remote agentic verification,
636
- retaining the bounded single-pass retrieval channels.
662
+ ``fast=True`` skips the internal agentic verification round while retaining
663
+ the six local retrieval channels + reranker. ``fast=None`` (unset) resolves
664
+ to the client-driven-agentic default (see ``resolve_hot_path_fast``): the
665
+ agent hot path skips the internal round and delegates refinement to the
666
+ calling LLM. ``fast=False`` forces the internal agentic round.
637
667
  """
638
668
  # Pre-operation hooks
639
669
  hook_ctx = {
@@ -646,6 +676,12 @@ def run_recall(
646
676
 
647
677
  m = mode or config.mode
648
678
 
679
+ # v3.8.2: resolve the client-driven-agentic default when a caller left
680
+ # ``fast`` unset (None). After this line ``fast`` is a concrete bool, so
681
+ # the agentic gate below (``if not fast``) behaves identically for every
682
+ # entry point that funnels through here.
683
+ fast = resolve_hot_path_fast(fast, config)
684
+
649
685
  # v3.5.0 diagnostic: per-stage recall timing under SLM_RECALL_TIMING=1.
650
686
  # Zero overhead when the env var is unset. Permanent observability hook.
651
687
  import os as _os_t
@@ -658,7 +694,9 @@ def run_recall(
658
694
  logger.warning("[RECALL-TIMING] %-22s %.0f ms",
659
695
  _label, (_time_t.monotonic() - _t0) * 1000.0)
660
696
 
661
- extra_disabled = {"spreading_activation"} if fast else None
697
+ # The interactive path must retain the complete local retrieval contract.
698
+ # Only agentic verification can invoke an unbounded model round.
699
+ extra_disabled = None
662
700
  response = retrieval_engine.recall(
663
701
  query, profile_id, m, limit,
664
702
  extra_disabled_channels=extra_disabled,