superlocalmemory 3.4.18 → 3.4.21

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 (172) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +42 -34
  3. package/bin/slm +11 -0
  4. package/bin/slm.bat +12 -0
  5. package/package.json +4 -3
  6. package/pyproject.toml +3 -2
  7. package/scripts/build-slm-hook.ps1 +40 -0
  8. package/scripts/build-slm-hook.sh +45 -0
  9. package/scripts/build_entry.py +452 -0
  10. package/scripts/ci/stage5b_gate.sh +50 -0
  11. package/scripts/postinstall/validation.js +187 -0
  12. package/scripts/postinstall-interactive.js +756 -0
  13. package/scripts/postinstall_binary.js +287 -0
  14. package/scripts/release_manifest.py +273 -0
  15. package/scripts/slm-hook.spec +56 -0
  16. package/skills/slm-build-graph/SKILL.md +423 -0
  17. package/skills/slm-list-recent/SKILL.md +348 -0
  18. package/skills/slm-recall/SKILL.md +343 -0
  19. package/skills/slm-remember/SKILL.md +194 -0
  20. package/skills/slm-show-patterns/SKILL.md +224 -0
  21. package/skills/slm-status/SKILL.md +363 -0
  22. package/skills/slm-switch-profile/SKILL.md +442 -0
  23. package/src/superlocalmemory/cli/commands.py +219 -79
  24. package/src/superlocalmemory/cli/context_commands.py +192 -0
  25. package/src/superlocalmemory/cli/daemon.py +15 -1
  26. package/src/superlocalmemory/cli/db_migrate.py +80 -0
  27. package/src/superlocalmemory/cli/escape_hatch.py +220 -0
  28. package/src/superlocalmemory/cli/main.py +72 -1
  29. package/src/superlocalmemory/core/context_cache.py +397 -0
  30. package/src/superlocalmemory/core/embeddings.py +8 -2
  31. package/src/superlocalmemory/core/engine.py +38 -2
  32. package/src/superlocalmemory/core/engine_wiring.py +1 -1
  33. package/src/superlocalmemory/core/ram_lock.py +111 -0
  34. package/src/superlocalmemory/core/recall_pipeline.py +433 -3
  35. package/src/superlocalmemory/core/recall_worker.py +8 -3
  36. package/src/superlocalmemory/core/security_primitives.py +635 -0
  37. package/src/superlocalmemory/core/shadow_router.py +319 -0
  38. package/src/superlocalmemory/core/slm_disabled.py +87 -0
  39. package/src/superlocalmemory/core/slmignore.py +125 -0
  40. package/src/superlocalmemory/core/topic_signature.py +143 -0
  41. package/src/superlocalmemory/core/worker_pool.py +14 -3
  42. package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
  43. package/src/superlocalmemory/evolution/budget.py +321 -0
  44. package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
  45. package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
  46. package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
  47. package/src/superlocalmemory/hooks/adapter_base.py +317 -0
  48. package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
  49. package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
  50. package/src/superlocalmemory/hooks/context_payload.py +312 -0
  51. package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
  52. package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
  53. package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
  54. package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
  55. package/src/superlocalmemory/hooks/ide_connector.py +25 -2
  56. package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
  57. package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
  58. package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
  59. package/src/superlocalmemory/hooks/session_registry.py +186 -0
  60. package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
  61. package/src/superlocalmemory/hooks/sync_loop.py +114 -0
  62. package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
  63. package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
  64. package/src/superlocalmemory/infra/backup.py +3 -3
  65. package/src/superlocalmemory/infra/cloud_backup.py +2 -2
  66. package/src/superlocalmemory/infra/event_bus.py +2 -2
  67. package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
  68. package/src/superlocalmemory/learning/arm_catalog.py +99 -0
  69. package/src/superlocalmemory/learning/bandit.py +526 -0
  70. package/src/superlocalmemory/learning/bandit_cache.py +133 -0
  71. package/src/superlocalmemory/learning/behavioral.py +53 -1
  72. package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
  73. package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
  74. package/src/superlocalmemory/learning/database.py +256 -0
  75. package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
  76. package/src/superlocalmemory/learning/ensemble.py +300 -0
  77. package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
  78. package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
  79. package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
  80. package/src/superlocalmemory/learning/labeler.py +87 -0
  81. package/src/superlocalmemory/learning/legacy_migration.py +277 -0
  82. package/src/superlocalmemory/learning/memory_merge.py +160 -0
  83. package/src/superlocalmemory/learning/model_cache.py +269 -0
  84. package/src/superlocalmemory/learning/model_rollback.py +278 -0
  85. package/src/superlocalmemory/learning/outcome_queue.py +284 -0
  86. package/src/superlocalmemory/learning/pattern_miner.py +415 -0
  87. package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
  88. package/src/superlocalmemory/learning/ranker.py +225 -81
  89. package/src/superlocalmemory/learning/ranker_common.py +163 -0
  90. package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
  91. package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
  92. package/src/superlocalmemory/learning/reward.py +777 -0
  93. package/src/superlocalmemory/learning/reward_archive.py +210 -0
  94. package/src/superlocalmemory/learning/reward_boost.py +201 -0
  95. package/src/superlocalmemory/learning/reward_proxy.py +326 -0
  96. package/src/superlocalmemory/learning/shadow_test.py +524 -0
  97. package/src/superlocalmemory/learning/signal_worker.py +270 -0
  98. package/src/superlocalmemory/learning/signals.py +314 -0
  99. package/src/superlocalmemory/learning/trigram_index.py +547 -0
  100. package/src/superlocalmemory/mcp/server.py +5 -5
  101. package/src/superlocalmemory/mcp/tools_context.py +183 -0
  102. package/src/superlocalmemory/mcp/tools_core.py +92 -27
  103. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
  104. package/src/superlocalmemory/retrieval/engine.py +52 -0
  105. package/src/superlocalmemory/retrieval/reranker.py +4 -2
  106. package/src/superlocalmemory/server/api.py +2 -2
  107. package/src/superlocalmemory/server/bandit_loops.py +140 -0
  108. package/src/superlocalmemory/server/middleware/__init__.py +11 -0
  109. package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
  110. package/src/superlocalmemory/server/routes/backup.py +36 -13
  111. package/src/superlocalmemory/server/routes/behavioral.py +50 -19
  112. package/src/superlocalmemory/server/routes/brain.py +1234 -0
  113. package/src/superlocalmemory/server/routes/data_io.py +4 -4
  114. package/src/superlocalmemory/server/routes/events.py +2 -2
  115. package/src/superlocalmemory/server/routes/helpers.py +1 -1
  116. package/src/superlocalmemory/server/routes/learning.py +192 -7
  117. package/src/superlocalmemory/server/routes/memories.py +189 -1
  118. package/src/superlocalmemory/server/routes/prewarm.py +171 -0
  119. package/src/superlocalmemory/server/routes/profiles.py +3 -3
  120. package/src/superlocalmemory/server/routes/token.py +88 -0
  121. package/src/superlocalmemory/server/routes/ws.py +5 -5
  122. package/src/superlocalmemory/server/security_middleware.py +13 -7
  123. package/src/superlocalmemory/server/ui.py +2 -2
  124. package/src/superlocalmemory/server/unified_daemon.py +335 -3
  125. package/src/superlocalmemory/storage/migration_runner.py +545 -0
  126. package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
  127. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
  128. package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
  129. package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
  130. package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
  131. package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
  132. package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
  133. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
  134. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
  135. package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
  136. package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
  137. package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
  138. package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
  139. package/src/superlocalmemory/storage/models.py +4 -0
  140. package/src/superlocalmemory/ui/css/brain.css +409 -0
  141. package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
  142. package/src/superlocalmemory/ui/index.html +459 -1345
  143. package/src/superlocalmemory/ui/js/brain.js +1321 -0
  144. package/src/superlocalmemory/ui/js/clusters.js +123 -4
  145. package/src/superlocalmemory/ui/js/init.js +48 -39
  146. package/src/superlocalmemory/ui/js/memories.js +88 -2
  147. package/src/superlocalmemory/ui/js/modal.js +71 -1
  148. package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
  149. package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
  150. package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
  151. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  152. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  153. package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
  154. package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
  155. package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
  156. package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
  157. package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
  158. package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
  159. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
  160. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
  161. package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
  162. package/src/superlocalmemory/ui/js/behavioral.js +0 -447
  163. package/src/superlocalmemory/ui/js/graph-core.js +0 -447
  164. package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
  165. package/src/superlocalmemory/ui/js/learning.js +0 -435
  166. package/src/superlocalmemory/ui/js/patterns.js +0 -93
  167. package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
  168. package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
  169. package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
  170. package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
  171. package/src/superlocalmemory.egg-info/requires.txt +0 -58
  172. package/src/superlocalmemory.egg-info/top_level.txt +0 -1
