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.
Files changed (120) hide show
  1. package/CHANGELOG.md +146 -1
  2. package/README.md +6 -6
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/scripts/ensure-venv.sh +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +1 -1
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +1 -1
  19. package/plugin/skills/slm-recall/SKILL.md +1 -1
  20. package/plugin/skills/slm-remember/SKILL.md +1 -1
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +1 -1
  23. package/plugin/skills/slm-status/SKILL.md +3 -3
  24. package/plugin-src/rules/AGENTS.md +1 -1
  25. package/plugin-src/skills/slm-status/SKILL.md +2 -2
  26. package/pyproject.toml +1 -1
  27. package/scripts/postinstall.js +4 -0
  28. package/src/superlocalmemory/__init__.py +1 -1
  29. package/src/superlocalmemory/cli/_lazy_init.py +1 -1
  30. package/src/superlocalmemory/cli/commands.py +119 -9
  31. package/src/superlocalmemory/cli/db_migrate.py +0 -2
  32. package/src/superlocalmemory/cli/gdpr_io.py +1 -1
  33. package/src/superlocalmemory/cli/main.py +5 -5
  34. package/src/superlocalmemory/cli/service_installer.py +2 -1
  35. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  36. package/src/superlocalmemory/core/config.py +41 -7
  37. package/src/superlocalmemory/core/context_cache.py +0 -2
  38. package/src/superlocalmemory/core/engine.py +371 -63
  39. package/src/superlocalmemory/core/evidence_bundle.py +3 -1
  40. package/src/superlocalmemory/core/install_detector.py +131 -0
  41. package/src/superlocalmemory/core/progressive_abstraction.py +1 -1
  42. package/src/superlocalmemory/core/security_primitives.py +3 -6
  43. package/src/superlocalmemory/core/store_pipeline.py +94 -26
  44. package/src/superlocalmemory/core/topic_signature.py +0 -2
  45. package/src/superlocalmemory/core/transactions/concrete_owners.py +15 -8
  46. package/src/superlocalmemory/dynamics/eap_scheduler.py +17 -6
  47. package/src/superlocalmemory/encoding/graph_builder.py +2 -2
  48. package/src/superlocalmemory/encoding/scene_builder.py +8 -2
  49. package/src/superlocalmemory/hooks/adapter_base.py +0 -2
  50. package/src/superlocalmemory/hooks/context_payload.py +0 -2
  51. package/src/superlocalmemory/hooks/portable_kit.py +8 -8
  52. package/src/superlocalmemory/hooks/post_tool_async_hook.py +0 -2
  53. package/src/superlocalmemory/hooks/prewarm_auth.py +0 -2
  54. package/src/superlocalmemory/hooks/user_prompt_hook.py +0 -2
  55. package/src/superlocalmemory/infra/backup.py +44 -8
  56. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +24 -7
  57. package/src/superlocalmemory/learning/arm_catalog.py +0 -2
  58. package/src/superlocalmemory/learning/bandit.py +0 -2
  59. package/src/superlocalmemory/learning/bandit_cache.py +0 -2
  60. package/src/superlocalmemory/learning/dedup_hnsw.py +11 -11
  61. package/src/superlocalmemory/learning/ensemble.py +0 -2
  62. package/src/superlocalmemory/learning/labeler.py +0 -2
  63. package/src/superlocalmemory/learning/legacy_migration.py +0 -2
  64. package/src/superlocalmemory/learning/model_cache.py +0 -2
  65. package/src/superlocalmemory/learning/ranker.py +0 -2
  66. package/src/superlocalmemory/learning/reward_archive.py +6 -1
  67. package/src/superlocalmemory/learning/reward_proxy.py +0 -2
  68. package/src/superlocalmemory/learning/signal_worker.py +0 -2
  69. package/src/superlocalmemory/math/fisher.py +1 -1
  70. package/src/superlocalmemory/math/hopfield.py +4 -1
  71. package/src/superlocalmemory/math/langevin.py +1 -1
  72. package/src/superlocalmemory/math/sheaf.py +7 -3
  73. package/src/superlocalmemory/mcp/cli_fallback.py +1 -1
  74. package/src/superlocalmemory/mcp/profiles.py +1 -1
  75. package/src/superlocalmemory/mcp/server.py +1 -1
  76. package/src/superlocalmemory/mcp/tools_active.py +56 -0
  77. package/src/superlocalmemory/mcp/tools_core.py +1 -1
  78. package/src/superlocalmemory/optimize/cache/manager.py +2 -2
  79. package/src/superlocalmemory/optimize/compress/ccr.py +1 -1
  80. package/src/superlocalmemory/optimize/compress/router.py +1 -1
  81. package/src/superlocalmemory/optimize/proxy/_helpers.py +2 -2
  82. package/src/superlocalmemory/optimize/proxy/server.py +1 -1
  83. package/src/superlocalmemory/optimize/proxy/vertex_surface.py +2 -2
  84. package/src/superlocalmemory/optimize/storage/db.py +2 -2
  85. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  86. package/src/superlocalmemory/retrieval/ann_index.py +9 -2
  87. package/src/superlocalmemory/retrieval/bm25_channel.py +2 -2
  88. package/src/superlocalmemory/retrieval/bridge_discovery.py +2 -2
  89. package/src/superlocalmemory/retrieval/engine.py +272 -43
  90. package/src/superlocalmemory/retrieval/entity_channel.py +1 -1
  91. package/src/superlocalmemory/retrieval/hopfield_channel.py +8 -2
  92. package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
  93. package/src/superlocalmemory/retrieval/quantization_aware_search.py +1 -1
  94. package/src/superlocalmemory/retrieval/remote_reranker.py +2 -2
  95. package/src/superlocalmemory/retrieval/reranker.py +3 -3
  96. package/src/superlocalmemory/retrieval/semantic_channel.py +3 -3
  97. package/src/superlocalmemory/retrieval/spreading_activation.py +8 -8
  98. package/src/superlocalmemory/retrieval/strategy.py +94 -0
  99. package/src/superlocalmemory/retrieval/temporal_channel.py +167 -10
  100. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +1 -1
  101. package/src/superlocalmemory/retrieval/vector_store.py +88 -10
  102. package/src/superlocalmemory/server/recall_serializer.py +10 -0
  103. package/src/superlocalmemory/server/routes/brain.py +2 -2
  104. package/src/superlocalmemory/server/routes/prewarm.py +4 -4
  105. package/src/superlocalmemory/server/unified_daemon.py +459 -7
  106. package/src/superlocalmemory/storage/_schema_version.py +46 -3
  107. package/src/superlocalmemory/storage/backup.py +531 -0
  108. package/src/superlocalmemory/storage/database.py +11 -4
  109. package/src/superlocalmemory/storage/embedding_codec.py +129 -0
  110. package/src/superlocalmemory/storage/embedding_migrator.py +5 -3
  111. package/src/superlocalmemory/storage/migration_runner.py +142 -2
  112. package/src/superlocalmemory/storage/migrations/__init__.py +1 -1
  113. package/src/superlocalmemory/storage/migrations.py +15 -1
  114. package/src/superlocalmemory/storage/models.py +7 -0
  115. package/src/superlocalmemory/storage/quantized_store.py +4 -2
  116. package/src/superlocalmemory/summaries/session_summary.py +1 -1
  117. package/src/superlocalmemory/ui/css/legacy-dashboard.css +1 -1
  118. package/src/superlocalmemory/ui/css/neural-glass.css +1 -1
  119. package/src/superlocalmemory/ui/js/core.js +1 -1
  120. 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
- _jd(fact.embedding), _jd(fact.fisher_mean), _jd(fact.fisher_variance),
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=_jl(d.get("embedding"), None),
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
- _jd(fact.embedding),
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 isinstance(v, (list, dict)):
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