loop-memory 0.4.14__tar.gz → 0.4.15__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 (165) hide show
  1. {loop_memory-0.4.14 → loop_memory-0.4.15}/PKG-INFO +2 -1
  2. {loop_memory-0.4.14 → loop_memory-0.4.15}/README.md +1 -0
  3. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/cli/commands/read.py +27 -1
  4. loop_memory-0.4.15/loop_memory/decorators.py +243 -0
  5. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/serve/routes/system.py +37 -14
  6. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/storage/retrieval.py +95 -6
  7. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/storage/sqlite_store.py +400 -21
  8. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory.egg-info/PKG-INFO +2 -1
  9. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory.egg-info/SOURCES.txt +5 -0
  10. {loop_memory-0.4.14 → loop_memory-0.4.15}/pyproject.toml +1 -1
  11. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_agents_init.py +2 -2
  12. loop_memory-0.4.15/tests/test_content_hash_dedup_2026_10_18.py +436 -0
  13. loop_memory-0.4.15/tests/test_decorators_2026_10_18.py +279 -0
  14. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_evolution_quality.py +12 -2
  15. loop_memory-0.4.15/tests/test_recall_validity_http_2026_10_18.py +169 -0
  16. loop_memory-0.4.15/tests/test_retrieval_channels_2026_10_18.py +216 -0
  17. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_store.py +11 -2
  18. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_supersession_chain.py +4 -2
  19. {loop_memory-0.4.14 → loop_memory-0.4.15}/LICENSE +0 -0
  20. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/__init__.py +0 -0
  21. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/backends/__init__.py +0 -0
  22. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/backends/embedding.py +0 -0
  23. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/backends/sentence_embedder.py +0 -0
  24. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/backends/vector_store.py +0 -0
  25. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/cli/__init__.py +0 -0
  26. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/cli/_common.py +0 -0
  27. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/cli/commands/__init__.py +0 -0
  28. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/cli/commands/cognitive.py +0 -0
  29. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/cli/commands/diag.py +0 -0
  30. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/cli/commands/doctor.py +0 -0
  31. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/cli/commands/graph.py +0 -0
  32. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/cli/commands/hooks.py +0 -0
  33. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/cli/commands/rules.py +0 -0
  34. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/cli/commands/serve.py +0 -0
  35. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/cli/commands/write.py +0 -0
  36. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/cli/main.py +0 -0
  37. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/doctor.py +0 -0
  38. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/engine/__init__.py +0 -0
  39. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/engine/loop.py +0 -0
  40. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/engine/reflect.py +0 -0
  41. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/examples/__init__.py +0 -0
  42. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/examples/demo.py +0 -0
  43. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/export/__init__.py +0 -0
  44. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/export/memory_md.py +0 -0
  45. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/graph/__init__.py +0 -0
  46. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/graph/build.py +0 -0
  47. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/graph/extract.py +0 -0
  48. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/ingest/__init__.py +0 -0
  49. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/ingest/loader.py +0 -0
  50. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/ingest/pipeline.py +0 -0
  51. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/jobs/__init__.py +0 -0
  52. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/jobs/cognitive.py +0 -0
  53. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/jobs/compact.py +0 -0
  54. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/jobs/consolidate.py +0 -0
  55. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/jobs/contradiction.py +0 -0
  56. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/jobs/evolution.py +0 -0
  57. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/jobs/graph.py +0 -0
  58. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/jobs/llm_compact_pass.py +0 -0
  59. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/jobs/llm_consolidate.py +0 -0
  60. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/jobs/scheduler.py +0 -0
  61. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/llm/__init__.py +0 -0
  62. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/llm/base.py +0 -0
  63. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/llm/openai_adapter.py +0 -0
  64. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/llm/providers.py +0 -0
  65. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/mcp/__init__.py +0 -0
  66. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/memory/__init__.py +0 -0
  67. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/memory/types.py +0 -0
  68. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/privacy/__init__.py +0 -0
  69. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/privacy/private.py +0 -0
  70. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/privacy/redact.py +0 -0
  71. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/py.typed +0 -0
  72. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/sdk.py +0 -0
  73. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/sdk_extensions.py +0 -0
  74. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/security/__init__.py +0 -0
  75. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/security/secrets.py +0 -0
  76. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/serve/__init__.py +0 -0
  77. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/serve/app.py +0 -0
  78. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/serve/handlers.py +0 -0
  79. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/serve/routes/_shared.py +0 -0
  80. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/serve/routes/admin.py +0 -0
  81. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/serve/routes/cognitive.py +0 -0
  82. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/serve/routes/export.py +0 -0
  83. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/serve/routes/graph.py +0 -0
  84. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/serve/routes/insights.py +0 -0
  85. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/serve/routes/memories.py +0 -0
  86. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/serve/routes/sessions.py +0 -0
  87. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/serve/routes/wiki.py +0 -0
  88. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/serve/static/__init__.py +0 -0
  89. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/serve/static/index.html +0 -0
  90. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/serve/watcher.py +0 -0
  91. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/storage/__init__.py +0 -0
  92. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/storage/snapshot.py +0 -0
  93. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/wiki/__init__.py +0 -0
  94. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/wiki/backfill.py +0 -0
  95. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/wiki/classifier.py +0 -0
  96. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/wiki/prompts.py +0 -0
  97. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory/wiki/scope.py +0 -0
  98. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory.egg-info/dependency_links.txt +0 -0
  99. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory.egg-info/entry_points.txt +0 -0
  100. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory.egg-info/requires.txt +0 -0
  101. {loop_memory-0.4.14 → loop_memory-0.4.15}/loop_memory.egg-info/top_level.txt +0 -0
  102. {loop_memory-0.4.14 → loop_memory-0.4.15}/setup.cfg +0 -0
  103. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_admin_ingest_route.py +0 -0
  104. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_agent_memory_api.py +0 -0
  105. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_agent_memory_sdk.py +0 -0
  106. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_audit_triggers_2026_09_27.py +0 -0
  107. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_auth_token_rotate.py +0 -0
  108. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_busy_timeout_2026_09_24.py +0 -0
  109. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_cache_cap_2026_09_23.py +0 -0
  110. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_cli_audit_supersede.py +0 -0
  111. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_cli_export_okf.py +0 -0
  112. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_cli_init.py +0 -0
  113. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_cli_memory_stats.py +0 -0
  114. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_cli_recall_as_of.py +0 -0
  115. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_cli_recall_paths.py +0 -0
  116. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_cli_recall_verbose.py +0 -0
  117. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_cli_rules.py +0 -0
  118. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_cli_snapshot.py +0 -0
  119. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_cli_v7.py +0 -0
  120. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_cli_version.py +0 -0
  121. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_contradictions.py +0 -0
  122. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_doctor_2026_10_12.py +0 -0
  123. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_evolution.py +0 -0
  124. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_expanduser.py +0 -0
  125. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_export_ask.py +0 -0
  126. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_export_okf.py +0 -0
  127. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_graph.py +0 -0
  128. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_healthz_readyz_2026_09_24.py +0 -0
  129. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_import_okf_2026_09_27.py +0 -0
  130. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_ingest.py +0 -0
  131. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_llm_consolidator.py +0 -0
  132. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_llm_fingerprint_not_persisted.py +0 -0
  133. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_llm_providers.py +0 -0
  134. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_llm_test_endpoint.py +0 -0
  135. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_loop.py +0 -0
  136. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_mcp.py +0 -0
  137. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_memories_pagination.py +0 -0
  138. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_memory_stats.py +0 -0
  139. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_openclaw_loader.py +0 -0
  140. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_package_version.py +0 -0
  141. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_path_safety_2026_09_23.py +0 -0
  142. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_recall_as_of.py +0 -0
  143. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_recall_budget_2026_10_05.py +0 -0
  144. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_recall_paths.py +0 -0
  145. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_recall_provenance.py +0 -0
  146. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_reflection.py +0 -0
  147. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_repo_framing.py +0 -0
  148. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_routes_audit_2026_09_13.py +0 -0
  149. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_routes_audit_2026_09_20.py +0 -0
  150. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_score_api.py +0 -0
  151. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_scoring_v2.py +0 -0
  152. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_secrets.py +0 -0
  153. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_serve_app.py +0 -0
  154. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_serve_handlers.py +0 -0
  155. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_session_order.py +0 -0
  156. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_short_query.py +0 -0
  157. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_snapshot.py +0 -0
  158. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_subgraph_hops_2026_09_23.py +0 -0
  159. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_summarization.py +0 -0
  160. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_universal_memory.py +0 -0
  161. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_vector_store.py +0 -0
  162. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_watcher.py +0 -0
  163. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_wiki_classifier.py +0 -0
  164. {loop_memory-0.4.14 → loop_memory-0.4.15}/tests/test_wiki_export_escape.py +0 -0
  165. {loop_memory-0.4.14 → loop_memory-0.4.15}/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.14
