loop-memory 0.4.5__tar.gz → 0.4.6__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 (134) hide show
  1. {loop_memory-0.4.5 → loop_memory-0.4.6}/PKG-INFO +10 -7
  2. {loop_memory-0.4.5 → loop_memory-0.4.6}/README.md +9 -6
  3. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/cli/commands/cognitive.py +39 -0
  4. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/cli/commands/read.py +41 -3
  5. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/cli/main.py +3 -0
  6. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/storage/sqlite_store.py +226 -17
  7. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory.egg-info/PKG-INFO +10 -7
  8. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory.egg-info/SOURCES.txt +4 -0
  9. {loop_memory-0.4.5 → loop_memory-0.4.6}/pyproject.toml +1 -1
  10. loop_memory-0.4.6/tests/test_cli_audit_supersede.py +162 -0
  11. loop_memory-0.4.6/tests/test_cli_recall_verbose.py +146 -0
  12. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_contradictions.py +19 -4
  13. loop_memory-0.4.6/tests/test_recall_provenance.py +197 -0
  14. loop_memory-0.4.6/tests/test_supersession_chain.py +370 -0
  15. {loop_memory-0.4.5 → loop_memory-0.4.6}/LICENSE +0 -0
  16. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/__init__.py +0 -0
  17. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/backends/__init__.py +0 -0
  18. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/backends/embedding.py +0 -0
  19. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/backends/sentence_embedder.py +0 -0
  20. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/backends/vector_store.py +0 -0
  21. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/cli/__init__.py +0 -0
  22. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/cli/_common.py +0 -0
  23. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/cli/commands/__init__.py +0 -0
  24. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/cli/commands/diag.py +0 -0
  25. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/cli/commands/graph.py +0 -0
  26. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/cli/commands/hooks.py +0 -0
  27. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/cli/commands/rules.py +0 -0
  28. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/cli/commands/serve.py +0 -0
  29. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/cli/commands/write.py +0 -0
  30. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/engine/__init__.py +0 -0
  31. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/engine/loop.py +0 -0
  32. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/engine/reflect.py +0 -0
  33. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/examples/__init__.py +0 -0
  34. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/examples/demo.py +0 -0
  35. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/export/__init__.py +0 -0
  36. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/export/memory_md.py +0 -0
  37. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/graph/__init__.py +0 -0
  38. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/graph/build.py +0 -0
  39. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/graph/extract.py +0 -0
  40. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/ingest/__init__.py +0 -0
  41. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/ingest/loader.py +0 -0
  42. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/ingest/pipeline.py +0 -0
  43. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/jobs/__init__.py +0 -0
  44. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/jobs/cognitive.py +0 -0
  45. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/jobs/compact.py +0 -0
  46. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/jobs/consolidate.py +0 -0
  47. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/jobs/contradiction.py +0 -0
  48. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/jobs/evolution.py +0 -0
  49. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/jobs/graph.py +0 -0
  50. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/jobs/llm_compact_pass.py +0 -0
  51. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/jobs/llm_consolidate.py +0 -0
  52. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/jobs/scheduler.py +0 -0
  53. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/llm/__init__.py +0 -0
  54. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/llm/base.py +0 -0
  55. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/llm/openai_adapter.py +0 -0
  56. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/llm/providers.py +0 -0
  57. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/mcp/__init__.py +0 -0
  58. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/memory/__init__.py +0 -0
  59. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/memory/types.py +0 -0
  60. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/privacy/__init__.py +0 -0
  61. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/privacy/private.py +0 -0
  62. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/privacy/redact.py +0 -0
  63. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/py.typed +0 -0
  64. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/sdk.py +0 -0
  65. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/sdk_extensions.py +0 -0
  66. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/security/__init__.py +0 -0
  67. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/security/secrets.py +0 -0
  68. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/serve/__init__.py +0 -0
  69. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/serve/app.py +0 -0
  70. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/serve/handlers.py +0 -0
  71. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/serve/routes/_shared.py +0 -0
  72. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/serve/routes/admin.py +0 -0
  73. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/serve/routes/cognitive.py +0 -0
  74. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/serve/routes/export.py +0 -0
  75. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/serve/routes/graph.py +0 -0
  76. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/serve/routes/insights.py +0 -0
  77. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/serve/routes/memories.py +0 -0
  78. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/serve/routes/sessions.py +0 -0
  79. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/serve/routes/system.py +0 -0
  80. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/serve/routes/wiki.py +0 -0
  81. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/serve/static/__init__.py +0 -0
  82. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/serve/static/index.html +0 -0
  83. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/serve/watcher.py +0 -0
  84. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/storage/__init__.py +0 -0
  85. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/storage/retrieval.py +0 -0
  86. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/wiki/__init__.py +0 -0
  87. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/wiki/backfill.py +0 -0
  88. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/wiki/classifier.py +0 -0
  89. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/wiki/prompts.py +0 -0
  90. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory/wiki/scope.py +0 -0
  91. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory.egg-info/dependency_links.txt +0 -0
  92. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory.egg-info/entry_points.txt +0 -0
  93. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory.egg-info/requires.txt +0 -0
  94. {loop_memory-0.4.5 → loop_memory-0.4.6}/loop_memory.egg-info/top_level.txt +0 -0
  95. {loop_memory-0.4.5 → loop_memory-0.4.6}/setup.cfg +0 -0
  96. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_admin_ingest_route.py +0 -0
  97. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_agent_memory_api.py +0 -0
  98. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_agent_memory_sdk.py +0 -0
  99. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_auth_token_rotate.py +0 -0
  100. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_cli_rules.py +0 -0
  101. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_cli_v7.py +0 -0
  102. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_cli_version.py +0 -0
  103. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_evolution.py +0 -0
  104. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_evolution_quality.py +0 -0
  105. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_expanduser.py +0 -0
  106. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_export_ask.py +0 -0
  107. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_graph.py +0 -0
  108. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_ingest.py +0 -0
  109. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_llm_consolidator.py +0 -0
  110. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_llm_fingerprint_not_persisted.py +0 -0
  111. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_llm_providers.py +0 -0
  112. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_llm_test_endpoint.py +0 -0
  113. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_loop.py +0 -0
  114. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_mcp.py +0 -0
  115. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_memories_pagination.py +0 -0
  116. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_openclaw_loader.py +0 -0
  117. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_package_version.py +0 -0
  118. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_reflection.py +0 -0
  119. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_repo_framing.py +0 -0
  120. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_score_api.py +0 -0
  121. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_scoring_v2.py +0 -0
  122. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_secrets.py +0 -0
  123. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_serve_app.py +0 -0
  124. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_serve_handlers.py +0 -0
  125. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_session_order.py +0 -0
  126. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_short_query.py +0 -0
  127. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_store.py +0 -0
  128. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_summarization.py +0 -0
  129. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_universal_memory.py +0 -0
  130. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_vector_store.py +0 -0
  131. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_watcher.py +0 -0
  132. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_wiki_classifier.py +0 -0
  133. {loop_memory-0.4.5 → loop_memory-0.4.6}/tests/test_wiki_export_escape.py +0 -0
  134. {loop_memory-0.4.5 → loop_memory-0.4.6}/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.5
