loop-memory 0.4.9__tar.gz → 0.4.10__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 (153) hide show
  1. {loop_memory-0.4.9 → loop_memory-0.4.10}/PKG-INFO +16 -1
  2. {loop_memory-0.4.9 → loop_memory-0.4.10}/README.md +15 -0
  3. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/jobs/evolution.py +14 -0
  4. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/jobs/graph.py +45 -29
  5. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/jobs/llm_consolidate.py +24 -0
  6. loop_memory-0.4.10/loop_memory/serve/routes/_shared.py +196 -0
  7. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/serve/routes/export.py +30 -5
  8. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/serve/routes/system.py +8 -2
  9. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory.egg-info/PKG-INFO +16 -1
  10. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory.egg-info/SOURCES.txt +3 -0
  11. {loop_memory-0.4.9 → loop_memory-0.4.10}/pyproject.toml +1 -1
  12. loop_memory-0.4.10/tests/test_cache_cap_2026_09_23.py +84 -0
  13. loop_memory-0.4.10/tests/test_path_safety_2026_09_23.py +233 -0
  14. loop_memory-0.4.10/tests/test_subgraph_hops_2026_09_23.py +102 -0
  15. loop_memory-0.4.9/loop_memory/serve/routes/_shared.py +0 -59
  16. {loop_memory-0.4.9 → loop_memory-0.4.10}/LICENSE +0 -0
  17. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/__init__.py +0 -0
  18. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/backends/__init__.py +0 -0
  19. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/backends/embedding.py +0 -0
  20. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/backends/sentence_embedder.py +0 -0
  21. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/backends/vector_store.py +0 -0
  22. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/cli/__init__.py +0 -0
  23. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/cli/_common.py +0 -0
  24. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/cli/commands/__init__.py +0 -0
  25. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/cli/commands/cognitive.py +0 -0
  26. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/cli/commands/diag.py +0 -0
  27. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/cli/commands/graph.py +0 -0
  28. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/cli/commands/hooks.py +0 -0
  29. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/cli/commands/read.py +0 -0
  30. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/cli/commands/rules.py +0 -0
  31. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/cli/commands/serve.py +0 -0
  32. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/cli/commands/write.py +0 -0
  33. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/cli/main.py +0 -0
  34. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/engine/__init__.py +0 -0
  35. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/engine/loop.py +0 -0
  36. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/engine/reflect.py +0 -0
  37. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/examples/__init__.py +0 -0
  38. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/examples/demo.py +0 -0
  39. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/export/__init__.py +0 -0
  40. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/export/memory_md.py +0 -0
  41. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/graph/__init__.py +0 -0
  42. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/graph/build.py +0 -0
  43. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/graph/extract.py +0 -0
  44. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/ingest/__init__.py +0 -0
  45. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/ingest/loader.py +0 -0
  46. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/ingest/pipeline.py +0 -0
  47. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/jobs/__init__.py +0 -0
  48. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/jobs/cognitive.py +0 -0
  49. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/jobs/compact.py +0 -0
  50. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/jobs/consolidate.py +0 -0
  51. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/jobs/contradiction.py +0 -0
  52. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/jobs/llm_compact_pass.py +0 -0
  53. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/jobs/scheduler.py +0 -0
  54. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/llm/__init__.py +0 -0
  55. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/llm/base.py +0 -0
  56. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/llm/openai_adapter.py +0 -0
  57. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/llm/providers.py +0 -0
  58. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/mcp/__init__.py +0 -0
  59. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/memory/__init__.py +0 -0
  60. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/memory/types.py +0 -0
  61. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/privacy/__init__.py +0 -0
  62. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/privacy/private.py +0 -0
  63. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/privacy/redact.py +0 -0
  64. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/py.typed +0 -0
  65. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/sdk.py +0 -0
  66. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/sdk_extensions.py +0 -0
  67. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/security/__init__.py +0 -0
  68. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/security/secrets.py +0 -0
  69. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/serve/__init__.py +0 -0
  70. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/serve/app.py +0 -0
  71. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/serve/handlers.py +0 -0
  72. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/serve/routes/admin.py +0 -0
  73. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/serve/routes/cognitive.py +0 -0
  74. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/serve/routes/graph.py +0 -0
  75. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/serve/routes/insights.py +0 -0
  76. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/serve/routes/memories.py +0 -0
  77. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/serve/routes/sessions.py +0 -0
  78. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/serve/routes/wiki.py +0 -0
  79. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/serve/static/__init__.py +0 -0
  80. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/serve/static/index.html +0 -0
  81. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/serve/watcher.py +0 -0
  82. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/storage/__init__.py +0 -0
  83. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/storage/retrieval.py +0 -0
  84. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/storage/snapshot.py +0 -0
  85. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/storage/sqlite_store.py +0 -0
  86. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/wiki/__init__.py +0 -0
  87. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/wiki/backfill.py +0 -0
  88. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/wiki/classifier.py +0 -0
  89. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/wiki/prompts.py +0 -0
  90. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory/wiki/scope.py +0 -0
  91. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory.egg-info/dependency_links.txt +0 -0
  92. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory.egg-info/entry_points.txt +0 -0
  93. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory.egg-info/requires.txt +0 -0
  94. {loop_memory-0.4.9 → loop_memory-0.4.10}/loop_memory.egg-info/top_level.txt +0 -0
  95. {loop_memory-0.4.9 → loop_memory-0.4.10}/setup.cfg +0 -0
  96. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_admin_ingest_route.py +0 -0
  97. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_agent_memory_api.py +0 -0
  98. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_agent_memory_sdk.py +0 -0
  99. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_agents_init.py +0 -0
  100. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_auth_token_rotate.py +0 -0
  101. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_cli_audit_supersede.py +0 -0
  102. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_cli_export_okf.py +0 -0
  103. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_cli_init.py +0 -0
  104. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_cli_memory_stats.py +0 -0
  105. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_cli_recall_as_of.py +0 -0
  106. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_cli_recall_paths.py +0 -0
  107. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_cli_recall_verbose.py +0 -0
  108. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_cli_rules.py +0 -0
  109. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_cli_snapshot.py +0 -0
  110. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_cli_v7.py +0 -0
  111. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_cli_version.py +0 -0
  112. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_contradictions.py +0 -0
  113. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_evolution.py +0 -0
  114. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_evolution_quality.py +0 -0
  115. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_expanduser.py +0 -0
  116. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_export_ask.py +0 -0
  117. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_export_okf.py +0 -0
  118. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_graph.py +0 -0
  119. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_ingest.py +0 -0
  120. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_llm_consolidator.py +0 -0
  121. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_llm_fingerprint_not_persisted.py +0 -0
  122. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_llm_providers.py +0 -0
  123. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_llm_test_endpoint.py +0 -0
  124. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_loop.py +0 -0
  125. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_mcp.py +0 -0
  126. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_memories_pagination.py +0 -0
  127. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_memory_stats.py +0 -0
  128. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_openclaw_loader.py +0 -0
  129. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_package_version.py +0 -0
  130. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_recall_as_of.py +0 -0
  131. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_recall_paths.py +0 -0
  132. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_recall_provenance.py +0 -0
  133. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_reflection.py +0 -0
  134. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_repo_framing.py +0 -0
  135. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_routes_audit_2026_09_13.py +0 -0
  136. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_routes_audit_2026_09_20.py +0 -0
  137. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_score_api.py +0 -0
  138. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_scoring_v2.py +0 -0
  139. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_secrets.py +0 -0
  140. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_serve_app.py +0 -0
  141. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_serve_handlers.py +0 -0
  142. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_session_order.py +0 -0
  143. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_short_query.py +0 -0
  144. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_snapshot.py +0 -0
  145. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_store.py +0 -0
  146. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_summarization.py +0 -0
  147. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_supersession_chain.py +0 -0
  148. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_universal_memory.py +0 -0
  149. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_vector_store.py +0 -0
  150. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_watcher.py +0 -0
  151. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_wiki_classifier.py +0 -0
  152. {loop_memory-0.4.9 → loop_memory-0.4.10}/tests/test_wiki_export_escape.py +0 -0
  153. {loop_memory-0.4.9 → loop_memory-0.4.10}/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.9
