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
@@ -15,7 +15,25 @@ Privacy:
15
15
  - Queries are hashed to SHA-256[:16] for grouping.
16
16
 
17
17
  Storage:
18
- Uses direct sqlite3 with a self-contained ``learning_feedback`` table.
18
+ Every explicit-feedback event is written to the CANONICAL learning store
19
+ -- a ``learning_signals`` row paired 1:1 with a ``learning_features`` row
20
+ -- in the same transaction as the historic ``learning_feedback`` row.
21
+
22
+ ``learning_signals`` is canonical because every live consumer already
23
+ reads it: the dashboard's Living Brain panel and ranker-phase card
24
+ (``server/routes/brain.py``, ``server/routes/learning.py``), the LightGBM
25
+ retrainer, and -- since issue #106 -- the recall phase gate.
26
+ ``learning_feedback`` is the pre-v3.4.22 table: ``legacy_migration``
27
+ copies it forward into ``learning_signals``, the dashboard reports it as
28
+ ``legacy_feedback_rows`` with a "pending migration" card, and the phase
29
+ gate's own docstring calls it legacy. Writing feedback only there (the
30
+ v3.8.11 attempt at issue #102) put the durable write in a table no phase
31
+ counter consumes, which is why reported feedback still changed nothing.
32
+
33
+ It is kept written for one more release (LLD-07 D5) so ``pattern_miner``
34
+ channel mining and GDPR erasure keep working; the shared identity from
35
+ ``legacy_migration.legacy_query_id`` stops the two writers double-counting.
36
+
19
37
  NOT coupled to V3 DatabaseManager -- this is a standalone data collector.
20
38
  """
21
39
 
@@ -25,6 +43,7 @@ import hashlib
25
43
  import logging
26
44
  import sqlite3
27
45
  import threading
46
+ from dataclasses import dataclass
28
47
  from datetime import datetime, timezone
29
48
  from pathlib import Path
30
49
  from typing import Any, Dict, List, Optional
@@ -85,11 +104,72 @@ CREATE INDEX IF NOT EXISTS idx_feedback_channel
85
104
  """
86
105
 
87
106
 
107
+ # Signal type stamped on the canonical ``learning_signals`` row for an
108
+ # explicit-feedback event. Identical to what ``legacy_migration`` writes when
109
+ # it carries a ``learning_feedback`` row forward, so a row recorded eagerly
110
+ # and a row migrated in batch are indistinguishable to every consumer.
111
+ CANONICAL_SIGNAL_TYPE = "legacy_feedback"
112
+
113
+ # ``learning_features.features_json`` for a feedback event. Feedback arrives
114
+ # out of band -- there is no ranked candidate list to extract a real feature
115
+ # vector from -- so the row is empty and flagged ``is_synthetic=1``. The
116
+ # LightGBM retrainer selects ``WHERE is_synthetic=0``, so these rows move the
117
+ # phase counters and the bandit without ever polluting model training.
118
+ _SYNTHETIC_FEATURES_JSON = "{}"
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class FeedbackWrite:
123
+ """Outcome of one explicit-feedback write.
124
+
125
+ ``canonical`` is the only field callers should gate user-facing success
126
+ on: it is True when the ``learning_signals`` row that every phase counter
127
+ reads actually landed. ``feedback_row_id`` alone means the legacy row was
128
+ written, which on its own influences nothing.
129
+ """
130
+
131
+ feedback_row_id: Optional[int]
132
+ signal_row_id: Optional[int]
133
+ canonical: bool
134
+
135
+
88
136
  def _utcnow_iso() -> str:
89
137
  """Return current UTC time as ISO-8601 string."""
90
138
  return datetime.now(timezone.utc).isoformat()
91
139
 
92
140
 
