superlocalmemory 4.0.10 → 4.1.2

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 (145) hide show
  1. package/.claude-plugin/marketplace.json +12 -2
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +40 -75
  4. package/package.json +6 -3
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/CLAUDE.md +3 -3
  7. package/plugin/agents/slm-governance-advisor.md +1 -1
  8. package/plugin/agents/slm-loop-runner.md +4 -4
  9. package/plugin/agents/slm-memory-advisor.md +1 -1
  10. package/plugin/agents/slm-optimize-advisor.md +1 -1
  11. package/plugin/requirements.txt +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +2 -2
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +5 -5
  19. package/plugin/skills/slm-recall/SKILL.md +102 -15
  20. package/plugin/skills/slm-remember/SKILL.md +35 -3
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +29 -3
  23. package/plugin/skills/slm-status/SKILL.md +1 -1
  24. package/plugin-src/rules/AGENTS.md +16 -8
  25. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-loop/SKILL.md +2 -2
  30. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-profile/SKILL.md +5 -5
  32. package/plugin-src/skills/slm-recall/SKILL.md +102 -15
  33. package/plugin-src/skills/slm-remember/SKILL.md +35 -3
  34. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +29 -3
  36. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  37. package/pyproject.toml +1 -1
  38. package/src/superlocalmemory/__init__.py +1 -1
  39. package/src/superlocalmemory/cli/commands.py +357 -18
  40. package/src/superlocalmemory/cli/daemon.py +30 -0
  41. package/src/superlocalmemory/cli/db_migrate.py +71 -1
  42. package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
  43. package/src/superlocalmemory/cli/main.py +24 -2
  44. package/src/superlocalmemory/code_graph/database.py +44 -0
  45. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  46. package/src/superlocalmemory/core/admission.py +231 -11
  47. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  48. package/src/superlocalmemory/core/config.py +90 -11
  49. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  50. package/src/superlocalmemory/core/engine.py +140 -11
  51. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  52. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  53. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  54. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  55. package/src/superlocalmemory/core/mode_capability.py +111 -0
  56. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  57. package/src/superlocalmemory/core/projection_drain.py +380 -0
  58. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  59. package/src/superlocalmemory/core/recall_worker.py +6 -3
  60. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  61. package/src/superlocalmemory/core/scale_engine.py +16 -2
  62. package/src/superlocalmemory/core/score_contract.py +21 -1
  63. package/src/superlocalmemory/core/session_identity.py +85 -0
  64. package/src/superlocalmemory/core/status_contract.py +108 -0
  65. package/src/superlocalmemory/core/worker_pool.py +4 -4
  66. package/src/superlocalmemory/core/working_memory.py +288 -0
  67. package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
  68. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  69. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  70. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  71. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  72. package/src/superlocalmemory/encoding/type_router.py +12 -12
  73. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  74. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  75. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  76. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  77. package/src/superlocalmemory/learning/bandit.py +43 -0
  78. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  79. package/src/superlocalmemory/learning/database.py +60 -3
  80. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  81. package/src/superlocalmemory/learning/feedback.py +3 -1
  82. package/src/superlocalmemory/learning/outcomes.py +47 -16
  83. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  84. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  85. package/src/superlocalmemory/learning/pcos.py +291 -0
  86. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  87. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  88. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  89. package/src/superlocalmemory/mcp/profiles.py +14 -2
  90. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  91. package/src/superlocalmemory/mcp/tools_core.py +31 -3
  92. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  93. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  94. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  95. package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
  96. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  97. package/src/superlocalmemory/retrieval/engine.py +106 -11
  98. package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
  99. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  100. package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
  101. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  102. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  103. package/src/superlocalmemory/server/api.py +24 -5
  104. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  105. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  106. package/src/superlocalmemory/server/recall_health.py +87 -10
  107. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  108. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  109. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  110. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  111. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  112. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  113. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  114. package/src/superlocalmemory/server/routes/memories.py +49 -7
  115. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  116. package/src/superlocalmemory/server/routes/v3_api.py +191 -15
  117. package/src/superlocalmemory/server/ui.py +20 -4
  118. package/src/superlocalmemory/server/unified_daemon.py +241 -7
  119. package/src/superlocalmemory/storage/_migration_internals.py +54 -2
  120. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  121. package/src/superlocalmemory/storage/database.py +477 -59
  122. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  123. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  124. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  125. package/src/superlocalmemory/storage/migration_runner.py +119 -0
  126. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
  127. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  128. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  129. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  130. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  131. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  132. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  133. package/src/superlocalmemory/storage/migrations.py +18 -2
  134. package/src/superlocalmemory/storage/models.py +40 -1
  135. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  136. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  137. package/src/superlocalmemory/storage/schema.py +35 -1
  138. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  139. package/src/superlocalmemory/trust/scorer.py +43 -1
  140. package/src/superlocalmemory/ui/index.html +9 -18
  141. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  142. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  143. package/src/superlocalmemory/ui/js/od-memories.js +19 -0
  144. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  145. package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
