loop-memory 0.4.8__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.
- {loop_memory-0.4.8 → loop_memory-0.4.10}/PKG-INFO +18 -1
- {loop_memory-0.4.8 → loop_memory-0.4.10}/README.md +17 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/cli/commands/cognitive.py +33 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/cli/commands/read.py +34 -6
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/cli/main.py +5 -1
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/jobs/evolution.py +14 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/jobs/graph.py +45 -29
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/jobs/llm_consolidate.py +24 -0
- loop_memory-0.4.10/loop_memory/serve/routes/_shared.py +196 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/serve/routes/export.py +30 -5
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/serve/routes/system.py +61 -3
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/storage/sqlite_store.py +462 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory.egg-info/PKG-INFO +18 -1
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory.egg-info/SOURCES.txt +8 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/pyproject.toml +1 -1
- loop_memory-0.4.10/tests/test_cache_cap_2026_09_23.py +84 -0
- loop_memory-0.4.10/tests/test_cli_export_okf.py +89 -0
- loop_memory-0.4.10/tests/test_cli_recall_as_of.py +90 -0
- loop_memory-0.4.10/tests/test_export_okf.py +162 -0
- loop_memory-0.4.10/tests/test_path_safety_2026_09_23.py +233 -0
- loop_memory-0.4.10/tests/test_recall_as_of.py +154 -0
- loop_memory-0.4.10/tests/test_routes_audit_2026_09_20.py +119 -0
- loop_memory-0.4.10/tests/test_subgraph_hops_2026_09_23.py +102 -0
- loop_memory-0.4.8/loop_memory/serve/routes/_shared.py +0 -59
- {loop_memory-0.4.8 → loop_memory-0.4.10}/LICENSE +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/__init__.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/backends/__init__.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/backends/embedding.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/backends/sentence_embedder.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/backends/vector_store.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/cli/__init__.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/cli/_common.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/cli/commands/__init__.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/cli/commands/diag.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/cli/commands/graph.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/cli/commands/hooks.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/cli/commands/rules.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/cli/commands/serve.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/cli/commands/write.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/engine/__init__.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/engine/loop.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/engine/reflect.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/examples/__init__.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/examples/demo.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/export/__init__.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/export/memory_md.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/graph/__init__.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/graph/build.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/graph/extract.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/ingest/__init__.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/ingest/loader.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/ingest/pipeline.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/jobs/__init__.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/jobs/cognitive.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/jobs/compact.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/jobs/consolidate.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/jobs/contradiction.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/jobs/llm_compact_pass.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/jobs/scheduler.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/llm/__init__.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/llm/base.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/llm/openai_adapter.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/llm/providers.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/mcp/__init__.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/memory/__init__.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/memory/types.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/privacy/__init__.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/privacy/private.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/privacy/redact.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/py.typed +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/sdk.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/sdk_extensions.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/security/__init__.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/security/secrets.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/serve/__init__.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/serve/app.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/serve/handlers.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/serve/routes/admin.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/serve/routes/cognitive.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/serve/routes/graph.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/serve/routes/insights.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/serve/routes/memories.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/serve/routes/sessions.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/serve/routes/wiki.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/serve/static/__init__.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/serve/static/index.html +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/serve/watcher.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/storage/__init__.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/storage/retrieval.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/storage/snapshot.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/wiki/__init__.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/wiki/backfill.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/wiki/classifier.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/wiki/prompts.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory/wiki/scope.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory.egg-info/dependency_links.txt +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory.egg-info/entry_points.txt +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory.egg-info/requires.txt +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/loop_memory.egg-info/top_level.txt +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/setup.cfg +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_admin_ingest_route.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_agent_memory_api.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_agent_memory_sdk.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_agents_init.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_auth_token_rotate.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_cli_audit_supersede.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_cli_init.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_cli_memory_stats.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_cli_recall_paths.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_cli_recall_verbose.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_cli_rules.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_cli_snapshot.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_cli_v7.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_cli_version.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_contradictions.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_evolution.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_evolution_quality.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_expanduser.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_export_ask.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_graph.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_ingest.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_llm_consolidator.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_llm_fingerprint_not_persisted.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_llm_providers.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_llm_test_endpoint.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_loop.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_mcp.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_memories_pagination.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_memory_stats.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_openclaw_loader.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_package_version.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_recall_paths.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_recall_provenance.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_reflection.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_repo_framing.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_routes_audit_2026_09_13.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_score_api.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_scoring_v2.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_secrets.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_serve_app.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_serve_handlers.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_session_order.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_short_query.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_snapshot.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_store.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_summarization.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_supersession_chain.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_universal_memory.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_vector_store.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_watcher.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_wiki_classifier.py +0 -0
- {loop_memory-0.4.8 → loop_memory-0.4.10}/tests/test_wiki_export_escape.py +0 -0
- {loop_memory-0.4.8 → 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.
|
|
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,23 @@ 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
|
+
|
|
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)
|
|
85
|
+
|
|
69
86
|
> **What's new in 0.4.8** — **L0 outline recall** +
|
|
70
87
|
> **per-agent identity bootstrap**. `loop-memory recall-paths <q>`
|
|
71
88
|
> (and the new `recall --outline` flag) returns only `id` +
|
|
@@ -22,6 +22,23 @@
|
|
|
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
|
+
|
|
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)
|
|
41
|
+
|
|
25
42
|
> **What's new in 0.4.8** — **L0 outline recall** +
|
|
26
43
|
> **per-agent identity bootstrap**. `loop-memory recall-paths <q>`
|
|
27
44
|
> (and the new `recall --outline` flag) returns only `id` +
|
|
@@ -108,6 +108,17 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
108
108
|
rs.add_argument("in_path",
|
|
109
109
|
help="Source .memory.sqlite file")
|
|
110
110
|
|
|
111
|
+
# Audit 2026-09-20: OKF v0.2 bundle export
|
|
112
|
+
# (akitaonrails/ai-memory 2.0 + okf-memory/okf-agent-memory +
|
|
113
|
+
# Google OKF v0.2 spec).
|
|
114
|
+
eo = sub.add_parser(
|
|
115
|
+
"export-okf",
|
|
116
|
+
help="Export wiki pages as an OKF v0.2 markdown bundle")
|
|
117
|
+
eo.add_argument("out_dir",
|
|
118
|
+
help="Destination directory for the bundle")
|
|
119
|
+
eo.add_argument("--scope", default=None,
|
|
120
|
+
help="Filter: 'global' or a single source token (e.g. codex)")
|
|
121
|
+
|
|
111
122
|
return p
|
|
112
123
|
|
|
113
124
|
|
|
@@ -342,3 +353,25 @@ def run_restore(args: list) -> int:
|
|
|
342
353
|
return _emit(_restore(s, ns.in_path))
|
|
343
354
|
except (FileNotFoundError, ValueError) as e:
|
|
344
355
|
return _emit({"error": str(e)})
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def run_export_okf(args) -> int:
|
|
359
|
+
"""``loop-memory export-okf <out_dir> [--scope SCOPE]``
|
|
360
|
+
|
|
361
|
+
Audit 2026-09-20: OKF v0.2 bundle export
|
|
362
|
+
(akitaonrails/ai-memory 2.0 + okf-memory/okf-agent-memory +
|
|
363
|
+
Google OKF v0.2 spec). Returns a summary dict with the bundle
|
|
364
|
+
root + per-page list so the caller can confirm the bundle is
|
|
365
|
+
sane. ``--scope`` accepts ``global`` (only globally-scoped
|
|
366
|
+
pages) or a single source token (only pages whose scope list
|
|
367
|
+
contains the token); omit the flag to emit every page.
|
|
368
|
+
"""
|
|
369
|
+
import argparse as _ap
|
|
370
|
+
p = _ap.ArgumentParser(prog="loop-memory export-okf")
|
|
371
|
+
p.add_argument("out_dir")
|
|
372
|
+
p.add_argument("--scope", default=None)
|
|
373
|
+
p.add_argument("--db", default=os.environ.get("LOOP_MEMORY_DB", DEFAULT_DB))
|
|
374
|
+
ns = p.parse_args(args)
|
|
375
|
+
s = MemoryStore(ns.db)
|
|
376
|
+
r = s.export_okf(ns.out_dir, scope_filter=ns.scope)
|
|
377
|
+
return _emit(r)
|
|
@@ -51,13 +51,17 @@ def run_stats(_args) -> int:
|
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
def run_recall(args) -> int:
|
|
54
|
-
"""Run ``loop-memory recall <query> [--verbose] [--limit N]``.
|
|
54
|
+
"""Run ``loop-memory recall <query> [--verbose] [--outline] [--as-of T] [--limit N]``.
|
|
55
55
|
|
|
56
56
|
With ``--verbose`` each memory hit is annotated with the
|
|
57
57
|
``why: [...]`` provenance labels produced by ``MemoryStore.recall``
|
|
58
|
-
(audit 2026-08-30, agentmemory v1.2.0 pattern).
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
(audit 2026-08-30, agentmemory v1.2.0 pattern). ``--outline``
|
|
59
|
+
switches to the L0 list (audit 2026-09-13, tigerless-labs/
|
|
60
|
+
agent-memory v0.3.0). ``--as-of <float-or-ISO>`` enables
|
|
61
|
+
bi-temporal recall (audit 2026-09-20, loomcycle v1.33+) so a
|
|
62
|
+
caller can ask "what did we know about this query at that
|
|
63
|
+
moment?". The wiki and entity sections are unchanged — only
|
|
64
|
+
the raw-memory block grows the new line.
|
|
61
65
|
"""
|
|
62
66
|
from ...storage.sqlite_store import MemoryStore
|
|
63
67
|
if not args:
|
|
@@ -68,6 +72,7 @@ def run_recall(args) -> int:
|
|
|
68
72
|
verbose = False
|
|
69
73
|
outline = False
|
|
70
74
|
limit = 10
|
|
75
|
+
as_of: float | None = None
|
|
71
76
|
qargs: list[str] = []
|
|
72
77
|
i = 0
|
|
73
78
|
while i < len(args):
|
|
@@ -81,6 +86,24 @@ def run_recall(args) -> int:
|
|
|
81
86
|
# routes the call to ``recall_paths`` below.
|
|
82
87
|
outline = True
|
|
83
88
|
i += 1
|
|
89
|
+
elif a == "--as-of" and i + 1 < len(args):
|
|
90
|
+
# Audit 2026-09-20 (loomcycle v1.33+) — bi-temporal
|
|
91
|
+
# recall: "what did we know about this query at that
|
|
92
|
+
# moment?". Accepts float epoch OR ISO-8601 string.
|
|
93
|
+
raw = args[i + 1].strip()
|
|
94
|
+
try:
|
|
95
|
+
as_of = float(raw)
|
|
96
|
+
except ValueError:
|
|
97
|
+
try:
|
|
98
|
+
from datetime import datetime
|
|
99
|
+
as_of = datetime.fromisoformat(
|
|
100
|
+
raw.replace("Z", "+00:00")
|
|
101
|
+
).timestamp()
|
|
102
|
+
except Exception:
|
|
103
|
+
return die(
|
|
104
|
+
f"--as-of must be float epoch or ISO-8601; got {raw!r}"
|
|
105
|
+
)
|
|
106
|
+
i += 2
|
|
84
107
|
elif a == "--limit" and i + 1 < len(args):
|
|
85
108
|
try:
|
|
86
109
|
limit = int(args[i + 1])
|
|
@@ -93,10 +116,15 @@ def run_recall(args) -> int:
|
|
|
93
116
|
qargs.append(a)
|
|
94
117
|
i += 1
|
|
95
118
|
if not qargs:
|
|
96
|
-
return die("usage: loop-memory recall <query> [--verbose] [--outline] [--limit N]")
|
|
119
|
+
return die("usage: loop-memory recall <query> [--verbose] [--outline] [--as-of <ISO|epoch>] [--limit N]")
|
|
97
120
|
store = MemoryStore(default_db_path())
|
|
98
121
|
query = " ".join(qargs)
|
|
99
|
-
if
|
|
122
|
+
if as_of is not None:
|
|
123
|
+
try:
|
|
124
|
+
r = store.recall_as_of(query, as_of, limit=limit)
|
|
125
|
+
except ValueError as e:
|
|
126
|
+
return die(str(e))
|
|
127
|
+
elif outline:
|
|
100
128
|
r = store.recall_paths(query, limit=limit)
|
|
101
129
|
else:
|
|
102
130
|
r = store.recall(query, limit=limit)
|
|
@@ -125,6 +125,9 @@ COMMANDS = {
|
|
|
125
125
|
# Audit 2026-09-13 — L0 outline recall + per-agent identity bootstrap.
|
|
126
126
|
"recall-paths": read_cmd.run_recall_paths,
|
|
127
127
|
"init": hooks_cmd.run_init,
|
|
128
|
+
# Audit 2026-09-20 — OKF v0.2 export (akitaonrails/ai-memory 2.0
|
|
129
|
+
# + okf-agent-memory + Google OKF spec).
|
|
130
|
+
"export-okf": cognitive_cmd.run_export_okf,
|
|
128
131
|
"rules": rules_cmd.run_rules,
|
|
129
132
|
"version": _run_version,
|
|
130
133
|
}
|
|
@@ -158,7 +161,7 @@ COMMAND_HELP: dict[str, str] = {
|
|
|
158
161
|
"install-hooks": "loop-memory install-hooks # auto-write MCP + SessionStart hooks for known clients.",
|
|
159
162
|
"mcp": "loop-memory mcp # stdio MCP server (for codex/claude/hermes). Requires ``[serve]`` for HTTP transport.",
|
|
160
163
|
"openclaw-setup": "loop-memory openclaw-setup # install the openclaw/clawx auto-ingest watcher (launchd on macOS).",
|
|
161
|
-
"recall": "loop-memory recall <text> # show top memories.",
|
|
164
|
+
"recall": "loop-memory recall <text> [--as-of <ISO|epoch>] # show top memories (--as-of = bi-temporal recall).",
|
|
162
165
|
"rescore": "loop-memory rescore [--half-life 30] # recompute memory scores.",
|
|
163
166
|
"serve": "loop-memory serve [--port 7767] [--no-browser] # start the local web UI. Requires the ``[serve]`` extra (fastapi + uvicorn).",
|
|
164
167
|
"stats": "loop-memory stats # counters.",
|
|
@@ -168,6 +171,7 @@ COMMAND_HELP: dict[str, str] = {
|
|
|
168
171
|
"wiki-reclassify-legacy": "loop-memory wiki-reclassify-legacy # back-fill scope + scope_filter for H4+H5 pages.",
|
|
169
172
|
"memory-stats": "loop-memory memory-stats <id> [--prefix] # per-memory lifetime stats (audit 2026-09-06, agentmemory v1.3.0).",
|
|
170
173
|
"snapshot": "loop-memory snapshot <out.memory.sqlite> # write a portable SQLite snapshot (audit 2026-09-06, codexa-memory v0.2.0).",
|
|
174
|
+
"export-okf": "loop-memory export-okf <out_dir> [--scope S] # write an OKF v0.2 bundle (audit 2026-09-20).",
|
|
171
175
|
"restore": "loop-memory restore <in.memory.sqlite> # re-hydrate a portable SQLite snapshot.",
|
|
172
176
|
# Audit 2026-09-13 — L0 outline recall (tigerless-labs/agent-memory v0.3.0).
|
|
173
177
|
"recall-paths": "loop-memory recall-paths <query> [--limit N] # cheap L0 outline (id+abstract+score) instead of full text (audit 2026-09-13).",
|
|
@@ -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}")
|
|
@@ -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:
|