pi-hashline-edit-pro 0.13.0 → 0.13.1

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/index.ts CHANGED
@@ -4,7 +4,7 @@ import { join, isAbsolute } from "path";
4
4
  import { lineHashes, initHasher, fmtRegion } from "./src/hashline";
5
5
  import { regReplace } from "./src/replace";
6
6
  import { regRead, formatPaginationHint } from "./src/read";
7
- import { toLF } from "./src/replace-diff";
7
+ import { toLF, stripBOM } from "./src/replace-diff";
8
8
  import { visLines } from "./src/utils";
9
9
  import { AUTO_READ_MAX } from "./src/constants";
10
10
 
@@ -12,10 +12,15 @@ export default function (pi: ExtensionAPI): void {
12
12
  regRead(pi);
13
13
  regReplace(pi);
14
14
 
15
+ const debugValue = process.env.PI_HASHLINE_DEBUG;
16
+
15
17
  pi.on("session_start", async (_event, ctx) => {
16
18
  const active = pi.getActiveTools();
17
19
  pi.setActiveTools(active.filter((t) => t !== "edit"));
18
20
  await initHasher();
21
+ if (debugValue === "1" || debugValue === "true") {
22
+ ctx.ui.notify("Hashline Edit mode active", "info");
23
+ }
19
24
  });
20
25
 
21
26
  const autoReadValue = process.env.PI_HASHLINE_AUTO_READ;
@@ -40,8 +45,8 @@ export default function (pi: ExtensionAPI): void {
40
45
  try {
41
46
  const absolutePath = isAbsolute(filePath) ? filePath : join(ctx.cwd, filePath);
42
47
  const content = await readFile(absolutePath, "utf-8");
43
-
44
- const normalized = toLF(content);
48
+ const { text: rawContent } = stripBOM(content);
49
+ const normalized = toLF(rawContent);
45
50
  const visibleLines = visLines(normalized);
46
51
 
47
52
  if (visibleLines.length === 0) return;
@@ -65,14 +70,9 @@ export default function (pi: ExtensionAPI): void {
65
70
  ],
66
71
  };
67
72
  }
68
- } catch {
73
+ } catch (error) {
74
+ console.error("Auto-read after write failed:", error);
69
75
  }
70
76
  });
71
77
 
72
- const debugValue = process.env.PI_HASHLINE_DEBUG;
73
- if (debugValue === "1" || debugValue === "true") {
74
- pi.on("session_start", async (_event, ctx) => {
75
- ctx.ui.notify("Hashline Edit mode active", "info");
76
- });
77
- }
78
78
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
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": {
@@ -98,8 +98,5 @@ export function lineHashes(content: string): string[] {
98
98
  return hashes;
99
99
  }
100
100
 
101
- export function lineHash(idx: number, line: string): string {
102
- return h2s(xxh32(canon(line)));
103
- }
104
101
 
105
102
  export { ALPH_RE };
@@ -8,7 +8,6 @@ export {
8
8
  DIFF_MINUS_RE,
9
9
  HL_BARE_PREFIX_RE,
10
10
  lineHashes,
11
- lineHash,
12
11
  initHasher,
13
12
  } from "./hash";
14
13
 
@@ -222,6 +222,31 @@ export function descEdit(edit: RHEdit): string {
222
222
  return `replace ${edit.hash_range_inclusive[0].hash}-${edit.hash_range_inclusive[1].hash}`;
223
223
  }
224
224
 
