loop-memory 0.4.6__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.
Files changed (139) hide show
  1. {loop_memory-0.4.6 → loop_memory-0.4.7}/PKG-INFO +14 -10
  2. {loop_memory-0.4.6 → loop_memory-0.4.7}/README.md +13 -9
  3. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/cli/commands/cognitive.py +100 -0
  4. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/cli/main.py +7 -0
  5. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/serve/routes/export.py +36 -0
  6. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/serve/routes/memories.py +16 -0
  7. loop_memory-0.4.7/loop_memory/storage/snapshot.py +231 -0
  8. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/storage/sqlite_store.py +63 -0
  9. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory.egg-info/PKG-INFO +14 -10
  10. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory.egg-info/SOURCES.txt +5 -0
  11. {loop_memory-0.4.6 → loop_memory-0.4.7}/pyproject.toml +1 -1
  12. loop_memory-0.4.7/tests/test_cli_memory_stats.py +82 -0
  13. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_cli_recall_verbose.py +11 -8
  14. loop_memory-0.4.7/tests/test_cli_snapshot.py +112 -0
  15. loop_memory-0.4.7/tests/test_memory_stats.py +155 -0
  16. loop_memory-0.4.7/tests/test_snapshot.py +215 -0
  17. {loop_memory-0.4.6 → loop_memory-0.4.7}/LICENSE +0 -0
  18. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/__init__.py +0 -0
  19. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/backends/__init__.py +0 -0
  20. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/backends/embedding.py +0 -0
  21. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/backends/sentence_embedder.py +0 -0
  22. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/backends/vector_store.py +0 -0
  23. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/cli/__init__.py +0 -0
  24. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/cli/_common.py +0 -0
  25. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/cli/commands/__init__.py +0 -0
  26. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/cli/commands/diag.py +0 -0
  27. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/cli/commands/graph.py +0 -0
  28. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/cli/commands/hooks.py +0 -0
  29. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/cli/commands/read.py +0 -0
  30. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/cli/commands/rules.py +0 -0
  31. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/cli/commands/serve.py +0 -0
  32. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/cli/commands/write.py +0 -0
  33. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/engine/__init__.py +0 -0
  34. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/engine/loop.py +0 -0
  35. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/engine/reflect.py +0 -0
  36. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/examples/__init__.py +0 -0
  37. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/examples/demo.py +0 -0
  38. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/export/__init__.py +0 -0
  39. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/export/memory_md.py +0 -0
  40. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/graph/__init__.py +0 -0
  41. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/graph/build.py +0 -0
  42. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/graph/extract.py +0 -0
  43. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/ingest/__init__.py +0 -0
  44. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/ingest/loader.py +0 -0
  45. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/ingest/pipeline.py +0 -0
  46. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/jobs/__init__.py +0 -0
  47. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/jobs/cognitive.py +0 -0
  48. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/jobs/compact.py +0 -0
  49. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/jobs/consolidate.py +0 -0
  50. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/jobs/contradiction.py +0 -0
  51. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/jobs/evolution.py +0 -0
  52. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/jobs/graph.py +0 -0
  53. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/jobs/llm_compact_pass.py +0 -0
  54. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/jobs/llm_consolidate.py +0 -0
  55. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/jobs/scheduler.py +0 -0
  56. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/llm/__init__.py +0 -0
  57. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/llm/base.py +0 -0
  58. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/llm/openai_adapter.py +0 -0
  59. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/llm/providers.py +0 -0
  60. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/mcp/__init__.py +0 -0
  61. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/memory/__init__.py +0 -0
  62. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/memory/types.py +0 -0
  63. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/privacy/__init__.py +0 -0
  64. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/privacy/private.py +0 -0
  65. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/privacy/redact.py +0 -0
  66. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/py.typed +0 -0
  67. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/sdk.py +0 -0
  68. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/sdk_extensions.py +0 -0
  69. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/security/__init__.py +0 -0
  70. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/security/secrets.py +0 -0
  71. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/serve/__init__.py +0 -0
  72. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/serve/app.py +0 -0
  73. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/serve/handlers.py +0 -0
  74. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/serve/routes/_shared.py +0 -0
  75. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/serve/routes/admin.py +0 -0
  76. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/serve/routes/cognitive.py +0 -0
  77. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/serve/routes/graph.py +0 -0
  78. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/serve/routes/insights.py +0 -0
  79. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/serve/routes/sessions.py +0 -0
  80. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/serve/routes/system.py +0 -0
  81. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/serve/routes/wiki.py +0 -0
  82. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/serve/static/__init__.py +0 -0
  83. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/serve/static/index.html +0 -0
  84. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/serve/watcher.py +0 -0
  85. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/storage/__init__.py +0 -0
  86. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/storage/retrieval.py +0 -0
  87. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/wiki/__init__.py +0 -0
  88. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/wiki/backfill.py +0 -0
  89. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/wiki/classifier.py +0 -0
  90. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/wiki/prompts.py +0 -0
  91. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory/wiki/scope.py +0 -0
  92. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory.egg-info/dependency_links.txt +0 -0
  93. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory.egg-info/entry_points.txt +0 -0
  94. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory.egg-info/requires.txt +0 -0
  95. {loop_memory-0.4.6 → loop_memory-0.4.7}/loop_memory.egg-info/top_level.txt +0 -0
  96. {loop_memory-0.4.6 → loop_memory-0.4.7}/setup.cfg +0 -0
  97. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_admin_ingest_route.py +0 -0
  98. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_agent_memory_api.py +0 -0
  99. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_agent_memory_sdk.py +0 -0
  100. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_auth_token_rotate.py +0 -0
  101. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_cli_audit_supersede.py +0 -0
  102. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_cli_rules.py +0 -0
  103. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_cli_v7.py +0 -0
  104. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_cli_version.py +0 -0
  105. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_contradictions.py +0 -0
  106. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_evolution.py +0 -0
  107. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_evolution_quality.py +0 -0
  108. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_expanduser.py +0 -0
  109. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_export_ask.py +0 -0
  110. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_graph.py +0 -0
  111. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_ingest.py +0 -0
  112. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_llm_consolidator.py +0 -0
  113. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_llm_fingerprint_not_persisted.py +0 -0
  114. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_llm_providers.py +0 -0
  115. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_llm_test_endpoint.py +0 -0
  116. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_loop.py +0 -0
  117. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_mcp.py +0 -0
  118. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_memories_pagination.py +0 -0
  119. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_openclaw_loader.py +0 -0
  120. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_package_version.py +0 -0
  121. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_recall_provenance.py +0 -0
  122. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_reflection.py +0 -0
  123. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_repo_framing.py +0 -0
  124. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_score_api.py +0 -0
  125. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_scoring_v2.py +0 -0
  126. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_secrets.py +0 -0
  127. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_serve_app.py +0 -0
  128. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_serve_handlers.py +0 -0
  129. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_session_order.py +0 -0
  130. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_short_query.py +0 -0
  131. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_store.py +0 -0
  132. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_summarization.py +0 -0
  133. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_supersession_chain.py +0 -0
  134. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_universal_memory.py +0 -0
  135. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_vector_store.py +0 -0
  136. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_watcher.py +0 -0
  137. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_wiki_classifier.py +0 -0
  138. {loop_memory-0.4.6 → loop_memory-0.4.7}/tests/test_wiki_export_escape.py +0 -0
  139. {loop_memory-0.4.6 → 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.6
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,15 +66,19 @@ Dynamic: license-file
66
66
 
67
67
  ---
68
68
 
69
- > **What's new in 0.4.6** — **memory supersession chain** + **recall
70
- > provenance labels**. `merge_memories()` no longer silently
71
- > `DELETE`s the loser it writes an explicit `superseded_by`
72
- > pointer (audit 2026-08-30, Mem0 v2.0.19 Dream pattern), the new
73
- > `loop-memory audit-supersede` walks the chain, and every
74
- > `recall()` hit now carries a `why: [...]` provenance list
75
- > (adopted from agentmemory v1.2.0) that you can surface with
76
- > `loop-memory recall --verbose`. 26 new regression cases pin
77
- > both shapes.
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.
78
82
  > [Full changelog →](CHANGELOG.md)
79
83
 
80
84
  ---
@@ -22,15 +22,19 @@
22
22
 
23
23
  ---
24
24
 
25
- > **What's new in 0.4.6** — **memory supersession chain** + **recall
26
- > provenance labels**. `merge_memories()` no longer silently
27
- > `DELETE`s the loser it writes an explicit `superseded_by`
28
- > pointer (audit 2026-08-30, Mem0 v2.0.19 Dream pattern), the new
29
- > `loop-memory audit-supersede` walks the chain, and every
30
- > `recall()` hit now carries a `why: [...]` provenance list
31
- > (adopted from agentmemory v1.2.0) that you can surface with
32
- > `loop-memory recall --verbose`. 26 new regression cases pin
33
- > both shapes.
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.
34
38
  > [Full changelog →](CHANGELOG.md)
35
39
 
36
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
 
@@ -242,3 +265,80 @@ def run_wiki_reclassify_legacy(args: list) -> int:
242
265
  })
