@dijkspicy/opencode-stats-reporter 0.1.1 → 0.1.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/README.md CHANGED
@@ -23,7 +23,7 @@ npm i -g @dijkspicy/opencode-stats-reporter
23
23
  opencode-stats-reporter web start # 打开 http://127.0.0.1:8787
24
24
  ```
25
25
 
26
- - **无需单独填网关和 Key**:默认自动从 opencode 配置读取——取所选模型对应 provider 的 `options.baseURL` / `options.apiKey`(Key 不在配置里时回退到 opencode 凭据存储 `auth.json`);也可显式覆盖:`opencode-stats-reporter config set --gateway <url> --key <key>`,或环境变量 `OPENCODE_STATS_GATEWAY` / `OPENCODE_STATS_API_KEY`(优先级:本工具配置 > 环境变量 > opencode 推导);
26
+ - **无需单独填网关和 Key**:默认自动从 opencode 配置读取——取所选模型对应 provider 的 `options.baseURL` / `options.apiKey`(Key 不在配置里时回退到 opencode 凭据存储 `auth.json`);也可显式覆盖:`opencode-stats-reporter config set --gateway <url> --key <key> [--endpoint <url>]`,或环境变量 `OPENCODE_STATS_GATEWAY` / `OPENCODE_STATS_API_KEY`(优先级:本工具配置 > 环境变量 > opencode 推导);
27
27
 
28
28
  - 本地服务同时做三件事:托管静态面板、把 `/v1/usage/*` 与 `/v1/models` **反代**到你的网关(Key 只保存在服务端)、并在本地 SQLite 存储会话用量;
29
29
  - 浏览器只与 `localhost` 通信,因此**没有 CORS 问题,上游 Key 也不会进入浏览器**;
@@ -31,7 +31,7 @@ opencode-stats-reporter web start # 打开 http://127.0.0.1:8787
31
31
  ```bash
32
32
  opencode plugin @dijkspicy/opencode-stats-reporter -g
33
33
  ```
34
- 离线/本地文件方式作为替代:`opencode-stats-reporter plugin install`。之后在 opencode 进程环境设置 `SESSION_USAGE_ENDPOINT=http://127.0.0.1:8787/v1/session-usage/report`(本地模式无需 `SESSION_USAGE_API_KEY`,且不要用 `--pure` 启动);
34
+ 离线/本地文件方式作为替代:`opencode-stats-reporter plugin install`。插件**零配置**:启动服务后插件自动上报(`web start` 会把实际端点写入 `config.json`,插件未设环境变量时也生效;如需覆盖可用 `config set --endpoint <url>` 或环境变量 `SESSION_USAGE_ENDPOINT`。本地模式无需 `SESSION_USAGE_API_KEY`,且不要用 `--pure` 启动);
35
35
  - 默认仅监听 `127.0.0.1`;数据落在 `~/.local/share/opencode-stats-reporter/`,不会上传第三方;
36
36
  - **托管模式**:`opencode-stats-reporter web start --mode gateway` 时,会话接口要求网关注入的身份(`X-Forward-Consumer`),并按消费者做租户隔离;本地模式则为单一本地用户;
37
37
  - 要求 Node ≥ 24(使用内置 `node:sqlite`,零外部依赖)。
@@ -22,11 +22,23 @@ cp session-usage-reporter.ts .opencode/plugins/
22
22
  - 注意:以 `--pure` 启动 opencode 会禁用全部外部插件(包括本插件)。
23
23
  - 一个 opencode server 实例加载一份插件实例;插件按事件里的 `sessionID` 处理对应会话,无需多份。
24
24
 
25
+ ## 零配置:上报端点解析
26
+
27
+ 插件**始终激活**,无需任何环境变量。上报端点按以下优先级解析(从高到低):
28
+
29
+ 1. 环境变量 `SESSION_USAGE_ENDPOINT`(trim 后非空即用;保留为显式覆盖)
30
+ 2. 本工具配置文件 `<configDir>/config.json` 的 `reportEndpoint` 字段(须是合法 http(s) URL)
31
+ 3. 内置默认值 `http://127.0.0.1:8787/v1/session-usage/report`
32
+
33
+ 其中 `configDir` 为 `MONITOR_CONFIG_DIR` 或 `$XDG_CONFIG_HOME/opencode-stats-reporter`(与 `src/paths.js` 一致)。配套 CLI 的 `web start` 启动后会自动把实际监听端口的端点(`http://127.0.0.1:<port>/v1/session-usage/report`)写入 `config.json` 的 `reportEndpoint`,因此**启动服务 + 安装插件即可,无需任何手动配置**。
34
+
35
+ config.json 不存在、JSON 非法、字段缺失或 URL 非法时一律静默回退到下一优先级,解析过程绝不抛错。插件初始化成功后会通过 `app.log` 输出一条 info 日志(含最终端点与来源 `env/config/default`),便于排查「装了插件却没上报」的问题。
36
+
25
37
  ## 环境变量
26
38
 
27
39
  | 变量 | 必填 | 说明 |
28
40
  | --- | --- | --- |
29
- | `SESSION_USAGE_ENDPOINT` | 是(未设置则插件完全惰性) | 上报端点 URL。插件对它发 `POST`,`content-type: application/json`,body 见下。 |
41
+ | `SESSION_USAGE_ENDPOINT` | 否(显式覆盖,优先级最高) | 上报端点 URL。插件对它发 `POST`,`content-type: application/json`,body 见下。不设置时走 `config.json` 的 `reportEndpoint`,再退回内置默认值。 |
30
42
  | `SESSION_USAGE_API_KEY` | 否 | 上报请求的网关 Key(插件发 `Authorization: Bearer <key>`)。网关 key-auth 通过后注入 `X-Forward-Consumer`,服务端据此确定租户。未配置则不带鉴权头。 |
31
43
 
32
44
  > **身份来源**:载荷本身**不含**任何消费者身份字段。若 `SESSION_USAGE_ENDPOINT` 指向网关后的服务,必须配 `SESSION_USAGE_API_KEY`,否则请求会被网关 401 拒绝。
@@ -36,6 +48,7 @@ cp session-usage-reporter.ts .opencode/plugins/
36
48
  ## 行为
37
49
 
38
50
  - **触发时机**:bus 事件 `session.idle`,或 `session.status` 且 `status.type === "idle"`(两种空闲信号都处理)。
51
+ - **初始化可观测**:插件加载即解析端点并通过 `app.log` 记录 info 日志(端点 + 来源);上报失败仅在**每轮连续失败的首 次**记录日志(成功后重置),服务端未运行时不会刷屏。
39
52
  - **采集**:经 SDK 读取 `session.messages`(仅统计 `role === "assistant"` 且 `info.time.completed` 已设置的**已完成**消息,天然去重不双计),再经 `session.children` 递归子会话。
40
53
  - **父子独立**:每个会话(含子代理会话)各自生成一条快照;**父会话快照绝不并入子会话用量**。
41
54
  - **口径原样分列**:`tokens_input / tokens_output / tokens_reasoning / tokens_cache_read / tokens_cache_write` 直接取自 `info.tokens`,**客户端不做任何相加或换算**(opencode 语义:`output` 不含 `reasoning`,`input` 不含 `cache.read`;`cost` 恒为 0,不采信、不上报)。合计由服务端派生。
@@ -52,7 +65,7 @@ cp session-usage-reporter.ts .opencode/plugins/
52
65
  {
53
66
  "schema_version": 1,
54
67
  "install_id": "0b3f7e6a-....",
55
- "reporter": { "client": "opencode", "version": "0.1.0" },
68
+ "reporter": { "client": "opencode", "version": "0.1.3" },
56
69
  "sessions": [
57
70
  {
58
71
  "session_id": "ses_...",
@@ -66,13 +79,15 @@ cp session-usage-reporter.ts .opencode/plugins/
66
79
  "tokens_cache_write": 1320,
67
80
  "message_count": 12,
68
81
  "session_created_at": 1759000000000,
82
+ "session_title": "每30s自动刷新功能",
83
+ "session_directory": "/opt/projects/BlueRegionUsage",
69
84
  "last_activity_at": 1759003600123
70
85
  }
71
86
  ]
72
87
  }
73
88
  ```
74
89
 
75
- `model` 取该会话最后一条已完成助手消息的 `providerID/modelID`;`agent` 取其 `mode`(无则为 `null`)。载荷中**不存在** consumer/identity 类字段。
90
+ `model` 取该会话最后一条已完成助手消息的 `providerID/modelID`;`agent` 取其 `mode`(无则为 `null`)。`session_title` / `session_directory` 取自**同一次** `session.get` 调用(不新增 API 请求),取不到时为 `null`(不是空字符串)。载荷中**不存在** consumer/identity 类字段。
76
91
 
77
92
  ## 测试
78
93
 
@@ -80,13 +95,13 @@ cp session-usage-reporter.ts .opencode/plugins/
80
95
  bun test reporter.test.ts # 或 bun run reporter.test.ts
81
96
  ```
82
97
 
83
- 自包含 mock 测试(mock client + mock fetch),无网络、无需真实 opencode:覆盖已完成消息过滤、父子独立计量、载荷结构与无身份字段、队列上限丢最旧、失败批次保留 + 退避调度、成功清空队列、dispose 成功/失败路径、退避曲线、install_id 稳定性。
98
+ 自包含 mock 测试(mock client + mock fetch),无网络、无需真实 opencode:覆盖已完成消息过滤、父子独立计量、载荷结构与无身份字段、队列上限丢最旧、失败批次保留 + 退避调度、成功清空队列、dispose 成功/失败路径、退避曲线、install_id 稳定性、**端点解析优先级(env / config / default 全分支)与未设环境变量时插件仍激活**。
84
99
 
85
100
  ## 局限
86
101
 
87
102
  - **队列在内存**:进程崩溃(非正常 dispose 退出)会丢失未上报的积压;队列满后丢最旧。
88
103
  - **会话级快照**:无 per-message / 按模型 / 按时间细分;一个会话只记录最后一条消息的 model/agent,多模型会话归属到最后的模型。
89
104
  - **消息被压缩/删除**时 `message_count` 可能回退,revision 随之回退——依赖服务端单调守卫保留较高修订。
90
- - **不携带身份与鉴权**:请求无凭据,完全依赖网关注入消费者身份(与设计 D4/D9 一致);端点本身须由网关/服务端做租户隔离与限流。
105
+ - **不携带身份与鉴权**:载荷无凭据;`SESSION_USAGE_API_KEY` 未配置时请求不带鉴权头,完全依赖网关注入消费者身份(与设计 D4/D9 一致);端点本身须由网关/服务端做租户隔离与限流。默认端点指向本机 `127.0.0.1`,服务未启动时数据仅进本地队列(有界、退避重试)。
91
106
  - **与 opencode 版本耦合**:消息/事件/插件 loader 形状按 1.18.33 核实,字段均为防御式读取;升级 opencode 后建议重跑测试。
92
107
  - 每台机器一个 `install_id`;同一消费者跨机器的会话默认按 `install_id` 分列。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-session-reporter",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "opencode 客户端插件:在会话空闲时聚合各会话(含子代理会话)的 token 用量快照,经有界队列 + 指数退避非阻塞上报;载荷不含消费者身份字段。",
@@ -20,6 +20,8 @@ import {
20
20
  createQueue,
21
21
  createReporterCore,
22
22
  loadOrCreateInstallId,
23
+ resolveReportEndpoint,
24
+ SessionUsageReporter,
23
25
  } from "./session-usage-reporter.ts"
24
26
  import type {
25
27
  ReporterClient,
@@ -75,12 +77,25 @@ const assistantMessage = (over: Partial<NonNullable<UsageMessage["info"]>> = {})
75
77
  const makeClient = (
76
78
  messages: Record<string, UsageMessage[]>,
77
79
  children: Record<string, string[]> = {},
80
+ /** session.get 的返回元数据;不传则 client 不含 session.get */
81
+ meta?: { created?: number | null; title?: string | null; directory?: string | null; throws?: boolean },
78
82
  ): ReporterClient => ({
79
83
  session: {
80
84
  messages: async (args: { path: { id: string } }) => ({ data: messages[args.path.id] ?? [] }),
81
85
  children: async (args: { path: { id: string } }) => ({
82
86
  data: (children[args.path.id] ?? []).map((id) => ({ id })),
83
87
  }),
88
+ ...(meta
89
+ ? {
90
+ get: meta.throws
91
+ ? async () => {
92
+ throw new Error("boom: session.get failed")
93
+ }
94
+ : async () => ({
95
+ data: { time: { created: meta.created ?? 1_700_000_000_000 }, title: meta.title ?? null, directory: meta.directory ?? null },
96
+ }),
97
+ }
98
+ : {}),
84
99
  },
85
100
  })