141
+ def _canonical_schema_ready(conn: sqlite3.Connection) -> bool:
142
+ """Return True when learning.db can accept a canonical feedback event.
143
+
144
+ Both tables must exist AND carry the LLD-02 columns the event needs
145
+ (``learning_signals.query_id`` for the shared identity that keeps the
146
+ batch migration from double-counting, ``learning_features.is_synthetic``
147
+ for the flag that keeps these rows out of LightGBM training). A table
148
+ that exists without them cannot hold the event correctly, so treating
149
+ mere existence as readiness would write a row that silently breaks both
150
+ invariants.
151
+ """
152
+ try:
153
+ signal_cols = {
154
+ row[1] for row in conn.execute(
155
+ "PRAGMA table_info(learning_signals)",
156
+ )
157
+ }
158
+ feature_cols = {
159
+ row[1] for row in conn.execute(
160
+ "PRAGMA table_info(learning_features)",
161
+ )
162
+ }
163
+ except sqlite3.Error:
164
+ return False
165
+ return (
166
+ "query_id" in signal_cols
167
+ and "query_text_hash" in signal_cols
168
+ and "signal_id" in feature_cols
169
+ and "is_synthetic" in feature_cols
170
+ )
171
+
172
+
93
173
  def _hash_query(query: str) -> str:
94
174
  """Privacy-preserving SHA-256[:16] query hash."""
95
175
  return hashlib.sha256(query.encode("utf-8")).hexdigest()[:16]
@@ -109,7 +189,12 @@ class FeedbackCollector:
109
189
  def __init__(self, db_path: Path) -> None:
110
190
  self._db_path = Path(db_path)
111
191
  self._lock = threading.Lock()
192
+ # Latched once the canonical LLD-02 tables are confirmed present, so
193
+ # the sqlite_master probe runs at most once per collector instead of
194
+ # on every feedback write.
195
+ self._canonical_ready = False
112
196
  self._ensure_schema()
197
+ self._bootstrap_canonical_schema()
113
198
 
114
199
  # ------------------------------------------------------------------
115
200
  # Schema
@@ -226,16 +311,45 @@ class FeedbackCollector:
226
311
  query: str = "",
227
312
  channel: str = "unknown",
228
313
  ) -> Optional[int]:
314
+ """Record explicit user feedback on a specific fact.
315
+
316
+ Back-compatible wrapper: returns the ``learning_feedback`` row id.
317
+ Callers that must tell a user whether the feedback actually reached
318
+ the store the phase counters read should use
319
+ :meth:`record_explicit_event` and check ``FeedbackWrite.canonical`` —
320
+ a legacy row id on its own influences no consumer.
229
321
  """
230
- Record explicit user feedback on a specific fact.
322
+ return self.record_explicit_event(
323
+ profile_id=profile_id, fact_id=fact_id, signal_type=signal_type,
324
+ value=value, query=query, channel=channel,
325
+ ).feedback_row_id
231
326
 
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
327
+ def record_explicit_event(
328
+ self,
329
+ profile_id: str,
330
+ fact_id: str,
331
+ signal_type: str,
332
+ value: float,
333
+ query: str = "",
334
+ channel: str = "unknown",
335
+ ) -> FeedbackWrite:
336
+ """
337
+ Record explicit user feedback as ONE atomic learning event.
338
+
339
+ Writes three rows in a single transaction: the historic
340
+ ``learning_feedback`` row (kept one more release for ``pattern_miner``
341
+ channel mining and GDPR erasure) plus the canonical
342
+ ``learning_signals`` + ``learning_features`` pair that the dashboard,
343
+ the recall phase gate, and the retrainer all read. Either the whole
344
+ event is durable or none of it is — a partial write would leave the
345
+ legacy table and the phase counters permanently disagreeing, which is
346
+ the shape of issue #106.
347
+
348
+ Recall itself is deliberately read-only (it must never open a writer —
349
+ see
234
350
  ``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.
351
+ so explicit feedback is the only path that grows these tables outside
352
+ the signal worker.
239
353
 
240
354
  Args:
241
355
  profile_id: Profile providing feedback.
@@ -248,10 +362,10 @@ class FeedbackCollector:
248
362
  channel: Retrieval channel that surfaced the fact.
249
363
 
250
364
  Returns:
251
- Row ID of the inserted record, or None on error.
365
+ A :class:`FeedbackWrite` describing exactly which rows landed.
252
366
  """
253
367
  if not profile_id or not fact_id:
