@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.
@@ -1,88 +1,109 @@
1
1
  /**
2
- * Override edit:只接受 hashline patch(`input`)。
2
+ * Override edit: hashline ops via structured `edits` (LINE#HASH anchors).
3
3
  *
4
- * 不兼容旧 oldText/newText——发现旧格式输入时明确报错,让开发者知道
5
- * 模型没用新方案,而非静默降级。容错仅限不影响结果的格式归一化
6
- * (parse 层的可选冒号、CRLF 等);范式级兼容一概拒绝。
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).
7
11
  *
8
- * 并发安全:read-modify-write 包在 withFileMutationQueue 里,串行化同文件
9
- * 的多次 edit,防止 pi 默认并行执行下丢数据。响应 AbortSignal——读后/写前
10
- * 检查,用户取消时不落盘。
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.
11
18
  *
12
19
  * @module pi-hashline-edit/pi
13
20
  */
14
21
 
15
- 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";
16
23
  import { Type, type Static } from "typebox";
24
+ import { Text } from "@earendil-works/pi-tui";
17
25
  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";
26
+ import { applyEdits, hashFileLines } from "../core/index.ts";
27
+ import { splitLines } from "../core/lines.ts";
28
+ import type { Edit, PatchError } from "../core/types.ts";
20
29
  import { canonicalPath } from "./read-tool.ts";
21
- import { getState, getSnapshot, putSnapshot, recordSnapshot } from "./state.ts";
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;
34
+
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
+ });
22
39
 
23
- // schema 不声明 additionalProperties:false:模型误发旧 edits/oldText/newText
24
- // 时,这些额外字段原样到达 execute,由 missingInputError 检测并明确拒绝。
25
- // 依赖 typebox 默认允许额外属性 + pi validation 不 strip —— 勿改这两点。
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
+ });
26
56
 
27
57
  const editSchema = Type.Object({
28
58
  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
- ),
59
+ edits: Type.Array(editOpSchema, { description: "Hashline ops, each referencing LINE#HASH anchors from your latest read or edit result" }),
35
60
  });
36
61
 
37
- /** PatchError 转成对模型有用的提示文本。 */
62
+ type EditOpInput = Static<typeof editOpSchema>;
63
+
64
+ /** Turn a PatchError into helpful hint text for the model. */
38
65
  function errorText(e: PatchError, path: string): string {
39
66
  switch (e.kind) {
40
- case "stale":
41
- return `File ${path} changed since your last read. Re-read it before editing.`;
42
67
  case "anchor":
43
68
  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
69
  case "range":
47
70
  return `Bad range: ${e.message}`;
48
71
  case "noop":
49
72
  return `Edit produced no change: ${e.message}`;
50
- case "parse":
51
- return `Parse error${e.line ? ` at line ${e.line}` : ""}: ${e.message}`;
52
73
  }
53
74
  }
54
75
 
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;
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
+ }
66
102
  }
67
- return min;
103
+ return { ok: true, edits };
68
104
  }
