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,306 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Rewrite the two Fisher vectors on each fact as float32, like the embedding.
6
+
7
+ WHAT THIS IS ABOUT
8
+
9
+ A fact carries three vectors of the same width: its embedding, and the diagonal
10
+ Fisher mean and variance the memory dynamics read to decide how fast it decays.
11
+ 4.0.9 converted the embedding from JSON text to float32 and got 5.5x. The other
12
+ two were left in text.
13
+
14
+ Measured on a real 447 MB store:
15
+
16
+ fisher_mean + fisher_variance 116.5 MB
17
+ embedding (already binary) 15.5 MB
18
+ content — the memories themselves 3.6 MB
19
+
20
+ The Fisher matrices were thirty-two times the size of the memories they
21
+ describe, and more than a quarter of the entire file, because a float printed
22
+ as decimal text costs about 22 bytes and the same float costs 4.
23
+
24
+ WHY AN UPDATE AND NOT A REBUILD
25
+
26
+ ``atomic_facts`` has a TEXT primary key, so it also has an implicit rowid, and
27
+ its full-text index is external-content keyed on that rowid. Rebuilding the
28
+ table reassigns rowids and every index entry then points at a different fact —
29
+ searches keep working and return the wrong rows. An UPDATE touches no rowid, so
30
+ the search index is untouched by construction. Nothing here alters the schema:
31
+ the columns are declared TEXT and SQLite stores a BLOB in them regardless.
32
+
33
+ WHY IT CAN BE INTERRUPTED
34
+
35
+ Every row is converted independently and the read path accepts both forms, so a
36
+ store stopped halfway is a working store. Re-running finishes it. That is why
37
+ the work is committed in batches rather than held in one transaction across
38
+ three thousand facts.
39
+
40
+ WHAT VERIFY CAN AND CANNOT CHECK
41
+
42
+ float32 has about seven significant digits, and these numbers were written from
43
+ float64, so the conversion loses precision on purpose. That trade is checked in
44
+ the tests, which hold the original values and compare against them at the
45
+ tolerance float32 provides.
46
+
47
+ ``verify`` runs after the fact, when the text it replaced no longer exists, so
48
+ it cannot make that comparison. What it can do is refuse the shapes a broken
49
+ conversion would leave: a convertible vector still in the old form, meaning the
50
+ run stopped early; a buffer that is not a whole number of floats; a non-finite
51
+ value; and a buffer that is entirely zero — which passes every structural check
52
+ and is a deletion wearing the right shape, because the dynamics read an all-zero
53
+ vector as "this memory carries no evidence".
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import json
59
+ import logging
60
+ import sqlite3
61
+
62
+ import numpy as np
63
+
64
+ logger = logging.getLogger(__name__)
65
+
66
+ NAME = "M047_fisher_vectors_are_stored_like_every_other_vector"
67
+ DB_TARGET = "memory"
68
+
69
+ #: No schema change and both forms are readable, so an older build opening a
70
+ #: converted store is safe. The floor does not move.
71
+ BREAKING_VERSION = 0
72
+
73
+ _TABLE = "atomic_facts"
74
+ _COLUMNS = ("fisher_mean", "fisher_variance")
75
+
76
+ #: Rows per transaction. Large enough that the commit overhead is irrelevant,
77
+ #: small enough that an interrupted run loses a fraction of a second of work.
78
+ _BATCH = 500
79
+
80
+ #: float32 keeps about seven significant digits. Anything inside this is the
81
+ #: cost that was chosen; anything outside it is a bug.
82
+ _TOLERANCE = 1e-6
83
+
84
+ DDL = """
85
+ -- No schema change. apply() rewrites the two Fisher columns of each fact from
86
+ -- JSON text to a little-endian float32 buffer, in place, in batches.
87
+ """
88
+
89
+
90
+ def _is_text_vector(value: object) -> bool:
91
+ """A value still in the old form: a string that opens like a JSON list."""
92
+ return isinstance(value, str) and value.strip().startswith("[")
93
+
94
+
95
+ def _encode(text: str) -> bytes | None:
96
+ """Text form to float32 buffer, or None when there is nothing to store."""
97
+ parsed = json.loads(text)
98
+ if parsed is None:
99
+ return None
100
+ if not isinstance(parsed, list):
101
+ raise ValueError(f"expected a list, got {type(parsed).__name__}")
102
+ return np.asarray(parsed, dtype=np.float32).tobytes()
103
+
104
+
105
+ def _pending(conn: sqlite3.Connection) -> int:
106
+ """How many rows still hold a Fisher vector in the old form."""
107
+ clauses = " OR ".join(
108
+ f"(typeof({col}) = 'text' AND {col} LIKE '[%')" for col in _COLUMNS
109
+ )
110
+ row = conn.execute(f"SELECT COUNT(*) FROM {_TABLE} WHERE {clauses}").fetchone()
111
+ return int(row[0]) if row else 0
112
+
113
+
114
+ def apply(conn: sqlite3.Connection) -> None:
115
+ """Convert every remaining text Fisher vector, in batches, resumably."""
116
+ existing = {r[1] for r in conn.execute(f"PRAGMA table_info({_TABLE})")}
117
+ missing = [c for c in _COLUMNS if c not in existing]
118
+ if missing:
119
+ logger.info("M047: %s has no %s; nothing to convert", _TABLE, missing)
120
+ return
121
+
122
+ remaining = _pending(conn)
123
+ if remaining == 0:
124
+ logger.info("M047: no Fisher vector is in the old form")
125
+ return
126
+ logger.info("M047: converting Fisher vectors on %d fact(s)", remaining)
127
+
128
+ converted = 0
129
+ skipped = 0
130
+ # Walk forward by rowid rather than re-querying "what is left". A row can
131
+ # legitimately still match the WHERE clause after being visited — one of its
132
+ # two vectors may be unreadable while the other converts — and re-selecting
133
+ # on the predicate alone would then hand back the same row forever.
134
+ cursor = 0
135
+ clauses = " OR ".join(
136
+ f"(typeof({col}) = 'text' AND {col} LIKE '[%')" for col in _COLUMNS
137
+ )
138
+
139
+ while True:
140
+ batch = conn.execute(
141
+ f"SELECT rowid, fact_id, {', '.join(_COLUMNS)} FROM {_TABLE} "
142
+ f"WHERE rowid > ? AND ({clauses}) ORDER BY rowid LIMIT {_BATCH}",
143
+ (cursor,),
144
+ ).fetchall()
145
+ if not batch:
146
+ break
147
+ cursor = batch[-1][0]
148
+
149
+ updates: list[tuple[object, ...]] = []
150
+ for row in batch:
151
+ rowid, fact_id = row[0], row[1]
152
+ values: list[object] = []
153
+ changed = False
154
+ unreadable = False
155
+ # The two vectors are independent. One being unreadable is no reason
156
+ # to leave the other in the form that costs five times as much.
157
+ for offset, column in enumerate(_COLUMNS, start=2):
158
+ raw = row[offset]
159
+ if not _is_text_vector(raw):
160
+ values.append(raw)
161
+ continue
162
+ try:
163
+ values.append(_encode(raw))
164
+ changed = True
165
+ except (ValueError, TypeError, json.JSONDecodeError) as exc:
166
+ # A vector that cannot be parsed is left exactly as it is.
167
+ # Replacing it with NULL would turn "unreadable" into
168
+ # "absent", and absent reads as "no evidence" in the decay
169
+ # dynamics rather than as something to look at.
170
+ logger.warning(
171
+ "M047: leaving %s on fact %s as text — %s",
172
+ column, fact_id, exc,
173
+ )
174
+ values.append(raw)
175
+ unreadable = True
176
+ if unreadable:
177
+ skipped += 1
178
+ if changed:
179
+ updates.append((*values, rowid))
180
+
181
+ if updates:
182
+ conn.execute("BEGIN IMMEDIATE")
183
+ try:
184
+ # Only write over a value that is STILL in the old form. Reading
185
+ # a row and writing it back later is a lost update: a live
186
+ # writer that recomputes this fact's vectors between the two
187
+ # would have its new value replaced by the old one re-encoded,
188
+ # silently, with no error and no retry. The typeof() guard makes
189
+ # the update conditional on nothing having changed underneath.
190
+ for column in _COLUMNS:
191
+ per_column = [
192
+ (values[_COLUMNS.index(column)], rowid)
193
+ for *values, rowid in updates
194
+ ]
195
+ conn.executemany(
196
+ f"UPDATE {_TABLE} SET {column} = ? "
197
+ f"WHERE rowid = ? AND typeof({column}) = 'text'",
198
+ per_column,
199
+ )
200
+ conn.commit()
201
+ except Exception:
202
+ conn.rollback()
203
+ raise
204
+ converted += len(updates)
205
+
206
+ logger.info(
207
+ "M047: converted %d fact(s); left %d vector(s) as text for inspection",
208
+ converted, skipped,
209
+ )
210
+
211
+
212
+ def repair(conn: sqlite3.Connection) -> None:
213
+ """Finish the conversion on a store that has drifted back to the old form.
214
+
215
+ WHY A COMPLETED MIGRATION NEEDS THIS
216
+ ------------------------------------
217
+ A long-running daemon holds its writers in memory. This migration and the
218
+ write-path change that stores the new form shipped in the same commit, so a
219
+ daemon started before that commit converts nothing and keeps writing text --
220
+ and the migration, run by some later short-lived process, marks itself
221
+ complete against a store that is still gaining old-form rows behind it.
222
+
223
+ That happened, and the numbers say so exactly: on the author's store the
224
+ migration completed at 08:37, the newest converted row was written at 08:08,
225
+ and sixteen facts written between 10:50 and 17:34 were text. The daemon had
226
+ been up for twenty-five hours, since before the commit existed.
227
+
228
+ Without a repair hook the framework refuses to touch a completed migration
229
+ and logs "schema incomplete for completed migration; automatic replay is
230
+ disabled" on every open. Both halves of that are wrong here -- the
231
+ conversion did not stop early, and disabling replay for a false alarm means
232
+ a migration that IS incomplete later goes unrepaired. The mechanism was
233
+ already there; this migration simply never supplied the hook.
234
+
235
+ ``apply`` is the whole repair: every row converts independently, the read
236
+ path accepts both forms, and re-running finishes what is left. So this is
237
+ not a second implementation to keep in step -- it is the same one.
238
+ """
239
+ apply(conn)
240
+
241
+
242
+ def verify(conn: sqlite3.Connection) -> bool:
243
+ """Every convertible vector is converted, and the numbers still agree.
244
+
245
+ Reading a sample back and comparing it to what it replaced is the only
246
+ check that distinguishes a conversion from a deletion. A count of BLOBs
247
+ would pass just as happily on zeroed buffers.
248
+ """
249
+ existing = {r[1] for r in conn.execute(f"PRAGMA table_info({_TABLE})")}
250
+ if any(c not in existing for c in _COLUMNS):
251
+ return True
252
+
253
+ # A row whose vector could not be parsed is left as text on purpose, so
254
+ # "some text remains" is not by itself a failure. What would be a failure is
255
+ # text that WOULD have converted — that means apply() stopped early.
256
+ for column in _COLUMNS:
257
+ leftover = conn.execute(
258
+ f"SELECT fact_id, {column} FROM {_TABLE} "
259
+ f"WHERE typeof({column}) = 'text' AND {column} LIKE '[%'"
260
+ ).fetchall()
261
+ for fact_id, raw in leftover:
262
+ try:
263
+ _encode(raw)
264
+ except (ValueError, TypeError, json.JSONDecodeError):
265
+ continue # genuinely unconvertible; apply() reported it
266
+ logger.error(
267
+ "M047 verify: %s on fact %s is still text and would have "
268
+ "converted, so the conversion stopped early",
269
+ column, fact_id,
270
+ )
271
+ return False
272
+
273
+ for column in _COLUMNS:
274
+ sample = conn.execute(
275
+ f"SELECT {column} FROM {_TABLE} "
276
+ f"WHERE typeof({column}) = 'blob' LIMIT 50"
277
+ ).fetchall()
278
+ nonzero = 0
279
+ for (raw,) in sample:
280
+ if raw is None:
281
+ continue
282
+ if len(raw) == 0 or len(raw) % 4 != 0:
283
+ logger.error(
284
+ "M047 verify: a %s buffer is %d bytes, not a float32 vector",
285
+ column, len(raw),
286
+ )
287
+ return False
288
+ values = np.frombuffer(raw, dtype=np.float32)
289
+ if not np.all(np.isfinite(values)):
290
+ logger.error("M047 verify: a %s buffer holds a non-finite value", column)
291
+ return False
292
+ if np.any(values):
293
+ nonzero += 1
294
+ if sample and nonzero == 0:
295
+ # EVERY sampled vector being zero is a blanket wipe wearing the
296
+ # right shape: the buffers are the correct length and finite, and
297
+ # the dynamics would read all of them as "this memory carries no
298
+ # evidence". One honestly-zero vector is not that, and refusing on
299
+ # a single one would mark the migration incomplete forever on a
300
+ # store that legitimately holds one.
301
+ logger.error(
302
+ "M047 verify: every sampled %s buffer is entirely zero, which "
303
+ "is a wipe rather than a conversion", column,
304
+ )
305
+ return False
306
+ return True
@@ -0,0 +1,207 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Re-read what is filed as a plan, and file the rest correctly.
6
+
7
+ M046 renamed the type used for planned events. Renaming is all it did: every
8
+ row that said ``temporal`` now says ``prospective``, and the contents were never
9
+ re-read. On a real store 869 rows carried that type and seven of them contained
10
+ any planning language — session summaries, records of finished work, lists of
11
+ commits. After the rename that same set carries a more confident name, and the
12
+ question a user actually asks — "what is coming up" — reads exactly that set.
13
+
14
+ So the rename needs a second half. This one re-reads each of those memories
15
+ under the rule that decides the question today and moves the ones that are not
16
+ plans to the type their wording supports.
17
+
18
+ WHY THIS ONLY DEMOTES
19
+
20
+ It never promotes. A memory filed as ordinary that turns out to read like a plan
21
+ is left alone, because the cost is asymmetric in the same way the classifier's
22
+ own tiers are: a plan filed as an ordinary memory is still found by every
23
+ retrieval channel, and an ordinary memory filed as a plan is the pollution this
24
+ exists to remove. Walking every fact in the store to look for promotions would
25
+ also cost a full table scan for the smaller half of the benefit.
26
+
27
+ WHY IT IS SAFE TO RE-RUN
28
+
29
+ The rule is a pure function of the text. Running it twice gives the same answer,
30
+ so a second pass moves nothing.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import logging
36
+ import contextlib
37
+ import sqlite3
38
+ from typing import Any
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ NAME = "M048_upcoming_holds_only_what_is_upcoming"
43
+ DB_TARGET = "memory"
44
+
45
+ #: No schema change and no new value — every type written here already existed.
46
+ BREAKING_VERSION = 0
47
+
48
+ _TABLE = "atomic_facts"
49
+ _PROSPECTIVE = "prospective"
50
+
51
+ #: Rows per transaction.
52
+ _BATCH = 500
53
+
54
+ DDL = """
55
+ -- No schema change. apply() re-reads the content of every fact typed
56
+ -- 'prospective' and demotes the ones whose wording does not describe something
57
+ -- still ahead.
58
+ """
59
+
60
+
61
+ def _resolve(content: str) -> str:
62
+ """The type this text supports — and the question asked in the right order.
63
+
64
+ The full classifier answers "which of the four is this", and it asks about
65
+ opinion before it asks about plans. That is right for a new memory: "I think
66
+ we should ship next week" is an opinion. It is WRONG here, because this
67
+ migration is only deciding whether a row belongs under the plan type, and
68
+ "we should deploy next Tuesday" IS something coming up. Asking the full
69
+ classifier demoted real deadlines into opinions and they vanished from the
70
+ list of what is upcoming — which is the exact harm this exists to repair.
71
+
72
+ So: if it reads as a plan, it stays a plan. Only when it does not is the
73
+ full classifier asked where it should go instead, and its answer is taken
74
+ only if it is not "prospective".
75
+ """
76
+ from superlocalmemory.encoding.fact_extractor import _classify_sentence
77
+ from superlocalmemory.encoding.prospective_markers import looks_prospective
78
+
79
+ text = content or ""
80
+ if looks_prospective(text):
81
+ return _PROSPECTIVE
82
+ resolved = _classify_sentence(text).value
83
+ return "semantic" if resolved == _PROSPECTIVE else resolved
84
+
85
+
86
+ @contextlib.contextmanager
87
+ def _held(conn: sqlite3.Connection):
88
+ """Yield the connection the caller already owns, and leave it open."""
89
+ yield conn
90
+
91
+
92
+ def apply(
93
+ conn: sqlite3.Connection | None = None,
94
+ *,
95
+ open_connection: Any = None,
96
+ ) -> None:
97
+ """Demote every wrongly-filed plan, in batches, resumably.
98
+
99
+ Pass ``conn`` when the caller owns the connection for the whole pass -- the
100
+ migration runner at startup, where nothing else is writing. Pass
101
+ ``open_connection`` (a context manager factory, typically the database
102
+ manager's ``raw_connection``) on a running store: each batch then takes and
103
+ releases the process write lock, so a memory being saved waits one batch
104
+ rather than the whole pass. The batching below cannot do that on its own,
105
+ because holding the connection is what holds the lock.
106
+ """
107
+ if (conn is None) == (open_connection is None):
108
+ raise ValueError("pass exactly one of conn or open_connection")
109
+ acquire = (lambda: _held(conn)) if conn is not None else open_connection
110
+
111
+ with acquire() as probe:
112
+ existing = {r[1] for r in probe.execute(f"PRAGMA table_info({_TABLE})")}
113
+ if "fact_type" not in existing or "content" not in existing:
114
+ logger.info("M048: %s has no fact_type/content; nothing to re-read", _TABLE)
115
+ return
116
+
117
+ cursor = 0
118
+ demoted = 0
119
+ kept = 0
120
+ while True:
121
+ with acquire() as active:
122
+ batch = active.execute(
123
+ f"SELECT rowid, fact_id, content FROM {_TABLE} "
124
+ f"WHERE rowid > ? AND fact_type = ? ORDER BY rowid LIMIT {_BATCH}",
125
+ (cursor, _PROSPECTIVE),
126
+ ).fetchall()
127
+ if not batch:
128
+ break
129
+ cursor = batch[-1][0]
130
+
131
+ moves: list[tuple[str, int]] = []
132
+ for rowid, _fact_id, content in batch:
133
+ resolved = _resolve(content)
134
+ if resolved == _PROSPECTIVE:
135
+ kept += 1
136
+ continue
137
+ moves.append((resolved, rowid))
138
+
139
+ if moves:
140
+ active.execute("BEGIN IMMEDIATE")
141
+ try:
142
+ # Conditional on the row still being what was read, so a
143
+ # concurrent write is not clobbered.
144
+ changed = 0
145
+ for new_type, rowid in moves:
146
+ cur = active.execute(
147
+ f"UPDATE {_TABLE} SET fact_type = ? "
148
+ f"WHERE rowid = ? AND fact_type = '{_PROSPECTIVE}'",
149
+ (new_type, rowid),
150
+ )
151
+ # Count what the guard let through, not what was
152
+ # offered. A concurrent write can change the row
153
+ # underneath, and a log line that reports the intention
154
+ # as the outcome is how a receipt comes to overstate
155
+ # what happened.
156
+ changed += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
157
+ active.commit()
158
+ except Exception:
159
+ active.rollback()
160
+ raise
161
+ demoted += changed
162
+
163
+ logger.info(
164
+ "M048: re-read %d memories filed as plans; %d were, %d moved",
165
+ demoted + kept, kept, demoted,
166
+ )
167
+
168
+
169
+ def verify(conn: sqlite3.Connection) -> bool:
170
+ """Is the end state in place: would running this pass again change nothing?
171
+
172
+ That is the question the runner asks a verify, and it is answerable exactly
173
+ — this pass is a pure function of the text, so its end state is its own
174
+ fixed point. No estimate, no threshold.
175
+
176
+ An earlier version tried to tell "the pass never ran" from "the rule got
177
+ sharper" by how much disagreed, and a share cannot carry that: a store with
178
+ nine memories, six of them real plans, sits at 33% disagreement having never
179
+ been touched at all. It reported success.
180
+
181
+ Drift after a rule change makes this answer False, correctly — the end state
182
+ under today's rule is genuinely not in place. It is not left to the runner
183
+ to repair, because a completed migration is never replayed: the maintenance
184
+ cycle re-runs the same pass, so the store converges on its own. See
185
+ ``core/maintenance_scheduler``.
186
+ """
187
+ existing = {r[1] for r in conn.execute(f"PRAGMA table_info({_TABLE})")}
188
+ if "fact_type" not in existing or "content" not in existing:
189
+ return True
190
+
191
+ rows = conn.execute(
192
+ f"SELECT fact_id, content FROM {_TABLE} WHERE fact_type = ?",
193
+ (_PROSPECTIVE,),
194
+ ).fetchall()
195
+ if not rows:
196
+ return True
197
+
198
+ drifted = [
199
+ fact_id for fact_id, content in rows if _resolve(content) != _PROSPECTIVE
200
+ ]
201
+ if drifted:
202
+ logger.info(
203
+ "M048: %d of %d memories filed as plans no longer read as one; the "
204
+ "maintenance cycle re-reads them", len(drifted), len(rows),
205
+ )
206
+ return False
207
+ return True