3
+ Version: 0.4.10
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,21 @@ Dynamic: license-file
66
66
 
67
67
  ---
68
68
 
69
+ > **What's new in 0.4.10** — **path-safety guard** for the
70
+ five export / snapshot endpoints. ``MemoryStore`` already
71
+ validated query / wiki content; the new
72
+ ``_safe_resolve_path`` helper (audit 2026-09-23) closes the
73
+ remaining path-traversal vector by refusing any path that
74
+ points at the live SQLite store, a system directory
75
+ (``/etc`` / ``/usr`` / ``/System``), or a sensitive dotfile
76
+ (``~/.ssh`` / ``~/.aws`` / ``~/.gnupg``). Also in this
77
+ release: a hard cap on the in-process LLM response cache
78
+ (was unbounded; now 256 entries with FIFO eviction) and a
79
+ fix for ``subgraph_for(max_hops=...)`` which used to ignore
80
+ the argument and always walk one hop (now BFS up to N hops,
81
+ hard-capped at 8). 29 new regression cases. [Full
82
+ CHANGELOG →](./CHANGELOG.md#0410---2026-09-23)
83
+
69
84
  > **What's new in 0.4.9** — **OKF v0.2 export** + **bi-temporal `as_of` recall**. `loop-memory export-okf <dir>` writes an [Open Knowledge Format](https://github.com/okf/SPEC) v0.2 bundle — one `.md` file per wiki page with YAML frontmatter + an `index.md` index — so a Loop Memory store can be handed to any OKF-aware tool (Grep, Obsidian, akitaonrails/ai-memory, okf-agent-memory, ...) without re-parsing the body (audit 2026-09-20, Google OKF v0.2 spec + akitaonrails/ai-memory 2.0 + okf-memory/okf-agent-memory, all Apache-2.0/MIT, 2026-09-01 → 2026-09-06). `loop-memory recall <q> --as-of <ISO|epoch>` answers the bi-temporal question "what did we know about this query at that moment?" — a memory written after the moment is invisible, and a superseded memory walks its chain to find whether the invalidation had happened by then (audit 2026-09-20, loomcycle v1.33-v1.49 RFCs BL/BS/BU/BV/BW, Apache-2.0). Both shapes ship with HTTP routes (`POST /api/export/okf`, `GET /api/recall?as_of=...`). 34 new regression cases pin both shapes. [Full CHANGELOG →](./CHANGELOG.md#049---2026-09-20)
70
85
 
71
86
  > **What's new in 0.4.8** — **L0 outline recall** +
@@ -22,6 +22,21 @@
22
22
 
23
23
  ---
24
24
 
25
+ > **What's new in 0.4.10** — **path-safety guard** for the
26
+ five export / snapshot endpoints. ``MemoryStore`` already
27
+ validated query / wiki content; the new
28
+ ``_safe_resolve_path`` helper (audit 2026-09-23) closes the
29
+ remaining path-traversal vector by refusing any path that
30
+ points at the live SQLite store, a system directory
31
+ (``/etc`` / ``/usr`` / ``/System``), or a sensitive dotfile
32
+ (``~/.ssh`` / ``~/.aws`` / ``~/.gnupg``). Also in this
33
+ release: a hard cap on the in-process LLM response cache
34
+ (was unbounded; now 256 entries with FIFO eviction) and a
35
+ fix for ``subgraph_for(max_hops=...)`` which used to ignore
36
+ the argument and always walk one hop (now BFS up to N hops,
37
+ hard-capped at 8). 29 new regression cases. [Full
38
+ CHANGELOG →](./CHANGELOG.md#0410---2026-09-23)
39
+
25
40
  > **What's new in 0.4.9** — **OKF v0.2 export** + **bi-temporal `as_of` recall**. `loop-memory export-okf <dir>` writes an [Open Knowledge Format](https://github.com/okf/SPEC) v0.2 bundle — one `.md` file per wiki page with YAML frontmatter + an `index.md` index — so a Loop Memory store can be handed to any OKF-aware tool (Grep, Obsidian, akitaonrails/ai-memory, okf-agent-memory, ...) without re-parsing the body (audit 2026-09-20, Google OKF v0.2 spec + akitaonrails/ai-memory 2.0 + okf-memory/okf-agent-memory, all Apache-2.0/MIT, 2026-09-01 → 2026-09-06). `loop-memory recall <q> --as-of <ISO|epoch>` answers the bi-temporal question "what did we know about this query at that moment?" — a memory written after the moment is invisible, and a superseded memory walks its chain to find whether the invalidation had happened by then (audit 2026-09-20, loomcycle v1.33-v1.49 RFCs BL/BS/BU/BV/BW, Apache-2.0). Both shapes ship with HTTP routes (`POST /api/export/okf`, `GET /api/recall?as_of=...`). 34 new regression cases pin both shapes. [Full CHANGELOG →](./CHANGELOG.md#049---2026-09-20)
26
41
 
27
42
  > **What's new in 0.4.8** — **L0 outline recall** +
@@ -684,6 +684,11 @@ class EvolutionConsolidator:
684
684
  self._cache: dict[str, str] = {}
685
685
  self._cache_ttl = 300.0
686
686
  self._cache_ts: dict[str, float] = {}
687
+ # Audit 2026-09-23: cap the in-process LLM response cache so a
688
+ # long-running evolution pass cannot leak memory. When the cache
689
+ # exceeds ``_MAX_CACHE_SIZE`` we evict the oldest entries (FIFO
690
+ # via ``_cache_ts``; an LRU variant would also be fine).
691
+ self._MAX_CACHE_SIZE = 256
687
692
  self._run_id: str | None = None
688
693
 
689
694
  # --- public ----------------------------------------------------------
@@ -1998,6 +2003,15 @@ class EvolutionConsolidator:
1998
2003
  return ""
1999
2004
  self._cache[cache_key] = reply
2000
2005
  self._cache_ts[cache_key] = now
2006
+ # Bound the cache: drop oldest entries when we exceed the cap.
2007
+ # Cheap O(n) walk; the cache holds at most _MAX_CACHE_SIZE so
2008
+ # the walk is bounded too.
2009
+ if len(self._cache) > self._MAX_CACHE_SIZE:
2010
+ ordered = sorted(self._cache_ts.items(), key=lambda kv: kv[1])
2011
+ evict_count = len(self._cache) - self._MAX_CACHE_SIZE
2012
+ for old_key, _ in ordered[:evict_count]:
2013
+ self._cache.pop(old_key, None)
2014
+ self._cache_ts.pop(old_key, None)
2001
2015
  if kind == "cluster":
2002
2016
  stats.cluster_calls += 1
2003
2017
  elif kind == "wiki":
@@ -337,46 +337,62 @@ def subgraph_for(
337
337
  ) -> Subgraph:
338
338
  """Build a small subgraph relevant to ``query``.
339
339
 
340
- 1. Extract entities from the query.
341
- 2. Pull every relation (1 hop) that touches a query entity.
342
- 3. Look up the backing memory ids for each entity.
340
+ 1. Extract entities from the query (seed set).
341
+ 2. BFS up to ``max_hops`` edges from the seed set, walking
342
+ ``store.related_entities`` at each layer.
343
+ 3. Look up the backing memory ids for each entity seen.
343
344
  4. Trim to ``max_nodes`` / ``max_edges``.
344
345
 
346
+ Audit 2026-09-23: previously the function ignored ``max_hops``
347
+ and always walked exactly one hop. The default is unchanged
348
+ (1 hop) so existing callers see no behaviour delta; callers
349
+ that explicitly pass ``max_hops >= 2`` now get the multi-hop
350
+ walk they asked for. ``max_hops`` is hard-capped at 8 so a typo
351
+ cannot fan out across the whole graph.
352
+
345
353
  Returns a :class:`Subgraph` dataclass so callers can render it
346
354
  however they like (Mermaid, JSON for the SDK, etc.).
347
355
  """
348
356
  from ..graph.extract import extract_entities
349
- ents = [n for (n, _) in extract_entities(query) if n]
350
- if not ents:
357
+ max_hops = max(1, min(int(max_hops or 1), 8))
358
+ seed_ents = [n for (n, _) in extract_entities(query) if n]
359
+ if not seed_ents:
351
360
  return Subgraph(nodes=[], edges=[], memory_ids=[])
352
361
  nodes: dict[str, dict[str, Any]] = {}
353
362
  edges: list[dict[str, Any]] = []
354
363
  memory_ids: set[str] = set()
355
- for ent in ents:
356
- info = store.entity_by_name(ent)
357
- if info:
358
- nodes[ent] = {
359
- "name": ent,
360
- "kind": info.get("kind") or "concept",
361
- "weight": float(info.get("weight") or 0),
362
- "mention_count": int(info.get("mention_count") or 0),
363
- }
364
- else:
365
- nodes[ent] = {"name": ent, "kind": "concept", "weight": 0.5, "mention_count": 0}
366
- for nb in store.related_entities(ent, limit=32):
367
- if nb not in nodes:
368
- nb_info = store.entity_by_name(nb) or {}
369
- nodes[nb] = {
370
- "name": nb,
371
- "kind": nb_info.get("kind") or "concept",
372
- "weight": float(nb_info.get("weight") or 0),
373
- "mention_count": int(nb_info.get("mention_count") or 0),
374
- }
375
- edge = {"src": ent, "dst": nb, "kind": "related"}
376
- if edge not in edges:
377
- edges.append(edge)
378
- for mid in store.memory_ids_for_entity(ent, limit=32):
364
+
365
+ def _record_node(name: str) -> None:
366
+ if name in nodes:
367
+ return
368
+ info = store.entity_by_name(name) or {}
369
+ nodes[name] = {
370
+ "name": name,
371
+ "kind": info.get("kind") or "concept",
372
+ "weight": float(info.get("weight") or 0),
373
+ "mention_count": int(info.get("mention_count") or 0),
374
+ }
375
+ for mid in store.memory_ids_for_entity(name, limit=32):
379
376
  memory_ids.add(mid)
377
+
378
+ frontier = list(seed_ents)
379
+ for ent in frontier:
380
+ _record_node(ent)
381
+ seen_edge: set[tuple[str, str]] = set()
382
+ for _ in range(max_hops):
383
+ if not frontier:
384
+ break
385
+ next_frontier: list[str] = []
386
+ for ent in frontier:
387
+ for nb in store.related_entities(ent, limit=32):
388
+ _record_node(nb)
389
+ key = (ent, nb)
390
+ if key in seen_edge:
391
+ continue
392
+ seen_edge.add(key)
393
+ edges.append({"src": ent, "dst": nb, "kind": "related"})
394
+ next_frontier.append(nb)
395
+ frontier = next_frontier
380
396
  # Trim
381
397
  n_nodes = list(nodes.values())[:max_nodes]
382
398
  n_edges = edges[:max_edges]
@@ -335,6 +335,10 @@ class LLMConsolidator:
335
335
  self._cache: dict[str, str] = {} # batch hash -> LLM reply
336
336
  self._cache_ttl = 300.0
337
337
  self._cache_ts: dict[str, float] = {}
338
+ # Audit 2026-09-23: cap the in-process LLM response cache so a
339
+ # long-running consolidation pass cannot leak memory. Same FIFO
340
+ # eviction as ``EvolutionRunner._MAX_CACHE_SIZE``.
341
+ self._MAX_CACHE_SIZE = 256
338
342
 
339
343
  # --- public -----------------------------------------------------------
340
344
 
@@ -620,6 +624,16 @@ class LLMConsolidator:
620
624
  ) or ""
621
625
  self._cache[cache_key] = reply
622
626
  self._cache_ts[cache_key] = now
627
+ # Bound the cache: drop oldest entries when we exceed
628
+ # the cap (FIFO via _cache_ts).
629
+ if len(self._cache) > self._MAX_CACHE_SIZE:
630
+ ordered = sorted(
631
+ self._cache_ts.items(), key=lambda kv: kv[1]
632
+ )
633
+ evict_count = len(self._cache) - self._MAX_CACHE_SIZE
634
+ for old_key, _ in ordered[:evict_count]:
635
+ self._cache.pop(old_key, None)
636
+ self._cache_ts.pop(old_key, None)
623
637
  stats.llm_calls += 1
624
638
  except Exception as e:
625
639
  stats.notes.append(f"wiki llm error: {type(e).__name__}: {e}")
@@ -863,6 +877,16 @@ class LLMConsolidator:
863
877
  stats.llm_calls += 1
864
878
  self._cache[cache_key] = reply
865
879
  self._cache_ts[cache_key] = now
880
+ # Bound the cache: drop oldest entries when we exceed
881
+ # the cap (FIFO via _cache_ts).
882
+ if len(self._cache) > self._MAX_CACHE_SIZE:
883
+ ordered = sorted(
884
+ self._cache_ts.items(), key=lambda kv: kv[1]
885
+ )
886
+ evict_count = len(self._cache) - self._MAX_CACHE_SIZE
887
+ for old_key, _ in ordered[:evict_count]:
888
+ self._cache.pop(old_key, None)
889
+ self._cache_ts.pop(old_key, None)
866
890
  except Exception as e:
867
891
  log.warning("LLM call failed: %s", e)
868
892
  stats.notes.append(f"llm error in batch: {type(e).__name__}: {e}")
@@ -0,0 +1,196 @@
1
+ """Helpers shared across route modules.
2
+
3
+ The original ``serve/app.py`` (3226 lines) had every route defined as a
4
+ closure inside ``create_app``. To keep the central file small (audit O1)
5
+ we lifted each route group into its own ``routes/<bucket>.py`` module
6
+ and emit ``register(app, store, scheduler=None)``. Closures over
7
+ ``_memory_to_dict`` and ``_export_safe_segment`` still work because
8
+ both are imported here under the same names.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Any, Optional
15
+
16
+ from ..handlers import (
17
+ memory_to_dict as _memory_to_dict, # noqa: F401
18
+ session_to_dict as _session_to_dict, # noqa: F401
19
+ )
20
+
21
+
22
+
23
+
24
+ _THINK_RE = re.compile(r"<(think|reasoning)>(.*?)</\1>", re.DOTALL)
25
+
26
+
27
+ def _split_think(text: str) -> tuple[str, str]:
28
+ """Pull LLM thinking/reasoning blocks out of a model reply.
29
+
30
+ Several providers (MiniMax reasoning, Anthropic extended thinking)
31
+ emit a ``<think>...</think>`` or ``<reasoning>...</reasoning>``
32
+ block before the user-visible answer. We surface the block under a
33
+ separate field so the UI can render the report as Markdown while
34
+ still letting power users peek at the chain-of-thought in a
35
+ collapsed ``<details>``.
36
+ """
37
+ if not text:
38
+ return "", ""
39
+ thinking_parts = _THINK_RE.findall(text)
40
+ cleaned = _THINK_RE.sub("", text).strip()
41
+ thinking = "\n\n".join(p[1].strip() for p in thinking_parts if p[1].strip())
42
+ return cleaned, thinking
43
+
44
+
45
+ def _export_safe_segment(text: str, *, fallback: str = "", kind: str = "line") -> str:
46
+ """Sanitise a user-controlled wiki field for the markdown export.
47
+
48
+ Audit M2: a malicious title ``"My\\n## Pwned"`` would otherwise
49
+ create a second ``## Pwned`` heading on re-import. ``title`` /
50
+ ``summary`` fields are collapsed to a single line and stripped of
51
+ any leading ``## `` prefix; body content (legitimate markdown) is
52
+ left alone.
53
+ """
54
+ if not text:
55
+ return fallback
56
+ s = text
57
+ if kind in ("title", "summary"):
58
+ s = re.sub(r"\s+", " ", s).strip()
59
+ s = re.sub(r"^#+\s*", "", s).strip()
60
+ return s or fallback
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Path safety (audit 2026-09-23)
65
+ # ---------------------------------------------------------------------------
66
+ # Five export / snapshot endpoints accept a user-supplied absolute
67
+ # path. Before this helper shipped, every endpoint passed the path
68
+ # straight through to ``Path(...).expanduser().resolve()`` with no
69
+ # validation, which meant a caller could:
70
+ #
71
+ # * write into ``/`` or ``~/.ssh`` via ``out_dir``
72
+ # * overwrite the live SQLite store by setting
73
+ # ``out_path`` to ``~/.loop_memory/loop_memory.db`` via
74
+ # ``POST /api/snapshot``
75
+ # * read any path with a valid snapshot header via
76
+ # ``POST /api/snapshot/restore``
77
+ #
78
+ # When the bearer token is unset (the local-only default) the auth
79
+ # middleware does not enforce auth at all, so any process on the same
80
+ # host — including a malicious MCP client — could exploit the gap.
81
+ #
82
+ # ``_safe_resolve_path`` closes the gap with three rules:
83
+ #
84
+ # 1. Reject any path that resolves to the live DB (its canonical
85
+ # location, the WAL sidecar, or the SHM sidecar).
86
+ # 2. Reject any path inside a system / sensitive directory
87
+ # (``/System``, ``/usr``, ``/etc``, ``/var``, ``/private``,
88
+ # ``~/.ssh``, ``~/.aws``, ``~/.gnupg``). Each rule covers a real
89
+ # exfiltration / corruption vector observed in the wild for local
90
+ # AI tooling.
91
+ # 3. Require the parent directory to exist for writes (so a typo
92
+ # doesn't silently materialise a path that later resolves into
93
+ # a sensitive root via symlink).
94
+ #
95
+ # ``kind="read"`` skips rule 3 (a read can describe a non-existent
96
+ # file) but still applies rules 1 and 2.
97
+
98
+ _FORBIDDEN_PREFIXES = (
99
+ "/etc", "/private/etc", "/var", "/private/var",
100
+ "/usr", "/bin", "/sbin", "/System", "/Library",
101
+ "/private/var/db", "/private/var/log",
102
+ "/private/var/root", "/private/var/run",
103
+ "/var/db", "/var/log", "/var/run",
104
+ "/dev", "/proc", "/sys",
105
+ )
106
+ # macOS resolves ``/tmp`` to ``/private/var/folders/<user>/T/`` and
107
+ # shells routinely write there. We allow the per-user temp subtree
108
+ # even though it starts with the otherwise-forbidden ``/private/var``.
109
+ _ALLOWED_TEMP_SUBSTRINGS = (
110
+ "/var/folders/", # macOS per-user temp
111
+ "/var/tmp/", # BSD/Linux preserved temp
112
+ "/tmp/", # Linux /tmp + macOS /private/tmp
113
+ "/private/tmp/", # macOS /tmp
114
+ "/private/var/folders/", # macOS per-user temp (resolved)
115
+ )
116
+
117
+
118
+ def _safe_resolve_path(
119
+ raw: str | Path,
120
+ *,
121
+ kind: str = "write",
122
+ live_db_path: str | Path | None = None,
123
+ ) -> Path:
124
+ """Return an absolute, validated Path or raise ``ValueError``.
125
+
126
+ ``kind`` is ``"write"`` (default) or ``"read"``. Writes also
127
+ require the parent directory to exist; reads don't.
128
+
129
+ ``live_db_path`` is the canonical SQLite path the running store
130
+ owns. If a caller hands us that path (or the WAL/SHM sidecars),
131
+ we refuse loudly instead of silently overwriting the live store.
132
+ """
133
+ if raw is None or (isinstance(raw, str) and not raw.strip()):
134
+ raise ValueError("path is required")
135
+ try:
136
+ resolved = Path(str(raw)).expanduser().resolve(strict=False)
137
+ except (OSError, RuntimeError) as e:
138
+ raise ValueError(f"could not resolve path: {e}") from e
139
+ s = str(resolved)
140
+
141
+ # Rule 1 — refuse the live DB + its WAL/SHM sidecars.
142
+ if live_db_path is not None:
143
+ try:
144
+ live_resolved = Path(str(live_db_path)).expanduser().resolve(strict=False)
145
+ except (OSError, RuntimeError):
146
+ live_resolved = None
147
+ if live_resolved is not None:
148
+ live_str = str(live_resolved)
149
+ for suffix in ("", "-wal", "-shm", "-journal"):
150
+ if s == live_str + suffix:
151
+ raise ValueError(
152
+ "refusing to write to the live SQLite store; "
153
+ "use a different path (e.g. ~/.loop_memory/snapshots/)"
154
+ )
155
+
156
+ # Rule 2 — refuse system / sensitive directories.
157
+ # Allow common per-user temp directories before applying the
158
+ # forbidden-prefix rule so ``/tmp`` (which resolves to
159
+ # ``/private/var/folders/...`` on macOS) still works.
160
+ is_temp = any(sub in s for sub in _ALLOWED_TEMP_SUBSTRINGS)
161
+ if not is_temp:
162
+ for prefix in _FORBIDDEN_PREFIXES:
163
+ if s == prefix.rstrip("/") or s.startswith(prefix + "/"):
164
+ raise ValueError(
165
+ f"refusing to {'write to' if kind == 'write' else 'read from'} "
166
+ f"system directory {prefix!r}"
167
+ )
168
+ home = Path.home()
169
+ home_str = str(home)
170
+ for sensitive in (".ssh", ".aws", ".gnupg", ".config/gh", ".npmrc", ".zsh_history", ".bash_history"):
171
+ forbidden = home_str + "/" + sensitive
172
+ if s == forbidden or s.startswith(forbidden + "/"):
173
+ raise ValueError(
174
+ f"refusing to {'write to' if kind == 'write' else 'read from'} "
175
+ f"sensitive path {sensitive!r}"
176
+ )
177
+
178
+ # Rule 3 — writes require an existing parent (catches typos that
179
+ # could later resolve into a sensitive root via symlink).
180
+ if kind == "write":
181
+ parent = resolved.parent
182
+ if not parent.exists():
183
+ raise ValueError(
184
+ f"parent directory does not exist: {parent} "
185
+ "(create it first; refusing to materialise arbitrary paths)"
186
+ )
187
+
188
+ return resolved
189
+
190
+
191
+ __all__ = [
192
+ "_memory_to_dict",
193
+ "_session_to_dict",
194
+ "_export_safe_segment",
195
+ "_safe_resolve_path",
196
+ ]
@@ -18,21 +18,30 @@ from ...storage.sqlite_store import MemoryStore
18
18
  from ._shared import _memory_to_dict, _export_safe_segment
19
19
 
20
20
 
21
- def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None) -> None:
21
+ def register(app, store, scheduler=None):
22
22
  """Mount every route in this bucket onto ``app``.
23
23
 
24
24
  ``store`` and ``scheduler`` are captured in the route closures so
25
25
  the function bodies stay byte-identical to the pre-split layout.
26
26
  """
27
+ # Audit 2026-09-23: cache the live DB path so each route can hand
28
+ # it to ``_safe_resolve_path`` (rule 1 refuses writes to the live
29
+ # DB). Pre-computing here keeps the per-request path cheap.
30
+ _live_db_path = str(store.path)
31
+ from ._shared import _safe_resolve_path as _safe_path
27
32
  @app.post("/api/v1/export")
28
33
  def v1_export(body: dict):
29
34
  from ...export import export_bundle
30
35
  out_dir = (body.get("out_dir") or "").strip()
31
36
  if not out_dir:
32
37
  raise HTTPException(400, "out_dir is required")
38
+ try:
39
+ safe_dir = _safe_path(out_dir, kind="write", live_db_path=_live_db_path)
40
+ except ValueError as e:
41
+ raise HTTPException(400, str(e))
33
42
  try:
34
43
  r = export_bundle(
35
- store, out_dir,
44
+ store, str(safe_dir),
36
45
  agent_id=body.get("agent_id") or None,
37
46
  user_id=body.get("user_id") or None,
38
47
  scope=body.get("scope") or "global",
@@ -49,9 +58,13 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None)
49
58
  in_dir = (body.get("in_dir") or "").strip()
50
59
  if not in_dir:
51
60
  raise HTTPException(400, "in_dir is required")
61
+ try:
62
+ safe_dir = _safe_path(in_dir, kind="read", live_db_path=_live_db_path)
63
+ except ValueError as e:
64
+ raise HTTPException(400, str(e))
52
65
  try:
53
66
  r = import_bundle(
54
- store, in_dir,
67
+ store, str(safe_dir),
55
68
  agent_id=body.get("agent_id") or None,
56
69
  user_id=body.get("user_id") or None,
57
70
  dry_run=bool(body.get("dry_run", False)),
@@ -68,13 +81,19 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None)
68
81
 
69
82
  Body: ``{"out_path": "/path/to/file.memory.sqlite"}``. Returns
70
83
  the summary dict from ``loop_memory.storage.snapshot.snapshot``.
84
+ Audit 2026-09-23: ``out_path`` is rejected if it points at the
85
+ live store or a system / sensitive directory.
71
86
  """
72
87
  from ...storage.snapshot import snapshot as _snapshot
73
88
  out = (body.get("out_path") or "").strip()
74
89
  if not out:
75
90
  raise HTTPException(400, "out_path is required")
76
91
  try:
77
- return _snapshot(store, out)
92
+ safe_out = _safe_path(out, kind="write", live_db_path=_live_db_path)
93
+ except ValueError as e:
94
+ raise HTTPException(400, str(e))
95
+ try:
96
+ return _snapshot(store, str(safe_out))
78
97
  except Exception as e:
79
98
  raise HTTPException(500, f"snapshot failed: {e}")
80
99
 
@@ -86,13 +105,19 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None)
86
105
  the summary dict from ``loop_memory.storage.snapshot.restore``.
