@arnilo/prism-coding-agent 0.0.3

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/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.0.3] - 2026-07-08
11
+
12
+ ### Added
13
+
14
+ - Initial release of `@arnilo/prism-coding-agent`: first-party optional coding tools package for Prism.
15
+ - `shell` tool: run host shell commands with bounded output, timeout, abort support, and cross-platform process-tree cleanup.
16
+ - `read` tool: read text files with offset/limit/continuation and truncation, or read supported image files (PNG/JPEG/GIF/WebP/BMP) as `ImageContent`.
17
+ - `write` tool: create or overwrite files, creating parent directories as needed, with UTF-8 byte-correct confirmation.
18
+ - `edit` tool: precise exact-then-fuzzy text replacement in existing files, returning diff/patch metadata.
19
+ - Aggregator factories: `createCodingTools`, `createReadOnlyTools`, `createAllTools`.
20
+ - Pluggable operation backends for every tool (`BashOperations`, `ReadOperations`, `WriteOperations`, `EditOperations`).
21
+ - Behavioral ports of pi coding-agent primitives: `truncate`, `edit-diff`, `path-utils`, `output-accumulator`, `file-mutation-queue`.
22
+ - Runtime dependency on `diff` for unified patch generation; otherwise Node standard library only.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Prism contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without including without limitation the rights to use, copy,
8
+ modify, merge, publish, distribute, sublicense, and/or sell copies of the
9
+ Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # @arnilo/prism-coding-agent
2
+
3
+ Optional first-party coding tools package for [Prism](https://www.npmjs.com/package/@arnilo/prism). Provides host shell/filesystem tools — `shell`, `read`, `write`, `edit` — as Prism `ToolDefinition` objects. **Inert until a host imports it and registers the tools into a `ToolRegistry`.**
4
+
5
+ Behavior is a behavioral port of the pi coding agent's `bash`/`read`/`write`/`edit` tools, adapted to Prism's `ToolDefinition` / `ToolResult` contracts (no `@earendil-works/*` or `typebox` dependencies).
6
+
7
+ > ⚠️ **These tools perform real shell and filesystem operations on the host. They provide no sandbox.** Gate them with Prism `PermissionPolicy` / `ToolValidator` / trust policies before registering them for any provider turn. See the [coding agent tools docs](https://github.com/ashiqrniloy/prism/blob/main/docs/coding-agent-tools.md) and the [host security guide](https://github.com/ashiqrniloy/prism/blob/main/docs/host-security.md).
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install @arnilo/prism-coding-agent
13
+ ```
14
+
15
+ `@arnilo/prism` is a peer dependency.
16
+
17
+ ## Usage
18
+
19
+ Register the full coding set:
20
+
21
+ ```ts
22
+ import { createToolRegistry } from "@arnilo/prism";
23
+ import { createCodingTools } from "@arnilo/prism-coding-agent";
24
+
25
+ const tools = createToolRegistry(createCodingTools(process.cwd()));
26
+ ```
27
+
28
+ Read-only subset (inspection-only agents):
29
+
30
+ ```ts
31
+ import { createReadOnlyTools } from "@arnilo/prism-coding-agent";
32
+
33
+ const tools = createToolRegistry(createReadOnlyTools(process.cwd()));
34
+ ```
35
+
36
+ Individual tools with options:
37
+
38
+ ```ts
39
+ import { createShellTool, createWriteTool } from "@arnilo/prism-coding-agent";
40
+
41
+ const shell = createShellTool(process.cwd(), {
42
+ shellPath: "/bin/bash", // force bash; default: SHELL env → /bin/bash → sh
43
+ commandPrefix: "set -euo pipefail",
44
+ maxLines: 500,
45
+ });
46
+
47
+ const remoteWrite = createWriteTool(process.cwd(), {
48
+ operations: {
49
+ writeFile: async (abs, content) => { /* ship to remote */ },
50
+ mkdir: async (dir) => { /* mkdir -p remotely */ },
51
+ },
52
+ });
53
+ ```
54
+
55
+ ## Tools
56
+
57
+ | Tool | Input | Result |
58
+ | --- | --- | --- |
59
+ | `shell` | `{ command, timeout? }` | Combined output + `metadata.exitCode`. Non-zero exit is **not** an error. |
60
+ | `read` | `{ path, offset?, limit? }` | `TextContent` (text) or `[note, ImageContent]` (image). |
61
+ | `write` | `{ path, content }` | `Successfully wrote N bytes (M lines) to <abs>`. |
62
+ | `edit` | `{ path, edits: [{oldText,newText}] }` | `Successfully replaced N block(s)` + `metadata.{diff,patch,firstChangedLine}`. |
63
+
64
+ ### pi name mapping
65
+
66
+ | Prism | pi |
67
+ | --- | --- |
68
+ | `shell` | `bash` |
69
+ | `read` / `write` / `edit` | `read` / `write` / `edit` |
70
+
71
+ ## Exports
72
+
73
+ Factories: `createShellTool`, `createReadTool`, `createWriteTool`, `createEditTool`, `createCodingTools`, `createReadOnlyTools`, `createAllTools`, `createLocalBashOperations`.
74
+
75
+ Helpers: `detectSupportedImageMimeType`, `detectSupportedImageMimeTypeFromFile`, `getShellConfig`, `killProcessTree`, `waitForChildProcess`, `withFileMutationQueue`.
76
+
77
+ Option/operation types: `ToolsOptions`, `ShellToolOptions`/`BashOperations`, `ReadToolOptions`/`ReadOperations`, `WriteToolOptions`/`WriteOperations`, `EditToolOptions`/`EditOperations`/`EditToolDetails`.
78
+
79
+ ## License
80
+
81
+ MIT
@@ -0,0 +1,93 @@
1
+ export interface Edit {
2
+ oldText: string;
3
+ newText: string;
4
+ }
5
+ export declare function detectLineEnding(content: string): "\r\n" | "\n";
6
+ export declare function normalizeToLF(text: string): string;
7
+ export declare function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string;
8
+ /**
9
+ * Normalize text for fuzzy matching. Applies progressive transformations:
10
+ * - Strip trailing whitespace from each line
11
+ * - Normalize smart quotes to ASCII equivalents
12
+ * - Normalize Unicode dashes/hyphens to ASCII hyphen
13
+ * - Normalize special Unicode spaces to regular space
14
+ */
15
+ export declare function normalizeForFuzzyMatch(text: string): string;
16
+ interface TextReplacement {
17
+ matchIndex: number;
18
+ matchLength: number;
19
+ newText: string;
20
+ }
21
+ /**
22
+ * Apply replacements matched against `baseContent` to `originalContent` while
23
+ * preserving unchanged line blocks from the original.
24
+ *
25
+ * Useful when `baseContent` is a normalized view of the original. Each
26
+ * replacement is widened to the lines it actually touches, those touched lines
27
+ * are rewritten from the normalized base, and all other lines are copied back
28
+ * from `originalContent`. The actual replacement ranges drive preservation so
29
+ * duplicate normalized lines cannot be aligned to the wrong occurrence.
30
+ */
31
+ export declare function applyReplacementsPreservingUnchangedLines(originalContent: string, baseContent: string, replacements: TextReplacement[]): string;
32
+ export interface FuzzyMatchResult {
33
+ /** Whether a match was found */
34
+ found: boolean;
35
+ /** The index where the match starts (in the content that should be used for replacement) */
36
+ index: number;
37
+ /** Length of the matched text */
38
+ matchLength: number;
39
+ /** Whether fuzzy matching was used (false = exact match) */
40
+ usedFuzzyMatch: boolean;
41
+ /**
42
+ * The content to use for replacement operations.
43
+ * When exact match: original content. When fuzzy match: normalized content.
44
+ */
45
+ contentForReplacement: string;
46
+ }
47
+ /**
48
+ * Find oldText in content, trying exact match first, then fuzzy match.
49
+ * When fuzzy matching is used, the returned contentForReplacement is the
50
+ * fuzzy-normalized version of the content.
51
+ */
52
+ export declare function fuzzyFindText(content: string, oldText: string): FuzzyMatchResult;
53
+ /** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it. */
54
+ export declare function stripBom(content: string): {
55
+ bom: string;
56
+ text: string;
57
+ };
58
+ export interface AppliedEditsResult {
59
+ baseContent: string;
60
+ newContent: string;
61
+ }
62
+ /**
63
+ * Apply one or more exact-text replacements to LF-normalized content.
64
+ *
65
+ * All edits are matched against the same original content. Replacements are
66
+ * then applied in reverse order so offsets remain stable. If any edit needs
67
+ * fuzzy matching, the operation runs in fuzzy-normalized content space and then
68
+ * overlays those line-level changes onto the original content so unchanged line
69
+ * blocks keep their original bytes.
70
+ */
71
+ export declare function applyEditsToNormalizedContent(normalizedContent: string, edits: Edit[], path: string): AppliedEditsResult;
72
+ /** Generate a standard unified patch. */
73
+ export declare function generateUnifiedPatch(path: string, oldContent: string, newContent: string, contextLines?: number): string;
74
+ export interface EditDiffResult {
75
+ diff: string;
76
+ firstChangedLine: number | undefined;
77
+ }
78
+ export interface EditDiffError {
79
+ error: string;
80
+ }
81
+ /**
82
+ * Generate a display-oriented diff string with line numbers and context.
83
+ * Returns both the diff string and the first changed line number (in the new file).
84
+ */
85
+ export declare function generateDiffString(oldContent: string, newContent: string, contextLines?: number): EditDiffResult;
86
+ /**
87
+ * Compute the diff for one or more edit operations without applying them.
88
+ * Used for preview before the edit tool executes.
89
+ */
90
+ export declare function computeEditsDiff(path: string, edits: Edit[], cwd: string): Promise<EditDiffResult | EditDiffError>;
91
+ /** Compute the diff for a single edit operation without applying it. */
92
+ export declare function computeEditDiff(path: string, oldText: string, newText: string, cwd: string): Promise<EditDiffResult | EditDiffError>;
93
+ export {};
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Shared diff computation utilities for the edit and similar tools.
3
+ *
4
+ * Behavioral port of pi's core/tools/edit-diff for @arnilo/prism-coding-agent.
5
+ * stdlib (node:fs/promises, node:fs constants) plus the `diff` package for
6
+ * unified-patch / display-diff generation. Fuzzy matching, replacement
7
+ * preservation, BOM/line-ending handling are dep-free.
8
+ */
9
+ import * as Diff from "diff";
10
+ import { constants } from "node:fs";
11
+ import { access, readFile } from "node:fs/promises";
12
+ import { resolveToCwd } from "./path-utils.js";
13
+ export function detectLineEnding(content) {
14
+ const crlfIdx = content.indexOf("\r\n");
15
+ const lfIdx = content.indexOf("\n");
16
+ if (lfIdx === -1)
17
+ return "\n";
18
+ if (crlfIdx === -1)
19
+ return "\n";
20
+ return crlfIdx < lfIdx ? "\r\n" : "\n";
21
+ }
22
+ export function normalizeToLF(text) {
23
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
24
+ }
25
+ export function restoreLineEndings(text, ending) {
26
+ return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
27
+ }
28
+ /**
29
+ * Normalize text for fuzzy matching. Applies progressive transformations:
30
+ * - Strip trailing whitespace from each line
31
+ * - Normalize smart quotes to ASCII equivalents
32
+ * - Normalize Unicode dashes/hyphens to ASCII hyphen
33
+ * - Normalize special Unicode spaces to regular space
34
+ */
35
+ export function normalizeForFuzzyMatch(text) {
36
+ return (text
37
+ .normalize("NFKC")
38
+ // Strip trailing whitespace per line
39
+ .split("\n")
40
+ .map((line) => line.trimEnd())
41
+ .join("\n")
42
+ // Smart single quotes → '
43
+ .replace(/[\u2018\u2019\u201A\u201B]/g, "'")
44
+ // Smart double quotes → "
45
+ .replace(/[\u201C\u201D\u201E\u201F]/g, '"')
46
+ // Various dashes/hyphens → -
47
+ // U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash,
48
+ // U+2013 en-dash, U+2014 em-dash, U+2015 horizontal bar, U+2212 minus
49
+ .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
50
+ // Special spaces → regular space
51
+ // U+00A0 NBSP, U+2002-U+200A various spaces, U+202F narrow NBSP,
52
+ // U+205F medium math space, U+3000 ideographic space
53
+ .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " "));
54
+ }
55
+ function splitLinesWithEndings(content) {
56
+ return content.match(/[^\n]*\n|[^\n]+/g) ?? [];
57
+ }
58
+ function getLineSpans(content) {
59
+ let offset = 0;
60
+ return splitLinesWithEndings(content).map((line) => {
61
+ const span = { start: offset, end: offset + line.length };
62
+ offset = span.end;
63
+ return span;
64
+ });
65
+ }
66
+ function getReplacementLineRange(lines, replacement) {
67
+ const replacementStart = replacement.matchIndex;
68
+ const replacementEnd = replacement.matchIndex + replacement.matchLength;
69
+ let startLine = -1;
70
+ for (let i = 0; i < lines.length; i++) {
71
+ const line = lines[i];
72
+ if (replacementStart >= line.start && replacementStart < line.end) {
73
+ startLine = i;
74
+ break;
75
+ }
76
+ }
77
+ if (startLine === -1) {
78
+ throw new Error("Replacement range is outside the base content.");
79
+ }
80
+ let endLine = startLine;
81
+ while (endLine < lines.length && lines[endLine].end < replacementEnd) {
82
+ endLine++;
83
+ }
84
+ if (endLine >= lines.length) {
85
+ throw new Error("Replacement range is outside the base content.");
86
+ }
87
+ return { startLine, endLine: endLine + 1 };
88
+ }
89
+ function applyReplacements(content, replacements, offset = 0) {
90
+ let result = content;
91
+ for (let i = replacements.length - 1; i >= 0; i--) {
92
+ const replacement = replacements[i];
93
+ const matchIndex = replacement.matchIndex - offset;
94
+ result =
95
+ result.substring(0, matchIndex) +
96
+ replacement.newText +
97
+ result.substring(matchIndex + replacement.matchLength);
98
+ }
99
+ return result;
100
+ }
101
+ /**
102
+ * Apply replacements matched against `baseContent` to `originalContent` while
103
+ * preserving unchanged line blocks from the original.
104
+ *
105
+ * Useful when `baseContent` is a normalized view of the original. Each
106
+ * replacement is widened to the lines it actually touches, those touched lines
107
+ * are rewritten from the normalized base, and all other lines are copied back
108
+ * from `originalContent`. The actual replacement ranges drive preservation so
109
+ * duplicate normalized lines cannot be aligned to the wrong occurrence.
110
+ */
111
+ export function applyReplacementsPreservingUnchangedLines(originalContent, baseContent, replacements) {
112
+ const originalLines = splitLinesWithEndings(originalContent);
113
+ const baseLines = getLineSpans(baseContent);
114
+ if (originalLines.length !== baseLines.length) {
115
+ throw new Error("Cannot preserve unchanged lines because the base content has a different line count.");
116
+ }
117
+ const groups = [];
118
+ const sortedReplacements = [...replacements].sort((a, b) => a.matchIndex - b.matchIndex);
119
+ for (const replacement of sortedReplacements) {
120
+ const range = getReplacementLineRange(baseLines, replacement);
121
+ const current = groups[groups.length - 1];
122
+ if (current && range.startLine < current.endLine) {
123
+ current.endLine = Math.max(current.endLine, range.endLine);
124
+ current.replacements.push(replacement);
125
+ continue;
126
+ }
127
+ groups.push({ ...range, replacements: [replacement] });
128
+ }
129
+ let originalLineIndex = 0;
130
+ let result = "";
131
+ for (const group of groups) {
132
+ result += originalLines.slice(originalLineIndex, group.startLine).join("");
133
+ const groupStartOffset = baseLines[group.startLine].start;
134
+ const groupEndOffset = baseLines[group.endLine - 1].end;
135
+ result += applyReplacements(baseContent.slice(groupStartOffset, groupEndOffset), group.replacements, groupStartOffset);
136
+ originalLineIndex = group.endLine;
137
+ }
138
+ result += originalLines.slice(originalLineIndex).join("");
139
+ return result;
140
+ }
141
+ /**
142
+ * Find oldText in content, trying exact match first, then fuzzy match.
143
+ * When fuzzy matching is used, the returned contentForReplacement is the
144
+ * fuzzy-normalized version of the content.
145
+ */
146
+ export function fuzzyFindText(content, oldText) {
147
+ // Try exact match first.
148
+ const exactIndex = content.indexOf(oldText);
149
+ if (exactIndex !== -1) {
150
+ return {
151
+ found: true,
152
+ index: exactIndex,
153
+ matchLength: oldText.length,
154
+ usedFuzzyMatch: false,
155
+ contentForReplacement: content,
156
+ };
157
+ }
158
+ // Try fuzzy match — work entirely in normalized space.
159
+ const fuzzyContent = normalizeForFuzzyMatch(content);
160
+ const fuzzyOldText = normalizeForFuzzyMatch(oldText);
161
+ const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
162
+ if (fuzzyIndex === -1) {
163
+ return {
164
+ found: false,
165
+ index: -1,
166
+ matchLength: 0,
167
+ usedFuzzyMatch: false,
168
+ contentForReplacement: content,
169
+ };
170
+ }
171
+ return {
172
+ found: true,
173
+ index: fuzzyIndex,
174
+ matchLength: fuzzyOldText.length,
175
+ usedFuzzyMatch: true,
176
+ contentForReplacement: fuzzyContent,
177
+ };
178
+ }
179
+ /** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it. */
180
+ export function stripBom(content) {
181
+ return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
182
+ }
183
+ function countOccurrences(content, oldText) {
184
+ const fuzzyContent = normalizeForFuzzyMatch(content);
185
+ const fuzzyOldText = normalizeForFuzzyMatch(oldText);
186
+ return fuzzyContent.split(fuzzyOldText).length - 1;
187
+ }
188
+ function getNotFoundError(path, editIndex, totalEdits) {
189
+ if (totalEdits === 1) {
190
+ return new Error(`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`);
191
+ }
192
+ return new Error(`Could not find edits[${editIndex}] in ${path}. The oldText must match exactly including all whitespace and newlines.`);
193
+ }
194
+ function getDuplicateError(path, editIndex, totalEdits, occurrences) {
195
+ if (totalEdits === 1) {
196
+ return new Error(`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`);
197
+ }
198
+ return new Error(`Found ${occurrences} occurrences of edits[${editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`);
199
+ }
200
+ function getEmptyOldTextError(path, editIndex, totalEdits) {
201
+ if (totalEdits === 1) {
202
+ return new Error(`oldText must not be empty in ${path}.`);
203
+ }
204
+ return new Error(`edits[${editIndex}].oldText must not be empty in ${path}.`);
205
+ }
206
+ function getNoChangeError(path, totalEdits) {
207
+ if (totalEdits === 1) {
208
+ return new Error(`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`);
209
+ }
210
+ return new Error(`No changes made to ${path}. The replacements produced identical content.`);
211
+ }
212
+ /**
213
+ * Apply one or more exact-text replacements to LF-normalized content.
214
+ *
215
+ * All edits are matched against the same original content. Replacements are
216
+ * then applied in reverse order so offsets remain stable. If any edit needs
217
+ * fuzzy matching, the operation runs in fuzzy-normalized content space and then
218
+ * overlays those line-level changes onto the original content so unchanged line
219
+ * blocks keep their original bytes.
220
+ */
221
+ export function applyEditsToNormalizedContent(normalizedContent, edits, path) {
222
+ const normalizedEdits = edits.map((edit) => ({
223
+ oldText: normalizeToLF(edit.oldText),
224
+ newText: normalizeToLF(edit.newText),
225
+ }));
226
+ for (let i = 0; i < normalizedEdits.length; i++) {
227
+ if (normalizedEdits[i].oldText.length === 0) {
228
+ throw getEmptyOldTextError(path, i, normalizedEdits.length);
229
+ }
230
+ }
231
+ const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText));
232
+ const usedFuzzyMatch = initialMatches.some((match) => match.usedFuzzyMatch);
233
+ const replacementBaseContent = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent;
234
+ const matchedEdits = [];
235
+ for (let i = 0; i < normalizedEdits.length; i++) {
236
+ const edit = normalizedEdits[i];
237
+ const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText);
238
+ if (!matchResult.found) {
239
+ throw getNotFoundError(path, i, normalizedEdits.length);
240
+ }
241
+ const occurrences = countOccurrences(replacementBaseContent, edit.oldText);
242
+ if (occurrences > 1) {
243
+ throw getDuplicateError(path, i, normalizedEdits.length, occurrences);
244
+ }
245
+ matchedEdits.push({
246
+ editIndex: i,
247
+ matchIndex: matchResult.index,
248
+ matchLength: matchResult.matchLength,
249
+ newText: edit.newText,
250
+ });
251
+ }
252
+ matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);
253
+ for (let i = 1; i < matchedEdits.length; i++) {
254
+ const previous = matchedEdits[i - 1];
255
+ const current = matchedEdits[i];
256
+ if (previous.matchIndex + previous.matchLength > current.matchIndex) {
257
+ throw new Error(`edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`);
258
+ }
259
+ }
260
+ const baseContent = normalizedContent;
261
+ const newContent = usedFuzzyMatch
262
+ ? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits)
263
+ : applyReplacements(replacementBaseContent, matchedEdits);
264
+ if (baseContent === newContent) {
265
+ throw getNoChangeError(path, normalizedEdits.length);
266
+ }
267
+ return { baseContent, newContent };
268
+ }
269
+ /** Generate a standard unified patch. */
270
+ export function generateUnifiedPatch(path, oldContent, newContent, contextLines = 4) {
271
+ return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, {
272
+ context: contextLines,
273
+ headerOptions: Diff.FILE_HEADERS_ONLY,
274
+ });
275
+ }
276
+ /**
277
+ * Generate a display-oriented diff string with line numbers and context.
278
+ * Returns both the diff string and the first changed line number (in the new file).
279
+ */
280
+ export function generateDiffString(oldContent, newContent, contextLines = 4) {
281
+ const parts = Diff.diffLines(oldContent, newContent);
282
+ const output = [];
283
+ const oldLines = oldContent.split("\n");
284
+ const newLines = newContent.split("\n");
285
+ const maxLineNum = Math.max(oldLines.length, newLines.length);
286
+ const lineNumWidth = String(maxLineNum).length;
287
+ let oldLineNum = 1;
288
+ let newLineNum = 1;
289
+ let lastWasChange = false;
290
+ let firstChangedLine;
291
+ for (let i = 0; i < parts.length; i++) {
292
+ const part = parts[i];
293
+ const raw = part.value.split("\n");
294
+ if (raw[raw.length - 1] === "") {
295
+ raw.pop();
296
+ }
297
+ if (part.added || part.removed) {
298
+ // Capture the first changed line (in the new file).
299
+ if (firstChangedLine === undefined) {
300
+ firstChangedLine = newLineNum;
301
+ }
302
+ for (const line of raw) {
303
+ if (part.added) {
304
+ const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
305
+ output.push(`+${lineNum} ${line}`);
306
+ newLineNum++;
307
+ }
308
+ else {
309
+ const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
310
+ output.push(`-${lineNum} ${line}`);
311
+ oldLineNum++;
312
+ }
313
+ }
314
+ lastWasChange = true;
315
+ }
316
+ else {
317
+ // Context lines - only show a few before/after changes.
318
+ const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
319
+ const hasLeadingChange = lastWasChange;
320
+ const hasTrailingChange = nextPartIsChange;
321
+ if (hasLeadingChange && hasTrailingChange) {
322
+ if (raw.length <= contextLines * 2) {
323
+ for (const line of raw) {
324
+ const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
325
+ output.push(` ${lineNum} ${line}`);
326
+ oldLineNum++;
327
+ newLineNum++;
328
+ }
329
+ }
330
+ else {
331
+ const leadingLines = raw.slice(0, contextLines);
332
+ const trailingLines = raw.slice(raw.length - contextLines);
333
+ const skippedLines = raw.length - leadingLines.length - trailingLines.length;
334
+ for (const line of leadingLines) {
335
+ const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
336
+ output.push(` ${lineNum} ${line}`);
337
+ oldLineNum++;
338
+ newLineNum++;
339
+ }
340
+ output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
341
+ oldLineNum += skippedLines;
342
+ newLineNum += skippedLines;
343
+ for (const line of trailingLines) {
344
+ const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
345
+ output.push(` ${lineNum} ${line}`);
346
+ oldLineNum++;
347
+ newLineNum++;
348
+ }
349
+ }
350
+ }
351
+ else if (hasLeadingChange) {
352
+ const shownLines = raw.slice(0, contextLines);
353
+ const skippedLines = raw.length - shownLines.length;
354
+ for (const line of shownLines) {
355
+ const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
356
+ output.push(` ${lineNum} ${line}`);
357
+ oldLineNum++;
358
+ newLineNum++;
359
+ }
360
+ if (skippedLines > 0) {
361
+ output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
362
+ oldLineNum += skippedLines;
363
+ newLineNum += skippedLines;
364
+ }
365
+ }
366
+ else if (hasTrailingChange) {
367
+ const skippedLines = Math.max(0, raw.length - contextLines);
368
+ if (skippedLines > 0) {
369
+ output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
370
+ oldLineNum += skippedLines;
371
+ newLineNum += skippedLines;
372
+ }
373
+ for (const line of raw.slice(skippedLines)) {
374
+ const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
375
+ output.push(` ${lineNum} ${line}`);
376
+ oldLineNum++;
377
+ newLineNum++;
378
+ }
379
+ }
380
+ else {
381
+ // Skip these context lines entirely.
382
+ oldLineNum += raw.length;
383
+ newLineNum += raw.length;
384
+ }
385
+ lastWasChange = false;
386
+ }
387
+ }
388
+ return { diff: output.join("\n"), firstChangedLine };
389
+ }
390
+ /**
391
+ * Compute the diff for one or more edit operations without applying them.
392
+ * Used for preview before the edit tool executes.
393
+ */
394
+ export async function computeEditsDiff(path, edits, cwd) {
395
+ const absolutePath = resolveToCwd(path, cwd);
396
+ try {
397
+ try {
398
+ await access(absolutePath, constants.R_OK);
399
+ }
400
+ catch (error) {
401
+ const errorMessage = error instanceof Error && "code" in error ? `Error code: ${String(error.code)}` : String(error);
402
+ return { error: `Could not edit file: ${path}. ${errorMessage}.` };
403
+ }
404
+ const rawContent = await readFile(absolutePath, "utf-8");
405
+ // Strip BOM before matching (LLM won't include invisible BOM in oldText).
406
+ const { text: content } = stripBom(rawContent);
407
+ const normalizedContent = normalizeToLF(content);
408
+ const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);
409
+ return generateDiffString(baseContent, newContent);
410
+ }
411
+ catch (err) {
412
+ return { error: err instanceof Error ? err.message : String(err) };
413
+ }
414
+ }
415
+ /** Compute the diff for a single edit operation without applying it. */
416
+ export async function computeEditDiff(path, oldText, newText, cwd) {
417
+ return computeEditsDiff(path, [{ oldText, newText }], cwd);
418
+ }
419
+ //# sourceMappingURL=edit-diff.js.map