@bacnh85/pi-model-tools 0.2.0 → 0.3.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/README.md +38 -0
- package/extensions/index.ts +156 -5
- package/extensions/lib/apply-patch.ts +401 -0
- package/extensions/lib/edit-repair.ts +163 -0
- package/extensions/lib/guidance.ts +18 -0
- package/extensions/lib/shell-helpers.ts +5 -1
- package/extensions/lib/tool-input-repair.ts +1 -1
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -53,6 +53,8 @@ a family is detected; everything degrades gracefully to a no-op otherwise.
|
|
|
53
53
|
| **Read-on-guessed-path blocking** | Blocks `read` on a non-existent code-file path, suggests `find` first |
|
|
54
54
|
| **Prompt-aware first-tool hints** | Forces the correct first tool: `bash`-first for RUN/BUILD/EXECUTE tasks, `bash` git-clone-first for analyze-a-repo-URL tasks, and `find`-first for bare-filename reads. Targeted (only fires on matching intent) and applied to all detected families. |
|
|
55
55
|
| **Error categorization** | Classifies tool errors and injects recovery hints on the next turn |
|
|
56
|
+
| **Edit mismatch repair** | Strips `read`-tool truncation notices (`[Showing lines … Use offset=N to continue.]`, etc.) that models copy into `edit` oldText — the documented root cause of "Could not find the exact text" failures. On a match failure, retries once with whitespace-tolerant matching (copying the file's real indentation); on unresolvable matches, enriches the error with the nearest numbered region. Always on. |
|
|
57
|
+
| **`apply_patch` tool** | A Codex-style V4D diff/patch tool: emit only `@@` context + `-`/`+` change lines instead of large verbatim oldText blocks. Robust for multi-line/multi-file edits across all models. DeepSeek/GLM get steering to prefer it for non-trivial edits. |
|
|
56
58
|
|
|
57
59
|
### DeepSeek V4 only (verbose steering the Flash model needs)
|
|
58
60
|
|
|
@@ -65,6 +67,42 @@ a family is detected; everything degrades gracefully to a no-op otherwise.
|
|
|
65
67
|
|
|
66
68
|
## Installation
|
|
67
69
|
|
|
70
|
+
## `apply_patch` — diff-based editing
|
|
71
|
+
|
|
72
|
+
`apply_patch` is a Codex-style V4D patch tool. Instead of reproducing a large
|
|
73
|
+
verbatim `oldText` block (where weaker models drift on indentation/quotes),
|
|
74
|
+
you emit only the changed lines plus a little surrounding context:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
*** Begin Patch
|
|
78
|
+
*** Update File: src/foo.ts
|
|
79
|
+
@@ export function foo() {
|
|
80
|
+
- return 1;
|
|
81
|
+
+ return 2;
|
|
82
|
+
*** End Patch
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Rules:
|
|
86
|
+
- `*** Add File: <path>` — one `+` line per content line (creates the file).
|
|
87
|
+
- `*** Delete File: <path>` — no payload lines.
|
|
88
|
+
- `*** Update File: <path>` — hunks; also `*** Update File: <old> → <new>` to rename.
|
|
89
|
+
- Each Update hunk starts with a `@@ <unchanged context line>` anchor, then
|
|
90
|
+
`-` removed lines and `+` added lines. Leading-space context lines (` `) are
|
|
91
|
+
also accepted.
|
|
92
|
+
- Context+removed must match **uniquely** in the file (diverges from Codex's
|
|
93
|
+
first-match for safety, matching `edit`'s philosophy). Add more context lines
|
|
94
|
+
if a match is ambiguous.
|
|
95
|
+
- If the `@@` anchor text repeats as the immediately-following context or
|
|
96
|
+
removed line (common when models treat `@@` as a diff-style locator header),
|
|
97
|
+
the duplicate is auto-collapsed — only one occurrence is matched.
|
|
98
|
+
- Matching is progressive-fuzzy (exact → strip-trailing-ws → strip-both-ws →
|
|
99
|
+
Unicode-normalize), so minor whitespace/Unicode differences still apply.
|
|
100
|
+
|
|
101
|
+
DeepSeek and GLM are steered to prefer `apply_patch` for multi-line/multi-hunk
|
|
102
|
+
edits; Claude/OpenAI keep using `edit` (they're already reliable with it).
|
|
103
|
+
|
|
104
|
+
## Installation
|
|
105
|
+
|
|
68
106
|
```bash
|
|
69
107
|
pi install npm:@bacnh85/pi-model-tools
|
|
70
108
|
```
|
package/extensions/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
3
4
|
import { dirname, resolve as resolvePath } from "node:path";
|
|
4
5
|
import {
|
|
5
6
|
createBashToolDefinition,
|
|
@@ -9,7 +10,9 @@ import {
|
|
|
9
10
|
createLsToolDefinition,
|
|
10
11
|
createReadToolDefinition,
|
|
11
12
|
createWriteToolDefinition,
|
|
13
|
+
defineTool,
|
|
12
14
|
} from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { Type } from "typebox";
|
|
13
16
|
import {
|
|
14
17
|
isRecord,
|
|
15
18
|
detectFamily,
|
|
@@ -21,6 +24,16 @@ import {
|
|
|
21
24
|
blockDangerousEnabled,
|
|
22
25
|
} from "./lib/model-detection.ts";
|
|
23
26
|
import { repairToolArguments, type RepairKind } from "./lib/tool-input-repair.ts";
|
|
27
|
+
import {
|
|
28
|
+
stripReadContamination,
|
|
29
|
+
computeRetryEdit,
|
|
30
|
+
nearestBlock,
|
|
31
|
+
parseFailedEditIndex,
|
|
32
|
+
isEditMismatchError,
|
|
33
|
+
stripBom as stripBomStr,
|
|
34
|
+
normalizeToLF,
|
|
35
|
+
} from "./lib/edit-repair.ts";
|
|
36
|
+
import { parsePatch, applyPatchToFiles, PatchParseError } from "./lib/apply-patch.ts";
|
|
24
37
|
import { stripReasoningContent, cleanLeakedContentFromMessages } from "./lib/reasoning-content.ts";
|
|
25
38
|
import {
|
|
26
39
|
looksLikeCodePath,
|
|
@@ -39,6 +52,7 @@ import {
|
|
|
39
52
|
runTaskFirstToolHint,
|
|
40
53
|
readUncertainPathHint,
|
|
41
54
|
githubCloneFirstToolHint,
|
|
55
|
+
applyPatchPreferenceGuidance,
|
|
42
56
|
selectionGuidanceEnabled,
|
|
43
57
|
strictSerenaEnabled,
|
|
44
58
|
superPowerModeEnabled,
|
|
@@ -60,14 +74,55 @@ function appendReadNote(result: any, note: unknown) {
|
|
|
60
74
|
return { ...result, content: [...(Array.isArray(result?.content) ? result.content : []), { type: "text", text: note }] };
|
|
61
75
|
}
|
|
62
76
|
|
|
77
|
+
// Strip read-tool contamination notices from an edit's oldText fields. Mutates
|
|
78
|
+
// in place and reports whether anything changed.
|
|
79
|
+
function decontaminateEditArgs(args: any): boolean {
|
|
80
|
+
if (!isRecord(args)) return false;
|
|
81
|
+
const hasOld = Array.isArray(args.edits)
|
|
82
|
+
? args.edits.some((e: any) => isRecord(e) && typeof e.oldText === "string")
|
|
83
|
+
: typeof args.oldText === "string";
|
|
84
|
+
if (!hasOld) return false;
|
|
85
|
+
let changed = false;
|
|
86
|
+
const clean = (s: string): string => {
|
|
87
|
+
const r = stripReadContamination(s);
|
|
88
|
+
if (r.changed) changed = true;
|
|
89
|
+
return r.text;
|
|
90
|
+
};
|
|
91
|
+
if (Array.isArray(args.edits)) {
|
|
92
|
+
for (const e of args.edits) if (isRecord(e) && typeof e.oldText === "string") e.oldText = clean(e.oldText);
|
|
93
|
+
}
|
|
94
|
+
if (typeof args.oldText === "string") args.oldText = clean(args.oldText);
|
|
95
|
+
return changed;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Locate the file, read it, and report its (BOM-stripped, LF-normalized)
|
|
99
|
+
// content for trim-tolerant retry. Returns null on any I/O problem.
|
|
100
|
+
async function readFileForRetry(filePath: string, cwd: string): Promise<string | null> {
|
|
101
|
+
const abs = resolvePath(cwd, filePath);
|
|
102
|
+
try {
|
|
103
|
+
const buf = await readFile(abs);
|
|
104
|
+
return normalizeToLF(stripBomStr(buf.toString("utf-8")));
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
63
110
|
function wrapToolDefinition(base: any, factory: (cwd: string) => any, shouldRepair: () => boolean, onRepair: (toolName: string, repairs: readonly RepairKind[]) => void): any {
|
|
64
111
|
return {
|
|
65
112
|
...base,
|
|
66
113
|
prepareArguments(args: unknown) {
|
|
67
114
|
let prepared = base.prepareArguments ? base.prepareArguments(args as never) : args;
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
|
|
115
|
+
if (shouldRepair()) {
|
|
116
|
+
const repaired = repairToolArguments(base.name, base.parameters, prepared);
|
|
117
|
+
if (repaired.repaired) { onRepair(base.name, repaired.repairs); prepared = repaired.args; }
|
|
118
|
+
}
|
|
119
|
+
// Strip read-tool contamination from edit oldText (always on — it's a
|
|
120
|
+
// safe, deterministic fix for the documented mismatch root cause).
|
|
121
|
+
if (base.name === "edit" && isRecord(prepared)) {
|
|
122
|
+
if (decontaminateEditArgs(prepared)) {
|
|
123
|
+
onRepair(base.name, ["read-notice-stripped"]);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
71
126
|
return base.name === "read" ? addReadDefaults(prepared) : prepared;
|
|
72
127
|
},
|
|
73
128
|
async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: any) {
|
|
@@ -75,8 +130,51 @@ function wrapToolDefinition(base: any, factory: (cwd: string) => any, shouldRepa
|
|
|
75
130
|
const freshDef = factory(cwd);
|
|
76
131
|
const readNote = base.name === "read" && isRecord(params) ? params.__mtReadNote : undefined;
|
|
77
132
|
if (isRecord(params)) delete params.__mtReadNote;
|
|
78
|
-
|
|
79
|
-
|
|
133
|
+
|
|
134
|
+
if (base.name !== "edit") {
|
|
135
|
+
const result = await freshDef.execute(toolCallId, params, signal, onUpdate, ctx);
|
|
136
|
+
return base.name === "read" ? appendReadNote(result, readNote) : result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// edit: try once; on a match-failure, retry once with trim-tolerant
|
|
140
|
+
// matching (copying actual file bytes) before giving up with a richer
|
|
141
|
+
// error that shows the nearest region.
|
|
142
|
+
try {
|
|
143
|
+
const result = await freshDef.execute(toolCallId, params, signal, onUpdate, ctx);
|
|
144
|
+
return result;
|
|
145
|
+
} catch (err: any) {
|
|
146
|
+
const message: string = err?.message ? String(err.message) : "";
|
|
147
|
+
if (!isEditMismatchError(message)) throw err;
|
|
148
|
+
|
|
149
|
+
const filePath = typeof params?.path === "string" ? params.path : "";
|
|
150
|
+
if (!filePath) throw err;
|
|
151
|
+
const fileContent = await readFileForRetry(filePath, cwd);
|
|
152
|
+
if (fileContent === null) throw err;
|
|
153
|
+
|
|
154
|
+
const edits: { oldText: string; newText: string }[] = Array.isArray(params?.edits) && params.edits.length > 0
|
|
155
|
+
? params.edits
|
|
156
|
+
: (typeof params?.oldText === "string" ? [{ oldText: params.oldText, newText: params.newText }] : []);
|
|
157
|
+
if (edits.length === 0) throw err;
|
|
158
|
+
|
|
159
|
+
const retry = computeRetryEdit(fileContent, edits, parseFailedEditIndex(message));
|
|
160
|
+
if (retry) {
|
|
161
|
+
// Rebuild oldText from the file's real bytes (real indentation) so the
|
|
162
|
+
// core exact matcher succeeds; keep the model's newText as-is.
|
|
163
|
+
const fixedParams = { ...params };
|
|
164
|
+
if (Array.isArray(fixedParams.edits)) fixedParams.edits = retry.fixedEdits;
|
|
165
|
+
else fixedParams.oldText = retry.fixedEdits[0].oldText;
|
|
166
|
+
onRepair(base.name, ["trim-match-retry"]);
|
|
167
|
+
return await freshDef.execute(toolCallId, fixedParams, signal, onUpdate, ctx);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Unresolvable: enrich the error with the nearest region so the model
|
|
171
|
+
// can copy verbatim on the next turn. categorizeToolError checks
|
|
172
|
+
// edit_mismatch before rate_limit/timeout for the edit tool, so a snippet
|
|
173
|
+
// containing 'timeout'/'429' cannot misclassify this.
|
|
174
|
+
const failing = edits[Math.min(parseFailedEditIndex(message), edits.length - 1)];
|
|
175
|
+
const nearest = failing ? nearestBlock(fileContent, stripReadContamination(failing.oldText).text) : "";
|
|
176
|
+
throw new Error(nearest ? `${message}\n\n${nearest}` : message);
|
|
177
|
+
}
|
|
80
178
|
},
|
|
81
179
|
};
|
|
82
180
|
}
|
|
@@ -117,6 +215,52 @@ export default function (pi: ExtensionAPI) {
|
|
|
117
215
|
}));
|
|
118
216
|
}
|
|
119
217
|
|
|
218
|
+
// ── apply_patch: Codex-style diff/patch tool (robust for weak models) ──
|
|
219
|
+
pi.registerTool(defineTool({
|
|
220
|
+
name: "apply_patch",
|
|
221
|
+
label: "apply_patch",
|
|
222
|
+
description: [
|
|
223
|
+
"Apply a Codex-style V4D patch to edit one or more files. Emit only changed lines plus a little surrounding context (a small diff), which is easier to get right than reproducing a large verbatim block. Supported sections: `*** Add File: <path>` (only `+` lines), `*** Delete File: <path>` (no payload), `*** Update File: <path>` or `*** Update File: <old> → <new>` (rename). Inside an Update section, each hunk is preceded by a `@@` anchor line whose text is an unchanged context line, then `-` removed lines and `+` added lines. Leading-space context lines (` `) are also allowed. If the @@ anchor text is restated as the immediately-following context or removed line, the duplicate is auto-collapsed. Context+removed must match UNIQUELY in the file. Wrap the whole patch in `*** Begin Patch` ... `*** End Patch`.",
|
|
224
|
+
"",
|
|
225
|
+
"Example:",
|
|
226
|
+
"*** Begin Patch", "*** Update File: src/foo.ts", "@@ export function foo()", "- return 1", "+ return 2", "*** End Patch",
|
|
227
|
+
].join("\n"),
|
|
228
|
+
promptSnippet: "Apply a diff/patch to edit one or more files (Codex V4D format)",
|
|
229
|
+
promptGuidelines: [
|
|
230
|
+
"Use apply_patch for multi-line or multi-file edits: emit a small diff (context + -/+ lines) instead of reproducing large verbatim oldText blocks.",
|
|
231
|
+
"Each Update hunk needs a unique anchor: include enough unchanged context lines so the context+removed block matches exactly once in the file.",
|
|
232
|
+
"If the @@ anchor repeats on the very next line (as space-context or -removed), the duplicate is auto-collapsed.",
|
|
233
|
+
"For a single tiny one-line replacement, edit is fine; for anything larger or spanning multiple files, prefer apply_patch.",
|
|
234
|
+
],
|
|
235
|
+
parameters: Type.Object({ patch: Type.String({ description: "The V4D patch text, wrapped in *** Begin Patch ... *** End Patch." }) }),
|
|
236
|
+
renderShell: "self",
|
|
237
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
238
|
+
const cwd = ctx?.cwd || process.cwd();
|
|
239
|
+
let parsed;
|
|
240
|
+
try {
|
|
241
|
+
parsed = parsePatch(params.patch);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
const msg = err instanceof PatchParseError ? err.message : String(err);
|
|
244
|
+
return { content: [{ type: "text", text: `Invalid patch: ${msg}` }], isError: true, details: undefined };
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
const res = await applyPatchToFiles(parsed, cwd);
|
|
248
|
+
const summary = res.files.map((f) => {
|
|
249
|
+
if (f.kind === "add") return `Added ${f.path}`;
|
|
250
|
+
if (f.kind === "delete") return `Deleted ${f.path}`;
|
|
251
|
+
return `Updated ${f.path}`;
|
|
252
|
+
}).join("\n");
|
|
253
|
+
const exactness = res.exact ? "" : "\nNote: some hunks matched via fuzzy (whitespace/Unicode) normalization.";
|
|
254
|
+
return {
|
|
255
|
+
content: [{ type: "text", text: `${summary}${exactness}` }],
|
|
256
|
+
details: { diff: res.diff, files: res.files.map((f) => f.path) },
|
|
257
|
+
};
|
|
258
|
+
} catch (err) {
|
|
259
|
+
return { content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }], isError: true, details: undefined };
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
}));
|
|
263
|
+
|
|
120
264
|
// ── /model-tools-status ──
|
|
121
265
|
pi.registerCommand("model-tools-status", {
|
|
122
266
|
description: "Show pi-model-tools configuration, detected family, repair stats, and error history.",
|
|
@@ -201,6 +345,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
201
345
|
if (readHint) systemPrompt = `${systemPrompt}\n\n${readHint}`;
|
|
202
346
|
}
|
|
203
347
|
|
|
348
|
+
// apply_patch preference — DeepSeek V4 Flash only (GLM is better with exact-match
|
|
349
|
+
// edit; apply_patch context anchors confuse it and it lacks agentic fallback).
|
|
350
|
+
if (activeFamily === "deepseek-v4") {
|
|
351
|
+
const patchHint = applyPatchPreferenceGuidance(activeForHint);
|
|
352
|
+
if (patchHint) systemPrompt = `${systemPrompt}\n\n${patchHint}`;
|
|
353
|
+
}
|
|
354
|
+
|
|
204
355
|
// DeepSeek-only: Super Power Mode + verbose selection guidance (DeepSeek V4
|
|
205
356
|
// needs the full steering block; GLM reaches 100% with prompt-aware hints alone).
|
|
206
357
|
if (activeFamily === "deepseek-v4") {
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* apply-patch.ts — Codex-style V4D patch parser + applier for the `apply_patch`
|
|
3
|
+
* tool. The model emits only `@@` context + `-`/`+` change lines (a small diff),
|
|
4
|
+
* which is far easier for weaker models to reproduce verbatim than a large
|
|
5
|
+
* str_replace oldText block.
|
|
6
|
+
*
|
|
7
|
+
* Format (mirrors OpenAI Codex `apply_patch`):
|
|
8
|
+
* *** Begin Patch
|
|
9
|
+
* *** Add File: path/to/new.txt
|
|
10
|
+
* +content line 1
|
|
11
|
+
* +content line 2
|
|
12
|
+
* *** Delete File: path/to/old.txt
|
|
13
|
+
* *** Update File: path/to/existing.ts
|
|
14
|
+
* @@ context line (unchanged, for anchoring)
|
|
15
|
+
* -removed line
|
|
16
|
+
* +added line
|
|
17
|
+
* unchanged context line (leading space preserved)
|
|
18
|
+
* *** Update File: a.ts → b.ts (rename)
|
|
19
|
+
* *** End Patch
|
|
20
|
+
*
|
|
21
|
+
* Divergence from Codex: context+removed blocks must match UNIQUELY (Codex
|
|
22
|
+
* takes the first match). Silent wrong-location edits are worse than a clear
|
|
23
|
+
* error, and this matches pi's own `edit` philosophy.
|
|
24
|
+
*
|
|
25
|
+
* Leniency: if the @@ anchor text repeats as the immediately-following context
|
|
26
|
+
* or removed line (common when models treat `@@` as a git-diff-style locator
|
|
27
|
+
* header), the duplicate is collapsed automatically.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readFile, writeFile, unlink } from "node:fs/promises";
|
|
31
|
+
import { isAbsolute, resolve } from "node:path";
|
|
32
|
+
import { generateUnifiedPatch, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
33
|
+
|
|
34
|
+
export type FileOpKind = "add" | "delete" | "update";
|
|
35
|
+
|
|
36
|
+
export interface Hunk {
|
|
37
|
+
/** Ordered lines: context (with leading space stripped) + removed + added. */
|
|
38
|
+
context: string[];
|
|
39
|
+
removed: string[];
|
|
40
|
+
added: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface FileOp {
|
|
44
|
+
kind: FileOpKind;
|
|
45
|
+
path: string;
|
|
46
|
+
/** For update-with-rename: the new path. */
|
|
47
|
+
movePath?: string;
|
|
48
|
+
hunks: Hunk[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ParsedPatch {
|
|
52
|
+
ops: FileOp[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class PatchParseError extends Error {}
|
|
56
|
+
|
|
57
|
+
// ── Parser ──
|
|
58
|
+
|
|
59
|
+
function stripLeadingSpace(line: string): string {
|
|
60
|
+
// In an update hunk, every payload line begins with a marker: ' ', '-', '+'.
|
|
61
|
+
// A literal leading space is the "context" marker and is part of the content.
|
|
62
|
+
return line.length > 0 ? line.slice(1) : "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function parsePatch(patch: string): ParsedPatch {
|
|
66
|
+
const lines = patch.split("\n");
|
|
67
|
+
const ops: FileOp[] = [];
|
|
68
|
+
let i = 0;
|
|
69
|
+
|
|
70
|
+
// Optional *** Begin Patch header.
|
|
71
|
+
if (i < lines.length && lines[i].trim() === "*** Begin Patch") i++;
|
|
72
|
+
|
|
73
|
+
let current: FileOp | null = null;
|
|
74
|
+
const finalize = () => { if (current) { ops.push(current); current = null; } };
|
|
75
|
+
|
|
76
|
+
for (; i < lines.length; i++) {
|
|
77
|
+
const line = lines[i];
|
|
78
|
+
const trimmed = line.trim();
|
|
79
|
+
|
|
80
|
+
if (trimmed === "*** End Patch") { finalize(); break; }
|
|
81
|
+
if (trimmed === "*** Begin Patch") continue;
|
|
82
|
+
|
|
83
|
+
if (trimmed.startsWith("*** ")) {
|
|
84
|
+
finalize();
|
|
85
|
+
current = parseFileHeader(trimmed);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!current) {
|
|
90
|
+
if (trimmed.length === 0) continue; // tolerate blank lines between ops
|
|
91
|
+
throw new PatchParseError(`Unexpected patch line outside a file section: ${JSON.stringify(line)}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// `@@ <text>` introduces a new hunk; the text after `@@ ` is the anchor
|
|
95
|
+
// context line. A bare `@@` just flushes/separates hunks.
|
|
96
|
+
if (line.startsWith("@@")) {
|
|
97
|
+
if (current.kind === "add") throw new PatchParseError("'@@' is invalid in an *** Add File section");
|
|
98
|
+
current.hunks.push({ context: [], removed: [], added: [] });
|
|
99
|
+
const anchor = line.slice(2);
|
|
100
|
+
if (anchor.startsWith(" ")) {
|
|
101
|
+
current.hunks[current.hunks.length - 1].context.push(anchor.slice(1));
|
|
102
|
+
} else if (anchor.length > 0) {
|
|
103
|
+
// tolerate `@@text` with no space
|
|
104
|
+
current.hunks[current.hunks.length - 1].context.push(anchor);
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Payload lines only valid inside update/add.
|
|
110
|
+
if (current.kind === "delete") {
|
|
111
|
+
throw new PatchParseError(`*** Delete File sections must not contain payload lines: ${JSON.stringify(line)}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (line.startsWith("+")) {
|
|
115
|
+
// Add-File payload ('+' lines) and Update added-lines share this path;
|
|
116
|
+
// content is the text after the '+' marker.
|
|
117
|
+
current.hunks.push({ context: [], removed: [], added: [stripLeadingSpace(line)] });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (line.startsWith("-")) {
|
|
121
|
+
if (current.kind === "add") throw new PatchParseError(`'-' lines are invalid in an *** Add File section`);
|
|
122
|
+
current.hunks.push({ context: [], removed: [], added: [] });
|
|
123
|
+
current.hunks[current.hunks.length - 1].removed.push(stripLeadingSpace(line));
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
// In an Add section, a blank line is a literal empty content line.
|
|
127
|
+
if (current.kind === "add" && line === "") {
|
|
128
|
+
current.hunks.push({ context: [], removed: [], added: [""] });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// Any other line in an Update section is context. Models frequently omit
|
|
132
|
+
// the leading-space context marker, so be lenient: treat the line verbatim.
|
|
133
|
+
// (In an Add section, only '+' lines are valid — anything else is an error.)
|
|
134
|
+
if (current.kind === "add") {
|
|
135
|
+
throw new PatchParseError(`Invalid Add File payload line (expected '+'): ${JSON.stringify(line)}`);
|
|
136
|
+
}
|
|
137
|
+
if (line.startsWith(" ")) {
|
|
138
|
+
current.hunks.push({ context: [], removed: [], added: [] });
|
|
139
|
+
current.hunks[current.hunks.length - 1].context.push(stripLeadingSpace(line));
|
|
140
|
+
} else {
|
|
141
|
+
current.hunks.push({ context: [], removed: [], added: [] });
|
|
142
|
+
current.hunks[current.hunks.length - 1].context.push(line);
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
finalize();
|
|
148
|
+
if (ops.length === 0) throw new PatchParseError("Patch contains no file operations.");
|
|
149
|
+
return { ops };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseFileHeader(header: string): FileOp {
|
|
153
|
+
// *** Add File: path / *** Delete File: path / *** Update File: path [→ newPath]
|
|
154
|
+
const addMatch = header.match(/^\*\*\* Add File:\s*(.+)$/);
|
|
155
|
+
if (addMatch) return { kind: "add", path: addMatch[1].trim(), hunks: [] };
|
|
156
|
+
const delMatch = header.match(/^\*\*\* Delete File:\s*(.+)$/);
|
|
157
|
+
if (delMatch) return { kind: "delete", path: delMatch[1].trim(), hunks: [] };
|
|
158
|
+
const updMatch = header.match(/^\*\*\* Update File:\s*(.+?)(?:\s*(?:->|→)\s*(.+))?$/);
|
|
159
|
+
if (updMatch) {
|
|
160
|
+
return { kind: "update", path: updMatch[1].trim(), movePath: updMatch[2]?.trim(), hunks: [] };
|
|
161
|
+
}
|
|
162
|
+
throw new PatchParseError(`Unrecognized file header: ${JSON.stringify(header)}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Hunk assembly: collapse the marker-stream into ordered hunk groups ──
|
|
166
|
+
|
|
167
|
+
interface AssembledHunk {
|
|
168
|
+
/** Lines before the first `-`/`+`: pure context (anchor). */
|
|
169
|
+
context: string[];
|
|
170
|
+
/** Removed + added payload, in emission order. */
|
|
171
|
+
removed: string[];
|
|
172
|
+
added: string[];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Merge adjacent context/removed/added markers into coherent hunks. */
|
|
176
|
+
function assembleHunks(raw: Hunk[]): AssembledHunk[] {
|
|
177
|
+
const out: AssembledHunk[] = [];
|
|
178
|
+
let cur: AssembledHunk = { context: [], removed: [], added: [] };
|
|
179
|
+
let started = false;
|
|
180
|
+
const flush = () => {
|
|
181
|
+
if (started) out.push(cur);
|
|
182
|
+
cur = { context: [], removed: [], added: [] };
|
|
183
|
+
started = false;
|
|
184
|
+
};
|
|
185
|
+
for (const h of raw) {
|
|
186
|
+
if (h.context.length) {
|
|
187
|
+
if (started && (cur.removed.length || cur.added.length)) flush();
|
|
188
|
+
cur.context.push(...h.context);
|
|
189
|
+
}
|
|
190
|
+
if (h.removed.length || h.added.length) {
|
|
191
|
+
started = true;
|
|
192
|
+
cur.removed.push(...h.removed);
|
|
193
|
+
cur.added.push(...h.added);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (started) out.push(cur);
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── seekSequence: Codex's progressive fuzzy matcher (4 passes) ──
|
|
201
|
+
|
|
202
|
+
function normalizeAscii(s: string): string {
|
|
203
|
+
return s.trim()
|
|
204
|
+
.replace(/[\u2010-\u2015\u2212]/g, "-")
|
|
205
|
+
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
|
|
206
|
+
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
|
|
207
|
+
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function eqExact(a: string, b: string) { return a === b; }
|
|
211
|
+
function eqRstrip(a: string, b: string) { return a.trimEnd() === b.trimEnd(); }
|
|
212
|
+
function eqTrim(a: string, b: string) { return a.trim() === b.trim(); }
|
|
213
|
+
function eqUnicode(a: string, b: string) { return normalizeAscii(a) === normalizeAscii(b); }
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Loose equality matching seekSequence's 4-pass progression. Used for dedup
|
|
217
|
+
* so the collapse is consistent with the matcher's own fuzzy boundaries.
|
|
218
|
+
*/
|
|
219
|
+
function linesLooselyEqual(a: string, b: string): boolean {
|
|
220
|
+
return a === b
|
|
221
|
+
|| a.trimEnd() === b.trimEnd()
|
|
222
|
+
|| a.trim() === b.trim()
|
|
223
|
+
|| normalizeAscii(a) === normalizeAscii(b);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Count matches of `pattern` lines within `lines`, trying progressively
|
|
228
|
+
* lenient equality. Returns count and first start index under the strictest
|
|
229
|
+
* pass that yields any match.
|
|
230
|
+
*/
|
|
231
|
+
export function seekSequence(lines: string[], pattern: string[]): { count: number; firstIndex: number; exact: boolean } {
|
|
232
|
+
if (pattern.length === 0) return { count: 0, firstIndex: -1, exact: true };
|
|
233
|
+
if (pattern.length > lines.length) return { count: 0, firstIndex: -1, exact: true };
|
|
234
|
+
for (const eq of [eqExact, eqRstrip, eqTrim, eqUnicode]) {
|
|
235
|
+
let count = 0;
|
|
236
|
+
let firstIndex = -1;
|
|
237
|
+
for (let i = 0; i <= lines.length - pattern.length; i++) {
|
|
238
|
+
let ok = true;
|
|
239
|
+
for (let j = 0; j < pattern.length; j++) {
|
|
240
|
+
if (!eq(lines[i + j], pattern[j])) { ok = false; break; }
|
|
241
|
+
}
|
|
242
|
+
if (ok) {
|
|
243
|
+
count++;
|
|
244
|
+
if (firstIndex === -1) firstIndex = i;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (count > 0) return { count, firstIndex, exact: eq === eqExact };
|
|
248
|
+
}
|
|
249
|
+
return { count: 0, firstIndex: -1, exact: true };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Applier ──
|
|
253
|
+
|
|
254
|
+
function stripBom(s: string): string { return s.startsWith("\uFEFF") ? s.slice(1) : s; }
|
|
255
|
+
function normalizeLF(s: string): string { return s.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); }
|
|
256
|
+
function detectLineEnding(content: string): "\r\n" | "\n" {
|
|
257
|
+
const crlf = content.indexOf("\r\n");
|
|
258
|
+
const lf = content.indexOf("\n");
|
|
259
|
+
if (lf === -1) return "\n";
|
|
260
|
+
if (crlf === -1) return "\n";
|
|
261
|
+
return crlf < lf ? "\r\n" : "\n";
|
|
262
|
+
}
|
|
263
|
+
function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string {
|
|
264
|
+
return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Resolve a patch path the same way pi's built-in tools do: relative paths
|
|
268
|
+
* resolve under cwd, absolute paths are honored anywhere. (pi's resolveToCwd
|
|
269
|
+
* does NOT restrict absolute paths to cwd; matching that behavior keeps
|
|
270
|
+
* apply_patch compatible with edits the model references by absolute path.) */
|
|
271
|
+
function resolvePathLikePi(p: string, cwd: string): string {
|
|
272
|
+
const expanded = p.startsWith("~") ? (process.env.HOME ?? "") + p.slice(1) : p;
|
|
273
|
+
return isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface AppliedFile {
|
|
277
|
+
path: string;
|
|
278
|
+
/** For updates: the before/after content for diffing. */
|
|
279
|
+
before?: string;
|
|
280
|
+
after?: string;
|
|
281
|
+
kind: FileOpKind;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export interface ApplyResult {
|
|
285
|
+
files: AppliedFile[];
|
|
286
|
+
/** false if any hunk matched only via fuzzy (non-exact) pass. */
|
|
287
|
+
exact: boolean;
|
|
288
|
+
/** Unified diff string across all updated files. */
|
|
289
|
+
diff: string;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Apply a parsed patch's ops to the filesystem under cwd. Requires unique
|
|
294
|
+
* context+removed matches for every update hunk. Returns per-file before/after
|
|
295
|
+
* and a combined unified diff.
|
|
296
|
+
*/
|
|
297
|
+
export async function applyPatchToFiles(parsed: ParsedPatch, cwd: string): Promise<ApplyResult> {
|
|
298
|
+
let exact = true;
|
|
299
|
+
const files: AppliedFile[] = [];
|
|
300
|
+
const diffParts: string[] = [];
|
|
301
|
+
const applied: string[] = [];
|
|
302
|
+
|
|
303
|
+
for (const op of parsed.ops) {
|
|
304
|
+
const abs = resolvePathLikePi(op.path, cwd);
|
|
305
|
+
try {
|
|
306
|
+
if (op.kind === "delete") {
|
|
307
|
+
const before = await withFileMutationQueue(abs, async () => {
|
|
308
|
+
const raw = await readOptional(abs);
|
|
309
|
+
if (raw === undefined) throw new Error(`Cannot delete (not found): ${op.path}`);
|
|
310
|
+
await unlink(abs);
|
|
311
|
+
return raw;
|
|
312
|
+
});
|
|
313
|
+
files.push({ path: op.path, before, after: "", kind: "delete" });
|
|
314
|
+
applied.push(op.path);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (op.kind === "add") {
|
|
319
|
+
const content = op.hunks.map((h) => h.added.join("\n")).join("\n");
|
|
320
|
+
await withFileMutationQueue(abs, async () => {
|
|
321
|
+
if ((await readOptional(abs)) !== undefined) throw new Error(`Cannot add (already exists): ${op.path}`);
|
|
322
|
+
await writeFile(abs, content, "utf-8");
|
|
323
|
+
});
|
|
324
|
+
files.push({ path: op.path, before: "", after: content, kind: "add" });
|
|
325
|
+
applied.push(op.path);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// update
|
|
330
|
+
const destAbs = op.movePath ? resolvePathLikePi(op.movePath, cwd) : abs;
|
|
331
|
+
const { before, after } = await withFileMutationQueue(destAbs, async () => {
|
|
332
|
+
const raw = await readOptional(abs);
|
|
333
|
+
if (raw === undefined) throw new Error(`Cannot update (not found): ${op.path}`);
|
|
334
|
+
const bom = raw.startsWith("\uFEFF") ? "\uFEFF" : "";
|
|
335
|
+
const ending = detectLineEnding(stripBom(raw));
|
|
336
|
+
const beforeContent = normalizeLF(stripBom(raw));
|
|
337
|
+
const hunks = assembleHunks(op.hunks);
|
|
338
|
+
|
|
339
|
+
let work = beforeContent.split("\n");
|
|
340
|
+
const newHunks: { start: number; removedLen: number; added: string[] }[] = [];
|
|
341
|
+
|
|
342
|
+
for (const h of hunks) {
|
|
343
|
+
// ponytail: collapse a redundant @@ anchor the model restated as
|
|
344
|
+
// the first payload line (context or removed). Models treat `@@` like
|
|
345
|
+
// a git-diff locator header and repeat it as context/removed, which
|
|
346
|
+
// would otherwise require the line to appear twice consecutively in
|
|
347
|
+
// the file. Real Codex makes @@ a pure cursor-advance locator; we keep
|
|
348
|
+
// it in the match block so unique-match disambiguation still works
|
|
349
|
+
// (@@ fn / -dup), but drop the duplicate. Upgrade to full cursor
|
|
350
|
+
// semantics if unique-match is ever relaxed.
|
|
351
|
+
let matchBlock = [...h.context, ...h.removed];
|
|
352
|
+
if (matchBlock.length >= 2 && linesLooselyEqual(matchBlock[0], matchBlock[1])) {
|
|
353
|
+
matchBlock = matchBlock.slice(1);
|
|
354
|
+
}
|
|
355
|
+
const { count, firstIndex, exact: hExact } = seekSequence(work, matchBlock);
|
|
356
|
+
if (!hExact) exact = false;
|
|
357
|
+
if (count === 0) throw new Error(`Hunk context not found in ${op.path}. Ensure context lines match the file.`);
|
|
358
|
+
if (count > 1) throw new Error(`Hunk context is ambiguous (${count} matches) in ${op.path}. Add more surrounding context lines to make it unique.`);
|
|
359
|
+
const removedStart = firstIndex + (matchBlock.length - h.removed.length);
|
|
360
|
+
newHunks.push({ start: removedStart, removedLen: h.removed.length, added: h.added });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
for (const h of [...newHunks].sort((a, b) => b.start - a.start)) {
|
|
364
|
+
work = [...work.slice(0, h.start), ...h.added, ...work.slice(h.start + h.removedLen)];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const afterContent = work.join("\n");
|
|
368
|
+
if (op.movePath && op.movePath !== op.path) {
|
|
369
|
+
if ((await readOptional(destAbs)) !== undefined) throw new Error(`Cannot rename onto existing file: ${op.movePath}`);
|
|
370
|
+
const outContent = bom + restoreLineEndings(afterContent, ending);
|
|
371
|
+
await unlink(abs);
|
|
372
|
+
await writeFile(destAbs, outContent, "utf-8");
|
|
373
|
+
} else {
|
|
374
|
+
const outContent = bom + restoreLineEndings(afterContent, ending);
|
|
375
|
+
await writeFile(abs, outContent, "utf-8");
|
|
376
|
+
}
|
|
377
|
+
return { before: beforeContent, after: afterContent };
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
files.push({ path: op.movePath ?? op.path, before, after, kind: "update" });
|
|
381
|
+
diffParts.push(generateUnifiedPatch(op.movePath ?? op.path, before, after));
|
|
382
|
+
applied.push(op.movePath ?? op.path);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
// Report any files already applied before this op failed, so the model
|
|
385
|
+
// doesn't blindly re-apply the whole patch (and hit mismatches).
|
|
386
|
+
const appliedNote = applied.length > 0 ? ` (already applied: ${applied.join(", ")})` : "";
|
|
387
|
+
throw new Error(`${err instanceof Error ? err.message : String(err)}${appliedNote}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return { files, exact, diff: diffParts.join("\n").trimEnd() };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function readOptional(abs: string): Promise<string | undefined> {
|
|
395
|
+
try {
|
|
396
|
+
const buf = await readFile(abs);
|
|
397
|
+
return buf.toString("utf-8");
|
|
398
|
+
} catch {
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* edit-repair.ts — helpers that harden the built-in `edit` tool against the
|
|
3
|
+
* two documented failure modes for weaker models:
|
|
4
|
+
*
|
|
5
|
+
* 1. read-tool contamination: `read` appends truncation/continuation notices
|
|
6
|
+
* to file content; models copy them into oldText, where they don't exist
|
|
7
|
+
* in the file → every match (exact and fuzzy) fails.
|
|
8
|
+
* 2. leading-whitespace drift: pi's matcher normalizes trailing whitespace
|
|
9
|
+
* only; Codex's seek_sequence also tolerates leading-ws differences.
|
|
10
|
+
*
|
|
11
|
+
* Pure + testable. The wiring (prepareArguments/execute) lives in index.ts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Strip a UTF-8 BOM if present. */
|
|
15
|
+
export function stripBom(content: string): string {
|
|
16
|
+
return content.startsWith("\uFEFF") ? content.slice(1) : content;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Normalize CRLF/CR to LF. */
|
|
20
|
+
export function normalizeToLF(text: string): string {
|
|
21
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The exact read-tool notice patterns that contaminate oldText
|
|
26
|
+
* (see pi-coding-agent dist/core/tools/read.js).
|
|
27
|
+
* Each is anchored on its distinctive shape so it cannot match real source.
|
|
28
|
+
*/
|
|
29
|
+
const READ_CONTAMINATION_PATTERNS: RegExp[] = [
|
|
30
|
+
// [Showing lines A-B of C (50KB limit). Use offset=N to continue.]
|
|
31
|
+
/\n{1,2}\[Showing lines \d+-\d+ of \d+(?: \([^)]+\))?\.\s*Use offset=\d+ to continue\.\]/g,
|
|
32
|
+
// [N more lines in file. Use offset=N to continue.]
|
|
33
|
+
/\n{1,2}\[\d+ more lines in file\.\s*Use offset=\d+ to continue\.\]/g,
|
|
34
|
+
// [Line X is SIZE, exceeds 50KB limit. Use bash: sed -n ...]
|
|
35
|
+
/\n\[Line \d+ is [^,]+, exceeds [^\]]+ limit\.[^\]]*\]/g,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Strip read-tool contamination notices from an oldText string.
|
|
40
|
+
* Returns the cleaned text and whether anything changed.
|
|
41
|
+
* Safe: these notice shapes never legitimately appear inside matched source.
|
|
42
|
+
*/
|
|
43
|
+
export function stripReadContamination(text: string): { text: string; changed: boolean } {
|
|
44
|
+
let changed = false;
|
|
45
|
+
let out = text;
|
|
46
|
+
for (const re of READ_CONTAMINATION_PATTERNS) {
|
|
47
|
+
const next = out.replace(re, "");
|
|
48
|
+
if (next !== out) changed = true;
|
|
49
|
+
out = next;
|
|
50
|
+
}
|
|
51
|
+
return { text: out, changed };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function splitLines(content: string): string[] {
|
|
55
|
+
return content.length === 0 ? [] : content.split("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Count contiguous block matches of oldText in content, tolerating
|
|
60
|
+
* per-line leading+trailing whitespace differences (Codex seek_sequence pass 3).
|
|
61
|
+
* Returns the number of matches and the start line of the first match.
|
|
62
|
+
*/
|
|
63
|
+
export function findTrimMatch(content: string, oldText: string): { count: number; firstIndex: number } {
|
|
64
|
+
const fileLines = splitLines(content).map((l) => l.trim());
|
|
65
|
+
const patternLines = splitLines(oldText).map((l) => l.trim());
|
|
66
|
+
// Drop a single trailing empty pattern line so a stray trailing newline in
|
|
67
|
+
// oldText doesn't break an otherwise-exact block match.
|
|
68
|
+
if (patternLines.length > 1 && patternLines[patternLines.length - 1] === "") patternLines.pop();
|
|
69
|
+
if (patternLines.length === 0 || patternLines.length > fileLines.length) return { count: 0, firstIndex: -1 };
|
|
70
|
+
|
|
71
|
+
let count = 0;
|
|
72
|
+
let firstIndex = -1;
|
|
73
|
+
for (let i = 0; i <= fileLines.length - patternLines.length; i++) {
|
|
74
|
+
let ok = true;
|
|
75
|
+
for (let j = 0; j < patternLines.length; j++) {
|
|
76
|
+
if (fileLines[i + j] !== patternLines[j]) { ok = false; break; }
|
|
77
|
+
}
|
|
78
|
+
if (ok) {
|
|
79
|
+
count++;
|
|
80
|
+
if (firstIndex === -1) firstIndex = i;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { count, firstIndex };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Extract the ACTUAL file bytes for a matched block (with real indentation),
|
|
88
|
+
* given the content and the 0-based start line + block length. Used to rebuild
|
|
89
|
+
* oldText from the file so the core matcher's exact pass succeeds.
|
|
90
|
+
*/
|
|
91
|
+
export function fileBytesForBlock(content: string, startLine: number, blockLen: number): string {
|
|
92
|
+
const lines = splitLines(content);
|
|
93
|
+
return lines.slice(startLine, startLine + blockLen).join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Compute a retry edit set after a trim-tolerant re-match. Returns the rebuilt
|
|
98
|
+
* edits (oldText taken from the file's real bytes so the core exact matcher
|
|
99
|
+
* succeeds) when there is exactly one trim match, otherwise null. Pure/testable
|
|
100
|
+
* — the orchestration (read file, catch throw, retry) lives in index.ts.
|
|
101
|
+
*
|
|
102
|
+
* IMPORTANT: blockLen drops a single trailing empty line to stay in sync with
|
|
103
|
+
* findTrimMatch, which pops one trailing empty pattern line before matching.
|
|
104
|
+
*/
|
|
105
|
+
export function computeRetryEdit(fileContent: string, edits: { oldText: string; newText: string }[], failIdx: number): { fixedEdits: { oldText: string; newText: string }[] } | null {
|
|
106
|
+
const idx = Math.min(failIdx, edits.length - 1);
|
|
107
|
+
const failing = edits[idx];
|
|
108
|
+
const oldText = typeof failing?.oldText === "string" ? failing.oldText : "";
|
|
109
|
+
if (!oldText) return null;
|
|
110
|
+
const decontaminated = stripReadContamination(oldText).text;
|
|
111
|
+
const { count, firstIndex } = findTrimMatch(fileContent, decontaminated);
|
|
112
|
+
if (count !== 1) return null;
|
|
113
|
+
const patternLines = decontaminated.split("\n");
|
|
114
|
+
if (patternLines.length > 1 && patternLines[patternLines.length - 1] === "") patternLines.pop();
|
|
115
|
+
const realOld = fileBytesForBlock(fileContent, firstIndex, patternLines.length);
|
|
116
|
+
const fixedEdits = edits.map((e, i) => i === idx ? { ...e, oldText: realOld } : e);
|
|
117
|
+
return { fixedEdits };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Return a numbered snippet of the file region most similar to oldText, to help
|
|
122
|
+
* the model self-correct on an unresolvable mismatch. Picks the window with the
|
|
123
|
+
* most overlapping (trimmed) lines.
|
|
124
|
+
*/
|
|
125
|
+
export function nearestBlock(content: string, oldText: string, ctx = 6): string {
|
|
126
|
+
const fileLines = splitLines(content);
|
|
127
|
+
const patternSet = new Set(splitLines(oldText).map((l) => l.trim()).filter((l) => l.length > 0));
|
|
128
|
+
if (patternSet.size === 0 || fileLines.length === 0) return "";
|
|
129
|
+
|
|
130
|
+
const window = Math.max(ctx, Math.min(40, fileLines.length));
|
|
131
|
+
let bestStart = 0;
|
|
132
|
+
let bestScore = -1;
|
|
133
|
+
for (let i = 0; i <= fileLines.length - window; i++) {
|
|
134
|
+
let score = 0;
|
|
135
|
+
for (let j = 0; j < window; j++) {
|
|
136
|
+
if (patternSet.has(fileLines[i + j].trim())) score++;
|
|
137
|
+
}
|
|
138
|
+
if (score > bestScore) { bestScore = score; bestStart = i; }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const width = String(fileLines.length).length;
|
|
142
|
+
const start = Math.max(0, bestStart - 1);
|
|
143
|
+
const end = Math.min(fileLines.length, bestStart + window + 1);
|
|
144
|
+
const shown = fileLines.slice(start, end).map((l, idx) => {
|
|
145
|
+
const num = String(start + idx + 1).padStart(width, " ");
|
|
146
|
+
return `${num} | ${l}`;
|
|
147
|
+
});
|
|
148
|
+
return `Nearest matching region (lines ${start + 1}-${end}):\n${shown.join("\n")}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Parse the failing edit index from an edit-tool error message.
|
|
153
|
+
* Returns the 0-based index, or 0 when it can't be determined (single-edit).
|
|
154
|
+
*/
|
|
155
|
+
export function parseFailedEditIndex(message: string): number {
|
|
156
|
+
const m = message.match(/edits\[(\d+)\]/i);
|
|
157
|
+
return m ? Number(m[1]) : 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** True if an error message/result indicates an edit match failure. */
|
|
161
|
+
export function isEditMismatchError(message: string): boolean {
|
|
162
|
+
return /could not find|oldtext must match exactly|found \d+ occurrences|text must be unique|provide more context to make it unique/i.test(message);
|
|
163
|
+
}
|
|
@@ -93,6 +93,24 @@ export function deepSeekSelectionGuidance(activeTools: readonly string[]): strin
|
|
|
93
93
|
return result;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// ── apply_patch preference hint (DeepSeek/GLM) ──
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* When apply_patch is available, tell weaker models to prefer it over edit for
|
|
100
|
+
* anything beyond a single tiny replacement. Returns undefined if the tool is
|
|
101
|
+
* not active so it never misdirects. Gated to DeepSeek/GLM by the caller (see
|
|
102
|
+
* index.ts before_agent_start), since Claude/OpenAI are already reliable with edit.
|
|
103
|
+
*/
|
|
104
|
+
export function applyPatchPreferenceGuidance(activeTools: readonly string[]): string | undefined {
|
|
105
|
+
if (!activeTools.includes("apply_patch")) return undefined;
|
|
106
|
+
return [
|
|
107
|
+
"apply_patch (preferred for non-trivial edits):",
|
|
108
|
+
" • For multi-line, multi-hunk, or multi-file edits, emit a small V4D diff (context + -/+ lines) via apply_patch instead of reproducing large verbatim oldText blocks — far fewer match failures.",
|
|
109
|
+
" • Each Update hunk must anchor on enough unchanged context that the context+removed block matches UNIQUELY in the file.",
|
|
110
|
+
" • Keep using edit for a single tiny one-line exact replacement.",
|
|
111
|
+
].join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
96
114
|
// ── Prompt-aware first-tool hints ──
|
|
97
115
|
|
|
98
116
|
/**
|
|
@@ -133,9 +133,13 @@ export function categorizeToolError(toolName: string, errorResult: unknown): Err
|
|
|
133
133
|
const text = (isRecord(errorResult) && Array.isArray(errorResult.content)
|
|
134
134
|
? errorResult.content.map((p) => isRecord(p) && typeof p.text === "string" ? p.text : "").join("\n")
|
|
135
135
|
: String(errorResult ?? "")).toLowerCase();
|
|
136
|
+
// Edit mismatch is checked before rate_limit/timeout because an enriched edit error may
|
|
137
|
+
// append a nearest-region file snippet containing 'timeout'/'429'/'rate limit'
|
|
138
|
+
// strings (e.g. `const timeout = 5000;`, `if (status === 429)`), which would
|
|
139
|
+
// otherwise misclassify as timeout/rate_limit and give the wrong recovery hint.
|
|
140
|
+
if (toolName === "edit" && /could not find (?:edits|the exact text)|old ?text must match exactly|found \d+ occurrences|(?:old)?text must be unique|provide more context to make it unique/i.test(text)) return { category: "edit_mismatch", toolName, hint: "Edit requires exact unique matching. Read a narrow range, copy oldText verbatim, include surrounding lines." };
|
|
136
141
|
if (/rate limit|429|too many requests|exceeded.*limit/i.test(text)) return { category: "rate_limit", toolName, hint: "Rate-limited. Wait before retrying or simplify the request." };
|
|
137
142
|
if (/timed? ?out|timeout/i.test(text)) return { category: "timeout", toolName, hint: "Timed out. Use simpler inputs or reduce scope." };
|
|
138
|
-
if (/could not find edits|oldtext must match exactly|found \d+ occurrences|(?:old)?text must be unique|provide more context to make it unique/i.test(text)) return { category: "edit_mismatch", toolName, hint: "Edit requires exact unique matching. Read a narrow range, copy oldText verbatim, include surrounding lines." };
|
|
139
143
|
if (/validation failed|invalid_type|required|missing.*(field|argument|property)/i.test(text)) return { category: "validation", toolName, hint: "Invalid arguments. Provide all required fields with correct types." };
|
|
140
144
|
if (/enoent|no such file or directory|(?:file|path) not found/i.test(text)) return { category: "path_not_found", toolName, hint: "Path missing or guessed. Discover the exact path with find first." };
|
|
141
145
|
if (/no such tool|unknown tool|is not a function|tool\s+\S+\s+(?:was\s+)?not found/i.test(text)) return { category: "tool_not_found", toolName, hint: "Use only exact Pi tool names. Never invent names like read_file." };
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { Compile } from "typebox/compile";
|
|
10
10
|
import { isRecord } from "./model-detection.ts";
|
|
11
11
|
|
|
12
|
-
export type RepairKind = "path-markdown-autolink" | "optional-null" | "json-string" | "empty-object-array" | "bare-string-array" | "json-object-wrapped-array" | "top-level-json-string";
|
|
12
|
+
export type RepairKind = "path-markdown-autolink" | "optional-null" | "json-string" | "empty-object-array" | "bare-string-array" | "json-object-wrapped-array" | "top-level-json-string" | "read-notice-stripped" | "trim-match-retry";
|
|
13
13
|
|
|
14
14
|
export type RepairResult = {
|
|
15
15
|
args: unknown;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bacnh85/pi-model-tools",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Unified tool-wrapping, argument repair, reasoning management, DeepSeek V4 guidance + Super Power Mode,
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Unified tool-wrapping, argument repair, reasoning management, DeepSeek V4 guidance + Super Power Mode, defensive leak-cleaning, edit mismatch repair (read-notice stripping + trim-tolerant retry), and a Codex-style apply_patch diff tool for DeepSeek V4 and GLM model families from any provider.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"publishConfig": {
|
|
@@ -27,7 +27,10 @@
|
|
|
27
27
|
"super-power",
|
|
28
28
|
"selection-guidance",
|
|
29
29
|
"agent-tools",
|
|
30
|
-
"reasoning"
|
|
30
|
+
"reasoning",
|
|
31
|
+
"apply-patch",
|
|
32
|
+
"diff-edit",
|
|
33
|
+
"edit-repair"
|
|
31
34
|
],
|
|
32
35
|
"scripts": {
|
|
33
36
|
"test": "node --import tsx --test extensions/test/unit/*.test.ts",
|