@@ -20,7 +20,7 @@ from typing import Callable
20
20
  from mcp.types import ToolAnnotations
21
21
 
22
22
  from superlocalmemory.core.admission import admits
23
- from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
23
+ from superlocalmemory.core.config import CANONICAL_LIST_LIMIT, CANONICAL_RECALL_LIMIT
24
24
  from superlocalmemory.core.operation_request import OperationKind
25
25
  from superlocalmemory.infra.data_root import state_path
26
26
  from superlocalmemory.mcp._daemon_proxy import daemon_unavailable_error
@@ -29,6 +29,19 @@ from superlocalmemory.mcp.shared import authorize_mcp_mutation
29
29
  logger = logging.getLogger(__name__)
30
30
 
31
31
 
32
+ def _projection_queue_depth(db: object) -> int:
33
+ """Facts queued for the graph and vector projections, or 0 when there are none.
34
+
35
+ Imported inside the function: this module is loaded on every MCP start over
36
+ stdio, where import cost is startup latency a user feels.
37
+ """
38
+ try:
39
+ from superlocalmemory.storage import projection_outbox
40
+ return projection_outbox.depth(db)
41
+ except Exception:
42
+ return 0
43
+
44
+
32
45
  async def _runtime_profile(get_engine: Callable, explicit: str = "") -> str:
33
46
  """Resolve an MCP default profile from daemon runtime truth."""
34
47
  if explicit:
@@ -425,6 +438,9 @@ def register_core_tools(server, get_engine: Callable) -> None:
425
438
  "score_contract_version": result.get("score_contract_version", "2"),
426
439
  "calibration_status": result.get("calibration_status", "uncalibrated"),
427
440
  "calibration_id": result.get("calibration_id"),
441
+ # Quote this back to report_outcome and the report ties to
442
+ # this exact answer instead of being matched by guesswork.
443
+ "query_id": result.get("query_id", ""),
428
444
  "answer_confidence": result.get("answer_confidence"),
429
445
  "abstained": result.get("abstained", False),
430
446
  "abstention_reason": result.get("abstention_reason"),
@@ -436,7 +452,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
436
452
 
437
453
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
438
454
  @admits(OperationKind.RECALL)
439
- async def search(query: str, limit: int = 10) -> dict:
455
+ async def search(query: str, limit: int = CANONICAL_RECALL_LIMIT) -> dict:
440
456
  """Full-text search across memories using FTS5 with BM25 ranking."""
441
457
  try:
442
458
  engine = get_engine()
@@ -457,6 +473,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
457
473
  return {"success": False, "error": str(exc)}
458
474
 
459
475
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
476
+ @admits(OperationKind.RECALL)
460
477
  async def fetch(fact_ids: str) -> dict:
461
478
  """Fetch full details for specific fact IDs (comma-separated)."""
462
479
  try:
@@ -484,7 +501,8 @@ def register_core_tools(server, get_engine: Callable) -> None:
484
501
  return {"success": False, "error": str(exc)}
485
502
 
486
503
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
487
- async def list_recent(limit: int = 20) -> dict:
504
+ @admits(OperationKind.RECALL)
505
+ async def list_recent(limit: int = CANONICAL_LIST_LIMIT) -> dict:
488
506
  """List most recently stored memories, newest first."""
489
507
  try:
490
508
  engine = get_engine()
@@ -511,6 +529,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
511
529
  async def get_status() -> dict:
512
530
  """Get memory system status: fact count, entity count, mode, profile, db size."""
513
531
  try:
