pi-hashline-edit-pro 4.2.3 → 4.2.4

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 CHANGED
@@ -97,7 +97,9 @@ An edit that produces identical content reports `No changes made` and leaves the
97
97
 
98
98
  After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as a marker that keeps the row's anchor, and only the rows shown in the capped diff are recorded as served. The same caps apply to the `insert` and `undo_last_change` diffs, to the interactive previews, and to `details.patch`.
99
99
 
100
- Multiple `replace` and `insert` calls on the same file in one message are grouped per file into one batch that validates every call against the pre-batch state and then applies them together on the batch's last call: earlier calls reply `In batch` (`In batch N` when several files batch) and the batch's last call shows the combined diff, with one undo reverting the whole batch. Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch. Calls with stale anchors join their file's batch through a `requirePath` path hint or a valid co-anchor and abort it instead of applying partially; a same-turn sibling whose anchors resolve nowhere still aborts the batch when no other file is being edited. An error that aborts a batch ends with `Aborts batch N.`, while the abort itself reads `[E_OP_ABORTED] Batch N aborted.` Anchor capacity is preflighted before writing; if anchor finalization fails after the write, the error states the file was written with one undo available. Verify each batch diff before the next turn's edits on that file.
100
+ Multiple `replace` and `insert` calls on the same file in one assistant message are grouped per file into one batch. The batch unit is the message, not the turn: calls from separate messages in the same turn run solo, one after another. A solo edit commits before its result returns; a batch validates every call against the pre-batch state and commits once, during the batch's last call: earlier calls reply `In batch` (`In batch N` when several files batch) and the batch's last call shows the combined diff, with one undo reverting the whole batch. Nothing commits at turn end.
101
+ Tools in one message run concurrently, so a `read` or shell `cat` issued alongside an edit can observe the pre-commit state; verify in the next message with the post-edit diff or a fresh `read`.
102
+ Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch unwritten. A resolvable failure aborts its batch-mates, while an anchor that resolves nowhere cannot join a batch: same-message [resolvable-fail + resolvable-valid] on one file fails fast with `[E_OP_ABORTED]`, but the same pair split across messages runs as two solos (fail, then apply). Calls with stale anchors join their file's batch through a `requirePath` path hint or a valid co-anchor and abort it instead of applying partially; a same-message sibling whose anchors resolve nowhere still aborts the batch when no other file is being edited. An error that aborts a batch ends with `Aborts batch N.`, while the abort itself reads `[E_OP_ABORTED] Batch N aborted.` Anchor capacity is preflighted before writing; if anchor finalization fails after the write, the error states the file was written with one undo available. Verify each batch diff before the next turn's edits on that file.
101
103
 
102
104
  ### insert
103
105
 
@@ -139,7 +141,7 @@ Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whicheve
139
141
 
140
142
  `undo_last_change` reverts the most recent successful `replace` or `insert` on a file, restoring the exact previous content, BOM and line endings included, plus the previous anchors.
141
143
 
142
- - History is per-file and single-level: only the most recent `replace` or `insert` can be reverted. A same-turn batch of `replace`/`insert` calls on one file counts as one entry: one undo reverts the whole batch.
144
+ - History is per-file and single-level: only the most recent `replace` or `insert` can be reverted. A same-message batch of `replace`/`insert` calls on one file counts as one entry: one undo reverts the whole batch.
143
145
  - History is persisted and survives session restarts. A failed `write` does not clear it.
144
146
  - Every applied `replace` or `insert` is undoable; the undo record is saved before the edit is written.
145
147
  - A successful `write` clears the history for that file.
@@ -151,7 +153,7 @@ Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whicheve
151
153
 
152
154
  Auto-read is enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block, so you get fresh `anchor│content` anchors without a separate `read` call.
153
155
 
