memorius 0.7.0__tar.gz → 0.7.2__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 (100) hide show
  1. {memorius-0.7.0/memorius.egg-info → memorius-0.7.2}/PKG-INFO +52 -46
  2. {memorius-0.7.0 → memorius-0.7.2}/README.md +43 -30
  3. {memorius-0.7.0 → memorius-0.7.2}/manifest.yaml +1 -1
  4. {memorius-0.7.0 → memorius-0.7.2}/memorius/backup.py +31 -2
  5. {memorius-0.7.0 → memorius-0.7.2}/memorius/cli/main.py +128 -17
  6. {memorius-0.7.0 → memorius-0.7.2}/memorius/cli/obsidian.py +9 -7
  7. {memorius-0.7.0 → memorius-0.7.2}/memorius/config.py +1 -1
  8. {memorius-0.7.0 → memorius-0.7.2}/memorius/consolidation.py +0 -1
  9. {memorius-0.7.0 → memorius-0.7.2}/memorius/context_inject.py +1 -1
  10. {memorius-0.7.0 → memorius-0.7.2}/memorius/data/examples/quickstart.py +1 -1
  11. {memorius-0.7.0 → memorius-0.7.2}/memorius/doctor.py +6 -8
  12. {memorius-0.7.0 → memorius-0.7.2}/memorius/embeddings.py +6 -2
  13. {memorius-0.7.0 → memorius-0.7.2}/memorius/graph.py +134 -0
  14. memorius-0.7.2/memorius/graph_visualizer.py +905 -0
  15. {memorius-0.7.0 → memorius-0.7.2}/memorius/hooks/cli.py +0 -2
  16. {memorius-0.7.0 → memorius-0.7.2}/memorius/hooks/engine.py +61 -33
  17. {memorius-0.7.0 → memorius-0.7.2}/memorius/hooks/models.py +1 -1
  18. {memorius-0.7.0 → memorius-0.7.2}/memorius/llm_extract.py +36 -26
  19. {memorius-0.7.0 → memorius-0.7.2}/memorius/mcp_server.py +75 -9
  20. {memorius-0.7.0 → memorius-0.7.2}/memorius/meta_store.py +54 -8
  21. {memorius-0.7.0 → memorius-0.7.2}/memorius/model_download.py +15 -3
  22. {memorius-0.7.0 → memorius-0.7.2}/memorius/normalizers/__init__.py +0 -4
  23. {memorius-0.7.0 → memorius-0.7.2}/memorius/normalizers/cli.py +1 -4
  24. {memorius-0.7.0 → memorius-0.7.2}/memorius/obsidian.py +6 -1
  25. {memorius-0.7.0 → memorius-0.7.2}/memorius/plugin_gen/cli.py +1 -5
  26. {memorius-0.7.0 → memorius-0.7.2}/memorius/reranker.py +1 -1
  27. {memorius-0.7.0 → memorius-0.7.2}/memorius/rest_server.py +125 -11
  28. {memorius-0.7.0 → memorius-0.7.2}/memorius/search_module.py +5 -8
  29. {memorius-0.7.0 → memorius-0.7.2}/memorius/sqlite_vec_store.py +1 -1
  30. {memorius-0.7.0 → memorius-0.7.2}/memorius/store_module.py +5 -14
  31. {memorius-0.7.0 → memorius-0.7.2}/memorius/temporal.py +0 -1
  32. {memorius-0.7.0 → memorius-0.7.2}/memorius/utils.py +17 -1
  33. {memorius-0.7.0 → memorius-0.7.2}/memorius/validation.py +36 -1
  34. {memorius-0.7.0 → memorius-0.7.2}/memorius/vault.py +85 -36
  35. {memorius-0.7.0 → memorius-0.7.2/memorius.egg-info}/PKG-INFO +52 -46
  36. {memorius-0.7.0 → memorius-0.7.2}/memorius.egg-info/SOURCES.txt +2 -0
  37. memorius-0.7.2/memorius.egg-info/requires.txt +14 -0
  38. {memorius-0.7.0 → memorius-0.7.2}/pyproject.toml +9 -21
  39. memorius-0.7.2/tests/test_graph_visualizer.py +152 -0
  40. {memorius-0.7.0 → memorius-0.7.2}/tests/test_validation.py +22 -0
  41. memorius-0.7.0/memorius.egg-info/requires.txt +0 -26
  42. {memorius-0.7.0 → memorius-0.7.2}/LICENSE +0 -0
  43. {memorius-0.7.0 → memorius-0.7.2}/MANIFEST.in +0 -0
  44. {memorius-0.7.0 → memorius-0.7.2}/examples/quickstart.py +0 -0
  45. {memorius-0.7.0 → memorius-0.7.2}/hooks/memorius-hook.sh +0 -0
  46. {memorius-0.7.0 → memorius-0.7.2}/memorius/__init__.py +0 -0
  47. {memorius-0.7.0 → memorius-0.7.2}/memorius/cli/__init__.py +0 -0
  48. {memorius-0.7.0 → memorius-0.7.2}/memorius/data/LICENSE +0 -0
  49. {memorius-0.7.0 → memorius-0.7.2}/memorius/data/__init__.py +0 -0
  50. {memorius-0.7.0 → memorius-0.7.2}/memorius/data/examples/__init__.py +0 -0
  51. {memorius-0.7.0 → memorius-0.7.2}/memorius/data/manifest.yaml +0 -0
  52. {memorius-0.7.0 → memorius-0.7.2}/memorius/data/memorius-hook.sh +0 -0
  53. {memorius-0.7.0 → memorius-0.7.2}/memorius/factcheck.py +0 -0
  54. {memorius-0.7.0 → memorius-0.7.2}/memorius/hooks/__init__.py +0 -0
  55. {memorius-0.7.0 → memorius-0.7.2}/memorius/hooks/adapters/__init__.py +0 -0
  56. {memorius-0.7.0 → memorius-0.7.2}/memorius/hooks/adapters/base.py +0 -0
  57. {memorius-0.7.0 → memorius-0.7.2}/memorius/hooks/adapters/claude_code.py +0 -0
  58. {memorius-0.7.0 → memorius-0.7.2}/memorius/hooks/adapters/codex.py +0 -0
  59. {memorius-0.7.0 → memorius-0.7.2}/memorius/hooks/adapters/gemini.py +0 -0
  60. {memorius-0.7.0 → memorius-0.7.2}/memorius/hooks/adapters/generic.py +0 -0
  61. {memorius-0.7.0 → memorius-0.7.2}/memorius/hooks/adapters/openclaude.py +0 -0
  62. {memorius-0.7.0 → memorius-0.7.2}/memorius/hooks/adapters/openclaw.py +0 -0
  63. {memorius-0.7.0 → memorius-0.7.2}/memorius/hooks/adapters/opencode.py +0 -0
  64. {memorius-0.7.0 → memorius-0.7.2}/memorius/hooks/adapters/pi.py +0 -0
  65. {memorius-0.7.0 → memorius-0.7.2}/memorius/models.py +0 -0
  66. {memorius-0.7.0 → memorius-0.7.2}/memorius/plugin_gen/__init__.py +0 -0
  67. {memorius-0.7.0 → memorius-0.7.2}/memorius/session.py +0 -0
  68. {memorius-0.7.0 → memorius-0.7.2}/memorius/vector_store.py +0 -0
  69. {memorius-0.7.0 → memorius-0.7.2}/memorius/vector_store_base.py +0 -0
  70. {memorius-0.7.0 → memorius-0.7.2}/memorius/web_search.py +0 -0
  71. {memorius-0.7.0 → memorius-0.7.2}/memorius.egg-info/dependency_links.txt +0 -0
  72. {memorius-0.7.0 → memorius-0.7.2}/memorius.egg-info/entry_points.txt +0 -0
  73. {memorius-0.7.0 → memorius-0.7.2}/memorius.egg-info/top_level.txt +0 -0
  74. {memorius-0.7.0 → memorius-0.7.2}/setup.cfg +0 -0
  75. {memorius-0.7.0 → memorius-0.7.2}/tests/test_core.py +0 -0
  76. {memorius-0.7.0 → memorius-0.7.2}/tests/test_delete.py +0 -0
  77. {memorius-0.7.0 → memorius-0.7.2}/tests/test_domain_modules.py +0 -0
  78. {memorius-0.7.0 → memorius-0.7.2}/tests/test_encoding_regression.py +0 -0
  79. {memorius-0.7.0 → memorius-0.7.2}/tests/test_feature_backup.py +0 -0
  80. {memorius-0.7.0 → memorius-0.7.2}/tests/test_feature_batch_pagination.py +0 -0
  81. {memorius-0.7.0 → memorius-0.7.2}/tests/test_feature_consolidation_scaling.py +0 -0
  82. {memorius-0.7.0 → memorius-0.7.2}/tests/test_feature_contradiction_edges.py +0 -0
  83. {memorius-0.7.0 → memorius-0.7.2}/tests/test_feature_cors.py +0 -0
  84. {memorius-0.7.0 → memorius-0.7.2}/tests/test_feature_crud.py +0 -0
  85. {memorius-0.7.0 → memorius-0.7.2}/tests/test_feature_daemon.py +0 -0
  86. {memorius-0.7.0 → memorius-0.7.2}/tests/test_feature_doctor.py +0 -0
  87. {memorius-0.7.0 → memorius-0.7.2}/tests/test_feature_factcheck_access.py +0 -0
  88. {memorius-0.7.0 → memorius-0.7.2}/tests/test_feature_graph_retrieval.py +0 -0
  89. {memorius-0.7.0 → memorius-0.7.2}/tests/test_feature_prune.py +0 -0
  90. {memorius-0.7.0 → memorius-0.7.2}/tests/test_feature_rerank.py +0 -0
  91. {memorius-0.7.0 → memorius-0.7.2}/tests/test_feature_search_filtering.py +0 -0
  92. {memorius-0.7.0 → memorius-0.7.2}/tests/test_feature_sqlite_vec.py +0 -0
  93. {memorius-0.7.0 → memorius-0.7.2}/tests/test_feature_ttl.py +0 -0
  94. {memorius-0.7.0 → memorius-0.7.2}/tests/test_features.py +0 -0
  95. {memorius-0.7.0 → memorius-0.7.2}/tests/test_graph.py +0 -0
  96. {memorius-0.7.0 → memorius-0.7.2}/tests/test_integration.py +0 -0
  97. {memorius-0.7.0 → memorius-0.7.2}/tests/test_polish_regressions.py +0 -0
  98. {memorius-0.7.0 → memorius-0.7.2}/tests/test_regression_bugs.py +0 -0
  99. {memorius-0.7.0 → memorius-0.7.2}/tests/test_temporal.py +0 -0
  100. {memorius-0.7.0 → memorius-0.7.2}/tests/test_web_search.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memorius