3
+ Version: 0.4.6
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,15 @@ Dynamic: license-file
66
66
 
67
67
  ---
68
68
 
69
- > **What's new in 0.4.1** — CLI dispatch hardened so every subcommand's
70
- > `--help` / `--version` exits 0 cleanly (no more `ValueError: unknown
71
- > source: '--help'` on a zero-deps install). Added a static
72
- > `COMMAND_HELP` table guarded by 10 new regression cases in
73
- > `tests/test_cli_version.py`, plus GitHub `About` / README lead now
74
- > surface for "agent loop" searches.
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.
75
78
  > [Full changelog →](CHANGELOG.md)
76
79
 
77
80
  ---
@@ -22,12 +22,15 @@
22
22
 
23
23
  ---
24
24
 
25
- > **What's new in 0.4.1** — CLI dispatch hardened so every subcommand's
26
- > `--help` / `--version` exits 0 cleanly (no more `ValueError: unknown
27
- > source: '--help'` on a zero-deps install). Added a static
28
- > `COMMAND_HELP` table guarded by 10 new regression cases in
29
- > `tests/test_cli_version.py`, plus GitHub `About` / README lead now
30
- > surface for "agent loop" searches.
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.
31
34
  > [Full changelog →](CHANGELOG.md)
32
35
 
33
36
  ---
@@ -116,6 +116,45 @@ def run_audit(args: list) -> int:
116
116
  return _emit({"rows": s.list_audit(kind=ns.kind, action=ns.action, limit=ns.limit)})
