@d3ara1n/pi-hashline-edit 0.1.1 → 0.1.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/README.md +15 -13
- package/package.json +1 -1
- package/src/core/apply.test.ts +80 -92
- package/src/core/apply.ts +54 -46
- package/src/core/hash.test.ts +16 -11
- package/src/core/hash.ts +30 -57
- package/src/core/index.ts +1 -5
- package/src/core/lines.test.ts +30 -0
- package/src/core/lines.ts +42 -0
- package/src/core/types.ts +11 -30
- package/src/index.ts +7 -10
- package/src/pi/edit-tool.ts +124 -106
- package/src/pi/execute.test.ts +193 -47
- package/src/pi/pi.test.ts +1 -20
- package/src/pi/read-tool.ts +12 -15
- package/src/pi/state.ts +4 -58
- package/src/core/diff.test.ts +0 -21
- package/src/core/diff.ts +0 -41
- package/src/core/parse.test.ts +0 -117
- package/src/core/parse.ts +0 -166
- package/src/core/snapshot.test.ts +0 -60
- package/src/core/snapshot.ts +0 -84
package/src/core/hash.ts
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-line
|
|
2
|
+
* Per-line content hash.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* The hash mixes the 1-based line number into the line content, so every line
|
|
5
|
+
* gets a unique hash by construction — line numbers are unique, therefore no
|
|
6
|
+
* in-file collision is possible, and no length extension / fallback is ever
|
|
7
|
+
* needed.
|
|
8
|
+
*
|
|
9
|
+
* Why line + content (not content alone, not content + neighbors):
|
|
10
|
+
*
|
|
11
|
+
* - The line number is the address; the hash is a checksum that the line at
|
|
12
|
+
* that address is still what was read. Mixing the number in makes the hash a
|
|
13
|
+
* pure fingerprint of (position, content): it changes only when the line's
|
|
14
|
+
* own content changes, never when a neighbor changes. (A neighbor-aware hash
|
|
15
|
+
* would change an unchanged line's hash when an adjacent line is edited — a
|
|
16
|
+
* spurious dependency with no benefit under this design's position-fixed
|
|
17
|
+
* apply.)
|
|
18
|
+
* - Content alone would leave identical lines (blank lines, `}`) sharing a
|
|
19
|
+
* hash; mixing the line number disambiguates them for free.
|
|
20
|
+
*
|
|
21
|
+
* Drift (file changed since read) is caught up-front by the global stale check
|
|
22
|
+
* (`text !== snapshot.text`). This hash's job is to verify the model actually
|
|
23
|
+
* read the line — it cannot forge a `(line, content)` hash without reading.
|
|
9
24
|
*
|
|
10
25
|
* @module pi-hashline-edit/core
|
|
11
26
|
*/
|
|
@@ -38,62 +53,20 @@ function toBase32(n: number, len: number): string {
|
|
|
38
53
|
}
|
|
39
54
|
|
|
40
55
|
/**
|
|
41
|
-
* Compute the
|
|
56
|
+
* Compute the hash of a single line from its 1-based line number and content.
|
|
42
57
|
*
|
|
43
|
-
* @param
|
|
44
|
-
* @param
|
|
45
|
-
* @param
|
|
46
|
-
* @param len hash length (default 4, 20 bits ≈ 1M values)
|
|
58
|
+
* @param line 1-based line number
|
|
59
|
+
* @param content the line's content (no line terminator)
|
|
60
|
+
* @param len hash length (default 4, 20 bits ≈ 1M values)
|
|
47
61
|
*/
|
|
48
|
-
export function computeLineHash(
|
|
49
|
-
|
|
50
|
-
return toBase32(h, len);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Compute raw per-line hashes (no collision handling). */
|
|
54
|
-
function rawHashes(lines: readonly string[], len: number): string[] {
|
|
55
|
-
return lines.map((line, i) =>
|
|
56
|
-
computeLineHash(lines[i - 1] ?? "", line, lines[i + 1] ?? "", len),
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Find hash values that appear more than once. */
|
|
61
|
-
function duplicatedHashes(hashes: readonly string[]): Set<string> {
|
|
62
|
-
const counts = new Map<string, number>();
|
|
63
|
-
for (const h of hashes) counts.set(h, (counts.get(h) ?? 0) + 1);
|
|
64
|
-
return new Set([...counts.entries()].filter(([, c]) => c > 1).map(([h]) => h));
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Fallback: force uniqueness with an index suffix (theoretically unreachable under context-aware hashing). */
|
|
68
|
-
function forceUnique(hashes: string[]): string[] {
|
|
69
|
-
const seen = new Map<string, number>();
|
|
70
|
-
return hashes.map((h) => {
|
|
71
|
-
const c = seen.get(h) ?? 0;
|
|
72
|
-
seen.set(h, c + 1);
|
|
73
|
-
return c === 0 ? h : `${h}${c}`;
|
|
74
|
-
});
|
|
62
|
+
export function computeLineHash(line: number, content: string, len = 4): string {
|
|
63
|
+
return toBase32(fnv1a32(`${line}\n${content}`), len);
|
|
75
64
|
}
|
|
76
65
|
|
|
77
66
|
/**
|
|
78
|
-
* Compute per-line hashes for
|
|
79
|
-
*
|
|
80
|
-
* (per-file zero-collision guarantee).
|
|
81
|
-
*
|
|
82
|
-
* Design rationale: context-aware hashing already drives the collision
|
|
83
|
-
* probability near zero; this extension is a defensive fallback guaranteeing
|
|
84
|
-
* apply never mislocates due to hash ambiguity.
|
|
67
|
+
* Compute per-line hashes for a file. Unique by construction — the 1-based line
|
|
68
|
+
* number is part of each hash, so two identical content lines always differ.
|
|
85
69
|
*/
|
|
86
70
|
export function hashFileLines(lines: readonly string[], len = 4): string[] {
|
|
87
|
-
|
|
88
|
-
let hashes = rawHashes(lines, len);
|
|
89
|
-
for (let curLen = len; curLen <= len + 4; curLen++) {
|
|
90
|
-
const dups = duplicatedHashes(hashes);
|
|
91
|
-
if (dups.size === 0) return hashes;
|
|
92
|
-
hashes = hashes.map((h, i) =>
|
|
93
|
-
dups.has(h)
|
|
94
|
-
? computeLineHash(lines[i - 1] ?? "", lines[i] ?? "", lines[i + 1] ?? "", curLen + 1)
|
|
95
|
-
: h,
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
return forceUnique(hashes);
|
|
71
|
+
return lines.map((content, i) => computeLineHash(i + 1, content, len));
|
|
99
72
|
}
|
package/src/core/index.ts
CHANGED
|
@@ -9,9 +9,5 @@
|
|
|
9
9
|
|
|
10
10
|
export * from "./types.ts";
|
|
11
11
|
export { computeLineHash, hashFileLines } from "./hash.ts";
|
|
12
|
-
export { splitLines, joinLines,
|
|
13
|
-
export type { AnchorVerifyResult } from "./snapshot.ts";
|
|
14
|
-
export { parsePatch } from "./parse.ts";
|
|
15
|
-
export type { ParseResult } from "./parse.ts";
|
|
12
|
+
export { splitLines, joinLines, detectLineEnding } from "./lines.ts";
|
|
16
13
|
export { applyEdits } from "./apply.ts";
|
|
17
|
-
export { buildDiff } from "./diff.ts";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { splitLines, joinLines, detectLineEnding } from "./lines.ts";
|
|
4
|
+
|
|
5
|
+
test("splitLines edge cases", () => {
|
|
6
|
+
assert.deepEqual(splitLines(""), []);
|
|
7
|
+
assert.deepEqual(splitLines("a"), ["a"]);
|
|
8
|
+
assert.deepEqual(splitLines("a\nb"), ["a", "b"]);
|
|
9
|
+
assert.deepEqual(splitLines("a\nb\n"), ["a", "b"]);
|
|
10
|
+
assert.deepEqual(splitLines("a\n\n"), ["a", ""]);
|
|
11
|
+
assert.deepEqual(splitLines("\n"), [""]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("splitLines strips CRLF \\r", () => {
|
|
15
|
+
assert.deepEqual(splitLines("a\r\nb\r\n"), ["a", "b"]);
|
|
16
|
+
assert.deepEqual(splitLines("a\r\nb"), ["a", "b"]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("joinLines restores line endings", () => {
|
|
20
|
+
assert.equal(joinLines(["a", "b"]), "a\nb\n");
|
|
21
|
+
assert.equal(joinLines([]), "");
|
|
22
|
+
assert.equal(joinLines(["a", "b"], "crlf"), "a\r\nb\r\n");
|
|
23
|
+
assert.equal(joinLines(["a", "b"], "lf"), "a\nb\n");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("detectLineEnding", () => {
|
|
27
|
+
assert.equal(detectLineEnding("a\nb\n"), "lf");
|
|
28
|
+
assert.equal(detectLineEnding("a\r\nb\r\n"), "crlf");
|
|
29
|
+
assert.equal(detectLineEnding("a\nb\r\nc\n"), "crlf");
|
|
30
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line text helpers: split/join with CRLF normalization and line-ending
|
|
3
|
+
* detection.
|
|
4
|
+
*
|
|
5
|
+
* CRLF: splitLines strips the trailing `\r` from each line (hashes are based on
|
|
6
|
+
* clean lines, matching the `\r`-free content the model copies from the
|
|
7
|
+
* display); detectLineEnding records the original ending so joinLines can
|
|
8
|
+
* restore it — guaranteeing a CRLF file keeps its endings after edit.
|
|
9
|
+
*
|
|
10
|
+
* @module pi-hashline-edit/core
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { LineEnding } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Split text into lines, stripping the trailing `\r` of each line (CRLF
|
|
17
|
+
* normalization, so hashes are based on clean lines).
|
|
18
|
+
*
|
|
19
|
+
* Convention: a trailing newline is treated as the terminator of the last line,
|
|
20
|
+
* not as producing an extra empty trailing line.
|
|
21
|
+
* - `"a\nb\n"` → `["a", "b"]`
|
|
22
|
+
* - `"a\r\nb\r\n"` → `["a", "b"]` (`\r` stripped)
|
|
23
|
+
* - `"a\n\n"` → `["a", ""]`
|
|
24
|
+
* - `""` → `[]`
|
|
25
|
+
*/
|
|
26
|
+
export function splitLines(text: string): string[] {
|
|
27
|
+
if (text === "") return [];
|
|
28
|
+
const normalized = text.endsWith("\n") ? text.slice(0, -1) : text;
|
|
29
|
+
return normalized.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Detect the dominant line ending of the text (any `\r\n` counts as CRLF). */
|
|
33
|
+
export function detectLineEnding(text: string): LineEnding {
|
|
34
|
+
return text.includes("\r\n") ? "crlf" : "lf";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Join a line array back into text, restoring the given line ending (default LF). Non-empty files end with a newline. */
|
|
38
|
+
export function joinLines(lines: readonly string[], ending: LineEnding = "lf"): string {
|
|
39
|
+
if (lines.length === 0) return "";
|
|
40
|
+
const sep = ending === "crlf" ? "\r\n" : "\n";
|
|
41
|
+
return lines.join(sep) + sep;
|
|
42
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -12,8 +12,8 @@ export interface Anchor {
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Edit operation. Every line-numbered op references a line via {@link Anchor} —
|
|
15
|
-
* the line number is
|
|
16
|
-
*
|
|
15
|
+
* the line number is the address, the hash a checksum that the line at that
|
|
16
|
+
* address is still what was read; both must match at apply time.
|
|
17
17
|
*/
|
|
18
18
|
export type Edit =
|
|
19
19
|
| { readonly op: "replace"; readonly start: Anchor; readonly end?: Anchor; readonly body: string[] }
|
|
@@ -25,47 +25,28 @@ export type Edit =
|
|
|
25
25
|
|
|
26
26
|
export type LineEnding = "lf" | "crlf";
|
|
27
27
|
|
|
28
|
-
/** File snapshot: original text recorded at read time + per-line context-aware hash. */
|
|
29
|
-
export interface FileSnapshot {
|
|
30
|
-
readonly path: string;
|
|
31
|
-
/** `lineHashes[i]` = hash of line (i+1); length always equals the file's line count. */
|
|
32
|
-
readonly lineHashes: readonly string[];
|
|
33
|
-
readonly text: string;
|
|
34
|
-
/** Hash length used when generating lineHashes; apply must reuse it for the new snapshot to avoid length drift on the next verification. */
|
|
35
|
-
readonly hashLen: number;
|
|
36
|
-
/** Original file line ending (lf/crlf); apply restores it so a CRLF file keeps its line endings after edit. */
|
|
37
|
-
readonly lineEnding: LineEnding;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** A single parsed file patch. */
|
|
41
|
-
export interface ParsedPatch {
|
|
42
|
-
readonly path: string;
|
|
43
|
-
readonly edits: Edit[];
|
|
44
|
-
}
|
|
45
|
-
|
|
46
28
|
/** Error kinds. */
|
|
47
29
|
export type PatchErrorKind =
|
|
48
|
-
| "
|
|
49
|
-
| "
|
|
50
|
-
| "anchor" // anchor hash does not match the snapshot (model misremembered) or line out of range
|
|
51
|
-
| "collision" // hash appears at multiple lines, cannot locate uniquely
|
|
52
|
-
| "range" // illegal operation range (overlap, reverse order, spanning a gap, etc.)
|
|
30
|
+
| "anchor" // anchor hash does not match the current line content (line changed, or model misremembered) or line out of range
|
|
31
|
+
| "range" // illegal operation range (overlap, reverse order, etc.)
|
|
53
32
|
| "noop"; // edit produced no change (body byte-identical to the target)
|
|
54
33
|
|
|
55
34
|
export interface PatchError {
|
|
56
35
|
readonly kind: PatchErrorKind;
|
|
57
36
|
readonly message: string;
|
|
58
|
-
/** Line number (1-based) in the input patch, for error localization. */
|
|
59
|
-
readonly line?: number;
|
|
60
37
|
}
|
|
61
38
|
|
|
62
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Apply result. On success, `touchedLines` lists the 0-based line indices in
|
|
41
|
+
* the NEW file that this edit produced (inserted or replaced) — callers use it
|
|
42
|
+
* to surface fresh `LINE#HASH` anchors so the model can chain edits without a
|
|
43
|
+
* re-read.
|
|
44
|
+
*/
|
|
63
45
|
export type ApplyResult =
|
|
64
46
|
| {
|
|
65
47
|
readonly ok: true;
|
|
66
48
|
readonly text: string;
|
|
67
|
-
readonly newSnapshot: FileSnapshot;
|
|
68
49
|
readonly changed: boolean;
|
|
69
|
-
readonly
|
|
50
|
+
readonly touchedLines: readonly number[];
|
|
70
51
|
}
|
|
71
52
|
| { readonly ok: false; readonly error: PatchError };
|
package/src/index.ts
CHANGED
|
@@ -1,30 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-hashline-edit extension entry.
|
|
3
3
|
*
|
|
4
|
-
* Overrides the built-in read/edit: read outputs "lineNo#hash│content"
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Overrides the built-in read/edit: read outputs "lineNo#hash│content";
|
|
5
|
+
* edit accepts structured hashline ops (edits[] with LINE#HASH anchors), and
|
|
6
|
+
* legacy oldText/newText is rejected explicitly (no silent degradation). Each
|
|
7
|
+
* tool carries its own renderer.
|
|
8
8
|
*
|
|
9
9
|
* @module pi-hashline-edit
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { loadConfig } from "./pi/config.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { getState } from "./pi/state.ts";
|
|
15
15
|
import { makeEditOverride } from "./pi/edit-tool.ts";
|
|
16
16
|
import { makeReadOverride } from "./pi/read-tool.ts";
|
|
17
17
|
|
|
18
18
|
export default function (pi: ExtensionAPI) {
|
|
19
19
|
const cwd = process.cwd();
|
|
20
20
|
|
|
21
|
-
// refresh config
|
|
21
|
+
// refresh config on session start / reload
|
|
22
22
|
pi.on("session_start", async () => {
|
|
23
|
-
const config = loadConfig(cwd);
|
|
24
23
|
const state = getState();
|
|
25
|
-
state.config =
|
|
26
|
-
state.hashLen = config.hashLen;
|
|
27
|
-
clearSnapshots();
|
|
24
|
+
state.config = loadConfig(cwd);
|
|
28
25
|
});
|
|
29
26
|
|
|
30
27
|
pi.registerTool(makeReadOverride(cwd));
|
package/src/pi/edit-tool.ts
CHANGED
|
@@ -1,95 +1,109 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Override edit:
|
|
2
|
+
* Override edit: hashline ops via structured `edits` (LINE#HASH anchors).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Each op in `edits` references line anchors copied from read output (or from a
|
|
5
|
+
* prior edit's "Updated anchors"). The core verifies each anchor live against
|
|
6
|
+
* the current file content — no snapshot, no global stale check: a cited line
|
|
7
|
+
* that changed (or was misremembered) fails its own anchor; unchanged lines
|
|
8
|
+
* elsewhere never block the edit. Legacy oldText/newText is not accepted — the
|
|
9
|
+
* schema requires an `op` discriminator, so legacy payloads are rejected at the
|
|
10
|
+
* schema layer (a visible failure, never a silent degradation).
|
|
9
11
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* On success the result carries fresh `LINE#HASH` anchors for the lines this
|
|
13
|
+
* edit produced (and the line that shifted into a deletion gap), so the model
|
|
14
|
+
* can chain edits without a re-read.
|
|
15
|
+
*
|
|
16
|
+
* Concurrency safety: read-modify-write is wrapped in withFileMutationQueue.
|
|
17
|
+
* AbortSignal is honored — checked after read / before write.
|
|
14
18
|
*
|
|
15
19
|
* @module pi-hashline-edit/pi
|
|
16
20
|
*/
|
|
17
21
|
|
|
18
|
-
import { createEditTool, generateDiffString, generateUnifiedPatch, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { createEditTool, generateDiffString, generateUnifiedPatch, withFileMutationQueue, type EditToolDetails } from "@earendil-works/pi-coding-agent";
|
|
19
23
|
import { Type, type Static } from "typebox";
|
|
24
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
20
25
|
import { readFile, writeFile } from "node:fs/promises";
|
|
21
|
-
import { applyEdits,
|
|
22
|
-
import
|
|
26
|
+
import { applyEdits, hashFileLines } from "../core/index.ts";
|
|
27
|
+
import { splitLines } from "../core/lines.ts";
|
|
28
|
+
import type { Edit, PatchError } from "../core/types.ts";
|
|
23
29
|
import { canonicalPath } from "./read-tool.ts";
|
|
24
|
-
import { getState
|
|
25
|
-
|
|
30
|
+
import { getState } from "./state.ts";
|
|
31
|
+
|
|
32
|
+
/** Cap on the number of updated anchors returned inline (bounds token cost for large inserts). */
|
|
33
|
+
const MAX_ANCHOR_LINES = 40;
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
const anchorSchema = Type.Object({
|
|
36
|
+
line: Type.Number({ description: "1-based line number" }),
|
|
37
|
+
hash: Type.String({ description: "Line content hash copied from read output (the #HASH after the line number)" }),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const editOpSchema = Type.Object({
|
|
41
|
+
op: Type.Union(
|
|
42
|
+
[
|
|
43
|
+
Type.Literal("replace"),
|
|
44
|
+
Type.Literal("delete"),
|
|
45
|
+
Type.Literal("insert_after"),
|
|
46
|
+
Type.Literal("insert_before"),
|
|
47
|
+
Type.Literal("append"),
|
|
48
|
+
Type.Literal("prepend"),
|
|
49
|
+
],
|
|
50
|
+
{ description: "Operation kind" },
|
|
51
|
+
),
|
|
52
|
+
anchor: Type.Optional(anchorSchema),
|
|
53
|
+
end: Type.Optional(anchorSchema),
|
|
54
|
+
body: Type.Optional(Type.Array(Type.String(), { description: "New content lines (required for replace/insert/append/prepend; omit for delete)" })),
|
|
55
|
+
});
|
|
32
56
|
|
|
33
57
|
const editSchema = Type.Object({
|
|
34
58
|
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
|
|
35
|
-
|
|
36
|
-
Type.String({
|
|
37
|
-
description:
|
|
38
|
-
"Hashline patch referencing LINE#HASH anchors from your latest read. Ops: replace / delete / insert_after / insert_before / append / prepend. Body rows start with `+`. This tool does NOT accept legacy oldText/newText.",
|
|
39
|
-
}),
|
|
40
|
-
),
|
|
59
|
+
edits: Type.Array(editOpSchema, { description: "Hashline ops, each referencing LINE#HASH anchors from your latest read or edit result" }),
|
|
41
60
|
});
|
|
42
61
|
|
|
62
|
+
type EditOpInput = Static<typeof editOpSchema>;
|
|
63
|
+
|
|
43
64
|
/** Turn a PatchError into helpful hint text for the model. */
|
|
44
65
|
function errorText(e: PatchError, path: string): string {
|
|
45
66
|
switch (e.kind) {
|
|
46
|
-
case "stale":
|
|
47
|
-
return `File ${path} changed since your last read. Re-read it before editing.`;
|
|
48
67
|
case "anchor":
|
|
49
68
|
return `Anchor mismatch: ${e.message}. Re-read ${path} to get current line hashes (LINE#HASH).`;
|
|
50
|
-
case "collision":
|
|
51
|
-
return `Hash collision: ${e.message}. Re-read ${path}.`;
|
|
52
69
|
case "range":
|
|
53
70
|
return `Bad range: ${e.message}`;
|
|
54
71
|
case "noop":
|
|
55
72
|
return `Edit produced no change: ${e.message}`;
|
|
56
|
-
case "parse":
|
|
57
|
-
return `Parse error${e.line ? ` at line ${e.line}` : ""}: ${e.message}`;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Get the first changed line after applying (for firstChangedLine / TUI jump). insert_after uses anchor + 1. */
|
|
62
|
-
function minAnchorLine(edits: readonly Edit[]): number | undefined {
|
|
63
|
-
let min: number | undefined;
|
|
64
|
-
for (const e of edits) {
|
|
65
|
-
let line: number | undefined;
|
|
66
|
-
if (e.op === "replace" || e.op === "delete") line = e.start.line;
|
|
67
|
-
else if (e.op === "insert_after") line = e.anchor.line + 1;
|
|
68
|
-
else if (e.op === "insert_before") line = e.anchor.line;
|
|
69
|
-
else if (e.op === "prepend") line = 1;
|
|
70
|
-
// append has no anchor, skip (trailing append)
|
|
71
|
-
if (line !== undefined && (min === undefined || line < min)) min = line;
|
|
72
73
|
}
|
|
73
|
-
return min;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
76
|
+
/** Translate JSON edit ops into core Edit[]. Validates conditional required fields (anchor/body per op). */
|
|
77
|
+
function toCoreEdits(ops: readonly EditOpInput[]): { ok: true; edits: Edit[] } | { ok: false; error: string } {
|
|
78
|
+
const edits: Edit[] = [];
|
|
79
|
+
for (const o of ops) {
|
|
80
|
+
switch (o.op) {
|
|
81
|
+
case "replace":
|
|
82
|
+
if (!o.anchor) return { ok: false, error: "replace needs `anchor` {line, hash}" };
|
|
83
|
+
if (!o.body) return { ok: false, error: "replace needs `body`" };
|
|
84
|
+
edits.push({ op: "replace", start: o.anchor, end: o.end, body: o.body });
|
|
85
|
+
break;
|
|
86
|
+
case "delete":
|
|
87
|
+
if (!o.anchor) return { ok: false, error: "delete needs `anchor` {line, hash}" };
|
|
88
|
+
edits.push({ op: "delete", start: o.anchor, end: o.end });
|
|
89
|
+
break;
|
|
90
|
+
case "insert_after":
|
|
91
|
+
case "insert_before":
|
|
92
|
+
if (!o.anchor) return { ok: false, error: `${o.op} needs \`anchor\` {line, hash}` };
|
|
93
|
+
if (!o.body) return { ok: false, error: `${o.op} needs \`body\`` };
|
|
94
|
+
edits.push({ op: o.op, anchor: o.anchor, body: o.body });
|
|
95
|
+
break;
|
|
96
|
+
case "append":
|
|
97
|
+
case "prepend":
|
|
98
|
+
if (!o.body) return { ok: false, error: `${o.op} needs \`body\`` };
|
|
99
|
+
edits.push({ op: o.op, body: o.body });
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
88
102
|
}
|
|
89
|
-
return
|
|
103
|
+
return { ok: true, edits };
|
|
90
104
|
}
|
|
91
105
|
|
|
92
|
-
/** Build an error result (
|
|
106
|
+
/** Build an error result (isError: true so the TUI/agent loop treats it as a failure). */
|
|
93
107
|
function errResult(text: string) {
|
|
94
108
|
return {
|
|
95
109
|
isError: true as const,
|
|
@@ -98,6 +112,21 @@ function errResult(text: string) {
|
|
|
98
112
|
};
|
|
99
113
|
}
|
|
100
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Format the updated anchors (fresh LINE#HASH│content) for the touched new-file
|
|
117
|
+
* lines, so the model can chain edits without a re-read. Capped to bound tokens.
|
|
118
|
+
*/
|
|
119
|
+
function formatUpdatedAnchors(newText: string, touched: readonly number[], hashLen: number): string {
|
|
120
|
+
const newLines = splitLines(newText);
|
|
121
|
+
const newHashes = hashFileLines(newLines, hashLen);
|
|
122
|
+
const idxs = [...new Set(touched)].sort((a, b) => a - b);
|
|
123
|
+
if (idxs.length === 0) return "";
|
|
124
|
+
const rows = idxs.map((i) => `${i + 1}#${newHashes[i]}│${newLines[i]}`);
|
|
125
|
+
const shown = rows.length > MAX_ANCHOR_LINES ? rows.slice(0, MAX_ANCHOR_LINES) : rows;
|
|
126
|
+
const more = rows.length > MAX_ANCHOR_LINES ? `\n… (${rows.length - MAX_ANCHOR_LINES} more; re-read for full anchors)` : "";
|
|
127
|
+
return `\nUpdated anchors (use these for the next edit):\n${shown.join("\n")}${more}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
101
130
|
export function makeEditOverride(cwd: string) {
|
|
102
131
|
const builtin = createEditTool(cwd);
|
|
103
132
|
|
|
@@ -105,32 +134,30 @@ export function makeEditOverride(cwd: string) {
|
|
|
105
134
|
name: "edit" as const,
|
|
106
135
|
label: "edit",
|
|
107
136
|
description:
|
|
108
|
-
"Edit a file via hashline
|
|
109
|
-
promptSnippet: "Edit files via hashline LINE#HASH anchors
|
|
137
|
+
"Edit a file via hashline ops (LINE#HASH anchors, content-verified). Each op in `edits` references line anchors from your latest read or edit result.",
|
|
138
|
+
promptSnippet: "Edit files via hashline ops (edits[] with LINE#HASH anchors from read)",
|
|
110
139
|
promptGuidelines: [
|
|
111
|
-
"Pass `
|
|
112
|
-
"
|
|
113
|
-
"
|
|
114
|
-
"
|
|
115
|
-
"
|
|
140
|
+
"Pass `edits`: an array of ops. Each op = {op, anchor?, end?, body?}.",
|
|
141
|
+
"op ∈ replace | delete | insert_after | insert_before | append | prepend.",
|
|
142
|
+
"anchor & end = {line, hash} copied from your latest read or edit result (the `#HASH` after each line number). replace/delete take anchor (+ optional end for a range); insert_after/insert_before take anchor; append/prepend take neither.",
|
|
143
|
+
"body = string[] of new content lines (required for replace/insert/append/prepend; omit for delete).",
|
|
144
|
+
"A successful edit returns `Updated anchors` for the changed lines — use those (not stale line numbers) for the next edit to the same file; re-read only if you need lines outside that set.",
|
|
116
145
|
],
|
|
117
146
|
parameters: editSchema,
|
|
118
|
-
renderShell: "
|
|
147
|
+
renderShell: "default" as const,
|
|
119
148
|
|
|
120
149
|
renderCall(args: Static<typeof editSchema>, theme: any) {
|
|
121
150
|
let text = theme.fg("toolTitle", theme.bold("edit "));
|
|
122
151
|
text += theme.fg("accent", args.path);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (firstOp) text += theme.fg("dim", ` — ${firstOp.trim()}`);
|
|
126
|
-
}
|
|
152
|
+
const n = args.edits?.length ?? 0;
|
|
153
|
+
if (n) text += theme.fg("dim", ` — ${n} op${n > 1 ? "s" : ""}: ${args.edits[0].op}`);
|
|
127
154
|
return new Text(text, 0, 0);
|
|
128
155
|
},
|
|
129
156
|
|
|
130
|
-
renderResult(result: any, { isPartial, expanded }: any, theme: any) {
|
|
157
|
+
renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
|
|
131
158
|
if (isPartial) return new Text(theme.fg("warning", "Editing…"), 0, 0);
|
|
132
159
|
const content = result.content?.[0];
|
|
133
|
-
if (
|
|
160
|
+
if (context.isError) {
|
|
134
161
|
const t = content?.type === "text" ? content.text.split("\n")[0] : "Error";
|
|
135
162
|
return new Text(theme.fg("error", t), 0, 0);
|
|
136
163
|
}
|
|
@@ -160,19 +187,19 @@ export function makeEditOverride(cwd: string) {
|
|
|
160
187
|
|
|
161
188
|
const path = params.path;
|
|
162
189
|
const absPath = canonicalPath(cwd, path);
|
|
163
|
-
const input = params.input;
|
|
164
190
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return errResult(missingInputError(path, params as any));
|
|
191
|
+
if (!params.edits?.length) {
|
|
192
|
+
return errResult(`Edit ${path}: \`edits\` is empty or missing.`);
|
|
168
193
|
}
|
|
169
194
|
// withFileMutationQueue serializes read-modify-write for the same file, preventing parallel-edit data loss
|
|
170
|
-
return await withFileMutationQueue(absPath, () => runHashline(absPath, path,
|
|
195
|
+
return await withFileMutationQueue(absPath, () => runHashline(absPath, path, params.edits, signal));
|
|
171
196
|
},
|
|
172
197
|
};
|
|
173
198
|
}
|
|
174
199
|
|
|
175
|
-
async function runHashline(absPath: string, displayPath: string,
|
|
200
|
+
async function runHashline(absPath: string, displayPath: string, editOps: readonly EditOpInput[], signal: AbortSignal | undefined) {
|
|
201
|
+
const hashLen = getState().config.hashLen;
|
|
202
|
+
|
|
176
203
|
let currentText: string;
|
|
177
204
|
try {
|
|
178
205
|
currentText = (await readFile(absPath)).toString("utf-8");
|
|
@@ -183,18 +210,13 @@ async function runHashline(absPath: string, displayPath: string, input: string,
|
|
|
183
210
|
// Check for cancel after read: if the user aborted, don't proceed to parse/apply; the file stays untouched
|
|
184
211
|
if (signal?.aborted) return errResult(`Edit ${displayPath} aborted before apply.`);
|
|
185
212
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
// correctly without reading), naturally steering it to read first.
|
|
189
|
-
const snap: FileSnapshot = getSnapshot(absPath) ?? recordSnapshot(absPath, currentText);
|
|
190
|
-
|
|
191
|
-
// input has no file header; prepend one so parsePatch passes (path is just a label)
|
|
192
|
-
const parsed = parsePatch(`file: ${absPath}\n\n${input}`);
|
|
193
|
-
if (!parsed.ok) {
|
|
194
|
-
return errResult(errorText(parsed.error, displayPath));
|
|
195
|
-
}
|
|
213
|
+
const translated = toCoreEdits(editOps);
|
|
214
|
+
if (!translated.ok) return errResult(translated.error);
|
|
196
215
|
|
|
197
|
-
|
|
216
|
+
// Anchors are verified against the current content. A line that changed (or a
|
|
217
|
+
// hash the model didn't actually read) fails its own anchor — steering it to
|
|
218
|
+
// read first. Unrelated changes elsewhere never block the edit.
|
|
219
|
+
const result = applyEdits(currentText, translated.edits, hashLen);
|
|
198
220
|
if (!result.ok) {
|
|
199
221
|
return errResult(errorText(result.error, displayPath));
|
|
200
222
|
}
|
|
@@ -209,20 +231,16 @@ async function runHashline(absPath: string, displayPath: string, input: string,
|
|
|
209
231
|
return errResult(`Error writing ${displayPath}: ${msg}`);
|
|
210
232
|
}
|
|
211
233
|
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
234
|
+
// pi's generateDiffString returns the display diff (colored by the renderer) and the first changed line
|
|
235
|
+
const { diff, firstChangedLine } = generateDiffString(currentText, result.text);
|
|
236
|
+
const details: EditToolDetails = {
|
|
237
|
+
diff,
|
|
238
|
+
patch: generateUnifiedPatch(displayPath, currentText, result.text),
|
|
239
|
+
firstChangedLine,
|
|
240
|
+
};
|
|
241
|
+
const anchors = formatUpdatedAnchors(result.text, result.touchedLines, hashLen);
|
|
215
242
|
return {
|
|
216
|
-
content: [
|
|
217
|
-
|
|
218
|
-
],
|
|
219
|
-
details: {
|
|
220
|
-
// details.diff must use pi's generateDiffString (+N content format); the built-in
|
|
221
|
-
// renderer's parseDiffLine only recognizes this format — core's standard unified diff
|
|
222
|
-
// would be grayed out as plain text
|
|
223
|
-
diff: generateDiffString(currentText, result.text),
|
|
224
|
-
patch: generateUnifiedPatch(displayPath, currentText, result.text),
|
|
225
|
-
firstChangedLine: minAnchorLine(parsed.patch.edits),
|
|
226
|
-
},
|
|
243
|
+
content: [{ type: "text" as const, text: `Edited ${displayPath} (${translated.edits.length} op(s)).${anchors}` }],
|
|
244
|
+
details,
|
|
227
245
|
};
|
|
228
246
|
}
|