@danielmarbach/pi-tgrep0.5.0
tgrep-backed code search for pi: auto-indexes the repo and routes all grep traffic through the trigram index
Untested on omp
This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.
Reports 0
No reports yet.
Readme
pi-tgrep
tgrep for pi: auto-indexes your repo
with a trigram index and makes sure every search the LLM performs, whether through the grep
tool or through shell commands, runs on tgrep instead of scanning every file.
Why
grep and ripgrep re-scan the whole tree on every query (O(total bytes) each time). On large
repos that is the slowest thing an agent does all day. tgrep keeps a trigram index with a
persistent, file-watching server and answers the same queries orders of magnitude faster,
while staying ripgrep-compatible (same flags, same --json events, same ignore rules).
pi's grep tool is the code-search path of least resistance for a model. pi-tgrep turns that
path into the fast one and closes the side doors.
What it does
- Auto-index: on
session_startit finds the enclosing git repo and ensures atgrep servedaemon is running for it (spawned detached, builds the index if missing, and the file watcher keeps it fresh). The server is shared across sessions and with other tgrep clients..tgrep/is added to.git/info/excludesogit statusstays clean. - Owns the
greptool: registers a tool namedgrepwith the identical schema (pattern,path,glob,ignoreCase,literal,context,limit) and identical output contract. The model callsgreplike always, but the results come from the trigram index. Every result carriesdetails.enginefor provenance, plusdetails.fallback(why ripgrep served the query: index still building, no binary, or error) anddetails.truncatedwhen the match limit kicked in. When the repo index exists, searches under the repo root automatically pass--index-path <index dir>, because tgrep only looks for the index next to the searched path, so a subdirectory search would otherwise silently fall back to scanning every file. A search path that is itself git-ignored (bin/Debug,node_modules/pkg, a single file insideobj/) passes--no-ignore-parent, so it returns the same hits as ripgrep instead of "No matches found"; non-ignored paths are unaffected and still skip their ignored files. - Guards the shell: a
tool_callhook inspectsbashand MCP shell-executing tools (ctx_execute,ctx_execute_file,ctx_batch_execute, bare or namespaced likemcp__context-mode__ctx_execute, extend viaPI_TGREP_WATCH_TOOLS), pipeline-aware:- commands are split on top-level
|; each segment is judged on its primary binary; - grep/
rgscan segments (pattern + path present) are transparently translated totgrep search(the CLI'ssearchsubcommand — it answers from an existing index or scans directly, while the bare default query mode can hang when no server is running) with quote-preserving re-emission ('foo|bar'stays quoted, an empty""pattern stays an empty argument, unquoted globs/~/braces likesrc/*.tsstay unquoted so the shell still expands them, globs translated from--includeare always quoted,sudo/envprefixes survive); - translated bash commands are stamped onto the tool result (
details.engine: "tgrep"plus the rewritten and original commands), so session logs andnpm run analyzeattribute shell tgrep usage directly; when a translated command fails or prints nothing, a[pi-tgrep] ran … in place of …line is appended to the output the model sees, so an empty result is never mistaken for a block; - the index directory is resolved for the directory each grep actually runs in — the
session cwd, followed through any
cdin the command or script — socd /other/repo && grep …searches that repo's index, not the session repo's; when a valid index directory exists, translated commands whose positional paths are all relative get--index-path '<index dir>'injected (any absolute positional, or an explicit--index-path, skips the injection); without an index thesearchscans the files directly (tgrep prints its own notice) — a bare query-modetgrep, which would rebuild an index beside the searched path or hang, is never emitted;-e/--regexp/-f/--filepattern arguments are tracked so a pattern never masquerades as a path; - the same
--no-ignore-parentis added when a translated command's path operand exists in the repo andgit check-ignorereports it ignored; rg --files,rg --type-list,rg -h/--help/-V/--versionandgrep --help/--versionprint listings or help rather than search, so they run unchanged (tgrep has no file-listing mode);- grep/
rgstdin post-filters (… | grep -v x) are left verbatim: no tree scan, no block; ctx_execute/ctx_execute_fileshell code is scanned exactly like abashcommand: newlines separate commands,#comments are ignored, and heredoc bodies are left untouched while a grep on another line is still translated; a grep that reads a heredoc or here-string (<<,<<<) runs unchanged sincetgrep searchdoesn't read stdin;- non-shell
ctx_execute/ctx_execute_filecode (e.g. JavaScript) is scanned forchild_processexec/spawn calls (exec,execSync,execFile,execFileSync,spawn,spawnSync): an embedded shell grep inside a backtick template or double-quoted string is translated in place, while single-quoted, escaped, interpolated, or statically unextractable commands are blocked, soexecSync('grep -rn …')can no longer bypass the index; member calls only count when the receiver is bound tochild_process(soregex.exec(line)is left alone), and an unextractable command only blocks when the code mentions grep; ctx_batch_executeentries are checked individually (the entry's label appears in block reasons);- output-side redirects are preserved with fd numbers still glued to their redirect
(
2>/dev/null,2>>file,2>&1), so the fd digit never leaks into tgrep as a search path; ;,&&and||split the command and each part is judged on its own; a grep after a shell keyword (do,then,else,if,while,until,!,{) is translated too, soif grep -q …; thenkeeps its exit status;- grep whose operands contain a shell expansion (
"$f",$FILES,$(find …), backticks) greps a dynamic list the index can't help with, so it runs unchanged, as does a command follows acdwith a dynamic target (until the next absolutecd); - the body of
$(…)or backticks is judged like any other command, in the directory it runs in: stdin filters ($(git ls-files | grep -v obj/)) and other binaries ($(which rg)) run unchanged, a tree scan ($(grep -rl X src | grep -v obj/)) is translated in place or, when it can't be, left as written; it is blocked only when an in-place rewrite is unsafe (a backgrounded grep or an unsupported flag inside it, unparseable quoting, or a backtick body with backslash escapes); - a grep that is itself backgrounded with
&(grep … &,a && grep … &; one inside a backgrounded loop or group is translated) and a grep with a stdin redirect (<) are blocked, while the same constructs on other commands are left alone; - every block reason names what triggered it (the construct, or the unsupported flag) and
keeps the phrase "bypasses the tgrep index" so
npm run analyzecan count blocks; BRE patterns the converter can't prove equivalent (back-references such as\1, see Safety notes) andag/ack/pt(flag semantics diverge from rg/tgrep) run the original command untranslated, exact but slow; underPI_TGREP_BASH_POLICY=blockboth are still blocked; zgrep,git log --grep, and filenames containing "grep" are never touched.
- commands are split on top-level
- Degrades gracefully: no tgrep binary means it is fully dormant (built-in grep untouched,
with a one-time offer to
brew install tgrep). Index still building falls back to ripgrep so results are never silently incomplete. Server dead falls back to tgrep's own on-disk index.
Install
# from a local checkout
pi install /path/to/pi-tgrep
# from git
pi install git:github.com/danielmarbach/pi-tgrep
# from npm
pi install npm:@danielmarbach/pi-tgrep
Requires the tgrep binary (brew install tgrep, or cargo from the tgrep repo). If it is
missing, pi-tgrep offers to install it via Homebrew once (see PI_TGREP_AUTO_INSTALL).
For development, skip the install and load it directly:
pi -e /path/to/pi-tgrep/extensions
Configuration
All configuration is via environment variables.
| Variable | Default | Meaning |
|---|---|---|
PI_TGREP_DISABLED |
– | 1 disables the extension entirely |
PI_TGREP_AUTO_INSTALL |
ask |
ask | never | always: brew install when tgrep is missing; ask prompts once and remembers (~/.cache/pi-tgrep/auto-install.json) |
PI_TGREP_BASH_POLICY |
translate |
translate (translate scan segments in pipelines, block the rest) | block (block all grep-family shell use) | warn (allow, notify) | off |
PI_TGREP_WATCH_TOOLS |
– | additional tool names to watch, comma-separated, additive to the default set (bash, ctx_execute, ctx_execute_file, ctx_batch_execute); names match bare or namespace__name |
PI_TGREP_SERVE_ARGS |
– | extra args passed to tgrep serve (e.g. "--exclude vendor --no-watch"); split on whitespace, quotes are not parsed, so individual args cannot contain spaces |
PI_TGREP_INDEX_PATH |
tgrep default (<repo>/.tgrep) |
index directory override, honored everywhere: serve, status/stop/reindex, the grep tool, and shell injection; relative values resolve against the repo root |
PI_TGREP_SCOPE |
repo |
repo keeps the server after the session ends; session stops servers this session started on shutdown |
Commands
| Command | Action |
|---|---|
/tgrep-status |
show server/index status for the current repo |
/tgrep-reindex |
stop the server, rebuild the index, restart |
/tgrep-stop |
stop the server for this repo |
The footer shows tgrep: N files (or tgrep: indexing…) while active.
Architecture
extensions/index.ts factory: config → binary check → session lifecycle → hooks → commands
src/config.ts env-var parsing
src/tgrep-client.ts binary discovery, status parsing (both output shapes), serve.json
discovery + PID liveness, stop (SIGTERM→SIGKILL; tgrep has no stop cmd)
src/server-manager.ts repo-root detection, detached `tgrep serve` spawn, .git/info/exclude
maintenance, readiness monitor, per-scope shutdown, reindex
src/git-ignore.ts `git check-ignore` wrapper deciding when a search path needs --no-ignore-parent
src/grep-tool.ts the grep override: TypeBox schema identical to the built-in, tgrep
--json streaming (ripgrep event schema, parsed 1:1), built-in truncation
rules via pi's own helpers, abort handling, rg fallback while indexing
src/bash-policy.ts shell detection (quote-aware), pipeline segmentation, rg→tgrep
pass-through, grep→tgrep flag translator with quote-preserving
re-emission, verbatim fallback or guided blocks for the rest
Output parity
The override keeps pi's grep output contract byte-for-byte where it matters: path:line: text
matches, path-line- context lines, No matches found, the [N matches limit reached…] and
truncation notices, and details fields (matchLimitReached, truncation, linesTruncated).
The only additions are metadata: details.engine: "tgrep" | "rg-fallback",
details.fallback: { reason: "indexing" | "no-binary" | "error", message? }, and
details.truncated.
The grep tool also recovers from common model mistakes, adding only notices and metadata:
- Invalid regex. A pattern tgrep cannot compile (
Generate(,Handle(Foo|Bar) is retried once as a literal (-F) before the ripgrep fallback. The result ends with[pi-tgrep] pattern is not a valid regex; searched literallyand hasdetails.regexFallback: true. - Multiple paths in one string. If
pathdoes not exist as written but every whitespace-separated token does ("src tests"), all of them are searched (details.searchPaths). Results are shown relative to the working directory. - Path not found. The error keeps the
Path not found: <path>prefix and adds the nearest existing parent directory and up to 20 of its entries. - tgrep errors. When tgrep fails and the ripgrep fallback fails too, the error carries tgrep's
first stderr line (not just
tgrep exited with code 2).
Safety notes
- GNU grep's
-ris recursive, but tgrep's-ris--replace, so the translator drops-r/-Rinstead of passing them through (tgrep walks recursively by default). Sincegrep -rprints file names even for a single file operand while tgrep doesn't,-His added in that case. - grep accepts rg's
--type/-t,--type-notand--type-add;rg --include=GLOBis translated to-g GLOBasgrep --includeis;rg -Pmaps like--pcre2. - Plain
grep(no-E/-P/-F) patterns are POSIX BRE, but tgrep's default engine is a Rust regex (ERE-like), so the pattern is converted before it is passed on:\|\(\)\{\}\+\?become|(){}+?,\<and\>become\b, and the unescaped( ) { } + ? |, which are literal in BRE, are escaped. A*that starts the pattern, a group or an alternative, and a^/$that is not at the edge of the pattern, stay literal. Anything the converter can't prove equivalent runs the original command instead: back-references (\1),\</\>that don't sit next to a required word character, stacked quantifiers, empty alternatives (a\|), brackets containing\, unknown escapes such as\d.-E/egrepneed no flag;-F/fgrepmaps to-F;-P/--perl-regexpmaps to tgrep's-P(PCRE2). - During a from-scratch index build the tgrep server answers from an empty index, so searches
temporarily use ripgrep until
Indexing: complete, favoring correctness over speed.
Validation
npm run typecheck # npx tsc --noEmit -p tsconfig.json
npm test # node --test over harness.mjs + the *.test.mjs suites
test/differential.test.mjs runs a battery of grep/rg commands twice, as written and as
translated against a real index, and compares the sorted output and exit code; it is skipped when
tgrep or rg is not installed. Known differences are listed in the battery with a reason.
The test suites shell out to the real tgrep binary (brew install tgrep), so they need it on
PATH locally and in CI (see .github/workflows/ci.yml, which runs on macos-latest for that
reason).
node scripts/analyze-pi-sessions.mjs (or npm run analyze) mines ~/.pi/agent/sessions/
for the real numbers: indexed vs fallback vs builtin grep traffic, zero-result rate, latency
p50/p95, search→read conversion, and how often the shell policy translates or blocks.
Translated shell greps are identified by the details.engine: "tgrep" stamp the extension
adds to the bash tool result; sessions recorded before the stamp existed count translated
greps as plain grep/rg.
See test/e2e.md for headless pi -p scripts and test/tgrep-cli-notes.md for the verified
tgrep CLI ground truth this implementation relies on.
License
MIT