pi-hashline-edit-pro 3.0.0 → 3.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -1,4 +1,5 @@
1
1
  - `replace`: use the same anchor for `remove_from` and `remove_to` to change one line.
2
2
  - `replace`: `replacement_lines` takes bare lines without `│`; `[""]` is one blank line; pasted `anchor│` prefixes are stripped automatically.
3
3
  - `replace`: keep the range tight — only lines that actually change — and copy leading spaces exactly.
4
- - `replace`: post-edit diff `+anchor│`/` anchor│` rows are fresh anchors for the next edit — no new `read` needed. One edit per turn; check the diff before the next edit on that file.
4
+ - `replace`: post-edit diff `+anchor│`/` anchor│` rows are fresh anchors for the next edit — no new `read` needed. One edit per turn; check the diff before the next edit on that file.
5
+ - `replace`: if `replacement_lines` re-include the boundary line adjacent to the range, it is deduplicated automatically, shown as `dedup│content` rows in the diff (not editable, never use `dedup` as an anchor).
package/src/commit.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { PipelineResult } from "./replace";
2
- import { abortIf, clipLine } from "./utils";
2
+ import { abortIf } from "./utils";
3
+ import { DEDUP_ANCHOR } from "./constants";
4
+ import { HASH_SEP } from "./hashline";
3
5
  import { buildChanged, buildNoop, type RMeta, type TResult } from "./replace-response";
4
6
  import { saveUndo } from "./replace-undo";
5
7
  import { safeSnapId } from "./file-reader";
@@ -22,10 +24,10 @@ export interface CommitMeta {
22
24
  onNoopDedup?: () => void;
23
25
  }
24
26
 
25
- function boundaryDedupWarning(lineTexts: string[]): string {
26
- const quoted = lineTexts.map((line) => `"${clipLine(line, 80)}"`).join(", ");
27
- const plural = lineTexts.length > 1;
28
- return `Boundary dedup: ${quoted} already ${plural ? "exist" : "exists"} next to the edited range, so ${plural ? "they were" : "it was"} not added again.`;
27
+ function boundaryDedupWarning(count: number): string {
28
+ const noun = count === 1 ? "1 line" : `${count} lines`;
29
+ const row = count === 1 ? "row" : "rows";
30
+ return `Boundary dedup: ${noun} not added again (see ${DEDUP_ANCHOR}${HASH_SEP} ${row}).`;
29
31
  }
30
32
 
31
33
  export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promise<TResult> {
@@ -61,7 +63,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
61
63
  );
62
64
  }
63
65
  if (pipe.boundaryRemovedLineTexts.length > 0) {
64
- warnings.push(boundaryDedupWarning(pipe.boundaryRemovedLineTexts));
66
+ warnings.push(boundaryDedupWarning(pipe.boundaryRemovedLineTexts.length));
65
67
  }
66
68
 
67
69
  abortIf(signal);
@@ -109,6 +111,8 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
109
111
  warnings,
110
112
  snapshotId: updatedSnapshotId,
111
113
  editMeta,
114
+ boundaryDedupAbove: pipe.boundaryDedupAbove,
115
+ boundaryDedupBelow: pipe.boundaryDedupBelow,
112
116
  };
113
117
  const changed = buildChanged(successInput, meta.verb);
