superlocalmemory 4.0.8 → 4.0.9
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 +146 -1
- package/README.md +6 -6
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/scripts/ensure-venv.sh +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +3 -3
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +2 -2
- package/pyproject.toml +1 -1
- package/scripts/postinstall.js +4 -0
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/_lazy_init.py +1 -1
- package/src/superlocalmemory/cli/commands.py +119 -9
- package/src/superlocalmemory/cli/db_migrate.py +0 -2
- package/src/superlocalmemory/cli/gdpr_io.py +1 -1
- package/src/superlocalmemory/cli/main.py +5 -5
- package/src/superlocalmemory/cli/service_installer.py +2 -1
- package/src/superlocalmemory/cli/setup_wizard.py +1 -1
- package/src/superlocalmemory/core/config.py +41 -7
- package/src/superlocalmemory/core/context_cache.py +0 -2
- package/src/superlocalmemory/core/engine.py +371 -63
- package/src/superlocalmemory/core/evidence_bundle.py +3 -1
- package/src/superlocalmemory/core/install_detector.py +131 -0
- package/src/superlocalmemory/core/progressive_abstraction.py +1 -1
- package/src/superlocalmemory/core/security_primitives.py +3 -6
- package/src/superlocalmemory/core/store_pipeline.py +94 -26
- package/src/superlocalmemory/core/topic_signature.py +0 -2
- package/src/superlocalmemory/core/transactions/concrete_owners.py +15 -8
- package/src/superlocalmemory/dynamics/eap_scheduler.py +17 -6
- package/src/superlocalmemory/encoding/graph_builder.py +2 -2
- package/src/superlocalmemory/encoding/scene_builder.py +8 -2
- package/src/superlocalmemory/hooks/adapter_base.py +0 -2
- package/src/superlocalmemory/hooks/context_payload.py +0 -2
- package/src/superlocalmemory/hooks/portable_kit.py +8 -8
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +0 -2
- package/src/superlocalmemory/hooks/prewarm_auth.py +0 -2
- package/src/superlocalmemory/hooks/user_prompt_hook.py +0 -2
- package/src/superlocalmemory/infra/backup.py +44 -8
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +24 -7
- package/src/superlocalmemory/learning/arm_catalog.py +0 -2
- package/src/superlocalmemory/learning/bandit.py +0 -2
- package/src/superlocalmemory/learning/bandit_cache.py +0 -2
- package/src/superlocalmemory/learning/dedup_hnsw.py +11 -11
- package/src/superlocalmemory/learning/ensemble.py +0 -2
- package/src/superlocalmemory/learning/labeler.py +0 -2
- package/src/superlocalmemory/learning/legacy_migration.py +0 -2
- package/src/superlocalmemory/learning/model_cache.py +0 -2
- package/src/superlocalmemory/learning/ranker.py +0 -2
- package/src/superlocalmemory/learning/reward_archive.py +6 -1
- package/src/superlocalmemory/learning/reward_proxy.py +0 -2
- package/src/superlocalmemory/learning/signal_worker.py +0 -2
- package/src/superlocalmemory/math/fisher.py +1 -1
- package/src/superlocalmemory/math/hopfield.py +4 -1
- package/src/superlocalmemory/math/langevin.py +1 -1
- package/src/superlocalmemory/math/sheaf.py +7 -3
- package/src/superlocalmemory/mcp/cli_fallback.py +1 -1
- package/src/superlocalmemory/mcp/profiles.py +1 -1
- package/src/superlocalmemory/mcp/server.py +1 -1
- package/src/superlocalmemory/mcp/tools_active.py +56 -0
- package/src/superlocalmemory/mcp/tools_core.py +1 -1
- package/src/superlocalmemory/optimize/cache/manager.py +2 -2
- package/src/superlocalmemory/optimize/compress/ccr.py +1 -1
- package/src/superlocalmemory/optimize/compress/router.py +1 -1
- package/src/superlocalmemory/optimize/proxy/_helpers.py +2 -2
- package/src/superlocalmemory/optimize/proxy/server.py +1 -1
- package/src/superlocalmemory/optimize/proxy/vertex_surface.py +2 -2
- package/src/superlocalmemory/optimize/storage/db.py +2 -2
- package/src/superlocalmemory/retrieval/agentic.py +1 -1
- package/src/superlocalmemory/retrieval/ann_index.py +9 -2
- package/src/superlocalmemory/retrieval/bm25_channel.py +2 -2
- package/src/superlocalmemory/retrieval/bridge_discovery.py +2 -2
- package/src/superlocalmemory/retrieval/engine.py +272 -43
- package/src/superlocalmemory/retrieval/entity_channel.py +1 -1
- package/src/superlocalmemory/retrieval/hopfield_channel.py +8 -2
- package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
- package/src/superlocalmemory/retrieval/quantization_aware_search.py +1 -1
- package/src/superlocalmemory/retrieval/remote_reranker.py +2 -2
- package/src/superlocalmemory/retrieval/reranker.py +3 -3
- package/src/superlocalmemory/retrieval/semantic_channel.py +3 -3
- package/src/superlocalmemory/retrieval/spreading_activation.py +8 -8
- package/src/superlocalmemory/retrieval/strategy.py +94 -0
- package/src/superlocalmemory/retrieval/temporal_channel.py +167 -10
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +1 -1
- package/src/superlocalmemory/retrieval/vector_store.py +88 -10
- package/src/superlocalmemory/server/recall_serializer.py +10 -0
- package/src/superlocalmemory/server/routes/brain.py +2 -2
- package/src/superlocalmemory/server/routes/prewarm.py +4 -4
- package/src/superlocalmemory/server/unified_daemon.py +459 -7
- package/src/superlocalmemory/storage/_schema_version.py +46 -3
- package/src/superlocalmemory/storage/backup.py +531 -0
- package/src/superlocalmemory/storage/database.py +11 -4
- package/src/superlocalmemory/storage/embedding_codec.py +129 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +5 -3
- package/src/superlocalmemory/storage/migration_runner.py +142 -2
- package/src/superlocalmemory/storage/migrations/__init__.py +1 -1
- package/src/superlocalmemory/storage/migrations.py +15 -1
- package/src/superlocalmemory/storage/models.py +7 -0
- package/src/superlocalmemory/storage/quantized_store.py +4 -2
- package/src/superlocalmemory/summaries/session_summary.py +1 -1
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +1 -1
- package/src/superlocalmemory/ui/css/neural-glass.css +1 -1
- package/src/superlocalmemory/ui/js/core.js +1 -1
- package/src/superlocalmemory/ui/js/od-brain.js +1 -1
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
|
|
4
|
+
"""Pre-migration database backup utilities.
|
|
5
|
+
|
|
6
|
+
Provides a consistent, WAL-safe snapshot of both managed databases before
|
|
7
|
+
any schema migration runs. Uses the SQLite backup API rather than a
|
|
8
|
+
filesystem copy so that in-flight writers on a live WAL database cannot
|
|
9
|
+
produce a torn snapshot.
|
|
10
|
+
|
|
11
|
+
Snapshots are written as flat files directly in ``snapshots_root``
|
|
12
|
+
(default: ``canonical_data_root() / "pre-migration-snapshots"``).
|
|
13
|
+
This directory is separate from the ``backups/`` directory managed by
|
|
14
|
+
``BackupManager``, so pre-migration snapshots are never subject to
|
|
15
|
+
``BackupManager._enforce_retention()`` regardless of how many ordinary
|
|
16
|
+
backups accumulate. The separation is structural — not timing-dependent.
|
|
17
|
+
|
|
18
|
+
Public API intended for use by the migration runner:
|
|
19
|
+
- _backup_via_sqlite_api(src, dest)
|
|
20
|
+
- _pre_migration_backup(learning_db, memory_db, *, backups_root) -> Path
|
|
21
|
+
- _gc_old_backups(backups_root, keep=2) -> None
|
|
22
|
+
- InsufficientDiskSpaceError
|
|
23
|
+
|
|
24
|
+
Restoring a pre-migration snapshot
|
|
25
|
+
-----------------------------------
|
|
26
|
+
Use ``restore_pre_migration_snapshot()`` in this module::
|
|
27
|
+
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from superlocalmemory.storage.backup import restore_pre_migration_snapshot
|
|
30
|
+
from superlocalmemory.infra.data_root import canonical_data_root
|
|
31
|
+
|
|
32
|
+
root = canonical_data_root()
|
|
33
|
+
snap = root / "pre-migration-snapshots" / "memory-20260819-120000-pre-migration.db"
|
|
34
|
+
restore_pre_migration_snapshot(snap, root / "memory.db")
|
|
35
|
+
|
|
36
|
+
It verifies the snapshot is a readable database with content and refuses before
|
|
37
|
+
touching the live store if it is not, copies the current live database aside
|
|
38
|
+
into ``pre-restore/`` first, and only then writes the snapshot into place.
|
|
39
|
+
|
|
40
|
+
**Do not use ``BackupManager.restore_backup()`` for these snapshots.** It checks
|
|
41
|
+
that the source exists, then takes its own pre-restore backup, which runs
|
|
42
|
+
retention across the same directory. Retention can unlink the file being
|
|
43
|
+
restored; ``sqlite3.connect`` then recreates that path as an EMPTY database, and
|
|
44
|
+
the empty database is copied over the live store — and the call returns ``True``.
|
|
45
|
+
The snapshot is left as a zero-byte file under its original name, so a second
|
|
46
|
+
attempt also appears to succeed. Reproduced: a 500-fact store became 0 tables
|
|
47
|
+
while the call reported success.
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
Why snapshots live outside ``backups/``
|
|
51
|
+
----------------------------------------
|
|
52
|
+
``BackupManager._enforce_retention()`` globs only its own ``backup_dir``
|
|
53
|
+
(``canonical_data_root() / "backups"``). Because pre-migration snapshots
|
|
54
|
+
are in ``pre-migration-snapshots/`` — a completely different directory — no
|
|
55
|
+
retention policy can delete them, regardless of how many ordinary backups
|
|
56
|
+
accumulate. An ordinary ``BackupManager()`` call (without ``backup_dir``
|
|
57
|
+
override) will not list or touch these files. That is intentional.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
import logging
|
|
63
|
+
import re
|
|
64
|
+
import shutil
|
|
65
|
+
import os
|
|
66
|
+
import sqlite3
|
|
67
|
+
import time
|
|
68
|
+
from datetime import UTC, datetime
|
|
69
|
+
from pathlib import Path
|
|
70
|
+
|
|
71
|
+
from superlocalmemory.infra.data_root import canonical_data_root
|
|
72
|
+
|
|
73
|
+
logger = logging.getLogger(__name__)
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Filename parsing helpers for snapshot ordering and generation grouping
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
# Timestamps in snapshot filenames are YYYYMMDD-HHmmss-ffffff (microseconds).
|
|
80
|
+
# _free_name appends a collision suffix -N (integer ≥ 1) when a file already
|
|
81
|
+
# exists. Both forms are matched by this pattern.
|
|
82
|
+
_SNAPSHOT_TIMESTAMP_RE = re.compile(r"^(\d{8}-\d{6}-\d{6})(?:-(\d+))?$")
|
|
83
|
+
|
|
84
|
+
# Pattern used to extract a timestamp (and optional collision suffix) from the
|
|
85
|
+
# RIGHT side of a stripped filename. Anchoring at the END means the stem may
|
|
86
|
+
# contain hyphens without confusing the parser: only the rightmost field that
|
|
87
|
+
# looks like a full YYYYMMDD-HHmmss-ffffff[-N] is extracted.
|
|
88
|
+
_SNAPSHOT_TIMESTAMP_TAIL_RE = re.compile(
|
|
89
|
+
r"(\d{8}-\d{6}-\d{6})(?:-(\d+))?$"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _extract_snapshot_stamp(name_without_suffix: str) -> re.Match | None:
|
|
94
|
+
"""Return a regex match for the timestamp[-N] tail of a stripped filename.
|
|
95
|
+
|
|
96
|
+
``name_without_suffix`` is the filename after removing the
|
|
97
|
+
``-pre-migration.db`` suffix (i.e. ``{stem}-{timestamp}[-{N}]``).
|
|
98
|
+
|
|
99
|
+
Using ``re.search`` anchored at ``$`` instead of ``split('-', 1)[-1]``
|
|
100
|
+
means a stem that contains hyphens (e.g. ``pre-migration``) does not
|
|
101
|
+
shift the extracted timestamp to the right, which would cause the file
|
|
102
|
+
to sort as if it were newer than any validly-named snapshot.
|
|
103
|
+
"""
|
|
104
|
+
return _SNAPSHOT_TIMESTAMP_TAIL_RE.search(name_without_suffix)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_generation_stamp(raw_stamp: str) -> str:
|
|
108
|
+
"""Return the base YYYYMMDD-HHmmss-ffffff portion of a raw snapshot stamp.
|
|
109
|
+
|
|
110
|
+
Strips any trailing -N collision suffix added by ``_free_name``, so that::
|
|
111
|
+
|
|
112
|
+
memory-20260819-120000-123456-pre-migration.db
|
|
113
|
+
memory-20260819-120000-123456-1-pre-migration.db
|
|
114
|
+
|
|
115
|
+
are recognised as the same logical generation.
|
|
116
|
+
|
|
117
|
+
If the stamp does not match the expected pattern (e.g. an externally
|
|
118
|
+
created file), the raw stamp is returned unchanged so GC does not crash.
|
|
119
|
+
"""
|
|
120
|
+
m = _SNAPSHOT_TIMESTAMP_RE.match(raw_stamp)
|
|
121
|
+
return m.group(1) if m else raw_stamp
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _snapshot_sort_key(path: Path) -> tuple[str, int]:
|
|
125
|
+
"""Stable sort key for a ``*-pre-migration.db`` file.
|
|
126
|
+
|
|
127
|
+
Returns ``(base_timestamp, collision_suffix_int)`` so that:
|
|
128
|
+
|
|
129
|
+
* Files from earlier migrations sort before later ones.
|
|
130
|
+
* Among files sharing a base timestamp (collision duplicates), the
|
|
131
|
+
unsuffixed file sorts first (-0) and each higher suffix follows.
|
|
132
|
+
|
|
133
|
+
Sorting ascending and indexing ``[-1]`` yields the newest snapshot,
|
|
134
|
+
regardless of filesystem mtime granularity.
|
|
135
|
+
|
|
136
|
+
The timestamp is found by searching from the right of the stripped name
|
|
137
|
+
rather than by splitting on the first hyphen, so stems that contain
|
|
138
|
+
hyphens do not corrupt the extracted timestamp.
|
|
139
|
+
"""
|
|
140
|
+
name = path.name
|
|
141
|
+
stripped = name.rsplit("-pre-migration.db", 1)[0]
|
|
142
|
+
m = _extract_snapshot_stamp(stripped)
|
|
143
|
+
if m:
|
|
144
|
+
base = m.group(1)
|
|
145
|
+
suffix = int(m.group(2)) if m.group(2) is not None else 0
|
|
146
|
+
return (base, suffix)
|
|
147
|
+
# Unrecognised filename format; use the full stripped name as a fallback
|
|
148
|
+
# key so GC never crashes, but log a warning — a file that lands here
|
|
149
|
+
# may sort unexpectedly relative to validly-named snapshots.
|
|
150
|
+
logger.warning(
|
|
151
|
+
"[SLM] Cannot parse timestamp from snapshot filename %r; "
|
|
152
|
+
"it will sort by raw name and may be kept or deleted out of order",
|
|
153
|
+
name,
|
|
154
|
+
)
|
|
155
|
+
return (stripped, 0)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class InsufficientDiskSpaceError(Exception):
|
|
159
|
+
"""Raised when the filesystem cannot hold the pre-migration backup.
|
|
160
|
+
|
|
161
|
+
Attributes:
|
|
162
|
+
needed_bytes: How many bytes would be required.
|
|
163
|
+
free_bytes: How many bytes are currently available.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def __init__(self, needed_bytes: int, free_bytes: int) -> None:
|
|
167
|
+
self.needed_bytes = needed_bytes
|
|
168
|
+
self.free_bytes = free_bytes
|
|
169
|
+
super().__init__(
|
|
170
|
+
f"Insufficient disk space for pre-migration backup: "
|
|
171
|
+
f"need {needed_bytes:,} bytes, have {free_bytes:,} bytes free"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _backup_via_sqlite_api(src: Path, dest: Path) -> None:
|
|
176
|
+
"""Copy a live SQLite database to dest using the SQLite backup API.
|
|
177
|
+
|
|
178
|
+
Unlike a filesystem copy, sqlite3.Connection.backup() acquires page-level
|
|
179
|
+
read locks one batch at a time, allowing concurrent writers to proceed
|
|
180
|
+
between batches. The resulting snapshot reflects only committed pages —
|
|
181
|
+
uncommitted WAL frames are never included.
|
|
182
|
+
|
|
183
|
+
dest.parent is created if it does not exist.
|
|
184
|
+
Both connections are closed in a finally block even if an error occurs.
|
|
185
|
+
"""
|
|
186
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
|
|
188
|
+
# Write to a temporary sibling and rename into place. A copy interrupted by
|
|
189
|
+
# a full disk or a crash would otherwise leave a truncated file at the final
|
|
190
|
+
# name — a snapshot that looks present and restores nothing. rename() within
|
|
191
|
+
# one directory is atomic, so the final name only ever appears complete.
|
|
192
|
+
staging = dest.with_name(dest.name + ".partial")
|
|
193
|
+
src_conn = sqlite3.connect(str(src), check_same_thread=False)
|
|
194
|
+
dst_conn = sqlite3.connect(str(staging))
|
|
195
|
+
try:
|
|
196
|
+
# pages=-1 copies all pages in a single pass (fastest; no yielding to
|
|
197
|
+
# other writers between batches, which is acceptable here because the
|
|
198
|
+
# backup happens before the migration run begins — no other migration
|
|
199
|
+
# writer is active at this point).
|
|
200
|
+
src_conn.backup(dst_conn, pages=-1)
|
|
201
|
+
dst_conn.commit()
|
|
202
|
+
finally:
|
|
203
|
+
src_conn.close()
|
|
204
|
+
dst_conn.close()
|
|
205
|
+
|
|
206
|
+
# Verify and durably flush BEFORE the rename, so the final name never
|
|
207
|
+
# appears over incomplete or corrupt content.
|
|
208
|
+
try:
|
|
209
|
+
fd = os.open(str(staging), os.O_RDONLY)
|
|
210
|
+
try:
|
|
211
|
+
os.fsync(fd)
|
|
212
|
+
finally:
|
|
213
|
+
os.close(fd)
|
|
214
|
+
verify = sqlite3.connect(f"file:{staging}?mode=ro", uri=True)
|
|
215
|
+
try:
|
|
216
|
+
if verify.execute("PRAGMA quick_check").fetchone()[0] != "ok":
|
|
217
|
+
raise SnapshotUnusableError(
|
|
218
|
+
f"snapshot failed its integrity check immediately after copy: {dest}")
|
|
219
|
+
finally:
|
|
220
|
+
verify.close()
|
|
221
|
+
except Exception:
|
|
222
|
+
staging.unlink(missing_ok=True) # never leave a partial file behind
|
|
223
|
+
raise
|
|
224
|
+
|
|
225
|
+
# Verifying the copy read-only makes SQLite materialise a -shm beside the
|
|
226
|
+
# staging file, and a read-only connection cannot remove it on close. The
|
|
227
|
+
# rename below moves only the main file, so the companion is left behind
|
|
228
|
+
# under the staging name — observed: every snapshot left a stray
|
|
229
|
+
# `.partial-shm` and `.partial-wal` in the snapshot directory. Removing them
|
|
230
|
+
# is safe because the copy was checkpointed when its read-write connection
|
|
231
|
+
# closed, so whatever exists now came from verification and holds nothing.
|
|
232
|
+
# That is checked rather than trusted: content in the log would mean the copy
|
|
233
|
+
# was not fully checkpointed, and renaming it would strand those pages.
|
|
234
|
+
for suffix in ("-wal", "-shm"):
|
|
235
|
+
companion = staging.with_name(staging.name + suffix)
|
|
236
|
+
if not companion.exists():
|
|
237
|
+
continue
|
|
238
|
+
if suffix == "-wal":
|
|
239
|
+
leftover = companion.stat().st_size
|
|
240
|
+
if leftover > 0:
|
|
241
|
+
# Read the size BEFORE unlinking. Reading it inside the message
|
|
242
|
+
# after the unlink raised FileNotFoundError instead of this
|
|
243
|
+
# error, so the caller saw a generic crash — and the staging
|
|
244
|
+
# file was already gone, taking the evidence with it.
|
|
245
|
+
staging.unlink(missing_ok=True)
|
|
246
|
+
companion.unlink(missing_ok=True)
|
|
247
|
+
raise SnapshotUnusableError(
|
|
248
|
+
f"copy left {leftover} bytes in its write-ahead log; "
|
|
249
|
+
f"renaming it would strand those pages: {dest}"
|
|
250
|
+
)
|
|
251
|
+
companion.unlink(missing_ok=True)
|
|
252
|
+
|
|
253
|
+
staging.replace(dest)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class SnapshotUnusableError(RuntimeError):
|
|
257
|
+
"""Raised when a snapshot cannot be verified, BEFORE the live store is touched."""
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def restore_pre_migration_snapshot(snapshot: Path, target: Path) -> Path:
|
|
261
|
+
"""Restore ``snapshot`` over ``target``, verifying before it destroys anything.
|
|
262
|
+
|
|
263
|
+
Do NOT restore these snapshots with ``BackupManager.restore_backup()``. That
|
|
264
|
+
method checks the source exists, then takes its own "pre-restore" backup,
|
|
265
|
+
which runs retention over the same directory. Retention can unlink the very
|
|
266
|
+
file being restored; ``sqlite3.connect`` then RECREATES that path as an empty
|
|
267
|
+
database, and the empty database is copied over the live store. It returns
|
|
268
|
+
True. The snapshot is left as a zero-byte file with its original name, so a
|
|
269
|
+
second attempt appears to succeed as well. Reproduced: a 500-fact store
|
|
270
|
+
restored to 0 tables while the call reported success.
|
|
271
|
+
|
|
272
|
+
This function instead:
|
|
273
|
+
1. verifies the snapshot is a readable database with content, and refuses
|
|
274
|
+
before touching ``target`` if it is not,
|
|
275
|
+
2. copies the CURRENT ``target`` aside first, outside the snapshot
|
|
276
|
+
directory so no retention policy can reclaim it,
|
|
277
|
+
3. copies the snapshot into place through the SQLite backup API.
|
|
278
|
+
|
|
279
|
+
Returns the path of the safety copy of the pre-restore state.
|
|
280
|
+
"""
|
|
281
|
+
if not snapshot.is_file() or snapshot.stat().st_size == 0:
|
|
282
|
+
raise SnapshotUnusableError(f"snapshot is missing or empty: {snapshot}")
|
|
283
|
+
try:
|
|
284
|
+
conn = sqlite3.connect(f"file:{snapshot}?mode=ro", uri=True)
|
|
285
|
+
try:
|
|
286
|
+
tables = [r[0] for r in conn.execute(
|
|
287
|
+
"SELECT name FROM sqlite_master WHERE type='table'")]
|
|
288
|
+
if not tables:
|
|
289
|
+
raise SnapshotUnusableError(
|
|
290
|
+
f"snapshot contains no tables, refusing to restore it over "
|
|
291
|
+
f"{target.name}: {snapshot}")
|
|
292
|
+
if conn.execute("PRAGMA quick_check").fetchone()[0] != "ok":
|
|
293
|
+
raise SnapshotUnusableError(f"snapshot failed integrity check: {snapshot}")
|
|
294
|
+
finally:
|
|
295
|
+
conn.close()
|
|
296
|
+
except sqlite3.Error as exc:
|
|
297
|
+
raise SnapshotUnusableError(f"snapshot is not a readable database: {snapshot}") from exc
|
|
298
|
+
|
|
299
|
+
# Safety copy of what we are about to overwrite, deliberately NOT in the
|
|
300
|
+
# snapshot directory — nothing prunes this location.
|
|
301
|
+
safety_dir = target.parent / "pre-restore"
|
|
302
|
+
safety_dir.mkdir(parents=True, exist_ok=True)
|
|
303
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
304
|
+
safety = safety_dir / f"{target.stem}-{stamp}-before-restore{target.suffix}"
|
|
305
|
+
if target.exists():
|
|
306
|
+
_backup_via_sqlite_api(target, safety)
|
|
307
|
+
|
|
308
|
+
_backup_via_sqlite_api(snapshot, target)
|
|
309
|
+
logger.info("[SLM] Restored %s from %s (previous state saved to %s)",
|
|
310
|
+
target.name, snapshot.name, safety)
|
|
311
|
+
return safety
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _find_existing_ancestor(path: Path) -> Path:
|
|
315
|
+
"""Return the nearest ancestor of path that exists on the filesystem."""
|
|
316
|
+
p = path
|
|
317
|
+
while not p.exists():
|
|
318
|
+
if p.parent == p:
|
|
319
|
+
# Reached the root without finding an existing dir; use cwd.
|
|
320
|
+
return Path.cwd()
|
|
321
|
+
p = p.parent
|
|
322
|
+
return p
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _pre_migration_backup(
|
|
326
|
+
learning_db: Path,
|
|
327
|
+
memory_db: Path,
|
|
328
|
+
*,
|
|
329
|
+
backups_root: Path | None = None,
|
|
330
|
+
) -> Path:
|
|
331
|
+
"""Snapshot both databases as flat files before migration.
|
|
332
|
+
|
|
333
|
+
Creates ``{db_stem}-{YYYYMMDD-HHmmss}-pre-migration.db`` files directly
|
|
334
|
+
in ``backups_root`` (no subdirectory). Files are named with a
|
|
335
|
+
``-pre-migration`` suffix so that the GC glob ``*-pre-migration.db``
|
|
336
|
+
identifies them unambiguously without matching any file produced by
|
|
337
|
+
``BackupManager``.
|
|
338
|
+
|
|
339
|
+
Only databases that exist on disk are copied; a missing database is
|
|
340
|
+
silently skipped (first-install scenario where learning.db may not
|
|
341
|
+
exist yet).
|
|
342
|
+
|
|
343
|
+
The ``backups_root`` directory defaults to
|
|
344
|
+
``canonical_data_root() / "pre-migration-snapshots"`` — a directory
|
|
345
|
+
separate from ``BackupManager``'s ``backups/`` directory. This
|
|
346
|
+
separation means ``BackupManager._enforce_retention()`` can never
|
|
347
|
+
reach these files regardless of how many routine backups accumulate.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
learning_db: Path to the learning-plane database.
|
|
351
|
+
memory_db: Path to the memory database.
|
|
352
|
+
backups_root: Override the canonical snapshots root. Tests pass a
|
|
353
|
+
tmp_path here to avoid writing to the user's data directory.
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
The Path of ``backups_root`` (the directory holding the new flat
|
|
357
|
+
snapshot files).
|
|
358
|
+
|
|
359
|
+
Raises:
|
|
360
|
+
InsufficientDiskSpaceError: When the free space on the target
|
|
361
|
+
filesystem is less than 110% of the combined source database sizes.
|
|
362
|
+
"""
|
|
363
|
+
if backups_root is None:
|
|
364
|
+
backups_root = canonical_data_root() / "pre-migration-snapshots"
|
|
365
|
+
|
|
366
|
+
# Second granularity is not enough. Two migrations inside the same second —
|
|
367
|
+
# a daemon restart loop, or the second apply_all() during startup — produced
|
|
368
|
+
# identical filenames, and the atomic rename then replaced the FIRST
|
|
369
|
+
# snapshot cleanly. The first is the valuable one: it holds the state before
|
|
370
|
+
# anything was touched. Microseconds make a collision practically
|
|
371
|
+
# impossible, and the loop below refuses to overwrite regardless.
|
|
372
|
+
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S-%f")
|
|
373
|
+
|
|
374
|
+
# Measure combined size of databases that actually exist, INCLUDING their
|
|
375
|
+
# write-ahead log and shared-memory files. On a busy store the -wal file can
|
|
376
|
+
# hold a large fraction of the data not yet checkpointed into the main file,
|
|
377
|
+
# and the snapshot materialises all of it. Sizing against the main file
|
|
378
|
+
# alone under-counts the requirement and lets a migration start with too
|
|
379
|
+
# little room, which is the situation the check exists to prevent.
|
|
380
|
+
total_bytes = 0
|
|
381
|
+
for db_path in (memory_db, learning_db):
|
|
382
|
+
for companion in (db_path, Path(f"{db_path}-wal"), Path(f"{db_path}-shm")):
|
|
383
|
+
if companion.exists():
|
|
384
|
+
total_bytes += companion.stat().st_size
|
|
385
|
+
|
|
386
|
+
# Check that the target filesystem has enough room. We check against an
|
|
387
|
+
# existing ancestor because the snapshot directory itself may not exist yet.
|
|
388
|
+
check_path = _find_existing_ancestor(backups_root)
|
|
389
|
+
free_bytes = shutil.disk_usage(str(check_path)).free
|
|
390
|
+
# Peak disk requirement — sequential writes, staging-then-rename:
|
|
391
|
+
#
|
|
392
|
+
# Step 1: write memory.staging (up to memory_size bytes)
|
|
393
|
+
# Step 2: rename memory.staging → memory.final (0 extra bytes; atomic)
|
|
394
|
+
# Step 3: write learning.staging (up to learning_size bytes)
|
|
395
|
+
# Step 4: rename learning.staging → learning.final (0 extra bytes)
|
|
396
|
+
#
|
|
397
|
+
# Peak between steps 3-4: memory.final + learning.staging
|
|
398
|
+
# = memory_size + learning_size = total_bytes
|
|
399
|
+
#
|
|
400
|
+
# The 10 % buffer (0.1 × total_bytes) covers:
|
|
401
|
+
# - filesystem metadata: directory entries and inode table entries for
|
|
402
|
+
# 2 new files are at most a few KiB — negligible for MB-sized stores.
|
|
403
|
+
# - WAL pages materialised during the copy: _backup_via_sqlite_api uses
|
|
404
|
+
# sqlite3.Connection.backup(pages=-1), a single-pass read lock. The
|
|
405
|
+
# daemon is expected to be idle while this script runs (the caller
|
|
406
|
+
# checks _writer_lock_held() before calling this function), so WAL
|
|
407
|
+
# growth during the copy is near zero. If the caller has not stopped
|
|
408
|
+
# the daemon, a concurrent checkpoint could transiently inflate the
|
|
409
|
+
# source WAL, but that WAL growth is bounded by the daemon's own write
|
|
410
|
+
# rate and is already counted in total_bytes (we measure the -wal file
|
|
411
|
+
# size before the copy). 10 % headroom covers reasonable variance.
|
|
412
|
+
#
|
|
413
|
+
# The previous formula used total_bytes * 2.1. That was derived for a
|
|
414
|
+
# SINGLE database (1.0 × staging + 1.0 × final + 0.1 × headroom = 2.1 ×)
|
|
415
|
+
# but was mistakenly applied to the SUM of two databases, over-reserving
|
|
416
|
+
# by ~2 × and blocking migrations on machines with limited disk space.
|
|
417
|
+
needed_bytes = int(total_bytes * 1.1)
|
|
418
|
+
if free_bytes < needed_bytes:
|
|
419
|
+
raise InsufficientDiskSpaceError(needed_bytes, free_bytes)
|
|
420
|
+
|
|
421
|
+
backups_root.mkdir(parents=True, exist_ok=True)
|
|
422
|
+
|
|
423
|
+
# Perform the backup. Each db gets a flat file with a -pre-migration suffix
|
|
424
|
+
# so the GC glob *-pre-migration.db identifies our files precisely.
|
|
425
|
+
t0 = time.monotonic()
|
|
426
|
+
|
|
427
|
+
def _free_name(stem: str) -> Path:
|
|
428
|
+
"""Never overwrite an existing snapshot; the older one may be the only
|
|
429
|
+
copy of the pre-migration state."""
|
|
430
|
+
candidate = backups_root / f"{stem}-{timestamp}-pre-migration.db"
|
|
431
|
+
suffix = 1
|
|
432
|
+
while candidate.exists():
|
|
433
|
+
candidate = backups_root / f"{stem}-{timestamp}-{suffix}-pre-migration.db"
|
|
434
|
+
suffix += 1
|
|
435
|
+
return candidate
|
|
436
|
+
|
|
437
|
+
# Keep each snapshot paired with the database it came from. Emitting a
|
|
438
|
+
# single restore command built from written[0] named the LEARNING snapshot
|
|
439
|
+
# (it sorts first) against memory.db as the target — a command that would
|
|
440
|
+
# restore the wrong database over the user's memories.
|
|
441
|
+
pairs: list[tuple[Path, Path]] = []
|
|
442
|
+
for stem, db_path in (("memory", memory_db), ("learning", learning_db)):
|
|
443
|
+
if db_path.exists():
|
|
444
|
+
dest = _free_name(stem)
|
|
445
|
+
_backup_via_sqlite_api(db_path, dest)
|
|
446
|
+
pairs.append((dest, db_path))
|
|
447
|
+
|
|
448
|
+
elapsed = time.monotonic() - t0
|
|
449
|
+
|
|
450
|
+
written = [snap for snap, _ in pairs]
|
|
451
|
+
size_bytes = sum(f.stat().st_size for f in written)
|
|
452
|
+
size_mb = size_bytes / (1024 * 1024)
|
|
453
|
+
filenames = "\n ".join(f.name for f in written)
|
|
454
|
+
|
|
455
|
+
logger.info(
|
|
456
|
+
"[SLM] Pre-migration snapshot written (%.0f MB in %.1fs):\n"
|
|
457
|
+
" Location : %s\n"
|
|
458
|
+
" Files :\n %s\n"
|
|
459
|
+
" To restore if migration fails — run the line for the database you\n"
|
|
460
|
+
" need; each snapshot restores only its own database:\n"
|
|
461
|
+
" from pathlib import Path\n"
|
|
462
|
+
" from superlocalmemory.storage.backup import restore_pre_migration_snapshot\n"
|
|
463
|
+
"%s",
|
|
464
|
+
size_mb,
|
|
465
|
+
elapsed,
|
|
466
|
+
str(backups_root),
|
|
467
|
+
filenames,
|
|
468
|
+
"\n".join(
|
|
469
|
+
f" restore_pre_migration_snapshot(Path({str(snap)!r}), Path({str(db)!r}))"
|
|
470
|
+
for snap, db in pairs
|
|
471
|
+
) or " (no snapshot was written — nothing to restore)",
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
return backups_root
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _gc_old_backups(backups_root: Path, keep: int = 2) -> None:
|
|
478
|
+
"""Remove old pre-migration snapshot GENERATIONS, retaining ``keep`` newest.
|
|
479
|
+
|
|
480
|
+
A generation is one migration's snapshots — ``memory-<ts>-pre-migration.db``
|
|
481
|
+
and ``learning-<ts>-pre-migration.db`` share a timestamp and are only useful
|
|
482
|
+
together. Counting files instead of generations kept ``keep`` FILES: with
|
|
483
|
+
``keep=2`` that is a single generation, and where mtimes interleave it could
|
|
484
|
+
retain a ``memory`` snapshot whose matching ``learning`` snapshot had been
|
|
485
|
+
deleted — a half set that cannot restore a consistent store.
|
|
486
|
+
|
|
487
|
+
Only files directly under ``backups_root`` matching ``*-pre-migration.db``
|
|
488
|
+
are eligible. Every deletion uses an explicit full path; no glob is ever
|
|
489
|
+
passed to the deletion call.
|
|
490
|
+
"""
|
|
491
|
+
if not backups_root.exists():
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
generations: dict[str, list[Path]] = {}
|
|
495
|
+
for candidate in backups_root.glob("*-pre-migration.db"):
|
|
496
|
+
if not candidate.is_file() or candidate.parent != backups_root:
|
|
497
|
+
continue
|
|
498
|
+
# Extract the raw stamp and normalise away any collision suffix so that
|
|
499
|
+
# "memory-20260819-120000-123456-pre-migration.db" and
|
|
500
|
+
# "memory-20260819-120000-123456-1-pre-migration.db" land in the same
|
|
501
|
+
# generation bucket. Without normalisation a collision suffix makes GC
|
|
502
|
+
# count one migration's files as two separate generations and can delete
|
|
503
|
+
# one file from a paired set, leaving a snapshot that cannot be used for
|
|
504
|
+
# a consistent restore.
|
|
505
|
+
#
|
|
506
|
+
# Search from the RIGHT side of the stripped name so a stem that
|
|
507
|
+
# contains hyphens does not shift the extracted timestamp — the same
|
|
508
|
+
# fix applied to _snapshot_sort_key.
|
|
509
|
+
stripped = candidate.name.rsplit("-pre-migration.db", 1)[0]
|
|
510
|
+
m = _extract_snapshot_stamp(stripped)
|
|
511
|
+
raw_stamp = m.group(1) if m else stripped
|
|
512
|
+
stamp = _parse_generation_stamp(raw_stamp)
|
|
513
|
+
generations.setdefault(stamp, []).append(candidate)
|
|
514
|
+
|
|
515
|
+
if len(generations) <= keep:
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
# Sort by the base timestamp string. YYYYMMDD-HHmmss-ffffff is lexically
|
|
519
|
+
# monotonic, so alphabetical order is chronological order. Using st_mtime
|
|
520
|
+
# here was nondeterministic when two snapshots landed in the same filesystem
|
|
521
|
+
# timestamp second (FAT, relatime ext4).
|
|
522
|
+
ordered = sorted(
|
|
523
|
+
generations.items(),
|
|
524
|
+
key=lambda kv: kv[0],
|
|
525
|
+
)
|
|
526
|
+
for _stamp, files in ordered[: len(generations) - keep]:
|
|
527
|
+
for target in sorted(files):
|
|
528
|
+
if target.parent != backups_root or not target.is_file():
|
|
529
|
+
continue
|
|
530
|
+
logger.info("[SLM] Removing old pre-migration snapshot: %s", target)
|
|
531
|
+
target.unlink()
|
|
@@ -40,6 +40,7 @@ from superlocalmemory.storage.models import (
|
|
|
40
40
|
TemporalEvent,
|
|
41
41
|
TrustScore,
|
|
42
42
|
)
|
|
43
|
+
from superlocalmemory.storage.embedding_codec import decode_embedding, encode_embedding
|
|
43
44
|
from superlocalmemory.storage.write_lock import get_write_lock
|
|
44
45
|
|
|
45
46
|
logger = logging.getLogger(__name__)
|
|
@@ -555,7 +556,7 @@ class DatabaseManager:
|
|
|
555
556
|
fact.interval_start, fact.interval_end,
|
|
556
557
|
fact.confidence, fact.importance, fact.evidence_count, fact.access_count,
|
|
557
558
|
json.dumps(fact.source_turn_ids), fact.session_id,
|
|
558
|
-
|
|
559
|
+
encode_embedding(fact.embedding), _jd(fact.fisher_mean), _jd(fact.fisher_variance),
|
|
559
560
|
fact.lifecycle.value, _jd(fact.langevin_position),
|
|
560
561
|
fact.emotional_valence, fact.emotional_arousal,
|
|
561
562
|
fact.signal_type.value, fact.created_at, _scope, _shared),
|
|
@@ -591,7 +592,7 @@ class DatabaseManager:
|
|
|
591
592
|
evidence_count=d["evidence_count"], access_count=d["access_count"],
|
|
592
593
|
source_turn_ids=_jl(d.get("source_turn_ids_json")),
|
|
593
594
|
session_id=d.get("session_id", ""),
|
|
594
|
-
embedding=
|
|
595
|
+
embedding=decode_embedding(d.get("embedding"), fact_id=d.get("fact_id", "<unknown>")),
|
|
595
596
|
fisher_mean=_jl(d.get("fisher_mean"), None),
|
|
596
597
|
fisher_variance=_jl(d.get("fisher_variance"), None),
|
|
597
598
|
lifecycle=MemoryLifecycle(d["lifecycle"]) if d.get("lifecycle") else MemoryLifecycle.ACTIVE,
|
|
@@ -645,7 +646,7 @@ class DatabaseManager:
|
|
|
645
646
|
fact.access_count,
|
|
646
647
|
json.dumps(fact.source_turn_ids),
|
|
647
648
|
fact.session_id,
|
|
648
|
-
|
|
649
|
+
encode_embedding(fact.embedding),
|
|
649
650
|
_jd(fact.fisher_mean),
|
|
650
651
|
_jd(fact.fisher_variance),
|
|
651
652
|
fact.lifecycle.value,
|
|
@@ -873,7 +874,13 @@ class DatabaseManager:
|
|
|
873
874
|
raise ValueError(f"Disallowed column(s): {bad_keys}")
|
|
874
875
|
clean: dict[str, Any] = {}
|
|
875
876
|
for k, v in updates.items():
|
|
876
|
-
if
|
|
877
|
+
if k == "embedding":
|
|
878
|
+
# Embeddings are stored in the canonical binary form. Falling
|
|
879
|
+
# through to json.dumps here would write a text row back into a
|
|
880
|
+
# converted store, one fact at a time, undoing the conversion
|
|
881
|
+
# wherever a fact is updated.
|
|
882
|
+
clean[k] = encode_embedding(v) if v is not None else None
|
|
883
|
+
elif isinstance(v, (list, dict)):
|
|
877
884
|
clean[k] = json.dumps(v)
|
|
878
885
|
elif isinstance(v, (MemoryLifecycle, FactType, SignalType)):
|
|
879
886
|
clean[k] = v.value
|