532
+ # Same source the HTTP surface reads, imported here rather than at
533
+ # module scope: that module costs ~260ms and MCP starts over stdio.
534
+ from superlocalmemory.server.routes.helpers import SLM_VERSION
535
+
514
536
  import asyncio
515
537
  import os
516
538
 
@@ -540,6 +562,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
540
562
  "profile_generation": int(
541
563
  daemon_status.get("profile_generation", 0)
542
564
  ),
565
+ "version": SLM_VERSION,
566
+ "projection_queue_depth": int(
567
+ daemon_status.get("projection_queue_depth", 0)
568
+ ),
543
569
  }
544
570
 
545
571
  engine = get_engine()
@@ -576,6 +602,8 @@ def register_core_tools(server, get_engine: Callable) -> None:
576
602
  "entity_count": entity_count,
577
603
  "edge_count": edge_count,
578
604
  "profile_generation": 0,
605
+ "version": SLM_VERSION,
606
+ "projection_queue_depth": _projection_queue_depth(engine._db),
579
607
  }
580
608
  except Exception as exc:
581
609
  logger.exception("get_status failed")
@@ -36,6 +36,7 @@ def register_v28_tools(server, get_engine: Callable) -> None:
36
36
  memory_ids: str,
37
37
  outcome: str,
38
38
  context: str = "",
39
+ recall_query_id: str = "",
39
40
  ) -> dict:
40
41
  """Report outcome of using recalled memories.
41
42
 
@@ -46,6 +47,10 @@ def register_v28_tools(server, get_engine: Callable) -> None:
46
47
  memory_ids: Comma-separated list of fact/memory IDs.
47
48
  outcome: One of 'success', 'failure', 'partial'.
48
49
  context: Optional freetext context about the outcome.
50
+ recall_query_id: The ``query_id`` that came back with the recall
51
+ this report is about. Passing it ties the report to that exact
52
+ answer; leaving it out falls back to matching on which memories
53
+ overlap, within a time window.
49
54
  """
50
55
  try:
51
56
  engine = get_engine()
@@ -54,7 +59,20 @@ def register_v28_tools(server, get_engine: Callable) -> None:
54
59
  "update",
55
60
  mutation_source="mcp-report-outcome",
56
61
  )
57
- from superlocalmemory.learning.outcomes import OutcomeTracker
62
+ from superlocalmemory.learning.outcomes import (
63
+ VALID_OUTCOMES,
64
+ OutcomeTracker,
65
+ )
66
+ if outcome not in VALID_OUTCOMES:
67
+ # Answered here rather than as a stack trace: the caller is an
68
+ # assistant that can correct itself if told what is allowed.
69
+ return {
70
+ "success": False,
71
+ "error": (
72
+ f"outcome must be one of {sorted(VALID_OUTCOMES)}, "
73
+ f"not {outcome!r}"
74
+ ),
75
+ }
58
76
  tracker = OutcomeTracker(engine._db)
59
77
  ids = [mid.strip() for mid in memory_ids.split(",") if mid.strip()]
60
78
  ctx = {"note": context} if context else None
@@ -64,6 +82,7 @@ def register_v28_tools(server, get_engine: Callable) -> None:
64
82
  outcome=outcome,
65
83
  profile_id=engine.profile_id,
66
84
  context=ctx,
85
+ recall_query_id=str(recall_query_id or "").strip(),
67
86
  )
68
87
 
69
88
  # v3.4.7: Bridge outcomes → learning signals for two-way learning.
@@ -66,7 +66,17 @@ _BEHAVIORAL_TYPE_MAP: dict[str, str] = {
66
66
  "query_type": "workflow_pattern",
67
67
  "time_of_day": "workflow_pattern",
68
68
  "refinement": "communication_style",
69
- "interest": "tech_preference",
69
+ # An "interest" is a word that shows up often in this user's memories. It is
70
+ # NOT a statement about their tooling, and calling it one produced the
71
+ # single worst thing this subsystem has shipped: a prompt injected on every
72
+ # turn reading "the user's preferred technology stack includes: test, gate,
73
+ # practices, compliance, projects, while, their, processing, data".
74
+ #
75
+ # Every one of those words is a real and frequent topic for this user. The
76
+ # values were right; the claim about them was false. Measured on a live
77
+ # store, `_store_patterns` holds correct tech_preference rows alongside
78
+ # these — Node.js, Go, Git, pip — so the two kinds were never the same kind.
79
+ "interest": "topic_interest",
70
80
  "archival": "avoidance",
71
81
  }
