superlocalmemory 3.8.9 → 3.8.11

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 +65 -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 +1 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/cli/commands.py +13 -3
  34. package/src/superlocalmemory/cli/daemon.py +89 -16
  35. package/src/superlocalmemory/core/embeddings.py +35 -4
  36. package/src/superlocalmemory/core/ollama_embedder.py +11 -2
  37. package/src/superlocalmemory/core/remember_admission.py +14 -5
  38. package/src/superlocalmemory/core/reranker_worker.py +59 -17
  39. package/src/superlocalmemory/hooks/adapter_base.py +10 -3
  40. package/src/superlocalmemory/learning/feedback.py +46 -4
  41. package/src/superlocalmemory/learning/pattern_miner.py +31 -11
  42. package/src/superlocalmemory/mcp/tools_active.py +115 -4
  43. package/src/superlocalmemory/mcp/tools_core.py +16 -5
  44. package/src/superlocalmemory/optimize/proxy/capture.py +148 -30
  45. package/src/superlocalmemory/optimize/storage/db.py +6 -2
  46. package/src/superlocalmemory/retrieval/reranker.py +52 -5
  47. package/src/superlocalmemory/server/unified_daemon.py +12 -1
  48. package/src/superlocalmemory/storage/admission_codec.py +10 -0
  49. package/src/superlocalmemory/storage/admission_journal.py +182 -67
  50. package/src/superlocalmemory/storage/embedding_migrator.py +27 -13
  51. package/src/superlocalmemory/storage/migration_runner.py +9 -0
  52. package/src/superlocalmemory/storage/migrations/M033_learning_feedback_channel.py +77 -0
  53. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
  54. package/src/superlocalmemory/storage/write_coordinator.py +68 -21
@@ -119,7 +119,9 @@ def _worker_main() -> None:
119
119
  if cmd == "load":
120
120
  name = req.get("model_name", "cross-encoder/ms-marco-MiniLM-L-12-v2")
121
121
  backend = req.get("backend", "onnx")
122
- model, active_backend, model_name = _load_model(name, backend)
122
+ model, active_backend, model_name, load_error = _load_model(
123
+ name, backend,
124
+ )
123
125
  # V3.3.16: Run real inference to trigger ONNX CoreML JIT compilation.
124
126
  # Without this, first real rerank call triggers 30-60s compilation
125
127
  # that exceeds the caller's timeout, killing the worker.
@@ -147,6 +149,9 @@ def _worker_main() -> None:
147
149
  "backend": active_backend,
148
150
  "model": model_name,
149
151
  "warmup_inference": warmup_ok,
152
+ # Carries the real reason to the parent so the warmup log can
153
+ # print it instead of a generic timeout message (issue #103).
154
+ "error": load_error,
150
155
  })
151
156
  continue
152
157
 
@@ -160,9 +165,14 @@ def _worker_main() -> None:
160
165
  # Auto-load with defaults
161
166
  name = req.get("model_name", "cross-encoder/ms-marco-MiniLM-L-12-v2")
162
167
  backend = req.get("backend", "onnx")
163
- model, active_backend, model_name = _load_model(name, backend)
168
+ model, active_backend, model_name, load_error = _load_model(
169
+ name, backend,
170
+ )
164
171
  if model is None:
165
- _respond({"ok": False, "error": "Model load failed"})
172
+ _respond({
173
+ "ok": False,
174
+ "error": load_error or "Model load failed",
175
+ })
166
176
  continue
167
177
  try:
168
178
  pairs = [(query, doc) for doc in documents]
@@ -195,9 +205,14 @@ def _worker_main() -> None:
195
205
  if model is None:
196
206
  name = req.get("model_name", "cross-encoder/ms-marco-MiniLM-L-12-v2")
197
207
  backend = req.get("backend", "onnx")
198
- model, active_backend, model_name = _load_model(name, backend)
208
+ model, active_backend, model_name, load_error = _load_model(
209
+ name, backend,
210
+ )
199
211
  if model is None:
