pi-hashline-edit-pro 0.16.13 → 0.16.15

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.16.13",
3
+ "version": "0.16.15",
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/file-kind.ts CHANGED
@@ -31,9 +31,6 @@ export type LFile =
31
31
  | { kind: "text"; text: string; hadUtf8DecodeErrors?: true }
32
32
  | { kind: "binary"; description: string };
33
33
 
34
- function hasNull(buffer: Uint8Array): boolean {
35
- return buffer.includes(0);
36
- }
37
34
 
38
35
  export async function loadFileKindAndText(
39
36
  filePath: string,
@@ -82,12 +79,7 @@ export async function loadFileKindAndText(
82
79
  description: detectedMimeType,
83
80
  };
84
81
  }
85
- if (hasNull(sample)) {
86
- return {
87
- kind: "binary",
88
- description: "null bytes detected",
89
- };
90
- }
82
+
91
83
 
92
84
  const decoder = new TextDecoder("utf-8");
93
85
  const fatalDecoder = new TextDecoder("utf-8", { fatal: true });
@@ -121,17 +113,10 @@ export async function loadFileKindAndText(
121
113
  }
122
114
 
123
115
  const chunk = buffer.subarray(0, chunkBytesRead);
124
- if (hasNull(chunk)) {
125
- return {
126
- kind: "binary",
127
- description: "null bytes detected",
128
- };
129
- }
130
116
  noteUtf8Err(chunk);
131
117
  parts.push(decoder.decode(chunk, { stream: true }));
132
118
  position += chunkBytesRead;
133
119
  }
134
-
135
120
  noteUtf8Err();
136
121
  parts.push(decoder.decode());
137
122
 
@@ -1,4 +1,4 @@
1
- import { abortIf } from "../utils";
1
+ import { abortIf, visLines, lastNonEmptyIndex, firstNonEmptyIndex } from "../utils";
2
2
  import { _lineHashesPure, HASH_SEP } from "./hash";
