@leviyuan/lodestar 0.2.2 → 0.2.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviyuan/lodestar",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/cards.ts CHANGED
@@ -595,6 +595,15 @@ function fmtResetIn(date: Date | null): string {
595
595
  return `${Math.round(ms / (24 * 60 * 60 * 1000))}d`
596
596
  }
597
597
 
598
+ /** Human-readable "time since" — clamps sub-minute values to "刚刚". */
599
+ function fmtAgo(timestamp: number): string {
600
+ const ms = Date.now() - timestamp
601
+ if (ms < 60_000) return '刚刚'
602
+ if (ms < 60 * 60 * 1000) return `${Math.round(ms / 60_000)}m 前`
603
+ if (ms < 24 * 60 * 60 * 1000) return `${Math.round(ms / (60 * 60 * 1000))}h 前`
604
+ return `${Math.round(ms / (24 * 60 * 60 * 1000))}d 前`
605
+ }
606
+
598
607
  const PEER_STATUS_EMOJI: Record<string, string> = {
599
608
  idle: '🟢', working: '⚙️', awaiting_permission: '🔐',
600
609
  starting: '🚀', stopped: '⚪',
@@ -623,19 +632,22 @@ export function consoleUsageContent(
623
632
  case 'network':
624
633
  return `**📊 订阅额度** 拉取失败${usage.reason ? ' — `' + usage.reason + '`' : ''}`
625
634
  }
626
- // state === 'ok'
635
+ // state === 'ok' —— stale 时 head 加 "缓存 Xm 前",重置时间加 `~`
636
+ // 前缀,沿用 omchud HUD 的 stale 标记约定。
637
+ const staleNote = usage.stale ? ` _· 缓存 ${fmtAgo(usage.fetchedAt)}_` : ''
638
+ const resetPrefix = usage.stale ? '~' : ''
627
639
  const head = usage.subscriptionType
628
- ? `**📊 订阅额度** · ${usage.subscriptionType}`
629
- : '**📊 订阅额度**'
640
+ ? `**📊 订阅额度** · ${usage.subscriptionType}${staleNote}`
641
+ : `**📊 订阅额度**${staleNote}`
630
642
  const lines: string[] = [head]
631
643
  if (usage.fiveHour) {
632
644
  const parts = [`${Math.round(usage.fiveHour.percent)}%`]
633
- if (usage.fiveHour.resetsAt) parts.push(`重置 ${fmtResetIn(usage.fiveHour.resetsAt)}`)
645
+ if (usage.fiveHour.resetsAt) parts.push(`重置 ${resetPrefix}${fmtResetIn(usage.fiveHour.resetsAt)}`)
634
646
  lines.push(` · 5h ${parts.join(' · ')}`)
635
647
  }
636
648
  if (usage.weekly) {
637
649
  const parts = [`${Math.round(usage.weekly.percent)}%`]
638
- if (usage.weekly.resetsAt) parts.push(`重置 ${fmtResetIn(usage.weekly.resetsAt)}`)
650
+ if (usage.weekly.resetsAt) parts.push(`重置 ${resetPrefix}${fmtResetIn(usage.weekly.resetsAt)}`)
639
651
  lines.push(` · 7d ${parts.join(' · ')}`)
640
652
  }
641
653
  return lines.length === 1 ? '**📊 订阅额度** _无数据_' : lines.join('\n')
package/src/usage.ts CHANGED
@@ -26,6 +26,18 @@
26
26
  * 调用共享同一份快照,不打 API。in-flight 去重保证并发的多个
27
27
  * 群同时唤出控制台时只触发一次后台请求。
28
28
  *
29
+ * Stale fallback (照 omchud HUD 规则): 单独记最后一次成功拉到的
30
+ * `state:'ok'` 快照,本次拉取失败 (network/rate_limited/auth_failed)
31
+ * 且距上次成功 <= MAX_STALE_MS (15 分钟) 时,返回上次的 ok 快照并打
32
+ * `stale:true` 标签,卡片层加 "缓存 Xm 前" 提示。这是 no_fallbacks
33
+ * 规则的显式例外 —— 用户明确要求订阅额度面板用缓存兜底,因为短暂
34
+ * 网络抖动里把面板上的数字抹成红色"拉取失败"信息密度反而更低。
35
+ *
36
+ * 429 指数退避: 收到 rate_limited 时增加 rateLimitedCount,下次允许
37
+ * 实拉的时间设为 now + CACHE_TTL_MS * 2^(count-1),封顶 5 分钟。
38
+ * 退避窗口内的 readUsage 直接走 stale fallback,不打 API。任何非 429
39
+ * 的响应 (ok / network / auth_failed) 都会重置计数器。
40
+ *
29
41
  * 参考实现: oh-my-claudecode HUD `src/hud/usage-api.ts`。这里只保留
30
42
  * Lodestar 用得到的最小子集 —— 不处理 keychain、不处理第三方网关
31
43
  * (z.ai / MiniMax)、不处理 enterprise 货币换算、不做多文件 cache 与
@@ -42,6 +54,11 @@ const TOKEN_REFRESH_URL = 'https://platform.claude.com/v1/oauth/token'
42
54
  const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
43
55
  const API_TIMEOUT_MS = 10_000
44
56
  const CACHE_TTL_MS = 60_000
57
+ /** 失败时回退到上次成功快照的最大年龄。超过此值就不再用缓存兜底,
58
+ * 显示真实失败状态 —— 跟 omchud HUD 的 MAX_STALE_DATA_MS 对齐。 */
59
+ const MAX_STALE_MS = 15 * 60 * 1000
60
+ /** 429 退避封顶,跟 omchud HUD 的 MAX_RATE_LIMITED_BACKOFF_MS 对齐。 */
61
+ const RATE_LIMITED_MAX_BACKOFF_MS = 5 * 60 * 1000
45
62
 
46
63
  function credentialsPath(): string {
47
64
  return join(homedir(), '.claude', '.credentials.json')
@@ -73,10 +90,29 @@ export type UsageSnapshot =
73
90
  fiveHour: UsageWindow | null
74
91
  weekly: UsageWindow | null
75
92
  fetchedAt: number
93
+ /** true 时本快照不是这次实拉的,而是 lastOk 兜底回来的旧数据。
94
+ * 卡片层据此显示 "缓存" 标记 + 重置时间加 `~` 前缀。 */
95
+ stale?: boolean
76
96
  }
77
97
 
98
+ type UsageSnapshotOk = Extract<UsageSnapshot, { state: 'ok' }>
99
+
78
100
  let cache: { data: UsageSnapshot; at: number } | null = null
101
+ /** 最近一次 state:'ok' 的快照,用于失败时兜底。和 cache 分开存:
102
+ * cache 是短时去重 (60s),lastOk 是长尾兜底 (15min)。 */
103
+ let lastOk: { snapshot: UsageSnapshotOk; at: number } | null = null
79
104
  let inFlight: Promise<UsageSnapshot> | null = null
105
+ /** 连续 429 计数,用于指数退避;遇到任何非 429 响应就重置为 0。 */
106
+ let rateLimitedCount = 0
107
+ /** 在这个时间戳之前不打 API,直接走 stale fallback。 */
108
+ let rateLimitedUntil = 0
109
+
110
+ function rateLimitedBackoffMs(count: number): number {
111
+ return Math.min(
112
+ CACHE_TTL_MS * Math.pow(2, Math.max(0, count - 1)),
113
+ RATE_LIMITED_MAX_BACKOFF_MS,
114
+ )
115
+ }
80
116
 
81
117
  function readCredentials(): OAuthCredentials | null {
82
118
  const path = credentialsPath()
@@ -246,18 +282,46 @@ async function fetchUsage(): Promise<UsageSnapshot> {
246
282
  }
247
283
  }
248
284
 
285
+ /** 失败快照 → 如果 MAX_STALE_MS 内还有 lastOk,就返回 lastOk 的副本
286
+ * (打 stale 标);否则透传失败快照。state:'ok' 走 fast path 原样返回。 */
287
+ function withStaleFallback(snapshot: UsageSnapshot): UsageSnapshot {
288
+ if (snapshot.state === 'ok') return snapshot
289
+ if (lastOk && Date.now() - lastOk.at < MAX_STALE_MS) {
290
+ return { ...lastOk.snapshot, stale: true }
291
+ }
292
+ return snapshot
293
+ }
294
+
249
295
  /** 返回订阅额度快照。CACHE_TTL_MS 内的重复调用读缓存;并发请求去重为
250
- * 单次后台 fetch。永不抛出 —— 失败状态由 `state` 字段表达,卡片层
251
- * state 分支渲染。 */
296
+ * 单次后台 fetch。拉取失败但 lastOk 仍在 MAX_STALE_MS 内时,回退到
297
+ * lastOk 并打 stale 标。连续 429 走指数退避,退避窗口内不打 API。
298
+ * 永不抛出 —— 失败状态由 `state` 字段表达,卡片层按 state 分支渲染。 */
252
299
  export async function readUsage(): Promise<UsageSnapshot> {
253
- if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.data
300
+ // 429 退避窗口内不打 API。cache 里可能存的就是 rate_limited 失败态,
301
+ // withStaleFallback 会自动用 lastOk 顶上(15min 内)。
302
+ if (Date.now() < rateLimitedUntil) {
303
+ return withStaleFallback(cache?.data ?? { state: 'rate_limited' })
304
+ }
305
+ if (cache && Date.now() - cache.at < CACHE_TTL_MS) return withStaleFallback(cache.data)
254
306
  if (inFlight) return inFlight
255
307
  inFlight = fetchUsage()
256
- .then(d => { cache = { data: d, at: Date.now() }; inFlight = null; return d })
308
+ .then(d => {
309
+ cache = { data: d, at: Date.now() }
310
+ if (d.state === 'ok') lastOk = { snapshot: d, at: Date.now() }
311
+ if (d.state === 'rate_limited') {
312
+ rateLimitedCount += 1
313
+ rateLimitedUntil = Date.now() + rateLimitedBackoffMs(rateLimitedCount)
314
+ } else {
315
+ rateLimitedCount = 0
316
+ rateLimitedUntil = 0
317
+ }
318
+ inFlight = null
319
+ return withStaleFallback(d)
320
+ })
257
321
  .catch(e => {
258
322
  log(`usage: fetchUsage threw: ${e}`)
259
323
  inFlight = null
260
- return cache?.data ?? { state: 'network', reason: String(e) }
324
+ return withStaleFallback({ state: 'network', reason: String(e) })
261
325
  })
262
326
  return inFlight
263
327
  }