254
- return None
368
+ return FeedbackWrite(None, None, False)
255
369
 
256
370
  clamped = max(0.0, min(1.0, float(value)))
257
371
  now = _utcnow_iso()
@@ -268,10 +382,159 @@ class FeedbackCollector:
268
382
  (profile_id, fact_id, signal_type, clamped, query_hash,
269
383
  now, None, channel or "unknown"),
270
384
  )
385
+ feedback_row_id = cursor.lastrowid
386
+ signal_row_id = self._insert_canonical_pair(
387
+ conn,
388
+ profile_id=profile_id,
389
+ fact_id=fact_id,
390
+ value=clamped,
391
+ query_hash=query_hash,
392
+ created_at=now,
393
+ feedback_row_id=feedback_row_id,
394
+ )
271
395
  conn.commit()
272
- return cursor.lastrowid
396
+ return FeedbackWrite(
397
+ feedback_row_id, signal_row_id, signal_row_id is not None,
398
+ )
399
+ except sqlite3.Error:
400
+ conn.rollback()
401
+ raise
402
+ finally:
403
+ conn.close()
404
+
405
+ # ------------------------------------------------------------------
406
+ # Canonical store
407
+ # ------------------------------------------------------------------
408
+
409
+ def _insert_canonical_pair(
410
+ self,
411
+ conn: sqlite3.Connection,
412
+ *,
413
+ profile_id: str,
414
+ fact_id: str,
415
+ value: float,
416
+ query_hash: Optional[str],
417
+ created_at: str,
418
+ feedback_row_id: Optional[int],
419
+ ) -> Optional[int]:
420
+ """Insert the ``learning_signals`` + ``learning_features`` pair.
421
+
422
+ Runs inside the caller's open transaction so the canonical rows commit
423
+ with the legacy row or not at all. Returns the new signal row id, or
424
+ None when the canonical tables are absent — they are owned by the
425
+ migration runner (LLD-06 H15 forbids DDL here), so on a database that
426
+ predates them the caller is told the truth rather than handed a
427
+ fabricated success.
428
+ """
429
+ if feedback_row_id is None:
430
+ return None
431
+ if not self._canonical_tables_present(conn):
432
+ return None
433
+
434
+ from superlocalmemory.learning.legacy_migration import legacy_query_id
435
+
436
+ query_id = legacy_query_id(feedback_row_id)
437
+ # Pad to 32 hex chars so an eagerly-written row has the same shape as
438
+ # both a migrated row and a fresh signal-worker row.
439
+ padded_hash = ((query_hash or "") + ("0" * 32))[:32]
440
+
441
+ cursor = conn.execute(
442
+ "INSERT INTO learning_signals "
443
+ "(profile_id, query, fact_id, signal_type, value, created_at, "
444
+ " query_id, query_text_hash, position, channel_scores, "
445
+ " cross_encoder) "
446
+ "VALUES (?, '', ?, ?, ?, ?, ?, ?, 0, '{}', NULL)",
447
+ (profile_id, fact_id, CANONICAL_SIGNAL_TYPE, value, created_at,
448
+ query_id, padded_hash),
449
+ )
450
+ signal_row_id = cursor.lastrowid
451
+ conn.execute(
452
+ "INSERT INTO learning_features "
453
+ "(profile_id, query_id, fact_id, features_json, label, "
454
+ " created_at, signal_id, is_synthetic) "
455
+ "VALUES (?, ?, ?, ?, ?, ?, ?, 1)",
456
+ (profile_id, query_id, fact_id, _SYNTHETIC_FEATURES_JSON,
457
+ value, created_at, signal_row_id),
458
+ )
459
+ return signal_row_id
460
+
461
+ def _bootstrap_canonical_schema(self) -> None:
462
+ """Make sure the canonical store exists before any feedback arrives.
463
+
464
+ Explicit feedback can be the very first learning write on a machine —
465
+ a user can rate a recall before the daemon has ever run the migration
466
+ runner. Without this the write would honestly, but uselessly, report
467
+ that it never reached the store that gates ranking.
468
+
469
+ No DDL is authored here (LLD-06 H15). The base tables come from
470
+ ``LearningDatabase``, which the migration runner itself calls as its
471
+ first-boot bootstrap, and the LLD-02 columns come from M001's own DDL.
472
+ Applying M001's DDL without a ``migration_log`` row is safe: when the
473
+ real runner reaches M001 its ALTERs fail, ``M001.verify`` passes, and
474
+ the runner records it as "already applied (verified via schema
475
+ inspection)".
476
+
477
+ Never fatal — a read-only or unwritable learning.db must not stop a
478
+ collector from being constructed.
479
+ """
480
+ try:
481
+ from superlocalmemory.learning.database import LearningDatabase
482
+ from superlocalmemory.storage.migrations import (
483
+ M001_add_signal_features_columns as _m001,
484
+ )
485
+
486
+ LearningDatabase(self._db_path)
487
+ conn = self._connect()
488
+ try:
489
+ if not _m001.verify(conn):
490
+ conn.executescript(_m001.DDL)
491
+ conn.commit()
492
+ self._canonical_ready = _canonical_schema_ready(conn)
273
493
  finally:
