superlocalmemory 3.4.19 → 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.
- package/CHANGELOG.md +24 -0
- package/README.md +42 -34
- package/bin/slm +11 -0
- package/bin/slm.bat +12 -0
- package/package.json +4 -3
- package/pyproject.toml +3 -2
- package/scripts/build-slm-hook.ps1 +40 -0
- package/scripts/build-slm-hook.sh +45 -0
- package/scripts/build_entry.py +452 -0
- package/scripts/ci/stage5b_gate.sh +50 -0
- package/scripts/postinstall/validation.js +187 -0
- package/scripts/postinstall-interactive.js +756 -0
- package/scripts/postinstall_binary.js +287 -0
- package/scripts/release_manifest.py +273 -0
- package/scripts/slm-hook.spec +56 -0
- package/skills/slm-build-graph/SKILL.md +423 -0
- package/skills/slm-list-recent/SKILL.md +348 -0
- package/skills/slm-recall/SKILL.md +343 -0
- package/skills/slm-remember/SKILL.md +194 -0
- package/skills/slm-show-patterns/SKILL.md +224 -0
- package/skills/slm-status/SKILL.md +363 -0
- package/skills/slm-switch-profile/SKILL.md +442 -0
- package/src/superlocalmemory/cli/commands.py +219 -79
- package/src/superlocalmemory/cli/context_commands.py +192 -0
- package/src/superlocalmemory/cli/daemon.py +15 -1
- package/src/superlocalmemory/cli/db_migrate.py +80 -0
- package/src/superlocalmemory/cli/escape_hatch.py +220 -0
- package/src/superlocalmemory/cli/main.py +72 -1
- package/src/superlocalmemory/core/context_cache.py +397 -0
- package/src/superlocalmemory/core/engine.py +38 -2
- package/src/superlocalmemory/core/engine_wiring.py +1 -1
- package/src/superlocalmemory/core/ram_lock.py +111 -0
- package/src/superlocalmemory/core/recall_pipeline.py +433 -3
- package/src/superlocalmemory/core/recall_worker.py +8 -3
- package/src/superlocalmemory/core/security_primitives.py +635 -0
- package/src/superlocalmemory/core/shadow_router.py +319 -0
- package/src/superlocalmemory/core/slm_disabled.py +87 -0
- package/src/superlocalmemory/core/slmignore.py +125 -0
- package/src/superlocalmemory/core/topic_signature.py +143 -0
- package/src/superlocalmemory/core/worker_pool.py +14 -3
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
- package/src/superlocalmemory/evolution/budget.py +321 -0
- package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
- package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
- package/src/superlocalmemory/hooks/adapter_base.py +317 -0
- package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
- package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
- package/src/superlocalmemory/hooks/context_payload.py +312 -0
- package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
- package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
- package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
- package/src/superlocalmemory/hooks/ide_connector.py +25 -2
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
- package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
- package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
- package/src/superlocalmemory/hooks/session_registry.py +186 -0
- package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
- package/src/superlocalmemory/hooks/sync_loop.py +114 -0
- package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
- package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
- package/src/superlocalmemory/infra/backup.py +3 -3
- package/src/superlocalmemory/infra/cloud_backup.py +2 -2
- package/src/superlocalmemory/infra/event_bus.py +2 -2
- package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
- package/src/superlocalmemory/learning/arm_catalog.py +99 -0
- package/src/superlocalmemory/learning/bandit.py +526 -0
- package/src/superlocalmemory/learning/bandit_cache.py +133 -0
- package/src/superlocalmemory/learning/behavioral.py +53 -1
- package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
- package/src/superlocalmemory/learning/database.py +256 -0
- package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
- package/src/superlocalmemory/learning/ensemble.py +300 -0
- package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
- package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
- package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
- package/src/superlocalmemory/learning/labeler.py +87 -0
- package/src/superlocalmemory/learning/legacy_migration.py +277 -0
- package/src/superlocalmemory/learning/memory_merge.py +160 -0
- package/src/superlocalmemory/learning/model_cache.py +269 -0
- package/src/superlocalmemory/learning/model_rollback.py +278 -0
- package/src/superlocalmemory/learning/outcome_queue.py +284 -0
- package/src/superlocalmemory/learning/pattern_miner.py +415 -0
- package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
- package/src/superlocalmemory/learning/ranker.py +225 -81
- package/src/superlocalmemory/learning/ranker_common.py +163 -0
- package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
- package/src/superlocalmemory/learning/reward.py +777 -0
- package/src/superlocalmemory/learning/reward_archive.py +210 -0
- package/src/superlocalmemory/learning/reward_boost.py +201 -0
- package/src/superlocalmemory/learning/reward_proxy.py +326 -0
- package/src/superlocalmemory/learning/shadow_test.py +524 -0
- package/src/superlocalmemory/learning/signal_worker.py +270 -0
- package/src/superlocalmemory/learning/signals.py +314 -0
- package/src/superlocalmemory/learning/trigram_index.py +547 -0
- package/src/superlocalmemory/mcp/server.py +5 -5
- package/src/superlocalmemory/mcp/tools_context.py +183 -0
- package/src/superlocalmemory/mcp/tools_core.py +92 -27
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
- package/src/superlocalmemory/retrieval/engine.py +52 -0
- package/src/superlocalmemory/server/api.py +2 -2
- package/src/superlocalmemory/server/bandit_loops.py +140 -0
- package/src/superlocalmemory/server/middleware/__init__.py +11 -0
- package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
- package/src/superlocalmemory/server/routes/backup.py +36 -13
- package/src/superlocalmemory/server/routes/behavioral.py +50 -19
- package/src/superlocalmemory/server/routes/brain.py +1234 -0
- package/src/superlocalmemory/server/routes/data_io.py +4 -4
- package/src/superlocalmemory/server/routes/events.py +2 -2
- package/src/superlocalmemory/server/routes/helpers.py +1 -1
- package/src/superlocalmemory/server/routes/learning.py +192 -7
- package/src/superlocalmemory/server/routes/memories.py +189 -1
- package/src/superlocalmemory/server/routes/prewarm.py +171 -0
- package/src/superlocalmemory/server/routes/profiles.py +3 -3
- package/src/superlocalmemory/server/routes/token.py +88 -0
- package/src/superlocalmemory/server/routes/ws.py +5 -5
- package/src/superlocalmemory/server/security_middleware.py +13 -7
- package/src/superlocalmemory/server/ui.py +2 -2
- package/src/superlocalmemory/server/unified_daemon.py +335 -3
- package/src/superlocalmemory/storage/migration_runner.py +545 -0
- package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
- package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
- package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
- package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
- package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
- package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
- package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
- package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
- package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/ui/css/brain.css +409 -0
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
- package/src/superlocalmemory/ui/index.html +459 -1345
- package/src/superlocalmemory/ui/js/brain.js +1321 -0
- package/src/superlocalmemory/ui/js/clusters.js +123 -4
- package/src/superlocalmemory/ui/js/init.js +48 -39
- package/src/superlocalmemory/ui/js/memories.js +88 -2
- package/src/superlocalmemory/ui/js/modal.js +71 -1
- package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
- package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
- package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
- package/src/superlocalmemory/ui/js/behavioral.js +0 -447
- package/src/superlocalmemory/ui/js/graph-core.js +0 -447
- package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
- package/src/superlocalmemory/ui/js/learning.js +0 -435
- package/src/superlocalmemory/ui/js/patterns.js +0 -93
- package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
- package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
- package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
- package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
- package/src/superlocalmemory.egg-info/requires.txt +0 -58
- 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
|
+
"""
|