@kitsunemori96/opencode-session-metrics 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -183,15 +183,67 @@ opencode.db (只读) .data/metrics.db (查询用)
183
183
  正文落库时有长度上限(text / reasoning 2000 字符、工具入参 1500、工具回显 3000),
184
184
  超出部分面板本来就画不出来(回显最多展示 40 行)。
185
185
 
186
+ ## 数据源
187
+
188
+ 面板可以读两种来源,两库并存、随时切换:
189
+
190
+ | 来源 | 位置 | 成本单位 | 物化库 |
191
+ | --- | --- | --- | --- |
192
+ | OpenCode | `~/.local/share/opencode/opencode.db`(SQLite,只读) | 美元 | `metrics.db` |
193
+ | Qoder CLI | `~/.qoder/projects/<encoded-cwd>/<sessionId>.jsonl`(JSONL) | 积分 | `metrics-qoder.db` |
194
+
195
+ 数据源是**作用域**,不是筛选器:它是面板最高一层决定(高于路由、窗口、facet),在页眉右端
196
+ 一次性选定并记住,不写进 URL。两库各存一份切片,切回来还是原来的窗口与筛选。
197
+ 完整设计(线框、像素账、API 契约、验收门)见
198
+ [docs/source-scope.md](https://github.com/kitsuneMori96/opencode-session-metrics/blob/master/docs/source-scope.md)。
199
+
200
+ 启动时的默认源:`OSM_SOURCE=opencode\|qoder` → 显式给了 `OSM_SOURCE_DB`(按 OpenCode 处理)→
201
+ 存在 `opencode.db` → 存在 `~/.qoder/projects`。启动只物化默认源;另一个库在你第一次切过去时
202
+ 才建(Qoder 约 4 秒),之后由 2 分钟一轮的增量同步跟着走。
203
+
204
+ Qoder 没有会话数据库,转录是逐行 JSONL。两条格式陷阱决定了解析方式:一次模型响应会被
205
+ 拆成 2–4 个 `assistant` 事件(只有最后一条带 `usage`,必须按 `message.id` 合并),
206
+ 子代理的转录写在 `<sessionId>/subagents/` 下(`sessionId` 仍是父会话,必须并入父会话而不是
207
+ 另起一个会话)。token 字段恒为 0,上下文占用由 `context_usage_ratio × contextWindow` 还原;
208
+ 积分按整数微积分(`credits_micro`,×1e6)入库,`cost` 列存同一数值供现有聚合使用。
209
+ 完整规则与验收门见 [docs/qoder-source.md](https://github.com/kitsuneMori96/opencode-session-metrics/blob/master/docs/qoder-source.md)。
210
+
211
+ 成本单位由 `meta.unit` 声明、`/api/status` 透出,面板在渲染任何数字之前按当前库的单位把
212
+ `$` 换成 `cr`,不需要手工切换。
213
+
186
214
  ## 快速开始
187
215
 
188
- 从源码运行:要求 Node 24+、pnpm,且本机已存在 `opencode.db`。
216
+ 从源码运行:要求 Node 24+、pnpm,且本机已有 `opencode.db` 或 `~/.qoder/projects`。
189
217
 
190
218
  ```bash
191
219
  pnpm install
192
- pnpm build # web/dist + server/dist
193
- pnpm materialize --full
194
- pnpm start # http://127.0.0.1:8787
220
+ pnpm serve # 构建 web+server,重启在 http://127.0.0.1:8787,就绪后才返回
221
+ ```
222
+
223
+ `pnpm serve` 是推荐的启动方式,它把「构建」和「重启」绑成一件不可分的事:
224
+
225
+ 1. `pnpm build`(web → server)。构建失败就到此为止,旧进程继续服务,不会留下半个产物。
226
+ 2. 停掉占用端口的那个进程——**包括没有 pid 文件、手工 `node server/dist/index.js` 起的**。
227
+ 3. detached 重启,等到 `/api/status` 真正应答才返回,并打印 pid、数据目录与 URL。
228
+
229
+ 这一步之所以必要:前端产物与服务端产物分别构建,而 `express.static` 会把未知的 `/api/*`
230
+ 回落到 `index.html`。只换前端不换后端时,新 bundle 请求 `/api/sources` 收到 200 HTML,
231
+ 解析失败后静默降级——页眉的数据源控件会退化成纯文本。`pnpm serve` 从根上消掉这种错配。
232
+
233
+ 首次启动若无指标库会自动物化(全量,几分钟内完成);之后按水位增量更新。
234
+
235
+ ```bash
236
+ pnpm serve -- --no-open # 不打开浏览器
237
+ pnpm serve -- --port 8899
238
+ ```
239
+
240
+ 进程管理交给同一个脚本:
241
+
242
+ ```bash
243
+ node bin/osm.js status # 端口、pid、数据目录、bundle 构建时间;进程早于产物会直接告警
244
+ node bin/osm.js stop
245
+ node bin/osm.js logs # 跟随日志
246
+ node bin/osm.js restart
195
247
  ```
196
248
 
197
249
  开发模式(server 热重载 + Vite 代理):
@@ -200,8 +252,6 @@ pnpm start # http://127.0.0.1:8787
200
252
  pnpm dev # API :8787 · Web :5174
201
253
  ```
202
254
 
203
- 首次启动若无指标库会自动物化;之后按水位增量更新。表结构变更后需要 `--full` 重建。
204
-
205
255
  仓库里的 `bin/osm.js` 与 npm 包里的 `osm` 是同一个脚本,数据目录为仓库的 `.data`:
206
256
 
207
257
  ```bash
@@ -273,11 +323,14 @@ pid 与日志落在数据目录的 `osm.{pid,log}`。
273
323
 
274
324
  ```bash
275
325
  pnpm dev # server + web
326
+ pnpm serve # 构建 + 重启 8787(见「快速开始」)
276
327
  pnpm typecheck # 两个包 tsc --noEmit
277
328
  pnpm test # ETL 测试(离线 fixture,36 项断言,无需服务与网络)
278
329
  pnpm -F @osm/server test:smoke # HTTP 冒烟测试(需服务在线,9 项)
279
330
  pnpm materialize # 增量物化
280
331
  pnpm materialize --full # 全量重建(写入临时文件后原子替换)
332
+ pnpm materialize --source qoder --full # 只重建 Qoder 库
333
+ pnpm plans # 按两库实测单价算套餐性价比(见 docs/plan-value.md)
281
334
  ```
282
335
 
283
336
  前端图表用 `@visx/*`(d3 的无样式原语)与 `@tanstack/react-table` +
@@ -288,8 +341,10 @@ pnpm materialize --full # 全量重建(写入临时文件后原子替换
288
341
 
289
342
  | 变量 | 默认值 | 说明 |
290
343
  | --- | --- | --- |
291
- | `OSM_SOURCE_DB` | `~/.local/share/opencode/opencode.db` | 源库路径(只读) |
292
- | `OSM_DATA_DIR` | `<项目根>/.data` | 指标库与价目缓存目录(已 gitignore) |
344
+ | `OSM_SOURCE` | 自动 | 默认数据源:`opencode` 或 `qoder`(页眉可切换,不设则自动探测) |
345
+ | `OSM_SOURCE_DB` | `~/.local/share/opencode/opencode.db` | OpenCode 源库路径(只读) |
346
+ | `OSM_QODER_HOME` | `~/.qoder` | Qoder 数据根目录(读其下 `projects/` 与 `logs/`) |
347
+ | `OSM_DATA_DIR` | `<项目根>/.data` | 两库(`metrics.db` / `metrics-qoder.db`)与价目缓存目录(已 gitignore) |
293
348
  | `OSM_HOST` | `127.0.0.1` | API 监听地址 |
294
349
  | `OSM_PORT` | `8787` | API 端口 |
295
350
  | `OSM_OFFLINE` | — | 设为 `1` 时不访问 models.dev,价目走缓存或零值兜底 |
@@ -345,5 +400,11 @@ web/src/
345
400
 
346
401
  - [docs/opencode-top-parity.md](https://github.com/kitsuneMori96/opencode-session-metrics/blob/master/docs/opencode-top-parity.md) — 与 `opencode-top` 的功能对照,
347
402
  以及两处有意的口径差异
403
+ - [docs/qoder-source.md](https://github.com/kitsuneMori96/opencode-session-metrics/blob/master/docs/qoder-source.md) — Qoder CLI 数据源:
404
+ 实测证据、解析规则、单位与模型映射、验收门
405
+ - [docs/source-scope.md](https://github.com/kitsuneMori96/opencode-session-metrics/blob/master/docs/source-scope.md) — 数据源切换:
406
+ 作用域定位、页眉线框与像素账、两库契约、切片归属、验收门
348
407
  - [docs/backend-refactor.md](https://github.com/kitsuneMori96/opencode-session-metrics/blob/master/docs/backend-refactor.md) — 2026-10 后端重构的接口变更明细
349
408
  - [docs/motion.md](https://github.com/kitsuneMori96/opencode-session-metrics/blob/master/docs/motion.md) — 动效令牌、六个动词、交互对照与静态审计的规则
409
+ - [docs/markdown-transcript.md](https://github.com/kitsuneMori96/opencode-session-metrics/blob/master/docs/markdown-transcript.md) — transcript 的 Markdown 子集:
410
+ 映射表、语料实测、表格判据、搜索投影、验收门与已知边界
package/bin/osm.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // One process, one port: Express serves both the API and the built web bundle.
12
12
  // State goes to the XDG data dir (`~/.local/share/opencode-session-metrics`), never
13
13
  // into the package directory, so `npm i -g` upgrades cannot delete it.
14
- import { spawn } from "node:child_process";
14
+ import { execFileSync, spawn } from "node:child_process";
15
15
  import {
16
16
  createReadStream,
17
17
  existsSync,
@@ -43,6 +43,7 @@ const USAGE = `osm ${PKG.version} — local web cockpit for OpenCode session met
43
43
  Usage
44
44
  osm [start] [options] start in the background, then open the browser
45
45
  osm run [options] run in the foreground (Ctrl-C to stop)
46
+ osm restart [options] stop, then start again
46
47
  osm stop stop the background server
47
48
  osm status process, URL, data dir, source db, bundle age
48
49
  osm logs [-n N] follow the server log (default: last 40 lines)
@@ -67,6 +68,9 @@ let logLines = 40;
67
68
  for (let i = 0; i < argv.length; i += 1) {
68
69
  const arg = argv[i];
69
70
  switch (arg) {
71
+ // `pnpm serve -- --no-open` forwards the separator verbatim.
72
+ case "--":
73
+ break;
70
74
  case "-h":
71
75
  case "--help":
72
76
  positional.push("help");
@@ -189,6 +193,38 @@ function isOurServer(pid) {
189
193
  }
190
194
  }
191
195
 
196
+ /**
197
+ * Who holds the port, when we can see it without being root.
198
+ *
199
+ * This exists because a server started by hand (`node server/dist/index.js &`)
200
+ * writes no pid file, and `osm stop` would then refuse to touch it — leaving a
201
+ * stale bundle answering on the port while `osm start` says "already running".
202
+ * That is exactly the mismatch `pnpm serve` must not inherit.
203
+ */
204
+ function listenerPid() {
205
+ const run = (bin, args) => {
206
+ try {
207
+ return execFileSync(bin, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
208
+ } catch {
209
+ return "";
210
+ }
211
+ };
212
+ let out = "";
213
+ if (process.platform === "linux") {
214
+ for (const bin of ["ss", "/usr/sbin/ss", "/bin/ss", "/usr/bin/ss"]) {
215
+ out = run(bin, ["-ltnpH", `sport = :${PORT}`]);
216
+ if (out) break;
217
+ }
218
+ const hit = out.match(/pid=(\d+)/);
219
+ return hit ? Number(hit[1]) : null;
220
+ }
221
+ if (process.platform === "darwin") {
222
+ out = run("lsof", ["-nP", `-iTCP:${PORT}`, "-sTCP:LISTEN", "-t"]);
223
+ return Number(out.trim().split("\n")[0]) || null;
224
+ }
225
+ return null;
226
+ }
227
+
192
228
  function onPath(bin) {
193
229
  return (process.env.PATH ?? "")
194
230
  .split(path.delimiter)
@@ -225,6 +261,27 @@ function childEnv() {
225
261
  };
226
262
  }
227
263
 
264
+ /**
265
+ * When a pid started, as epoch ms (Linux only).
266
+ *
267
+ * `status` uses it for the one staleness question that matters: a process older
268
+ * than `web/dist` is serving a bundle that no longer exists on disk — the panel
269
+ * runs, the page renders, and only the API is from last week.
270
+ */
271
+ function startedAt(pid) {
272
+ if (process.platform !== "linux") return null;
273
+ try {
274
+ // Field 22 is starttime in USER_HZ ticks since boot. `comm` may contain
275
+ // spaces, so the fields resume after the last ")".
276
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
277
+ const ticks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
278
+ const uptime = Number(readFileSync("/proc/uptime", "utf8").split(" ")[0]);
279
+ return Date.now() - (uptime - ticks / 100) * 1000;
280
+ } catch {
281
+ return null;
282
+ }
283
+ }
284
+
228
285
  function lastLines(file, count) {
229
286
  const size = statSync(file).size;
230
287
  const start = Math.max(0, size - 64 * 1024);
@@ -243,6 +300,11 @@ async function start(foreground) {
243
300
 
244
301
  if (await isUp()) {
245
302
  note(`already running — ${URL}`);
303
+ const holder = readPid() ?? listenerPid();
304
+ const since = holder ? startedAt(holder) : null;
305
+ if (since && existsSync(WEB_INDEX) && statSync(WEB_INDEX).mtime.getTime() - since > 60000) {
306
+ note("the bundle on disk is newer than the running process — osm restart (or pnpm serve) to load it");
307
+ }
246
308
  openBrowser();
247
309
  return;
248
310
  }
@@ -298,7 +360,16 @@ async function start(foreground) {
298
360
  }
299
361
 
300
362
  async function stop() {
301
- const pid = readPid();
363
+ let pid = readPid();
364
+ let adopted = false;
365
+ if (!pid) {
366
+ // No pid file: the process may still be ours, just started by hand.
367
+ const holder = listenerPid();
368
+ if (holder && isOurServer(holder)) {
369
+ pid = holder;
370
+ adopted = true;
371
+ }
372
+ }
302
373
  if (!pid) {
303
374
  if (await isUp()) {
304
375
  note(`${URL} answers, but it was not started by osm — leaving it alone`);
@@ -331,19 +402,31 @@ async function stop() {
331
402
  try {
332
403
  unlinkSync(PID_FILE);
333
404
  } catch {}
334
- note(`stopped (pid ${pid})`);
405
+ note(`stopped (pid ${pid}${adopted ? ", stopped a server osm did not start" : ""})`);
335
406
  }
336
407
 
337
408
  async function status() {
338
409
  const pid = readPid();
339
410
  const up = await isUp();
411
+ // One lookup either way: a pid file is authoritative, the listener is the fallback.
412
+ const holder = pid ?? (up ? listenerPid() : null);
340
413
  note(`${up ? "up" : "down"} — ${URL}`);
341
414
  if (pid) note(` pid ${pid}`);
342
- else if (up) note(" pid unknown (not started by osm)");
415
+ else if (up) note(` pid ${holder ? `${holder} (not started by osm)` : "unknown (not started by osm)"}`);
343
416
  note(` data ${DATA_DIR}${existsSync(path.join(DATA_DIR, "metrics.db")) ? "" : " (no metrics.db yet)"}`);
344
417
  note(` source ${SOURCE_DB}${existsSync(SOURCE_DB) ? "" : " (missing)"}`);
345
418
  if (existsSync(WEB_INDEX)) {
346
- note(` bundle ${statSync(WEB_INDEX).mtime.toISOString().slice(0, 16).replace("T", " ")}`);
419
+ // Local time, not ISO: the question this answers is "did the build I just ran
420
+ // land in the bundle the server is serving", and that comparison happens in
421
+ // the reader's timezone.
422
+ const built = statSync(WEB_INDEX).mtime;
423
+ const age = Math.round((Date.now() - built.getTime()) / 60000);
424
+ note(` bundle ${built.toLocaleString("sv-SE").slice(0, 16)} (${age}m old)`);
425
+ const since = holder ? startedAt(holder) : null;
426
+ // A minute of slack: the two timestamps come from different clocks.
427
+ if (since && built.getTime() - since > 60000) {
428
+ note(` stale pid ${holder} predates the bundle — restart it: pnpm serve`);
429
+ }
347
430
  } else {
348
431
  note(" bundle missing");
349
432
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitsunemori96/opencode-session-metrics",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A local-first web cockpit for OpenCode session metrics: tokens, cost, tools, and transcripts.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,7 +40,9 @@
40
40
  "build:web": "pnpm -F @osm/web build",
41
41
  "build:server": "pnpm -F @osm/server build",
42
42
  "prepack": "pnpm build",
43
+ "serve": "pnpm build && node bin/osm.js restart",
43
44
  "materialize": "pnpm -F @osm/server materialize",
45
+ "plans": "pnpm -F @osm/server plans",
44
46
  "start": "pnpm -F @osm/server start",
45
47
  "typecheck": "pnpm -r typecheck",
46
48
  "test": "pnpm -r test"