@bacnh85/pi-model-tools 0.2.0 → 0.3.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/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,39 @@ 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
+ - Matching is progressive-fuzzy (exact → strip-trailing-ws → strip-both-ws →
96
+ Unicode-normalize), so minor whitespace/Unicode differences still apply.
97
+
98
+ DeepSeek and GLM are steered to prefer `apply_patch` for multi-line/multi-hunk
99
+ edits; Claude/OpenAI keep using `edit` (they're already reliable with it).
100
+
101
+ ## Installation
102
+
68
103
  ```bash
69
104
  pi install npm:@bacnh85/pi-model-tools
70
105
  ```
@@ -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 (!shouldRepair()) return prepared;
69
- const repaired = repairToolArguments(base.name, base.parameters, prepared);
70
- if (repaired.repaired) { onRepair(base.name, repaired.repairs); prepared = repaired.args; }
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
- const result = await freshDef.execute(toolCallId, params, signal, onUpdate, ctx);
79
- return base.name === "read" ? appendReadNote(result, readNote) : result;
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,51 @@ 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. 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
+ "For a single tiny one-line replacement, edit is fine; for anything larger or spanning multiple files, prefer apply_patch.",
233
+ ],
234
+ parameters: Type.Object({ patch: Type.String({ description: "The V4D patch text, wrapped in *** Begin Patch ... *** End Patch." }) }),
235
+ renderShell: "self",
236
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
237
+ const cwd = ctx?.cwd || process.cwd();
238
+ let parsed;
239
+ try {
240
+ parsed = parsePatch(params.patch);
241
+ } catch (err) {
242
+ const msg = err instanceof PatchParseError ? err.message : String(err);
243
+ return { content: [{ type: "text", text: `Invalid patch: ${msg}` }], isError: true, details: undefined };
244
+ }
245
+ try {
246
+ const res = await applyPatchToFiles(parsed, cwd);
247
+ const summary = res.files.map((f) => {
248
+ if (f.kind === "add") return `Added ${f.path}`;
249
+ if (f.kind === "delete") return `Deleted ${f.path}`;
250
+ return `Updated ${f.path}`;
251
+ }).join("\n");
252
+ const exactness = res.exact ? "" : "\nNote: some hunks matched via fuzzy (whitespace/Unicode) normalization.";
253
+ return {
254
+ content: [{ type: "text", text: `${summary}${exactness}` }],
255
+ details: { diff: res.diff, files: res.files.map((f) => f.path) },
256
+ };
257
+ } catch (err) {
258
+ return { content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }], isError: true, details: undefined };
259
+ }
260
+ },
261
+ }));
262
+
120
263
  // ── /model-tools-status ──
