現在のセッションのモデル・effort・トークン・推定コストと、モデル選択の妥当性を表示する(読み取り専用)

Claude Code の Mods(関数フック型プラグイン)を作って配布するリポジトリ。1リポジトリ = 1マーケットプレイス、mods/<name>/ が1 mod。
| mod | 内容 | コマンド |
|---|---|---|
context-doctor | コンテキストの内訳を分析し、課題点と改善点を出す(読み取り専用) | /ctx |
session-recap | ツール別の呼び出し回数とエラー報告件数を一覧にする(読み取り専用・tool 名の集計のみ、input は出力しない) | /recap |
repeat-call-notice | 1 ターンで同じツールを同じ入力で N 回以上呼んだら、ターン終了時にトーストで知らせる(読み取り専用・tool 名と回数のみ、input は出力しない。N=3 は仮置き) | トースト |
model-review | 現在のセッションのモデル・effort・トークン・推定コストを表示し、モデル選択の妥当性を参考として示す。公式 /usage にないモデル×effort 別コスト、Haiku 換算の差額、モデル/effort 切替のキャッシュ再構築コスト、キャッシュヒット率、実行中ターンのリアルタイム表示を持つ(読み取り専用・ヒューリスティック。Mod 読み込み後のターンだけが対象) | /model-review |
このリポジトリは現在 private です。リポジトリへのアクセス権(GitHub 上で閲覧できる権限)がある人だけが取得できます。公開するかどうかは未判断で、判断の経緯は docs/design/open-questions.md の Q-001 を参照してください。
以下の手順は、リポジトリへのアクセス権があり、GitHub 認証済みの利用者向けです。
/plugin install context-doctor --marketplace dtakamiya/claude-code-mods
y でマーケットプレイスを追加 → スコープを選択。terminal セッションのプロンプトで実行する。
Claude Code 2.1.300(validate / test / tsc 通過。UI の目視確認は未実施)。Mods API は early access のため、バージョンが変わったら再検証する。
Mods はサンプルボックスなしで利用者の権限のまま動く。このリポジトリの mod は既定で読み取り専用。$.process.run・$.fs 書込・外部ネットワークを使う mod は、使う理由を各 mod の README に書く。
claude plugin validate mods/<name>
claude plugin test mods/<name>
claude --plugin-dir mods/<name> # 目視確認
詳細は CLAUDE.md。
hooks/register.tsx 93 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { PaneBody } from './pane'
5import { EMPTY_REVIEW, appendTurn, withBaseline, withCurrent } from './state'
6import { beginTurn, finishTurn, recordStep, recordTool, type Drafts } from './recorder'
7import { buildPaneRows } from './pane-model'
8import { buildReportLines } from './report'
9
10const PANE_ID = 'model-review'
11const PANE_TITLE = 'モデル振り返り'
12const ADVISOR_TOOL_NAME = 'advisor'
13const PANE_OPENED_TEXT = 'モデル振り返りペインを開きました。'
14
15// validate の静的走査が読めるよう、参照はこのファイルに置く(リテラルの plugin / key)。
16const reviewAtom = atom({ plugin: 'model-review', key: 'review' } as const, EMPTY_REVIEW)
17
18export const register: Register = on => {
19 // 進行中のターンの下書き(ループごと)。値は差し替えるだけで、中身は書き換えない。
20 let drafts: Drafts = {}
21
22 on('session.start', async ($, e, next) => {
23 await $.command.register({
24 name: 'model-review',
25 description: 'セッションのモデル・effort・トークン・推定コストと、モデル選択の妥当性を表示する(読み取り専用)',
26 })
27 const usage = await $.session.usage()
28 await update($, reviewAtom, state => withBaseline(state, usage.cost?.usd ?? null))
29 return next(e)
30 })
31
32 on('command.run', { command: 'model-review' }, async $ => {
33 const usd = (await $.session.usage()).cost?.usd
34 if (usd !== undefined) await update($, reviewAtom, state => withCurrent(state, usd))
35 const lines = buildReportLines(await read($, reviewAtom))
36 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
37 return { text: opened.isPlaced ? PANE_OPENED_TEXT : lines.join('\n') }
38 })
39
40 on('session.measure', async ($, e, next) => {
41 const costUsd = e.cost?.usd
42 if (costUsd !== undefined) await update($, reviewAtom, state => withCurrent(state, costUsd))
43 return next(e)
44 })
45
46 // 実行中のターンの途中経過は $.state に載らないので、変わるたびにペインを描き直させる(毎秒 10 回まで)。
47 on('turn.start', async ($, e, next) => {
48 drafts = beginTurn(drafts, e)
49 $.ui.invalidate('ui.render')
50 return next(e)
51 })
52
53 // モデルと effort は turn.start ではなく、応答ごとの turn.step にしか載らない。
54 on('turn.step', async function* ($, e, next) {
55 const result = yield* next(e)
56 drafts = recordStep(drafts, {
57 turnId: e.turnId,
58 agentId: e.agentId,
59 model: e.model,
60 effort: e.effort,
61 usage: result.usage,
62 advisorCalls: (result.serverToolUses ?? []).filter(use => use.name === ADVISOR_TOOL_NAME).length,
63 })
64 $.ui.invalidate('ui.render')
65 return result
66 })
67
68 on('tool.call', async ($, e, next) => {
69 drafts = recordTool(drafts, { agentId: e.agentId, tool: e.tool })
70 $.ui.invalidate('ui.render')
71 return next(e)
72 })
73
74 on('turn.complete', async ($, e, next) => {
75 const finished = finishTurn(drafts, {
76 turnId: e.turnId,
77 agentId: e.agentId,
78 usage: e.usage,
79 durationMs: e.durationMs,
80 reason: e.reason,
81 })
82 drafts = finished.drafts
83 await update($, reviewAtom, state => appendTurn(state, finished.record))
84 $.ui.invalidate('ui.render')
85 return next(e)
86 })
87
88 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
89 const rows = buildPaneRows(await read($, reviewAtom), drafts)
90 return <PaneBody ui={$.ui.resolve(e)} rows={rows} />
91 })
92}
93hooks/pane.tsx 102 lines1// 行モデル → Box / Text。ロジックを持たない(色とグリフの対応だけ)。色はテーマキーで、生の色名は使わない。
2import type { Elements } from 'claude-code'
3
4import { BAR_WIDTH, type PaneRow, type Tier, type Tone } from './pane-model'
5
6type Ui = Pick<Elements['terminal'], 'Box' | 'Text'>
7
8const FILLED_GLYPH = '█'
9const EMPTY_GLYPH = '░'
10const TONE_COLOR: Record<Tone, 'success' | 'warning' | 'error' | 'suggestion' | undefined> = {
11 success: 'success',
12 warning: 'warning',
13 error: 'error',
14 suggestion: 'suggestion',
15 plain: undefined,
16}
17// モデル階層の色: 軽いほど success、重いほど warning。未知は色なし。
18const TIER_COLOR: Record<Tier, 'success' | 'suggestion' | 'warning' | undefined> = {
19 haiku: 'success',
20 sonnet: 'suggestion',
21 opus: 'warning',
22 other: undefined,
23}
24
25export function PaneBody({ ui, rows }: { ui: Ui; rows: readonly PaneRow[] }) {
26 const { Box, Text } = ui
27 const dim = (text: string) => <Text color="inactive" dimColor>{text}</Text>
28
29 const renderRow = (row: PaneRow) => {
30 switch (row.kind) {
31 case 'title':
32 return (
33 <Text wrap="truncate-end">
34 <Text bold>{`${row.turns} ターン`}</Text>
35 {dim(` · サブエージェント ${row.subagents} · 推定 `)}
36 <Text bold>{row.costText}</Text>
37 </Text>
38 )
39 case 'heading':
40 return <Text bold wrap="truncate-end">{`▎${row.text}`}</Text>
41 case 'group':
42 return (
43 <Box flexDirection="column">
44 <Text wrap="truncate-end">
45 <Text bold color={TIER_COLOR[row.tier]}>{row.model}</Text>
46 <Text>{' '}</Text>
47 <Text color="subtle">{`effort ${row.effort}`}</Text>
48 {dim(` ${row.turns} ターン`)}
49 </Text>
50 <Text wrap="truncate-end">
51 <Text>{' '}</Text>
52 <Text color={TIER_COLOR[row.tier]}>{FILLED_GLYPH.repeat(row.filled)}</Text>
53 {dim(EMPTY_GLYPH.repeat(BAR_WIDTH - row.filled))}
54 <Text bold>{` ${row.costText}`}</Text>
55 {row.percentText === '' ? null : dim(` ${row.percentText}`)}
56 </Text>
57 <Text color="inactive" dimColor wrap="truncate-end">{` ${row.tokenText}`}</Text>
58 </Box>
59 )
60 case 'turn':
61 return (
62 <Text wrap="truncate-end">
63 <Text bold color={TIER_COLOR[row.tier]}>{row.model}</Text>
64 <Text bold>{` ${row.costText}`}</Text>
65 {dim(` ${row.label} · ${row.detail}`)}
66 </Text>
67 )
68 case 'kv':
69 return (
70 <Text wrap="wrap">
71 {dim(`${row.label} `)}
72 <Text color={TONE_COLOR[row.tone]}>{row.value}</Text>
73 </Text>
74 )
75 case 'badge':
76 return (
77 <Text wrap="wrap">
78 <Text bold inverse color={TONE_COLOR[row.tone]}>{` ${row.label} `}</Text>
79 <Text>{` ${row.text}`}</Text>
80 </Text>
81 )
82 case 'example':
83 return <Text color="inactive" dimColor wrap="truncate-end">{` ・${row.text}`}</Text>
84 case 'ok':
85 return (
86 <Text>
87 <Text color="success" bold>{'✓ '}</Text>
88 <Text>{row.text}</Text>
89 </Text>
90 )
91 case 'note':
92 return <Text color="inactive" dimColor wrap="wrap">{row.text}</Text>
93 case 'message':
94 return <Text dimColor wrap="wrap">{row.text}</Text>
95 default:
96 return <Text>{' '}</Text>
97 }
98 }
99
100 return <Box flexDirection="column">{rows.map(renderRow)}</Box>
101}
102hooks/state.ts 22 lines1// $.state に置く値(atom の初期値と、その書き換え)。すべて新しいオブジェクトを返す。
2import type { ReviewState, TurnRecord } from '../types'
3
4export const EMPTY_REVIEW: ReviewState = {
5 turns: [],
6 sessionCostUsd: { baseline: null, current: null },
7}
8
9export function appendTurn(state: ReviewState, record: TurnRecord): ReviewState {
10 return { ...state, turns: [...state.turns, record] }
11}
12
13/** 基準点は最初に取れた値だけを使う。Mod 読み込み時の総額がここに入る。 */
14export function withBaseline(state: ReviewState, costUsd: number | null): ReviewState {
15 if (state.sessionCostUsd.baseline !== null || costUsd === null) return state
16 return { ...state, sessionCostUsd: { ...state.sessionCostUsd, baseline: costUsd } }
17}
18
19export function withCurrent(state: ReviewState, costUsd: number): ReviewState {
20 return { ...state, sessionCostUsd: { ...state.sessionCostUsd, current: costUsd } }
21}
22hooks/recorder.ts 217 lines1// ターンの記録を組み立てる純粋な reducer。$ に触れず、入力を受けて新しいオブジェクトを返す。
2// tool.call は turnId を持たないため、ループ(メイン or サブエージェント)ごとに下書きを持つ。
3import type { TurnRecord } from '../types'
4import { HAIKU_REFERENCE_MODEL, SONNET_REFERENCE_MODEL, priceUsage, type TokenUsageLike } from './pricing'
5
6export const MAIN_LOOP_KEY = 'main'
7export const UNKNOWN_MODEL = 'unknown'
8export const EDIT_TOOLS: ReadonlySet<string> = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
9
10export type LoopDraft = {
11 readonly turnId: string
12 readonly agentId: string | null
13 readonly model: string | null
14 readonly effort: string | null
15 readonly stepCount: number
16 /** 単価が分かっている応答の推定コストの合計。 */
17 readonly pricedCostUsd: number
18 /** 書込を 1 時間単価で計算した推定コストの合計(上限側)。 */
19 readonly pricedUpperCostUsd: number
20 /** 単価不明のモデルで応答した step があるか。 */
21 readonly hasUnpricedStep: boolean
22 /** 同じ応答を Haiku 単価で計算した推定コストの合計(応答ごとに単価段を判定)。 */
23 readonly haikuPricedCostUsd: number
24 /** 同じ応答を Sonnet 単価で計算した推定コストの合計。 */
25 readonly sonnetPricedCostUsd: number
26 /** 最初の応答のキャッシュ書込トークン数。 */
27 readonly firstStepCacheWrite: number
28 /** 最初の応答のプロンプト長(入力 + 読込 + 書込)。 */
29 readonly firstStepPromptTokens: number
30 readonly advisorCalls: number
31 readonly toolCount: number
32 readonly editCount: number
33}
34
35export type Drafts = Readonly<Record<string, LoopDraft>>
36
37export type StepInput = {
38 readonly turnId: string
39 readonly agentId?: string
40 readonly model: string
41 readonly effort?: string | number
42 readonly usage?: TokenUsageLike | null
43 /** この応答で API が実行した advisor の呼び出し回数。 */
44 readonly advisorCalls?: number
45}
46
47export type ToolInput = {
48 readonly agentId?: string
49 readonly tool: string
50}
51
52export type TurnEndInput = {
53 readonly turnId: string
54 readonly agentId?: string
55 readonly usage?: TokenUsageLike
56 readonly durationMs: number
57 readonly reason: TurnRecord['reason']
58}
59
60export type FinishedTurn = {
61 readonly drafts: Drafts
62 readonly record: TurnRecord
63}
64
65const loopKey = (agentId: string | undefined): string => agentId ?? MAIN_LOOP_KEY
66
67const emptyDraft = (turnId: string, agentId: string | null): LoopDraft => ({
68 turnId,
69 agentId,
70 model: null,
71 effort: null,
72 stepCount: 0,
73 pricedCostUsd: 0,
74 pricedUpperCostUsd: 0,
75 hasUnpricedStep: false,
76 haikuPricedCostUsd: 0,
77 sonnetPricedCostUsd: 0,
78 firstStepCacheWrite: 0,
79 firstStepPromptTokens: 0,
80 advisorCalls: 0,
81 toolCount: 0,
82 editCount: 0,
83})
84
85const draftOf = (drafts: Drafts, agentId: string | undefined, turnId: string): LoopDraft =>
86 drafts[loopKey(agentId)] ?? emptyDraft(turnId, agentId ?? null)
87
88const withDraft = (drafts: Drafts, agentId: string | undefined, draft: LoopDraft): Drafts => ({
89 ...drafts,
90 [loopKey(agentId)]: draft,
91})
92
93const ZERO_TOKENS = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } as const
94
95export function beginTurn(drafts: Drafts, turn: { readonly turnId: string; readonly agentId?: string }): Drafts {
96 return withDraft(drafts, turn.agentId, emptyDraft(turn.turnId, turn.agentId ?? null))
97}
98
99const promptTokensOf = (usage: TokenUsageLike | null): number =>
100 usage === null ? 0 : usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
101
102export function recordStep(drafts: Drafts, step: StepInput): Drafts {
103 const prev = draftOf(drafts, step.agentId, step.turnId)
104 const usage = step.usage ?? null
105 const priced = usage ? priceUsage(usage) : null
106 const pricedUpper = usage ? priceUsage(usage, '1h') : null
107 const pricedAsHaiku = usage ? priceUsage({ ...usage, model: HAIKU_REFERENCE_MODEL }) : null
108 const pricedAsSonnet = usage ? priceUsage({ ...usage, model: SONNET_REFERENCE_MODEL }) : null
109 const next: LoopDraft = {
110 ...prev,
111 turnId: step.turnId,
112 model: usage?.model ?? prev.model ?? step.model,
113 effort: step.effort === undefined ? null : String(step.effort),
114 stepCount: prev.stepCount + 1,
115 pricedCostUsd: prev.pricedCostUsd + (priced ?? 0),
116 pricedUpperCostUsd: prev.pricedUpperCostUsd + (pricedUpper ?? 0),
117 hasUnpricedStep: prev.hasUnpricedStep || (usage !== null && priced === null),
118 haikuPricedCostUsd: prev.haikuPricedCostUsd + (pricedAsHaiku ?? 0),
119 sonnetPricedCostUsd: prev.sonnetPricedCostUsd + (pricedAsSonnet ?? 0),
120 firstStepCacheWrite: prev.stepCount === 0 ? (usage?.cache_creation_input_tokens ?? 0) : prev.firstStepCacheWrite,
121 firstStepPromptTokens: prev.stepCount === 0 ? promptTokensOf(usage) : prev.firstStepPromptTokens,
122 advisorCalls: prev.advisorCalls + (step.advisorCalls ?? 0),
123 }
124 return withDraft(drafts, step.agentId, next)
125}
126
127export function recordTool(drafts: Drafts, call: ToolInput): Drafts {
128 const prev = draftOf(drafts, call.agentId, '')
129 const next: LoopDraft = {
130 ...prev,
131 toolCount: prev.toolCount + 1,
132 editCount: prev.editCount + (EDIT_TOOLS.has(call.tool) ? 1 : 0),
133 }
134 return withDraft(drafts, call.agentId, next)
135}
136
137function tokenCountsOf(usage: TokenUsageLike | undefined): TurnRecord['tokens'] {
138 if (!usage) return ZERO_TOKENS
139 return {
140 input: usage.input_tokens,
141 output: usage.output_tokens,
142 cacheRead: usage.cache_read_input_tokens,
143 cacheWrite: usage.cache_creation_input_tokens,
144 }
145}
146
147/** 応答ごとに計算した合計を優先する。step が一つもないターンだけ、ターン全体の usage で計算する。 */
148function costOf(draft: LoopDraft, usage: TokenUsageLike | undefined): number | null {
149 if (draft.hasUnpricedStep) return null
150 if (draft.stepCount > 0) return draft.pricedCostUsd
151 if (!usage) return 0
152 return priceUsage(usage)
153}
154
155/** costOf と同じ規則で、キャッシュ書込を 1 時間単価にした推定コスト(上限側)。 */
156function upperCostOf(draft: LoopDraft, usage: TokenUsageLike | undefined): number | null {
157 if (draft.hasUnpricedStep) return null
158 if (draft.stepCount > 0) return draft.pricedUpperCostUsd
159 if (!usage) return 0
160 return priceUsage(usage, '1h')
161}
162
163/** costOf と同じ規則で、Haiku 単価に置き換えた推定コスト。 */
164function haikuCostOf(draft: LoopDraft, usage: TokenUsageLike | undefined): number | null {
165 if (draft.hasUnpricedStep) return null
166 if (draft.stepCount > 0) return draft.haikuPricedCostUsd
167 if (!usage) return 0
168 return priceUsage({ ...usage, model: HAIKU_REFERENCE_MODEL })
169}
170
171/** costOf と同じ規則で、Sonnet 単価に置き換えた推定コスト。 */
172function sonnetCostOf(draft: LoopDraft, usage: TokenUsageLike | undefined): number | null {
173 if (draft.hasUnpricedStep) return null
174 if (draft.stepCount > 0) return draft.sonnetPricedCostUsd
175 if (!usage) return 0
176 return priceUsage({ ...usage, model: SONNET_REFERENCE_MODEL })
177}
178
179/**
180 * 最初の応答のキャッシュ書込とプロンプト長。応答(step)がないターンは終了時の usage で補い、
181 * どちらもなければ未取得(undefined)にする。0 と書くと切替の再構築が 0 円に読めてしまう。
182 */
183function firstStepOf(
184 draft: LoopDraft,
185 usage: TokenUsageLike | undefined,
186): { readonly cacheWrite: number | undefined; readonly promptTokens: number | undefined } {
187 if (draft.stepCount > 0) return { cacheWrite: draft.firstStepCacheWrite, promptTokens: draft.firstStepPromptTokens }
188 if (!usage) return { cacheWrite: undefined, promptTokens: undefined }
189 return { cacheWrite: usage.cache_creation_input_tokens, promptTokens: promptTokensOf(usage) }
190}
191
192export function finishTurn(drafts: Drafts, end: TurnEndInput): FinishedTurn {
193 const key = loopKey(end.agentId)
194 const draft = draftOf(drafts, end.agentId, end.turnId)
195 const first = firstStepOf(draft, end.usage)
196 const record: TurnRecord = {
197 turnId: end.turnId,
198 agentId: end.agentId ?? null,
199 model: end.usage?.model ?? draft.model ?? UNKNOWN_MODEL,
200 effort: draft.effort,
201 tokens: tokenCountsOf(end.usage),
202 costUsd: costOf(draft, end.usage),
203 costUpperUsd: upperCostOf(draft, end.usage),
204 toolCount: draft.toolCount,
205 editCount: draft.editCount,
206 durationMs: end.durationMs,
207 reason: end.reason,
208 haikuCostUsd: haikuCostOf(draft, end.usage),
209 sonnetCostUsd: sonnetCostOf(draft, end.usage),
210 firstStepCacheWrite: first.cacheWrite,
211 firstStepPromptTokens: first.promptTokens,
212 advisorCalls: draft.advisorCalls,
213 }
214 const remaining = Object.fromEntries(Object.entries(drafts).filter(([candidate]) => candidate !== key))
215 return { drafts: remaining, record }
216}
217hooks/pane-model.ts 197 lines1// ペインの行モデルを組み立てる純粋関数。`$` にも JSX にも依存しない。描画は pane.tsx。
2import type { ReviewState } from '../types'
3import {
4 HEAVY_TOOL_CALLS,
5 LIGHT_MAX_OUTPUT_TOKENS,
6 LIGHT_MAX_TOOL_CALLS,
7 estimateRange,
8 estimateTotal,
9 findConcerns,
10 groupByModelEffort,
11 modelRank,
12 topCostTurns,
13 type Concern,
14 type ConcernKind,
15 type ModelGroup,
16} from './analyze'
17import type { Drafts, LoopDraft } from './recorder'
18import { ALWAYS_NOTE, advisorCallTotal, buildNotes } from './notes'
19import { CONCERN_AMOUNT_LABEL, NO_TURNS_LINE, formatInt, formatUsd, formatUsdRange, hitRateText } from './report'
20
21export const BAR_WIDTH = 14
22const MODEL_ID_PREFIX = 'claude-'
23const NO_EFFORT_LABEL = '-'
24
25export type Tone = 'success' | 'warning' | 'error' | 'suggestion' | 'plain'
26export type Tier = 'haiku' | 'sonnet' | 'opus' | 'other'
27
28export type PaneRow =
29 | { kind: 'message'; text: string }
30 | { kind: 'blank' }
31 | { kind: 'title'; turns: number; subagents: number; costText: string }
32 | { kind: 'heading'; text: string }
33 | { kind: 'group'; model: string; effort: string; tier: Tier; turns: number; filled: number; percentText: string; costText: string; tokenText: string }
34 | { kind: 'turn'; label: string; model: string; tier: Tier; costText: string; detail: string }
35 | { kind: 'kv'; label: string; value: string; tone: Tone }
36 | { kind: 'badge'; label: string; tone: Tone; text: string }
37 | { kind: 'example'; text: string }
38 | { kind: 'ok'; text: string }
39 | { kind: 'note'; text: string }
40
41const TIER_OF_RANK: Readonly<Record<number, Tier>> = { 0: 'haiku', 1: 'sonnet', 2: 'opus' }
42const CONCERN_BADGE: Readonly<Record<ConcernKind, { label: string; tone: Tone; text: string }>> = {
43 overspec: { label: '過剰', tone: 'warning', text: '軽作業に重いモデル / effort' },
44 underspec: { label: '不足', tone: 'error', text: '重い作業に軽い設定' },
45 delegation: {
46 label: '委譲',
47 tone: 'suggestion',
48 text: 'Haiku サブエージェントへの委譲なし(サブエージェントは 5 分 TTL・親のキャッシュを読まない)',
49 },
50 switch: { label: '切替', tone: 'warning', text: 'モデル / effort の切替でキャッシュを再構築' },
51}
52
53const tierOf = (model: string): Tier => TIER_OF_RANK[modelRank(model)] ?? 'other'
54const shortModel = (model: string): string => (model.startsWith(MODEL_ID_PREFIX) ? model.slice(MODEL_ID_PREFIX.length) : model)
55
56const MIN_VISIBLE_SHARE = 0.01
57
58/** コスト比(0〜1)。単価不明(null)や合計 0 は null。 */
59function costShare(costUsd: number | null, totalUsd: number): number | null {
60 if (costUsd === null || totalUsd <= 0) return null
61 return costUsd / totalUsd
62}
63
64/** コスト比を 0〜BAR_WIDTH のセル数にする。1% 未満は 0、それ以上は最低 1 セル。 */
65function filledCells(share: number | null): number {
66 if (share === null || share < MIN_VISIBLE_SHARE) return 0
67 return Math.min(BAR_WIDTH, Math.max(1, Math.round(share * BAR_WIDTH)))
68}
69
70function percentText(share: number | null): string {
71 if (share === null) return ''
72 return share < MIN_VISIBLE_SHARE ? '<1%' : `${Math.round(share * 100)}%`
73}
74
75function groupRow(group: ModelGroup, totalUsd: number): PaneRow {
76 const { tokens } = group
77 const share = costShare(group.costUsd, totalUsd)
78 const hit = hitRateText(tokens)
79 return {
80 kind: 'group',
81 model: shortModel(group.model),
82 effort: group.effort ?? NO_EFFORT_LABEL,
83 tier: tierOf(group.model),
84 turns: group.turns,
85 filled: filledCells(share),
86 percentText: percentText(share),
87 costText: formatUsd(group.costUsd),
88 tokenText: `${hit === null ? '' : `${hit} · `}入力 ${formatInt(tokens.input)} · 出力 ${formatInt(tokens.output)} · キャッシュ 読 ${formatInt(tokens.cacheRead)} / 書 ${formatInt(tokens.cacheWrite)}`,
89 }
90}
91
92function costRows(review: ReviewState, estimateUsd: number, unpricedTurns: number): PaneRow[] {
93 const unpriced = unpricedTurns > 0 ? ` (単価不明 ${unpricedTurns} ターンを除く)` : ''
94 const { lowUsd, highUsd } = estimateRange(review.turns)
95 const estimate = `${formatUsdRange(lowUsd, highUsd)}${unpriced}`
96 const { baseline, current } = review.sessionCostUsd
97 if (baseline === null || current === null) {
98 return [{ kind: 'kv', label: 'コスト', value: `推定 ${estimate} / 実コスト 取得できません`, tone: 'plain' }]
99 }
100 const actual = current - baseline
101 const diff = actual - estimateUsd
102 const diffText = `${diff >= 0 ? '+' : '-'}${formatUsd(Math.abs(diff))}`
103 return [{ kind: 'kv', label: 'コスト', value: `推定 ${estimate} / 実 ${formatUsd(actual)}(読み込み後)/ 差 ${diffText}`, tone: 'plain' }]
104}
105
106function concernRows(concerns: readonly Concern[]): PaneRow[] {
107 if (concerns.length === 0) return [{ kind: 'ok', text: '指摘なし' }]
108 return concerns.flatMap((concern): PaneRow[] => {
109 const badge = CONCERN_BADGE[concern.kind]
110 const amountLabel = CONCERN_AMOUNT_LABEL[concern.kind]
111 const amount = concern.amountUsd === null || amountLabel === null ? '' : ` · ${amountLabel} ${formatUsd(concern.amountUsd)}`
112 return [
113 { kind: 'badge', label: badge.label, tone: badge.tone, text: `${badge.text} · ${concern.count} 件${amount}` },
114 ...concern.examples.map((text): PaneRow => ({ kind: 'example', text })),
115 ]
116 })
117}
118
119function topTurnRows(records: ReviewState['turns']): PaneRow[] {
120 const top = topCostTurns(records)
121 if (top.length === 0) return []
122 return [
123 { kind: 'heading', text: '高コストターン' },
124 ...top.map((record): PaneRow => ({
125 kind: 'turn',
126 label: `${record.turnId} · ${record.agentId === null ? 'メイン' : 'サブエージェント'}`,
127 model: shortModel(record.model),
128 tier: tierOf(record.model),
129 costText: formatUsd(record.costUsd),
130 detail: `ツール ${record.toolCount} 回 · ${record.durationMs} ms`,
131 })),
132 { kind: 'blank' },
133 ]
134}
135
136function advisorRows(records: ReviewState['turns']): PaneRow[] {
137 const calls = advisorCallTotal(records)
138 if (calls === 0) return []
139 return [{ kind: 'kv', label: 'advisor', value: `${calls} 回(費用が usage に載るかは未確認。金額には含めていません)`, tone: 'plain' }]
140}
141
142const concernHeading = (concerns: readonly Concern[]): string => {
143 const total = concerns.reduce((sum, concern) => sum + concern.count, 0)
144 return total === 0 ? '妥当性(参考)' : `妥当性(参考)· 指摘 ${total} 件`
145}
146
147const CRITERIA_NOTE = `軽作業 = ツール ${LIGHT_MAX_TOOL_CALLS} 回以下・編集なし・出力 ${LIGHT_MAX_OUTPUT_TOKENS} tokens 以下 / 重い = ツール ${HEAVY_TOOL_CALLS} 回以上・長時間・エラー`
148
149const isStarted = (draft: LoopDraft): boolean => draft.stepCount > 0 || draft.toolCount > 0
150
151/** 完了前のターン(ループごと)の途中経過。まだ何も起きていないものは出さない。 */
152function liveRows(drafts: Drafts): PaneRow[] {
153 const started = Object.values(drafts).filter(isStarted)
154 if (started.length === 0) return []
155 return [
156 ...started.map((draft): PaneRow => ({
157 kind: 'kv',
158 label: '実行中',
159 value: `${draft.agentId === null ? 'メイン' : 'サブエージェント'} ${shortModel(draft.model ?? '-')} · 応答 ${draft.stepCount} 回 · ツール ${draft.toolCount} 回 · 推定 ${formatUsd(draft.hasUnpricedStep ? null : draft.pricedCostUsd)}`,
160 tone: 'suggestion',
161 })),
162 { kind: 'blank' },
163 ]
164}
165
166export function buildPaneRows(review: ReviewState, drafts: Drafts = {}): PaneRow[] {
167 const records = review.turns
168 const live = liveRows(drafts)
169 if (records.length === 0) return live.length === 0 ? [{ kind: 'message', text: NO_TURNS_LINE }] : live.slice(0, -1)
170 const { costUsd: estimateUsd, unpricedTurns } = estimateTotal(records)
171 const concerns = findConcerns(records)
172 return [
173 {
174 kind: 'title',
175 turns: records.length,
176 subagents: records.filter(record => record.agentId !== null).length,
177 costText: formatUsd(unpricedTurns === records.length ? null : estimateUsd),
178 },
179 { kind: 'blank' },
180 ...live,
181 { kind: 'heading', text: concernHeading(concerns) },
182 ...concernRows(concerns),
183 { kind: 'blank' },
184 { kind: 'heading', text: 'モデル × effort(コスト比)' },
185 ...groupByModelEffort(records).map(group => groupRow(group, estimateUsd)),
186 { kind: 'blank' },
187 ...topTurnRows(records),
188 { kind: 'heading', text: 'コスト' },
189 ...costRows(review, estimateUsd, unpricedTurns),
190 ...advisorRows(records),
191 { kind: 'blank' },
192 ...buildNotes(review).map((text): PaneRow => ({ kind: 'note', text })),
193 { kind: 'note', text: ALWAYS_NOTE },
194 { kind: 'note', text: CRITERIA_NOTE },
195 ]
196}
197hooks/report.ts 131 lines1// ペインとコマンド出力に載せる文面(日本語)を組み立てる純粋関数。
2import type { ReviewState, TurnRecord } from '../types'
3import { cacheHitRate } from './cache'
4import { formatInt, formatUsd, formatUsdRange } from './format'
5import { ALWAYS_NOTE, advisorCallTotal, buildNotes } from './notes'
6import { PRICE_TABLE_FETCHED_AT } from './pricing'
7import {
8 HEAVY_TOOL_CALLS,
9 LIGHT_MAX_OUTPUT_TOKENS,
10 LIGHT_MAX_TOOL_CALLS,
11 estimateRange,
12 estimateTotal,
13 findConcerns,
14 groupByModelEffort,
15 topCostTurns,
16 type Concern,
17 type ModelGroup,
18} from './analyze'
19
20const CONCERN_TITLE: Readonly<Record<Concern['kind'], string>> = {
21 overspec: '過剰の可能性',
22 underspec: '不足の可能性',
23 delegation: '指揮者モデル運用の確認',
24 switch: 'モデル・effort の切替',
25}
26
27/** 指摘の金額の前に付ける語。金額の意味が種類ごとに違う。 */
28export const CONCERN_AMOUNT_LABEL: Readonly<Record<Concern['kind'], string | null>> = {
29 overspec: '最大',
30 underspec: null,
31 delegation: null,
32 switch: '推定',
33}
34
35export const hitRateText = (tokens: ModelGroup['tokens']): string | null => {
36 const rate = cacheHitRate(tokens)
37 return rate === null ? null : `ヒット ${Math.round(rate * 100)}%`
38}
39
40export const NO_TURNS_LINE ='記録されたターンはまだありません。この Mod の読み込み後に完了したターンが対象です。'
41
42export { formatInt, formatUsd, formatUsdRange }
43
44function groupLine(group: ModelGroup): string {
45 const { tokens } = group
46 const hit = hitRateText(tokens)
47 return [
48 `${group.model} effort ${group.effort ?? '-'}`,
49 `ターン ${group.turns}`,
50 ...(hit === null ? [] : [hit]),
51 `入力 ${formatInt(tokens.input)}`,
52 `出力 ${formatInt(tokens.output)}`,
53 `キャッシュ読 ${formatInt(tokens.cacheRead)}`,
54 `書 ${formatInt(tokens.cacheWrite)}`,
55 `推定 ${formatUsd(group.costUsd)}`,
56 ].join(' ')
57}
58
59function costLines(review: ReviewState, records: readonly TurnRecord[]): string[] {
60 const estimate = estimateTotal(records)
61 const { lowUsd, highUsd } = estimateRange(records)
62 const estimateLine = `合計推定コスト: ${formatUsdRange(lowUsd, highUsd)}(単価不明 ${estimate.unpricedTurns} ターンを除く)`
63 const { baseline, current } = review.sessionCostUsd
64 if (baseline === null || current === null) {
65 return [estimateLine, 'セッション実コスト: 取得できません(コストの記録がない環境)']
66 }
67 const actual = current - baseline
68 return [
69 estimateLine,
70 `セッション実コスト(読み込み後): ${formatUsd(actual)}`,
71 `差(実コスト − 推定): ${formatUsd(actual - estimate.costUsd)}(推定は単価表の値。キャッシュ書込みは 5 分単価で概算し、ターン外の要求は含まない)`,
72 ]
73}
74
75function amountSuffix(concern: Concern): string {
76 const label = CONCERN_AMOUNT_LABEL[concern.kind]
77 return concern.amountUsd === null || label === null ? '' : ` · ${label} ${formatUsd(concern.amountUsd)}`
78}
79
80function concernLines(concerns: readonly Concern[]): string[] {
81 if (concerns.length === 0) return ['指摘はありません。']
82 return concerns.flatMap(concern => [
83 `【${CONCERN_TITLE[concern.kind]}】 ${concern.count} 件${amountSuffix(concern)}`,
84 ...concern.examples.map(example => ` ・${example}`),
85 ])
86}
87
88function topTurnLines(records: readonly TurnRecord[]): string[] {
89 return topCostTurns(records).map(
90 record =>
91 ` ${formatUsd(record.costUsd)} ターン ${record.turnId}(${record.agentId === null ? 'メイン' : 'サブエージェント'})${record.model} ツール ${record.toolCount} 回 · ${record.durationMs} ms`,
92 )
93}
94
95function advisorLines(records: readonly TurnRecord[]): string[] {
96 const calls = advisorCallTotal(records)
97 return calls === 0 ? [] : [`advisor ${calls} 回(費用が usage に載るかは未確認。金額には含めていません)`]
98}
99
100const CRITERIA_LINES = [
101 `判定基準(参考): 軽作業 = ツール ${LIGHT_MAX_TOOL_CALLS} 回以下・編集系なし・出力 ${LIGHT_MAX_OUTPUT_TOKENS} tokens 以下。重い = ツール ${HEAVY_TOOL_CALLS} 回以上、または長時間・エラー。`,
102 `単価は pricing.ts の表(platform.claude.com の価格表、${PRICE_TABLE_FETCHED_AT} 取得)。Haiku 5.5 はプロンプト 10 万 tokens 超の応答で単価が上がるため、応答ごとに判定している。`,
103]
104
105/** ペインに表示する行。記録がなければ案内の 1 行だけを返す。 */
106export function buildReportLines(review: ReviewState): string[] {
107 const records = review.turns
108 if (records.length === 0) return [NO_TURNS_LINE]
109 const subagentCount = records.filter(record => record.agentId !== null).length
110 return [
111 `対象: ${records.length} ターン(サブエージェント ${subagentCount} 件)。Mod 読み込み前のターンは含みません。`,
112 '',
113 '■ モデル × effort 別',
114 ...groupByModelEffort(records).map(groupLine),
115 '',
116 '■ 高コストターン',
117 ...topTurnLines(records),
118 '',
119 '■ コスト',
120 ...costLines(review, records),
121 ...advisorLines(records),
122 ...buildNotes(review),
123 ALWAYS_NOTE,
124 '',
125 '■ 妥当性(参考・ヒューリスティック)',
126 ...concernLines(findConcerns(records)),
127 '',
128 ...CRITERIA_LINES,
129 ]
130}
131hooks/pricing.ts 141 lines1// 単価表。出典: https://platform.claude.com/docs/en/about-claude/pricing (PRICE_TABLE_FETCHED_AT に取得)。
2// 単位は USD / 100 万トークン(MTok)。$ には触れない純粋な表。単価が変わったらここだけを直す。
3// キャッシュ書き込みは 5 分単価を基本にする(TurnUsage は TTL 別の内訳を持たないため)。1 時間単価は上限の見積りにだけ使う。
4
5export type Rates = {
6 readonly input: number
7 readonly output: number
8 readonly cacheRead: number
9 readonly cacheWrite: number
10 /** 1 時間 TTL の書込単価。TTL が分からないので、推定の上限側を出すためだけに使う。 */
11 readonly cacheWrite1h: number
12}
13
14/** プロンプト長(入力 + キャッシュ読み書き)の上限ごとの単価。上限の昇順に並べる。 */
15export type RateTier = {
16 readonly maxPromptTokens: number
17 readonly rates: Rates
18}
19
20export type TokenUsageLike = {
21 readonly model: string
22 readonly input_tokens: number
23 readonly output_tokens: number
24 readonly cache_read_input_tokens: number
25 readonly cache_creation_input_tokens: number
26}
27
28/** 単価表を取得した日。ペインの注記で共用する。単価を直したらここも更新する。 */
29export const PRICE_TABLE_FETCHED_AT = '2026-10-15'
30/** 「Haiku なら」の差額を出すときの基準モデル。 */
31export const HAIKU_REFERENCE_MODEL = 'claude-haiku-5-5'
32/** Opus 系の軽作業で「Sonnet なら」の差額を出すときの基準モデル。 */
33export const SONNET_REFERENCE_MODEL = 'claude-sonnet-5-5'
34
35const TOKENS_PER_MTOK = 1_000_000
36const UNBOUNDED = Number.POSITIVE_INFINITY
37const HAIKU_5_5_PROMPT_TIER_LIMIT = 100_000
38
39const rates = (input: number, cacheWrite: number, cacheRead: number, output: number, cacheWrite1h: number): Rates => ({
40 input,
41 output,
42 cacheRead,
43 cacheWrite,
44 cacheWrite1h,
45})
46const single = (tierRates: Rates): readonly RateTier[] => [{ maxPromptTokens: UNBOUNDED, rates: tierRates }]
47
48const FABLE_5_1 = rates(10, 12.5, 0.25, 50, 20)
49const FABLE_5 = rates(10, 12.5, 1, 50, 20)
50const OPUS_5_5 = rates(4, 5, 0.2, 20, 8)
51const OPUS_5 = rates(5, 6.25, 0.5, 25, 10)
52const OPUS_4 = rates(15, 18.75, 1.5, 75, 30)
53const SONNET_5_5 = rates(2, 2.5, 0.1, 10, 4)
54const SONNET_5 = rates(2, 2.5, 0.2, 10, 4)
55const SONNET_4 = rates(3, 3.75, 0.3, 15, 6)
56const HAIKU_4_5 = rates(1, 1.25, 0.1, 5, 2)
57
58const RATE_TABLE: ReadonlyMap<string, readonly RateTier[]> = new Map<string, readonly RateTier[]>([
59 ['claude-fable-5-1', single(FABLE_5_1)],
60 ['claude-mythos-5-1', single(FABLE_5_1)],
61 ['claude-fable-5', single(FABLE_5)],
62 ['claude-mythos-5', single(FABLE_5)],
63 ['claude-opus-5-5', single(OPUS_5_5)],
64 ['claude-opus-5', single(OPUS_5)],
65 ['claude-opus-4-8', single(OPUS_5)],
66 ['claude-opus-4-7', single(OPUS_5)],
67 ['claude-opus-4-6', single(OPUS_5)],
68 ['claude-opus-4-5', single(OPUS_5)],
69 ['claude-opus-4-1', single(OPUS_4)],
70 ['claude-opus-4-0', single(OPUS_4)],
71 ['claude-sonnet-5-5', single(SONNET_5_5)],
72 ['claude-sonnet-5', single(SONNET_5)],
73 ['claude-sonnet-4-6', single(SONNET_4)],
74 ['claude-sonnet-4-5', single(SONNET_4)],
75 ['claude-sonnet-4-0', single(SONNET_4)],
76 [
77 'claude-haiku-5-5',
78 [
79 { maxPromptTokens: HAIKU_5_5_PROMPT_TIER_LIMIT, rates: rates(0.1, 0.125, 0.01, 0.5, 0.2) },
80 { maxPromptTokens: UNBOUNDED, rates: rates(0.5, 0.625, 0.05, 2.5, 1) },
81 ],
82 ],
83 ['claude-haiku-4-5', single(HAIKU_4_5)],
84])
85
86const BEDROCK_PREFIX = 'anthropic.'
87const BEDROCK_VERSION_SUFFIX = /-v\d+:\d+$/
88const DATE_SUFFIX = /[-@]\d{8}$/
89const BRACKET_SUFFIX = /\[[^\]]*\]$/
90
91/**
92 * API・Bedrock・Vertex・`[1m]` 付きの表記を、単価表のキー(例: `claude-haiku-4-5`)に寄せる。
93 * 例: `claude-haiku-4-5-20251001`、`us.anthropic.claude-opus-4-5-20251101-v1:0`、`claude-opus-5-5[1m]`。
94 */
95export function normalizeModelId(raw: string): string {
96 const trimmed = raw.trim().replace(BRACKET_SUFFIX, '')
97 const prefixAt = trimmed.indexOf(BEDROCK_PREFIX)
98 const unprefixed = prefixAt === -1 ? trimmed : trimmed.slice(prefixAt + BEDROCK_PREFIX.length)
99 return unprefixed.replace(BEDROCK_VERSION_SUFFIX, '').replace(DATE_SUFFIX, '').toLowerCase()
100}
101
102export function ratesFor(model: string, promptTokens: number): Rates | null {
103 const tiers = RATE_TABLE.get(normalizeModelId(model))
104 if (!tiers) return null
105 const tier = tiers.find(candidate => promptTokens <= candidate.maxPromptTokens) ?? tiers[tiers.length - 1]
106 return tier?.rates ?? null
107}
108
109export type CacheWriteTtl = '5m' | '1h'
110
111/**
112 * 1 応答の推定コスト(USD)。単価表にないモデルは null(単価不明)。プロンプト長は応答ごとに判定する。
113 * writeTtl が '1h' なら、書込を 1 時間単価で計算した上限側の値になる(既定は 5 分単価)。
114 */
115export function priceUsage(usage: TokenUsageLike, writeTtl: CacheWriteTtl = '5m'): number | null {
116 const promptTokens = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
117 const tierRates = ratesFor(usage.model, promptTokens)
118 if (!tierRates) return null
119 const cost =
120 usage.input_tokens * tierRates.input +
121 usage.output_tokens * tierRates.output +
122 usage.cache_read_input_tokens * tierRates.cacheRead +
123 usage.cache_creation_input_tokens * (writeTtl === '1h' ? tierRates.cacheWrite1h : tierRates.cacheWrite)
124 return cost / TOKENS_PER_MTOK
125}
126
127/**
128 * キャッシュを読めていれば払わなかった額の推定(USD)。書込トークンを、書込単価と読込単価の差で評価する。
129 * 単価不明のモデルは null。
130 * 単価段はプロンプト全体の長さで決まる(priceUsage と同じ)。省略時は書込トークンだけで判定する。
131 */
132export function cacheRebuildCostUsd(
133 model: string,
134 cacheWriteTokens: number,
135 promptTokens: number = cacheWriteTokens,
136): number | null {
137 const tierRates = ratesFor(model, Math.max(promptTokens, cacheWriteTokens))
138 if (!tierRates) return null
139 return (cacheWriteTokens * (tierRates.cacheWrite - tierRates.cacheRead)) / TOKENS_PER_MTOK
140}
141hooks/analyze.ts 255 lines1// 集計と妥当性の判定。$ に触れない純粋関数。判定は参考(LLM は使わず、閾値は下の名前付き定数)。
2import type { TokenCounts, TurnRecord } from '../types'
3import { formatUsd } from './format'
4import { normalizeModelId } from './pricing'
5import { findSwitches } from './switches'
6
7export const RANK_UNRANKED = -1
8export const RANK_HAIKU = 0
9export const RANK_SONNET = 1
10export const RANK_OPUS = 2
11
12/** 「軽作業」: ツール呼び出しがこの回数以下、編集系なし、出力トークンがこの値以下。 */
13export const LIGHT_MAX_TOOL_CALLS = 3
14export const LIGHT_MAX_OUTPUT_TOKENS = 1_000
15/** 「重い」ターン: ツール呼び出しがこの回数以上、またはこの時間(ms)以上。 */
16export const HEAVY_TOOL_CALLS = 10
17export const LONG_TURN_MS = 120_000
18/** メインループが非 Haiku でこの回数以上ツールを呼び、Haiku のサブエージェントがなければ指摘する。 */
19export const MAIN_DELEGATION_MIN_TOOL_CALLS = 10
20export const HIGH_EFFORTS: ReadonlySet<string> = new Set(['xhigh', 'max'])
21export const LOW_EFFORT = 'low'
22export const EXAMPLE_LIMIT = 3
23/** 「Haiku なら」の差額がこれ未満の軽作業は、モデル起因の過剰として指摘しない(仮置き)。 */
24export const MIN_OVERSPEC_SAVINGS_USD = 0.01
25/** Sonnet の low effort で、エラーがこの回数以上連続したら不足として指摘する(仮置き)。 */
26export const UNDERSPEC_ERROR_STREAK = 2
27export const TOP_COST_TURN_LIMIT = 3
28
29export type ModelGroup = {
30 readonly model: string
31 readonly effort: string | null
32 readonly turns: number
33 readonly unpricedTurns: number
34 readonly tokens: TokenCounts
35 /** 単価不明のターンを含むとき null。 */
36 readonly costUsd: number | null
37}
38
39export type ConcernKind = 'overspec' | 'underspec' | 'delegation' | 'switch'
40
41export type Concern = {
42 readonly kind: ConcernKind
43 readonly count: number
44 readonly examples: readonly string[]
45 /** 金額の目安(USD)。過剰は Haiku 換算の差額の上限。分からなければ null。 */
46 readonly amountUsd: number | null
47}
48
49const ZERO_TOKENS: TokenCounts = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
50
51export function modelRank(model: string): number {
52 const id = normalizeModelId(model)
53 if (id.includes('haiku')) return RANK_HAIKU
54 if (id.includes('sonnet')) return RANK_SONNET
55 if (id.includes('opus') || id.includes('fable') || id.includes('mythos')) return RANK_OPUS
56 return RANK_UNRANKED
57}
58
59const addTokens = (a: TokenCounts, b: TokenCounts): TokenCounts => ({
60 input: a.input + b.input,
61 output: a.output + b.output,
62 cacheRead: a.cacheRead + b.cacheRead,
63 cacheWrite: a.cacheWrite + b.cacheWrite,
64})
65
66const addCost = (total: number | null, cost: number | null): number | null =>
67 total === null || cost === null ? null : total + cost
68
69function addToGroup(group: ModelGroup | undefined, record: TurnRecord): ModelGroup {
70 const base: ModelGroup = group ?? {
71 model: record.model,
72 effort: record.effort,
73 turns: 0,
74 unpricedTurns: 0,
75 tokens: ZERO_TOKENS,
76 costUsd: 0,
77 }
78 return {
79 model: base.model,
80 effort: base.effort,
81 turns: base.turns + 1,
82 unpricedTurns: base.unpricedTurns + (record.costUsd === null ? 1 : 0),
83 tokens: addTokens(base.tokens, record.tokens),
84 costUsd: addCost(base.costUsd, record.costUsd),
85 }
86}
87
88/** モデル × effort 別に集計し、ターン数の多い順に並べる。 */
89export function groupByModelEffort(records: readonly TurnRecord[]): readonly ModelGroup[] {
90 const groups = records.reduce<Readonly<Record<string, ModelGroup>>>((acc, record) => {
91 const key = `${record.model}\u0000${record.effort ?? ''}`
92 return { ...acc, [key]: addToGroup(acc[key], record) }
93 }, {})
94 return Object.values(groups).sort((a, b) => b.turns - a.turns || a.model.localeCompare(b.model))
95}
96
97/** 推定の幅。下限は 5 分単価、上限は書込を 1 時間単価にした値。上限のない古い記録は下限と同じ値で足す。 */
98export function estimateRange(records: readonly TurnRecord[]): { lowUsd: number; highUsd: number } {
99 return records.reduce(
100 (total, record) =>
101 record.costUsd === null
102 ? total
103 : {
104 lowUsd: total.lowUsd + record.costUsd,
105 highUsd: total.highUsd + (record.costUpperUsd ?? record.costUsd),
106 },
107 { lowUsd: 0, highUsd: 0 },
108 )
109}
110
111export function estimateTotal(records: readonly TurnRecord[]): { costUsd: number; unpricedTurns: number } {
112 return records.reduce(
113 (total, record) =>
114 record.costUsd === null
115 ? { costUsd: total.costUsd, unpricedTurns: total.unpricedTurns + 1 }
116 : { costUsd: total.costUsd + record.costUsd, unpricedTurns: total.unpricedTurns },
117 { costUsd: 0, unpricedTurns: 0 },
118 )
119}
120
121const isLightTurn = (record: TurnRecord): boolean =>
122 record.toolCount <= LIGHT_MAX_TOOL_CALLS &&
123 record.editCount === 0 &&
124 record.tokens.output <= LIGHT_MAX_OUTPUT_TOKENS
125
126/** 同じ応答を Haiku 単価で計算した場合との差額(上限)。0.3.0 の記録や単価不明は null。 */
127const haikuSavingsUsd = (record: TurnRecord): number | null => {
128 const haiku = record.haikuCostUsd ?? null
129 if (record.costUsd === null || haiku === null) return null
130 return Math.max(0, record.costUsd - haiku)
131}
132
133/** 同じ応答を Sonnet 単価で計算した場合との差額(上限)。Opus 系のみ意味がある。古い記録や単価不明は null。 */
134const sonnetSavingsUsd = (record: TurnRecord): number | null => {
135 const sonnet = record.sonnetCostUsd ?? null
136 if (record.costUsd === null || sonnet === null || modelRank(record.model) <= RANK_SONNET) return null
137 return Math.max(0, record.costUsd - sonnet)
138}
139
140/** モデル起因: Haiku より上位で、差額が下限以上(差額が分からないときは従来どおり指摘する)。 */
141const isModelOverspec = (record: TurnRecord): boolean => {
142 if (!isLightTurn(record) || modelRank(record.model) <= RANK_HAIKU) return false
143 const savings = haikuSavingsUsd(record)
144 return savings === null || savings >= MIN_OVERSPEC_SAVINGS_USD
145}
146
147/** effort 起因: モデルによらず、差額では絞らない。 */
148const isEffortOverspec = (record: TurnRecord): boolean =>
149 isLightTurn(record) && HIGH_EFFORTS.has(record.effort ?? '')
150
151const isOverspecTurn = (record: TurnRecord): boolean => isModelOverspec(record) || isEffortOverspec(record)
152
153/** 失敗はエラー終了のみ。ユーザーの中断(aborted)はモデルの不足とは言えないので含めない。 */
154const isFailureTurn = (record: TurnRecord): boolean => record.reason === 'error'
155
156const isHaikuUnderspecTurn = (record: TurnRecord): boolean =>
157 (record.toolCount >= HEAVY_TOOL_CALLS || record.durationMs >= LONG_TURN_MS || isFailureTurn(record)) &&
158 modelRank(record.model) === RANK_HAIKU &&
159 record.effort === LOW_EFFORT
160
161/** メインループで Sonnet / low の失敗が連続しているターン(連続 UNDERSPEC_ERROR_STREAK 回目以降)。 */
162function sonnetLowFailureStreakTurns(records: readonly TurnRecord[]): ReadonlySet<TurnRecord> {
163 const scan = records
164 .filter(record => record.agentId === null)
165 .reduce<{ readonly streak: number; readonly hits: readonly TurnRecord[] }>(
166 (acc, record) => {
167 const failed =
168 isFailureTurn(record) && modelRank(record.model) === RANK_SONNET && record.effort === LOW_EFFORT
169 const streak = failed ? acc.streak + 1 : 0
170 const isHit = failed && streak >= UNDERSPEC_ERROR_STREAK
171 return { streak, hits: isHit ? [...acc.hits, record] : acc.hits }
172 },
173 { streak: 0, hits: [] },
174 )
175 return new Set(scan.hits)
176}
177
178const underspecTurns = (records: readonly TurnRecord[]): readonly TurnRecord[] => {
179 const streakTurns = sonnetLowFailureStreakTurns(records)
180 return records.filter(record => isHaikuUnderspecTurn(record) || streakTurns.has(record))
181}
182
183const loopLabel = (record: TurnRecord): string => (record.agentId === null ? 'メイン' : 'サブエージェント')
184
185const overspecLine = (record: TurnRecord): string => {
186 const savings = isModelOverspec(record) ? haikuSavingsUsd(record) : null
187 const sonnetSavings = isModelOverspec(record) ? sonnetSavingsUsd(record) : null
188 const sonnetText = sonnetSavings === null ? '' : ` · Sonnet なら約 ${formatUsd(sonnetSavings)} 減`
189 const haikuText = savings === null ? '' : ` · Haiku なら約 ${formatUsd(savings)} 減(上限)`
190 const savingsText = `${sonnetText}${haikuText}`
191 return `[${loopLabel(record)}] ターン ${record.turnId}: ツール ${record.toolCount} 回・出力 ${record.tokens.output} tokens の軽作業を ${record.model} / effort ${record.effort ?? '-'} で実行${savingsText}`
192}
193
194const underspecLine = (record: TurnRecord): string =>
195 `[${loopLabel(record)}] ターン ${record.turnId}: ツール ${record.toolCount} 回・${record.durationMs} ms・終了理由 ${record.reason} を ${record.model} / effort low で実行`
196
197function concernOf(
198 kind: ConcernKind,
199 count: number,
200 lines: readonly string[],
201 amountUsd: number | null = null,
202): Concern | null {
203 if (count === 0) return null
204 return { kind, count, examples: lines.slice(0, EXAMPLE_LIMIT), amountUsd }
205}
206
207/** 差額の大きい順(差額が分からないものは後ろ)。元の配列は変えない。 */
208const bySavingsDesc = (records: readonly TurnRecord[]): readonly TurnRecord[] =>
209 [...records].sort((a, b) => (haikuSavingsUsd(b) ?? -1) - (haikuSavingsUsd(a) ?? -1))
210
211function overspecConcern(records: readonly TurnRecord[]): Concern | null {
212 const overspec = bySavingsDesc(records.filter(isOverspecTurn))
213 const known = overspec.filter(isModelOverspec).flatMap(record => haikuSavingsUsd(record) ?? [])
214 const amountUsd = known.length === 0 ? null : known.reduce((sum, value) => sum + value, 0)
215 return concernOf('overspec', overspec.length, overspec.map(overspecLine), amountUsd)
216}
217
218function delegationConcern(records: readonly TurnRecord[]): Concern | null {
219 const mainNonHaikuTools = records
220 .filter(record => record.agentId === null && modelRank(record.model) !== RANK_HAIKU)
221 .reduce((sum, record) => sum + record.toolCount, 0)
222 const hasHaikuSubagent = records.some(
223 record => record.agentId !== null && modelRank(record.model) === RANK_HAIKU,
224 )
225 if (mainNonHaikuTools < MAIN_DELEGATION_MIN_TOOL_CALLS || hasHaikuSubagent) return null
226 return concernOf('delegation', 1, [
227 `メインループが非 Haiku モデルでツールを ${mainNonHaikuTools} 回呼んだが、Haiku のサブエージェントへの委譲がない`,
228 ])
229}
230
231function switchConcern(records: readonly TurnRecord[]): Concern | null {
232 const summary = findSwitches(records)
233 return concernOf('switch', summary.count, summary.examples, summary.costUsd)
234}
235
236/** 妥当性の指摘。該当しない種類は含めない。 */
237export function findConcerns(records: readonly TurnRecord[]): readonly Concern[] {
238 const underspec = underspecTurns(records)
239 const concerns = [
240 overspecConcern(records),
241 concernOf('underspec', underspec.length, underspec.map(underspecLine)),
242 delegationConcern(records),
243 switchConcern(records),
244 ]
245 return concerns.filter((concern): concern is Concern => concern !== null)
246}
247
248/** コストの高いターン(単価不明は除く)。降順で上位 limit 件。元の配列は変えない。 */
249export function topCostTurns(records: readonly TurnRecord[], limit: number = TOP_COST_TURN_LIMIT): readonly TurnRecord[] {
250 return records
251 .filter(record => record.costUsd !== null)
252 .sort((a, b) => (b.costUsd ?? 0) - (a.costUsd ?? 0))
253 .slice(0, limit)
254}
255hooks/notes.ts 62 lines1// ペイン・コマンド出力で共用する注記と合計。$ に触れない純粋関数。
2import type { ReviewState, TurnRecord } from '../types'
3import { estimateRange, estimateTotal } from './analyze'
4import { CACHE_REBUILD_STREAK, longestRebuildStreak, ttlUnderestimateSuspected } from './cache'
5import { PRICE_TABLE_FETCHED_AT } from './pricing'
6import { findSwitches } from './switches'
7
8/** 単価不明のターンがこの割合以上なら、単価表が古い可能性を注記する(仮置き)。 */
9export const UNPRICED_STALE_RATIO = 0.3
10
11export const ALWAYS_NOTE =
12 '推定に含まれないもの: fast mode の割増、advisor の費用(未確認)。キャッシュ書込の TTL は分からないため、推定は 5 分単価(下限)〜1 時間単価(上限)の幅で示します。'
13
14export const advisorCallTotal = (records: readonly TurnRecord[]): number =>
15 records.reduce((sum, record) => sum + (record.advisorCalls ?? 0), 0)
16
17const cacheWriteTotal = (records: readonly TurnRecord[]): number =>
18 records.reduce((sum, record) => sum + record.tokens.cacheWrite, 0)
19
20function rebuildStreakNote(records: readonly TurnRecord[]): readonly string[] {
21 const streak = longestRebuildStreak(records)
22 if (streak < CACHE_REBUILD_STREAK) return []
23 return [
24 `キャッシュ書込が読込を上回るターンが ${streak} 回連続しています(プレフィックスが変わり続けている可能性。モデル・effort・MCP・プラグインの変更を確認)`,
25 ]
26}
27
28function ttlNote(review: ReviewState, upperUsd: number): readonly string[] {
29 const { baseline, current } = review.sessionCostUsd
30 if (baseline === null || current === null) return []
31 const suspected = ttlUnderestimateSuspected({
32 actualUsd: current - baseline,
33 estimateUsd: upperUsd,
34 cacheWriteTokens: cacheWriteTotal(review.turns),
35 })
36 if (!suspected) return []
37 return ['実コストが、キャッシュ書込を 1 時間単価にした上限の推定も大きく上回っています。fast mode・advisor・ターン外の要求・単価表の古さなどが考えられます']
38}
39
40function staleNote(records: readonly TurnRecord[], unpricedTurns: number): readonly string[] {
41 if (unpricedTurns / records.length < UNPRICED_STALE_RATIO) return []
42 return [`単価不明のターンが ${unpricedTurns} / ${records.length} 件あります。単価表が古い可能性があります(pricing.ts の取得日: ${PRICE_TABLE_FETCHED_AT})`]
43}
44
45function switchNote(records: readonly TurnRecord[]): readonly string[] {
46 if (findSwitches(records).count === 0) return []
47 return ['切替の推定は、切替直後の最初の応答のキャッシュ書込から算出しています。長い中断によるキャッシュ切れとは区別できません。Bedrock・Vertex・gateway 経由では effort 変更でもキャッシュは維持されません']
48}
49
50/** 状況に応じた注記(常に出す 1 行を除く)。記録がなければ空。 */
51export function buildNotes(review: ReviewState): readonly string[] {
52 const records = review.turns
53 if (records.length === 0) return []
54 const { unpricedTurns } = estimateTotal(records)
55 return [
56 ...rebuildStreakNote(records),
57 ...ttlNote(review, estimateRange(records).highUsd),
58 ...staleNote(records, unpricedTurns),
59 ...switchNote(records),
60 ]
61}
62hooks/cache.ts 42 lines1// キャッシュまわりの判定。$ に触れない純粋関数。閾値は名前付き定数(仮置き)。
2import type { TokenCounts, TurnRecord } from '../types'
3
4/** 書込が読込を上回るターンがこの回数以上続いたら、プレフィックスが変わり続けている可能性として注記する。 */
5export const CACHE_REBUILD_STREAK = 3
6/** 実コストが推定をこの割合より多く上回るとき、TTL 単価差(1 時間書込は 5 分書込の 1.6 倍)を疑う。 */
7export const CACHE_TTL_DIFF_RATIO = 0.2
8/** TTL 単価差を疑うために必要な、キャッシュ書込の合計トークン数。 */
9export const CACHE_TTL_MIN_WRITE_TOKENS = 50_000
10
11/** キャッシュ読込 ÷(入力 + 読込 + 書込)。分母が 0 なら null。 */
12export function cacheHitRate(tokens: TokenCounts): number | null {
13 const promptTokens = tokens.input + tokens.cacheRead + tokens.cacheWrite
14 return promptTokens === 0 ? null : tokens.cacheRead / promptTokens
15}
16
17/** メインループで、書込が読込を上回るターンの最長の連続回数。 */
18export function longestRebuildStreak(records: readonly TurnRecord[]): number {
19 const scan = records
20 .filter(record => record.agentId === null)
21 .reduce(
22 (acc, record) => {
23 const streak = record.tokens.cacheWrite > record.tokens.cacheRead ? acc.streak + 1 : 0
24 return { streak, longest: Math.max(acc.longest, streak) }
25 },
26 { streak: 0, longest: 0 },
27 )
28 return scan.longest
29}
30
31export type TtlCheck = {
32 readonly actualUsd: number
33 readonly estimateUsd: number
34 readonly cacheWriteTokens: number
35}
36
37/** 推定が 5 分書込単価の概算で、実際は 1 時間書込(サブスクのメイン会話)だった可能性があるか。 */
38export function ttlUnderestimateSuspected(check: TtlCheck): boolean {
39 if (check.estimateUsd <= 0 || check.cacheWriteTokens < CACHE_TTL_MIN_WRITE_TOKENS) return false
40 return (check.actualUsd - check.estimateUsd) / check.estimateUsd > CACHE_TTL_DIFF_RATIO
41}
42hooks/format.ts 16 lines1// 表示用の数値整形。純粋関数。
2export function formatInt(value: number): string {
3 return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
4}
5
6/** 推定の幅。上限が下限と(表示上)同じなら単独の値にする。 */
7export function formatUsdRange(lowUsd: number, highUsd: number): string {
8 const low = formatUsd(lowUsd)
9 const high = formatUsd(highUsd)
10 return low === high ? low : `${low}〜${high}`
11}
12
13export function formatUsd(value: number | null): string {
14 return value === null ? '単価不明' : `$${value.toFixed(4)}`
15}
16hooks/switches.ts 83 lines1// モデル・effort の切替と、その直後のキャッシュ再構築コストの推定。$ に触れない純粋関数。
2// 切替直後のターンの最初の応答の書込トークンを、読めていれば払わなかった額として評価する。
3// 長い中断によるキャッシュ切れ(TTL)とは区別できないので、金額はあくまで推定。
4import type { TurnRecord } from '../types'
5import { formatUsd } from './format'
6import { cacheRebuildCostUsd, normalizeModelId } from './pricing'
7import { UNKNOWN_MODEL } from './recorder'
8
9/** API キー・サブスク経由で、effort を変えてもキャッシュが維持されるモデル(公式ドキュメントの記載)。 */
10export const CACHE_SAFE_EFFORT_MODELS: ReadonlySet<string> = new Set([
11 'claude-opus-5-5',
12 'claude-sonnet-5-5',
13 'claude-haiku-5-5',
14 'claude-fable-5-1',
15])
16/** Bedrock(`anthropic.`)・Vertex(`@`)の表記。これらでは effort を変えるとキャッシュが維持されない。 */
17const THIRD_PARTY_MARKERS: readonly string[] = ['anthropic.', '@']
18export const SWITCH_EXAMPLE_LIMIT = 3
19
20type SwitchKind = 'model' | 'effort'
21
22type SwitchEvent = {
23 readonly turnId: string
24 readonly text: string
25 readonly costUsd: number | null
26}
27
28export type SwitchSummary = {
29 readonly count: number
30 /** 再構築コストの推定合計。単価がわかる切替がなければ null。 */
31 readonly costUsd: number | null
32 readonly examples: readonly string[]
33}
34
35const isThirdPartyId = (model: string): boolean => THIRD_PARTY_MARKERS.some(marker => model.includes(marker))
36
37const keepsCacheOnEffortChange = (model: string): boolean =>
38 !isThirdPartyId(model) && CACHE_SAFE_EFFORT_MODELS.has(normalizeModelId(model))
39
40function switchKind(prev: TurnRecord, current: TurnRecord): SwitchKind | null {
41 if (prev.model === UNKNOWN_MODEL || current.model === UNKNOWN_MODEL) return null
42 if (normalizeModelId(prev.model) !== normalizeModelId(current.model)) return 'model'
43 if (prev.effort === current.effort || keepsCacheOnEffortChange(current.model)) return null
44 return 'effort'
45}
46
47function describe(kind: SwitchKind, prev: TurnRecord, current: TurnRecord): string {
48 const change =
49 kind === 'model'
50 ? `${prev.model} → ${current.model}`
51 : `effort ${prev.effort ?? '-'} → ${current.effort ?? '-'}(${current.model})`
52 return `ターン ${current.turnId}: ${change}`
53}
54
55function eventOf(prev: TurnRecord, current: TurnRecord): SwitchEvent | null {
56 const kind = switchKind(prev, current)
57 if (kind === null) return null
58 // 0.3.0 の記録(firstStepCacheWrite なし)は不明(null)として扱い、0 円とは読ませない。
59 const costUsd =
60 current.firstStepCacheWrite === undefined
61 ? null
62 : cacheRebuildCostUsd(current.model, current.firstStepCacheWrite, current.firstStepPromptTokens)
63 const costText = costUsd === null ? '' : ` · 再構築の推定 ${formatUsd(costUsd)}`
64 return { turnId: current.turnId, text: `${describe(kind, prev, current)}${costText}`, costUsd }
65}
66
67/** メインループの切替を数える。サブエージェントのターンは対象外。 */
68export function findSwitches(records: readonly TurnRecord[]): SwitchSummary {
69 const main = records.filter(record => record.agentId === null)
70 const events = main
71 .flatMap((record, index) => {
72 const prev = main[index - 1]
73 return prev === undefined ? [] : (eventOf(prev, record) ?? [])
74 })
75 .sort((a, b) => (b.costUsd ?? -1) - (a.costUsd ?? -1))
76 const known = events.flatMap(event => event.costUsd ?? [])
77 return {
78 count: events.length,
79 costUsd: known.length === 0 ? null : known.reduce((sum, value) => sum + value, 0),
80 examples: events.slice(0, SWITCH_EXAMPLE_LIMIT).map(event => event.text),
81 }
82}
83