loop-memory 0.4.9__tar.gz → 0.4.11__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 (155) hide show
  1. {loop_memory-0.4.9 → loop_memory-0.4.11}/PKG-INFO +29 -1
  2. {loop_memory-0.4.9 → loop_memory-0.4.11}/README.md +28 -0
  3. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/jobs/evolution.py +14 -0
  4. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/jobs/graph.py +45 -29
  5. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/jobs/llm_consolidate.py +24 -0
  6. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/app.py +6 -0
  7. loop_memory-0.4.11/loop_memory/serve/routes/_shared.py +196 -0
  8. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/routes/export.py +30 -5
  9. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/routes/system.py +43 -2
  10. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/storage/sqlite_store.py +10 -1
  11. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory.egg-info/PKG-INFO +29 -1
  12. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory.egg-info/SOURCES.txt +5 -0
  13. {loop_memory-0.4.9 → loop_memory-0.4.11}/pyproject.toml +1 -1
  14. loop_memory-0.4.11/tests/test_busy_timeout_2026_09_24.py +110 -0
  15. loop_memory-0.4.11/tests/test_cache_cap_2026_09_23.py +84 -0
  16. loop_memory-0.4.11/tests/test_healthz_readyz_2026_09_24.py +80 -0
  17. loop_memory-0.4.11/tests/test_path_safety_2026_09_23.py +233 -0
  18. loop_memory-0.4.11/tests/test_subgraph_hops_2026_09_23.py +102 -0
  19. loop_memory-0.4.9/loop_memory/serve/routes/_shared.py +0 -59
  20. {loop_memory-0.4.9 → loop_memory-0.4.11}/LICENSE +0 -0
  21. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/__init__.py +0 -0
  22. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/backends/__init__.py +0 -0
  23. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/backends/embedding.py +0 -0
  24. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/backends/sentence_embedder.py +0 -0
  25. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/backends/vector_store.py +0 -0
  26. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/__init__.py +0 -0
  27. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/_common.py +0 -0
  28. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/commands/__init__.py +0 -0
  29. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/commands/cognitive.py +0 -0
  30. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/commands/diag.py +0 -0
  31. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/commands/graph.py +0 -0
  32. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/commands/hooks.py +0 -0
  33. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/commands/read.py +0 -0
  34. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/commands/rules.py +0 -0
  35. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/commands/serve.py +0 -0
  36. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/commands/write.py +0 -0
  37. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/main.py +0 -0
  38. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/engine/__init__.py +0 -0
  39. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/engine/loop.py +0 -0
  40. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/engine/reflect.py +0 -0
  41. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/examples/__init__.py +0 -0
  42. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/examples/demo.py +0 -0
  43. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/export/__init__.py +0 -0
  44. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/export/memory_md.py +0 -0
  45. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/graph/__init__.py +0 -0
  46. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/graph/build.py +0 -0
  47. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/graph/extract.py +0 -0
  48. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/ingest/__init__.py +0 -0
  49. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/ingest/loader.py +0 -0
  50. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/ingest/pipeline.py +0 -0
  51. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/jobs/__init__.py +0 -0
  52. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/jobs/cognitive.py +0 -0
  53. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/jobs/compact.py +0 -0
  54. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/jobs/consolidate.py +0 -0
  55. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/jobs/contradiction.py +0 -0
  56. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/jobs/llm_compact_pass.py +0 -0
  57. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/jobs/scheduler.py +0 -0
  58. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/llm/__init__.py +0 -0
  59. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/llm/base.py +0 -0
  60. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/llm/openai_adapter.py +0 -0
  61. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/llm/providers.py +0 -0
  62. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/mcp/__init__.py +0 -0
  63. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/memory/__init__.py +0 -0
  64. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/memory/types.py +0 -0
  65. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/privacy/__init__.py +0 -0
  66. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/privacy/private.py +0 -0
  67. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/privacy/redact.py +0 -0
  68. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/py.typed +0 -0
  69. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/sdk.py +0 -0
  70. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/sdk_extensions.py +0 -0
  71. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/security/__init__.py +0 -0
  72. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/security/secrets.py +0 -0
  73. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/__init__.py +0 -0
  74. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/handlers.py +0 -0
  75. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/routes/admin.py +0 -0
  76. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/routes/cognitive.py +0 -0
  77. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/routes/graph.py +0 -0
  78. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/routes/insights.py +0 -0
  79. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/routes/memories.py +0 -0
  80. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/routes/sessions.py +0 -0
  81. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/routes/wiki.py +0 -0
  82. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/static/__init__.py +0 -0
  83. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/static/index.html +0 -0
  84. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/watcher.py +0 -0
  85. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/storage/__init__.py +0 -0
  86. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/storage/retrieval.py +0 -0
  87. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/storage/snapshot.py +0 -0
  88. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/wiki/__init__.py +0 -0
  89. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/wiki/backfill.py +0 -0
  90. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/wiki/classifier.py +0 -0
  91. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/wiki/prompts.py +0 -0
  92. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/wiki/scope.py +0 -0
  93. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory.egg-info/dependency_links.txt +0 -0
  94. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory.egg-info/entry_points.txt +0 -0
  95. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory.egg-info/requires.txt +0 -0
  96. {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory.egg-info/top_level.txt +0 -0
  97. {loop_memory-0.4.9 → loop_memory-0.4.11}/setup.cfg +0 -0
  98. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_admin_ingest_route.py +0 -0
  99. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_agent_memory_api.py +0 -0
  100. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_agent_memory_sdk.py +0 -0
  101. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_agents_init.py +0 -0
  102. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_auth_token_rotate.py +0 -0
  103. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_audit_supersede.py +0 -0
  104. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_export_okf.py +0 -0
  105. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_init.py +0 -0
  106. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_memory_stats.py +0 -0
  107. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_recall_as_of.py +0 -0
  108. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_recall_paths.py +0 -0
  109. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_recall_verbose.py +0 -0
  110. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_rules.py +0 -0
  111. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_snapshot.py +0 -0
  112. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_v7.py +0 -0
  113. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_version.py +0 -0
  114. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_contradictions.py +0 -0
  115. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_evolution.py +0 -0
  116. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_evolution_quality.py +0 -0
  117. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_expanduser.py +0 -0
  118. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_export_ask.py +0 -0
  119. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_export_okf.py +0 -0
  120. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_graph.py +0 -0
  121. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_ingest.py +0 -0
  122. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_llm_consolidator.py +0 -0
  123. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_llm_fingerprint_not_persisted.py +0 -0
  124. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_llm_providers.py +0 -0
  125. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_llm_test_endpoint.py +0 -0
  126. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_loop.py +0 -0
  127. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_mcp.py +0 -0
  128. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_memories_pagination.py +0 -0
  129. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_memory_stats.py +0 -0
  130. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_openclaw_loader.py +0 -0
  131. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_package_version.py +0 -0
  132. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_recall_as_of.py +0 -0
  133. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_recall_paths.py +0 -0
  134. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_recall_provenance.py +0 -0
  135. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_reflection.py +0 -0
  136. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_repo_framing.py +0 -0
  137. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_routes_audit_2026_09_13.py +0 -0
  138. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_routes_audit_2026_09_20.py +0 -0
  139. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_score_api.py +0 -0
  140. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_scoring_v2.py +0 -0
  141. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_secrets.py +0 -0
  142. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_serve_app.py +0 -0
  143. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_serve_handlers.py +0 -0
  144. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_session_order.py +0 -0
  145. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_short_query.py +0 -0
  146. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_snapshot.py +0 -0
  147. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_store.py +0 -0
  148. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_summarization.py +0 -0
  149. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_supersession_chain.py +0 -0
  150. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_universal_memory.py +0 -0
  151. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_vector_store.py +0 -0
  152. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_watcher.py +0 -0
  153. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_wiki_classifier.py +0 -0
  154. {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_wiki_export_escape.py +0 -0
  155. {loop_memory-0.4.9 → loop_memory-0.4.11}/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.11
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,34 @@ Dynamic: license-file
66
66
 
67
67
  ---
68
68
 
69
+ > **What's new in 0.4.11** — **operational probes** +
70
+ **SQLite write-contention fix** (audit 2026-09-24). New
71
+ ``GET /api/healthz`` (liveness) + ``GET /api/readyz``
72
+ (readiness) endpoints let Kubernetes / launchd keepalive
73
+ polls probe the serve without provisioning a Bearer token;
74
+ ``/api/readyz`` returns 503 when the SQLite store is
75
+ unreachable. Also fixed a real concurrency bug: every
76
+ store connection now sets ``PRAGMA busy_timeout=5000``
77
+ (was 0 ms) so the watcher + serve + scheduler threads no
78
+ longer fail with ``database is locked`` when the
79
+ consolidator writes concurrently. 7 new regression cases.
80
+ [Full CHANGELOG →](./CHANGELOG.md#0411---2026-09-24)
81
+
82
+ > **What's new in 0.4.10** — **path-safety guard** for the
83
+ five export / snapshot endpoints. ``MemoryStore`` already
84
+ validated query / wiki content; the new
85
+ ``_safe_resolve_path`` helper (audit 2026-09-23) closes the
86
+ remaining path-traversal vector by refusing any path that
87
+ points at the live SQLite store, a system directory
88
+ (``/etc`` / ``/usr`` / ``/System``), or a sensitive dotfile
89
+ (``~/.ssh`` / ``~/.aws`` / ``~/.gnupg``). Also in this
90
+ release: a hard cap on the in-process LLM response cache
91
+ (was unbounded; now 256 entries with FIFO eviction) and a
92
+ fix for ``subgraph_for(max_hops=...)`` which used to ignore
93
+ the argument and always walk one hop (now BFS up to N hops,
94
+ hard-capped at 8). 29 new regression cases. [Full
95
+ CHANGELOG →](./CHANGELOG.md#0410---2026-09-23)
96
+
69
97
  > **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
98
 
71
99
  > **What's new in 0.4.8** — **L0 outline recall** +
@@ -22,6 +22,34 @@
22
22
 
23
23
  ---
24
24
 
25
+ > **What's new in 0.4.11** — **operational probes** +
26
+ **SQLite write-contention fix** (audit 2026-09-24). New
27
+ ``GET /api/healthz`` (liveness) + ``GET /api/readyz``
28
+ (readiness) endpoints let Kubernetes / launchd keepalive
29
+ polls probe the serve without provisioning a Bearer token;
30
+ ``/api/readyz`` returns 503 when the SQLite store is
31
+ unreachable. Also fixed a real concurrency bug: every
32
+ store connection now sets ``PRAGMA busy_timeout=5000``
33
+ (was 0 ms) so the watcher + serve + scheduler threads no
34
+ longer fail with ``database is locked`` when the
35
+ consolidator writes concurrently. 7 new regression cases.
36
+ [Full CHANGELOG →](./CHANGELOG.md#0411---2026-09-24)
37
+
38
+ > **What's new in 0.4.10** — **path-safety guard** for the
39
+ five export / snapshot endpoints. ``MemoryStore`` already
40
+ validated query / wiki content; the new
41
+ ``_safe_resolve_path`` helper (audit 2026-09-23) closes the
42
+ remaining path-traversal vector by refusing any path that
43
+ points at the live SQLite store, a system directory
44
+ (``/etc`` / ``/usr`` / ``/System``), or a sensitive dotfile
45
+ (``~/.ssh`` / ``~/.aws`` / ``~/.gnupg``). Also in this
46
+ release: a hard cap on the in-process LLM response cache
47
+ (was unbounded; now 256 entries with FIFO eviction) and a
48
+ fix for ``subgraph_for(max_hops=...)`` which used to ignore
49
+ the argument and always walk one hop (now BFS up to N hops,
50
+ hard-capped at 8). 29 new regression cases. [Full
51
+ CHANGELOG →](./CHANGELOG.md#0410---2026-09-23)
52
+
25
53
  > **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
54
 
27
55
  > **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}")
@@ -176,6 +176,12 @@ def create_app(store: MemoryStore, static_dir: Path | None = None, scheduler=Non
176
176
  '/api/pipeline', '/api/weekly-report', '/api/llm-audit',
177
177
  '/api/source-health', '/api/write-guard', '/api/diag',
178
178
  '/api/install-hooks', '/api/insights',
179
+ # Audit 2026-09-24: probes are intentionally public so a
180
+ # load balancer / keepalive script doesn't need a Bearer
181
+ # token to ask "is the process up?". The DB-touching
182
+ # ``/api/readyz`` only exposes a coarse health signal
183
+ # (UP / DOWN), no data.
184
+ '/api/healthz', '/api/readyz',
179
185
  ))
180
186
 
181
187
  def _is_public_path(path: str) -> bool:
@@ -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:
@@ -36,6 +36,41 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None,
36
36
  """
37
37
  if static_dir is None:
38
38
  static_dir = Path(__file__).parent.parent / "static"
39
+ @app.get("/api/healthz")
40
+ def healthz():
41
+ """Liveness probe.
42
+
43
+ Audit 2026-09-24. Returns 200 as long as the process is
44
+ responsive. **Does not** touch the SQLite store — so the
45
+ probe cannot deadlock behind a slow writer and cannot give
46
+ a false negative when the DB is briefly busy. Use this for
47
+ ``livenessProbe`` in Kubernetes / launchd keepalive scripts.
48
+ """
49
+ return {"status": "ok", "ts": time.time()}
50
+
51
+ @app.get("/api/readyz")
52
+ def readyz():
53
+ """Readiness probe.
54
+
55
+ Audit 2026-09-24. Verifies the SQLite store is reachable AND
56
+ responsive (one trivial ``PRAGMA quick_check`` round-trip
57
+ inside a 1-second budget). Returns ``{"status": "ok"}`` on
58
+ success, or ``{"status": "degraded", "error": "..."}`` with
59
+ HTTP 503 when the DB is unhealthy. Use this for
60
+ ``readinessProbe`` so a degraded loop-memory is taken out of
61
+ a load-balancer rotation until it recovers.
62
+ """
63
+ import sqlite3 as _sq3
64
+ try:
65
+ with _sq3.connect(str(store.path), timeout=1.0) as c:
66
+ c.execute("PRAGMA quick_check").fetchone()
67
+ return {"status": "ok", "ts": time.time()}
68
+ except Exception as e:
69
+ return JSONResponse(
70
+ {"status": "degraded", "error": str(e)},
71
+ status_code=503,
72
+ )
73
+
39
74
  @app.get("/")
40
75
  def index():
41
76
  """Serve the dashboard HTML with the i18n JSONs inlined.
@@ -430,8 +465,10 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None,
430
465
  Writes one ``.md`` file per wiki page under
431
466
  ``<out_dir>/pages/<slug>.md`` plus an ``index.md`` index.
432
467
  See ``MemoryStore.export_okf`` for the field-mapping
433
- contract.
468
+ contract. Audit 2026-09-23: ``out_dir`` is rejected if it
469
+ points at the live store or a system / sensitive directory.
434
470
  """
471
+ from ._shared import _safe_resolve_path as _safe_path
435
472
  out_dir = (body.get("out_dir") or "").strip()
436
473
  if not out_dir:
437
474
  raise HTTPException(400, "out_dir is required")
@@ -439,7 +476,11 @@ def register(app: FastAPI, store: MemoryStore, scheduler: Optional[Any] = None,
439
476
  if scope is not None:
440
477
  scope = str(scope).strip() or None
441
478
  try:
442
- r = store.export_okf(out_dir, scope_filter=scope)
479
+ safe_dir = _safe_path(out_dir, kind="write", live_db_path=str(store.path))
480
+ except ValueError as e:
481
+ raise HTTPException(400, str(e))
482
+ try:
483
+ r = store.export_okf(safe_dir, scope_filter=scope)
443
484
  except Exception as e:
444
485
  raise HTTPException(500, f"export-okf failed: {e}")
445
486
  return r
@@ -715,10 +715,19 @@ class MemoryStore:
715
715
 
716
716
  @contextmanager
717
717
  def _conn(self) -> Iterator[sqlite3.Connection]:
718
- conn = sqlite3.connect(str(self.path))
718
+ # Audit 2026-09-24: every connection sets three PRAGMAs.
719
+ # ``busy_timeout=5000`` makes SQLite retry a contended writer
720
+ # for up to 5 seconds before raising ``OperationalError:
721
+ # database is locked``. Without it the watcher (launchd
722
+ # background thread) and the serve thread can fail spuriously
723
+ # when the consolidator or scheduler also writes concurrently.
724
+ # 5s is a generous budget for any local operation; an actual
725
+ # deadlock surfaces faster via the SQLite error code.
726
+ conn = sqlite3.connect(str(self.path), timeout=5.0)
719
727
  conn.row_factory = sqlite3.Row
720
728
  conn.execute("PRAGMA journal_mode=WAL")
721
729
  conn.execute("PRAGMA foreign_keys=ON")
730
+ conn.execute("PRAGMA busy_timeout=5000")
722
731
  try:
723
732
  yield conn
724
733
  conn.commit()