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,288 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """A small, per-session set of memories that biases the next recall.
5
+
6
+ WHAT THIS IS FOR
7
+ ----------------
8
+ Turn 5 of a conversation currently knows nothing about turns 1-4. Every recall
9
+ starts from the query alone, so a memory the user was just discussing competes
10
+ from scratch against every other memory in the store.
11
+
12
+ ``ContextCache`` does not fix this, because it does something different: on a
13
+ hit it answers *instead of* searching, and on a miss it contributes nothing.
14
+ This module biases a search that always runs in full. The distinction is the
15
+ whole point — a cache replaces retrieval, a working set shapes it.
16
+
17
+ The two are complementary and both stay: the cache short-circuits an exact
18
+ repeat of a query before the daemon is reached, this biases the ranking of
19
+ every query that does reach it.
20
+
21
+ WHY SO SMALL
22
+ ------------
23
+ Seven slots per session, evicting the least-activated when full. Human working
24
+ memory is capacity-limited to a handful of items, and the limit is what makes it
25
+ useful: an unbounded "everything recently seen" set would boost most of the
26
+ store and therefore rank nothing.
27
+
28
+ Seven is the constant this ships with. It is a starting point, not a derived
29
+ optimum, and ``MAX_SLOTS`` is the single place to change it.
30
+
31
+ ADMISSION IS BY RANK, NOT BY SCORE
32
+ ----------------------------------
33
+ An earlier design admitted a memory when its fused score cleared 0.60. That
34
+ threshold is unreachable, and measurably so: reciprocal-rank fusion of *n*
35
+ channels caps the top score at ``n/(k+1)``, which is 0.31 at ``rrf_k=15`` and
36
+ 0.08 at ``rrf_k=60``. Nothing would ever have been admitted, the bias would
37
+ never have fired, and the symptom would have been silence rather than an error.
38
+ (The 0.60 that does exist in retrieval is the evidence floor's semantic cosine —
39
+ a different quantity on a different scale.)
40
+
41
+ So admission takes the memories that were actually *shown* at the top of an
42
+ answer. Rank survives every rescaling the pipeline applies to scores; an
43
+ absolute threshold does not.
44
+
45
+ WHY A LOCK, AND NOT "THE GIL IS ENOUGH"
46
+ ---------------------------------------
47
+ ``admit`` reads the slot list, decides an eviction, and writes it back. That is
48
+ not atomic under the GIL — it spans many bytecodes, and the daemon serves
49
+ recalls from a thread-pool executor, so two recalls in one session genuinely
50
+ interleave. Without the lock, both can pass the capacity check and both append,
51
+ leaving eight slots in a seven-slot set. A lock acquisition costs nanoseconds
52
+ against a recall measured in hundreds of milliseconds.
53
+
54
+ NEVER PERSISTED
55
+ ---------------
56
+ There is no table behind this and there must not be. It is ephemeral by
57
+ definition; persisting it would create an unbounded write per recall for state
58
+ whose entire value is that it is cheap.
59
+ """
60
+
61
+ from __future__ import annotations
62
+
63
+ import threading
64
+ import time
65
+ from dataclasses import dataclass
66
+
67
+ __all__ = [
68
+ "MAX_SLOTS",
69
+ "WorkingMemory",
70
+ "discard",
71
+ "discard_profile",
72
+ "get_or_create",
73
+ "registry_size",
74
+ ]
75
+
76
+ #: Slots per session. See "WHY SO SMALL" above.
77
+ MAX_SLOTS = 7
78
+
79
+ #: How many shown memories a single answer may offer for admission. Matches the
80
+ #: prefix the settlement path records as "what this query showed", so the two
81
+ #: notions of "what the user actually saw" cannot drift apart.
82
+ ADMIT_TOP_N = 5
83
+
84
+ #: A session untouched for this long is dropped. The daemon runs for weeks.
85
+ SESSION_IDLE_EVICT_SECS = 86_400 # 24 h
86
+
87
+ #: Hard ceiling on tracked sessions, evicting least-recently-touched first.
88
+ #: Age alone does not bound anything inside the first 24 hours, and a burst of
89
+ #: short-lived session ids is exactly the shape of an agent workload.
90
+ MAX_SESSIONS = 512
91
+
92
+ #: Activation halves roughly every hour of disuse.
93
+ _RECENCY_HALFLIFE_SECS = 3_600.0
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class _Slot:
98
+ """One remembered memory. Frozen, so an update is a replacement.
99
+
100
+ Immutability is not decoration here: a reader holding this object while a
101
+ writer updates the same memory sees either the old slot or the new one,
102
+ never a half-applied mix of the two.
103
+ """
104
+
105
+ fact_id: str
106
+ hits: int
107
+ last_seen: float
108
+
109
+ def activation(self, now: float) -> float:
110
+ """How strongly this memory is currently held.
111
+
112
+ Repetition raises it, elapsed time lowers it. The decay is a smooth
113
+ reciprocal rather than a step, so eviction never depends on which side
114
+ of a boundary a timestamp happened to land.
115
+ """
116
+ age = max(0.0, now - self.last_seen)
117
+ return self.hits / (1.0 + age / _RECENCY_HALFLIFE_SECS)
118
+
119
+
120
+ class WorkingMemory:
121
+ """The working set for one (profile, session) pair."""
122
+
123
+ __slots__ = ("_lock", "_slots", "_touched_at")
124
+
125
+ def __init__(self) -> None:
126
+ self._lock = threading.Lock()
127
+ self._slots: dict[str, _Slot] = {}
128
+ self._touched_at = time.monotonic()
129
+
130
+ # -- mutation ----------------------------------------------------------
131
+
132
+ def admit(self, fact_ids: list[str] | tuple[str, ...]) -> None:
133
+ """Offer the memories an answer just showed, best-ranked first.
134
+
135
+ Already-held memories are reinforced rather than duplicated, which is
136
+ what makes a memory referenced across several turns hard to evict.
137
+ """
138
+ if not fact_ids:
139
+ return
140
+ now = time.monotonic()
141
+ with self._lock:
142
+ self._touched_at = now
143
+ for fact_id in fact_ids[:ADMIT_TOP_N]:
144
+ if not fact_id:
145
+ continue
146
+ existing = self._slots.get(fact_id)
147
+ self._slots[fact_id] = _Slot(
148
+ fact_id=fact_id,
149
+ hits=(existing.hits + 1) if existing else 1,
150
+ last_seen=now,
151
+ )
152
+ self._evict_locked(now)
153
+
154
+ def touch(self, fact_id: str) -> None:
155
+ """Reinforce a memory something downstream actually used.
156
+
157
+ Stronger evidence than having been displayed, and it arrives later, so
158
+ it is a separate entry point rather than a flag on ``admit``. A memory
159
+ that is not held is not admitted by being touched: this reinforces
160
+ attention, it does not create it.
161
+ """
162
+ if not fact_id:
163
+ return
164
+ now = time.monotonic()
165
+ with self._lock:
166
+ self._touched_at = now
167
+ existing = self._slots.get(fact_id)
168
+ if existing is None:
169
+ return
170
+ self._slots[fact_id] = _Slot(
171
+ fact_id=fact_id, hits=existing.hits + 1, last_seen=now,
172
+ )
173
+
174
+ def _evict_locked(self, now: float) -> None:
175
+ """Drop the least-activated slots. Caller holds the lock."""
176
+ while len(self._slots) > MAX_SLOTS:
177
+ weakest = min(
178
+ self._slots.values(),
179
+ key=lambda s: (s.activation(now), s.last_seen, s.fact_id),
180
+ )
181
+ self._slots.pop(weakest.fact_id, None)
182
+
183
+ # -- reads -------------------------------------------------------------
184
+
185
+ def boost_set(self) -> frozenset[str]:
186
+ """The memories currently held. Read by the ranking bias."""
187
+ with self._lock:
188
+ return frozenset(self._slots)
189
+
190
+ def idle_secs(self, now: float | None = None) -> float:
191
+ with self._lock:
192
+ return max(0.0, (now or time.monotonic()) - self._touched_at)
193
+
194
+ def __len__(self) -> int:
195
+ with self._lock:
196
+ return len(self._slots)
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Registry
201
+ #
202
+ # Keyed by (profile_id, session_id), never by session_id alone. Two profiles in
203
+ # one daemon can be handed the same session id, and a shared key would let one
204
+ # profile's memories bias the other's answers. It also gives erasure something
205
+ # to erase: ``discard_profile`` can find every set belonging to a profile.
206
+ # ---------------------------------------------------------------------------
207
+
208
+ _REGISTRY: dict[tuple[str, str], WorkingMemory] = {}
209
+ _REGISTRY_LOCK = threading.Lock()
210
+
211
+
212
+ def get_or_create(profile_id: str, session_id: str) -> WorkingMemory:
213
+ """The working set for this session, creating it on first use.
214
+
215
+ Both arguments are required and neither defaults. A default ``profile_id``
216
+ is how two profiles end up sharing one set.
217
+ """
218
+ key = (profile_id or "", session_id or "")
219
+ with _REGISTRY_LOCK:
220
+ _sweep_locked()
221
+ existing = _REGISTRY.get(key)
222
+ if existing is not None:
223
+ return existing
224
+ created = WorkingMemory()
225
+ _REGISTRY[key] = created
226
+ return created
227
+
228
+
229
+ def peek(profile_id: str, session_id: str) -> WorkingMemory | None:
230
+ """The working set if one exists, without creating one.
231
+
232
+ Reading must not allocate: a bias that runs on every recall would otherwise
233
+ register an entry for every session id it ever sees, including those that
234
+ never admit anything.
235
+ """
236
+ with _REGISTRY_LOCK:
237
+ return _REGISTRY.get((profile_id or "", session_id or ""))
238
+
239
+
240
+ def discard(profile_id: str, session_id: str) -> None:
241
+ """Forget one session. Called when a session closes."""
242
+ with _REGISTRY_LOCK:
243
+ _REGISTRY.pop((profile_id or "", session_id or ""), None)
244
+
245
+
246
+ def discard_profile(profile_id: str) -> int:
247
+ """Forget every session belonging to a profile. Returns the count dropped.
248
+
249
+ Called from erasure. In-process state is still the erased subject's data:
250
+ leaving it behind would let a deleted profile's memories bias a later
251
+ session that reuses its session id, which is the residue defect this
252
+ project has already paid for once in a search index.
253
+ """
254
+ target = profile_id or ""
255
+ with _REGISTRY_LOCK:
256
+ doomed = [k for k in _REGISTRY if k[0] == target]
257
+ for key in doomed:
258
+ _REGISTRY.pop(key, None)
259
+ return len(doomed)
260
+
261
+
262
+ def registry_size() -> int:
263
+ with _REGISTRY_LOCK:
264
+ return len(_REGISTRY)
265
+
266
+
267
+ def _sweep_locked() -> None:
268
+ """Bound the registry by idle time and then by count. Caller holds the lock."""
269
+ now = time.monotonic()
270
+ stale = [
271
+ key for key, wm in _REGISTRY.items()
272
+ if wm.idle_secs(now) > SESSION_IDLE_EVICT_SECS
273
+ ]
274
+ for key in stale:
275
+ _REGISTRY.pop(key, None)
276
+ # Trim to one below the ceiling, because the caller is about to insert.
277
+ # Trimming to the ceiling itself leaves the registry one over after every
278
+ # insertion, which is a cap that is never actually enforced. Sweeping after
279
+ # the insert instead would make the new entry — idle for zero seconds, tied
280
+ # with every other new entry — a candidate for its own eviction.
281
+ headroom = MAX_SESSIONS - 1
282
+ if len(_REGISTRY) <= headroom:
283
+ return
284
+ by_age = sorted(
285
+ _REGISTRY.items(), key=lambda kv: (-kv[1].idle_secs(now), kv[0]),
286
+ )
287
+ for key, _wm in by_age[: len(_REGISTRY) - headroom]:
288
+ _REGISTRY.pop(key, None)
@@ -187,6 +187,33 @@ def _temporal_midpoint(dates: list[datetime]) -> str:
187
187
  # ---------------------------------------------------------------------------
