From 754a5c0eda3483e8b0b3dedec99eeb8d2a4e788e Mon Sep 17 00:00:00 2001 From: WangJun Date: Wed, 28 Oct 2026 12:11:27 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E8=B7=A8=E5=B9=B3=E5=8F=B0=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E5=A6=82=E4=BD=95=E5=AE=89=E8=A3=85=20zstd=EF=BC=88Wi?= =?UTF-8?q?ndows/Linux=20=E4=B8=8D=E5=86=8D=E5=8F=AA=E6=9C=89=20brew?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dsh 多帧会话必须用外部 zstd CLI 解压,但旧报错/自检只给了 macOS 的 `brew install zstd`,Windows 与 Linux 用户看到后无法落地。 - src/zstd.js: 新增 zstdInstallHint(),按 process.platform 返回对应包管理器命令或官方二进制下载地址 - src/doctor.js: 健康自检的 zstd 缺失提示改用 zstdInstallHint() - README.zh-CN.md: 安装说明补充 Windows/Linux 的安装方式 实测:Windows 未装 zstd 时 update-and-resync 会因 34 个 dsh 多帧文件解压失败而卡在 rescanning;装好 zstd 后重算正常。 --- README.zh-CN.md | 1103 ++++++++++++++++++++++++----------------------- src/doctor.js | 237 +++++----- src/zstd.js | 201 +++++---- 3 files changed, 782 insertions(+), 759 deletions(-) diff --git a/README.zh-CN.md b/README.zh-CN.md index 6c9ec90..cf33c8e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,550 +1,553 @@ -# Token Watcher - -**English** · 简体中文 - -**Mac 本地多源 AI Agent 用量与配额实时面板。** 一个常驻进程解析你机器上各 AI 编码工具留下的本地会话记录,统一归一化为 token 事件流,提供 Codex 风格的统计面板、实时配额卡、厂商余额轮询、费用估算与菜单栏胶囊。零框架依赖、纯本地运行。 - -![Token Watcher](https://raw.githubusercontent.com/luwill/token-watcher/main/docs/screenshot.png) - -> 截图为真实运行数据(厂商余额与项目名已脱敏为 ••••)。 - -## 支持的工具(13 源) - -| 工具 | 数据位置 | 能看到什么 | -|---|---|---| -| Claude Code(含本地 Cowork) | `~/.claude/projects` + Desktop Cowork 日志 | 逐消息真实用量、跨格式去重 | -| ccmr(claude-code-model-router) | `~/.claude-gateway/projects` | 同上(国产模型隔离目录,真实模型名) | -| Codex | `~/.codex/sessions`、`archived_sessions` | token 明细、**官方配额百分比与重置时间**(5 小时 / 每周窗口)、模型(含 auto-review 子代理)、工具调用 | -| ZCode | `~/.zcode/cli/db/db.sqlite` | 逐请求明细(model_usage 表)、工具调用、**GLM Coding Plan 积分窗口**(5 小时 / 每周,官方端点) | -| dsh(DeepSeek Harness) | `~/.dsh/sessions` | 逐请求明细(zstd 压缩会话) | -| WorkBuddy | `~/.WorkBuddy/projects` | 逐请求明细 + **积分费率自学习**(credit 账本 × token 最小二乘) | -| Grok Build | `~/.grok/sessions` | 轮次用量明细(含厂商侧成本刻度)、工具调用 | -| Pi | `~/.pi/agent/sessions` | 逐请求明细、工具调用(会话 JSONL 自带 USD 成本明细) | -| OpenCode | `~/.local/share/opencode/opencode.db` | 逐请求明细(message 表)、工具调用 | -| Antigravity | `~/.gemini/antigravity*/brain/**/transcript*.jsonl` | 新版逐轮真实输入/缓存/输出;旧日志按完整上下文和字符估算 | -| Kimi Code | `~/.kimi-code/sessions/**/wire.jsonl` | 逐轮明细(step.end 用量,camelCase/Anthropic/OpenAI 兼容三形状自适应) | -| Qoder | `~/.qoder{,-cn}/projects/**/*.jsonl` | **积分账本**(上游本地不报 token,只报 credits);若上游恢复上报 token 则自动入事件 | -| Cursor | 账号级用量账单(CSV) | 逐请求明细(缓存三段分项)——本地无逐请求数据,用本地凭证拉官方导出 | - -**Claude Desktop 覆盖范围(1.9.4)**:已验证的本地 Cowork `agent/local_ditto_*` transcript 与 audit 日志并入 Claude Code 统计,标准 transcript 优先,两种格式及镜像共用消息身份去重。自动发现 macOS、Windows Roaming 和已安装 Claude MSIX 的目录;扩展目录支持 Windows 盘符与 UNC。本地 Code 仍要求日志落在配置目录。普通 Chat、未镜像的云端会话尚无明细采集源,result 汇总也不与逐消息用量叠加。验证与升级行为见 [Claude Desktop 调查与修复](docs/CLAUDE-DESKTOP-AUDIT-2026-10-14.md)。 - -本项目尚未提供网页版聊天(ChatGPT/豆包/DeepSeek 网页等)的 token 明细采集;不能用对话文本长度或配额百分比代替真实用量。 - -> **口径说明**:Kimi Code / Qoder / Antigravity 均在本机真实数据上与独立重算逐条核对一致。 -> **Qoder 实测本地只报积分不报 token**(2026-09,qmodel_38max):积分走独立账本出 -> "积分消耗"卡,不折算 token——无官方兑换率,编造即失真;token 事件待上游恢复上报。 -> **Cursor 本地不留逐请求数据**(state.vscdb 全零),唯一权威是账号级 CSV 导出: -> 凭证取自本地(state.vscdb 的 JWT 拼会话 cookie,只在服务进程内使用),30 分钟轮询。 -> Gemini CLI 上游已于 2026-06-18 官方过渡并入 Antigravity CLI,不再作为独立源。 -> **Antigravity 新版 CLI 本地已写入逐轮 usage**(2026-10-14,CLI 1.3.4 实测):优先读取 -> input_tokens(不含缓存)、cache_read_tokens、output_tokens(含思维);本项目总量包含缓存, -> 不重复加思维 token。旧日志没有这些字段时,输入取 conversations/gen_metadata 的完整上下文, -> 再回落到完整 transcript 字符上下文;输出/思维按 CJK×1 + 其他×¼ 估算。每轮都处理其上下文, -> 不再用相邻上下文增长量代替输入。库内 usage_quality=3 为实数、2 为上下文估算、1 为字符估算。 -> 常规/full 日志去重,实数可覆盖旧估算,晚到 metadata 即使 transcript 不变也会重试。 -> 工具名统一显示 Antigravity,页面底部的数据源悬浮说明保留新旧日志的计量口径;这里的 token 和 API 等价费用不代表 Google 官方剩余额度或账单。 - -## 快速开始 - -> **Codex 历史少算?** 源码已支持 `.jsonl`、`.jsonl.zst`、`.jsonl.zstd`,采集器 v5 会补扫仍在本机的压缩历史。缺失的日期会同时影响落在该范围内的 7/30 天统计;本地恢复不会自动回填社区云榜。解码要求、日期口径与恢复步骤见 [Codex 压缩历史说明](./docs/CODEX-COMPRESSED-HISTORY.md)。 - -三种用法,按"要不要长期用"来选。 - -### 方式一:npx 试用(什么都不装) - -```bash -npx --yes token-watcher@latest serve -open http://127.0.0.1:8787 -``` - -关掉终端即停止,适合先看看有没有用。 - -两个参数都别省:`--yes` 跳过安装确认;`@latest` 绕开 npx 的版本缓存——不加的话它 -可能跑到一个早先缓存的旧版本。另外 npx 会优先解析**本地**依赖,所以如果你正好在 -本仓库目录里执行,跑的其实是仓库代码而非 npm 上的包。 - -### 方式二:npm 全局安装(长期使用,推荐) - -```bash -npm install -g token-watcher -token-watcher serve -``` - -装完除了 `serve`,还多出两个只有全局安装才方便用的能力: - -```bash -# 常驻 + 开机自启(macOS,崩溃自动拉起) -token-watcher install-agent -token-watcher install-agent --port 9000 -token-watcher uninstall-agent - -# 菜单栏胶囊(macOS) -token-watcher bar -token-watcher bar --port 9000 # 服务不在默认端口时 -``` - -`install-agent` 会把 node 与入口脚本的**绝对路径**固化进 LaunchAgent——launchd 的 -PATH 是系统默认,既不含 npm 全局 bin(前缀还可能被改过),也不保证含 homebrew, -靠命令名会起不来。日志在 `~/.tokenmeter/logs/`。停用请用 `uninstall-agent` 或 -`launchctl bootout`,`launchctl stop` 会被 KeepAlive 立刻拉起。 - -菜单栏 app 以 universal 二进制随包发布(arm64 + x86_64,约 340KB),不需要 Xcode -工具链;从菜单里选「退出」可关闭。 - -三个命令名等价:`token-watcher` / `tokenwatcher` / `tokenmeter`(后者是旧名别名)。 - -### 方式三:克隆仓库(想改代码) - -```bash -git clone https://github.com/luwill/token-watcher.git -cd token-watcher && npm install -npm run serve # 首扫历史 ~3.3GB 约 6 秒,仅首次 -``` - -```bash -npm test # 回归测试,零依赖、离线可跑 -npm run build-bar # 重新编译菜单栏 app(需 Xcode CLT;发版时 prepack 会自动跑) -``` - -仓库里不含编译产物,`.app` 由 `prepack` 在发版前构建。所以克隆后想用菜单栏胶囊, -需先执行 `npm run build-bar`。 - -### 三种方式的差别 - -| | npx | 全局安装 | 克隆仓库 | -|---|---|---|---| -| 面板 / 采集 | ✓ | ✓ | ✓ | -| 菜单栏胶囊 | ✓ | ✓ | 需先 `npm run build-bar` | -| 开机自启 | 不建议 | ✓ | ✓(指向仓库路径)| -| 升级 | 每次拉最新 | `npm i -g token-watcher` | `git pull` | - -npx 下不建议装开机自启:生成的 LaunchAgent 会指向 npx 的缓存目录,而那个目录随时 -可能被 npm 清理,届时服务会静默起不来。要常驻就用全局安装。 - -### 方式四:Homebrew(macOS) - -```bash -brew install luwill/tap/token-watcher -token-watcher serve -``` - -formula 直接引用 npm registry 的 tarball(见 `packaging/homebrew/`),装出来的功能与 -`npm i -g` 完全一致(面板、采集、菜单栏胶囊、开机自启)。Homebrew 与 npm 装一份即可。 - -### serve 的端口与自动打开 - -- 未显式指定端口(默认 8787,可用环境变量 `TOKENMETER_PORT` 改)且被占用时,自动向后 - 尝试最多 20 个端口,面板照常起来;**显式 `--port` 被占则大声失败**——菜单栏胶囊等 - 指向固定端口,悄悄换一个更危险。 -- 交互式终端里启动 `serve` 会自动打开浏览器(launchd / CI 下 stdout 不是 TTY,不会开); - 不想开加 `--no-open`。 - -### 其他命令 - -```bash -token-watcher today # 终端速览今日消耗 -token-watcher today --json # 机器可读(可管道给 jq / 供 agent 调用) -token-watcher today --light # 纯 ASCII 表(CI/SSH 终端安全) -token-watcher sessions --day 2026-09-20 # 会话级统计(JSON) -token-watcher sessions --from 2026-09-01 --to 2026-09-30 --csv --out s.csv -token-watcher sessions --day 2026-09-20 --git # 每个会话挂上窗口内的 git 提交(产出归因) -token-watcher wrapped [--year 2026] [--json] # 年度报告(全年/最猛一天/连续天数/项目 Top) -token-watcher roi [--json] # 订阅 ROI(本月 API 等值 vs 实付) -token-watcher extra-roots [list|add|remove <路径>] - # 多机合并镜像目录的登记(改后需重启服务) -token-watcher doctor # 环境与数据源体检(node 版本/zstd/库完整性/逐源健康) -token-watcher uninstall # 摘除开机自启等本机痕迹 -token-watcher uninstall --purge-data --yes # 连 ~/.tokenmeter 数据目录一起删(不可恢复) -token-watcher scan # 只扫描一次 -token-watcher serve --port 9000 --no-open -token-watcher --version -``` - -`sessions --git` 的归因窗口是 `[会话首事件, 末事件 + 30 分钟]`(生成结束到落提交的常见 -滞后),从源文件首段读完整 cwd(events 表只存目录名,反推不唯一);非 git 项目的会话 -`commits` 保持 `null`,不编造。 - -### 合并另一台机器的用量(extra roots) - -多台电脑跑 Agent?把其他机器的数据目录镜像到本机,面板会把它并入同一工具的 -总账。去重键全局唯一,同一份会话同步两遍——包括同步盘的冲突副本——也只计一次。 -在 `~/.tokenmeter/extra-roots.json` 登记镜像目录(或用下面的 CLI),重启服务后 -生效(root 列表在进程启动时读取一次): - -```json -{ "claude-code": ["~/.tokenmeter/remote/home-pc/claude/projects"] } -``` - -```bash -token-watcher extra-roots list # 查看已登记的镜像目录 -token-watcher extra-roots add claude-code ~/mirror/home-pc/claude/projects -token-watcher extra-roots remove claude-code ~/mirror/home-pc/claude/projects -``` - -仪表盘顶部支持“全部设备 / Mac / VPS / Windows”筛选,图表、会话与CSV遵循相同设备范围;选择设备时仍保留全部设备累计总量。账户配额、余额和订阅ROI为共享信息。来源识别与历史兼容见[设备筛选说明](docs/DEVICE-FILTER.md)。 - -任何能把文件送到本机的传输都行,按机器条件选: - -| 传输方式 | jsonl 源(Claude Code、Codex、Grok…) | sqlite 源(ZCode、OpenCode) | -| --- | --- | --- | -| SSH + rsync 定时拉(cron / LaunchAgent) | ✅ | ✅ 拉的是静止副本 | -| Syncthing / 网盘持续同步 | ✅ 冲突副本已自动跳过 | ❌ 活库 WAL 会被同步盘撕裂 | -| U 盘 / 手动拷贝 | ✅ | ✅ 需先关闭源工具再拷 | - -- Cursor 无需合并:它的用量 CSV 是账号级的,任一台配了凭证的机器轮询到的就是 - 整个账号的用量。 -- `token-watcher doctor` 会显示每个镜像 root 及最近新数据的时间——同步悄悄断掉 - 会变得可见。 -- 社区排行榜:若 A 镜像了 B,只应有一台向榜单上报,否则 B 的用量在榜上双计。 - -该文件只存在于创建它的那台机器上——你的合并配置不会离开自己的数据目录。 - -## 运行要求 - -**Node ≥ 22.13**(`node:sqlite` 自该版本起免 `--experimental-sqlite` flag,零原生依赖)。 - -**dsh 源需要系统 `zstd`**(`brew install zstd`)。dsh 的会话快照是追加式多帧写入, -单个文件可达数千帧,而 Node 内置的 zstd 只解第一帧就结束且不报错,因此这里以外部 -`zstd` 为准,仅在确认文件只含单帧时才回落内置实现。未安装且遇到多帧文件时,该源会在 -健康自检里报错,而不是静默少算。其余数据源不需要它。 - -**平台支持:macOS 全功能验证**(含菜单栏 App 与 launchd)。Windows / Linux 上核心链路(采集、面板、API)随 CI 在 macOS + Ubuntu + Windows 三平台跑回归,包含"装成依赖后能否真正跑起来"的安装冒烟。菜单栏 App 与 launchd 开机自启为 macOS 专属。 - -开发与测试:`npm test` 运行五层回归——语法检查、import 冒烟(拦模块级错误,`node --check` 看不见)、静态断言、前端纯函数行为、端到端冒烟(fixtures 黄金数字 + 幂等 + API 结构)。零依赖、离线运行,GitHub Actions 上跑 macOS/Ubuntu × Node 22.13/24 矩阵。 - -## 功能 - -- **实时**:FSEvents 监听各数据目录 → 增量解析 → SSE 推送,面板秒级刷新(实测 <1s) -- **增量采集**:字节级游标 / sqlite 水位 / 快照重解析三种策略,二次扫描 0ms;dedup 键保证幂等;采集器版本号机制支持逻辑升级后自动全量回填 -- **统计面板**:指标卡、近一年 GitHub 风格日历热力图(每日/每周/累计)、按天/模型/工具图表、最近请求实时流 -- **会话钻取**:点击趋势图任意一天 → 会话列表(峰值上下文估算)→ 单会话逐请求 token 曲线 -- **工具活动**:四源工具调用频次榜 -- **配额**:Codex 官方配额直读(百分比/重置倒计时)、**Claude 官方配额**(复用 Claude Code - 本地 OAuth 凭证调官方用量端点,5 小时窗/周窗/模型专属周额度;无凭证时自动回落 5h 窗口推算) - Statuspage 公开接口,5 分钟刷新;厂商挂没挂一眼可辨) -- **订阅 ROI**:本月 API 等值 vs 订阅实付(`token-watcher roi`,月费在 subscriptions.json 配置) -- **积分账本**:Qoder 积分消耗卡(今日/累计,官方 credits 直读) -- **余额与费用**:DeepSeek/Kimi 余额轮询(读 `~/.ccmr/.env` 的 key,密钥不出后端);ccmr 侧按官方牌价折算(`~/.tokenmeter/pricing.json` 可编辑)+ 余额对账 -- **阈值通知**(菜单栏 App):Codex ≥80%/95%、余额 <¥10/¥2 弹 macOS 通知,级别上升只提醒一次 -- **数据源健康自检**:解析错误标红、"文件在写但无新事件"标黄——私有格式漂移不再静默失败 -- **导出与备份**:CSV 导出、会话/年度报告 CLI、每日 `VACUUM INTO` 备份保留 7 份 - -## 隐私 - -- 纯本地:只读取上述本地文件,**不上传任何用量数据**,面板只监听 127.0.0.1 且校验 Host(挡 DNS rebinding) -- API 密钥仅在服务进程内使用(调厂商余额接口),不入库、不进前端 -- 统计库只存聚合事件(时间/工具/模型/token 数/项目名),不存对话内容 - -**出站请求清单**(只有这四类,都不携带你的用量数据): - -| 用途 | 端点 | 频率 | -|---|---|---| -| USD→CNY 汇率 | `open.er-api.com`、`cdn.jsdelivr.net` | 12 小时,本地缓存兜底 | -| 模型牌价 | `raw.githubusercontent.com`(LiteLLM 价格表) | 24 小时,本地缓存兜底 | -| 厂商余额 | DeepSeek / Kimi 官方接口(带你的 key) | 30 分钟,连续失败自动熔断 | -| Claude 官方配额 | `api.anthropic.com/api/oauth/usage`(带 Claude Code 本地 OAuth token,macOS 首次可能弹一次钥匙串授权) | 10 分钟,无凭证自动跳过 | -| Cursor 用量账单 | `cursor.com/api/dashboard/export-usage-events-csv`(带本地凭证拼的会话 cookie) | 30 分钟,未装/未登录自动跳过 | -| GLM Coding Plan 积分配额 | `bigmodel.cn / api.z.ai` 的 `/api/monitor/usage/quota/limit`(带 ZCode 自己的本地 API key) | 10 分钟,未装/未订阅自动跳过;MCP 调用配额只读本地日志不出网 | - -要完全断网运行,设 `TOKENMETER_OFFLINE=1`:三类请求全部跳过,改用本地缓存与 -`~/.tokenmeter/pricing.json`(可设 `usd_to_cny` + `usd_to_cny_manual: true` 固定汇率)。 -回归测试即以此模式运行,不依赖公网。 - -费用解析优先级:用户 `pricing.json` → 已核实的官方价格与来源限定别名 → LiteLLM 缓存牌价。 -Gemini 3.8 Flash 的 Antigravity 内部名、Cursor MAX/Fast 等模式在价格层解析,保留原始用量记录。 -缓存读、缓存写分别计价,明确免费模型按零价识别;自动路由或身份未知的模型继续标记缺价。 -订阅工具显示 API 等值估算;Cursor/Grok 多次调用汇总无法还原单次长上下文档位,按标准档估算。 -详见 [2026-10-14 全模型价格核对](docs/MODEL-PRICING-AUDIT-2026-10-14.md)。 - -## 免责声明 - -Token Watcher 是**非官方**工具,解析的均为各产品留在本地的**私有格式**(无稳定性承诺),上游版本升级可能导致个别源解析中断——健康面板会标出,欢迎提 issue。各产品版权归其厂商所有。 - -## 架构 - -详见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。核心:`src/collectors/*` 每源一个适配器(增量 + dedup + 版本号),`src/scanner.js` 统一调度,SQLite 归一化事件表,`src/server.js` HTTP API + SSE,`web/` 零构建前端(ECharts UMD)。 - -维护流程见 [每日维护 Loop 与 CI/CD](docs/MAINTENANCE-LOOP.md):定时扫描 PR/Issue 和 CI 变化、有限修复与草稿 PR;发布工作流默认演练,可在确认后使用 npm OIDC 发布已验证的同一安装包。 - -**添加新数据源**只需:写一个 collector(实现 `collect(store, {path, offset, state})` → `{inserted, newOffset, state}`)、在 `src/config.js` 注册、前端加一个颜色。见 [CONTRIBUTING.md](CONTRIBUTING.md)。 - -## Roadmap - -- 跨平台验证(Linux/Windows) -- 订阅 ROI 视角:API 等值成本 vs 订阅实付 -- Grok 官方配额直连(ZCode/GLM 已做) -- Cursor / Copilot 数据源(待拿到可验证的真实数据布局) -- i18n / 英文 README - -## 更新日志 - -### 1.9.5(2026-10-14) - -- **Antigravity 名称显示**:移除固定的「≈」,底部数据源悬浮说明解释新版真实用量与旧日志估算;统计口径保持不变。 -- **OpenCode 大库扫描**:仅取所需 JSON 字段并逐行读取,避免完整工具输出占满 JS 堆;保持 SQLite 语句存活,修复 Node 22.13 下 GC 导致的扫描失败([#15](https://github.com/luwill/token-watcher/pull/15))。 -- **会话详情显示**:失败或空数据清除旧图;迟到响应和同 ID 重试不会覆盖最新选择。无会话 ID 的行不再可点击,标题加入工具名([#14](https://github.com/luwill/token-watcher/pull/14))。 -- Node 22.13/24 回归、强制 GC、48 MiB 堆大库扫描及请求乱序测试通过;详见 [1.9.5 说明](docs/RELEASE-1.9.5.md)。 - -### 1.9.4(2026-10-14) - -- 补采已验证的 Desktop 本地 Cowork transcript/audit;按消息 ID 跨格式去重,标准 transcript 优先,镜像、重扫与重开不双计。 -- 修复 Windows 盘符、UNC 和 tilde 扩展目录校验;CLI 与配置加载共用规则。 -- 旧键合并与游标提交同事务,失败回滚、保留缺源历史;30 项定向及 863 项完整隔离回归通过。 -- Chat/未镜像云端和 result 汇总仍有覆盖边界,升级前先备份;详见 [1.9.4 说明](docs/RELEASE-1.9.4.md)。 - -### 1.9.3(2026-10-14) - -- **每日维护 Loop**:09:00(Asia/Taipei)完整分页扫描 PR/Issue 与 CI 变化,增量快照避免重复待办;有限修复和草稿 PR,合并与发版需维护者确认。 -- **受验证的发布流程**:版本标签、main 祖先关系和完整 CI 通过后构建 universal 安装包并验证干净安装;默认演练,标签先生成 Release 草稿。正式发布通过 npm OIDC 与 GitHub 环境审核进行,发布包与 registry 逐字节核对后再公开 Release。 -- **草稿复用保护**:正式发布前与公开前核对已有草稿安装包和校验文件;相同产物可继续公开,附件不一致/缺失或读取失败则停止。 -- 完整隔离回归 829 项通过;保留 1.9.2 的 Antigravity 用量和模型定价修复,详见 [维护协议](docs/MAINTENANCE-LOOP.md)。 - -### 1.9.2(2026-10-14) - -- **Antigravity 真实用量与历史补正**:优先读取逐轮非缓存输入、缓存读和含思维输出;旧日志按完整上下文估算,不再按相邻轮差分。实数可覆盖旧估算,常规/full/重放日志统一去重,ACTIVE 不抢占 DONE。 -- **晚到 metadata 与刷新**:会话结束后 transcript 不变化也重试待补元数据;仅补正已有记录时通知面板,质量标记防止估算覆盖实数。采集版本 v3 会重扫现存日志,升级前建议生成一致 SQLite 备份,见 [恢复说明](docs/ANTIGRAVITY-RECOVERY-2026-10-14.md)。 -- **补齐官方模型价格与来源别名**:Gemini 4 Argon、Antigravity Gemini 3.8 Flash、Step 5 Preview、Cursor Opus 5.5 MAX、Grok 4.7 Fast/Build、Kimi K3、Hy4 与完整 OpenRouter ID;免费名称按零价识别,身份未知的自动路由名称保留缺价。 -- **缓存与模式计价**:缓存读/写分别计费,覆盖 Google 优惠截止、Cursor MAX/Fast 加价和 Grok 上下文档位;用户价格仍优先,费用卡/按天图/ROI 保持一致。订阅工具是现价 API 等值估算,多次调用汇总按标准档估算,见 [价格核对](docs/MODEL-PRICING-AUDIT-2026-10-14.md)。 -- 完整隔离回归 820 项通过,真实日志独立逐键对账、重复重扫幂等、在线费用 API 与实际图表验收通过。 - -### 1.9.1(2026-10-13) - -- **大体积 Codex 压缩归档改为流式解析**([#12](https://github.com/luwill/token-watcher/issues/12)):解压后超过 V8 字符串上限(~512MiB)的 `.jsonl.zst` 归档此前每轮扫描都报解析错误,现改为 `zstd -dc` 边解边逐行消费(无 zstd CLI 时回落内置整缓冲实现,边界不变);行语义与原整串解析严格一致,截断/损坏档案仍按单文件错误整体回滚,绝不静默吞掉。 -- **会话钻取修复**([#11](https://github.com/luwill/token-watcher/pull/11),感谢 [@amenjason936](https://github.com/amenjason936);1.9.0 的 npm 包已提前包含):`/api/session/:sid` 接受含 `.`/`:`/空格等文件名字符的真实会话 ID(原白名单正则会静默 400),残缺百分号编码按 400 处理而非 500;钻取按 `(tool, session_id)` 取数,两个工具的同名会话不再被合并成一条。 -- Homebrew 安装路径修正为 `brew install luwill/tap/token-watcher`(原文档指向不存在的仓库);tap 同步补到 1.9.0。 -- 三平台 CI 全绿,新增流式解压等价回归(与整串解析输出全等)与坏档抛错检查。 - -### 1.9.0(2026-10-13) - -- **多机用量合并(extra roots)**:把另一台机器(VPS / 家里电脑)的数据目录用任意传输(SSH + rsync 定时拉、Syncthing / 网盘持续同步、手动拷贝)镜像到本机,在 `~/.tokenmeter/extra-roots.json` 登记后并入同一工具的总账。去重键全局唯一,同步冲突副本自动跳过,不会双计;逐请求粒度、会话钻取、5h 窗口估算全量覆盖。sqlite 源(ZCode / OpenCode)请勿走同步盘(活库 WAL 会被撕裂,用 rsync 或关工具后拷贝);Cursor 是账号级数据无需合并。新增 `token-watcher extra-roots list|add|remove`,`doctor` 显示镜像目录的文件数与新鲜度(7 天无新数据提示疑似同步中断)。多机合并下社区排行榜只应由一台机器上报,否则镜像方与被镜像方双计。 -- **采集与守护修复批**(感谢 [@amenjason936](https://github.com/amenjason936) 的 [#3](https://github.com/luwill/token-watcher/pull/3)–[#7](https://github.com/luwill/token-watcher/pull/7)):服务端口重试不再泄漏后台任务与定时器;ROI 对无牌价模型如实显示「缺价」而非 ×0;菜单栏正确解码 Codex 多窗口配额;采集器写库改为暂存回放(事务不跨 await);Cursor CSV 按 RFC4180 解析;测试套件与调用方环境完全隔离。 -- **游标清理按路径段归属 root**([#9](https://github.com/luwill/token-watcher/pull/9),感谢 @amenjason936):配置任意镜像目录后,同前缀的兄弟目录(如 `/a/mac` 与 `/a/mac-old`)的游标行不再被误删。 -- **Codex 键迁移归档搬出热库**([#10](https://github.com/luwill/token-watcher/pull/10),感谢 [@xmasdong](https://github.com/xmasdong)):一次性恢复归档移入同目录独立文件,不再占用统计库、也不再被每日快照复制 7 份(贡献者实测 1.2 GB 库降至 264 MB)。恢复仍可查(ATTACH 侧库),搬家逐行核对后才删原表,失败保持原样下次重试。 -- 三平台 CI(macOS / Ubuntu / Windows × Node 22.13 / 24)全绿,新增多机合并、冲突副本、CLI 登记台与归档搬家的定向检查。 - -### 1.8.4(2026-10-10) - -- **补回 Codex 压缩日志中的历史用量**(感谢 [@horacehht](https://github.com/horacehht) 的 [#2](https://github.com/luwill/token-watcher/pull/2)):支持 `.jsonl.zst` / `.jsonl.zstd`,普通、压缩与归档副本统一去重;采集器 v5 首次运行会重扫仍在本机的历史。压缩文件解码失败会回滚当前文件,不覆盖其已有记录。 -- **统一 7/30 天统计窗口**:本地卡片、费用、图表、CSV 和工具活动按含今天的本地自然日计算,跨夏令时仍保持日期边界;排行榜客户端用量及主力模型按含今天的 UTC 自然日计算,与服务端窗口对齐。 -- **归一 Cursor 模型名**:合并代理前缀与推理强度后缀,避免同一模型分裂成多条统计。 -- Node 22.13 读取压缩日志需要系统 `zstd`;有内置 zstd 的 Node 可回落解码单帧,多帧仍需要 CLI。更新可恢复本地现存日志,**不会自动补报云榜过去日期**,详见 [恢复说明](./docs/CODEX-COMPRESSED-HISTORY.md)。 -- 隔离完整回归 644 项通过,新增压缩日志与日期窗口定向检查 18 项。 - -### 1.8.3(2026-09-30) - -- **修复睡眠唤醒后服务"活着但不工作"**:合盖睡眠再唤醒后,进程还在、端口开着,面板却打不开、 - 也不再采集新数据,只能手动重启。原因是内部线程池(默认 4 个线程)被占满——唤醒时网络未恢复, - 几个外网请求同时卡在域名解析上,文件读取全部排不上队。现在: - - 外网请求发出前先检查域名解析,同时最多让 2 个解析占用线程 - - 线程池从 4 个加大到 16 个(Windows 需在启动前自行设置 `UV_THREADPOOL_SIZE=16`) - - 新增卡死自检:连续约 5 分钟无法完成文件操作,或扫描没有任何进展,服务会打印原因并结束; - 由 `install-agent` 托管时会自动重启 -- 如果仍遇到卡死,重启前请执行 `sample <进程号> 5` 并反馈输出,可以确定卡在哪一步 - -### 1.8.2(2026-09-28) - -- **修复 dsh 用量多算**(感谢 [@xmasdong](https://github.com/xmasdong) 的 [#1](https://github.com/luwill/token-watcher/pull/1)): - dsh 会把同一次请求写成"流式用量"和"完整消息"两条记录,迁移到新格式后还会再抄进新快照, - 一次请求最多被算 3 次。现按"会话 + turn + step + 输入侧用量"认请求,新旧快照都读、 - 各自独有的请求都保留。新旧快照并存的会话越多影响越大(贡献者本机 55 亿 → 28 亿, - 作者本机 20.6 亿 → 19.98 亿) -- 升级后首次启动会自动重建 dsh 的历史数据(只动 dsh)。只清理能从原始日志确认的重复行, - 日志已不在的历史保持原样 -- 附独立核对脚本 `python3 test/dsh-audit.py`:只读、只输出汇总数字,可用来与面板对账 - -### 1.8.1(2026-09-28) - -- **修复 Codex 用量多算**:子代理 / 分叉 / 审查代理的会话文件,开头会整段重放父会话的历史。 - 这些请求父会话里已经计过,此前又被算了一遍——子代理用得越多,多算越多(作者本机 +23%, - 有用户反馈总量是 TokenTracker 的 2.4 倍)。现按"文件创建瞬间一次性写入"的时间特征识别并跳过, - 重放里的工具调用与配额快照同样不再计入 -- **补上此前漏算的三类 Codex 请求**:每个会话的第一次请求、会话恢复后计数器归零的那一次、 - 上下文压缩调用(合计约占 0.5%) -- 同一文件里交错的多条累计流(主代理 + 内嵌审查代理)各算各的,不再把累计差当成一次请求 -- 升级后首次启动会自动重建 Codex 的历史数据(只动 Codex,其余工具不受影响;本机 698 个 - 文件约 10 秒)。**Codex 的累计总量、费用估算与订阅 ROI 会相应下降,这是修正不是丢数据** -- 与 TokenTracker 1.1.2 在同一批日志上逐文件比对:Claude Code 相差 0.08%,Codex 相差 0.15% - -### 1.7.0(2026-09-21) - -- **GLM Coding Plan 官方配额**:ZCode 凭证直连官方 monitor 端点(`/api/monitor/usage/quota/limit`), - 面板新增「GLM Coding Plan」卡:5 小时 / 每周两个积分窗口的已用百分比、进度条与重置倒计时, - 积分明细(如 1,183 / 12,000)在悬浮提示里;套餐等级(如 Pro)作徽章。MCP 工具调用配额 - (1000 次/期)从 ZCode 本地日志读取(不出网),进 summary JSON(`quota.zcode.mcp`); - 快照陈旧度跟踪积分拉取时间,不会被活跃的本地日志刷成"新鲜" -- 窗口语义按实测归一:`percentage` 为已用百分比、`usage` 为总额度、`currentValue` 为已用、 - `unit=3` 为 5 小时窗、`unit=6` 为每周窗(重置落在固定周几);未验证过的 type/unit 组合如实显示原始值不硬贴标签 -- **Claude 官方配额卡恢复**:上游接口改版(`used_percent`→`utilization`、scoped 周窗移入 - `limits[]`)后归一化如实拒认、面板静默回落推算卡——现两种形状都认,官方卡与 Claude Code - 自带 /usage 视图同构(5 小时 / 每周 / scoped 模型如 Fable 各一条),并移入宽卡网格 -- 配额卡倒计时改紧凑格式(`3天 13:17` / `2:06:10`),紧凑卡 6 列布局下单行不再折行; - 订阅 ROI 行文案压缩(全称进悬浮提示)适配四卡同行 -- Windows:自动递增端口跳过系统保留段(Hyper-V/WinNAT 的 `EACCES`,CI 实测踩过), - 显式 `--port` 仍大声失败;zstd 单帧回落不再被压缩负载里的巧合帧魔数误判 -- 凭证纪律:ZCode key 与 Claude token 都只在服务进程内使用,不入库、不进前端 - -### 1.6.0(2026-09-21) - -- **新增订阅 ROI**:回答"我这个月 ¥X 的订阅值不值"。配置 `~/.tokenmeter/subscriptions.json` - 的月费后,面板出"订阅 ROI(本月)"卡、CLI 出 `token-watcher roi [--json]`:逐订阅对比 - 本月 **API 等值成本**(与费用卡同一条链路:pricing.json > 官方回退 > LiteLLM×汇率 + 峰谷)与 - **订阅实付**(price_cny / price_usd 二选一)。比值 ×N≥1 标"划算";积分制工具(Qoder) - 如实显示本月积分消耗、不硬造比值;卡片明确标注假设性口径(订阅含速率限制、API 可能 - 有折扣价)。未配置时若订阅制工具本月已有花费,显示引导卡与本月等值合计 -- 配置格式:`{"monthly": {"claude-code": {"name": "Claude Max", "price_usd": 200}, - "kimi": {"name": "Kimi 会员", "price_cny": 49}}}`(默认键为工具 id)。两个进阶字段: - `tool` 指定数据源 id(同一工具可拆多条订阅——GLM 与 MiniMax 的用量都落在 zcode 下); - `models` 按模型前缀过滤聚合范围。月费未填的条目仍显示 API 等值并标"月费未填" - -### 1.5.1(2026-09-21) - -- **修复模型 Top 10 与工具调用榜的悬浮提示张冠李戴**:横向条形图数据画图前做了 - 反转(大的在上),tooltip 却按原顺序数组取下标——悬停榜首显示的是末位模型的 - 调用次数(实测 deepseek-v4.1-flash 显示成 304 次,真实 13018 次;token 数值不受 - 影响)。两处统一改为从反转后数组取值,并补防回归断言。该数字已与原始文件独立 - 重算逐位核对一致 - -### 1.5.0(2026-09-21,已发布) - -对标 [TokenTracker](https://github.com/xiufengsun/TokenTracker) 补齐 11 项能力,同时守住 -本项目的差异化(实时、逐请求粒度、费用对账、零遥测、单包零依赖)。 - -- **新增 Kimi Code、Qoder、Antigravity 三个数据源 + Cursor 账单(共 13 源,Kiro 移除)。** - Kimi Code 解析 `~/.kimi-code/sessions/**/wire.jsonl`(step.end 用量,三种形状自适应, - 项目名来自 workspaces.json);Qoder 解析 `~/.qoder{,-cn}/projects/**`(实测本地只报 - 积分不报 token:积分入独立账本出卡,token 事件待上游恢复上报自动生效)。 - Gemini CLI 上游已于 2026-06-18 官方过渡并入 Antigravity CLI,不再单列。 - 当时 Antigravity 为**估算口径**(旧版日志无逐请求 usage;新版支持见顶部口径说明):输入取 conversations - db 的 gen_metadata 权威上下文增量,输出/思维链按字符估算(CJK×1+其他×¼,与 - TokenTracker 同法),界面标注「估算」;估算法在本机 744 条真实事件上与独立重算逐条 - 核对一致,且处理了上游 transcript 重放历史行的去重(step_index 重复即跳过,防上下文 - 双重膨胀与差分负值覆盖原计费)与权威值晚到的原地补正。三源均在本机真实数据上独立重算逐条核对 -- **Claude 官方配额卡。** 复用 Claude Code 本地 OAuth 凭证(macOS 钥匙串 / Linux·Win 的 - `~/.claude/.credentials.json`)调官方用量端点,显示 5 小时窗、周窗与模型专属周额度的 - 官方百分比和重置倒计时;无凭证/离线时自动回落原有 5h 推算卡。token 只在服务进程内 - 使用,不入库不进前端;`TOKENMETER_NO_KEYCHAIN=1` 可禁用钥匙串读取 -- **CLI 补全**:`today --json`(机器可读,stdout 纯 JSON)与 `--light`(纯 ASCII); - `sessions --day/--from/--to --json/--csv --out`(会话级统计导出);`wrapped`(年度 - 报告,含最猛一天/连续天数/项目 Top,`--json` 可编程消费);`doctor`(环境 + 库完整性 + - 逐源健康体检,有 error 退出码非 0);`uninstall`(摘 LaunchAgent,`--purge-data` - 才动数据目录且需确认);`--version` / `--help` -- **sessions --git 提交归因**:从源文件首段读完整 cwd(events 只存目录名),在 - `[首事件, 末事件+30min]` 窗口内挂 git 提交——把"花了多少 token"连到"产出了哪些 commit"; - 非 git 会话 `commits=null` 不编造 -- **serve 体验**:默认端口被占时自动向后尝试(显式 `--port` 仍大声失败,防菜单栏胶囊 - 连不上);交互终端自动打开面板(`--no-open` 可关,launchd 下不会开);`TOKENMETER_PORT` - 环境变量可改默认端口 -- **Homebrew 分发**:`brew install luwill/tap/token-watcher`(formula 在 - `packaging/homebrew/`,直连 npm tarball) -- Claude 官方配额卡的重置倒计时按 ISO 时间归一,不再恒显 `--` - -### 1.4.4(2026-09-18,未单独发布,随 1.5.0 一同上线) - -- **修复 Codex plus 用户的配额卡显示成 5 小时额度。** Codex 上报的配额分 primary / secondary - 两个位置,但位置不代表窗口:plus 套餐 primary 是 5 小时、secondary 才是周额度;pro 只有 - primary,且是周额度。此前一律取 primary 并标成"周配额",plus 用户看到的其实是 5 小时额度 - (卡片上"0 天窗口"就是 300 分钟被当成天数取整的结果)。现在按窗口时长识别,卡片更名为 - 「Codex 配额」,有几个窗口就显示几条:plus 显示 5 小时与每周两条,pro 只显示每周 -- 修复配额被非主额度的快照顶掉:使用 GPT-5.3-Codex-Spark 时 Codex 会上报一份独立额度, - 另有一种窗口全空的快照,此前只要它们是最新一条,就会让配额卡显示成 Spark 的额度或 0% -- 升级前存下的旧快照无需重扫即可正确显示;下一次使用 Codex 时自动换成完整的窗口数据 -- 重置倒计时满一天时显示"3天23时58分",不再显示成"95时58分" -- 修掉一条会随运行时刻变红的测试:美元折算用例的事件取"1 小时前",落在 DeepSeek 谷时就被减半 - -### 1.4.3(2026-09-17) - -- **修复 OpenCode 漏采约八成消息。** OpenCode 的 assistant 消息是"先插后改":开始生成就插入 - 一行(用量全 0),生成结束才原地更新写入用量。采集器按 rowid 增量,而服务监听数据库写入、 - 生成过程中就会触发扫描——这行被当成 0 用量跳过,水位却越过了它,完成后的更新再也读不到。 - 面板表现为 OpenCode 明明在用却显示"疑似停更"。现改为按 `time_updated` 增量;升级后自动 - 全量重扫,补回历史漏采的消息(实测 99 条中原先只采到 20 条,修复后 99/99、token 总量逐一吻合) -- 修掉一条会随日期自己变红的测试:余额对账用例把事件时刻写死在某一天,而对账窗口是"最近 24 小时" - -### 1.4.2(2026-09-16) - -- **修复 1.4.1 引入的回归:dsh 整源静默归零。** 1.4.1 把 zstd 解压改为优先使用 Node - 内置实现(为解决 launchd 下找不到外部 zstd 的问题),但内置的 `zstdDecompressSync` - 与 `createZstdDecompress` 都只解**第一帧**就结束且不报错。而 dsh 是追加式多帧写入, - 实测单个会话文件有 5800+ 帧——外部 zstd 解出 19MB,内置实现只解出 226 字节。 - 现改为以外部 zstd 为准(仍显式试常见绝对路径以解决 launchd 的 PATH 问题), - 仅在确认文件只含单帧时才回落内置实现,多帧又无外部 zstd 时大声报错而非静默少算。 - **使用 dsh 源的用户请从 1.4.1 升级。** - -- 新增 `token-watcher install-agent` / `uninstall-agent`,把服务装成 macOS 开机自启项。 - 此前 README 指向 `npm run install-agent`,但 npm scripts 对全局安装的用户不可见, - 且那条命令只负责 `launchctl bootstrap`、从不生成 plist,plist 也不随包发布—— - 全局安装的用户实际没有可用的常驻方案 -- 生成的 plist 固化 node 与入口脚本的绝对路径。launchd 的 PATH 不含 npm 全局 bin 与 - homebrew,而入口脚本的 shebang 是 `#!/usr/bin/env node`,靠命令名无法启动 -- 检测到旧 `com.tokenmeter.server` 仍在运行时拒绝安装(两者抢同一端口),可用 `--force` 覆盖 -- 新增 `token-watcher bar`,菜单栏胶囊随包发布。此前 `.app` 只存在于仓库、也不在 - 发布白名单里,全局安装的用户拿不到,而指引用的 `npm run bar` 同样不可见 -- 菜单栏 app 改为 universal 二进制(arm64 + x86_64)。此前产物只有 arm64,Intel Mac - 上无法运行;`prepack` 会在发版前自动重新编译,避免发出陈旧或缺失的产物 -- 菜单栏 app 不再写死 8787 端口,`serve --port` 的用户不会再拿到连不上的胶囊 -- 修掉菜单栏的数量换算:「亿」档阈值错写成 1e6 却按 1e8 换算,200 万会显示成 "0.0亿" - -### 1.4.1(2026-09-16) - -修掉一批让面板数字低于厂商账单的问题。起因是实测发现面板显示 DeepSeek 当日 ¥8.34, -而官方后台是 ¥58.24。定价表本身无误,问题全在数据进库之前。 - -- **ccmr 的输出 token 被钉死在 0**:Claude Code transcript 把一次响应按 content block - 拆成多行,只有终结块带真实 `output_tokens`;ccmr 网关又不写 `requestId`,分片塌成 - 同一去重键,先到者胜留下的正是 output=0 的首块。实测单日丢掉 94.5% 的输出量。 -- **dsh 自 2026-08-14 起整源静默归零**:上游把会话结构换到 v3(`assistant/chunk` → - `assistant/message`,`data.chunk.usage` → `data.usage`),采集器一条也匹配不上, - 却照常返回 0 条且不报错。全库停在 165 条,而恢复后单日就有 790 次请求 / 1.07 亿 token。 -- **守护进程解不开 zstd**:launchd 的 PATH 不含 homebrew 目录,dsh 快照只有手动 - `scan` 才解得开。改为优先使用 Node 内置 zstd(≥23.8),旧版本回落外部可执行文件。 -- **DeepSeek 峰谷价未实现**:谷时用量一律按峰时价折算。峰时为 UTC 周一至周五 - 01:00-04:00 与 06:00-10:00,其余时段半价。 -- **余额对账漏算同账户的工具**:`computeRecon` 写死只统计 ccmr,而 dsh 花的是同一个 - DeepSeek 账户,导致对账永远显示巨大缺口。改为按注册表的 `apiBilled` 标记推导。 - -升级后首次扫描会全量重扫 ccmr 与 dsh 以补正存量数据。 - -### 1.4.0(2026-09-16) - -- 新增 **Pi** 与 **OpenCode** 两个数据源(共 9 源)。两者口径均逐条核对 - `total == input + output + cacheRead + cacheWrite` 后才接入 -- 健康自检的工具清单改为从数据源注册表推导。此前是另抄的一份硬编码数组,两份清单 - 必然漂移,而漏登记的源不会报错,只是从健康自检里静默消失 - -### 1.3.1(2026-09-15) - -- **装成依赖后面板全空**:ECharts 路径写死了嵌套 `node_modules`,而 npm 安装时会把 - 依赖提升到顶层,于是 `/vendor/echarts.min.js` 返回 404、前端整个挂掉。改为交给 - Node 自己的解析算法 -- 修掉两处 Windows 专属问题(目录分隔符解析) - -### 1.3.0(2026-09-15,未发布) - -- 补上与包名同名的 `token-watcher` 命令入口。改名后 bin 有多个条目且都不叫包名, - `npx token-watcher` 失去确定的解析依据 - -### 1.2.0(2026-09-15) - -- **服务不再会悄悄退出**:扫描时源文件被删(Claude 清理会话、Codex 归档搬移都是常态) - 会抛 ENOENT 冲垮整轮扫描,而这发生在防抖定时器回调里,等于未处理 rejection -- `scanning` 标志异常后不复位会让后续每轮扫描都被"并发中"挡掉,面板从此停更 -- 补齐路由错误处理与 `busy_timeout`,一次 SQLITE_BUSY 不再能杀掉进程 -- 清理已消失文件的游标行(只清 `files`,不动 `events`) - -### 1.1.0(2026-09-15) - -- 汇率实时化:公开接口拉取 USD/CNY,12 小时刷新 + 断网兜底 -- 新增逐日 token 消耗图与按天 × 模型堆叠花费图(Top6 + 其他聚合,低价模型不再被静默丢弃) -- 布局重构为两列严格等高对齐 -- 回归测试强化 - -### 1.0.0(2026-09-14) - -- 首个版本:本地多源 AI Agent 用量与配额实时面板,7 源采集 -- 费用折算接入 LiteLLM 实时牌价 -- GitHub 风格热力图、会话钻取 -- 三层回归测试(语法 / 静态断言 / 端到端冒烟) - -## License - -[MIT](LICENSE) +# Token Watcher + +**English** · 简体中文 + +**Mac 本地多源 AI Agent 用量与配额实时面板。** 一个常驻进程解析你机器上各 AI 编码工具留下的本地会话记录,统一归一化为 token 事件流,提供 Codex 风格的统计面板、实时配额卡、厂商余额轮询、费用估算与菜单栏胶囊。零框架依赖、纯本地运行。 + +![Token Watcher](https://raw.githubusercontent.com/luwill/token-watcher/main/docs/screenshot.png) + +> 截图为真实运行数据(厂商余额与项目名已脱敏为 ••••)。 + +## 支持的工具(13 源) + +| 工具 | 数据位置 | 能看到什么 | +|---|---|---| +| Claude Code(含本地 Cowork) | `~/.claude/projects` + Desktop Cowork 日志 | 逐消息真实用量、跨格式去重 | +| ccmr(claude-code-model-router) | `~/.claude-gateway/projects` | 同上(国产模型隔离目录,真实模型名) | +| Codex | `~/.codex/sessions`、`archived_sessions` | token 明细、**官方配额百分比与重置时间**(5 小时 / 每周窗口)、模型(含 auto-review 子代理)、工具调用 | +| ZCode | `~/.zcode/cli/db/db.sqlite` | 逐请求明细(model_usage 表)、工具调用、**GLM Coding Plan 积分窗口**(5 小时 / 每周,官方端点) | +| dsh(DeepSeek Harness) | `~/.dsh/sessions` | 逐请求明细(zstd 压缩会话) | +| WorkBuddy | `~/.WorkBuddy/projects` | 逐请求明细 + **积分费率自学习**(credit 账本 × token 最小二乘) | +| Grok Build | `~/.grok/sessions` | 轮次用量明细(含厂商侧成本刻度)、工具调用 | +| Pi | `~/.pi/agent/sessions` | 逐请求明细、工具调用(会话 JSONL 自带 USD 成本明细) | +| OpenCode | `~/.local/share/opencode/opencode.db` | 逐请求明细(message 表)、工具调用 | +| Antigravity | `~/.gemini/antigravity*/brain/**/transcript*.jsonl` | 新版逐轮真实输入/缓存/输出;旧日志按完整上下文和字符估算 | +| Kimi Code | `~/.kimi-code/sessions/**/wire.jsonl` | 逐轮明细(step.end 用量,camelCase/Anthropic/OpenAI 兼容三形状自适应) | +| Qoder | `~/.qoder{,-cn}/projects/**/*.jsonl` | **积分账本**(上游本地不报 token,只报 credits);若上游恢复上报 token 则自动入事件 | +| Cursor | 账号级用量账单(CSV) | 逐请求明细(缓存三段分项)——本地无逐请求数据,用本地凭证拉官方导出 | + +**Claude Desktop 覆盖范围(1.9.4)**:已验证的本地 Cowork `agent/local_ditto_*` transcript 与 audit 日志并入 Claude Code 统计,标准 transcript 优先,两种格式及镜像共用消息身份去重。自动发现 macOS、Windows Roaming 和已安装 Claude MSIX 的目录;扩展目录支持 Windows 盘符与 UNC。本地 Code 仍要求日志落在配置目录。普通 Chat、未镜像的云端会话尚无明细采集源,result 汇总也不与逐消息用量叠加。验证与升级行为见 [Claude Desktop 调查与修复](docs/CLAUDE-DESKTOP-AUDIT-2026-10-14.md)。 + +本项目尚未提供网页版聊天(ChatGPT/豆包/DeepSeek 网页等)的 token 明细采集;不能用对话文本长度或配额百分比代替真实用量。 + +> **口径说明**:Kimi Code / Qoder / Antigravity 均在本机真实数据上与独立重算逐条核对一致。 +> **Qoder 实测本地只报积分不报 token**(2026-09,qmodel_38max):积分走独立账本出 +> "积分消耗"卡,不折算 token——无官方兑换率,编造即失真;token 事件待上游恢复上报。 +> **Cursor 本地不留逐请求数据**(state.vscdb 全零),唯一权威是账号级 CSV 导出: +> 凭证取自本地(state.vscdb 的 JWT 拼会话 cookie,只在服务进程内使用),30 分钟轮询。 +> Gemini CLI 上游已于 2026-06-18 官方过渡并入 Antigravity CLI,不再作为独立源。 +> **Antigravity 新版 CLI 本地已写入逐轮 usage**(2026-10-14,CLI 1.3.4 实测):优先读取 +> input_tokens(不含缓存)、cache_read_tokens、output_tokens(含思维);本项目总量包含缓存, +> 不重复加思维 token。旧日志没有这些字段时,输入取 conversations/gen_metadata 的完整上下文, +> 再回落到完整 transcript 字符上下文;输出/思维按 CJK×1 + 其他×¼ 估算。每轮都处理其上下文, +> 不再用相邻上下文增长量代替输入。库内 usage_quality=3 为实数、2 为上下文估算、1 为字符估算。 +> 常规/full 日志去重,实数可覆盖旧估算,晚到 metadata 即使 transcript 不变也会重试。 +> 工具名统一显示 Antigravity,页面底部的数据源悬浮说明保留新旧日志的计量口径;这里的 token 和 API 等价费用不代表 Google 官方剩余额度或账单。 + +## 快速开始 + +> **Codex 历史少算?** 源码已支持 `.jsonl`、`.jsonl.zst`、`.jsonl.zstd`,采集器 v5 会补扫仍在本机的压缩历史。缺失的日期会同时影响落在该范围内的 7/30 天统计;本地恢复不会自动回填社区云榜。解码要求、日期口径与恢复步骤见 [Codex 压缩历史说明](./docs/CODEX-COMPRESSED-HISTORY.md)。 + +三种用法,按"要不要长期用"来选。 + +### 方式一:npx 试用(什么都不装) + +```bash +npx --yes token-watcher@latest serve +open http://127.0.0.1:8787 +``` + +关掉终端即停止,适合先看看有没有用。 + +两个参数都别省:`--yes` 跳过安装确认;`@latest` 绕开 npx 的版本缓存——不加的话它 +可能跑到一个早先缓存的旧版本。另外 npx 会优先解析**本地**依赖,所以如果你正好在 +本仓库目录里执行,跑的其实是仓库代码而非 npm 上的包。 + +### 方式二:npm 全局安装(长期使用,推荐) + +```bash +npm install -g token-watcher +token-watcher serve +``` + +装完除了 `serve`,还多出两个只有全局安装才方便用的能力: + +```bash +# 常驻 + 开机自启(macOS,崩溃自动拉起) +token-watcher install-agent +token-watcher install-agent --port 9000 +token-watcher uninstall-agent + +# 菜单栏胶囊(macOS) +token-watcher bar +token-watcher bar --port 9000 # 服务不在默认端口时 +``` + +`install-agent` 会把 node 与入口脚本的**绝对路径**固化进 LaunchAgent——launchd 的 +PATH 是系统默认,既不含 npm 全局 bin(前缀还可能被改过),也不保证含 homebrew, +靠命令名会起不来。日志在 `~/.tokenmeter/logs/`。停用请用 `uninstall-agent` 或 +`launchctl bootout`,`launchctl stop` 会被 KeepAlive 立刻拉起。 + +菜单栏 app 以 universal 二进制随包发布(arm64 + x86_64,约 340KB),不需要 Xcode +工具链;从菜单里选「退出」可关闭。 + +三个命令名等价:`token-watcher` / `tokenwatcher` / `tokenmeter`(后者是旧名别名)。 + +### 方式三:克隆仓库(想改代码) + +```bash +git clone https://github.com/luwill/token-watcher.git +cd token-watcher && npm install +npm run serve # 首扫历史 ~3.3GB 约 6 秒,仅首次 +``` + +```bash +npm test # 回归测试,零依赖、离线可跑 +npm run build-bar # 重新编译菜单栏 app(需 Xcode CLT;发版时 prepack 会自动跑) +``` + +仓库里不含编译产物,`.app` 由 `prepack` 在发版前构建。所以克隆后想用菜单栏胶囊, +需先执行 `npm run build-bar`。 + +### 三种方式的差别 + +| | npx | 全局安装 | 克隆仓库 | +|---|---|---|---| +| 面板 / 采集 | ✓ | ✓ | ✓ | +| 菜单栏胶囊 | ✓ | ✓ | 需先 `npm run build-bar` | +| 开机自启 | 不建议 | ✓ | ✓(指向仓库路径)| +| 升级 | 每次拉最新 | `npm i -g token-watcher` | `git pull` | + +npx 下不建议装开机自启:生成的 LaunchAgent 会指向 npx 的缓存目录,而那个目录随时 +可能被 npm 清理,届时服务会静默起不来。要常驻就用全局安装。 + +### 方式四:Homebrew(macOS) + +```bash +brew install luwill/tap/token-watcher +token-watcher serve +``` + +formula 直接引用 npm registry 的 tarball(见 `packaging/homebrew/`),装出来的功能与 +`npm i -g` 完全一致(面板、采集、菜单栏胶囊、开机自启)。Homebrew 与 npm 装一份即可。 + +### serve 的端口与自动打开 + +- 未显式指定端口(默认 8787,可用环境变量 `TOKENMETER_PORT` 改)且被占用时,自动向后 + 尝试最多 20 个端口,面板照常起来;**显式 `--port` 被占则大声失败**——菜单栏胶囊等 + 指向固定端口,悄悄换一个更危险。 +- 交互式终端里启动 `serve` 会自动打开浏览器(launchd / CI 下 stdout 不是 TTY,不会开); + 不想开加 `--no-open`。 + +### 其他命令 + +```bash +token-watcher today # 终端速览今日消耗 +token-watcher today --json # 机器可读(可管道给 jq / 供 agent 调用) +token-watcher today --light # 纯 ASCII 表(CI/SSH 终端安全) +token-watcher sessions --day 2026-09-20 # 会话级统计(JSON) +token-watcher sessions --from 2026-09-01 --to 2026-09-30 --csv --out s.csv +token-watcher sessions --day 2026-09-20 --git # 每个会话挂上窗口内的 git 提交(产出归因) +token-watcher wrapped [--year 2026] [--json] # 年度报告(全年/最猛一天/连续天数/项目 Top) +token-watcher roi [--json] # 订阅 ROI(本月 API 等值 vs 实付) +token-watcher extra-roots [list|add|remove <路径>] + # 多机合并镜像目录的登记(改后需重启服务) +token-watcher doctor # 环境与数据源体检(node 版本/zstd/库完整性/逐源健康) +token-watcher uninstall # 摘除开机自启等本机痕迹 +token-watcher uninstall --purge-data --yes # 连 ~/.tokenmeter 数据目录一起删(不可恢复) +token-watcher scan # 只扫描一次 +token-watcher serve --port 9000 --no-open +token-watcher --version +``` + +`sessions --git` 的归因窗口是 `[会话首事件, 末事件 + 30 分钟]`(生成结束到落提交的常见 +滞后),从源文件首段读完整 cwd(events 表只存目录名,反推不唯一);非 git 项目的会话 +`commits` 保持 `null`,不编造。 + +### 合并另一台机器的用量(extra roots) + +多台电脑跑 Agent?把其他机器的数据目录镜像到本机,面板会把它并入同一工具的 +总账。去重键全局唯一,同一份会话同步两遍——包括同步盘的冲突副本——也只计一次。 +在 `~/.tokenmeter/extra-roots.json` 登记镜像目录(或用下面的 CLI),重启服务后 +生效(root 列表在进程启动时读取一次): + +```json +{ "claude-code": ["~/.tokenmeter/remote/home-pc/claude/projects"] } +``` + +```bash +token-watcher extra-roots list # 查看已登记的镜像目录 +token-watcher extra-roots add claude-code ~/mirror/home-pc/claude/projects +token-watcher extra-roots remove claude-code ~/mirror/home-pc/claude/projects +``` + +仪表盘顶部支持“全部设备 / Mac / VPS / Windows”筛选,图表、会话与CSV遵循相同设备范围;选择设备时仍保留全部设备累计总量。账户配额、余额和订阅ROI为共享信息。来源识别与历史兼容见[设备筛选说明](docs/DEVICE-FILTER.md)。 + +任何能把文件送到本机的传输都行,按机器条件选: + +| 传输方式 | jsonl 源(Claude Code、Codex、Grok…) | sqlite 源(ZCode、OpenCode) | +| --- | --- | --- | +| SSH + rsync 定时拉(cron / LaunchAgent) | ✅ | ✅ 拉的是静止副本 | +| Syncthing / 网盘持续同步 | ✅ 冲突副本已自动跳过 | ❌ 活库 WAL 会被同步盘撕裂 | +| U 盘 / 手动拷贝 | ✅ | ✅ 需先关闭源工具再拷 | + +- Cursor 无需合并:它的用量 CSV 是账号级的,任一台配了凭证的机器轮询到的就是 + 整个账号的用量。 +- `token-watcher doctor` 会显示每个镜像 root 及最近新数据的时间——同步悄悄断掉 + 会变得可见。 +- 社区排行榜:若 A 镜像了 B,只应有一台向榜单上报,否则 B 的用量在榜上双计。 + +该文件只存在于创建它的那台机器上——你的合并配置不会离开自己的数据目录。 + +## 运行要求 + +**Node ≥ 22.13**(`node:sqlite` 自该版本起免 `--experimental-sqlite` flag,零原生依赖)。 + +**dsh 源需要系统 `zstd`**。dsh 的会话快照是追加式多帧写入,必须用外部 zstd CLI 解压: +- macOS:`brew install zstd` +- Windows:`scoop install zstd` 或 `choco install zstd`,或从 [facebook/zstd releases](https://github.com/facebook/zstd/releases) 下载 `zstd.exe` 加入 PATH +- Linux:`apt-get install -y zstd` / `dnf install -y zstd` / `pacman -S zstd` +单个文件可达数千帧,而 Node 内置的 zstd 只解第一帧就结束且不报错,因此这里以外部 +`zstd` 为准,仅在确认文件只含单帧时才回落内置实现。未安装且遇到多帧文件时,该源会在 +健康自检里报错,而不是静默少算。其余数据源不需要它。 + +**平台支持:macOS 全功能验证**(含菜单栏 App 与 launchd)。Windows / Linux 上核心链路(采集、面板、API)随 CI 在 macOS + Ubuntu + Windows 三平台跑回归,包含"装成依赖后能否真正跑起来"的安装冒烟。菜单栏 App 与 launchd 开机自启为 macOS 专属。 + +开发与测试:`npm test` 运行五层回归——语法检查、import 冒烟(拦模块级错误,`node --check` 看不见)、静态断言、前端纯函数行为、端到端冒烟(fixtures 黄金数字 + 幂等 + API 结构)。零依赖、离线运行,GitHub Actions 上跑 macOS/Ubuntu × Node 22.13/24 矩阵。 + +## 功能 + +- **实时**:FSEvents 监听各数据目录 → 增量解析 → SSE 推送,面板秒级刷新(实测 <1s) +- **增量采集**:字节级游标 / sqlite 水位 / 快照重解析三种策略,二次扫描 0ms;dedup 键保证幂等;采集器版本号机制支持逻辑升级后自动全量回填 +- **统计面板**:指标卡、近一年 GitHub 风格日历热力图(每日/每周/累计)、按天/模型/工具图表、最近请求实时流 +- **会话钻取**:点击趋势图任意一天 → 会话列表(峰值上下文估算)→ 单会话逐请求 token 曲线 +- **工具活动**:四源工具调用频次榜 +- **配额**:Codex 官方配额直读(百分比/重置倒计时)、**Claude 官方配额**(复用 Claude Code + 本地 OAuth 凭证调官方用量端点,5 小时窗/周窗/模型专属周额度;无凭证时自动回落 5h 窗口推算) + Statuspage 公开接口,5 分钟刷新;厂商挂没挂一眼可辨) +- **订阅 ROI**:本月 API 等值 vs 订阅实付(`token-watcher roi`,月费在 subscriptions.json 配置) +- **积分账本**:Qoder 积分消耗卡(今日/累计,官方 credits 直读) +- **余额与费用**:DeepSeek/Kimi 余额轮询(读 `~/.ccmr/.env` 的 key,密钥不出后端);ccmr 侧按官方牌价折算(`~/.tokenmeter/pricing.json` 可编辑)+ 余额对账 +- **阈值通知**(菜单栏 App):Codex ≥80%/95%、余额 <¥10/¥2 弹 macOS 通知,级别上升只提醒一次 +- **数据源健康自检**:解析错误标红、"文件在写但无新事件"标黄——私有格式漂移不再静默失败 +- **导出与备份**:CSV 导出、会话/年度报告 CLI、每日 `VACUUM INTO` 备份保留 7 份 + +## 隐私 + +- 纯本地:只读取上述本地文件,**不上传任何用量数据**,面板只监听 127.0.0.1 且校验 Host(挡 DNS rebinding) +- API 密钥仅在服务进程内使用(调厂商余额接口),不入库、不进前端 +- 统计库只存聚合事件(时间/工具/模型/token 数/项目名),不存对话内容 + +**出站请求清单**(只有这四类,都不携带你的用量数据): + +| 用途 | 端点 | 频率 | +|---|---|---| +| USD→CNY 汇率 | `open.er-api.com`、`cdn.jsdelivr.net` | 12 小时,本地缓存兜底 | +| 模型牌价 | `raw.githubusercontent.com`(LiteLLM 价格表) | 24 小时,本地缓存兜底 | +| 厂商余额 | DeepSeek / Kimi 官方接口(带你的 key) | 30 分钟,连续失败自动熔断 | +| Claude 官方配额 | `api.anthropic.com/api/oauth/usage`(带 Claude Code 本地 OAuth token,macOS 首次可能弹一次钥匙串授权) | 10 分钟,无凭证自动跳过 | +| Cursor 用量账单 | `cursor.com/api/dashboard/export-usage-events-csv`(带本地凭证拼的会话 cookie) | 30 分钟,未装/未登录自动跳过 | +| GLM Coding Plan 积分配额 | `bigmodel.cn / api.z.ai` 的 `/api/monitor/usage/quota/limit`(带 ZCode 自己的本地 API key) | 10 分钟,未装/未订阅自动跳过;MCP 调用配额只读本地日志不出网 | + +要完全断网运行,设 `TOKENMETER_OFFLINE=1`:三类请求全部跳过,改用本地缓存与 +`~/.tokenmeter/pricing.json`(可设 `usd_to_cny` + `usd_to_cny_manual: true` 固定汇率)。 +回归测试即以此模式运行,不依赖公网。 + +费用解析优先级:用户 `pricing.json` → 已核实的官方价格与来源限定别名 → LiteLLM 缓存牌价。 +Gemini 3.8 Flash 的 Antigravity 内部名、Cursor MAX/Fast 等模式在价格层解析,保留原始用量记录。 +缓存读、缓存写分别计价,明确免费模型按零价识别;自动路由或身份未知的模型继续标记缺价。 +订阅工具显示 API 等值估算;Cursor/Grok 多次调用汇总无法还原单次长上下文档位,按标准档估算。 +详见 [2026-10-14 全模型价格核对](docs/MODEL-PRICING-AUDIT-2026-10-14.md)。 + +## 免责声明 + +Token Watcher 是**非官方**工具,解析的均为各产品留在本地的**私有格式**(无稳定性承诺),上游版本升级可能导致个别源解析中断——健康面板会标出,欢迎提 issue。各产品版权归其厂商所有。 + +## 架构 + +详见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。核心:`src/collectors/*` 每源一个适配器(增量 + dedup + 版本号),`src/scanner.js` 统一调度,SQLite 归一化事件表,`src/server.js` HTTP API + SSE,`web/` 零构建前端(ECharts UMD)。 + +维护流程见 [每日维护 Loop 与 CI/CD](docs/MAINTENANCE-LOOP.md):定时扫描 PR/Issue 和 CI 变化、有限修复与草稿 PR;发布工作流默认演练,可在确认后使用 npm OIDC 发布已验证的同一安装包。 + +**添加新数据源**只需:写一个 collector(实现 `collect(store, {path, offset, state})` → `{inserted, newOffset, state}`)、在 `src/config.js` 注册、前端加一个颜色。见 [CONTRIBUTING.md](CONTRIBUTING.md)。 + +## Roadmap + +- 跨平台验证(Linux/Windows) +- 订阅 ROI 视角:API 等值成本 vs 订阅实付 +- Grok 官方配额直连(ZCode/GLM 已做) +- Cursor / Copilot 数据源(待拿到可验证的真实数据布局) +- i18n / 英文 README + +## 更新日志 + +### 1.9.5(2026-10-14) + +- **Antigravity 名称显示**:移除固定的「≈」,底部数据源悬浮说明解释新版真实用量与旧日志估算;统计口径保持不变。 +- **OpenCode 大库扫描**:仅取所需 JSON 字段并逐行读取,避免完整工具输出占满 JS 堆;保持 SQLite 语句存活,修复 Node 22.13 下 GC 导致的扫描失败([#15](https://github.com/luwill/token-watcher/pull/15))。 +- **会话详情显示**:失败或空数据清除旧图;迟到响应和同 ID 重试不会覆盖最新选择。无会话 ID 的行不再可点击,标题加入工具名([#14](https://github.com/luwill/token-watcher/pull/14))。 +- Node 22.13/24 回归、强制 GC、48 MiB 堆大库扫描及请求乱序测试通过;详见 [1.9.5 说明](docs/RELEASE-1.9.5.md)。 + +### 1.9.4(2026-10-14) + +- 补采已验证的 Desktop 本地 Cowork transcript/audit;按消息 ID 跨格式去重,标准 transcript 优先,镜像、重扫与重开不双计。 +- 修复 Windows 盘符、UNC 和 tilde 扩展目录校验;CLI 与配置加载共用规则。 +- 旧键合并与游标提交同事务,失败回滚、保留缺源历史;30 项定向及 863 项完整隔离回归通过。 +- Chat/未镜像云端和 result 汇总仍有覆盖边界,升级前先备份;详见 [1.9.4 说明](docs/RELEASE-1.9.4.md)。 + +### 1.9.3(2026-10-14) + +- **每日维护 Loop**:09:00(Asia/Taipei)完整分页扫描 PR/Issue 与 CI 变化,增量快照避免重复待办;有限修复和草稿 PR,合并与发版需维护者确认。 +- **受验证的发布流程**:版本标签、main 祖先关系和完整 CI 通过后构建 universal 安装包并验证干净安装;默认演练,标签先生成 Release 草稿。正式发布通过 npm OIDC 与 GitHub 环境审核进行,发布包与 registry 逐字节核对后再公开 Release。 +- **草稿复用保护**:正式发布前与公开前核对已有草稿安装包和校验文件;相同产物可继续公开,附件不一致/缺失或读取失败则停止。 +- 完整隔离回归 829 项通过;保留 1.9.2 的 Antigravity 用量和模型定价修复,详见 [维护协议](docs/MAINTENANCE-LOOP.md)。 + +### 1.9.2(2026-10-14) + +- **Antigravity 真实用量与历史补正**:优先读取逐轮非缓存输入、缓存读和含思维输出;旧日志按完整上下文估算,不再按相邻轮差分。实数可覆盖旧估算,常规/full/重放日志统一去重,ACTIVE 不抢占 DONE。 +- **晚到 metadata 与刷新**:会话结束后 transcript 不变化也重试待补元数据;仅补正已有记录时通知面板,质量标记防止估算覆盖实数。采集版本 v3 会重扫现存日志,升级前建议生成一致 SQLite 备份,见 [恢复说明](docs/ANTIGRAVITY-RECOVERY-2026-10-14.md)。 +- **补齐官方模型价格与来源别名**:Gemini 4 Argon、Antigravity Gemini 3.8 Flash、Step 5 Preview、Cursor Opus 5.5 MAX、Grok 4.7 Fast/Build、Kimi K3、Hy4 与完整 OpenRouter ID;免费名称按零价识别,身份未知的自动路由名称保留缺价。 +- **缓存与模式计价**:缓存读/写分别计费,覆盖 Google 优惠截止、Cursor MAX/Fast 加价和 Grok 上下文档位;用户价格仍优先,费用卡/按天图/ROI 保持一致。订阅工具是现价 API 等值估算,多次调用汇总按标准档估算,见 [价格核对](docs/MODEL-PRICING-AUDIT-2026-10-14.md)。 +- 完整隔离回归 820 项通过,真实日志独立逐键对账、重复重扫幂等、在线费用 API 与实际图表验收通过。 + +### 1.9.1(2026-10-13) + +- **大体积 Codex 压缩归档改为流式解析**([#12](https://github.com/luwill/token-watcher/issues/12)):解压后超过 V8 字符串上限(~512MiB)的 `.jsonl.zst` 归档此前每轮扫描都报解析错误,现改为 `zstd -dc` 边解边逐行消费(无 zstd CLI 时回落内置整缓冲实现,边界不变);行语义与原整串解析严格一致,截断/损坏档案仍按单文件错误整体回滚,绝不静默吞掉。 +- **会话钻取修复**([#11](https://github.com/luwill/token-watcher/pull/11),感谢 [@amenjason936](https://github.com/amenjason936);1.9.0 的 npm 包已提前包含):`/api/session/:sid` 接受含 `.`/`:`/空格等文件名字符的真实会话 ID(原白名单正则会静默 400),残缺百分号编码按 400 处理而非 500;钻取按 `(tool, session_id)` 取数,两个工具的同名会话不再被合并成一条。 +- Homebrew 安装路径修正为 `brew install luwill/tap/token-watcher`(原文档指向不存在的仓库);tap 同步补到 1.9.0。 +- 三平台 CI 全绿,新增流式解压等价回归(与整串解析输出全等)与坏档抛错检查。 + +### 1.9.0(2026-10-13) + +- **多机用量合并(extra roots)**:把另一台机器(VPS / 家里电脑)的数据目录用任意传输(SSH + rsync 定时拉、Syncthing / 网盘持续同步、手动拷贝)镜像到本机,在 `~/.tokenmeter/extra-roots.json` 登记后并入同一工具的总账。去重键全局唯一,同步冲突副本自动跳过,不会双计;逐请求粒度、会话钻取、5h 窗口估算全量覆盖。sqlite 源(ZCode / OpenCode)请勿走同步盘(活库 WAL 会被撕裂,用 rsync 或关工具后拷贝);Cursor 是账号级数据无需合并。新增 `token-watcher extra-roots list|add|remove`,`doctor` 显示镜像目录的文件数与新鲜度(7 天无新数据提示疑似同步中断)。多机合并下社区排行榜只应由一台机器上报,否则镜像方与被镜像方双计。 +- **采集与守护修复批**(感谢 [@amenjason936](https://github.com/amenjason936) 的 [#3](https://github.com/luwill/token-watcher/pull/3)–[#7](https://github.com/luwill/token-watcher/pull/7)):服务端口重试不再泄漏后台任务与定时器;ROI 对无牌价模型如实显示「缺价」而非 ×0;菜单栏正确解码 Codex 多窗口配额;采集器写库改为暂存回放(事务不跨 await);Cursor CSV 按 RFC4180 解析;测试套件与调用方环境完全隔离。 +- **游标清理按路径段归属 root**([#9](https://github.com/luwill/token-watcher/pull/9),感谢 @amenjason936):配置任意镜像目录后,同前缀的兄弟目录(如 `/a/mac` 与 `/a/mac-old`)的游标行不再被误删。 +- **Codex 键迁移归档搬出热库**([#10](https://github.com/luwill/token-watcher/pull/10),感谢 [@xmasdong](https://github.com/xmasdong)):一次性恢复归档移入同目录独立文件,不再占用统计库、也不再被每日快照复制 7 份(贡献者实测 1.2 GB 库降至 264 MB)。恢复仍可查(ATTACH 侧库),搬家逐行核对后才删原表,失败保持原样下次重试。 +- 三平台 CI(macOS / Ubuntu / Windows × Node 22.13 / 24)全绿,新增多机合并、冲突副本、CLI 登记台与归档搬家的定向检查。 + +### 1.8.4(2026-10-10) + +- **补回 Codex 压缩日志中的历史用量**(感谢 [@horacehht](https://github.com/horacehht) 的 [#2](https://github.com/luwill/token-watcher/pull/2)):支持 `.jsonl.zst` / `.jsonl.zstd`,普通、压缩与归档副本统一去重;采集器 v5 首次运行会重扫仍在本机的历史。压缩文件解码失败会回滚当前文件,不覆盖其已有记录。 +- **统一 7/30 天统计窗口**:本地卡片、费用、图表、CSV 和工具活动按含今天的本地自然日计算,跨夏令时仍保持日期边界;排行榜客户端用量及主力模型按含今天的 UTC 自然日计算,与服务端窗口对齐。 +- **归一 Cursor 模型名**:合并代理前缀与推理强度后缀,避免同一模型分裂成多条统计。 +- Node 22.13 读取压缩日志需要系统 `zstd`;有内置 zstd 的 Node 可回落解码单帧,多帧仍需要 CLI。更新可恢复本地现存日志,**不会自动补报云榜过去日期**,详见 [恢复说明](./docs/CODEX-COMPRESSED-HISTORY.md)。 +- 隔离完整回归 644 项通过,新增压缩日志与日期窗口定向检查 18 项。 + +### 1.8.3(2026-09-30) + +- **修复睡眠唤醒后服务"活着但不工作"**:合盖睡眠再唤醒后,进程还在、端口开着,面板却打不开、 + 也不再采集新数据,只能手动重启。原因是内部线程池(默认 4 个线程)被占满——唤醒时网络未恢复, + 几个外网请求同时卡在域名解析上,文件读取全部排不上队。现在: + - 外网请求发出前先检查域名解析,同时最多让 2 个解析占用线程 + - 线程池从 4 个加大到 16 个(Windows 需在启动前自行设置 `UV_THREADPOOL_SIZE=16`) + - 新增卡死自检:连续约 5 分钟无法完成文件操作,或扫描没有任何进展,服务会打印原因并结束; + 由 `install-agent` 托管时会自动重启 +- 如果仍遇到卡死,重启前请执行 `sample <进程号> 5` 并反馈输出,可以确定卡在哪一步 + +### 1.8.2(2026-09-28) + +- **修复 dsh 用量多算**(感谢 [@xmasdong](https://github.com/xmasdong) 的 [#1](https://github.com/luwill/token-watcher/pull/1)): + dsh 会把同一次请求写成"流式用量"和"完整消息"两条记录,迁移到新格式后还会再抄进新快照, + 一次请求最多被算 3 次。现按"会话 + turn + step + 输入侧用量"认请求,新旧快照都读、 + 各自独有的请求都保留。新旧快照并存的会话越多影响越大(贡献者本机 55 亿 → 28 亿, + 作者本机 20.6 亿 → 19.98 亿) +- 升级后首次启动会自动重建 dsh 的历史数据(只动 dsh)。只清理能从原始日志确认的重复行, + 日志已不在的历史保持原样 +- 附独立核对脚本 `python3 test/dsh-audit.py`:只读、只输出汇总数字,可用来与面板对账 + +### 1.8.1(2026-09-28) + +- **修复 Codex 用量多算**:子代理 / 分叉 / 审查代理的会话文件,开头会整段重放父会话的历史。 + 这些请求父会话里已经计过,此前又被算了一遍——子代理用得越多,多算越多(作者本机 +23%, + 有用户反馈总量是 TokenTracker 的 2.4 倍)。现按"文件创建瞬间一次性写入"的时间特征识别并跳过, + 重放里的工具调用与配额快照同样不再计入 +- **补上此前漏算的三类 Codex 请求**:每个会话的第一次请求、会话恢复后计数器归零的那一次、 + 上下文压缩调用(合计约占 0.5%) +- 同一文件里交错的多条累计流(主代理 + 内嵌审查代理)各算各的,不再把累计差当成一次请求 +- 升级后首次启动会自动重建 Codex 的历史数据(只动 Codex,其余工具不受影响;本机 698 个 + 文件约 10 秒)。**Codex 的累计总量、费用估算与订阅 ROI 会相应下降,这是修正不是丢数据** +- 与 TokenTracker 1.1.2 在同一批日志上逐文件比对:Claude Code 相差 0.08%,Codex 相差 0.15% + +### 1.7.0(2026-09-21) + +- **GLM Coding Plan 官方配额**:ZCode 凭证直连官方 monitor 端点(`/api/monitor/usage/quota/limit`), + 面板新增「GLM Coding Plan」卡:5 小时 / 每周两个积分窗口的已用百分比、进度条与重置倒计时, + 积分明细(如 1,183 / 12,000)在悬浮提示里;套餐等级(如 Pro)作徽章。MCP 工具调用配额 + (1000 次/期)从 ZCode 本地日志读取(不出网),进 summary JSON(`quota.zcode.mcp`); + 快照陈旧度跟踪积分拉取时间,不会被活跃的本地日志刷成"新鲜" +- 窗口语义按实测归一:`percentage` 为已用百分比、`usage` 为总额度、`currentValue` 为已用、 + `unit=3` 为 5 小时窗、`unit=6` 为每周窗(重置落在固定周几);未验证过的 type/unit 组合如实显示原始值不硬贴标签 +- **Claude 官方配额卡恢复**:上游接口改版(`used_percent`→`utilization`、scoped 周窗移入 + `limits[]`)后归一化如实拒认、面板静默回落推算卡——现两种形状都认,官方卡与 Claude Code + 自带 /usage 视图同构(5 小时 / 每周 / scoped 模型如 Fable 各一条),并移入宽卡网格 +- 配额卡倒计时改紧凑格式(`3天 13:17` / `2:06:10`),紧凑卡 6 列布局下单行不再折行; + 订阅 ROI 行文案压缩(全称进悬浮提示)适配四卡同行 +- Windows:自动递增端口跳过系统保留段(Hyper-V/WinNAT 的 `EACCES`,CI 实测踩过), + 显式 `--port` 仍大声失败;zstd 单帧回落不再被压缩负载里的巧合帧魔数误判 +- 凭证纪律:ZCode key 与 Claude token 都只在服务进程内使用,不入库、不进前端 + +### 1.6.0(2026-09-21) + +- **新增订阅 ROI**:回答"我这个月 ¥X 的订阅值不值"。配置 `~/.tokenmeter/subscriptions.json` + 的月费后,面板出"订阅 ROI(本月)"卡、CLI 出 `token-watcher roi [--json]`:逐订阅对比 + 本月 **API 等值成本**(与费用卡同一条链路:pricing.json > 官方回退 > LiteLLM×汇率 + 峰谷)与 + **订阅实付**(price_cny / price_usd 二选一)。比值 ×N≥1 标"划算";积分制工具(Qoder) + 如实显示本月积分消耗、不硬造比值;卡片明确标注假设性口径(订阅含速率限制、API 可能 + 有折扣价)。未配置时若订阅制工具本月已有花费,显示引导卡与本月等值合计 +- 配置格式:`{"monthly": {"claude-code": {"name": "Claude Max", "price_usd": 200}, + "kimi": {"name": "Kimi 会员", "price_cny": 49}}}`(默认键为工具 id)。两个进阶字段: + `tool` 指定数据源 id(同一工具可拆多条订阅——GLM 与 MiniMax 的用量都落在 zcode 下); + `models` 按模型前缀过滤聚合范围。月费未填的条目仍显示 API 等值并标"月费未填" + +### 1.5.1(2026-09-21) + +- **修复模型 Top 10 与工具调用榜的悬浮提示张冠李戴**:横向条形图数据画图前做了 + 反转(大的在上),tooltip 却按原顺序数组取下标——悬停榜首显示的是末位模型的 + 调用次数(实测 deepseek-v4.1-flash 显示成 304 次,真实 13018 次;token 数值不受 + 影响)。两处统一改为从反转后数组取值,并补防回归断言。该数字已与原始文件独立 + 重算逐位核对一致 + +### 1.5.0(2026-09-21,已发布) + +对标 [TokenTracker](https://github.com/xiufengsun/TokenTracker) 补齐 11 项能力,同时守住 +本项目的差异化(实时、逐请求粒度、费用对账、零遥测、单包零依赖)。 + +- **新增 Kimi Code、Qoder、Antigravity 三个数据源 + Cursor 账单(共 13 源,Kiro 移除)。** + Kimi Code 解析 `~/.kimi-code/sessions/**/wire.jsonl`(step.end 用量,三种形状自适应, + 项目名来自 workspaces.json);Qoder 解析 `~/.qoder{,-cn}/projects/**`(实测本地只报 + 积分不报 token:积分入独立账本出卡,token 事件待上游恢复上报自动生效)。 + Gemini CLI 上游已于 2026-06-18 官方过渡并入 Antigravity CLI,不再单列。 + 当时 Antigravity 为**估算口径**(旧版日志无逐请求 usage;新版支持见顶部口径说明):输入取 conversations + db 的 gen_metadata 权威上下文增量,输出/思维链按字符估算(CJK×1+其他×¼,与 + TokenTracker 同法),界面标注「估算」;估算法在本机 744 条真实事件上与独立重算逐条 + 核对一致,且处理了上游 transcript 重放历史行的去重(step_index 重复即跳过,防上下文 + 双重膨胀与差分负值覆盖原计费)与权威值晚到的原地补正。三源均在本机真实数据上独立重算逐条核对 +- **Claude 官方配额卡。** 复用 Claude Code 本地 OAuth 凭证(macOS 钥匙串 / Linux·Win 的 + `~/.claude/.credentials.json`)调官方用量端点,显示 5 小时窗、周窗与模型专属周额度的 + 官方百分比和重置倒计时;无凭证/离线时自动回落原有 5h 推算卡。token 只在服务进程内 + 使用,不入库不进前端;`TOKENMETER_NO_KEYCHAIN=1` 可禁用钥匙串读取 +- **CLI 补全**:`today --json`(机器可读,stdout 纯 JSON)与 `--light`(纯 ASCII); + `sessions --day/--from/--to --json/--csv --out`(会话级统计导出);`wrapped`(年度 + 报告,含最猛一天/连续天数/项目 Top,`--json` 可编程消费);`doctor`(环境 + 库完整性 + + 逐源健康体检,有 error 退出码非 0);`uninstall`(摘 LaunchAgent,`--purge-data` + 才动数据目录且需确认);`--version` / `--help` +- **sessions --git 提交归因**:从源文件首段读完整 cwd(events 只存目录名),在 + `[首事件, 末事件+30min]` 窗口内挂 git 提交——把"花了多少 token"连到"产出了哪些 commit"; + 非 git 会话 `commits=null` 不编造 +- **serve 体验**:默认端口被占时自动向后尝试(显式 `--port` 仍大声失败,防菜单栏胶囊 + 连不上);交互终端自动打开面板(`--no-open` 可关,launchd 下不会开);`TOKENMETER_PORT` + 环境变量可改默认端口 +- **Homebrew 分发**:`brew install luwill/tap/token-watcher`(formula 在 + `packaging/homebrew/`,直连 npm tarball) +- Claude 官方配额卡的重置倒计时按 ISO 时间归一,不再恒显 `--` + +### 1.4.4(2026-09-18,未单独发布,随 1.5.0 一同上线) + +- **修复 Codex plus 用户的配额卡显示成 5 小时额度。** Codex 上报的配额分 primary / secondary + 两个位置,但位置不代表窗口:plus 套餐 primary 是 5 小时、secondary 才是周额度;pro 只有 + primary,且是周额度。此前一律取 primary 并标成"周配额",plus 用户看到的其实是 5 小时额度 + (卡片上"0 天窗口"就是 300 分钟被当成天数取整的结果)。现在按窗口时长识别,卡片更名为 + 「Codex 配额」,有几个窗口就显示几条:plus 显示 5 小时与每周两条,pro 只显示每周 +- 修复配额被非主额度的快照顶掉:使用 GPT-5.3-Codex-Spark 时 Codex 会上报一份独立额度, + 另有一种窗口全空的快照,此前只要它们是最新一条,就会让配额卡显示成 Spark 的额度或 0% +- 升级前存下的旧快照无需重扫即可正确显示;下一次使用 Codex 时自动换成完整的窗口数据 +- 重置倒计时满一天时显示"3天23时58分",不再显示成"95时58分" +- 修掉一条会随运行时刻变红的测试:美元折算用例的事件取"1 小时前",落在 DeepSeek 谷时就被减半 + +### 1.4.3(2026-09-17) + +- **修复 OpenCode 漏采约八成消息。** OpenCode 的 assistant 消息是"先插后改":开始生成就插入 + 一行(用量全 0),生成结束才原地更新写入用量。采集器按 rowid 增量,而服务监听数据库写入、 + 生成过程中就会触发扫描——这行被当成 0 用量跳过,水位却越过了它,完成后的更新再也读不到。 + 面板表现为 OpenCode 明明在用却显示"疑似停更"。现改为按 `time_updated` 增量;升级后自动 + 全量重扫,补回历史漏采的消息(实测 99 条中原先只采到 20 条,修复后 99/99、token 总量逐一吻合) +- 修掉一条会随日期自己变红的测试:余额对账用例把事件时刻写死在某一天,而对账窗口是"最近 24 小时" + +### 1.4.2(2026-09-16) + +- **修复 1.4.1 引入的回归:dsh 整源静默归零。** 1.4.1 把 zstd 解压改为优先使用 Node + 内置实现(为解决 launchd 下找不到外部 zstd 的问题),但内置的 `zstdDecompressSync` + 与 `createZstdDecompress` 都只解**第一帧**就结束且不报错。而 dsh 是追加式多帧写入, + 实测单个会话文件有 5800+ 帧——外部 zstd 解出 19MB,内置实现只解出 226 字节。 + 现改为以外部 zstd 为准(仍显式试常见绝对路径以解决 launchd 的 PATH 问题), + 仅在确认文件只含单帧时才回落内置实现,多帧又无外部 zstd 时大声报错而非静默少算。 + **使用 dsh 源的用户请从 1.4.1 升级。** + +- 新增 `token-watcher install-agent` / `uninstall-agent`,把服务装成 macOS 开机自启项。 + 此前 README 指向 `npm run install-agent`,但 npm scripts 对全局安装的用户不可见, + 且那条命令只负责 `launchctl bootstrap`、从不生成 plist,plist 也不随包发布—— + 全局安装的用户实际没有可用的常驻方案 +- 生成的 plist 固化 node 与入口脚本的绝对路径。launchd 的 PATH 不含 npm 全局 bin 与 + homebrew,而入口脚本的 shebang 是 `#!/usr/bin/env node`,靠命令名无法启动 +- 检测到旧 `com.tokenmeter.server` 仍在运行时拒绝安装(两者抢同一端口),可用 `--force` 覆盖 +- 新增 `token-watcher bar`,菜单栏胶囊随包发布。此前 `.app` 只存在于仓库、也不在 + 发布白名单里,全局安装的用户拿不到,而指引用的 `npm run bar` 同样不可见 +- 菜单栏 app 改为 universal 二进制(arm64 + x86_64)。此前产物只有 arm64,Intel Mac + 上无法运行;`prepack` 会在发版前自动重新编译,避免发出陈旧或缺失的产物 +- 菜单栏 app 不再写死 8787 端口,`serve --port` 的用户不会再拿到连不上的胶囊 +- 修掉菜单栏的数量换算:「亿」档阈值错写成 1e6 却按 1e8 换算,200 万会显示成 "0.0亿" + +### 1.4.1(2026-09-16) + +修掉一批让面板数字低于厂商账单的问题。起因是实测发现面板显示 DeepSeek 当日 ¥8.34, +而官方后台是 ¥58.24。定价表本身无误,问题全在数据进库之前。 + +- **ccmr 的输出 token 被钉死在 0**:Claude Code transcript 把一次响应按 content block + 拆成多行,只有终结块带真实 `output_tokens`;ccmr 网关又不写 `requestId`,分片塌成 + 同一去重键,先到者胜留下的正是 output=0 的首块。实测单日丢掉 94.5% 的输出量。 +- **dsh 自 2026-08-14 起整源静默归零**:上游把会话结构换到 v3(`assistant/chunk` → + `assistant/message`,`data.chunk.usage` → `data.usage`),采集器一条也匹配不上, + 却照常返回 0 条且不报错。全库停在 165 条,而恢复后单日就有 790 次请求 / 1.07 亿 token。 +- **守护进程解不开 zstd**:launchd 的 PATH 不含 homebrew 目录,dsh 快照只有手动 + `scan` 才解得开。改为优先使用 Node 内置 zstd(≥23.8),旧版本回落外部可执行文件。 +- **DeepSeek 峰谷价未实现**:谷时用量一律按峰时价折算。峰时为 UTC 周一至周五 + 01:00-04:00 与 06:00-10:00,其余时段半价。 +- **余额对账漏算同账户的工具**:`computeRecon` 写死只统计 ccmr,而 dsh 花的是同一个 + DeepSeek 账户,导致对账永远显示巨大缺口。改为按注册表的 `apiBilled` 标记推导。 + +升级后首次扫描会全量重扫 ccmr 与 dsh 以补正存量数据。 + +### 1.4.0(2026-09-16) + +- 新增 **Pi** 与 **OpenCode** 两个数据源(共 9 源)。两者口径均逐条核对 + `total == input + output + cacheRead + cacheWrite` 后才接入 +- 健康自检的工具清单改为从数据源注册表推导。此前是另抄的一份硬编码数组,两份清单 + 必然漂移,而漏登记的源不会报错,只是从健康自检里静默消失 + +### 1.3.1(2026-09-15) + +- **装成依赖后面板全空**:ECharts 路径写死了嵌套 `node_modules`,而 npm 安装时会把 + 依赖提升到顶层,于是 `/vendor/echarts.min.js` 返回 404、前端整个挂掉。改为交给 + Node 自己的解析算法 +- 修掉两处 Windows 专属问题(目录分隔符解析) + +### 1.3.0(2026-09-15,未发布) + +- 补上与包名同名的 `token-watcher` 命令入口。改名后 bin 有多个条目且都不叫包名, + `npx token-watcher` 失去确定的解析依据 + +### 1.2.0(2026-09-15) + +- **服务不再会悄悄退出**:扫描时源文件被删(Claude 清理会话、Codex 归档搬移都是常态) + 会抛 ENOENT 冲垮整轮扫描,而这发生在防抖定时器回调里,等于未处理 rejection +- `scanning` 标志异常后不复位会让后续每轮扫描都被"并发中"挡掉,面板从此停更 +- 补齐路由错误处理与 `busy_timeout`,一次 SQLITE_BUSY 不再能杀掉进程 +- 清理已消失文件的游标行(只清 `files`,不动 `events`) + +### 1.1.0(2026-09-15) + +- 汇率实时化:公开接口拉取 USD/CNY,12 小时刷新 + 断网兜底 +- 新增逐日 token 消耗图与按天 × 模型堆叠花费图(Top6 + 其他聚合,低价模型不再被静默丢弃) +- 布局重构为两列严格等高对齐 +- 回归测试强化 + +### 1.0.0(2026-09-14) + +- 首个版本:本地多源 AI Agent 用量与配额实时面板,7 源采集 +- 费用折算接入 LiteLLM 实时牌价 +- GitHub 风格热力图、会话钻取 +- 三层回归测试(语法 / 静态断言 / 端到端冒烟) + +## License + +[MIT](LICENSE) diff --git a/src/doctor.js b/src/doctor.js index 298779b..a32c270 100644 --- a/src/doctor.js +++ b/src/doctor.js @@ -1,118 +1,119 @@ -import { execFileSync } from 'node:child_process'; -import { existsSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; -import { homedir } from 'node:os'; -import { SOURCES, DB_PATH, DATA_DIR, EXTRA_ROOTS, isOffline } from './config.js'; -import { isUnderRoot } from './scanner.js'; -import { computeHealth } from './server.js'; -import { zcodeQuotaTargets } from './zcodeQuota.js'; -import { readClaudeOauthToken } from './claudeUsage.js'; -import { getLeaderboardState } from './leaderboard.js'; - -/** - * token-watcher doctor:把面板上的健康自检带到终端,外加环境与库体检。 - * 退出码:0 = 全绿或有警示(stale/empty);1 = 存在 error(解析失败/库损坏)。 - */ -const pad = (s, n) => String(s).padEnd(n, ' '); -const ICON = { ok: '✓', empty: '·', stale: '⚠', error: '✗' }; - -export async function runDoctor(store, scannerStats, { log = console.log } = {}) { - const problems = []; - - log('环境'); - const v = process.version; - const [maj, min] = v.slice(1).split('.').map(Number); - const nodeOk = maj > 22 || (maj === 22 && min >= 13); - log(` ${nodeOk ? '✓' : '✗'} node ${v}(要求 ≥22.13,node:sqlite 免 flag)`); - if (!nodeOk) problems.push('node 版过低'); - log(` ${isOffline() ? '⚠' : '✓'} 离线模式:${isOffline() ? '已开启(跳过汇率/牌价/余额/配额外网请求)' : '关闭'}`); - - // dsh 源依赖外部 zstd:有 dsh 数据却解不开是最常见的"整源归零"成因 - const hasDsh = !!store.db.prepare("SELECT 1 FROM events WHERE tool = 'dsh' LIMIT 1").get(); - if (hasDsh) { - const zstd = (() => { - try { execFileSync('zstd', ['--version'], { stdio: 'ignore', timeout: 3000 }); return true; } - catch { return false; } - })(); - log(` ${zstd ? '✓' : '⚠'} 系统 zstd(dsh 多帧会话必需):${zstd ? '可用' : '未找到,brew install zstd'}`); - } - - log('数据库'); - const integrity = String(store.db.prepare('PRAGMA quick_check').get()?.quick_check ?? 'unknown'); - log(` ${integrity === 'ok' ? '✓' : '✗'} PRAGMA quick_check: ${integrity}`); - if (integrity !== 'ok') problems.push('数据库完整性检查失败'); - const { n, total } = store.countEvents(); - log(` ✓ ${DB_PATH}(${n} 事件 / ${(total / 1e6).toFixed(1)}M tokens)`); - try { - const backups = readdirSync(join(DATA_DIR, 'backups')).filter(f => f.endsWith('.db')); - log(` ${backups.length ? '✓' : '⚠'} 每日备份:${backups.length ? `最近 ${backups.slice(-1)[0]}(共 ${backups.length} 份)` : '尚无(服务跑满 30 秒后生成)'}`); - } catch { log(' ⚠ 每日备份:尚无(服务跑满 30 秒后生成)'); } - - log('数据源'); - const health = computeHealth(store.db, scannerStats || {}); - for (const h of health) { - const src = SOURCES.find(s => s.tool === h.tool); - const existsOnDisk = (src?.roots || []).some(r => existsSync(r)); - const icon = ICON[h.status] ?? '?'; - const tail = []; - if (!existsOnDisk) tail.push('未安装(正常跳过)'); - if (h.status === 'stale') tail.push('文件在写但无新事件,疑似格式漂移'); - if (h.status === 'error') tail.push(`解析失败:${h.last_error ?? ''}`); - if (h.last_error && h.status !== 'error') tail.push(`上次错误:${h.last_error}`); - log(` ${icon} ${pad(src?.label ?? h.tool, 12)} ${pad(h.status, 5)} 事件 ${String(h.events).padStart(7)} ${tail.join(';')}`); - if (h.status === 'error') problems.push(`${h.tool} 解析失败`); - } - - // extra roots:多机合并的镜像目录要看得见——同步悄悄断了没人发现,统计会静默缺量 - log('扩展 root(多机合并镜像)'); - const extraPairs = Object.entries(EXTRA_ROOTS).flatMap(([t, roots]) => roots.map((r) => [t, r])); - if (!extraPairs.length) { - log(' · 未配置(可选;多台机器用量合并的配方见 README「extra roots」)'); - } else { - for (const [tool, root] of extraPairs) { - if (!existsSync(root)) { log(` ⚠ ${tool} ${root} 目录不存在(未同步/未挂载?恢复后自动续扫)`); continue; } - let count = 0, newest = 0; - for (const r of store.db.prepare('SELECT path, mtime_ms FROM files WHERE tool = ?').all(tool)) { - if (!isUnderRoot(r.path, root)) continue; - count++; - if (r.mtime_ms > newest) newest = r.mtime_ms; - } - if (!count) { log(` · ${tool} ${root} 尚无数据(首次同步后自动入库)`); continue; } - const idleDays = Math.floor((Date.now() - newest) / 86400_000); - const when = new Date(newest).toLocaleString('zh-CN'); - log(` ${idleDays > 7 ? '⚠' : '✓'} ${tool} ${root}(${count} 文件,最新 ${when}${idleDays > 7 ? `,${idleDays} 天无新数据(远端未用或同步中断)` : ''})`); - } - } - - // 凭证探测只报"有没有",不打印任何值 - log('凭证(只探测存在性,不显示内容)'); - const envPath = join(homedir(), '.ccmr', '.env'); - log(` ${existsSync(envPath) ? '✓' : '·'} ${envPath}${existsSync(envPath) ? '' : '(无 → 余额轮询跳过,不影响其他功能)'}`); - const claudeTok = await readClaudeOauthToken().catch(() => null); - log(` ${claudeTok ? '✓' : '·'} Claude Code OAuth 凭证${claudeTok ? '(官方配额轮询可用)' : '(无 → 面板用 5h 窗口推算兜底)'}`); - const zcodeTargets = await zcodeQuotaTargets().catch(() => []); - log(` ${zcodeTargets.length ? '✓' : '·'} ZCode coding-plan API key${zcodeTargets.length ? `(GLM 配额轮询可用,${zcodeTargets.length} 个)` : '(无 → GLM 配额卡跳过,不影响其他功能)'}`); - - // 排行榜是唯一的出网数据通道,参与状态必须可见(默认关闭也如实展示,不制造"偷偷上报"疑虑) - log('社区排行榜'); - const lb = getLeaderboardState(store); - if (!lb.enabled) { - log(' · 未参与(默认;token-watcher leaderboard on <昵称> 加入,只上报聚合数字)'); - } else { - const when = lb.last_push_ms ? new Date(lb.last_push_ms).toLocaleString('zh-CN') : '尚未上报'; - log(` ${lb.last_error ? '⚠' : '✓'} 已参与(昵称「${lb.name}」→ ${lb.url})`); - log(` 上次上报 ${when}${lb.last_error ? ` · 失败:${lb.last_error}` : ''}`); - // 上报失败只警示不置 error:可选的虚荣功能不应把整个 doctor 判红 - } - - log(problems.length ? `结论:${problems.length} 项需要处理(见上)` : '结论:未发现问题'); - return { ok: problems.length === 0, problems }; -} - -/** 供 bin 使用:先扫一轮拿到新鲜 parse_errors,再体检 */ -export async function doctorCommand(store, { log = console.log } = {}) { - const { Scanner } = await import('./scanner.js'); - const scanner = new Scanner(store, { log: () => {} }); - await scanner.scanAll({ quiet: true }); - return runDoctor(store, scanner.stats, { log }); -} +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { SOURCES, DB_PATH, DATA_DIR, EXTRA_ROOTS, isOffline } from './config.js'; +import { isUnderRoot } from './scanner.js'; +import { zstdInstallHint } from './zstd.js'; +import { computeHealth } from './server.js'; +import { zcodeQuotaTargets } from './zcodeQuota.js'; +import { readClaudeOauthToken } from './claudeUsage.js'; +import { getLeaderboardState } from './leaderboard.js'; + +/** + * token-watcher doctor:把面板上的健康自检带到终端,外加环境与库体检。 + * 退出码:0 = 全绿或有警示(stale/empty);1 = 存在 error(解析失败/库损坏)。 + */ +const pad = (s, n) => String(s).padEnd(n, ' '); +const ICON = { ok: '✓', empty: '·', stale: '⚠', error: '✗' }; + +export async function runDoctor(store, scannerStats, { log = console.log } = {}) { + const problems = []; + + log('环境'); + const v = process.version; + const [maj, min] = v.slice(1).split('.').map(Number); + const nodeOk = maj > 22 || (maj === 22 && min >= 13); + log(` ${nodeOk ? '✓' : '✗'} node ${v}(要求 ≥22.13,node:sqlite 免 flag)`); + if (!nodeOk) problems.push('node 版过低'); + log(` ${isOffline() ? '⚠' : '✓'} 离线模式:${isOffline() ? '已开启(跳过汇率/牌价/余额/配额外网请求)' : '关闭'}`); + + // dsh 源依赖外部 zstd:有 dsh 数据却解不开是最常见的"整源归零"成因 + const hasDsh = !!store.db.prepare("SELECT 1 FROM events WHERE tool = 'dsh' LIMIT 1").get(); + if (hasDsh) { + const zstd = (() => { + try { execFileSync('zstd', ['--version'], { stdio: 'ignore', timeout: 3000 }); return true; } + catch { return false; } + })(); + log(` ${zstd ? '✓' : '⚠'} 系统 zstd(dsh 多帧会话必需):${zstd ? '可用' : '未找到,' + zstdInstallHint()}`); + } + + log('数据库'); + const integrity = String(store.db.prepare('PRAGMA quick_check').get()?.quick_check ?? 'unknown'); + log(` ${integrity === 'ok' ? '✓' : '✗'} PRAGMA quick_check: ${integrity}`); + if (integrity !== 'ok') problems.push('数据库完整性检查失败'); + const { n, total } = store.countEvents(); + log(` ✓ ${DB_PATH}(${n} 事件 / ${(total / 1e6).toFixed(1)}M tokens)`); + try { + const backups = readdirSync(join(DATA_DIR, 'backups')).filter(f => f.endsWith('.db')); + log(` ${backups.length ? '✓' : '⚠'} 每日备份:${backups.length ? `最近 ${backups.slice(-1)[0]}(共 ${backups.length} 份)` : '尚无(服务跑满 30 秒后生成)'}`); + } catch { log(' ⚠ 每日备份:尚无(服务跑满 30 秒后生成)'); } + + log('数据源'); + const health = computeHealth(store.db, scannerStats || {}); + for (const h of health) { + const src = SOURCES.find(s => s.tool === h.tool); + const existsOnDisk = (src?.roots || []).some(r => existsSync(r)); + const icon = ICON[h.status] ?? '?'; + const tail = []; + if (!existsOnDisk) tail.push('未安装(正常跳过)'); + if (h.status === 'stale') tail.push('文件在写但无新事件,疑似格式漂移'); + if (h.status === 'error') tail.push(`解析失败:${h.last_error ?? ''}`); + if (h.last_error && h.status !== 'error') tail.push(`上次错误:${h.last_error}`); + log(` ${icon} ${pad(src?.label ?? h.tool, 12)} ${pad(h.status, 5)} 事件 ${String(h.events).padStart(7)} ${tail.join(';')}`); + if (h.status === 'error') problems.push(`${h.tool} 解析失败`); + } + + // extra roots:多机合并的镜像目录要看得见——同步悄悄断了没人发现,统计会静默缺量 + log('扩展 root(多机合并镜像)'); + const extraPairs = Object.entries(EXTRA_ROOTS).flatMap(([t, roots]) => roots.map((r) => [t, r])); + if (!extraPairs.length) { + log(' · 未配置(可选;多台机器用量合并的配方见 README「extra roots」)'); + } else { + for (const [tool, root] of extraPairs) { + if (!existsSync(root)) { log(` ⚠ ${tool} ${root} 目录不存在(未同步/未挂载?恢复后自动续扫)`); continue; } + let count = 0, newest = 0; + for (const r of store.db.prepare('SELECT path, mtime_ms FROM files WHERE tool = ?').all(tool)) { + if (!isUnderRoot(r.path, root)) continue; + count++; + if (r.mtime_ms > newest) newest = r.mtime_ms; + } + if (!count) { log(` · ${tool} ${root} 尚无数据(首次同步后自动入库)`); continue; } + const idleDays = Math.floor((Date.now() - newest) / 86400_000); + const when = new Date(newest).toLocaleString('zh-CN'); + log(` ${idleDays > 7 ? '⚠' : '✓'} ${tool} ${root}(${count} 文件,最新 ${when}${idleDays > 7 ? `,${idleDays} 天无新数据(远端未用或同步中断)` : ''})`); + } + } + + // 凭证探测只报"有没有",不打印任何值 + log('凭证(只探测存在性,不显示内容)'); + const envPath = join(homedir(), '.ccmr', '.env'); + log(` ${existsSync(envPath) ? '✓' : '·'} ${envPath}${existsSync(envPath) ? '' : '(无 → 余额轮询跳过,不影响其他功能)'}`); + const claudeTok = await readClaudeOauthToken().catch(() => null); + log(` ${claudeTok ? '✓' : '·'} Claude Code OAuth 凭证${claudeTok ? '(官方配额轮询可用)' : '(无 → 面板用 5h 窗口推算兜底)'}`); + const zcodeTargets = await zcodeQuotaTargets().catch(() => []); + log(` ${zcodeTargets.length ? '✓' : '·'} ZCode coding-plan API key${zcodeTargets.length ? `(GLM 配额轮询可用,${zcodeTargets.length} 个)` : '(无 → GLM 配额卡跳过,不影响其他功能)'}`); + + // 排行榜是唯一的出网数据通道,参与状态必须可见(默认关闭也如实展示,不制造"偷偷上报"疑虑) + log('社区排行榜'); + const lb = getLeaderboardState(store); + if (!lb.enabled) { + log(' · 未参与(默认;token-watcher leaderboard on <昵称> 加入,只上报聚合数字)'); + } else { + const when = lb.last_push_ms ? new Date(lb.last_push_ms).toLocaleString('zh-CN') : '尚未上报'; + log(` ${lb.last_error ? '⚠' : '✓'} 已参与(昵称「${lb.name}」→ ${lb.url})`); + log(` 上次上报 ${when}${lb.last_error ? ` · 失败:${lb.last_error}` : ''}`); + // 上报失败只警示不置 error:可选的虚荣功能不应把整个 doctor 判红 + } + + log(problems.length ? `结论:${problems.length} 项需要处理(见上)` : '结论:未发现问题'); + return { ok: problems.length === 0, problems }; +} + +/** 供 bin 使用:先扫一轮拿到新鲜 parse_errors,再体检 */ +export async function doctorCommand(store, { log = console.log } = {}) { + const { Scanner } = await import('./scanner.js'); + const scanner = new Scanner(store, { log: () => {} }); + await scanner.scanAll({ quiet: true }); + return runDoctor(store, scanner.stats, { log }); +} diff --git a/src/zstd.js b/src/zstd.js index 9ae21d1..bec6d06 100644 --- a/src/zstd.js +++ b/src/zstd.js @@ -1,91 +1,110 @@ -import { execFile, spawn } from 'node:child_process'; -import { promisify } from 'node:util'; -import { readFile } from 'node:fs/promises'; -import zlib from 'node:zlib'; - -const execFileP = promisify(execFile); -const ZSTD_BINS = ['zstd', '/opt/homebrew/bin/zstd', '/usr/local/bin/zstd', '/usr/bin/zstd']; -const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]); - -/** Decompress a complete zstd stream, including concatenated frames. */ -export async function decompressZstd(path, { bins = ZSTD_BINS } = {}) { - for (const bin of bins) { - try { - const { stdout } = await execFileP(bin, ['-dc', path], { - encoding: 'buffer', maxBuffer: 1024 * 1024 * 1024, - }); - return stdout; - } catch (err) { - if (err.code !== 'ENOENT') throw err; - } - } - - if (typeof zlib.zstdDecompressSync === 'function') { - const buf = await readFile(path); - // Node's built-in decoder currently stops after the first frame. Confirm any candidate - // boundary by successfully decoding the prefix so payload bytes resembling magic do not - // create a false multi-frame diagnosis. - let multi = false; - let idx = buf.indexOf(ZSTD_MAGIC, 1); - while (idx !== -1) { - try { - zlib.zstdDecompressSync(buf.subarray(0, idx)); - multi = true; - break; - } catch { /* A magic-like sequence inside the frame payload is not a frame boundary. */ } - idx = buf.indexOf(ZSTD_MAGIC, idx + 1); - } - if (multi) throw new Error(`${path} 含多个 zstd 帧,Node 内置实现只能解第一帧。请安装 zstd:brew install zstd`); - return zlib.zstdDecompressSync(buf); - } - - throw new Error('zstd 不可用:PATH 与常见安装路径下均无 zstd,请执行 brew install zstd'); -} - -/** - * 流式解压并逐行产出(外部 zstd CLI:spawn + 增量切行)。 - * - * 大归档会话解压后可超 V8 字符串上限(~512MiB,issue #12:331MB 压缩档每轮扫描 - * parse error),整缓冲 + toString 的路走不通,必须边解边消费。行语义与旧的整串 - * 循环严格一致:按 \n 切分,尾部无换行的残行丢弃(压缩快照按完整行解析)。 - * 解压途中失败(文件截断/损坏)在产出结束后抛出——scanner 的逐文件事务会整体 - * 回滚,保持"坏档案是单文件错误而非静默跳过"。无 CLI 时回落内置整缓冲实现 - * (仅单帧,小文件场景;超大文件仍会撞上限,是无 CLI 环境的既有边界)。 - */ -export async function* decompressZstdLines(path, { bins = ZSTD_BINS } = {}) { - let child = null; - for (const bin of bins) { - const c = spawn(bin, ['-dc', path], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); - // 'spawn' = 进程真的起来了;起不来(ENOENT 等)异步走 'error',换下一个候选 - if (await new Promise(res => { c.on('spawn', () => res(true)); c.on('error', () => res(false)); })) { - child = c; - break; - } - } - if (!child) { - const text = (await decompressZstd(path, { bins })).toString('utf8'); - let start = 0, end; - while ((end = text.indexOf('\n', start)) !== -1) { yield text.slice(start, end); start = end + 1; } - return; - } - let errText = ''; - child.stderr.setEncoding('utf8'); - child.stderr.on('data', d => { if (errText.length < 4096) errText += d; }); - // setEncoding 后 chunk 是 string,跨 chunk 的多字节 UTF-8 由内部 StringDecoder 处理 - child.stdout.setEncoding('utf8'); - try { - let rest = ''; - for await (const chunk of child.stdout) { - rest += chunk; - let i; - while ((i = rest.indexOf('\n')) !== -1) { - yield rest.slice(0, i); - rest = rest.slice(i + 1); - } - } - const code = await new Promise(res => child.on('close', res)); - if (code !== 0) throw new Error(`zstd 解压失败(exit ${code}):${errText.trim().slice(0, 200)}`); - } finally { - if (child.exitCode === null && !child.killed) child.kill(); // 消费方提前退出时收掉子进程 - } -} +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import { readFile } from 'node:fs/promises'; +import zlib from 'node:zlib'; + +const execFileP = promisify(execFile); +const ZSTD_BINS = ['zstd', '/opt/homebrew/bin/zstd', '/usr/local/bin/zstd', '/usr/bin/zstd']; +const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]); + +/** + * 跨平台「如何安装 zstd」提示。dsh 多帧会话必须用外部 zstd CLI 解压, + * 但旧报错只给了 macOS 的 `brew install zstd`,Windows/Linux 用户无解。 + * 这里按 process.platform 给出对应包管理器命令或官方二进制下载地址。 + */ +export function zstdInstallHint() { + switch (process.platform) { + case 'win32': + return '请安装 zstd:scoop install zstd 或 choco install zstd;也可从 https://github.com/facebook/zstd/releases 下载 zstd.exe 并加入 PATH'; + case 'darwin': + return '请安装 zstd:brew install zstd'; + case 'linux': + return '请安装 zstd:apt-get install -y zstd(Debian/Ubuntu)| dnf install -y zstd(Fedora)| pacman -S zstd(Arch)'; + default: + return '请通过系统包管理器安装 zstd'; + } +} + + +/** Decompress a complete zstd stream, including concatenated frames. */ +export async function decompressZstd(path, { bins = ZSTD_BINS } = {}) { + for (const bin of bins) { + try { + const { stdout } = await execFileP(bin, ['-dc', path], { + encoding: 'buffer', maxBuffer: 1024 * 1024 * 1024, + }); + return stdout; + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + } + + if (typeof zlib.zstdDecompressSync === 'function') { + const buf = await readFile(path); + // Node's built-in decoder currently stops after the first frame. Confirm any candidate + // boundary by successfully decoding the prefix so payload bytes resembling magic do not + // create a false multi-frame diagnosis. + let multi = false; + let idx = buf.indexOf(ZSTD_MAGIC, 1); + while (idx !== -1) { + try { + zlib.zstdDecompressSync(buf.subarray(0, idx)); + multi = true; + break; + } catch { /* A magic-like sequence inside the frame payload is not a frame boundary. */ } + idx = buf.indexOf(ZSTD_MAGIC, idx + 1); + } + if (multi) throw new Error(`${path} 含多个 zstd 帧,Node 内置实现只能解第一帧。${zstdInstallHint()}`); + return zlib.zstdDecompressSync(buf); + } + + throw new Error(`zstd 不可用:PATH 与常见安装路径下均无 zstd。${zstdInstallHint()}`); +} + +/** + * 流式解压并逐行产出(外部 zstd CLI:spawn + 增量切行)。 + * + * 大归档会话解压后可超 V8 字符串上限(~512MiB,issue #12:331MB 压缩档每轮扫描 + * parse error),整缓冲 + toString 的路走不通,必须边解边消费。行语义与旧的整串 + * 循环严格一致:按 \n 切分,尾部无换行的残行丢弃(压缩快照按完整行解析)。 + * 解压途中失败(文件截断/损坏)在产出结束后抛出——scanner 的逐文件事务会整体 + * 回滚,保持"坏档案是单文件错误而非静默跳过"。无 CLI 时回落内置整缓冲实现 + * (仅单帧,小文件场景;超大文件仍会撞上限,是无 CLI 环境的既有边界)。 + */ +export async function* decompressZstdLines(path, { bins = ZSTD_BINS } = {}) { + let child = null; + for (const bin of bins) { + const c = spawn(bin, ['-dc', path], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); + // 'spawn' = 进程真的起来了;起不来(ENOENT 等)异步走 'error',换下一个候选 + if (await new Promise(res => { c.on('spawn', () => res(true)); c.on('error', () => res(false)); })) { + child = c; + break; + } + } + if (!child) { + const text = (await decompressZstd(path, { bins })).toString('utf8'); + let start = 0, end; + while ((end = text.indexOf('\n', start)) !== -1) { yield text.slice(start, end); start = end + 1; } + return; + } + let errText = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', d => { if (errText.length < 4096) errText += d; }); + // setEncoding 后 chunk 是 string,跨 chunk 的多字节 UTF-8 由内部 StringDecoder 处理 + child.stdout.setEncoding('utf8'); + try { + let rest = ''; + for await (const chunk of child.stdout) { + rest += chunk; + let i; + while ((i = rest.indexOf('\n')) !== -1) { + yield rest.slice(0, i); + rest = rest.slice(i + 1); + } + } + const code = await new Promise(res => child.on('close', res)); + if (code !== 0) throw new Error(`zstd 解压失败(exit ${code}):${errText.trim().slice(0, 200)}`); + } finally { + if (child.exitCode === null && !child.killed) child.kill(); // 消费方提前退出时收掉子进程 + } +} From 0a0b19d0bf1098b6b82e9e81dfba384174bb9c7a Mon Sep 17 00:00:00 2001 From: louwill Date: Wed, 28 Oct 2026 16:34:38 +0800 Subject: [PATCH 2/2] chore: keep the zstd hint review focused by restoring LF endings --- README.zh-CN.md | 1106 +++++++++++++++++++++++------------------------ src/doctor.js | 238 +++++----- src/zstd.js | 220 +++++----- 3 files changed, 782 insertions(+), 782 deletions(-) diff --git a/README.zh-CN.md b/README.zh-CN.md index cf33c8e..6dae14c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,553 +1,553 @@ -# Token Watcher - -**English** · 简体中文 - -**Mac 本地多源 AI Agent 用量与配额实时面板。** 一个常驻进程解析你机器上各 AI 编码工具留下的本地会话记录,统一归一化为 token 事件流,提供 Codex 风格的统计面板、实时配额卡、厂商余额轮询、费用估算与菜单栏胶囊。零框架依赖、纯本地运行。 - -![Token Watcher](https://raw.githubusercontent.com/luwill/token-watcher/main/docs/screenshot.png) - -> 截图为真实运行数据(厂商余额与项目名已脱敏为 ••••)。 - -## 支持的工具(13 源) - -| 工具 | 数据位置 | 能看到什么 | -|---|---|---| -| Claude Code(含本地 Cowork) | `~/.claude/projects` + Desktop Cowork 日志 | 逐消息真实用量、跨格式去重 | -| ccmr(claude-code-model-router) | `~/.claude-gateway/projects` | 同上(国产模型隔离目录,真实模型名) | -| Codex | `~/.codex/sessions`、`archived_sessions` | token 明细、**官方配额百分比与重置时间**(5 小时 / 每周窗口)、模型(含 auto-review 子代理)、工具调用 | -| ZCode | `~/.zcode/cli/db/db.sqlite` | 逐请求明细(model_usage 表)、工具调用、**GLM Coding Plan 积分窗口**(5 小时 / 每周,官方端点) | -| dsh(DeepSeek Harness) | `~/.dsh/sessions` | 逐请求明细(zstd 压缩会话) | -| WorkBuddy | `~/.WorkBuddy/projects` | 逐请求明细 + **积分费率自学习**(credit 账本 × token 最小二乘) | -| Grok Build | `~/.grok/sessions` | 轮次用量明细(含厂商侧成本刻度)、工具调用 | -| Pi | `~/.pi/agent/sessions` | 逐请求明细、工具调用(会话 JSONL 自带 USD 成本明细) | -| OpenCode | `~/.local/share/opencode/opencode.db` | 逐请求明细(message 表)、工具调用 | -| Antigravity | `~/.gemini/antigravity*/brain/**/transcript*.jsonl` | 新版逐轮真实输入/缓存/输出;旧日志按完整上下文和字符估算 | -| Kimi Code | `~/.kimi-code/sessions/**/wire.jsonl` | 逐轮明细(step.end 用量,camelCase/Anthropic/OpenAI 兼容三形状自适应) | -| Qoder | `~/.qoder{,-cn}/projects/**/*.jsonl` | **积分账本**(上游本地不报 token,只报 credits);若上游恢复上报 token 则自动入事件 | -| Cursor | 账号级用量账单(CSV) | 逐请求明细(缓存三段分项)——本地无逐请求数据,用本地凭证拉官方导出 | - -**Claude Desktop 覆盖范围(1.9.4)**:已验证的本地 Cowork `agent/local_ditto_*` transcript 与 audit 日志并入 Claude Code 统计,标准 transcript 优先,两种格式及镜像共用消息身份去重。自动发现 macOS、Windows Roaming 和已安装 Claude MSIX 的目录;扩展目录支持 Windows 盘符与 UNC。本地 Code 仍要求日志落在配置目录。普通 Chat、未镜像的云端会话尚无明细采集源,result 汇总也不与逐消息用量叠加。验证与升级行为见 [Claude Desktop 调查与修复](docs/CLAUDE-DESKTOP-AUDIT-2026-10-14.md)。 - -本项目尚未提供网页版聊天(ChatGPT/豆包/DeepSeek 网页等)的 token 明细采集;不能用对话文本长度或配额百分比代替真实用量。 - -> **口径说明**:Kimi Code / Qoder / Antigravity 均在本机真实数据上与独立重算逐条核对一致。 -> **Qoder 实测本地只报积分不报 token**(2026-09,qmodel_38max):积分走独立账本出 -> "积分消耗"卡,不折算 token——无官方兑换率,编造即失真;token 事件待上游恢复上报。 -> **Cursor 本地不留逐请求数据**(state.vscdb 全零),唯一权威是账号级 CSV 导出: -> 凭证取自本地(state.vscdb 的 JWT 拼会话 cookie,只在服务进程内使用),30 分钟轮询。 -> Gemini CLI 上游已于 2026-06-18 官方过渡并入 Antigravity CLI,不再作为独立源。 -> **Antigravity 新版 CLI 本地已写入逐轮 usage**(2026-10-14,CLI 1.3.4 实测):优先读取 -> input_tokens(不含缓存)、cache_read_tokens、output_tokens(含思维);本项目总量包含缓存, -> 不重复加思维 token。旧日志没有这些字段时,输入取 conversations/gen_metadata 的完整上下文, -> 再回落到完整 transcript 字符上下文;输出/思维按 CJK×1 + 其他×¼ 估算。每轮都处理其上下文, -> 不再用相邻上下文增长量代替输入。库内 usage_quality=3 为实数、2 为上下文估算、1 为字符估算。 -> 常规/full 日志去重,实数可覆盖旧估算,晚到 metadata 即使 transcript 不变也会重试。 -> 工具名统一显示 Antigravity,页面底部的数据源悬浮说明保留新旧日志的计量口径;这里的 token 和 API 等价费用不代表 Google 官方剩余额度或账单。 - -## 快速开始 - -> **Codex 历史少算?** 源码已支持 `.jsonl`、`.jsonl.zst`、`.jsonl.zstd`,采集器 v5 会补扫仍在本机的压缩历史。缺失的日期会同时影响落在该范围内的 7/30 天统计;本地恢复不会自动回填社区云榜。解码要求、日期口径与恢复步骤见 [Codex 压缩历史说明](./docs/CODEX-COMPRESSED-HISTORY.md)。 - -三种用法,按"要不要长期用"来选。 - -### 方式一:npx 试用(什么都不装) - -```bash -npx --yes token-watcher@latest serve -open http://127.0.0.1:8787 -``` - -关掉终端即停止,适合先看看有没有用。 - -两个参数都别省:`--yes` 跳过安装确认;`@latest` 绕开 npx 的版本缓存——不加的话它 -可能跑到一个早先缓存的旧版本。另外 npx 会优先解析**本地**依赖,所以如果你正好在 -本仓库目录里执行,跑的其实是仓库代码而非 npm 上的包。 - -### 方式二:npm 全局安装(长期使用,推荐) - -```bash -npm install -g token-watcher -token-watcher serve -``` - -装完除了 `serve`,还多出两个只有全局安装才方便用的能力: - -```bash -# 常驻 + 开机自启(macOS,崩溃自动拉起) -token-watcher install-agent -token-watcher install-agent --port 9000 -token-watcher uninstall-agent - -# 菜单栏胶囊(macOS) -token-watcher bar -token-watcher bar --port 9000 # 服务不在默认端口时 -``` - -`install-agent` 会把 node 与入口脚本的**绝对路径**固化进 LaunchAgent——launchd 的 -PATH 是系统默认,既不含 npm 全局 bin(前缀还可能被改过),也不保证含 homebrew, -靠命令名会起不来。日志在 `~/.tokenmeter/logs/`。停用请用 `uninstall-agent` 或 -`launchctl bootout`,`launchctl stop` 会被 KeepAlive 立刻拉起。 - -菜单栏 app 以 universal 二进制随包发布(arm64 + x86_64,约 340KB),不需要 Xcode -工具链;从菜单里选「退出」可关闭。 - -三个命令名等价:`token-watcher` / `tokenwatcher` / `tokenmeter`(后者是旧名别名)。 - -### 方式三:克隆仓库(想改代码) - -```bash -git clone https://github.com/luwill/token-watcher.git -cd token-watcher && npm install -npm run serve # 首扫历史 ~3.3GB 约 6 秒,仅首次 -``` - -```bash -npm test # 回归测试,零依赖、离线可跑 -npm run build-bar # 重新编译菜单栏 app(需 Xcode CLT;发版时 prepack 会自动跑) -``` - -仓库里不含编译产物,`.app` 由 `prepack` 在发版前构建。所以克隆后想用菜单栏胶囊, -需先执行 `npm run build-bar`。 - -### 三种方式的差别 - -| | npx | 全局安装 | 克隆仓库 | -|---|---|---|---| -| 面板 / 采集 | ✓ | ✓ | ✓ | -| 菜单栏胶囊 | ✓ | ✓ | 需先 `npm run build-bar` | -| 开机自启 | 不建议 | ✓ | ✓(指向仓库路径)| -| 升级 | 每次拉最新 | `npm i -g token-watcher` | `git pull` | - -npx 下不建议装开机自启:生成的 LaunchAgent 会指向 npx 的缓存目录,而那个目录随时 -可能被 npm 清理,届时服务会静默起不来。要常驻就用全局安装。 - -### 方式四:Homebrew(macOS) - -```bash -brew install luwill/tap/token-watcher -token-watcher serve -``` - -formula 直接引用 npm registry 的 tarball(见 `packaging/homebrew/`),装出来的功能与 -`npm i -g` 完全一致(面板、采集、菜单栏胶囊、开机自启)。Homebrew 与 npm 装一份即可。 - -### serve 的端口与自动打开 - -- 未显式指定端口(默认 8787,可用环境变量 `TOKENMETER_PORT` 改)且被占用时,自动向后 - 尝试最多 20 个端口,面板照常起来;**显式 `--port` 被占则大声失败**——菜单栏胶囊等 - 指向固定端口,悄悄换一个更危险。 -- 交互式终端里启动 `serve` 会自动打开浏览器(launchd / CI 下 stdout 不是 TTY,不会开); - 不想开加 `--no-open`。 - -### 其他命令 - -```bash -token-watcher today # 终端速览今日消耗 -token-watcher today --json # 机器可读(可管道给 jq / 供 agent 调用) -token-watcher today --light # 纯 ASCII 表(CI/SSH 终端安全) -token-watcher sessions --day 2026-09-20 # 会话级统计(JSON) -token-watcher sessions --from 2026-09-01 --to 2026-09-30 --csv --out s.csv -token-watcher sessions --day 2026-09-20 --git # 每个会话挂上窗口内的 git 提交(产出归因) -token-watcher wrapped [--year 2026] [--json] # 年度报告(全年/最猛一天/连续天数/项目 Top) -token-watcher roi [--json] # 订阅 ROI(本月 API 等值 vs 实付) -token-watcher extra-roots [list|add|remove <路径>] - # 多机合并镜像目录的登记(改后需重启服务) -token-watcher doctor # 环境与数据源体检(node 版本/zstd/库完整性/逐源健康) -token-watcher uninstall # 摘除开机自启等本机痕迹 -token-watcher uninstall --purge-data --yes # 连 ~/.tokenmeter 数据目录一起删(不可恢复) -token-watcher scan # 只扫描一次 -token-watcher serve --port 9000 --no-open -token-watcher --version -``` - -`sessions --git` 的归因窗口是 `[会话首事件, 末事件 + 30 分钟]`(生成结束到落提交的常见 -滞后),从源文件首段读完整 cwd(events 表只存目录名,反推不唯一);非 git 项目的会话 -`commits` 保持 `null`,不编造。 - -### 合并另一台机器的用量(extra roots) - -多台电脑跑 Agent?把其他机器的数据目录镜像到本机,面板会把它并入同一工具的 -总账。去重键全局唯一,同一份会话同步两遍——包括同步盘的冲突副本——也只计一次。 -在 `~/.tokenmeter/extra-roots.json` 登记镜像目录(或用下面的 CLI),重启服务后 -生效(root 列表在进程启动时读取一次): - -```json -{ "claude-code": ["~/.tokenmeter/remote/home-pc/claude/projects"] } -``` - -```bash -token-watcher extra-roots list # 查看已登记的镜像目录 -token-watcher extra-roots add claude-code ~/mirror/home-pc/claude/projects -token-watcher extra-roots remove claude-code ~/mirror/home-pc/claude/projects -``` - -仪表盘顶部支持“全部设备 / Mac / VPS / Windows”筛选,图表、会话与CSV遵循相同设备范围;选择设备时仍保留全部设备累计总量。账户配额、余额和订阅ROI为共享信息。来源识别与历史兼容见[设备筛选说明](docs/DEVICE-FILTER.md)。 - -任何能把文件送到本机的传输都行,按机器条件选: - -| 传输方式 | jsonl 源(Claude Code、Codex、Grok…) | sqlite 源(ZCode、OpenCode) | -| --- | --- | --- | -| SSH + rsync 定时拉(cron / LaunchAgent) | ✅ | ✅ 拉的是静止副本 | -| Syncthing / 网盘持续同步 | ✅ 冲突副本已自动跳过 | ❌ 活库 WAL 会被同步盘撕裂 | -| U 盘 / 手动拷贝 | ✅ | ✅ 需先关闭源工具再拷 | - -- Cursor 无需合并:它的用量 CSV 是账号级的,任一台配了凭证的机器轮询到的就是 - 整个账号的用量。 -- `token-watcher doctor` 会显示每个镜像 root 及最近新数据的时间——同步悄悄断掉 - 会变得可见。 -- 社区排行榜:若 A 镜像了 B,只应有一台向榜单上报,否则 B 的用量在榜上双计。 - -该文件只存在于创建它的那台机器上——你的合并配置不会离开自己的数据目录。 - -## 运行要求 - -**Node ≥ 22.13**(`node:sqlite` 自该版本起免 `--experimental-sqlite` flag,零原生依赖)。 - -**dsh 源需要系统 `zstd`**。dsh 的会话快照是追加式多帧写入,必须用外部 zstd CLI 解压: -- macOS:`brew install zstd` -- Windows:`scoop install zstd` 或 `choco install zstd`,或从 [facebook/zstd releases](https://github.com/facebook/zstd/releases) 下载 `zstd.exe` 加入 PATH -- Linux:`apt-get install -y zstd` / `dnf install -y zstd` / `pacman -S zstd` -单个文件可达数千帧,而 Node 内置的 zstd 只解第一帧就结束且不报错,因此这里以外部 -`zstd` 为准,仅在确认文件只含单帧时才回落内置实现。未安装且遇到多帧文件时,该源会在 -健康自检里报错,而不是静默少算。其余数据源不需要它。 - -**平台支持:macOS 全功能验证**(含菜单栏 App 与 launchd)。Windows / Linux 上核心链路(采集、面板、API)随 CI 在 macOS + Ubuntu + Windows 三平台跑回归,包含"装成依赖后能否真正跑起来"的安装冒烟。菜单栏 App 与 launchd 开机自启为 macOS 专属。 - -开发与测试:`npm test` 运行五层回归——语法检查、import 冒烟(拦模块级错误,`node --check` 看不见)、静态断言、前端纯函数行为、端到端冒烟(fixtures 黄金数字 + 幂等 + API 结构)。零依赖、离线运行,GitHub Actions 上跑 macOS/Ubuntu × Node 22.13/24 矩阵。 - -## 功能 - -- **实时**:FSEvents 监听各数据目录 → 增量解析 → SSE 推送,面板秒级刷新(实测 <1s) -- **增量采集**:字节级游标 / sqlite 水位 / 快照重解析三种策略,二次扫描 0ms;dedup 键保证幂等;采集器版本号机制支持逻辑升级后自动全量回填 -- **统计面板**:指标卡、近一年 GitHub 风格日历热力图(每日/每周/累计)、按天/模型/工具图表、最近请求实时流 -- **会话钻取**:点击趋势图任意一天 → 会话列表(峰值上下文估算)→ 单会话逐请求 token 曲线 -- **工具活动**:四源工具调用频次榜 -- **配额**:Codex 官方配额直读(百分比/重置倒计时)、**Claude 官方配额**(复用 Claude Code - 本地 OAuth 凭证调官方用量端点,5 小时窗/周窗/模型专属周额度;无凭证时自动回落 5h 窗口推算) - Statuspage 公开接口,5 分钟刷新;厂商挂没挂一眼可辨) -- **订阅 ROI**:本月 API 等值 vs 订阅实付(`token-watcher roi`,月费在 subscriptions.json 配置) -- **积分账本**:Qoder 积分消耗卡(今日/累计,官方 credits 直读) -- **余额与费用**:DeepSeek/Kimi 余额轮询(读 `~/.ccmr/.env` 的 key,密钥不出后端);ccmr 侧按官方牌价折算(`~/.tokenmeter/pricing.json` 可编辑)+ 余额对账 -- **阈值通知**(菜单栏 App):Codex ≥80%/95%、余额 <¥10/¥2 弹 macOS 通知,级别上升只提醒一次 -- **数据源健康自检**:解析错误标红、"文件在写但无新事件"标黄——私有格式漂移不再静默失败 -- **导出与备份**:CSV 导出、会话/年度报告 CLI、每日 `VACUUM INTO` 备份保留 7 份 - -## 隐私 - -- 纯本地:只读取上述本地文件,**不上传任何用量数据**,面板只监听 127.0.0.1 且校验 Host(挡 DNS rebinding) -- API 密钥仅在服务进程内使用(调厂商余额接口),不入库、不进前端 -- 统计库只存聚合事件(时间/工具/模型/token 数/项目名),不存对话内容 - -**出站请求清单**(只有这四类,都不携带你的用量数据): - -| 用途 | 端点 | 频率 | -|---|---|---| -| USD→CNY 汇率 | `open.er-api.com`、`cdn.jsdelivr.net` | 12 小时,本地缓存兜底 | -| 模型牌价 | `raw.githubusercontent.com`(LiteLLM 价格表) | 24 小时,本地缓存兜底 | -| 厂商余额 | DeepSeek / Kimi 官方接口(带你的 key) | 30 分钟,连续失败自动熔断 | -| Claude 官方配额 | `api.anthropic.com/api/oauth/usage`(带 Claude Code 本地 OAuth token,macOS 首次可能弹一次钥匙串授权) | 10 分钟,无凭证自动跳过 | -| Cursor 用量账单 | `cursor.com/api/dashboard/export-usage-events-csv`(带本地凭证拼的会话 cookie) | 30 分钟,未装/未登录自动跳过 | -| GLM Coding Plan 积分配额 | `bigmodel.cn / api.z.ai` 的 `/api/monitor/usage/quota/limit`(带 ZCode 自己的本地 API key) | 10 分钟,未装/未订阅自动跳过;MCP 调用配额只读本地日志不出网 | - -要完全断网运行,设 `TOKENMETER_OFFLINE=1`:三类请求全部跳过,改用本地缓存与 -`~/.tokenmeter/pricing.json`(可设 `usd_to_cny` + `usd_to_cny_manual: true` 固定汇率)。 -回归测试即以此模式运行,不依赖公网。 - -费用解析优先级:用户 `pricing.json` → 已核实的官方价格与来源限定别名 → LiteLLM 缓存牌价。 -Gemini 3.8 Flash 的 Antigravity 内部名、Cursor MAX/Fast 等模式在价格层解析,保留原始用量记录。 -缓存读、缓存写分别计价,明确免费模型按零价识别;自动路由或身份未知的模型继续标记缺价。 -订阅工具显示 API 等值估算;Cursor/Grok 多次调用汇总无法还原单次长上下文档位,按标准档估算。 -详见 [2026-10-14 全模型价格核对](docs/MODEL-PRICING-AUDIT-2026-10-14.md)。 - -## 免责声明 - -Token Watcher 是**非官方**工具,解析的均为各产品留在本地的**私有格式**(无稳定性承诺),上游版本升级可能导致个别源解析中断——健康面板会标出,欢迎提 issue。各产品版权归其厂商所有。 - -## 架构 - -详见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。核心:`src/collectors/*` 每源一个适配器(增量 + dedup + 版本号),`src/scanner.js` 统一调度,SQLite 归一化事件表,`src/server.js` HTTP API + SSE,`web/` 零构建前端(ECharts UMD)。 - -维护流程见 [每日维护 Loop 与 CI/CD](docs/MAINTENANCE-LOOP.md):定时扫描 PR/Issue 和 CI 变化、有限修复与草稿 PR;发布工作流默认演练,可在确认后使用 npm OIDC 发布已验证的同一安装包。 - -**添加新数据源**只需:写一个 collector(实现 `collect(store, {path, offset, state})` → `{inserted, newOffset, state}`)、在 `src/config.js` 注册、前端加一个颜色。见 [CONTRIBUTING.md](CONTRIBUTING.md)。 - -## Roadmap - -- 跨平台验证(Linux/Windows) -- 订阅 ROI 视角:API 等值成本 vs 订阅实付 -- Grok 官方配额直连(ZCode/GLM 已做) -- Cursor / Copilot 数据源(待拿到可验证的真实数据布局) -- i18n / 英文 README - -## 更新日志 - -### 1.9.5(2026-10-14) - -- **Antigravity 名称显示**:移除固定的「≈」,底部数据源悬浮说明解释新版真实用量与旧日志估算;统计口径保持不变。 -- **OpenCode 大库扫描**:仅取所需 JSON 字段并逐行读取,避免完整工具输出占满 JS 堆;保持 SQLite 语句存活,修复 Node 22.13 下 GC 导致的扫描失败([#15](https://github.com/luwill/token-watcher/pull/15))。 -- **会话详情显示**:失败或空数据清除旧图;迟到响应和同 ID 重试不会覆盖最新选择。无会话 ID 的行不再可点击,标题加入工具名([#14](https://github.com/luwill/token-watcher/pull/14))。 -- Node 22.13/24 回归、强制 GC、48 MiB 堆大库扫描及请求乱序测试通过;详见 [1.9.5 说明](docs/RELEASE-1.9.5.md)。 - -### 1.9.4(2026-10-14) - -- 补采已验证的 Desktop 本地 Cowork transcript/audit;按消息 ID 跨格式去重,标准 transcript 优先,镜像、重扫与重开不双计。 -- 修复 Windows 盘符、UNC 和 tilde 扩展目录校验;CLI 与配置加载共用规则。 -- 旧键合并与游标提交同事务,失败回滚、保留缺源历史;30 项定向及 863 项完整隔离回归通过。 -- Chat/未镜像云端和 result 汇总仍有覆盖边界,升级前先备份;详见 [1.9.4 说明](docs/RELEASE-1.9.4.md)。 - -### 1.9.3(2026-10-14) - -- **每日维护 Loop**:09:00(Asia/Taipei)完整分页扫描 PR/Issue 与 CI 变化,增量快照避免重复待办;有限修复和草稿 PR,合并与发版需维护者确认。 -- **受验证的发布流程**:版本标签、main 祖先关系和完整 CI 通过后构建 universal 安装包并验证干净安装;默认演练,标签先生成 Release 草稿。正式发布通过 npm OIDC 与 GitHub 环境审核进行,发布包与 registry 逐字节核对后再公开 Release。 -- **草稿复用保护**:正式发布前与公开前核对已有草稿安装包和校验文件;相同产物可继续公开,附件不一致/缺失或读取失败则停止。 -- 完整隔离回归 829 项通过;保留 1.9.2 的 Antigravity 用量和模型定价修复,详见 [维护协议](docs/MAINTENANCE-LOOP.md)。 - -### 1.9.2(2026-10-14) - -- **Antigravity 真实用量与历史补正**:优先读取逐轮非缓存输入、缓存读和含思维输出;旧日志按完整上下文估算,不再按相邻轮差分。实数可覆盖旧估算,常规/full/重放日志统一去重,ACTIVE 不抢占 DONE。 -- **晚到 metadata 与刷新**:会话结束后 transcript 不变化也重试待补元数据;仅补正已有记录时通知面板,质量标记防止估算覆盖实数。采集版本 v3 会重扫现存日志,升级前建议生成一致 SQLite 备份,见 [恢复说明](docs/ANTIGRAVITY-RECOVERY-2026-10-14.md)。 -- **补齐官方模型价格与来源别名**:Gemini 4 Argon、Antigravity Gemini 3.8 Flash、Step 5 Preview、Cursor Opus 5.5 MAX、Grok 4.7 Fast/Build、Kimi K3、Hy4 与完整 OpenRouter ID;免费名称按零价识别,身份未知的自动路由名称保留缺价。 -- **缓存与模式计价**:缓存读/写分别计费,覆盖 Google 优惠截止、Cursor MAX/Fast 加价和 Grok 上下文档位;用户价格仍优先,费用卡/按天图/ROI 保持一致。订阅工具是现价 API 等值估算,多次调用汇总按标准档估算,见 [价格核对](docs/MODEL-PRICING-AUDIT-2026-10-14.md)。 -- 完整隔离回归 820 项通过,真实日志独立逐键对账、重复重扫幂等、在线费用 API 与实际图表验收通过。 - -### 1.9.1(2026-10-13) - -- **大体积 Codex 压缩归档改为流式解析**([#12](https://github.com/luwill/token-watcher/issues/12)):解压后超过 V8 字符串上限(~512MiB)的 `.jsonl.zst` 归档此前每轮扫描都报解析错误,现改为 `zstd -dc` 边解边逐行消费(无 zstd CLI 时回落内置整缓冲实现,边界不变);行语义与原整串解析严格一致,截断/损坏档案仍按单文件错误整体回滚,绝不静默吞掉。 -- **会话钻取修复**([#11](https://github.com/luwill/token-watcher/pull/11),感谢 [@amenjason936](https://github.com/amenjason936);1.9.0 的 npm 包已提前包含):`/api/session/:sid` 接受含 `.`/`:`/空格等文件名字符的真实会话 ID(原白名单正则会静默 400),残缺百分号编码按 400 处理而非 500;钻取按 `(tool, session_id)` 取数,两个工具的同名会话不再被合并成一条。 -- Homebrew 安装路径修正为 `brew install luwill/tap/token-watcher`(原文档指向不存在的仓库);tap 同步补到 1.9.0。 -- 三平台 CI 全绿,新增流式解压等价回归(与整串解析输出全等)与坏档抛错检查。 - -### 1.9.0(2026-10-13) - -- **多机用量合并(extra roots)**:把另一台机器(VPS / 家里电脑)的数据目录用任意传输(SSH + rsync 定时拉、Syncthing / 网盘持续同步、手动拷贝)镜像到本机,在 `~/.tokenmeter/extra-roots.json` 登记后并入同一工具的总账。去重键全局唯一,同步冲突副本自动跳过,不会双计;逐请求粒度、会话钻取、5h 窗口估算全量覆盖。sqlite 源(ZCode / OpenCode)请勿走同步盘(活库 WAL 会被撕裂,用 rsync 或关工具后拷贝);Cursor 是账号级数据无需合并。新增 `token-watcher extra-roots list|add|remove`,`doctor` 显示镜像目录的文件数与新鲜度(7 天无新数据提示疑似同步中断)。多机合并下社区排行榜只应由一台机器上报,否则镜像方与被镜像方双计。 -- **采集与守护修复批**(感谢 [@amenjason936](https://github.com/amenjason936) 的 [#3](https://github.com/luwill/token-watcher/pull/3)–[#7](https://github.com/luwill/token-watcher/pull/7)):服务端口重试不再泄漏后台任务与定时器;ROI 对无牌价模型如实显示「缺价」而非 ×0;菜单栏正确解码 Codex 多窗口配额;采集器写库改为暂存回放(事务不跨 await);Cursor CSV 按 RFC4180 解析;测试套件与调用方环境完全隔离。 -- **游标清理按路径段归属 root**([#9](https://github.com/luwill/token-watcher/pull/9),感谢 @amenjason936):配置任意镜像目录后,同前缀的兄弟目录(如 `/a/mac` 与 `/a/mac-old`)的游标行不再被误删。 -- **Codex 键迁移归档搬出热库**([#10](https://github.com/luwill/token-watcher/pull/10),感谢 [@xmasdong](https://github.com/xmasdong)):一次性恢复归档移入同目录独立文件,不再占用统计库、也不再被每日快照复制 7 份(贡献者实测 1.2 GB 库降至 264 MB)。恢复仍可查(ATTACH 侧库),搬家逐行核对后才删原表,失败保持原样下次重试。 -- 三平台 CI(macOS / Ubuntu / Windows × Node 22.13 / 24)全绿,新增多机合并、冲突副本、CLI 登记台与归档搬家的定向检查。 - -### 1.8.4(2026-10-10) - -- **补回 Codex 压缩日志中的历史用量**(感谢 [@horacehht](https://github.com/horacehht) 的 [#2](https://github.com/luwill/token-watcher/pull/2)):支持 `.jsonl.zst` / `.jsonl.zstd`,普通、压缩与归档副本统一去重;采集器 v5 首次运行会重扫仍在本机的历史。压缩文件解码失败会回滚当前文件,不覆盖其已有记录。 -- **统一 7/30 天统计窗口**:本地卡片、费用、图表、CSV 和工具活动按含今天的本地自然日计算,跨夏令时仍保持日期边界;排行榜客户端用量及主力模型按含今天的 UTC 自然日计算,与服务端窗口对齐。 -- **归一 Cursor 模型名**:合并代理前缀与推理强度后缀,避免同一模型分裂成多条统计。 -- Node 22.13 读取压缩日志需要系统 `zstd`;有内置 zstd 的 Node 可回落解码单帧,多帧仍需要 CLI。更新可恢复本地现存日志,**不会自动补报云榜过去日期**,详见 [恢复说明](./docs/CODEX-COMPRESSED-HISTORY.md)。 -- 隔离完整回归 644 项通过,新增压缩日志与日期窗口定向检查 18 项。 - -### 1.8.3(2026-09-30) - -- **修复睡眠唤醒后服务"活着但不工作"**:合盖睡眠再唤醒后,进程还在、端口开着,面板却打不开、 - 也不再采集新数据,只能手动重启。原因是内部线程池(默认 4 个线程)被占满——唤醒时网络未恢复, - 几个外网请求同时卡在域名解析上,文件读取全部排不上队。现在: - - 外网请求发出前先检查域名解析,同时最多让 2 个解析占用线程 - - 线程池从 4 个加大到 16 个(Windows 需在启动前自行设置 `UV_THREADPOOL_SIZE=16`) - - 新增卡死自检:连续约 5 分钟无法完成文件操作,或扫描没有任何进展,服务会打印原因并结束; - 由 `install-agent` 托管时会自动重启 -- 如果仍遇到卡死,重启前请执行 `sample <进程号> 5` 并反馈输出,可以确定卡在哪一步 - -### 1.8.2(2026-09-28) - -- **修复 dsh 用量多算**(感谢 [@xmasdong](https://github.com/xmasdong) 的 [#1](https://github.com/luwill/token-watcher/pull/1)): - dsh 会把同一次请求写成"流式用量"和"完整消息"两条记录,迁移到新格式后还会再抄进新快照, - 一次请求最多被算 3 次。现按"会话 + turn + step + 输入侧用量"认请求,新旧快照都读、 - 各自独有的请求都保留。新旧快照并存的会话越多影响越大(贡献者本机 55 亿 → 28 亿, - 作者本机 20.6 亿 → 19.98 亿) -- 升级后首次启动会自动重建 dsh 的历史数据(只动 dsh)。只清理能从原始日志确认的重复行, - 日志已不在的历史保持原样 -- 附独立核对脚本 `python3 test/dsh-audit.py`:只读、只输出汇总数字,可用来与面板对账 - -### 1.8.1(2026-09-28) - -- **修复 Codex 用量多算**:子代理 / 分叉 / 审查代理的会话文件,开头会整段重放父会话的历史。 - 这些请求父会话里已经计过,此前又被算了一遍——子代理用得越多,多算越多(作者本机 +23%, - 有用户反馈总量是 TokenTracker 的 2.4 倍)。现按"文件创建瞬间一次性写入"的时间特征识别并跳过, - 重放里的工具调用与配额快照同样不再计入 -- **补上此前漏算的三类 Codex 请求**:每个会话的第一次请求、会话恢复后计数器归零的那一次、 - 上下文压缩调用(合计约占 0.5%) -- 同一文件里交错的多条累计流(主代理 + 内嵌审查代理)各算各的,不再把累计差当成一次请求 -- 升级后首次启动会自动重建 Codex 的历史数据(只动 Codex,其余工具不受影响;本机 698 个 - 文件约 10 秒)。**Codex 的累计总量、费用估算与订阅 ROI 会相应下降,这是修正不是丢数据** -- 与 TokenTracker 1.1.2 在同一批日志上逐文件比对:Claude Code 相差 0.08%,Codex 相差 0.15% - -### 1.7.0(2026-09-21) - -- **GLM Coding Plan 官方配额**:ZCode 凭证直连官方 monitor 端点(`/api/monitor/usage/quota/limit`), - 面板新增「GLM Coding Plan」卡:5 小时 / 每周两个积分窗口的已用百分比、进度条与重置倒计时, - 积分明细(如 1,183 / 12,000)在悬浮提示里;套餐等级(如 Pro)作徽章。MCP 工具调用配额 - (1000 次/期)从 ZCode 本地日志读取(不出网),进 summary JSON(`quota.zcode.mcp`); - 快照陈旧度跟踪积分拉取时间,不会被活跃的本地日志刷成"新鲜" -- 窗口语义按实测归一:`percentage` 为已用百分比、`usage` 为总额度、`currentValue` 为已用、 - `unit=3` 为 5 小时窗、`unit=6` 为每周窗(重置落在固定周几);未验证过的 type/unit 组合如实显示原始值不硬贴标签 -- **Claude 官方配额卡恢复**:上游接口改版(`used_percent`→`utilization`、scoped 周窗移入 - `limits[]`)后归一化如实拒认、面板静默回落推算卡——现两种形状都认,官方卡与 Claude Code - 自带 /usage 视图同构(5 小时 / 每周 / scoped 模型如 Fable 各一条),并移入宽卡网格 -- 配额卡倒计时改紧凑格式(`3天 13:17` / `2:06:10`),紧凑卡 6 列布局下单行不再折行; - 订阅 ROI 行文案压缩(全称进悬浮提示)适配四卡同行 -- Windows:自动递增端口跳过系统保留段(Hyper-V/WinNAT 的 `EACCES`,CI 实测踩过), - 显式 `--port` 仍大声失败;zstd 单帧回落不再被压缩负载里的巧合帧魔数误判 -- 凭证纪律:ZCode key 与 Claude token 都只在服务进程内使用,不入库、不进前端 - -### 1.6.0(2026-09-21) - -- **新增订阅 ROI**:回答"我这个月 ¥X 的订阅值不值"。配置 `~/.tokenmeter/subscriptions.json` - 的月费后,面板出"订阅 ROI(本月)"卡、CLI 出 `token-watcher roi [--json]`:逐订阅对比 - 本月 **API 等值成本**(与费用卡同一条链路:pricing.json > 官方回退 > LiteLLM×汇率 + 峰谷)与 - **订阅实付**(price_cny / price_usd 二选一)。比值 ×N≥1 标"划算";积分制工具(Qoder) - 如实显示本月积分消耗、不硬造比值;卡片明确标注假设性口径(订阅含速率限制、API 可能 - 有折扣价)。未配置时若订阅制工具本月已有花费,显示引导卡与本月等值合计 -- 配置格式:`{"monthly": {"claude-code": {"name": "Claude Max", "price_usd": 200}, - "kimi": {"name": "Kimi 会员", "price_cny": 49}}}`(默认键为工具 id)。两个进阶字段: - `tool` 指定数据源 id(同一工具可拆多条订阅——GLM 与 MiniMax 的用量都落在 zcode 下); - `models` 按模型前缀过滤聚合范围。月费未填的条目仍显示 API 等值并标"月费未填" - -### 1.5.1(2026-09-21) - -- **修复模型 Top 10 与工具调用榜的悬浮提示张冠李戴**:横向条形图数据画图前做了 - 反转(大的在上),tooltip 却按原顺序数组取下标——悬停榜首显示的是末位模型的 - 调用次数(实测 deepseek-v4.1-flash 显示成 304 次,真实 13018 次;token 数值不受 - 影响)。两处统一改为从反转后数组取值,并补防回归断言。该数字已与原始文件独立 - 重算逐位核对一致 - -### 1.5.0(2026-09-21,已发布) - -对标 [TokenTracker](https://github.com/xiufengsun/TokenTracker) 补齐 11 项能力,同时守住 -本项目的差异化(实时、逐请求粒度、费用对账、零遥测、单包零依赖)。 - -- **新增 Kimi Code、Qoder、Antigravity 三个数据源 + Cursor 账单(共 13 源,Kiro 移除)。** - Kimi Code 解析 `~/.kimi-code/sessions/**/wire.jsonl`(step.end 用量,三种形状自适应, - 项目名来自 workspaces.json);Qoder 解析 `~/.qoder{,-cn}/projects/**`(实测本地只报 - 积分不报 token:积分入独立账本出卡,token 事件待上游恢复上报自动生效)。 - Gemini CLI 上游已于 2026-06-18 官方过渡并入 Antigravity CLI,不再单列。 - 当时 Antigravity 为**估算口径**(旧版日志无逐请求 usage;新版支持见顶部口径说明):输入取 conversations - db 的 gen_metadata 权威上下文增量,输出/思维链按字符估算(CJK×1+其他×¼,与 - TokenTracker 同法),界面标注「估算」;估算法在本机 744 条真实事件上与独立重算逐条 - 核对一致,且处理了上游 transcript 重放历史行的去重(step_index 重复即跳过,防上下文 - 双重膨胀与差分负值覆盖原计费)与权威值晚到的原地补正。三源均在本机真实数据上独立重算逐条核对 -- **Claude 官方配额卡。** 复用 Claude Code 本地 OAuth 凭证(macOS 钥匙串 / Linux·Win 的 - `~/.claude/.credentials.json`)调官方用量端点,显示 5 小时窗、周窗与模型专属周额度的 - 官方百分比和重置倒计时;无凭证/离线时自动回落原有 5h 推算卡。token 只在服务进程内 - 使用,不入库不进前端;`TOKENMETER_NO_KEYCHAIN=1` 可禁用钥匙串读取 -- **CLI 补全**:`today --json`(机器可读,stdout 纯 JSON)与 `--light`(纯 ASCII); - `sessions --day/--from/--to --json/--csv --out`(会话级统计导出);`wrapped`(年度 - 报告,含最猛一天/连续天数/项目 Top,`--json` 可编程消费);`doctor`(环境 + 库完整性 + - 逐源健康体检,有 error 退出码非 0);`uninstall`(摘 LaunchAgent,`--purge-data` - 才动数据目录且需确认);`--version` / `--help` -- **sessions --git 提交归因**:从源文件首段读完整 cwd(events 只存目录名),在 - `[首事件, 末事件+30min]` 窗口内挂 git 提交——把"花了多少 token"连到"产出了哪些 commit"; - 非 git 会话 `commits=null` 不编造 -- **serve 体验**:默认端口被占时自动向后尝试(显式 `--port` 仍大声失败,防菜单栏胶囊 - 连不上);交互终端自动打开面板(`--no-open` 可关,launchd 下不会开);`TOKENMETER_PORT` - 环境变量可改默认端口 -- **Homebrew 分发**:`brew install luwill/tap/token-watcher`(formula 在 - `packaging/homebrew/`,直连 npm tarball) -- Claude 官方配额卡的重置倒计时按 ISO 时间归一,不再恒显 `--` - -### 1.4.4(2026-09-18,未单独发布,随 1.5.0 一同上线) - -- **修复 Codex plus 用户的配额卡显示成 5 小时额度。** Codex 上报的配额分 primary / secondary - 两个位置,但位置不代表窗口:plus 套餐 primary 是 5 小时、secondary 才是周额度;pro 只有 - primary,且是周额度。此前一律取 primary 并标成"周配额",plus 用户看到的其实是 5 小时额度 - (卡片上"0 天窗口"就是 300 分钟被当成天数取整的结果)。现在按窗口时长识别,卡片更名为 - 「Codex 配额」,有几个窗口就显示几条:plus 显示 5 小时与每周两条,pro 只显示每周 -- 修复配额被非主额度的快照顶掉:使用 GPT-5.3-Codex-Spark 时 Codex 会上报一份独立额度, - 另有一种窗口全空的快照,此前只要它们是最新一条,就会让配额卡显示成 Spark 的额度或 0% -- 升级前存下的旧快照无需重扫即可正确显示;下一次使用 Codex 时自动换成完整的窗口数据 -- 重置倒计时满一天时显示"3天23时58分",不再显示成"95时58分" -- 修掉一条会随运行时刻变红的测试:美元折算用例的事件取"1 小时前",落在 DeepSeek 谷时就被减半 - -### 1.4.3(2026-09-17) - -- **修复 OpenCode 漏采约八成消息。** OpenCode 的 assistant 消息是"先插后改":开始生成就插入 - 一行(用量全 0),生成结束才原地更新写入用量。采集器按 rowid 增量,而服务监听数据库写入、 - 生成过程中就会触发扫描——这行被当成 0 用量跳过,水位却越过了它,完成后的更新再也读不到。 - 面板表现为 OpenCode 明明在用却显示"疑似停更"。现改为按 `time_updated` 增量;升级后自动 - 全量重扫,补回历史漏采的消息(实测 99 条中原先只采到 20 条,修复后 99/99、token 总量逐一吻合) -- 修掉一条会随日期自己变红的测试:余额对账用例把事件时刻写死在某一天,而对账窗口是"最近 24 小时" - -### 1.4.2(2026-09-16) - -- **修复 1.4.1 引入的回归:dsh 整源静默归零。** 1.4.1 把 zstd 解压改为优先使用 Node - 内置实现(为解决 launchd 下找不到外部 zstd 的问题),但内置的 `zstdDecompressSync` - 与 `createZstdDecompress` 都只解**第一帧**就结束且不报错。而 dsh 是追加式多帧写入, - 实测单个会话文件有 5800+ 帧——外部 zstd 解出 19MB,内置实现只解出 226 字节。 - 现改为以外部 zstd 为准(仍显式试常见绝对路径以解决 launchd 的 PATH 问题), - 仅在确认文件只含单帧时才回落内置实现,多帧又无外部 zstd 时大声报错而非静默少算。 - **使用 dsh 源的用户请从 1.4.1 升级。** - -- 新增 `token-watcher install-agent` / `uninstall-agent`,把服务装成 macOS 开机自启项。 - 此前 README 指向 `npm run install-agent`,但 npm scripts 对全局安装的用户不可见, - 且那条命令只负责 `launchctl bootstrap`、从不生成 plist,plist 也不随包发布—— - 全局安装的用户实际没有可用的常驻方案 -- 生成的 plist 固化 node 与入口脚本的绝对路径。launchd 的 PATH 不含 npm 全局 bin 与 - homebrew,而入口脚本的 shebang 是 `#!/usr/bin/env node`,靠命令名无法启动 -- 检测到旧 `com.tokenmeter.server` 仍在运行时拒绝安装(两者抢同一端口),可用 `--force` 覆盖 -- 新增 `token-watcher bar`,菜单栏胶囊随包发布。此前 `.app` 只存在于仓库、也不在 - 发布白名单里,全局安装的用户拿不到,而指引用的 `npm run bar` 同样不可见 -- 菜单栏 app 改为 universal 二进制(arm64 + x86_64)。此前产物只有 arm64,Intel Mac - 上无法运行;`prepack` 会在发版前自动重新编译,避免发出陈旧或缺失的产物 -- 菜单栏 app 不再写死 8787 端口,`serve --port` 的用户不会再拿到连不上的胶囊 -- 修掉菜单栏的数量换算:「亿」档阈值错写成 1e6 却按 1e8 换算,200 万会显示成 "0.0亿" - -### 1.4.1(2026-09-16) - -修掉一批让面板数字低于厂商账单的问题。起因是实测发现面板显示 DeepSeek 当日 ¥8.34, -而官方后台是 ¥58.24。定价表本身无误,问题全在数据进库之前。 - -- **ccmr 的输出 token 被钉死在 0**:Claude Code transcript 把一次响应按 content block - 拆成多行,只有终结块带真实 `output_tokens`;ccmr 网关又不写 `requestId`,分片塌成 - 同一去重键,先到者胜留下的正是 output=0 的首块。实测单日丢掉 94.5% 的输出量。 -- **dsh 自 2026-08-14 起整源静默归零**:上游把会话结构换到 v3(`assistant/chunk` → - `assistant/message`,`data.chunk.usage` → `data.usage`),采集器一条也匹配不上, - 却照常返回 0 条且不报错。全库停在 165 条,而恢复后单日就有 790 次请求 / 1.07 亿 token。 -- **守护进程解不开 zstd**:launchd 的 PATH 不含 homebrew 目录,dsh 快照只有手动 - `scan` 才解得开。改为优先使用 Node 内置 zstd(≥23.8),旧版本回落外部可执行文件。 -- **DeepSeek 峰谷价未实现**:谷时用量一律按峰时价折算。峰时为 UTC 周一至周五 - 01:00-04:00 与 06:00-10:00,其余时段半价。 -- **余额对账漏算同账户的工具**:`computeRecon` 写死只统计 ccmr,而 dsh 花的是同一个 - DeepSeek 账户,导致对账永远显示巨大缺口。改为按注册表的 `apiBilled` 标记推导。 - -升级后首次扫描会全量重扫 ccmr 与 dsh 以补正存量数据。 - -### 1.4.0(2026-09-16) - -- 新增 **Pi** 与 **OpenCode** 两个数据源(共 9 源)。两者口径均逐条核对 - `total == input + output + cacheRead + cacheWrite` 后才接入 -- 健康自检的工具清单改为从数据源注册表推导。此前是另抄的一份硬编码数组,两份清单 - 必然漂移,而漏登记的源不会报错,只是从健康自检里静默消失 - -### 1.3.1(2026-09-15) - -- **装成依赖后面板全空**:ECharts 路径写死了嵌套 `node_modules`,而 npm 安装时会把 - 依赖提升到顶层,于是 `/vendor/echarts.min.js` 返回 404、前端整个挂掉。改为交给 - Node 自己的解析算法 -- 修掉两处 Windows 专属问题(目录分隔符解析) - -### 1.3.0(2026-09-15,未发布) - -- 补上与包名同名的 `token-watcher` 命令入口。改名后 bin 有多个条目且都不叫包名, - `npx token-watcher` 失去确定的解析依据 - -### 1.2.0(2026-09-15) - -- **服务不再会悄悄退出**:扫描时源文件被删(Claude 清理会话、Codex 归档搬移都是常态) - 会抛 ENOENT 冲垮整轮扫描,而这发生在防抖定时器回调里,等于未处理 rejection -- `scanning` 标志异常后不复位会让后续每轮扫描都被"并发中"挡掉,面板从此停更 -- 补齐路由错误处理与 `busy_timeout`,一次 SQLITE_BUSY 不再能杀掉进程 -- 清理已消失文件的游标行(只清 `files`,不动 `events`) - -### 1.1.0(2026-09-15) - -- 汇率实时化:公开接口拉取 USD/CNY,12 小时刷新 + 断网兜底 -- 新增逐日 token 消耗图与按天 × 模型堆叠花费图(Top6 + 其他聚合,低价模型不再被静默丢弃) -- 布局重构为两列严格等高对齐 -- 回归测试强化 - -### 1.0.0(2026-09-14) - -- 首个版本:本地多源 AI Agent 用量与配额实时面板,7 源采集 -- 费用折算接入 LiteLLM 实时牌价 -- GitHub 风格热力图、会话钻取 -- 三层回归测试(语法 / 静态断言 / 端到端冒烟) - -## License - -[MIT](LICENSE) +# Token Watcher + +**English** · 简体中文 + +**Mac 本地多源 AI Agent 用量与配额实时面板。** 一个常驻进程解析你机器上各 AI 编码工具留下的本地会话记录,统一归一化为 token 事件流,提供 Codex 风格的统计面板、实时配额卡、厂商余额轮询、费用估算与菜单栏胶囊。零框架依赖、纯本地运行。 + +![Token Watcher](https://raw.githubusercontent.com/luwill/token-watcher/main/docs/screenshot.png) + +> 截图为真实运行数据(厂商余额与项目名已脱敏为 ••••)。 + +## 支持的工具(13 源) + +| 工具 | 数据位置 | 能看到什么 | +|---|---|---| +| Claude Code(含本地 Cowork) | `~/.claude/projects` + Desktop Cowork 日志 | 逐消息真实用量、跨格式去重 | +| ccmr(claude-code-model-router) | `~/.claude-gateway/projects` | 同上(国产模型隔离目录,真实模型名) | +| Codex | `~/.codex/sessions`、`archived_sessions` | token 明细、**官方配额百分比与重置时间**(5 小时 / 每周窗口)、模型(含 auto-review 子代理)、工具调用 | +| ZCode | `~/.zcode/cli/db/db.sqlite` | 逐请求明细(model_usage 表)、工具调用、**GLM Coding Plan 积分窗口**(5 小时 / 每周,官方端点) | +| dsh(DeepSeek Harness) | `~/.dsh/sessions` | 逐请求明细(zstd 压缩会话) | +| WorkBuddy | `~/.WorkBuddy/projects` | 逐请求明细 + **积分费率自学习**(credit 账本 × token 最小二乘) | +| Grok Build | `~/.grok/sessions` | 轮次用量明细(含厂商侧成本刻度)、工具调用 | +| Pi | `~/.pi/agent/sessions` | 逐请求明细、工具调用(会话 JSONL 自带 USD 成本明细) | +| OpenCode | `~/.local/share/opencode/opencode.db` | 逐请求明细(message 表)、工具调用 | +| Antigravity | `~/.gemini/antigravity*/brain/**/transcript*.jsonl` | 新版逐轮真实输入/缓存/输出;旧日志按完整上下文和字符估算 | +| Kimi Code | `~/.kimi-code/sessions/**/wire.jsonl` | 逐轮明细(step.end 用量,camelCase/Anthropic/OpenAI 兼容三形状自适应) | +| Qoder | `~/.qoder{,-cn}/projects/**/*.jsonl` | **积分账本**(上游本地不报 token,只报 credits);若上游恢复上报 token 则自动入事件 | +| Cursor | 账号级用量账单(CSV) | 逐请求明细(缓存三段分项)——本地无逐请求数据,用本地凭证拉官方导出 | + +**Claude Desktop 覆盖范围(1.9.4)**:已验证的本地 Cowork `agent/local_ditto_*` transcript 与 audit 日志并入 Claude Code 统计,标准 transcript 优先,两种格式及镜像共用消息身份去重。自动发现 macOS、Windows Roaming 和已安装 Claude MSIX 的目录;扩展目录支持 Windows 盘符与 UNC。本地 Code 仍要求日志落在配置目录。普通 Chat、未镜像的云端会话尚无明细采集源,result 汇总也不与逐消息用量叠加。验证与升级行为见 [Claude Desktop 调查与修复](docs/CLAUDE-DESKTOP-AUDIT-2026-10-14.md)。 + +本项目尚未提供网页版聊天(ChatGPT/豆包/DeepSeek 网页等)的 token 明细采集;不能用对话文本长度或配额百分比代替真实用量。 + +> **口径说明**:Kimi Code / Qoder / Antigravity 均在本机真实数据上与独立重算逐条核对一致。 +> **Qoder 实测本地只报积分不报 token**(2026-09,qmodel_38max):积分走独立账本出 +> "积分消耗"卡,不折算 token——无官方兑换率,编造即失真;token 事件待上游恢复上报。 +> **Cursor 本地不留逐请求数据**(state.vscdb 全零),唯一权威是账号级 CSV 导出: +> 凭证取自本地(state.vscdb 的 JWT 拼会话 cookie,只在服务进程内使用),30 分钟轮询。 +> Gemini CLI 上游已于 2026-06-18 官方过渡并入 Antigravity CLI,不再作为独立源。 +> **Antigravity 新版 CLI 本地已写入逐轮 usage**(2026-10-14,CLI 1.3.4 实测):优先读取 +> input_tokens(不含缓存)、cache_read_tokens、output_tokens(含思维);本项目总量包含缓存, +> 不重复加思维 token。旧日志没有这些字段时,输入取 conversations/gen_metadata 的完整上下文, +> 再回落到完整 transcript 字符上下文;输出/思维按 CJK×1 + 其他×¼ 估算。每轮都处理其上下文, +> 不再用相邻上下文增长量代替输入。库内 usage_quality=3 为实数、2 为上下文估算、1 为字符估算。 +> 常规/full 日志去重,实数可覆盖旧估算,晚到 metadata 即使 transcript 不变也会重试。 +> 工具名统一显示 Antigravity,页面底部的数据源悬浮说明保留新旧日志的计量口径;这里的 token 和 API 等价费用不代表 Google 官方剩余额度或账单。 + +## 快速开始 + +> **Codex 历史少算?** 源码已支持 `.jsonl`、`.jsonl.zst`、`.jsonl.zstd`,采集器 v5 会补扫仍在本机的压缩历史。缺失的日期会同时影响落在该范围内的 7/30 天统计;本地恢复不会自动回填社区云榜。解码要求、日期口径与恢复步骤见 [Codex 压缩历史说明](./docs/CODEX-COMPRESSED-HISTORY.md)。 + +三种用法,按"要不要长期用"来选。 + +### 方式一:npx 试用(什么都不装) + +```bash +npx --yes token-watcher@latest serve +open http://127.0.0.1:8787 +``` + +关掉终端即停止,适合先看看有没有用。 + +两个参数都别省:`--yes` 跳过安装确认;`@latest` 绕开 npx 的版本缓存——不加的话它 +可能跑到一个早先缓存的旧版本。另外 npx 会优先解析**本地**依赖,所以如果你正好在 +本仓库目录里执行,跑的其实是仓库代码而非 npm 上的包。 + +### 方式二:npm 全局安装(长期使用,推荐) + +```bash +npm install -g token-watcher +token-watcher serve +``` + +装完除了 `serve`,还多出两个只有全局安装才方便用的能力: + +```bash +# 常驻 + 开机自启(macOS,崩溃自动拉起) +token-watcher install-agent +token-watcher install-agent --port 9000 +token-watcher uninstall-agent + +# 菜单栏胶囊(macOS) +token-watcher bar +token-watcher bar --port 9000 # 服务不在默认端口时 +``` + +`install-agent` 会把 node 与入口脚本的**绝对路径**固化进 LaunchAgent——launchd 的 +PATH 是系统默认,既不含 npm 全局 bin(前缀还可能被改过),也不保证含 homebrew, +靠命令名会起不来。日志在 `~/.tokenmeter/logs/`。停用请用 `uninstall-agent` 或 +`launchctl bootout`,`launchctl stop` 会被 KeepAlive 立刻拉起。 + +菜单栏 app 以 universal 二进制随包发布(arm64 + x86_64,约 340KB),不需要 Xcode +工具链;从菜单里选「退出」可关闭。 + +三个命令名等价:`token-watcher` / `tokenwatcher` / `tokenmeter`(后者是旧名别名)。 + +### 方式三:克隆仓库(想改代码) + +```bash +git clone https://github.com/luwill/token-watcher.git +cd token-watcher && npm install +npm run serve # 首扫历史 ~3.3GB 约 6 秒,仅首次 +``` + +```bash +npm test # 回归测试,零依赖、离线可跑 +npm run build-bar # 重新编译菜单栏 app(需 Xcode CLT;发版时 prepack 会自动跑) +``` + +仓库里不含编译产物,`.app` 由 `prepack` 在发版前构建。所以克隆后想用菜单栏胶囊, +需先执行 `npm run build-bar`。 + +### 三种方式的差别 + +| | npx | 全局安装 | 克隆仓库 | +|---|---|---|---| +| 面板 / 采集 | ✓ | ✓ | ✓ | +| 菜单栏胶囊 | ✓ | ✓ | 需先 `npm run build-bar` | +| 开机自启 | 不建议 | ✓ | ✓(指向仓库路径)| +| 升级 | 每次拉最新 | `npm i -g token-watcher` | `git pull` | + +npx 下不建议装开机自启:生成的 LaunchAgent 会指向 npx 的缓存目录,而那个目录随时 +可能被 npm 清理,届时服务会静默起不来。要常驻就用全局安装。 + +### 方式四:Homebrew(macOS) + +```bash +brew install luwill/tap/token-watcher +token-watcher serve +``` + +formula 直接引用 npm registry 的 tarball(见 `packaging/homebrew/`),装出来的功能与 +`npm i -g` 完全一致(面板、采集、菜单栏胶囊、开机自启)。Homebrew 与 npm 装一份即可。 + +### serve 的端口与自动打开 + +- 未显式指定端口(默认 8787,可用环境变量 `TOKENMETER_PORT` 改)且被占用时,自动向后 + 尝试最多 20 个端口,面板照常起来;**显式 `--port` 被占则大声失败**——菜单栏胶囊等 + 指向固定端口,悄悄换一个更危险。 +- 交互式终端里启动 `serve` 会自动打开浏览器(launchd / CI 下 stdout 不是 TTY,不会开); + 不想开加 `--no-open`。 + +### 其他命令 + +```bash +token-watcher today # 终端速览今日消耗 +token-watcher today --json # 机器可读(可管道给 jq / 供 agent 调用) +token-watcher today --light # 纯 ASCII 表(CI/SSH 终端安全) +token-watcher sessions --day 2026-09-20 # 会话级统计(JSON) +token-watcher sessions --from 2026-09-01 --to 2026-09-30 --csv --out s.csv +token-watcher sessions --day 2026-09-20 --git # 每个会话挂上窗口内的 git 提交(产出归因) +token-watcher wrapped [--year 2026] [--json] # 年度报告(全年/最猛一天/连续天数/项目 Top) +token-watcher roi [--json] # 订阅 ROI(本月 API 等值 vs 实付) +token-watcher extra-roots [list|add|remove <路径>] + # 多机合并镜像目录的登记(改后需重启服务) +token-watcher doctor # 环境与数据源体检(node 版本/zstd/库完整性/逐源健康) +token-watcher uninstall # 摘除开机自启等本机痕迹 +token-watcher uninstall --purge-data --yes # 连 ~/.tokenmeter 数据目录一起删(不可恢复) +token-watcher scan # 只扫描一次 +token-watcher serve --port 9000 --no-open +token-watcher --version +``` + +`sessions --git` 的归因窗口是 `[会话首事件, 末事件 + 30 分钟]`(生成结束到落提交的常见 +滞后),从源文件首段读完整 cwd(events 表只存目录名,反推不唯一);非 git 项目的会话 +`commits` 保持 `null`,不编造。 + +### 合并另一台机器的用量(extra roots) + +多台电脑跑 Agent?把其他机器的数据目录镜像到本机,面板会把它并入同一工具的 +总账。去重键全局唯一,同一份会话同步两遍——包括同步盘的冲突副本——也只计一次。 +在 `~/.tokenmeter/extra-roots.json` 登记镜像目录(或用下面的 CLI),重启服务后 +生效(root 列表在进程启动时读取一次): + +```json +{ "claude-code": ["~/.tokenmeter/remote/home-pc/claude/projects"] } +``` + +```bash +token-watcher extra-roots list # 查看已登记的镜像目录 +token-watcher extra-roots add claude-code ~/mirror/home-pc/claude/projects +token-watcher extra-roots remove claude-code ~/mirror/home-pc/claude/projects +``` + +仪表盘顶部支持“全部设备 / Mac / VPS / Windows”筛选,图表、会话与CSV遵循相同设备范围;选择设备时仍保留全部设备累计总量。账户配额、余额和订阅ROI为共享信息。来源识别与历史兼容见[设备筛选说明](docs/DEVICE-FILTER.md)。 + +任何能把文件送到本机的传输都行,按机器条件选: + +| 传输方式 | jsonl 源(Claude Code、Codex、Grok…) | sqlite 源(ZCode、OpenCode) | +| --- | --- | --- | +| SSH + rsync 定时拉(cron / LaunchAgent) | ✅ | ✅ 拉的是静止副本 | +| Syncthing / 网盘持续同步 | ✅ 冲突副本已自动跳过 | ❌ 活库 WAL 会被同步盘撕裂 | +| U 盘 / 手动拷贝 | ✅ | ✅ 需先关闭源工具再拷 | + +- Cursor 无需合并:它的用量 CSV 是账号级的,任一台配了凭证的机器轮询到的就是 + 整个账号的用量。 +- `token-watcher doctor` 会显示每个镜像 root 及最近新数据的时间——同步悄悄断掉 + 会变得可见。 +- 社区排行榜:若 A 镜像了 B,只应有一台向榜单上报,否则 B 的用量在榜上双计。 + +该文件只存在于创建它的那台机器上——你的合并配置不会离开自己的数据目录。 + +## 运行要求 + +**Node ≥ 22.13**(`node:sqlite` 自该版本起免 `--experimental-sqlite` flag,零原生依赖)。 + +**dsh 源需要系统 `zstd`**。dsh 的会话快照是追加式多帧写入,必须用外部 zstd CLI 解压: +- macOS:`brew install zstd` +- Windows:`scoop install zstd` 或 `choco install zstd`,或从 [facebook/zstd releases](https://github.com/facebook/zstd/releases) 下载 `zstd.exe` 加入 PATH +- Linux:`apt-get install -y zstd` / `dnf install -y zstd` / `pacman -S zstd` +单个文件可达数千帧,而 Node 内置的 zstd 只解第一帧就结束且不报错,因此这里以外部 +`zstd` 为准,仅在确认文件只含单帧时才回落内置实现。未安装且遇到多帧文件时,该源会在 +健康自检里报错,而不是静默少算。其余数据源不需要它。 + +**平台支持:macOS 全功能验证**(含菜单栏 App 与 launchd)。Windows / Linux 上核心链路(采集、面板、API)随 CI 在 macOS + Ubuntu + Windows 三平台跑回归,包含"装成依赖后能否真正跑起来"的安装冒烟。菜单栏 App 与 launchd 开机自启为 macOS 专属。 + +开发与测试:`npm test` 运行五层回归——语法检查、import 冒烟(拦模块级错误,`node --check` 看不见)、静态断言、前端纯函数行为、端到端冒烟(fixtures 黄金数字 + 幂等 + API 结构)。零依赖、离线运行,GitHub Actions 上跑 macOS/Ubuntu × Node 22.13/24 矩阵。 + +## 功能 + +- **实时**:FSEvents 监听各数据目录 → 增量解析 → SSE 推送,面板秒级刷新(实测 <1s) +- **增量采集**:字节级游标 / sqlite 水位 / 快照重解析三种策略,二次扫描 0ms;dedup 键保证幂等;采集器版本号机制支持逻辑升级后自动全量回填 +- **统计面板**:指标卡、近一年 GitHub 风格日历热力图(每日/每周/累计)、按天/模型/工具图表、最近请求实时流 +- **会话钻取**:点击趋势图任意一天 → 会话列表(峰值上下文估算)→ 单会话逐请求 token 曲线 +- **工具活动**:四源工具调用频次榜 +- **配额**:Codex 官方配额直读(百分比/重置倒计时)、**Claude 官方配额**(复用 Claude Code + 本地 OAuth 凭证调官方用量端点,5 小时窗/周窗/模型专属周额度;无凭证时自动回落 5h 窗口推算) + Statuspage 公开接口,5 分钟刷新;厂商挂没挂一眼可辨) +- **订阅 ROI**:本月 API 等值 vs 订阅实付(`token-watcher roi`,月费在 subscriptions.json 配置) +- **积分账本**:Qoder 积分消耗卡(今日/累计,官方 credits 直读) +- **余额与费用**:DeepSeek/Kimi 余额轮询(读 `~/.ccmr/.env` 的 key,密钥不出后端);ccmr 侧按官方牌价折算(`~/.tokenmeter/pricing.json` 可编辑)+ 余额对账 +- **阈值通知**(菜单栏 App):Codex ≥80%/95%、余额 <¥10/¥2 弹 macOS 通知,级别上升只提醒一次 +- **数据源健康自检**:解析错误标红、"文件在写但无新事件"标黄——私有格式漂移不再静默失败 +- **导出与备份**:CSV 导出、会话/年度报告 CLI、每日 `VACUUM INTO` 备份保留 7 份 + +## 隐私 + +- 纯本地:只读取上述本地文件,**不上传任何用量数据**,面板只监听 127.0.0.1 且校验 Host(挡 DNS rebinding) +- API 密钥仅在服务进程内使用(调厂商余额接口),不入库、不进前端 +- 统计库只存聚合事件(时间/工具/模型/token 数/项目名),不存对话内容 + +**出站请求清单**(只有这四类,都不携带你的用量数据): + +| 用途 | 端点 | 频率 | +|---|---|---| +| USD→CNY 汇率 | `open.er-api.com`、`cdn.jsdelivr.net` | 12 小时,本地缓存兜底 | +| 模型牌价 | `raw.githubusercontent.com`(LiteLLM 价格表) | 24 小时,本地缓存兜底 | +| 厂商余额 | DeepSeek / Kimi 官方接口(带你的 key) | 30 分钟,连续失败自动熔断 | +| Claude 官方配额 | `api.anthropic.com/api/oauth/usage`(带 Claude Code 本地 OAuth token,macOS 首次可能弹一次钥匙串授权) | 10 分钟,无凭证自动跳过 | +| Cursor 用量账单 | `cursor.com/api/dashboard/export-usage-events-csv`(带本地凭证拼的会话 cookie) | 30 分钟,未装/未登录自动跳过 | +| GLM Coding Plan 积分配额 | `bigmodel.cn / api.z.ai` 的 `/api/monitor/usage/quota/limit`(带 ZCode 自己的本地 API key) | 10 分钟,未装/未订阅自动跳过;MCP 调用配额只读本地日志不出网 | + +要完全断网运行,设 `TOKENMETER_OFFLINE=1`:三类请求全部跳过,改用本地缓存与 +`~/.tokenmeter/pricing.json`(可设 `usd_to_cny` + `usd_to_cny_manual: true` 固定汇率)。 +回归测试即以此模式运行,不依赖公网。 + +费用解析优先级:用户 `pricing.json` → 已核实的官方价格与来源限定别名 → LiteLLM 缓存牌价。 +Gemini 3.8 Flash 的 Antigravity 内部名、Cursor MAX/Fast 等模式在价格层解析,保留原始用量记录。 +缓存读、缓存写分别计价,明确免费模型按零价识别;自动路由或身份未知的模型继续标记缺价。 +订阅工具显示 API 等值估算;Cursor/Grok 多次调用汇总无法还原单次长上下文档位,按标准档估算。 +详见 [2026-10-14 全模型价格核对](docs/MODEL-PRICING-AUDIT-2026-10-14.md)。 + +## 免责声明 + +Token Watcher 是**非官方**工具,解析的均为各产品留在本地的**私有格式**(无稳定性承诺),上游版本升级可能导致个别源解析中断——健康面板会标出,欢迎提 issue。各产品版权归其厂商所有。 + +## 架构 + +详见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。核心:`src/collectors/*` 每源一个适配器(增量 + dedup + 版本号),`src/scanner.js` 统一调度,SQLite 归一化事件表,`src/server.js` HTTP API + SSE,`web/` 零构建前端(ECharts UMD)。 + +维护流程见 [每日维护 Loop 与 CI/CD](docs/MAINTENANCE-LOOP.md):定时扫描 PR/Issue 和 CI 变化、有限修复与草稿 PR;发布工作流默认演练,可在确认后使用 npm OIDC 发布已验证的同一安装包。 + +**添加新数据源**只需:写一个 collector(实现 `collect(store, {path, offset, state})` → `{inserted, newOffset, state}`)、在 `src/config.js` 注册、前端加一个颜色。见 [CONTRIBUTING.md](CONTRIBUTING.md)。 + +## Roadmap + +- 跨平台验证(Linux/Windows) +- 订阅 ROI 视角:API 等值成本 vs 订阅实付 +- Grok 官方配额直连(ZCode/GLM 已做) +- Cursor / Copilot 数据源(待拿到可验证的真实数据布局) +- i18n / 英文 README + +## 更新日志 + +### 1.9.5(2026-10-14) + +- **Antigravity 名称显示**:移除固定的「≈」,底部数据源悬浮说明解释新版真实用量与旧日志估算;统计口径保持不变。 +- **OpenCode 大库扫描**:仅取所需 JSON 字段并逐行读取,避免完整工具输出占满 JS 堆;保持 SQLite 语句存活,修复 Node 22.13 下 GC 导致的扫描失败([#15](https://github.com/luwill/token-watcher/pull/15))。 +- **会话详情显示**:失败或空数据清除旧图;迟到响应和同 ID 重试不会覆盖最新选择。无会话 ID 的行不再可点击,标题加入工具名([#14](https://github.com/luwill/token-watcher/pull/14))。 +- Node 22.13/24 回归、强制 GC、48 MiB 堆大库扫描及请求乱序测试通过;详见 [1.9.5 说明](docs/RELEASE-1.9.5.md)。 + +### 1.9.4(2026-10-14) + +- 补采已验证的 Desktop 本地 Cowork transcript/audit;按消息 ID 跨格式去重,标准 transcript 优先,镜像、重扫与重开不双计。 +- 修复 Windows 盘符、UNC 和 tilde 扩展目录校验;CLI 与配置加载共用规则。 +- 旧键合并与游标提交同事务,失败回滚、保留缺源历史;30 项定向及 863 项完整隔离回归通过。 +- Chat/未镜像云端和 result 汇总仍有覆盖边界,升级前先备份;详见 [1.9.4 说明](docs/RELEASE-1.9.4.md)。 + +### 1.9.3(2026-10-14) + +- **每日维护 Loop**:09:00(Asia/Taipei)完整分页扫描 PR/Issue 与 CI 变化,增量快照避免重复待办;有限修复和草稿 PR,合并与发版需维护者确认。 +- **受验证的发布流程**:版本标签、main 祖先关系和完整 CI 通过后构建 universal 安装包并验证干净安装;默认演练,标签先生成 Release 草稿。正式发布通过 npm OIDC 与 GitHub 环境审核进行,发布包与 registry 逐字节核对后再公开 Release。 +- **草稿复用保护**:正式发布前与公开前核对已有草稿安装包和校验文件;相同产物可继续公开,附件不一致/缺失或读取失败则停止。 +- 完整隔离回归 829 项通过;保留 1.9.2 的 Antigravity 用量和模型定价修复,详见 [维护协议](docs/MAINTENANCE-LOOP.md)。 + +### 1.9.2(2026-10-14) + +- **Antigravity 真实用量与历史补正**:优先读取逐轮非缓存输入、缓存读和含思维输出;旧日志按完整上下文估算,不再按相邻轮差分。实数可覆盖旧估算,常规/full/重放日志统一去重,ACTIVE 不抢占 DONE。 +- **晚到 metadata 与刷新**:会话结束后 transcript 不变化也重试待补元数据;仅补正已有记录时通知面板,质量标记防止估算覆盖实数。采集版本 v3 会重扫现存日志,升级前建议生成一致 SQLite 备份,见 [恢复说明](docs/ANTIGRAVITY-RECOVERY-2026-10-14.md)。 +- **补齐官方模型价格与来源别名**:Gemini 4 Argon、Antigravity Gemini 3.8 Flash、Step 5 Preview、Cursor Opus 5.5 MAX、Grok 4.7 Fast/Build、Kimi K3、Hy4 与完整 OpenRouter ID;免费名称按零价识别,身份未知的自动路由名称保留缺价。 +- **缓存与模式计价**:缓存读/写分别计费,覆盖 Google 优惠截止、Cursor MAX/Fast 加价和 Grok 上下文档位;用户价格仍优先,费用卡/按天图/ROI 保持一致。订阅工具是现价 API 等值估算,多次调用汇总按标准档估算,见 [价格核对](docs/MODEL-PRICING-AUDIT-2026-10-14.md)。 +- 完整隔离回归 820 项通过,真实日志独立逐键对账、重复重扫幂等、在线费用 API 与实际图表验收通过。 + +### 1.9.1(2026-10-13) + +- **大体积 Codex 压缩归档改为流式解析**([#12](https://github.com/luwill/token-watcher/issues/12)):解压后超过 V8 字符串上限(~512MiB)的 `.jsonl.zst` 归档此前每轮扫描都报解析错误,现改为 `zstd -dc` 边解边逐行消费(无 zstd CLI 时回落内置整缓冲实现,边界不变);行语义与原整串解析严格一致,截断/损坏档案仍按单文件错误整体回滚,绝不静默吞掉。 +- **会话钻取修复**([#11](https://github.com/luwill/token-watcher/pull/11),感谢 [@amenjason936](https://github.com/amenjason936);1.9.0 的 npm 包已提前包含):`/api/session/:sid` 接受含 `.`/`:`/空格等文件名字符的真实会话 ID(原白名单正则会静默 400),残缺百分号编码按 400 处理而非 500;钻取按 `(tool, session_id)` 取数,两个工具的同名会话不再被合并成一条。 +- Homebrew 安装路径修正为 `brew install luwill/tap/token-watcher`(原文档指向不存在的仓库);tap 同步补到 1.9.0。 +- 三平台 CI 全绿,新增流式解压等价回归(与整串解析输出全等)与坏档抛错检查。 + +### 1.9.0(2026-10-13) + +- **多机用量合并(extra roots)**:把另一台机器(VPS / 家里电脑)的数据目录用任意传输(SSH + rsync 定时拉、Syncthing / 网盘持续同步、手动拷贝)镜像到本机,在 `~/.tokenmeter/extra-roots.json` 登记后并入同一工具的总账。去重键全局唯一,同步冲突副本自动跳过,不会双计;逐请求粒度、会话钻取、5h 窗口估算全量覆盖。sqlite 源(ZCode / OpenCode)请勿走同步盘(活库 WAL 会被撕裂,用 rsync 或关工具后拷贝);Cursor 是账号级数据无需合并。新增 `token-watcher extra-roots list|add|remove`,`doctor` 显示镜像目录的文件数与新鲜度(7 天无新数据提示疑似同步中断)。多机合并下社区排行榜只应由一台机器上报,否则镜像方与被镜像方双计。 +- **采集与守护修复批**(感谢 [@amenjason936](https://github.com/amenjason936) 的 [#3](https://github.com/luwill/token-watcher/pull/3)–[#7](https://github.com/luwill/token-watcher/pull/7)):服务端口重试不再泄漏后台任务与定时器;ROI 对无牌价模型如实显示「缺价」而非 ×0;菜单栏正确解码 Codex 多窗口配额;采集器写库改为暂存回放(事务不跨 await);Cursor CSV 按 RFC4180 解析;测试套件与调用方环境完全隔离。 +- **游标清理按路径段归属 root**([#9](https://github.com/luwill/token-watcher/pull/9),感谢 @amenjason936):配置任意镜像目录后,同前缀的兄弟目录(如 `/a/mac` 与 `/a/mac-old`)的游标行不再被误删。 +- **Codex 键迁移归档搬出热库**([#10](https://github.com/luwill/token-watcher/pull/10),感谢 [@xmasdong](https://github.com/xmasdong)):一次性恢复归档移入同目录独立文件,不再占用统计库、也不再被每日快照复制 7 份(贡献者实测 1.2 GB 库降至 264 MB)。恢复仍可查(ATTACH 侧库),搬家逐行核对后才删原表,失败保持原样下次重试。 +- 三平台 CI(macOS / Ubuntu / Windows × Node 22.13 / 24)全绿,新增多机合并、冲突副本、CLI 登记台与归档搬家的定向检查。 + +### 1.8.4(2026-10-10) + +- **补回 Codex 压缩日志中的历史用量**(感谢 [@horacehht](https://github.com/horacehht) 的 [#2](https://github.com/luwill/token-watcher/pull/2)):支持 `.jsonl.zst` / `.jsonl.zstd`,普通、压缩与归档副本统一去重;采集器 v5 首次运行会重扫仍在本机的历史。压缩文件解码失败会回滚当前文件,不覆盖其已有记录。 +- **统一 7/30 天统计窗口**:本地卡片、费用、图表、CSV 和工具活动按含今天的本地自然日计算,跨夏令时仍保持日期边界;排行榜客户端用量及主力模型按含今天的 UTC 自然日计算,与服务端窗口对齐。 +- **归一 Cursor 模型名**:合并代理前缀与推理强度后缀,避免同一模型分裂成多条统计。 +- Node 22.13 读取压缩日志需要系统 `zstd`;有内置 zstd 的 Node 可回落解码单帧,多帧仍需要 CLI。更新可恢复本地现存日志,**不会自动补报云榜过去日期**,详见 [恢复说明](./docs/CODEX-COMPRESSED-HISTORY.md)。 +- 隔离完整回归 644 项通过,新增压缩日志与日期窗口定向检查 18 项。 + +### 1.8.3(2026-09-30) + +- **修复睡眠唤醒后服务"活着但不工作"**:合盖睡眠再唤醒后,进程还在、端口开着,面板却打不开、 + 也不再采集新数据,只能手动重启。原因是内部线程池(默认 4 个线程)被占满——唤醒时网络未恢复, + 几个外网请求同时卡在域名解析上,文件读取全部排不上队。现在: + - 外网请求发出前先检查域名解析,同时最多让 2 个解析占用线程 + - 线程池从 4 个加大到 16 个(Windows 需在启动前自行设置 `UV_THREADPOOL_SIZE=16`) + - 新增卡死自检:连续约 5 分钟无法完成文件操作,或扫描没有任何进展,服务会打印原因并结束; + 由 `install-agent` 托管时会自动重启 +- 如果仍遇到卡死,重启前请执行 `sample <进程号> 5` 并反馈输出,可以确定卡在哪一步 + +### 1.8.2(2026-09-28) + +- **修复 dsh 用量多算**(感谢 [@xmasdong](https://github.com/xmasdong) 的 [#1](https://github.com/luwill/token-watcher/pull/1)): + dsh 会把同一次请求写成"流式用量"和"完整消息"两条记录,迁移到新格式后还会再抄进新快照, + 一次请求最多被算 3 次。现按"会话 + turn + step + 输入侧用量"认请求,新旧快照都读、 + 各自独有的请求都保留。新旧快照并存的会话越多影响越大(贡献者本机 55 亿 → 28 亿, + 作者本机 20.6 亿 → 19.98 亿) +- 升级后首次启动会自动重建 dsh 的历史数据(只动 dsh)。只清理能从原始日志确认的重复行, + 日志已不在的历史保持原样 +- 附独立核对脚本 `python3 test/dsh-audit.py`:只读、只输出汇总数字,可用来与面板对账 + +### 1.8.1(2026-09-28) + +- **修复 Codex 用量多算**:子代理 / 分叉 / 审查代理的会话文件,开头会整段重放父会话的历史。 + 这些请求父会话里已经计过,此前又被算了一遍——子代理用得越多,多算越多(作者本机 +23%, + 有用户反馈总量是 TokenTracker 的 2.4 倍)。现按"文件创建瞬间一次性写入"的时间特征识别并跳过, + 重放里的工具调用与配额快照同样不再计入 +- **补上此前漏算的三类 Codex 请求**:每个会话的第一次请求、会话恢复后计数器归零的那一次、 + 上下文压缩调用(合计约占 0.5%) +- 同一文件里交错的多条累计流(主代理 + 内嵌审查代理)各算各的,不再把累计差当成一次请求 +- 升级后首次启动会自动重建 Codex 的历史数据(只动 Codex,其余工具不受影响;本机 698 个 + 文件约 10 秒)。**Codex 的累计总量、费用估算与订阅 ROI 会相应下降,这是修正不是丢数据** +- 与 TokenTracker 1.1.2 在同一批日志上逐文件比对:Claude Code 相差 0.08%,Codex 相差 0.15% + +### 1.7.0(2026-09-21) + +- **GLM Coding Plan 官方配额**:ZCode 凭证直连官方 monitor 端点(`/api/monitor/usage/quota/limit`), + 面板新增「GLM Coding Plan」卡:5 小时 / 每周两个积分窗口的已用百分比、进度条与重置倒计时, + 积分明细(如 1,183 / 12,000)在悬浮提示里;套餐等级(如 Pro)作徽章。MCP 工具调用配额 + (1000 次/期)从 ZCode 本地日志读取(不出网),进 summary JSON(`quota.zcode.mcp`); + 快照陈旧度跟踪积分拉取时间,不会被活跃的本地日志刷成"新鲜" +- 窗口语义按实测归一:`percentage` 为已用百分比、`usage` 为总额度、`currentValue` 为已用、 + `unit=3` 为 5 小时窗、`unit=6` 为每周窗(重置落在固定周几);未验证过的 type/unit 组合如实显示原始值不硬贴标签 +- **Claude 官方配额卡恢复**:上游接口改版(`used_percent`→`utilization`、scoped 周窗移入 + `limits[]`)后归一化如实拒认、面板静默回落推算卡——现两种形状都认,官方卡与 Claude Code + 自带 /usage 视图同构(5 小时 / 每周 / scoped 模型如 Fable 各一条),并移入宽卡网格 +- 配额卡倒计时改紧凑格式(`3天 13:17` / `2:06:10`),紧凑卡 6 列布局下单行不再折行; + 订阅 ROI 行文案压缩(全称进悬浮提示)适配四卡同行 +- Windows:自动递增端口跳过系统保留段(Hyper-V/WinNAT 的 `EACCES`,CI 实测踩过), + 显式 `--port` 仍大声失败;zstd 单帧回落不再被压缩负载里的巧合帧魔数误判 +- 凭证纪律:ZCode key 与 Claude token 都只在服务进程内使用,不入库、不进前端 + +### 1.6.0(2026-09-21) + +- **新增订阅 ROI**:回答"我这个月 ¥X 的订阅值不值"。配置 `~/.tokenmeter/subscriptions.json` + 的月费后,面板出"订阅 ROI(本月)"卡、CLI 出 `token-watcher roi [--json]`:逐订阅对比 + 本月 **API 等值成本**(与费用卡同一条链路:pricing.json > 官方回退 > LiteLLM×汇率 + 峰谷)与 + **订阅实付**(price_cny / price_usd 二选一)。比值 ×N≥1 标"划算";积分制工具(Qoder) + 如实显示本月积分消耗、不硬造比值;卡片明确标注假设性口径(订阅含速率限制、API 可能 + 有折扣价)。未配置时若订阅制工具本月已有花费,显示引导卡与本月等值合计 +- 配置格式:`{"monthly": {"claude-code": {"name": "Claude Max", "price_usd": 200}, + "kimi": {"name": "Kimi 会员", "price_cny": 49}}}`(默认键为工具 id)。两个进阶字段: + `tool` 指定数据源 id(同一工具可拆多条订阅——GLM 与 MiniMax 的用量都落在 zcode 下); + `models` 按模型前缀过滤聚合范围。月费未填的条目仍显示 API 等值并标"月费未填" + +### 1.5.1(2026-09-21) + +- **修复模型 Top 10 与工具调用榜的悬浮提示张冠李戴**:横向条形图数据画图前做了 + 反转(大的在上),tooltip 却按原顺序数组取下标——悬停榜首显示的是末位模型的 + 调用次数(实测 deepseek-v4.1-flash 显示成 304 次,真实 13018 次;token 数值不受 + 影响)。两处统一改为从反转后数组取值,并补防回归断言。该数字已与原始文件独立 + 重算逐位核对一致 + +### 1.5.0(2026-09-21,已发布) + +对标 [TokenTracker](https://github.com/xiufengsun/TokenTracker) 补齐 11 项能力,同时守住 +本项目的差异化(实时、逐请求粒度、费用对账、零遥测、单包零依赖)。 + +- **新增 Kimi Code、Qoder、Antigravity 三个数据源 + Cursor 账单(共 13 源,Kiro 移除)。** + Kimi Code 解析 `~/.kimi-code/sessions/**/wire.jsonl`(step.end 用量,三种形状自适应, + 项目名来自 workspaces.json);Qoder 解析 `~/.qoder{,-cn}/projects/**`(实测本地只报 + 积分不报 token:积分入独立账本出卡,token 事件待上游恢复上报自动生效)。 + Gemini CLI 上游已于 2026-06-18 官方过渡并入 Antigravity CLI,不再单列。 + 当时 Antigravity 为**估算口径**(旧版日志无逐请求 usage;新版支持见顶部口径说明):输入取 conversations + db 的 gen_metadata 权威上下文增量,输出/思维链按字符估算(CJK×1+其他×¼,与 + TokenTracker 同法),界面标注「估算」;估算法在本机 744 条真实事件上与独立重算逐条 + 核对一致,且处理了上游 transcript 重放历史行的去重(step_index 重复即跳过,防上下文 + 双重膨胀与差分负值覆盖原计费)与权威值晚到的原地补正。三源均在本机真实数据上独立重算逐条核对 +- **Claude 官方配额卡。** 复用 Claude Code 本地 OAuth 凭证(macOS 钥匙串 / Linux·Win 的 + `~/.claude/.credentials.json`)调官方用量端点,显示 5 小时窗、周窗与模型专属周额度的 + 官方百分比和重置倒计时;无凭证/离线时自动回落原有 5h 推算卡。token 只在服务进程内 + 使用,不入库不进前端;`TOKENMETER_NO_KEYCHAIN=1` 可禁用钥匙串读取 +- **CLI 补全**:`today --json`(机器可读,stdout 纯 JSON)与 `--light`(纯 ASCII); + `sessions --day/--from/--to --json/--csv --out`(会话级统计导出);`wrapped`(年度 + 报告,含最猛一天/连续天数/项目 Top,`--json` 可编程消费);`doctor`(环境 + 库完整性 + + 逐源健康体检,有 error 退出码非 0);`uninstall`(摘 LaunchAgent,`--purge-data` + 才动数据目录且需确认);`--version` / `--help` +- **sessions --git 提交归因**:从源文件首段读完整 cwd(events 只存目录名),在 + `[首事件, 末事件+30min]` 窗口内挂 git 提交——把"花了多少 token"连到"产出了哪些 commit"; + 非 git 会话 `commits=null` 不编造 +- **serve 体验**:默认端口被占时自动向后尝试(显式 `--port` 仍大声失败,防菜单栏胶囊 + 连不上);交互终端自动打开面板(`--no-open` 可关,launchd 下不会开);`TOKENMETER_PORT` + 环境变量可改默认端口 +- **Homebrew 分发**:`brew install luwill/tap/token-watcher`(formula 在 + `packaging/homebrew/`,直连 npm tarball) +- Claude 官方配额卡的重置倒计时按 ISO 时间归一,不再恒显 `--` + +### 1.4.4(2026-09-18,未单独发布,随 1.5.0 一同上线) + +- **修复 Codex plus 用户的配额卡显示成 5 小时额度。** Codex 上报的配额分 primary / secondary + 两个位置,但位置不代表窗口:plus 套餐 primary 是 5 小时、secondary 才是周额度;pro 只有 + primary,且是周额度。此前一律取 primary 并标成"周配额",plus 用户看到的其实是 5 小时额度 + (卡片上"0 天窗口"就是 300 分钟被当成天数取整的结果)。现在按窗口时长识别,卡片更名为 + 「Codex 配额」,有几个窗口就显示几条:plus 显示 5 小时与每周两条,pro 只显示每周 +- 修复配额被非主额度的快照顶掉:使用 GPT-5.3-Codex-Spark 时 Codex 会上报一份独立额度, + 另有一种窗口全空的快照,此前只要它们是最新一条,就会让配额卡显示成 Spark 的额度或 0% +- 升级前存下的旧快照无需重扫即可正确显示;下一次使用 Codex 时自动换成完整的窗口数据 +- 重置倒计时满一天时显示"3天23时58分",不再显示成"95时58分" +- 修掉一条会随运行时刻变红的测试:美元折算用例的事件取"1 小时前",落在 DeepSeek 谷时就被减半 + +### 1.4.3(2026-09-17) + +- **修复 OpenCode 漏采约八成消息。** OpenCode 的 assistant 消息是"先插后改":开始生成就插入 + 一行(用量全 0),生成结束才原地更新写入用量。采集器按 rowid 增量,而服务监听数据库写入、 + 生成过程中就会触发扫描——这行被当成 0 用量跳过,水位却越过了它,完成后的更新再也读不到。 + 面板表现为 OpenCode 明明在用却显示"疑似停更"。现改为按 `time_updated` 增量;升级后自动 + 全量重扫,补回历史漏采的消息(实测 99 条中原先只采到 20 条,修复后 99/99、token 总量逐一吻合) +- 修掉一条会随日期自己变红的测试:余额对账用例把事件时刻写死在某一天,而对账窗口是"最近 24 小时" + +### 1.4.2(2026-09-16) + +- **修复 1.4.1 引入的回归:dsh 整源静默归零。** 1.4.1 把 zstd 解压改为优先使用 Node + 内置实现(为解决 launchd 下找不到外部 zstd 的问题),但内置的 `zstdDecompressSync` + 与 `createZstdDecompress` 都只解**第一帧**就结束且不报错。而 dsh 是追加式多帧写入, + 实测单个会话文件有 5800+ 帧——外部 zstd 解出 19MB,内置实现只解出 226 字节。 + 现改为以外部 zstd 为准(仍显式试常见绝对路径以解决 launchd 的 PATH 问题), + 仅在确认文件只含单帧时才回落内置实现,多帧又无外部 zstd 时大声报错而非静默少算。 + **使用 dsh 源的用户请从 1.4.1 升级。** + +- 新增 `token-watcher install-agent` / `uninstall-agent`,把服务装成 macOS 开机自启项。 + 此前 README 指向 `npm run install-agent`,但 npm scripts 对全局安装的用户不可见, + 且那条命令只负责 `launchctl bootstrap`、从不生成 plist,plist 也不随包发布—— + 全局安装的用户实际没有可用的常驻方案 +- 生成的 plist 固化 node 与入口脚本的绝对路径。launchd 的 PATH 不含 npm 全局 bin 与 + homebrew,而入口脚本的 shebang 是 `#!/usr/bin/env node`,靠命令名无法启动 +- 检测到旧 `com.tokenmeter.server` 仍在运行时拒绝安装(两者抢同一端口),可用 `--force` 覆盖 +- 新增 `token-watcher bar`,菜单栏胶囊随包发布。此前 `.app` 只存在于仓库、也不在 + 发布白名单里,全局安装的用户拿不到,而指引用的 `npm run bar` 同样不可见 +- 菜单栏 app 改为 universal 二进制(arm64 + x86_64)。此前产物只有 arm64,Intel Mac + 上无法运行;`prepack` 会在发版前自动重新编译,避免发出陈旧或缺失的产物 +- 菜单栏 app 不再写死 8787 端口,`serve --port` 的用户不会再拿到连不上的胶囊 +- 修掉菜单栏的数量换算:「亿」档阈值错写成 1e6 却按 1e8 换算,200 万会显示成 "0.0亿" + +### 1.4.1(2026-09-16) + +修掉一批让面板数字低于厂商账单的问题。起因是实测发现面板显示 DeepSeek 当日 ¥8.34, +而官方后台是 ¥58.24。定价表本身无误,问题全在数据进库之前。 + +- **ccmr 的输出 token 被钉死在 0**:Claude Code transcript 把一次响应按 content block + 拆成多行,只有终结块带真实 `output_tokens`;ccmr 网关又不写 `requestId`,分片塌成 + 同一去重键,先到者胜留下的正是 output=0 的首块。实测单日丢掉 94.5% 的输出量。 +- **dsh 自 2026-08-14 起整源静默归零**:上游把会话结构换到 v3(`assistant/chunk` → + `assistant/message`,`data.chunk.usage` → `data.usage`),采集器一条也匹配不上, + 却照常返回 0 条且不报错。全库停在 165 条,而恢复后单日就有 790 次请求 / 1.07 亿 token。 +- **守护进程解不开 zstd**:launchd 的 PATH 不含 homebrew 目录,dsh 快照只有手动 + `scan` 才解得开。改为优先使用 Node 内置 zstd(≥23.8),旧版本回落外部可执行文件。 +- **DeepSeek 峰谷价未实现**:谷时用量一律按峰时价折算。峰时为 UTC 周一至周五 + 01:00-04:00 与 06:00-10:00,其余时段半价。 +- **余额对账漏算同账户的工具**:`computeRecon` 写死只统计 ccmr,而 dsh 花的是同一个 + DeepSeek 账户,导致对账永远显示巨大缺口。改为按注册表的 `apiBilled` 标记推导。 + +升级后首次扫描会全量重扫 ccmr 与 dsh 以补正存量数据。 + +### 1.4.0(2026-09-16) + +- 新增 **Pi** 与 **OpenCode** 两个数据源(共 9 源)。两者口径均逐条核对 + `total == input + output + cacheRead + cacheWrite` 后才接入 +- 健康自检的工具清单改为从数据源注册表推导。此前是另抄的一份硬编码数组,两份清单 + 必然漂移,而漏登记的源不会报错,只是从健康自检里静默消失 + +### 1.3.1(2026-09-15) + +- **装成依赖后面板全空**:ECharts 路径写死了嵌套 `node_modules`,而 npm 安装时会把 + 依赖提升到顶层,于是 `/vendor/echarts.min.js` 返回 404、前端整个挂掉。改为交给 + Node 自己的解析算法 +- 修掉两处 Windows 专属问题(目录分隔符解析) + +### 1.3.0(2026-09-15,未发布) + +- 补上与包名同名的 `token-watcher` 命令入口。改名后 bin 有多个条目且都不叫包名, + `npx token-watcher` 失去确定的解析依据 + +### 1.2.0(2026-09-15) + +- **服务不再会悄悄退出**:扫描时源文件被删(Claude 清理会话、Codex 归档搬移都是常态) + 会抛 ENOENT 冲垮整轮扫描,而这发生在防抖定时器回调里,等于未处理 rejection +- `scanning` 标志异常后不复位会让后续每轮扫描都被"并发中"挡掉,面板从此停更 +- 补齐路由错误处理与 `busy_timeout`,一次 SQLITE_BUSY 不再能杀掉进程 +- 清理已消失文件的游标行(只清 `files`,不动 `events`) + +### 1.1.0(2026-09-15) + +- 汇率实时化:公开接口拉取 USD/CNY,12 小时刷新 + 断网兜底 +- 新增逐日 token 消耗图与按天 × 模型堆叠花费图(Top6 + 其他聚合,低价模型不再被静默丢弃) +- 布局重构为两列严格等高对齐 +- 回归测试强化 + +### 1.0.0(2026-09-14) + +- 首个版本:本地多源 AI Agent 用量与配额实时面板,7 源采集 +- 费用折算接入 LiteLLM 实时牌价 +- GitHub 风格热力图、会话钻取 +- 三层回归测试(语法 / 静态断言 / 端到端冒烟) + +## License + +[MIT](LICENSE) diff --git a/src/doctor.js b/src/doctor.js index a32c270..fc24d36 100644 --- a/src/doctor.js +++ b/src/doctor.js @@ -1,119 +1,119 @@ -import { execFileSync } from 'node:child_process'; -import { existsSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; -import { homedir } from 'node:os'; -import { SOURCES, DB_PATH, DATA_DIR, EXTRA_ROOTS, isOffline } from './config.js'; -import { isUnderRoot } from './scanner.js'; -import { zstdInstallHint } from './zstd.js'; -import { computeHealth } from './server.js'; -import { zcodeQuotaTargets } from './zcodeQuota.js'; -import { readClaudeOauthToken } from './claudeUsage.js'; -import { getLeaderboardState } from './leaderboard.js'; - -/** - * token-watcher doctor:把面板上的健康自检带到终端,外加环境与库体检。 - * 退出码:0 = 全绿或有警示(stale/empty);1 = 存在 error(解析失败/库损坏)。 - */ -const pad = (s, n) => String(s).padEnd(n, ' '); -const ICON = { ok: '✓', empty: '·', stale: '⚠', error: '✗' }; - -export async function runDoctor(store, scannerStats, { log = console.log } = {}) { - const problems = []; - - log('环境'); - const v = process.version; - const [maj, min] = v.slice(1).split('.').map(Number); - const nodeOk = maj > 22 || (maj === 22 && min >= 13); - log(` ${nodeOk ? '✓' : '✗'} node ${v}(要求 ≥22.13,node:sqlite 免 flag)`); - if (!nodeOk) problems.push('node 版过低'); - log(` ${isOffline() ? '⚠' : '✓'} 离线模式:${isOffline() ? '已开启(跳过汇率/牌价/余额/配额外网请求)' : '关闭'}`); - - // dsh 源依赖外部 zstd:有 dsh 数据却解不开是最常见的"整源归零"成因 - const hasDsh = !!store.db.prepare("SELECT 1 FROM events WHERE tool = 'dsh' LIMIT 1").get(); - if (hasDsh) { - const zstd = (() => { - try { execFileSync('zstd', ['--version'], { stdio: 'ignore', timeout: 3000 }); return true; } - catch { return false; } - })(); - log(` ${zstd ? '✓' : '⚠'} 系统 zstd(dsh 多帧会话必需):${zstd ? '可用' : '未找到,' + zstdInstallHint()}`); - } - - log('数据库'); - const integrity = String(store.db.prepare('PRAGMA quick_check').get()?.quick_check ?? 'unknown'); - log(` ${integrity === 'ok' ? '✓' : '✗'} PRAGMA quick_check: ${integrity}`); - if (integrity !== 'ok') problems.push('数据库完整性检查失败'); - const { n, total } = store.countEvents(); - log(` ✓ ${DB_PATH}(${n} 事件 / ${(total / 1e6).toFixed(1)}M tokens)`); - try { - const backups = readdirSync(join(DATA_DIR, 'backups')).filter(f => f.endsWith('.db')); - log(` ${backups.length ? '✓' : '⚠'} 每日备份:${backups.length ? `最近 ${backups.slice(-1)[0]}(共 ${backups.length} 份)` : '尚无(服务跑满 30 秒后生成)'}`); - } catch { log(' ⚠ 每日备份:尚无(服务跑满 30 秒后生成)'); } - - log('数据源'); - const health = computeHealth(store.db, scannerStats || {}); - for (const h of health) { - const src = SOURCES.find(s => s.tool === h.tool); - const existsOnDisk = (src?.roots || []).some(r => existsSync(r)); - const icon = ICON[h.status] ?? '?'; - const tail = []; - if (!existsOnDisk) tail.push('未安装(正常跳过)'); - if (h.status === 'stale') tail.push('文件在写但无新事件,疑似格式漂移'); - if (h.status === 'error') tail.push(`解析失败:${h.last_error ?? ''}`); - if (h.last_error && h.status !== 'error') tail.push(`上次错误:${h.last_error}`); - log(` ${icon} ${pad(src?.label ?? h.tool, 12)} ${pad(h.status, 5)} 事件 ${String(h.events).padStart(7)} ${tail.join(';')}`); - if (h.status === 'error') problems.push(`${h.tool} 解析失败`); - } - - // extra roots:多机合并的镜像目录要看得见——同步悄悄断了没人发现,统计会静默缺量 - log('扩展 root(多机合并镜像)'); - const extraPairs = Object.entries(EXTRA_ROOTS).flatMap(([t, roots]) => roots.map((r) => [t, r])); - if (!extraPairs.length) { - log(' · 未配置(可选;多台机器用量合并的配方见 README「extra roots」)'); - } else { - for (const [tool, root] of extraPairs) { - if (!existsSync(root)) { log(` ⚠ ${tool} ${root} 目录不存在(未同步/未挂载?恢复后自动续扫)`); continue; } - let count = 0, newest = 0; - for (const r of store.db.prepare('SELECT path, mtime_ms FROM files WHERE tool = ?').all(tool)) { - if (!isUnderRoot(r.path, root)) continue; - count++; - if (r.mtime_ms > newest) newest = r.mtime_ms; - } - if (!count) { log(` · ${tool} ${root} 尚无数据(首次同步后自动入库)`); continue; } - const idleDays = Math.floor((Date.now() - newest) / 86400_000); - const when = new Date(newest).toLocaleString('zh-CN'); - log(` ${idleDays > 7 ? '⚠' : '✓'} ${tool} ${root}(${count} 文件,最新 ${when}${idleDays > 7 ? `,${idleDays} 天无新数据(远端未用或同步中断)` : ''})`); - } - } - - // 凭证探测只报"有没有",不打印任何值 - log('凭证(只探测存在性,不显示内容)'); - const envPath = join(homedir(), '.ccmr', '.env'); - log(` ${existsSync(envPath) ? '✓' : '·'} ${envPath}${existsSync(envPath) ? '' : '(无 → 余额轮询跳过,不影响其他功能)'}`); - const claudeTok = await readClaudeOauthToken().catch(() => null); - log(` ${claudeTok ? '✓' : '·'} Claude Code OAuth 凭证${claudeTok ? '(官方配额轮询可用)' : '(无 → 面板用 5h 窗口推算兜底)'}`); - const zcodeTargets = await zcodeQuotaTargets().catch(() => []); - log(` ${zcodeTargets.length ? '✓' : '·'} ZCode coding-plan API key${zcodeTargets.length ? `(GLM 配额轮询可用,${zcodeTargets.length} 个)` : '(无 → GLM 配额卡跳过,不影响其他功能)'}`); - - // 排行榜是唯一的出网数据通道,参与状态必须可见(默认关闭也如实展示,不制造"偷偷上报"疑虑) - log('社区排行榜'); - const lb = getLeaderboardState(store); - if (!lb.enabled) { - log(' · 未参与(默认;token-watcher leaderboard on <昵称> 加入,只上报聚合数字)'); - } else { - const when = lb.last_push_ms ? new Date(lb.last_push_ms).toLocaleString('zh-CN') : '尚未上报'; - log(` ${lb.last_error ? '⚠' : '✓'} 已参与(昵称「${lb.name}」→ ${lb.url})`); - log(` 上次上报 ${when}${lb.last_error ? ` · 失败:${lb.last_error}` : ''}`); - // 上报失败只警示不置 error:可选的虚荣功能不应把整个 doctor 判红 - } - - log(problems.length ? `结论:${problems.length} 项需要处理(见上)` : '结论:未发现问题'); - return { ok: problems.length === 0, problems }; -} - -/** 供 bin 使用:先扫一轮拿到新鲜 parse_errors,再体检 */ -export async function doctorCommand(store, { log = console.log } = {}) { - const { Scanner } = await import('./scanner.js'); - const scanner = new Scanner(store, { log: () => {} }); - await scanner.scanAll({ quiet: true }); - return runDoctor(store, scanner.stats, { log }); -} +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { SOURCES, DB_PATH, DATA_DIR, EXTRA_ROOTS, isOffline } from './config.js'; +import { isUnderRoot } from './scanner.js'; +import { zstdInstallHint } from './zstd.js'; +import { computeHealth } from './server.js'; +import { zcodeQuotaTargets } from './zcodeQuota.js'; +import { readClaudeOauthToken } from './claudeUsage.js'; +import { getLeaderboardState } from './leaderboard.js'; + +/** + * token-watcher doctor:把面板上的健康自检带到终端,外加环境与库体检。 + * 退出码:0 = 全绿或有警示(stale/empty);1 = 存在 error(解析失败/库损坏)。 + */ +const pad = (s, n) => String(s).padEnd(n, ' '); +const ICON = { ok: '✓', empty: '·', stale: '⚠', error: '✗' }; + +export async function runDoctor(store, scannerStats, { log = console.log } = {}) { + const problems = []; + + log('环境'); + const v = process.version; + const [maj, min] = v.slice(1).split('.').map(Number); + const nodeOk = maj > 22 || (maj === 22 && min >= 13); + log(` ${nodeOk ? '✓' : '✗'} node ${v}(要求 ≥22.13,node:sqlite 免 flag)`); + if (!nodeOk) problems.push('node 版过低'); + log(` ${isOffline() ? '⚠' : '✓'} 离线模式:${isOffline() ? '已开启(跳过汇率/牌价/余额/配额外网请求)' : '关闭'}`); + + // dsh 源依赖外部 zstd:有 dsh 数据却解不开是最常见的"整源归零"成因 + const hasDsh = !!store.db.prepare("SELECT 1 FROM events WHERE tool = 'dsh' LIMIT 1").get(); + if (hasDsh) { + const zstd = (() => { + try { execFileSync('zstd', ['--version'], { stdio: 'ignore', timeout: 3000 }); return true; } + catch { return false; } + })(); + log(` ${zstd ? '✓' : '⚠'} 系统 zstd(dsh 多帧会话必需):${zstd ? '可用' : '未找到,' + zstdInstallHint()}`); + } + + log('数据库'); + const integrity = String(store.db.prepare('PRAGMA quick_check').get()?.quick_check ?? 'unknown'); + log(` ${integrity === 'ok' ? '✓' : '✗'} PRAGMA quick_check: ${integrity}`); + if (integrity !== 'ok') problems.push('数据库完整性检查失败'); + const { n, total } = store.countEvents(); + log(` ✓ ${DB_PATH}(${n} 事件 / ${(total / 1e6).toFixed(1)}M tokens)`); + try { + const backups = readdirSync(join(DATA_DIR, 'backups')).filter(f => f.endsWith('.db')); + log(` ${backups.length ? '✓' : '⚠'} 每日备份:${backups.length ? `最近 ${backups.slice(-1)[0]}(共 ${backups.length} 份)` : '尚无(服务跑满 30 秒后生成)'}`); + } catch { log(' ⚠ 每日备份:尚无(服务跑满 30 秒后生成)'); } + + log('数据源'); + const health = computeHealth(store.db, scannerStats || {}); + for (const h of health) { + const src = SOURCES.find(s => s.tool === h.tool); + const existsOnDisk = (src?.roots || []).some(r => existsSync(r)); + const icon = ICON[h.status] ?? '?'; + const tail = []; + if (!existsOnDisk) tail.push('未安装(正常跳过)'); + if (h.status === 'stale') tail.push('文件在写但无新事件,疑似格式漂移'); + if (h.status === 'error') tail.push(`解析失败:${h.last_error ?? ''}`); + if (h.last_error && h.status !== 'error') tail.push(`上次错误:${h.last_error}`); + log(` ${icon} ${pad(src?.label ?? h.tool, 12)} ${pad(h.status, 5)} 事件 ${String(h.events).padStart(7)} ${tail.join(';')}`); + if (h.status === 'error') problems.push(`${h.tool} 解析失败`); + } + + // extra roots:多机合并的镜像目录要看得见——同步悄悄断了没人发现,统计会静默缺量 + log('扩展 root(多机合并镜像)'); + const extraPairs = Object.entries(EXTRA_ROOTS).flatMap(([t, roots]) => roots.map((r) => [t, r])); + if (!extraPairs.length) { + log(' · 未配置(可选;多台机器用量合并的配方见 README「extra roots」)'); + } else { + for (const [tool, root] of extraPairs) { + if (!existsSync(root)) { log(` ⚠ ${tool} ${root} 目录不存在(未同步/未挂载?恢复后自动续扫)`); continue; } + let count = 0, newest = 0; + for (const r of store.db.prepare('SELECT path, mtime_ms FROM files WHERE tool = ?').all(tool)) { + if (!isUnderRoot(r.path, root)) continue; + count++; + if (r.mtime_ms > newest) newest = r.mtime_ms; + } + if (!count) { log(` · ${tool} ${root} 尚无数据(首次同步后自动入库)`); continue; } + const idleDays = Math.floor((Date.now() - newest) / 86400_000); + const when = new Date(newest).toLocaleString('zh-CN'); + log(` ${idleDays > 7 ? '⚠' : '✓'} ${tool} ${root}(${count} 文件,最新 ${when}${idleDays > 7 ? `,${idleDays} 天无新数据(远端未用或同步中断)` : ''})`); + } + } + + // 凭证探测只报"有没有",不打印任何值 + log('凭证(只探测存在性,不显示内容)'); + const envPath = join(homedir(), '.ccmr', '.env'); + log(` ${existsSync(envPath) ? '✓' : '·'} ${envPath}${existsSync(envPath) ? '' : '(无 → 余额轮询跳过,不影响其他功能)'}`); + const claudeTok = await readClaudeOauthToken().catch(() => null); + log(` ${claudeTok ? '✓' : '·'} Claude Code OAuth 凭证${claudeTok ? '(官方配额轮询可用)' : '(无 → 面板用 5h 窗口推算兜底)'}`); + const zcodeTargets = await zcodeQuotaTargets().catch(() => []); + log(` ${zcodeTargets.length ? '✓' : '·'} ZCode coding-plan API key${zcodeTargets.length ? `(GLM 配额轮询可用,${zcodeTargets.length} 个)` : '(无 → GLM 配额卡跳过,不影响其他功能)'}`); + + // 排行榜是唯一的出网数据通道,参与状态必须可见(默认关闭也如实展示,不制造"偷偷上报"疑虑) + log('社区排行榜'); + const lb = getLeaderboardState(store); + if (!lb.enabled) { + log(' · 未参与(默认;token-watcher leaderboard on <昵称> 加入,只上报聚合数字)'); + } else { + const when = lb.last_push_ms ? new Date(lb.last_push_ms).toLocaleString('zh-CN') : '尚未上报'; + log(` ${lb.last_error ? '⚠' : '✓'} 已参与(昵称「${lb.name}」→ ${lb.url})`); + log(` 上次上报 ${when}${lb.last_error ? ` · 失败:${lb.last_error}` : ''}`); + // 上报失败只警示不置 error:可选的虚荣功能不应把整个 doctor 判红 + } + + log(problems.length ? `结论:${problems.length} 项需要处理(见上)` : '结论:未发现问题'); + return { ok: problems.length === 0, problems }; +} + +/** 供 bin 使用:先扫一轮拿到新鲜 parse_errors,再体检 */ +export async function doctorCommand(store, { log = console.log } = {}) { + const { Scanner } = await import('./scanner.js'); + const scanner = new Scanner(store, { log: () => {} }); + await scanner.scanAll({ quiet: true }); + return runDoctor(store, scanner.stats, { log }); +} diff --git a/src/zstd.js b/src/zstd.js index bec6d06..b82798b 100644 --- a/src/zstd.js +++ b/src/zstd.js @@ -1,110 +1,110 @@ -import { execFile, spawn } from 'node:child_process'; -import { promisify } from 'node:util'; -import { readFile } from 'node:fs/promises'; -import zlib from 'node:zlib'; - -const execFileP = promisify(execFile); -const ZSTD_BINS = ['zstd', '/opt/homebrew/bin/zstd', '/usr/local/bin/zstd', '/usr/bin/zstd']; -const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]); - -/** - * 跨平台「如何安装 zstd」提示。dsh 多帧会话必须用外部 zstd CLI 解压, - * 但旧报错只给了 macOS 的 `brew install zstd`,Windows/Linux 用户无解。 - * 这里按 process.platform 给出对应包管理器命令或官方二进制下载地址。 - */ -export function zstdInstallHint() { - switch (process.platform) { - case 'win32': - return '请安装 zstd:scoop install zstd 或 choco install zstd;也可从 https://github.com/facebook/zstd/releases 下载 zstd.exe 并加入 PATH'; - case 'darwin': - return '请安装 zstd:brew install zstd'; - case 'linux': - return '请安装 zstd:apt-get install -y zstd(Debian/Ubuntu)| dnf install -y zstd(Fedora)| pacman -S zstd(Arch)'; - default: - return '请通过系统包管理器安装 zstd'; - } -} - - -/** Decompress a complete zstd stream, including concatenated frames. */ -export async function decompressZstd(path, { bins = ZSTD_BINS } = {}) { - for (const bin of bins) { - try { - const { stdout } = await execFileP(bin, ['-dc', path], { - encoding: 'buffer', maxBuffer: 1024 * 1024 * 1024, - }); - return stdout; - } catch (err) { - if (err.code !== 'ENOENT') throw err; - } - } - - if (typeof zlib.zstdDecompressSync === 'function') { - const buf = await readFile(path); - // Node's built-in decoder currently stops after the first frame. Confirm any candidate - // boundary by successfully decoding the prefix so payload bytes resembling magic do not - // create a false multi-frame diagnosis. - let multi = false; - let idx = buf.indexOf(ZSTD_MAGIC, 1); - while (idx !== -1) { - try { - zlib.zstdDecompressSync(buf.subarray(0, idx)); - multi = true; - break; - } catch { /* A magic-like sequence inside the frame payload is not a frame boundary. */ } - idx = buf.indexOf(ZSTD_MAGIC, idx + 1); - } - if (multi) throw new Error(`${path} 含多个 zstd 帧,Node 内置实现只能解第一帧。${zstdInstallHint()}`); - return zlib.zstdDecompressSync(buf); - } - - throw new Error(`zstd 不可用:PATH 与常见安装路径下均无 zstd。${zstdInstallHint()}`); -} - -/** - * 流式解压并逐行产出(外部 zstd CLI:spawn + 增量切行)。 - * - * 大归档会话解压后可超 V8 字符串上限(~512MiB,issue #12:331MB 压缩档每轮扫描 - * parse error),整缓冲 + toString 的路走不通,必须边解边消费。行语义与旧的整串 - * 循环严格一致:按 \n 切分,尾部无换行的残行丢弃(压缩快照按完整行解析)。 - * 解压途中失败(文件截断/损坏)在产出结束后抛出——scanner 的逐文件事务会整体 - * 回滚,保持"坏档案是单文件错误而非静默跳过"。无 CLI 时回落内置整缓冲实现 - * (仅单帧,小文件场景;超大文件仍会撞上限,是无 CLI 环境的既有边界)。 - */ -export async function* decompressZstdLines(path, { bins = ZSTD_BINS } = {}) { - let child = null; - for (const bin of bins) { - const c = spawn(bin, ['-dc', path], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); - // 'spawn' = 进程真的起来了;起不来(ENOENT 等)异步走 'error',换下一个候选 - if (await new Promise(res => { c.on('spawn', () => res(true)); c.on('error', () => res(false)); })) { - child = c; - break; - } - } - if (!child) { - const text = (await decompressZstd(path, { bins })).toString('utf8'); - let start = 0, end; - while ((end = text.indexOf('\n', start)) !== -1) { yield text.slice(start, end); start = end + 1; } - return; - } - let errText = ''; - child.stderr.setEncoding('utf8'); - child.stderr.on('data', d => { if (errText.length < 4096) errText += d; }); - // setEncoding 后 chunk 是 string,跨 chunk 的多字节 UTF-8 由内部 StringDecoder 处理 - child.stdout.setEncoding('utf8'); - try { - let rest = ''; - for await (const chunk of child.stdout) { - rest += chunk; - let i; - while ((i = rest.indexOf('\n')) !== -1) { - yield rest.slice(0, i); - rest = rest.slice(i + 1); - } - } - const code = await new Promise(res => child.on('close', res)); - if (code !== 0) throw new Error(`zstd 解压失败(exit ${code}):${errText.trim().slice(0, 200)}`); - } finally { - if (child.exitCode === null && !child.killed) child.kill(); // 消费方提前退出时收掉子进程 - } -} +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import { readFile } from 'node:fs/promises'; +import zlib from 'node:zlib'; + +const execFileP = promisify(execFile); +const ZSTD_BINS = ['zstd', '/opt/homebrew/bin/zstd', '/usr/local/bin/zstd', '/usr/bin/zstd']; +const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]); + +/** + * 跨平台「如何安装 zstd」提示。dsh 多帧会话必须用外部 zstd CLI 解压, + * 但旧报错只给了 macOS 的 `brew install zstd`,Windows/Linux 用户无解。 + * 这里按 process.platform 给出对应包管理器命令或官方二进制下载地址。 + */ +export function zstdInstallHint() { + switch (process.platform) { + case 'win32': + return '请安装 zstd:scoop install zstd 或 choco install zstd;也可从 https://github.com/facebook/zstd/releases 下载 zstd.exe 并加入 PATH'; + case 'darwin': + return '请安装 zstd:brew install zstd'; + case 'linux': + return '请安装 zstd:apt-get install -y zstd(Debian/Ubuntu)| dnf install -y zstd(Fedora)| pacman -S zstd(Arch)'; + default: + return '请通过系统包管理器安装 zstd'; + } +} + + +/** Decompress a complete zstd stream, including concatenated frames. */ +export async function decompressZstd(path, { bins = ZSTD_BINS } = {}) { + for (const bin of bins) { + try { + const { stdout } = await execFileP(bin, ['-dc', path], { + encoding: 'buffer', maxBuffer: 1024 * 1024 * 1024, + }); + return stdout; + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + } + + if (typeof zlib.zstdDecompressSync === 'function') { + const buf = await readFile(path); + // Node's built-in decoder currently stops after the first frame. Confirm any candidate + // boundary by successfully decoding the prefix so payload bytes resembling magic do not + // create a false multi-frame diagnosis. + let multi = false; + let idx = buf.indexOf(ZSTD_MAGIC, 1); + while (idx !== -1) { + try { + zlib.zstdDecompressSync(buf.subarray(0, idx)); + multi = true; + break; + } catch { /* A magic-like sequence inside the frame payload is not a frame boundary. */ } + idx = buf.indexOf(ZSTD_MAGIC, idx + 1); + } + if (multi) throw new Error(`${path} 含多个 zstd 帧,Node 内置实现只能解第一帧。${zstdInstallHint()}`); + return zlib.zstdDecompressSync(buf); + } + + throw new Error(`zstd 不可用:PATH 与常见安装路径下均无 zstd。${zstdInstallHint()}`); +} + +/** + * 流式解压并逐行产出(外部 zstd CLI:spawn + 增量切行)。 + * + * 大归档会话解压后可超 V8 字符串上限(~512MiB,issue #12:331MB 压缩档每轮扫描 + * parse error),整缓冲 + toString 的路走不通,必须边解边消费。行语义与旧的整串 + * 循环严格一致:按 \n 切分,尾部无换行的残行丢弃(压缩快照按完整行解析)。 + * 解压途中失败(文件截断/损坏)在产出结束后抛出——scanner 的逐文件事务会整体 + * 回滚,保持"坏档案是单文件错误而非静默跳过"。无 CLI 时回落内置整缓冲实现 + * (仅单帧,小文件场景;超大文件仍会撞上限,是无 CLI 环境的既有边界)。 + */ +export async function* decompressZstdLines(path, { bins = ZSTD_BINS } = {}) { + let child = null; + for (const bin of bins) { + const c = spawn(bin, ['-dc', path], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); + // 'spawn' = 进程真的起来了;起不来(ENOENT 等)异步走 'error',换下一个候选 + if (await new Promise(res => { c.on('spawn', () => res(true)); c.on('error', () => res(false)); })) { + child = c; + break; + } + } + if (!child) { + const text = (await decompressZstd(path, { bins })).toString('utf8'); + let start = 0, end; + while ((end = text.indexOf('\n', start)) !== -1) { yield text.slice(start, end); start = end + 1; } + return; + } + let errText = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', d => { if (errText.length < 4096) errText += d; }); + // setEncoding 后 chunk 是 string,跨 chunk 的多字节 UTF-8 由内部 StringDecoder 处理 + child.stdout.setEncoding('utf8'); + try { + let rest = ''; + for await (const chunk of child.stdout) { + rest += chunk; + let i; + while ((i = rest.indexOf('\n')) !== -1) { + yield rest.slice(0, i); + rest = rest.slice(i + 1); + } + } + const code = await new Promise(res => child.on('close', res)); + if (code !== 0) throw new Error(`zstd 解压失败(exit ${code}):${errText.trim().slice(0, 200)}`); + } finally { + if (child.exitCode === null && !child.killed) child.kill(); // 消费方提前退出时收掉子进程 + } +}