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.
- package/CHANGELOG.md +91 -0
- package/README.md +7 -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 +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +28 -6
- package/src/superlocalmemory/cli/daemon.py +219 -10
- package/src/superlocalmemory/cli/setup_wizard.py +45 -1
- package/src/superlocalmemory/core/component_registry.py +25 -0
- package/src/superlocalmemory/core/config.py +35 -1
- package/src/superlocalmemory/core/engine_wiring.py +81 -5
- package/src/superlocalmemory/core/recall_pipeline.py +25 -4
- package/src/superlocalmemory/core/reranker_worker.py +78 -17
- package/src/superlocalmemory/infra/daemon_identity.py +16 -0
- package/src/superlocalmemory/infra/process_identity.py +180 -0
- package/src/superlocalmemory/learning/feedback.py +328 -27
- package/src/superlocalmemory/learning/legacy_migration.py +45 -4
- package/src/superlocalmemory/learning/pattern_miner.py +31 -11
- package/src/superlocalmemory/mcp/_daemon_proxy.py +23 -1
- package/src/superlocalmemory/mcp/tools_active.py +179 -17
- package/src/superlocalmemory/mcp/tools_core.py +6 -5
- package/src/superlocalmemory/retrieval/remote_reranker.py +636 -0
- package/src/superlocalmemory/retrieval/reranker.py +52 -5
- package/src/superlocalmemory/server/unified_daemon.py +4 -0
- package/src/superlocalmemory/storage/migration_runner.py +9 -0
- package/src/superlocalmemory/storage/migrations/M033_learning_feedback_channel.py +77 -0
- 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
|
-
#
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
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":
|
|
529
|
+
"phase": _phase_for_signal_count(feedback_count),
|
|
416
530
|
"status": (
|
|
417
531
|
"collecting"
|
|
418
|
-
if feedback_count <
|
|
419
|
-
else "learning"
|
|
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
|
-
|
|
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":
|
|
719
|
+
"phase": phase,
|
|
562
720
|
})
|
|
563
721
|
|
|
564
|
-
|
|
722
|
+
result = {
|
|
565
723
|
"success": True,
|
|
724
|
+
"durable": True,
|
|
566
725
|
"feedback_id": record.feedback_id,
|
|
567
726
|
"total_signals": count,
|
|
568
|
-
"phase":
|
|
727
|
+
"phase": phase,
|
|
569
728
|
"message": f"Feedback recorded. {count} total signals."
|
|
570
|
-
+ (" Phase 2 unlocked!"
|
|
571
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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":
|
|
246
|
+
"error": daemon_unavailable_error(),
|
|
246
247
|
}
|
|
247
248
|
|
|
248
249
|
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|