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.
Files changed (82) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +3 -3
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +9 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/cli/commands.py +139 -404
  34. package/src/superlocalmemory/core/backend_orchestrator.py +7 -1
  35. package/src/superlocalmemory/core/component_registry.py +4 -2
  36. package/src/superlocalmemory/core/embeddings.py +33 -6
  37. package/src/superlocalmemory/core/engine.py +94 -49
  38. package/src/superlocalmemory/core/engine_ingestion.py +150 -63
  39. package/src/superlocalmemory/core/ingestion_command.py +133 -21
  40. package/src/superlocalmemory/core/mutations.py +32 -10
  41. package/src/superlocalmemory/core/recall_pipeline.py +111 -77
  42. package/src/superlocalmemory/core/remember_admission.py +152 -0
  43. package/src/superlocalmemory/core/remember_runtime.py +712 -0
  44. package/src/superlocalmemory/graph/cozo_backend.py +5 -5
  45. package/src/superlocalmemory/learning/bandit.py +50 -1
  46. package/src/superlocalmemory/learning/source_quality.py +38 -35
  47. package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
  48. package/src/superlocalmemory/mcp/tools_active.py +4 -41
  49. package/src/superlocalmemory/mcp/tools_core.py +26 -87
  50. package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
  51. package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
  52. package/src/superlocalmemory/retrieval/engine.py +8 -3
  53. package/src/superlocalmemory/retrieval/reranker.py +35 -10
  54. package/src/superlocalmemory/server/loopback.py +7 -13
  55. package/src/superlocalmemory/server/profile_runtime.py +14 -0
  56. package/src/superlocalmemory/server/routes/abstraction.py +2 -4
  57. package/src/superlocalmemory/server/routes/agents.py +3 -5
  58. package/src/superlocalmemory/server/routes/behavioral.py +5 -13
  59. package/src/superlocalmemory/server/routes/brain.py +6 -9
  60. package/src/superlocalmemory/server/routes/entity.py +3 -7
  61. package/src/superlocalmemory/server/routes/evolution.py +3 -5
  62. package/src/superlocalmemory/server/routes/helpers.py +44 -23
  63. package/src/superlocalmemory/server/routes/insights.py +2 -4
  64. package/src/superlocalmemory/server/routes/learning.py +2 -5
  65. package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
  66. package/src/superlocalmemory/server/routes/memories.py +122 -100
  67. package/src/superlocalmemory/server/routes/tiers.py +3 -22
  68. package/src/superlocalmemory/server/routes/timeline.py +2 -4
  69. package/src/superlocalmemory/server/routes/v3_api.py +18 -16
  70. package/src/superlocalmemory/server/unified_daemon.py +200 -109
  71. package/src/superlocalmemory/storage/admission_codec.py +119 -0
  72. package/src/superlocalmemory/storage/admission_journal.py +728 -0
  73. package/src/superlocalmemory/storage/database.py +59 -0
  74. package/src/superlocalmemory/storage/deferred_writes.py +67 -11
  75. package/src/superlocalmemory/storage/memory_write.py +8 -12
  76. package/src/superlocalmemory/storage/migration_runner.py +37 -0
  77. package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
  78. package/src/superlocalmemory/storage/read_connection.py +115 -0
  79. package/src/superlocalmemory/storage/write_coordinator.py +756 -0
  80. package/src/superlocalmemory/ui/index.html +1 -1
  81. package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
  82. 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
- # Attempt count AFTER this transition = current + 1 (the UPDATE adds
485
- # the delta via attempt_count+delta but here we compare the pre-update
486
- # value + 1 to the cap).
487
- next_attempt_count = current.attempt_count + 1
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 next_attempt_count >= _MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS
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 unixepoch(created_at) FROM ingestion_operations "
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
- next_attempt_count,
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
- next_attempt_count,
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 RuntimeError("ingestion produced no queryable facts")
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
- # attempt_count is incremented by claim_enriching BEFORE this method sees
739
- # the new value, so the cap check uses the CURRENT (pre-claim) count.
740
- # After the (cap-1)th attempt is claimed, attempt_count reaches cap-1;
741
- # finish_enriching sees current.attempt_count=cap-1, next_attempt_count=cap
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 - 1
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
- rows = engine._db.execute(
55
- "SELECT content FROM atomic_facts "
56
- "WHERE fact_id = ? AND profile_id = ? LIMIT 1",
57
- (fact_id, profile_id),
58
- )
59
- if not rows:
60
- return {"ok": False, "error": f"Memory {fact_id} not found"}
61
- content_preview = dict(rows[0]).get("content", "")[:80]
62
- engine._db.delete_fact(fact_id, profile_id=profile_id)
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
- engine._db.update_fact(fact_id, updates, profile_id=profile_id)
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
- collector = FeedbackCollector(learning_db)
307
- signal_count = collector.get_feedback_count(pid)
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 = LearningDatabase(db_path)
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
- choice = bandit.choose(
525
- {
526
- "query_type": response.query_type,
527
- "entity_count": entity_count,
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 = LearningDatabase(db_path)
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
- # --- 5. enqueue signals (non-blocking) ----------------------------
565
- try:
566
- top20 = final_results[:20]
567
- candidates = tuple(
568
- SignalCandidate(
569
- fact_id=r.fact.fact_id,
570
- channel_scores=dict(r.channel_scores or {}),
571
- cross_encoder_score=None,
572
- result_dict={"fact_id": r.fact.fact_id,
573
- "score": r.score},
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
- for r in top20
576
- )
577
- enqueue(SignalBatch(
578
- profile_id=profile_id,
579
- query_id=query_id,
580
- query_text=query,
581
- candidates=candidates,
582
- query_context=query_context,
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