编码智能体知识索引工具

kvault 是一款面向编码智能体的本地优先知识索引工具,将磁盘上的 Markdown 文件索引为可搜索的 SQLite 数据库,并通过 CLI、Claude Code 钩子和可选 MCP 适配器提供按 token 预算控制的检索能力,用于在分散的笔记与文档中快速定位项目决策和相关信息。

  • by rabrooks
  • Rust
  • 本地部署
  • 2 星标
  • 更新于
在 GitHub 查看 rabrooks/kvault

使用说明

kvault

Local-first knowledge index for coding agents. Markdown files on disk are the only source of truth; kvault derives a disposable SQLite index (FTS5 over heading-level chunks, links, tags) and gives agents token-budgeted retrieval through a CLI, Claude Code hooks, and an optional MCP adapter.

Status

v0 rebuild in progress. The 0.1 manifest/ripgrep/Tantivy code was removed; it never saw real use. Commands land milestone by milestone:

Milestone Delivers State
M0 toolchain pin, module tree, schema v1, status, CI done
M1 init, index, list over real roots (incremental, rename-safe) done
M2 search, get, --explain, bench done
M3 recall budget packer, brief done
M4 Claude Code hooks, SKILL.md, add, session capture done
M5 links stream, links next
M6 MCP stdio adapter (--features mcp)

Commands that are not implemented yet exit with code 3 and name their milestone.

Why

Knowledge for agent work ends up in several places at once: Claude Code auto-memory dirs, per-project notes/ folders, reference notes, CLAUDE.md. Nothing searches across them, and the agent only sees what fits in a session-start file. kvault indexes those directories in place, never moves or rewrites a file, and answers "what did we decide about X" in one command sized to a token budget.

Design principles: markdown is the only truth; index what exists (frontmatter optional); budgets are tokens, not counts; hooks are fast and silent; no LLM and no daemon in the loop; ranking is explainable; the core stays small.

Build

Requires Rust 1.95 (pinned in rust-toolchain.toml; rustup picks it up).

cargo build --release
cargo test

Configuration

~/.config/kvault/config.toml (override with KVAULT_CONFIG; index path with KVAULT_DB):

[[roots]]
path = "~/.claude/projects/*/memory"   # globs allowed
kind = "memory"
weight = 1.2
project_from = "claude_slug"

[[roots]]
path = "~/Projects/*/notes"
kind = "notes"
project_from = "parent"

[[roots]]
path = "~/Projects/notes"
kind = "reference"
weight = 0.8

[brief]
budget = 2000
recent_days = 14

kvault init writes this for you (only roots that exist on disk) and runs the first index.

Indexing

kvault index is incremental: files whose mtime and size match are skipped without being read; changed files are hashed with BLAKE3 and re-parsed only if the hash differs. A file that moves keeps its document id. Deleted files are soft-deleted so a restored file reclaims its id. A root that is missing on disk is skipped, never treated as a mass delete. Git ignore files are not honoured inside roots (they are often inside ignored directories); a .kvaultignore is.

Documents are chunked by heading. Sections over 1,200 tokens split at the best blank line or rule, never inside a code fence; sections under 40 tokens merge into the next one. Frontmatter is optional: title, description, kind/type, tags, created, updated, pinned are lifted, everything else is kept as filterable metadata. [[wikilinks]], relative .md links, - verb [[Target]] relations, and - [category] text #tag observation lines are extracted.

On a corpus of 226 files / 1.3 MB: cold index ~200 ms, warm ~4 ms.

Searching

The agent writes the query; kvault does no expansion of its own.

kvault search "lambda cold start"            # bare words: any may match, each prefix-matched
kvault search '"cold start" -timeout'        # quoted = phrase, -word excludes
kvault search 'tag:devops symplr'            # inline tag filter; -tag:x excludes
kvault search 'tag:devops'                   # filters alone list matching docs, newest first
kvault search 'lex: title:lambda NEAR(cold start)'   # raw FTS5 syntax passed through
kvault search ... --project vllm --kind memory --path 'research/*' --since 30d
kvault search ... --format cli|json|md|paths|ids --explain --per-chunk -n 20

Four streams run independently and are fused by reciprocal rank fusion (k=60): all-terms FTS (weight 2), any-term FTS, exact title match, and tag match. Exclusions (-word, -tag:x, NOT inside lex:) apply to every stream. A small authority multiplier (0.85–1.15) from the root weight and pinned: true breaks ties. Results are one chunk per document with the other matching chunks listed as siblings. --explain prints each hit's per-stream rank and raw score, its fused score, and per-stream timings.

Hits carry an eight-hex-digit id, a prefix of a BLAKE3 over the file path, heading path and chunk text, so it is stable until one of those changes and identical boilerplate sections in different files still get different ids. kvault get <id> prints the chunk with line numbers; kvault get <path>[:from[:count]] prints a line window; --doc prints the whole file. Paths are accepted as printed by search, absolute, or as any unique suffix. If a file changed since it was indexed the output is marked [stale].

