Memilih model dan effort Claude secara otomatis sesuai tingkat kesulitan tugas, tanpa membuang prompt cache, dengan statistik biaya di status line.

Plugin Claude Code yang memilih model dan effort secara otomatis sesuai tingkat kesulitan tugas, tanpa membuang prompt cache yang sudah terbayar.
low sampai xhigh) di model yang tetap memakai cache saat effort berubah: Opus 5.5, Sonnet 5.5, dan Fable 5.1.Explore, general-purpose) dipilihkan model dari tugas yang didelegasikan. Plugin juga membawa tiga agen bertingkat: slash:scout, slash:quick, dan slash:deep./slash menampilkan laporan per model tanpa memakan token konteks.Setiap model punya prompt cache sendiri. Begitu model diganti di tengah percakapan, seluruh riwayat dibaca ulang tanpa cache. Harga resmi per 1 juta token (sumber, dicek 2 Oktober 2026):
| Model | Input | Tulis cache 5 mnt / 1 jam | Baca cache | Output |
|---|---|---|---|---|
| Haiku 4.5 | $1 | $1.25 / $2 | $0.10 | $5 |
| Sonnet 5.5 | $2 | $2.50 / $4 | $0.20 | $10 |
| Opus 5.5 | $4 | $5 / $8 | $0.20 | $20 |
| Fable 5.1 | $10 | $12.50 / $20 | $0.25 | $50 |
Contoh: percakapan sudah berisi 100 ribu token, dan giliran berikutnya menghasilkan 2 ribu token output (cache 5 menit).
| Pilihan | Biaya giliran itu |
|---|---|
| Tetap di Opus 5.5 (cache hangat) | $0.06 |
| Pindah ke Haiku 4.5, yang katanya lebih murah | $0.135, 2,3× lebih mahal |
| Pindah ke Sonnet 5.5 | $0.27, 4,5× lebih mahal |
Di sesi agentik, biaya terbesar adalah membaca ulang konteks. Harga baca cache Opus 5.5 dan Sonnet 5.5 bahkan sama. Karena itu plugin ini:
/compact, atau lewat subagent yang memang mulai dengan konteks kosong.| Tier | Contoh tugas | Model utama | Effort | Subagent general-purpose |
|---|---|---|---|---|
| 0 sangat mudah | typo, rename, format, terjemah | Haiku 4.5 | (tanpa effort) | Haiku |
| 1 mudah | ubah satu fungsi, unit test, penjelasan | Sonnet 5.5 | low | Haiku (cari/jelaskan) atau Sonnet |
| 2 sedang | fitur, bug biasa, refactor satu modul | Sonnet 5.5 | medium | Sonnet |
| 3 sulit | arsitektur, race condition, performa, keamanan | Opus 5.5 | high | Opus |
| 4 ekstrem | aplikasi lengkap dari nol, tulis ulang total | Opus 5.5 (Fable 5.1 bila diizinkan) | xhigh | Opus atau Fable |
Subagent Explore hanya membaca, jadi selalu dibatasi di Haiku (tier 0-2) atau Sonnet (tier 3-4).
Aturan pergantian model loop utama (mode hemat-cache):
cache_ttl_minutes, atau setelah /compact): pindah ke model yang sesuai tier, naik maupun turun.max_switch_usd (default $1)./model (atau Claude Code memakai model fallback), plugin mengikuti pilihan itu dan berhenti mengganti model untuk percakapan tersebut. /slash auto mengaktifkannya lagi.Ada juga uji diri: kalau setelah effort berubah ternyata cache meleset di suatu model, effort otomatis dimatikan untuk model itu dan plugin menulis catatan di transcript.
Butuh Claude Code v2.1.287 atau lebih baru, karena plugin ini memakai mods (function hooks). Plugin berjalan di dalam Claude Code, jadi bisa dipakai dengan login langganan Claude (Pro/Max/Team/Enterprise) maupun API key, tanpa login terpisah.
Di dalam Claude Code:
/plugin marketplace add sofanaja44/slash
/plugin install slash@slash
Atau dari terminal:
claude plugin marketplace add sofanaja44/slash
claude plugin install slash@slash
Untuk mencoba tanpa memasang, jalankan claude --plugin-dir /path/ke/slash.
Setelah terpasang, semuanya berjalan otomatis. Di bawah prompt muncul status seperti:
slash · T2 sedang · sonnet 5.5/medium · cache 94% · $0.31
Notifikasi singkat muncul saat model loop utama atau model subagent diganti.
| Perintah | Fungsi |
|---|---|
/slash | Laporan: tier terakhir, keputusan model, hit rate cache, biaya, dan statistik per model (sesi ini dan semua sesi) |
/slash 0 … /slash 4 | Paksa tier untuk giliran-giliran berikutnya |
/slash auto | Kembali ke mode otomatis, termasuk setelah kamu memakai /model |
/slash reset | Hapus statistik |
Laporan /slash hanya ditampilkan di transcript dan tidak dikirim ke model, jadi tidak memakan token. Menulis ultrathink di prompt menaikkan tugas minimal ke tier 3.
Semua opsi bisa diubah lewat /config.
| Opsi | Default | Arti |
|---|---|---|
auto_effort | true | Effort per giliran sesuai tier |
route_subagents | true | Pilihkan model subagent bawaan dari tugasnya |
auto_main_model | hemat-cache | off = model loop utama tidak pernah diganti |
max_switch_usd | 1 | Batas biaya naik model saat cache hangat |
cache_ttl_minutes | 60 | Umur cache: 60 untuk langganan Claude, 5 untuk API key (kecuali promptCacheTtl diubah) |
allow_fable | false | Izinkan Fable 5.1 untuk tier 4 |
policy_prompt | true | Tambahkan sekitar 150 token panduan delegasi ke system prompt (teks tetap, ikut ter-cache) |
status_line | true | Status line dan notifikasi |
Untuk memakai versi model tertentu, set ANTHROPIC_DEFAULT_HAIKU_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL, atau ANTHROPIC_DEFAULT_FABLE_MODEL. Plugin memakai ID model itu saat berganti model.
claude plugin validate dan claude plugin test adalah cara tercepat untuk memeriksanya setelah update./slash 0-4 tersedia untuk koreksi manual, dan polanya ada di hooks/classify.ts.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS diset, plugin tidak mengganti model maupun effort loop utama. Routing subagent tetap jalan.hooks/pricing.ts. Angka biaya sesi di status line berasal dari ledger Claude Code sendiri.claude plugin validate . # marketplace
claude plugin validate ./.claude-plugin/plugin.json # manifest + modul hook
claude plugin test . # semua *.test.ts
claude --plugin-dir . # coba di sesi; juga menaruh tipe API di .claude-plugin/types/
npx tsc -p . # cek tipe, setelah tipe API ditaruh
.claude-plugin/
plugin.json manifest + userConfig
marketplace.json supaya bisa dipasang lewat /plugin marketplace add
agents/ scout (Haiku, read-only), quick (Haiku), deep (Opus, high)
hooks/
hooks.json { "modules": ["./register.ts"] }
register.ts menghubungkan event Claude Code: turn.start, turn.step, agent.spawn, ...
classify.ts classifier tier 0-4 (lokal, tanpa token)
policy.ts tier → model/effort, keputusan kapan aman ganti model
pricing.ts tabel harga resmi dan hitungan biaya
models.ts keluarga model, ID, model yang aman ganti effort
stats.ts akumulasi token dan biaya
types/index.d.ts kontrak nilai $.state
tests/ tes classifier, kebijakan, harga, dan hook
Kalau Anthropic mengubah harga atau merilis model baru, perbarui hooks/pricing.ts (tabel harga), hooks/models.ts (DEFAULT_MODEL_ID dan isEffortCacheSafe), lalu jalankan tes.
hooks/register.ts 380 lines1// Plugin slash: memilih model dan effort sesuai tingkat kesulitan tugas,
2// dengan menjaga prompt cache tetap terpakai.
3//
4// turn.start menilai prompt (lokal, 0 token) → tier 0-4
5// turn.step loop utama: effort per giliran, model hanya di titik murah
6// agent.spawn subagent bawaan: model dipilih dari tugas yang didelegasikan
7// prompt.compose kebijakan delegasi singkat di system prompt (stabil, ter-cache)
8// turn.complete statistik token & biaya per keluarga model
9// /slash laporan dan paksa tier, tanpa memakan token konteks
10
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, PluginOptions, Register } from 'claude-code'
13
14import type { SlashMain, SlashRoute, SlashTier, SlashTotals } from '../types'
15import { TIER_LABEL, classify } from './classify'
16import { DEFAULT_MODEL_ID, type Effort, type Family, FAMILIES, familyOf, isEffortCacheSafe, isFirstPartyId, shortName } from './models'
17import { decideSwitch, effortFor, isCacheCold, mainFamilyFor, subagentFamilyFor, ttlOf } from './policy'
18import { cacheHitRate, contextTokens, costUsd } from './pricing'
19import { type Totals, addTurn, formatPercent, formatUsd, totalsLine } from './stats'
20
21const INITIAL_MAIN: SlashMain = {
22 pinnedModel: null,
23 engineModel: null,
24 isUserPinned: false,
25 lastRequestAt: null,
26 lastContextTokens: 0,
27 lastModel: null,
28 lastEffort: null,
29 lastHitRate: null,
30 lastDecision: null,
31}
32
33const route = atom({ plugin: 'slash', key: 'route' } as const, null)
34const main = atom({ plugin: 'slash', key: 'main' } as const, INITIAL_MAIN)
35const override = atom({ plugin: 'slash', key: 'override' } as const, null)
36const totals = atom({ plugin: 'slash', key: 'totals' } as const, {})
37
38/** Subagent bawaan yang modelnya dipilih plugin bila model tidak ditentukan. */
39const ROUTED_SUBAGENTS = new Set(['general-purpose', 'Explore'])
40
41const STORE_TOTALS = 'totals'
42const STORE_EFFORT_UNSAFE = 'effortUnsafe'
43
44const POLICY_ID = 'slash:routing'
45const POLICY_TEXT = [
46 '# Token-efficient delegation (slash plugin)',
47 '- Keep a task inline when the files and context it needs are already in this conversation: a subagent starts empty and must read them again.',
48 '- Delegate self-contained, read-heavy work (broad codebase searches, reading logs or many files, summarizing docs) to the `slash:scout` agent. It runs on a fast, low-cost model and returns only a summary.',
49 '- Delegate small, fully specified mechanical edits that need nothing from this conversation to `slash:quick`.',
50 '- Use `slash:deep` only for a hard, isolated sub-problem (tricky debugging, a design review) that needs a stronger model.',
51 "- Leave the Agent tool's `model` parameter unset; the plugin picks the model from the task.",
52].join('\n')
53
54type Config = {
55 autoEffort: boolean
56 routeSubagents: boolean
57 autoMainModel: 'off' | 'hemat-cache'
58 maxSwitchUsd: number
59 cacheTtlMinutes: number
60 allowFable: boolean
61 policyPrompt: boolean
62 statusLine: boolean
63}
64
65function readConfig(options: PluginOptions): Config {
66 const flag = (key: string, fallback: boolean): boolean =>
67 typeof options[key] === 'boolean' ? (options[key] as boolean) : fallback
68 const number = (key: string, fallback: number): number =>
69 typeof options[key] === 'number' && Number.isFinite(options[key]) ? (options[key] as number) : fallback
70 return {
71 autoEffort: flag('auto_effort', true),
72 routeSubagents: flag('route_subagents', true),
73 autoMainModel: options['auto_main_model'] === 'off' ? 'off' : 'hemat-cache',
74 maxSwitchUsd: number('max_switch_usd', 1),
75 cacheTtlMinutes: number('cache_ttl_minutes', 60),
76 allowFable: flag('allow_fable', false),
77 policyPrompt: flag('policy_prompt', true),
78 statusLine: flag('status_line', true),
79 }
80}
81
82type Runtime = {
83 /** Claude API langsung: ID first-party dan ganti effort yang aman untuk cache. */
84 isFirstParty: boolean
85 modelIds: Record<Family, string>
86 effortUnsafe: Set<string>
87}
88
89function isEnabled(value: string | undefined): boolean {
90 return value !== undefined && value !== '' && value !== '0' && value.toLowerCase() !== 'false'
91}
92
93// Nama variabel lingkungan ditulis literal: validator plugin mendaftar apa saja yang dibaca modul.
94async function loadRuntime($: EngineInterface): Promise<Runtime> {
95 const otherProvider =
96 isEnabled(await $.env.get('CLAUDE_CODE_USE_BEDROCK')) ||
97 isEnabled(await $.env.get('CLAUDE_CODE_USE_VERTEX')) ||
98 isEnabled(await $.env.get('CLAUDE_CODE_USE_FOUNDRY')) ||
99 isEnabled(await $.env.get('CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS'))
100 const pinned: Record<Family, string | undefined> = {
101 haiku: await $.env.get('ANTHROPIC_DEFAULT_HAIKU_MODEL'),
102 sonnet: await $.env.get('ANTHROPIC_DEFAULT_SONNET_MODEL'),
103 opus: await $.env.get('ANTHROPIC_DEFAULT_OPUS_MODEL'),
104 fable: await $.env.get('ANTHROPIC_DEFAULT_FABLE_MODEL'),
105 }
106 const modelIds = { ...DEFAULT_MODEL_ID }
107 for (const family of FAMILIES) {
108 const id = pinned[family]
109 if (id !== undefined && isFirstPartyId(id)) modelIds[family] = id
110 }
111 const stored = await $.store.get(STORE_EFFORT_UNSAFE)
112 const effortUnsafe = new Set(Array.isArray(stored) ? stored.filter((id): id is string => typeof id === 'string') : [])
113 return { isFirstParty: !otherProvider, modelIds, effortUnsafe }
114}
115
116// Dibaca sekali per pemuatan modul; reload membaca ulang.
117let runtimeCache: Runtime | null = null
118
119async function getRuntime($: EngineInterface): Promise<Runtime> {
120 runtimeCache ??= await loadRuntime($)
121 return runtimeCache
122}
123
124async function showStatus($: EngineInterface, config: Config): Promise<void> {
125 if (!config.statusLine) return
126 const current = await read($, route)
127 const state = await read($, main)
128 const parts = ['slash']
129 parts.push(current === null ? 'siap' : `T${current.tier} ${TIER_LABEL[current.tier]}${current.source === 'paksa' ? ' (paksa)' : ''}`)
130 if (state.lastModel !== null) parts.push(shortName(state.lastModel) + (state.lastEffort === null ? '' : `/${state.lastEffort}`))
131 if (state.lastHitRate !== null) parts.push(`cache ${formatPercent(state.lastHitRate)}`)
132 const usage = await $.session.usage()
133 if (usage.cost !== undefined) parts.push(formatUsd(usage.cost.usd))
134 $.ui.status(parts.join(' · '))
135}
136
137export const register: Register = (on, options) => {
138 const config = readConfig(options)
139
140 on('session.start', async ($, e, next) => {
141 await $.command.register({
142 name: 'slash',
143 description: 'Router hemat token: laporan, atau paksa tier (0-4 | auto | reset)',
144 argumentHint: '[0-4|auto|reset]',
145 })
146 await showStatus($, config)
147 return next(e)
148 })
149
150 // Nilai tier tiap giliran utama. Subagent tidak memicu turn.start.
151 on('turn.start', async ($, e, next) => {
152 const previous = await read($, route)
153 const forced = await read($, override)
154 const result = classify(e.text, previous)
155 const nextRoute: SlashRoute =
156 forced === null
157 ? { ...result, turnId: e.turnId, source: 'otomatis' }
158 : { ...result, tier: forced, confidence: 1, reasons: ['dipaksa lewat /slash'], turnId: e.turnId, source: 'paksa' }
159 await update($, route, () => nextRoute)
160 await showStatus($, config)
161 return next(e)
162 })
163
164 on('turn.step', async function* ($, e, next) {
165 const current = await read($, route)
166 // Langkah subagent, atau giliran yang tidak sempat dinilai: biarkan apa adanya.
167 if (e.agentId !== undefined || current === null || current.turnId !== e.turnId) {
168 return yield* next(e)
169 }
170
171 const rt = await getRuntime($)
172 const now = await $.clock.now()
173 let state = await read($, main)
174
175 // Model diganti di luar plugin (/model, fallback keamanan, opusplan): ikuti, berhenti auto-switch.
176 if (state.engineModel !== null && e.model !== state.engineModel) {
177 state = { ...state, pinnedModel: null, isUserPinned: true, lastDecision: `model diganti di luar plugin (${shortName(e.model)}): auto-switch berhenti untuk percakapan ini` }
178 }
179
180 const cacheCold = isCacheCold(state.lastRequestAt, now, config.cacheTtlMinutes)
181 let model = state.pinnedModel ?? e.model
182 let toast: string | null = null
183
184 const canSwitch =
185 e.index === 0 &&
186 config.autoMainModel !== 'off' &&
187 rt.isFirstParty &&
188 !state.isUserPinned &&
189 !current.isContinuation &&
190 isFirstPartyId(e.model)
191 if (canSwitch) {
192 const target = rt.modelIds[mainFamilyFor(current.tier, config)]
193 if (familyOf(target) !== familyOf(model)) {
194 const decision = decideSwitch({
195 fromModel: model,
196 toModel: target,
197 contextTokens: state.lastContextTokens,
198 cacheCold,
199 confidence: current.confidence,
200 maxSwitchUsd: config.maxSwitchUsd,
201 ttl: ttlOf(config.cacheTtlMinutes),
202 })
203 state = { ...state, lastDecision: `${shortName(model)} → ${shortName(target)}: ${decision.reason}` }
204 if (decision.isSwitch) {
205 toast = `slash: model ${shortName(model)} → ${shortName(target)} (${decision.reason})`
206 model = target
207 }
208 }
209 }
210
211 // Effort per giliran, hanya di model yang mempertahankan cache saat effort berubah.
212 let effort: Effort | number | undefined = e.effort
213 const targetFamily = familyOf(model)
214 if (targetFamily === 'haiku') {
215 effort = undefined
216 } else if (
217 config.autoEffort &&
218 rt.isFirstParty &&
219 typeof e.effort !== 'number' &&
220 isEffortCacheSafe(model) &&
221 !rt.effortUnsafe.has(model)
222 ) {
223 effort = effortFor(current)
224 }
225
226 const previous = state
227 state = { ...state, engineModel: e.model, pinnedModel: model === e.model ? null : model }
228 await update($, main, () => state)
229 if (toast !== null) $.ui.toast(toast)
230
231 // effort undefined berarti tanpa effort (mis. Haiku), bukan "pakai nilai lama".
232 const result = yield* next({ ...e, model, effort })
233
234 if (result.usage !== null) {
235 const usage = result.usage
236 const tokens = contextTokens(usage)
237 const effortLabel = typeof effort === 'string' ? effort : null
238
239 // Uji diri: kalau ganti effort ternyata membuat cache meleset di model ini, matikan effort otomatis untuknya.
240 const effortChanged = effortLabel !== null && previous.lastEffort !== null && effortLabel !== previous.lastEffort
241 if (
242 e.index === 0 &&
243 effortChanged &&
244 !cacheCold &&
245 previous.lastModel === model &&
246 previous.lastContextTokens >= 20_000 &&
247 usage.cache_read_input_tokens < previous.lastContextTokens * 0.5 &&
248 !rt.effortUnsafe.has(model)
249 ) {
250 rt.effortUnsafe.add(model)
251 await $.store.set(STORE_EFFORT_UNSAFE, [...rt.effortUnsafe])
252 $.ui.log(`slash: ganti effort tampaknya mereset prompt cache di ${shortName(model)}; effort otomatis dimatikan untuk model ini.`)
253 }
254
255 await update($, main, latest => ({
256 ...latest,
257 lastRequestAt: now,
258 lastContextTokens: tokens,
259 lastModel: model,
260 lastEffort: effortLabel,
261 lastHitRate: cacheHitRate(usage),
262 }))
263 }
264 return result
265 })
266
267 // Subagent bawaan tanpa model eksplisit: pilih model dari tugas yang didelegasikan.
268 on('agent.spawn', async ($, e, next) => {
269 if (!config.routeSubagents || e.fork || e.model !== undefined || e.provider.plugin !== 'engine' || !ROUTED_SUBAGENTS.has(e.subagentType)) {
270 return next(e)
271 }
272 const task = classify(`${e.description}\n${e.prompt}`)
273 const family = subagentFamilyFor(task, e.subagentType, config)
274 if (config.statusLine) $.ui.toast(`slash: subagent ${e.subagentType} → ${family} (T${task.tier} ${TIER_LABEL[task.tier]})`)
275 return next({ ...e, model: family })
276 })
277
278 // Kebijakan delegasi: teks tetap, jadi ikut ter-cache bersama system prompt.
279 on('prompt.compose', async ($, e, next) => {
280 const result = await next(e)
281 if (!config.policyPrompt || !e.tools.includes('Agent') || result.sections.some(section => section.id === POLICY_ID)) {
282 return result
283 }
284 return { sections: [...result.sections, { id: POLICY_ID, text: POLICY_TEXT, scope: 'session' as const }] }
285 })
286
287 on('turn.complete', async ($, e, next) => {
288 const result = await next(e)
289 if (e.usage !== undefined) {
290 const usage = e.usage
291 const family = familyOf(usage.model) ?? 'lain'
292 // Subagent selalu menulis cache 5 menit; loop utama mengikuti umur cache yang diatur.
293 const usd = costUsd(usage, usage.model, e.agentId === undefined ? ttlOf(config.cacheTtlMinutes) : '5m') ?? 0
294 await update($, totals, all => ({ ...all, [family]: addTurn(all[family], usage, usd) }))
295 const stored = (await $.store.get(STORE_TOTALS)) as Record<string, Totals> | undefined
296 await $.store.set(STORE_TOTALS, { ...stored, [family]: addTurn(stored?.[family], usage, usd) })
297 }
298 if (e.agentId === undefined) await showStatus($, config)
299 return result
300 })
301
302 // Setelah compact, cache percakapan dibangun ulang: titik gratis untuk ganti model.
303 on('session.compact', async ($, e, next) => {
304 const result = await next(e)
305 if (!('skip' in result && typeof result.skip === 'string')) {
306 await update($, main, state => ({ ...state, lastRequestAt: null }))
307 }
308 return result
309 })
310
311 // /clear memulai percakapan baru.
312 on('session.end', async ($, e, next) => {
313 if (e.reason === 'clear') {
314 await update($, main, () => INITIAL_MAIN)
315 await update($, route, () => null)
316 await update($, override, () => null)
317 }
318 return next(e)
319 })
320
321 on('command.run', { command: 'slash' }, async ($, e) => {
322 const arg = e.args.trim().toLowerCase()
323 if (/^[0-4]$/.test(arg)) {
324 const tier = Number(arg) as SlashTier
325 await update($, override, () => tier)
326 $.ui.log(`slash: tier dipaksa T${tier} (${TIER_LABEL[tier]}) mulai giliran berikutnya. /slash auto untuk kembali otomatis.`)
327 return {}
328 }
329 if (arg === 'auto') {
330 await update($, override, () => null)
331 await update($, main, state => ({ ...state, isUserPinned: false }))
332 $.ui.log('slash: kembali ke mode otomatis.')
333 return {}
334 }
335 if (arg === 'reset') {
336 await update($, totals, () => ({}))
337 await $.store.delete(STORE_TOTALS)
338 $.ui.log('slash: statistik dihapus.')
339 return {}
340 }
341 for (const line of await report($, config)) $.ui.log(line)
342 return {}
343 })
344}
345
346async function report($: EngineInterface, config: Config): Promise<string[]> {
347 const current = await read($, route)
348 const state = await read($, main)
349 const forced = await read($, override)
350 const session = await read($, totals)
351 const stored = (await $.store.get(STORE_TOTALS)) as Record<string, SlashTotals> | undefined
352 const { cost } = await $.session.usage()
353
354 const lines = ['slash · router hemat token']
355 if (current !== null) {
356 lines.push(` Giliran terakhir : T${current.tier} ${TIER_LABEL[current.tier]} · ${current.kind} · yakin ${Math.round(current.confidence * 100)}% · ${current.reasons.join(', ')}`)
357 }
358 lines.push(` Model utama : ${state.lastModel === null ? '-' : shortName(state.lastModel)}${state.pinnedModel === null ? '' : ' (dipilih plugin)'}${state.isUserPinned ? ' · auto-switch berhenti (/slash auto untuk lanjut)' : ''}`)
359 lines.push(` Effort : ${state.lastEffort ?? '-'} · cache giliran terakhir ${formatPercent(state.lastHitRate)}`)
360 if (state.lastDecision !== null) lines.push(` Keputusan model : ${state.lastDecision}`)
361 if (forced !== null) lines.push(` Tier dipaksa : T${forced} (/slash auto untuk kembali)`)
362 if (cost !== undefined) lines.push(` Biaya sesi : ${formatUsd(cost.usd)} (ledger Claude Code)`)
363
364 const section = (title: string, rows: Record<string, SlashTotals> | undefined): void => {
365 const entries = Object.entries(rows ?? {}).filter(([, value]) => value.turns > 0)
366 if (entries.length === 0) return
367 lines.push(title)
368 for (const [family, value] of entries) lines.push(` ${totalsLine(family, value)}`)
369 }
370 section(' Sesi ini (perkiraan per model):', session)
371 section(' Semua sesi di mesin ini:', stored)
372
373 lines.push(
374 ` Pengaturan: effort otomatis ${config.autoEffort ? 'ON' : 'OFF'} · subagent ${config.routeSubagents ? 'ON' : 'OFF'} · model utama ${config.autoMainModel} (maks ${formatUsd(config.maxSwitchUsd)}/pindah, TTL ${config.cacheTtlMinutes} mnt) · Fable ${config.allowFable ? 'ON' : 'OFF'}`,
375 )
376 lines.push(' Perintah: /slash 0-4 paksa tier · /slash auto · /slash reset · ubah pengaturan di /config')
377 return lines
378}
379
380hooks/classify.ts 185 lines1// Klasifikasi tingkat kesulitan prompt secara lokal: heuristik kata kunci
2// (Indonesia + Inggris) dan sinyal struktur. Tidak memanggil model, jadi 0 token.
3
4export type Tier = 0 | 1 | 2 | 3 | 4
5
6export type TaskKind = 'chat' | 'explain' | 'explore' | 'edit' | 'build' | 'debug' | 'design'
7
8export type Classification = {
9 tier: Tier
10 kind: TaskKind
11 /** 0..1. Router tidak mengganti model bila keyakinan rendah. */
12 confidence: number
13 /** Alasan singkat, untuk status line dan /slash. */
14 reasons: string[]
15 /** Prompt hanya melanjutkan tugas sebelumnya ("ok", "lanjut", ...). */
16 isContinuation: boolean
17}
18
19export const TIER_LABEL: Readonly<Record<Tier, string>> = {
20 0: 'sangat mudah',
21 1: 'mudah',
22 2: 'sedang',
23 3: 'sulit',
24 4: 'ekstrem',
25}
26
27type Rule = { id: string; kind: TaskKind; tier: Tier; weight: number; pattern: RegExp }
28
29const RULES: readonly Rule[] = [
30 // Tier 0: mekanis, jelas, lokal.
31 { id: 'typo', kind: 'edit', tier: 0, weight: 2, pattern: /\b(typo|salah ketik|ejaan|spelling)\b/ },
32 { id: 'rename', kind: 'edit', tier: 0, weight: 2, pattern: /\b(rename|ganti nama|ubah nama)\b/ },
33 { id: 'format', kind: 'edit', tier: 0, weight: 2, pattern: /\b(format(ting)?|prettier|indent(asi)?|rapikan|lint(er)?)\b/ },
34 { id: 'terjemah', kind: 'edit', tier: 0, weight: 2, pattern: /\b(translate|terjemah(kan)?)\b/ },
35 { id: 'komentar', kind: 'edit', tier: 0, weight: 1, pattern: /\b(komentar|comments?|docstrings?|jsdoc)\b/ },
36 { id: 'cari', kind: 'explore', tier: 0, weight: 1, pattern: /\b(di ?mana|where (is|are)|cari(kan)?|find|search|grep|locate|tampilkan|show me|baca(kan)?)\b/ },
37 { id: 'ringkas', kind: 'explore', tier: 0, weight: 1, pattern: /\b(ringkas(kan)?|rangkum(kan)?|summari[sz]e|tl;?dr)\b/ },
38 // Tier 1: perubahan kecil atau penjelasan.
39 { id: 'definisi', kind: 'explain', tier: 1, weight: 1, pattern: /\b(apa (itu|maksud(nya)?)|what (is|are)|arti(nya)?|definisi|meaning of)\b/ },
40 { id: 'jelaskan', kind: 'explain', tier: 1, weight: 1, pattern: /\b(jelaskan|explain|bagaimana cara|gimana cara|how (do|to|does))\b/ },
41 { id: 'ubah-kecil', kind: 'edit', tier: 1, weight: 1, pattern: /\b(tambah(kan)?|add|ubah|change|update|ganti|replace|hapus|remove|delete|perbarui)\b/ },
42 { id: 'buat', kind: 'build', tier: 1, weight: 1, pattern: /\b(buat(kan)?|bikin(kan)?|create|write|tulis(kan)?)\b/ },
43 { id: 'unit-test', kind: 'edit', tier: 1, weight: 1, pattern: /\b(unit ?tests?|test cases?|tes unit)\b/ },
44 { id: 'potongan-kode', kind: 'build', tier: 1, weight: 1, pattern: /\b(fungsi|function|snippet|script|regex|query sql|one-?liner)\b/ },
45 // Tier 2: fitur, bug, banyak bagian.
46 { id: 'fitur', kind: 'build', tier: 2, weight: 2, pattern: /\b(implement(asi(kan)?)?|fitur|features?|endpoints?|integrasi(kan)?|integrat(e|ion)|crud)\b/ },
47 { id: 'ui', kind: 'build', tier: 2, weight: 1, pattern: /\b(halaman|pages?|komponen|components?|form(ulir)?|login|register|dashboard)\b/ },
48 { id: 'bug', kind: 'debug', tier: 2, weight: 2, pattern: /\b(bugs?|errors?|exceptions?|crash(es|ed)?|gagal|broken|rusak|failing|not working|tidak (jalan|bisa|berfungsi)|(nggak|ngga|gak|ga|enggak) (jalan|bisa))\b/ },
49 { id: 'refactor', kind: 'build', tier: 2, weight: 2, pattern: /\b(refactor(ing)?|refaktor|restruktur(isasi)?|restructure)\b/ },
50 { id: 'optimasi', kind: 'build', tier: 2, weight: 1, pattern: /\b(optimi[sz]e|optimasi|lebih cepat|faster)\b/ },
51 { id: 'infra', kind: 'build', tier: 2, weight: 1, pattern: /\b(docker|kubernetes|k8s|ci\/cd|pipeline|deploy(ment)?|migrasi|migrations?|schema|skema|auth(entication)?|oauth|jwt|websockets?)\b/ },
52 // Tier 3: butuh penalaran berat.
53 { id: 'arsitektur', kind: 'design', tier: 3, weight: 3, pattern: /\b(arsitektur|architecture|system design|desain sistem|rancang(an)? sistem|trade-?offs?|scalab\w*|skalab\w*|distributed|terdistribusi|microservices?)\b/ },
54 { id: 'konkurensi', kind: 'debug', tier: 3, weight: 3, pattern: /\b(race conditions?|deadlocks?|concurren\w*|konkuren\w*|thread-?safe\w*|memory leaks?|kebocoran memori|heisenbugs?|flaky|intermittent\w*|kadang (gagal|error))\b/ },
55 { id: 'performa', kind: 'debug', tier: 3, weight: 2, pattern: /\b(bottlenecks?|profil(ing|er)|latency|latensi|throughput)\b/ },
56 { id: 'keamanan', kind: 'design', tier: 3, weight: 3, pattern: /\b(security audit|audit keamanan|threat model\w*|vulnerab\w*|kerentanan|exploits?|cryptograph\w*|kriptografi)\b/ },
57 { id: 'akar-masalah', kind: 'debug', tier: 3, weight: 2, pattern: /\b(root cause|akar masalah|investigat\w*|selidiki|analisis mendalam|deep dive)\b/ },
58 { id: 'algoritma', kind: 'design', tier: 3, weight: 2, pattern: /\b(algoritma|algorithms?|dynamic programming|kompleksitas waktu|time complexity)\b/ },
59 // Tier 4: sangat besar atau terbuka.
60 { id: 'dari-nol', kind: 'design', tier: 4, weight: 3, pattern: /\b(dari nol|from scratch|aplikasi lengkap|sistem lengkap|full[- ]stack app\w*|complete (app|application|system))\b/ },
61 { id: 'tulis-ulang', kind: 'design', tier: 4, weight: 2, pattern: /\b(rewrite|tulis ulang|migrasi (seluruh|semua|total)|migrate (the )?(entire|whole))\b/ },
62]
63
64const CONTINUATION = /^((ok(e|ay)?|sip|siap|lanjut(kan)?|terus(kan)?|ya|iya|yes|yep|y|go|gas|next|continue|proceed|mantap|setuju|boleh|saja|aja|dong|deh|please|pls)[\s,.!]*)+$/
65
66const GREETING = /^(hai|halo|hallo|hello|hi|hey|pagi|siang|sore|malam|selamat (pagi|siang|sore|malam)|terima ?kasih|makasih|thanks|thank you|thx)\b/
67
68const QUESTION_START = /^(apa|apakah|bagaimana|gimana|what|how|kenapa|mengapa|why)\b/
69
70const ULTRATHINK = /\b(ultrathink|think (hard|harder|deeply)|pikirkan (baik-baik|matang-matang|dengan matang|mendalam))\b/
71
72const BROAD_SCOPE = /\b(semua file|seluruh|semuanya|entire|whole (codebase|project|repo)|all files|setiap file|project-wide|codebase)\b/
73
74const STACK_TRACE = /(traceback \(most recent call last\)|^\s*at [\w$.<>]+ \(?[^\s)]+:\d+|panic:|segmentation fault|exception in thread|uncaught \w*error|\b\w*(error|exception): )/m
75
76const FILE_PATH = /\b[\w./-]+\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|cpp|cc|c|h|hpp|swift|json|ya?ml|toml|md|sql|sh|css|scss|html|vue|svelte)\b/g
77
78const CODE_BLOCKS = /```[\s\S]*?```/g
79
80function clampTier(value: number): Tier {
81 return Math.max(0, Math.min(4, Math.round(value))) as Tier
82}
83
84/**
85 * Menilai prompt. `previous` dipakai saat prompt hanya melanjutkan tugas
86 * sebelumnya, supaya tier tidak turun hanya karena kata "ok".
87 */
88export function classify(text: string, previous?: Classification | null): Classification {
89 const lower = text.trim().toLowerCase()
90 const prose = lower.replace(CODE_BLOCKS, ' ')
91 const words = prose.split(/\s+/).filter(word => word.length > 0)
92
93 if (lower.length === 0 || (CONTINUATION.test(lower) && words.length <= 4)) {
94 if (previous != null) {
95 return { ...previous, reasons: ['lanjutan tugas sebelumnya'], isContinuation: true }
96 }
97 return { tier: 1, kind: 'chat', confidence: 0.3, reasons: ['lanjutan'], isContinuation: true }
98 }
99
100 if (GREETING.test(lower) && words.length <= 6) {
101 return { tier: 0, kind: 'chat', confidence: 0.4, reasons: ['sapaan'], isContinuation: false }
102 }
103
104 const scores = [0, 0, 0, 0, 0]
105 const matched: Rule[] = []
106 for (const rule of RULES) {
107 if (rule.pattern.test(prose)) {
108 matched.push(rule)
109 scores[rule.tier] = (scores[rule.tier] ?? 0) + rule.weight
110 }
111 }
112 const scoreOf = (tier: number): number => scores[tier] ?? 0
113
114 let tier: number
115 let confidence: number
116 const strongTier = [4, 3, 2, 1, 0].find(t => scoreOf(t) >= 2)
117 if (strongTier !== undefined) {
118 tier = strongTier
119 confidence = scoreOf(strongTier) >= 4 ? 0.85 : 0.7
120 } else if (matched.length > 0) {
121 tier = Math.max(...matched.map(rule => rule.tier))
122 confidence = 0.5
123 } else {
124 tier = 2
125 confidence = 0.3
126 }
127
128 const isQuestion = lower.endsWith('?') || QUESTION_START.test(lower)
129 const top = [...matched].sort((a, b) => b.tier * 10 + b.weight - (a.tier * 10 + a.weight))[0]
130 let kind: TaskKind = top?.kind ?? (isQuestion ? 'explain' : 'build')
131 const reasons = matched
132 .filter(rule => rule.tier === tier || rule.weight >= 2)
133 .slice(0, 3)
134 .map(rule => rule.id)
135
136 const hasCode = lower.includes('```')
137 const hasTrace = STACK_TRACE.test(lower)
138
139 // Pertanyaan konsep pendek ("apa itu closure?") cukup dijawab ringan.
140 const has = (id: string): boolean => matched.some(rule => rule.id === id)
141 const isShortQuestion = isQuestion && !hasCode && !hasTrace && words.length <= 25
142 if (isShortQuestion && tier > 1 && (has('definisi') || (has('jelaskan') && scoreOf(3) + scoreOf(4) < 3))) {
143 tier = 1
144 kind = 'explain'
145 reasons.unshift('pertanyaan konsep')
146 }
147
148 let bump = 0
149 if (hasTrace) {
150 if (tier < 2) tier = 2
151 kind = 'debug'
152 reasons.push('stack trace')
153 confidence += 0.05
154 }
155 const files = new Set(prose.match(FILE_PATH) ?? [])
156 if (files.size >= 3) {
157 bump += 1
158 reasons.push(`${files.size} file`)
159 }
160 if (BROAD_SCOPE.test(prose)) {
161 bump += 1
162 reasons.push('cakupan luas')
163 }
164 if (words.length > 120) {
165 bump += 1
166 reasons.push('spesifikasi panjang')
167 }
168 tier += Math.min(bump, 2)
169 confidence += 0.05 * Math.min(bump, 2)
170
171 if (ULTRATHINK.test(prose)) {
172 tier = Math.max(tier, 3)
173 reasons.push('ultrathink')
174 confidence = Math.max(confidence, 0.8)
175 }
176
177 return {
178 tier: clampTier(tier),
179 kind,
180 confidence: Math.min(0.95, Math.round(confidence * 100) / 100),
181 reasons: reasons.length > 0 ? reasons : ['tanpa sinyal kuat'],
182 isContinuation: false,
183 }
184}
185hooks/models.ts 55 lines1// Keluarga model Claude, urutan kemampuannya, dan aturan kapan mengganti
2// effort aman untuk prompt cache.
3
4export type Family = 'haiku' | 'sonnet' | 'opus' | 'fable'
5
6export const FAMILIES: readonly Family[] = ['haiku', 'sonnet', 'opus', 'fable']
7
8/** Urutan kemampuan (dan harga): makin besar makin kuat. */
9export const RANK: Readonly<Record<Family, number>> = { haiku: 0, sonnet: 1, opus: 2, fable: 3 }
10
11/** Model ID first-party default per keluarga, bisa ditimpa lewat ANTHROPIC_DEFAULT_*_MODEL. */
12export const DEFAULT_MODEL_ID: Readonly<Record<Family, string>> = {
13 haiku: 'claude-haiku-4-5',
14 sonnet: 'claude-sonnet-5-5',
15 opus: 'claude-opus-5-5',
16 fable: 'claude-fable-5-1',
17}
18
19export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
20
21export function familyOf(model: string): Family | null {
22 const id = model.toLowerCase()
23 if (id.includes('mythos')) return 'fable'
24 for (const family of FAMILIES) {
25 if (id.includes(family)) return family
26 }
27 return null
28}
29
30/**
31 * True untuk ID Claude API first-party seperti `claude-opus-5-5` atau
32 * `claude-haiku-4-5-20251001`: tanpa prefix provider (`anthropic.`), tanpa
33 * `@versi` dan tanpa sufiks seperti `[1m]`. Hanya ID begini yang aman diganti.
34 */
35export function isFirstPartyId(model: string): boolean {
36 return /^claude-[a-z]+-\d+(-\d+)*$/.test(model)
37}
38
39/** Nama pendek untuk status line: `claude-opus-5-5` → `opus 5.5`. */
40export function shortName(model: string): string {
41 const match = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/.exec(model)
42 if (match === null) return model
43 const [, family, major, minor] = match
44 return minor === undefined ? `${family} ${major}` : `${family} ${major}.${minor}`
45}
46
47/**
48 * Model yang mempertahankan prompt cache saat effort diganti di tengah sesi
49 * (Claude Code memakai per-message effort di model ini, dengan API key atau
50 * langganan Claude). Di model lain, ganti effort = seluruh riwayat dibaca ulang.
51 */
52export function isEffortCacheSafe(model: string): boolean {
53 return /(opus-5-5|sonnet-5-5|fable-5-1|mythos-5-1)(?!\d)/.test(model.toLowerCase())
54}
55hooks/policy.ts 120 lines1// Kebijakan routing: tier → keluarga model dan effort, plus keputusan kapan
2// mengganti model loop utama tanpa membuang prompt cache yang mahal.
3
4import type { Classification, Tier } from './classify'
5import { type Effort, type Family, RANK, familyOf } from './models'
6import { type CacheTtl, priceFor, writePrice } from './pricing'
7
8export const EFFORT_BY_TIER: Readonly<Record<Tier, Effort>> = {
9 0: 'low',
10 1: 'low',
11 2: 'medium',
12 3: 'high',
13 4: 'xhigh',
14}
15
16export type PolicyOptions = { allowFable: boolean }
17
18/** Keluarga model untuk loop utama pada tier tertentu. */
19export function mainFamilyFor(tier: Tier, options: PolicyOptions): Family {
20 if (tier === 0) return 'haiku'
21 if (tier <= 2) return 'sonnet'
22 if (tier === 3) return 'opus'
23 return options.allowFable ? 'fable' : 'opus'
24}
25
26/**
27 * Keluarga model untuk subagent bawaan. `Explore` hanya membaca, jadi
28 * dibatasi di Haiku/Sonnet; tugas tier 1 yang sifatnya mencari atau
29 * menjelaskan juga cukup di Haiku.
30 */
31export function subagentFamilyFor(route: Classification, subagentType: string, options: PolicyOptions): Family {
32 if (subagentType === 'Explore') return route.tier <= 2 ? 'haiku' : 'sonnet'
33 if (route.tier === 0) return 'haiku'
34 if (route.tier === 1) return route.kind === 'explore' || route.kind === 'explain' ? 'haiku' : 'sonnet'
35 return mainFamilyFor(route.tier, options)
36}
37
38export function effortFor(route: Classification): Effort {
39 return EFFORT_BY_TIER[route.tier]
40}
41
42export function isCacheCold(lastRequestAt: number | null, now: number, ttlMinutes: number): boolean {
43 return lastRequestAt === null || now - lastRequestAt > ttlMinutes * 60_000
44}
45
46export function ttlOf(ttlMinutes: number): CacheTtl {
47 return ttlMinutes > 5 ? '1h' : '5m'
48}
49
50/**
51 * Biaya tambahan (USD) giliran pertama setelah pindah model: konteks harus
52 * ditulis ulang ke cache model baru. Saat cache hangat, pembandingnya adalah
53 * membaca cache model lama; saat dingin, menulis ulang cache model lama.
54 * Negatif berarti pindah justru lebih murah.
55 */
56export function switchPenaltyUsd(
57 contextTokens: number,
58 fromModel: string,
59 toModel: string,
60 cacheCold: boolean,
61 ttl: CacheTtl,
62): number {
63 const from = priceFor(fromModel)
64 const to = priceFor(toModel)
65 if (from === null || to === null) return Number.POSITIVE_INFINITY
66 const stay = cacheCold ? writePrice(from, ttl) : from.read
67 return (contextTokens * (writePrice(to, ttl) - stay)) / 1_000_000
68}
69
70export type SwitchInput = {
71 /** Model ID yang akan dipakai bila tidak pindah. */
72 fromModel: string
73 /** Model ID tujuan. */
74 toModel: string
75 /** Token input request terakhir (0 bila belum ada). */
76 contextTokens: number
77 cacheCold: boolean
78 confidence: number
79 maxSwitchUsd: number
80 ttl: CacheTtl
81}
82
83export type SwitchDecision = { isSwitch: boolean; reason: string; penaltyUsd: number }
84
85const usd = (value: number): string => `$${value.toFixed(value < 0.1 ? 3 : 2)}`
86
87/**
88 * Pindah model hanya di titik yang murah: cache sudah dingin (sesi baru,
89 * jeda lama, setelah compact), atau naik model selama biaya tulis ulang
90 * cache masih di bawah batas. Turun model saat cache hangat selalu ditunda,
91 * karena membaca cache model mahal sering lebih murah daripada menulis
92 * ulang seluruh konteks ke model murah.
93 */
94export function decideSwitch(input: SwitchInput): SwitchDecision {
95 const from = familyOf(input.fromModel)
96 const to = familyOf(input.toModel)
97 if (from === null || to === null) return { isSwitch: false, reason: 'model tidak dikenali', penaltyUsd: 0 }
98 if (from === to) return { isSwitch: false, reason: 'model sudah sesuai', penaltyUsd: 0 }
99
100 const isUpgrade = RANK[to] > RANK[from]
101 const penaltyUsd = switchPenaltyUsd(input.contextTokens, input.fromModel, input.toModel, input.cacheCold, input.ttl)
102 if (input.confidence < (isUpgrade ? 0.5 : 0.7)) {
103 return { isSwitch: false, reason: 'keyakinan klasifikasi rendah', penaltyUsd }
104 }
105 if (input.cacheCold) {
106 return { isSwitch: true, reason: 'cache dingin, tidak ada cache yang terbuang', penaltyUsd }
107 }
108 if (!isUpgrade) {
109 return { isSwitch: false, reason: 'turun model ditunda selama cache hangat', penaltyUsd }
110 }
111 if (penaltyUsd <= input.maxSwitchUsd) {
112 return { isSwitch: true, reason: `naik model, biaya tulis ulang cache ≈ ${usd(penaltyUsd)}`, penaltyUsd }
113 }
114 return {
115 isSwitch: false,
116 reason: `naik model ditunda: biaya cache ≈ ${usd(penaltyUsd)} > batas ${usd(input.maxSwitchUsd)}`,
117 penaltyUsd,
118 }
119}
120hooks/pricing.ts 75 lines1// Harga resmi Claude API per 1 juta token (USD), dari
2// https://platform.claude.com/docs/en/about-claude/pricing (dicek 2026-10-02).
3// Perbarui tabel ini bila Anthropic mengubah harga.
4
5import { DEFAULT_MODEL_ID, familyOf } from './models'
6
7export type Price = {
8 input: number
9 /** Tulis cache dengan TTL 5 menit (1,25× input). */
10 write5m: number
11 /** Tulis cache dengan TTL 1 jam (2× input). */
12 write1h: number
13 /** Baca cache (hit/refresh). */
14 read: number
15 output: number
16}
17
18export type Usage = {
19 input_tokens: number
20 output_tokens: number
21 cache_read_input_tokens: number
22 cache_creation_input_tokens: number
23}
24
25export type CacheTtl = '5m' | '1h'
26
27// Urutan penting: ID yang lebih spesifik dicek lebih dulu.
28const PRICES: readonly { pattern: RegExp; price: Price }[] = [
29 { pattern: /(fable|mythos)-5-1/, price: { input: 10, write5m: 12.5, write1h: 20, read: 0.25, output: 50 } },
30 { pattern: /(fable|mythos)-5/, price: { input: 10, write5m: 12.5, write1h: 20, read: 1, output: 50 } },
31 { pattern: /opus-5-5/, price: { input: 4, write5m: 5, write1h: 8, read: 0.2, output: 20 } },
32 { pattern: /opus-(5|4-[5-8])/, price: { input: 5, write5m: 6.25, write1h: 10, read: 0.5, output: 25 } },
33 { pattern: /sonnet-5/, price: { input: 2, write5m: 2.5, write1h: 4, read: 0.2, output: 10 } },
34 { pattern: /sonnet-4-[56]/, price: { input: 3, write5m: 3.75, write1h: 6, read: 0.3, output: 15 } },
35 { pattern: /haiku-4-5/, price: { input: 1, write5m: 1.25, write1h: 2, read: 0.1, output: 5 } },
36]
37
38/** Harga untuk model ID atau alias keluarga (`opus`, `haiku`, ...); null bila tidak dikenal. */
39export function priceFor(model: string): Price | null {
40 const id = model.toLowerCase()
41 for (const row of PRICES) {
42 if (row.pattern.test(id)) return row.price
43 }
44 const family = familyOf(id)
45 if (family !== null && id === family) return priceFor(DEFAULT_MODEL_ID[family])
46 return null
47}
48
49export function writePrice(price: Price, ttl: CacheTtl): number {
50 return ttl === '1h' ? price.write1h : price.write5m
51}
52
53/** Biaya satu request atau giliran dalam USD; null bila harga model tidak dikenal. */
54export function costUsd(usage: Usage, model: string, ttl: CacheTtl = '5m'): number | null {
55 const price = priceFor(model)
56 if (price === null) return null
57 return (
58 usage.input_tokens * price.input +
59 usage.cache_creation_input_tokens * writePrice(price, ttl) +
60 usage.cache_read_input_tokens * price.read +
61 usage.output_tokens * price.output
62 ) / 1_000_000
63}
64
65/** Total token input yang dibawa request: tanpa cache, ditulis, dan dibaca. */
66export function contextTokens(usage: Usage): number {
67 return usage.input_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens
68}
69
70/** Bagian input yang dibaca dari cache, 0..1; null bila tidak ada input. */
71export function cacheHitRate(usage: Usage): number | null {
72 const total = contextTokens(usage)
73 return total === 0 ? null : usage.cache_read_input_tokens / total
74}
75hooks/stats.ts 60 lines1// Akumulasi pemakaian token per keluarga model dan format angka untuk tampilan.
2
3import type { Usage } from './pricing'
4
5export type Totals = {
6 turns: number
7 input: number
8 output: number
9 cacheRead: number
10 cacheWrite: number
11 usd: number
12}
13
14export const EMPTY_TOTALS: Totals = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, usd: 0 }
15
16export function addTurn(totals: Totals | undefined, usage: Usage, usd: number): Totals {
17 const base = totals ?? EMPTY_TOTALS
18 return {
19 turns: base.turns + 1,
20 input: base.input + usage.input_tokens,
21 output: base.output + usage.output_tokens,
22 cacheRead: base.cacheRead + usage.cache_read_input_tokens,
23 cacheWrite: base.cacheWrite + usage.cache_creation_input_tokens,
24 usd: base.usd + usd,
25 }
26}
27
28export function hitRateOf(totals: Totals): number | null {
29 const all = totals.input + totals.cacheRead + totals.cacheWrite
30 return all === 0 ? null : totals.cacheRead / all
31}
32
33export function formatTokens(count: number): string {
34 if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`
35 if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`
36 return String(count)
37}
38
39export function formatUsd(value: number): string {
40 return `$${value.toFixed(value < 1 ? 3 : 2)}`
41}
42
43export function formatPercent(rate: number | null): string {
44 return rate === null ? '-' : `${Math.round(rate * 100)}%`
45}
46
47/** Baris ringkas satu keluarga model untuk laporan /slash. */
48export function totalsLine(name: string, totals: Totals): string {
49 return [
50 name.padEnd(7),
51 `${totals.turns} giliran`,
52 `cache ${formatPercent(hitRateOf(totals))}`,
53 `input ${formatTokens(totals.input)}`,
54 `baca ${formatTokens(totals.cacheRead)}`,
55 `tulis ${formatTokens(totals.cacheWrite)}`,
56 `output ${formatTokens(totals.output)}`,
57 `≈${formatUsd(totals.usd)}`,
58 ].join(' · ')
59}
60types/index.d.ts 56 lines1// Kontrak nilai $.state milik plugin slash. Nilai ini milik percakapan:
2// dikembalikan ke awal oleh /clear.
3
4export type SlashTier = 0 | 1 | 2 | 3 | 4
5
6export type SlashRoute = {
7 /** Giliran yang dinilai (id dari turn.start). */
8 turnId: string
9 tier: SlashTier
10 kind: 'chat' | 'explain' | 'explore' | 'edit' | 'build' | 'debug' | 'design'
11 confidence: number
12 reasons: string[]
13 isContinuation: boolean
14 /** `otomatis` dari classifier, `paksa` dari /slash 0-4. */
15 source: 'otomatis' | 'paksa'
16}
17
18export type SlashMain = {
19 /** Model yang dipasang plugin untuk loop utama; null = pilihan Claude Code. */
20 pinnedModel: string | null
21 /** Model yang terakhir dipilih Claude Code sendiri, untuk mendeteksi /model. */
22 engineModel: string | null
23 /** Model diganti di luar plugin (/model, fallback): auto-switch berhenti. */
24 isUserPinned: boolean
25 /** Waktu request utama terakhir (ms), null = belum ada atau cache dianggap dingin. */
26 lastRequestAt: number | null
27 /** Token input request utama terakhir (tanpa cache + tulis + baca). */
28 lastContextTokens: number
29 lastModel: string | null
30 lastEffort: string | null
31 /** Bagian input request terakhir yang dibaca dari cache, 0..1. */
32 lastHitRate: number | null
33 /** Catatan keputusan terakhir, untuk /slash. */
34 lastDecision: string | null
35}
36
37export type SlashTotals = {
38 turns: number
39 input: number
40 output: number
41 cacheRead: number
42 cacheWrite: number
43 usd: number
44}
45
46declare module 'claude-code' {
47 interface PluginState {
48 slash: {
49 route: SlashRoute | null
50 main: SlashMain
51 override: SlashTier | null
52 totals: Record<string, SlashTotals>
53 }
54 }
55}
56