superlocalmemory 4.0.8 → 4.0.10

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 (147) hide show
  1. package/CHANGELOG.md +221 -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 +164 -11
  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 +7 -7
  34. package/src/superlocalmemory/cli/service_installer.py +2 -1
  35. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  36. package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
  37. package/src/superlocalmemory/core/config.py +41 -7
  38. package/src/superlocalmemory/core/context_cache.py +0 -2
  39. package/src/superlocalmemory/core/engine.py +371 -63
  40. package/src/superlocalmemory/core/evidence_bundle.py +3 -1
  41. package/src/superlocalmemory/core/fact_consolidator.py +316 -125
  42. package/src/superlocalmemory/core/install_detector.py +131 -0
  43. package/src/superlocalmemory/core/maintenance.py +44 -6
  44. package/src/superlocalmemory/core/memory_health.py +266 -0
  45. package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
  46. package/src/superlocalmemory/core/operation_request.py +1 -1
  47. package/src/superlocalmemory/core/ops_remediation.py +2 -2
  48. package/src/superlocalmemory/core/progressive_abstraction.py +1 -1
  49. package/src/superlocalmemory/core/security_primitives.py +3 -6
  50. package/src/superlocalmemory/core/store_pipeline.py +172 -29
  51. package/src/superlocalmemory/core/topic_signature.py +0 -2
  52. package/src/superlocalmemory/core/transactions/concrete_owners.py +15 -8
  53. package/src/superlocalmemory/dynamics/eap_scheduler.py +17 -6
  54. package/src/superlocalmemory/encoding/cognitive_consolidator.py +15 -1
  55. package/src/superlocalmemory/encoding/graph_builder.py +2 -2
  56. package/src/superlocalmemory/encoding/scene_builder.py +8 -2
  57. package/src/superlocalmemory/hooks/adapter_base.py +0 -2
  58. package/src/superlocalmemory/hooks/context_payload.py +0 -2
  59. package/src/superlocalmemory/hooks/portable_kit.py +8 -8
  60. package/src/superlocalmemory/hooks/post_tool_async_hook.py +0 -2
  61. package/src/superlocalmemory/hooks/prewarm_auth.py +0 -2
  62. package/src/superlocalmemory/hooks/user_prompt_hook.py +0 -2
  63. package/src/superlocalmemory/infra/backup.py +44 -8
  64. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +24 -7
  65. package/src/superlocalmemory/learning/arm_catalog.py +0 -2
  66. package/src/superlocalmemory/learning/bandit.py +0 -2
  67. package/src/superlocalmemory/learning/bandit_cache.py +0 -2
  68. package/src/superlocalmemory/learning/dedup_hnsw.py +11 -11
  69. package/src/superlocalmemory/learning/ensemble.py +0 -2
  70. package/src/superlocalmemory/learning/labeler.py +0 -2
  71. package/src/superlocalmemory/learning/legacy_migration.py +0 -2
  72. package/src/superlocalmemory/learning/model_cache.py +0 -2
  73. package/src/superlocalmemory/learning/ranker.py +0 -2
  74. package/src/superlocalmemory/learning/reward_archive.py +6 -1
  75. package/src/superlocalmemory/learning/reward_proxy.py +0 -2
  76. package/src/superlocalmemory/learning/signal_worker.py +0 -2
  77. package/src/superlocalmemory/math/fisher.py +1 -1
  78. package/src/superlocalmemory/math/hopfield.py +4 -1
  79. package/src/superlocalmemory/math/langevin.py +1 -1
  80. package/src/superlocalmemory/math/sheaf.py +7 -3
  81. package/src/superlocalmemory/mcp/cli_fallback.py +1 -1
  82. package/src/superlocalmemory/mcp/profiles.py +1 -1
  83. package/src/superlocalmemory/mcp/server.py +2 -2
  84. package/src/superlocalmemory/mcp/session_binding.py +92 -0
  85. package/src/superlocalmemory/mcp/tools_active.py +56 -0
  86. package/src/superlocalmemory/mcp/tools_core.py +41 -40
  87. package/src/superlocalmemory/mcp/tools_ops.py +2 -2
  88. package/src/superlocalmemory/optimize/cache/manager.py +2 -2
  89. package/src/superlocalmemory/optimize/compress/ccr.py +1 -1
  90. package/src/superlocalmemory/optimize/compress/router.py +1 -1
  91. package/src/superlocalmemory/optimize/proxy/_helpers.py +2 -2
  92. package/src/superlocalmemory/optimize/proxy/server.py +1 -1
  93. package/src/superlocalmemory/optimize/proxy/vertex_surface.py +2 -2
  94. package/src/superlocalmemory/optimize/storage/db.py +2 -2
  95. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  96. package/src/superlocalmemory/retrieval/ann_index.py +9 -2
  97. package/src/superlocalmemory/retrieval/bm25_channel.py +6 -10
  98. package/src/superlocalmemory/retrieval/bridge_discovery.py +2 -2
  99. package/src/superlocalmemory/retrieval/engine.py +272 -43
  100. package/src/superlocalmemory/retrieval/entity_channel.py +8 -2
  101. package/src/superlocalmemory/retrieval/hopfield_channel.py +8 -2
  102. package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
  103. package/src/superlocalmemory/retrieval/quantization_aware_search.py +1 -1
  104. package/src/superlocalmemory/retrieval/remote_reranker.py +2 -2
  105. package/src/superlocalmemory/retrieval/reranker.py +3 -3
  106. package/src/superlocalmemory/retrieval/scope_policy.py +22 -1
  107. package/src/superlocalmemory/retrieval/semantic_channel.py +3 -3
  108. package/src/superlocalmemory/retrieval/spreading_activation.py +8 -8
  109. package/src/superlocalmemory/retrieval/strategy.py +94 -0
  110. package/src/superlocalmemory/retrieval/temporal_channel.py +179 -10
  111. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +1 -1
  112. package/src/superlocalmemory/retrieval/vector_store.py +151 -10
  113. package/src/superlocalmemory/server/api.py +6 -1
  114. package/src/superlocalmemory/server/asset_versions.py +171 -0
  115. package/src/superlocalmemory/server/recall_serializer.py +10 -0
  116. package/src/superlocalmemory/server/routes/abstraction.py +201 -0
  117. package/src/superlocalmemory/server/routes/brain.py +2 -2
  118. package/src/superlocalmemory/server/routes/data_io.py +29 -1
  119. package/src/superlocalmemory/server/routes/entity.py +13 -1
  120. package/src/superlocalmemory/server/routes/mesh.py +1 -1
  121. package/src/superlocalmemory/server/routes/prewarm.py +4 -4
  122. package/src/superlocalmemory/server/routes/v3_api.py +2 -2
  123. package/src/superlocalmemory/server/ui.py +8 -1
  124. package/src/superlocalmemory/server/unified_daemon.py +570 -16
  125. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  126. package/src/superlocalmemory/storage/_schema_version.py +46 -3
  127. package/src/superlocalmemory/storage/backup.py +531 -0
  128. package/src/superlocalmemory/storage/database.py +139 -34
  129. package/src/superlocalmemory/storage/embedding_codec.py +129 -0
  130. package/src/superlocalmemory/storage/embedding_migrator.py +5 -3
  131. package/src/superlocalmemory/storage/migration_runner.py +153 -2
  132. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
  133. package/src/superlocalmemory/storage/migrations/__init__.py +1 -1
  134. package/src/superlocalmemory/storage/migrations.py +15 -1
  135. package/src/superlocalmemory/storage/models.py +7 -0
  136. package/src/superlocalmemory/storage/quantized_store.py +4 -2
  137. package/src/superlocalmemory/storage/schema.py +98 -0
  138. package/src/superlocalmemory/summaries/base.py +1 -1
  139. package/src/superlocalmemory/summaries/non_answer.py +223 -0
  140. package/src/superlocalmemory/summaries/session_summary.py +1 -1
  141. package/src/superlocalmemory/ui/css/legacy-dashboard.css +1 -1
  142. package/src/superlocalmemory/ui/css/neural-glass.css +1 -1
  143. package/src/superlocalmemory/ui/index.html +1 -1
  144. package/src/superlocalmemory/ui/js/core.js +1 -1
  145. package/src/superlocalmemory/ui/js/od-brain.js +1 -1
  146. package/src/superlocalmemory/ui/js/od-memories.js +190 -1
  147. package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
