@d3ara1n/pi-hashline-edit 0.1.0 → 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 +20 -21
- package/package.json +1 -1
- package/src/core/apply.test.ts +89 -101
- package/src/core/apply.ts +58 -47
- package/src/core/hash.test.ts +21 -16
- package/src/core/hash.ts +35 -57
- package/src/core/index.ts +4 -8
- package/src/core/lines.test.ts +30 -0
- package/src/core/lines.ts +42 -0
- package/src/core/types.ts +16 -34
- package/src/index.ts +8 -10
- package/src/pi/config.ts +8 -7
- package/src/pi/edit-tool.ts +159 -96
- package/src/pi/execute.test.ts +194 -47
- package/src/pi/pi.test.ts +1 -20
- package/src/pi/read-tool.ts +16 -19
- package/src/pi/state.ts +4 -57
- package/src/core/diff.test.ts +0 -21
- package/src/core/diff.ts +0 -39
- package/src/core/parse.test.ts +0 -117
- package/src/core/parse.ts +0 -165
- package/src/core/snapshot.test.ts +0 -60
- package/src/core/snapshot.ts +0 -78
package/src/pi/execute.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi
|
|
3
|
-
* execute
|
|
2
|
+
* pi integration execute tests: drive the real makeReadOverride/makeEditOverride
|
|
3
|
+
* execute, covering text read with anchors, the hashline edit round-trip,
|
|
4
|
+
* chained edits via returned anchors, and error returns with isError.
|
|
4
5
|
*/
|
|
5
6
|
import { test } from "node:test";
|
|
6
7
|
import assert from "node:assert/strict";
|
|
@@ -9,26 +10,36 @@ import { tmpdir } from "node:os";
|
|
|
9
10
|
import { join } from "node:path";
|
|
10
11
|
import { makeEditOverride } from "./edit-tool.ts";
|
|
11
12
|
import { makeReadOverride } from "./read-tool.ts";
|
|
12
|
-
import {
|
|
13
|
+
import { computeLineHash } from "../core/hash.ts";
|
|
14
|
+
import { splitLines } from "../core/lines.ts";
|
|
13
15
|
|
|
14
16
|
async function withDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
15
17
|
const dir = await mkdtemp(join(tmpdir(), "hl-"));
|
|
16
|
-
clearSnapshots();
|
|
17
18
|
try {
|
|
18
19
|
return await fn(dir);
|
|
19
20
|
} finally {
|
|
20
21
|
await rm(dir, { recursive: true, force: true });
|
|
21
|
-
clearSnapshots();
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
const call = (tool: any, params: any) => tool.execute("0", params, undefined, undefined);
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
/** Anchor a model would copy from read output for `line` of `text` (1-based). */
|
|
28
|
+
function h(text: string, line: number) {
|
|
29
|
+
return { line, hash: computeLineHash(line, splitLines(text)[line - 1]) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Extract a `LINE#HASH` anchor from a read/edit result text block. */
|
|
33
|
+
function anchorLine(block: string, line: number) {
|
|
34
|
+
const m = new RegExp(`^${line}#([0-9A-Z]+)│`, "m").exec(block);
|
|
35
|
+
if (!m) throw new Error(`line ${line} anchor not found in block`);
|
|
36
|
+
return { line, hash: m[1] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("read execute: text outputs LINE#HASH│content", async () => {
|
|
28
40
|
await withDir(async (dir) => {
|
|
29
41
|
await writeFile(join(dir, "f.txt"), "line1\nline2\n");
|
|
30
|
-
const
|
|
31
|
-
const r: any = await call(read, { path: "f.txt" });
|
|
42
|
+
const r: any = await call(makeReadOverride(dir), { path: "f.txt" });
|
|
32
43
|
const text = r.content[0];
|
|
33
44
|
assert.equal(text.type, "text");
|
|
34
45
|
assert.match(text.text, /1#[0-9A-Z]+│line1/);
|
|
@@ -37,88 +48,224 @@ test("read execute:文本输出 LINE#HASH│content", async () => {
|
|
|
37
48
|
});
|
|
38
49
|
});
|
|
39
50
|
|
|
40
|
-
test("
|
|
51
|
+
test("edit execute: hashline round-trip (read → edit → file changed)", async () => {
|
|
41
52
|
await withDir(async (dir) => {
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
await
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
const f = join(dir, "f.txt");
|
|
54
|
+
const text = "a\nb\nc\n";
|
|
55
|
+
await writeFile(f, text);
|
|
56
|
+
await call(makeReadOverride(dir), { path: "f.txt" });
|
|
57
|
+
const r: any = await call(makeEditOverride(dir), {
|
|
58
|
+
path: "f.txt",
|
|
59
|
+
edits: [{ op: "replace", anchor: h(text, 2), body: ["B"] }],
|
|
60
|
+
});
|
|
61
|
+
assert.equal(r.isError, undefined, "should not be an error");
|
|
62
|
+
assert.equal(await readFile(f, "utf-8"), "a\nB\nc\n");
|
|
48
63
|
});
|
|
49
64
|
});
|
|
50
65
|
|
|
51
|
-
test("edit execute
|
|
66
|
+
test("edit execute: multiple ops in one call", async () => {
|
|
52
67
|
await withDir(async (dir) => {
|
|
53
68
|
const f = join(dir, "f.txt");
|
|
54
|
-
|
|
69
|
+
const text = "a\nb\nc\n";
|
|
70
|
+
await writeFile(f, text);
|
|
55
71
|
await call(makeReadOverride(dir), { path: "f.txt" });
|
|
56
|
-
const
|
|
72
|
+
const r: any = await call(makeEditOverride(dir), {
|
|
73
|
+
path: "f.txt",
|
|
74
|
+
edits: [
|
|
75
|
+
{ op: "insert_after", anchor: h(text, 3), body: ["z"] },
|
|
76
|
+
{ op: "replace", anchor: h(text, 1), body: ["A"] },
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
assert.equal(r.isError, undefined);
|
|
80
|
+
assert.equal(await readFile(f, "utf-8"), "A\nb\nc\nz\n");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("edit result returns Updated anchors that chain the next edit without a re-read", async () => {
|
|
85
|
+
await withDir(async (dir) => {
|
|
86
|
+
const f = join(dir, "f.txt");
|
|
87
|
+
const text = "a\nb\nc\n";
|
|
88
|
+
await writeFile(f, text);
|
|
57
89
|
const edit = makeEditOverride(dir);
|
|
58
|
-
|
|
90
|
+
// first edit (model cites the read anchor for line 1)
|
|
91
|
+
const r1: any = await call(edit, {
|
|
59
92
|
path: "f.txt",
|
|
60
|
-
|
|
93
|
+
edits: [{ op: "replace", anchor: h(text, 1), body: ["A"] }],
|
|
61
94
|
});
|
|
62
|
-
assert.equal(
|
|
63
|
-
|
|
95
|
+
assert.equal(r1.isError, undefined);
|
|
96
|
+
const out: string = r1.content[0].text;
|
|
97
|
+
assert.match(out, /Updated anchors/);
|
|
98
|
+
// second edit chains on the anchor returned by the first edit — no read in between
|
|
99
|
+
const r2: any = await call(edit, {
|
|
100
|
+
path: "f.txt",
|
|
101
|
+
edits: [{ op: "replace", anchor: anchorLine(out, 1), body: ["AA"] }],
|
|
102
|
+
});
|
|
103
|
+
assert.equal(r2.isError, undefined);
|
|
104
|
+
assert.equal(await readFile(f, "utf-8"), "AA\nb\nc\n");
|
|
64
105
|
});
|
|
65
106
|
});
|
|
66
107
|
|
|
67
|
-
test("edit
|
|
108
|
+
test("edit result anchors cover an inserted block (chain an edit inside it)", async () => {
|
|
68
109
|
await withDir(async (dir) => {
|
|
69
110
|
const f = join(dir, "f.txt");
|
|
70
|
-
|
|
71
|
-
|
|
111
|
+
const text = "a\nb\n";
|
|
112
|
+
await writeFile(f, text);
|
|
72
113
|
const edit = makeEditOverride(dir);
|
|
73
|
-
await call(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
114
|
+
const r1: any = await call(edit, {
|
|
115
|
+
path: "f.txt",
|
|
116
|
+
edits: [{ op: "insert_after", anchor: h(text, 2), body: ["c", "d", "e"] }],
|
|
117
|
+
});
|
|
118
|
+
assert.equal(r1.isError, undefined);
|
|
119
|
+
const out: string = r1.content[0].text;
|
|
120
|
+
// line 4 (d, one of the inserted lines) must be anchored in the result
|
|
121
|
+
const a4 = anchorLine(out, 4);
|
|
122
|
+
const r2: any = await call(edit, {
|
|
123
|
+
path: "f.txt",
|
|
124
|
+
edits: [{ op: "replace", anchor: a4, body: ["DD"] }],
|
|
125
|
+
});
|
|
126
|
+
assert.equal(r2.isError, undefined);
|
|
127
|
+
assert.equal(await readFile(f, "utf-8"), "a\nb\nc\nDD\ne\n");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("unrelated external change does NOT block an edit on a stable line", async () => {
|
|
132
|
+
await withDir(async (dir) => {
|
|
133
|
+
const f = join(dir, "f.txt");
|
|
134
|
+
const text = "a\nb\nc\n";
|
|
135
|
+
await writeFile(f, text);
|
|
136
|
+
// simulate an external change at line 3 between read and edit
|
|
137
|
+
await writeFile(f, "a\nb\nCHANGED\n");
|
|
138
|
+
const r: any = await call(makeEditOverride(dir), {
|
|
139
|
+
path: "f.txt",
|
|
140
|
+
edits: [{ op: "replace", anchor: h(text, 1), body: ["A"] }],
|
|
141
|
+
});
|
|
79
142
|
assert.equal(r.isError, undefined);
|
|
80
|
-
assert.equal(await readFile(f, "utf-8"), "A\
|
|
143
|
+
assert.equal(await readFile(f, "utf-8"), "A\nb\nCHANGED\n");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("edit on a line that changed externally → anchor mismatch", async () => {
|
|
148
|
+
await withDir(async (dir) => {
|
|
149
|
+
const f = join(dir, "f.txt");
|
|
150
|
+
const text = "a\nb\nc\n";
|
|
151
|
+
await writeFile(f, text);
|
|
152
|
+
await writeFile(f, "a\nBCHANGED\nc\n"); // line 2 changed
|
|
153
|
+
const r: any = await call(makeEditOverride(dir), {
|
|
154
|
+
path: "f.txt",
|
|
155
|
+
edits: [{ op: "replace", anchor: h(text, 2), body: ["x"] }],
|
|
156
|
+
});
|
|
157
|
+
assert.equal(r.isError, true);
|
|
158
|
+
assert.match(r.content[0].text, /anchor|re-read/i);
|
|
81
159
|
});
|
|
82
160
|
});
|
|
83
161
|
|
|
84
|
-
test("edit execute
|
|
162
|
+
test("edit execute: no read before edit → anchor verification fails", async () => {
|
|
85
163
|
await withDir(async (dir) => {
|
|
86
164
|
await writeFile(join(dir, "f.txt"), "a\nb\n");
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
165
|
+
const r: any = await call(makeEditOverride(dir), {
|
|
166
|
+
path: "f.txt",
|
|
167
|
+
edits: [{ op: "replace", anchor: { line: 1, hash: "XXXX" }, body: ["A"] }],
|
|
168
|
+
});
|
|
90
169
|
assert.equal(r.isError, true);
|
|
91
170
|
});
|
|
92
171
|
});
|
|
93
172
|
|
|
94
|
-
test("edit execute
|
|
173
|
+
test("edit execute: empty edits → isError", async () => {
|
|
95
174
|
await withDir(async (dir) => {
|
|
96
175
|
await writeFile(join(dir, "f.txt"), "a\n");
|
|
97
|
-
const r: any = await call(makeEditOverride(dir), { path: "f.txt" });
|
|
176
|
+
const r: any = await call(makeEditOverride(dir), { path: "f.txt", edits: [] });
|
|
98
177
|
assert.equal(r.isError, true);
|
|
99
|
-
assert.match(r.content[0].text, /missing/);
|
|
178
|
+
assert.match(r.content[0].text, /empty|missing/i);
|
|
100
179
|
});
|
|
101
180
|
});
|
|
102
181
|
|
|
103
|
-
test("edit execute
|
|
182
|
+
test("edit execute: malformed op (replace without body) → isError", async () => {
|
|
104
183
|
await withDir(async (dir) => {
|
|
105
184
|
await writeFile(join(dir, "f.txt"), "a\n");
|
|
106
185
|
const r: any = await call(makeEditOverride(dir), {
|
|
107
186
|
path: "f.txt",
|
|
108
|
-
edits: [{
|
|
187
|
+
edits: [{ op: "replace", anchor: { line: 1, hash: "XX" } }],
|
|
109
188
|
});
|
|
110
189
|
assert.equal(r.isError, true);
|
|
111
|
-
assert.match(r.content[0].text, /
|
|
112
|
-
assert.match(r.content[0].text, /ONLY/);
|
|
190
|
+
assert.match(r.content[0].text, /body/i);
|
|
113
191
|
});
|
|
114
192
|
});
|
|
115
193
|
|
|
116
|
-
test("edit execute
|
|
194
|
+
test("edit execute: delete op", async () => {
|
|
117
195
|
await withDir(async (dir) => {
|
|
118
|
-
|
|
196
|
+
const f = join(dir, "f.txt");
|
|
197
|
+
const text = "a\nb\nc\n";
|
|
198
|
+
await writeFile(f, text);
|
|
199
|
+
await call(makeReadOverride(dir), { path: "f.txt" });
|
|
200
|
+
const r: any = await call(makeEditOverride(dir), {
|
|
201
|
+
path: "f.txt",
|
|
202
|
+
edits: [{ op: "delete", anchor: h(text, 2) }],
|
|
203
|
+
});
|
|
204
|
+
assert.equal(r.isError, undefined);
|
|
205
|
+
assert.equal(await readFile(f, "utf-8"), "a\nc\n");
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// --- renderer regression guards (details.diff must be a string, renderResult must not throw) ---
|
|
210
|
+
|
|
211
|
+
const stubTheme = { fg: (_k: string, s: string) => s, bold: (s: string) => s };
|
|
212
|
+
|
|
213
|
+
test("edit success: details.diff is a string (not the generateDiffString object)", async () => {
|
|
214
|
+
await withDir(async (dir) => {
|
|
215
|
+
const f = join(dir, "f.txt");
|
|
216
|
+
const text = "a\nb\nc\n";
|
|
217
|
+
await writeFile(f, text);
|
|
119
218
|
await call(makeReadOverride(dir), { path: "f.txt" });
|
|
120
|
-
const r: any = await call(makeEditOverride(dir), {
|
|
219
|
+
const r: any = await call(makeEditOverride(dir), {
|
|
220
|
+
path: "f.txt",
|
|
221
|
+
edits: [{ op: "replace", anchor: h(text, 2), body: ["B"] }],
|
|
222
|
+
});
|
|
223
|
+
assert.equal(typeof r.details.diff, "string", "details.diff must be a string");
|
|
224
|
+
assert.equal(typeof r.details.patch, "string");
|
|
225
|
+
assert.equal(typeof r.details.firstChangedLine, "number");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("edit success: renderResult renders the diff without throwing", async () => {
|
|
230
|
+
await withDir(async (dir) => {
|
|
231
|
+
const f = join(dir, "f.txt");
|
|
232
|
+
const text = "a\nb\nc\n";
|
|
233
|
+
await writeFile(f, text);
|
|
234
|
+
const edit = makeEditOverride(dir);
|
|
235
|
+
const r: any = await call(edit, {
|
|
236
|
+
path: "f.txt",
|
|
237
|
+
edits: [{ op: "replace", anchor: h(text, 2), body: ["B"] }],
|
|
238
|
+
});
|
|
239
|
+
// @ts-ignore — drive the renderer with a stub theme
|
|
240
|
+
const comp: any = edit.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: true }, stubTheme, { isError: r.isError ?? false });
|
|
241
|
+
assert.ok(typeof comp?.text === "string");
|
|
242
|
+
assert.ok(comp.text.includes("B"), "rendered diff should contain the new content");
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("edit error: renderResult renders the error line without throwing", async () => {
|
|
247
|
+
await withDir(async (dir) => {
|
|
248
|
+
await writeFile(join(dir, "f.txt"), "a\n");
|
|
249
|
+
const edit = makeEditOverride(dir);
|
|
250
|
+
const r: any = await call(edit, {
|
|
251
|
+
path: "f.txt",
|
|
252
|
+
edits: [{ op: "replace", anchor: { line: 1, hash: "XXXX" }, body: ["A"] }],
|
|
253
|
+
});
|
|
121
254
|
assert.equal(r.isError, true);
|
|
122
|
-
|
|
255
|
+
// @ts-ignore
|
|
256
|
+
const comp: any = edit.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: false }, stubTheme, { isError: r.isError ?? false });
|
|
257
|
+
assert.ok(typeof comp?.text === "string");
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("hash length stays 4 even for runs of identical lines (no explosion)", async () => {
|
|
262
|
+
await withDir(async (dir) => {
|
|
263
|
+
const f = join(dir, "f.txt");
|
|
264
|
+
await writeFile(f, "\n\n\n\ncode\n");
|
|
265
|
+
const r: any = await call(makeReadOverride(dir), { path: "f.txt" });
|
|
266
|
+
const text: string = r.content[0].text;
|
|
267
|
+
for (const m of text.matchAll(/\d+#([0-9A-Z]+)│/g)) {
|
|
268
|
+
assert.equal(m[1].length, 4, `anchor ${m[0]} hash is not 4 chars`);
|
|
269
|
+
}
|
|
123
270
|
});
|
|
124
271
|
});
|
package/src/pi/pi.test.ts
CHANGED
|
@@ -1,28 +1,9 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { canonicalPath } from "./read-tool.ts";
|
|
4
|
-
import { missingInputError } from "./edit-tool.ts";
|
|
5
4
|
|
|
6
|
-
test("canonicalPath
|
|
5
|
+
test("canonicalPath resolves relative and absolute", () => {
|
|
7
6
|
assert.equal(canonicalPath("/cwd", "foo.ts"), "/cwd/foo.ts");
|
|
8
7
|
assert.equal(canonicalPath("/cwd", "./foo.ts"), "/cwd/foo.ts");
|
|
9
8
|
assert.equal(canonicalPath("/cwd", "/abs/x.ts"), "/abs/x.ts");
|
|
10
9
|
});
|
|
11
|
-
|
|
12
|
-
test("missingInputError: edits 数组 → 明确告知不降级", () => {
|
|
13
|
-
const msg = missingInputError("f.ts", { edits: [{ oldText: "a", newText: "b" }] });
|
|
14
|
-
assert.ok(msg.includes("legacy"), msg);
|
|
15
|
-
assert.ok(msg.includes("ONLY"), msg);
|
|
16
|
-
assert.ok(msg.includes("f.ts"), msg);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test("missingInputError: 顶层 oldText/newText 也识别为旧格式", () => {
|
|
20
|
-
const msg = missingInputError("f.ts", { oldText: "a", newText: "b" });
|
|
21
|
-
assert.ok(msg.includes("legacy"));
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test("missingInputError: 仅缺 input(非旧格式)", () => {
|
|
25
|
-
const msg = missingInputError("f.ts", {});
|
|
26
|
-
assert.ok(msg.includes("missing"));
|
|
27
|
-
assert.ok(!msg.includes("legacy"));
|
|
28
|
-
});
|
package/src/pi/read-tool.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Override read
|
|
3
|
-
*
|
|
2
|
+
* Override read: text files output "lineNo#hash│content"; non-text (images /
|
|
3
|
+
* binary) and read errors delegate to the built-in read.
|
|
4
|
+
*
|
|
5
|
+
* Hashes are computed from the current content on the fly — nothing is stored.
|
|
6
|
+
* The hash is `(line number, content)`, recomputed and checked at edit time, so
|
|
7
|
+
* no snapshot is needed to verify an anchor later.
|
|
4
8
|
*
|
|
5
9
|
* @module pi-hashline-edit/pi
|
|
6
10
|
*/
|
|
@@ -8,18 +12,19 @@
|
|
|
8
12
|
import { createReadTool } from "@earendil-works/pi-coding-agent";
|
|
9
13
|
import { readFile } from "node:fs/promises";
|
|
10
14
|
import { resolve } from "node:path";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
15
|
+
import { hashFileLines } from "../core/hash.ts";
|
|
16
|
+
import { splitLines } from "../core/lines.ts";
|
|
17
|
+
import { getState } from "./state.ts";
|
|
13
18
|
|
|
14
19
|
const MAX_LINES = 2000;
|
|
15
20
|
const MAX_BYTES = 256 * 1024;
|
|
16
21
|
|
|
17
|
-
/** canonical path
|
|
22
|
+
/** canonical path: shared by read/edit to resolve a file consistently. */
|
|
18
23
|
export function canonicalPath(cwd: string, p: string): string {
|
|
19
24
|
return resolve(cwd, p);
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
/**
|
|
27
|
+
/** Build the read override (a ToolDefinition fragment for registerTool). */
|
|
23
28
|
export function makeReadOverride(cwd: string) {
|
|
24
29
|
const builtin = createReadTool(cwd);
|
|
25
30
|
|
|
@@ -36,8 +41,7 @@ export function makeReadOverride(cwd: string) {
|
|
|
36
41
|
parameters: builtin.parameters,
|
|
37
42
|
|
|
38
43
|
async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any) {
|
|
39
|
-
//
|
|
40
|
-
// 未启用 或 用户已取消 → 透传内置(builtin 自行处理 abort)
|
|
44
|
+
// Not enabled OR user cancelled → delegate to the built-in (builtin handles abort itself)
|
|
41
45
|
if (!getState().config.enabled || signal?.aborted) return builtin.execute(toolCallId, params, signal, onUpdate);
|
|
42
46
|
|
|
43
47
|
const absPath = canonicalPath(cwd, params.path as string);
|
|
@@ -45,19 +49,17 @@ export function makeReadOverride(cwd: string) {
|
|
|
45
49
|
try {
|
|
46
50
|
buf = await readFile(absPath);
|
|
47
51
|
} catch {
|
|
48
|
-
//
|
|
52
|
+
// read error → delegate to the built-in (it has polished error messages)
|
|
49
53
|
return builtin.execute(toolCallId, params, signal, onUpdate);
|
|
50
54
|
}
|
|
51
55
|
|
|
52
|
-
//
|
|
56
|
+
// binary/image detection (null byte) → delegate to the built-in (it uses file-type for images)
|
|
53
57
|
if (buf.includes(0)) return builtin.execute(toolCallId, params, signal, onUpdate);
|
|
54
58
|
|
|
55
59
|
const text = buf.toString("utf-8");
|
|
56
60
|
const allLines = splitLines(text);
|
|
57
61
|
const totalLines = allLines.length;
|
|
58
|
-
|
|
59
|
-
// 记录全文快照(edit 锚基于全文行号)
|
|
60
|
-
const snap = recordSnapshot(absPath, text);
|
|
62
|
+
const hashes = hashFileLines(allLines, getState().config.hashLen);
|
|
61
63
|
|
|
62
64
|
// offset/limit
|
|
63
65
|
const offset = (params.offset as number | undefined) ?? 1;
|
|
@@ -70,7 +72,7 @@ export function makeReadOverride(cwd: string) {
|
|
|
70
72
|
let truncated = false;
|
|
71
73
|
for (let i = startIdx; i < endIdx; i++) {
|
|
72
74
|
const lineNo = i + 1;
|
|
73
|
-
const row = `${lineNo}#${
|
|
75
|
+
const row = `${lineNo}#${hashes[i]}│${allLines[i]}`;
|
|
74
76
|
bytes += Buffer.byteLength(row, "utf-8");
|
|
75
77
|
if (bytes > MAX_BYTES) {
|
|
76
78
|
truncated = true;
|
|
@@ -91,8 +93,3 @@ export function makeReadOverride(cwd: string) {
|
|
|
91
93
|
},
|
|
92
94
|
};
|
|
93
95
|
}
|
|
94
|
-
|
|
95
|
-
/** 供 edit override 复用:取某 path 的已记录快照(按 canonical path)。 */
|
|
96
|
-
export function lookupSnapshot(cwd: string, p: string) {
|
|
97
|
-
return getSnapshot(canonicalPath(cwd, p));
|
|
98
|
-
}
|
package/src/pi/state.ts
CHANGED
|
@@ -1,27 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session
|
|
2
|
+
* Session-level config holder.
|
|
3
3
|
*
|
|
4
|
-
* globalThis
|
|
5
|
-
*
|
|
6
|
-
* read 记录、edit 校验;key 为 canonical 绝对路径。
|
|
7
|
-
* config 放 state,session_start 重载。
|
|
4
|
+
* globalThis singleton (consistent with the repo's module-identity guidance).
|
|
5
|
+
* Config is loaded on session_start and read by the read/edit overrides.
|
|
8
6
|
*
|
|
9
7
|
* @module pi-hashline-edit/pi
|
|
10
8
|
*/
|
|
11
9
|
|
|
12
|
-
import { createSnapshot } from "../core/snapshot.ts";
|
|
13
|
-
import type { FileSnapshot } from "../core/types.ts";
|
|
14
10
|
import type { HashlineEditConfig } from "./config.ts";
|
|
15
11
|
|
|
16
12
|
const GLOBAL_KEY = "__piHashlineEdit";
|
|
17
13
|
const DEFAULT_CONFIG: HashlineEditConfig = { enabled: true, hashLen: 4 };
|
|
18
|
-
/** 最多缓存的文件快照数;超出按 LRU 驱逐最久未访问的。 */
|
|
19
|
-
const MAX_SNAPSHOTS = 64;
|
|
20
14
|
|
|
21
15
|
export interface HashlineEditState {
|
|
22
|
-
/** canonical path → 快照。LRU 顺序:Map 插入序,最近访问的在末尾。 */
|
|
23
|
-
readonly snapshots: Map<string, FileSnapshot>;
|
|
24
|
-
hashLen: number;
|
|
25
16
|
config: HashlineEditConfig;
|
|
26
17
|
}
|
|
27
18
|
|
|
@@ -29,51 +20,7 @@ export function getState(): HashlineEditState {
|
|
|
29
20
|
const g = globalThis as Record<string, unknown>;
|
|
30
21
|
const existing = g[GLOBAL_KEY];
|
|
31
22
|
if (existing) return existing as HashlineEditState;
|
|
32
|
-
const state: HashlineEditState = {
|
|
33
|
-
snapshots: new Map(),
|
|
34
|
-
hashLen: DEFAULT_CONFIG.hashLen,
|
|
35
|
-
config: DEFAULT_CONFIG,
|
|
36
|
-
};
|
|
23
|
+
const state: HashlineEditState = { config: DEFAULT_CONFIG };
|
|
37
24
|
g[GLOBAL_KEY] = state;
|
|
38
25
|
return state;
|
|
39
26
|
}
|
|
40
|
-
|
|
41
|
-
/** 写入快照并维持 LRU:移到末尾(最近使用),超限驱逐最旧(Map 首项)。 */
|
|
42
|
-
function touchAndEvict(map: Map<string, FileSnapshot>, path: string, snap: FileSnapshot): void {
|
|
43
|
-
if (map.has(path)) map.delete(path);
|
|
44
|
-
map.set(path, snap);
|
|
45
|
-
while (map.size > MAX_SNAPSHOTS) {
|
|
46
|
-
const oldest = map.keys().next().value;
|
|
47
|
-
if (oldest === undefined) break;
|
|
48
|
-
map.delete(oldest);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** 记录文件快照(read 时调用):算行 hash + LRU。 */
|
|
53
|
-
export function recordSnapshot(canonicalPath: string, text: string): FileSnapshot {
|
|
54
|
-
const state = getState();
|
|
55
|
-
const snap = createSnapshot(canonicalPath, text, state.hashLen);
|
|
56
|
-
touchAndEvict(state.snapshots, canonicalPath, snap);
|
|
57
|
-
return snap;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** 存入已算好的快照(edit 成功后更新),走 LRU。 */
|
|
61
|
-
export function putSnapshot(canonicalPath: string, snap: FileSnapshot): void {
|
|
62
|
-
touchAndEvict(getState().snapshots, canonicalPath, snap);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** 取文件快照(edit 校验用);命中时移到末尾(LRU touch)。 */
|
|
66
|
-
export function getSnapshot(canonicalPath: string): FileSnapshot | undefined {
|
|
67
|
-
const map = getState().snapshots;
|
|
68
|
-
const snap = map.get(canonicalPath);
|
|
69
|
-
if (snap) {
|
|
70
|
-
map.delete(canonicalPath);
|
|
71
|
-
map.set(canonicalPath, snap);
|
|
72
|
-
}
|
|
73
|
-
return snap;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** 清空快照(session 重启时)。 */
|
|
77
|
-
export function clearSnapshots(): void {
|
|
78
|
-
getState().snapshots.clear();
|
|
79
|
-
}
|
package/src/core/diff.test.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { test } from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { buildDiff } from "./diff.ts";
|
|
4
|
-
|
|
5
|
-
test("单 hunk", () => {
|
|
6
|
-
const d = buildDiff("f.ts", ["a", "b", "c"], [{ lo: 1, hi: 2, newLines: ["X"] }]);
|
|
7
|
-
assert.ok(d.startsWith("--- a/f.ts\n"));
|
|
8
|
-
assert.ok(d.includes("+++ b/f.ts"));
|
|
9
|
-
assert.ok(d.includes("@@ -2 +2 @@"));
|
|
10
|
-
assert.ok(d.includes("-b"));
|
|
11
|
-
assert.ok(d.includes("+X"));
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
test("多行 range hunk 带计数", () => {
|
|
15
|
-
const d = buildDiff("f", ["a", "b", "c", "d"], [{ lo: 0, hi: 3, newLines: ["X"] }]);
|
|
16
|
-
assert.ok(d.includes("@@ -1,3 +1 @@")); // newCount=1 时省略计数(git 惯例)
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test("空 ops 返回空串", () => {
|
|
20
|
-
assert.equal(buildDiff("f", ["a"], []), "");
|
|
21
|
-
});
|
package/src/core/diff.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 基于 ops 的 unified diff 预览。
|
|
3
|
-
*
|
|
4
|
-
* 每个 SpanOp 生成一个 hunk,`@@` 行号基于原始文件(多 op 时各自的原始位置),
|
|
5
|
-
* 内容准确。这是 Phase 1 的近似实现;如需精确的多 op 行号可后续换 LCS。
|
|
6
|
-
*
|
|
7
|
-
* @module pi-hashline-edit/core
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
interface SpanOpLike {
|
|
11
|
-
lo: number;
|
|
12
|
-
hi: number;
|
|
13
|
-
newLines: string[];
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* 生成 unified diff。
|
|
18
|
-
*
|
|
19
|
-
* @param path 文件路径(用于 diff 头)
|
|
20
|
-
* @param oldLines 应用前的原始行数组
|
|
21
|
-
* @param ops 已应用的行级操作
|
|
22
|
-
*/
|
|
23
|
-
export function buildDiff(path: string, oldLines: readonly string[], ops: readonly SpanOpLike[]): string {
|
|
24
|
-
if (ops.length === 0) return "";
|
|
25
|
-
const out: string[] = [`--- a/${path}`, `+++ b/${path}`];
|
|
26
|
-
for (const op of ops) {
|
|
27
|
-
const oldCount = op.hi - op.lo;
|
|
28
|
-
const oldStart = oldCount === 0 ? op.lo : op.lo + 1; // 零宽(插入点)用 lo,符合 unified-diff "after line N" 惯例
|
|
29
|
-
const newCount = op.newLines.length;
|
|
30
|
-
const newStart = op.lo + 1;
|
|
31
|
-
// 单行 hunk 省略计数,符合 unified-diff 惯例
|
|
32
|
-
const oldRange = oldCount === 1 ? `${oldStart}` : `${oldStart},${oldCount}`;
|
|
33
|
-
const newRange = newCount === 1 ? `${newStart}` : `${newStart},${newCount}`;
|
|
34
|
-
out.push(`@@ -${oldRange} +${newRange} @@`);
|
|
35
|
-
for (let i = op.lo; i < op.hi; i++) out.push(`-${oldLines[i]}`);
|
|
36
|
-
for (const nl of op.newLines) out.push(`+${nl}`);
|
|
37
|
-
}
|
|
38
|
-
return out.join("\n") + "\n";
|
|
39
|
-
}
|