3
- Version: 0.7.0
4
- Summary: Self-contained memory vault for any AI agent - vector search, session diaries, and agent-agnostic hooks.
3
+ Version: 0.7.2
4
+ Summary: Self-contained memory vault for any AI agent — vector search, session diaries, and agent-agnostic hooks.
5
5
  Author-email: dimonapatrick243 <dimona.patrick@gmail.com>
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/Dream-Pixels-Forge/memorius
@@ -17,30 +17,23 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
17
  Requires-Python: >=3.11
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: LICENSE
20
- Requires-Dist: pyyaml>=6.0
21
- Requires-Dist: chromadb>=0.4.0
22
- Requires-Dist: httpx>=0.24.0
23
- Requires-Dist: fastapi>=0.104.0
20
+ Requires-Dist: pyyaml>=6.0.1
21
+ Requires-Dist: chromadb>=0.4.22
22
+ Requires-Dist: httpx>=0.25.0
23
+ Requires-Dist: fastapi>=0.104.1
24
24
  Requires-Dist: uvicorn[standard]>=0.24.0
25
25
  Requires-Dist: pydantic>=2.0.0
26
26
  Requires-Dist: sse-starlette>=1.0.0
27
- Provides-Extra: local-embeddings
28
- Requires-Dist: sentence-transformers>=2.2.0; extra == "local-embeddings"
29
- Provides-Extra: openai
30
- Requires-Dist: openai>=1.0.0; extra == "openai"
31
- Provides-Extra: ranker
32
- Requires-Dist: sentence-transformers>=2.2.0; extra == "ranker"
33
- Provides-Extra: single-file
34
- Requires-Dist: sqlite-vec>=0.1.0; extra == "single-file"
35
- Provides-Extra: all
36
- Requires-Dist: memorius[local-embeddings,openai,ranker,single-file]; extra == "all"
27
+ Requires-Dist: sentence-transformers>=2.6.0
28
+ Requires-Dist: openai>=1.10.0
29
+ Requires-Dist: sqlite-vec>=0.1.0
37
30
  Provides-Extra: dev
