Shows where the session's tokens go and when a fresh session would be cheaper

会話が長くなると、1 回のやり取りで使うトークンが増えていきます。この mod は、今のセッションをあとどのくらい気にせず続けてよいかを入力欄の上に目安として出し、新しいセッションに切り替えたほうが安くなったら知らせます。 切り替えるときは、引き継ぎ文の下書きを入力欄に入れるところまで手伝います。 トークンがどこに使われているか(主スレッドとサブエージェント、種類とモデル、キャッシュの読み書き、期限切れ後の再書き込み、文脈の大きさ)は、パネルで数字として見られます。 その数字はファイルに書き出せるので、会話の中で Claude に読ませて相談できます。
このセッションで最初の応答があった後は、いつも 1 行で出ています。数字は出さず、目安のゲージと、今の段階を一言で出します。
トークン ■■■■■■□□□□ Soon · 区切りで引き継ぐと節約に [引き継ぐ…] [詳しく]
ゲージは、新しいセッションを始めた直後の大きさで空、新しいセッションに引き継いだほうが得になる大きさ(引き継ぎの手間を 5 回以内のやり取りで取り戻せるところ)で満杯です。
| 段階 | いつ | 色 |
|---|---|---|
| OK | ゲージが 6 割未満 | 色なし |
| Soon | ゲージが 6 割以上 | 黄 |
| Switch | ゲージが満杯、または休憩でキャッシュが切れた後 | 赤 |
段階の後ろの短い一言と [引き継ぐ…] は、することがあるときだけ出ます。
OK のときは、ゲージと「OK」と [詳しく] だけです。
トークン ■■□□□□□□□□ OK [詳しく]
[引き継ぐ…] で引き継ぎの下書きを入力欄に入れ、[詳しく] で下のパネルを開きます。何を意味するか、何をすればよいかは、パネルの「状況」に文で出ます。 引き継ぎの途中は「引き継ぎの下書きを入力欄に入れました」と [取り消す]、新しいセッションが始まった後は「引き継ぎ済み」が、同じ行に出ます。引き継ぎの詳しい結果はパネルで見られます。
トークン ■■■■■■■■■■ Switch · 引き継ぎの下書きを入力欄に入れました [取り消す] [詳しく]
会話がまだ新しいセッションの最初の大きさより小さいうちは、ゲージは空のままで、一言も出ません。 幅が狭いときは、短い一言、ゲージの長さ、ゲージ、「トークン」の順に省きます。段階とボタンは残ります。 ほかの mod が帯に出しているもの(usage-band など)は、その下にそのまま残ります。
/usage-ledger か、帯の [詳しく] で開きます。最初は「状況」の画面です。上のボタンで画面を切り替えます。行の [▸] で、その行の詳細を開きます。
| 画面 | 内容 |
|---|---|
| 状況 | 今の段階、それが何を意味するか、何をすればよいかを文で出す。[引き継ぐ…]、詳しい数字と引き継ぎの画面へのボタン、[書き出す] |
| 概要 | 今の文脈、キャッシュの残り、次の 1 回と再書き込みの重み、新しいセッションの損益分岐、合計、トークンの種類別、[書き出す] |
| スレッド | 主スレッドと各サブエージェントの種類、使ったモデル(opus・sonnet・haiku、複数なら haiku/opus のように)、要求数、重み、割合。幅が狭いときは、説明、種類の順に短くし、種類、要求数、重みの順に省く(省いたものは [▸] の詳細に出る)。モデルと割合は残る |
| 種類・モデル | エージェントの種類別、モデル別 |
| 高い要求 | 重みの大きい要求の上位 15 件 |
| 待ちと再書込 | 5 分を超えて間が空いた要求と、キャッシュが切れた後の再書き込み |
| 主のツール | 主スレッドの要求を、その応答が呼んだツールで分けたもの |
| 引き継ぎ | 引き継ぎの設定、[引き継ぐ…]、引き継ぎの結果 |
パネルの「状況」か「概要」の [書き出す] を押すと、このセッションの集計を exportDir(既定 .claude/usage-ledger/)に 2 つのファイルで書き出します。
| ファイル | 中身 |
|---|---|
<YYYYMMDD>-<セッション id の先頭 8 文字>.md | パネルと同じ集計を Markdown の表にしたもの。概要、スレッド、種類とモデル、高い要求、主のツール、待ちと再書込の節に分かれる |
同じ名前の .jsonl | 要求 1 回につき 1 行。時刻、スレッド、エージェントの種類と説明、モデル、トークンの種類別の数、文脈、重み、呼んだツール |
日付は集計開始の日です。同じセッションで押し直すと、同じ 2 つのファイルを上書きします。会話の本文は書きません(数字、モデル名、ツール名、エージェントの種類と説明だけ)。
書き出すと、場所をトーストで知らせ、入力欄に「usage-ledger の集計を <.md の場所> に書き出しました。」という一文を入れます。入力欄に書きかけの文があるときは、その後ろに足します。送る前に「どのサブエージェントがいちばん使っている?」のような質問を書き足して送ってください。
このプラグインには、書き出したファイルの読み方を Claude に教えるスキル usage-ledger-data が入っています。Claude は質問に合う節や行だけを読むので、集計の全体を会話に流し込まずに済みます。書き出しは mod が直接ファイルに書くので、書く時点ではトークンを使いません。
exportDir は git で管理しないフォルダにしておくと、集計がコミットに混ざりません。例えば .gitignore に .claude/usage-ledger/ を足します。
「重み」は、利用制限への響き方の目安です。サブスクの利用制限の計算式は公開されていないので、公開 API の価格比から推計しています。
重みの付いた数字には * を付け、「*重みは推計。サブスクの計算式は非公開」と添えています。
ttlMain と ttlSub で振り分けます新しいセッションに切り替えるときの手順です。mod が自分で送信したり、セッションを開いたりすることはありません。
[usage-ledger handoff <id>] を最初の発言にして新しいセッションを開くよう、Claude に頼む内容です。読んで、送るかどうかを決めてください新しいセッションの最初の文脈の実測は、次からの損益分岐の計算に使います。 下書きを入れた後にやめるときは、帯かパネルの [取り消す] を押します。
| 設定 | 既定 | 内容 |
|---|---|---|
ttlMain | 1h | 主スレッドのキャッシュ書き込みを 5m と 1h のどちらで数えるか |
ttlSub | 5m | サブエージェントのキャッシュ書き込みを 5m と 1h のどちらで数えるか |
freshCtx | 70000 | 新しいセッションの最初の文脈の見込み(実測があればそちらを使う) |
baseCtx | 40000 | どのセッションにも入る部分(システムプロンプトやツールの説明など)の見込み |
handoffDir | .claude/handoffs/ | 引き継ぎ文を書くフォルダ(プロジェクトからの相対パス) |
exportDir | .claude/usage-ledger/ | [書き出す] で集計を書くフォルダ(プロジェクトからの相対パス。絶対パスも使える) |
showBand | true | 入力欄の上の帯(目安のゲージ)を出すかどうか |
設定は /config で変えます。例えば引き継ぎ文の置き場を notes/handoffs/ にするには、次のように入力します。
/config usage-ledger.handoffDir=notes/handoffs/
handoffDir は git で管理しないフォルダにしておくと、引き継ぎ文がコミットに混ざりません。
Claude Code v2.1.286 以降向けです。 インストール方法は、リポジトリの README を見てください。
この mod は以前 token-ledger という名前でした。名前に token を含むパスへのアクセスを権限設定で絞っていると、同梱のスキルや書き出し先が読み書きできなくなるため、改名しました。 token-ledger を入れている場合は、外してから入れ直してください。
/plugin uninstall token-ledger@codingway-claude-mods
/plugin install usage-ledger@codingway-claude-mods
/config で設定を変えていた場合は、usage-ledger.<設定名> で設定し直してください。書き出し先の既定は .claude/usage-ledger/ に変わりました。.gitignore に .claude/token-ledger/ を足していた場合は、.claude/usage-ledger/ も足してください。目印も [usage-ledger handoff <id>] に変わったため、改名前に作った引き継ぎの下書きは記録されません。
hooks/register.js 284 lines1// Shows where the session's tokens go and when a fresh session would be cheaper.
2//
3// register.js the events: each model request (turn.step) into $.state, which subagent is
4// which ($.agent.list, classic.SubagentStart, the Agent tool's result), a
5// finished subagent's requests read back from its transcript, the handoff marker
6// in a new session's first prompt, the timers, /usage-ledger's registration
7// pane.js the pane (one, with views), /usage-ledger, the band above the prompt,
8// [引き継ぐ…] and [書き出す]
9// export.js the two files [書き出す] writes, a .md summary and a .jsonl of the requests (pure)
10// meter.js the gauge line `トークン ■■■□□ OK`, drawn as usage-band draws its meters (pure)
11// aggregate.js the aggregation and the break-even (pure; also used outside the mod)
12// ledger.js options, the gauge's stage and advice, formatting, threads, the handoff's draft
13// and marker (pure)
14// style.js the shared look (pure)
15// press-guard.js runs a pane press again that did not reach its Button (pure)
16//
17// Counting starts when the module first loads in a session ("集計開始"): the main thread's
18// transcript is too large to read back, so only what happens from then on is counted.
19
20import { normalizeStep, contextOf, parseThread } from './aggregate.js'
21import {
22 MAX_TRANSCRIPT_BYTES,
23 HANDOFF_MARK,
24 handoffKey,
25 setConfig,
26 getConfig,
27 setObserved,
28 getObserved,
29 appendRequest,
30 replaceThread,
31 parseJsonl,
32 summarize,
33 assumed,
34} from './ledger.js'
35import { registerPane } from './pane.js'
36import { update } from 'claude-code'
37
38// The state this file writes (declared in types/index.d.ts)
39const REQUESTS = { plugin: 'usage-ledger', key: 'requests' }
40const THREADS = { plugin: 'usage-ledger', key: 'threads' }
41const STARTED_AT = { plugin: 'usage-ledger', key: 'startedAt' }
42const HANDOFF = { plugin: 'usage-ledger', key: 'handoff' }
43const INCOMING = { plugin: 'usage-ledger', key: 'incoming' }
44
45// The $.store key of the context sizes observed in earlier sessions: { fresh, base, at }
46const OBSERVED_KEY = 'observed'
47// How many of a handed-off session's first requests are reported back
48const OBSERVE_FIRST = 3
49// How often a drafted handoff looks in the store for the new session's report
50const WATCH_MS = 60_000
51// The cache's last ten minutes, when the band starts showing while idle
52const WARN_BEFORE_MS = 10 * 60_000
53
54// Subagent ids already looked up in $.agent.list(), so each is looked up once
55const looked = new Set()
56// True when this session had no turn when the module loaded: its first request is the base
57let isFreshSession = false
58// The redraw at the cache's next edge (ten minutes left, expired), and the handoff watcher
59let edgeTimer = null
60let watcher = null
61
62export function register(on, options) {
63 setConfig(options)
64
65 // Fires on the session's start, and again after a hot reload (which drops the timers)
66 on('session.start', async ($, e, next) => {
67 await $.command.register({ name: 'usage-ledger', description: 'このセッションのトークンの内訳を開く' })
68 const now = await $.clock.now()
69 const started = await $.state.get(STARTED_AT)
70 if (typeof started.value !== 'number') await $.state.set(STARTED_AT, now)
71 try {
72 isFreshSession = (await $.session.turns()) === 0
73 } catch {
74 isFreshSession = false
75 }
76 try {
77 setObserved(await $.store.get(OBSERVED_KEY))
78 } catch {}
79 try {
80 await mapAgents($)
81 } catch {}
82 const { value: requests } = await $.state.get(REQUESTS)
83 scheduleEdges($, requests ?? [], now)
84 watcher?.cancel()
85 watcher = $.clock.every(WATCH_MS, () => void watchHandoff($))
86 return next(e)
87 })
88
89 // One model request, of the main thread or a subagent; its result goes on unchanged
90 on('turn.step', async function* ($, e, next) {
91 const result = yield* next(e)
92 try {
93 await recordStep($, e, result)
94 } catch {}
95 return result
96 })
97
98 // A subagent starts: its type
99 on('classic.SubagentStart', async ($, e, next) => {
100 try {
101 await noteThread($, e.agent_id, { agentType: e.agent_type })
102 } catch {}
103 return next(e)
104 })
105
106 // The Agent tool: its description and type, by the agentId its result names
107 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
108 const result = await next(e)
109 try {
110 const agentId = result?.result?.agentId
111 if (typeof agentId === 'string') {
112 await noteThread($, agentId, {
113 agentType: e.subagent_type ?? result.result.agentType,
114 description: typeof e.description === 'string' ? e.description : undefined,
115 })
116 }
117 } catch {}
118 return result
119 })
120
121 // A subagent finished: its transcript has every request with the exact cache-write split,
122 // so its live tally is replaced by it (left as it is when the file is too large to read)
123 on('classic.SubagentStop', async ($, e, next) => {
124 const result = await next(e)
125 try {
126 await backfill($, e.agent_id, e.agent_type, e.agent_transcript_path)
127 } catch {}
128 return result
129 })
130
131 // A prompt carrying the handoff marker: this session was started from a handoff (unless it
132 // is the session that drafted it, sending the draft)
133 on('prompt.submit', async ($, e, next) => {
134 try {
135 const match = HANDOFF_MARK.exec(e.text ?? '')
136 if (match) await noteIncoming($, match[1])
137 } catch {}
138 return next(e)
139 })
140
141 // The pane, /usage-ledger and the band
142 registerPane(on)
143}
144
145// ---- The live tally
146
147async function recordStep($, e, result) {
148 const now = await $.clock.now()
149 const request = normalizeStep(e, result, now, getConfig())
150 if (!request) return
151 await update($, REQUESTS, (list) => appendRequest(list, request))
152 if (request.thread !== 'main') {
153 if (!looked.has(request.thread)) {
154 looked.add(request.thread)
155 await mapAgents($)
156 }
157 return
158 }
159 const { value: requests } = await $.state.get(REQUESTS)
160 scheduleEdges($, requests ?? [], now)
161 await observeMain($, contextOf(request.tok))
162}
163
164// The context of a main request, as an observation: the first of a fresh session is the base
165// every session starts with; the first three of a handed-off session go back to the handoff's
166// record, the first of them as the size a fresh session starts at
167async function observeMain($, context) {
168 const { value: incoming } = await $.state.get(INCOMING)
169 if (incoming && incoming.observed.length < OBSERVE_FIRST) {
170 const observed = [...incoming.observed, context]
171 await $.state.set(INCOMING, { ...incoming, observed })
172 const record = await $.store.get(handoffKey(incoming.id))
173 if (record && typeof record === 'object') await $.store.set(handoffKey(incoming.id), { ...record, observed })
174 if (observed.length === 1) await saveObserved($, { fresh: context })
175 isFreshSession = false
176 return
177 }
178 // A first request larger than a fresh session's assumed start opened with a long prompt
179 // (or is no fresh session at all): it says nothing about the base
180 if (isFreshSession) {
181 isFreshSession = false
182 if (context < assumed().freshCtx) await saveObserved($, { base: context })
183 }
184}
185
186async function saveObserved($, patch) {
187 const now = await $.clock.now()
188 const value = { ...getObserved(), ...patch, at: now }
189 setObserved(value)
190 await $.store.set(OBSERVED_KEY, value)
191}
192
193// Redraws the band and the pane when the main cache reaches its last ten minutes and when it
194// expires, since what they show changes then without a new request. Nothing else redraws on a
195// timer: in the desktop app every redraw rebuilds every mod's drawing, other mods' open panes
196// included
197function scheduleEdges($, requests, now) {
198 edgeTimer?.cancel()
199 edgeTimer = null
200 const s = summarize(requests, now)
201 if (s.expiresAt == null || s.isExpired) return
202 const warnAt = s.expiresAt - WARN_BEFORE_MS
203 const at = now < warnAt ? warnAt : s.expiresAt
204 const own = $.clock.after(Math.max(0, at - now), async () => {
205 if (edgeTimer === own) edgeTimer = null
206 $.ui.invalidate('ui.render')
207 const { value } = await $.state.get(REQUESTS)
208 scheduleEdges($, value ?? [], await $.clock.now())
209 })
210 edgeTimer = own
211}
212
213// ---- Which subagent is which
214
215async function mapAgents($) {
216 const agents = await $.agent.list()
217 if (!Array.isArray(agents) || agents.length === 0) return
218 await update($, THREADS, (threads) => {
219 const next = { ...(threads ?? {}) }
220 for (const a of agents) {
221 looked.add(a.id)
222 next[a.id] = { ...next[a.id], agentType: next[a.id]?.agentType ?? a.type, description: next[a.id]?.description ?? a.description }
223 }
224 return next
225 })
226}
227
228async function noteThread($, agentId, patch) {
229 if (typeof agentId !== 'string' || agentId === '') return
230 const clean = Object.fromEntries(Object.entries(patch).filter(([, v]) => typeof v === 'string' && v !== ''))
231 await update($, THREADS, (threads) => ({ ...(threads ?? {}), [agentId]: { ...(threads ?? {})[agentId], ...clean } }))
232}
233
234async function backfill($, agentId, agentType, path) {
235 if (typeof agentId !== 'string') return
236 await noteThread($, agentId, { agentType })
237 if (typeof path !== 'string' || path === '') return
238 try {
239 const stat = await $.fs.stat(path)
240 if (typeof stat?.size === 'number' && stat.size > MAX_TRANSCRIPT_BYTES) return
241 } catch {
242 return
243 }
244 const text = await $.fs.read(path)
245 const parsed = parseThread(parseJsonl(text))
246 if (parsed.requests.length === 0) return
247 const requests = parsed.requests.map((q) => ({ id: String(q.id), ts: q.ts, model: q.model, tools: q.tools, tok: q.tok, thread: agentId }))
248 await update($, REQUESTS, (list) => replaceThread(list, agentId, requests))
249 await update($, THREADS, (threads) => ({ ...(threads ?? {}), [agentId]: { ...(threads ?? {})[agentId], isBackfilled: true } }))
250}
251
252// ---- Handoff
253
254async function noteIncoming($, id) {
255 const { value: current } = await $.state.get(INCOMING)
256 if (current) return
257 const record = await $.store.get(handoffKey(id))
258 const self = await $.session.id()
259 if (record && typeof record === 'object' && record.fromSession === self) return
260 const { value: drafted } = await $.state.get(HANDOFF)
261 if (drafted?.id === id) return
262 const known = record && typeof record === 'object'
263 await $.state.set(INCOMING, {
264 id,
265 fromSession: known && typeof record.fromSession === 'string' ? record.fromSession : null,
266 ctxAtHandoff: known && typeof record.ctxAtHandoff === 'number' ? record.ctxAtHandoff : null,
267 predictedCtx: known && typeof record.predictedCtx === 'number' ? record.predictedCtx : null,
268 observed: [],
269 })
270}
271
272// A drafted handoff turns 'done' once the new session stored its first context; its later
273// observations are picked up until there are OBSERVE_FIRST
274async function watchHandoff($) {
275 try {
276 const { value: handoff } = await $.state.get(HANDOFF)
277 if (!handoff || handoff.observed.length >= OBSERVE_FIRST) return
278 const record = await $.store.get(handoffKey(handoff.id))
279 const observed = Array.isArray(record?.observed) ? record.observed.filter((n) => typeof n === 'number') : []
280 if (observed.length <= handoff.observed.length) return
281 await $.state.set(HANDOFF, { ...handoff, status: 'done', observed })
282 } catch {}
283}
284hooks/aggregate.js 414 lines1// Pure token-usage aggregation for one Claude Code session.
2//
3// No Node, fs, process or Date: input is already-parsed transcript records
4// (or the mod's live tally), output is a plain JSON-serialisable summary.
5// Timestamps are read and written by the ISO helpers below, so the module
6// runs the same in Node and in a mod's environment. Deterministic, so a mod
7// can import it and feed it either transcript records or its own live tally
8// (see aggregateThreads, which takes normalized requests directly).
9//
10// Normalized request: { id, ts (epoch ms), model, tools: [name],
11// tok: { input, cache_read, cache_write_5m, cache_write_1h, output } }
12
13export const TYPES = ['input', 'cache_read', 'cache_write_5m', 'cache_write_1h', 'output'];
14
15// ---- ISO 8601 timestamps without Date (civil-date arithmetic, proleptic Gregorian)
16
17const DAY_MS = 86400000;
18const ISO_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})$/;
19
20function daysFromCivil(y, m, d) {
21 y -= m <= 2 ? 1 : 0;
22 const era = Math.floor(y / 400), yoe = y - era * 400;
23 const doy = Math.floor((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5) + d - 1;
24 return era * 146097 + yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy - 719468;
25}
26
27function civilFromDays(z) {
28 z += 719468;
29 const era = Math.floor(z / 146097), doe = z - era * 146097;
30 const yoe = Math.floor((doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365);
31 const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
32 const mp = Math.floor((5 * doy + 2) / 153);
33 const m = mp + (mp < 10 ? 3 : -9);
34 return [yoe + era * 400 + (m <= 2 ? 1 : 0), m, doy - Math.floor((153 * mp + 2) / 5) + 1];
35}
36
37/** Epoch ms of an ISO timestamp with a zone (`...Z`, `+09:00`); NaN for anything else. */
38export function parseIso(s) {
39 const t = ISO_RE.exec(String(s ?? ''));
40 if (!t) return NaN;
41 const ms = t[7] ? Number((t[7] + '00').slice(0, 3)) : 0;
42 let v = daysFromCivil(+t[1], +t[2], +t[3]) * DAY_MS + ((+t[4] * 60 + +t[5]) * 60 + (+t[6] || 0)) * 1000 + ms;
43 if (t[8] !== 'Z') {
44 const z = t[8].replace(':', '');
45 v -= (z[0] === '-' ? -1 : 1) * (Number(z.slice(1, 3)) * 60 + Number(z.slice(3, 5))) * 60000;
46 }
47 return v;
48}
49
50/** `YYYY-MM-DDTHH:MM:SS.sssZ` for epoch ms, as Date#toISOString writes it (4-digit years). */
51export function isoString(ms) {
52 if (!Number.isFinite(ms)) return 'Invalid';
53 const days = Math.floor(ms / DAY_MS), rest = ms - days * DAY_MS;
54 const [y, m, d] = civilFromDays(days);
55 const p = (n, w = 2) => String(n).padStart(w, '0');
56 return `${p(y, 4)}-${p(m)}-${p(d)}T${p(Math.floor(rest / 3600000))}:${p(Math.floor(rest / 60000) % 60)}:` +
57 `${p(Math.floor(rest / 1000) % 60)}.${p(rest % 1000, 3)}Z`;
58}
59
60/** `HH:MM` of epoch ms shifted by `tzOffsetMinutes` (local time when that is the local offset). */
61export function clockOf(ms, tzOffsetMinutes = 0) {
62 return isoString(ms + tzOffsetMinutes * 60000).slice(11, 16);
63}
64
65// Rough "limit weight" (assumption, NOT the subscription formula, which is
66// not public). Token-type ratios follow public API prices relative to an
67// uncached input token; model ratios follow public input prices per MTok
68// (Haiku 4.5 $1, Sonnet 4.5 $3, Opus 4.5 $5), unverified for newer models.
69export const DEFAULT_WEIGHTS = {
70 type: { input: 1, cache_read: 0.1, cache_write_5m: 1.25, cache_write_1h: 2, output: 5 },
71 model: [['haiku', 1], ['sonnet', 3], ['opus', 5]],
72 modelFallback: 5,
73};
74
75/** The model ratio of DEFAULT_WEIGHTS (or `W`) for a model id: the first name it contains. */
76export function modelWeight(model, W = DEFAULT_WEIGHTS) {
77 const s = String(model || '').toLowerCase();
78 for (const [k, w] of W.model) if (s.includes(k)) return w;
79 return W.modelFallback;
80}
81
82const zero = () => ({ input: 0, cache_read: 0, cache_write_5m: 0, cache_write_1h: 0, output: 0 });
83const addTok = (acc, t) => { for (const k of TYPES) acc[k] += t[k] || 0; return acc; };
84const ctxOf = (t) => t.input + t.cache_read + t.cache_write_5m + t.cache_write_1h;
85const writeOf = (t) => t.cache_write_5m + t.cache_write_1h;
86const median = (xs) => {
87 if (!xs.length) return 0;
88 const s = [...xs].sort((a, b) => a - b), m = s.length >> 1;
89 return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
90};
91const basename = (p) => String(p).replace(/\\/g, '/').split('/').pop();
92
93export function usageToTok(u) {
94 const cc = u.cache_creation || {};
95 let w5 = cc.ephemeral_5m_input_tokens || 0;
96 const w1 = cc.ephemeral_1h_input_tokens || 0;
97 const total = u.cache_creation_input_tokens || 0;
98 if (w5 + w1 < total) w5 += total - w5 - w1; // no TTL split reported: count as 5m
99 return { input: u.input_tokens || 0, cache_read: u.cache_read_input_tokens || 0,
100 cache_write_5m: w5, cache_write_1h: w1, output: u.output_tokens || 0 };
101}
102
103// One thread's transcript records -> requests, tool results, skills, errors.
104// Streamed responses repeat one message id over several records; the copy
105// with the largest output_tokens carries the final counts.
106export function parseThread(records) {
107 const reqs = new Map();
108 const toolName = new Map(), readTarget = new Map();
109 const results = [], errors = [], skills = {};
110 for (const r of records) {
111 if (!r || typeof r !== 'object') continue;
112 if (r.type === 'assistant') {
113 const m = r.message || {};
114 if (r.isApiErrorMessage) {
115 errors.push({ ts: parseIso(r.timestamp), status: r.apiErrorStatus ?? null,
116 limit: (r.quotaLimits && r.quotaLimits.rateLimitType) || null });
117 }
118 if (m.model === '<synthetic>' || !m.usage) continue;
119 const id = m.id || r.requestId || r.uuid;
120 const tok = usageToTok(m.usage);
121 let q = reqs.get(id);
122 if (!q) {
123 q = { id, ts: parseIso(r.timestamp), model: m.model || '?', tools: [], tok, stop: m.stop_reason || null };
124 reqs.set(id, q);
125 } else if (tok.output >= q.tok.output) {
126 q.tok = tok;
127 q.stop = m.stop_reason || q.stop;
128 }
129 for (const b of m.content || []) {
130 if (b && b.type === 'tool_use') {
131 const name = b.name || '?', inp = b.input || {};
132 toolName.set(b.id, name);
133 q.tools.push(name);
134 if (name === 'Skill') skills[String(inp.skill)] = (skills[String(inp.skill)] || 0) + 1;
135 if (name === 'Read' && inp.file_path) readTarget.set(b.id, basename(inp.file_path));
136 }
137 }
138 } else if (r.type === 'user') {
139 const c = r.message && r.message.content;
140 if (!Array.isArray(c)) continue;
141 for (const b of c) {
142 if (!b || b.type !== 'tool_result') continue;
143 const ct = b.content;
144 const chars = Array.isArray(ct)
145 ? ct.reduce((n, x) => n + ((x && x.text) ? x.text.length : 0), 0)
146 : (ct || '').length;
147 results.push({ tool: toolName.get(b.tool_use_id) || '?', target: readTarget.get(b.tool_use_id) || '', chars });
148 }
149 }
150 }
151 const requests = [...reqs.values()].sort((a, b) => a.ts - b.ts);
152 return { requests, results, skills, errors };
153}
154
155// input: { main: records[], subagents: [{ id, meta, records }] }
156export function aggregate(input, opts = {}) {
157 const threads = [{ key: 'main', label: 'main', agentType: '(main)', description: '', ...parseThread(input.main || []) }];
158 for (const s of input.subagents || []) {
159 const meta = s.meta || {};
160 threads.push({ key: s.id, label: String(s.id).replace(/^agent-/, '').slice(0, 8),
161 agentType: meta.agentType || '?', description: meta.description || '', toolUseId: meta.toolUseId || null,
162 ...parseThread(s.records || []) });
163 }
164 return aggregateThreads(threads, opts);
165}
166
167// threads: [{ key, label, agentType, description, requests, results?, skills?, errors? }]
168// opts: { weights, top, tzOffsetMinutes (for hour buckets), gapSeconds }
169export function aggregateThreads(threads, opts = {}) {
170 const W = opts.weights || DEFAULT_WEIGHTS;
171 const top = opts.top ?? 15;
172 const tzOff = (opts.tzOffsetMinutes || 0) * 60000;
173 const gapMin = opts.gapSeconds ?? 300;
174 const modelW = (m) => modelWeight(m, W);
175 const weighOf = (tok, model) => modelW(model) * TYPES.reduce((n, t) => n + W.type[t] * (tok[t] || 0), 0);
176 const writeWeigh = (tok, model) => modelW(model) * (W.type.cache_write_5m * tok.cache_write_5m + W.type.cache_write_1h * tok.cache_write_1h);
177 const hourKey = (ts) => isoString(ts + tzOff).slice(0, 13); // shifted ISO, "YYYY-MM-DDTHH"
178
179 const all = [];
180 for (const t of threads) for (const q of t.requests) all.push({ ...q, thread: t.key });
181 const sumUp = (reqs) => {
182 const tok = zero(); let weighted = 0;
183 for (const q of reqs) { addTok(tok, q.tok); weighted += weighOf(q.tok, q.model); }
184 return { requests: reqs.length, tok, weighted };
185 };
186 const total = sumUp(all);
187 const ctxTotal = ctxOf(total.tok);
188 const byType = TYPES.map((t) => ({ type: t, tokens: total.tok[t],
189 weighted: all.reduce((n, q) => n + modelW(q.model) * W.type[t] * q.tok[t], 0) }));
190
191 const threadRows = threads.map((t) => ({
192 key: t.key, label: t.label, agentType: t.agentType, description: t.description,
193 models: [...new Set(t.requests.map((q) => q.model))].sort(),
194 start: t.requests.length ? t.requests[0].ts : null,
195 end: t.requests.length ? t.requests[t.requests.length - 1].ts : null,
196 firstContext: t.requests.length ? ctxOf(t.requests[0].tok) : 0,
197 lastContext: t.requests.length ? ctxOf(t.requests[t.requests.length - 1].tok) : 0,
198 ...sumUp(t.requests),
199 })).sort((a, b) => b.weighted - a.weighted);
200
201 const group = (rows, keyFn) => {
202 const m = new Map();
203 for (const r of rows) {
204 const k = keyFn(r);
205 const e = m.get(k) || { key: k, runs: 0, requests: 0, tok: zero(), weighted: 0 };
206 e.runs += 1; e.requests += r.requests; addTok(e.tok, r.tok); e.weighted += r.weighted;
207 m.set(k, e);
208 }
209 return [...m.values()].sort((a, b) => b.weighted - a.weighted);
210 };
211 const byAgentType = group(threadRows, (r) => r.agentType);
212 const byModel = group(all.map((q) => ({ requests: 1, tok: q.tok, weighted: weighOf(q.tok, q.model), model: q.model })), (r) => r.model)
213 .map(({ runs, ...e }) => e);
214
215 const main = (threads.find((t) => t.key === 'main') || { requests: [] }).requests;
216 const mainWeighted = main.reduce((n, q) => n + weighOf(q.tok, q.model), 0);
217 const byTool = group(main.map((q) => ({ requests: 1, tok: q.tok, weighted: weighOf(q.tok, q.model),
218 tool: q.tools.length ? [...new Set(q.tools)].sort().join('+') : 'text' })), (r) => r.tool).map(({ runs, ...e }) => e);
219
220 const biggest = [...all].sort((a, b) => weighOf(b.tok, b.model) - weighOf(a.tok, a.model)).slice(0, top)
221 .map((q) => ({ ts: q.ts, thread: q.thread, model: q.model, context: ctxOf(q.tok), write: writeOf(q.tok),
222 output: q.tok.output, weighted: weighOf(q.tok, q.model), tools: q.tools }));
223
224 const toolResults = new Map(), reads = new Map(), skills = {};
225 for (const t of threads) {
226 const where = t.key === 'main' ? 'main' : 'subagents';
227 for (const r of t.results || []) {
228 const k = where + '\u0000' + r.tool;
229 const e = toolResults.get(k) || { where, tool: r.tool, calls: 0, chars: 0 };
230 e.calls += 1; e.chars += r.chars; toolResults.set(k, e);
231 if (r.tool === 'Read' && r.target) {
232 const f = reads.get(r.target) || { file: r.target, reads: 0, chars: 0, threads: new Set() };
233 f.reads += 1; f.chars += r.chars; f.threads.add(t.key); reads.set(r.target, f);
234 }
235 }
236 for (const [k, v] of Object.entries(t.skills || {})) {
237 const kk = (t.key === 'main' ? 'main:' : 'sub:') + k;
238 skills[kk] = (skills[kk] || 0) + v;
239 }
240 }
241
242 const hours = new Map();
243 for (const q of [...all].sort((a, b) => a.ts - b.ts)) {
244 const k = hourKey(q.ts);
245 const e = hours.get(k) || { hour: k, requests: 0, cache_read: 0, cache_write: 0, output: 0, weighted: 0 };
246 e.requests += 1; e.cache_read += q.tok.cache_read; e.cache_write += writeOf(q.tok);
247 e.output += q.tok.output; e.weighted += weighOf(q.tok, q.model);
248 hours.set(k, e);
249 }
250
251 // Gaps: TTL follows what the previous request of the same thread wrote.
252 const gaps = [];
253 const tax = { expired: { requests: 0, write: 0, weighted: 0 }, within: { requests: 0, write: 0, weighted: 0 } };
254 for (const t of threads) {
255 let ttl = 300;
256 for (let i = 1; i < t.requests.length; i++) {
257 const prev = t.requests[i - 1], cur = t.requests[i];
258 if (prev.tok.cache_write_1h) ttl = 3600; else if (prev.tok.cache_write_5m) ttl = 300;
259 const gap = (cur.ts - prev.ts) / 1000;
260 if (gap <= gapMin) continue;
261 const state = gap > ttl ? 'expired' : 'within';
262 const ww = writeWeigh(cur.tok, cur.model);
263 gaps.push({ ts: cur.ts, thread: t.key, gapSeconds: gap, ttlSeconds: ttl, context: ctxOf(cur.tok),
264 write: writeOf(cur.tok), cacheRead: cur.tok.cache_read, weighted: ww, state });
265 tax[state].requests += 1; tax[state].write += writeOf(cur.tok); tax[state].weighted += ww;
266 }
267 }
268 gaps.sort((a, b) => a.ts - b.ts);
269 const writeWeightTotal = all.reduce((n, q) => n + writeWeigh(q.tok, q.model), 0);
270
271 const ctxs = main.map((q) => ctxOf(q.tok));
272 const drops = [];
273 for (let i = 1; i < ctxs.length; i++) if (ctxs[i] < 0.6 * ctxs[i - 1]) drops.push({ ts: main[i].ts, from: ctxs[i - 1], to: ctxs[i] });
274 const ctxHours = new Map();
275 for (const q of main) {
276 const k = hourKey(q.ts);
277 const e = ctxHours.get(k) || [];
278 e.push(ctxOf(q.tok)); ctxHours.set(k, e);
279 }
280 const buckets = [[0, 50e3], [50e3, 100e3], [100e3, 150e3], [150e3, 200e3], [200e3, Infinity]]
281 .map(([lo, hi]) => ({ from: lo, to: hi === Infinity ? null : hi, requests: ctxs.filter((c) => c >= lo && c < hi).length }));
282
283 return {
284 weights: W,
285 span: all.length ? { start: Math.min(...all.map((q) => q.ts)), end: Math.max(...all.map((q) => q.ts)) } : null,
286 threadCount: threads.length,
287 total: { ...total, all: ctxTotal + total.tok.output, cacheHitRatio: ctxTotal ? total.tok.cache_read / ctxTotal : 0 },
288 byType,
289 errors: threads.flatMap((t) => (t.errors || []).map((e) => ({ ...e, thread: t.key }))).sort((a, b) => a.ts - b.ts),
290 threads: threadRows,
291 byAgentType,
292 byModel,
293 main: { weighted: mainWeighted, byTool },
294 biggest,
295 toolResults: [...toolResults.values()].sort((a, b) => b.chars - a.chars),
296 reads: [...reads.values()].map((f) => ({ file: f.file, reads: f.reads, chars: f.chars, threads: f.threads.size }))
297 .sort((a, b) => b.chars - a.chars),
298 skills,
299 cacheByHour: [...hours.values()],
300 gaps,
301 gapTax: { ...tax, writeWeightTotal },
302 context: {
303 requests: ctxs.length,
304 min: ctxs.length ? Math.min(...ctxs) : 0, max: ctxs.length ? Math.max(...ctxs) : 0,
305 median: median(ctxs), mean: ctxs.length ? ctxs.reduce((a, b) => a + b, 0) / ctxs.length : 0,
306 sum: ctxs.reduce((a, b) => a + b, 0),
307 drops, buckets,
308 byHour: [...ctxHours.entries()].map(([hour, v]) => ({ hour, requests: v.length, min: Math.min(...v),
309 median: median(v), max: Math.max(...v), sum: v.reduce((a, b) => a + b, 0) })),
310 },
311 };
312}
313
314// ---- Live use (usage-ledger): one turn.step result, the break-even, the band's summary
315
316const TTL_MS = { '5m': 300000, '1h': 3600000 };
317/** Context at or above this is "heavy": the pane's overview marks it. */
318export const HEAVY_CONTEXT = 300000;
319/** The band warns while idle once the main cache has this much or less left. */
320export const IDLE_WARN_MS = 600000;
321/** Output tokens assumed for the one turn that writes the handoff. */
322export const HANDOFF_OUTPUT = 2000;
323
324const ctxOfTok = (t) => (t.input || 0) + (t.cache_read || 0) + (t.cache_write_5m || 0) + (t.cache_write_1h || 0);
325export { ctxOfTok as contextOf };
326
327/**
328 * One turn.step as a normalized request, or null when the response carried no usage.
329 * `e` is the step's input (turnId, index, model, agentId?), `result` what next(e) returned,
330 * `now` the clock's ms. turn.step does not say which lifetime a cache write used, so the
331 * thread's assumed TTL decides: `ttlMain` for the main thread, `ttlSub` for a subagent.
332 */
333export function normalizeStep(e, result, now, opts = {}) {
334 const u = result && result.usage;
335 if (!u) return null;
336 const thread = e.agentId == null ? 'main' : String(e.agentId);
337 const ttl = thread === 'main' ? (opts.ttlMain || '1h') : (opts.ttlSub || '5m');
338 const write = u.cache_creation_input_tokens || 0;
339 return {
340 id: `${e.turnId}:${e.index}`,
341 ts: now,
342 model: u.model || e.model || '?',
343 tools: (result.toolUses || []).map((t) => String(t.name)),
344 tok: {
345 input: u.input_tokens || 0,
346 cache_read: u.cache_read_input_tokens || 0,
347 cache_write_5m: ttl === '1h' ? 0 : write,
348 cache_write_1h: ttl === '1h' ? write : 0,
349 output: u.output_tokens || 0,
350 },
351 thread,
352 };
353}
354
355/**
356 * When a fresh session pays for itself, in the weights' units (model ratio included).
357 * r context now, b the base every session starts with (cached elsewhere), n a fresh
358 * session's first context, m the model ratio, w the cache-write weight of `ttl`.
359 * next = 0.1·m·r one more request on a live cache
360 * rewrite = w·m·(r−b) the next request once the cache has expired
361 * saving = 0.1·m·(r−n) what each later request saves in a fresh session
362 * fixed = w·m·(n−b) + 0.1·m·r + 5·m·HANDOFF_OUTPUT writing the fresh cache + the handoff turn
363 * runs = ⌈fixed/saving⌉, 0 once expired ("now"); null when r ≤ n (applies: false)
364 */
365export function breakEven({ context, base, fresh, model, ttl = '1h', isExpired = false, weights = DEFAULT_WEIGHTS }) {
366 const m = modelWeight(model, weights), T = weights.type;
367 const w = ttl === '5m' ? T.cache_write_5m : T.cache_write_1h;
368 const next = T.cache_read * m * context;
369 const rewrite = w * m * Math.max(0, context - base);
370 if (!(context > fresh)) return { applies: false, next, rewrite, saving: 0, fixed: 0, runs: null };
371 const saving = T.cache_read * m * (context - fresh);
372 const fixed = w * m * Math.max(0, fresh - base) + T.cache_read * m * context + T.output * m * HANDOFF_OUTPUT;
373 return { applies: true, next, rewrite, saving, fixed, runs: isExpired ? 0 : Math.ceil(fixed / saving) };
374}
375
376/**
377 * The smallest context at which breakEven's runs come to `runs` or fewer (on a live cache),
378 * solved from fixed ≤ runs·saving. The model ratio cancels out, so no model is needed:
379 * w·(n−b) + 0.1·r + 5·HANDOFF_OUTPUT ≤ runs·0.1·(r−n)
380 * r ≥ (w·(n−b) + 5·HANDOFF_OUTPUT + runs·0.1·n) / ((runs−1)·0.1)
381 * Infinity for runs ≤ 1 (the handoff turn alone costs a request's read).
382 */
383export function contextForRuns({ runs = 5, base, fresh, ttl = '1h', weights = DEFAULT_WEIGHTS }) {
384 if (!(runs > 1)) return Infinity;
385 const T = weights.type;
386 const w = ttl === '5m' ? T.cache_write_5m : T.cache_write_1h;
387 const c = T.cache_read;
388 return (w * Math.max(0, fresh - base) + T.output * HANDOFF_OUTPUT + runs * c * fresh) / ((runs - 1) * c);
389}
390
391/**
392 * The main thread's state the band and the pane draw from, from the live requests.
393 * opts: { now, ttlMain, freshCtx, baseCtx, handoff ('drafted' | 'done' | undefined) }
394 */
395export function summaryForBand(requests, opts = {}) {
396 let last = null;
397 for (const q of requests) if (q.thread === 'main' && (!last || q.ts >= last.ts)) last = q;
398 const ttlMs = TTL_MS[opts.ttlMain] || TTL_MS['1h'];
399 const out = { context: null, model: null, lastMainAt: null, expiresAt: null, remainingMs: null,
400 isExpired: false, isHeavy: false, breakEven: null, handoff: opts.handoff || null };
401 if (!last) return out;
402 const now = opts.now ?? last.ts;
403 out.context = ctxOfTok(last.tok);
404 out.model = last.model;
405 out.lastMainAt = last.ts;
406 out.expiresAt = last.ts + ttlMs;
407 out.remainingMs = Math.max(0, out.expiresAt - now);
408 out.isExpired = now >= out.expiresAt;
409 out.isHeavy = out.context >= HEAVY_CONTEXT;
410 out.breakEven = breakEven({ context: out.context, base: opts.baseCtx ?? 40000, fresh: opts.freshCtx ?? 70000,
411 model: last.model, ttl: opts.ttlMain || '1h', isExpired: out.isExpired });
412 return out;
413}
414hooks/ledger.js 284 lines1// The pure parts usage-ledger's hook files share: its options, the context sizes observed in
2// earlier sessions, the live requests turned into aggregate.js's threads, number and time
3// formatting, and the handoff's id, file name, draft and marker.
4//
5// Pure: no $ here, so the hook files keep every $ call themselves.
6
7import { summaryForBand, contextForRuns, IDLE_WARN_MS, isoString } from './aggregate.js'
8
9/** The pane's id ($.ui.open, the Pane matcher) and the slash command's name. */
10export const PANE = 'usage-ledger'
11
12/** At most this many requests are kept in $.state; the oldest go first. */
13export const MAX_REQUESTS = 5000
14
15/** A subagent transcript larger than this is not read back ($.fs.read refuses over 4 MiB). */
16export const MAX_TRANSCRIPT_BYTES = 4 * 1024 * 1024
17
18/** Always shown beside a weighted figure. */
19export const ESTIMATE_NOTE = '*重みは推計。サブスクの計算式は非公開'
20
21/** How the weights are assumed, for the pane. */
22export const WEIGHTS_NOTE =
23 '公開 API の価格比で推計: 入力 1、キャッシュ読み 0.1、書き 5m 1.25、書き 1h 2、出力 5。モデル比 haiku 1、sonnet 3、opus 5'
24
25/** The pane's views, in the order of its navigation. */
26export const VIEWS = [
27 { id: 'status', label: '状況' },
28 { id: 'overview', label: '概要' },
29 { id: 'threads', label: 'スレッド' },
30 { id: 'kinds', label: '種類・モデル' },
31 { id: 'costly', label: '高い要求' },
32 { id: 'gaps', label: '待ちと再書込' },
33 { id: 'tools', label: '主のツール' },
34 { id: 'handoff', label: '引き継ぎ' },
35]
36
37const DEFAULTS = { ttlMain: '1h', ttlSub: '5m', freshCtx: 70000, baseCtx: 40000, handoffDir: '.claude/handoffs/', exportDir: '.claude/usage-ledger/', showBand: true }
38
39// The options register() received, defaults filled in
40let config = { ...DEFAULTS }
41// Context sizes observed in earlier sessions ($.store 'observed'): { fresh, base } or nulls
42let observed = { fresh: null, base: null }
43
44/** Takes register()'s options; a value of the wrong type keeps the default. */
45export function setConfig(options = {}) {
46 const pick = (key, ok) => (ok(options?.[key]) ? options[key] : DEFAULTS[key])
47 const ttl = (v) => v === '1h' || v === '5m'
48 const count = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0
49 config = {
50 ttlMain: pick('ttlMain', ttl),
51 ttlSub: pick('ttlSub', ttl),
52 freshCtx: pick('freshCtx', count),
53 baseCtx: pick('baseCtx', count),
54 handoffDir: pick('handoffDir', (v) => typeof v === 'string' && v.trim() !== ''),
55 exportDir: pick('exportDir', (v) => typeof v === 'string' && v.trim() !== ''),
56 showBand: pick('showBand', (v) => typeof v === 'boolean'),
57 }
58 return config
59}
60
61export function getConfig() {
62 return config
63}
64
65/** Takes the store's 'observed' value (anything else is ignored). */
66export function setObserved(value) {
67 const n = (v) => (typeof v === 'number' && v > 0 ? v : null)
68 observed = { fresh: n(value?.fresh), base: n(value?.base) }
69}
70
71export function getObserved() {
72 return observed
73}
74
75/**
76 * The fresh-session and base context the break-even assumes: observed first, else the options;
77 * the base never above the fresh size.
78 */
79export function assumed() {
80 const freshCtx = observed.fresh ?? config.freshCtx
81 return { freshCtx, baseCtx: Math.min(observed.base ?? config.baseCtx, freshCtx) }
82}
83
84/** summaryForBand with the options and observations filled in. */
85export function summarize(requests, now, { handoff } = {}) {
86 return summaryForBand(requests ?? [], { now, ttlMain: config.ttlMain, ...assumed(), handoff })
87}
88
89// ---- The gauge: how long the session can go on before a handoff pays
90
91/** The gauge is full where a fresh session pays back within this many requests. */
92export const FULL_RUNS = 5
93/** At or above this percentage of the gauge, the stage is 'soon'. */
94export const SOON_PERCENT = 60
95
96/** The stages' words, for the band and the pane. */
97export const STAGE_LABEL = { ok: 'OK', soon: 'Soon', switch: 'Switch' }
98
99/**
100 * The band's short phrases, by gaugeOf's `advice`. The band stays one line; the pane's 状況
101 * view says the same in full sentences.
102 */
103export const ADVICE = {
104 soon: '区切りで引き継ぐと節約に',
105 switch: '今引き継ぐと節約に',
106 idle: '10 分操作がないと割高に',
107 expired: '続きは新しいセッションが安い',
108}
109
110/**
111 * Where the main context sits on the gauge, from summaryForBand's `s`.
112 * opts: { freshCtx, baseCtx, ttl, isWorking }
113 * percent 0 at a fresh session's context, 100 at contextForRuns(FULL_RUNS), linear between;
114 * 100 once the cache expired (when a fresh session is smaller at all); null with no
115 * main request yet
116 * stage 'ok' below SOON_PERCENT, 'soon' below 100, 'switch' at 100
117 * advice which ADVICE phrase to show, or null: expired, else idle in the cache's last ten
118 * minutes, else the stage's own ('soon', 'switch')
119 * A context no larger than a fresh session's stays at 0 and never advises: a fresh session
120 * would not be cheaper.
121 */
122export function gaugeOf(s, { freshCtx, baseCtx, ttl = '1h', isWorking = false }) {
123 const full = contextForRuns({ runs: FULL_RUNS, base: baseCtx, fresh: freshCtx, ttl })
124 if (s?.context == null) return { percent: null, stage: null, advice: null, fullAt: full }
125 const pays = s.context > freshCtx
126 let percent = pays ? Math.min(100, (100 * (s.context - freshCtx)) / (full - freshCtx)) : 0
127 if (pays && s.isExpired) percent = 100
128 const stage = percent >= 100 ? 'switch' : percent >= SOON_PERCENT ? 'soon' : 'ok'
129 let advice = null
130 if (pays && s.isExpired) advice = 'expired'
131 else if (pays && !isWorking && s.remainingMs != null && s.remainingMs <= IDLE_WARN_MS) advice = 'idle'
132 else if (stage !== 'ok') advice = stage
133 return { percent, stage, advice, fullAt: full }
134}
135
136/** gaugeOf with the options and observations filled in. */
137export function gauge(s, { isWorking = false } = {}) {
138 return gaugeOf(s, { ...assumed(), ttl: config.ttlMain, isWorking })
139}
140
141/** Appends one request, dropping the oldest beyond MAX_REQUESTS. */
142export function appendRequest(list, request) {
143 const next = [...(list ?? []), request]
144 return next.length > MAX_REQUESTS ? next.slice(next.length - MAX_REQUESTS) : next
145}
146
147/** Replaces one thread's requests with `requests` (read back from its transcript). */
148export function replaceThread(list, thread, requests) {
149 const next = [...(list ?? []).filter((q) => q.thread !== thread), ...requests].sort((a, b) => a.ts - b.ts)
150 return next.length > MAX_REQUESTS ? next.slice(next.length - MAX_REQUESTS) : next
151}
152
153/** A JSONL text as records; a line that does not parse (a partial last line) is skipped. */
154export function parseJsonl(text) {
155 const out = []
156 for (const line of String(text ?? '').split('\n')) {
157 if (!line.trim()) continue
158 try {
159 out.push(JSON.parse(line))
160 } catch {}
161 }
162 return out
163}
164
165/** A subagent's label: its description cut short, else its id's first 8 characters. */
166export function threadLabel(key, meta) {
167 if (key === 'main') return '主'
168 const d = meta?.description?.trim()
169 if (d) return d
170 return String(key).replace(/^agent-/, '').slice(0, 8)
171}
172
173/**
174 * The live requests as aggregate.js's threads: main first, then each subagent in order of its
175 * first request; each thread's requests oldest first.
176 */
177export function toThreads(requests, metas) {
178 const by = new Map([['main', []]])
179 for (const q of requests ?? []) {
180 if (!by.has(q.thread)) by.set(q.thread, [])
181 by.get(q.thread).push(q)
182 }
183 const out = []
184 for (const [key, list] of by) {
185 list.sort((a, b) => a.ts - b.ts)
186 const meta = key === 'main' ? {} : metas?.[key] ?? {}
187 out.push({
188 key,
189 label: threadLabel(key, meta),
190 agentType: key === 'main' ? '(主)' : meta.agentType || '?',
191 description: meta.description || '',
192 requests: list,
193 })
194 }
195 return out
196}
197
198// ---- Formatting
199
200/** 370k, 1.2M, 950: a token count or weight, short. */
201export function short(n) {
202 if (n == null || !Number.isFinite(n)) return '—'
203 const a = Math.abs(n)
204 if (a < 1000) return String(Math.round(n))
205 if (a < 10000) return (n / 1000).toFixed(1) + 'k'
206 if (a < 1e6) return Math.round(n / 1000) + 'k'
207 return (n / 1e6).toFixed(a < 1e7 ? 2 : 1) + 'M'
208}
209
210/** 12%, 0.4%: a share; '—' with no whole. */
211export function pct(part, whole) {
212 if (!whole) return '—'
213 const p = (100 * part) / whole
214 return (p < 1 && p > 0 ? p.toFixed(1) : Math.round(p)) + '%'
215}
216
217/** 42 分, 1 時間 5 分, 30 秒 */
218export function duration(ms) {
219 const s = Math.max(0, Math.round(ms / 1000))
220 if (s < 60) return s + ' 秒'
221 const m = Math.floor(s / 60)
222 if (m < 60) return m + ' 分'
223 return Math.floor(m / 60) + ' 時間' + (m % 60 ? ' ' + (m % 60) + ' 分' : '')
224}
225
226/** The model id without its `claude-` prefix. */
227export function modelShort(model) {
228 return String(model ?? '?').replace(/^claude-/, '')
229}
230
231/** The model's family (opus, sonnet, haiku), else modelShort's name. */
232export function modelFamily(model) {
233 const s = String(model ?? '').toLowerCase()
234 for (const k of ['opus', 'sonnet', 'haiku']) if (s.includes(k)) return k
235 return modelShort(model)
236}
237
238/** A thread's models in a list row: their families, each once, joined by '/'. */
239export const modelsLine = (models) => [...new Set((models ?? []).map(modelFamily))].join('/') || '—'
240
241/** The local offset in minutes; 0 where Date is unavailable. */
242export function localOffsetMinutes() {
243 try {
244 return typeof Date === 'function' ? -new Date().getTimezoneOffset() : 0
245 } catch {
246 return 0
247 }
248}
249
250// ---- Handoff
251
252/** The marker the new session's first message carries. */
253export const HANDOFF_MARK = /\[usage-ledger handoff ([0-9]{8}-[0-9]{4}-[0-9a-z]{4})\]/
254
255/** The $.store key of one handoff's record. */
256export const handoffKey = (id) => 'handoff:' + id
257
258/** `YYYYMMDD-HHMM-xxxx` in local time; xxxx from the clock's milliseconds, so ids rarely repeat. */
259export function handoffId(now, tzOffsetMinutes) {
260 return stamp(now, tzOffsetMinutes) + '-' + (Math.floor(now) % 1679616).toString(36).padStart(4, '0')
261}
262
263function stamp(now, tzOffsetMinutes) {
264 const d = isoString(now + tzOffsetMinutes * 60000)
265 return d.slice(0, 4) + d.slice(5, 7) + d.slice(8, 10) + '-' + d.slice(11, 13) + d.slice(14, 16)
266}
267
268/** `<handoffDir>/handoff-YYYYMMDD-HHMM.md`, with forward slashes. */
269export function handoffFile(dir, now, tzOffsetMinutes) {
270 const base = String(dir || DEFAULTS.handoffDir).replace(/\\/g, '/').replace(/\/+$/, '')
271 return `${base}/handoff-${stamp(now, tzOffsetMinutes)}.md`
272}
273
274/** The prompt the band's and the pane's [引き継ぐ…] put in the prompt box. Never sent by the mod. */
275export function handoffDraft({ file, id }) {
276 return [
277 `${file} に引き継ぎ文を書いてください。`,
278 '中身は「目標・今の段階・決めたこと・未解決・最初に読むファイル」の 5 項目で、各 5 行以内、全体で 1.5k トークン以内にします。会話の本文は写さないでください。',
279 `書いたら、その本文と目印 [usage-ledger handoff ${id}] を最初の発言にして、新しいセッションを開いてください。`,
280 '新しいセッションからはこのファイルが見えないことがあるので、本文は最初の発言に含めてください。',
281 '開く前に、どのツールで何を開くかを私に確認してください。',
282 ].join('\n')
283}
284hooks/pane.js 704 lines1// The pane /usage-ledger opens, the band above the prompt, and the handoff the pane and the
2// band start.
3//
4// The pane is one, its view ($.state `view`) switching what it draws, since a second pane
5// opened from a button may not come to the front. Its first view, 状況, says in plain words
6// where the session stands and what to do; the others are tables of one-line rows, a row's
7// [▸] opening its details under it ($.state `open`, one row per view). Everything is
8// aggregated afresh from $.state at each drawing, and the drawing reads that state, so a new
9// request redraws it; nothing here calls $.ui.invalidate (register.js redraws at the cache's
10// two edges only, since every redraw rebuilds every mod's drawing in the desktop app).
11//
12// The band shows whenever the main context is known: a gauge with no number of how far the
13// context is from where a handoff pays (ledger.js gaugeOf, drawn by meter.js), the stage's
14// word and [詳しく], all on one line; a short phrase and [引き継ぐ…] join it only when there is
15// something to do. On a narrow line it gives way as meter.js fitMeter says, without a redraw
16// of its own: a change of width draws it again.
17// What mods beneath draw in the band (usage-band) stays under it.
18//
19// [引き継ぐ…] only fills the prompt box with a draft asking the model to write a handoff and
20// open a new session; the person reads it and decides whether to send it.
21
22import { aggregateThreads, TYPES, clockOf } from './aggregate.js'
23import {
24 VIEWS,
25 ADVICE,
26 gauge,
27 ESTIMATE_NOTE,
28 WEIGHTS_NOTE,
29 handoffKey,
30 getConfig,
31 getObserved,
32 assumed,
33 summarize,
34 toThreads,
35 short,
36 pct,
37 duration,
38 modelShort,
39 modelsLine,
40 localOffsetMinutes,
41 handoffId,
42 handoffFile,
43 handoffDraft,
44} from './ledger.js'
45import { SPACE, COLOR, BUTTON, choice, dim, inline, cell, slot, tableRow, field, section, details, page } from './style.js'
46import { fitMeter, meterParts, cells } from './meter.js'
47import { exportPaths, exportMarkdown, exportJsonl, promptLine } from './export.js'
48import { GRACE_MS, guardDrawing, beginPress, hasStarted, takeOver, endPress } from './press-guard.js'
49import { atom, read, update } from 'claude-code'
50
51// The state this file reads and writes (declared in types/index.d.ts)
52const view = atom({ plugin: 'usage-ledger', key: 'view' }, 'status')
53const REQUESTS = { plugin: 'usage-ledger', key: 'requests' }
54const THREADS = { plugin: 'usage-ledger', key: 'threads' }
55const STARTED_AT = { plugin: 'usage-ledger', key: 'startedAt' }
56const OPEN = { plugin: 'usage-ledger', key: 'open' }
57const HANDOFF = { plugin: 'usage-ledger', key: 'handoff' }
58const INCOMING = { plugin: 'usage-ledger', key: 'incoming' }
59
60const TITLE = 'トークンの内訳'
61const TYPE_LABEL = { input: '入力', cache_read: 'キャッシュ読み', cache_write_5m: '書き 5m', cache_write_1h: '書き 1h', output: '出力' }
62// Rows a long list shows
63const MAX_ROWS = 40
64
65// Element keys allow a plain set of characters; agent ids and tool names may carry others
66const keyOf = (prefix, id) => prefix + '-' + String(id).replace(/[^A-Za-z0-9_-]/g, '_')
67
68// ===== Actions: called from press handlers only (never while drawing) =====
69
70async function go($, id) {
71 await update($, view, () => id)
72 try {
73 await $.ui.scroll({ in: 'usage-ledger', to: 'start' })
74 } catch {}
75}
76
77// Opens the pane at view `id`: /usage-ledger and the band's [詳しく]
78async function openPane($, id) {
79 await update($, view, () => id)
80 return $.ui.open({ id: 'usage-ledger', title: TITLE })
81}
82
83// Opens one row's details in a view, or closes them when pressed again
84function toggleOpen($, viewId, rowKey) {
85 return update($, { ...OPEN, id: viewId }, (value) => (value === rowKey ? '' : rowKey))
86}
87
88// Puts the handoff draft in the prompt box (never sends it) and records the handoff, here
89// ($.state) and for the new session ($.store)
90async function startHandoff($) {
91 const now = await $.clock.now()
92 const tz = localOffsetMinutes()
93 const { value: requests } = await $.state.get(REQUESTS)
94 const s = summarize(requests, now)
95 const id = handoffId(now, tz)
96 const file = handoffFile(getConfig().handoffDir, now, tz)
97 const text = handoffDraft({ file, id })
98 const box = await $.prompt.read()
99 const hasDraft = typeof box?.text === 'string' && box.text.trim() !== ''
100 const filled = await $.prompt.fill(hasDraft ? { text: '\n\n' + text, mode: 'append' } : { text, mode: 'replace' })
101 if (!filled?.isFilled) {
102 $.ui.toast('入力欄に下書きを入れられませんでした' + (filled?.refusal ? `(${filled.refusal})` : ''))
103 return
104 }
105 const predictedCtx = assumed().freshCtx
106 await $.store.set(handoffKey(id), { fromSession: await $.session.id(), ctxAtHandoff: s.context, predictedCtx, createdAt: now, observed: [] })
107 await $.state.set(HANDOFF, { id, status: 'drafted', createdAt: now, file, ctxAtHandoff: s.context, predictedCtx, observed: [] })
108 $.ui.toast('引き継ぎの下書きを入力欄に入れました。読んでから、送るかどうかを決めてください')
109}
110
111async function cancelHandoff($) {
112 await $.state.set(HANDOFF, null)
113}
114
115// Writes the session's numbers to the export folder (export.js: a .md summary and a .jsonl of
116// the requests, the pair overwritten on each press), then names the .md in a toast and in a
117// line added to the prompt box (never sent). Nothing goes into $.state, so nothing redraws.
118async function exportData($) {
119 const now = await $.clock.now()
120 const tz = localOffsetMinutes()
121 const { value: requests = [] } = await $.state.get(REQUESTS)
122 const { value: threads = {} } = await $.state.get(THREADS)
123 const { value: startedAt } = await $.state.get(STARTED_AT)
124 const sessionId = await $.session.id()
125 const paths = exportPaths({
126 dir: getConfig().exportDir,
127 root: await $.session.root(),
128 sessionId,
129 startedAt: typeof startedAt === 'number' ? startedAt : now,
130 tzOffsetMinutes: tz,
131 })
132 const jsonlName = paths.name + '.jsonl'
133 try {
134 await $.fs.write(paths.md, exportMarkdown({ requests, threads, startedAt, now, tzOffsetMinutes: tz, sessionId, jsonlName }))
135 await $.fs.write(paths.jsonl, exportJsonl(requests, threads, tz))
136 } catch (error) {
137 $.ui.toast(`集計を書き出せませんでした(${paths.shown.md}): ${String(error?.message ?? error)}`)
138 return
139 }
140 const line = promptLine(paths.shown.md)
141 const box = await $.prompt.read()
142 const hasDraft = typeof box?.text === 'string' && box.text.trim() !== ''
143 const filled = await $.prompt.fill(hasDraft ? { text: '\n\n' + line, mode: 'append' } : { text: line, mode: 'replace' })
144 $.ui.toast(`集計を ${paths.shown.md} に書き出しました` + (filled?.isFilled ? '' : '(入力欄には入れられませんでした)'))
145}
146
147// ===== The pane =====
148
149// Reads everything a view draws from; each read subscribes the drawing
150async function gather($, e) {
151 const current = await read($, view)
152 const { value: requests = [] } = await $.state.get(REQUESTS)
153 const { value: threads = {} } = await $.state.get(THREADS)
154 const { value: startedAt } = await $.state.get(STARTED_AT)
155 const { value: open = '' } = await $.state.get({ ...OPEN, id: current })
156 const { value: handoff = null } = await $.state.get(HANDOFF)
157 const { value: incoming = null } = await $.state.get(INCOMING)
158 const now = await $.clock.now()
159 const tz = localOffsetMinutes()
160 const agg = aggregateThreads(toThreads(requests, threads), { tzOffsetMinutes: tz, top: 15 })
161 const s = summarize(requests, now, { handoff: handoff?.status })
162 // The pane is not told whether a turn runs: the idle warning is left to the band
163 const g = gauge(s, { isWorking: true })
164 return { view: current, requests, threads, startedAt, open, handoff, incoming, now, tz, agg, s, g, surface: e.surface, width: widthOf(e) ?? 84 }
165}
166
167function drawHeader($, ui, d) {
168 const started = typeof d.startedAt === 'number' ? '集計開始 ' + clockOf(d.startedAt, d.tz) : '集計開始 —'
169 const nav = VIEWS.map((v) =>
170 ui.Button({ key: 'view-' + v.id, label: v.label, ...choice(d.view === v.id), onPress: () => go($, v.id) }),
171 )
172 nav.push(ui.Button({ key: 'close', label: '閉じる', role: 'dismiss', ...BUTTON.nav, onPress: () => $.ui.close({ id: 'usage-ledger' }) }))
173 return ui.Box({
174 key: 'header',
175 flexDirection: 'column',
176 width: '100%',
177 children: [
178 ui.Text({ bold: true, children: [TITLE] }),
179 dim(ui, 'header-about', `${started} · 要求 ${d.agg.total.requests} 回 · 重み計 ${short(d.agg.total.weighted)}*`),
180 inline(ui, 'header-nav', nav, 1),
181 ],
182 })
183}
184
185// A [▸] button that opens a row's details
186function openButton($, ui, d, rowKey) {
187 const isOpen = d.open === rowKey
188 return ui.Button({ key: keyOf('open-' + d.view, rowKey), label: isOpen ? '▾' : '▸', ...choice(isOpen), onPress: () => toggleOpen($, d.view, rowKey) })
189}
190
191const num = (ui, width, text, props = {}) => cell(ui, width, text, props, 'flex-end')
192const head = (ui, width, text, align) => cell(ui, width, text, { dimColor: true }, align)
193const tokLine = (t) => TYPES.map((k) => `${TYPE_LABEL[k]} ${short(t[k])}`).join(' · ')
194
195// ----- 状況 -----
196
197// What each stage means and what to do, in plain words: [what it is, what to do]
198const STATUS_TEXT = {
199 ok: [
200 'まだ余裕があります。このまま続けて大丈夫です。',
201 '会話が長くなるほど、1 回のやり取りで使う量が増えます。目安が右端に近づいたら、新しいセッションに引き継ぐと節約になります。',
202 ],
203 soon: [
204 '会話が長くなってきました。',
205 'このまま続けるより、新しいセッションに引き継いだほうが使う量が少なく済むようになってきています。作業の区切りで [引き継ぐ…] を押してください。',
206 ],
207 switch: [
208 '会話がかなり長くなりました。',
209 '新しいセッションに引き継いだほうが、使う量が少なく済みます。今の区切りで [引き継ぐ…] を押してください。',
210 ],
211 expired: [
212 '休憩の間に割高になりました。',
213 'しばらく操作がなかったので、次の 1 回は会話全体を読み込み直すことになり、使う量が増えます。続きは新しいセッションのほうが安く済みます。[引き継ぐ…] を押してください。',
214 ],
215}
216
217function drawStatus($, ui, d) {
218 const { g } = d
219 const lines = []
220 if (g.percent == null) {
221 lines.push(dim(ui, 'status-empty', 'まだこのセッションの要求がありません。会話を始めると、ここに目安が出ます'))
222 } else {
223 // The page's padding and the section's indent come off the pane's width
224 const fit = fitMeter({ surface: d.surface, stage: g.stage, columns: d.width - 2 * SPACE.page - SPACE.indent })
225 lines.push(ui.Box({ key: 'status-meter', flexDirection: 'row', flexWrap: 'nowrap', columnGap: SPACE.inline, alignItems: 'center', children: meterParts(ui, d.surface, fit, g) }))
226 const [what, todo] = STATUS_TEXT[g.advice === 'expired' ? 'expired' : g.stage]
227 lines.push(ui.Box({ key: 'status-what', children: [ui.Text({ wrap: 'wrap', ...(g.stage === 'ok' ? {} : { bold: true }), children: [what] })] }))
228 lines.push(ui.Box({ key: 'status-todo', children: [ui.Text({ wrap: 'wrap', children: [todo] })] }))
229 }
230 if (d.handoff) lines.push(field(ui, 'status-handoff-state', '引き継ぎ', handoffState(d.handoff), 10))
231
232 const actions = []
233 if (d.handoff?.status === 'drafted') {
234 actions.push(ui.Button({ key: 'status-cancel', label: '取り消す', ...BUTTON.minor, onPress: () => cancelHandoff($) }))
235 } else if (!d.handoff && g.advice) {
236 actions.push(ui.Button({ key: 'status-handoff', label: '引き継ぐ…', ...BUTTON.main, onPress: () => startHandoff($) }))
237 }
238 actions.push(ui.Button({ key: 'status-details', label: '詳しい数字', ...BUTTON.nav, onPress: () => go($, 'overview') }))
239 actions.push(ui.Button({ key: 'status-export', label: '書き出す', ...BUTTON.nav, onPress: () => exportData($) }))
240 actions.push(ui.Button({ key: 'status-about-handoff', label: '引き継ぎについて', ...BUTTON.nav, onPress: () => go($, 'handoff') }))
241 return [section(ui, 'status', '今の状況', lines), inline(ui, 'status-actions', actions)]
242}
243
244// ----- 概要 -----
245function drawOverview($, ui, d) {
246 const { s, agg } = d
247 const be = s.breakEven
248 const { freshCtx, baseCtx } = assumed()
249 const obs = getObserved()
250 const now = []
251 if (s.context == null) {
252 now.push(dim(ui, 'now-empty', 'まだ主スレッドの要求がありません。集計開始より後の要求だけを数えます'))
253 } else {
254 now.push(field(ui, 'now-ctx', '主の文脈', `${short(s.context)}(${modelShort(s.model)})`, 18, s.isHeavy ? { color: COLOR.warn } : {}))
255 now.push(
256 field(
257 ui,
258 'now-cache',
259 'キャッシュ',
260 s.isExpired
261 ? `切れた(${duration(d.now - s.expiresAt)} 前)`
262 : `残り ${duration(s.remainingMs)}(${getConfig().ttlMain} で計算)`,
263 18,
264 s.isExpired ? { color: COLOR.bad } : {},
265 ),
266 )
267 now.push(field(ui, 'now-next', '次の1回', `≈${short(be.next)}*`))
268 now.push(field(ui, 'now-rewrite', '切れたら再書込', `≈${short(be.rewrite)}*`))
269 const premise = `初期 ${short(freshCtx)}・基礎 ${short(baseCtx)} を前提${obs.fresh || obs.base ? '(実測を含む)' : ''}`
270 let fresh = '今の文脈が新しいセッションの初期文脈より小さいので、引き継ぐ得はない'
271 if (be.applies) fresh = (be.runs === 0 ? 'キャッシュが切れたので、今なら新しいセッションのほうが安い' : `${be.runs} 回の要求で回収できる`) + '。' + premise
272 now.push(field(ui, 'now-fresh', '新しいセッション', fresh))
273 }
274 if (d.handoff) now.push(field(ui, 'now-handoff', '引き継ぎ', handoffState(d.handoff)))
275
276 const main = agg.threads.find((t) => t.key === 'main') ?? { requests: 0, weighted: 0 }
277 const subs = { requests: agg.total.requests - main.requests, weighted: agg.total.weighted - main.weighted }
278 const tax = agg.gapTax.expired
279 const totals = [
280 field(ui, 'total-requests', '要求', `${agg.total.requests} 回(主 ${main.requests}・サブ ${subs.requests})`),
281 field(ui, 'total-weighted', '重み*', `${short(agg.total.weighted)}(主 ${pct(main.weighted, agg.total.weighted)}・サブ ${pct(subs.weighted, agg.total.weighted)})`),
282 field(ui, 'total-hit', 'キャッシュ読みの割合', pct(agg.total.cacheHitRatio, 1)),
283 field(ui, 'total-expired', '期限切れ後の再書込', `${tax.requests} 回 · 重み ${short(tax.weighted)}*(全体の ${pct(tax.weighted, agg.total.weighted)})`),
284 ]
285
286 const typeRows = [
287 tableRow(ui, 'type-head', [head(ui, 16, '種類'), head(ui, 10, 'トークン', 'flex-end'), head(ui, 10, '重み*', 'flex-end'), head(ui, 6, '割合', 'flex-end')]),
288 ...agg.byType.map((r) =>
289 tableRow(ui, keyOf('type', r.type), [
290 cell(ui, 16, TYPE_LABEL[r.type]),
291 num(ui, 10, short(r.tokens)),
292 num(ui, 10, short(r.weighted)),
293 num(ui, 6, pct(r.weighted, agg.total.weighted)),
294 ]),
295 ),
296 ]
297
298 return [
299 section(ui, 'now', '今の文脈', now),
300 section(ui, 'totals', '集計開始からの合計', totals),
301 section(ui, 'types', 'トークンの種類別', typeRows),
302 dim(ui, 'weights-note', WEIGHTS_NOTE),
303 section(ui, 'export', '書き出し', [
304 dim(ui, 'export-about', `この集計を ${getConfig().exportDir} にファイルで書き出し、会話で Claude に読ませられます`),
305 inline(ui, 'overview-actions', [ui.Button({ key: 'overview-export', label: '書き出す', ...BUTTON.nav, onPress: () => exportData($) })]),
306 ]),
307 ]
308}
309
310function handoffState(h) {
311 if (h.status === 'drafted') return '下書きを入力欄に入れた(新しいセッションはまだ)'
312 const first = h.observed[0]
313 return `済み · 元 ${short(h.ctxAtHandoff)} → 初回 ${short(first)}(予測 ${short(h.predictedCtx)})`
314}
315
316// ----- スレッド -----
317
318// The widths of the thread list's columns in `room` cells, a row being one line cut at the
319// pane's edge: the thread (its description), its kind, its models, 要求, 重み*, 割合 and [▸].
320// As room runs out, the thread's text shortens first, then the kind's, then the kind goes,
321// then 要求, then 重み*; the models and 割合 stay. 0 is a column left out.
322const THREAD_COL = { label: [12, 28], type: [8, 16], model: [6, 13], req: 5, weight: 8, share: 6, open: 3, labelMin: 6 }
323export function threadColumns(room, modelCells) {
324 const C = THREAD_COL
325 let model = Math.min(Math.max(modelCells, C.model[0]), C.model[1])
326 const steps = [
327 { type: true, req: true, weight: true, labelMin: C.label[0] },
328 { type: false, req: true, weight: true, labelMin: C.labelMin },
329 { type: false, req: false, weight: true, labelMin: C.labelMin },
330 { type: false, req: false, weight: false, labelMin: C.labelMin },
331 ]
332 for (const [i, s] of steps.entries()) {
333 const nums = (s.req ? C.req : 0) + (s.weight ? C.weight : 0) + C.share + C.open
334 const count = 4 + (s.type ? 1 : 0) + (s.req ? 1 : 0) + (s.weight ? 1 : 0)
335 const free = room - model - nums - SPACE.column * (count - 1)
336 const isLast = i === steps.length - 1
337 if (s.type) {
338 if (free - C.label[0] < C.type[0]) continue
339 const type = Math.min(C.type[1], free - C.label[0])
340 return { label: Math.min(C.label[1], free - type), type, model, req: C.req, weight: C.weight }
341 }
342 if (free < s.labelMin && !isLast) continue
343 // Last: the models give way to keep the thread's text, then both stay at their least and
344 // what does not fit is cut at the edge
345 if (free < s.labelMin) model = Math.max(C.model[0] - 2, model - (s.labelMin - free))
346 const label = Math.max(s.labelMin, Math.min(C.label[1], room - model - nums - SPACE.column * (count - 1)))
347 return { label, type: 0, model, req: s.req ? C.req : 0, weight: s.weight ? C.weight : 0 }
348 }
349}
350
351function drawThreads($, ui, d) {
352 const { agg } = d
353 const shown = agg.threads.slice(0, MAX_ROWS)
354 const models = new Map(shown.map((t) => [t.key, modelsLine(t.models)]))
355 // The page's padding and the section's indent come off the pane's width
356 const col = threadColumns(d.width - 2 * SPACE.page - SPACE.indent, Math.max(0, ...[...models.values()].map(cells)))
357 const rows = [
358 tableRow(ui, 'thread-head', [
359 head(ui, col.label, 'スレッド'),
360 col.type ? head(ui, col.type, '種類') : null,
361 head(ui, col.model, 'モデル'),
362 col.req ? head(ui, col.req, '要求', 'flex-end') : null,
363 col.weight ? head(ui, col.weight, '重み*', 'flex-end') : null,
364 head(ui, THREAD_COL.share, '割合', 'flex-end'),
365 ].filter(Boolean)),
366 ]
367 for (const t of shown) {
368 const meta = d.threads[t.key] ?? {}
369 rows.push(
370 tableRow(ui, keyOf('thread', t.key), [
371 cell(ui, col.label, t.label, t.key === 'main' ? { bold: true } : {}),
372 col.type ? cell(ui, col.type, t.agentType, { dimColor: true }) : null,
373 cell(ui, col.model, models.get(t.key)),
374 col.req ? num(ui, col.req, t.requests) : null,
375 col.weight ? num(ui, col.weight, short(t.weighted)) : null,
376 num(ui, THREAD_COL.share, pct(t.weighted, agg.total.weighted)),
377 slot(ui, THREAD_COL.open, [openButton($, ui, d, t.key)]),
378 ].filter(Boolean)),
379 )
380 if (d.open === t.key) {
381 const span = t.start != null ? `${clockOf(t.start, d.tz)}–${clockOf(t.end, d.tz)}` : '—'
382 rows.push(
383 details(ui, keyOf('thread-details', t.key), [
384 t.description ? field(ui, 'd-desc', '説明', t.description, 14) : null,
385 // What the row leaves out on a narrow pane
386 col.type ? null : field(ui, 'd-type', '種類', t.agentType, 14),
387 col.req && col.weight ? null : field(ui, 'd-weight', '要求・重み', `${t.requests} 回 · 重み ${short(t.weighted)}*`, 14),
388 field(ui, 'd-id', 'id', t.key, 14),
389 field(ui, 'd-models', 'モデル', t.models.map(modelShort).join('、') || '—', 14),
390 field(ui, 'd-span', '期間', span, 14),
391 field(ui, 'd-ctx', '文脈', `初回 ${short(t.firstContext)} → 最後 ${short(t.lastContext)}`, 14),
392 field(ui, 'd-tok', 'トークン', tokLine(t.tok), 14),
393 field(
394 ui,
395 'd-source',
396 '数え方',
397 t.key === 'main' ? 'ライブ(書き込みの 5m / 1h は設定で振り分け)' : meta.isBackfilled ? '終了後に記録から読み直した' : 'ライブ(書き込みの 5m / 1h は設定で振り分け)',
398 14,
399 ),
400 ]),
401 )
402 }
403 }
404 if (agg.threads.length > MAX_ROWS) rows.push(dim(ui, 'thread-more', `ほか ${agg.threads.length - MAX_ROWS} 件`))
405 return [section(ui, 'threads', '主スレッドとサブエージェント(重みの大きい順)', rows)]
406}
407
408// ----- 種類・モデル -----
409function drawKinds($, ui, d) {
410 const { agg } = d
411 const kindRows = [
412 tableRow(ui, 'kind-head', [head(ui, 22, '種類'), head(ui, 4, '回', 'flex-end'), head(ui, 5, '要求', 'flex-end'), head(ui, 8, '重み*', 'flex-end'), head(ui, 6, '割合', 'flex-end')]),
413 ...agg.byAgentType.map((r) =>
414 tableRow(ui, keyOf('kind', r.key), [
415 cell(ui, 22, r.key),
416 num(ui, 4, r.runs),
417 num(ui, 5, r.requests),
418 num(ui, 8, short(r.weighted)),
419 num(ui, 6, pct(r.weighted, agg.total.weighted)),
420 ]),
421 ),
422 ]
423 const modelRows = [
424 tableRow(ui, 'model-head', [head(ui, 28, 'モデル'), head(ui, 5, '要求', 'flex-end'), head(ui, 8, '重み*', 'flex-end'), head(ui, 6, '割合', 'flex-end')]),
425 ...agg.byModel.map((r) =>
426 tableRow(ui, keyOf('model', r.key), [
427 cell(ui, 28, modelShort(r.key)),
428 num(ui, 5, r.requests),
429 num(ui, 8, short(r.weighted)),
430 num(ui, 6, pct(r.weighted, agg.total.weighted)),
431 ]),
432 ),
433 ]
434 return [section(ui, 'kinds', 'エージェントの種類別', kindRows), section(ui, 'models', 'モデル別', modelRows)]
435}
436
437// ----- 高い要求 -----
438function drawCostly($, ui, d) {
439 const { agg } = d
440 const label = Object.fromEntries(agg.threads.map((t) => [t.key, t.label]))
441 const rows = [
442 tableRow(ui, 'costly-head', [
443 head(ui, 5, '時刻'),
444 head(ui, 18, 'スレッド'),
445 head(ui, 7, '文脈', 'flex-end'),
446 head(ui, 7, '書込', 'flex-end'),
447 head(ui, 6, '出力', 'flex-end'),
448 head(ui, 8, '重み*', 'flex-end'),
449 ]),
450 ]
451 agg.biggest.forEach((q, i) => {
452 const rowKey = String(i)
453 rows.push(
454 tableRow(ui, keyOf('costly', i), [
455 cell(ui, 5, clockOf(q.ts, d.tz), { dimColor: true }),
456 cell(ui, 18, label[q.thread] ?? q.thread),
457 num(ui, 7, short(q.context)),
458 num(ui, 7, short(q.write), q.write > 0.5 * q.context ? { color: COLOR.warn } : {}),
459 num(ui, 6, short(q.output)),
460 num(ui, 8, short(q.weighted)),
461 slot(ui, 3, [openButton($, ui, d, rowKey)]),
462 ]),
463 )
464 if (d.open === rowKey) {
465 rows.push(
466 details(ui, keyOf('costly-details', i), [
467 field(ui, 'd-model', 'モデル', modelShort(q.model), 14),
468 field(ui, 'd-tools', 'ツール', q.tools.length ? q.tools.join('、') : '(文字だけ)', 14),
469 ]),
470 )
471 }
472 })
473 if (agg.biggest.length === 0) rows.push(dim(ui, 'costly-empty', 'まだ要求がありません'))
474 return [section(ui, 'costly', '重みの大きい要求(全スレッド、上位 15)', rows)]
475}
476
477// ----- 待ちと再書込 -----
478function drawGaps($, ui, d) {
479 const { agg } = d
480 const label = Object.fromEntries(agg.threads.map((t) => [t.key, t.label]))
481 const tax = agg.gapTax
482 const summary = [
483 field(ui, 'gap-expired', '期限切れの後', `${tax.expired.requests} 回 · 書込 ${short(tax.expired.write)} · 重み ${short(tax.expired.weighted)}*(全体の ${pct(tax.expired.weighted, agg.total.weighted)})`),
484 field(ui, 'gap-within', '期限内', `${tax.within.requests} 回 · 書込 ${short(tax.within.write)} · 重み ${short(tax.within.weighted)}*`),
485 ]
486 const rows = [
487 tableRow(ui, 'gap-head', [
488 head(ui, 5, '時刻'),
489 head(ui, 18, 'スレッド'),
490 head(ui, 7, '待ち', 'flex-end'),
491 head(ui, 3, 'TTL'),
492 head(ui, 4, '状態'),
493 head(ui, 7, '書込', 'flex-end'),
494 head(ui, 8, '重み*', 'flex-end'),
495 ]),
496 ]
497 for (const g of [...agg.gaps].reverse().slice(0, MAX_ROWS)) {
498 const expired = g.state === 'expired'
499 rows.push(
500 tableRow(ui, keyOf('gap', g.thread + '-' + g.ts), [
501 cell(ui, 5, clockOf(g.ts, d.tz), { dimColor: true }),
502 cell(ui, 18, label[g.thread] ?? g.thread),
503 num(ui, 7, duration(g.gapSeconds * 1000)),
504 cell(ui, 3, g.ttlSeconds === 3600 ? '1h' : '5m', { dimColor: true }),
505 cell(ui, 4, expired ? '切れ' : '内', expired ? { color: COLOR.bad } : { dimColor: true }),
506 num(ui, 7, short(g.write)),
507 num(ui, 8, short(g.weighted)),
508 ]),
509 )
510 }
511 if (agg.gaps.length === 0) rows.push(dim(ui, 'gap-empty', '5 分を超えて間が空いた要求はまだありません'))
512 return [
513 section(ui, 'gap-summary', 'キャッシュの再書込', summary),
514 section(ui, 'gaps', '5 分を超えて間が空いた要求(新しい順)', rows),
515 dim(ui, 'gap-note', 'TTL は同じスレッドの直前の要求の書き込みで判断します。ライブで数えた分の 5m / 1h は設定による推定です'),
516 ]
517}
518
519// ----- 主のツール -----
520function drawTools($, ui, d) {
521 const { agg } = d
522 const rows = [
523 tableRow(ui, 'tool-head', [head(ui, 30, '応答で呼んだツール'), head(ui, 5, '要求', 'flex-end'), head(ui, 8, '重み*', 'flex-end'), head(ui, 6, '主の中', 'flex-end')]),
524 ...agg.main.byTool.slice(0, MAX_ROWS).map((r) =>
525 tableRow(ui, keyOf('tool', r.key), [
526 cell(ui, 30, r.key === 'text' ? '(文字だけ)' : r.key),
527 num(ui, 5, r.requests),
528 num(ui, 8, short(r.weighted)),
529 num(ui, 6, pct(r.weighted, agg.main.weighted)),
530 ]),
531 ),
532 ]
533 if (agg.main.byTool.length === 0) rows.push(dim(ui, 'tool-empty', 'まだ主スレッドの要求がありません'))
534 return [section(ui, 'tools', '主スレッドの要求を、その応答が呼んだツールで分けたもの', rows)]
535}
536
537// ----- 引き継ぎ -----
538function drawHandoff($, ui, d) {
539 const { freshCtx, baseCtx } = assumed()
540 const blocks = [
541 dim(
542 ui,
543 'handoff-about',
544 '[引き継ぐ…] は、引き継ぎ文を書いて新しいセッションを開くよう頼む文を、入力欄に入れるだけです。送るかどうかはあなたが決めます。新しいセッションを開く前にも、Claude があなたに確認します',
545 ),
546 section(ui, 'handoff-setup', '設定', [
547 field(ui, 'handoff-dir', '置き場', getConfig().handoffDir),
548 field(ui, 'handoff-premise', '前提', `初期文脈 ${short(freshCtx)}・基礎部分 ${short(baseCtx)}`),
549 ]),
550 inline(ui, 'handoff-actions', [ui.Button({ key: 'handoff-start', label: '引き継ぐ…', ...BUTTON.main, onPress: () => startHandoff($) })]),
551 ]
552 const h = d.handoff
553 if (h) {
554 const lines = [
555 field(ui, 'out-state', '状態', handoffState(h)),
556 field(ui, 'out-file', 'ファイル', h.file),
557 field(ui, 'out-id', '目印', `[usage-ledger handoff ${h.id}]`),
558 ]
559 if (h.observed.length > 0) lines.push(field(ui, 'out-observed', '新しいセッション', h.observed.map(short).join(' → ')))
560 blocks.push(section(ui, 'handoff-out', 'このセッションからの引き継ぎ', lines))
561 if (h.status === 'drafted') {
562 blocks.push(inline(ui, 'handoff-out-actions', [ui.Button({ key: 'handoff-cancel', label: '取り消す', ...BUTTON.minor, onPress: () => cancelHandoff($) })]))
563 }
564 }
565 const inc = d.incoming
566 if (inc) {
567 const first = inc.observed[0]
568 blocks.push(
569 section(ui, 'handoff-in', 'このセッションへの引き継ぎ', [
570 field(ui, 'in-result', '文脈', `元 ${short(inc.ctxAtHandoff)} → 初回 ${first == null ? '—' : short(first)}(予測 ${short(inc.predictedCtx)})`),
571 field(ui, 'in-observed', '最初の要求', inc.observed.length ? inc.observed.map(short).join(' → ') : 'まだありません'),
572 field(ui, 'in-id', '目印', inc.id),
573 ]),
574 )
575 }
576 return blocks
577}
578
579function drawView($, ui, d) {
580 switch (d.view) {
581 case 'threads':
582 return drawThreads($, ui, d)
583 case 'kinds':
584 return drawKinds($, ui, d)
585 case 'costly':
586 return drawCostly($, ui, d)
587 case 'gaps':
588 return drawGaps($, ui, d)
589 case 'tools':
590 return drawTools($, ui, d)
591 case 'handoff':
592 return drawHandoff($, ui, d)
593 case 'overview':
594 return drawOverview($, ui, d)
595 default:
596 return drawStatus($, ui, d)
597 }
598}
599
600// ===== The band =====
601
602// The cells across a drawing: the site's own width, else what the surface measured; null when
603// neither is known
604function widthOf(e) {
605 const n = e.props?.bodyColumns
606 if (typeof n === 'number' && n > 0) return n
607 const v = e.viewport?.columns
608 return typeof v === 'number' && v > 0 ? v : null
609}
610
611// The band, one line: the gauge, the stage's word, a short phrase and its button when there is
612// something to do (or a handoff under way), then [詳しく]. The longer details are the pane's.
613// null before the main context is known.
614function drawBand($, ui, s, g, surface, columns) {
615 const { Box, Text, Button } = ui
616 if (g.percent == null) return null
617 let phrase = null
618 let color = null
619 let action = null
620 if (s.handoff === 'drafted') {
621 phrase = '引き継ぎの下書きを入力欄に入れました'
622 action = { key: 'band-cancel', label: '取り消す', ...BUTTON.minor, onPress: () => cancelHandoff($) }
623 } else if (s.handoff === 'done') {
624 phrase = '引き継ぎ済み'
625 } else if (g.advice) {
626 phrase = ADVICE[g.advice]
627 color = g.advice === 'expired' || g.advice === 'switch' ? COLOR.bad : COLOR.warn
628 action = { key: 'band-handoff', label: '引き継ぐ…', ...BUTTON.main, onPress: () => startHandoff($) }
629 }
630 const actions = [...(action ? [action] : []), { key: 'band-details', label: '詳しく', ...BUTTON.nav, onPress: () => openPane($, 'status') }]
631
632 const fit = fitMeter({ surface, stage: g.stage, columns, phrase, buttons: actions.map((a) => a.label) })
633 const children = meterParts(ui, surface, fit, g)
634 if (fit.hasPhrase) {
635 children.push(Box({ key: 'band-sep', flexShrink: 0, children: [Text({ dimColor: true, children: ['·'] })] }))
636 // The only part that shrinks: cut with an ellipsis, never wrapped
637 children.push(Box({ key: 'band-phrase', flexShrink: 1, minWidth: 0, children: [Text({ wrap: 'truncate-end', ...(color ? { color } : {}), children: [phrase] })] }))
638 }
639 children.push(...actions.map((a) => Box({ key: a.key + '-slot', flexShrink: 0, children: [Button(a)] })))
640 return Box({ key: 'usage-ledger-band', flexDirection: 'row', flexWrap: 'nowrap', columnGap: 1, alignItems: 'center', children })
641}
642
643export function registerPane(on) {
644 // /usage-ledger opens the pane at its first view, 状況
645 on('command.run', { command: 'usage-ledger' }, async ($) => {
646 const opened = await openPane($, 'status')
647 return { text: opened.isPlaced ? 'トークンの内訳を開きました' : 'トークンの内訳を開きました(まだ表示されていません)' }
648 })
649
650 on('ui.render', { component: 'Pane', requestId: 'usage-ledger' }, async ($, e) => {
651 const guard = guardDrawing(e.requestId)
652 const ui = guard.wrap($.ui.resolve(e))
653 const d = await gather($, e)
654 return guard.done(page(ui, [drawHeader($, ui, d), ...drawView($, ui, d), dim(ui, 'estimate-note', ESTIMATE_NOTE)]))
655 })
656
657 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
658 const rest = await next(e)
659 if (!getConfig().showBand || e.props.hasSurvey) return rest
660 const { value: requests } = await $.state.get(REQUESTS)
661 const { value: handoff } = await $.state.get(HANDOFF)
662 const s = summarize(requests, await $.clock.now(), { handoff: handoff?.status })
663 const ui = $.ui.resolve(e)
664 const mine = drawBand($, ui, s, gauge(s, { isWorking: e.props.isWorking }), e.surface, widthOf(e))
665 if (!mine) return rest
666 if (!rest) return mine
667 return ui.Box({ flexDirection: 'column', children: [mine, rest] })
668 })
669
670 // A press on this mod's elements (the pane and the band). On the pane
671 // (press-guard.js) it also checks that the press reached the Button's onPress; when the chain
672 // settled, threw, or stayed silent for GRACE_MS without it, the press runs once with the
673 // latest drawing's closure under the same key.
674 on('ui.press', { plugin: 'usage-ledger' }, async ($, e, next) => {
675 const record = beginPress(e)
676 const chain = next(e).then(
677 (value) => ({ kind: 'value', value }),
678 (error) => ({ kind: 'error', error }),
679 )
680 let outcome
681 if (record) {
682 const timer = new AbortController()
683 const grace = $.clock.sleep(GRACE_MS, { signal: timer.signal }).then(
684 () => ({ kind: 'grace' }),
685 () => new Promise(() => {}),
686 )
687 outcome = await Promise.race([chain, grace])
688 timer.abort()
689 if (outcome.kind === 'grace' && hasStarted(record)) outcome = await chain
690 } else {
691 outcome = await chain
692 }
693 if (!record || hasStarted(record)) {
694 if (record) endPress(e, record)
695 if (outcome.kind === 'error') throw outcome.error
696 return outcome.value
697 }
698 try {
699 await takeOver(record, e)
700 } catch {}
701 return { element: e.element }
702 })
703}
704hooks/style.js 92 lines1// The look the pane and the band share: spacing, colors, button roles, and small builders for
2// the recurring blocks (a header, a section, a table row, a key/value field). Pure: each
3// builder takes the element table `$.ui.resolve(e)` handed out (`ui`) and never touches $.
4
5/** Spacing, in character cells. */
6export const SPACE = {
7 page: 1, // the left and right padding of the pane
8 section: 1, // blank rows between sections
9 inline: 1, // columns between buttons in a row
10 column: 2, // columns between the cells of a table row
11 indent: 2, // the indent of a section's body, and of a row's details
12}
13
14/** Theme color names only, so the surface's theme decides the shade. */
15export const COLOR = {
16 warn: 'warning',
17 bad: 'error',
18 good: 'success',
19}
20
21/**
22 * Button roles, spread into a Button's props.
23 * main: the one action a place leads to, and the picked one of several choices.
24 * nav: moving between views, closing.
25 * minor: an unpicked choice, a row's closed [▸].
26 */
27export const BUTTON = {
28 main: { variant: 'primary' },
29 nav: { variant: 'secondary' },
30 minor: { variant: 'secondary', dimColor: true },
31}
32
33/** The role of a button that is one of several choices: picked is main, the rest minor. */
34export const choice = (isPicked) => (isPicked ? BUTTON.main : BUTTON.minor)
35
36/** Dim text that wraps at the pane's edge. */
37export const dim = (ui, key, text) => ui.Box({ key, children: [ui.Text({ dimColor: true, wrap: 'wrap', children: [text] })] })
38
39/** A row of buttons (or other inline items), wrapping when narrow. */
40export const inline = (ui, key, children, marginTop = 0) =>
41 ui.Box({ key, marginTop, flexDirection: 'row', columnGap: SPACE.inline, rowGap: 1, alignItems: 'center', flexWrap: 'wrap', children })
42
43/** A cell of fixed width holding one line of text, cut with … when too long. */
44export const cell = (ui, width, text, props = {}, align = 'flex-start') =>
45 ui.Box({
46 width,
47 flexShrink: 0,
48 flexDirection: 'row',
49 justifyContent: align,
50 children: [ui.Text({ wrap: 'truncate-end', ...props, children: [String(text)] })],
51 })
52
53/** A cell of fixed width holding elements (a button). */
54export const slot = (ui, width, children) => ui.Box({ width, flexShrink: 0, flexDirection: 'row', children })
55
56/** A table row: cells on one line. */
57export const tableRow = (ui, key, children) =>
58 ui.Box({ key, flexDirection: 'row', columnGap: SPACE.column, alignItems: 'center', width: '100%', children })
59
60/** A key/value line: the name dim in a fixed column, the value wrapping beside it. */
61export const field = (ui, key, name, value, width = 18, props = {}) =>
62 ui.Box({
63 key,
64 flexDirection: 'row',
65 columnGap: SPACE.column,
66 width: '100%',
67 children: [
68 ui.Box({ width, flexShrink: 0, children: [ui.Text({ dimColor: true, children: [name] })] }),
69 ui.Box({ flexGrow: 1, flexShrink: 1, children: [ui.Text({ wrap: 'wrap', ...props, children: [value] })] }),
70 ],
71 })
72
73/** A section: a bold heading, then its body indented under it. */
74export const section = (ui, key, title, children) =>
75 ui.Box({
76 key,
77 flexDirection: 'column',
78 width: '100%',
79 children: [
80 ui.Text({ bold: true, children: [title] }),
81 ui.Box({ flexDirection: 'column', paddingLeft: SPACE.indent, width: '100%', children }),
82 ],
83 })
84
85/** A row's details, indented under it. */
86export const details = (ui, key, children) =>
87 ui.Box({ key, flexDirection: 'column', paddingLeft: SPACE.indent * 2, width: '100%', children })
88
89/** The whole pane: its blocks one blank row apart, inside the page padding. */
90export const page = (ui, children) =>
91 ui.Box({ flexDirection: 'column', rowGap: SPACE.section, paddingX: SPACE.page, width: '100%', children })
92hooks/meter.js 130 lines1// The gauge line, `トークン ■■■■■■□□□□ OK`: a label, a bar with no number, and the stage's
2// word. Drawn the way usage-band draws its meters, so the two look alike in the band: an SVG
3// bar on the desktop app, a text bar elsewhere.
4//
5// On a narrow line the parts give way in a fixed order (fitMeter): the band's short phrase
6// first, then the bar shortens, then the bar goes, then the label; the stage's word and the
7// buttons always stay. Every part but the phrase keeps its width (flexShrink 0), so none of
8// them wraps inside the row.
9//
10// Pure: no $ here, so the hook files keep every $ call themselves.
11
12import { STAGE_LABEL } from './ledger.js'
13
14/** The line's label. */
15export const LABEL = 'トークン'
16
17// The text bar's cells, at full length and shortened
18const BAR_CELLS = { full: 10, short: 5 }
19// The SVG bar's size in pixels, at full length and shortened
20const SVG_BAR = { full: 96, short: 48, height: 10 }
21// Pixels taken as one cell of the surface's monospace metric, to size the SVG bar in cells
22// (an estimate: the desktop app does not report its font's advance)
23const PX_PER_CELL = 8
24// The line's last columns, which the terminal may draw over
25const RESERVED_COLUMNS = 2
26// The fewest cells the phrase keeps, its ellipsis included; below this it is left out
27const MIN_PHRASE_CELLS = 8
28const SVG_COLORS = { success: '#4caf50', warning: '#e0a526', error: '#e5534b', track: 'rgba(128,128,128,0.3)' }
29
30/** The theme color of the bar's fill and of the stage's word, by stage ('ok' leaves the word as it is). */
31const STAGE_COLOR = { ok: 'success', soon: 'warning', switch: 'error' }
32
33/** Character cells a text takes: two for a wide (CJK, full-width) character, one otherwise. */
34export function cells(text) {
35 let n = 0
36 for (const ch of String(text)) {
37 const c = ch.codePointAt(0)
38 n += c >= 0x1100 && (c <= 0x115f || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6)) ? 2 : 1
39 }
40 return n
41}
42
43/** The cells a Button takes: its label in brackets on a terminal, inside a frame elsewhere (an estimate). */
44export const buttonCells = (surface, label) => cells(label) + (surface === 'terminal' ? 2 : 4)
45
46// The cells the bar takes at a length ('full', 'short')
47const barCells = (surface, length) => (surface === 'desktop' ? Math.ceil(SVG_BAR[length] / PX_PER_CELL) : BAR_CELLS[length])
48
49// How the line may give way, widest first
50const STEPS = [
51 { hasLabel: true, bar: 'full', hasPhrase: true },
52 { hasLabel: true, bar: 'full', hasPhrase: false },
53 { hasLabel: true, bar: 'short', hasPhrase: false },
54 { hasLabel: true, bar: null, hasPhrase: false },
55 { hasLabel: false, bar: null, hasPhrase: false },
56]
57
58/**
59 * Which parts of the line fit in `columns`: { hasLabel, bar ('full' | 'short' | null),
60 * hasPhrase }. `phrase` is the band's short phrase (null for none), `buttons` the labels of the
61 * Buttons after it. Parts sit a column apart. With no width known (0, absent) everything is
62 * drawn; when nothing fits, the narrowest step (the stage's word and the buttons).
63 */
64export function fitMeter({ surface, stage, columns, phrase = null, buttons = [] }) {
65 const steps = STEPS.map((s) => ({ ...s, hasPhrase: s.hasPhrase && phrase != null }))
66 if (!(typeof columns === 'number' && columns > 0)) return steps[0]
67 const room = columns - RESERVED_COLUMNS
68 for (const step of steps) {
69 const widths = [cells(STAGE_LABEL[stage] ?? ''), ...buttons.map((b) => buttonCells(surface, b))]
70 if (step.hasLabel) widths.push(cells(LABEL))
71 if (step.bar) widths.push(barCells(surface, step.bar))
72 if (step.hasPhrase) widths.push(1, Math.min(MIN_PHRASE_CELLS, cells(phrase)))
73 if (widths.reduce((a, b) => a + b, 0) + widths.length - 1 <= room) return step
74 }
75 return steps[steps.length - 1]
76}
77
78/**
79 * The line's parts, to sit in a row: the label, the bar and the stage's word as `fit`
80 * (fitMeter's result) says, each in a keyed Box that does not shrink (a Text carries no key).
81 * `g` is ledger.js gaugeOf's result with a known percent.
82 */
83export function meterParts(ui, surface, fit, g) {
84 const color = STAGE_COLOR[g.stage]
85 const word = STAGE_LABEL[g.stage]
86 const part = (key, child) => ui.Box({ key, flexDirection: 'row', flexShrink: 0, children: [child] })
87 const parts = []
88 if (fit.hasLabel) parts.push(part('meter-label', ui.Text({ wrap: 'truncate-end', children: [LABEL] })))
89 if (fit.bar && surface === 'desktop') {
90 const width = SVG_BAR[fit.bar]
91 parts.push(part('meter-bar', ui.Svg({ source: svgBar(g.percent, color, width), alt: LABEL + ' ' + word, width, height: SVG_BAR.height })))
92 } else if (fit.bar) {
93 parts.push(part('meter-bar', textBar(ui.Text, g.percent, color, BAR_CELLS[fit.bar])))
94 }
95 parts.push(part('meter-stage', ui.Text({ wrap: 'truncate-end', ...(g.stage === 'ok' ? {} : { color }), children: [word] })))
96 return parts
97}
98
99/** How many of a text bar's `total` cells are filled. */
100export const filledCells = (percent, total = BAR_CELLS.full) => Math.round((clamp(percent) / 100) * total)
101
102// Filled cells in the stage's color, the rest dim, nested in one Text so they stay on one line
103function textBar(Text, percent, color, total) {
104 const filled = filledCells(percent, total)
105 return Text({
106 wrap: 'truncate-end',
107 children: [Text({ color, children: ['█'.repeat(filled)] }), Text({ dimColor: true, children: ['░'.repeat(total - filled)] })],
108 })
109}
110
111/** An SVG bar's fill width in pixels, out of `width`. */
112export const filledPixels = (percent, width = SVG_BAR.full) => Math.round((clamp(percent) / 100) * width)
113
114function svgBar(percent, color, width) {
115 const { height } = SVG_BAR
116 const fill = filledPixels(percent, width)
117 return [
118 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
119 `<clipPath id="c"><rect width="${width}" height="${height}" rx="${height / 2}"/></clipPath>`,
120 `<g clip-path="url(#c)">`,
121 `<rect width="${width}" height="${height}" fill="${SVG_COLORS.track}"/>`,
122 fill > 0 ? `<rect width="${fill}" height="${height}" fill="${SVG_COLORS[color] ?? SVG_COLORS.success}"/>` : '',
123 '</g></svg>',
124 ].join('')
125}
126
127function clamp(percent) {
128 return Math.min(Math.max(percent, 0), 100)
129}
130hooks/export.js 228 lines1// The export [書き出す] writes: the session's numbers as a Markdown summary and as one JSON
2// line per request, for the main conversation to read only the part a question needs (the
3// format is explained in the usage-ledger-data skill the plugin ships).
4//
5// Pure: no $ here; pane.js does the writing. Neither file carries conversation text: numbers,
6// model ids, tool names, agent types and the Agent tool's short descriptions only.
7
8import { aggregateThreads, TYPES, isoString, modelWeight, DEFAULT_WEIGHTS } from './aggregate.js'
9import { MAX_REQUESTS, WEIGHTS_NOTE, ESTIMATE_NOTE, getConfig, summarize, toThreads, duration } from './ledger.js'
10
11/** The skill that explains the files' format. */
12export const SKILL = 'usage-ledger-data'
13
14/** The .md's `##` sections, in order. */
15export const SECTIONS = ['概要', 'スレッド', '種類とモデル', '高い要求', '主のツール', '待ちと再書込']
16
17/** The .jsonl's fields, in the order each line writes them. */
18export const JSONL_FIELDS = ['ts', 'thread', 'agentType', 'description', 'model', ...TYPES, 'context', 'weight', 'tools']
19
20// ---- Names and paths
21
22const isAbsolute = (p) => /^([A-Za-z]:)?[\\/]/.test(p)
23const trimDir = (dir) => String(dir).trim().replace(/\\/g, '/').replace(/\/+$/, '')
24
25/**
26 * The pair's paths: `shown` is what the toast and the prompt line name (relative to the
27 * project unless exportDir is absolute), `md`/`jsonl` what $.fs.write takes (under `root`, the
28 * session's project root, when relative). The date is the local day counting started, so a
29 * session keeps one pair across midnight.
30 */
31export function exportPaths({ dir, root, sessionId, startedAt, tzOffsetMinutes = 0 }) {
32 const d = trimDir(dir || getConfig().exportDir) || '.'
33 const day = isoString(startedAt + tzOffsetMinutes * 60000).slice(0, 10).replace(/-/g, '')
34 const sid = String(sessionId ?? '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 8) || 'session'
35 const name = `${day}-${sid}`
36 const shownBase = d === '.' ? name : `${d}/${name}`
37 const base = isAbsolute(d) || !root ? shownBase : `${String(root).replace(/[\\/]+$/, '')}/${shownBase}`
38 return { name, shown: { md: shownBase + '.md', jsonl: shownBase + '.jsonl' }, md: base + '.md', jsonl: base + '.jsonl' }
39}
40
41/** The line [書き出す] adds to the prompt box. */
42export const promptLine = (shownMd) => `usage-ledger の集計を ${shownMd} に書き出しました。`
43
44// ---- Formatting
45
46/** `2026-10-03T09:00:00.000+09:00`: local time with its offset, so a grep by hour is local. */
47export function isoLocal(ms, tzOffsetMinutes = 0) {
48 const sign = tzOffsetMinutes < 0 ? '-' : '+'
49 const a = Math.abs(tzOffsetMinutes)
50 const zone = `${sign}${String(Math.floor(a / 60)).padStart(2, '0')}:${String(a % 60).padStart(2, '0')}`
51 return isoString(ms + tzOffsetMinutes * 60000).slice(0, 23) + zone
52}
53
54/** `2026-10-03 09:00`, local. */
55const when = (ms, tz) => (Number.isFinite(ms) ? isoString(ms + tz * 60000).slice(0, 16).replace('T', ' ') : '—')
56const int = (n) => (Number.isFinite(n) ? String(Math.round(n)) : '—')
57const share = (part, whole) => (whole ? ((100 * part) / whole).toFixed(1) + '%' : '—')
58// A table cell: one line, its pipes escaped
59const esc = (v) => String(v ?? '').replace(/\r?\n/g, ' ').replace(/\|/g, '\\|')
60const row = (cells) => `| ${cells.map(esc).join(' | ')} |`
61function table(head, rows) {
62 const out = [row(head), row(head.map(() => '---'))]
63 if (rows.length === 0) out.push(row(head.map((_, i) => (i === 0 ? '(なし)' : ''))))
64 else for (const r of rows) out.push(row(r))
65 return out
66}
67
68/** The weight of one request, as aggregate.js weighs it. */
69export function weightOf(tok, model, W = DEFAULT_WEIGHTS) {
70 return modelWeight(model, W) * TYPES.reduce((n, t) => n + W.type[t] * (tok?.[t] || 0), 0)
71}
72
73const contextOf = (t) => (t.input || 0) + (t.cache_read || 0) + (t.cache_write_5m || 0) + (t.cache_write_1h || 0)
74
75// ---- The two files
76
77/**
78 * The .jsonl: one line per request, oldest first, the subagents' read-back requests as they
79 * are in state. metas: $.state `threads`.
80 */
81export function exportJsonl(requests, metas, tzOffsetMinutes = 0) {
82 const lines = [...(requests ?? [])]
83 .sort((a, b) => a.ts - b.ts)
84 .map((q) => {
85 const isMain = q.thread === 'main'
86 const meta = isMain ? {} : metas?.[q.thread] ?? {}
87 const tok = Object.fromEntries(TYPES.map((t) => [t, q.tok?.[t] || 0]))
88 return JSON.stringify({
89 ts: isoLocal(q.ts, tzOffsetMinutes),
90 thread: isMain ? 'main' : String(q.thread),
91 agentType: isMain ? '(主)' : meta.agentType || '?',
92 description: isMain ? '' : meta.description || '',
93 model: q.model ?? '?',
94 ...tok,
95 context: contextOf(tok),
96 weight: Math.round(weightOf(tok, q.model)),
97 tools: Array.isArray(q.tools) ? q.tools.map(String) : [],
98 })
99 })
100 return lines.length ? lines.join('\n') + '\n' : ''
101}
102
103/**
104 * The .md summary. d: { requests, threads (metas), startedAt, now, tzOffsetMinutes, sessionId,
105 * jsonlName (the .jsonl's file name, mentioned at the top) }
106 */
107export function exportMarkdown(d) {
108 const tz = d.tzOffsetMinutes ?? 0
109 const requests = d.requests ?? []
110 const agg = aggregateThreads(toThreads(requests, d.threads), { tzOffsetMinutes: tz, top: 15 })
111 const s = summarize(requests, d.now)
112 const total = agg.total.weighted
113 const keyOf = (t) => (t === 'main' ? 'main' : String(t))
114 const out = []
115
116 out.push('# usage-ledger の集計', '')
117 out.push(`この形式の説明は、スキル ${SKILL} にあります。`, '')
118 out.push(`- 書き出し: ${when(d.now, tz)}(${isoLocal(d.now, tz).slice(23)})`)
119 out.push(`- 集計範囲: 集計開始 ${when(d.startedAt, tz)} から書き出しまで。集計開始は usage-ledger が読み込まれた時刻で、それより前の主スレッドの要求は含まない`)
120 if (requests.length >= MAX_REQUESTS) out.push(`- 要求は新しい ${MAX_REQUESTS} 件だけを残している(古いものは捨てた)`)
121 out.push(`- セッション: ${d.sessionId ?? '—'}`)
122 if (d.jsonlName) out.push(`- 要求ごとの記録: ${d.jsonlName}(1 行 1 要求)`)
123 out.push(`- 目次: ${SECTIONS.join(' / ')}`)
124 out.push(`- 重み: ${WEIGHTS_NOTE}。サブスクの計算式は非公開なので、あくまで推計`)
125 out.push('- 数字: トークン数と重みは丸めない整数、割合は重みの割合、時刻は書き出した PC のローカル時刻', '')
126
127 // ----- 概要
128 out.push('## 概要', '', '### 今の文脈', '')
129 const now = []
130 if (s.context == null) {
131 now.push(['主の文脈', 'まだ主スレッドの要求がない'])
132 } else {
133 const be = s.breakEven
134 now.push(['主の文脈', `${s.context}(${s.model})`])
135 now.push(['キャッシュ', s.isExpired ? `切れた(${duration(d.now - s.expiresAt)} 前)` : `残り ${duration(s.remainingMs)}(${getConfig().ttlMain} で計算)`])
136 now.push(['次の1回の重み', int(be.next)])
137 now.push(['切れたら再書込の重み', int(be.rewrite)])
138 now.push([
139 '新しいセッション',
140 be.applies ? (be.runs === 0 ? 'キャッシュが切れたので、今なら新しいセッションのほうが安い' : `${be.runs} 回の要求で回収できる`) : '今の文脈が新しいセッションの初期文脈より小さく、引き継ぐ得はない',
141 ])
142 }
143 out.push(...table(['項目', '値'], now), '')
144
145 const main = agg.threads.find((t) => t.key === 'main') ?? { requests: 0, weighted: 0 }
146 const tax = agg.gapTax.expired
147 out.push('### 合計', '')
148 out.push(
149 ...table(
150 ['項目', '値'],
151 [
152 ['要求', `${agg.total.requests}(主 ${main.requests}・サブ ${agg.total.requests - main.requests})`],
153 ['重み計', `${int(total)}(主 ${share(main.weighted, total)}・サブ ${share(total - main.weighted, total)})`],
154 ['キャッシュ読みの割合', share(agg.total.cacheHitRatio, 1)],
155 ['期限切れ後の再書込', `${tax.requests} 回・重み ${int(tax.weighted)}(全体の ${share(tax.weighted, total)})`],
156 ],
157 ),
158 '',
159 )
160 out.push('### トークンの種類別', '')
161 out.push(...table(['種類', 'トークン', '重み', '割合'], agg.byType.map((r) => [r.type, r.tokens, int(r.weighted), share(r.weighted, total)])), '')
162
163 // ----- スレッド
164 out.push('## スレッド', '', '重みの大きい順。スレッドは main か、サブエージェントの agentId。', '')
165 out.push(
166 ...table(
167 ['スレッド', '種類', '説明', 'モデル', '要求', ...TYPES, '重み', '割合'],
168 agg.threads.map((t) => [keyOf(t.key), t.agentType, t.description, t.models.join(', ') || '—', t.requests, ...TYPES.map((k) => t.tok[k]), int(t.weighted), share(t.weighted, total)]),
169 ),
170 '',
171 )
172
173 // ----- 種類とモデル
174 out.push('## 種類とモデル', '', '### エージェントの種類別', '')
175 out.push(
176 ...table(
177 ['種類', '回', '要求', ...TYPES, '重み', '割合'],
178 agg.byAgentType.map((r) => [r.key, r.runs, r.requests, ...TYPES.map((k) => r.tok[k]), int(r.weighted), share(r.weighted, total)]),
179 ),
180 '',
181 )
182 out.push('### モデル別', '')
183 out.push(
184 ...table(
185 ['モデル', '要求', ...TYPES, '重み', '割合'],
186 agg.byModel.map((r) => [r.key, r.requests, ...TYPES.map((k) => r.tok[k]), int(r.weighted), share(r.weighted, total)]),
187 ),
188 '',
189 )
190
191 // ----- 高い要求
192 out.push('## 高い要求', '', `重みの大きい要求(全スレッド、上位 ${agg.biggest.length})。文脈は入力とキャッシュの読み書きの合計、書込はキャッシュ書き込み。`, '')
193 out.push(
194 ...table(
195 ['時刻', 'スレッド', 'モデル', '文脈', '書込', '出力', '重み', 'ツール'],
196 agg.biggest.map((q) => [when(q.ts, tz), keyOf(q.thread), q.model, q.context, q.write, q.output, int(q.weighted), q.tools.join(', ') || '(文字だけ)']),
197 ),
198 '',
199 )
200
201 // ----- 主のツール
202 out.push('## 主のツール', '', '主スレッドの要求を、その応答が呼んだツールで分けたもの。text は文字だけの応答、複数は + でつなぐ。', '')
203 out.push(...table(['ツール', '要求', '重み', '主の中'], agg.main.byTool.map((r) => [r.key, r.requests, int(r.weighted), share(r.weighted, agg.main.weighted)])), '')
204
205 // ----- 待ちと再書込
206 const gt = agg.gapTax
207 out.push('## 待ちと再書込', '', '同じスレッドで直前の要求から 5 分を超えて空いた要求。TTL は直前の要求の書き込みで判断(ライブで数えた分の 5m / 1h は設定による推定)。状態 expired はキャッシュが切れた後、within は期限内。', '')
208 out.push(
209 ...table(
210 ['状態', '要求', '書込', '重み', '割合'],
211 [
212 ['expired', gt.expired.requests, gt.expired.write, int(gt.expired.weighted), share(gt.expired.weighted, total)],
213 ['within', gt.within.requests, gt.within.write, int(gt.within.weighted), share(gt.within.weighted, total)],
214 ],
215 ),
216 '',
217 )
218 out.push(
219 ...table(
220 ['時刻', 'スレッド', '待ち秒', 'TTL', '状態', '書込', '重み'],
221 agg.gaps.map((g) => [when(g.ts, tz), keyOf(g.thread), Math.round(g.gapSeconds), g.ttlSeconds === 3600 ? '1h' : '5m', g.state, g.write, int(g.weighted)]),
222 ),
223 '',
224 )
225 out.push(ESTIMATE_NOTE.replace(/^\*/, ''), '')
226 return out.join('\n')
227}
228hooks/press-guard.js 97 lines1// Runs a pane's Button press again when it did not reach the Button's onPress (copied from
2// ui-sampler, without its diagnostics log).
3//
4// A Button's onPress stays in this mod's environment under a handle the host holds for the
5// life of one drawing. A pane is drawn again whenever its props change, so a press that comes
6// in just as the drawing it was aimed at is replaced (the first click on an unfocused pane
7// focuses it, and `isFocused` changes) can find its handle gone and never reach the closure.
8//
9// So each guarded drawing keeps its own Buttons' closures here, by requestId and key, and wraps
10// each onPress so that a run of it is seen. The ui.press hook (pane.js) opens a press record
11// before next(e); if the chain settles, fails, or stays silent past a grace without the
12// Button's onPress having started, the hook takes the press over and runs the closure the
13// latest drawing holds under the same key. A press runs at most once: a closure the engine
14// starts after the take-over finds the record taken and does nothing.
15//
16// Pure: no $ here, so the hook files keep every $ call themselves.
17
18/** How long the hook waits for the press to reach an onPress before it takes it over. */
19export const GRACE_MS = 2000
20
21// The closures of the latest finished drawing, by requestId, then key
22const drawn = new Map()
23// The press in flight per `requestId key`: { started, takenOver }
24const pending = new Map()
25
26const slot = (requestId, key) => requestId + ' ' + key
27
28/**
29 * Starts one drawing of the pane `requestId`. `wrap(table)` hands back the element table with
30 * its Button keeping each onPress here; `done(tree)` makes this drawing the latest and returns
31 * the tree. A drawing that fails before done() leaves the previous one in place.
32 */
33export function guardDrawing(requestId) {
34 const handlers = new Map()
35 return {
36 wrap(table) {
37 const Button = props => {
38 const onPress = props.onPress
39 if (typeof onPress !== 'function') return table.Button(props)
40 handlers.set(props.key, onPress)
41 return table.Button({ ...props, onPress: press => runGuarded(requestId, props.key, onPress, press) })
42 }
43 return { ...table, Button }
44 },
45 done(tree) {
46 drawn.set(requestId, handlers)
47 return tree
48 },
49 }
50}
51
52// The onPress the surface runs: marks the press as reached, unless the hook already took it
53// over, in which case it does nothing (the press has run once already)
54function runGuarded(requestId, key, onPress, press) {
55 const record = pending.get(slot(requestId, key))
56 if (record?.takenOver) return undefined
57 if (record) record.started = true
58 return onPress(press)
59}
60
61/**
62 * Opens the record of one press before next(e). Undefined when the latest drawing of that
63 * pane has no guarded Button under the key (another site, or a key this mod does not keep):
64 * such a press is only watched.
65 */
66export function beginPress(e) {
67 if (!drawn.get(e.requestId)?.has(e.element)) return undefined
68 const record = { started: false, takenOver: false }
69 pending.set(slot(e.requestId, e.element), record)
70 return record
71}
72
73/** Whether the press has reached (started) its Button's onPress. */
74export function hasStarted(record) {
75 return record.started
76}
77
78/**
79 * Takes the press over and runs the latest drawing's closure for its key with `press` (the
80 * ui.press argument). Marks the record first, so the engine's own onPress, if it still
81 * comes, does nothing. Resolves to what the closure returned.
82 */
83export function takeOver(record, press) {
84 record.takenOver = true
85 const onPress = drawn.get(press.requestId)?.get(press.element)
86 return onPress ? onPress(press) : undefined
87}
88
89/**
90 * Closes the record of a press the engine ran itself. A taken-over record stays until the
91 * next press on the key replaces it, so the engine's own onPress, arriving late, still finds it.
92 */
93export function endPress(press, record) {
94 const key = slot(press.requestId, press.element)
95 if (!record.takenOver && pending.get(key) === record) pending.delete(key)
96}
97types/index.d.ts 78 lines1// The values usage-ledger keeps in $.state for the session. Small summaries that outlive the
2// session (handoff records, observed context sizes) go to $.store instead.
3
4/** The five token kinds of one request, the cache writes split by their assumed lifetime. */
5export type UsageLedgerTokens = {
6 input: number
7 cache_read: number
8 cache_write_5m: number
9 cache_write_1h: number
10 output: number
11}
12
13/**
14 * One model request: `id` is `turnId:index` (live) or the response's message id (read back
15 * from a subagent's transcript), `ts` epoch ms, `thread` 'main' or the subagent's agentId.
16 */
17export type UsageLedgerRequest = {
18 id: string
19 ts: number
20 model: string
21 tools: string[]
22 tok: UsageLedgerTokens
23 thread: string
24}
25
26/** What is known of a subagent, by its agentId. */
27export type UsageLedgerThread = {
28 agentType?: string
29 description?: string
30 /** True once its requests were replaced by the ones its transcript records. */
31 isBackfilled?: boolean
32}
33
34/** The pane's views. */
35export type UsageLedgerView = 'status' | 'overview' | 'threads' | 'kinds' | 'costly' | 'gaps' | 'tools' | 'handoff'
36
37/** A handoff this session drafted: 'drafted' once the prompt box holds it, 'done' once a new session reported in. */
38export type UsageLedgerHandoff = {
39 id: string
40 status: 'drafted' | 'done'
41 createdAt: number
42 file: string
43 ctxAtHandoff: number | null
44 predictedCtx: number
45 /** The new session's context on its first requests, as it stored them. */
46 observed: number[]
47}
48
49/** The handoff this session was started from: its first requests' context goes back to the store. */
50export type UsageLedgerIncoming = {
51 id: string
52 fromSession: string | null
53 ctxAtHandoff: number | null
54 predictedCtx: number | null
55 observed: number[]
56}
57
58declare module 'claude-code' {
59 interface PluginState {
60 'usage-ledger': {
61 /** Every request seen since `startedAt`, oldest first, capped. Unset before the first. */
62 requests: UsageLedgerRequest[]
63 /** Subagents by agentId. */
64 threads: Record<string, UsageLedgerThread>
65 /** When counting started (the first load in this session), epoch ms. */
66 startedAt: number
67 /** Unset means 'status'. */
68 view: UsageLedgerView
69 /** The row whose details are open, by view; '' or unset means none. */
70 open: StateFamily<string>
71 /** Unset or null: no handoff drafted here. */
72 handoff: UsageLedgerHandoff | null
73 /** Unset or null: this session was not started from a handoff. */
74 incoming: UsageLedgerIncoming | null
75 }
76 }
77}
78