3
+ Version: 0.4.15
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,6 +66,7 @@ Dynamic: license-file
66
66
 
67
67
  ---
68
68
 
69
+ > **What's new in 0.4.15** — **content-hash dedup + bi-temporal validity window + evidence chain + `@agent_memory` decorator** (audit 2026-10-18, weekly ecosystem sync). Three new columns on `memories`: `content_hash` (SHA-256 of normalised text, indexed), `valid_at` (epoch the memory becomes active), `invalid_at` (epoch it expires — NULL means still open-ended). The schema migration bumps `SCHEMA_VERSION` 11 → 12 with an idempotent backfill so legacy DBs upgrade in-place. New `upsert_memory(valid_at=, invalid_at=, dedup=)` parameters let callers schedule future-dated or expiring memories and opt out of content-hash dedup for episodic event streams. `recall()` and `recall_hybrid()` now accept an optional `as_of_ts` (float epoch or ISO-8601) — when set, both methods drop memories whose `valid_at > as_of` or `invalid_at <= as_of`, complementing the existing `recall_as_of()` supersession-chain walk. Every recall hit now carries an `evidence: [{field, value, weight}]` array — BM25 / semantic / entity / rrf_total / high_importance channels are exposed individually so a UI can render "this hit because ... [bm25 1.0] [entity 0.7]" instead of a black-box score (Cognee 1.0 / Mem0 v2 evidence-citation pattern, zero-dep). New `loop_memory.decorators` exposes `@agent_memory(store, agent_id=, limit=)` — a one-line wrapper that recalls the top-k relevant memories before the body runs and writes a fresh `kind="interaction"` memory after. Strings returned from the wrapped function are stored verbatim; dict/list returns are JSON-serialised; the recall list is injected as a `memory_context` keyword when the function declares it, otherwise attached to dict results under `memories`. Sister `remember(store, text, ...)` helper for seeding. The BM25 `_like_fallback` now falls through to per-token OR-LIKE when the whole-string match returns nothing — natural-language questions like "What is the deploy process?" now hit stored facts even when FTS5 trigram can't match quoted exact phrases. Source: Cognee 1.0 ECL pipeline + Mem0 v2 hybrid fusion + Zep/Graphiti bi-temporal validity fields + Letta block-memory (pattern only, no code copied). 59 new regression cases. [Full CHANGELOG →](./CHANGELOG.md#0415---2026-10-18)
69
70
  > **What's new in 0.4.14** — **`loop-memory doctor` deep store-health check** (audit 2026-10-12). Ten diagnostic checks — `db_exists`, `db_integrity`, `schema_version`, `audit_triggers`, `audit_log_writable`, `audit_log_size`, `hooks_installed`, `version_consistent`, `wiki_no_orphans`, `recall_paths_works` — report `ok`/`info`/`warn`/`error` for the live `MemoryStore`. `--fix` auto-repairs the `audit_triggers` check. `--json` emits a stable shape for CI. `--list` prints the check function names without touching the store. `--verbose` shows `info`-level checks. Exit code `1` on any `error` status, `0` otherwise. Source: `cortexkit/magic-context doctor` auto-diagnostic pattern (MIT, 2026-10-12) + `dsh-memento` write-approval-gate pattern (Apache-2.0, 2026-10-12). Stdlib-only — we re-implement from scratch against our own `MemoryStore`. Also fixed a Python 3.14 bug: chained `store._conn().__enter__()` immediately triggers the contextmanager's `finally` and closes the connection mid-query — both call sites in `doctor.py` now use explicit `with` form. 27 new regression cases. [Full CHANGELOG →](./CHANGELOG.md#0414---2026-10-12)
70
71
 
71
72
  > **What's new in 0.4.13** — **recall token / char budget cap** + **unified `level=0|1|2` tiered-loader knob** (audit 2026-10-05). New `max_chars` parameter on `MemoryStore.recall()` / `recall_paths()` / `recall_hybrid()` / `recall_as_of()` caps the *total* payload across all returned hits — when the budget is exceeded the lowest-ranked hit shrinks first so the top-scoring hits always keep their full body. `recall()` / `recall_paths()` / `recall_as_of()` now also accept `level=0|1|2` so a caller can ask for the L0 abstract ladder rung inline (titles + tags + abstract only, never the full `text` / `body`) without routing through `recall_paths()` separately. Both knobs are wired through the HTTP routes (`GET /api/recall?max_chars=&level=`, `GET /api/recall/outline?max_chars=`), the CLI (`loop-memory recall --max-chars N --level 0|1|2`, `recall-paths --max-chars N`), the SDK (`LoopMemoryClient.recall(max_chars=, level=)`), and the MCP `tool_recall` server. Source: `vectorize-io/hindsight v0.10.2` "concise extraction by default" (Apache-2.0, 2026-09-29) + `aiming-lab/SimpleMem` ICML'26 semantic-lossless-compression (Apache-2.0, 2026-10-04) + `volcengine/OpenViking` tiered-loader (AGPLv3, 2026-10-02 — *pattern only, no code copied*). 26 new regression cases. [Full CHANGELOG →](./CHANGELOG.md#0413---2026-10-05)
@@ -22,6 +22,7 @@
22
22
 
23
23
  ---
24
24
 
25
+ > **What's new in 0.4.15** — **content-hash dedup + bi-temporal validity window + evidence chain + `@agent_memory` decorator** (audit 2026-10-18, weekly ecosystem sync). Three new columns on `memories`: `content_hash` (SHA-256 of normalised text, indexed), `valid_at` (epoch the memory becomes active), `invalid_at` (epoch it expires — NULL means still open-ended). The schema migration bumps `SCHEMA_VERSION` 11 → 12 with an idempotent backfill so legacy DBs upgrade in-place. New `upsert_memory(valid_at=, invalid_at=, dedup=)` parameters let callers schedule future-dated or expiring memories and opt out of content-hash dedup for episodic event streams. `recall()` and `recall_hybrid()` now accept an optional `as_of_ts` (float epoch or ISO-8601) — when set, both methods drop memories whose `valid_at > as_of` or `invalid_at <= as_of`, complementing the existing `recall_as_of()` supersession-chain walk. Every recall hit now carries an `evidence: [{field, value, weight}]` array — BM25 / semantic / entity / rrf_total / high_importance channels are exposed individually so a UI can render "this hit because ... [bm25 1.0] [entity 0.7]" instead of a black-box score (Cognee 1.0 / Mem0 v2 evidence-citation pattern, zero-dep). New `loop_memory.decorators` exposes `@agent_memory(store, agent_id=, limit=)` — a one-line wrapper that recalls the top-k relevant memories before the body runs and writes a fresh `kind="interaction"` memory after. Strings returned from the wrapped function are stored verbatim; dict/list returns are JSON-serialised; the recall list is injected as a `memory_context` keyword when the function declares it, otherwise attached to dict results under `memories`. Sister `remember(store, text, ...)` helper for seeding. The BM25 `_like_fallback` now falls through to per-token OR-LIKE when the whole-string match returns nothing — natural-language questions like "What is the deploy process?" now hit stored facts even when FTS5 trigram can't match quoted exact phrases. Source: Cognee 1.0 ECL pipeline + Mem0 v2 hybrid fusion + Zep/Graphiti bi-temporal validity fields + Letta block-memory (pattern only, no code copied). 59 new regression cases. [Full CHANGELOG →](./CHANGELOG.md#0415---2026-10-18)
25
26
  > **What's new in 0.4.14** — **`loop-memory doctor` deep store-health check** (audit 2026-10-12). Ten diagnostic checks — `db_exists`, `db_integrity`, `schema_version`, `audit_triggers`, `audit_log_writable`, `audit_log_size`, `hooks_installed`, `version_consistent`, `wiki_no_orphans`, `recall_paths_works` — report `ok`/`info`/`warn`/`error` for the live `MemoryStore`. `--fix` auto-repairs the `audit_triggers` check. `--json` emits a stable shape for CI. `--list` prints the check function names without touching the store. `--verbose` shows `info`-level checks. Exit code `1` on any `error` status, `0` otherwise. Source: `cortexkit/magic-context doctor` auto-diagnostic pattern (MIT, 2026-10-12) + `dsh-memento` write-approval-gate pattern (Apache-2.0, 2026-10-12). Stdlib-only — we re-implement from scratch against our own `MemoryStore`. Also fixed a Python 3.14 bug: chained `store._conn().__enter__()` immediately triggers the contextmanager's `finally` and closes the connection mid-query — both call sites in `doctor.py` now use explicit `with` form. 27 new regression cases. [Full CHANGELOG →](./CHANGELOG.md#0414---2026-10-12)
26
27
 
27
28
  > **What's new in 0.4.13** — **recall token / char budget cap** + **unified `level=0|1|2` tiered-loader knob** (audit 2026-10-05). New `max_chars` parameter on `MemoryStore.recall()` / `recall_paths()` / `recall_hybrid()` / `recall_as_of()` caps the *total* payload across all returned hits — when the budget is exceeded the lowest-ranked hit shrinks first so the top-scoring hits always keep their full body. `recall()` / `recall_paths()` / `recall_as_of()` now also accept `level=0|1|2` so a caller can ask for the L0 abstract ladder rung inline (titles + tags + abstract only, never the full `text` / `body`) without routing through `recall_paths()` separately. Both knobs are wired through the HTTP routes (`GET /api/recall?max_chars=&level=`, `GET /api/recall/outline?max_chars=`), the CLI (`loop-memory recall --max-chars N --level 0|1|2`, `recall-paths --max-chars N`), the SDK (`LoopMemoryClient.recall(max_chars=, level=)`), and the MCP `tool_recall` server. Source: `vectorize-io/hindsight v0.10.2` "concise extraction by default" (Apache-2.0, 2026-09-29) + `aiming-lab/SimpleMem` ICML'26 semantic-lossless-compression (Apache-2.0, 2026-10-04) + `volcengine/OpenViking` tiered-loader (AGPLv3, 2026-10-02 — *pattern only, no code copied*). 26 new regression cases. [Full CHANGELOG →](./CHANGELOG.md#0413---2026-10-05)
@@ -73,6 +73,9 @@ def run_recall(args) -> int:
73
73
  outline = False
74
74
  limit = 10
75
75
  as_of: float | None = None
76
+ # Audit 2026-10-18: bi-temporal *validity* window (distinct
77
+ # from ``as_of`` which routes through the supersession chain).
78
+ validity_at: float | None = None
76
79
  max_chars: int | None = None
77
80
  level = 1
78
81
  qargs: list[str] = []
@@ -106,6 +109,24 @@ def run_recall(args) -> int:
106
109
  f"--as-of must be float epoch or ISO-8601; got {raw!r}"
107
110
  )
108
111
  i += 2
112
+ elif a == "--validity-at" and i + 1 < len(args):
113
+ # Audit 2026-10-18 (loop-memory 0.4.15) -- cheaper
114
+ # validity-window filter (vs --as-of which walks the
115
+ # supersession chain). Accepts the same float-or-ISO.
116
+ raw = args[i + 1].strip()
117
+ try:
118
+ validity_at = float(raw)
119
+ except ValueError:
120
+ try:
121
+ from datetime import datetime
122
+ validity_at = datetime.fromisoformat(
123
+ raw.replace("Z", "+00:00")
124
+ ).timestamp()
125
+ except Exception:
126
+ return die(
127
+ f"--validity-at must be float epoch or ISO-8601; got {raw!r}"
128
+ )
129
+ i += 2
109
130
  elif a == "--max-chars" and i + 1 < len(args):
110
131
  # Audit 2026-10-05 (vectorize-io/hindsight v0.10.2 +
111
132
  # aiming-lab/SimpleMem ICML'26) — cap the total payload
@@ -154,7 +175,12 @@ def run_recall(args) -> int:
154
175
  r = store.recall_paths(query, limit=limit, max_chars=max_chars)
155
176
  else:
156
177
  r = store.recall(query, limit=limit,
157
- max_chars=max_chars, level=level)
178
+ max_chars=max_chars, level=level,
179
+ # Audit 2026-10-18: pass validity_at through
180
+ # to recall() so the new bi-temporal window
181
+ # filter applies. ``None`` keeps the legacy
182
+ # "everything" semantics.
183
+ as_of_ts=validity_at)
158
184
  has = False
159
185
  if r["wiki"]:
160
186
  has = True
@@ -0,0 +1,243 @@
1
+ """Lightweight decorators for one-line agent memory integration.
2
+
3
+ Audit 2026-10-18 (loop-memory 0.4.15): the decorator pattern is the
4
+ adoption-shortcut inspired by LangChain ``@tool`` / Mem0 v2's
5
+ ``@remember`` -- a single line of boilerplate turns any plain Python
6
+ function into an "agent-callable" that reads from long-term memory
7
+ before it runs and writes a fresh memory after it returns.
8
+
9
+ Two decorators ship here:
10
+
11
+ * :func:`agent_memory` -- wraps a callable so every invocation:
12
+ - **recalls** the top-k relevant memories for the function's first
13
+ string argument (the "query") *before* running the body, and
14
+ - either exposes the recall as a ``memory_context`` keyword
15
+ argument (when the wrapped function's signature declares it),
16
+ OR surfaces it on the return value as ``result["memories"]``;
17
+ - **stores** the (query, return-value) pair as a single
18
+ ``kind="interaction"`` memory row *after* the body finishes, so
19
+ the next invocation can recall it.
20
+
21
+ * :func:`remember` -- a tiny sugar that just calls
22
+ ``store.upsert_memory(kind="fact", text=text, ...)`` once at
23
+ definition time. Useful for facts the agent wants to hard-code into
24
+ memory at import time ("I am a code assistant", "My user prefers
25
+ dark mode", etc.).
26
+
27
+ Both decorators are intentionally zero-dep and free of any LLM/agent
28
+ framework dependency -- they sit on top of the public
29
+ ``MemoryStore`` API only.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import functools
34
+ import inspect
35
+ import json
36
+ from collections.abc import Callable
37
+ from typing import Any, ParamSpec, TypeVar
38
+
39
+ from .storage.sqlite_store import MemoryStore
40
+
41
+ P = ParamSpec("P")
42
+ R = TypeVar("R")
43
+
44
+
45
+ def _stringify(value: Any, *, max_len: int = 2000) -> str:
46
+ """Coerce a Python return value to a memory-friendly string.
47
+
48
+ * ``str`` -> as-is (truncated to ``max_len``).
49
+ * ``dict`` / ``list`` -> JSON-serialised then truncated.
50
+ * ``bytes``-> hex preview.
51
+ * everything else -> ``repr(value)``.
52
+
53
+ The truncation is the only place this helper swallows information;
54
+ downstream recall still works because the original ``text`` is on
55
+ the row, and the full structured payload is also stored verbatim
56
+ in ``tags`` when the caller passes a dict/list.
57
+ """
58
+ if isinstance(value, str):
59
+ return value if len(value) <= max_len else value[: max_len - 1] + "\u2026"
60
+ if isinstance(value, (dict, list)):
61
+ s = json.dumps(value, ensure_ascii=False, default=str)
62
+ return s if len(s) <= max_len else s[: max_len - 1] + "\u2026"
63
+ if isinstance(value, bytes):
64
+ return value[:64].hex() + (f" (len={len(value)})" if len(value) > 64 else "")
65
+ return repr(value)
66
+
67
+
68
+ def _first_string_arg(
69
+ args: tuple, kwargs: dict, signature: inspect.Signature
70
+ ) -> str | None:
71
+ """Return the first positional str argument from the call.
72
+
73
+ Falls back to ``None`` when the call carries no string-y input
74
+ (e.g. ``@agent_memory`` wrapping an internal ``tick()`` that
75
+ takes only objects).
76
+ """
77
+ if args:
78
+ first = args[0]
79
+ if isinstance(first, str) and first.strip():
80
+ return first
81
+ return None
82
+ for name, p in signature.parameters.items():
83
+ if name in kwargs:
84
+ v = kwargs[name]
85
+ if isinstance(v, str) and v.strip():
86
+ return v
87
+ return None
88
+ return None
89
+
90
+
91
+ def agent_memory(
92
+ store: MemoryStore,
93
+ *,
94
+ agent_id: str | None = None,
95
+ user_id: str | None = None,
96
+ kind: str = "interaction",
97
+ limit: int = 5,
98
+ recall_kwargs_name: str = "memory_context",
99
+ ) -> Callable[[Callable[P, R]], Callable[P, R]]:
100
+ """Wrap ``fn`` so every call reads + writes persistent memory.
101
+
102
+ Parameters
103
+ ----------
104
+ store:
105
+ An open :class:`~loop_memory.storage.sqlite_store.MemoryStore`.
106
+ Required -- the decorator has nowhere to put memory otherwise.
107
+ agent_id, user_id:
108
+ Optional identity columns. When ``None`` the wrapped call
109
+ pushes a memory that is global to the store (the same scope
110
+ that pre-0.4.15 callers used).
111
+ kind:
112
+ Memory row kind. Default ``"interaction"`` matches the
113
+ dashboard's existing tag.
114
+ limit:
115
+ Number of top-k memories to recall *before* the body runs.
116
+ Capped at 50 to keep the prompt tiny.
117
+ recall_kwargs_name:
118
+ Name of the optional kwarg the wrapped function can declare to
119
+ receive the recall as a string. Defaults to ``"memory_context"``.
120
+ If the function does NOT declare that kwarg the recall is
121
+ silently dropped (the body still runs) but the row is still
122
+ stored after the call returns.
123
+
124
+ Example
125
+ -------
126
+ >>> store = MemoryStore("loop.db")
127
+ >>> @agent_memory(store, agent_id="codex")
128
+ ... def ask(question: str, memory_context: str = "") -> str:
129
+ ... # memory_context arrives pre-formatted from the recall
130
+ ... return f"{question} -> {memory_context[:40]}"
131
+ >>> ask("What is the deploy process?")
132
+ """
133
+ if not isinstance(store, MemoryStore):
134
+ raise TypeError(
135
+ f"@agent_memory requires a MemoryStore, got {type(store).__name__}"
136
+ )
137
+ if limit <= 0 or limit > 50:
138
+ raise ValueError(f"limit must be in [1, 50], got {limit}")
139
+
140
+ def _decorator(fn: Callable[P, R]) -> Callable[P, R]:
141
+ sig = inspect.signature(fn)
142
+ _ = sig # pre-bound for _first_string_arg
143
+ recall_kw = recall_kwargs_name
144
+
145
+ @functools.wraps(fn)
146
+ def _wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
147
+ query = _first_string_arg(args, kwargs, sig)
148
+ # --- pre-call: recall ---------------------------------
149
+ recall_hits: list[dict] = []
150
+ context_text = ""
151
+ if query:
152
+ try:
153
+ res = store.recall_hybrid(
154
+ query, limit=limit, as_of_ts=None,
155
+ )
156
+ recall_hits = list(res.get("memories", []))
157
+ except Exception:
158
+ recall_hits = []
159
+ if recall_hits:
160
+ lines = []
161
+ for h in recall_hits:
162
+ prev = (h.get("text") or h.get("preview") or "").strip()
163
+ if prev:
164
+ lines.append(f"- {prev[:240]}")
165
+ context_text = "\n".join(lines)
166
+
167
+ # Inject memory_context only when the function actually
168
+ # declares the kwarg -- otherwise the decorator stays
169
+ # invisible to legacy callables that don't know about it.
170
+ injected = False
171
+ if recall_kw in sig.parameters and context_text:
172
+ kwargs = {**kwargs, recall_kw: context_text}
173
+ injected = True
174
+
175
+ # --- body ----------------------------------------------
176
+ result = fn(*args, **kwargs)
177
+
178
+ # --- post-call: store ----------------------------------
179
+ if query:
180
+ try:
181
+ text = _stringify(result)
182
+ tags = ["agent_memory"]
183
+ if isinstance(result, (dict, list)):
184
+ tags.append("structured")
185
+ store.upsert_memory(
186
+ kind=kind,
187
+ text=text,
188
+ importance=0.6,
189
+ agent_id=agent_id,
190
+ user_id=user_id,
191
+ tags=tags,
192
+ source=f"agent_memory:{fn.__name__}",
193
+ )
194
+ except Exception:
195
+ # Never let a store failure break the wrapped call.
196
+ pass
197
+
198
+ # Attach the recall to a dict result so callers can
199
+ # surface "I knew this because ..." in the UI. Plain
200
+ # string/int/float returns stay untouched.
201
+ if isinstance(result, dict) and recall_hits and not injected:
202
+ result.setdefault("memories", recall_hits)
203
+ return result
204
+
205
+ # Friendly metadata on the wrapper so ``help()`` is useful.
206
+ _wrapper.__wrapped_agent_memory__ = True # type: ignore[attr-defined]
207
+ return _wrapper
208
+
209
+ return _decorator
210
+
211
+
212
+ def remember(
213
+ store: MemoryStore,
214
+ text: str,
215
+ *,
216
+ kind: str = "fact",
217
+ importance: float = 0.7,
218
+ tags: list[str] | None = None,
219
+ agent_id: str | None = None,
220
+ user_id: str | None = None,
221
+ ) -> str:
222
+ """Insert ``text`` into the store as a single memory and return its id.
223
+
224
+ Sugar for callers that want to seed memory at import time without
225
+ having to instantiate ``StoredMemory`` / ``upsert_memory``:
226
+
227
+ >>> store = MemoryStore("loop.db")
228
+ >>> remember(store, "User prefers dark mode",
229
+ ... tags=["ui", "preference"], agent_id="codex")
230
+ """
231
+ if not isinstance(store, MemoryStore):
232
+ raise TypeError(
233
+ f"remember() requires a MemoryStore, got {type(store).__name__}"
234
+ )
235
+ item = store.upsert_memory(
236
+ kind=kind, text=text, importance=importance,
237
+ tags=tags or ["remember"], agent_id=agent_id, user_id=user_id,
238
+ source="remember",
239
+ )
240
+ return item.id
241
+
242
+
243
+ __all__ = ["agent_memory", "remember"]
@@ -387,6 +387,15 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None,
387
387
  bump: int = 1, source: str | None = None,
388
388
  mode: str = "hybrid", level: int = 1,
389
389
  as_of: str | None = None,
390
+ # Audit 2026-10-18: bi-temporal *validity-window* filter
391
+ # -- distinct from ``as_of`` (which routes through the
392
+ # supersession-chain ``recall_as_of()``). When set, the
393
+ # standard ``recall()`` / ``recall_hybrid()`` path drops
394
+ # memories whose ``valid_at > validity_at`` or
395
+ # ``invalid_at <= validity_at``. Complements ``as_of`` for
396
+ # callers that want "show me what's currently in its
397
+ # validity window" without walking the supersession chain.
398
+ validity_at: str | None = None,
390
399
  max_chars: int | None = None):
391
400
  """Unified recall — wiki + memories + entities in one ranked stream.
392
401
 
@@ -450,24 +459,38 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None,
450
459
  except ValueError as e:
451
460
  raise HTTPException(400, str(e))
452
461
  elif mode == "legacy" or not hasattr(store, "recall_hybrid"):
453
- r = store.recall(
454
- query,
455
- limit=limit,
456
- include=wanted,
457
- bump_signals=bool(bump),
458
- source=source,
459
- max_chars=max_chars,
460
- level=level,
461
- )
462
+ try:
463
+ r = store.recall(
464
+ query,
465
+ limit=limit,
466
+ include=wanted,
467
+ bump_signals=bool(bump),
468
+ source=source,
469
+ max_chars=max_chars,
470
+ level=level,
471
+ # Audit 2026-10-18: only pass when caller
472
+ # supplied something so the default-None legacy
473
+ # contract holds.
474
+ as_of_ts=(validity_at if (validity_at and str(validity_at).strip()) else None),
475
+ )
476
+ except ValueError as e:
477
+ # Invalid ``validity_at`` -> 400 (audit 2026-10-18)
478
+ # so the HTTP surface mirrors ``as_of``'s contract.
479
+ raise HTTPException(400, str(e))
462
480
  recall_mode = mode
463
481
  else:
464
- r = store.recall_hybrid(
465
- query, limit=limit, include=wanted,
466
- bump_signals=bool(bump), source=source, level=level,
467
- max_chars=max_chars,
468
- )
482
+ try:
483
+ r = store.recall_hybrid(
484
+ query, limit=limit, include=wanted,
485
+ bump_signals=bool(bump), source=source, level=level,
486
+ max_chars=max_chars,
487
+ as_of_ts=(validity_at if (validity_at and str(validity_at).strip()) else None),
488
+ )
489
+ except ValueError as e:
490
+ raise HTTPException(400, str(e))
469
491
  recall_mode = mode
470
492
  return {
493
+ "validity_at": validity_at,
471
494
  "query": query,
472
495
  "tokens": r.get("tokens", []),
473
496
  "memories": r.get("memories", []),
@@ -169,12 +169,25 @@ def _like_fallback(
169
169
  source_filter: str | None = None,
170
170
  ) -> list:
171
171
  """Brute-force LIKE fallback used when FTS5 trigram can't index
172
- short CJK queries. Searches <substr> against the source table
173
- directly. Returns rows in the same shape as bm25_search.
172
+ a query -- short strings, all-CJK with <3 chars, or the FTS5
173
+ ``MATCH`` returned 0 rows because quoted exact-phrase tokens
174
+ don't exist in the trigram index.
175
+
176
+ Audit 2026-10-18: now tokenises the query and OR-LIKEs every
177
+ token. The legacy whole-string LIKE is still tried first so
178
+ existing callers that depend on ``"%deploy process%"`` semantics
179
+ (e.g. ``recall_paths`` callers) get byte-identical results. When
180
+ the whole-string LIKE returns nothing we fall through to the
181
+ per-token OR pass which is strictly more lenient -- a multi-word
182
+ question like "What is the deploy process?" now matches the
183
+ stored fact "Deploy process uses blue/green" because "deploy"
184
+ appears, even though the exact substring "What is the deploy
185
+ process" does not.
174
186
  """
175
- pat = f"%{(query or '').strip()}%"
176
- if not query.strip():
187
+ q = (query or "").strip()
188
+ if not q:
177
189
  return []
190
+ pat = f"%{q}%"
178
191
  with store._conn() as c:
179
192
  if kind == "memories":
180
193
  sql = (
@@ -182,7 +195,22 @@ def _like_fallback(
182
195
  "FROM memories m WHERE LOWER(text) LIKE LOWER(?) "
183
196
  "ORDER BY m.importance DESC, m.created_at DESC LIMIT ?"
184
197
  )
185
- return [dict(r) for r in c.execute(sql, (pat, limit)).fetchall()]
198
+ whole = [dict(r) for r in c.execute(sql, (pat, limit)).fetchall()]
199
+ if whole:
200
+ return whole
201
+ # Per-token OR pass -- at least one query word must hit.
202
+ import re as _re
203
+ tokens = [t for t in _re.findall(r"[A-Za-z0-9]+", q.lower()) if len(t) >= 2]
204
+ if not tokens:
205
+ return whole
206
+ placeholders = " OR ".join("LOWER(text) LIKE LOWER(?)" for _ in tokens)
207
+ params = [f"%{t}%" for t in tokens]
208
+ sql_t = (
209
+ "SELECT m.id AS id, m.importance AS importance "
210
+ f"FROM memories m WHERE {placeholders} "
211
+ "ORDER BY m.importance DESC, m.created_at DESC LIMIT ?"
212
+ )
213
+ return [dict(r) for r in c.execute(sql_t, (*params, limit)).fetchall()]
186
214
  sql = (
187
215
  "SELECT w.id AS id, w.importance AS importance, w.scope AS scope "
188
216
  "FROM wiki_pages w "
@@ -190,7 +218,24 @@ def _like_fallback(
190
218
  "ORDER BY w.importance DESC, w.updated_at DESC LIMIT ?"
191
219
  )
192
220
  rows = list(c.execute(sql, (pat, pat, limit * 3)).fetchall())
193
- return _filter_wiki_scope(rows, source_filter)[:limit]
221
+ rows = _filter_wiki_scope(rows, source_filter)[:limit]
222
+ if rows:
223
+ return rows
224
+ import re as _re
225
+ tokens = [t for t in _re.findall(r"[A-Za-z0-9]+", q.lower()) if len(t) >= 2]
226
+ if not tokens:
227
+ return rows
228
+ title_parts = " OR ".join("LOWER(w.title) LIKE LOWER(?)" for _ in tokens)
229
+ body_parts = " OR ".join("LOWER(w.body) LIKE LOWER(?)" for _ in tokens)
230
+ params = [f"%{t}%" for t in tokens] * 2
231
+ sql_t = (
232
+ "SELECT w.id AS id, w.importance AS importance, w.scope AS scope "
233
+ "FROM wiki_pages w "
234
+ f"WHERE ({title_parts}) OR ({body_parts}) "
235
+ "ORDER BY w.importance DESC, w.updated_at DESC LIMIT ?"
236
+ )
237
+ rows_t = [dict(r) for r in c.execute(sql_t, (*params, limit * 3)).fetchall()]
238
+ return _filter_wiki_scope(rows_t, source_filter)[:limit]
194
239
 
195
240
 
196
241
  def _filter_wiki_scope(rows: Iterable, source: str | None) -> list:
@@ -238,6 +283,50 @@ def fuse_rrf(
238
283
  return [{"id": i, "_rrf": s} for i, s in sorted(fused.items(), key=lambda kv: -kv[1])]
239
284
 
240
285
 
286
+ def fuse_rrf_with_channels(
287
+ ranked_lists_with_labels: list[tuple[str, list[dict]]],
288
+ k: int = DEFAULT_RRF_K,
289
+ ) -> list[dict]:
290
+ """Reciprocal Rank Fusion that also returns a per-channel score
291
+ breakdown (audit 2026-10-18).
292
+
293
+ ``ranked_lists_with_labels`` is a list of ``(channel, list)``
294
+ pairs -- e.g. ``[("bm25", bm25_mem), ("semantic", sem_mem),
295
+ ("entity", ent_mem)]``. Each ``list`` is a ranked list of
296
+ ``{"id": ..., "_score": ...}`` already sorted by descending
297
+ score.
298
+
299
+ The returned list carries one dict per distinct document::
300
+
301
+ {"id": id, "_rrf": float, "_channel_scores": {channel: float}}
302
+
303
+ ``_channel_scores`` is the raw score each channel assigned to the
304
+ document (None when the channel did not surface it). It powers the
305
+ evidence chain that the rest of loop-memory attaches to each
306
+ recall hit -- so a UI can render "this hit because BM25 (1.0)
307
+ + entity (0.7)" instead of a black-box RRF total.
308
+
309
+ RRF semantics are identical to ``fuse_rrf``: this is the same
310
+ algorithm with extra metadata. The 0-dep property is preserved
311
+ (pure Python).
312
+ """
313
+ fused: dict[str, float] = {}
314
+ raw_scores: dict[str, dict[str, float]] = {}
315
+ for channel, lst in ranked_lists_with_labels:
316
+ for rank, item in enumerate(lst, start=1):
317
+ doc_id = item["id"]
318
+ fused[doc_id] = fused.get(doc_id, 0.0) + 1.0 / (k + rank)
319
+ raw_scores.setdefault(doc_id, {})[channel] = float(item.get("_score") or 0.0)
320
+ out = []
321
+ for doc_id, total in sorted(fused.items(), key=lambda kv: -kv[1]):
322
+ out.append({
323
+ "id": doc_id,
324
+ "_rrf": total,
325
+ "_channel_scores": raw_scores.get(doc_id, {}),
326
+ })
327
+ return out
328
+
329
+
241
330
  # ----------------------------------------------------------------------
242
331
  # Temporal reasoning layer
243
332
  # ----------------------------------------------------------------------