@dijkspicy/opencode-stats-reporter 0.1.0

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.
@@ -0,0 +1,610 @@
1
+ /**
2
+ * session-usage-reporter — opencode 客户端会话 token 用量上报插件
3
+ *
4
+ * 行为概要:
5
+ * - 在会话空闲(bus 事件 `session.idle`,或 `session.status` 且 `status.type === "idle"`)时,
6
+ * 读取该会话及其全部后代子会话的「已完成」助手消息 token 用量,按会话各自聚合为快照。
7
+ * - 父会话快照绝不并入子会话用量;每个会话(含子代理会话)都是独立记录。
8
+ * - token 口径原样分列上报(input / output / reasoning / cache.read / cache.write),
9
+ * 不在客户端做任何相加或换算(`output` 不含 reasoning,`input` 不含 cache.read)。
10
+ * - hook 只触发后台采集并入队,绝不等待网络;由后台 flush 统一 POST 到 SESSION_USAGE_ENDPOINT。
11
+ * - 有界内存队列(默认 500 条快照,溢出丢最旧)+ 指数退避(带抖动)+ AbortSignal.timeout 超时。
12
+ * - `dispose` 钩子在进程退出时以约 3 秒预算尽力刷新队列,超时即放弃,绝不阻塞退出。
13
+ * - 载荷不含任何消费者身份字段;身份完全由网关(key-auth 注入 X-Forward-Consumer)决定。
14
+ *
15
+ * 修订号(revision)选型:
16
+ * 直接使用「该会话已完成助手消息条数」。追加式会话中随时间单调递增;重复上报同一数值
17
+ * 对服务端幂等无害(upsert 按 (consumer, install_id, session_id) + 单调守卫);若会话
18
+ * 压缩/删消息导致回退,服务端单调守卫会拒绝旧修订,数据不会倒退。
19
+ *
20
+ * 已在 opencode 1.18.33 上核实的 loader 行为(勿破坏):
21
+ * - 本文件所有运行时导出必须都是函数,否则 loader 抛 "Plugin export is not a function"。
22
+ * - loader 会把【每一个】函数导出都当插件工厂调用一次(参数是插件输入
23
+ * { client, project, worktree, directory, experimental_workspace, serverUrl, $ }),
24
+ * 并对返回值做 `V.event?.(...)` / `V.dispose?.()` 访问。
25
+ * 因此每个纯辅助导出在收到「插件输入形态」的首参时必须返回无害对象 `{}`,
26
+ * 绝不能抛错、返回 null 或 undefined(否则 `V.event?.` 会读到空值导致崩溃)。
27
+ *
28
+ * 安装:复制本文件到 ~/.config/opencode/plugins/(全局)或 <项目>/.opencode/plugins/(项目级)。
29
+ * 启用:设置环境变量 SESSION_USAGE_ENDPOINT(未设置时插件保持惰性)。
30
+ */
31
+
32
+ import type { Plugin } from "@opencode-ai/plugin"
33
+ import { randomUUID } from "node:crypto"
34
+ import { mkdir, readFile, writeFile } from "node:fs/promises"
35
+ import { homedir } from "node:os"
36
+ import { dirname, join } from "node:path"
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // 常量(模块私有:loader 要求所有导出必须是函数,因此常量一律不导出)
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const SCHEMA_VERSION = 1
43
+ const PLUGIN_VERSION = "0.1.0"
44
+ /** 队列容量:最多积压 500 条会话快照,溢出丢最旧 */
45
+ const QUEUE_CAP = 500
46
+ /** 单次上报请求超时(AbortSignal.timeout) */
47
+ const REQUEST_TIMEOUT_MS = 10_000
48
+ /** 退避基值:第 n 次失败后延迟 ≈ RETRY_BASE_MS * 2^n,封顶 RETRY_MAX_MS,再乘 [0.5, 1.5) 抖动 */
49
+ const RETRY_BASE_MS = 1_000
50
+ const RETRY_MAX_MS = 5 * 60_000
51
+ /** 入队后延迟 flush 的去抖窗口 */
52
+ const FLUSH_DEBOUNCE_MS = 250
53
+ /** dispose 刷新预算 */
54
+ const DISPOSE_BUDGET_MS = 3_000
55
+ /** 子会话递归深度上限(防御环/异常数据) */
56
+ const MAX_SESSION_DEPTH = 32
57
+
58
+ const INSTALL_ID_STATE_FILE = () => join(homedir(), ".local", "state", "opencode-session-reporter", "install_id")
59
+
60
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // 类型(type-only 导出在运行时会被擦除,不影响 loader 的「导出必须全是函数」校验)
64
+ // ---------------------------------------------------------------------------
65
+
66
+ export type UsageMessageInfo = {
67
+ id?: string
68
+ role?: string
69
+ modelID?: string
70
+ providerID?: string
71
+ mode?: string
72
+ tokens?: {
73
+ input?: number
74
+ output?: number
75
+ reasoning?: number
76
+ cache?: { read?: number; write?: number }
77
+ }
78
+ time?: { completed?: number | null }
79
+ }
80
+
81
+ /** `client.session.messages` 返回的条目中与本插件相关的最小形状 */
82
+ export type UsageMessage = { info: UsageMessageInfo }
83
+
84
+ /** 单个会话的用量快照(同时是上报载荷里 sessions 数组条目的线上格式) */
85
+ export type SessionSnapshot = {
86
+ session_id: string
87
+ revision: number
88
+ model: string | null
89
+ agent: string | null
90
+ tokens_input: number
91
+ tokens_output: number
92
+ tokens_reasoning: number
93
+ tokens_cache_read: number
94
+ tokens_cache_write: number
95
+ message_count: number
96
+ session_created_at: number | null
97
+ last_activity_at: number | null
98
+ }
99
+
100
+ export type BoundedQueue = {
101
+ readonly capacity: number
102
+ readonly size: number
103
+ readonly dropped: number
104
+ push: (item: SessionSnapshot) => void
105
+ drainAll: () => SessionSnapshot[]
106
+ restore: (items: readonly SessionSnapshot[]) => void
107
+ }
108
+
109
+ export type ReportPayload = {
110
+ schema_version: number
111
+ install_id: string
112
+ reporter: { client: string; version: string }
113
+ sessions: SessionSnapshot[]
114
+ }
115
+
116
+ /** 本插件实际用到的 opencode SDK client 最小形状(便于 mock 测试) */
117
+ export type ReporterClient = {
118
+ session: {
119
+ messages: (args: { path: { id: string }; query?: { directory?: string } }) => Promise<{ data?: unknown }>
120
+ children: (args: { path: { id: string }; query?: { directory?: string } }) => Promise<{ data?: unknown }>
121
+ get?: (args: { path: { id: string }; query?: { directory?: string } }) => Promise<{ data?: unknown }>
122
+ }
123
+ }
124
+
125
+ /** 可注入的定时器(测试用手动调度器替换,避免真实 setTimeout) */
126
+ export type ReporterScheduler = (fn: () => void, delayMs: number) => () => void
127
+
128
+ export type ReporterCoreOptions = {
129
+ client: ReporterClient
130
+ installId: string
131
+ endpoint?: string
132
+ directory?: string
133
+ reporterVersion?: string
134
+ authToken?: string
135
+ fetchFn?: typeof fetch
136
+ queue?: BoundedQueue
137
+ scheduler?: ReporterScheduler
138
+ flushDebounceMs?: number
139
+ requestTimeoutMs?: number
140
+ log?: (message: string, error?: unknown) => void
141
+ random?: () => number
142
+ }
143
+
144
+ export type ReporterCore = {
145
+ handleEvent: (event?: { type?: unknown; properties?: unknown }) => Promise<void>
146
+ collectSession: (sessionID: string) => Promise<void>
147
+ flushOnce: () => Promise<boolean>
148
+ disposeFlush: (budgetMs?: number) => Promise<void>
149
+ readonly queue: BoundedQueue
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // 通用小工具
154
+ // ---------------------------------------------------------------------------
155
+
156
+ const toCount = (value: unknown): number =>
157
+ typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0
158
+
159
+ const joinModel = (providerID?: string, modelID?: string): string | null =>
160
+ providerID && modelID ? `${providerID}/${modelID}` : null
161
+
162
+ /**
163
+ * 判断首参是否是 opencode loader 传入的插件输入。
164
+ * loader 会对本文件所有函数导出各调用一次 `export(ctxInput, options)`。
165
+ */
166
+ const isPluginInput = (value: unknown): boolean => {
167
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false
168
+ return (
169
+ "client" in value &&
170
+ ("project" in value || "worktree" in value || "experimental_workspace" in value || "serverUrl" in value)
171
+ )
172
+ }
173
+
174
+ /**
175
+ * 包装纯辅助导出:被 loader 误当插件工厂调用时返回 `{}`(安全、可被 `V.event?.` 访问),
176
+ * 正常按签名调用时行为与原函数完全一致。
177
+ */
178
+ const pluginSafe = <A extends unknown[], R>(fn: (...args: A) => R): ((...args: A) => R) => {
179
+ return (...args: A): R => {
180
+ if (isPluginInput(args[0])) return {} as R
181
+ return fn(...args)
182
+ }
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // 纯函数:聚合 / 合并 / 载荷 / 队列 / 退避
187
+ // ---------------------------------------------------------------------------
188
+
189
+ const aggregateSessionImpl = (sessionID: string, messages: readonly UsageMessage[]): SessionSnapshot | null => {
190
+ let input = 0
191
+ let output = 0
192
+ let reasoning = 0
193
+ let cacheRead = 0
194
+ let cacheWrite = 0
195
+ let count = 0
196
+ let lastActivity: number | null = null
197
+ let model: string | null = null
198
+ let agent: string | null = null
199
+
200
+ for (const message of messages ?? []) {
201
+ const info = message?.info
202
+ if (!info || info.role !== "assistant") continue
203
+ const completed = info.time?.completed
204
+ // 只统计已完成(time.completed 已设置)的助手消息,天然去重、不双计
205
+ if (typeof completed !== "number" || !Number.isFinite(completed)) continue
206
+ count += 1
207
+ input += toCount(info.tokens?.input)
208
+ output += toCount(info.tokens?.output)
209
+ reasoning += toCount(info.tokens?.reasoning)
210
+ cacheRead += toCount(info.tokens?.cache?.read)
211
+ cacheWrite += toCount(info.tokens?.cache?.write)
212
+ if (lastActivity === null || completed > lastActivity) lastActivity = completed
213
+ const messageModel = joinModel(info.providerID, info.modelID)
214
+ if (messageModel) {
215
+ // 取最后一条已完成助手消息的模型 / agent 作为会话级归属
216
+ model = messageModel
217
+ agent = typeof info.mode === "string" ? info.mode : null
218
+ }
219
+ }
220
+
221
+ if (count === 0) return null
222
+
223
+ return {
224
+ session_id: sessionID,
225
+ revision: count, // 修订号 = 已完成助手消息数(见文件头说明)
226
+ model,
227
+ agent,
228
+ tokens_input: input,
229
+ tokens_output: output,
230
+ tokens_reasoning: reasoning,
231
+ tokens_cache_read: cacheRead,
232
+ tokens_cache_write: cacheWrite,
233
+ message_count: count,
234
+ session_created_at: null, // 由采集层补充
235
+ last_activity_at: lastActivity,
236
+ }
237
+ }
238
+
239
+ /** 合并同一会话的多个快照:保留修订号最高者;同修订取 last_activity 更新(或后到)者 */
240
+ const mergeSnapshotsImpl = (snapshots: readonly SessionSnapshot[]): SessionSnapshot[] => {
241
+ const byID = new Map<string, SessionSnapshot>()
242
+ for (const snapshot of snapshots ?? []) {
243
+ if (!snapshot || typeof snapshot.session_id !== "string" || !snapshot.session_id) continue
244
+ const existing = byID.get(snapshot.session_id)
245
+ if (!existing || snapshot.revision > existing.revision) {
246
+ byID.set(snapshot.session_id, snapshot)
247
+ continue
248
+ }
249
+ if (snapshot.revision === existing.revision) {
250
+ const existingAt = existing.last_activity_at ?? 0
251
+ const incomingAt = snapshot.last_activity_at ?? 0
252
+ if (incomingAt >= existingAt) byID.set(snapshot.session_id, snapshot)
253
+ }
254
+ }
255
+ return [...byID.values()]
256
+ }
257
+
258
+ /** 组装上报载荷。绝不包含消费者身份字段 —— 身份由网关注入。 */
259
+ const buildPayloadImpl = (
260
+ installId: string,
261
+ reporterVersion: string,
262
+ snapshots: readonly SessionSnapshot[],
263
+ ): ReportPayload => ({
264
+ schema_version: SCHEMA_VERSION,
265
+ install_id: installId,
266
+ reporter: { client: "opencode", version: reporterVersion },
267
+ sessions: mergeSnapshotsImpl(snapshots),
268
+ })
269
+
270
+ /** 有界队列:溢出丢最旧;失败批次按原顺序放回队首 */
271
+ const createQueueImpl = (cap: number = QUEUE_CAP): BoundedQueue => {
272
+ const capacity = typeof cap === "number" && Number.isFinite(cap) && cap >= 1 ? Math.floor(cap) : 1
273
+ let items: SessionSnapshot[] = []
274
+ let dropped = 0
275
+ return {
276
+ capacity,
277
+ get size() {
278
+ return items.length
279
+ },
280
+ get dropped() {
281
+ return dropped
282
+ },
283
+ push(item) {
284
+ if (!item) return
285
+ if (items.length >= capacity) {
286
+ items.shift()
287
+ dropped += 1
288
+ }
289
+ items.push(item)
290
+ },
291
+ drainAll() {
292
+ const drained = items
293
+ items = []
294
+ return drained
295
+ },
296
+ restore(incoming) {
297
+ const valid = (incoming ?? []).filter((item) => !!item)
298
+ items = [...valid, ...items]
299
+ // 极端情况下放回后超容量:保最旧(先产生的数据),裁掉最新
300
+ if (items.length > capacity) {
301
+ dropped += items.length - capacity
302
+ items = items.slice(0, capacity)
303
+ }
304
+ },
305
+ }
306
+ }
307
+
308
+ /** 指数退避 + 抖动:delay = min(RETRY_MAX_MS, BASE * 2^attempt) * [0.5, 1.5),结果封顶 RETRY_MAX_MS */
309
+ const computeBackoffMsImpl = (attempt: number, random01: number = Math.random()): number => {
310
+ const safeAttempt =
311
+ typeof attempt === "number" && Number.isFinite(attempt) ? Math.max(0, Math.floor(attempt)) : 0
312
+ const exponential = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** safeAttempt)
313
+ const safeRandom =
314
+ typeof random01 === "number" && Number.isFinite(random01) ? Math.min(1, Math.max(0, random01)) : 0.5
315
+ const jittered = Math.round(exponential * (0.5 + safeRandom))
316
+ return Math.min(RETRY_MAX_MS, Math.max(0, jittered))
317
+ }
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // install_id:本地稳定 UUID(生成一次,持久化到用户状态目录)
321
+ // ---------------------------------------------------------------------------
322
+
323
+ const loadOrCreateInstallIdImpl = async (
324
+ options: { stateFile?: string; randomUUIDFn?: () => string } = {},
325
+ ): Promise<string> => {
326
+ const stateFile = options.stateFile ?? INSTALL_ID_STATE_FILE()
327
+ try {
328
+ const existing = (await readFile(stateFile, "utf8")).trim()
329
+ if (UUID_PATTERN.test(existing)) return existing
330
+ } catch {
331
+ // 首次运行或不可读:走生成流程
332
+ }
333
+ const id = options.randomUUIDFn ? options.randomUUIDFn() : randomUUID()
334
+ try {
335
+ await mkdir(dirname(stateFile), { recursive: true })
336
+ await writeFile(stateFile, `${id}\n`, "utf8")
337
+ } catch {
338
+ // 只读文件系统等场景:退化为本次进程内的临时 id
339
+ }
340
+ return id
341
+ }
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // 核心:采集 + 非阻塞队列 + 退避重试 + dispose 限时刷新
345
+ // ---------------------------------------------------------------------------
346
+
347
+ const defaultScheduler: ReporterScheduler = (fn, delayMs) => {
348
+ const timer = setTimeout(fn, Math.max(0, delayMs))
349
+ const unref = (timer as unknown as { unref?: () => void }).unref
350
+ if (typeof unref === "function") unref.call(timer) // 定时器不阻止进程退出
351
+ return () => clearTimeout(timer)
352
+ }
353
+
354
+ const createReporterCoreImpl = (options: ReporterCoreOptions): ReporterCore => {
355
+ const queue = options.queue ?? createQueueImpl(QUEUE_CAP)
356
+ const fetchFn = options.fetchFn ?? fetch
357
+ const endpoint = typeof options.endpoint === "string" && options.endpoint.trim() ? options.endpoint.trim() : undefined
358
+ const reporterVersion = options.reporterVersion ?? PLUGIN_VERSION
359
+ const requestTimeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS
360
+ const flushDebounceMs = options.flushDebounceMs ?? FLUSH_DEBOUNCE_MS
361
+ const scheduler = options.scheduler ?? defaultScheduler
362
+ const random = options.random ?? Math.random
363
+ const log = options.log ?? (() => {})
364
+
365
+ let flushCancel: (() => void) | null = null
366
+ let retryCancel: (() => void) | null = null
367
+ let retryAttempt = 0
368
+ const inFlight = new Set<string>()
369
+
370
+ const send = async (snapshots: readonly SessionSnapshot[], timeoutMs: number): Promise<void> => {
371
+ if (!endpoint) return
372
+ const payload = buildPayloadImpl(options.installId, reporterVersion, snapshots)
373
+ const abortTimeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal } | undefined)?.timeout
374
+ const signal = typeof abortTimeout === "function" ? abortTimeout(Math.max(1, timeoutMs)) : undefined
375
+ const headers: Record<string, string> = { "content-type": "application/json" }
376
+ // 载荷不含身份;上报请求需带网关 Key,由 APIG key-auth 校验后注入消费者身份。
377
+ if (options.authToken) headers["authorization"] = `Bearer ${options.authToken}`
378
+ const response = await fetchFn(endpoint, {
379
+ method: "POST",
380
+ headers,
381
+ body: JSON.stringify(payload),
382
+ signal,
383
+ })
384
+ if (!response.ok) throw new Error(`session-usage endpoint responded ${response.status}`)
385
+ }
386
+
387
+ /** 发送当前全部积压;失败则原样放回队首并调度退避重试。返回是否成功(或无事可做)。 */
388
+ const flushOnce = async (): Promise<boolean> => {
389
+ if (!endpoint) return true
390
+ const batch = queue.drainAll()
391
+ if (batch.length === 0) return true
392
+ try {
393
+ await send(batch, requestTimeoutMs)
394
+ retryAttempt = 0
395
+ return true
396
+ } catch (error) {
397
+ queue.restore(batch)
398
+ retryAttempt += 1
399
+ // 第 1 次失败 → 基础 1s,此后指数翻倍(2s、4s…),封顶 5 分钟,均带抖动
400
+ const delay = computeBackoffMsImpl(Math.max(0, retryAttempt - 1), random())
401
+ log(`session-usage: 上报失败,${delay}ms 后重试(队列积压 ${queue.size})`, error)
402
+ if (!retryCancel) {
403
+ retryCancel = scheduler(() => {
404
+ retryCancel = null
405
+ void flushOnce()
406
+ }, delay)
407
+ }
408
+ return false
409
+ }
410
+ }
411
+
412
+ const scheduleFlush = (): void => {
413
+ if (flushCancel || queue.size === 0) return
414
+ flushCancel = scheduler(() => {
415
+ flushCancel = null
416
+ void flushOnce()
417
+ }, flushDebounceMs)
418
+ }
419
+
420
+ const query = (): { directory: string } | undefined =>
421
+ options.directory ? { directory: options.directory } : undefined
422
+
423
+ const readMessages = async (sessionID: string): Promise<UsageMessage[]> => {
424
+ const response = await options.client.session.messages({ path: { id: sessionID }, query: query() })
425
+ const data = (response as { data?: unknown } | undefined)?.data
426
+ return Array.isArray(data) ? (data as UsageMessage[]) : []
427
+ }
428
+
429
+ const readChildIDs = async (sessionID: string): Promise<string[]> => {
430
+ const response = await options.client.session.children({ path: { id: sessionID }, query: query() })
431
+ const data = (response as { data?: unknown } | undefined)?.data
432
+ if (!Array.isArray(data)) return []
433
+ const ids: string[] = []
434
+ for (const entry of data) {
435
+ const id = typeof entry === "string" ? entry : (entry as { id?: unknown } | null)?.id
436
+ if (typeof id === "string" && id) ids.push(id)
437
+ }
438
+ return ids
439
+ }
440
+
441
+ const readSessionCreatedAt = async (sessionID: string): Promise<number | null> => {
442
+ try {
443
+ const getSession = options.client.session.get
444
+ if (typeof getSession !== "function") return null
445
+ const response = await getSession.call(options.client.session, { path: { id: sessionID }, query: query() })
446
+ const created = (response as { data?: { time?: { created?: unknown } } | undefined } | undefined)?.data?.time
447
+ ?.created
448
+ return typeof created === "number" ? created : null
449
+ } catch {
450
+ return null
451
+ }
452
+ }
453
+
454
+ /** 递归聚合会话及其后代;每个会话独立成快照,父快照不含子用量 */
455
+ const collectRecursive = async (
456
+ sessionID: string,
457
+ out: SessionSnapshot[],
458
+ visited: Set<string>,
459
+ depth: number,
460
+ ): Promise<void> => {
461
+ if (!sessionID || visited.has(sessionID) || depth > MAX_SESSION_DEPTH || visited.size >= QUEUE_CAP) return
462
+ visited.add(sessionID)
463
+ const snapshot = aggregateSessionImpl(sessionID, await readMessages(sessionID))
464
+ if (snapshot) {
465
+ snapshot.session_created_at = await readSessionCreatedAt(sessionID)
466
+ out.push(snapshot)
467
+ }
468
+ for (const childID of await readChildIDs(sessionID)) {
469
+ await collectRecursive(childID, out, visited, depth + 1)
470
+ }
471
+ }
472
+
473
+ const collectSession = async (sessionID: string): Promise<void> => {
474
+ if (typeof sessionID !== "string" || !sessionID) return
475
+ if (inFlight.has(sessionID)) return // 同一会话采集中:跳过,下次空闲信号再采
476
+ inFlight.add(sessionID)
477
+ try {
478
+ const out: SessionSnapshot[] = []
479
+ await collectRecursive(sessionID, out, new Set(), 0)
480
+ for (const snapshot of out) queue.push(snapshot)
481
+ if (out.length > 0) scheduleFlush()
482
+ } catch (error) {
483
+ log(`session-usage: 采集会话 ${sessionID} 用量失败`, error)
484
+ } finally {
485
+ inFlight.delete(sessionID)
486
+ }
487
+ }
488
+
489
+ /** 统一处理两种空闲信号;绝不抛出 */
490
+ const handleEvent = async (event?: { type?: unknown; properties?: unknown }): Promise<void> => {
491
+ try {
492
+ const type = event?.type
493
+ const properties = (event?.properties ?? {}) as { sessionID?: unknown; status?: { type?: unknown } }
494
+ let sessionID: unknown
495
+ if (type === "session.idle") {
496
+ sessionID = properties.sessionID
497
+ } else if (type === "session.status" && properties.status?.type === "idle") {
498
+ sessionID = properties.sessionID
499
+ }
500
+ if (typeof sessionID === "string" && sessionID) await collectSession(sessionID)
501
+ } catch (error) {
502
+ log("session-usage: 处理事件失败", error)
503
+ }
504
+ }
505
+
506
+ /** dispose:限时预算内尽力清空队列;失败立即止损,绝不阻塞退出 */
507
+ const disposeFlush = async (budgetMs: number = DISPOSE_BUDGET_MS): Promise<void> => {
508
+ try {
509
+ flushCancel?.()
510
+ flushCancel = null
511
+ retryCancel?.()
512
+ retryCancel = null
513
+ if (!endpoint) return
514
+ const deadline = Date.now() + Math.max(0, budgetMs)
515
+ while (queue.size > 0) {
516
+ const remaining = deadline - Date.now()
517
+ if (remaining <= 0) break
518
+ const batch = queue.drainAll()
519
+ if (batch.length === 0) break
520
+ try {
521
+ await send(batch, Math.min(requestTimeoutMs, remaining))
522
+ retryAttempt = 0
523
+ } catch (error) {
524
+ queue.restore(batch)
525
+ log("session-usage: 退出前上报失败,放弃剩余队列", error)
526
+ break
527
+ }
528
+ }
529
+ } catch (error) {
530
+ log("session-usage: dispose 刷新失败", error)
531
+ }
532
+ }
533
+
534
+ return { handleEvent, collectSession, flushOnce, disposeFlush, queue }
535
+ }
536
+
537
+ // ---------------------------------------------------------------------------
538
+ // 导出:全部是函数(loader 硬约束),纯辅助导出带插件工厂防御
539
+ // ---------------------------------------------------------------------------
540
+
541
+ export const aggregateSession = pluginSafe(aggregateSessionImpl)
542
+ export const mergeSnapshots = pluginSafe(mergeSnapshotsImpl)
543
+ export const buildPayload = pluginSafe(buildPayloadImpl)
544
+ export const createQueue = pluginSafe(createQueueImpl)
545
+ export const computeBackoffMs = pluginSafe(computeBackoffMsImpl)
546
+ export const loadOrCreateInstallId = pluginSafe(loadOrCreateInstallIdImpl)
547
+ export const createReporterCore = pluginSafe(createReporterCoreImpl)
548
+
549
+ /**
550
+ * 插件入口。
551
+ * 未设置 SESSION_USAGE_ENDPOINT 时返回空 hooks,完全不影响会话。
552
+ */
553
+ export const SessionUsageReporter: Plugin = async (ctx) => {
554
+ try {
555
+ const endpoint = (process.env.SESSION_USAGE_ENDPOINT ?? "").trim() || undefined
556
+ if (!endpoint) return {}
557
+
558
+ const log = (message: string, error?: unknown): void => {
559
+ try {
560
+ const detail = error instanceof Error ? `${error.name}: ${error.message}` : error === undefined ? "" : String(error)
561
+ const client = ctx.client as { app?: { log?: (args: unknown) => unknown } }
562
+ const result = client.app?.log?.({
563
+ body: {
564
+ level: detail ? "error" : "info",
565
+ service: "session-usage-reporter",
566
+ message: detail ? `${message}: ${detail}` : message,
567
+ },
568
+ })
569
+ const promise = result as Promise<void> | undefined
570
+ if (promise && typeof promise.catch === "function") promise.catch(() => {})
571
+ } catch {
572
+ // 日志失败绝不影响会话
573
+ }
574
+ }
575
+
576
+ const installId = await loadOrCreateInstallIdImpl()
577
+ const core = createReporterCoreImpl({
578
+ client: ctx.client as unknown as ReporterClient,
579
+ directory: ctx.directory,
580
+ endpoint,
581
+ installId,
582
+ // 上报请求的网关 Key(APIG key-auth 后注入 X-Forward-Consumer);未配置则不带鉴权头
583
+ authToken: (process.env.SESSION_USAGE_API_KEY ?? "").trim() || undefined,
584
+ log,
585
+ })
586
+
587
+ return {
588
+ event: async (input: { event?: { type?: unknown; properties?: unknown } }) => {
589
+ try {
590
+ // 只触发后台采集,不 await:hook 内禁止等待网络,hooks 串行等待不能被拖慢
591
+ core.handleEvent(input?.event).catch(() => {})
592
+ } catch (error) {
593
+ log("session-usage: hook 执行异常(已忽略)", error)
594
+ }
595
+ },
596
+ dispose: async () => {
597
+ await core.disposeFlush()
598
+ },
599
+ }
600
+ } catch (error) {
601
+ try {
602
+ console.error("[session-usage-reporter] 初始化失败,插件已禁用:", error)
603
+ } catch {
604
+ // 连 console 都失败时保持静默
605
+ }
606
+ return {}
607
+ }
608
+ }
609
+
610
+ export default SessionUsageReporter
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@dijkspicy/opencode-stats-reporter",
3
+ "version": "0.1.0",
4
+ "description": "本地一体化 MaaS 用量看板:托管静态面板、反代用量接口、本地存储会话用量",
5
+ "type": "module",
6
+ "main": "./contrib/opencode-session-reporter/plugin-server.ts",
7
+ "exports": {
8
+ ".": "./contrib/opencode-session-reporter/plugin-server.ts",
9
+ "./server": "./contrib/opencode-session-reporter/plugin-server.ts",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "bin": {
13
+ "opencode-stats-reporter": "bin/opencode-stats-reporter.js"
14
+ },
15
+ "engines": {
16
+ "node": ">=24.0.0"
17
+ },
18
+ "files": [
19
+ "bin",
20
+ "src",
21
+ "panel",
22
+ "contrib/opencode-session-reporter",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "scripts": {
27
+ "test": "node --test test/*.test.js"
28
+ },
29
+ "license": "Apache-2.0"
30
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * BlueRegion Usage 面板配置模板(示例)
3
+ * ===============================================================
4
+ * 使用方法:复制本文件为 config.js,按需修改下方配置项。
5
+ * !! 注意:config.js 会包含你的 API Key,请勿提交 git / 请勿截图分享 !!
6
+ * 优先级:浏览器 localStorage 中保存的配置 > config.js。
7
+ */
8
+
9
+ window.APP_CONFIG = {
10
+ // 网关地址(必填):BlueRegion API 网关基础地址,必须是合法 http(s) URL
11
+ // 示例:'https://gateway.example.com';本地联调可用 'http://localhost:8765'
12
+ gatewayBaseUrl: 'http://localhost:8765',
13
+
14
+ // API Key(必填):网关签发的个人 Key,请求时以 Bearer 方式携带
15
+ apiKey: 'api-key-xxxxxxxx',
16
+
17
+ // 趋势图天数(1-15,默认 14):仅"今日"周期下,趋势图回看的天数
18
+ trendDays: 14,
19
+
20
+ // 主题:auto(跟随系统)| light(浅色)| dark(深色),默认 auto
21
+ theme: 'auto',
22
+
23
+ // 大数字缩写风格:wan(万 / 亿,默认)| intl(K / M / B)
24
+ numberStyle: 'wan',
25
+
26
+ // 每日 Credit 限额(可选):今日用量超出该值,Credit 卡红字提示「已超出」。
27
+ // 留空(null)表示不限;可在网页首次登录或「配置限额」中填写,例如 100。
28
+ dailyCreditLimit: null,
29
+
30
+ // 每周 Credit 限额(可选):本周用量对应额度。
31
+ weeklyCreditLimit: null,
32
+
33
+ // 每月 Credit 限额(可选):本月用量对应额度,同时用于「本月进度条」的月均 pacing,例如 3000。
34
+ monthlyCreditLimit: null
35
+ };