Limits band above the prompt (5h, week, context, cache hit, context delta, distiller savings) with a turn detail line, a /usage-plus dashboard, and a Bash…

Mod cho Claude Code (≥ 2.1.287): dòng hạn mức phía trên ô prompt, bảng /usage-plus, và bộ chưng cất output Bash.
Band (phía trên prompt):
5h █░░░░░ 13% 2h19m | wk ██░░░░ 34% T2 10:00 | ctx ░░░░░░ 7% ▲+18k | cache 90% | dist -142k [+]
turn 4 req · in 61k (cache 55k · write 4k · new 2k) · out 2.3k · ~$0.42 · opus · ctx ▃▄▆█
5h / wk: hạn mức 5 giờ và tuần, thời điểm reset, ! khi dự báo sẽ cạn trước khi reset. Có toast ở 80% và 95%.ctx: mức đầy context; ▲+N / ▼-N: context đổi bao nhiêu trong turn vừa xong (đỏ nếu quá 10% cửa sổ).cache N%: tỉ lệ cache hit của turn vừa xong (xanh ≥ 80, vàng 50–79, đỏ < 50).dist -N: số ký tự distiller đã cắt trong phiên.[+] / /limits detail: dòng chi tiết turn (số request, token theo loại, chi phí API-equiv, model, biểu đồ ctx). Lựa chọn được nhớ giữa các phiên.dist, rồi ▲, rồi cache, rồi thu gọn thanh./usage-plus: pane ba tab, phím 1 Session, 2 Week, 3 Month, x đóng (cần focus pane: click hoặc ctrl+x tab). Token theo loại mỗi ngày, cache hit, tiền tiết kiệm nhờ cache, các lần cache bust kèm nguyên nhân (sau /compact, hết TTL, đổi model, không rõ), phân bổ theo model và project, lịch sử hạn mức và dự báo tuần, thống kê distiller. Mỗi lần mở sẽ chạy indexer (đọc tăng dần ~/.claude/projects).
Distiller (hook trên Bash): output dài (≥ 8000 ký tự) được rút gọn còn ≤ 4000 trước khi Claude đọc; bản đầy đủ luôn lưu ở <project>/.claude/distill/ (tự thêm vào .gitignore của repo git). Có bộ lọc riêng cho pytest (gom lỗi theo chữ ký, traceback đầu của mỗi nhóm), các log khác dùng bộ lọc chung (đầu, cuối và mọi dòng lỗi).
python *train*, docker logs, pip install, make/ninja/cargo/npm build) được bọc trước khi chạy: output đầy đủ ghi ra file, in bản rút gọn, giữ đúng mã thoát. Vì vậy quy tắc permission sẽ thấy lệnh đã bọc.| Lệnh | Việc |
|---|---|
/limits | Ẩn / hiện band |
/limits detail | Bật / tắt dòng chi tiết turn |
/usage-plus | Mở bảng usage (nếu trùng tên: /usage-x) |
/distill on / off | Bật / tắt distiller (nhớ giữa các phiên) |
/distill stats | Số lần, ký tự đã cắt theo loại, tỉ lệ đọc lại |
/distill last | Đường dẫn log đầy đủ của lần gần nhất |
Muốn xem output gốc của một lệnh: thêm NO_DISTILL=1 vào lệnh. Lệnh đọc file trong .claude/distill/ không bị chưng cất.
~/.local/share/limit-line/:
ledger/<ngày>.json mỗi request trực tiếp (token, model, project; không có nội dung)
measure/<tháng>.json các lần đọc hạn mức (thưa)
compact/<tháng>.json các lần compact
distill/<tháng>.json các lần chưng cất (kích thước, đường dẫn log)
index/state.json vị trí đã đọc của từng file JSONL
index/days/<ngày>.json tổng hợp theo ngày (giờ Asia/Bangkok)
Xóa dữ liệu: rm -rf ~/.local/share/limit-line (indexer sẽ đọc lại từ đầu lần sau), và rm -rf <project>/.claude/distill. Đọc lại lịch sử từ đầu: node scripts/indexer.mjs --rebuild.
hooks/pricing.ts (cập nhật 2026-09-25, cần xác nhận); với gói subscription đây không phải số tiền phải trả.usage.iterations của JSONL: indexer có tính, band và ledger trực tiếp thì không.node ≥ 23.6 trong PATH của Claude Code (indexer và wrapper của distiller chạy file .ts trực tiếp).claude plugin validate --strict . # manifest + module
claude plugin test . # test của engine
node --test tests/indexer.node.mjs # test của indexer
npx -p typescript@5 tsc -p . # kiểu (sau khi Claude Code đã sinh .claude-plugin/types)
node scripts/build-fixtures.mjs # sau khi thêm log vào tests/fixtures/<loại>/hooks/register.tsx 454 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, SessionContextUsage } from 'claude-code'
3
4import type { CompactMark, DayAgg, DistillEvent, LastTurn, MeasureSnapshot, Reading, RequestRecord, TurnAcc, View, Win } from '../types'
5import { bandLine, extrasOf, shortModel, turnLine } from './band'
6import { buildDash, lastDates } from './dashdata'
7import type { DashData } from './dashdata'
8import { dashboardLines } from './dashboard'
9import type { Tab } from './dashboard'
10import { basename, byDay, shouldSnapshot, toRecord } from './ledger'
11import { KINDS, addReading, fullAt, warnLevel } from './limits'
12import type { Kind, Segment } from './limits'
13import { OFF_KEY, registerDistiller } from './distiller'
14import { costOf } from './metrics'
15import { dataDir, dayKey, monthKey, TZ_OFFSET_MIN } from './paths'
16import { addStep, pushHistory } from './turnstats'
17
18const view = atom({ plugin: 'limit-line', key: 'view' } as const, { now: 0 } as View)
19const isHidden = atom({ plugin: 'limit-line', key: 'isHidden' } as const, false)
20const turnAcc = atom({ plugin: 'limit-line', key: 'turn' } as const, null as TurnAcc | null)
21const lastTurn = atom({ plugin: 'limit-line', key: 'lastTurn' } as const, null as LastTurn | null)
22const ctxHistory = atom({ plugin: 'limit-line', key: 'ctxHistory' } as const, [] as number[])
23const distSession = atom({ plugin: 'limit-line', key: 'distSession' } as const, 0)
24const expanded = atom({ plugin: 'limit-line', key: 'expanded' } as const, false)
25const distillOn = atom({ plugin: 'limit-line', key: 'distillOn' } as const, true)
26// Requests not yet written to the day's ledger file.
27const ledgerBuf = atom({ plugin: 'limit-line', key: 'ledgerBuf' } as const, [] as RequestRecord[])
28const lastSnap = atom({ plugin: 'limit-line', key: 'lastSnap' } as const, null as MeasureSnapshot | null)
29// What /usage-plus last computed, and the tab it shows.
30const dash = atom({ plugin: 'limit-line', key: 'dash' } as const, null as DashData | null)
31const dashTab = atom({ plugin: 'limit-line', key: 'dashTab' } as const, 'week' as Tab)
32
33const PANE_ID = 'usage-plus'
34const TABS: { tab: Tab; key: string; label: string }[] = [
35 { tab: 'session', key: '1', label: 'Session' },
36 { tab: 'week', key: '2', label: 'Week' },
37 { tab: 'month', key: '3', label: 'Month' },
38]
39
40// Remembered across sessions; $.state holds the live value.
41const EXPANDED_KEY = 'band.expanded'
42// The [+]/[-] button and the space before it.
43const BUTTON_COLUMNS = 4
44
45// Kept in $.store so the pace survives a restart inside the same window.
46type Readings = Partial<Record<Kind, Reading[]>>
47type Warned = Partial<Record<Kind, { resetsAt?: number; level: number }>>
48
49const LABEL: Record<Kind, string> = { five_hour: 'Hạn mức 5 giờ', seven_day: 'Hạn mức tuần' }
50
51function isKind(kind: string): kind is Kind {
52 return kind in KINDS
53}
54
55// $ goes into functions of this file only (the engine follows it no further).
56async function dataPath($: EngineInterface, rel: string): Promise<string> {
57 const home = await $.env.get('HOME')
58 if (!home) throw new Error('HOME is not set')
59 return `${dataDir(home)}/${rel}`
60}
61
62async function appendJson<T>($: EngineInterface, path: string, items: readonly T[]): Promise<void> {
63 if (items.length === 0) return
64 let list: T[] = []
65 try {
66 list = JSON.parse(await $.fs.read(path)) as T[]
67 } catch {
68 // A new file.
69 }
70 await $.fs.write(path, JSON.stringify([...list, ...items]))
71}
72
73async function readJsonFile<T>($: EngineInterface, path: string, fallback: T): Promise<T> {
74 try {
75 return JSON.parse(await $.fs.read(path)) as T
76 } catch {
77 return fallback
78 }
79}
80
81/** Runs the indexer, reads what the mod and the indexer keep, and computes the dashboard. */
82async function loadDash($: EngineInterface): Promise<DashData> {
83 const home = await $.env.get('HOME')
84 if (!home) throw new Error('HOME is not set')
85 const data = dataDir(home)
86 const now = await $.clock.now()
87
88 let indexer: { newLines: number; bad: number } | { error: string }
89 try {
90 const run = await $.process.run(['node', `${$.plugin.root}/scripts/indexer.mjs`, '--data', data], { timeoutMs: 120_000 })
91 const last = run.stdout.trim().split('\n').pop() ?? ''
92 indexer = run.exitCode === 0 ? (JSON.parse(last) as { newLines: number; bad: number }) : { error: run.stderr.trim().split('\n').pop() ?? `exit ${run.exitCode}` }
93 } catch (err) {
94 indexer = { error: String(err).slice(0, 160) }
95 }
96
97 const today = dayKey(now, TZ_OFFSET_MIN)
98 const dates = lastDates(today, 35)
99 const days: DayAgg[] = []
100 for (const date of dates) {
101 const d = await readJsonFile<DayAgg | null>($, `${data}/index/days/${date}.json`, null)
102 if (d) days.push(d)
103 }
104 const ledger: RequestRecord[] = []
105 for (const date of dates.slice(-2)) ledger.push(...(await readJsonFile<RequestRecord[]>($, `${data}/ledger/${date}.json`, [])))
106 const months = [...new Set([monthKey(now - 35 * 86_400_000, TZ_OFFSET_MIN), monthKey(now, TZ_OFFSET_MIN)])]
107 const marks: CompactMark[] = []
108 const snaps: MeasureSnapshot[] = []
109 const distill: DistillEvent[] = []
110 for (const m of months) {
111 marks.push(...(await readJsonFile<CompactMark[]>($, `${data}/compact/${m}.json`, [])))
112 snaps.push(...(await readJsonFile<MeasureSnapshot[]>($, `${data}/measure/${m}.json`, [])))
113 distill.push(...(await readJsonFile<DistillEvent[]>($, `${data}/distill/${m}.json`, [])))
114 }
115 return buildDash({ now, sessionId: await $.session.id(), days, ledger, marks, snaps, distill, shortModel, indexer })
116}
117
118/** Writes the buffered requests to their day files. */
119async function flushLedger($: EngineInterface): Promise<void> {
120 const buf = await read($, ledgerBuf)
121 if (buf.length === 0) return
122 await update($, ledgerBuf, () => [])
123 for (const [day, records] of byDay(buf)) {
124 await appendJson($, await dataPath($, `ledger/${day}.json`), records)
125 }
126}
127
128async function toggleExpanded($: EngineInterface): Promise<boolean> {
129 const isExpanded = await update($, expanded, x => !x)
130 await $.store.set(EXPANDED_KEY, isExpanded)
131 return isExpanded
132}
133
134// Closes the turn: its sums, the context after it and the change since the last one.
135async function finishTurn($: EngineInterface, turnId: string, usage: TurnAcc | null): Promise<void> {
136 const acc = await read($, turnAcc)
137 const turn = acc !== null && acc.turnId === turnId ? acc : usage
138 await update($, turnAcc, () => null)
139 if (turn === null) return
140 const { context } = await $.session.usage()
141 const prev = await read($, lastTurn)
142 const ctxEnd = context.tokens
143 const delta = ctxEnd !== undefined && prev?.ctxEnd !== undefined ? ctxEnd - prev.ctxEnd : undefined
144 const done: LastTurn = { ...turn, window: context.window }
145 if (ctxEnd !== undefined) done.ctxEnd = ctxEnd
146 if (delta !== undefined) done.delta = delta
147 await update($, lastTurn, () => done)
148 if (ctxEnd !== undefined) await update($, ctxHistory, h => pushHistory(h, ctxEnd))
149}
150
151async function measure(
152 $: EngineInterface,
153 rateLimits: readonly SessionRateLimit[],
154 context: SessionContextUsage,
155): Promise<void> {
156 const now = await $.clock.now()
157 const readings = ((await $.store.get('readings')) ?? {}) as Readings
158 const warned = ((await $.store.get('warned')) ?? {}) as Warned
159 const next: View = { now }
160
161 for (const limit of rateLimits) {
162 if (!isKind(limit.kind)) continue
163 const windowMs = KINDS[limit.kind]
164 const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : undefined
165 const list = addReading(readings[limit.kind] ?? [], { at: now, pct: limit.percentUsed }, windowMs)
166 readings[limit.kind] = list
167 const win: Win = { pct: limit.percentUsed }
168 if (resetsAt !== undefined && !Number.isNaN(resetsAt)) win.resetsAt = resetsAt
169 const eta = fullAt(list, windowMs, win.resetsAt)
170 if (eta !== undefined) win.fullAt = eta
171 if (limit.kind === 'five_hour') next.five = win
172 else next.week = win
173
174 // Toast once per threshold per window; a new window starts over.
175 const level = warnLevel(limit.percentUsed)
176 const seen = warned[limit.kind]
177 const seenLevel = seen && seen.resetsAt === win.resetsAt ? seen.level : 0
178 if (level > seenLevel) {
179 $.ui.toast(`⚠ ${LABEL[limit.kind]} đã dùng ${Math.round(limit.percentUsed)}%`, { timeoutMs: 8000 })
180 }
181 warned[limit.kind] = { resetsAt: win.resetsAt, level: Math.max(level, seenLevel) }
182 }
183
184 if (context.percent !== undefined) next.ctx = context.percent
185
186 await $.store.set('readings', readings)
187 await $.store.set('warned', warned)
188 await update($, view, () => next)
189}
190
191export const register: Register = on => {
192 registerDistiller(on)
193
194 on('session.start', async ($, e, next) => {
195 const result = await next(e)
196 // The readout first: a failure registering the command must not cost it.
197 try {
198 const usage = await $.session.usage()
199 await measure($, usage.rateLimits, usage.context)
200 } catch {
201 // No reading yet; session.measure brings the first one.
202 }
203 try {
204 if ((await $.store.get(EXPANDED_KEY)) === true) await update($, expanded, () => true)
205 } catch {
206 // Collapsed is the default.
207 }
208 try {
209 await $.command.register({
210 name: 'limits',
211 description: 'Ẩn / hiện dòng hạn mức; /limits detail: bật/tắt dòng chi tiết turn',
212 immediate: true,
213 })
214 } catch {
215 // The line still draws; only the toggle is missing.
216 }
217 try {
218 await update($, distillOn, () => true)
219 if ((await $.store.get(OFF_KEY)) === true) await update($, distillOn, () => false)
220 } catch {
221 // The distiller is on by default.
222 }
223 try {
224 await $.command.register({ name: 'usage-plus', description: 'Bảng usage: phiên / tuần / tháng', immediate: true })
225 } catch {
226 try {
227 await $.command.register({ name: 'usage-x', description: 'Bảng usage: phiên / tuần / tháng', immediate: true })
228 } catch {
229 // No dashboard command this session.
230 }
231 }
232 try {
233 await $.command.register({ name: 'distill', description: 'Distiller: /distill on | off | stats | last' })
234 } catch {
235 // A clash with another command's name: the fallback name (NOTES.md).
236 try {
237 await $.command.register({ name: 'distill-x', description: 'Distiller: on | off | stats | last' })
238 } catch {
239 // No command; the distiller still runs.
240 }
241 }
242 // Keep the countdowns moving while nobody types.
243 $.clock.every(60_000, () => {
244 void $.clock.now().then(now => update($, view, v => ({ ...v, now })))
245 })
246 return result
247 })
248
249 on('session.measure', async ($, e, next) => {
250 const result = await next(e)
251 await measure($, e.rateLimits, e.context)
252 try {
253 const five = e.rateLimits.find(r => r.kind === 'five_hour')
254 const week = e.rateLimits.find(r => r.kind === 'seven_day')
255 if (five || week) {
256 const ts = await $.clock.now()
257 const snap: MeasureSnapshot = { ts, fiveHourPct: five?.percentUsed ?? 0, weekPct: week?.percentUsed ?? 0 }
258 const r5 = five?.resetsAt ? Date.parse(five.resetsAt) : NaN
259 const rw = week?.resetsAt ? Date.parse(week.resetsAt) : NaN
260 if (!Number.isNaN(r5)) snap.resetsAt5h = r5
261 if (!Number.isNaN(rw)) snap.resetsAtWeek = rw
262 if (shouldSnapshot(snap, await read($, lastSnap))) {
263 await update($, lastSnap, () => snap)
264 await appendJson($, await dataPath($, `measure/${monthKey(ts, TZ_OFFSET_MIN)}.json`), [snap])
265 }
266 }
267 } catch {
268 // History misses one reading.
269 }
270 return result
271 })
272
273 // A compaction that went through marks the session, for the bust it causes.
274 on('session.compact', async ($, e, next) => {
275 const result = await next(e)
276 try {
277 if (e.trigger !== 'precompute' && e.agentId === undefined && !('skip' in result && result.skip !== undefined)) {
278 const ts = await $.clock.now()
279 const mark: CompactMark = { ts, sessionId: await $.session.id(), trigger: e.trigger }
280 await appendJson($, await dataPath($, `compact/${monthKey(ts, TZ_OFFSET_MIN)}.json`), [mark])
281 }
282 } catch {
283 // The bust will read as unknown.
284 }
285 return result
286 })
287
288 // One model request: its usage joins the turn's sums. Subagents' requests do not.
289 on('turn.step', async function* ($, e, next) {
290 // The model's answer time: the clock before and after the request, tools not included.
291 const t0 = await $.clock.now().catch(() => undefined)
292 const result = yield* next(e)
293 const t1 = await $.clock.now().catch(() => undefined)
294 const ms = t0 !== undefined && t1 !== undefined ? t1 - t0 : undefined
295 try {
296 await update($, turnAcc, acc => addStep(acc, { turnId: e.turnId, agentId: e.agentId, usage: result.usage, ...(ms !== undefined ? { ms } : {}) }))
297 } catch {
298 // The band misses one request; the turn goes on.
299 }
300 try {
301 // The ledger keeps subagents' requests too, marked.
302 const usage = result.usage
303 if (usage !== null) {
304 const record = toRecord(usage, {
305 ts: await $.clock.now(),
306 sessionId: await $.session.id(),
307 project: basename(await $.session.cwd()),
308 turnId: e.turnId,
309 ...(e.agentId !== undefined ? { agentId: e.agentId } : {}),
310 ...(ms !== undefined ? { ms } : {}),
311 })
312 await update($, ledgerBuf, buf => [...buf, record])
313 }
314 } catch {
315 // The ledger misses one request; the indexer still has it from the transcript.
316 }
317 return result
318 })
319
320 on('turn.complete', async ($, e, next) => {
321 const result = await next(e)
322 try {
323 await flushLedger($)
324 } catch {
325 // The buffer waits for the next turn.
326 }
327 if (e.agentId !== undefined) return result
328 try {
329 // When no step was summed (a hook above answered them), the turn's own total stands in.
330 const u = e.usage
331 const fallback: TurnAcc | null = u
332 ? {
333 turnId: e.turnId,
334 requests: 1,
335 input: u.input_tokens,
336 output: u.output_tokens,
337 cacheRead: u.cache_read_input_tokens,
338 cacheWrite: u.cache_creation_input_tokens,
339 model: u.model,
340 }
341 : null
342 await finishTurn($, e.turnId, fallback)
343 } catch {
344 // The extras keep the last turn's figures.
345 }
346 return result
347 })
348
349 on('command.run', { command: /^usage-(plus|x)$/ }, async $ => {
350 try {
351 // The ledger's buffer first, so the session tab has this turn's requests.
352 await flushLedger($)
353 } catch {
354 // The session tab is a turn behind.
355 }
356 const computed = await loadDash($)
357 await update($, dash, () => computed)
358 const opened = await $.ui.open({ id: PANE_ID, title: 'usage-plus', focus: true, closeOnEscape: true, rows: 30 })
359 if (!opened.isPlaced) return { text: `usage-plus: ${opened.reason}` }
360 // A redraw for a pane that was already open.
361 $.ui.invalidate('ui.render')
362 return {}
363 })
364
365 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e, next) => {
366 const d = await read($, dash)
367 const tab = await read($, dashTab)
368 const { Box, Button, Text } = $.ui.resolve(e)
369 const cols = e.props.bodyColumns
370 const lines = d === null ? [[{ text: 'Gõ /usage-plus để tính lại.', dim: true }]] : dashboardLines(d, tab, cols)
371 return (
372 <Box flexDirection="column">
373 <Box flexDirection="row">
374 {TABS.map(t => (
375 <Box key={`tab-box-${t.tab}`} marginRight={3}>
376 <Button
377 key={`tab-${t.tab}`}
378 label={t.label}
379 hotkey={t.key}
380 plain
381 dimColor={tab !== t.tab}
382 onPress={() => void update($, dashTab, () => t.tab)}
383 />
384 </Box>
385 ))}
386 </Box>
387 <Text dimColor wrap="truncate">{'─'.repeat(cols)}</Text>
388 <Text> </Text>
389 {lines.map((line, i) => (
390 <Box key={`l${i}`} flexDirection="row">
391 {line.length === 0 ? (
392 <Text> </Text>
393 ) : (
394 line.map((s, j) => (
395 <Text key={`l${i}s${j}`} color={s.color} dimColor={s.dim} wrap="truncate">
396 {s.text}
397 </Text>
398 ))
399 )}
400 </Box>
401 ))}
402 </Box>
403 )
404 })
405
406 on('command.run', { command: 'limits' }, async ($, e) => {
407 if (e.args.trim() === 'detail') {
408 const isExpanded = await toggleExpanded($)
409 return { text: isExpanded ? 'Đã bật dòng chi tiết turn.' : 'Đã tắt dòng chi tiết turn.' }
410 }
411 const hidden = await update($, isHidden, h => !h)
412 return { text: hidden ? 'Đã ẩn dòng hạn mức. Gõ /limits để hiện lại.' : 'Đã hiện dòng hạn mức.' }
413 })
414
415 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
416 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
417 const current = await read($, view)
418 const last = await read($, lastTurn)
419 const isExpanded = await read($, expanded)
420 const offsetMin = -new Date(current.now || Date.now()).getTimezoneOffset()
421 const columns = e.props.bodyColumns - 2
422 const extras = extrasOf(last, await read($, distSession))
423 const line = bandLine(current, extras, columns - BUTTON_COLUMNS, offsetMin)
424 const detail = isExpanded && last !== null
425 ? turnLine(last, await read($, ctxHistory), costOf(last, last.model), columns)
426 : undefined
427 const { Box, Button, Text } = $.ui.resolve(e)
428 const texts = (segments: Segment[], prefix: string) =>
429 segments.map((s, i) => (
430 <Text key={`${prefix}${i}`} color={s.color} dimColor={s.dim} wrap="truncate">
431 {s.text}
432 </Text>
433 ))
434
435 return (
436 <Box flexDirection="column" paddingX={1}>
437 <Box flexDirection="row">
438 <Box flexDirection="row" flexGrow={1}>
439 {texts(line, 's')}
440 </Box>
441 <Button
442 key="expand"
443 label={isExpanded ? '−' : '+'}
444 plain
445 dimColor
446 onPress={() => void toggleExpanded($)}
447 />
448 </Box>
449 {detail && <Box flexDirection="row">{texts(detail, 'd')}</Box>}
450 </Box>
451 )
452 })
453}
454hooks/band.ts 165 lines1// The band's two lines: limits plus the last turn's extras, and the turn detail line.
2// Pure: built from values, measured in cells, trimmed to fit.
3
4import type { LastTurn, View } from '../types'
5import { lineFor, SEP, TIERS, width } from './limits.ts'
6import type { Tier } from './limits.ts'
7import type { Segment } from './limits.ts'
8import { cacheHit, tokPerSec } from './metrics.ts'
9
10export type Extras = {
11 /** Cache hit of the last turn, 0..1. */
12 cacheHit?: number
13 /** Context change over the last turn, tokens. */
14 delta?: number
15 /** The context window `delta` is judged against. */
16 window?: number
17 /** Output tokens per second of the last turn, when it had a long enough answer. */
18 tps?: number
19 /** Characters the distiller cut this session. */
20 dist: number
21}
22
23/** The extras of a finished turn; nothing before the first one. */
24export function extrasOf(last: LastTurn | null, dist: number): Extras {
25 if (last === null) return { dist }
26 const tps = tokPerSec(last.genOut, last.genMs)
27 return { cacheHit: cacheHit(last), delta: last.delta, window: last.window, ...(tps !== undefined ? { tps } : {}), dist }
28}
29
30export function fmtTokens(n: number): string {
31 const a = Math.abs(n)
32 if (a < 1000) return `${Math.round(n)}`
33 if (a < 10_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`
34 if (a < 1_000_000) return `${Math.round(n / 1000)}k`
35 return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
36}
37
38/** Green from 80%, yellow from 50%, red below: a high cache hit is good. */
39export function cacheColor(pct: number): string {
40 if (pct >= 80) return 'green'
41 if (pct >= 50) return 'yellow'
42 return 'red'
43}
44
45/** Red when the turn moved the context by more than a tenth of the window. */
46export function deltaColor(delta: number, window: number | undefined): string {
47 return window !== undefined && Math.abs(delta) > window * 0.1 ? 'red' : 'yellow'
48}
49
50export function deltaText(delta: number): string {
51 return delta < 0 ? `▼-${fmtTokens(-delta)}` : `▲+${fmtTokens(delta)}`
52}
53
54const LEVELS = '▁▂▃▄▅▆▇█'
55
56/** One cell per value, scaled to the largest. */
57export function sparkline(values: readonly number[]): string {
58 const max = Math.max(0, ...values)
59 return values.map(v => LEVELS[max > 0 ? Math.round((Math.max(0, v) / max) * 7) : 0]).join('')
60}
61
62export function shortModel(model: string): string {
63 return /opus|sonnet|haiku|fable|mythos/.exec(model)?.[0] ?? model
64}
65
66/** Green from 60 tok/s, yellow from 30, red below. */
67export function tpsColor(tps: number): string {
68 if (tps >= 60) return 'green'
69 if (tps >= 30) return 'yellow'
70 return 'red'
71}
72
73export function fmtTps(tps: number): string {
74 return `${Math.round(tps)}`
75}
76
77type Extra = 'cache' | 'delta' | 'tps' | 'dist'
78// Lowest priority last: dropped first.
79const EXTRA_ORDER: Extra[] = ['cache', 'delta', 'tps', 'dist']
80
81function withExtras(base: Segment[], view: View, ex: Extras, keep: readonly Extra[]): Segment[] {
82 const out = [...base]
83 if (keep.includes('delta') && ex.delta !== undefined) {
84 const seg = { text: deltaText(ex.delta), color: deltaColor(ex.delta, ex.window) }
85 // Next to the ctx figure it changes; on its own when there is none.
86 if (view.ctx !== undefined) out.push({ text: ' ' }, seg)
87 else out.push(...(out.length > 0 ? [SEP] : []), { text: 'ctx ', dim: true }, seg)
88 }
89 if (keep.includes('cache') && ex.cacheHit !== undefined) {
90 const pct = Math.round(ex.cacheHit * 100)
91 out.push(...(out.length > 0 ? [SEP] : []), { text: 'cache ', dim: true }, { text: `${pct}%`, color: cacheColor(pct) })
92 }
93 if (keep.includes('tps') && ex.tps !== undefined) {
94 out.push(...(out.length > 0 ? [SEP] : []), { text: '⚡ ', dim: true }, { text: `${fmtTps(ex.tps)} tok/s`, color: tpsColor(ex.tps) })
95 }
96 if (keep.includes('dist') && ex.dist > 0) {
97 out.push(...(out.length > 0 ? [SEP] : []), { text: 'dist ', dim: true }, { text: `-${fmtTokens(ex.dist)}` })
98 }
99 return out
100}
101
102/** The widest first line that fits `columns`: extras go lowest priority first, then the
103 * limits drop detail tier by tier as before. */
104export function bandLine(view: View, ex: Extras, columns: number, offsetMin: number): Segment[] {
105 const hasLimits = view.five !== undefined || view.week !== undefined || view.ctx !== undefined
106 const hasExtras = ex.cacheHit !== undefined || ex.delta !== undefined || ex.tps !== undefined || ex.dist > 0
107 if (!hasLimits && !hasExtras) return [{ text: 'limits: chờ lượt trả lời đầu tiên…', dim: true }]
108
109 const [full, ...narrower] = TIERS as [Tier, ...Tier[]]
110 let line: Segment[] = []
111 for (let n = EXTRA_ORDER.length; n >= 0; n--) {
112 line = withExtras(lineFor(view, full, offsetMin), view, ex, EXTRA_ORDER.slice(0, n))
113 if (width(line) <= columns) return line
114 }
115 for (const tier of narrower) {
116 line = lineFor(view, tier, offsetMin)
117 if (width(line) <= columns) return line
118 }
119 return line
120}
121
122/** `turn 4 req · in 61k (cache 55k · write 4k · new 2k) · out 2.3k · ~$0.42 · opus · ctx ▂▃▄▆█`,
123 * its least needed parts dropped until it fits. */
124export function turnLine(t: LastTurn, history: readonly number[], cost: number | undefined, columns: number): Segment[] {
125 const parts: { prio: number; segs: Segment[] }[] = [
126 { prio: 1, segs: [{ text: 'turn ', dim: true }, { text: `${t.requests} req` }] },
127 {
128 prio: 1,
129 segs: [{ text: 'in ', dim: true }, { text: fmtTokens(t.input + t.cacheRead + t.cacheWrite) }],
130 },
131 {
132 prio: 3,
133 segs: [
134 {
135 text: ` (cache ${fmtTokens(t.cacheRead)} · write ${fmtTokens(t.cacheWrite)} · new ${fmtTokens(t.input)})`,
136 dim: true,
137 },
138 ],
139 },
140 { prio: 1, segs: [{ text: 'out ', dim: true }, { text: fmtTokens(t.output) }] },
141 ]
142 const tps = tokPerSec(t.genOut, t.genMs)
143 if (tps !== undefined) parts.push({ prio: 2, segs: [{ text: fmtTps(tps), color: tpsColor(tps) }, { text: ' tok/s', dim: true }] })
144 if (cost !== undefined) parts.push({ prio: 2, segs: [{ text: `~$${cost.toFixed(2)}` }] })
145 if (t.model) parts.push({ prio: 4, segs: [{ text: shortModel(t.model), dim: true }] })
146 if (history.length > 1) parts.push({ prio: 5, segs: [{ text: 'ctx ', dim: true }, { text: sparkline(history), color: 'cyan' }] })
147
148 const DOT: Segment = { text: ' · ', dim: true }
149 const build = (maxPrio: number): Segment[] => {
150 const out: Segment[] = []
151 for (const [i, p] of parts.entries()) {
152 if (p.prio > maxPrio) continue
153 // The breakdown hangs off `in`; every other part is its own.
154 if (i !== 2 && out.length > 0) out.push(DOT)
155 out.push(...p.segs)
156 }
157 return out
158 }
159 for (let maxPrio = 5; maxPrio > 1; maxPrio--) {
160 const line = build(maxPrio)
161 if (width(line) <= columns) return line
162 }
163 return build(1)
164}
165hooks/dashdata.ts 231 lines1// What /usage-plus shows, computed from the files the mod and the indexer keep. Pure.
2
3import type { BustEvent, CompactMark, DashData, DayAgg, DayRow, DistillEvent, DistillSummary, LimitsHistory, MeasureSnapshot, RequestRecord, Share, Tokens, TurnRow } from '../types'
4
5export type { DashData, DayRow, DistillSummary, LimitsHistory, Share, TurnRow }
6import { bustCause, cacheHit, costOf, ctxOf, elsewhereShare, isBust, savings, timeable, tokPerSec } from './metrics.ts'
7import type { Prev } from './metrics.ts'
8import { dayKey, TZ_OFFSET_MIN } from './paths.ts'
9
10const DAY_MS = 86_400_000
11const FIVE_H_MS = 5 * 3_600_000
12const WEEK_MS = 7 * DAY_MS
13
14export const totalOf = (t: { input: number; output: number; cacheRead: number; cacheWrite: number }) =>
15 t.input + t.output + t.cacheRead + t.cacheWrite
16
17/** `n` dates ending at `today`, oldest first. */
18export function lastDates(today: string, n: number): string[] {
19 const [y, m, d] = today.split('-').map(Number) as [number, number, number]
20 const base = Date.UTC(y, m - 1, d)
21 return Array.from({ length: n }, (_, i) => new Date(base - (n - 1 - i) * DAY_MS).toISOString().slice(0, 10))
22}
23
24function sumModels(day: DayAgg, f: (t: Tokens, model: string) => number | undefined): number {
25 return Object.entries(day.byModel).reduce((n, [model, t]) => n + (f(t, model) ?? 0), 0)
26}
27
28export function dayRows(days: readonly DayAgg[], dates: readonly string[]): DayRow[] {
29 const byDate = new Map(days.map(d => [d.date, d]))
30 return dates.map(date => {
31 const d = byDate.get(date)
32 if (!d) return { date, read: 0, write: 0, input: 0, output: 0, cost: 0, savings: 0, requests: 0 }
33 const t = d.total
34 const row: DayRow = {
35 date,
36 read: t.cacheRead,
37 write: t.cacheWrite,
38 input: t.input,
39 output: t.output,
40 cost: t.costUsd,
41 savings: sumModels(d, (tok, model) => savings(tok, model)),
42 requests: d.requests,
43 }
44 const hit = cacheHit(t)
45 if (hit !== undefined) row.hit = hit
46 return row
47 })
48}
49
50export function shares(days: readonly DayAgg[], key: 'byModel' | 'byProject', top = 4, short = (s: string) => s): Share[] {
51 const sums = new Map<string, number>()
52 for (const d of days) {
53 for (const [name, t] of Object.entries(d[key])) sums.set(short(name), (sums.get(short(name)) ?? 0) + totalOf(t))
54 }
55 const all = [...sums.values()].reduce((a, b) => a + b, 0)
56 const sorted = [...sums.entries()].sort((a, b) => b[1] - a[1])
57 const head = sorted.slice(0, top).map(([name, tokens]) => ({ name, tokens, share: all > 0 ? tokens / all : 0 }))
58 const rest = sorted.slice(top).reduce((n, [, t]) => n + t, 0)
59 if (rest > 0) head.push({ name: 'khác', tokens: rest, share: rest / all })
60 return head
61}
62
63/** The session's main-loop turns, oldest first. */
64export function sessionTurns(records: readonly RequestRecord[]): TurnRow[] {
65 const rows: TurnRow[] = []
66 const byTurn = new Map<string, RequestRecord[]>()
67 for (const r of records) {
68 if (r.isSubagent) continue
69 const id = r.turnId ?? `@${r.ts}`
70 if (!byTurn.has(id)) byTurn.set(id, [])
71 byTurn.get(id)?.push(r)
72 }
73 let prevCtx: number | undefined
74 for (const [turnId, rs] of byTurn) {
75 const sum = rs.reduce(
76 (t, r) => ({ input: t.input + r.input, output: t.output + r.output, cacheRead: t.cacheRead + r.cacheRead, cacheWrite: t.cacheWrite + r.cacheWrite }),
77 { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
78 )
79 const last = rs[rs.length - 1] as RequestRecord
80 const ctx = ctxOf(last)
81 const row: TurnRow = {
82 turnId,
83 ts: (rs[0] as RequestRecord).ts,
84 requests: rs.length,
85 ctx,
86 cost: rs.reduce((n, r) => n + (costOf(r, r.model) ?? 0), 0),
87 }
88 const hit = cacheHit(sum)
89 if (hit !== undefined) row.hit = hit
90 const timed = rs.filter(r => timeable(r.output, r.durationMs))
91 const tps = tokPerSec(timed.reduce((n, r) => n + r.output, 0), timed.reduce((n, r) => n + (r.durationMs as number), 0))
92 if (tps !== undefined) row.tps = tps
93 if (prevCtx !== undefined) row.delta = ctx - prevCtx
94 prevCtx = ctx
95 rows.push(row)
96 }
97 return rows
98}
99
100/** Busts among a session's live requests (no 5m/1h split: the live TTL default). */
101export function sessionBusts(records: readonly RequestRecord[], marks: readonly CompactMark[]): BustEvent[] {
102 const out: BustEvent[] = []
103 let prev: (Prev & { ts: number }) | undefined
104 for (const r of records) {
105 if (r.isSubagent) continue
106 if (prev && isBust(r, prev)) {
107 const p = prev
108 const compactBetween = marks.some(m => m.sessionId === r.sessionId && m.ts >= p.ts && m.ts <= r.ts)
109 out.push({
110 ts: r.ts,
111 sessionId: r.sessionId,
112 project: r.project,
113 model: r.model,
114 prevModel: p.model,
115 gapMs: r.ts - p.ts,
116 ctx: ctxOf(r),
117 cacheRead: r.cacheRead,
118 cause: bustCause(r, p, compactBetween),
119 })
120 }
121 prev = { ts: r.ts, model: r.model }
122 }
123 return out
124}
125
126function windowsOf(snaps: readonly MeasureSnapshot[]): Map<number, MeasureSnapshot[]> {
127 const out = new Map<number, MeasureSnapshot[]>()
128 for (const s of snaps) {
129 if (s.resetsAt5h === undefined) continue
130 const key = Math.round(s.resetsAt5h / 60_000) * 60_000
131 if (!out.has(key)) out.set(key, [])
132 out.get(key)?.push(s)
133 }
134 return out
135}
136
137export function limitsHistory(snaps: readonly MeasureSnapshot[], records: readonly RequestRecord[], now: number, dates: readonly string[]): LimitsHistory {
138 const out: LimitsHistory = { five: [], week: [] }
139 const windows = [...windowsOf(snaps).entries()].sort((a, b) => a[0] - b[0])
140 const peaks = windows.map(([reset, ss]) => {
141 const top = ss.reduce((a, b) => (b.fiveHourPct > a.fiveHourPct ? b : a))
142 return { reset, pct: top.fiveHourPct, ts: top.ts }
143 })
144 out.five = peaks.slice(-7).map(p => p.pct)
145 const peak = peaks.slice(-7).reduce<(typeof peaks)[number] | undefined>((a, b) => (a === undefined || b.pct > a.pct ? b : a), undefined)
146 if (peak) out.fivePeak = { pct: peak.pct, ts: peak.ts }
147
148 out.week = dates.map(date => {
149 const ss = snaps.filter(s => dayKey(s.ts, TZ_OFFSET_MIN) === date)
150 return ss.length > 0 ? Math.max(...ss.map(s => s.weekPct)) : undefined
151 })
152 const last = snaps[snaps.length - 1]
153 if (last) {
154 out.weekNow = last.weekPct
155 if (last.resetsAtWeek !== undefined) {
156 const elapsed = last.ts - (last.resetsAtWeek - WEEK_MS)
157 if (elapsed > 6 * 3_600_000) out.weekForecast = Math.round((last.weekPct / elapsed) * WEEK_MS)
158 }
159 }
160
161 // Usage elsewhere: each 5-hour window's peak against what this machine spent in it.
162 const costIn = (from: number, to: number) =>
163 records.filter(r => r.ts >= from && r.ts < to).reduce((n, r) => n + (costOf(r, r.model) ?? 0), 0)
164 const done = peaks.filter(p => p.reset <= now).map(p => ({ pctUsed: p.pct, localCost: costIn(p.reset - FIVE_H_MS, p.reset) }))
165 const current = peaks.find(p => p.reset > now)
166 if (current) {
167 const share = elsewhereShare(done, { pctUsed: current.pct, localCost: costIn(current.reset - FIVE_H_MS, now) })
168 if (share !== undefined) out.elsewhere = share
169 }
170 return out
171}
172
173export function distillSummary(events: readonly DistillEvent[]): DistillSummary {
174 const byKind = new Map<string, { kind: string; n: number; cut: number }>()
175 let cut = 0
176 let reread = 0
177 for (const e of events) {
178 const c = Math.max(0, e.rawChars - e.outChars)
179 cut += c
180 if (e.reread) reread++
181 const k = byKind.get(e.kind) ?? { kind: e.kind, n: 0, cut: 0 }
182 k.n++
183 k.cut += c
184 byKind.set(e.kind, k)
185 }
186 return { count: events.length, cut, reread, byKind: [...byKind.values()].sort((a, b) => b.cut - a.cut) }
187}
188
189export type DashInput = {
190 now: number
191 sessionId: string
192 days: DayAgg[]
193 ledger: RequestRecord[]
194 marks: CompactMark[]
195 snaps: MeasureSnapshot[]
196 distill: DistillEvent[]
197 shortModel: (m: string) => string
198 indexer?: { newLines: number; bad: number } | { error: string }
199}
200
201export function buildDash(i: DashInput): DashData {
202 const today = dayKey(i.now, TZ_OFFSET_MIN)
203 const dates35 = lastDates(today, 35)
204 const week = new Set(dates35.slice(-7))
205 const month = new Set(dates35.slice(-30))
206 const weekDays = i.days.filter(d => week.has(d.date))
207 const monthDays = i.days.filter(d => month.has(d.date))
208 const mine = i.ledger.filter(r => r.sessionId === i.sessionId).sort((a, b) => a.ts - b.ts)
209 const inWeek = (ts: number) => week.has(dayKey(ts, TZ_OFFSET_MIN))
210 const inMonth = (ts: number) => month.has(dayKey(ts, TZ_OFFSET_MIN))
211
212 const data: DashData = {
213 builtAt: i.now,
214 sessionId: i.sessionId,
215 days: dayRows(i.days, dates35),
216 week: { byModel: shares(weekDays, 'byModel', 3, i.shortModel), byProject: shares(weekDays, 'byProject', 3) },
217 month: { byModel: shares(monthDays, 'byModel', 3, i.shortModel), byProject: shares(monthDays, 'byProject', 3) },
218 busts: weekDays
219 .flatMap(d => d.busts)
220 .sort((a, b) => b.ts - a.ts)
221 .slice(0, 5),
222 session: { turns: sessionTurns(mine), busts: sessionBusts(mine, i.marks) },
223 limits: limitsHistory(i.snaps, i.ledger, i.now, dates35.slice(-7)),
224 distillWeek: distillSummary(i.distill.filter(e => inWeek(e.ts))),
225 distillMonth: distillSummary(i.distill.filter(e => inMonth(e.ts))),
226 }
227 if (i.indexer && 'error' in i.indexer) data.indexerError = i.indexer.error
228 else if (i.indexer && i.indexer.newLines > 0) data.badShare = i.indexer.bad / i.indexer.newLines
229 return data
230}
231hooks/dashboard.ts 293 lines1// /usage-plus as lines of coloured segments, per tab and width. Pure: the render hook turns
2// each segment into a Text and adds the tab buttons.
3
4import type { BustEvent } from '../types'
5import { cacheColor, fmtTokens, fmtTps, shortModel, tpsColor } from './band.ts'
6import { columns, fit, hbar, spark, stacked } from './charts.ts'
7import type { DashData, DayRow, DistillSummary, Share } from './dashdata.ts'
8import { local } from './paths.ts'
9import { TZ_OFFSET_MIN } from './paths.ts'
10import type { Segment } from './limits.ts'
11import { width } from './limits.ts'
12
13export type Tab = 'session' | 'week' | 'month'
14export type Line = Segment[]
15
16export const COLORS = { read: 'green', write: 'yellow', input: 'blue', output: 'magenta' } as const
17const WEEKDAYS = ['CN', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7']
18
19const dim = (text: string): Segment => ({ text, dim: true })
20const plain = (text: string): Segment => ({ text })
21const blank: Line = []
22const rule = (cols: number): Line => [dim('─'.repeat(Math.max(0, cols)))]
23
24export function usd(n: number): string {
25 const a = Math.abs(n)
26 const s = a >= 100 ? a.toFixed(0) : a.toFixed(2)
27 return `${n < 0 ? '-' : ''}$${s}`
28}
29
30function weekdayOf(date: string): string {
31 const [y, m, d] = date.split('-').map(Number) as [number, number, number]
32 return `${WEEKDAYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()]} ${String(d).padStart(2, '0')}`
33}
34
35function clock(ts: number, withDay = true): string {
36 const t = local(ts, TZ_OFFSET_MIN)
37 const day = WEEKDAYS[new Date(ts + TZ_OFFSET_MIN * 60_000).getUTCDay()]
38 return `${withDay ? `${day} ` : ''}${t.hh}:${t.mm}`
39}
40
41const totalOf = (r: DayRow) => r.read + r.write + r.input + r.output
42const pct = (x: number) => `${Math.round(x * 100)}%`
43
44function hitOf(rows: readonly DayRow[]): number | undefined {
45 const read = rows.reduce((n, r) => n + r.read, 0)
46 const ctx = rows.reduce((n, r) => n + r.read + r.write + r.input, 0)
47 return ctx > 0 ? read / ctx : undefined
48}
49
50function legend(): Line {
51 return [
52 dim('Tokens / ngày '),
53 { text: '█', color: COLORS.read },
54 dim(' cache read '),
55 { text: '█', color: COLORS.write },
56 dim(' cache write '),
57 { text: '█', color: COLORS.input },
58 dim(' input '),
59 { text: '█', color: COLORS.output },
60 dim(' output'),
61 ]
62}
63
64function barRows(rows: readonly { label: string; row: DayRow }[], cols: number): Line[] {
65 const max = Math.max(1, ...rows.map(r => totalOf(r.row)))
66 const labelW = Math.max(...rows.map(r => r.label.length))
67 const cells = Math.max(4, cols - labelW - 11)
68 return rows.map(({ label, row }) => {
69 const n = totalOf(row)
70 const bar = stacked(
71 [
72 { value: row.read, color: COLORS.read },
73 { value: row.write, color: COLORS.write },
74 { value: row.input, color: COLORS.input },
75 { value: row.output, color: COLORS.output },
76 ],
77 Math.round((n / max) * cells),
78 )
79 if (n === 0) return [plain(' '), dim(label.padEnd(labelW + 2)), dim('·')]
80 return [plain(' '), dim(label.padEnd(labelW + 2)), ...bar, plain(' '.repeat(cells - width(bar) + 1)), plain(fmtTokens(n).padStart(6))]
81 })
82}
83
84function hitLine(rows: readonly DayRow[], label: string): Line {
85 const hit = hitOf(rows)
86 const saved = rows.reduce((n, r) => n + r.savings, 0)
87 const line: Line = [dim('Cache hit ')]
88 if (hit !== undefined) line.push({ text: pct(hit), color: cacheColor(hit * 100) }, dim(` (${label}) `))
89 line.push({ text: spark(rows.map(r => r.hit), 1), color: 'green' })
90 if (saved !== 0) line.push(dim(' tiết kiệm ≈ '), { text: usd(saved), color: saved >= 0 ? 'green' : 'red' }, dim(' so với không cache'))
91 return line
92}
93
94export function bustLabel(b: BustEvent): string {
95 switch (b.cause) {
96 case 'compact':
97 return 'sau /compact'
98 case 'ttl':
99 return `nghỉ ${Math.round((b.gapMs ?? 0) / 60_000)} phút (TTL hết)`
100 case 'model_switch':
101 return `đổi model ${shortModel(b.prevModel ?? '?')} → ${shortModel(b.model)}`
102 default:
103 return 'không rõ (prompt/tool đổi?)'
104 }
105}
106
107function bustColor(b: BustEvent): string | undefined {
108 if (b.cause === 'compact' || b.cause === 'ttl') return 'red'
109 if (b.cause === 'model_switch') return 'yellow'
110 return undefined
111}
112
113function bustLines(busts: readonly BustEvent[], title: string, cols: number, withDay = true): Line[] {
114 if (busts.length === 0) return [[dim(`${title}: không có`)]]
115 const labelW = Math.max(16, cols - 32)
116 const out: Line[] = [[dim(`${title.padEnd(labelW + 13)} ctx đọc cache`)]]
117 for (const b of busts) {
118 const color = bustColor(b)
119 out.push([
120 color ? { text: '●', color } : dim('●'),
121 plain(` ${clock(b.ts, withDay).padEnd(withDay ? 9 : 5)} `),
122 ...fit([plain(bustLabel(b))], labelW),
123 plain(fmtTokens(b.ctx).padStart(6)),
124 plain(fmtTokens(b.cacheRead).padStart(10)),
125 ])
126 }
127 return out
128}
129
130function shareLines(title: string, list: readonly Share[], w: number): Line[] {
131 const out: Line[] = [[dim(title[0]!.toUpperCase() + title.slice(1))]]
132 const nameW = Math.min(10, Math.max(4, ...list.map(s => s.name.length)))
133 const cells = Math.max(3, Math.min(6, w - nameW - 7))
134 for (const s of list) {
135 out.push([plain(s.name.slice(0, nameW).padEnd(nameW + 1)), ...hbar(s.share * 100, cells, 'cyan'), plain(` ${pct(s.share).padStart(4)}`)])
136 }
137 if (list.length === 0) out.push([dim('chưa có dữ liệu')])
138 return out
139}
140
141function limitLines(d: DashData): Line[] {
142 const l = d.limits
143 const out: Line[] = [[dim('Hạn mức (lịch sử)')]]
144 if (l.five.length > 0) {
145 const line: Line = [dim('5h '), { text: spark(l.five, 100), color: 'yellow' }]
146 if (l.fivePeak) line.push(dim(` đỉnh ${Math.round(l.fivePeak.pct)}% ${clock(l.fivePeak.ts).split(' ')[0]}`))
147 out.push(line)
148 }
149 if (l.week.some(v => v !== undefined)) {
150 const line: Line = [dim('wk '), { text: spark(l.week, 100), color: 'yellow' }]
151 if (l.weekNow !== undefined) line.push(plain(` ${Math.round(l.weekNow)}%`))
152 if (l.weekForecast !== undefined) line.push(dim(' → dự báo '), { text: `${l.weekForecast}%`, color: l.weekForecast >= 100 ? 'red' : 'yellow' })
153 out.push(line)
154 }
155 if (l.elsewhere !== undefined) out.push([dim('ngoài máy này ≈ '), plain(pct(l.elsewhere))])
156 if (out.length === 1) out.push([dim('chưa có lần đọc nào')])
157 return out
158}
159
160function distillLines(s: DistillSummary): Line[] {
161 if (s.count === 0) return [[dim('Distiller chưa chưng cất lần nào')]]
162 return [
163 [
164 dim('Distiller '),
165 plain(`${s.count} lần · -${fmtTokens(s.cut)} ký tự · đọc lại ${s.reread}/${s.count} (${pct(s.reread / s.count)})`),
166 ],
167 s.byKind.flatMap((k, i) => [...(i > 0 ? [plain(' ')] : []), dim(`${k.kind} `), plain(`-${fmtTokens(k.cut)}`)]),
168 ]
169}
170
171/** The three side columns, or stacked under 80 columns. */
172function sideBySide(d: DashData, byModel: Share[], byProject: Share[], cols: number): Line[] {
173 const a = shareLines('theo model', byModel, 20)
174 const b = shareLines('theo project', byProject, 24)
175 const c = limitLines(d)
176 if (cols < 80) return [...a, blank, ...b, blank, ...c]
177 const w = Math.floor((cols - 4) / 3)
178 return columns([a, b, c], [w - 2, w + 1, cols - 4 - (w - 2) - (w + 1)])
179}
180
181function summaryOnly(rows: readonly DayRow[], label: string): Line[] {
182 const n = rows.reduce((t, r) => t + totalOf(r), 0)
183 const cost = rows.reduce((t, r) => t + r.cost, 0)
184 const hit = hitOf(rows)
185 return [
186 [dim(`${label}: `), plain(`${fmtTokens(n)} token · ~${usd(cost)} API-equiv`)],
187 hit === undefined ? [dim('cache hit: -')] : [dim('cache hit: '), { text: pct(hit), color: cacheColor(hit * 100) }],
188 ]
189}
190
191function notices(d: DashData): Line[] {
192 const out: Line[] = []
193 if (d.indexerError) out.push([{ text: `! indexer: ${d.indexerError}`, color: 'yellow' }])
194 if (d.badShare !== undefined && d.badShare > 0.01) out.push([{ text: `! JSONL: ${pct(d.badShare)} dòng không đọc được`, color: 'yellow' }])
195 return out
196}
197
198function weekTab(d: DashData, cols: number): Line[] {
199 const rows = d.days.slice(-7)
200 if (cols < 60) return [...notices(d), ...summaryOnly(rows, '7 ngày')]
201 return [
202 ...notices(d),
203 legend(),
204 blank,
205 ...barRows(rows.map(r => ({ label: weekdayOf(r.date), row: r })), cols),
206 blank,
207 hitLine(rows, 'tuần'),
208 ...bustLines(d.busts, 'Cache bust gần đây', cols),
209 blank,
210 rule(cols),
211 ...sideBySide(d, d.week.byModel, d.week.byProject, cols),
212 blank,
213 rule(cols),
214 ...distillLines(d.distillWeek),
215 ]
216}
217
218function monthTab(d: DashData, cols: number): Line[] {
219 const rows = d.days.slice(-30)
220 if (cols < 60) return [...notices(d), ...summaryOnly(rows, '30 ngày')]
221 // Weeks of the month, Monday first; the first may be partial.
222 const weeks: { label: string; row: DayRow }[] = []
223 for (const r of rows) {
224 const [y, m, dd] = r.date.split('-').map(Number) as [number, number, number]
225 const isMonday = new Date(Date.UTC(y, m - 1, dd)).getUTCDay() === 1
226 const last = weeks[weeks.length - 1]
227 if (!last || isMonday) {
228 weeks.push({ label: `từ ${String(dd).padStart(2, '0')}/${String(m).padStart(2, '0')}`, row: { ...r } })
229 } else {
230 const w = last.row
231 last.row = {
232 ...w,
233 read: w.read + r.read,
234 write: w.write + r.write,
235 input: w.input + r.input,
236 output: w.output + r.output,
237 cost: w.cost + r.cost,
238 savings: w.savings + r.savings,
239 requests: w.requests + r.requests,
240 }
241 }
242 }
243 return [
244 ...notices(d),
245 legend(),
246 blank,
247 ...barRows(weeks, cols),
248 blank,
249 [dim('Theo ngày '), { text: spark(rows.map(totalOf)), color: 'cyan' }],
250 hitLine(rows, 'tháng'),
251 blank,
252 rule(cols),
253 ...sideBySide(d, d.month.byModel, d.month.byProject, cols),
254 blank,
255 rule(cols),
256 ...distillLines(d.distillMonth),
257 ]
258}
259
260function sessionTab(d: DashData, cols: number): Line[] {
261 const turns = d.session.turns
262 if (turns.length === 0) return [[dim('Phiên này chưa có turn nào được ghi (ledger bắt đầu từ khi mod chạy).')]]
263 const total = turns.reduce((n, t) => n + t.cost, 0)
264 const head: Line = [dim(`phiên này · ${turns.length} turn · ~${usd(total)} API-equiv`)]
265 if (cols < 60) return [head, ...summaryOnly(d.days.slice(-1), 'hôm nay')]
266 const table: Line[] = [[dim('lúc req cache Δctx chi phí tok/s')]]
267 for (const t of turns.slice(-10)) {
268 table.push([
269 plain(clock(t.ts, false).padEnd(6)),
270 plain(String(t.requests).padStart(4)),
271 t.hit === undefined ? plain(' -') : { text: pct(t.hit).padStart(7), color: cacheColor(t.hit * 100) },
272 plain((t.delta === undefined ? '-' : `${t.delta >= 0 ? '+' : '-'}${fmtTokens(Math.abs(t.delta))}`).padStart(8)),
273 plain(`~${usd(t.cost)}`.padStart(10)),
274 t.tps === undefined ? plain(' -') : { text: fmtTps(t.tps).padStart(7), color: tpsColor(t.tps) },
275 ])
276 }
277 const top = [...turns].sort((a, b) => b.cost - a.cost).slice(0, 3)
278 const topLine: Line = [dim('tốn nhất: '), plain(top.map(t => `${clock(t.ts, false)} ~${usd(t.cost)}`).join(' · '))]
279 return [head, blank, ...table, blank, topLine, blank, ...bustLines(d.session.busts, 'Cache bust trong phiên', cols, false)]
280}
281
282/** Every line of a tab, none wider than `cols`. */
283export function dashboardLines(d: DashData, tab: Tab, cols: number): Line[] {
284 const lines = tab === 'session' ? sessionTab(d, cols) : tab === 'week' ? weekTab(d, cols) : monthTab(d, cols)
285 return lines.map(l => (width(l) > cols ? fit(l, cols) : l))
286}
287
288export const SUBTITLE: Record<Tab, string> = {
289 session: 'phiên hiện tại · tz +07',
290 week: '7 ngày qua · tz +07',
291 month: '30 ngày qua · tz +07',
292}
293hooks/ledger.ts 53 lines1// The live ledger's pure half: records from step usage, files by day, sparse snapshots.
2
3import type { MeasureSnapshot, RequestRecord } from '../types'
4import { dayKey, TZ_OFFSET_MIN } from './paths.ts'
5import type { StepUsage } from './turnstats.ts'
6
7export function toRecord(
8 usage: StepUsage,
9 at: { ts: number; sessionId: string; project: string; turnId: string; agentId?: string; ms?: number },
10): RequestRecord {
11 return {
12 ts: at.ts,
13 sessionId: at.sessionId,
14 project: at.project,
15 model: usage.model,
16 input: usage.input_tokens,
17 output: usage.output_tokens,
18 cacheRead: usage.cache_read_input_tokens,
19 cacheWrite: usage.cache_creation_input_tokens,
20 turnId: at.turnId,
21 ...(at.ms !== undefined ? { durationMs: at.ms } : {}),
22 isSubagent: at.agentId !== undefined,
23 source: 'live',
24 }
25}
26
27/** Records grouped by the Bangkok day they belong to. */
28export function byDay(records: readonly RequestRecord[]): Map<string, RequestRecord[]> {
29 const out = new Map<string, RequestRecord[]>()
30 for (const r of records) {
31 const k = dayKey(r.ts, TZ_OFFSET_MIN)
32 out.set(k, [...(out.get(k) ?? []), r])
33 }
34 return out
35}
36
37export const SNAPSHOT_EVERY_MS = 10 * 60_000
38
39/** A reading is kept when a percentage moved or ten minutes passed since the last kept. */
40export function shouldSnapshot(next: MeasureSnapshot, last: MeasureSnapshot | null): boolean {
41 if (last === null) return true
42 return (
43 next.fiveHourPct !== last.fiveHourPct ||
44 next.weekPct !== last.weekPct ||
45 next.ts - last.ts >= SNAPSHOT_EVERY_MS
46 )
47}
48
49export function basename(path: string): string {
50 const parts = path.replace(/[\\/]+$/, '').split(/[\\/]/)
51 return parts[parts.length - 1] || path
52}
53hooks/limits.ts 134 lines1// Pure logic for limit-line: no engine calls, so it can be tested on its own.
2
3import type { Reading, View, Win } from '../types'
4
5export const FIVE_HOUR_MS = 5 * 60 * 60 * 1000
6export const WEEK_MS = 7 * 24 * 60 * 60 * 1000
7export const KINDS = { five_hour: FIVE_HOUR_MS, seven_day: WEEK_MS } as const
8export type Kind = keyof typeof KINDS
9
10// A rate is only quoted once readings span 5% of the window: whole-percent
11// readings are a step function, and a single tick over a short span lies.
12const MIN_SPAN_SHARE = 0.05
13
14export type Segment = { text: string; color?: string; dim?: boolean }
15
16export function colorOf(pct: number): string {
17 if (pct >= 80) return 'red'
18 if (pct >= 50) return 'yellow'
19 return 'green'
20}
21
22export function bar(pct: number, cells: number): string {
23 const clamped = Math.max(0, Math.min(100, pct))
24 const filled = Math.round((clamped / 100) * cells)
25 return '█'.repeat(filled) + '░'.repeat(cells - filled)
26}
27
28export function duration(ms: number): string {
29 const minutes = Math.max(0, Math.round(ms / 60000))
30 const days = Math.floor(minutes / 1440)
31 const hours = Math.floor((minutes % 1440) / 60)
32 const mins = minutes % 60
33 if (days > 0) return `${days}d${hours}h`
34 if (hours > 0) return `${hours}h${String(mins).padStart(2, '0')}m`
35 return `${mins}m`
36}
37
38const WEEKDAYS = ['CN', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7']
39
40// Weekday and clock time of `ms` in the given UTC offset (minutes east).
41export function dayTime(ms: number, offsetMin: number): string {
42 const d = new Date(ms + offsetMin * 60000)
43 const hh = String(d.getUTCHours()).padStart(2, '0')
44 const mm = String(d.getUTCMinutes()).padStart(2, '0')
45 return `${WEEKDAYS[d.getUTCDay()]} ${hh}:${mm}`
46}
47
48// Keeps the readings that still describe the current window.
49export function addReading(list: Reading[], next: Reading, windowMs: number): Reading[] {
50 const last = list[list.length - 1]
51 // A drop of more than half a point means the window reset.
52 const kept = last !== undefined && next.pct < last.pct - 0.5 ? [] : list
53 return [...kept, next].filter(r => next.at - r.at <= windowMs).slice(-500)
54}
55
56// When the window fills at the recent pace, or undefined when it does not
57// fill before it resets, the pace is flat, or there is too little to go on.
58export function fullAt(
59 list: Reading[],
60 windowMs: number,
61 resetsAt: number | undefined,
62): number | undefined {
63 const first = list[0]
64 const last = list[list.length - 1]
65 if (first === undefined || last === undefined) return undefined
66 const span = last.at - first.at
67 if (span < windowMs * MIN_SPAN_SHARE) return undefined
68 const perMs = (last.pct - first.pct) / span
69 if (perMs <= 0) return undefined
70 const at = last.at + (100 - last.pct) / perMs
71 if (resetsAt !== undefined && at >= resetsAt) return undefined
72 return at
73}
74
75// The toast level a reading has reached: 0, 80 or 95.
76export function warnLevel(pct: number): 0 | 80 | 95 {
77 if (pct >= 95) return 95
78 if (pct >= 80) return 80
79 return 0
80}
81
82export type Tier = { cells: number; weekReset: boolean; eta: boolean }
83
84export const TIERS: Tier[] = [
85 { cells: 6, weekReset: true, eta: true },
86 { cells: 4, weekReset: false, eta: true },
87 { cells: 0, weekReset: false, eta: false },
88]
89
90export const SEP: Segment = { text: ' | ', dim: true }
91
92function windowSegments(
93 label: string,
94 win: Win,
95 now: number,
96 tier: Tier,
97 resetText: (at: number) => string,
98 showReset: boolean,
99): Segment[] {
100 const color = colorOf(win.pct)
101 const out: Segment[] = [{ text: `${label} `, dim: true }]
102 if (tier.cells > 0) {
103 out.push({ text: `${bar(win.pct, tier.cells)} `, color })
104 }
105 out.push({ text: `${Math.round(win.pct)}%`, color })
106 if (showReset && win.resetsAt !== undefined) {
107 out.push({ text: ` ${resetText(win.resetsAt)}`, dim: true })
108 }
109 if (tier.eta && win.fullAt !== undefined && win.fullAt > now) {
110 out.push({ text: ` !${duration(win.fullAt - now)}`, color: 'red' })
111 }
112 return out
113}
114
115export function lineFor(view: View, tier: Tier, offsetMin: number): Segment[] {
116 const parts: Segment[][] = []
117 const now = view.now
118 if (view.five) {
119 parts.push(windowSegments('5h', view.five, now, tier, at => duration(at - now), true))
120 }
121 if (view.week) {
122 parts.push(windowSegments('wk', view.week, now, tier, at => dayTime(at, offsetMin), tier.weekReset))
123 }
124 if (view.ctx !== undefined) {
125 const ctxWin: Win = { pct: view.ctx }
126 parts.push(windowSegments('ctx', ctxWin, now, { ...tier, eta: false }, () => '', false))
127 }
128 return parts.flatMap((p, i) => (i === 0 ? p : [SEP, ...p]))
129}
130
131export function width(segments: Segment[]): number {
132 return segments.reduce((n, s) => n + [...s.text].length, 0)
133}
134hooks/distiller.ts 207 lines1// The distiller's wiring: Bash and Read hooks, the /distill command, the log files.
2// The filtering itself is in distill/ and has no $ in it.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, On } from 'claude-code'
6
7import type { DistillEvent, DistillKind } from '../types'
8import { ANNOTATION, DISTILL_MIN_CHARS, annotation, distillText, isBypassed, isWrapped } from './distill/index.ts'
9import { dataDir, monthKey, stamp, TZ_OFFSET_MIN, wrapCommand } from './paths.ts'
10
11// The same $.state values register.tsx declares; an atom is read where it is written.
12const distSession = atom({ plugin: 'limit-line', key: 'distSession' } as const, 0)
13const distillOn = atom({ plugin: 'limit-line', key: 'distillOn' } as const, true)
14// This session's distillations, newest last: for /distill last and the re-read check.
15const distEvents = atom({ plugin: 'limit-line', key: 'distEvents' } as const, [] as DistillEvent[])
16
17export const OFF_KEY = 'distill.off'
18// Parts a log is split into stay under $.fs's 4 MiB.
19const PART_CHARS = 1_000_000
20const IGNORE_LINE = '.claude/distill/'
21
22// $ goes into functions of this file only (the engine follows it no further).
23async function home($: EngineInterface): Promise<string> {
24 const h = await $.env.get('HOME')
25 if (!h) throw new Error('HOME is not set')
26 return h
27}
28
29async function dataPath($: EngineInterface, rel: string): Promise<string> {
30 return `${dataDir(await home($))}/${rel}`
31}
32
33async function readJson<T>($: EngineInterface, path: string, fallback: T): Promise<T> {
34 try {
35 return JSON.parse(await $.fs.read(path)) as T
36 } catch {
37 return fallback
38 }
39}
40
41/** Reads the array at `path`, adds `items`, writes it back. */
42async function appendJson<T>($: EngineInterface, path: string, items: readonly T[]): Promise<void> {
43 if (items.length === 0) return
44 const list = await readJson<T[]>($, path, [])
45 await $.fs.write(path, JSON.stringify([...list, ...items]))
46}
47
48type BashOut = { stdout: string; stderr: string; persistedOutputPath?: string; persistedOutputSize?: number }
49
50async function logDir($: EngineInterface): Promise<string> {
51 return `${await $.session.cwd()}/.claude/distill`
52}
53
54/** Adds `.claude/distill/` to the project's .gitignore, in a git project, once. */
55async function ensureIgnored($: EngineInterface): Promise<void> {
56 const cwd = await $.session.cwd()
57 const path = `${cwd}/.gitignore`
58 let text = ''
59 try {
60 text = await $.fs.read(path)
61 } catch {
62 if (!(await $.fs.exists(`${cwd}/.git`))) return
63 }
64 if (text.split('\n').some(l => l.trim() === IGNORE_LINE || l.trim() === '.claude/distill')) return
65 await $.fs.write(path, `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}${IGNORE_LINE}\n`)
66}
67
68async function record($: EngineInterface, kind: DistillKind, rawChars: number, outChars: number, logPath: string) {
69 const ts = await $.clock.now()
70 const event: DistillEvent = { ts, sessionId: await $.session.id(), kind, rawChars, outChars, logPath, reread: false }
71 await update($, distEvents, list => [...list, event].slice(-200))
72 await update($, distSession, n => n + Math.max(0, rawChars - outChars))
73 await appendJson($, await dataPath($, `distill/${monthKey(ts, TZ_OFFSET_MIN)}.json`), [event])
74}
75
76/** A later command or Read that touches an earlier log marks that distillation re-read. */
77async function noteReread($: EngineInterface, text: string): Promise<void> {
78 const events = await read($, distEvents)
79 const hit = events.filter(ev => !ev.reread && text.includes(ev.logPath))
80 if (hit.length === 0) return
81 const paths = new Set(hit.map(h => h.logPath))
82 await update($, distEvents, list => list.map(ev => (paths.has(ev.logPath) ? { ...ev, reread: true } : ev)))
83 // The month file keeps the flag for the dashboard.
84 for (const ev of hit) {
85 const file = await dataPath($, `distill/${monthKey(ev.ts, TZ_OFFSET_MIN)}.json`)
86 const list = await readJson<DistillEvent[]>($, file, [])
87 await $.fs.write(file, JSON.stringify(list.map(x => (x.logPath === ev.logPath ? { ...x, reread: true } : x))))
88 }
89}
90
91/** A successful result, distilled in place; the original when it is short or anything fails. */
92async function distillResult($: EngineInterface, out: BashOut): Promise<BashOut | undefined> {
93 const raw = out.persistedOutputPath
94 ? await $.fs.read(out.persistedOutputPath)
95 : out.stderr
96 ? `${out.stdout}\n${out.stderr}`
97 : out.stdout
98 if (raw.length < DISTILL_MIN_CHARS) return undefined
99 const d = distillText(raw)
100 const dir = await logDir($)
101 const base = `${dir}/${stamp(await $.clock.now(), TZ_OFFSET_MIN)}-${d.kind}`
102 let logPath = `${base}.log`
103 if (raw.length <= PART_CHARS) {
104 await $.fs.write(logPath, raw)
105 } else {
106 for (let i = 0; i * PART_CHARS < raw.length; i++) {
107 await $.fs.write(`${base}.part${i + 1}.log`, raw.slice(i * PART_CHARS, (i + 1) * PART_CHARS))
108 }
109 logPath = `${base}.part1.log`
110 }
111 await ensureIgnored($)
112 await record($, d.kind, d.rawChars, d.outChars, logPath)
113 const copy: BashOut = { ...out, stdout: `${d.out}\n${annotation(d, logPath)}`, stderr: '' }
114 delete copy.persistedOutputPath
115 delete copy.persistedOutputSize
116 return copy
117}
118
119export function registerDistiller(on: On): void {
120 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
121 try {
122 await noteReread($, e.file_path)
123 } catch {
124 // Observation only.
125 }
126 return next(e)
127 })
128
129 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
130 try {
131 await noteReread($, e.command)
132 } catch {
133 // Observation only.
134 }
135 let isOn = true
136 try {
137 isOn = await read($, distillOn)
138 } catch {
139 // On by default.
140 }
141 if (!isOn || isBypassed(e.command) || e.run_in_background) return next(e)
142
143 if (isWrapped(e.command)) {
144 // Failing output cannot be shortened once it ran (NOTES.md): the command is wrapped.
145 let command: string
146 try {
147 const dir = await logDir($)
148 await ensureIgnored($)
149 const log = `${dir}/${stamp(await $.clock.now(), TZ_OFFSET_MIN)}.log`
150 await $.fs.write(log, '')
151 command = wrapCommand(e.command, log, `${$.plugin.root}/hooks/distill/cli.ts`)
152 } catch {
153 return next(e)
154 }
155 const ran = await next({ ...e, command })
156 try {
157 const m = ran.deny === undefined && ran.text !== undefined ? ANNOTATION.exec(ran.text) : null
158 if (m) await record($, m[1] as DistillKind, Number(m[2]), Number(m[3]), m[4] as string)
159 } catch {
160 // The output is already distilled; only the statistics miss it.
161 }
162 return ran
163 }
164
165 const ran = await next(e)
166 if (ran.deny !== undefined || ran.isError) return ran
167 try {
168 const copy = await distillResult($, ran.result as BashOut)
169 return copy ? { result: copy as typeof ran.result } : ran
170 } catch {
171 return ran
172 }
173 })
174
175 on('command.run', { command: /^distill(-x)?$/ }, async ($, e) => {
176 const arg = e.args.trim()
177 if (arg === 'on' || arg === 'off') {
178 await update($, distillOn, () => arg === 'on')
179 await $.store.set(OFF_KEY, arg === 'off')
180 return { text: arg === 'on' ? 'Distiller: bật.' : 'Distiller: tắt.' }
181 }
182 const events = await read($, distEvents)
183 if (arg === 'last') {
184 const last = events[events.length - 1]
185 return { text: last ? `Lần gần nhất (${last.kind}): ${last.logPath}` : 'Phiên này chưa chưng cất lần nào.' }
186 }
187 if (arg === 'stats' || arg === '') {
188 const ts = await $.clock.now()
189 const month = await readJson<DistillEvent[]>($, await dataPath($, `distill/${monthKey(ts, TZ_OFFSET_MIN)}.json`), [])
190 const lines = [`Distiller: ${(await read($, distillOn)) ? 'bật' : 'tắt'}. Phiên này ${events.length} lần, tháng này ${month.length} lần.`]
191 const byKind = new Map<string, { n: number; cut: number; reread: number }>()
192 for (const ev of month) {
193 const k = byKind.get(ev.kind) ?? { n: 0, cut: 0, reread: 0 }
194 k.n++
195 k.cut += Math.max(0, ev.rawChars - ev.outChars)
196 if (ev.reread) k.reread++
197 byKind.set(ev.kind, k)
198 }
199 for (const [kind, k] of byKind) {
200 lines.push(` ${kind}: ${k.n} lần, cắt ${k.cut} ký tự, đọc lại ${k.reread}/${k.n} (${Math.round((k.reread / k.n) * 100)}%)`)
201 }
202 return { text: lines.join('\n') }
203 }
204 return { text: 'Dùng: /distill on | off | stats | last' }
205 })
206}
207hooks/metrics.ts 136 lines1// Token arithmetic shared by the band and the dashboard. Pure.
2
3import { priceOf } from './pricing.ts'
4
5export type Usage = {
6 input: number
7 output: number
8 cacheRead: number
9 cacheWrite: number
10 /** The part of cacheWrite written with the 1-hour TTL, when the source splits it. */
11 cacheWrite1h?: number
12}
13
14// Live usage does not split 5m from 1h writes; every write seen on this account is 1h.
15export const LIVE_WRITE_IS_1H = true
16
17/** What the request was answered over: uncached, cache-written and cache-read input. */
18export function ctxOf(u: Usage): number {
19 return u.input + u.cacheRead + u.cacheWrite
20}
21
22/** Share of the context served from cache, 0..1; undefined with no context. Sum token
23 * counts before calling it: a rate of sums, never a mean of rates. */
24export function cacheHit(u: Usage): number | undefined {
25 const ctx = ctxOf(u)
26 return ctx > 0 ? u.cacheRead / ctx : undefined
27}
28
29/** API-equivalent USD; undefined for a model with no price. */
30export function costOf(u: Usage, model: string, writeIs1h = LIVE_WRITE_IS_1H): number | undefined {
31 const p = priceOf(model)
32 if (p === undefined) return undefined
33 const w1h = u.cacheWrite1h ?? (writeIs1h ? u.cacheWrite : 0)
34 const w5m = u.cacheWrite - w1h
35 return (
36 (u.input * p.input +
37 u.output * p.output +
38 u.cacheRead * p.cacheRead +
39 w5m * p.cacheWrite5m +
40 w1h * p.cacheWrite1h) /
41 1_000_000
42 )
43}
44
45// ── Speed ──────────────────────────────────────────────────────────────────────────
46
47/** Answers shorter than this are mostly time-to-first-token, not writing: left out of tok/s. */
48export const TPS_MIN_OUT = 50
49
50/** Output tokens per second over timed requests; undefined when there is nothing to divide.
51 * Sum tokens and time first, then divide: a rate of sums, never a mean of rates. */
52export function tokPerSec(out: number | undefined, ms: number | undefined): number | undefined {
53 return out !== undefined && ms !== undefined && out > 0 && ms > 0 ? out / (ms / 1000) : undefined
54}
55
56/** Whether a request is long enough for its speed to mean something. */
57export function timeable(output: number, ms: number | undefined): ms is number {
58 return ms !== undefined && ms > 0 && output >= TPS_MIN_OUT
59}
60
61// ── Cache busts (PLAN §5) ──────────────────────────────────────────────────────────
62
63export const BUST_MIN_CTX = 20_000
64export const BUST_READ_SHARE = 0.1
65export const TTL_5M_MIN = 5
66export const TTL_1H_MIN = 60
67/** Live usage does not say 5m or 1h: assume 1h, as every JSONL write so far. */
68export const TTL_LIVE_DEFAULT_MIN = 60
69
70export type BustCause = 'compact' | 'ttl' | 'model_switch' | 'session_start' | 'unknown'
71
72/** One request as bust detection sees it. */
73export type Req = Usage & { ts: number; model: string }
74
75/** What a session's previous request left behind. */
76export type Prev = {
77 ts: number
78 model: string
79 /** True: its cache entries live 1 h; false: 5 min; undefined: the source does not say. */
80 ttl1h?: boolean
81}
82
83/** A big request that read almost nothing from cache, and was not the session's first. */
84export function isBust(req: Usage, prev: Prev | undefined): boolean {
85 const ctx = ctxOf(req)
86 return prev !== undefined && ctx >= BUST_MIN_CTX && req.cacheRead < BUST_READ_SHARE * ctx
87}
88
89export function ttlMinutes(prev: Prev): number {
90 if (prev.ttl1h === undefined) return TTL_LIVE_DEFAULT_MIN
91 return prev.ttl1h ? TTL_1H_MIN : TTL_5M_MIN
92}
93
94/** The first matching cause, in PLAN order: compact, model switch, TTL, unknown. */
95export function bustCause(req: Req, prev: Prev, compactBetween: boolean): BustCause {
96 if (compactBetween) return 'compact'
97 if (req.model !== prev.model) return 'model_switch'
98 if (req.ts - prev.ts > ttlMinutes(prev) * 60_000) return 'ttl'
99 return 'unknown'
100}
101
102/** The TTL a request leaves its session with: its own write's, else the one before. */
103export function nextTtl1h(req: Usage, before: boolean | undefined): boolean | undefined {
104 if (req.cacheWrite <= 0) return before
105 if (req.cacheWrite1h === undefined) return before
106 return req.cacheWrite1h > 0
107}
108
109// ── Savings ───────────────────────────────────────────────────────────────────────
110
111/** What caching saved: the cost with every cached token billed as plain input, less the
112 * real cost. Negative on a day of many writes. */
113export function savings(u: Usage, model: string): number | undefined {
114 const real = costOf(u, model)
115 const plain = costOf({ input: u.input + u.cacheRead + u.cacheWrite, output: u.output, cacheRead: 0, cacheWrite: 0 }, model)
116 return real === undefined || plain === undefined ? undefined : plain - real
117}
118
119// ── Usage elsewhere (optional, PLAN §5) ─────────────────────────────────────────────
120
121/** One finished 5-hour window: how far it moved and what this machine spent in it. */
122export type WindowSample = { pctUsed: number; localCost: number }
123
124/**
125 * The share of the current window's use that did not come from this machine, 0..1, or
126 * undefined until two full windows calibrate it. The calibration is the most local cost one
127 * percent ever took: a window used from here alone.
128 */
129export function elsewhereShare(done: readonly WindowSample[], now: WindowSample): number | undefined {
130 const rates = done.filter(w => w.pctUsed >= 1 && w.localCost > 0).map(w => w.localCost / w.pctUsed)
131 if (rates.length < 2 || now.pctUsed <= 0) return undefined
132 const perPct = Math.max(...rates)
133 const localPct = now.localCost / perPct
134 return Math.min(1, Math.max(0, 1 - localPct / now.pctUsed))
135}
136hooks/paths.ts 56 lines1// Where the mod keeps its data and names its files. Pure, given HOME and a clock reading.
2
3/** DATA_DIR: no per-plugin data folder exists in this build (NOTES.md). */
4export function dataDir(home: string): string {
5 return `${home}/.local/share/limit-line`
6}
7
8const pad = (n: number) => String(n).padStart(2, '0')
9
10/** Local calendar fields of `ms` at UTC offset `offsetMin` (minutes east). */
11export function local(ms: number, offsetMin: number) {
12 const d = new Date(ms + offsetMin * 60_000)
13 return {
14 y: d.getUTCFullYear(),
15 m: pad(d.getUTCMonth() + 1),
16 d: pad(d.getUTCDate()),
17 hh: pad(d.getUTCHours()),
18 mm: pad(d.getUTCMinutes()),
19 ss: pad(d.getUTCSeconds()),
20 }
21}
22
23/** `2026-10-02` */
24export function dayKey(ms: number, offsetMin: number): string {
25 const t = local(ms, offsetMin)
26 return `${t.y}-${t.m}-${t.d}`
27}
28
29/** `2026-10` */
30export function monthKey(ms: number, offsetMin: number): string {
31 const t = local(ms, offsetMin)
32 return `${t.y}-${t.m}`
33}
34
35/** `20261002-101500` */
36export function stamp(ms: number, offsetMin: number): string {
37 const t = local(ms, offsetMin)
38 return `${t.y}${t.m}${t.d}-${t.hh}${t.mm}${t.ss}`
39}
40
41/** One argument for sh, single-quoted. */
42export function shQuote(s: string): string {
43 return `'${s.replace(/'/g, `'\\''`)}'`
44}
45
46/** The command run in place of `command`: its whole output to `log`, the CLI's distillation
47 * printed, the original exit status kept. A missing node prints the log as it is.
48 * A subshell, so an `exit` or `set -e` inside the command cannot skip the printing. */
49export function wrapCommand(command: string, log: string, cli: string): string {
50 const l = shQuote(log)
51 return `( ${command}\n) > ${l} 2>&1; __ll_rc=$?; node ${shQuote(cli)} ${l} 2>/dev/null || cat ${l}; exit $__ll_rc`
52}
53
54/** Asia/Bangkok: the day boundary for ledgers and aggregates (PLAN §8). */
55export const TZ_OFFSET_MIN = 7 * 60
56hooks/turnstats.ts 45 lines1// Sums the requests of one main-loop turn. Pure.
2
3import type { TurnAcc } from '../types'
4import { timeable } from './metrics.ts'
5
6export type StepUsage = {
7 input_tokens: number
8 output_tokens: number
9 cache_read_input_tokens: number
10 cache_creation_input_tokens: number
11 model: string
12}
13
14export function emptyTurn(turnId: string): TurnAcc {
15 return { turnId, requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, model: '' }
16}
17
18/** Adds one step. A subagent's step (agentId set) or a step with no usage changes nothing;
19 * a step of another turn starts the sum over. */
20export function addStep(
21 acc: TurnAcc | null,
22 step: { turnId: string; agentId?: string; usage: StepUsage | null; ms?: number },
23): TurnAcc | null {
24 if (step.agentId !== undefined || step.usage === null) return acc
25 const base = acc !== null && acc.turnId === step.turnId ? acc : emptyTurn(step.turnId)
26 const u = step.usage
27 const timed = timeable(u.output_tokens, step.ms)
28 return {
29 turnId: base.turnId,
30 requests: base.requests + 1,
31 input: base.input + u.input_tokens,
32 output: base.output + u.output_tokens,
33 cacheRead: base.cacheRead + u.cache_read_input_tokens,
34 cacheWrite: base.cacheWrite + u.cache_creation_input_tokens,
35 model: u.model,
36 genOut: (base.genOut ?? 0) + (timed ? u.output_tokens : 0),
37 genMs: (base.genMs ?? 0) + (timed ? (step.ms as number) : 0),
38 }
39}
40
41/** Keeps the last `max` context readings. */
42export function pushHistory(list: readonly number[], value: number, max = 12): number[] {
43 return [...list, value].slice(-max)
44}
45hooks/charts.ts 77 lines1// Character charts: one-cell glyphs only (PLAN §2.9). Pure.
2
3import type { Segment } from './limits.ts'
4import { width } from './limits.ts'
5
6export type Part = { value: number; color: string }
7
8/** A bar of `cells` cells split between the parts in proportion, by largest remainder. */
9export function stacked(parts: readonly Part[], cells: number): Segment[] {
10 const total = parts.reduce((n, p) => n + Math.max(0, p.value), 0)
11 if (total <= 0 || cells <= 0) return []
12 const exact = parts.map(p => (Math.max(0, p.value) / total) * cells)
13 const floor = exact.map(Math.floor)
14 let left = cells - floor.reduce((a, b) => a + b, 0)
15 const order = exact.map((x, i) => [x - Math.floor(x), i] as const).sort((a, b) => b[0] - a[0])
16 for (const [, i] of order) {
17 if (left <= 0) break
18 floor[i] = (floor[i] as number) + 1
19 left--
20 }
21 return parts
22 .map((p, i) => ({ text: '█'.repeat(floor[i] as number), color: p.color }))
23 .filter(s => s.text.length > 0)
24}
25
26/** A one-colour bar of `pct` percent over `cells` cells. */
27export function hbar(pct: number, cells: number, color: string): Segment[] {
28 const filled = Math.round((Math.max(0, Math.min(100, pct)) / 100) * cells)
29 return [{ text: '█'.repeat(filled), color }, { text: '░'.repeat(cells - filled), dim: true }]
30}
31
32const LEVELS = '▁▂▃▄▅▆▇█'
33
34/** One cell per value, on a fixed 0..max scale; undefined is a gap. */
35export function spark(values: readonly (number | undefined)[], max?: number): string {
36 const top = max ?? Math.max(0, ...values.filter((v): v is number => v !== undefined))
37 return values
38 .map(v => (v === undefined ? ' ' : LEVELS[top > 0 ? Math.min(7, Math.round((Math.max(0, v) / top) * 7)) : 0]))
39 .join('')
40}
41
42/** Pads a line with spaces to `cells`, or clips it there. */
43export function fit(line: Segment[], cells: number): Segment[] {
44 const w = width(line)
45 if (w === cells) return line
46 if (w < cells) return [...line, { text: ' '.repeat(cells - w) }]
47 const out: Segment[] = []
48 let left = cells
49 for (const s of line) {
50 if (left <= 0) break
51 const chars = [...s.text]
52 if (chars.length <= left) {
53 out.push(s)
54 left -= chars.length
55 } else {
56 out.push({ ...s, text: chars.slice(0, Math.max(0, left - 1)).join('') + '…' })
57 left = 0
58 }
59 }
60 return out
61}
62
63/** Columns side by side, each padded to its width, `gap` spaces between. */
64export function columns(cols: readonly Segment[][][], widths: readonly number[], gap = 2): Segment[][] {
65 const rows = Math.max(0, ...cols.map(c => c.length))
66 const out: Segment[][] = []
67 for (let r = 0; r < rows; r++) {
68 const line: Segment[] = []
69 cols.forEach((c, i) => {
70 if (i > 0) line.push({ text: ' '.repeat(gap) })
71 line.push(...fit(c[r] ?? [], widths[i] as number))
72 })
73 out.push(line)
74 }
75 return out
76}
77hooks/distill/index.ts 67 lines1// The distiller pipeline, pure: clean, detect, filter. The hook and the wrapper's CLI both
2// call it; neither the file writes nor the annotation's path are made here.
3
4import { clean, foldRuns } from './common.ts'
5import { detect } from './detect.ts'
6import type { Kind } from './detect.ts'
7import { generic } from './generic.ts'
8import { pytest } from './pytest.ts'
9
10export type { Kind }
11
12export const DISTILL_MIN_CHARS = 8000
13export const DISTILL_TARGET_CHARS = 4000
14
15export type Distilled = { kind: Kind; out: string; rawChars: number; outChars: number }
16
17export function distillText(raw: string, target = DISTILL_TARGET_CHARS): Distilled {
18 const text = clean(raw)
19 const kind = detect(text)
20 let out: string
21 switch (kind) {
22 case 'pytest':
23 out = pytest(foldRuns(text.split('\n'), l => /^(FAILED|ERROR) /.test(l)).join('\n'), target)
24 break
25 default:
26 out = generic(text, target)
27 }
28 return { kind: kind === 'pytest' ? 'pytest' : 'generic', out, rawChars: raw.length, outChars: out.length }
29}
30
31export function annotation(d: Distilled, logPath: string): string {
32 return `[distilled ${d.kind} ${d.rawChars}→${d.outChars} · full log: ${logPath} · raw: rerun with NO_DISTILL=1]`
33}
34
35/** Matches the annotation line back into its parts (the hook reads the wrapper's). */
36export const ANNOTATION = /\[distilled (\w+) (\d+)→(\d+) · full log: (.+?) · raw: rerun with NO_DISTILL=1\]\s*$/
37
38/** Commands whose failing output is worth distilling: wrapped before they run. Each pattern
39 * is matched at the start of a command segment (see `segments`), never inside an argument. */
40export const WRAP_ALLOWLIST: RegExp[] = [
41 /^(python3? -m |uv run |poetry run )?pytest\b/,
42 /^(python3?|torchrun|accelerate launch|deepspeed)\s+\S*train\S*/,
43 /^docker(-compose| compose)? logs\b/,
44 /^(pip3?|uv pip) install\b/,
45 /^(make|ninja|cmake --build|cargo build|npm run build|pnpm( run)? build|yarn build)\b/,
46 /^python3? setup\.py (build|install|develop)\b/,
47]
48
49/** The commands of a shell line: split at `;`, `&&`, `||`, `|`, `(` and newlines, quoted text
50 * removed first, leading `VAR=value` assignments dropped. */
51export function segments(command: string): string[] {
52 const unquoted = command.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, "''")
53 return unquoted
54 .split(/;|&&|\|\||\||\(|\n/)
55 .map(seg => seg.trim().replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, ''))
56 .filter(seg => seg.length > 0)
57}
58
59export function isWrapped(command: string): boolean {
60 return segments(command).some(seg => WRAP_ALLOWLIST.some(r => r.test(seg)))
61}
62
63/** Commands that pass through untouched. */
64export function isBypassed(command: string): boolean {
65 return /\bNO_DISTILL=1\b/.test(command) || command.includes('.claude/distill/')
66}
67