117
117
 
118
118
 
119
+ def run_audit_supersede(args: list) -> int:
120
+ """``loop-memory audit-supersede [--target ID] [--limit N] [--by ID]``
121
+
122
+ Walks the supersession chain (audit 2026-08-30, Mem0 v2.0.19
123
+ Dream pattern). Without ``--target`` it lists every superseded
124
+ memory (the loser side of every merge), most-recent first. With
125
+ ``--target <id>`` it returns just the chain starting at that
126
+ memory (oldest to newest). With ``--by <id>`` it filters the
127
+ list to memories that were superseded by a specific winner.
128
+
129
+ The CLI prints JSON so a shell pipeline can consume the output.
130
+ """
131
+ import argparse as _ap
132
+ p = _ap.ArgumentParser(prog="loop-memory audit-supersede")
133
+ p.add_argument("--target", default=None,
134
+ help="Walk the supersession chain starting at this memory id")
135
+ p.add_argument("--by", default=None,
136
+ help="List memories superseded by this specific winner id")
137
+ p.add_argument("--limit", type=int, default=200)
138
+ p.add_argument("--db", default=os.environ.get("LOOP_MEMORY_DB", DEFAULT_DB))
139
+ ns = p.parse_args(args)
140
+ s = MemoryStore(ns.db)
141
+ if ns.target:
142
+ chain = s.trace_supersession(ns.target)
143
+ return _emit({
144
+ "target": ns.target,
145
+ "chain": chain,
146
+ "chain_length": len(chain),
147
+ "winner": chain[-1] if chain else None,
148
+ })
149
+ rows = s.list_superseded(superseded_by=ns.by, limit=ns.limit)
150
+ return _emit({
151
+ "rows": rows,
152
+ "total": s.supersession_count(),
153
+ "filtered_by": ns.by,
154
+ "limit": ns.limit,
155
+ })
156
+
157
+
119
158
  def run_export(args: list) -> int:
120
159
  p = _build_parser()
121
160
  db = os.environ.get("LOOP_MEMORY_DB", DEFAULT_DB)