@@ -0,0 +1,545 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory v3.4.21 — LLD-07 §4
4
+
5
+ """Forward-only additive migrations for SLM v3.4.21.
6
+
7
+ LLD reference: ``.backup/active-brain/lld/LLD-07-schema-migrations-and-security-primitives.md``
8
+ Section 4 (Migration Runner).
9
+
10
+ Contract:
11
+ - ``apply_all(learning_db, memory_db, *, dry_run=False) -> dict`` —
12
+ runs every v3.4.21 migration, idempotent and transactional. Returns
13
+ ``{"applied": [names], "skipped": [names], "failed": [names],
14
+ "details": {name: str}}``.
15
+ - ``status(learning_db, memory_db) -> dict[str, str]`` — returns the
16
+ status of each migration as recorded in the target DB's ``migration_log``
17
+ (``"complete"``, ``"failed"``, ``"in_progress"``, or ``"missing"``).
18
+
19
+ Hard rules enforced (LLD-07 §7):
20
+ - MIG-HR-01: idempotent — re-applying is a no-op.
21
+ - MIG-HR-02: atomic — each migration wrapped in BEGIN IMMEDIATE / COMMIT
22
+ via the DDL itself (or by the single-statement guarantee).
23
+ - MIG1: ``ddl_sha256`` prevents silent DDL drift.
24
+ - MIG3: a failing migration does NOT prevent the runner from attempting
25
+ the rest, and does NOT raise to the caller — result comes through the
26
+ returned stats dict.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import logging
33
+ import sqlite3
34
+ from dataclasses import dataclass, field
35
+ from datetime import datetime, timezone
36
+ from pathlib import Path
37
+ from typing import Iterable
38
+
39
+ from superlocalmemory.storage.migrations import (
40
+ M001_add_signal_features_columns as _M001,
41
+ M002_model_state_history as _M002,
42
+ M003_migration_log as _M003,
43
+ M004_cross_platform_sync_log as _M004,
44
+ M005_bandit_tables as _M005,
45
+ M006_action_outcomes_reward as _M006,
46
+ M007_pending_outcomes as _M007,
47
+ M009_model_lineage as _M009,
48
+ M010_evolution_config as _M010,
49
+ M011_archive_and_merge as _M011,
50
+ M012_shadow_observations as _M012,
51
+ M013_bi_temporal_columns as _M013,
52
+ )
53
+
54
+ # Map migration name → module (used for the optional ``verify(conn)`` hook
55
+ # that lets the runner detect "already applied" state when an idempotent
56
+ # retry would otherwise trigger duplicate-column / duplicate-table errors).
57
+ _MODULES = {
58
+ _M001.NAME: _M001,
59
+ _M002.NAME: _M002,
60
+ _M003.NAME: _M003,
61
+ _M004.NAME: _M004,
62
+ _M005.NAME: _M005,
63
+ _M006.NAME: _M006,
64
+ _M007.NAME: _M007,
65
+ _M009.NAME: _M009,
66
+ _M010.NAME: _M010,
67
+ _M011.NAME: _M011,
68
+ _M012.NAME: _M012,
69
+ _M013.NAME: _M013,
70
+ }
71
+
72
+ logger = logging.getLogger(__name__)
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class Migration:
77
+ """Single migration definition."""
78
+
79
+ name: str
80
+ db_target: str # 'learning' or 'memory'
81
+ ddl: str
82
+ dependencies: tuple[str, ...] = field(default_factory=tuple)
83
+
84
+
85
+ # Order matters: M003 creates the log table. The runner handles M003's own
86
+ # bootstrap (it can't record itself before it exists).
87
+ MIGRATIONS: list[Migration] = [
88
+ Migration(name=_M003.NAME, db_target="learning", ddl=_M003.DDL),
89
+ Migration(name=_M001.NAME, db_target="learning", ddl=_M001.DDL,
90
+ dependencies=(_M003.NAME,)),
91
+ Migration(name=_M002.NAME, db_target="learning", ddl=_M002.DDL,
92
+ dependencies=(_M003.NAME,)),
93
+ Migration(name=_M005.NAME, db_target="learning", ddl=_M005.DDL,
94
+ dependencies=(_M003.NAME,)),
95
+ # M009 extends learning_model_state (created by M002).
96
+ Migration(name=_M009.NAME, db_target="learning", ddl=_M009.DDL,
97
+ dependencies=(_M002.NAME,)),
98
+ # M010 creates evolution_config + evolution_llm_cost_log (learning.db).
99
+ Migration(name=_M010.NAME, db_target="learning", ddl=_M010.DDL,
100
+ dependencies=(_M003.NAME,)),
101
+ # M012 creates shadow_observations (learning.db) — paired NDCG@10
102
+ # observations for ShadowTest persistence across daemon restart.
103
+ Migration(name=_M012.NAME, db_target="learning", ddl=_M012.DDL,
104
+ dependencies=(_M003.NAME,)),
105
+ Migration(name=_M004.NAME, db_target="memory", ddl=_M004.DDL),
106
+ # M007 creates pending_outcomes (memory.db, LLD-00 §1.2).
107
+ Migration(name=_M007.NAME, db_target="memory", ddl=_M007.DDL),
108
+ # M006 + M011 are deliberately NOT here — see DEFERRED_MIGRATIONS below.
109
+ ]
110
+
111
+
112
+ # Deferred migrations run AFTER ``MemoryEngine.initialize()`` has called
113
+ # ``storage.schema.create_all_tables`` to bootstrap runtime tables such as
114
+ # ``action_outcomes``. Running them during ``apply_all`` (which fires BEFORE
115
+ # engine init on daemon startup) would blow up with "no such table".
116
+ #
117
+ # ``learning.database.fetch_training_examples`` already checks
118
+ # ``_migration_applied("M006_action_outcomes_reward")`` and falls back to the
119
+ # position proxy when the column is absent, so a failed deferred apply never
120
+ # crashes the trainer — it just keeps the old label path.
121
+ DEFERRED_MIGRATIONS: list[Migration] = [
122
+ Migration(name=_M006.NAME, db_target="memory", ddl=_M006.DDL),
123
+ # M011 extends atomic_facts + creates memory_archive / memory_merge_log.
124
+ # atomic_facts is bootstrapped at engine init, so M011 defers alongside M006.
125
+ Migration(name=_M011.NAME, db_target="memory", ddl=_M011.DDL),
126
+ # M013 adds bi-temporal columns (valid_from / valid_until) to
127
+ # atomic_facts. Deferred for the same engine-init-bootstrap reason
128
+ # as M011.
129
+ Migration(name=_M013.NAME, db_target="memory", ddl=_M013.DDL),
130
+ ]
131
+
132
+
133
+ def _now_iso() -> str:
134
+ return datetime.now(timezone.utc).isoformat()
135
+
136
+
137
+ def _ddl_hash(ddl: str) -> str:
138
+ return hashlib.sha256(ddl.encode("utf-8")).hexdigest()
139
+
140
+
141
+ def _connect(db_path: Path) -> sqlite3.Connection:
142
+ # isolation_level=None → we manage transactions explicitly via DDL.
143
+ conn = sqlite3.connect(db_path, isolation_level=None)
144
+ conn.execute("PRAGMA foreign_keys = OFF;")
145
+ return conn
146
+
147
+
148
+ def _migration_log_exists(conn: sqlite3.Connection) -> bool:
149
+ row = conn.execute(
150
+ "SELECT name FROM sqlite_master "
151
+ "WHERE type='table' AND name='migration_log'"
152
+ ).fetchone()
153
+ return row is not None
154
+
155
+
156
+ def _ensure_migration_log(conn: sqlite3.Connection) -> None:
157
+ """Bootstrap the migration_log table on a DB if absent.
158
+
159
+ Uses the M003 DDL verbatim so the runner treats migration_log identically
160
+ on both learning.db and memory.db.
161
+ """
162
+ conn.executescript(_M003.DDL)
163
+
164
+
165
+ def _get_log_row(conn: sqlite3.Connection, name: str) -> tuple | None:
166
+ return conn.execute(
167
+ "SELECT name, applied_at, ddl_sha256, rows_affected, status "
168
+ "FROM migration_log WHERE name = ?",
169
+ (name,),
170
+ ).fetchone()
171
+
172
+
173
+ def _upsert_log(
174
+ conn: sqlite3.Connection,
175
+ name: str,
176
+ ddl_hash: str,
177
+ status: str,
178
+ rows_affected: int = 0,
179
+ ) -> None:
180
+ conn.execute(
181
+ "INSERT INTO migration_log "
182
+ "(name, applied_at, ddl_sha256, rows_affected, status) "
183
+ "VALUES (?, ?, ?, ?, ?) "
184
+ "ON CONFLICT(name) DO UPDATE SET "
185
+ " applied_at = excluded.applied_at, "
186
+ " ddl_sha256 = excluded.ddl_sha256, "
187
+ " rows_affected = excluded.rows_affected, "
188
+ " status = excluded.status",
189
+ (name, _now_iso(), ddl_hash, rows_affected, status),
190
+ )
191
+
192
+
193
+ def _delete_log(conn: sqlite3.Connection, name: str) -> None:
194
+ conn.execute("DELETE FROM migration_log WHERE name = ?", (name,))
195
+
196
+
197
+ def _apply_single(
198
+ conn: sqlite3.Connection,
199
+ migration: Migration,
200
+ *,
201
+ dry_run: bool,
202
+ ) -> tuple[str, str]:
203
+ """Apply one migration against ``conn``.
204
+
205
+ Returns (outcome, detail) where outcome is one of:
206
+ - "applied"
207
+ - "skipped"
208
+ - "failed"
209
+ """
210
+ ddl_hash = _ddl_hash(migration.ddl)
211
+
212
+ # Bootstrap: if migration_log doesn't exist yet, this MUST be M003.
213
+ if not _migration_log_exists(conn):
214
+ if migration.name != _M003.NAME:
215
+ # Other migrations can't check state → treat as unrecoverable here.
216
+ return ("failed",
217
+ f"migration_log missing when attempting {migration.name}")
218
+ if dry_run:
219
+ return ("skipped", "dry-run: would create migration_log")
220
+ try:
221
+ _ensure_migration_log(conn)
222
+ _upsert_log(conn, migration.name, ddl_hash, "complete")
223
+ return ("applied", "bootstrapped migration_log")
224
+ except sqlite3.Error as exc: # pragma: no cover — defensive
225
+ logger.warning("M003 bootstrap failed: %s", exc)
226
+ return ("failed", f"bootstrap error: {exc}")
227
+
228
+ # M003 specifically — if log already exists, ensure M003's own row is there
229
+ # (records the fact that the table was bootstrapped previously).
230
+ existing = _get_log_row(conn, migration.name)
231
+
232
+ if existing is not None:
233
+ _, _, logged_hash, _, status = existing
234
+ if status == "complete":
235
+ if logged_hash != ddl_hash:
236
+ detail = (
237
+ f"DDL drift detected for {migration.name}: "
238
+ f"logged={logged_hash[:8]}... current={ddl_hash[:8]}..."
239
+ )
240
+ logger.warning(detail)
241
+ return ("failed", detail)
242
+ return ("skipped", "already complete")
243
+ # status is 'failed' or 'in_progress' → retry from scratch.
244
+ if dry_run:
245
+ return ("skipped", f"dry-run: would retry (status={status})")
246
+ try:
247
+ _delete_log(conn, migration.name)
248
+ except sqlite3.Error as exc: # pragma: no cover — log table exists
249
+ return ("failed", f"cannot clear prior log: {exc}")
250
+
251
+ if dry_run:
252
+ return ("skipped", "dry-run: would apply")
253
+
254
+ # Mark in_progress, execute, update status. If DDL fails we roll our log
255
+ # entry to 'failed' so next attempt will retry cleanly.
256
+ try:
257
+ _upsert_log(conn, migration.name, ddl_hash, "in_progress")
258
+ except sqlite3.Error as exc: # pragma: no cover
259
+ return ("failed", f"cannot record in_progress: {exc}")
260
+
261
+ try:
262
+ conn.executescript(migration.ddl)
263
+ except sqlite3.Error as exc:
264
+ # Best-effort rollback.
265
+ try:
266
+ conn.execute("ROLLBACK")
267
+ except sqlite3.Error: # pragma: no cover — best-effort
268
+ pass
269
+ # Before marking failed, check if the migration's end-state is
270
+ # already in place (e.g. crash-recovery retry against a DB where the
271
+ # columns were added in a previous partial apply). If so, this is
272
+ # effectively a successful idempotent re-run.
273
+ mod = _MODULES.get(migration.name)
274
+ verify_fn = getattr(mod, "verify", None) if mod is not None else None
275
+ if verify_fn is not None:
276
+ try:
277
+ if verify_fn(conn):
278
+ try:
279
+ _upsert_log(conn, migration.name, ddl_hash, "complete")
280
+ except sqlite3.Error: # pragma: no cover
281
+ pass
282
+ return ("applied",
283
+ "already applied (verified via schema inspection)")
284
+ except sqlite3.Error: # pragma: no cover
285
+ pass
286
+
287
+ logger.warning("Migration %s failed: %s", migration.name, exc)
288
+ try:
289
+ _upsert_log(conn, migration.name, ddl_hash, "failed")
290
+ except sqlite3.Error: # pragma: no cover
291
+ pass
292
+ return ("failed", f"{type(exc).__name__}: {exc}")
293
+
294
+ # S9-W1 H-DATA-01: optional post-DDL Python hook. Runs inside the same
295
+ # connection (same DB file) after the DDL commits. Used by M002 to
296
+ # backfill ``bytes_sha256`` on rows copied forward by the new-table
297
+ # rename. If the hook raises, the migration is marked failed; the DDL
298
+ # is NOT rolled back (already committed) but the runner reports the
299
+ # problem so operators can intervene. Non-existent hooks are a no-op.
300
+ mod = _MODULES.get(migration.name)
301
+ post_hook = getattr(mod, "post_ddl_hook", None) if mod is not None else None
302
+ if post_hook is not None:
303
+ try:
304
+ post_hook(conn)
305
+ except Exception as exc: # noqa: BLE001 — report + mark failed
306
+ logger.warning(
307
+ "Migration %s DDL applied but post_ddl_hook failed: %s",
308
+ migration.name, exc,
309
+ )
310
+ try:
311
+ _upsert_log(conn, migration.name, ddl_hash, "failed")
312
+ except sqlite3.Error: # pragma: no cover
313
+ pass
314
+ return ("failed", f"post_ddl_hook: {type(exc).__name__}: {exc}")
315
+
316
+ try:
317
+ _upsert_log(conn, migration.name, ddl_hash, "complete")
318
+ except sqlite3.Error as exc: # pragma: no cover
319
+ return ("failed", f"cannot record complete: {exc}")
320
+ return ("applied", "ok")
321
+
322
+
323
+ def _db_for(target: str, learning_db: Path, memory_db: Path) -> Path:
324
+ if target == "learning":
325
+ return learning_db
326
+ if target == "memory":
327
+ return memory_db
328
+ raise ValueError(f"unknown db_target: {target}") # pragma: no cover
329
+
330
+
331
+ def _bootstrap_both_migration_logs(
332
+ learning_db: Path, memory_db: Path, *, dry_run: bool,
333
+ ) -> tuple[list[str], dict[str, str]]:
334
+ """S9-W1 C3: bootstrap ``migration_log`` on BOTH DBs up-front.
335
+
336
+ Prior versions deferred memory-side bootstrap until the first memory
337
+ migration ran in ``apply_all``, and ``apply_deferred`` did its own
338
+ independent bootstrap. That created a split-brain failure mode: if
339
+ ``apply_all`` crashed before any memory migration ran (e.g. disk-full
340
+ on learning-side M005), the memory DB never got its log table, and
341
+ ``apply_deferred`` would later create one without any record of the
342
+ sync-set attempt. Memory DB is sacred — 18k+ atomic_facts.
343
+
344
+ By bootstrapping both DBs up-front here, we make the invariant
345
+ "migration_log exists on both DBs before any migration runs" hold
346
+ unconditionally. Returns (failed_names, details) for any DB where
347
+ bootstrap fails.
348
+ """
349
+ failed: list[str] = []
350
+ details: dict[str, str] = {}
351
+ if dry_run:
352
+ return failed, details
353
+ for label, db_path in (("learning_db", learning_db),
354
+ ("memory_db", memory_db)):
355
+ try:
356
+ conn = _connect(db_path)
357
+ except sqlite3.Error as exc: # pragma: no cover — defensive
358
+ failed.append(label)
359
+ details[label] = f"cannot open db for log bootstrap: {exc}"
360
+ continue
361
+ try:
362
+ if not _migration_log_exists(conn):
363
+ _ensure_migration_log(conn)
364
+ except sqlite3.Error as exc: # pragma: no cover — defensive
365
+ failed.append(label)
366
+ details[label] = f"migration_log bootstrap failed: {exc}"
367
+ finally:
368
+ try:
369
+ conn.close()
370
+ except sqlite3.Error: # pragma: no cover
371
+ pass
372
+ return failed, details
373
+
374
+
375
+ def apply_all(
376
+ learning_db: Path,
377
+ memory_db: Path,
378
+ *,
379
+ dry_run: bool = False,
380
+ ) -> dict:
381
+ """Apply all v3.4.21 migrations; return stats.
382
+
383
+ Idempotent: already-applied migrations are skipped. Non-fatal: any
384
+ migration that fails is recorded in ``failed`` and the runner moves on.
385
+ """
386
+ applied: list[str] = []
387
+ skipped: list[str] = []
388
+ failed: list[str] = []
389
+ details: dict[str, str] = {}
390
+
391
+ # S9-W1 C3: unify the migration_log bootstrap across both DBs up-front.
392
+ bs_failed, bs_details = _bootstrap_both_migration_logs(
393
+ learning_db, memory_db, dry_run=dry_run,
394
+ )
395
+ failed.extend(bs_failed)
396
+ details.update(bs_details)
397
+
398
+ for migration in MIGRATIONS:
399
+ db_path = _db_for(migration.db_target, learning_db, memory_db)
400
+ try:
401
+ conn = _connect(db_path)
402
+ except sqlite3.Error as exc: # pragma: no cover — defensive
403
+ failed.append(migration.name)
404
+ details[migration.name] = f"cannot open db: {exc}"
405
+ continue
406
+
407
+ try:
408
+ outcome, detail = _apply_single(conn, migration, dry_run=dry_run)
409
+ details[migration.name] = detail
410
+ if outcome == "applied":
411
+ applied.append(migration.name)
412
+ elif outcome == "skipped":
413
+ skipped.append(migration.name)
414
+ else:
415
+ failed.append(migration.name)
416
+ finally:
417
+ try:
418
+ conn.close()
419
+ except sqlite3.Error: # pragma: no cover
420
+ pass
421
+
422
+ return {
423
+ "applied": applied,
424
+ "skipped": skipped,
425
+ "failed": failed,
426
+ "details": details,
427
+ }
428
+
429
+
430
+ def apply_deferred(
431
+ learning_db: Path,
432
+ memory_db: Path,
433
+ *,
434
+ dry_run: bool = False,
435
+ ) -> dict:
436
+ """Apply deferred migrations; return the same stats shape as apply_all.
437
+
438
+ Deferred migrations target runtime-bootstrapped tables (e.g.
439
+ ``action_outcomes``) that don't exist until ``MemoryEngine.initialize()``
440
+ has run ``storage.schema.create_all_tables``. The daemon lifespan calls
441
+ this immediately after engine init.
442
+
443
+ Same idempotency + non-fatal guarantees as ``apply_all``. If the target
444
+ table is still missing, the underlying DDL raises ``no such table`` and
445
+ the migration is recorded as ``failed`` — safe, the trainer already
446
+ falls back to the position proxy when M006 hasn't completed.
447
+ """
448
+ applied: list[str] = []
449
+ skipped: list[str] = []
450
+ failed: list[str] = []
451
+ details: dict[str, str] = {}
452
+
453
+ for migration in DEFERRED_MIGRATIONS:
454
+ db_path = _db_for(migration.db_target, learning_db, memory_db)
455
+ try:
456
+ conn = _connect(db_path)
457
+ except sqlite3.Error as exc: # pragma: no cover — defensive
458
+ failed.append(migration.name)
459
+ details[migration.name] = f"cannot open db: {exc}"
460
+ continue
461
+
462
+ try:
463
+ # S9-W1 C3: apply_deferred must NOT independently bootstrap
464
+ # migration_log. apply_all is the single source of truth for
465
+ # log-table creation (bootstraps BOTH DBs up-front). A missing
466
+ # log here means apply_all never ran or crashed catastrophically
467
+ # before touching this DB — fail loudly so the operator can
468
+ # run apply_all first instead of letting the deferred path
469
+ # silently create a table that records nothing of the sync set.
470
+ if not _migration_log_exists(conn):
471
+ failed.append(migration.name)
472
+ details[migration.name] = (
473
+ "migration_log missing on target DB — apply_all must "
474
+ "run first (or failed before reaching this DB); "
475
+ "refusing to create split-brain log"
476
+ )
477
+ continue
478
+
479
+ outcome, detail = _apply_single(conn, migration, dry_run=dry_run)
480
+ details[migration.name] = detail
481
+ if outcome == "applied":
482
+ applied.append(migration.name)
483
+ elif outcome == "skipped":
484
+ skipped.append(migration.name)
485
+ else:
486
+ failed.append(migration.name)
487
+ finally:
488
+ try:
489
+ conn.close()
490
+ except sqlite3.Error: # pragma: no cover
491
+ pass
492
+
493
+ return {
494
+ "applied": applied,
495
+ "skipped": skipped,
496
+ "failed": failed,
497
+ "details": details,
498
+ }
499
+
500
+
501
+ def status(learning_db: Path, memory_db: Path) -> dict[str, str]:
502
+ """Return the per-migration status as recorded in the target DB.
503
+
504
+ Values: ``"complete"``, ``"failed"``, ``"in_progress"``, or ``"missing"``.
505
+ Includes both ``MIGRATIONS`` and ``DEFERRED_MIGRATIONS``.
506
+ """
507
+ out: dict[str, str] = {}
508
+ # Read-only — if the DB doesn't have migration_log, every migration is
509
+ # reported as "missing".
510
+ cached: dict[str, dict[str, str]] = {}
511
+ for migration in (*MIGRATIONS, *DEFERRED_MIGRATIONS):
512
+ db_path = _db_for(migration.db_target, learning_db, memory_db)
513
+ db_key = str(db_path)
514
+ if db_key not in cached:
515
+ cached[db_key] = _read_log(db_path)
516
+ out[migration.name] = cached[db_key].get(migration.name, "missing")
517
+ return out
518
+
519
+
520
+ def _read_log(db_path: Path) -> dict[str, str]:
521
+ try:
522
+ conn = sqlite3.connect(db_path)
523
+ except sqlite3.Error: # pragma: no cover
524
+ return {}
525
+ try:
526
+ if not _migration_log_exists(conn):
527
+ return {}
528
+ rows = conn.execute(
529
+ "SELECT name, status FROM migration_log"
530
+ ).fetchall()
531
+ return {name: status for (name, status) in rows}
532
+ except sqlite3.Error: # pragma: no cover
533
+ return {}
534
+ finally:
535
+ conn.close()
536
+
537
+
538
+ __all__ = (
539
+ "Migration",
540
+ "MIGRATIONS",
541
+ "DEFERRED_MIGRATIONS",
542
+ "apply_all",
543
+ "apply_deferred",
544
+ "status",
545
+ )
@@ -0,0 +1,67 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory v3.4.21 — LLD-07 §3.1
4
+
5
+ """M001 — add rich-signal columns to learning_signals + learning_features.
6
+
7
+ Additive only — uses ``ALTER TABLE ADD COLUMN``. No data loss, no type
8
+ changes. Every ADD is guarded by the runner's idempotency check against
9
+ ``migration_log``; on rerun the migration is skipped outright so duplicate
10
+ ALTER errors never surface.
11
+
12
+ The runner wraps this DDL in ``BEGIN IMMEDIATE`` / ``COMMIT`` when needed.
13
+ SQLite DDL is transactional except for schema-version bumps, so the BEGIN
14
+ here is defense-in-depth against partial application on crash.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sqlite3
20
+
21
+ NAME = "M001_add_signal_features_columns"
22
+ DB_TARGET = "learning"
23
+
24
+ _REQUIRED_SIGNAL_COLS = frozenset({
25
+ "query_id", "query_text_hash", "position", "channel_scores", "cross_encoder",
26
+ })
27
+ _REQUIRED_FEATURE_COLS = frozenset({"signal_id", "is_synthetic"})
28
+
29
+
30
+ def verify(conn: sqlite3.Connection) -> bool:
31
+ """Return True if the migration's end-state is already present."""
32
+ try:
33
+ sig_cols = {r[1] for r in conn.execute(
34
+ "PRAGMA table_info(learning_signals)"
35
+ ).fetchall()}
36
+ feat_cols = {r[1] for r in conn.execute(
37
+ "PRAGMA table_info(learning_features)"
38
+ ).fetchall()}
39
+ except sqlite3.Error:
40
+ return False
41
+ return (_REQUIRED_SIGNAL_COLS <= sig_cols
42
+ and _REQUIRED_FEATURE_COLS <= feat_cols)
43
+
44
+ DDL = """
45
+ BEGIN IMMEDIATE;
46
+
47
+ ALTER TABLE learning_signals ADD COLUMN query_id TEXT DEFAULT '';
48
+ ALTER TABLE learning_signals ADD COLUMN query_text_hash TEXT DEFAULT '';
49
+ ALTER TABLE learning_signals ADD COLUMN position INTEGER DEFAULT 0;
50
+ ALTER TABLE learning_signals ADD COLUMN channel_scores TEXT DEFAULT '{}';
51
+ ALTER TABLE learning_signals ADD COLUMN cross_encoder REAL;
52
+
53
+ CREATE INDEX IF NOT EXISTS idx_signals_profile_time
54
+ ON learning_signals(profile_id, created_at);
55
+ CREATE INDEX IF NOT EXISTS idx_signals_query_id
56
+ ON learning_signals(query_id);
57
+
58
+ ALTER TABLE learning_features ADD COLUMN signal_id INTEGER DEFAULT 0;
59
+ ALTER TABLE learning_features ADD COLUMN is_synthetic INTEGER NOT NULL DEFAULT 0;
60
+
61
+ CREATE INDEX IF NOT EXISTS idx_features_signal
62
+ ON learning_features(signal_id);
63
+ CREATE INDEX IF NOT EXISTS idx_features_synthetic
64
+ ON learning_features(is_synthetic) WHERE is_synthetic = 0;
65
+
66
+ COMMIT;
67
+ """