@r3b1s/pi-repair-layer 0.2.1 → 0.4.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.
Files changed (76) hide show
  1. package/README.md +173 -293
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/{index.ts → dist/index.js} +2 -1
  5. package/dist/index.js.map +1 -0
  6. package/dist/src/core.d.ts +8 -0
  7. package/dist/src/core.d.ts.map +1 -0
  8. package/dist/src/core.js +7 -0
  9. package/dist/src/core.js.map +1 -0
  10. package/dist/src/envelope.d.ts +21 -0
  11. package/dist/src/envelope.d.ts.map +1 -0
  12. package/dist/src/envelope.js +158 -0
  13. package/dist/src/envelope.js.map +1 -0
  14. package/dist/src/grammar-recovery.d.ts +105 -0
  15. package/dist/src/grammar-recovery.d.ts.map +1 -0
  16. package/dist/src/grammar-recovery.js +1052 -0
  17. package/dist/src/grammar-recovery.js.map +1 -0
  18. package/dist/src/grammar.d.ts +2 -0
  19. package/dist/src/grammar.d.ts.map +1 -0
  20. package/dist/src/grammar.js +2 -0
  21. package/dist/src/grammar.js.map +1 -0
  22. package/dist/src/index.d.ts +36 -0
  23. package/dist/src/index.d.ts.map +1 -0
  24. package/dist/src/index.js +558 -0
  25. package/dist/src/index.js.map +1 -0
  26. package/dist/src/lifecycle.d.ts +34 -0
  27. package/dist/src/lifecycle.d.ts.map +1 -0
  28. package/dist/src/lifecycle.js +133 -0
  29. package/dist/src/lifecycle.js.map +1 -0
  30. package/dist/src/pi.d.ts +19 -0
  31. package/dist/src/pi.d.ts.map +1 -0
  32. package/dist/src/pi.js +38 -0
  33. package/dist/src/pi.js.map +1 -0
  34. package/dist/src/pipeline.d.ts +10 -0
  35. package/dist/src/pipeline.d.ts.map +1 -0
  36. package/dist/src/pipeline.js +166 -0
  37. package/dist/src/pipeline.js.map +1 -0
  38. package/dist/src/policy.d.ts +16 -0
  39. package/dist/src/policy.d.ts.map +1 -0
  40. package/dist/src/policy.js +31 -0
  41. package/dist/src/policy.js.map +1 -0
  42. package/dist/src/preprocess.d.ts +37 -0
  43. package/dist/src/preprocess.d.ts.map +1 -0
  44. package/dist/src/preprocess.js +216 -0
  45. package/dist/src/preprocess.js.map +1 -0
  46. package/dist/src/repair-engine.d.ts +91 -0
  47. package/dist/src/repair-engine.d.ts.map +1 -0
  48. package/dist/src/repair-engine.js +457 -0
  49. package/dist/src/repair-engine.js.map +1 -0
  50. package/dist/src/settings.d.ts +38 -0
  51. package/dist/src/settings.d.ts.map +1 -0
  52. package/dist/src/settings.js +87 -0
  53. package/dist/src/settings.js.map +1 -0
  54. package/dist/src/tables.d.ts +16 -0
  55. package/dist/src/tables.d.ts.map +1 -0
  56. package/dist/src/tables.js +302 -0
  57. package/dist/src/tables.js.map +1 -0
  58. package/dist/src/types.d.ts +52 -0
  59. package/dist/src/types.d.ts.map +1 -0
  60. package/dist/src/types.js +2 -0
  61. package/dist/src/types.js.map +1 -0
  62. package/dist/src/value-strips.d.ts +52 -0
  63. package/dist/src/value-strips.d.ts.map +1 -0
  64. package/dist/src/value-strips.js +191 -0
  65. package/dist/src/value-strips.js.map +1 -0
  66. package/docs/how-it-works.md +227 -0
  67. package/docs/operations.md +141 -0
  68. package/docs/research.md +390 -0
  69. package/docs/tool-owner-integration.md +557 -0
  70. package/package.json +31 -6
  71. package/src/grammar-recovery.ts +0 -1269
  72. package/src/index.ts +0 -621
  73. package/src/repair-engine.ts +0 -518
  74. package/src/settings.ts +0 -95
  75. package/src/tables.ts +0 -184
  76. package/src/value-strips.ts +0 -218
