dsh-zvec-grep
Verified@jayyuen66/dsh-zvec-grep · v0.1.2 · MIT · Web UI
dsh 工作区语义检索插件(zg):zg_search / zg_index / zg_status + 本地 embedding 设置卡片 + systemPrompt 路由规则 + guard 安全兜底。
Install
dsh plugin add @jayyuen66/dsh-zvec-grep Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Readme
@jayyuen66/dsh-zvec-grep
What it does
- Adds semantic workspace search to dsh sessions: registers three model tools, zg_search / zg_index / zg_status, which drive the zg CLI (BM25 + vector + RRF hybrid) through the host shell with
--mode direct. - Injects one systemPrompt rule section (
zvec-grep-routing, order 1550) plus two tools.guard layers: safety backstop for zg_* and the search-first gate (in an indexed workspace, grep/rg requires a prior successful zg_search). - Ships a web settings card: default embedding, limit, search-first quota/window, and a "rebuild workspace" button.
Installation
dsh plugin --profile web add @jayyuen66/dsh-zvec-grep
- The packages are on the public npm registry, so no credentials are needed; the published artifact only carries host.js, client.js and cordis.patch.yml (the files field in package.json).
- Runtime value imports are
@jayyuen66/dsh-plugin-shared(lib/http, lib/locale, lib/text) and@deepseek-ai/schemastery(the host's fork — the only one whose resolve wraps.volatile()fields intoVolatilereferences).- shared's own
lib/textvalue-imports@deepseek-ai/dsh-output-retention, so that host package has to be supplied too, on the same terms asschemastery: a requiredpeerDependencyplus the same exact pin indevDependencies, never independencies. Only a gate can hold this line - with the entry missing, typecheck, build and tests are all green and the failure shows up after installation as ERR_MODULE_NOT_FOUND. - Missing either one is
ERR_MODULE_NOT_FOUNDat load, not a graceful downgrade — that is also why the fork cannot be swapped for publicschemastery. - Only the shared package sits in
dependencies.@deepseek-ai/schemasteryis a host package, so it is declared as a requiredpeerDependencyand repeated at the same exact pin indevDependenciesfor this repository's own typecheck/build/tests — never independencies. That is not a style preference: consumer profiles install withnodeLinker: hoistedandautoInstallPeers: false, so a host package listed underdependenciesis written into the profile as a real directory, coexisting with the copy the host process already loaded.@deepseek-ai/dsh-toolskeys itsTOOL_RUNTIME_SCHEDULERwith a plainSymbol(notSymbol.for), so symbol identity is per module instance: the host'sdsh-agent-loopgetsundefinedand every native tool call throwsCannot read properties of undefined (reading 'prepare')— the whole profile loses its tools. Build, typecheck and tests all stay green right up to that point, which is why the invariant is pinned intest/publish-manifest.test.ts.
- shared's own
Enabling it in dsh
- add/remove maintain the registration line
- id: zvec-grep(name points at this package) from the in-package cordis.patch.yml, whichdsh.bundle.patchinpackage.jsonpoints at. - Host compatibility is judged on the peer declarations, not on
engines.dsh: before composition the host compares@deepseek-ai/dshand every@deepseek-ai/dsh-*range against the current runtime version, prereleases included (the rule is owned by the dsh-app-boot README). A mismatch denies that entry; a bundle without an exemption is skipped whole and listed underskippedBundles.- This package pins every
dsh-*exactly (a requirement oftest/publish-manifest.test.ts, because "a second copy of the host type surface" is worse than a version conflict), so a host newer than0.2.1-alpha.2also needs approval. The escape hatch is the one the host prints:dsh plugin --profile web allow-version @jayyuen66/dsh-zvec-grep@<version> --dsh-version <exact version> --accept-risk. engines.dshis a declaration that nothing reads; keeping it at>= 0.2.1-alpha.2only avoids leaving two contradictory numbers behind.
- This package pins every
dsh.client.platformis web with immediately set, so the card's client half is delivered by the host; bundle installation goes through pnpm (>= 12.3.0).- apply needs the settings/shell/tools/systemPrompt/timer surfaces: all five sit in
inject, so a missing one keeps cordis from activating the entry (the startup diagnostic printspending (waiting for service: …)).- The guard inside apply is the second layer: a malformed ctx throws
required services … missing. - The three
/_dsh/zvec-grep/*routes hang off aninject(["webServer"])child fiber: with no webServer (a TUI host) the child never activates, so those routes simply do not exist while the tools keep working. - On the real host webServer arrives about a second after this entry, which is why it must be a dependency and not a one-off
ctx.get.
- The guard inside apply is the second layer: a malformed ctx throws
Tools exposed to the model
- zg_search: at least one of query / queries / fts / vector (32 routes max, 4000 chars per query).
- Plus globs / insensitiveGlobs / fileTypes / excludedFileTypes / symbolTypes / preferSymbol / modifiedAfter / modifiedBefore / fuse / limit / preview / refresh / device.
globs/insensitiveGlobs/fileTypes/excludedFileTypesaccept a single string as well as an array of strings, matching zg's own contract for those fields (128 items max, 1024 chars per item).symbolTypesis bound to zg's real closed set and accepts lowercase only:module/class/interface/function/value/alias. zg itself exits non-zero on--symbol-type Class(Unsupported symbol type: Class); the plugin refuses before assembling the command and lists the allowed values. It does not fold case — silently lowercasing would rewrite a constraint the model did not write.previewhas three levels:noneanchor line only /shortanchor plus a bounded context window /fullthe whole chunk. Default short, so every hit carries a bounded source snippet.refreshhas three levels:waitrefresh to the latest before searching (default) /offdo not refresh /backgroundhand it to the daemon.backgroundonly holds in daemon mode: in direct mode zg does not fail, it degrades tooffand writeswarning: --refresh background requires Server mode; Direct mode uses --refresh offto stderr — this package echoes stderr verbatim, so that line reaches the model on its own.- A hit summary is appended at the end of the output with two separately reported counts:
grouped(the sum of each query group'shits:) andN distinct location(s) after dedup(keyed by relative path + line range). They are split because one location is hit by several groups at once (rank andmatchedBydiffer per group), so a plain sum counts the same location repeatedly and passes itself off as the hit total. - When some group's hit count equals
--limit, that is reported as an observation only, with an explicit note that zg provides no truncation signal and the summary does not assert one. When the host truncates stdout at its cap, a "covers only the kept part" note is added.
- zg_index: confirm is required and must be true (rejected twice, by the required check and by the guard).
background=trueruns a model-side background index: one call spawns the zg process, registers it in the host job registry, attaches the timeout reaper, hands back a pollable job id and returns immediately. Building an index may download embedding weights and take a long time, so background execution keeps the tool call from hanging.- It does not depend on a host job tool: that tool is disabled in the web plane, so the model-side route is simply unavailable. The plugin reuses the same spawn/register/claim steps as the settings card (one shared function, not a copy), so jobs started from either side are identical in the registry, the status projection and the polling endpoint — the card sees model-started indexing and vice versa.
- The claim is mutually exclusive with the card's rebuild, across both entry points: while an index write is running for a root, the model side and the card side both follow that one instead of each starting a second zg against the same index.
- When the host job registry is absent it does not spawn at all and fails immediately: a zg with no ring, no capacity and nobody to reap it is worse than not starting — it would hold the root's write lock forever and block every later index operation on that tree.
- Until it settles, semantic search on that root waits for it and retries automatically, and grep/rg are not subject to the gate during that window.
- Also embedding / rebuild / drop / resetPaths / globs / insensitiveGlobs / fileTypes / excludedFileTypes / ignoreFiles / excludeSecrets / hidden / noIgnore / maxDepth / maxFileSizeBytes / follow / embeddingConcurrency / device; its five path filters likewise take a single value or an array.
- Switching embedding only takes effect with rebuild=true.
- An explicit embedding goes through an assembly-time allowlist: only models in the local catalog (the same list the settings card's dropdown renders) are accepted; anything else is refused before the command is assembled, with actionable wording. The reason is the nature of the capability — remote embedding sends workspace content to an external endpoint, and the model can invent an arbitrary prefixed reference. Whether workspace content may leave the machine is a gate only the user opens in the deployment config (see the deployment values below).
resetPaths=truepasses through--reset-paths, clearing the file-selection settings this root inherited (globs/fileTypes/hidden/…). It is not mutually exclusive with globs/fileTypes (upstream clears first, then applies this call's rules on top); it is mutually exclusive withdrop, which is refused at assembly time rather than silently dropped.
- zg_status: root + checkReady, for diagnosing a missing/failed/cancelled index or watching progress explicitly.
checkReady=truepasses through--check-ready. Its real contract: the readiness report still goes to stdout, and only the exit code expresses readiness. So not-ready is not an execution failure — the plugin returns the report verbatim and appends one line saying the index is not ready and that this is the conclusioncheckReady=trueasked for, together with zg's own wording. The ordinary failure path would keep only the stderr line and throw away exactly the report the user needed.- A non-zero exit with an empty report still throws; without
checkReadya non-zero exit throws as before. - Integrated with the background rebuild state: while a rebuild of the same root is running (job registered) the tool returns the rebuild projection and starts no subprocess — the answer to "is the index ready" is negative by definition at that moment, so reporting
not readyis worse than saying "rebuilding, job X, ask again once it settles".
- First-choice path when there is no index:
zg_searchtakes anrgswitch that routes through zg's exhaustive lexical channel (zg query --rg), which needs no index.rgunset (default): when the workspace and every directory above it has no index, the search falls back to exhaustive matching; when an index exists, the indexed search runs as usual. An automatic fallback prepends a note saying the results are literal line matches rather than semantic relevance ranking, and thatzg_indexis needed for semantic search — without that note a mismatched answer is handed over as if it were a semantic conclusion.rg=trueforces exhaustive search;rg=falserefuses the fallback and uses indexed search only (with no index, zg reportsWORKSPACE_INDEX_NOT_FOUNDand points at building one).- Exhaustive search grants no grep/rg quota: it reads no index, so it is not evidence that semantic search has been used. In an indexed workspace, still run one indexed search before reaching for grep/rg.
- The legal flag set differs from the indexed channel. zg rejects
preview/refresh/symbol-type/prefer-symbol/fuse/fts/vector(observed:--preview is not supported with --rg; use -A/-B/-C for rg context); the plugin refuses at command-assembly time and lists the conflicts rather than dropping them silently.limit/glob/iglob/type/type-not/modified-after/modified-before/device/modeall still work. - Exhaustive output has no
hits:count lines, so the hit summary is automatically omitted under the "if it is not counted, do not claim it" rule. tracepasses through--trace(troubleshooting): every hit carries a search trace saying which routes recalled it, its rank in each group, and its score. Output gets noticeably longer; exhaustive mode rejects the parameter at assembly time.- With
traceon, the hit header gains ascore=field and a selection marker; the hit summary's dedup key strips both. Observed: the same location can score differently across groups (src/beta.ts:1-2is 0.0164 in the fts group and 0.0161 in the vector group), so putting the score in the key makes every hit a "new location", dedup stops working, and the location count is overstated twofold.
- root may be omitted in all three (defaults to the current session workspace); output is plain text; timeouts are 5 minutes / 10 minutes / 60 seconds, stdout capped at 400000 bytes (truncated to the tail with a note).
Settings and search roots
- Namespace
zvec-grep, fourteen fields with built-in defaults, six of them volatile and on the card.- defaultEmbedding=local/qwen3-embedding-0.6b, defaultLimit=10 (1-50), hfEndpoint=a China-reachable HF-compatible mirror base (set another mirror or leave empty for the official HF).
- enforceSearchFirst=true, grepBudgetPerSearch=3 (1-20), unlockWindowMin=10 (1-240).
- Additional non-volatile deployment values are not on the settings card: change them only via the line config in cordis.yml. The "5 minutes / 10 minutes / 60 seconds / 400000 bytes" in the tools section are their defaults.
- searchTimeoutMs=300000, statusTimeoutMs=60000, indexTimeoutMs=600000 (also the background rebuild kill-timer duration), stdoutMaxBytes=400000 (each with a minimum of 1), clientMode=auto, rebuildWaitMs=120000.
- Remote embedding, three values, all off by default:
allowRemoteEmbedding=false,remoteEmbeddingEndpoint="",remoteEmbeddingApiKeyFrom="".- While it is off, an explicit embedding reference must be in the local catalog at command-assembly time.
- Once open, the endpoint and the credential reach the zg child process only through environment variables (
ZVEC_GREP_ENDPOINT/ZVEC_GREP_API_KEY) and never through argv — this plugin reuses the whole command string as a background-job label that is echoed on the card, so a key in argv would land in the UI, in logs, and in the process table. - The credential value holds the name of the environment variable in the host process that holds the credential (e.g.
ZVEC_GREP_API_KEY), not the credential itself: the host's subprocess layer scrubs inherited names matching KEY/SECRET/TOKEN, so only an explicit forward from this plugin reaches zg — and recording just the name also keeps the secret out of the plugin's own config file. - This plugin does not pass
--allow-remotefor you: zg still requires the user to runzg auth grant <root> --capability embedding --scope workspaceonce, and that per-root authorization is not something a plugin should do on the user's behalf.
requireApprovalForExplicitRoot=false: when on, an explicitrooton any of the three zg tools that is neither the current session workspace nor already registered in this session triggers one user confirmation through the officialtools/pre-execute → {kind:'ask'} → ctx.approvalseam. It defaults to off, and three boundaries are known in advance: thedanger-full-accesspreset setsapproval/policytonever(the ask is deterministically rejected), delegated subagents are pinned toapprovalPolicy: 'never'(the switch has no effect inside a subagent), and with no approval service installed the ask fails closed to a denial. This confirmation only ever adds a step:rootOf's authorization predicate is unchanged, so an out-of-scope root is still refused even after the user approves.
- Precedence: runtime settings values > the line config of the bundle/user layer > built-in base; explicitly absent fields in the line config do not override the base.
- Volatile fields are read via
config.<field>.get(); the non-volatile deployment values are plain values and skip.get().
- Volatile fields are read via
- Root validation: must be an absolute path starting with
/, lexically normalised (empty and.segments folded).- Rejects empty strings, non-strings, NUL bytes, relative paths and
..segments escaping the filesystem root, and/itself after normalisation. - A missing directory is reported as "root does not exist" instead of a bare spawn ENOENT.
- Rejects empty strings, non-strings, NUL bytes, relative paths and
- The guard and execute share exactly this root predicate, so nothing is refused twice with different wording.
- Unlocks are counted per "session x index root": each successful zg_search resets to grepBudgetPerSearch calls valid for unlockWindowMin minutes; the index root is the root itself or its nearest indexed ancestor (up to 8 levels up).
- The session used for partitioning is the root session: the chain is walked up
header.parentSessionto the topmost hop. So the main agent and its subagents under one delegation tree share one quota — once the main agent has searched, its subagents can immediately spend those grep/rg calls instead of each searching again (they are usually searching the same tree). - Degradation along the chain is three-level and never throws: a top-level session uses itself; when a hop's parent cannot be found (the session has settled, or the host has no session store) the walk stops at the last known hop; a cycle stops before the first repeat. The hop count is bounded.
- The session used for partitioning is the root session: the chain is walked up
- "Indexed" means
<root>/.zvec-grep/manifest.jsonis on disk; the directory name alone is not enough: the same.zvec-grepname is also zg's own global home (ZVEC_GREP_HOME ?? ~/.zvec-grep, holding config.json / locks / models), so a plain workspace that never built an index but merely collides on an ancestor's name would get gated too. - enforceSearchFirst=false disables only the gate; the zg_* backstop (confirm, root rules) is unaffected.
External dependency (rg)
- The real binary dependency is zg (installed via
npm install -g @zvec/zvec-grep): when it is not on PATH the shell reports exit 127 and the tool answers "zg is not installed or not on PATH", kept separate from "the sandbox runner never started". - Upstream is
@zvec/zvec-grep(it provides thezgbin), repository https://github.com/zvec-ai/zvec-grep, licensed Apache-2.0. This package is a dsh adaptation layer on top of it: it shells out to the CLI, and contains no upstream source, nor does it redistribute it on anyone's behalf.- Version floor
0.2.2(MINIMUM_ZG_VERSIONin lib/zg-version.ts). Below that the plugin stops before dispatching a command and names the actual version, because the command shapes and flag names are written against that release - an older zg can parse the same argv as something else entirely and return results that look fine but searched nothing.
- Version floor
- Local embeddings are loaded by zg's backends: qwen3/embeddinggemma need llama-cpp (device=auto tries Metal and falls back to CPU), potion-* use model2vec, the rest use transformers-js in plain JS.
- Weights are downloaded with HF_ENDPOINT injected from hfEndpoint.
- This package neither depends on nor calls ripgrep: rg appears only in the gate predicate.
- After quote/comment-aware tokenising of a bash/pwsh command string, grep/egrep/fgrep/rg at a command position and the native grep tool count as searching.
--help/--version/-V,echo "grep x"andgit commit -m "use rg"do not.
- With a sandboxed executor, runner failure, policy denial and a genuine zg failure are told apart, and incomplete results are flagged explicitly.
Public interface
- Every handler opens with
guardTrust(req, res, { servingNonLoopback })(shared/lib/trust); failing any leg yields403with{ ok:false, error: ... }.- Order of legs: Host authority -> the
sec-fetch-siteallowlist -> a verbatimoriginvs Host compare. The two rejection sentences are"untrusted host authority"and"cross-origin request rejected". servingNonLoopbackis computed byisNonLoopbackBindHost(webServer.host)from the bound address itself - the host only rejects wildcard listeners, while binding a concrete IPv4/IPv6 of a local interface is a legitimate configuration, and a literal comparison would read that surface as loopback. A wrong method yields405carryingAllowplus{ ok:false, error:"GET only" | "POST only" }- no longer an empty reply.
- Order of legs: Host authority -> the
GET /_dsh/zvec-grep/rebuild-roots: returns only this plugin's own data (a CSRF token regenerated on every apply, plus the observed workspace list); GET/HEAD allowed, 403 when the gate above rejects it.POST /_dsh/zvec-grep/rebuild?root=...: gate + headerx-zvec-grep-csrf+ body <= 4096 bytes + root in the whitelist + directory exists; returns{ ok, jobId }, otherwise 405/403/400/500./_dsh/zvec-grep/rebuild-status?jobId=...: polled behind the gate (the card polls every 2 seconds); returns status/exitCode/root/output (plus sandbox when present); an unknown jobId gets a 404 explaining it left the 10-record settled window.- Also a systemPrompt section
zvec-grep-routing(order 1550) and the settings namespacezvec-grep.- How many rebuilds run at once is decided by the host registry (measured default: the unowned bucket holds 10); this package no longer carries its own concurrency cap.
When it is full,
POST /rebuildanswers 500 with the official message that names the limit - it no longer kills a rebuild that is already running. rebuild-statusonly resolves jobIds present in this package's side table: the last 10 settled records are kept (older ones are removed from the registry => 404); running records are never displaced.
- How many rebuilds run at once is decided by the host registry (measured default: the unowned bucket holds 10); this package no longer carries its own concurrency cap.
When it is full,
The background job surface (official ctx.jobs)
This package no longer ships a job registry: a "rebuild workspace" run is one unowned job in the host's ctx.jobs (kind: "zvec-grep-rebuild", so the id reads zvec-grep-rebuild-N). Three visible consequences:
| Aspect | Fact |
|---|---|
| The model can see it | The official job_list / job_output / job_kill tools now list the rebuild, with the exit code / signal name carried in the job's detail (same shape the host's own bash tool uses - measured in plugins/docs/harness/f3-equiv/probe-jobs.mjs). Output rides the official pull source (proc.observed[*].readFrom), so the ring advances on the registry's own cadence - even with the settings panel closed - and the observer never steals bytes from readOutput() (the d.ts says so verbatim). The controller is attached only for the instant of start and detached right after (see the cross-package rule) |
| Unowned means visible to every session | The registry isolates by owner, and an unowned job can be listed / read / killed by any caller. This is a known, accepted exposure of this wave - the card is a webServer producer outside any session, and owned jobs would need the dsh-agent registry (measured unavailable). Rolling this adoption back is the only way to close it. Note /_dsh/zvec-grep/rebuild-status still only answers for ids in this package's side table, so somebody else's job (the host's bash-1, say) cannot be read through it |
| Two more status words | stopping (cancellation issued, process not finished settling - the card shows it as still running) and failed (the registry's own verdict - it gets its own message instead of rendering as "exit code null") |
What is not retained, stated plainly: the output ring keeps UTF-8 bytes (measured host default 262144), while the card's 4000-character tail window and its shown/total count UTF-16 code units - two unit systems, each doing its own job. total therefore counts code units inside the retention window (the official byte total lives in view.output.total, different unit, never mixed), and for CJK output that window is about 1/3 smaller than what this package kept before the adoption - the price of using the official ring. The two loss marks say different things: "truncated" = the ring evicted its head (view.output.earliest > 0, simply not visible any more), "buffer overflow" = the executor's reader reported lossy or threw outright (bytes really are gone; a spill file may exist). The timeout notice and the sandbox facts are deliberately not written into the ring: the ring belongs to the registry's pump, and the registry drops every write after settlement (measured), so the endpoint synthesises both at projection time and they can never be lost by arriving late. Polling never throws through: when the jobs service is reloaded (or somebody else reclaimed the record) the status endpoint answers 404 rather than letting the official unknown job error escape - the host's catch for a throwing handler logs one line and, while no header has gone out yet, writes a bare 400 with no body and no content-type (it only destroys the socket once headers were sent: installed dsh-host-webserver lib/index.js:246-256), which would put the card into its terminal error phase, stop the polling and hide a rebuild that is still running.
Data and privacy
- The only on-disk shape of the index is the
.zvec-grep/directory under the searched root, written by zg (a read-only sandbox cannot write it, and the result says the index may be incomplete). - Two rebuild paths: the model-side
zg_index(rebuild=true, confirm=true), or the card's "rebuild workspace" button through the rebuild route above; deleting iszg_indexwith drop=true (assembled aszg index <root> --drop --yes). - The package itself writes no files (its only fs calls are the index probe: existsSync plus one manifest read) and puts nothing in the host data directory
$DSH_HOME(<dsh data dir>).- The probe does not merely check that
.zvec-grep/manifest.jsonexists: it parses the manifest and confirms itsrootPathsreally covers this directory. Two real situations are misread as "indexed" by an existence-only check while search would necessarily fail — the file is there but is not a manifest (a truncated write, or something else occupying the name), and the manifest is there but its root path points elsewhere. If the gate still blocked grep/rg on that basis, both search and the gate would be deadlocked. - Only two fields are checked:
manifestVersion(strictly equal to 1) androotPaths(a non-empty array whose entries each carry a non-emptyabsolutePath). A version mismatch makes upstream throwMANIFEST.INVALIDon read, meaning not a single search can succeed in that directory — a lenient check would only trap the user in a corner where the gate blocks grep/rg while every search is bound to fail. Embedding / policy fields are deliberately not checked — when those change the index still works, so calling it "no index" would be wrong. - Coverage is decided by normalised equality. On macOS the manifest records the unresolved path (
/tmp/x) while itspathfield is the resolved one (/private/tmp/x), so the/privateprefix is stripped from both sides before comparing; either spelling matches. - The whitelist and unlock table live in bounded memory, and their eviction matches their meaning: a hit touches the entry (moving it to the tail), and eviction first drops expired entries, then drops the coldest one by least-recently-used until back under the cap — not by insertion order. Plain insertion order is no eviction at all for a table where every successful search resets the same keys: the genuinely cold entries stay forever while a long-untouched key keeps its slot. Whitelist entries additionally carry a one-day validity, so a directory opened casually a month ago should not still hold a slot today. The capacity caps (64 whitelist roots / 256 session partitions) are unchanged, so resident memory stays bounded.
- The rebuild jobs themselves live in the host registry (section above) - on unload this package's side table is reclaimed with the effect and running jobs are killed through the registry.
- The probe does not merely check that
- Excluded from indexing by default:
*.pem/*.key/*.p12/*.pfx/*.keystore/id_rsa*/id_ed25519*(set excludeSecrets=false to allow). These rules match case-insensitively, soServer.PEMis excluded too, and they are applied after the caller's own path rules, so a broad positive rule of yours cannot re-include them.- Residual gap: extension rules cannot cover extensionless key files (e.g.
myprivatekey,rsa_private);id_rsa*/id_ed25519*only cover the OpenSSH default names. Declare such files through ignoreFiles (--ignore-file).
- Residual gap: extension rules cannot cover extensionless key files (e.g.
- Hidden files are skipped and ignore files honoured unless noIgnore is set.
- Nothing is uploaded: the package only assembles zg commands on the local machine and passes output through.
- The only outbound traffic is the embedding weight download driven by hfEndpoint.
- Echoes are bounded — stdout over the cap keeps the tail (with the spill path when present), and the last 400 characters of stderr are appended so a "degraded to pure lexical" notice is not mistaken for a full semantic result.
- stderr is classified before it is echoed: only recognised indexing progress is dropped (
Scanning files…/Indexing files…/Indexing complete/Preparing <model>/Downloading <model> · …/Model ready: <model>— written line by line when stderr is not a TTY, present on every index build), while degradation notices, lock conflicts, sandbox facts and any unrecognised new warning are all kept. When the whole stream is noise no note is added at all. The test is "drop only what is recognised", never "keep only what is recognised" — the latter would silently swallow anything new zg starts emitting, which is the worst possible failure mode. Indexing completematches on whole-line equality, not a prefix:Indexing completed with 3 failed files(the partial-failure signal) literally starts with it, so a prefix match would drop exactly the information that matters.
FAQ
- grep/rg refused with "no zg_search has run yet": that is the search-first gate; run zg_search. The same applies once the quota is spent or expired — re-search with a sharper query/fts to refresh it, no need to disable the plugin.
- Search times out: the default
--refresh waitrefreshes the existing index synchronously; narrow the range with globs/fileTypes or lower the limit, or fall back to a hand-written literal search. - The rebuild button answers 403 "root is not in the workspace whitelist this daemon observed": that directory never had a session in this daemon; start one there first, or let the model call zg_index with confirm=true.
- The card reports a failed rebuild although zg looks finished: success is "completed with exit 0" — exit 127 (zg not installed) also lands in completed, so read the tail of the output.
- A root that was never indexed: zg_search fails loudly instead of silently building the index; building happens only via zg_index on an explicit user request.
- Changed the embedding but semantic hits are missing: switching embedding only takes effect after a rebuild with rebuild=true.
Development
npm run checkis the single gate to run before publishing: typecheck (both tsconfigs) +oxlint+ rebuild both bundles +vitest run --coverage+oxfmt --check.- Coverage runs under a 100% global threshold (statements/branches/functions/lines): any line or branch that no test pins turns the suite red by design.
npm run build/npm run build:clientproducehost.js/client.js, and neither is committed -prepareandprepackrebuild them, whiletest/build-host.test.ts/test/build-client.test.tscompare the on-disk bundle against an in-memory build byte for byte, so a stale bundle fails the suite. Never hand-edit the bundles: the published entry is the built artifact, because Node refuses to strip types for a.tssitting insidenode_modules.- Beyond behaviour, a family of manifest gates guards the published surface (
test/publish-manifest.test.ts): every entry target covered byfiles, host packages kept out ofdependencies,dsh-*pinned exactly and at the peer version, no local paths or store layout inside the shipped artifacts, and every host package that shared's own shipped code value-imports supplied by this package.test/dist-size.test.tsbudgets bundle bytes;test/readme-parity.test.tskeeps the English and Chinese halves structurally in sync.
License
MIT - see LICENSE. The zg CLI this plugin drives is upstream @zvec/zvec-grep, licensed Apache-2.0. This package contains no upstream source, so no upstream license text is bundled with it; if you redistribute the two together, keep each project's notices separate and intact.