pi-diet-ripgrep 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +23 -1
  2. package/index.ts +146 -65
  3. package/package.json +13 -1
  4. package/spec.md +5 -9
package/README.md CHANGED
@@ -37,6 +37,28 @@ mkdir -p ~/.pi/agent/extensions/pi-diet-ripgrep
37
37
  ln -sf /path/to/pi-diet-ripgrep/index.ts ~/.pi/agent/extensions/pi-diet-ripgrep/index.ts
38
38
  ```
39
39
 
40
+ ## Development and validation
41
+
42
+ Install a current Node.js release and `rg` (ripgrep), then run:
43
+
44
+ ```bash
45
+ npm ci
46
+ npm run lint
47
+ npm run build
48
+ npm test
49
+ npm pack --dry-run
50
+ ```
51
+
52
+ `lint` performs strict TypeScript checking of the extension and tests. `build`
53
+ optionally compiles the extension to ignored `dist/` output; Pi and the published
54
+ package continue to load `index.ts` directly.
55
+
56
+ Tests require a POSIX environment (macOS or Linux). They exercise the registered
57
+ tool with controlled subprocess fixtures plus a real `rg` integration test.
58
+ Coverage includes arguments and leading-dash patterns, formatting and context,
59
+ no matches, invalid regexes, missing executables, match/byte/line limits, complete
60
+ saved output, and self-contained tool guidance.
61
+
40
62
  ## Publishing
41
63
 
42
64
  First publish manually once:
@@ -67,4 +89,4 @@ git push && git push origin v0.1.1
67
89
 
68
90
  ## Tool
69
91
 
70
- `rg` is intended for exact text and regex search only.
92
+ `rg` is intended for exact text and regex search: literal verification, logs, config keys, TODOs, and message lookup. Text matches alone do not establish symbol identity, code structure, or behavior. Its guidance does not assume other extensions are installed.
package/index.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { execFile as execFileCallback } from "node:child_process";
2
- import { mkdtemp, writeFile } from "node:fs/promises";
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtemp, open, rm } from "node:fs/promises";
3
+ import { createReadStream } from "node:fs";
4
+ import { createInterface } from "node:readline";
3
5
  import { tmpdir } from "node:os";
4
6
  import { join } from "node:path";
5
- import { promisify } from "node:util";
6
7
  import { Type } from "@earendil-works/pi-ai";
7
8
  import {
8
9
  DEFAULT_MAX_BYTES,
@@ -11,16 +12,16 @@ import {
11
12
  formatSize,
12
13
  truncateHead,
13
14
  type TruncationResult,
14
- withFileMutationQueue,
15
15
  type ExtensionAPI,
16
16
  } from "@earendil-works/pi-coding-agent";
17
17
 
18
- const execFile = promisify(execFileCallback);
18
+ const MAX_METADATA_MATCH_BYTES = 20_000;
19
+ const MAX_LINE_BYTES = 1_024;
19
20
 
20
21
  const rgTool = defineTool({
21
22
  name: "rg",
22
23
  label: "ripgrep",
23
- description: "Exact text/regex search with ripgrep. Use for literal verification, logs, config keys, TODOs, and message lookup. Prefer LSP/AST/Semble for symbol, structural, or semantic discovery.",
24
+ description: "Exact text/regex search with ripgrep. Use for literal verification, logs, config keys, TODOs, and message lookup. Text matches alone do not establish symbol identity, code structure, or behavior.",
24
25
  parameters: Type.Object({
25
26
  query: Type.String({ description: "Pattern" }),
26
27
  paths: Type.Optional(Type.Array(Type.String({ description: "Path" }))),
@@ -33,72 +34,58 @@ const rgTool = defineTool({
33
34
  fixedStrings: Type.Optional(Type.Boolean({ description: "Literal strings" })),
34
35
  }),
35
36
 
36
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
37
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
37
38
  const args = buildArgs(params);
38
39
  const searchPaths = params.paths?.length ? params.paths : ["."];
39
40
  const maxMatches = params.maxMatches ?? 50;
40
41
 
41
- let stdout = "";
42
- try {
43
- const result = await execFile("rg", [...args, params.query, ...searchPaths], {
44
- cwd: ctx.cwd,
45
- encoding: "utf8",
46
- maxBuffer: 100 * 1024 * 1024,
47
- });
48
- stdout = result.stdout;
49
- } catch (error: any) {
50
- stdout = error?.stdout ?? "";
51
- if (error?.code !== 1) {
52
- throw new Error(error?.stderr?.trim() || error?.message || "ripgrep failed");
53
- }
54
- }
55
-
56
- const parsed = parseRgJson(stdout, maxMatches);
42
+ const capture = await captureRg([...args, "--", params.query, ...searchPaths], ctx.cwd, signal);
43
+ const parsed = await parseRgJson(capture.rawPath, capture.fullPath, maxMatches);
44
+ const truncation = truncateHead(parsed.lines.join("\n"), {
45
+ maxLines: DEFAULT_MAX_LINES,
46
+ maxBytes: DEFAULT_MAX_BYTES,
47
+ });
57
48
  const details: RgDetails = {
58
- query: params.query,
59
- searchedPaths: searchPaths,
49
+ query: shortText(params.query, 512),
50
+ searchedPaths: searchPaths.slice(0, 20).map(value => shortText(value, 256)),
60
51
  matchCount: parsed.totalMatchCount,
61
- truncated: parsed.totalMatchCount > parsed.matches.length,
52
+ truncated: parsed.omittedMatches > 0 || parsed.metadataOmitted > 0 ||
53
+ parsed.shortenedLines > 0 || parsed.previewOverflow || truncation.truncated ||
54
+ parsed.invalidRecords > 0,
62
55
  matches: parsed.matches,
56
+ metadataOmitted: parsed.metadataOmitted,
57
+ shortenedLines: parsed.shortenedLines,
58
+ incomplete: parsed.invalidRecords > 0,
63
59
  };
64
-
65
- if (!parsed.totalMatchCount) {
66
- return {
67
- content: [{ type: "text", text: "No matches found" }],
68
- details,
69
- };
70
- }
71
-
72
- const fullText = parsed.lines.join("\n");
73
- const truncation = truncateHead(fullText, {
74
- maxLines: DEFAULT_MAX_LINES,
75
- maxBytes: DEFAULT_MAX_BYTES,
76
- });
77
-
78
- let text = truncation.content;
79
- if (details.truncated || truncation.truncated) {
80
- details.truncated = true;
81
- details.truncation = truncation;
82
- const tempDir = await mkdtemp(join(tmpdir(), "pi-diet-ripgrep-"));
83
- const tempFile = join(tempDir, "output.txt");
84
- await withFileMutationQueue(tempFile, async () => {
85
- await writeFile(tempFile, fullText, "utf8");
86
- });
87
- details.fullOutputPath = tempFile;
88
- const omittedMatches = Math.max(0, parsed.totalMatchCount - parsed.matches.length);
60
+ if (details.query !== params.query || searchPaths.length > 20 ||
61
+ searchPaths.some(value => Buffer.byteLength(value) > 256)) details.truncated = true;
62
+ let text = truncation.content || (parsed.invalidRecords ? "No complete matching records parsed." : "No matches found");
63
+ if (details.truncated) {
64
+ // Do not duplicate the display content inside transcript metadata.
65
+ const { content: _content, ...stats } = truncation;
66
+ details.truncation = stats;
67
+ details.fullOutputPath = capture.fullPath;
68
+ details.rawOutputPath = capture.rawPath;
89
69
  const notices: string[] = [];
90
- if (omittedMatches) notices.push(`${omittedMatches} matches omitted by maxMatches cap`);
91
- if (truncation.truncated) {
70
+ if (parsed.omittedMatches) notices.push(`${parsed.omittedMatches} matches omitted by maxMatches cap`);
71
+ if (parsed.metadataOmitted) notices.push(`${parsed.metadataOmitted} selected matches omitted from metadata by its byte budget`);
72
+ if (parsed.shortenedLines) notices.push(`${parsed.shortenedLines} long lines shortened; match windows may omit other occurrences`);
73
+ if (parsed.invalidRecords) notices.push(`${parsed.invalidRecords} invalid JSON records; search evidence is incomplete`);
74
+ if (truncation.truncated || parsed.previewOverflow) {
92
75
  notices.push(
93
76
  `output truncated to ${truncation.outputLines}/${truncation.totalLines} lines and ${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)}`,
94
77
  );
95
78
  }
96
- text += `\n\n[${notices.join("; ")}. Full output: ${tempFile}]`;
79
+ if (!notices.length) notices.push("query/path metadata shortened");
80
+ text += `\n\n[${notices.join("; ")}. Full output: ${capture.fullPath}; raw rg JSON: ${capture.rawPath}. Temporary files; not a durable archive.]`;
81
+ } else {
82
+ await rm(capture.directory, { recursive: true, force: true });
97
83
  }
98
84
 
99
85
  return {
100
86
  content: [{ type: "text", text }],
101
87
  details,
88
+ isError: parsed.invalidRecords > 0,
102
89
  };
103
90
  },
104
91
  });
@@ -142,21 +129,94 @@ type RgDetails = {
142
129
  matchCount: number;
143
130
  truncated: boolean;
144
131
  matches: RgMatch[];
145
- truncation?: TruncationResult;
132
+ truncation?: Omit<TruncationResult, "content">;
146
133
  fullOutputPath?: string;
134
+ rawOutputPath?: string;
135
+ metadataOmitted?: number;
136
+ shortenedLines?: number;
137
+ incomplete?: boolean;
147
138
  };
148
139
 
149
- function parseRgJson(stdout: string, maxMatches: number) {
140
+ function shortText(value: string, maxBytes: number) {
141
+ const buffer = Buffer.from(value);
142
+ if (buffer.length <= maxBytes) return value;
143
+ return windowText(buffer, 0, maxBytes - 3) + "...";
144
+ }
145
+
146
+ function windowText(buffer: Buffer, start: number, end: number) {
147
+ while (start < end && (buffer[start] & 0xc0) === 0x80) start++;
148
+ while (end < buffer.length && end > start && (buffer[end] & 0xc0) === 0x80) end--;
149
+ return buffer.subarray(start, end).toString("utf8");
150
+ }
151
+
152
+ function linePreview(value: string, submatches: { start: number }[] = []) {
153
+ const buffer = Buffer.from(value);
154
+ if (buffer.length <= MAX_LINE_BYTES) return value;
155
+ if (!submatches.length) return shortText(value, MAX_LINE_BYTES);
156
+ return submatches.slice(0, 3).map(match => {
157
+ const start = Math.max(0, match.start - 120);
158
+ const end = Math.min(buffer.length, match.start + 200);
159
+ return `[match byte column ${match.start + 1}] ${start > 0 ? "..." : ""}${windowText(buffer, start, end)}${end < buffer.length ? "..." : ""}`;
160
+ }).join(" | ");
161
+ }
162
+
163
+ async function captureRg(args: string[], cwd: string, signal?: AbortSignal) {
164
+ const directory = await mkdtemp(join(tmpdir(), "pi-diet-ripgrep-"));
165
+ const rawPath = join(directory, "rg.jsonl");
166
+ const stderrPath = join(directory, "stderr.txt");
167
+ const out = await open(rawPath, "w", 0o600);
168
+ const err = await open(stderrPath, "w", 0o600);
169
+ let failure: Error | undefined;
170
+ let code: number | null;
171
+ try {
172
+ code = await new Promise<number | null>(resolve => {
173
+ const child = spawn("rg", args, { cwd, signal, stdio: ["ignore", out.fd, err.fd] });
174
+ child.on("error", error => { failure = error; });
175
+ child.on("close", resolve);
176
+ });
177
+ } finally {
178
+ await Promise.all([out.close(), err.close()]);
179
+ }
180
+ if (failure || (code !== 0 && code !== 1)) {
181
+ const file = await open(stderrPath, "r");
182
+ let message: string;
183
+ try {
184
+ const bytes = Buffer.alloc(4_000);
185
+ const { bytesRead } = await file.read(bytes, 0, bytes.length, 0);
186
+ message = bytes.subarray(0, bytesRead).toString("utf8").trim();
187
+ } finally { await file.close(); }
188
+ throw new Error(`${message || failure?.message || `ripgrep exited with code ${code}`}\nCaptured stdout: ${rawPath}; stderr: ${stderrPath}`);
189
+ }
190
+ return { directory, rawPath, fullPath: join(directory, "output.txt") };
191
+ }
192
+
193
+ async function parseRgJson(rawPath: string, fullPath: string, maxMatches: number) {
150
194
  const lines: string[] = [];
151
195
  const matches: RgMatch[] = [];
152
196
  let totalMatchCount = 0;
153
-
154
- for (const rawLine of stdout.split(/\r?\n/)) {
197
+ let metadataBytes = 0, metadataOmitted = 0, shortenedLines = 0, invalidRecords = 0;
198
+ let previewBytes = 0, previewOverflow = false, first = true;
199
+ const output = await open(fullPath, "w", 0o600);
200
+ const input = createReadStream(rawPath);
201
+ const records = createInterface({ input, crlfDelay: Infinity });
202
+ const addPreview = (line: string) => {
203
+ if (previewOverflow) return;
204
+ lines.push(line);
205
+ previewBytes += Buffer.byteLength(line) + 1;
206
+ if (lines.length > DEFAULT_MAX_LINES || previewBytes > DEFAULT_MAX_BYTES) previewOverflow = true;
207
+ };
208
+ try {
209
+ for await (const rawLine of records) {
155
210
  if (!rawLine.trim()) continue;
156
211
  let record: any;
157
212
  try {
158
213
  record = JSON.parse(rawLine);
159
214
  } catch {
215
+ invalidRecords++;
216
+ continue;
217
+ }
218
+ if (!record || typeof record !== "object") {
219
+ invalidRecords++;
160
220
  continue;
161
221
  }
162
222
 
@@ -166,20 +226,41 @@ function parseRgJson(stdout: string, maxMatches: number) {
166
226
  const line = record.data?.line_number ?? 0;
167
227
  const text = String(record.data?.lines?.text ?? "").replace(/\n$/, "");
168
228
  const column = (record.data?.submatches?.[0]?.start ?? 0) + 1;
169
- if (matches.length < maxMatches) {
170
- matches.push({ path, line, column, text });
171
- lines.push(`${path}:${line}:${column}:${text}`);
229
+ await output.writeFile(`${first ? "" : "\n"}${path}:${line}:${column}:${text}`);
230
+ first = false;
231
+ if (totalMatchCount <= maxMatches) {
232
+ const preview = linePreview(text, record.data?.submatches);
233
+ if (preview !== text || Buffer.byteLength(path) > 512) shortenedLines++;
234
+ const match = { path: shortText(path, 512), line, column, text: preview };
235
+ const bytes = Buffer.byteLength(JSON.stringify(match));
236
+ if (metadataBytes + bytes <= MAX_METADATA_MATCH_BYTES) {
237
+ matches.push(match);
238
+ metadataBytes += bytes;
239
+ } else metadataOmitted++;
240
+ addPreview(`${shortText(path, 512)}:${line}:${column}:${preview}`);
172
241
  }
173
242
  continue;
174
243
  }
175
244
 
176
- if (record.type === "context" && matches.length > 0) {
245
+ if (record.type === "context") {
177
246
  const path = record.data?.path?.text ?? "<unknown>";
178
247
  const line = record.data?.line_number ?? 0;
179
248
  const text = String(record.data?.lines?.text ?? "").replace(/\n$/, "");
180
- lines.push(`${path}-${line}- ${text}`);
249
+ const formatted = `${path}-${line}- ${text}`;
250
+ await output.writeFile(`${first ? "" : "\n"}${formatted}`);
251
+ first = false;
252
+ if (totalMatchCount <= maxMatches) {
253
+ const preview = linePreview(text);
254
+ if (preview !== text || Buffer.byteLength(path) > 512) shortenedLines++;
255
+ addPreview(`${shortText(path, 512)}-${line}- ${preview}`);
256
+ }
181
257
  }
182
258
  }
183
-
184
- return { lines, matches, totalMatchCount };
259
+ } finally {
260
+ records.close();
261
+ input.destroy();
262
+ await output.close();
263
+ }
264
+ return { lines, matches, totalMatchCount, metadataOmitted, shortenedLines, invalidRecords,
265
+ previewOverflow, omittedMatches: Math.max(0, totalMatchCount - maxMatches) };
185
266
  }
package/package.json CHANGED
@@ -1,9 +1,14 @@
1
1
  {
2
2
  "name": "pi-diet-ripgrep",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Minimal Pi extension exposing ripgrep as a first-class exact-search tool.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
+ "scripts": {
8
+ "lint": "tsc --noEmit",
9
+ "build": "tsc --project tsconfig.build.json",
10
+ "test": "tsx --test tests/*.test.ts"
11
+ },
7
12
  "files": [
8
13
  "index.ts",
9
14
  "README.md",
@@ -38,5 +43,12 @@
38
43
  "extensions": [
39
44
  "./index.ts"
40
45
  ]
46
+ },
47
+ "devDependencies": {
48
+ "@earendil-works/pi-ai": "^1.4.0",
49
+ "@earendil-works/pi-coding-agent": "^1.4.0",
50
+ "@types/node": "^26.6.5",
51
+ "tsx": "^4.23.15",
52
+ "typescript": "^7.0.2"
41
53
  }
42
54
  }
package/spec.md CHANGED
@@ -8,11 +8,7 @@ The extension is intentionally "diet": it should add as little system-prompt ove
8
8
 
9
9
  ## Problem
10
10
 
11
- Pi already supports:
12
- - LSP for symbol-aware navigation
13
- - AST tools for structural search
14
- - Semble for semantic discovery
15
- - `bash` for ad hoc `rg`
11
+ Pi supports `bash` for ad hoc `rg`, but additional code-intelligence extensions are optional and must not be assumed to be installed.
16
12
 
17
13
  What is missing is a compact, structured, exact-text search tool that:
18
14
  - is cheaper and safer than freeform shell use
@@ -26,11 +22,11 @@ What is missing is a compact, structured, exact-text search tool that:
26
22
  2. Keep added prompt footprint to roughly 100-200 tokens, ideally under 200.
27
23
  3. Return concise structured output suitable for agent use.
28
24
  4. Bound output size and support truncation safely.
29
- 5. Avoid duplicating guidance already covered by Pi's built-in search/routing instructions.
25
+ 5. Keep guidance self-contained: describe this tool's purpose and limits without recommending other extensions.
30
26
 
31
27
  ## Non-goals
32
28
 
33
- - Replacing LSP, AST, or semantic tools.
29
+ - Providing symbol-aware navigation, structural matching, or semantic discovery.
34
30
  - Becoming a full grep UI.
35
31
  - Supporting every ripgrep flag.
36
32
  - Adding large prompt instructions, examples, or verbose routing prose.
@@ -41,7 +37,7 @@ Use `rg` when the task needs:
41
37
  - exact literals
42
38
  - regex search
43
39
  - finding strings, log messages, config keys, TODOs, identifiers by text
44
- - quick scoped verification after semantic/AST discovery
40
+ - quick scoped verification of candidate source locations
45
41
 
46
42
  Do not prefer `rg` for:
47
43
  - definitions/references/types
@@ -166,7 +162,7 @@ That usage is common enough to justify a small first-class tool, but not a verbo
166
162
  Build the richer-but-still-diet version:
167
163
  - `rg` tool only
168
164
  - compact schema with `hidden` and `fixedStrings`
169
- - routing-aware description that defers to LSP/AST/Semble
165
+ - self-contained description covering exact-search use cases and the limits of text matches
170
166
  - `rg --json`
171
167
  - structured matches
172
168
  - small default result cap