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.
- package/CHANGELOG.md +75 -0
- package/README.md +3 -3
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +45 -2
- package/src/superlocalmemory/cli/main.py +2 -2
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
- package/src/superlocalmemory/core/fact_consolidator.py +316 -125
- package/src/superlocalmemory/core/maintenance.py +44 -6
- package/src/superlocalmemory/core/memory_health.py +266 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
- package/src/superlocalmemory/core/operation_request.py +1 -1
- package/src/superlocalmemory/core/ops_remediation.py +2 -2
- package/src/superlocalmemory/core/store_pipeline.py +78 -3
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +15 -1
- package/src/superlocalmemory/mcp/server.py +1 -1
- package/src/superlocalmemory/mcp/session_binding.py +92 -0
- package/src/superlocalmemory/mcp/tools_core.py +40 -39
- package/src/superlocalmemory/mcp/tools_ops.py +2 -2
- package/src/superlocalmemory/retrieval/bm25_channel.py +4 -8
- package/src/superlocalmemory/retrieval/entity_channel.py +7 -1
- package/src/superlocalmemory/retrieval/scope_policy.py +22 -1
- package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
- package/src/superlocalmemory/retrieval/vector_store.py +63 -0
- package/src/superlocalmemory/server/api.py +6 -1
- package/src/superlocalmemory/server/asset_versions.py +171 -0
- package/src/superlocalmemory/server/routes/abstraction.py +201 -0
- package/src/superlocalmemory/server/routes/data_io.py +29 -1
- package/src/superlocalmemory/server/routes/entity.py +13 -1
- package/src/superlocalmemory/server/routes/mesh.py +1 -1
- package/src/superlocalmemory/server/routes/v3_api.py +2 -2
- package/src/superlocalmemory/server/ui.py +8 -1
- package/src/superlocalmemory/server/unified_daemon.py +111 -9
- package/src/superlocalmemory/storage/_migration_internals.py +4 -0
- package/src/superlocalmemory/storage/database.py +128 -30
- package/src/superlocalmemory/storage/migration_runner.py +11 -0
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
- package/src/superlocalmemory/storage/schema.py +98 -0
- package/src/superlocalmemory/summaries/base.py +1 -1
- package/src/superlocalmemory/summaries/non_answer.py +223 -0
- package/src/superlocalmemory/ui/index.html +1 -1
- package/src/superlocalmemory/ui/js/od-memories.js +190 -1
- 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
|
|
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"
|