154
- After `replace`, `insert`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-turn batch, only the batch's last call shows the combined diff, headed by a `batch:` line (`batch N:` when several files batch); earlier calls reply `In batch` (`In batch N` when several files batch). The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
156
+ After `replace`, `insert`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch:` line (`batch N:` when several files batch); earlier calls reply `In batch` (`In batch N` when several files batch). The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
155
157
 
156
158
  Auto-read keeps the same 50KB and 2000-line budget as `read`. Change it in `/hashline-config`; both settings persist across sessions. The post-edit diff shows 1 surrounding line by default; change Diff context in `/hashline-config` (0-10, needs Auto-read) to show more or fewer.
157
159
 
@@ -211,7 +213,7 @@ On POSIX systems, the state directory is restricted to mode `0700` and the SQLit
211
213
 
212
214
  ## Error and warning codes
213
215
 
214
- Codes starting with `E_` are errors (the operation failed); codes starting with `W_` are warnings (the operation succeeded with a notice).
216
+ Codes starting with `E_` are errors: nothing was written — except `File was written; anchor finalization failed`, which means the file was written and one undo reverts it. Codes starting with `W_` are warnings: the call succeeded with an auto-fix notice; check `classification` (`applied` vs `noop`) in `details.metrics` to tell whether bytes changed.
215
217
 
216
218
  | Code | Meaning |
217
219
  | --- | --- |
@@ -237,7 +239,7 @@ Codes starting with `E_` are errors (the operation failed); codes starting with
237
239
  | `[E_WRITE_HASH_ECHO]` | A `write` `content` line begins with the exact `anchor│` served for this file at the same line. The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
238
240
  | `[E_PATH_CHANGED]` | A write target changed identity after it was read; the write was refused to avoid following a swapped symlink or overwriting a replacement file. |
239
241
  | `[E_BATCH_OVERLAP]` | Batched `replace`/`insert` calls target overlapping ranges; the whole batch was refused. Retry with disjoint ranges. |
240
- | `[E_OP_ABORTED]` | An edit aborted (a same-turn batch member failed, or the file changed or was deleted after the edit started). Fix the sibling failure and retry the batch, otherwise call `read` for fresh anchors and retry. |
242
+ | `[E_OP_ABORTED]` | An edit aborted (a same-message batch member failed, or the file changed or was deleted after the edit started). Nothing was written. Fix the sibling failure and retry the batch, otherwise call `read` for fresh anchors and retry. |
241
243
  | `[E_UNSAFE_REGEX]` | A grep regex can trigger excessive backtracking; simplify it or search with `literal: true`. |
242
244
 
243
245
  ## Troubleshooting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.2.3",
3
+ "version": "4.2.4",
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,7 +1,7 @@
1
- import { chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "fs/promises";
2
- import { appendFileSync, chmodSync } from "fs";
3
- import { join } from "path";
4
- import { createHash } from "crypto";
1
+ import { chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { appendFileSync, chmodSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { createHash } from "node:crypto";
5
5
  import { sessionClaimsDir } from "./paths";
6
6
  import { contentChecksum } from "./hashline/hasher";
7
7
  import { ANCHOR_COUNT, anchorAt } from "./hashline/alphabet";
package/src/batch.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { readFile } from "fs/promises";
2
- import { constants } from "fs";
3
- import { relative } from "path";
1
+ import { readFile } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { relative } from "node:path";
4
4
  import { readConfig, getDiffContextLines } from "./config";
5
5
  import { resolveEditTarget, throwIfStrictInput, tryResolveEditTarget } from "./edit-common";
6
6
  import { readNormFile, safeSnapId } from "./file-reader";
@@ -656,6 +656,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
656
656
  },
657
657
  boundaryDedupAbove: [],
658
658
  boundaryDedupBelow: [],
659
+ spans,
659
660
  },
660
661
  batchVerb(runtime),
661
662
  await getDiffContextLines(),
package/src/commit.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readFile } from "fs/promises";
1
+ import { readFile } from "node:fs/promises";
2
2
  import type { PipelineResult } from "./replace";
3
3
  import { abortIf, errCode, splitLines } from "./utils";
4
4
  import { DEDUP_ANCHOR } from "./constants";
@@ -149,6 +149,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
149
149
  editMeta,
150
150
  boundaryDedupAbove: pipe.boundaryDedupAbove,
151
151
  boundaryDedupBelow: pipe.boundaryDedupBelow,
152
+ ...(span ? { spans: [{ start: span[0], end: span[1], replacementCount }] } : {}),
152
153
  };
153
154
  const changed = buildChanged(successInput, meta.verb, await getDiffContextLines());
154
155
  if (changed.details.diff) {
package/src/config.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { mkdir, readFile, rename, rm, stat } from "fs/promises";
2
- import { dirname } from "path";
1
+ import { mkdir, readFile, rename, rm, stat } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
3
  import { configPath } from "./paths";
4
4
  import { errCode, isRec } from "./utils";
5
5
  import { writeAtomic } from "./fs-write";
package/src/file-kind.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { open as fsOpen, stat as fsStat } from "fs/promises";
1
+ import { open as fsOpen, stat as fsStat } from "node:fs/promises";
2
2
  import { fileTypeFromBuffer } from "file-type";
3
3
  import { SNIFF_BYTES, MAX_BYTES } from "./constants";
4
4
  import { assertLineLimit, lineLimitMoreThanMessage } from "./utils";
@@ -1,6 +1,6 @@
1
- import { constants } from "fs";
2
- import { stat } from "fs/promises";
3
- import { relative } from "path";
1
+ import { constants } from "node:fs";
2
+ import { stat } from "node:fs/promises";
3
+ import { relative } from "node:path";
4
4
  import { lineHashes } from "./hashline";
5
5
  import { loadFileKindAndText, type LFile } from "./file-kind";
6
6
  import { resolveTarget, type FileIdentity } from "./fs-write";
package/src/fs-write.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { randomUUID } from "crypto";
2
- import { constants } from "fs";
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
3
  import {
4
4
  lstat,
5
5
  mkdir,
@@ -10,8 +10,8 @@ import {
10
10
  rm,
11
11
  stat,
12
12
  writeFile,
13
- } from "fs/promises";
14
- import { dirname, join, parse, resolve, sep } from "path";
13
+ } from "node:fs/promises";
14
+ import { dirname, join, parse, resolve, sep } from "node:path";
15
15
  import { toCwd } from "./paths";
16
16
  import { errCode } from "./utils";
17
17
 
package/src/grep.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
- import { stat } from "fs/promises";
5
- import { dirname, isAbsolute, join, relative, win32 } from "path";
6
- import { spawn, spawnSync } from "child_process";
7
- import { createInterface } from "readline";
4
+ import { stat } from "node:fs/promises";
5
+ import { dirname, isAbsolute, join, relative, win32 } from "node:path";
6
+ import { spawn, spawnSync } from "node:child_process";
7
+ import { createInterface } from "node:readline";
8
8
  import { tryReadNormFile } from "./file-reader";
9
9
  import { MAX_HASH_LINES, fmtRow, HASH_LEN, HASH_SEP } from "./hashline";
10
10
  import { ANCHOR_POOL_EXHAUSTED_PREFIX, MAX_GREP_LINE_BYTES } from "./constants";
@@ -292,8 +292,8 @@ async function resolveRgPath(): Promise<string> {
292
292
  if (!r.error && r.status === 0) { cachedRgPath = "rg"; return "rg"; }
293
293
  } catch {}
294
294
  try {
295
- const { homedir } = await import("os");
296
- const { existsSync } = await import("fs");
295
+ const { homedir } = await import("node:os");
296
+ const { existsSync } = await import("node:fs");
297
297
  const home = process.env.HOME ?? homedir();
298
298
  const base = process.env.PI_CODING_AGENT_DIR ?? join(home, ".pi", "agent");
299
299
  const bin = join(base, "bin", process.platform === "win32" ? "rg.exe" : "rg");
@@ -303,10 +303,10 @@ async function resolveRgPath(): Promise<string> {
303
303
  }
304
304
  } catch {}
305
305
  try {
306
- const { createRequire } = await import("module");
306
+ const { createRequire } = await import("node:module");
307
307
  const require = createRequire(import.meta.url);
308
308
  const pkgPath = require.resolve("@earendil-works/pi-coding-agent/package.json");
309
- const { dirname } = await import("path");
309
+ const { dirname } = await import("node:path");
310
310
  const piDir = dirname(pkgPath);
311
311
  const toolsManagerPath = join(piDir, "dist/utils/tools-manager.js");
312
312
  const mod = await import("file://" + toolsManagerPath);
package/src/hash-store.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { randomUUID } from "crypto";
2
- import { existsSync } from "fs";
3
- import { chmod, readFile, rename, mkdir, stat } from "fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { chmod, readFile, rename, mkdir, stat } from "node:fs/promises";
4
4
  import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
5
5
  import { errCode, isRec, splitLines } from "./utils";
6
6
  import { initHasher, contentChecksum } from "./hashline/hasher";
@@ -39,48 +39,10 @@ interface RawDb {
39
39
  close(): void;
40
40
  readonly isOpen: boolean;
41
41
  }
42
- export type SqliteEngine = "node:sqlite" | "bun:sqlite";
43
-
44
- interface BunDbLike {
45
- exec(sql: string): void;
46
- prepare(sql: string): {
47
- get(...params: SqlParams): unknown;
48
- all(...params: SqlParams): unknown[];
49
- run(...params: SqlParams): unknown;
50
- };
51
- close(): void;
52
- }
53
-
54
- let openDbFn: (path: string) => RawDb;
55
- let sqliteEngine: SqliteEngine;
56
-
57
- if (typeof process !== "undefined" && (process.versions as Record<string, string | undefined>).bun) {
58
- const specifier = "bun:sqlite";
59
- const mod = await import(specifier) as { Database: new (path: string) => BunDbLike };
60
- sqliteEngine = "bun:sqlite";
61
- openDbFn = (path) => {
62
- const db = new mod.Database(path);
63
- db.exec(`PRAGMA busy_timeout = ${HASH_STORE_BUSY_TIMEOUT}`);
64
- let closed = false;
65
- return {
66
- exec: (sql) => db.exec(sql),
67
- prepare: (sql) => {
68
- const stmt = db.prepare(sql);
69
- return {
70
- get: (...p) => stmt.get(...p) ?? undefined,
71
- all: (...p) => stmt.all(...p),
72
- run: (...p) => stmt.run(...p),
73
- };
74
- },
75
- close: () => { if (!closed) { closed = true; db.close(); } },
76
- get isOpen() { return !closed; },
77
- };
78
- };
79
- } else {
80
- const { DatabaseSync } = await import("node:sqlite");
81
- sqliteEngine = "node:sqlite";
82
- openDbFn = (path) => new DatabaseSync(path, { timeout: HASH_STORE_BUSY_TIMEOUT }) as unknown as RawDb;
83
- }
42
+ export type SqliteEngine = "node:sqlite";
43
+ const sqliteEngine: SqliteEngine = "node:sqlite";
44
+ const { DatabaseSync } = await import("node:sqlite");
45
+ const openDbFn = (path: string): RawDb => new DatabaseSync(path, { timeout: HASH_STORE_BUSY_TIMEOUT }) as unknown as RawDb;
84
46
 
85
47
  interface Prepared {
86
48
  get: (...params: SqlParams) => Record<string, unknown> | undefined;
package/src/insert.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
- import { constants } from "fs";
3
+ import { constants } from "node:fs";
4
4
  import { execPipeline, type ReqParams, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
5
5
  import { commitEdit } from "./commit";
6
6
  import { batchMemberFor, ensureBatchBase, executeBatchMember, noteBatchFailure, suffixPoisonCause } from "./batch";
package/src/paths.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { homedir } from "os";
2
- import { isAbsolute, resolve as resolvePath, join, dirname } from "path";
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, resolve as resolvePath, join, dirname } from "node:path";
3
3
 
4
4
 
5
5
  function homeBase(): string {
package/src/prompts.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "fs";
1
+ import { readFileSync } from "node:fs";
2
2
 
3
3
  export function loadP(relativePath: string): string {
4
4
  return readFileSync(new URL(relativePath, import.meta.url), "utf-8").trim();
@@ -18,6 +18,19 @@ import {
18
18
 
19
19
  export { detectEnding, toLF, restoreEndings, stripBOM, type LineEnding };
20
20
 
21
+ export interface DiffSpan {
22
+ start: number;
23
+ end: number;
24
+ replacementCount: number;
25
+ }
26
+
27
+ interface PlacedSpan {
28
+ oldStart: number;
29
+ oldEnd: number;
30
+ newStart: number;
31
+ newEnd: number;
32
+ }
33
+
21
34
  function fmtDiffLine(
22
35
  prefix: " " | "+" | "-",
23
36
  line: string,
@@ -43,6 +56,282 @@ export interface DiffLimits {
43
56
  unlimited?: boolean;
44
57
  }
45
58
 
59
+ export function spansFromHashes(oldHashes: string[], newHashes: string[]): DiffSpan[] {
60
+ const parts = Diff.diffArrays(oldHashes, newHashes) as unknown as Array<{ added?: boolean; removed?: boolean; count?: number; value?: string[] }>;
61
+ const spans: DiffSpan[] = [];
62
+ let oldIdx = 0;
63
+ let i = 0;
64
+ while (i < parts.length) {
65
+ const part = parts[i]!;
66
+ const isChange = part.added === true || part.removed === true;
67
+ if (!isChange) {
68
+ const count = part.count ?? part.value?.length ?? 0;
69
+ oldIdx += count;
70
+ i += 1;
71
+ continue;
72
+ }
73
+ const runStart = oldIdx;
74
+ let removedTotal = 0;
75
+ let addedTotal = 0;
76
+ while (i < parts.length) {
77
+ const runPart = parts[i]!;
78
+ const runChange = runPart.added === true || runPart.removed === true;
79
+ if (!runChange) break;
80
+ const count = runPart.count ?? runPart.value?.length ?? 0;
81
+ if (runPart.removed === true && runPart.added !== true) {
82
+ removedTotal += count;
83
+ oldIdx += count;
84
+ } else if (runPart.added === true && runPart.removed !== true) {
85
+ addedTotal += count;
86
+ } else {
87
+ removedTotal += count;
88
+ addedTotal += count;
89
+ oldIdx += count;
90
+ }
91
+ i += 1;
92
+ }
93
+ spans.push({
94
+ start: runStart,
95
+ end: runStart + removedTotal - 1,
96
+ replacementCount: addedTotal,
97
+ });
98
+ }
99
+ return spans;
100
+ }
101
+
102
+ const ANCHORED_DIFF_ROW_RE = /^([+ -])([A-Za-z0-9]{4})│/;
103
+
104
+ export function disambiguateDuplicateAnchors(diff: string): string {
105
+ if (!diff.includes("│")) return diff;
106
+ const live = new Set<string>();
107
+ const lines = diff.split("\n");
108
+ for (const line of lines) {
109
+ const match = ANCHORED_DIFF_ROW_RE.exec(line);
110
+ if (!match) continue;
111
+ const prefix = match[1]!;
112
+ const anchor = match[2]!;
113
+ if (prefix === "+" || prefix === " ") live.add(anchor);
114
+ }
115
+ if (live.size === 0) return diff;
116
+ let changed = false;
117
+ const out = lines.map((line) => {
118
+ const match = ANCHORED_DIFF_ROW_RE.exec(line);
119
+ if (!match || match[1] !== "-") return line;
120
+ if (!live.has(match[2]!)) return line;
121
+ changed = true;
122
+ return `- │${line.slice(match[0].length)}`;
123
+ });
124
+ return changed ? out.join("\n") : diff;
125
+ }
126
+
127
+ function placeSpans(
128
+ oldLen: number,
129
+ newLen: number,
130
+ spans: DiffSpan[],
131
+ ): PlacedSpan[] | undefined {
132
+ const sorted = [...spans].sort((a, b) => a.start - b.start || a.end - b.end);
133
+ for (const span of sorted) {
134
+ if (!Number.isInteger(span.start) || !Number.isInteger(span.end) || !Number.isInteger(span.replacementCount)) return undefined;
135
+ if (span.replacementCount < 0) return undefined;
136
+ if (span.start > span.end) {
137
+ if (span.end !== span.start - 1) return undefined;
138
+ if (span.start < 0 || span.start > oldLen) return undefined;
139
+ } else {
140
+ if (span.start < 0 || span.end >= oldLen) return undefined;
141
+ }
142
+ }
143
+ for (let i = 1; i < sorted.length; i++) {
144
+ if (sorted[i]!.start <= sorted[i - 1]!.end) return undefined;
145
+ }
146
+ const placed: PlacedSpan[] = [];
147
+ let offset = 0;
148
+ let removedSum = 0;
149
+ let addedSum = 0;
150
+ for (const span of sorted) {
151
+ const spanLen = Math.max(0, span.end - span.start + 1);
152
+ const newStart = span.start + offset;
153
+ const newEnd = newStart + span.replacementCount - 1;
154
+ if (newStart < 0 || newStart > newLen) return undefined;
155
+ if (span.replacementCount === 0) {
156
+ if (newEnd !== newStart - 1) return undefined;
157
+ } else if (newEnd >= newLen) return undefined;
158
+ placed.push({ oldStart: span.start, oldEnd: span.end, newStart, newEnd });
159
+ offset += span.replacementCount - spanLen;
160
+ removedSum += spanLen;
161
+ addedSum += span.replacementCount;
162
+ }
163
+ if (oldLen - removedSum + addedSum !== newLen) return undefined;
164
+ return placed;
165
+ }
166
+
167
+ function trimPlacedSpans(
168
+ placed: PlacedSpan[],
169
+ oldLines: string[],
170
+ newLines: string[],
171
+ ): PlacedSpan[] {
172
+ const out: PlacedSpan[] = [];
173
+ for (const span of placed) {
174
+ let oldStart = span.oldStart;
175
+ let oldEnd = span.oldEnd;
176
+ let newStart = span.newStart;
177
+ let newEnd = span.newEnd;
178
+ while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldStart] === newLines[newStart]) {
179
+ oldStart += 1;
180
+ newStart += 1;
181
+ }
182
+ while (oldStart <= oldEnd && newStart <= newEnd && oldLines[oldEnd] === newLines[newEnd]) {
183
+ oldEnd -= 1;
184
+ newEnd -= 1;
185
+ }
186
+ if (oldStart > oldEnd && newStart > newEnd) continue;
187
+ out.push({ oldStart, oldEnd, newStart, newEnd });
188
+ }
189
+ return out;
190
+ }
191
+
192
+ function genSpanDiff(
193
+ oldContent: string,
194
+ newContent: string,
195
+ contextLines: number,
196
+ newHashes: string[],
197
+ oldHashes: string[],
198
+ maxLineBytes: number,
199
+ maxBytes: number,
200
+ spans: DiffSpan[],
201
+ ): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number | undefined)[] } | undefined {
202
+ const oldLines = splitLines(oldContent);
203
+ const newLines = splitLines(newContent);
204
+ if (oldHashes.length !== oldLines.length || newHashes.length !== newLines.length) return undefined;
205
+ const placed = placeSpans(oldLines.length, newLines.length, spans);
206
+ if (!placed) return undefined;
207
+ const trimmed = trimPlacedSpans(placed, oldLines, newLines);
208
+ const ordered = [...trimmed].sort((a, b) => a.oldStart - b.oldStart || a.newStart - b.newStart);
209
+ let oldPos = 0;
210
+ let newPos = 0;
211
+ for (const span of ordered) {
212
+ if (span.oldStart < oldPos || span.newStart < newPos) return undefined;
213
+ const gapOld = span.oldStart - oldPos;
214
+ const gapNew = span.newStart - newPos;
215
+ if (gapOld !== gapNew || gapOld < 0) return undefined;
216
+ oldPos = span.oldEnd + 1;
217
+ newPos = span.newEnd + 1;
218
+ }
219
+ if (oldLines.length - ordered.reduce((sum, span) => sum + Math.max(0, span.oldEnd - span.oldStart + 1), 0) + ordered.reduce((sum, span) => sum + Math.max(0, span.newEnd - span.newStart + 1), 0) !== newLines.length) {
220
+ return undefined;
221
+ }
222
+ const output: string[] = [];
223
+ const lineNumbers: (number | undefined)[] = [];
224
+ let firstChangedLine: number | undefined;
225
+ let outBytes = 0;
226
+ let stopped = false;
227
+ let diffTruncated = false;
228
+ const emitPlain = (line: string, num?: number): void => {
229
+ if (stopped) return;
230
+ const lineBytes = Buffer.byteLength(line, "utf-8") + 1;
231
+ if (outBytes + lineBytes > maxBytes) {
232
+ stopped = true;
233
+ diffTruncated = true;
234
+ return;
235
+ }
236
+ outBytes += lineBytes;
237
+ output.push(line);
238
+ lineNumbers.push(num);
239
+ };
240
+ const emitRow = (prefix: " " | "+" | "-", line: string, hash: string | undefined, num?: number): void => {
241
+ if (stopped) return;
242
+ const full = fmtDiffLine(prefix, line, hash);
243
+ const rowBytes = Buffer.byteLength(full, "utf-8");
244
+ if (rowBytes > maxLineBytes) {
245
+ const marker = `[Row is ${formatSize(rowBytes)}, exceeds ${formatSize(maxLineBytes)}; content not shown. Use read to see the full line.]`;
246
+ emitPlain(fmtDiffLine(prefix, marker, hash), num);
247
+ return;
248
+ }
249
+ if (outBytes + rowBytes + 1 > maxBytes) {
250
+ stopped = true;
251
+ diffTruncated = true;
252
+ return;
253
+ }
254
+ outBytes += rowBytes + 1;
255
+ output.push(full);
256
+ lineNumbers.push(num);
257
+ };
258
+ const emitGap = (gapStartNew: number, gapLen: number, position: "leading" | "middle" | "trailing"): void => {
259
+ if (stopped || gapLen <= 0) return;
260
+ if (position === "leading") {
261
+ let count = contextLines;
262
+ if (contextLines > 0 && gapLen > count && isBlankLine(newLines[gapStartNew + gapLen - 1]!)) count += 1;
263
+ count = Math.min(count, gapLen);
264
+ const skipStart = gapLen - count;
265
+ if (skipStart > 0) emitPlain(" ...", undefined);
266
+ for (let k = gapLen - count; k < gapLen && !stopped; k++) {
267
+ const newIdx = gapStartNew + k;
268
+ emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
269
+ }
270
+ return;
271
+ }
272
+ if (position === "trailing") {
273
+ let count = contextLines;
274
+ if (contextLines > 0 && gapLen > count && isBlankLine(newLines[gapStartNew]!)) count += 1;
275
+ count = Math.min(count, gapLen);
276
+ for (let k = 0; k < count && !stopped; k++) {
277
+ const newIdx = gapStartNew + k;
278
+ emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
279
+ }
280
+ if (gapLen > count) emitPlain(" ...", undefined);
281
+ return;
282
+ }
283
+ let headCount = contextLines;
284
+ let tailCount = contextLines;
285
+ if (contextLines > 0 && gapLen - headCount > tailCount && isBlankLine(newLines[gapStartNew + headCount - 1]!)) headCount += 1;
286
+ if (contextLines > 0 && gapLen - tailCount > headCount && isBlankLine(newLines[gapStartNew + gapLen - tailCount]!)) tailCount += 1;
287
+ const middleLen = gapLen - headCount - tailCount;
288
+ if (middleLen <= 0) {
289
+ for (let k = 0; k < gapLen && !stopped; k++) {
290
+ const newIdx = gapStartNew + k;
291
+ emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
292
+ }
293
+ return;
294
+ }
295
+ for (let k = 0; k < headCount && !stopped; k++) {
296
+ const newIdx = gapStartNew + k;
297
+ emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
298
+ }
299
+ if (!stopped) emitPlain(" ...", undefined);
300
+ for (let k = gapLen - tailCount; k < gapLen && !stopped; k++) {
301
+ const newIdx = gapStartNew + k;
302
+ emitRow(" ", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
303
+ }
304
+ };
305
+ oldPos = 0;
306
+ newPos = 0;
307
+ for (let spanIdx = 0; spanIdx < ordered.length && !stopped; spanIdx++) {
308
+ const span = ordered[spanIdx]!;
309
+ const gapLen = span.oldStart - oldPos;
310
+ emitGap(newPos, gapLen, spanIdx === 0 ? "leading" : "middle");
311
+ if (stopped) break;
312
+ if (firstChangedLine === undefined) firstChangedLine = span.newStart + 1;
313
+ for (let oldIdx = span.oldStart; oldIdx <= span.oldEnd && !stopped; oldIdx++) {
314
+ emitRow("-", oldLines[oldIdx]!, oldHashes[oldIdx], oldIdx + 1);
315
+ }
316
+ for (let newIdx = span.newStart; newIdx <= span.newEnd && !stopped; newIdx++) {
317
+ emitRow("+", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
318
+ }
319
+ oldPos = span.oldEnd + 1;
320
+ newPos = span.newEnd + 1;
321
+ }
322
+ if (!stopped) {
323
+ const trailingLen = oldLines.length - oldPos;
324
+ emitGap(newPos, trailingLen, "trailing");
325
+ }
326
+ if (diffTruncated) {
327
+ output.push(" ...");
328
+ lineNumbers.push(undefined);
329
+ output.push(`[diff truncated at ${formatSize(maxBytes)}; use read to see the rest.]`);
330
+ lineNumbers.push(undefined);
331
+ }
332
+ return { diff: output.join("\n"), firstChangedLine, lineNumbers };
333
+ }
334
+
46
335
  export function genDiff(
47
336
  oldContent: string,
48
337
  newContent: string,
@@ -50,9 +339,17 @@ export function genDiff(
50
339
  newContentHashes?: string[],
51
340
  oldContentHashes?: string[],
52
341
  limits?: DiffLimits,
342
+ spans?: DiffSpan[],
53
343
  ): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number|undefined)[] } {
54
344
  const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
55
345
  const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
346
+ if (spans && newContentHashes && oldContentHashes) {
347
+ const anchored = genSpanDiff(oldContent, newContent, contextLines, newContentHashes, oldContentHashes, maxLineBytes, maxBytes, spans);
348
+ if (anchored) {
349
+ const diff = disambiguateDuplicateAnchors(anchored.diff);
350
+ return { diff, firstChangedLine: anchored.firstChangedLine, lineNumbers: anchored.lineNumbers };
351
+ }
352
+ }
56
353
  if (!limits?.unlimited && Buffer.byteLength(oldContent, "utf-8") + Buffer.byteLength(newContent, "utf-8") > MAX_DIFF_INPUT_BYTES) {
57
354
  const guardedRange = changedRange(oldContent, newContent);
58
355
  const guardNote = `[diff truncated at ${formatSize(maxBytes)}; use read to see the rest.]`;
@@ -105,7 +402,7 @@ export function genDiff(
105
402
  guardedNumbers.push(undefined);
106
403
  guarded.push(guardNote);
107
404
  guardedNumbers.push(undefined);
108
- return { diff: guarded.join("\n"), firstChangedLine: first, lineNumbers: guardedNumbers };
405
+ return { diff: disambiguateDuplicateAnchors(guarded.join("\n")), firstChangedLine: first, lineNumbers: guardedNumbers };
109
406
  }
110
407
  const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
111
408
 
@@ -118,9 +415,9 @@ export function genDiff(
118
415
  let outBytes = 0;
119
416
  let stopped = false;
120
417
  let diffTruncated = false;
121
- const lineNumbers: (number|undefined)[] = [];
418
+ const lineNumbers: (number|undefined)[] = [];
122
419
 
123
- const emitPlain = (line: string, num?: number): void => {
420
+ const emitPlain = (line: string, num?: number): void => {
124
421
  if (stopped) return;
125
422
  const lineBytes = Buffer.byteLength(line, "utf-8") + 1;
126
423
  if (outBytes + lineBytes > maxBytes) {
@@ -133,7 +430,7 @@ export function genDiff(
133
430
  lineNumbers.push(num);
134
431
  };
135
432
 
136
- const emitRow = (prefix: " " | "+" | "-", line: string, hash: string | undefined, num?: number): void => {
433
+ const emitRow = (prefix: " " | "+" | "-", line: string, hash: string | undefined, num?: number): void => {
137
434
  if (stopped) return;
138
435
  const full = fmtDiffLine(prefix, line, hash);
139
436
  const rowBytes = Buffer.byteLength(full, "utf-8");
@@ -270,7 +567,8 @@ export function genDiff(
270
567
  lineNumbers.push(undefined);
271
568
  }
272
569
 
273
- return { diff: output.join("\n"), firstChangedLine, lineNumbers };
570
+ const diff = newContentHashes && oldContentHashes ? disambiguateDuplicateAnchors(output.join("\n")) : output.join("\n");
571
+ return { diff, firstChangedLine, lineNumbers };
274
572
  }
275
573
 
276
574
  export function genPatch(
@@ -279,10 +577,10 @@ export function genPatch(
279
577
  newContent: string,
280
578
  limits?: DiffLimits,
281
579
  ): { patch: string; truncated: boolean } {
282
- const patchOpts: Record<string, unknown> = { context: 4 };
283
- const ho = (Diff as unknown as Record<string, unknown>).FILE_HEADERS_ONLY;
284
- if (ho !== undefined) patchOpts.headerOptions = ho;
285
- const full = (Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, patchOpts as never) as unknown as string) ?? "";
580
+ const patchOpts: Record<string, unknown> = { context: 4 };
581
+ const ho = (Diff as unknown as Record<string, unknown>).FILE_HEADERS_ONLY;
582
+ if (ho !== undefined) patchOpts.headerOptions = ho;
583
+ const full = (Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, patchOpts as never) as unknown as string) ?? "";
286
584
  const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
287
585
  const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
288
586
  const out: string[] = [];
@@ -2,7 +2,7 @@ import { formatSize, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
2
2
  import type { NEdit } from "./hashline";
3
3
  import { HASH_SEP } from "./hashline";
4
4
  import type { ReplaceDetails } from "./replace";
5
- import { genDiff, genPatch } from "./replace-diff";
5
+ import { genDiff, genPatch, type DiffSpan } from "./replace-diff";
6
6
  import { visLines, clipLine } from "./utils";
7
7
  import { DEDUP_ANCHOR } from "./constants";
8
8
 
@@ -51,6 +51,7 @@ export interface SuccessInput {
51
51
  editMeta: RMeta;
52
52
  boundaryDedupAbove?: string[];
53
53
  boundaryDedupBelow?: string[];
54
+ spans?: DiffSpan[];
54
55
  }
55
56
 
56
57
 
@@ -171,9 +172,9 @@ export function withDedupRows(diff: string, lineNumbers: (number | undefined)[],
171
172
  }
172
173
 
173
174
  export function buildChanged(input: SuccessInput, verb = "replaced", diffContextLines = 1): TResult {
174
- const { path, result, warnings, snapshotId, originalNormalized, originalHashes, editMeta, resultHashes, boundaryDedupAbove, boundaryDedupBelow } = input;
175
+ const { path, result, warnings, snapshotId, originalNormalized, originalHashes, editMeta, resultHashes, boundaryDedupAbove, boundaryDedupBelow, spans } = input;
175
176
  const resultLines = visLines(result);
176
- const baseDiff = genDiff(originalNormalized, result, diffContextLines, resultHashes, originalHashes);
177
+ const baseDiff = genDiff(originalNormalized, result, diffContextLines, resultHashes, originalHashes, undefined, spans);
177
178
  const diffResult = withDedupRows(baseDiff.diff, baseDiff.lineNumbers, boundaryDedupAbove, boundaryDedupBelow);
178
179
  const addedLines = editMeta.addedLines;
179
180
  const removedLines = editMeta.removedLines;
@@ -1,5 +1,5 @@
1
- import { constants } from "fs";
2
- import { open } from "fs/promises";
1
+ import { constants } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
5
5
  import { Type } from "typebox";
@@ -10,7 +10,7 @@ import { hashSource } from "./hashline";
10
10
  import { markServed as markServedScoped, freeAnchors, adoptAnchors } from "./anchor-registry";
11
11
  import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
12
12
  import { toLF, stripBOM, restoreEndings, type LineEnding } from "./normalize";
13
- import { genDiff, genPatch } from "./replace-diff";
13
+ import { genDiff, genPatch, spansFromHashes } from "./replace-diff";
14
14
  import { getDiffContextLines } from "./config";
15
15
  import { cntDiff, errCode, makePrepareArguments, splitLines } from "./utils";
16
16
  import { loadP, loadGuide } from "./prompts";
@@ -179,7 +179,8 @@ export function regUndo(pi: ExtensionAPI): void {
179
179
  const linesAddedByReplace = cntDiff(diffResult.diff, "+");
180
180
  const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
181
181
  const restoredRange = changedRange(currentNormalized, undo.content);
182
- const undoDiffResult = genDiff(currentNormalized, undo.content, await getDiffContextLines(), undo.hashes, currentHashes);
182
+ const undoSpans = spansFromHashes(currentHashes, undo.hashes);
183
+ const undoDiffResult = genDiff(currentNormalized, undo.content, await getDiffContextLines(), undo.hashes, currentHashes, undefined, undoSpans);
183
184
  const undoDiff = undoDiffResult.diff;
184
185
 
185
186
  try {
package/src/replace.ts CHANGED
@@ -2,15 +2,16 @@ import type {
2
2
  ExtensionAPI,
3
3
  ToolDefinition,
4
4
  } from "@earendil-works/pi-coding-agent";
5
- import { constants } from "fs";
6
- import { relative } from "path";
5
+ import { constants } from "node:fs";
6
+ import { relative } from "node:path";
7
7
  import {
8
8
  genDiff,
9
+ type DiffSpan,
9
10
  type LineEnding,
10
11
  } from "./replace-diff";
11
12
  import { readNormFile, type NormFile } from "./file-reader";
12
13
  import { editToolSchema, buildEditToolSchema, type ReqParams, assertReq, normReq } from "./payload-contract";
13
- import { decodeStringArray } from "./utils";
14
+ import { decodeStringArray, splitLines } from "./utils";
14
15
  import { loadP, loadGuide } from "./prompts";
15
16
  import { type FileIdentity } from "./fs-write";
16
17
  import { applyEdit,
@@ -72,6 +73,7 @@ export interface PipelineResult {
72
73
  boundaryDedupAbove: string[];
73
74
  boundaryDedupBelow: string[];
74
75
  identity: FileIdentity;
76
+ spans?: DiffSpan[];
75
77
  }
76
78
 
77
79
 
@@ -187,6 +189,9 @@ export async function execPipeline(
187
189
  const sortedFixes = [...(anchorResult.autoFixes ?? [])].sort((a, b) => a.removedLineIndex - b.removedLineIndex);
188
190
  const aboveFixes = sortedFixes.filter((fix) => fix.kind === "leading" || fix.kind === "last-new-before");
189
191
  const belowFixes = sortedFixes.filter((fix) => fix.kind === "trailing" || fix.kind === "first-new-after");
192
+ const pipeSpan = isNoop ? undefined : hashSpan(originalHashes, edit.hash_bounds[0].hash, edit.hash_bounds[1].hash);
193
+ const pipeResultCount = splitLines(result).length;
194
+ const pipeSpans = pipeSpan ? [{ start: pipeSpan[0], end: pipeSpan[1], replacementCount: pipeResultCount - (originalHashes.length - (pipeSpan[1] - pipeSpan[0] + 1)) }] : undefined;
190
195
  return {
191
196
  path: displayPath,
192
197
  originalNormalized,
@@ -208,6 +213,7 @@ export async function execPipeline(
208
213
  boundaryDedupAbove: aboveFixes.map((fix) => fix.removedLine),
209
214
  boundaryDedupBelow: belowFixes.map((fix) => fix.removedLine),
210
215
  identity,
216
+ ...(pipeSpans ? { spans: pipeSpans } : {}),
211
217
  };
212
218
  }
213
219
 
@@ -218,7 +224,7 @@ export function previewFromPipe(pipe: PipelineResult): RPreview {
218
224
  path: pipe.path,
219
225
  };
220
226
  }
221
- const base = genDiff(pipe.originalNormalized, pipe.result, 4, pipe.resultHashes, pipe.originalHashes);
227
+ const base = genDiff(pipe.originalNormalized, pipe.result, 4, pipe.resultHashes, pipe.originalHashes, undefined, pipe.spans);
222
228
  return { diff: withDedupRows(base.diff, base.lineNumbers, pipe.boundaryDedupAbove, pipe.boundaryDedupBelow).diff, path: pipe.path };
223
229
  }
224
230
  export function previewError(error: unknown): RPreview {
package/src/validation.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { constants } from "fs";
2
- import { access as fsAccess } from "fs/promises";
1
+ import { constants } from "node:fs";
2
+ import { access as fsAccess } from "node:fs/promises";
3
3
  import type { LFile } from "./file-kind";
4
4
  import type { FileIdentity } from "./fs-write";
5
5
  import { errCode } from "./utils";