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.
- package/CHANGELOG.md +112 -0
- package/README.md +32 -120
- package/package.json +9 -2
- package/plugin/.claude-plugin/plugin.json +1 -2
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +2 -2
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +3 -5
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +2 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +3 -5
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +2 -1
- package/scripts/postinstall.js +7 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +494 -9
- package/src/superlocalmemory/cli/daemon.py +7 -0
- package/src/superlocalmemory/cli/loop_cmd.py +2 -7
- package/src/superlocalmemory/cli/main.py +72 -7
- package/src/superlocalmemory/cli/setup_wizard.py +142 -16
- package/src/superlocalmemory/cli/version_banner.py +17 -3
- package/src/superlocalmemory/core/backend_orchestrator.py +18 -16
- package/src/superlocalmemory/core/component_healer.py +144 -0
- package/src/superlocalmemory/core/component_registry.py +487 -0
- package/src/superlocalmemory/core/config.py +21 -0
- package/src/superlocalmemory/core/embedding_worker.py +4 -5
- package/src/superlocalmemory/core/embeddings.py +132 -45
- package/src/superlocalmemory/core/engine.py +29 -22
- package/src/superlocalmemory/core/engine_ingestion.py +332 -45
- package/src/superlocalmemory/core/ingestion_command.py +154 -25
- package/src/superlocalmemory/core/injection.py +12 -7
- package/src/superlocalmemory/core/maintenance.py +43 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +44 -6
- package/src/superlocalmemory/core/recall_pipeline.py +42 -4
- package/src/superlocalmemory/core/store_pipeline.py +195 -20
- package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
- package/src/superlocalmemory/hooks/portable_kit.py +34 -2
- package/src/superlocalmemory/learning/model_rollback.py +3 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
- package/src/superlocalmemory/learning/reward.py +50 -0
- package/src/superlocalmemory/learning/source_quality.py +523 -1
- package/src/superlocalmemory/loops/ledger.py +25 -5
- package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
- package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
- package/src/superlocalmemory/mcp/server.py +11 -30
- package/src/superlocalmemory/mcp/tools_active.py +1 -1
- package/src/superlocalmemory/mcp/tools_core.py +21 -5
- package/src/superlocalmemory/mcp/tools_learning.py +2 -2
- package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
- package/src/superlocalmemory/retrieval/engine.py +53 -21
- package/src/superlocalmemory/retrieval/reranker.py +3 -4
- package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
- package/src/superlocalmemory/server/config_file.py +90 -0
- package/src/superlocalmemory/server/origin.py +50 -0
- package/src/superlocalmemory/server/routes/backup.py +293 -70
- package/src/superlocalmemory/server/routes/behavioral.py +342 -61
- package/src/superlocalmemory/server/routes/brain.py +57 -16
- package/src/superlocalmemory/server/routes/config_api.py +84 -82
- package/src/superlocalmemory/server/routes/entity.py +100 -23
- package/src/superlocalmemory/server/routes/evolution.py +103 -100
- package/src/superlocalmemory/server/routes/learning.py +286 -105
- package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
- package/src/superlocalmemory/server/routes/memories.py +8 -3
- package/src/superlocalmemory/server/routes/mesh.py +121 -32
- package/src/superlocalmemory/server/routes/ratelimit.py +33 -25
- package/src/superlocalmemory/server/routes/stats.py +93 -155
- package/src/superlocalmemory/server/routes/token.py +3 -13
- package/src/superlocalmemory/server/routes/v3_api.py +184 -20
- package/src/superlocalmemory/server/unified_daemon.py +732 -41
- package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
- package/src/superlocalmemory/storage/migration_runner.py +79 -1
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
- package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
- package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
- package/src/superlocalmemory/storage/schema.py +49 -1
- package/src/superlocalmemory/storage/schema_v32.py +2 -0
- package/src/superlocalmemory/storage/schema_v347.py +4 -0
- package/src/superlocalmemory/ui/index.html +6 -8
- package/src/superlocalmemory/ui/js/core.js +52 -9
- package/src/superlocalmemory/ui/js/dashboard.js +169 -82
- package/src/superlocalmemory/ui/js/od-backup.js +156 -65
- package/src/superlocalmemory/ui/js/od-brain.js +88 -51
- package/src/superlocalmemory/ui/js/od-components.js +147 -0
- package/src/superlocalmemory/ui/js/od-entities.js +65 -22
- package/src/superlocalmemory/ui/js/od-graph.js +46 -4
- package/src/superlocalmemory/ui/js/od-health.js +18 -0
- package/src/superlocalmemory/ui/js/od-memories.js +84 -5
- package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
- package/src/superlocalmemory/ui/js/od-operations.js +36 -0
- package/src/superlocalmemory/ui/js/od-settings.js +186 -63
- package/src/superlocalmemory/ui/js/od-shell.js +249 -33
- package/src/superlocalmemory/ui/js/od-skills.js +44 -17
- package/src/superlocalmemory/ui/js/settings.js +15 -1
- package/plugin-src/.mcp.json +0 -12
- package/plugin-src/agents/slm-governance-advisor.md +0 -80
- package/plugin-src/agents/slm-loop-runner.md +0 -71
- package/plugin-src/agents/slm-memory-advisor.md +0 -49
- package/plugin-src/agents/slm-optimize-advisor.md +0 -44
- package/plugin-src/commands/slm-loop.md +0 -31
- package/plugin-src/hooks/.gitkeep +0 -0
- package/plugin-src/hooks/hooks.json +0 -102
- package/plugin-src/manifest.json +0 -30
- package/plugin-src/requirements.txt +0 -1
- package/plugin-src/rules/CLAUDE.md.fragment +0 -44
- package/plugin-src/scripts/ensure-venv.bat +0 -122
- package/plugin-src/scripts/ensure-venv.sh +0 -105
- package/plugin-src/scripts/slm-launch +0 -62
- package/plugin-src/scripts/slm-launch.bat +0 -23
- package/plugin-src/settings.json +0 -25
- package/plugin-src/skills/slm-governance/SKILL.md +0 -248
- package/plugin-src/skills/slm-loop/SKILL.md +0 -99
- package/plugin-src/skills/slm-mesh/SKILL.md +0 -282
- package/plugin-src/skills/slm-profile/SKILL.md +0 -148
- 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
|
|
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
|
-
(
|
|
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
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
"""
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
84
|
-
|
|
85
|
-
|
|
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.
|
|
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 =
|
|
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
|
|
636
|
-
|
|
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
|
-
|
|
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,
|