SLOPSHOPPER

qsm-usage

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…

newpaneguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · qsm-usage
│ ┃ qsm-usage ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Context: 97.4k of 200k used, 102.6k left │ qsm-usage │ │ ┃ (49%) ● qsm-usage: tool.call│ qsm-usage ERROR: tool.call failed: │ │ ┃ ⏺ Read(src/auth.ts) │ undefined is not an object (evaluating │ │ ┃ | Category | Tokens | ⎿ Read 6 lines │ '$.usage.filter') │ │ ┃ | --- | ---: | ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Largest items ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ The breakdown names no single item. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 1 turns so far. │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Counts are the engine's summary breakdown, │ ┃ an estimate. Nothing here changes anything. › /qsm-usage │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · qsm-usage
Context: 97.4k of 200k used, 102.6k left (49%) | Category | Tokens | | --- | ---: | Largest items The breakdown names no single item. 1 turns so far. Counts are the engine's summary breakdown, an estimate. Nothing here changes anything.
README

qsm-usage

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.

Why it exists

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.

What you get

KindNameWhat it does
SkillusageReviews your setup in four stages, then gives one plan with each saving in tokens
Skillclaude-md-trimTrims instruction files that have grown
SkillduplicatesFinds the same guidance in two places
Skillhooks-reviewFinds slow or noisy hooks and what they inject
SkillinsightsFinds recurring friction across past sessions
Skillpermissions-tuneProposes an allowlist for commands you keep approving
Skilltools-reviewFinds a better tool for what the agent does the long way
Skillunused-extensionsLists extensions you never use, with their cost
Command/qsm-usageOpens a pane on this session's context by category
Toolsbreakdown, ledgerGive the agent the context breakdown and recorded sessions
Hooktool.call on BashRuns the command through the rtk output filter
Hooktool.call on wrapped toolsHands large structured output to the model as TOON
Hooksession and turn eventsRecords a ledger of each session
CLIqsm-usage10 verbs (count, toon, filter, report and more), see USAGE.md

Install

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.

Use

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.

Caveats

  • The default token count is characters / 4. --mode exact needs ANTHROPIC_API_KEY.
  • The Bash hook is inert without rtk. Do not install rtk from crates.io by name, because that is another package (INSTALL.md).
  • Prefix a command with QSM_TOON=0 for raw output from the TOON hook.
  • The ledger stores names, sizes and counts only, never prompt or tool text.
Source 10 files
hooks/register.tsx 435 lines
1// 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}
435
hooks/ledger.ts 309 lines
1// 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}
309
lib/filter.ts 73 lines
1// 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}
73
lib/toon-scalar.ts 129 lines
1// 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}
129
hooks/noun.generated.ts 134 lines
1// 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}
134
hooks/pane.ts 88 lines
1// 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}
88
hooks/report.ts 60 lines
1// 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}
60
hooks/toon-wrap.ts 122 lines
1// 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}
122
types/index.d.ts 50 lines
1// 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}
50
lib/toon-encode.ts 109 lines
1// 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