依頼の難しさを Haiku が判定し、Claude の考える深さ(effort)を依頼ごとに自動で切り替える mod

Claude Codeは、どの依頼も同じ「考える深さ」の設定で動く作りです。 誤字を1か所直すだけの依頼でも、設計を決める依頼と同じ設定なので、必要以上に考えて返事が遅くなることがあります。
effort-autoは、依頼を送るたびにHaiku(軽くて安いモデル)が依頼の難しさを判定し、Claudeの考える深さ(effort)をその依頼に合わせて切り替えます。

| 判定 | 考える深さ | 当てはまる依頼の例 |
|---|---|---|
| 簡単 | 中 | 一言で答えられる質問、誤字の修正、ファイルを開くだけの依頼 |
| 普通 | 高 | メールや資料の文章、調べもの、アイデア出しや相談 |
| 難しい | 最大 | 企画や設計の全体、原因の調査、数字の集計、本番環境やお金や顧客データが関わる作業 |
作者の1日分の記録では、速くなり、トークンも減りました(どちらも中央値)。
| 判定 | 返事までの時間 | 考えるのに使うトークン |
|---|---|---|
| 簡単 | 24秒から10秒に | 695から234に |
| 普通 | 84秒から44秒に | 3,664から867に |
深さを下げた92回分では、使った量全体が2〜3割ほど減った見込みです。 判定は1回1秒ほどです。 Haikuが使うトークンは1回あたり入力1,000前後(長い依頼では3,000ほど)、出力20〜30で、本体の作業に比べてわずかです。
Claude Codeの入力欄で次の1行を実行します。
/plugin install effort-auto --marketplace k-hide10/claude-code-plugins
シェルから入れる場合は次の2行です。
claude plugin marketplace add k-hide10/claude-code-plugins
claude plugin install effort-auto@hide10
デスクトップアプリで/pluginと打つと設定の「プラグイン」画面が開きますが、この画面からは入れられません。 代わりに、新しいセッションでClaudeに次の文を送ってください。 claudeコマンドを入れていなくても、デスクトップアプリに入っているClaude Codeを使って入れられます(Macで確認済み)。
Claude Code のプラグイン effort-auto を入れたいです。次のとおり進めて。
1. claude コマンドがあればそれを使う。なければ、デスクトップアプリに入っている Claude Code を使う
(~/Library/Application Support/Claude/claude-code/ の中の claude.app/Contents/MacOS/claude で、一番新しい版のもの)
2. その claude で、次の 2 つを順に実行する
plugin marketplace add k-hide10/claude-code-plugins
plugin install effort-auto@hide10
3. plugin list で effort-auto@hide10 が入ったか確かめて、結果を教えて
実行してよいか聞かれたら、許可してください。
どちらの場合も、入れたあとに始めたセッションから動きます。
ふだんどおりに依頼を送るだけです。 判定の結果は、入力欄の下に次のように表示されます。