@@ -1,518 +0,0 @@
1
- /**
2
- * Validate-then-repair engine for LLM tool-call inputs.
3
- *
4
- * Design (matching the publicly described behavior of commandcode's repair layer):
5
- *
6
- * - Strictly valid inputs are never touched: the fast path is a plain
7
- * `Value.Check` and returns the original input by reference.
8
- * - On failure, the validator's own issue list localizes the damage. Repairs are
9
- * attempted only at the exact paths the schema disagreed with, in a fixed
10
- * order (JSON-array parsing must run before bare-string wrapping, or
11
- * `'["a","b"]'` becomes `['["a","b"]']`).
12
- * - Every mutation produces a model-facing note so the model can learn the real
13
- * contract on the next turn. Transparency over silent magic.
14
- *
15
- * The strict check deliberately runs BEFORE TypeBox's `Value.Convert` (which pi
16
- * applies during validation), because Convert silently corrupts exactly the
17
- * inputs this layer exists to fix: `'["a","b"]'` for an array field becomes
18
- * `['["a","b"]']`, `null` for an optional string becomes the string `"null"`,
19
- * and `null` for an optional number becomes `0` — all of which then pass
20
- * validation and execute with garbage. Repairing at the strict-error sites
21
- * first means those inputs are fixed properly (with a note) instead. Benign
22
- * coercions ("5" -> 5) are left to Convert: if no repair rule fires and Convert
23
- * alone makes the input valid, the input is reported as valid and returned
24
- * untouched, so pi's native behavior is preserved.
25
- *
26
- * The one deliberate exception to validate-then-repair is markdown auto-link
27
- * unwrapping on path fields: `[notes.md](http://notes.md)` is a perfectly valid
28
- * string, so validation can never flag it. It is unwrapped unconditionally, but
29
- * only in the degenerate case where the link text equals the url without its
30
- * protocol — real markdown links pass through untouched.
31
- */
32
-
33
- import type { TSchema } from "typebox";
34
- import { Value } from "typebox/value";
35
-
36
- export interface StructuralRepair {
37
- /** Rule name recorded in telemetry when the repair fires. */
38
- name: string;
39
- /** Mutate `args` in place. Return a model-facing note when a repair was applied, false otherwise. */
40
- apply(args: Record<string, unknown>, toolName: string): string | false;
41
- }
42
-
43
- export interface ToolRepairConfig {
44
- /** canonical field name -> wrong names models emit for it, matched at any depth by key. */
45
- fieldAliases?: Record<string, readonly string[]>;
46
- /** When the whole input is a bare string, wrap it as `{ [field]: value }`. */
47
- rootString?: { field: string; wrapInArray?: boolean };
48
- /** Top-level string fields holding filesystem paths (markdown auto-link unwrapping). */
49
- pathFields?: readonly string[];
50
- /** Tool-specific shape folds that single-field rules cannot express. */
51
- structural?: readonly StructuralRepair[];
52
- }
53
-
54
- export interface RepairResult {
55
- outcome: "valid" | "repaired" | "unrepairable";
56
- /** What prepareArguments should return: the untouched input, the repaired input, or (unrepairable) the untouched input. */
57
- args: unknown;
58
- rulesFired: string[];
59
- notes: string[];
60
- /** Compact description of the original validation failure, for telemetry. */
61
- issueSummary: string | undefined;
62
- /** Stable hash of (tool, failure shape), for spotting per-model regressions. */
63
- fingerprint: string | undefined;
64
- /**
65
- * Model-readable error for unrepairable input. Throwing this from
66
- * prepareArguments matters: handing the raw input back to pi instead would
67
- * let Value.Convert corrupt it (null -> "null") and execute the call anyway.
68
- */
69
- retryMessage: string | undefined;
70
- }
71
-
72
- const MAX_ISSUES = 32;
73
-
74
- // ---------------------------------------------------------------------------
75
- // Markdown auto-link unwrapping
76
- // ---------------------------------------------------------------------------
77
-
78
- const MARKDOWN_LINK = /\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g;
79
- const PROTOCOL = /^https?:\/\//;
80
-
81
- export function unwrapMarkdownAutoLinks(value: string): string {
82
- return value.replace(MARKDOWN_LINK, (match, text: string, url: string) =>
83
- url.replace(PROTOCOL, "") === text ? text : match,
84
- );
85
- }
86
-
87
- // ---------------------------------------------------------------------------
88
- // Issue collection (TypeBox emits AJV-style errors)
89
- // ---------------------------------------------------------------------------
90
-
91
- interface RawIssue {
92
- keyword: string;
93
- instancePath: string;
94
- params: Record<string, unknown> | undefined;
95
- message: string | undefined;
96
- }
97
-
98
- interface IssueSite {
99
- parent: Record<string, unknown> | unknown[];
100
- key: string | number;
101
- keyword: string;
102
- /** JSON-schema type name the schema expected at this site, when known. */
103
- expected: string | undefined;
104
- }
105
-
106
- function collectErrors(schema: TSchema, value: unknown): RawIssue[] {
107
- const out: RawIssue[] = [];
108
- for (const error of Value.Errors(schema, value)) {
109
- out.push({
110
- keyword: String((error as { keyword?: string }).keyword ?? ""),
111
- instancePath: String(
112
- (error as { instancePath?: string }).instancePath ?? "",
113
- ),
114
- params: (error as { params?: Record<string, unknown> }).params,
115
- message: (error as { message?: string }).message,
116
- });
117
- if (out.length >= MAX_ISSUES) break;
118
- }
119
- return out;
120
- }
121
-
122
- function parsePointer(pointer: string): (string | number)[] {
123
- if (pointer === "" || pointer === "/") return [];
124
- return pointer
125
- .split("/")
126
- .slice(1)
127
- .map((segment) => {
128
- const decoded = segment.replace(/~1/g, "/").replace(/~0/g, "~");
129
- return /^\d+$/.test(decoded) ? Number(decoded) : decoded;
130
- });
131
- }
132
-
133
- function resolveAt(root: unknown, segments: (string | number)[]): unknown {
134
- let current: unknown = root;
135
- for (const segment of segments) {
136
- if (current === null || typeof current !== "object") return undefined;
137
- current = (current as Record<string | number, unknown>)[segment];
138
- }
139
- return current;
140
- }
141
-
142
- function isPlainObject(value: unknown): value is Record<string, unknown> {
143
- return value !== null && typeof value === "object" && !Array.isArray(value);
144
- }
145
-
146
- function isContainer(
147
- value: unknown,
148
- ): value is Record<string, unknown> | unknown[] {
149
- return value !== null && typeof value === "object";
150
- }
151
-
152
- /** Value.Check without its type predicate — the predicate narrows `unknown` to `never` on the false branch. */
153
- function schemaAccepts(schema: TSchema, value: unknown): boolean {
154
- return Value.Check(schema, value);
155
- }
156
-
157
- function collectIssueSites(schema: TSchema, value: unknown): IssueSite[] {
158
- const sites: IssueSite[] = [];
159
- const seen = new Set<string>();
160
- const push = (site: IssueSite, pointerKey: string) => {
161
- if (seen.has(pointerKey)) return;
162
- seen.add(pointerKey);
163
- sites.push(site);
164
- };
165
- for (const issue of collectErrors(schema, value)) {
166
- if (issue.keyword === "required") {
167
- const container = resolveAt(value, parsePointer(issue.instancePath));
168
- if (!isPlainObject(container)) continue;
169
- const missing = issue.params?.requiredProperties;
170
- if (!Array.isArray(missing)) continue;
171
- for (const key of missing) {
172
- if (typeof key !== "string") continue;
173
- push(
174
- {
175
- parent: container,
176
- key,
177
- keyword: issue.keyword,
178
- expected: undefined,
179
- },
180
- `${issue.instancePath}::${key}`,
181
- );
182
- }
183
- continue;
184
- }
185
- const segments = parsePointer(issue.instancePath);
186
- if (segments.length === 0) continue; // root-level issues are handled by root repairs
187
- const parent = resolveAt(value, segments.slice(0, -1));
188
- if (!isContainer(parent)) continue;
189
- const key = segments[segments.length - 1];
190
- const expected =
191
- typeof issue.params?.type === "string" ? issue.params.type : undefined;
192
- push(
193
- { parent, key, keyword: issue.keyword, expected },
194
- `${issue.instancePath}`,
195
- );
196
- }
197
- return sites;
198
- }
199
-
200
- // ---------------------------------------------------------------------------
201
- // Per-issue repair rules, in application order
202
- // ---------------------------------------------------------------------------
203
-
204
- type IssueRule = (
205
- site: IssueSite,
206
- toolName: string,
207
- config: ToolRepairConfig,
208
- ) => string | false;
209
-
210
- const renameAliasedField: IssueRule = (site, toolName, config) => {
211
- const { parent, key } = site;
212
- if (typeof key !== "string" || !isPlainObject(parent)) return false;
213
- // A null target does not block the rename: `{path: null, file_path: "/x"}`
214
- // should recover from the alias, and dropNullOrUndefinedField runs later.
215
- if (key in parent && parent[key] !== undefined && parent[key] !== null)
216
- return false;
217
- const aliases = config.fieldAliases?.[key];
218
- if (!aliases) return false;
219
- for (const alias of aliases) {
220
- if (!(alias in parent)) continue;
221
- const value = parent[alias];
222
- if (value == null) continue;
223
- if (value === "") continue;
224
- parent[key] = value;
225
- delete parent[alias];
226
- return `Renamed \`${alias}\` to \`${key}\` for tool "${toolName}". Use \`${key}\` next time — \`${alias}\` is not a valid field for this tool.`;
227
- }
228
- return false;
229
- };
230
-
231
- const dropNullOrUndefinedField: IssueRule = (site, toolName) => {
232
- const { parent, key } = site;
233
- if (typeof key !== "string" || !isPlainObject(parent)) return false;
234
- if (!(key in parent)) return false;
235
- const value = parent[key];
236
- if (value !== null && value !== undefined) return false;
237
- delete parent[key];
238
- const kind = value === null ? "null" : "undefined";
239
- return `Dropped ${kind} \`${key}\` from tool "${toolName}". Optional fields can be omitted entirely rather than sent as ${kind}.`;
240
- };
241
-
242
- const dropEmptyObjectPlaceholder: IssueRule = (site, toolName) => {
243
- const { parent, key, expected } = site;
244
- if (expected !== "array" || !isPlainObject(parent) || typeof key !== "string")
245
- return false;
246
- const value = parent[key];
247
- if (!isPlainObject(value) || Object.keys(value).length > 0) return false;
248
- delete parent[key];
249
- return `Dropped empty \`{}\` placeholder from \`${key}\` for tool "${toolName}". Send an actual array (or omit the field) next time.`;
250
- };
251
-
252
- function tryParseJson(text: string): unknown {
253
- try {
254
- return JSON.parse(text);
255
- } catch {
256
- return undefined;
257
- }
258
- }
259
-
260
- const parseJsonStringifiedArray: IssueRule = (site, toolName) => {
261
- const { parent, key, expected } = site;
262
- if (expected !== "array") return false;
263
- const value = (parent as Record<string | number, unknown>)[key];
264
- if (typeof value !== "string") return false;
265
- const trimmed = value.trim();
266
- if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return false;
267
- const parsed = tryParseJson(trimmed);
268
- if (!Array.isArray(parsed)) return false;
269
- (parent as Record<string | number, unknown>)[key] = parsed;
270
- return `Parsed JSON-stringified array for \`${String(key)}\` in tool "${toolName}". Send the array literal directly (e.g. \`["a","b"]\`) next time, not a string.`;
271
- };
272
-
273
- const parseJsonStringifiedObject: IssueRule = (site, toolName) => {
274
- const { parent, key, expected } = site;
275
- if (expected !== "object") return false;
276
- const value = (parent as Record<string | number, unknown>)[key];
277
- if (typeof value !== "string") return false;
278
- const trimmed = value.trim();
279
- if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return false;
280
- const parsed = tryParseJson(trimmed);
281
- if (!isPlainObject(parsed)) return false;
282
- (parent as Record<string | number, unknown>)[key] = parsed;
283
- return `Parsed JSON-stringified object for \`${String(key)}\` in tool "${toolName}". Send the object literal directly next time, not a string.`;
284
- };
285
-
286
- const wrapBareStringAsArray: IssueRule = (site, toolName) => {
287
- const { parent, key, expected } = site;
288
- if (expected !== "array") return false;
289
- const value = (parent as Record<string | number, unknown>)[key];
290
- if (typeof value !== "string") return false;
291
- (parent as Record<string | number, unknown>)[key] = [value];
292
- return `Wrapped your bare string in a single-element array for \`${String(key)}\` in tool "${toolName}". Send an array (e.g. \`["foo"]\`) next time, not a single string.`;
293
- };
294
-
295
- /**
296
- * Stage 5 re-collects issue sites after each pass that fired a rule, so
297
- * repairs that reveal nested problems (parse a stringified array, then rename
298
- * aliased fields inside it) converge instead of failing. Three passes cover
299
- * every known two-step combination with one pass of headroom.
300
- */
301
- const MAX_ISSUE_PASSES = 3;
302
-
303
- // Order matters: parse before wrap, rename before drop.
304
- const ISSUE_RULES: readonly [string, IssueRule][] = [
305
- ["renameAliasedField", renameAliasedField],
306
- ["dropNullOrUndefinedField", dropNullOrUndefinedField],
307
- ["dropEmptyObjectPlaceholder", dropEmptyObjectPlaceholder],
308
- ["parseJsonStringifiedArray", parseJsonStringifiedArray],
309
- ["parseJsonStringifiedObject", parseJsonStringifiedObject],
310
- ["wrapBareStringAsArray", wrapBareStringAsArray],
311
- ];
312
-
313
- // ---------------------------------------------------------------------------
314
- // Fingerprinting (FNV-1a) for local telemetry
315
- // ---------------------------------------------------------------------------
316
-
317
- function fnv1a(text: string): string {
318
- let hash = 0x811c9dc5;
319
- for (let i = 0; i < text.length; i++) {
320
- hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
321
- }
322
- return (hash >>> 0).toString(16).padStart(8, "0");
323
- }
324
-
325
- function describeIssue(issue: RawIssue): string {
326
- const path = issue.instancePath === "" ? "(root)" : issue.instancePath;
327
- const detail =
328
- typeof issue.params?.type === "string"
329
- ? issue.params.type
330
- : Array.isArray(issue.params?.requiredProperties)
331
- ? issue.params.requiredProperties.join(",")
332
- : "";
333
- return `${path}|${issue.keyword}|${detail}`;
334
- }
335
-
336
- function formatRetryMessage(
337
- toolName: string,
338
- issues: RawIssue[],
339
- input: unknown,
340
- ): string {
341
- const lines = issues
342
- .slice(0, 8)
343
- .map(
344
- (issue) =>
345
- ` • ${issue.instancePath === "" ? "(root)" : issue.instancePath}: ${issue.message ?? issue.keyword}`,
346
- );
347
- let received: string;
348
- try {
349
- received = JSON.stringify(input) ?? String(input);
350
- } catch {
351
- received = String(input);
352
- }
353
- if (received.length > 300) received = `${received.slice(0, 300)}…`;
354
- return `Invalid input for tool "${toolName}". Fix these issues and retry:\n${lines.join("\n")}\nReceived: ${received}`;
355
- }
356
-
357
- // ---------------------------------------------------------------------------
358
- // Driver
359
- // ---------------------------------------------------------------------------
360
-
361
- export function repairToolInput(options: {
362
- toolName: string;
363
- schema: TSchema;
364
- input: unknown;
365
- config?: ToolRepairConfig;
366
- }): RepairResult {
367
- const { toolName, schema, input } = options;
368
- const config = options.config ?? {};
369
- const rulesFired: string[] = [];
370
- const notes: string[] = [];
371
- const fire = (rule: string, note: string) => {
372
- if (!rulesFired.includes(rule)) rulesFired.push(rule);
373
- notes.push(note);
374
- };
375
-
376
- const unwrapPathFields = (target: Record<string, unknown>) => {
377
- for (const field of config.pathFields ?? []) {
378
- const value = target[field];
379
- if (typeof value !== "string") continue;
380
- const unwrapped = unwrapMarkdownAutoLinks(value);
381
- if (unwrapped === value) continue;
382
- target[field] = unwrapped;
383
- fire(
384
- "unwrapMarkdownAutoLink",
385
- `Unwrapped a markdown auto-link in \`${field}\` for tool "${toolName}" (\`${value}\` -> \`${unwrapped}\`). Send plain paths, not markdown links.`,
386
- );
387
- }
388
- };
389
-
390
- // Stage 1: unconditional path-field cleanup on a clone. Auto-linked paths are
391
- // valid strings, so validation alone can never catch them.
392
- let current: unknown = structuredClone(input);
393
- if (isPlainObject(current)) unwrapPathFields(current);
394
-
395
- // Stage 2: strict fast path. No Convert here — see the header comment.
396
- if (schemaAccepts(schema, current)) {
397
- if (rulesFired.length === 0) {
398
- return {
399
- outcome: "valid",
400
- args: input,
401
- rulesFired,
402
- notes,
403
- issueSummary: undefined,
404
- fingerprint: undefined,
405
- retryMessage: undefined,
406
- };
407
- }
408
- return {
409
- outcome: "repaired",
410
- args: current,
411
- rulesFired,
412
- notes,
413
- issueSummary: undefined,
414
- fingerprint: undefined,
415
- retryMessage: undefined,
416
- };
417
- }
418
-
419
- // Record the original failure shape before repairs mutate it.
420
- const originalIssues = collectErrors(schema, current);
421
- const issueSummary = originalIssues.map(describeIssue).join("; ");
422
- const fingerprint = fnv1a(
423
- `${toolName}::${originalIssues.map(describeIssue).sort().join(";")}`,
424
- );
425
- const unrepairable = (): RepairResult => ({
426
- outcome: "unrepairable",
427
- args: input,
428
- rulesFired: [],
429
- notes: [],
430
- issueSummary,
431
- fingerprint,
432
- retryMessage: formatRetryMessage(toolName, originalIssues, input),
433
- });
434
-
435
- // Stage 3: root repairs — the model sent something other than an object.
436
- if (typeof current === "string") {
437
- const trimmed = current.trim();
438
- const parsed =
439
- trimmed.startsWith("{") && trimmed.endsWith("}")
440
- ? tryParseJson(trimmed)
441
- : undefined;
442
- if (isPlainObject(parsed)) {
443
- current = parsed;
444
- fire(
445
- "parseJsonStringifiedRootObject",
446
- `Parsed your JSON-stringified arguments for tool "${toolName}". Send the arguments as a JSON object next time, not a string.`,
447
- );
448
- } else if (config.rootString) {
449
- const { field, wrapInArray } = config.rootString;
450
- current = { [field]: wrapInArray ? [current] : current };
451
- fire(
452
- "wrapRootStringAsObject",
453
- `Wrapped your bare string as \`{${field}: ${wrapInArray ? '["..."]' : '"..."'}}\` for tool "${toolName}". Call this tool with an object, not a bare string, next time.`,
454
- );
455
- }
456
- if (isPlainObject(current)) unwrapPathFields(current);
457
- }
458
- if (!isPlainObject(current)) return unrepairable();
459
-
460
- // Stage 4: tool-specific structural repairs.
461
- for (const structural of config.structural ?? []) {
462
- const note = structural.apply(current, toolName);
463
- if (note !== false) fire(structural.name, note);
464
- }
465
-
466
- // Stage 5: per-issue repairs at the strict validator's failure sites.
467
- // Iterates because one repair can expose sites the first collection couldn't
468
- // see — e.g. parsing a stringified `edits` array surfaces aliased fields
469
- // inside the parsed elements. Stops when the value validates or a full pass
470
- // fires nothing (each firing rule mutates `current`, so a firing pass always
471
- // makes progress); the cap only bounds pathological rule interactions.
472
- for (
473
- let pass = 0;
474
- pass < MAX_ISSUE_PASSES && !schemaAccepts(schema, current);
475
- pass++
476
- ) {
477
- let repairedThisPass = false;
478
- for (const site of collectIssueSites(schema, current)) {
479
- for (const [name, rule] of ISSUE_RULES) {
480
- const note = rule(site, toolName, config);
481
- if (note !== false) {
482
- fire(name, note);
483
- repairedThisPass = true;
484
- break;
485
- }
486
- }
487
- }
488
- if (!repairedThisPass) break;
489
- }
490
-
491
- // Stage 6: final verdict, now through pi's own pipeline (Convert, then
492
- // Check) so benign coercions like "5" -> 5 are accounted for.
493
- const probe = Value.Convert(schema, structuredClone(current));
494
- if (schemaAccepts(schema, probe)) {
495
- if (rulesFired.length === 0) {
496
- // Convert alone fixes it — defer to pi's native coercion, untouched.
497
- return {
498
- outcome: "valid",
499
- args: input,
500
- rulesFired,
501
- notes,
502
- issueSummary: undefined,
503
- fingerprint: undefined,
504
- retryMessage: undefined,
505
- };
506
- }
507
- return {
508
- outcome: "repaired",
509
- args: probe,
510
- rulesFired,
511
- notes,
512
- issueSummary,
513
- fingerprint,
514
- retryMessage: undefined,
515
- };
516
- }
517
- return unrepairable();
518
- }
package/src/settings.ts DELETED
@@ -1,95 +0,0 @@
1
- /**
2
- * Display settings for the repair layer, persisted at
3
- * ~/.pi/agent/tool-repair/settings.json and edited via /repair-settings.
4
- */
5
-
6
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
- import { dirname, join } from "node:path";
8
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
9
-
10
- /**
11
- * Grammar-leak recovery mode:
12
- * - "off": never touch assistant text.
13
- * - "strip": remove leaked tool-call grammar from text (model-gated), but never
14
- * promote it to an executable call. This is the default.
15
- * - "recover": additionally promote leaked calls to real toolCalls that execute
16
- * in the same turn. Opt-in, because it promotes model text into execution.
17
- */
18
- export type GrammarRecoveryMode = "off" | "strip" | "recover";
19
-
20
- export interface RepairDisplaySettings {
21
- /** Append a `🔨 ✓ input repaired (...)` line to repaired tool calls in the TUI. */
22
- showIndicator: boolean;
23
- /** Also show the repair note text beneath the indicator. */
24
- showNotes: boolean;
25
- /** Grammar-leak recovery mode. Default "strip" (still subject to the model gate). */
26
- grammarRecovery: GrammarRecoveryMode;
27
- /**
28
- * Optional allowlist of tool names a leaked call may be promoted to. When
29
- * empty, the active tool set is used (a leaked call is only promoted when its
30
- * name is a currently-registered tool).
31
- */
32
- grammarAllowedTools: string[];
33
- }
34
-
35
- export const DEFAULT_DISPLAY_SETTINGS: RepairDisplaySettings = {
36
- showIndicator: true,
37
- showNotes: false,
38
- grammarRecovery: "strip",
39
- grammarAllowedTools: [],
40
- };
41
-
42
- const GRAMMAR_MODES: GrammarRecoveryMode[] = ["off", "strip", "recover"];
43
-
44
- function isGrammarMode(value: unknown): value is GrammarRecoveryMode {
45
- return (
46
- typeof value === "string" && (GRAMMAR_MODES as string[]).includes(value)
47
- );
48
- }
49
-
50
- export function displaySettingsPath(): string {
51
- return (
52
- process.env.PI_TOOL_REPAIR_SETTINGS ??
53
- join(getAgentDir(), "tool-repair", "settings.json")
54
- );
55
- }
56
-
57
- export function loadDisplaySettings(
58
- path = displaySettingsPath(),
59
- ): RepairDisplaySettings {
60
- try {
61
- const parsed = JSON.parse(readFileSync(path, "utf-8"));
62
- return {
63
- showIndicator:
64
- typeof parsed.showIndicator === "boolean"
65
- ? parsed.showIndicator
66
- : DEFAULT_DISPLAY_SETTINGS.showIndicator,
67
- showNotes:
68
- typeof parsed.showNotes === "boolean"
69
- ? parsed.showNotes
70
- : DEFAULT_DISPLAY_SETTINGS.showNotes,
71
- grammarRecovery: isGrammarMode(parsed.grammarRecovery)
72
- ? parsed.grammarRecovery
73
- : DEFAULT_DISPLAY_SETTINGS.grammarRecovery,
74
- grammarAllowedTools:
75
- Array.isArray(parsed.grammarAllowedTools) &&
76
- parsed.grammarAllowedTools.every((t: unknown) => typeof t === "string")
77
- ? parsed.grammarAllowedTools
78
- : [...DEFAULT_DISPLAY_SETTINGS.grammarAllowedTools],
79
- };
80
- } catch {
81
- return { ...DEFAULT_DISPLAY_SETTINGS };
82
- }
83
- }
84
-
85
- export function saveDisplaySettings(
86
- settings: RepairDisplaySettings,
87
- path = displaySettingsPath(),
88
- ): void {
89
- try {
90
- mkdirSync(dirname(path), { recursive: true });
91
- writeFileSync(path, `${JSON.stringify(settings, null, "\t")}\n`);
92
- } catch {
93
- // Settings persistence must never break the session.
94
- }
95
- }