274
494
  conn.close()
495
+ except Exception as exc: # noqa: BLE001 — construction must not fail
496
+ logger.warning(
497
+ "canonical learning schema bootstrap failed for %s: %s",
498
+ self._db_path, exc,
499
+ )
500
+
501
+ def _canonical_tables_present(self, conn: sqlite3.Connection) -> bool:
502
+ """Return True when both canonical LLD-02 tables exist.
503
+
504
+ Re-probes while unready so a collector constructed before the
505
+ migration runner ran starts writing canonically as soon as the tables
506
+ appear, instead of degrading for the life of the process.
507
+ """
508
+ if self._canonical_ready:
509
+ return True
510
+ self._canonical_ready = _canonical_schema_ready(conn)
511
+ if not self._canonical_ready:
512
+ logger.warning(
513
+ "learning.db at %s has no usable learning_signals/"
514
+ "learning_features schema; explicit feedback cannot reach the "
515
+ "store that gates adaptive ranking.",
516
+ self._db_path,
517
+ )
518
+ return self._canonical_ready
519
+
520
+ def get_signal_count(self, profile_id: str) -> int:
521
+ """Return the canonical signal count that gates the ranking phase.
522
+
523
+ This is the single number the recall phase gate, the dashboard's
524
+ Living Brain panel, and the ranker-phase card all resolve their phase
525
+ from. Reporting anything else to a user — as ``report_feedback`` did
526
+ with ``feedback_records`` before issue #106 — shows progress toward a
527
+ threshold nothing is actually measuring.
528
+ """
529
+ conn = self._connect()
530
+ try:
531
+ row = conn.execute(
532
+ "SELECT COUNT(*) FROM learning_signals WHERE profile_id = ?",
533
+ (profile_id,),
534
+ ).fetchone()
535
+ return row[0] if row else 0
536
+ finally:
537
+ conn.close()
275
538
 
276
539
  # ------------------------------------------------------------------
277
540
  # Public API: record dashboard feedback
@@ -295,30 +558,26 @@ class FeedbackCollector:
295
558
  This method restores the dashboard feedback path: the HTTP routes in
296
559
  ``server/routes/learning.py`` called it before it existed, so every
