SLOPSHOPPER

limit-line

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…

newpanebandguardcommandtoast
v0.2.0no licenseupdated 2026-10-02Haworthiasa/limiter/limit-line
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limit-line
│ ┃ usage-plus ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Session 2: Week 3: Month │ ┃ ───────────────────────────────────────────… ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ! indexer: SyntaxError: JSON Parse error: U… ⏺ Update(src/auth.ts) │ ┃ 7 ngày: 0 token · ~$0.00 API-equiv ⎿ Added 2 lines, removed 1 line │ ┃ cache hit: - ⏺ 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 │ │ › /limits │ ⎿ limit-line: Đã ẩn dòng hạn mức. Gõ /limits để hiện lại. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · usage-plus
1: Session 2: Week 3: Month ──────────────────────────────────────────────────────── ! indexer: SyntaxError: JSON Parse error: Unexpected id… 7 ngày: 0 token · ~$0.00 API-equiv cache hit: -
README

limit-line

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.

Tính năng

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.
  • Màn hình hẹp: bỏ 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).

  • Lệnh thành công: output được thay sau khi chạy.
  • Lệnh thất bại: core không cho rút gọn sau khi chạy, nên các lệnh khớp danh sách (pytest, 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

LệnhViệc
/limitsẨn / hiện band
/limits detailBật / tắt dòng chi tiết turn
/usage-plusMở bảng usage (nếu trùng tên: /usage-x)
/distill on / offBật / tắt distiller (nhớ giữa các phiên)
/distill statsSố 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.

Dữ liệu

~/.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.

Giới hạn đã biết

  • Chi phí là API-equiv theo bảng giá trong 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 trực tiếp không tách cache write 5m/1h: band và ledger tính như 1h; indexer dùng số tách trong JSONL.
  • Lời gọi advisor nằm trong usage.iterations của JSONL: indexer có tính, band và ledger trực tiếp thì không.
  • Dấu compact và subagent trong JSONL chưa được xác minh trên dữ liệu thật (chưa có phiên nào như vậy).
  • Ước tính "ngoài máy này" chỉ hiện khi có ít nhất 2 cửa sổ 5h đầy đủ trong ledger.
  • Distiller mới có bộ lọc pytest và chung; cuda, docker, train để sau.
  • Cần node ≥ 23.6 trong PATH của Claude Code (indexer và wrapper của distiller chạy file .ts trực tiếp).

Phát triển

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>/
Source 18 files
hooks/register.tsx 454 lines
1import { 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}
454
hooks/band.ts 165 lines
1// 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}
165
hooks/dashdata.ts 231 lines
1// 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}
231
hooks/dashboard.ts 293 lines
1// /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}
293
hooks/ledger.ts 53 lines
1// 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}
53
hooks/limits.ts 134 lines
1// 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}
134
hooks/distiller.ts 207 lines
1// 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}
207
hooks/metrics.ts 136 lines
1// 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}
136
hooks/paths.ts 56 lines
1// 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
56
hooks/turnstats.ts 45 lines
1// 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}
45
hooks/charts.ts 77 lines
1// 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}
77
hooks/distill/index.ts 67 lines
1// 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