@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/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
  }
@@ -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 { Edit, PatchError } from "../core/types.ts";
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
- /** Turn a PatchError into helpful hint text for the model. */
65
- function errorText(e: PatchError, path: string): string {
66
- switch (e.kind) {
67
- case "anchor":
68
- return `Anchor mismatch: ${e.message}. Re-read ${path} to get current line hashes (LINE#HASH).`;
69
- case "range":
70
- return `Bad range: ${e.message}`;
71
- case "noop":
72
- return `Edit produced no change: ${e.message}`;
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
- /** Build an error result (isError: true so the TUI/agent loop treats it as a failure). */
107
- function errResult(text: string) {
108
- return {
109
- isError: true as const,
110
- content: [{ type: "text" as const, text }],
111
- details: undefined,
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.hashLen;
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 — steering it to
218
- // read first. Unrelated changes elsewhere never block the edit.
219
- const result = applyEdits(currentText, translated.edits, hashLen);
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(errorText(result.error, displayPath));
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
- // pi's generateDiffString returns the display diff (colored by the renderer) and the first changed line
235
- const { diff, firstChangedLine } = generateDiffString(currentText, result.text);
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, currentText, result.text),
291
+ patch: generateUnifiedPatch(displayPath, oldLf, newLf),
239
292
  firstChangedLine,
240
293
  };
241
294
  const anchors = formatUpdatedAnchors(result.text, result.touchedLines, hashLen);
@@ -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
- const r: any = await call(makeEditOverride(dir), {
154
- path: "f.txt",
155
- edits: [{ op: "replace", anchor: h(text, 2), body: ["x"] }],
156
- });
157
- assert.equal(r.isError, true);
158
- assert.match(r.content[0].text, /anchor|re-read/i);
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
- const r: any = await call(makeEditOverride(dir), {
166
- path: "f.txt",
167
- edits: [{ op: "replace", anchor: { line: 1, hash: "XXXX" }, body: ["A"] }],
168
- });
169
- assert.equal(r.isError, true);
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 → isError", async () => {
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
- const r: any = await call(makeEditOverride(dir), { path: "f.txt", edits: [] });
177
- assert.equal(r.isError, true);
178
- assert.match(r.content[0].text, /empty|missing/i);
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) → isError", async () => {
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
- const r: any = await call(makeEditOverride(dir), {
186
- path: "f.txt",
187
- edits: [{ op: "replace", anchor: { line: 1, hash: "XX" } }],
188
- });
189
- assert.equal(r.isError, true);
190
- assert.match(r.content[0].text, /body/i);
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
- const r: any = await call(edit, {
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.equal(r.isError, true);
255
- // @ts-ignore
256
- const comp: any = edit.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: false }, stubTheme, { isError: r.isError ?? false });
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
+ });
@@ -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
- /** canonical path: shared by read/edit to resolve a file consistently. */
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). */