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,129 @@
1
+ """Encode and decode atomic_facts.embedding values.
2
+
3
+ All read and write paths for atomic_facts.embedding must go through
4
+ ``encode_embedding`` and ``decode_embedding``. Centralising the logic here
5
+ means a future format change requires one edit, not one per reader.
6
+
7
+ Storage format
8
+ --------------
9
+ New rows: 768 × float32, little-endian, stored as SQLite BLOB (3,072 bytes).
10
+ Legacy rows: JSON TEXT produced by json.dumps(list[float]).
11
+
12
+ The read path accepts both formats so a partial backfill is safe by
13
+ construction: callers see ``list[float]`` regardless of storage format.
14
+
15
+ Error contract
16
+ --------------
17
+ A value that is neither valid JSON nor a well-formed float32 buffer raises
18
+ ``ValueError`` with the fact_id in the message. Returning ``None`` silently
19
+ for a malformed value is forbidden: the caller cannot distinguish a legitimate
20
+ absent embedding from a data-loss event.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import logging
26
+ from typing import TYPE_CHECKING
27
+
28
+ import numpy as np
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ if TYPE_CHECKING:
33
+ pass
34
+
35
+ __all__ = [
36
+ "EMBEDDING_DIM",
37
+ "EMBEDDING_BYTES",
38
+ "encode_embedding",
39
+ "decode_embedding",
40
+ ]
41
+
42
+ EMBEDDING_DIM: int = 768
43
+ EMBEDDING_BYTES: int = EMBEDDING_DIM * 4 # float32 = 4 bytes
44
+
45
+
46
+ def encode_embedding(vec: list[float] | None) -> bytes | None:
47
+ """Serialise a float list to a binary float32 BLOB for SQLite storage.
48
+
49
+ Parameters
50
+ ----------
51
+ vec:
52
+ A list of floats, or ``None``. Production embeddings are always
53
+ 768-dimensional; the backfill script asserts the dimension before
54
+ calling this function.
55
+
56
+ Returns
57
+ -------
58
+ bytes | None
59
+ Little-endian float32 buffer, or ``None`` if *vec* is ``None``.
60
+ """
61
+ if vec is None:
62
+ return None
63
+ return np.array(vec, dtype=np.float32).tobytes()
64
+
65
+
66
+ def decode_embedding(
67
+ raw: bytes | str | None,
68
+ *,
69
+ fact_id: str = "<unknown>",
70
+ ) -> list[float] | None:
71
+ """Deserialise an embedding from either TEXT (JSON) or BLOB (binary float32).
72
+
73
+ Parameters
74
+ ----------
75
+ raw:
76
+ The raw value from ``atomic_facts.embedding``:
77
+ ``None`` or empty string → absent embedding (returns ``None``).
78
+ ``bytes`` → binary float32 BLOB path.
79
+ ``str`` → legacy JSON TEXT path.
80
+ fact_id:
81
+ Included in any ``ValueError`` message for fast triage.
82
+
83
+ Returns
84
+ -------
85
+ list[float] | None
86
+ 768-element list of floats, or ``None`` when the embedding is absent.
87
+
88
+ Raises
89
+ ------
90
+ ValueError
91
+ For a non-null value that is neither valid JSON nor a well-formed
92
+ float32 buffer. Never returns ``None`` for a malformed value.
93
+ """
94
+ if raw is None or raw == "":
95
+ return None
96
+
97
+ if isinstance(raw, (bytes, bytearray)):
98
+ if len(raw) % 4 != 0 or len(raw) == 0:
99
+ raise ValueError(
100
+ f"Corrupt embedding buffer for fact {fact_id!r}: "
101
+ f"{len(raw)} bytes is not a multiple of 4 (float32)"
102
+ )
103
+ if len(raw) != EMBEDDING_BYTES:
104
+ # A torn write that happens to land on a 4-byte boundary is
105
+ # indistinguishable from a short vector by length alone, and it was
106
+ # accepted silently at debug level: 767 of 768 values still looks
107
+ # like a valid embedding, and every similarity computed from it is
108
+ # quietly wrong. Smaller vectors ARE legitimate in tests, so this is
109
+ # a warning rather than a refusal, but it must be visible.
110
+ logger.warning(
111
+ "embedding for fact %s is %d bytes (%d floats), not the expected "
112
+ "%d (%d floats) — expected only for a test vector; on a real "
113
+ "store this is a truncated write",
114
+ fact_id, len(raw), len(raw) // 4, EMBEDDING_BYTES, EMBEDDING_DIM,
115
+ )
116
+ return np.frombuffer(raw, dtype=np.float32).tolist()
117
+
118
+ if isinstance(raw, str):
119
+ try:
120
+ return json.loads(raw)
121
+ except (json.JSONDecodeError, ValueError) as exc:
122
+ raise ValueError(
123
+ f"Corrupt JSON embedding for fact {fact_id!r}: {exc}"
124
+ ) from exc
125
+
126
+ raise ValueError(
127
+ f"Unexpected embedding type {type(raw).__name__!r} for fact {fact_id!r}; "
128
+ f"expected bytes or str"
129
+ )
@@ -29,6 +29,8 @@ from typing import TYPE_CHECKING, Any
29
29
 
30
30
  import numpy as np
31
31
 
32
+ from superlocalmemory.storage.embedding_codec import encode_embedding
33
+
32
34
  if TYPE_CHECKING:
33
35
  from superlocalmemory.core.config import SLMConfig
34
36
 
@@ -185,7 +187,7 @@ def _activate_staged_vectors(
185
187
  updated = conn.execute(
186
188
  "UPDATE atomic_facts SET embedding = ? "
187
189
  "WHERE fact_id = ? AND profile_id = ?",
188
- (embedding_json, fact_id, profile_id),
190
+ (encode_embedding(vector), fact_id, profile_id),
189
191
  )
190
192
  if updated.rowcount != 1:
191
193
  raise RuntimeError(
@@ -606,7 +608,7 @@ def backfill_missing_embeddings(
606
608
  logger.warning("backfill: null vector for fact %s — skipping.", fid[:16])
607
609
  continue
608
610
  try:
609
- embedding_json = json.dumps(vec)
611
+ embedding_blob = encode_embedding(vec)
610
612
  # Metadata is not an independent record: it is the pointer to
611
613
  # a sqlite-vec row. Creating it before the vector payload leaves
612
614
  # semantic recall permanently blind while reporting success.
@@ -638,7 +640,7 @@ def backfill_missing_embeddings(
638
640
  # this remains the supported JSON-only fallback path.
639
641
  db.execute(
640
642
  "UPDATE atomic_facts SET embedding = ? WHERE fact_id = ?",
641
- (embedding_json, fid),
643
+ (embedding_blob, fid),
642
644
  )
643
645
  except Exception:
644
646
  if projection_written and vector_store is not None:
@@ -4,8 +4,6 @@
4
4
 
5
5
  """Forward-only additive migrations for SLM v3.4.22.
