superlocalmemory 4.0.9 → 4.0.10

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 (64) hide show
  1. package/CHANGELOG.md +75 -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/pyproject.toml +1 -1
  25. package/src/superlocalmemory/__init__.py +1 -1
  26. package/src/superlocalmemory/cli/commands.py +45 -2
  27. package/src/superlocalmemory/cli/main.py +2 -2
  28. package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
  29. package/src/superlocalmemory/core/fact_consolidator.py +316 -125
  30. package/src/superlocalmemory/core/maintenance.py +44 -6
  31. package/src/superlocalmemory/core/memory_health.py +266 -0
  32. package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
  33. package/src/superlocalmemory/core/operation_request.py +1 -1
  34. package/src/superlocalmemory/core/ops_remediation.py +2 -2
  35. package/src/superlocalmemory/core/store_pipeline.py +78 -3
  36. package/src/superlocalmemory/encoding/cognitive_consolidator.py +15 -1
  37. package/src/superlocalmemory/mcp/server.py +1 -1
  38. package/src/superlocalmemory/mcp/session_binding.py +92 -0
  39. package/src/superlocalmemory/mcp/tools_core.py +40 -39
  40. package/src/superlocalmemory/mcp/tools_ops.py +2 -2
  41. package/src/superlocalmemory/retrieval/bm25_channel.py +4 -8
  42. package/src/superlocalmemory/retrieval/entity_channel.py +7 -1
  43. package/src/superlocalmemory/retrieval/scope_policy.py +22 -1
  44. package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
  45. package/src/superlocalmemory/retrieval/vector_store.py +63 -0
  46. package/src/superlocalmemory/server/api.py +6 -1
  47. package/src/superlocalmemory/server/asset_versions.py +171 -0
  48. package/src/superlocalmemory/server/routes/abstraction.py +201 -0
  49. package/src/superlocalmemory/server/routes/data_io.py +29 -1
  50. package/src/superlocalmemory/server/routes/entity.py +13 -1
  51. package/src/superlocalmemory/server/routes/mesh.py +1 -1
  52. package/src/superlocalmemory/server/routes/v3_api.py +2 -2
  53. package/src/superlocalmemory/server/ui.py +8 -1
  54. package/src/superlocalmemory/server/unified_daemon.py +111 -9
  55. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  56. package/src/superlocalmemory/storage/database.py +128 -30
  57. package/src/superlocalmemory/storage/migration_runner.py +11 -0
  58. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
  59. package/src/superlocalmemory/storage/schema.py +98 -0
  60. package/src/superlocalmemory/summaries/base.py +1 -1
  61. package/src/superlocalmemory/summaries/non_answer.py +223 -0
  62. package/src/superlocalmemory/ui/index.html +1 -1
  63. package/src/superlocalmemory/ui/js/od-memories.js +190 -1
  64. package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
@@ -31,6 +31,16 @@ if TYPE_CHECKING:
31
31
 
32
32
  logger = logging.getLogger(__name__)
33
33
 
34
+
35
+ class _ConsolidationDisabled(Exception):
36
+ """Internal signal: consolidation is switched off, so skip its block.
37
+
38
+ A private exception rather than restructuring the surrounding try/except:
39
+ the block's job is to keep one optional maintenance step from taking the
40
+ whole pass down with it, and that guarantee should not be weakened to
41
+ express "deliberately skipped". Caught immediately below, never propagated.
42
+ """
43
+
34
44
  # Backfill constants
35
45
  _BACKFILL_BURN_IN_STEPS = 50
36
46
  _LANGEVIN_DIM = 8
