superlocalmemory 4.0.7 → 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 (142) hide show
  1. package/CHANGELOG.md +219 -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/cli/summary_cmd.py +23 -3
  37. package/src/superlocalmemory/code_graph/bridge/maintenance.py +7 -1
  38. package/src/superlocalmemory/core/config.py +41 -7
  39. package/src/superlocalmemory/core/consolidation_engine.py +14 -15
  40. package/src/superlocalmemory/core/context_cache.py +0 -2
  41. package/src/superlocalmemory/core/engine.py +371 -63
  42. package/src/superlocalmemory/core/evidence_bundle.py +3 -1
  43. package/src/superlocalmemory/core/install_detector.py +131 -0
  44. package/src/superlocalmemory/core/progressive_abstraction.py +1 -1
  45. package/src/superlocalmemory/core/recall_worker.py +4 -0
  46. package/src/superlocalmemory/core/security_primitives.py +3 -6
  47. package/src/superlocalmemory/core/store_pipeline.py +94 -26
  48. package/src/superlocalmemory/core/topic_signature.py +0 -2
  49. package/src/superlocalmemory/core/transactions/concrete_owners.py +15 -8
  50. package/src/superlocalmemory/dynamics/eap_scheduler.py +17 -6
  51. package/src/superlocalmemory/encoding/graph_builder.py +2 -2
  52. package/src/superlocalmemory/encoding/scene_builder.py +8 -2
  53. package/src/superlocalmemory/evolution/skill_evolver.py +16 -1
  54. package/src/superlocalmemory/hooks/adapter_base.py +0 -2
  55. package/src/superlocalmemory/hooks/context_payload.py +0 -2
  56. package/src/superlocalmemory/hooks/hook_handlers.py +38 -11
  57. package/src/superlocalmemory/hooks/portable_kit.py +8 -8
  58. package/src/superlocalmemory/hooks/post_tool_async_hook.py +0 -2
  59. package/src/superlocalmemory/hooks/prewarm_auth.py +0 -2
  60. package/src/superlocalmemory/hooks/user_prompt_hook.py +0 -2
  61. package/src/superlocalmemory/infra/backup.py +44 -8
  62. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +24 -7
  63. package/src/superlocalmemory/learning/arm_catalog.py +0 -2
  64. package/src/superlocalmemory/learning/bandit.py +0 -2
  65. package/src/superlocalmemory/learning/bandit_cache.py +0 -2
  66. package/src/superlocalmemory/learning/dedup_hnsw.py +11 -11
  67. package/src/superlocalmemory/learning/ensemble.py +0 -2
  68. package/src/superlocalmemory/learning/labeler.py +0 -2
  69. package/src/superlocalmemory/learning/legacy_migration.py +0 -2
  70. package/src/superlocalmemory/learning/model_cache.py +0 -2
  71. package/src/superlocalmemory/learning/pattern_miner.py +12 -7
  72. package/src/superlocalmemory/learning/ranker.py +0 -2
  73. package/src/superlocalmemory/learning/reward_archive.py +6 -1
  74. package/src/superlocalmemory/learning/reward_proxy.py +0 -2
  75. package/src/superlocalmemory/learning/signal_worker.py +0 -2
  76. package/src/superlocalmemory/math/fisher.py +1 -1
  77. package/src/superlocalmemory/math/hopfield.py +4 -1
  78. package/src/superlocalmemory/math/langevin.py +1 -1
  79. package/src/superlocalmemory/math/sheaf.py +7 -3
  80. package/src/superlocalmemory/mcp/cli_fallback.py +1 -1
  81. package/src/superlocalmemory/mcp/profiles.py +11 -4
  82. package/src/superlocalmemory/mcp/server.py +8 -1
  83. package/src/superlocalmemory/mcp/tools_active.py +56 -0
  84. package/src/superlocalmemory/mcp/tools_core.py +1 -1
  85. package/src/superlocalmemory/mcp/tools_summaries.py +147 -0
  86. package/src/superlocalmemory/optimize/cache/manager.py +2 -2
  87. package/src/superlocalmemory/optimize/compress/ccr.py +1 -1
  88. package/src/superlocalmemory/optimize/compress/router.py +1 -1
  89. package/src/superlocalmemory/optimize/proxy/_helpers.py +2 -2
  90. package/src/superlocalmemory/optimize/proxy/server.py +1 -1
  91. package/src/superlocalmemory/optimize/proxy/vertex_surface.py +2 -2
  92. package/src/superlocalmemory/optimize/storage/db.py +2 -2
  93. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  94. package/src/superlocalmemory/retrieval/ann_index.py +9 -2
  95. package/src/superlocalmemory/retrieval/bm25_channel.py +2 -2
  96. package/src/superlocalmemory/retrieval/bridge_discovery.py +2 -2
  97. package/src/superlocalmemory/retrieval/engine.py +272 -43
  98. package/src/superlocalmemory/retrieval/entity_channel.py +1 -1
  99. package/src/superlocalmemory/retrieval/hopfield_channel.py +8 -2
  100. package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
  101. package/src/superlocalmemory/retrieval/quantization_aware_search.py +1 -1
  102. package/src/superlocalmemory/retrieval/remote_reranker.py +2 -2
  103. package/src/superlocalmemory/retrieval/reranker.py +3 -3
  104. package/src/superlocalmemory/retrieval/semantic_channel.py +3 -3
  105. package/src/superlocalmemory/retrieval/spreading_activation.py +8 -8
  106. package/src/superlocalmemory/retrieval/strategy.py +94 -0
  107. package/src/superlocalmemory/retrieval/temporal_channel.py +167 -10
  108. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +1 -1
  109. package/src/superlocalmemory/retrieval/vector_store.py +88 -10
  110. package/src/superlocalmemory/server/consolidation_runner.py +140 -0
  111. package/src/superlocalmemory/server/recall_serializer.py +44 -2
  112. package/src/superlocalmemory/server/routes/agents.py +52 -8
  113. package/src/superlocalmemory/server/routes/brain.py +110 -2
  114. package/src/superlocalmemory/server/routes/memories.py +153 -0
  115. package/src/superlocalmemory/server/routes/prewarm.py +4 -4
  116. package/src/superlocalmemory/server/routes/v3_api.py +24 -46
  117. package/src/superlocalmemory/server/unified_daemon.py +566 -7
  118. package/src/superlocalmemory/storage/_schema_version.py +46 -3
  119. package/src/superlocalmemory/storage/backup.py +531 -0
  120. package/src/superlocalmemory/storage/database.py +11 -4
  121. package/src/superlocalmemory/storage/embedding_codec.py +129 -0
  122. package/src/superlocalmemory/storage/embedding_migrator.py +5 -3
  123. package/src/superlocalmemory/storage/migration_runner.py +142 -2
  124. package/src/superlocalmemory/storage/migrations/__init__.py +1 -1
  125. package/src/superlocalmemory/storage/migrations.py +15 -1
  126. package/src/superlocalmemory/storage/models.py +7 -0
  127. package/src/superlocalmemory/storage/quantized_store.py +4 -2
  128. package/src/superlocalmemory/summaries/base.py +159 -0
  129. package/src/superlocalmemory/summaries/daily_reflection.py +55 -8
  130. package/src/superlocalmemory/summaries/project_work_log.py +23 -7
  131. package/src/superlocalmemory/summaries/session_summary.py +10 -6
  132. package/src/superlocalmemory/ui/css/legacy-dashboard.css +1 -1
  133. package/src/superlocalmemory/ui/css/neural-glass.css +1 -1
  134. package/src/superlocalmemory/ui/index.html +9 -3
  135. package/src/superlocalmemory/ui/js/core.js +1 -1
  136. package/src/superlocalmemory/ui/js/od-boundedloops.js +324 -0
  137. package/src/superlocalmemory/ui/js/od-brain.js +1 -1
  138. package/src/superlocalmemory/ui/js/od-memories.js +337 -12
  139. package/src/superlocalmemory/ui/js/od-mesh.js +97 -5
  140. package/src/superlocalmemory/ui/js/od-operations.js +1 -150
  141. package/src/superlocalmemory/ui/js/od-optimize.js +36 -9
  142. package/src/superlocalmemory/ui/js/od-shell.js +10 -0
