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
@@ -0,0 +1,488 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ """M043 — take model-written summaries out of the retrieval corpus.
4
+
5
+ Runs unattended on upgrade. Three steps, in this order, all idempotent:
6
+
7
+ 1. PRESERVE — copy every consolidator-authored row's text into
8
+ ``consolidated_summaries``, the display-only table, so nothing the user
9
+ could see today disappears from view.
10
+ 2. WITHHOLD — set ``atomic_facts.quarantined = 1`` on those rows. They stay
11
+ on disk, with their provenance, and stop being answers.
12
+ 3. RESTORE — un-hide genuine memories whose retention row says they are
13
+ maximally retained but whose zone says 'archive'.
14
+
15
+ WHY THIS IS A MIGRATION AND NOT A ONE-OFF SCRIPT
16
+ ------------------------------------------------
17
+ About three quarters of this product's users are not engineers. A repair that
18
+ requires reading a runbook, cloning a repository or running SQL is a repair
19
+ they will not get. The store is the whole point of the product, so a defective
20
+ store has to fix itself on the next start, whether they installed by pip, npm
21
+ or from source.
22
+
23
+ DEFERRED, NOT EAGER
24
+ -------------------
25
+ ``atomic_facts`` is bootstrapped by ``MemoryEngine.initialize()``, which runs
26
+ *after* ``apply_all``. Every existing migration that touches it — M011, M013,
27
+ M015, M016 — is deferred for that reason and this one is no different. Deferred
28
+ migrations are snapshotted: ``apply_deferred`` takes a verified backup lazily,
29
+ immediately before the first migration that will actually apply
30
+ (``migration_runner.py``, ``_ensure_snapshot``), using the sqlite3 backup API
31
+ rather than a file copy, which cannot snapshot a live WAL database.
32
+
33
+ WHAT IT IS REPAIRING, MEASURED
34
+ ------------------------------
35
+ On the author's 5,089-fact store, before this ran:
36
+
37
+ rows written by the consolidator into atomic_facts 1,195
38
+ ...retrieval-eligible (retention zone not archive) 307
39
+ ...that are summaries of other summaries 353
40
+ ...carrying a temporal_events row 0
41
+ ...violating the declared FK to memories 1,195
42
+ genuine memories archived by consolidation 528
43
+ ...archived by anything other than consolidation 0
44
+ ...whose retention score is 1.0 (i.e. "keep") 528
45
+
46
+ Asked "what am I working on", ranks 1, 2 and 3 all read "Unfortunately, there
47
+ is no information available about 'Gateway', 'State', 'Bounded', or 'Claude' in
48
+ the provided text."
49
+
50
+ THE THREE PREDICATES, AND WHY EACH IS EXACT
51
+ -------------------------------------------
52
+ **Which rows are consolidator-authored.** ``memory_id = ''`` *and* present in
53
+ ``fact_consolidations``. Verified to agree exactly on the real store: 1,195
54
+ either way, zero rows on either difference, and zero genuine facts with a
55
+ dangling ``memory_id`` that could be swept up by accident. Both halves are
56
+ required so that a future fact with an empty ``memory_id`` — or a consolidation
57
+ record pointing at a real fact — is left alone.
58
+
59
+ Content matching was considered and rejected. Only 34 of the 307
60
+ retrieval-eligible rows read as refusals; the other 273 are fluent, plausible,
61
+ generic prose that no honest predicate separates from a real summary. Repairing
62
+ by content would have cleared a ninth of the problem and looked finished.
63
+
64
+ **Which memories to un-hide.** ``lifecycle_zone IN ('archive','forgotten')``
65
+ together with ``retention_score > 0.8``. That combination is a contradiction on
66
+ its own terms: ``math/ebbinghaus.py:lifecycle_zone`` maps any score above 0.8 to
67
+ 'active', so a row scoring 1.0 and filed under 'archive' was put there by
68
+ something that did not consult the score. Consolidation was that something, but
69
+ the predicate does not need to know it — which is what makes it safe to re-run
70
+ forever, and self-limiting: it describes an inconsistency, so it empties itself.
71
+
72
+ The restored zone is *recomputed* from the score with the same thresholds, not
73
+ guessed. Guessing 'warm' would have quietly demoted 528 facts that the maths
74
+ already called 'active'.
75
+
76
+ **Which summaries to preserve.** All of them, including the refusals. A reader
77
+ looking at their dashboard should see what their store actually generated;
78
+ silently dropping the embarrassing ones would hide the problem this repair
79
+ exists to fix. W5 renders them, and its own tests cover not showing junk as
80
+ though it were insight.
81
+ """
82
+
83
+ from __future__ import annotations
84
+
85
+ import logging
86
+ import sqlite3
87
+
88
+ logger = logging.getLogger(__name__)
89
+
90
+ NAME = "M043_quarantine_display_summaries"
91
+ DB_TARGET = "memory"
92
+
93
+ #: Kept short and STABLE. The runner hashes this text and fails a completed
94
+ #: migration whose hash has drifted, so it must not be edited to track changes
95
+ #: in ``apply()``. The work lives in ``apply()`` because it is conditional data
96
+ #: movement that one DDL script cannot express.
97
+ DDL = "-- M043: see apply(); preserve, withhold, restore"
98
+
99
+
100
+ #: Retention thresholds, mirroring ``math/ebbinghaus.py::lifecycle_zone`` and
101
+ #: ``ForgettingConfig`` defaults (archive_threshold 0.2, forget_threshold 0.05).
102
+ #: A migration is SQL and cannot read the user's config, so these are the
103
+ #: shipped defaults. That is safe here because this expression is only ever
104
+ #: applied to rows whose score is already above 0.8 — the top branch — where no
105
+ #: threshold below it can change the answer.
106
+ _ZONE_FROM_SCORE = """
107
+ CASE
108
+ WHEN retention_score > 0.8 THEN 'active'
109
+ WHEN retention_score > 0.5 THEN 'warm'
110
+ WHEN retention_score > 0.2 THEN 'cold'
111
+ WHEN retention_score > 0.05 THEN 'archive'
112
+ ELSE 'forgotten'
113
+ END
114
+ """
115
+
116
+ #: Rows the fact consolidator wrote directly into the retrieval corpus.
117
+ _CONSOLIDATOR_ROWS = """
118
+ SELECT af.fact_id
119
+ FROM atomic_facts af
120
+ WHERE af.memory_id = ''
121
+ AND af.fact_id IN (
122
+ SELECT consolidated_fact_id FROM fact_consolidations
123
+ )
124
+ """
125
+
126
+ #: A genuine memory that consolidation hid.
127
+ #:
128
+ #: BY PROVENANCE, NOT BY SCORE. The first version of this required
129
+ #: ``retention_score > 0.8``, reasoning that a score above 0.8 maps to 'active'
130
+ #: so zone 'archive' must be a contradiction. Every one of the 528 hidden
131
+ #: memories on the author's store scored exactly 1.0, so it worked there — and
132
+ #: that store is the LUCKY shape. Those rows scored 1.0 because they had no
133
+ #: prior retention row and the schema default is 1.0.
134
+ #:
135
+ #: On a store where the forgetting scheduler has been running (the default,
136
+ #: ``ForgettingConfig.enabled = True``) a consolidation victim carries whatever
137
+ #: score it had drifted to — typically 0.21 to 0.80 — because
138
+ #: ``set_fact_lifecycle_zone`` moves the zone and leaves the score alone. The
139
+ #: score-based predicate does not see those rows at all: they stay archived,
140
+ #: ``verify()`` returns true because it cannot see them either, and ``doctor``
141
+ #: reports healthy while the memories remain unreachable. A repair that is
142
+ #: silent about what it failed to repair is worse than one that fails loudly.
143
+ #:
144
+ #: So the test is the same one the withhold step uses: was this row a
145
+ #: consolidation source? That is recorded, and it does not depend on a number
146
+ #: that something else was free to change afterwards.
147
+ #:
148
+ #: Self-correcting rather than indiscriminate: the zone is RECOMPUTED from the
149
+ #: score, so a source that has genuinely faded maps straight back to
150
+ #: archive/forgotten and nothing moves. Only a row whose own score says it
151
+ #: should be reachable becomes reachable.
152
+ _WRONGLY_HIDDEN_BASE = """
153
+ SELECT r.fact_id
154
+ FROM fact_retention r
155
+ JOIN atomic_facts af ON af.fact_id = r.fact_id
156
+ WHERE r.lifecycle_zone IN ('archive', 'forgotten')
157
+ AND af.memory_id <> ''
158
+ AND ({extra})
159
+ """
160
+
161
+ #: The provenance half. Only usable when the ledger exists, which is why this
162
+ #: is composed at call time rather than being one constant: a first version
163
+ #: baked the subquery in and ``verify()`` then raised "no such table:
164
+ #: fact_consolidations" on a store without a ledger -- caught by the test for
165
+ #: exactly that store shape.
166
+ _ARCHIVED_BY_CONSOLIDATION = """
167
+ af.fact_id IN (
168
+ SELECT je.value
169
+ FROM fact_consolidations fc, json_each(fc.source_fact_ids) je
170
+ WHERE json_valid(fc.source_fact_ids)
171
+ )
172
+ """
173
+
174
+ #: The score half: hidden while the retention maths says to keep.
175
+ _SCORED_TO_KEEP = "r.retention_score > 0.8"
176
+
177
+
178
+ def _wrongly_hidden(conn: sqlite3.Connection) -> str:
179
+ """The restore predicate, using whichever halves this store supports."""
180
+ if _table_exists(conn, "fact_consolidations"):
181
+ return _WRONGLY_HIDDEN_BASE.format(
182
+ extra=f"{_SCORED_TO_KEEP} OR {_ARCHIVED_BY_CONSOLIDATION}"
183
+ )
184
+ return _WRONGLY_HIDDEN_BASE.format(extra=_SCORED_TO_KEEP)
185
+
186
+
187
+ def apply(conn: sqlite3.Connection) -> None:
188
+ """Preserve, withhold, restore. Atomic, and safe to run again."""
189
+ if not _table_exists(conn, "atomic_facts"):
190
+ # Nothing to repair on a store whose corpus does not exist yet. Not an
191
+ # error: a fresh install reaches this migration with the table created
192
+ # moments earlier, and a store older than the table has no rows to fix.
193
+ logger.debug("M043: atomic_facts absent, nothing to repair")
194
+ return
195
+ if not _has_column(conn, "atomic_facts", "quarantined"):
196
+ # storage.schema.create_all_tables adds it at every engine init, so
197
+ # reaching here means engine init has not run against this store.
198
+ # Adding it is cheap and keeps the migration independent of that order.
199
+ conn.execute(
200
+ "ALTER TABLE atomic_facts ADD COLUMN quarantined "
201
+ "INTEGER NOT NULL DEFAULT 0"
202
+ )
203
+ if not _table_exists(conn, "consolidated_summaries"):
204
+ # UNCONDITIONALLY, because verify() requires it and verify() is what the
205
+ # runner consults on every later start.
206
+ #
207
+ # A first draft created it only inside _preserve, which is skipped when
208
+ # there is no provenance ledger. On a store with no ledger the migration
209
+ # therefore applied, recorded 'complete', and then failed verify() on
210
+ # the NEXT start -- and since repair() is apply(), it failed again and
211
+ # was reported as a failed migration forever. Two existing
212
+ # idempotency tests caught it. The lesson generalises: everything
213
+ # verify() asserts has to be produced on every path through apply(),
214
+ # not only on the path that happens to need it.
215
+ #
216
+ # The DDL is imported, not restated, so the two definitions cannot
217
+ # drift apart.
218
+ from superlocalmemory.storage.schema import CONSOLIDATED_SUMMARIES_DDL
219
+
220
+ conn.executescript(CONSOLIDATED_SUMMARIES_DDL)
221
+
222
+ # No provenance ledger means no consolidator-authored rows can be
223
+ # identified, so there is nothing to preserve or withhold. The restore step
224
+ # is independent of it and still runs — a store can have wrongly-hidden
225
+ # memories without having the ledger that explains how they got that way.
226
+ #
227
+ # Written as a skip rather than an error after the first draft raised here
228
+ # and broke three existing migration-runner tests, which drive
229
+ # apply_deferred against minimal fixtures. A repair that only works on a
230
+ # fully-bootstrapped store is a repair that will not run on the store that
231
+ # needs it most.
232
+ has_ledger = _table_exists(conn, "fact_consolidations")
233
+
234
+ conn.execute("BEGIN IMMEDIATE")
235
+ try:
236
+ preserved = _preserve(conn) if has_ledger else 0
237
+ withheld = _withhold(conn) if has_ledger else 0
238
+ restored = _restore(conn)
239
+ conn.execute("COMMIT")
240
+ except sqlite3.Error:
241
+ try:
242
+ conn.execute("ROLLBACK")
243
+ except sqlite3.Error: # pragma: no cover — best effort
244
+ pass
245
+ raise
246
+
247
+ # INFO, not debug. This changes what the user's next recall returns, so it
248
+ # belongs in the log they can actually see.
249
+ if preserved or withheld or restored:
250
+ logger.info(
251
+ "M043 memory repair: %d summaries preserved for display, "
252
+ "%d withheld from recall, %d memories restored to recall",
253
+ preserved, withheld, restored,
254
+ )
255
+
256
+
257
+ def _preserve(conn: sqlite3.Connection) -> int:
258
+ """Copy consolidator rows into the display table. Returns rows added."""
259
+ before = _count(conn, "SELECT COUNT(*) FROM consolidated_summaries")
260
+ # The entity a summary was written about is not recoverable from the row —
261
+ # entities_json holds the whole cluster's pool — so entity_id is left empty
262
+ # and entity_name carries the summary's own leading label when it has one.
263
+ # source_fact_ids comes from the provenance ledger, which does know.
264
+ conn.execute("""
265
+ INSERT INTO consolidated_summaries
266
+ (summary_id, profile_id, entity_id, entity_name, content,
267
+ source_fact_ids, source_count, char_count, generated_by,
268
+ scope, shared_with, source_earliest, source_latest, created_at)
269
+ SELECT af.fact_id,
270
+ af.profile_id,
271
+ '',
272
+ '',
273
+ af.content,
274
+ COALESCE(
275
+ (SELECT fc.source_fact_ids FROM fact_consolidations fc
276
+ WHERE fc.consolidated_fact_id = af.fact_id
277
+ AND json_valid(fc.source_fact_ids)
278
+ ORDER BY fc.created_at DESC LIMIT 1),
279
+ '[]'
280
+ ),
281
+ -- Count only sources that are the owner's OWN memories.
282
+ --
283
+ -- Two wrong answers were tried first. evidence_count reached 450
284
+ -- on one row, because the old reinforce-or-insert path bumped it
285
+ -- every time it saw the same summary text again. The length of
286
+ -- the provenance array is 10, which is closer but still a lie:
287
+ -- 353 of these summaries are summaries OF SUMMARIES, and on the
288
+ -- author's store the first row's ten "sources" are all
289
+ -- consolidator rows written 10-20 seconds apart. Reporting that
290
+ -- as "10 memories summarised" tells the owner this is a digest
291
+ -- of ten things they said, when it is a digest of ten things the
292
+ -- summarizer said.
293
+ --
294
+ -- So: count the sources that have a parent memory. A
295
+ -- summary-of-summaries therefore reports 0, which is the honest
296
+ -- number, and the endpoint ranks it below one that covers real
297
+ -- memories.
298
+ -- json_valid guards every json_each in this file. Without it
299
+ -- ONE malformed source_fact_ids anywhere in the ledger makes
300
+ -- json_each raise, apply() roll back, verify() stay false, and
301
+ -- repair() re-raise on every start for the life of the store —
302
+ -- a single bad row taking the whole repair down with it.
303
+ (SELECT COUNT(*) FROM atomic_facts src
304
+ WHERE src.memory_id <> ''
305
+ AND src.fact_id IN (
306
+ SELECT je.value FROM fact_consolidations fc,
307
+ json_each(fc.source_fact_ids) je
308
+ WHERE fc.consolidated_fact_id = af.fact_id
309
+ AND json_valid(fc.source_fact_ids))),
310
+ LENGTH(af.content),
311
+ 'migrated',
312
+ COALESCE(af.scope, 'personal'),
313
+ af.shared_with,
314
+ -- Coverage window over those same genuine sources only.
315
+ -- A first draft spanned every source and produced
316
+ -- "2026-08-18 to 2026-08-18" on a row whose sources were ten
317
+ -- summaries written seconds apart -- the span of a summarisation
318
+ -- run, presented as the stretch of work it covers. NULL when
319
+ -- there are no genuine sources, and the dashboard then shows no
320
+ -- span rather than a false one.
321
+ (SELECT MIN(src.created_at) FROM atomic_facts src
322
+ WHERE src.memory_id <> ''
323
+ AND src.fact_id IN (
324
+ SELECT je.value FROM fact_consolidations fc,
325
+ json_each(fc.source_fact_ids) je
326
+ WHERE fc.consolidated_fact_id = af.fact_id
327
+ AND json_valid(fc.source_fact_ids))),
328
+ (SELECT MAX(src.created_at) FROM atomic_facts src
329
+ WHERE src.memory_id <> ''
330
+ AND src.fact_id IN (
331
+ SELECT je.value FROM fact_consolidations fc,
332
+ json_each(fc.source_fact_ids) je
333
+ WHERE fc.consolidated_fact_id = af.fact_id
334
+ AND json_valid(fc.source_fact_ids))),
335
+ af.created_at
336
+ FROM atomic_facts af
337
+ WHERE af.fact_id IN (""" + _CONSOLIDATOR_ROWS + """)
338
+ -- UNTARGETED. `ON CONFLICT (profile_id, entity_id, content)` names one
339
+ -- constraint and raises on any other, and this table has two: that
340
+ -- unique triple, and summary_id as primary key. summary_id is the
341
+ -- source fact_id, so a row whose CONTENT changed between runs conflicts
342
+ -- on the primary key, which the targeted form does not catch --
343
+ -- reproduced as `IntegrityError: UNIQUE constraint failed:
344
+ -- consolidated_summaries.summary_id`. Because the runner calls
345
+ -- repair() (which is apply()) whenever verify() fails, that failure
346
+ -- would then repeat on every single start, forever.
347
+ --
348
+ -- Untargeted DO NOTHING absorbs both. Losing the newer text is the
349
+ -- right trade: this is a display copy of a row that is about to be
350
+ -- withheld, and the first copy taken is the one the owner has already
351
+ -- been shown.
352
+ ON CONFLICT DO NOTHING
353
+ """)
354
+ return _count(conn, "SELECT COUNT(*) FROM consolidated_summaries") - before
355
+
356
+
357
+ def _withhold(conn: sqlite3.Connection) -> int:
358
+ """Mark consolidator rows quarantined. Returns rows changed."""
359
+ cur = conn.execute(
360
+ "UPDATE atomic_facts SET quarantined = 1 "
361
+ "WHERE COALESCE(quarantined, 0) = 0 "
362
+ " AND fact_id IN (" + _CONSOLIDATOR_ROWS + ")"
363
+ )
364
+ return int(cur.rowcount or 0)
365
+
366
+
367
+ def _restore(conn: sqlite3.Connection) -> int:
368
+ """Un-hide memories consolidation archived. Returns rows changed.
369
+
370
+ Needs both tables: ``fact_retention`` to hold the zone, and
371
+ ``fact_consolidations`` for the provenance half of the predicate. Without
372
+ the ledger it falls back to the score-only test, which is strictly weaker
373
+ but is all a store with no ledger can support.
374
+ """
375
+ if not _table_exists(conn, "fact_retention"):
376
+ return 0
377
+ cur = conn.execute(
378
+ "UPDATE fact_retention SET lifecycle_zone = (" + _ZONE_FROM_SCORE + ") "
379
+ "WHERE fact_id IN (" + _wrongly_hidden(conn) + ")"
380
+ )
381
+ changed = int(cur.rowcount or 0)
382
+ if changed:
383
+ _sync_lifecycle_mirror(conn)
384
+ return changed
385
+
386
+
387
+ def _sync_lifecycle_mirror(conn: sqlite3.Connection) -> None:
388
+ """Bring atomic_facts.lifecycle back in line with the canonical zone.
389
+
390
+ Same mapping core/lifecycle_state.py applies: archive/forgotten map to
391
+ 'archived', every other zone keeps its own name.
392
+ """
393
+ conn.execute("""
394
+ UPDATE atomic_facts
395
+ SET lifecycle = (
396
+ SELECT CASE
397
+ WHEN r.lifecycle_zone IN ('archive', 'forgotten')
398
+ THEN 'archived'
399
+ ELSE r.lifecycle_zone
400
+ END
401
+ FROM fact_retention r
402
+ WHERE r.fact_id = atomic_facts.fact_id
403
+ )
404
+ WHERE lifecycle = 'archived'
405
+ AND fact_id IN (
406
+ SELECT fact_id FROM fact_retention
407
+ WHERE lifecycle_zone NOT IN ('archive', 'forgotten')
408
+ )
409
+ """)
410
+
411
+
412
+ def verify(conn: sqlite3.Connection) -> bool:
413
+ """Whether the repair's end-state holds.
414
+
415
+ Called on every start for an already-complete migration. Returning False
416
+ routes to ``repair()``, which makes this a standing guard: if pollution ever
417
+ reappears, the next daemon start withholds it without anyone asking.
418
+ """
419
+ if not _table_exists(conn, "atomic_facts"):
420
+ return True
421
+ if not _has_column(conn, "atomic_facts", "quarantined"):
422
+ return False
423
+ if not _table_exists(conn, "consolidated_summaries"):
424
+ return False
425
+
426
+ if _table_exists(conn, "fact_consolidations"):
427
+ unwithheld = _count(
428
+ conn,
429
+ "SELECT COUNT(*) FROM atomic_facts WHERE COALESCE(quarantined, 0) = 0 "
430
+ " AND fact_id IN (" + _CONSOLIDATOR_ROWS + ")",
431
+ )
432
+ if unwithheld:
433
+ return False
434
+
435
+ # Every withheld row must still be visible somewhere, or the repair has
436
+ # deleted the owner's view of it rather than moved it.
437
+ #
438
+ # BY IDENTITY *OR* CONTENT, and the "or" is what makes this an invariant
439
+ # rather than a trap. Matching on content alone could never become true
440
+ # once a row's content changed after being preserved: the display copy
441
+ # keeps the old text, verify stays false, repair() runs apply() again,
442
+ # apply() cannot change the past, and the migration is reported failed
443
+ # on every start for the rest of the store's life. Matching on identity
444
+ # alone fails the other way, because two withheld rows with identical
445
+ # text collapse into one display row under the unique triple, leaving the
446
+ # second with no row of its own id.
447
+ #
448
+ # Either match satisfies the guarantee that actually matters: nothing
449
+ # the owner could see has stopped being visible.
450
+ unpreserved = _count(conn, """
451
+ SELECT COUNT(*) FROM atomic_facts af
452
+ WHERE af.fact_id IN (""" + _CONSOLIDATOR_ROWS + """)
453
+ AND NOT EXISTS (
454
+ SELECT 1 FROM consolidated_summaries cs
455
+ WHERE cs.profile_id = af.profile_id
456
+ AND (cs.summary_id = af.fact_id
457
+ OR cs.content = af.content)
458
+ )
459
+ """)
460
+ if unpreserved:
461
+ return False
462
+
463
+ if _table_exists(conn, "fact_retention"):
464
+ if _count(conn, "SELECT COUNT(*) FROM (" + _wrongly_hidden(conn) + ")"):
465
+ return False
466
+ return True
467
+
468
+
469
+ def repair(conn: sqlite3.Connection) -> None:
470
+ """Re-run the repair. It is idempotent, so this is simply apply()."""
471
+ apply(conn)
472
+
473
+
474
+ def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
475
+ return conn.execute(
476
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,),
477
+ ).fetchone() is not None
478
+
479
+
480
+ def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
481
+ return any(
482
+ row[1] == column for row in conn.execute(f"PRAGMA table_info({table})")
483
+ )
484
+
485
+
486
+ def _count(conn: sqlite3.Connection, sql: str) -> int:
487
+ row = conn.execute(sql).fetchone()
488
+ return int(row[0]) if row else 0
@@ -21,9 +21,12 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
+ import logging
24
25
  import sqlite3
