pi-hashline-edit-pro 0.2.0

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.
@@ -0,0 +1,531 @@
1
+ /**
2
+ * Edit response builders.
3
+ *
4
+ * Pulled out of `src/edit.ts` execute() so each returnMode branch
5
+ * (noop / full / ranges / changed) is independently testable and the
6
+ * top-level execute path stays narrative.
7
+ *
8
+ * No behaviour change: outputs are byte-identical to the previous inline
9
+ * implementation. The only additive surface is `details.metrics` (Phase 2 C
10
+ * — observability for hosts; the LLM-visible text is unchanged).
11
+ */
12
+
13
+ import { generateDiffString } from "./edit-diff";
14
+ import {
15
+ computeAffectedLineRange,
16
+ computeLineHashes,
17
+ formatHashlineRegion,
18
+ } from "./hashline";
19
+ import { formatHashlineReadPreview } from "./read";
20
+
21
+ // Local shape — pi-coding-agent does not export a public `ToolResult`. The
22
+ // builders return `details` as `any` so callers can keep their own per-tool
23
+ // details type without re-asserting it here. This file intentionally does
24
+ // not import the agent's tool-result type to stay decoupled from internals.
25
+ type ToolResult = {
26
+ content: Array<{ type: "text"; text: string }>;
27
+ isError?: boolean;
28
+ details: any;
29
+ };
30
+
31
+ const CHANGED_ANCHOR_TEXT_BUDGET_BYTES = 50 * 1024;
32
+
33
+ // ─── Public types ───────────────────────────────────────────────────────
34
+
35
+ export type ReturnMode = "changed" | "full" | "ranges";
36
+
37
+ export type ReturnRange = {
38
+ start: number;
39
+ end?: number;
40
+ };
41
+
42
+ export type ReturnedRangePreview = {
43
+ start: number;
44
+ end: number;
45
+ text: string;
46
+ nextOffset?: number;
47
+ empty?: true;
48
+ };
49
+
50
+ export type FullContentPreview = {
51
+ text: string;
52
+ nextOffset?: number;
53
+ };
54
+
55
+ /**
56
+ * Host-visible, opt-in observability surface (Phase 2 C). The LLM never sees
57
+ * this — it lives in `details` only. Hosts can use it for dashboards,
58
+ * adoption metrics, or regression alarms (e.g. "noop rate spiking").
59
+ *
60
+ * snake_case is intentional: most observability backends prefer it and
61
+ * avoiding camelCase saves a transform on the host side.
62
+ */
63
+ export type EditMetrics = {
64
+ edits_attempted: number;
65
+ edits_noop: number;
66
+ warnings: number;
67
+ return_mode: ReturnMode;
68
+ classification: "applied" | "noop";
69
+ changed_lines?: { first: number; last: number };
70
+ added_lines?: number;
71
+ removed_lines?: number;
72
+ };
73
+
74
+ export type ReadMetrics = {
75
+ truncated: boolean;
76
+ next_offset?: number;
77
+ };
78
+
79
+ export type EditMeta = {
80
+ editsAttempted: number;
81
+ noopEditsCount: number;
82
+ firstChangedLine?: number;
83
+ lastChangedLine?: number;
84
+ };
85
+
86
+ type NoopEditEntry = {
87
+ editIndex: number;
88
+ loc: string;
89
+ currentContent: string;
90
+ };
91
+
92
+ // ─── Builder inputs ─────────────────────────────────────────────────────
93
+
94
+ export interface NoopResponseInput {
95
+ path: string;
96
+ returnMode: ReturnMode;
97
+ requestedReturnRanges: ReturnRange[] | undefined;
98
+ noopEdits: NoopEditEntry[] | undefined;
99
+ originalNormalized: string;
100
+ snapshotId: string;
101
+ editMeta: EditMeta;
102
+ warnings: string[] | undefined;
103
+ }
104
+
105
+ export interface SuccessResponseInput {
106
+ path: string;
107
+ returnMode: ReturnMode;
108
+ requestedReturnRanges: ReturnRange[] | undefined;
109
+ originalNormalized: string;
110
+ result: string;
111
+ /** Precomputed hashes for `result`. When omitted the response builder
112
+ * computes them on demand; the caller should pass them in when the same
113
+ * result is rendered through more than one path (full / ranges / changed)
114
+ * to avoid redundant work. */
115
+ resultHashes?: string[];
116
+ warnings: string[] | undefined;
117
+ snapshotId: string;
118
+ editMeta: EditMeta;
119
+ }
120
+
121
+ // ─── Helpers ────────────────────────────────────────────────────────────
122
+
123
+ function getVisibleLines(text: string): string[] {
124
+ if (text.length === 0) return [];
125
+ const lines = text.split("\n");
126
+ return text.endsWith("\n") ? lines.slice(0, -1) : lines;
127
+ }
128
+
129
+ function countDiffLines(diff: string, marker: "+" | "-"): number {
130
+ if (!diff) return 0;
131
+ let count = 0;
132
+ for (const line of diff.split("\n")) {
133
+ if (
134
+ line.startsWith(marker) &&
135
+ !line.startsWith(`${marker}${marker}${marker}`)
136
+ ) {
137
+ count += 1;
138
+ }
139
+ }
140
+ return count;
141
+ }
142
+
143
+ function buildMetrics(args: {
144
+ classification: "applied" | "noop";
145
+ returnMode: ReturnMode;
146
+ editsAttempted: number;
147
+ noopEditsCount: number;
148
+ warningsCount: number;
149
+ firstChangedLine?: number;
150
+ lastChangedLine?: number;
151
+ addedLines?: number;
152
+ removedLines?: number;
153
+ }): EditMetrics {
154
+ const metrics: EditMetrics = {
155
+ edits_attempted: args.editsAttempted,
156
+ edits_noop: args.noopEditsCount,
157
+ warnings: args.warningsCount,
158
+ return_mode: args.returnMode,
159
+ classification: args.classification,
160
+ };
161
+ if (
162
+ args.classification === "applied" &&
163
+ args.firstChangedLine !== undefined &&
164
+ args.lastChangedLine !== undefined
165
+ ) {
166
+ metrics.changed_lines = {
167
+ first: args.firstChangedLine,
168
+ last: args.lastChangedLine,
169
+ };
170
+ }
171
+ if (args.addedLines !== undefined) metrics.added_lines = args.addedLines;
172
+ if (args.removedLines !== undefined)
173
+ metrics.removed_lines = args.removedLines;
174
+ return metrics;
175
+ }
176
+
177
+ function warningsBlockOf(warnings: string[] | undefined): string {
178
+ return warnings?.length ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
179
+ }
180
+
181
+ function outlineBlockOf(outlineText: string): string {
182
+ return outlineText ? `\n\n${outlineText}` : "";
183
+ }
184
+
185
+ // ─── Structure outline ──────────────────────────────────────────────────
186
+
187
+ const STRUCTURE_MARKER_RE =
188
+ /^(#{1,6}\s+.+|(export\s+)?(async\s+)?function\s+\w+|(export\s+)?class\s+\w+|(export\s+)?interface\s+\w+|(export\s+)?type\s+\w+|(export\s+)?enum\s+\w+|(const|let|var)\s+\w+\s*=\s*(async\s*)?\()/;
189
+
190
+ function truncateOutlineEntry(text: string, max = 88): string {
191
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
192
+ }
193
+
194
+ function collectOutlineEntries(previewText: string): string[] {
195
+ const structural: string[] = [];
196
+ for (const line of previewText.split("\n")) {
197
+ const match = line.match(/^\s*([A-Za-z0-9_\-]{4}):(.*)$/);
198
+ if (!match) continue;
199
+ const content = match[2]!.trim();
200
+ if (content.length === 0) continue;
201
+ if (!STRUCTURE_MARKER_RE.test(content)) continue;
202
+ structural.push(truncateOutlineEntry(content.replace(/\s+/g, " ")));
203
+ }
204
+ return structural.slice(0, 8);
205
+ }
206
+
207
+ function buildStructureOutline(
208
+ sections: Array<{ label?: string; previewText: string }>,
209
+ ): { text: string; outline: string[] } {
210
+ const outlineLines: string[] = [];
211
+ const detailOutline: string[] = [];
212
+ const useSectionLabels = sections.length > 1;
213
+
214
+ for (const section of sections) {
215
+ const entries = collectOutlineEntries(section.previewText);
216
+ if (entries.length === 0) continue;
217
+ if (useSectionLabels && section.label) {
218
+ outlineLines.push(`- ${section.label}`);
219
+ }
220
+ for (const entry of entries) {
221
+ outlineLines.push(useSectionLabels ? ` - ${entry}` : `- ${entry}`);
222
+ detailOutline.push(section.label ? `${section.label}: ${entry}` : entry);
223
+ }
224
+ }
225
+
226
+ if (outlineLines.length === 0) {
227
+ return { text: "", outline: [] };
228
+ }
229
+ return {
230
+ text: ["Structure outline:", ...outlineLines].join("\n"),
231
+ outline: detailOutline,
232
+ };
233
+ }
234
+
235
+ // ─── Range previews ─────────────────────────────────────────────────────
236
+
237
+ function formatRequestedRangePreviews(
238
+ text: string,
239
+ ranges: ReturnRange[],
240
+ precomputedHashes?: string[],
241
+ ): { text: string; returnedRanges: ReturnedRangePreview[] } {
242
+ const totalLines = getVisibleLines(text).length;
243
+ const returnedRanges = ranges.map((range) => {
244
+ const requestedEnd = range.end ?? range.start;
245
+ const preview = formatHashlineReadPreview(
246
+ text,
247
+ {
248
+ offset: range.start,
249
+ limit: requestedEnd - range.start + 1,
250
+ },
251
+ precomputedHashes,
252
+ );
253
+ const hasReturnedLines = /^\s*[A-Za-z0-9_\-]{4}:/m.test(preview.text);
254
+ const actualEnd = hasReturnedLines
255
+ ? preview.nextOffset !== undefined
256
+ ? preview.nextOffset - 1
257
+ : Math.min(requestedEnd, totalLines)
258
+ : requestedEnd;
259
+ return {
260
+ start: range.start,
261
+ end: hasReturnedLines ? Math.max(range.start, actualEnd) : actualEnd,
262
+ text: preview.text,
263
+ ...(preview.nextOffset !== undefined
264
+ ? { nextOffset: preview.nextOffset }
265
+ : {}),
266
+ ...(!hasReturnedLines ? { empty: true as const } : {}),
267
+ };
268
+ });
269
+
270
+ const formatted = returnedRanges
271
+ .map(
272
+ (range, index) =>
273
+ `--- Range ${index + 1} ---\n${range.text}`,
274
+ )
275
+ .join("\n\n");
276
+
277
+ return {
278
+ text: formatted,
279
+ returnedRanges,
280
+ };
281
+ }
282
+
283
+ // ─── Builders ───────────────────────────────────────────────────────────
284
+
285
+ export function buildNoopResponse(input: NoopResponseInput): ToolResult {
286
+ const {
287
+ path,
288
+ returnMode,
289
+ requestedReturnRanges,
290
+ noopEdits,
291
+ originalNormalized,
292
+ snapshotId,
293
+ editMeta,
294
+ warnings,
295
+ } = input;
296
+
297
+ const noopDetailsText = noopEdits?.length
298
+ ? noopEdits
299
+ .map(
300
+ (edit) =>
301
+ `Edit ${edit.editIndex}: replacement for ${edit.loc} is identical to current content:\n ${edit.loc}: ${edit.currentContent}`,
302
+ )
303
+ .join("\n")
304
+ : "The edits produced identical content.";
305
+
306
+ const fullPreview =
307
+ returnMode === "full"
308
+ ? formatHashlineReadPreview(originalNormalized, { offset: 1 })
309
+ : undefined;
310
+ const rangePreviews =
311
+ returnMode === "ranges"
312
+ ? formatRequestedRangePreviews(
313
+ originalNormalized,
314
+ requestedReturnRanges!,
315
+ )
316
+ : undefined;
317
+ const outline =
318
+ returnMode === "full"
319
+ ? buildStructureOutline([{ previewText: fullPreview!.text }])
320
+ : returnMode === "ranges"
321
+ ? buildStructureOutline(
322
+ rangePreviews!.returnedRanges.map((range, index) => ({
323
+ label: `Range ${index + 1} (lines ${range.start}-${range.end})`,
324
+ previewText: range.text,
325
+ })),
326
+ )
327
+ : undefined;
328
+
329
+ const text =
330
+ returnMode === "full"
331
+ ? `No changes made to ${path}\nClassification: noop${outlineBlockOf(outline!.text)}\n\nFull content is available in details.fullContent.`
332
+ : returnMode === "ranges"
333
+ ? `No changes made to ${path}\nClassification: noop${outlineBlockOf(outline!.text)}\n\nRequested range payloads are available in details.returnedRanges.`
334
+ : `No changes made to ${path}\nClassification: noop\n${noopDetailsText}`;
335
+
336
+ const metrics = buildMetrics({
337
+ classification: "noop",
338
+ returnMode,
339
+ editsAttempted: editMeta.editsAttempted,
340
+ noopEditsCount: editMeta.noopEditsCount,
341
+ warningsCount: warnings?.length ?? 0,
342
+ });
343
+
344
+ return {
345
+ content: [{ type: "text", text }],
346
+ details: {
347
+ diff: "",
348
+ firstChangedLine: undefined,
349
+ snapshotId,
350
+ classification: "noop" as const,
351
+ ...(fullPreview?.nextOffset !== undefined
352
+ ? { nextOffset: fullPreview.nextOffset }
353
+ : {}),
354
+ ...(fullPreview
355
+ ? {
356
+ fullContent: {
357
+ text: fullPreview.text,
358
+ ...(fullPreview.nextOffset !== undefined
359
+ ? { nextOffset: fullPreview.nextOffset }
360
+ : {}),
361
+ },
362
+ }
363
+ : {}),
364
+ ...(rangePreviews
365
+ ? { returnedRanges: rangePreviews.returnedRanges }
366
+ : {}),
367
+ ...(outline ? { structureOutline: outline.outline } : {}),
368
+ metrics,
369
+ },
370
+ };
371
+ }
372
+
373
+ export function buildFullResponse(input: SuccessResponseInput): ToolResult {
374
+ const { path, result, warnings, snapshotId, originalNormalized, editMeta } =
375
+ input;
376
+
377
+ const diffResult = generateDiffString(originalNormalized, result);
378
+ const resultHashes = input.resultHashes ?? computeLineHashes(result);
379
+ const fullPreview = formatHashlineReadPreview(
380
+ result,
381
+ { offset: 1 },
382
+ resultHashes,
383
+ );
384
+ const outline = buildStructureOutline([{ previewText: fullPreview.text }]);
385
+ const text = `Updated ${path}${warningsBlockOf(warnings)}${outlineBlockOf(outline.text)}\n\nFull content is available in details.fullContent.`;
386
+
387
+ const metrics = buildMetrics({
388
+ classification: "applied",
389
+ returnMode: "full",
390
+ editsAttempted: editMeta.editsAttempted,
391
+ noopEditsCount: editMeta.noopEditsCount,
392
+ warningsCount: warnings?.length ?? 0,
393
+ firstChangedLine: editMeta.firstChangedLine,
394
+ lastChangedLine: editMeta.lastChangedLine,
395
+ });
396
+
397
+ return {
398
+ content: [{ type: "text", text }],
399
+ details: {
400
+ diff: diffResult.diff,
401
+ firstChangedLine:
402
+ editMeta.firstChangedLine ?? diffResult.firstChangedLine,
403
+ snapshotId,
404
+ ...(fullPreview.nextOffset !== undefined
405
+ ? { nextOffset: fullPreview.nextOffset }
406
+ : {}),
407
+ fullContent: {
408
+ text: fullPreview.text,
409
+ ...(fullPreview.nextOffset !== undefined
410
+ ? { nextOffset: fullPreview.nextOffset }
411
+ : {}),
412
+ },
413
+ structureOutline: outline.outline,
414
+ metrics,
415
+ },
416
+ };
417
+ }
418
+
419
+ export function buildRangesResponse(input: SuccessResponseInput): ToolResult {
420
+ const {
421
+ path,
422
+ result,
423
+ warnings,
424
+ snapshotId,
425
+ originalNormalized,
426
+ requestedReturnRanges,
427
+ editMeta,
428
+ } = input;
429
+
430
+ const diffResult = generateDiffString(originalNormalized, result);
431
+ const resultHashes = input.resultHashes ?? computeLineHashes(result);
432
+ const rangePreviews = formatRequestedRangePreviews(
433
+ result,
434
+ requestedReturnRanges!,
435
+ resultHashes,
436
+ );
437
+ const outline = buildStructureOutline(
438
+ rangePreviews.returnedRanges.map((range, index) => ({
439
+ label: `Range ${index + 1} (lines ${range.start}-${range.end})`,
440
+ previewText: range.text,
441
+ })),
442
+ );
443
+ const text = `Updated ${path}${warningsBlockOf(warnings)}${outlineBlockOf(outline.text)}\n\nRequested range payloads are available in details.returnedRanges.`;
444
+
445
+ const metrics = buildMetrics({
446
+ classification: "applied",
447
+ returnMode: "ranges",
448
+ editsAttempted: editMeta.editsAttempted,
449
+ noopEditsCount: editMeta.noopEditsCount,
450
+ warningsCount: warnings?.length ?? 0,
451
+ firstChangedLine: editMeta.firstChangedLine,
452
+ lastChangedLine: editMeta.lastChangedLine,
453
+ });
454
+
455
+ return {
456
+ content: [{ type: "text", text }],
457
+ details: {
458
+ diff: diffResult.diff,
459
+ firstChangedLine:
460
+ editMeta.firstChangedLine ?? diffResult.firstChangedLine,
461
+ snapshotId,
462
+ returnedRanges: rangePreviews.returnedRanges,
463
+ structureOutline: outline.outline,
464
+ metrics,
465
+ },
466
+ };
467
+ }
468
+
469
+ export function buildChangedResponse(input: SuccessResponseInput): ToolResult {
470
+ const { result, warnings, snapshotId, originalNormalized, editMeta } = input;
471
+
472
+ const diffResult = generateDiffString(originalNormalized, result);
473
+ const addedLines = countDiffLines(diffResult.diff, "+");
474
+ const removedLines = countDiffLines(diffResult.diff, "-");
475
+ const warningsBlock = warningsBlockOf(warnings);
476
+
477
+ const resultLines = getVisibleLines(result);
478
+ const resultHashes = input.resultHashes ?? computeLineHashes(result);
479
+ const anchorRange = computeAffectedLineRange({
480
+ firstChangedLine: editMeta.firstChangedLine,
481
+ lastChangedLine: editMeta.lastChangedLine,
482
+ resultLineCount: resultLines.length,
483
+ });
484
+ const anchorsBlock = anchorRange
485
+ ? (() => {
486
+ const region = resultLines.slice(
487
+ anchorRange.start - 1,
488
+ anchorRange.end,
489
+ );
490
+ const regionHashes = resultHashes.slice(
491
+ anchorRange.start - 1,
492
+ anchorRange.end,
493
+ );
494
+ const formatted = formatHashlineRegion(regionHashes, region);
495
+ const block = `--- Anchors ---\n${formatted}`;
496
+ return Buffer.byteLength(block, "utf8") <=
497
+ CHANGED_ANCHOR_TEXT_BUDGET_BYTES
498
+ ? block
499
+ : "Anchors omitted; use read for subsequent edits.";
500
+ })()
501
+ : resultLines.length === 0
502
+ ? "File is empty. Use edit with prepend or append and omit pos to insert content."
503
+ : "Anchors omitted; use read for subsequent edits.";
504
+
505
+ const text = [anchorsBlock, warningsBlock.trimStart()]
506
+ .filter((section) => section.length > 0)
507
+ .join("\n\n");
508
+
509
+ const metrics = buildMetrics({
510
+ classification: "applied",
511
+ returnMode: "changed",
512
+ editsAttempted: editMeta.editsAttempted,
513
+ noopEditsCount: editMeta.noopEditsCount,
514
+ warningsCount: warnings?.length ?? 0,
515
+ firstChangedLine: editMeta.firstChangedLine,
516
+ lastChangedLine: editMeta.lastChangedLine,
517
+ addedLines,
518
+ removedLines,
519
+ });
520
+
521
+ return {
522
+ content: [{ type: "text", text }],
523
+ details: {
524
+ diff: diffResult.diff,
525
+ firstChangedLine:
526
+ editMeta.firstChangedLine ?? diffResult.firstChangedLine,
527
+ snapshotId,
528
+ metrics,
529
+ },
530
+ };
531
+ }