@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 +25 -1
- package/package.json +51 -46
- package/src/caveman.test.ts +17 -16
- package/src/caveman.ts +90 -48
- package/src/index.ts +17 -2
- package/src/json.test.ts +5 -5
- package/src/json.ts +40 -15
- package/src/opt.test.ts +6 -7
- package/src/opt.ts +17 -5
- package/src/rtk.test.ts +110 -13
- package/src/rtk.ts +116 -18
- package/src/status.test.ts +2 -2
- package/src/status.ts +8 -4
- package/src/tool-result-filter.test.ts +102 -0
- package/src/tool-result-filter.ts +56 -0
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
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
}
|
package/src/caveman.test.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { describe,
|
|
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
|
-
|
|
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
|
|
56
|
-
expect(STATUS_LABELS
|
|
57
|
-
expect(STATUS_LABELS
|
|
58
|
-
expect(STATUS_LABELS
|
|
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(
|
|
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
|
|
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
|
-
|
|
47
|
-
{ value: "1",
|
|
48
|
-
{ value: "2",
|
|
49
|
-
{ value: "3",
|
|
50
|
-
{ value: "lite",
|
|
51
|
-
{ value: "full",
|
|
52
|
-
{ value: "ultra",
|
|
53
|
-
{
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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:
|
|
64
|
-
full:
|
|
65
|
-
ultra:
|
|
66
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
const snapshot = JSON.stringify(config, null, 2)
|
|
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(
|
|
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
|
|
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) => {
|
|
269
|
-
|
|
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(
|
|
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(
|
|
340
|
-
|
|
341
|
-
|
|
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(
|
|
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 = (
|
|
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 =
|
|
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") {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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,
|
|
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
|
-
|
|
77
|
-
)
|
|
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 {
|
|
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 {
|
|
134
|
+
return {
|
|
135
|
+
useToon: true,
|
|
136
|
+
reason: "primitive array — TOON omits quotes/braces",
|
|
137
|
+
};
|
|
135
138
|
}
|
|
136
|
-
return {
|
|
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 {
|
|
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(
|
|
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) => {
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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
|
-
{
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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 [
|
|
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) =>
|
|
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,
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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 = {
|
|
141
|
-
|
|
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, {
|
|
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, {
|
|
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(
|
|
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(
|
|
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, {
|
|
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",
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
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(
|
|
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 (
|
|
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
|
|
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) => {
|
|
216
|
-
|
|
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(
|
|
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.
|
package/src/status.test.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { describe,
|
|
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
|
-
|
|
56
|
+
const TOOL_ORDER: readonly OptimizerTool[] = ["caveman", "rtk", "toon"];
|
|
57
57
|
|
|
58
58
|
/** Theme color for enabled icons. */
|
|
59
|
-
|
|
59
|
+
const ENABLED_COLOR: ThemeColor = "accent";
|
|
60
60
|
/** Theme color for disabled icons. */
|
|
61
|
-
|
|
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(
|
|
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
|
+
}
|