pi-hashline-edit-pro 0.13.0 → 0.13.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/index.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { readFile } from "fs/promises";
3
3
  import { join, isAbsolute } from "path";
4
- import { lineHashes, initHasher, fmtRegion } from "./src/hashline";
4
+ import { initHasher } from "./src/hashline";
5
5
  import { regReplace } from "./src/replace";
6
- import { regRead, formatPaginationHint } from "./src/read";
7
- import { toLF } from "./src/replace-diff";
6
+ import { regRead, fmtReadPreview } from "./src/read";
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,39 +45,23 @@ 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");
48
+ const { text: rawContent } = stripBOM(content);
49
+ const normalized = toLF(rawContent);
43
50
 
44
- const normalized = toLF(content);
45
- const visibleLines = visLines(normalized);
46
-
47
- if (visibleLines.length === 0) return;
51
+ if (visLines(normalized).length === 0) return;
48
52
 
49
- const truncated = visibleLines.length > AUTO_READ_MAX;
50
- const displayLines = truncated ? visibleLines.slice(0, AUTO_READ_MAX) : visibleLines;
53
+ const preview = fmtReadPreview(normalized, { limit: AUTO_READ_MAX });
54
+ if (!preview.text) return;
51
55
 
52
- const hashes = lineHashes(normalized);
53
- const selectedHashes = hashes.slice(0, displayLines.length);
54
- const hashlineOutput = fmtRegion(selectedHashes, displayLines);
55
-
56
- const paginationHint = truncated
57
- ? `\n\n${formatPaginationHint(1, AUTO_READ_MAX, visibleLines.length, AUTO_READ_MAX + 1)}`
58
- : "";
59
-
60
- if (hashlineOutput) {
61
- return {
62
- content: [
63
- ...(event.content ?? []),
64
- { type: "text", text: `\n\n--- Auto-read (hashline anchors) ---\n${hashlineOutput}${paginationHint}` },
65
- ],
66
- };
67
- }
68
- } catch {
56
+ return {
57
+ content: [
58
+ ...(event.content ?? []),
59
+ { type: "text", text: `\n\n--- Auto-read (hashline anchors) ---\n${preview.text}` },
60
+ ],
61
+ };
62
+ } catch (error) {
63
+ console.error("Auto-read after write failed:", error);
69
64
  }
70
65
  });
71
66
 
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
67
  }
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.2",
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": {
package/src/constants.ts CHANGED
@@ -1,16 +1,4 @@
1
1
  export const AUTO_READ_MAX = 2000;
2
- export const ANCHOR_BUDGET = 50 * 1024;
3
-
4
- /**
5
- * Context lines of post-edit anchor surfacing. Intentionally 0: after a
6
- * successful `replace` the response text is empty by design (the model calls
7
- * `read` for fresh anchors — see "Chained edits" in the README). With 0,
8
- * `affRange` returns null, so the anchor-block branch in `buildChanged`
9
- * (`src/replace-response.ts`) is dormant. Set this > 0 (and likely bump
10
- * MAX_OUT) to revive surfacing a small anchor window after each edit.
11
- */
12
- export const CTX_LINES = 0;
13
- export const MAX_OUT = 12;
14
2
  export const SNIFF_BYTES = 8192;
15
3
  export const MAX_BYTES = 100 * 1024 * 1024;
16
4
 
@@ -12,7 +12,6 @@ import {
12
12
  type BDupWarn,
13
13
  } from "./resolve";
14
14
  import { cntLines } from "../utils";
15
- import { CTX_LINES, MAX_OUT } from "../constants";
16
15
 
