@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/daemon.ts +57 -6
- package/package.json +1 -1
- package/src/cardkit.ts +46 -0
- package/src/cards.ts +263 -93
- package/src/feishu.ts +76 -2
- package/src/paths.ts +6 -0
- package/src/session.ts +220 -98
- package/src/usage.ts +201 -0
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
|
+
}
|