mediarag 0.1.14__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 (37) hide show
  1. mediarag-0.1.14/LICENSE +21 -0
  2. mediarag-0.1.14/PKG-INFO +229 -0
  3. mediarag-0.1.14/README.md +204 -0
  4. mediarag-0.1.14/__init__.py +140 -0
  5. mediarag-0.1.14/adapters/__init__.py +0 -0
  6. mediarag-0.1.14/adapters/dropbox +48 -0
  7. mediarag-0.1.14/adapters/obsidian +53 -0
  8. mediarag-0.1.14/adapters/pcloud +42 -0
  9. mediarag-0.1.14/adapters/raindrop +64 -0
  10. mediarag-0.1.14/adapters/rclone +68 -0
  11. mediarag-0.1.14/core/engine.py +238 -0
  12. mediarag-0.1.14/core/ingest.py +387 -0
  13. mediarag-0.1.14/core/search.py +49 -0
  14. mediarag-0.1.14/core/store.py +221 -0
  15. mediarag-0.1.14/core/tools.py +265 -0
  16. mediarag-0.1.14/core/urlsafe.py +73 -0
  17. mediarag-0.1.14/mcp.json +9 -0
  18. mediarag-0.1.14/mediarag-config.example.yaml +4 -0
  19. mediarag-0.1.14/mediarag.egg-info/PKG-INFO +229 -0
  20. mediarag-0.1.14/mediarag.egg-info/SOURCES.txt +56 -0
  21. mediarag-0.1.14/mediarag.egg-info/dependency_links.txt +1 -0
  22. mediarag-0.1.14/mediarag.egg-info/entry_points.txt +2 -0
  23. mediarag-0.1.14/mediarag.egg-info/requires.txt +8 -0
  24. mediarag-0.1.14/mediarag.egg-info/top_level.txt +1 -0
  25. mediarag-0.1.14/plugin.json +20 -0
  26. mediarag-0.1.14/plugin.yaml +29 -0
  27. mediarag-0.1.14/pyproject.toml +43 -0
  28. mediarag-0.1.14/requirements.txt +10 -0
  29. mediarag-0.1.14/scripts/fetch_model.py +16 -0
  30. mediarag-0.1.14/scripts/mcp_launch.py +104 -0
  31. mediarag-0.1.14/scripts/mcp_server.py +139 -0
  32. mediarag-0.1.14/setup.cfg +4 -0
  33. mediarag-0.1.14/tests/test_config_root.py +67 -0
  34. mediarag-0.1.14/tests/test_contract.py +338 -0
  35. mediarag-0.1.14/tests/test_engine_deref.py +91 -0
  36. mediarag-0.1.14/tests/test_ingest_units.py +166 -0
  37. mediarag-0.1.14/tests/test_parity_slices.py +77 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 D Yiapanis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,229 @@
