superlocalmemory 4.0.9 → 4.1.0

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 (165) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +245 -0
  3. package/README.md +7 -7
  4. package/package.json +4 -2
  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 +308 -20
  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 +26 -4
  44. package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
  45. package/src/superlocalmemory/code_graph/database.py +44 -0
  46. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  47. package/src/superlocalmemory/core/admission.py +231 -11
  48. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  49. package/src/superlocalmemory/core/config.py +90 -11
  50. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  51. package/src/superlocalmemory/core/engine.py +140 -11
  52. package/src/superlocalmemory/core/fact_consolidator.py +316 -125
  53. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  54. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  55. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  56. package/src/superlocalmemory/core/maintenance.py +44 -6
  57. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  58. package/src/superlocalmemory/core/memory_health.py +266 -0
  59. package/src/superlocalmemory/core/mode_capability.py +111 -0
  60. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  61. package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
  62. package/src/superlocalmemory/core/operation_request.py +1 -1
  63. package/src/superlocalmemory/core/ops_remediation.py +2 -2
  64. package/src/superlocalmemory/core/projection_drain.py +380 -0
  65. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  66. package/src/superlocalmemory/core/recall_worker.py +6 -3
  67. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  68. package/src/superlocalmemory/core/scale_engine.py +16 -2
  69. package/src/superlocalmemory/core/score_contract.py +21 -1
  70. package/src/superlocalmemory/core/session_identity.py +85 -0
  71. package/src/superlocalmemory/core/status_contract.py +108 -0
  72. package/src/superlocalmemory/core/store_pipeline.py +78 -3
  73. package/src/superlocalmemory/core/worker_pool.py +4 -4
  74. package/src/superlocalmemory/core/working_memory.py +288 -0
  75. package/src/superlocalmemory/encoding/cognitive_consolidator.py +51 -7
  76. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  77. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  78. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  79. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  80. package/src/superlocalmemory/encoding/type_router.py +12 -12
  81. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  82. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  83. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  84. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  85. package/src/superlocalmemory/learning/bandit.py +43 -0
  86. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  87. package/src/superlocalmemory/learning/database.py +60 -3
  88. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  89. package/src/superlocalmemory/learning/feedback.py +3 -1
  90. package/src/superlocalmemory/learning/outcomes.py +47 -16
  91. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  92. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  93. package/src/superlocalmemory/learning/pcos.py +291 -0
  94. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  95. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  96. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  97. package/src/superlocalmemory/mcp/profiles.py +14 -2
  98. package/src/superlocalmemory/mcp/server.py +1 -1
  99. package/src/superlocalmemory/mcp/session_binding.py +92 -0
  100. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  101. package/src/superlocalmemory/mcp/tools_core.py +71 -42
  102. package/src/superlocalmemory/mcp/tools_ops.py +2 -2
  103. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  104. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  105. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  106. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -11
  107. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  108. package/src/superlocalmemory/retrieval/engine.py +106 -11
  109. package/src/superlocalmemory/retrieval/entity_channel.py +217 -257
  110. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  111. package/src/superlocalmemory/retrieval/scope_policy.py +42 -1
  112. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  113. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  114. package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
  115. package/src/superlocalmemory/retrieval/vector_store.py +63 -0
  116. package/src/superlocalmemory/server/api.py +26 -2
  117. package/src/superlocalmemory/server/asset_versions.py +171 -0
  118. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  119. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  120. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  121. package/src/superlocalmemory/server/routes/abstraction.py +201 -0
  122. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  123. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  124. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  125. package/src/superlocalmemory/server/routes/data_io.py +29 -1
  126. package/src/superlocalmemory/server/routes/entity.py +13 -1
  127. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  128. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  129. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  130. package/src/superlocalmemory/server/routes/memories.py +49 -7
  131. package/src/superlocalmemory/server/routes/mesh.py +1 -1
  132. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  133. package/src/superlocalmemory/server/routes/v3_api.py +193 -17
  134. package/src/superlocalmemory/server/ui.py +24 -1
  135. package/src/superlocalmemory/server/unified_daemon.py +292 -9
  136. package/src/superlocalmemory/storage/_migration_internals.py +35 -0
  137. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  138. package/src/superlocalmemory/storage/database.py +598 -82
  139. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  140. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  141. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  142. package/src/superlocalmemory/storage/migration_runner.py +130 -0
  143. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
  144. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  145. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  146. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  147. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  148. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  149. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  150. package/src/superlocalmemory/storage/migrations.py +18 -2
  151. package/src/superlocalmemory/storage/models.py +40 -1
  152. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  153. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  154. package/src/superlocalmemory/storage/schema.py +110 -1
  155. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  156. package/src/superlocalmemory/summaries/base.py +1 -1
  157. package/src/superlocalmemory/summaries/non_answer.py +223 -0
  158. package/src/superlocalmemory/trust/scorer.py +43 -1
  159. package/src/superlocalmemory/ui/index.html +10 -19
  160. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  161. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  162. package/src/superlocalmemory/ui/js/od-memories.js +209 -1
  163. package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
  164. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  165. package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
