@xynogen/pix-optimizer 1.0.0 → 1.0.2

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
@@ -78,7 +78,7 @@ npm i -g @toon-format/cli
78
78
  ## Installation
79
79
 
80
80
  ```bash
81
- pi install git:github.com/xynogen/pix-optimizer
81
+ pi install npm:@xynogen/pix-optimizer
82
82
  ```
83
83
 
84
84
  ## Architecture
@@ -101,6 +101,30 @@ that `/opt` dispatches to. All three share one `OptimizerStatus`.
101
101
  bun test
102
102
  ```
103
103
 
104
+ ## Origin
105
+
106
+ This package was built by merging two upstream Pi community packages:
107
+
108
+ - **Caveman mode** — merged from [`git:github.com/jonjonrankin/pi-caveman`](https://github.com/jonjonrankin/pi-caveman)
109
+ (itself a fork of `npm:pi-caveman`). Reimplemented here with multiple compression levels,
110
+ a settings dialog, per-session persistence, and integration with the shared `/opt` command.
111
+
112
+ - **RTK rewriting** — merged from `npm:pi-rtk-optimizer`. Reimplemented here with a two-layer
113
+ approach: prompt injection + live bash command rewriting that handles chained commands
114
+ (`&&`, `||`, `;`, `|`).
115
+
116
+ Both upstreams are MIT licensed. Neither codebase was copied directly — the logic was
117
+ rewritten and combined into a single extension with a unified `/opt` command and shared status bar.
118
+ This package does not sync back to either upstream.
119
+
120
+ ## Full distro
121
+
122
+ To install the complete pix suite (all packages + Pi itself):
123
+
124
+ ```bash
125
+ curl -fsSL https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.sh | sh
126
+ ```
127
+
104
128
  ## License
105
129
 
106
130
  MIT
package/package.json CHANGED
@@ -1,48 +1,53 @@
1
1
  {
2
- "name": "@xynogen/pix-optimizer",
3
- "version": "1.0.0",
4
- "description": "Performance optimization suite for Pi Coding Agent - caveman mode + RTK tool rewriting + jq/TOON JSON compression",
5
- "type": "module",
6
- "main": "src/index.ts",
7
- "scripts": {
8
- "test": "bun test"
9
- },
10
- "files": [
11
- "src",
12
- "README.md",
13
- "LICENSE"
14
- ],
15
- "pi": {
16
- "extensions": ["./src/index.ts"]
17
- },
18
- "keywords": [
19
- "pi",
20
- "pi-package",
21
- "pi-extension",
22
- "optimizer",
23
- "caveman",
24
- "rtk",
25
- "toon",
26
- "jq",
27
- "json",
28
- "performance"
29
- ],
30
- "author": "xynogen",
31
- "license": "MIT",
32
- "repository": {
33
- "type": "git",
34
- "url": "git+https://github.com/xynogen/pix-mono.git",
35
- "directory": "packages/pix-optimizer"
36
- },
37
- "homepage": "https://github.com/xynogen/pix-mono/tree/main/packages/pix-optimizer#readme",
38
- "bugs": {
39
- "url": "https://github.com/xynogen/pix-mono/issues"
40
- },
41
- "publishConfig": {
42
- "access": "public"
43
- },
44
- "peerDependencies": {
45
- "@earendil-works/pi-coding-agent": "*",
46
- "@earendil-works/pi-tui": "*"
47
- }
2
+ "name": "@xynogen/pix-optimizer",
3
+ "version": "1.0.2",
4
+ "description": "Performance optimization suite for Pi Coding Agent - caveman mode + RTK tool rewriting + jq/TOON JSON compression",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "scripts": {
8
+ "test": "bun test"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "pi": {
16
+ "extensions": [
17
+ "./src/index.ts"
18
+ ],
19
+ "skills": [
20
+ "./src/skills"
21
+ ]
22
+ },
23
+ "keywords": [
24
+ "pi",
25
+ "pi-package",
26
+ "pi-extension",
27
+ "optimizer",
28
+ "caveman",
29
+ "rtk",
30
+ "toon",
31
+ "jq",
32
+ "json",
33
+ "performance"
34
+ ],
35
+ "author": "xynogen",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/xynogen/pix-mono.git",
40
+ "directory": "packages/pix-optimizer"
41
+ },
42
+ "homepage": "https://github.com/xynogen/pix-mono/tree/main/packages/pix-optimizer#readme",
43
+ "bugs": {
44
+ "url": "https://github.com/xynogen/pix-mono/issues"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "peerDependencies": {
50
+ "@earendil-works/pi-coding-agent": "*",
51
+ "@earendil-works/pi-tui": "*"
52
+ }
48
53
  }
@@ -1,16 +1,16 @@
1
- import { describe, it, expect } from "bun:test";
1
+ import { describe, expect, it } from "bun:test";
2
2
  import {
3
- LEVELS,
4
- LEVEL_NUMBERS,
5
- STOP_ALIASES,
6
- STATUS_LABELS,
7
- buildPrompt,
8
3
  buildHelp,
9
- resolveLevel,
10
- toggleLevel,
11
- parseConfig,
4
+ buildPrompt,
12
5
  DEFAULT_CONFIG,
6
+ LEVEL_NUMBERS,
7
+ LEVELS,
13
8
  type Level,
9
+ parseConfig,
10
+ resolveLevel,
11
+ STATUS_LABELS,
12
+ STOP_ALIASES,
13
+ toggleLevel,
14
14
  } from "./caveman.ts";
15
15
 
16
16
  // ── LEVELS ────────────────────────────────────────────────────────────────────
@@ -21,7 +21,7 @@ describe("LEVELS", () => {
21
21
  });
22
22
 
23
23
  it("contains all expected levels", () => {
24
- const expected = ["off", "lite", "full", "ultra", "micro"];
24
+ const expected: Level[] = ["off", "lite", "full", "ultra", "micro"];
25
25
  for (const l of expected) expect(LEVELS).toContain(l);
26
26
  });
27
27
  });
@@ -52,10 +52,10 @@ describe("STATUS_LABELS", () => {
52
52
  });
53
53
 
54
54
  it("latin levels are uppercase", () => {
55
- expect(STATUS_LABELS["lite"]).toBe("LITE");
56
- expect(STATUS_LABELS["full"]).toBe("FULL");
57
- expect(STATUS_LABELS["ultra"]).toBe("ULTRA");
58
- expect(STATUS_LABELS["micro"]).toBe("MICRO");
55
+ expect(STATUS_LABELS.lite).toBe("LITE");
56
+ expect(STATUS_LABELS.full).toBe("FULL");
57
+ expect(STATUS_LABELS.ultra).toBe("ULTRA");
58
+ expect(STATUS_LABELS.micro).toBe("MICRO");
59
59
  });
60
60
  });
61
61
 
@@ -202,7 +202,6 @@ describe("buildPrompt", () => {
202
202
  expect(ultra).toContain("Abbreviate");
203
203
  expect(lite).not.toContain("Abbreviate");
204
204
  });
205
-
206
205
  });
207
206
 
208
207
  // ── parseConfig ───────────────────────────────────────────────────────────────
@@ -233,7 +232,9 @@ describe("parseConfig", () => {
233
232
 
234
233
  it("accepts all valid levels as defaultLevel", () => {
235
234
  for (const l of LEVELS) {
236
- expect(parseConfig({ defaultLevel: l, showStatus: true }).defaultLevel).toBe(l);
235
+ expect(
236
+ parseConfig({ defaultLevel: l, showStatus: true }).defaultLevel,
237
+ ).toBe(l);
237
238
  }
238
239
  });
239
240
  });
package/src/caveman.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * called by index.ts alongside rtk(pi).
6
6
  */
7
7
 
8
- import { readFile, writeFile, mkdir } from "node:fs/promises";
8
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
9
9
  import { homedir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import type {
@@ -14,23 +14,17 @@ import type {
14
14
  ExtensionContext,
15
15
  } from "@earendil-works/pi-coding-agent";
16
16
  import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
17
- import type { OptimizerHandle, OptimizerStatus } from "./status.ts";
18
17
  import {
19
18
  Container,
20
19
  type SettingItem,
21
20
  SettingsList,
22
21
  Text,
23
22
  } from "@earendil-works/pi-tui";
23
+ import type { OptimizerHandle, OptimizerStatus } from "./status.ts";
24
24
 
25
25
  // ── Levels ────────────────────────────────────────────────────────────────────
26
26
 
27
- export const LEVELS = [
28
- "off",
29
- "lite",
30
- "full",
31
- "ultra",
32
- "micro",
33
- ] as const;
27
+ export const LEVELS = ["off", "lite", "full", "ultra", "micro"] as const;
34
28
 
35
29
  export type Level = (typeof LEVELS)[number];
36
30
 
@@ -43,27 +37,31 @@ export const LEVEL_NUMBERS: Record<string, Level> = {
43
37
  "3": "ultra",
44
38
  };
45
39
 
46
- export const CAVEMAN_COMMAND_OPTIONS = [
47
- { value: "1", label: "1 (lite)", description: "Professional, no fluff" },
48
- { value: "2", label: "2 (full)", description: "Classic caveman" },
49
- { value: "3", label: "3 (ultra)", description: "Maximum compression" },
50
- { value: "lite", label: "lite", description: "Professional, no fluff" },
51
- { value: "full", label: "full", description: "Classic caveman" },
52
- { value: "ultra", label: "ultra", description: "Maximum compression" },
53
- { value: "micro", label: "micro", description: "Experimental prompt-minimized mode" },
54
- { value: "off", label: "off", description: "Disable caveman mode" },
55
- { value: "stop", label: "stop", description: "Disable caveman mode" },
56
- { value: "quit", label: "quit", description: "Disable caveman mode" },
57
- { value: "config", label: "config", description: "Open settings dialog" },
40
+ const CAVEMAN_COMMAND_OPTIONS = [
41
+ { value: "1", label: "1 (lite)", description: "Professional, no fluff" },
42
+ { value: "2", label: "2 (full)", description: "Classic caveman" },
43
+ { value: "3", label: "3 (ultra)", description: "Maximum compression" },
44
+ { value: "lite", label: "lite", description: "Professional, no fluff" },
45
+ { value: "full", label: "full", description: "Classic caveman" },
46
+ { value: "ultra", label: "ultra", description: "Maximum compression" },
47
+ {
48
+ value: "micro",
49
+ label: "micro",
50
+ description: "Experimental prompt-minimized mode",
51
+ },
52
+ { value: "off", label: "off", description: "Disable caveman mode" },
53
+ { value: "stop", label: "stop", description: "Disable caveman mode" },
54
+ { value: "quit", label: "quit", description: "Disable caveman mode" },
55
+ { value: "config", label: "config", description: "Open settings dialog" },
58
56
  ] as const;
59
57
 
60
58
  // ── Status labels ─────────────────────────────────────────────────────────────
61
59
 
62
60
  export const STATUS_LABELS: Record<Exclude<Level, "off">, string> = {
63
- lite: "LITE",
64
- full: "FULL",
65
- ultra: "ULTRA",
66
- micro: "MICRO",
61
+ lite: "LITE",
62
+ full: "FULL",
63
+ ultra: "ULTRA",
64
+ micro: "MICRO",
67
65
  };
68
66
 
69
67
  // ── Prompt fragments ──────────────────────────────────────────────────────────
@@ -174,7 +172,7 @@ export const DEFAULT_CONFIG: CavemanConfig = {
174
172
  showStatus: true,
175
173
  };
176
174
 
177
- export const CONFIG_PATH = join(homedir(), ".pi", "agent", "caveman.json");
175
+ const CONFIG_PATH = join(homedir(), ".pi", "agent", "caveman.json");
178
176
 
179
177
  export function parseConfig(raw: unknown): CavemanConfig {
180
178
  const parsed = raw as Record<string, unknown>;
@@ -191,7 +189,7 @@ export function parseConfig(raw: unknown): CavemanConfig {
191
189
 
192
190
  let _saveQueue: Promise<void> = Promise.resolve();
193
191
 
194
- export async function loadConfig(): Promise<CavemanConfig> {
192
+ async function loadConfig(): Promise<CavemanConfig> {
195
193
  try {
196
194
  const raw = await readFile(CONFIG_PATH, "utf8");
197
195
  return parseConfig(JSON.parse(raw));
@@ -200,8 +198,8 @@ export async function loadConfig(): Promise<CavemanConfig> {
200
198
  }
201
199
  }
202
200
 
203
- export async function saveConfig(config: CavemanConfig): Promise<void> {
204
- const snapshot = JSON.stringify(config, null, 2) + "\n";
201
+ async function saveConfig(config: CavemanConfig): Promise<void> {
202
+ const snapshot = `${JSON.stringify(config, null, 2)}\n`;
205
203
  _saveQueue = _saveQueue.then(async () => {
206
204
  await mkdir(join(homedir(), ".pi", "agent"), { recursive: true });
207
205
  await writeFile(CONFIG_PATH, snapshot, "utf8");
@@ -211,7 +209,10 @@ export async function saveConfig(config: CavemanConfig): Promise<void> {
211
209
 
212
210
  // ── Pi extension ────────────────────────────────────────────────────────────
213
211
 
214
- export function caveman(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHandle {
212
+ export function caveman(
213
+ pi: ExtensionAPI,
214
+ status: OptimizerStatus,
215
+ ): OptimizerHandle {
215
216
  let level: Level = "off";
216
217
  let config: CavemanConfig = { ...DEFAULT_CONFIG };
217
218
  let configLoadPromise: Promise<void> | null = null;
@@ -240,7 +241,7 @@ export function caveman(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHan
240
241
  const prompt = buildPrompt(level);
241
242
  if (!prompt) return undefined;
242
243
  const existing = event.systemPrompt ?? "";
243
- return { systemPrompt: prompt + "\n\n" + existing };
244
+ return { systemPrompt: `${prompt}\n\n${existing}` };
244
245
  });
245
246
 
246
247
  // -- Restore state on session load --
@@ -265,13 +266,20 @@ export function caveman(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHan
265
266
  syncStatus(ctx);
266
267
  });
267
268
 
268
- pi.on("agent_start", async (_event, ctx) => { syncStatus(ctx); });
269
- pi.on("agent_end", async (_event, ctx) => { syncStatus(ctx); });
269
+ pi.on("agent_start", async (_event, ctx) => {
270
+ syncStatus(ctx);
271
+ });
272
+ pi.on("agent_end", async (_event, ctx) => {
273
+ syncStatus(ctx);
274
+ });
270
275
  pi.on("session_shutdown", async () => {});
271
276
 
272
277
  // -- Subcommand handler (dispatched by the merged /opt router) --
273
278
 
274
- async function run(args: string, ctx: ExtensionCommandContext): Promise<void> {
279
+ async function run(
280
+ args: string,
281
+ ctx: ExtensionCommandContext,
282
+ ): Promise<void> {
275
283
  const arg = args.trim().toLowerCase();
276
284
 
277
285
  // No argument → show help
@@ -336,9 +344,19 @@ export function caveman(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHan
336
344
  ];
337
345
 
338
346
  const container = new Container();
339
- container.addChild(new Text(theme.fg("accent", theme.bold(" Caveman Config")), 0, 0));
340
- container.addChild(new Text(theme.fg("dim", " Saved to ~/.pi/agent/caveman.json"), 0, 0));
341
- container.addChild(new Text(theme.fg("dim", " Default level applies to future sessions."), 0, 0));
347
+ container.addChild(
348
+ new Text(theme.fg("accent", theme.bold(" Caveman Config")), 0, 0),
349
+ );
350
+ container.addChild(
351
+ new Text(theme.fg("dim", " Saved to ~/.pi/agent/caveman.json"), 0, 0),
352
+ );
353
+ container.addChild(
354
+ new Text(
355
+ theme.fg("dim", " Default level applies to future sessions."),
356
+ 0,
357
+ 0,
358
+ ),
359
+ );
342
360
  container.addChild(new Text("", 0, 0));
343
361
 
344
362
  const applySettingChange = (id: string, newValue: string) => {
@@ -360,14 +378,23 @@ export function caveman(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHan
360
378
  );
361
379
 
362
380
  container.addChild(settingsList);
363
- container.addChild(new Text(theme.fg("dim", " ←→/hl/tab change • ↑↓/jk move • esc close"), 0, 0));
381
+ container.addChild(
382
+ new Text(
383
+ theme.fg("dim", " ←→/hl/tab change • ↑↓/jk move • esc close"),
384
+ 0,
385
+ 0,
386
+ ),
387
+ );
364
388
 
365
389
  const cycleSelectedValue = (direction: -1 | 1) => {
366
- const selectedIndex = (settingsList as unknown as { selectedIndex: number }).selectedIndex;
390
+ const selectedIndex = (
391
+ settingsList as unknown as { selectedIndex: number }
392
+ ).selectedIndex;
367
393
  const item = items[selectedIndex];
368
394
  if (!item?.values?.length) return;
369
395
  const currentIndex = item.values.indexOf(item.currentValue);
370
- const nextIndex = (currentIndex + direction + item.values.length) % item.values.length;
396
+ const nextIndex =
397
+ (currentIndex + direction + item.values.length) % item.values.length;
371
398
  const newValue = item.values[nextIndex]!;
372
399
  item.currentValue = newValue;
373
400
  settingsList.updateValue(item.id, newValue);
@@ -380,14 +407,29 @@ export function caveman(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHan
380
407
  handleInput: (data: string) => {
381
408
  if (data === "j") data = "\u001b[B";
382
409
  else if (data === "k") data = "\u001b[A";
383
- else if (data === "h") { cycleSelectedValue(-1); _tui.requestRender(); return; }
384
- else if (data === "l" || data === "\u001b[C" || data === "\t") { cycleSelectedValue(1); _tui.requestRender(); return; }
385
- else if (data === "\u001b[D") { cycleSelectedValue(-1); _tui.requestRender(); return; }
386
- else if (data === "\u001b" || data === "q") { done(undefined); return; }
387
-
388
- const result = settingsList.handleInput(data);
389
- if (result === "done") done(undefined);
390
- else _tui.requestRender();
410
+ else if (data === "h") {
411
+ cycleSelectedValue(-1);
412
+ _tui.requestRender();
413
+ return;
414
+ } else if (data === "l" || data === "\u001b[C" || data === "\t") {
415
+ cycleSelectedValue(1);
416
+ _tui.requestRender();
417
+ return;
418
+ } else if (data === "\u001b[D") {
419
+ cycleSelectedValue(-1);
420
+ _tui.requestRender();
421
+ return;
422
+ } else if (data === "\u001b" || data === "q") {
423
+ done(undefined);
424
+ return;
425
+ }
426
+
427
+ // SettingsList.handleInput now returns void; completion/cancel is
428
+ // signalled through the onChange/onCancel callbacks passed to the
429
+ // constructor (onCancel -> done(undefined)). Just forward input and
430
+ // re-render.
431
+ settingsList.handleInput(data);
432
+ _tui.requestRender();
391
433
  },
392
434
  };
393
435
  });
package/src/index.ts CHANGED
@@ -13,10 +13,15 @@
13
13
 
14
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
15
  import { caveman } from "./caveman.ts";
16
- import { rtk } from "./rtk.ts";
17
16
  import { json } from "./json.ts";
18
- import { OptimizerStatus, type OptimizerHandle, type OptimizerTool } from "./status.ts";
19
17
  import { registerOptCommand } from "./opt.ts";
18
+ import { rtk } from "./rtk.ts";
19
+ import {
20
+ type OptimizerHandle,
21
+ OptimizerStatus,
22
+ type OptimizerTool,
23
+ } from "./status.ts";
24
+ import { filterModelWarnings } from "./tool-result-filter.ts";
20
25
 
21
26
  export default function optimizer(pi: ExtensionAPI) {
22
27
  const status = new OptimizerStatus();
@@ -30,4 +35,14 @@ export default function optimizer(pi: ExtensionAPI) {
30
35
  };
31
36
 
32
37
  registerOptCommand(pi, handles);
38
+
39
+ // Strip model-guidance warnings injected by pi-lens into tool_result content.
40
+ // These strings (BLIND WRITE, THRASHING) are directives for the LLM, not
41
+ // information for the user — filtering them here hides them from the TUI
42
+ // without affecting the model (it already acted on the write/edit result).
43
+ pi.on("tool_result", async (event) => {
44
+ const filtered = filterModelWarnings(event.content);
45
+ if (filtered === event.content) return undefined;
46
+ return { content: filtered };
47
+ });
33
48
  }
package/src/json.test.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { describe, it, expect } from "bun:test";
1
+ import { describe, expect, it } from "bun:test";
2
2
  import {
3
- JSON_SYSTEM_PROMPT,
4
3
  adviseToon,
5
4
  isUniformObjectArray,
5
+ JSON_SYSTEM_PROMPT,
6
6
  mentionsJson,
7
7
  objectDepth,
8
8
  skillDir,
@@ -72,9 +72,9 @@ describe("isUniformObjectArray", () => {
72
72
  });
73
73
 
74
74
  it("false for differing keys", () => {
75
- expect(
76
- isUniformObjectArray([{ id: 1 }, { id: 2, extra: true }]),
77
- ).toBe(false);
75
+ expect(isUniformObjectArray([{ id: 1 }, { id: 2, extra: true }])).toBe(
76
+ false,
77
+ );
78
78
  });
79
79
 
80
80
  it("false when a value is itself an object", () => {
package/src/json.ts CHANGED
@@ -76,10 +76,7 @@ const JSON_TRIGGERS = [
76
76
  "swagger",
77
77
  ] as const;
78
78
 
79
- const JSON_TRIGGER_RE = new RegExp(
80
- `\\b(${JSON_TRIGGERS.join("|")})\\b`,
81
- "i",
82
- );
79
+ const JSON_TRIGGER_RE = new RegExp(`\\b(${JSON_TRIGGERS.join("|")})\\b`, "i");
83
80
 
84
81
  /**
85
82
  * True when the user prompt mentions JSON (or a related token), so the
@@ -120,7 +117,10 @@ export function adviseToon(value: unknown, maxDepth = 4): ToonAdvice {
120
117
  }
121
118
  // Array of arrays: TOON's only structurally-worse case.
122
119
  if (value.every((v) => Array.isArray(v))) {
123
- return { useToon: false, reason: "array of arrays — JSON is more compact" };
120
+ return {
121
+ useToon: false,
122
+ reason: "array of arrays — JSON is more compact",
123
+ };
124
124
  }
125
125
  // Uniform array of flat objects → tabular sweet spot.
126
126
  if (isUniformObjectArray(value)) {
@@ -131,9 +131,15 @@ export function adviseToon(value: unknown, maxDepth = 4): ToonAdvice {
131
131
  }
132
132
  // Array of primitives.
133
133
  if (value.every((v) => !isObjectLike(v))) {
134
- return { useToon: true, reason: "primitive array — TOON omits quotes/braces" };
134
+ return {
135
+ useToon: true,
136
+ reason: "primitive array — TOON omits quotes/braces",
137
+ };
135
138
  }
136
- return { useToon: false, reason: "non-uniform array — savings uncertain, keep JSON" };
139
+ return {
140
+ useToon: false,
141
+ reason: "non-uniform array — savings uncertain, keep JSON",
142
+ };
137
143
  }
138
144
 
139
145
  if (isObjectLike(value)) {
@@ -144,7 +150,10 @@ export function adviseToon(value: unknown, maxDepth = 4): ToonAdvice {
144
150
  reason: `nesting depth ${depth} > ${maxDepth} — compact JSON may win`,
145
151
  };
146
152
  }
147
- return { useToon: true, reason: "shallow object — TOON drops quotes/braces" };
153
+ return {
154
+ useToon: true,
155
+ reason: "shallow object — TOON drops quotes/braces",
156
+ };
148
157
  }
149
158
 
150
159
  return { useToon: false, reason: "primitive value — nothing to compress" };
@@ -209,7 +218,10 @@ export function skillDir(): string {
209
218
 
210
219
  // ── Pi extension ──────────────────────────────────────────────────────────────
211
220
 
212
- export function json(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHandle {
221
+ export function json(
222
+ pi: ExtensionAPI,
223
+ status: OptimizerStatus,
224
+ ): OptimizerHandle {
213
225
  let enabled = true;
214
226
 
215
227
  // Report into the shared optimizer indicator.
@@ -217,9 +229,15 @@ export function json(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHandle
217
229
  status.set("toon", enabled, ctx);
218
230
  }
219
231
 
220
- pi.on("session_start", async (_event, ctx) => { syncStatus(ctx); });
221
- pi.on("agent_start", async (_event, ctx) => { syncStatus(ctx); });
222
- pi.on("agent_end", async (_event, ctx) => { syncStatus(ctx); });
232
+ pi.on("session_start", async (_event, ctx) => {
233
+ syncStatus(ctx);
234
+ });
235
+ pi.on("agent_start", async (_event, ctx) => {
236
+ syncStatus(ctx);
237
+ });
238
+ pi.on("agent_end", async (_event, ctx) => {
239
+ syncStatus(ctx);
240
+ });
223
241
 
224
242
  // Surface the bundled skill so the model can load the full workflow.
225
243
  pi.on("resources_discover", async () => {
@@ -234,7 +252,7 @@ export function json(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHandle
234
252
  if (!enabled) return undefined;
235
253
  if (!mentionsJson(event.prompt)) return undefined;
236
254
  const existing = event.systemPrompt ?? "";
237
- return { systemPrompt: JSON_SYSTEM_PROMPT + "\n\n" + existing };
255
+ return { systemPrompt: `${JSON_SYSTEM_PROMPT}\n\n${existing}` };
238
256
  });
239
257
 
240
258
  // -- Subcommand handler (dispatched by the merged /opt router) --
@@ -242,14 +260,21 @@ export function json(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHandle
242
260
  function complete(prefix: string) {
243
261
  const items = [
244
262
  { value: "on", label: "on", description: "Force JSON/TOON guidance on" },
245
- { value: "off", label: "off", description: "Force JSON/TOON guidance off" },
263
+ {
264
+ value: "off",
265
+ label: "off",
266
+ description: "Force JSON/TOON guidance off",
267
+ },
246
268
  ];
247
269
  const n = prefix.trim().toLowerCase();
248
270
  const filtered = items.filter((i) => i.value.startsWith(n));
249
271
  return filtered.length > 0 ? filtered : null;
250
272
  }
251
273
 
252
- async function run(args: string, ctx: ExtensionCommandContext): Promise<void> {
274
+ async function run(
275
+ args: string,
276
+ ctx: ExtensionCommandContext,
277
+ ): Promise<void> {
253
278
  {
254
279
  const arg = args.trim().toLowerCase();
255
280
  if (arg === "on") enabled = true;
package/src/opt.test.ts CHANGED
@@ -1,9 +1,5 @@
1
- import { describe, it, expect } from "bun:test";
2
- import {
3
- parseInvocation,
4
- completeInvocation,
5
- buildOptHelp,
6
- } from "./opt.ts";
1
+ import { describe, expect, it } from "bun:test";
2
+ import { buildOptHelp, completeInvocation, parseInvocation } from "./opt.ts";
7
3
  import type { OptimizerHandle, OptimizerTool } from "./status.ts";
8
4
 
9
5
  /** Build a handle set with spy-able run/complete. */
@@ -88,6 +84,9 @@ describe("dispatch via run", () => {
88
84
  const { name, rest } = parseInvocation("rtk off");
89
85
  expect(name).toBe("rtk");
90
86
  void handles[name as OptimizerTool].run(rest, {} as never);
91
- expect(received).toBe("off");
87
+ // `received` is mutated inside the run() callback above; TS narrows it to
88
+ // `null` here since it can't see the side effect, so read it through an
89
+ // untyped cast before asserting.
90
+ expect(received as string | null).toBe("off");
92
91
  });
93
92
  });
package/src/opt.ts CHANGED
@@ -12,7 +12,11 @@
12
12
  */
13
13
 
14
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
- import type { CompletionItem, OptimizerHandle, OptimizerTool } from "./status.ts";
15
+ import type {
16
+ CompletionItem,
17
+ OptimizerHandle,
18
+ OptimizerTool,
19
+ } from "./status.ts";
16
20
 
17
21
  /** Split raw args into the subcommand name and the rest. */
18
22
  export function parseInvocation(args: string): { name: string; rest: string } {
@@ -38,7 +42,7 @@ export function completeInvocation(
38
42
  handles: Record<OptimizerTool, OptimizerHandle>,
39
43
  ): CompletionItem[] | null {
40
44
  // No space yet → still completing the tool name.
41
- if (!/\s/.test(args.trimStart()) ) {
45
+ if (!/\s/.test(args.trimStart())) {
42
46
  const prefix = args.trim().toLowerCase();
43
47
  const names = (Object.keys(handles) as OptimizerTool[])
44
48
  .filter((n) => n.startsWith(prefix))
@@ -58,11 +62,18 @@ export function completeInvocation(
58
62
  }
59
63
 
60
64
  /** Build the `/opt` help text listing every tool. */
61
- export function buildOptHelp(handles: Record<OptimizerTool, OptimizerHandle>): string {
65
+ export function buildOptHelp(
66
+ handles: Record<OptimizerTool, OptimizerHandle>,
67
+ ): string {
62
68
  const lines = (Object.keys(handles) as OptimizerTool[]).map(
63
69
  (n) => ` /opt ${handles[n].help}`,
64
70
  );
65
- return ["pix-optimizer — token tools", "", "Usage: /opt <tool> [args]", ...lines].join("\n");
71
+ return [
72
+ "pix-optimizer — token tools",
73
+ "",
74
+ "Usage: /opt <tool> [args]",
75
+ ...lines,
76
+ ].join("\n");
66
77
  }
67
78
 
68
79
  export function registerOptCommand(
@@ -71,7 +82,8 @@ export function registerOptCommand(
71
82
  ): void {
72
83
  pi.registerCommand("opt", {
73
84
  description: "pix-optimizer: caveman / rtk / toon token tools",
74
- getArgumentCompletions: (prefix: string) => completeInvocation(prefix, handles),
85
+ getArgumentCompletions: (prefix: string) =>
86
+ completeInvocation(prefix, handles),
75
87
  handler: async (args, ctx) => {
76
88
  const { name, rest } = parseInvocation(args ?? "");
77
89
 
package/src/rtk.test.ts CHANGED
@@ -1,9 +1,11 @@
1
- import { describe, it, expect } from "bun:test";
1
+ import { describe, expect, it } from "bun:test";
2
2
  import {
3
- splitChain,
4
- rewriteChain,
5
3
  applyRtkRewrite,
6
4
  type BashCallEvent,
5
+ buildSudoBlockReason,
6
+ detectSudoSegments,
7
+ rewriteChain,
8
+ splitChain,
7
9
  } from "./rtk.ts";
8
10
 
9
11
  /** Build a fresh bash tool_call event for hook tests. */
@@ -105,13 +107,83 @@ describe("rewriteChain", () => {
105
107
  });
106
108
  });
107
109
 
110
+ // ── detectSudoSegments ───────────────────────────────────────────────────────
111
+
112
+ describe("detectSudoSegments", () => {
113
+ it("returns empty for command with no sudo", () => {
114
+ expect(detectSudoSegments(["git status"])).toEqual([]);
115
+ });
116
+
117
+ it("detects a plain sudo segment", () => {
118
+ expect(detectSudoSegments(["sudo apt-get install foo"])).toEqual([
119
+ "sudo apt-get install foo",
120
+ ]);
121
+ });
122
+
123
+ it("detects sudo in a chain (operators excluded)", () => {
124
+ const parts = ["git status ", "&&", " sudo make install"];
125
+ expect(detectSudoSegments(parts)).toEqual(["sudo make install"]);
126
+ });
127
+
128
+ it("detects multiple sudo segments", () => {
129
+ const parts = ["sudo rm -rf /tmp ", ";", " sudo reboot"];
130
+ expect(detectSudoSegments(parts)).toEqual([
131
+ "sudo rm -rf /tmp",
132
+ "sudo reboot",
133
+ ]);
134
+ });
135
+
136
+ it("does not match 'sudoer' or 'pseudo'", () => {
137
+ expect(detectSudoSegments(["sudoers-check", "pseudo sudo"])).toEqual([]);
138
+ });
139
+
140
+ it("returns empty for operators-only parts", () => {
141
+ expect(detectSudoSegments(["&&", "||", ";"])).toEqual([]);
142
+ });
143
+ });
144
+
145
+ // ── buildSudoBlockReason ──────────────────────────────────────────────────────
146
+
147
+ describe("buildSudoBlockReason", () => {
148
+ it("with sudo_run available: mentions sudo_run tool", () => {
149
+ const reason = buildSudoBlockReason(["sudo apt install curl"], true);
150
+ expect(reason).toContain("sudo_run");
151
+ expect(reason).toContain("sudo apt install curl");
152
+ expect(reason).not.toContain("not available");
153
+ });
154
+
155
+ it("with sudo_run available: instructs to strip sudo prefix", () => {
156
+ const reason = buildSudoBlockReason(["sudo make install"], true);
157
+ expect(reason).toContain("Strip the leading");
158
+ });
159
+
160
+ it("without sudo_run: explains restriction and asks user to run manually", () => {
161
+ const reason = buildSudoBlockReason(["sudo reboot"], false);
162
+ expect(reason).toContain("no sudo_run tool is available");
163
+ expect(reason).toContain("manually");
164
+ expect(reason).toContain("sudo reboot");
165
+ });
166
+
167
+ it("lists all blocked commands", () => {
168
+ const reason = buildSudoBlockReason(
169
+ ["sudo rm -rf /tmp", "sudo reboot"],
170
+ true,
171
+ );
172
+ expect(reason).toContain("sudo rm -rf /tmp");
173
+ expect(reason).toContain("sudo reboot");
174
+ });
175
+ });
176
+
108
177
  // Integration tests for the `tool_call` hook step. These guard the bug that
109
178
  // silently disabled rewriting: wrong event name + wrong field + wrong patch
110
179
  // mechanism. They assert on the IN-PLACE mutation contract the SDK requires.
111
180
  describe("applyRtkRewrite (tool_call hook step)", () => {
112
181
  it("mutates event.input.command in place for a known bash command", () => {
113
182
  const event = bashEvent("git status");
114
- const changed = applyRtkRewrite(event, { enabled: true, rtkAvailable: true });
183
+ const changed = applyRtkRewrite(event, {
184
+ enabled: true,
185
+ rtkAvailable: true,
186
+ });
115
187
  expect(changed).toBe(true);
116
188
  expect(event.input.command).toBe("rtk git status");
117
189
  });
@@ -124,49 +196,74 @@ describe("applyRtkRewrite (tool_call hook step)", () => {
124
196
 
125
197
  it("does not mutate when disabled", () => {
126
198
  const event = bashEvent("git status");
127
- const changed = applyRtkRewrite(event, { enabled: false, rtkAvailable: true });
199
+ const changed = applyRtkRewrite(event, {
200
+ enabled: false,
201
+ rtkAvailable: true,
202
+ });
128
203
  expect(changed).toBe(false);
129
204
  expect(event.input.command).toBe("git status");
130
205
  });
131
206
 
132
207
  it("does not mutate when rtk binary is unavailable", () => {
133
208
  const event = bashEvent("git status");
134
- const changed = applyRtkRewrite(event, { enabled: true, rtkAvailable: false });
209
+ const changed = applyRtkRewrite(event, {
210
+ enabled: true,
211
+ rtkAvailable: false,
212
+ });
135
213
  expect(changed).toBe(false);
136
214
  expect(event.input.command).toBe("git status");
137
215
  });
138
216
 
139
217
  it("ignores non-bash tools", () => {
140
- const event: BashCallEvent = { toolName: "grep", input: { command: "git status" } };
141
- const changed = applyRtkRewrite(event, { enabled: true, rtkAvailable: true });
218
+ const event: BashCallEvent = {
219
+ toolName: "grep",
220
+ input: { command: "git status" },
221
+ };
222
+ const changed = applyRtkRewrite(event, {
223
+ enabled: true,
224
+ rtkAvailable: true,
225
+ });
142
226
  expect(changed).toBe(false);
143
227
  expect(event.input.command).toBe("git status");
144
228
  });
145
229
 
146
230
  it("leaves unknown commands untouched", () => {
147
231
  const event = bashEvent("mkdir build && cd build");
148
- const changed = applyRtkRewrite(event, { enabled: true, rtkAvailable: true });
232
+ const changed = applyRtkRewrite(event, {
233
+ enabled: true,
234
+ rtkAvailable: true,
235
+ });
149
236
  expect(changed).toBe(false);
150
237
  expect(event.input.command).toBe("mkdir build && cd build");
151
238
  });
152
239
 
153
240
  it("does not double-prefix an already-rtk command", () => {
154
241
  const event = bashEvent("rtk git status");
155
- const changed = applyRtkRewrite(event, { enabled: true, rtkAvailable: true });
242
+ const changed = applyRtkRewrite(event, {
243
+ enabled: true,
244
+ rtkAvailable: true,
245
+ });
156
246
  expect(changed).toBe(false);
157
247
  expect(event.input.command).toBe("rtk git status");
158
248
  });
159
249
 
160
250
  it("handles missing / non-string command safely", () => {
161
251
  const event: BashCallEvent = { toolName: "bash", input: {} };
162
- expect(applyRtkRewrite(event, { enabled: true, rtkAvailable: true })).toBe(false);
252
+ expect(applyRtkRewrite(event, { enabled: true, rtkAvailable: true })).toBe(
253
+ false,
254
+ );
163
255
  const event2: BashCallEvent = { toolName: "bash", input: { command: 123 } };
164
- expect(applyRtkRewrite(event2, { enabled: true, rtkAvailable: true })).toBe(false);
256
+ expect(applyRtkRewrite(event2, { enabled: true, rtkAvailable: true })).toBe(
257
+ false,
258
+ );
165
259
  });
166
260
 
167
261
  it("leaves command unchanged on unbalanced quotes", () => {
168
262
  const event = bashEvent('git commit -m "oops');
169
- const changed = applyRtkRewrite(event, { enabled: true, rtkAvailable: true });
263
+ const changed = applyRtkRewrite(event, {
264
+ enabled: true,
265
+ rtkAvailable: true,
266
+ });
170
267
  expect(changed).toBe(false);
171
268
  expect(event.input.command).toBe('git commit -m "oops');
172
269
  });
package/src/rtk.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Simple RTK Integration
3
- *
3
+ *
4
4
  * 1. Injects RTK system prompt (tells model to prefix commands with rtk)
5
5
  * 2. Rewrites bash commands to add rtk prefix when model forgets
6
6
  * 3. Falls back gracefully if rtk binary is missing
@@ -33,6 +33,46 @@ export interface BashCallEvent {
33
33
  * Extracted from the hook closure so the integration is directly testable
34
34
  * without a live ExtensionAPI.
35
35
  */
36
+ /**
37
+ * Return the list of sudo sub-commands found in parsed chain segments.
38
+ * Each entry is the full segment body (trimmed) that starts with `sudo`.
39
+ * Operators are excluded. Returns empty array if none found.
40
+ */
41
+ export function detectSudoSegments(parts: string[]): string[] {
42
+ return parts
43
+ .filter((p) => !CHAIN_OPERATORS.has(p.trim()))
44
+ .map((p) => p.trim())
45
+ .filter((p) => /^sudo\b/.test(p));
46
+ }
47
+
48
+ /**
49
+ * Build the block reason string shown to the model when a sudo command is
50
+ * intercepted. Directs the model to `sudo_run` when available, otherwise
51
+ * explains the restriction clearly.
52
+ */
53
+ export function buildSudoBlockReason(
54
+ sudoCmds: string[],
55
+ hasSudoRunTool: boolean,
56
+ ): string {
57
+ const list = sudoCmds.map((c) => ` - ${c}`).join("\n");
58
+ if (hasSudoRunTool) {
59
+ return (
60
+ `bash cannot run sudo commands directly. ` +
61
+ `Use the \`sudo_run\` tool instead — it shows the user a confirmation dialog ` +
62
+ `and handles authentication securely.\n` +
63
+ `Blocked command(s):\n${list}\n` +
64
+ `Strip the leading \`sudo\` from the command and pass the rest to \`sudo_run\` ` +
65
+ `with a clear \`reason\` parameter.`
66
+ );
67
+ }
68
+ return (
69
+ `bash cannot run sudo commands in this session — ` +
70
+ `no sudo_run tool is available and direct sudo is not permitted.\n` +
71
+ `Blocked command(s):\n${list}\n` +
72
+ `Ask the user to run the command manually with elevated privileges.`
73
+ );
74
+ }
75
+
36
76
  export function applyRtkRewrite(
37
77
  event: BashCallEvent,
38
78
  opts: { enabled: boolean; rtkAvailable: boolean },
@@ -62,13 +102,40 @@ RTK also has filtering subcommands the auto-rewriter won't add — reach for the
62
102
 
63
103
  // Commands that should be prefixed with rtk
64
104
  const RTK_COMMANDS = new Set([
65
- "git", "gh", "ls", "tree", "find", "grep", "cat", "head", "tail",
66
- "tsc", "lint", "eslint", "prettier", "next", "cargo", "rustc",
67
- "vitest", "playwright", "jest", "test",
68
- "pnpm", "npm", "npx", "yarn", "bun",
69
- "docker", "kubectl", "aws", "psql",
70
- "curl", "wget", "wc",
71
- "prisma", "dotnet"
105
+ "git",
106
+ "gh",
107
+ "ls",
108
+ "tree",
109
+ "find",
110
+ "grep",
111
+ "cat",
112
+ "head",
113
+ "tail",
114
+ "tsc",
115
+ "lint",
116
+ "eslint",
117
+ "prettier",
118
+ "next",
119
+ "cargo",
120
+ "rustc",
121
+ "vitest",
122
+ "playwright",
123
+ "jest",
124
+ "test",
125
+ "pnpm",
126
+ "npm",
127
+ "npx",
128
+ "yarn",
129
+ "bun",
130
+ "docker",
131
+ "kubectl",
132
+ "aws",
133
+ "psql",
134
+ "curl",
135
+ "wget",
136
+ "wc",
137
+ "prisma",
138
+ "dotnet",
72
139
  ]);
73
140
 
74
141
  interface RtkStatus {
@@ -159,10 +226,16 @@ export function rewriteChain(command: string): string {
159
226
  return changed ? rewritten.join("") : command;
160
227
  }
161
228
 
162
- export function rtk(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHandle {
229
+ export function rtk(
230
+ pi: ExtensionAPI,
231
+ status: OptimizerStatus,
232
+ ): OptimizerHandle {
163
233
  let rtkStatus: RtkStatus | null = null;
164
234
  let warnedMissing = false;
165
235
  let enabled = true;
236
+ // Tracks whether pix-sudo's sudo_run tool is active this session.
237
+ // Set from before_agent_start selectedTools; defaults to false until known.
238
+ let hasSudoRunTool = false;
166
239
 
167
240
  // Report into the shared optimizer indicator. RTK counts as "on" only when
168
241
  // enabled AND the binary is actually available.
@@ -183,27 +256,31 @@ export function rtk(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHandle
183
256
  rtkStatus = {
184
257
  available: true,
185
258
  checkedAt: Date.now(),
186
- path: result.stdout.trim()
259
+ path: result.stdout.trim(),
187
260
  };
188
261
  warnedMissing = false;
189
262
  return rtkStatus;
190
263
  }
191
- } catch (error) {
264
+ } catch (_error) {
192
265
  // which command failed
193
266
  }
194
267
 
195
268
  rtkStatus = {
196
269
  available: false,
197
- checkedAt: Date.now()
270
+ checkedAt: Date.now(),
198
271
  };
199
272
  return rtkStatus;
200
273
  };
201
274
 
202
- // Inject RTK system prompt
275
+ // Inject RTK system prompt + detect sudo_run tool availability.
203
276
  pi.on("before_agent_start", async (event) => {
277
+ // Detect sudo_run from the active tool list for this session.
278
+ const tools: string[] = event.systemPromptOptions?.selectedTools ?? [];
279
+ hasSudoRunTool = tools.includes("sudo_run");
280
+
204
281
  if (!enabled) return undefined;
205
282
  const existing = event.systemPrompt ?? "";
206
- return { systemPrompt: RTK_SYSTEM_PROMPT + "\n\n" + existing };
283
+ return { systemPrompt: `${RTK_SYSTEM_PROMPT}\n\n${existing}` };
207
284
  });
208
285
 
209
286
  // Keep the status indicator in sync across the agent lifecycle. Probe
@@ -212,12 +289,19 @@ export function rtk(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHandle
212
289
  await checkRtkAvailability();
213
290
  syncStatus(ctx);
214
291
  });
215
- pi.on("agent_start", async (_event, ctx) => { syncStatus(ctx); });
216
- pi.on("agent_end", async (_event, ctx) => { syncStatus(ctx); });
292
+ pi.on("agent_start", async (_event, ctx) => {
293
+ syncStatus(ctx);
294
+ });
295
+ pi.on("agent_end", async (_event, ctx) => {
296
+ syncStatus(ctx);
297
+ });
217
298
 
218
299
  // -- Subcommand handler (dispatched by the merged /opt router) --
219
300
 
220
- async function run(args: string, ctx: ExtensionCommandContext): Promise<void> {
301
+ async function run(
302
+ args: string,
303
+ ctx: ExtensionCommandContext,
304
+ ): Promise<void> {
221
305
  const arg = args.trim().toLowerCase();
222
306
  if (arg === "on") enabled = true;
223
307
  else if (arg === "off") enabled = false;
@@ -259,7 +343,7 @@ export function rtk(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHandle
259
343
  if (!warnedMissing) {
260
344
  ctx.ui.notify(
261
345
  "RTK binary not found. Install: cargo install rtk-ai",
262
- "warning"
346
+ "warning",
263
347
  );
264
348
  warnedMissing = true;
265
349
  }
@@ -269,6 +353,20 @@ export function rtk(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHandle
269
353
  // First confirmed-available probe may have flipped state — refresh icon.
270
354
  syncStatus(ctx);
271
355
 
356
+ // Block sudo segments before rtk rewriting.
357
+ // splitChain is safe to call here — same parser used by rewriteChain.
358
+ const command = event.input?.command;
359
+ if (typeof command === "string" && command) {
360
+ const parts = splitChain(command);
361
+ if (parts) {
362
+ const sudoCmds = detectSudoSegments(parts);
363
+ if (sudoCmds.length > 0) {
364
+ const reason = buildSudoBlockReason(sudoCmds, hasSudoRunTool);
365
+ return { block: true, reason };
366
+ }
367
+ }
368
+ }
369
+
272
370
  // Rewrite every segment in the command chain that uses a known RTK
273
371
  // command (e.g. `git add . && git push` -> `rtk git add . && rtk git push`).
274
372
  // Mutates `event.input.command` in place — the SDK's supported patch path.
@@ -1,9 +1,9 @@
1
- import { describe, it, expect } from "bun:test";
1
+ import { describe, expect, it } from "bun:test";
2
2
  import {
3
3
  OptimizerStatus,
4
+ renderStatus,
4
5
  STATUS_KEY,
5
6
  TOOL_ICONS,
6
- renderStatus,
7
7
  } from "./status.ts";
8
8
 
9
9
  /** Tagging colorizer: <accent>X</accent> / <dim>X</dim> for assertions. */
package/src/status.ts CHANGED
@@ -53,12 +53,12 @@ export const TOOL_ICONS: Record<OptimizerTool, string> = {
53
53
  };
54
54
 
55
55
  /** Fixed left-to-right order of icons in the cell. */
56
- export const TOOL_ORDER: readonly OptimizerTool[] = ["caveman", "rtk", "toon"];
56
+ const TOOL_ORDER: readonly OptimizerTool[] = ["caveman", "rtk", "toon"];
57
57
 
58
58
  /** Theme color for enabled icons. */
59
- export const ENABLED_COLOR: ThemeColor = "accent";
59
+ const ENABLED_COLOR: ThemeColor = "accent";
60
60
  /** Theme color for disabled icons. */
61
- export const DISABLED_COLOR: ThemeColor = "dim";
61
+ const DISABLED_COLOR: ThemeColor = "dim";
62
62
 
63
63
  /** Colorizer: maps a (theme color, text) pair to a rendered string. */
64
64
  export type Colorize = (color: ThemeColor, text: string) => string;
@@ -89,7 +89,11 @@ export class OptimizerStatus {
89
89
  private states: Partial<Record<OptimizerTool, boolean>> = {};
90
90
 
91
91
  /** Update one tool's enabled state and repaint the shared cell. */
92
- set(tool: OptimizerTool, enabled: boolean, ctx: Pick<ExtensionContext, "ui">): void {
92
+ set(
93
+ tool: OptimizerTool,
94
+ enabled: boolean,
95
+ ctx: Pick<ExtensionContext, "ui">,
96
+ ): void {
93
97
  this.states[tool] = enabled;
94
98
  this.paint(ctx);
95
99
  }
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ filterModelWarnings,
4
+ stripModelWarningParagraphs,
5
+ } from "./tool-result-filter.ts";
6
+
7
+ // ── stripModelWarningParagraphs ───────────────────────────────────────────────
8
+
9
+ describe("stripModelWarningParagraphs", () => {
10
+ it("passes through text with no warnings", () => {
11
+ const text = "Exit code: 0\n\nsome output here";
12
+ expect(stripModelWarningParagraphs(text)).toBe(text);
13
+ });
14
+
15
+ it("strips a BLIND WRITE paragraph", () => {
16
+ const text =
17
+ "Exit code: 0\n\nsome output\n\n⚠ BLIND WRITE — editing `foo.ts` without reading in the last 5 tool calls. Read the file first to avoid assumptions.";
18
+ const result = stripModelWarningParagraphs(text);
19
+ expect(result).not.toContain("BLIND WRITE");
20
+ expect(result).toContain("some output");
21
+ });
22
+
23
+ it("strips a THRASHING paragraph", () => {
24
+ const text =
25
+ "ok\n\n🔴 THRASHING — 3 consecutive `edit` calls on foo.ts. Consider reading first.";
26
+ const result = stripModelWarningParagraphs(text);
27
+ expect(result).not.toContain("THRASHING");
28
+ expect(result).toContain("ok");
29
+ });
30
+
31
+ it("strips multiple warning paragraphs", () => {
32
+ const text =
33
+ "output\n\n⚠ BLIND WRITE — foo\n\n🔴 THRASHING — bar\n\nmore output";
34
+ const result = stripModelWarningParagraphs(text);
35
+ expect(result).not.toContain("BLIND WRITE");
36
+ expect(result).not.toContain("THRASHING");
37
+ expect(result).toContain("output");
38
+ expect(result).toContain("more output");
39
+ });
40
+
41
+ it("returns original reference when nothing stripped", () => {
42
+ const text = "no warnings here";
43
+ expect(stripModelWarningParagraphs(text)).toBe(text);
44
+ });
45
+
46
+ it("trims trailing whitespace after stripping", () => {
47
+ const text = "output\n\n⚠ BLIND WRITE — foo";
48
+ const result = stripModelWarningParagraphs(text);
49
+ expect(result).toBe("output");
50
+ });
51
+
52
+ it("does not strip mid-paragraph BLIND WRITE mentions", () => {
53
+ // Only strips when the paragraph *starts* with the prefix
54
+ const text = "The BLIND WRITE check is a feature.\n\nsome output";
55
+ expect(stripModelWarningParagraphs(text)).toBe(text);
56
+ });
57
+ });
58
+
59
+ // ── filterModelWarnings ───────────────────────────────────────────────────────
60
+
61
+ describe("filterModelWarnings", () => {
62
+ it("returns original array when no warnings present", () => {
63
+ const content = [{ type: "text" as const, text: "clean output" }];
64
+ expect(filterModelWarnings(content)).toBe(content);
65
+ });
66
+
67
+ it("filters BLIND WRITE from text block", () => {
68
+ const content = [
69
+ {
70
+ type: "text" as const,
71
+ text: "Exit code: 0\n\nok\n\n⚠ BLIND WRITE — editing `x.ts` without reading.",
72
+ },
73
+ ];
74
+ const result = filterModelWarnings(content);
75
+ expect(result).not.toBe(content);
76
+ expect(result[0]?.type).toBe("text");
77
+ if (result[0]?.type === "text") {
78
+ expect((result[0] as { type: string; text: string }).text).not.toContain(
79
+ "BLIND WRITE",
80
+ );
81
+ }
82
+ });
83
+
84
+ it("leaves image blocks untouched", () => {
85
+ const img = { type: "image", source: { type: "base64", data: "abc" } };
86
+ const content = [img] as never[];
87
+ expect(filterModelWarnings(content)).toBe(content);
88
+ });
89
+
90
+ it("only modifies affected blocks, keeps others by reference", () => {
91
+ const clean = { type: "text" as const, text: "clean" };
92
+ const dirty = {
93
+ type: "text" as const,
94
+ text: "output\n\n⚠ BLIND WRITE — foo",
95
+ };
96
+ const content = [clean, dirty];
97
+ const result = filterModelWarnings(content);
98
+ // The array is new but clean block is the same reference
99
+ expect(result[0]).toBe(clean);
100
+ expect(result[1]).not.toBe(dirty);
101
+ });
102
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * tool-result-filter.ts — strip model-guidance text from tool_result content.
3
+ *
4
+ * pi-lens appends diagnostic strings (BLIND WRITE, THRASHING) to tool_result
5
+ * content as guidance for the LLM. These are not useful to the user and clutter
6
+ * the TUI. This module filters them out of content blocks before they render.
7
+ *
8
+ * Patterns are anchored to the start of a paragraph (preceded by \n\n) so we
9
+ * don't accidentally clip real output that happens to contain these strings.
10
+ */
11
+
12
+ import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
13
+
14
+ export type ToolContent = TextContent | ImageContent;
15
+
16
+ /**
17
+ * Paragraph prefixes that identify model-only guidance injected by pi-lens.
18
+ * Each pattern matches from the start of a \n\n-delimited paragraph.
19
+ */
20
+ const MODEL_WARNING_PREFIXES = ["⚠ BLIND WRITE —", "🔴 THRASHING —"] as const;
21
+
22
+ /**
23
+ * Strip model-guidance paragraphs from a single text block's content.
24
+ * Returns the original string if nothing was removed.
25
+ */
26
+ export function stripModelWarningParagraphs(text: string): string {
27
+ // Split on double-newline paragraph boundaries, filter out guidance paragraphs,
28
+ // then rejoin. Trim trailing whitespace from the result.
29
+ const paragraphs = text.split(/\n\n/);
30
+ const kept = paragraphs.filter(
31
+ (p) =>
32
+ !MODEL_WARNING_PREFIXES.some((prefix) =>
33
+ p.trimStart().startsWith(prefix),
34
+ ),
35
+ );
36
+ if (kept.length === paragraphs.length) return text;
37
+ return kept.join("\n\n").trimEnd();
38
+ }
39
+
40
+ /**
41
+ * Filter model-guidance warnings from an array of tool content blocks.
42
+ * Returns the original array reference if no blocks were modified.
43
+ */
44
+ export function filterModelWarnings(content: ToolContent[]): ToolContent[] {
45
+ let changed = false;
46
+ const result = content.map((block) => {
47
+ if (block.type !== "text") return block;
48
+ const text = (block as TextContent).text;
49
+ if (typeof text !== "string") return block;
50
+ const filtered = stripModelWarningParagraphs(text);
51
+ if (filtered === text) return block;
52
+ changed = true;
53
+ return { ...block, text: filtered } as TextContent;
54
+ });
55
+ return changed ? result : content;
56
+ }