pi-hashline-edit-pro 0.16.12 → 0.16.14

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.12",
3
+ "version": "0.16.14",
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": {
@@ -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,7 +12,6 @@ import {
12
12
  type BDupWarn,
13
13
  type AutoFix,
14
14
  } from "./resolve";
15
- import { visLines } from "../utils";
16
15
 
17
16
  type LIdx = {
18
17
  fileLines: string[];
@@ -306,13 +305,15 @@ export function applyEdits(
306
305
  const edit = correctedEdits[bw.editIndex];
307
306
  if (!edit) continue;
308
307
  if (bw.kind === "trailing") {
309
- const removed = edit.content_lines.pop();
310
- if (removed !== undefined) {
308
+ const idx = lastNonEmptyIndex(edit.content_lines);
309
+ if (idx >= 0) {
310
+ const removed = edit.content_lines.splice(idx, 1)[0];
311
311
  autoFixes.push({ kind: "trailing", editIndex: bw.editIndex, removedLine: removed });
312
312
  }
313
313
  } else {
314
- const removed = edit.content_lines.shift();
315
- if (removed !== undefined) {
314
+ const idx = firstNonEmptyIndex(edit.content_lines);
315
+ if (idx >= 0) {
316
+ const removed = edit.content_lines.splice(idx, 1)[0];
316
317
  autoFixes.push({ kind: "leading", editIndex: bw.editIndex, removedLine: removed });
317
318
  }
318
319
  }
@@ -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";
@@ -94,7 +93,7 @@ export function fmtMismatch(
94
93
  const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
95
94
  if (notFound.length > 0) {
96
95
  out.push(
97
- `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_range_inclusive of your next replace call.`
96
+ `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file content has changed since those anchors were read. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_range_inclusive of your next replace call.`
98
97
  );
99
98
  }
100
99
  if (ambiguous.length > 0) {
@@ -153,9 +152,19 @@ function assertItem(edit: Record<string, unknown>, index: number): void {
153
152
  if ("content_lines" in edit && !isStrArr(edit.content_lines)) {
154
153
  const val = edit.content_lines;
155
154
  if (typeof val === "string") {
156
- throw new Error(CONTENT_LINES_NOT_STRING_MSG);
155
+ try {
156
+ const parsed = JSON.parse(val);
157
+ if (Array.isArray(parsed)) {
158
+ edit.content_lines = parsed;
159
+ } else {
160
+ throw new Error(CONTENT_LINES_NOT_STRING_MSG);
161
+ }
162
+ } catch {
163
+ throw new Error(CONTENT_LINES_NOT_STRING_MSG);
164
+ }
165
+ } else {
166
+ throw new Error(`[E_BAD_SHAPE] Edit ${index} field "content_lines" must be a string array.`);
157
167
  }
158
- throw new Error(`[E_BAD_SHAPE] Edit ${index} field "content_lines" must be a string array.`);
159
168
  }
160
169
  if (!isStrPair(edit.hash_range_inclusive)) {
161
170
  throw new Error(
@@ -230,6 +239,7 @@ export function descEdit(edit: RHEdit): string {
230
239
  return `replace ${edit.hash_range_inclusive[0].hash}-${edit.hash_range_inclusive[1].hash}`;
231
240
  }
232
241
 
242
+
233
243
  function checkBoundaryDup(
234
244
  adjacentLine: string | undefined,
235
245
  replacementEdge: string | undefined,
@@ -296,11 +306,11 @@ export function valEdits(
296
306
  }
297
307
  const endLine = endResolved.line;
298
308
  const nextLine = fileLines[endLine];
299
- const replacementLastLine = edit.content_lines.at(-1);
309
+ const replacementLastLine = lastNonEmpty(edit.content_lines);
300
310
  const trailing = checkBoundaryDup(nextLine, replacementLastLine, "trailing", endLine, resolved.length);
301
311
  if (trailing) boundaryWarnings.push(trailing);
302
312
  const prevLine = fileLines[startResolved.line - 2];
303
- const replacementFirstLine = edit.content_lines[0];
313
+ const replacementFirstLine = firstNonEmpty(edit.content_lines);
304
314
  const leading = checkBoundaryDup(prevLine, replacementFirstLine, "leading", startResolved.line - 2, resolved.length);
305
315
  if (leading) boundaryWarnings.push(leading);
306
316
  resolved.push({
@@ -1,12 +1,19 @@
1
1
  import { isRec, has } from "./utils";
2
2
  import { CONTENT_LINES_NOT_STRING_MSG } from "./constants";
3
- function assertContentLinesNotString(
4
- value: unknown,
5
- label: string,
6
- ): void {
7
- if (typeof value === "string") {
8
- throw new Error(CONTENT_LINES_NOT_STRING_MSG);
9
- }
3
+
4
+ function tryParseContentLines(record: Record<string, unknown>, key: string, label: string): void {
5
+ const val = record[key];
6
+ if (typeof val !== "string") return;
7
+ try {
8
+ const parsed = JSON.parse(val);
9
+ if (Array.isArray(parsed)) {
10
+ record[key] = parsed;
11
+ return;
12
+ }
13
+ } catch {
14
+ // fall through to error
15
+ }
16
+ throw new Error(CONTENT_LINES_NOT_STRING_MSG);
10
17
  }
11
18
 
12
19
  export function normalizeFilePath(record: Record<string, unknown>): void {
@@ -27,6 +34,17 @@ function normalizeField(
27
34
  record[to] = raw;
28
35
  } else if (isRec(raw)) {
29
36
  record[to] = [raw];
37
+ } else if (typeof raw === "string") {
38
+ try {
39
+ const parsed = JSON.parse(raw);
40
+ if (Array.isArray(parsed)) {
41
+ record[to] = parsed;
42
+ } else if (isRec(parsed)) {
43
+ record[to] = [parsed];
44
+ }
45
+ } catch {
46
+ // not valid JSON, leave as-is for downstream validation
47
+ }
30
48
  }
31
49
  if (from !== to) delete record[from];
32
50
  }
@@ -41,7 +59,7 @@ export function normReq(input: unknown): unknown {
41
59
  normalizeFilePath(record);
42
60
 
43
61
  if (has(record, "content_lines") && typeof record.content_lines === "string") {
44
- assertContentLinesNotString(record.content_lines, "Top-level");
62
+ tryParseContentLines(record, "content_lines", "Top-level");
45
63
  }
46
64
 
47
65
  normalizeField(record, "changes", "changes");
@@ -51,7 +69,7 @@ export function normReq(input: unknown): unknown {
51
69
  for (let i = 0; i < record.changes.length; i++) {
52
70
  const item = record.changes[i];
53
71
  if (isRec(item) && has(item, "content_lines") && typeof item.content_lines === "string") {
54
- assertContentLinesNotString(item.content_lines, `changes[${i}]`);
72
+ tryParseContentLines(item, "content_lines", `changes[${i}]`);
55
73
  }
56
74
  }
57
75
  }
@@ -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,12 +111,15 @@ 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
- const ROOT_KS = new Set(["path", "changes"]);
118
+ const ROOT_KS = new Set(["path", "changes", "content_lines", "hash_range_inclusive"]);
117
119
 
118
120
  export function assertReq(
119
121
  request: unknown,
122
+ flat?: boolean
120
123
  ): asserts request is ReqParams {
121
124
  if (!isRec(request)) {
122
125
  throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
@@ -137,10 +140,14 @@ export function assertReq(
137
140
  }
138
141
 
139
142
  if (!Array.isArray(request.changes)) {
143
+ if (flat) {
144
+ throw new Error(
145
+ '[E_BAD_SHAPE] Edit request requires both "content_lines" and "hash_range_inclusive" at the top level.',
146
+ );
147
+ }
140
148
  throw new Error('[E_BAD_SHAPE] Edit request requires a "changes" array. Each change is { content_lines: [...], hash_range_inclusive: ["<START>", "<END>"] }.');
141
149
  }
142
150
  }
143
-
144
151
  export async function execPipeline(
145
152
  params: ReqParams,
146
153
  cwd: string,
@@ -197,6 +204,21 @@ export async function execPipeline(
197
204
  }, hashStore);
198
205
 
199
206
  const warnings = [...(anchorResult.warnings ?? [])];
207
+
208
+ let totalAddedLines = 0;
209
+ let totalRemovedLines = 0;
210
+ const noopIndices = new Set(anchorResult.noopEdits?.map((n) => n.editIndex) ?? []);
211
+ for (let i = 0; i < resolved.length; i++) {
212
+ if (noopIndices.has(i)) continue;
213
+ const edit = resolved[i]!;
214
+ const startLine = originalHashes.indexOf(edit.hash_range_inclusive[0].hash);
215
+ const endLine = originalHashes.indexOf(edit.hash_range_inclusive[1].hash);
216
+ if (startLine >= 0 && endLine >= 0) {
217
+ totalRemovedLines += endLine - startLine + 1;
218
+ }
219
+ totalAddedLines += edit.content_lines.length;
220
+ }
221
+
200
222
  return {
201
223
  path,
202
224
  toolEdits,
@@ -211,16 +233,19 @@ export async function execPipeline(
211
233
  lastChangedLine: anchorResult.lastChangedLine,
212
234
  resultHashes,
213
235
  originalHashes,
236
+ totalAddedLines,
237
+ totalRemovedLines,
214
238
  };
215
239
  }
216
240
 
217
241
  export async function compPreview(
218
242
  request: unknown,
219
243
  cwd: string,
244
+ flat?: boolean
220
245
  ): Promise<RPreview> {
221
246
  try {
222
247
  const normalized = normReq(request);
223
- assertReq(normalized);
248
+ assertReq(normalized, flat);
224
249
  const { path, originalNormalized, originalHashes, result, resultHashes } = await execPipeline(
225
250
  normalized,
226
251
  cwd,
@@ -260,6 +285,32 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
260
285
  m.setText(content);
261
286
  return m;
262
287
  }
288
+ const MODE_CFG = {
289
+ flat: {
290
+ desc: " Only one edit per call. The `hash_range_inclusive` and `content_lines` fields sit at the top level of the request object.",
291
+ examples: [
292
+ "", "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\" }",
293
+ ].join("\n"),
294
+ rules: "",
295
+ requestStructure: [
296
+ "Flat mode:", "```json", "{ \"content_lines\": [...], \"hash_range_inclusive\": [\"aB3\", \"xY7\"], \"path\": \"...\" }", "```",
297
+ ].join("\n"),
298
+ prefix: "one edit per call (flat mode)",
299
+ guidePrefix: "- Use `replace` with HASH anchors for all file changes. Only one edit per call.",
300
+ },
301
+ bulk: {
302
+ 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.",
303
+ examples: [
304
+ "", "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\" }",
305
+ ].join("\n"),
306
+ rules: "- Multiple edits in one call must not overlap. Overlapping ranges are rejected with [E_EDIT_CONFLICT].",
307
+ requestStructure: [
308
+ "Bulk mode (default):", "```json", "{ \"changes\": [{ \"content_lines\": [...], \"hash_range_inclusive\": [\"aB3\", \"xY7\"] }], \"path\": \"...\" }", "```",
309
+ ].join("\n"),
310
+ prefix: "batching all changes to a file in one call",
311
+ guidePrefix: "- Use `replace` with HASH anchors for all file changes; batch every change to one file into a single `replace` call.",
312
+ },
313
+ } as const;
263
314
 
264
315
  export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolDef {
265
316
  const autoRead = opts.autoRead ?? false;
@@ -267,50 +318,20 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
267
318
  ? "Anchors are provided automatically after write and replace operations when auto-read is enabled."
268
319
  : "Call `read` to get fresh anchors for follow-up edits.";
269
320
 
270
- const modeDesc = opts.flat
271
- ? " Only one edit per call. The `hash_range_inclusive` and `content_lines` fields sit at the top level of the request object."
272
- : "\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.";
273
-
274
- const modeExamples = opts.flat
275
- ? [
276
- "", "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\" }",
277
- ].join("\n")
278
- : [
279
- "", "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\" }",
280
- ].join("\n")
281
-
282
- const modeRules = opts.flat
283
- ? ""
284
- : "- Multiple edits in one call must not overlap. Overlapping ranges are rejected with [E_EDIT_CONFLICT]."
285
-
286
- const modeRequestStructure = opts.flat
287
- ? [
288
- "Flat mode:", "```json", "{ \"content_lines\": [...], \"hash_range_inclusive\": [\"aB3\", \"xY7\"], \"path\": \"...\" }", "```",
289
- ].join("\n")
290
- : [
291
- "Bulk mode (default):", "```json", "{ \"changes\": [{ \"content_lines\": [...], \"hash_range_inclusive\": [\"aB3\", \"xY7\"] }], \"path\": \"...\" }", "```",
292
- ].join("\n")
293
-
294
- const modePrefix = opts.flat
295
- ? "one edit per call (flat mode)"
296
- : "batching all changes to a file in one call"
297
-
298
- const modeGuidePrefix = opts.flat
299
- ? "- Use `replace` with HASH anchors for all file changes. Only one edit per call."
300
- : "- Use `replace` with HASH anchors for all file changes; batch every change to one file into a single `replace` call."
321
+ const cfg = MODE_CFG[opts.flat ? "flat" : "bulk"];
301
322
 
302
323
  const E_DESC = loadP("../prompts/replace.md", {
303
- MODE_DESCRIPTION: modeDesc,
304
- MODE_EXAMPLES: modeExamples,
305
- MODE_RULES: modeRules,
306
- MODE_REQUEST_STRUCTURE: modeRequestStructure,
324
+ MODE_DESCRIPTION: cfg.desc,
325
+ MODE_EXAMPLES: cfg.examples,
326
+ MODE_RULES: cfg.rules,
327
+ MODE_REQUEST_STRUCTURE: cfg.requestStructure,
307
328
  AUTO_READ_GUIDANCE: readGuidance,
308
329
  });
309
330
  const E_SNIPPET = loadP("../prompts/replace-snippet.md", {
310
- MODE_PREFIX: modePrefix,
331
+ MODE_PREFIX: cfg.prefix,
311
332
  });
312
333
  const E_GUIDE = loadGuide("../prompts/replace-guidelines.md", {
313
- MODE_PREFIX: modeGuidePrefix,
334
+ MODE_PREFIX: cfg.guidePrefix,
314
335
  AUTO_READ_GUIDANCE: readGuidance,
315
336
  });
316
337
 
@@ -352,7 +373,7 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
352
373
  context.state.preview = undefined;
353
374
  const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
354
375
  context.state.previewGeneration = previewGeneration;
355
- compPreview(previewInput, context.cwd)
376
+ compPreview(previewInput, context.cwd, opts.flat)
356
377
  .then((preview) => {
357
378
  if (
358
379
  context.state.argsKey === argsKey &&
@@ -443,6 +464,8 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
443
464
  firstChangedLine,
444
465
  lastChangedLine,
445
466
  resultHashes,
467
+ totalAddedLines,
468
+ totalRemovedLines,
446
469
  } = await execPipeline(
447
470
  normalizedParams,
448
471
  ctx.cwd,
@@ -465,6 +488,8 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
465
488
  editMeta: {
466
489
  editsAttempted,
467
490
  noopEditsCount: noopEdits?.length ?? 0,
491
+ addedLines: 0,
492
+ removedLines: 0,
468
493
  },
469
494
  warnings,
470
495
  });
@@ -495,6 +520,8 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
495
520
  noopEditsCount: noopEdits?.length ?? 0,
496
521
  firstChangedLine,
497
522
  lastChangedLine,
523
+ addedLines: totalAddedLines,
524
+ removedLines: totalRemovedLines,
498
525
  };
499
526
 
500
527
  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
+ }