@jerryan/pi-hashline-edit 0.7.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,226 @@
1
+ /**
2
+ * Edit response builders.
3
+ *
4
+ * Pulled out of `src/edit.ts` execute() so the noop and changed branches
5
+ * are independently testable and the top-level execute path stays narrative.
6
+ *
7
+ * No behaviour change: outputs are byte-identical to the previous inline
8
+ * implementation. The only additive surface is `details.metrics` (Phase 2 C
9
+ * — observability for hosts; the LLM-visible text is unchanged).
10
+ */
11
+
12
+ import { generateDiffString } from "./edit-diff";
13
+ import {
14
+ computeAffectedLineRange,
15
+ formatHashlineRegion,
16
+ } from "./hashline";
17
+
18
+ const CHANGED_ANCHOR_TEXT_BUDGET_BYTES = 50 * 1024;
19
+
20
+ // ─── Public types ───────────────────────────────────────────────────────
21
+
22
+ export type EditMetrics = {
23
+ edits_attempted: number;
24
+ edits_noop: number;
25
+ warnings: number;
26
+ classification: "applied" | "noop";
27
+ changed_lines?: { first: number; last: number };
28
+ added_lines?: number;
29
+ removed_lines?: number;
30
+ };
31
+
32
+ // ─── Builder inputs ─────────────────────────────────────────────────────
33
+
34
+ type NoopEditEntry = {
35
+ editIndex: number;
36
+ loc: string;
37
+ currentContent: string;
38
+ };
39
+
40
+ export interface NoopResponseInput {
41
+ path: string;
42
+ noopEdits: NoopEditEntry[] | undefined;
43
+ originalNormalized: string;
44
+ snapshotId: string;
45
+ editsAttempted: number;
46
+ warnings: string[] | undefined;
47
+ }
48
+
49
+ export interface SuccessResponseInput {
50
+ path: string;
51
+ originalNormalized: string;
52
+ result: string;
53
+ warnings: string[] | undefined;
54
+ firstChangedLine: number | undefined;
55
+ lastChangedLine: number | undefined;
56
+ snapshotId: string;
57
+ editsAttempted: number;
58
+ noopEditsCount: number;
59
+ }
60
+
61
+ // ─── Helpers ────────────────────────────────────────────────────────────
62
+
63
+ function getVisibleLines(text: string): string[] {
64
+ if (text.length === 0) return [];
65
+ const lines = text.split("\n");
66
+ return text.endsWith("\n") ? lines.slice(0, -1) : lines;
67
+ }
68
+
69
+ function countDiffLines(diff: string, marker: "+" | "-"): number {
70
+ if (!diff) return 0;
71
+ let count = 0;
72
+ for (const line of diff.split("\n")) {
73
+ if (line.startsWith(marker) && !line.startsWith(`${marker}${marker}${marker}`)) {
74
+ count += 1;
75
+ }
76
+ }
77
+ return count;
78
+ }
79
+
80
+ function buildMetrics(args: {
81
+ classification: "applied" | "noop";
82
+ editsAttempted: number;
83
+ noopEditsCount: number;
84
+ warningsCount: number;
85
+ firstChangedLine?: number;
86
+ lastChangedLine?: number;
87
+ addedLines?: number;
88
+ removedLines?: number;
89
+ }): EditMetrics {
90
+ const metrics: EditMetrics = {
91
+ edits_attempted: args.editsAttempted,
92
+ edits_noop: args.noopEditsCount,
93
+ warnings: args.warningsCount,
94
+ classification: args.classification,
95
+ };
96
+ if (
97
+ args.classification === "applied" &&
98
+ args.firstChangedLine !== undefined &&
99
+ args.lastChangedLine !== undefined
100
+ ) {
101
+ metrics.changed_lines = {
102
+ first: args.firstChangedLine,
103
+ last: args.lastChangedLine,
104
+ };
105
+ }
106
+ if (args.addedLines !== undefined) metrics.added_lines = args.addedLines;
107
+ if (args.removedLines !== undefined) metrics.removed_lines = args.removedLines;
108
+ return metrics;
109
+ }
110
+
111
+ function warningsBlockOf(warnings: string[] | undefined): string {
112
+ return warnings?.length ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
113
+ }
114
+
115
+ // ─── Builders ───────────────────────────────────────────────────────────
116
+
117
+ export function buildNoopResponse(input: NoopResponseInput): ToolResult {
118
+ const {
119
+ path,
120
+ noopEdits,
121
+ snapshotId,
122
+ editsAttempted,
123
+ warnings,
124
+ } = input;
125
+
126
+ const noopDetailsText = noopEdits?.length
127
+ ? noopEdits
128
+ .map(
129
+ (edit) =>
130
+ `Edit ${edit.editIndex}: replacement for ${edit.loc} is identical to current content:\n ${edit.loc}: ${edit.currentContent}`,
131
+ )
132
+ .join("\n")
133
+ : "The edits produced identical content.";
134
+
135
+ const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}`;
136
+
137
+ const metrics = buildMetrics({
138
+ classification: "noop",
139
+ editsAttempted,
140
+ noopEditsCount: noopEdits?.length ?? 0,
141
+ warningsCount: warnings?.length ?? 0,
142
+ });
143
+
144
+ return {
145
+ content: [{ type: "text", text }],
146
+ details: {
147
+ diff: "",
148
+ firstChangedLine: undefined,
149
+ snapshotId,
150
+ classification: "noop" as const,
151
+ metrics,
152
+ },
153
+ };
154
+ }
155
+
156
+ export function buildChangedResponse(input: SuccessResponseInput): ToolResult {
157
+ const {
158
+ result,
159
+ warnings,
160
+ firstChangedLine,
161
+ lastChangedLine,
162
+ snapshotId,
163
+ originalNormalized,
164
+ editsAttempted,
165
+ noopEditsCount,
166
+ } = input;
167
+
168
+ const diffResult = generateDiffString(originalNormalized, result);
169
+ const addedLines = countDiffLines(diffResult.diff, "+");
170
+ const removedLines = countDiffLines(diffResult.diff, "-");
171
+ const warningsBlock = warningsBlockOf(warnings);
172
+
173
+ const resultLines = getVisibleLines(result);
174
+ const anchorRange = computeAffectedLineRange({
175
+ firstChangedLine,
176
+ lastChangedLine,
177
+ resultLineCount: resultLines.length,
178
+ });
179
+ const anchorsBlock = anchorRange
180
+ ? (() => {
181
+ const region = resultLines.slice(anchorRange.start - 1, anchorRange.end);
182
+ const formatted = formatHashlineRegion(region, anchorRange.start);
183
+ const block = `--- Anchors ${anchorRange.start}-${anchorRange.end} ---\n${formatted}`;
184
+ return Buffer.byteLength(block, "utf8") <= CHANGED_ANCHOR_TEXT_BUDGET_BYTES
185
+ ? block
186
+ : "Anchors omitted; use read for subsequent edits.";
187
+ })()
188
+ : resultLines.length === 0
189
+ ? "File is empty. Use edit with prepend or append and omit pos to insert content."
190
+ : "Anchors omitted; use read for subsequent edits.";
191
+
192
+ const text = [anchorsBlock, warningsBlock.trimStart()]
193
+ .filter((section) => section.length > 0)
194
+ .join("\n\n");
195
+
196
+ const metrics = buildMetrics({
197
+ classification: "applied",
198
+ editsAttempted,
199
+ noopEditsCount,
200
+ warningsCount: warnings?.length ?? 0,
201
+ firstChangedLine,
202
+ lastChangedLine,
203
+ addedLines,
204
+ removedLines,
205
+ });
206
+
207
+ return {
208
+ content: [{ type: "text", text }],
209
+ details: {
210
+ diff: diffResult.diff,
211
+ firstChangedLine: firstChangedLine ?? diffResult.firstChangedLine,
212
+ snapshotId,
213
+ metrics,
214
+ },
215
+ };
216
+ }
217
+
218
+ // Local shape — pi-coding-agent does not export a public `ToolResult`. The
219
+ // builders return `details` as `any` so callers can keep their own per-tool
220
+ // details type without re-asserting it here. This file intentionally does
221
+ // not import the agent's tool-result type to stay decoupled from internals.
222
+ type ToolResult = {
223
+ content: Array<{ type: "text"; text: string }>;
224
+ isError?: boolean;
225
+ details: any;
226
+ };