@maxiaochao/pi-toolkit 0.8.2 → 0.9.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.
- package/CHANGELOG.md +8 -0
- package/README.md +9 -13
- package/extensions/codex-edit/README.md +53 -0
- package/extensions/codex-edit/codex-edit-explainer.html +316 -0
- package/extensions/codex-edit/config.json +6 -0
- package/extensions/codex-edit/index.ts +218 -0
- package/extensions/codex-edit/model-routing.ts +12 -0
- package/extensions/codex-edit/parser.ts +154 -0
- package/extensions/codex-edit/summary.md +56 -0
- package/extensions/codex-edit/tests/package.test.mjs +38 -0
- package/extensions/codex-edit/tests/parser.test.mjs +89 -0
- package/extensions/tests/todo-state.mjs +84 -0
- package/extensions/todo-state.ts +151 -0
- package/extensions/todo.ts +60 -156
- package/package.json +6 -5
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { access, lstat, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
5
|
+
import {
|
|
6
|
+
generateDiffString,
|
|
7
|
+
generateUnifiedPatch,
|
|
8
|
+
withFileMutationQueue,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import {
|
|
12
|
+
APPLY_PATCH_LARK_GRAMMAR,
|
|
13
|
+
applyUpdate,
|
|
14
|
+
parsePatch,
|
|
15
|
+
resolveWorkspacePath,
|
|
16
|
+
} from "./parser.ts";
|
|
17
|
+
import { modelIsAllowed } from "./model-routing.ts";
|
|
18
|
+
|
|
19
|
+
const editorToolNames = new Set(["edit", "apply_patch"]);
|
|
20
|
+
const settingsUrl = new URL("./config.json", import.meta.url);
|
|
21
|
+
|
|
22
|
+
async function loadSettings() {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(await readFile(settingsUrl, "utf8"));
|
|
25
|
+
} catch {
|
|
26
|
+
return { enabled: true, models: [] };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function syncEditorTools(pi, ctx, model, settings) {
|
|
31
|
+
const active = pi.getActiveTools();
|
|
32
|
+
if (!active.some((name) => editorToolNames.has(name))) return;
|
|
33
|
+
const selected = modelIsAllowed(model, settings) ? "apply_patch" : "edit";
|
|
34
|
+
const next = active.filter((name) => !editorToolNames.has(name));
|
|
35
|
+
next.push(selected);
|
|
36
|
+
pi.setActiveTools([...new Set(next)]);
|
|
37
|
+
if (ctx.hasUI) ctx.ui.setStatus("codex-edit", `editor: ${selected}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function fileInfo(path, label) {
|
|
41
|
+
let info;
|
|
42
|
+
try {
|
|
43
|
+
info = await lstat(path);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const code = error && typeof error === "object" && "code" in error ? error.code : "unknown";
|
|
46
|
+
throw new Error(`${label} is unavailable (${code})`);
|
|
47
|
+
}
|
|
48
|
+
if (info.isSymbolicLink()) throw new Error(`${label} must not be a symbolic link`);
|
|
49
|
+
if (!info.isFile()) throw new Error(`${label} must be a regular file`);
|
|
50
|
+
await access(path, constants.R_OK | constants.W_OK);
|
|
51
|
+
return info;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function ensureParentIsSafe(path, workspace) {
|
|
55
|
+
let current = dirname(path);
|
|
56
|
+
while (current !== workspace && current.startsWith(`${workspace}/`)) {
|
|
57
|
+
try {
|
|
58
|
+
const info = await lstat(current);
|
|
59
|
+
if (info.isSymbolicLink()) throw new Error(`Parent path is a symbolic link: ${current}`);
|
|
60
|
+
if (!info.isDirectory()) throw new Error(`Parent path is not a directory: ${current}`);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
63
|
+
current = dirname(current);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
current = dirname(current);
|
|
69
|
+
}
|
|
70
|
+
if (current !== workspace) throw new Error(`Parent path escapes workspace: ${path}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function pathExists(path) {
|
|
74
|
+
try {
|
|
75
|
+
await lstat(path);
|
|
76
|
+
return true;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return false;
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function withPathQueues(paths, fn) {
|
|
84
|
+
const ordered = [...new Set(paths)].sort();
|
|
85
|
+
async function acquire(index) {
|
|
86
|
+
if (index === ordered.length) return fn();
|
|
87
|
+
return withFileMutationQueue(ordered[index], () => acquire(index + 1));
|
|
88
|
+
}
|
|
89
|
+
return acquire(0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildFileDetail(path, action, before, after, moveTo) {
|
|
93
|
+
const diff = generateDiffString(before, after);
|
|
94
|
+
const patch = generateUnifiedPatch(path, before, after);
|
|
95
|
+
return { path, action, moveTo, diff: diff.diff, patch, firstChangedLine: diff.firstChangedLine };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export default async function (pi) {
|
|
99
|
+
const settings = await loadSettings();
|
|
100
|
+
|
|
101
|
+
pi.registerTool({
|
|
102
|
+
name: "apply_patch",
|
|
103
|
+
label: "apply_patch",
|
|
104
|
+
description: "Apply a Codex-style patch. This is a FREEFORM tool, so provide raw patch text and never wrap it in JSON or a Markdown fence.",
|
|
105
|
+
promptSnippet: "Apply Codex-style freeform patches to add, delete, update, or move files",
|
|
106
|
+
promptGuidelines: [
|
|
107
|
+
"Use apply_patch for file changes and provide raw text between *** Begin Patch and *** End Patch.",
|
|
108
|
+
"Do not wrap apply_patch input in JSON or a Markdown code fence.",
|
|
109
|
+
],
|
|
110
|
+
parameters: Type.Object({
|
|
111
|
+
patch: Type.String({ description: "Raw Codex-style patch text" }),
|
|
112
|
+
}),
|
|
113
|
+
constrainedSampling: {
|
|
114
|
+
type: "grammar",
|
|
115
|
+
variants: { openai_lark: APPLY_PATCH_LARK_GRAMMAR },
|
|
116
|
+
},
|
|
117
|
+
executionMode: "sequential",
|
|
118
|
+
async execute(_toolCallId, { patch }, signal, _onUpdate, ctx) {
|
|
119
|
+
const operations = parsePatch(patch);
|
|
120
|
+
const resolved = [];
|
|
121
|
+
const targets = [];
|
|
122
|
+
for (const operation of operations) {
|
|
123
|
+
const source = resolveWorkspacePath(ctx.cwd, operation.path);
|
|
124
|
+
const destination = operation.moveTo ? resolveWorkspacePath(ctx.cwd, operation.moveTo) : undefined;
|
|
125
|
+
targets.push(source, ...(destination ? [destination] : []));
|
|
126
|
+
resolved.push({ ...operation, source, destination });
|
|
127
|
+
}
|
|
128
|
+
if (new Set(targets).size !== targets.length) throw new Error("Multiple patch operations target the same resolved path");
|
|
129
|
+
|
|
130
|
+
const committed = [];
|
|
131
|
+
return withPathQueues(targets, async () => {
|
|
132
|
+
const prepared = [];
|
|
133
|
+
for (const operation of resolved) {
|
|
134
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
135
|
+
await ensureParentIsSafe(operation.source, ctx.cwd);
|
|
136
|
+
if (operation.destination) await ensureParentIsSafe(operation.destination, ctx.cwd);
|
|
137
|
+
if (operation.action === "add") {
|
|
138
|
+
if (await pathExists(operation.source)) throw new Error(`Cannot add existing file ${operation.path}`);
|
|
139
|
+
prepared.push({ ...operation, before: "", after: operation.content });
|
|
140
|
+
} else {
|
|
141
|
+
await fileInfo(operation.source, `Source file ${operation.path}`);
|
|
142
|
+
const before = await readFile(operation.source, "utf8");
|
|
143
|
+
const after = operation.action === "delete" ? "" : applyUpdate(before, operation.chunks, operation.path);
|
|
144
|
+
if (operation.destination && await pathExists(operation.destination)) {
|
|
145
|
+
throw new Error(`Cannot move over existing file ${operation.moveTo}`);
|
|
146
|
+
}
|
|
147
|
+
prepared.push({ ...operation, before, after });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const operation of prepared) {
|
|
152
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
153
|
+
if (operation.action === "add") {
|
|
154
|
+
await mkdir(dirname(operation.source), { recursive: true });
|
|
155
|
+
await writeFile(operation.source, operation.after, "utf8");
|
|
156
|
+
} else if (operation.action === "delete") {
|
|
157
|
+
await unlink(operation.source);
|
|
158
|
+
} else if (operation.destination) {
|
|
159
|
+
await mkdir(dirname(operation.destination), { recursive: true });
|
|
160
|
+
await writeFile(operation.destination, operation.after, "utf8");
|
|
161
|
+
await unlink(operation.source);
|
|
162
|
+
} else {
|
|
163
|
+
await writeFile(operation.source, operation.after, "utf8");
|
|
164
|
+
}
|
|
165
|
+
committed.push(operation);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const files = committed.map((operation) => buildFileDetail(
|
|
169
|
+
operation.path,
|
|
170
|
+
operation.destination ? "move" : operation.action,
|
|
171
|
+
operation.before,
|
|
172
|
+
operation.after,
|
|
173
|
+
operation.moveTo,
|
|
174
|
+
));
|
|
175
|
+
return {
|
|
176
|
+
content: [{ type: "text", text: `Applied ${committed.length} file operation(s): ${committed.map((operation) => operation.path).join(", ")}` }],
|
|
177
|
+
details: {
|
|
178
|
+
files,
|
|
179
|
+
committedPaths: committed.map((operation) => operation.path),
|
|
180
|
+
exact: true,
|
|
181
|
+
combinedPatch: files.map((file) => file.patch).join("\n"),
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}).catch((error) => {
|
|
185
|
+
const prefix = committed.length ? ` Committed before failure: ${committed.map((operation) => operation.path).join(", ")}.` : "";
|
|
186
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}${prefix}`);
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
renderCall(args, theme, context) {
|
|
190
|
+
const component = context.lastComponent || new Text("", 0, 0);
|
|
191
|
+
const patch = typeof args?.patch === "string" ? args.patch : "";
|
|
192
|
+
const files = [...patch.matchAll(/\*\*\* (?:Add|Delete|Update) File: ([^\n]+)/g)].map((match) => match[1]);
|
|
193
|
+
component.setText(theme.fg("toolTitle", "apply_patch") + theme.fg("muted", files.length ? ` ${files.join(", ")}` : " patch"));
|
|
194
|
+
return component;
|
|
195
|
+
},
|
|
196
|
+
renderResult(result, options, theme, context) {
|
|
197
|
+
const details = result?.details;
|
|
198
|
+
const component = context.lastComponent || new Text("", 0, 0);
|
|
199
|
+
if (context.isError) {
|
|
200
|
+
component.setText(theme.fg("error", result?.content?.map((item) => item.text || "").join("\n") || "apply_patch failed"));
|
|
201
|
+
return component;
|
|
202
|
+
}
|
|
203
|
+
const files = details?.files || [];
|
|
204
|
+
let text = theme.fg("success", `Applied ${files.length} file operation(s)`);
|
|
205
|
+
if (options.expanded) {
|
|
206
|
+
for (const file of files) {
|
|
207
|
+
text += `\n${theme.fg("toolTitle", file.action)} ${file.path}`;
|
|
208
|
+
if (file.diff) text += `\n${theme.fg("dim", file.diff)}`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
component.setText(text);
|
|
212
|
+
return component;
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
pi.on("session_start", (_event, ctx) => syncEditorTools(pi, ctx, ctx.model, settings));
|
|
217
|
+
pi.on("model_select", (event, ctx) => syncEditorTools(pi, ctx, event.model, settings));
|
|
218
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function globMatches(value, pattern) {
|
|
2
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*");
|
|
3
|
+
return new RegExp(`^${escaped}$`).test(value);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function modelIsAllowed(model, settings) {
|
|
7
|
+
if (!settings.enabled || !model) return false;
|
|
8
|
+
if (model.api !== "openai-responses" && model.api !== "openai-codex-responses") return false;
|
|
9
|
+
return (settings.models || []).some((pattern) =>
|
|
10
|
+
typeof pattern === "string" && globMatches(model.id, pattern),
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { relative, resolve, sep } from "node:path";
|
|
2
|
+
|
|
3
|
+
export const APPLY_PATCH_LARK_GRAMMAR = `start: begin_patch hunk+ end_patch
|
|
4
|
+
begin_patch: "*** Begin Patch" LF
|
|
5
|
+
end_patch: "*** End Patch" LF?
|
|
6
|
+
hunk: add_hunk | delete_hunk | update_hunk
|
|
7
|
+
add_hunk: "*** Add File: " filename LF add_line+
|
|
8
|
+
delete_hunk: "*** Delete File: " filename LF
|
|
9
|
+
update_hunk: "*** Update File: " filename LF change_move? change?
|
|
10
|
+
filename: /(.+)/
|
|
11
|
+
add_line: "+" /(.*)/ LF -> line
|
|
12
|
+
change_move: "*** Move to: " filename LF
|
|
13
|
+
change: (change_context | change_line)+ eof_line?
|
|
14
|
+
change_context: ("@@" | "@@ " /(.+)/) LF
|
|
15
|
+
change_line: ("+" | "-" | " ") /(.*)/ LF
|
|
16
|
+
eof_line: "*** End of File" LF
|
|
17
|
+
%import common.LF`;
|
|
18
|
+
|
|
19
|
+
export function parsePatch(patch) {
|
|
20
|
+
const lines = patch.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim().split("\n");
|
|
21
|
+
if (lines[0]?.trim() !== "*** Begin Patch") throw new Error("The first line of the patch must be '*** Begin Patch'");
|
|
22
|
+
if (lines.at(-1)?.trim() !== "*** End Patch") throw new Error("The last line of the patch must be '*** End Patch'");
|
|
23
|
+
const operations = [];
|
|
24
|
+
let index = 1;
|
|
25
|
+
while (index < lines.length - 1) {
|
|
26
|
+
const line = lines[index];
|
|
27
|
+
if (line.startsWith("*** Add File: ")) {
|
|
28
|
+
const path = line.slice("*** Add File: ".length);
|
|
29
|
+
index += 1;
|
|
30
|
+
const content = [];
|
|
31
|
+
while (index < lines.length - 1 && !lines[index].startsWith("*** ")) {
|
|
32
|
+
if (!lines[index].startsWith("+")) throw new Error(`Invalid add line ${index + 1}`);
|
|
33
|
+
content.push(lines[index].slice(1));
|
|
34
|
+
index += 1;
|
|
35
|
+
}
|
|
36
|
+
if (content.length === 0) throw new Error(`Add file hunk for path '${path}' is empty`);
|
|
37
|
+
operations.push({ action: "add", path, content: `${content.join("\n")}\n` });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (line.startsWith("*** Delete File: ")) {
|
|
41
|
+
const path = line.slice("*** Delete File: ".length);
|
|
42
|
+
if (!path) throw new Error(`Delete file hunk on line ${index + 1} has an empty path`);
|
|
43
|
+
operations.push({ action: "delete", path });
|
|
44
|
+
index += 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (line.startsWith("*** Update File: ")) {
|
|
48
|
+
const path = line.slice("*** Update File: ".length);
|
|
49
|
+
index += 1;
|
|
50
|
+
let moveTo;
|
|
51
|
+
if (lines[index]?.startsWith("*** Move to: ")) {
|
|
52
|
+
moveTo = lines[index].slice("*** Move to: ".length);
|
|
53
|
+
index += 1;
|
|
54
|
+
}
|
|
55
|
+
const chunks = [];
|
|
56
|
+
while (index < lines.length - 1 && !lines[index].startsWith("*** ")) {
|
|
57
|
+
let context;
|
|
58
|
+
if (lines[index] === "@@" || lines[index].startsWith("@@ ")) {
|
|
59
|
+
context = lines[index].slice(2).trim() || undefined;
|
|
60
|
+
index += 1;
|
|
61
|
+
}
|
|
62
|
+
const oldLines = [];
|
|
63
|
+
const newLines = [];
|
|
64
|
+
const contextLineIndices = [];
|
|
65
|
+
while (index < lines.length - 1 && !lines[index].startsWith("*** ") && !lines[index].startsWith("@@")) {
|
|
66
|
+
const change = lines[index];
|
|
67
|
+
const marker = change[0];
|
|
68
|
+
if (!["+", "-", " "].includes(marker)) throw new Error(`Invalid update line ${index + 1}`);
|
|
69
|
+
const text = change.slice(1);
|
|
70
|
+
if (marker === "+") newLines.push(text);
|
|
71
|
+
else if (marker === "-") oldLines.push(text);
|
|
72
|
+
else {
|
|
73
|
+
contextLineIndices.push([oldLines.length, newLines.length]);
|
|
74
|
+
oldLines.push(text);
|
|
75
|
+
newLines.push(text);
|
|
76
|
+
}
|
|
77
|
+
index += 1;
|
|
78
|
+
}
|
|
79
|
+
const endOfFile = lines[index] === "*** End of File";
|
|
80
|
+
if (endOfFile) index += 1;
|
|
81
|
+
if (oldLines.length === 0 && newLines.length === 0) throw new Error(`Empty update chunk on line ${index + 1}`);
|
|
82
|
+
chunks.push({ context, oldLines, newLines, contextLineIndices, endOfFile });
|
|
83
|
+
}
|
|
84
|
+
if (chunks.length === 0) throw new Error(`Update file hunk for path '${path}' is empty`);
|
|
85
|
+
operations.push({ action: "update", path, moveTo, chunks });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
throw new Error(`Invalid hunk header on line ${index + 1}: ${line}`);
|
|
89
|
+
}
|
|
90
|
+
if (operations.length === 0) throw new Error("Patch contains no file operations");
|
|
91
|
+
const targets = operations.flatMap((operation) => [operation.path, operation.moveTo].filter(Boolean));
|
|
92
|
+
if (new Set(targets).size !== targets.length) throw new Error("Multiple operations target the same path");
|
|
93
|
+
return operations;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeLineEndings(text) {
|
|
97
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function findCandidates(lines, pattern, start, eof, compare) {
|
|
101
|
+
const candidates = [];
|
|
102
|
+
for (let index = start; index + pattern.length <= lines.length; index += 1) {
|
|
103
|
+
if (pattern.every((line, offset) => compare(lines[index + offset], line))) candidates.push(index);
|
|
104
|
+
}
|
|
105
|
+
candidates.sort((left, right) => eof ? right - left : left - right);
|
|
106
|
+
return candidates;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function locate(lines, pattern, start, eof, label) {
|
|
110
|
+
if (pattern.length === 0) return Math.min(start, lines.length);
|
|
111
|
+
const comparisons = [
|
|
112
|
+
(left, right) => left === right,
|
|
113
|
+
(left, right) => left.trimEnd() === right.trimEnd(),
|
|
114
|
+
(left, right) => left.trim() === right.trim(),
|
|
115
|
+
];
|
|
116
|
+
for (const compare of comparisons) {
|
|
117
|
+
const candidates = findCandidates(lines, pattern, start, eof, compare);
|
|
118
|
+
if (candidates.length === 1) return candidates[0];
|
|
119
|
+
if (candidates.length > 1) throw new Error(`Ambiguous ${label || "patch"} match: ${candidates.length} regions found`);
|
|
120
|
+
}
|
|
121
|
+
throw new Error(`Failed to find expected lines${label ? ` in ${label}` : ""}: ${pattern.join("\\n")}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function applyUpdate(content, chunks, path) {
|
|
125
|
+
const bom = content.startsWith("\ufeff") ? "\ufeff" : "";
|
|
126
|
+
const withoutBom = bom ? content.slice(1) : content;
|
|
127
|
+
const ending = withoutBom.includes("\r\n") ? "\r\n" : "\n";
|
|
128
|
+
const normalized = normalizeLineEndings(withoutBom);
|
|
129
|
+
const hadFinalNewline = normalized.endsWith("\n");
|
|
130
|
+
const lines = normalized === "" ? [] : normalized.split("\n");
|
|
131
|
+
if (hadFinalNewline) lines.pop();
|
|
132
|
+
const replacements = [];
|
|
133
|
+
let cursor = 0;
|
|
134
|
+
for (const chunk of chunks) {
|
|
135
|
+
if (chunk.context !== undefined) cursor = locate(lines, [chunk.context], cursor, false, path) + 1;
|
|
136
|
+
const start = locate(lines, chunk.oldLines, cursor, chunk.endOfFile, path);
|
|
137
|
+
replacements.push({ start, length: chunk.oldLines.length, next: chunk.newLines });
|
|
138
|
+
cursor = start + chunk.oldLines.length;
|
|
139
|
+
}
|
|
140
|
+
const output = [...lines];
|
|
141
|
+
for (const replacement of [...replacements].reverse()) output.splice(replacement.start, replacement.length, ...replacement.next);
|
|
142
|
+
let next = output.join("\n");
|
|
143
|
+
if (hadFinalNewline || next.length > 0) next += "\n";
|
|
144
|
+
if (ending === "\r\n") next = next.replace(/\n/g, "\r\n");
|
|
145
|
+
return bom + next;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function resolveWorkspacePath(cwd, rawPath) {
|
|
149
|
+
if (!rawPath || rawPath.includes("\0")) throw new Error(`Unsafe patch path: ${rawPath}`);
|
|
150
|
+
const absolute = resolve(cwd, rawPath);
|
|
151
|
+
const rel = relative(cwd, absolute);
|
|
152
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`)) throw new Error(`Patch path escapes workspace: ${rawPath}`);
|
|
153
|
+
return absolute;
|
|
154
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# GPT-5.6 Edit Benchmark Final Report
|
|
2
|
+
|
|
3
|
+
Generated: 2026-09-04
|
|
4
|
+
|
|
5
|
+
## Isolation
|
|
6
|
+
|
|
7
|
+
All Pi runs used no-context-files, a temporary PI_CODING_AGENT_DIR, a dedicated session directory, fresh temporary workspaces, and no ambient extensions. The global /home/maxiaochao/.pi/agent/AGENTS.md was not loaded. The fair comparison gave both arms read, bash, and write; only the editor tool differed.
|
|
8
|
+
|
|
9
|
+
## Main comparison
|
|
10
|
+
|
|
11
|
+
| Metric | A current edit | C production apply_patch | Difference |
|
|
12
|
+
|---|---:|---:|---:|
|
|
13
|
+
| Fixed fixtures | 20 | 20 | same |
|
|
14
|
+
| Final exact correctness | 18/20 (90%) | 18/20 (90%) | 0 points |
|
|
15
|
+
| First editor success | 85% | 95% | +10 points |
|
|
16
|
+
| Median editor calls | 1 | 1 | same |
|
|
17
|
+
| Median recovery editor calls | 0 | 0 | same |
|
|
18
|
+
| Median agent turns | 4 | 4 | same |
|
|
19
|
+
| Median input+output tokens | 9732 | 9978 | +2.5% |
|
|
20
|
+
| Median all tokens including cache | 26722 | 26503 | -0.8% |
|
|
21
|
+
| Median duration | 17.75s | 16.95s | -4.5% |
|
|
22
|
+
| Protocol failures | 1 | 0 | improved |
|
|
23
|
+
| Context files loaded | 0 | 0 | valid |
|
|
24
|
+
|
|
25
|
+
A/B prompt exploration with the restricted read+edit tool set produced 75% final correctness for both current and neutral prompt arms. That result is not used as the main decision because it excluded the real Pi write and bash tools. It is useful only as evidence that the current edit contract cannot express add/delete/move operations by itself.
|
|
26
|
+
|
|
27
|
+
## What apply_patch improved
|
|
28
|
+
|
|
29
|
+
- GPT-5.6 emitted valid first-pass custom-tool calls in 19 of 20 fair cases; the remaining case used another available tool instead of apply_patch.
|
|
30
|
+
- The production route eliminated the observed editor protocol failure in the fair set.
|
|
31
|
+
- The Codex protocol naturally handles add, delete, update, and move in one patch, but the fair baseline could often complete those tasks through write/bash, so final correctness did not improve.
|
|
32
|
+
- Custom Lark tool encoding was verified through persisted apply_patch calls with ctc_ identifiers.
|
|
33
|
+
|
|
34
|
+
## What it did not improve
|
|
35
|
+
|
|
36
|
+
- Exact final correctness remained 90% for both arms.
|
|
37
|
+
- Median editor calls and agent turns were unchanged.
|
|
38
|
+
- Input/output tokens were slightly higher; cache-inclusive token totals were effectively unchanged.
|
|
39
|
+
- Median duration improved only 4.5%, below the 15% production threshold.
|
|
40
|
+
- Codex CLI could not serve as a reference arm because the proxy returned HTTP 401 even when retried with the Pi-configured key.
|
|
41
|
+
|
|
42
|
+
## Dynamic routing
|
|
43
|
+
|
|
44
|
+
The production extension route probe passed:
|
|
45
|
+
|
|
46
|
+
- GPT-5.6 Sol: active tools included read and apply_patch, with grammar capability true.
|
|
47
|
+
- DeepSeek V4 Flash: active tools included read and edit, with grammar capability false.
|
|
48
|
+
- The unrelated active tools were preserved and no extension errors occurred after fixing the settings closure bug.
|
|
49
|
+
|
|
50
|
+
The probe conditions above reflect v0.1.5, when the route still required grammar capability. Since v0.1.6 the route is `model.api` plus the model-ID allowlist only; a provider without grammar tools falls back to a normal function tool.
|
|
51
|
+
|
|
52
|
+
## Decision
|
|
53
|
+
|
|
54
|
+
No-go for universal enablement in the original benchmark environment. The evidence did not justify routing every model through `apply_patch` or making it the default editor for the whole Pi workflow. The shipped route is therefore a model-ID allowlist (`gpt-5.6-*` plus an `openai-responses`/`openai-codex-responses` API check) that keeps `edit` as the fallback everywhere else. The implementation now ships inside `@maxiaochao/pi-toolkit` instead of as a standalone opt-in package, but the allowlist — not the packaging — is what bounds the change: for a non-allowlisted model the extension only re-syncs an already-active editor tool and otherwise returns early.
|
|
55
|
+
|
|
56
|
+
The production route was validated with `pudu-openai-proxy/gpt-5.6-sol:high` and `pudu-openai-proxy/gpt-5.6-luna:high`. Sol's 20-case fair run improved first editor-call success from 85% to 95% with equal final correctness. A corrected Luna rerun used the fully qualified `pudu-openai-proxy/gpt-5.6-luna:high` in both arms, with 20/20 normally completed runs in each arm: both reached 18/20 final correctness, while first editor-call success was 18/20 for `edit` and 20/20 for `apply_patch`. Among the 17 cases correct in both arms, the paired median total-duration delta was +2.3s for `apply_patch`, with 8 cases faster and 9 slower; across all 20 paired runs, the median delta was +1.2s, with 9 cases faster and 11 slower. The measured tradeoff justified validating Sol first; the shipped `gpt-5.6-*` glob additionally enables Luna and Terra, whose gap is protocol adaptation rather than a stable duration win.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { modelIsAllowed } from "../model-routing.ts";
|
|
6
|
+
|
|
7
|
+
const extensionDir = dirname(dirname(new URL(import.meta.url).pathname));
|
|
8
|
+
|
|
9
|
+
test("config is next to the extension entry file", async () => {
|
|
10
|
+
const source = await readFile(join(extensionDir, "index.ts"), "utf8");
|
|
11
|
+
assert.match(source, /new URL\("\.\/config\.json", import\.meta\.url\)/);
|
|
12
|
+
assert.match(source, /model-routing\.ts/);
|
|
13
|
+
const config = JSON.parse(await readFile(join(extensionDir, "config.json"), "utf8"));
|
|
14
|
+
assert.equal(config.enabled, true);
|
|
15
|
+
assert.deepEqual(config.models, ["gpt-5.6-*"]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("matches allowed model IDs with globs independently of provider and grammar support", () => {
|
|
19
|
+
const settings = { enabled: true, models: ["gpt-5.6-*"] };
|
|
20
|
+
const baseModel = {
|
|
21
|
+
id: "gpt-5.6-terra",
|
|
22
|
+
api: "openai-codex-responses",
|
|
23
|
+
compat: { supportsOpenAIGrammarTools: true },
|
|
24
|
+
};
|
|
25
|
+
assert.equal(modelIsAllowed({ ...baseModel, provider: "openai-codex" }, settings), true);
|
|
26
|
+
assert.equal(modelIsAllowed({ ...baseModel, provider: "another-provider" }, settings), true);
|
|
27
|
+
assert.equal(modelIsAllowed({ ...baseModel, compat: { supportsOpenAIGrammarTools: false } }, settings), true);
|
|
28
|
+
assert.equal(
|
|
29
|
+
modelIsAllowed(
|
|
30
|
+
{ ...baseModel, api: "openai-responses", compat: { sessionAffinityFormat: "openai-nosession" } },
|
|
31
|
+
settings,
|
|
32
|
+
),
|
|
33
|
+
true,
|
|
34
|
+
);
|
|
35
|
+
assert.equal(modelIsAllowed({ ...baseModel, api: "openai-completions" }, settings), false);
|
|
36
|
+
assert.equal(modelIsAllowed({ ...baseModel, id: "gpt-5.5" }, settings), false);
|
|
37
|
+
assert.equal(modelIsAllowed(baseModel, { enabled: false, models: ["gpt-5.6-*"] }), false);
|
|
38
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { applyUpdate, parsePatch, resolveWorkspacePath } from "../parser.ts";
|
|
7
|
+
|
|
8
|
+
test("parses add, delete, update, and move operations", () => {
|
|
9
|
+
const operations = parsePatch(`*** Begin Patch
|
|
10
|
+
*** Add File: added.txt
|
|
11
|
+
+hello
|
|
12
|
+
*** Delete File: removed.txt
|
|
13
|
+
*** Update File: old.txt
|
|
14
|
+
*** Move to: new.txt
|
|
15
|
+
@@
|
|
16
|
+
-old
|
|
17
|
+
+new
|
|
18
|
+
*** End Patch`);
|
|
19
|
+
assert.deepEqual(operations.map((operation) => operation.action), ["add", "delete", "update"]);
|
|
20
|
+
assert.equal(operations[0].content, "hello\n");
|
|
21
|
+
assert.equal(operations[2].moveTo, "new.txt");
|
|
22
|
+
assert.deepEqual(operations[2].chunks[0].oldLines, ["old"]);
|
|
23
|
+
assert.deepEqual(operations[2].chunks[0].newLines, ["new"]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("rejects malformed patches and duplicate targets", () => {
|
|
27
|
+
assert.throws(() => parsePatch("bad"), /first line/);
|
|
28
|
+
assert.throws(() => parsePatch(`*** Begin Patch
|
|
29
|
+
*** Add File: same.txt
|
|
30
|
+
+x
|
|
31
|
+
*** Add File: same.txt
|
|
32
|
+
+y
|
|
33
|
+
*** End Patch`), /same path/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("applies multiple chunks using context and preserves BOM and CRLF", () => {
|
|
37
|
+
const before = "\ufefffunction run() {\r\n const value = 1;\r\n return value;\r\n}\r\n";
|
|
38
|
+
const operations = parsePatch(`*** Begin Patch
|
|
39
|
+
*** Update File: src/run.js
|
|
40
|
+
@@ function run() {
|
|
41
|
+
- const value = 1;
|
|
42
|
+
+ const value = 2;
|
|
43
|
+
@@
|
|
44
|
+
- return value;
|
|
45
|
+
+ return value + 1;
|
|
46
|
+
*** End Patch`);
|
|
47
|
+
const after = applyUpdate(before, operations[0].chunks, "src/run.js");
|
|
48
|
+
assert.equal(after, "\ufefffunction run() {\r\n const value = 2;\r\n return value + 1;\r\n}\r\n");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("supports insertion chunks and EOF matching", () => {
|
|
52
|
+
const operations = parsePatch(`*** Begin Patch
|
|
53
|
+
*** Update File: file.txt
|
|
54
|
+
@@
|
|
55
|
+
+first
|
|
56
|
+
*** End of File
|
|
57
|
+
*** End Patch`);
|
|
58
|
+
assert.equal(applyUpdate("", operations[0].chunks, "file.txt"), "first\n");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("rejects ambiguous matches and workspace escapes", () => {
|
|
62
|
+
const operations = parsePatch(`*** Begin Patch
|
|
63
|
+
*** Update File: file.txt
|
|
64
|
+
@@
|
|
65
|
+
-value
|
|
66
|
+
+changed
|
|
67
|
+
*** End Patch`);
|
|
68
|
+
assert.throws(() => applyUpdate("value\nvalue\n", operations[0].chunks, "file.txt"), /Ambiguous/);
|
|
69
|
+
assert.equal(resolveWorkspacePath("/tmp/project", "/tmp/project/src/a.js"), "/tmp/project/src/a.js");
|
|
70
|
+
assert.throws(() => resolveWorkspacePath("/tmp/project", "/tmp/other/a.js"), /escapes/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("round-trips a temporary file operation fixture", async () => {
|
|
74
|
+
const workspace = await mkdtemp(join(tmpdir(), "codex-edit-test-"));
|
|
75
|
+
try {
|
|
76
|
+
const path = join(workspace, "file.txt");
|
|
77
|
+
await writeFile(path, "before\n", "utf8");
|
|
78
|
+
const operations = parsePatch(`*** Begin Patch
|
|
79
|
+
*** Update File: file.txt
|
|
80
|
+
@@
|
|
81
|
+
-before
|
|
82
|
+
+after
|
|
83
|
+
*** End Patch`);
|
|
84
|
+
await writeFile(path, applyUpdate(await readFile(path, "utf8"), operations[0].chunks, "file.txt"), "utf8");
|
|
85
|
+
assert.equal(await readFile(path, "utf8"), "after\n");
|
|
86
|
+
} finally {
|
|
87
|
+
await rm(workspace, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import {
|
|
3
|
+
activeTodos,
|
|
4
|
+
applyTodoAction,
|
|
5
|
+
restoreTodoState,
|
|
6
|
+
} from "../todo-state.ts";
|
|
7
|
+
|
|
8
|
+
const empty = { todos: [], nextId: 1 };
|
|
9
|
+
|
|
10
|
+
const replaced = applyTodoAction(empty, {
|
|
11
|
+
action: "replace",
|
|
12
|
+
items: ["Inspect the failure", "Implement the fix", "Run verification"],
|
|
13
|
+
});
|
|
14
|
+
assert.deepEqual(
|
|
15
|
+
replaced.todos.map(({ id, text, status }) => ({ id, text, status })),
|
|
16
|
+
[
|
|
17
|
+
{ id: 1, text: "Inspect the failure", status: "doing" },
|
|
18
|
+
{ id: 2, text: "Implement the fix", status: "todo" },
|
|
19
|
+
{ id: 3, text: "Run verification", status: "todo" },
|
|
20
|
+
],
|
|
21
|
+
"replace installs a fresh ordered plan and starts its first task",
|
|
22
|
+
);
|
|
23
|
+
assert.equal(replaced.nextId, 4, "replace advances the ID sequence past the new plan");
|
|
24
|
+
|
|
25
|
+
const completed = applyTodoAction(replaced, { action: "update", id: 1, status: "done" });
|
|
26
|
+
assert.deepEqual(
|
|
27
|
+
completed.todos.map(({ id, status }) => ({ id, status })),
|
|
28
|
+
[
|
|
29
|
+
{ id: 1, status: "done" },
|
|
30
|
+
{ id: 2, status: "doing" },
|
|
31
|
+
{ id: 3, status: "todo" },
|
|
32
|
+
],
|
|
33
|
+
"finishing active work advances the next queued task",
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const cancelled = applyTodoAction(completed, { action: "update", id: 2, status: "cancelled" });
|
|
37
|
+
assert.equal(cancelled.todos[2].status, "doing", "cancelling active work advances the next queued task");
|
|
38
|
+
assert.deepEqual(activeTodos(cancelled.todos).map((todo) => todo.id), [3], "closed work is absent from the active view");
|
|
39
|
+
|
|
40
|
+
const restarted = applyTodoAction(cancelled, { action: "add", text: "Review the redirected implementation", status: "doing" });
|
|
41
|
+
assert.equal(restarted.todos.find((todo) => todo.id === 3).status, "todo", "starting another task demotes the previous active task");
|
|
42
|
+
assert.equal(restarted.todos.find((todo) => todo.id === 4).status, "doing", "the requested task becomes active");
|
|
43
|
+
|
|
44
|
+
const removed = applyTodoAction(restarted, { action: "remove", id: 4 });
|
|
45
|
+
assert.equal(removed.todos.find((todo) => todo.id === 3).status, "doing", "removing active work advances the queue");
|
|
46
|
+
assert.equal(removed.nextId, 5, "removing a task does not reuse its ID");
|
|
47
|
+
|
|
48
|
+
const duplicate = applyTodoAction(removed, { action: "add", text: "Run verification" });
|
|
49
|
+
assert.match(duplicate.error ?? "", /already exists/, "active duplicates are rejected");
|
|
50
|
+
assert.deepEqual(duplicate.todos, removed.todos, "a rejected mutation preserves the previous state");
|
|
51
|
+
|
|
52
|
+
const replacedAgain = applyTodoAction(removed, { action: "replace", items: ["New direction"] });
|
|
53
|
+
assert.deepEqual(
|
|
54
|
+
replacedAgain.todos,
|
|
55
|
+
[{ id: 5, text: "New direction", status: "doing" }],
|
|
56
|
+
"replace discards the obsolete plan without reusing an old task ID",
|
|
57
|
+
);
|
|
58
|
+
assert.equal(replacedAgain.nextId, 6);
|
|
59
|
+
|
|
60
|
+
const invalidReplace = applyTodoAction(replacedAgain, { action: "replace", items: [" "] });
|
|
61
|
+
assert.ok(invalidReplace.error, "replace rejects empty task text");
|
|
62
|
+
assert.deepEqual(invalidReplace.todos, replacedAgain.todos, "invalid replacement is atomic");
|
|
63
|
+
|
|
64
|
+
const restored = restoreTodoState([
|
|
65
|
+
{ id: 4, text: "legacy complete", done: true },
|
|
66
|
+
{ id: 2, text: "first active", status: "doing" },
|
|
67
|
+
{ id: 3, text: "second active", status: "doing" },
|
|
68
|
+
], 2);
|
|
69
|
+
assert.deepEqual(
|
|
70
|
+
restored.todos.map(({ id, status }) => ({ id, status })),
|
|
71
|
+
[
|
|
72
|
+
{ id: 2, status: "doing" },
|
|
73
|
+
{ id: 3, status: "todo" },
|
|
74
|
+
{ id: 4, status: "done" },
|
|
75
|
+
],
|
|
76
|
+
"restoration migrates legacy entries and enforces one active task",
|
|
77
|
+
);
|
|
78
|
+
assert.equal(restored.nextId, 5, "restoration repairs stale next IDs");
|
|
79
|
+
|
|
80
|
+
const cleared = applyTodoAction(restored, { action: "clear" });
|
|
81
|
+
assert.deepEqual(cleared.todos, []);
|
|
82
|
+
assert.equal(cleared.nextId, 1, "clear resets the ID sequence");
|
|
83
|
+
|
|
84
|
+
console.log("Todo state tests passed");
|