Skip to content
Back to skills

Skill Retrieval

ASecurity

BM25-based skill retrieval plugin for Hermes Agent. Replaces the full skill list in the system prompt with a names-only compact view (~2K tokens) and injects top-K relevant skill descriptions per turn via BM25 retrieval (~300 tokens). Saves ~9K tokens/turn. Use when system prompt token overhead from skills is a concern, or when skill discovery quality matters.

  • 89 stars
  • 0 votes
  • 0 copies
  • 5 views
  • Added September 19, 2026
ai-agentspythongobashapiperformance

Works with

  • claude code
  • api

Security analysis

A96/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro scans all 13 files and shows the line behind each finding

Scanned October 4, 2026

npx -y skills add moonlight-lupin/agent-skills --skill skill-retrieval --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Skill Retrieval?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Skill Retrieval
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/moonlight-lupin-skill-retrieval/badge)](https://www.skillsdirectory.com/skills/moonlight-lupin-skill-retrieval)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: skill-retrieval
description: >-
  BM25-based skill retrieval plugin for Hermes Agent. Replaces the full skill
  list in the system prompt with a names-only compact view (~2K tokens) and
  injects top-K relevant skill descriptions per turn via BM25 retrieval (~300
  tokens). Saves ~9K tokens/turn. Use when system prompt token overhead from
  skills is a concern, or when skill discovery quality matters.
license: MIT
metadata:
  version: 0.6.0
  author: moonlight-lupin
  platforms: [linux, macos, windows]
  tags: [bm25, skill-retrieval, system-prompt, token-optimization, plugin]
  hermes:
    plugin_type: hook
    hooks: [pre_llm_call]
---

# Skill Retrieval

This is a **Hermes Agent** plugin. It is not a Claude Code plugin and will
not load in Claude Code — that runtime has no `pre_llm_call` event, no Python
`register()` entry point, and reads `.claude-plugin/plugin.json` rather than
`plugin.yaml`. Requires Hermes Agent >=0.21.1 (`requires_hermes` in
`plugin.yaml`).

BM25-based progressive disclosure for Hermes Agent skills. Instead of dumping
every skill description into the system prompt (~11.5K tokens), this plugin
keeps a compact names-only index and injects only the top-K relevant
descriptions per turn.

## What it does

Two-phase progressive disclosure:

1. **Phase 1 — System prompt compaction** (an `llm_request` middleware):
   Rewrites the outgoing request's `<available_skills>` block so it lists
   skill names only (descriptions stripped). All skills remain discoverable
   by name (~2K tokens instead of ~11.5K). The system prompt Hermes builds
   is never modified — prompt caching is unaffected.

2. **Phase 2 — Per-turn BM25 retrieval** (`pre_llm_call` hook): Tokenizes the
   user message, ranks active skill descriptions with BM25 Okapi, and injects
   the top-K matches (~300 tokens) as context appended after the user
   message.

## Architecture

```
Each turn (llm_request middleware)
    │
    ▼
Phase 1: rewrite <available_skills> → names only (~2K tokens, request only)

Each turn (pre_llm_call)
    │
    ▼
Phase 2: BM25Index.retrieve(user_message, top_k)
    └── inject "## Retrieved Skills ..." into user message (~300 tokens)
```

The BM25 index is built lazily on the first turn from standalone skills
(`~/.hermes/skills`) and plugin-bundled skills (`~/.hermes/plugins/*/skills`),
then cached per Hermes home, tool capability snapshot, session platform and
resolved disabled-skill set (the same inputs Hermes keys its own skills prompt
cache on). The cache is rebuilt without a restart when Hermes clears its skills
prompt cache (`skill_manage` create/patch/delete, hub install, skill toggles)
or when a cheap on-disk manifest changes (skill root and category dir mtimes,
top-level `SKILL.md` files, `config.yaml`).
Retrieval uses a pure-stdlib inverted index (term → posting list of
precomputed BM25 weights) and is sub-millisecond for ~200 skills.

## Token savings

| Stage | Tokens (approx.) |
|-------|------------------|
| Before (full skill list in system prompt) | ~11.5K |
| After — names-only system prompt | ~2.0K |
| After — per-turn top-K descriptions | ~0.3K |
| **Net per turn** | **~2.3K** (~9K saved) |

Measured on a Hermes install with ~300 skills; savings scale with skill count.

## Installation

Copy or symlink this directory into the Hermes plugins folder:

```bash
# From this repo
ln -s "$(pwd)/plugins/skill-retrieval" ~/.hermes/plugins/skill-retrieval

# Or copy
cp -r plugins/skill-retrieval ~/.hermes/plugins/skill-retrieval
```

User plugins are opt-in: Hermes discovers the directory but does not load it
until it is enabled (this adds `skill-retrieval` to `plugins.enabled` in
`~/.hermes/config.yaml`):

```bash
hermes plugins enable skill-retrieval
```

Restart the agent session so `register()` runs — it registers the
`llm_request` middleware and the `pre_llm_call` hook.

Dependencies (install into the Hermes Python env if missing):

```bash
pip install pyyaml
```

## Configuration

| Setting | Default | How to set |
|---------|---------|------------|
| `TOP_K` | `6` | Env var `SKILL_RETRIEVAL_TOP_K` |
| System prompt compaction | enabled | Set `SKILL_RETRIEVAL_COMPACT=0` to disable compaction while keeping BM25 retrieval injection |
| BM25 `k1` | `1.5` | Constant in `scripts/bm25_retriever.py` |
| BM25 `b` | `0.75` | Constant in `scripts/bm25_retriever.py` |
| Jev rerank | off | Set `SKILL_RETRIEVAL_RERANK=jev` to enable; key in `TYPESAFE_API_KEY` (legacy `TYPESAFE_KEY` accepted), read from `os.environ` |
| Rerank log path | `$HERMES_HOME/data/jev-trial/rerank_ab_log.jsonl` (rotates past 5 MB) | Env var `SKILL_RETRIEVAL_RERANK_LOG` |

```bash
export SKILL_RETRIEVAL_TOP_K=8
export SKILL_RETRIEVAL_COMPACT=0
export SKILL_RETRIEVAL_RERANK=jev
```

### Jev rerank (optional, off by default)

When `SKILL_RETRIEVAL_RERANK=jev`, the BM25 top-N shortlist is reranked by the
Jev system-one judgment model (`POST https://api.typesafe.ai/v1/systemone`,
model `jev-latest`). Behavior:

- **Fail-soft** — 3.0 s timeout, no retries; on any error the request logs a
  warning and BM25's original order stands. Retrieval never blocks on Jev.
- **Env-only config** — all settings come from `os.environ` (never a file
  read): `TYPESAFE_API_KEY` (legacy `TYPESAFE_KEY` accepted). Hermes loads
  the active profile's `.env` into environ at startup, so no cross-profile
  file is ever read.
- **A/B log** — every query records the timestamp, session id, a 200-char
  query excerpt, both orders, probabilities, tokens and latency to the log
  path above; the log rotates past 5 MB; `scripts/rerank_ab_report.py`
  summarizes it locally.
- **Outbound cap** — at most `RERANK_CANDIDATES` (default 12) descriptions
  are sent per call, even when the caller's shortlist is longer; the
  remainder keeps BM25 order at the tail and never leaves the machine.

## Verify it's working

Phase 1 silently no-ops outside a full Hermes runtime, and the BM25 index can
silently empty. After restart, check the Hermes logs.

**Healthy start — look for these log lines:**

- `BM25 index built: N docs …`
- `Skill retrieval plugin registered (top_k=…, compact=true)`

**Degraded — these warnings mean it's not working:**

- `Cannot locate prompt_builder — compaction skipped` (Phase 1 failed; Phase 2
  still runs for anonymous sessions, but named sessions skip injection because
  no capability snapshot is recorded)
- `No active skills found for BM25 index` (index is empty — zero retrieval injection)

## How it works

- **Tokenizer** — lowercases text, strips punctuation, splits on whitespace.
- **Corpus** — each skill becomes `"name: description"` plus its frontmatter
  `triggers:` list and `metadata.hermes.tags` (when present) from SKILL.md YAML.
  Trigger phrases and tags participate in retrieval scoring but only the
  description is injected into the prompt. Disabled skills from
  `~/.hermes/config.yaml` are skipped.
- **Index** — BM25 Okapi TF saturation + Lucene IDF
  `log(1 + (N-df+0.5)/(df+0.5))` (always positive, so small corpora and common
  terms still score), stored as an inverted index:
  ``dict[str, list[tuple[int, float]]]`` mapping each term to a posting list of
  (doc_index, precomputed BM25 weight).
- **Retrieve** — for each unique query token present in the index, walk its
  posting list and accumulate scores; sort by descending score (score > 0 only).

## Performance

- Index built on first use and cached (~8 ms for 200 skills on a CPU-only
  VM). Each later turn only stats the skill roots, their immediate
  subdirectories and `config.yaml` to validate the cache. A zero-skill
  install caches the empty result too (one warning, no rescans).
- Retrieval is sub-millisecond (~0.03 ms mean for 200 skills). The inverted
  index touches only documents that share a query term — no full-corpus scan.
- No compiled dependencies. The plugin uses only the Python standard library
  (plus pyyaml for config/frontmatter parsing). This removes a 154 MB
  numpy/scipy install and a ~573 ms import cost, which matters for subprocess
  spawning paths (e.g. a Claude Code `UserPromptSubmit` variant).
- Failures in the hook return `None` (no injection) so the agent keeps working.

## Dependencies

- `pyyaml`

## Limitations

- BM25 is **lexical**, not semantic. Paraphrased queries that share few tokens
  with a skill's description may rank poorly even when the intent matches.
- Descriptions longer than 200 characters are truncated in the injected block;
  use `skill_view(name)` for the full skill body.
- Compaction uses Hermes's read-only `agent.prompt_builder` helpers; if they
  cannot be imported, Phase 1 is skipped and retrieval falls back to the
  standalone loader.
- A named session is only injected once its system prompt has been built in
  this process (that build records the session's tool capabilities). A session
  restored after a restart without a rebuild gets no injection rather than
  risking skills Hermes hides from it.
- A content-only edit of a nested `SKILL.md` (`category/skill/SKILL.md`)
  made outside Hermes (e.g. in an editor) changes no directory mtime, so it is
  picked up only after Hermes clears its skills prompt cache or the agent
  restarts. Edits through `skill_manage`, and any added/removed skill or
  config change, are picked up on the next turn.
- Phase 1 depends on Hermes internals (`agent.prompt_builder`) and can break
  on a Hermes upgrade.
- BM25 top-1 precision is soft: the best-matching skill is often not rank 1,
  though it usually lands within the first few results. Ranking depends entirely
  on your own corpus and how its descriptions are worded, so `TOP_K` below ~5 is
  not recommended.
- **Compaction** rewrites the request's `<available_skills>` block using
  read-only imports from `agent.prompt_builder` and `agent.skill_utils`
  (all ten disclosed in the README); if those change signature, any
  mid-scan exception falls back to the standalone loader, so the corpus
  is never silently empty.
- The stdlib index computes in float64 (the previous scipy version used
  float32). Equal-scoring skills may order differently than before. This is
  harmless — the scores are genuine ties (~1e-6 difference) — but it is a real
  behaviour delta from the scipy version.

Files in this skill

  • LICENSE2.4 KB
  • README.md2.1 KB
  • SKILL.md6.9 KB
  • __init__.py12.8 KB
  • plugin.yaml477 B
  • requirements-dev.txt78 B
  • requirements.txt12 B
  • scripts/bm25_retriever.py25.9 KB
  • tests/conftest.py188 B
  • tests/test_bm25_retriever.py16.4 KB
  • tests/test_issue8_round2.py13.1 KB
  • tests/test_regressions.py25.1 KB
  • tests/test_stdlib_parity.py11.4 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.