3
3
  import {
4
4
  valEdits,
@@ -12,21 +12,6 @@ import {
12
12
  type BDupWarn,
13
13
  type AutoFix,
14
14
  } from "./resolve";
15
- import { visLines } from "../utils";
16
-
17
- function lastNonEmptyIndex(lines: string[]): number {
18
- for (let i = lines.length - 1; i >= 0; i--) {
19
- if (lines[i]!.length > 0) return i;
20
- }
21
- return -1;
22
- }
23
-
24
- function firstNonEmptyIndex(lines: string[]): number {
25
- for (let i = 0; i < lines.length; i++) {
26
- if (lines[i]!.length > 0) return i;
27
- }
28
- return -1;
29
- }
30
15
 
31
16
  type LIdx = {
32
17
  fileLines: string[];
@@ -267,7 +252,7 @@ export function fmtBoundaryWarning(params: {
267
252
 
268
253
  export function applyEdits(
269
254
  content: string,
270
- edits: import("./resolve").HEdit[],
255
+ edits: HEdit[],
271
256
  signal?: AbortSignal,
272
257
  precomputedHashes?: string[],
273
258
  filePath?: string,
@@ -312,7 +297,7 @@ export function applyEdits(
312
297
  let autoFixes: AutoFix[] | undefined;
313
298
  if (boundaryWarnings.length > 0) {
314
299
  autoFixes = [];
315
- const correctedEdits: import("./resolve").HEdit[] = edits.map(e => ({
300
+ const correctedEdits: HEdit[] = edits.map(e => ({
316
301
  ...e,
317
302
  content_lines: [...e.content_lines],
318
303
  }));
@@ -166,6 +166,7 @@ function mapStableHashes(
166
166
  }
167
167
  }
168
168
 
169
+ if (removedHashes?.has(candidates[bestIdx]!.hash)) continue;
169
170
  const match = candidates.splice(bestIdx, 1)[0]!;
170
171
  newHashes[i] = match.hash;
171
172
  used.add(match.hash);
@@ -1,5 +1,4 @@
1
- import { abortIf } from "../utils";
2
- import { rejectUnknownFields } from "../utils";
1
+ import { abortIf, rejectUnknownFields, lastNonEmpty, firstNonEmpty } from "../utils";
3
2
  import { HL_BARE_PREFIX_RE } from "./hash";
4
3
  import { parseHashRef, parseText, type Anchor } from "./parse";
5
4
  import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
@@ -240,19 +239,6 @@ export function descEdit(edit: RHEdit): string {
240
239
  return `replace ${edit.hash_range_inclusive[0].hash}-${edit.hash_range_inclusive[1].hash}`;
241
240
  }
242
241
 
243
- function lastNonEmpty(lines: string[]): string | undefined {
244
- for (let i = lines.length - 1; i >= 0; i--) {
245
- if (lines[i]!.length > 0) return lines[i]!;
246
- }
247
- return undefined;
248
- }
249
-
250
- function firstNonEmpty(lines: string[]): string | undefined {
251
- for (let i = 0; i < lines.length; i++) {
252
- if (lines[i]!.length > 0) return lines[i]!;
253
- }
254
- return undefined;
255
- }
256
242
 
257
243
  function checkBoundaryDup(
258
244
  adjacentLine: string | undefined,
package/src/prompts.ts CHANGED
@@ -4,7 +4,7 @@ export function loadP(relativePath: string, replacements?: Record<string, string
4
4
  let content = readFileSync(new URL(relativePath, import.meta.url), "utf-8").trim();
5
5
  if (replacements) {
6
6
  for (const [key, value] of Object.entries(replacements)) {
7
- content = content.replaceAll(`{{${key}}}`, value);
7
+ content = content.split(`{{${key}}}`).join(value);
8
8
  }
9
9
  }
10
10
  return content;
@@ -14,7 +14,7 @@ export function loadGuide(relativePath: string, replacements?: Record<string, st
14
14
  let content = readFileSync(new URL(relativePath, import.meta.url), "utf-8");
15
15
  if (replacements) {
16
16
  for (const [key, value] of Object.entries(replacements)) {
17
- content = content.replaceAll(`{{${key}}}`, value);
17
+ content = content.split(`{{${key}}}`).join(value);
18
18
  }
19
19
  }
20
20
  return content
@@ -19,10 +19,12 @@ export type RMetrics = {
19
19
  };
20
20
 
21
21
  export type RMeta = {
22
- editsAttempted: number;
23
- noopEditsCount: number;
24
- firstChangedLine?: number;
25
- lastChangedLine?: number;
22
+ editsAttempted: number;
23
+ noopEditsCount: number;
24
+ firstChangedLine?: number;
25
+ lastChangedLine?: number;
26
+ addedLines: number;
27
+ removedLines: number;
26
28
  };
27
29
 
28
30
  type NEditEntry = {
@@ -131,8 +133,8 @@ export function buildChanged(input: SuccessInput): TResult {
131
133
 
132
134
  const resultLines = visLines(result);
133
135
  const diffResult = genDiff(originalNormalized, result, 2, resultHashes, originalHashes);
134
- const addedLines = cntDiff(diffResult.diff, "+");
135
- const removedLines = cntDiff(diffResult.diff, "-");
136
+ const addedLines = editMeta.addedLines;
137
+ const removedLines = editMeta.removedLines;
136
138
  const warningsBlock = warnBlock(warnings);
137
139
  const successPrefix = `Successfully replaced in ${path}.`;
138
140
  const lineSummary = addedLines > 0 || removedLines > 0
package/src/replace.ts CHANGED
@@ -111,6 +111,8 @@ interface PipelineResult {
111
111
  lastChangedLine?: number;
112
112
  originalHashes: string[];
113
113
  resultHashes: string[];
114
+ totalAddedLines: number;
115
+ totalRemovedLines: number;
114
116
  }
115
117
 
116
118
  const ROOT_KS = new Set(["path", "changes", "content_lines", "hash_range_inclusive"]);
@@ -165,12 +167,10 @@ export async function execPipeline(
165
167
 
166
168
  const hashStore = store ?? await loadHashStore();
167
169
 
168
- const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors } = await readNormFile(
170
+ const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath } = await readNormFile(
169
171
  path, cwd, signal, accessMode, undefined, MAX_HASH_LINES, hashStore,
170
172
  );
171
173
 
172
- const absolutePath = toCwd(path, cwd);
173
- const resolvedPath = await resolveTarget(absolutePath);
174
174
  const resolved = resEdits(toolEdits);
175
175
  const anchorResult = applyEdits(
176
176
  originalNormalized,
@@ -195,13 +195,28 @@ export async function execPipeline(
195
195
  }
196
196
  }
197
197
 
198
- const resultHashes = await lineHashes(result, resolvedPath, {
198
+ const resultHashes = await lineHashes(result, absolutePath, {
199
199
  content: originalNormalized,
200
200
  hashes: originalHashes,
201
201
  removedHashes,
202
202
  }, hashStore);
203
203
 
204
204
  const warnings = [...(anchorResult.warnings ?? [])];
205
+
206
+ let totalAddedLines = 0;
207
+ let totalRemovedLines = 0;
208
+ const noopIndices = new Set(anchorResult.noopEdits?.map((n) => n.editIndex) ?? []);
209
+ for (let i = 0; i < resolved.length; i++) {
210
+ if (noopIndices.has(i)) continue;
211
+ const edit = resolved[i]!;
212
+ const startLine = originalHashes.indexOf(edit.hash_range_inclusive[0].hash);
213
+ const endLine = originalHashes.indexOf(edit.hash_range_inclusive[1].hash);
214
+ if (startLine >= 0 && endLine >= 0) {
215
+ totalRemovedLines += endLine - startLine + 1;
216
+ }
217
+ totalAddedLines += edit.content_lines.length;
218
+ }
219
+
205
220
  return {
206
221
  path,
207
222
  toolEdits,
@@ -216,6 +231,8 @@ export async function execPipeline(
216
231
  lastChangedLine: anchorResult.lastChangedLine,
217
232
  resultHashes,
218
233
  originalHashes,
234
+ totalAddedLines,
235
+ totalRemovedLines,
219
236
  };
220
237
  }
221
238
 
@@ -266,6 +283,32 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
266
283
  m.setText(content);
267
284
  return m;
268
285
  }
286
+ const MODE_CFG = {
287
+ flat: {
288
+ desc: " Only one edit per call. The `hash_range_inclusive` and `content_lines` fields sit at the top level of the request object.",
289
+ examples: [
290
+ "", "Single line:", "{ \"content_lines\": [\"const x = 1;\"], \"hash_range_inclusive\": [\"MQX\", \"MQX\"], \"path\": \"src/main.ts\" }", "", "Range replace:", "{ \"content_lines\": [\"function greet() {\", \" return 1;\", \"}\"], \"hash_range_inclusive\": [\"ZPM\", \"VRW\"], \"path\": \"src/main.ts\" }",
291
+ ].join("\n"),
292
+ rules: "",
293
+ requestStructure: [
294
+ "Flat mode:", "```json", "{ \"content_lines\": [...], \"hash_range_inclusive\": [\"aB3\", \"xY7\"], \"path\": \"...\" }", "```",
295
+ ].join("\n"),
296
+ prefix: "one edit per call (flat mode)",
297
+ guidePrefix: "- Use `replace` with HASH anchors for all file changes. Only one edit per call.",
298
+ },
299
+ bulk: {
300
+ desc: "\n\nPut all operations on one file in a single `replace` call. Stack every region into the `changes` array, even when they are far apart. Anchors within one call must all come from the same pre-edit read; the runtime applies them atomically against that one snapshot.",
301
+ examples: [
302
+ "", "Single line:", "{ \"changes\": [{ \"content_lines\": [\"const x = 1;\"], \"hash_range_inclusive\": [\"MQX\", \"MQX\"] }], \"path\": \"src/main.ts\" }", "", "Range replace:", "{ \"changes\": [{ \"content_lines\": [\"function greet() {\", \" return 1;\", \"}\"], \"hash_range_inclusive\": [\"ZPM\", \"VRW\"] }], \"path\": \"src/main.ts\" }",
303
+ ].join("\n"),
304
+ rules: "- Multiple edits in one call must not overlap. Overlapping ranges are rejected with [E_EDIT_CONFLICT].",
305
+ requestStructure: [
306
+ "Bulk mode (default):", "```json", "{ \"changes\": [{ \"content_lines\": [...], \"hash_range_inclusive\": [\"aB3\", \"xY7\"] }], \"path\": \"...\" }", "```",
307
+ ].join("\n"),
308
+ prefix: "batching all changes to a file in one call",
309
+ guidePrefix: "- Use `replace` with HASH anchors for all file changes; batch every change to one file into a single `replace` call.",
310
+ },
311
+ } as const;
269
312
 
270
313
  export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolDef {
271
314
  const autoRead = opts.autoRead ?? false;
@@ -273,50 +316,20 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
273
316
  ? "Anchors are provided automatically after write and replace operations when auto-read is enabled."
274
317
  : "Call `read` to get fresh anchors for follow-up edits.";
275
318
 
276
- const modeDesc = opts.flat
277
- ? " Only one edit per call. The `hash_range_inclusive` and `content_lines` fields sit at the top level of the request object."
278
- : "\n\nPut all operations on one file in a single `replace` call. Stack every region into the `changes` array, even when they are far apart. Anchors within one call must all come from the same pre-edit read; the runtime applies them atomically against that one snapshot.";
279
-
280
- const modeExamples = opts.flat
281
- ? [
282
- "", "Single line:", "{ \"content_lines\": [\"const x = 1;\"], \"hash_range_inclusive\": [\"MQX\", \"MQX\"], \"path\": \"src/main.ts\" }", "", "Range replace:", "{ \"content_lines\": [\"function greet() {\", \" return 1;\", \"}\"], \"hash_range_inclusive\": [\"ZPM\", \"VRW\"], \"path\": \"src/main.ts\" }",
283
- ].join("\n")
284
- : [
285
- "", "Single line:", "{ \"changes\": [{ \"content_lines\": [\"const x = 1;\"], \"hash_range_inclusive\": [\"MQX\", \"MQX\"] }], \"path\": \"src/main.ts\" }", "", "Range replace:", "{ \"changes\": [{ \"content_lines\": [\"function greet() {\", \" return 1;\", \"}\"], \"hash_range_inclusive\": [\"ZPM\", \"VRW\"] }], \"path\": \"src/main.ts\" }",
286
- ].join("\n")
287
-
288
- const modeRules = opts.flat
289
- ? ""
290
- : "- Multiple edits in one call must not overlap. Overlapping ranges are rejected with [E_EDIT_CONFLICT]."
291
-
292
- const modeRequestStructure = opts.flat
293
- ? [
294
- "Flat mode:", "```json", "{ \"content_lines\": [...], \"hash_range_inclusive\": [\"aB3\", \"xY7\"], \"path\": \"...\" }", "```",
295
- ].join("\n")
296
- : [
297
- "Bulk mode (default):", "```json", "{ \"changes\": [{ \"content_lines\": [...], \"hash_range_inclusive\": [\"aB3\", \"xY7\"] }], \"path\": \"...\" }", "```",
298
- ].join("\n")
299
-
300
- const modePrefix = opts.flat
301
- ? "one edit per call (flat mode)"
302
- : "batching all changes to a file in one call"
303
-
304
- const modeGuidePrefix = opts.flat
305
- ? "- Use `replace` with HASH anchors for all file changes. Only one edit per call."
306
- : "- Use `replace` with HASH anchors for all file changes; batch every change to one file into a single `replace` call."
319
+ const cfg = MODE_CFG[opts.flat ? "flat" : "bulk"];
307
320
 
308
321
  const E_DESC = loadP("../prompts/replace.md", {
309
- MODE_DESCRIPTION: modeDesc,
310
- MODE_EXAMPLES: modeExamples,
311
- MODE_RULES: modeRules,
312
- MODE_REQUEST_STRUCTURE: modeRequestStructure,
322
+ MODE_DESCRIPTION: cfg.desc,
323
+ MODE_EXAMPLES: cfg.examples,
324
+ MODE_RULES: cfg.rules,
325
+ MODE_REQUEST_STRUCTURE: cfg.requestStructure,
313
326
  AUTO_READ_GUIDANCE: readGuidance,
314
327
  });
315
328
  const E_SNIPPET = loadP("../prompts/replace-snippet.md", {
316
- MODE_PREFIX: modePrefix,
329
+ MODE_PREFIX: cfg.prefix,
317
330
  });
318
331
  const E_GUIDE = loadGuide("../prompts/replace-guidelines.md", {
319
- MODE_PREFIX: modeGuidePrefix,
332
+ MODE_PREFIX: cfg.guidePrefix,
320
333
  AUTO_READ_GUIDANCE: readGuidance,
321
334
  });
322
335
 
@@ -449,6 +462,8 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
449
462
  firstChangedLine,
450
463
  lastChangedLine,
451
464
  resultHashes,
465
+ totalAddedLines,
466
+ totalRemovedLines,
452
467
  } = await execPipeline(
453
468
  normalizedParams,
454
469
  ctx.cwd,
@@ -471,6 +486,8 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
471
486
  editMeta: {
472
487
  editsAttempted,
473
488
  noopEditsCount: noopEdits?.length ?? 0,
489
+ addedLines: 0,
490
+ removedLines: 0,
474
491
  },
475
492
  warnings,
476
493
  });
@@ -501,6 +518,8 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
501
518
  noopEditsCount: noopEdits?.length ?? 0,
502
519
  firstChangedLine,
503
520
  lastChangedLine,
521
+ addedLines: totalAddedLines,
522
+ removedLines: totalRemovedLines,
504
523
  };
505
524
 
506
525
  const successInput = {
package/src/utils.ts CHANGED
@@ -52,3 +52,27 @@ export function errCode(error: unknown): string | undefined {
52
52
  }
53
53
  return undefined;
54
54
  }
55
+
56
+ export function lastNonEmptyIndex(lines: string[]): number {
57
+ for (let i = lines.length - 1; i >= 0; i--) {
58
+ if (lines[i]!.length > 0) return i;
59
+ }
60
+ return -1;
61
+ }
62
+
63
+ export function firstNonEmptyIndex(lines: string[]): number {
64
+ for (let i = 0; i < lines.length; i++) {
65
+ if (lines[i]!.length > 0) return i;
66
+ }
67
+ return -1;
68
+ }
69
+
70
+ export function lastNonEmpty(lines: string[]): string | undefined {
71
+ const idx = lastNonEmptyIndex(lines);
72
+ return idx >= 0 ? lines[idx] : undefined;
73
+ }
74
+
75
+ export function firstNonEmpty(lines: string[]): string | undefined {
76
+ const idx = firstNonEmptyIndex(lines);
77
+ return idx >= 0 ? lines[idx] : undefined;
78
+ }