243
266
  summary = reclassify_legacy_pages(s, batch=ns.batch)
244
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)})
@@ -118,6 +118,10 @@ COMMANDS = {
118
118
  "subgraph": cognitive_cmd.run_subgraph,
119
119
  "graph-rebuild": cognitive_cmd.run_graph_rebuild,
120
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,
121
125
  "rules": rules_cmd.run_rules,
122
126
  "version": _run_version,
123
127
  }
@@ -159,6 +163,9 @@ COMMAND_HELP: dict[str, str] = {
159
163
  "subgraph": "loop-memory subgraph <query> # print a small subgraph.",
160
164
  "version": "loop-memory version # print the installed distribution version.",
161
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.",
162
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.",
163
170
  }
164
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"]
@@ -896,6 +896,69 @@ class MemoryStore:
896
896
  ).fetchall()
897
897
  return [dict(r) for r in rows]
898
898
 
899
+ # ----- Per-memory lifetime stats (v10) ---------------------------
900
+
901
+ def memory_stats(self, mid: str) -> Dict[str, Any]:
902
+ """Per-memory lifetime stats (audit 2026-09-06, agentmemory v1.3.0).
903
+
904
+ Joins the memories row with its memory_signals row and
905
+ returns a flat dict suitable for the CLI / HTTP route / MCP
906
+ surface. The shape is intentionally small — one round-trip,
907
+ no nested objects — so it round-trips cleanly through JSON.
908
+
909
+ text is truncated to 240 chars so a 10-KB memory does
910
+ not blow up the response; callers who want the full text
911
+ use get_memory(mid).text or the drill-down endpoint.
912
+
913
+ Returns None if the memory id is unknown (the dashboard
914
+ surfaces this as a 404). The signal counters default to 0
915
+ if the memory has no memory_signals row yet.
916
+ """
917
+ mid = str(mid or "")
918
+ if not mid:
919
+ return None
920
+ with self._conn() as c:
921
+ row = c.execute(
922
+ """SELECT m.id, m.kind, m.text, m.importance, m.score,
923
+ m.source, m.tags, m.created_at, m.updated_at,
924
+ m.superseded_by, m.session_id,
925
+ COALESCE(s.recall_count, 0) AS recall_count,
926
+ COALESCE(s.positive, 0) AS positive,
927
+ COALESCE(s.negative, 0) AS negative,
928
+ s.last_recalled_at,
929
+ s.last_feedback_at
930
+ FROM memories m
931
+ LEFT JOIN memory_signals s ON s.memory_id = m.id
932
+ WHERE m.id = ?""",
933
+ (mid,),
934
+ ).fetchone()
935
+ if row is None:
936
+ return None
937
+ text = row["text"] or ""
938
+ if len(text) > 240:
939
+ text = text[:237] + "..."
940
+ now = time.time()
941
+ age = max(0.0, now - float(row["created_at"]))
942
+ return {
943
+ "id": row["id"],
944
+ "kind": row["kind"],
945
+ "text": text,
946
+ "importance": row["importance"],
947
+ "score": row["score"],
948
+ "source": row["source"],
949
+ "tags": row["tags"],
950
+ "session_id": row["session_id"],
951
+ "created_at": row["created_at"],
952
+ "updated_at": row["updated_at"],
953
+ "superseded_by": row["superseded_by"],
954
+ "recall_count": row["recall_count"],
955
+ "positive": row["positive"],
956
+ "negative": row["negative"],
957
+ "last_recalled_at": row["last_recalled_at"],
958
+ "last_feedback_at": row["last_feedback_at"],
959
+ "age_seconds": age,
960
+ }
961
+
899
962
  # ----- Pipeline stage recording (v5) ------------------------------
