@d3ara1n/pi-hashline-edit 0.1.2 → 0.3.0
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.
- package/README.md +98 -10
- package/package.json +1 -1
- package/src/core/apply.test.ts +98 -7
- package/src/core/apply.ts +126 -39
- package/src/core/hash.ts +0 -4
- package/src/core/types.ts +45 -10
- package/src/index.ts +9 -2
- package/src/pi/config.ts +7 -1
- package/src/pi/edit-tool.ts +78 -25
- package/src/pi/execute.test.ts +39 -26
- package/src/pi/grep-tool.ts +353 -0
- package/src/pi/pi.test.ts +7 -0
- package/src/pi/read-tool.ts +16 -3
- package/src/pi/replace-tool.ts +302 -0
- package/src/pi/replace.test.ts +283 -0
- package/src/pi/state.ts +1 -1
package/src/pi/config.ts
CHANGED
|
@@ -15,9 +15,11 @@ export interface HashlineEditConfig {
|
|
|
15
15
|
enabled: boolean;
|
|
16
16
|
/** Line hash length (default 4). */
|
|
17
17
|
hashLen: number;
|
|
18
|
+
/** ±line radius for shifted-anchor recovery (default 15; 0 disables rescue). */
|
|
19
|
+
shiftRadius: number;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
const DEFAULT_CONFIG: HashlineEditConfig = { enabled: true, hashLen: 4 };
|
|
22
|
+
const DEFAULT_CONFIG: HashlineEditConfig = { enabled: true, hashLen: 4, shiftRadius: 15 };
|
|
21
23
|
|
|
22
24
|
function getAgentDir(): string {
|
|
23
25
|
const envDir = process.env.PI_AGENT_DIR;
|
|
@@ -49,5 +51,9 @@ export function loadConfig(cwd?: string): HashlineEditConfig {
|
|
|
49
51
|
typeof raw.hashLen === "number" && raw.hashLen >= 2 && raw.hashLen <= 8
|
|
50
52
|
? raw.hashLen
|
|
51
53
|
: DEFAULT_CONFIG.hashLen,
|
|
54
|
+
shiftRadius:
|
|
55
|
+
typeof raw.shiftRadius === "number" && raw.shiftRadius >= 0 && raw.shiftRadius <= 100
|
|
56
|
+
? raw.shiftRadius
|
|
57
|
+
: DEFAULT_CONFIG.shiftRadius,
|
|
52
58
|
};
|
|
53
59
|
}
|
package/src/pi/edit-tool.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
25
25
|
import { readFile, writeFile } from "node:fs/promises";
|
|
26
26
|
import { applyEdits, hashFileLines } from "../core/index.ts";
|
|
27
27
|
import { splitLines } from "../core/lines.ts";
|
|
28
|
-
import type {
|
|
28
|
+
import type { ApplyFailure, Edit } from "../core/types.ts";
|
|
29
29
|
import { canonicalPath } from "./read-tool.ts";
|
|
30
30
|
import { getState } from "./state.ts";
|
|
31
31
|
|
|
@@ -61,16 +61,59 @@ const editSchema = Type.Object({
|
|
|
61
61
|
|
|
62
62
|
type EditOpInput = Static<typeof editOpSchema>;
|
|
63
63
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Turn an ApplyFailure into LLM-facing text. The FIRST line is a terse summary
|
|
66
|
+
* (the TUI's renderResult shows only the first line of an error result); the
|
|
67
|
+
* remaining lines carry the structured detail the model needs to retry without a
|
|
68
|
+
* re-read — rescued anchors / ambiguous candidates / the cited line's live
|
|
69
|
+
* content. range/noop are already terse single-line messages.
|
|
70
|
+
*/
|
|
71
|
+
function formatFailure(failure: ApplyFailure, path: string): string {
|
|
72
|
+
if (failure.kind === "range") return failure.message;
|
|
73
|
+
if (failure.kind === "noop") return failure.message;
|
|
74
|
+
|
|
75
|
+
const lines: string[] = [];
|
|
76
|
+
let found = 0;
|
|
77
|
+
let ambiguous = 0;
|
|
78
|
+
let none = 0;
|
|
79
|
+
for (const f of failure.failures) {
|
|
80
|
+
const where = `op #${f.opIndex} ${f.op} ${f.which} (line ${f.cited.line})`;
|
|
81
|
+
switch (f.recovery.kind) {
|
|
82
|
+
case "found": {
|
|
83
|
+
found++;
|
|
84
|
+
lines.push(
|
|
85
|
+
`• ${where}: content shifted to line ${f.recovery.newLine}. Resend this op with ${f.which} { "line": ${f.recovery.newLine}, "hash": "${f.recovery.newHash}" }.`,
|
|
86
|
+
);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case "ambiguous": {
|
|
90
|
+
ambiguous++;
|
|
91
|
+
const nums = f.recovery.candidates.map((c) => c.line).join(", ");
|
|
92
|
+
const list = f.recovery.candidates
|
|
93
|
+
.map((c) => `{ "line": ${c.line}, "hash": "${c.hash}" }`)
|
|
94
|
+
.join(" / ");
|
|
95
|
+
lines.push(
|
|
96
|
+
`• ${where}: ambiguous — same content at lines ${nums}. Pick the right one and resend ${f.which} ${list}.`,
|
|
97
|
+
);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case "none": {
|
|
101
|
+
none++;
|
|
102
|
+
const cur =
|
|
103
|
+
f.current != null
|
|
104
|
+
? `current line ${f.cited.line}: ${f.cited.line}#${f.current.hash}│${f.current.content}`
|
|
105
|
+
: `line ${f.cited.line} is out of range`;
|
|
106
|
+
lines.push(`• ${where}: not found nearby — content changed. ${cur}. Re-read ${path} for fresh anchors.`);
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
73
110
|
}
|
|
111
|
+
const parts: string[] = [];
|
|
112
|
+
if (found) parts.push(`${found} rescued`);
|
|
113
|
+
if (ambiguous) parts.push(`${ambiguous} ambiguous`);
|
|
114
|
+
if (none) parts.push(`${none} need re-read`);
|
|
115
|
+
const brief = `Anchor mismatch: ${parts.join(", ")}.`;
|
|
116
|
+
return `${brief}\n${lines.join("\n")}`;
|
|
74
117
|
}
|
|
75
118
|
|
|
76
119
|
/** Translate JSON edit ops into core Edit[]. Validates conditional required fields (anchor/body per op). */
|
|
@@ -103,13 +146,16 @@ function toCoreEdits(ops: readonly EditOpInput[]): { ok: true; edits: Edit[] } |
|
|
|
103
146
|
return { ok: true, edits };
|
|
104
147
|
}
|
|
105
148
|
|
|
106
|
-
/**
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Fail the edit by throwing. pi's contract: a tool failure is signaled by throwing,
|
|
151
|
+
* not by returning `{ isError: true }` — the framework derives `context.isError` from
|
|
152
|
+
* whether execute threw, and overwrites `result.isError` with it
|
|
153
|
+
* (`updateResult({ ...result, isError: event.isError })`). Returning an isError object
|
|
154
|
+
* left the TUI rendering failures as success (green). The thrown message reaches the
|
|
155
|
+
* LLM verbatim; renderResult shows its first line in red.
|
|
156
|
+
*/
|
|
157
|
+
function errResult(text: string): never {
|
|
158
|
+
throw new Error(text);
|
|
113
159
|
}
|
|
114
160
|
|
|
115
161
|
/**
|
|
@@ -198,7 +244,7 @@ export function makeEditOverride(cwd: string) {
|
|
|
198
244
|
}
|
|
199
245
|
|
|
200
246
|
async function runHashline(absPath: string, displayPath: string, editOps: readonly EditOpInput[], signal: AbortSignal | undefined) {
|
|
201
|
-
const hashLen = getState().config
|
|
247
|
+
const { hashLen, shiftRadius } = getState().config;
|
|
202
248
|
|
|
203
249
|
let currentText: string;
|
|
204
250
|
try {
|
|
@@ -214,11 +260,13 @@ async function runHashline(absPath: string, displayPath: string, editOps: readon
|
|
|
214
260
|
if (!translated.ok) return errResult(translated.error);
|
|
215
261
|
|
|
216
262
|
// Anchors are verified against the current content. A line that changed (or a
|
|
217
|
-
// hash the model didn't actually read) fails its own anchor —
|
|
218
|
-
//
|
|
219
|
-
|
|
263
|
+
// hash the model didn't actually read) fails its own anchor — but first we try
|
|
264
|
+
// shifted recovery: if the content merely moved within ±shiftRadius, a fresh
|
|
265
|
+
// anchor is returned so the model can retry without a re-read. All failures in
|
|
266
|
+
// the batch are collected (nothing written on any failure).
|
|
267
|
+
const result = applyEdits(currentText, translated.edits, hashLen, shiftRadius);
|
|
220
268
|
if (!result.ok) {
|
|
221
|
-
return errResult(
|
|
269
|
+
return errResult(formatFailure(result.failure, displayPath));
|
|
222
270
|
}
|
|
223
271
|
|
|
224
272
|
// Check for cancel before write: if aborted, don't touch the disk; the file stays untouched
|
|
@@ -231,11 +279,16 @@ async function runHashline(absPath: string, displayPath: string, editOps: readon
|
|
|
231
279
|
return errResult(`Error writing ${displayPath}: ${msg}`);
|
|
232
280
|
}
|
|
233
281
|
|
|
234
|
-
//
|
|
235
|
-
|
|
282
|
+
// generateDiffString / generateUnifiedPatch split on \n, so raw CRLF content would
|
|
283
|
+
// leave a trailing \r on every diff line — the TUI line-wrapper (wrapTextWithAnsi)
|
|
284
|
+
// then emits a spurious blank line per diff line. Normalize to LF for diff/patch
|
|
285
|
+
// only; the disk write above already preserved the original line endings.
|
|
286
|
+
const oldLf = currentText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
287
|
+
const newLf = result.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
288
|
+
const { diff, firstChangedLine } = generateDiffString(oldLf, newLf);
|
|
236
289
|
const details: EditToolDetails = {
|
|
237
290
|
diff,
|
|
238
|
-
patch: generateUnifiedPatch(displayPath,
|
|
291
|
+
patch: generateUnifiedPatch(displayPath, oldLf, newLf),
|
|
239
292
|
firstChangedLine,
|
|
240
293
|
};
|
|
241
294
|
const anchors = formatUpdatedAnchors(result.text, result.touchedLines, hashLen);
|
package/src/pi/execute.test.ts
CHANGED
|
@@ -150,44 +150,49 @@ test("edit on a line that changed externally → anchor mismatch", async () => {
|
|
|
150
150
|
const text = "a\nb\nc\n";
|
|
151
151
|
await writeFile(f, text);
|
|
152
152
|
await writeFile(f, "a\nBCHANGED\nc\n"); // line 2 changed
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
153
|
+
await assert.rejects(
|
|
154
|
+
call(makeEditOverride(dir), {
|
|
155
|
+
path: "f.txt",
|
|
156
|
+
edits: [{ op: "replace", anchor: h(text, 2), body: ["x"] }],
|
|
157
|
+
}),
|
|
158
|
+
/anchor|re-read/i,
|
|
159
|
+
);
|
|
159
160
|
});
|
|
160
161
|
});
|
|
161
162
|
|
|
162
163
|
test("edit execute: no read before edit → anchor verification fails", async () => {
|
|
163
164
|
await withDir(async (dir) => {
|
|
164
165
|
await writeFile(join(dir, "f.txt"), "a\nb\n");
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
166
|
+
await assert.rejects(
|
|
167
|
+
call(makeEditOverride(dir), {
|
|
168
|
+
path: "f.txt",
|
|
169
|
+
edits: [{ op: "replace", anchor: { line: 1, hash: "XXXX" }, body: ["A"] }],
|
|
170
|
+
}),
|
|
171
|
+
/anchor|re-read/i,
|
|
172
|
+
);
|
|
170
173
|
});
|
|
171
174
|
});
|
|
172
175
|
|
|
173
|
-
test("edit execute: empty edits →
|
|
176
|
+
test("edit execute: empty edits → throws", async () => {
|
|
174
177
|
await withDir(async (dir) => {
|
|
175
178
|
await writeFile(join(dir, "f.txt"), "a\n");
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
+
await assert.rejects(
|
|
180
|
+
call(makeEditOverride(dir), { path: "f.txt", edits: [] }),
|
|
181
|
+
/empty|missing/i,
|
|
182
|
+
);
|
|
179
183
|
});
|
|
180
184
|
});
|
|
181
185
|
|
|
182
|
-
test("edit execute: malformed op (replace without body) →
|
|
186
|
+
test("edit execute: malformed op (replace without body) → throws", async () => {
|
|
183
187
|
await withDir(async (dir) => {
|
|
184
188
|
await writeFile(join(dir, "f.txt"), "a\n");
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
189
|
+
await assert.rejects(
|
|
190
|
+
call(makeEditOverride(dir), {
|
|
191
|
+
path: "f.txt",
|
|
192
|
+
edits: [{ op: "replace", anchor: { line: 1, hash: "XX" } }],
|
|
193
|
+
}),
|
|
194
|
+
/body/i,
|
|
195
|
+
);
|
|
191
196
|
});
|
|
192
197
|
});
|
|
193
198
|
|
|
@@ -247,13 +252,21 @@ test("edit error: renderResult renders the error line without throwing", async (
|
|
|
247
252
|
await withDir(async (dir) => {
|
|
248
253
|
await writeFile(join(dir, "f.txt"), "a\n");
|
|
249
254
|
const edit = makeEditOverride(dir);
|
|
250
|
-
|
|
255
|
+
let thrown: any;
|
|
256
|
+
await call(edit, {
|
|
251
257
|
path: "f.txt",
|
|
252
258
|
edits: [{ op: "replace", anchor: { line: 1, hash: "XXXX" }, body: ["A"] }],
|
|
259
|
+
}).catch((e: any) => {
|
|
260
|
+
thrown = e;
|
|
253
261
|
});
|
|
254
|
-
assert.
|
|
255
|
-
// @ts-ignore
|
|
256
|
-
const comp: any = edit.renderResult(
|
|
262
|
+
assert.ok(thrown, "expected the edit to throw");
|
|
263
|
+
// @ts-ignore — simulate the framework handing the thrown message to renderResult
|
|
264
|
+
const comp: any = edit.renderResult(
|
|
265
|
+
{ content: [{ type: "text", text: thrown.message }] },
|
|
266
|
+
{ isPartial: false, expanded: false },
|
|
267
|
+
stubTheme,
|
|
268
|
+
{ isError: true },
|
|
269
|
+
);
|
|
257
270
|
assert.ok(typeof comp?.text === "string");
|
|
258
271
|
});
|
|
259
272
|
});
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Override grep: search results carry `LINE#HASH│` anchors (same format as
|
|
3
|
+
* read), grouped by file. The model can copy `LINE#HASH` straight into an edit
|
|
4
|
+
* anchor — no re-read needed. Context lines (`-C`) are anchored too.
|
|
5
|
+
*
|
|
6
|
+
* We run ripgrep directly (`--json`) rather than wrap the built-in grep, so we
|
|
7
|
+
* control formatting and can compute each line's hash from its FULL content
|
|
8
|
+
* while displaying a truncated copy. (The built-in grep truncates long lines
|
|
9
|
+
* before formatting; hashing that truncated text would not match what edit
|
|
10
|
+
* verifies against the full line — so the hash must be computed from the full
|
|
11
|
+
* content, independently of what is displayed.)
|
|
12
|
+
*
|
|
13
|
+
* Falls back to the built-in grep when: hashline disabled, aborted, or ripgrep
|
|
14
|
+
* cannot be located.
|
|
15
|
+
*
|
|
16
|
+
* @module pi-hashline-edit/pi
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
createGrepTool,
|
|
21
|
+
truncateHead,
|
|
22
|
+
truncateLine,
|
|
23
|
+
formatSize,
|
|
24
|
+
DEFAULT_MAX_BYTES,
|
|
25
|
+
} from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
27
|
+
import { spawn } from "node:child_process";
|
|
28
|
+
import { createInterface } from "node:readline";
|
|
29
|
+
import { access, constants, readFile, stat } from "node:fs/promises";
|
|
30
|
+
import { basename, delimiter, join, relative } from "node:path";
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
import { hashFileLines } from "../core/hash.ts";
|
|
33
|
+
import { splitLines } from "../core/lines.ts";
|
|
34
|
+
import { getState } from "./state.ts";
|
|
35
|
+
import { canonicalPath } from "./read-tool.ts";
|
|
36
|
+
|
|
37
|
+
const DEFAULT_LIMIT = 100;
|
|
38
|
+
/** Max chars per result line for display (mirrors pi's truncate.ts; not exported there). */
|
|
39
|
+
const GREP_MAX_LINE_LENGTH = 500;
|
|
40
|
+
|
|
41
|
+
/** Locate ripgrep: pi's bundled bin first, then PATH. Returns null if not found. */
|
|
42
|
+
async function findRg(): Promise<string | null> {
|
|
43
|
+
const agentDir = process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
44
|
+
const piRg = join(agentDir, "bin", "rg");
|
|
45
|
+
try {
|
|
46
|
+
await access(piRg, constants.X_OK);
|
|
47
|
+
return piRg;
|
|
48
|
+
} catch {}
|
|
49
|
+
for (const dir of process.env.PATH?.split(delimiter) ?? []) {
|
|
50
|
+
if (!dir) continue;
|
|
51
|
+
const p = join(dir, "rg");
|
|
52
|
+
try {
|
|
53
|
+
await access(p, constants.X_OK);
|
|
54
|
+
return p;
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface RawMatch {
|
|
61
|
+
filePath: string;
|
|
62
|
+
lineNumber: number;
|
|
63
|
+
match: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Convert the anchored grep output (grouped, `LINE#HASH│`) into a human-readable
|
|
68
|
+
* form for the TUI: drop the hash, keep file headers and line numbers. Within each
|
|
69
|
+
* file group, the common leading whitespace shared by all matched lines is folded
|
|
70
|
+
* into a single marker (›) so deep, repeated indentation doesn't eat display width;
|
|
71
|
+
* each line's indentation relative to that common base is preserved. The model still
|
|
72
|
+
* receives the anchored `content` text verbatim — this only affects what the user sees.
|
|
73
|
+
*/
|
|
74
|
+
function countLeading(s: string): number {
|
|
75
|
+
const m = s.match(/^[ \t]*/);
|
|
76
|
+
return m ? m[0].length : 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function toDisplayLines(raw: string, theme: any): string[] {
|
|
80
|
+
const out: string[] = [];
|
|
81
|
+
const lines = raw.split("\n");
|
|
82
|
+
let i = 0;
|
|
83
|
+
while (i < lines.length) {
|
|
84
|
+
const line = lines[i];
|
|
85
|
+
const h = line.match(/^(.+?) · (\d+ match(?:es)?)$/);
|
|
86
|
+
if (h) {
|
|
87
|
+
out.push(theme.fg("success", h[1]) + theme.fg("dim", ` · ${h[2]}`));
|
|
88
|
+
// collect the anchor lines in this file group
|
|
89
|
+
const group: { lineNo: string; content: string }[] = [];
|
|
90
|
+
let j = i + 1;
|
|
91
|
+
while (j < lines.length) {
|
|
92
|
+
const a = lines[j].match(/^(\d+)#[A-Za-z0-9]+│(.*)$/);
|
|
93
|
+
if (!a) break;
|
|
94
|
+
group.push({ lineNo: a[1], content: a[2] });
|
|
95
|
+
j++;
|
|
96
|
+
}
|
|
97
|
+
// common base = min leading whitespace across the group; fold it into a marker
|
|
98
|
+
const base = group.length ? Math.min(...group.map((g) => countLeading(g.content))) : 0;
|
|
99
|
+
const marker = base > 0 ? theme.fg("dim", "›") + " " : "";
|
|
100
|
+
for (const g of group) {
|
|
101
|
+
const body = g.content.slice(base);
|
|
102
|
+
out.push(theme.fg("dim", ` ${g.lineNo}: `) + marker + theme.fg("toolOutput", body));
|
|
103
|
+
}
|
|
104
|
+
i = j;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (line.startsWith("[")) out.push(theme.fg("warning", line));
|
|
108
|
+
else out.push(theme.fg("toolOutput", line));
|
|
109
|
+
i++;
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Build the grep override (a ToolDefinition fragment for registerTool). */
|
|
115
|
+
export function makeGrepOverride(cwd: string) {
|
|
116
|
+
const builtin = createGrepTool(cwd);
|
|
117
|
+
const delegate = (toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any) =>
|
|
118
|
+
builtin.execute(toolCallId, params, signal, onUpdate);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
name: "grep" as const,
|
|
122
|
+
label: "grep",
|
|
123
|
+
description:
|
|
124
|
+
"Search file contents for a pattern. Matches show per-line content hashes (LINE#HASH│content) grouped by file — copy LINE#HASH straight into an edit anchor, no re-read needed. Context lines (context) are anchored too. Respects .gitignore.",
|
|
125
|
+
promptSnippet: "Search file contents; results show LINE#HASH anchors usable directly in edit (no re-read needed)",
|
|
126
|
+
promptGuidelines: [
|
|
127
|
+
"Results are grouped by file under a `path · N matches` header; each line shows `LINE#HASH│content` (same format as read).",
|
|
128
|
+
"Copy `LINE#HASH` straight into an edit `anchor`/`end` — no re-read needed. Context lines (from `context`) are anchored and editable too.",
|
|
129
|
+
"Pass `pattern`; optionally `path`, `glob`, `ignoreCase`, `literal`, `context` (lines before+after each match), `limit` (max matches, default 100).",
|
|
130
|
+
],
|
|
131
|
+
parameters: builtin.parameters,
|
|
132
|
+
|
|
133
|
+
renderShell: "default" as const,
|
|
134
|
+
|
|
135
|
+
renderCall(args: any, theme: any) {
|
|
136
|
+
const pattern = args?.pattern ?? "";
|
|
137
|
+
const p = args?.path ?? ".";
|
|
138
|
+
let text =
|
|
139
|
+
theme.fg("toolTitle", theme.bold("grep ")) +
|
|
140
|
+
theme.fg("accent", `/${pattern}/`) +
|
|
141
|
+
theme.fg("toolOutput", ` in ${p}`);
|
|
142
|
+
if (args?.glob) text += theme.fg("toolOutput", ` (${args.glob})`);
|
|
143
|
+
if (args?.limit !== undefined) text += theme.fg("toolOutput", ` limit ${args.limit}`);
|
|
144
|
+
return new Text(text, 0, 0);
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
|
|
148
|
+
if (isPartial) return new Text(theme.fg("warning", "Searching…"), 0, 0);
|
|
149
|
+
if (context?.isError) {
|
|
150
|
+
const t = result.content?.[0]?.type === "text" ? result.content[0].text.split("\n")[0] : "Error";
|
|
151
|
+
return new Text(theme.fg("error", t), 0, 0);
|
|
152
|
+
}
|
|
153
|
+
const out = result.content?.[0]?.type === "text" ? result.content[0].text : "";
|
|
154
|
+
const styled = toDisplayLines(out, theme);
|
|
155
|
+
const maxLines = expanded ? styled.length : 15;
|
|
156
|
+
const shown = styled.slice(0, maxLines);
|
|
157
|
+
const more =
|
|
158
|
+
!expanded && styled.length > maxLines
|
|
159
|
+
? `\n${theme.fg("muted", `… (${styled.length - maxLines} more lines)`)}`
|
|
160
|
+
: "";
|
|
161
|
+
return new Text(shown.join("\n") + more, 0, 0);
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any): Promise<any> {
|
|
165
|
+
const state = getState();
|
|
166
|
+
// disabled or already aborted → built-in grep (it handles abort itself)
|
|
167
|
+
if (!state.config.enabled || signal?.aborted) return delegate(toolCallId, params, signal, onUpdate);
|
|
168
|
+
|
|
169
|
+
const rgPath = await findRg();
|
|
170
|
+
// ripgrep unavailable → degrade to the built-in (which can auto-download rg)
|
|
171
|
+
if (!rgPath) return delegate(toolCallId, params, signal, onUpdate);
|
|
172
|
+
|
|
173
|
+
const { pattern, path: searchDir, glob, ignoreCase, literal, context, limit } = params;
|
|
174
|
+
const searchPath = canonicalPath(cwd, searchDir || ".");
|
|
175
|
+
const hashLen = state.config.hashLen;
|
|
176
|
+
|
|
177
|
+
let isDir = true;
|
|
178
|
+
try {
|
|
179
|
+
isDir = (await stat(searchPath)).isDirectory();
|
|
180
|
+
} catch {
|
|
181
|
+
throw new Error(`Path not found: ${searchPath}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return new Promise((resolvePromise, reject) => {
|
|
185
|
+
if (signal?.aborted) {
|
|
186
|
+
reject(new Error("Operation aborted"));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const args = ["--json", "--line-number", "--color=never", "--hidden"];
|
|
191
|
+
if (ignoreCase) args.push("--ignore-case");
|
|
192
|
+
if (literal) args.push("--fixed-strings");
|
|
193
|
+
if (glob) args.push("--glob", glob);
|
|
194
|
+
const ctx = context && context > 0 ? context : 0;
|
|
195
|
+
if (ctx > 0) args.push("--context", String(ctx));
|
|
196
|
+
args.push("--", String(pattern), searchPath);
|
|
197
|
+
|
|
198
|
+
const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
|
|
199
|
+
const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
200
|
+
const rl = createInterface({ input: child.stdout });
|
|
201
|
+
let stderr = "";
|
|
202
|
+
let matchCount = 0;
|
|
203
|
+
let matchLimitReached = false;
|
|
204
|
+
let linesTruncated = false;
|
|
205
|
+
let aborted = false;
|
|
206
|
+
let killedDueToLimit = false;
|
|
207
|
+
const raw: RawMatch[] = [];
|
|
208
|
+
|
|
209
|
+
const cleanup = () => {
|
|
210
|
+
rl.close();
|
|
211
|
+
signal?.removeEventListener("abort", onAbort);
|
|
212
|
+
};
|
|
213
|
+
const stopChild = (dueToLimit = false) => {
|
|
214
|
+
if (!child.killed) {
|
|
215
|
+
killedDueToLimit = dueToLimit;
|
|
216
|
+
child.kill();
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
const onAbort = () => {
|
|
220
|
+
aborted = true;
|
|
221
|
+
stopChild();
|
|
222
|
+
};
|
|
223
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
224
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
225
|
+
stderr += chunk.toString();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
rl.on("line", (line: string) => {
|
|
229
|
+
if (!line.trim() || matchCount >= effectiveLimit) return;
|
|
230
|
+
let event: any;
|
|
231
|
+
try {
|
|
232
|
+
event = JSON.parse(line);
|
|
233
|
+
} catch {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (event.type === "match") {
|
|
237
|
+
matchCount++;
|
|
238
|
+
const filePath = event.data?.path?.text;
|
|
239
|
+
const lineNumber = event.data?.line_number;
|
|
240
|
+
if (filePath && typeof lineNumber === "number") raw.push({ filePath, lineNumber, match: true });
|
|
241
|
+
if (matchCount >= effectiveLimit) {
|
|
242
|
+
matchLimitReached = true;
|
|
243
|
+
stopChild(true);
|
|
244
|
+
}
|
|
245
|
+
} else if (event.type === "context") {
|
|
246
|
+
const filePath = event.data?.path?.text;
|
|
247
|
+
const lineNumber = event.data?.line_number;
|
|
248
|
+
if (filePath && typeof lineNumber === "number") raw.push({ filePath, lineNumber, match: false });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
child.on("error", (error) => {
|
|
253
|
+
cleanup();
|
|
254
|
+
reject(new Error(`Failed to run ripgrep: ${error.message}`));
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
child.on("close", async (code) => {
|
|
258
|
+
cleanup();
|
|
259
|
+
if (aborted) {
|
|
260
|
+
reject(new Error("Operation aborted"));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (!killedDueToLimit && code !== 0 && code !== 1) {
|
|
264
|
+
reject(new Error(stderr.trim() || `ripgrep exited with code ${code}`));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (raw.length === 0) {
|
|
268
|
+
resolvePromise({ content: [{ type: "text", text: "No matches found" }], details: undefined });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Dedupe by (file, line); a line that is both a match and a context line counts as a match.
|
|
273
|
+
const map = new Map<string, RawMatch>();
|
|
274
|
+
for (const m of raw) {
|
|
275
|
+
const key = `${m.filePath}:${m.lineNumber}`;
|
|
276
|
+
const prev = map.get(key);
|
|
277
|
+
if (!prev || (!prev.match && m.match)) map.set(key, m);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Group by file, each group sorted by line number.
|
|
281
|
+
const byFile = new Map<string, RawMatch[]>();
|
|
282
|
+
for (const m of map.values()) {
|
|
283
|
+
const arr = byFile.get(m.filePath) ?? [];
|
|
284
|
+
arr.push(m);
|
|
285
|
+
byFile.set(m.filePath, arr);
|
|
286
|
+
}
|
|
287
|
+
for (const arr of byFile.values()) arr.sort((a, b) => a.lineNumber - b.lineNumber);
|
|
288
|
+
|
|
289
|
+
// Read each file once and hash all its lines; hash is computed from the FULL line.
|
|
290
|
+
const fileCache = new Map<string, { lines: string[]; hashes: string[] }>();
|
|
291
|
+
const getFile = async (fp: string) => {
|
|
292
|
+
let entry = fileCache.get(fp);
|
|
293
|
+
if (!entry) {
|
|
294
|
+
let content = "";
|
|
295
|
+
try {
|
|
296
|
+
content = (await readFile(fp)).toString("utf-8");
|
|
297
|
+
} catch {
|
|
298
|
+
content = "";
|
|
299
|
+
}
|
|
300
|
+
const lines = splitLines(content);
|
|
301
|
+
entry = { lines, hashes: hashFileLines(lines, hashLen) };
|
|
302
|
+
fileCache.set(fp, entry);
|
|
303
|
+
}
|
|
304
|
+
return entry;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const formatPath = (fp: string): string => {
|
|
308
|
+
if (isDir) {
|
|
309
|
+
const rel = relative(searchPath, fp).replace(/\\/g, "/");
|
|
310
|
+
if (rel && !rel.startsWith("..")) return rel;
|
|
311
|
+
}
|
|
312
|
+
return basename(fp);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const blocks: string[] = [];
|
|
316
|
+
for (const [fp, matches] of byFile) {
|
|
317
|
+
const { lines, hashes } = await getFile(fp);
|
|
318
|
+
const n = matches.filter((m) => m.match).length;
|
|
319
|
+
const header = `${formatPath(fp)} · ${n} match${n !== 1 ? "es" : ""}`;
|
|
320
|
+
const rows: string[] = [];
|
|
321
|
+
for (const m of matches) {
|
|
322
|
+
const content = lines[m.lineNumber - 1] ?? "";
|
|
323
|
+
const hash = hashes[m.lineNumber - 1] ?? "";
|
|
324
|
+
const { text: disp, wasTruncated } = truncateLine(content.replace(/\r/g, ""));
|
|
325
|
+
if (wasTruncated) linesTruncated = true;
|
|
326
|
+
rows.push(`${m.lineNumber}#${hash}│${disp}`);
|
|
327
|
+
}
|
|
328
|
+
blocks.push(`${header}\n${rows.join("\n")}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let output = blocks.join("\n\n");
|
|
332
|
+
const truncation = truncateHead(output, { maxBytes: DEFAULT_MAX_BYTES });
|
|
333
|
+
output = truncation.content;
|
|
334
|
+
|
|
335
|
+
const notices: string[] = [];
|
|
336
|
+
if (matchLimitReached)
|
|
337
|
+
notices.push(
|
|
338
|
+
`${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
|
|
339
|
+
);
|
|
340
|
+
if (truncation.truncated) notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
|
341
|
+
if (linesTruncated)
|
|
342
|
+
notices.push(`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read to see full lines`);
|
|
343
|
+
if (notices.length) output += `\n\n[${notices.join(". ")}]`;
|
|
344
|
+
|
|
345
|
+
resolvePromise({
|
|
346
|
+
content: [{ type: "text" as const, text: output }],
|
|
347
|
+
details: undefined,
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
package/src/pi/pi.test.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { canonicalPath } from "./read-tool.ts";
|
|
4
|
+
import { homedir } from "node:os";
|
|
4
5
|
|
|
5
6
|
test("canonicalPath resolves relative and absolute", () => {
|
|
6
7
|
assert.equal(canonicalPath("/cwd", "foo.ts"), "/cwd/foo.ts");
|
|
7
8
|
assert.equal(canonicalPath("/cwd", "./foo.ts"), "/cwd/foo.ts");
|
|
8
9
|
assert.equal(canonicalPath("/cwd", "/abs/x.ts"), "/abs/x.ts");
|
|
9
10
|
});
|
|
11
|
+
|
|
12
|
+
test("canonicalPath expands ~ to home directory", () => {
|
|
13
|
+
const home = homedir();
|
|
14
|
+
assert.equal(canonicalPath("/cwd", "~"), home);
|
|
15
|
+
assert.equal(canonicalPath("/cwd", "~/foo.ts"), `${home}/foo.ts`);
|
|
16
|
+
});
|
package/src/pi/read-tool.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
|
|
12
12
|
import { createReadTool } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { readFile } from "node:fs/promises";
|
|
14
|
-
import { resolve } from "node:path";
|
|
14
|
+
import { join, resolve } from "node:path";
|
|
15
|
+
import { homedir } from "node:os";
|
|
15
16
|
import { hashFileLines } from "../core/hash.ts";
|
|
16
17
|
import { splitLines } from "../core/lines.ts";
|
|
17
18
|
import { getState } from "./state.ts";
|
|
@@ -19,9 +20,21 @@ import { getState } from "./state.ts";
|
|
|
19
20
|
const MAX_LINES = 2000;
|
|
20
21
|
const MAX_BYTES = 256 * 1024;
|
|
21
22
|
|
|
22
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Canonical absolute path: shared by read/edit/grep to resolve a file consistently.
|
|
25
|
+
* Expands a leading `~` / `~/` to the user's home directory. (`~user` is not supported.)
|
|
26
|
+
*/
|
|
23
27
|
export function canonicalPath(cwd: string, p: string): string {
|
|
24
|
-
return resolve(cwd, p);
|
|
28
|
+
return resolve(cwd, expandTilde(p));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Mirrors pi core's `normalizePath` tilde handling: expands `~` / `~/` (and `~\` on Windows), leaves `~user` untouched. */
|
|
32
|
+
function expandTilde(p: string): string {
|
|
33
|
+
if (p === "~") return homedir();
|
|
34
|
+
if (p.startsWith("~/") || (process.platform === "win32" && p.startsWith("~\\"))) {
|
|
35
|
+
return join(homedir(), p.slice(2));
|
|
36
|
+
}
|
|
37
|
+
return p;
|
|
25
38
|
}
|
|
26
39
|
|
|
27
40
|
/** Build the read override (a ToolDefinition fragment for registerTool). */
|