Reviews how you use Claude Code and proposes what to cut, what to make permanent, and what to reach for instead, each with its saving in tokens; ships the…

A review of how you use Claude Code, measured in tokens: what to cut, what to make permanent, what to reach for instead, for the person who pays for those habits.
A developer who works beside an agent every day pays for habits nobody measures: the same instruction typed into every session, context loaded on every turn and never read, a hook that injects noise into every call, a tool on the machine the agent never reaches for. qsm-usage exists to look at how this person actually uses Claude Code and hand back, in tokens, what to cut, what to make permanent, and what to reach for instead. It takes the stance that token efficiency is the measure of a good setup, that every proposal carries its saving as a number, and that nothing changes without the user's choice and a way back. It refuses to be a dashboard, a billing tool, or an optimiser that edits behind the user's back. When the harness reviews its own use and tunes itself to the person in front of it, this plugin has no reason to exist.
| Kind | Name | What it does |
|---|---|---|
| Skill | usage | Reviews your setup in four stages, then gives one plan with each saving in tokens |
| Skill | claude-md-trim | Trims instruction files that have grown |
| Skill | duplicates | Finds the same guidance in two places |
| Skill | hooks-review | Finds slow or noisy hooks and what they inject |
| Skill | insights | Finds recurring friction across past sessions |
| Skill | permissions-tune | Proposes an allowlist for commands you keep approving |
| Skill | tools-review | Finds a better tool for what the agent does the long way |
| Skill | unused-extensions | Lists extensions you never use, with their cost |
| Command | /qsm-usage | Opens a pane on this session's context by category |
| Tools | breakdown, ledger | Give the agent the context breakdown and recorded sessions |
| Hook | tool.call on Bash | Runs the command through the rtk output filter |
| Hook | tool.call on wrapped tools | Hands large structured output to the model as TOON |
| Hook | session and turn events | Records a ledger of each session |
| CLI | qsm-usage | 10 verbs (count, toon, filter, report and more), see USAGE.md |
claude plugin install qsm-usage@qsm-marketplace
bun plugins/qsm-usage/scripts/install.ts
qsm-usage doctor
The install script compiles qsm-usage and links it into ~/.local/bin. It also builds the rtk output filter with cargo when it is missing. More in INSTALL.md.
Ask: "review my Claude Code usage", "which hooks are slow?" or "which plugins do I never use?". The matching skill fires. Type /qsm-usage for a pane on why this session is expensive. The CLI:
qsm-usage count CLAUDE.md .claude/rules/*.md --total
qsm-usage toon size people.json
qsm-usage toon encode people.json
Every verb and flag is in USAGE.md.
--mode exact needs ANTHROPIC_API_KEY.rtk. Do not install rtk from crates.io by name, because that is another package (INSTALL.md).QSM_TOON=0 for raw output from the TOON hook.hooks/register.tsx 435 lines1// qsm-usage' function hooks module: the $.usage noun, so another plugin measures and encodes
2// through a typed call instead of shelling out to a path, the ledger that records every session
3// into the plugin's store at no model cost, and the two tool.call hooks that shape what a Bash
4// call runs and what the model reads back.
5import type {
6 Caught,
7 EngineInterface,
8 On,
9 PluginOptions,
10 ToolCallInput,
11 ToolCallResult,
12} from "claude-code";
13import {
14 addCost,
15 addInjection,
16 addToon,
17 addUse,
18 asSession,
19 type Breakdown,
20 breakdownOf,
21 newSession,
22 nextIndex,
23 type SessionRecord,
24 toolUsesSince,
25} from "./ledger.ts";
26import { isFilterAnswer } from "../lib/filter.ts";
27import { isPlainObject } from "../lib/toon-scalar.ts";
28import { argvOf, registerNoun, unwrap, verbs } from "./noun.generated.ts";
29import { paneMarkdown, startBeforeFirstTurn } from "./pane.ts";
30import { breakdownText, ledgerText } from "./report.ts";
31import { conversionOf, offerOf, WRAP_TIMEOUT_MS } from "./toon-wrap.ts";
32
33const INDEX_KEY = "sessions";
34const sessionKey = (id: string) => `session:${id}`;
35
36let current: SessionRecord | null = null;
37let hasLogged = false;
38
39// differs: hooks sandbox, root lib's messageOf
40function reasonOf(error: unknown): string {
41 return error instanceof Error ? error.message : String(error);
42}
43
44/** The first failure of the module's life is shown once; later ones are silent. */
45function logOnce($: EngineInterface, event: string, error: unknown): void {
46 if (hasLogged) {
47 return;
48 }
49 hasLogged = true;
50 const line = `${event} failed: ${reasonOf(error)}`;
51 try {
52 // The engine names the plugin before a log line; a toast carries no name, so it adds its own.
53 $.ui.log(line);
54 $.ui.toast(`qsm-usage ERROR: ${line}`);
55 } catch {
56 // Nowhere left to report to; the event still continues.
57 }
58}
59
60async function load($: EngineInterface): Promise<SessionRecord> {
61 const id = await $.session.id();
62 if (current?.id === id) {
63 return current;
64 }
65 const stored = asSession(await $.store.get(sessionKey(id)));
66 current = stored ?? newSession(id, await $.clock.now());
67 return current;
68}
69
70/** Runs `change` on the current session's record; never throws. */
71async function record(
72 $: EngineInterface,
73 event: string,
74 change: (rec: SessionRecord) => void | Promise<void>,
75): Promise<void> {
76 try {
77 await change(await load($));
78 } catch (error) {
79 logOnce($, event, error);
80 }
81}
82
83/** Writes the current session and trims the index to the cap; never throws. */
84async function flush($: EngineInterface, event: string): Promise<void> {
85 const rec = current;
86 if (rec === null) {
87 return;
88 }
89 try {
90 const stored = await $.store.get(INDEX_KEY);
91 const known = Array.isArray(stored)
92 ? stored.filter((i): i is string => typeof i === "string")
93 : [];
94 const next = nextIndex(known, rec.id);
95 let { index } = next;
96 for (const id of next.dropped) {
97 await $.store.delete(sessionKey(id));
98 }
99 // The store file has a size limit: when a write is over it, the oldest sessions make room.
100 for (;;) {
101 try {
102 await $.store.set(sessionKey(rec.id), rec);
103 break;
104 } catch (error) {
105 const oldest = index.find((i) => i !== rec.id);
106 if (!String(error).includes("over the") || oldest === undefined) {
107 throw error;
108 }
109 index = index.filter((i) => i !== oldest);
110 await $.store.delete(sessionKey(oldest));
111 }
112 }
113 await $.store.set(INDEX_KEY, index);
114 } catch (error) {
115 logOnce($, event, error);
116 }
117}
118
119/** Ends the in-memory session after its last write (session end, /clear). */
120async function close($: EngineInterface, event: string): Promise<void> {
121 await flush($, event);
122 current = null;
123}
124
125/** The current session's record, read without a write. */
126async function currentSession($: EngineInterface): Promise<SessionRecord | null> {
127 try {
128 return await load($);
129 } catch {
130 return null;
131 }
132}
133
134/** The last `n` recorded sessions, newest first; the current one as in memory. */
135async function recentSessions($: EngineInterface, n: number): Promise<SessionRecord[]> {
136 const stored = await $.store.get(INDEX_KEY);
137 const index = Array.isArray(stored)
138 ? stored.filter((i): i is string => typeof i === "string")
139 : [];
140 const ids = index.slice(-n).toReversed();
141 const out: SessionRecord[] = [];
142 for (const id of ids) {
143 const rec = id === current?.id ? current : asSession(await $.store.get(sessionKey(id)));
144 if (rec !== null) {
145 out.push(rec);
146 }
147 }
148 return out;
149}
150
151const BREAKDOWN_TOOL = "mcp__qsm-usage__breakdown";
152const LEDGER_TOOL = "mcp__qsm-usage__ledger";
153const DEFAULT_SESSIONS = 10;
154
155/** The summary breakdown is estimated locally; `full` would pay one
156 * token-count request per tool and memory file, so the ledger never asks for it. */
157async function breakdownNow($: EngineInterface): Promise<Breakdown> {
158 const usage = await $.session.usage({ breakdown: "summary" });
159 return breakdownOf(usage.context, await $.clock.now());
160}
161
162/** The tools and the command register once the session is ready, so both
163 * are listed by the first turn. */
164async function registerSurfaces($: EngineInterface): Promise<void> {
165 try {
166 await $.command.register({
167 name: "qsm-usage",
168 description:
169 "Show this session's context: used and left, by category, and the three largest items with what cutting each saves. No model call.",
170 });
171 await $.tool.register({
172 name: "breakdown",
173 description:
174 "The current session's context window by category (tokens per category, used and window), from the engine's summary breakdown. Costs no model call.",
175 inputSchema: { type: "object", properties: {} },
176 });
177 await $.tool.register({
178 name: "ledger",
179 description:
180 "The sessions qsm-usage recorded, newest first: turns, token usage, cost, injected text size, and the skills, commands, agents and tools used by name. Names and counts only, never prompt text.",
181 inputSchema: {
182 type: "object",
183 properties: {
184 sessions: {
185 type: "number",
186 description: `How many recent sessions; default ${DEFAULT_SESSIONS}.`,
187 },
188 },
189 },
190 });
191 } catch (error) {
192 logOnce($, "session.start", error);
193 }
194}
195
196function sessionsWanted(input: unknown): number {
197 const n = isPlainObject(input) ? input.sessions : undefined;
198 return typeof n === "number" && Number.isInteger(n) && n > 0 ? n : DEFAULT_SESSIONS;
199}
200
201function registerSession(on: On): void {
202 on("session.start", async ($, e, next) => {
203 const result = await next(e);
204 await registerSurfaces($);
205 await record($, "session.start", async (rec) => {
206 rec.start = await breakdownNow($);
207 });
208 await flush($, "session.start");
209 return result;
210 });
211
212 on("turn.complete", async ($, e, next) => {
213 const result = await next(e);
214 const isMain = e.agentId === undefined;
215 await record($, "turn.complete", async (rec) => {
216 rec.turns.push({
217 at: await $.clock.now(),
218 durationMs: e.durationMs,
219 reason: e.reason,
220 agentId: e.agentId ?? null,
221 usage: e.usage === undefined ? null : { ...e.usage },
222 costUsd: null,
223 });
224 // Only the main conversation's own tool_use blocks: `$.session.messages()` with no argument
225 // is that conversation, and a subagent's turn.complete adds none of them.
226 if (isMain) {
227 const messages = await $.session.messages();
228 for (const tool of toolUsesSince(messages, rec.toolMessagesSeen)) {
229 addUse(rec, "tools", tool);
230 }
231 rec.toolMessagesSeen = messages.length;
232 }
233 });
234 if (isMain) {
235 await flush($, "turn.complete");
236 }
237 return result;
238 });
239
240 on("session.measure", async ($, e, next) => {
241 const result = await next(e);
242 const usd = e.cost?.usd;
243 if (usd !== undefined) {
244 await record($, "session.measure", (rec) => {
245 addCost(rec, usd);
246 });
247 }
248 return result;
249 });
250
251 on("session.end", async ($, e, next) => {
252 const result = await next(e);
253 await close($, "session.end");
254 return result;
255 });
256}
257
258function registerInjections(on: On): void {
259 on("prompt.attachment", async ($, e, next) => {
260 const result = await next(e);
261 const { text } = result;
262 if (text !== null) {
263 await record($, "prompt.attachment", (rec) => {
264 addInjection(rec, "prompt.attachment", e.type, text.length);
265 });
266 }
267 return result;
268 });
269
270 on("tool.describe", async ($, e, next) => {
271 const result = await next(e);
272 await record($, "tool.describe", (rec) => {
273 addInjection(rec, "tool.describe", e.tool, result.description.length);
274 });
275 return result;
276 });
277
278 on("skill.prompt", async ($, e, next) => {
279 const result = await next(e);
280 await record($, "skill.prompt", (rec) => {
281 addInjection(rec, "skill.prompt", e.skill, result.text.length);
282 addUse(rec, "skills", e.skill);
283 });
284 return result;
285 });
286}
287
288function registerUses(on: On): void {
289 on("command.run", async ($, e, next) => {
290 const result = await next(e);
291 await record($, "command.run", (rec) => {
292 addUse(rec, "commands", e.command);
293 });
294 return result;
295 });
296
297 on("agent.spawn", async ($, e, next) => {
298 const result = await next(e);
299 if (!("deny" in result)) {
300 await record($, "agent.spawn", (rec) => {
301 addUse(rec, "agents", e.subagentType);
302 });
303 }
304 return result;
305 });
306}
307
308function registerLedgerTools(on: On): void {
309 on("tool.call", { tool: BREAKDOWN_TOOL }, async ($) => ({
310 result: breakdownText(await breakdownNow($)),
311 }));
312
313 on("tool.call", { tool: LEDGER_TOOL }, async ($, e) => ({
314 result: ledgerText(await recentSessions($, sessionsWanted(e))),
315 }));
316}
317
318/** Everything the ledger records, and the two tools that read it. */
319function registerLedger(on: On): void {
320 registerSession(on);
321 registerInjections(on);
322 registerUses(on);
323 registerLedgerTools(on);
324}
325
326/** What every tool.call hook here does when it throws: the failure is shown once, and the call
327 * carries on as it was. */
328function carryOn<E, R>($: EngineInterface, e: E, next: ((e: E) => R) & Caught): R {
329 logOnce($, "tool.call", next.error.message ?? next.error.kind);
330 return next(e);
331}
332
333/** The command output filter may rewrite a Bash command or refuse it; the verb holds the rules. */
334function registerFilter(on: On): void {
335 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
336 const answer = await $.usage.filter({
337 command: e.command,
338 subagent: e.agentId !== undefined,
339 });
340 if (!isFilterAnswer(answer)) {
341 throw new TypeError("qsm-usage filter answered with something other than { command, deny }");
342 }
343 if (answer.deny !== null) {
344 return { deny: answer.deny };
345 }
346 return next(answer.command === null ? e : { ...e, command: answer.command });
347 }).catch(carryOn);
348}
349
350function stdoutOf(result: unknown): unknown {
351 return isPlainObject(result) ? result.stdout : undefined;
352}
353
354/** The text a call printed and the command that printed it (null for an MCP tool): Bash's stdout,
355 * or an MCP tool's result. Null when the call is no Bash or MCP call, failed, or printed none. */
356function printed(e: ToolCallInput, ran: ToolCallResult) {
357 if (ran.deny !== undefined || ran.isError === true) {
358 return null;
359 }
360 const isBash = e.tool === "Bash";
361 const out = isBash ? stdoutOf(ran.result) : ran.result;
362 const isWrappable = isBash || e.tool.startsWith("mcp__");
363 return isWrappable && typeof out === "string"
364 ? { tool: e.tool, command: isBash ? e.command : null, raw: out }
365 : null;
366}
367
368/** `qsm-usage toon wrap` decides on the text; the hook only offers it and swaps in the answer. */
369async function toonOf($: EngineInterface, raw: string, hint: string) {
370 const row = verbs.toonWrap;
371 if (row === undefined) {
372 throw new Error("the generated noun has no toonWrap verb; regenerate noun.generated.ts");
373 }
374 const argv = argvOf(row, { stdin: true });
375 const ran = await $.process.run(argv, { stdin: raw, timeoutMs: WRAP_TIMEOUT_MS });
376 return conversionOf(unwrap(argv, ran), hint);
377}
378
379/** A wrapped tool's structured result reaches the model as TOON when that is smaller. An errored
380 * call and a call outside the list pass untouched. */
381function registerToon(on: On, patterns: readonly string[]): void {
382 on("tool.call", async ($, e, next) => {
383 const ran = await next(e);
384 const call = printed(e, ran);
385 const offer = call === null ? null : offerOf(patterns, call);
386 const conversion = offer === null ? null : await toonOf($, offer.raw, offer.hint);
387 if (offer === null || conversion === null) {
388 return ran;
389 }
390 await record($, "tool.call", (rec) => {
391 addToon(rec, offer.pattern, conversion.fromTokens, conversion.toTokens);
392 });
393 const result =
394 e.tool === "Bash" && isPlainObject(ran.result)
395 ? { ...ran.result, stdout: conversion.text }
396 : conversion.text;
397 return { result, context: ran.context };
398 }).catch(carryOn);
399}
400
401const PANE_ID = "qsm-usage";
402
403/** Before the first main-thread turn the pane shows the recorded start
404 * breakdown; after it, the breakdown as it is now. */
405async function paneText($: EngineInterface): Promise<string> {
406 try {
407 const rec = await currentSession($);
408 return paneMarkdown(startBeforeFirstTurn(rec) ?? (await breakdownNow($)), rec);
409 } catch (error) {
410 return `qsm-usage ERROR: ui.render: the breakdown is unavailable: ${reasonOf(error)}`;
411 }
412}
413
414/** /qsm-usage: a pane over the session, drawn with no model call and no turn.
415 * The command answers itself (no `next`), so nothing beneath runs. */
416function registerPane(on: On): void {
417 on("command.run", { command: "qsm-usage" }, async ($) => {
418 await $.ui.open({ id: PANE_ID, title: "qsm-usage" });
419 return {};
420 });
421
422 on("ui.render", { component: "Pane", requestId: PANE_ID }, async ($, e) => {
423 const { Markdown } = $.ui.resolve(e);
424 return <Markdown text={await paneText($)} />;
425 });
426}
427
428export function register(on: On, options: PluginOptions): void {
429 registerNoun(on);
430 registerToon(on, [options.toonWrap ?? []].flat().map(String));
431 registerFilter(on);
432 registerLedger(on);
433 registerPane(on);
434}
435hooks/ledger.ts 309 lines1// The ledger's data: one record per session, names, sizes and counts only.
2
3import { isPlainObject } from "../lib/toon-scalar.ts";
4
5export interface TurnUsage {
6 model: string;
7 input_tokens: number;
8 output_tokens: number;
9 cache_read_input_tokens: number;
10 cache_creation_input_tokens: number;
11}
12
13export interface ContextUsage {
14 tokens?: number;
15 window: number;
16 breakdown?: {
17 categories: readonly { name: string; tokens: number; kind: string }[];
18 memoryFiles: readonly { path: string; tokens: number }[];
19 mcpTools: readonly { name: string; serverName: string; tokens: number; isLoaded: boolean }[];
20 agents: readonly { agentType: string; tokens: number }[];
21 skills?: { skillFrontmatter: readonly { name: string; pluginName?: string; tokens: number }[] };
22 };
23}
24
25export const LEDGER_SESSIONS = 200;
26
27export type InjectionSource = "prompt.attachment" | "tool.describe" | "skill.prompt";
28
29export type UseKind = "skills" | "commands" | "agents" | "tools";
30
31/** The context breakdown as the ledger keeps it: the summary figures, no grid. */
32export interface Breakdown {
33 at: number;
34 tokens: number | null;
35 window: number;
36 categories: readonly { name: string; tokens: number; kind: string }[];
37 mcpServers: readonly { server: string; tokens: number; tools: number }[];
38 memoryFiles: readonly { path: string; tokens: number }[];
39 skills: readonly { name: string; plugin: string | null; tokens: number }[];
40 agents: readonly { name: string; tokens: number }[];
41}
42
43export interface TurnRecord {
44 at: number;
45 durationMs: number;
46 reason: string;
47 agentId: string | null;
48 usage: TurnUsage | null;
49 costUsd: number | null;
50}
51
52/** Every piece with one source and name folded into one row: how often, how big. */
53export interface Injection {
54 source: InjectionSource;
55 name: string;
56 count: number;
57 chars: number;
58 lastChars: number;
59}
60
61export interface ToonSaving {
62 pattern: string;
63 count: number;
64 fromTokens: number;
65 toTokens: number;
66}
67
68export interface SessionRecord {
69 id: string;
70 startedAt: number;
71 start: Breakdown | null;
72 turns: TurnRecord[];
73 costUsd: number | null;
74 injected: Injection[];
75 uses: Record<UseKind, Record<string, number>>;
76 toon: ToonSaving[];
77 /**
78 * How many of the main conversation's messages `turn.complete` has already read for their
79 * tool_use blocks; `toolUsesSince` reads only what came after.
80 */
81 toolMessagesSeen: number;
82}
83
84export function newSession(id: string, at: number): SessionRecord {
85 return {
86 id,
87 startedAt: at,
88 start: null,
89 turns: [],
90 costUsd: null,
91 injected: [],
92 uses: { skills: {}, commands: {}, agents: {}, tools: {} },
93 toon: [],
94 toolMessagesSeen: 0,
95 };
96}
97
98const isNum = (value: unknown): value is number => typeof value === "number";
99const isStr = (value: unknown): value is string => typeof value === "string";
100const isNumOrNull = (value: unknown): value is number | null => value === null || isNum(value);
101const isStrOrNull = (value: unknown): value is string | null => value === null || isStr(value);
102
103function everyRow(value: unknown, row: (item: Record<string, unknown>) => boolean): boolean {
104 return Array.isArray(value) && value.every((item: unknown) => isPlainObject(item) && row(item));
105}
106
107function isCounts(value: unknown): boolean {
108 return isPlainObject(value) && Object.values(value).every((count) => isNum(count));
109}
110
111function isBreakdown(value: unknown): boolean {
112 return (
113 isPlainObject(value) &&
114 isNum(value.at) &&
115 isNumOrNull(value.tokens) &&
116 isNum(value.window) &&
117 everyRow(value.categories, (c) => isStr(c.name) && isNum(c.tokens) && isStr(c.kind)) &&
118 everyRow(value.mcpServers, (m) => isStr(m.server) && isNum(m.tokens) && isNum(m.tools)) &&
119 everyRow(value.memoryFiles, (f) => isStr(f.path) && isNum(f.tokens)) &&
120 everyRow(value.skills, (s) => isStr(s.name) && isStrOrNull(s.plugin) && isNum(s.tokens)) &&
121 everyRow(value.agents, (a) => isStr(a.name) && isNum(a.tokens))
122 );
123}
124
125const isBreakdownOrNull = (value: unknown): boolean => value === null || isBreakdown(value);
126
127function isTurnUsage(usage: unknown): boolean {
128 return (
129 isPlainObject(usage) &&
130 isStr(usage.model) &&
131 isNum(usage.input_tokens) &&
132 isNum(usage.output_tokens) &&
133 isNum(usage.cache_read_input_tokens) &&
134 isNum(usage.cache_creation_input_tokens)
135 );
136}
137
138function isTurn(turn: Record<string, unknown>): boolean {
139 return (
140 isNum(turn.at) &&
141 isNum(turn.durationMs) &&
142 isStr(turn.reason) &&
143 isStrOrNull(turn.agentId) &&
144 isNumOrNull(turn.costUsd) &&
145 (turn.usage === null || isTurnUsage(turn.usage))
146 );
147}
148
149const isInjectionSource = (value: unknown): value is InjectionSource =>
150 value === "prompt.attachment" || value === "tool.describe" || value === "skill.prompt";
151
152function isInjection(row: Record<string, unknown>): boolean {
153 return (
154 isInjectionSource(row.source) &&
155 isStr(row.name) &&
156 isNum(row.count) &&
157 isNum(row.chars) &&
158 isNum(row.lastChars)
159 );
160}
161
162function isSaving(row: Record<string, unknown>): boolean {
163 return isStr(row.pattern) && isNum(row.count) && isNum(row.fromTokens) && isNum(row.toTokens);
164}
165
166/** The use counts, one count map per kind. */
167function isUses(uses: unknown): boolean {
168 return (
169 isPlainObject(uses) &&
170 isCounts(uses.skills) &&
171 isCounts(uses.commands) &&
172 isCounts(uses.agents) &&
173 isCounts(uses.tools)
174 );
175}
176
177/** Checks every field the ledger reads from a stored record, down to the rows. */
178function isSessionRecord(value: unknown): value is SessionRecord {
179 return (
180 isPlainObject(value) &&
181 isStr(value.id) &&
182 isNum(value.startedAt) &&
183 isBreakdownOrNull(value.start) &&
184 everyRow(value.turns, isTurn) &&
185 isNumOrNull(value.costUsd) &&
186 everyRow(value.injected, isInjection) &&
187 isUses(value.uses) &&
188 everyRow(value.toon, isSaving) &&
189 isNum(value.toolMessagesSeen)
190 );
191}
192
193/** A stored value read back: a record this module wrote, or null. */
194export function asSession(value: unknown): SessionRecord | null {
195 if (!isPlainObject(value) || typeof value.id !== "string" || !Array.isArray(value.turns)) {
196 return null;
197 }
198 // Older records carried a per-turn breakdown nothing read; it goes when the record is next written.
199 const turns: unknown[] = value.turns.map((turn: unknown) => {
200 if (!isPlainObject(turn)) {
201 return turn;
202 }
203 const { after: _after, ...kept } = turn;
204 return kept;
205 });
206 const record = { ...value, turns };
207 return isSessionRecord(record) ? record : null;
208}
209
210export function breakdownOf(context: ContextUsage, at: number): Breakdown {
211 const b = context.breakdown;
212 const servers = new Map<string, { tokens: number; tools: number }>();
213 for (const tool of b?.mcpTools ?? []) {
214 if (!tool.isLoaded) {
215 continue;
216 }
217 const row = servers.get(tool.serverName) ?? { tokens: 0, tools: 0 };
218 servers.set(tool.serverName, { tokens: row.tokens + tool.tokens, tools: row.tools + 1 });
219 }
220 return {
221 at,
222 tokens: context.tokens ?? null,
223 window: context.window,
224 categories: (b?.categories ?? []).map((c) => ({
225 name: c.name,
226 tokens: c.tokens,
227 kind: c.kind,
228 })),
229 mcpServers: [...servers].map(([server, row]) => ({ server, ...row })),
230 memoryFiles: (b?.memoryFiles ?? []).map((f) => ({ path: f.path, tokens: f.tokens })),
231 skills: (b?.skills?.skillFrontmatter ?? []).map((s) => ({
232 name: s.name,
233 plugin: s.pluginName ?? null,
234 tokens: s.tokens,
235 })),
236 agents: (b?.agents ?? []).map((a) => ({ name: a.agentType, tokens: a.tokens })),
237 };
238}
239
240export function addInjection(
241 rec: SessionRecord,
242 source: InjectionSource,
243 name: string,
244 chars: number,
245): void {
246 const row = rec.injected.find((i) => i.source === source && i.name === name);
247 if (row === undefined) {
248 rec.injected.push({ source, name, count: 1, chars, lastChars: chars });
249 return;
250 }
251 row.count += 1;
252 row.chars += chars;
253 row.lastChars = chars;
254}
255
256export function addUse(rec: SessionRecord, kind: UseKind, name: string): void {
257 rec.uses[kind][name] = (rec.uses[kind][name] ?? 0) + 1;
258}
259
260/** A message with the shape `turn.complete` reads from `$.session.messages()`:
261 * structural, so this file stays free of the ambient 'claude-code' module. */
262export interface MessageToolUses {
263 toolUses: readonly { tool: string }[];
264}
265
266/** The tool names of every `tool_use` block added since message `seen`, in order. */
267export function toolUsesSince(
268 messages: readonly MessageToolUses[],
269 seen: number,
270): readonly string[] {
271 return messages.slice(seen).flatMap((m) => m.toolUses.map((t) => t.tool));
272}
273
274export function addToon(
275 rec: SessionRecord,
276 pattern: string,
277 fromTokens: number,
278 toTokens: number,
279): void {
280 const row = rec.toon.find((t) => t.pattern === pattern);
281 if (row === undefined) {
282 rec.toon.push({ pattern, count: 1, fromTokens, toTokens });
283 return;
284 }
285 row.count += 1;
286 row.fromTokens += fromTokens;
287 row.toTokens += toTokens;
288}
289
290/** session.measure fires after a main-thread turn: the session's cost so far
291 * lands on the turn that just ended, once, and on the session always. */
292export function addCost(rec: SessionRecord, usd: number): void {
293 rec.costUsd = usd;
294 const last = rec.turns.toReversed().find((t) => t.agentId === null);
295 if (last !== undefined && last.costUsd === null) {
296 last.costUsd = usd;
297 }
298}
299
300/** The index after `id` is written: newest last, the oldest dropped past the cap. */
301export function nextIndex(
302 index: readonly string[],
303 id: string,
304): { index: string[]; dropped: string[] } {
305 const next = [...index.filter((i) => i !== id), id];
306 const over = Math.max(0, next.length - LEDGER_SESSIONS);
307 return { index: next.slice(over), dropped: next.slice(0, over) };
308}
309lib/filter.ts 73 lines1// Where the command output filter comes from, in one place, so the installer, the doctor, and the
2// documentation cannot disagree about it.
3
4import { isPlainObject } from "./toon-scalar.ts";
5
6const FILTER_REPO = "https://github.com/rtk-ai/rtk";
7
8const FILTER_REVISION = "5d32d0736f686b69d1e8b9dc45c007d4eb77a0a2";
9
10export const FILTER_INSTALL_COMMAND = `cargo install --git ${FILTER_REPO} --rev ${FILTER_REVISION} --locked`;
11
12/** The subcommand the filter is handed on a tool call. */
13export const FILTER_HOOK_ARGS = ["hook", "claude"] as const;
14
15/**
16 * Splits a shell command on the operators that chain one command into the next: `&&`, `||`, `;`,
17 * and `|`.
18 */
19const COMMAND_SEPARATOR = /&&|\|\||[;|]/;
20
21/** True when the first word of any segment of `command` is `git`. */
22export function runsGit(command: string): boolean {
23 return command
24 .split(COMMAND_SEPARATOR)
25 .some((segment) => segment.trim().split(/\s+/)[0] === "git");
26}
27
28/** What the filter made of a command: the command to run instead, or the reason to refuse it. */
29export interface FilterAnswer {
30 readonly command: string | null;
31 readonly deny: string | null;
32}
33
34export function isFilterAnswer(value: unknown): value is FilterAnswer {
35 return (
36 isPlainObject(value) &&
37 (value.command === null || typeof value.command === "string") &&
38 (value.deny === null || typeof value.deny === "string")
39 );
40}
41
42export const NO_ANSWER: FilterAnswer = { command: null, deny: null };
43
44/** The harness's exit code for "block this call, here is why on stderr". */
45const BLOCK_EXIT = 2;
46
47/**
48 * The filter's own reply to the PreToolUse payload: exit 2 refuses with its stderr, any other
49 * non-zero exit is its own failure and changes nothing, and exit 0 may carry the rewritten command
50 * at `hookSpecificOutput.updatedInput.command`.
51 */
52export function filterAnswer(reply: {
53 readonly status: number;
54 readonly stdout: string;
55 readonly stderr: string;
56}): FilterAnswer {
57 if (reply.status === BLOCK_EXIT) {
58 return { command: null, deny: reply.stderr.trim() || "refused by the command output filter" };
59 }
60 if (reply.status !== 0 || reply.stdout.trim().length === 0) {
61 return NO_ANSWER;
62 }
63 try {
64 const parsed: unknown = JSON.parse(reply.stdout);
65 const specific = isPlainObject(parsed) ? parsed.hookSpecificOutput : undefined;
66 const updated = isPlainObject(specific) ? specific.updatedInput : undefined;
67 const rewritten = isPlainObject(updated) ? updated.command : undefined;
68 return typeof rewritten === "string" ? { command: rewritten, deny: null } : NO_ANSWER;
69 } catch {
70 return NO_ANSWER;
71 }
72}
73lib/toon-scalar.ts 129 lines1// The scalar layer both halves of the codec share: what a value looks like on one TOON line, and
2// how a line turns back into a value.
3
4export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
5
6/** A value that fits on one line: null, a boolean, a finite number, a string. */
7export function isScalar(value: unknown): boolean {
8 if (value === null || value === undefined) {
9 return true;
10 }
11 const t = typeof value;
12 return (
13 t === "string" || t === "boolean" || (t === "number" && Number.isFinite(value)) || isDate(value)
14 );
15}
16
17function isDate(value: unknown): value is Date {
18 return value instanceof Date;
19}
20
21export function isPlainObject(value: unknown): value is Record<string, unknown> {
22 return typeof value === "object" && value !== null && !Array.isArray(value) && !isDate(value);
23}
24
25/**
26 * A bare (unquoted) token: no character that carries structure, no leading dash (which starts a
27 * list item), no surrounding space, and nothing that would read back as a number, a boolean, or
28 * null.
29 */
30const STRUCTURAL = /["`,:[\]{}#\n\r]/;
31const LITERAL = /^(?:true|false|null|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)$/;
32
33function isBare(text: string): boolean {
34 return (
35 text.length > 0 &&
36 !STRUCTURAL.test(text) &&
37 text.trim() === text &&
38 !text.startsWith("-") &&
39 !LITERAL.test(text)
40 );
41}
42
43/** One value as it appears on a line or inside a comma-separated row. */
44export function scalarText(value: unknown): string {
45 if (value === null || value === undefined) {
46 return "null";
47 }
48 if (isDate(value)) {
49 return JSON.stringify(value.toISOString());
50 }
51 if (typeof value === "number") {
52 return Number.isFinite(value) ? String(value) : "null";
53 }
54 if (typeof value === "boolean") {
55 return String(value);
56 }
57 if (typeof value !== "string") {
58 throw new TypeError("a TOON scalar is null, a boolean, a number, a string or a date");
59 }
60 const text = value;
61 return isBare(text) ? text : JSON.stringify(text);
62}
63
64/** An object key: same rule as a string value, minus the literal test: a key
65 * named `true` reads back as the string it is. */
66export function keyText(key: string): string {
67 return key.length > 0 && !STRUCTURAL.test(key) && key.trim() === key && !key.startsWith("-")
68 ? key
69 : JSON.stringify(key);
70}
71
72/** A JSON string literal read back as its string; anything else is an error. */
73export function parseQuoted(literal: string): string {
74 const parsed: unknown = JSON.parse(literal);
75 if (typeof parsed !== "string") {
76 throw new TypeError(`${literal} is not a JSON string literal`);
77 }
78 return parsed;
79}
80
81/** The inverse of `scalarText`. An empty token is an empty string, which is
82 * what an empty cell in a row means. */
83export function parseScalar(token: string): Json {
84 const text = token.trim();
85 if (text.length === 0) {
86 return "";
87 }
88 if (text.startsWith('"')) {
89 return parseQuoted(text);
90 }
91 if (text === "null") {
92 return null;
93 }
94 if (text === "true") {
95 return true;
96 }
97 if (text === "false") {
98 return false;
99 }
100 return LITERAL.test(text) ? Number(text) : text;
101}
102
103/** Splits `a,"b,c",d` on the commas that are not inside a quoted token. */
104export function splitCells(text: string): string[] {
105 const cells: string[] = [];
106 let current = "";
107 let quoted = false;
108 let escaped = false;
109 for (const ch of text) {
110 if (escaped) {
111 current += ch;
112 escaped = false;
113 } else if (quoted && ch === "\\") {
114 current += ch;
115 escaped = true;
116 } else if (ch === '"') {
117 current += ch;
118 quoted = !quoted;
119 } else if (ch === "," && !quoted) {
120 cells.push(current);
121 current = "";
122 } else {
123 current += ch;
124 }
125 }
126 cells.push(current);
127 return cells;
128}
129hooks/noun.generated.ts 134 lines1// Generated by lib/generate-noun.ts from qsm.json. Do not hand-edit; run
2// `bun lib/generate-noun.ts plugins/<name>` again after changing qsm.json.
3import type { Caught, EngineInterface, On } from "claude-code";
4import type { Usage } from "../types/index.d.ts";
5
6/** How one option becomes argv: a positional value (`flag` null), a bare
7 * boolean flag, or a flag and its value; a list repeats it per element. */
8export interface VerbArg {
9 readonly name: string;
10 readonly flag: string | null;
11 readonly boolean?: true;
12}
13
14export interface VerbRow {
15 readonly words: readonly string[];
16 readonly args: readonly VerbArg[];
17 readonly passthrough?: true;
18}
19
20export const verbs: Readonly<Record<string, VerbRow>> = {
21 toonEncode: { words: ["qsm-usage","toon","encode"], args: [{ name: "file", flag: null }, { name: "frontmatter", flag: "--frontmatter", boolean: true }, { name: "table", flag: "--table", boolean: true }, { name: "section", flag: "--section" }] },
22 toonDecode: { words: ["qsm-usage","toon","decode"], args: [{ name: "file", flag: null }] },
23 toonSize: { words: ["qsm-usage","toon","size"], args: [{ name: "file", flag: null }, { name: "frontmatter", flag: "--frontmatter", boolean: true }, { name: "table", flag: "--table", boolean: true }, { name: "section", flag: "--section" }] },
24 toonWrap: { words: ["qsm-usage","toon","wrap"], args: [{ name: "stdin", flag: "--stdin", boolean: true }] },
25 filter: { words: ["qsm-usage","filter"], args: [{ name: "command", flag: "--command" }, { name: "subagent", flag: "--subagent", boolean: true }] },
26 doctor: { words: ["qsm-usage","doctor"], args: [] },
27 count: { words: ["qsm-usage","count"], args: [{ name: "files", flag: null }, { name: "stdin", flag: "--stdin", boolean: true }, { name: "total", flag: "--total", boolean: true }, { name: "mode", flag: "--mode" }, { name: "model", flag: "--model" }] },
28 drop: { words: ["qsm-usage","drop"], args: [{ name: "key", flag: null }] },
29 dropped: { words: ["qsm-usage","dropped"], args: [] },
30 report: { words: ["qsm-usage","report"], args: [{ name: "file", flag: null }, { name: "out", flag: "--out" }] },
31};
32
33/** One verb's argv: its words, each option in declaration order, the
34 * passthrough rest after `--`, and `--json` last. */
35export function argvOf(row: VerbRow, options: Readonly<Record<string, unknown>> = {}): string[] {
36 const argv = [...row.words];
37 for (const arg of row.args) {
38 const value = options[arg.name];
39 const values = Array.isArray(value) ? value : [value];
40 for (const one of values) {
41 if (one === undefined || one === false) {
42 continue;
43 }
44 argv.push(...(arg.flag === null ? [] : [arg.flag]), ...(arg.boolean ? [] : [String(one)]));
45 }
46 }
47 const rest = options.rest;
48 if (row.passthrough && Array.isArray(rest)) {
49 argv.push("--", ...rest.map(String));
50 }
51 argv.push("--json");
52 return argv;
53}
54
55/** The binary prints one outcome envelope on stdout under --json:
56 * { code, exit, reason, message, warnings, data }. Data on ok; otherwise the
57 * code and message become the error. A stale or misbehaving binary that
58 * prints something else (not JSON, or JSON with no numeric `exit`) throws
59 * loud here instead of a raw JSON.parse SyntaxError or a silent `undefined`. */
60export function unwrap(
61 argv: readonly string[],
62 result: { readonly exitCode: number; readonly stdout: string; readonly stderr: string },
63 label = argv.slice(0, 2).join(" "),
64): unknown {
65 const text = result.stdout.trim();
66 const stderr = result.stderr.trim().slice(0, 200);
67 const cause = `(exit ${result.exitCode}): ${stderr.length > 0 ? stderr : "no stderr"}`;
68 if (text.length === 0) {
69 throw new Error(`${argv.join(" ")}: printed no envelope ${cause}`);
70 }
71 let parsed: unknown;
72 try {
73 parsed = JSON.parse(text);
74 } catch {
75 parsed = undefined;
76 }
77 if (typeof parsed !== "object" || parsed === null || typeof (parsed as { exit?: unknown }).exit !== "number") {
78 throw new Error(`${argv.join(" ")}: stdout is not an outcome envelope ${cause}: ${text.slice(0, 200)}`);
79 }
80 const envelope = parsed as { code: string; exit: number; message: string; data: unknown };
81 if (envelope.exit !== 0) {
82 throw new Error(`${label}: ${envelope.code}: ${envelope.message}`);
83 }
84 return envelope.data;
85}
86
87/** What every `.catch` does first: a toast and a log for the person. The toast says `fixedReason`
88 * when the cause may carry something the toast must not show, else the cause; the cause goes to
89 * the log and comes back for the caller's own policy. */
90export function failure(
91 $: EngineInterface,
92 event: string,
93 error: Caught["error"],
94 fixedReason?: string,
95): string {
96 const said = error.message ?? error.kind;
97 $.ui.toast(`qsm-usage ERROR: ${event}: ${(fixedReason ?? said).slice(0, 120)}`);
98 $.ui.log(`${event} failed (${error.kind}): ${said}`);
99 return said;
100}
101
102/** Adds $.usage to the engine. Call once from the plugin's own
103 * `register(on, options)` — never call the engine.create hook's own
104 * `next(e)` result from anywhere but here. */
105export function registerNoun(on: On): void {
106 on("engine.create", async (_$, e, next) => {
107 const beneath = await next(e);
108 const run = async (row: VerbRow, options?: Readonly<Record<string, unknown>>): Promise<unknown> => {
109 const argv = argvOf(row, options);
110 let result: Awaited<ReturnType<typeof beneath.process.run>>;
111 try {
112 result = await beneath.process.run(argv);
113 } catch (error) {
114 // A spawn in a deleted directory fails as ENOENT naming the binary; name the directory. A
115 // check that cannot run leaves the spawn's own error as the cause.
116 const gone = await beneath.session
117 .cwd()
118 .then(async (cwd) => ((await beneath.fs.exists(cwd)) ? null : cwd))
119 .catch(() => null);
120 if (gone !== null) {
121 throw new Error(`${row.words.join(" ")}: the session's working directory ${gone} no longer exists; cd to one that does`);
122 }
123 throw error;
124 }
125 return unwrap(argv, result, row.words.join(" "));
126 };
127 const methods = Object.entries(verbs).map(([method, row]) => [
128 method,
129 (options?: Readonly<Record<string, unknown>>) => run(row, options),
130 ]);
131 return { ...beneath, usage: Object.fromEntries(methods) as Usage };
132 });
133}
134hooks/pane.ts 88 lines1// The /qsm-usage pane: context used and left, the categories, and the three largest resident items
2// with the change that would cut each.
3import type { Breakdown, SessionRecord } from "./ledger.ts";
4import { kTokens, used } from "./report.ts";
5
6/** One of the session's largest resident items and the change that would cut it. */
7export interface Item {
8 tokens: number;
9 line: string;
10}
11
12function mcpCalls(rec: SessionRecord | null, server: string): number {
13 const prefix = `mcp__${server}__`;
14 return Object.entries(rec?.uses.tools ?? {})
15 .filter(([tool]) => tool.startsWith(prefix))
16 .reduce((sum, [, n]) => sum + n, 0);
17}
18
19function mcpItem(server: string, tokens: number, calls: number): Item {
20 const k = kTokens(tokens);
21 return {
22 tokens,
23 line:
24 calls === 0
25 ? `MCP server ${server} has no calls this session: \`/mcp disable ${server}\` saves ~${k} tokens per turn`
26 : `MCP server ${server} (~${k} tokens) was called ${calls} times this session: keep it, no saving`,
27 };
28}
29
30function unusedLine(kind: string, name: string, tokens: number, uses: number): Item {
31 const k = kTokens(tokens);
32 return {
33 tokens,
34 line:
35 uses === 0
36 ? `${kind} ${name} was not used this session: switching it off saves ~${k} tokens per turn (the unused-extensions skill offers it)`
37 : `${kind} ${name} (~${k} tokens) was used ${uses} times this session: keep it, no saving`,
38 };
39}
40
41/** The resident items the breakdown names one by one, largest first. */
42export function largestItems(b: Breakdown, rec: SessionRecord | null, count = 3): Item[] {
43 const items: Item[] = [
44 ...b.mcpServers.map((s) => mcpItem(s.server, s.tokens, mcpCalls(rec, s.server))),
45 ...b.memoryFiles.map((f) => ({
46 tokens: f.tokens,
47 line: `${f.path} loads on every turn: the claude-md-trim skill finds what to cut, up to ~${kTokens(f.tokens)} tokens per turn`,
48 })),
49 ...b.skills.map((s) => unusedLine("skill", s.name, s.tokens, rec?.uses.skills[s.name] ?? 0)),
50 ...b.agents.map((a) => unusedLine("agent", a.name, a.tokens, rec?.uses.agents[a.name] ?? 0)),
51 ];
52 return items.toSorted((x, y) => y.tokens - x.tokens).slice(0, count);
53}
54
55/** The recorded start breakdown while no main-thread turn has run, else null. */
56export function startBeforeFirstTurn(rec: SessionRecord | null): Breakdown | null {
57 if (rec === null || rec.turns.some((t) => t.agentId === null)) {
58 return null;
59 }
60 return rec.start;
61}
62
63export function paneMarkdown(b: Breakdown, rec: SessionRecord | null): string {
64 const u = used(b);
65 const left = Math.max(0, b.window - u);
66 const percent = b.window > 0 ? Math.round((u / b.window) * 100) : 0;
67 const rows = b.categories
68 .toSorted((x, y) => y.tokens - x.tokens)
69 .map((c) => `| ${c.name} | ${kTokens(c.tokens)} |`);
70 const items = largestItems(b, rec).map((item, i) => `${i + 1}. ${item.line}`);
71 const turns = rec?.turns.filter((t) => t.agentId === null).length ?? 0;
72 return [
73 `**Context:** ${kTokens(u)} of ${kTokens(b.window)} used, ${kTokens(left)} left (${percent}%)`,
74 "",
75 "| Category | Tokens |",
76 "| --- | ---: |",
77 ...rows,
78 "",
79 "**Largest items**",
80 "",
81 ...(items.length > 0 ? items : ["The breakdown names no single item."]),
82 "",
83 turns === 0 ? "No turn has run yet: this is the start breakdown." : `${turns} turns so far.`,
84 "",
85 "_Counts are the engine's summary breakdown, an estimate. Nothing here changes anything._",
86 ].join("\n");
87}
88hooks/report.ts 60 lines1// What the ledger reads back as through its two tools. Pure: the caller
2// fetches the breakdown and the records.
3import { encode } from "../lib/toon-encode.ts";
4import type { Breakdown, SessionRecord } from "./ledger.ts";
5
6/** 1234 -> "1.2k", 800 -> "800". */
7export function kTokens(n: number): string {
8 return n < 1000 ? String(Math.round(n)) : `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
9}
10
11export function used(b: Breakdown): number {
12 return (
13 b.tokens ??
14 b.categories.filter((c) => c.kind === "used").reduce((total, c) => total + c.tokens, 0)
15 );
16}
17
18/** The `breakdown` tool's answer: the current context by category. */
19export function breakdownText(b: Breakdown): string {
20 return encode({
21 context: { used: used(b), window: b.window },
22 categories: b.categories.toSorted((x, y) => y.tokens - x.tokens),
23 });
24}
25
26function counts(map: Record<string, number>): string {
27 return Object.entries(map)
28 .map(([name, n]) => `${name}=${n}`)
29 .join(" ");
30}
31
32function sum(values: readonly number[]): number {
33 return values.reduce((total, v) => total + v, 0);
34}
35
36/** The `ledger` tool's answer: one summary row per session, newest first. */
37export function ledgerText(sessions: readonly SessionRecord[]): string {
38 const rows = sessions.map((s) => {
39 const usages = s.turns.flatMap((t) => (t.usage === null ? [] : [t.usage]));
40 return {
41 id: s.id,
42 startedAt: new Date(s.startedAt).toISOString(),
43 turns: s.turns.filter((t) => t.agentId === null).length,
44 startTokens: s.start === null ? null : used(s.start),
45 inputTokens: sum(usages.map((u) => u.input_tokens)),
46 outputTokens: sum(usages.map((u) => u.output_tokens)),
47 cacheReadTokens: sum(usages.map((u) => u.cache_read_input_tokens)),
48 cacheCreationTokens: sum(usages.map((u) => u.cache_creation_input_tokens)),
49 costUsd: s.costUsd,
50 injectedChars: sum(s.injected.map((i) => i.chars)),
51 toonSavedTokens: sum(s.toon.map((t) => t.fromTokens - t.toTokens)),
52 skills: counts(s.uses.skills),
53 commands: counts(s.uses.commands),
54 agents: counts(s.uses.agents),
55 tools: counts(s.uses.tools),
56 };
57 });
58 return rows.length === 0 ? "no sessions recorded yet" : encode({ sessions: rows });
59}
60hooks/toon-wrap.ts 122 lines1// Which tool results are worth asking about, and the header a conversion reaches the model under.
2
3import { isPlainObject } from "../lib/toon-scalar.ts";
4
5/** Output this short is under the binary's ~1,000-token threshold at any
6 * format, so it is not worth starting a process for. */
7export const MIN_CHARS = 4000;
8export const RAW_PREFIX = "QSM_TOON=0";
9export const WRAP_TIMEOUT_MS = 10_000;
10
11const MCP_PREFIX = "mcp__";
12const FILE_PRINTERS = new Set(["cat", "head", "tail"]);
13
14/** `*` is any run of characters, everything else literal, the whole text. */
15export function globMatches(pattern: string, text: string): boolean {
16 const body = pattern
17 .split("*")
18 .map((part) => part.replaceAll(/[.+?^${}()|[\]\\]/g, "\\$&"))
19 .join(".*");
20 return new RegExp(`^${body}$`, "s").test(text);
21}
22
23/** A command whose output is a file's exact text: the model edits by it. */
24export function printsFile(command: string): boolean {
25 return command
26 .split(/\|\||&&|[|;]/)
27 .some((segment) => FILE_PRINTERS.has(segment.trim().split(/\s+/)[0] ?? ""));
28}
29
30/** The wrap-list pattern a call matches, or null: Bash patterns match the
31 * command, `mcp__` patterns the tool name. `Read` and every other tool never. */
32export function matchingPattern(
33 patterns: readonly string[],
34 tool: string,
35 command: string | null,
36): string | null {
37 if (tool.startsWith(MCP_PREFIX)) {
38 return patterns.find((p) => p.startsWith(MCP_PREFIX) && globMatches(p, tool)) ?? null;
39 }
40 if (tool !== "Bash" || command === null) {
41 return null;
42 }
43 const trimmed = command.trim();
44 if (trimmed.startsWith(RAW_PREFIX) || printsFile(trimmed)) {
45 return null;
46 }
47 return patterns.find((p) => !p.startsWith(MCP_PREFIX) && globMatches(p, trimmed)) ?? null;
48}
49
50/** How the header says to get the raw output back. */
51export function rawHint(pattern: string, command: string | null): string {
52 return command === null
53 ? `drop "${pattern}" from the qsm-usage toonWrap option`
54 : `${RAW_PREFIX} ${command.trim()}`;
55}
56
57export interface Offer {
58 readonly pattern: string;
59 readonly raw: string;
60 readonly hint: string;
61}
62
63/**
64 * One tool call as `offerOf` reads it: which tool, its Bash command (null for an MCP tool), and
65 * the raw text it printed (null when there is none to read).
66 */
67export interface Call {
68 readonly tool: string;
69 readonly command: string | null;
70 readonly raw: string | null;
71}
72
73/** What to ask the binary about, or null when the call passes without
74 * asking: no wrap-list match (which covers `Read`, file printers and
75 * `QSM_TOON=0`), or output too short to matter. */
76export function offerOf(patterns: readonly string[], call: Call): Offer | null {
77 const pattern = matchingPattern(patterns, call.tool, call.command);
78 const { raw } = call;
79 if (pattern === null || raw === null || raw.length <= MIN_CHARS) {
80 return null;
81 }
82 return { pattern, raw, hint: rawHint(pattern, call.command) };
83}
84
85function kilo(tokens: number): string {
86 return `${(tokens / 1000).toFixed(1).replace(/\.0$/, "")}k`;
87}
88
89export interface Conversion {
90 text: string;
91 fromTokens: number;
92 toTokens: number;
93}
94
95const FORMATS = new Set(["json", "yaml", "toml"]);
96
97function isCount(value: unknown): value is number {
98 return typeof value === "number" && Number.isFinite(value) && value >= 0;
99}
100
101/** The TOON text under its header line when the binary said to use it, null
102 * when it said to keep the original; a data shape it never prints throws. */
103export function conversionOf(data: unknown, hint: string): Conversion | null {
104 const d: Record<string, unknown> = isPlainObject(data) ? data : {};
105 if (d.use === "original") {
106 return null;
107 }
108 const { format, toon, originalTokens, toonTokens } = d;
109 if (
110 d.use !== "toon" ||
111 typeof format !== "string" ||
112 !FORMATS.has(format) ||
113 typeof toon !== "string" ||
114 !isCount(originalTokens) ||
115 !isCount(toonTokens)
116 ) {
117 throw new Error(`qsm-usage toon wrap: unexpected data: ${JSON.stringify(data).slice(0, 200)}`);
118 }
119 const header = `[qsm-toon: ${format.toUpperCase()} -> TOON, ${kilo(originalTokens)} -> ${kilo(toonTokens)} tokens; raw: ${hint}]`;
120 return { text: `${header}\n${toon}`, fromTokens: originalTokens, toTokens: toonTokens };
121}
122types/index.d.ts 50 lines1// Generated by lib/generate-noun.ts from qsm.json. Do not hand-edit; run
2// `bun lib/generate-noun.ts plugins/<name>` again after changing qsm.json.
3
4export type Usage = {
5 toonEncode(options: {
6 file: string;
7 frontmatter?: boolean;
8 table?: boolean;
9 section?: string;
10 }): Promise<unknown>;
11 toonDecode(options: {
12 file: string;
13 }): Promise<unknown>;
14 toonSize(options: {
15 file: string;
16 frontmatter?: boolean;
17 table?: boolean;
18 section?: string;
19 }): Promise<unknown>;
20 toonWrap(options: {
21 stdin?: boolean;
22 }): Promise<unknown>;
23 filter(options: {
24 command: string;
25 subagent?: boolean;
26 }): Promise<unknown>;
27 doctor(): Promise<unknown>;
28 count(options: {
29 files?: (string)[];
30 stdin?: boolean;
31 total?: boolean;
32 mode?: "estimate" | "exact";
33 model?: string;
34 }): Promise<unknown>;
35 drop(options: {
36 key: string;
37 }): Promise<unknown>;
38 dropped(): Promise<unknown>;
39 report(options: {
40 file: string;
41 out: string;
42 }): Promise<unknown>;
43};
44
45declare module 'claude-code' {
46 interface EngineInterface {
47 usage: Usage;
48 }
49}
50lib/toon-encode.ts 109 lines1// TOON encoder.
2
3import { isPlainObject, isScalar, keyText, scalarText } from "./toon-scalar.ts";
4
5const STEP = 2;
6
7function pad(indent: number): string {
8 return " ".repeat(indent);
9}
10
11/** The one shape worth encoding: every element an object, same keys in the
12 * same order, every value on one line. Returns the shared columns, or null. */
13export function tableColumns(items: readonly unknown[]): string[] | null {
14 const [first] = items;
15 if (items.length === 0 || !isPlainObject(first)) {
16 return null;
17 }
18 const columns = Object.keys(first);
19 if (columns.length === 0) {
20 return null;
21 }
22 for (const item of items) {
23 if (!isPlainObject(item)) {
24 return null;
25 }
26 const keys = Object.keys(item);
27 if (keys.length !== columns.length || keys.some((k, i) => k !== columns[i])) {
28 return null;
29 }
30 if (Object.values(item).some((v) => !isScalar(v))) {
31 return null;
32 }
33 }
34 return columns;
35}
36
37function emitArray(key: string | null, items: readonly unknown[], indent: number, out: string[]) {
38 const head = `${pad(indent)}${key === null ? "" : keyText(key)}[${items.length}]`;
39 if (items.length === 0) {
40 out.push(`${head}:`);
41 return;
42 }
43 if (items.every(isScalar)) {
44 out.push(`${head}: ${items.map(scalarText).join(",")}`);
45 return;
46 }
47 const columns = tableColumns(items);
48 if (columns !== null) {
49 out.push(`${head}{${columns.map(keyText).join(",")}}:`);
50 for (const item of items.filter((cell) => isPlainObject(cell))) {
51 out.push(pad(indent + STEP) + columns.map((c) => scalarText(item[c])).join(","));
52 }
53 return;
54 }
55 out.push(`${head}:`);
56 for (const item of items) {
57 emitItem(item, indent + STEP, out);
58 }
59}
60
61/** A list element. */
62function emitItem(value: unknown, indent: number, out: string[]): void {
63 if (isScalar(value)) {
64 out.push(`${pad(indent)}- ${scalarText(value)}`);
65 return;
66 }
67 const body: string[] = [];
68 const inner = indent + STEP;
69 if (Array.isArray(value)) {
70 emitArray(null, value, inner, body);
71 } else if (isPlainObject(value)) {
72 emitObject(value, inner, body);
73 } else {
74 throw new TypeError("a TOON list element is a scalar, an array or a plain object");
75 }
76 const [first, ...rest] = body;
77 out.push(
78 first === undefined ? `${pad(indent)}-` : `${pad(indent)}- ${first.slice(inner)}`,
79 ...rest,
80 );
81}
82
83function emitObject(value: Record<string, unknown>, indent: number, out: string[]): void {
84 for (const [key, child] of Object.entries(value)) {
85 if (Array.isArray(child)) {
86 emitArray(key, child, indent, out);
87 } else if (isPlainObject(child)) {
88 out.push(`${pad(indent)}${keyText(key)}:`);
89 emitObject(child, indent + STEP, out);
90 } else {
91 out.push(`${pad(indent)}${keyText(key)}: ${scalarText(child)}`);
92 }
93 }
94}
95
96/** Encodes any JSON-shaped value. An empty object encodes to an empty
97 * document, which `decode` reads back as an empty object. */
98export function encode(value: unknown): string {
99 const out: string[] = [];
100 if (Array.isArray(value)) {
101 emitArray(null, value, 0, out);
102 } else if (isPlainObject(value)) {
103 emitObject(value, 0, out);
104 } else {
105 out.push(scalarText(value));
106 }
107 return out.join("\n");
108}
109