1
+ Metadata-Version: 2.4
2
+ Name: mediarag
3
+ Version: 0.1.14
4
+ Summary: Multimodal local search over documents and media — pdf, office files, notes, images, audio and subtitles — from any MCP-capable agent.
5
+ Author: D Yiapanis
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/dyiapanis/mediarag
8
+ Project-URL: Issues, https://github.com/dyiapanis/mediarag/issues
9
+ Keywords: rag,mcp,search,embeddings,onnx,multimodal
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Text Processing :: Indexing
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: onnxruntime==1.29.0
17
+ Requires-Dist: numpy<3,>=2.0
18
+ Requires-Dist: pillow<13,>=11.0
19
+ Requires-Dist: tokenizers<1,>=0.20
20
+ Requires-Dist: transformers==5.19.0
21
+ Requires-Dist: torch<3,>=2.2
22
+ Requires-Dist: torchvision<1,>=0.17
23
+ Requires-Dist: huggingface_hub<3,>=0.26
24
+ Dynamic: license-file
25
+
26
+ # MediaRAG
27
+
28
+ ![MediaRAG — one semantic index over documents, images and audio](assets/banner.jpg)
29
+
30
+ Multimodal local semantic search over documents and media, packaged as an
31
+ agent-plugin per the [agent-plugins.org](https://agent-plugins.org/) 1.0
32
+ standard.
33
+
34
+ It indexes documents (pdf, office files, notes, html) and media (images, audio,
35
+ subtitles) into one local SQLite corpus, and you search both by meaning from any
36
+ MCP-capable agent. Video is not an ingest format: pull the soundtrack out first
37
+ (`ffmpeg -i clip.mp4 -vn -ac 1 -ar 16000 soundtrack.wav`) and index that — the
38
+ audio tower then makes the clip findable by what was said in it. "Which file mentions the
39
+ landing" and "where is the clip where I talked about it" are the same kind of
40
+ question.
41
+
42
+ How it runs:
43
+
44
+ - In-gateway: five native tools (`mediarag_search`, `mediarag_add_source`,
45
+ `mediarag_ingest_dir`, `mediarag_add_note`, `mediarag_status`) register once,
46
+ so one gateway serves all agents and the embedding model loads once.
47
+ - Cross-client: `mcp.json` declares a stdio MCP server, so Claude Desktop,
48
+ Cursor, and other Agent Plugins compliant clients load the same package.
49
+ Per the spec the entry uses a plugin-relative `command`
50
+ (`./scripts/mcp_launch.py`), never a bare `python3`: portable plugins must not
51
+ depend on `PATH` or ambient variables for resolution. That launcher is
52
+ stdlib-only and finds an interpreter that can import the engine
53
+ (numpy/onnxruntime/transformers/torch, ~500 MB), which Agent Plugins 1.0 has
54
+ no field to declare — so a client without a provisioned interpreter needs one
55
+ command, once:
56
+
57
+ ```bash
58
+ python3 -m venv .venv && .venv/bin/python -m pip install -r requirements.txt
59
+ ```
60
+
61
+ Order of preference: `$MEDIARAG_PYTHON`, the package's own `.venv/`, the
62
+ client's `$PLUGIN_DATA/venv`, then the interpreter that started it. With none
63
+ present it exits 3 naming what's missing instead of vanishing mid-handshake.
64
+
65
+ If `uv` is installed, it can own the environment instead — same
66
+ `requirements.txt`, resolved and cached by the installer:
67
+
68
+ ```bash
69
+ uv run --with-requirements requirements.txt -- python -c pass # once
70
+ ```
71
+
72
+ Set `MEDIARAG_AUTO_BOOTSTRAP=1` to have the launcher run that on start. It is
73
+ off by default: a first run pulls ~1.7 GB, longer than most clients wait.
74
+ - Local-only: no cloud services. The corpus lives at `<root>/.corpus/store.db`.
75
+
76
+ Documents and media are embedded by the same model into the same vector space
77
+ (see [Embedding model](#embedding-model)), which is why one index answers both
78
+ kinds of question.
79
+
80
+ ## Install
81
+
82
+ As a Python package (any MCP client, no clone):
83
+
84
+ ```bash
85
+ uvx --from git+https://github.com/dyiapanis/mediarag mediarag-mcp # runs it
86
+ pipx install git+https://github.com/dyiapanis/mediarag # or: mediarag-mcp on PATH
87
+ ```
88
+
89
+ As a Hermes plugin (dependencies provisioned by the host):
90
+
91
+ ```bash
92
+ hermes plugins install dyiapanis/mediarag
93
+ ```
94
+
95
+ The engine needs numpy, onnxruntime, transformers and torch (~1.7 GB with the
96
+ model weights); `pipx`/`uvx`/`hermes` install that for you. Running from a bare
97
+ checkout instead, see the MCP section below.
98
+
99
+ ## Features
100
+
101
+ - Hybrid retrieval: semantic vectors plus an FTS5/BM25 keyword lane, fused with
102
+ reciprocal rank fusion. Exact-string lookups and meaning searches both work.
103
+ - Documents and media in one index: pdf via `pdftotext`, docx/odt/epub/rst/html/csv/rtf
104
+ via `pandoc`, plus images, audio, and subtitles.
105
+ - Incremental re-indexing: unchanged files are skipped on an mtime+size
106
+ fingerprint.
107
+ - Source adapters: external collections through `sources.d` executables (see
108
+ below).
109
+
110
+ ## Install
111
+
112
+ Python dependencies: `pip install -r requirements.txt`. The system tools are
113
+ only needed for the lanes that use them: `ffmpeg` for audio, `pandoc` for
114
+ documents, `pdftotext` for pdf.
115
+
116
+ Runs on Linux and Windows. On Windows those system tools are not preinstalled —
117
+ `winget install Gyan.FFmpeg JohnMacFarlane.Pandoc oschwartz10612.Poppler` (or the
118
+ builds of your choice) and make sure they are on `PATH`. The corpus cache lives
119
+ at `~\.cache\mediarag`; adapters are python scripts and are run through the
120
+ current interpreter there, so they do not need the executable bit or a shebang.
121
+
122
+ The embedding weights are not downloaded on startup. Once, before first search:
123
+
124
+ ```bash
125
+ python scripts/fetch_model.py
126
+ ```
127
+
128
+ That is about 285MB into the Hugging Face cache; what is in it, and why, is
129
+ covered under [Embedding model](#embedding-model). After the download everything
130
+ runs offline (`HF_HUB_OFFLINE=1`).
131
+
132
+ ## Embedding model
133
+
134
+ One model embeds everything: [EmbeddingGemma
135
+ 2](https://huggingface.co/onnx-community/embeddinggemma-2-ONNX) in its ONNX q4
136
+ build, on onnxruntime, on the CPU. It ships three towers — the text trunk, a
137
+ vision encoder and an audio encoder — and all three write into the same
138
+ 768-dimensional space, so a pdf, a photograph and a clip of you talking about
139
+ both are directly comparable.
140
+
141
+ - **Snapshot** `daa72c51243991dfcaf9f9137d2c573d8f7790c0` of
142
+ `onnx-community/embeddinggemma-2-ONNX`. `scripts/fetch_model.py` pulls only the
143
+ q4 towers plus the processor configs, about 285MB, and never at startup. (A
144
+ cache that has also seen other quantisations of this model will be larger —
145
+ 482MB on the machine this was written on — because those blobs are still there.)
146
+ - **768 dimensions**, L2-normalised, stored as f32 blobs (3,072 bytes per row).
147
+ Retrieval is exact cosine over the whole corpus; there is no approximate index.
148
+ - **1024-token cap** on the text trunk. Attention is quadratic in sequence
149
+ length, so tool input is sliced at that boundary rather than trusted.
150
+ - **Chunking**: documents and notes at 1,500 characters, subtitles at 600,
151
+ audio in 11.2-second windows.
152
+ - If the Hugging Face cache stores the ONNX shards as symlinks, they are
153
+ materialised once into `~/.cache/mediarag/onnx-real`: onnxruntime will not
154
+ load external-data blobs out of a sharded cache.
155
+
156
+ The three embedders are parity-locked against the reference model — 12/12 golden
157
+ vectors at cosine 1.000000 (`tests/fixtures/golden.json`, enforced by
158
+ `tests/test_parity_slices.py`).
159
+
160
+ ## What it touches
161
+
162
+ No telemetry. It shells out to `curl` (http sources), `ffmpeg`, `pandoc`,
163
+ `pdftotext`, and `rclone` when those lanes are used, and to whatever adapter
164
+ you drop in `sources.d/`. It reads the paths you pass plus the corpus at `root`
165
+ (`mediarag-config.yaml` `root:`, else `MEDIARAG_ROOT`, else `~/.mediarag`). The Raindrop adapter calls
166
+ `api.raindrop.io` with `RAINDROP_API_TOKEN` — optional; inside Hermes the token
167
+ is resolved through the profile secret scope, and a missing scope does not
168
+ inherit another profile's value.
169
+
170
+ To run it as a Hermes plugin, point the plugin loader at this directory (or at
171
+ the agent plugin catalog entry). The manifests (`mcp.json`, `plugin.yaml`,
172
+ `plugin.json`) describe both the stdio MCP server (`scripts/mcp_server.py`,
173
+ spawned by the client) and the in-gateway native tools. Neither mode opens a
174
+ network listener.
175
+
176
+ ## Configuration
177
+
178
+ Everything is optional and set through the environment:
179
+
180
+ - `mediarag-config.yaml` — named corpus config in the plugin dir (`root:` key); wins over env and the default (see `mediarag-config.example.yaml`)
181
+ - `MEDIARAG_ROOT` — corpus root (default: `~/.mediarag`)
182
+ - `MEDIARAG_ALLOW_PRIVATE_URLS=1` — let `add_source` fetch loopback/private-network URLs (standalone; inside Hermes the host's `security.allow_private_urls` applies). Link-local and cloud-metadata addresses are always refused.
183
+ - `RAINDROP_API_TOKEN`, `PCLOUD_MOUNT`, `OBSIDIAN_VAULT` — adapter inputs
184
+
185
+ ## Adapters
186
+
187
+ `add_source` accepts public `http(s)://` URLs (fetched with curl, capped at 256MB;
188
+ every redirect hop is checked and loopback/private/link-local/metadata hosts are refused)
189
+ and source-scheme URIs (`<scheme>://...`) handled by adapters. Five ship in the
190
+ box:
191
+
192
+ - `dropbox://` — Dropbox through the desktop client's local folder, or any mirror
193
+ (`DROPBOX_DIR` env). Skips Dropbox's own `.dropbox/` bookkeeping.
194
+ - `rclone://remote:path` — any [rclone](https://rclone.org/) remote. Files copy
195
+ into a persistent cache at `~/.cache/mediarag/rclone` and index from there.
196
+ - `pcloud://` — walks a locally mounted pCloud drive (`PCLOUD_MOUNT` env).
197
+ - `obsidian://` — an Obsidian vault; skips `.obsidian/`, `.trash/`, `.git/`.
198
+ - `raindrop://` — Raindrop.io bookmarks through the REST API
199
+ (`RAINDROP_API_TOKEN` env).
200
+
201
+ To write your own adapter, drop an executable at `sources.d/<scheme>` under the
202
+ configured corpus root (`mediarag-config.yaml` / `MEDIARAG_ROOT` / `~/.mediarag`; a
203
+ per-call `root` argument never adds adapters) that
204
+ accepts `fetch <rest>` and prints one JSON object per line, either
205
+ `{"uri","title","text"}` or `{"uri","path"}`. `status` reports the adapters it
206
+ found; an unknown scheme fails with the list of what is available.
207
+
208
+ ## Development
209
+
210
+ Tests are direct-run scripts, not pytest-collectable:
211
+
212
+ - `python tests/test_contract.py` — tool/store/adapter contracts (no model)
213
+ - `python tests/test_ingest_units.py` — chunking, subtitle timings, store integrity
214
+ - `python tests/test_engine_deref.py` — sharded-cache materialisation (no model)
215
+ - `python tests/test_config_root.py` — corpus-root precedence (no model)
216
+ - `python tests/test_parity_slices.py` — FTS lane, RRF fusion, doc bridge (needs the model)
217
+
218
+ The fixtures under `tests/fixtures/` are synthetic and deterministic, and nothing
219
+ contacts the network. `tests/bench_engine.py` and `scripts/bench.sh` are the
220
+ local benchmark harness. CI runs the model-free suites on Linux and Windows,
221
+ plus the full parity lane on Linux.
222
+
223
+ ## License
224
+
225
+ MIT. For security reports, see `SECURITY.md`.
226
+
227
+ ## Acknowledgements
228
+
229
+ This project was developed with AI assistance using [Hermes Agent](https://github.com/NousResearch/hermes-agent).
@@ -0,0 +1,204 @@
1
+ # MediaRAG
2
+
3
+ ![MediaRAG — one semantic index over documents, images and audio](assets/banner.jpg)
4
+
5
+ Multimodal local semantic search over documents and media, packaged as an
6
+ agent-plugin per the [agent-plugins.org](https://agent-plugins.org/) 1.0
7
+ standard.
8
+
9
+ It indexes documents (pdf, office files, notes, html) and media (images, audio,
10
+ subtitles) into one local SQLite corpus, and you search both by meaning from any
11
+ MCP-capable agent. Video is not an ingest format: pull the soundtrack out first
12
+ (`ffmpeg -i clip.mp4 -vn -ac 1 -ar 16000 soundtrack.wav`) and index that — the
13
+ audio tower then makes the clip findable by what was said in it. "Which file mentions the
14
+ landing" and "where is the clip where I talked about it" are the same kind of
15
+ question.
16
+
17
+ How it runs:
18
+
19
+ - In-gateway: five native tools (`mediarag_search`, `mediarag_add_source`,
20
+ `mediarag_ingest_dir`, `mediarag_add_note`, `mediarag_status`) register once,
21
+ so one gateway serves all agents and the embedding model loads once.
22
+ - Cross-client: `mcp.json` declares a stdio MCP server, so Claude Desktop,
23
+ Cursor, and other Agent Plugins compliant clients load the same package.
24
+ Per the spec the entry uses a plugin-relative `command`
25
+ (`./scripts/mcp_launch.py`), never a bare `python3`: portable plugins must not
26
+ depend on `PATH` or ambient variables for resolution. That launcher is
27
+ stdlib-only and finds an interpreter that can import the engine
28
+ (numpy/onnxruntime/transformers/torch, ~500 MB), which Agent Plugins 1.0 has
29
+ no field to declare — so a client without a provisioned interpreter needs one
30
+ command, once:
31
+
32
+ ```bash
33
+ python3 -m venv .venv && .venv/bin/python -m pip install -r requirements.txt
34
+ ```
35
+
36
+ Order of preference: `$MEDIARAG_PYTHON`, the package's own `.venv/`, the
37
+ client's `$PLUGIN_DATA/venv`, then the interpreter that started it. With none
38
+ present it exits 3 naming what's missing instead of vanishing mid-handshake.
39
+
40
+ If `uv` is installed, it can own the environment instead — same
41
+ `requirements.txt`, resolved and cached by the installer:
42
+
43
+ ```bash
44
+ uv run --with-requirements requirements.txt -- python -c pass # once
45
+ ```
46
+
47
+ Set `MEDIARAG_AUTO_BOOTSTRAP=1` to have the launcher run that on start. It is
48
+ off by default: a first run pulls ~1.7 GB, longer than most clients wait.
49
+ - Local-only: no cloud services. The corpus lives at `<root>/.corpus/store.db`.
50
+
51
+ Documents and media are embedded by the same model into the same vector space
52
+ (see [Embedding model](#embedding-model)), which is why one index answers both
53
+ kinds of question.
54
+
55
+ ## Install
56
+
57
+ As a Python package (any MCP client, no clone):
58
+
59
+ ```bash
60
+ uvx --from git+https://github.com/dyiapanis/mediarag mediarag-mcp # runs it
61
+ pipx install git+https://github.com/dyiapanis/mediarag # or: mediarag-mcp on PATH
62
+ ```
63
+
64
+ As a Hermes plugin (dependencies provisioned by the host):
65
+
66
+ ```bash
67
+ hermes plugins install dyiapanis/mediarag
68
+ ```
69
+
70
+ The engine needs numpy, onnxruntime, transformers and torch (~1.7 GB with the
71
+ model weights); `pipx`/`uvx`/`hermes` install that for you. Running from a bare
72
+ checkout instead, see the MCP section below.
73
+
74
+ ## Features
75
+
76
+ - Hybrid retrieval: semantic vectors plus an FTS5/BM25 keyword lane, fused with
77
+ reciprocal rank fusion. Exact-string lookups and meaning searches both work.
78
+ - Documents and media in one index: pdf via `pdftotext`, docx/odt/epub/rst/html/csv/rtf
79
+ via `pandoc`, plus images, audio, and subtitles.
80
+ - Incremental re-indexing: unchanged files are skipped on an mtime+size
81
+ fingerprint.
82
+ - Source adapters: external collections through `sources.d` executables (see
83
+ below).
84
+
85
+ ## Install
86
+
87
+ Python dependencies: `pip install -r requirements.txt`. The system tools are
88
+ only needed for the lanes that use them: `ffmpeg` for audio, `pandoc` for
89
+ documents, `pdftotext` for pdf.
90
+
91
+ Runs on Linux and Windows. On Windows those system tools are not preinstalled —
92
+ `winget install Gyan.FFmpeg JohnMacFarlane.Pandoc oschwartz10612.Poppler` (or the
93
+ builds of your choice) and make sure they are on `PATH`. The corpus cache lives
94
+ at `~\.cache\mediarag`; adapters are python scripts and are run through the
95
+ current interpreter there, so they do not need the executable bit or a shebang.
96
+
97
+ The embedding weights are not downloaded on startup. Once, before first search:
98
+
99
+ ```bash
100
+ python scripts/fetch_model.py
101
+ ```
102
+
103
+ That is about 285MB into the Hugging Face cache; what is in it, and why, is
104
+ covered under [Embedding model](#embedding-model). After the download everything
105
+ runs offline (`HF_HUB_OFFLINE=1`).
106
+
107
+ ## Embedding model
108
+
109
+ One model embeds everything: [EmbeddingGemma
110
+ 2](https://huggingface.co/onnx-community/embeddinggemma-2-ONNX) in its ONNX q4
111
+ build, on onnxruntime, on the CPU. It ships three towers — the text trunk, a
112
+ vision encoder and an audio encoder — and all three write into the same
113
+ 768-dimensional space, so a pdf, a photograph and a clip of you talking about
114
+ both are directly comparable.
115
+
116
+ - **Snapshot** `daa72c51243991dfcaf9f9137d2c573d8f7790c0` of
117
+ `onnx-community/embeddinggemma-2-ONNX`. `scripts/fetch_model.py` pulls only the
118
+ q4 towers plus the processor configs, about 285MB, and never at startup. (A
119
+ cache that has also seen other quantisations of this model will be larger —
120
+ 482MB on the machine this was written on — because those blobs are still there.)
121
+ - **768 dimensions**, L2-normalised, stored as f32 blobs (3,072 bytes per row).
122
+ Retrieval is exact cosine over the whole corpus; there is no approximate index.
123
+ - **1024-token cap** on the text trunk. Attention is quadratic in sequence
124
+ length, so tool input is sliced at that boundary rather than trusted.
125
+ - **Chunking**: documents and notes at 1,500 characters, subtitles at 600,
126
+ audio in 11.2-second windows.
127
+ - If the Hugging Face cache stores the ONNX shards as symlinks, they are
128
+ materialised once into `~/.cache/mediarag/onnx-real`: onnxruntime will not
129
+ load external-data blobs out of a sharded cache.
130
+
131
+ The three embedders are parity-locked against the reference model — 12/12 golden
132
+ vectors at cosine 1.000000 (`tests/fixtures/golden.json`, enforced by
133
+ `tests/test_parity_slices.py`).
134
+
135
+ ## What it touches
136
+
137
+ No telemetry. It shells out to `curl` (http sources), `ffmpeg`, `pandoc`,
138
+ `pdftotext`, and `rclone` when those lanes are used, and to whatever adapter
139
+ you drop in `sources.d/`. It reads the paths you pass plus the corpus at `root`
140
+ (`mediarag-config.yaml` `root:`, else `MEDIARAG_ROOT`, else `~/.mediarag`). The Raindrop adapter calls
141
+ `api.raindrop.io` with `RAINDROP_API_TOKEN` — optional; inside Hermes the token
142
+ is resolved through the profile secret scope, and a missing scope does not
143
+ inherit another profile's value.
144
+
145
+ To run it as a Hermes plugin, point the plugin loader at this directory (or at
146
+ the agent plugin catalog entry). The manifests (`mcp.json`, `plugin.yaml`,
147
+ `plugin.json`) describe both the stdio MCP server (`scripts/mcp_server.py`,
148
+ spawned by the client) and the in-gateway native tools. Neither mode opens a
149
+ network listener.
150
+
151
+ ## Configuration
152
+
153
+ Everything is optional and set through the environment:
154
+
155
+ - `mediarag-config.yaml` — named corpus config in the plugin dir (`root:` key); wins over env and the default (see `mediarag-config.example.yaml`)
156
+ - `MEDIARAG_ROOT` — corpus root (default: `~/.mediarag`)
157
+ - `MEDIARAG_ALLOW_PRIVATE_URLS=1` — let `add_source` fetch loopback/private-network URLs (standalone; inside Hermes the host's `security.allow_private_urls` applies). Link-local and cloud-metadata addresses are always refused.
158
+ - `RAINDROP_API_TOKEN`, `PCLOUD_MOUNT`, `OBSIDIAN_VAULT` — adapter inputs
159
+
160
+ ## Adapters
161
+
162
+ `add_source` accepts public `http(s)://` URLs (fetched with curl, capped at 256MB;
163
+ every redirect hop is checked and loopback/private/link-local/metadata hosts are refused)
164
+ and source-scheme URIs (`<scheme>://...`) handled by adapters. Five ship in the
165
+ box:
166
+
167
+ - `dropbox://` — Dropbox through the desktop client's local folder, or any mirror
168
+ (`DROPBOX_DIR` env). Skips Dropbox's own `.dropbox/` bookkeeping.
169
+ - `rclone://remote:path` — any [rclone](https://rclone.org/) remote. Files copy
170
+ into a persistent cache at `~/.cache/mediarag/rclone` and index from there.
171
+ - `pcloud://` — walks a locally mounted pCloud drive (`PCLOUD_MOUNT` env).
172
+ - `obsidian://` — an Obsidian vault; skips `.obsidian/`, `.trash/`, `.git/`.
173
+ - `raindrop://` — Raindrop.io bookmarks through the REST API
174
+ (`RAINDROP_API_TOKEN` env).
175
+
176
+ To write your own adapter, drop an executable at `sources.d/<scheme>` under the
177
+ configured corpus root (`mediarag-config.yaml` / `MEDIARAG_ROOT` / `~/.mediarag`; a
178
+ per-call `root` argument never adds adapters) that
179
+ accepts `fetch <rest>` and prints one JSON object per line, either
180
+ `{"uri","title","text"}` or `{"uri","path"}`. `status` reports the adapters it
181
+ found; an unknown scheme fails with the list of what is available.
182
+
183
+ ## Development
184
+
185
+ Tests are direct-run scripts, not pytest-collectable:
186
+
187
+ - `python tests/test_contract.py` — tool/store/adapter contracts (no model)
188
+ - `python tests/test_ingest_units.py` — chunking, subtitle timings, store integrity
189
+ - `python tests/test_engine_deref.py` — sharded-cache materialisation (no model)
190
+ - `python tests/test_config_root.py` — corpus-root precedence (no model)
191
+ - `python tests/test_parity_slices.py` — FTS lane, RRF fusion, doc bridge (needs the model)
192
+
193
+ The fixtures under `tests/fixtures/` are synthetic and deterministic, and nothing
194
+ contacts the network. `tests/bench_engine.py` and `scripts/bench.sh` are the
195
+ local benchmark harness. CI runs the model-free suites on Linux and Windows,
196
+ plus the full parity lane on Linux.
197
+
198
+ ## License
199
+
200
+ MIT. For security reports, see `SECURITY.md`.
201
+
202
+ ## Acknowledgements
203
+
204
+ This project was developed with AI assistance using [Hermes Agent](https://github.com/NousResearch/hermes-agent).
@@ -0,0 +1,140 @@
1
+ """MediaRAG plugin entry — agent-plugins 1.0, loaded as hermes_plugins.<slug>.
2
+
3
+ register(ctx) is the real loader contract: ctx.register_tool(name, toolset,
4
+ schema={"type":"object","parameters":…}, handler, description). Handlers
5
+ absorb dispatch kwargs themselves (**_x — Hermes passes task_id + extras).
6
+
7
+ Env contract: register() checks that onnxruntime, numpy, and transformers
8
+ import, and refuses to register tools when they do not. Install them with
9
+ `pip install -r requirements.txt`. The model snapshot is loaded from the
10
+ Hugging Face cache and is not downloaded on startup.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import sys
16
+
17
+ _PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
18
+
19
+ def _config_root() -> str:
20
+ """Corpus root from mediarag-config.yaml in the plugin dir (named config, rag-config.yaml pattern).
21
+ # ponytail: flat `root:` line parse — no yaml dependency on the stdio spawn path;
22
+ # switch to yaml.safe_load if the config ever grows beyond one key.
23
+ """
24
+ path = os.path.join(_PLUGIN_DIR, "mediarag-config.yaml")
25
+ try:
26
+ with open(path, encoding="utf-8") as fh:
27
+ for line in fh:
28
+ if line.strip().startswith("root:"):
29
+ return line.split(":", 1)[1].strip().strip("'\"")
30
+ except OSError:
31
+ pass
32
+ return ""
33
+
34
+
35
+ TOOL_ROOT = _config_root() or os.environ.get("MEDIARAG_ROOT") or os.path.expanduser("~/.mediarag")
36
+
37
+ PYVER = f"python{sys.version_info.major}.{sys.version_info.minor}"
38
+
39
+ BOOTSTRAP_HINT = (
40
+ "MediaRAG engine deps missing — pip install -r requirements.txt "
41
+ "from the plugin directory"
42
+ )
43
+
44
+
45
+ def _engine_importable() -> bool:
46
+ try:
47
+ import onnxruntime # noqa: F401
48
+ import numpy # noqa: F401
49
+ import transformers # noqa: F401
50
+ # The processor chain constructs a video processor needing BOTH torch and
51
+ # torchvision; without them every embed raises ImportError at call time
52
+ # while the plugin still advertises five working tools.
53
+ import torch # noqa: F401
54
+ import torchvision # noqa: F401
55
+ return True
56
+ except Exception:
57
+ return False
58
+
59
+
60
+ def register(ctx) -> None:
61
+ """MediaRAG native tool registration (Hermes loader contract)."""
62
+ # Deps arrive with the plugin install (python_dependencies / requirements.txt).
63
+ # Nothing here edits sys.path or the process environment: this runs inside the
64
+ # Hermes gateway, where either would leak into every other plugin.
65
+ if not _engine_importable():
66
+ print(f"[mediarag] {BOOTSTRAP_HINT}")
67
+ return
68
+
69
+ import json as _json
70
+
71
+ from .core import tools as _tools
72
+
73
+ def _json_result(handler):
74
+ """Registry dispatch contract: handler receives ONE positional arg (the
75
+ args dict, task_id-style extras included) and returns a str (dict
76
+ results are rejected — the workspace plugin's lifetime bug)."""
77
+ def wrapped(args=None, **kwargs):
78
+ a = dict(args or {})
79
+ a.update(kwargs)
80
+ return _json.dumps(handler(**a))
81
+ return wrapped
82
+
83
+ def reg(name, desc, handler, params, required):
84
+ ctx.register_tool(
85
+ name=name,
86
+ toolset="mediarag",
87
+ schema={"type": "object", "parameters": {
88
+ "type": "object", "properties": params, "required": required}},
89
+ handler=_json_result(handler),
90
+ check_fn=lambda: True,
91
+ description=desc,
92
+ )
93
+
94
+ reg(
95
+ "mediarag_search",
96
+ "Search documents and media (hybrid: semantic + keyword/BM25): text, images, "
97
+ "audio. Ranked hits with snippets, scores, spans, and image thumbnails.",
98
+ _tools.tool_search,
99
+ {
100
+ "query": {"type": "string", "description": "natural-language query (or keyword terms)"},
101
+ "k": {"type": "integer", "default": 5},
102
+ "kind": {"type": "string", "enum": ["text", "image", "audio"]},
103
+ "include_thumbnails": {"type": "boolean", "default": True},
104
+ },
105
+ ["query"],
106
+ )
107
+ reg(
108
+ "mediarag_add_note",
109
+ "Store a text note into the MediaRAG corpus (searchable afterwards).",
110
+ _tools.tool_add_note,
111
+ {"text": {"type": "string", "description": "note text"},
112
+ },
113
+ ["text"],
114
+ )
115
+ reg(
116
+ "mediarag_ingest_dir",
117
+ "Bulk-index a directory of documents and media (pdf, office, notes, "
118
+ "subtitles, images, audio). Per-file errors are collected, never fatal.",
119
+ _tools.tool_ingest_dir,
120
+ {"path": {"type": "string"},
121
+ },
122
+ ["path"],
123
+ )
124
+ reg(
125
+ "mediarag_add_source",
126
+ "Add one source: local file/dir, http(s) URL, or adapter scheme URI "
127
+ "(raindrop://, pcloud://, obsidian://, rclone://backend:path).",
128
+ _tools.tool_add_source,
129
+ {"source": {"type": "string"},
130
+ },
131
+ ["source"],
132
+ )
133
+ reg(
134
+ "mediarag_status",
135
+ "MediaRAG corpus status: row counts by kind, corpus root, adapters.",
136
+ _tools.tool_status,
137
+ {},
138
+ [],
139
+ )
140
+ print(f"[mediarag] 5 tools registered (corpus root: {TOOL_ROOT})")
File without changes
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env python3
2
+ """MediaRAG source adapter: dropbox://<path-in-dropbox> — Dropbox assets via the
3
+ desktop client's local folder (or any local mirror).
4
+
5
+ Env: DROPBOX_DIR (default: "~/Dropbox"). Item shape: {"uri", "path"} — the
6
+ indexer decodes/embeds real files itself, so this adapter deliberately does not
7
+ carry its own extension list: `ing.index_file` knows which formats it can read
8
+ (pdf/epub/docx via pandoc, pdf via pdftotext, images, audio) and returns an
9
+ `unsupported_ext` skip for the rest. Duplicating that list here is how Dropbox
10
+ PDFs would go missing without a word.
11
+ """
12
+ import json
13
+ import os
14
+ import sys
15
+
16
+ # Dropbox's own bookkeeping, plus the usual VCS/mount debris. Anything else is
17
+ # the indexer's call.
18
+ SKIP_DIRS = {".dropbox", ".dropbox.cache", ".git", ".obsidian", ".trash"}
19
+
20
+
21
+ def main():
22
+ if len(sys.argv) != 3 or sys.argv[1] != "fetch":
23
+ print("usage: dropbox fetch <path-in-dropbox> (e.g. dropbox://Papers)", file=sys.stderr)
24
+ return 2
25
+ base = os.path.expanduser(os.environ.get("DROPBOX_DIR", "~/Dropbox"))
26
+ sub = sys.argv[2].lstrip("/")
27
+ root = os.path.join(base, sub)
28
+ if not os.path.isdir(root):
29
+ print(f"not a directory: {root} (set DROPBOX_DIR if your Dropbox lives elsewhere)",
30
+ file=sys.stderr)
31
+ return 1
32
+ total = 0
33
+ for dirpath, dirnames, filenames in os.walk(root):
34
+ dirnames[:] = [d for d in dirnames if not d.startswith(".") and d not in SKIP_DIRS]
35
+ for fn in sorted(filenames):
36
+ if fn.startswith(".") or fn.endswith(".dropbox.attr"):
37
+ continue
38
+ p = os.path.join(dirpath, fn)
39
+ print(json.dumps({"uri": f"dropbox://{os.path.relpath(p, base)}", "path": p},
40
+ ensure_ascii=False))
41
+ total += 1
42
+ print(f"emitted {total} files under {sub or '/'} (indexer decides what it can read)",
43
+ file=sys.stderr)
44
+ return 0
45
+
46
+
47
+ if __name__ == "__main__":
48
+ sys.exit(main())