121
264
  pi.registerCommand("model-tools-status", {
122
265
  description: "Show pi-model-tools configuration, detected family, repair stats, and error history.",
@@ -201,6 +344,13 @@ export default function (pi: ExtensionAPI) {
201
344
  if (readHint) systemPrompt = `${systemPrompt}\n\n${readHint}`;
202
345
  }
203
346
 
347
+ // apply_patch preference — DeepSeek V4 Flash only (GLM is better with exact-match
348
+ // edit; apply_patch context anchors confuse it and it lacks agentic fallback).
349
+ if (activeFamily === "deepseek-v4") {
350
+ const patchHint = applyPatchPreferenceGuidance(activeForHint);
351
+ if (patchHint) systemPrompt = `${systemPrompt}\n\n${patchHint}`;
352
+ }
353
+
204
354
  // DeepSeek-only: Super Power Mode + verbose selection guidance (DeepSeek V4
205
355
  // needs the full steering block; GLM reaches 100% with prompt-aware hints alone).
206
356
  if (activeFamily === "deepseek-v4") {
@@ -0,0 +1,375 @@
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
+
26
+ import { readFile, writeFile, unlink } from "node:fs/promises";
27
+ import { isAbsolute, resolve } from "node:path";
28
+ import { generateUnifiedPatch, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
29
+
30
+ export type FileOpKind = "add" | "delete" | "update";
31
+
32
+ export interface Hunk {
33
+ /** Ordered lines: context (with leading space stripped) + removed + added. */
34
+ context: string[];
35
+ removed: string[];
36
+ added: string[];
37
+ }
38
+
39
+ export interface FileOp {
40
+ kind: FileOpKind;
41
+ path: string;
42
+ /** For update-with-rename: the new path. */
43
+ movePath?: string;
44
+ hunks: Hunk[];
45
+ }
46
+
47
+ export interface ParsedPatch {
48
+ ops: FileOp[];
49
+ }
50
+
51
+ export class PatchParseError extends Error {}
52
+
53
+ // ── Parser ──
54
+
55
+ function stripLeadingSpace(line: string): string {
56
+ // In an update hunk, every payload line begins with a marker: ' ', '-', '+'.
57
+ // A literal leading space is the "context" marker and is part of the content.
58
+ return line.length > 0 ? line.slice(1) : "";
59
+ }
60
+
61
+ export function parsePatch(patch: string): ParsedPatch {
62
+ const lines = patch.split("\n");
63
+ const ops: FileOp[] = [];
64
+ let i = 0;
65
+
66
+ // Optional *** Begin Patch header.
67
+ if (i < lines.length && lines[i].trim() === "*** Begin Patch") i++;
68
+
69
+ let current: FileOp | null = null;
70
+ const finalize = () => { if (current) { ops.push(current); current = null; } };
71
+
72
+ for (; i < lines.length; i++) {
73
+ const line = lines[i];
74
+ const trimmed = line.trim();
75
+
76
+ if (trimmed === "*** End Patch") { finalize(); break; }
77
+ if (trimmed === "*** Begin Patch") continue;
78
+
79
+ if (trimmed.startsWith("*** ")) {
80
+ finalize();
81
+ current = parseFileHeader(trimmed);
82
+ continue;
83
+ }
84
+
85
+ if (!current) {
86
+ if (trimmed.length === 0) continue; // tolerate blank lines between ops
87
+ throw new PatchParseError(`Unexpected patch line outside a file section: ${JSON.stringify(line)}`);
88
+ }
89
+
90
+ // `@@ <text>` introduces a new hunk; the text after `@@ ` is the anchor
91
+ // context line. A bare `@@` just flushes/separates hunks.
92
+ if (line.startsWith("@@")) {
93
+ if (current.kind === "add") throw new PatchParseError("'@@' is invalid in an *** Add File section");
94
+ current.hunks.push({ context: [], removed: [], added: [] });
95
+ const anchor = line.slice(2);
96
+ if (anchor.startsWith(" ")) {
97
+ current.hunks[current.hunks.length - 1].context.push(anchor.slice(1));
98
+ } else if (anchor.length > 0) {
99
+ // tolerate `@@text` with no space
100
+ current.hunks[current.hunks.length - 1].context.push(anchor);
101
+ }
102
+ continue;
103
+ }
104
+
105
+ // Payload lines only valid inside update/add.
106
+ if (current.kind === "delete") {
107
+ throw new PatchParseError(`*** Delete File sections must not contain payload lines: ${JSON.stringify(line)}`);
108
+ }
109
+
110
+ if (line.startsWith("+")) {
111
+ // Add-File payload ('+' lines) and Update added-lines share this path;
112
+ // content is the text after the '+' marker.
113
+ current.hunks.push({ context: [], removed: [], added: [stripLeadingSpace(line)] });
114
+ continue;
115
+ }
116
+ if (line.startsWith("-")) {
117
+ if (current.kind === "add") throw new PatchParseError(`'-' lines are invalid in an *** Add File section`);
118
+ current.hunks.push({ context: [], removed: [], added: [] });
119
+ current.hunks[current.hunks.length - 1].removed.push(stripLeadingSpace(line));
120
+ continue;
121
+ }
122
+ // In an Add section, a blank line is a literal empty content line.
123
+ if (current.kind === "add" && line === "") {
124
+ current.hunks.push({ context: [], removed: [], added: [""] });
125
+ continue;
126
+ }
127
+ // Any other line in an Update section is context. Models frequently omit
128
+ // the leading-space context marker, so be lenient: treat the line verbatim.
129
+ // (In an Add section, only '+' lines are valid — anything else is an error.)
130
+ if (current.kind === "add") {
131
+ throw new PatchParseError(`Invalid Add File payload line (expected '+'): ${JSON.stringify(line)}`);
132
+ }
133
+ if (line.startsWith(" ")) {
134
+ current.hunks.push({ context: [], removed: [], added: [] });
135
+ current.hunks[current.hunks.length - 1].context.push(stripLeadingSpace(line));
136
+ } else {
137
+ current.hunks.push({ context: [], removed: [], added: [] });
138
+ current.hunks[current.hunks.length - 1].context.push(line);
139
+ }
140
+ continue;
141
+ }
142
+
143
+ finalize();
144
+ if (ops.length === 0) throw new PatchParseError("Patch contains no file operations.");
145
+ return { ops };
146
+ }
147
+
148
+ function parseFileHeader(header: string): FileOp {
149
+ // *** Add File: path / *** Delete File: path / *** Update File: path [→ newPath]
150
+ const addMatch = header.match(/^\*\*\* Add File:\s*(.+)$/);
151
+ if (addMatch) return { kind: "add", path: addMatch[1].trim(), hunks: [] };
152
+ const delMatch = header.match(/^\*\*\* Delete File:\s*(.+)$/);
153
+ if (delMatch) return { kind: "delete", path: delMatch[1].trim(), hunks: [] };
154
+ const updMatch = header.match(/^\*\*\* Update File:\s*(.+?)(?:\s*(?:->|→)\s*(.+))?$/);
155
+ if (updMatch) {
156
+ return { kind: "update", path: updMatch[1].trim(), movePath: updMatch[2]?.trim(), hunks: [] };
157
+ }
158
+ throw new PatchParseError(`Unrecognized file header: ${JSON.stringify(header)}`);
159
+ }
160
+
161
+ // ── Hunk assembly: collapse the marker-stream into ordered hunk groups ──
162
+
163
+ interface AssembledHunk {
164
+ /** Lines before the first `-`/`+`: pure context (anchor). */
165
+ context: string[];
166
+ /** Removed + added payload, in emission order. */
167
+ removed: string[];
168
+ added: string[];
169
+ }
170
+
171
+ /** Merge adjacent context/removed/added markers into coherent hunks. */
172
+ function assembleHunks(raw: Hunk[]): AssembledHunk[] {
173
+ const out: AssembledHunk[] = [];
174
+ let cur: AssembledHunk = { context: [], removed: [], added: [] };
175
+ let started = false;
176
+ const flush = () => {
177
+ if (started) out.push(cur);
178
+ cur = { context: [], removed: [], added: [] };
179
+ started = false;
180
+ };
181
+ for (const h of raw) {
182
+ if (h.context.length) {
183
+ if (started && (cur.removed.length || cur.added.length)) flush();
184
+ cur.context.push(...h.context);
185
+ }
186
+ if (h.removed.length || h.added.length) {
187
+ started = true;
188
+ cur.removed.push(...h.removed);
189
+ cur.added.push(...h.added);
190
+ }
191
+ }
192
+ if (started) out.push(cur);
193
+ return out;
194
+ }
195
+
196
+ // ── seekSequence: Codex's progressive fuzzy matcher (4 passes) ──
197
+
198
+ function normalizeAscii(s: string): string {
199
+ return s.trim()
200
+ .replace(/[\u2010-\u2015\u2212]/g, "-")
201
+ .replace(/[\u2018\u2019\u201A\u201B]/g, "'")
202
+ .replace(/[\u201C\u201D\u201E\u201F]/g, '"')
203
+ .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
204
+ }
205
+
206
+ function eqExact(a: string, b: string) { return a === b; }
207
+ function eqRstrip(a: string, b: string) { return a.trimEnd() === b.trimEnd(); }
208
+ function eqTrim(a: string, b: string) { return a.trim() === b.trim(); }
209
+ function eqUnicode(a: string, b: string) { return normalizeAscii(a) === normalizeAscii(b); }
210
+
211
+ /**
212
+ * Count matches of `pattern` lines within `lines`, trying progressively
213
+ * lenient equality. Returns count and first start index under the strictest
214
+ * pass that yields any match.
215
+ */
216
+ export function seekSequence(lines: string[], pattern: string[]): { count: number; firstIndex: number; exact: boolean } {
217
+ if (pattern.length === 0) return { count: 0, firstIndex: -1, exact: true };
218
+ if (pattern.length > lines.length) return { count: 0, firstIndex: -1, exact: true };
219
+ for (const eq of [eqExact, eqRstrip, eqTrim, eqUnicode]) {
220
+ let count = 0;
221
+ let firstIndex = -1;
222
+ for (let i = 0; i <= lines.length - pattern.length; i++) {
223
+ let ok = true;
224
+ for (let j = 0; j < pattern.length; j++) {
225
+ if (!eq(lines[i + j], pattern[j])) { ok = false; break; }
226
+ }
227
+ if (ok) {
228
+ count++;
229
+ if (firstIndex === -1) firstIndex = i;
230
+ }
231
+ }
232
+ if (count > 0) return { count, firstIndex, exact: eq === eqExact };
233
+ }
234
+ return { count: 0, firstIndex: -1, exact: true };
235
+ }
236
+
237
+ // ── Applier ──
238
+
239
+ function stripBom(s: string): string { return s.startsWith("\uFEFF") ? s.slice(1) : s; }
240
+ function normalizeLF(s: string): string { return s.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); }
241
+ function detectLineEnding(content: string): "\r\n" | "\n" {
242
+ const crlf = content.indexOf("\r\n");
243
+ const lf = content.indexOf("\n");
244
+ if (lf === -1) return "\n";
245
+ if (crlf === -1) return "\n";
246
+ return crlf < lf ? "\r\n" : "\n";
247
+ }
248
+ function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string {
249
+ return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
250
+ }
251
+
252
+ /** Resolve a patch path the same way pi's built-in tools do: relative paths
253
+ * resolve under cwd, absolute paths are honored anywhere. (pi's resolveToCwd
254
+ * does NOT restrict absolute paths to cwd; matching that behavior keeps
255
+ * apply_patch compatible with edits the model references by absolute path.) */
256
+ function resolvePathLikePi(p: string, cwd: string): string {
257
+ const expanded = p.startsWith("~") ? (process.env.HOME ?? "") + p.slice(1) : p;
258
+ return isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
259
+ }
260
+
261
+ export interface AppliedFile {
262
+ path: string;
263
+ /** For updates: the before/after content for diffing. */
264
+ before?: string;
265
+ after?: string;
266
+ kind: FileOpKind;
267
+ }
268
+
269
+ export interface ApplyResult {
270
+ files: AppliedFile[];
271
+ /** false if any hunk matched only via fuzzy (non-exact) pass. */
272
+ exact: boolean;
273
+ /** Unified diff string across all updated files. */
274
+ diff: string;
275
+ }
276
+
277
+ /**
278
+ * Apply a parsed patch's ops to the filesystem under cwd. Requires unique
279
+ * context+removed matches for every update hunk. Returns per-file before/after
280
+ * and a combined unified diff.
281
+ */
282
+ export async function applyPatchToFiles(parsed: ParsedPatch, cwd: string): Promise<ApplyResult> {
283
+ let exact = true;
284
+ const files: AppliedFile[] = [];
285
+ const diffParts: string[] = [];
286
+ const applied: string[] = [];
287
+
288
+ for (const op of parsed.ops) {
289
+ const abs = resolvePathLikePi(op.path, cwd);
290
+ try {
291
+ if (op.kind === "delete") {
292
+ const before = await withFileMutationQueue(abs, async () => {
293
+ const raw = await readOptional(abs);
294
+ if (raw === undefined) throw new Error(`Cannot delete (not found): ${op.path}`);
295
+ await unlink(abs);
296
+ return raw;
297
+ });
298
+ files.push({ path: op.path, before, after: "", kind: "delete" });
299
+ applied.push(op.path);
300
+ continue;
301
+ }
302
+
303
+ if (op.kind === "add") {
304
+ const content = op.hunks.map((h) => h.added.join("\n")).join("\n");
305
+ await withFileMutationQueue(abs, async () => {
306
+ if ((await readOptional(abs)) !== undefined) throw new Error(`Cannot add (already exists): ${op.path}`);
307
+ await writeFile(abs, content, "utf-8");
308
+ });
309
+ files.push({ path: op.path, before: "", after: content, kind: "add" });
310
+ applied.push(op.path);
311
+ continue;
312
+ }
313
+
314
+ // update
315
+ const destAbs = op.movePath ? resolvePathLikePi(op.movePath, cwd) : abs;
316
+ const { before, after } = await withFileMutationQueue(destAbs, async () => {
317
+ const raw = await readOptional(abs);
318
+ if (raw === undefined) throw new Error(`Cannot update (not found): ${op.path}`);
319
+ const bom = raw.startsWith("\uFEFF") ? "\uFEFF" : "";
320
+ const ending = detectLineEnding(stripBom(raw));
321
+ const beforeContent = normalizeLF(stripBom(raw));
322
+ const hunks = assembleHunks(op.hunks);
323
+
324
+ let work = beforeContent.split("\n");
325
+ const newHunks: { start: number; removedLen: number; added: string[] }[] = [];
326
+
327
+ for (const h of hunks) {
328
+ const anchor = [...h.context, ...h.removed];
329
+ const { count, firstIndex, exact: hExact } = seekSequence(work, anchor);
330
+ if (!hExact) exact = false;
331
+ if (count === 0) throw new Error(`Hunk context not found in ${op.path}. Ensure context lines match the file.`);
332
+ if (count > 1) throw new Error(`Hunk context is ambiguous (${count} matches) in ${op.path}. Add more surrounding context lines to make it unique.`);
333
+ const removedStart = firstIndex + h.context.length;
334
+ newHunks.push({ start: removedStart, removedLen: h.removed.length, added: h.added });
335
+ }
336
+
337
+ for (const h of [...newHunks].sort((a, b) => b.start - a.start)) {
338
+ work = [...work.slice(0, h.start), ...h.added, ...work.slice(h.start + h.removedLen)];
339
+ }
340
+
341
+ const afterContent = work.join("\n");
342
+ if (op.movePath && op.movePath !== op.path) {
343
+ if ((await readOptional(destAbs)) !== undefined) throw new Error(`Cannot rename onto existing file: ${op.movePath}`);
344
+ const outContent = bom + restoreLineEndings(afterContent, ending);
345
+ await unlink(abs);
346
+ await writeFile(destAbs, outContent, "utf-8");
347
+ } else {
348
+ const outContent = bom + restoreLineEndings(afterContent, ending);
349
+ await writeFile(abs, outContent, "utf-8");
350
+ }
351
+ return { before: beforeContent, after: afterContent };
352
+ });
353
+
354
+ files.push({ path: op.movePath ?? op.path, before, after, kind: "update" });
355
+ diffParts.push(generateUnifiedPatch(op.movePath ?? op.path, before, after));
356
+ applied.push(op.movePath ?? op.path);
357
+ } catch (err) {
358
+ // Report any files already applied before this op failed, so the model
359
+ // doesn't blindly re-apply the whole patch (and hit mismatches).
360
+ const appliedNote = applied.length > 0 ? ` (already applied: ${applied.join(", ")})` : "";
361
+ throw new Error(`${err instanceof Error ? err.message : String(err)}${appliedNote}`);
362
+ }
363
+ }
364
+
365
+ return { files, exact, diff: diffParts.join("\n").trimEnd() };
366
+ }
367
+
368
+ async function readOptional(abs: string): Promise<string | undefined> {
369
+ try {
370
+ const buf = await readFile(abs);
371
+ return buf.toString("utf-8");
372
+ } catch {
373
+ return undefined;
374
+ }
375
+ }
@@ -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.2.0",
4
- "description": "Unified tool-wrapping, argument repair, reasoning management, DeepSeek V4 guidance + Super Power Mode, and defensive leak-cleaning for DeepSeek V4 and GLM model families from any provider.",
3
+ "version": "0.3.0",
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",