@r3b1s/pi-repair-layer 0.2.1 → 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 +159 -293
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/{index.ts → dist/index.js} +2 -1
- package/dist/index.js.map +1 -0
- package/dist/src/core.d.ts +8 -0
- package/dist/src/core.d.ts.map +1 -0
- package/dist/src/core.js +7 -0
- package/dist/src/core.js.map +1 -0
- package/dist/src/envelope.d.ts +21 -0
- package/dist/src/envelope.d.ts.map +1 -0
- package/dist/src/envelope.js +158 -0
- package/dist/src/envelope.js.map +1 -0
- package/dist/src/grammar-recovery.d.ts +105 -0
- package/dist/src/grammar-recovery.d.ts.map +1 -0
- package/dist/src/grammar-recovery.js +1052 -0
- package/dist/src/grammar-recovery.js.map +1 -0
- package/dist/src/grammar.d.ts +2 -0
- package/dist/src/grammar.d.ts.map +1 -0
- package/dist/src/grammar.js +2 -0
- package/dist/src/grammar.js.map +1 -0
- package/dist/src/index.d.ts +36 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +558 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/lifecycle.d.ts +34 -0
- package/dist/src/lifecycle.d.ts.map +1 -0
- package/dist/src/lifecycle.js +133 -0
- package/dist/src/lifecycle.js.map +1 -0
- package/dist/src/pi.d.ts +19 -0
- package/dist/src/pi.d.ts.map +1 -0
- package/dist/src/pi.js +38 -0
- package/dist/src/pi.js.map +1 -0
- package/dist/src/pipeline.d.ts +10 -0
- package/dist/src/pipeline.d.ts.map +1 -0
- package/dist/src/pipeline.js +166 -0
- package/dist/src/pipeline.js.map +1 -0
- package/dist/src/policy.d.ts +16 -0
- package/dist/src/policy.d.ts.map +1 -0
- package/dist/src/policy.js +31 -0
- package/dist/src/policy.js.map +1 -0
- package/dist/src/preprocess.d.ts +37 -0
- package/dist/src/preprocess.d.ts.map +1 -0
- package/dist/src/preprocess.js +216 -0
- package/dist/src/preprocess.js.map +1 -0
- package/dist/src/repair-engine.d.ts +91 -0
- package/dist/src/repair-engine.d.ts.map +1 -0
- package/dist/src/repair-engine.js +457 -0
- package/dist/src/repair-engine.js.map +1 -0
- package/dist/src/settings.d.ts +38 -0
- package/dist/src/settings.d.ts.map +1 -0
- package/dist/src/settings.js +87 -0
- package/dist/src/settings.js.map +1 -0
- package/dist/src/tables.d.ts +16 -0
- package/dist/src/tables.d.ts.map +1 -0
- package/dist/src/tables.js +302 -0
- package/dist/src/tables.js.map +1 -0
- package/dist/src/types.d.ts +52 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/value-strips.d.ts +52 -0
- package/dist/src/value-strips.d.ts.map +1 -0
- package/dist/src/value-strips.js +191 -0
- package/dist/src/value-strips.js.map +1 -0
- package/docs/how-it-works.md +227 -0
- package/docs/operations.md +141 -0
- package/docs/research.md +247 -0
- package/docs/tool-owner-integration.md +327 -0
- package/package.json +31 -6
- package/src/grammar-recovery.ts +0 -1269
- package/src/index.ts +0 -621
- package/src/repair-engine.ts +0 -518
- package/src/settings.ts +0 -95
- package/src/tables.ts +0 -184
- package/src/value-strips.ts +0 -218
package/src/index.ts
DELETED
|
@@ -1,621 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* pi-repair-layer — tool-input repair for pi's built-in tools.
|
|
3
|
-
*
|
|
4
|
-
* Why this hooks where it does: pi's agent loop runs
|
|
5
|
-
*
|
|
6
|
-
* tool.prepareArguments(raw) -> validateToolArguments(...) -> tool_call event -> execute
|
|
7
|
-
*
|
|
8
|
-
* A validation failure short-circuits to an error result before the
|
|
9
|
-
* `tool_call` extension event ever fires, so event handlers can never see
|
|
10
|
-
* (let alone repair) malformed input. The only pre-validation seam is
|
|
11
|
-
* `prepareArguments`, which extensions reach by overriding a built-in tool
|
|
12
|
-
* with `pi.registerTool({ same name })`. Each override spreads the original
|
|
13
|
-
* tool definition — renderers, prompt metadata, execution — and replaces only
|
|
14
|
-
* `prepareArguments` (chaining the tool's own shim first) plus thin wrappers
|
|
15
|
-
* around `execute` and `renderResult`.
|
|
16
|
-
*
|
|
17
|
-
* Feedback paths per repaired call:
|
|
18
|
-
* - Model: `<repair_note>` lines prepended to the tool result content, seen
|
|
19
|
-
* on the very next inference step of the same agent turn.
|
|
20
|
-
* - User: a `🔨 ✓ input repaired (...)` line appended to the tool row in the
|
|
21
|
-
* TUI (plus the note text if enabled) — toggle both via /repair-settings.
|
|
22
|
-
* - Telemetry: local-only JSONL, summarized by /repair-stats.
|
|
23
|
-
*
|
|
24
|
-
* Unrepairable input (still invalid after repairs) raises a model-readable
|
|
25
|
-
* retry message instead of passing through, because pi's Value.Convert would
|
|
26
|
-
* otherwise coerce it into garbage (null -> "null") and execute it anyway.
|
|
27
|
-
*
|
|
28
|
-
* Env:
|
|
29
|
-
* PI_TOOL_REPAIR_LOG=1 log repair decisions to stderr
|
|
30
|
-
* PI_TOOL_REPAIR_TELEMETRY=off disable telemetry (or =<path> to relocate it)
|
|
31
|
-
* PI_TOOL_REPAIR_PASSTHROUGH=1 on unrepairable input, defer to pi's native
|
|
32
|
-
* validation instead of raising the retry error
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
36
|
-
import { dirname, join } from "node:path";
|
|
37
|
-
import {
|
|
38
|
-
createBashToolDefinition,
|
|
39
|
-
createEditToolDefinition,
|
|
40
|
-
createFindToolDefinition,
|
|
41
|
-
createGrepToolDefinition,
|
|
42
|
-
createLsToolDefinition,
|
|
43
|
-
createReadToolDefinition,
|
|
44
|
-
createWriteToolDefinition,
|
|
45
|
-
type ExtensionAPI,
|
|
46
|
-
getAgentDir,
|
|
47
|
-
type ToolDefinition,
|
|
48
|
-
} from "@earendil-works/pi-coding-agent";
|
|
49
|
-
import {
|
|
50
|
-
type MinimalAssistantMessage,
|
|
51
|
-
modelLeaksGrammar,
|
|
52
|
-
recoverGrammarLeaks,
|
|
53
|
-
} from "./grammar-recovery.ts";
|
|
54
|
-
import { type RepairResult, repairToolInput } from "./repair-engine.ts";
|
|
55
|
-
import {
|
|
56
|
-
loadDisplaySettings,
|
|
57
|
-
type RepairDisplaySettings,
|
|
58
|
-
saveDisplaySettings,
|
|
59
|
-
} from "./settings.ts";
|
|
60
|
-
import { REPAIR_CONFIGS } from "./tables.ts";
|
|
61
|
-
import { stripValues } from "./value-strips.ts";
|
|
62
|
-
|
|
63
|
-
const BUILTIN_FACTORIES = {
|
|
64
|
-
read: createReadToolDefinition,
|
|
65
|
-
bash: createBashToolDefinition,
|
|
66
|
-
edit: createEditToolDefinition,
|
|
67
|
-
write: createWriteToolDefinition,
|
|
68
|
-
grep: createGrepToolDefinition,
|
|
69
|
-
find: createFindToolDefinition,
|
|
70
|
-
ls: createLsToolDefinition,
|
|
71
|
-
} as const;
|
|
72
|
-
|
|
73
|
-
const NOTE_TTL_MS = 5 * 60 * 1000;
|
|
74
|
-
const REPAIR_ENTRY_TYPE = "tool-repair";
|
|
75
|
-
|
|
76
|
-
interface PendingRepair {
|
|
77
|
-
argsJson: string;
|
|
78
|
-
rules: string[];
|
|
79
|
-
notes: string[];
|
|
80
|
-
ts: number;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
interface RepairInfo {
|
|
84
|
-
rules: string[];
|
|
85
|
-
notes: string[];
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
interface TelemetryRecord {
|
|
89
|
-
ts: string;
|
|
90
|
-
/** Present on the tool channel; absent on the message channel. */
|
|
91
|
-
tool?: string;
|
|
92
|
-
model: string | undefined;
|
|
93
|
-
outcome: "repaired" | "unrepairable" | "recovered" | "stripped";
|
|
94
|
-
rules: string[];
|
|
95
|
-
issues?: string | undefined;
|
|
96
|
-
fingerprint?: string | undefined;
|
|
97
|
-
/**
|
|
98
|
-
* "tool" (default when absent, for backward-readability of old records) keys
|
|
99
|
-
* on a tool; "message" is for grammar strip-only events that have no tool.
|
|
100
|
-
*/
|
|
101
|
-
channel?: "tool" | "message";
|
|
102
|
-
/** Grammar family for recovered/stripped events. */
|
|
103
|
-
grammar?: string;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function telemetryPath(): string | undefined {
|
|
107
|
-
const override = process.env.PI_TOOL_REPAIR_TELEMETRY;
|
|
108
|
-
if (override === "off" || override === "0" || override === "false")
|
|
109
|
-
return undefined;
|
|
110
|
-
if (override) return override;
|
|
111
|
-
return join(getAgentDir(), "tool-repair", "telemetry.jsonl");
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function diagnosticsEnabled(): boolean {
|
|
115
|
-
const value = process.env.PI_TOOL_REPAIR_LOG;
|
|
116
|
-
return (
|
|
117
|
-
value !== undefined && value !== "" && value !== "0" && value !== "false"
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function passthroughEnabled(): boolean {
|
|
122
|
-
const value = process.env.PI_TOOL_REPAIR_PASSTHROUGH;
|
|
123
|
-
return (
|
|
124
|
-
value !== undefined && value !== "" && value !== "0" && value !== "false"
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Wraps the built-in result component and appends the repair indicator lines.
|
|
130
|
-
* The inner component is threaded back through `context.lastComponent` on
|
|
131
|
-
* re-renders because built-in renderers reuse it (`lastComponent ?? new Text`)
|
|
132
|
-
* without instanceof checks.
|
|
133
|
-
*/
|
|
134
|
-
class RepairIndicatorComponent {
|
|
135
|
-
inner: { render(width: number): string[] } | undefined;
|
|
136
|
-
extraLines: string[] = [];
|
|
137
|
-
|
|
138
|
-
render(width: number): string[] {
|
|
139
|
-
const lines = this.inner?.render(width) ?? [];
|
|
140
|
-
return this.extraLines.length > 0 ? [...lines, ...this.extraLines] : lines;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export default function toolRepairExtension(pi: ExtensionAPI) {
|
|
145
|
-
let currentModelId: string | undefined;
|
|
146
|
-
let registeredCwd: string | undefined;
|
|
147
|
-
const displaySettings: RepairDisplaySettings = loadDisplaySettings();
|
|
148
|
-
const pendingRepairs = new Map<string, PendingRepair[]>();
|
|
149
|
-
const repairInfoByCallId = new Map<string, RepairInfo>();
|
|
150
|
-
|
|
151
|
-
const stashRepair = (
|
|
152
|
-
tool: string,
|
|
153
|
-
argsJson: string,
|
|
154
|
-
rules: string[],
|
|
155
|
-
notes: string[],
|
|
156
|
-
) => {
|
|
157
|
-
const queue = pendingRepairs.get(tool) ?? [];
|
|
158
|
-
const now = Date.now();
|
|
159
|
-
const fresh = queue.filter((entry) => now - entry.ts < NOTE_TTL_MS);
|
|
160
|
-
fresh.push({ argsJson, rules, notes, ts: now });
|
|
161
|
-
pendingRepairs.set(tool, fresh);
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
const takeRepair = (
|
|
165
|
-
tool: string,
|
|
166
|
-
argsJson: string,
|
|
167
|
-
): PendingRepair | undefined => {
|
|
168
|
-
const queue = pendingRepairs.get(tool);
|
|
169
|
-
if (!queue) return undefined;
|
|
170
|
-
const index = queue.findIndex((entry) => entry.argsJson === argsJson);
|
|
171
|
-
if (index === -1) return undefined;
|
|
172
|
-
const [entry] = queue.splice(index, 1);
|
|
173
|
-
return entry;
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
const logTelemetry = (record: TelemetryRecord) => {
|
|
177
|
-
const path = telemetryPath();
|
|
178
|
-
if (!path) return;
|
|
179
|
-
try {
|
|
180
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
181
|
-
appendFileSync(path, `${JSON.stringify(record)}\n`);
|
|
182
|
-
} catch {
|
|
183
|
-
// Telemetry must never break tool execution.
|
|
184
|
-
}
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
const diag = (tool: string, result: RepairResult) => {
|
|
188
|
-
if (!diagnosticsEnabled()) return;
|
|
189
|
-
const rules =
|
|
190
|
-
result.rulesFired.length > 0 ? result.rulesFired.join(",") : "none";
|
|
191
|
-
process.stderr.write(
|
|
192
|
-
`[pi-repair] tool=${tool} outcome=${result.outcome} rules=${rules}${
|
|
193
|
-
result.issueSummary ? ` issues=${result.issueSummary}` : ""
|
|
194
|
-
}\n`,
|
|
195
|
-
);
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
const indicatorLines = (
|
|
199
|
-
info: RepairInfo,
|
|
200
|
-
theme: { fg?: (color: string, text: string) => string },
|
|
201
|
-
): string[] => {
|
|
202
|
-
if (!displaySettings.showIndicator) return [];
|
|
203
|
-
const muted = (text: string) => {
|
|
204
|
-
try {
|
|
205
|
-
return theme?.fg ? theme.fg("muted", text) : text;
|
|
206
|
-
} catch {
|
|
207
|
-
return text;
|
|
208
|
-
}
|
|
209
|
-
};
|
|
210
|
-
const lines = [muted(`🔨 ✓ input repaired (${info.rules.join(", ")})`)];
|
|
211
|
-
if (displaySettings.showNotes) {
|
|
212
|
-
for (const note of info.notes) lines.push(muted(` ↳ ${note}`));
|
|
213
|
-
}
|
|
214
|
-
return lines;
|
|
215
|
-
};
|
|
216
|
-
|
|
217
|
-
const registerOverrides = (cwd: string) => {
|
|
218
|
-
if (registeredCwd === cwd) return;
|
|
219
|
-
registeredCwd = cwd;
|
|
220
|
-
for (const [name, factory] of Object.entries(BUILTIN_FACTORIES)) {
|
|
221
|
-
const original = factory(cwd) as ToolDefinition<any, any>;
|
|
222
|
-
const config = REPAIR_CONFIGS[name];
|
|
223
|
-
const originalPrepare = original.prepareArguments;
|
|
224
|
-
const originalRenderResult = original.renderResult?.bind(original);
|
|
225
|
-
|
|
226
|
-
pi.registerTool({
|
|
227
|
-
...original,
|
|
228
|
-
prepareArguments(raw: unknown) {
|
|
229
|
-
let shimmed = raw;
|
|
230
|
-
if (originalPrepare) {
|
|
231
|
-
try {
|
|
232
|
-
shimmed = originalPrepare(raw);
|
|
233
|
-
} catch {
|
|
234
|
-
shimmed = raw;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
// Value-strip pre-pass: model-gated strips run before the engine, on
|
|
238
|
-
// input that is valid both before and after (an anchor-bled string
|
|
239
|
-
// still validates as a string), so the engine never sees them.
|
|
240
|
-
const strip = stripValues({
|
|
241
|
-
toolName: name,
|
|
242
|
-
input: shimmed,
|
|
243
|
-
modelId: currentModelId,
|
|
244
|
-
});
|
|
245
|
-
const engineInput = strip.result.changed ? strip.input : shimmed;
|
|
246
|
-
const result = repairToolInput({
|
|
247
|
-
toolName: name,
|
|
248
|
-
schema: original.parameters,
|
|
249
|
-
input: engineInput,
|
|
250
|
-
config,
|
|
251
|
-
});
|
|
252
|
-
if (result.outcome === "valid") {
|
|
253
|
-
// Engine found nothing to fix. If a strip fired, still surface it as
|
|
254
|
-
// a repair; otherwise pass the (untouched) input straight through.
|
|
255
|
-
if (!strip.result.changed) return shimmed;
|
|
256
|
-
logTelemetry({
|
|
257
|
-
ts: new Date().toISOString(),
|
|
258
|
-
tool: name,
|
|
259
|
-
model: currentModelId,
|
|
260
|
-
outcome: "repaired",
|
|
261
|
-
rules: strip.result.rules,
|
|
262
|
-
issues: undefined,
|
|
263
|
-
fingerprint: undefined,
|
|
264
|
-
});
|
|
265
|
-
stashRepair(
|
|
266
|
-
name,
|
|
267
|
-
JSON.stringify(engineInput),
|
|
268
|
-
strip.result.rules,
|
|
269
|
-
strip.result.notes,
|
|
270
|
-
);
|
|
271
|
-
return engineInput;
|
|
272
|
-
}
|
|
273
|
-
diag(name, result);
|
|
274
|
-
logTelemetry({
|
|
275
|
-
ts: new Date().toISOString(),
|
|
276
|
-
tool: name,
|
|
277
|
-
model: currentModelId,
|
|
278
|
-
outcome: result.outcome,
|
|
279
|
-
rules: [...strip.result.rules, ...result.rulesFired],
|
|
280
|
-
issues: result.issueSummary,
|
|
281
|
-
fingerprint: result.fingerprint,
|
|
282
|
-
});
|
|
283
|
-
if (result.outcome === "repaired") {
|
|
284
|
-
stashRepair(
|
|
285
|
-
name,
|
|
286
|
-
JSON.stringify(result.args),
|
|
287
|
-
[...strip.result.rules, ...result.rulesFired],
|
|
288
|
-
[...strip.result.notes, ...result.notes],
|
|
289
|
-
);
|
|
290
|
-
return result.args;
|
|
291
|
-
}
|
|
292
|
-
// Unrepairable. Raise a model-readable retry error: pi's loop turns
|
|
293
|
-
// it into an error tool result. Passing the input through instead
|
|
294
|
-
// would let Value.Convert coerce it (null -> "null") and execute.
|
|
295
|
-
if (result.retryMessage && !passthroughEnabled()) {
|
|
296
|
-
throw new Error(result.retryMessage);
|
|
297
|
-
}
|
|
298
|
-
// Passthrough mode: hand back the stripped input if a strip fired, so
|
|
299
|
-
// its cleanup is not lost when we defer to pi's native validation.
|
|
300
|
-
return engineInput;
|
|
301
|
-
},
|
|
302
|
-
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
303
|
-
const repair = takeRepair(name, JSON.stringify(params));
|
|
304
|
-
if (repair) {
|
|
305
|
-
repairInfoByCallId.set(toolCallId, {
|
|
306
|
-
rules: repair.rules,
|
|
307
|
-
notes: repair.notes,
|
|
308
|
-
});
|
|
309
|
-
try {
|
|
310
|
-
pi.appendEntry(REPAIR_ENTRY_TYPE, {
|
|
311
|
-
toolCallId,
|
|
312
|
-
tool: name,
|
|
313
|
-
rules: repair.rules,
|
|
314
|
-
notes: repair.notes,
|
|
315
|
-
});
|
|
316
|
-
} catch {
|
|
317
|
-
// Persistence is best-effort; the in-memory map still works.
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
const result = await original.execute(
|
|
321
|
-
toolCallId,
|
|
322
|
-
params,
|
|
323
|
-
signal,
|
|
324
|
-
onUpdate,
|
|
325
|
-
ctx,
|
|
326
|
-
);
|
|
327
|
-
if (repair && repair.notes.length > 0) {
|
|
328
|
-
const noteText = repair.notes
|
|
329
|
-
.map((note) => `<repair_note>${note}</repair_note>`)
|
|
330
|
-
.join("\n");
|
|
331
|
-
const first = Array.isArray(result.content)
|
|
332
|
-
? result.content[0]
|
|
333
|
-
: undefined;
|
|
334
|
-
if (first?.type === "text") {
|
|
335
|
-
first.text = `${noteText}\n${first.text}`;
|
|
336
|
-
} else if (Array.isArray(result.content)) {
|
|
337
|
-
result.content.unshift({ type: "text", text: noteText });
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
return result;
|
|
341
|
-
},
|
|
342
|
-
...(originalRenderResult
|
|
343
|
-
? {
|
|
344
|
-
renderResult(
|
|
345
|
-
result: any,
|
|
346
|
-
options: any,
|
|
347
|
-
theme: any,
|
|
348
|
-
context: any,
|
|
349
|
-
) {
|
|
350
|
-
const info = repairInfoByCallId.get(context.toolCallId);
|
|
351
|
-
const last = context.lastComponent;
|
|
352
|
-
const wrapper =
|
|
353
|
-
last instanceof RepairIndicatorComponent
|
|
354
|
-
? last
|
|
355
|
-
: info
|
|
356
|
-
? new RepairIndicatorComponent()
|
|
357
|
-
: undefined;
|
|
358
|
-
if (!wrapper)
|
|
359
|
-
return originalRenderResult(result, options, theme, context);
|
|
360
|
-
wrapper.inner = originalRenderResult(result, options, theme, {
|
|
361
|
-
...context,
|
|
362
|
-
lastComponent: wrapper.inner,
|
|
363
|
-
});
|
|
364
|
-
wrapper.extraLines = info ? indicatorLines(info, theme) : [];
|
|
365
|
-
return wrapper as any;
|
|
366
|
-
},
|
|
367
|
-
}
|
|
368
|
-
: {}),
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
};
|
|
372
|
-
|
|
373
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
374
|
-
currentModelId = ctx.model?.id;
|
|
375
|
-
registerOverrides(ctx.cwd);
|
|
376
|
-
// Restore indicators for repairs recorded earlier in this session
|
|
377
|
-
// (survives /reload and session resume).
|
|
378
|
-
try {
|
|
379
|
-
for (const entry of ctx.sessionManager.getEntries()) {
|
|
380
|
-
const e = entry as { type?: string; customType?: string; data?: any };
|
|
381
|
-
if (
|
|
382
|
-
e.type === "custom" &&
|
|
383
|
-
e.customType === REPAIR_ENTRY_TYPE &&
|
|
384
|
-
typeof e.data?.toolCallId === "string"
|
|
385
|
-
) {
|
|
386
|
-
repairInfoByCallId.set(e.data.toolCallId, {
|
|
387
|
-
rules: Array.isArray(e.data.rules) ? e.data.rules : [],
|
|
388
|
-
notes: Array.isArray(e.data.notes) ? e.data.notes : [],
|
|
389
|
-
});
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
} catch {
|
|
393
|
-
// Older pi versions may not expose entries here; indicators just start fresh.
|
|
394
|
-
}
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
pi.on("model_select", async (event, ctx) => {
|
|
398
|
-
currentModelId =
|
|
399
|
-
(event as { model?: { id?: string } }).model?.id ??
|
|
400
|
-
ctx.model?.id ??
|
|
401
|
-
currentModelId;
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
const safeGetActiveTools = (): string[] => {
|
|
405
|
-
try {
|
|
406
|
-
return (
|
|
407
|
-
(pi as { getActiveTools?: () => string[] }).getActiveTools?.() ?? []
|
|
408
|
-
);
|
|
409
|
-
} catch {
|
|
410
|
-
return [];
|
|
411
|
-
}
|
|
412
|
-
};
|
|
413
|
-
|
|
414
|
-
// Grammar-leak recovery: on message_end, strip tool-call grammar the model
|
|
415
|
-
// printed as text and — in recover mode — promote it to real toolCalls that
|
|
416
|
-
// execute the same turn (docs/research.md Claim 4). Promotion is gated on
|
|
417
|
-
// stopReason "stop" (Claim 7); stripping is allowed on any stopReason.
|
|
418
|
-
pi.on("message_end", async (event, _ctx) => {
|
|
419
|
-
const mode = displaySettings.grammarRecovery;
|
|
420
|
-
if (mode === "off") return undefined;
|
|
421
|
-
const message = (event as unknown as { message?: MinimalAssistantMessage })
|
|
422
|
-
.message;
|
|
423
|
-
if (message?.role !== "assistant") return undefined;
|
|
424
|
-
if (!modelLeaksGrammar(currentModelId)) return undefined;
|
|
425
|
-
|
|
426
|
-
const allowed = displaySettings.grammarAllowedTools;
|
|
427
|
-
const knownTools = new Set(
|
|
428
|
-
allowed.length > 0 ? allowed : safeGetActiveTools(),
|
|
429
|
-
);
|
|
430
|
-
|
|
431
|
-
const result = recoverGrammarLeaks(message, {
|
|
432
|
-
mode,
|
|
433
|
-
knownTools,
|
|
434
|
-
requireKnownTool: true,
|
|
435
|
-
});
|
|
436
|
-
if (!result.changed) return undefined;
|
|
437
|
-
|
|
438
|
-
if (result.promoted) {
|
|
439
|
-
// Tool-keyed "recovered" telemetry, and a stashed note so the executed
|
|
440
|
-
// (re-entering) built-in call surfaces <repair_note> via the execute wrap.
|
|
441
|
-
for (const call of result.recoveredCalls) {
|
|
442
|
-
const note = `Recovered a leaked ${call.grammar} tool call for "${call.name}" that the model printed as text instead of emitting a real tool call. Emit a proper tool call next time.`;
|
|
443
|
-
stashRepair(
|
|
444
|
-
call.name,
|
|
445
|
-
JSON.stringify(call.arguments),
|
|
446
|
-
[`grammarRecovery:${call.grammar}`],
|
|
447
|
-
[note],
|
|
448
|
-
);
|
|
449
|
-
logTelemetry({
|
|
450
|
-
ts: new Date().toISOString(),
|
|
451
|
-
tool: call.name,
|
|
452
|
-
model: currentModelId,
|
|
453
|
-
outcome: "recovered",
|
|
454
|
-
rules: [`grammarRecovery:${call.grammar}`],
|
|
455
|
-
grammar: call.grammar,
|
|
456
|
-
});
|
|
457
|
-
}
|
|
458
|
-
} else {
|
|
459
|
-
// Strip-only: no tool to key on. One message-channel event naming the
|
|
460
|
-
// grammar families that were stripped.
|
|
461
|
-
logTelemetry({
|
|
462
|
-
ts: new Date().toISOString(),
|
|
463
|
-
model: currentModelId,
|
|
464
|
-
outcome: "stripped",
|
|
465
|
-
rules: ["grammarStrip"],
|
|
466
|
-
channel: "message",
|
|
467
|
-
grammar:
|
|
468
|
-
result.strippedGrammars.length > 0
|
|
469
|
-
? result.strippedGrammars.join(",")
|
|
470
|
-
: undefined,
|
|
471
|
-
});
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// The replacement keeps the original assistant role; pi types the message
|
|
475
|
-
// as its own AgentMessage union, so hand it back through `any`.
|
|
476
|
-
return { message: result.message as any };
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
const nextGrammarMode = (mode: RepairDisplaySettings["grammarRecovery"]) =>
|
|
480
|
-
mode === "off" ? "strip" : mode === "strip" ? "recover" : "off";
|
|
481
|
-
|
|
482
|
-
pi.registerCommand("repair-settings", {
|
|
483
|
-
description:
|
|
484
|
-
"Toggle the tool-repair indicator (🔨), repair-note display, and grammar recovery",
|
|
485
|
-
handler: async (_args, ctx) => {
|
|
486
|
-
for (;;) {
|
|
487
|
-
const indicatorLabel = `Repair indicator (🔨 ✓): ${displaySettings.showIndicator ? "on" : "off"} — toggle`;
|
|
488
|
-
const notesLabel = `Repair note text beneath indicator: ${displaySettings.showNotes ? "on" : "off"} — toggle`;
|
|
489
|
-
const grammarLabel = `Grammar-leak recovery: ${displaySettings.grammarRecovery} — cycle (off → strip → recover)`;
|
|
490
|
-
const choice = await ctx.ui.select("Tool repair display settings", [
|
|
491
|
-
indicatorLabel,
|
|
492
|
-
notesLabel,
|
|
493
|
-
grammarLabel,
|
|
494
|
-
"Close",
|
|
495
|
-
]);
|
|
496
|
-
if (choice === undefined || choice === "Close") break;
|
|
497
|
-
if (choice === indicatorLabel)
|
|
498
|
-
displaySettings.showIndicator = !displaySettings.showIndicator;
|
|
499
|
-
if (choice === notesLabel)
|
|
500
|
-
displaySettings.showNotes = !displaySettings.showNotes;
|
|
501
|
-
if (choice === grammarLabel)
|
|
502
|
-
displaySettings.grammarRecovery = nextGrammarMode(
|
|
503
|
-
displaySettings.grammarRecovery,
|
|
504
|
-
);
|
|
505
|
-
saveDisplaySettings(displaySettings);
|
|
506
|
-
}
|
|
507
|
-
ctx.ui.notify(
|
|
508
|
-
`Repair display: indicator ${displaySettings.showIndicator ? "on" : "off"}, notes ${
|
|
509
|
-
displaySettings.showNotes ? "on" : "off"
|
|
510
|
-
}, grammar recovery ${displaySettings.grammarRecovery} (applies from now on)`,
|
|
511
|
-
"info",
|
|
512
|
-
);
|
|
513
|
-
},
|
|
514
|
-
});
|
|
515
|
-
|
|
516
|
-
pi.registerCommand("repair-stats", {
|
|
517
|
-
description: "Summarize tool-input repair telemetry",
|
|
518
|
-
handler: async (_args, ctx) => {
|
|
519
|
-
const path = telemetryPath();
|
|
520
|
-
if (!path || !existsSync(path)) {
|
|
521
|
-
ctx.ui.notify("No repair telemetry recorded yet.", "info");
|
|
522
|
-
return;
|
|
523
|
-
}
|
|
524
|
-
const records: TelemetryRecord[] = [];
|
|
525
|
-
for (const line of readFileSync(path, "utf-8").split("\n")) {
|
|
526
|
-
if (!line.trim()) continue;
|
|
527
|
-
try {
|
|
528
|
-
records.push(JSON.parse(line));
|
|
529
|
-
} catch {
|
|
530
|
-
// skip malformed lines
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
if (records.length === 0) {
|
|
534
|
-
ctx.ui.notify("No repair telemetry recorded yet.", "info");
|
|
535
|
-
return;
|
|
536
|
-
}
|
|
537
|
-
type ToolCounts = {
|
|
538
|
-
repaired: number;
|
|
539
|
-
unrepairable: number;
|
|
540
|
-
recovered: number;
|
|
541
|
-
};
|
|
542
|
-
const emptyCounts = (): ToolCounts => ({
|
|
543
|
-
repaired: 0,
|
|
544
|
-
unrepairable: 0,
|
|
545
|
-
recovered: 0,
|
|
546
|
-
});
|
|
547
|
-
const byTool = new Map<string, ToolCounts>();
|
|
548
|
-
const byRule = new Map<string, number>();
|
|
549
|
-
const byModel = new Map<string, ToolCounts>();
|
|
550
|
-
const byGrammar = new Map<string, number>();
|
|
551
|
-
let stripOnlyEvents = 0;
|
|
552
|
-
let toolEvents = 0;
|
|
553
|
-
|
|
554
|
-
const bump = (
|
|
555
|
-
counts: ToolCounts,
|
|
556
|
-
outcome: TelemetryRecord["outcome"],
|
|
557
|
-
) => {
|
|
558
|
-
if (
|
|
559
|
-
outcome === "repaired" ||
|
|
560
|
-
outcome === "unrepairable" ||
|
|
561
|
-
outcome === "recovered"
|
|
562
|
-
)
|
|
563
|
-
counts[outcome] += 1;
|
|
564
|
-
};
|
|
565
|
-
|
|
566
|
-
for (const record of records) {
|
|
567
|
-
// Records without an explicit channel are legacy tool-channel repairs.
|
|
568
|
-
if (record.channel === "message") {
|
|
569
|
-
stripOnlyEvents += 1;
|
|
570
|
-
for (const family of (record.grammar ?? "unknown").split(","))
|
|
571
|
-
byGrammar.set(family, (byGrammar.get(family) ?? 0) + 1);
|
|
572
|
-
} else {
|
|
573
|
-
toolEvents += 1;
|
|
574
|
-
const key = record.tool ?? "unknown";
|
|
575
|
-
const tool = byTool.get(key) ?? emptyCounts();
|
|
576
|
-
bump(tool, record.outcome);
|
|
577
|
-
byTool.set(key, tool);
|
|
578
|
-
const model = byModel.get(record.model ?? "unknown") ?? emptyCounts();
|
|
579
|
-
bump(model, record.outcome);
|
|
580
|
-
byModel.set(record.model ?? "unknown", model);
|
|
581
|
-
}
|
|
582
|
-
for (const rule of record.rules)
|
|
583
|
-
byRule.set(rule, (byRule.get(rule) ?? 0) + 1);
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
const fmt = (c: ToolCounts) =>
|
|
587
|
-
`${c.repaired} repaired, ${c.recovered} recovered, ${c.unrepairable} unrepairable`;
|
|
588
|
-
const lines = [
|
|
589
|
-
`Tool repair telemetry (${records.length} events: ${toolEvents} tool, ${stripOnlyEvents} grammar strip-only)`,
|
|
590
|
-
"",
|
|
591
|
-
"By tool:",
|
|
592
|
-
];
|
|
593
|
-
for (const [tool, counts] of [...byTool].sort(
|
|
594
|
-
(a, b) =>
|
|
595
|
-
b[1].repaired + b[1].recovered - (a[1].repaired + a[1].recovered),
|
|
596
|
-
)) {
|
|
597
|
-
lines.push(` ${tool}: ${fmt(counts)}`);
|
|
598
|
-
}
|
|
599
|
-
lines.push("", "By model:");
|
|
600
|
-
for (const [model, counts] of [...byModel].sort(
|
|
601
|
-
(a, b) =>
|
|
602
|
-
b[1].repaired + b[1].recovered - (a[1].repaired + a[1].recovered),
|
|
603
|
-
)) {
|
|
604
|
-
lines.push(` ${model}: ${fmt(counts)}`);
|
|
605
|
-
}
|
|
606
|
-
if (byGrammar.size > 0) {
|
|
607
|
-
lines.push("", "Grammar strip-only events (message channel):");
|
|
608
|
-
for (const [family, count] of [...byGrammar].sort(
|
|
609
|
-
(a, b) => b[1] - a[1],
|
|
610
|
-
)) {
|
|
611
|
-
lines.push(` ${family}: ${count}`);
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
lines.push("", "Rules fired:");
|
|
615
|
-
for (const [rule, count] of [...byRule].sort((a, b) => b[1] - a[1])) {
|
|
616
|
-
lines.push(` ${rule}: ${count}`);
|
|
617
|
-
}
|
|
618
|
-
ctx.ui.notify(lines.join("\n"), "info");
|
|
619
|
-
},
|
|
620
|
-
});
|
|
621
|
-
}
|