38
31
  Requires-Dist: pytest>=7.0; extra == "dev"
39
32
  Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
40
33
  Dynamic: license-file
41
34
 
42
35
  # Memorius
43
- <img width="1584" height="672" alt="memorius" src="https://github.com/user-attachments/assets/1a62163d-1ff5-4112-8b8f-1a6b19afab7a" />
36
+ <img alt="memorius" src="assets/banner.png" />
44
37
 
45
38
  <p align="center">
46
39
  <strong>Universal memory vault for any AI agent.</strong>
@@ -52,7 +45,7 @@ Dynamic: license-file
52
45
  <a href="https://github.com/Dream-Pixels-Forge/memorius/actions/workflows/publish.yml"><img src="https://github.com/Dream-Pixels-Forge/memorius/actions/workflows/publish.yml/badge.svg" alt="PyPI publish"></a>
53
46
  <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
54
47
  <a href="https://python.org"><img src="https://img.shields.io/badge/python-3.11%20%7C%203.12-blue" alt="Python 3.11 | 3.12"></a>
55
- <a href="https://github.com/Dream-Pixels-Forge/memorius/blob/main/CHANGELOG.md"><img src="https://img.shields.io/badge/changelog-v1.2.4-green" alt="Changelog"></a>
48
+ <a href="https://github.com/Dream-Pixels-Forge/memorius/blob/main/CHANGELOG.md"><img src="https://img.shields.io/badge/changelog-v0.7.1-green" alt="Changelog"></a>
56
49
  </p>
57
50
 
58
51
  > **Works with Claude Code, Codex CLI, Gemini CLI, OpenClaude, OpenCode, Pi, and any MCP-compatible agent.**
@@ -117,23 +110,33 @@ memorius diary "session-001" --title "Research findings"
117
110
 
118
111
  <img width="979" height="632" alt="Screenshot from 2026-06-18 15-14-39" src="https://github.com/user-attachments/assets/6cbca59b-1a3c-4b1d-85e8-dd5b256b9061" />
119
112
 
120
- ## What's New in v1.2.4
113
+ ## What's New in v0.7.2
121
114
 
122
- ### Architecture Improvements
123
- - **Modular VaultEngine** — extracted `SearchModule` (5-stage search pipeline) and `StoreModule` (CRUD operations). VaultEngine reduced from 692 to 328 lines (53% reduction).
124
- - **VectorStore ABC** — `ChromaStore` and `SqliteVecStore` now share an abstract base class for swappable backends.
125
- - **Sealed `_conn()` leakage** — all external callers now use `SQLiteStore` public API (`execute`, `fetchone`, `fetchall`, `transaction`, graph/temporal adapters, import/export methods).
126
- - **Shared Obsidian module** — `memorius/obsidian.py` consolidates helpers used by REST server and CLI.
115
+ ### Security Hardening (Comprehensive Audit)
116
+ - **Path traversal guards** — `export_graph_html` and CLI obsidian export validate paths stay within storage/export directories.
117
+ - **HTML injection prevention** — Graph visualizer title escaped via `html.escape()`.
118
+ - **Command injection prevention** — Windows daemon startup passes host/port/tls via environment variables.
119
+ - **LLM extraction trust model** — System/user message split isolates extraction rules from user-provided content.
120
+ - **Template injection prevention** — Recursion depth limited to 10 in `_substitute_templates`.
121
+ - **UUID validation on MCP handlers** — `memorius_contradictions`, `memorius_get`, `memorius_update`, `memorius_delete` validate memory_id format.
122
+ - **Dependency version pinning** — Minimum versions set for `chromadb>=0.4.22`, `sentence-transformers>=2.6.0`, `httpx>=0.25.0`, `openai>=1.1.0`.
123
+ - **Rate limiter memory growth fix** — Sweep every 200 requests + IP count cap (10,000).
124
+ - **Security documentation** — MCP tool descriptions include security notes about treating memory content as untrusted data.
127
125
 
128
- ### Code Quality
129
- - **Type hints** — complete type annotations on `SQLiteStore` public API.
130
- - **Exception documentation** — 30 `except Exception` blocks annotated as best-effort with reasons.
131
- - **Dead code removal** — HNSW switchover path removed from consolidation.
132
- - **26 new tests** — `SearchModule` (7) and `StoreModule` (19) unit tests.
126
+ ### Added
127
+ - **Knowledge Graph Visualizer** — standalone interactive HTML visualization with force-directed canvas layout, shelf/relation filtering, search, node inspector, and zoom/pan. Access via `memorius graph view`, MCP `memorius_graph_export`, or REST `GET /graph/view`.
128
+ - **Dependency consolidation** — `sentence-transformers`, `openai`, `sqlite-vec` now included by default. `pip install memorius` includes all features.
133
129
 
134
- ### Fixed
135
- - **Version mismatch** — `__version__` now uses `importlib.metadata.version()` consistently.
136
- - **Legacy test references** — `test_features.py` updated to use `SQLiteStore` public API instead of `_conn()`.
130
+ ### Code Quality
131
+ - **Extracted lazy-init pattern** — `_get_store_module()` and `_get_search_module()` helpers eliminate repeated init blocks.
132
+ - **Deduplicated JSON parsing** — `safe_parse_json()` in `utils.py` used across `store_module.py` and `search_module.py`.
133
+ - **Missing exception annotations** — 6 unannotated blocks in `doctor.py` and `context_inject.py` now documented.
134
+ - **Sealed message-chain breach** — MCP server calls `engine.list_diaries()` instead of `_engine._meta.list_diaries()`.
135
+ - **Clarified HookEngine naming** — `_get_engine()` → `_get_vault()` to avoid confusion with the HookEngine class.
136
+ - **SQL PRAGMA whitelist** — `_get_columns()` validates table names against `frozenset` allowlist.
137
+ - **UUID validation on batch queries** — `get_memory_meta_batch()` validates IDs before `WHERE IN` interpolation.
138
+ - **Typed module dependencies** — `SearchModule`/`StoreModule` `meta` param typed as `SQLiteStore` (was `Any`).
139
+ - **Scoped connection cleanup** — `VaultEngine.close()` closes only its own DB, not all thread connections.
137
140
 
