npx skills add ...
npx skills add moonlight-lupin/agent-skills --skill library-rag
Semantic search over a personal library using Nemotron-3-Embed-1B embeddings + sqlite-vec. Index books, documents, any text corpus; query by meaning. Includes EPUB→Markdown conversion and MCP server for auto-available search tools.
npx skills add moonlight-lupin/agent-skills --skill library-rag
Semantic search over ~/.hermes/library/ using Nemotron-3-Embed-1B embeddings (via NVIDIA NIM) stored in sqlite-vec. Enables meaning-based retrieval across any text corpus — books, documents, reference works — in any language.
When a user wants to start using Library RAG, walk them through this sequence.
Use AskUserQuestion for the decisions marked ASK — don't assume paths or
silently create directories outside the home folder.
sqlite-vec import fails → run pip install -r requirements.txt (add
--break-system-packages on externally-managed Python). See README "Installation".NO API KEY → help the user create a free NVIDIA NIM key at
https://build.nvidia.com/nvidia/nemotron-3-embed-1b, then
store it: echo 'NVIDIA_API_KEY=nvapi-...' >> ~/.hermes/.env.
(Legacy OpenRouter keys still work: if only OPENROUTER_API_KEY is set,
load_api_key() auto-switches to the bge-m3 OpenRouter endpoint.)Three locations drive everything. Confirm them with the user and export the env vars (persist in their shell profile). Defaults in parentheses:
| Purpose | Env var | Default | Notes |
|---|---|---|---|
| Indexed content + vector DB | LIBRARY_ROOT | ~/.hermes/library | Holds the structured .md/.txt that get embedded, plus rag_index.db. |
| Vector DB file | (derived) | $LIBRARY_ROOT/rag_index.db | Override per-run with rag_index.py --db <path> if you want it elsewhere. |
| Raw books + conversion staging | BOOKS_DIR | ~/.hermes/book | Where original EPUBs/PDFs land and where text is extracted. |
Recommended layout (keep raw/text out of LIBRARY_ROOT — see pitfall below):
Pitfall — avoid double-indexing:
discover_files()indexes every.mdand.txtunderLIBRARY_ROOT. If both the extracted.txtand the converted.mdof the same book live underLIBRARY_ROOT, the content is embedded twice. Keep only one representation (prefer the structured.md) underLIBRARY_ROOT; keep raw + text staging inBOOKS_DIRoutside it.
Create the chosen dirs:
add_book(...) — it converts and
indexes in one step.$BOOKS_DIR/raw, convert to structured markdown under
$LIBRARY_ROOT/<source_type>/ (see convert_epub_library.py and
references/epub-conversion.md), then:Ask the user whether they want new files indexed automatically or manually:
Manual (default, recommended to start): they run python3 scripts/rag_index.py
after adding files. Incremental + SHA-256 tracking means re-runs are cheap and only touch
new/changed files. No setup needed.
Automated (cron): schedule a periodic incremental index. Only offer this once Step 3 works end-to-end. Example — index nightly at 02:00 and log output:
If they also want EPUBs auto-converted before indexing, chain a conversion step ahead of
rag_index.py in the same cron line (or a small wrapper script). On macOS, launchd /
a launchd plist is the more reliable equivalent of cron.
Cron cautions to mention:
NVIDIA_API_KEY, LIBRARY_ROOT, and use an
absolute python3 path (or activate the venv inside a wrapper script).--rebuild runs on the same DB (see Pitfalls). A nightly incremental
run is safe; a full --rebuild should stay manual.After onboarding, summarize for the user: the three paths chosen, where the DB lives, and whether indexing is manual or scheduled.
Once configured in config.yaml, these tools are available as
mcp_library_rag_search, mcp_library_rag_stats, mcp_library_rag_add_book.
Restart the gateway after adding.
Returns JSON array of results with similarity, source_type, book, chapter,
section_title, text.
Converts EPUB → chapters or PDF → pages, then indexes them. One-call workflow for adding new books to the library.
When to use:
--rebuild--dry-run first--prune-missing
(removes orphaned chunks + vectors so the DB doesn't keep dead rows; safe
alongside incremental indexing — only files absent from disk are retired)--rebuild or a large --prune-missing → run --vacuum to shrink
the DB file (SQLite reuses freed pages but doesn't shrink the file otherwise)Can also be imported:
scripts/convert_epub_library.py)Extracts text from EPUB files and splits into per-chapter Markdown with YAML frontmatter.
Runnable as a CLI or importable as convert_epub() — the same implementation backs the MCP
add_book tool, so there is one conversion code path.
Raw EPUB + extracted text stage under $BOOKS_DIR (outside LIBRARY_ROOT); only the
structured markdown is written to --md-root, so the raw text is never double-indexed.
Chapter detection tries several styles (CHAPTER 12 / CHAPTER One / PART IV /
12. Title / bare 12\nTitle), picking the first that confidently applies, and guards
against date/page-number false positives. Before trusting a conversion, eyeball
convert_epub_library.audit_chapter_markers(text) — every entry should be a real chapter,
not a date or page number.
Key pitfalls (see references/epub-conversion.md for full details):
title: field.chNN- prefix)..md file has valid YAML frontmatter.scripts/convert_pdf_library.py)Extracts text and tables with pdfplumber. Each page → a ## Page N section (so the
markdown chunker yields per-page citations); tables → **[Table N]** blocks. CLI or
convert_pdf(); it also backs add_book for .pdf inputs.
The table block is display-only: clean_for_embedding strips **[Table N]** and rows ending
in <br> from the embedding input (the page's narrative text already carries the table content
linearly), so tables stay searchable without being embedded twice.
rag_index.py)| Chunker | Input | Strategy |
|---|---|---|
chunk_markdown() | .md with headings | Split by ##/### (a ### chunk carries its parent ## as a Section — Subsection breadcrumb), paragraph merge fallback, single-newline normalization |
chunk_plain_text() | .txt files | Paragraph merge with 15% overlap |
For domain-specific formats (PDFs, XML, JSON), write a custom chunker and register it in
discover_files(). See references/chunking-strategies.md for patterns.
Use mcp_library_rag_add_book — it dispatches on extension and handles
EPUB/PDF → md → index in one call.
Convert to structured Markdown with the converter CLI for the format. Both stage
the raw file + extracted text under $BOOKS_DIR (outside LIBRARY_ROOT, so they're not
indexed) and write markdown to --md-root:
For other sources (XML/JSON, etc.), follow the extraction pattern in
references/chunking-strategies.md and write the markdown under
$LIBRARY_ROOT/<source_type>/ yourself.
Register the chunker (if new file type):
rag_index.pydiscover_files()
See references/chunking-strategies.md for patterns and pitfalls.
See references/portable-rag-per-skill.md for the standalone per-skill RAG pattern.
See references/rag-pipeline-review.md for a systematic audit checklist.Index:
Default provider is NVIDIA NIM (nvidia/nemotron-3-embed-1b) — free on the
NVIDIA API free trial tier. No per-token embedding cost for personal use.
Legacy OpenRouter path (baai/bge-m3, ~$0.01/M tokens) remains available as a
fallback; see references/openrouter-embeddings.md.
Effectively free for personal use.
The same Nemotron-3-Embed-1B → sqlite-vec pattern can be deployed as a standalone,
self-contained RAG inside any skill directory — no MCP registration, no
dependency on ~/.hermes/library/. This makes the skill portable: zip the
folder, drop on another machine, it works.
rag_index.py and rag_query.py into the skill's scripts/ dirLIBRARY_ROOT /
DB_PATH with an env var or CLI flag, defaulting to the skill's own
references/ directory<!-- Page N --> markers via split_by_pages() for per-chunk page citations.rag_query.py directly:
normalize_vec() to
float_to_blob() (indexer) and get_embedding() (query). Makes cosine
similarity exact, not an implicit assumption.split_long_text() should carry the tail
of each chunk into the next for better recall.clean_for_embedding() must strip <br>,
**[Table N]**, and <!-- Page N --> in addition to markdown markers.MIN_CHUNK_CHARS — call merge_tiny_chunks() after chunking
to prevent near-empty chunks from diluting search results.See references/portable-rag-per-skill.md for full code patterns and
references/rag-pipeline-review.md for a systematic audit checklist.
| Use the main library when... | Use standalone when... |
|---|---|
| Content is reference/books you want globally searchable | Content is domain-specific (rulebooks, code docs, etc.) |
| You want auto-available MCP search tools | You want the skill to be portable/self-contained |
| Content should be globally searchable | Content should only surface when that skill is loaded |
init_db().file_hash must be added to chunks before storing: The indexer adds it after chunking but before embedding. Don't forget this when adding new chunkers.\n for line wrapping, not paragraph breaks. Normalize to spaces before splitting on \n\n.search and stats. add_book may need more time for large EPUBs — increase timeout in config if needed.MATCH/k vector search cannot be combined with arbitrary WHERE clauses. source_type and source_book filters must be applied as post-filters after over-fetching results (fetch top_k * 5, then filter on chunk metadata). See references/portable-rag-per-skill.md for the correct pattern.rag_index.py and rag_query.py and parameterize: (1) resolve paths from __file__ not hardcoded LIBRARY_ROOT, (2) store DB inside the skill's references/ dir, (3) skip the MCP server — import search() directly instead, (4) write a domain-specific chunker, (5) parse <!-- Page N --> markers for per-chunk page citations, (6) L2-normalize vectors at store+query time, (7) add ~15% chunk overlap for better recall. See references/portable-rag-per-skill.md for the full pattern.<!-- Page N --> comments, but chunkers must actively parse them — otherwise all chunks degrade to chapter-level citations. Use split_by_pages() in the chunker to split by markers and stamp each chunk with its page.1 - dist²/2 is only exact cosine if both stored and query vectors are unit-normalized. Nemotron-3-Embed-1B (and the legacy bge-m3 path) may return near-unit vectors, but normalize_vec() is called at store time (float_to_blob) and query time (get_embedding) to guarantee this explicitly.clean_for_embedding must strip table/PDF artifacts: In addition to markdown markers, strip <br>, **[Table N]**, and <!-- Page N --> from embedding input. These are noise from PDF table extraction. Chunk text in the DB retains them for display readability.--rebuild processes on the same DB simultaneously: SQLite allows concurrent connections but --rebuild drops and recreates tables. If a foreground test and a background job overlap, the background process gets spurious "Failed after 3 retries" errors. Always let any foreground --rebuild test fully exit before starting the background job, or use --dry-run for quick tests (it doesn't touch the DB).python3 --version # need 3.9+
python3 -c "import sqlite_vec; print('sqlite-vec ok')" 2>&1
test -n "$NVIDIA_API_KEY" && echo "key in env" || grep -qs NVIDIA_API_KEY ~/.hermes/.env && echo "key in .env" || echo "NO API KEY"$BOOKS_DIR/ staging — NOT indexed
raw/ original EPUBs/PDFs
text/ extracted plain text
$LIBRARY_ROOT/ indexed — scanned recursively
rag_index.db
<source_type>/ top-level dir name becomes the "source type"
markdown/<slug>/chapters/*.md the structured files that get embeddedmkdir -p "$LIBRARY_ROOT" "$BOOKS_DIR/raw" "$BOOKS_DIR/text"python3 scripts/rag_index.py --dry-run # preview chunk counts, no API cost
python3 scripts/rag_index.py # incremental build
python3 scripts/rag_query.py --stats # confirm chunks landed
python3 scripts/rag_query.py "a test query"