loop-memory 0.4.5__tar.gz → 0.4.7__tar.gz
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.
- {loop_memory-0.4.5 → loop_memory-0.4.7}/PKG-INFO +14 -7
- {loop_memory-0.4.5 → loop_memory-0.4.7}/README.md +13 -6
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/cli/commands/cognitive.py +139 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/cli/commands/read.py +41 -3
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/cli/main.py +10 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/serve/routes/export.py +36 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/serve/routes/memories.py +16 -0
- loop_memory-0.4.7/loop_memory/storage/snapshot.py +231 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/storage/sqlite_store.py +289 -17
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory.egg-info/PKG-INFO +14 -7
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory.egg-info/SOURCES.txt +9 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/pyproject.toml +1 -1
- loop_memory-0.4.7/tests/test_cli_audit_supersede.py +162 -0
- loop_memory-0.4.7/tests/test_cli_memory_stats.py +82 -0
- loop_memory-0.4.7/tests/test_cli_recall_verbose.py +149 -0
- loop_memory-0.4.7/tests/test_cli_snapshot.py +112 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_contradictions.py +19 -4
- loop_memory-0.4.7/tests/test_memory_stats.py +155 -0
- loop_memory-0.4.7/tests/test_recall_provenance.py +197 -0
- loop_memory-0.4.7/tests/test_snapshot.py +215 -0
- loop_memory-0.4.7/tests/test_supersession_chain.py +370 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/LICENSE +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/__init__.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/backends/__init__.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/backends/embedding.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/backends/sentence_embedder.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/backends/vector_store.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/cli/__init__.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/cli/_common.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/cli/commands/__init__.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/cli/commands/diag.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/cli/commands/graph.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/cli/commands/hooks.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/cli/commands/rules.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/cli/commands/serve.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/cli/commands/write.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/engine/__init__.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/engine/loop.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/engine/reflect.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/examples/__init__.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/examples/demo.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/export/__init__.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/export/memory_md.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/graph/__init__.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/graph/build.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/graph/extract.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/ingest/__init__.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/ingest/loader.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/ingest/pipeline.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/jobs/__init__.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/jobs/cognitive.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/jobs/compact.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/jobs/consolidate.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/jobs/contradiction.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/jobs/evolution.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/jobs/graph.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/jobs/llm_compact_pass.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/jobs/llm_consolidate.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/jobs/scheduler.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/llm/__init__.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/llm/base.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/llm/openai_adapter.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/llm/providers.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/mcp/__init__.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/memory/__init__.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/memory/types.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/privacy/__init__.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/privacy/private.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/privacy/redact.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/py.typed +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/sdk.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/sdk_extensions.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/security/__init__.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/security/secrets.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/serve/__init__.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/serve/app.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/serve/handlers.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/serve/routes/_shared.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/serve/routes/admin.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/serve/routes/cognitive.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/serve/routes/graph.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/serve/routes/insights.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/serve/routes/sessions.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/serve/routes/system.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/serve/routes/wiki.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/serve/static/__init__.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/serve/static/index.html +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/serve/watcher.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/storage/__init__.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/storage/retrieval.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/wiki/__init__.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/wiki/backfill.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/wiki/classifier.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/wiki/prompts.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory/wiki/scope.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory.egg-info/dependency_links.txt +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory.egg-info/entry_points.txt +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory.egg-info/requires.txt +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/loop_memory.egg-info/top_level.txt +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/setup.cfg +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_admin_ingest_route.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_agent_memory_api.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_agent_memory_sdk.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_auth_token_rotate.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_cli_rules.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_cli_v7.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_cli_version.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_evolution.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_evolution_quality.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_expanduser.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_export_ask.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_graph.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_ingest.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_llm_consolidator.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_llm_fingerprint_not_persisted.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_llm_providers.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_llm_test_endpoint.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_loop.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_mcp.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_memories_pagination.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_openclaw_loader.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_package_version.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_reflection.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_repo_framing.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_score_api.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_scoring_v2.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_secrets.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_serve_app.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_serve_handlers.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_session_order.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_short_query.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_store.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_summarization.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_universal_memory.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_vector_store.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_watcher.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_wiki_classifier.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_wiki_export_escape.py +0 -0
- {loop_memory-0.4.5 → loop_memory-0.4.7}/tests/test_wiki_prompts.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loop-memory
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.7
|
|
4
4
|
Summary: A general-purpose, local memory system for every AI agent you run. Loop Memory auto-captures conversations from Codex / Claude / Hermes / OpenClaw, scores them by importance × recency × usage × feedback, distils them into a curated wiki, and serves everything from a single web UI.
|
|
5
5
|
Author: Loop Memory contributors <loop-memory@users.noreply.github.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -66,12 +66,19 @@ Dynamic: license-file
|
|
|
66
66
|
|
|
67
67
|
---
|
|
68
68
|
|
|
69
|
-
> **What's new in 0.4.
|
|
70
|
-
>
|
|
71
|
-
>
|
|
72
|
-
>
|
|
73
|
-
>
|
|
74
|
-
>
|
|
69
|
+
> **What's new in 0.4.7** — **per-memory lifetime stats** +
|
|
70
|
+
> **portable SQLite snapshot**. `loop-memory memory-stats <id>`
|
|
71
|
+
> returns a flat dict (recall_count, positive, negative, age,
|
|
72
|
+
> etc.) so you can ask "is anyone still using this memory?"
|
|
73
|
+
> without cracking open the SQLite file (audit 2026-09-06,
|
|
74
|
+
> agentmemory v1.3.0 pattern). `loop-memory snapshot <out>` /
|
|
75
|
+
> `loop-memory restore <in>` move a single-file, lossless
|
|
76
|
+
> snapshot of the entire store — including recall-quality
|
|
77
|
+
> signals and the supersession chain — to another machine
|
|
78
|
+
> (audit 2026-09-06, codexa-memory v0.2.0 pattern). Both shapes
|
|
79
|
+
> ship with HTTP routes (`/api/memories/{id}/stats`,
|
|
80
|
+
> `/api/snapshot`, `/api/snapshot/restore`). 33 new regression
|
|
81
|
+
> cases pin both shapes.
|
|
75
82
|
> [Full changelog →](CHANGELOG.md)
|
|
76
83
|
|
|
77
84
|
---
|
|
@@ -22,12 +22,19 @@
|
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
25
|
-
> **What's new in 0.4.
|
|
26
|
-
>
|
|
27
|
-
>
|
|
28
|
-
>
|
|
29
|
-
>
|
|
30
|
-
>
|
|
25
|
+
> **What's new in 0.4.7** — **per-memory lifetime stats** +
|
|
26
|
+
> **portable SQLite snapshot**. `loop-memory memory-stats <id>`
|
|
27
|
+
> returns a flat dict (recall_count, positive, negative, age,
|
|
28
|
+
> etc.) so you can ask "is anyone still using this memory?"
|
|
29
|
+
> without cracking open the SQLite file (audit 2026-09-06,
|
|
30
|
+
> agentmemory v1.3.0 pattern). `loop-memory snapshot <out>` /
|
|
31
|
+
> `loop-memory restore <in>` move a single-file, lossless
|
|
32
|
+
> snapshot of the entire store — including recall-quality
|
|
33
|
+
> signals and the supersession chain — to another machine
|
|
34
|
+
> (audit 2026-09-06, codexa-memory v0.2.0 pattern). Both shapes
|
|
35
|
+
> ship with HTTP routes (`/api/memories/{id}/stats`,
|
|
36
|
+
> `/api/snapshot`, `/api/snapshot/restore`). 33 new regression
|
|
37
|
+
> cases pin both shapes.
|
|
31
38
|
> [Full changelog →](CHANGELOG.md)
|
|
32
39
|
|
|
33
40
|
---
|
|
@@ -85,6 +85,29 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
85
85
|
rc.add_argument("--dry-run", action="store_true",
|
|
86
86
|
help="Report what would change without persisting")
|
|
87
87
|
|
|
88
|
+
# Audit 2026-09-06: per-memory lifetime stats
|
|
89
|
+
# (agentmemory v1.3.0 pattern, slimmed down).
|
|
90
|
+
ms = sub.add_parser(
|
|
91
|
+
"memory-stats",
|
|
92
|
+
help="Per-memory lifetime stats (audit 2026-09-06)")
|
|
93
|
+
ms.add_argument("memory_id",
|
|
94
|
+
help="Memory id (or short prefix) to look up")
|
|
95
|
+
ms.add_argument("--prefix", action="store_true",
|
|
96
|
+
help="Allow a short prefix match (first 8+ chars)")
|
|
97
|
+
|
|
98
|
+
# Audit 2026-09-06: portable SQLite snapshot
|
|
99
|
+
# (codexa-memory v0.2.0 pattern).
|
|
100
|
+
sn = sub.add_parser(
|
|
101
|
+
"snapshot",
|
|
102
|
+
help="Write a portable SQLite snapshot of the live store")
|
|
103
|
+
sn.add_argument("out_path",
|
|
104
|
+
help="Destination .memory.sqlite file")
|
|
105
|
+
rs = sub.add_parser(
|
|
106
|
+
"restore",
|
|
107
|
+
help="Re-hydrate a portable SQLite snapshot into the live store")
|
|
108
|
+
rs.add_argument("in_path",
|
|
109
|
+
help="Source .memory.sqlite file")
|
|
110
|
+
|
|
88
111
|
return p
|
|
89
112
|
|
|
90
113
|
|
|
@@ -116,6 +139,45 @@ def run_audit(args: list) -> int:
|
|
|
116
139
|
return _emit({"rows": s.list_audit(kind=ns.kind, action=ns.action, limit=ns.limit)})
|
|
117
140
|
|
|
118
141
|
|
|
142
|
+
def run_audit_supersede(args: list) -> int:
|
|
143
|
+
"""``loop-memory audit-supersede [--target ID] [--limit N] [--by ID]``
|
|
144
|
+
|
|
145
|
+
Walks the supersession chain (audit 2026-08-30, Mem0 v2.0.19
|
|
146
|
+
Dream pattern). Without ``--target`` it lists every superseded
|
|
147
|
+
memory (the loser side of every merge), most-recent first. With
|
|
148
|
+
``--target <id>`` it returns just the chain starting at that
|
|
149
|
+
memory (oldest to newest). With ``--by <id>`` it filters the
|
|
150
|
+
list to memories that were superseded by a specific winner.
|
|
151
|
+
|
|
152
|
+
The CLI prints JSON so a shell pipeline can consume the output.
|
|
153
|
+
"""
|
|
154
|
+
import argparse as _ap
|
|
155
|
+
p = _ap.ArgumentParser(prog="loop-memory audit-supersede")
|
|
156
|
+
p.add_argument("--target", default=None,
|
|
157
|
+
help="Walk the supersession chain starting at this memory id")
|
|
158
|
+
p.add_argument("--by", default=None,
|
|
159
|
+
help="List memories superseded by this specific winner id")
|
|
160
|
+
p.add_argument("--limit", type=int, default=200)
|
|
161
|
+
p.add_argument("--db", default=os.environ.get("LOOP_MEMORY_DB", DEFAULT_DB))
|
|
162
|
+
ns = p.parse_args(args)
|
|
163
|
+
s = MemoryStore(ns.db)
|
|
164
|
+
if ns.target:
|
|
165
|
+
chain = s.trace_supersession(ns.target)
|
|
166
|
+
return _emit({
|
|
167
|
+
"target": ns.target,
|
|
168
|
+
"chain": chain,
|
|
169
|
+
"chain_length": len(chain),
|
|
170
|
+
"winner": chain[-1] if chain else None,
|
|
171
|
+
})
|
|
172
|
+
rows = s.list_superseded(superseded_by=ns.by, limit=ns.limit)
|
|
173
|
+
return _emit({
|
|
174
|
+
"rows": rows,
|
|
175
|
+
"total": s.supersession_count(),
|
|
176
|
+
"filtered_by": ns.by,
|
|
177
|
+
"limit": ns.limit,
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
|
|
119
181
|
def run_export(args: list) -> int:
|
|
120
182
|
p = _build_parser()
|
|
121
183
|
db = os.environ.get("LOOP_MEMORY_DB", DEFAULT_DB)
|
|
@@ -203,3 +265,80 @@ def run_wiki_reclassify_legacy(args: list) -> int:
|
|
|
203
265
|
})
|
|
204
266
|
summary = reclassify_legacy_pages(s, batch=ns.batch)
|
|
205
267
|
return _emit(summary)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def run_memory_stats(args: list) -> int:
|
|
271
|
+
"""``loop-memory memory-stats <id> [--prefix]``
|
|
272
|
+
|
|
273
|
+
Per-memory lifetime stats (audit 2026-09-06, agentmemory v1.3.0).
|
|
274
|
+
Returns a flat dict suitable for ``jq`` and the dashboard.
|
|
275
|
+
|
|
276
|
+
With ``--prefix`` the caller can pass the first 8+ chars of the id
|
|
277
|
+
(handy in a shell pipeline where the full UUID is awkward).
|
|
278
|
+
"""
|
|
279
|
+
import argparse as _ap
|
|
280
|
+
p = _ap.ArgumentParser(prog="loop-memory memory-stats")
|
|
281
|
+
p.add_argument("memory_id")
|
|
282
|
+
p.add_argument("--prefix", action="store_true")
|
|
283
|
+
p.add_argument("--db", default=os.environ.get("LOOP_MEMORY_DB", DEFAULT_DB))
|
|
284
|
+
ns = p.parse_args(args)
|
|
285
|
+
s = MemoryStore(ns.db)
|
|
286
|
+
mid = ns.memory_id.strip()
|
|
287
|
+
if not mid:
|
|
288
|
+
return _emit({"error": "memory_id is required"})
|
|
289
|
+
if ns.prefix:
|
|
290
|
+
# Resolve a prefix to a full id. The store indexes by full
|
|
291
|
+
# id so we walk a list_memories scan (cheap because prefix
|
|
292
|
+
# queries are rare / human-driven).
|
|
293
|
+
candidates = [m.id for m in s.list_memories(limit=10000)
|
|
294
|
+
if m.id.startswith(mid)]
|
|
295
|
+
if not candidates:
|
|
296
|
+
return _emit({"error": f"no memory matches prefix {mid!r}"})
|
|
297
|
+
if len(candidates) > 1:
|
|
298
|
+
return _emit({
|
|
299
|
+
"error": f"prefix {mid!r} matches {len(candidates)} memories",
|
|
300
|
+
"candidates": candidates[:8],
|
|
301
|
+
})
|
|
302
|
+
mid = candidates[0]
|
|
303
|
+
res = s.memory_stats(mid)
|
|
304
|
+
if res is None:
|
|
305
|
+
return _emit({"error": f"memory {mid!r} not found"})
|
|
306
|
+
return _emit(res)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def run_snapshot(args: list) -> int:
|
|
310
|
+
"""``loop-memory snapshot <out_path>``
|
|
311
|
+
|
|
312
|
+
Write a portable SQLite snapshot (audit 2026-09-06,
|
|
313
|
+
codexa-memory v0.2.0). Returns a summary dict with size + table
|
|
314
|
+
counts so the caller can confirm the snapshot is sane.
|
|
315
|
+
"""
|
|
316
|
+
import argparse as _ap
|
|
317
|
+
p = _ap.ArgumentParser(prog="loop-memory snapshot")
|
|
318
|
+
p.add_argument("out_path")
|
|
319
|
+
p.add_argument("--db", default=os.environ.get("LOOP_MEMORY_DB", DEFAULT_DB))
|
|
320
|
+
ns = p.parse_args(args)
|
|
321
|
+
s = MemoryStore(ns.db)
|
|
322
|
+
from ...storage.snapshot import snapshot as _snapshot
|
|
323
|
+
return _emit(_snapshot(s, ns.out_path))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def run_restore(args: list) -> int:
|
|
327
|
+
"""``loop-memory restore <in_path>``
|
|
328
|
+
|
|
329
|
+
Re-hydrate a portable SQLite snapshot (audit 2026-09-06,
|
|
330
|
+
codexa-memory v0.2.0). Returns a summary dict with table counts
|
|
331
|
+
so the caller can confirm the restore is sane. Refuses
|
|
332
|
+
wrong-magic files loudly instead of corrupting the live store.
|
|
333
|
+
"""
|
|
334
|
+
import argparse as _ap
|
|
335
|
+
p = _ap.ArgumentParser(prog="loop-memory restore")
|
|
336
|
+
p.add_argument("in_path")
|
|
337
|
+
p.add_argument("--db", default=os.environ.get("LOOP_MEMORY_DB", DEFAULT_DB))
|
|
338
|
+
ns = p.parse_args(args)
|
|
339
|
+
s = MemoryStore(ns.db)
|
|
340
|
+
from ...storage.snapshot import restore as _restore
|
|
341
|
+
try:
|
|
342
|
+
return _emit(_restore(s, ns.in_path))
|
|
343
|
+
except (FileNotFoundError, ValueError) as e:
|
|
344
|
+
return _emit({"error": str(e)})
|
|
@@ -51,12 +51,45 @@ def run_stats(_args) -> int:
|
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
def run_recall(args) -> int:
|
|
54
|
+
"""Run ``loop-memory recall <query> [--verbose] [--limit N]``.
|
|
55
|
+
|
|
56
|
+
With ``--verbose`` each memory hit is annotated with the
|
|
57
|
+
``why: [...]`` provenance labels produced by ``MemoryStore.recall``
|
|
58
|
+
(audit 2026-08-30, agentmemory v1.2.0 pattern). The wiki and
|
|
59
|
+
entity sections are unchanged — only the raw-memory block grows
|
|
60
|
+
the new line.
|
|
61
|
+
"""
|
|
54
62
|
from ...storage.sqlite_store import MemoryStore
|
|
55
63
|
if not args:
|
|
56
|
-
return die("usage: loop-memory recall <query>")
|
|
64
|
+
return die("usage: loop-memory recall <query> [--verbose] [--limit N]")
|
|
65
|
+
# Parse the small flag subset we expose. Keep it dumb-on-purpose:
|
|
66
|
+
# we don't want argparse to swallow a token that happens to start
|
|
67
|
+
# with "--" inside the user's query (e.g. ``recall --foo bar``).
|
|
68
|
+
verbose = False
|
|
69
|
+
limit = 10
|
|
70
|
+
qargs: list[str] = []
|
|
71
|
+
i = 0
|
|
72
|
+
while i < len(args):
|
|
73
|
+
a = args[i]
|
|
74
|
+
if a == "--verbose":
|
|
75
|
+
verbose = True
|
|
76
|
+
i += 1
|
|
77
|
+
elif a == "--limit" and i + 1 < len(args):
|
|
78
|
+
try:
|
|
79
|
+
limit = int(args[i + 1])
|
|
80
|
+
except ValueError:
|
|
81
|
+
return die(f"--limit must be an integer, got {args[i + 1]!r}")
|
|
82
|
+
i += 2
|
|
83
|
+
elif a.startswith("--"):
|
|
84
|
+
return die(f"unknown flag: {a}")
|
|
85
|
+
else:
|
|
86
|
+
qargs.append(a)
|
|
87
|
+
i += 1
|
|
88
|
+
if not qargs:
|
|
89
|
+
return die("usage: loop-memory recall <query> [--verbose] [--limit N]")
|
|
57
90
|
store = MemoryStore(default_db_path())
|
|
58
|
-
query = " ".join(
|
|
59
|
-
r = store.recall(query, limit=
|
|
91
|
+
query = " ".join(qargs)
|
|
92
|
+
r = store.recall(query, limit=limit)
|
|
60
93
|
has = False
|
|
61
94
|
if r["wiki"]:
|
|
62
95
|
has = True
|
|
@@ -73,6 +106,11 @@ def run_recall(args) -> int:
|
|
|
73
106
|
for m in r["memories"]:
|
|
74
107
|
tag_s = " [" + ", ".join(m.get("tags") or []) + "]" if m.get("tags") else ""
|
|
75
108
|
print(f"- [{m['kind']}] (imp={m['importance']:.2f}){tag_s}")
|
|
109
|
+
if verbose and m.get("why"):
|
|
110
|
+
# Stable order (built by MemoryStore.recall) so callers can
|
|
111
|
+
# render the list as a deterministic badge strip without
|
|
112
|
+
# re-sorting.
|
|
113
|
+
print(f" why: {', '.join(m['why'])}")
|
|
76
114
|
print(f" {m['text'][:240]}")
|
|
77
115
|
print()
|
|
78
116
|
if r["entities"]:
|
|
@@ -22,6 +22,7 @@ Usage:
|
|
|
22
22
|
loop-memory ask "what about…" # print a paste-ready context block for any LLM client
|
|
23
23
|
loop-memory cognitive-sleep [--apply] # dry-run / apply cognitive sweep (v7)
|
|
24
24
|
loop-memory audit [--kind X] [--action Y] # read the cognitive audit trail
|
|
25
|
+
loop-memory audit-supersede [--target ID] # walk / list the memory supersession chain
|
|
25
26
|
loop-memory export <out_dir> # write a MEMORY.md bundle (v7)
|
|
26
27
|
loop-memory export-bundle <out_dir> # explicit v7 bundle alias
|
|
27
28
|
loop-memory import <in_dir> # re-hydrate a bundle
|
|
@@ -109,6 +110,7 @@ COMMANDS = {
|
|
|
109
110
|
# Universal Agent Memory v7 — graph, cognitive, export, fork
|
|
110
111
|
"cognitive-sleep": cognitive_cmd.run_cognitive_sleep,
|
|
111
112
|
"audit": cognitive_cmd.run_audit,
|
|
113
|
+
"audit-supersede": cognitive_cmd.run_audit_supersede,
|
|
112
114
|
"export-bundle": cognitive_cmd.run_export,
|
|
113
115
|
"import": cognitive_cmd.run_import,
|
|
114
116
|
"fork": cognitive_cmd.run_fork,
|
|
@@ -116,6 +118,10 @@ COMMANDS = {
|
|
|
116
118
|
"subgraph": cognitive_cmd.run_subgraph,
|
|
117
119
|
"graph-rebuild": cognitive_cmd.run_graph_rebuild,
|
|
118
120
|
"wiki-reclassify-legacy": cognitive_cmd.run_wiki_reclassify_legacy,
|
|
121
|
+
# Audit 2026-09-06 — per-memory stats + portable snapshot.
|
|
122
|
+
"memory-stats": cognitive_cmd.run_memory_stats,
|
|
123
|
+
"snapshot": cognitive_cmd.run_snapshot,
|
|
124
|
+
"restore": cognitive_cmd.run_restore,
|
|
119
125
|
"rules": rules_cmd.run_rules,
|
|
120
126
|
"version": _run_version,
|
|
121
127
|
}
|
|
@@ -128,6 +134,7 @@ COMMANDS = {
|
|
|
128
134
|
COMMAND_HELP: dict[str, str] = {
|
|
129
135
|
"ask": "loop-memory ask '<question>' # print a paste-ready context block for any LLM client.",
|
|
130
136
|
"audit": "loop-memory audit [--kind X] [--action Y] # read the cognitive audit trail.",
|
|
137
|
+
"audit-supersede": "loop-memory audit-supersede [--target ID] # walk / list supersession chain (Mem0 Dream pattern).",
|
|
131
138
|
"chat": "loop-memory chat # REPL with echo LLM.",
|
|
132
139
|
"cognitive-sleep": "loop-memory cognitive-sleep [--apply] # dry-run / apply cognitive sweep.",
|
|
133
140
|
"consolidate": "loop-memory consolidate # rescore + GC + dedupe.",
|
|
@@ -156,6 +163,9 @@ COMMAND_HELP: dict[str, str] = {
|
|
|
156
163
|
"subgraph": "loop-memory subgraph <query> # print a small subgraph.",
|
|
157
164
|
"version": "loop-memory version # print the installed distribution version.",
|
|
158
165
|
"wiki-reclassify-legacy": "loop-memory wiki-reclassify-legacy # back-fill scope + scope_filter for H4+H5 pages.",
|
|
166
|
+
"memory-stats": "loop-memory memory-stats <id> [--prefix] # per-memory lifetime stats (audit 2026-09-06, agentmemory v1.3.0).",
|
|
167
|
+
"snapshot": "loop-memory snapshot <out.memory.sqlite> # write a portable SQLite snapshot (audit 2026-09-06, codexa-memory v0.2.0).",
|
|
168
|
+
"restore": "loop-memory restore <in.memory.sqlite> # re-hydrate a portable SQLite snapshot.",
|
|
159
169
|
"rules": "loop-memory rules [--agent codex|claude|hermes|openclaw] [--write] [--force]\n Print, or append into the agent's rule file, the three-phase memory discipline (task start / mid-task / wrap-up). Never overwrites user content.",
|
|
160
170
|
}
|
|
161
171
|
|
|
@@ -62,4 +62,40 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None)
|
|
|
62
62
|
raise HTTPException(500, f"import failed: {e}")
|
|
63
63
|
return r.to_dict()
|
|
64
64
|
|
|
65
|
+
@app.post("/api/snapshot")
|
|
66
|
+
def snapshot_create(body: dict):
|
|
67
|
+
"""Write a portable SQLite snapshot of the live store.
|
|
65
68
|
|
|
69
|
+
Body: ``{"out_path": "/path/to/file.memory.sqlite"}``. Returns
|
|
70
|
+
the summary dict from ``loop_memory.storage.snapshot.snapshot``.
|
|
71
|
+
"""
|
|
72
|
+
from ...storage.snapshot import snapshot as _snapshot
|
|
73
|
+
out = (body.get("out_path") or "").strip()
|
|
74
|
+
if not out:
|
|
75
|
+
raise HTTPException(400, "out_path is required")
|
|
76
|
+
try:
|
|
77
|
+
return _snapshot(store, out)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
raise HTTPException(500, f"snapshot failed: {e}")
|
|
80
|
+
|
|
81
|
+
@app.post("/api/snapshot/restore")
|
|
82
|
+
def snapshot_restore(body: dict):
|
|
83
|
+
"""Re-hydrate a portable SQLite snapshot into the live store.
|
|
84
|
+
|
|
85
|
+
Body: ``{"in_path": "/path/to/file.memory.sqlite"}``. Returns
|
|
86
|
+
the summary dict from ``loop_memory.storage.snapshot.restore``.
|
|
87
|
+
Refuses wrong-magic files with a 400 so the dashboard surfaces
|
|
88
|
+
the error clearly instead of corrupting the live store.
|
|
89
|
+
"""
|
|
90
|
+
from ...storage.snapshot import restore as _restore
|
|
91
|
+
in_path = (body.get("in_path") or "").strip()
|
|
92
|
+
if not in_path:
|
|
93
|
+
raise HTTPException(400, "in_path is required")
|
|
94
|
+
try:
|
|
95
|
+
return _restore(store, in_path)
|
|
96
|
+
except FileNotFoundError as e:
|
|
97
|
+
raise HTTPException(404, str(e))
|
|
98
|
+
except ValueError as e:
|
|
99
|
+
raise HTTPException(400, str(e))
|
|
100
|
+
except Exception as e:
|
|
101
|
+
raise HTTPException(500, f"restore failed: {e}")
|
|
@@ -414,6 +414,22 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None)
|
|
|
414
414
|
return {"deleted": n}
|
|
415
415
|
|
|
416
416
|
|
|
417
|
+
@app.get("/api/memories/{mid}/stats")
|
|
418
|
+
def memory_stats_route(mid: str):
|
|
419
|
+
"""Per-memory lifetime stats (audit 2026-09-06).
|
|
420
|
+
|
|
421
|
+
Returns the flat dict produced by ``MemoryStore.memory_stats``
|
|
422
|
+
— see that method for the field list. 404 if the memory id
|
|
423
|
+
is unknown. The dashboard uses this to surface
|
|
424
|
+
recall / positive / negative counters alongside each memory
|
|
425
|
+
row without a second round-trip.
|
|
426
|
+
"""
|
|
427
|
+
res = store.memory_stats(mid)
|
|
428
|
+
if res is None:
|
|
429
|
+
from fastapi import HTTPException
|
|
430
|
+
raise HTTPException(404, "memory not found")
|
|
431
|
+
return res
|
|
432
|
+
|
|
417
433
|
@app.get("/api/memories/{mid}", name="memory_drill_down")
|
|
418
434
|
def memory_drill_down(mid: str):
|
|
419
435
|
"""L2 drill-down for a single memory row (full text)."""
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Portable single-file SQLite snapshot of a :class:`MemoryStore`.
|
|
2
|
+
|
|
3
|
+
Audit 2026-09-06 (codexa-memory v0.2.0). The markdown ``export-bundle``
|
|
4
|
+
is Git-friendly but lossy: it drops the recall-quality signals,
|
|
5
|
+
entity-mention weights, and the supersession audit trail. This module
|
|
6
|
+
adds a lossless portable snapshot so a user can hand their full store
|
|
7
|
+
to another machine without losing recall fidelity.
|
|
8
|
+
|
|
9
|
+
The snapshot is a single ``.memory.sqlite`` file that holds the same
|
|
10
|
+
schema + rows as the live store. The header row in ``schema_meta``
|
|
11
|
+
records the schema version + a magic value so a future restore can
|
|
12
|
+
refuse a downgrade or a file from a different project.
|
|
13
|
+
|
|
14
|
+
Two operations:
|
|
15
|
+
|
|
16
|
+
* :func:`snapshot` — write a single-file SQLite copy of ``store`` to
|
|
17
|
+
``out_path``. Uses ``sqlite3.Connection.backup()`` so the live store
|
|
18
|
+
is not blocked by a long copy.
|
|
19
|
+
* :func:`restore` — re-hydrate a snapshot file into ``store``. Each
|
|
20
|
+
table is copied in dependency order with ``INSERT OR REPLACE`` so
|
|
21
|
+
the operation is idempotent on the primary key. Triggers and the
|
|
22
|
+
FTS mirrors are NOT copied (they are re-created by ``_init_schema``
|
|
23
|
+
the next time the live store opens); the live store rebuilds them
|
|
24
|
+
on demand.
|
|
25
|
+
|
|
26
|
+
Both operations return a small dict (rows-per-table + timing) so the
|
|
27
|
+
CLI / HTTP route can surface a one-line summary to the user.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import shutil
|
|
33
|
+
import sqlite3
|
|
34
|
+
import tempfile
|
|
35
|
+
import time
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any, Dict, List
|
|
38
|
+
|
|
39
|
+
from ..storage.sqlite_store import MemoryStore
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
SNAPSHOT_MAGIC = "loop_memory_snapshot_v1"
|
|
43
|
+
# Order matters for restore: child tables after their parents so
|
|
44
|
+
# INSERT OR REPLACE never trips a FK constraint. Mirrors and triggers
|
|
45
|
+
# are NOT copied (the live store rebuilds them on demand).
|
|
46
|
+
_RESTORE_ORDER: list[str] = [
|
|
47
|
+
"sessions",
|
|
48
|
+
"memories",
|
|
49
|
+
"memory_signals",
|
|
50
|
+
"entities",
|
|
51
|
+
"entity_mentions",
|
|
52
|
+
"relations",
|
|
53
|
+
"wiki_pages",
|
|
54
|
+
"wiki_versions",
|
|
55
|
+
"cognitive_audit",
|
|
56
|
+
"contradiction_ignored",
|
|
57
|
+
"consolidation_runs",
|
|
58
|
+
"auth_tokens",
|
|
59
|
+
"settings",
|
|
60
|
+
"schema_meta",
|
|
61
|
+
"llm_audit",
|
|
62
|
+
"pipeline_runs",
|
|
63
|
+
"write_guard_drops",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _list_user_tables(conn: sqlite3.Connection) -> list[str]:
|
|
68
|
+
"""Names of every user table the snapshot should carry.
|
|
69
|
+
|
|
70
|
+
Excludes SQLite-internal tables (``sqlite_*``) and the FTS5
|
|
71
|
+
shadow tables (``memories_fts_data`` / ``_idx`` / ``_content``
|
|
72
|
+
/ ``_config`` / ``_docsize``); the live store rebuilds the FTS
|
|
73
|
+
mirrors from the source rows via triggers when it next opens.
|
|
74
|
+
"""
|
|
75
|
+
rows = conn.execute(
|
|
76
|
+
"SELECT name FROM sqlite_master "
|
|
77
|
+
"WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
78
|
+
).fetchall()
|
|
79
|
+
out: list[str] = []
|
|
80
|
+
skip_suffixes = ("_fts_data", "_fts_idx", "_fts_content",
|
|
81
|
+
"_fts_config", "_fts_docsize")
|
|
82
|
+
for r in rows:
|
|
83
|
+
name = r["name"]
|
|
84
|
+
if any(name.endswith(suf) for suf in skip_suffixes):
|
|
85
|
+
continue
|
|
86
|
+
out.append(name)
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def snapshot(store: MemoryStore, out_path: str | Path) -> dict[str, Any]:
|
|
91
|
+
"""Write a portable SQLite snapshot of ``store`` to ``out_path``.
|
|
92
|
+
|
|
93
|
+
The file at ``out_path`` is overwritten atomically: the snapshot
|
|
94
|
+
is built in a sibling temp directory (so the WAL / -shm sidecars
|
|
95
|
+
stay scoped) and then moved into place with ``shutil.move``.
|
|
96
|
+
|
|
97
|
+
Returns a summary dict suitable for the CLI / HTTP route.
|
|
98
|
+
"""
|
|
99
|
+
out = Path(out_path).expanduser().resolve()
|
|
100
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
started = time.time()
|
|
102
|
+
live_path = Path(store.path).expanduser().resolve()
|
|
103
|
+
with tempfile.TemporaryDirectory(prefix="loop-memory-snapshot-") as td:
|
|
104
|
+
tmp_dir = Path(td)
|
|
105
|
+
tmp = tmp_dir / out.name
|
|
106
|
+
if tmp.exists():
|
|
107
|
+
tmp.unlink()
|
|
108
|
+
with sqlite3.connect(live_path) as src, sqlite3.connect(tmp) as dst:
|
|
109
|
+
src.row_factory = sqlite3.Row
|
|
110
|
+
# Force the snapshot DB into rollback journal mode so
|
|
111
|
+
# the destination ends up as a single self-contained
|
|
112
|
+
# .db file (no WAL sidecar that could later confuse a
|
|
113
|
+
# reader on macOS).
|
|
114
|
+
dst.execute("PRAGMA journal_mode=DELETE")
|
|
115
|
+
src.backup(dst)
|
|
116
|
+
# Stamp the snapshot header so a future restore can
|
|
117
|
+
# refuse an obvious mismatch (wrong project, downgrade).
|
|
118
|
+
dst.execute(
|
|
119
|
+
"INSERT OR REPLACE INTO schema_meta(k, v) "
|
|
120
|
+
"VALUES ('snapshot_magic', ?), "
|
|
121
|
+
" ('snapshot_source_schema', ?), "
|
|
122
|
+
" ('snapshot_taken_at', ?)",
|
|
123
|
+
(SNAPSHOT_MAGIC, MemoryStore.SCHEMA_VERSION,
|
|
124
|
+
time.time()),
|
|
125
|
+
)
|
|
126
|
+
dst.commit()
|
|
127
|
+
dst.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
128
|
+
table_counts: dict[str, int] = {}
|
|
129
|
+
for name in _list_user_tables(src):
|
|
130
|
+
table_counts[name] = src.execute(
|
|
131
|
+
f"SELECT COUNT(*) AS c FROM {name}"
|
|
132
|
+
).fetchone()["c"]
|
|
133
|
+
if out.exists():
|
|
134
|
+
out.unlink()
|
|
135
|
+
shutil.move(str(tmp), str(out))
|
|
136
|
+
elapsed = time.time() - started
|
|
137
|
+
return {
|
|
138
|
+
"out_path": str(out),
|
|
139
|
+
"size_bytes": out.stat().st_size,
|
|
140
|
+
"elapsed_seconds": round(elapsed, 4),
|
|
141
|
+
"schema_version": MemoryStore.SCHEMA_VERSION,
|
|
142
|
+
"tables": table_counts,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def restore(store: MemoryStore, snapshot_path: str | Path) -> dict[str, Any]:
|
|
147
|
+
"""Re-hydrate ``snapshot_path`` into ``store``.
|
|
148
|
+
|
|
149
|
+
The destination store is upserted (INSERT OR REPLACE on PK) so a
|
|
150
|
+
restore on top of an existing store is a no-op for rows that
|
|
151
|
+
already match — useful when a user wants to "merge" snapshots.
|
|
152
|
+
|
|
153
|
+
A wrong-magic snapshot raises :class:`ValueError` loudly so the
|
|
154
|
+
CLI surfaces a useful error instead of silently corrupting the
|
|
155
|
+
destination store. Schema-downgrade detection is best-effort:
|
|
156
|
+
the snapshot's ``snapshot_source_schema`` is logged in the
|
|
157
|
+
summary dict for the dashboard to surface.
|
|
158
|
+
|
|
159
|
+
Returns a summary dict suitable for the CLI / HTTP route.
|
|
160
|
+
"""
|
|
161
|
+
src_path = Path(snapshot_path).expanduser().resolve()
|
|
162
|
+
if not src_path.exists():
|
|
163
|
+
raise FileNotFoundError(f"snapshot not found: {src_path}")
|
|
164
|
+
started = time.time()
|
|
165
|
+
# Open our own connections (NOT the store's shared connection —
|
|
166
|
+
# same reason as snapshot() above).
|
|
167
|
+
live_path = Path(store.path).expanduser().resolve()
|
|
168
|
+
with sqlite3.connect(live_path) as dst, sqlite3.connect(src_path) as src:
|
|
169
|
+
src.row_factory = sqlite3.Row
|
|
170
|
+
try:
|
|
171
|
+
magic_row = src.execute(
|
|
172
|
+
"SELECT v FROM schema_meta WHERE k='snapshot_magic'"
|
|
173
|
+
).fetchone()
|
|
174
|
+
except sqlite3.OperationalError:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f"{src_path} is not a loop-memory snapshot "
|
|
177
|
+
f"(no schema_meta table)"
|
|
178
|
+
) from None
|
|
179
|
+
if magic_row is None or magic_row["v"] != SNAPSHOT_MAGIC:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"{src_path} is not a loop-memory snapshot "
|
|
182
|
+
f"(missing or wrong snapshot_magic)"
|
|
183
|
+
)
|
|
184
|
+
version_row = src.execute(
|
|
185
|
+
"SELECT v FROM schema_meta WHERE k='snapshot_source_schema'"
|
|
186
|
+
).fetchone()
|
|
187
|
+
source_version = version_row["v"] if version_row else "unknown"
|
|
188
|
+
counts: dict[str, int] = {}
|
|
189
|
+
snapshot_tables = _list_user_tables(src)
|
|
190
|
+
# Drop the schema_meta snapshot header on the source so it
|
|
191
|
+
# doesn't clobber the live store's own schema_meta. Also
|
|
192
|
+
# skip transient / per-install tables that don't belong in
|
|
193
|
+
# a portable bundle (auth tokens, write-guard drops, etc).
|
|
194
|
+
sanitised_tables = [
|
|
195
|
+
t for t in snapshot_tables
|
|
196
|
+
if t not in {"schema_meta", "llm_audit", "auth_tokens",
|
|
197
|
+
"write_guard_drops"}
|
|
198
|
+
]
|
|
199
|
+
ordered = [t for t in _RESTORE_ORDER if t in sanitised_tables]
|
|
200
|
+
extras = [t for t in sanitised_tables if t not in ordered]
|
|
201
|
+
ordered.extend(extras)
|
|
202
|
+
for name in ordered:
|
|
203
|
+
# Fetch column names via a 0-row probe (cursor.description
|
|
204
|
+
# is only populated by execute(), not by the connection).
|
|
205
|
+
probe = src.execute(f"SELECT * FROM {name} WHERE 0")
|
|
206
|
+
cols = [d[0] for d in (probe.description or [])]
|
|
207
|
+
probe.close()
|
|
208
|
+
rows = src.execute(f"SELECT * FROM {name}").fetchall()
|
|
209
|
+
if not rows:
|
|
210
|
+
counts[name] = 0
|
|
211
|
+
continue
|
|
212
|
+
placeholders = ",".join("?" for _ in cols)
|
|
213
|
+
col_list = ",".join(f'"{c}"' for c in cols)
|
|
214
|
+
dst.executemany(
|
|
215
|
+
f'INSERT OR REPLACE INTO {name} ({col_list}) '
|
|
216
|
+
f"VALUES ({placeholders})",
|
|
217
|
+
[tuple(r[c] for c in cols) for r in rows],
|
|
218
|
+
)
|
|
219
|
+
counts[name] = len(rows)
|
|
220
|
+
dst.commit()
|
|
221
|
+
elapsed = time.time() - started
|
|
222
|
+
return {
|
|
223
|
+
"snapshot_path": str(src_path),
|
|
224
|
+
"elapsed_seconds": round(elapsed, 4),
|
|
225
|
+
"source_schema_version": source_version,
|
|
226
|
+
"live_schema_version": MemoryStore.SCHEMA_VERSION,
|
|
227
|
+
"tables": counts,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
__all__ = ["snapshot", "restore", "SNAPSHOT_MAGIC"]
|