@@ -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,
@@ -685,8 +686,12 @@ class DatabaseManager:
685
686
  include_shared=include_shared,
686
687
  prefix="f",
687
688
  )
689
+ # Pins are injected straight into an agent's context, which makes this
690
+ # the most consequential display path in the class: a withheld row here
691
+ # is not merely shown, it is asserted as background truth.
688
692
  rows = self.execute(
689
693
  f"SELECT f.* FROM atomic_facts f WHERE {where} AND f.pinned = 1 "
694
+ f"{self.visible_fact_clause('f')} "
690
695
  "AND NOT EXISTS ("
691
696
  " SELECT 1 FROM fact_temporal_validity tv "
692
697
  " WHERE tv.fact_id = f.fact_id "
@@ -732,6 +737,65 @@ class DatabaseManager:
732
737
  self._archive_col_present = True
733
738
  return present
734
739
 
740
+ def _has_quarantine_column(self) -> bool:
741
+ """Whether atomic_facts carries the ``quarantined`` column.
742
+
743
+ Same shape as ``_has_archive_status``: cached once True (a column never
744
+ disappears), re-checked while absent so a later schema pass is picked
745
+ up. ``storage.schema.create_all_tables`` adds the column at every engine
746
+ init, so on any store the daemon has opened this is True — the guard
747
+ exists for a bare DatabaseManager pointed at a store that engine init
748
+ never touched, where filtering on the column would raise instead of
749
+ returning results.
750
+ """
751
+ if getattr(self, "_quarantine_col_present", False):
752
+ return True
753
+ present = any(
754
+ dict(row).get("name") == "quarantined"
755
+ for row in self.execute("PRAGMA table_info(atomic_facts)")
756
+ )
757
+ if present:
758
+ self._quarantine_col_present = True
759
+ return present
760
+
761
+ def visible_fact_clause(
762
+ self, prefix: str = "", *, include_quarantined: bool = False,
763
+ ) -> str:
764
+ """AND-clause excluding rows no caller should be shown as a memory.
765
+
766
+ Two exclusions, one definition: soft-deleted (``archive_status``) and
767
+ withheld (``quarantined``). Both are presence-guarded, because each
768
+ column arrives with a migration and may be absent on a store the engine
769
+ has not opened.
770
+
771
+ WHY THIS EXISTS AS A FUNCTION. 4.0.10 first put the quarantine filter in
772
+ ``get_facts_by_ids`` alone, reasoning that every retrieval channel
773
+ re-authorises through it and the engine drops what it cannot hydrate.
774
+ That reasoning was correct and the conclusion was wrong: it covered the
775
+ RECALL pipeline, and ``search``, ``list_recent``, ``fetch``, the MCP
776
+ resources and the dashboard's own search are not the recall pipeline.
777
+ Measured on a copy of the author's store, ``search_facts_fts`` returned
778
+ 20 withheld rows out of 50 and ``get_all_facts`` 66 out of 400 — the
779
+ exact defect the design was meant to prevent, in the paths the design
780
+ never looked at.
781
+
782
+ There is no single SQL choke point in this codebase; ``_scope_where`` is
783
+ spliced against six other tables and cannot carry a fact column. So the
784
+ honest form of "one place" is one CLAUSE with an enumerable set of call
785
+ sites, and a test that fails when a read path does not use it:
786
+ tests/test_storage/test_no_read_path_shows_a_withheld_row.py
787
+
788
+ ``include_quarantined=True`` is for repair, erasure and export — paths
789
+ that must reach a withheld row to act on it.
790
+ """
791
+ table = f"{prefix}." if prefix else ""
792
+ clause = ""
793
+ if self._has_archive_status():
794
+ clause += f" AND COALESCE({table}archive_status, 'live') != 'archived'"
795
+ if not include_quarantined and self._has_quarantine_column():
796
+ clause += f" AND COALESCE({table}quarantined, 0) = 0"
797
+ return clause
798
+
735
799
  def get_all_facts(
736
800
  self, profile_id: str, limit: int | None = None,
737
801
  *,
@@ -754,14 +818,10 @@ class DatabaseManager:
754
818
  # hard, env-tunable ceiling even when the caller passes limit=None.
755
819
  if limit is None:
756
820
  limit = _unbounded_facts_ceiling()
757
- # Archived facts are not live; never surface them in direct reads.
758
- archive_clause = (
759
- " AND COALESCE(archive_status, 'live') != 'archived'"
760
- if self._has_archive_status()
761
- else ""
762
- )
821
+ # Soft-deleted and withheld rows are not memories a caller may see.
763
822
  rows = self.execute(
764
- f"SELECT * FROM atomic_facts WHERE {where}{archive_clause} "
823
+ f"SELECT * FROM atomic_facts WHERE {where}"
824
+ f"{self.visible_fact_clause()} "
765
825
  "ORDER BY created_at DESC LIMIT ?",
766
826
  (*params, int(limit)),
767
827
  )
@@ -789,14 +849,12 @@ class DatabaseManager:
789
849
  include_global=include_global,
790
850
  include_shared=include_shared,
791
851
  )
792
- archive_clause = (
793
- " AND COALESCE(archive_status, 'live') != 'archived'"
794
- if self._has_archive_status()
795
- else ""
796
- )
852
+ # Crossing a profile boundary is the last place a withheld row should
853
+ # appear: it would be a model's non-answer presented to somebody else
854
+ # as one of this profile's shared memories.
797
855
  rows = self.execute(
798
856
  f"SELECT * FROM atomic_facts WHERE {where} AND profile_id != ?"
799
- f"{archive_clause} ORDER BY created_at DESC",
857
+ f"{self.visible_fact_clause()} ORDER BY created_at DESC",
800
858
  (*params, profile_id),
801
859
  )
802
860
  return [self._row_to_fact(r) for r in rows]
@@ -873,7 +931,13 @@ class DatabaseManager:
873
931
  raise ValueError(f"Disallowed column(s): {bad_keys}")
874
932
  clean: dict[str, Any] = {}
875
933
  for k, v in updates.items():
876
- if isinstance(v, (list, dict)):
934
+ if k == "embedding":
935
+ # Embeddings are stored in the canonical binary form. Falling
936
+ # through to json.dumps here would write a text row back into a
937
+ # converted store, one fact at a time, undoing the conversion
938
+ # wherever a fact is updated.
939
+ clean[k] = encode_embedding(v) if v is not None else None
940
+ elif isinstance(v, (list, dict)):
877
941
  clean[k] = json.dumps(v)
878
942
  elif isinstance(v, (MemoryLifecycle, FactType, SignalType)):
879
943
  clean[k] = v.value
@@ -946,14 +1010,21 @@ class DatabaseManager:
946
1010
  include_global: bool = False,
947
1011
  include_shared: bool = False,
948
1012
  ) -> int:
