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

Packages

Package details

extension

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-grep pulls onnxruntime-node, whose postinstall script picks a CUDA-specific build and can die with Failed to detect CUDA version ... Unsupported CUDA version: 13 followed by connect ECONNREFUSED 127.0.0.1:443. This is the native binding download, not the CLI. npm install -g @zvec/zvec-grep --ignore-scripts installs a working zg for 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 status the moment it appears; a post-build prompt leaves a multi-minute window where git add -A commits 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 .gitignore in an untracked tree.
  • The authoritative check asks git (git check-ignore), so a rule you already cover via a global core.excludesFile, .git/info/exclude, or a parent .gitignore is 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": false to manage .gitignore yourself.

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 auth is never touched. zg's local-first guardrails remain in force regardless.
  • /zg:rg runs 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 -g and 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 .gitignore before building it.
  • The only file pi-zvec ever writes inside your repo (besides the index itself) is that one .gitignore line, 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