Airgapped multimodal local file search engine for Linux / WSL.
Indexes text, PDF, image, audio, and video files on your machine and lets you search any modality with a query of any modality — text, image, audio, or video. Fully local. No cloud. No telemetry.
Exposes the index over a local HTTP API, an MCP server (for Claude Desktop, Hermes, OpenClaw), and a CLI.
Project status and next work items are tracked in WORK_ITEMS.md. For deeper operational docs, see Architecture Notes, Agent And MCP Setup, and Operations Playbook.
- Any-to-any search: text → images, image → videos, audio → text, and every other combination across the 4×5 modality matrix
- Fully airgapped: after one-time model download, runs 100% offline. Zero network calls at index or query time
- Multimodal indexing: text, PDF, images, audio, video — all in one shared embedding space
- Incremental: only changed files get re-embedded. Deleted files are swept automatically
- MCP server: agents (Claude Desktop, Hermes, OpenClaw) can call
search_filesas a tool - Web UI: search box with drag-and-drop file upload, modality filters, thumbnail previews
- CLI: scriptable commands for indexing, searching, and status
Measured on WSL2 with jinaai/jina-embeddings-v5-omni-small, CUDA, 1024-dim
embeddings, and exact generated benchmark corpora.
| Run | Files | Index throughput | Search p95 @ c1/c2/c4 | Failures |
|---|---|---|---|---|
| Corpus 100 serial index-only | 100 | 2.25 files/s | n/a | 0 |
| Corpus 100 bounded-threaded index-only | 100 | 2.36 files/s | n/a | 0 |
| Corpus 100 full benchmark | 100 | 2.61 files/s | 161.9 / 289.8 / 678.5 ms | 0 |
| Corpus 200 full benchmark | 200 | 2.76 files/s | 179.9 / 317.4 / 711.9 ms | 0 |
| Corpus 500 bounded-threaded index-only | 500 | 2.16 files/s | n/a | 0 |
| Corpus 500 load on existing index | 500 | 235.44 files/s incremental | 184.1 / 342.7 / 892.2 ms | 0 |
| Corpus 1000 bounded-threaded index-only | 1000 | 2.15 files/s | n/a | 0 |
| Corpus 1000 load on existing index | 1000 | 413.13 files/s incremental | 229.7 / 410.6 / 898.0 ms | 0 |
The 100-file serial-vs-threaded index-only comparison showed a 4.7% wall-time reduction and 5.0% throughput increase for bounded threaded preprocessing, with about 174 MiB higher max RSS.
The 500-file threaded run exposed a native PDFium crash under concurrent PDF preprocessing; Localens now serializes PDF rendering while keeping other modalities parallel. The rerun completed 500/500 files with zero failures.
Uses jinaai/jina-embeddings-v5-omni-small (~1.74B params, 1024-dim embeddings, Matryoshka-truncatable).
License: CC BY-NC 4.0 (non-commercial). Personal and research use is fine. Commercial use requires a license from Jina.
trust_remote_code: This model uses custom Python code loaded via trust_remote_code=True. Pin the model revision for safety. See Security section.
Lighter alternative for low-VRAM/CPU: jinaai/jina-embeddings-v5-omni-nano (~0.95B). Set model.id in config.
- Python 3.11+
uv(recommended) orpipffmpeg(for audio/video codec support)- NVIDIA GPU with CUDA (optional; CPU works but is slower)
git clone <this-repo> && cd localens
# Create environment and install
uv venv .venv
source .venv/bin/activate
uv pip install -e "."
# Direct executable path from this checkout
./bin/localens --help# Run with internet access — downloads the model (~3.5GB)
LOCALENS_OFFLINE=false localens serve
# Or use Python directly:
# python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('jinaai/jina-embeddings-v5-omni-small', trust_remote_code=True)"After download, all subsequent runs are offline by default.
Create a localens.toml in the project root or ~/.config/localens/config.toml:
[index]
directories = ["/home/you/documents", "/home/you/photos"]
[model]
device = "auto" # "cuda", "cpu", or "auto"
truncate_dim = 1024
[server]
port = 8077# Start the server + web UI
localens serve
# Open http://127.0.0.1:8077 in your browser
# Or use the CLI
localens index /path/to/your/files
localens search "machine learning frameworks"
localens statusFor direct PATH access without activating the virtual environment:
mkdir -p ~/.local/bin
ln -sf "$PWD/bin/localens" ~/.local/bin/localens
localens --helpThe bin/localens launcher resolves the checkout path even when symlinked,
uses .venv/bin/localens when available, and falls back to uv run localens.
localens serve [--config PATH] Start the API server + web UI
localens scan [PATHS...] Count eligible files without indexing
localens index [PATHS...] [--full] Index files (uses config dirs if no paths given)
--min-files INT Require at least this many eligible files
--max-files INT Index at most this many eligible files
--confirm-large Allow an unbounded large indexing job
localens cancel-index JOB_ID Request cancellation of an indexing job
localens search "QUERY" [OPTIONS] Search with a text query
-m, --modalities TEXT Comma-separated: text,image,audio,video,pdf
-k, --top-k INT Number of results (default: 20)
-d, --dir PATH Restrict to directory
--json Output as JSON
--group Group results by file
localens search-file PATH [OPTIONS] Search by file (image/audio/video)
localens status Show index stats and health
localens watch [--config PATH] Start file watcher (foreground)
localens mcp [--config PATH] Start MCP server on stdio
All endpoints bound to 127.0.0.1:8077 by default. If server.token is set, requires Authorization: Bearer <token> on all routes except /health.
| Method | Path | Description |
|---|---|---|
| GET | /health |
Health check (no auth required) |
| GET | /stats |
Per-modality counts, index size, failures |
| POST | /index/scan |
Count eligible files without indexing |
| POST | /index |
Start indexing {paths?: [], full?: bool} |
| GET | /index/{job_id} |
Poll indexing job status |
| POST | /index/{job_id}/cancel |
Request cancellation of an indexing job |
| POST | /index/sweep |
Remove records for deleted files |
| DELETE | /index |
Clear index {confirm: true} |
| POST | /search |
Search (see below) |
| POST | /search/upload |
Search by uploaded file (multipart) |
| GET | /file?path=... |
Stream original file (path-confined) |
| GET | /thumb?path=... |
Stream thumbnail |
{
"query": "text query",
"query_file": null,
"query_files": null,
"modalities": ["text", "image", "audio", "video", "pdf"],
"top_k": 20,
"min_score": 0.0,
"dir": null,
"group_by_file": false
}Exactly one of query, query_file, or query_files must be set.
Use /index/scan before large jobs:
{
"paths": ["/home/you/documents"],
"min_files": 100,
"max_files": 500
}The response includes eligible/selected file counts, size, modality counts, and a path preview.
POST /index accepts the same guard fields plus full and confirm_large:
{
"paths": ["/home/you/documents"],
"full": false,
"min_files": 100,
"max_files": 500,
"confirm_large": false
}min_files fails the job before embedding if not enough eligible files are
found. max_files caps the selected files. Unbounded jobs over the built-in
large-job threshold require confirm_large=true.
For use with Claude Desktop, Hermes, OpenClaw, and other MCP-compatible agents. See Agent And MCP Setup for copy-paste client configs, WSL/Windows notes, and safe agent-indexing guidance.
# Start on stdio (agents spawn this)
localens mcp
# Or add to your agent's MCP config:
# { "command": "localens", "args": ["mcp"] }search_files(query, modalities?, top_k?, dir?)— text search across all modalitiessearch_by_file(path, modalities?, top_k?)— search by a file on diskget_index_stats()— compact document counts, index health, model id, dimensions, failures, and last index timelist_benchmark_reports(limit?)— compact summaries of recent benchmark reports without raw path lists or logsscan_index(paths?, min_files?, max_files?)— count eligible files without indexing (only ifmcp.allow_index = true)reindex(paths?, full?, min_files?, max_files?, confirm_large?)— trigger bounded indexing (only ifmcp.allow_index = true)get_index_job(job_id)— poll indexing status (only ifmcp.allow_index = true)cancel_index(job_id)— request indexing cancellation (only ifmcp.allow_index = true)
All settings in localens.toml. Env var overrides prefixed LOCALENS_.
| Key | Default | Description |
|---|---|---|
index.directories |
[] |
Paths to index |
index.exclude_globs |
node_modules, .git, etc. | Glob patterns to skip |
index.max_file_mb |
200 |
Skip files larger than this |
index.modalities |
all five | Which modalities to index |
index.text_chunk_tokens |
512 |
Token count per text chunk |
index.text_chunk_overlap |
64 |
Overlap tokens between chunks |
index.preprocess_workers |
1 |
Parallel file preprocessing workers; keep 1 for serial indexing |
index.queue_size |
32 |
Max in-flight files waiting in the bounded preprocessing pipeline |
index.embed_batch_size |
32 |
Prepared text items per embedding batch |
index.upsert_batch_size |
128 |
Records per vector-store upsert batch |
model.id |
jinaai/jina-embeddings-v5-omni-small |
HuggingFace model ID |
model.device |
auto |
cuda, cpu, or auto |
model.truncate_dim |
1024 |
Embedding dimension (Matryoshka) |
model.batch_size_text |
32 |
Text embedding batch size |
store.backend |
lancedb |
Vector store backend |
store.path |
~/.local/share/localens/index |
Index data directory |
server.host |
127.0.0.1 |
API bind address |
server.port |
8077 |
API port |
server.token |
"" |
Bearer token (required for non-loopback) |
watch.enabled |
false |
Enable file watcher |
watch.debounce_ms |
1500 |
Watcher debounce |
app.offline |
true |
Set HF_HUB_OFFLINE at startup |
mcp.allow_index |
false |
Allow agents to trigger indexing |
mcp.benchmark_dir |
benchmarks |
Directory for MCP benchmark report discovery |
WSL2 uses the Windows host NVIDIA driver. Inside WSL, install only the CUDA toolkit (not the driver):
# Install CUDA toolkit inside WSL
# Follow: https://developer.nvidia.com/cuda-downloads (select WSL-Ubuntu)
# Then install PyTorch with CUDA support:
uv pip install torch --index-url https://download.pytorch.org/whl/cu124Verify: python -c "import torch; print(torch.cuda.is_available())"
Files under /mnt/c (Windows drives) go through the 9p bridge and are slow. Recommend indexing files in the Linux filesystem (e.g., /home/you/). Localens prints a warning when indexing /mnt/ paths.
WSL2 forwards localhost, so http://127.0.0.1:8077 is reachable from Windows browsers directly.
sudo apt install ffmpeg# System deps
sudo apt install ffmpeg
# For GPU: install NVIDIA driver + CUDA toolkit
# Then install CUDA PyTorch:
uv pip install torch --index-url https://download.pytorch.org/whl/cu124
# CPU-only is fine too — model.device will auto-detect- Loopback only: server binds to
127.0.0.1by default. Refuses to start on non-loopback withoutserver.token. - Token auth: when
server.tokenis set, all routes except/healthrequireAuthorization: Bearer <token>. - Path confinement:
/fileroute only serves files inside indexed directories. Rejects..traversal and symlink escapes. trust_remote_code: the model runs custom Python from its HuggingFace repo. Pin the revision to prevent code changes. Consider vendoring the model code locally.- No telemetry: zero outbound network calls. Enforced by
offline=truedefault. mcp.allow_index: defaultsfalseso agents cannot trigger indexing/deletion without explicit opt-in.
Architecture Option A (all-Python): single FastAPI process handles ingestion, embedding, vector storage, REST API, and serves the web UI. The MCP server runs as a separate stdio process. The CLI talks to the REST API.
files → File Walker → Preprocessors → Embedding Service → Vector Store (LanceDB)
↑
query (any modality) → Search Service → ANN search ──────────┘
↓
REST API / MCP Server / CLI / Web UI
| Modality | Extensions |
|---|---|
| Text | .txt .md .markdown .rst .csv .json .log .html .xml + source code |
| Image | .jpg .jpeg .png .gif .webp .bmp .tif .tiff .avif .heic .svg |
| Audio | .wav .mp3 .flac .ogg .m4a .opus |
| Video | .mp4 .avi .mov .mkv .webm .flv .wmv |
MIT (this codebase). The embedding model (jina-embeddings-v5-omni) is CC BY-NC 4.0.