114
118
  if (changed.details.diff) {
package/src/constants.ts CHANGED
@@ -6,7 +6,11 @@ export const MAX_OVERSIZED_WARNING_LINES = 100;
6
6
  export const MAX_HASH_SOURCE_BYTES = 500;
7
7
  export const MAX_GREP_LINE_BYTES = 500;
8
8
 
9
+ export const MAX_DIFF_INPUT_BYTES = 1024 * 1024;
10
+
9
11
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
10
12
  export const HASH_STORE_VERSION = 7;
11
13
  export const NEW_CONTENT_NOT_ARRAY_MSG =
12
14
  `[E_BAD_SHAPE] "replacement_lines" must be an array of strings, one per line (use [] to delete).`;
15
+
16
+ export const DEDUP_ANCHOR = "dedup";
@@ -16,7 +16,7 @@ export function editRenderResultWrapper(
16
16
  }
17
17
 
18
18
  export function editRenderCallWrapper(
19
- preview: (args: unknown, cwd: string) => Promise<RPreview>,
19
+ preview: (args: unknown, cwd: string, signal?: AbortSignal) => Promise<RPreview>,
20
20
  getInput?: (args: unknown) => { path?: string } | null,
21
21
  toolName?: string,
22
22
  ) {
package/src/fs-write.ts CHANGED
@@ -100,20 +100,21 @@ export async function resolveTarget(path: string): Promise<string> {
100
100
  const TEMP_PREFIX = ".tmp-";
101
101
  const TEMP_UUID_RE = /^\.tmp-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
102
102
  const STALE_TEMP_MS = 60 * 60 * 1000;
103
- const sweptDirs = new Set<string>();
103
+ const sweptDirs = new Map<string, number>();
104
104
 
105
105
  async function sweepStaleTemps(dir: string): Promise<void> {
106
- if (sweptDirs.has(dir)) return;
107
- sweptDirs.add(dir);
106
+ const sweepNow = Date.now();
107
+ const lastSweep = sweptDirs.get(dir);
108
+ if (lastSweep !== undefined && sweepNow - lastSweep < STALE_TEMP_MS) return;
109
+ sweptDirs.set(dir, sweepNow);
108
110
  try {
109
111
  const entries = await readdir(dir, { withFileTypes: true });
110
- const now = Date.now();
111
112
  for (const entry of entries) {
112
113
  if (!entry.isFile() || !TEMP_UUID_RE.test(entry.name)) continue;
113
114
  const tempPath = join(dir, entry.name);
114
115
  try {
115
116
  const stats = await stat(tempPath);
116
- if (now - stats.mtimeMs > STALE_TEMP_MS) {
117
+ if (sweepNow - stats.mtimeMs > STALE_TEMP_MS) {
117
118
  await rm(tempPath, { force: true });
118
119
  }
119
120
  } catch {
package/src/grep.ts CHANGED
@@ -274,10 +274,16 @@ function makeHitFromIndices(
274
274
  };
275
275
  }
276
276
 
277
+ let cachedRgPath: string | undefined;
278
+ export function clearRgPathCache(): void {
279
+ cachedRgPath = undefined;
280
+ }
281
+
277
282
  async function resolveRgPath(): Promise<string> {
283
+ if (cachedRgPath !== undefined) return cachedRgPath;
278
284
  try {
279
285
  const r = spawnSync("rg", ["--version"], { stdio: "pipe" });
280
- if (!r.error && r.status === 0) return "rg";
286
+ if (!r.error && r.status === 0) { cachedRgPath = "rg"; return "rg"; }
281
287
  } catch {}
282
288
  try {
283
289
  const { homedir } = await import("os");
@@ -287,7 +293,7 @@ async function resolveRgPath(): Promise<string> {
287
293
  const bin = join(base, "bin", process.platform === "win32" ? "rg.exe" : "rg");
288
294
  if (existsSync(bin)) {
289
295
  const r = spawnSync(bin, ["--version"], { stdio: "pipe" });
290
- if (!r.error && r.status === 0) return bin;
296
+ if (!r.error && r.status === 0) { cachedRgPath = bin; return bin; }
291
297
  }
292
298
  } catch {}
293
299
  try {
@@ -300,7 +306,7 @@ async function resolveRgPath(): Promise<string> {
300
306
  const mod = await import("file://" + toolsManagerPath);
301
307
  if (mod.ensureTool) {
302
308
  const p = await mod.ensureTool("rg", true);
303
- if (p) return p;
309
+ if (p) { cachedRgPath = p; return p; }
304
310
  }
305
311
  } catch {}
306
312
  throw new Error("[E_ACCESS] ripgrep (rg) is required for grep but was not found. Install ripgrep or ensure pi can download it to ~/.pi/agent/bin.");
@@ -366,6 +372,7 @@ async function collectRgMatches(
366
372
  });
367
373
  child.on("error", (error) => {
368
374
  cleanup();
375
+ if (rgPath === cachedRgPath) cachedRgPath = undefined;
369
376
  reject(error);
370
377
  });
371
378
  child.on("close", (code) => {
@@ -494,17 +501,22 @@ export function regGrep(pi: ExtensionAPI): void {
494
501
  const sortedNums = [...allNums].sort((a, b) => a - b);
495
502
  const indices = sortedNums.map((n) => n - 1).filter((n) => n >= 0);
496
503
  if (countOnly) {
504
+ if (globRegex) {
505
+ const displayPath = relative(ctx.cwd, absPath).replace(/\\/g, "/");
506
+ const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
507
+ if (!globRegex.test(globPath) && !globRegex.test(displayPath)) continue;
508
+ }
497
509
  const norm = await tryReadNormFile(absPath, ctx.cwd, { maxLines: MAX_HASH_LINES, noPersist: true, signal });
498
510
  if (!norm) continue;
499
511
  const hit = makeHitFromIndices(norm, relative(ctx.cwd, absPath).replace(/\\/g, "/"), indices, context, validatedRegex, totalForFile, indices.length);
500
512
  const display = displayRowsForHit(hit);
501
513
  totalRows += display.length;
502
514
  for (const r of display) totalBytes += Buffer.byteLength(r, "utf-8") + 1;
503
- const remaining = limit - matches;
504
- if (remaining > 0) {
505
- const add = Math.min(hit.matchCount, remaining);
515
+ const remainingCountOnly = limit - matches;
516
+ if (remainingCountOnly > 0) {
517
+ const add = Math.min(hit.matchCount, remainingCountOnly);
506
518
  matches += add;
507
- if (hit.matchCount > remaining) limitTruncated = true;
519
+ if (hit.matchCount > remainingCountOnly) limitTruncated = true;
508
520
  } else {
509
521
  limitTruncated = true;
510
522
  }
@@ -515,13 +527,13 @@ export function regGrep(pi: ExtensionAPI): void {
515
527
  limitTruncated = true;
516
528
  break;
517
529
  }
518
- const norm = await tryReadNormFile(absPath, ctx.cwd, { maxLines: MAX_HASH_LINES, noPersist: true, signal });
519
- if (!norm) continue;
520
530
  if (globRegex) {
521
531
  const displayPath = relative(ctx.cwd, absPath).replace(/\\/g, "/");
522
532
  const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
523
533
  if (!globRegex.test(globPath) && !globRegex.test(displayPath)) continue;
524
534
  }
535
+ const norm = await tryReadNormFile(absPath, ctx.cwd, { maxLines: MAX_HASH_LINES, noPersist: true, signal });
536
+ if (!norm) continue;
525
537
  const hit = makeHitFromIndices(norm, relative(ctx.cwd, absPath).replace(/\\/g, "/"), indices, context, validatedRegex, totalForFile, Math.min(totalForFile, remaining));
526
538
  if (!hit) continue;
527
539
  const display = displayRowsForHit(hit);
package/src/hash-store.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "crypto";
1
2
  import { existsSync } from "fs";
2
3
  import { chmod, readFile, rename, mkdir, stat } from "fs/promises";
3
4
  import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
@@ -226,7 +227,7 @@ function isHealthy(db: RawDb): boolean {
226
227
  }
227
228
 
228
229
  async function quarantineStore(storePath: string): Promise<void> {
229
- const suffix = `.corrupt-${Date.now()}`;
230
+ const suffix = `.corrupt-${Date.now()}-${process.pid}-${randomUUID()}`;
230
231
  for (const candidate of [storePath, `${storePath}-wal`, `${storePath}-shm`]) {
231
232
  try {
232
233
  await rename(candidate, `${candidate}${suffix}`);
@@ -442,7 +443,7 @@ export function getSnapshot(
442
443
  snapshotCache.set(path, cached);
443
444
  return cached.hashes.slice();
444
445
  }
445
- const row = store.stmts.get(path, checksum, lineCount);
446
+ const row = withBusyRetry(() => store.stmts.get(path, checksum, lineCount));
446
447
  const parsed = parseStoredHashes(row, () => {
447
448
  if (deleteCorrupt) store.stmts.deleteOne(path);
448
449
  snapshotCache.delete(path);
@@ -484,7 +485,7 @@ export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): v
484
485
  }
485
486
 
486
487
  export function getUndoEntry(store: HashStore, path: string): UndoRecord | undefined {
487
- const row = store.stmts.undoGet(path);
488
+ const row = withBusyRetry(() => store.stmts.undoGet(path));
488
489
  if (!row) return undefined;
489
490
  const parsed = parseStoredHashes(row, () => store.stmts.undoDelete(path));
490
491
  if (!parsed) return undefined;
@@ -60,7 +60,13 @@ function unwrapJsonEnvelope(line: string, warnings?: string[]): string {
60
60
  if (!match) return line;
61
61
  const withoutDot = line.trim().slice(0, -1);
62
62
  try {
63
- JSON.parse(withoutDot);
63
+ const parsed: unknown = JSON.parse(withoutDot);
64
+ if (Array.isArray(parsed) && parsed.length === 1 && typeof parsed[0] === "string") {
65
+ warnings?.push(
66
+ '[E_BAD_SHAPE] Unwrapped JSON array syntax from a replacement_lines element.',
67
+ );
68
+ return parsed[0];
69
+ }
64
70
  return line;
65
71
  } catch {
66
72
  warnings?.push(
package/src/insert.ts CHANGED
@@ -8,7 +8,7 @@ import { MAX_HASH_LINES, parseHashRef, resolveAnchorLine, type Anchor } from "./
8
8
  import { stripAnchorRow } from "./hashline/resolve";
9
9
  import { loadP, loadGuide } from "./prompts";
10
10
  import { normReq } from "./payload-contract";
11
- import { isRec, rejectUnknownFields, splitLines } from "./utils";
11
+ import { decodeStringArray, isRec, rejectUnknownFields, splitLines } from "./utils";
12
12
  import { clearBoundaryBypass } from "./boundary-bypass";
13
13
  import type { RPreview, RRState } from "./replace-render";
14
14
  import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper } from "./edit-common";
@@ -101,15 +101,20 @@ function buildInsertEdit(
101
101
  return { editParams, anchorLine };
102
102
  }
103
103
 
104
- export async function insertPreview(request: unknown, cwd: string): Promise<RPreview> {
104
+ export async function insertPreview(request: unknown, cwd: string, signal?: AbortSignal): Promise<RPreview> {
105
105
  try {
106
106
  const normalized = normReq(request);
107
+ if (isRec(normalized)) {
108
+ const expanded = decodeStringArray(normalized.lines);
109
+ if (expanded) normalized.lines = expanded;
110
+ }
107
111
  assertInsertReq(normalized);
108
112
  const { ref } = parseInsertAnchor(normalized.anchor);
109
113
  const preload = await readNormFile(normalized.path, cwd, {
110
114
  accessMode: constants.R_OK,
111
115
  maxLines: MAX_HASH_LINES,
112
116
  noPersist: true,
117
+ signal,
113
118
  });
114
119
  const { editParams } = buildInsertEdit(normalized, preload, ref);
115
120
  const pipe = await execPipeline(editParams, cwd, {
@@ -117,9 +122,11 @@ export async function insertPreview(request: unknown, cwd: string): Promise<RPre
117
122
  noPersist: true,
118
123
  preloadedNorm: preload,
119
124
  skipBoundaryDedup: true,
125
+ signal,
120
126
  });
121
127
  return previewFromPipe(pipe);
122
128
  } catch (error: unknown) {
129
+ if (signal?.aborted) throw error;
123
130
  return previewError(error);
124
131
  }
125
132
  }
@@ -163,6 +170,14 @@ export function buildInsertToolDef(): InsertToolDef {
163
170
  renderResult: editRenderResultWrapper,
164
171
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
165
172
  const canonical = normReq(params);
173
+ const insertWarnings: string[] = [];
174
+ if (isRec(canonical)) {
175
+ const expanded = decodeStringArray(canonical.lines);
176
+ if (expanded) {
177
+ insertWarnings.push('[E_BAD_SHAPE] Unwrapped JSON array syntax from a lines element.');
178
+ canonical.lines = expanded;
179
+ }
180
+ }
166
181
  assertInsertReq(canonical);
167
182
  const req = canonical;
168
183
  const path = req.path;
@@ -188,7 +203,7 @@ export function buildInsertToolDef(): InsertToolDef {
188
203
  verb: "inserted",
189
204
  noopNoun: "Insertion",
190
205
  foldedAnchorLines: anchorLine === undefined ? 0 : 1,
191
- prefixWarnings: anchorWarnings,
206
+ prefixWarnings: [...anchorWarnings, ...insertWarnings],
192
207
  onApplied: () => clearBoundaryBypass(mutationTargetPath),
193
208
  });
194
209
  });
@@ -4,7 +4,10 @@ import {
4
4
  _lineHashesPure,
5
5
  ANCHOR_LEN,
6
6
  HASH_SEP,
7
+ changedRange,
7
8
  } from "./hashline";
9
+ import { MAX_DIFF_INPUT_BYTES } from "./constants";
10
+ import { splitLines } from "./utils";
8
11
  import {
9
12
  detectEnding,
10
13
  toLF,
@@ -48,9 +51,63 @@ export function genDiff(
48
51
  oldContentHashes?: string[],
49
52
  limits?: DiffLimits,
50
53
  ): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number|undefined)[] } {
51
- const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
52
54
  const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
53
55
  const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
56
+ if (!limits?.unlimited && Buffer.byteLength(oldContent, "utf-8") + Buffer.byteLength(newContent, "utf-8") > MAX_DIFF_INPUT_BYTES) {
57
+ const guardedRange = changedRange(oldContent, newContent);
58
+ const guardNote = `[diff truncated at ${formatSize(maxBytes)}; use read to see the rest.]`;
59
+ if (!guardedRange || !newContentHashes) {
60
+ return { diff: ` ...\n${guardNote}`, firstChangedLine: guardedRange?.firstChangedLine, lineNumbers: [undefined, undefined] };
61
+ }
62
+ const newLines = splitLines(newContent);
63
+ const oldLines = splitLines(oldContent);
64
+ const first = guardedRange.firstChangedLine;
65
+ const last = guardedRange.lastChangedLine;
66
+ const suffixLen = newLines.length - last;
67
+ const oldLast = oldLines.length - suffixLen;
68
+ const beforeStart = Math.max(1, first - contextLines);
69
+ const afterEnd = Math.min(newLines.length, last + contextLines);
70
+ const guarded: string[] = [];
71
+ const guardedNumbers: (number|undefined)[] = [];
72
+ let guardedBytes = 0;
73
+ const pushGuarded = (text: string, num?: number): boolean => {
74
+ const size = Buffer.byteLength(text, "utf-8") + 1;
75
+ if (guardedBytes + size > maxBytes) return false;
76
+ guardedBytes += size;
77
+ guarded.push(text);
78
+ guardedNumbers.push(num);
79
+ return true;
80
+ };
81
+ const pushGuardedRow = (prefix: " " | "+" | "-", line: string, hash: string | undefined, num?: number): boolean => {
82
+ const full = fmtDiffLine(prefix, line, hash);
83
+ if (Buffer.byteLength(full, "utf-8") > maxLineBytes) {
84
+ const marker = `[Row is ${formatSize(Buffer.byteLength(full, "utf-8"))}, exceeds ${formatSize(maxLineBytes)}; content not shown. Use read to see the full line.]`;
85
+ return pushGuarded(fmtDiffLine(prefix, marker, hash), num);
86
+ }
87
+ return pushGuarded(full, num);
88
+ };
89
+ if (beforeStart > 1) pushGuarded(" ...", undefined);
90
+ for (let n = beforeStart; n < first; n++) {
91
+ if (!pushGuardedRow(" ", newLines[n - 1]!, newContentHashes[n - 1], n)) break;
92
+ }
93
+ if (oldContent.length > 0) {
94
+ for (let n = first; n <= Math.min(oldLast, oldLines.length); n++) {
95
+ if (!pushGuardedRow("-", oldLines[n - 1]!, oldContentHashes?.[n - 1], n)) break;
96
+ }
97
+ }
98
+ for (let n = first; n <= last; n++) {
99
+ if (!pushGuardedRow("+", newLines[n - 1]!, newContentHashes[n - 1], n)) break;
100
+ }
101
+ for (let n = last + 1; n <= afterEnd; n++) {
102
+ if (!pushGuardedRow(" ", newLines[n - 1]!, newContentHashes[n - 1], n)) break;
103
+ }
104
+ guarded.push(" ...");
105
+ guardedNumbers.push(undefined);
106
+ guarded.push(guardNote);
107
+ guardedNumbers.push(undefined);
108
+ return { diff: guarded.join("\n"), firstChangedLine: first, lineNumbers: guardedNumbers };
109
+ }
110
+ const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
54
111
 
55
112
  const parts = Diff.diffLines(oldContent, newContent);
56
113
  const output: string[] = [];
@@ -19,6 +19,7 @@ export type RRState = {
19
19
  preview?: RPreview;
20
20
  previewGeneration?: number;
21
21
  previewTimer?: ReturnType<typeof setTimeout>;
22
+ previewAbort?: AbortController;
22
23
  };
23
24
 
24
25
  type DiffRowKind = "added" | "removed" | "context";
@@ -232,7 +233,7 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
232
233
  }
233
234
 
234
235
  export function makeRenderCall(
235
- preview: (args: unknown, cwd: string) => Promise<RPreview>,
236
+ preview: (args: unknown, cwd: string, signal?: AbortSignal) => Promise<RPreview>,
236
237
  options: { getInput?: (args: unknown) => { path?: string } | null; toolName?: string } = {},
237
238
  ) {
238
239
  const getInput = options.getInput ?? getPreviewInput;
@@ -244,6 +245,10 @@ export function makeRenderCall(
244
245
  clearTimeout(context.state.previewTimer);
245
246
  context.state.previewTimer = undefined;
246
247
  }
248
+ if (context.state.previewAbort) {
249
+ context.state.previewAbort.abort();
250
+ context.state.previewAbort = undefined;
251
+ }
247
252
  };
248
253
  if (context.executionStarted) {
249
254
  cancelPendingPreview();
@@ -265,8 +270,12 @@ export function makeRenderCall(
265
270
  context.state.previewGeneration = previewGeneration;
266
271
  context.state.previewTimer = setTimeout(() => {
267
272
  context.state.previewTimer = undefined;
268
- preview(args, context.cwd)
273
+ const controller = new AbortController();
274
+ context.state.previewAbort = controller;
275
+ preview(args, context.cwd, controller.signal)
269
276
  .then((result) => {
277
+ if (controller.signal.aborted) return;
278
+ if (context.state.previewAbort === controller) context.state.previewAbort = undefined;
270
279
  if (
271
280
  context.state.argsKey === argsKey &&
272
281
  context.state.previewGeneration === previewGeneration
@@ -276,6 +285,8 @@ export function makeRenderCall(
276
285
  }
277
286
  })
278
287
  .catch((err: unknown) => {
288
+ if (controller.signal.aborted) return;
289
+ if (context.state.previewAbort === controller) context.state.previewAbort = undefined;
279
290
  if (
280
291
  context.state.argsKey === argsKey &&
281
292
  context.state.previewGeneration === previewGeneration
@@ -311,6 +322,10 @@ export function renderEditResult(
311
322
  clearTimeout(renderState.previewTimer);
312
323
  renderState.previewTimer = undefined;
313
324
  }
325
+ if (renderState.previewAbort) {
326
+ renderState.previewAbort.abort();
327
+ renderState.previewAbort = undefined;
328
+ }
314
329
  renderState.preview = undefined;
315
330
  renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
316
331
  }
@@ -1,7 +1,10 @@
1
+ import { formatSize, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
1
2
  import type { NEdit } from "./hashline";
3
+ import { HASH_SEP } from "./hashline";
2
4
  import type { ReplaceDetails } from "./replace";
3
5
  import { genDiff, genPatch } from "./replace-diff";
4
6
  import { visLines, clipLine } from "./utils";
7
+ import { DEDUP_ANCHOR } from "./constants";
5
8
 
6
9
  export type TResult = {
7
10
  content: Array<{ type: "text"; text: string }>;
@@ -46,6 +49,8 @@ export interface SuccessInput {
46
49
  warnings: string[] | undefined;
47
50
  snapshotId?: string;
48
51
  editMeta: RMeta;
52
+ boundaryDedupAbove?: string[];
53
+ boundaryDedupBelow?: string[];
49
54
  }
50
55
 
51
56
 
@@ -124,11 +129,47 @@ export function buildNoop(input: NoopInput, noopNoun = "Replacement"): TResult {
124
129
  },
125
130
  };
126
131
  }
132
+ export function fmtDedupRow(line: string): string {
133
+ const row = `${DEDUP_ANCHOR}${HASH_SEP}${line}`;
134
+ if (Buffer.byteLength(row, "utf-8") <= DEFAULT_MAX_BYTES) return row;
135
+ const size = formatSize(Buffer.byteLength(row, "utf-8"));
136
+ const limit = formatSize(DEFAULT_MAX_BYTES);
137
+ return `${DEDUP_ANCHOR}${HASH_SEP}[Row is ${size}, exceeds ${limit}; content not shown. Use read to see the full line.]`;
138
+ }
139
+
140
+ export function isChangeRow(line: string): boolean {
141
+ return line.startsWith("+") || line.startsWith("-");
142
+ }
143
+
144
+ export function withDedupRows(diff: string, lineNumbers: (number | undefined)[], above: string[] | undefined, below: string[] | undefined): { diff: string; lineNumbers: (number | undefined)[] } {
145
+ const top = (above ?? []).map(fmtDedupRow);
146
+ const bottom = (below ?? []).map(fmtDedupRow);
147
+ if (top.length === 0 && bottom.length === 0) return { diff, lineNumbers };
148
+ if (diff.length === 0) return { diff: [...top, ...bottom].join("\n"), lineNumbers: [...lineNumbers, ...[...top, ...bottom].map(() => undefined)] };
149
+ const lines = diff.split("\n");
150
+ let first = -1;
151
+ let last = -1;
152
+ for (let i = 0; i < lines.length; i++) {
153
+ if (isChangeRow(lines[i]!)) {
154
+ if (first < 0) first = i;
155
+ last = i;
156
+ }
157
+ }
158
+ if (first < 0) return { diff: `${diff}\n${[...top, ...bottom].join("\n")}`, lineNumbers: [...lineNumbers, ...[...top, ...bottom].map(() => undefined)] };
159
+ const out = [...lines];
160
+ const nums = [...lineNumbers];
161
+ out.splice(last + 1, 0, ...bottom);
162
+ nums.splice(last + 1, 0, ...bottom.map(() => undefined));
163
+ out.splice(first, 0, ...top);
164
+ nums.splice(first, 0, ...top.map(() => undefined));
165
+ return { diff: out.join("\n"), lineNumbers: nums };
166
+ }
127
167
 
128
168
  export function buildChanged(input: SuccessInput, verb = "replaced"): TResult {
129
- const { path, result, warnings, snapshotId, originalNormalized, originalHashes, editMeta, resultHashes } = input;
169
+ const { path, result, warnings, snapshotId, originalNormalized, originalHashes, editMeta, resultHashes, boundaryDedupAbove, boundaryDedupBelow } = input;
130
170
  const resultLines = visLines(result);
131
- const diffResult = genDiff(originalNormalized, result, 1, resultHashes, originalHashes);
171
+ const baseDiff = genDiff(originalNormalized, result, 1, resultHashes, originalHashes);
172
+ const diffResult = withDedupRows(baseDiff.diff, baseDiff.lineNumbers, boundaryDedupAbove, boundaryDedupBelow);
132
173
  const addedLines = editMeta.addedLines;
133
174
  const removedLines = editMeta.removedLines;
134
175
  const warningsBlock = warnBlock(warnings);
@@ -161,7 +202,7 @@ export function buildChanged(input: SuccessInput, verb = "replaced"): TResult {
161
202
  patch: patchResult.patch,
162
203
  ...(patchResult.truncated ? { patchTruncated: true as const } : {}),
163
204
  firstChangedLine:
164
- editMeta.firstChangedLine ?? diffResult.firstChangedLine,
205
+ editMeta.firstChangedLine ?? baseDiff.firstChangedLine,
165
206
  snapshotId,
166
207
  metrics,
167
208
  diffLineNumbers: diffResult.lineNumbers,
@@ -105,6 +105,9 @@ export function regUndo(pi: ExtensionAPI): void {
105
105
  },
106
106
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
107
107
  const path = params.path;
108
+ if (typeof path !== "string" || path.length === 0) {
109
+ throw new Error('[E_BAD_SHAPE] Undo request requires a non-empty "path" string.');
110
+ }
108
111
  const { resolved: mutationTargetPath } = await resolveInCwd(path, ctx.cwd);
109
112
 
110
113
  const undo = await getUndo(mutationTargetPath);
package/src/replace.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  } from "./replace-diff";
10
10
  import { readNormFile, type NormFile } from "./file-reader";
11
11
  import { editToolSchema, type ReqParams, assertReq, normReq } from "./payload-contract";
12
- import { isRec } from "./utils";
12
+ import { decodeStringArray, isRec } from "./utils";
13
13
  import { loadP, loadGuide } from "./prompts";
14
14
  import { type FileIdentity } from "./fs-write";
15
15
  import { applyEdit,
@@ -23,7 +23,7 @@ import { applyEdit,
23
23
  type NEdit,
24
24
  } from "./hashline";
25
25
  import { commitEdit } from "./commit";
26
- import type { RMetrics } from "./replace-response";
26
+ import { withDedupRows, type RMetrics } from "./replace-response";
27
27
  import {
28
28
  type RPreview,
29
29
  type RRState,
@@ -64,6 +64,8 @@ export interface PipelineResult {
64
64
  hadBoundaryDedup: boolean;
65
65
  boundaryRemovedLines: number;
66
66
  boundaryRemovedLineTexts: string[];
67
+ boundaryDedupAbove: string[];
68
+ boundaryDedupBelow: string[];
67
69
  identity: FileIdentity;
68
70
  }
69
71
 
@@ -160,11 +162,17 @@ export async function execPipeline(
160
162
  const path = params.path;
161
163
 
162
164
  const editWarnings: string[] = [];
165
+ let replacementLines = params.replacement_lines;
166
+ const expandedReplacement = decodeStringArray(replacementLines);
167
+ if (expandedReplacement) {
168
+ editWarnings.push('[E_BAD_SHAPE] Unwrapped JSON array syntax from a replacement_lines element.');
169
+ replacementLines = expandedReplacement;
170
+ }
163
171
  const edit = resEdit(
164
172
  {
165
173
  remove_from: params.remove_from,
166
174
  remove_to: params.remove_to,
167
- replacement_lines: params.replacement_lines,
175
+ replacement_lines: replacementLines,
168
176
  },
169
177
  editWarnings,
170
178
  );
@@ -210,6 +218,9 @@ export async function execPipeline(
210
218
  edit, originalHashes, isNoop, anchorResult.autoFixes?.length ?? 0,
211
219
  );
212
220
 
221
+ const sortedFixes = [...(anchorResult.autoFixes ?? [])].sort((a, b) => a.removedLineIndex - b.removedLineIndex);
222
+ const aboveFixes = sortedFixes.filter((fix) => fix.kind === "leading" || fix.kind === "last-new-before");
223
+ const belowFixes = sortedFixes.filter((fix) => fix.kind === "trailing" || fix.kind === "first-new-after");
213
224
  return {
214
225
  path,
215
226
  originalNormalized,
@@ -227,7 +238,9 @@ export async function execPipeline(
227
238
  totalRemovedLines,
228
239
  hadBoundaryDedup: (anchorResult.autoFixes?.length ?? 0) > 0,
229
240
  boundaryRemovedLines: anchorResult.autoFixes?.length ?? 0,
230
- boundaryRemovedLineTexts: anchorResult.autoFixes?.map((fix) => fix.removedLine) ?? [],
241
+ boundaryRemovedLineTexts: sortedFixes.map((fix) => fix.removedLine),
242
+ boundaryDedupAbove: aboveFixes.map((fix) => fix.removedLine),
243
+ boundaryDedupBelow: belowFixes.map((fix) => fix.removedLine),
231
244
  identity,
232
245
  };
233
246
  }
@@ -238,7 +251,8 @@ export function previewFromPipe(pipe: PipelineResult): RPreview {
238
251
  error: `No changes made to ${pipe.path}. The edit produced identical content.`,
239
252
  };
240
253
  }
241
- return { diff: genDiff(pipe.originalNormalized, pipe.result, 4, pipe.resultHashes, pipe.originalHashes).diff };
254
+ const base = genDiff(pipe.originalNormalized, pipe.result, 4, pipe.resultHashes, pipe.originalHashes);
255
+ return { diff: withDedupRows(base.diff, base.lineNumbers, pipe.boundaryDedupAbove, pipe.boundaryDedupBelow).diff };
242
256
  }
243
257
  export function previewError(error: unknown): RPreview {
244
258
  return { error: error instanceof Error ? error.message : String(error) };
@@ -246,6 +260,7 @@ export function previewError(error: unknown): RPreview {
246
260
  export async function compPreview(
247
261
  request: unknown,
248
262
  cwd: string,
263
+ signal?: AbortSignal,
249
264
  ): Promise<RPreview> {
250
265
  try {
251
266
  const normalized = normReq(request);
@@ -253,10 +268,11 @@ export async function compPreview(
253
268
  const pipe = await execPipeline(
254
269
  normalized,
255
270
  cwd,
256
- { accessMode: constants.R_OK, noPersist: true },
271
+ { accessMode: constants.R_OK, noPersist: true, signal },
257
272
  );
258
273
  return previewFromPipe(pipe);
259
274
  } catch (error: unknown) {
275
+ if (signal?.aborted) throw error;
260
276
  return previewError(error);
261
277
  }
262
278
  }
package/src/served.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { loadHashStore, parseStoredServed, STORE_NOT_OPEN_MESSAGE, withStore, type HashStore } from "./hash-store";
2
+ import { withBusyRetry } from "./hash-store/retry";
2
3
  import { HASH_CLASS } from "./hashline/alphabet";
3
4
  import { contentChecksum } from "./hashline/hasher";
4
5
 
@@ -37,7 +38,7 @@ export function buildServedMap(fileHashes: string[], fileLines: string[], wanted
37
38
  }
38
39
 
39
40
  export function getServed(store: HashStore, path: string): Map<string, string> | undefined {
40
- const row = store.stmts.servedGet(path);
41
+ const row = withBusyRetry(() => store.stmts.servedGet(path));
41
42
  const parsed = parseStoredServed(row, () => store.stmts.servedDelete(path));
42
43
  if (!parsed) return undefined;
43
44
  return parsed;
package/src/utils.ts CHANGED
@@ -168,3 +168,84 @@ function formatLineLimit(displayPath: string, limit: number, count: number | und
168
168
  const detail = count === undefined ? `has more than ${limit}` : `has ${count}`;
169
169
  return `[E_FILE_TOO_LARGE] ${displayPath} ${detail} lines, exceeding the ${limit}-line hashline limit. For very large files, use write.`;
170
170
  }
171
+
172
+ function escapeRawControl(value: string): string {
173
+ if (value === "\b") return "\\b";
174
+ if (value === "\t") return "\\t";
175
+ if (value === "\n") return "\\n";
176
+ if (value === "\f") return "\\f";
177
+ if (value === "\r") return "\\r";
178
+ const hex = value.charCodeAt(0).toString(16).padStart(4, "0");
179
+ return `\\u${hex}`;
180
+ }
181
+
182
+ function escapeControls(value: string): string {
183
+ let out = "";
184
+ for (const char of value) {
185
+ out += char.charCodeAt(0) < 32 ? escapeRawControl(char) : char;
186
+ }
187
+ return out;
188
+ }
189
+
190
+ function decodeArraySegment(segment: string): string | undefined {
191
+ const trimmed = segment.trim();
192
+ if (trimmed.length < 2 || !trimmed.startsWith('"') || !trimmed.endsWith('"')) return undefined;
193
+ try {
194
+ const cleaned = escapeControls(trimmed);
195
+ const parsed: unknown = JSON.parse(cleaned);
196
+ return typeof parsed === "string" ? parsed : undefined;
197
+ } catch {
198
+ return undefined;
199
+ }
200
+ }
201
+
202
+ function splitArraySegments(inner: string): string[] | undefined {
203
+ const segments: string[] = [];
204
+ let current = "";
205
+ let inQuotes = false;
206
+ let escaped = false;
207
+ for (const char of inner) {
208
+ if (inQuotes) {
209
+ current += char;
210
+ if (escaped) escaped = false;
211
+ else if (char === "\\") escaped = true;
212
+ else if (char === '"') inQuotes = false;
213
+ continue;
214
+ }
215
+ if (char === '"') {
216
+ inQuotes = true;
217
+ current += char;
218
+ continue;
219
+ }
220
+ if (char === ",") {
221
+ segments.push(current);
222
+ current = "";
223
+ continue;
224
+ }
225
+ current += char;
226
+ }
227
+ if (inQuotes || escaped) return undefined;
228
+ segments.push(current);
229
+ return segments;
230
+ }
231
+
232
+ function decodeArrayText(value: unknown): string[] | undefined {
233
+ if (typeof value !== "string") return undefined;
234
+ const trimmed = value.trim();
235
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return undefined;
236
+ const segments = splitArraySegments(trimmed.slice(1, -1));
237
+ if (!segments) return undefined;
238
+ const decoded: string[] = [];
239
+ for (const segment of segments) {
240
+ const part = decodeArraySegment(segment);
241
+ if (part === undefined) return undefined;
242
+ decoded.push(part);
243
+ }
244
+ return decoded.length > 0 ? decoded : undefined;
245
+ }
246
+
247
+ export function decodeStringArray(value: unknown): string[] | undefined {
248
+ if (typeof value === "string") return decodeArrayText(value);
249
+ if (Array.isArray(value) && value.length === 1) return decodeArrayText(value[0]);
250
+ return undefined;
251
+ }