900
963
 
901
964
  def start_pipeline_run(self, stage: str) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loop-memory
3
- Version: 0.4.6
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,15 +66,19 @@ Dynamic: license-file
66
66
 
67
67
  ---
68
68
 
69
- > **What's new in 0.4.6** — **memory supersession chain** + **recall
70
- > provenance labels**. `merge_memories()` no longer silently
71
- > `DELETE`s the loser it writes an explicit `superseded_by`
72
- > pointer (audit 2026-08-30, Mem0 v2.0.19 Dream pattern), the new
73
- > `loop-memory audit-supersede` walks the chain, and every
74
- > `recall()` hit now carries a `why: [...]` provenance list
75
- > (adopted from agentmemory v1.2.0) that you can surface with
76
- > `loop-memory recall --verbose`. 26 new regression cases pin
77
- > both shapes.
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.
78
82
  > [Full changelog →](CHANGELOG.md)
79
83
 
80
84
  ---
@@ -80,6 +80,7 @@ loop_memory/serve/static/__init__.py
80
80
  loop_memory/serve/static/index.html
81
81
  loop_memory/storage/__init__.py
82
82
  loop_memory/storage/retrieval.py
83
+ loop_memory/storage/snapshot.py
83
84
  loop_memory/storage/sqlite_store.py