86
101
 
@@ -120,6 +135,8 @@ const snap = (sessionID: string, over: Partial<SessionSnapshot> = {}): SessionSn
120
135
  tokens_cache_write: 0,
121
136
  message_count: 1,
122
137
  session_created_at: null,
138
+ session_title: null,
139
+ session_directory: null,
123
140
  last_activity_at: 1_000 + snapSeq,
124
141
  ...over,
125
142
  }
@@ -134,7 +151,9 @@ const SESSION_KEYS = [
134
151
  "model",
135
152
  "revision",
136
153
  "session_created_at",
154
+ "session_directory",
137
155
  "session_id",
156
+ "session_title",
138
157
  "tokens_cache_read",
139
158
  "tokens_cache_write",
140
159
  "tokens_input",
@@ -364,6 +383,177 @@ await runTest("loadOrCreateInstallId 首次生成、之后稳定复用同一 UUI
364
383
  }
365
384
  })
366
385
 
386
+ await runTest("resolveReportEndpoint: env 存在时优先于 config 与默认值", () => {
387
+ const r = resolveReportEndpoint({
388
+ env: { SESSION_USAGE_ENDPOINT: "http://env.example:1/report" },
389
+ configFile: "/nonexistent/config.json",
390
+ readFileSyncFn: () => JSON.stringify({ reportEndpoint: "http://cfg.example:2/report" }),
391
+ })
392
+ assertEqual(r.endpoint, "http://env.example:1/report", "endpoint")
393
+ assertEqual(r.source, "env", "source")
394
+ })
395
+
396
+ await runTest("resolveReportEndpoint: env 为空/空白时回退 config(并 trim)", () => {
397
+ const empty = resolveReportEndpoint({
398
+ env: {},
399
+ configFile: "/nonexistent/config.json",
400
+ readFileSyncFn: () => JSON.stringify({ reportEndpoint: "http://cfg.example:2/report" }),
401
+ })
402
+ assertEqual(empty.endpoint, "http://cfg.example:2/report", "空 env → config")
403
+ assertEqual(empty.source, "config", "source")
404
+
405
+ const blank = resolveReportEndpoint({
406
+ env: { SESSION_USAGE_ENDPOINT: " " },
407
+ configFile: "/nonexistent/config.json",
408
+ readFileSyncFn: () => JSON.stringify({ reportEndpoint: " http://cfg.example:2/report " }),
409
+ })
410
+ assertEqual(blank.endpoint, "http://cfg.example:2/report", "空白 env → config(值已 trim)")
411
+ assertEqual(blank.source, "config", "source")
412
+ })
413
+
414
+ await runTest("resolveReportEndpoint: config 缺失/JSON 非法/reportEndpoint 非法 → 回退默认值", () => {
415
+ const env = {}
416
+ const missing = resolveReportEndpoint({
417
+ env,
418
+ configFile: "/nonexistent/config.json",
419
+ readFileSyncFn: () => {
420
+ throw new Error("ENOENT")
421
+ },
422
+ })
423
+ assertEqual(missing.endpoint, "http://127.0.0.1:8787/v1/session-usage/report", "config 缺失 → 默认")
424
+ assertEqual(missing.source, "default", "source")
425
+
426
+ const badJson = resolveReportEndpoint({ env, configFile: "/x", readFileSyncFn: () => "{not json" })
427
+ assertEqual(badJson.source, "default", "JSON 非法 → 默认")
428
+
429
+ const badUrl = resolveReportEndpoint({
430
+ env,
431
+ configFile: "/x",
432
+ readFileSyncFn: () => JSON.stringify({ reportEndpoint: "not-a-url" }),
433
+ })
434
+ assertEqual(badUrl.source, "default", "非法 URL → 默认")
435
+
436
+ const wrongScheme = resolveReportEndpoint({
437
+ env,
438
+ configFile: "/x",
439
+ readFileSyncFn: () => JSON.stringify({ reportEndpoint: "ftp://example.com/report" }),
440
+ })
441
+ assertEqual(wrongScheme.endpoint, "http://127.0.0.1:8787/v1/session-usage/report", "非 http(s) → 默认")
442
+
443
+ const wrongType = resolveReportEndpoint({
444
+ env,
445
+ configFile: "/x",
446
+ readFileSyncFn: () => JSON.stringify({ reportEndpoint: 123 }),
447
+ })
448
+ assertEqual(wrongType.source, "default", "非字符串字段 → 默认")
449
+ })
450
+
451
+ await runTest("resolveReportEndpoint: 三者皆缺 → 内置默认值", () => {
452
+ const r = resolveReportEndpoint({
453
+ env: {},
454
+ configFile: "/nonexistent/config.json",
455
+ readFileSyncFn: () => {
456
+ throw new Error("ENOENT")
457
+ },
458
+ })
459
+ assertEqual(r.endpoint, "http://127.0.0.1:8787/v1/session-usage/report", "默认端点")
460
+ assertEqual(r.source, "default", "来源")
461
+ })
462
+
463
+ await runTest("插件未设 SESSION_USAGE_ENDPOINT 时依然激活(返回 event/dispose 并输出端点日志)", async () => {
464
+ const savedEndpoint = process.env.SESSION_USAGE_ENDPOINT
465
+ const savedConfigDir = process.env.MONITOR_CONFIG_DIR
466
+ delete process.env.SESSION_USAGE_ENDPOINT
467
+ // 指向不存在的配置目录 → config 分支静默回退默认值,测试不依赖真实 HOME
468
+ process.env.MONITOR_CONFIG_DIR = join(tmpdir(), `sur-resolve-${randomUUID()}`)
469
+ const logs: Array<{ body?: { level?: string; service?: string; message?: string } }> = []
470
+ try {
471
+ const fakeCtx = {
472
+ client: {
473
+ app: {
474
+ log: (args: { body?: { level?: string; service?: string; message?: string } }) => {
475
+ logs.push(args)
476
+ return Promise.resolve()
477
+ },
478
+ },
479
+ session: makeClient({}).session,
480
+ },
481
+ directory: "/tmp/fake-project",
482
+ } as unknown as Parameters<typeof SessionUsageReporter>[0]
483
+ const hooks = (await SessionUsageReporter(fakeCtx)) as Record<string, unknown>
484
+ assert(typeof hooks.event === "function", "不再惰性:应返回 event hook")
485
+ assert(typeof hooks.dispose === "function", "不再惰性:应返回 dispose hook")
486
+ const initLog = logs.map((l) => l.body?.message ?? "").find((m) => m.includes("端点"))
487
+ assert(
488
+ !!initLog &&
489
+ initLog.includes("http://127.0.0.1:8787/v1/session-usage/report") &&
490
+ initLog.includes("default"),
491
+ `初始化日志应包含端点与来源,实际: ${initLog}`,
492
+ )
493
+ } finally {
494
+ if (savedEndpoint === undefined) delete process.env.SESSION_USAGE_ENDPOINT
495
+ else process.env.SESSION_USAGE_ENDPOINT = savedEndpoint
496
+ if (savedConfigDir === undefined) delete process.env.MONITOR_CONFIG_DIR
497
+ else process.env.MONITOR_CONFIG_DIR = savedConfigDir
498
+ }
499
+ })
500
+
501
+ await runTest("session.get 返回 title/directory → 快照与载荷带上,且复用同一次调用", async () => {
502
+ const client = makeClient(
503
+ { ses_meta: [assistantMessage()] },
504
+ {},
505
+ { created: 1_759_000_000_000, title: "每30s自动刷新功能", directory: "/opt/projects/BlueRegionUsage" },
506
+ )
507
+ let getCalls = 0
508
+ const origGet = client.session.get!
509
+ client.session.get = (async (args: { path: { id: string } }) => {
510
+ getCalls += 1
511
+ return origGet(args)
512
+ }) as typeof client.session.get
513
+
514
+ const { fetchFn, calls } = makeFetch("ok")
515
+ const { core } = makeCore(client, { endpoint: "http://e", fetchFn })
516
+ await core.collectSession("ses_meta")
517
+
518
+ const snapshot = core.queue.drainAll().find((s) => s.session_id === "ses_meta")
519
+ assert(!!snapshot, "应产出快照")
520
+ assertEqual(snapshot!.session_title, "每30s自动刷新功能", "快照 title")
521
+ assertEqual(snapshot!.session_directory, "/opt/projects/BlueRegionUsage", "快照 directory")
522
+ assertEqual(snapshot!.session_created_at, 1_759_000_000_000, "快照 created")
523
+ assertEqual(getCalls, 1, "单会话恰好一次 session.get(不新增 API 请求)")
524
+
525
+ // 载荷层面
526
+ const q = createQueue(50)
527
+ q.push(snapshot!)
528
+ const core2 = makeCore(makeClient({}), { endpoint: "http://e", queue: q, fetchFn }).core
529
+ await core2.flushOnce()
530
+ const body = JSON.parse(calls[0]!.init.body ?? "{}")
531
+ assertEqual(body.sessions[0].session_title, "每30s自动刷新功能", "载荷 title")
532
+ assertEqual(body.sessions[0].session_directory, "/opt/projects/BlueRegionUsage", "载荷 directory")
533
+ })
534
+
535
+ await runTest("session.get 抛错 → title/directory/created 为 null 而非崩溃", async () => {
536
+ const client = makeClient({ ses_err: [assistantMessage()] }, {}, { throws: true })
537
+ const { core } = makeCore(client, { endpoint: "http://e" })
538
+ await core.collectSession("ses_err")
539
+ const snapshot = core.queue.drainAll().find((s) => s.session_id === "ses_err")
540
+ assert(!!snapshot, "采集主流程不受影响,快照仍产生")
541
+ assertEqual(snapshot!.session_title, null, "title → null")
542
+ assertEqual(snapshot!.session_directory, null, "directory → null")
543
+ assertEqual(snapshot!.session_created_at, null, "created → null")
544
+ })
545
+
546
+ await runTest("session.get 不存在 → 三个元数据字段均为 null", async () => {
547
+ const client = makeClient({ ses_noget: [assistantMessage()] }) // 无 session.get
548
+ const { core } = makeCore(client, { endpoint: "http://e" })
549
+ await core.collectSession("ses_noget")
550
+ const snapshot = core.queue.drainAll().find((s) => s.session_id === "ses_noget")
551
+ assert(!!snapshot, "应产出快照")
552
+ assertEqual(snapshot!.session_title, null, "title → null")
553
+ assertEqual(snapshot!.session_directory, null, "directory → null")
554
+ assertEqual(snapshot!.session_created_at, null, "created → null")
555
+ })
556
+
367
557
  // ---------------------------------------------------------------------------