225
+ function checkBoundaryDup(
226
+ adjacentLine: string | undefined,
227
+ replacementEdge: string | undefined,
228
+ kind: "trailing" | "leading",
229
+ survivingLineIndex: number,
230
+ fileLines: string[],
231
+ editIndex: number,
232
+ ): BDupWarn | null {
233
+ if (
234
+ adjacentLine === undefined ||
235
+ replacementEdge === undefined ||
236
+ replacementEdge.length === 0 ||
237
+ replacementEdge !== adjacentLine
238
+ ) return null;
239
+ return {
240
+ kind,
241
+ survivingLineContent: adjacentLine,
242
+ survivingLineIndex,
243
+ occurrence: fileLines.slice(0, survivingLineIndex).filter((l) => l === adjacentLine).length,
244
+ replacementLineContent: replacementEdge,
245
+ editIndex,
246
+ };
247
+ }
248
+
249
+
225
250
  export function valEdits(
226
251
  edits: HEdit[],
227
252
  fileLines: string[],
@@ -258,38 +283,12 @@ export function valEdits(
258
283
  const endLine = endResolved.line;
259
284
  const nextLine = fileLines[endLine];
260
285
  const replacementLastLine = edit.content_lines.at(-1);
261
- if (
262
- nextLine !== undefined &&
263
- replacementLastLine !== undefined &&
264
- replacementLastLine.length > 0 &&
265
- replacementLastLine === nextLine
266
- ) {
267
- boundaryWarnings.push({
268
- kind: "trailing",
269
- survivingLineContent: nextLine,
270
- survivingLineIndex: endLine,
271
- occurrence: fileLines.slice(0, endLine).filter(l => l === nextLine).length,
272
- replacementLineContent: replacementLastLine,
273
- editIndex: resolved.length,
274
- });
275
- }
286
+ const trailing = checkBoundaryDup(nextLine, replacementLastLine, "trailing", endLine, fileLines, resolved.length);
287
+ if (trailing) boundaryWarnings.push(trailing);
276
288
  const prevLine = fileLines[startResolved.line - 2];
277
289
  const replacementFirstLine = edit.content_lines[0];
278
- if (
279
- prevLine !== undefined &&
280
- replacementFirstLine !== undefined &&
281
- replacementFirstLine.length > 0 &&
282
- replacementFirstLine === prevLine
283
- ) {
284
- boundaryWarnings.push({
285
- kind: "leading",
286
- survivingLineContent: prevLine,
287
- survivingLineIndex: startResolved.line - 2,
288
- occurrence: fileLines.slice(0, startResolved.line - 2).filter(l => l === prevLine).length,
289
- replacementLineContent: replacementFirstLine,
290
- editIndex: resolved.length,
291
- });
292
- }
290
+ const leading = checkBoundaryDup(prevLine, replacementFirstLine, "leading", startResolved.line - 2, fileLines, resolved.length);
291
+ if (leading) boundaryWarnings.push(leading);
293
292
  resolved.push({
294
293
  hash_range_inclusive: [startResolved, endResolved],
295
294
  content_lines: edit.content_lines,
@@ -1,46 +1,12 @@
1
1
  import { isRec, has } from "./utils";
2
2
 
3
- function coerceChangesArray(changes: unknown): unknown {
4
- if (typeof changes !== "string") {
5
- return changes;
6
- }
3
+ function tryParseJSON<T>(value: unknown, guard: (v: unknown) => v is T): T | undefined {
4
+ if (typeof value !== "string") return undefined;
7
5
  try {
8
- const parsed: unknown = JSON.parse(changes);
9
- return Array.isArray(parsed) ? parsed : changes;
10
- } catch {
11
- return changes;
12
- }
13
- }
14
-
15
- function coerceContentLines(changes: unknown): unknown {
16
- if (!Array.isArray(changes)) return changes;
17
- return changes.map((change: unknown) => {
18
- if (!isRec(change)) return change;
19
- if (typeof change.content_lines !== "string") return change;
20
- try {
21
- const parsed: unknown = JSON.parse(change.content_lines);
22
- if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
23
- return { ...change, content_lines: parsed };
24
- }
25
- } catch {
26
- // not valid JSON, leave as-is for downstream validation
27
- }
28
- return change;
29
- });
30
- }
31
-
32
- function coerceChangeItems(changes: unknown): unknown {
33
- if (!Array.isArray(changes)) return changes;
34
- return changes.map((item: unknown) => {
35
- if (typeof item !== "string") return item;
36
- try {
37
- const parsed: unknown = JSON.parse(item);
38
- if (isRec(parsed)) return parsed;
39
- } catch {
40
- // not valid JSON, leave as-is for downstream validation
41
- }
42
- return item;
43
- });
6
+ const parsed: unknown = JSON.parse(value);
7
+ if (guard(parsed)) return parsed;
8
+ } catch {}
9
+ return undefined;
44
10
  }
45
11
 
46
12
  export function normReq(input: unknown): unknown {
@@ -59,14 +25,33 @@ export function normReq(input: unknown): unknown {
59
25
  const hasEditsField = has(record, "edits");
60
26
 
61
27
  if (hasChangesField) {
62
- record.changes = coerceChangesArray(record.changes);
63
- record.changes = coerceChangeItems(record.changes);
64
- record.changes = coerceContentLines(record.changes);
28
+ const raw = tryParseJSON(record.changes, Array.isArray) ?? record.changes;
29
+ if (Array.isArray(raw)) {
30
+ record.changes = raw
31
+ .map((item: unknown) => tryParseJSON(item, isRec) ?? item)
32
+ .map((change: unknown) => {
33
+ if (!isRec(change)) return change;
34
+ if (typeof change.content_lines !== "string") return change;
35
+ const parsed = tryParseJSON(change.content_lines, (v): v is string[] =>
36
+ Array.isArray(v) && v.every((i) => typeof i === "string"),
37
+ );
38
+ return parsed ? { ...change, content_lines: parsed } : change;
39
+ });
40
+ }
65
41
  } else if (hasEditsField) {
66
- // Accept "edits" as an alias for "changes" for backward compatibility
67
- record.changes = coerceChangesArray(record.edits);
68
- record.changes = coerceChangeItems(record.changes);
69
- record.changes = coerceContentLines(record.changes);
42
+ const raw = tryParseJSON(record.edits, Array.isArray) ?? record.edits;
43
+ if (Array.isArray(raw)) {
44
+ record.changes = raw
45
+ .map((item: unknown) => tryParseJSON(item, isRec) ?? item)
46
+ .map((change: unknown) => {
47
+ if (!isRec(change)) return change;
48
+ if (typeof change.content_lines !== "string") return change;
49
+ const parsed = tryParseJSON(change.content_lines, (v): v is string[] =>
50
+ Array.isArray(v) && v.every((i) => typeof i === "string"),
51
+ );
52
+ return parsed ? { ...change, content_lines: parsed } : change;
53
+ });
54
+ }
70
55
  delete record.edits;
71
56
  }
72
57
 
package/src/replace.ts CHANGED
@@ -86,6 +86,21 @@ export type ReplaceDetails = {
86
86
  metrics?: RMetrics;
87
87
  };
88
88
 
89
+ interface PipelineResult {
90
+ path: string;
91
+ toolEdits: HTEdit[];
92
+ originalNormalized: string;
93
+ result: string;
94
+ bom: string;
95
+ originalEnding: "\r\n" | "\n";
96
+ hadUtf8DecodeErrors: boolean;
97
+ warnings: string[];
98
+ noopEdits?: { editIndex: number; loc: string; currentContent: string }[];
99
+ firstChangedLine?: number;
100
+ lastChangedLine?: number;
101
+ originalHashes?: string[];
102
+ }
103
+
89
104
  const E_DESC = loadP("../prompts/replace.md");
90
105
  const E_SNIPPET = loadP("../prompts/replace-snippet.md");
91
106
  const E_GUIDE = loadGuide("../prompts/replace-guidelines.md");
@@ -122,20 +137,7 @@ async function execPipeline(
122
137
  cwd: string,
123
138
  accessMode: number,
124
139
  signal?: AbortSignal,
125
- ): Promise<{
126
- path: string;
127
- toolEdits: HTEdit[];
128
- originalNormalized: string;
129
- result: string;
130
- bom: string;
131
- originalEnding: "\r\n" | "\n";
132
- hadUtf8DecodeErrors: boolean;
133
- warnings: string[];
134
- noopEdits?: { editIndex: number; loc: string; currentContent: string }[];
135
- firstChangedLine?: number;
136
- lastChangedLine?: number;
137
- originalHashes?: string[];
138
- }> {
140
+ ): Promise<PipelineResult> {
139
141
 
140
142
  const path = params.path;
141
143
  const toolEdits = Array.isArray(params.changes)
@@ -205,6 +207,21 @@ type ToolDef = ToolDefinition<
205
207
  ReplaceDetails,
206
208
  RRState
207
209
  > & { renderShell?: "default" | "self" };
210
+ function reuseText(context: any, content: string): Text {
211
+ const t = context.lastComponent instanceof Text
212
+ ? context.lastComponent
213
+ : new Text("", 0, 0);
214
+ t.setText(content);
215
+ return t;
216
+ }
217
+
218
+ function reuseMarkdown(context: any, content: string, theme: any): Markdown {
219
+ const m = context.lastComponent instanceof Markdown
220
+ ? context.lastComponent
221
+ : new Markdown("", 0, 0, mkMdTheme(theme));
222
+ m.setText(content);
223
+ return m;
224
+ }
208
225
 
209
226
  const toolDef: ToolDef = {
210
227
  name: "replace",
@@ -273,10 +290,7 @@ const toolDef: ToolDef = {
273
290
 
274
291
  renderResult(result, { isPartial }, theme, context) {
275
292
  if (isPartial) {
276
- const text =
277
- (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
278
- text.setText(theme.fg("warning", "Editing..."));
279
- return text;
293
+ return reuseText(context, theme.fg("warning", "Editing..."));
280
294
  }
281
295
 
282
296
  const typedResult = result as {
@@ -292,44 +306,18 @@ const toolDef: ToolDef = {
292
306
  }
293
307
 
294
308
  if (context.isError) {
295
- if (!renderedText) {
296
- return new Text("", 0, 0);
297
- }
298
- const text =
299
- context.lastComponent instanceof Text
300
- ? context.lastComponent
301
- : new Text("", 0, 0);
302
- text.setText(`\n${theme.fg("error", renderedText)}`);
303
- return text;
309
+ return renderedText
310
+ ? reuseText(context, `\n${theme.fg("error", renderedText)}`)
311
+ : new Text("", 0, 0);
304
312
  }
305
313
 
306
314
  if (isApplied(typedResult.details)) {
307
- const appliedChangedText = buildAppliedText(
308
- renderedText,
309
- typedResult.details,
310
- theme,
311
- );
312
- if (!appliedChangedText) {
313
- return new Text("", 0, 0);
314
- }
315
- const text =
316
- context.lastComponent instanceof Text
317
- ? context.lastComponent
318
- : new Text("", 0, 0);
319
- text.setText(appliedChangedText);
320
- return text;
321
- }
322
-
323
- if (!renderedText) {
324
- return new Text("", 0, 0);
315
+ const appliedText = buildAppliedText(renderedText, typedResult.details, theme);
316
+ return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
325
317
  }
326
318
 
327
- const markdown =
328
- context.lastComponent instanceof Markdown
329
- ? context.lastComponent
330
- : new Markdown("", 0, 0, mkMdTheme(theme));
331
- markdown.setText(fmtResultMd(renderedText));
332
- return markdown;
319
+ if (!renderedText) return new Text("", 0, 0);
320
+ return reuseMarkdown(context, fmtResultMd(renderedText), theme);
333
321
  },
334
322
 
335
323
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
@@ -353,7 +341,7 @@ const toolDef: ToolDef = {
353
341
  firstChangedLine,
354
342
  lastChangedLine,
355
343
  } = await execPipeline(
356
- normalized,
344
+ normalizedParams,
357
345
  ctx.cwd,
358
346
  constants.R_OK | constants.W_OK,
359
347
  signal,