@@ -0,0 +1,92 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """Work out which session a tool call belongs to, the same way every time.
5
+
6
+ ``recall`` has resolved this through a four-step ladder since S9-DASH-10 — the
7
+ explicit argument, then the environment, then the hook registry, then a stable
8
+ per-agent fallback — so an engagement signal lands on the right pending outcome.
9
+ ``remember`` never had it. It took ``session_id: str = ""`` and stored whatever
10
+ it was handed, which for a caller that does not pass one is nothing.
11
+
12
+ Measured on the author's store: **192 of 3,894 genuine facts carry a session_id
13
+ (4.9%)**, and 4 of the 200 most recent (2%). Every one of the rest was written
14
+ through a path that could have known and did not.
15
+
16
+ That is not bookkeeping. ``RetrievalEngine`` promotes results so the top of an
17
+ answer spans more than one session (its ``sessions_in_top`` pass), and a fact
18
+ with no session_id can never be promoted by it — so the diversity mechanism was
19
+ running against a corpus where 95% of rows were indistinguishable. It also means
20
+ "what did we discuss in that session" has almost nothing to match on.
21
+
22
+ One implementation, called by both tools, so the read path and the write path
23
+ cannot disagree about which session they are in.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ import os
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ __all__ = ["resolve_session_id", "SESSION_ENV_VARS"]
34
+
35
+ #: Checked in order. Hosts set one or the other; SLM's own takes precedence so a
36
+ #: user can override a host that sets its variable to something unhelpful.
37
+ SESSION_ENV_VARS = ("SLM_SESSION_ID", "CLAUDE_SESSION_ID")
38
+
39
+
40
+ def resolve_session_id(
41
+ explicit: str = "",
42
+ *,
43
+ agent_id: str = "unknown",
44
+ allow_agent_fallback: bool = True,
45
+ ) -> str:
46
+ """Best available session id for this call. Never raises.
47
+
48
+ Order, most to least specific:
49
+
50
+ 1. ``explicit`` — what the caller passed. Always wins.
51
+ 2. ``SLM_SESSION_ID`` / ``CLAUDE_SESSION_ID`` from the environment.
52
+ 3. The hook registry: the session whose parent process is ours, else the
53
+ most recently active one inside 60 seconds. Parent-PID lookup is
54
+ collision-free across parallel host sessions, because each MCP
55
+ server's parent is the editor that spawned it.
56
+ 4. ``mcp:<agent_id>`` — stable per agent, and deliberately NOT matched by
57
+ the Stop hook, so the reaper settles those outcomes at a neutral 0.5
58
+ rather than crediting or blaming a session that never existed.
59
+
60
+ ``allow_agent_fallback=False`` stops before step 4 and returns "". Use it
61
+ where a synthetic id would be worse than none: grouping memories under
62
+ ``mcp:<agent>`` would put every memory an agent ever wrote into one bucket
63
+ and make session-diversity promotion rank them as a single session, which
64
+ is the opposite of what it is for.
65
+ """
66
+ if explicit and explicit.strip():
67
+ return explicit.strip()
68
+
69
+ for name in SESSION_ENV_VARS:
70
+ value = os.environ.get(name)
71
+ if value and value.strip():
72
+ return value.strip()
73
+
74
+ try:
75
+ from superlocalmemory.hooks.session_registry import (
76
+ lookup_by_parent,
77
+ most_recent_active,
78
+ )
79
+
80
+ found = (
81
+ lookup_by_parent(within_seconds=60)
82
+ or most_recent_active(agent_type="claude", within_seconds=60)
83
+ or ""
84
+ )
85
+ if found:
86
+ return found
87
+ except Exception as exc: # noqa: BLE001 — a hint must never fail a call
88
+ logger.debug("session registry lookup unavailable: %s", exc)
89
+
90
+ if allow_agent_fallback:
91
+ return f"mcp:{agent_id}"
92
+ return ""
@@ -287,7 +287,7 @@ def _upcoming_scheduled_facts(engine, now: datetime.datetime) -> list[dict]:
287
287
  "SELECT fact_id, content, referenced_date"
288
288
  " FROM atomic_facts"
289
289
  " WHERE profile_id = ?"
290
- " AND fact_type = 'temporal'"
290
+ " AND fact_type = 'prospective'"
291
291
  " AND referenced_date IS NOT NULL"
292
292
  " AND referenced_date >= ?"
293
293
  " AND referenced_date < ?"
@@ -312,6 +312,7 @@ def register_active_tools(server, get_engine: Callable) -> None:
312
312
  # 1. session_init — Auto-recall project context at session start
313
313
  # ------------------------------------------------------------------
314
314
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
315
+ @admits(OperationKind.RECALL)
315
316
  async def session_init(
316
317
  project_path: str = "",
317
318
  query: str = "",
@@ -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:
@@ -73,6 +86,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
73
86
  scope: str | None = None,
74
87
  shared_with: str = "",
75
88
  idempotency_key: str = "",
89
+ session_date: str = "",
76
90
  ) -> dict:
77
91
  """Store content to memory with intelligent indexing.
