@d3ara1n/pi-hashline-edit 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -15
- package/package.json +1 -1
- package/src/core/apply.test.ts +177 -98
- package/src/core/apply.ts +161 -66
- package/src/core/hash.test.ts +16 -11
- package/src/core/hash.ts +26 -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 +52 -36
- package/src/index.ts +9 -10
- package/src/pi/config.ts +7 -1
- package/src/pi/edit-tool.ts +184 -118
- package/src/pi/execute.test.ts +193 -47
- package/src/pi/grep-tool.ts +335 -0
- package/src/pi/pi.test.ts +6 -18
- package/src/pi/read-tool.ts +27 -17
- package/src/pi/state.ts +5 -59
- 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/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,63 @@ export type Edit =
|
|
|
25
25
|
|
|
26
26
|
export type LineEnding = "lf" | "crlf";
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Outcome of shifted-anchor recovery. When a cited anchor's hash no longer
|
|
30
|
+
* matches the live content, the applicator rescans ±radius lines for the
|
|
31
|
+
* original content — holding the ORIGINAL line number fixed and re-hashing each
|
|
32
|
+
* candidate's content (`computeLineHash(citedLine, candidateContent) === citedHash`
|
|
33
|
+
* iff the candidate is the original content). A ready-to-resend anchor (with the
|
|
34
|
+
* freshly computed hash) is returned so the model can retry without a re-read.
|
|
35
|
+
*
|
|
36
|
+
* - `found` — exactly one nearby line holds the original content; resend the op
|
|
37
|
+
* with the provided anchor.
|
|
38
|
+
* - `ambiguous` — several nearby lines match (e.g. duplicate content); the model
|
|
39
|
+
* picks the right one from the candidates (each carries its own new hash).
|
|
40
|
+
* - `none` — the content genuinely changed; re-read.
|
|
41
|
+
*/
|
|
42
|
+
export type AnchorRecovery =
|
|
43
|
+
| { readonly kind: "found"; readonly newLine: number; readonly newHash: string }
|
|
44
|
+
| {
|
|
45
|
+
readonly kind: "ambiguous";
|
|
46
|
+
readonly candidates: ReadonlyArray<{ readonly line: number; readonly hash: string }>;
|
|
47
|
+
}
|
|
48
|
+
| { readonly kind: "none" };
|
|
39
49
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
/**
|
|
51
|
+
* A single anchor that failed verification, with its recovery attempt.
|
|
52
|
+
*
|
|
53
|
+
* `opIndex` is the 0-based position in the input `edits[]`; `which` names the
|
|
54
|
+
* op's anchor (`"anchor"` = start, `"end"` = range end); `op` is the op kind.
|
|
55
|
+
* `current` is the cited line's live content + hash (null if the line number is
|
|
56
|
+
* out of range) — surfaced when recovery is `none` so the model can self-diagnose.
|
|
57
|
+
*/
|
|
58
|
+
export interface AnchorFailure {
|
|
59
|
+
readonly opIndex: number;
|
|
60
|
+
readonly which: "anchor" | "end";
|
|
61
|
+
readonly op: Edit["op"];
|
|
62
|
+
readonly cited: Anchor;
|
|
63
|
+
readonly recovery: AnchorRecovery;
|
|
64
|
+
readonly current: { readonly hash: string; readonly content: string } | null;
|
|
44
65
|
}
|
|
45
66
|
|
|
46
|
-
/**
|
|
47
|
-
export type
|
|
48
|
-
| "
|
|
49
|
-
|
|
|
50
|
-
|
|
|
51
|
-
| "collision" // hash appears at multiple lines, cannot locate uniquely
|
|
52
|
-
| "range" // illegal operation range (overlap, reverse order, spanning a gap, etc.)
|
|
53
|
-
| "noop"; // edit produced no change (body byte-identical to the target)
|
|
54
|
-
|
|
55
|
-
export interface PatchError {
|
|
56
|
-
readonly kind: PatchErrorKind;
|
|
57
|
-
readonly message: string;
|
|
58
|
-
/** Line number (1-based) in the input patch, for error localization. */
|
|
59
|
-
readonly line?: number;
|
|
60
|
-
}
|
|
67
|
+
/** Batch-level failure. `anchor` carries every per-anchor failure collected across the batch. */
|
|
68
|
+
export type ApplyFailure =
|
|
69
|
+
| { readonly kind: "anchor"; readonly failures: readonly AnchorFailure[] }
|
|
70
|
+
| { readonly kind: "range"; readonly message: string }
|
|
71
|
+
| { readonly kind: "noop"; readonly message: string };
|
|
61
72
|
|
|
62
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* Apply result. On success, `touchedLines` lists the 0-based line indices in
|
|
75
|
+
* the NEW file that this edit produced (inserted or replaced) — callers use it
|
|
76
|
+
* to surface fresh `LINE#HASH` anchors so the model can chain edits without a
|
|
77
|
+
* re-read. On failure, `failure` is either the collected set of anchor failures
|
|
78
|
+
* (each with recovery) or a single range/noop error; nothing is written.
|
|
79
|
+
*/
|
|
63
80
|
export type ApplyResult =
|
|
64
81
|
| {
|
|
65
82
|
readonly ok: true;
|
|
66
83
|
readonly text: string;
|
|
67
|
-
readonly newSnapshot: FileSnapshot;
|
|
68
84
|
readonly changed: boolean;
|
|
69
|
-
readonly
|
|
85
|
+
readonly touchedLines: readonly number[];
|
|
70
86
|
}
|
|
71
|
-
| { readonly ok: false; readonly
|
|
87
|
+
| { readonly ok: false; readonly failure: ApplyFailure };
|
package/src/index.ts
CHANGED
|
@@ -1,32 +1,31 @@
|
|
|
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
|
+
import { makeGrepOverride } from "./pi/grep-tool.ts";
|
|
17
18
|
|
|
18
19
|
export default function (pi: ExtensionAPI) {
|
|
19
20
|
const cwd = process.cwd();
|
|
20
21
|
|
|
21
|
-
// refresh config
|
|
22
|
+
// refresh config on session start / reload
|
|
22
23
|
pi.on("session_start", async () => {
|
|
23
|
-
const config = loadConfig(cwd);
|
|
24
24
|
const state = getState();
|
|
25
|
-
state.config =
|
|
26
|
-
state.hashLen = config.hashLen;
|
|
27
|
-
clearSnapshots();
|
|
25
|
+
state.config = loadConfig(cwd);
|
|
28
26
|
});
|
|
29
27
|
|
|
30
28
|
pi.registerTool(makeReadOverride(cwd));
|
|
31
29
|
pi.registerTool(makeEditOverride(cwd));
|
|
30
|
+
pi.registerTool(makeGrepOverride(cwd));
|
|
32
31
|
}
|
package/src/pi/config.ts
CHANGED
|
@@ -15,9 +15,11 @@ export interface HashlineEditConfig {
|
|
|
15
15
|
enabled: boolean;
|
|
16
16
|
/** Line hash length (default 4). */
|
|
17
17
|
hashLen: number;
|
|
18
|
+
/** ±line radius for shifted-anchor recovery (default 15; 0 disables rescue). */
|
|
19
|
+
shiftRadius: number;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
const DEFAULT_CONFIG: HashlineEditConfig = { enabled: true, hashLen: 4 };
|
|
22
|
+
const DEFAULT_CONFIG: HashlineEditConfig = { enabled: true, hashLen: 4, shiftRadius: 15 };
|
|
21
23
|
|
|
22
24
|
function getAgentDir(): string {
|
|
23
25
|
const envDir = process.env.PI_AGENT_DIR;
|
|
@@ -49,5 +51,9 @@ export function loadConfig(cwd?: string): HashlineEditConfig {
|
|
|
49
51
|
typeof raw.hashLen === "number" && raw.hashLen >= 2 && raw.hashLen <= 8
|
|
50
52
|
? raw.hashLen
|
|
51
53
|
: DEFAULT_CONFIG.hashLen,
|
|
54
|
+
shiftRadius:
|
|
55
|
+
typeof raw.shiftRadius === "number" && raw.shiftRadius >= 0 && raw.shiftRadius <= 100
|
|
56
|
+
? raw.shiftRadius
|
|
57
|
+
: DEFAULT_CONFIG.shiftRadius,
|
|
52
58
|
};
|
|
53
59
|
}
|
package/src/pi/edit-tool.ts
CHANGED
|
@@ -1,101 +1,176 @@
|
|
|
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 { ApplyFailure, Edit } 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
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
type EditOpInput = Static<typeof editOpSchema>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Turn an ApplyFailure into LLM-facing text. The FIRST line is a terse summary
|
|
66
|
+
* (the TUI's renderResult shows only the first line of an error result); the
|
|
67
|
+
* remaining lines carry the structured detail the model needs to retry without a
|
|
68
|
+
* re-read — rescued anchors / ambiguous candidates / the cited line's live
|
|
69
|
+
* content. range/noop are already terse single-line messages.
|
|
70
|
+
*/
|
|
71
|
+
function formatFailure(failure: ApplyFailure, path: string): string {
|
|
72
|
+
if (failure.kind === "range") return failure.message;
|
|
73
|
+
if (failure.kind === "noop") return failure.message;
|
|
74
|
+
|
|
75
|
+
const lines: string[] = [];
|
|
76
|
+
let found = 0;
|
|
77
|
+
let ambiguous = 0;
|
|
78
|
+
let none = 0;
|
|
79
|
+
for (const f of failure.failures) {
|
|
80
|
+
const where = `op #${f.opIndex} ${f.op} ${f.which} (line ${f.cited.line})`;
|
|
81
|
+
switch (f.recovery.kind) {
|
|
82
|
+
case "found": {
|
|
83
|
+
found++;
|
|
84
|
+
lines.push(
|
|
85
|
+
`• ${where}: content shifted to line ${f.recovery.newLine}. Resend this op with ${f.which} { "line": ${f.recovery.newLine}, "hash": "${f.recovery.newHash}" }.`,
|
|
86
|
+
);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case "ambiguous": {
|
|
90
|
+
ambiguous++;
|
|
91
|
+
const nums = f.recovery.candidates.map((c) => c.line).join(", ");
|
|
92
|
+
const list = f.recovery.candidates
|
|
93
|
+
.map((c) => `{ "line": ${c.line}, "hash": "${c.hash}" }`)
|
|
94
|
+
.join(" / ");
|
|
95
|
+
lines.push(
|
|
96
|
+
`• ${where}: ambiguous — same content at lines ${nums}. Pick the right one and resend ${f.which} ${list}.`,
|
|
97
|
+
);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case "none": {
|
|
101
|
+
none++;
|
|
102
|
+
const cur =
|
|
103
|
+
f.current != null
|
|
104
|
+
? `current line ${f.cited.line}: ${f.cited.line}#${f.current.hash}│${f.current.content}`
|
|
105
|
+
: `line ${f.cited.line} is out of range`;
|
|
106
|
+
lines.push(`• ${where}: not found nearby — content changed. ${cur}. Re-read ${path} for fresh anchors.`);
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
58
110
|
}
|
|
111
|
+
const parts: string[] = [];
|
|
112
|
+
if (found) parts.push(`${found} rescued`);
|
|
113
|
+
if (ambiguous) parts.push(`${ambiguous} ambiguous`);
|
|
114
|
+
if (none) parts.push(`${none} need re-read`);
|
|
115
|
+
const brief = `Anchor mismatch: ${parts.join(", ")}.`;
|
|
116
|
+
return `${brief}\n${lines.join("\n")}`;
|
|
59
117
|
}
|
|
60
118
|
|
|
61
|
-
/**
|
|
62
|
-
function
|
|
63
|
-
|
|
64
|
-
for (const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
119
|
+
/** Translate JSON edit ops into core Edit[]. Validates conditional required fields (anchor/body per op). */
|
|
120
|
+
function toCoreEdits(ops: readonly EditOpInput[]): { ok: true; edits: Edit[] } | { ok: false; error: string } {
|
|
121
|
+
const edits: Edit[] = [];
|
|
122
|
+
for (const o of ops) {
|
|
123
|
+
switch (o.op) {
|
|
124
|
+
case "replace":
|
|
125
|
+
if (!o.anchor) return { ok: false, error: "replace needs `anchor` {line, hash}" };
|
|
126
|
+
if (!o.body) return { ok: false, error: "replace needs `body`" };
|
|
127
|
+
edits.push({ op: "replace", start: o.anchor, end: o.end, body: o.body });
|
|
128
|
+
break;
|
|
129
|
+
case "delete":
|
|
130
|
+
if (!o.anchor) return { ok: false, error: "delete needs `anchor` {line, hash}" };
|
|
131
|
+
edits.push({ op: "delete", start: o.anchor, end: o.end });
|
|
132
|
+
break;
|
|
133
|
+
case "insert_after":
|
|
134
|
+
case "insert_before":
|
|
135
|
+
if (!o.anchor) return { ok: false, error: `${o.op} needs \`anchor\` {line, hash}` };
|
|
136
|
+
if (!o.body) return { ok: false, error: `${o.op} needs \`body\`` };
|
|
137
|
+
edits.push({ op: o.op, anchor: o.anchor, body: o.body });
|
|
138
|
+
break;
|
|
139
|
+
case "append":
|
|
140
|
+
case "prepend":
|
|
141
|
+
if (!o.body) return { ok: false, error: `${o.op} needs \`body\`` };
|
|
142
|
+
edits.push({ op: o.op, body: o.body });
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
72
145
|
}
|
|
73
|
-
return
|
|
146
|
+
return { ok: true, edits };
|
|
74
147
|
}
|
|
75
148
|
|
|
76
149
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
150
|
+
* Fail the edit by throwing. pi's contract: a tool failure is signaled by throwing,
|
|
151
|
+
* not by returning `{ isError: true }` — the framework derives `context.isError` from
|
|
152
|
+
* whether execute threw, and overwrites `result.isError` with it
|
|
153
|
+
* (`updateResult({ ...result, isError: event.isError })`). Returning an isError object
|
|
154
|
+
* left the TUI rendering failures as success (green). The thrown message reaches the
|
|
155
|
+
* LLM verbatim; renderResult shows its first line in red.
|
|
80
156
|
*/
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
Array.isArray(params?.edits) ||
|
|
84
|
-
typeof params?.oldText === "string" ||
|
|
85
|
-
typeof params?.newText === "string";
|
|
86
|
-
if (legacy) {
|
|
87
|
-
return `⚠ ${path}: you sent the legacy oldText/newText format, but this edit tool ONLY accepts hashline \`input\`. The legacy path was intentionally removed so this is surfaced, not silently degraded. Re-read ${path} to get LINE#HASH anchors, then send \`input\` (e.g. \`replace 4#aF3:\` followed by \`+\` body rows).`;
|
|
88
|
-
}
|
|
89
|
-
return `Edit ${path}: missing \`input\` (hashline patch). Read ${path} first, then send \`input\` referencing LINE#HASH anchors.`;
|
|
157
|
+
function errResult(text: string): never {
|
|
158
|
+
throw new Error(text);
|
|
90
159
|
}
|
|
91
160
|
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Format the updated anchors (fresh LINE#HASH│content) for the touched new-file
|
|
163
|
+
* lines, so the model can chain edits without a re-read. Capped to bound tokens.
|
|
164
|
+
*/
|
|
165
|
+
function formatUpdatedAnchors(newText: string, touched: readonly number[], hashLen: number): string {
|
|
166
|
+
const newLines = splitLines(newText);
|
|
167
|
+
const newHashes = hashFileLines(newLines, hashLen);
|
|
168
|
+
const idxs = [...new Set(touched)].sort((a, b) => a - b);
|
|
169
|
+
if (idxs.length === 0) return "";
|
|
170
|
+
const rows = idxs.map((i) => `${i + 1}#${newHashes[i]}│${newLines[i]}`);
|
|
171
|
+
const shown = rows.length > MAX_ANCHOR_LINES ? rows.slice(0, MAX_ANCHOR_LINES) : rows;
|
|
172
|
+
const more = rows.length > MAX_ANCHOR_LINES ? `\n… (${rows.length - MAX_ANCHOR_LINES} more; re-read for full anchors)` : "";
|
|
173
|
+
return `\nUpdated anchors (use these for the next edit):\n${shown.join("\n")}${more}`;
|
|
99
174
|
}
|
|
100
175
|
|
|
101
176
|
export function makeEditOverride(cwd: string) {
|
|
@@ -105,32 +180,30 @@ export function makeEditOverride(cwd: string) {
|
|
|
105
180
|
name: "edit" as const,
|
|
106
181
|
label: "edit",
|
|
107
182
|
description:
|
|
108
|
-
"Edit a file via hashline
|
|
109
|
-
promptSnippet: "Edit files via hashline LINE#HASH anchors
|
|
183
|
+
"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.",
|
|
184
|
+
promptSnippet: "Edit files via hashline ops (edits[] with LINE#HASH anchors from read)",
|
|
110
185
|
promptGuidelines: [
|
|
111
|
-
"Pass `
|
|
112
|
-
"
|
|
113
|
-
"
|
|
114
|
-
"
|
|
115
|
-
"
|
|
186
|
+
"Pass `edits`: an array of ops. Each op = {op, anchor?, end?, body?}.",
|
|
187
|
+
"op ∈ replace | delete | insert_after | insert_before | append | prepend.",
|
|
188
|
+
"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.",
|
|
189
|
+
"body = string[] of new content lines (required for replace/insert/append/prepend; omit for delete).",
|
|
190
|
+
"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
191
|
],
|
|
117
192
|
parameters: editSchema,
|
|
118
|
-
renderShell: "
|
|
193
|
+
renderShell: "default" as const,
|
|
119
194
|
|
|
120
195
|
renderCall(args: Static<typeof editSchema>, theme: any) {
|
|
121
196
|
let text = theme.fg("toolTitle", theme.bold("edit "));
|
|
122
197
|
text += theme.fg("accent", args.path);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (firstOp) text += theme.fg("dim", ` — ${firstOp.trim()}`);
|
|
126
|
-
}
|
|
198
|
+
const n = args.edits?.length ?? 0;
|
|
199
|
+
if (n) text += theme.fg("dim", ` — ${n} op${n > 1 ? "s" : ""}: ${args.edits[0].op}`);
|
|
127
200
|
return new Text(text, 0, 0);
|
|
128
201
|
},
|
|
129
202
|
|
|
130
|
-
renderResult(result: any, { isPartial, expanded }: any, theme: any) {
|
|
203
|
+
renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
|
|
131
204
|
if (isPartial) return new Text(theme.fg("warning", "Editing…"), 0, 0);
|
|
132
205
|
const content = result.content?.[0];
|
|
133
|
-
if (
|
|
206
|
+
if (context.isError) {
|
|
134
207
|
const t = content?.type === "text" ? content.text.split("\n")[0] : "Error";
|
|
135
208
|
return new Text(theme.fg("error", t), 0, 0);
|
|
136
209
|
}
|
|
@@ -160,19 +233,19 @@ export function makeEditOverride(cwd: string) {
|
|
|
160
233
|
|
|
161
234
|
const path = params.path;
|
|
162
235
|
const absPath = canonicalPath(cwd, path);
|
|
163
|
-
const input = params.input;
|
|
164
236
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return errResult(missingInputError(path, params as any));
|
|
237
|
+
if (!params.edits?.length) {
|
|
238
|
+
return errResult(`Edit ${path}: \`edits\` is empty or missing.`);
|
|
168
239
|
}
|
|
169
240
|
// withFileMutationQueue serializes read-modify-write for the same file, preventing parallel-edit data loss
|
|
170
|
-
return await withFileMutationQueue(absPath, () => runHashline(absPath, path,
|
|
241
|
+
return await withFileMutationQueue(absPath, () => runHashline(absPath, path, params.edits, signal));
|
|
171
242
|
},
|
|
172
243
|
};
|
|
173
244
|
}
|
|
174
245
|
|
|
175
|
-
async function runHashline(absPath: string, displayPath: string,
|
|
246
|
+
async function runHashline(absPath: string, displayPath: string, editOps: readonly EditOpInput[], signal: AbortSignal | undefined) {
|
|
247
|
+
const { hashLen, shiftRadius } = getState().config;
|
|
248
|
+
|
|
176
249
|
let currentText: string;
|
|
177
250
|
try {
|
|
178
251
|
currentText = (await readFile(absPath)).toString("utf-8");
|
|
@@ -183,20 +256,17 @@ async function runHashline(absPath: string, displayPath: string, input: string,
|
|
|
183
256
|
// Check for cancel after read: if the user aborted, don't proceed to parse/apply; the file stays untouched
|
|
184
257
|
if (signal?.aborted) return errResult(`Edit ${displayPath} aborted before apply.`);
|
|
185
258
|
|
|
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
|
-
}
|
|
259
|
+
const translated = toCoreEdits(editOps);
|
|
260
|
+
if (!translated.ok) return errResult(translated.error);
|
|
196
261
|
|
|
197
|
-
|
|
262
|
+
// Anchors are verified against the current content. A line that changed (or a
|
|
263
|
+
// hash the model didn't actually read) fails its own anchor — but first we try
|
|
264
|
+
// shifted recovery: if the content merely moved within ±shiftRadius, a fresh
|
|
265
|
+
// anchor is returned so the model can retry without a re-read. All failures in
|
|
266
|
+
// the batch are collected (nothing written on any failure).
|
|
267
|
+
const result = applyEdits(currentText, translated.edits, hashLen, shiftRadius);
|
|
198
268
|
if (!result.ok) {
|
|
199
|
-
return errResult(
|
|
269
|
+
return errResult(formatFailure(result.failure, displayPath));
|
|
200
270
|
}
|
|
201
271
|
|
|
202
272
|
// Check for cancel before write: if aborted, don't touch the disk; the file stays untouched
|
|
@@ -209,20 +279,16 @@ async function runHashline(absPath: string, displayPath: string, input: string,
|
|
|
209
279
|
return errResult(`Error writing ${displayPath}: ${msg}`);
|
|
210
280
|
}
|
|
211
281
|
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
282
|
+
// pi's generateDiffString returns the display diff (colored by the renderer) and the first changed line
|
|
283
|
+
const { diff, firstChangedLine } = generateDiffString(currentText, result.text);
|
|
284
|
+
const details: EditToolDetails = {
|
|
285
|
+
diff,
|
|
286
|
+
patch: generateUnifiedPatch(displayPath, currentText, result.text),
|
|
287
|
+
firstChangedLine,
|
|
288
|
+
};
|
|
289
|
+
const anchors = formatUpdatedAnchors(result.text, result.touchedLines, hashLen);
|
|
215
290
|
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
|
-
},
|
|
291
|
+
content: [{ type: "text" as const, text: `Edited ${displayPath} (${translated.edits.length} op(s)).${anchors}` }],
|
|
292
|
+
details,
|
|
227
293
|
};
|
|
228
294
|
}
|