@@ -638,9 +648,12 @@ def run_maintenance(
638
648
  logger.warning("Entity summary consolidation failed: %s", exc)
639
649
 
640
650
  # 4. Fact consolidation (v3.8.4 concurrency-safe path via DatabaseManager).
641
- # Merges clusters of warm/cold atomic facts about the same entity into a
642
- # single consolidated fact, archives the originals (NEVER deletes them),
643
- # and records provenance in fact_consolidations.
651
+ # Groups warm/cold atomic facts that share an entity and writes ONE
652
+ # DISPLAY summary per cluster into consolidated_summaries, with provenance
653
+ # in fact_consolidations. It does not write to atomic_facts and does not
654
+ # archive the source facts — until 4.0.10 it did both, which put 1,195
655
+ # model-written rows into the retrieval corpus and left 528 genuine
656
+ # memories archived out of normal recall.
644
657
  #
645
658
  # Uses the DatabaseManager path so LLM calls happen OUTSIDE the write lock:
646
659
  # - Discover clusters in a short memory_read() (no write lock held).
@@ -652,20 +665,45 @@ def run_maintenance(
652
665
  try:
653
666
  from superlocalmemory.core.fact_consolidator import consolidate_facts
654
667
 
668
+ # The documented off-switch has to actually switch something off.
669
+ # ConsolidationConfig.enabled has existed since Phase 5 and this call
670
+ # site never read it, so a user who ran `slm config` to turn
671
+ # consolidation off got consolidation anyway — for four months, on
672
+ # every maintenance pass. -2 is a third distinguishable value, kept
673
+ # apart from 0 (nothing to merge) and -1 (the step failed), so a
674
+ # deliberately disabled step is never mistaken for either.
675
+ _consolidation = getattr(config, "consolidation", None)
676
+ if _consolidation is not None and not getattr(_consolidation, "enabled", True):
677
+ counts["facts_consolidated"] = -2
678
+ logger.debug("Fact consolidation disabled by configuration")
679
+ raise _ConsolidationDisabled
680
+
655
681
  fc_stats = consolidate_facts(
656
682
  db,
657
683
  profile_id=profile_id,
658
- max_clusters=getattr(config, "max_consolidation_clusters", 20),
684
+ # Read from ConsolidationConfig, with the old SLMConfig-level name
685
+ # as the fallback. `getattr(config, "max_consolidation_clusters")`
686
+ # alone never resolved — SLMConfig has no such attribute — so the
687
+ # default was the only value this had ever used.
688
+ max_clusters=int(
689
+ getattr(_consolidation, "max_consolidation_clusters", None)
690
+ or getattr(config, "max_consolidation_clusters", None)
691
+ or 20
692
+ ),
659
693
  dry_run=False,
660
694
  config=config,
661
695
  )
662
696
  counts["facts_consolidated"] = fc_stats.get("consolidated", 0)
663
697
  if fc_stats.get("consolidated", 0) > 0:
664
698
  logger.info(
665
- "Fact consolidation: %d clusters merged, %d facts archived",
699
+ "Fact consolidation: %d display summaries over %d facts "
700
+ "(%d clusters refused)",
666
701
  fc_stats.get("consolidated", 0),
667
- fc_stats.get("facts_archived", 0),
702
+ fc_stats.get("facts_summarized", 0),
703
+ fc_stats.get("rejected", 0),
668
704
  )
705
+ except _ConsolidationDisabled:
706
+ pass
669
707
  except Exception as exc:
670
708
  # WARNING, not debug, and a distinguishable count. Leaving this at debug
671
709
  # with facts_consolidated=0 made a failing consolidation report exactly
@@ -0,0 +1,266 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """Tell the owner, in their own words, whether their memory works.
5
+
6
+ Until now the only way to learn that 43.7% of a store could not be found by
7
+ asking a question was to write the SQL yourself. One machine sat in exactly
8
+ that state for months while every status line it showed said the system was
9
+ healthy, because nothing measured reachability and nothing reported it.
10
+
11
+ So this module answers four questions a non-engineer can act on:
12
+
13
+ * How many memories do I have?
14
+ * How many can actually be found by asking a question?
15
+ * How many were withheld because a model wrote them, not me?
16
+ * Is anything still being repaired?
17
+
18
+ Read-only, and every query is bounded. Fail-soft by construction: a missing
19
+ table or column yields ``None`` for that line rather than an exception, because
20
+ a health report that crashes on an old store is worse than one that says "not
21
+ known yet".
22
+
23
+ Consumed by ``slm doctor``, ``GET /api/v3/memory-health``, and the dashboard.
24
+ One implementation so the three cannot disagree with each other.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import re
31
+ import sqlite3
32
+ from dataclasses import dataclass, field
33
+ from pathlib import Path
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ __all__ = ["MemoryHealth", "measure", "describe"]
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class MemoryHealth:
42
+ """A store's answer-ability, counted rather than assumed."""
43
+
44
+ #: Memories that recall is allowed to return.
45
+ live_facts: int = 0
46
+ #: Of those, how many have a vector projection, i.e. can be found by
47
+ #: meaning rather than only by matching words.
48
+ findable_by_meaning: int = 0
49
+ #: Memories with no vector at all. These are reachable by keyword only.
50
+ missing_vector: int = 0
51
+ #: Machine-written summaries withheld from recall and kept for display.
52
+ withheld_summaries: int = 0
53
+ #: Summaries preserved in the display table.
54
+ display_summaries: int = 0
55
+ #: Memories hidden by the retention system, excluding the withheld ones.
56
+ hidden_by_forgetting: int = 0
57
+ #: Rows whose retention zone contradicts their retention score, i.e. hidden
58
+ #: while the maths says to keep them. Should be zero after repair.
59
+ inconsistently_hidden: int = 0
60
+ #: Present only when a table or column was absent.
61
+ unavailable: tuple[str, ...] = field(default_factory=tuple)
62
+
63
+ @property
64
+ def reachability(self) -> float:
65
+ """Share of live memories findable by meaning, 0.0-1.0."""
66
+ if self.live_facts <= 0:
67
+ return 1.0
68
+ return self.findable_by_meaning / self.live_facts
69
+
70
+ @property
71
+ def healthy(self) -> bool:
72
+ """Whether anything here warrants telling the owner about."""
73
+ return (
74
+ self.reachability >= 0.99
75
+ and self.missing_vector == 0
76
+ and self.inconsistently_hidden == 0
77
+ )
78
+
79
+
80
+ def measure(db_path: str | Path) -> MemoryHealth:
81
+ """Count the store's answer-ability. Read-only; never raises."""
82
+ unavailable: list[str] = []
83
+ try:
84
+ conn = sqlite3.connect(f"file:{Path(db_path)}?mode=ro", uri=True)
85
+ except sqlite3.Error as exc:
86
+ logger.debug("memory health: cannot open %s: %s", db_path, exc)
87
+ return MemoryHealth(unavailable=("database",))
88
+
89
+ try:
90
+ if not _table_exists(conn, "atomic_facts"):
91
+ return MemoryHealth(unavailable=("atomic_facts",))
92
+
93
+ # Quarantine came in 4.0.10. On an older store every fact is "live",
94
+ # which is the honest reading of a store that has no withheld rows.
95
+ has_q = _has_column(conn, "atomic_facts", "quarantined")
96
+ if not has_q:
97
+ unavailable.append("quarantined")
98
+ live_clause = "COALESCE(quarantined, 0) = 0" if has_q else "1=1"
99
+
100
+ live = _count(conn, f"SELECT COUNT(*) FROM atomic_facts WHERE {live_clause}")
101
+ withheld = (
102
+ _count(conn, "SELECT COUNT(*) FROM atomic_facts WHERE quarantined = 1")
103
+ if has_q else 0
104
+ )
105
+ missing_vec = _count(
106
+ conn,
107
+ f"SELECT COUNT(*) FROM atomic_facts "
108
+ f"WHERE embedding IS NULL AND {live_clause}",
109
+ )
110
+
111
+ if _table_exists(conn, "embedding_metadata"):
112
+ findable = _count(
113
+ conn,
114
+ "SELECT COUNT(*) FROM embedding_metadata em "
115
+ "JOIN atomic_facts af ON af.fact_id = em.fact_id "
116
+ f"WHERE {_prefixed(live_clause, 'af')}",
117
+ )
118
+ else:
119
+ unavailable.append("embedding_metadata")
120
+ findable = 0
121
+
122
+ display = (
123
+ _count(conn, "SELECT COUNT(*) FROM consolidated_summaries")
124
+ if _table_exists(conn, "consolidated_summaries") else 0
125
+ )
126
+ if not _table_exists(conn, "consolidated_summaries"):
127
+ unavailable.append("consolidated_summaries")
128
+
129
+ hidden = inconsistent = 0
130
+ if _table_exists(conn, "fact_retention"):
131
+ hidden = _count(
132
+ conn,
133
+ "SELECT COUNT(*) FROM fact_retention r "
134
+ "JOIN atomic_facts af ON af.fact_id = r.fact_id "
135
+ "WHERE r.lifecycle_zone IN ('archive', 'forgotten') "
136
+ f" AND {_prefixed(live_clause, 'af')}",
137
+ )
138
+ # The contradiction M043 repairs: hidden, yet scored to keep.
139
+ inconsistent = _count(
140
+ conn,
141
+ "SELECT COUNT(*) FROM fact_retention r "
142
+ "JOIN atomic_facts af ON af.fact_id = r.fact_id "
143
+ "WHERE r.lifecycle_zone IN ('archive', 'forgotten') "
144
+ " AND r.retention_score > 0.8 "
145
+ f" AND {_prefixed(live_clause, 'af')}",
146
+ )
147
+ else:
148
+ unavailable.append("fact_retention")
149
+
150
+ return MemoryHealth(
151
+ live_facts=live,
152
+ findable_by_meaning=findable,
153
+ missing_vector=missing_vec,
154
+ withheld_summaries=withheld,
155
+ display_summaries=display,
156
+ hidden_by_forgetting=hidden,
157
+ inconsistently_hidden=inconsistent,
158
+ unavailable=tuple(unavailable),
159
+ )
160
+ except sqlite3.Error as exc:
161
+ logger.debug("memory health measurement failed: %s", exc)
162
+ return MemoryHealth(unavailable=(*unavailable, "query_failed"))
163
+ finally:
164
+ conn.close()
165
+
166
+
167
+ def describe(health: MemoryHealth) -> list[str]:
168
+ """Plain-language lines for a reader who does not write SQL.
169
+
170
+ No percentages without the counts behind them, and no jargon: "findable by
171
+ asking a question" rather than "vector coverage", because the person who
172
+ needs this line is the one who would not know what a vector is.
173
+ """
174
+ lines: list[str] = []
175
+ if "atomic_facts" in health.unavailable or "database" in health.unavailable:
176
+ return ["Memory store not readable yet."]
177
+
178
+ lines.append(f"You have {health.live_facts:,} memories.")
179
+
180
+ if "embedding_metadata" in health.unavailable:
181
+ lines.append(
182
+ "Whether they can be found by asking a question is not known yet — "
183
+ "the search index has not been built."
184
+ )
185
+ elif health.live_facts:
186
+ pct = 100.0 * health.reachability
187
+ if health.findable_by_meaning >= health.live_facts:
188
+ # "All" only when the counts actually agree. The threshold used to
189
+ # be reachability >= 0.99, which printed "All of them can be found
190
+ # by asking a question (5,199 indexed)" on a store of 5,205 — a
191
+ # claim of all, contradicted by the number beside it. This module
192
+ # exists to be believed; it cannot round in its own favour.
193
+ lines.append(
194
+ f"All {health.live_facts:,} of them can be found by asking a "
195
+ f"question."
196
+ )
197
+ elif health.reachability >= 0.99:
198
+ gap = health.live_facts - health.findable_by_meaning
199
+ lines.append(
200
+ f"{health.findable_by_meaning:,} of them can be found by asking "
201
+ f"a question. The other {gap:,} can only be found by matching "
202
+ f"words. That is a small enough share to be normal — a memory "
203
+ f"written moments ago, or one the model could not read."
204
+ )
205
+ else:
206
+ gap = health.live_facts - health.findable_by_meaning
207
+ lines.append(
208
+ f"{health.findable_by_meaning:,} of them ({pct:.0f}%) can be "
209
+ f"found by asking a question. The other {gap:,} can only be "
210
+ f"found by matching words, so a question phrased differently "
211
+ f"will miss them. This repairs itself as the service runs; if "
212
+ f"it does not, the embedding model is unavailable."
213
+ )
214
+
215
+ if health.withheld_summaries:
216
+ lines.append(
217
+ f"{health.withheld_summaries:,} machine-written summaries are kept "
218
+ f"out of your answers and shown on the dashboard instead. They were "
219
+ f"written by the summarizer, not by you, and they used to be "
220
+ f"returned as if they were your own notes."
221
+ )
222
+
223
+ if health.inconsistently_hidden:
224
+ lines.append(
225
+ f"{health.inconsistently_hidden:,} memories are hidden even though "
226
+ f"they are marked worth keeping. This is a fault and it is repaired "
227
+ f"automatically the next time the service starts."
228
+ )
229
+
230
+ if health.hidden_by_forgetting:
231
+ lines.append(
232
+ f"{health.hidden_by_forgetting:,} older memories are set aside by "
233
+ f"the forgetting curve. They are not deleted and a deep search "
234
+ f"still reaches them."
235
+ )
236
+
237
+ return lines
238
+
239
+
240
+ def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
241
+ return conn.execute(
242
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,),
243
+ ).fetchone() is not None
244
+
245
+
246
+ def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
247
+ return any(
248
+ row[1] == column for row in conn.execute(f"PRAGMA table_info({table})")
249
+ )
250
+
251
+
252
+ def _prefixed(clause: str, prefix: str) -> str:
253
+ """Qualify a bare column reference for use in a joined query.
254
+
255
+ Word-bounded, so a future column named ``quarantined_at`` is not silently
256
+ rewritten to ``af.quarantined_at`` by a substring match. No such column
257
+ exists today; the point is that the failure would be a wrong count rather
258
+ than an error, and a wrong count in a health report is the one thing this
259
+ module must not produce.
260
+ """
261
+ return re.sub(r"\bquarantined\b", f"{prefix}.quarantined", clause)
262
+
263
+
264
+ def _count(conn: sqlite3.Connection, sql: str) -> int:
265
+ row = conn.execute(sql).fetchone()
266
+ return int(row[0]) if row else 0
@@ -281,7 +281,7 @@ class OperationPolicyRegistry:
281
281
  allowed_transports=_ALL_TRANSPORTS,
282
282
  audit_level="full",
283
283
  ),
