@xynogen/pix-edit 0.2.13 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +3 -1
  2. package/src/edit.ts +181 -49
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-edit",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "description": "Pi tool \u2014 precise text replacement edit with diff rendering",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -43,6 +43,8 @@
43
43
  "@xynogen/pix-runtime": "^0.16.0"
44
44
  },
45
45
  "peerDependencies": {
46
+ "typebox": "*",
47
+ "@earendil-works/pi-ai": "*",
46
48
  "@earendil-works/pi-coding-agent": "*",
47
49
  "@earendil-works/pi-tui": "*"
48
50
  }
package/src/edit.ts CHANGED
@@ -2,6 +2,7 @@ import { constants } from "node:fs";
2
2
  import { access, readFile, writeFile } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { validateToolArguments } from "@earendil-works/pi-ai";
5
6
  import type {
6
7
  AgentToolUpdateCallback,
7
8
  EditToolInput,
@@ -116,70 +117,201 @@ const hashFields = {
116
117
  }),
117
118
  ),
118
119
  };
119
- const editSchema = Type.Object(
120
- {
121
- path: Type.String({ minLength: 1 }),
122
- // ponytail: union whole arrays so the schema rejects mixed formats before execution.
123
- edits: Type.Union(
124
- [
125
- Type.Array(
126
- Type.Union([
127
- Type.Object(
128
- {
129
- op: Type.Literal("replace"),
130
- ...hashFields,
131
- end: Type.Optional(
132
- Type.String({
133
- pattern: "^[1-9][0-9]*#[0-9A-F]{3}$",
134
- description: "Inclusive end anchor. Omit for one line.",
135
- }),
136
- ),
137
- },
138
- { additionalProperties: false },
139
- ),
140
- Type.Object(
141
- {
142
- op: Type.Union([Type.Literal("insert_before"), Type.Literal("insert_after")]),
143
- ...hashFields,
144
- },
145
- { additionalProperties: false },
146
- ),
147
- ]),
148
- { minItems: 1 },
149
- ),
150
- Type.Array(
151
- Type.Object(
152
- {
153
- oldText: Type.String({
154
- minLength: 1,
155
- description:
156
- "Fallback only after a hash-anchor edit fails. Exact current source text; must match once.",
157
- }),
158
- newText: Type.String({ description: "Replacement text. Empty string deletes." }),
159
- },
160
- { additionalProperties: false },
161
- ),
162
- { minItems: 1 },
120
+ const hashEdits = Type.Array(
121
+ Type.Union([
122
+ Type.Object(
123
+ {
124
+ op: Type.Literal("replace"),
125
+ ...hashFields,
126
+ end: Type.Optional(
127
+ Type.String({
128
+ pattern: "^[1-9][0-9]*#[0-9A-F]{3}$",
129
+ description: "Inclusive end anchor. Omit for one line.",
130
+ }),
163
131
  ),
164
- ],
132
+ },
133
+ { additionalProperties: false },
134
+ ),
135
+ Type.Object(
165
136
  {
166
- description:
167
- "Try hash-anchor edits first. Use exact-text edits only after a hash-anchor edit fails. Never mix formats.",
137
+ op: Type.Union([Type.Literal("insert_before"), Type.Literal("insert_after")]),
138
+ ...hashFields,
168
139
  },
140
+ { additionalProperties: false },
169
141
  ),
142
+ ]),
143
+ { minItems: 1 },
144
+ );
145
+ const exactEdits = Type.Array(
146
+ Type.Object(
147
+ {
148
+ oldText: Type.String({
149
+ minLength: 1,
150
+ description:
151
+ "Fallback only after a hash-anchor edit fails. Exact current source text; must match once.",
152
+ }),
153
+ newText: Type.String({ description: "Replacement text. Empty string deletes." }),
154
+ },
155
+ { additionalProperties: false },
156
+ ),
157
+ { minItems: 1 },
158
+ );
159
+ const path = Type.String({ minLength: 1 });
160
+ const editSchema = Type.Object(
161
+ {
162
+ path,
163
+ // ponytail: union whole arrays so the schema rejects mixed formats before execution.
164
+ edits: Type.Union([hashEdits, exactEdits], {
165
+ description:
166
+ "Try hash-anchor edits first. Use exact-text edits only after a hash-anchor edit fails. Never mix formats.",
167
+ }),
170
168
  },
171
169
  { additionalProperties: false },
172
170
  );
171
+ // One branch each, so a bad call reports only the errors of the format it meant.
172
+ const hashSchema = Type.Object({ path, edits: hashEdits }, { additionalProperties: false });
173
+ const exactSchema = Type.Object({ path, edits: exactEdits }, { additionalProperties: false });
174
+
175
+ const parseJson = (value: string): unknown => {
176
+ try {
177
+ return JSON.parse(value);
178
+ } catch {
179
+ return value;
180
+ }
181
+ };
173
182
 
174
183
  export function prepareHashArguments(input: unknown): unknown {
175
184
  if (!input || typeof input !== "object" || Array.isArray(input)) return input;
176
185
  const args = { ...input } as Record<string, unknown>;
177
- if (typeof args.edits === "string") args.edits = JSON.parse(args.edits);
186
+ if (typeof args.edits === "string") args.edits = parseJson(args.edits);
178
187
  if (args.edits && typeof args.edits === "object" && !Array.isArray(args.edits))
179
188
  args.edits = [args.edits];
189
+ // Top recorded failure: one edit item sent as a JSON string.
190
+ if (Array.isArray(args.edits))
191
+ args.edits = args.edits.map((item) => {
192
+ if (typeof item !== "string") return item;
193
+ const parsed = parseJson(item);
194
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : item;
195
+ });
180
196
  return args;
181
197
  }
182
198
 
199
+ const EDIT_FIX = "Fix: resend only the broken field; keep one format per call.";
200
+
201
+ function editFix(format: "hash" | "exact", path: string, problem: string): string {
202
+ if (/\.(pos|end)$/.test(path) && problem.startsWith("expected to match"))
203
+ return "LINE#HASH from read, hash is uppercase hex (12#A3F)";
204
+ if (/\.op$/.test(path)) return "op is replace, insert_before or insert_after";
205
+ if (/\.lines$/.test(path) && problem === "missing")
206
+ return "missing; add lines: [] of literal source lines ([] deletes)";
207
+ if (/\.lines(\.\d+)?$/.test(path)) return "lines is an array of strings, one per line";
208
+ if (/\.oldText$/.test(path) && problem !== "missing")
209
+ return "oldText is non-empty exact current source text";
210
+ if (problem === "unknown field") {
211
+ if (!path.startsWith("edits.")) return "unknown field; send only path and edits";
212
+ return format === "hash"
213
+ ? "unknown field; hash items take op, pos, end?, lines"
214
+ : "unknown field; exact items take oldText, newText";
215
+ }
216
+ if (/^edits\.\d+$/.test(path)) return `${problem}; each edit is one object`;
217
+ if (path === "edits") return `${problem}; edits is a non-empty array of objects`;
218
+ return problem;
219
+ }
220
+
221
+ /**
222
+ * Normalize, then check with the host validator. A bad call fails with a few
223
+ * lines that name the field and the fix, for the format the call meant.
224
+ */
225
+ export function prepareEditArguments(input: unknown): unknown {
226
+ const args = prepareHashArguments(input);
227
+ const edits = (args as { edits?: unknown } | undefined)?.edits;
228
+ const items = Array.isArray(edits) ? edits : [];
229
+ const kind = (item: unknown) =>
230
+ item && typeof item === "object"
231
+ ? ["op", "pos", "end", "lines"].some((k) => k in item)
232
+ ? "hash"
233
+ : ["oldText", "newText"].some((k) => k in item)
234
+ ? "exact"
235
+ : undefined
236
+ : undefined;
237
+ const kinds = items.map(kind);
238
+ const at = (k: string) => kinds.flatMap((x, i) => (x === k ? [i] : [])).join(",");
239
+ if (kinds.includes("hash") && kinds.includes("exact"))
240
+ throw new Error(
241
+ badArgs(
242
+ "edit",
243
+ [`- edits: mixed formats (hash at [${at("hash")}], exact at [${at("exact")}])`],
244
+ EDIT_FIX,
245
+ ),
246
+ );
247
+ const format = kinds.includes("exact") ? "exact" : "hash";
248
+ checkArgs(
249
+ "edit",
250
+ format === "hash" ? hashSchema : exactSchema,
251
+ args,
252
+ (path, problem) => editFix(format, path, problem),
253
+ EDIT_FIX,
254
+ );
255
+ return args;
256
+ }
257
+
258
+ // ponytail: pix-ask has a copy of this host-error reformatter (clip/badArgs/checkArgs). Move it
259
+ // to pix-runtime when a third tool needs it (public API addition, minor bump).
260
+ const clip = (text: string, max: number) =>
261
+ text.length > max ? `${text.slice(0, max - 1)}…` : text;
262
+
263
+ function badArgs(tool: string, rows: string[], footer: string): string {
264
+ const head =
265
+ rows.length > 5 ? `${tool}: ${rows.length} bad arguments, first 5` : `${tool}: bad arguments`;
266
+ return [head, ...rows.slice(0, 5), footer].join("\n");
267
+ }
268
+
269
+ /** Host-validate `args`; on failure throw a short field/fix list, not the full-argument echo. */
270
+ function checkArgs(
271
+ tool: string,
272
+ schema: object,
273
+ args: unknown,
274
+ fix: (path: string, problem: string, value: unknown) => string,
275
+ footer: string,
276
+ ): void {
277
+ try {
278
+ validateToolArguments(
279
+ { name: tool, description: "", parameters: schema },
280
+ { type: "toolCall", id: "", name: tool, arguments: args as never },
281
+ );
282
+ return;
283
+ } catch (error) {
284
+ // ponytail: reads pi-ai's " - path: problem" rows; an unknown format rethrows the host error.
285
+ const text = (error instanceof Error ? error.message : String(error)).split(
286
+ "\n\nReceived arguments:",
287
+ )[0];
288
+ const seen = new Set<string>();
289
+ const rows = [...(text ?? "").matchAll(/^ {2}- (\S+): (.+)$/gm)].map(
290
+ ([, path = "", problem = ""]) => {
291
+ const value =
292
+ path === "root"
293
+ ? args
294
+ : path
295
+ .split(".")
296
+ .reduce<unknown>(
297
+ (v, k) =>
298
+ v && typeof v === "object" ? (v as Record<string, unknown>)[k] : undefined,
299
+ args,
300
+ );
301
+ const shown =
302
+ value === undefined ? "" : ` ${clip(JSON.stringify(value) ?? String(value), 40)}`;
303
+ // State a repeated fix once, so five rows stay a few hundred bytes.
304
+ const advice = fix(path, problem, value);
305
+ const said = seen.has(advice);
306
+ seen.add(advice);
307
+ return `- ${path.replace(/\.(\d+)/g, "[$1]")}${shown}: ${said ? "same fix" : advice}`;
308
+ },
309
+ );
310
+ if (!rows.length) throw error;
311
+ throw new Error(badArgs(tool, rows, footer));
312
+ }
313
+ }
314
+
183
315
  export function editPath(path: string, cwd: string): string {
184
316
  let value = path.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " ").replace(/^@/, "");
185
317
  if (
@@ -437,7 +569,7 @@ export function registerEditTool(
437
569
  promptGuidelines: [
438
570
  "For edit, try hash anchors from read first. Only if the hash-anchor edit fails, fall back to oldText/newText. If the file changed, read the affected region again before using current source text; never guess anchors or overwrite unseen changes.",
439
571
  ],
440
- prepareArguments: prepareHashArguments,
572
+ prepareArguments: prepareEditArguments,
441
573
  constrainedSampling: { type: "json_schema", strict: "prefer" },
442
574
  renderShell: "self",
443
575