kvault bench <queries.jsonl> [--baseline] [-v] [--min-hit5 F] scores hit@1/3/5, section hit@5, MRR and latency over a query file ({"query", "expected": ["path#heading"]} per line), optionally against a case-insensitive grep over the same files. --min-hit5 makes it a CI gate. On a 44-query bench over the real corpus (release build): kvault hit@1 0.95, hit@5 1.00, MRR 0.98, p50 1 ms; grep hit@1 0.59, hit@5 0.98, MRR 0.75, p50 10 ms.

Recall and brief

kvault recall "<query>" --budget 1500 [--project P] [--format md|json] is search packed into a token budget. It is what agents call. Each hit is shown at one of three tiers: a one-line reference, the reference plus a two-line preview, or the whole chunk. The packer starts with the top three hits in full and the next seven previewed, then sheds from the bottom of the ranking (drop reference lines, downgrade previews, downgrade full chunks, downgrade the remaining previews, and only then drop hits) until the whole output, framing included, fits. A chunk is shown whole or not at all. The budget applies to the bytes printed in whichever format was asked for, as measured by the index's token counter; the output ends with the number of hits omitted (counted up to 200) and the kvault get hint.

Shown text is verbatim except for trimming: frontmatter is dropped, trailing whitespace and runs of blank lines are collapsed. Nothing inside a line is rewritten, so path:line citations and code fences survive. A chunk whose file changed since it was indexed is marked [stale] and shown as a preview at most, never quoted in full.

kvault brief [--cwd P] [--budget N] prints the context block the SessionStart hook injects for a working directory: pinned notes and rules, notes updated in the last 14 days, and notes elsewhere that link to or share tags with the project, one line each with a chunk id. The project is the nearest ancestor directory whose name matches a configured root's project. The block fits the budget (2000 tokens by default) and stays under 8,000 characters so Claude Code does not swap it for a file preview; the header line is byte-stable across runs except the counts and a coarse index age at its end.

Claude Code integration

The repository is also a Claude Code plugin: hooks/hooks.json, skills/kvault/SKILL.md and .claude-plugin/plugin.json. With the binary on PATH:

claude --plugin-dir ~/Projects/kvault        # try it for one session

or copy the three hook entries into ~/.claude/settings.json and the skill directory into ~/.claude/skills/. The hooks:

Event Command What it does
SessionStart (startup, clear, compact; not resume or fork) kvault hook session-start incremental index, then injects kvault brief for the session's cwd as additionalContext
Stop kvault hook stop writes or rewrites the session note, indexes it
PreCompact kvault hook pre-compact same writer, so a compaction never loses the note

Hooks read Claude Code's JSON on stdin, print JSON on stdout and always exit 0. A failure prints {} and appends one line to ~/.local/state/kvault/hooks.log (KVAULT_LOG overrides), so a week of use can be audited with one cat. Stop and PreCompact make no LLM calls and read at most 1 MB of the transcript (the first 128 KB and the last 896 KB).

The session note is <project>/notes/sessions/<date>-<session8>.md, one file per session rewritten on every stop, where <project> is the nearest ancestor of cwd with a .git. When the project has no notes/ dir it goes to [capture] fallback_dir (default ~/.local/share/kvault/sessions/<project>/), which is only indexed if you add it as a root. The note carries kind: session, the first prompt (its first line is the title, so the brief's recent list reads as a log), the last assistant message, and the files the transcript shows were read or edited. <private>…</private> spans are dropped and credential-shaped tokens (PEM blocks, Bearer x, AWS, GitHub, Slack, OpenAI, Anthropic and Google key prefixes) are replaced with [redacted]. Promoting a session note into a durable note is the agent's job, through the skill and kvault add.

kvault add --title T [--kind decision|fact|howto|note] [--tags a,b] [--project P | --root DIR] [--file F]

writes <slug>.md with frontmatter into the project's notes root (the memory root when that is the only one; --root to choose) and indexes it. The body comes from --file or stdin. An existing file is replaced only when --expected-hash matches the BLAKE3 the previous add printed (a prefix of eight or more hex digits is enough), so two agents cannot overwrite each other unknowingly. In a Claude Code memory root the file uses that loader's name/description/metadata.type frontmatter and a pointer line is appended to MEMORY.md.

CLI (v0 surface)

kvault init                                  write config, index
kvault index [--full] [--root P]             incremental reindex
kvault search <q> [--project P] [--tag T] [--path G] [--since 30d] [--explain]
kvault recall <q> --budget N [--project P]   search packed to a token budget (agents call this)
kvault get <id|path>[:from[:count]]          print a chunk or line window
kvault brief [--cwd P] [--budget N]          what the SessionStart hook injects
kvault add --title T [--kind K] [--tags a,b] write a note and index it
kvault list / links / status / bench
kvault hook session-start|stop|pre-compact   Claude Code hook entry points
kvault serve                                 MCP stdio (feature `mcp`)

License

MIT