188
188
 
189
189
 
190
+ def _config_for_this_store() -> "CCQConfig":
191
+ """The settings this store is actually running under.
192
+
193
+ A caller that supplies only a database used to get the bare defaults, which
194
+ say a language model may be used to summarise. Three of the four places
195
+ that build this pass only a database, so on a store configured to use no
196
+ language model at all, the refusal below rested on nobody having handed one
197
+ in -- true today, and not a property of the mode. Reading the store's own
198
+ settings makes it a property of the mode.
199
+
200
+ Falls back to the defaults if the settings cannot be read, because a
201
+ consolidation pass that cannot start is worse than one that starts with the
202
+ documented defaults.
203
+ """
204
+ from superlocalmemory.core.config import CCQConfig as _CCQConfig
205
+
206
+ try:
207
+ from superlocalmemory.core.config import SLMConfig
208
+
209
+ resolved = getattr(SLMConfig.load(), "ccq", None)
210
+ if isinstance(resolved, _CCQConfig):
211
+ return resolved
212
+ except Exception as exc: # noqa: BLE001
213
+ logger.debug("consolidator: using default settings (%s)", exc)
214
+ return _CCQConfig()
215
+
216
+
190
217
  class CognitiveConsolidator:
191
218
  """CCQ engine: sleep-time consolidation with quantization.
192
219
 
@@ -203,12 +230,10 @@ class CognitiveConsolidator:
203
230
  llm: LLM | None = None,
204
231
  config: CCQConfig | None = None,
205
232
  ) -> None:
206
- from superlocalmemory.core.config import CCQConfig as _CCQConfig
207
-
208
233
  self._db = db
209
234
  self._embedder = embedder
210
235
  self._llm = llm
211
- self._config = config or _CCQConfig()
236
+ self._config = config if config is not None else _config_for_this_store()
212
237
 
213
238
  # ------------------------------------------------------------------
214
239
  # Public API
@@ -327,6 +352,17 @@ class CognitiveConsolidator:
327
352
  AND r.lifecycle_zone IN ('warm', 'cold')
328
353
  AND r.retention_score < ?
329
354
  AND f.lifecycle != 'forgotten'
355
+ -- Withheld rows are not candidates, and this is not tidiness.
356
+ -- 304 of them remain warm/cold in atomic_facts after 4.0.10
357
+ -- withholds them, and each still carries its cluster's POOLED
358
+ -- canonical_entities_json. CCQ clusters on entity overlap, so one
359
+ -- withheld summary naming ten entities joins a cluster of real
360
+ -- memories, contributes model prose to the gist, and then this
361
+ -- pass archives every source in the cluster -- including the real
362
+ -- memories, at scores M043's restore would not bring back.
363
+ -- Exactly the damage this release exists to stop, on a path the
364
+ -- release did not otherwise touch.
365
+ AND COALESCE(f.quarantined, 0) = 0
330
366
  AND f.fact_id NOT IN (
331
367
  SELECT je.value
332
368
  FROM ccq_consolidated_blocks ccb,
@@ -494,7 +530,10 @@ class CognitiveConsolidator:
494
530
  f"SELECT fact_id, content, importance, confidence, "
495
531
  f" canonical_entities_json "
496
532
  f"FROM atomic_facts "
497
- f"WHERE fact_id IN ({placeholders}) AND profile_id = ?",
533
+ f"WHERE fact_id IN ({placeholders}) AND profile_id = ? "
534
+ # Belt and braces with the identify query above: a cluster assembled
535
+ # before a row was withheld must not contribute its text to a gist.
536
+ f" AND COALESCE(quarantined, 0) = 0",
498
537
  (*cluster.fact_ids, profile_id),
499
538
  )
500
539
 
@@ -516,10 +555,15 @@ class CognitiveConsolidator:
516
555
  representative_fact_id="",
517
556
  )
518
557
 
519
- # Try LLM mode (Mode B) if configured
558
+ # Configuration first, then availability. Mode A sets use_llm_gist=False,
559
+ # so this refuses because the mode says to — not because an LLM happened
560
+ # not to be passed in. The previous order tested availability first,
561
+ # which meant a caller that supplied a model to a Mode A engine would
562
+ # have got an LLM call on a mode whose whole promise is that nothing
563
+ # does.
520
564
  if (
521
- self._llm is not None
522
- and self._config.use_llm_gist
565
+ self._config.use_llm_gist
566
+ and self._llm is not None
523
567
  ):
524
568
  try:
525
569
  gist = self._extract_gist_llm(
@@ -98,7 +98,7 @@ class ContextGenerator:
98
98
  FactType.EPISODIC: "episodic event",
99
99
  FactType.SEMANTIC: "semantic knowledge",
100
100
  FactType.OPINION: "opinion or preference",
101
- FactType.TEMPORAL: "time-bounded event",
101
+ FactType.PROSPECTIVE: "time-bounded event",
102
102
  }
103
103
  type_label = type_labels.get(fact.fact_type, "memory")
104
104
 
@@ -223,6 +223,39 @@ def _guess_entity_type(name: str) -> str:
223
223
  # Entity Resolver
224
224
  # ---------------------------------------------------------------------------
225
225
 
226
+ def _unproject_entity(entity_id: str) -> bool:
227
+ """Remove an entity node from the graph projection, if there is one.
228
+
229
+ The projection is a separate storage engine, so deleting the row from
230
+ SQLite reaches nothing there. An entity node left behind still carries the
231
+ name it was created with, which is exactly the thing an erasure was asked
232
+ to remove.
233
+
234
+ Returns False when a projection exists and refused, so a caller that
235
+ reports completeness can report this honestly. No projection at all is not
236
+ a failure -- there is nothing to remove.
237
+ """
238
+ try:
239
+ from superlocalmemory.core.backend_orchestrator import get_orchestrator
240
+ except Exception: # noqa: BLE001
241
+ return True
242
+ try:
243
+ orchestrator = get_orchestrator()
244
+ graph = orchestrator.get_graph_backend() if orchestrator else None
245
+ if graph is None:
246
+ return True
247
+ remove = getattr(graph, "remove_entity", None)
248
+ if remove is None:
249
+ return True
250
+ remove(entity_id)
251
+ return True
252
+ except Exception as exc: # noqa: BLE001
253
+ logger.warning(
254
+ "the graph projection still holds entity %s (%s)", entity_id[:12], exc,
255
+ )
256
+ return False
257
+
258
+
226
259
  class EntityResolver:
227
260
  """Resolves raw entity mentions to persisted canonical entities.