138
141
  See [CHANGELOG.md](CHANGELOG.md) for full release history.
139
142
 
@@ -237,8 +240,8 @@ Memorius uses a **VectorStore ABC** (abstract base class) for swappable backends
237
240
 
238
241
  | Backend | Dependency | Description |
239
242
  |---|---|---|
240
- | **ChromaDB** (default) | ChromaDB (bundled) | Persistent HNSW cosine search, auto-migration from legacy collections |
241
- | **SQLite-vec** | `pip install memorius[single-file]` | Single-file alternative, no ChromaDB dependency |
243
+ | **ChromaDB** (default) | ChromaDB (included) | Persistent HNSW cosine search, auto-migration from legacy collections |
244
+ | **SQLite-vec** | SQLite-vec (included) | Single-file alternative, no ChromaDB dependency |
242
245
 
243
246
  Set `storage.type: sqlite-vec` in config or `MEMORIUS_STORAGE_TYPE=sqlite-vec` to use the lightweight backend.
244
247
 
@@ -246,8 +249,8 @@ Set `storage.type: sqlite-vec` in config or `MEMORIUS_STORAGE_TYPE=sqlite-vec` t
246
249
 
247
250
  | Provider | Requirement | Quality |
248
251
  |---|---|---|
249
- | `chroma-default` | ChromaDB (bundled ONNX) | Good (384d) |
250
- | `sentence-transformers` | `pip install memorius[local-embeddings]` | Better (768d+) |
252
+ | `chroma-default` | ChromaDB (included ONNX) | Good (384d) |
253
+ | `sentence-transformers` | `sentence-transformers` (included) | Better (768d+) |
251
254
  | `openai` | `OPENAI_API_KEY` env var | Best (1536d or 3072d) |
252
255
 
253
256
  Providers are extensible via `EmbeddingFactory.register()` for custom runtime providers.
@@ -267,7 +270,8 @@ Memorius implements the **Ebbinghaus forgetting curve** to rank memories by rele
267
270
  - **Auto-linking** on every `store()` call by content proximity (Jaccard word overlap or vector cosine)
268
271
  - **BFS expansion** from search results (`--expand-graph` on CLI / `expand_graph=True` on MCP)
269
272
  - **Contradiction edges** persisted by `factcheck` (bidirectional `relation='contradicts'`)
270
- - **Graph statistics** via `memorius stats` or MCP `memorius_graph_stats`
273
+ - **Graph visualization** via `memorius graph view` (interactive browser UI) or `memorius graph export-html`
274
+ - **Graph statistics** via `memorius graph stats` or MCP `memorius_graph_stats`
271
275
 
272
276
  ## Cross-Encoder Reranker
273
277
 
@@ -279,7 +283,7 @@ memorius search "query" --rerank
279
283
 
280
284
  | Provider | Requirement |
281
285
  |---|---|
282
- | `cross-encoder/ms-marco-MiniLM-L-6-v2` | `pip install memorius[ranker]` |
286
+ | `cross-encoder/ms-marco-MiniLM-L-6-v2` | Included out of the box (`pip install memorius`) |
283
287
 
284
288
  Adds `__rerank_score__` to memory metadata for transparent ranking.
285
289
 
@@ -518,7 +522,7 @@ any MCP-compatible client by pointing it at the MCP server:
518
522
  }
519
523
  ```
520
524
 
521
- Available MCP tools (22 tools):
525
+ Available MCP tools (21 tools):
522
526
 
523
527
  | Tool | Description |
524
528
  |---|---|
@@ -799,15 +803,17 @@ All inputs are validated for security and correctness:
799
803
  - CORS: restrictive by default (Obsidian app + localhost only)
800
804
  - Path traversal detection on Obsidian export
801
805
 
802
- ## Optional Dependencies
806
+ ## Installation
807
+
808
+ ```bash
809
+ pip install memorius
810
+ ```
811
+
812
+ All features — including the cross-encoder reranker, sentence-transformers, SQLite-vec single-file backend, OpenAI embeddings support, REST API server, and graph visualizer — are included out of the box.
803
813
 
814
+ For development:
804
815
  ```bash
