superlocalmemory 3.8.5 → 3.8.7
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 +47 -0
- package/README.md +3 -3
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- 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 +1 -1
- 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 +1 -1
- 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 +1 -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 +1 -1
- 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 +9 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +139 -404
- package/src/superlocalmemory/core/backend_orchestrator.py +7 -1
- package/src/superlocalmemory/core/component_registry.py +4 -2
- package/src/superlocalmemory/core/embeddings.py +33 -6
- package/src/superlocalmemory/core/engine.py +94 -49
- package/src/superlocalmemory/core/engine_ingestion.py +150 -63
- package/src/superlocalmemory/core/ingestion_command.py +133 -21
- package/src/superlocalmemory/core/mutations.py +32 -10
- package/src/superlocalmemory/core/recall_pipeline.py +111 -77
- package/src/superlocalmemory/core/remember_admission.py +152 -0
- package/src/superlocalmemory/core/remember_runtime.py +712 -0
- package/src/superlocalmemory/graph/cozo_backend.py +5 -5
- package/src/superlocalmemory/learning/bandit.py +50 -1
- package/src/superlocalmemory/learning/source_quality.py +38 -35
- package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
- package/src/superlocalmemory/mcp/tools_active.py +4 -41
- package/src/superlocalmemory/mcp/tools_core.py +26 -87
- package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
- package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
- package/src/superlocalmemory/retrieval/engine.py +8 -3
- package/src/superlocalmemory/retrieval/reranker.py +35 -10
- package/src/superlocalmemory/server/loopback.py +7 -13
- package/src/superlocalmemory/server/profile_runtime.py +14 -0
- package/src/superlocalmemory/server/routes/abstraction.py +2 -4
- package/src/superlocalmemory/server/routes/agents.py +3 -5
- package/src/superlocalmemory/server/routes/behavioral.py +5 -13
- package/src/superlocalmemory/server/routes/brain.py +6 -9
- package/src/superlocalmemory/server/routes/entity.py +3 -7
- package/src/superlocalmemory/server/routes/evolution.py +3 -5
- package/src/superlocalmemory/server/routes/helpers.py +44 -23
- package/src/superlocalmemory/server/routes/insights.py +2 -4
- package/src/superlocalmemory/server/routes/learning.py +2 -5
- package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
- package/src/superlocalmemory/server/routes/memories.py +122 -100
- package/src/superlocalmemory/server/routes/tiers.py +3 -22
- package/src/superlocalmemory/server/routes/timeline.py +2 -4
- package/src/superlocalmemory/server/routes/v3_api.py +18 -16
- package/src/superlocalmemory/server/unified_daemon.py +200 -109
- package/src/superlocalmemory/storage/admission_codec.py +119 -0
- package/src/superlocalmemory/storage/admission_journal.py +728 -0
- package/src/superlocalmemory/storage/database.py +59 -0
- package/src/superlocalmemory/storage/deferred_writes.py +67 -11
- package/src/superlocalmemory/storage/memory_write.py +8 -12
- package/src/superlocalmemory/storage/migration_runner.py +37 -0
- package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
- package/src/superlocalmemory/storage/read_connection.py +115 -0
- package/src/superlocalmemory/storage/write_coordinator.py +756 -0
- package/src/superlocalmemory/ui/index.html +1 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
- package/src/superlocalmemory/ui/js/od-settings.js +9 -3
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import hashlib
|
|
15
15
|
import json
|
|
16
|
+
import logging
|
|
16
17
|
import sqlite3
|
|
17
18
|
import threading
|
|
18
19
|
import time
|
|
@@ -21,14 +22,13 @@ from dataclasses import dataclass, field
|
|
|
21
22
|
from enum import Enum
|
|
22
23
|
from typing import Any, Callable
|
|
23
24
|
|
|
24
|
-
import logging
|
|
25
|
-
|
|
26
25
|
from superlocalmemory.storage.database import DatabaseManager
|
|
27
26
|
|
|
28
27
|
logger = logging.getLogger("superlocalmemory.ingestion_command")
|
|
29
28
|
|
|
30
29
|
_MATERIALIZATION_LOCKS = tuple(threading.RLock() for _ in range(64))
|
|
31
30
|
_MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS = 10
|
|
31
|
+
_NEVER_RETRY_AT = 9_999_999_999.0
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
def _materialization_lock(operation_id: str) -> threading.RLock:
|
|
@@ -48,6 +48,10 @@ class IdempotencyConflict(ValueError):
|
|
|
48
48
|
"""The same idempotency key was reused for different immutable evidence."""
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
class IngestionRejectedError(RuntimeError):
|
|
52
|
+
"""Deterministic evidence policy produced no queryable projection."""
|
|
53
|
+
|
|
54
|
+
|
|
51
55
|
class InvalidStateTransition(RuntimeError):
|
|
52
56
|
"""An ingestion operation attempted an illegal or stale transition."""
|
|
53
57
|
|
|
@@ -481,13 +485,14 @@ class IngestionOperationRepository:
|
|
|
481
485
|
if target not in {IngestionState.COMPLETE, IngestionState.FAILED}:
|
|
482
486
|
raise InvalidStateTransition(f"enriching -> {target.value}")
|
|
483
487
|
current = self.get(operation_id)
|
|
484
|
-
#
|
|
485
|
-
#
|
|
486
|
-
#
|
|
487
|
-
|
|
488
|
+
# claim_enriching() already increments attempt_count before the
|
|
489
|
+
# materializer runs. finish_enriching() records that claimed attempt;
|
|
490
|
+
# incrementing again here would dead-letter after only nine real tries
|
|
491
|
+
# while claiming that ten had run.
|
|
492
|
+
attempt_count = current.attempt_count
|
|
488
493
|
is_exhausted = (
|
|
489
494
|
target is IngestionState.FAILED
|
|
490
|
-
and
|
|
495
|
+
and attempt_count >= _MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS
|
|
491
496
|
)
|
|
492
497
|
# Fix E: exhausted → far-future retry_at so list_materializable's
|
|
493
498
|
# `next_retry_at <= now` clause never matches, excluding the
|
|
@@ -537,18 +542,19 @@ class IngestionOperationRepository:
|
|
|
537
542
|
"INSERT INTO dead_letter_operations "
|
|
538
543
|
"(original_op_id, operation_type, content, "
|
|
539
544
|
" metadata_json, error, attempt_count, "
|
|
540
|
-
" first_attempt_at, profile_id) "
|
|
545
|
+
" first_attempt_at, dead_lettered_at, profile_id) "
|
|
541
546
|
"VALUES (?, 'M018', ?, ?, ?, ?, "
|
|
542
|
-
" (SELECT
|
|
543
|
-
" WHERE operation_id=?), ?)",
|
|
547
|
+
" (SELECT CAST(strftime('%s', created_at) AS REAL) "
|
|
548
|
+
" FROM ingestion_operations WHERE operation_id=?), ?, ?)",
|
|
544
549
|
(
|
|
545
550
|
operation_id,
|
|
546
551
|
current.raw_content,
|
|
547
552
|
json.dumps(current.metadata, separators=(",", ":"))
|
|
548
553
|
if current.metadata else None,
|
|
549
554
|
last_error or current.last_error,
|
|
550
|
-
|
|
555
|
+
attempt_count,
|
|
551
556
|
operation_id,
|
|
557
|
+
time.time(),
|
|
552
558
|
current.profile_id,
|
|
553
559
|
),
|
|
554
560
|
)
|
|
@@ -556,7 +562,7 @@ class IngestionOperationRepository:
|
|
|
556
562
|
"Operation %s exhausted %d attempts — moved to dead-letter. "
|
|
557
563
|
"Last error: %s",
|
|
558
564
|
operation_id,
|
|
559
|
-
|
|
565
|
+
attempt_count,
|
|
560
566
|
last_error or current.last_error,
|
|
561
567
|
)
|
|
562
568
|
except InvalidStateTransition:
|
|
@@ -600,6 +606,107 @@ class IngestionOperationRepository:
|
|
|
600
606
|
) from exc
|
|
601
607
|
return self._from_row(rows[0])
|
|
602
608
|
|
|
609
|
+
def reap_stuck_enriching(
|
|
610
|
+
self,
|
|
611
|
+
*,
|
|
612
|
+
now: float | None = None,
|
|
613
|
+
limit: int = 100,
|
|
614
|
+
) -> list[str]:
|
|
615
|
+
"""Terminalize expired enrichment leases that exhausted automatic retries.
|
|
616
|
+
|
|
617
|
+
``list_materializable`` intentionally excludes operations at the retry
|
|
618
|
+
cap. If a worker dies while such an operation is still ``enriching``,
|
|
619
|
+
it otherwise becomes a permanent phantom: no worker can reclaim it and
|
|
620
|
+
it never reaches a terminal state. Queryable facts are already durable,
|
|
621
|
+
so reaping abandons only optional derivation work.
|
|
622
|
+
|
|
623
|
+
Each candidate is transitioned with a compare-and-swap update in its
|
|
624
|
+
own bounded transaction. A dead-letter record is supplemental; an
|
|
625
|
+
older database without M031 is still terminalized safely.
|
|
626
|
+
"""
|
|
627
|
+
cutoff = time.time() if now is None else float(now)
|
|
628
|
+
batch_limit = max(1, min(int(limit), 500))
|
|
629
|
+
candidates = self.db.execute(
|
|
630
|
+
"SELECT operation_id, attempt_count, last_error, raw_content, "
|
|
631
|
+
"raw_metadata_json, profile_id FROM ingestion_operations "
|
|
632
|
+
"WHERE state='enriching' AND lease_expires_at <= ? "
|
|
633
|
+
"AND attempt_count >= ? ORDER BY updated_at, operation_id LIMIT ?",
|
|
634
|
+
(
|
|
635
|
+
cutoff,
|
|
636
|
+
_MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS,
|
|
637
|
+
batch_limit,
|
|
638
|
+
),
|
|
639
|
+
)
|
|
640
|
+
reaped: list[str] = []
|
|
641
|
+
for row in candidates:
|
|
642
|
+
data = dict(row)
|
|
643
|
+
operation_id = str(data["operation_id"])
|
|
644
|
+
terminal_error = (
|
|
645
|
+
data["last_error"]
|
|
646
|
+
or "reaped: enrichment exhausted automatic attempts"
|
|
647
|
+
)
|
|
648
|
+
try:
|
|
649
|
+
with self.db.transaction():
|
|
650
|
+
updated = self.db.execute(
|
|
651
|
+
"UPDATE ingestion_operations SET state='failed', "
|
|
652
|
+
"lease_owner='', lease_expires_at=0, next_retry_at=?, "
|
|
653
|
+
"last_error=?, "
|
|
654
|
+
"updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') "
|
|
655
|
+
"WHERE operation_id=? AND state='enriching' "
|
|
656
|
+
"AND lease_expires_at <= ? AND attempt_count >= ? "
|
|
657
|
+
"RETURNING operation_id",
|
|
658
|
+
(
|
|
659
|
+
_NEVER_RETRY_AT,
|
|
660
|
+
terminal_error,
|
|
661
|
+
operation_id,
|
|
662
|
+
cutoff,
|
|
663
|
+
_MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS,
|
|
664
|
+
),
|
|
665
|
+
)
|
|
666
|
+
if not updated:
|
|
667
|
+
continue
|
|
668
|
+
try:
|
|
669
|
+
self.db.execute(
|
|
670
|
+
"INSERT INTO dead_letter_operations "
|
|
671
|
+
"(original_op_id, operation_type, content, "
|
|
672
|
+
"metadata_json, error, attempt_count, "
|
|
673
|
+
"first_attempt_at, dead_lettered_at, profile_id) "
|
|
674
|
+
"VALUES (?, 'M018', ?, ?, ?, ?, "
|
|
675
|
+
"(SELECT CAST(strftime('%s', created_at) AS REAL) "
|
|
676
|
+
"FROM ingestion_operations WHERE operation_id=?), ?, ?)",
|
|
677
|
+
(
|
|
678
|
+
operation_id,
|
|
679
|
+
data["raw_content"],
|
|
680
|
+
data["raw_metadata_json"] or None,
|
|
681
|
+
terminal_error,
|
|
682
|
+
int(data["attempt_count"]),
|
|
683
|
+
operation_id,
|
|
684
|
+
time.time(),
|
|
685
|
+
data["profile_id"],
|
|
686
|
+
),
|
|
687
|
+
)
|
|
688
|
+
except sqlite3.OperationalError as exc:
|
|
689
|
+
if "no such table" not in str(exc).lower():
|
|
690
|
+
raise
|
|
691
|
+
logger.info(
|
|
692
|
+
"Dead-letter table unavailable while reaping %s; "
|
|
693
|
+
"terminal transition preserved",
|
|
694
|
+
operation_id,
|
|
695
|
+
)
|
|
696
|
+
except Exception:
|
|
697
|
+
logger.exception(
|
|
698
|
+
"Failed to reap exhausted ingestion operation %s",
|
|
699
|
+
operation_id,
|
|
700
|
+
)
|
|
701
|
+
continue
|
|
702
|
+
reaped.append(operation_id)
|
|
703
|
+
logger.warning(
|
|
704
|
+
"Reaped exhausted ingestion operation %s at attempt %d",
|
|
705
|
+
operation_id,
|
|
706
|
+
int(data["attempt_count"]),
|
|
707
|
+
)
|
|
708
|
+
return reaped
|
|
709
|
+
|
|
603
710
|
|
|
604
711
|
@dataclass(frozen=True, slots=True)
|
|
605
712
|
class MaterializationResult:
|
|
@@ -611,6 +718,7 @@ class MaterializationResult:
|
|
|
611
718
|
|
|
612
719
|
|
|
613
720
|
QueryableWriter = Callable[[IngestionRequest, str], list[str]]
|
|
721
|
+
AdmissionValidator = Callable[[IngestionRequest], None]
|
|
614
722
|
Materializer = Callable[
|
|
615
723
|
[IngestionOperation],
|
|
616
724
|
list[str] | tuple[str, ...] | MaterializationResult,
|
|
@@ -627,6 +735,7 @@ class IngestionCommand:
|
|
|
627
735
|
*,
|
|
628
736
|
write_queryable: QueryableWriter,
|
|
629
737
|
materialize: Materializer,
|
|
738
|
+
validate_admission: AdmissionValidator | None = None,
|
|
630
739
|
project: Projector | None = None,
|
|
631
740
|
derivation_version: str = "v3.7-ingestion-1",
|
|
632
741
|
lease_seconds: float = 900.0,
|
|
@@ -634,6 +743,7 @@ class IngestionCommand:
|
|
|
634
743
|
self.repository = repository
|
|
635
744
|
self._write_queryable = write_queryable
|
|
636
745
|
self._materializer = materialize
|
|
746
|
+
self._validate_admission = validate_admission
|
|
637
747
|
self._projector = project
|
|
638
748
|
self._derivation_version = derivation_version
|
|
639
749
|
self._lease_seconds = max(1.0, float(lease_seconds))
|
|
@@ -704,13 +814,18 @@ class IngestionCommand:
|
|
|
704
814
|
self, request: IngestionRequest,
|
|
705
815
|
) -> tuple[IngestionOperation, bool]:
|
|
706
816
|
"""Submit once and report whether this call created the operation."""
|
|
817
|
+
# Trust/authentication and deterministic policy checks belong before
|
|
818
|
+
# the durable transaction. They may reject, log, or consult policy,
|
|
819
|
+
# but must never extend SQLite's writer critical section.
|
|
820
|
+
if self._validate_admission is not None:
|
|
821
|
+
self._validate_admission(request)
|
|
707
822
|
with self.repository.db.transaction():
|
|
708
823
|
operation, created = self.repository.create_with_status(request)
|
|
709
824
|
if operation.state is not IngestionState.RAW:
|
|
710
825
|
return operation, created
|
|
711
826
|
fact_ids = tuple(self._write_queryable(request, operation.operation_id))
|
|
712
827
|
if not fact_ids:
|
|
713
|
-
raise
|
|
828
|
+
raise IngestionRejectedError("ingestion produced no queryable facts")
|
|
714
829
|
receipt = self.repository.transition(
|
|
715
830
|
operation.operation_id,
|
|
716
831
|
expected=IngestionState.RAW,
|
|
@@ -735,19 +850,16 @@ class IngestionCommand:
|
|
|
735
850
|
if operation.state is IngestionState.COMPLETE:
|
|
736
851
|
return operation
|
|
737
852
|
# Fix E: guard against re-attempting exhausted (dead-lettered) operations.
|
|
738
|
-
#
|
|
739
|
-
#
|
|
740
|
-
#
|
|
741
|
-
#
|
|
742
|
-
# and inserts the dead-letter row. Any subsequent materialize() call
|
|
743
|
-
# (attempt_count already at cap-1 in FAILED state) would re-claim and
|
|
744
|
-
# insert a second dead-letter row — this guard prevents that.
|
|
853
|
+
# claim_enriching increments the count before each real attempt, and
|
|
854
|
+
# finish_enriching dead-letters the failure whose count reaches the
|
|
855
|
+
# cap. A subsequent materialize() call sees count==cap and must not
|
|
856
|
+
# re-claim or insert another dead-letter row.
|
|
745
857
|
# ``force=True`` is the operator escape hatch used by retry() — it bypasses
|
|
746
858
|
# this guard so an admin can manually re-enqueue a dead-lettered operation.
|
|
747
859
|
if (
|
|
748
860
|
not force
|
|
749
861
|
and operation.state is IngestionState.FAILED
|
|
750
|
-
and operation.attempt_count >= _MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS
|
|
862
|
+
and operation.attempt_count >= _MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS
|
|
751
863
|
):
|
|
752
864
|
# Already dead-lettered — return FAILED without re-claiming.
|
|
753
865
|
return operation
|
|
@@ -42,6 +42,8 @@ def delete_fact_authorized(
|
|
|
42
42
|
*,
|
|
43
43
|
trusted_actor_id: str,
|
|
44
44
|
source_agent_id: str,
|
|
45
|
+
canonical_runtime: Any | None = None,
|
|
46
|
+
idempotency_key: str | None = None,
|
|
45
47
|
) -> dict[str, Any]:
|
|
46
48
|
"""Authorize, delete one profile-owned fact, then emit post hooks."""
|
|
47
49
|
profile_id, context = _context(
|
|
@@ -51,15 +53,23 @@ def delete_fact_authorized(
|
|
|
51
53
|
trusted_actor_id=trusted_actor_id,
|
|
52
54
|
source_agent_id=source_agent_id,
|
|
53
55
|
)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
if canonical_runtime is not None:
|
|
57
|
+
result = dict(canonical_runtime.delete_fact(
|
|
58
|
+
profile_id, fact_id, idempotency_key=idempotency_key,
|
|
59
|
+
))
|
|
60
|
+
if not result.get("ok"):
|
|
61
|
+
return {"ok": False, "error": f"Memory {fact_id} not found"}
|
|
62
|
+
content_preview = str(result.get("content_preview", ""))
|
|
63
|
+
else:
|
|
64
|
+
rows = engine._db.execute(
|
|
65
|
+
"SELECT content FROM atomic_facts "
|
|
66
|
+
"WHERE fact_id = ? AND profile_id = ? LIMIT 1",
|
|
67
|
+
(fact_id, profile_id),
|
|
68
|
+
)
|
|
69
|
+
if not rows:
|
|
70
|
+
return {"ok": False, "error": f"Memory {fact_id} not found"}
|
|
71
|
+
content_preview = dict(rows[0]).get("content", "")[:80]
|
|
72
|
+
engine._db.delete_fact(fact_id, profile_id=profile_id)
|
|
63
73
|
try:
|
|
64
74
|
from superlocalmemory.core.backend_orchestrator import get_orchestrator
|
|
65
75
|
orchestrator = get_orchestrator()
|
|
@@ -86,6 +96,8 @@ def update_fact_authorized(
|
|
|
86
96
|
*,
|
|
87
97
|
trusted_actor_id: str,
|
|
88
98
|
source_agent_id: str,
|
|
99
|
+
canonical_runtime: Any | None = None,
|
|
100
|
+
idempotency_key: str | None = None,
|
|
89
101
|
) -> dict[str, Any]:
|
|
90
102
|
"""Authorize a fact update and refresh semantic and lexical indexes."""
|
|
91
103
|
if not content or not content.strip():
|
|
@@ -120,7 +132,17 @@ def update_fact_authorized(
|
|
|
120
132
|
updates["fisher_variance"] = fisher_variance
|
|
121
133
|
except Exception as exc:
|
|
122
134
|
logger.warning("UPDATE embedding refresh failed: %s", exc)
|
|
123
|
-
|
|
135
|
+
if canonical_runtime is not None:
|
|
136
|
+
result = dict(canonical_runtime.update_fact(
|
|
137
|
+
profile_id,
|
|
138
|
+
fact_id,
|
|
139
|
+
updates,
|
|
140
|
+
idempotency_key=idempotency_key,
|
|
141
|
+
))
|
|
142
|
+
if not result.get("ok"):
|
|
143
|
+
return {"ok": False, "error": f"Memory {fact_id} not found"}
|
|
144
|
+
else:
|
|
145
|
+
engine._db.update_fact(fact_id, updates, profile_id=profile_id)
|
|
124
146
|
try:
|
|
125
147
|
from superlocalmemory.core.backend_orchestrator import get_orchestrator
|
|
126
148
|
orchestrator = get_orchestrator()
|
|
@@ -14,6 +14,8 @@ from __future__ import annotations
|
|
|
14
14
|
import hashlib
|
|
15
15
|
import hmac
|
|
16
16
|
import logging
|
|
17
|
+
import sqlite3
|
|
18
|
+
from pathlib import Path
|
|
17
19
|
from typing import TYPE_CHECKING, Any
|
|
18
20
|
|
|
19
21
|
if TYPE_CHECKING:
|
|
@@ -212,6 +214,70 @@ def _behavioral_entities(results: list[Any], limit: int = 20) -> list[str]:
|
|
|
212
214
|
_RANKING_MODES: frozenset[str] = frozenset({"off", "v1", "v2", "v2-ensemble"})
|
|
213
215
|
|
|
214
216
|
|
|
217
|
+
class _ReadOnlyLearningView:
|
|
218
|
+
"""Minimal learning-model reader that cannot initialise or mutate a DB."""
|
|
219
|
+
|
|
220
|
+
def __init__(self, db_path: Path) -> None:
|
|
221
|
+
self._db_path = db_path.resolve()
|
|
222
|
+
|
|
223
|
+
def _connection(self) -> sqlite3.Connection:
|
|
224
|
+
connection = sqlite3.connect(
|
|
225
|
+
f"{self._db_path.as_uri()}?mode=ro",
|
|
226
|
+
uri=True,
|
|
227
|
+
timeout=0.25,
|
|
228
|
+
)
|
|
229
|
+
connection.row_factory = sqlite3.Row
|
|
230
|
+
connection.execute("PRAGMA query_only=ON")
|
|
231
|
+
connection.execute("PRAGMA busy_timeout=250")
|
|
232
|
+
return connection
|
|
233
|
+
|
|
234
|
+
def count_signals(self, profile_id: str) -> int:
|
|
235
|
+
connection = self._connection()
|
|
236
|
+
try:
|
|
237
|
+
row = connection.execute(
|
|
238
|
+
"SELECT COUNT(*) AS count FROM learning_signals "
|
|
239
|
+
"WHERE profile_id = ?",
|
|
240
|
+
(profile_id,),
|
|
241
|
+
).fetchone()
|
|
242
|
+
return int(row["count"]) if row else 0
|
|
243
|
+
finally:
|
|
244
|
+
connection.close()
|
|
245
|
+
|
|
246
|
+
def count_feedback(self, profile_id: str) -> int:
|
|
247
|
+
"""Count legacy feedback without running schema initialization."""
|
|
248
|
+
connection = self._connection()
|
|
249
|
+
try:
|
|
250
|
+
row = connection.execute(
|
|
251
|
+
"SELECT COUNT(*) AS count FROM learning_feedback "
|
|
252
|
+
"WHERE profile_id = ?",
|
|
253
|
+
(profile_id,),
|
|
254
|
+
).fetchone()
|
|
255
|
+
return int(row["count"]) if row else 0
|
|
256
|
+
finally:
|
|
257
|
+
connection.close()
|
|
258
|
+
|
|
259
|
+
def load_active_model(self, profile_id: str) -> dict[str, Any] | None:
|
|
260
|
+
connection = self._connection()
|
|
261
|
+
try:
|
|
262
|
+
row = connection.execute(
|
|
263
|
+
"SELECT state_bytes, bytes_sha256, feature_names, trained_at, "
|
|
264
|
+
"model_version FROM learning_model_state "
|
|
265
|
+
"WHERE profile_id = ? AND is_active = 1 LIMIT 1",
|
|
266
|
+
(profile_id,),
|
|
267
|
+
).fetchone()
|
|
268
|
+
if row is None:
|
|
269
|
+
return None
|
|
270
|
+
return {
|
|
271
|
+
"state_bytes": bytes(row["state_bytes"]),
|
|
272
|
+
"bytes_sha256": row["bytes_sha256"],
|
|
273
|
+
"feature_names": row["feature_names"],
|
|
274
|
+
"trained_at": row["trained_at"],
|
|
275
|
+
"model_version": row["model_version"],
|
|
276
|
+
}
|
|
277
|
+
finally:
|
|
278
|
+
connection.close()
|
|
279
|
+
|
|
280
|
+
|
|
215
281
|
def _resolve_ranking_mode(env: "dict[str, str] | os._Environ[str]") -> str:
|
|
216
282
|
"""Map the ``SLM_RANKING`` env var to a canonical mode.
|
|
217
283
|
|
|
@@ -239,6 +305,7 @@ def apply_ranking(
|
|
|
239
305
|
*,
|
|
240
306
|
config: Any = None,
|
|
241
307
|
pipeline_version: str = "v2-ensemble",
|
|
308
|
+
record_signals: bool = False,
|
|
242
309
|
) -> "RecallResponse":
|
|
243
310
|
"""Run the ranking pipeline at the requested version.
|
|
244
311
|
|
|
@@ -273,6 +340,7 @@ def apply_ranking(
|
|
|
273
340
|
try:
|
|
274
341
|
response = apply_v2_bandit_ensemble(
|
|
275
342
|
response, query, profile_id, query_id,
|
|
343
|
+
record_signals=record_signals,
|
|
276
344
|
)
|
|
277
345
|
except Exception as exc: # pragma: no cover — defensive
|
|
278
346
|
logger.debug("apply_ranking ensemble step skipped: %s", exc)
|
|
@@ -297,14 +365,16 @@ def apply_adaptive_ranking(
|
|
|
297
365
|
Phase 3 (200+): LightGBM ML-based reranking.
|
|
298
366
|
"""
|
|
299
367
|
from superlocalmemory.infra.data_root import state_path
|
|
300
|
-
from superlocalmemory.learning.feedback import FeedbackCollector
|
|
301
|
-
|
|
302
368
|
learning_db = state_path("learning.db")
|
|
303
369
|
if not learning_db.exists():
|
|
304
370
|
return response
|
|
305
371
|
|
|
306
|
-
|
|
307
|
-
|
|
372
|
+
try:
|
|
373
|
+
signal_count = _ReadOnlyLearningView(learning_db).count_feedback(pid)
|
|
374
|
+
except sqlite3.Error:
|
|
375
|
+
# A pre-learning database may not have this optional table yet.
|
|
376
|
+
# Recall remains a query and cannot create it on demand.
|
|
377
|
+
return response
|
|
308
378
|
|
|
309
379
|
if signal_count < 50:
|
|
310
380
|
return response # Phase 1: no change
|
|
@@ -388,7 +458,6 @@ def apply_v2_adaptive_ranking(
|
|
|
388
458
|
from pathlib import Path as _P
|
|
389
459
|
|
|
390
460
|
from superlocalmemory.infra.data_root import state_path
|
|
391
|
-
from superlocalmemory.learning.database import LearningDatabase
|
|
392
461
|
from superlocalmemory.learning.model_cache import load_active
|
|
393
462
|
from superlocalmemory.learning.ranker import AdaptiveRanker
|
|
394
463
|
|
|
@@ -397,7 +466,7 @@ def apply_v2_adaptive_ranking(
|
|
|
397
466
|
if not db_path.exists():
|
|
398
467
|
return response
|
|
399
468
|
|
|
400
|
-
db =
|
|
469
|
+
db = _ReadOnlyLearningView(db_path)
|
|
401
470
|
signal_count = db.count_signals(profile_id)
|
|
402
471
|
active = load_active(db, profile_id)
|
|
403
472
|
|
|
@@ -487,6 +556,7 @@ def apply_v2_bandit_ensemble(
|
|
|
487
556
|
query_id: str,
|
|
488
557
|
*,
|
|
489
558
|
learning_db_path: Any = None,
|
|
559
|
+
record_signals: bool = False,
|
|
490
560
|
) -> RecallResponse:
|
|
491
561
|
"""Apply contextual bandit + optional LGBM ensemble rerank. Safe on error."""
|
|
492
562
|
import os as _os
|
|
@@ -521,12 +591,14 @@ def apply_v2_bandit_ensemble(
|
|
|
521
591
|
entity_count = 0
|
|
522
592
|
# Use query_context hints if available on the engine — cheap fallback.
|
|
523
593
|
bandit = ContextualBandit(db_path, profile_id)
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
query_id
|
|
594
|
+
context = {
|
|
595
|
+
"query_type": response.query_type,
|
|
596
|
+
"entity_count": entity_count,
|
|
597
|
+
}
|
|
598
|
+
choice = (
|
|
599
|
+
bandit.choose(context, query_id)
|
|
600
|
+
if record_signals
|
|
601
|
+
else bandit.choose_readonly(context)
|
|
530
602
|
)
|
|
531
603
|
|
|
532
604
|
# --- 2. apply channel weights -------------------------------------
|
|
@@ -536,9 +608,8 @@ def apply_v2_bandit_ensemble(
|
|
|
536
608
|
active_model = None
|
|
537
609
|
signal_count = 0
|
|
538
610
|
try:
|
|
539
|
-
from superlocalmemory.learning.database import LearningDatabase
|
|
540
611
|
from superlocalmemory.learning.model_cache import load_active
|
|
541
|
-
db =
|
|
612
|
+
db = _ReadOnlyLearningView(db_path)
|
|
542
613
|
signal_count = db.count_signals(profile_id)
|
|
543
614
|
active_model = load_active(db, profile_id)
|
|
544
615
|
except Exception as exc:
|
|
@@ -561,28 +632,32 @@ def apply_v2_bandit_ensemble(
|
|
|
561
632
|
logger.debug("v2 bandit ensemble_rerank skipped: %s", exc)
|
|
562
633
|
final_results = weighted
|
|
563
634
|
|
|
564
|
-
#
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
635
|
+
# Recall is a query. Implicit learning signals are deliberately
|
|
636
|
+
# disabled on this path: even a non-blocking enqueue eventually writes
|
|
637
|
+
# canonical/learning state and turns dashboard polling into contention.
|
|
638
|
+
# An explicit feedback command owns durable learning signals instead.
|
|
639
|
+
if record_signals:
|
|
640
|
+
try:
|
|
641
|
+
top20 = final_results[:20]
|
|
642
|
+
candidates = tuple(
|
|
643
|
+
SignalCandidate(
|
|
644
|
+
fact_id=r.fact.fact_id,
|
|
645
|
+
channel_scores=dict(r.channel_scores or {}),
|
|
646
|
+
cross_encoder_score=None,
|
|
647
|
+
result_dict={"fact_id": r.fact.fact_id,
|
|
648
|
+
"score": r.score},
|
|
649
|
+
)
|
|
650
|
+
for r in top20
|
|
574
651
|
)
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
except Exception as exc:
|
|
585
|
-
logger.debug("v2 bandit signal enqueue skipped: %s", exc)
|
|
652
|
+
enqueue(SignalBatch(
|
|
653
|
+
profile_id=profile_id,
|
|
654
|
+
query_id=query_id,
|
|
655
|
+
query_text=query,
|
|
656
|
+
candidates=candidates,
|
|
657
|
+
query_context=query_context,
|
|
658
|
+
))
|
|
659
|
+
except Exception as exc:
|
|
660
|
+
logger.debug("v2 bandit signal enqueue skipped: %s", exc)
|
|
586
661
|
|
|
587
662
|
return RecallResponse(
|
|
588
663
|
query=response.query,
|
|
@@ -665,15 +740,6 @@ def run_recall(
|
|
|
665
740
|
agent hot path skips the internal round and delegates refinement to the
|
|
666
741
|
calling LLM. ``fast=False`` forces the internal agentic round.
|
|
667
742
|
"""
|
|
668
|
-
# Pre-operation hooks
|
|
669
|
-
hook_ctx = {
|
|
670
|
-
"operation": "recall",
|
|
671
|
-
"agent_id": agent_id,
|
|
672
|
-
"profile_id": profile_id,
|
|
673
|
-
"query_preview": query[:100],
|
|
674
|
-
}
|
|
675
|
-
hooks.run_pre("recall", hook_ctx)
|
|
676
|
-
|
|
677
743
|
m = mode or config.mode
|
|
678
744
|
|
|
679
745
|
# v3.8.2: resolve the client-driven-agentic default when a caller left
|
|
@@ -772,33 +838,6 @@ def run_recall(
|
|
|
772
838
|
logger.debug("Agentic sufficiency skipped: %s", exc)
|
|
773
839
|
|
|
774
840
|
_mark("agentic")
|
|
775
|
-
# V3.2: Log access for recalled facts (Phase 1)
|
|
776
|
-
if access_log and response.results:
|
|
777
|
-
try:
|
|
778
|
-
fact_ids = [r.fact.fact_id for r in response.results]
|
|
779
|
-
# Recall exposure logging is a durable analytics contract (tested),
|
|
780
|
-
# so it stays synchronous — it is a single batched write, cheap
|
|
781
|
-
# relative to the deferred spreading-activation cache / last_seen.
|
|
782
|
-
access_log.store_access_batch(
|
|
783
|
-
fact_ids=fact_ids,
|
|
784
|
-
profile_id=profile_id,
|
|
785
|
-
access_type="recall",
|
|
786
|
-
)
|
|
787
|
-
except Exception as exc:
|
|
788
|
-
logger.debug("Access log batch store failed: %s", exc)
|
|
789
|
-
|
|
790
|
-
# Query telemetry is permitted, but result-derived entities are not fed
|
|
791
|
-
# back as preferences. Retrieval exposure is not positive feedback.
|
|
792
|
-
try:
|
|
793
|
-
_get_behavioral_tracker(db).record_query(
|
|
794
|
-
query=query,
|
|
795
|
-
query_type=response.query_type,
|
|
796
|
-
entities=[],
|
|
797
|
-
profile_id=profile_id,
|
|
798
|
-
)
|
|
799
|
-
except Exception as exc:
|
|
800
|
-
logger.debug("Behavioral tracking: %s", exc)
|
|
801
|
-
|
|
802
841
|
# S8-ARC-04 (v3.4.22): unified ranking entry point. Single env-var
|
|
803
842
|
# (SLM_RANKING=off|v1|v2|v2-ensemble) controls the pipeline. Legacy
|
|
804
843
|
# SLM_V2_PIPELINE_DISABLED + SLM_BANDIT_DISABLED still honoured for
|
|
@@ -810,7 +849,7 @@ def run_recall(
|
|
|
810
849
|
mode = _resolve_ranking_mode(_os.environ)
|
|
811
850
|
response = apply_ranking(
|
|
812
851
|
response, query, profile_id, query_id,
|
|
813
|
-
config=config, pipeline_version=mode,
|
|
852
|
+
config=config, pipeline_version=mode, record_signals=False,
|
|
814
853
|
)
|
|
815
854
|
except Exception as exc:
|
|
816
855
|
logger.debug("Ranking pipeline skipped: %s", exc)
|
|
@@ -820,11 +859,6 @@ def run_recall(
|
|
|
820
859
|
# mutation here. Those state transitions require a separately authenticated
|
|
821
860
|
# positive/negative outcome; merely returning a result is an exposure.
|
|
822
861
|
|
|
823
|
-
# Post-operation hooks (audit, trust signal, learning)
|
|
824
|
-
hook_ctx["result_count"] = len(response.results)
|
|
825
|
-
hook_ctx["query_type"] = response.query_type
|
|
826
|
-
hooks.run_post("recall", hook_ctx)
|
|
827
|
-
|
|
828
862
|
from superlocalmemory.core.score_contract import finalize_score_contract
|
|
829
863
|
finalize_score_contract(response)
|
|
830
864
|
|