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.
- package/CHANGELOG.md +221 -1
- package/README.md +6 -6
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/scripts/ensure-venv.sh +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +3 -3
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +2 -2
- package/pyproject.toml +1 -1
- package/scripts/postinstall.js +4 -0
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/_lazy_init.py +1 -1
- package/src/superlocalmemory/cli/commands.py +164 -11
- package/src/superlocalmemory/cli/db_migrate.py +0 -2
- package/src/superlocalmemory/cli/gdpr_io.py +1 -1
- package/src/superlocalmemory/cli/main.py +7 -7
- package/src/superlocalmemory/cli/service_installer.py +2 -1
- package/src/superlocalmemory/cli/setup_wizard.py +1 -1
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
- package/src/superlocalmemory/core/config.py +41 -7
- package/src/superlocalmemory/core/context_cache.py +0 -2
- package/src/superlocalmemory/core/engine.py +371 -63
- package/src/superlocalmemory/core/evidence_bundle.py +3 -1
- package/src/superlocalmemory/core/fact_consolidator.py +316 -125
- package/src/superlocalmemory/core/install_detector.py +131 -0
- package/src/superlocalmemory/core/maintenance.py +44 -6
- package/src/superlocalmemory/core/memory_health.py +266 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
- package/src/superlocalmemory/core/operation_request.py +1 -1
- package/src/superlocalmemory/core/ops_remediation.py +2 -2
- package/src/superlocalmemory/core/progressive_abstraction.py +1 -1
- package/src/superlocalmemory/core/security_primitives.py +3 -6
- package/src/superlocalmemory/core/store_pipeline.py +172 -29
- package/src/superlocalmemory/core/topic_signature.py +0 -2
- package/src/superlocalmemory/core/transactions/concrete_owners.py +15 -8
- package/src/superlocalmemory/dynamics/eap_scheduler.py +17 -6
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +15 -1
- package/src/superlocalmemory/encoding/graph_builder.py +2 -2
- package/src/superlocalmemory/encoding/scene_builder.py +8 -2
- package/src/superlocalmemory/hooks/adapter_base.py +0 -2
- package/src/superlocalmemory/hooks/context_payload.py +0 -2
- package/src/superlocalmemory/hooks/portable_kit.py +8 -8
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +0 -2
- package/src/superlocalmemory/hooks/prewarm_auth.py +0 -2
- package/src/superlocalmemory/hooks/user_prompt_hook.py +0 -2
- package/src/superlocalmemory/infra/backup.py +44 -8
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +24 -7
- package/src/superlocalmemory/learning/arm_catalog.py +0 -2
- package/src/superlocalmemory/learning/bandit.py +0 -2
- package/src/superlocalmemory/learning/bandit_cache.py +0 -2
- package/src/superlocalmemory/learning/dedup_hnsw.py +11 -11
- package/src/superlocalmemory/learning/ensemble.py +0 -2
- package/src/superlocalmemory/learning/labeler.py +0 -2
- package/src/superlocalmemory/learning/legacy_migration.py +0 -2
- package/src/superlocalmemory/learning/model_cache.py +0 -2
- package/src/superlocalmemory/learning/ranker.py +0 -2
- package/src/superlocalmemory/learning/reward_archive.py +6 -1
- package/src/superlocalmemory/learning/reward_proxy.py +0 -2
- package/src/superlocalmemory/learning/signal_worker.py +0 -2
- package/src/superlocalmemory/math/fisher.py +1 -1
- package/src/superlocalmemory/math/hopfield.py +4 -1
- package/src/superlocalmemory/math/langevin.py +1 -1
- package/src/superlocalmemory/math/sheaf.py +7 -3
- package/src/superlocalmemory/mcp/cli_fallback.py +1 -1
- package/src/superlocalmemory/mcp/profiles.py +1 -1
- package/src/superlocalmemory/mcp/server.py +2 -2
- package/src/superlocalmemory/mcp/session_binding.py +92 -0
- package/src/superlocalmemory/mcp/tools_active.py +56 -0
- package/src/superlocalmemory/mcp/tools_core.py +41 -40
- package/src/superlocalmemory/mcp/tools_ops.py +2 -2
- package/src/superlocalmemory/optimize/cache/manager.py +2 -2
- package/src/superlocalmemory/optimize/compress/ccr.py +1 -1
- package/src/superlocalmemory/optimize/compress/router.py +1 -1
- package/src/superlocalmemory/optimize/proxy/_helpers.py +2 -2
- package/src/superlocalmemory/optimize/proxy/server.py +1 -1
- package/src/superlocalmemory/optimize/proxy/vertex_surface.py +2 -2
- package/src/superlocalmemory/optimize/storage/db.py +2 -2
- package/src/superlocalmemory/retrieval/agentic.py +1 -1
- package/src/superlocalmemory/retrieval/ann_index.py +9 -2
- package/src/superlocalmemory/retrieval/bm25_channel.py +6 -10
- package/src/superlocalmemory/retrieval/bridge_discovery.py +2 -2
- package/src/superlocalmemory/retrieval/engine.py +272 -43
- package/src/superlocalmemory/retrieval/entity_channel.py +8 -2
- package/src/superlocalmemory/retrieval/hopfield_channel.py +8 -2
- package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
- package/src/superlocalmemory/retrieval/quantization_aware_search.py +1 -1
- package/src/superlocalmemory/retrieval/remote_reranker.py +2 -2
- package/src/superlocalmemory/retrieval/reranker.py +3 -3
- package/src/superlocalmemory/retrieval/scope_policy.py +22 -1
- package/src/superlocalmemory/retrieval/semantic_channel.py +3 -3
- package/src/superlocalmemory/retrieval/spreading_activation.py +8 -8
- package/src/superlocalmemory/retrieval/strategy.py +94 -0
- package/src/superlocalmemory/retrieval/temporal_channel.py +179 -10
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +1 -1
- package/src/superlocalmemory/retrieval/vector_store.py +151 -10
- package/src/superlocalmemory/server/api.py +6 -1
- package/src/superlocalmemory/server/asset_versions.py +171 -0
- package/src/superlocalmemory/server/recall_serializer.py +10 -0
- package/src/superlocalmemory/server/routes/abstraction.py +201 -0
- package/src/superlocalmemory/server/routes/brain.py +2 -2
- package/src/superlocalmemory/server/routes/data_io.py +29 -1
- package/src/superlocalmemory/server/routes/entity.py +13 -1
- package/src/superlocalmemory/server/routes/mesh.py +1 -1
- package/src/superlocalmemory/server/routes/prewarm.py +4 -4
- package/src/superlocalmemory/server/routes/v3_api.py +2 -2
- package/src/superlocalmemory/server/ui.py +8 -1
- package/src/superlocalmemory/server/unified_daemon.py +570 -16
- package/src/superlocalmemory/storage/_migration_internals.py +4 -0
- package/src/superlocalmemory/storage/_schema_version.py +46 -3
- package/src/superlocalmemory/storage/backup.py +531 -0
- package/src/superlocalmemory/storage/database.py +139 -34
- package/src/superlocalmemory/storage/embedding_codec.py +129 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +5 -3
- package/src/superlocalmemory/storage/migration_runner.py +153 -2
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +1 -1
- package/src/superlocalmemory/storage/migrations.py +15 -1
- package/src/superlocalmemory/storage/models.py +7 -0
- package/src/superlocalmemory/storage/quantized_store.py +4 -2
- package/src/superlocalmemory/storage/schema.py +98 -0
- package/src/superlocalmemory/summaries/base.py +1 -1
- package/src/superlocalmemory/summaries/non_answer.py +223 -0
- package/src/superlocalmemory/summaries/session_summary.py +1 -1
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +1 -1
- package/src/superlocalmemory/ui/css/neural-glass.css +1 -1
- package/src/superlocalmemory/ui/index.html +1 -1
- package/src/superlocalmemory/ui/js/core.js +1 -1
- package/src/superlocalmemory/ui/js/od-brain.js +1 -1
- package/src/superlocalmemory/ui/js/od-memories.js +190 -1
- 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
|
-
|
|
559
|
+
encode_embedding(fact.embedding), _jd(fact.fisher_mean), _jd(fact.fisher_variance),
|
|
559
560
|
fact.lifecycle.value, _jd(fact.langevin_position),
|
|
560
561
|
fact.emotional_valence, fact.emotional_arousal,
|
|
561
562
|
fact.signal_type.value, fact.created_at, _scope, _shared),
|
|
@@ -591,7 +592,7 @@ class DatabaseManager:
|
|
|
591
592
|
evidence_count=d["evidence_count"], access_count=d["access_count"],
|
|
592
593
|
source_turn_ids=_jl(d.get("source_turn_ids_json")),
|
|
593
594
|
session_id=d.get("session_id", ""),
|
|
594
|
-
embedding=
|
|
595
|
+
embedding=decode_embedding(d.get("embedding"), fact_id=d.get("fact_id", "<unknown>")),
|
|
595
596
|
fisher_mean=_jl(d.get("fisher_mean"), None),
|
|
596
597
|
fisher_variance=_jl(d.get("fisher_variance"), None),
|
|
597
598
|
lifecycle=MemoryLifecycle(d["lifecycle"]) if d.get("lifecycle") else MemoryLifecycle.ACTIVE,
|
|
@@ -645,7 +646,7 @@ class DatabaseManager:
|
|
|
645
646
|
fact.access_count,
|
|
646
647
|
json.dumps(fact.source_turn_ids),
|
|
647
648
|
fact.session_id,
|
|
648
|
-
|
|
649
|
+
encode_embedding(fact.embedding),
|
|
649
650
|
_jd(fact.fisher_mean),
|
|
650
651
|
_jd(fact.fisher_variance),
|
|
651
652
|
fact.lifecycle.value,
|
|
@@ -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
|
-
#
|
|
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}
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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"{
|
|
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
|
|
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
|
-
"""
|
|
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}"
|
|
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
|
-
#
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
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}
|
|
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
|
|
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}
|
|
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
|
-
(
|
|
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
|
-
|
|
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
|
-
(
|
|
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":
|