17
16
  type LIdx = {
18
17
  fileLines: string[];
@@ -271,6 +270,7 @@ export function applyEdits(
271
270
  content: string;
272
271
  firstChangedLine: number | undefined;
273
272
  lastChangedLine: number | undefined;
273
+ resultHashes?: string[];
274
274
  warnings?: string[];
275
275
  noopEdits?: NEdit[];
276
276
  } {
@@ -350,52 +350,12 @@ export function applyEdits(
350
350
  content: result,
351
351
  firstChangedLine: range?.firstChangedLine,
352
352
  lastChangedLine: range?.lastChangedLine,
353
+ resultHashes,
353
354
  ...(warnings.length ? { warnings } : {}),
354
355
  ...(noopEdits.length ? { noopEdits } : {}),
355
356
  };
356
357
  }
357
358
 
358
- export function affRange(params: {
359
- firstChangedLine: number | undefined;
360
- lastChangedLine: number | undefined;
361
- resultLineCount: number;
362
- contextLines?: number;
363
- maxOutputLines?: number;
364
- }): { start: number; end: number } | null {
365
- const {
366
- firstChangedLine,
367
- lastChangedLine,
368
- resultLineCount,
369
- contextLines = CTX_LINES,
370
- maxOutputLines = MAX_OUT,
371
- } = params;
372
-
373
- if (firstChangedLine === undefined || lastChangedLine === undefined) {
374
- return null;
375
- }
376
-
377
- if (contextLines === 0) {
378
- return null;
379
- }
380
-
381
- if (resultLineCount === 0) {
382
- return null;
383
- }
384
-
385
- const start = Math.max(1, firstChangedLine - contextLines);
386
- const end = Math.min(resultLineCount, lastChangedLine + contextLines);
387
-
388
- if (end < start) {
389
- return null;
390
- }
391
-
392
- if (end - start + 1 > maxOutputLines) {
393
- return null;
394
- }
395
-
396
- return { start, end };
397
- }
398
-
399
359
  export function fmtRegion(
400
360
  hashes: string[],
401
361
  lines: string[],
@@ -55,6 +55,9 @@ function getH(): Hasher {
55
55
  hasherP = xxhash().then((h) => {
56
56
  hasher = h;
57
57
  return h;
58
+ }).catch((err) => {
59
+ console.error("xxhash-wasm initialization failed:", err);
60
+ throw err;
58
61
  });
59
62
 
60
63
  export function initHasher(): Promise<Hasher> {
@@ -98,8 +101,5 @@ export function lineHashes(content: string): string[] {
98
101
  return hashes;
99
102
  }
100
103
 
101
- export function lineHash(idx: number, line: string): string {
102
- return h2s(xxh32(canon(line)));
103
- }
104
104
 
105
105
  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
 
@@ -35,7 +34,6 @@ export {
35
34
  export {
36
35
  buildIdx,
37
36
  applyEdits,
38
- affRange,
39
37
  fmtRegion,
40
38
  changedRange,
41
39
  } from "./apply";
@@ -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,30 @@
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
- }
6
+ const parsed: unknown = JSON.parse(value);
7
+ if (guard(parsed)) return parsed;
8
+ } catch {}
9
+ return undefined;
13
10
  }
14
11
 
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
- });
12
+ /**
13
+ * Coerces an array of edit items: JSON-string items → objects,
14
+ * JSON-string content_lines → string arrays. Shared by the `changes`
15
+ * and `edits` normalization branches.
16
+ */
17
+ function coerceEditArray(items: unknown[]): unknown[] {
18
+ return items
19
+ .map((item: unknown) => tryParseJSON(item, isRec) ?? item)
20
+ .map((change: unknown) => {
21
+ if (!isRec(change)) return change;
22
+ if (typeof change.content_lines !== "string") return change;
23
+ const parsed = tryParseJSON(change.content_lines, (v): v is string[] =>
24
+ Array.isArray(v) && v.every((i) => typeof i === "string"),
25
+ );
26
+ return parsed ? { ...change, content_lines: parsed } : change;
27
+ });
44
28
  }
45
29
 
46
30
  export function normReq(input: unknown): unknown {
@@ -59,16 +43,14 @@ export function normReq(input: unknown): unknown {
59
43
  const hasEditsField = has(record, "edits");
60
44
 
61
45
  if (hasChangesField) {
62
- record.changes = coerceChangesArray(record.changes);
63
- record.changes = coerceChangeItems(record.changes);
64
- record.changes = coerceContentLines(record.changes);
46
+ const raw = tryParseJSON(record.changes, Array.isArray) ?? record.changes;
47
+ if (Array.isArray(raw)) record.changes = coerceEditArray(raw);
65
48
  } 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);
49
+ const raw = tryParseJSON(record.edits, Array.isArray) ?? record.edits;
50
+ if (Array.isArray(raw)) record.changes = coerceEditArray(raw);
70
51
  delete record.edits;
71
52
  }
72
53
 
73
54
  return record;
74
55
  }
56
+
@@ -1,12 +1,9 @@
1
1
  import type { ReplaceDetails } from "./replace";
2
2
  import { genDiff } from "./replace-diff";
3
3
  import {
4
- affRange,
5
4
  lineHashes,
6
- fmtRegion,
7
5
  } from "./hashline";
8
6
  import { visLines } from "./utils";
9
- import { ANCHOR_BUDGET } from "./constants";
10
7
 