368
558
  // 汇总
369
559
  // ---------------------------------------------------------------------------
@@ -7,7 +7,8 @@
7
7
  * - 父会话快照绝不并入子会话用量;每个会话(含子代理会话)都是独立记录。
8
8
  * - token 口径原样分列上报(input / output / reasoning / cache.read / cache.write),
9
9
  * 不在客户端做任何相加或换算(`output` 不含 reasoning,`input` 不含 cache.read)。
10
- * - hook 只触发后台采集并入队,绝不等待网络;由后台 flush 统一 POST 到 SESSION_USAGE_ENDPOINT。
10
+ * - hook 只触发后台采集并入队,绝不等待网络;由后台 flush 统一 POST 到解析出的上报端点
11
+ * (零配置解析:环境变量 SESSION_USAGE_ENDPOINT > <configDir>/config.json 的 reportEndpoint > 内置默认值)。
11
12
  * - 有界内存队列(默认 500 条快照,溢出丢最旧)+ 指数退避(带抖动)+ AbortSignal.timeout 超时。
12
13
  * - `dispose` 钩子在进程退出时以约 3 秒预算尽力刷新队列,超时即放弃,绝不阻塞退出。
13
14
  * - 载荷不含任何消费者身份字段;身份完全由网关(key-auth 注入 X-Forward-Consumer)决定。