805
- pip install memorius[local-embeddings] # sentence-transformers
806
- pip install memorius[openai] # openai SDK
807
- pip install memorius[ranker] # cross-encoder reranker
808
- pip install memorius[single-file] # sqlite-vec backend
809
- pip install memorius[all] # everything above
810
- pip install memorius[dev] # pytest + pytest-asyncio
816
+ pip install memorius[dev] # pytest + pytest-asyncio
811
817
  ```
812
818
 
813
819
  ## Installed CLI Entry Points
@@ -1,5 +1,5 @@
1
1
  # Memorius
2
- <img width="1584" height="672" alt="memorius" src="https://github.com/user-attachments/assets/1a62163d-1ff5-4112-8b8f-1a6b19afab7a" />
2
+ <img alt="memorius" src="assets/banner.png" />
3
3
 
4
4
  <p align="center">
5
5
  <strong>Universal memory vault for any AI agent.</strong>
@@ -11,7 +11,7 @@
11
11
  <a href="https://github.com/Dream-Pixels-Forge/memorius/actions/workflows/publish.yml"><img src="https://github.com/Dream-Pixels-Forge/memorius/actions/workflows/publish.yml/badge.svg" alt="PyPI publish"></a>
12
12
  <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
13
13
  <a href="https://python.org"><img src="https://img.shields.io/badge/python-3.11%20%7C%203.12-blue" alt="Python 3.11 | 3.12"></a>
14
- <a href="https://github.com/Dream-Pixels-Forge/memorius/blob/main/CHANGELOG.md"><img src="https://img.shields.io/badge/changelog-v1.2.4-green" alt="Changelog"></a>
14
+ <a href="https://github.com/Dream-Pixels-Forge/memorius/blob/main/CHANGELOG.md"><img src="https://img.shields.io/badge/changelog-v0.7.1-green" alt="Changelog"></a>
15
15
  </p>
16
16
 
17
17
  > **Works with Claude Code, Codex CLI, Gemini CLI, OpenClaude, OpenCode, Pi, and any MCP-compatible agent.**
@@ -76,23 +76,33 @@ memorius diary "session-001" --title "Research findings"
76
76
 
77
77
  <img width="979" height="632" alt="Screenshot from 2026-06-18 15-14-39" src="https://github.com/user-attachments/assets/6cbca59b-1a3c-4b1d-85e8-dd5b256b9061" />
78
78
 
79
- ## What's New in v1.2.4
79
+ ## What's New in v0.7.2
80
80
 
81
- ### Architecture Improvements
82
- - **Modular VaultEngine** — extracted `SearchModule` (5-stage search pipeline) and `StoreModule` (CRUD operations). VaultEngine reduced from 692 to 328 lines (53% reduction).
83
- - **VectorStore ABC** — `ChromaStore` and `SqliteVecStore` now share an abstract base class for swappable backends.
84
- - **Sealed `_conn()` leakage** — all external callers now use `SQLiteStore` public API (`execute`, `fetchone`, `fetchall`, `transaction`, graph/temporal adapters, import/export methods).
85
- - **Shared Obsidian module** — `memorius/obsidian.py` consolidates helpers used by REST server and CLI.
81
+ ### Security Hardening (Comprehensive Audit)
82
+ - **Path traversal guards** — `export_graph_html` and CLI obsidian export validate paths stay within storage/export directories.
83
+ - **HTML injection prevention** — Graph visualizer title escaped via `html.escape()`.
84
+ - **Command injection prevention** — Windows daemon startup passes host/port/tls via environment variables.
85
+ - **LLM extraction trust model** — System/user message split isolates extraction rules from user-provided content.
86
+ - **Template injection prevention** — Recursion depth limited to 10 in `_substitute_templates`.
87
+ - **UUID validation on MCP handlers** — `memorius_contradictions`, `memorius_get`, `memorius_update`, `memorius_delete` validate memory_id format.
88
+ - **Dependency version pinning** — Minimum versions set for `chromadb>=0.4.22`, `sentence-transformers>=2.6.0`, `httpx>=0.25.0`, `openai>=1.1.0`.
89
+ - **Rate limiter memory growth fix** — Sweep every 200 requests + IP count cap (10,000).
90
+ - **Security documentation** — MCP tool descriptions include security notes about treating memory content as untrusted data.
86
91
 
87
- ### Code Quality
88
- - **Type hints** — complete type annotations on `SQLiteStore` public API.
89
- - **Exception documentation** — 30 `except Exception` blocks annotated as best-effort with reasons.
90
- - **Dead code removal** — HNSW switchover path removed from consolidation.
91
- - **26 new tests** — `SearchModule` (7) and `StoreModule` (19) unit tests.
92
+ ### Added
93
+ - **Knowledge Graph Visualizer** — standalone interactive HTML visualization with force-directed canvas layout, shelf/relation filtering, search, node inspector, and zoom/pan. Access via `memorius graph view`, MCP `memorius_graph_export`, or REST `GET /graph/view`.
94
+ - **Dependency consolidation** — `sentence-transformers`, `openai`, `sqlite-vec` now included by default. `pip install memorius` includes all features.
92
95
 
93
- ### Fixed
94
- - **Version mismatch** — `__version__` now uses `importlib.metadata.version()` consistently.
95
- - **Legacy test references** — `test_features.py` updated to use `SQLiteStore` public API instead of `_conn()`.
96
+ ### Code Quality
97
+ - **Extracted lazy-init pattern** — `_get_store_module()` and `_get_search_module()` helpers eliminate repeated init blocks.
98
+ - **Deduplicated JSON parsing** — `safe_parse_json()` in `utils.py` used across `store_module.py` and `search_module.py`.
99
+ - **Missing exception annotations** — 6 unannotated blocks in `doctor.py` and `context_inject.py` now documented.
100
+ - **Sealed message-chain breach** — MCP server calls `engine.list_diaries()` instead of `_engine._meta.list_diaries()`.
101
+ - **Clarified HookEngine naming** — `_get_engine()` → `_get_vault()` to avoid confusion with the HookEngine class.
102
+ - **SQL PRAGMA whitelist** — `_get_columns()` validates table names against `frozenset` allowlist.
103
+ - **UUID validation on batch queries** — `get_memory_meta_batch()` validates IDs before `WHERE IN` interpolation.
104
+ - **Typed module dependencies** — `SearchModule`/`StoreModule` `meta` param typed as `SQLiteStore` (was `Any`).
105
+ - **Scoped connection cleanup** — `VaultEngine.close()` closes only its own DB, not all thread connections.
96
106
 
97
107
  See [CHANGELOG.md](CHANGELOG.md) for full release history.
98
108
 
@@ -196,8 +206,8 @@ Memorius uses a **VectorStore ABC** (abstract base class) for swappable backends
196
206
 
197
207
  | Backend | Dependency | Description |
198
208
  |---|---|---|
199
- | **ChromaDB** (default) | ChromaDB (bundled) | Persistent HNSW cosine search, auto-migration from legacy collections |
200
- | **SQLite-vec** | `pip install memorius[single-file]` | Single-file alternative, no ChromaDB dependency |
209
+ | **ChromaDB** (default) | ChromaDB (included) | Persistent HNSW cosine search, auto-migration from legacy collections |
210
+ | **SQLite-vec** | SQLite-vec (included) | Single-file alternative, no ChromaDB dependency |
201
211
 
202
212
  Set `storage.type: sqlite-vec` in config or `MEMORIUS_STORAGE_TYPE=sqlite-vec` to use the lightweight backend.
203
213
 
@@ -205,8 +215,8 @@ Set `storage.type: sqlite-vec` in config or `MEMORIUS_STORAGE_TYPE=sqlite-vec` t
205
215
 
206
216
  | Provider | Requirement | Quality |
207
217
  |---|---|---|
208
- | `chroma-default` | ChromaDB (bundled ONNX) | Good (384d) |
209
- | `sentence-transformers` | `pip install memorius[local-embeddings]` | Better (768d+) |
218
+ | `chroma-default` | ChromaDB (included ONNX) | Good (384d) |
219
+ | `sentence-transformers` | `sentence-transformers` (included) | Better (768d+) |
210
220
  | `openai` | `OPENAI_API_KEY` env var | Best (1536d or 3072d) |
211
221
 
212
222
  Providers are extensible via `EmbeddingFactory.register()` for custom runtime providers.
@@ -226,7 +236,8 @@ Memorius implements the **Ebbinghaus forgetting curve** to rank memories by rele
226
236
  - **Auto-linking** on every `store()` call by content proximity (Jaccard word overlap or vector cosine)
227
237
  - **BFS expansion** from search results (`--expand-graph` on CLI / `expand_graph=True` on MCP)
228
238
  - **Contradiction edges** persisted by `factcheck` (bidirectional `relation='contradicts'`)
229
- - **Graph statistics** via `memorius stats` or MCP `memorius_graph_stats`
239
+ - **Graph visualization** via `memorius graph view` (interactive browser UI) or `memorius graph export-html`
240
+ - **Graph statistics** via `memorius graph stats` or MCP `memorius_graph_stats`
230
241
 
231
242
  ## Cross-Encoder Reranker
232
243
 
@@ -238,7 +249,7 @@ memorius search "query" --rerank
238
249
 
239
250
  | Provider | Requirement |
240
251
  |---|---|
241
- | `cross-encoder/ms-marco-MiniLM-L-6-v2` | `pip install memorius[ranker]` |
252
+ | `cross-encoder/ms-marco-MiniLM-L-6-v2` | Included out of the box (`pip install memorius`) |
242
253
 
243
254
  Adds `__rerank_score__` to memory metadata for transparent ranking.
244
255
 
@@ -477,7 +488,7 @@ any MCP-compatible client by pointing it at the MCP server:
477
488
  }
478
489
  ```
