superlocalmemory 4.0.10 → 4.1.2

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 (145) hide show
  1. package/.claude-plugin/marketplace.json +12 -2
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +40 -75
  4. package/package.json +6 -3
  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 +357 -18
  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 +24 -2
  44. package/src/superlocalmemory/code_graph/database.py +44 -0
  45. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  46. package/src/superlocalmemory/core/admission.py +231 -11
  47. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  48. package/src/superlocalmemory/core/config.py +90 -11
  49. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  50. package/src/superlocalmemory/core/engine.py +140 -11
  51. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  52. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  53. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  54. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  55. package/src/superlocalmemory/core/mode_capability.py +111 -0
  56. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  57. package/src/superlocalmemory/core/projection_drain.py +380 -0
  58. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  59. package/src/superlocalmemory/core/recall_worker.py +6 -3
  60. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  61. package/src/superlocalmemory/core/scale_engine.py +16 -2
  62. package/src/superlocalmemory/core/score_contract.py +21 -1
  63. package/src/superlocalmemory/core/session_identity.py +85 -0
  64. package/src/superlocalmemory/core/status_contract.py +108 -0
  65. package/src/superlocalmemory/core/worker_pool.py +4 -4
  66. package/src/superlocalmemory/core/working_memory.py +288 -0
  67. package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
  68. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  69. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  70. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  71. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  72. package/src/superlocalmemory/encoding/type_router.py +12 -12
  73. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  74. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  75. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  76. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  77. package/src/superlocalmemory/learning/bandit.py +43 -0
  78. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  79. package/src/superlocalmemory/learning/database.py +60 -3
  80. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  81. package/src/superlocalmemory/learning/feedback.py +3 -1
  82. package/src/superlocalmemory/learning/outcomes.py +47 -16
  83. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  84. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  85. package/src/superlocalmemory/learning/pcos.py +291 -0
  86. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  87. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  88. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  89. package/src/superlocalmemory/mcp/profiles.py +14 -2
  90. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  91. package/src/superlocalmemory/mcp/tools_core.py +31 -3
  92. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  93. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  94. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  95. package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
  96. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  97. package/src/superlocalmemory/retrieval/engine.py +106 -11
  98. package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
  99. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  100. package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
  101. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  102. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  103. package/src/superlocalmemory/server/api.py +24 -5
  104. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  105. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  106. package/src/superlocalmemory/server/recall_health.py +87 -10
  107. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  108. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  109. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  110. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  111. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  112. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  113. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  114. package/src/superlocalmemory/server/routes/memories.py +49 -7
  115. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  116. package/src/superlocalmemory/server/routes/v3_api.py +191 -15
  117. package/src/superlocalmemory/server/ui.py +20 -4
  118. package/src/superlocalmemory/server/unified_daemon.py +241 -7
  119. package/src/superlocalmemory/storage/_migration_internals.py +54 -2
  120. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  121. package/src/superlocalmemory/storage/database.py +477 -59
  122. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  123. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  124. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  125. package/src/superlocalmemory/storage/migration_runner.py +119 -0
  126. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
  127. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  128. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  129. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  130. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  131. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  132. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  133. package/src/superlocalmemory/storage/migrations.py +18 -2
  134. package/src/superlocalmemory/storage/models.py +40 -1
  135. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  136. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  137. package/src/superlocalmemory/storage/schema.py +35 -1
  138. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  139. package/src/superlocalmemory/trust/scorer.py +43 -1
  140. package/src/superlocalmemory/ui/index.html +9 -18
  141. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  142. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  143. package/src/superlocalmemory/ui/js/od-memories.js +19 -0
  144. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  145. package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
