@maxiaochao/pi-toolkit 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +7 -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/package.json +6 -5
- package/skills/tapd/SKILL.md +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2026-09-30 - v0.9.0
|
|
2
|
+
|
|
3
|
+
- merge the Codex-style apply_patch extension into the toolkit (from the standalone @maxiaochao/pi-codex-edit v0.1.6): it now ships as extensions/codex-edit/ registered in pi.extensions, its parser and model-routing tests join npm test, and the separate package, release script and publish workflow are removed
|
|
4
|
+
|
|
5
|
+
## 2026-09-24 - v0.8.2
|
|
6
|
+
|
|
7
|
+
- tapd skill: document the safe attachment-download flow for the short-lived signed URLs (re-fetch the URL for every attempt, download to .part with curl --fail --location --retry 3, reject text/error bodies before resuming, accept only 206 with a matching Content-Range, then verify size and zip integrity before renaming); it is a local addition on top of the `tapd skill init` output, recorded in AGENTS.md
|
|
8
|
+
|
|
1
9
|
## 2026-09-24 - v0.8.1
|
|
2
10
|
|
|
3
11
|
- lowercase the herdr skill name in its description and H1 (a deliberate local edit over the upstream v0.9.1 copy, now recorded in NOTICE, README and AGENTS.md); drop the stale `herdr --skill` pointer from the agent-team skill
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-toolkit
|
|
2
2
|
|
|
3
|
-
Personal Pi toolkit with the `/todos`, `/btw`, and
|
|
3
|
+
Personal Pi toolkit with the `/todos`, `/btw`, `/cache_export`, and `apply_patch` extensions, bundled agent skills including Chrome/Chromium automation, the Night Owl theme, and the `pi-worktree` CLI.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -87,6 +87,8 @@ The bundled `pdlog` skill decompresses and reads Pudu `.pdlog` proprietary log f
|
|
|
87
87
|
|
|
88
88
|
The bundled `herdr` skill (from [herdrdev/herdr](https://github.com/herdrdev/herdr) at tag `v0.9.1` with the skill name lowercased, Apache-2.0) drives Herdr, a terminal multiplexer for coding agents: inspecting workspaces, tabs, and panes, starting and prompting other agents, running commands in background panes without stealing focus, reading pane output, and waiting on agent lifecycle states. It activates only when you explicitly mention Herdr, and only when the agent itself runs inside a Herdr pane (`HERDR_ENV=1`); outside a pane it refuses to control the session and stops.
|
|
89
89
|
|
|
90
|
+
The bundled `agent-team` skill runs cross-vendor adversarial review: the current session acts as manager, and the roster in `skills/agent-team/models.json` (one auditor, one replicator, one adversary on different vendors) is started as long-lived herdr panes that get re-prompted round after round until no pane can produce a new finding.
|
|
91
|
+
|
|
90
92
|
The bundled `tapd` skill documents the third-party `tapd` CLI ([studyzy/tapd-ai-cli](https://github.com/studyzy/tapd-ai-cli)) for TAPD: requirements, defects, tasks, iterations, wikis, comments, attachments, timesheets, and workflow status maps, including the `--filter` advanced query syntax. It is the CLI's own command reference, so refresh the file with `tapd skill init` when the CLI changes.
|
|
91
93
|
|
|
92
94
|
The bundled `calldiff` skill (copied verbatim from [tanishqkancharla/calldiff](https://github.com/tanishqkancharla/calldiff), MIT, pinned to a `main` commit because upstream has no release tags) teaches call-stack diffs across git commits: `diff` shows which callees appeared, disappeared, or moved under an entrypoint, `tree` prints one call tree, and `reach` finds call paths between two symbols, all with `--entry`/`--file` entrypoint selection, `--locs` source locations, and `--format json` for agents.
|
|
@@ -108,21 +110,13 @@ pi update npm:@maxiaochao/pi-tiny-subagent
|
|
|
108
110
|
|
|
109
111
|
See [packages/tiny-subagent/README.md](packages/tiny-subagent/README.md) for personas, model selection, child isolation, live transcript rendering, and diagnostics.
|
|
110
112
|
|
|
111
|
-
## Codex Edit
|
|
112
|
-
|
|
113
|
-
The Codex edit package is a separate npm package. Install it when you want GPT/Codex models to use the freeform `apply_patch` protocol while other models keep Pi's native `edit` tool:
|
|
113
|
+
## Codex Edit (apply_patch)
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
pi install npm:@maxiaochao/pi-codex-edit
|
|
117
|
-
```
|
|
115
|
+
The `apply_patch` extension ships with the toolkit as part of `pi.extensions`. It activates Codex's freeform `apply_patch` protocol for GPT/Codex models while other models keep Pi's native `edit` tool. Routing is by model ID (`gpt-5.6-*`) plus API (`openai-responses` or `openai-codex-responses`); provider does not matter. For every other model the extension only re-syncs an already-active editor tool, so it is a no-op.
|
|
118
116
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
```bash
|
|
122
|
-
pi update npm:@maxiaochao/pi-codex-edit
|
|
123
|
-
```
|
|
117
|
+
Because it ships with the toolkit, there is nothing extra to install or update — `pi update npm:@maxiaochao/pi-toolkit` covers it. The model allowlist lives in `extensions/codex-edit/config.json` inside the installed package; edit it there only if you need a different allowlist.
|
|
124
118
|
|
|
125
|
-
See [
|
|
119
|
+
See [extensions/codex-edit/README.md](extensions/codex-edit/README.md) for the routing rules and [extensions/codex-edit/summary.md](extensions/codex-edit/summary.md) for the measured tradeoffs, including why it stays limited to the `gpt-5.6-*` allowlist instead of replacing `edit` everywhere.
|
|
126
120
|
|
|
127
121
|
## Development
|
|
128
122
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Codex Edit (apply_patch)
|
|
2
|
+
|
|
3
|
+
A Pi extension bundled with `@maxiaochao/pi-toolkit` that exposes Codex-style `apply_patch` for GPT/Codex models and keeps Pi's native `edit` tool for other models.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Bundled with the toolkit; install or update the root package and the extension comes along:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install npm:@maxiaochao/pi-toolkit
|
|
11
|
+
pi update npm:@maxiaochao/pi-toolkit
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The extension registers `apply_patch` and selects the editing tool from the resolved model configuration:
|
|
15
|
+
|
|
16
|
+
- Allowlisted GPT/Codex model: `apply_patch`
|
|
17
|
+
- Other models: Pi's native `edit`
|
|
18
|
+
|
|
19
|
+
The default allowlist enables all `gpt-5.6-*` models regardless of provider. Other model families can be added after separate validation by editing `config.json`.
|
|
20
|
+
|
|
21
|
+
The extension does not add a permanent AGENTS rule telling models not to use `apply_patch`. Only the selected editor tool is active, so the model receives one editing protocol.
|
|
22
|
+
|
|
23
|
+
## What `apply_patch` does
|
|
24
|
+
|
|
25
|
+
The tool accepts raw Codex patch text between `*** Begin Patch` and `*** End Patch`. It supports:
|
|
26
|
+
|
|
27
|
+
- `Add File`
|
|
28
|
+
- `Delete File`
|
|
29
|
+
- `Update File`
|
|
30
|
+
- `Move to`
|
|
31
|
+
- multiple update hunks with `@@` context
|
|
32
|
+
- EOF-oriented insertions
|
|
33
|
+
|
|
34
|
+
Before writing, it parses the complete patch, resolves paths, rejects workspace escapes and unsafe symlink/parent paths, reads all source files, and computes every update in memory. Only after preflight succeeds does it mutate files. It returns per-file diff details and reports committed paths if a later filesystem operation fails.
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
The extension defaults to a model-ID allowlist in `config.json`: `"gpt-5.6-*"`. Provider does not affect routing. Each entry is a `*` glob, so this matches every `gpt-5.6-` variant. The route additionally requires:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
model.api = openai-responses or openai-codex-responses
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Grammar support is not part of the route. When a provider does not emit Lark grammar tools, Pi falls back to a normal function tool, so `apply_patch` still runs there — only the grammar-enforced output shape is lost.
|
|
45
|
+
|
|
46
|
+
Edit `extensions/codex-edit/config.json` inside the installed `@maxiaochao/pi-toolkit` package only if you need to change the default allowlist. The implementation keeps both tools registered for session replay and changes only the active tool set on `session_start` and `model_select`.
|
|
47
|
+
|
|
48
|
+
## Documentation
|
|
49
|
+
|
|
50
|
+
- `summary.md`: benchmark summary and decision record.
|
|
51
|
+
- `codex-edit-explainer.html`: self-contained interactive architecture and configuration explainer.
|
|
52
|
+
|
|
53
|
+
The benchmark found equal final correctness in the fair 20-case comparison, with first editor-call success improving from 85% to 95%. Overall tool-call count and cache-inclusive token use were effectively unchanged, so the route stays limited to the GPT/Codex allowlist rather than replacing `edit` universally.
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Pi apply_patch 方案说明</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--ink: #17221f;
|
|
10
|
+
--muted: #65736d;
|
|
11
|
+
--paper: #f5f7f2;
|
|
12
|
+
--panel: #ffffff;
|
|
13
|
+
--line: #d9e1db;
|
|
14
|
+
--green: #0e6b50;
|
|
15
|
+
--green-soft: #dcefe7;
|
|
16
|
+
--amber: #a96518;
|
|
17
|
+
--amber-soft: #fff0d9;
|
|
18
|
+
--red: #a33c32;
|
|
19
|
+
--red-soft: #fde5e0;
|
|
20
|
+
--blue: #245f8c;
|
|
21
|
+
--blue-soft: #e2eff8;
|
|
22
|
+
--shadow: 0 12px 32px rgba(23, 34, 31, .08);
|
|
23
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
24
|
+
}
|
|
25
|
+
* { box-sizing: border-box; }
|
|
26
|
+
html { scroll-behavior: smooth; }
|
|
27
|
+
body { margin: 0; color: var(--ink); background: var(--paper); line-height: 1.55; }
|
|
28
|
+
button { font: inherit; }
|
|
29
|
+
.topbar { position: sticky; top: 0; z-index: 5; background: rgba(245,247,242,.94); border-bottom: 1px solid var(--line); backdrop-filter: blur(12px); }
|
|
30
|
+
.topbar-inner { max-width: 1180px; margin: auto; padding: 14px 26px; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
|
31
|
+
.brand { font-weight: 800; letter-spacing: .02em; }
|
|
32
|
+
.brand span { color: var(--green); }
|
|
33
|
+
.nav { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
|
34
|
+
.nav a { color: var(--muted); text-decoration: none; padding: 6px 10px; border-radius: 7px; font-size: 13px; }
|
|
35
|
+
.nav a:hover, .nav a:focus-visible { color: var(--green); background: var(--green-soft); outline: none; }
|
|
36
|
+
main { max-width: 1180px; margin: auto; padding: 58px 26px 90px; }
|
|
37
|
+
.eyebrow { color: var(--green); font-size: 12px; text-transform: uppercase; letter-spacing: .14em; font-weight: 800; }
|
|
38
|
+
h1 { font-size: clamp(34px, 5vw, 66px); line-height: 1.03; max-width: 860px; margin: 12px 0 18px; letter-spacing: -.035em; }
|
|
39
|
+
.lede { max-width: 760px; font-size: 19px; color: #42514a; margin: 0; }
|
|
40
|
+
.hero-grid { display: grid; grid-template-columns: 1.25fr .75fr; gap: 26px; align-items: end; }
|
|
41
|
+
.signal { border-left: 4px solid var(--amber); padding: 18px 20px; background: var(--amber-soft); }
|
|
42
|
+
.signal strong { display: block; font-size: 18px; margin-bottom: 5px; }
|
|
43
|
+
.signal p { margin: 0; color: #66431f; font-size: 14px; }
|
|
44
|
+
.section { margin-top: 70px; }
|
|
45
|
+
.section-head { display: flex; justify-content: space-between; align-items: end; gap: 20px; margin-bottom: 20px; }
|
|
46
|
+
h2 { font-size: 28px; line-height: 1.12; margin: 0; letter-spacing: -.025em; }
|
|
47
|
+
h3 { margin: 0 0 8px; font-size: 17px; }
|
|
48
|
+
.section-note { max-width: 560px; color: var(--muted); margin: 0; font-size: 14px; }
|
|
49
|
+
.metrics { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
50
|
+
.metric { padding: 21px 18px; border-right: 1px solid var(--line); }
|
|
51
|
+
.metric:last-child { border-right: 0; }
|
|
52
|
+
.metric .value { font-size: 31px; font-weight: 800; line-height: 1.1; }
|
|
53
|
+
.metric .label { color: var(--muted); font-size: 13px; margin-top: 5px; }
|
|
54
|
+
.metric.good .value { color: var(--green); }
|
|
55
|
+
.metric.neutral .value { color: var(--blue); }
|
|
56
|
+
.metric.warn .value { color: var(--amber); }
|
|
57
|
+
.explain { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
|
58
|
+
.panel { background: var(--panel); border: 1px solid var(--line); box-shadow: var(--shadow); padding: 23px; }
|
|
59
|
+
.panel p:last-child { margin-bottom: 0; }
|
|
60
|
+
.badge { display: inline-flex; align-items: center; gap: 7px; padding: 4px 9px; border-radius: 999px; font-weight: 750; font-size: 12px; }
|
|
61
|
+
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
|
62
|
+
.badge.amber { color: var(--amber); background: var(--amber-soft); }
|
|
63
|
+
.badge.green { color: var(--green); background: var(--green-soft); }
|
|
64
|
+
.badge.red { color: var(--red); background: var(--red-soft); }
|
|
65
|
+
.callout { margin-top: 16px; padding: 15px 17px; border-left: 3px solid var(--blue); background: var(--blue-soft); color: #244961; font-size: 14px; }
|
|
66
|
+
.callout code { color: var(--blue); }
|
|
67
|
+
code, pre { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; }
|
|
68
|
+
code { background: #edf1ee; padding: 2px 5px; border-radius: 4px; font-size: .92em; }
|
|
69
|
+
pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: #d8eee5; background: #172522; padding: 20px; border-radius: 7px; font-size: 13px; line-height: 1.65; }
|
|
70
|
+
.code-wrap { position: relative; }
|
|
71
|
+
.copy { position: absolute; right: 10px; top: 10px; border: 1px solid #527269; color: #d8eee5; background: #20352f; padding: 5px 8px; border-radius: 5px; cursor: pointer; font-size: 12px; }
|
|
72
|
+
.copy:hover, .copy:focus-visible { background: #2d4c42; outline: 2px solid #97cdb8; outline-offset: 2px; }
|
|
73
|
+
.tabs { display: flex; gap: 8px; flex-wrap: wrap; border-bottom: 1px solid var(--line); margin-bottom: 18px; }
|
|
74
|
+
.tab { border: 0; background: transparent; color: var(--muted); cursor: pointer; padding: 10px 3px; margin-right: 14px; border-bottom: 3px solid transparent; font-weight: 750; }
|
|
75
|
+
.tab[aria-selected="true"] { color: var(--green); border-color: var(--green); }
|
|
76
|
+
.tab:focus-visible { outline: 2px solid var(--green); outline-offset: 3px; }
|
|
77
|
+
.tab-panel { display: none; }
|
|
78
|
+
.tab-panel.active { display: block; }
|
|
79
|
+
.architecture { background: #e9f0eb; border: 1px solid var(--line); padding: 24px; overflow-x: auto; }
|
|
80
|
+
.arch-row { min-width: 900px; display: grid; grid-template-columns: 1fr 36px 1.15fr 36px 1.25fr 36px 1fr; align-items: stretch; gap: 10px; }
|
|
81
|
+
.arch-node { min-height: 124px; background: var(--panel); border: 1px solid #c6d4ca; padding: 16px; position: relative; }
|
|
82
|
+
.arch-node small { color: var(--muted); display: block; font-size: 11px; text-transform: uppercase; letter-spacing: .09em; margin-bottom: 9px; font-weight: 800; }
|
|
83
|
+
.arch-node strong { display: block; font-size: 16px; margin-bottom: 6px; }
|
|
84
|
+
.arch-node p { color: var(--muted); margin: 0; font-size: 13px; line-height: 1.4; }
|
|
85
|
+
.arch-arrow { display: grid; place-items: center; color: var(--green); font-size: 25px; }
|
|
86
|
+
.boundary { margin-top: 20px; padding: 13px 15px; border: 1px dashed #9eb5a6; color: #456453; background: rgba(255,255,255,.55); font-size: 13px; }
|
|
87
|
+
.flow { display: grid; grid-template-columns: repeat(6, 1fr); gap: 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
88
|
+
.flow-step { min-height: 150px; padding: 19px 15px 18px; border-right: 1px solid var(--line); position: relative; }
|
|
89
|
+
.flow-step:last-child { border-right: 0; }
|
|
90
|
+
.flow-number { color: var(--green); font-size: 12px; font-weight: 850; letter-spacing: .1em; }
|
|
91
|
+
.flow-step strong { display: block; margin: 15px 0 7px; font-size: 15px; }
|
|
92
|
+
.flow-step p { color: var(--muted); margin: 0; font-size: 13px; }
|
|
93
|
+
.flow-step::after { content: "→"; position: absolute; right: -10px; top: 57px; color: var(--green); background: var(--panel); font-size: 18px; z-index: 1; }
|
|
94
|
+
.flow-step:last-child::after { display: none; }
|
|
95
|
+
.compare { width: 100%; border-collapse: collapse; background: var(--panel); font-size: 14px; }
|
|
96
|
+
.compare th, .compare td { padding: 14px 16px; border-bottom: 1px solid var(--line); text-align: left; }
|
|
97
|
+
.compare th { background: #edf3ee; color: #405148; font-size: 12px; text-transform: uppercase; letter-spacing: .07em; }
|
|
98
|
+
.compare td:not(:first-child), .compare th:not(:first-child) { text-align: right; }
|
|
99
|
+
.compare .positive { color: var(--green); font-weight: 800; }
|
|
100
|
+
.compare .flat { color: var(--muted); }
|
|
101
|
+
.principles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
|
|
102
|
+
.principle { border-top: 3px solid var(--green); background: var(--panel); padding: 18px; }
|
|
103
|
+
.principle:nth-child(2) { border-top-color: var(--blue); }
|
|
104
|
+
.principle:nth-child(3) { border-top-color: var(--amber); }
|
|
105
|
+
.principle p { margin: 0; color: var(--muted); font-size: 14px; }
|
|
106
|
+
.status { display: inline-flex; align-items: center; gap: 8px; color: var(--red); font-weight: 800; font-size: 14px; }
|
|
107
|
+
.status i { width: 9px; height: 9px; border-radius: 50%; background: currentColor; }
|
|
108
|
+
footer { max-width: 1180px; margin: 0 auto; padding: 0 26px 54px; color: var(--muted); font-size: 13px; }
|
|
109
|
+
@media (max-width: 820px) {
|
|
110
|
+
.topbar-inner { padding: 12px 17px; align-items: flex-start; flex-direction: column; }
|
|
111
|
+
.nav { justify-content: flex-start; }
|
|
112
|
+
main { padding: 40px 17px 70px; }
|
|
113
|
+
.hero-grid, .explain { grid-template-columns: 1fr; }
|
|
114
|
+
.metrics { grid-template-columns: repeat(2, 1fr); }
|
|
115
|
+
.metric:nth-child(2) { border-right: 0; }
|
|
116
|
+
.metric:nth-child(-n+2) { border-bottom: 1px solid var(--line); }
|
|
117
|
+
.flow { display: block; }
|
|
118
|
+
.flow-step { min-height: auto; border-right: 0; border-bottom: 1px solid var(--line); padding-left: 18px; }
|
|
119
|
+
.flow-step::after { content: "↓"; top: auto; bottom: -13px; right: 50%; }
|
|
120
|
+
.flow-step:last-child { border-bottom: 0; }
|
|
121
|
+
.principles { grid-template-columns: 1fr; }
|
|
122
|
+
.section-head { display: block; }
|
|
123
|
+
.section-note { margin-top: 10px; }
|
|
124
|
+
}
|
|
125
|
+
@media print {
|
|
126
|
+
.topbar { position: static; }
|
|
127
|
+
.nav, .copy, .tabs { display: none; }
|
|
128
|
+
.tab-panel { display: block !important; }
|
|
129
|
+
.panel, .metric, .compare, .flow, .architecture { box-shadow: none; break-inside: avoid; }
|
|
130
|
+
}
|
|
131
|
+
</style>
|
|
132
|
+
</head>
|
|
133
|
+
<body>
|
|
134
|
+
<header class="topbar">
|
|
135
|
+
<div class="topbar-inner">
|
|
136
|
+
<div class="brand">Pi <span>apply_patch</span> 方案说明</div>
|
|
137
|
+
<nav class="nav" aria-label="页面导航">
|
|
138
|
+
<a href="#answer">结论</a><a href="#architecture">架构</a><a href="#config">配置</a><a href="#principle">原理</a><a href="#evidence">数据</a>
|
|
139
|
+
</nav>
|
|
140
|
+
</div>
|
|
141
|
+
</header>
|
|
142
|
+
<main>
|
|
143
|
+
<section class="hero-grid" id="answer">
|
|
144
|
+
<div>
|
|
145
|
+
<div class="eyebrow">GPT-5.6 / Pi / Codex-style editing</div>
|
|
146
|
+
<h1>它变好了,但改善主要在“第一次编辑能不能正确开始”。</h1>
|
|
147
|
+
<p class="lede">公平对照下,apply_patch 没有提升最终正确率,却把 GPT-5.6 的首次编辑成功率从 85% 提到 95%,并消除了观察到的编辑协议失败。它适合作为 GPT/Codex 模型的候选工具,不应无条件替代所有模型的 edit。</p>
|
|
148
|
+
</div>
|
|
149
|
+
<aside class="signal">
|
|
150
|
+
<strong>当前包状态:可安装,按模型路由</strong>
|
|
151
|
+
<p>这个 npm 包包含真正的 apply_patch extension。安装后,GPT/Codex 模型按配置使用 apply_patch,其他模型继续使用 edit。</p>
|
|
152
|
+
</aside>
|
|
153
|
+
</section>
|
|
154
|
+
|
|
155
|
+
<section class="section">
|
|
156
|
+
<div class="metrics" aria-label="公平 benchmark 核心指标">
|
|
157
|
+
<div class="metric good"><div class="value">85% → 95%</div><div class="label">首次编辑成功率</div></div>
|
|
158
|
+
<div class="metric neutral"><div class="value">90% = 90%</div><div class="label">最终精确正确率</div></div>
|
|
159
|
+
<div class="metric good"><div class="value">1 → 0</div><div class="label">编辑协议失败</div></div>
|
|
160
|
+
<div class="metric warn"><div class="value">−4.5%</div><div class="label">中位端到端耗时</div></div>
|
|
161
|
+
</div>
|
|
162
|
+
</section>
|
|
163
|
+
|
|
164
|
+
<section class="section">
|
|
165
|
+
<div class="section-head"><div><div class="eyebrow">先解释指标</div><h2>“编辑协议失败:1 → 0”到底是什么?</h2></div><p class="section-note">这是本次 benchmark 自定义的分类名,不是 HTTP、Responses API 或 custom tool 协议错误。</p></div>
|
|
166
|
+
<div class="explain">
|
|
167
|
+
<article class="panel">
|
|
168
|
+
<span class="badge amber">A · 当前 edit</span>
|
|
169
|
+
<h3 style="margin-top:16px">1 次工具调用失败,但任务后来完成</h3>
|
|
170
|
+
<p>公平 A 组中唯一被归类为协议失败的是 <code>11-bom</code>。模型先读取了带 UTF-8 BOM 的文件,然后调用 Pi <code>edit</code>,把 BOM 放进了 <code>oldText</code>。Pi 的精确匹配没有找到这段文本,于是返回:</p>
|
|
171
|
+
<div class="callout"><code>Could not find the exact text in src/bom.js</code><br>old text 必须逐字匹配,包括空白和换行。</div>
|
|
172
|
+
<p>模型随后使用 bash 做了字节级修改,最终文件正确,所以“最终正确率”仍算成功;但“首次编辑成功率”和“编辑协议失败”仍记录了这次失败。</p>
|
|
173
|
+
</article>
|
|
174
|
+
<article class="panel">
|
|
175
|
+
<span class="badge green">C · apply_patch</span>
|
|
176
|
+
<h3 style="margin-top:16px">工具协议直接匹配模型的 Codex 先验</h3>
|
|
177
|
+
<p>GPT-5.6 在 C 组中以 <code>apply_patch</code> 的名字调用 custom tool,输入是原始 patch 文本,而不是嵌套的 <code>path + edits[]</code> JSON。19/20 个公平用例出现了可识别的 custom call,且没有编辑工具执行失败。</p>
|
|
178
|
+
<div class="callout">因此“1 → 0”表示:首次编辑环节少了一次失败调用。它不是说所有文件修改错误都消失了,也不是说 API 从不失败。</div>
|
|
179
|
+
<p>最终正确率仍然是 90%,因为剩余问题属于内容语义或文本格式细节,例如换行转义和引号选择。</p>
|
|
180
|
+
</article>
|
|
181
|
+
</div>
|
|
182
|
+
</section>
|
|
183
|
+
|
|
184
|
+
<section class="section" id="architecture">
|
|
185
|
+
<div class="section-head"><div><div class="eyebrow">Architecture</div><h2>工具的完整调用链</h2></div><p class="section-note">关键设计是“模型看到哪种工具”与“工具如何安全落盘”分开。</p></div>
|
|
186
|
+
<div class="architecture">
|
|
187
|
+
<div class="arch-row">
|
|
188
|
+
<div class="arch-node"><small>01 · Model state</small><strong>当前模型配置</strong><p>ctx.model.provider、api、id、compat 能力标记。</p></div>
|
|
189
|
+
<div class="arch-arrow">→</div>
|
|
190
|
+
<div class="arch-node"><small>02 · Routing</small><strong>syncEditorTools()</strong><p>命中的 GPT/Codex 模型启用 apply_patch;其他模型启用 edit。</p></div>
|
|
191
|
+
<div class="arch-arrow">→</div>
|
|
192
|
+
<div class="arch-node"><small>03 · Provider</small><strong>Responses serializer</strong><p>Grammar 支持时序列化为 type=custom + Lark definition,否则回退为普通 function tool。</p></div>
|
|
193
|
+
<div class="arch-arrow">→</div>
|
|
194
|
+
<div class="arch-node"><small>04 · Execution</small><strong>parse → validate → apply</strong><p>完整解析、路径检查、预校验,最后才写文件。</p></div>
|
|
195
|
+
</div>
|
|
196
|
+
<div class="boundary"><strong>信任边界:</strong>grammar 只约束模型输出形状,不等于 sandbox。路径、软链接、目录、覆盖和权限必须由执行端再次检查。</div>
|
|
197
|
+
</div>
|
|
198
|
+
</section>
|
|
199
|
+
|
|
200
|
+
<section class="section">
|
|
201
|
+
<div class="section-head"><div><div class="eyebrow">Execution</div><h2>一次 apply_patch 如何落盘</h2></div><p class="section-note">先把 patch 变成可信的 change plan,再进入写入阶段。</p></div>
|
|
202
|
+
<div class="flow">
|
|
203
|
+
<div class="flow-step"><div class="flow-number">01 / RECEIVE</div><strong>接收原始 patch</strong><p>模型发出 Begin Patch 到 End Patch 的自由格式文本。</p></div>
|
|
204
|
+
<div class="flow-step"><div class="flow-number">02 / PARSE</div><strong>解析成 AST</strong><p>Add、Delete、Update、Move 和多个 update chunk。</p></div>
|
|
205
|
+
<div class="flow-step"><div class="flow-number">03 / RESOLVE</div><strong>解析路径</strong><p>相对路径以 ctx.cwd 为根,允许 workspace 内绝对路径。</p></div>
|
|
206
|
+
<div class="flow-step"><div class="flow-number">04 / PREFLIGHT</div><strong>全部预校验</strong><p>读取快照,匹配上下文,计算每个文件的新内容。</p></div>
|
|
207
|
+
<div class="flow-step"><div class="flow-number">05 / MUTATE</div><strong>串行写入</strong><p>通过 mutation queue 写入;移动先写目标再删源。</p></div>
|
|
208
|
+
<div class="flow-step"><div class="flow-number">06 / REPORT</div><strong>返回 diff</strong><p>返回逐文件 diff、unified patch、已提交路径和失败前缀。</p></div>
|
|
209
|
+
</div>
|
|
210
|
+
</section>
|
|
211
|
+
|
|
212
|
+
<section class="section" id="config">
|
|
213
|
+
<div class="section-head"><div><div class="eyebrow">Configuration</div><h2>当前配置与可选扩展</h2></div><p class="section-note">默认只为已稳定验证的 Sol High 启用;Luna 的性能结果存在长尾波动,需要显式 opt-in。</p></div>
|
|
214
|
+
<div class="tabs" role="tablist" aria-label="配置视图">
|
|
215
|
+
<button class="tab" role="tab" aria-selected="true" aria-controls="config-current" id="tab-current">当前实际配置</button>
|
|
216
|
+
<button class="tab" role="tab" aria-selected="false" aria-controls="config-recommended" id="tab-recommended">Luna 显式启用</button>
|
|
217
|
+
<button class="tab" role="tab" aria-selected="false" aria-controls="config-route" id="tab-route">路由规则</button>
|
|
218
|
+
</div>
|
|
219
|
+
<div class="tab-panel active" id="config-current" role="tabpanel" aria-labelledby="tab-current">
|
|
220
|
+
<div class="code-wrap"><button class="copy" data-copy="current-config">复制</button><pre id="current-config">{
|
|
221
|
+
"enabled": true,
|
|
222
|
+
"models": [
|
|
223
|
+
{ "provider": "pudu-openai-proxy", "model": "gpt-5.6-sol" }
|
|
224
|
+
]
|
|
225
|
+
}</pre></div>
|
|
226
|
+
<div class="callout">models.json 中的 provider API key 不在页面展示。建议使用环境变量或 Pi auth 存储,并轮换已经出现在配置文件中的 key。</div>
|
|
227
|
+
</div>
|
|
228
|
+
<div class="tab-panel" id="config-recommended" role="tabpanel" aria-labelledby="tab-recommended">
|
|
229
|
+
<div class="code-wrap"><button class="copy" data-copy="recommended-config">复制</button><pre id="recommended-config">{
|
|
230
|
+
"enabled": true,
|
|
231
|
+
"models": [
|
|
232
|
+
{ "provider": "pudu-openai-proxy", "model": "gpt-5.6-sol" },
|
|
233
|
+
{ "provider": "pudu-openai-proxy", "model": "gpt-5.6-luna" }
|
|
234
|
+
]
|
|
235
|
+
}</pre></div>
|
|
236
|
+
<p>Luna High 的有效重测中,两组都 20/20 正常结束,最终正确率都是 18/20;首次编辑成功率为 edit 90%、apply_patch 100%。按 17 个两组都正确的配对样本,apply_patch 中位总耗时多 2.3s;按全部 20 个配对样本,中位多 1.2s,9 例更快、11 例更慢。因此 Luna 有协议适配收益,但没有稳定的总耗时优势,当前不默认启用。</p>
|
|
237
|
+
</div>
|
|
238
|
+
<div class="tab-panel" id="config-route" role="tabpanel" aria-labelledby="tab-route">
|
|
239
|
+
<div class="code-wrap"><button class="copy" data-copy="route-rule">复制</button><pre id="route-rule">model = ctx.model
|
|
240
|
+
|
|
241
|
+
if enabled
|
|
242
|
+
and model.api in ["openai-responses", "openai-codex-responses"]
|
|
243
|
+
and provider/model matches allowlist:
|
|
244
|
+
active = ["apply_patch"]
|
|
245
|
+
else:
|
|
246
|
+
active = ["edit"]
|
|
247
|
+
|
|
248
|
+
# Preserve all unrelated active tools.
|
|
249
|
+
# Register both tools permanently for session replay.</pre></div>
|
|
250
|
+
<p>切换发生在 <code>session_start</code> 和 <code>model_select</code>。不重新定义同名工具,只改变 active tool 集合。</p>
|
|
251
|
+
</div>
|
|
252
|
+
</section>
|
|
253
|
+
|
|
254
|
+
<section class="section" id="principle">
|
|
255
|
+
<div class="section-head"><div><div class="eyebrow">Design principles</div><h2>这个工具的三个核心原则</h2></div></div>
|
|
256
|
+
<div class="principles">
|
|
257
|
+
<article class="principle"><h3>模型协议对齐</h3><p>GPT/Codex 模型不再被要求适应 Pi 自定义的 <code>oldText/newText</code> JSON;它看到原生的 <code>apply_patch</code> 名称和 patch grammar。</p></article>
|
|
258
|
+
<article class="principle"><h3>执行端独立防护</h3><p>grammar 不是安全策略。执行端仍要做 workspace 边界、符号链接、目录、覆盖冲突、权限和取消检查。</p></article>
|
|
259
|
+
<article class="principle"><h3>按能力选择,不靠 AGENTS 对抗</h3><p>不在 AGENTS 中要求模型“不要使用 apply_patch”。扩展只让模型看到适合自己的一个编辑工具,避免两套协议同时暴露。</p></article>
|
|
260
|
+
</div>
|
|
261
|
+
</section>
|
|
262
|
+
|
|
263
|
+
<section class="section" id="evidence">
|
|
264
|
+
<div class="section-head"><div><div class="eyebrow">Evidence</div><h2>公平 benchmark 对照</h2></div><p class="section-note">20 个固定用例;两组都拥有 read、bash、write,只替换编辑工具。</p></div>
|
|
265
|
+
<table class="compare">
|
|
266
|
+
<thead><tr><th>指标</th><th>A · current edit</th><th>C · apply_patch</th><th>解读</th></tr></thead>
|
|
267
|
+
<tbody>
|
|
268
|
+
<tr><td>最终精确正确率</td><td>18 / 20 · 90%</td><td>18 / 20 · 90%</td><td class="flat">没有下降,也没有提升</td></tr>
|
|
269
|
+
<tr><td>首次编辑成功率</td><td>85%</td><td class="positive">95%</td><td class="positive">+10 个百分点</td></tr>
|
|
270
|
+
<tr><td>中位编辑调用</td><td>1</td><td>1</td><td class="flat">无变化</td></tr>
|
|
271
|
+
<tr><td>中位 agent turns</td><td>4</td><td>4</td><td class="flat">无变化</td></tr>
|
|
272
|
+
<tr><td>中位 input/output tokens</td><td>9732</td><td>9978</td><td class="flat">+2.5%</td></tr>
|
|
273
|
+
<tr><td>中位全部 tokens</td><td>26722</td><td>26503</td><td class="flat">−0.8%,基本持平</td></tr>
|
|
274
|
+
<tr><td>中位耗时</td><td>17.75s</td><td class="positive">16.95s</td><td class="positive">−4.5%,但低于设定门槛</td></tr>
|
|
275
|
+
<tr><td>编辑协议失败</td><td>1</td><td class="positive">0</td><td class="positive">首次协议更稳定</td></tr>
|
|
276
|
+
</tbody>
|
|
277
|
+
</table>
|
|
278
|
+
<div class="panel" style="margin-top:18px">
|
|
279
|
+
<div class="status"><i></i>最终判断:候选实现保留,默认自动启用暂不打开</div>
|
|
280
|
+
<p>本页的耗时比较优先使用同一 fixture 的配对差值,避免两组成功样本构成不同造成中位数偏差。runner 的 180s 进程上限是删失边界,不是 API timeout;本次有效 Luna 重测没有触发该上限。</p>
|
|
281
|
+
</div>
|
|
282
|
+
</section>
|
|
283
|
+
|
|
284
|
+
<section class="section">
|
|
285
|
+
<div class="section-head"><div><div class="eyebrow">Files</div><h2>实现位置</h2></div></div>
|
|
286
|
+
<div class="panel">
|
|
287
|
+
<p><code>extensions/codex-edit/index.ts</code>:工具注册、执行、防护、renderer 和模型路由。</p>
|
|
288
|
+
<p><code>extensions/codex-edit/parser.ts</code>:patch grammar 对应的 parser、hunk matcher 和 workspace path resolver。</p>
|
|
289
|
+
<p><code>extensions/codex-edit/tests/parser.test.mjs</code>:6 个纯函数/文件操作测试,当前全部通过。</p>
|
|
290
|
+
<p><code>/home/maxiaochao/.pi/agent/evaluations/gpt-edit-benchmark/report-final.md</code>:最终 benchmark 报告。</p>
|
|
291
|
+
</div>
|
|
292
|
+
</section>
|
|
293
|
+
</main>
|
|
294
|
+
<footer>说明页基于 Pi 0.84.4、Codex apply_patch 源码分析和 20-case GPT-5.6 benchmark。页面内没有包含任何 API key。</footer>
|
|
295
|
+
<script>
|
|
296
|
+
const tabs = [...document.querySelectorAll('.tab')];
|
|
297
|
+
const panels = [...document.querySelectorAll('.tab-panel')];
|
|
298
|
+
function activateTab(tab) {
|
|
299
|
+
tabs.forEach(item => item.setAttribute('aria-selected', String(item === tab)));
|
|
300
|
+
panels.forEach(panel => panel.classList.toggle('active', panel.id === tab.getAttribute('aria-controls')));
|
|
301
|
+
}
|
|
302
|
+
tabs.forEach(tab => tab.addEventListener('click', () => activateTab(tab)));
|
|
303
|
+
tabs.forEach((tab, index) => tab.addEventListener('keydown', event => {
|
|
304
|
+
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return;
|
|
305
|
+
event.preventDefault();
|
|
306
|
+
const next = tabs[(index + (event.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length];
|
|
307
|
+
next.focus(); activateTab(next);
|
|
308
|
+
}));
|
|
309
|
+
document.querySelectorAll('.copy').forEach(button => button.addEventListener('click', async () => {
|
|
310
|
+
const text = document.getElementById(button.dataset.copy).textContent;
|
|
311
|
+
try { await navigator.clipboard.writeText(text); button.textContent = '已复制'; setTimeout(() => button.textContent = '复制', 1200); }
|
|
312
|
+
catch { button.textContent = '复制失败'; setTimeout(() => button.textContent = '复制', 1200); }
|
|
313
|
+
}));
|
|
314
|
+
</script>
|
|
315
|
+
</body>
|
|
316
|
+
</html>
|
|
@@ -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
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maxiaochao/pi-toolkit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Personal pi coding agent toolkit: /todos, /btw, /cache_export extensions, browser automation, and bundled theme/skills.",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Personal pi coding agent toolkit: /todos, /btw, /cache_export and apply_patch extensions, browser automation, and bundled theme/skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi-package",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"extensions": [
|
|
26
26
|
"./extensions/todo.ts",
|
|
27
27
|
"./extensions/cache-export/index.ts",
|
|
28
|
-
"./extensions/btw.ts"
|
|
28
|
+
"./extensions/btw.ts",
|
|
29
|
+
"./extensions/codex-edit/index.ts"
|
|
29
30
|
],
|
|
30
31
|
"skills": [
|
|
31
32
|
"./skills"
|
|
@@ -47,8 +48,8 @@
|
|
|
47
48
|
"ws": "^8.19.0"
|
|
48
49
|
},
|
|
49
50
|
"scripts": {
|
|
50
|
-
"test": "cd extensions/cache-export && node --experimental-strip-types tests/run-tests.mjs && node --experimental-strip-types ../tests/btw-history.mjs && node tests/e2e-browser.mjs",
|
|
51
|
-
"load-test": "pi -ne -e ./extensions/todo.ts && pi -ne -e ./extensions/cache-export/index.ts && pi -ne -e ./extensions/btw.ts",
|
|
51
|
+
"test": "cd extensions/cache-export && node --experimental-strip-types tests/run-tests.mjs && node --experimental-strip-types ../tests/btw-history.mjs && node tests/e2e-browser.mjs && cd ../codex-edit && node --experimental-strip-types --test tests/package.test.mjs tests/parser.test.mjs",
|
|
52
|
+
"load-test": "pi -ne -e ./extensions/todo.ts && pi -ne -e ./extensions/cache-export/index.ts && pi -ne -e ./extensions/btw.ts && pi -ne -e ./extensions/codex-edit/index.ts",
|
|
52
53
|
"eval:agents": "node evaluations/agents-md/run.mjs",
|
|
53
54
|
"eval:agents:test": "node evaluations/agents-md/tests/run-tests.mjs",
|
|
54
55
|
"postinstall": "node scripts/install-agents.mjs"
|
package/skills/tapd/SKILL.md
CHANGED
|
@@ -280,6 +280,18 @@ tapd attachment download --id=<必需> # 获取附件下载链接
|
|
|
280
280
|
tapd attachment upload --custom-field=<必需> --entry-id=<必需> --file=<必需> --type=<如 story_custom_field> # 上传附件
|
|
281
281
|
```
|
|
282
282
|
|
|
283
|
+
#### 安全下载附件
|
|
284
|
+
|
|
285
|
+
`attachment download` 返回的是带短期授权 token 的临时 URL,不是附件内容。下载大附件时必须遵循以下流程:
|
|
286
|
+
|
|
287
|
+
1. 先用 `attachment list` 记录附件文件名和预期大小;每次发起下载或续传前,重新执行 `attachment download` 获取新 URL。
|
|
288
|
+
2. 下载到 `<文件名>.part`,使用 `curl --fail --location --retry 3`;HTTP 失败时立即停止,禁止把响应正文直接写入最终文件。
|
|
289
|
+
3. 续传前检查 `.part` 开头不是 `token expire`、JSON/HTML 错误信息等文本响应。发现污染时删除 `.part` 并重新下载,不修补或继续追加。
|
|
290
|
+
4. 使用新 URL 和 `curl -C -` 续传。已有部分长度为 N 时,只接受状态 `206` 且 `Content-Range` 从 N 开始;返回 `200`、范围不符或服务器不支持 Range 时,删除 `.part` 后从头下载。
|
|
291
|
+
5. 下载完成后,文件大小必须等于 `attachment list` 的预期大小。ZIP 附件还必须通过 `unzip -t`;其他格式执行相应完整性检查。全部通过后才将 `.part` 重命名为最终文件。
|
|
292
|
+
|
|
293
|
+
临时 URL 过期只需重新获取链接,不能据此判断 TAPD 原附件损坏。
|
|
294
|
+
|
|
283
295
|
### baseline — 基线管理
|
|
284
296
|
|
|
285
297
|
```bash
|