87
106
  Refuses wrong-magic files with a 400 so the dashboard surfaces
88
107
  the error clearly instead of corrupting the live store.
108
+ Audit 2026-09-23: ``in_path`` is rejected if it points at the
109
+ live store or a system / sensitive directory.
89
110
  """
90
111
  from ...storage.snapshot import restore as _restore
91
112
  in_path = (body.get("in_path") or "").strip()
92
113
  if not in_path:
93
114
  raise HTTPException(400, "in_path is required")
94
115
  try:
95
- return _restore(store, in_path)
116
+ safe_in = _safe_path(in_path, kind="read", live_db_path=_live_db_path)
117
+ except ValueError as e:
118
+ raise HTTPException(400, str(e))
119
+ try:
120
+ return _restore(store, str(safe_in))
96
121
  except FileNotFoundError as e:
97
122
  raise HTTPException(404, str(e))
98
123
  except ValueError as e:
@@ -430,8 +430,10 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None,
430
430
  Writes one ``.md`` file per wiki page under
431
431
  ``<out_dir>/pages/<slug>.md`` plus an ``index.md`` index.
432
432
  See ``MemoryStore.export_okf`` for the field-mapping
433
- contract.
433
+ contract. Audit 2026-09-23: ``out_dir`` is rejected if it
434
+ points at the live store or a system / sensitive directory.
434
435
  """
