@oai404iao/pi-codex-core 0.1.0-alpha.1

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.
Files changed (53) hide show
  1. package/LICENSE +28 -0
  2. package/LICENSES/Apache-2.0.txt +201 -0
  3. package/LICENSES/OpenAI-Codex-NOTICE.txt +6 -0
  4. package/README.md +26 -0
  5. package/THIRD_PARTY_NOTICES.md +18 -0
  6. package/package.json +84 -0
  7. package/provenance/openai-codex-eb9dceba-reserved-tools.json +140 -0
  8. package/src/adapter/compaction/checkpoint.ts +159 -0
  9. package/src/adapter/compaction/collect.ts +51 -0
  10. package/src/adapter/compaction/http.ts +101 -0
  11. package/src/adapter/compaction/request.ts +159 -0
  12. package/src/adapter/compaction/transport.ts +125 -0
  13. package/src/adapter/compaction/websocket.ts +119 -0
  14. package/src/extension/prewarm-snapshot.ts +27 -0
  15. package/src/extension/provider-runtime.ts +101 -0
  16. package/src/extension/startup-prewarm.ts +264 -0
  17. package/src/fast-mode.ts +124 -0
  18. package/src/index.ts +257 -0
  19. package/src/native-compaction.ts +392 -0
  20. package/src/patch/apply.ts +338 -0
  21. package/src/patch/parser.ts +224 -0
  22. package/src/patch/render.ts +201 -0
  23. package/src/provider-native-tools.ts +75 -0
  24. package/src/providers/codex-apply-patch-tool.ts +23 -0
  25. package/src/providers/codex-apply-patch.lark +19 -0
  26. package/src/providers/openai-codex/cache-key.ts +52 -0
  27. package/src/providers/openai-codex/captured-stream.ts +50 -0
  28. package/src/providers/openai-codex/constants.ts +61 -0
  29. package/src/providers/openai-codex/continuation.ts +110 -0
  30. package/src/providers/openai-codex/errors.ts +130 -0
  31. package/src/providers/openai-codex/events.ts +123 -0
  32. package/src/providers/openai-codex/headers.ts +224 -0
  33. package/src/providers/openai-codex/lite.ts +24 -0
  34. package/src/providers/openai-codex/message.ts +33 -0
  35. package/src/providers/openai-codex/prewarm.ts +76 -0
  36. package/src/providers/openai-codex/proxy.ts +55 -0
  37. package/src/providers/openai-codex/reasoning.ts +54 -0
  38. package/src/providers/openai-codex/request-body.ts +149 -0
  39. package/src/providers/openai-codex/request-context.ts +20 -0
  40. package/src/providers/openai-codex/request-metadata.ts +137 -0
  41. package/src/providers/openai-codex/retry.ts +154 -0
  42. package/src/providers/openai-codex/runtime.ts +1 -0
  43. package/src/providers/openai-codex/sse.ts +93 -0
  44. package/src/providers/openai-codex/stream.ts +367 -0
  45. package/src/providers/openai-codex/urls.ts +24 -0
  46. package/src/providers/openai-codex/usage.ts +60 -0
  47. package/src/providers/openai-codex/websocket-connection.ts +216 -0
  48. package/src/providers/openai-codex/websocket-events.ts +210 -0
  49. package/src/providers/openai-codex/websocket-session.ts +192 -0
  50. package/src/providers/openai-codex/websocket-socket.ts +18 -0
  51. package/src/providers/openai-codex/websocket-stream.ts +151 -0
  52. package/src/tools/apply-patch.ts +84 -0
  53. package/src/tools/view-image.ts +98 -0