11
8
  type TResult = {
12
9
  content: Array<{ type: "text"; text: string }>;
@@ -153,34 +150,9 @@ export function buildChanged(input: SuccessInput): TResult {
153
150
  const addedLines = cntDiff(diffResult.diff, "+");
154
151
  const removedLines = cntDiff(diffResult.diff, "-");
155
152
  const warningsBlock = warnBlock(warnings);
156
- const anchorRange = affRange({
157
- firstChangedLine: editMeta.firstChangedLine,
158
- lastChangedLine: editMeta.lastChangedLine,
159
- resultLineCount: resultLines.length,
160
- });
161
- const anchorsBlock = anchorRange
162
- ? (() => {
163
- const region = resultLines.slice(
164
- anchorRange.start - 1,
165
- anchorRange.end,
166
- );
167
- const regionHashes = resultHashes.slice(
168
- anchorRange.start - 1,
169
- anchorRange.end,
170
- );
171
- const formatted = fmtRegion(regionHashes, region);
172
- const block = `--- Anchors ---\n${formatted}`;
173
- return Buffer.byteLength(block, "utf8") <=
174
- ANCHOR_BUDGET
175
- ? block
176
- : "Anchors omitted; use read for subsequent edits.";
177
- })()
178
- : resultLines.length === 0
179
- ? "File is empty. Use replace to insert content."
180
- : "";
181
- const text = [anchorsBlock, warningsBlock.trimStart()]
182
- .filter((section) => section.length > 0)
183
- .join("\n\n");
153
+ const text = resultLines.length === 0
154
+ ? "File is empty. Use replace to insert content."
155
+ : warningsBlock.trimStart();
184
156
 
185
157
  const metrics = buildM({
186
158
  classification: "applied",
package/src/replace.ts CHANGED
@@ -86,6 +86,22 @@ 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
+ resultHashes?: string[];
103
+ }
104
+
89
105
  const E_DESC = loadP("../prompts/replace.md");
90
106
  const E_SNIPPET = loadP("../prompts/replace-snippet.md");
91
107
  const E_GUIDE = loadGuide("../prompts/replace-guidelines.md");
@@ -122,20 +138,7 @@ async function execPipeline(
122
138
  cwd: string,
123
139
  accessMode: number,
124
140
  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
- }> {
141
+ ): Promise<PipelineResult> {
139
142
 
140
143
  const path = params.path;
141
144
  const toolEdits = Array.isArray(params.changes)
@@ -171,6 +174,7 @@ async function execPipeline(
171
174
  noopEdits: anchorResult.noopEdits,
172
175
  firstChangedLine: anchorResult.firstChangedLine,
173
176
  lastChangedLine: anchorResult.lastChangedLine,
177
+ resultHashes: anchorResult.resultHashes,
174
178
  originalHashes,
175
179
  };
176
180
  }
@@ -182,7 +186,7 @@ export async function compPreview(
182
186
  try {
183
187
  const normalized = normReq(request);
184
188
  assertReq(normalized);
185
- const { path, originalNormalized, result } = await execPipeline(
189
+ const { path, originalNormalized, result, resultHashes } = await execPipeline(
186
190
  normalized,
187
191
  cwd,
188
192
  constants.R_OK,
@@ -194,7 +198,7 @@ export async function compPreview(
194
198
  };
195
199
  }
196
200
 
197
- return { diff: genDiff(originalNormalized, result, 4, lineHashes(result)).diff };
201
+ return { diff: genDiff(originalNormalized, result, 4, resultHashes).diff };
198
202
  } catch (error: unknown) {
199
203
  return { error: error instanceof Error ? error.message : String(error) };
200
204
  }
@@ -205,6 +209,21 @@ type ToolDef = ToolDefinition<
205
209
  ReplaceDetails,
206
210
  RRState
207
211
  > & { renderShell?: "default" | "self" };
212
+ function reuseText(context: any, content: string): Text {
213
+ const t = context.lastComponent instanceof Text
214
+ ? context.lastComponent
215
+ : new Text("", 0, 0);
216
+ t.setText(content);
217
+ return t;
218
+ }
219
+
220
+ function reuseMarkdown(context: any, content: string, theme: any): Markdown {
221
+ const m = context.lastComponent instanceof Markdown
222
+ ? context.lastComponent
223
+ : new Markdown("", 0, 0, mkMdTheme(theme));
224
+ m.setText(content);
225
+ return m;
226
+ }
208
227
 
209
228
  const toolDef: ToolDef = {
210
229
  name: "replace",
@@ -273,10 +292,7 @@ const toolDef: ToolDef = {
273
292
 
274
293
  renderResult(result, { isPartial }, theme, context) {
275
294
  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;
295
+ return reuseText(context, theme.fg("warning", "Editing..."));
280
296
  }
281
297
 
282
298
  const typedResult = result as {
@@ -292,44 +308,18 @@ const toolDef: ToolDef = {
292
308
  }
293
309
 
294
310
  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;
311
+ return renderedText
312
+ ? reuseText(context, `\n${theme.fg("error", renderedText)}`)
313
+ : new Text("", 0, 0);
304
314
  }
305
315
 
306
316
  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);
317
+ const appliedText = buildAppliedText(renderedText, typedResult.details, theme);
318
+ return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
325
319
  }
326
320
 
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;
321
+ if (!renderedText) return new Text("", 0, 0);
322
+ return reuseMarkdown(context, fmtResultMd(renderedText), theme);
333
323
  },
334
324
 
335
325
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
@@ -352,8 +342,9 @@ const toolDef: ToolDef = {
352
342
  noopEdits,
353
343
  firstChangedLine,
354
344
  lastChangedLine,
345
+ resultHashes,
355
346
  } = await execPipeline(
356
- normalized,
347
+ normalizedParams,
357
348
  ctx.cwd,
358
349
  constants.R_OK | constants.W_OK,
359
350
  signal,
@@ -402,7 +393,7 @@ const toolDef: ToolDef = {
402
393
  path,
403
394
  originalNormalized,
404
395
  result,
405
- resultHashes: lineHashes(result),
396
+ resultHashes,
406
397
  warnings,
407
398
  snapshotId: updatedSnapshotId,
408
399
  editMeta,