479
490
 
480
- Available MCP tools (22 tools):
491
+ Available MCP tools (21 tools):
481
492
 
482
493
  | Tool | Description |
483
494
  |---|---|
@@ -758,15 +769,17 @@ All inputs are validated for security and correctness:
758
769
  - CORS: restrictive by default (Obsidian app + localhost only)
759
770
  - Path traversal detection on Obsidian export
760
771
 
761
- ## Optional Dependencies
772
+ ## Installation
773
+
774
+ ```bash
775
+ pip install memorius
776
+ ```
777
+
778
+ All features — including the cross-encoder reranker, sentence-transformers, SQLite-vec single-file backend, OpenAI embeddings support, REST API server, and graph visualizer — are included out of the box.
762
779
 
780
+ For development:
763
781
  ```bash
764
- pip install memorius[local-embeddings] # sentence-transformers
765
- pip install memorius[openai] # openai SDK
766
- pip install memorius[ranker] # cross-encoder reranker
767
- pip install memorius[single-file] # sqlite-vec backend
768
- pip install memorius[all] # everything above
769
- pip install memorius[dev] # pytest + pytest-asyncio
782
+ pip install memorius[dev] # pytest + pytest-asyncio
770
783
  ```
771
784
 
772
785
  ## Installed CLI Entry Points
@@ -3,7 +3,7 @@
3
3
  # Run: memorius-plugin-gen generate
4
4
 
5
5
  name: memorius
6
- version: "0.7.0"
6
+ version: "1.2.3"
7
7
  description: "Memorius — universal memory vault for any AI agent"
8
8
  author: "dimonapatrick243"
9
9
  license: MIT
@@ -13,17 +13,29 @@ from __future__ import annotations
13
13
 
14
14
  import json
15
15
  import logging
16
- import os
17
- import re
18
16
  from datetime import datetime, timezone
19
17
  from pathlib import Path
20
18
  from typing import Any
21
19
 
20
+ from memorius.validation import validate_name as _validate_name
21
+
22
22
  logger = logging.getLogger("memorius.backup")
23
23
 
24
24
  SCHEMA_VERSION = 1
25
25
 
26
26
 
27
+ def _validate_hierarchy_names(vaults, shelves, folders, notes):
28
+ """Validate all hierarchy items before import to prevent path traversal."""
29
+ for v in vaults:
30
+ _validate_name(v.get("name", ""), "vault")
31
+ for s in shelves:
32
+ _validate_name(s.get("name", ""), "shelf")
33
+ for f in folders:
34
+ _validate_name(f.get("name", ""), "folder")
35
+ for n in notes:
36
+ _validate_name(n.get("name", ""), "note")
37
+
38
+
27
39
  # ── JSON export / import ─────────────────────────────────────────────────────
28
40
 
29
41
  def export_json(engine, dest: str | Path) -> Path:
@@ -99,6 +111,13 @@ def import_json(engine, src: str | Path, *, merge: bool = True) -> dict[str, Any
99
111
  }
100
112
 
101
113
  # ── hierarchy ──
