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.
- mediarag-0.1.14/LICENSE +21 -0
- mediarag-0.1.14/PKG-INFO +229 -0
- mediarag-0.1.14/README.md +204 -0
- mediarag-0.1.14/__init__.py +140 -0
- mediarag-0.1.14/adapters/__init__.py +0 -0
- mediarag-0.1.14/adapters/dropbox +48 -0
- mediarag-0.1.14/adapters/obsidian +53 -0
- mediarag-0.1.14/adapters/pcloud +42 -0
- mediarag-0.1.14/adapters/raindrop +64 -0
- mediarag-0.1.14/adapters/rclone +68 -0
- mediarag-0.1.14/core/engine.py +238 -0
- mediarag-0.1.14/core/ingest.py +387 -0
- mediarag-0.1.14/core/search.py +49 -0
- mediarag-0.1.14/core/store.py +221 -0
- mediarag-0.1.14/core/tools.py +265 -0
- mediarag-0.1.14/core/urlsafe.py +73 -0
- mediarag-0.1.14/mcp.json +9 -0
- mediarag-0.1.14/mediarag-config.example.yaml +4 -0
- mediarag-0.1.14/mediarag.egg-info/PKG-INFO +229 -0
- mediarag-0.1.14/mediarag.egg-info/SOURCES.txt +56 -0
- mediarag-0.1.14/mediarag.egg-info/dependency_links.txt +1 -0
- mediarag-0.1.14/mediarag.egg-info/entry_points.txt +2 -0
- mediarag-0.1.14/mediarag.egg-info/requires.txt +8 -0
- mediarag-0.1.14/mediarag.egg-info/top_level.txt +1 -0
- mediarag-0.1.14/plugin.json +20 -0
- mediarag-0.1.14/plugin.yaml +29 -0
- mediarag-0.1.14/pyproject.toml +43 -0
- mediarag-0.1.14/requirements.txt +10 -0
- mediarag-0.1.14/scripts/fetch_model.py +16 -0
- mediarag-0.1.14/scripts/mcp_launch.py +104 -0
- mediarag-0.1.14/scripts/mcp_server.py +139 -0
- mediarag-0.1.14/setup.cfg +4 -0
- mediarag-0.1.14/tests/test_config_root.py +67 -0
- mediarag-0.1.14/tests/test_contract.py +338 -0
- mediarag-0.1.14/tests/test_engine_deref.py +91 -0
- mediarag-0.1.14/tests/test_ingest_units.py +166 -0
- mediarag-0.1.14/tests/test_parity_slices.py +77 -0
mediarag-0.1.14/LICENSE
ADDED
|
@@ -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.
|
mediarag-0.1.14/PKG-INFO
ADDED
|
@@ -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
|
+

|
|
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
|
+

|
|
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())
|