pi-zvec-grep-ext
pi plugin for zvec-grep (zg): hybrid BM25 + embedding search and managed ripgrep in one tool, with freshness-guaranteed queries, CPU-pinned local embeddings, readiness commands, and a ModelScope mirror warm-up that works without access to huggingface.co
Package details
Install pi-zvec-grep-ext from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:pi-zvec-grep-ext- Package
pi-zvec-grep-ext- Version
0.3.5- Published
- Sep 15, 2026
- Downloads
- 497/mo · 53/wk
- Author
- angri450
- License
- Apache-2.0
- Types
- extension
- Size
- 204.2 KB
- Dependencies
- 0 dependencies · 2 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-grep-ext
zvec-grep (zg) inside pi:
hybrid BM25 + embedding retrieval and managed ripgrep, wrapped as one native tool (four
actions behind a single schema), plus human commands (/zg:*) and a mirror-based model warm-up
that works where huggingface.co is unreachable.
pi install npm:pi-zvec-grep-ext
Try it without installing (nothing is added to your settings):
pi -e npm:pi-zvec-grep-ext
Then start pi and run /zg:status. If zg itself is not on PATH, that command prints the one
line that installs it. The tool appears as zg; ask something like "where is retry backoff
handled?" and the agent will call it.
What you get
tool zg |
one tool, four actions: search (hybrid / fts / vector / fuse), rg (managed ripgrep, no index needed), index (build / rebuild / drop), status |
/zg:status |
readiness ladder: node, resolved binary + version + where it came from, model cache, index coverage/embedding/dims, .gitignore state, effective config with the source of every value, and which directory config came from vs which one queries run in |
/zg:index |
build or update: --rebuild, -g/-t filters, a bare model name, --device; streams progress |
/zg:query / /zg:rg |
search straight from the prompt, no model turn |
/zg:drop |
delete the index - confirms first, refuses without a UI |
/zg:settings |
per-project knobs without editing JSON by hand |
/zg:disable / /zg:enable |
opt a project out; "never for this project" from the startup dialog sticks |
Behaviour that is guaranteed, not configured
These are the things that were wrong in the first version of this package and are now pinned by tests. Each one is a silent failure mode if dropped.
- Queries see your latest save. Every search passes
--refresh wait.zgdefaults tooffin direct mode andbackgroundin server mode, so without this the agent edits a file, searches for it, and gets stale hits - and has no way to know. - No CUDA roulette. Indexing passes
--device cpuon every platform except macOS (metalworks there), and exportsZVEC_GREP_DEVICE=cpufor the child process when nothing else pinned a device. On linux x64zg'sautoresolves to the CUDA execution provider; a faileddlopenpoisons every later ONNX session in that process (transformers.jscaches the rejected promise), which shows up as an endless per-file retry that leaks memory off the JS heap until the OOM killer arrives. See upstream zvec-grep#135 and transformers.js#1767. - Index builds do not pass
--mode direct. If a daemon already owns the workspace, upstream rejects the build withDAEMON_LEASE_ACTIVE.autoworks with and without a daemon. - Output is capped at pi's own tool limits (2000 lines / 50KB) at line boundaries, and the cap is announced to the model with the total line count plus how to narrow the query.
- Credentials never reach the transcript.
details.commandis what lands in the session file, so--api-keyvalues are redacted there. PreferZVEC_GREP_API_KEY. - Deleting an index needs a human. The tool refuses
drop: truewithoutconfirm: true;/zg:dropasks, and refuses to act when there is no UI to ask in. .zvec-grep/is gitignored before it is created. The check asks git itself (git check-ignore), so a globalcore.excludesFile,.git/info/excludeor a parent.gitignorecounts, and no redundant line is written. Outside a git repository nothing is written at all. The probe path carries a trailing slash on purpose: a.zvec-grep/rule is directory-only and git cannot classify a path that does not exist yet (measured:check-ignore -q .zvec-grepsays "not ignored",check-ignore -q .zvec-grep/says ignored).- A half-built index is reported as a failure. When every file fails to embed,
zgcan leave a 0%-coverage directory; the plugin says so instead of reporting success. - A stale index is not an error, and a subdirectory is not a different project. Readiness is
"an index exists and can answer" (parsed from
zg status), not--check-ready- a 88%-coverage index that merely needs a refresh is reported as present and the query's--refresh waitfixes it. And queries run from your directory, sozgfinds the nearest index the same way it does on the command line; only.pi/zvec-grep.jsonis looked up at the repository root. Getting this backwards meant "no index" in exactly the layout used by this machine's literature matrices (index in.../apple-replant, git root one level up), so it has a regression test. - Destructive and cancelled actions are honest.
/zg:dropasks first, refuses without a UI, and its argv contains nothing but--drop --yes(upstream rejects drop combined with indexing options - the earlier version always sent--deviceand therefore never deleted anything, silently reporting an error instead). Pressing Esc surfaces asCancelled., not as an unknown zg failure. Arootthat does not exist says so instead of "zg binary not found". - It stays quiet in directories that are not projects. The startup prompt and the footer
✗ no indexonly appear where an index makes sense: a git repository, a directory with.pi/zvec-grep.json, or one that already has an index. In a plain container directory (the pi workspace here holds 97 repositories and 4.9GB) it says nothing instead of offering to index all of it. Building there is still possible -/zg:indexworks, and it warns once when the scope looks accidental (no VCS, >25 subdirectories, no glob/type filters). - A blocked
postinstallis not a dead end. npm hosts that gate lifecycle scripts (this machine printsnpm warn allow-scripts ... pi-zvec-grep-ext@0.2.0 (postinstall: node scripts/warmup.js)) never run the warm-up, so the plugin re-checks the model cache before every index build: in a TUI it asks once before downloading ~32MB, and in-p/JSON/RPC it only prints the command. Nothing gets downloaded behind your back either way.
Search results come with a support verdict
zg fuses its lexical and vector routes with Reciprocal Rank Fusion and cuts at a positional
limit. RRF is purely ordinal, so the displayed score carries no confidence: the authors of
pi-zvec measured 0.0320 for an on-topic query against 0.0323
for "quantum chromodynamics lattice QCD gluon field tensor" in the same repository. What this
package measured itself is the observable part of the same effect: a query whose terms occur
nowhere in the workspace still comes back with a full page of line-cited hits
(tests/live.test.ts asserts exactly that, so the claim is re-checked on every run rather than
trusted from prose). matchedBy=fts+vector is not a signal either - both routes always return
their top-k - and the raw cosine is dropped in zg's recall layer, so a plugin cannot read a real
score. Instead the tool adds one line, computed locally from the actual cited text:
[zg-support weak] only 1/5 query terms appear in the hits; missing from hits: jitter, 429, upstream
ok- a real share of the query terms is present in the hits.weak- terms exist but are thin in the hits, or the workspace scan hit its budget (absence is then never claimed).none- the scan completed and none of the terms occur anywhere in the workspace.
Two deliberate deviations from the same idea elsewhere:
- CJK queries are supported. A whitespace tokenizer turns a whole Chinese sentence into one
giant token; terms here are character bigrams, so
重试退避is checkable. - Nothing is ever suppressed.
zgmatches across languages - a Chinese query legitimately hits English text (measured: 生物炭改良土壤 → Biochar alleviates apple replant disease…). A lexical-absence rule that deleted hits would throw away exactly those cases, so the worst outcome here is a warning line above the results. Set"verify": falsefor rawzgoutput.
Cost: bounded in-process scan (5000 files / 12MB total / 2MB per file), cached 60s per cwd+terms. Retrieval itself stays local.
When the model keeps grepping and finding nothing
In an unfamiliar repository the agent guesses identifiers and greps: three, four, five empty rounds,
each one costing a turn. After three consecutive search-shaped commands that returned nothing, the
plugin nudges once - concept questions do not need the identifier first; try zg with
action=search - and then stays quiet: at most twice per session, at least five minutes apart,
never twice in a row, and only when the index is actually ready (advertising a tool that cannot
run is worse than silence). A hit, or any use of zg, resets the streak.
ls | grep foo deliberately does not count: an empty result there means "no such entry", not "the
concept is elsewhere". Bounded state machine, no suppression of anything (coach.ts, 10 tests).
What it costs (measured on a 3.6GB, GPU-less box)
Exact search and semantic search have completely different prices, and only one of them needs a model:
| what you do | embedding model | daemon RSS |
|---|---|---|
| daemon running, nothing asked | none | 71 MB |
zg with action=rg (exact, no index) |
no model is loaded | 71 MB |
action=search with local/potion-code-16m-v2 (code) |
loaded | ~400 MB |
action=search with local/multilingual-e5-small (Chinese) |
loaded | ~600 MB |
| 15 minutes after the last semantic query | released automatically | back to idle |
So: semantic search is the only thing that costs RAM, and the model is the whole cost. The
daemon drops it after its idle TTL (15 minutes, hardcoded - no env or flag changes it; 42 releases
in this machine's log), and systemctl restart zvec-grep-mcp reclaims it immediately. Note that
switching projects can briefly hold two models; the pool only trims on the next acquire.
Disk follows the file count, not the byte count: 248 indexed files here cost 21MB (~85KB/file), so a 4.9GB tree of 97 repositories would be hundreds of megabytes and a long build. Index the project you are working in, not the container directory - the plugin does this by default (see guarantee 10).
Memory, and the incident this plugin is built around
A previous run of this stack was OOM-killed at 2GB on a 3.6GB machine. The chain: a
transformers-js model had no quantized weights (fp32 fallback), the device resolved to the CUDA
execution provider, the CUDA library was absent, so every ONNX session creation failed - and
transformers.js memoises that rejected promise in a module-level variable, which kills the CPU
fallback too. The failure was then retried per file: measured at ~13MB of native (off-heap)
memory per attempt, ~313 attempts, and the V8 heap stayed flat the whole time, so nothing looked
wrong until the kernel intervened.
Three protections now stand between that chain and your machine:
- The storm is aborted. The build stream is watched live; three occurrences of the poison
signature (
dtype not specified, CUDA provider load failure,TRANSFORMERS_JS_EMBED_FAILED) and the child getsSIGKILLed - measured in the test suite as a sub-second stop instead of running to completion. Disable with"stormGuard": false. - The device is pinned durably.
--device cpuis passed for local models, and the model is pinned in zg's own config (zg config model set <ref> --device cpu) before the first build. That second step exists because of a measurement: the daemon ignores the per-command--device- sendingcpu,cudaandautofrom the client produced no CUDA activity in the daemon at all; the daemon uses its own resolution, andconfig.jsonis the knob both the CLI and the daemon read. Disable with"pinDevice": false. - fp32-only weights are called out before they bite. If the cached weights for the chosen
model have no quantized file, the build summary says so and points at
local/multilingual-e5-small(q8) orlocal/potion-code-16m-v2(static).
Concurrency, measured (20 files of 40KB, isolated HOME, no daemon, direct mode - this box):
| model (backend) | concurrency 1 | 2 | 4 | 8 | wall time 1 → 8 |
|---|---|---|---|---|---|
local/potion-code-16m-v2 (model2vec, static) |
476MB | 470MB | 525MB | 537MB | 19.4s → 18.6s |
local/multilingual-e5-small (transformers-js, 512 tokens) |
838MB | 905MB | 948MB | 1063MB | 86.2s → 78.5s |
Two things fall out of that table, and both contradict the obvious guess: the model itself is the
cost (476 / 838MB) while concurrency is a second-order correction (~+32MB per extra slot for a
512-token model), and concurrency buys almost nothing - 8 slots are ~9% faster than 1 for
+225MB. So the plugin picks from {1,2,4} (never 8) by predicting the peak
(base + slots x per-slot, both from the measured constants and the model's token limit) and
keeping it under 60% of free RAM, then says nothing when it had to settle for 1 slot unless even
that does not fit - in which case it tells you the margin is thin and suggests the static model.
Override with concurrency when you know better.
Workspaces upstream has opted out of
zg keeps its own per-workspace decision in <workspace>/.zvec-grep/manifest.json
(indexPolicy: enabled | disabled | undecided). With disabled, its read path refuses indexed
search and returns agent_action: do_not_build_index. The plugin follows that instead of arguing
with it: the footer says index off (zg policy) · rg works, no index offer appears at session
start, indexMode: auto does not build, and the coach does not suggest semantic search there.
search reports it as its own state - use action=rg; building would flip the policy back to
enabled, so do that only if the user asks. The plugin also reads upstream's own Next line
(zg query --rg in that state) rather than assuming every unbuilt workspace wants an index.
.zvec-grep/manifest.json is upstream's file; the plugin only reads the policy out of the rendered
zg status, and writes to that manifest only by running zg index, which is also the documented
way to re-enable indexing.
Worktrees and submodules
Measured with real git worktree and real submodules: the index is written to the current
working tree root (<worktree>/.zvec-grep), git rev-parse --show-toplevel inside a worktree
returns the worktree root, so the .gitignore rule lands in the right file and stays idempotent
(a second run reports "already ignored"); the main checkout is untouched. One thing to know rather
than a bug: indexing inside a submodule writes a .gitignore inside the submodule, so the
parent repository will show the submodule as dirty ( M sub) - that is submodule semantics.
An index outlives its model
An index stores the embedding schema it was built with, so a query needs that model to be
resolvable. Clear the cache, move to another machine, or copy an index around, and the query fails
with a download error - zg has no way to phrase that as "your index needs its model back". Before
the first search of a session (and only when an index directory actually exists, so it costs one
zg status at most) the plugin checks whether the recorded model is in the local cache and says so
up front:
note: this index was built with local/potion-code-16m-v2, which is not in the local model cache
(/root/.zvec-grep/models). The query will try to download it; if that fails, either restore the
cache (node <package-dir>/scripts/warmup.js) or rebuild the index with a cached model. An index
stores its embedding schema - you cannot just switch models without rebuilding.
Matching a model to its cache directory is done by listing the cache
(Xenova/, model2vec/, sentence-transformers/) and matching on the reference's last segment -
never by guessing a path. Guessing is wrong in practice: local/potion-code-16m-v2 lives in
model2vec/minishlab--potion-code-16M-v2 (different org prefix, different case), so a
built-from-the-name path check reports "not cached" forever.
When things fail slowly
The retry storm is loud; the slow failures are the expensive ones. Two guards:
- Nothing indexed at all.
zgreportsINDEXING.FILES_FAILED ... with N failed filesand - measured - does not print the usualN scanned, N addedsummary on that path, so a parser that only knows the happy shape sees nothing (this plugin's first attempt did exactly that). Every-file failures now produce a different message: do not simply retry - check the model cache, free space and permissions. A handful of failures stays a retry hint, since binaries and oversized files failing is normal. - Disk. Before a build the plugin estimates the index size (1.5x the previous index, else 0.8x the source bytes, else 100KB per known file - all measured: 248 files here cost 21MB, 40KB x 20 files cost 6MB) and warns when free space is under 200MB or under the estimate. Running out of space mid-build is the slowest possible failure.
Honest limitation: a build's stdout is almost empty (measured: Scanning files...,
Preparing <model>, Indexing complete), so a storm that lives inside the daemon is only visible
in the final summary, not while it is happening. That is why the prevention (device pin, fp32
check, storm signature abort when the child does print it) carries the weight, and why
/zg:status and the memory line exist.
Visibility, so this is not guesswork next time: /zg:status prints the daemon's own RSS
(daemon 631MB - model loaded; the model is released ~15min after the last semantic query, or immediately if you restart the daemon), and when less than 1.5GB of RAM is free an index build
lowers --embedding-concurrency to 1 and says why (concurrent embedding is the peak-memory
amplifier). Upstream facts worth knowing: zg server on --listen X reuses an already-running
daemon (the port is ignored), zg config model set always writes ~/.zvec-grep/config.json
(independent of ZVEC_GREP_HOME), and only one model is trimmed when a second is acquired - so two
projects with different models can briefly hold both.
Turning semantic search off entirely
{ "semantic": false }
in .pi/zvec-grep.json (or ZG_SEMANTIC=0). Then the plugin never looks for an index, never
prompts, leaves the footer empty (a setting you chose is not a status worth repeating every
session - /zg:status reports it),
and action=search / action=index refuse immediately with a pointer to action=rg - no zg
subprocess is started, so no model can be loaded. Exact search, which is the part that needs no
model anyway, keeps working. Use --rg semantics as the default and turn semantic on per project
only where it earns its ~600MB.
Why the engine is not a dependency
Bundling @zvec/zvec-grep looks convenient but measured 1.3GB / 192 packages per install, of
which node-llama-cpp is 703MB and is never loaded by local CPU search; onnxruntime-node
adds 208MB and onnxruntime-web 92MB. And --omit=optional (440MB) breaks the install:
the @zvec/zvec native bindings are an optional dependency, so the CLI dies with
Failed to load prebuilt binary for linux-x64.
So zg is resolved at runtime instead, in this order:
ZG_BIN, or"zgBin"in.pi/zvec-grep.json- if it points at nothing, that is an error and no fallback happens (a silently wrong binary is worse than an honest one);node_modules/@zvec/zvec-grepnext to this package or in your project (works if you add it yourself);zgonPATH.
When nothing is found, /zg:status prints:
ONNXRUNTIME_NODE_INSTALL_CUDA=skip npm install -g @zvec/zvec-grep
That env var keeps the CUDA probe out of onnxruntime-node's postinstall, which is the usual
reason that install dies; --ignore-scripts is not enough on its own (@vscode/ripgrep
fetches its binary in a postinstall, and zg query --rg then fails at runtime).
First search on a machine that cannot reach huggingface.co
zg downloads potion-code-16m-v2 from huggingface.co on the first index. Where that is
unreachable the build fails per file and leaves a 0% index. This package's postinstall warms
that model from a ModelScope mirror (~32MB, 2.5s measured), validates the byte counts (mirrors
return a 15-byte placeholder body rather than 404 for missing files), and never fails the
install:
node <install-dir>/scripts/warmup.js # potion-code-16m-v2, code search (~32MB)
node <install-dir>/scripts/warmup.js e5-small # multilingual-e5-small q8, Chinese (~118MB)
node <install-dir>/scripts/warmup.js all --verify # check the cache, download nothing (exit 1 if incomplete)
bash <install-dir>/fetch-model.sh e5-small # same thing as a shell script, if you prefer
PI_ZVEC_GREP_SKIP_WARMUP=1 npm install ... # skip the warm-up (offline/CI images)
If the host blocks lifecycle scripts the postinstall never runs; /zg:index then asks to do the
same thing at the moment it matters (see guarantee 9).
Picking an embedding model
| model | good at | measured | cost |
|---|---|---|---|
local/potion-code-16m-v2 |
code | near-random ranking for Chinese queries (real matrix test: correct segment ranked #3, #2 was unrelated) | 32MB, no GPU, ~1s/round |
local/multilingual-e5-small |
Chinese + multilingual prose | 4/4 top-1 on Chinese queries; reload leaks ~80-100MB off-heap per session, so ~500MB resident | 118MB q8, CPU |
qwen/text-embedding-v4 |
best of the three; Chinese query hits English documents | 3/3 | remote: quota + the query text leaves the machine |
e5-small must be pinned to cpu in zg's own config, because the flag alone does not
persist and daemon/MCP paths never see it:
zg config model set local/multilingual-e5-small --device cpu
Configuration
.pi/zvec-grep.json at the project root (shareable with your team); environment variables win
over the file, the file wins over built-in defaults. Invalid values are dropped one by one with a
note that /zg:status shows - one typo does not take the plugin down.
| key | default | meaning |
|---|---|---|
indexMode |
ask |
ask prompts once at session start, manual only reports, auto builds in the background |
disabled |
false |
full opt-out for this project, including the prompt |
mode |
auto |
index transport: prefer a live daemon, else direct |
refresh |
wait |
wait answers from a current index; background/off are faster and can be stale |
preview |
short |
cite the matched range so the model does not have to re-read the file |
device |
cpu (macOS: auto) | local embedding device |
embedding |
zg default |
model used for new indexes; existing ones keep theirs |
verify |
true |
the [zg-support …] line |
pinDevice |
true |
pin the chosen model to cpu in zg's own config before building |
stormGuard |
true |
abort a build whose embedding backend fails the same way repeatedly |
semantic |
true |
false = exact search only: no index, no prompt, no footer line, no model ever loaded |
footer |
true |
false = never write the footer status at all |
gitignore |
true |
let the plugin keep .zvec-grep/ out of git |
timeoutQueryMs / timeoutIndexMs |
120s / 1h | |
zgBin |
unset | explicit CLI path |
/zg:settings validates before writing: every value that ends up in argv (device, mode,
preview, refresh, indexMode, the on/off flags) is checked against its allowed set, and a typo is
rejected with the list of accepted values instead of being written to the config file.
Environment: ZG_BIN, ZG_INDEX_MODE, ZG_SEMANTIC (0/off/false disables semantic
search), ZVEC_GREP_DEVICE, ZVEC_GREP_EMBEDDING, ZVEC_GREP_MODEL_CACHE,
PI_ZVEC_GREP_SKIP_WARMUP.
Windows
zg.cmd is launched through cmd.exe (/d /s /c), the PATH lookup uses ;, and the model
warm-up is a Node script rather than a shell script, so no bash is required. The honest caveat:
this was implemented and unit-tested on Linux (the resolver, the shim wrapping and the PATH
splitting are covered by tests) but has not been run on a real Windows machine. zg itself
supports Windows; pi-zgrep documents Windows as unsupported.
Privacy and safety
- Retrieval is local: local model, CPU device, no telemetry. Nothing is sent anywhere unless you ask for a remote embedding model.
- Remote embeddings need
allowRemote(per call) orzg auth grant(persistent); the plugin surfaces the difference between "not authorized" and "bad key", whichzgreports identically. - The support scan reads file contents in-process, bounded, for the query terms only. Same bytes the index already holds; nothing leaves the machine.
- The only file written inside your repository (besides the index) is one
.gitignoreline. - Managed
--rggoes throughzg's flag whitelist; no shell interpolation, no output-format rewrites.
Out of scope
Not a memory/recall system, not multi-workspace search, does not override pi's built-in grep
or find, no MCP-client registration, no custom TUI rendering. Related capabilities belong in
their own packages.
Development
npm run verify # typecheck + the offline suite (what CI would run)
npm run test:live # + builds real scratch indexes and feeds our argv to the real CLI
npm run test:unit # everything, no network access
The live tier skips (not fails) without zg, because an unready machine is the exact case this
extension has to handle. tests/fixtures.ts holds captured zg 0.2.1 output as the parser's
compat tripwire; regenerate it when upstream changes shape and update buildArgs in the same
commit.
中文说明
安装:pi install npm:pi-zvec-grep-ext(国内网络可走 pi install git:gitcode.com/Agricultural/pi-zvec-grep-ext)。
装完 /zg:status 看六层就绪:node、CLI、模型缓存、索引覆盖率、.gitignore、生效配置(每个值都标来源)。
三条与墙内环境直接相关的实测结论,都是这个包存在的主要理由:
- 模型下载:
zg默认从 huggingface.co 拉模型,直连不可达时逐文件失败并留下 0% 的半成品索引。 本包 postinstall 从魔搭镜像预热 potion(31MB,实测 2.5 秒),并按字节校验(镜像对缺失文件返回 15 字节占位正文而不是 404),失败不阻断安装。 - CPU 后端:
zg的auto在 linux x64 上解析成 CUDA EP。无 GPU 时 dlopen 失败会把同进程后续 所有 ONNX session 一起毒死(transformers.js 缓存了 rejected promise),表现为按文件无限重试、 每次在堆外漏约 13MB,最终被内核 OOM 杀掉。本包的 index 一律带--device cpu(macOS 保留 auto, metal 能用),并为子进程注入ZVEC_GREP_DEVICE=cpu。 - 模型选型看语言,不看维度:代码用
local/potion-code-16m-v2;中文/多语文档用local/multilingual-e5-small(q8,实测中文 4/4,需--device cpu),要更高质量且接受外发就用qwen/text-embedding-v4(实测 3/3,且支持中文问命中英文文献)。potion 处理中文排序近乎随机, 别拿它当通用模型。
配套技能包(zg CLI 的完整配方、索引治理与案例):
pi install git:gitcode.com/Agricultural/zvec-grep。两者不重复:插件管 pi 里的工具与生命周期命令,
技能包管 CLI 的用法细节。
License
Apache-2.0. zvec-grep itself is upstream's (@zvec/zvec-grep); this package only shells out to it.