@@ -26,11 +27,13 @@
26
27
  * 绝不能抛错、返回 null 或 undefined(否则 `V.event?.` 会读到空值导致崩溃)。
27
28
  *
28
29
  * 安装:复制本文件到 ~/.config/opencode/plugins/(全局)或 <项目>/.opencode/plugins/(项目级)。
29
- * 启用:设置环境变量 SESSION_USAGE_ENDPOINT(未设置时插件保持惰性)。
30
+ * 启用:零配置即生效(插件始终激活),初始化时会通过 app.log 输出最终端点与来源;
31
+ * 如需覆盖端点,可设置环境变量 SESSION_USAGE_ENDPOINT 或用配套 CLI 写 config.json 的 reportEndpoint。
30
32
  */
31
33
 
32
34
  import type { Plugin } from "@opencode-ai/plugin"
33
35
  import { randomUUID } from "node:crypto"
36
+ import { readFileSync } from "node:fs"
34
37
  import { mkdir, readFile, writeFile } from "node:fs/promises"
35
38
  import { homedir } from "node:os"
36
39
  import { dirname, join } from "node:path"
@@ -40,7 +43,7 @@ import { dirname, join } from "node:path"
40
43
  // ---------------------------------------------------------------------------
41
44
 
42
45
  const SCHEMA_VERSION = 1
43
- const PLUGIN_VERSION = "0.1.0"
46
+ const PLUGIN_VERSION = "0.1.3"
44
47
  /** 队列容量:最多积压 500 条会话快照,溢出丢最旧 */
45
48
  const QUEUE_CAP = 500
46
49
  /** 单次上报请求超时(AbortSignal.timeout) */