78
92
 
@@ -82,11 +96,35 @@ def register_core_tools(server, get_engine: Callable) -> None:
82
96
  Multi-scope: ``scope`` sets visibility (personal/shared/global).
83
97
  ``shared_with`` is a comma-separated list of profile_ids for
84
98
  shared scope.
99
+
100
+ ``session_date`` says WHEN the memory is about, as opposed to when it
101
+ was written. Omit it and the memory is dated today, which is what every
102
+ memory got before 4.0.10 because there was no way to say otherwise.
103
+ Accepts YYYY-MM-DD or a full ISO 8601 timestamp.
85
104
  """
86
105
  # v3.6.10: resolve "mcp_client" sentinel → URL path (HTTP) or env var (stdio)
87
106
  if agent_id == "mcp_client":
88
107
  from superlocalmemory.mcp.agent_context import get_current_agent_id
89
108
  agent_id = get_current_agent_id()
109
+ # Bind the write to a session the same way the read path does.
110
+ #
111
+ # recall has resolved this through a four-step ladder since S9-DASH-10;
112
+ # remember stored whatever it was handed, which for a caller that does
113
+ # not pass one is nothing. Result on the author's store: 192 of 3,894
114
+ # facts carry a session_id (4.9%). The engine's session-diversity
115
+ # promotion cannot promote a fact with no session, so it was running
116
+ # against a corpus where 95% of rows looked like the same session.
117
+ #
118
+ # allow_agent_fallback is OFF here, unlike recall. `mcp:<agent_id>` is a
119
+ # useful key for settling one outcome; as a stored session_id it would
120
+ # file every memory an agent ever wrote under one session, and diversity
121
+ # promotion would then treat a whole history as a single conversation —
122
+ # worse than the empty string it replaces.
123
+ from superlocalmemory.mcp.session_binding import resolve_session_id
124
+
125
+ session_id = resolve_session_id(
126
+ session_id, agent_id=agent_id, allow_agent_fallback=False,
127
+ )
90
128
  meta = {
91
129
  "project": project,
92
130
  "importance": importance,
@@ -141,6 +179,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
141
179
  "content": content, "tags": tags, "metadata": meta,
142
180
  "scope": scope, "shared_with": _shared_list,
143
181
  "session_id": session_id,
182
+ "session_date": session_date,
144
183
  "idempotency_key": effective_idempotency_key or None,
145
184
  })
146
185
  if resp and (resp.get("fact_ids") is not None or resp.get("ok")):
@@ -320,45 +359,20 @@ def register_core_tools(server, get_engine: Callable) -> None:
320
359
  import asyncio
321
360
  try:
322
361
  from superlocalmemory.mcp._daemon_proxy import choose_pool
323
- # S9-DASH-10: priority for session_id, so engagement
324
- # signals land on the right pending_outcome:
325
- # 1. Explicit ``session_id`` tool-call argument.
326
- # 2. ``SLM_SESSION_ID`` / ``CLAUDE_SESSION_ID`` env var.
327
- # 3. Most-recent-active Claude session from the hook
328
- # registry (last 60s). This catches the common case
329
- # where Claude Code's hooks ran the UserPromptSubmit
330
- # hook right before invoking the MCP tool.
331
- # 4. Stable per-agent fallback ``mcp:<agent_id>`` the
332
- # Stop hook will NOT match this, so the reaper
333
- # settles it at neutral 0.5.
334
- effective_sid = session_id
335
- if not effective_sid:
336
- import os as _os
337
- effective_sid = (
338
- _os.environ.get("SLM_SESSION_ID")
339
- or _os.environ.get("CLAUDE_SESSION_ID")
340
- or ""
341
- )
342
- if not effective_sid:
343
- try:
344
- from superlocalmemory.hooks.session_registry import (
345
- lookup_by_parent,
346
- most_recent_active,
347
- )
348
- # Parent-PID lookup is collision-free across multiple
349
- # parallel Claude sessions (each MCP server's parent
350
- # is the IDE that spawned it).
351
- effective_sid = (
352
- lookup_by_parent(within_seconds=60)
353
- or most_recent_active(
354
- agent_type="claude", within_seconds=60,
355
- )
356
- or ""
357
- )
358
- except Exception:
359
- pass
360
- if not effective_sid:
361
- effective_sid = f"mcp:{agent_id}"
362
+ from superlocalmemory.mcp.session_binding import resolve_session_id
363
+
364
+ # S9-DASH-10's four-step ladder, now shared with remember() so the
365
+ # read path and the write path cannot disagree about which session
366
+ # they are in. remember() had no ladder at all, which is why 95% of
367
+ # stored facts carry no session_id. See mcp/session_binding.py.
368
+ #
369
+ # The per-agent fallback stays ON here: this id settles a pending
370
+ # outcome, and `mcp:<agent_id>` is deliberately not matched by the
371
+ # Stop hook, so the reaper settles it at a neutral 0.5 rather than
372
+ # attributing engagement to a session that never existed.
373
+ effective_sid = resolve_session_id(
374
+ session_id, agent_id=agent_id, allow_agent_fallback=True,
375
+ )
362
376
  # Resolve the daemon proxy inside the worker too. ``choose_pool``
363
377
  # verifies daemon ownership through a synchronous /health request;
364
378
  # when this tool is served by the daemon's mounted HTTP MCP app,
@@ -424,6 +438,9 @@ def register_core_tools(server, get_engine: Callable) -> None:
424
438
  "score_contract_version": result.get("score_contract_version", "2"),
425
439
  "calibration_status": result.get("calibration_status", "uncalibrated"),
426
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", ""),
427
444
  "answer_confidence": result.get("answer_confidence"),
428
445
  "abstained": result.get("abstained", False),
429
446
  "abstention_reason": result.get("abstention_reason"),
@@ -435,7 +452,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
435
452
 
436
453
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
437
454
  @admits(OperationKind.RECALL)
438
- async def search(query: str, limit: int = 10) -> dict:
455
+ async def search(query: str, limit: int = CANONICAL_RECALL_LIMIT) -> dict:
439
456
  """Full-text search across memories using FTS5 with BM25 ranking."""
440
457
  try:
441
458
  engine = get_engine()
@@ -456,6 +473,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
456
473
  return {"success": False, "error": str(exc)}
457
474
 
458
475
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
476
+ @admits(OperationKind.RECALL)
459
477
  async def fetch(fact_ids: str) -> dict:
460
478
  """Fetch full details for specific fact IDs (comma-separated)."""
461
479
  try:
@@ -483,7 +501,8 @@ def register_core_tools(server, get_engine: Callable) -> None:
483
501
  return {"success": False, "error": str(exc)}
484
502
 
485
503
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
486
- async def list_recent(limit: int = 20) -> dict:
504
+ @admits(OperationKind.RECALL)
505
+ async def list_recent(limit: int = CANONICAL_LIST_LIMIT) -> dict:
487
506
  """List most recently stored memories, newest first."""
488
507
  try:
489
508
  engine = get_engine()
@@ -510,6 +529,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
510
529
  async def get_status() -> dict:
511
530
  """Get memory system status: fact count, entity count, mode, profile, db size."""
512
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
+
513
536
  import asyncio
514
537
  import os
515
538
 
@@ -539,6 +562,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
539
562
  "profile_generation": int(
540
563
  daemon_status.get("profile_generation", 0)
541
564
  ),
565
+ "version": SLM_VERSION,
566
+ "projection_queue_depth": int(
567
+ daemon_status.get("projection_queue_depth", 0)
568
+ ),
542
569
  }
543
570
 
544
571
  engine = get_engine()
@@ -575,6 +602,8 @@ def register_core_tools(server, get_engine: Callable) -> None:
575
602
  "entity_count": entity_count,
576
603
  "edge_count": edge_count,
577
604
  "profile_generation": 0,
605
+ "version": SLM_VERSION,
606
+ "projection_queue_depth": _projection_queue_depth(engine._db),
578
607
  }
579
608
  except Exception as exc:
580
609
  logger.exception("get_status failed")
@@ -2,7 +2,7 @@
2
2
  # Licensed under AGPL-3.0-or-later - see LICENSE file
3
3
  # Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
4
4
 
5
- """Wave-3 Operational Recovery & Admin Remediation MCP tools (2 tools).
5
+ """Operational Recovery & Admin Remediation MCP tools (2 tools).
6
6
 
7
7
  list_failed_operations — Surface dead-letter, degraded, and exhausted ops.
8
8
  resolve_operation — Admin retry/force-reconcile/cancel for stuck ops.
@@ -28,7 +28,7 @@ _VALID_ACTIONS = frozenset({"retry", "force_reconcile", "cancel"})
28
28
 
29
29
 
30
30
  def register_ops_tools(server, get_engine: Callable) -> None:
31
- """Register Wave-3 operational-recovery MCP tools on *server*."""
31
+ """Register operational-recovery MCP tools on *server*."""
32
32
 
33
33
  # ------------------------------------------------------------------
34
34
  # 1. list_failed_operations — surface all stuck/failed/degraded ops
@@ -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
 
@@ -221,14 +280,10 @@ class BM25Channel:
221
280
  include_shared=include_shared,
222
281
  prefix="af",
223
282
  )
224
- # Archived facts are not live; never surface them in keyword recall.
225
- # Guarded on column presence: the archive column is added by a deferred
226
- # migration and may be absent on an unmigrated database.
227
- archive_clause = (
228
- " AND COALESCE(af.archive_status, 'live') != 'archived'"
229
- if self._db._has_archive_status()
230
- else ""
231
- )
283
+ # Soft-deleted and withheld rows are not live. Filtered HERE rather
284
+ # than only at hydration so neither spends one of this channel's top_k
285
+ # slots; the shared clause keeps the definition in one place.
286
+ archive_clause = self._db.visible_fact_clause("af")
232
287
  sql = (
233
288
  "SELECT af.fact_id AS fact_id, bm25(atomic_facts_fts) AS rank "
234
289
  "FROM atomic_facts_fts "
@@ -304,10 +359,10 @@ class BM25Channel:
304
359
  # Falls back to rank_bm25 ONLY if the FTS5 table is genuinely
305
360
  # unavailable (raises) — e.g. a pre-FTS legacy DB.
306
361
  try:
307
- return self._fts5_search(
362
+ return _to_unit_scale(self._fts5_search(
308
363
  query, profile_id, top_k,
309
364
  include_global=include_global, include_shared=include_shared,
310
- )
365
+ ))
311
366
  except Exception as exc: # pragma: no cover — legacy/missing FTS table
312
367
  logger.debug(
313
368
  "BM25 FTS5 path unavailable, using rank_bm25 fallback: %s", exc,
@@ -342,7 +397,9 @@ class BM25Channel:
342
397
  scored.append((self._fact_ids[i], bonus))
343
398
 
344
399
  scored.sort(key=lambda x: (-x[1], x[0]))
345
- 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])
346
403
 
347
404
  def update_fact(self, fact_id: str, new_content: str, profile_id: str) -> None:
348
405
  """Replace a fact's representation in the live index and persist new tokens.