6
6
 
7
- LLD reference: ``.backup/active-brain/lld/LLD-07-schema-migrations-and-security-primitives.md``
8
- Section 4 (Migration Runner).
9
7
 
10
8
  Contract:
11
9
  - ``apply_all(learning_db, memory_db, *, dry_run=False) -> dict`` —
@@ -34,6 +32,7 @@ catalogue and the public orchestration functions.
34
32
  from __future__ import annotations
35
33
 
36
34
  import logging
35
+ import os
37
36
  import sqlite3
38
37
  from pathlib import Path
39
38
 
@@ -177,6 +176,10 @@ from superlocalmemory.storage._migration_internals import (
177
176
  _migration_log_exists,
178
177
  _read_log,
179
178
  )
179
+ from superlocalmemory.storage.backup import (
180
+ _gc_old_backups,
181
+ _pre_migration_backup,
182
+ )
180
183
 
181
184
  logger = logging.getLogger(__name__)
182
185
 
@@ -375,6 +378,61 @@ def _bootstrap_learning_schema(learning_db: Path, *, dry_run: bool) -> str | Non
375
378
  return None
376
379
 
377
380
 
381
+ def _foreign_live_daemon(memory_db: Path) -> "int | None":
382
+ """Return the pid of another live daemon holding this data dir, or None.
383
+
384
+ Migrations are not fenced against concurrent writers. The realistic hazard
385
+ is an OLD daemon still running after an upgrade while a NEW one starts: its
386
+ WAL appends continue while DDL is applied, which can make a migration fail
387
+ non-deterministically. The snapshot itself stays consistent — the SQLite
388
+ backup API copies committed pages only — and a racing migration is recorded
389
+ as ``failed`` and is non-fatal, so this does not corrupt data.
390
+
391
+ This detects the condition and reports it. It deliberately does NOT refuse:
392
+ ``apply_all`` runs inside the daemon's own startup, so refusing whenever "a
393
+ daemon is running" would refuse on itself, and blocking on a lock here would
394
+ risk wedging startup — a worse outcome than a retryable failed step.
395
+ """
396
+ try:
397
+ pid_file = memory_db.parent / "daemon.pid"
398
+ if not pid_file.is_file():
399
+ return None
400
+ pid = int(pid_file.read_text().strip() or 0)
401
+ if pid <= 0 or pid == os.getpid():
402
+ return None
403
+ os.kill(pid, 0) # signal 0 tests liveness without touching it
404
+ return pid
405
+ except (OSError, ValueError):
406
+ return None
407
+
408
+
409
+ def _nothing_left_to_apply(learning_db: Path, memory_db: Path) -> bool:
410
+ """True when every migration is already recorded in its target database.
411
+
412
+ Used to decide whether a snapshot is worth taking. A snapshot is only
413
+ valuable when something is about to change; taking one on a start where
414
+ nothing changes copies the ALREADY-MIGRATED store and then prunes a
415
+ generation — so after two such starts the last copy of the original is gone,
416
+ and the safety net has quietly deleted the thing it exists to protect.
417
+
418
+ Errs toward False, which means "take the snapshot" — the safe direction.
419
+ """
420
+ try:
421
+ for migration in MIGRATIONS:
422
+ db_path = _db_for(migration.db_target, learning_db, memory_db)
423
+ if not db_path.exists():
424
+ return False
425
+ conn = _connect(db_path)
426
+ try:
427
+ if not _deferred_already_applied(conn, migration.name):
428
+ return False
429
+ finally:
430
+ conn.close()
431
+ except Exception: # noqa: BLE001 — any doubt means take the snapshot
432
+ return False
433
+ return True
434
+
435
+
378
436
  def apply_all(
379
437
  learning_db: Path,
380
438
  memory_db: Path,
@@ -401,6 +459,44 @@ def apply_all(
401
459
  failed: list[str] = []
402
460
  details: dict[str, str] = {}
403
461
 
462
+ # Take a consistent snapshot of both databases before any migration runs.
463
+ # The backup uses the SQLite backup API so in-flight WAL writers are
464
+ # never captured mid-transaction. InsufficientDiskSpaceError propagates
465
+ # to the caller — migration is intentionally aborted when disk is too
466
+ # tight to keep a recoverable copy.
467
+ # A snapshot is only worth taking when something is about to change. This
468
+ # runs on every engine construction, not just upgrades, so snapshotting
469
+ # unconditionally meant an ordinary start copied the already-migrated store
470
+ # and pruned a generation — two extra starts and the original was gone.
471
+ _pending = not _nothing_left_to_apply(learning_db, memory_db)
472
+ if not dry_run and not _pending:
473
+ details["_backup"] = "skipped: every migration already applied"
474
+
475
+ if not dry_run and _pending:
476
+ _other = _foreign_live_daemon(memory_db)
477
+ if _other is not None:
478
+ logger.warning(
479
+ "Another SuperLocalMemory daemon (pid %s) is still running and "
480
+ "writing to this data directory. Migrations are not fenced "
481
+ "against concurrent writers, so a step may fail and need a "
482
+ "retry. Your data is not at risk: the snapshot copies committed "
483
+ "pages only, and a failed step is recorded, never forced. Stop "
484
+ "the other daemon and restart if a step fails.",
485
+ _other,
486
+ )
487
+ details["_concurrent_daemon_pid"] = str(_other)
488
+
489
+ backup_dir = _pre_migration_backup(
490
+ learning_db, memory_db,
491
+ backups_root=memory_db.parent / "pre-migration-snapshots",
492
+ )
493
+ # _pre_migration_backup returns the snapshots root itself, so this is
494
+ # the directory to prune. Passing .parent pointed the collector at the
495
+ # data directory, where it matched nothing and pruned nothing — leaving
496
+ # every snapshot on disk for ever.
497
+ _gc_old_backups(backup_dir)
498
+ details["_backup"] = str(backup_dir)
499
+
404
500
  schema_error = _bootstrap_learning_schema(learning_db, dry_run=dry_run)
405
501
  if schema_error is not None:
406
502
  failed.append("learning_schema_bootstrap")
@@ -461,6 +557,28 @@ def apply_all(
461
557
  }
462
558
 
463
559
 
560
+ def _deferred_already_applied(conn: sqlite3.Connection, name: str) -> bool:
561
+ """True when ``name`` is recorded as ``complete`` in this database's migration_log.
562
+
563
+ Used only to decide whether a snapshot is needed. On any error it returns
564
+ False, which errs toward taking a snapshot — the safe direction.
565
+
566
+ A row whose status is ``failed`` or ``in_progress`` is NOT considered applied:
567
+ the runner will retry those entries, and the store deserves a fresh snapshot
568
+ before any retry runs DDL against it. Counting any row (regardless of status)
569
+ caused ``_nothing_left_to_apply`` to return True after a failed migration,
570
+ so the retry ran against the already-partial store with no new safety copy.
571
+ """
572
+ try:
573
+ row = conn.execute(
574
+ "SELECT 1 FROM migration_log WHERE name = ? AND status = 'complete' LIMIT 1",
575
+ (name,),
576
+ ).fetchone()
577
+ return row is not None
578
+ except sqlite3.Error:
579
+ return False
580
+
581
+
464
582
  def apply_deferred(
465
583
  learning_db: Path,
466
584
  memory_db: Path,
@@ -495,6 +613,25 @@ def apply_deferred(
495
613
  failed: list[str] = []
496
614
  details: dict[str, str] = {}
497
615
 
616
+ # apply_all snapshots before it touches anything; this pass did not, yet it
617
+ # applies real DDL to both managed databases — including the column the
618
+ # daemon needs to start. An interrupted deferred pass therefore had no
619
+ # recoverable copy at all. The snapshot is taken LAZILY, immediately before
620
+ # the first migration that will actually be applied, so a pass with nothing
621
+ # to do costs no disk and does not capture post-init state unnecessarily.
622
+ _snapshot_state: dict[str, object] = {"taken": dry_run}
623
+
624
+ def _ensure_snapshot() -> None:
625
+ if _snapshot_state["taken"]:
626
+ return
627
+ _snapshot_state["taken"] = True
628
+ backup_dir = _pre_migration_backup(
629
+ learning_db, memory_db,
630
+ backups_root=memory_db.parent / "pre-migration-snapshots",
631
+ )
632
+ _gc_old_backups(backup_dir)
633
+ details["_deferred_backup"] = str(backup_dir)
634
+
498
635
  blocked: set[str] = set()
499
636
  for migration in DEFERRED_MIGRATIONS:
500
637
  unmet = [d for d in migration.dependencies if d in failed or d in blocked]
@@ -529,6 +666,9 @@ def apply_deferred(
529
666
  )
530
667
  continue
531
668
 
669
+ if not dry_run and not _deferred_already_applied(conn, migration.name):
670
+ _ensure_snapshot()
671
+
532
672
  outcome, detail = _apply_single(conn, migration, dry_run=dry_run)
533
673
  details[migration.name] = detail
534
674
  if outcome == "applied":
@@ -40,7 +40,7 @@ from . import (
40
40
  # ``superlocalmemory/storage/migrations.py`` shipped in v3.4.20 carried
41
41
  # ``CURRENT_SCHEMA_VERSION`` / ``get_schema_version`` / ``set_schema_version``
42
42
  # / ``is_v1_database`` / ``needs_migration`` / ``backup_database``. Creating
43
- # the ``migrations/`` package in Wave 1 shadowed that flat module, so any
43
+ # the ``migrations/`` package (introduced in v3.4) shadowed that flat module, so any
44
44
  # caller that did ``from superlocalmemory.storage.migrations import X``
45
45
  # broke. We re-load the legacy module under a distinct name and re-export
46
46
  # the symbols here so pre-3.4.22 callers (and the pre-existing tests at
@@ -16,6 +16,7 @@ import json
16
16
  import logging
17
17
  import shutil
18
18
  import sqlite3
19
+ import warnings
19
20
  from datetime import UTC, datetime
20
21
  from pathlib import Path
21
22
  from typing import Any
@@ -75,7 +76,20 @@ def is_v1_database(db_path: Path) -> bool:
75
76
 
76
77
 
77
78
  def backup_database(db_path: Path) -> Path:
78
- """Create timestamped backup before migration."""
79
+ """Create a timestamped copy of db_path alongside the source file.
80
+
81
+ .. deprecated::
82
+ Use the pre-migration backup in ``superlocalmemory.storage.backup``
83
+ instead, which produces a WAL-consistent snapshot via the SQLite
84
+ backup API rather than a raw filesystem copy.
85
+ """
86
+ warnings.warn(
87
+ "backup_database() uses shutil.copy2 which cannot produce a "
88
+ "consistent snapshot of a live WAL database. "
89
+ "Use superlocalmemory.storage.backup._pre_migration_backup() instead.",
90
+ DeprecationWarning,
91
+ stacklevel=2,
92
+ )
79
93
  timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
80
94
  backup_path = db_path.with_suffix(f".backup_{timestamp}.db")
81
95
  shutil.copy2(str(db_path), str(backup_path))
@@ -452,3 +452,10 @@ class RecallResponse:
452
452
  # fall into (thematic context). None unless results cluster into one
453
453
  # community above threshold. Additive — backward compatible.
454
454
  community_context: dict | None = None
455
+ # Channels abandoned at the hang guard, so they contributed no candidates
456
+ # to this answer. Non-empty means the result is INCOMPLETE, not merely
457
+ # slow: asking the same question again on an idle machine can legitimately
458
+ # return something better. Empty is the normal case and the only one in
459
+ # which two runs of one query are expected to agree. Sorted, so the field
460
+ # is itself repeatable. Additive — backward compatible.
461
+ incomplete_channels: tuple[str, ...] = ()
@@ -214,8 +214,10 @@ class QuantizedEmbeddingStore:
214
214
 
215
215
  results.append((qe.fact_id, sim))
216
216
 
217
- # Sort descending by similarity
218
- results.sort(key=lambda x: x[1], reverse=True)
217
+ # Sort by descending similarity; use fact_id as the tie-break so that
218
+ # two facts with identical approximate similarity always return in the
219
+ # same order regardless of SQLite storage layout or insertion sequence.
220
+ results.sort(key=lambda x: (-x[1], x[0]))
219
221
  return results[:top_k]
220
222
 
221
223
  # -- Compression helpers -----------------------------------------------
@@ -8,7 +8,7 @@ CRITICAL DATA HONESTY NOTE
8
8
  ---------------------------
9
9
  On a real 3,294-fact store, only 127 facts (3.9%) carry a session_id.
10
10
  A Session Summary that presents itself as "everything you did this session"
11
- while silently covering 4% of the facts is the same overclaiming Wave 4
11
+ while silently covering 4% of the facts is overclaiming — the same defect
12
12
  removed from brain/truth.py.
13
13
 
14
14
  Coverage is ALWAYS disclosed. If the session has too few facts to summarise
@@ -202,7 +202,7 @@
202
202
  margin-bottom: 12px;
203
203
  }
204
204
 
205
- /* Pane error state — WP-12 */
205
+ /* Pane error state */
206
206
  .pane-error {
207
207
  text-align: center;
208
208
  padding: 40px;
@@ -1593,7 +1593,7 @@ body.ng-privacy-blur::before {
1593
1593
  }
1594
1594
  }
1595
1595
 
1596
- /* WP-12 — pane-error dark-mode override */
1596
+ /* pane-error dark-mode override */
1597
1597
  .ng-dark .pane-error {
1598
1598
  color: var(--ng-status-error);
1599
1599
  }
@@ -292,7 +292,7 @@ function showEmpty(containerId, icon, message) {
292
292
  }
293
293
 
294
294
  // ============================================================================
295
- // Pane error state — WP-12
295
+ // Pane error state
296
296
  // showPaneError / clearPaneError / paneErrorMessage
297
297
  // ============================================================================
298
298
 
@@ -774,7 +774,7 @@
774
774
  var evb = EL('div', { className: 'card-pad' });
775
775
 
776
776
  if (registryStatus === 'error' || registryStatus === 'unknown') {
777
- // Wave 4 honesty rule: failure and emptiness must not return the same value.
777
+ // Honesty constraint: failure and emptiness must not return the same value.
778
778
  // 'unknown' belongs HERE, not in the healthy-empty branch below. The
779
779
  // registry reader is fail-soft — a corrupt or unreadable file makes
780
780
  // _load() return {} and active_client_summary() return [], so the read