@@ -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(args)
59
- r = store.recall(query, limit=10)
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,
@@ -128,6 +130,7 @@ COMMANDS = {
128
130
  COMMAND_HELP: dict[str, str] = {
129
131
  "ask": "loop-memory ask '<question>' # print a paste-ready context block for any LLM client.",
130
132
  "audit": "loop-memory audit [--kind X] [--action Y] # read the cognitive audit trail.",
133
+ "audit-supersede": "loop-memory audit-supersede [--target ID] # walk / list supersession chain (Mem0 Dream pattern).",
131
134
  "chat": "loop-memory chat # REPL with echo LLM.",
132
135
  "cognitive-sleep": "loop-memory cognitive-sleep [--apply] # dry-run / apply cognitive sweep.",
133
136
  "consolidate": "loop-memory consolidate # rescore + GC + dedupe.",
@@ -444,7 +444,7 @@ class MemoryStore:
444
444
  The zero-dep claim holds — Python ships with sqlite3 and struct.
445
445
  """
446
446
 
447
- SCHEMA_VERSION = "8"
447
+ SCHEMA_VERSION = "9"
448
448
 
449
449
  def __init__(self, path: str | Path) -> None:
450
450
  self.path = Path(path).expanduser()
@@ -511,6 +511,25 @@ class MemoryStore:
511
511
  "WHERE external_id IS NOT NULL AND external_id != ''"
512
512
  )
513
513
 
514
+ # Audit 2026-08-30 — supersession chain (Mem0 v2.0.19 Dream
515
+ # pattern, slimmed down). Adds a nullable FK ``superseded_by``
516
+ # so ``merge_memories()`` can write an explicit pointer on
517
+ # the loser rather than DELETE-ing the row; ``recall()``
518
+ # filters it out, and ``list_superseded()`` /
519
+ # ``trace_supersession()`` walk the chain. SCHEMA_VERSION
520
+ # bumped "8" -> "9".
521
+ mem_cols = {row["name"] for row in c.execute("PRAGMA table_info(memories)").fetchall()}
522
+ if "superseded_by" not in mem_cols:
523
+ c.execute("ALTER TABLE memories ADD COLUMN superseded_by TEXT")
524
+ # Partial index keeps the recall filter O(1) even on stores
525
+ # with millions of memories. ``superseded_by IS NULL`` is
526
+ # the hot path; the index only covers the rare "show me the
527
+ # chain" query.
528
+ c.execute(
529
+ "CREATE INDEX IF NOT EXISTS idx_mem_superseded_by "
530
+ "ON memories(superseded_by) WHERE superseded_by IS NOT NULL"
531
+ )
532
+
514
533
  # One-shot FTS5 tokenizer migration. ``CREATE VIRTUAL TABLE
515
534
  # IF NOT EXISTS`` will *not* rebuild an existing FTS5 table
516
535
  # if its schema differs from the DDL — which is exactly
@@ -1272,10 +1291,16 @@ class MemoryStore:
1272
1291
  sql = (
1273
1292
  "SELECT m.id, m.kind, m.text, m.importance, m.score, m.source, "
1274
1293
  "m.tags, m.created_at, m.updated_at, "
1275
- "COALESCE(s.recall_count, 0) AS recall_count "
1294
+ "COALESCE(s.recall_count, 0) AS recall_count, "
1295
+ "m.superseded_by "
1276
1296
  "FROM memories m "
1277
1297
  "LEFT JOIN memory_signals s ON s.memory_id = m.id "
1278
- f"WHERE {clause} OR {tag_clause} "
1298
+ # Filter superseded memories out of the recall stream
1299
+ # (audit 2026-08-30, Mem0 v2.0.19 Dream pattern). The
1300
+ # partial index on ``superseded_by`` makes this an
1301
+ # O(1) skip; the chain itself stays queryable via
1302
+ # ``list_superseded()`` / ``trace_supersession()``.
1303
+ f"WHERE m.superseded_by IS NULL AND ({clause} OR {tag_clause}) "
1279
1304
  "ORDER BY m.score DESC, m.importance DESC, m.created_at DESC "
1280
1305
  "LIMIT ?"
1281
1306
  )
@@ -1297,26 +1322,49 @@ class MemoryStore:
1297
1322
  tag_lc = ",".join(tags).lower()
1298
1323
  body_hits = sum(txt.count(t) for t in tokens)
1299
1324
  tag_hits = sum(tag_lc.count(t) for t in tokens)
1325
+ importance = float(r["importance"] or 0)
1326
+ score_field = float(r["score"] or 0)
1300
1327
  score = body_hits + 2 * tag_hits
1301
- score *= 0.5 + float(r["importance"] or 0) * 0.8
1302
- score *= 0.7 + float(r["score"] or 0) * 0.6
1328
+ score *= 0.5 + importance * 0.8
1329
+ score *= 0.7 + score_field * 0.6
1330
+ # Build a small ``why: [...]`` provenance list
1331
+ # (audit 2026-08-30, agentmemory v1.2.0 pattern).
1332
+ # Each label names a scoring signal that actually
1333
+ # contributed to the hit; users see this through
1334
+ # ``loop-memory recall --verbose`` and the
1335
+ # dashboard tooltip. Order is stable so callers
1336
+ # can render it as a deterministic badge list.
1337
+ why: list[str] = []
1338
+ if body_hits > 0:
1339
+ why.append("keyword_match")
1340
+ if tag_hits > 0:
1341
+ why.append("tag_match")
1342
+ if importance >= 0.7:
1343
+ why.append("high_importance")
1344
+ if score_field >= 0.7:
1345
+ why.append("high_score_field")
1346
+ recall_count = int(r["recall_count"] or 0)
1303
1347
  if short_query:
1304
- recall_count = int(r["recall_count"] or 0)
1305
1348
  # log1p saturates so a memory with thousands of
1306
1349
  # recalls does not crowd out everything else.
1307
1350
  score *= 1.0 + min(0.3, recall_count * 0.03)
1351
+ if recall_count > 0:
1352
+ why.append("short_query_boost")
1353
+ if recall_count >= 3:
1354
+ why.append("high_recall_count")
1308
1355
  out["memories"].append({
1309
1356
  "id": r["id"],
1310
1357
  "kind": "memory",
1311
1358
  "text": r["text"],
1312
- "importance": float(r["importance"] or 0),
1313
- "score_field": float(r["score"] or 0),
1359
+ "importance": importance,
1360
+ "score_field": score_field,
1314
1361
  "source": r["source"],
1315
1362
  "tags": tags,
1316
1363
  "created_at": float(r["created_at"] or 0),
1317
- "recall_count": int(r["recall_count"] or 0),
1364
+ "recall_count": recall_count,
1318
1365
  "score": round(score, 3),
1319
1366
  "preview": (r["text"] or "")[:240],
1367
+ "why": why,
1320
1368
  })
1321
1369
  out["memories"].sort(key=lambda m: -m["score"])
1322
1370
  out["memories"] = out["memories"][:limit]
@@ -3173,13 +3221,18 @@ class MemoryStore:
3173
3221
  def merge_memories(self, a_id: str, b_id: str) -> dict:
3174
3222
  """True memory-pair merge.
3175
3223
 
3176
- Behaviour:
3224
+ Behaviour (audit 2026-08-30 — supersession chain, Mem0 v2.0.19
3225
+ Dream pattern, slimmed down):
3177
3226
  - The higher-scored memory wins (ties go to ``a_id``).
3178
3227
  - The loser's text is appended to the winner's text (de-duplicated
3179
3228
  if the loser's text is already a substring of the winner's).
3180
3229
  - The winner's ``importance`` and ``score`` are bumped to the max
3181
3230
  of the two so the fused memory keeps the strongest signal.
3182
- - The loser is deleted in the same transaction.
3231
+ - The loser is **not** deleted: it gets ``superseded_by = winner_id``
3232
+ so the audit trail can walk the chain. ``recall()`` filters
3233
+ out rows with a non-null ``superseded_by`` so callers see the
3234
+ latest view. The chain is queryable via ``list_superseded()``
3235
+ and ``trace_supersession()``.
3183
3236
  - The pair is recorded in ``contradiction_ignored`` so the pulse
3184
3237
  does not surface it again.
3185
3238
 
@@ -3193,23 +3246,36 @@ class MemoryStore:
3193
3246
  now = time.time()
3194
3247
  with self._conn() as c:
3195
3248
  a_row = c.execute(
3196
- "SELECT id, text, importance, score FROM memories WHERE id=?",
3249
+ "SELECT id, text, importance, score, superseded_by FROM memories WHERE id=?",
3197
3250
  (a_id,),
3198
3251
  ).fetchone()
3199
3252
  b_row = c.execute(
3200
- "SELECT id, text, importance, score FROM memories WHERE id=?",
3253
+ "SELECT id, text, importance, score, superseded_by FROM memories WHERE id=?",
3201
3254
  (b_id,),
3202
3255
  ).fetchone()
3203
3256
  if a_row is None and b_row is None:
3204
3257
  return {"merged": False, "reason": "neither_exists"}
3205
3258
  if a_row is None:
3206
- # Only B exists — silently delete the missing A and keep B.
3207
- c.execute("DELETE FROM memories WHERE id=?", (a_id,))
3259
+ # Only B exists — silently treat the missing A as a tombstone
3260
+ # and keep B. ``a_missing`` is rare (concurrent delete) so
3261
+ # we don't bother writing a supersede row.
3208
3262
  return {"merged": False, "kept": b_id, "lost": a_id, "reason": "a_missing"}
3209
3263
  if b_row is None:
3210
- c.execute("DELETE FROM memories WHERE id=?", (b_id,))
3211
3264
  return {"merged": False, "kept": a_id, "lost": b_id, "reason": "b_missing"}
3212
3265
 
3266
+ # If either side is already superseded, refuse to keep merging
3267
+ # to avoid creating a chain that's harder to reason about
3268
+ # (callers should walk the existing chain first via
3269
+ # ``trace_supersession``). The UI surfaces the existing chain
3270
+ # via ``audit supersede --target``.
3271
+ if a_row["superseded_by"] or b_row["superseded_by"]:
3272
+ return {
3273
+ "merged": False,
3274
+ "reason": "already_superseded",
3275
+ "a_superseded_by": a_row["superseded_by"],
3276
+ "b_superseded_by": b_row["superseded_by"],
3277
+ }
3278
+
3213
3279
  # Pick winner = higher score; ties go to a_id.
3214
3280
  a_score = a_row["score"] or 0.0
3215
3281
  b_score = b_row["score"] or 0.0
@@ -3239,7 +3305,44 @@ class MemoryStore:
3239
3305
  "WHERE id=?",
3240
3306
  (merged_text, importance_max, score_max, now, winner_id),
3241
3307
  )
3242
- c.execute("DELETE FROM memories WHERE id=?", (loser_id,))
3308
+ # Write an explicit supersession pointer instead of DELETE
3309
+ # (audit 2026-08-30). The loser row stays so
3310
+ # ``list_superseded`` / ``trace_supersession`` can walk the
3311
+ # chain; ``recall()`` filters ``superseded_by IS NULL``.
3312
+ c.execute(
3313
+ "UPDATE memories SET superseded_by=?, score=0, updated_at=? "
3314
+ "WHERE id=?",
3315
+ (winner_id, now, loser_id),
3316
+ )
3317
+
3318
+ # Audit 2026-08-30 — emit a ``supersede`` row into
3319
+ # ``cognitive_audit`` so the dashboard / ``loop-memory audit
3320
+ # --kind supersede`` surface can show "memory X was
3321
+ # superseded by memory Y on date Z". We do this inside the
3322
+ # same transaction so a partial merge never leaves the
3323
+ # audit pointing at a missing row.
3324
+ c.execute(
3325
+ """INSERT INTO cognitive_audit
3326
+ (id, ts, kind, action, target_kind, target_id,
3327
+ target_text, reason, score, payload)
3328
+ VALUES (?,?,?,?,?,?,?,?,?,?)""",
3329
+ (
3330
+ uuid.uuid4().hex,
3331
+ now,
3332
+ "supersede",
3333
+ "applied",
3334
+ "memory",
3335
+ loser_id,
3336
+ (loser_text or "")[:160],
3337
+ f"merged into {winner_id}",
3338
+ float(score_max),
3339
+ json.dumps({
3340
+ "winner_id": winner_id,
3341
+ "appended": needs_append,
3342
+ "winner_was_a": winner_is_a,
3343
+ }),
3344
+ ),
3345
+ )
3243
3346
 
3244
3347
  # Suppress the pair so the pulse does not resurface it.
3245
3348
  lo, hi = sorted([a_id, b_id])
@@ -3259,6 +3362,112 @@ class MemoryStore:
3259
3362
  "new_length": len(merged_text),
3260
3363
  }
