superlocalmemory 3.8.11 → 3.8.13

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 (50) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +7 -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 +1 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/cli/commands.py +62 -3
  34. package/src/superlocalmemory/cli/daemon.py +219 -10
  35. package/src/superlocalmemory/cli/setup_wizard.py +45 -1
  36. package/src/superlocalmemory/core/component_registry.py +25 -0
  37. package/src/superlocalmemory/core/config.py +35 -1
  38. package/src/superlocalmemory/core/engine_wiring.py +81 -5
  39. package/src/superlocalmemory/core/recall_pipeline.py +25 -4
  40. package/src/superlocalmemory/core/reranker_worker.py +23 -4
  41. package/src/superlocalmemory/infra/daemon_identity.py +16 -0
  42. package/src/superlocalmemory/infra/process_identity.py +180 -0
  43. package/src/superlocalmemory/infra/version_integrity.py +229 -0
  44. package/src/superlocalmemory/learning/feedback.py +288 -29
  45. package/src/superlocalmemory/learning/legacy_migration.py +45 -4
  46. package/src/superlocalmemory/mcp/_daemon_proxy.py +23 -1
  47. package/src/superlocalmemory/mcp/tools_active.py +109 -58
  48. package/src/superlocalmemory/mcp/tools_core.py +6 -5
  49. package/src/superlocalmemory/retrieval/remote_reranker.py +636 -0
  50. package/src/superlocalmemory/server/unified_daemon.py +27 -0
