pi-zvec
Local semantic code search for pi via zvec-grep (zg): hybrid BM25 + embedding retrieval over a per-project index. The zg_search tool is exposed to the model only while the index is ready; /zg:status reports state + environment diagnostics in one framed di
Package details
Install pi-zvec from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:pi-zvec- Package
pi-zvec- Version
0.4.0- Published
- Sep 4, 2026
- Downloads
- 622/mo · 622/wk
- Author
- kslamph
- License
- MIT
- Types
- extension
- Size
- 123.6 KB
- Dependencies
- 0 dependencies · 4 peers
Pi manifest JSON
{
"extensions": [
"./index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-zvec
Local semantic code search for pi, powered by zvec-grep (zg).
Hybrid BM25 + embedding retrieval over a per-project index, maintained by zg and exposed to pi as one LLM tool plus lifecycle commands. Fully local: local Potion embedding models run on CPU; no remote-authorization path is ever touched.
Why
ffgrep/rg find exact strings. They can't answer "where do we handle retry backoff?" when you don't know the identifier — that's grep thrash: repeated empty searches, growing context, wasted tokens. zg_search complements exact search with ranked, line-cited retrieval: concept in, evidence out, in one tool call.
Prerequisites
npm install -g @zvec/zvec-grep # Node >= 22
The first index downloads the small potion-code-16m-v2 embedding model (~32 MB) to ~/.zvec-grep/models (respects ZVEC_GREP_HOME). Index state lives in .zvec-grep/ under your project root.
You do not have to install it yourself: pi-zvec detects a missing zg and offers to run the install (see Environment readiness).
Known install snag.
@zvec/zvec-greppullsonnxruntime-node, whose postinstall script picks a CUDA-specific build and can die withFailed to detect CUDA version ... Unsupported CUDA version: 13followed byconnect ECONNREFUSED 127.0.0.1:443. This is the native binding download, not the CLI.npm install -g @zvec/zvec-grep --ignore-scriptsinstalls a workingzgfor local CPU embeddings. pi-zvec surfaces the raw npm log rather than hiding it.
Install
pi install /absolute/path/to/pi-zvec # local
pi install git:github.com/kslamph/pi-zg # once published
or copy/symlink index.ts into ~/.pi/agent/extensions/ / .pi/extensions/.
What you get
zg_search tool (for the model) — visible only when the index is ready.
Natural-language search over the project index. Ranked, compact, line-cited snippets.
The tool description teaches routing: concept questions → zg_search; exact
identifiers/paths/regexes → ffgrep/rg. It also tells the model the call is
cheap (local, sub-second), so it reaches for it before a third guess-and-check
exact search rather than after. Every result passes through the evidence
gate first.
The tool is registered always but activated only when zg status --check-ready
passes. An unindexed project costs the model nothing: no schema, no Available tools line, no guideline bullet — and no coaching hint pointing at a tool it
cannot call. When a background index finishes mid-session, the tool is revealed
at the next agent_settled boundary, never mid-request (activating a tool
rebuilds the system prompt and can invalidate the provider's cached prefix).
Commands (for you)
| Command | What it does |
|---|---|
/zg:index |
Diagnose + repair, then build/update the index: /zg:index once, /zg:index auto, --rebuild. Installs zg, clears a project opt-out, and gitignores the index before creating it |
/zg:status |
Read-only, framed report: project state on top (tool visibility, mode, last indexed, embedding, coaching), environment diagnostics below (node, binary, launch proof, model cache, index dir, gitignore), plus the next step if it isn't ready |
/zg:query |
Search from the prompt: /zg:query how are callbacks validated [-t ts] [-g src/**] [-n 10] |
/zg:rg |
Managed ripgrep (no index needed): /zg:rg -i -C 2 pattern path |
/zg:drop |
Delete the project's index (confirm first) and hide the tool |
/zg:disable |
Stop pi-zvec in this project — hides the tool, no nudges, and never asks again |
/zg:enable |
Re-enable pi-zvec for this project after an opt-out |
Index lifecycle: once vs auto
indexMode in .pi/zvec.json decides what happens at session start when the
project has no index:
| Mode | Behaviour |
|---|---|
ask (default) |
One dialog: index once, auto-index, ask later, or never for this project |
manual |
Never index on its own — just report that /zg:index is available |
auto |
Build/update in the background at session start, no prompt |
{
"indexMode": "auto",
"disabled": false,
"coach": true,
"verify": true,
"gitignore": true
}
Note that --refresh wait on every query already keeps an index current for
freshly saved files, so auto is about first build / rebuild after drop, not
about per-edit freshness. disabled is orthogonal: it opts the project out of
pi-zvec entirely, keeps the tool out of the prompt, and suppresses the
setup offer — choosing "Never for this project" sticks across sessions.
Index hygiene
pi-zvec creates .zvec-grep/, so it owns keeping it out of git. The entry is
added to the repository root's .gitignore before zg index runs, not
offered afterwards:
- A cache dir that exists but is not ignored shows up in
git statusthe moment it appears; a post-build prompt leaves a multi-minute window wheregit add -Acommits it. - Doing it up front means background and auto builds are covered too. They must never raise a dialog, and this needs none — it is a one-line, idempotent append, reported through a notification rather than a question.
- Outside a git repository it is a no-op: pi-zvec never creates a stray
.gitignorein an untracked tree. - The authoritative check asks git (
git check-ignore), so a rule you already cover via a globalcore.excludesFile,.git/info/exclude, or a parent.gitignoreis respected and no redundant line is written. A cheap in-process text scan gates that subprocess, and is deliberately kept no looser than git's own whitespace rules — a scanner that accepted.zvec-grep/(leading spaces are significant to git) would skip a repair that is actually needed. - Set
"gitignore": falseto manage.gitignoreyourself.
Environment readiness
pi-zvec walks a ladder, cheapest check first, and names the first unmet layer instead of failing with an opaque exit code:
node >= 22 → zg on PATH → zg actually launches → model cache → .zvec-grep/ → index ready
Session start uses only the in-process layers (PATH scan, stat) plus one
zg status when an index dir already exists, so an unindexed project pays no
subprocess at startup. When zg is missing, pi-zvec asks before installing and
then runs npm install -g @zvec/zvec-grep using the npm beside the running
node binary (so an nvm switch cannot install into a prefix pi is not using),
re-probes with zg --version, and continues into indexing. Nothing installs
without a yes, and nothing installs at all in non-interactive modes.
Grep-thrash coaching — after 3 consecutive near-empty rg/grep/ffgrep searches, one bounded hint suggests zg_search for concept queries. Strictly rate-limited: max 2 per session, 5-minute cooldown, never twice in a row, only for search-shaped commands (rg exit 1 = no matches; path typos are ignored), and only while zg_search is actually callable.
Evidence gate (lexical verification)
The problem this solves. zvec-grep fuses its FTS and vector routes with
Reciprocal Rank Fusion (score = Σ 1/(60 + rank)) and cuts at a positional
limit. RRF is purely ordinal, so the top hit scores ~0.032 whether its cosine
similarity was 0.92 or 0.03 — and a query for something the repo does not
contain still fills to limit with confident, line-cited hits. Measured here:
a genuinely relevant query scored 0.0320, while
"quantum chromodynamics lattice QCD gluon field tensor" scored 0.0323.
matchedBy=fts+vector is not a confidence signal either — both routes always
return topk documents, so it is set for any query. zg discards the raw cosine
in its recall layer, so pi-zvec cannot simply read a real score.
What the gate does instead, using only local, cheap signals: tokenizes the
query into distinctive terms (stopwords, generic verbs, and structural words
like field/value/data removed), reads the actual text of each cited
range (zg prints only an anchor line), and does one bounded in-process scan
of the workspace for those terms.
| verdict | when | what the model sees |
|---|---|---|
none |
no query term occurs anywhere in the workspace | hits suppressed; explicit no-match + "confirm with ffgrep" |
weak |
terms exist, but too few appear in the returned hits | hits kept, warning before them, rarest terms to hunt |
ok |
a real share of the terms is found in the hit text | hits + one ✓ lexical support line |
Cost: 3–4 ms cold, 1–2 ms cached (60 s TTL per cwd+terms), against zg's own
1–2 s. Set "verify": false to see zg's raw positional ranking untouched.
Honest limits. This is lexical, not semantic — it cannot catch polysemy, so
"rate limiting" still looks supported in a repo whose only rate limiting is a
UI hint throttle. It reports the terms it did not find, which is the most the
model can act on. And absence is only claimed when the scan finished: a scan
that hits its file/byte budget says "not seen before the scan budget ran out"
instead of "absent", and can never produce none.
Privacy / security
- Retrieval is local: local embedding model, CPU device, direct (no-daemon) mode.
- The evidence gate reads file contents in-process to check the query's terms (bounded: 5 000 files / 12 MB / 2 MB per file). It is the same data the index already holds, and nothing is sent anywhere.
- Remote embeddings are never configured;
zg authis never touched. zg's local-first guardrails remain in force regardless. /zg:rgruns through a whitelist of rg flags; no output-format rewrites, no shell interpolation. Flags that execute programs (--pre) or rewrite output (--json,-r) are rejected.- The only network access in pi-zvec is the one you approve: an
npm install -gand zg's first model download. Indexing itself is local CPU inference. - The index (
.zvec-grep/) and settings (.pi/zvec.json) live in your project directory; pi-zvec adds the index to your repository's.gitignorebefore building it. - The only file pi-zvec ever writes inside your repo (besides the index itself) is that one
.gitignoreline, and only when the directory is a git repository. Opt out with"gitignore": false.
Architecture
index.ts extension entry: tool, commands, readiness-gated activation, coaching
zg.ts zg CLI wrapper (direct mode, typed errors: noIndex / missingBinary)
env.ts readiness ladder + consent-gated repair (PATH scan, launch proof, npm i)
coach.ts thrash state machine (pure, testable)
relevance.ts evidence gate: tokenizer, hit parser, corpus scan, verdict (pure core)
state.ts per-project settings and indexMode migration (.pi/zvec.json)
render.ts TUI text dialog for command output
smoke.ts functional tests: `node smoke.ts` (live tier auto-skips without `zg`)
test/ pi-server resolve stub, so the pi-dependent tiers can actually run
Every zg call runs --mode direct with --refresh wait, so results see freshly
saved files without a daemon — except while an index build is in flight, where
queries drop to --refresh off rather than blocking on the writer. Errors are
typed: "no index" and "binary missing" are detected precisely and surface
targeted messages instead of stack traces. Detached work (background index,
npm install) survives session replacement: every UI side effect in those paths
is stale-context-safe, because a throw inside a stdout stream callback would
otherwise kill the host process.
Tests
node smoke.ts # pure tier always runs; live tier needs `zg`
PI_ZVEC_SMOKE_LIVE=0 node smoke.ts # pure tier only
# Full coverage: also runs the pi-dependent tiers (state migration, prompt
# parsing, session-start decision, index hygiene). Plain `node smoke.ts` skips
# them, because pi-coding-agent's public entry statically imports
# @earendil-works/pi-server, which is not installed with it.
node --import ./test/register-pi-server-stub.mjs smoke.ts
The live tier builds a scratch workspace, queries it, drops the index, and
asserts the post-drop noIndex path; it skips (not fails) when the binary is
absent, so the suite is runnable on an unready machine — the exact case this
extension has to handle well. A missing peer dependency skips; any other load
error fails the run, because reporting a broken module as "skipped" is how the
index.ts tier went unnoticed while it rejected Node's strip-only type loader.
The scratch fixtures use real git init repositories, so .gitignore behavior
is verified against git itself rather than against our own text scan.
The evidence gate is tested both ways: pure-tier cases for the tokenizer,
metadata-stripping hit parser, stem matcher, bounded scan and verdict; and a
live-tier end-to-end that builds a real index, confirms zg still pads a
"quantum chromodynamics…" query to the limit, and asserts the gate verdicts it
none while a genuine "payment retry backoff" query comes back ok.
Scope discipline
pi-zvec does one thing: semantic search over a workspace index. Related but separate capabilities (memory/docs recall, multi-workspace search) belong in their own extensions — install what you need.
License
MIT