@xynogen/pix-optimizer 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/package.json +48 -0
- package/src/caveman.test.ts +239 -0
- package/src/caveman.ts +402 -0
- package/src/index.ts +33 -0
- package/src/json.test.ts +177 -0
- package/src/json.ts +270 -0
- package/src/opt.test.ts +93 -0
- package/src/opt.ts +96 -0
- package/src/rtk.test.ts +173 -0
- package/src/rtk.ts +285 -0
- package/src/skills/toon-json/SKILL.md +101 -0
- package/src/status.test.ts +85 -0
- package/src/status.ts +102 -0
package/src/json.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* json.ts — JSON token-optimization via jq + TOON.
|
|
3
|
+
*
|
|
4
|
+
* Two parts, mirroring rtk.ts/caveman.ts:
|
|
5
|
+
* 1. A small system-prompt nudge teaching the model to run JSON through
|
|
6
|
+
* `jq` (query/reshape) and `toon` (compress for context), and to convert
|
|
7
|
+
* back to JSON only when a strict contract requires it.
|
|
8
|
+
* 2. A bundled `toon-json` skill, surfaced via `resources_discover`, that
|
|
9
|
+
* holds the full workflow + when-NOT-to-use guidance.
|
|
10
|
+
*
|
|
11
|
+
* TOON = Token-Oriented Object Notation (https://github.com/toon-format/spec).
|
|
12
|
+
* It shines on uniform/tabular arrays of objects (declare keys once, stream
|
|
13
|
+
* rows) and loses to compact JSON on deeply nested / non-uniform / array-of-
|
|
14
|
+
* arrays data. The prompt encodes that boundary so the model picks correctly.
|
|
15
|
+
*
|
|
16
|
+
* Pure helpers are exported for tests; json(pi) is the extension entry, called
|
|
17
|
+
* by index.ts alongside caveman(pi) and rtk(pi).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import type {
|
|
23
|
+
ExtensionAPI,
|
|
24
|
+
ExtensionCommandContext,
|
|
25
|
+
ExtensionContext,
|
|
26
|
+
} from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import type { OptimizerHandle, OptimizerStatus } from "./status.ts";
|
|
28
|
+
|
|
29
|
+
// ── System prompt ───────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export const JSON_SYSTEM_PROMPT = `# JSON Handling — jq + TOON
|
|
32
|
+
|
|
33
|
+
When working with information-dense JSON (LLM/OpenAPI schemas, API responses,
|
|
34
|
+
config dumps, datasets), prefer this pipeline over dumping raw JSON into context:
|
|
35
|
+
|
|
36
|
+
\`\`\`bash
|
|
37
|
+
curl -s <url> | jq '<query>' | toon # fetch → reshape → compress
|
|
38
|
+
cat data.json | jq '.items' | toon --stats # local file, show token savings
|
|
39
|
+
echo "$TOON" | toon -d # convert TOON back to JSON
|
|
40
|
+
\`\`\`
|
|
41
|
+
|
|
42
|
+
## Why
|
|
43
|
+
- **jq** queries/reshapes so you only carry the slice you need.
|
|
44
|
+
- **toon** re-encodes JSON as TOON: uniform arrays of objects declare their
|
|
45
|
+
keys once (\`key[N]{a,b,c}:\`) then stream bare rows — large token savings on
|
|
46
|
+
tabular/dense data. \`toon\` auto-detects direction; \`-d\` decodes back.
|
|
47
|
+
|
|
48
|
+
## When TOON helps (use it)
|
|
49
|
+
- Uniform/tabular arrays of objects (TOON's sweet spot — savings scale with rows × fields)
|
|
50
|
+
- Flat objects and primitive arrays
|
|
51
|
+
- Shallow nesting
|
|
52
|
+
|
|
53
|
+
## When to SKIP TOON (keep JSON)
|
|
54
|
+
- API-level contracts / payloads you must send or store verbatim
|
|
55
|
+
- Deeply nested or non-uniform structures (compact JSON can win)
|
|
56
|
+
- Arrays of arrays (TOON is less efficient here)
|
|
57
|
+
- Anything a downstream parser requires as strict JSON
|
|
58
|
+
|
|
59
|
+
Rule of thumb: TOON for **reading** dense data into context; JSON for **contracts**.
|
|
60
|
+
See the \`toon-json\` skill for the full workflow.`;
|
|
61
|
+
|
|
62
|
+
// ── Prompt relevance gate ─────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Tokens that signal the user prompt is about JSON / the jq+TOON workflow.
|
|
66
|
+
* Matched as whole words (case-insensitive) so "adjust" / "jsx" don't trip "js".
|
|
67
|
+
*/
|
|
68
|
+
const JSON_TRIGGERS = [
|
|
69
|
+
"json",
|
|
70
|
+
"jsonl",
|
|
71
|
+
"ndjson",
|
|
72
|
+
"js",
|
|
73
|
+
"jq",
|
|
74
|
+
"toon",
|
|
75
|
+
"openapi",
|
|
76
|
+
"swagger",
|
|
77
|
+
] as const;
|
|
78
|
+
|
|
79
|
+
const JSON_TRIGGER_RE = new RegExp(
|
|
80
|
+
`\\b(${JSON_TRIGGERS.join("|")})\\b`,
|
|
81
|
+
"i",
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* True when the user prompt mentions JSON (or a related token), so the
|
|
86
|
+
* jq+TOON guidance is only injected when actually relevant. Case-insensitive
|
|
87
|
+
* and word-bounded ("JSON", "Json", "json" all match; "adjust" does not).
|
|
88
|
+
*/
|
|
89
|
+
export function mentionsJson(prompt: string | undefined | null): boolean {
|
|
90
|
+
if (!prompt) return false;
|
|
91
|
+
return JSON_TRIGGER_RE.test(prompt);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Pure decision helper ──────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export interface ToonAdvice {
|
|
97
|
+
/** Whether TOON is likely to reduce tokens for this shape. */
|
|
98
|
+
useToon: boolean;
|
|
99
|
+
/** Short human-readable reason. */
|
|
100
|
+
reason: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Heuristic guidance on whether a parsed JSON value is a good TOON candidate.
|
|
105
|
+
*
|
|
106
|
+
* This is intentionally a *recommendation* surfaced to the model / user, not an
|
|
107
|
+
* enforcement. It encodes the efficiency boundary from the TOON spec:
|
|
108
|
+
* - uniform arrays of objects → strongly yes (tabular sweet spot)
|
|
109
|
+
* - flat objects / primitive arrays → yes
|
|
110
|
+
* - arrays of arrays → no (TOON's one losing case)
|
|
111
|
+
* - deeply nested / non-uniform → no (compact JSON can win)
|
|
112
|
+
*
|
|
113
|
+
* @param value parsed JSON (object/array/primitive)
|
|
114
|
+
* @param maxDepth nesting depth at which we stop recommending TOON (default 4)
|
|
115
|
+
*/
|
|
116
|
+
export function adviseToon(value: unknown, maxDepth = 4): ToonAdvice {
|
|
117
|
+
if (Array.isArray(value)) {
|
|
118
|
+
if (value.length === 0) {
|
|
119
|
+
return { useToon: false, reason: "empty array — nothing to compress" };
|
|
120
|
+
}
|
|
121
|
+
// Array of arrays: TOON's only structurally-worse case.
|
|
122
|
+
if (value.every((v) => Array.isArray(v))) {
|
|
123
|
+
return { useToon: false, reason: "array of arrays — JSON is more compact" };
|
|
124
|
+
}
|
|
125
|
+
// Uniform array of flat objects → tabular sweet spot.
|
|
126
|
+
if (isUniformObjectArray(value)) {
|
|
127
|
+
return {
|
|
128
|
+
useToon: true,
|
|
129
|
+
reason: `uniform array of ${value.length} objects — TOON tabular sweet spot`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// Array of primitives.
|
|
133
|
+
if (value.every((v) => !isObjectLike(v))) {
|
|
134
|
+
return { useToon: true, reason: "primitive array — TOON omits quotes/braces" };
|
|
135
|
+
}
|
|
136
|
+
return { useToon: false, reason: "non-uniform array — savings uncertain, keep JSON" };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (isObjectLike(value)) {
|
|
140
|
+
const depth = objectDepth(value);
|
|
141
|
+
if (depth > maxDepth) {
|
|
142
|
+
return {
|
|
143
|
+
useToon: false,
|
|
144
|
+
reason: `nesting depth ${depth} > ${maxDepth} — compact JSON may win`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return { useToon: true, reason: "shallow object — TOON drops quotes/braces" };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { useToon: false, reason: "primitive value — nothing to compress" };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** True for plain objects/arrays (things with nested structure). */
|
|
154
|
+
function isObjectLike(v: unknown): v is Record<string, unknown> | unknown[] {
|
|
155
|
+
return typeof v === "object" && v !== null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A uniform array of objects: every element is a plain (non-array) object and
|
|
160
|
+
* they all share the same set of keys, each holding a primitive value. This is
|
|
161
|
+
* exactly the shape TOON encodes as a single header + bare rows.
|
|
162
|
+
*/
|
|
163
|
+
export function isUniformObjectArray(arr: unknown[]): boolean {
|
|
164
|
+
if (arr.length === 0) return false;
|
|
165
|
+
const first = arr[0];
|
|
166
|
+
if (!isPlainObject(first)) return false;
|
|
167
|
+
const keys = Object.keys(first).sort();
|
|
168
|
+
if (keys.length === 0) return false;
|
|
169
|
+
|
|
170
|
+
return arr.every((el) => {
|
|
171
|
+
if (!isPlainObject(el)) return false;
|
|
172
|
+
const elKeys = Object.keys(el).sort();
|
|
173
|
+
if (elKeys.length !== keys.length) return false;
|
|
174
|
+
for (let i = 0; i < keys.length; i++) {
|
|
175
|
+
if (elKeys[i] !== keys[i]) return false;
|
|
176
|
+
// values must be primitive for a clean tabular row
|
|
177
|
+
if (isObjectLike((el as Record<string, unknown>)[keys[i]!])) return false;
|
|
178
|
+
}
|
|
179
|
+
return true;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
184
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Max nesting depth of a JSON value (primitives = 0). */
|
|
188
|
+
export function objectDepth(value: unknown): number {
|
|
189
|
+
if (!isObjectLike(value)) return 0;
|
|
190
|
+
const children = Array.isArray(value) ? value : Object.values(value);
|
|
191
|
+
let max = 0;
|
|
192
|
+
for (const child of children) {
|
|
193
|
+
const d = objectDepth(child);
|
|
194
|
+
if (d > max) max = d;
|
|
195
|
+
}
|
|
196
|
+
return max + 1;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Bundled skill path ────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Absolute path to the bundled `toon-json` skill directory, resolved relative
|
|
203
|
+
* to this module so it works regardless of the extension's install location.
|
|
204
|
+
*/
|
|
205
|
+
export function skillDir(): string {
|
|
206
|
+
const here = fileURLToPath(new URL(".", import.meta.url));
|
|
207
|
+
return join(here, "skills", "toon-json");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Pi extension ──────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
export function json(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHandle {
|
|
213
|
+
let enabled = true;
|
|
214
|
+
|
|
215
|
+
// Report into the shared optimizer indicator.
|
|
216
|
+
function syncStatus(ctx: Pick<ExtensionContext, "ui">) {
|
|
217
|
+
status.set("toon", enabled, ctx);
|
|
218
|
+
}
|
|
219
|
+
|
|
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); });
|
|
223
|
+
|
|
224
|
+
// Surface the bundled skill so the model can load the full workflow.
|
|
225
|
+
pi.on("resources_discover", async () => {
|
|
226
|
+
if (!enabled) return undefined;
|
|
227
|
+
return { skillPaths: [skillDir()] };
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Inject the JSON-handling nudge into the system prompt, but ONLY when the
|
|
231
|
+
// user prompt actually mentions JSON / a related token — otherwise it's dead
|
|
232
|
+
// weight in every turn.
|
|
233
|
+
pi.on("before_agent_start", async (event) => {
|
|
234
|
+
if (!enabled) return undefined;
|
|
235
|
+
if (!mentionsJson(event.prompt)) return undefined;
|
|
236
|
+
const existing = event.systemPrompt ?? "";
|
|
237
|
+
return { systemPrompt: JSON_SYSTEM_PROMPT + "\n\n" + existing };
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// -- Subcommand handler (dispatched by the merged /opt router) --
|
|
241
|
+
|
|
242
|
+
function complete(prefix: string) {
|
|
243
|
+
const items = [
|
|
244
|
+
{ value: "on", label: "on", description: "Force JSON/TOON guidance on" },
|
|
245
|
+
{ value: "off", label: "off", description: "Force JSON/TOON guidance off" },
|
|
246
|
+
];
|
|
247
|
+
const n = prefix.trim().toLowerCase();
|
|
248
|
+
const filtered = items.filter((i) => i.value.startsWith(n));
|
|
249
|
+
return filtered.length > 0 ? filtered : null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function run(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
253
|
+
{
|
|
254
|
+
const arg = args.trim().toLowerCase();
|
|
255
|
+
if (arg === "on") enabled = true;
|
|
256
|
+
else if (arg === "off") enabled = false;
|
|
257
|
+
else enabled = !enabled; // bare toggles
|
|
258
|
+
|
|
259
|
+
syncStatus(ctx);
|
|
260
|
+
ctx.ui.notify(`JSON/TOON guidance ${enabled ? "on" : "off"}.`, "info");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
name: "toon",
|
|
266
|
+
help: "toon [on|off] — jq+TOON guidance for dense JSON",
|
|
267
|
+
run,
|
|
268
|
+
complete,
|
|
269
|
+
};
|
|
270
|
+
}
|
package/src/opt.test.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
parseInvocation,
|
|
4
|
+
completeInvocation,
|
|
5
|
+
buildOptHelp,
|
|
6
|
+
} from "./opt.ts";
|
|
7
|
+
import type { OptimizerHandle, OptimizerTool } from "./status.ts";
|
|
8
|
+
|
|
9
|
+
/** Build a handle set with spy-able run/complete. */
|
|
10
|
+
function fakeHandles(): Record<OptimizerTool, OptimizerHandle> {
|
|
11
|
+
const mk = (name: OptimizerTool): OptimizerHandle => ({
|
|
12
|
+
name,
|
|
13
|
+
help: `${name} help`,
|
|
14
|
+
run: () => {},
|
|
15
|
+
complete: (prefix: string) =>
|
|
16
|
+
["on", "off"]
|
|
17
|
+
.filter((v) => v.startsWith(prefix.trim().toLowerCase()))
|
|
18
|
+
.map((v) => ({ value: v, label: v, description: v })),
|
|
19
|
+
});
|
|
20
|
+
return { caveman: mk("caveman"), rtk: mk("rtk"), toon: mk("toon") };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("parseInvocation", () => {
|
|
24
|
+
it("empty args → empty name", () => {
|
|
25
|
+
expect(parseInvocation("")).toEqual({ name: "", rest: "" });
|
|
26
|
+
expect(parseInvocation(" ")).toEqual({ name: "", rest: "" });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("single token → name only", () => {
|
|
30
|
+
expect(parseInvocation("rtk")).toEqual({ name: "rtk", rest: "" });
|
|
31
|
+
expect(parseInvocation(" RTK ")).toEqual({ name: "rtk", rest: "" });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("splits name from rest", () => {
|
|
35
|
+
expect(parseInvocation("caveman ultra")).toEqual({
|
|
36
|
+
name: "caveman",
|
|
37
|
+
rest: "ultra",
|
|
38
|
+
});
|
|
39
|
+
expect(parseInvocation("rtk on extra")).toEqual({
|
|
40
|
+
name: "rtk",
|
|
41
|
+
rest: "on extra",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("completeInvocation", () => {
|
|
47
|
+
it("completes tool names before any space", () => {
|
|
48
|
+
const handles = fakeHandles();
|
|
49
|
+
const out = completeInvocation("r", handles);
|
|
50
|
+
expect(out?.map((i) => i.value)).toEqual(["rtk"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns all tool names for empty prefix", () => {
|
|
54
|
+
const handles = fakeHandles();
|
|
55
|
+
const out = completeInvocation("", handles);
|
|
56
|
+
expect(out?.map((i) => i.value)).toEqual(["caveman", "rtk", "toon"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("delegates to the tool completer after the name", () => {
|
|
60
|
+
const handles = fakeHandles();
|
|
61
|
+
const out = completeInvocation("rtk o", handles);
|
|
62
|
+
expect(out?.map((i) => i.value)).toEqual(["on", "off"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("null for unknown tool", () => {
|
|
66
|
+
const handles = fakeHandles();
|
|
67
|
+
expect(completeInvocation("nope x", handles)).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("buildOptHelp", () => {
|
|
72
|
+
it("lists every tool's help line", () => {
|
|
73
|
+
const help = buildOptHelp(fakeHandles());
|
|
74
|
+
expect(help).toContain("/opt caveman help");
|
|
75
|
+
expect(help).toContain("/opt rtk help");
|
|
76
|
+
expect(help).toContain("/opt toon help");
|
|
77
|
+
expect(help).toContain("Usage: /opt <tool> [args]");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("dispatch via run", () => {
|
|
82
|
+
it("routes rest to the matching handle", () => {
|
|
83
|
+
const handles = fakeHandles();
|
|
84
|
+
let received: string | null = null;
|
|
85
|
+
handles.rtk.run = (args: string) => {
|
|
86
|
+
received = args;
|
|
87
|
+
};
|
|
88
|
+
const { name, rest } = parseInvocation("rtk off");
|
|
89
|
+
expect(name).toBe("rtk");
|
|
90
|
+
void handles[name as OptimizerTool].run(rest, {} as never);
|
|
91
|
+
expect(received).toBe("off");
|
|
92
|
+
});
|
|
93
|
+
});
|
package/src/opt.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opt.ts — the single `/opt` command that fronts every optimizer tool.
|
|
3
|
+
*
|
|
4
|
+
* caveman / rtk / json each register their own lifecycle hooks but expose an
|
|
5
|
+
* OptimizerHandle for command dispatch. This router wires them under one
|
|
6
|
+
* command so there's exactly one entry point:
|
|
7
|
+
*
|
|
8
|
+
* /opt → status + help
|
|
9
|
+
* /opt caveman <level> → caveman subcommand
|
|
10
|
+
* /opt rtk [on|off] → rtk subcommand
|
|
11
|
+
* /opt toon [on|off] → toon subcommand
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import type { CompletionItem, OptimizerHandle, OptimizerTool } from "./status.ts";
|
|
16
|
+
|
|
17
|
+
/** Split raw args into the subcommand name and the rest. */
|
|
18
|
+
export function parseInvocation(args: string): { name: string; rest: string } {
|
|
19
|
+
const trimmed = args.trim();
|
|
20
|
+
if (!trimmed) return { name: "", rest: "" };
|
|
21
|
+
const idx = trimmed.search(/\s/);
|
|
22
|
+
if (idx === -1) return { name: trimmed.toLowerCase(), rest: "" };
|
|
23
|
+
return {
|
|
24
|
+
name: trimmed.slice(0, idx).toLowerCase(),
|
|
25
|
+
rest: trimmed.slice(idx + 1),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Compute completions for `/opt ...`:
|
|
31
|
+
* - first token → tool names (filtered by prefix)
|
|
32
|
+
* - after a known tool name → delegate to that tool's complete()
|
|
33
|
+
*
|
|
34
|
+
* Pure + exported for tests.
|
|
35
|
+
*/
|
|
36
|
+
export function completeInvocation(
|
|
37
|
+
args: string,
|
|
38
|
+
handles: Record<OptimizerTool, OptimizerHandle>,
|
|
39
|
+
): CompletionItem[] | null {
|
|
40
|
+
// No space yet → still completing the tool name.
|
|
41
|
+
if (!/\s/.test(args.trimStart()) ) {
|
|
42
|
+
const prefix = args.trim().toLowerCase();
|
|
43
|
+
const names = (Object.keys(handles) as OptimizerTool[])
|
|
44
|
+
.filter((n) => n.startsWith(prefix))
|
|
45
|
+
.map((n) => ({
|
|
46
|
+
value: n,
|
|
47
|
+
label: n,
|
|
48
|
+
description: handles[n].help,
|
|
49
|
+
}));
|
|
50
|
+
return names.length > 0 ? names : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Past the tool name → delegate to the tool's own completer.
|
|
54
|
+
const { name, rest } = parseInvocation(args);
|
|
55
|
+
const handle = handles[name as OptimizerTool];
|
|
56
|
+
if (!handle) return null;
|
|
57
|
+
return handle.complete(rest);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Build the `/opt` help text listing every tool. */
|
|
61
|
+
export function buildOptHelp(handles: Record<OptimizerTool, OptimizerHandle>): string {
|
|
62
|
+
const lines = (Object.keys(handles) as OptimizerTool[]).map(
|
|
63
|
+
(n) => ` /opt ${handles[n].help}`,
|
|
64
|
+
);
|
|
65
|
+
return ["pix-optimizer — token tools", "", "Usage: /opt <tool> [args]", ...lines].join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function registerOptCommand(
|
|
69
|
+
pi: ExtensionAPI,
|
|
70
|
+
handles: Record<OptimizerTool, OptimizerHandle>,
|
|
71
|
+
): void {
|
|
72
|
+
pi.registerCommand("opt", {
|
|
73
|
+
description: "pix-optimizer: caveman / rtk / toon token tools",
|
|
74
|
+
getArgumentCompletions: (prefix: string) => completeInvocation(prefix, handles),
|
|
75
|
+
handler: async (args, ctx) => {
|
|
76
|
+
const { name, rest } = parseInvocation(args ?? "");
|
|
77
|
+
|
|
78
|
+
// No subcommand → show help.
|
|
79
|
+
if (!name) {
|
|
80
|
+
ctx.ui.notify(buildOptHelp(handles), "info");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const handle = handles[name as OptimizerTool];
|
|
85
|
+
if (!handle) {
|
|
86
|
+
ctx.ui.notify(
|
|
87
|
+
`Unknown tool: "${name}". Try: ${Object.keys(handles).join(", ")}`,
|
|
88
|
+
"error",
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
await handle.run(rest, ctx);
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
package/src/rtk.test.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
splitChain,
|
|
4
|
+
rewriteChain,
|
|
5
|
+
applyRtkRewrite,
|
|
6
|
+
type BashCallEvent,
|
|
7
|
+
} from "./rtk.ts";
|
|
8
|
+
|
|
9
|
+
/** Build a fresh bash tool_call event for hook tests. */
|
|
10
|
+
function bashEvent(command: string): BashCallEvent {
|
|
11
|
+
return { toolName: "bash", input: { command } };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("splitChain", () => {
|
|
15
|
+
it("returns single segment for plain command", () => {
|
|
16
|
+
expect(splitChain("git status")).toEqual(["git status"]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("splits on && keeping operator", () => {
|
|
20
|
+
expect(splitChain("git add . && git push")).toEqual([
|
|
21
|
+
"git add . ",
|
|
22
|
+
"&&",
|
|
23
|
+
" git push",
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("splits on ||, ;, |", () => {
|
|
28
|
+
expect(splitChain("a || b")).toEqual(["a ", "||", " b"]);
|
|
29
|
+
expect(splitChain("a ; b")).toEqual(["a ", ";", " b"]);
|
|
30
|
+
expect(splitChain("a | b")).toEqual(["a ", "|", " b"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("ignores operators inside double quotes", () => {
|
|
34
|
+
expect(splitChain('git commit -m "a && b"')).toEqual([
|
|
35
|
+
'git commit -m "a && b"',
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("ignores operators inside single quotes", () => {
|
|
40
|
+
expect(splitChain("echo 'x | y'")).toEqual(["echo 'x | y'"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns null on unbalanced quotes", () => {
|
|
44
|
+
expect(splitChain('git commit -m "oops')).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("rewriteChain", () => {
|
|
49
|
+
it("prefixes a single known command", () => {
|
|
50
|
+
expect(rewriteChain("git status")).toBe("rtk git status");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("prefixes every segment in a chain", () => {
|
|
54
|
+
expect(rewriteChain("git add . && git commit -m x && git push")).toBe(
|
|
55
|
+
"rtk git add . && rtk git commit -m x && rtk git push",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("prefixes mixed known commands", () => {
|
|
60
|
+
expect(rewriteChain("cargo build && npm test")).toBe(
|
|
61
|
+
"rtk cargo build && rtk npm test",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("leaves unknown commands alone", () => {
|
|
66
|
+
expect(rewriteChain("echo hi && mkdir x")).toBe("echo hi && mkdir x");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("only prefixes known segments in a mixed chain", () => {
|
|
70
|
+
expect(rewriteChain("cd /tmp && git status")).toBe(
|
|
71
|
+
"cd /tmp && rtk git status",
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("does not double-prefix already-rtk commands", () => {
|
|
76
|
+
expect(rewriteChain("rtk git status")).toBe("rtk git status");
|
|
77
|
+
expect(rewriteChain("rtk git add . && git push")).toBe(
|
|
78
|
+
"rtk git add . && rtk git push",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("does not touch operators inside quotes", () => {
|
|
83
|
+
expect(rewriteChain('git commit -m "a && b"')).toBe(
|
|
84
|
+
'rtk git commit -m "a && b"',
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns original on unbalanced quotes", () => {
|
|
89
|
+
const cmd = 'git commit -m "oops';
|
|
90
|
+
expect(rewriteChain(cmd)).toBe(cmd);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("prefixes known commands across a pipe (ls, wc)", () => {
|
|
94
|
+
expect(rewriteChain("ls -la | wc -l")).toBe("rtk ls -la | rtk wc -l");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("truly leaves a chain of only-unknown commands untouched", () => {
|
|
98
|
+
expect(rewriteChain("cd /tmp | sort | uniq")).toBe("cd /tmp | sort | uniq");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("handles pipes between known commands", () => {
|
|
102
|
+
expect(rewriteChain("git log | grep fix")).toBe(
|
|
103
|
+
"rtk git log | rtk grep fix",
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Integration tests for the `tool_call` hook step. These guard the bug that
|
|
109
|
+
// silently disabled rewriting: wrong event name + wrong field + wrong patch
|
|
110
|
+
// mechanism. They assert on the IN-PLACE mutation contract the SDK requires.
|
|
111
|
+
describe("applyRtkRewrite (tool_call hook step)", () => {
|
|
112
|
+
it("mutates event.input.command in place for a known bash command", () => {
|
|
113
|
+
const event = bashEvent("git status");
|
|
114
|
+
const changed = applyRtkRewrite(event, { enabled: true, rtkAvailable: true });
|
|
115
|
+
expect(changed).toBe(true);
|
|
116
|
+
expect(event.input.command).toBe("rtk git status");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("rewrites every segment of a chain in place", () => {
|
|
120
|
+
const event = bashEvent("git add . && git push");
|
|
121
|
+
applyRtkRewrite(event, { enabled: true, rtkAvailable: true });
|
|
122
|
+
expect(event.input.command).toBe("rtk git add . && rtk git push");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("does not mutate when disabled", () => {
|
|
126
|
+
const event = bashEvent("git status");
|
|
127
|
+
const changed = applyRtkRewrite(event, { enabled: false, rtkAvailable: true });
|
|
128
|
+
expect(changed).toBe(false);
|
|
129
|
+
expect(event.input.command).toBe("git status");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("does not mutate when rtk binary is unavailable", () => {
|
|
133
|
+
const event = bashEvent("git status");
|
|
134
|
+
const changed = applyRtkRewrite(event, { enabled: true, rtkAvailable: false });
|
|
135
|
+
expect(changed).toBe(false);
|
|
136
|
+
expect(event.input.command).toBe("git status");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("ignores non-bash tools", () => {
|
|
140
|
+
const event: BashCallEvent = { toolName: "grep", input: { command: "git status" } };
|
|
141
|
+
const changed = applyRtkRewrite(event, { enabled: true, rtkAvailable: true });
|
|
142
|
+
expect(changed).toBe(false);
|
|
143
|
+
expect(event.input.command).toBe("git status");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("leaves unknown commands untouched", () => {
|
|
147
|
+
const event = bashEvent("mkdir build && cd build");
|
|
148
|
+
const changed = applyRtkRewrite(event, { enabled: true, rtkAvailable: true });
|
|
149
|
+
expect(changed).toBe(false);
|
|
150
|
+
expect(event.input.command).toBe("mkdir build && cd build");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("does not double-prefix an already-rtk command", () => {
|
|
154
|
+
const event = bashEvent("rtk git status");
|
|
155
|
+
const changed = applyRtkRewrite(event, { enabled: true, rtkAvailable: true });
|
|
156
|
+
expect(changed).toBe(false);
|
|
157
|
+
expect(event.input.command).toBe("rtk git status");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("handles missing / non-string command safely", () => {
|
|
161
|
+
const event: BashCallEvent = { toolName: "bash", input: {} };
|
|
162
|
+
expect(applyRtkRewrite(event, { enabled: true, rtkAvailable: true })).toBe(false);
|
|
163
|
+
const event2: BashCallEvent = { toolName: "bash", input: { command: 123 } };
|
|
164
|
+
expect(applyRtkRewrite(event2, { enabled: true, rtkAvailable: true })).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("leaves command unchanged on unbalanced quotes", () => {
|
|
168
|
+
const event = bashEvent('git commit -m "oops');
|
|
169
|
+
const changed = applyRtkRewrite(event, { enabled: true, rtkAvailable: true });
|
|
170
|
+
expect(changed).toBe(false);
|
|
171
|
+
expect(event.input.command).toBe('git commit -m "oops');
|
|
172
|
+
});
|
|
173
|
+
});
|