superlocalmemory 3.8.10 → 3.8.12

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 (54) hide show
  1. package/CHANGELOG.md +91 -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 +28 -6
  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 +78 -17
  41. package/src/superlocalmemory/infra/daemon_identity.py +16 -0
  42. package/src/superlocalmemory/infra/process_identity.py +180 -0
  43. package/src/superlocalmemory/learning/feedback.py +328 -27
  44. package/src/superlocalmemory/learning/legacy_migration.py +45 -4
  45. package/src/superlocalmemory/learning/pattern_miner.py +31 -11
  46. package/src/superlocalmemory/mcp/_daemon_proxy.py +23 -1
  47. package/src/superlocalmemory/mcp/tools_active.py +179 -17
  48. package/src/superlocalmemory/mcp/tools_core.py +6 -5
  49. package/src/superlocalmemory/retrieval/remote_reranker.py +636 -0
  50. package/src/superlocalmemory/retrieval/reranker.py +52 -5
  51. package/src/superlocalmemory/server/unified_daemon.py +4 -0
  52. package/src/superlocalmemory/storage/migration_runner.py +9 -0
  53. package/src/superlocalmemory/storage/migrations/M033_learning_feedback_channel.py +77 -0
  54. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
@@ -140,6 +140,117 @@ def _emit_event(event_type: str, payload: dict | None = None,
140
140
  logger.warning("event emit failed: type=%s err=%s", event_type, exc)
141
141
 
142
142
 
143
+ # ---------------------------------------------------------------------------
144
+ # Canonical learning-store feedback (issues #102, #106)
145
+ #
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.
159
+ # ---------------------------------------------------------------------------
160
+
161
+ _FEEDBACK_SIGNAL_MAP: dict[str, tuple[str, float]] = {
162
+ "relevant": ("user_positive", 1.0),
163
+ "irrelevant": ("user_negative", 0.0),
164
+ "partial": ("user_correction", 0.5),
165
+ }
166
+
167
+
168
+ def _learning_db_path():
169
+ """Resolve the canonical learning.db path."""
170
+ return state_path("learning.db")
171
+
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
+
200
+ def _record_canonical_feedback(
201
+ *, profile_id: str, fact_id: str, feedback: str, query: str = "",
202
+ channel: str = "explicit",
203
+ ) -> bool:
204
+ """Write explicit feedback to learning.db. Returns True on success.
205
+
206
+ True means the ``learning_signals`` row that every phase counter reads
207
+ actually landed — not 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.
210
+ """
211
+ signal_type, value = _FEEDBACK_SIGNAL_MAP.get(
212
+ feedback, ("user_correction", 0.5),
213
+ )
214
+ try:
215
+ from superlocalmemory.learning.feedback import FeedbackCollector
216
+
217
+ collector = FeedbackCollector(_learning_db_path())
218
+ write = collector.record_explicit_event(
219
+ profile_id=profile_id,
220
+ fact_id=fact_id,
221
+ signal_type=signal_type,
222
+ value=value,
223
+ query=query,
224
+ channel=channel,
225
+ )
226
+ return write.canonical
227
+ except Exception as exc:
228
+ logger.warning(
229
+ "canonical feedback write failed (fact_id=%s): %s", fact_id, exc,
230
+ )
231
+ return False
232
+
233
+
234
+ def _canonical_feedback_count(profile_id: str) -> int | None:
235
+ """Count the store that gates the adaptive phases.
236
+
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.
242
+ """
243
+ try:
244
+ from superlocalmemory.learning.feedback import FeedbackCollector
245
+
246
+ return FeedbackCollector(
247
+ _learning_db_path(),
248
+ ).get_signal_count(profile_id)
249
+ except Exception as exc:
250
+ logger.warning("canonical feedback count failed: %s", exc)
251
+ return None
252
+
253
+
143
254
  def register_active_tools(server, get_engine: Callable) -> None:
144
255
  """Register 3 active memory tools on *server*."""
145
256
 
@@ -369,16 +480,19 @@ def register_active_tools(server, get_engine: Callable) -> None:
369
480
  "source_type": m.source_type,
370
481
  })
371
482
 
372
- # Get learning status
373
- feedback_count = 0
374
- try:
375
- feedback_count = engine._adaptive_learner.get_feedback_count(pid)
376
- except Exception as exc:
377
- # Feedback count is a Dash-Core signal; a silent zero
378
- # 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:
379
491
  logger.warning(
380
- "session_init feedback_count read failed: %s", exc,
492
+ "session_init canonical signal count unavailable for "
493
+ "profile %s; reporting 0", pid,
381
494
  )
495
+ feedback_count = 0
382
496
 
383
497
  # v3.6.9 (#35): generate a stable session_id so clients can pass it
384
498
  # to remember() and close_session() for proper session aggregation.
@@ -412,11 +526,13 @@ def register_active_tools(server, get_engine: Callable) -> None:
412
526
  "abstention_reason": getattr(response, "abstention_reason", None),
413
527
  "learning": {
414
528
  "feedback_signals": feedback_count,
415
- "phase": 1 if feedback_count < 50 else (2 if feedback_count < 200 else 3),
529
+ "phase": _phase_for_signal_count(feedback_count),
416
530
  "status": (
417
531
  "collecting"
418
- if feedback_count < 50
419
- 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"
420
536
  ),
421
537
  },
422
538
  }
@@ -551,25 +667,71 @@ def register_active_tools(server, get_engine: Callable) -> None:
551
667
  profile_id=pid,
552
668
  )
553
669
 
554
- count = engine._adaptive_learner.get_feedback_count(pid)
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.
675
+ #
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.
682
+ canonical_recorded = _record_canonical_feedback(
683
+ profile_id=pid,
684
+ fact_id=fact_id,
685
+ feedback=feedback,
686
+ query=query,
687
+ )
688
+
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.
696
+ count = _canonical_feedback_count(pid)
555
697
  authorization.complete()
556
698
 
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)
557
715
  _emit_event("pattern.learned", {
558
716
  "fact_id": fact_id,
559
717
  "feedback": feedback,
560
718
  "total_signals": count,
561
- "phase": 1 if count < 50 else (2 if count < 200 else 3),
719
+ "phase": phase,
562
720
  })
563
721
 
564
- return {
722
+ result = {
565
723
  "success": True,
724
+ "durable": True,
566
725
  "feedback_id": record.feedback_id,
567
726
  "total_signals": count,
568
- "phase": 1 if count < 50 else (2 if count < 200 else 3),
727
+ "phase": phase,
569
728
  "message": f"Feedback recorded. {count} total signals."
570
- + (" Phase 2 unlocked!" if count == 50 else "")
571
- + (" 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 ""),
572
733
  }
734
+ return result
573
735
  except Exception as exc:
574
736
  logger.exception("report_feedback failed")
575
737
  return {"success": False, "error": str(exc)}
@@ -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))