SLOPSHOPPER

usage-ledger

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-ledger
│ ┃ トークンの内訳 ✕ › fix the failing auth test and add an audit log call │ ┃ トークンの内訳 │ ┃ 集計開始 08:53 · 要求 0 回 · 重み計 0* ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ 状況 ] [ 概要 ] [ スレッド ] [ 種類・モデ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 今の状況 ⏺ Bash(bun test) │ ┃ まだこのセッションの要求がありません。会 ⎿ 3 pass, 1 fail │ ┃ 話を始めると、ここに目安が出ます │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ 詳しい数字 ] [ 書き出す ] [ 引き継ぎについ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ *重みは推計。サブスクの計算式は非公開 │ › /usage-ledger │ ⎿ usage-ledger: トークンの内訳を開きました │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · トークンの内訳
トークンの内訳 集計開始 08:53 · 要求 0 回 · 重み計 0* [ 状況 ] [ 概要 ] [ スレッド ] [ 種類・モデル ] [ 高い要求 今の状況 まだこのセッションの要求がありません。会話を始めると、こ こに目安が出ます [ 詳しい数字 ] [ 書き出す ] [ 引き継ぎについて ] *重みは推計。サブスクの計算式は非公開
README

usage-ledger

会話が長くなると、1 回のやり取りで使うトークンが増えていきます。この mod は、今のセッションをあとどのくらい気にせず続けてよいかを入力欄の上に目安として出し、新しいセッションに切り替えたほうが安くなったら知らせます。 切り替えるときは、引き継ぎ文の下書きを入力欄に入れるところまで手伝います。 トークンがどこに使われているか(主スレッドとサブエージェント、種類とモデル、キャッシュの読み書き、期限切れ後の再書き込み、文脈の大きさ)は、パネルで数字として見られます。 その数字はファイルに書き出せるので、会話の中で Claude に読ませて相談できます。

表示

入力欄の上の帯

このセッションで最初の応答があった後は、いつも 1 行で出ています。数字は出さず、目安のゲージと、今の段階を一言で出します。

トークン ■■■■■■□□□□ Soon · 区切りで引き継ぐと節約に [引き継ぐ…] [詳しく]

ゲージは、新しいセッションを始めた直後の大きさで空、新しいセッションに引き継いだほうが得になる大きさ(引き継ぎの手間を 5 回以内のやり取りで取り戻せるところ)で満杯です。

段階いつ色
OKゲージが 6 割未満色なし
Soonゲージが 6 割以上黄
Switchゲージが満杯、または休憩でキャッシュが切れた後赤

段階の後ろの短い一言と [引き継ぐ…] は、することがあるときだけ出ます。

  • Soon: 「区切りで引き継ぐと節約に」
  • Switch: 「今引き継ぐと節約に」
  • キャッシュの残りが 10 分を切り、Claude が作業していないとき: 「10 分操作がないと割高に」
  • キャッシュが切れた後: 「続きは新しいセッションが安い」

OK のときは、ゲージと「OK」と [詳しく] だけです。

トークン ■■□□□□□□□□ OK [詳しく]

[引き継ぐ…] で引き継ぎの下書きを入力欄に入れ、[詳しく] で下のパネルを開きます。何を意味するか、何をすればよいかは、パネルの「状況」に文で出ます。 引き継ぎの途中は「引き継ぎの下書きを入力欄に入れました」と [取り消す]、新しいセッションが始まった後は「引き継ぎ済み」が、同じ行に出ます。引き継ぎの詳しい結果はパネルで見られます。

トークン ■■■■■■■■■■ Switch · 引き継ぎの下書きを入力欄に入れました [取り消す] [詳しく]

会話がまだ新しいセッションの最初の大きさより小さいうちは、ゲージは空のままで、一言も出ません。 幅が狭いときは、短い一言、ゲージの長さ、ゲージ、「トークン」の順に省きます。段階とボタンは残ります。 ほかの mod が帯に出しているもの(usage-band など)は、その下にそのまま残ります。

パネル

/usage-ledger か、帯の [詳しく] で開きます。最初は「状況」の画面です。上のボタンで画面を切り替えます。行の [▸] で、その行の詳細を開きます。

画面内容
状況今の段階、それが何を意味するか、何をすればよいかを文で出す。[引き継ぐ…]、詳しい数字と引き継ぎの画面へのボタン、[書き出す]
概要今の文脈、キャッシュの残り、次の 1 回と再書き込みの重み、新しいセッションの損益分岐、合計、トークンの種類別、[書き出す]
スレッド主スレッドと各サブエージェントの種類、使ったモデル(opus・sonnet・haiku、複数なら haiku/opus のように)、要求数、重み、割合。幅が狭いときは、説明、種類の順に短くし、種類、要求数、重みの順に省く(省いたものは [▸] の詳細に出る)。モデルと割合は残る
種類・モデルエージェントの種類別、モデル別
高い要求重みの大きい要求の上位 15 件
待ちと再書込5 分を超えて間が空いた要求と、キャッシュが切れた後の再書き込み
主のツール主スレッドの要求を、その応答が呼んだツールで分けたもの
引き継ぎ引き継ぎの設定、[引き継ぐ…]、引き継ぎの結果

書き出して Claude と話す

パネルの「状況」か「概要」の [書き出す] を押すと、このセッションの集計を 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 の価格比から推計しています。

  • トークンの種類: 入力 1、キャッシュ読み 0.1、キャッシュ書き込み(5 分)1.25、キャッシュ書き込み(1 時間)2、出力 5
  • モデル: haiku 1、sonnet 3、opus 5

重みの付いた数字には * を付け、「*重みは推計。サブスクの計算式は非公開」と添えています。

数える範囲

  • 数えるのは、この mod が読み込まれてから(パネルの「集計開始」の時刻から)の要求です。それより前の主スレッドの要求は数えません
  • キャッシュ書き込みが 5 分と 1 時間のどちらだったかは、要求ごとには分かりません。設定の ttlMain と ttlSub で振り分けます
  • サブエージェントは、終わった時点でその記録を読み直し、書き込みの 5 分と 1 時間の内訳まで正確な値に置き換えます(記録が 4 MiB を超えるときは置き換えません)

引き継ぎ

新しいセッションに切り替えるときの手順です。mod が自分で送信したり、セッションを開いたりすることはありません。

  1. 帯かパネルの [引き継ぐ…] を押すと、入力欄に下書きが入ります。入力欄に書きかけの文があるときは、その後ろに足します
  2. 下書きは、引き継ぎ文(目標・今の段階・決めたこと・未解決・最初に読むファイル)をファイルに書き、その本文と目印 [usage-ledger handoff <id>] を最初の発言にして新しいセッションを開くよう、Claude に頼む内容です。読んで、送るかどうかを決めてください
  3. 送ると Claude が引き継ぎ文を書きます。下書きでは、新しいセッションを開く前に、どの方法で何を開くかをあなたに確認するよう頼んでいます。開く方法がない場合は、書かれた引き継ぎ文を自分で新しいセッションに貼ってください
  4. 新しいセッションの usage-ledger は目印を見つけ、最初の 3 回の要求の文脈を記録します。元のセッションの帯は「引き継ぎ済み」に変わり、パネルに「元 370k → 初回 52k(予測 70k)」のように出ます

新しいセッションの最初の文脈の実測は、次からの損益分岐の計算に使います。 下書きを入れた後にやめるときは、帯かパネルの [取り消す] を押します。

設定

設定既定内容
ttlMain1h主スレッドのキャッシュ書き込みを 5m と 1h のどちらで数えるか
ttlSub5mサブエージェントのキャッシュ書き込みを 5m と 1h のどちらで数えるか
freshCtx70000新しいセッションの最初の文脈の見込み(実測があればそちらを使う)
baseCtx40000どのセッションにも入る部分(システムプロンプトやツールの説明など)の見込み
handoffDir.claude/handoffs/引き継ぎ文を書くフォルダ(プロジェクトからの相対パス)
exportDir.claude/usage-ledger/[書き出す] で集計を書くフォルダ(プロジェクトからの相対パス。絶対パスも使える)
showBandtrue入力欄の上の帯(目安のゲージ)を出すかどうか

設定は /config で変えます。例えば引き継ぎ文の置き場を notes/handoffs/ にするには、次のように入力します。

/config usage-ledger.handoffDir=notes/handoffs/

handoffDir は git で管理しないフォルダにしておくと、引き継ぎ文がコミットに混ざりません。

使い方

Claude Code v2.1.286 以降向けです。 インストール方法は、リポジトリの README を見てください。

token-ledger から移る

この 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>] に変わったため、改名前に作った引き継ぎの下書きは記録されません。

Source 9 files
hooks/register.js 284 lines
1// 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}
284
hooks/aggregate.js 414 lines
1// 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}
414
hooks/ledger.js 284 lines
1// 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}
284
hooks/pane.js 704 lines
1// 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}
704
hooks/style.js 92 lines
1// 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 })
92
hooks/meter.js 130 lines
1// 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}
130
hooks/export.js 228 lines
1// 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}
228
hooks/press-guard.js 97 lines
1// 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}
97
types/index.d.ts 78 lines
1// 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