228
261
 
@@ -444,6 +477,11 @@ class EntityResolver:
444
477
  "DELETE FROM canonical_entities WHERE entity_id = ? AND profile_id = ?",
445
478
  (entity_id_merge, profile_id),
446
479
  )
480
+ # The merged-away node has its own copy in the graph projection, and
481
+ # deleting the row above does not reach it. Left there it keeps its own
482
+ # name and its own edges, so the two halves of a merged entity stay
483
+ # visible to a graph query as two entities.
484
+ _unproject_entity(entity_id_merge)
447
485
  logger.info(
448
486
  "Merged entity %s into %s (profile=%s)",
449
487
  entity_id_merge, entity_id_keep, profile_id,
@@ -17,7 +17,7 @@ the majority of benchmark score differences.
17
17
  Key patterns implemented:
18
18
  - Conversation chunking (5-10 turns, 2-turn overlap)
19
19
  - Three-date temporal model (observation, referenced, interval)
20
- - Typed fact classification (episodic / semantic / opinion / temporal)
20
+ - Typed fact classification (episodic / semantic / opinion / prospective)
21
21
  - Importance scoring (entity frequency + emotional markers + recency)
22
22
  - Narrative fact extraction in LLM modes (self-contained, context-rich)
23
23
 
@@ -34,6 +34,7 @@ import uuid
34
34
  from typing import Any, Protocol, runtime_checkable
35
35
 
36
36
  from superlocalmemory.core.config import EncodingConfig
37
+ from superlocalmemory.encoding.prospective_markers import looks_prospective
37
38
  from superlocalmemory.storage.models import AtomicFact, FactType, Mode, SignalType
38
39
 
39
40
  logger = logging.getLogger(__name__)
@@ -107,13 +108,6 @@ _EXPERIENCE_MARKERS = re.compile(
107
108
  re.IGNORECASE,
108
109
  )
109
110
 
110
- _TEMPORAL_MARKERS = re.compile(
111
- r"\b(?:deadline|due date|expires?|scheduled|appointment|meeting|"
112
- r"on \w+day|at \d{1,2}:\d{2}|by \w+|until|before|after|"
113
- r"in \d+ (?:days?|weeks?|months?|years?)|"
114
- r"next week|next month|this weekend|tomorrow|yesterday)\b",
115
- re.IGNORECASE,
116
- )
117
111
 
118
112
  _EMOTIONAL_KEYWORDS = frozenset({
119
113
  "love", "hate", "amazing", "terrible", "wonderful", "awful", "excited",
@@ -153,7 +147,7 @@ _SYSTEM_PROMPT = (
153
147
  "- episodic: personal event or experience (visited, attended, did)\n"
154
148
  "- semantic: objective fact about the world (jobs, locations, relations)\n"
155
149
  "- opinion: subjective belief or preference (likes, thinks, prefers)\n"
156
- "- temporal: time-bound fact with dates or deadlines\n\n"
150
+ "- prospective: something planned for later, with a date or deadline\n\n"
157
151
  "Respond ONLY with a JSON array. Example:\n"
158
152
  '[{"text":"Alice works at Google as a software engineer",'
159
153
  '"fact_type":"semantic","entities":["Alice","Google"],'
@@ -320,11 +314,18 @@ def _extract_entities(text: str) -> list[str]:
320
314
 
321
315
 
322
316
  def _classify_sentence(sentence: str) -> FactType:
323
- """Classify a sentence into a FactType using keyword markers."""
324
- if _TEMPORAL_MARKERS.search(sentence):
325
- return FactType.TEMPORAL
317
+ """Classify a sentence into a FactType using keyword markers.
318
+
319
+ Opinion is asked first, and the router asks in the same order. They used to
320
+ differ, so "I think we should ship next week" was a plan on one path and an
321
+ opinion on the other, for the same sentence. Opinion wins because an opinion
322
+ about a plan is not a commitment, and the list of what is coming up should
323
+ hold commitments.
324
+ """
326
325
  if _OPINION_MARKERS.search(sentence):
327
326
  return FactType.OPINION
327
+ if looks_prospective(sentence):
328
+ return FactType.PROSPECTIVE
328
329
  if _EXPERIENCE_MARKERS.search(sentence):
329
330
  return FactType.EPISODIC
330
331
  return FactType.SEMANTIC
@@ -370,7 +371,7 @@ def _signal_from_fact_type(ft: FactType) -> SignalType:
370
371
  FactType.EPISODIC: SignalType.FACTUAL,
371
372
  FactType.SEMANTIC: SignalType.FACTUAL,
372
373
  FactType.OPINION: SignalType.OPINION,
373
- FactType.TEMPORAL: SignalType.TEMPORAL,
374
+ FactType.PROSPECTIVE: SignalType.TEMPORAL,
374
375
  }
375
376
  return mapping.get(ft, SignalType.FACTUAL)
376
377
 
@@ -770,7 +771,10 @@ class FactExtractor:
770
771
  "semantic": FactType.SEMANTIC,
771
772
  "world": FactType.SEMANTIC,
772
773
  "opinion": FactType.OPINION,
773
- "temporal": FactType.TEMPORAL,
774
+ "prospective": FactType.PROSPECTIVE,
775
+ # Kept alongside the current word for the reason given in
776
+ # type_router: an unrecognised type falls through to SEMANTIC.
777
+ "temporal": FactType.PROSPECTIVE,
774
778
  }
775
779
  fact_type = type_map.get(raw_type, FactType.SEMANTIC)
776
780