@r3b1s/pi-repair-layer 0.1.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/src/index.ts ADDED
@@ -0,0 +1,621 @@
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
+ }