@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 +69 -8
- package/bin/osm.js +88 -5
- package/package.json +3 -1
- package/server/dist/index.js +830 -91
- package/web/dist/assets/index-CXuzIF_g.css +1 -0
- package/web/dist/assets/index-Dr7sP5of.js +228 -0
- package/web/dist/assets/index-Dr7sP5of.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CxDo3sN-.css +0 -1
- package/web/dist/assets/index-a_KpAtqb.js +0 -221
- package/web/dist/assets/index-a_KpAtqb.js.map +0 -1
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
|
|
216
|
+
从源码运行:要求 Node 24+、pnpm,且本机已有 `opencode.db` 或 `~/.qoder/projects`。
|
|
189
217
|
|
|
190
218
|
```bash
|
|
191
219
|
pnpm install
|
|
192
|
-
pnpm
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
| `
|
|
292
|
-
| `
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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"
|