@@ -59,6 +62,20 @@ const INSTALL_ID_STATE_FILE = () => join(homedir(), ".local", "state", "opencode
59
62
 
60
63
  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
64
 
65
+ /** 零配置默认上报端点(与本地 `web start` 的默认端口一致) */
66
+ const DEFAULT_REPORT_ENDPOINT = "http://127.0.0.1:8787/v1/session-usage/report"
67
+
68
+ /**
69
+ * 本工具配置目录:必须与 src/paths.js 的 configDir() 完全一致。
70
+ * 本文件保持独立可复制,故此处自带一份等价实现(不 import src/)。
71
+ */
72
+ const configFilePath = (): string =>
73
+ join(
74
+ process.env.MONITOR_CONFIG_DIR ||
75
+ join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode-stats-reporter"),
76
+ "config.json",
77
+ )
78
+
62
79
  // ---------------------------------------------------------------------------
63
80
  // 类型(type-only 导出在运行时会被擦除,不影响 loader 的「导出必须全是函数」校验)
64
81
  // ---------------------------------------------------------------------------
@@ -94,6 +111,8 @@ export type SessionSnapshot = {
94
111
  tokens_cache_write: number
95
112
  message_count: number
96
113
  session_created_at: number | null
114
+ session_title: string | null
115
+ session_directory: string | null
97
116
  last_activity_at: number | null
98
117
  }
99
118
 
@@ -113,6 +132,13 @@ export type ReportPayload = {
113
132
  sessions: SessionSnapshot[]
114
133
  }
115
134
 
135
+ export type ReportEndpointSource = "env" | "config" | "default"
136
+
137
+ export type ResolvedReportEndpoint = {
138
+ endpoint: string
139
+ source: ReportEndpointSource
140
+ }
141
+
116
142
  /** 本插件实际用到的 opencode SDK client 最小形状(便于 mock 测试) */
117
143
  export type ReporterClient = {
118
144
  session: {
@@ -232,6 +258,8 @@ const aggregateSessionImpl = (sessionID: string, messages: readonly UsageMessage
232
258
  tokens_cache_write: cacheWrite,
233
259
  message_count: count,
234
260
  session_created_at: null, // 由采集层补充
261
+ session_title: null, // 由采集层补充(session.get)
262
+ session_directory: null, // 由采集层补充(session.get)
235
263
  last_activity_at: lastActivity,
236
264
  }
237
265
  }
@@ -340,6 +368,51 @@ const loadOrCreateInstallIdImpl = async (
340
368
  return id
341
369
  }
342
370
 
371
+ // ---------------------------------------------------------------------------
372
+ // 零配置端点解析:env > config.json(reportEndpoint) > 内置默认值;绝不抛错
373
+ // ---------------------------------------------------------------------------
374
+
375
+ const isValidHttpUrl = (value: string): boolean => {
376
+ try {
377
+ const parsed = new URL(value)
378
+ return parsed.protocol === "http:" || parsed.protocol === "https:"
379
+ } catch {
380
+ return false
381
+ }
382
+ }
383
+
384
+ /**
385
+ * 解析上报端点(优先级从高到低):
386
+ * 1. 环境变量 SESSION_USAGE_ENDPOINT(trim 后非空即用,显式覆盖)
387
+ * 2. 本工具配置文件 <configDir>/config.json 的 reportEndpoint 字段(须为合法 http(s) URL)
388
+ * 3. 内置默认值 http://127.0.0.1:8787/v1/session-usage/report
389
+ * config.json 不存在 / JSON 非法 / 字段缺失 / URL 非法 → 一律静默回退到下一优先级。
390
+ * 依赖全部可注入(env / configFile / readFileSyncFn),便于单测且不碰真实环境。
391
+ */
392
+ const resolveReportEndpointImpl = (
393
+ options: {
394
+ env?: Record<string, string | undefined>
395
+ configFile?: string
396
+ readFileSyncFn?: (file: string, encoding: "utf8") => string
397
+ } = {},
398
+ ): ResolvedReportEndpoint => {
399
+ const env = options.env ?? process.env
400
+ const fromEnv = (env.SESSION_USAGE_ENDPOINT ?? "").trim()
401
+ if (fromEnv) return { endpoint: fromEnv, source: "env" }
402
+
403
+ const configFile = options.configFile ?? configFilePath()
404
+ const readFileSyncFn = options.readFileSyncFn ?? ((file: string) => readFileSync(file, "utf8"))
405
+ try {
406
+ const parsed = JSON.parse(readFileSyncFn(configFile, "utf8")) as { reportEndpoint?: unknown } | null
407
+ const candidate = typeof parsed?.reportEndpoint === "string" ? parsed.reportEndpoint.trim() : ""
408
+ if (candidate && isValidHttpUrl(candidate)) return { endpoint: candidate, source: "config" }
409
+ } catch {
410
+ // 文件不存在 / 不可读 / JSON 非法:静默回退到默认值
411
+ }
412
+
413
+ return { endpoint: DEFAULT_REPORT_ENDPOINT, source: "default" }
414
+ }
415
+
343
416
  // ---------------------------------------------------------------------------
344
417
  // 核心:采集 + 非阻塞队列 + 退避重试 + dispose 限时刷新
345
418
  // ---------------------------------------------------------------------------
@@ -365,6 +438,7 @@ const createReporterCoreImpl = (options: ReporterCoreOptions): ReporterCore => {
365
438
  let flushCancel: (() => void) | null = null
366
439
  let retryCancel: (() => void) | null = null
367
440
  let retryAttempt = 0
441
+ let failureLogged = false
368
442
  const inFlight = new Set<string>()
369
443
 
370
444
  const send = async (snapshots: readonly SessionSnapshot[], timeoutMs: number): Promise<void> => {
@@ -392,13 +466,18 @@ const createReporterCoreImpl = (options: ReporterCoreOptions): ReporterCore => {
392
466
  try {
393
467
  await send(batch, requestTimeoutMs)
394
468
  retryAttempt = 0
469
+ failureLogged = false // 成功即重置:下一轮失败重新记录首次
395
470
  return true
396
471
  } catch (error) {
397
472
  queue.restore(batch)
398
473
  retryAttempt += 1
399
474
  // 第 1 次失败 → 基础 1s,此后指数翻倍(2s、4s…),封顶 5 分钟,均带抖动
400
475
  const delay = computeBackoffMsImpl(Math.max(0, retryAttempt - 1), random())
401
- log(`session-usage: 上报失败,${delay}ms 后重试(队列积压 ${queue.size})`, error)
476
+ // 降噪:连续失败只记录首轮,避免服务端未运行时刷屏
477
+ if (!failureLogged) {
478
+ failureLogged = true
479
+ log(`session-usage: 上报失败,${delay}ms 后重试(队列积压 ${queue.size})`, error)
480
+ }
402
481
  if (!retryCancel) {
403
482
  retryCancel = scheduler(() => {
404
483
  retryCancel = null
@@ -438,16 +517,29 @@ const createReporterCoreImpl = (options: ReporterCoreOptions): ReporterCore => {
438
517
  return ids
439
518
  }
440
519
 
441
- const readSessionCreatedAt = async (sessionID: string): Promise<number | null> => {
520
+ const readSessionMeta = async (
521
+ sessionID: string,
522
+ ): Promise<{ created: number | null; title: string | null; directory: string | null }> => {
442
523
  try {
443
524
  const getSession = options.client.session.get
444
- if (typeof getSession !== "function") return null
525
+ if (typeof getSession !== "function") return { created: null, title: null, directory: null }
445
526
  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
527
+ const data = (
528
+ response as
529
+ | { data?: { time?: { created?: unknown }; title?: unknown; directory?: unknown } }
530
+ | undefined
531
+ )?.data
532
+ const created = data?.time?.created
533
+ const title = data?.title
534
+ const directory = data?.directory
535
+ return {
536
+ created: typeof created === "number" ? created : null,
537
+ title: typeof title === "string" && title ? title : null,
538
+ directory: typeof directory === "string" && directory ? directory : null,
539
+ }
449
540
  } catch {
450
- return null
541
+ // session.get 抛错 / 形状异常:元数据缺省为 null,绝不影响采集主流程
542
+ return { created: null, title: null, directory: null }
451
543
  }
452
544
  }
453
545
 
@@ -462,7 +554,11 @@ const createReporterCoreImpl = (options: ReporterCoreOptions): ReporterCore => {
462
554
  visited.add(sessionID)
463
555
  const snapshot = aggregateSessionImpl(sessionID, await readMessages(sessionID))
464
556
  if (snapshot) {
465
- snapshot.session_created_at = await readSessionCreatedAt(sessionID)
557
+ // 复用同一次 session.get:created / title / directory 一并取得,不新增 API 请求
558
+ const meta = await readSessionMeta(sessionID)
559
+ snapshot.session_created_at = meta.created
560
+ snapshot.session_title = meta.title
561
+ snapshot.session_directory = meta.directory
466
562
  out.push(snapshot)
467
563
  }
468
564
  for (const childID of await readChildIDs(sessionID)) {
@@ -544,17 +640,15 @@ export const buildPayload = pluginSafe(buildPayloadImpl)
544
640
  export const createQueue = pluginSafe(createQueueImpl)
545
641
  export const computeBackoffMs = pluginSafe(computeBackoffMsImpl)
546
642
  export const loadOrCreateInstallId = pluginSafe(loadOrCreateInstallIdImpl)
643
+ export const resolveReportEndpoint = pluginSafe(resolveReportEndpointImpl)
547
644
  export const createReporterCore = pluginSafe(createReporterCoreImpl)
548
645
 
549
646
  /**
550
- * 插件入口。
551
- * 未设置 SESSION_USAGE_ENDPOINT 时返回空 hooks,完全不影响会话。
647
+ * 插件入口:零配置,始终激活。
648
+ * 端点按 env > config.json > 默认值 解析;初始化成功后通过 app.log 输出端点与来源,便于排查。
552
649
  */
553
650
  export const SessionUsageReporter: Plugin = async (ctx) => {
554
651
  try {
555
- const endpoint = (process.env.SESSION_USAGE_ENDPOINT ?? "").trim() || undefined
556
- if (!endpoint) return {}
557
-
558
652
  const log = (message: string, error?: unknown): void => {
559
653
  try {
560
654
  const detail = error instanceof Error ? `${error.name}: ${error.message}` : error === undefined ? "" : String(error)
@@ -573,17 +667,21 @@ export const SessionUsageReporter: Plugin = async (ctx) => {
573
667
  }
574
668
  }
575
669
 
670
+ const resolved = resolveReportEndpointImpl()
576
671
  const installId = await loadOrCreateInstallIdImpl()
577
672
  const core = createReporterCoreImpl({
578
673
  client: ctx.client as unknown as ReporterClient,
579
674
  directory: ctx.directory,
580
- endpoint,
675
+ endpoint: resolved.endpoint,
581
676
  installId,
582
677
  // 上报请求的网关 Key(APIG key-auth 后注入 X-Forward-Consumer);未配置则不带鉴权头
583
678
  authToken: (process.env.SESSION_USAGE_API_KEY ?? "").trim() || undefined,
584
679
  log,
585
680
  })
586
681
 
682
+ // 可观测性:明确告知最终端点与来源(排查「装了插件却没上报」的关键线索)
683
+ log(`session-usage: 已启用会话用量上报,端点 ${resolved.endpoint}(来源: ${resolved.source})`)
684
+
587
685
  return {
588
686
  event: async (input: { event?: { type?: unknown; properties?: unknown } }) => {
589
687
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dijkspicy/opencode-stats-reporter",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "本地一体化 MaaS 用量看板:托管静态面板、反代用量接口、本地存储会话用量",
5
5
  "type": "module",
6
6
  "main": "./contrib/opencode-session-reporter/plugin-server.ts",
@@ -527,22 +527,63 @@ body {
527
527
  #session-table tbody tr:hover { background: rgba(127,127,127,.06); }
528
528
  #session-table tbody tr:last-child td { border-bottom: none; }
529
529
 
530
- /* 会话 ID:等宽截断,完整值在 title,点击进详情 */
531
- .sess-id-btn {
530
+ /* 会话首列:两行结构(主行标题 / 次行目录+ID),次要信息弱化不喧宾夺主 */
531
+ .sess-title-line { min-width: 0; }
532
+ .sess-title {
532
533
  display: inline-block;
534
+ max-width: 340px;
535
+ overflow: hidden;
536
+ text-overflow: ellipsis;
537
+ white-space: nowrap;
538
+ vertical-align: bottom;
539
+ font-weight: 600;
540
+ cursor: pointer;
541
+ }
542
+ .sess-title:hover { color: var(--accent-hover); text-decoration: underline; }
543
+ .sess-sub-line {
544
+ display: flex;
545
+ align-items: center;
546
+ gap: 6px;
547
+ margin-top: 2px;
548
+ font-size: 11px;
549
+ color: var(--text-secondary);
550
+ }
551
+ .sess-sub-sep { flex: none; }
552
+ .sess-dir {
533
553
  max-width: 200px;
534
554
  overflow: hidden;
535
555
  text-overflow: ellipsis;
536
556
  white-space: nowrap;
557
+ font-size: 11px;
558
+ cursor: default;
559
+ }
560
+ @media (max-width: 767px) {
561
+ .sess-title { max-width: 180px; }
562
+ .sess-dir { max-width: 120px; }
563
+ }
564
+
565
+ /* 会话 ID:默认弱化(有标题时),等宽头尾截断,完整值在 title,点击进详情 */
566
+ .sess-id-btn {
567
+ display: inline-block;
568
+ max-width: 130px;
569
+ overflow: hidden;
570
+ text-overflow: ellipsis;
571
+ white-space: nowrap;
537
572
  vertical-align: bottom;
538
573
  padding: 0;
539
574
  border: none;
540
575
  background: transparent;
541
- color: var(--accent);
576
+ color: var(--text-secondary);
542
577
  font-family: Consolas, monospace;
543
- font-size: 12px;
578
+ font-size: 11px;
544
579
  cursor: pointer;
545
580
  }
581
+ /* 无标题时的降级主标识:恢复高亮,保证一眼可点 */
582
+ .sess-id-btn.is-primary {
583
+ max-width: 200px;
584
+ color: var(--accent);
585
+ font-size: 12px;
586
+ }
546
587
  .sess-id-btn:hover { color: var(--accent-hover); text-decoration: underline; }
547
588
  .sess-id-btn:focus-visible { outline: 1px solid var(--accent); }
548
589
 
package/panel/index.html CHANGED
@@ -296,7 +296,7 @@
296
296
  <table id="session-table" class="session-table">
297
297
  <thead>
298
298
  <tr>
299
- <th>会话</th>
299
+ <th title="优先显示会话标题,无标题时显示会话 ID;下方小字为会话所属项目目录,悬浮可看完整路径">会话</th>
300
300
  <th>模型</th>
301
301
  <th>代理</th>
302
302
  <th class="num" title="输入总量 = 输入 + 缓存读 + 缓存写">输入总量</th>
@@ -106,6 +106,15 @@
106
106
  ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
107
107
  }
108
108
 
109
+ /** 毫秒时间戳 -> 'MM-DD HH:MM'(当年,会话列表"最后活动"列);跨年回退完整格式,完整值另放 title */
110
+ function shortDateTime(ms) {
111
+ if (ms === null || ms === undefined || !isFinite(Number(ms))) return '--';
112
+ var d = new Date(Number(ms));
113
+ if (d.getFullYear() !== new Date().getFullYear()) return dateTime(ms);
114
+ function p(x) { return (x < 10 ? '0' : '') + x; }
115
+ return p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
116
+ }
117
+
109
118
  /**
110
119
  * 环比变化(比值):当前值与上一周期值比较,返回 { text, dir }
111
120
  * dir: 1 升 / -1 降 / 0 持平;基准为 0 或空值时返回 null(不展示)
@@ -149,6 +158,7 @@
149
158
  todayStr: todayStr,
150
159
  hourLabel: hourLabel,
151
160
  dateTime: dateTime,
161
+ shortDateTime: shortDateTime,
152
162
  pctChange: pctChange,
153
163
  ppChange: ppChange,
154
164
  maskKey: maskKey
@@ -196,18 +196,84 @@
196
196
  st.rows.forEach(function (r) { tbody.appendChild(sessionRow(r)); });
197
197
  }
198
198
 
199
+ /** 空值守卫:非字符串或空白串返回 null,避免把 null/undefined/空串渲染到页面 */
200
+ function nonEmpty(v) {
201
+ return (typeof v === 'string' && v.trim()) ? v.trim() : null;
202
+ }
203
+
204
+ /** 会话 ID 头尾截断:ses_k2X9fjQ3mT…89xQ,头尾是最有辨识度的部分;完整值在 title 与详情 */
205
+ function shortId(id) {
206
+ return id.length > 20 ? id.slice(0, 14) + '…' + id.slice(-4) : id;
207
+ }
208
+
209
+ /** 目录压缩:取末两级,如 /opt/projects/BlueRegionUsage -> …/projects/BlueRegionUsage;完整路径放 title */
210
+ function shortDir(dir) {
211
+ var parts = String(dir).replace(/\\/g, '/').split('/').filter(function (s) { return s !== ''; });
212
+ if (!parts.length) return dir;
213
+ if (parts.length <= 2) return '/' + parts.join('/');
214
+ return '…/' + parts.slice(-2).join('/');
215
+ }
216
+
217
+ /** 会话 ID 按钮:等宽展示(头尾截断),完整值放 title,点击进详情;primary = 无标题时作为降级主标识 */
218
+ function idButton(sid, primary) {
219
+ var b = document.createElement('button');
220
+ b.type = 'button';
221
+ b.className = 'sess-id-btn mono' + (primary ? ' is-primary' : '');
222
+ b.textContent = shortId(sid);
223
+ b.title = sid + '\n点击查看详情';
224
+ b.addEventListener('click', function () { openDetail(sid); });
225
+ return b;
226
+ }
227
+
228
+ /**
229
+ * 首列「会话」:两行结构
230
+ * 主行:会话标题(加粗);无标题时降级为会话 ID(等宽、高亮、可点)
231
+ * 次行:项目目录(末两级压缩,悬浮显全路径)· 会话 ID(弱化;ID 已作主标识时不重复)
232
+ */
199
233
  function sessionRow(r) {
200
234
  var tr = document.createElement('tr');
201
- // 会话 ID:等宽截断展示,完整值放 title,点击查看详情
202
- var sidTd = document.createElement('td');
203
- var sidBtn = document.createElement('button');
204
- sidBtn.type = 'button';
205
- sidBtn.className = 'sess-id-btn mono';
206
- sidBtn.textContent = r.session_id || '--';
207
- sidBtn.title = (r.session_id || '') + '\n点击查看详情';
208
- sidBtn.addEventListener('click', function () { openDetail(r.session_id); });
209
- sidTd.appendChild(sidBtn);
210
- tr.appendChild(sidTd);
235
+ var title = nonEmpty(r.session_title);
236
+ var dir = nonEmpty(r.session_directory);
237
+ var sid = nonEmpty(r.session_id) || '--';
238
+
239
+ var cell = document.createElement('td');
240
+ cell.className = 'sess-cell';
241
+
242
+ var line1 = document.createElement('div');
243
+ line1.className = 'sess-title-line';
244
+ if (title) {
245
+ var t = document.createElement('span');
246
+ t.className = 'sess-title';
247
+ t.textContent = title;
248
+ t.title = title + '\n点击查看详情';
249
+ t.addEventListener('click', function () { openDetail(sid); });
250
+ line1.appendChild(t);
251
+ } else {
252
+ line1.appendChild(idButton(sid, true));
253
+ }
254
+ cell.appendChild(line1);
255
+
256
+ var line2 = document.createElement('div');
257
+ line2.className = 'sess-sub-line';
258
+ if (dir) {
259
+ var d = document.createElement('span');
260
+ d.className = 'sess-dir mono';
261
+ d.textContent = shortDir(dir);
262
+ d.title = '目录:' + dir;
263
+ line2.appendChild(d);
264
+ }
265
+ if (title) {
266
+ if (dir) {
267
+ var sep = document.createElement('span');
268
+ sep.className = 'sess-sub-sep';
269
+ sep.textContent = '·';
270
+ line2.appendChild(sep);
271
+ }
272
+ line2.appendChild(idButton(sid, false));
273
+ }
274
+ if (line2.childNodes.length) cell.appendChild(line2);
275
+
276
+ tr.appendChild(cell);
211
277
 
212
278
  tr.appendChild(td(r.model || '--'));
213
279
  tr.appendChild(td(r.agent || '--'));
@@ -217,7 +283,8 @@
217
283
  tr.appendChild(td(F.fullNumber(r.tokens_cache_read), 'num'));
218
284
  tr.appendChild(td(F.fullNumber(r.tokens_cache_write), 'num'));
219
285
  tr.appendChild(td(F.fullNumber(r.message_count), 'num'));
220
- tr.appendChild(td(F.dateTime(r.last_activity_at)));
286
+ var lastFull = F.dateTime(r.last_activity_at);
287
+ tr.appendChild(td(F.shortDateTime(r.last_activity_at), null, lastFull === '--' ? '' : lastFull));
221
288
  return tr;
222
289
  }
223
290
 
@@ -312,6 +379,8 @@
312
379
  ]));
313
380
 
314
381
  body.appendChild(group('会话信息', [
382
+ item('会话标题', nonEmpty(d.session_title) || '--'),
383
+ item('项目目录', nonEmpty(d.session_directory) || '--', true),
315
384
  item('模型', d.model || '--'),
316
385
  item('代理', d.agent || '--'),
317
386
  item('安装标识', d.install_id || '--', true),
package/src/cli.js CHANGED
@@ -42,11 +42,12 @@ function parseFlags(argv) {
42
42
  const HELP = `opencode-stats-reporter — 本地一体化 MaaS 用量看板
43
43
 
44
44
  用法:
45
- opencode-stats-reporter config set --gateway <url> --key <key> 配置上游网关与 Key
45
+ opencode-stats-reporter config set --gateway <url> --key <key> [--endpoint <url>]
46
+ 配置上游网关、Key 与插件上报端点
46
47
  opencode-stats-reporter config get 查看配置(Key 脱敏)
47
48
  opencode-stats-reporter web start [--port 8787] [--host 127.0.0.1] [--open]
48
- 启动本地服务并托管面板
49
- opencode-stats-reporter plugin install [--port 8787] 安装 opencode 上报插件
49
+ 启动本地服务并托管面板(自动写入插件上报端点 reportEndpoint)
50
+ opencode-stats-reporter plugin install [--port 8787] 安装 opencode 上报插件(零配置,默认上报本机服务)
50
51
 
51
52
  选项:
52
53
  -h, --help 显示帮助
@@ -57,15 +58,17 @@ function cmdConfig(argv) {
57
58
  const sub = argv[0]
58
59
  const flags = parseFlags(argv.slice(1))
59
60
  if (sub === "set") {
60
- const cfg = setConfig({ gateway: flags.gateway, key: flags.key })
61
+ const cfg = setConfig({ gateway: flags.gateway, key: flags.key, endpoint: flags.endpoint })
61
62
  console.log("已写入配置: " + (cfg.gatewayBaseUrl || "(未设置)"))
62
63
  console.log("API Key: " + maskKey(cfg.apiKey))
64
+ console.log("reportEndpoint: " + (cfg.reportEndpoint || "(未设置)"))
63
65
  return
64
66
  }
65
67
  if (sub === "get") {
66
68
  const cfg = readConfig()
67
69
  console.log("显式配置 gatewayBaseUrl: " + (cfg.gatewayBaseUrl || "(未设置)"))
68
70
  console.log("显式配置 apiKey: " + maskKey(cfg.apiKey))
71
+ console.log("显式配置 reportEndpoint: " + (cfg.reportEndpoint || "(未设置)"))
69
72
  const r = resolveGateway({ fileConfig: cfg })
70
73
  if (r.ok) {
71
74
  console.log("生效网关: " + r.config.gatewayBaseUrl + "(来源: " + r.source + (r.providerId ? ",provider=" + r.providerId : "") + ")")
@@ -107,11 +110,25 @@ async function cmdWeb(argv) {
107
110
  server.listen(port, host, resolve)
108
111
  })
109
112
 
110
- const url = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}`
113
+ const address = server.address()
114
+ const actualPort = address && typeof address === "object" ? address.port : port
115
+ const url = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${actualPort}`
111
116
  console.log("本地看板已启动: " + url)
112
117
  console.log("上游网关: " + cfg.gatewayBaseUrl + "(来源: " + resolved.source +
113
118
  (resolved.providerId ? ",provider=" + resolved.providerId : "") + ")")
114
119
  console.log("数据文件: " + dbFile())
120
+
121
+ // 零配置上报:把插件上报端点写入 config.json(插件解析顺序 env > reportEndpoint > 默认值)。
122
+ // 即使 --host 0.0.0.0 也写 127.0.0.1(插件默认同机);写入失败只告警,不影响服务启动。
123
+ const reportEndpoint = `http://127.0.0.1:${actualPort}/v1/session-usage/report`
124
+ try {
125
+ setConfig({ endpoint: reportEndpoint })
126
+ console.log("插件上报端点: " + reportEndpoint + "(已写入 config.json,插件零配置自动上报)")
127
+ } catch (error) {
128
+ console.warn("警告: 未能写入插件上报端点 reportEndpoint(不影响服务运行): " +
129
+ (error && error.message ? error.message : String(error)))
130
+ }
131
+
115
132
  if (!loopback) console.warn("警告: 正在监听非回环地址 " + host + ",本机网络内可访问,请自行确认风险")
116
133
  if (flags.open) openBrowser(url)
117
134
 
@@ -121,6 +138,7 @@ async function cmdWeb(argv) {
121
138
  server.close(() => process.exit(0))
122
139
  setTimeout(() => process.exit(0), 1000).unref()
123
140
  })
141
+ return server
124
142
  }
125
143
 
126
144
  function cmdPlugin(argv) {
@@ -145,7 +163,8 @@ function cmdPlugin(argv) {
145
163
  console.log("推荐用 npm 方式安装(可随 npm 更新、支持 -f 覆盖):")
146
164
  console.log(" opencode plugin @dijkspicy/opencode-stats-reporter -g")
147
165
  console.log("")
148
- console.log("以上为离线/本地文件安装方式。之后在 opencode 进程环境中设置:")
166
+ console.log("以上为离线/本地文件安装方式。插件零配置:默认上报本机服务(web start 会把实际端点自动写入 config.json)。")
167
+ console.log("如需显式覆盖端点,可在 opencode 进程环境中设置:")
149
168
  console.log(" SESSION_USAGE_ENDPOINT=http://127.0.0.1:" + port + "/v1/session-usage/report")
150
169
  console.log("(本地模式无需 SESSION_USAGE_API_KEY;请勿使用 --pure 启动 opencode)")
151
170
  }
package/src/config.js CHANGED
@@ -34,7 +34,7 @@ function validUrl(url) {
34
34
  }
35
35
  }
36
36
 
37
- export function setConfig({ gateway, key }) {
37
+ export function setConfig({ gateway, key, endpoint }) {
38
38
  const cfg = readConfig()
39
39
  if (gateway !== undefined) {
40
40
  const g = String(gateway).trim().replace(/\/+$/, "")
@@ -46,6 +46,11 @@ export function setConfig({ gateway, key }) {
46
46
  if (!k) throw new Error("key 不能为空")
47
47
  cfg.apiKey = k
48
48
  }
49
+ if (endpoint !== undefined) {
50
+ const e = String(endpoint).trim()
51
+ if (!validUrl(e)) throw new Error("endpoint 必须是合法的 http(s) URL")
52
+ cfg.reportEndpoint = e
53
+ }
49
54
  return writeConfig(cfg)
50
55
  }
51
56
 
package/src/store.js CHANGED
@@ -29,6 +29,8 @@ CREATE TABLE IF NOT EXISTS session_usage (
29
29
  client_reported_at INTEGER,
30
30
  first_seen_at INTEGER NOT NULL,
31
31
  last_reported_at INTEGER NOT NULL,
32
+ session_title TEXT,
33
+ session_directory TEXT,
32
34
  PRIMARY KEY (consumer_id, install_id, session_id)
33
35
  );
34
36
  CREATE INDEX IF NOT EXISTS idx_su_consumer_activity ON session_usage(consumer_id, last_activity_at DESC);
@@ -89,6 +91,8 @@ export function sanitizeRecord(raw) {
89
91
  last_activity_at: int(raw.last_activity_at),
90
92
  client_reported_at: optInt(raw.client_reported_at),
91
93
  client_session_header: str(raw.client_session_header),
94
+ session_title: str(raw.session_title, 300),
95
+ session_directory: str(raw.session_directory, 512),
92
96
  }
93
97
  }
94
98
 
@@ -109,6 +113,23 @@ export class Store {
109
113
 
110
114
  migrate() {
111
115
  this.db.exec(SCHEMA)
116
+ this.ensureColumns()
117
+ }
118
+
119
+ /**
120
+ * 幂等迁移:为已存在的旧库补齐后增列。
121
+ * SCHEMA 只在表不存在时建表;旧库需用 PRAGMA table_info 检测缺列后 ALTER TABLE ADD COLUMN
122
+ * (可空 TEXT 列,不写默认值 → 旧行自动为 NULL,既有数据不受影响;重复调用安全)。
123
+ */
124
+ ensureColumns() {
125
+ const existing = new Set(
126
+ this.db.prepare("PRAGMA table_info(session_usage)").all().map((r) => r.name),
127
+ )
128
+ for (const [name, type] of [["session_title", "TEXT"], ["session_directory", "TEXT"]]) {
129
+ if (!existing.has(name)) {
130
+ this.db.exec(`ALTER TABLE session_usage ADD COLUMN ${name} ${type}`)
131
+ }
132
+ }
112
133
  }
113
134
 
114
135
  indexes() {
@@ -134,13 +155,13 @@ export class Store {
134
155
  (consumer_id, install_id, session_id, source, schema_version, reporter_version, client_session_header,
135
156
  model, agent, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
136
157
  cost, message_count, revision, session_created_at, last_activity_at, client_reported_at,
137
- first_seen_at, last_reported_at)
138
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
158
+ first_seen_at, last_reported_at, session_title, session_directory)
159
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
139
160
  const update = this.db.prepare(`UPDATE session_usage SET
140
161
  source=?, schema_version=?, reporter_version=?, client_session_header=?, model=?, agent=?,
141
162
  tokens_input=?, tokens_output=?, tokens_reasoning=?, tokens_cache_read=?, tokens_cache_write=?,
142
163
  cost=?, message_count=?, revision=?, session_created_at=?, last_activity_at=?, client_reported_at=?,
143
- last_reported_at=?
164
+ last_reported_at=?, session_title=?, session_directory=?
144
165
  WHERE consumer_id=? AND install_id=? AND session_id=?`)
145
166
 
146
167
  for (const r of records) {
@@ -152,6 +173,7 @@ export class Store {
152
173
  consumerId, r.install_id, r.session_id, source, schemaVersion, reporterVersion, r.client_session_header,
153
174
  r.model, r.agent, r.tokens_input, r.tokens_output, r.tokens_reasoning, r.tokens_cache_read, r.tokens_cache_write,
154
175
  r.cost, r.message_count, r.revision, r.session_created_at, r.last_activity_at, r.client_reported_at, ts, ts,
176
+ r.session_title, r.session_directory,
155
177
  )
156
178
  } else if (r.revision > prev.revision || (r.revision === prev.revision && r.last_activity_at > prev.last_activity_at)) {
157
179
  status = "upserted"
@@ -159,6 +181,7 @@ export class Store {
159
181
  source, schemaVersion, reporterVersion, r.client_session_header, r.model, r.agent,
160
182
  r.tokens_input, r.tokens_output, r.tokens_reasoning, r.tokens_cache_read, r.tokens_cache_write,
161
183
  r.cost, r.message_count, r.revision, r.session_created_at, r.last_activity_at, r.client_reported_at, ts,
184
+ r.session_title, r.session_directory,
162
185
  consumerId, r.install_id, r.session_id,
163
186
  )
164
187
  } else if (r.revision === prev.revision && r.last_activity_at === prev.last_activity_at) {