25
26
  from typing import Final
26
27
 
28
+ logger = logging.getLogger(__name__)
29
+
27
30
  # ---------------------------------------------------------------------------
28
31
  # Constants
29
32
  # ---------------------------------------------------------------------------
@@ -57,6 +60,7 @@ _TABLES: Final[tuple[str, ...]] = (
57
60
  "config",
58
61
  "entity_communities",
59
62
  "community_summaries",
63
+ "consolidated_summaries",
60
64
  "persona_summary",
61
65
  )
62
66
 
@@ -190,6 +194,11 @@ CREATE TABLE IF NOT EXISTS atomic_facts (
190
194
  CHECK (lifecycle IN (
191
195
  'active', 'warm', 'cold', 'archived'
192
196
  )),
197
+ -- Withheld from retrieval without being destroyed. Set by repair, never
198
+ -- by a normal write; enforced in exactly one place,
199
+ -- DatabaseManager.get_facts_by_ids, which every channel's candidates are
200
+ -- re-authorised through and which the engine hydrates from.
201
+ quarantined INTEGER NOT NULL DEFAULT 0,
193
202
  langevin_position TEXT,
194
203
 
195
204
  -- Emotional
@@ -842,6 +851,51 @@ CREATE INDEX IF NOT EXISTS idx_comm_summ_profile
842
851
  ON community_summaries(profile_id);
843
852
  """
844
853
 
854
+ # Display-only consolidated summaries.
855
+ #
856
+ # A summary of a cluster of facts is a VIEW of memory, not a memory. Between
857
+ # v3.6.4 and 4.0.9 the fact consolidator wrote its summaries straight into
858
+ # atomic_facts with a raw INSERT, which put model-authored prose into the
859
+ # retrieval corpus alongside the user's own words — where it out-ranked them,
860
+ # because those rows carried every entity in their cluster and so had more
861
+ # entity links than any real fact.
862
+ #
863
+ # This table restores the boundary. community_summaries is the precedent to
864
+ # read it by: written by one owner, read only after retrieval has finished, and
865
+ # named by no channel. tests/test_retrieval/test_summaries_stay_out_of_recall.py
866
+ # fails if a retrieval module so much as mentions it.
867
+ #
868
+ # source_earliest / source_latest are the honest dates for a derived row. A
869
+ # summary has no observation_date of its own — it was never observed — but the
870
+ # span of what it summarises is real, and it is what lets the dashboard say
871
+ # which stretch of work a summary covers.
872
+ CONSOLIDATED_SUMMARIES_DDL: Final[str] = """
873
+ CREATE TABLE IF NOT EXISTS consolidated_summaries (
874
+ summary_id TEXT PRIMARY KEY,
875
+ profile_id TEXT NOT NULL,
876
+ entity_id TEXT NOT NULL DEFAULT '',
877
+ entity_name TEXT NOT NULL DEFAULT '',
878
+ content TEXT NOT NULL,
879
+ source_fact_ids TEXT NOT NULL DEFAULT '[]',
880
+ source_count INTEGER NOT NULL DEFAULT 0,
881
+ char_count INTEGER NOT NULL DEFAULT 0,
882
+ generated_by TEXT NOT NULL DEFAULT 'extractive'
883
+ CHECK (generated_by IN (
884
+ 'extractive', 'ollama', 'cloud', 'migrated'
885
+ )),
886
+ scope TEXT NOT NULL DEFAULT 'personal',
887
+ shared_with TEXT,
888
+ source_earliest TEXT,
889
+ source_latest TEXT,
890
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
891
+ UNIQUE (profile_id, entity_id, content)
892
+ );
893
+ CREATE INDEX IF NOT EXISTS idx_consolidated_summaries_profile
894
+ ON consolidated_summaries(profile_id, created_at DESC);
895
+ CREATE INDEX IF NOT EXISTS idx_consolidated_summaries_entity
896
+ ON consolidated_summaries(profile_id, entity_id);
897
+ """
898
+
845
899
  # Wave Q3: progressive-abstraction top tier — one persona roll-up per profile
846
900
  # consuming the top community summaries (additive; safe on existing DBs).
847
901
  # Recall-gated (never auto-injected into hot recall) and size-bounded to avoid
@@ -893,6 +947,7 @@ _DDL_ORDERED: Final[tuple[str, ...]] = (
893
947
  _SQL_ENTITY_COMMUNITIES,
894
948
  # Wave Q2: community summaries (additive; safe on existing DBs)
895
949
  _SQL_COMMUNITY_SUMMARIES,
950
+ CONSOLIDATED_SUMMARIES_DDL,
896
951
  # Wave Q3: persona roll-up tier (additive; safe on existing DBs)
897
952
  _SQL_PERSONA_SUMMARY,
898
953
  )
@@ -902,6 +957,38 @@ _DDL_ORDERED: Final[tuple[str, ...]] = (
902
957
  # Public API
903
958
  # ---------------------------------------------------------------------------
904
959
 
960
+ #: (table, column, column definition). Applied with ALTER TABLE ADD COLUMN,
961
+ #: which SQLite offers no IF NOT EXISTS form of, so presence is checked first.
962
+ _ADDITIVE_COLUMNS: Final[tuple[tuple[str, str, str], ...]] = (
963
+ ("atomic_facts", "quarantined", "INTEGER NOT NULL DEFAULT 0"),
964
+ )
965
+
966
+
967
+ def _add_missing_columns(conn: sqlite3.Connection) -> None:
968
+ """Add columns that upgraded databases predate. Idempotent.
969
+
970
+ A missing table is not an error: this runs inside create_all_tables, so the
971
+ table is created moments earlier in the same call, and a database old enough
972
+ to lack it entirely has nothing to alter.
973
+ """
974
+ for table, column, definition in _ADDITIVE_COLUMNS:
975
+ try:
976
+ present = any(
977
+ row[1] == column
978
+ for row in conn.execute(f"PRAGMA table_info({table})")
979
+ )
980
+ if not present:
981
+ conn.execute(
982
+ f"ALTER TABLE {table} ADD COLUMN {column} {definition}"
983
+ )
984
+ except sqlite3.Error as exc:
985
+ # Never fatal. A store that cannot take the column keeps working;
986
+ # get_facts_by_ids checks for the column before filtering on it.
987
+ logger.warning(
988
+ "additive column %s.%s not applied: %s", table, column, exc,
989
+ )
990
+
991
+
905
992
  def create_all_tables(conn: sqlite3.Connection) -> None:
906
993
  """Create every table, index, trigger, and FTS virtual table.
907
994
 
@@ -921,6 +1008,17 @@ def create_all_tables(conn: sqlite3.Connection) -> None:
921
1008
  for ddl in V32_DDL:
922
1009
  conn.executescript(ddl)
923
1010
 
1011
+ # Additive columns on tables that predate them.
1012
+ #
1013
+ # CREATE TABLE IF NOT EXISTS cannot add a column to a table that already
1014
+ # exists, so an upgraded database gets the column here rather than only from
1015
+ # a migration. Doing it at every engine init makes the invariant "if
1016
+ # atomic_facts exists then quarantined exists" hold even when the migration
1017
+ # pass failed or was never reached — which matters because withholding a
1018
+ # poisoned row from retrieval must not be contingent on a migration having
1019
+ # succeeded.
1020
+ _add_missing_columns(conn)
1021
+
924
1022
  # Seed schema version on first run.
925
1023
  existing = conn.execute(
926
1024
  "SELECT COUNT(*) AS n FROM schema_version"
@@ -62,7 +62,7 @@ class SummaryResult:
62
62
 
63
63
  # ── coverage constants ──────────────────────────────────────────────────────
64
64
  #
65
- # Use these strings; the acceptance gate checks for their presence
65
+ # Use these strings; the tests check for their presence
66
66
  # and the values must be human-interpretable without this file.
67
67
 
68
68
  COVERAGE_FULL = "full"