3261
3364
 
3365
+ # ------------------------------------------------------------------
3366
+ # Supersession chain (audit 2026-08-30, Mem0 v2.0.19 Dream pattern)
3367
+ # ------------------------------------------------------------------
3368
+
3369
+ def list_superseded(self, superseded_by: str | None = None,
3370
+ limit: int = 200) -> list[dict]:
3371
+ """List memories that have been superseded.
3372
+
3373
+ ``superseded_by=None`` (default) — return every superseded
3374
+ memory (the loser side of every merge). With
3375
+ ``superseded_by=<id>`` — return only the memories that point
3376
+ at that specific winner. Results are ordered by ``updated_at
3377
+ DESC`` so the most recent supersession is first.
3378
+
3379
+ Returned dicts mirror the memory row + the ``superseded_by``
3380
+ pointer + the ``updated_at`` timestamp so the caller can
3381
+ render an audit view without an extra round trip.
3382
+ """
3383
+ limit = max(1, min(int(limit), 5000))
3384
+ sql = (
3385
+ "SELECT id, kind, text, importance, source, session_id, "
3386
+ "tags, created_at, updated_at, score, ttl, "
3387
+ "agent_id, user_id, external_id, superseded_by "
3388
+ "FROM memories WHERE superseded_by IS NOT NULL "
3389
+ )
3390
+ params: list[Any] = []
3391
+ if superseded_by is not None:
3392
+ sql += "AND superseded_by = ? "
3393
+ params.append(str(superseded_by))
3394
+ sql += "ORDER BY updated_at DESC LIMIT ?"
3395
+ params.append(limit)
3396
+ with self._conn() as c:
3397
+ rows = c.execute(sql, params).fetchall()
3398
+ out: list[dict] = []
3399
+ for r in rows:
3400
+ tags_raw = r["tags"] or "[]"
3401
+ try:
3402
+ tags = json.loads(tags_raw)
3403
+ except Exception:
3404
+ tags = []
3405
+ out.append({
3406
+ "id": r["id"],
3407
+ "kind": r["kind"],
3408
+ "text": r["text"] or "",
3409
+ "importance": float(r["importance"] or 0),
3410
+ "source": r["source"],
3411
+ "session_id": r["session_id"],
3412
+ "tags": tags,
3413
+ "created_at": float(r["created_at"] or 0),
3414
+ "updated_at": float(r["updated_at"] or 0),
3415
+ "score": float(r["score"] or 0),
3416
+ "ttl": r["ttl"],
3417
+ "agent_id": r["agent_id"],
3418
+ "user_id": r["user_id"],
3419
+ "external_id": r["external_id"],
3420
+ "superseded_by": r["superseded_by"],
3421
+ })
3422
+ return out
3423
+
3424
+ def trace_supersession(self, target_id: str) -> list[str]:
3425
+ """Walk the supersession chain starting from ``target_id``.
3426
+
3427
+ Returns the **list of memory ids** in the chain from oldest to
3428
+ newest (the last entry is the current "winner"). If the
3429
+ target is itself a winner, the list is just ``[target_id]``.
3430
+ If the target is not in the store, the list is empty.
3431
+
3432
+ Bounded to 64 hops so a corrupted FK cannot spin forever.
3433
+ """
3434
+ target_id = str(target_id or "")
3435
+ if not target_id:
3436
+ return []
3437
+ seen: set[str] = set()
3438
+ out: list[str] = []
3439
+ current = target_id
3440
+ with self._conn() as c:
3441
+ for _ in range(64):
3442
+ if current in seen:
3443
+ break
3444
+ seen.add(current)
3445
+ row = c.execute(
3446
+ "SELECT id, superseded_by FROM memories WHERE id=?",
3447
+ (current,),
3448
+ ).fetchone()
3449
+ if row is None:
3450
+ break
3451
+ out.append(row["id"])
3452
+ nxt = row["superseded_by"]
3453
+ if not nxt:
3454
+ break
3455
+ current = nxt
3456
+ return out
3457
+
3458
+ def supersession_count(self) -> int:
3459
+ """Total number of superseded memories in the store.
3460
+
3461
+ Used by the dashboard so the user can see how much history
3462
+ the supersession chain carries. Cheap because the partial
3463
+ index covers it.
3464
+ """
3465
+ with self._conn() as c:
3466
+ row = c.execute(
3467
+ "SELECT COUNT(*) c FROM memories WHERE superseded_by IS NOT NULL"
3468
+ ).fetchone()
3469
+ return int(row["c"] or 0)
3470
+
3262
3471
  def delete_session(self, session_id: str) -> int:
