pi-hashline-edit-pro 4.2.3 → 4.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/package.json +1 -1
- package/src/anchor-registry.ts +8 -8
- package/src/batch.ts +4 -3
- package/src/commit.ts +2 -1
- package/src/config.ts +5 -5
- package/src/file-kind.ts +1 -1
- package/src/file-reader.ts +3 -3
- package/src/fs-write.ts +4 -4
- package/src/grep.ts +8 -8
- package/src/hash-store.ts +7 -45
- package/src/insert.ts +1 -1
- package/src/paths.ts +2 -2
- package/src/prompts.ts +1 -1
- package/src/replace-diff.ts +307 -9
- package/src/replace-response.ts +4 -3
- package/src/replace-undo.ts +5 -4
- package/src/replace.ts +10 -4
- package/src/validation.ts +2 -2
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
|
|
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-
|
|
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-
|
|
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
|
|
|
@@ -190,7 +192,7 @@ On non-Windows platforms the directory honors `XDG_CONFIG_HOME` when set (fallin
|
|
|
190
192
|
|
|
191
193
|
## How anchors work
|
|
192
194
|
|
|
193
|
-
Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a large stride (roughly the golden ratio of the anchor space) coprime to it, so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, or you ran `/clear-anchors`). Minting prefers anchors the session has never used; when every unused anchor has been spent, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: at most 1,353,139 lines per file, beyond which `read`, `replace`, and `insert` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
|
|
195
|
+
Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a large stride (roughly the golden ratio of the anchor space) coprime to it, so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint disjoint sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, or you ran `/clear-anchors`). Minting prefers anchors the session has never used; when every unused anchor has been spent, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: at most 1,353,139 lines per file, beyond which `read`, `replace`, and `insert` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
|
|
194
196
|
|
|
195
197
|
The table is curated for tokenizers, not for humans. Every anchor is the concatenation of two 2-character pieces that each encode as a single token, and beside the `│` separator the whole 5-character `anchor│` unit is verified to tokenize as exactly three tokens in each of eight modern open-weights tokenizers (Qwen 3.5, DeepSeek V4, Gemma 4, GLM 5.3 Flash, Tencent Hy4-preview, MiniMax M3, MiMo V2.5, Kimi K3). The shipped table is the intersection that satisfies the criterion on all of them; Nemotron 3 Ultra is the one modern tokenizer excluded. An anchor therefore costs 2 tokens on a read row and 2 in an edit call, with the `│` separator as the third. Anchors are letters only. The table is shipped as `src/hashline/anchor-table.json`.
|
|
196
198
|
|
|
@@ -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
|
|
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-
|
|
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
|
+
"version": "4.2.5",
|
|
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",
|
package/src/anchor-registry.ts
CHANGED
|
@@ -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";
|
|
@@ -60,8 +60,8 @@ function seedServedFromOwned(state: SessionState): void {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
export function foldRegistryEvents(events: RegistryEvent[]): SessionState {
|
|
64
|
-
const state = newSessionState();
|
|
63
|
+
export function foldRegistryEvents(events: RegistryEvent[], seed?: string): SessionState {
|
|
64
|
+
const state = newSessionState(seed);
|
|
65
65
|
for (const event of events) {
|
|
66
66
|
if (event.kind === "clear") {
|
|
67
67
|
state.owned.clear();
|
|
@@ -157,7 +157,7 @@ export async function initRegistry(sessionFile: string | undefined): Promise<voi
|
|
|
157
157
|
if (!sessionFile) {
|
|
158
158
|
currentKey = `__ephemeral__-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
159
159
|
currentSidecar = undefined;
|
|
160
|
-
registries.set(currentKey, newSessionState());
|
|
160
|
+
registries.set(currentKey, newSessionState(currentKey));
|
|
161
161
|
return;
|
|
162
162
|
}
|
|
163
163
|
const key = sidecarKeyFor(sessionFile);
|
|
@@ -173,7 +173,7 @@ export async function initRegistry(sessionFile: string | undefined): Promise<voi
|
|
|
173
173
|
console.error("Failed to read anchor registry sidecar:", error);
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
|
-
const folded = foldRegistryEvents(events);
|
|
176
|
+
const folded = foldRegistryEvents(events, `${key}:${process.pid}`);
|
|
177
177
|
seedServedFromOwned(folded);
|
|
178
178
|
registries.set(key, folded);
|
|
179
179
|
if (rawLog.length > 0) {
|
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";
|
|
@@ -46,10 +46,10 @@ export function normalizeDiffContextLines(value: unknown): number {
|
|
|
46
46
|
|
|
47
47
|
function parseConfig(content: string): Config {
|
|
48
48
|
const parsed = JSON.parse(content) as unknown;
|
|
49
|
-
|
|
50
|
-
if (typeof autoRead !== "boolean") {
|
|
49
|
+
if (!isRec(parsed) || (parsed.autoRead !== undefined && typeof parsed.autoRead !== "boolean")) {
|
|
51
50
|
throw new Error("config.json must be an object with a boolean autoRead field");
|
|
52
51
|
}
|
|
52
|
+
const autoRead = parsed.autoRead;
|
|
53
53
|
const anchorGrepEnabled = isRec(parsed) ? parsed.anchorGrepEnabled : undefined;
|
|
54
54
|
const requirePath = isRec(parsed) ? parsed.requirePath : undefined;
|
|
55
55
|
const strictInput = isRec(parsed) ? parsed.strictInput : undefined;
|
|
@@ -57,7 +57,7 @@ function parseConfig(content: string): Config {
|
|
|
57
57
|
const legacyBoundaryDedup = isRec(parsed) ? parsed.boundaryDedupEnabled : undefined;
|
|
58
58
|
const diffContextLines = isRec(parsed) ? parsed.diffContextLines : undefined;
|
|
59
59
|
return {
|
|
60
|
-
autoRead,
|
|
60
|
+
autoRead: typeof autoRead === "boolean" ? autoRead : DEFAULT_CONFIG.autoRead,
|
|
61
61
|
anchorGrepEnabled: typeof anchorGrepEnabled === "boolean" ? anchorGrepEnabled : DEFAULT_CONFIG.anchorGrepEnabled,
|
|
62
62
|
requirePath: typeof requirePath === "boolean" ? requirePath : DEFAULT_CONFIG.requirePath,
|
|
63
63
|
strictInput: typeof strictInput === "boolean" ? strictInput : DEFAULT_CONFIG.strictInput,
|
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";
|
package/src/file-reader.ts
CHANGED
|
@@ -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"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
package/src/prompts.ts
CHANGED
package/src/replace-diff.ts
CHANGED
|
@@ -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
|
-
|
|
418
|
+
const lineNumbers: (number|undefined)[] = [];
|
|
122
419
|
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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[] = [];
|
package/src/replace-response.ts
CHANGED
|
@@ -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;
|
package/src/replace-undo.ts
CHANGED
|
@@ -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
|
|
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";
|