返答が終わると、かかった時間も足されます。
考える深さ: 中(簡単: 誤字の修正)
考える深さ: 中(簡単: 誤字の修正)・12秒で回答
依頼の頭に言葉を付けると、判定を待たずに深さを決められます。
| コマンド | すること |
|---|---|
/effort-auto | 直近10件の判定を見る |
/effort-auto off | 自動の切り替えを止める |
/effort-auto on | 自動の切り替えを再開する |
次の場合は判定せず、いつもの深さ(Claude Codeの設定)のまま動きます。
/ で始まるスラッシュコマンド、バックグラウンド作業の完了通知、ほかのセッションからの連絡、定期実行など、人が打った依頼ではないものアプリを開き直したときに自動で送られる続きの依頼や「もう一度試す」は判定せず、直前の判定をそのまま使います。 サブエージェントの深さは変えません。
判定に合わせて、いつもの深さより下げることも、上げることもあります。 いつもの深さを中や低にして節約している人は、難しい依頼で最大まで上がるぶん、消費が増えることがあります。
判定の基準は、日本語の業務(企画、資料、文章、データ分析)と開発の依頼で調整しました。 職種の違う40件の依頼(エンジニア、デザイナー、営業、問い合わせ対応、人事、経理、マーケティング)を、1件につき3回ずつ判定させたところ、39件が3回とも想定どおりでした。 外れた1件(作ったメールの送信を了承する返事)も、想定より深く考える側の外れです。 難しい依頼を「簡単」と見誤ったものはありませんでした。 ずれに気づいたら、GitHubのIssuesで教えてください。
この条件にしているのは、キャッシュ(前回までのやり取りの使い回し)のためです。 公式ドキュメントによると、上のモデル以外では深さを変えるたびにキャッシュが作り直しになり、長い会話ではかえって高くつきます。 そのため、上のモデル以外では深さを変えません。 Amazon Bedrock、Google Cloud、Microsoft Foundry、Anthropic以外のゲートウェイを経由する設定が入っているときは、上のモデルでもキャッシュが作り直しになるので、判定も切り替えもしません(入力欄の下に「この接続方法は対象外」と出ます)。 組織でHIPAAの設定をしている場合も同じですが、mod側では見分けられないので入れないでください。
判定ごとに、~/.claude/mod-data/effort-auto.jsonl に1行ずつ記録します。 中身は、依頼の先頭60字、判定、理由、考える深さ、かかった秒数、使ったトークン数です。 記録はこのファイルにだけ書き、ほかへは送りません。 判定のために、依頼文(2,000字を超える場合は頭の1,200字と終わりの800字)と直前のClaudeの返答(最後の1,200字)をHaikuに渡します。 これはClaude Codeのふだんのやり取りと同じく、自分のアカウントでAnthropicに送られます。
modは、使う人のパソコンの上で、その人と同じ権限で動きます。 入れる前に、claude plugin validate で何をするmodなのかを確認できます。
claude plugin validate .hooks/register.ts 381 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// 依頼の難しさと、そのときに使う考える深さ(effort)
4type Level = '簡単' | '普通' | '難しい'
5type Effort = 'medium' | 'high' | 'max'
6type Decision = { level: Level; effort: Effort; reason: string; source: 'auto' | 'prefix' | 'continue'; prompt: string; origin?: string; retried?: boolean; judgeMs?: number; judgeTokens?: { input: number; output: number; cacheRead: number } }
7type Step = { effort: Effort; cacheRead: number; cacheWrite: number; output: number }
8// 判定しないときに使われる深さ(turn.step に届く、差し替える前の値)
9type Usual = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | number
10
11const EFFORT: Record<Level, Effort> = { 簡単: 'medium', 普通: 'high', 難しい: 'max' }
12const LABEL: Record<string, string> = { low: '低', medium: '中', high: '高', max: '最大' }
13
14// 深さの表示名。まだ分からないときは「いつもの深さ」、名前を決めていないもの(xhigh や数値)はそのまま出す
15function label(effort: Usual | undefined): string {
16 return effort === undefined ? 'いつもの深さ' : (LABEL[String(effort)] ?? String(effort))
17}
18
19// 判定の記録はホームの ~/.claude/mod-data/ に置く(使う人ごとの場所。mod のフォルダの中だと再読み込みが起きる)
20const LOG_FILE = '.claude/mod-data/effort-auto.jsonl'
21
22// effort を変えてもキャッシュが残るモデル(公式 prompt-caching ドキュメント)。それ以外では深さを変えない
23const CACHE_SAFE_MODEL = /(opus|sonnet)-5-5|fable-5-1/
24
25// 上のモデルでも、Bedrock、Google Cloud、ゲートウェイなどを経由するとキャッシュが作り直しになる。
26// その経路を選ぶ設定が入っていたら、判定も切り替えもしない
27function isOnFlag(value: string | undefined): boolean {
28 const v = value?.trim().toLowerCase()
29 return !!v && v !== '0' && v !== 'false'
30}
31
32async function isCacheSafeRoute($: EngineInterface): Promise<boolean> {
33 // 読む設定の名前は、入れる前に一覧できるよう 1 つずつ書く
34 const flags = [
35 await $.env.get('CLAUDE_CODE_USE_BEDROCK'),
36 await $.env.get('CLAUDE_CODE_USE_VERTEX'),
37 await $.env.get('CLAUDE_CODE_USE_FOUNDRY'),
38 await $.env.get('CLAUDE_CODE_USE_MANTLE'),
39 await $.env.get('CLAUDE_CODE_USE_ANTHROPIC_AWS'),
40 await $.env.get('CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS'),
41 ]
42 if (flags.some(isOnFlag)) {
43 return false
44 }
45 // 接続先を Anthropic 以外(社内のゲートウェイなど)に向けている場合も対象外
46 const baseUrl = (await $.env.get('ANTHROPIC_BASE_URL'))?.trim()
47 return !baseUrl || /^https:\/\/api\.anthropic\.com\/?$/.test(baseUrl)
48}
49
50// 接続の経路はセッションの途中で変わらないので、始まったときに 1 回だけ調べる
51let routeOk = true
52
53// 人が打った依頼ではないもの(バックグラウンドの通知、ほかのセッションからの連絡、定期実行、観察役、mod)は判定しない
54const SKIP_ORIGINS = new Set([
55 'task-notification',
56 'peer',
57 'peer-send-message',
58 'projects-relay',
59 'coordinator',
60 'scheduled-trigger',
61 'auto-continuation',
62 'observer',
63 'observer-activity',
64 'plugin',
65])
66
67// システムが送る形式の依頼(スラッシュコマンドの記録、通知、ほかのセッションからの連絡)は判定しない
68const SYSTEM_TAG = /^<(command-|local-command|task-notification|system-reminder|bash-|cross-session-message)/
69
70// 先頭の見えない注記は、判定の前に外す
71// デスクトップの「返信」でつく <!-- reply 1 -->、ワークツリーで始めたセッションの最初の依頼につく <system-reminder>
72function cleanText(raw: string): string {
73 return raw.replace(/^(\s*(<!--[\s\S]*?-->|<system-reminder>[\s\S]*?<\/system-reminder>))+/, '').trim()
74}
75
76// アプリを開き直したときの「The app was quit…」や「もう一度試す」ボタンは、中身のない続きの依頼。
77// 判定せず、直前の判定をそのまま使う(直前の判定がなければ、いつもの深さ)
78const CONTINUE = /^(The app was quit while you were working|もう一度試す$|Try again$)/
79
80// 判定から 2 分以上たっても始まらなかったターンには使わない
81const PENDING_TTL_MS = 120_000
82
83const SYSTEM = [
84 'あなたは、Claude Code(AI の業務アシスタント)に届いた依頼の難しさを判定する係です。',
85 '依頼は開発だけでなく、企画、資料づくり、文章、データ分析、事務作業など仕事全般です。',
86 'Claude がどれだけ深く考えるべきかを「簡単」「普通」「難しい」の 3 つから 1 つ選びます。',
87 '',
88 '簡単: 答えが一言で済む質問や確認、相づちやお礼、止める・待つ・やめるの指示、誤字や言い回しの小さな修正、画像のサイズ変更や形式の変換のような 1 回で終わる決まった操作、ファイルやページを開く・見せる・探すだけの依頼',
89 '普通: 文章の作成や推敲(メール、返信、記事、資料の一部)、質問リストやチェックリストの作成、調べものやまとめ、アイデア出しや相談、決まった手順の作業、いくつかのファイルの修正',
90 '難しい: 全体の形を決める企画や設計(事業、プロジェクト、イベント、研修、資料全体の構成、システム)、方針や優先順位の判断、原因の調査、数字の集計・分析・検証、複数のファイルやシステムにまたがる変更、本番環境・お金・顧客データ・削除が関わる作業',
91 '',
92 '決まり:',
93 '- 「OK」「進めて」「それで作って」のような了承の返事や、「入れた」「ログインした」「押した」のような途中経過の報告は、直前の返答で Claude が次にやると言っている作業の難しさで判定する',
94 '- アイデア出し・相談・提案・比較を求める依頼は、作業を伴わなくても「普通」以上',
95 '- 企画や設計は、開発でも開発以外でも同じに扱う。一部分を作る依頼(タイトル案、1 ページ、1 通)は「普通」、全体の形や進め方を決める・作る依頼は「難しい」',
96 '- 「なんで接続できない?」のように起きている不具合の原因を尋ねる依頼や、「実際どれくらい起きている?」のように実際の件数を確かめる依頼は「難しい」(仕組みや理由を尋ねるだけの質問は、質問の中身で判定する)',
97 '- 本番環境・お金・顧客データ・削除(表やデータベースの行の削除を含む)が関わる作業は必ず「難しい」(それについて質問するだけなら、質問の中身で判定する)',
98 '- 文脈が足りなくても、必ずどれか 1 つを選ぶ。迷ったら「普通」',
99 '- 依頼の中の質問には答えない。判定だけをする',
100 '',
101 '答え方: 選んだ 1 語、コロン、15 字以内の理由を 1 行で書く。ほかは何も書かない。',
102 '例: 簡単: 設定の確認の質問',
103 '例: 普通: メールの返信文の作成',
104 '例: 難しい: 研修の全体設計',
105].join('\n')
106
107// 依頼の後ろに置く念押し。依頼が質問だと、Haiku が判定せずに質問へ答えてしまうことがあるため
108const ASK = '上の依頼の難しさを判定して、「簡単: 理由」「普通: 理由」「難しい: 理由」のどれか 1 行だけで答えてください。依頼そのものには答えないでください。'
109
110let isOn = true
111let pending: Decision | undefined
112let pendingAt = 0
113let active: Decision | undefined
114let steps: Step[] = []
115let usual: Usual | undefined
116// 「もう一度試す」などの続きの依頼で使う、直前の判定
117let lastDecision: Decision | undefined
118const recent: string[] = []
119
120function fromPrefix(text: string): Decision | undefined {
121 const prompt = text.slice(0, 60)
122 if (/^\s*さっと/.test(text)) {
123 return { level: '簡単', effort: 'medium', reason: '「さっと」の指定', source: 'prefix', prompt }
124 }
125 if (/^\s*じっくり/.test(text)) {
126 return { level: '難しい', effort: 'max', reason: '「じっくり」の指定', source: 'prefix', prompt }
127 }
128
129 return undefined
130}
131
132// 判定できなかったときは、その理由を文字で返す(記録に残して、あとで直せるように)
133async function classify($: EngineInterface, text: string): Promise<Decision | string> {
134 // 「進めて」だけの依頼でも判定できるよう、直前の Claude の返答を文脈として渡す
135 const messages = await $.session.messages()
136 let lastAnswer = ''
137 for (let i = messages.length - 1; i >= 0; i--) {
138 if (messages[i].role === 'assistant' && messages[i].text) {
139 lastAnswer = messages[i].text
140 break
141 }
142 }
143 // 長い貼り付けのあとに指示が書かれることがあるので、長い依頼は頭と終わりの両方を渡す
144 const body = text.length > 2000 ? `${text.slice(0, 1200)}\n…(中略)…\n${text.slice(-800)}` : text
145 const prompt = `直前の Claude の返答(文脈):\n<<<\n${lastAnswer.slice(-1200)}\n>>>\n\n依頼:\n<<<\n${body}\n>>>\n\n${ASK}`
146
147 // 判定に使ったトークン数も残す(どれだけ消費しているかを後で確かめるため)。聞き直した分も足す
148 const judgeTokens = { input: 0, output: 0, cacheRead: 0 }
149 let why = ''
150 // 3 語のどれも書かずに返すことがまれにあるので、そのときだけ 1 回聞き直す
151 for (let attempt = 1; attempt <= 2; attempt++) {
152 const reply = await $.model.complete({ model: 'haiku', system: SYSTEM, prompt, maxTokens: 60, timeoutMs: 5000 })
153 judgeTokens.input += reply.usage.input_tokens + reply.usage.cache_creation_input_tokens
154 judgeTokens.output += reply.usage.output_tokens
155 judgeTokens.cacheRead += reply.usage.cache_read_input_tokens
156 if (!reply.isAnswered) {
157 if (reply.reason === 'empty-reply') {
158 why = reply.reason
159 continue
160 }
161
162 // API の不調や時間切れは、聞き直しても待ち時間が延びるだけなので、ここで諦める
163 return reply.reason === 'api-error' ? `api-error ${reply.status ?? '-'} ${reply.error}` : reply.reason
164 }
165
166 // 行頭の「普通:」を先に探し、なければ文中から探す(「分類: 普通」のように見出しがつくことがある)
167 const match =
168 reply.text.match(/^\s*(?:分類\s*[::]\s*)?(簡単|普通|難しい)\s*[::]?\s*([^\n]*)/m) ?? reply.text.match(/(簡単|普通|難しい)\s*[::]?\s*([^\n]*)/)
169 if (!match) {
170 why = `no-match: ${reply.text.replace(/\s+/g, ' ').slice(0, 40)}`
171 continue
172 }
173 const level = match[1] as Level
174
175 // 「理由:」の見出しや区切り記号が前につくことがあるので取り除く
176 const reason = match[2].replace(/^[\s||//::。、-]*(理由\s*[::]\s*)?/, '').trim().slice(0, 20)
177
178 return { level, effort: EFFORT[level], reason, source: 'auto', prompt: text.slice(0, 60), judgeTokens, ...(attempt > 1 ? { retried: true } : {}) }
179 }
180
181 return why
182}
183
184async function appendLog($: EngineInterface, entry: Record<string, unknown>) {
185 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
186 if (!home) {
187 return
188 }
189 const path = `${home}/${LOG_FILE}`
190 let text = ''
191 try {
192 text = String(await $.fs.read(path))
193 } catch {
194 text = ''
195 }
196 await $.fs.write(path, `${text}${JSON.stringify(entry)}\n`)
197}
198
199export const register: Register = on => {
200 on('session.start', async ($, e, next) => {
201 await $.command.register({
202 name: 'effort-auto',
203 description: '考える深さの自動振り分け: 直近の判定を見る(on / off で切り替え)',
204 argumentHint: '[on|off]',
205 })
206 routeOk = await isCacheSafeRoute($)
207 $.ui.status(routeOk ? '考える深さ: 自動振り分けオン' : '考える深さ: この接続方法は対象外のため、自動振り分けは動きません')
208
209 return next(e)
210 })
211
212 on('prompt.submit', async ($, e, next) => {
213 if (!isOn) {
214 return next(e)
215 }
216 // 作業中に送った依頼は、動いているターンの中で読まれるので深さを変えられない。判定も表示の書き換えもしない
217 if (e.turnId !== undefined && !e.wait) {
218 return next(e)
219 }
220 if (!routeOk) {
221 pending = undefined
222 $.ui.status(`考える深さ: ${label(usual)}のまま(この接続方法は対象外)`)
223
224 return next(e)
225 }
226 if (!CACHE_SAFE_MODEL.test(await $.session.model())) {
227 pending = undefined
228 $.ui.status(`考える深さ: ${label(usual)}のまま(このモデルは対象外)`)
229
230 return next(e)
231 }
232 const text = cleanText(e.text)
233 if (SKIP_ORIGINS.has(e.origin.kind) || text === '' || text.startsWith('/') || SYSTEM_TAG.test(text)) {
234 pending = undefined
235 // 前の判定が表示に残らないよう、判定しなかったことを出す
236 $.ui.status(`考える深さ: ${label(usual)}のまま(判定なし)`)
237
238 return next(e)
239 }
240 if (CONTINUE.test(text)) {
241 pendingAt = await $.clock.now()
242 pending = lastDecision ? { ...lastDecision, reason: '前の依頼の続き', source: 'continue', prompt: text.slice(0, 60), judgeMs: 0, judgeTokens: undefined } : undefined
243 $.ui.status(pending ? `考える深さ: ${label(pending.effort)}(${pending.level}: 前の依頼の続き)` : `考える深さ: ${label(usual)}のまま(前の依頼の続き)`)
244
245 return next(e)
246 }
247 const startedAt = await $.clock.now()
248 let judged: Decision | string
249 try {
250 judged = fromPrefix(text) ?? (await classify($, text))
251 } catch (error) {
252 judged = `error: ${error instanceof Error ? error.name : 'unknown'}`
253 }
254 pendingAt = await $.clock.now()
255 // 判定にかかった時間も残す(待ち時間を増やしていないかを後で確かめるため)
256 const judgeMs = pendingAt - startedAt
257 if (typeof judged === 'string') {
258 pending = undefined
259 // 判定できなかった依頼も、その理由と一緒に残す(いつもの深さで動いたことが後から分かるように)
260 try {
261 await appendLog($, { at: new Date(pendingAt).toISOString(), prompt: text.slice(0, 60), origin: e.origin.kind, source: 'fail', why: judged, judgeMs })
262 } catch {
263 // 記録に失敗しても会話は止めない
264 }
265 $.ui.status(`考える深さ: ${label(usual)}のまま(判定できず)`)
266
267 return next(e)
268 }
269 pending = { ...judged, origin: e.origin.kind, judgeMs }
270 lastDecision = pending
271 $.ui.status(`考える深さ: ${label(judged.effort)}(${judged.level}: ${judged.reason})`)
272
273 return next(e)
274 })
275
276 on('turn.start', async ($, e, next) => {
277 const isFresh = pending !== undefined && (await $.clock.now()) - pendingAt < PENDING_TTL_MS
278 active = isFresh ? pending : undefined
279 pending = undefined
280 steps = []
281 // 作業中の表示(スピナー)を、このターンの深さで描き直す
282 $.ui.invalidate('ui.render')
283
284 return next(e)
285 })
286
287 // 作業中は「考え中…」の横に、いま使っている深さを出す
288 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
289 if (active === undefined) {
290 return next(e)
291 }
292
293 return next({ ...e, props: { ...e.props, suffix: `${e.props.suffix} · 考える深さ: ${label(active.effort)}(${active.level})` } })
294 })
295
296 // サブエージェントのリクエストには触らず、メインの会話だけ深さを差し替える
297 on('turn.step', async function* ($, e, next) {
298 if (e.agentId !== undefined) {
299 return yield* next(e)
300 }
301 // 判定しないときに使われる深さを覚えて、表示に使う(Claude Code の設定で決まる)
302 usual = e.effort ?? usual
303 if (active === undefined || !routeOk || !CACHE_SAFE_MODEL.test(e.model)) {
304 return yield* next(e)
305 }
306 const effort = active.effort
307 const result = yield* next({ ...e, effort })
308 steps.push({
309 effort,
310 cacheRead: result.usage?.cache_read_input_tokens ?? 0,
311 cacheWrite: result.usage?.cache_creation_input_tokens ?? 0,
312 output: result.usage?.output_tokens ?? 0,
313 })
314
315 return result
316 })
317
318 on('turn.complete', async ($, e, next) => {
319 if (e.agentId === undefined && active !== undefined) {
320 const seconds = Math.round(e.durationMs / 1000)
321 const line = `考える深さ: ${label(active.effort)}(${active.level}: ${active.reason})・${seconds}秒`
322 recent.unshift(`${label(active.effort)}|${active.level}|${seconds}秒|${active.prompt}`)
323 if (recent.length > 10) {
324 recent.length = 10
325 }
326 try {
327 await appendLog($, {
328 at: new Date(await $.clock.now()).toISOString(),
329 prompt: active.prompt,
330 origin: active.origin,
331 level: active.level,
332 reason: active.reason,
333 effort: active.effort,
334 usual,
335 source: active.source,
336 retried: active.retried,
337 judgeMs: active.judgeMs,
338 judgeTokens: active.judgeTokens,
339 seconds,
340 steps,
341 })
342 } catch {
343 // 記録に失敗しても会話は止めない
344 }
345 active = undefined
346 // デスクトップでも見えるよう、入力欄の下の表示にも結果を出す
347 $.ui.status(`${line}で回答`)
348 const result = await next(e)
349
350 // 返答の下に、このターンで使った深さを 1 行で残す(ターミナルだけに出る。デスクトップには出ない)
351 return { ...result, text: line }
352 }
353 if (e.agentId === undefined && isOn) {
354 $.ui.status(`考える深さ: ${label(usual)}のまま・${Math.round(e.durationMs / 1000)}秒で回答`)
355 }
356
357 return next(e)
358 })
359
360 on('command.run', { command: 'effort-auto' }, async ($, e) => {
361 const arg = e.args.trim()
362 if (arg === 'off') {
363 isOn = false
364 pending = undefined
365 active = undefined
366 $.ui.status('考える深さ: 自動振り分けオフ')
367
368 return { text: '自動振り分けを止めました。いつもの設定で動きます' }
369 }
370 if (arg === 'on') {
371 isOn = true
372 $.ui.status('考える深さ: 自動振り分けオン')
373
374 return { text: '自動振り分けを再開しました' }
375 }
376 const header = `自動振り分け: ${isOn ? 'オン' : 'オフ'}(簡単=中・普通=高・難しい=最大。頭に「さっと」で中、「じっくり」で最大。判定しないときは${label(usual)})`
377
378 return { text: [header, ...(recent.length > 0 ? ['直近の判定:', ...recent] : ['まだ判定していません'])].join('\n') }
379 })
380}
381