@leviyuan/lodestar 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/usage.ts ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Subscription usage snapshot for the `hi` console panel.
3
+ *
4
+ * Source: the `ccusage` CLI (https://github.com/ryoppippi/ccusage), which
5
+ * parses Claude Code's local JSONL transcripts on demand. We shell out
6
+ * twice in parallel and cache the merged result for CACHE_TTL_MS.
7
+ *
8
+ * - `blocks --active --token-limit max` → current 5h billing block.
9
+ * `tokenLimitStatus.limit` is ccusage's "peak historical block"
10
+ * value, used as the denominator for the 5h percentage. NOTE:
11
+ * this is consumption relative to your own heaviest 5h ever —
12
+ * NOT the Anthropic tier quota (which we have no way to read
13
+ * without OAuth roundtrips). It's an internally-consistent burn
14
+ * indicator, not an official quota gauge.
15
+ *
16
+ * - `weekly --order desc` → list of weekly aggregates, newest first.
17
+ * ccusage's weekly doesn't expose tokenLimitStatus, so we compute
18
+ * the same "peak historical week" ratio locally.
19
+ *
20
+ * Failures stay visible (no fallback fabrication):
21
+ * - ccusage not on PATH → `installed: false` → card renders install hint.
22
+ * - ccusage runs but yields nothing → `fiveHour: null`, `weekly: null`.
23
+ */
24
+
25
+ import { spawn } from 'node:child_process'
26
+ import { log } from './log'
27
+
28
+ const CCUSAGE_BIN = 'ccusage'
29
+ const CACHE_TTL_MS = 60_000
30
+ const SPAWN_TIMEOUT_MS = 15_000
31
+
32
+ export interface FiveHourBlock {
33
+ costUsd: number
34
+ totalTokens: number
35
+ /** End of the current 5h billing window per ccusage. */
36
+ windowEndsAt: Date
37
+ /** Tokens/min over the current window, if ccusage reported one. */
38
+ burnRatePerMin: number | null
39
+ /** Consumption vs. user's historical peak 5h block (0–100). Null
40
+ * when ccusage hasn't built a peak yet (very new install). */
41
+ percentUsed: number | null
42
+ /** Minutes left in this 5h window per ccusage's projection. */
43
+ remainingMinutes: number | null
44
+ }
45
+
46
+ export interface WeeklyAggregate {
47
+ /** ISO date of this week's start, format ccusage chose (Sun by default). */
48
+ weekStart: string
49
+ costUsd: number
50
+ totalTokens: number
51
+ /** Consumption vs. user's historical peak week (0–100). Null when
52
+ * there's no prior week to compare against. */
53
+ percentUsed: number | null
54
+ /** Fractional days remaining until end of week (start + 7d). */
55
+ remainingDays: number | null
56
+ }
57
+
58
+ export type UsageSnapshot =
59
+ | { installed: false }
60
+ | {
61
+ installed: true
62
+ fiveHour: FiveHourBlock | null
63
+ weekly: WeeklyAggregate | null
64
+ /** When this snapshot was computed. */
65
+ fetchedAt: number
66
+ }
67
+
68
+ function clampPct(v: number): number {
69
+ if (!isFinite(v)) return 0
70
+ return Math.max(0, Math.min(100, v))
71
+ }
72
+
73
+ let cache: { data: UsageSnapshot; at: number } | null = null
74
+ let inFlight: Promise<UsageSnapshot> | null = null
75
+
76
+ /** `null` = not on PATH (ENOENT); `undefined` = ran but failed (timeout,
77
+ * non-zero exit, JSON parse error). Distinct so the caller can render
78
+ * different UX. */
79
+ type RunResult = any | null | undefined
80
+
81
+ function runCcusage(args: string[]): Promise<RunResult> {
82
+ return new Promise((resolve) => {
83
+ let stdout = ''
84
+ let stderr = ''
85
+ let proc
86
+ try {
87
+ proc = spawn(CCUSAGE_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
88
+ } catch (e: any) {
89
+ if (e?.code === 'ENOENT') return resolve(null)
90
+ log(`ccusage spawn threw: ${e}`)
91
+ return resolve(undefined)
92
+ }
93
+
94
+ const timer = setTimeout(() => {
95
+ proc.kill('SIGTERM')
96
+ log(`ccusage ${args.join(' ')}: timeout after ${SPAWN_TIMEOUT_MS}ms`)
97
+ }, SPAWN_TIMEOUT_MS)
98
+
99
+ proc.on('error', (err: any) => {
100
+ clearTimeout(timer)
101
+ if (err?.code === 'ENOENT') resolve(null)
102
+ else { log(`ccusage error: ${err}`); resolve(undefined) }
103
+ })
104
+ proc.stdout!.on('data', (b) => { stdout += b.toString() })
105
+ proc.stderr!.on('data', (b) => { stderr += b.toString() })
106
+ proc.on('close', (code) => {
107
+ clearTimeout(timer)
108
+ if (code !== 0) {
109
+ log(`ccusage ${args.join(' ')}: exit ${code} stderr=${stderr.slice(0, 200)}`)
110
+ return resolve(undefined)
111
+ }
112
+ try { resolve(JSON.parse(stdout)) }
113
+ catch (e) { log(`ccusage JSON parse: ${e}`); resolve(undefined) }
114
+ })
115
+ })
116
+ }
117
+
118
+ async function fetchUsage(): Promise<UsageSnapshot> {
119
+ const [blocks, weekly] = await Promise.all([
120
+ // --active filters to the current 5h block (cheaper to parse).
121
+ // --token-limit max derives a cap from the user's peak historical
122
+ // block so ccusage emits `tokenLimitStatus`, giving us a numerator+
123
+ // denominator without us reading every block ourselves.
124
+ runCcusage(['blocks', '--json', '--active', '--token-limit', 'max']),
125
+ runCcusage(['weekly', '--json', '--order', 'desc']),
126
+ ])
127
+
128
+ if (blocks === null || weekly === null) return { installed: false }
129
+
130
+ let fiveHour: FiveHourBlock | null = null
131
+ if (blocks && Array.isArray(blocks.blocks)) {
132
+ const active = blocks.blocks.find((b: any) => b?.isActive && !b?.isGap)
133
+ if (active) {
134
+ const totalTokens = Number(active.totalTokens) || 0
135
+ const limit = Number(active.tokenLimitStatus?.limit) || 0
136
+ fiveHour = {
137
+ costUsd: Number(active.costUSD) || 0,
138
+ totalTokens,
139
+ windowEndsAt: new Date(active.endTime),
140
+ burnRatePerMin: typeof active.burnRate?.tokensPerMinute === 'number'
141
+ ? active.burnRate.tokensPerMinute : null,
142
+ percentUsed: limit > 0 ? clampPct((totalTokens / limit) * 100) : null,
143
+ remainingMinutes: typeof active.projection?.remainingMinutes === 'number'
144
+ ? active.projection.remainingMinutes : null,
145
+ }
146
+ }
147
+ }
148
+
149
+ let wk: WeeklyAggregate | null = null
150
+ if (weekly && Array.isArray(weekly.weekly) && weekly.weekly.length > 0) {
151
+ const current = weekly.weekly[0]
152
+ const totalTokens = Number(current.totalTokens) || 0
153
+ // Peak historical week (excluding the current one — comparing
154
+ // against itself would always read 100%). When this is the only
155
+ // recorded week we leave percentUsed null.
156
+ const peakTokens = weekly.weekly.slice(1).reduce(
157
+ (m: number, w: any) => Math.max(m, Number(w?.totalTokens) || 0), 0)
158
+ const percentUsed = peakTokens > 0 ? clampPct((totalTokens / peakTokens) * 100) : null
159
+ // Week end = weekStart + 7 days. ccusage emits weekStart as YYYY-MM-DD;
160
+ // parse as UTC so DST/timezone shifts don't drift the countdown.
161
+ const weekStartIso = String(current.week ?? '')
162
+ let remainingDays: number | null = null
163
+ if (weekStartIso) {
164
+ const start = new Date(weekStartIso + 'T00:00:00Z')
165
+ if (!isNaN(start.getTime())) {
166
+ const endMs = start.getTime() + 7 * 24 * 60 * 60 * 1000
167
+ remainingDays = Math.max(0, (endMs - Date.now()) / (24 * 60 * 60 * 1000))
168
+ }
169
+ }
170
+ wk = {
171
+ weekStart: weekStartIso,
172
+ costUsd: Number(current.totalCost) || 0,
173
+ totalTokens,
174
+ percentUsed,
175
+ remainingDays,
176
+ }
177
+ }
178
+
179
+ return { installed: true, fiveHour, weekly: wk, fetchedAt: Date.now() }
180
+ }
181
+
182
+ /** Returns a usage snapshot. Cached for CACHE_TTL_MS; concurrent callers
183
+ * dedupe to a single in-flight ccusage run. First call after stale-out
184
+ * pays the full ccusage cost (~5s on this machine); subsequent reads are
185
+ * instant. Never throws — returns `{ installed: false }` if ccusage is
186
+ * missing, or an empty `{ installed: true, fiveHour: null, ... }` if it
187
+ * runs but yields no data. */
188
+ export async function readUsage(): Promise<UsageSnapshot> {
189
+ if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.data
190
+ if (inFlight) return inFlight
191
+ inFlight = fetchUsage().then(d => {
192
+ cache = { data: d, at: Date.now() }
193
+ inFlight = null
194
+ return d
195
+ }).catch(e => {
196
+ log(`usage: fetchUsage threw: ${e}`)
197
+ inFlight = null
198
+ return cache?.data ?? { installed: false }
199
+ })
200
+ return inFlight
201
+ }