@@ -141,13 +141,21 @@ def _emit_event(event_type: str, payload: dict | None = None,
141
141
 
142
142
 
143
143
  # ---------------------------------------------------------------------------
144
- # Canonical learning-store feedback (issue #102)
144
+ # Canonical learning-store feedback (issues #102, #106)
145
145
  #
146
- # learning.db is the single store every learning consumer reads: the phase
147
- # gate (recall_pipeline._ReadOnlyLearningView.count_feedback), pattern_miner,
148
- # the ranker retrainers, and the dashboard. Recall itself is deliberately
149
- # read-only and must never open a writer, so an explicit feedback command is
150
- # the only durable writer in the design. These helpers are that writer.
146
+ # learning.db is the single store every learning consumer reads. Within it the
147
+ # canonical tables are ``learning_signals`` + ``learning_features``: the phase
148
+ # gate (recall_pipeline), the dashboard Living Brain panel, the ranker-phase
149
+ # card, and the retrainer all resolve their phase from ``learning_signals``.
150
+ # ``learning_feedback`` is the pre-v3.4.22 table that legacy_migration copies
151
+ # forward into it.
152
+ #
153
+ # Recall itself is deliberately read-only and must never open a writer, so an
154
+ # explicit feedback command is the only durable writer in the design. These
155
+ # helpers are that writer, and — per issue #106 — they report the SAME number
156
+ # the gate and the dashboard use. There is deliberately no fall back to a
157
+ # different store's count: a cross-store fallback is what let a total write
158
+ # failure still return "success" beside a plausibly incrementing counter.
151
159
  # ---------------------------------------------------------------------------
152
160
 
153
161
  _FEEDBACK_SIGNAL_MAP: dict[str, tuple[str, float]] = {
@@ -162,15 +170,43 @@ def _learning_db_path():
162
170
  return state_path("learning.db")
163
171
 
164
172
 
173
+ def _phase_thresholds() -> tuple[int, int]:
174
+ """Return the (phase 2, phase 3) signal thresholds.
175
+
176
+ Sourced from ``learning.ranker`` so the MCP surface can never report a
177
+ different phase than the one recall actually applies. Falls back to the
178
+ documented defaults only if the learning package is unavailable.
179
+ """
180
+ try:
181
+ from superlocalmemory.learning.ranker import (
182
+ PHASE_2_THRESHOLD,
183
+ PHASE_3_THRESHOLD,
184
+ )
185
+ return PHASE_2_THRESHOLD, PHASE_3_THRESHOLD
186
+ except Exception: # pragma: no cover — learning extras absent
187
+ return 50, 200
188
+
189
+
190
+ _PHASE_2_THRESHOLD, _PHASE_3_THRESHOLD = _phase_thresholds()
191
+
192
+
193
+ def _phase_for_signal_count(count: int) -> int:
194
+ """Map a canonical signal count onto the adaptive ranking phase."""
195
+ if count < _PHASE_2_THRESHOLD:
196
+ return 1
197
+ return 2 if count < _PHASE_3_THRESHOLD else 3
198
+
199
+
165
200
  def _record_canonical_feedback(
166
201
  *, profile_id: str, fact_id: str, feedback: str, query: str = "",
167
202
  channel: str = "explicit",
168
203
  ) -> bool:
169
204
  """Write explicit feedback to learning.db. Returns True on success.
170
205
 
171
- Best-effort by design a learning write must never fail the user's
172
- feedback callbut the outcome is RETURNED rather than swallowed, so the
173
- caller can tell the user the truth about whether the write was durable.
206
+ True means the ``learning_signals`` row that every phase counter reads
207
+ actually landednot merely that some row was written somewhere. The
208
+ outcome is RETURNED rather than swallowed so the caller can tell the user
209
+ the truth about whether the write was durable.
174
210
  """
175
211
  signal_type, value = _FEEDBACK_SIGNAL_MAP.get(
176
212
  feedback, ("user_correction", 0.5),
@@ -179,7 +215,7 @@ def _record_canonical_feedback(
179
215
  from superlocalmemory.learning.feedback import FeedbackCollector
180
216
 
181
217
  collector = FeedbackCollector(_learning_db_path())
182
- row_id = collector.record_explicit(
218
+ write = collector.record_explicit_event(
183
219
  profile_id=profile_id,
184
220
  fact_id=fact_id,
185
221
  signal_type=signal_type,
@@ -187,7 +223,7 @@ def _record_canonical_feedback(
187
223
  query=query,
188
224
  channel=channel,
189
225
  )
190
- return row_id is not None
226
+ return write.canonical
191
227
  except Exception as exc:
192
228
  logger.warning(
193
229
  "canonical feedback write failed (fact_id=%s): %s", fact_id, exc,
@@ -196,17 +232,20 @@ def _record_canonical_feedback(
196
232
 
197
233
 
198
234
  def _canonical_feedback_count(profile_id: str) -> int | None:
199
- """Count rows in the store that gates the adaptive phases.
235
+ """Count the store that gates the adaptive phases.
200
236
 
201
- Returns None when the store cannot be read, so the caller can fall back
202
- rather than report a misleading zero.
237
+ Returns None when the store cannot be read. The caller must NOT substitute
238
+ a count from a different table: before issue #106 an unreadable learning.db
239
+ silently fell back to ``feedback_records`` in memory.db — a table no
240
+ consumer reads — so the user watched a fabricated counter climb toward a
241
+ threshold that nothing was measuring, while the durable write did nothing.
203
242
  """
204
243
  try:
205
244
  from superlocalmemory.learning.feedback import FeedbackCollector
206
245
 
207
246
  return FeedbackCollector(
208
247
  _learning_db_path(),
209
- ).get_feedback_count(profile_id)
248
+ ).get_signal_count(profile_id)
210
249
  except Exception as exc:
211
250
  logger.warning("canonical feedback count failed: %s", exc)
212
251
  return None
@@ -441,16 +480,19 @@ def register_active_tools(server, get_engine: Callable) -> None:
441
480
  "source_type": m.source_type,
442
481
  })
443
482
 
444
- # Get learning status
445
- feedback_count = 0
446
- try:
447
- feedback_count = engine._adaptive_learner.get_feedback_count(pid)
448
- except Exception as exc:
449
- # Feedback count is a Dash-Core signal; a silent zero
450
- # masks wiring bugs. Log so operators see the failure.
483
+ # Learning status — issue #106: read the SAME canonical counter
484
+ # that report_feedback reports, the recall gate applies, and the
485
+ # dashboard displays. This used to read ``feedback_records`` in
486
+ # memory.db, so session_init and report_feedback returned two
487
+ # different "signal" totals for one profile in the same session.
488
+ # A silent zero masks wiring bugs, so a failed read is logged.
489
+ feedback_count = _canonical_feedback_count(pid)
490
+ if feedback_count is None:
451
491
  logger.warning(
452
- "session_init feedback_count read failed: %s", exc,
492
+ "session_init canonical signal count unavailable for "
493
+ "profile %s; reporting 0", pid,
453
494
  )
495
+ feedback_count = 0
454
496
 
455
497
  # v3.6.9 (#35): generate a stable session_id so clients can pass it
456
498
  # to remember() and close_session() for proper session aggregation.
@@ -484,11 +526,13 @@ def register_active_tools(server, get_engine: Callable) -> None:
484
526
  "abstention_reason": getattr(response, "abstention_reason", None),
485
527
  "learning": {
486
528
  "feedback_signals": feedback_count,
487
- "phase": 1 if feedback_count < 50 else (2 if feedback_count < 200 else 3),
529
+ "phase": _phase_for_signal_count(feedback_count),
488
530
  "status": (
489
531
  "collecting"
490
- if feedback_count < 50
491
- else "learning" if feedback_count < 200 else "trained"
532
+ if feedback_count < _PHASE_2_THRESHOLD
533
+ else "learning"
534
+ if feedback_count < _PHASE_3_THRESHOLD
535
+ else "trained"
492
536
  ),
493
537
  },
494
538
  }
@@ -623,22 +667,18 @@ def register_active_tools(server, get_engine: Callable) -> None:
623
667
  profile_id=pid,
624
668
  )
625
669
 
626
- # v3.8.11 (issue #102): the AdaptiveLearner write above lands in
627
- # ``feedback_records`` in memory.db — a table whose only readers
628
- # are AdaptiveLearner's own count and its train(), which nothing
629
- # in the running system calls. Reported feedback therefore
630
- # returned success and an incrementing counter while every actual
631
- # consumer saw nothing.
632
- #
633
- # The canonical learning store is learning.db. Writing here is
634
- # what makes feedback do work: the phase gate
635
- # (_ReadOnlyLearningView.count_feedback) unlocks adaptive ranking
636
- # at 50 rows, pattern_miner mines channel_performance from it, and
637
- # the dashboard Living Brain reads it. Recall stays read-only by
638
- # design, so this explicit path is the ONLY durable writer.
670
+ # The AdaptiveLearner write above lands in ``feedback_records`` in
671
+ # memory.db — a table whose only readers are AdaptiveLearner's own
672
+ # count and its train(), which nothing in the running system
673
+ # calls. It is kept so existing data and GDPR erasure stay intact,
674
+ # but it is NOT the learning write and its count is NOT reported.
639
675
  #
640
- # Kept alongside (not replacing) the AdaptiveLearner write so
641
- # existing feedback_records data and GDPR erasure stay intact.
676
+ # The canonical store is learning.db's ``learning_signals`` (+ the
677
+ # paired ``learning_features`` row). Writing there is what makes
678
+ # feedback do work: the recall phase gate, the dashboard Living
679
+ # Brain panel, the ranker-phase card, and the retrainer all read
680
+ # it. Recall stays read-only by design, so this explicit path is
681
+ # the only durable writer.
642
682
  canonical_recorded = _record_canonical_feedback(
643
683
  profile_id=pid,
644
684
  fact_id=fact_id,
@@ -646,16 +686,32 @@ def register_active_tools(server, get_engine: Callable) -> None:
646
686
  query=query,
647
687
  )
648
688
 
649
- # Report the count from the store that ACTUALLY gates the phases.
650
- # Pre-3.8.11 this returned the feedback_records count, so the
651
- # caller watched a number climb toward 50 while the gate — which
652
- # reads learning_feedback never moved.
689
+ # issue #106: report the count from the store that ACTUALLY gates
690
+ # the phases, and report NOTHING when it cannot be read. The old
691
+ # fallback to ``feedback_records`` is what made a total write
692
+ # failure indistinguishable from success: the response carried a
693
+ # plausible, incrementing ``total_signals`` sourced from a table
694
+ # nothing consumes, so the caller had no way to notice that
695
+ # learning.db was never touched.
653
696
  count = _canonical_feedback_count(pid)
654
- if count is None:
655
- count = engine._adaptive_learner.get_feedback_count(pid)
656
697
  authorization.complete()
657
698
 
658
- phase = 1 if count < 50 else (2 if count < 200 else 3)
699
+ if not canonical_recorded or count is None:
700
+ # Never claim a durable learning write that did not happen.
701
+ return {
702
+ "success": False,
703
+ "durable": False,
704
+ "feedback_id": record.feedback_id,
705
+ "total_signals": count,
706
+ "error": (
707
+ "Feedback was accepted but could not be written to "
708
+ "the canonical learning store (learning.db), so it "
709
+ "will not influence ranking. Run 'slm doctor' to "
710
+ "diagnose learning.db."
711
+ ),
712
+ }
713
+
714
+ phase = _phase_for_signal_count(count)
659
715
  _emit_event("pattern.learned", {
660
716
  "fact_id": fact_id,
661
717
  "feedback": feedback,
@@ -665,21 +721,16 @@ def register_active_tools(server, get_engine: Callable) -> None:
665
721
 
666
722
  result = {
667
723
  "success": True,
724
+ "durable": True,
668
725
  "feedback_id": record.feedback_id,
669
726
  "total_signals": count,
670
727
  "phase": phase,
671
728
  "message": f"Feedback recorded. {count} total signals."
672
- + (" Phase 2 unlocked!" if count == 50 else "")
673
- + (" Phase 3 (ML) unlocked!" if count == 200 else ""),
729
+ + (" Phase 2 unlocked!"
730
+ if count == _PHASE_2_THRESHOLD else "")
731
+ + (" Phase 3 (ML) unlocked!"
732
+ if count == _PHASE_3_THRESHOLD else ""),
674
733
  }
675
- if not canonical_recorded:
676
- # Never claim a durable learning write that did not happen.
677
- result["durable"] = False
678
- result["warning"] = (
679
- "Feedback was accepted but could not be written to the "
680
- "canonical learning store; it will not influence ranking. "
681
- "Run 'slm doctor' to diagnose learning.db."
682
- )
683
734
  return result
684
735
  except Exception as exc:
685
736
  logger.exception("report_feedback failed")
@@ -21,6 +21,7 @@ from mcp.types import ToolAnnotations
21
21
 
22
22
  from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
23
23
  from superlocalmemory.infra.data_root import state_path
24
+ from superlocalmemory.mcp._daemon_proxy import daemon_unavailable_error
24
25
  from superlocalmemory.mcp.shared import authorize_mcp_mutation
25
26
 
26
27
  logger = logging.getLogger(__name__)
@@ -155,7 +156,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
155
156
  "code": "DAEMON_UNAVAILABLE",
156
157
  "retryable": True,
157
158
  "error": (
158
- "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later."
159
+ daemon_unavailable_error()
159
160
  ),
160
161
  }
161
162
  except Exception as dexc:
@@ -166,7 +167,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
166
167
  "code": "DAEMON_UNAVAILABLE",
167
168
  "retryable": True,
168
169
  "error": (
169
- "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later."
170
+ daemon_unavailable_error()
170
171
  ),
171
172
  }
172
173
 
@@ -198,14 +199,14 @@ def register_core_tools(server, get_engine: Callable) -> None:
198
199
  "retryable": True,
199
200
  "error": stored.get(
200
201
  "error",
201
- "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later.",
202
+ daemon_unavailable_error(),
202
203
  ),
203
204
  }
204
205
  return {
205
206
  "success": False,
206
207
  "code": "DAEMON_UNAVAILABLE",
207
208
  "retryable": True,
208
- "error": "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later.",
209
+ "error": daemon_unavailable_error(),
209
210
  }
210
211
  fact_ids = list(stored.get("fact_ids") or [])
211
212
  materialization_state = str(
@@ -242,7 +243,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
242
243
  "success": False,
243
244
  "code": "DAEMON_UNAVAILABLE",
244
245
  "retryable": True,
245
- "error": "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later.",
246
+ "error": daemon_unavailable_error(),
246
247
  }
247
248
 
248
249
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))