SLOPSHOPPER

compactly

Compact smartly. Cost & cache panel for Claude Code: cache countdown, cost per turn, ranked cost hints, savings from compacting, and compaction at natural…

panebandspinnerguardcommandtoast
★ 1v0.1.0Apache-2.0updated 2026-10-22swapnildahiphale/compactly/plugin/compactly
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · compactly
│ ┃ Cost & cache ✕ › fix the failing auth test and add an audit log call │ ┃ Cost & cache claude-opus-5-5 · 1h cache[ ⚙ │ ┃ ● compactly: compactly: loaded (mode=auto, judge=sonnet, minTokens=15 │ ┃ ╭────────────────────────────────────────╮ ● compactly: compactly: panel placed │ ┃ │ Session cost │ ⏺ Read(src/auth.ts) │ ┃ │ $0.42 │ ⎿ Read 6 lines │ ┃ │ – │ ⏺ Update(src/auth.ts) │ ┃ ╰────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ● Warm expires in 59 min · rebuilding ⎿ 3 pass, 1 fail │ ┃ later costs $0.78 │ ┃ ████████████████████████████████ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ █ warm █ last 5 min before expiry: │ ┃ auto-compact ░ cold ✻ Worked for 42s · done 4:20 PM │ ┃ Context █████████████░░░░░░░░░░░░░ 97K / │ ┃ 200K (49%) › /compactly │ ┃ ⎿ compactly: compactly: compacting with a handoff summary… │ ┃ Hints ● compactly: compactly: bash ok=false boundary=none: bun test │ ┃ ✓ Nothing to act on. The cache is doing ● compactly: compactly: bash ok=true boundary=none: git status --porc │ ┃ its job. │ ┃ │ ┃ Saved by compacting │ ┃ Nothing yet this session. Compact at a │ ┃ task boundary to start saving. │ ┃ ★ Level 1: Cache Rookie all time $0.000 │ ┃ ░░░░░░░░░░░░░░░░░░░░░░░░░░ $1.00 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits [ ● warm 59m · $0.78 if cold ]

Draws

Pane · Cost & cache
Cost & cache claude-opus-5-5 · 1h cache[ ⚙ Settings ] [ Co ╭────────────────────────────────────────────────────────╮ │ Session cost │ │ $0.42 │ │ – │ ╰────────────────────────────────────────────────────────╯ ● Warm expires in 59 min · rebuilding later costs $0.78 ████████████████████████████████ █ warm █ last 5 min before expiry: auto-compact ░ cold Context █████████████░░░░░░░░░░░░░ 97K / 200K (49%) Hints ✓ Nothing to act on. The cache is doing its job. Saved by compacting Nothing yet this session. Compact at a task boundary to start saving. ★ Level 1: Cache Rookie all time $0.000 ░░░░░░░░░░░░░░░░░░░░░░░░░░ $1.00 to Context Trimmer Cost per turn █ Last turn: cache read $0.018 · cache write $0.034 · output $0.030 · uncached input $0.008
README

compactly

Compact smartly.

A Claude Code plugin that shows what your session costs and compacts it at task boundaries, with a summary written as a handoff for the next task.

What it does

  • Cost & cache panel (/cost-panel): what each turn costs, when the prompt cache expires, and ranked hints: task boundary, cache expiring or cold, unexpected cache rebuild, large context, a cheaper model, large tool output. From top to bottom it has:
  • a header with the model, the cache TTL, a ⚙ Settings button and a Compact button;
  • four stat tiles with sparklines: Session cost, Cache hit, Re-read per turn, Rebuild if cold;
  • a cache clock bar with warm, compact-window and cold zones, a marker at now, and a legend;
  • the Context bar;
  • ranked hints, each with 1 Compact now, 2 Snooze 10 min and 3 Not this session (hotkeys 1, 2, 3 act on the top hint only);
  • Saved by compacting, and Cost per turn.

⚙ Settings turns each section on or off and picks the footer line's fields (presets minimal, cost, standard, or custom); choices are kept across sessions.

A one-line footer line beside the prompt keeps the cache state in view when the panel is closed (● warm 42m · $0.36 if cold · ▲ 1 tip). Press it to open the panel.

The terminal draws text bars; the desktop app draws SVG. Clicking the buttons in the CLI needs /tui fullscreen; the hotkeys work without it. Where no window can be drawn, the command replies with the same report as text.

  • Compaction at natural breaks (auto mode): when you send a prompt and the context is at least minTokens, a judge model (Claude Sonnet by default) reads a short digest of the session and the new prompt. If it sees a natural break with enough confidence, the plugin holds the prompt back, compacts with a handoff summary that also names the new prompt, and then sends it. The prompt you see in the transcript is then sent by the plugin. A prompt with an image or an @-mention is never held back, because a plugin can't send those again. Neither is a prompt from an SDK host (claude -p or another stream-json host), where a held-back prompt would be lost. There the git signals decide, as with judge=off.
  • Compaction before the cache expires (auto mode with a judge): after a turn with a git commit, push or PR on a large context, if no prompt follows, the plugin compacts 5 minutes before the 1-hour cache expires (1 minute before the 5-minute one). The summarizer still reads the context from cache, and when you come back your next message re-writes the short summary instead of the whole context. A prompt sent before then cancels it, and the judge decides as usual. This only runs while Claude Code stays open.
  • Paused work is left alone: if your last three prompts, or the answers since, say work is paused, on hold, blocked or waiting ("paused after step 3, resume once replica lag drops"), a git signal does not compact automatically. The plugin logs why; run /compactly if it is safe. The judge still decides at your next prompt.
  • 👍 / 👎 on each automatic compaction: a band above the prompt asks whether it was a natural break (or run /compactly-rate up|down). The judge learns from the answers:
  • each 👎 in your last 20 ratings raises the confidence it needs by 0.05, and each 👍 lowers it by 0.02, never below where it started or above 0.9;
  • your newest three 👍 and three 👎 from the same project go into the judge's prompt as examples of where your work really ends.

Ratings are kept in the plugin's store under ~/.claude, with the digest the judge read (the start of your prompts and of the agent's answer, and file names) and the project's root. Ratings from every project move the confidence bar, but a digest is only shown to the judge in the project it was rated in, so prompts from one project never reach another's.

  • Task-boundary suggestions (suggest mode): after a successful git commit, git push or gh pr create/merge command (more categories via signals), once the turn ends and the context is at least minTokens, it suggests /compactly. With judge=off, auto mode compacts on these signals instead.
  • /compactly: compact now, with instructions to keep open work, paused work and why it stopped, decisions with their reasons, and file paths.
  • Saved by compacting: what your compactions have saved, in the panel and in /cost-panel's text. Each turn after a compaction is credited with the context it no longer re-reads; the compaction's own cost (the summarizer, and writing the new context to the cache) is taken off first, so a fresh compaction shows a small loss until it pays back. A compaction after the cache expired is priced against the full re-write the next turn would have needed anyway: it isn't charged for writing the new context, and the next turn is credited with the re-write it avoided. Your /compact counts; autocompact doesn't, since it would have run anyway. An all-time total across sessions earns levels, from Cache Rookie ($0) to Compaction Legend ($500), with a toast when you reach one. The total is kept in the plugin's own store under ~/.claude: dollar amounts and counts only, one entry per session.

See the decision flow.

Install and configure

Requires Claude Code 2.1.295 or later. Install and settings are covered in the top-level README, installation and configuration.

Develop

claude plugin validate plugin/compactly
claude plugin test plugin/compactly

.claude-plugin/types/ is generated by the Claude Code plugin tooling and is not committed; tsconfig.json extends it.

Source 14 files
hooks/register.tsx 777 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register } from 'claude-code'
3
4import type { PanelState, PrefsState, Rating, Savings, Unrated } from '../types'
5import { chartAlt, costChart, meter, SERIES, sparkline } from './chart'
6import { footerLine } from './footer'
7import { cacheState, minutes, SNOOZE_MS } from './hints'
8import type { Hint } from './hints'
9import { digest, examples, isOnHold, JUDGES, prompt, SYSTEM, threshold, verdict } from './judge'
10import type { Judge } from './judge'
11import { panelModel, ttlMsOf, windowMsOf } from './model'
12import { clockView, hintView, tilesView } from './panel-view'
13import { costSplit, kTokens, priceOf, usd, writePrice } from './pricing'
14import { report } from './report'
15import { DEFAULT_PREFS, normalize, PRESETS } from './settings'
16import type { FieldId, ViewId } from './settings'
17import { settingsView } from './settings-view'
18import { carried, compacted, level, lifetime, net, NO_SAVINGS, payback, signed } from './savings'
19
20type Boundary = 'pr' | 'push' | 'deploy' | 'commit' | 'tests' | 'validate' | 'build' | 'todos'
21
22// git's global options may sit before the subcommand: `git -C <dir> commit`.
23const GIT = String.raw`\bgit(?:\s+(?:-[Cc]\s+\S+|--\S+))*\s+`
24
25// Strongest first: a turn that commits and opens a PR reports `pr`.
26const SIGNALS: readonly (readonly [Boundary, RegExp])[] = [
27  ['pr', /\bgh\s+pr\s+(create|merge)\b/],
28  ['push', new RegExp(`${GIT}push\\b`)],
29  ['commit', new RegExp(`${GIT}commit\\b`)],
30  ['deploy', /\bhelm\s+(upgrade|install)\b|\bkubectl\s+(apply|rollout\s+restart)\b(?!.*--dry-run)|\bterraform\s+apply\b|\bargocd\s+app\s+sync\b/],
31  [
32    'tests',
33    /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\bpytest\b|\bgo\s+test\b|\bcargo\s+test\b|\b(jest|vitest)\b|\bmake\s+test\b|\bgradlew?\b.*\btest\b|\bmvn\b.*\b(test|verify)\b/,
34  ],
35  ['validate', /\bhelm\s+(lint|template)\b|\bterraform\s+(validate|plan)\b|\bkubectl\b.*--dry-run|\bkubeconform\b|\bkustomize\s+build\b/],
36  [
37    'build',
38    /\b(npm|pnpm|yarn|bun)\s+(run\s+)?(build|lint|typecheck)\b|\btsc\b|\bgo\s+(build|vet)\b|\bcargo\s+(build|check|clippy)\b|\bruff\b|\beslint\b/,
39  ],
40]
41
42const RANK: Record<Boundary, number> = { pr: 0, push: 1, deploy: 2, commit: 3, tests: 4, validate: 5, build: 6, todos: 7 }
43
44// Which signals count, by the `signals` setting.
45const SIGNAL_SETS: Record<string, readonly Boundary[]> = {
46  git: ['pr', 'push', 'commit'],
47  'git+deploy': ['pr', 'push', 'commit', 'deploy'],
48  all: ['pr', 'push', 'deploy', 'commit', 'tests', 'validate', 'build', 'todos'],
49}
50
51const COMMAND = 'compactly'
52const PANEL_COMMAND = 'cost-panel'
53const RATE_COMMAND = 'compactly-rate'
54const PANE = 'cost-panel'
55const DEFER_MS = 1500
56const COMMAND_DEFER_MS = 100
57const TICK_MS = 30_000
58// Room the footer line may take beside the mode labels; fields drop from the right past it.
59const FOOTER_COLS = 50
60const MAX_TURNS = 60
61// A tool result at least this large (in tokens, ~4 characters each) is worth a hint.
62const BIG_OUTPUT = 8_000
63// The judge runs before the prompt enters; past this it lets the prompt through unjudged.
64const JUDGE_TIMEOUT_MS = 15_000
65// With the judge on, a boundary no prompt followed compacts this long after the turn: 55 minutes
66// on the 1-hour cache, 4 on the 5-minute one, so the summarizer still reads it warm.
67const beforeExpiryMs = (ttl: string) => ttlMsOf(ttl) - windowMsOf(ttl)
68// Prompts the person typed, here or through the Remote Control bridge. An SDK host's prompt
69// (-p, or another SDK host) is never held back: a -p run ends with it, and the prompt is lost.
70const PERSON = new Set(['composer', 'bridge'])
71const MENTION = /(^|\s)@\S/
72
73const PREFS = 'prefs'
74
75const prefs = atom({ plugin: 'compactly', key: 'prefs' } as const, { ...DEFAULT_PREFS, isSettingsOpen: false } as PrefsState)
76
77/** Applies a change to the preferences and keeps it for later sessions. */
78const setPrefs = async ($: EngineInterface, change: (p: PrefsState) => PrefsState) => {
79  const next = await update($, prefs, change)
80  const { isSettingsOpen: _, ...keep } = next
81  await $.store.set(PREFS, keep).catch(() => undefined)
82}
83
84const panel = atom({ plugin: 'compactly', key: 'panel' } as const, {
85  turns: [],
86  dismissed: [],
87  tick: 0,
88  tokens: 0,
89  window: 0,
90  sessionUsd: 0,
91  model: '',
92  saved: NO_SAVINGS,
93} as PanelState)
94
95// Each run of a session keeps its own entry in the store, so parallel sessions never race.
96const RUN = 'run:'
97
98/** Net savings and compactions of every earlier run, from the plugin's store. */
99const pastRuns = async ($: EngineInterface) => {
100  const keys = (await $.store.keys()).filter(k => k.startsWith(RUN))
101  const runs = (await Promise.all(keys.map(k => $.store.get(k)))) as ({ usd?: number; compactions?: number } | undefined)[]
102  const total = { usd: 0, compactions: 0 }
103  for (const r of runs) {
104    total.usd += r?.usd ?? 0
105    total.compactions += r?.compactions ?? 0
106  }
107  return total
108}
109
110// Each rating of an automatic compaction is its own store entry, for the same reason.
111const RATING = 'rating:'
112
113const ratings = async ($: EngineInterface) => {
114  const keys = (await $.store.keys()).filter(k => k.startsWith(RATING))
115  return ((await Promise.all(keys.map(k => $.store.get(k)))) as (Rating | undefined)[]).filter(r => r !== undefined)
116}
117
118const persist = ($: EngineInterface, v: Savings, now: number) =>
119  v.runKey === undefined || v.compactions === 0
120    ? Promise.resolve()
121    : $.store.set(v.runKey, { usd: net(v), compactions: v.compactions, at: now }).catch(() => undefined)
122
123type Reason = Boundary | 'manual' | 'natural break'
124
125export const handoff = (boundary: Reason, next?: string) =>
126  [
127    `This compaction runs at a task boundary (${boundary}). Write the summary as a handoff for the NEXT task, not a history of this one.`,
128    ...(next === undefined ? [] : [`The next task starts with this prompt; keep what it will need: ${JSON.stringify(next.slice(0, 500))}`]),
129    'Keep, in this order:',
130    '1. What was just completed, in two or three sentences, with commit hashes, branch and PR links.',
131    '2. Open work and follow-ups the user asked for or agreed to, verbatim where the user wrote them.',
132    '3. Work that is paused, blocked or waiting: what it is, why it stopped, and what must be true before it resumes. Never describe paused work as in progress or as the next step.',
133    '4. Decisions, constraints and preferences that still apply, each with its reason, whether the user stated it or the work found it.',
134    '5. Files and paths that matter for the follow-up work.',
135    '6. Anything still failing, skipped or unverified.',
136    'Drop: dead ends, superseded plans, raw tool and test output, and file contents that can be read again.',
137  ].join('\n')
138
139export const detect = (command: string, allowed: readonly Boundary[] = SIGNAL_SETS.all!): Boundary | undefined =>
140  SIGNALS.find(([b, re]) => allowed.includes(b) && re.test(command))?.[0]
141
142// A todo list whose every item is done marks the end of the work it planned.
143const isTodoDone = (input: unknown) => {
144  const todos = (input as { todos?: { status?: string }[] } | undefined)?.todos ?? []
145  return todos.length > 0 && todos.every(t => t.status === 'completed')
146}
147
148const stronger = (a: Boundary | undefined, b: Boundary) =>
149  a === undefined || RANK[b] < RANK[a] ? b : a
150
151// Per register call, so a reload or a second session has its own.
152type Idle = {
153  /** Cancels the armed idle compaction's timer; a compaction that runs first, or a prompt, calls it. */
154  cancel?: () => void
155  isCompacting: boolean
156}
157
158// One compaction at a time: false, without running, when another is in flight.
159const oneAtATime = async (idle: Idle, run: () => Promise<void>) => {
160  if (idle.isCompacting) return false
161  idle.isCompacting = true
162  try {
163    await run()
164    return true
165  } finally {
166    idle.isCompacting = false
167  }
168}
169
170// Right after a compaction `context.tokens` is empty until the next response; /context's estimate is not.
171const sizeNow = async ($: EngineInterface) => {
172  const { context } = await $.session.usage({ breakdown: 'summary' }).catch(() => ({ context: undefined }))
173  return context?.tokens ?? context?.breakdown?.totalTokens
174}
175
176/**
177 * Settles a main-conversation compaction: the boundary and the big output are gone with the
178 * context, and one the person or a plugin chose counts toward the savings.
179 */
180const settle = async (
181  $: EngineInterface,
182  idle: Idle,
183  r: { tokensBefore?: number; tokensAfter?: number; usage?: ModelUsage },
184  counts: boolean,
185  ttl: string,
186) => {
187  idle.cancel?.()
188  idle.cancel = undefined
189  const now = await $.clock.now()
190  const tokensAfter = r.tokensAfter ?? (await sizeNow($))
191  return update($, panel, s => {
192    const p = priceOf(s.model)
193    const tokensBefore = r.tokensBefore ?? s.tokens
194    // After the cache expired, as when the judge compacts at the first prompt back from a break.
195    const isCold = cacheState(s, now, ttlMsOf(ttl))?.isWarm === false
196    return {
197      ...s,
198      // The context shrank now; the next measure or turn would only catch up.
199      tokens: tokensAfter ?? s.tokens,
200      boundary: undefined,
201      bigOutput: undefined,
202      compactAt: undefined,
203      saved: counts && p !== undefined && tokensBefore > 0 && tokensAfter !== undefined
204        ? compacted(s.saved, { tokensBefore, tokensAfter, usage: r.usage }, p, ttl, isCold)
205        : s.saved,
206    }
207  })
208}
209
210// The panel announces the armed idle compaction; a cancelled or run one drops the announcement.
211const clearCompactAt = ($: EngineInterface) =>
212  update($, panel, s => (s.compactAt === undefined ? s : { ...s, compactAt: undefined }))
213
214// The plugin's own compaction does not pass through its own session.compact hook: it settles here.
215const compact = async ($: EngineInterface, idle: Idle, boundary: Reason, ttl: string, next?: string) => {
216  let r
217  try {
218    r = await $.session.compact({ instructions: handoff(boundary, next) })
219  } catch (err) {
220    if (!/headless/.test(String(err))) throw err
221    // SDK hosts (-p, stream-json) compact only as a command: run `/compact` itself.
222    const tokensBefore = (await read($, panel)).tokens
223    await $.command.run({ command: 'compact', args: handoff(boundary, next) })
224    await settle($, idle, { tokensBefore }, true, ttl)
225    return { text: `compactly: ${boundary} → ran /compact (SDK session)`, isCompacted: true }
226  }
227  if (r.skip !== undefined) return { text: `compactly: skipped (${r.skip})`, isCompacted: false }
228  await settle($, idle, r, true, ttl)
229  const before = r.tokensBefore === undefined ? '?' : kTokens(r.tokensBefore)
230  const after = r.tokensAfter === undefined ? '?' : kTokens(r.tokensAfter)
231  const cost = r.usage
232    ? `, summarizer read ${kTokens(r.usage.cache_read_input_tokens ?? 0)} from cache`
233    : ''
234  const p = priceOf((await read($, panel)).model)
235  const removed = (r.tokensBefore ?? 0) - (r.tokensAfter ?? r.tokensBefore ?? 0)
236  const saves = p !== undefined && removed > 0 ? ` · saves ~${usd((removed * p.read) / 1e6)} per turn` : ''
237  return { text: `compactly: ${boundary} → compacted ${before} → ${after}${cost}${saves}`, isCompacted: true }
238}
239
240const compactAndLog = async ($: EngineInterface, idle: Idle, ttl: string) => {
241  const ran = await oneAtATime(idle, async () => {
242    try {
243      $.ui.log((await compact($, idle, 'manual', ttl)).text)
244    } catch (err) {
245      $.ui.toast(`compactly: could not compact (${String(err)})`)
246    }
247  })
248  if (!ran) $.ui.toast('compactly: already compacting')
249}
250
251/**
252 * Compacts at a natural break the judge found, then sends the prompt it held back: the host
253 * refuses a compaction under a prompt's own submission, so the prompt waits for it.
254 */
255const compactThenSend = async ($: EngineInterface, idle: Idle, text: string, ttl: string, unrated: Unrated, why: string) => {
256  try {
257    await oneAtATime(idle, async () => {
258      const done = await compact($, idle, 'natural break', ttl, text)
259      $.ui.log(done.isCompacted ? `${done.text} · ${why} · rate it: /${RATE_COMMAND} up|down` : done.text)
260      if (done.isCompacted) await update($, panel, s => ({ ...s, unrated }))
261    })
262  } catch (err) {
263    $.ui.log(`compactly: could not compact (${String(err)})`)
264  } finally {
265    await $.prompt.submit({ text, asUser: true })
266  }
267}
268
269/** Stores the person's rating of the last automatic compaction; the judge learns from it. */
270const rate = async ($: EngineInterface, isGood: boolean, start: number | undefined) => {
271  const { unrated } = await read($, panel)
272  if (unrated === undefined) return 'compactly: no automatic compaction to rate.'
273  const rating: Rating = { at: unrated.at, isGood, confidence: unrated.confidence, digest: unrated.digest, project: unrated.project }
274  await $.store.set(`${RATING}${await $.session.id()}:${unrated.at}`, rating)
275  await update($, panel, s => ({ ...s, unrated: undefined }))
276  const bar = threshold(start ?? unrated.threshold, await ratings($))
277  return `compactly: ${isGood ? '👍' : '👎'} saved. The judge sees this moment as an example from now on, and compacts at confidence ≥ ${bar.toFixed(2)}.`
278}
279
280// Drops the snoozes that have run out, so the map keeps only the hints still hidden.
281const liveSnoozes = (snoozed: Record<string, number> | undefined, now: number) =>
282  Object.fromEntries(Object.entries(snoozed ?? {}).filter(([, until]) => until > now))
283
284/** The one-line summary for the status line, where no pane is drawn. */
285const statusLine = (s: PanelState, now: number, ttl: string) => {
286  const p = priceOf(s.model)
287  if (p === undefined || s.tokens === 0) return undefined
288  const cache = cacheState(s, now, ttlMsOf(ttl))
289  const state = cache === undefined ? '' : cache.isWarm ? `cache warm ${minutes(cache.remainingMs)} · ` : 'cache cold · '
290  return `${state}${kTokens(s.tokens)} ctx · ${usd((s.tokens * p.read) / 1e6)}/turn · /${PANEL_COMMAND}`
291}
292
293export const register: Register = (on, options) => {
294  const mode = String(options.mode ?? 'auto')
295  const minTokens = Number(options.minTokens ?? 150000)
296  const ttl = String(options.cacheTtl ?? '1h')
297  const openPanel = options.openPanel !== false
298  const allowed = SIGNAL_SETS[String(options.signals ?? 'git')] ?? SIGNAL_SETS.git!
299  const judgeName = String(options.judge ?? 'sonnet')
300  // In auto mode a judge, when one is set, decides at prompt time in place of the signals.
301  const judge = judgeName in JUDGES ? JUDGES[judgeName as Judge] : undefined
302
303  let pending: Boundary | undefined
304  let isTurnRunning = false
305  const idle: Idle = { isCompacting: false }
306  const cancel = () => {
307    idle.cancel?.()
308    idle.cancel = undefined
309  }
310  let isPanePlaced = false
311  // An SDK host sends its prompts as `sdk`; there the signals decide, as with judge=off.
312  let isSdk = false
313
314  on('session.start', async ($, e, next) => {
315    // A reload keeps the panel state but not the timer that would run the idle compaction.
316    await clearCompactAt($)
317    $.ui.log(`compactly: loaded (mode=${mode}, judge=${judge === undefined ? 'off' : judgeName}, minTokens=${minTokens}, ttl=${ttl})`, { to: 'debug' })
318    await $.command.register({
319      name: COMMAND,
320      description: 'Compact now with a task-handoff summary (keeps open work, decisions, files).',
321    })
322    await $.command.register({
323      name: RATE_COMMAND,
324      description: 'Rate the last automatic compaction: up (a natural break) or down (the work was still going).',
325      argumentHint: 'up|down',
326    })
327    await $.command.register({
328      name: PANEL_COMMAND,
329      description: 'Open the cost & cache panel: cache countdown, cost per turn, and hints.',
330    })
331    if (openPanel) {
332      const opened = await $.ui.open({ id: PANE, title: 'Cost & cache' })
333      isPanePlaced = opened.isPlaced
334      $.ui.log(`compactly: panel ${opened.isPlaced ? 'placed' : `waiting (${opened.reason})`}`, { to: 'debug' })
335    }
336    try {
337      const saved = normalize(await $.store.get(PREFS))
338      await update($, prefs, p => ({ ...p, ...saved }))
339    } catch (err) {
340      $.ui.log(`compactly: no saved settings (${String(err)})`, { to: 'debug' })
341    }
342    // Savings from earlier runs, for the all-time total and the level.
343    try {
344      const [id, now, past] = await Promise.all([$.session.id(), $.clock.now(), pastRuns($)])
345      await update($, panel, s => ({
346        ...s,
347        saved: { ...s.saved, pastUsd: past.usd, pastCompactions: past.compactions, runKey: s.saved.runKey ?? `${RUN}${id}:${now}` },
348      }))
349    } catch (err) {
350      $.ui.log(`compactly: no saved totals (${String(err)})`, { to: 'debug' })
351    }
352    // Redraws the countdown; the status line carries it where no pane is drawn.
353    $.clock.every(TICK_MS, () => {
354      void update($, panel, s => ({ ...s, tick: s.tick + 1 }))
355      if (isPanePlaced) return
356      void Promise.all([read($, panel), $.clock.now()]).then(([s, now]) => {
357        const line = statusLine(s, now, ttl)
358        if (line !== undefined) $.ui.status(line)
359      })
360    })
361    return next(e)
362  })
363
364  // The host refuses a compaction under the command's own turn: run it once the command returns.
365  on('command.run', { command: COMMAND }, async $ => {
366    $.clock.after(COMMAND_DEFER_MS, () => void compactAndLog($, idle, ttl))
367    return { text: 'compactly: compacting with a handoff summary…' }
368  })
369
370  on('command.run', { command: RATE_COMMAND }, async ($, e) => {
371    const arg = e.args.trim().toLowerCase()
372    if (!/^(up|down|good|bad)$/.test(arg)) return { text: `Usage: /${RATE_COMMAND} up|down` }
373    return { text: await rate($, arg === 'up' || arg === 'good', judge?.confidence) }
374  })
375
376  // Prompt time: the judge reads the new prompt. At a natural break the plugin holds the prompt
377  // back, compacts, and sends it again, so it starts on the handoff summary.
378  on('prompt.submit', async ($, e, next) => {
379    if (e.origin.kind === 'sdk') isSdk = true
380    // A prompt for the model is back before the cache expired: the judge decides, not the timer.
381    if (!e.text.trimStart().startsWith('/')) {
382      cancel()
383      await clearCompactAt($)
384    }
385    if (judge === undefined || mode !== 'auto' || isTurnRunning || e.turnId !== undefined) return next(e)
386    if (!PERSON.has(e.origin.kind) || e.text.trimStart().startsWith('/')) return next(e)
387    // A prompt sent again by a plugin loses its images and @-mentions: never hold one back.
388    if (e.attachments?.length || MENTION.test(e.text)) return next(e)
389    if (((await $.session.usage()).context.tokens ?? 0) < minTokens) return next(e)
390    const d = digest(await $.session.messages(), e.text)
391    if (d === undefined) return next(e)
392    const [rated, project] = await Promise.all([ratings($), $.session.root()])
393    // Every rating moves the bar, but only this project's digests are shown to the judge:
394    // prompts from one project never reach another project's judge.
395    const bar = threshold(judge.confidence, rated)
396    const own = rated.filter(r => r.project === project)
397    $.ui.status('compactly: checking for a natural break…')
398    try {
399      const r = await $.model.complete({
400        model: judge.model,
401        system: SYSTEM,
402        prompt: prompt(d, examples(own)),
403        maxTokens: 100,
404        effort: 'low',
405        timeoutMs: JUDGE_TIMEOUT_MS,
406      })
407      // The judge's cost counts against what compacting saves.
408      const p = priceOf(judge.priceAs)
409      if (p !== undefined) {
410        const u = r.usage
411        const spent = (u.input_tokens * p.input + u.output_tokens * p.output + u.cache_read_input_tokens * p.read
412          + u.cache_creation_input_tokens * p.write5m) / 1e6
413        await update($, panel, s => ({ ...s, saved: { ...s.saved, spent: s.saved.spent + spent } }))
414      }
415      const v = r.isAnswered ? verdict(r.text) : undefined
416      const said = v === undefined ? `no verdict (${r.isAnswered ? 'unreadable reply' : r.reason})` : `break=${v.isBreak} confidence=${v.confidence}`
417      $.ui.log(`compactly: ${judgeName} judge ${said}, needs ${bar}`, { to: 'debug' })
418      if (v?.isBreak && v.confidence >= bar) {
419        const unrated: Unrated = { at: await $.clock.now(), judge: judgeName, confidence: v.confidence, threshold: bar, digest: d, project }
420        const why = `${judgeName} ${v.confidence.toFixed(2)} ≥ ${bar.toFixed(2)}`
421        $.clock.after(COMMAND_DEFER_MS, () => void compactThenSend($, idle, e.text, ttl, unrated, why))
422        return { drop: `compactly: a natural break (${why}). Compacting first, then sending your prompt.` }
423      }
424    } finally {
425      $.ui.status(undefined)
426    }
427    return next(e)
428  }).catch(($, e, next) => next(e)) // a failed judge never holds the prompt back
429
430  on('command.run', { command: PANEL_COMMAND }, async $ => {
431    const [surfaces, s, now] = await Promise.all([$.session.surfaces(), read($, panel), $.clock.now()])
432    // A surface that draws no plugin pane answers with the report as text.
433    if (!surfaces.some(surface => surface === 'terminal' || surface === 'desktop')) return { text: report(s, now, ttl, COMMAND) }
434    const opened = await $.ui.open({ id: PANE, title: 'Cost & cache' })
435    isPanePlaced = opened.isPlaced
436    return { text: opened.isPlaced ? 'Cost & cache panel opened.' : `Cost & cache panel is waiting: ${opened.reason}` }
437  })
438
439  on('session.measure', async ($, e, next) => {
440    await update($, panel, s => ({
441      ...s,
442      tokens: e.context.tokens ?? s.tokens,
443      window: e.context.window,
444      sessionUsd: e.cost?.usd ?? s.sessionUsd,
445    }))
446    return next(e)
447  })
448
449  // Compactions the host runs: the person's /compact and autocompact, which would have run anyway.
450  on('session.compact', async ($, e, next) => {
451    const r = await next(e)
452    if (e.agentId === undefined && r.skip === undefined && e.trigger !== 'precompute') {
453      await settle($, idle, r, e.trigger !== 'auto', ttl)
454    }
455    return r
456  }).catch(($, e, next) => next(e))
457
458  // Only the main loop raises turn.start; a subagent's run raises none.
459  on('turn.start', async ($, e, next) => {
460    isTurnRunning = true
461    cancel()
462    await clearCompactAt($)
463    return next(e)
464  })
465
466  on('tool.call', async ($, e, next) => {
467    const ran = await next(e)
468    if (e.agentId !== undefined) return ran
469    const tokens = Math.round((ran.text?.length ?? 0) / 4)
470    if (tokens >= BIG_OUTPUT) {
471      await update($, panel, s => ({ ...s, bigOutput: { tool: String(e.tool), tokens } }))
472    }
473    if (mode === 'off') return ran
474    const isOk = ran.deny === undefined && ran.isError !== true
475    if (e.tool === 'TodoWrite' && isOk && allowed.includes('todos') && isTodoDone(e)) {
476      pending = stronger(pending, 'todos')
477      return ran
478    }
479    if (e.tool !== 'Bash') return ran
480    const boundary = isOk ? detect(e.command, allowed) : undefined
481    if (boundary !== undefined) pending = stronger(pending, boundary)
482    $.ui.log(`compactly: bash ok=${isOk} boundary=${boundary ?? 'none'}: ${e.command.slice(0, 60)}`, { to: 'debug' })
483    return ran
484  }).catch(($, e, next) => next(e)) // observe only: never block a tool
485
486  on('turn.complete', async ($, e, next) => {
487    const result = await next(e)
488    if (e.agentId !== undefined) return result
489    isTurnRunning = false
490
491    const boundary = pending
492    pending = undefined
493    const [usage, now, model] = await Promise.all([$.session.usage(), $.clock.now(), $.session.model()])
494    const tokens = usage.context.tokens ?? 0
495    const turnModel = e.usage?.model ?? model
496    const p = priceOf(turnModel)
497
498    let was = NO_SAVINGS
499    const after = await update($, panel, s => {
500      const turn = e.usage && p
501        ? [{
502            at: now,
503            model: turnModel,
504            ctx: tokens,
505            readTokens: e.usage.cache_read_input_tokens,
506            writeTokens: e.usage.cache_creation_input_tokens,
507            ...costSplit(e.usage, p, ttl),
508          }]
509        : []
510      was = s.saved
511      return {
512        ...s,
513        saved: turn[0] && p ? carried(s.saved, turn[0], usage.context.window, p, ttl) : s.saved,
514        turns: [...s.turns, ...turn].slice(-MAX_TURNS),
515        lastActivityAt: e.usage ? now : s.lastActivityAt,
516        model: turnModel,
517        tokens: tokens || s.tokens,
518        window: usage.context.window,
519        sessionUsd: usage.cost?.usd ?? s.sessionUsd,
520        boundary: mode === 'suggest' && boundary !== undefined && tokens >= minTokens ? boundary : s.boundary,
521      }
522    })
523
524    const saved = after.saved
525    if (saved.gross !== was.gross) {
526      await persist($, saved, now)
527      const [from, to] = [level(lifetime(was)), level(lifetime(saved))]
528      if (to.rank > from.rank) {
529        $.ui.toast(`★ Level ${to.rank}: ${to.name}. Compacting has saved you ${usd(lifetime(saved))} so far.`, { timeoutMs: 8000 })
530      } else if (net(was) < 0 && net(saved) >= 0) {
531        $.ui.toast(`Compaction paid for itself: ${usd(net(saved))} saved this session.`, { timeoutMs: 6000 })
532      }
533    }
534
535    $.ui.log(`compactly: turn.complete reason=${e.reason} boundary=${boundary ?? 'none'}`, { to: 'debug' })
536    if (boundary === undefined || e.reason !== 'answer' || mode === 'off') return result
537
538    $.ui.log(`compactly: context ${tokens} tokens (min ${minTokens})`, { to: 'debug' })
539    if (tokens < minTokens) return result
540
541    if (mode === 'auto') {
542      // A commit can land with work paused on purpose: leave that compaction to the person.
543      if (isOnHold(await $.session.messages())) {
544        $.ui.log(`compactly: ${boundary}, but recent turns mention paused or blocked work; not compacting automatically. Run /${COMMAND} if it is safe.`)
545        return result
546      }
547      // With a judge, the signals no longer compact now: the judge decides at the next prompt.
548      // If none comes before the cache expires, compact just before it does, while it is warm.
549      if (judge !== undefined && !isSdk) {
550        cancel()
551        await clearCompactAt($)
552        const timer = $.clock.after(beforeExpiryMs(ttl), () => {
553          idle.cancel = undefined
554          void update($, panel, st => ({ ...st, compactAt: undefined })).catch(() => undefined)
555          if (isTurnRunning) return
556          // A /compactly since the boundary may have made the context small already.
557          void $.session.usage()
558            .then(u => {
559              if ((u.context.tokens ?? 0) < minTokens) return undefined
560              return oneAtATime(idle, async () => {
561                const r = await compact($, idle, boundary, ttl)
562                $.ui.log(r.isCompacted ? `${r.text} · idle, before the cache expired` : r.text)
563              })
564            })
565            .catch(err => $.ui.log(`compactly: could not compact (${String(err)})`))
566        })
567        const armed = () => timer.cancel()
568        idle.cancel = armed
569        await update($, panel, st => ({ ...st, compactAt: now + beforeExpiryMs(ttl) }))
570        // A compaction or a prompt during that write cancelled the timer; the write must not leave its announcement behind.
571        if (idle.cancel !== armed) await clearCompactAt($)
572        return result
573      }
574      // Compaction rejects while a turn runs; wait until this one has fully settled.
575      const timer = $.clock.after(DEFER_MS, () => {
576        idle.cancel = undefined
577        if (isTurnRunning) return
578        void oneAtATime(idle, async () => {
579          $.ui.log((await compact($, idle, boundary, ttl)).text)
580        }).catch(err => $.ui.log(`compactly: could not compact (${String(err)})`))
581      })
582      idle.cancel = () => timer.cancel()
583      return result
584    }
585
586    const carry = p === undefined ? '' : ` (~${usd((tokens * p.read) / 1e6)} per turn to carry)`
587    $.ui.status(`task boundary (${boundary}) · ${kTokens(tokens)} ctx${carry} · /${COMMAND}`)
588    // Hosts without toasts still show the prompt box's suggestion: Tab takes it.
589    $.prompt.suggest({ text: `/${COMMAND}` }).catch(() => undefined)
590    $.ui.toast(
591      `Task boundary (${boundary}) with ${kTokens(tokens)} of context${carry}. Run /${COMMAND} now, while the cache is warm.`,
592      { timeoutMs: 8000 },
593    )
594    return result
595  })
596
597  // Above the prompt after an automatic compaction: was it a natural break? The answer teaches the judge.
598  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
599    const { unrated } = await read($, panel)
600    if (unrated === undefined || e.props.hasSurvey) return next(e)
601    const { Box, Text, Button } = $.ui.resolve(e)
602    const press = (isGood: boolean) => () => void rate($, isGood, judge?.confidence).then(text => $.ui.toast(text, { timeoutMs: 6000 }))
603    return (
604      <Box flexDirection="row">
605        <Text dimColor wrap="truncate-end">
606          Compacted at a natural break ({unrated.judge}, {unrated.confidence.toFixed(2)}). Right call?{' '}
607        </Text>
608        <Button key="rate-good" label="👍 Yes" onPress={press(true)} />
609        <Button key="rate-bad" label="👎 No, I was mid-task" onPress={press(false)} />
610        <Button key="rate-dismiss" label="Dismiss" onPress={() => void update($, panel, s => ({ ...s, unrated: undefined }))} />
611      </Box>
612    )
613  })
614
615  // The footer line stays whether the pane is open or closed; pressing it opens the pane.
616  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
617    const [s, now, pf] = await Promise.all([read($, panel), $.clock.now(), read($, prefs)])
618    const f = footerLine(s, now, ttl, FOOTER_COLS, pf.fields)
619    if (f === undefined) return next(e)
620    const { Box, Text, Button } = $.ui.resolve(e)
621    return (
622      <Box flexDirection="row" gap={1}>
623        {e.props.modes.length > 0 && <Text dimColor>{e.props.modes.join(' · ')}</Text>}
624        <Button
625          key="footer-open"
626          label={f.text}
627          onPress={() =>
628            void $.ui.open({ id: PANE, title: 'Cost & cache' }).then(opened => {
629              isPanePlaced = opened.isPlaced
630            })
631          }
632        />
633      </Box>
634    )
635  })
636
637  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
638    const els = $.ui.resolve(e)
639    const { Box, Text, Button } = els
640    // The terminal draws the bars and sparklines as text even where the kit offers an Svg element.
641    const Svg = e.surface !== 'terminal' && 'Svg' in els ? els.Svg : undefined
642    const [s, now] = await Promise.all([read($, panel), $.clock.now()])
643    const m = panelModel(s, now, ttl)
644    const p = priceOf(s.model)
645    const cols = e.props.bodyColumns
646
647    if (m === undefined || p === undefined) {
648      return (
649        <Box flexDirection="column" paddingX={1}>
650          <Text bold>Cost & cache</Text>
651          <Text dimColor>Send a message: the panel fills in after the first reply.</Text>
652        </Box>
653      )
654    }
655
656    const pf = await read($, prefs)
657    if (pf.isSettingsOpen) {
658      const f = footerLine(s, now, ttl, FOOTER_COLS, pf.fields)
659      return settingsView(els, pf, f?.text, {
660        toggleView: (id: ViewId) => void setPrefs($, p => ({ ...p, views: { ...p.views, [id]: !p.views[id] } })),
661        toggleField: (id: FieldId) => void setPrefs($, p => ({ ...p, fields: { ...p.fields, [id]: !p.fields[id] } })),
662        setPreset: name => void setPrefs($, p => ({ ...p, fields: { ...PRESETS[name]! } })),
663        close: () => void update($, prefs, p => ({ ...p, isSettingsOpen: false })),
664      })
665    }
666
667    const share = s.window > 0 ? s.tokens / s.window : 0
668    const fill = meter(share, Math.max(10, Math.min(30, cols - 30)))
669    const fillColor = share >= 0.8 ? 'error' : share >= 0.5 ? 'warning' : 'claude'
670    const rebuild = (s.tokens * writePrice(p, ttl)) / 1e6
671    const last = s.turns.at(-1)
672    const v = s.saved
673    const perTurn = (v.removed * p.read) / 1e6
674    const turnsLeft = payback(v, perTurn)
675    const total = lifetime(v)
676    const lv = level(total)
677    const progress = meter(lv.next === undefined ? 1 : (total - lv.at) / (lv.next.usd - lv.at), Math.max(10, Math.min(30, cols - 30)))
678
679    const actions = (hint: Hint) => ({
680      compact: () => void compactAndLog($, idle, ttl),
681      snooze: () =>
682        void $.clock.now().then(at =>
683          update($, panel, st => ({ ...st, snoozed: { ...liveSnoozes(st.snoozed, at), [hint.id]: at + SNOOZE_MS } })),
684        ),
685      dismiss: () => void update($, panel, st => ({ ...st, dismissed: [...st.dismissed, hint.id] })),
686    })
687
688    return (
689      <Box flexDirection="column" paddingX={1}>
690        <Box flexDirection="row" justifyContent="space-between">
691          <Text>
692            <Text bold>Cost & cache</Text>
693            <Text dimColor>  {s.model} · {ttl} cache</Text>
694          </Text>
695          <Box flexDirection="row" gap={1}>
696            <Button key="settings-open" label="⚙ Settings" onPress={() => void update($, prefs, p => ({ ...p, isSettingsOpen: true }))} />
697            <Button key="compact-header" label="Compact" onPress={() => void compactAndLog($, idle, ttl)} />
698          </Box>
699        </Box>
700
701        {pf.views.tiles && tilesView(els, m.tiles, Svg)}
702        {pf.views.clock &&
703          clockView(els, m.clock, { tokens: kTokens(s.tokens), rebuild: usd(rebuild), auto: mode === 'auto' && judge !== undefined && !isSdk }, cols, Svg)}
704
705        {pf.views.context && (
706          <Text>
707            <Text dimColor>Context  </Text>
708            <Text color={fillColor}>{fill.filled}</Text>
709            <Text dimColor>{fill.track}</Text>
710            <Text>  {kTokens(s.tokens)} / {kTokens(s.window)} ({Math.round(share * 100)}%)</Text>
711          </Text>
712        )}
713
714        {pf.views.hints && (
715          <Box flexDirection="column" marginTop={1}>
716            <Text bold>Hints</Text>
717            {m.hints.length === 0 ? (
718              <Text>
719                <Text color="success">✓ Nothing to act on.</Text>
720                <Text dimColor>  The cache is doing its job.</Text>
721              </Text>
722            ) : (
723              m.hints.map((hint, i) => hintView(els, hint, actions(hint), i === 0))
724            )}
725          </Box>
726        )}
727
728        {pf.views.saved && (
729          <Box flexDirection="column" marginTop={1}>
730            <Text bold>Saved by compacting</Text>
731            {v.compactions === 0 ? (
732              <Text dimColor>Nothing yet this session. Compact at a task boundary to start saving.</Text>
733            ) : (
734              <Text>
735                <Text bold color={net(v) >= 0 ? 'success' : 'warning'}>{signed(net(v))}</Text>
736                <Text> this session</Text>
737                <Text dimColor>
738                  {'  '}· {v.compactions} compaction{v.compactions === 1 ? '' : 's'} · {kTokens(v.removed)} less to re-read, ~{usd(perTurn)} per turn
739                </Text>
740              </Text>
741            )}
742            {turnsLeft > 0 && <Text dimColor>  The compaction pays for itself in {turnsLeft} more turn{turnsLeft === 1 ? '' : 's'}.</Text>}
743            <Text>
744              <Text color="claude">★ Level {lv.rank}: {lv.name}</Text>
745              <Text dimColor>  all time {signed(total)}</Text>
746            </Text>
747            {lv.next !== undefined && (
748              <Text>
749                <Text dimColor>{'         '}</Text>
750                <Text color="claude">{progress.filled}</Text>
751                <Text dimColor>{progress.track}</Text>
752                <Text dimColor>  {usd(lv.next.usd - total)} to {lv.next.name}</Text>
753              </Text>
754            )}
755          </Box>
756        )}
757
758        {pf.views.chart && s.turns.length > 0 && (
759          <Box flexDirection="column" marginTop={1}>
760            <Text bold>Cost per turn</Text>
761            {Svg !== undefined ? (
762              <Svg source={costChart(s.turns)} alt={chartAlt(s.turns)} isInteractive />
763            ) : (
764              <Text color="claude">{sparkline(s.turns, Math.max(10, cols - 4))}</Text>
765            )}
766            {last !== undefined && (
767              <Text dimColor wrap="wrap">
768                Last turn: {SERIES.map(x => `${x.label.toLowerCase()} ${usd(last[x.key])}`).join(' · ')}
769              </Text>
770            )}
771          </Box>
772        )}
773      </Box>
774    )
775  })
776}
777
hooks/chart.ts 134 lines
1import type { TurnRow } from '../types'
2import { kTokens, usd } from './pricing'
3
4// Categorical slots 1-4 of the reference palette, validated as adjacent stack pairs in both
5// modes. Light aqua and yellow sit under 3:1 on the surface, so the panel adds a text
6// breakdown of the last turn beside the chart (the relief rule).
7export const SERIES = [
8  { key: 'read', label: 'Cache read', light: '#2a78d6', dark: '#3987e5' },
9  { key: 'write', label: 'Cache write', light: '#eb6834', dark: '#d95926' },
10  { key: 'output', label: 'Output', light: '#1baf7a', dark: '#199e70' },
11  { key: 'input', label: 'Uncached input', light: '#eda100', dark: '#c98500' },
12] as const
13
14const SLOTS = 30
15const W = 560
16const H = 180
17const M = { top: 30, right: 8, bottom: 20, left: 44 }
18const GAP = 2
19
20const total = (t: TurnRow) => t.read + t.write + t.output + t.input
21
22const niceMax = (n: number) => {
23  if (n <= 0) return 0.01
24  const step = 10 ** Math.floor(Math.log10(n))
25  return [1, 2, 2.5, 5, 10].map(m => m * step).find(v => v >= n) ?? n
26}
27
28const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
29
30// A column segment whose top corners are rounded (the data end), square at the bottom.
31const topRounded = (x: number, y: number, w: number, h: number, cls: string) => {
32  const r = Math.min(4, h, w / 2)
33  return `<path class="${cls}" d="M${x},${y + h}V${y + r}Q${x},${y} ${x + r},${y}H${x + w - r}Q${x + w},${y} ${x + w},${y + r}V${y + h}Z"/>`
34}
35
36/** Stacked cost per turn, newest on the right, as one SVG document. */
37export const costChart = (turns: readonly TurnRow[]) => {
38  const shown = turns.slice(-SLOTS)
39  const max = niceMax(Math.max(...shown.map(total)))
40  const plotW = W - M.left - M.right
41  const plotH = H - M.top - M.bottom
42  const slot = plotW / SLOTS
43  const barW = Math.min(24, Math.max(4, slot * 0.6))
44  const base = M.top + plotH
45  const y = (v: number) => (v / max) * plotH
46
47  const grid = [0, max / 2, max]
48    .map(v => {
49      const gy = base - y(v)
50      return `<line class="${v === 0 ? 'b' : 'g'}" x1="${M.left}" x2="${W - M.right}" y1="${gy}" y2="${gy}"/>` +
51        `<text class="mu num" x="${M.left - 6}" y="${gy + 4}" text-anchor="end">${v === 0 ? '$0' : usd(v)}</text>`
52    })
53    .join('')
54
55  const bars = shown
56    .map((t, i) => {
57      const x = M.left + (SLOTS - shown.length + i) * slot + (slot - barW) / 2
58      const parts = SERIES.map((s, k) => ({ cls: `c${k + 1}`, h: y(t[s.key]) })).filter(p => p.h >= 0.5)
59      let top = base
60      const marks = parts
61        .map((p, k) => {
62          const h = k === 0 ? p.h : p.h - GAP
63          top -= p.h
64          if (h < 0.5) return ''
65          const yTop = top
66          return k === parts.length - 1
67            ? topRounded(x, yTop, barW, h, p.cls)
68            : `<rect class="${p.cls}" x="${x}" y="${yTop}" width="${barW}" height="${h}"/>`
69        })
70        .join('')
71      const tip = [
72        `Turn ${turns.length - shown.length + i + 1}: ${usd(total(t))}, ${kTokens(t.ctx)} context`,
73        ...SERIES.map(s => `${s.label}: ${usd(t[s.key])}`),
74      ].join('\n')
75      const hitX = M.left + (SLOTS - shown.length + i) * slot
76      return `<g class="bar"><title>${esc(tip)}</title><rect class="hit" x="${hitX}" y="${M.top}" width="${slot}" height="${plotH}"/>${marks}</g>`
77    })
78    .join('')
79
80  // One direct label, on the most expensive turn: the story is usually a cache rebuild.
81  const peakIndex = shown.reduce((best, t, i) => (total(t) > total(shown[best]!) ? i : best), 0)
82  const peak = shown[peakIndex]
83  const peakLabel = peak === undefined ? '' : (() => {
84    const cx = M.left + (SLOTS - shown.length + peakIndex) * slot + slot / 2
85    const what = peak.write > total(peak) / 2 ? ' cache rebuild' : ''
86    const anchor = cx > W - 90 ? 'end' : cx < M.left + 60 ? 'start' : 'middle'
87    return `<text class="t2 num" x="${cx}" y="${base - y(total(peak)) - 6}" text-anchor="${anchor}">${usd(total(peak))}${what}</text>`
88  })()
89
90  let lx = M.left
91  const legend = SERIES.map((s, k) => {
92    const item = `<rect class="c${k + 1}" x="${lx}" y="8" width="10" height="10" rx="2"/><text class="t2" x="${lx + 14}" y="17">${s.label}</text>`
93    lx += 14 + s.label.length * 5.9 + 18
94    return item
95  }).join('')
96
97  const light = SERIES.map((s, k) => `.c${k + 1}{fill:${s.light}}`).join('')
98  const dark = SERIES.map((s, k) => `.c${k + 1}{fill:${s.dark}}`).join('')
99
100  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">` +
101    `<style>text{font:11px system-ui,-apple-system,"Segoe UI",sans-serif}.num{font-variant-numeric:tabular-nums}` +
102    `.s{fill:#fcfcfb}.t2{fill:#52514e}.mu{fill:#898781}.g{stroke:#e1e0d9;stroke-width:1}.b{stroke:#c3c2b7;stroke-width:1}` +
103    `.hit{fill:transparent}g.bar:hover .hit{fill:rgba(137,135,129,.14)}${light}` +
104    `@media (prefers-color-scheme: dark){.s{fill:#1a1a19}.t2{fill:#c3c2b7}.g{stroke:#2c2c2a}.b{stroke:#383835}${dark}}</style>` +
105    `<rect class="s" width="${W}" height="${H}" rx="6"/>${legend}${grid}${bars}${peakLabel}` +
106    `<text class="mu" x="${W - M.right}" y="${H - 5}" text-anchor="end">last ${shown.length} turn${shown.length === 1 ? '' : 's'}, newest right</text>` +
107    `</svg>`
108}
109
110export const chartAlt = (turns: readonly TurnRow[]) => {
111  const shown = turns.slice(-SLOTS)
112  const sum = shown.reduce((n, t) => n + total(t), 0)
113  const peak = shown.reduce((a, t) => (total(t) > total(a) ? t : a), shown[0] ?? turns[0]!)
114  return `Cost per turn for the last ${shown.length} turns: ${usd(sum)} in all, the most expensive ${usd(total(peak))}.`
115}
116
117const BLOCKS = '▁▂▃▄▅▆▇█'
118
119/** One block per value, tallest at the series' own maximum. */
120export const blocks = (series: readonly number[], width: number) => {
121  const shown = series.slice(-Math.max(1, width))
122  const max = Math.max(...shown, 1e-9)
123  return shown.map(v => BLOCKS[Math.min(7, Math.round((v / max) * 7))]).join('')
124}
125
126/** The terminal's stand-in for the chart: one block per turn, height by cost. */
127export const sparkline = (turns: readonly TurnRow[], width: number) => blocks(turns.map(total), width)
128
129/** A text meter: filled cells for the share used, light cells for the rest. */
130export const meter = (share: number, cells: number) => {
131  const filled = Math.round(Math.min(1, Math.max(0, share)) * cells)
132  return { filled: '█'.repeat(filled), track: '░'.repeat(cells - filled) }
133}
134
hooks/footer.ts 44 lines
1import type { PanelState, Prefs } from '../types'
2import { panelModel } from './model'
3import { DEFAULT_PREFS } from './settings'
4
5/** Context share at which the footer starts to show it, the same point the pane's bar turns red. */
6const CTX_WARN = 0.8
7
8export type Footer = { text: string; color: 'success' | 'warning' | 'error' | undefined }
9
10const short = (ms: number) => (ms < 60_000 ? '<1m' : `${Math.floor(ms / 60_000)}m`)
11
12/**
13 * The one-line footer: the fields `fields` turns on, in order, dropping from the right until the line fits
14 * `width`. Undefined while there is nothing priced to show, or no field is on.
15 */
16export const footerLine = (
17  s: PanelState,
18  now: number,
19  ttl: string,
20  width = Infinity,
21  fields: Prefs['fields'] = DEFAULT_PREFS.fields,
22): Footer | undefined => {
23  const m = panelModel(s, now, ttl)
24  if (m === undefined) return undefined
25  const rebuild = m.tiles.find(t => t.id === 'rebuild')!.value
26  const { zone, remainingMs } = m.clock
27  const tips = m.hints.length
28  const share = s.window > 0 ? s.tokens / s.window : 0
29
30  const head = zone === 'unknown' ? '○ cache ?' : zone === 'cold' ? '○ cold' : `● warm ${short(remainingMs)}`
31  const color = zone === 'cold' || zone === 'window' ? 'warning' : zone === 'warm' ? 'success' : undefined
32  const parts = [
33    fields.cache ? head : '',
34    fields.rebuild ? (zone === 'cold' ? `next msg re-writes ${rebuild}` : zone === 'unknown' ? '' : `${rebuild} if cold`) : '',
35    fields.tips && tips > 0 ? `▲ ${tips} tip${tips === 1 ? '' : 's'}` : '',
36    fields.ctx && share >= CTX_WARN ? `ctx ${Math.round(share * 100)}%` : '',
37  ].filter(p => p !== '')
38  if (parts.length === 0) return undefined
39
40  let line = parts
41  while (line.length > 1 && line.join(' · ').length > width) line = line.slice(0, -1)
42  return { text: line.join(' · '), color: share >= CTX_WARN ? 'error' : color }
43}
44
hooks/hints.ts 143 lines
1import type { PanelState } from '../types'
2import { cheaperModel, kTokens, priceOf, usd, writePrice } from './pricing'
3import type { Price } from './pricing'
4
5export type Level = 'critical' | 'warning' | 'info'
6
7export type Hint = {
8  id: string
9  level: Level
10  title: string
11  detail: string
12  /** Estimated USD at stake, used to rank the hints. */
13  stake: number
14  action?: 'compact'
15}
16
17export type CacheState = { isWarm: boolean; remainingMs: number }
18
19// A handoff summary is a few thousand output tokens.
20const SUMMARY_TOKENS = 3000
21// How many more turns a session is assumed to run, for "per turn" stakes.
22const HORIZON = 10
23
24/** How long "Snooze" hides a hint. */
25export const SNOOZE_MS = 10 * 60_000
26
27/** What a handoff compaction costs now: the summarizer reads the context from cache and writes a short summary. */
28export const compactCost = (tokens: number, p: Price) => (tokens * p.read) / 1e6 + (SUMMARY_TOKENS * p.output) / 1e6
29
30export const cacheState = (s: PanelState, now: number, ttlMs: number): CacheState | undefined => {
31  if (s.lastActivityAt === undefined) return undefined
32  const remainingMs = s.lastActivityAt + ttlMs - now
33  return { isWarm: remainingMs > 0, remainingMs: Math.max(0, remainingMs) }
34}
35
36export const hitRatio = (s: PanelState) => {
37  const read = s.turns.reduce((n, t) => n + t.readTokens, 0)
38  const write = s.turns.reduce((n, t) => n + t.writeTokens, 0)
39  return read + write === 0 ? undefined : read / (read + write)
40}
41
42export const minutes = (ms: number) => (ms < 60_000 ? '<1 min' : `${Math.floor(ms / 60_000)} min`)
43
44/** Every hint that applies now, most money at stake first, dismissed ones left out. */
45export const hints = (s: PanelState, now: number, ttl: string): Hint[] => {
46  const p = priceOf(s.model)
47  if (p === undefined || s.tokens === 0) return []
48  const ttlMs = ttl === '5m' ? 5 * 60_000 : 60 * 60_000
49  const ctx = kTokens(s.tokens)
50  const carry = (s.tokens * p.read) / 1e6
51  const rebuild = (s.tokens * writePrice(p, ttl)) / 1e6
52  const compactNow = compactCost(s.tokens, p)
53  const cache = cacheState(s, now, ttlMs)
54  const out: Hint[] = []
55
56  if (s.boundary !== undefined && s.tokens >= 50_000) {
57    const turns = Math.max(1, Math.ceil(compactNow / carry))
58    out.push({
59      id: 'boundary',
60      level: 'warning',
61      title: `Task finished (${s.boundary}): compact while the cache is warm`,
62      detail: `Carrying ${ctx} costs ${usd(carry)} per turn. A handoff compaction costs about ${usd(compactNow)} now and pays for itself in ${turns} turn${turns === 1 ? '' : 's'}.`,
63      stake: carry * HORIZON,
64      action: 'compact',
65    })
66  }
67
68  if (cache?.isWarm && cache.remainingMs < 10 * 60_000 && s.tokens >= 100_000) {
69    out.push({
70      id: 'expiring',
71      level: 'critical',
72      title: `Cache expires in ${minutes(cache.remainingMs)}`,
73      detail: `After that, your next message re-writes ${ctx} (${usd(rebuild)}). Reply now, compact (about ${usd(compactNow)}), or /clear before you step away.`,
74      stake: rebuild,
75      action: 'compact',
76    })
77  }
78
79  if (cache !== undefined && !cache.isWarm && s.tokens >= 100_000) {
80    out.push({
81      id: 'cold',
82      level: 'warning',
83      title: 'Cache is cold',
84      detail: `Your next message re-writes ${ctx} (${usd(rebuild)}). Starting something new? /clear and restate the task instead.`,
85      stake: rebuild,
86    })
87  }
88
89  const [prev, last] = s.turns.slice(-2)
90  if (last !== undefined && last.ctx >= 50_000 && last.writeTokens >= 0.6 * last.ctx) {
91    const wasIdle = prev === undefined || last.at - prev.at >= ttlMs
92    const switched = prev !== undefined && prev.model !== last.model
93    if (!wasIdle) {
94      out.push({
95        id: `rebuild-${last.at}`,
96        level: 'warning',
97        title: `Unexpected cache rebuild: ${usd(last.write)}`,
98        detail: switched
99          ? `The model changed (${prev?.model} → ${last.model}), and each model has its own cache. Pick the model at the start of a session.`
100          : 'The cache was warm, but the prefix was re-written. Usual causes: an effort or fast-mode change, or an MCP server or plugin change mid-session.',
101        stake: last.write,
102      })
103    }
104  }
105
106  if (s.tokens >= 300_000) {
107    out.push({
108      id: 'large',
109      level: 'warning',
110      title: `Large context: ${ctx} is re-read on every turn`,
111      detail: `That's ${usd(carry)} per turn before Claude writes anything. /compact at the next break, or /clear between tasks.`,
112      stake: carry * HORIZON,
113      action: 'compact',
114    })
115  }
116
117  const cheaper = cheaperModel(s.model)
118  const cheaperPrice = cheaper === undefined ? undefined : priceOf(cheaper)
119  if (cheaper !== undefined && cheaperPrice !== undefined && cheaperPrice.read < p.read) {
120    const saved = Math.round((1 - cheaperPrice.read / p.read) * 100)
121    out.push({
122      id: 'model',
123      level: 'info',
124      title: `${cheaper} reads the cache ${saved}% cheaper`,
125      detail: `Start your next session on ${cheaper}. Switching mid-session re-writes ${ctx} (${usd(rebuild)}), so switch at a /clear, not now.`,
126      stake: ((s.tokens * (p.read - cheaperPrice.read)) / 1e6) * HORIZON,
127    })
128  }
129
130  if (s.bigOutput !== undefined && s.bigOutput.tokens >= 8_000) {
131    const perTurn = (s.bigOutput.tokens * p.read) / 1e6
132    out.push({
133      id: 'output',
134      level: 'info',
135      title: `${s.bigOutput.tool} output added about ${kTokens(s.bigOutput.tokens)} tokens`,
136      detail: `It stays in context and is re-read on every turn (${usd(perTurn)} per turn). Pipe long output through tail or grep, or ask for a summary.`,
137      stake: perTurn * HORIZON,
138    })
139  }
140
141  return out.filter(h => !s.dismissed.includes(h.id) && (s.snoozed?.[h.id] ?? 0) <= now).sort((a, b) => b.stake - a.stake)
142}
143
hooks/judge.ts 146 lines
1import type { SessionMessage } from 'claude-code'
2
3import type { Digest, Rating } from '../types'
4
5export const SYSTEM = `You label moments in a developer's session with an AI coding agent.
6You see what the session has been about and the developer's NEW prompt.
7Decide whether this is a NATURAL BREAK: the previous piece of work is finished, and the new
8prompt could be handled well if the earlier conversation were replaced by a short handoff
9summary (what was done, decisions, open items, file paths). Answer false when the new prompt
10depends on exact details from the recent conversation (error output, a specific line just
11discussed, an answer it is replying to, an in-progress debugging thread).
12Judge only from what is shown. Reply with one JSON object only:
13{"break": true|false, "confidence": <0.0-1.0>}`
14
15export type Judge = 'sonnet' | 'haiku'
16
17export const JUDGES: Record<Judge, { model: string; priceAs: string; confidence: number }> = {
18  // Sonnet starts at confidence 0.5; Haiku at 0.8.
19  sonnet: { model: 'sonnet', priceAs: 'claude-sonnet-5-5', confidence: 0.5 },
20  haiku: { model: 'haiku', priceAs: 'claude-haiku-5-5', confidence: 0.8 },
21}
22
23// Learning: each 👎 in the last RECENT ratings raises the bar, each 👍 takes some of that back.
24const RECENT = 20
25const BAD_RAISES = 0.05
26const GOOD_LOWERS = 0.02
27const CEILING = 0.9
28const EXAMPLES_EACH = 3
29
30const TAG = /<(ide_[a-z_]+|system-reminder)>[\s\S]*?<\/\1>/g
31const SUMMARY = /^This session is being continued from a previous conversation/
32const EDITS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
33
34const clean = (text: string) => text.replace(TAG, '').trim()
35
36/** The digest of the session at a new prompt, or undefined before the session's second prompt. */
37export const digest = (messages: readonly SessionMessage[], newPrompt: string): Digest | undefined => {
38  const prompts: string[] = []
39  let taskStart: string | undefined
40  let files: string[] = []
41  let lastAnswer = ''
42  for (const m of messages) {
43    if (m.role === 'assistant') {
44      for (const t of m.toolUses) {
45        const path = t.input.file_path ?? t.input.notebook_path ?? t.input.path
46        if (EDITS.has(t.tool) && typeof path === 'string') files.push(path.split('/').at(-1) ?? path)
47      }
48      if (m.text.trim() !== '') lastAnswer = m.text.trim()
49      continue
50    }
51    if (m.toolResults?.length) continue
52    const text = clean(m.text)
53    // Command rows and notices are tagged; a compaction's summary starts the task over.
54    if (text === '' || text.startsWith('<')) continue
55    if (SUMMARY.test(text)) {
56      taskStart = undefined
57      continue
58    }
59    prompts.push(text)
60    taskStart ??= text
61    files = []
62    lastAnswer = ''
63  }
64  if (prompts.length === 0) return undefined
65  return {
66    taskStart: (taskStart ?? prompts[0]!).slice(0, 400),
67    previous: prompts.slice(-2).map(p => p.slice(0, 300)),
68    files: [...new Set(files)].slice(-8),
69    lastAnswer: lastAnswer.slice(0, 300),
70    newPrompt: clean(newPrompt).slice(0, 500),
71  }
72}
73
74// Words for work stopped on purpose: a paused migration, a rollout waiting on someone.
75const HOLD = /\b(paus(e|ed|es|ing)|on hold|hold off|halt(ed)?|blocked (on|by)|waiting (for|on)|wait (for|until)|(don'?t|do not) (continue|proceed|resume|run)|resume (once|when|after|later)|stopp?(ed)? (here|before|partway|halfway))\b/i
76const HOLD_PROMPTS = 3
77
78/**
79 * Whether the last few prompts, or the answers since, say work is paused or blocked. A
80 * summary could drop why it stopped, so a compaction there is the person's call.
81 */
82export const isOnHold = (messages: readonly SessionMessage[]) => {
83  let prompts = 0
84  for (let i = messages.length - 1; i >= 0 && prompts < HOLD_PROMPTS; i--) {
85    const m = messages[i]!
86    if (m.role === 'user' && m.toolResults?.length) continue
87    const text = clean(m.text)
88    if (m.role === 'user') {
89      if (text === '' || text.startsWith('<')) continue
90      if (SUMMARY.test(text)) break
91      prompts++
92    }
93    if (HOLD.test(text)) return true
94  }
95  return false
96}
97
98const itemText = (d: Digest) =>
99  [
100    `Session started with: ${d.taskStart}`,
101    'Recent prompts:',
102    ...d.previous.map(p => `- ${p}`),
103    `Files edited since the last prompt: ${d.files.join(', ') || 'none'}`,
104    `Agent's last answer began: ${d.lastAnswer}`,
105    `NEW prompt: ${d.newPrompt}`,
106  ].join('\n')
107
108/** The newest ratings of each kind, oldest first: what the judge learns this developer's breaks from. */
109export const examples = (ratings: readonly Rating[]) => {
110  const newest = [...ratings].sort((a, b) => b.at - a.at)
111  const pick = (isGood: boolean) => newest.filter(r => r.isGood === isGood).slice(0, EXAMPLES_EACH)
112  return [...pick(true), ...pick(false)].sort((a, b) => a.at - b.at)
113}
114
115export const prompt = (d: Digest, rated: readonly Rating[]) => {
116  const past = rated.map(r =>
117    `<rated verdict="${r.isGood ? 'right: a natural break' : 'wrong: the work was still going'}">\n${itemText(r.digest)}\n</rated>`,
118  )
119  const lead = past.length === 0
120    ? []
121    : ['This developer rated earlier moments where a compaction ran. Learn from them where their work really ends:', ...past, '']
122  return [...lead, `<item>\n${itemText(d)}\n</item>`].join('\n')
123}
124
125/** The judge's reply as a verdict, or undefined when it is not the JSON asked for. */
126export const verdict = (text: string): { isBreak: boolean; confidence: number } | undefined => {
127  const json = /\{[\s\S]*?\}/.exec(text)?.[0]
128  if (json === undefined) return undefined
129  try {
130    const v = JSON.parse(json) as { break?: unknown; confidence?: unknown }
131    if (typeof v.break !== 'boolean' || typeof v.confidence !== 'number') return undefined
132    return { isBreak: v.break, confidence: v.confidence }
133  } catch {
134    return undefined
135  }
136}
137
138/** The confidence a break needs, from where the judge starts and how its compactions were rated. */
139export const threshold = (start: number, ratings: readonly Rating[]) => {
140  const recent = [...ratings].sort((a, b) => b.at - a.at).slice(0, RECENT)
141  const bad = recent.filter(r => !r.isGood).length
142  const good = recent.length - bad
143  const t = Math.min(CEILING, start + Math.max(0, BAD_RAISES * bad - GOOD_LOWERS * good))
144  return Math.round(t * 100) / 100
145}
146
hooks/model.ts 72 lines
1import type { PanelState, TurnRow } from '../types'
2import { cacheState, compactCost, hints, hitRatio } from './hints'
3import type { Hint } from './hints'
4import { priceOf, usd, writePrice } from './pricing'
5
6export const ttlMsOf = (ttl: string) => (ttl === '5m' ? 5 * 60_000 : 60 * 60_000)
7
8/** The last stretch of the cache's life, when the plugin compacts if no prompt came: 5 minutes, or a fifth of the 5-minute TTL. */
9export const windowMsOf = (ttl: string) => Math.min(5 * 60_000, ttlMsOf(ttl) / 5)
10
11export type Tile = { id: 'session' | 'hit' | 'carry' | 'rebuild'; label: string; value: string; series: number[] }
12export type Zone = 'unknown' | 'warm' | 'window' | 'cold'
13export type Clock = {
14  zone: Zone
15  remainingMs: number
16  ttlMs: number
17  windowMs: number
18  /** Set while the pre-expiry compaction is armed. */
19  compactInMs?: number
20  /** What that compaction saves against the cache rebuild, in USD. */
21  saves?: number
22}
23export type PanelModel = { tiles: Tile[]; clock: Clock; hints: Hint[] }
24
25const cost = (t: TurnRow) => t.read + t.write + t.output + t.input
26
27const running = (turns: readonly TurnRow[]) => {
28  let sum = 0
29  return turns.map(t => (sum += cost(t)))
30}
31
32const hitOf = (t: TurnRow) => t.readTokens / (t.readTokens + t.writeTokens)
33
34/** What the panel shows, or undefined while there is nothing priced to show. */
35export const panelModel = (s: PanelState, now: number, ttl: string): PanelModel | undefined => {
36  const p = priceOf(s.model)
37  if (p === undefined || s.tokens === 0) return undefined
38  const ttlMs = ttlMsOf(ttl)
39  const windowMs = windowMsOf(ttl)
40  const carry = (s.tokens * p.read) / 1e6
41  const rebuild = (s.tokens * writePrice(p, ttl)) / 1e6
42  const ratio = hitRatio(s)
43  const priceFor = (t: TurnRow) => priceOf(t.model) ?? p
44  const cache = cacheState(s, now, ttlMs)
45  const zone: Zone = cache === undefined ? 'unknown' : !cache.isWarm ? 'cold' : cache.remainingMs <= windowMs ? 'window' : 'warm'
46  return {
47    tiles: [
48      { id: 'session', label: 'Session cost', value: usd(s.sessionUsd), series: running(s.turns) },
49      { id: 'hit', label: 'Cache hit', value: ratio === undefined ? '–' : `${(ratio * 100).toFixed(1)}%`, series: s.turns.filter(t => t.readTokens + t.writeTokens > 0).map(hitOf) },
50      { id: 'carry', label: 'Re-read per turn', value: usd(carry), series: s.turns.map(t => (t.ctx * priceFor(t).read) / 1e6) },
51      { id: 'rebuild', label: 'Rebuild if cold', value: usd(rebuild), series: s.turns.map(t => (t.ctx * writePrice(priceFor(t), ttl)) / 1e6) },
52    ],
53    clock: {
54      zone,
55      remainingMs: cache?.remainingMs ?? 0,
56      ttlMs,
57      windowMs,
58      ...(s.compactAt === undefined
59        ? {}
60        : { compactInMs: Math.max(0, s.compactAt - now), saves: Math.max(0, rebuild - compactCost(s.tokens, p)) }),
61    },
62    hints: hints(s, now, ttl).slice(0, 4),
63  }
64}
65
66/** The bar left to right: the compact window, the rest of the warm time, then what has drained. Shares of the TTL. */
67export const clockShares = (c: Clock) => {
68  const filled = Math.min(1, c.remainingMs / c.ttlMs)
69  const window = Math.min(filled, c.windowMs / c.ttlMs)
70  return { window, warm: filled - window, track: 1 - filled }
71}
72
hooks/panel-view.tsx 116 lines
1import type { ElementTable } from 'claude-code'
2
3import { blocks } from './chart'
4import { minutes } from './hints'
5import type { Hint } from './hints'
6import { clockShares } from './model'
7import type { Clock, Tile } from './model'
8import { usd } from './pricing'
9import { clockAlt, clockSvg, sparkAlt, sparkSvg } from './svg'
10
11export const LEVEL = {
12  critical: { icon: '▲', color: 'error', word: 'Act now' },
13  warning: { icon: '◆', color: 'warning', word: 'Worth it' },
14  info: { icon: '●', color: 'suggestion', word: 'Tip' },
15} as const
16
17/** Bordered stat tiles that wrap with the pane's width; a sparkline under each value once there are two turns. */
18export const tilesView = (els: ElementTable, tiles: readonly Tile[], Svg: ElementTable['Svg'] | undefined) => {
19  const { Box, Text } = els
20  const spark = (t: Tile) =>
21    t.series.length < 2 ? (
22      <Text dimColor>–</Text>
23    ) : Svg !== undefined ? (
24      <Svg source={sparkSvg(t.series)} alt={sparkAlt(t)} />
25    ) : (
26      <Text color="claude">{blocks(t.series, 14)}</Text>
27    )
28  return (
29    <Box flexDirection="row" flexWrap="wrap" gap={1} marginTop={1}>
30      {tiles.map(t => (
31        <Box key={`tile-${t.id}`} flexDirection="column" borderStyle="round" paddingX={1}>
32          <Text dimColor>{t.label}</Text>
33          <Text bold>{t.value}</Text>
34          {spark(t)}
35        </Box>
36      ))}
37    </Box>
38  )
39}
40
41export type ClockText = { tokens: string; rebuild: string; auto: boolean }
42
43/** The cache clock: state line, the draining bar by zone, a legend, and the armed idle compaction when there is one. */
44export const clockView = (els: ElementTable, c: Clock, t: ClockText, cols: number, Svg: ElementTable['Svg'] | undefined) => {
45  const { Box, Text } = els
46  const sh = clockShares(c)
47  const cells = Math.max(10, Math.min(40, cols - 24))
48  const win = Math.round(sh.window * cells)
49  const warm = Math.round((sh.window + sh.warm) * cells) - win
50  const status =
51    c.zone === 'unknown' ? (
52      <Text dimColor>○ Cache state unknown until the next reply</Text>
53    ) : c.zone === 'cold' ? (
54      <Text>
55        <Text color="warning">○ Cold</Text>
56        <Text>  the next message re-writes {t.tokens}</Text>
57        <Text dimColor>  · {t.rebuild}</Text>
58      </Text>
59    ) : (
60      <Text>
61        <Text color={c.zone === 'window' ? 'warning' : 'success'}>● Warm</Text>
62        <Text>  expires in {minutes(c.remainingMs)}</Text>
63        <Text dimColor>  · rebuilding later costs {t.rebuild}</Text>
64      </Text>
65    )
66  const next =
67    c.compactInMs === undefined
68      ? undefined
69      : `Next: auto-compact in ${minutes(c.compactInMs)}${c.saves !== undefined && c.saves > 0 ? ` (saves ≈ ${usd(c.saves)})` : ''}`
70  return (
71    <Box flexDirection="column" marginTop={1}>
72      {status}
73      {c.zone !== 'unknown' &&
74        (Svg !== undefined ? (
75          <Svg source={clockSvg(c)} alt={clockAlt(c)} height={36} isInteractive />
76        ) : (
77          <Text>
78            <Text color="warning">{'█'.repeat(win)}</Text>
79            <Text color="success">{'█'.repeat(warm)}</Text>
80            <Text dimColor>{'░'.repeat(cells - win - warm)}</Text>
81          </Text>
82        ))}
83      <Text dimColor>
84        <Text color="success">█</Text> warm   <Text color="warning">█</Text> last {minutes(c.windowMs)} before expiry{t.auto ? ': auto-compact' : ''}   <Text dimColor>░</Text> cold
85      </Text>
86      {next !== undefined && <Text>{next}</Text>}
87    </Box>
88  )
89}
90
91export type HintActions = { compact: () => void; snooze: () => void; dismiss: () => void }
92
93/** One hint with its three actions. Only the top hint takes the hotkeys 1, 2, 3: two buttons on a hotkey, the later wins. */
94export const hintView = (els: ElementTable, hint: Hint, act: HintActions, hasKeys: boolean) => {
95  const { Box, Text, Button } = els
96  const lv = LEVEL[hint.level]
97  const key = (digit: string) => (hasKeys ? { hotkey: digit } : {})
98  return (
99    <Box key={`hint-${hint.id}`} flexDirection="column" marginTop={1}>
100      <Text>
101        <Text color={lv.color}>{lv.icon} {lv.word}</Text>
102        <Text bold>  {hint.title}</Text>
103        <Text dimColor>  ~{usd(hint.stake)} at stake</Text>
104      </Text>
105      <Text dimColor wrap="wrap">  {hint.detail}</Text>
106      <Box flexDirection="row" marginLeft={2} gap={1}>
107        {hint.action === 'compact' && (
108          <Button key={`compact-${hint.id}`} variant="primary" label="Compact now" {...key('1')} onPress={act.compact} />
109        )}
110        <Button key={`snooze-${hint.id}`} label="Snooze 10 min" {...key('2')} onPress={act.snooze} />
111        <Button key={`dismiss-${hint.id}`} label="Not this session" {...key('3')} onPress={act.dismiss} />
112      </Box>
113    </Box>
114  )
115}
116
hooks/pricing.ts 45 lines
1// List prices, USD per million tokens (platform.claude.com/docs/en/about-claude/pricing).
2// Matched in order, so the 5.5 rows sit before the family fallbacks.
3export type Price = { input: number; write5m: number; write1h: number; read: number; output: number }
4
5const PRICES: readonly (readonly [RegExp, Price])[] = [
6  [/opus-5-5/, { input: 4, write5m: 5, write1h: 8, read: 0.2, output: 20 }],
7  [/opus/, { input: 5, write5m: 6.25, write1h: 10, read: 0.5, output: 25 }],
8  [/sonnet-5-5/, { input: 2, write5m: 2.5, write1h: 4, read: 0.1, output: 10 }],
9  [/sonnet/, { input: 2, write5m: 2.5, write1h: 4, read: 0.2, output: 10 }],
10  [/haiku/, { input: 0.1, write5m: 0.125, write1h: 0.2, read: 0.01, output: 0.5 }],
11  [/fable/, { input: 10, write5m: 12.5, write1h: 20, read: 0.25, output: 50 }],
12]
13
14// The model whose cache reads cost less, for the "move to 5.5" hint.
15const CHEAPER: readonly (readonly [RegExp, string])[] = [
16  [/opus-5(?!-5)|opus-4/, 'claude-opus-5-5'],
17  [/sonnet-5(?!-5)|sonnet-4/, 'claude-sonnet-5-5'],
18]
19
20export const priceOf = (model: string): Price | undefined =>
21  PRICES.find(([re]) => re.test(model))?.[1]
22
23export const cheaperModel = (model: string): string | undefined =>
24  CHEAPER.find(([re]) => re.test(model))?.[1]
25
26export const writePrice = (p: Price, ttl: string) => (ttl === '5m' ? p.write5m : p.write1h)
27
28export type Split = { read: number; write: number; output: number; input: number }
29
30/** One turn's cost in USD, split the way the chart stacks it. */
31export const costSplit = (
32  usage: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number },
33  p: Price,
34  ttl: string,
35): Split => ({
36  read: (usage.cache_read_input_tokens * p.read) / 1e6,
37  write: (usage.cache_creation_input_tokens * writePrice(p, ttl)) / 1e6,
38  output: (usage.output_tokens * p.output) / 1e6,
39  input: (usage.input_tokens * p.input) / 1e6,
40})
41
42export const usd = (n: number) => (n >= 10 ? `$${n.toFixed(1)}` : n >= 0.1 ? `$${n.toFixed(2)}` : `$${n.toFixed(3)}`)
43
44export const kTokens = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : `${Math.round(n / 1000)}K`)
45
hooks/report.ts 56 lines
1import type { PanelState } from '../types'
2import { sparkline } from './chart'
3import { cacheState, hints, hitRatio, minutes } from './hints'
4import { kTokens, priceOf, usd, writePrice } from './pricing'
5import { level, lifetime, net, signed } from './savings'
6
7const ICON = { critical: '▲', warning: '◆', info: '●' } as const
8
9/**
10 * The panel as plain text, for surfaces that draw no plugin pane:
11 * `/cost-panel` answers with it, so it shows in the transcript. The model reads it too, so it
12 * stays short.
13 */
14export const report = (s: PanelState, now: number, ttl: string, compactCommand: string) => {
15  const p = priceOf(s.model)
16  if (p === undefined || s.tokens === 0) return 'Cost & cache: send a message first; the report fills in after the first reply.'
17
18  const cache = cacheState(s, now, ttl === '5m' ? 5 * 60_000 : 60 * 60_000)
19  const rebuild = usd((s.tokens * writePrice(p, ttl)) / 1e6)
20  const ratio = hitRatio(s)
21  const share = s.window > 0 ? Math.round((s.tokens / s.window) * 100) : 0
22  const recent = s.turns.slice(-20)
23  const peak = recent.reduce<(typeof recent)[number] | undefined>(
24    (a, t) => (a === undefined || t.read + t.write + t.output + t.input > a.read + a.write + a.output + a.input ? t : a),
25    undefined,
26  )
27
28  const lines = [
29    `Cost & cache · ${s.model} · ${ttl} cache`,
30    cache === undefined
31      ? '○ Cache state unknown until the next reply'
32      : cache.isWarm
33        ? `● Warm, expires in ${minutes(cache.remainingMs)} (rebuilding later costs ${rebuild})`
34        : `○ Cold: the next message re-writes ${kTokens(s.tokens)} (${rebuild})`,
35    `Context ${kTokens(s.tokens)} / ${kTokens(s.window)} (${share}%) · session ${usd(s.sessionUsd)} · cache hit ${ratio === undefined ? '–' : `${(ratio * 100).toFixed(1)}%`} · re-read ${usd((s.tokens * p.read) / 1e6)}/turn`,
36  ]
37  if (recent.length > 1 && peak !== undefined) {
38    const peakUsd = peak.read + peak.write + peak.output + peak.input
39    const what = peak.write > peakUsd / 2 ? ' cache rebuild' : ''
40    lines.push(`Last ${recent.length} turns: ${sparkline(recent, 20)}  (peak ${usd(peakUsd)}${what})`)
41  }
42
43  const v = s.saved
44  const lv = level(lifetime(v))
45  lines.push(
46    `Saved by compacting: ${v.compactions === 0 ? 'nothing yet this session' : `${signed(net(v))} this session (${v.compactions} compaction${v.compactions === 1 ? '' : 's'}, ${kTokens(v.removed)} less to re-read per turn)`} · ★ Level ${lv.rank} ${lv.name}, all time ${signed(lifetime(v))}`,
47  )
48
49  const list = hints(s, now, ttl).slice(0, 3)
50  if (list.length === 0) lines.push('✓ Nothing to act on: the cache is doing its job.')
51  for (const h of list) {
52    lines.push(`${ICON[h.level]} ${h.title} (~${usd(h.stake)} at stake). ${h.detail}${h.action === 'compact' ? ` → /${compactCommand}` : ''}`)
53  }
54  return lines.join('\n')
55}
56
hooks/settings.ts 45 lines
1import type { Prefs } from '../types'
2
3export const VIEWS = [
4  { id: 'tiles', label: 'Stat tiles' },
5  { id: 'clock', label: 'Cache clock' },
6  { id: 'context', label: 'Context bar' },
7  { id: 'hints', label: 'Hints' },
8  { id: 'saved', label: 'Saved by compacting' },
9  { id: 'chart', label: 'Cost per turn' },
10] as const
11
12export const FIELDS = [
13  { id: 'cache', label: 'Cache state' },
14  { id: 'rebuild', label: 'Rebuild cost' },
15  { id: 'tips', label: 'Tip count' },
16  { id: 'ctx', label: 'Context % (from 80%)' },
17] as const
18
19export type ViewId = (typeof VIEWS)[number]['id']
20export type FieldId = (typeof FIELDS)[number]['id']
21
22/** `standard` is what the footer showed before it was configurable. */
23export const PRESETS: Record<string, Record<FieldId, boolean>> = {
24  minimal: { cache: true, rebuild: false, tips: false, ctx: false },
25  cost: { cache: true, rebuild: true, tips: false, ctx: false },
26  standard: { cache: true, rebuild: true, tips: true, ctx: true },
27}
28
29export const DEFAULT_PREFS: Prefs = {
30  views: { tiles: true, clock: true, context: true, hints: true, saved: true, chart: true },
31  fields: PRESETS.standard!,
32}
33
34/** The preset whose fields match exactly, else `custom`. */
35export const presetOf = (fields: Prefs['fields']): string =>
36  Object.keys(PRESETS).find(name => FIELDS.every(f => PRESETS[name]![f.id] === fields[f.id])) ?? 'custom'
37
38/** A stored value from an older or hand-edited store: keep what is a boolean, default the rest. */
39export const normalize = (raw: unknown): Prefs => {
40  const r = (raw ?? {}) as Partial<Record<'views' | 'fields', Record<string, unknown>>>
41  const pick = <K extends string>(ids: readonly { id: K }[], from: Record<string, unknown> | undefined, base: Record<K, boolean>) =>
42    Object.fromEntries(ids.map(({ id }) => [id, typeof from?.[id] === 'boolean' ? from[id] : base[id]])) as Record<K, boolean>
43  return { views: pick(VIEWS, r.views, DEFAULT_PREFS.views), fields: pick(FIELDS, r.fields, DEFAULT_PREFS.fields) }
44}
45
hooks/settings-view.tsx 57 lines
1import type { ElementTable } from 'claude-code'
2
3import type { Prefs } from '../types'
4import { FIELDS, PRESETS, presetOf, VIEWS } from './settings'
5import type { FieldId, ViewId } from './settings'
6
7export type SettingsActions = {
8  toggleView: (id: ViewId) => void
9  toggleField: (id: FieldId) => void
10  setPreset: (name: string) => void
11  close: () => void
12}
13
14const box = (on: boolean) => (on ? '[x]' : '[ ]')
15
16/** The panel's settings page: which sections to draw, and which fields the footer line carries. */
17export const settingsView = (els: ElementTable, p: Prefs, footer: string | undefined, act: SettingsActions) => {
18  const { Box, Text, Button } = els
19  const preset = presetOf(p.fields)
20  return (
21    <Box flexDirection="column" paddingX={1}>
22      <Box flexDirection="row" justifyContent="space-between">
23        <Text bold>Cost & cache settings</Text>
24        <Button key="settings-close" label="← Back" onPress={act.close} />
25      </Box>
26
27      <Box flexDirection="column" marginTop={1}>
28        <Text bold>Panel sections</Text>
29        <Box flexDirection="row" flexWrap="wrap" gap={1}>
30          {VIEWS.map(v => (
31            <Button key={`view-${v.id}`} label={`${box(p.views[v.id])} ${v.label}`} onPress={() => act.toggleView(v.id)} />
32          ))}
33        </Box>
34      </Box>
35
36      <Box flexDirection="column" marginTop={1}>
37        <Text bold>Footer line</Text>
38        <Box flexDirection="row" flexWrap="wrap" gap={1}>
39          {[...Object.keys(PRESETS), ...(preset === 'custom' ? ['custom'] : [])].map(name => (
40            <Button
41              key={`preset-${name}`}
42              label={`${name === preset ? '(•)' : '( )'} ${name}`}
43              onPress={() => name in PRESETS && act.setPreset(name)}
44            />
45          ))}
46        </Box>
47        <Box flexDirection="row" flexWrap="wrap" gap={1}>
48          {FIELDS.map(f => (
49            <Button key={`field-${f.id}`} label={`${box(p.fields[f.id])} ${f.label}`} onPress={() => act.toggleField(f.id)} />
50          ))}
51        </Box>
52        <Text dimColor>Now: {footer ?? '(nothing to show: the footer line is hidden)'}</Text>
53      </Box>
54    </Box>
55  )
56}
57
hooks/savings.ts 84 lines
1import type { Savings, TurnRow } from '../types'
2import type { Price } from './pricing'
3import { usd, writePrice } from './pricing'
4
5// A handoff summary is a few thousand output tokens, when the compaction reports no usage.
6const SUMMARY_TOKENS = 3000
7
8export const NO_SAVINGS: Savings = {
9  gross: 0,
10  spent: 0,
11  removed: 0,
12  compactions: 0,
13  isFresh: false,
14  pastUsd: 0,
15  pastCompactions: 0,
16}
17
18export const LEVELS = [
19  { usd: 0, name: 'Cache Rookie' },
20  { usd: 1, name: 'Context Trimmer' },
21  { usd: 5, name: 'Token Saver' },
22  { usd: 25, name: 'Cache Keeper' },
23  { usd: 100, name: 'Context Master' },
24  { usd: 500, name: 'Compaction Legend' },
25] as const
26
27export const net = (v: Savings) => v.gross - v.spent
28export const lifetime = (v: Savings) => v.pastUsd + net(v)
29
30/** The level a lifetime total has reached, and the next one up. */
31export const level = (total: number) => {
32  const i = LEVELS.findLastIndex(l => total >= l.usd)
33  const at = Math.max(0, i)
34  return { rank: at + 1, name: LEVELS[at]!.name, at: LEVELS[at]!.usd, next: LEVELS[at + 1] }
35}
36
37/** USD with a sign: a compaction costs before it pays back. */
38export const signed = (n: number) => (n < 0 ? `−${usd(-n)}` : usd(n))
39
40/**
41 * One compaction: the tokens it took out, and its cost. That is the summarizer's request,
42 * plus writing the new context to the cache on the next turn, where it would have been read.
43 * After the cache expired the old context would have been re-written too: there the next
44 * turn's write costs nothing extra, and the rebuild it avoids is credited by `carried`.
45 */
46export const compacted = (
47  v: Savings,
48  r: { tokensBefore: number; tokensAfter: number; usage?: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number } },
49  p: Price,
50  ttl: string,
51  isCold = false,
52): Savings => {
53  const summarizer = r.usage
54    ? (r.usage.input_tokens * p.input +
55        r.usage.output_tokens * p.output +
56        r.usage.cache_read_input_tokens * p.read +
57        r.usage.cache_creation_input_tokens * writePrice(p, ttl)) / 1e6
58    : // Cold, the summarizer reads the context uncached: priced as a cache write, the dearer case.
59      (r.tokensBefore * (isCold ? writePrice(p, ttl) : p.read) + SUMMARY_TOKENS * p.output) / 1e6
60  const rewrite = isCold ? 0 : (r.tokensAfter * (writePrice(p, ttl) - p.read)) / 1e6
61  return {
62    ...v,
63    spent: v.spent + summarizer + rewrite,
64    removed: v.removed + Math.max(0, r.tokensBefore - r.tokensAfter),
65    compactions: v.compactions + 1,
66    isFresh: !isCold,
67  }
68}
69
70/**
71 * One turn since a compaction: what it would also have carried, priced as a cache read, or
72 * as a write when the turn rebuilt a cold cache. Never more than the window could hold.
73 */
74export const carried = (v: Savings, turn: TurnRow, window: number, p: Price, ttl: string): Savings => {
75  if (v.removed === 0) return v
76  const extra = window > 0 ? Math.min(v.removed, Math.max(0, window - turn.ctx)) : v.removed
77  const isRebuild = !v.isFresh && turn.ctx > 0 && turn.writeTokens >= 0.6 * turn.ctx
78  return { ...v, gross: v.gross + (extra * (isRebuild ? writePrice(p, ttl) : p.read)) / 1e6, isFresh: false }
79}
80
81/** Turns until a compaction's cost is paid back, at the current saving per turn. */
82export const payback = (v: Savings, perTurn: number) =>
83  net(v) >= 0 || perTurn <= 0 ? 0 : Math.ceil(-net(v) / perTurn)
84