@ottocode/sdk 0.1.275 → 0.1.276
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/src/tools/builtin/fs/copy-into.ts +18 -2
- package/src/core/src/tools/builtin/fs/edit.ts +18 -2
- package/src/core/src/tools/builtin/fs/multiedit.ts +18 -2
- package/src/core/src/tools/builtin/fs/util.ts +32 -15
- package/src/core/src/tools/builtin/fs/write.ts +15 -1
- package/src/core/src/tools/builtin/patch/apply.ts +177 -0
- package/src/core/src/tools/builtin/patch/constants.ts +6 -0
- package/src/core/src/tools/builtin/patch/parse-enveloped.ts +228 -0
- package/src/core/src/tools/builtin/patch/types.ts +35 -3
- package/src/core/src/tools/builtin/patch.ts +6 -4
- package/src/core/src/tools/builtin/patch.txt +42 -2
- package/src/providers/src/openai-oauth-client.ts +238 -15
package/package.json
CHANGED
|
@@ -2,7 +2,12 @@ import { readFile, writeFile } from 'node:fs/promises';
|
|
|
2
2
|
import { tool, type Tool } from 'ai';
|
|
3
3
|
import { z } from 'zod/v3';
|
|
4
4
|
import DESCRIPTION from './copy-into.txt' with { type: 'text' };
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
buildMutationMetadata,
|
|
7
|
+
buildWriteArtifact,
|
|
8
|
+
isAbsoluteLike,
|
|
9
|
+
resolveSafePath,
|
|
10
|
+
} from './util.ts';
|
|
6
11
|
import {
|
|
7
12
|
convertToLineEnding,
|
|
8
13
|
detectLineEnding,
|
|
@@ -199,6 +204,7 @@ export function buildCopyIntoTool(projectRoot: string): {
|
|
|
199
204
|
inputSchema: copyIntoSchema,
|
|
200
205
|
async execute(input: CopyIntoInput): Promise<
|
|
201
206
|
ToolResponse<{
|
|
207
|
+
operation: 'copy_into';
|
|
202
208
|
sourcePath: string;
|
|
203
209
|
targetPath: string;
|
|
204
210
|
sourceRange: string;
|
|
@@ -206,6 +212,10 @@ export function buildCopyIntoTool(projectRoot: string): {
|
|
|
206
212
|
mode: string;
|
|
207
213
|
linesCopied: number;
|
|
208
214
|
bytes: number;
|
|
215
|
+
bytesWritten: number;
|
|
216
|
+
changed: boolean;
|
|
217
|
+
sha256: string;
|
|
218
|
+
summary: { files: number; additions: number; deletions: number };
|
|
209
219
|
artifact: unknown;
|
|
210
220
|
}>
|
|
211
221
|
> {
|
|
@@ -271,6 +281,7 @@ export function buildCopyIntoTool(projectRoot: string): {
|
|
|
271
281
|
|
|
272
282
|
await writeFile(targetAbs, nextContent, 'utf-8');
|
|
273
283
|
await rememberFileWrite(projectRoot, targetAbs);
|
|
284
|
+
const metadata = buildMutationMetadata(targetContent, nextContent);
|
|
274
285
|
const artifact = await buildWriteArtifact(
|
|
275
286
|
input.targetPath,
|
|
276
287
|
true,
|
|
@@ -279,13 +290,18 @@ export function buildCopyIntoTool(projectRoot: string): {
|
|
|
279
290
|
);
|
|
280
291
|
return {
|
|
281
292
|
ok: true,
|
|
293
|
+
operation: 'copy_into',
|
|
282
294
|
sourcePath: input.sourcePath,
|
|
283
295
|
targetPath: input.targetPath,
|
|
284
296
|
sourceRange: `${input.startLine}-${sourceRange.endLine}`,
|
|
285
297
|
targetRange: applied.targetRange,
|
|
286
298
|
mode: input.mode ?? 'insert_before',
|
|
287
299
|
linesCopied: sourceRange.copied.length,
|
|
288
|
-
bytes:
|
|
300
|
+
bytes: metadata.bytesWritten,
|
|
301
|
+
bytesWritten: metadata.bytesWritten,
|
|
302
|
+
changed: metadata.changed,
|
|
303
|
+
sha256: metadata.sha256,
|
|
304
|
+
summary: metadata.summary,
|
|
289
305
|
artifact,
|
|
290
306
|
};
|
|
291
307
|
} catch (error: unknown) {
|
|
@@ -2,7 +2,12 @@ import { readFile, writeFile } from 'node:fs/promises';
|
|
|
2
2
|
import { tool, type Tool } from 'ai';
|
|
3
3
|
import { z } from 'zod/v3';
|
|
4
4
|
import DESCRIPTION from './edit.txt' with { type: 'text' };
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
buildMutationMetadata,
|
|
7
|
+
buildWriteArtifact,
|
|
8
|
+
isAbsoluteLike,
|
|
9
|
+
resolveSafePath,
|
|
10
|
+
} from './util.ts';
|
|
6
11
|
import { applyStringEdit } from './edit-shared.ts';
|
|
7
12
|
import { assertFreshRead, rememberFileWrite } from './read-tracker.ts';
|
|
8
13
|
import { createToolError, type ToolResponse } from '../../error.ts';
|
|
@@ -42,8 +47,13 @@ export function buildEditTool(projectRoot: string): {
|
|
|
42
47
|
}): Promise<
|
|
43
48
|
ToolResponse<{
|
|
44
49
|
path: string;
|
|
50
|
+
operation: 'edit';
|
|
45
51
|
occurrences: number;
|
|
46
52
|
bytes: number;
|
|
53
|
+
bytesWritten: number;
|
|
54
|
+
changed: boolean;
|
|
55
|
+
sha256: string;
|
|
56
|
+
summary: { files: number; additions: number; deletions: number };
|
|
47
57
|
artifact: unknown;
|
|
48
58
|
}>
|
|
49
59
|
> {
|
|
@@ -89,6 +99,7 @@ export function buildEditTool(projectRoot: string): {
|
|
|
89
99
|
|
|
90
100
|
await writeFile(abs, updated.content, 'utf-8');
|
|
91
101
|
await rememberFileWrite(projectRoot, abs);
|
|
102
|
+
const metadata = buildMutationMetadata(original, updated.content);
|
|
92
103
|
const artifact = await buildWriteArtifact(
|
|
93
104
|
path,
|
|
94
105
|
true,
|
|
@@ -98,8 +109,13 @@ export function buildEditTool(projectRoot: string): {
|
|
|
98
109
|
return {
|
|
99
110
|
ok: true,
|
|
100
111
|
path,
|
|
112
|
+
operation: 'edit',
|
|
101
113
|
occurrences: updated.occurrences,
|
|
102
|
-
bytes:
|
|
114
|
+
bytes: metadata.bytesWritten,
|
|
115
|
+
bytesWritten: metadata.bytesWritten,
|
|
116
|
+
changed: metadata.changed,
|
|
117
|
+
sha256: metadata.sha256,
|
|
118
|
+
summary: metadata.summary,
|
|
103
119
|
artifact,
|
|
104
120
|
};
|
|
105
121
|
} catch (error: unknown) {
|
|
@@ -2,7 +2,12 @@ import { readFile, writeFile } from 'node:fs/promises';
|
|
|
2
2
|
import { tool, type Tool } from 'ai';
|
|
3
3
|
import { z } from 'zod/v3';
|
|
4
4
|
import DESCRIPTION from './multiedit.txt' with { type: 'text' };
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
buildMutationMetadata,
|
|
7
|
+
buildWriteArtifact,
|
|
8
|
+
isAbsoluteLike,
|
|
9
|
+
resolveSafePath,
|
|
10
|
+
} from './util.ts';
|
|
6
11
|
import { applyStringEdit } from './edit-shared.ts';
|
|
7
12
|
import { assertFreshRead, rememberFileWrite } from './read-tracker.ts';
|
|
8
13
|
import { createToolError, type ToolResponse } from '../../error.ts';
|
|
@@ -39,8 +44,13 @@ export function buildMultiEditTool(projectRoot: string): {
|
|
|
39
44
|
async execute({ path, edits }: z.infer<typeof multiEditSchema>): Promise<
|
|
40
45
|
ToolResponse<{
|
|
41
46
|
path: string;
|
|
47
|
+
operation: 'multiedit';
|
|
42
48
|
editsApplied: number;
|
|
43
49
|
bytes: number;
|
|
50
|
+
bytesWritten: number;
|
|
51
|
+
changed: boolean;
|
|
52
|
+
sha256: string;
|
|
53
|
+
summary: { files: number; additions: number; deletions: number };
|
|
44
54
|
artifact: unknown;
|
|
45
55
|
}>
|
|
46
56
|
> {
|
|
@@ -97,6 +107,7 @@ export function buildMultiEditTool(projectRoot: string): {
|
|
|
97
107
|
|
|
98
108
|
await writeFile(abs, nextContent, 'utf-8');
|
|
99
109
|
await rememberFileWrite(projectRoot, abs);
|
|
110
|
+
const metadata = buildMutationMetadata(original, nextContent);
|
|
100
111
|
const artifact = await buildWriteArtifact(
|
|
101
112
|
path,
|
|
102
113
|
true,
|
|
@@ -106,8 +117,13 @@ export function buildMultiEditTool(projectRoot: string): {
|
|
|
106
117
|
return {
|
|
107
118
|
ok: true,
|
|
108
119
|
path,
|
|
120
|
+
operation: 'multiedit',
|
|
109
121
|
editsApplied: edits.length,
|
|
110
|
-
bytes:
|
|
122
|
+
bytes: metadata.bytesWritten,
|
|
123
|
+
bytesWritten: metadata.bytesWritten,
|
|
124
|
+
changed: metadata.changed,
|
|
125
|
+
sha256: metadata.sha256,
|
|
126
|
+
summary: metadata.summary,
|
|
111
127
|
artifact,
|
|
112
128
|
};
|
|
113
129
|
} catch (error: unknown) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
2
|
import { resolve as resolvePath } from 'node:path';
|
|
3
|
+
import { createTwoFilesPatch, diffLines } from 'diff';
|
|
3
4
|
|
|
4
5
|
function normalizeForComparison(value: string) {
|
|
5
6
|
const withForwardSlashes = value.replace(/\\/g, '/');
|
|
@@ -36,6 +37,17 @@ export function isAbsoluteLike(p: string): boolean {
|
|
|
36
37
|
return p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p);
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
export function buildMutationMetadata(oldText: string, newText: string) {
|
|
41
|
+
const bytesWritten = Buffer.byteLength(newText, 'utf-8');
|
|
42
|
+
const { additions, deletions } = summarizeTextChanges(oldText, newText);
|
|
43
|
+
return {
|
|
44
|
+
bytesWritten,
|
|
45
|
+
changed: oldText !== newText,
|
|
46
|
+
sha256: createHash('sha256').update(newText).digest('hex'),
|
|
47
|
+
summary: { files: 1, additions, deletions },
|
|
48
|
+
} as const;
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
export async function buildWriteArtifact(
|
|
40
52
|
relPath: string,
|
|
41
53
|
existed: boolean,
|
|
@@ -67,7 +79,7 @@ export async function buildWriteArtifact(
|
|
|
67
79
|
lines.push('*** End Patch');
|
|
68
80
|
patch = lines.join('\n');
|
|
69
81
|
}
|
|
70
|
-
const { additions, deletions } =
|
|
82
|
+
const { additions, deletions } = summarizeTextChanges(oldText, newText);
|
|
71
83
|
return {
|
|
72
84
|
kind: 'file_diff',
|
|
73
85
|
patch,
|
|
@@ -75,21 +87,26 @@ export async function buildWriteArtifact(
|
|
|
75
87
|
} as const;
|
|
76
88
|
}
|
|
77
89
|
|
|
78
|
-
|
|
90
|
+
function countDiffLines(value: string): number {
|
|
91
|
+
if (value.length === 0) return 0;
|
|
92
|
+
const lines = value.split('\n');
|
|
93
|
+
if (value.endsWith('\n')) lines.pop();
|
|
94
|
+
return lines.length;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function summarizeTextChanges(
|
|
98
|
+
oldText: string,
|
|
99
|
+
newText: string,
|
|
100
|
+
): {
|
|
79
101
|
additions: number;
|
|
80
102
|
deletions: number;
|
|
81
103
|
} {
|
|
82
|
-
let
|
|
83
|
-
let
|
|
84
|
-
for (const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
line.startsWith('diff ')
|
|
89
|
-
)
|
|
90
|
-
continue;
|
|
91
|
-
if (line.startsWith('+')) adds += 1;
|
|
92
|
-
else if (line.startsWith('-')) dels += 1;
|
|
104
|
+
let additions = 0;
|
|
105
|
+
let deletions = 0;
|
|
106
|
+
for (const part of diffLines(String(oldText ?? ''), String(newText ?? ''))) {
|
|
107
|
+
const lineCount = countDiffLines(part.value);
|
|
108
|
+
if (part.added) additions += lineCount;
|
|
109
|
+
else if (part.removed) deletions += lineCount;
|
|
93
110
|
}
|
|
94
|
-
return { additions
|
|
111
|
+
return { additions, deletions };
|
|
95
112
|
}
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod/v3';
|
|
|
3
3
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
4
4
|
import { dirname } from 'node:path';
|
|
5
5
|
import {
|
|
6
|
+
buildMutationMetadata,
|
|
6
7
|
buildWriteArtifact,
|
|
7
8
|
resolveSafePath,
|
|
8
9
|
expandTilde,
|
|
@@ -38,7 +39,13 @@ export function buildWriteTool(projectRoot: string): {
|
|
|
38
39
|
}): Promise<
|
|
39
40
|
ToolResponse<{
|
|
40
41
|
path: string;
|
|
42
|
+
operation: 'write';
|
|
41
43
|
bytes: number;
|
|
44
|
+
bytesWritten: number;
|
|
45
|
+
created: boolean;
|
|
46
|
+
changed: boolean;
|
|
47
|
+
sha256: string;
|
|
48
|
+
summary: { files: number; additions: number; deletions: number };
|
|
42
49
|
artifact: unknown;
|
|
43
50
|
}>
|
|
44
51
|
> {
|
|
@@ -81,6 +88,7 @@ export function buildWriteTool(projectRoot: string): {
|
|
|
81
88
|
} catch {}
|
|
82
89
|
await writeFile(abs, content);
|
|
83
90
|
await rememberFileWrite(projectRoot, abs);
|
|
91
|
+
const metadata = buildMutationMetadata(oldText, content);
|
|
84
92
|
const artifact = await buildWriteArtifact(
|
|
85
93
|
req,
|
|
86
94
|
existed,
|
|
@@ -90,7 +98,13 @@ export function buildWriteTool(projectRoot: string): {
|
|
|
90
98
|
return {
|
|
91
99
|
ok: true,
|
|
92
100
|
path: req,
|
|
93
|
-
|
|
101
|
+
operation: 'write',
|
|
102
|
+
bytes: metadata.bytesWritten,
|
|
103
|
+
bytesWritten: metadata.bytesWritten,
|
|
104
|
+
created: !existed,
|
|
105
|
+
changed: metadata.changed,
|
|
106
|
+
sha256: metadata.sha256,
|
|
107
|
+
summary: metadata.summary,
|
|
94
108
|
artifact,
|
|
95
109
|
};
|
|
96
110
|
} catch (error: unknown) {
|
|
@@ -8,6 +8,9 @@ import type {
|
|
|
8
8
|
PatchAddOperation,
|
|
9
9
|
PatchApplicationResult,
|
|
10
10
|
PatchDeleteOperation,
|
|
11
|
+
PatchLineDeleteOperation,
|
|
12
|
+
PatchLineInsertOperation,
|
|
13
|
+
PatchLineReplaceOperation,
|
|
11
14
|
PatchOperation,
|
|
12
15
|
PatchUpdateOperation,
|
|
13
16
|
RejectedPatch,
|
|
@@ -161,6 +164,174 @@ async function applyUpdateOperation(
|
|
|
161
164
|
return makeAppliedRecord('update', operation.filePath, appliedHunks);
|
|
162
165
|
}
|
|
163
166
|
|
|
167
|
+
function resolveLineRange(
|
|
168
|
+
filePath: string,
|
|
169
|
+
lineCount: number,
|
|
170
|
+
startLine: number,
|
|
171
|
+
endLine: number | 'end',
|
|
172
|
+
) {
|
|
173
|
+
const resolvedEndLine = endLine === 'end' ? lineCount : endLine;
|
|
174
|
+
if (startLine > lineCount) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Line range ${startLine}-${resolvedEndLine} is outside ${filePath} (${lineCount} lines).`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (resolvedEndLine > lineCount) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`Line range ${startLine}-${resolvedEndLine} is outside ${filePath} (${lineCount} lines).`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (resolvedEndLine < startLine) {
|
|
185
|
+
throw new Error('Line range end must be greater than or equal to start.');
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
startIndex: startLine - 1,
|
|
189
|
+
endIndexExclusive: resolvedEndLine,
|
|
190
|
+
resolvedEndLine,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function readUpdateTarget(projectRoot: string, filePath: string) {
|
|
195
|
+
const target = resolveProjectPath(projectRoot, filePath);
|
|
196
|
+
let original: string;
|
|
197
|
+
try {
|
|
198
|
+
original = await readFile(target, 'utf-8');
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
201
|
+
throw new Error(`File not found: ${filePath}`);
|
|
202
|
+
}
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
return { target, ...splitLines(original) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function applyLineDeleteOperation(
|
|
209
|
+
projectRoot: string,
|
|
210
|
+
operation: PatchLineDeleteOperation,
|
|
211
|
+
): Promise<AppliedPatchOperation> {
|
|
212
|
+
const { target, lines, newline } = await readUpdateTarget(
|
|
213
|
+
projectRoot,
|
|
214
|
+
operation.filePath,
|
|
215
|
+
);
|
|
216
|
+
const { startIndex, endIndexExclusive, resolvedEndLine } = resolveLineRange(
|
|
217
|
+
operation.filePath,
|
|
218
|
+
lines.length,
|
|
219
|
+
operation.startLine,
|
|
220
|
+
operation.endLine,
|
|
221
|
+
);
|
|
222
|
+
const removed = lines.slice(startIndex, endIndexExclusive);
|
|
223
|
+
const workingLines = [...lines];
|
|
224
|
+
workingLines.splice(startIndex, removed.length);
|
|
225
|
+
ensureTrailingNewline(workingLines);
|
|
226
|
+
await writeFile(target, joinLines(workingLines, newline), 'utf-8');
|
|
227
|
+
|
|
228
|
+
const appliedHunk: AppliedPatchHunk = {
|
|
229
|
+
header: {
|
|
230
|
+
oldStart: operation.startLine,
|
|
231
|
+
oldLines: removed.length,
|
|
232
|
+
newStart: operation.startLine,
|
|
233
|
+
newLines: 0,
|
|
234
|
+
context: `lines ${operation.startLine}-${resolvedEndLine}`,
|
|
235
|
+
},
|
|
236
|
+
lines: removed.map((line) => ({ kind: 'remove', content: line })),
|
|
237
|
+
oldStart: operation.startLine,
|
|
238
|
+
oldLines: removed.length,
|
|
239
|
+
newStart: operation.startLine,
|
|
240
|
+
newLines: 0,
|
|
241
|
+
additions: 0,
|
|
242
|
+
deletions: removed.length,
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
return makeAppliedRecord('update', operation.filePath, [appliedHunk]);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function applyLineReplaceOperation(
|
|
249
|
+
projectRoot: string,
|
|
250
|
+
operation: PatchLineReplaceOperation,
|
|
251
|
+
): Promise<AppliedPatchOperation> {
|
|
252
|
+
const { target, lines, newline } = await readUpdateTarget(
|
|
253
|
+
projectRoot,
|
|
254
|
+
operation.filePath,
|
|
255
|
+
);
|
|
256
|
+
const { startIndex, endIndexExclusive, resolvedEndLine } = resolveLineRange(
|
|
257
|
+
operation.filePath,
|
|
258
|
+
lines.length,
|
|
259
|
+
operation.startLine,
|
|
260
|
+
operation.endLine,
|
|
261
|
+
);
|
|
262
|
+
const removed = lines.slice(startIndex, endIndexExclusive);
|
|
263
|
+
const added = [...operation.lines];
|
|
264
|
+
const workingLines = [...lines];
|
|
265
|
+
workingLines.splice(startIndex, removed.length, ...added);
|
|
266
|
+
ensureTrailingNewline(workingLines);
|
|
267
|
+
await writeFile(target, joinLines(workingLines, newline), 'utf-8');
|
|
268
|
+
|
|
269
|
+
const appliedHunk: AppliedPatchHunk = {
|
|
270
|
+
header: {
|
|
271
|
+
oldStart: operation.startLine,
|
|
272
|
+
oldLines: removed.length,
|
|
273
|
+
newStart: operation.startLine,
|
|
274
|
+
newLines: added.length,
|
|
275
|
+
context: `lines ${operation.startLine}-${resolvedEndLine}`,
|
|
276
|
+
},
|
|
277
|
+
lines: [
|
|
278
|
+
...removed.map((line) => ({ kind: 'remove' as const, content: line })),
|
|
279
|
+
...added.map((line) => ({ kind: 'add' as const, content: line })),
|
|
280
|
+
],
|
|
281
|
+
oldStart: operation.startLine,
|
|
282
|
+
oldLines: removed.length,
|
|
283
|
+
newStart: operation.startLine,
|
|
284
|
+
newLines: added.length,
|
|
285
|
+
additions: added.length,
|
|
286
|
+
deletions: removed.length,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
return makeAppliedRecord('update', operation.filePath, [appliedHunk]);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function applyLineInsertOperation(
|
|
293
|
+
projectRoot: string,
|
|
294
|
+
operation: PatchLineInsertOperation,
|
|
295
|
+
): Promise<AppliedPatchOperation> {
|
|
296
|
+
const { target, lines, newline } = await readUpdateTarget(
|
|
297
|
+
projectRoot,
|
|
298
|
+
operation.filePath,
|
|
299
|
+
);
|
|
300
|
+
const insertIndex =
|
|
301
|
+
operation.position === 'before' ? operation.line - 1 : operation.line;
|
|
302
|
+
if (insertIndex < 0 || insertIndex > lines.length) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`Insert ${operation.position} line ${operation.line} is outside ${operation.filePath} (${lines.length} lines).`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
const added = [...operation.lines];
|
|
308
|
+
const workingLines = [...lines];
|
|
309
|
+
workingLines.splice(insertIndex, 0, ...added);
|
|
310
|
+
ensureTrailingNewline(workingLines);
|
|
311
|
+
await writeFile(target, joinLines(workingLines, newline), 'utf-8');
|
|
312
|
+
|
|
313
|
+
const oldStart = insertIndex;
|
|
314
|
+
const newStart = insertIndex + 1;
|
|
315
|
+
const appliedHunk: AppliedPatchHunk = {
|
|
316
|
+
header: {
|
|
317
|
+
oldStart,
|
|
318
|
+
oldLines: 0,
|
|
319
|
+
newStart,
|
|
320
|
+
newLines: added.length,
|
|
321
|
+
context: `${operation.position} line ${operation.line}`,
|
|
322
|
+
},
|
|
323
|
+
lines: added.map((line) => ({ kind: 'add', content: line })),
|
|
324
|
+
oldStart,
|
|
325
|
+
oldLines: 0,
|
|
326
|
+
newStart,
|
|
327
|
+
newLines: added.length,
|
|
328
|
+
additions: added.length,
|
|
329
|
+
deletions: 0,
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
return makeAppliedRecord('update', operation.filePath, [appliedHunk]);
|
|
333
|
+
}
|
|
334
|
+
|
|
164
335
|
export async function applyPatchOperations(
|
|
165
336
|
projectRoot: string,
|
|
166
337
|
operations: PatchOperation[],
|
|
@@ -175,6 +346,12 @@ export async function applyPatchOperations(
|
|
|
175
346
|
applied.push(await applyAddOperation(projectRoot, operation));
|
|
176
347
|
} else if (operation.kind === 'delete') {
|
|
177
348
|
applied.push(await applyDeleteOperation(projectRoot, operation));
|
|
349
|
+
} else if (operation.kind === 'line-delete') {
|
|
350
|
+
applied.push(await applyLineDeleteOperation(projectRoot, operation));
|
|
351
|
+
} else if (operation.kind === 'line-replace') {
|
|
352
|
+
applied.push(await applyLineReplaceOperation(projectRoot, operation));
|
|
353
|
+
} else if (operation.kind === 'line-insert') {
|
|
354
|
+
applied.push(await applyLineInsertOperation(projectRoot, operation));
|
|
178
355
|
} else {
|
|
179
356
|
applied.push(
|
|
180
357
|
await applyUpdateOperation(
|
|
@@ -4,5 +4,11 @@ export const PATCH_ADD_PREFIX = '*** Add File:';
|
|
|
4
4
|
export const PATCH_UPDATE_PREFIX = '*** Update File:';
|
|
5
5
|
export const PATCH_DELETE_PREFIX = '*** Delete File:';
|
|
6
6
|
export const PATCH_REPLACE_PREFIX = '*** Replace in:';
|
|
7
|
+
export const PATCH_DELETE_LINES_PREFIX = '*** Delete Lines in:';
|
|
8
|
+
export const PATCH_REPLACE_LINES_PREFIX = '*** Replace Lines in:';
|
|
9
|
+
export const PATCH_INSERT_BEFORE_PREFIX = '*** Insert Before in:';
|
|
10
|
+
export const PATCH_INSERT_AFTER_PREFIX = '*** Insert After in:';
|
|
7
11
|
export const PATCH_FIND_MARKER = '*** Find:';
|
|
8
12
|
export const PATCH_WITH_MARKER = '*** With:';
|
|
13
|
+
export const PATCH_LINES_MARKER = '*** Lines:';
|
|
14
|
+
export const PATCH_LINE_MARKER = '*** Line:';
|
|
@@ -2,8 +2,14 @@ import {
|
|
|
2
2
|
PATCH_ADD_PREFIX,
|
|
3
3
|
PATCH_BEGIN_MARKER,
|
|
4
4
|
PATCH_DELETE_PREFIX,
|
|
5
|
+
PATCH_DELETE_LINES_PREFIX,
|
|
5
6
|
PATCH_END_MARKER,
|
|
6
7
|
PATCH_FIND_MARKER,
|
|
8
|
+
PATCH_INSERT_AFTER_PREFIX,
|
|
9
|
+
PATCH_INSERT_BEFORE_PREFIX,
|
|
10
|
+
PATCH_LINE_MARKER,
|
|
11
|
+
PATCH_LINES_MARKER,
|
|
12
|
+
PATCH_REPLACE_LINES_PREFIX,
|
|
7
13
|
PATCH_REPLACE_PREFIX,
|
|
8
14
|
PATCH_UPDATE_PREFIX,
|
|
9
15
|
PATCH_WITH_MARKER,
|
|
@@ -20,6 +26,9 @@ import type {
|
|
|
20
26
|
PatchDeleteOperation,
|
|
21
27
|
PatchHunk,
|
|
22
28
|
PatchHunkLine,
|
|
29
|
+
PatchLineDeleteOperation,
|
|
30
|
+
PatchLineInsertOperation,
|
|
31
|
+
PatchLineReplaceOperation,
|
|
23
32
|
PatchOperation,
|
|
24
33
|
PatchUpdateOperation,
|
|
25
34
|
} from './types.ts';
|
|
@@ -35,6 +44,62 @@ function parseDirectivePath(line: string, prefix: string): string {
|
|
|
35
44
|
return filePath;
|
|
36
45
|
}
|
|
37
46
|
|
|
47
|
+
function parsePositiveLineNumber(value: string, label: string): number {
|
|
48
|
+
const trimmed = value.trim();
|
|
49
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
50
|
+
throw new Error(`${label} must be a positive integer.`);
|
|
51
|
+
}
|
|
52
|
+
const line = Number.parseInt(trimmed, 10);
|
|
53
|
+
if (line < 1) {
|
|
54
|
+
throw new Error(`${label} must be a positive integer.`);
|
|
55
|
+
}
|
|
56
|
+
return line;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseLineRange(value: string): {
|
|
60
|
+
startLine: number;
|
|
61
|
+
endLine: number | 'end';
|
|
62
|
+
} {
|
|
63
|
+
const trimmed = value.trim();
|
|
64
|
+
const match = /^(\d+)(?:\s*-\s*(\d+|end|eof|\$))?$/i.exec(trimmed);
|
|
65
|
+
if (!match) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
'Line ranges must use "start" or "start-end" with 1-indexed positive integers.',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const startLine = parsePositiveLineNumber(match[1], 'Line range start');
|
|
72
|
+
const endLineToken = match[2];
|
|
73
|
+
if (!endLineToken) return { startLine, endLine: startLine };
|
|
74
|
+
const endLine = /^(end|eof|\$)$/i.test(endLineToken)
|
|
75
|
+
? 'end'
|
|
76
|
+
: parsePositiveLineNumber(endLineToken, 'Line range end');
|
|
77
|
+
if (typeof endLine === 'number' && endLine < startLine) {
|
|
78
|
+
throw new Error('Line range end must be greater than or equal to start.');
|
|
79
|
+
}
|
|
80
|
+
return { startLine, endLine };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type LineDeleteBuilder = Partial<PatchLineDeleteOperation> & {
|
|
84
|
+
kind: 'line-delete';
|
|
85
|
+
filePath: string;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
type LineReplaceBuilder = Partial<PatchLineReplaceOperation> & {
|
|
89
|
+
kind: 'line-replace';
|
|
90
|
+
filePath: string;
|
|
91
|
+
lines: string[];
|
|
92
|
+
phase: 'range' | 'with';
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
type LineInsertBuilder = Partial<PatchLineInsertOperation> & {
|
|
96
|
+
kind: 'line-insert';
|
|
97
|
+
filePath: string;
|
|
98
|
+
position: 'before' | 'after';
|
|
99
|
+
lines: string[];
|
|
100
|
+
phase: 'line' | 'with';
|
|
101
|
+
};
|
|
102
|
+
|
|
38
103
|
export function parseEnvelopedPatch(patch: string): PatchOperation[] {
|
|
39
104
|
const normalized = patch.replace(/\r\n/g, '\n');
|
|
40
105
|
const lines = normalized.split('\n');
|
|
@@ -47,6 +112,9 @@ export function parseEnvelopedPatch(patch: string): PatchOperation[] {
|
|
|
47
112
|
kind: 'update';
|
|
48
113
|
currentHunk: PatchHunk | null;
|
|
49
114
|
})
|
|
115
|
+
| LineDeleteBuilder
|
|
116
|
+
| LineReplaceBuilder
|
|
117
|
+
| LineInsertBuilder
|
|
50
118
|
| ReplaceBuilder;
|
|
51
119
|
|
|
52
120
|
let builder: Builder | null = null;
|
|
@@ -80,6 +148,54 @@ export function parseEnvelopedPatch(patch: string): PatchOperation[] {
|
|
|
80
148
|
filePath: builder.filePath,
|
|
81
149
|
lines: [...builder.lines],
|
|
82
150
|
});
|
|
151
|
+
} else if (builder.kind === 'line-delete') {
|
|
152
|
+
if (!builder.startLine || !builder.endLine) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`Delete Lines in ${builder.filePath}: missing required *** Lines: directive.`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
operations.push({
|
|
158
|
+
kind: 'line-delete',
|
|
159
|
+
filePath: builder.filePath,
|
|
160
|
+
startLine: builder.startLine,
|
|
161
|
+
endLine: builder.endLine,
|
|
162
|
+
});
|
|
163
|
+
} else if (builder.kind === 'line-replace') {
|
|
164
|
+
if (!builder.startLine || !builder.endLine) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Replace Lines in ${builder.filePath}: missing required *** Lines: directive.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (builder.phase !== 'with') {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`Replace Lines in ${builder.filePath}: missing required *** With: directive.`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
operations.push({
|
|
175
|
+
kind: 'line-replace',
|
|
176
|
+
filePath: builder.filePath,
|
|
177
|
+
startLine: builder.startLine,
|
|
178
|
+
endLine: builder.endLine,
|
|
179
|
+
lines: [...builder.lines],
|
|
180
|
+
});
|
|
181
|
+
} else if (builder.kind === 'line-insert') {
|
|
182
|
+
if (!builder.line) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`Insert ${builder.position} in ${builder.filePath}: missing required *** Line: directive.`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (builder.phase !== 'with') {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`Insert ${builder.position} in ${builder.filePath}: missing required *** With: directive.`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
operations.push({
|
|
193
|
+
kind: 'line-insert',
|
|
194
|
+
filePath: builder.filePath,
|
|
195
|
+
position: builder.position,
|
|
196
|
+
line: builder.line,
|
|
197
|
+
lines: [...builder.lines],
|
|
198
|
+
});
|
|
83
199
|
} else {
|
|
84
200
|
operations.push({ kind: 'delete', filePath: builder.filePath });
|
|
85
201
|
}
|
|
@@ -154,6 +270,50 @@ export function parseEnvelopedPatch(patch: string): PatchOperation[] {
|
|
|
154
270
|
continue;
|
|
155
271
|
}
|
|
156
272
|
|
|
273
|
+
if (line.startsWith(PATCH_DELETE_LINES_PREFIX)) {
|
|
274
|
+
flushBuilder();
|
|
275
|
+
builder = {
|
|
276
|
+
kind: 'line-delete',
|
|
277
|
+
filePath: parseDirectivePath(line, PATCH_DELETE_LINES_PREFIX),
|
|
278
|
+
};
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (line.startsWith(PATCH_REPLACE_LINES_PREFIX)) {
|
|
283
|
+
flushBuilder();
|
|
284
|
+
builder = {
|
|
285
|
+
kind: 'line-replace',
|
|
286
|
+
filePath: parseDirectivePath(line, PATCH_REPLACE_LINES_PREFIX),
|
|
287
|
+
lines: [],
|
|
288
|
+
phase: 'range',
|
|
289
|
+
};
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (line.startsWith(PATCH_INSERT_BEFORE_PREFIX)) {
|
|
294
|
+
flushBuilder();
|
|
295
|
+
builder = {
|
|
296
|
+
kind: 'line-insert',
|
|
297
|
+
filePath: parseDirectivePath(line, PATCH_INSERT_BEFORE_PREFIX),
|
|
298
|
+
position: 'before',
|
|
299
|
+
lines: [],
|
|
300
|
+
phase: 'line',
|
|
301
|
+
};
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (line.startsWith(PATCH_INSERT_AFTER_PREFIX)) {
|
|
306
|
+
flushBuilder();
|
|
307
|
+
builder = {
|
|
308
|
+
kind: 'line-insert',
|
|
309
|
+
filePath: parseDirectivePath(line, PATCH_INSERT_AFTER_PREFIX),
|
|
310
|
+
position: 'after',
|
|
311
|
+
lines: [],
|
|
312
|
+
phase: 'line',
|
|
313
|
+
};
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
157
317
|
if (builder && builder.kind === 'replace') {
|
|
158
318
|
if (line.startsWith(PATCH_FIND_MARKER)) {
|
|
159
319
|
flushReplacePair(builder);
|
|
@@ -188,6 +348,74 @@ export function parseEnvelopedPatch(patch: string): PatchOperation[] {
|
|
|
188
348
|
throw new Error(`Unexpected content in patch: "${line}"`);
|
|
189
349
|
}
|
|
190
350
|
|
|
351
|
+
if (builder.kind === 'line-delete') {
|
|
352
|
+
if (line.startsWith(PATCH_LINES_MARKER)) {
|
|
353
|
+
const range = parseLineRange(line.slice(PATCH_LINES_MARKER.length));
|
|
354
|
+
builder.startLine = range.startLine;
|
|
355
|
+
builder.endLine = range.endLine;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (line.trim() !== '') {
|
|
359
|
+
throw new Error(
|
|
360
|
+
`Delete Lines in ${builder.filePath}: expected *** Lines: directive, got "${line}"`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (builder.kind === 'line-replace') {
|
|
367
|
+
if (builder.phase === 'with') {
|
|
368
|
+
builder.lines.push(line);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (line.startsWith(PATCH_LINES_MARKER)) {
|
|
372
|
+
const range = parseLineRange(line.slice(PATCH_LINES_MARKER.length));
|
|
373
|
+
builder.startLine = range.startLine;
|
|
374
|
+
builder.endLine = range.endLine;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (line.startsWith(PATCH_WITH_MARKER)) {
|
|
378
|
+
if (!builder.startLine || !builder.endLine) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`Replace Lines in ${builder.filePath}: *** With: must follow *** Lines:.`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
builder.phase = 'with';
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (line.trim() === '') continue;
|
|
387
|
+
throw new Error(
|
|
388
|
+
`Replace Lines in ${builder.filePath}: expected *** Lines: or *** With: directive, got "${line}"`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (builder.kind === 'line-insert') {
|
|
393
|
+
if (builder.phase === 'with') {
|
|
394
|
+
builder.lines.push(line);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (line.startsWith(PATCH_LINE_MARKER)) {
|
|
398
|
+
builder.line = parsePositiveLineNumber(
|
|
399
|
+
line.slice(PATCH_LINE_MARKER.length),
|
|
400
|
+
'Insert line',
|
|
401
|
+
);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (line.startsWith(PATCH_WITH_MARKER)) {
|
|
405
|
+
if (!builder.line) {
|
|
406
|
+
throw new Error(
|
|
407
|
+
`Insert ${builder.position} in ${builder.filePath}: *** With: must follow *** Line:.`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
builder.phase = 'with';
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (line.trim() === '') continue;
|
|
414
|
+
throw new Error(
|
|
415
|
+
`Insert ${builder.position} in ${builder.filePath}: expected *** Line: or *** With: directive, got "${line}"`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
191
419
|
if (builder.kind === 'add') {
|
|
192
420
|
builder.lines.push(line.startsWith('+') ? line.slice(1) : line);
|
|
193
421
|
continue;
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
export type
|
|
1
|
+
export type AppliedPatchOperationKind = 'add' | 'update' | 'delete';
|
|
2
|
+
|
|
3
|
+
export type PatchOperationKind =
|
|
4
|
+
| AppliedPatchOperationKind
|
|
5
|
+
| 'line-delete'
|
|
6
|
+
| 'line-replace'
|
|
7
|
+
| 'line-insert';
|
|
2
8
|
|
|
3
9
|
export interface PatchHunkLine {
|
|
4
10
|
kind: 'context' | 'add' | 'remove';
|
|
@@ -35,10 +41,36 @@ export interface PatchUpdateOperation {
|
|
|
35
41
|
hunks: PatchHunk[];
|
|
36
42
|
}
|
|
37
43
|
|
|
44
|
+
export interface PatchLineDeleteOperation {
|
|
45
|
+
kind: 'line-delete';
|
|
46
|
+
filePath: string;
|
|
47
|
+
startLine: number;
|
|
48
|
+
endLine: number | 'end';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface PatchLineReplaceOperation {
|
|
52
|
+
kind: 'line-replace';
|
|
53
|
+
filePath: string;
|
|
54
|
+
startLine: number;
|
|
55
|
+
endLine: number | 'end';
|
|
56
|
+
lines: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface PatchLineInsertOperation {
|
|
60
|
+
kind: 'line-insert';
|
|
61
|
+
filePath: string;
|
|
62
|
+
position: 'before' | 'after';
|
|
63
|
+
line: number;
|
|
64
|
+
lines: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
38
67
|
export type PatchOperation =
|
|
39
68
|
| PatchAddOperation
|
|
40
69
|
| PatchDeleteOperation
|
|
41
|
-
| PatchUpdateOperation
|
|
70
|
+
| PatchUpdateOperation
|
|
71
|
+
| PatchLineDeleteOperation
|
|
72
|
+
| PatchLineReplaceOperation
|
|
73
|
+
| PatchLineInsertOperation;
|
|
42
74
|
|
|
43
75
|
export interface AppliedPatchHunk {
|
|
44
76
|
header: PatchHunkHeader;
|
|
@@ -52,7 +84,7 @@ export interface AppliedPatchHunk {
|
|
|
52
84
|
}
|
|
53
85
|
|
|
54
86
|
export interface AppliedPatchOperation {
|
|
55
|
-
kind:
|
|
87
|
+
kind: AppliedPatchOperationKind;
|
|
56
88
|
filePath: string;
|
|
57
89
|
stats: {
|
|
58
90
|
additions: number;
|
|
@@ -41,10 +41,6 @@ function serializeRejected(rejected: RejectedPatch[]) {
|
|
|
41
41
|
newStart: hunk.header.newStart,
|
|
42
42
|
newLines: hunk.header.newLines,
|
|
43
43
|
context: hunk.header.context,
|
|
44
|
-
lines: hunk.lines.map((line) => ({
|
|
45
|
-
kind: line.kind,
|
|
46
|
-
content: line.content,
|
|
47
|
-
})),
|
|
48
44
|
}))
|
|
49
45
|
: undefined,
|
|
50
46
|
}));
|
|
@@ -83,7 +79,10 @@ export function buildApplyPatchTool(projectRoot: string): {
|
|
|
83
79
|
fuzzyMatch?: boolean;
|
|
84
80
|
}): Promise<
|
|
85
81
|
ToolResponse<{
|
|
82
|
+
operation: 'apply_patch';
|
|
86
83
|
output: string;
|
|
84
|
+
changed: boolean;
|
|
85
|
+
summary: { files: number; additions: number; deletions: number };
|
|
87
86
|
changes: unknown[];
|
|
88
87
|
artifact: unknown;
|
|
89
88
|
rejected?: unknown[];
|
|
@@ -142,7 +141,10 @@ export function buildApplyPatchTool(projectRoot: string): {
|
|
|
142
141
|
|
|
143
142
|
return {
|
|
144
143
|
ok: true,
|
|
144
|
+
operation: 'apply_patch',
|
|
145
145
|
output: output.join('; '),
|
|
146
|
+
changed: result.operations.length > 0,
|
|
147
|
+
summary: result.summary,
|
|
146
148
|
changes,
|
|
147
149
|
artifact: {
|
|
148
150
|
kind: 'file_diff',
|
|
@@ -20,6 +20,46 @@ You can include multiple `*** Find:` / `*** With:` pairs under one `*** Replace
|
|
|
20
20
|
|
|
21
21
|
---
|
|
22
22
|
|
|
23
|
+
## Line-number mode: Delete / Replace / Insert
|
|
24
|
+
|
|
25
|
+
Use line-number directives for large removals or replacements after reading the
|
|
26
|
+
target file. Lines are 1-indexed and ranges are inclusive. `end`, `eof`, and `$`
|
|
27
|
+
can be used as a range end.
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
*** Begin Patch
|
|
31
|
+
*** Delete Lines in: path/to/file.ts
|
|
32
|
+
*** Lines: 120-184
|
|
33
|
+
*** End Patch
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
*** Begin Patch
|
|
38
|
+
*** Replace Lines in: path/to/file.ts
|
|
39
|
+
*** Lines: 120-184
|
|
40
|
+
*** With:
|
|
41
|
+
new replacement block
|
|
42
|
+
*** End Patch
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
*** Begin Patch
|
|
47
|
+
*** Insert Before in: path/to/file.ts
|
|
48
|
+
*** Line: 120
|
|
49
|
+
*** With:
|
|
50
|
+
new block before line 120
|
|
51
|
+
*** Insert After in: path/to/file.ts
|
|
52
|
+
*** Line: 200
|
|
53
|
+
*** With:
|
|
54
|
+
new block after line 200
|
|
55
|
+
*** End Patch
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Line-number mode is concise but fragile if the file changes after you read it.
|
|
59
|
+
Prefer text/context patches for small edits.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
23
63
|
## Standard mode: Add / Update / Delete
|
|
24
64
|
|
|
25
65
|
```text
|
|
@@ -48,9 +88,9 @@ For multiple edits in the same file, use **one** `*** Update File:` block with m
|
|
|
48
88
|
- File uses tabs → patch uses tabs.
|
|
49
89
|
- File uses 2 spaces → patch uses 2 spaces (not 4, not tabs).
|
|
50
90
|
|
|
51
|
-
**4. Include Sufficient Context.** Minimum 2 context lines before AND after each change (3+ for YAML). A single context line is fragile — it may match multiple locations.
|
|
91
|
+
**4. Include Sufficient Context.** Minimum 2 context lines before AND after each change (3+ for YAML). A single context line is fragile — it may match multiple locations. For line-number mode, verify the exact line numbers from the latest `read` output instead.
|
|
52
92
|
|
|
53
|
-
**5. Markers Required.** Every patch MUST start with `*** Begin Patch` and end with `*** End Patch`. Use `*** Update File:`, `*** Add File:`,
|
|
93
|
+
**5. Markers Required.** Every patch MUST start with `*** Begin Patch` and end with `*** End Patch`. Use `*** Update File:`, `*** Add File:`, `*** Delete File:`, or a line-number directive.
|
|
54
94
|
|
|
55
95
|
**6. One `apply_patch` Call Per File.** For multiple edits in the same file, use multiple `@@` hunks in ONE call. Never make separate `apply_patch` calls for the same file in one turn.
|
|
56
96
|
|
|
@@ -14,12 +14,18 @@ const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses';
|
|
|
14
14
|
const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
|
15
15
|
const TOKEN_REFRESH_MAX_RETRIES = 2;
|
|
16
16
|
const TOKEN_REFRESH_RETRY_DELAY_MS = 1000;
|
|
17
|
+
const CODEX_INSTALLATION_ID = crypto.randomUUID();
|
|
18
|
+
const CODEX_REQUEST_TIMEOUT_MS = 120_000;
|
|
19
|
+
const CODEX_STREAM_IDLE_TIMEOUT_MS = 120_000;
|
|
17
20
|
|
|
18
21
|
type OpenAIOAuthSessionState = {
|
|
19
22
|
responseId?: string;
|
|
20
23
|
model?: string;
|
|
21
24
|
status?: string;
|
|
22
25
|
incompleteReason?: string;
|
|
26
|
+
turnState?: string;
|
|
27
|
+
installationId?: string;
|
|
28
|
+
windowId?: string;
|
|
23
29
|
};
|
|
24
30
|
|
|
25
31
|
const openAIOAuthSessionState = new Map<string, OpenAIOAuthSessionState>();
|
|
@@ -83,6 +89,27 @@ function shouldUsePreviousResponseId() {
|
|
|
83
89
|
return process.env.OTTO_OPENAI_OAUTH_PREVIOUS_RESPONSE_ID === '1';
|
|
84
90
|
}
|
|
85
91
|
|
|
92
|
+
function parsePositiveIntegerEnv(name: string, fallback: number) {
|
|
93
|
+
const raw = process.env[name];
|
|
94
|
+
if (!raw) return fallback;
|
|
95
|
+
const parsed = Number.parseInt(raw, 10);
|
|
96
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getCodexRequestTimeoutMs() {
|
|
100
|
+
return parsePositiveIntegerEnv(
|
|
101
|
+
'OTTO_OPENAI_OAUTH_REQUEST_TIMEOUT_MS',
|
|
102
|
+
CODEX_REQUEST_TIMEOUT_MS,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getCodexStreamIdleTimeoutMs() {
|
|
107
|
+
return parsePositiveIntegerEnv(
|
|
108
|
+
'OTTO_OPENAI_OAUTH_STREAM_IDLE_TIMEOUT_MS',
|
|
109
|
+
CODEX_STREAM_IDLE_TIMEOUT_MS,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
86
113
|
export function clearOpenAIOAuthSessionState(sessionId?: string) {
|
|
87
114
|
if (sessionId) {
|
|
88
115
|
openAIOAuthSessionState.delete(sessionId);
|
|
@@ -171,6 +198,17 @@ function writeSessionState(sessionId: string, next: OpenAIOAuthSessionState) {
|
|
|
171
198
|
openAIOAuthSessionState.set(sessionId, next);
|
|
172
199
|
}
|
|
173
200
|
|
|
201
|
+
function mergeSessionState(sessionId: string, next: OpenAIOAuthSessionState) {
|
|
202
|
+
writeSessionState(sessionId, {
|
|
203
|
+
...readSessionState(sessionId),
|
|
204
|
+
...next,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function getCodexWindowId(sessionId: string) {
|
|
209
|
+
return `${sessionId}:0`;
|
|
210
|
+
}
|
|
211
|
+
|
|
174
212
|
function rewriteRequestBody(
|
|
175
213
|
body: string,
|
|
176
214
|
sessionId?: string,
|
|
@@ -178,8 +216,25 @@ function rewriteRequestBody(
|
|
|
178
216
|
try {
|
|
179
217
|
const parsed = JSON.parse(body) as Record<string, unknown>;
|
|
180
218
|
const model = typeof parsed.model === 'string' ? parsed.model : undefined;
|
|
219
|
+
let changed = stripStatelessResponseInputIds(parsed);
|
|
181
220
|
if (!sessionId) {
|
|
182
|
-
return { body, model };
|
|
221
|
+
return { body: changed ? JSON.stringify(parsed) : body, model };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const clientMetadata =
|
|
225
|
+
parsed.client_metadata && typeof parsed.client_metadata === 'object'
|
|
226
|
+
? (parsed.client_metadata as Record<string, unknown>)
|
|
227
|
+
: {};
|
|
228
|
+
if (clientMetadata['x-codex-installation-id'] !== CODEX_INSTALLATION_ID) {
|
|
229
|
+
parsed.client_metadata = {
|
|
230
|
+
...clientMetadata,
|
|
231
|
+
'x-codex-installation-id': CODEX_INSTALLATION_ID,
|
|
232
|
+
};
|
|
233
|
+
changed = true;
|
|
234
|
+
}
|
|
235
|
+
if (typeof parsed.prompt_cache_key !== 'string') {
|
|
236
|
+
parsed.prompt_cache_key = sessionId;
|
|
237
|
+
changed = true;
|
|
183
238
|
}
|
|
184
239
|
|
|
185
240
|
const prior = readSessionState(sessionId);
|
|
@@ -192,9 +247,10 @@ function rewriteRequestBody(
|
|
|
192
247
|
logOpenAIOAuth(
|
|
193
248
|
`not injecting previous_response_id=${prior.responseId} for session=${sessionId} model=${model ?? 'unknown'} because Codex HTTP backend rejects it; enable OTTO_OPENAI_OAUTH_PREVIOUS_RESPONSE_ID=1 only for validation`,
|
|
194
249
|
);
|
|
195
|
-
return { body, model };
|
|
250
|
+
return { body: changed ? JSON.stringify(parsed) : body, model };
|
|
196
251
|
}
|
|
197
252
|
parsed.previous_response_id = prior.responseId;
|
|
253
|
+
changed = true;
|
|
198
254
|
logOpenAIOAuth(
|
|
199
255
|
`injecting previous_response_id=${prior.responseId} for session=${sessionId} model=${model ?? 'unknown'}`,
|
|
200
256
|
);
|
|
@@ -205,12 +261,41 @@ function rewriteRequestBody(
|
|
|
205
261
|
};
|
|
206
262
|
}
|
|
207
263
|
|
|
208
|
-
return { body, model };
|
|
264
|
+
return { body: changed ? JSON.stringify(parsed) : body, model };
|
|
209
265
|
} catch {
|
|
210
266
|
return { body };
|
|
211
267
|
}
|
|
212
268
|
}
|
|
213
269
|
|
|
270
|
+
function stripStatelessResponseInputIds(
|
|
271
|
+
parsed: Record<string, unknown>,
|
|
272
|
+
): boolean {
|
|
273
|
+
if (parsed.store === true || !Array.isArray(parsed.input)) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let changed = false;
|
|
278
|
+
const input = parsed.input.map((item) => {
|
|
279
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
280
|
+
return item;
|
|
281
|
+
}
|
|
282
|
+
if (!('id' in item)) {
|
|
283
|
+
return item;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const next = { ...(item as Record<string, unknown>) };
|
|
287
|
+
delete next.id;
|
|
288
|
+
changed = true;
|
|
289
|
+
return next;
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
if (changed) {
|
|
293
|
+
parsed.input = input;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return changed;
|
|
297
|
+
}
|
|
298
|
+
|
|
214
299
|
function previewText(value: unknown, maxLength = 240): string | undefined {
|
|
215
300
|
if (typeof value !== 'string') return undefined;
|
|
216
301
|
const normalized = value.replace(/\s+/g, ' ').trim();
|
|
@@ -283,6 +368,9 @@ function trackResponseEvent(data: string, sessionId?: string) {
|
|
|
283
368
|
model: responseModel ?? prior?.model,
|
|
284
369
|
status: responseStatus ?? type,
|
|
285
370
|
incompleteReason,
|
|
371
|
+
turnState: prior?.turnState,
|
|
372
|
+
installationId: prior?.installationId,
|
|
373
|
+
windowId: prior?.windowId,
|
|
286
374
|
});
|
|
287
375
|
logOpenAIOAuth(
|
|
288
376
|
`tracked response event type=${type ?? 'unknown'} responseId=${responseId} session=${sessionId} status=${responseStatus ?? 'unknown'} incompleteReason=${incompleteReason ?? 'none'}`,
|
|
@@ -304,9 +392,29 @@ function trackResponsesStream(
|
|
|
304
392
|
const decoder = new TextDecoder();
|
|
305
393
|
const encoder = new TextEncoder();
|
|
306
394
|
let buffer = '';
|
|
395
|
+
let timeout: Timer | undefined;
|
|
396
|
+
const idleTimeoutMs = getCodexStreamIdleTimeoutMs();
|
|
397
|
+
const clearIdleTimeout = () => {
|
|
398
|
+
if (timeout) clearTimeout(timeout);
|
|
399
|
+
timeout = undefined;
|
|
400
|
+
};
|
|
401
|
+
const resetIdleTimeout = (controller: TransformStreamDefaultController) => {
|
|
402
|
+
clearIdleTimeout();
|
|
403
|
+
timeout = setTimeout(() => {
|
|
404
|
+
controller.error(
|
|
405
|
+
new Error(
|
|
406
|
+
`OpenAI OAuth Codex stream idle timeout after ${idleTimeoutMs}ms`,
|
|
407
|
+
),
|
|
408
|
+
);
|
|
409
|
+
}, idleTimeoutMs);
|
|
410
|
+
};
|
|
307
411
|
|
|
308
412
|
const transform = new TransformStream<Uint8Array, Uint8Array>({
|
|
413
|
+
start(controller) {
|
|
414
|
+
resetIdleTimeout(controller);
|
|
415
|
+
},
|
|
309
416
|
transform(chunk, controller) {
|
|
417
|
+
resetIdleTimeout(controller);
|
|
310
418
|
buffer += decoder.decode(chunk, { stream: true }).replace(/\r\n/g, '\n');
|
|
311
419
|
let boundary = buffer.indexOf('\n\n');
|
|
312
420
|
while (boundary !== -1) {
|
|
@@ -329,6 +437,7 @@ function trackResponsesStream(
|
|
|
329
437
|
}
|
|
330
438
|
},
|
|
331
439
|
flush(controller) {
|
|
440
|
+
clearIdleTimeout();
|
|
332
441
|
buffer += decoder.decode().replace(/\r\n/g, '\n');
|
|
333
442
|
if (buffer.length > 0) {
|
|
334
443
|
controller.enqueue(encoder.encode(buffer));
|
|
@@ -343,6 +452,72 @@ function trackResponsesStream(
|
|
|
343
452
|
});
|
|
344
453
|
}
|
|
345
454
|
|
|
455
|
+
async function fetchWithCodexRequestTimeout(
|
|
456
|
+
url: string,
|
|
457
|
+
init: RequestInit,
|
|
458
|
+
args: {
|
|
459
|
+
enabled: boolean;
|
|
460
|
+
sessionId?: string;
|
|
461
|
+
model?: string;
|
|
462
|
+
requestStartedAt: number;
|
|
463
|
+
},
|
|
464
|
+
) {
|
|
465
|
+
if (!args.enabled) {
|
|
466
|
+
return fetch(url, {
|
|
467
|
+
...init,
|
|
468
|
+
// @ts-expect-error Bun-specific fetch option
|
|
469
|
+
timeout: false,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const timeoutMs = getCodexRequestTimeoutMs();
|
|
474
|
+
const controller = new AbortController();
|
|
475
|
+
const timeout = setTimeout(() => {
|
|
476
|
+
controller.abort(
|
|
477
|
+
new Error(
|
|
478
|
+
`OpenAI OAuth Codex request timeout before response after ${timeoutMs}ms`,
|
|
479
|
+
),
|
|
480
|
+
);
|
|
481
|
+
}, timeoutMs);
|
|
482
|
+
let abortedByParent = false;
|
|
483
|
+
const parentSignal = init.signal;
|
|
484
|
+
const abortFromParent = () => {
|
|
485
|
+
abortedByParent = true;
|
|
486
|
+
controller.abort(parentSignal?.reason);
|
|
487
|
+
};
|
|
488
|
+
if (parentSignal) {
|
|
489
|
+
if (parentSignal.aborted) {
|
|
490
|
+
abortFromParent();
|
|
491
|
+
} else {
|
|
492
|
+
parentSignal.addEventListener('abort', abortFromParent, { once: true });
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
try {
|
|
497
|
+
return await fetch(url, {
|
|
498
|
+
...init,
|
|
499
|
+
signal: controller.signal,
|
|
500
|
+
// @ts-expect-error Bun-specific fetch option
|
|
501
|
+
timeout: false,
|
|
502
|
+
});
|
|
503
|
+
} catch (error) {
|
|
504
|
+
if (!abortedByParent && controller.signal.aborted) {
|
|
505
|
+
loggerWarn('[openai-oauth] request timed out before response', {
|
|
506
|
+
sessionId: args.sessionId,
|
|
507
|
+
model: args.model,
|
|
508
|
+
timeoutMs,
|
|
509
|
+
durationMs: Date.now() - args.requestStartedAt,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
throw error;
|
|
513
|
+
} finally {
|
|
514
|
+
clearTimeout(timeout);
|
|
515
|
+
if (parentSignal) {
|
|
516
|
+
parentSignal.removeEventListener('abort', abortFromParent);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
346
521
|
function buildHeaders(
|
|
347
522
|
init: RequestInit | undefined,
|
|
348
523
|
accessToken: string,
|
|
@@ -350,10 +525,15 @@ function buildHeaders(
|
|
|
350
525
|
sessionId?: string,
|
|
351
526
|
): Headers {
|
|
352
527
|
const headers = new Headers(init?.headers);
|
|
528
|
+
const prior = readSessionState(sessionId);
|
|
529
|
+
const windowId = sessionId
|
|
530
|
+
? (prior?.windowId ?? getCodexWindowId(sessionId))
|
|
531
|
+
: undefined;
|
|
353
532
|
headers.delete('Authorization');
|
|
354
533
|
headers.delete('authorization');
|
|
355
534
|
headers.set('authorization', `Bearer ${accessToken}`);
|
|
356
535
|
headers.set('originator', 'otto');
|
|
536
|
+
headers.set('x-codex-installation-id', CODEX_INSTALLATION_ID);
|
|
357
537
|
headers.set(
|
|
358
538
|
'User-Agent',
|
|
359
539
|
`otto/1.0 (${os.platform()} ${os.release()}; ${os.arch()})`,
|
|
@@ -363,10 +543,30 @@ function buildHeaders(
|
|
|
363
543
|
}
|
|
364
544
|
if (sessionId) {
|
|
365
545
|
headers.set('session_id', sessionId);
|
|
546
|
+
headers.set('thread_id', sessionId);
|
|
547
|
+
headers.set('x-codex-window-id', windowId ?? getCodexWindowId(sessionId));
|
|
548
|
+
if (prior?.turnState) {
|
|
549
|
+
headers.set('x-codex-turn-state', prior.turnState);
|
|
550
|
+
}
|
|
366
551
|
}
|
|
367
552
|
return headers;
|
|
368
553
|
}
|
|
369
554
|
|
|
555
|
+
function trackCodexResponseHeaders(response: Response, sessionId?: string) {
|
|
556
|
+
if (!sessionId) return;
|
|
557
|
+
const turnState = response.headers.get('x-codex-turn-state') ?? undefined;
|
|
558
|
+
if (!turnState) return;
|
|
559
|
+
const windowId = getCodexWindowId(sessionId);
|
|
560
|
+
mergeSessionState(sessionId, {
|
|
561
|
+
turnState,
|
|
562
|
+
installationId: CODEX_INSTALLATION_ID,
|
|
563
|
+
windowId,
|
|
564
|
+
});
|
|
565
|
+
logOpenAIOAuth(
|
|
566
|
+
`tracked x-codex-turn-state for session=${sessionId} window=${windowId}`,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
370
570
|
export function createOpenAIOAuthFetch(config: OpenAIOAuthConfig) {
|
|
371
571
|
let currentOAuth = config.oauth;
|
|
372
572
|
|
|
@@ -403,6 +603,9 @@ export function createOpenAIOAuthFetch(config: OpenAIOAuthConfig) {
|
|
|
403
603
|
model: requestModel,
|
|
404
604
|
status: prior?.status,
|
|
405
605
|
incompleteReason: prior?.incompleteReason,
|
|
606
|
+
turnState: prior?.turnState,
|
|
607
|
+
installationId: prior?.installationId,
|
|
608
|
+
windowId: prior?.windowId,
|
|
406
609
|
});
|
|
407
610
|
}
|
|
408
611
|
}
|
|
@@ -425,12 +628,19 @@ export function createOpenAIOAuthFetch(config: OpenAIOAuthConfig) {
|
|
|
425
628
|
|
|
426
629
|
let response: Response;
|
|
427
630
|
try {
|
|
428
|
-
response = await
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
631
|
+
response = await fetchWithCodexRequestTimeout(
|
|
632
|
+
targetUrl,
|
|
633
|
+
{
|
|
634
|
+
...requestInit,
|
|
635
|
+
headers,
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
enabled: isResponsesRequest,
|
|
639
|
+
sessionId: config.sessionId,
|
|
640
|
+
model: requestModel,
|
|
641
|
+
requestStartedAt,
|
|
642
|
+
},
|
|
643
|
+
);
|
|
434
644
|
} catch (error) {
|
|
435
645
|
loggerWarn('[openai-oauth] request failed before response', {
|
|
436
646
|
sessionId: config.sessionId,
|
|
@@ -453,6 +663,9 @@ export function createOpenAIOAuthFetch(config: OpenAIOAuthConfig) {
|
|
|
453
663
|
bodyCharsApprox: requestBodySize,
|
|
454
664
|
model: requestModel,
|
|
455
665
|
});
|
|
666
|
+
if (isResponsesRequest) {
|
|
667
|
+
trackCodexResponseHeaders(response, config.sessionId);
|
|
668
|
+
}
|
|
456
669
|
if (!response.ok && response.status !== 401) {
|
|
457
670
|
loggerWarn('[openai-oauth] non-OK response', {
|
|
458
671
|
sessionId: config.sessionId,
|
|
@@ -499,12 +712,22 @@ export function createOpenAIOAuthFetch(config: OpenAIOAuthConfig) {
|
|
|
499
712
|
);
|
|
500
713
|
|
|
501
714
|
const retryStartedAt = Date.now();
|
|
502
|
-
const retryResponse = await
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
715
|
+
const retryResponse = await fetchWithCodexRequestTimeout(
|
|
716
|
+
targetUrl,
|
|
717
|
+
{
|
|
718
|
+
...requestInit,
|
|
719
|
+
headers: retryHeaders,
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
enabled: isResponsesRequest,
|
|
723
|
+
sessionId: config.sessionId,
|
|
724
|
+
model: requestModel,
|
|
725
|
+
requestStartedAt: retryStartedAt,
|
|
726
|
+
},
|
|
727
|
+
);
|
|
728
|
+
if (isResponsesRequest) {
|
|
729
|
+
trackCodexResponseHeaders(retryResponse, config.sessionId);
|
|
730
|
+
}
|
|
508
731
|
loggerDebug('[openai-oauth] retry response received', {
|
|
509
732
|
sessionId: config.sessionId,
|
|
510
733
|
target: isResponsesRequest ? 'codex.responses' : 'other',
|