superlocalmemory 3.8.10 → 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 (42) hide show
  1. package/CHANGELOG.md +36 -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/core/reranker_worker.py +59 -17
  35. package/src/superlocalmemory/learning/feedback.py +46 -4
  36. package/src/superlocalmemory/learning/pattern_miner.py +31 -11
  37. package/src/superlocalmemory/mcp/tools_active.py +115 -4
  38. package/src/superlocalmemory/retrieval/reranker.py +52 -5
  39. package/src/superlocalmemory/server/unified_daemon.py +4 -0
  40. package/src/superlocalmemory/storage/migration_runner.py +9 -0
  41. package/src/superlocalmemory/storage/migrations/M033_learning_feedback_channel.py +77 -0
  42. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ All notable changes to SuperLocalMemory V3 will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.8.11] - 2026-08-02 — Learning-signal integrity and honest reranker diagnostics
9
+
10
+ ### Fixed
11
+ - Explicit feedback reported through `report_feedback` now writes to the
12
+ canonical learning store (`learning.db`), which every learning consumer
13
+ reads: the adaptive-ranking phase gate, `pattern_miner`, and the dashboard
14
+ Living Brain. Previously it wrote only to a table nothing else read, so
15
+ feedback returned success and a rising counter while the ranker never
16
+ advanced past Phase 1 (#102).
17
+ - `learning_feedback` now has the `channel` column `pattern_miner` has always
18
+ queried but no schema ever defined. Every fresh database raised
19
+ `no such column: channel` on the first mining pass — caught, logged at
20
+ debug, and silently disabled both channel-performance mining and the
21
+ co-retrieval mining that shared its error handler. Migration `M033`
22
+ backfills existing databases without touching existing rows (#102).
23
+ - The cross-encoder reranker now reports the real reason a model load
24
+ failed instead of a generic timeout message, and no longer retries a
25
+ configuration error five times (~7.5 minutes) before giving up. An
26
+ unrecognized `cross_encoder_backend` value is now rejected by name;
27
+ SuperLocalMemory has no remote/OpenAI-compatible reranker backend, so a
28
+ `cross_encoder_endpoint` config key was previously accepted and silently
29
+ ignored (#103).
30
+ - `slm recall` no longer crashes when a daemon response's
31
+ `retrieval_time_ms` or a result's `score` is present but `null` — the
32
+ keyword-fallback recall path now includes `retrieval_time_ms` in every
33
+ response, matching every other recall path's contract.
34
+ - The MagicMock artifact guard (`.gitignore` and its CI test) now also
35
+ catches the directory-shaped leak (`MagicMock/mock/<id>/`) produced when
36
+ a mock-derived path reaches `mkdir()`, not just the file-shaped leak
37
+ (`<MagicMock id='...'>`) produced by `os.open()`.
38
+
39
+ ### Documentation
40
+ - `docs/auto-memory.md` no longer references `slm patterns` / `slm useful`,
41
+ which do not exist in V3; documents the `report_feedback` MCP tool as the
42
+ supported path instead.
43
+
8
44
  ## [3.8.10] - 2026-07-29 — Reliable startup and MCP writes
9
45
 
10
46
  ### Fixed
package/README.md CHANGED
@@ -5,15 +5,15 @@
5
5
  </picture>
6
6
  </p>
7
7
 
8
- <h1 align="center">SuperLocalMemory V3.8.10</h1>
8
+ <h1 align="center">SuperLocalMemory V3.8.11</h1>
9
9
  <p align="center"><strong>Enterprise-grade, local-first memory for AI agents and teams.</strong><br/>
10
10
  <em>A persistent, auditable long-term brain for your agents that runs on your own infrastructure — with multi-workspace isolation, role-based access, and GDPR + EU AI Act governance controls built in.</em></p>
11
- <p align="center"><code>v3.8.10</code> — one control plane: auditable retrieval · multi-scope memory (personal / shared / global) · Cache · Compress · trusted-peer Mesh · bounded loops — across CLI, MCP, dashboard, the <strong>Claude plugin</strong>, the <strong>Codex add-on</strong>, and documented IDE integrations.<br/>
11
+ <p align="center"><code>v3.8.11</code> — one control plane: auditable retrieval · multi-scope memory (personal / shared / global) · Cache · Compress · trusted-peer Mesh · bounded loops — across CLI, MCP, dashboard, the <strong>Claude plugin</strong>, the <strong>Codex add-on</strong>, and documented IDE integrations.<br/>
12
12
  Proxy: <code>slm wrap claude</code> &nbsp;·&nbsp; MCP: add <code>slm_compress</code> to your config &nbsp;·&nbsp; Skill: zero-config</p>
13
13
  <p align="center"><strong>3 public research preprints</strong> (arXiv + Zenodo archives) · <a href="https://arxiv.org/abs/2603.02240">arXiv:2603.02240</a> · <a href="https://arxiv.org/abs/2603.14588">arXiv:2603.14588</a> · <a href="https://arxiv.org/abs/2604.04514">arXiv:2604.04514</a></p>
14
14
 
15
15
  <p align="center">
16
- <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/v3.8.10-Current_Release-2ea44f?style=for-the-badge&logo=checkmarx&logoColor=white" alt="v3.8.10 — Current Release"/></a>
16
+ <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/v3.8.11-Current_Release-2ea44f?style=for-the-badge&logo=checkmarx&logoColor=white" alt="v3.8.11 — Current Release"/></a>
17
17
  <a href="https://arxiv.org/abs/2603.14588"><img src="https://img.shields.io/badge/arXiv-2603.14588-b31b1b?style=for-the-badge&logo=arxiv&logoColor=white" alt="arXiv Paper"/></a>
18
18
  <a href="#three-surfaces-proxy--mcp-tools--skill"><img src="https://img.shields.io/badge/Proxy_|_MCP_|_Skill-22c55e?style=for-the-badge" alt="Three Surfaces: Proxy, MCP Tools, Skill"/></a>
19
19
  <a href="https://pypi.org/project/superlocalmemory/"><img src="https://img.shields.io/pypi/v/superlocalmemory?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"/></a>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.8.10",
3
+ "version": "3.8.11",
4
4
  "description": "Local-first agent memory with MCP and an agent-native CLI. Documented clients include Claude Code, Cursor, and Windsurf.",
5
5
  "keywords": [
6
6
  "ai-memory",
@@ -15,5 +15,5 @@
15
15
  "mcpServers": "./.mcp.json",
16
16
  "name": "superlocalmemory",
17
17
  "repository": "https://github.com/qualixar/superlocalmemory",
18
- "version": "3.8.10"
18
+ "version": "3.8.11"
19
19
  }
package/plugin/CLAUDE.md CHANGED
@@ -1,4 +1,4 @@
1
- <!-- BEGIN SuperLocalMemory v3.8.10 -->
1
+ <!-- BEGIN SuperLocalMemory v3.8.11 -->
2
2
 
3
3
  ## SuperLocalMemory (SLM) — Agent Rules
4
4
 
@@ -39,6 +39,6 @@ slm-recall · slm-remember · slm-session · slm-status · slm-cache · slm-comp
39
39
  ### Subagents
40
40
  slm-memory-advisor (memory decisions, session hygiene, scope/profile guidance) · slm-optimize-advisor (context compression + KV cache) · slm-governance-advisor (scope/roles/compliance/GDPR)
41
41
 
42
- <!-- END SuperLocalMemory v3.8.10 -->
42
+ <!-- END SuperLocalMemory v3.8.11 -->
43
43
 
44
- SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later
44
+ SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
@@ -77,4 +77,4 @@ slm-scope · slm-governance · slm-profile · slm-remember · slm-recall
77
77
  # What NOT to do
78
78
  Never session_init twice; never forget without dry-run preview; never store secrets; never bypass role checks; never claim an erasure succeeded without verifying via recall.
79
79
 
80
- SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later
80
+ SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
@@ -68,4 +68,4 @@ assessment. The gate is the authority.
68
68
 
69
69
  ---
70
70
 
71
- SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later
71
+ SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
@@ -46,4 +46,4 @@ slm-recall · slm-remember · slm-session · slm-scope · slm-profile · slm-gov
46
46
  # What NOT to do
47
47
  Never session_init twice; never forget dry_run=False without reporting preview; never dump a whole file into remember; never invent a memory; never claim "saved" without success:true / clean CLI exit; never bypass scope or governance restrictions.
48
48
 
49
- SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later
49
+ SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
@@ -41,4 +41,4 @@ slm-compress · slm-cache · slm-status · slm-profile
41
41
  # What NOT to do
42
42
  Never compress code-for-edit/JSON-to-parse/<500 chars; never store secrets/ccr_ids; never let optimize failure block/alter the task; never claim a specific savings %; never carry ccr_ids across profile switches.
43
43
 
44
- SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later
44
+ SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
@@ -1 +1 @@
1
- superlocalmemory==3.8.10
1
+ superlocalmemory==3.8.11
@@ -145,4 +145,4 @@ These subcommands control daemon-level cache settings. They do not read or write
145
145
 
146
146
  ---
147
147
 
148
- SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later
148
+ SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
@@ -147,4 +147,4 @@ Content over 1 MB (1 000 000 bytes UTF-8) is processed but `reversible` is force
147
147
 
148
148
  ---
149
149
 
150
- SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later
150
+ SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
@@ -245,4 +245,4 @@ Before running any destructive operation (`forget`, `compact_memories`):
245
245
 
246
246
  ---
247
247
 
248
- *SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later*
248
+ *SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later*
@@ -311,4 +311,4 @@ profile. See `slm-profile` for the full profile switching workflow.
311
311
 
312
312
  ---
313
313
 
314
- SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later
314
+ SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
@@ -96,4 +96,4 @@ paused, name the approval needed; when errored, quote the short detail.
96
96
 
97
97
  ---
98
98
 
99
- SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later
99
+ SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
@@ -279,4 +279,4 @@ mesh availability.
279
279
 
280
280
  ---
281
281
 
282
- *SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later*
282
+ *SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later*
@@ -145,4 +145,4 @@ Name them differently in your MCP config (e.g. `superlocalmemory-personal` and
145
145
 
146
146
  ---
147
147
 
148
- *SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later*
148
+ *SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later*
@@ -236,4 +236,4 @@ before recalling, then switch back. See `slm-profile` for workspace switching.
236
236
 
237
237
  ---
238
238
 
239
- *SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later*
239
+ *SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later*
@@ -238,4 +238,4 @@ different workspace, use `switch_profile` first. See `slm-profile`.
238
238
 
239
239
  ---
240
240
 
241
- *SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later*
241
+ *SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later*
@@ -173,4 +173,4 @@ to review the impact. See `slm-remember` for the full deletion discipline.
173
173
 
174
174
  ---
175
175
 
176
- *SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later*
176
+ *SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later*
@@ -227,4 +227,4 @@ explicitly and call `recall` with `include_global`/`include_shared` after
227
227
 
228
228
  ---
229
229
 
230
- *SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later*
230
+ *SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later*
@@ -163,4 +163,4 @@ multi-profile setup. To switch the active profile, see `slm-profile`.
163
163
 
164
164
  ---
165
165
 
166
- SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later
166
+ SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
@@ -128,4 +128,4 @@ When the SLM MCP server is unavailable, use these CLI equivalents:
128
128
  - **slm-optimize-advisor** — context compression and KV cache
129
129
  - **slm-governance-advisor** — scope/role compliance, retention policies, GDPR
130
130
 
131
- SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later
131
+ SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
@@ -145,4 +145,4 @@ These subcommands control daemon-level cache settings. They do not read or write
145
145
 
146
146
  ---
147
147
 
148
- SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later
148
+ SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
@@ -147,4 +147,4 @@ Content over 1 MB (1 000 000 bytes UTF-8) is processed but `reversible` is force
147
147
 
148
148
  ---
149
149
 
150
- SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later
150
+ SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
@@ -311,4 +311,4 @@ profile. See `slm-profile` for the full profile switching workflow.
311
311
 
312
312
  ---
313
313
 
314
- SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later
314
+ SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
@@ -236,4 +236,4 @@ before recalling, then switch back. See `slm-profile` for workspace switching.
236
236
 
237
237
  ---
238
238
 
239
- *SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later*
239
+ *SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later*
@@ -238,4 +238,4 @@ different workspace, use `switch_profile` first. See `slm-profile`.
238
238
 
239
239
  ---
240
240
 
241
- *SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later*
241
+ *SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later*
@@ -227,4 +227,4 @@ explicitly and call `recall` with `include_global`/`include_shared` after
227
227
 
228
228
  ---
229
229
 
230
- *SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later*
230
+ *SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later*
@@ -163,4 +163,4 @@ multi-profile setup. To switch the active profile, see `slm-profile`.
163
163
 
164
164
  ---
165
165
 
166
- SuperLocalMemory v3.8.10 · Qualixar · AGPL-3.0-or-later
166
+ SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "3.8.10"
3
+ version = "3.8.11"
4
4
  description = "Local-first agent memory with auditable hybrid retrieval"
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-or-later"
@@ -32,7 +32,7 @@ if "OMP_NUM_THREADS" not in os.environ:
32
32
  os.environ["OMP_NUM_THREADS"] = "2"
33
33
  # ---------------------------------------------------------------------------
34
34
 
35
- __version__ = "3.8.10"
35
+ __version__ = "3.8.11"
36
36
 
37
37
  _REQUIRED_VERSIONS = {
38
38
  "sentence_transformers": "5.3.0",
@@ -1459,10 +1459,20 @@ def cmd_recall(args: Namespace) -> None:
1459
1459
  if result.get("no_confident_match")
1460
1460
  else "No matching memories found.")
1461
1461
  return
1462
- # Text output
1463
- print(f"SpreadingActivation.search completed via daemon ({result.get('retrieval_time_ms', 0):.0f}ms)")
1462
+ # Text output.
1463
+ # PR #101: ``dict.get(k, 0)`` returns the DEFAULT only when the
1464
+ # key is ABSENT — a present-but-null value still reaches the
1465
+ # format spec and raises "unsupported format string passed to
1466
+ # NoneType.__format__". ``or 0`` covers both. Same for score,
1467
+ # which the keyword-fallback path returns as None.
1468
+ elapsed_ms = result.get('retrieval_time_ms') or 0
1469
+ print(
1470
+ "SpreadingActivation.search completed via daemon "
1471
+ f"({elapsed_ms:.0f}ms)"
1472
+ )
1464
1473
  for i, r in enumerate(result["results"], 1):
1465
- print(f" {i}. [{r['score']:.2f}] {r['content']}")
1474
+ score = r.get('score') or 0
1475
+ print(f" {i}. [{score:.2f}] {r['content']}")
1466
1476
  return
1467
1477
  except Exception as _exc: # noqa: BLE001
1468
1478
  logger.warning(
@@ -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:
@@ -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)}
@@ -84,6 +84,21 @@ _WARMUP_MAX_ATTEMPTS = int(os.environ.get("SLM_RERANKER_WARMUP_ATTEMPTS", "5"))
84
84
  _WARMUP_RETRY_BACKOFF_S = float(os.environ.get("SLM_RERANKER_WARMUP_BACKOFF", "3"))
85
85
 
86
86
 
87
+ # Substrings that mark a load failure as a configuration problem rather than a
88
+ # transient one. Retrying these can never succeed, so the warmup aborts on the
89
+ # first occurrence instead of spending _WARMUP_MAX_ATTEMPTS × backoff on them.
90
+ _PERMANENT_LOAD_ERROR_MARKERS = (
91
+ "unknown backend",
92
+ "sentence-transformers is not installed",
93
+ )
94
+
95
+
96
+ def _is_permanent_load_error(error: str) -> bool:
97
+ """True when a worker load error cannot be fixed by retrying."""
98
+ lowered = (error or "").lower()
99
+ return any(m in lowered for m in _PERMANENT_LOAD_ERROR_MARKERS)
100
+
101
+
87
102
  class CrossEncoderReranker:
88
103
  """Rerank candidate facts using a local cross-encoder model.
89
104
 
@@ -201,11 +216,43 @@ class CrossEncoderReranker:
201
216
  resp.get("warmup_inference", False),
202
217
  )
203
218
  return
204
- logger.warning(
205
- "Reranker warmup attempt %d/%d did not confirm "
206
- "ready (timeout=%ds); retrying",
207
- attempt, _WARMUP_MAX_ATTEMPTS, _WARMUP_LOAD_TIMEOUT,
208
- )
219
+ # v3.8.11 (issue #103): this used to report
220
+ # "(timeout=90s)" for EVERY failure, including loads
221
+ # that failed instantly. A user whose retries were 3s
222
+ # apart was told each one timed out after 90s, and the
223
+ # worker's actual error was never printed at all.
224
+ # Distinguish the two cases and surface the real cause.
225
+ if resp is None:
226
+ logger.warning(
227
+ "Reranker warmup attempt %d/%d: no response "
228
+ "from worker within %ds; retrying",
229
+ attempt, _WARMUP_MAX_ATTEMPTS,
230
+ _WARMUP_LOAD_TIMEOUT,
231
+ )
232
+ else:
233
+ load_error = (
234
+ resp.get("error")
235
+ or "worker reported not-ready without an error"
236
+ )
237
+ # A misconfiguration cannot fix itself. Retrying a
238
+ # bad backend name or a missing dependency four
239
+ # more times burns ~7.5 minutes of daemon startup
240
+ # to reach the same answer (issue #103). Fail fast
241
+ # and say exactly what to change.
242
+ if _is_permanent_load_error(load_error):
243
+ logger.error(
244
+ "Reranker disabled — configuration error: "
245
+ "%s. Not retrying. Fix the config or set "
246
+ "retrieval.use_cross_encoder=false; recall "
247
+ "continues with fusion scores.",
248
+ load_error,
249
+ )
250
+ return
251
+ logger.warning(
252
+ "Reranker warmup attempt %d/%d failed: %s; "
253
+ "retrying",
254
+ attempt, _WARMUP_MAX_ATTEMPTS, load_error,
255
+ )
209
256
 
210
257
  if attempt < _WARMUP_MAX_ATTEMPTS and not self._model_loaded:
211
258
  if self._shutdown_event.wait(
@@ -534,6 +534,10 @@ def _recall_keyword_fallback(engine, query: str, limit: int) -> dict:
534
534
  "results": results,
535
535
  "count": len(results),
536
536
  "no_confident_match": True,
537
+ # PR #101: every other recall path returns this key, so clients format
538
+ # it unconditionally. Omitting it here made the degraded path — the one
539
+ # that fires when recall is ALREADY struggling — crash the CLI.
540
+ "retrieval_time_ms": 0,
537
541
  }
538
542
 
539
543
  # v3.4.52: Embedding model warm state. Set to True by the async pre-warm
@@ -128,6 +128,9 @@ from superlocalmemory.storage.migrations import (
128
128
  from superlocalmemory.storage.migrations import (
129
129
  M032_write_coordinator_admission as _M032,
130
130
  )
131
+ from superlocalmemory.storage.migrations import (
132
+ M033_learning_feedback_channel as _M033,
133
+ )
131
134
 
132
135
  # Map migration name → module (used for the optional ``verify(conn)`` hook
133
136
  # that lets the runner detect "already applied" state when an idempotent
@@ -164,6 +167,7 @@ _MODULES = {
164
167
  _M030.NAME: _M030,
165
168
  _M031.NAME: _M031,
166
169
  _M032.NAME: _M032,
170
+ _M033.NAME: _M033,
167
171
  }
168
172
 
169
173
  logger = logging.getLogger(__name__)
@@ -220,6 +224,11 @@ MIGRATIONS: list[Migration] = [
220
224
  # observations for ShadowTest persistence across daemon restart.
221
225
  Migration(name=_M012.NAME, db_target="learning", ddl=_M012.DDL,
222
226
  dependencies=(_M003.NAME,)),
227
+ # M033 adds learning_feedback.channel, which pattern_miner has always
228
+ # queried but which no schema ever defined. Its DDL creates the table
229
+ # when absent, so it needs no dependency beyond the migration log.
230
+ Migration(name=_M033.NAME, db_target="learning", ddl=_M033.DDL,
231
+ dependencies=(_M003.NAME,)),
223
232
  Migration(name=_M004.NAME, db_target="memory", ddl=_M004.DDL),
224
233
  # M007 creates pending_outcomes (memory.db, LLD-00 §1.2).
225
234
  Migration(name=_M007.NAME, db_target="memory", ddl=_M007.DDL),
@@ -0,0 +1,77 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory v3.8.11
4
+
5
+ """M033 — add the ``channel`` column to ``learning_feedback``.
6
+
7
+ ``pattern_miner._mine_channel_and_coretrieval`` has always executed::
8
+
9
+ SELECT channel, COUNT(*) AS cnt, AVG(signal_value) AS avg_signal
10
+ FROM learning_feedback GROUP BY channel
11
+
12
+ but ``channel`` was never defined on the table. Every database therefore
13
+ raised ``sqlite3.OperationalError: no such column: channel``. That error was
14
+ caught by the miner's outer ``except Exception`` and logged at DEBUG, so the
15
+ failure was invisible — and because the channel query runs FIRST inside that
16
+ try block, it also aborted the co-retrieval mining below it. One missing
17
+ column silently disabled two pattern types (issue #102, "Patterns learned
18
+ remains 0" despite a restored backup: restoring rows cannot fix a schema gap).
19
+
20
+ Additive only — ``ALTER TABLE ADD COLUMN`` with a default. No data loss and no
21
+ type changes; existing rows get ``'unknown'``, which groups cleanly rather than
22
+ being dropped by the miner's ``GROUP BY``.
23
+
24
+ ``learning_feedback`` is bootstrapped at runtime by
25
+ ``learning.feedback.FeedbackCollector._ensure_schema`` rather than by a
26
+ migration, so the DDL below CREATEs it (without ``channel``) when absent
27
+ before the ALTER. That keeps all three states correct:
28
+
29
+ - table missing -> CREATE, then ALTER adds ``channel``
30
+ - table present, no column -> CREATE is a no-op, ALTER adds ``channel``
31
+ - table already migrated -> ``verify()`` returns True, runner skips entirely
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import sqlite3
37
+
38
+ NAME = "M033_learning_feedback_channel"
39
+ DB_TARGET = "learning"
40
+
41
+
42
+ def verify(conn: sqlite3.Connection) -> bool:
43
+ """Return True if ``learning_feedback.channel`` already exists."""
44
+ try:
45
+ cols = {
46
+ row[1] for row in
47
+ conn.execute("PRAGMA table_info(learning_feedback)").fetchall()
48
+ }
49
+ except sqlite3.Error:
50
+ return False
51
+ # An empty set means the table does not exist yet — not migrated.
52
+ return bool(cols) and "channel" in cols
53
+
54
+
55
+ DDL = """
56
+ BEGIN IMMEDIATE;
57
+
58
+ CREATE TABLE IF NOT EXISTS learning_feedback (
59
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
60
+ profile_id TEXT NOT NULL,
61
+ fact_id TEXT NOT NULL,
62
+ signal_type TEXT NOT NULL,
63
+ signal_value REAL NOT NULL,
64
+ query_hash TEXT,
65
+ created_at TEXT NOT NULL,
66
+ metadata TEXT
67
+ );
68
+
69
+ ALTER TABLE learning_feedback ADD COLUMN channel TEXT DEFAULT 'unknown';
70
+
71
+ CREATE INDEX IF NOT EXISTS idx_feedback_profile
72
+ ON learning_feedback (profile_id, created_at DESC);
73
+ CREATE INDEX IF NOT EXISTS idx_feedback_channel
74
+ ON learning_feedback (profile_id, channel);
75
+
76
+ COMMIT;
77
+ """
@@ -28,6 +28,7 @@ from . import (
28
28
  M020_model_state_integrity,
29
29
  M029_behavioral_history_indexes,
30
30
  M030_entity_explorer_indexes,
31
+ M033_learning_feedback_channel,
31
32
  )
32
33
 
33
34
  # ---------------------------------------------------------------------------
@@ -81,6 +82,7 @@ __all__ = (
81
82
  "M020_model_state_integrity",
82
83
  "M029_behavioral_history_indexes",
83
84
  "M030_entity_explorer_indexes",
85
+ "M033_learning_feedback_channel",
84
86
  # Legacy re-exports (backward compat):
85
87
  "CURRENT_SCHEMA_VERSION",
86
88
  "get_schema_version",