949
- """Total fact count for a profile."""
1013
+ """Memories this profile has, as the owner would count them.
1014
+
1015
+ Counts what a caller can be shown, which is why it applies
1016
+ ``visible_fact_clause``. It fed the dashboard's "All memories 5,093" and
1017
+ was counting 1,195 withheld summaries and every soft-deleted row into
1018
+ that figure -- a number the owner reads as "how much do I remember".
1019
+ """
950
1020
  where, params = _scope_where(
951
1021
  profile_id,
952
1022
  include_global=include_global,
953
1023
  include_shared=include_shared,
954
1024
  )
955
1025
  rows = self.execute(
956
- f"SELECT COUNT(*) AS c FROM atomic_facts WHERE {where}", (*params,),
1026
+ f"SELECT COUNT(*) AS c FROM atomic_facts WHERE {where}"
1027
+ f"{self.visible_fact_clause()}", (*params,),
957
1028
  )
958
1029
  return int(rows[0]["c"]) if rows else 0
959
1030
 
@@ -1223,16 +1294,15 @@ class DatabaseManager:
1223
1294
  include_shared=include_shared,
1224
1295
  prefix="f",
1225
1296
  )
1226
- # Archived facts must not surface via full-text search either.
1227
- archive_clause = (
1228
- " AND COALESCE(f.archive_status, 'live') != 'archived'"
1229
- if self._has_archive_status()
1230
- else ""
1231
- )
1297
+ # Full-text search is a display path: the dashboard search box, the
1298
+ # `search` tool and `fetch` all land here, and none of them go through
1299
+ # the recall engine. Before 4.0.10 put the clause here it returned 20
1300
+ # withheld rows out of 50 on the author's store.
1232
1301
  rows = self.execute(
1233
1302
  f"""SELECT f.* FROM atomic_facts_fts AS fts
1234
1303
  JOIN atomic_facts AS f ON f.fact_id = fts.fact_id
1235
- WHERE fts.atomic_facts_fts MATCH ? AND {where}{archive_clause}
1304
+ WHERE fts.atomic_facts_fts MATCH ? AND {where}
1305
+ {self.visible_fact_clause('f')}
1236
1306
  ORDER BY fts.rank LIMIT ?""",
1237
1307
  (match_expr, *params, limit),
1238
1308
  )