@@ -0,0 +1,338 @@
1
+ /*
2
+ * Substantially modified TypeScript adaptation of OpenAI Codex apply-patch
3
+ * behavior at 03bb3b12367397e14a8facc2e018d645ff4d8e83.
4
+ * See ../../THIRD_PARTY_NOTICES.md and ../../LICENSES/Apache-2.0.txt.
5
+ */
6
+
7
+ import { chmod, lstat, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
8
+ import { dirname, isAbsolute, resolve } from "node:path";
9
+ import { actionSummary, parseApplyPatch, parseApplyPatchProgress, type ParsedPatch, type PatchAction, type PatchUpdateChunk } from "./parser.js";
10
+
11
+ export interface ApplyPatchOptions {
12
+ cwd: string;
13
+ }
14
+
15
+ export interface AppliedPatchFile {
16
+ kind: PatchAction["kind"];
17
+ path: string;
18
+ absolutePath: string;
19
+ moveTo?: string;
20
+ absoluteMoveTo?: string;
21
+ }
22
+
23
+ export interface ApplyPatchResult {
24
+ files: AppliedPatchFile[];
25
+ summary: string;
26
+ }
27
+
28
+ export function resolvePatchPath(pathValue: string, options: ApplyPatchOptions): string {
29
+ const cleaned = pathValue.trim();
30
+ return isAbsolute(cleaned) ? resolve(cleaned) : resolve(options.cwd, cleaned);
31
+ }
32
+
33
+ function errorCode(error: unknown): string | undefined {
34
+ return (error as NodeJS.ErrnoException | undefined)?.code;
35
+ }
36
+
37
+ function verificationError(error: unknown): Error {
38
+ const message = error instanceof Error ? error.message : String(error);
39
+ return message.startsWith("apply_patch verification failed:") ? new Error(message) : new Error(`apply_patch verification failed: ${message}`);
40
+ }
41
+
42
+ async function validatePatchTarget(absolutePath: string, originalPath: string): Promise<void> {
43
+ try {
44
+ const info = await lstat(absolutePath);
45
+ if (info.isSymbolicLink()) throw new Error(`Patch target may not be a symbolic link: ${originalPath}`);
46
+ } catch (error) {
47
+ if (error instanceof Error && error.message.startsWith("Patch ")) throw error;
48
+ if (errorCode(error) !== "ENOENT") throw error;
49
+ }
50
+ }
51
+
52
+ function normalizeForMatch(value: string): string {
53
+ return value.trim().replace(/[‐‑‒–—―−]/gu, "-")
54
+ .replace(/[‘’‚‛]/gu, "'")
55
+ .replace(/[“”„‟]/gu, "\"")
56
+ .replace(/[             ]/gu, " ");
57
+ }
58
+
59
+ function sequenceMatches(lines: string[], pattern: string[], index: number, normalize: (value: string) => string): boolean {
60
+ for (let offset = 0; offset < pattern.length; offset++) {
61
+ if (normalize(lines[index + offset]!) !== normalize(pattern[offset]!)) return false;
62
+ }
63
+ return true;
64
+ }
65
+
66
+ export function seekSequence(lines: string[], pattern: string[], start: number, eof: boolean): number | undefined {
67
+ if (pattern.length === 0) return start;
68
+ if (pattern.length > lines.length) return undefined;
69
+ const lastStart = lines.length - pattern.length;
70
+ const searchStart = eof ? lastStart : start;
71
+ if (searchStart > lastStart) return undefined;
72
+ for (const normalize of [(value: string) => value, (value: string) => value.trimEnd(), (value: string) => value.trim(), normalizeForMatch]) {
73
+ for (let index = searchStart; index <= lastStart; index++) {
74
+ if (sequenceMatches(lines, pattern, index, normalize)) return index;
75
+ }
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ interface Replacement {
81
+ start: number;
82
+ oldLength: number;
83
+ newLines: string[];
84
+ }
85
+
86
+ function computeReplacements(originalLines: string[], path: string, chunks: PatchUpdateChunk[]): Replacement[] {
87
+ const replacements: Replacement[] = [];
88
+ let lineIndex = 0;
89
+ for (const chunk of chunks) {
90
+ if (chunk.changeContext !== undefined) {
91
+ const contextIndex = seekSequence(originalLines, [chunk.changeContext], lineIndex, false);
92
+ if (contextIndex === undefined) throw new Error(`Failed to find context '${chunk.changeContext}' in ${path}`);
93
+ lineIndex = contextIndex + 1;
94
+ }
95
+
96
+ if (chunk.oldLines.length === 0) {
97
+ replacements.push({ start: originalLines.length, oldLength: 0, newLines: chunk.newLines });
98
+ continue;
99
+ }
100
+
101
+ let pattern = chunk.oldLines;
102
+ let newLines = chunk.newLines;
103
+ let found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
104
+ if (found === undefined && pattern[pattern.length - 1] === "") {
105
+ pattern = pattern.slice(0, -1);
106
+ if (newLines[newLines.length - 1] === "") newLines = newLines.slice(0, -1);
107
+ found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
108
+ }
109
+ if (found === undefined) throw new Error(`Failed to find expected lines in ${path}:\n${chunk.oldLines.join("\n")}`);
110
+ replacements.push({ start: found, oldLength: pattern.length, newLines });
111
+ lineIndex = found + pattern.length;
112
+ }
113
+ return replacements.sort((a, b) => a.start - b.start);
114
+ }
115
+
116
+ function dominantLineEnding(content: string): "\n" | "\r\n" {
117
+ const crlfCount = content.match(/\r\n/g)?.length ?? 0;
118
+ const lfCount = content.match(/\n/g)?.length ?? 0;
119
+ return crlfCount > lfCount / 2 ? "\r\n" : "\n";
120
+ }
121
+
122
+ export function deriveUpdatedContent(content: string, path: string, chunks: PatchUpdateChunk[]): string {
123
+ const lineEnding = dominantLineEnding(content);
124
+ const originalLines = content.replace(/\r\n/g, "\n").split("\n");
125
+ if (originalLines[originalLines.length - 1] === "") originalLines.pop();
126
+ const replacements = computeReplacements(originalLines, path, chunks);
127
+ const updated = [...originalLines];
128
+ for (const replacement of [...replacements].reverse()) {
129
+ updated.splice(replacement.start, replacement.oldLength, ...replacement.newLines);
130
+ }
131
+ if (updated[updated.length - 1] !== "") updated.push("");
132
+ return updated.join(lineEnding);
133
+ }
134
+
135
+ interface VirtualFile {
136
+ exists: boolean;
137
+ content?: string;
138
+ }
139
+
140
+ interface PlannedAction {
141
+ action: PatchAction;
142
+ absolutePath: string;
143
+ absoluteMoveTo?: string;
144
+ previousContent: string;
145
+ content?: string;
146
+ }
147
+
148
+ export interface ApplyPatchPreviewFile {
149
+ kind: PatchAction["kind"];
150
+ path: string;
151
+ moveTo?: string;
152
+ previousContent: string;
153
+ content: string;
154
+ }
155
+
156
+ export interface ApplyPatchPreview {
157
+ complete: boolean;
158
+ files: ApplyPatchPreviewFile[];
159
+ }
160
+
161
+ async function loadVirtualFile(path: string, files: Map<string, VirtualFile>): Promise<VirtualFile> {
162
+ const cached = files.get(path);
163
+ if (cached) return cached;
164
+ try {
165
+ const info = await lstat(path);
166
+ const file = info.isDirectory() ? { exists: true } : { exists: true, content: await readFile(path, "utf8") };
167
+ files.set(path, file);
168
+ return file;
169
+ } catch (error) {
170
+ if (errorCode(error) !== "ENOENT") throw error;
171
+ const file = { exists: false };
172
+ files.set(path, file);
173
+ return file;
174
+ }
175
+ }
176
+
177
+ async function planPatch(parsed: ParsedPatch, options: ApplyPatchOptions): Promise<PlannedAction[]> {
178
+ const virtualFiles = new Map<string, VirtualFile>();
179
+ const plans: PlannedAction[] = [];
180
+ for (const action of parsed.actions) {
181
+ const absolutePath = resolvePatchPath(action.path, options);
182
+ await validatePatchTarget(absolutePath, action.path);
183
+ const source = await loadVirtualFile(absolutePath, virtualFiles);
184
+
185
+ if (action.kind === "add") {
186
+ if (source.exists) throw new Error(`Add File target already exists: ${action.path}`);
187
+ virtualFiles.set(absolutePath, { exists: true, content: action.content });
188
+ plans.push({ action, absolutePath, previousContent: "", content: action.content });
189
+ continue;
190
+ }
191
+
192
+ if (!source.exists) throw new Error(`Failed to read ${action.path}: file does not exist`);
193
+ if (source.content === undefined) throw new Error(`Patch target is not a file: ${action.path}`);
194
+ if (action.kind === "delete") {
195
+ virtualFiles.set(absolutePath, { exists: false });
196
+ plans.push({ action, absolutePath, previousContent: source.content });
197
+ continue;
198
+ }
199
+
200
+ const content = deriveUpdatedContent(source.content, action.path, action.chunks);
201
+ let absoluteMoveTo: string | undefined;
202
+ if (action.moveTo) {
203
+ absoluteMoveTo = resolvePatchPath(action.moveTo, options);
204
+ await validatePatchTarget(absoluteMoveTo, action.moveTo);
205
+ if (absoluteMoveTo !== absolutePath) {
206
+ const destination = await loadVirtualFile(absoluteMoveTo, virtualFiles);
207
+ if (destination.exists) throw new Error(`Move target already exists: ${action.moveTo}`);
208
+ virtualFiles.set(absolutePath, { exists: false });
209
+ }
210
+ }
211
+ virtualFiles.set(absoluteMoveTo ?? absolutePath, { exists: true, content });
212
+ plans.push({ action, absolutePath, ...(absoluteMoveTo ? { absoluteMoveTo } : {}), previousContent: source.content, content });
213
+ }
214
+ return plans;
215
+ }
216
+
217
+ export async function previewApplyPatch(input: string, options: ApplyPatchOptions, complete = false): Promise<ApplyPatchPreview> {
218
+ let parsed: ParsedPatch;
219
+ try {
220
+ parsed = complete ? parseApplyPatch(input) : parseApplyPatchProgress(input);
221
+ } catch (error) {
222
+ throw verificationError(error);
223
+ }
224
+ if (parsed.actions.length === 0) return { complete, files: [] };
225
+ let plans: PlannedAction[];
226
+ try {
227
+ plans = await planPatch(parsed, options);
228
+ } catch (error) {
229
+ throw verificationError(error);
230
+ }
231
+ return {
232
+ complete,
233
+ files: plans.map((plan) => ({
234
+ kind: plan.action.kind,
235
+ path: plan.action.path,
236
+ ...(plan.action.kind === "update" && plan.action.moveTo ? { moveTo: plan.action.moveTo } : {}),
237
+ previousContent: plan.previousContent,
238
+ content: plan.content ?? "",
239
+ })),
240
+ };
241
+ }
242
+
243
+ interface FileSnapshot {
244
+ absolutePath: string;
245
+ existed: boolean;
246
+ data?: Buffer;
247
+ mode?: number;
248
+ }
249
+
250
+ async function snapshotPath(absolutePath: string): Promise<FileSnapshot> {
251
+ try {
252
+ const [data, metadata] = await Promise.all([readFile(absolutePath), stat(absolutePath)]);
253
+ return { absolutePath, existed: true, data, mode: metadata.mode };
254
+ } catch (error) {
255
+ if (errorCode(error) === "ENOENT") return { absolutePath, existed: false };
256
+ throw error;
257
+ }
258
+ }
259
+
260
+ async function restoreSnapshots(snapshots: FileSnapshot[]): Promise<void> {
261
+ for (const snapshot of [...snapshots].reverse()) {
262
+ if (snapshot.existed) {
263
+ await mkdir(dirname(snapshot.absolutePath), { recursive: true });
264
+ await writeFile(snapshot.absolutePath, snapshot.data ?? Buffer.alloc(0));
265
+ if (snapshot.mode !== undefined) await chmod(snapshot.absolutePath, snapshot.mode);
266
+ } else {
267
+ await rm(snapshot.absolutePath, { force: true });
268
+ }
269
+ }
270
+ }
271
+
272
+ async function commitPlan(plan: PlannedAction): Promise<void> {
273
+ if (plan.action.kind === "add") {
274
+ await mkdir(dirname(plan.absolutePath), { recursive: true });
275
+ await writeFile(plan.absolutePath, plan.content ?? "", { encoding: "utf8", flag: "wx" });
276
+ } else if (plan.action.kind === "delete") {
277
+ await rm(plan.absolutePath, { force: false });
278
+ } else if (plan.absoluteMoveTo && plan.absoluteMoveTo !== plan.absolutePath) {
279
+ await writeFile(plan.absolutePath, plan.content ?? "", "utf8");
280
+ await mkdir(dirname(plan.absoluteMoveTo), { recursive: true });
281
+ await rename(plan.absolutePath, plan.absoluteMoveTo);
282
+ } else {
283
+ await writeFile(plan.absolutePath, plan.content ?? "", "utf8");
284
+ }
285
+ }
286
+
287
+ function appliedFile(plan: PlannedAction): AppliedPatchFile {
288
+ const { action } = plan;
289
+ return {
290
+ kind: action.kind,
291
+ path: action.path,
292
+ absolutePath: plan.absolutePath,
293
+ ...(action.kind === "update" && action.moveTo ? { moveTo: action.moveTo, absoluteMoveTo: plan.absoluteMoveTo } : {}),
294
+ };
295
+ }
296
+
297
+ export async function applyParsedPatch(parsed: ParsedPatch, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
298
+ let plans: PlannedAction[];
299
+ try {
300
+ plans = await planPatch(parsed, options);
301
+ } catch (error) {
302
+ throw verificationError(error);
303
+ }
304
+ const snapshots = new Map<string, FileSnapshot>();
305
+ for (const plan of plans) {
306
+ for (const path of [plan.absolutePath, plan.absoluteMoveTo].filter((value): value is string => Boolean(value))) {
307
+ if (!snapshots.has(path)) snapshots.set(path, await snapshotPath(path));
308
+ }
309
+ }
310
+
311
+ const files: AppliedPatchFile[] = [];
312
+ try {
313
+ for (const plan of plans) {
314
+ await commitPlan(plan);
315
+ files.push(appliedFile(plan));
316
+ }
317
+ } catch (error) {
318
+ const applied = files.map((file) => `${file.kind} ${file.path}`).join(", ") || "none";
319
+ const message = error instanceof Error ? error.message : String(error);
320
+ await restoreSnapshots([...snapshots.values()]);
321
+ throw new Error(`${message}\nPartial apply status: completed actions before failure: ${applied}. Rolled back touched files; review the working tree before retrying.`);
322
+ }
323
+
324
+ return {
325
+ files,
326
+ summary: `Success. Updated the following files:\n${parsed.actions.map(actionSummary).join("\n")}`,
327
+ };
328
+ }
329
+
330
+ export async function applyPatch(input: string, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
331
+ let parsed: ParsedPatch;
332
+ try {
333
+ parsed = parseApplyPatch(input);
334
+ } catch (error) {
335
+ throw verificationError(error);
336
+ }
337
+ return applyParsedPatch(parsed, options);
338
+ }
@@ -0,0 +1,224 @@
1
+ /*
2
+ * Substantially modified TypeScript adaptation of OpenAI Codex apply-patch
3
+ * behavior at 03bb3b12367397e14a8facc2e018d645ff4d8e83.
4
+ * See ../../THIRD_PARTY_NOTICES.md and ../../LICENSES/Apache-2.0.txt.
5
+ */
6
+
7
+ export type PatchActionKind = "add" | "update" | "delete";
8
+
9
+ export interface PatchUpdateChunk {
10
+ changeContext?: string;
11
+ oldLines: string[];
12
+ newLines: string[];
13
+ isEndOfFile: boolean;
14
+ }
15
+
16
+ export interface PatchAddAction {
17
+ kind: "add";
18
+ path: string;
19
+ content: string;
20
+ }
21
+
22
+ export interface PatchUpdateAction {
23
+ kind: "update";
24
+ path: string;
25
+ moveTo?: string;
26
+ chunks: PatchUpdateChunk[];
27
+ }
28
+
29
+ export interface PatchDeleteAction {
30
+ kind: "delete";
31
+ path: string;
32
+ }
33
+
34
+ export type PatchAction = PatchAddAction | PatchUpdateAction | PatchDeleteAction;
35
+
36
+ export interface ParsedPatch {
37
+ actions: PatchAction[];
38
+ }
39
+
40
+ const BEGIN_PATCH = "*** Begin Patch";
41
+ const END_PATCH = "*** End Patch";
42
+ const ADD_FILE = "*** Add File: ";
43
+ const UPDATE_FILE = "*** Update File: ";
44
+ const DELETE_FILE = "*** Delete File: ";
45
+ const MOVE_TO = "*** Move to: ";
46
+ const END_OF_FILE = "*** End of File";
47
+
48
+ function patchLines(input: string): string[] {
49
+ return input.trim().split("\n").map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
50
+ }
51
+
52
+ function parseHeader(line: string): { kind: PatchActionKind; path: string } | undefined {
53
+ for (const [marker, kind] of [[ADD_FILE, "add"], [UPDATE_FILE, "update"], [DELETE_FILE, "delete"]] as const) {
54
+ if (!line.startsWith(marker)) continue;
55
+ const path = line.slice(marker.length).trim();
56
+ if (!path) throw new Error("Patch file header is missing a path.");
57
+ return { kind, path };
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ function isBoundary(line: string): boolean {
63
+ const trimmed = line.trim();
64
+ return trimmed === END_PATCH || parseHeader(trimmed) !== undefined;
65
+ }
66
+
67
+ function invalidHunk(lineNumber: number, message: string): Error {
68
+ return new Error(`Invalid patch hunk on line ${lineNumber}: ${message}`);
69
+ }
70
+
71
+ function parseAdd(lines: string[], start: number, path: string): { action: PatchAddAction; next: number } {
72
+ let index = start;
73
+ let content = "";
74
+ while (index < lines.length && !isBoundary(lines[index]!)) {
75
+ const line = lines[index]!;
76
+ if (!line.startsWith("+")) {
77
+ throw invalidHunk(index + 1, `Add File lines must start with '+': ${path}`);
78
+ }
79
+ content += `${line.slice(1)}\n`;
80
+ index++;
81
+ }
82
+ if (!content) throw invalidHunk(start, `Add file hunk for path '${path}' is empty`);
83
+ return { action: { kind: "add", path, content }, next: index };
84
+ }
85
+
86
+ function newChunk(changeContext?: string): PatchUpdateChunk {
87
+ return { changeContext, oldLines: [], newLines: [], isEndOfFile: false };
88
+ }
89
+
90
+ function chunkIsEmpty(chunk: PatchUpdateChunk): boolean {
91
+ return chunk.oldLines.length === 0 && chunk.newLines.length === 0;
92
+ }
93
+
94
+ function parseUpdate(lines: string[], start: number, path: string): { action: PatchUpdateAction; next: number } {
95
+ let index = start;
96
+ let moveTo: string | undefined;
97
+ const moveLine = lines[index]?.trimEnd();
98
+ if (moveLine?.startsWith(MOVE_TO)) {
99
+ moveTo = moveLine.slice(MOVE_TO.length).trim();
100
+ if (!moveTo) throw invalidHunk(index + 1, `Move target is empty for ${path}`);
101
+ index++;
102
+ }
103
+
104
+ const chunks: PatchUpdateChunk[] = [];
105
+ let current: PatchUpdateChunk | undefined;
106
+ while (index < lines.length) {
107
+ const line = lines[index]!;
108
+ const updateLine = line.trimEnd();
109
+ if (updateLine === END_PATCH || parseHeader(updateLine)) break;
110
+
111
+ if (current?.isEndOfFile) {
112
+ if (updateLine === "") {
113
+ index++;
114
+ continue;
115
+ }
116
+ if (updateLine !== "@@" && !updateLine.startsWith("@@ ")) {
117
+ throw invalidHunk(index + 1, `Expected update hunk to start with a @@ context marker, got: '${line}'`);
118
+ }
119
+ }
120
+
121
+ if (updateLine === "@@" || updateLine.startsWith("@@ ")) {
122
+ if (current && chunkIsEmpty(current)) {
123
+ throw invalidHunk(index + 1, `Unexpected line found in update hunk: '${line}'. Every line should start with ' ', '+', or '-'`);
124
+ }
125
+ current = newChunk(updateLine === "@@" ? undefined : updateLine.slice(3));
126
+ chunks.push(current);
127
+ index++;
128
+ continue;
129
+ }
130
+
131
+ if (updateLine === END_OF_FILE) {
132
+ if (!current || chunkIsEmpty(current)) throw invalidHunk(index + 1, "Update hunk does not contain any lines");
133
+ current.isEndOfFile = true;
134
+ index++;
135
+ continue;
136
+ }
137
+
138
+ if (line === "" || line.startsWith(" ") || line.startsWith("+") || line.startsWith("-")) {
139
+ if (!current) {
140
+ current = newChunk();
141
+ chunks.push(current);
142
+ }
143
+ const text = line === "" ? "" : line.slice(1);
144
+ if (line === "" || line.startsWith(" ") || line.startsWith("-")) current.oldLines.push(text);
145
+ if (line === "" || line.startsWith(" ") || line.startsWith("+")) current.newLines.push(text);
146
+ index++;
147
+ continue;
148
+ }
149
+
150
+ throw invalidHunk(index + 1, `Expected update hunk to start with a @@ context marker, got: '${line}'`);
151
+ }
152
+
153
+ if (chunks.length === 0) throw invalidHunk(start, `Update file hunk for path '${path}' is empty`);
154
+ if (chunkIsEmpty(chunks[chunks.length - 1]!)) throw invalidHunk(index + 1, "Update hunk does not contain any lines");
155
+ return { action: { kind: "update", path, ...(moveTo ? { moveTo } : {}), chunks }, next: index };
156
+ }
157
+
158
+ export function parseApplyPatch(input: string): ParsedPatch {
159
+ if (typeof input !== "string" || input.trim().length === 0) throw new Error("apply_patch input must be a non-empty string.");
160
+ const lines = patchLines(input);
161
+ if (lines[0]?.trim() !== BEGIN_PATCH) throw new Error("The first line of the patch must be '*** Begin Patch'.");
162
+ if (lines[lines.length - 1]?.trim() !== END_PATCH) throw new Error("The last line of the patch must be '*** End Patch'.");
163
+
164
+ const actions: PatchAction[] = [];
165
+ let index = 1;
166
+ while (index < lines.length - 1) {
167
+ const line = lines[index]!.trim();
168
+ if (line === END_PATCH) {
169
+ for (let trailing = index + 1; trailing < lines.length; trailing++) {
170
+ if (lines[trailing]!.trim() !== "") throw new Error(`Unexpected content after '*** End Patch' at line ${trailing + 1}.`);
171
+ }
172
+ break;
173
+ }
174
+ const header = parseHeader(line);
175
+ if (!header) throw invalidHunk(index + 1, `'${line}' is not a valid file header`);
176
+ index++;
177
+ if (header.kind === "add") {
178
+ const parsed = parseAdd(lines, index, header.path);
179
+ actions.push(parsed.action);
180
+ index = parsed.next;
181
+ } else if (header.kind === "update") {
182
+ const parsed = parseUpdate(lines, index, header.path);
183
+ actions.push(parsed.action);
184
+ index = parsed.next;
185
+ } else {
186
+ actions.push({ kind: "delete", path: header.path });
187
+ }
188
+ }
189
+ if (index !== lines.length - 1) throw new Error("The last line of the patch must be '*** End Patch'.");
190
+ if (actions.length === 0) throw new Error("No files were modified.");
191
+ return { actions };
192
+ }
193
+
194
+ /**
195
+ * Best-effort parser for model arguments that are still streaming. Only
196
+ * newline-terminated input is considered, and incomplete trailing actions are
197
+ * removed until the remaining prefix forms a valid patch. Final execution
198
+ * always uses parseApplyPatch() instead.
199
+ */
200
+ export function parseApplyPatchProgress(input: string): ParsedPatch {
201
+ if (typeof input !== "string" || !input.includes("\n")) return { actions: [] };
202
+ const normalized = input.replace(/\r\n/g, "\n");
203
+ const completePrefix = normalized.slice(0, normalized.lastIndexOf("\n") + 1);
204
+ const lines = completePrefix.split("\n");
205
+ while (lines[lines.length - 1] === "") lines.pop();
206
+ if (lines[0]?.trim() !== BEGIN_PATCH) return { actions: [] };
207
+
208
+ const minimum = Math.max(1, lines.length - 64);
209
+ for (let end = lines.length; end >= minimum; end--) {
210
+ const candidate = `${lines.slice(0, end).join("\n")}\n${END_PATCH}`;
211
+ try {
212
+ return parseApplyPatch(candidate);
213
+ } catch {
214
+ // The current line/action may be incomplete; try the previous line.
215
+ }
216
+ }
217
+ return { actions: [] };
218
+ }
219
+
220
+ export function actionSummary(action: PatchAction): string {
221
+ const path = action.kind === "update" && action.moveTo ? action.moveTo : action.path;
222
+ const marker = action.kind === "add" ? "A" : action.kind === "delete" ? "D" : "M";
223
+ return `${marker} ${path}`;
224
+ }