Node Search 🕸️
Version: 0.3.1
Hermes Agent plugin exposing node_search: a bounded first-pass Markdown/Obsidian metadata and link-graph candidate selector.
Use it when an agent needs to decide which Markdown notes to read next: topic/project context, YAML/frontmatter filters, tags, backlinks, outgoing links, orphan/no-backlink notes, unresolved/dangling wikilinks, ambiguous links, and small graph expansion. It returns compact candidate evidence; the agent should then call read_file only on selected files.
It intentionally does not search full Markdown bodies in v1. Use normal file/content search for exact prose snippets, scripts, tables, transcripts, or non-Markdown files.
Features
- Model-callable
node_searchtool. - Configurable allowed Markdown roots.
.mdfiles only.- YAML frontmatter, headings, Obsidian wikilinks, outgoing links, backlinks.
- Explicit malformed YAML, unresolved links, orphan/no-backlink nodes, and ambiguous basename resolution.
- Graph/link-health filters:
orphan,no_backlinks,unresolved/dangling,ambiguous,has_outgoing,has_incoming. - Intent modes:
auto,topic_context,graph_health,metadata,link_neighborhood. - Compact raw output by default:
frontmatter_summary, link counts, andwhy_readevidence for triage before reading files. - Explicit
output_mode: "full"legacy/raw escape hatch for debugging. - Configurable output defaults in
plugin.yamlplus per-call overrides for frontmatter presets/fields, link detail, evidence, and result caps. - JSON cache derived from
$HERMES_HOMEorNODE_SEARCH_CACHE. - Empty broad calls fail with repair examples instead of dumping a vault.
Requirements
- Hermes Agent with plugin support.
- Python 3.10+.
PyYAML>=6.0,<7, declared inplugin.yamland installed by Hermes's plugin dependency manager. Hermes core constraints take precedence.
Python 3.10+ describes the standalone indexer; your Hermes release may require a newer Python. The previous empty Python-3.13-only uv.lock was not a usable project lock and has been removed. Development tooling is pinned in requirements-dev.txt.
Privacy and execution disclosure
The tool reads configured local Markdown files in full to extract metadata, headings and links, but returns candidate metadata rather than body prose. Default result exclusions are not scan exclusions. The persistent JSON cache contains extracted frontmatter, headings, links and paths, including fields omitted from compact responses; protect it as you would your notes. Old caches are rebuilt automatically after this upgrade. Deleting source notes removes their cached graph data on the next scan of that scope, not immediately. It performs no network requests, shell commands, background work, telemetry, paid/model calls, or credential-store access. Configuration is read through Hermes's active secret scope when available, with environment fallback only in standalone use. Roots and cache locations should be distinct per profile; explicitly sharing a cache path means sharing its stored metadata. Filesystem access is not a sandbox against concurrent hostile filesystem mutation.
Installation
Default profile:
mkdir -p ~/.hermes/plugins
git clone https://github.com/Heeervas/node-search-plugin.git ~/.hermes/plugins/node-search
hermes plugins enable node-search
Named profile:
mkdir -p ~/.hermes/profiles/<profile>/plugins
git clone https://github.com/Heeervas/node-search-plugin.git ~/.hermes/profiles/<profile>/plugins/node-search
hermes -p <profile> plugins enable node-search
Restart the CLI/gateway after enabling because plugin discovery and tool schemas load at startup.
Configuration
Set at least one allowed Markdown root for shared/public installs:
export NODE_SEARCH_ALLOWED_ROOTS="$HOME/notes,$HOME/work/wiki"
Compatibility variables:
| Variable | Default | Purpose |
|---|---:|---|
| NODE_SEARCH_ALLOWED_ROOTS | $HERMES_HOME/brain | Comma-separated roots that scope may scan inside. |
| NODE_SEARCH_ROOT | empty | Legacy single-root override; prepended to allowed roots when set. |
| NODE_SEARCH_CACHE | $HERMES_HOME/.cache/hermes/node_search/index.json | JSON index cache path. |
Output defaults live in plugin.yaml under defaults.output:
defaults:
output:
output_mode: compact
frontmatter_preset: default
frontmatter_fields: []
link_detail: counts
link_sample_limit: 5
evidence_detail: basic
max_frontmatter_value_chars: 240
max_chars_per_result: 2000
max_total_chars: 12000
Per-call arguments override these defaults. The default frontmatter preset intentionally excludes source/url-style fields; use frontmatter_preset: "source", frontmatter_preset: "all", or explicit frontmatter_fields when those are needed.
Security notes:
scopemay be relative to the selected root or absolute inside it.- Symlink/path traversal escapes are rejected or ignored.
- The tool returns metadata/link candidates, not file bodies.
- Result count, graph depth, links per node, headings, and frontmatter size are capped.
Example calls
Topic context:
{
"scope": "obsidian-vault",
"mode": "topic_context",
"query": "market radar",
"query_regex": false,
"where": ["path", "basename", "frontmatter", "links"],
"depth": 1,
"expand": "both",
"limit": 10
}
Compact output is the default. It returns stable metadata like:
{
"result_schema_version": "node_search.compact.v1",
"output_mode": "compact",
"frontmatter_preset": "default",
"frontmatter_fields_used": ["status", "note_type", "summary", "tags"],
"results": [
{
"path": "notes/example.md",
"title": "Example",
"score": 60,
"matched": ["basename", "frontmatter.title"],
"frontmatter_summary": {"status": "open", "summary": "..."},
"links": {"incoming_count": 1, "outgoing_count": 2, "resolved_count": 2, "unresolved_count": 0, "ambiguous_count": 0},
"why_read": ["matched indexed fields: basename"]
}
]
}
Full legacy/raw output for debugging:
{
"scope": "obsidian-vault",
"query": "market radar",
"output_mode": "full",
"include": ["frontmatter", "incoming_links", "outgoing_links", "why_read"],
"limit": 3
}
Custom compact metadata:
{
"scope": "obsidian-vault",
"query": "client",
"frontmatter_fields": ["status", "client", "priority", "updated_at"],
"link_detail": "samples",
"link_sample_limit": 3
}
Regex over indexed fields:
{
"scope": "obsidian-vault",
"query": "market[-_ ]radar|massive",
"query_regex": true,
"where": ["path", "basename", "frontmatter"],
"include": ["why_read"],
"limit": 10
}
Graph health:
{
"scope": "obsidian-vault",
"mode": "graph_health",
"link_state": ["orphan"],
"include": ["incoming_links", "why_read"],
"exclude_path_filter": ["archive/", "request_dump"],
"limit": 25
}
{
"scope": "obsidian-vault",
"link_state": ["unresolved"],
"include": ["outgoing_links", "why_read"],
"limit": 25
}
Guardrail: calls must provide scope plus at least one narrowing parameter: query, frontmatter, tags, has_links_to, linked_from, link_state, path_filter, or a non-auto mode.
Benchmark
The repository includes benchmark_node_search.py, a small reproducible benchmark using a fixed synthetic Obsidian-like dataset generated in a temporary directory. It does not require or expose a private/live vault.
python benchmark_node_search.py --repeats 7 \
--json-out benchmark_results.synthetic.json \
--md-out benchmark_results.synthetic.md
Development
python -m pip install -r requirements-dev.txt
python -m py_compile __init__.py node_index.py benchmark_node_search.py
ruff check .
python -m pytest . -q
Before publishing, scan for secrets and private artifacts. Do not commit real vault exports, handoffs, plans, .env, session transcripts, or internal docs.
README from the reviewed commit 2482119; it updates when the author re-pins.

Heeervas