114
+ # Validate all imported names to prevent path traversal
115
+ _validate_hierarchy_names(
116
+ payload.get("vaults", []),
117
+ payload.get("shelves", []),
118
+ payload.get("folders", []),
119
+ payload.get("notes", []),
120
+ )
102
121
  engine._meta.import_hierarchy(
103
122
  payload.get("vaults", []),
104
123
  payload.get("shelves", []),
@@ -112,6 +131,11 @@ def import_json(engine, src: str | Path, *, merge: bool = True) -> dict[str, Any
112
131
 
113
132
  # ── memories ──
114
133
  for m in payload.get("memories", []):
134
+ # Validate memory hierarchy fields to prevent path traversal
135
+ _validate_name(m["vault"], "vault")
136
+ _validate_name(m["shelf"], "shelf")
137
+ _validate_name(m["folder"], "folder")
138
+ _validate_name(m["note"], "note")
115
139
  engine._meta.ensure_note(m["vault"], m["shelf"], m["folder"], m["note"])
116
140
  imported = engine._meta.import_memory_meta(m, merge=merge)
117
141
  if imported:
@@ -174,6 +198,11 @@ def export_markdown(engine, dest: str | Path) -> Path:
174
198
  edges = edges_by_source.get(mid, [])
175
199
 
176
200
  md_path = dest / m["vault"] / m["shelf"] / m["folder"] / m["note"] / f"{mid}.md"
201
+ # Path traversal guard: resolved path must stay inside dest
202
+ md_path_resolved = md_path.resolve()
203
+ if not str(md_path_resolved).startswith(str(dest.resolve())):
204
+ logger.warning("Skipping memory %s: path traversal detected", mid)
205
+ continue
177
206
  md_path.parent.mkdir(parents=True, exist_ok=True)
178
207
 
179
208
  # Build YAML frontmatter
@@ -20,6 +20,8 @@ Usage:
20
20
  memorius obsidian list List notes in an Obsidian vault
21
21
  memorius obsidian import Import Obsidian notes as memories
22
22
  memorius obsidian export Export memories as Obsidian notes
23
+ memorius graph view Open interactive knowledge graph in browser
24
+ memorius graph export-html Export standalone interactive HTML graph
23
25
  memorius web <query> Search the internet (web fallback)
24
26
  """
25
27
 
@@ -119,10 +121,12 @@ def main():
119
121
 
120
122
  serve_rest_p = subparsers.add_parser("serve-rest", help="Start REST API server")
121
123
  serve_rest_p.add_argument("--port", type=int, default=None, help="Port")
122
- serve_rest_p.add_argument("--host", default=None, help="Host")
124
+ serve_rest_p.add_argument("--host", default=None, help="Host (default: 127.0.0.1)")
123
125
  serve_rest_p.add_argument("--daemon", action="store_true", help="Run as background daemon")
124
126
  serve_rest_p.add_argument("--stop", action="store_true", help="Stop running daemon")
125
127
  serve_rest_p.add_argument("--pid-file", default=None, help="PID file path")
128
+ serve_rest_p.add_argument("--tls-cert", default=None, help="Path to TLS certificate file (PEM)")
129
+ serve_rest_p.add_argument("--tls-key", default=None, help="Path to TLS private key file (PEM)")
126
130
 
127
131
  config_p = subparsers.add_parser("config", help="Show configuration")
128
132
  config_p.add_argument("--show", action="store_true", default=True, help="Show config")
@@ -201,6 +205,35 @@ def main():
201
205
  list_p.add_argument("--limit", type=int, default=10, help="Max results per page (default: 10)")
202
206
  list_p.add_argument("--cursor", help="Cursor for next page (timestamp)")
203
207
 
208
+ # ── Graph subcommands ──
209
+ graph_p = subparsers.add_parser("graph", help="Visualize and inspect the knowledge graph")
210
+ graph_sub = graph_p.add_subparsers(dest="subcommand")
211
+
212
+ g_view_p = graph_sub.add_parser("view", help="Open interactive knowledge graph visualizer in browser")
213
+ g_view_p.add_argument("--vault", default=None, help="Filter by vault")
214
+ g_view_p.add_argument("--shelf", default=None, help="Filter by shelf")
215
+ g_view_p.add_argument("--relation", default=None, help="Filter by relation type (related, references, contradicts, co_occurs)")
216
+ g_view_p.add_argument("--min-weight", type=float, default=0.0, help="Minimum edge weight (default: 0.0)")
217
+ g_view_p.add_argument("--limit", type=int, default=500, help="Max nodes to display (default: 500)")
218
+ g_view_p.add_argument("--no-open", action="store_true", help="Don't open browser automatically")
219
+
220
+ g_export_p = graph_sub.add_parser("export-html", help="Export standalone interactive HTML visualization")
221
+ g_export_p.add_argument("--output", "-o", default="graph.html", help="Output file path (default: graph.html)")
222
+ g_export_p.add_argument("--vault", default=None, help="Filter by vault")
223
+ g_export_p.add_argument("--shelf", default=None, help="Filter by shelf")
224
+ g_export_p.add_argument("--relation", default=None, help="Filter by relation type")
225
+ g_export_p.add_argument("--min-weight", type=float, default=0.0, help="Minimum edge weight")
226
+ g_export_p.add_argument("--limit", type=int, default=500, help="Max nodes to export")
227
+
228
+ graph_sub.add_parser("stats", help="Display knowledge graph statistics")
229
+
230
+ g_json_p = graph_sub.add_parser("json", help="Output graph topology as JSON")
231
+ g_json_p.add_argument("--vault", default=None, help="Filter by vault")
232
+ g_json_p.add_argument("--shelf", default=None, help="Filter by shelf")
233
+ g_json_p.add_argument("--relation", default=None, help="Filter by relation type")
234
+ g_json_p.add_argument("--min-weight", type=float, default=0.0, help="Minimum edge weight")
235
+ g_json_p.add_argument("--limit", type=int, default=500, help="Max nodes")
236
+
204
237
  # ── Obsidian subcommands ──
205
238
  obsidian_p = subparsers.add_parser("obsidian", help="Interact with Obsidian vaults")
206
239
  obsidian_sub = obsidian_p.add_subparsers(dest="subcommand")
@@ -268,6 +301,7 @@ def main():
268
301
  "import": cmd_import,
269
302
  "doctor": cmd_doctor,
270
303
  "list": cmd_list,
304
+ "graph": cmd_graph,
271
305
  }
272
306
  handler = commands.get(args.command)
273
307
  if handler:
@@ -525,10 +559,18 @@ def cmd_serve_rest(engine, args, config):
525
559
  return
526
560
 
527
561
  if getattr(args, "daemon", False):
528
- _start_daemon(engine, host, port, pid_file)
562
+ _start_daemon(
563
+ engine, host, port, pid_file,
564
+ tls_cert=getattr(args, "tls_cert", None),
565
+ tls_key=getattr(args, "tls_key", None),
566
+ )
529
567
  else:
530
568
  from memorius.rest_server import run_rest_server
531
- run_rest_server(engine, host=host, port=port)
569
+ run_rest_server(
570
+ engine, host=host, port=port,
571
+ tls_cert=getattr(args, "tls_cert", None),
572
+ tls_key=getattr(args, "tls_key", None),
573
+ )
532
574
 
533
575
 
534
576
  def _stop_daemon(pid_file: Path):
@@ -582,27 +624,44 @@ def _stop_daemon(pid_file: Path):
582
624
  print("Daemon stopped")
583
625
 
584
626
 
585
- def _start_daemon(engine, host: str, port: int, pid_file: Path):
627
+ def _start_daemon(engine, host: str, port: int, pid_file: Path,
628
+ tls_cert: str | None = None, tls_key: str | None = None):
586
629
  """Start REST server as a background daemon."""
587
630
  import os
588
631
  import sys
589
632
  import subprocess
590
- import time
591
633
 
592
634
  if os.name == "nt":
593
635
  # Windows: launch detached subprocess
594
- cmd = [
595
- sys.executable, "-c",
596
- (
597
- f"import os, sys; sys.stdin = open(os.devnull); "
598
- f"Path('{pid_file}').write_text(str(os.getpid())); "
599
- f"from memorius.rest_server import run_rest_server; "
600
- f"from memorius.vault import VaultEngine; "
601
- f"from memorius.config import load_config; "
602
- f"e = VaultEngine(load_config()); "
603
- f"run_rest_server(e, host='{host}', port={port})"
604
- ),
605
- ]
636
+ # Pass configuration via environment variables to avoid shell injection
637
+ # through string interpolation in python -c commands.
638
+ daemon_script = (
639
+ "import os, sys; sys.stdin = open(os.devnull); "
640
+ "from pathlib import Path; "
641
+ "Path(os.environ['MEMORIUS_PID_FILE']).write_text(str(os.getpid())); "
642
+ "from memorius.rest_server import run_rest_server; "
643
+ "from memorius.vault import VaultEngine; "
644
+ "from memorius.config import load_config; "
645
+ "e = VaultEngine(load_config()); "
646
+ "run_rest_server(e, host=os.environ['MEMORIUS_HOST'], "
647
+ "port=int(os.environ['MEMORIUS_PORT']))"
648
+ )
649
+ env = os.environ.copy()
650
+ env["MEMORIUS_HOST"] = host
651
+ env["MEMORIUS_PORT"] = str(port)
652
+ env["MEMORIUS_PID_FILE"] = str(pid_file)
653
+ if tls_cert and tls_key:
654
+ env["MEMORIUS_TLS_CERT"] = tls_cert
655
+ env["MEMORIUS_TLS_KEY"] = tls_key
656
+ daemon_script = daemon_script.replace(
657
+ "run_rest_server(e, host=os.environ['MEMORIUS_HOST'], "
658
+ "port=int(os.environ['MEMORIUS_PORT']))",
659
+ "run_rest_server(e, host=os.environ['MEMORIUS_HOST'], "
660
+ "port=int(os.environ['MEMORIUS_PORT']), "
661
+ "tls_cert=os.environ.get('MEMORIUS_TLS_CERT'), "
662
+ "tls_key=os.environ.get('MEMORIUS_TLS_KEY'))",
663
+ )
664
+ cmd = [sys.executable, "-c", daemon_script]
606
665
  proc = subprocess.Popen(
607
666
  cmd,
608
667
  creationflags=subprocess.CREATE_NO_WINDOW | subprocess.DETACHED_PROCESS,
@@ -848,6 +907,58 @@ def cmd_stats(engine, args, config):
848
907
  print(f" {rel}: {count}")
849
908
 
850
909
 
910
+ def cmd_graph(engine, args, config):
911
+ """Inspect or visualize knowledge graph."""
912
+ sub = getattr(args, "subcommand", None)
913
+ if sub == "stats" or sub is None:
914
+ graph_stats = engine.get_graph_stats()
915
+ print(" Knowledge Graph Statistics:")
916
+ print(f" Nodes: {graph_stats.get('unique_nodes', 0)}")
917
+ print(f" Edges: {graph_stats.get('total_edges', 0)}")
918
+ relations = graph_stats.get('relations', {})
919
+ if relations:
920
+ print(" Relations:")
921
+ for rel, count in relations.items():
922
+ print(f" {rel}: {count}")
923
+ elif sub == "export-html":
924
+ out_file = args.output
925
+ engine.export_graph_html(
926
+ dest=out_file,
927
+ vault=args.vault,
928
+ shelf=args.shelf,
929
+ relation=args.relation,
930
+ min_weight=args.min_weight,
931
+ limit=args.limit,
932
+ )
933
+ print(f"Knowledge graph visualization saved to: {Path(out_file).resolve()}")
934
+ elif sub == "view":
935
+ import tempfile
936
+ import webbrowser
937
+ temp_dir = Path(tempfile.gettempdir())
938
+ html_file = temp_dir / "memorius_graph.html"
939
+ engine.export_graph_html(
940
+ dest=str(html_file),
941
+ vault=args.vault,
942
+ shelf=args.shelf,
943
+ relation=args.relation,
944
+ min_weight=args.min_weight,
945
+ limit=args.limit,
946
+ )
947
+ print(f"Generated visualization: {html_file}")
948
+ if not getattr(args, "no_open", False):
949
+ print("Opening in default browser...")
950
+ webbrowser.open(f"file://{html_file.resolve()}")
951
+ elif sub == "json":
952
+ data = engine.get_graph_data(
953
+ vault=args.vault,
954
+ shelf=args.shelf,
955
+ relation=args.relation,
956
+ min_weight=args.min_weight,
957
+ limit=args.limit,
958
+ )
959
+ print(json.dumps(data, indent=2))
960
+
961
+
851
962
  def cmd_get(engine, args, config):
852
963
  """Get a single memory by ID."""
853
964
  from memorius.validation import validate_memory_id as _vid
@@ -11,9 +11,7 @@ Usage:
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- import json
15
14
  import logging
16
- import os
17
15
  import re
18
16
  from pathlib import Path
19
17
  from typing import Any
@@ -21,10 +19,7 @@ from typing import Any
21
19
  from memorius.vault import VaultEngine
22
20
  from memorius.obsidian import (
23
21
  resolve_vault_path as _resolve_vault_path,
24
- scan_vault as _scan_vault,
25
- parse_note as _parse_note,
26
22
  parse_frontmatter as _parse_frontmatter,
27
- FRONTMATTER_RE,
28
23
  )
29
24
 
30
25
  logger = logging.getLogger("memorius.obsidian")
@@ -158,7 +153,7 @@ def import_notes(
158
153
  metadata["obsidian_path"] = str(relative)
159
154
  metadata["obsidian_title"] = title
160
155
 
161
- memory = engine.store(
156
+ engine.store(
162
157
  content=store_content,
163
158
  vault=target_vault,
164
159
  shelf=target_shelf,
@@ -227,7 +222,14 @@ def export_memories(
227
222
  continue
228
223
 
229
224
  # Build output path mirroring hierarchy
230
- note_subdir = export_dir / sh["name"] / f["name"]
225
+ note_subdir = (export_dir / sh["name"] / f["name"]).resolve()
226
+ # Path traversal guard: must stay inside export_dir
227
+ if not str(note_subdir).startswith(str(export_dir.resolve())):
228
+ logger.warning(
229
+ "Skipping %s/%s/%s: path traversal detected",
230
+ sh["name"], f["name"], n["name"],
231
+ )
232
+ continue
231
233
  note_subdir.mkdir(parents=True, exist_ok=True)
232
234
  note_file = note_subdir / f"{n['name']}.md"
233
235