pi-hashline-edit-pro 0.15.3 → 0.15.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.15.3",
3
+ "version": "0.15.5",
4
4
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
5
5
  "main": "index.ts",
6
6
  "repository": {
@@ -47,22 +47,16 @@ export function genDiff(
47
47
  newContentHashes?: string[],
48
48
  oldHashes?: string[],
49
49
  ): { diff: string; firstChangedLine: number | undefined } {
50
- // Annotate each line with its hash so Diff.diffLines can distinguish
51
- // identical lines at different positions. The \0 separator cannot appear
52
- // in valid text file content.
50
+ // Run Diff.diffLines on raw content only (no hash annotations) so that
51
+ // lines whose content is identical are never reported as changed even
52
+ // when their hash differs due to collision resolution or position
53
+ // tracking. Hashes are used purely for display via fmtDiffLine.
53
54
  const oldLines = oldContent.split("\n");
54
- const effectiveOldHashes = oldHashes ?? _lineHashesPure(oldContent);
55
- const annotatedOld = oldLines
56
- .map((line, i) => `${line}\0${effectiveOldHashes[i] ?? ""}`)
57
- .join("\n");
58
-
59
55
  const newLines = newContent.split("\n");
56
+ const effectiveOldHashes = oldHashes ?? _lineHashesPure(oldContent);
60
57
  const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
61
- const annotatedNew = newLines
62
- .map((line, i) => `${line}\0${effectiveNewHashes[i] ?? ""}`)
63
- .join("\n");
64
58
 
65
- const parts = Diff.diffLines(annotatedOld, annotatedNew);
59
+ const parts = Diff.diffLines(oldContent, newContent);
66
60
  const output: string[] = [];
67
61
  let oldLineNum = 1;
68
62
  let newLineNum = 1;
@@ -73,11 +67,7 @@ export function genDiff(
73
67
  const part = parts[i]!;
74
68
  const raw = part.value.split("\n");
75
69
  if (raw[raw.length - 1] === "") raw.pop();
76
- // Strip \0hash annotation from each line for display
77
- const displayLines = raw.map((l) => {
78
- const nullIdx = l.indexOf("\0");
79
- return nullIdx >= 0 ? l.slice(0, nullIdx) : l;
80
- });
70
+ const displayLines = raw;
81
71
 
82
72
  if (part.added || part.removed) {
83
73
  if (firstChangedLine === undefined) firstChangedLine = newLineNum;
@@ -116,13 +106,13 @@ export function genDiff(
116
106
  }
117
107
 
118
108
  if (skipStart > 0) {
119
- output.push(` ...`);
109
+ output.push(" ...");
120
110
  oldLineNum += skipStart;
121
111
  newLineNum += skipStart;
122
112
  }
123
113
  for (const line of linesToShow) {
124
114
  if (line === "__ELLIPSIS__") {
125
- output.push(` ...`);
115
+ output.push(" ...");
126
116
  oldLineNum += skipMiddle;
127
117
  newLineNum += skipMiddle;
128
118
  continue;
@@ -1,313 +1,17 @@
1
- import { Markdown, Text } from "@earendil-works/pi-tui";
2
1
  import type {
3
2
  ExtensionAPI,
4
- ToolDefinition,
5
3
  } from "@earendil-works/pi-coding-agent";
6
- import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
7
- import { Type } from "typebox";
8
- import { constants } from "fs";
9
4
  import {
10
- genDiff,
11
- restoreEndings,
12
- } from "./replace-diff";
13
- import { readNormFile } from "./file-reader";
14
- import { normReq } from "./replace-normalize";
15
- import { isRec } from "./utils";
16
- import { MAX_HASH_LINES } from "./constants";
17
- import { resolveTarget, writeAtomic } from "./fs-write";
18
- import {
19
- applyEdits,
20
- resEdits,
21
- type HTEdit,
22
- } from "./hashline";
23
- import { toCwd } from "./path-utils";
24
- import { abortIf } from "./runtime";
25
- import { fileSnap } from "./snapshot";
26
- import {
27
- buildChanged,
28
- buildNoop,
29
- type RMeta,
30
- type RMetrics,
31
- } from "./replace-response";
32
- import {
33
- buildAppliedText,
34
- mkMdTheme,
35
- fmtCall,
36
- fmtResultMd,
37
- getPreviewInput,
38
- getResultText,
39
- isApplied,
40
- type RPreview,
41
- type RRState,
42
- } from "./replace-render";
43
- import { loadP, loadGuide } from "./prompts";
44
- import { execPipeline, compPreview, type ReplaceDetails } from "./replace";
45
- import { readAutoReadSync } from "./config";
46
-
47
- const contentLinesSchema = Type.Array(Type.String(), {
48
- description:
49
- "literal replacement file content, one string per line. Must not include the HASH│ prefix from read output.",
50
- });
51
-
52
- const hashRangeInclSchema = Type.Array(
53
- Type.String({ description: "anchor (3-char HASH)" }),
54
- {
55
- description: "inclusive hash range to replace [start_hash, end_hash]. Each element must be the 3-character hash anchor only; do not include the │ separator or line content.",
56
- minItems: 2,
57
- maxItems: 2,
58
- },
59
- );
60
-
61
- /**
62
- * Flat-mode schema: hash_range_inclusive and content_lines are at the top
63
- * level instead of inside a "changes" array. Only a single edit is supported
64
- * per call (no bulk changes).
65
- */
66
- export const flatEditToolSchema = Type.Object(
67
- {
68
- path: Type.String({ description: "path" }),
69
- hash_range_inclusive: hashRangeInclSchema,
70
- content_lines: contentLinesSchema,
71
- },
72
- { additionalProperties: false },
73
- );
74
-
75
- type ToolDef = ToolDefinition<
76
- typeof flatEditToolSchema,
77
- ReplaceDetails,
78
- RRState
79
- > & { renderShell?: "default" | "self" };
80
-
81
- function reuseText(context: any, content: string): Text {
82
- const t = context.lastComponent instanceof Text
83
- ? context.lastComponent
84
- : new Text("", 0, 0);
85
- t.setText(content);
86
- return t;
87
- }
88
-
89
- function reuseMarkdown(context: any, content: string, theme: any): Markdown {
90
- const m = context.lastComponent instanceof Markdown
91
- ? context.lastComponent
92
- : new Markdown("", 0, 0, mkMdTheme(theme));
93
- m.setText(content);
94
- return m;
95
- }
96
-
97
- export function buildToolDef(): ToolDef {
98
- const autoRead = readAutoReadSync();
99
- const readGuidance = autoRead
100
- ? "Anchors are provided automatically after write operations when auto-read is enabled."
101
- : "Call `read` to get fresh anchors for follow-up edits.";
102
-
103
- const E_DESC = loadP("../prompts/replace-flat.md", {
104
- AUTO_READ_GUIDANCE: readGuidance,
105
- });
106
- const E_SNIPPET = loadP("../prompts/replace-flat-snippet.md");
107
- const E_GUIDE = loadGuide("../prompts/replace-flat-guidelines.md", {
108
- AUTO_READ_GUIDANCE: readGuidance,
109
- });
110
-
111
- return {
112
- name: "replace",
113
- label: "Replace",
114
- description: E_DESC,
115
- parameters: flatEditToolSchema,
116
- promptSnippet: E_SNIPPET,
117
- promptGuidelines: E_GUIDE,
118
- prepareArguments: (args: unknown) => {
119
- // Minimal normalization: file_path → path, JSON string parsing.
120
- // The flat-to-canonical conversion happens in execute().
121
- if (!isRec(args)) return args as any;
122
- const record = { ...args };
123
- if (typeof record.path !== "string" && typeof record.file_path === "string") {
124
- record.path = record.file_path;
125
- delete record.file_path;
126
- }
127
- if (typeof record.hash_range_inclusive === "string") {
128
- try { record.hash_range_inclusive = JSON.parse(record.hash_range_inclusive as string); } catch { /* keep as-is */ }
129
- }
130
- if (typeof record.content_lines === "string") {
131
- try { record.content_lines = JSON.parse(record.content_lines as string); } catch { /* keep as-is */ }
132
- }
133
- return record as any;
134
- },
135
- renderShell: "default",
136
- renderCall(args, theme, context) {
137
- const previewInput = getPreviewInput(args);
138
- if (context.executionStarted) {
139
- context.state.argsKey = undefined;
140
- context.state.preview = undefined;
141
- context.state.previewGeneration =
142
- (context.state.previewGeneration ?? 0) + 1;
143
- } else if (!context.argsComplete || !previewInput) {
144
- context.state.argsKey = undefined;
145
- context.state.preview = undefined;
146
- context.state.previewGeneration =
147
- (context.state.previewGeneration ?? 0) + 1;
148
- } else {
149
- const argsKey = JSON.stringify(previewInput);
150
- if (context.state.argsKey !== argsKey) {
151
- context.state.argsKey = argsKey;
152
- context.state.preview = undefined;
153
- const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
154
- context.state.previewGeneration = previewGeneration;
155
- compPreview(previewInput, context.cwd)
156
- .then((preview) => {
157
- if (
158
- context.state.argsKey === argsKey &&
159
- context.state.previewGeneration === previewGeneration
160
- ) {
161
- context.state.preview = preview;
162
- context.invalidate();
163
- }
164
- })
165
- .catch((err: unknown) => {
166
- if (
167
- context.state.argsKey === argsKey &&
168
- context.state.previewGeneration === previewGeneration
169
- ) {
170
- context.state.preview = {
171
- error: err instanceof Error ? err.message : String(err),
172
- };
173
- context.invalidate();
174
- }
175
- });
176
- }
177
- }
178
- const text =
179
- (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
180
- text.setText(
181
- fmtCall(
182
- getPreviewInput(args) ?? undefined,
183
- context.state as RRState,
184
- context.expanded,
185
- theme,
186
- ),
187
- );
188
- return text;
189
- },
190
-
191
- renderResult(result, { isPartial }, theme, context) {
192
- if (isPartial) {
193
- return reuseText(context, theme.fg("warning", "Editing..."));
194
- }
195
-
196
- const typedResult = result as {
197
- content?: Array<{ type: string; text?: string }>;
198
- details?: ReplaceDetails;
199
- };
200
- const renderedText = getResultText(typedResult);
201
-
202
- const renderState = context.state as RRState | undefined;
203
- if (renderState) {
204
- renderState.preview = undefined;
205
- renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
206
- }
207
-
208
- if (context.isError) {
209
- return renderedText
210
- ? reuseText(context, `\n${theme.fg("error", renderedText)}`)
211
- : new Text("", 0, 0);
212
- }
213
-
214
- if (isApplied(typedResult.details)) {
215
- const appliedText = buildAppliedText(renderedText, typedResult.details, theme);
216
- return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
217
- }
218
-
219
- if (!renderedText) return new Text("", 0, 0);
220
- return reuseMarkdown(context, fmtResultMd(renderedText), theme);
221
- },
222
-
223
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
224
- // Wrap flat params into canonical shape for the pipeline
225
- const canonical = normReq({
226
- path: params.path,
227
- changes: [{
228
- hash_range_inclusive: params.hash_range_inclusive,
229
- content_lines: params.content_lines,
230
- }],
231
- });
232
- const normalizedParams = canonical as { path: string; changes: HTEdit[] };
233
- const path = normalizedParams.path;
234
- const absolutePath = toCwd(path, ctx.cwd);
235
- const mutationTargetPath = await resolveTarget(absolutePath);
236
- return withFileMutationQueue(mutationTargetPath, async () => {
237
- abortIf(signal);
238
-
239
- const {
240
- originalNormalized,
241
- originalHashes,
242
- result,
243
- bom,
244
- originalEnding,
245
- hadUtf8DecodeErrors,
246
- warnings,
247
- noopEdits,
248
- firstChangedLine,
249
- lastChangedLine,
250
- resultHashes,
251
- } = await execPipeline(
252
- normalizedParams,
253
- ctx.cwd,
254
- constants.R_OK | constants.W_OK,
255
- signal,
256
- );
257
-
258
- const editsAttempted = 1; // flat mode: exactly one edit per call
259
-
260
- if (originalNormalized === result) {
261
- const noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
262
- return buildNoop({
263
- path,
264
- noopEdits,
265
- snapshotId: noopSnapshotId,
266
- editMeta: {
267
- editsAttempted,
268
- noopEditsCount: noopEdits?.length ?? 0,
269
- },
270
- warnings,
271
- });
272
- }
273
-
274
- if (hadUtf8DecodeErrors) {
275
- warnings.push(
276
- "Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.",
277
- );
278
- }
279
-
280
- abortIf(signal);
281
- await writeAtomic(
282
- absolutePath,
283
- bom + restoreEndings(result, originalEnding),
284
- );
285
- const updatedSnapshotId = (await fileSnap(absolutePath))
286
- .snapshotId;
5
+ buildToolDef,
6
+ flatEditToolSchema,
7
+ } from "./replace";
287
8
 
288
- const editMeta: RMeta = {
289
- editsAttempted,
290
- noopEditsCount: noopEdits?.length ?? 0,
291
- firstChangedLine,
292
- lastChangedLine,
293
- };
9
+ export { flatEditToolSchema };
294
10
 
295
- const successInput = {
296
- path,
297
- originalNormalized,
298
- originalHashes,
299
- result,
300
- resultHashes,
301
- warnings,
302
- snapshotId: updatedSnapshotId,
303
- editMeta,
304
- };
305
- return buildChanged(successInput);
306
- });
307
- },
308
- };
11
+ export function buildToolDefFlat() {
12
+ return buildToolDef({ flat: true });
309
13
  }
310
14
 
311
15
  export function regReplaceFlat(pi: ExtensionAPI): void {
312
- pi.registerTool(buildToolDef());
16
+ pi.registerTool(buildToolDef({ flat: true }));
313
17
  }
package/src/replace.ts CHANGED
@@ -75,6 +75,20 @@ export const editToolSchema = Type.Object(
75
75
  { additionalProperties: false },
76
76
  );
77
77
 
78
+ /**
79
+ * Flat-mode schema: hash_range_inclusive and content_lines are at the top
80
+ * level instead of inside a "changes" array. Only a single edit is supported
81
+ * per call (no bulk changes).
82
+ */
83
+ export const flatEditToolSchema = Type.Object(
84
+ {
85
+ path: Type.String({ description: "path" }),
86
+ hash_range_inclusive: hashRangeInclSchema,
87
+ content_lines: contentLinesSchema,
88
+ },
89
+ { additionalProperties: false },
90
+ );
91
+
78
92
  export type ReqParams = {
79
93
  path: string;
80
94
  changes: HTEdit[];
@@ -101,7 +115,7 @@ interface PipelineResult {
101
115
  noopEdits?: { editIndex: number; loc: string; currentContent: string }[];
102
116
  firstChangedLine?: number;
103
117
  lastChangedLine?: number;
104
- originalHashes?: string[];
118
+ originalHashes: string[];
105
119
  resultHashes: string[];
106
120
  }
107
121
 
@@ -165,11 +179,23 @@ export async function execPipeline(
165
179
 
166
180
  const result = anchorResult.content;
167
181
 
168
- // Collect hashes targeted by the edit for hash-aware diff disambiguation
182
+ // Collect hashes targeted by the edit for hash-aware diff disambiguation.
183
+ // Include every hash in the replaced range, not just the boundary anchors,
184
+ // so that a surviving line whose content is identical to an interior line
185
+ // of the edit is not matched to a removed hash.
186
+ // Use originalHashes.indexOf to find line numbers since resEdits returns
187
+ // HEdit[] (Anchor = { hash }) not RHEdit[] (RAnchor = { line, hash }).
169
188
  const removedHashes = new Set<string>();
170
189
  for (const edit of resolved) {
171
- removedHashes.add(edit.hash_range_inclusive[0].hash);
172
- removedHashes.add(edit.hash_range_inclusive[1].hash);
190
+ const startHash = edit.hash_range_inclusive[0].hash;
191
+ const endHash = edit.hash_range_inclusive[1].hash;
192
+ const startLine = originalHashes.indexOf(startHash);
193
+ const endLine = originalHashes.indexOf(endHash);
194
+ if (startLine >= 0 && endLine >= 0) {
195
+ for (let i = startLine; i <= endLine; i++) {
196
+ removedHashes.add(originalHashes[i]!);
197
+ }
198
+ }
173
199
  }
174
200
 
175
201
  // Compute stable result hashes using hash-aware preservation
@@ -247,12 +273,12 @@ export async function compPreview(
247
273
  }
248
274
 
249
275
  type ToolDef = ToolDefinition<
250
- typeof editToolSchema,
276
+ any,
251
277
  ReplaceDetails,
252
278
  RRState
253
279
  > & { renderShell?: "default" | "self" };
254
280
 
255
- function reuseText(context: any, content: string): Text {
281
+ export function reuseText(context: any, content: string): Text {
256
282
  const t = context.lastComponent instanceof Text
257
283
  ? context.lastComponent
258
284
  : new Text("", 0, 0);
@@ -260,7 +286,7 @@ function reuseText(context: any, content: string): Text {
260
286
  return t;
261
287
  }
262
288
 
263
- function reuseMarkdown(context: any, content: string, theme: any): Markdown {
289
+ export function reuseMarkdown(context: any, content: string, theme: any): Markdown {
264
290
  const m = context.lastComponent instanceof Markdown
265
291
  ? context.lastComponent
266
292
  : new Markdown("", 0, 0, mkMdTheme(theme));
@@ -268,29 +294,49 @@ function reuseMarkdown(context: any, content: string, theme: any): Markdown {
268
294
  return m;
269
295
  }
270
296
 
271
- export function buildToolDef(): ToolDef {
297
+ export function buildToolDef(opts: { flat: boolean }): ToolDef {
272
298
  const autoRead = readAutoReadSync();
273
299
  const readGuidance = autoRead
274
300
  ? "Anchors are provided automatically after write operations when auto-read is enabled."
275
301
  : "Call `read` to get fresh anchors for follow-up edits.";
276
302
 
277
- const E_DESC = loadP("../prompts/replace-bulk.md", {
303
+ const E_DESC = loadP(opts.flat ? "../prompts/replace-flat.md" : "../prompts/replace-bulk.md", {
278
304
  AUTO_READ_GUIDANCE: readGuidance,
279
305
  });
280
- const E_SNIPPET = loadP("../prompts/replace-bulk-snippet.md");
281
- const E_GUIDE = loadGuide("../prompts/replace-bulk-guidelines.md", {
306
+ const E_SNIPPET = loadP(opts.flat ? "../prompts/replace-flat-snippet.md" : "../prompts/replace-bulk-snippet.md");
307
+ const E_GUIDE = loadGuide(opts.flat ? "../prompts/replace-flat-guidelines.md" : "../prompts/replace-bulk-guidelines.md", {
282
308
  AUTO_READ_GUIDANCE: readGuidance,
283
309
  });
284
310
 
311
+ const parameters = opts.flat ? flatEditToolSchema : editToolSchema;
312
+
285
313
  return {
286
314
  name: "replace",
287
315
  label: "Replace",
288
316
  description: E_DESC,
289
- parameters: editToolSchema,
317
+ parameters,
290
318
  promptSnippet: E_SNIPPET,
291
319
  promptGuidelines: E_GUIDE,
292
- prepareArguments: (args: unknown) =>
293
- normReq(args) as ReqParams,
320
+ prepareArguments: opts.flat
321
+ ? (args: unknown) => {
322
+ // Minimal normalization: file_path → path, JSON string parsing.
323
+ // The flat-to-canonical conversion happens in execute().
324
+ if (!isRec(args)) return args as any;
325
+ const record = { ...args };
326
+ if (typeof record.path !== "string" && typeof record.file_path === "string") {
327
+ record.path = record.file_path;
328
+ delete record.file_path;
329
+ }
330
+ if (typeof record.hash_range_inclusive === "string") {
331
+ try { record.hash_range_inclusive = JSON.parse(record.hash_range_inclusive as string); } catch { /* keep as-is */ }
332
+ }
333
+ if (typeof record.content_lines === "string") {
334
+ try { record.content_lines = JSON.parse(record.content_lines as string); } catch { /* keep as-is */ }
335
+ }
336
+ return record as any;
337
+ }
338
+ : (args: unknown) =>
339
+ normReq(args) as ReqParams,
294
340
  renderShell: "default",
295
341
  renderCall(args, theme, context) {
296
342
  const previewInput = getPreviewInput(args);
@@ -380,9 +426,18 @@ export function buildToolDef(): ToolDef {
380
426
  },
381
427
 
382
428
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
383
- const normalized = normReq(params);
384
- assertReq(normalized);
385
- const normalizedParams = normalized;
429
+ // Flat mode: wrap top-level fields into a single-element changes array.
430
+ // Bulk mode: use params as-is after normReq normalization.
431
+ const canonical = opts.flat
432
+ ? normReq({
433
+ path: (params as any).path,
434
+ changes: [{
435
+ hash_range_inclusive: (params as any).hash_range_inclusive,
436
+ content_lines: (params as any).content_lines,
437
+ }],
438
+ })
439
+ : normReq(params);
440
+ const normalizedParams = canonical as { path: string; changes: HTEdit[] };
386
441
  const path = normalizedParams.path;
387
442
  const absolutePath = toCwd(path, ctx.cwd);
388
443
  const mutationTargetPath = await resolveTarget(absolutePath);
@@ -408,9 +463,11 @@ export function buildToolDef(): ToolDef {
408
463
  signal,
409
464
  );
410
465
 
411
- const editsAttempted = Array.isArray(normalizedParams.changes)
412
- ? normalizedParams.changes.length
413
- : 0;
466
+ const editsAttempted = opts.flat
467
+ ? 1
468
+ : Array.isArray(normalizedParams.changes)
469
+ ? normalizedParams.changes.length
470
+ : 0;
414
471
 
415
472
  if (originalNormalized === result) {
416
473
  const noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
@@ -464,5 +521,5 @@ export function buildToolDef(): ToolDef {
464
521
  }
465
522
 
466
523
  export function regReplace(pi: ExtensionAPI): void {
467
- pi.registerTool(buildToolDef());
524
+ pi.registerTool(buildToolDef({ flat: false }));
468
525
  }