3263
3472
  with self._conn() as c:
3264
3473
  cur = c.execute("DELETE FROM memories WHERE session_id=?", (session_id,))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loop-memory
3
- Version: 0.4.5
3
+ Version: 0.4.6
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,15 @@ Dynamic: license-file
66
66
 
67
67
  ---
68
68
 
69
- > **What's new in 0.4.1** — CLI dispatch hardened so every subcommand's
70
- > `--help` / `--version` exits 0 cleanly (no more `ValueError: unknown
71
- > source: '--help'` on a zero-deps install). Added a static
72
- > `COMMAND_HELP` table guarded by 10 new regression cases in
73
- > `tests/test_cli_version.py`, plus GitHub `About` / README lead now
74
- > surface for "agent loop" searches.
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.
75
78
  > [Full changelog →](CHANGELOG.md)
76
79
 
77
80
  ---
@@ -90,6 +90,8 @@ tests/test_admin_ingest_route.py
90
90
  tests/test_agent_memory_api.py
91
91
  tests/test_agent_memory_sdk.py
92
92
  tests/test_auth_token_rotate.py
93
+ tests/test_cli_audit_supersede.py
94
+ tests/test_cli_recall_verbose.py
93
95
  tests/test_cli_rules.py
94
96
  tests/test_cli_v7.py
95
97
  tests/test_cli_version.py
@@ -109,6 +111,7 @@ tests/test_mcp.py
109
111
  tests/test_memories_pagination.py
110
112
  tests/test_openclaw_loader.py
111
113
  tests/test_package_version.py
114
+ tests/test_recall_provenance.py
112
115
  tests/test_reflection.py
113
116
  tests/test_repo_framing.py
114
117
  tests/test_score_api.py
@@ -120,6 +123,7 @@ tests/test_session_order.py
120
123
  tests/test_short_query.py
121
124
  tests/test_store.py
122
125
  tests/test_summarization.py
126
+ tests/test_supersession_chain.py
123
127
  tests/test_universal_memory.py
124
128
  tests/test_vector_store.py
125
129
  tests/test_watcher.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "loop-memory"
7
- version = "0.4.5"
7
+ version = "0.4.6"
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"