@@ -1263,12 +1333,22 @@ class DatabaseManager:
1263
1333
  # ------------------------------------------------------------------
1264
1334
 
1265
1335
  def get_fact(self, fact_id: str, profile_id: str | None = None) -> AtomicFact | None:
1266
- """Get a single fact by ID.
1336
+ """Get a single row by ID, exactly as stored. NOT a display path.
1267
1337
 
1268
1338
  C4 defense-in-depth: when ``profile_id`` is provided the lookup is
1269
1339
  tenant-scoped so a fact_id from another profile cannot resolve. Left
1270
1340
  optional (fact_id is a random UUID sourced from already-scoped queries)
1271
1341
  to avoid destabilizing the core store/consolidation write path.
1342
+
1343
+ DELIBERATELY UNFILTERED, and this is load-bearing. It applies neither
1344
+ ``archive_status`` nor ``quarantined`` because it is the primitive that
1345
+ write paths, correction handling and the 4.0.10 repair use to read a row
1346
+ they already hold the id of — including a withheld one, which they must
1347
+ be able to see in order to act on it. ``visible_fact_clause`` is for the
1348
+ paths that answer a question; this one answers "what is in that row".
1349
+
1350
+ A caller taking a fact_id from user input and rendering the result wants
1351
+ ``get_facts_by_ids`` instead.
1272
1352
  """
1273
1353
  if profile_id is not None:
1274
1354
  rows = self.execute(
@@ -1285,8 +1365,36 @@ class DatabaseManager:
1285
1365
  self, fact_ids: list[str], profile_id: str,
1286
1366
  include_global: bool = False,
1287
1367
  include_shared: bool = False,
1368
+ *,
1369
+ include_quarantined: bool = False,
1288
1370
  ) -> list[AtomicFact]:
1289
- """Get multiple facts by their IDs, scoped to a profile."""
1371
+ """Get multiple facts by their IDs, scoped to a profile.
1372
+
1373
+ THIS IS THE PLACE QUARANTINE IS ENFORCED, and the only one.
1374
+
1375
+ Every retrieval channel re-authorises its candidates through here
1376
+ (``retrieval/scope_policy.py`` — "candidate generators may use caches,
1377
+ approximate indexes, or graph stores that are not the authorization
1378
+ source of truth"), and the engine hydrates the fused set from here too.
1379
+ A fact this method does not return has no content to show, and
1380
+ ``retrieval/engine.py`` drops it: ``if fact is None: continue``. So one
1381
+ clause here covers bm25, semantic, temporal, entity, hopfield and
1382
+ spreading activation, in normal and deep recall alike, whether or not
1383
+ the forgetting filter is registered.
1384
+
1385
+ The alternatives were checked and rejected. ``_scope_where`` looks like
1386
+ the natural home but is spliced against ``graph_edges``,
1387
+ ``temporal_events``, ``memories``, ``bm25_tokens``,
1388
+ ``fact_temporal_validity`` and ``correction_cases`` as well as
1389
+ ``atomic_facts``, so a column reference there breaks eight call sites.
1390
+ ``ForgettingFilter`` is optional (it no-ops when forgetting is
1391
+ disabled) and excludes nothing in deep recall.
1392
+
1393
+ ``include_quarantined=True`` is for repair, export and erasure — paths
1394
+ that must be able to see a withheld row in order to act on it. It is
1395
+ keyword-only and greppable on purpose: every caller that opts in is
1396
+ meant to be found in one search.
1397
+ """
1290
1398
  if not fact_ids:
1291
1399
  return []
1292
1400
  where, params = _scope_where(
@@ -1294,15 +1402,12 @@ class DatabaseManager:
1294
1402
  include_global=include_global,
1295
1403
  include_shared=include_shared,
1296
1404
  )
1297
- archive_clause = (
1298
- " AND COALESCE(archive_status, 'live') != 'archived'"
1299
- if self._has_archive_status()
1300
- else ""
1301
- )
1302
1405
  placeholders = ",".join("?" for _ in fact_ids)
1303
1406
  rows = self.execute(
1304
1407
  f"SELECT * FROM atomic_facts WHERE fact_id IN ({placeholders}) "
1305
- f"AND {where}{archive_clause} ORDER BY created_at DESC",
1408
+ f"AND {where}"
1409
+ f"{self.visible_fact_clause(include_quarantined=include_quarantined)} "
1410
+ "ORDER BY created_at DESC",
1306
1411
  (*fact_ids, *params),
1307
1412
  )
1308
1413
  return [self._row_to_fact(r) for r in rows]
@@ -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
 
@@ -160,6 +159,9 @@ from superlocalmemory.storage.migrations import (
160
159
  from superlocalmemory.storage.migrations import (
161
160
  M042_correction_case_ledger as _M042,
162
161
  )
162
+ from superlocalmemory.storage.migrations import (
163
+ M043_quarantine_display_summaries as _M043,
164
+ )
163
165
  from superlocalmemory.storage._schema_version import (
164
166
  SUPPORTED_SCHEMA_VERSION,
165
167
  SchemaVersionError,
@@ -177,6 +179,10 @@ from superlocalmemory.storage._migration_internals import (
177
179
  _migration_log_exists,
178
180
  _read_log,
179
181
  )
182
+ from superlocalmemory.storage.backup import (
183
+ _gc_old_backups,
184
+ _pre_migration_backup,
185
+ )
180
186
 
181
187
  logger = logging.getLogger(__name__)
182
188
 
@@ -309,6 +315,14 @@ DEFERRED_MIGRATIONS: list[Migration] = [
309
315
  # Main-line M034 is renumbered in V4. It must remain deferred because its
310
316
  # backfill joins engine-bootstrapped memory_scenes and atomic_facts.
311
317
  Migration(name=_M039.NAME, db_target="memory", ddl=_M039.DDL),
318
+ # M043 withholds model-written summaries from the retrieval corpus and
319
+ # un-hides the memories they displaced. Deferred because it reads and
320
+ # writes atomic_facts + fact_retention, both bootstrapped at engine init —
321
+ # the same reason M011/M013/M015/M016 are deferred. apply_deferred takes a
322
+ # verified snapshot before the first migration it actually applies, so the
323
+ # store is recoverable.
324
+ Migration(name=_M043.NAME, db_target="memory", ddl=_M043.DDL,
325
+ dependencies=(_M011.NAME,)),
312
326
  ]
313
327
 
314
328
 
@@ -375,6 +389,61 @@ def _bootstrap_learning_schema(learning_db: Path, *, dry_run: bool) -> str | Non
375
389
  return None
376
390
 
377
391
 
392
+ def _foreign_live_daemon(memory_db: Path) -> "int | None":
393
+ """Return the pid of another live daemon holding this data dir, or None.
394
+
395
+ Migrations are not fenced against concurrent writers. The realistic hazard
396
+ is an OLD daemon still running after an upgrade while a NEW one starts: its
397
+ WAL appends continue while DDL is applied, which can make a migration fail
398
+ non-deterministically. The snapshot itself stays consistent — the SQLite
399
+ backup API copies committed pages only — and a racing migration is recorded
400
+ as ``failed`` and is non-fatal, so this does not corrupt data.
401
+
402
+ This detects the condition and reports it. It deliberately does NOT refuse:
403
+ ``apply_all`` runs inside the daemon's own startup, so refusing whenever "a
404
+ daemon is running" would refuse on itself, and blocking on a lock here would
405
+ risk wedging startup — a worse outcome than a retryable failed step.
406
+ """
407
+ try:
408
+ pid_file = memory_db.parent / "daemon.pid"
409
+ if not pid_file.is_file():
410
+ return None
411
+ pid = int(pid_file.read_text().strip() or 0)
412
+ if pid <= 0 or pid == os.getpid():
413
+ return None
414
+ os.kill(pid, 0) # signal 0 tests liveness without touching it
415
+ return pid
416
+ except (OSError, ValueError):
417
+ return None
418
+
419
+
420
+ def _nothing_left_to_apply(learning_db: Path, memory_db: Path) -> bool:
421
+ """True when every migration is already recorded in its target database.
422
+
423
+ Used to decide whether a snapshot is worth taking. A snapshot is only
424
+ valuable when something is about to change; taking one on a start where
425
+ nothing changes copies the ALREADY-MIGRATED store and then prunes a
426
+ generation — so after two such starts the last copy of the original is gone,
427
+ and the safety net has quietly deleted the thing it exists to protect.
428
+
429
+ Errs toward False, which means "take the snapshot" — the safe direction.
430
+ """
431
+ try:
432
+ for migration in MIGRATIONS:
433
+ db_path = _db_for(migration.db_target, learning_db, memory_db)
434
+ if not db_path.exists():
435
+ return False
436
+ conn = _connect(db_path)
437
+ try:
438
+ if not _deferred_already_applied(conn, migration.name):
439
+ return False
440
+ finally:
441
+ conn.close()
442
+ except Exception: # noqa: BLE001 — any doubt means take the snapshot
443
+ return False
444
+ return True
445
+
446
+
378
447
  def apply_all(
379
448
  learning_db: Path,
380
449
  memory_db: Path,
@@ -401,6 +470,44 @@ def apply_all(
401
470
  failed: list[str] = []
402
471
  details: dict[str, str] = {}
403
472
 
473
+ # Take a consistent snapshot of both databases before any migration runs.
474
+ # The backup uses the SQLite backup API so in-flight WAL writers are
475
+ # never captured mid-transaction. InsufficientDiskSpaceError propagates
476
+ # to the caller — migration is intentionally aborted when disk is too
477
+ # tight to keep a recoverable copy.
478
+ # A snapshot is only worth taking when something is about to change. This
479
+ # runs on every engine construction, not just upgrades, so snapshotting
480
+ # unconditionally meant an ordinary start copied the already-migrated store
481
+ # and pruned a generation — two extra starts and the original was gone.
482
+ _pending = not _nothing_left_to_apply(learning_db, memory_db)
483
+ if not dry_run and not _pending:
484
+ details["_backup"] = "skipped: every migration already applied"
485
+
486
+ if not dry_run and _pending:
487
+ _other = _foreign_live_daemon(memory_db)
488
+ if _other is not None:
489
+ logger.warning(
490
+ "Another SuperLocalMemory daemon (pid %s) is still running and "
491
+ "writing to this data directory. Migrations are not fenced "
492
+ "against concurrent writers, so a step may fail and need a "
493
+ "retry. Your data is not at risk: the snapshot copies committed "
494
+ "pages only, and a failed step is recorded, never forced. Stop "
495
+ "the other daemon and restart if a step fails.",
496
+ _other,
497
+ )
498
+ details["_concurrent_daemon_pid"] = str(_other)
499
+
500
+ backup_dir = _pre_migration_backup(
501
+ learning_db, memory_db,
502
+ backups_root=memory_db.parent / "pre-migration-snapshots",
503
+ )
504
+ # _pre_migration_backup returns the snapshots root itself, so this is
505
+ # the directory to prune. Passing .parent pointed the collector at the
506
+ # data directory, where it matched nothing and pruned nothing — leaving
507
+ # every snapshot on disk for ever.
508
+ _gc_old_backups(backup_dir)
509
+ details["_backup"] = str(backup_dir)
510
+
404
511
  schema_error = _bootstrap_learning_schema(learning_db, dry_run=dry_run)
405
512
  if schema_error is not None:
406
513
  failed.append("learning_schema_bootstrap")
@@ -461,6 +568,28 @@ def apply_all(
461
568
  }
462
569
 
463
570
 
571
+ def _deferred_already_applied(conn: sqlite3.Connection, name: str) -> bool:
572
+ """True when ``name`` is recorded as ``complete`` in this database's migration_log.
573
+
574
+ Used only to decide whether a snapshot is needed. On any error it returns
575
+ False, which errs toward taking a snapshot — the safe direction.
576
+
577
+ A row whose status is ``failed`` or ``in_progress`` is NOT considered applied:
578
+ the runner will retry those entries, and the store deserves a fresh snapshot
579
+ before any retry runs DDL against it. Counting any row (regardless of status)
580
+ caused ``_nothing_left_to_apply`` to return True after a failed migration,
581
+ so the retry ran against the already-partial store with no new safety copy.
582
+ """
583
+ try:
584
+ row = conn.execute(
585
+ "SELECT 1 FROM migration_log WHERE name = ? AND status = 'complete' LIMIT 1",
586
+ (name,),
587
+ ).fetchone()
588
+ return row is not None
589
+ except sqlite3.Error:
590
+ return False
591
+
592
+
464
593
  def apply_deferred(
465
594
  learning_db: Path,
466
595
  memory_db: Path,
@@ -495,6 +624,25 @@ def apply_deferred(
495
624
  failed: list[str] = []
496
625
  details: dict[str, str] = {}
497
626
 
627
+ # apply_all snapshots before it touches anything; this pass did not, yet it
628
+ # applies real DDL to both managed databases — including the column the
629
+ # daemon needs to start. An interrupted deferred pass therefore had no
630
+ # recoverable copy at all. The snapshot is taken LAZILY, immediately before
631
+ # the first migration that will actually be applied, so a pass with nothing
632
+ # to do costs no disk and does not capture post-init state unnecessarily.
633
+ _snapshot_state: dict[str, object] = {"taken": dry_run}
634
+
635
+ def _ensure_snapshot() -> None:
636
+ if _snapshot_state["taken"]:
637
+ return
638
+ _snapshot_state["taken"] = True
639
+ backup_dir = _pre_migration_backup(
640
+ learning_db, memory_db,
641
+ backups_root=memory_db.parent / "pre-migration-snapshots",
642
+ )
643
+ _gc_old_backups(backup_dir)
644
+ details["_deferred_backup"] = str(backup_dir)
645
+
498
646
  blocked: set[str] = set()
499
647
  for migration in DEFERRED_MIGRATIONS:
500
648
  unmet = [d for d in migration.dependencies if d in failed or d in blocked]
@@ -529,6 +677,9 @@ def apply_deferred(
529
677
  )
530
678
  continue
531
679
 
680
+ if not dry_run and not _deferred_already_applied(conn, migration.name):
681
+ _ensure_snapshot()
682
+
532
683
  outcome, detail = _apply_single(conn, migration, dry_run=dry_run)
533
684
  details[migration.name] = detail
534
685
  if outcome == "applied":