200
- _respond({"ok": False, "error": "Model load failed"})
212
+ _respond({
213
+ "ok": False,
214
+ "error": load_error or "Model load failed",
215
+ })
201
216
  continue
202
217
  try:
203
218
  try:
@@ -214,10 +229,13 @@ def _worker_main() -> None:
214
229
  _respond({"ok": False, "error": f"Unknown command: {cmd}"})
215
230
 
216
231
 
232
+ _KNOWN_BACKENDS = ("onnx", "", "pytorch", "torch")
233
+
234
+
217
235
  def _load_model(
218
236
  name: str, backend: str,
219
237
  ) -> tuple:
220
- """Load cross-encoder model. Returns (model, backend_name, model_name).
238
+ """Load cross-encoder model. Returns (model, backend_name, model_name, error).
221
239
 
222
240
  V3.3.13: sentence-transformers 5.x+ supports backend='onnx' for
223
241
  CrossEncoder. We use a 3-tier fallback chain:
@@ -231,6 +249,19 @@ def _load_model(
231
249
  x86_64 → model_quint8_avx2.onnx
232
250
  Fallback → model.onnx (generic)
233
251
  """
252
+ # v3.8.11 (issue #103): an unrecognised backend used to fall through to
253
+ # the PyTorch tier and fail there with a confusing model-load error. A
254
+ # user who set backend="openai" expecting a remote reranker got five
255
+ # silent failures and no hint that the value meant nothing. Name it.
256
+ if backend not in _KNOWN_BACKENDS:
257
+ return None, "", "", (
258
+ f"unknown backend {backend!r}; supported values are "
259
+ f"'onnx' or '' (PyTorch). SuperLocalMemory has no remote/"
260
+ f"OpenAI-compatible reranker backend — the cross-encoder always "
261
+ f"runs locally, so 'cross_encoder_endpoint' has no effect."
262
+ )
263
+
264
+ tier_errors: list[str] = []
234
265
  try:
235
266
  from sentence_transformers import CrossEncoder
236
267
 
@@ -242,24 +273,35 @@ def _load_model(
242
273
  name, backend="onnx",
243
274
  model_kwargs={"file_name": onnx_file},
244
275
  )
245
- return m, f"onnx-quantized({onnx_file})", name
246
- except Exception:
247
- pass
276
+ return m, f"onnx-quantized({onnx_file})", name, ""
277
+ except Exception as exc:
278
+ tier_errors.append(f"onnx-quantized: {exc}")
248
279
 
249
280
  # Tier 2: Generic ONNX (auto-exported by optimum)
250
281
  try:
251
282
  m = CrossEncoder(name, backend="onnx")
252
- return m, "onnx", name
253
- except Exception:
254
- pass
283
+ return m, "onnx", name, ""
284
+ except Exception as exc:
285
+ tier_errors.append(f"onnx: {exc}")
255
286
 
256
287
  # Tier 3: PyTorch (always works, no ONNX dependency needed)
257
288
  m = CrossEncoder(name)
258
- return m, "pytorch", name
259
- except ImportError:
260
- return None, "", ""
261
- except Exception:
262
- return None, "", ""
289
+ return m, "pytorch", name, ""
290
+ except ImportError as exc:
291
+ # Previously indistinguishable from a bad model name.
292
+ return None, "", "", (
293
+ f"sentence-transformers is not installed ({exc}); "
294
+ f"install it or set retrieval.use_cross_encoder=false"
295
+ )
296
+ except Exception as exc:
297
+ tier_errors.append(f"pytorch: {exc}")
298
+ # Every tier's real error, propagated instead of discarded. Before
299
+ # 3.8.11 this returned (None, "", "") and the operator saw only a
300
+ # generic "did not confirm ready" line from the parent process.
301
+ return None, "", "", (
302
+ f"could not load cross-encoder model {name!r} "
303
+ f"(backend={backend or 'pytorch'}): " + "; ".join(tier_errors)
304
+ )
263
305
 
264
306
 
265
307
  def _respond(data: dict) -> None:
@@ -26,7 +26,6 @@ from __future__ import annotations
26
26
  import hashlib
27
27
  import os
28
28
  import sqlite3
29
- import sys
30
29
  from dataclasses import dataclass
31
30
  from datetime import datetime, timezone
32
31
  from pathlib import Path
@@ -76,8 +75,11 @@ class Adapter(Protocol):
76
75
 
77
76
  def path_sha256(path: Path) -> str:
78
77
  """SHA-256 of the absolute path string, full 64-hex (never truncated)."""
79
- return hashlib.sha256(str(path.resolve() if path.exists()
80
- else path).encode("utf-8")).hexdigest()
78
+ # The identity must not depend on whether the target exists. On Windows,
79
+ # Path.resolve() can normalize an existing path differently from the same
80
+ # not-yet-created path, changing the sync-log key after the first write.
81
+ canonical = os.path.normcase(os.path.abspath(os.fspath(path)))
82
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
81
83
 
82
84
 
83
85
  def _now_iso() -> str:
@@ -252,6 +254,11 @@ def atomic_write(
252
254
  flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
253
255
  if hasattr(os, "O_NOFOLLOW") and _is_posix():
254
256
  flags |= os.O_NOFOLLOW # SEC — POSIX refuses symlinks
257
+ if hasattr(os, "O_BINARY") and not _is_posix():
258
+ # Windows file descriptors default to text mode, which rewrites LF
259
+ # bytes as CRLF. The sync log hashes the caller's original bytes, so
260
+ # text-mode conversion makes an unchanged file look modified forever.
261
+ flags |= os.O_BINARY
255
262
 
256
263
  mode = posix_mode if _is_posix() else windows_mode
257
264
  fd = os.open(str(tmp), flags, mode)
@@ -53,6 +53,13 @@ _DASHBOARD_SIGNAL_MAP: Dict[str, tuple[str, float]] = {
53
53
  "dwell_negative": ("dwell_negative", 0.2),
54
54
  }
55
55
 
56
+ # ``channel`` records WHICH retrieval channel surfaced the fact (semantic,
57
+ # bm25, entity_graph, temporal, ...). ``pattern_miner._mine_channel_and_
58
+ # coretrieval`` groups on it to mine ``channel_performance`` patterns. It was
59
+ # read by the miner but never defined here, so every fresh database raised
60
+ # "no such column: channel" — swallowed at debug level, which silently killed
61
+ # BOTH channel mining and the co-retrieval mining that followed it in the same
62
+ # try block. Defined here for new databases; M033 back-fills existing ones.
56
63
  _CREATE_TABLE = """
57
64
  CREATE TABLE IF NOT EXISTS learning_feedback (
58
65
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -62,7 +69,8 @@ CREATE TABLE IF NOT EXISTS learning_feedback (
62
69
  signal_value REAL NOT NULL,
63
70
  query_hash TEXT,
64
71
  created_at TEXT NOT NULL,
65
- metadata TEXT
72
+ metadata TEXT,
73
+ channel TEXT DEFAULT 'unknown'
66
74
  )
67
75
  """
68
76
 
@@ -71,6 +79,11 @@ CREATE INDEX IF NOT EXISTS idx_feedback_profile
71
79
  ON learning_feedback (profile_id, created_at DESC)
72
80
  """
73
81
 
82
+ _CREATE_CHANNEL_INDEX = """
83
+ CREATE INDEX IF NOT EXISTS idx_feedback_channel
84
+ ON learning_feedback (profile_id, channel)
85
+ """
86
+
74
87
 
75
88
  def _utcnow_iso() -> str:
76
89
  """Return current UTC time as ISO-8601 string."""
@@ -108,6 +121,20 @@ class FeedbackCollector:
108
121
  try:
109
122
  conn.execute(_CREATE_TABLE)
110
123
  conn.execute(_CREATE_INDEX)
124
+ # Pre-3.8.11 databases created ``learning_feedback`` without the
125
+ # ``channel`` column. M033 covers migrated installs; this ADD keeps
126
+ # a collector pointed at a legacy file self-healing rather than
127
+ # failing every channel query for the life of the process.
128
+ existing = {
129
+ row[1] for row in
130
+ conn.execute("PRAGMA table_info(learning_feedback)")
131
+ }
132
+ if "channel" not in existing:
133
+ conn.execute(
134
+ "ALTER TABLE learning_feedback "
135
+ "ADD COLUMN channel TEXT DEFAULT 'unknown'"
136
+ )
137
+ conn.execute(_CREATE_CHANNEL_INDEX)
111
138
  conn.commit()
112
139
  finally:
113
140
  conn.close()
@@ -196,16 +223,29 @@ class FeedbackCollector:
196
223
  fact_id: str,
197
224
  signal_type: str,
198
225
  value: float,
226
+ query: str = "",
227
+ channel: str = "unknown",
199
228
  ) -> Optional[int]:
200
229
  """
201
230
  Record explicit user feedback on a specific fact.
202
231
 
232
+ This is the canonical durable write for the learning system. Recall
233
+ itself is deliberately read-only (it must never open a writer — see
234
+ ``test_readonly_bandit_uses_uri_read_connection_and_never_records_play``),
235
+ so explicit feedback is the ONLY path that grows ``learning_feedback``.
236
+ Every downstream consumer reads this table: the phase gate
237
+ (``_ReadOnlyLearningView.count_feedback`` unlocks adaptive ranking at
238
+ 50 rows), ``pattern_miner`` (channel_performance), and the dashboard.
239
+
203
240
  Args:
204
241
  profile_id: Profile providing feedback.
205
242
  fact_id: The fact being rated.
206
243
  signal_type: One of ``user_positive``, ``user_negative``,
207
244
  ``user_correction``, or any custom type.
208
245
  value: Numeric signal value (0.0 to 1.0).
246
+ query: Originating query. Stored only as a SHA-256[:16]
247
+ hash — full text is never persisted.
248
+ channel: Retrieval channel that surfaced the fact.
209
249
 
210
250
  Returns:
211
251
  Row ID of the inserted record, or None on error.
@@ -215,6 +255,7 @@ class FeedbackCollector:
215
255
 
216
256
  clamped = max(0.0, min(1.0, float(value)))
217
257
  now = _utcnow_iso()
258
+ query_hash = _hash_query(query) if query else None
218
259
 
219
260
  with self._lock:
220
261
  conn = self._connect()
@@ -222,9 +263,10 @@ class FeedbackCollector:
222
263
  cursor = conn.execute(
223
264
  "INSERT INTO learning_feedback "
224
265
  "(profile_id, fact_id, signal_type, signal_value, "
225
- "query_hash, created_at, metadata) "
226
- "VALUES (?, ?, ?, ?, ?, ?, ?)",
227
- (profile_id, fact_id, signal_type, clamped, None, now, None),
266
+ "query_hash, created_at, metadata, channel) "
267
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
268
+ (profile_id, fact_id, signal_type, clamped, query_hash,
269
+ now, None, channel or "unknown"),
228
270
  )
229
271
  conn.commit()
230
272
  return cursor.lastrowid
@@ -325,14 +325,28 @@ def _mine_channel_and_coretrieval(
325
325
  learn_conn = sqlite3.connect(learning_db, timeout=10)
326
326
  learn_conn.row_factory = sqlite3.Row
327
327
 
328
- channel_rows = learn_conn.execute(
329
- "SELECT channel, COUNT(*) AS cnt, "
330
- "AVG(signal_value) AS avg_signal "
331
- "FROM learning_feedback "
332
- "WHERE profile_id = ? "
333
- "GROUP BY channel ORDER BY cnt DESC",
334
- (profile_id,),
335
- ).fetchall()
328
+ # Isolated from the co-retrieval block below. Until 3.8.11
329
+ # ``learning_feedback`` had no ``channel`` column, so this query
330
+ # raised and — sharing one try with co-retrieval — took that mining
331
+ # down with it. Two pattern types died from one missing column, and
332
+ # the only trace was a DEBUG line. Each miner now fails alone, loudly.
333
+ channel_rows = []
334
+ try:
335
+ channel_rows = learn_conn.execute(
336
+ "SELECT channel, COUNT(*) AS cnt, "
337
+ "AVG(signal_value) AS avg_signal "
338
+ "FROM learning_feedback "
339
+ "WHERE profile_id = ? "
340
+ "GROUP BY channel ORDER BY cnt DESC",
341
+ (profile_id,),
342
+ ).fetchall()
343
+ except sqlite3.Error as exc:
344
+ logger.warning(
345
+ "Channel pattern mining skipped — learning_feedback query "
346
+ "failed (%s). Run 'slm db migrate' to apply M033 if this "
347
+ "reports a missing 'channel' column. Co-retrieval mining "
348
+ "continues.", exc,
349
+ )
336
350
 
337
351
  for row in channel_rows:
338
352
  d = dict(row)
@@ -376,12 +390,18 @@ def _mine_channel_and_coretrieval(
376
390
  confidence=min(1.0, len(coret_rows) / 10),
377
391
  )
378
392
  gen += 1
379
- except Exception:
380
- pass
393
+ except sqlite3.Error as exc:
394
+ logger.warning(
395
+ "Co-retrieval pattern mining skipped — co_retrieval_edges "
396
+ "query failed: %s", exc,
397
+ )
381
398
 
382
399
  learn_conn.close()
383
400
  except Exception as exc:
384
- logger.debug("Signal pattern mining failed: %s", exc)
401
+ # Was DEBUG. A learning subsystem that mines nothing must say so at a
402
+ # level operators actually see; issue #102 went undiagnosed for weeks
403
+ # because the only evidence was invisible by default.
404
+ logger.warning("Signal pattern mining failed: %s", exc)
385
405
  return gen
386
406
 
387
407
 
@@ -140,6 +140,78 @@ 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 (issue #102)
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.
151
+ # ---------------------------------------------------------------------------
152
+
153
+ _FEEDBACK_SIGNAL_MAP: dict[str, tuple[str, float]] = {
154
+ "relevant": ("user_positive", 1.0),
155
+ "irrelevant": ("user_negative", 0.0),
156
+ "partial": ("user_correction", 0.5),
157
+ }
158
+
159
+
160
+ def _learning_db_path():
161
+ """Resolve the canonical learning.db path."""
162
+ return state_path("learning.db")
163
+
164
+
165
+ def _record_canonical_feedback(
166
+ *, profile_id: str, fact_id: str, feedback: str, query: str = "",
167
+ channel: str = "explicit",
168
+ ) -> bool:
169
+ """Write explicit feedback to learning.db. Returns True on success.
170
+
171
+ Best-effort by design — a learning write must never fail the user's
172
+ feedback call — but the outcome is RETURNED rather than swallowed, so the
173
+ caller can tell the user the truth about whether the write was durable.
174
+ """
175
+ signal_type, value = _FEEDBACK_SIGNAL_MAP.get(
176
+ feedback, ("user_correction", 0.5),
177
+ )
178
+ try:
179
+ from superlocalmemory.learning.feedback import FeedbackCollector
180
+
181
+ collector = FeedbackCollector(_learning_db_path())
182
+ row_id = collector.record_explicit(
183
+ profile_id=profile_id,
184
+ fact_id=fact_id,
185
+ signal_type=signal_type,
186
+ value=value,
187
+ query=query,
188
+ channel=channel,
189
+ )
190
+ return row_id is not None
191
+ except Exception as exc:
192
+ logger.warning(
193
+ "canonical feedback write failed (fact_id=%s): %s", fact_id, exc,
194
+ )
195
+ return False
196
+
197
+
198
+ def _canonical_feedback_count(profile_id: str) -> int | None:
199
+ """Count rows in the store that gates the adaptive phases.
200
+
201
+ Returns None when the store cannot be read, so the caller can fall back
202
+ rather than report a misleading zero.
203
+ """
204
+ try:
205
+ from superlocalmemory.learning.feedback import FeedbackCollector
206
+
207
+ return FeedbackCollector(
208
+ _learning_db_path(),
209
+ ).get_feedback_count(profile_id)
210
+ except Exception as exc:
211
+ logger.warning("canonical feedback count failed: %s", exc)
212
+ return None
213
+
214
+
143
215
  def register_active_tools(server, get_engine: Callable) -> None:
144
216
  """Register 3 active memory tools on *server*."""
145
217
 
@@ -551,25 +623,64 @@ def register_active_tools(server, get_engine: Callable) -> None:
551
623
  profile_id=pid,
552
624
  )
553
625
 
554
- count = engine._adaptive_learner.get_feedback_count(pid)
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.
639
+ #
640
+ # Kept alongside (not replacing) the AdaptiveLearner write so
641
+ # existing feedback_records data and GDPR erasure stay intact.
642
+ canonical_recorded = _record_canonical_feedback(
643
+ profile_id=pid,
644
+ fact_id=fact_id,
645
+ feedback=feedback,
646
+ query=query,
647
+ )
648
+
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.
653
+ count = _canonical_feedback_count(pid)
654
+ if count is None:
655
+ count = engine._adaptive_learner.get_feedback_count(pid)
555
656
  authorization.complete()
556
657
 
658
+ phase = 1 if count < 50 else (2 if count < 200 else 3)
557
659
  _emit_event("pattern.learned", {
558
660
  "fact_id": fact_id,
559
661
  "feedback": feedback,
560
662
  "total_signals": count,
561
- "phase": 1 if count < 50 else (2 if count < 200 else 3),
663
+ "phase": phase,
562
664
  })
563
665
 
564
- return {
666
+ result = {
565
667
  "success": True,
566
668
  "feedback_id": record.feedback_id,
567
669
  "total_signals": count,
568
- "phase": 1 if count < 50 else (2 if count < 200 else 3),
670
+ "phase": phase,
569
671
  "message": f"Feedback recorded. {count} total signals."
570
672
  + (" Phase 2 unlocked!" if count == 50 else "")
571
673
  + (" Phase 3 (ML) unlocked!" if count == 200 else ""),
572
674
  }
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
+ return result
573
684
  except Exception as exc:
574
685
  logger.exception("report_feedback failed")
575
686
  return {"success": False, "error": str(exc)}
@@ -105,6 +105,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
105
105
  # recall window so a parallel/next agent finds memories saved seconds ago.
106
106
  # Falls back to the capability-owned worker only if the daemon is
107
107
  # unreachable. Raw pending.db writes are legacy replay input only.
108
+ daemon_owned = False
108
109
  try:
109
110
  import asyncio as _asyncio
110
111
  from superlocalmemory.cli.daemon import daemon_request, is_daemon_running
@@ -159,6 +160,15 @@ def register_core_tools(server, get_engine: Callable) -> None:
159
160
  }
160
161
  except Exception as dexc:
161
162
  logger.debug("MCP remember via daemon failed, pending fallback: %s", dexc)
163
+ if daemon_owned:
164
+ return {
165
+ "success": False,
166
+ "code": "DAEMON_UNAVAILABLE",
167
+ "retryable": True,
168
+ "error": (
169
+ "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later."
170
+ ),
171
+ }
162
172
 
163
173
  try:
164
174
  import asyncio as _asyncio
@@ -174,11 +184,12 @@ def register_core_tools(server, get_engine: Callable) -> None:
174
184
  or "mcp:" + hashlib.sha256(content.encode("utf-8")).hexdigest()
175
185
  ),
176
186
  }
177
- stored = await _asyncio.to_thread(
178
- choose_pool().store,
179
- content,
180
- worker_meta,
181
- )
187
+
188
+ def _store_via_daemon_pool():
189
+ pool = choose_pool()
190
+ return pool.store(content, worker_meta)
191
+
192
+ stored = await _asyncio.to_thread(_store_via_daemon_pool)
182
193
  if not isinstance(stored, dict) or not stored.get("ok"):
183
194
  if isinstance(stored, dict) and stored.get("code") == "DAEMON_UNAVAILABLE":
184
195
  return {