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.
- {loop_memory-0.4.9 → loop_memory-0.4.11}/PKG-INFO +29 -1
- {loop_memory-0.4.9 → loop_memory-0.4.11}/README.md +28 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/jobs/evolution.py +14 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/jobs/graph.py +45 -29
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/jobs/llm_consolidate.py +24 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/app.py +6 -0
- loop_memory-0.4.11/loop_memory/serve/routes/_shared.py +196 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/routes/export.py +30 -5
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/routes/system.py +43 -2
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/storage/sqlite_store.py +10 -1
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory.egg-info/PKG-INFO +29 -1
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory.egg-info/SOURCES.txt +5 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/pyproject.toml +1 -1
- loop_memory-0.4.11/tests/test_busy_timeout_2026_09_24.py +110 -0
- loop_memory-0.4.11/tests/test_cache_cap_2026_09_23.py +84 -0
- loop_memory-0.4.11/tests/test_healthz_readyz_2026_09_24.py +80 -0
- loop_memory-0.4.11/tests/test_path_safety_2026_09_23.py +233 -0
- loop_memory-0.4.11/tests/test_subgraph_hops_2026_09_23.py +102 -0
- loop_memory-0.4.9/loop_memory/serve/routes/_shared.py +0 -59
- {loop_memory-0.4.9 → loop_memory-0.4.11}/LICENSE +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/__init__.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/backends/__init__.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/backends/embedding.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/backends/sentence_embedder.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/backends/vector_store.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/__init__.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/_common.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/commands/__init__.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/commands/cognitive.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/commands/diag.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/commands/graph.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/commands/hooks.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/commands/read.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/commands/rules.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/commands/serve.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/commands/write.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/cli/main.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/engine/__init__.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/engine/loop.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/engine/reflect.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/examples/__init__.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/examples/demo.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/export/__init__.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/export/memory_md.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/graph/__init__.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/graph/build.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/graph/extract.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/ingest/__init__.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/ingest/loader.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/ingest/pipeline.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/jobs/__init__.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/jobs/cognitive.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/jobs/compact.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/jobs/consolidate.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/jobs/contradiction.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/jobs/llm_compact_pass.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/jobs/scheduler.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/llm/__init__.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/llm/base.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/llm/openai_adapter.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/llm/providers.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/mcp/__init__.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/memory/__init__.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/memory/types.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/privacy/__init__.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/privacy/private.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/privacy/redact.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/py.typed +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/sdk.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/sdk_extensions.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/security/__init__.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/security/secrets.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/__init__.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/handlers.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/routes/admin.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/routes/cognitive.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/routes/graph.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/routes/insights.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/routes/memories.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/routes/sessions.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/routes/wiki.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/static/__init__.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/static/index.html +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/serve/watcher.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/storage/__init__.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/storage/retrieval.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/storage/snapshot.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/wiki/__init__.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/wiki/backfill.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/wiki/classifier.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/wiki/prompts.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory/wiki/scope.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory.egg-info/dependency_links.txt +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory.egg-info/entry_points.txt +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory.egg-info/requires.txt +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/loop_memory.egg-info/top_level.txt +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/setup.cfg +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_admin_ingest_route.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_agent_memory_api.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_agent_memory_sdk.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_agents_init.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_auth_token_rotate.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_audit_supersede.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_export_okf.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_init.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_memory_stats.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_recall_as_of.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_recall_paths.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_recall_verbose.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_rules.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_snapshot.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_v7.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_cli_version.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_contradictions.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_evolution.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_evolution_quality.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_expanduser.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_export_ask.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_export_okf.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_graph.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_ingest.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_llm_consolidator.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_llm_fingerprint_not_persisted.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_llm_providers.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_llm_test_endpoint.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_loop.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_mcp.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_memories_pagination.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_memory_stats.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_openclaw_loader.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_package_version.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_recall_as_of.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_recall_paths.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_recall_provenance.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_reflection.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_repo_framing.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_routes_audit_2026_09_13.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_routes_audit_2026_09_20.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_score_api.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_scoring_v2.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_secrets.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_serve_app.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_serve_handlers.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_session_order.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_short_query.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_snapshot.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_store.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_summarization.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_supersession_chain.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_universal_memory.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_vector_store.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_watcher.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_wiki_classifier.py +0 -0
- {loop_memory-0.4.9 → loop_memory-0.4.11}/tests/test_wiki_export_escape.py +0 -0
- {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.
|
|
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.
|
|
342
|
-
|
|
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
|
-
|
|
350
|
-
if
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
if
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
for
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|