84
85
  loop_memory/wiki/__init__.py
85
86
  loop_memory/wiki/backfill.py
@@ -91,8 +92,10 @@ tests/test_agent_memory_api.py
91
92
  tests/test_agent_memory_sdk.py
92
93
  tests/test_auth_token_rotate.py
93
94
  tests/test_cli_audit_supersede.py
95
+ tests/test_cli_memory_stats.py
94
96
  tests/test_cli_recall_verbose.py
95
97
  tests/test_cli_rules.py
98
+ tests/test_cli_snapshot.py
96
99
  tests/test_cli_v7.py
97
100
  tests/test_cli_version.py
98
101
  tests/test_contradictions.py
@@ -109,6 +112,7 @@ tests/test_llm_test_endpoint.py
109
112
  tests/test_loop.py
110
113
  tests/test_mcp.py
111
114
  tests/test_memories_pagination.py
115
+ tests/test_memory_stats.py
112
116
  tests/test_openclaw_loader.py
113
117
  tests/test_package_version.py
114
118
  tests/test_recall_provenance.py
@@ -121,6 +125,7 @@ tests/test_serve_app.py
121
125
  tests/test_serve_handlers.py
122
126
  tests/test_session_order.py
123
127
  tests/test_short_query.py
128
+ tests/test_snapshot.py
124
129
  tests/test_store.py
125
130
  tests/test_summarization.py
126
131
  tests/test_supersession_chain.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "loop-memory"
7
- version = "0.4.6"
7
+ version = "0.4.7"
8
8
  description = "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."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"