@@ -0,0 +1,620 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """M046 — a planned future event is prospective memory, not a temporal one.
5
+
6
+ THE COLLISION
7
+ -------------
8
+ ``FactType.TEMPORAL`` was documented in two places as two different things. The
9
+ type's own comment said "time-bounded events with intervals". The router that
10
+ assigns it says "a scheduled/planned future event" and matches on
11
+ ``scheduled|deadline|appointment|planned|tomorrow``. Those are not the same
12
+ concept: the second is prospective memory — remembering to do something later.
13
+
14
+ Meanwhile the retrieval **channel** named "temporal" scores every fact by date
15
+ proximity regardless of its type. So one word meant a memory type and a scoring
16
+ strategy, and the two met in the fusion step with nothing to distinguish them.
17
+
18
+ Renaming the type is not cosmetic. The surface that answers "what is coming up"
19
+ selects ``WHERE fact_type = 'temporal'`` in raw SQL — a reader looking for
20
+ prospective memory had to know it was filed under a word that also names a
21
+ scoring strategy applied to everything.
22
+
23
+ WHY THE TABLE HAS TO BE REBUILT
24
+ -------------------------------
25
+ ``atomic_facts`` carries ``CHECK (fact_type IN ('episodic','semantic','opinion',
26
+ 'temporal'))``. SQLite cannot alter a CHECK constraint, so a new value requires
27
+ the documented rebuild: create the corrected table, copy, drop, rename.
28
+
29
+ Widening the CHECK to accept both words was considered and rejected. It would
30
+ leave an old process able to keep writing the wrong value and succeed, which is
31
+ the whole failure this release is trying to close.
32
+
33
+ ROWID IS PRESERVED, AND THAT IS NOT OPTIONAL
34
+ --------------------------------------------
35
+ ``atomic_facts_fts`` is an external-content FTS5 index: ``content='atomic_facts',
36
+ content_rowid='rowid'``. The base table's primary key is TEXT, so it is *not* a
37
+ rowid alias — the table has an implicit rowid, and ``SELECT *`` does not include
38
+ it. A copy that lets SQLite assign fresh rowids leaves every FTS entry pointing
39
+ at a different fact than the one it was built from: searches keep working and
40
+ start returning the wrong rows. Nothing raises.
41
+
42
+ So the copy names ``rowid`` explicitly, and the index is rebuilt afterwards as
43
+ well. Either alone would probably do; both cost nothing and the failure mode is
44
+ silent.
45
+
46
+ ALL OR NOTHING
47
+ --------------
48
+ The runner does not wrap ``apply()`` in a transaction — atomicity is opt-in per
49
+ migration, and a migration that drops a table has to opt in. Everything below
50
+ happens inside one ``BEGIN IMMEDIATE``, and the row count is compared before the
51
+ COMMIT: a copy that lost rows raises, which rolls the whole thing back and leaves
52
+ the original table exactly as it was.
53
+
54
+ ``PRAGMA foreign_keys`` is set before the transaction opens, because setting it
55
+ inside one is a silent no-op.
56
+
57
+ FORWARD ONLY
58
+ ------------
59
+ The framework has no rollback. Recovery from a bad outcome is restoring the
60
+ pre-migration snapshot. That is why the backup tooling is a hard precondition
61
+ for this migration rather than a nice-to-have.
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import logging
67
+ import sqlite3
68
+
69
+ logger = logging.getLogger(__name__)
70
+
71
+ NAME = "M046_prospective_memory_has_its_own_name"
72
+ DB_TARGET = "memory"
73
+
74
+ #: A store this migration has touched must not be opened by a build whose
75
+ #: ceiling is below this.
76
+ #:
77
+ #: Declared per-migration rather than left to the runner's end-of-run stamp,
78
+ #: because that stamp is a completion certificate: it is written only when EVERY
79
+ #: migration on BOTH databases is recorded complete. So an unrelated failure
80
+ #: elsewhere — a deferred migration on the learning database, say — leaves this
81
+ #: rebuild applied and the ceiling still at the old value. An older build then
82
+ #: passes the guard, opens the store, and its first planned event is rejected by
83
+ #: the new constraint and lost. That is the exact outcome the ceiling exists to
84
+ #: convert into a refusal to start.
85
+ #:
86
+ #: Additive and monotonic: the runner raises the stored version to at least this
87
+ #: and never lowers it.
88
+ BREAKING_VERSION = 46
89
+
90
+ _TABLE = "atomic_facts"
91
+ _OLD_VALUE = "temporal"
92
+ _NEW_VALUE = "prospective"
93
+ _FTS = "atomic_facts_fts"
94
+
95
+ #: Recorded for the runner's DDL hash. ``apply()`` runs instead of this string —
96
+ #: the rebuild needs the live table's own definition, which no static script can
97
+ #: name. Kept accurate because the hash is what detects a shipped migration
98
+ #: being edited afterwards.
99
+ DDL = """
100
+ -- Rebuild atomic_facts with fact_type CHECK accepting 'prospective',
101
+ -- translating every existing 'temporal' row. See apply().
102
+ """
103
+
104
+ def _table_sql(conn: sqlite3.Connection, name: str) -> str | None:
105
+ try:
106
+ row = conn.execute(
107
+ "SELECT sql FROM sqlite_master WHERE type='table' AND name=?",
108
+ (name,),
109
+ ).fetchone()
110
+ except sqlite3.Error:
111
+ return None
112
+ if row is None:
113
+ return None
114
+ return row[0] if not isinstance(row, dict) else row.get("sql")
115
+
116
+
117
+ def _columns(conn: sqlite3.Connection, table: str) -> list[str]:
118
+ """Column names in declared order, tolerant of the connection's row factory."""
119
+ out: list[str] = []
120
+ try:
121
+ for row in conn.execute(f"PRAGMA table_info({table})"):
122
+ if isinstance(row, dict):
123
+ out.append(str(row.get("name", "")))
124
+ else:
125
+ try:
126
+ out.append(str(row["name"]))
127
+ except (TypeError, IndexError, KeyError):
128
+ out.append(str(row[1]))
129
+ except sqlite3.Error:
130
+ return []
131
+ return [c for c in out if c]
132
+
133
+
134
+ def _count(conn: sqlite3.Connection, sql: str) -> int:
135
+ try:
136
+ row = conn.execute(sql).fetchone()
137
+ except sqlite3.Error:
138
+ return -1
139
+ if row is None:
140
+ return -1
141
+ if isinstance(row, dict):
142
+ return int(next(iter(row.values())))
143
+ return int(row[0])
144
+
145
+
146
+ def _dependents(conn: sqlite3.Connection) -> list[str]:
147
+ """DDL for every index and trigger attached to the table.
148
+
149
+ Captured before the drop and replayed after the rename. Auto-created
150
+ indexes (those backing PRIMARY KEY / UNIQUE) have NULL sql and are recreated
151
+ by the table definition itself, so they are excluded rather than replayed.
152
+ """
153
+ out: list[str] = []
154
+ try:
155
+ rows = conn.execute(
156
+ "SELECT sql FROM sqlite_master "
157
+ "WHERE tbl_name=? AND type IN ('index','trigger') "
158
+ "AND sql IS NOT NULL",
159
+ (_TABLE,),
160
+ ).fetchall()
161
+ except sqlite3.Error:
162
+ return []
163
+ for row in rows:
164
+ sql = row[0] if not isinstance(row, dict) else row.get("sql")
165
+ if sql:
166
+ out.append(str(sql))
167
+ return out
168
+
169
+
170
+ def _referencing_objects(conn: sqlite3.Connection) -> list[tuple[str, str, str]]:
171
+ """Triggers and views that NAME this table while living somewhere else.
172
+
173
+ ``_dependents`` finds what is attached to the table. This finds what points
174
+ at it. Since SQLite 3.25 an ``ALTER TABLE ... RENAME`` reparses every
175
+ trigger and view in the schema so it can fix up their references, and at
176
+ that moment the old table has already been dropped — so a trigger on
177
+ another table that joins this one makes the rename fail outright:
178
+
179
+ error in trigger trg_scene_fact_members_insert:
180
+ no such table: main.atomic_facts
181
+
182
+ Two such triggers ship on ``memory_scenes``, which means every store that
183
+ has ever held a scene. Caught by running the whole migration chain against
184
+ a real archive; running this migration on its own does not reach it,
185
+ because the triggers are created by a migration that had not run yet.
186
+
187
+ Returned as (kind, name, sql) so each can be dropped before the rename and
188
+ replayed identically afterwards.
189
+ """
190
+ out: list[tuple[str, str, str]] = []
191
+ try:
192
+ rows = conn.execute(
193
+ "SELECT type, name, sql, tbl_name FROM sqlite_master "
194
+ "WHERE type IN ('trigger','view') AND sql IS NOT NULL "
195
+ "AND tbl_name <> ? AND sql LIKE ?",
196
+ (_TABLE, f"%{_TABLE}%"),
197
+ ).fetchall()
198
+ except sqlite3.Error:
199
+ return []
200
+ # ``LIKE '%atomic_facts%'`` is a substring test, so it also matches a
201
+ # future ``atomic_facts_archive``. Narrow it to the table named as a whole
202
+ # word, in any of the spellings SQL allows.
203
+ import re as _re
204
+
205
+ named = _re.compile(
206
+ r"(?<![A-Za-z0-9_])[\"`\[]?" + _re.escape(_TABLE) + r"[\"`\]]?(?![A-Za-z0-9_])"
207
+ )
208
+ for row in rows:
209
+ kind, name, sql = str(row[0]), str(row[1]), str(row[2])
210
+ # The shadow tables of the external-content FTS index name the table
211
+ # too, and they are handled by the FTS rebuild, not by replay.
212
+ if name.startswith(_FTS):
213
+ continue
214
+ if not named.search(sql):
215
+ continue
216
+ out.append((kind, name, sql))
217
+ return out
218
+
219
+
220
+ def _has_rowid(conn: sqlite3.Connection) -> bool:
221
+ """Whether this table has a rowid to preserve.
222
+
223
+ A ``WITHOUT ROWID`` table has none, and naming it in the copy fails with
224
+ "no column named rowid" — which rolls back and leaves the migration retrying
225
+ forever. Asked by trying, for the same reason the constraint is: the answer
226
+ is a property of the table, not of how its definition was written.
227
+
228
+ The shipped schema has a rowid and needs it preserved, because the search
229
+ index is keyed on it. A store that does not have one cannot have that index
230
+ either, so there is nothing to keep.
231
+ """
232
+ try:
233
+ conn.execute(f"SELECT rowid FROM {_TABLE} LIMIT 1").fetchone()
234
+ return True
235
+ except sqlite3.Error:
236
+ return False
237
+
238
+
239
+ def _fts_exists(conn: sqlite3.Connection) -> bool:
240
+ try:
241
+ return conn.execute(
242
+ "SELECT name FROM sqlite_master WHERE name=?", (_FTS,),
243
+ ).fetchone() is not None
244
+ except sqlite3.Error:
245
+ return False
246
+
247
+
248
+ def _fts_triggers_present(conn: sqlite3.Connection) -> bool:
249
+ """Whether the table still has triggers feeding the search index.
250
+
251
+ The rebuild drops every trigger and replays it. Without them the table and
252
+ the index drift apart from the next write onwards, which no row count and no
253
+ constraint check would notice.
254
+ """
255
+ try:
256
+ rows = conn.execute(
257
+ "SELECT COUNT(*) FROM sqlite_master "
258
+ "WHERE type='trigger' AND tbl_name=? AND sql LIKE ?",
259
+ (_TABLE, f"%{_FTS}%"),
260
+ ).fetchone()
261
+ except sqlite3.Error:
262
+ return False
263
+ if rows is None:
264
+ return False
265
+ count = next(iter(rows.values())) if isinstance(rows, dict) else rows[0]
266
+ return int(count) > 0
267
+
268
+
269
+ def _accepts(conn: sqlite3.Connection, value: str) -> bool | None:
270
+ """Whether the table will accept ``value`` in ``fact_type``. None if unknown.
271
+
272
+ Asked by attempting a write inside a savepoint and rolling it back, because
273
+ the constraint is what decides and its DDL text can be spelled several ways.
274
+
275
+ An earlier version read the answer out of the CREATE statement with a regex
276
+ that required a bare ``fact_type`` identifier. On a store whose constraint
277
+ was written ``CHECK ("fact_type" IN (...))`` the pattern did not match, the
278
+ migration concluded there was nothing blocking it, took the cheap UPDATE
279
+ path — and then either raised on the first converted row, or, on a store
280
+ with none, committed happily. In that second case ``verify()`` reported the
281
+ migration complete while the table went on rejecting the new value, so every
282
+ planned event stored afterwards was lost to a constraint failure. Nothing
283
+ would ever have repaired it, because the same regex answered the same way
284
+ every time.
285
+
286
+ Returns None when there is no row to probe with. The caller treats that as
287
+ "rebuild anyway": on an empty table a rebuild costs microseconds and makes
288
+ the constraint correct by construction, which is a better trade than
289
+ guessing from text.
290
+ """
291
+ try:
292
+ row = conn.execute(f"SELECT rowid FROM {_TABLE} LIMIT 1").fetchone()
293
+ except sqlite3.Error:
294
+ return None
295
+ if row is None:
296
+ return None
297
+ rowid = next(iter(row.values())) if isinstance(row, dict) else row[0]
298
+
299
+ try:
300
+ conn.execute("SAVEPOINT m046_probe")
301
+ except sqlite3.Error:
302
+ return None
303
+ try:
304
+ conn.execute(
305
+ f"UPDATE {_TABLE} SET fact_type=? WHERE rowid=?", (value, rowid),
306
+ )
307
+ return True
308
+ except sqlite3.IntegrityError:
309
+ return False
310
+ except sqlite3.Error:
311
+ return None
312
+ finally:
313
+ # Rolled back either way: this asks a question, it does not change data.
314
+ try:
315
+ conn.execute("ROLLBACK TO m046_probe")
316
+ conn.execute("RELEASE m046_probe")
317
+ except sqlite3.Error: # pragma: no cover — best effort
318
+ pass
319
+
320
+
321
+ def _ddl_mentions_old_value(conn: sqlite3.Connection) -> bool:
322
+ """Is the old value still written into the table definition?
323
+
324
+ The fallback for a table with no rows to probe. Deliberately looks for the
325
+ quoted literal rather than the word: the shipped schema carries a comment
326
+ reading "-- Temporal (3-date model)", which sqlite_master preserves, and
327
+ matching that would report every correctly-migrated store as unmigrated.
328
+ """
329
+ sql = _table_sql(conn, _TABLE) or ""
330
+ return f"'{_OLD_VALUE}'" in sql
331
+
332
+
333
+ def _has_fact_type(conn: sqlite3.Connection) -> bool:
334
+ """Whether the table carries the column this migration converts.
335
+
336
+ A store can have ``atomic_facts`` without ``fact_type``: the runner applies
337
+ migrations against partially-built schemas, and the column arrives with a
338
+ migration that may not have run yet. Without this check the no-constraint
339
+ path issues ``UPDATE ... SET fact_type`` and fails with "no such column",
340
+ and ``verify`` then fails forever on the same store — which is exactly how
341
+ the previous release's M043 became permanently stuck.
342
+ """
343
+ return "fact_type" in _columns(conn, _TABLE)
344
+
345
+
346
+ def verify(conn: sqlite3.Connection) -> bool:
347
+ """End state: no row carries the old value, and the CHECK accepts the new one.
348
+
349
+ Deliberately does NOT require that any ``prospective`` rows exist. A store
350
+ with no scheduled events is a normal store, and asserting a non-zero count
351
+ would make this migration fail forever on a fresh install — the mistake a
352
+ migration in the previous release made and had to be repaired for.
353
+
354
+ A missing table, or a table without the column, means there is nothing to
355
+ convert and that is the end state. Every branch of ``apply()`` produces what
356
+ this asserts.
357
+ """
358
+ if _table_sql(conn, _TABLE) is None:
359
+ return True
360
+ if not _has_fact_type(conn):
361
+ return True
362
+ if _count(conn, f"SELECT COUNT(*) FROM {_TABLE} "
363
+ f"WHERE fact_type='{_OLD_VALUE}'") != 0:
364
+ return False
365
+
366
+ # Two things must hold: the definition no longer names the old value
367
+ # anywhere, and the table actually accepts the new one. The second is asked
368
+ # of SQLite rather than read out of the DDL, because a constraint's text can
369
+ # be spelled several ways and its behaviour cannot.
370
+ #
371
+ # Deliberately NOT asserted: that the old value is rejected. On a store whose
372
+ # table never had a CHECK, that is unachievable without inventing a
373
+ # constraint it never had, and the schema-version ceiling is what actually
374
+ # stops an older writer.
375
+ if _ddl_mentions_old_value(conn):
376
+ return False
377
+
378
+ # The triggers that keep the search index in step with the table. Checked
379
+ # because the rebuild drops and replays them, and a store that ended up
380
+ # without them is not migrated — it is a store that has silently stopped
381
+ # indexing anything written since. Only asserted when the index exists at
382
+ # all: a store without it never had the triggers either.
383
+ if _fts_exists(conn) and not _fts_triggers_present(conn):
384
+ return False
385
+
386
+ accepts_new = _accepts(conn, _NEW_VALUE)
387
+ # None means there was no row to probe with, which on an empty table leaves
388
+ # the definition check above as the whole answer.
389
+ return accepts_new is not False
390
+
391
+
392
+ def apply(conn: sqlite3.Connection) -> None:
393
+ """Translate the rows, and rebuild the table if its CHECK stands in the way."""
394
+ if _table_sql(conn, _TABLE) is None:
395
+ # The schema has not been created yet; there is nothing to convert.
396
+ return
397
+ if not _has_fact_type(conn):
398
+ # The column this migration converts does not exist on this store yet.
399
+ return
400
+
401
+ # Rebuild only when the definition still names the old value. A store whose
402
+ # table never constrained fact_type at all has nothing to rewrite: its rows
403
+ # still need translating, but inventing a constraint it never had is not this
404
+ # migration's job — the version ceiling is what stops an older writer.
405
+ needs_rebuild = _ddl_mentions_old_value(conn)
406
+ if not needs_rebuild:
407
+ # The constraint is right; only the rows need translating. A plain UPDATE
408
+ # is enough and touches no index: FTS is external-content over
409
+ # `content`, which this does not change.
410
+ conn.execute("BEGIN IMMEDIATE")
411
+ try:
412
+ conn.execute(
413
+ f"UPDATE {_TABLE} SET fact_type=? WHERE fact_type=?",
414
+ (_NEW_VALUE, _OLD_VALUE),
415
+ )
416
+ conn.execute("COMMIT")
417
+ except sqlite3.Error:
418
+ conn.execute("ROLLBACK")
419
+ raise
420
+ return
421
+
422
+ _rebuild(conn)
423
+
424
+
425
+ def _rebuild(conn: sqlite3.Connection) -> None:
426
+ """The documented table rebuild, all inside one transaction."""
427
+ original = _table_sql(conn, _TABLE)
428
+ if not original: # pragma: no cover — guarded by the caller
429
+ raise sqlite3.OperationalError(f"M046: cannot read {_TABLE} definition")
430
+
431
+ # Rename the VALUE, not the column reference. Everything else about the
432
+ # table — every column, default, collation and constraint — is left exactly
433
+ # as the installed schema declared it, because reconstructing from
434
+ # PRAGMA table_info silently drops CHECK constraints and collations.
435
+ #
436
+ # Targeting the quoted literal is what makes this work on any spelling of
437
+ # the constraint. An earlier version matched the identifier, which required a
438
+ # bare `fact_type`; a store written `CHECK ("fact_type" IN (...))` did not
439
+ # match and could not be migrated at all. The value is always `'temporal'`
440
+ # regardless of how the column beside it is quoted.
441
+ rebuilt = original.replace(f"'{_OLD_VALUE}'", f"'{_NEW_VALUE}'")
442
+
443
+ # A rewrite that did not take must stop the migration. Proceeding would
444
+ # create a replacement table carrying the ORIGINAL constraint and then drop
445
+ # the real one — the corrupting outcome this whole module exists to avoid.
446
+ if f"'{_OLD_VALUE}'" in rebuilt or f"'{_NEW_VALUE}'" not in rebuilt:
447
+ raise sqlite3.OperationalError(
448
+ "M046: could not rewrite the fact_type constraint; "
449
+ "refusing to rebuild the table"
450
+ )
451
+
452
+ staged = rebuilt.replace(_TABLE, f"{_TABLE}_m046_new", 1)
453
+ if f"{_TABLE}_m046_new" not in staged: # pragma: no cover — defensive
454
+ raise sqlite3.OperationalError("M046: could not name the staging table")
455
+
456
+ columns = _columns(conn, _TABLE)
457
+ if not columns: # pragma: no cover — defensive
458
+ raise sqlite3.OperationalError(f"M046: {_TABLE} reports no columns")
459
+
460
+ # Generated columns are deliberately absent from this list: PRAGMA
461
+ # table_info does not return them (they appear only in table_xinfo, flagged
462
+ # hidden), and inserting into one is an error. So a store that has added one
463
+ # copies correctly without special handling — verified rather than assumed.
464
+ carries_rowid = _has_rowid(conn)
465
+
466
+ dependents = _dependents(conn)
467
+ referencing = _referencing_objects(conn)
468
+ before = _count(conn, f"SELECT COUNT(*) FROM {_TABLE}")
469
+ if before < 0: # pragma: no cover — defensive
470
+ raise sqlite3.OperationalError(f"M046: cannot count {_TABLE}")
471
+
472
+ # Outside the transaction: inside one, this is a silent no-op.
473
+ #
474
+ # The previous value is read first and put back at the end. Unconditionally
475
+ # switching it ON afterwards would leave the caller's connection in a state
476
+ # it did not ask for — this migration borrows the connection, it does not
477
+ # own its settings.
478
+ try:
479
+ _fk_was = conn.execute("PRAGMA foreign_keys").fetchone()
480
+ fk_was = bool(_fk_was[0]) if _fk_was else False
481
+ except sqlite3.Error: # pragma: no cover
482
+ fk_was = False
483
+ try:
484
+ conn.execute("PRAGMA foreign_keys=OFF")
485
+ except sqlite3.Error: # pragma: no cover
486
+ pass
487
+
488
+ select_list = ", ".join(
489
+ f"CASE WHEN fact_type='{_OLD_VALUE}' THEN '{_NEW_VALUE}' ELSE fact_type END"
490
+ if col == "fact_type" else f'"{col}"'
491
+ for col in columns
492
+ )
493
+ insert_list = ", ".join(f'"{col}"' for col in columns)
494
+
495
+ conn.execute("BEGIN IMMEDIATE")
496
+ try:
497
+ conn.execute(f'DROP TABLE IF EXISTS "{_TABLE}_m046_new"')
498
+ conn.execute(staged)
499
+
500
+ # rowid is named on both sides where there is one. See the module
501
+ # docstring: the FTS index is keyed on it and the primary key is TEXT,
502
+ # so letting SQLite assign fresh ones silently repoints every index
503
+ # entry. A WITHOUT ROWID table has none to name, and naming it there
504
+ # fails the whole rebuild.
505
+ if carries_rowid:
506
+ conn.execute(
507
+ f'INSERT INTO "{_TABLE}_m046_new" (rowid, {insert_list}) '
508
+ f"SELECT rowid, {select_list} FROM {_TABLE}"
509
+ )
510
+ else:
511
+ conn.execute(
512
+ f'INSERT INTO "{_TABLE}_m046_new" ({insert_list}) '
513
+ f"SELECT {select_list} FROM {_TABLE}"
514
+ )
515
+
516
+ after = _count(conn, f'SELECT COUNT(*) FROM "{_TABLE}_m046_new"')
517
+ if after != before:
518
+ raise sqlite3.OperationalError(
519
+ f"M046: copied {after} of {before} rows; rolling back"
520
+ )
521
+ stragglers = _count(
522
+ conn,
523
+ f'SELECT COUNT(*) FROM "{_TABLE}_m046_new" '
524
+ f"WHERE fact_type='{_OLD_VALUE}'",
525
+ )
526
+ if stragglers != 0:
527
+ raise sqlite3.OperationalError(
528
+ f"M046: {stragglers} rows still carry the old value; rolling back"
529
+ )
530
+
531
+ # Triggers first — they name the table and block the drop.
532
+ for kind, name in _trigger_and_index_names(conn):
533
+ conn.execute(f'DROP {kind} IF EXISTS "{name}"')
534
+
535
+ # And anything elsewhere in the schema that merely POINTS at the table:
536
+ # the rename reparses every trigger and view, and one that joins a table
537
+ # which no longer exists fails the whole statement.
538
+ for kind, name, _sql in referencing:
539
+ conn.execute(f'DROP {kind} IF EXISTS "{name}"')
540
+
541
+ conn.execute(f"DROP TABLE {_TABLE}")
542
+ conn.execute(f'ALTER TABLE "{_TABLE}_m046_new" RENAME TO {_TABLE}')
543
+
544
+ for _kind, _name, ddl in referencing:
545
+ # Fatal for the same reason the table's own dependents are: these
546
+ # keep a derived table in step, and committing without them leaves
547
+ # it silently stale.
548
+ conn.execute(ddl)
549
+
550
+ for ddl in dependents:
551
+ # Fatal, which reverses an earlier judgement here. That judgement
552
+ # was that losing the converted rows to a rollback would be worse
553
+ # than a missing index — and it was simply wrong: a rollback loses
554
+ # NOTHING, because the original table is still sitting there and the
555
+ # migration will be retried.
556
+ #
557
+ # Committing without these is what costs something. Two of the three
558
+ # replayed statements are the triggers that keep the search index in
559
+ # step with the table, so a store that commits without them stops
560
+ # indexing every memory saved from then on. Lexical search quietly
561
+ # goes blind to new writes, and nothing reports it.
562
+ conn.execute(ddl)
563
+
564
+ _rebuild_fts(conn)
565
+ conn.execute("COMMIT")
566
+ except sqlite3.Error:
567
+ try:
568
+ conn.execute("ROLLBACK")
569
+ except sqlite3.Error: # pragma: no cover — best effort
570
+ pass
571
+ raise
572
+ finally:
573
+ try:
574
+ conn.execute(
575
+ f"PRAGMA foreign_keys={'ON' if fk_was else 'OFF'}"
576
+ )
577
+ except sqlite3.Error: # pragma: no cover
578
+ pass
579
+
580
+
581
+ def _trigger_and_index_names(conn: sqlite3.Connection) -> list[tuple[str, str]]:
582
+ out: list[tuple[str, str]] = []
583
+ try:
584
+ rows = conn.execute(
585
+ "SELECT type, name FROM sqlite_master "
586
+ "WHERE tbl_name=? AND type IN ('index','trigger') "
587
+ "AND sql IS NOT NULL",
588
+ (_TABLE,),
589
+ ).fetchall()
590
+ except sqlite3.Error:
591
+ return []
592
+ for row in rows:
593
+ if isinstance(row, dict):
594
+ out.append((str(row.get("type", "")).upper(), str(row.get("name", ""))))
595
+ else:
596
+ out.append((str(row[0]).upper(), str(row[1])))
597
+ return [(k, n) for k, n in out if k and n]
598
+
599
+
600
+ def _rebuild_fts(conn: sqlite3.Connection) -> None:
601
+ """Re-derive the search index from the rebuilt table.
602
+
603
+ Belt and braces alongside preserving rowid. Never fatal: a store whose
604
+ search index needs another rebuild is recoverable, and losing the converted
605
+ rows to a rollback over it would not be.
606
+ """
607
+ try:
608
+ exists = conn.execute(
609
+ "SELECT name FROM sqlite_master WHERE name=?", (_FTS,),
610
+ ).fetchone()
611
+ if exists is None:
612
+ return
613
+ conn.execute(f"INSERT INTO {_FTS}({_FTS}) VALUES('rebuild')")
614
+ except sqlite3.Error as exc:
615
+ logger.warning("M046: search index rebuild deferred: %s", exc)
616
+
617
+
618
+ def repair(conn: sqlite3.Connection) -> None:
619
+ """Re-run apply. Safe: both paths are idempotent and verify their own work."""
620
+ apply(conn)