297
560
  thumbs/pin/dwell write raised ``AttributeError`` (issues #53/#59).
561
+
562
+ Routed through :meth:`record_explicit_event` so a thumbs-up from the
563
+ dashboard lands in exactly the same canonical store as a thumbs-up
564
+ from MCP. Before issue #106 this path wrote only ``learning_feedback``,
565
+ so the dashboard's own Living Brain counter — which reads
566
+ ``learning_signals`` — never moved in response to its own buttons.
298
567
  """
299
568
  if not memory_id:
300
569
  return None
301
570
  signal_type, value = _DASHBOARD_SIGNAL_MAP.get(
302
571
  feedback_type, ("user_correction", 0.5),
303
572
  )
304
- qhash = _hash_query(query) if query else None
305
- now = _utcnow_iso()
306
-
307
- with self._lock:
308
- conn = self._connect()
309
- try:
310
- cursor = conn.execute(
311
- "INSERT INTO learning_feedback "
312
- "(profile_id, fact_id, signal_type, signal_value, "
313
- "query_hash, created_at, metadata) "
314
- "VALUES (?, ?, ?, ?, ?, ?, ?)",
315
- (profile_id or "default", str(memory_id), signal_type,
316
- value, qhash, now, None),
317
- )
318
- conn.commit()
319
- return cursor.lastrowid
320
- finally:
321
- conn.close()
573
+ return self.record_explicit_event(
574
+ profile_id=profile_id or "default",
575
+ fact_id=str(memory_id),
576
+ signal_type=signal_type,
577
+ value=value,
578
+ query=query,
579
+ channel="dashboard",
580
+ ).feedback_row_id
322
581
 
323
582
  # ------------------------------------------------------------------
324
583
  # Public API: read feedback
@@ -41,6 +41,20 @@ MIGRATION_NAME = "LEG001_feedback_to_signals"
41
41
  _COPY_BATCH_SIZE = 500
42
42
 
43
43
 
44
+ def legacy_query_id(feedback_row_id: int | str) -> str:
45
+ """Return the canonical ``learning_signals.query_id`` for a feedback row.
46
+
47
+ One explicit-feedback event has exactly ONE canonical identity, whichever
48
+ path writes it: this batch migration, or ``FeedbackCollector`` writing the
49
+ canonical pair eagerly at feedback time. Both derive the id from the
50
+ ``learning_feedback`` row id through this function, which is what lets the
51
+ migration recognise — and skip — rows already carried forward. Without a
52
+ shared identity the two writers would double-count the same event into the
53
+ store that gates the ranking phase.
54
+ """
55
+ return f"legacy:{feedback_row_id}"
56
+
57
+
44
58
  def migrate_legacy_feedback(
45
59
  learning_db: Path,
46
60
  *,
@@ -156,10 +170,18 @@ def _copy_rows(conn: sqlite3.Connection) -> tuple[int, int]:
156
170
 
157
171
  Returns ``(copied, failed)``. Does not raise. Commits per batch so
158
172
  a later failure still leaves the earlier batches durable.
173
+
174
+ Rows whose canonical ``query_id`` is already present in
175
+ ``learning_signals`` are skipped rather than copied a second time.
176
+ ``FeedbackCollector`` writes the canonical pair at feedback time, so on
177
+ any install where this migration has not yet been sentinel-marked the
178
+ newest rows are already carried forward; copying them again would
179
+ inflate the very counter that gates the ranking phase.
159
180
  """
160
181
  copied = 0
161
182
  failed = 0
162
183
  offset = 0
184
+ already_present = _existing_legacy_query_ids(conn)
163
185
  while True:
164
186
  try:
165
187
  batch = conn.execute(
@@ -179,6 +201,8 @@ def _copy_rows(conn: sqlite3.Connection) -> tuple[int, int]:
179
201
  try:
180
202
  conn.execute("BEGIN IMMEDIATE")
181
203
  for row in batch:
204
+ if legacy_query_id(row["id"]) in already_present:
205
+ continue
182
206
  try:
183
207
  _copy_single_row(conn, row)
184
208
  copied += 1
@@ -199,6 +223,23 @@ def _copy_rows(conn: sqlite3.Connection) -> tuple[int, int]:
199
223
  return copied, failed
200
224
 
201
225
 
226
+ def _existing_legacy_query_ids(conn: sqlite3.Connection) -> set[str]:
227
+ """Return every ``legacy:`` query_id already present in learning_signals.
228
+
229
+ Read once up front: a per-row EXISTS probe over a signals table that grows
230
+ to tens of thousands of rows turns an O(n) copy into O(n*m).
231
+ """
232
+ try:
233
+ rows = conn.execute(
234
+ "SELECT DISTINCT query_id FROM learning_signals "
235
+ "WHERE query_id LIKE 'legacy:%'",
236
+ ).fetchall()
237
+ except sqlite3.Error as exc:
238
+ logger.warning("legacy migration: dedupe probe failed: %s", exc)
239
+ return set()
240
+ return {str(row[0]) for row in rows}
241
+
242
+
202
243
  def _copy_single_row(conn: sqlite3.Connection, row: sqlite3.Row) -> None:
203
244
  """Insert one legacy row into learning_signals + learning_features.
204
245
 
@@ -218,7 +259,7 @@ def _copy_single_row(conn: sqlite3.Connection, row: sqlite3.Row) -> None:
218
259
  datetime.now(timezone.utc).isoformat(timespec="seconds"))
219
260
  profile_id = str(row["profile_id"] or "default")
220
261
  fact_id = str(row["fact_id"] or "")
221
- legacy_query_id = f"legacy:{row['id']}"
262
+ query_id = legacy_query_id(row["id"])
222
263
 
223
264
  # Insert the signal row. ``signal_type='legacy_feedback'`` marks it
224
265
  # clearly so consumers (dashboard, labeler) can treat it correctly.
@@ -229,7 +270,7 @@ def _copy_single_row(conn: sqlite3.Connection, row: sqlite3.Row) -> None:
229
270
  "VALUES (?, '', ?, 'legacy_feedback', ?, ?, ?, ?, 0, '{}', NULL)",
230
271
  (profile_id, fact_id,
231
272
  float(row["signal_value"] or 1.0),
232
- created_at, legacy_query_id, query_hash),
273
+ created_at, query_id, query_hash),
233
274
  )
234
275
  sid = cur.lastrowid
235
276
 
@@ -241,7 +282,7 @@ def _copy_single_row(conn: sqlite3.Connection, row: sqlite3.Row) -> None:
241
282
  "(profile_id, query_id, fact_id, features_json, label, created_at, "
242
283
  " signal_id, is_synthetic) "
243
284
  "VALUES (?, ?, ?, '{}', 0.0, ?, ?, 1)",
244
- (profile_id, legacy_query_id, fact_id, created_at, sid),
285
+ (profile_id, query_id, fact_id, created_at, sid),
245
286
  )
246
287
 
247
288
 
@@ -274,4 +315,4 @@ def _record_migration(
274
315
  logger.warning("legacy migration: log record failed: %s", exc)
275
316
 
276
317
 
277
- __all__ = ("migrate_legacy_feedback", "MIGRATION_NAME")
318
+ __all__ = ("migrate_legacy_feedback", "MIGRATION_NAME", "legacy_query_id")
@@ -23,6 +23,28 @@ from typing import Any
23
23
 
24
24
  logger = logging.getLogger(__name__)
25
25
 
26
+ _OPAQUE_UNAVAILABLE = "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later."
27
+
28
+
29
+ def daemon_unavailable_error() -> str:
30
+ """Return a one-line, *diagnosed* daemon-unavailable message.
31
+
32
+ The opaque wording this replaces described a stopped daemon, a recycled
33
+ PID, an unreachable port and an identity mismatch identically (issue #104).
34
+ Diagnosis is best effort: if it fails for any reason the caller still gets
35
+ the original, retryable message rather than an exception.
36
+ """
37
+ try:
38
+ from superlocalmemory.cli.daemon import describe_daemon_unavailability
39
+
40
+ diagnosis = describe_daemon_unavailability()
41
+ return (
42
+ f"DAEMON_UNAVAILABLE ({diagnosis['reason']}): "
43
+ f"{diagnosis['message']} {diagnosis['hint']}"
44
+ )
45
+ except Exception: # noqa: BLE001 - diagnosis must never mask the failure
46
+ return _OPAQUE_UNAVAILABLE
47
+
26
48
 
27
49
  class DaemonPoolProxy:
28
50
  """:class:`WorkerPool`-shaped facade that talks to the daemon over HTTP.
@@ -52,7 +74,7 @@ class DaemonPoolProxy:
52
74
  "ok": False,
53
75
  "code": "DAEMON_UNAVAILABLE",
54
76
  "retryable": True,
55
- "error": "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later.",
77
+ "error": daemon_unavailable_error(),
56
78
  }
57
79
 
58
80
  def recall(