SLOPSHOPPER

model-review

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

newpaneguardcommand
v0.6.1no licenseupdated 2026-10-15dtakamiya/claude-code-mods/mods/model-review
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · model-review
│ ┃ モデル振り返り ✕ › fix the failing auth test and add an audit log call │ ┃ 1 ターン · サブエージェント 0 · 推定 $0… │ ┃ ⏺ Read(src/auth.ts) │ ┃ ▎妥当性(参考) ⎿ Read 6 lines │ ┃ ✓ 指摘なし ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ▎モデル × effort(コスト比) ⏺ Bash(bun test) │ ┃ opus-5-5 effort - 1 ターン ⎿ 3 pass, 1 fail │ ┃ ██████████████ $0.0777 100% │ ┃ ヒット 93% · 入力 2,100 · 出力 1,480 · キ… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ▎高コストターン ✻ Worked for 42s · done 4:20 PM │ ┃ opus-5-5 $0.0777 turn-1 · メイン · ツール… │ ┃ › /model-review │ ┃ ▎コスト ⎿ model-review: モデル振り返りペインを開きました。 │ ┃ コスト 推定 $0.0777〜$0.0906 / 実 │ ┃ $0.0000(読み込み後)/ 差 -$0.0777 │ ┃ │ ┃ 推定に含まれないもの: fast mode │ ┃ の割増、advisor │ ┃ の費用(未確認)。キャッシュ書込の TTL │ ┃ は分からないため、推定は 5 分単価(下限)〜1 │ ┃ 時間単価(上限)の幅で示します。 │ ┃ 軽作業 = ツール 3 回以下・編集なし・出力 │ ┃ 1000 tokens 以下 / 重い = ツール 10 │ ┃ 回以上・長時間・エラー ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · モデル振り返り
1 ターン · サブエージェント 0 · 推定 $0.0777 ▎妥当性(参考) ✓ 指摘なし ▎モデル × effort(コスト比) opus-5-5 effort - 1 ターン ██████████████ $0.0777 100% ヒット 93% · 入力 2,100 · 出力 1,480 · キャッシュ 読 91,0… ▎高コストターン opus-5-5 $0.0777 turn-1 · メイン · ツール 9 回 · 42000 ms ▎コスト コスト 推定 $0.0777〜$0.0906 / 実 $0.0000(読み込み後)/ 差 -$0.0777 推定に含まれないもの: fast mode の割増、advisor の費用(未確認)。キャッシュ書込の TTL は分からないため、推定は 5 分単価(下限)〜1 時間単価(上限)の幅で示します。 軽作業 = ツール 3 回以下・編集なし・出力 1000 tokens 以下 / 重い = ツール 10 回以上・長時間・エラー
README

claude-code-mods

Claude Code の Mods(関数フック型プラグイン)を作って配布するリポジトリ。1リポジトリ = 1マーケットプレイス、mods/<name>/ が1 mod。

収録 mod

mod内容コマンド
context-doctorコンテキストの内訳を分析し、課題点と改善点を出す(読み取り専用)/ctx
session-recapツール別の呼び出し回数とエラー報告件数を一覧にする(読み取り専用・tool 名の集計のみ、input は出力しない)/recap
repeat-call-notice1 ターンで同じツールを同じ入力で N 回以上呼んだら、ターン終了時にトーストで知らせる(読み取り専用・tool 名と回数のみ、input は出力しない。N=3 は仮置き)トースト
model-review現在のセッションのモデル・effort・トークン・推定コストを表示し、モデル選択の妥当性を参考として示す。公式 /usage にないモデル×effort 別コスト、Haiku 換算の差額、モデル/effort 切替のキャッシュ再構築コスト、キャッシュヒット率、実行中ターンのリアルタイム表示を持つ(読み取り専用・ヒューリスティック。Mod 読み込み後のターンだけが対象)/model-review

公開範囲(private リポジトリ)

このリポジトリは現在 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。

Source 13 files
hooks/register.tsx 93 lines
1import { 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}
93
hooks/pane.tsx 102 lines
1// 行モデル → 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}
102
hooks/state.ts 22 lines
1// $.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}
22
hooks/recorder.ts 217 lines
1// ターンの記録を組み立てる純粋な 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}
217
hooks/pane-model.ts 197 lines
1// ペインの行モデルを組み立てる純粋関数。`$` にも 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}
197
hooks/report.ts 131 lines
1// ペインとコマンド出力に載せる文面(日本語)を組み立てる純粋関数。
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}
131
hooks/pricing.ts 141 lines
1// 単価表。出典: 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}
141
hooks/analyze.ts 255 lines
1// 集計と妥当性の判定。$ に触れない純粋関数。判定は参考(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}
255
hooks/notes.ts 62 lines
1// ペイン・コマンド出力で共用する注記と合計。$ に触れない純粋関数。
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}
62
hooks/cache.ts 42 lines
1// キャッシュまわりの判定。$ に触れない純粋関数。閾値は名前付き定数(仮置き)。
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}
42
hooks/format.ts 16 lines
1// 表示用の数値整形。純粋関数。
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}
16
hooks/switches.ts 83 lines
1// モデル・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