284
- # Operational recovery & admin remediation (Wave-3 resilience slice)
284
+ # Operational recovery & admin remediation (resilience slice)
285
285
  # OPS_INSPECT: read-only listing of failed/stuck/degraded ops.
286
286
  # Allowed over all transports so dashboard, MCP, and CLI all work.
287
287
  OperationKind.OPS_INSPECT: OperationPolicy(
@@ -51,7 +51,7 @@ class OperationKind(str, Enum):
51
51
  SCHEMA_MIGRATE = "schema_migrate"
52
52
  VECTOR_MIGRATE = "vector_migrate"
53
53
  EVOLVE_SKILL = "evolve_skill"
54
- # Operational recovery & admin remediation (V4 Wave-3 resilience slice)
54
+ # Operational recovery & admin remediation (V4 resilience slice)
55
55
  OPS_INSPECT = "ops_inspect" # List failed/stuck/degraded operations (OWNER/ADMIN)
56
56
  OPS_RESOLVE = "ops_resolve" # Retry/force-reconcile/cancel an operation (OWNER/ADMIN)
57
57
 
@@ -1,7 +1,7 @@
1
1
  # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
2
  # Licensed under AGPL-3.0-or-later - see LICENSE file
3
3
 
4
- """Operational Recovery & Admin Remediation helpers (Wave-3 resilience slice).
4
+ """Operational Recovery & Admin Remediation helpers (resilience slice).
5
5
 
6
6
  Provides two primary functions used by HTTP endpoints, MCP tools, and CLI:
7
7
 
@@ -22,7 +22,7 @@ Design constraints (NON-NEGOTIABLE):
22
22
  - Additive & backward-compatible — healthy-path unaffected
23
23
  - Immutable return dicts; explicit error handling; no silent swallowing
24
24
 
25
- Part of SuperLocalMemory V4 | Wave-3: Operational Recovery
25
+ Part of SuperLocalMemory V4 | Operational Recovery
26
26
  """
27
27
 
28
28
  from __future__ import annotations
@@ -118,6 +118,54 @@ def _record_correction_candidate(
118
118
  logger.warning("Correction candidate not recorded for %s: %s", successor_fact_id, exc)
119
119
 
120
120
 
121
+ #: Entity id pattern for the per-profile placeholder a dated fact with no
122
+ #: resolved entity points its temporal event at. Per profile, not shared, so
123
+ #: profile deletion cascades it away with everything else.
124
+ _UNRESOLVED_ENTITY_PREFIX = "__slm_unresolved__:"
125
+
126
+
127
+ def _ensure_unresolved_entity(db: Any, profile_id: str) -> str | None:
128
+ """Return the placeholder entity id for ``profile_id``, creating it once.
129
+
130
+ ``temporal_events.entity_id`` is NOT NULL with a foreign key to
131
+ ``canonical_entities``, so an event for a fact with no recognised entity
132
+ needs something real to reference. This is that something.
133
+
134
+ It is deliberately inert as an entity:
135
+
136
+ * Its ``canonical_name`` is empty, and the only query that resolves an
137
+ entity by name (``retrieval/temporal_channel.py``, the "events for
138
+ entity X" lookup) is driven by names extracted from the user's
139
+ question, which are never empty.
140
+ * Nothing derives entity links from ``temporal_events``. The entity
141
+ channel builds its map from ``atomic_facts.canonical_entities_json``,
142
+ which this does not touch — so the placeholder cannot become the kind
143
+ of entity that links a quarter of the store and flattens entity
144
+ proximity. That failure mode is real (on the author's store 'State'
145
+ links 1,388 facts) and this is specifically shaped not to add to it.
146
+ * ``fact_count`` stays 0. It is a hook for a foreign key, not a concept.
147
+
148
+ Returns None if the row cannot be created, in which case the caller skips
149
+ the temporal event rather than failing the write — a missing temporal row
150
+ is a degraded memory, an aborted remember is a lost one.
151
+ """
152
+ entity_id = f"{_UNRESOLVED_ENTITY_PREFIX}{profile_id}"
153
+ try:
154
+ db.execute(
155
+ "INSERT OR IGNORE INTO canonical_entities "
156
+ "(entity_id, profile_id, canonical_name, entity_type, fact_count) "
157
+ "VALUES (?, ?, '', 'unresolved', 0)",
158
+ (entity_id, profile_id),
159
+ )
160
+ return entity_id
161
+ except Exception as exc: # noqa: BLE001 — never fail a write over this
162
+ logger.debug(
163
+ "temporal placeholder entity unavailable for %s: %s",
164
+ profile_id, exc,
165
+ )
166
+ return None
167
+
168
+
121
169
  def _record_fact_entity_association(
122
170
  db: DatabaseManager,
123
171
  *,
@@ -1031,12 +1079,39 @@ def run_store(
1031
1079
  if scene_builder:
1032
1080
  scene_builder.assign_to_scene(fact, profile_id)
1033
1081
 
1034
- # Populate temporal_events for temporal retrieval
1082
+ # Populate temporal_events for temporal retrieval.
1083
+ #
1084
+ # A DATE IS A DATE WHETHER OR NOT AN ENTITY WAS RESOLVED. This used to
1085
+ # read `if fact.canonical_entities and has_dates`, so a fact carrying a
1086
+ # perfectly good date but no recognised entity got no temporal row at
1087
+ # all. Measured on the author's store: 967 of 3,894 genuine facts have
1088
+ # no canonical entity (24.8%), and 958 of those have no temporal event
1089
+ # either — a quarter of the store that the temporal channel could only
1090
+ # reach through its created_at recency fallback, i.e. by being recent
1091
+ # rather than by being about the right time.
1092
+ #
1093
+ # The date-window query does not need the entity: it joins
1094
+ # temporal_events to atomic_facts and nothing else
1095
+ # (retrieval/temporal_channel.py). Only the "events for entity X"
1096
+ # lookup joins canonical_entities, and that lookup is correctly
1097
+ # uninterested in a fact with no entity.
1098
+ #
1099
+ # temporal_events.entity_id is NOT NULL with an FK to
1100
+ # canonical_entities, so an entity-less event needs a row to point at.
1101
+ # It gets a per-profile sentinel rather than a shared one, and the
1102
+ # sentinel is invisible to ranking: the entity channel builds its map
1103
+ # from atomic_facts.canonical_entities_json, which is untouched here, so
1104
+ # this cannot create the kind of entity that links a quarter of the
1105
+ # store and destroys entity proximity.
1035
1106
  has_dates = (fact.observation_date or fact.referenced_date
1036
1107
  or fact.interval_start)
1037
- if fact.canonical_entities and has_dates:
1108
+ if has_dates:
1038
1109
  from superlocalmemory.storage.models import TemporalEvent
1039
- for eid in fact.canonical_entities:
1110
+ entity_ids = list(fact.canonical_entities)
1111
+ if not entity_ids:
1112
+ sentinel = _ensure_unresolved_entity(db, profile_id)
1113
+ entity_ids = [sentinel] if sentinel else []
1114
+ for eid in entity_ids:
1040
1115
  event = TemporalEvent(
1041
1116
  event_id=_ingestion_effect_id(
1042
1117
  ingestion_operation_id,
@@ -327,6 +327,17 @@ class CognitiveConsolidator:
327
327
  AND r.lifecycle_zone IN ('warm', 'cold')
328
328
  AND r.retention_score < ?
329
329
  AND f.lifecycle != 'forgotten'
330
+ -- Withheld rows are not candidates, and this is not tidiness.
331
+ -- 304 of them remain warm/cold in atomic_facts after 4.0.10
332
+ -- withholds them, and each still carries its cluster's POOLED
333
+ -- canonical_entities_json. CCQ clusters on entity overlap, so one
334
+ -- withheld summary naming ten entities joins a cluster of real
335
+ -- memories, contributes model prose to the gist, and then this
336
+ -- pass archives every source in the cluster -- including the real
337
+ -- memories, at scores M043's restore would not bring back.
338
+ -- Exactly the damage this release exists to stop, on a path the
339
+ -- release did not otherwise touch.
340
+ AND COALESCE(f.quarantined, 0) = 0
330
341
  AND f.fact_id NOT IN (
331
342
  SELECT je.value
332
343
  FROM ccq_consolidated_blocks ccb,
@@ -494,7 +505,10 @@ class CognitiveConsolidator:
494
505
  f"SELECT fact_id, content, importance, confidence, "
495
506
  f" canonical_entities_json "
496
507
  f"FROM atomic_facts "
497
- f"WHERE fact_id IN ({placeholders}) AND profile_id = ?",
508
+ f"WHERE fact_id IN ({placeholders}) AND profile_id = ? "
509
+ # Belt and braces with the identify query above: a cluster assembled
510
+ # before a row was withheld must not contribute its text to a gist.
511
+ f" AND COALESCE(quarantined, 0) = 0",
498
512
  (*cluster.fact_ids, profile_id),
499
513
  )
500
514
 
@@ -282,7 +282,7 @@ register_optimize_tools(_target) # v3.6.11: Surface B Optimize tools (proxy-fre
282
282
  from superlocalmemory.mcp.tools_loops import register_loop_tools
283
283
  register_loop_tools(_target, get_engine) # v3.8.0: bounded-loop tools (CLI+command+MCP)
284
284
  from superlocalmemory.mcp.tools_ops import register_ops_tools
285
- register_ops_tools(_target, get_engine) # Wave-3: operational recovery & admin remediation
285
+ register_ops_tools(_target, get_engine) # operational recovery & admin remediation
286
286
  from superlocalmemory.mcp.tools_brain import register_brain_tools
287
287
  register_brain_tools(_target, get_engine) # v4.0.2 portable Brain receipts
288
288
  from superlocalmemory.mcp.tools_summaries import register_summary_tools
@@ -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 ""