69
105
 
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 识别失败而非当成功)。 */
106
+ /** Build an error result (isError: true so the TUI/agent loop treats it as a failure). */
86
107
  function errResult(text: string) {
87
108
  return {
88
109
  isError: true as const,
@@ -91,6 +112,21 @@ function errResult(text: string) {
91
112
  };
92
113
  }
93
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
+
94
130
  export function makeEditOverride(cwd: string) {
95
131
  const builtin = createEditTool(cwd);
96
132
 
@@ -98,38 +134,72 @@ export function makeEditOverride(cwd: string) {
98
134
  name: "edit" as const,
99
135
  label: "edit",
100
136
  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",
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)",
103
139
  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/newTextsending those returns an error (intentional, so it's visible).",
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.",
109
145
  ],
110
146
  parameters: editSchema,
111
- renderShell: "self" as const,
147
+ renderShell: "default" as const,
148
+
149
+ renderCall(args: Static<typeof editSchema>, theme: any) {
150
+ let text = theme.fg("toolTitle", theme.bold("edit "));
151
+ text += theme.fg("accent", args.path);
152
+ const n = args.edits?.length ?? 0;
153
+ if (n) text += theme.fg("dim", ` — ${n} op${n > 1 ? "s" : ""}: ${args.edits[0].op}`);
154
+ return new Text(text, 0, 0);
155
+ },
156
+
157
+ renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
158
+ if (isPartial) return new Text(theme.fg("warning", "Editing…"), 0, 0);
159
+ const content = result.content?.[0];
160
+ if (context.isError) {
161
+ const t = content?.type === "text" ? content.text.split("\n")[0] : "Error";
162
+ return new Text(theme.fg("error", t), 0, 0);
163
+ }
164
+ const diff: string | undefined = result.details?.diff;
165
+ if (!diff) {
166
+ const t = content?.type === "text" ? content.text : "Edited";
167
+ return new Text(theme.fg("success", t), 0, 0);
168
+ }
169
+ // details.diff is pi-format (+N/-N/<space>N content); color by leading char
170
+ const allLines = diff.split("\n");
171
+ const shown = expanded ? allLines : allLines.slice(0, 24);
172
+ const body = shown
173
+ .map((line: string) => {
174
+ if (line.startsWith("+")) return theme.fg("success", line);
175
+ if (line.startsWith("-")) return theme.fg("error", line);
176
+ return theme.fg("dim", line);
177
+ })
178
+ .join("\n");
179
+ const more = !expanded && allLines.length > 24 ? `\n${theme.fg("dim", `… (${allLines.length - 24} more)`)}` : "";
180
+ return new Text(body + more, 0, 0);
181
+ },
112
182
 
113
183
  async execute(toolCallId: string, params: Static<typeof editSchema>, signal: AbortSignal | undefined, onUpdate: any) {
114
184
  const state = getState();
115
- // 用户主动关闭 hashline(config.enabled=false)→ 透传内置
185
+ // hashline disabled by the user (config.enabled=false) → delegate to the built-in
116
186
  if (!state.config.enabled) return builtin.execute(toolCallId, params as any, signal, onUpdate);
117
187
 
118
188
  const path = params.path;
119
189
  const absPath = canonicalPath(cwd, path);
120
- const input = params.input;
121
190
 
122
- // input → 明确告知(区分旧格式 vs 缺失),不静默降级
123
- if (typeof input !== "string" || input.trim() === "") {
124
- return errResult(missingInputError(path, params as any));
191
+ if (!params.edits?.length) {
192
+ return errResult(`Edit ${path}: \`edits\` is empty or missing.`);
125
193
  }
126
- // withFileMutationQueue 串行化同文件的 read-modify-write,防并行 edit 丢数据
127
- return await withFileMutationQueue(absPath, () => runHashline(absPath, path, input, signal));
194
+ // withFileMutationQueue serializes read-modify-write for the same file, preventing parallel-edit data loss
195
+ return await withFileMutationQueue(absPath, () => runHashline(absPath, path, params.edits, signal));
128
196
  },
129
197
  };
130
198
  }
131
199
 
132
- async function runHashline(absPath: string, displayPath: string, input: string, signal: AbortSignal | undefined) {
200
+ async function runHashline(absPath: string, displayPath: string, editOps: readonly EditOpInput[], signal: AbortSignal | undefined) {
201
+ const hashLen = getState().config.hashLen;
202
+
133
203
  let currentText: string;
134
204
  try {
135
205
  currentText = (await readFile(absPath)).toString("utf-8");
@@ -137,25 +207,21 @@ async function runHashline(absPath: string, displayPath: string, input: string,
137
207
  const msg = e instanceof Error ? e.message : String(e);
138
208
  return errResult(`Error reading ${displayPath}: ${msg}`);
139
209
  }
140
- // 读后检查取消:用户 abort 则不继续 parse/apply,文件不变
210
+ // Check for cancel after read: if the user aborted, don't proceed to parse/apply; the file stays untouched
141
211
  if (signal?.aborted) return errResult(`Edit ${displayPath} aborted before apply.`);
142
212
 
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
- }
213
+ const translated = toCoreEdits(editOps);
214
+ if (!translated.ok) return errResult(translated.error);
152
215
 
153
- const result = applyEdits(currentText, parsed.patch.edits, snap);
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);
154
220
  if (!result.ok) {
155
221
  return errResult(errorText(result.error, displayPath));
156
222
  }
157
223
 
158
- // 写前检查取消:abort 则不落盘,文件不变
224
+ // Check for cancel before write: if aborted, don't touch the disk; the file stays untouched
159
225
  if (signal?.aborted) return errResult(`Edit ${displayPath} aborted before write.`);
160
226
 
161
227
  try {
@@ -165,19 +231,16 @@ async function runHashline(absPath: string, displayPath: string, input: string,
165
231
  return errResult(`Error writing ${displayPath}: ${msg}`);
166
232
  }
167
233
 
168
- // 更新快照:连续 edit 无需重读(result.newSnapshot 基于新文本),走 LRU
169
- putSnapshot(absPath, result.newSnapshot);
170
-
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);
171
242
  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
- },
243
+ content: [{ type: "text" as const, text: `Edited ${displayPath} (${translated.edits.length} op(s)).${anchors}` }],
244
+ details,
182
245
  };
183
246
  }