@d3ara1n/pi-hashline-edit 0.1.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 +59 -0
- package/package.json +28 -0
- package/src/core/apply.test.ts +185 -0
- package/src/core/apply.ts +150 -0
- package/src/core/diff.test.ts +21 -0
- package/src/core/diff.ts +39 -0
- package/src/core/hash.test.ts +49 -0
- package/src/core/hash.ts +94 -0
- package/src/core/index.ts +17 -0
- package/src/core/parse.test.ts +117 -0
- package/src/core/parse.ts +165 -0
- package/src/core/snapshot.test.ts +60 -0
- package/src/core/snapshot.ts +78 -0
- package/src/core/types.ts +70 -0
- package/src/index.ts +31 -0
- package/src/pi/config.ts +52 -0
- package/src/pi/edit-tool.ts +183 -0
- package/src/pi/execute.test.ts +124 -0
- package/src/pi/pi.test.ts +28 -0
- package/src/pi/read-tool.ts +98 -0
- package/src/pi/state.ts +79 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Override edit:只接受 hashline patch(`input`)。
|
|
3
|
+
*
|
|
4
|
+
* 不兼容旧 oldText/newText——发现旧格式输入时明确报错,让开发者知道
|
|
5
|
+
* 模型没用新方案,而非静默降级。容错仅限不影响结果的格式归一化
|
|
6
|
+
* (parse 层的可选冒号、CRLF 等);范式级兼容一概拒绝。
|
|
7
|
+
*
|
|
8
|
+
* 并发安全:read-modify-write 包在 withFileMutationQueue 里,串行化同文件
|
|
9
|
+
* 的多次 edit,防止 pi 默认并行执行下丢数据。响应 AbortSignal——读后/写前
|
|
10
|
+
* 检查,用户取消时不落盘。
|
|
11
|
+
*
|
|
12
|
+
* @module pi-hashline-edit/pi
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createEditTool, generateDiffString, generateUnifiedPatch, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Type, type Static } from "typebox";
|
|
17
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
18
|
+
import { applyEdits, parsePatch } from "../core/index.ts";
|
|
19
|
+
import type { Edit, FileSnapshot, PatchError } from "../core/types.ts";
|
|
20
|
+
import { canonicalPath } from "./read-tool.ts";
|
|
21
|
+
import { getState, getSnapshot, putSnapshot, recordSnapshot } from "./state.ts";
|
|
22
|
+
|
|
23
|
+
// schema 不声明 additionalProperties:false:模型误发旧 edits/oldText/newText
|
|
24
|
+
// 时,这些额外字段原样到达 execute,由 missingInputError 检测并明确拒绝。
|
|
25
|
+
// 依赖 typebox 默认允许额外属性 + pi validation 不 strip —— 勿改这两点。
|
|
26
|
+
|
|
27
|
+
const editSchema = Type.Object({
|
|
28
|
+
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
|
|
29
|
+
input: Type.Optional(
|
|
30
|
+
Type.String({
|
|
31
|
+
description:
|
|
32
|
+
"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.",
|
|
33
|
+
}),
|
|
34
|
+
),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** 把 PatchError 转成对模型有用的提示文本。 */
|
|
38
|
+
function errorText(e: PatchError, path: string): string {
|
|
39
|
+
switch (e.kind) {
|
|
40
|
+
case "stale":
|
|
41
|
+
return `File ${path} changed since your last read. Re-read it before editing.`;
|
|
42
|
+
case "anchor":
|
|
43
|
+
return `Anchor mismatch: ${e.message}. Re-read ${path} to get current line hashes (LINE#HASH).`;
|
|
44
|
+
case "collision":
|
|
45
|
+
return `Hash collision: ${e.message}. Re-read ${path}.`;
|
|
46
|
+
case "range":
|
|
47
|
+
return `Bad range: ${e.message}`;
|
|
48
|
+
case "noop":
|
|
49
|
+
return `Edit produced no change: ${e.message}`;
|
|
50
|
+
case "parse":
|
|
51
|
+
return `Parse error${e.line ? ` at line ${e.line}` : ""}: ${e.message}`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 取应用后首个变更行(用于 firstChangedLine / TUI 跳转)。insert_after 取锚 +1。 */
|
|
56
|
+
function minAnchorLine(edits: readonly Edit[]): number | undefined {
|
|
57
|
+
let min: number | undefined;
|
|
58
|
+
for (const e of edits) {
|
|
59
|
+
let line: number | undefined;
|
|
60
|
+
if (e.op === "replace" || e.op === "delete") line = e.start.line;
|
|
61
|
+
else if (e.op === "insert_after") line = e.anchor.line + 1;
|
|
62
|
+
else if (e.op === "insert_before") line = e.anchor.line;
|
|
63
|
+
else if (e.op === "prepend") line = 1;
|
|
64
|
+
// append 无锚,跳过(末尾追加)
|
|
65
|
+
if (line !== undefined && (min === undefined || line < min)) min = line;
|
|
66
|
+
}
|
|
67
|
+
return min;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 模型未用 hashline(发了旧 oldText/newText 或缺 input)→ 明确告知,不静默降级。
|
|
72
|
+
* 导出以便测试。params 用 any 以便检测 schema 外的旧格式字段。
|
|
73
|
+
*/
|
|
74
|
+
export function missingInputError(path: string, params: any): string {
|
|
75
|
+
const legacy =
|
|
76
|
+
Array.isArray(params?.edits) ||
|
|
77
|
+
typeof params?.oldText === "string" ||
|
|
78
|
+
typeof params?.newText === "string";
|
|
79
|
+
if (legacy) {
|
|
80
|
+
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).`;
|
|
81
|
+
}
|
|
82
|
+
return `Edit ${path}: missing \`input\` (hashline patch). Read ${path} first, then send \`input\` referencing LINE#HASH anchors.`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 构造错误 result(带 isError: true,让 TUI/agent loop 识别失败而非当成功)。 */
|
|
86
|
+
function errResult(text: string) {
|
|
87
|
+
return {
|
|
88
|
+
isError: true as const,
|
|
89
|
+
content: [{ type: "text" as const, text }],
|
|
90
|
+
details: undefined,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function makeEditOverride(cwd: string) {
|
|
95
|
+
const builtin = createEditTool(cwd);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
name: "edit" as const,
|
|
99
|
+
label: "edit",
|
|
100
|
+
description:
|
|
101
|
+
"Edit a file via hashline patch (LINE#HASH anchors, content-verified). Does NOT accept legacy oldText/newText.",
|
|
102
|
+
promptSnippet: "Edit files via hashline LINE#HASH anchors (input patch); legacy oldText/newText not accepted",
|
|
103
|
+
promptGuidelines: [
|
|
104
|
+
"Pass `input`: a hashline patch referencing `LINE#HASH` anchors copied from your latest read output (e.g. `replace 12#aF3:`).",
|
|
105
|
+
"Ops: `replace LINE#HASH[..LINE#HASH]:` · `delete LINE#HASH` · `insert_after LINE#HASH:` · `insert_before LINE#HASH:` · `append:` · `prepend:`.",
|
|
106
|
+
"Body rows start with `+` followed by the literal line. `+` alone = blank line. Literal `+`/`-` lines become `++`/`+-`.",
|
|
107
|
+
"After each successful edit, re-ground: line numbers shift, so take the next edit's anchors from a fresh read.",
|
|
108
|
+
"This tool does NOT accept legacy oldText/newText — sending those returns an error (intentional, so it's visible).",
|
|
109
|
+
],
|
|
110
|
+
parameters: editSchema,
|
|
111
|
+
renderShell: "self" as const,
|
|
112
|
+
|
|
113
|
+
async execute(toolCallId: string, params: Static<typeof editSchema>, signal: AbortSignal | undefined, onUpdate: any) {
|
|
114
|
+
const state = getState();
|
|
115
|
+
// 用户主动关闭 hashline(config.enabled=false)→ 透传内置
|
|
116
|
+
if (!state.config.enabled) return builtin.execute(toolCallId, params as any, signal, onUpdate);
|
|
117
|
+
|
|
118
|
+
const path = params.path;
|
|
119
|
+
const absPath = canonicalPath(cwd, path);
|
|
120
|
+
const input = params.input;
|
|
121
|
+
|
|
122
|
+
// 无 input → 明确告知(区分旧格式 vs 缺失),不静默降级
|
|
123
|
+
if (typeof input !== "string" || input.trim() === "") {
|
|
124
|
+
return errResult(missingInputError(path, params as any));
|
|
125
|
+
}
|
|
126
|
+
// withFileMutationQueue 串行化同文件的 read-modify-write,防并行 edit 丢数据
|
|
127
|
+
return await withFileMutationQueue(absPath, () => runHashline(absPath, path, input, signal));
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function runHashline(absPath: string, displayPath: string, input: string, signal: AbortSignal | undefined) {
|
|
133
|
+
let currentText: string;
|
|
134
|
+
try {
|
|
135
|
+
currentText = (await readFile(absPath)).toString("utf-8");
|
|
136
|
+
} catch (e) {
|
|
137
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
138
|
+
return errResult(`Error reading ${displayPath}: ${msg}`);
|
|
139
|
+
}
|
|
140
|
+
// 读后检查取消:用户 abort 则不继续 parse/apply,文件不变
|
|
141
|
+
if (signal?.aborted) return errResult(`Edit ${displayPath} aborted before apply.`);
|
|
142
|
+
|
|
143
|
+
// 取已记录快照;若无(模型未 read)则用当前文件建立——anchor 校验仍会强制
|
|
144
|
+
// 模型用真实 hash(没读过就猜不对),自然引导它先 read。
|
|
145
|
+
const snap: FileSnapshot = getSnapshot(absPath) ?? recordSnapshot(absPath, currentText);
|
|
146
|
+
|
|
147
|
+
// input 不含 file 头,prepend 一个让 parsePatch 通过(path 仅作标签)
|
|
148
|
+
const parsed = parsePatch(`file: ${absPath}\n\n${input}`);
|
|
149
|
+
if (!parsed.ok) {
|
|
150
|
+
return errResult(errorText(parsed.error, displayPath));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const result = applyEdits(currentText, parsed.patch.edits, snap);
|
|
154
|
+
if (!result.ok) {
|
|
155
|
+
return errResult(errorText(result.error, displayPath));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 写前检查取消:abort 则不落盘,文件不变
|
|
159
|
+
if (signal?.aborted) return errResult(`Edit ${displayPath} aborted before write.`);
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
await writeFile(absPath, result.text);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
165
|
+
return errResult(`Error writing ${displayPath}: ${msg}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 更新快照:连续 edit 无需重读(result.newSnapshot 基于新文本),走 LRU
|
|
169
|
+
putSnapshot(absPath, result.newSnapshot);
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
content: [
|
|
173
|
+
{ type: "text" as const, text: `Edited ${displayPath} (${parsed.patch.edits.length} op(s)).` },
|
|
174
|
+
],
|
|
175
|
+
details: {
|
|
176
|
+
// details.diff 必须用 pi 的 generateDiffString(+N content 格式),
|
|
177
|
+
// 内置 renderer 的 parseDiffLine 只认这个格式;core 的标准 unified diff 会被当纯文本灰显
|
|
178
|
+
diff: generateDiffString(currentText, result.text),
|
|
179
|
+
patch: generateUnifiedPatch(displayPath, currentText, result.text),
|
|
180
|
+
firstChangedLine: minAnchorLine(parsed.patch.edits),
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi 接入层 execute 集成测试:驱动真实的 makeReadOverride/makeEditOverride
|
|
3
|
+
* execute,覆盖文本 read 带锚、hashline edit 闭环、错误返回 isError。
|
|
4
|
+
*/
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { makeEditOverride } from "./edit-tool.ts";
|
|
11
|
+
import { makeReadOverride } from "./read-tool.ts";
|
|
12
|
+
import { clearSnapshots, getSnapshot } from "./state.ts";
|
|
13
|
+
|
|
14
|
+
async function withDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
15
|
+
const dir = await mkdtemp(join(tmpdir(), "hl-"));
|
|
16
|
+
clearSnapshots();
|
|
17
|
+
try {
|
|
18
|
+
return await fn(dir);
|
|
19
|
+
} finally {
|
|
20
|
+
await rm(dir, { recursive: true, force: true });
|
|
21
|
+
clearSnapshots();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const call = (tool: any, params: any) => tool.execute("0", params, undefined, undefined);
|
|
26
|
+
|
|
27
|
+
test("read execute:文本输出 LINE#HASH│content", async () => {
|
|
28
|
+
await withDir(async (dir) => {
|
|
29
|
+
await writeFile(join(dir, "f.txt"), "line1\nline2\n");
|
|
30
|
+
const read = makeReadOverride(dir);
|
|
31
|
+
const r: any = await call(read, { path: "f.txt" });
|
|
32
|
+
const text = r.content[0];
|
|
33
|
+
assert.equal(text.type, "text");
|
|
34
|
+
assert.match(text.text, /1#[0-9A-Z]+│line1/);
|
|
35
|
+
assert.match(text.text, /2#[0-9A-Z]+│line2/);
|
|
36
|
+
assert.match(text.text, /f\.txt · 2 lines/);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("read execute:记录 snapshot 供 edit 用", async () => {
|
|
41
|
+
await withDir(async (dir) => {
|
|
42
|
+
await writeFile(join(dir, "f.txt"), "a\nb\n");
|
|
43
|
+
const read = makeReadOverride(dir);
|
|
44
|
+
await call(read, { path: "f.txt" });
|
|
45
|
+
const snap = getSnapshot(join(dir, "f.txt"));
|
|
46
|
+
assert.ok(snap, "snapshot 未记录");
|
|
47
|
+
assert.equal(snap!.lineHashes.length, 2);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("edit execute:hashline 闭环(read → edit → 文件改)", async () => {
|
|
52
|
+
await withDir(async (dir) => {
|
|
53
|
+
const f = join(dir, "f.txt");
|
|
54
|
+
await writeFile(f, "a\nb\nc\n");
|
|
55
|
+
await call(makeReadOverride(dir), { path: "f.txt" });
|
|
56
|
+
const snap = getSnapshot(f)!;
|
|
57
|
+
const edit = makeEditOverride(dir);
|
|
58
|
+
const r: any = await call(edit, {
|
|
59
|
+
path: "f.txt",
|
|
60
|
+
input: `replace 2#${snap.lineHashes[1]}:\n+B`,
|
|
61
|
+
});
|
|
62
|
+
assert.equal(r.isError, undefined, "不应是错误");
|
|
63
|
+
assert.equal(await readFile(f, "utf-8"), "a\nB\nc\n");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("edit execute:连续 edit 复用更新后的 snapshot", async () => {
|
|
68
|
+
await withDir(async (dir) => {
|
|
69
|
+
const f = join(dir, "f.txt");
|
|
70
|
+
await writeFile(f, "a\nb\n");
|
|
71
|
+
const read = makeReadOverride(dir);
|
|
72
|
+
const edit = makeEditOverride(dir);
|
|
73
|
+
await call(read, { path: "f.txt" });
|
|
74
|
+
let snap = getSnapshot(f)!;
|
|
75
|
+
await call(edit, { path: "f.txt", input: `replace 1#${snap.lineHashes[0]}:\n+A` });
|
|
76
|
+
// 第二次 edit:snapshot 已被 edit 更新,用新 hash
|
|
77
|
+
snap = getSnapshot(f)!;
|
|
78
|
+
const r: any = await call(edit, { path: "f.txt", input: `replace 2#${snap.lineHashes[1]}:\n+B` });
|
|
79
|
+
assert.equal(r.isError, undefined);
|
|
80
|
+
assert.equal(await readFile(f, "utf-8"), "A\nB\n");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("edit execute:无 read 直接 edit → anchor 校验失败", async () => {
|
|
85
|
+
await withDir(async (dir) => {
|
|
86
|
+
await writeFile(join(dir, "f.txt"), "a\nb\n");
|
|
87
|
+
const edit = makeEditOverride(dir);
|
|
88
|
+
// 没 read 过,hash 是瞎写的
|
|
89
|
+
const r: any = await call(edit, { path: "f.txt", input: "replace 1#XXXX:\n+A" });
|
|
90
|
+
assert.equal(r.isError, true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("edit execute:缺 input → isError + missing 提示", async () => {
|
|
95
|
+
await withDir(async (dir) => {
|
|
96
|
+
await writeFile(join(dir, "f.txt"), "a\n");
|
|
97
|
+
const r: any = await call(makeEditOverride(dir), { path: "f.txt" });
|
|
98
|
+
assert.equal(r.isError, true);
|
|
99
|
+
assert.match(r.content[0].text, /missing/);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("edit execute:旧 oldText/newText → isError + legacy 提示", async () => {
|
|
104
|
+
await withDir(async (dir) => {
|
|
105
|
+
await writeFile(join(dir, "f.txt"), "a\n");
|
|
106
|
+
const r: any = await call(makeEditOverride(dir), {
|
|
107
|
+
path: "f.txt",
|
|
108
|
+
edits: [{ oldText: "a", newText: "b" }],
|
|
109
|
+
});
|
|
110
|
+
assert.equal(r.isError, true);
|
|
111
|
+
assert.match(r.content[0].text, /legacy/);
|
|
112
|
+
assert.match(r.content[0].text, /ONLY/);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("edit execute:parse 错误 → isError", async () => {
|
|
117
|
+
await withDir(async (dir) => {
|
|
118
|
+
await writeFile(join(dir, "f.txt"), "a\n");
|
|
119
|
+
await call(makeReadOverride(dir), { path: "f.txt" });
|
|
120
|
+
const r: any = await call(makeEditOverride(dir), { path: "f.txt", input: "SWAP 1#X:\n+a" });
|
|
121
|
+
assert.equal(r.isError, true);
|
|
122
|
+
assert.match(r.content[0].text, /Parse error|unknown verb/);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { canonicalPath } from "./read-tool.ts";
|
|
4
|
+
import { missingInputError } from "./edit-tool.ts";
|
|
5
|
+
|
|
6
|
+
test("canonicalPath resolve 相对/绝对", () => {
|
|
7
|
+
assert.equal(canonicalPath("/cwd", "foo.ts"), "/cwd/foo.ts");
|
|
8
|
+
assert.equal(canonicalPath("/cwd", "./foo.ts"), "/cwd/foo.ts");
|
|
9
|
+
assert.equal(canonicalPath("/cwd", "/abs/x.ts"), "/abs/x.ts");
|
|
10
|
+
});
|
|
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
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Override read:文本文件输出「行号#hash│内容」并记录快照;
|
|
3
|
+
* 非文本(图片/二进制)与读取错误透传内置 read,renderer 自动继承。
|
|
4
|
+
*
|
|
5
|
+
* @module pi-hashline-edit/pi
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createReadTool } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import { splitLines } from "../core/snapshot.ts";
|
|
12
|
+
import { getState, getSnapshot, recordSnapshot } from "./state.ts";
|
|
13
|
+
|
|
14
|
+
const MAX_LINES = 2000;
|
|
15
|
+
const MAX_BYTES = 256 * 1024;
|
|
16
|
+
|
|
17
|
+
/** canonical path:read/edit 共用,保证 snapshot key 一致。 */
|
|
18
|
+
export function canonicalPath(cwd: string, p: string): string {
|
|
19
|
+
return resolve(cwd, p);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 构造 read override(registerTool 的 ToolDefinition 片段)。 */
|
|
23
|
+
export function makeReadOverride(cwd: string) {
|
|
24
|
+
const builtin = createReadTool(cwd);
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
name: "read" as const,
|
|
28
|
+
label: "read",
|
|
29
|
+
description:
|
|
30
|
+
"Read file contents. Text files display per-line content hashes (LINE#HASH│content) for hashline-verified editing.",
|
|
31
|
+
promptSnippet: "Read files; each text line shows a content hash (LINE#HASH│content) anchoring it for edits",
|
|
32
|
+
promptGuidelines: [
|
|
33
|
+
'Text files display as `LINE#HASH│content` (e.g. `12#aF3│ return x`). The `#HASH` anchors each line for precise editing.',
|
|
34
|
+
"Pass `path`; optionally `offset` (1-indexed start line) and `limit` (max lines). Prefer read over cat/sed for files you intend to edit.",
|
|
35
|
+
],
|
|
36
|
+
parameters: builtin.parameters,
|
|
37
|
+
|
|
38
|
+
async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any) {
|
|
39
|
+
// 未启用 → 完全透传内置
|
|
40
|
+
// 未启用 或 用户已取消 → 透传内置(builtin 自行处理 abort)
|
|
41
|
+
if (!getState().config.enabled || signal?.aborted) return builtin.execute(toolCallId, params, signal, onUpdate);
|
|
42
|
+
|
|
43
|
+
const absPath = canonicalPath(cwd, params.path as string);
|
|
44
|
+
let buf: Buffer;
|
|
45
|
+
try {
|
|
46
|
+
buf = await readFile(absPath);
|
|
47
|
+
} catch {
|
|
48
|
+
// 读取错误 → 透传内置(它有完善的错误信息)
|
|
49
|
+
return builtin.execute(toolCallId, params, signal, onUpdate);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 二进制/图片检测(null 字节)→ 透传内置(内置用 file-type 处理图片)
|
|
53
|
+
if (buf.includes(0)) return builtin.execute(toolCallId, params, signal, onUpdate);
|
|
54
|
+
|
|
55
|
+
const text = buf.toString("utf-8");
|
|
56
|
+
const allLines = splitLines(text);
|
|
57
|
+
const totalLines = allLines.length;
|
|
58
|
+
|
|
59
|
+
// 记录全文快照(edit 锚基于全文行号)
|
|
60
|
+
const snap = recordSnapshot(absPath, text);
|
|
61
|
+
|
|
62
|
+
// offset/limit
|
|
63
|
+
const offset = (params.offset as number | undefined) ?? 1;
|
|
64
|
+
const limit = (params.limit as number | undefined) ?? MAX_LINES;
|
|
65
|
+
const startIdx = Math.max(0, offset - 1);
|
|
66
|
+
const endIdx = Math.min(totalLines, startIdx + limit);
|
|
67
|
+
|
|
68
|
+
const rows: string[] = [];
|
|
69
|
+
let bytes = 0;
|
|
70
|
+
let truncated = false;
|
|
71
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
72
|
+
const lineNo = i + 1;
|
|
73
|
+
const row = `${lineNo}#${snap.lineHashes[i]}│${allLines[i]}`;
|
|
74
|
+
bytes += Buffer.byteLength(row, "utf-8");
|
|
75
|
+
if (bytes > MAX_BYTES) {
|
|
76
|
+
truncated = true;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
rows.push(row);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const shownFrom = offset > 1 ? ` (from line ${offset})` : "";
|
|
83
|
+
const tail = truncated ? `\n… (truncated at ${MAX_BYTES >> 10}KB; use offset/limit to read more)` : "";
|
|
84
|
+
const header = `${params.path} · ${totalLines} lines${shownFrom}\n`;
|
|
85
|
+
const body = rows.join("\n");
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
content: [{ type: "text" as const, text: header + body + tail }],
|
|
89
|
+
details: undefined,
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
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
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session 级状态:文件快照存储(LRU)+ 配置。
|
|
3
|
+
*
|
|
4
|
+
* globalThis 单例(规避 Bun module identity 问题,见仓库 AGENTS)。
|
|
5
|
+
* 快照 LRU 驱逐(默认 64 个文件),防长会话内存无限增长——冷文件被新文件挤出。
|
|
6
|
+
* read 记录、edit 校验;key 为 canonical 绝对路径。
|
|
7
|
+
* config 放 state,session_start 重载。
|
|
8
|
+
*
|
|
9
|
+
* @module pi-hashline-edit/pi
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createSnapshot } from "../core/snapshot.ts";
|
|
13
|
+
import type { FileSnapshot } from "../core/types.ts";
|
|
14
|
+
import type { HashlineEditConfig } from "./config.ts";
|
|
15
|
+
|
|
16
|
+
const GLOBAL_KEY = "__piHashlineEdit";
|
|
17
|
+
const DEFAULT_CONFIG: HashlineEditConfig = { enabled: true, hashLen: 4 };
|
|
18
|
+
/** 最多缓存的文件快照数;超出按 LRU 驱逐最久未访问的。 */
|
|
19
|
+
const MAX_SNAPSHOTS = 64;
|
|
20
|
+
|
|
21
|
+
export interface HashlineEditState {
|
|
22
|
+
/** canonical path → 快照。LRU 顺序:Map 插入序,最近访问的在末尾。 */
|
|
23
|
+
readonly snapshots: Map<string, FileSnapshot>;
|
|
24
|
+
hashLen: number;
|
|
25
|
+
config: HashlineEditConfig;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getState(): HashlineEditState {
|
|
29
|
+
const g = globalThis as Record<string, unknown>;
|
|
30
|
+
const existing = g[GLOBAL_KEY];
|
|
31
|
+
if (existing) return existing as HashlineEditState;
|
|
32
|
+
const state: HashlineEditState = {
|
|
33
|
+
snapshots: new Map(),
|
|
34
|
+
hashLen: DEFAULT_CONFIG.hashLen,
|
|
35
|
+
config: DEFAULT_CONFIG,
|
|
36
|
+
};
|
|
37
|
+
g[GLOBAL_KEY] = state;
|
|
38
|
+
return state;
|
|
39
|
+
}
|
|
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
|
+
}
|