436
+ from ._shared import _safe_resolve_path as _safe_path
435
437
  out_dir = (body.get("out_dir") or "").strip()
436
438
  if not out_dir:
437
439
  raise HTTPException(400, "out_dir is required")
@@ -439,7 +441,11 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None,
439
441
  if scope is not None:
440
442
  scope = str(scope).strip() or None
441
443
  try:
442
- r = store.export_okf(out_dir, scope_filter=scope)
444
+ safe_dir = _safe_path(out_dir, kind="write", live_db_path=str(store.path))
445
+ except ValueError as e:
446
+ raise HTTPException(400, str(e))
447
+ try:
448
+ r = store.export_okf(safe_dir, scope_filter=scope)
443
449
  except Exception as e:
444
450
  raise HTTPException(500, f"export-okf failed: {e}")
445
451
  return r
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loop-memory
3
- Version: 0.4.9
3
+ Version: 0.4.10
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,21 @@ Dynamic: license-file
66
66
 
67
67
  ---
68
68
 
69
+ > **What's new in 0.4.10** — **path-safety guard** for the
70
+ five export / snapshot endpoints. ``MemoryStore`` already
71
+ validated query / wiki content; the new
72
+ ``_safe_resolve_path`` helper (audit 2026-09-23) closes the
73
+ remaining path-traversal vector by refusing any path that
74
+ points at the live SQLite store, a system directory
75
+ (``/etc`` / ``/usr`` / ``/System``), or a sensitive dotfile
76
+ (``~/.ssh`` / ``~/.aws`` / ``~/.gnupg``). Also in this
77
+ release: a hard cap on the in-process LLM response cache
78
+ (was unbounded; now 256 entries with FIFO eviction) and a
79
+ fix for ``subgraph_for(max_hops=...)`` which used to ignore
80
+ the argument and always walk one hop (now BFS up to N hops,
81
+ hard-capped at 8). 29 new regression cases. [Full
82
+ CHANGELOG →](./CHANGELOG.md#0410---2026-09-23)
83
+
69
84
  > **What's new in 0.4.9** — **OKF v0.2 export** + **bi-temporal `as_of` recall**. `loop-memory export-okf <dir>` writes an [Open Knowledge Format](https://github.com/okf/SPEC) v0.2 bundle — one `.md` file per wiki page with YAML frontmatter + an `index.md` index — so a Loop Memory store can be handed to any OKF-aware tool (Grep, Obsidian, akitaonrails/ai-memory, okf-agent-memory, ...) without re-parsing the body (audit 2026-09-20, Google OKF v0.2 spec + akitaonrails/ai-memory 2.0 + okf-memory/okf-agent-memory, all Apache-2.0/MIT, 2026-09-01 → 2026-09-06). `loop-memory recall <q> --as-of <ISO|epoch>` answers the bi-temporal question "what did we know about this query at that moment?" — a memory written after the moment is invisible, and a superseded memory walks its chain to find whether the invalidation had happened by then (audit 2026-09-20, loomcycle v1.33-v1.49 RFCs BL/BS/BU/BV/BW, Apache-2.0). Both shapes ship with HTTP routes (`POST /api/export/okf`, `GET /api/recall?as_of=...`). 34 new regression cases pin both shapes. [Full CHANGELOG →](./CHANGELOG.md#049---2026-09-20)
70
85
 
71
86
  > **What's new in 0.4.8** — **L0 outline recall** +
@@ -92,6 +92,7 @@ tests/test_agent_memory_api.py
92
92
  tests/test_agent_memory_sdk.py
93
93
  tests/test_agents_init.py
94
94
  tests/test_auth_token_rotate.py
95
+ tests/test_cache_cap_2026_09_23.py
95
96
  tests/test_cli_audit_supersede.py
96
97
  tests/test_cli_export_okf.py
97
98
  tests/test_cli_init.py
@@ -121,6 +122,7 @@ tests/test_memories_pagination.py
121
122
  tests/test_memory_stats.py
122
123
  tests/test_openclaw_loader.py
123
124
  tests/test_package_version.py
125
+ tests/test_path_safety_2026_09_23.py
124
126
  tests/test_recall_as_of.py
125
127
  tests/test_recall_paths.py
126
128
  tests/test_recall_provenance.py
@@ -137,6 +139,7 @@ tests/test_session_order.py
137
139
  tests/test_short_query.py
138
140
  tests/test_snapshot.py
139
141
  tests/test_store.py
142
+ tests/test_subgraph_hops_2026_09_23.py
140
143
  tests/test_summarization.py
141
144
  tests/test_supersession_chain.py
142
145
  tests/test_universal_memory.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "loop-memory"
7
- version = "0.4.9"
7
+ version = "0.4.10"
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"