72
82
 
@@ -90,6 +100,9 @@ class PatternCategory(str, Enum):
90
100
  WORKFLOW_PATTERN = "workflow_pattern"
91
101
  PROJECT_CONTEXT = "project_context"
92
102
  DECISION_HISTORY = "decision_history"
103
+ # Topics that come up a lot in this user's memories. Deliberately separate
104
+ # from TECH_PREFERENCE: a frequent word is not a tooling choice.
105
+ TOPIC_INTEREST = "topic_interest"
93
106
  AVOIDANCE = "avoidance"
94
107
  CUSTOM = "custom"
95
108
 
@@ -53,6 +53,11 @@ CATEGORY_TEMPLATES: dict[str, str] = {
53
53
  "Current active project: {project_name}. "
54
54
  "Key context: {context_summary}."
55
55
  ),
56
+ # Says only what is actually known — that these subjects recur — rather
57
+ # than inferring a preference, a project or a tool choice from them.
58
+ "topic_interest": (
59
+ "Subjects that come up often in the user's notes: {topics}."
60
+ ),
56
61
  "decision_history": (
57
62
  "Recent key decisions: {decisions}. "
58
63
  "These reflect the user's current direction."
@@ -75,6 +80,7 @@ CATEGORY_PRIORITY_ORDER: list[str] = [
75
80
  "behavioral", # v3.4.7: behavioral assertions after communication style
76
81
  "workflow_pattern",
77
82
  "project_context",
83
+ "topic_interest",
78
84
  "decision_history",
79
85
  "avoidance",
80
86
  ]
@@ -105,6 +111,82 @@ class SoftPromptTemplate:
105
111
  # SoftPromptGenerator class
106
112
  # ---------------------------------------------------------------------------
107
113
 
114
+ def _empty_words() -> frozenset[str]:
115
+ """Words that say nothing about a person when listed as a preference.
116
+
117
+ Read from the list the miner already filters on, so there is one definition
118
+ to extend rather than two that drift.
119
+ """
120
+ try:
121
+ from superlocalmemory.learning.pattern_miner_constants import STOPWORDS
122
+
123
+ return frozenset(STOPWORDS)
124
+ except Exception: # pragma: no cover — the check degrades to "keep it"
125
+ return frozenset()
126
+
127
+
128
+ _EMPTY_WORDS = _empty_words()
129
+
130
+
131
+ def _is_substantive(category: str, values: dict[str, str]) -> bool:
132
+ """Whether a rendered prompt says anything at all about the user.
133
+
134
+ Deliberately weak, and it got that way by being wrong in the other
135
+ direction first. The original version required a ``tech_preference`` claim to
136
+ name something from a fixed vocabulary of technologies — which discarded
137
+ ``Node.js``, ``Git``, ``pip``, ``npm``, ``zig`` and every stack the list did
138
+ not happen to enumerate. Those were the GENUINE rows on a live store. A
139
+ filter that silently drops real preferences to catch fake ones is a worse
140
+ failure than the one it was added for, because nothing reports it.
141
+
142
+ What it was actually added for no longer arrives here. The live nonsense —
143
+ "preferred technology stack includes: test, gate, practices, compliance,
144
+ projects, while, their, processing, data" — came from word-frequency topics
145
+ being mapped onto this category, and they are now their own category, where
146
+ the same words form a true statement. This is the remaining floor: a prompt
147
+ built entirely out of words that appear in most English sentences says
148
+ nothing, whatever category it lands in.
149
+
150
+ Length is checked per TERM and only against that word list, never as a
151
+ minimum: "CTO", "AWS", "npm" and "R" are all shorter than a threshold would
152
+ allow and all mean something.
153
+ """
154
+ filled = [
155
+ v.strip() for v in values.values()
156
+ if isinstance(v, str) and v.strip()
157
+ ]
158
+ if not filled:
159
+ return False
160
+ terms = [
161
+ t.strip().lower()
162
+ for value in filled
163
+ for t in value.replace(";", ",").split(",")
164
+ if t.strip()
165
+ ]
166
+ if not terms:
167
+ return False
168
+ return any(term not in _EMPTY_WORDS for term in terms)
169
+
170
+
171
+ def _fix_stutter(content: str) -> str:
172
+ """Remove the duplicated conjunction where a template meets its value.
173
+
174
+ ``"The user typically {workflow_description}"`` was rendering as "The user
175
+ typically When when using X" — the template supplies the lead-in and the
176
+ value already starts with its own. Observed on 19 of 34 stored prompts.
177
+ """
178
+ import re as _re
179
+
180
+ for word in ("when", "typically", "prefers", "often", "usually"):
181
+ content = _re.sub(
182
+ rf"\b({word})\s+{word}\b", r"\1", content, flags=_re.IGNORECASE,
183
+ )
184
+ # "typically When when" collapses to "typically When"; then the lead-in and
185
+ # the value's own opener are adjacent duplicates of different case.
186
+ content = _re.sub(r"\btypically\s+When\b", "typically, when", content)
187
+ return content
188
+
189
+
108
190
  class SoftPromptGenerator:
109
191
  """Convert extracted pattern assertions into natural language soft prompts.
110
192
 
@@ -243,12 +325,25 @@ class SoftPromptGenerator:
243
325
 
244
326
  # Clean up
245
327
  content = self._clean_content(content)
328
+ content = _fix_stutter(content)
246
329
 
247
330
  # Filter PII
248
331
  content = self._pii_filter.filter_text(content)
249
332
  if not content.strip():
250
333
  return None
251
334
 
335
+ # A prompt that says nothing must not be injected. These go into the
336
+ # model's context on every turn, so an empty claim is not neutral — it
337
+ # spends the budget and asserts something false. Measured on a live
338
+ # store: 15 of 34 stored prompts read "the user's preferred technology
339
+ # stack includes: data, processing, their, projects, test", built from
340
+ # common words that happened to appear near a technology keyword.
341
+ if not _is_substantive(category, values):
342
+ logger.debug(
343
+ "soft prompt for %r dropped: no substantive values", category,
344
+ )
345
+ return None
346
+
252
347
  # Trim to 100 tokens per category
253
348
  content = self._trim_to_tokens(content, 100)
254
349
 
@@ -305,6 +400,9 @@ class SoftPromptGenerator:
305
400
  elif category == "workflow_pattern":
306
401
  values["workflow_description"] = "; ".join(pat_values)
307
402
 
403
+ elif category == "topic_interest":
404
+ values["topics"] = ", ".join(pat_values)
405
+
308
406
  elif category == "project_context":
309
407
  values["project_name"] = pat_values[0] if pat_values else ""
310
408
  values["context_summary"] = ", ".join(pat_values[1:])
@@ -54,6 +54,65 @@ def tokenize(text: str) -> list[str]:
54
54
  return [t for t in tokens if t not in _STOPWORDS]
55
55
 
56
56
 
57
+ #: Saturation constant for the BM25 -> [0,1) transform. Chosen from measurement,
58
+ #: not taste: raw scores on a live store run ~2.5 to ~15 across query
59
+ #: lengths, so k = 5 puts an ordinary match near 0.4 and a strong one near 0.7,
60
+ #: leaving headroom at both ends rather than pinning everything to one corner.
61
+ _BM25_SATURATION = 5.0
62
+
63
+
64
+ def _to_unit_scale(scored: list[tuple[str, float]]) -> list[tuple[str, float]]:
65
+ """Map BM25 scores into [0, 1) without disturbing order or magnitude.
66
+
67
+ WHY ANY OF THIS. ``engine.apply_channel_weights`` re-scores a candidate as
68
+ ``sum(channel_scores[ch] * weights[ch])`` — a SUM of raw channel scores.
69
+ Every other channel is bounded: semantic cosine and Fisher-Rao are [0, 1],
70
+ the temporal proximity score is Gaussian on [0, 1]. BM25 is not. Measured on
71
+ a live store, one query at a time::
72
+
73
+ "slm release" max 2.845
74
+ "memory" max 3.865
75
+ "the release ships once both audits are clean" max 10.150
76
+
77
+ So the sum was decided by a scale rather than by evidence, and any weight
78
+ the bandit converged on was a correction for that scale — wrong the moment
79
+ the query length changed.
80
+
81
+ WHY NOT DIVIDE BY THE BATCH MAXIMUM. That was the first implementation here
82
+ and it is wrong, which an existing test caught before it shipped:
83
+ ``test_real_fts5_exact_hit_keeps_bounded_slot`` exists to prove *"a real
84
+ sub-1.0 FTS5 hit remains visible under semantic pressure"*. Dividing by the
85
+ batch maximum makes the best result exactly 1.0 **whatever it scored**, so a
86
+ query with one weak lexical match reports full confidence and outranks a
87
+ semantic channel it should lose to. Batch-relative scaling manufactures
88
+ confidence out of an empty batch, and it also makes a fact's score depend on
89
+ which other facts happened to come back — the same query returning a
90
+ different number on a different day, which HARD-RULES RULE 6 puts above
91
+ speed.
92
+
93
+ A saturating transform has neither problem: ``s / (s + k)`` is strictly
94
+ increasing, so order is untouched; it is bounded below 1.0, so nothing is
95
+ ever certain; and it depends only on the score itself, so it is repeatable.
96
+ Applied to the measurements above: 2.676 -> 0.35, 2.845 -> 0.36,
97
+ 3.865 -> 0.44, 10.150 -> 0.67.
98
+
99
+ FUSION IS UNAFFECTED, verified by reading ``fusion.weighted_rrf`` rather
100
+ than assuming: it computes ``fused += w / (k + rank)`` and keeps the score
101
+ only for reporting. Rescaling a value nothing divides by cannot move a
102
+ fused rank.
103
+
104
+ Non-positive scores map to 0.0. FTS5's ``bm25()`` is <= 0 and is negated
105
+ here, so a value at or below zero means "no lexical evidence", and that is
106
+ what it should contribute to a sum.
107
+ """
108
+ if not scored:
109
+ return scored
110
+ return [
111
+ (fid, (s / (s + _BM25_SATURATION)) if s > 0.0 else 0.0)
112
+ for fid, s in scored
113
+ ]
114
+
115
+
57
116
  class BM25Channel:
58
117
  """Persistent BM25Plus index for keyword retrieval.
59
118
 
@@ -300,10 +359,10 @@ class BM25Channel:
300
359
  # Falls back to rank_bm25 ONLY if the FTS5 table is genuinely
301
360
  # unavailable (raises) — e.g. a pre-FTS legacy DB.
302
361
  try:
303
- return self._fts5_search(
362
+ return _to_unit_scale(self._fts5_search(
304
363
  query, profile_id, top_k,
305
364
  include_global=include_global, include_shared=include_shared,
306
- )
365
+ ))
307
366
  except Exception as exc: # pragma: no cover — legacy/missing FTS table
308
367
  logger.debug(
309
368
  "BM25 FTS5 path unavailable, using rank_bm25 fallback: %s", exc,
@@ -338,7 +397,9 @@ class BM25Channel:
338
397
  scored.append((self._fact_ids[i], bonus))
339
398
 
340
399
  scored.sort(key=lambda x: (-x[1], x[0]))
341
- return scored[:top_k]
400
+ # Same rescale as the FTS5 path. This fallback applies a 1.5x exact
401
+ # phrase bonus, so its raw ceiling is higher still.
402
+ return _to_unit_scale(scored[:top_k])
342
403
 
343
404
  def update_fact(self, fact_id: str, new_content: str, profile_id: str) -> None:
344
405
  """Replace a fact's representation in the live index and persist new tokens.
@@ -0,0 +1,117 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """What happened to each retrieval channel on one recall.
5
+
6
+ WHY THIS EXISTS
7
+ ---------------
8
+ A channel that crashes on every query and a channel that correctly found
9
+ nothing produced the same observable result: absence. Both simply had no entry
10
+ in the fused candidate map, and the only trace was a log line on a machine
11
+ nobody is reading.
12
+
13
+ That matters because the channels are not interchangeable. Lexical search
14
+ finding nothing for a conceptual question is the system working. Lexical search
15
+ raising on every question is an outage that looks, from the outside, like a
16
+ store with nothing in it — the user sees weaker answers and has no way to tell
17
+ which of the two they are getting.
18
+
19
+ There is a third case the absence hid, and it is the worst of them: a channel
20
+ that never ran. When the query embedding is unavailable, three of the five
21
+ channels are never even dispatched. Nothing in the answer said so.
22
+
23
+ WHY MORE THAN "ok / empty / error"
24
+ ----------------------------------
25
+ Because the remedies differ, and a status whose remedy is ambiguous is a status
26
+ nobody acts on. ``error`` is a bug to fix. ``timeout`` is a capacity or data-size
27
+ problem. ``no_embedding`` means the embedding provider is down and several
28
+ channels are silently offline together. ``disabled`` and ``not_configured`` are
29
+ someone's deliberate choice and must not read as faults — which is the point:
30
+ without naming them, an operator reading a list of missing channels cannot tell
31
+ their own configuration apart from a failure.
32
+
33
+ STRINGS, NOT AN ENUM
34
+ --------------------
35
+ This crosses the MCP and HTTP surfaces, where it is JSON either way. A str-valued
36
+ constant serialises without a custom encoder and compares equal to what a client
37
+ sends back.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ from typing import Literal
43
+
44
+ __all__ = [
45
+ "ALL_STATUSES",
46
+ "CHANNEL_NAMES",
47
+ "ChannelStatus",
48
+ "DISABLED",
49
+ "EMPTY",
50
+ "ERROR",
51
+ "NOT_CONFIGURED",
52
+ "NO_CANDIDATES",
53
+ "NO_EMBEDDING",
54
+ "OK",
55
+ "TIMEOUT",
56
+ "is_fault",
57
+ ]
58
+
59
+ ChannelStatus = Literal[
60
+ "ok",
61
+ "empty",
62
+ "error",
63
+ "timeout",
64
+ "disabled",
65
+ "not_configured",
66
+ "no_embedding",
67
+ "no_candidates",
68
+ ]
69
+
70
+ #: Ran and contributed candidates.
71
+ OK: ChannelStatus = "ok"
72
+ #: Ran, contributed nothing. A legitimate answer, not a fault.
73
+ EMPTY: ChannelStatus = "empty"
74
+ #: Raised. This answer is missing whatever this channel alone could see.
75
+ ERROR: ChannelStatus = "error"
76
+ #: Abandoned at the hang guard. The answer is incomplete, not merely late.
77
+ TIMEOUT: ChannelStatus = "timeout"
78
+ #: Excluded by configuration or by a per-recall ablation flag.
79
+ DISABLED: ChannelStatus = "disabled"
80
+ #: No such channel on this engine — not built, or its dependency is absent.
81
+ NOT_CONFIGURED: ChannelStatus = "not_configured"
82
+ #: Needed the query embedding, which was unavailable. Several channels fail
83
+ #: together this way, and none of them individually did anything wrong.
84
+ NO_EMBEDDING: ChannelStatus = "no_embedding"
85
+ #: Had nothing to work on. Distinct from ``empty``, which claims a search
86
+ #: happened: a channel that re-scores other channels' candidates never searched
87
+ #: at all when there were none, and if the reason there were none is that the
88
+ #: others failed, calling this "found nothing" hides the actual fault.
89
+ NO_CANDIDATES: ChannelStatus = "no_candidates"
90
+
91
+ ALL_STATUSES: frozenset[str] = frozenset({
92
+ OK, EMPTY, ERROR, TIMEOUT, DISABLED, NOT_CONFIGURED, NO_EMBEDDING,
93
+ NO_CANDIDATES,
94
+ })
95
+
96
+ #: Every channel a recall can report on, so a caller can tell "this channel had
97
+ #: no status recorded" (a gap in the reporting) from "this channel reported that
98
+ #: it did nothing" (an answer). A missing key is a bug; ``empty`` is not.
99
+ CHANNEL_NAMES: tuple[str, ...] = (
100
+ "semantic",
101
+ "bm25",
102
+ "temporal",
103
+ "hopfield",
104
+ "spreading_activation",
105
+ "entity_graph",
106
+ "profile",
107
+ )
108
+
109
+ #: Statuses that mean the answer is worse than it should have been. ``empty``,
110
+ #: ``disabled`` and ``not_configured`` are deliberately absent: the first is a
111
+ #: valid finding and the other two are somebody's decision.
112
+ _FAULTS: frozenset[str] = frozenset({ERROR, TIMEOUT, NO_EMBEDDING})
113
+
114
+
115
+ def is_fault(status: str | None) -> bool:
116
+ """Whether this status means the answer was degraded."""
117
+ return (status or "") in _FAULTS