editcodewithai 2.4.0 → 2.6.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.
- package/README.md +223 -47
- package/dist/applyEngine.d.ts +13 -0
- package/dist/applyEngine.js +102 -0
- package/dist/fileUtils.d.ts +28 -0
- package/dist/fileUtils.js +62 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +38 -4
- package/dist/matching.d.ts +32 -0
- package/dist/matching.js +126 -0
- package/dist/prompt.d.ts +1 -1
- package/dist/prompt.js +41 -1
- package/dist/types.d.ts +40 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# editcodewithai
|
|
2
2
|
|
|
3
|
-
A lightweight,
|
|
3
|
+
A lightweight, model-agnostic library for AI-powered code editing.
|
|
4
4
|
|
|
5
5
|
See also [vizhub-benchmarks](https://github.com/vizhub-core/vizhub-benchmarks).
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ See also [vizhub-benchmarks](https://github.com/vizhub-core/vizhub-benchmarks).
|
|
|
8
8
|
|
|
9
9
|
`editcodewithai` is a JavaScript/TypeScript library that enables AI-powered code editing in your applications. It provides a simple interface to send code files and instructions to an LLM (Large Language Model) and receive edited code in return.
|
|
10
10
|
|
|
11
|
-
The library is designed to be model-agnostic
|
|
11
|
+
The library is designed to be **model-agnostic** — you provide a function that calls any LLM provider (OpenAI, Anthropic, OpenRouter, local models, etc.), and `editcodewithai` handles the prompt engineering, file parsing, and response processing.
|
|
12
12
|
|
|
13
13
|
Edit formats inspired by [Aider](https://aider.chat/). See [Aider: Edit Formats](https://aider.chat/docs/more/edit-formats.html) for details.
|
|
14
14
|
|
|
@@ -66,16 +66,24 @@ main();
|
|
|
66
66
|
|
|
67
67
|
## Edit Formats
|
|
68
68
|
|
|
69
|
-
This library supports
|
|
69
|
+
This library supports five edit formats that instruct the LLM on how to specify file changes. Different models may perform better with different formats. You specify the format via the `editFormat` parameter in `performAiEdit`.
|
|
70
|
+
|
|
71
|
+
| Format | Description | File path location |
|
|
72
|
+
| ------------- | ------------------------------------------------------------------------------------------- | ------------------------ |
|
|
73
|
+
| `whole` | Complete file replacement (default). Simple but wasteful for small changes in large files. | After `**file.js**` bold |
|
|
74
|
+
| `diff` | Search/replace blocks. Efficient — only the changed portions are transmitted. (Aider-style) | Outside the code fence |
|
|
75
|
+
| `diff-fenced` | Same search/replace blocks, but with file path _inside_ the code fence. | Inside the code fence |
|
|
76
|
+
| `udiff` | Unified diff format. Each hunk shows exact lines to add/remove with surrounding context. | After `---` / `+++` |
|
|
77
|
+
| `hybrid` | Mix-and-match: the LLM chooses per file whether to use `whole` or `diff` format. | Both styles supported |
|
|
70
78
|
|
|
71
79
|
### `whole` (default)
|
|
72
80
|
|
|
73
|
-
The LLM returns the complete, updated content for each file that needs changes.
|
|
81
|
+
The LLM returns the complete, updated content for each file that needs changes. The file name is written in bold (`**name**`) followed by a fenced code block.
|
|
74
82
|
|
|
75
|
-
**Example:**
|
|
83
|
+
**Example LLM response:**
|
|
76
84
|
|
|
77
85
|
````
|
|
78
|
-
index.js
|
|
86
|
+
**index.js**
|
|
79
87
|
```js
|
|
80
88
|
console.log("Hello, Universe!");
|
|
81
89
|
```
|
|
@@ -83,9 +91,9 @@ console.log("Hello, Universe!");
|
|
|
83
91
|
|
|
84
92
|
### `diff`
|
|
85
93
|
|
|
86
|
-
The LLM returns a
|
|
94
|
+
The LLM returns search/replace blocks. Each block specifies the file path on its own line, then a fenced block containing a `<<<<<<< SEARCH` / `=======` / `>>>>>>> REPLACE` section. The library finds the `SEARCH` text in the original file and replaces it with the `REPLACE` text.
|
|
87
95
|
|
|
88
|
-
**Example:**
|
|
96
|
+
**Example LLM response:**
|
|
89
97
|
|
|
90
98
|
````
|
|
91
99
|
index.js
|
|
@@ -98,23 +106,182 @@ console.log("Hello, Universe!");
|
|
|
98
106
|
```
|
|
99
107
|
````
|
|
100
108
|
|
|
101
|
-
###
|
|
109
|
+
### `diff-fenced`
|
|
110
|
+
|
|
111
|
+
Same search/replace concept as `diff`, but the file path is placed on the first line _inside_ the code fence instead of outside it.
|
|
112
|
+
|
|
113
|
+
**Example LLM response:**
|
|
114
|
+
|
|
115
|
+
````
|
|
116
|
+
```
|
|
117
|
+
index.js
|
|
118
|
+
<<<<<<< SEARCH
|
|
119
|
+
console.log("Hello, World!");
|
|
120
|
+
=======
|
|
121
|
+
console.log("Hello, Universe!");
|
|
122
|
+
>>>>>>> REPLACE
|
|
123
|
+
```
|
|
124
|
+
````
|
|
125
|
+
|
|
126
|
+
### `udiff`
|
|
127
|
+
|
|
128
|
+
The LLM returns standard unified diff hunks inside a ` ```diff ` code fence. Each hunk begins with `@@ ... @@` and uses `-` / `+` prefixes for removed / added lines. Lines prefixed with a space are context used for matching.
|
|
129
|
+
|
|
130
|
+
**Example LLM response:**
|
|
131
|
+
|
|
132
|
+
````diff
|
|
133
|
+
```diff
|
|
134
|
+
--- index.js
|
|
135
|
+
+++ index.js
|
|
136
|
+
@@ -1 +1 @@
|
|
137
|
+
-console.log("Hello, World!");
|
|
138
|
+
+console.log("Hello, Universe!");
|
|
139
|
+
```
|
|
140
|
+
````
|
|
141
|
+
|
|
142
|
+
### `hybrid`
|
|
143
|
+
|
|
144
|
+
The LLM can mix both whole-file and search/replace formats in the same response, choosing per file which is more appropriate. Use the **whole-file format** for major rewrites, new files, or when many parts of a file change. Use the **search/replace diff format** for small, targeted changes. If the same file appears in both formats, the whole-file content takes precedence (applied after the diff).
|
|
145
|
+
|
|
146
|
+
**Example LLM response:**
|
|
147
|
+
|
|
148
|
+
````
|
|
149
|
+
alpha.js
|
|
150
|
+
```
|
|
151
|
+
<<<<<<< SEARCH
|
|
152
|
+
const greeting = "Hello";
|
|
153
|
+
=======
|
|
154
|
+
const greeting = "Hi";
|
|
155
|
+
>>>>>>> REPLACE
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**beta.js**
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
// Entirely rewritten file
|
|
162
|
+
const beta = "new version";
|
|
163
|
+
```
|
|
164
|
+
````
|
|
165
|
+
|
|
166
|
+
## File Operations
|
|
102
167
|
|
|
103
168
|
The library handles several file operations automatically:
|
|
104
169
|
|
|
105
|
-
- **Updating existing files**: When the AI modifies a file's content
|
|
106
|
-
- **Creating new files**: When the AI
|
|
107
|
-
- **Deleting files**: When the AI returns empty content for a file
|
|
170
|
+
- **Updating existing files**: When the AI modifies a file's content (using any edit format).
|
|
171
|
+
- **Creating new files**: When the AI includes a file name that doesn't exist in the original set.
|
|
172
|
+
- **Deleting files**: When the AI returns empty/whitespace-only content for a file (works with `whole` format; for `diff`/`udiff`, deletions happen naturally when all content is replaced).
|
|
108
173
|
|
|
109
|
-
|
|
174
|
+
### Best-effort application and warnings
|
|
110
175
|
|
|
111
|
-
|
|
176
|
+
`performAiEdit` applies edits independently and **never throws because the model
|
|
177
|
+
returned an edit that could not be matched**. If one edit references a missing
|
|
178
|
+
file, an anchor that cannot be found, or an ambiguous location, that edit is
|
|
179
|
+
skipped and reported — every other valid edit is still applied.
|
|
112
180
|
|
|
113
|
-
|
|
114
|
-
- Understand exactly what instructions are sent to the LLM for each format
|
|
115
|
-
- Build your own AI editing tools using the same proven prompt patterns
|
|
181
|
+
Skipped edits are returned in `result.warnings`:
|
|
116
182
|
|
|
117
|
-
|
|
183
|
+
```typescript
|
|
184
|
+
const result = await performAiEdit({
|
|
185
|
+
prompt,
|
|
186
|
+
files,
|
|
187
|
+
llmFunction,
|
|
188
|
+
editFormat: "diff",
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
for (const warning of result.warnings ?? []) {
|
|
192
|
+
console.warn(warning.code, warning.fileName, warning.message);
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
| Warning code | Meaning |
|
|
197
|
+
| ------------------ | ------------------------------------------------------------------- |
|
|
198
|
+
| `FILE_NOT_FOUND` | The edit targets a filename that isn't in the file set. |
|
|
199
|
+
| `SEARCH_NOT_FOUND` | The `SEARCH` text could not be located in the file. |
|
|
200
|
+
| `HUNK_NOT_FOUND` | A udiff hunk's original lines could not be located. |
|
|
201
|
+
| `AMBIGUOUS_SEARCH` | The anchor matched more than one location, so the edit was skipped. |
|
|
202
|
+
| `AMBIGUOUS_FILE` | More than one file shares the target name, so the edit was skipped. |
|
|
203
|
+
| `EMPTY_SEARCH` | The search block was empty, so the edit could not be applied. |
|
|
204
|
+
| `NO_EDITS_PARSED` | The model response contained no recognizable edits. |
|
|
205
|
+
|
|
206
|
+
Matching is deterministic: only harmless formatting drift is absorbed (CRLF vs
|
|
207
|
+
LF, a missing/extra trailing newline, trailing whitespace). Anything ambiguous or
|
|
208
|
+
unmatched is skipped rather than guessed. Successful normalized matches produce
|
|
209
|
+
no warnings.
|
|
210
|
+
|
|
211
|
+
`warnings` is optional, so existing consumers continue to work unchanged.
|
|
212
|
+
|
|
213
|
+
## API Reference
|
|
214
|
+
|
|
215
|
+
### `performAiEdit(params)`
|
|
216
|
+
|
|
217
|
+
The main entry point. Accepts a `PerformAiEditParams` object and returns a `PerformAiEditResult`.
|
|
218
|
+
|
|
219
|
+
#### Input: `PerformAiEditParams`
|
|
220
|
+
|
|
221
|
+
| Property | Type | Default | Description |
|
|
222
|
+
| ------------- | ----------------------------------------------- | ---------- | -------------------------------------------------------------------------------- |
|
|
223
|
+
| `prompt` | `string` | _required_ | Natural-language instructions describing the desired changes. |
|
|
224
|
+
| `files` | `VizFiles` | _required_ | A record of file objects (`{ [id]: { name, text } }`). |
|
|
225
|
+
| `llmFunction` | `LlmFunction` | _required_ | Async function that sends the assembled prompt to an LLM and returns its output. |
|
|
226
|
+
| `editFormat` | `"whole" \| "diff" \| "diff-fenced" \| "udiff"` | `"whole"` | How the LLM should specify file changes. |
|
|
227
|
+
| `apiKey` | `string` | optional | OpenRouter API key — if provided, cost metadata is fetched automatically. |
|
|
228
|
+
|
|
229
|
+
#### `LlmFunction`
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
type LlmFunction = (prompt: string) => Promise<{
|
|
233
|
+
content: string; // The raw string response from the LLM
|
|
234
|
+
generationId?: string; // OpenRouter generation ID (for cost tracking)
|
|
235
|
+
}>;
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
#### Output: `PerformAiEditResult`
|
|
239
|
+
|
|
240
|
+
| Property | Type | Description |
|
|
241
|
+
| ------------------------ | ---------------- | ----------------------------------------------------------------- |
|
|
242
|
+
| `changedFiles` | `VizFiles` | The updated file collection with all edits applied. |
|
|
243
|
+
| `openRouterGenerationId` | `string` | The generation ID from the LLM function response. |
|
|
244
|
+
| `upstreamCostCents` | `number` | Cost in cents (only populated if `apiKey` was provided). |
|
|
245
|
+
| `provider` | `string` | The OpenRouter provider name used. |
|
|
246
|
+
| `inputTokens` | `number` | Number of input (prompt) tokens billed. |
|
|
247
|
+
| `outputTokens` | `number` | Number of output (completion) tokens billed. |
|
|
248
|
+
| `promptTemplateVersion` | `number` | Version of the prompt template used (for tracking migrations). |
|
|
249
|
+
| `rawResponse` | `string` | The raw string response from the LLM, unmodified. |
|
|
250
|
+
| `warnings` | `ApplyWarning[]` | Edits that were skipped because they could not be applied safely. |
|
|
251
|
+
|
|
252
|
+
### Exported Utilities
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
import {
|
|
256
|
+
// --- Prompt assembly ---
|
|
257
|
+
assembleFullPrompt, // Build a complete prompt string from task, files, format
|
|
258
|
+
FORMAT_INSTRUCTIONS, // { whole, diff, 'diff-fenced', udiff, hybrid } — full formatting instructions
|
|
259
|
+
PROMPT_TEMPLATE_VERSION,
|
|
260
|
+
|
|
261
|
+
// --- File processing ---
|
|
262
|
+
mergeFileChanges, // Merge LLM output back into original files
|
|
263
|
+
prepareFilesForPrompt, // Prepare files for prompt (truncation, image exclusion)
|
|
264
|
+
isImageFile, // Check if a filename refers to an image
|
|
265
|
+
|
|
266
|
+
// --- Diff parsing (manual use) ---
|
|
267
|
+
parseDiffs, // Parse search/replace blocks (diff format)
|
|
268
|
+
applyDiffs, // Apply parsed diffs to a file set (throws on unmatched edits)
|
|
269
|
+
applyDiffsSafe, // Best-effort apply: returns { files, warnings } instead of throwing
|
|
270
|
+
parseDiffFenced, // Parse search/replace blocks (diff-fenced format)
|
|
271
|
+
parseUdiffs, // Parse unified diff hunks
|
|
272
|
+
applyUdiffs, // Apply parsed unified diff hunks (throws on unmatched edits)
|
|
273
|
+
applyUdiffsSafe, // Best-effort apply: returns { files, warnings } instead of throwing
|
|
274
|
+
applyHybridEdits, // Apply mixed whole-file + diff edits from a single response
|
|
275
|
+
applyHybridEditsSafe, // Best-effort hybrid apply: returns { files, warnings }
|
|
276
|
+
|
|
277
|
+
// --- Cost metadata ---
|
|
278
|
+
getGenerationMetadata, // Fetch OpenRouter cost data for a generation ID
|
|
279
|
+
} from "editcodewithai";
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## Format Instructions
|
|
283
|
+
|
|
284
|
+
The `FORMAT_INSTRUCTIONS` export contains the exact prompt text sent to the LLM for each edit format. You can use them in custom prompts:
|
|
118
285
|
|
|
119
286
|
```typescript
|
|
120
287
|
import { FORMAT_INSTRUCTIONS } from "editcodewithai";
|
|
@@ -134,58 +301,67 @@ ${yourCodeHere}
|
|
|
134
301
|
`;
|
|
135
302
|
```
|
|
136
303
|
|
|
137
|
-
|
|
304
|
+
## Image File Handling
|
|
138
305
|
|
|
139
|
-
|
|
306
|
+
Files with image extensions (`.png`, `.jpg`, `.jpeg`, `.gif`, `.bmp`, `.svg`, `.webp`) are handled specially:
|
|
140
307
|
|
|
141
|
-
-
|
|
142
|
-
-
|
|
143
|
-
-
|
|
144
|
-
- **`udiff`**: Instructions for unified diff format
|
|
308
|
+
- They are **excluded** from the file listing sent to the LLM (binary content is not useful in the prompt).
|
|
309
|
+
- Their filenames are listed separately at the end of the prompt so the LLM is aware of them (e.g., to reference them in HTML `<img>` tags).
|
|
310
|
+
- The `isImageFile()` utility can be used to check if a filename is an image.
|
|
145
311
|
|
|
146
|
-
|
|
312
|
+
## File Truncation
|
|
147
313
|
|
|
148
|
-
|
|
314
|
+
When preparing files for the prompt via `prepareFilesForPrompt`, large files are truncated to keep prompts manageable:
|
|
315
|
+
|
|
316
|
+
- Regular files: truncated to **500 lines**, each line capped at **200 characters**.
|
|
317
|
+
- CSV and JSON files: truncated to **50 lines** (these files tend to be very large).
|
|
318
|
+
- Image files: excluded entirely (see above).
|
|
149
319
|
|
|
150
|
-
|
|
320
|
+
## Benchmarking
|
|
151
321
|
|
|
152
|
-
|
|
322
|
+
The project includes a benchmarking system for evaluating LLM edit quality across different models and edit formats.
|
|
153
323
|
|
|
154
|
-
|
|
324
|
+
```bash
|
|
325
|
+
# Run benchmarks
|
|
326
|
+
npm run benchmark
|
|
155
327
|
|
|
156
|
-
|
|
328
|
+
# Grade results
|
|
329
|
+
npm run grade
|
|
330
|
+
```
|
|
157
331
|
|
|
158
|
-
|
|
332
|
+
Benchmarks live in the `benchmarks/` directory. Results, caches, and challenges are gitignored to keep the repository size small.
|
|
159
333
|
|
|
160
|
-
|
|
334
|
+
## OpenRouter Cost Tracking
|
|
161
335
|
|
|
162
|
-
|
|
336
|
+
If you provide an `apiKey` (OpenRouter API key) and your `LlmFunction` returns a `generationId` from OpenRouter, the library automatically fetches cost metadata after each edit. The result includes `upstreamCostCents`, `provider`, `inputTokens`, and `outputTokens`.
|
|
163
337
|
|
|
164
|
-
|
|
338
|
+
The `getGenerationMetadata` utility implements retry logic with up to 10 attempts (1 second delay) to handle the brief delay before OpenRouter makes generation metadata available.
|
|
339
|
+
|
|
340
|
+
## Similar Projects
|
|
165
341
|
|
|
166
|
-
|
|
342
|
+
- **[Aider](https://aider.chat/)** — AI pair programming in the terminal with local git repos. The edit formats used in this library were inspired by Aider's search/replace approach.
|
|
343
|
+
- **[Bolt.new / Bolt.diy](https://bolt.new/)** — Prompt, run, edit, and deploy full-stack apps in the browser.
|
|
344
|
+
- **[Cline](https://cline.ai/)** — AI coding assistant for VS Code.
|
|
345
|
+
- **[Cerebras Coder](https://www.cerebras.net/)** — Code generation using Cerebras hardware.
|
|
346
|
+
- **[Pear AI](https://trypear.ai/)** — Open-source AI code editor.
|
|
347
|
+
- **[Void](https://void.dev/)** — Open-source AI coding environment with privacy focus.
|
|
348
|
+
- **[Cody](https://github.com/sourcegraph/cody)** — AI coding assistant by Sourcegraph, available as IDE extensions.
|
|
349
|
+
|
|
350
|
+
## Contributing
|
|
167
351
|
|
|
168
352
|
```bash
|
|
169
353
|
# Clone the repository
|
|
170
|
-
git clone https://github.com/
|
|
171
|
-
|
|
172
|
-
# Navigate to project directory
|
|
173
|
-
cd editcodewithai
|
|
354
|
+
git clone https://github.com/vizhub-core/editcodewithai.git
|
|
174
355
|
|
|
175
356
|
# Install dependencies
|
|
357
|
+
cd editcodewithai
|
|
176
358
|
npm install
|
|
177
359
|
```
|
|
178
360
|
|
|
179
|
-
|
|
361
|
+
Before submitting a PR, ensure all checks pass:
|
|
180
362
|
|
|
181
363
|
```bash
|
|
182
364
|
npm test
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
Please submit pull requests with clear descriptions of changes and ensure all tests pass. Protocol for wrapping up a PR:
|
|
186
|
-
|
|
187
|
-
```
|
|
188
|
-
npm test
|
|
189
365
|
npm run typecheck
|
|
190
366
|
npm run prettier
|
|
191
367
|
# Verify the README is up to date
|
|
@@ -195,4 +371,4 @@ Please create an issue first before creating a PR to discuss the changes you wan
|
|
|
195
371
|
|
|
196
372
|
## License
|
|
197
373
|
|
|
198
|
-
|
|
374
|
+
MIT. See the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { VizFiles } from "@vizhub/viz-types";
|
|
2
|
+
import type { ApplyResult, ParsedEdit } from "./types";
|
|
3
|
+
/**
|
|
4
|
+
* Applies parsed edits on a best-effort basis.
|
|
5
|
+
*
|
|
6
|
+
* Each edit is applied independently: an edit that cannot be applied safely is
|
|
7
|
+
* skipped and reported in `warnings`, and never prevents other edits from being
|
|
8
|
+
* applied. The input `files` is never mutated.
|
|
9
|
+
*
|
|
10
|
+
* This function only throws for programmer error; anticipated model-output
|
|
11
|
+
* failures are reported as warnings.
|
|
12
|
+
*/
|
|
13
|
+
export declare function applyEditsSafe(files: VizFiles, edits: ParsedEdit[]): ApplyResult;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.applyEditsSafe = applyEditsSafe;
|
|
4
|
+
const matching_1 = require("./matching");
|
|
5
|
+
/**
|
|
6
|
+
* Normalizes a filename for comparison: trims, converts Windows separators to
|
|
7
|
+
* "/", collapses duplicate separators, and strips leading "./" and "/".
|
|
8
|
+
*
|
|
9
|
+
* Case is intentionally preserved — case-insensitive matching is not part of
|
|
10
|
+
* the deterministic behavior and is deferred.
|
|
11
|
+
*/
|
|
12
|
+
function normalizeFileName(name) {
|
|
13
|
+
return name
|
|
14
|
+
.trim()
|
|
15
|
+
.replace(/\\/g, "/")
|
|
16
|
+
.replace(/\/+/g, "/")
|
|
17
|
+
.replace(/^\.\//, "")
|
|
18
|
+
.replace(/^\/+/, "");
|
|
19
|
+
}
|
|
20
|
+
function resolveFileId(files, rawName) {
|
|
21
|
+
const target = normalizeFileName(rawName);
|
|
22
|
+
const matches = Object.keys(files).filter((id) => normalizeFileName(files[id].name) === target);
|
|
23
|
+
if (matches.length === 1)
|
|
24
|
+
return { kind: "found", id: matches[0] };
|
|
25
|
+
if (matches.length > 1)
|
|
26
|
+
return { kind: "ambiguous" };
|
|
27
|
+
return { kind: "none" };
|
|
28
|
+
}
|
|
29
|
+
function anchorOf(edit) {
|
|
30
|
+
return edit.kind === "diff" ? edit.search : edit.original;
|
|
31
|
+
}
|
|
32
|
+
function replacementOf(edit) {
|
|
33
|
+
return edit.kind === "diff" ? edit.replace : edit.updated;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Applies parsed edits on a best-effort basis.
|
|
37
|
+
*
|
|
38
|
+
* Each edit is applied independently: an edit that cannot be applied safely is
|
|
39
|
+
* skipped and reported in `warnings`, and never prevents other edits from being
|
|
40
|
+
* applied. The input `files` is never mutated.
|
|
41
|
+
*
|
|
42
|
+
* This function only throws for programmer error; anticipated model-output
|
|
43
|
+
* failures are reported as warnings.
|
|
44
|
+
*/
|
|
45
|
+
function applyEditsSafe(files, edits) {
|
|
46
|
+
const work = JSON.parse(JSON.stringify(files));
|
|
47
|
+
const warnings = [];
|
|
48
|
+
for (const edit of edits) {
|
|
49
|
+
const resolution = resolveFileId(work, edit.fileName);
|
|
50
|
+
if (resolution.kind === "ambiguous") {
|
|
51
|
+
warnings.push({
|
|
52
|
+
code: "AMBIGUOUS_FILE",
|
|
53
|
+
fileName: edit.fileName,
|
|
54
|
+
message: `Skipped ${edit.fileName}: multiple files share that name.`,
|
|
55
|
+
});
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (resolution.kind === "none") {
|
|
59
|
+
warnings.push({
|
|
60
|
+
code: "FILE_NOT_FOUND",
|
|
61
|
+
fileName: edit.fileName,
|
|
62
|
+
message: `Skipped changes to ${edit.fileName}: file not found.`,
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const anchor = anchorOf(edit);
|
|
67
|
+
if (anchor.trim() === "") {
|
|
68
|
+
warnings.push({
|
|
69
|
+
code: "EMPTY_SEARCH",
|
|
70
|
+
fileName: edit.fileName,
|
|
71
|
+
message: `Skipped a change to ${edit.fileName}: the search block was empty.`,
|
|
72
|
+
});
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const file = work[resolution.id];
|
|
76
|
+
const match = (0, matching_1.findAnchor)(file.text, anchor);
|
|
77
|
+
if (match === null) {
|
|
78
|
+
warnings.push({
|
|
79
|
+
code: edit.kind === "udiff" ? "HUNK_NOT_FOUND" : "SEARCH_NOT_FOUND",
|
|
80
|
+
fileName: edit.fileName,
|
|
81
|
+
message: edit.kind === "udiff"
|
|
82
|
+
? `Skipped part of a change to ${edit.fileName}: referenced lines were not found.`
|
|
83
|
+
: `Skipped a change to ${edit.fileName}: the referenced code was not found.`,
|
|
84
|
+
});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// Ambiguous anchors are never guessed: skip rather than replace the first.
|
|
88
|
+
if (match.matchCount > 1) {
|
|
89
|
+
warnings.push({
|
|
90
|
+
code: "AMBIGUOUS_SEARCH",
|
|
91
|
+
fileName: edit.fileName,
|
|
92
|
+
message: `Skipped a change to ${edit.fileName}: the referenced code matched ${match.matchCount} locations.`,
|
|
93
|
+
});
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
file.text =
|
|
97
|
+
file.text.slice(0, match.start) +
|
|
98
|
+
replacementOf(edit) +
|
|
99
|
+
file.text.slice(match.end);
|
|
100
|
+
}
|
|
101
|
+
return { files: work, warnings };
|
|
102
|
+
}
|
package/dist/fileUtils.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { VizFiles, VizFile, FileCollection } from "@vizhub/viz-types";
|
|
2
|
+
import type { ApplyResult } from "./types";
|
|
2
3
|
/**
|
|
3
4
|
* If the LLM outputs empty text for a file, we interpret this
|
|
4
5
|
* as a request to delete the file.
|
|
@@ -33,4 +34,31 @@ export interface UdiffHunk {
|
|
|
33
34
|
updated: string;
|
|
34
35
|
}
|
|
35
36
|
export declare function parseUdiffs(responseText: string): UdiffHunk[];
|
|
37
|
+
/**
|
|
38
|
+
* Hybrid edit mode: parses both whole-file blocks (bold-markdown)
|
|
39
|
+
* and search/replace diff blocks from the same response.
|
|
40
|
+
*
|
|
41
|
+
* Strategy:
|
|
42
|
+
* 1. Apply all search/replace diffs first.
|
|
43
|
+
* 2. Then apply whole-file replacements on top.
|
|
44
|
+
* (If the same file appears in both forms, the whole-file content wins.)
|
|
45
|
+
*/
|
|
46
|
+
export declare function applyHybridEdits(responseText: string, originalFiles: VizFiles, parseWholeFiles: (text: string) => FileCollection): VizFiles;
|
|
47
|
+
/**
|
|
48
|
+
* Best-effort variant of `applyDiffs`. Never throws for unmatched files or
|
|
49
|
+
* anchors; skipped edits are reported in `warnings`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function applyDiffsSafe(originalFiles: VizFiles, diffs: Diff[]): ApplyResult;
|
|
52
|
+
/**
|
|
53
|
+
* Best-effort variant of `applyUdiffs`. Never throws for unmatched files or
|
|
54
|
+
* hunks; skipped edits are reported in `warnings`.
|
|
55
|
+
*/
|
|
56
|
+
export declare function applyUdiffsSafe(originalFiles: VizFiles, hunks: UdiffHunk[]): ApplyResult;
|
|
57
|
+
/**
|
|
58
|
+
* Best-effort variant of `applyHybridEdits`. Applies search/replace diffs first,
|
|
59
|
+
* then whole-file replacements. Warnings for diffs that target a file which is
|
|
60
|
+
* also replaced wholesale are suppressed, since the whole-file content
|
|
61
|
+
* supersedes the failed diff.
|
|
62
|
+
*/
|
|
63
|
+
export declare function applyHybridEditsSafe(responseText: string, originalFiles: VizFiles, parseWholeFiles: (text: string) => FileCollection): ApplyResult;
|
|
36
64
|
export declare function applyUdiffs(originalFiles: VizFiles, hunks: UdiffHunk[]): VizFiles;
|
package/dist/fileUtils.js
CHANGED
|
@@ -8,8 +8,13 @@ exports.parseDiffs = parseDiffs;
|
|
|
8
8
|
exports.applyDiffs = applyDiffs;
|
|
9
9
|
exports.parseDiffFenced = parseDiffFenced;
|
|
10
10
|
exports.parseUdiffs = parseUdiffs;
|
|
11
|
+
exports.applyHybridEdits = applyHybridEdits;
|
|
12
|
+
exports.applyDiffsSafe = applyDiffsSafe;
|
|
13
|
+
exports.applyUdiffsSafe = applyUdiffsSafe;
|
|
14
|
+
exports.applyHybridEditsSafe = applyHybridEditsSafe;
|
|
11
15
|
exports.applyUdiffs = applyUdiffs;
|
|
12
16
|
const viz_utils_1 = require("@vizhub/viz-utils");
|
|
17
|
+
const applyEngine_1 = require("./applyEngine");
|
|
13
18
|
/**
|
|
14
19
|
* If the LLM outputs empty text for a file, we interpret this
|
|
15
20
|
* as a request to delete the file.
|
|
@@ -165,6 +170,63 @@ function parseUdiffs(responseText) {
|
|
|
165
170
|
}
|
|
166
171
|
return hunks;
|
|
167
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Hybrid edit mode: parses both whole-file blocks (bold-markdown)
|
|
175
|
+
* and search/replace diff blocks from the same response.
|
|
176
|
+
*
|
|
177
|
+
* Strategy:
|
|
178
|
+
* 1. Apply all search/replace diffs first.
|
|
179
|
+
* 2. Then apply whole-file replacements on top.
|
|
180
|
+
* (If the same file appears in both forms, the whole-file content wins.)
|
|
181
|
+
*/
|
|
182
|
+
function applyHybridEdits(responseText, originalFiles, parseWholeFiles) {
|
|
183
|
+
// Step 1: apply search/replace diffs
|
|
184
|
+
const diffs = parseDiffs(responseText);
|
|
185
|
+
let changedFiles = applyDiffs(originalFiles, diffs);
|
|
186
|
+
// Step 2: parse and apply whole-file replacements on top
|
|
187
|
+
const parsed = parseWholeFiles(responseText);
|
|
188
|
+
changedFiles = mergeFileChanges(changedFiles, parsed);
|
|
189
|
+
return changedFiles;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Best-effort variant of `applyDiffs`. Never throws for unmatched files or
|
|
193
|
+
* anchors; skipped edits are reported in `warnings`.
|
|
194
|
+
*/
|
|
195
|
+
function applyDiffsSafe(originalFiles, diffs) {
|
|
196
|
+
return (0, applyEngine_1.applyEditsSafe)(originalFiles, diffs.map((diff) => ({
|
|
197
|
+
kind: "diff",
|
|
198
|
+
fileName: diff.fileName,
|
|
199
|
+
search: diff.search,
|
|
200
|
+
replace: diff.replace,
|
|
201
|
+
})));
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Best-effort variant of `applyUdiffs`. Never throws for unmatched files or
|
|
205
|
+
* hunks; skipped edits are reported in `warnings`.
|
|
206
|
+
*/
|
|
207
|
+
function applyUdiffsSafe(originalFiles, hunks) {
|
|
208
|
+
return (0, applyEngine_1.applyEditsSafe)(originalFiles, hunks.map((hunk) => ({
|
|
209
|
+
kind: "udiff",
|
|
210
|
+
fileName: hunk.fileName,
|
|
211
|
+
original: hunk.original,
|
|
212
|
+
updated: hunk.updated,
|
|
213
|
+
})));
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Best-effort variant of `applyHybridEdits`. Applies search/replace diffs first,
|
|
217
|
+
* then whole-file replacements. Warnings for diffs that target a file which is
|
|
218
|
+
* also replaced wholesale are suppressed, since the whole-file content
|
|
219
|
+
* supersedes the failed diff.
|
|
220
|
+
*/
|
|
221
|
+
function applyHybridEditsSafe(responseText, originalFiles, parseWholeFiles) {
|
|
222
|
+
const diffResult = applyDiffsSafe(originalFiles, parseDiffs(responseText));
|
|
223
|
+
const parsedWholeFiles = parseWholeFiles(responseText);
|
|
224
|
+
const changedFiles = mergeFileChanges(diffResult.files, parsedWholeFiles);
|
|
225
|
+
const wholeFileNames = new Set(Object.keys(parsedWholeFiles).map((name) => name.trim()));
|
|
226
|
+
const warnings = diffResult.warnings.filter((warning) => warning.fileName === undefined ||
|
|
227
|
+
!wholeFileNames.has(warning.fileName.trim()));
|
|
228
|
+
return { files: changedFiles, warnings };
|
|
229
|
+
}
|
|
168
230
|
function applyUdiffs(originalFiles, hunks) {
|
|
169
231
|
const changedFiles = JSON.parse(JSON.stringify(originalFiles));
|
|
170
232
|
for (const hunk of hunks) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { PerformAiEditParams, PerformAiEditResult } from "./types";
|
|
2
2
|
import { PROMPT_TEMPLATE_VERSION, assembleFullPrompt, FORMAT_INSTRUCTIONS } from "./prompt";
|
|
3
3
|
import { getGenerationMetadata } from "./metadata";
|
|
4
|
-
import { prepareFilesForPrompt, mergeFileChanges, parseDiffs, applyDiffs, parseDiffFenced, parseUdiffs, applyUdiffs, isImageFile } from "./fileUtils";
|
|
5
|
-
export type { LlmFunction, PerformAiEditParams, PerformAiEditResult, EditFormat, } from "./types";
|
|
6
|
-
export { FORMAT_INSTRUCTIONS, mergeFileChanges, prepareFilesForPrompt, isImageFile, parseDiffs, applyDiffs, parseDiffFenced, parseUdiffs, applyUdiffs, assembleFullPrompt, getGenerationMetadata, PROMPT_TEMPLATE_VERSION, };
|
|
4
|
+
import { prepareFilesForPrompt, mergeFileChanges, parseDiffs, applyDiffs, applyDiffsSafe, parseDiffFenced, parseUdiffs, applyUdiffs, applyUdiffsSafe, applyHybridEdits, applyHybridEditsSafe, isImageFile } from "./fileUtils";
|
|
5
|
+
export type { LlmFunction, PerformAiEditParams, PerformAiEditResult, EditFormat, ApplyWarning, ApplyWarningCode, ApplyResult, ParsedEdit, } from "./types";
|
|
6
|
+
export { FORMAT_INSTRUCTIONS, mergeFileChanges, prepareFilesForPrompt, isImageFile, parseDiffs, applyDiffs, applyDiffsSafe, parseDiffFenced, parseUdiffs, applyUdiffs, applyUdiffsSafe, applyHybridEdits, applyHybridEditsSafe, assembleFullPrompt, getGenerationMetadata, PROMPT_TEMPLATE_VERSION, };
|
|
7
7
|
/**
|
|
8
8
|
* Core AI logic for:
|
|
9
9
|
* - Building the prompt (including context)
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.PROMPT_TEMPLATE_VERSION = exports.getGenerationMetadata = exports.assembleFullPrompt = exports.applyUdiffs = exports.parseUdiffs = exports.parseDiffFenced = exports.applyDiffs = exports.parseDiffs = exports.isImageFile = exports.prepareFilesForPrompt = exports.mergeFileChanges = exports.FORMAT_INSTRUCTIONS = void 0;
|
|
3
|
+
exports.PROMPT_TEMPLATE_VERSION = exports.getGenerationMetadata = exports.assembleFullPrompt = exports.applyHybridEditsSafe = exports.applyHybridEdits = exports.applyUdiffsSafe = exports.applyUdiffs = exports.parseUdiffs = exports.parseDiffFenced = exports.applyDiffsSafe = exports.applyDiffs = exports.parseDiffs = exports.isImageFile = exports.prepareFilesForPrompt = exports.mergeFileChanges = exports.FORMAT_INSTRUCTIONS = void 0;
|
|
4
4
|
exports.performAiEdit = performAiEdit;
|
|
5
5
|
const llm_code_format_1 = require("llm-code-format");
|
|
6
6
|
const prompt_1 = require("./prompt");
|
|
@@ -14,9 +14,13 @@ Object.defineProperty(exports, "prepareFilesForPrompt", { enumerable: true, get:
|
|
|
14
14
|
Object.defineProperty(exports, "mergeFileChanges", { enumerable: true, get: function () { return fileUtils_1.mergeFileChanges; } });
|
|
15
15
|
Object.defineProperty(exports, "parseDiffs", { enumerable: true, get: function () { return fileUtils_1.parseDiffs; } });
|
|
16
16
|
Object.defineProperty(exports, "applyDiffs", { enumerable: true, get: function () { return fileUtils_1.applyDiffs; } });
|
|
17
|
+
Object.defineProperty(exports, "applyDiffsSafe", { enumerable: true, get: function () { return fileUtils_1.applyDiffsSafe; } });
|
|
17
18
|
Object.defineProperty(exports, "parseDiffFenced", { enumerable: true, get: function () { return fileUtils_1.parseDiffFenced; } });
|
|
18
19
|
Object.defineProperty(exports, "parseUdiffs", { enumerable: true, get: function () { return fileUtils_1.parseUdiffs; } });
|
|
19
20
|
Object.defineProperty(exports, "applyUdiffs", { enumerable: true, get: function () { return fileUtils_1.applyUdiffs; } });
|
|
21
|
+
Object.defineProperty(exports, "applyUdiffsSafe", { enumerable: true, get: function () { return fileUtils_1.applyUdiffsSafe; } });
|
|
22
|
+
Object.defineProperty(exports, "applyHybridEdits", { enumerable: true, get: function () { return fileUtils_1.applyHybridEdits; } });
|
|
23
|
+
Object.defineProperty(exports, "applyHybridEditsSafe", { enumerable: true, get: function () { return fileUtils_1.applyHybridEditsSafe; } });
|
|
20
24
|
Object.defineProperty(exports, "isImageFile", { enumerable: true, get: function () { return fileUtils_1.isImageFile; } });
|
|
21
25
|
const debug = false;
|
|
22
26
|
/**
|
|
@@ -43,31 +47,60 @@ async function performAiEdit({ prompt, files, llmFunction, apiKey, editFormat =
|
|
|
43
47
|
// 4. We parse the output to figure out which files changed
|
|
44
48
|
const resultString = result.content;
|
|
45
49
|
let changedFiles;
|
|
50
|
+
const warnings = [];
|
|
51
|
+
let editsParsed = 0;
|
|
46
52
|
switch (editFormat) {
|
|
47
53
|
case "whole": {
|
|
48
54
|
const parsed = (0, llm_code_format_1.parseMarkdownFiles)(resultString, "bold");
|
|
55
|
+
editsParsed = Object.keys(parsed.files).length;
|
|
49
56
|
changedFiles = (0, fileUtils_1.mergeFileChanges)(files, parsed.files);
|
|
50
57
|
break;
|
|
51
58
|
}
|
|
52
59
|
case "diff": {
|
|
53
60
|
const diffs = (0, fileUtils_1.parseDiffs)(resultString);
|
|
54
|
-
|
|
61
|
+
editsParsed = diffs.length;
|
|
62
|
+
const applied = (0, fileUtils_1.applyDiffsSafe)(files, diffs);
|
|
63
|
+
changedFiles = applied.files;
|
|
64
|
+
warnings.push(...applied.warnings);
|
|
55
65
|
break;
|
|
56
66
|
}
|
|
57
67
|
case "diff-fenced": {
|
|
58
68
|
const diffs = (0, fileUtils_1.parseDiffFenced)(resultString);
|
|
59
|
-
|
|
69
|
+
editsParsed = diffs.length;
|
|
70
|
+
const applied = (0, fileUtils_1.applyDiffsSafe)(files, diffs);
|
|
71
|
+
changedFiles = applied.files;
|
|
72
|
+
warnings.push(...applied.warnings);
|
|
60
73
|
break;
|
|
61
74
|
}
|
|
62
75
|
case "udiff": {
|
|
63
76
|
const hunks = (0, fileUtils_1.parseUdiffs)(resultString);
|
|
64
|
-
|
|
77
|
+
editsParsed = hunks.length;
|
|
78
|
+
const applied = (0, fileUtils_1.applyUdiffsSafe)(files, hunks);
|
|
79
|
+
changedFiles = applied.files;
|
|
80
|
+
warnings.push(...applied.warnings);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
case "hybrid": {
|
|
84
|
+
const diffs = (0, fileUtils_1.parseDiffs)(resultString);
|
|
85
|
+
const wholeFiles = (0, llm_code_format_1.parseMarkdownFiles)(resultString, "bold").files;
|
|
86
|
+
editsParsed = diffs.length + Object.keys(wholeFiles).length;
|
|
87
|
+
const applied = (0, fileUtils_1.applyHybridEditsSafe)(resultString, files, (text) => (0, llm_code_format_1.parseMarkdownFiles)(text, "bold").files);
|
|
88
|
+
changedFiles = applied.files;
|
|
89
|
+
warnings.push(...applied.warnings);
|
|
65
90
|
break;
|
|
66
91
|
}
|
|
67
92
|
default:
|
|
68
93
|
// This will catch any unhandled or unknown edit formats.
|
|
69
94
|
throw new Error(`Unknown edit format: ${editFormat}`);
|
|
70
95
|
}
|
|
96
|
+
// Zero parsed edits is worth surfacing, but only when nothing else was
|
|
97
|
+
// reported — otherwise it just adds noise on top of real failures.
|
|
98
|
+
if (editsParsed === 0 && warnings.length === 0) {
|
|
99
|
+
warnings.push({
|
|
100
|
+
code: "NO_EDITS_PARSED",
|
|
101
|
+
message: "The model returned no applicable edits.",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
71
104
|
// 6. Retrieve cost metadata for charging the user
|
|
72
105
|
const openRouterGenerationId = result.generationId || "";
|
|
73
106
|
let upstreamCostCents = 0;
|
|
@@ -93,5 +126,6 @@ async function performAiEdit({ prompt, files, llmFunction, apiKey, editFormat =
|
|
|
93
126
|
outputTokens,
|
|
94
127
|
promptTemplateVersion: prompt_1.PROMPT_TEMPLATE_VERSION,
|
|
95
128
|
rawResponse: resultString, // Include the raw response
|
|
129
|
+
warnings,
|
|
96
130
|
};
|
|
97
131
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic anchor matching for edit application.
|
|
3
|
+
*
|
|
4
|
+
* This module intentionally implements only deterministic strategies. It never
|
|
5
|
+
* guesses a location via similarity scoring; if a deterministic match cannot be
|
|
6
|
+
* found, callers skip the edit and report a warning.
|
|
7
|
+
*
|
|
8
|
+
* Strategies, in order:
|
|
9
|
+
* 1. `exact` — the anchor is a literal substring.
|
|
10
|
+
* 2. `line-normalized` — both sides split into lines and compared with
|
|
11
|
+
* trailing whitespace removed. This absorbs CRLF vs LF
|
|
12
|
+
* drift, a missing/extra trailing newline, and
|
|
13
|
+
* trailing-whitespace drift.
|
|
14
|
+
*
|
|
15
|
+
* Leading indentation drift is deliberately NOT handled (deferred).
|
|
16
|
+
*/
|
|
17
|
+
export type MatchStrategy = "exact" | "line-normalized";
|
|
18
|
+
export interface AnchorMatch {
|
|
19
|
+
start: number;
|
|
20
|
+
end: number;
|
|
21
|
+
strategy: MatchStrategy;
|
|
22
|
+
/** Number of equally-valid locations found. `> 1` means ambiguous. */
|
|
23
|
+
matchCount: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Finds `anchor` within `source` using deterministic strategies only.
|
|
27
|
+
*
|
|
28
|
+
* Returns `null` when no deterministic match exists. When a match exists,
|
|
29
|
+
* `matchCount` reports how many equivalent locations were found so callers can
|
|
30
|
+
* skip ambiguous edits.
|
|
31
|
+
*/
|
|
32
|
+
export declare function findAnchor(source: string, anchor: string): AnchorMatch | null;
|
package/dist/matching.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic anchor matching for edit application.
|
|
4
|
+
*
|
|
5
|
+
* This module intentionally implements only deterministic strategies. It never
|
|
6
|
+
* guesses a location via similarity scoring; if a deterministic match cannot be
|
|
7
|
+
* found, callers skip the edit and report a warning.
|
|
8
|
+
*
|
|
9
|
+
* Strategies, in order:
|
|
10
|
+
* 1. `exact` — the anchor is a literal substring.
|
|
11
|
+
* 2. `line-normalized` — both sides split into lines and compared with
|
|
12
|
+
* trailing whitespace removed. This absorbs CRLF vs LF
|
|
13
|
+
* drift, a missing/extra trailing newline, and
|
|
14
|
+
* trailing-whitespace drift.
|
|
15
|
+
*
|
|
16
|
+
* Leading indentation drift is deliberately NOT handled (deferred).
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.findAnchor = findAnchor;
|
|
20
|
+
/**
|
|
21
|
+
* Splits text into lines while retaining each line's character offsets.
|
|
22
|
+
* A trailing newline produces a final empty line, matching `String.split("\n")`.
|
|
23
|
+
*/
|
|
24
|
+
function splitLinesWithOffsets(text) {
|
|
25
|
+
const lines = [];
|
|
26
|
+
if (text.length === 0)
|
|
27
|
+
return lines;
|
|
28
|
+
let start = 0;
|
|
29
|
+
// Loop is bounded: `start` always advances past the newline.
|
|
30
|
+
for (;;) {
|
|
31
|
+
const newlineIndex = text.indexOf("\n", start);
|
|
32
|
+
if (newlineIndex === -1) {
|
|
33
|
+
lines.push({ text: text.slice(start), start, end: text.length });
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
lines.push({
|
|
37
|
+
text: text.slice(start, newlineIndex),
|
|
38
|
+
start,
|
|
39
|
+
end: newlineIndex,
|
|
40
|
+
});
|
|
41
|
+
start = newlineIndex + 1;
|
|
42
|
+
if (start === text.length) {
|
|
43
|
+
lines.push({ text: "", start, end: start });
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return lines;
|
|
48
|
+
}
|
|
49
|
+
function countOccurrences(haystack, needle) {
|
|
50
|
+
if (needle.length === 0)
|
|
51
|
+
return 0;
|
|
52
|
+
let count = 0;
|
|
53
|
+
let index = haystack.indexOf(needle);
|
|
54
|
+
while (index !== -1) {
|
|
55
|
+
count++;
|
|
56
|
+
index = haystack.indexOf(needle, index + needle.length);
|
|
57
|
+
}
|
|
58
|
+
return count;
|
|
59
|
+
}
|
|
60
|
+
function findLineNormalized(source, anchor) {
|
|
61
|
+
const sourceLines = splitLinesWithOffsets(source);
|
|
62
|
+
const anchorLines = splitLinesWithOffsets(anchor);
|
|
63
|
+
// Trailing/leading blank lines carry no anchoring information.
|
|
64
|
+
while (anchorLines.length > 0 &&
|
|
65
|
+
anchorLines[anchorLines.length - 1].text.trim() === "") {
|
|
66
|
+
anchorLines.pop();
|
|
67
|
+
}
|
|
68
|
+
while (anchorLines.length > 0 && anchorLines[0].text.trim() === "") {
|
|
69
|
+
anchorLines.shift();
|
|
70
|
+
}
|
|
71
|
+
if (anchorLines.length === 0)
|
|
72
|
+
return null;
|
|
73
|
+
if (anchorLines.length > sourceLines.length)
|
|
74
|
+
return null;
|
|
75
|
+
const normalizedAnchor = anchorLines.map((line) => line.text.trimEnd());
|
|
76
|
+
const normalizedSource = sourceLines.map((line) => line.text.trimEnd());
|
|
77
|
+
let matchCount = 0;
|
|
78
|
+
let first = null;
|
|
79
|
+
for (let i = 0; i + normalizedAnchor.length <= sourceLines.length; i++) {
|
|
80
|
+
let isMatch = true;
|
|
81
|
+
for (let j = 0; j < normalizedAnchor.length; j++) {
|
|
82
|
+
if (normalizedSource[i + j] !== normalizedAnchor[j]) {
|
|
83
|
+
isMatch = false;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (isMatch) {
|
|
88
|
+
matchCount++;
|
|
89
|
+
if (first === null) {
|
|
90
|
+
first = {
|
|
91
|
+
start: sourceLines[i].start,
|
|
92
|
+
end: sourceLines[i + normalizedAnchor.length - 1].end,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (first === null)
|
|
98
|
+
return null;
|
|
99
|
+
return {
|
|
100
|
+
start: first.start,
|
|
101
|
+
end: first.end,
|
|
102
|
+
strategy: "line-normalized",
|
|
103
|
+
matchCount,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Finds `anchor` within `source` using deterministic strategies only.
|
|
108
|
+
*
|
|
109
|
+
* Returns `null` when no deterministic match exists. When a match exists,
|
|
110
|
+
* `matchCount` reports how many equivalent locations were found so callers can
|
|
111
|
+
* skip ambiguous edits.
|
|
112
|
+
*/
|
|
113
|
+
function findAnchor(source, anchor) {
|
|
114
|
+
if (anchor.length === 0 || source.length === 0)
|
|
115
|
+
return null;
|
|
116
|
+
const exactIndex = source.indexOf(anchor);
|
|
117
|
+
if (exactIndex !== -1) {
|
|
118
|
+
return {
|
|
119
|
+
start: exactIndex,
|
|
120
|
+
end: exactIndex + anchor.length,
|
|
121
|
+
strategy: "exact",
|
|
122
|
+
matchCount: countOccurrences(source, anchor),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return findLineNormalized(source, anchor);
|
|
126
|
+
}
|
package/dist/prompt.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { EditFormat } from "./types";
|
|
2
|
-
export declare const PROMPT_TEMPLATE_VERSION =
|
|
2
|
+
export declare const PROMPT_TEMPLATE_VERSION = 2;
|
|
3
3
|
export declare const FORMAT_INSTRUCTIONS: Record<EditFormat, string>;
|
|
4
4
|
/**
|
|
5
5
|
* Assembles the full prompt by combining task, files context, and formatting instructions
|
package/dist/prompt.js
CHANGED
|
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.FORMAT_INSTRUCTIONS = exports.PROMPT_TEMPLATE_VERSION = void 0;
|
|
4
4
|
exports.assembleFullPrompt = assembleFullPrompt;
|
|
5
5
|
// Versions of the prompt template.
|
|
6
|
-
exports.PROMPT_TEMPLATE_VERSION =
|
|
6
|
+
exports.PROMPT_TEMPLATE_VERSION = 2;
|
|
7
7
|
// Template pieces
|
|
8
8
|
const TASK = (prompt) => `## Your Task\n\n${prompt}`;
|
|
9
9
|
const FILES = (filesContext) => `## Original Files\n\n${filesContext}`;
|
|
@@ -32,6 +32,7 @@ exports.FORMAT_INSTRUCTIONS = {
|
|
|
32
32
|
"// new code\n",
|
|
33
33
|
">>>>>>> REPLACE\n",
|
|
34
34
|
"```\n",
|
|
35
|
+
"Use the exact filename listed in the Original Files section. Copy the SEARCH text verbatim, including whitespace and indentation. Do not invent filenames.",
|
|
35
36
|
].join(""),
|
|
36
37
|
"diff-fenced": [
|
|
37
38
|
"## Formatting Instructions\n\n",
|
|
@@ -44,6 +45,7 @@ exports.FORMAT_INSTRUCTIONS = {
|
|
|
44
45
|
"// new code\n",
|
|
45
46
|
">>>>>>> REPLACE\n",
|
|
46
47
|
"```\n",
|
|
48
|
+
"Use the exact filename listed in the Original Files section. Copy the SEARCH text verbatim, including whitespace and indentation. Do not invent filenames.",
|
|
47
49
|
].join(""),
|
|
48
50
|
udiff: [
|
|
49
51
|
"## Formatting Instructions\n\n",
|
|
@@ -55,6 +57,44 @@ exports.FORMAT_INSTRUCTIONS = {
|
|
|
55
57
|
"-// line to be removed\n",
|
|
56
58
|
"+// line to be added\n",
|
|
57
59
|
"```\n",
|
|
60
|
+
"Use the exact filename listed in the Original Files section. Copy all context lines verbatim, including whitespace. Do not invent filenames; use the whole-file format when creating a new file.",
|
|
61
|
+
].join(""),
|
|
62
|
+
hybrid: [
|
|
63
|
+
"## Formatting Instructions\n\n",
|
|
64
|
+
"Suggest changes to the original files. You may use either of these two",
|
|
65
|
+
" formats for each file, choosing whichever is more appropriate:\n\n",
|
|
66
|
+
"**Whole file format** (use for major rewrites, new files, or when many",
|
|
67
|
+
" parts of a file change):\n\n",
|
|
68
|
+
"```\n",
|
|
69
|
+
"**path/to/fileA.js**\n",
|
|
70
|
+
"```js\n",
|
|
71
|
+
"// Entire updated code for fileA\n",
|
|
72
|
+
"```\n",
|
|
73
|
+
"```\n\n",
|
|
74
|
+
"**Search/replace diff format** (use for small, targeted changes):\n\n",
|
|
75
|
+
"```\n",
|
|
76
|
+
"path/to/fileB.js\n",
|
|
77
|
+
"```\n",
|
|
78
|
+
"<<<<<<< SEARCH\n",
|
|
79
|
+
"// code to be replaced\n",
|
|
80
|
+
"=======\n",
|
|
81
|
+
"// new code\n",
|
|
82
|
+
">>>>>>> REPLACE\n",
|
|
83
|
+
"```\n",
|
|
84
|
+
"```\n\n",
|
|
85
|
+
"You can mix both formats in the same response, choosing per file.",
|
|
86
|
+
" For the whole file format, you MUST include the ENTIRE content of",
|
|
87
|
+
" the updated file. For search/replace, only include the changed",
|
|
88
|
+
" portions.\n\n",
|
|
89
|
+
'NEVER leave out sections as in "... rest of the code remain the same',
|
|
90
|
+
' ...".\n\n',
|
|
91
|
+
"Delete all unused files, but we need to keep `README.md`. ",
|
|
92
|
+
"Files can be deleted by setting their content to empty, for example:\n\n",
|
|
93
|
+
"```\n",
|
|
94
|
+
"**fileToDelete.js**\n",
|
|
95
|
+
"```\n",
|
|
96
|
+
"```\n",
|
|
97
|
+
"```\n",
|
|
58
98
|
].join(""),
|
|
59
99
|
};
|
|
60
100
|
/**
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
import { VizFiles } from "@vizhub/viz-types";
|
|
2
|
-
export type EditFormat = "whole" | "diff" | "diff-fenced" | "udiff";
|
|
2
|
+
export type EditFormat = "whole" | "diff" | "diff-fenced" | "udiff" | "hybrid";
|
|
3
|
+
/**
|
|
4
|
+
* A parsed edit that can be applied independently to a file.
|
|
5
|
+
* `diff`/`diff-fenced` edits are search/replace pairs; `udiff` edits are
|
|
6
|
+
* original/updated blocks.
|
|
7
|
+
*/
|
|
8
|
+
export interface DiffEdit {
|
|
9
|
+
kind: "diff";
|
|
10
|
+
fileName: string;
|
|
11
|
+
search: string;
|
|
12
|
+
replace: string;
|
|
13
|
+
}
|
|
14
|
+
export interface UdiffEdit {
|
|
15
|
+
kind: "udiff";
|
|
16
|
+
fileName: string;
|
|
17
|
+
original: string;
|
|
18
|
+
updated: string;
|
|
19
|
+
}
|
|
20
|
+
export type ParsedEdit = DiffEdit | UdiffEdit;
|
|
21
|
+
/**
|
|
22
|
+
* Reason an edit was skipped. Warnings strictly mean "something went wrong"
|
|
23
|
+
* or "this edit could not be applied" — successful normalized matches are not
|
|
24
|
+
* reported here.
|
|
25
|
+
*/
|
|
26
|
+
export type ApplyWarningCode = "FILE_NOT_FOUND" | "SEARCH_NOT_FOUND" | "HUNK_NOT_FOUND" | "AMBIGUOUS_SEARCH" | "AMBIGUOUS_FILE" | "EMPTY_SEARCH" | "NO_EDITS_PARSED";
|
|
27
|
+
export interface ApplyWarning {
|
|
28
|
+
code: ApplyWarningCode;
|
|
29
|
+
fileName?: string;
|
|
30
|
+
message: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Result of best-effort edit application. `files` always contains the changes
|
|
34
|
+
* that could be applied safely; `warnings` describes everything skipped.
|
|
35
|
+
*/
|
|
36
|
+
export interface ApplyResult {
|
|
37
|
+
files: VizFiles;
|
|
38
|
+
warnings: ApplyWarning[];
|
|
39
|
+
}
|
|
3
40
|
export type LlmFunction = (prompt: string) => Promise<{
|
|
4
41
|
content: string;
|
|
5
42
|
generationId?: string;
|
|
@@ -20,4 +57,6 @@ export interface PerformAiEditResult {
|
|
|
20
57
|
outputTokens?: number;
|
|
21
58
|
promptTemplateVersion?: number;
|
|
22
59
|
rawResponse?: string;
|
|
60
|
+
/** Edits that were skipped because they could not be applied safely. */
|
|
61
|
+
warnings?: ApplyWarning[];
|
|
23
62
|
}
|