@@ -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 -----------------------------------------------
@@ -98,6 +98,165 @@ GENERATED_BY_LLM_C = "llm_c"
98
98
  """Cloud LLM (Mode C). Falls back via llm_b to extractive."""
99
99
 
100
100
 
101
+ # ── highlight formatting ────────────────────────────────────────────────────
102
+
103
+ #: Display width for one bullet in a summary body.
104
+ #:
105
+ #: Chosen for a bullet, not for a paragraph. The generators originally truncated
106
+ #: at 300 characters and nothing else, which looks fine on a synthetic corpus of
107
+ #: one-line facts and falls apart on a real store: agent-written facts routinely
108
+ #: contain blank lines and markdown headings, so a 300-character slice rendered
109
+ #: as six or more display lines and the bullet list stopped being a list.
110
+ HIGHLIGHT_CHARS = 180
111
+
112
+
113
+ def format_highlight(content: str, limit: int = HIGHLIGHT_CHARS) -> str:
114
+ """Collapse *content* to a single readable line for a summary bullet.
115
+
116
+ Three things, in order:
117
+
118
+ 1. **Flatten whitespace.** Newlines, blank lines and runs of spaces all
119
+ become one space. This is the fix that matters: character truncation
120
+ alone cannot keep a multi-paragraph fact on one line, and every
121
+ generator here writes into a bullet list.
122
+ 2. **Prefer a whole first sentence** when there is one and it fits. A
123
+ complete sentence reads better than a slice of one, and the first
124
+ sentence of a report is usually its summary.
125
+ 3. **Otherwise cut at a word boundary** and mark the cut with an ellipsis,
126
+ so it is visible that text was dropped rather than that a fact ended
127
+ mid-word.
128
+
129
+ Markdown heading markers are stripped because a flattened ``**Summary**``
130
+ mid-sentence reads as noise.
131
+ """
132
+ import re
133
+
134
+ text = re.sub(r"\s+", " ", (content or "")).strip()
135
+ # Leading/inline markdown emphasis and heading marks, once flattened, add
136
+ # nothing but clutter to a one-line bullet.
137
+ text = re.sub(r"(?:^|\s)#{1,6}\s+", " ", text)
138
+ text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
139
+ text = re.sub(r"\s+", " ", text).strip()
140
+
141
+ if not text:
142
+ return ""
143
+ if len(text) <= limit:
144
+ return text
145
+
146
+ # A complete first sentence, if it fits comfortably.
147
+ match = re.match(r"(.+?[.!?])(?:\s|$)", text)
148
+ if match:
149
+ sentence = match.group(1).strip()
150
+ if len(sentence) <= limit:
151
+ return sentence
152
+
153
+ cut = text[:limit]
154
+ space = cut.rfind(" ")
155
+ if space > limit * 0.6: # don't cut a long unbroken token to a stub
156
+ cut = cut[:space]
157
+ return cut.rstrip(" ,;:—-") + "…"
158
+
159
+
160
+ # ── LLM output cleanup ──────────────────────────────────────────────────────
161
+
162
+ #: Sentences a chat-tuned model emits *around* the answer rather than as part of
163
+ #: it. Anchored to the start of a paragraph so they cannot match mid-content.
164
+ #:
165
+ #: Measured, not guessed: a Mode B summary on the author's own store opened with
166
+ #: "I apologize for the previous confusion. It seems that I misunderstood the
167
+ #: context of the texts provided.\n\nTo provide a concise summary paragraph, here
168
+ #: is a merge of all the key information:" — 180 characters of the model talking
169
+ #: to itself, shown to the user as their daily reflection.
170
+ _LLM_PREAMBLE = (
171
+ r"^(?:"
172
+ r"i\s+apologi[sz]e\b.*"
173
+ r"|i'?m\s+sorry\b.*"
174
+ r"|it\s+seems\s+that\s+i\b.*"
175
+ r"|sure[,!.]?\s*(?:thing)?\b.*"
176
+ r"|certainly[,!.]?\b.*"
177
+ r"|of\s+course[,!.]?\b.*"
178
+ r"|here(?:'s|\s+is|\s+are)\b[^.!?]*:"
179
+ r"|to\s+(?:provide|summari[sz]e|answer)\b[^.!?]*:"
180
+ r"|based\s+on\s+the\s+(?:facts|texts|information|data)\s+provided[,:]?"
181
+ r"|as\s+(?:an|a)\s+(?:ai|language\s+model)\b.*"
182
+ r")\s*$"
183
+ )
184
+
185
+ #: Closing pleasantries. Same anchoring rule.
186
+ _LLM_POSTAMBLE = (
187
+ r"^(?:"
188
+ r"(?:i\s+hope|hope)\s+(?:this|that)\s+helps\b.*"
189
+ r"|let\s+me\s+know\b.*"
190
+ r"|feel\s+free\s+to\b.*"
191
+ r"|would\s+you\s+like\s+me\s+to\b.*"
192
+ r")$"
193
+ )
194
+
195
+
196
+ def clean_llm_summary(text: str) -> str:
197
+ """Strip chat-assistant scaffolding from a model-written summary.
198
+
199
+ WHY THIS EXISTS
200
+ ---------------
201
+ Mode B/C summaries are shown to the user as *their* memory, with no chat
202
+ framing around them. A chat-tuned model does not know that: it opens with an
203
+ apology or "Here is a concise summary:" and closes with "Let me know if you
204
+ want more detail". Both are addressed to a conversation that the reader
205
+ cannot see, and both make the product look broken.
206
+
207
+ The system prompt now asks for bare prose, which handles most of it. This is
208
+ the second line of defence, because instruction-following on a 3B local
209
+ model is not something to bet the displayed output on.
210
+
211
+ Conservative by construction: patterns are anchored to whole paragraphs or
212
+ whole leading sentences, so a summary that legitimately contains the word
213
+ "sure" mid-paragraph is untouched. If stripping would empty the text, the
214
+ original is returned — a scaffolded summary beats a blank one.
215
+ """
216
+ import re
217
+
218
+ original = (text or "").strip()
219
+ if not original:
220
+ return ""
221
+
222
+ # Fenced code blocks wrapping the whole answer: keep the contents.
223
+ fenced = re.match(r"^```[a-zA-Z]*\n(.*?)\n?```$", original, re.DOTALL)
224
+ if fenced:
225
+ original = fenced.group(1).strip()
226
+
227
+ paras = [p.strip() for p in re.split(r"\n\s*\n", original) if p.strip()]
228
+
229
+ while paras and re.match(_LLM_PREAMBLE, paras[0], re.IGNORECASE | re.DOTALL):
230
+ paras.pop(0)
231
+ while paras and re.match(_LLM_POSTAMBLE, paras[-1], re.IGNORECASE | re.DOTALL):
232
+ paras.pop()
233
+
234
+ if not paras:
235
+ return original
236
+
237
+ # A preamble that shares a paragraph with real content: drop just the leading
238
+ # sentence, and only when what follows is substantial enough to stand alone.
239
+ lead = re.match(r"(.+?[.:!?])\s+(\S.*)$", paras[0], re.DOTALL)
240
+ if lead and re.match(_LLM_PREAMBLE, lead.group(1), re.IGNORECASE | re.DOTALL):
241
+ if len(lead.group(2).strip()) > 40:
242
+ paras[0] = lead.group(2).strip()
243
+
244
+ cleaned = "\n\n".join(paras).strip()
245
+ return cleaned or original
246
+
247
+
248
+ #: System prompt for every summary generator, Mode B and Mode C alike.
249
+ #:
250
+ #: Mode C always sent one; Mode B sent none at all, which is why local-model
251
+ #: output arrived wrapped in chat scaffolding while cloud output did not.
252
+ SUMMARY_SYSTEM_PROMPT = (
253
+ "You summarise a person's own saved notes for them. "
254
+ "Reply with the summary text only — no preamble, no apologies, no sign-off, "
255
+ "no markdown headings, and never refer to yourself or to these instructions. "
256
+ "Write plain prose in the third person about the work described."
257
+ )
258
+
259
+
101
260
  def get_mode_str(config: object | None) -> str:
102
261
  """Extract the operating mode string ('a', 'b', or 'c') from a config."""
103
262
  if config is None: