SLOPSHOPPER

Slash: hemat token

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

newcommandtoaststatuspromptagents
v0.1.0no licenseupdated 2026-10-02sofanaja44/slash
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · slash
› fix the failing auth test and add an audit log call ● slash: slash · router hemat token ● slash: Model utama : - ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /slash ● slash: Effort : - · cache giliran terakhir - ● slash: Biaya sesi : $0.420 (ledger Claude Code) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ slash: slash · siap · $0.420
README

slash: plugin Claude Code untuk hemat token

Plugin Claude Code yang memilih model dan effort secara otomatis sesuai tingkat kesulitan tugas, tanpa membuang prompt cache yang sudah terbayar.

  • Setiap prompt dinilai secara lokal (0 token) ke tier 0 (sangat mudah) sampai 4 (ekstrem), dalam bahasa Indonesia maupun Inggris.
  • Effort diatur per giliran (low sampai xhigh) di model yang tetap memakai cache saat effort berubah: Opus 5.5, Sonnet 5.5, dan Fable 5.1.
  • Model loop utama hanya diganti di titik yang murah, yaitu saat cache sudah dingin, atau saat naik model dengan biaya tulis ulang cache di bawah batas.
  • Subagent bawaan (Explore, general-purpose) dipilihkan model dari tugas yang didelegasikan. Plugin juga membawa tiga agen bertingkat: slash:scout, slash:quick, dan slash:deep.
  • Status line menampilkan tier, model/effort, hit rate cache, dan biaya sesi. Perintah /slash menampilkan laporan per model tanpa memakan token konteks.

Kenapa tidak asal ganti model tiap giliran

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):

ModelInputTulis cache 5 mnt / 1 jamBaca cacheOutput
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).

PilihanBiaya 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:

  1. mengatur effort setiap giliran, karena mengganti effort di model-model di atas tidak merusak cache;
  2. mengganti model hanya di batas tugas: sesi baru, setelah jeda lebih lama dari umur cache, setelah /compact, atau lewat subagent yang memang mulai dengan konteks kosong.

Cara kerja

TierContoh tugasModel utamaEffortSubagent general-purpose
0 sangat mudahtypo, rename, format, terjemahHaiku 4.5(tanpa effort)Haiku
1 mudahubah satu fungsi, unit test, penjelasanSonnet 5.5lowHaiku (cari/jelaskan) atau Sonnet
2 sedangfitur, bug biasa, refactor satu modulSonnet 5.5mediumSonnet
3 sulitarsitektur, race condition, performa, keamananOpus 5.5highOpus
4 ekstremaplikasi lengkap dari nol, tulis ulang totalOpus 5.5 (Fable 5.1 bila diizinkan)xhighOpus 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 dingin (giliran pertama, jeda di atas cache_ttl_minutes, atau setelah /compact): pindah ke model yang sesuai tier, naik maupun turun.
  • Cache hangat, naik model: boleh bila biaya menulis ulang konteks ke cache model baru tidak lebih dari max_switch_usd (default $1).
  • Cache hangat, turun model: selalu ditunda. Membaca cache model mahal biasanya lebih murah daripada menulis ulang konteks ke model murah.
  • Pergantian butuh keyakinan klasifikasi yang cukup. Sapaan seperti "hai" tidak memicu pergantian, dan "ok, lanjut" mewarisi tier tugas sebelumnya.
  • Kalau model kamu ganti sendiri lewat /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.

Pasang

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.

Pemakaian

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.

PerintahFungsi
/slashLaporan: tier terakhir, keputusan model, hit rate cache, biaya, dan statistik per model (sesi ini dan semua sesi)
/slash 0 … /slash 4Paksa tier untuk giliran-giliran berikutnya
/slash autoKembali ke mode otomatis, termasuk setelah kamu memakai /model
/slash resetHapus 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.

Pengaturan

Semua opsi bisa diubah lewat /config.

OpsiDefaultArti
auto_efforttrueEffort per giliran sesuai tier
route_subagentstruePilihkan model subagent bawaan dari tugasnya
auto_main_modelhemat-cacheoff = model loop utama tidak pernah diganti
max_switch_usd1Batas biaya naik model saat cache hangat
cache_ttl_minutes60Umur cache: 60 untuk langganan Claude, 5 untuk API key (kecuali promptCacheTtl diubah)
allow_fablefalseIzinkan Fable 5.1 untuk tier 4
policy_prompttrueTambahkan sekitar 150 token panduan delegasi ke system prompt (teks tetap, ikut ter-cache)
status_linetrueStatus 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.

Batasan

  • API mods masih early access dan bisa berubah antar rilis Claude Code. claude plugin validate dan claude plugin test adalah cara tercepat untuk memeriksanya setelah update.
  • Classifier berbasis kata kunci dan struktur prompt, jadi bisa salah menilai. /slash 0-4 tersedia untuk koreksi manual, dan polanya ada di hooks/classify.ts.
  • Pergantian model loop utama dan effort otomatis hanya aktif di Claude API langsung. Di Amazon Bedrock, Google Cloud, Microsoft Foundry, atau saat CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS diset, plugin tidak mengganti model maupun effort loop utama. Routing subagent tetap jalan.
  • Saat model diganti di tengah percakapan, hasil penalaran (thinking) model sebelumnya tidak terbawa ke model baru.
  • Statistik biaya per model adalah perkiraan dari tabel harga di hooks/pricing.ts. Angka biaya sesi di status line berasal dari ledger Claude Code sendiri.

Pengembangan

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.

Source 7 files
hooks/register.ts 380 lines
1// 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
380
hooks/classify.ts 185 lines
1// 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}
185
hooks/models.ts 55 lines
1// 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}
55
hooks/policy.ts 120 lines
1// 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}
120
hooks/pricing.ts 75 lines
1// 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}
75
hooks/stats.ts 60 lines
1// 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}
60
types/index.d.ts 56 lines
1// 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