numable-codex-usage 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,162 @@
1
- # Temporary Holding Version
1
+ # Codex 用量采集器
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 在电脑上统计 Codex 用量,并按需同步到 Numable。首发版本为 `0.1.0`;各平台验证范围见文末兼容矩阵。
4
+
5
+ 需要 Node.js 18.17 或以上;建议使用当前长期支持版本。采集器不假设 Codex 自带可供外部使用的 Node.js。
6
+
7
+ ## 本机查看
8
+
9
+ 安装后可直接使用 `numable-codex`:
10
+
11
+ ```sh
12
+ npm install -g numable-codex-usage
13
+ numable-codex init
14
+ numable-codex collect
15
+ numable-codex summary
16
+ ```
17
+
18
+ 从源码运行:
19
+
20
+ ```sh
21
+ node codex/cli.cjs init
22
+ node codex/cli.cjs collect
23
+ node codex/cli.cjs summary
24
+ node codex/cli.cjs status
25
+ ```
26
+
27
+ `devices` 列出同一空间已接入的电脑;`forget --device <标识>` 删除指定的其他电脑在服务端的用量行,拒绝删除正在采集的本机。
28
+
29
+ Codex 的设备删除同时清除历史统计和实时视图。删除不撤销该电脑已有的写入凭证,也不清理它的本机历史;仍在同步的电脑有新活动或下一次实时补偿上报时可以重新出现。想持续移除一台电脑,应先在那台电脑运行 `pause` 或 `uninstall`,再从另一台电脑执行 `forget`。服务在 24 小时内保留删除前的实时序号,拒绝旧包复活会话;更新的正常上报仍可恢复。
30
+
31
+ 本机采集默认不联网。配置与历史保存在 `~/.numable/codex-usage/`,目录及文件分别以仅当前用户可访问的权限创建。
32
+
33
+ 首次扫描需要读取现有会话;之后只处理追加的完整行。支持 `sessions/` 与 `archived_sessions/`,gzip 归档可直接读取;zstd 归档需要系统提供 `zstd`。缺少解码器时会标注未读取,不伪装成零用量。
34
+
35
+ 默认读取 `CODEX_HOME` 或 `~/.codex`。可以用多个 `--home` 指定目录,用 `--timezone Asia/Kuala_Lumpur` 固定统计时区。每个命令均支持 `--state-dir` 指定独立状态目录。
36
+
37
+ ```sh
38
+ node codex/cli.cjs init --home /自己的/Codex目录 --timezone Asia/Kuala_Lumpur
39
+ node codex/cli.cjs collect --rebuild
40
+ node codex/cli.cjs export
41
+ ```
42
+
43
+ 重算将按现存日志替换历史;已被 Codex 清理的历史无法重建。执行重算前请保留原 `history.json`。
44
+
45
+ ## 额度
46
+
47
+ 日志包含额度时会保留观测快照;额度窗口到期后仍须等待新观测,不自动恢复成满额。
48
+
49
+ ```sh
50
+ node codex/cli.cjs quota
51
+ node codex/cli.cjs quota --codex-binary /自己的/Codex可执行文件
52
+ node codex/cli.cjs quota-auto-on
53
+ ```
54
+
55
+ 主动读取通过本机 Codex 协议完成,不创建会话,不要求复制 OpenAI 登录凭证。账号标识在本机匿名化;加入同一空间后使用空间范围的匿名标识,以便服务端合并同账号多台电脑的额度。不能识别账号的日志快照按设备分别展示。
56
+
57
+ `quota-auto-on` 显式开启每五分钟读取,需后台采集运行;`quota-auto-off` 关闭。多个目录分别连接,单个目录失败会保留其旧观测并标注不完整。macOS 的命令行启动器不可用时,会尝试已安装桌面端的内置可执行文件;也可显式指定路径。
58
+
59
+ ## 手机同步
60
+
61
+ 默认同步地址为 `https://usage.numable.app`。在自己的电脑显式运行 `numable-codex connect` 开启同步,再运行 `numable-codex collect`。在 Numable 海外商店安装「Codex 用量」,将 `numable-codex credentials` 输出的读取凭证绑定到工具。同步默认关闭,安装不会自动上传历史。
62
+
63
+ 以下为独立本机开发示例,不要把夹具发到生产。本地服务可用 `service/scripts/local-codex.mjs` 启动;用 `NUMABLE_TEST_WRANGLER` 指定已经安装的 `wrangler.js`。
64
+
65
+ ```sh
66
+ node codex/cli.cjs init --state-dir /自己的/测试状态目录 --endpoint http://127.0.0.1:8787
67
+ node codex/cli.cjs connect --state-dir /自己的/测试状态目录
68
+ node codex/cli.cjs collect --state-dir /自己的/测试状态目录
69
+ node codex/cli.cjs credentials --state-dir /自己的/测试状态目录
70
+ ```
71
+
72
+ `credentials` 显示手机使用的读取凭证;请直接粘贴到 Numable 的凭证绑定入口,不要贴到公开对话、截图或提交记录。
73
+
74
+ `pause` 暂停手机同步,`resume` 恢复已绑定的同步。并发采集的晚到响应不会撤销暂停、卸载或额度自动读取关闭;实时请求在发送前会再次检查当前开关。已经发出的网络请求无法撤回,暂停不删除此前同步的数据。所有网络操作有超时,且不会跨重定向发送凭证。已绑定的状态目录不能随意切换服务地址,调试请使用独立目录。
75
+
76
+ `link` 生成加入串,`join` 从标准输入接收加入串,将自己的另一台电脑加入同一空间。加入串包含写入权限,只在自己的设备间传递,不能当作手机读取凭证分享。
77
+
78
+ ## 自动采集
79
+
80
+ ```sh
81
+ node codex/cli.cjs install
82
+ node codex/cli.cjs live-on
83
+ node codex/cli.cjs start
84
+ node codex/cli.cjs stop
85
+ node codex/cli.cjs uninstall
86
+ ```
87
+
88
+ 安装保留已有钩子;升级时仅替换上次安装记录中完整匹配的本工具定义,用户修改过的定义保留。安装之后,需要在 Codex 中检查并信任新增定义才会执行;采集器不会绕过信任检查。卸载只移除完整匹配的本工具定义,保留本机历史并暂停同步。
89
+
90
+ `uninstall` 不删除服务端已有统计或实时记录,也不等于卸载 npm 程序。需要立即清除实时显示时,可先运行 `live-off` 并确认同步成功,再卸载;删除全部已投递数据需按上面的设备删除流程操作。实时视图正常保留 24 小时,历史投递空间在 90 天无推送后回收。
91
+
92
+ Windows 安装时,钩子通过系统 `powershell.exe -NoProfile -NonInteractive -EncodedCommand` 启动。编码载荷使用 UTF-16LE,内部以调用运算符和单引号字面量传递 Node、安装目录与状态目录,保留空格、中文及特殊字符;不修改 PowerShell 执行策略。升级后请在 Codex 重新审查发生变化的定义。
93
+
94
+ 事件钩子立即启动不持有原会话输入输出的后台采集进程。`watch` 可在终端保持运行,每 60 秒补偿扫描;`stop` 通过本工具的停止标记请求退出,不根据一个旧 PID 杀进程。
95
+
96
+ `start` 在后台运行并确认进程取得采集锁,重复启动复用现有进程。实时默认关闭,`live-on` 开启,`live-off` 清除本机实时视图并在下次同步时清空服务端本设备实时视图;`live` 查看当前投影。钩子先脱敏入队,采集锁繁忙时继续排队,不等待采集完成。状态只由真实事件更新,无事件时不会通过扫描伪造心跳。
97
+
98
+ 服务实时视图只保留最近 24 小时、最多 40 个会话与每会话最近 60 轮;传输接近上限时继续裁旧轮并显示省略数量。轮次结束、失败、中断、会话结束分别显示。只有明确目标完成事件才触发目标完成提醒,不保存目标正文。
99
+
100
+ ## 数据与隐私
101
+
102
+ - 本机只保存数字、匿名身份、模型标识、时间与固定分类,不保存对话、代码、命令原文、工具参数或登录凭证。
103
+ - `--projects on` 显式开启项目文件夹名称;默认关闭。关闭后下次采集会清除本机投影中的项目名。
104
+ - 总量是去重后的输入加输出;缓存读取包含在输入中,推理包含在输出中,两者不额外相加。
105
+ - 主会话数不包含子代理;用量包含子代理响应。分叉和归档副本按响应身份去重。
106
+ - 活跃分钟表示有活动记录的分钟数,同一设备内重叠分钟只算一次;不是人工工作时长。
107
+ - 历史范围是已接入电脑的本地记录,不代表账号所有云端活动。跨时区合并保留各采集器的自然日口径。
108
+
109
+ ## 验证
110
+
111
+ ```sh
112
+ node --test test/codex-collect.test.cjs test/codex-cli.test.cjs test/codex-quota.test.cjs test/codex-service.test.cjs
113
+ ```
114
+
115
+ 完整路由测试使用真实 SQLite,需 Node.js 22 或以上:
116
+
117
+ ```sh
118
+ node --test test/codex-worker.test.cjs
119
+ ```
120
+
121
+ 已启动独立本机服务时,可运行模拟双电脑接入闭环(临时配置与虚构日志,不修改真实 Codex 设置):
122
+
123
+ ```sh
124
+ node service/scripts/verify-codex-cli.mjs
125
+ node service/scripts/verify-codex-package.mjs
126
+ ```
127
+
128
+ 默认连接 `http://127.0.0.1:8797`,可用 `CODEX_VERIFY_BASE` 改成本机其他端口,脚本拒绝公网端点。这验证协议与命令行协作,不能代替两台真实电脑的操作系统验收。
129
+
130
+ 安装包验收脚本不需要本机服务:先打 tgz,再离线安装到含中文、空格与单引号的临时全局前缀,使用已安装命令完成采集、钩子执行和卸载。当前覆盖 POSIX 安装布局,Windows 安装布局仍需原生验收。
131
+
132
+ 可独立使用真实 Codex 校验安装定义与信任状态(无需登录,不提交模型请求,不修改真实配置):
133
+
134
+ ```sh
135
+ NUMABLE_CODEX_BINARY=/自己的/codex/可执行文件 node service/scripts/verify-codex-hook-runtime.mjs
136
+ ```
137
+
138
+ 此脚本只核对真实协议清单、事件 / 超时 / 异步参数和未信任状态,不代替信任后的生命周期触发验收。定义升级后,Codex 会要求重新审查发生变化的钩子。
139
+
140
+ Windows 主机可运行钩子命令验收:
141
+
142
+ ```sh
143
+ node --test test/codex-hook-command.test.cjs test/codex-cli.test.cjs
144
+ ```
145
+
146
+ Windows 上默认使用系统 Windows PowerShell,并额外经 cmd / PowerShell 两种外层 shell 执行生成命令。其他系统可通过 `NUMABLE_TEST_POWERSHELL` 指定已有 PowerShell 运行时,验证内部字面量、标准输入和退出码;这不能证明 Windows 外层 shell、npm `.cmd` 入口或原生 Codex 的事件触发已通过。
147
+
148
+ ## 当前兼容证据
149
+
150
+ | 范围 | 已有证据 | 尚未证明 |
151
+ |---|---|---|
152
+ | macOS 本机采集 | Node.js 24;真实 99 文件增量扫描、gzip / zstd、三种记录版本 | 所有 Codex 版本及所有入口 |
153
+ | 历史版本 | 0.147.0-alpha.6.5、0.159.2、0.163.0-alpha.10.2 的现存日志可解析 | 这些版本完整启动、恢复、分叉与休眠流程 |
154
+ | 额度 | 桌面端内置可执行文件的真实只读协议;多目录失败保留夹具 | 真实账号切换和 Windows 可执行文件发现 |
155
+ | 安装包 | tgz 六文件、离线安装、带空格 / 中文 / 单引号路径、生成钩子命令实际执行、卸载保留历史 | Windows npm 命令入口及钩子执行环境 |
156
+ | Windows 命令生成 | macOS 上官方 PowerShell 7.6.7 实际运行:复杂路径、Unicode 标准输入、0 / 23 退出码 | Windows PowerShell 5.1、cmd / PowerShell 外层、npm 命令入口与启动耗时 |
157
+ | 生命周期钩子 | 官方字段核对、并发进程队列、原始正文不落盘、SessionEnd / Interrupt 三秒上限;真实 app-server 识别全部 11 条定义、未信任状态与卸载后清单为空 | 在 Codex 内经过真实信任流程后触发 |
158
+ | 多设备 | 两份临时配置接真实本机服务的完整加入、合并、暂停恢复与删除 | 两台物理电脑及跨操作系统实操 |
159
+
160
+ 默认后台采集只在当前用户会话中运行;`start` 不注册开机启动服务。电脑重启后需再次运行。真实钩子和定时补偿互补,钩子尚未信任时仍可手动 `collect` 或运行 `start` 采集历史。
161
+
162
+ Windows、不同 Codex 入口、四端真机与线上发布进度见 meta 仓 `doc/codex-usage-impl-plan.md`,不能用本机测试通过代替。
package/cli.cjs ADDED
@@ -0,0 +1,422 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const crypto = require('node:crypto');
7
+ const { spawn } = require('node:child_process');
8
+ const { collect, initialState, atomicWrite, hash } = require('./lib/collect.cjs');
9
+ const { readQuotaHomes } = require('./lib/quota.cjs');
10
+ const { sanitizeHook, absorbHook, buildLive } = require('./lib/live.cjs');
11
+
12
+ function parse(args) {
13
+ const out = { command: args[0] || 'status', homes: [] };
14
+ for (let i = 1; i < args.length; i++) {
15
+ const key = args[i];
16
+ if (['--home', '--state-dir', '--timezone', '--endpoint', '--projects', '--codex-binary', '--device'].includes(key)) {
17
+ const value = args[++i]; if (!value || value.startsWith('--')) throw new Error('选项缺少参数');
18
+ if (key === '--home') out.homes.push(path.resolve(value));
19
+ else out[key.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value;
20
+ } else if (['--json', '--quiet', '--rebuild', '--hook-worker'].includes(key)) out[key.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = true;
21
+ else throw new Error('无法识别选项,请运行 help 查看用法');
22
+ }
23
+ return out;
24
+ }
25
+ function paths(args) {
26
+ const dir = path.resolve(args.stateDir || process.env.NUMABLE_CODEX_STATE_DIR || path.join(os.homedir(), '.numable', 'codex-usage'));
27
+ return { dir, config: path.join(dir, 'config.json'), history: path.join(dir, 'history.json'), snapshot: path.join(dir, 'snapshot.json'), lock: path.join(dir, 'collect.lock') };
28
+ }
29
+ function read(file, fallback) {
30
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
31
+ catch (e) { if (e.code === 'ENOENT') return fallback; throw new Error('本机记录无法读取,请先备份后运行重算'); }
32
+ }
33
+ function endpoint(value) {
34
+ let url; try { url = new URL(value); } catch { throw new Error('服务地址无效'); }
35
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/' ||
36
+ url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))) {
37
+ throw new Error('服务地址需要使用 HTTPS,本机调试可使用回环地址');
38
+ }
39
+ return url.origin;
40
+ }
41
+ const configBaselines = new WeakMap();
42
+ function configFor(args, p) {
43
+ const saved = read(p.config, null);
44
+ const homes = [...new Set((args.homes.length ? args.homes : saved?.homes || [path.resolve(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'))]).map((h) => path.resolve(h)))];
45
+ const timezone = args.timezone || saved?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
46
+ try { new Intl.DateTimeFormat('en', { timeZone: timezone }).format(); } catch { throw new Error('时区无效'); }
47
+ if (args.projects && !['on', 'off'].includes(args.projects)) throw new Error('项目名称选项必须是 on 或 off');
48
+ const projects = args.projects ? args.projects === 'on' : saved?.projects || false;
49
+ const target = endpoint(args.endpoint || saved?.endpoint || process.env.NUMABLE_USAGE_ENDPOINT || 'https://usage.numable.app');
50
+ if (saved?.writeToken && saved.endpoint !== target) throw new Error('已绑定的凭证不能发送到另一个服务,请使用独立状态目录');
51
+ const config = { version: 1, ...saved, homes, timezone, projects, endpoint: target, live: saved?.live === true,
52
+ salt: saved?.salt || crypto.randomBytes(32).toString('hex'),
53
+ device: saved?.device || crypto.randomBytes(12).toString('hex'), sync: saved?.sync || false };
54
+ configBaselines.set(config, structuredClone(saved || {}));
55
+ return config;
56
+ }
57
+ function lock(file) {
58
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
59
+ for (let i = 0; i < 2; i++) {
60
+ try {
61
+ const fd = fs.openSync(file, 'wx', 0o600);
62
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid })); fs.closeSync(fd);
63
+ return () => { try { fs.unlinkSync(file); } catch {} };
64
+ } catch (e) {
65
+ if (e.code !== 'EEXIST') throw e;
66
+ const previous = read(file, {});
67
+ if (!Number.isSafeInteger(previous.pid) || previous.pid <= 0) throw new Error('采集锁无效,请检查本机状态');
68
+ try { process.kill(previous.pid, 0); throw Object.assign(new Error('已有采集正在进行'), { code: 'LOCK_BUSY' }); }
69
+ catch (err) { if (err.code !== 'ESRCH') throw err; }
70
+ fs.unlinkSync(file);
71
+ }
72
+ }
73
+ throw new Error('无法取得采集锁');
74
+ }
75
+ // 所有配置回写共享短锁;只合并本次实际修改的字段,旧采集不能覆盖并发的暂停、卸载或隐私开关。
76
+ async function saveConfig(p, config) {
77
+ let release;
78
+ for (let i = 0; ; i++) {
79
+ try { release = lock(path.join(p.dir, 'config.lock')); break; }
80
+ catch (error) {
81
+ if (error.code !== 'LOCK_BUSY' || i >= 100) throw error;
82
+ await new Promise((resolve) => setTimeout(resolve, 10));
83
+ }
84
+ }
85
+ try {
86
+ const baseline = configBaselines.get(config) || {}, current = read(p.config, {});
87
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
88
+ const merged = { ...current };
89
+ for (const [key, value] of Object.entries(config)) {
90
+ if (!same(value, baseline[key]) && same(current[key], baseline[key])) merged[key] = value;
91
+ }
92
+ atomicWrite(p.config, merged);
93
+ Object.assign(config, merged);
94
+ configBaselines.set(config, structuredClone(merged));
95
+ } finally { release(); }
96
+ }
97
+ async function request(config, route, body, token) {
98
+ const controller = new AbortController();
99
+ const timer = setTimeout(() => controller.abort(), 10000);
100
+ try {
101
+ const r = await fetch(config.endpoint + route, { method: body === undefined ? 'GET' : 'POST', redirect: 'error', signal: controller.signal,
102
+ headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
103
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
104
+ if (!r.ok) throw new Error(`服务请求失败(${r.status}),请检查连接后重试`);
105
+ return await r.json();
106
+ } finally { clearTimeout(timer); }
107
+ }
108
+ function hookCommand({ stateDir, node = process.execPath, cli = __filename, platform = process.platform }) {
109
+ const args = [node, cli, 'hook', '--state-dir', stateDir];
110
+ if (platform === 'win32') {
111
+ // 外层 cmd / PowerShell 都只解析固定程序名与 Base64,路径仅在内层按字面量传递。
112
+ // 不使用 Invoke-Expression,也不修改执行策略;调用运算符负责启动带空格的可执行路径。
113
+ const quote = (s) => `'${s.replace(/'/g, "''")}'`;
114
+ const script = `$ErrorActionPreference='Stop'; & ${args.map(quote).join(' ')}; exit $LASTEXITCODE`;
115
+ return `powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(script, 'utf16le').toString('base64')}`;
116
+ }
117
+ const quote = (s) => `'${s.replace(/'/g, "'\\''")}'`;
118
+ return args.map(quote).join(' ');
119
+ }
120
+ function hookDefinitions(p) {
121
+ const command = hookCommand({ stateDir: p.dir });
122
+ return ['SessionStart', 'SessionEnd', 'Stop', 'UserPromptSubmit', 'PermissionRequest', 'PreToolUse', 'PostToolUse', 'PreCompact', 'SubagentStart', 'SubagentStop', 'Interrupt'].map((event) => ({ event,
123
+ group: { hooks: [{ type: 'command', command, ...(event === 'SessionEnd' ? {} : { async: true }), timeout: ['SessionEnd', 'Interrupt'].includes(event) ? 3 : 10 }] } }));
124
+ }
125
+ function installHooks(config, p) {
126
+ const installed = [];
127
+ for (const home of config.homes) {
128
+ const file = path.join(home, 'hooks.json'), doc = read(file, {});
129
+ if (doc.hooks && (typeof doc.hooks !== 'object' || Array.isArray(doc.hooks))) throw new Error('已有事件钩子的格式无效,未覆盖原配置');
130
+ doc.hooks ||= {};
131
+ for (const { event, group } of hookDefinitions(p)) {
132
+ let groups = doc.hooks[event] || [];
133
+ if (!Array.isArray(groups)) throw new Error('已有事件钩子的格式无效,未覆盖原配置');
134
+ // 升级仅替换上次安装记录中完整匹配的定义,用户修改过的定义保留。
135
+ const previous = (config.installed || []).filter((h) => h.file === file && h.event === event).map((h) => JSON.stringify(h.group));
136
+ groups = groups.filter((g) => !previous.includes(JSON.stringify(g)));
137
+ const serialized = JSON.stringify(group);
138
+ if (!groups.some((g) => JSON.stringify(g) === serialized)) groups.push(group);
139
+ doc.hooks[event] = groups;
140
+ installed.push({ file, event, group });
141
+ }
142
+ atomicWrite(file, doc);
143
+ }
144
+ return installed;
145
+ }
146
+ function uninstallHooks(config) {
147
+ for (const file of new Set((config.installed || []).map((h) => h.file))) {
148
+ const doc = read(file, null); if (!doc?.hooks) continue;
149
+ for (const h of config.installed.filter((h) => h.file === file)) {
150
+ if (!Array.isArray(doc.hooks[h.event])) continue;
151
+ doc.hooks[h.event] = doc.hooks[h.event].filter((g) => JSON.stringify(g) !== JSON.stringify(h.group));
152
+ if (!doc.hooks[h.event].length) delete doc.hooks[h.event];
153
+ }
154
+ atomicWrite(file, doc);
155
+ }
156
+ }
157
+ function pendingEvents(p) {
158
+ try { return fs.readdirSync(path.join(p.dir, 'events')).some((f) => f.endsWith('.json')); } catch { return false; }
159
+ }
160
+ function spawnEventWorker(p) {
161
+ const child = spawn(process.execPath, [__filename, 'collect', '--state-dir', p.dir, '--quiet', '--hook-worker'], { detached: true, stdio: 'ignore', windowsHide: true });
162
+ child.on('error', () => {}); child.unref();
163
+ }
164
+ async function scan(args, p, config) {
165
+ const unlock = lock(p.lock);
166
+ try {
167
+ // 等锁的期间可能已有其他采集更新序号或开关;取得锁后再读权威配置。
168
+ if (fs.existsSync(p.config)) config = configFor(args, p);
169
+ const projectionSettings = JSON.stringify([config.homes, config.timezone, config.projects, config.live, config.writeToken]);
170
+ let existing;
171
+ try { existing = read(p.history, null); }
172
+ catch (error) {
173
+ if (!args.rebuild) throw error;
174
+ const backup = path.join(p.dir, `history.corrupt-${Date.now()}.json`);
175
+ fs.copyFileSync(p.history, backup); fs.chmodSync(backup, 0o600);
176
+ existing = null;
177
+ }
178
+ if (existing?.salt) config.salt = existing.salt;
179
+ const state = args.rebuild ? { ...initialState(), salt: config.salt } : existing || { ...initialState(), salt: config.salt };
180
+ const queueDir = path.join(p.dir, 'events');
181
+ let queued = []; try { queued = fs.readdirSync(queueDir).filter((name) => /^[a-zA-Z0-9.-]+\.json$/.test(name)).map((name) => ({ file: path.join(queueDir, name), event: read(path.join(queueDir, name), null) })); } catch (e) { if (e.code !== 'ENOENT') throw e; }
182
+ const r = await collect(config.homes, state, { timezone: config.timezone, projects: config.projects, live: config.live });
183
+ let activeQuota = read(path.join(p.dir, 'quota.json'), null);
184
+ if (config.quotaAuto && Date.now() - (config.lastQuotaAttempt || 0) >= 300000) {
185
+ config.lastQuotaAttempt = Date.now();
186
+ try {
187
+ activeQuota = await readQuotaHomes({ binary: config.quotaBinary, homes: config.homes, salt: config.writeToken || state.salt, profileSalt: state.salt, previous: activeQuota });
188
+ atomicWrite(path.join(p.dir, 'quota.json'), activeQuota);
189
+ config.quotaStatus = activeQuota.supported ? activeQuota.failures ? 'partial' : 'ready' : 'unavailable';
190
+ } catch { config.quotaStatus = 'unavailable'; }
191
+ }
192
+ if (activeQuota?.supported && activeQuota.scope === hash(config.writeToken || state.salt, 'quota-scope')) {
193
+ const allowedProfiles = new Set(config.homes.map((home) => hash(state.salt, `profile:${path.resolve(home)}`)));
194
+ const profiles = new Set(activeQuota.quotas.filter((q) => allowedProfiles.has(q.profile)).map((q) => q.profile));
195
+ for (const profile of profiles) {
196
+ const latestLog = Math.max(0, ...r.snapshot.quotas.filter((q) => q.profile === profile).map((q) => q.observedAt));
197
+ const active = activeQuota.quotas.filter((q) => q.profile === profile);
198
+ if (active.length && Math.max(...active.map((q) => q.observedAt)) >= latestLog) r.snapshot.quotas = [...r.snapshot.quotas.filter((q) => q.profile !== profile), ...active];
199
+ }
200
+ }
201
+ r.snapshot.quotaDiagnostics = { failures: activeQuota?.failures || 0, profiles: activeQuota?.profileCount || config.homes.length };
202
+ if (config.live) {
203
+ r.state.liveHooks ||= { sessions: {} };
204
+ for (const q of queued.sort((a, b) => (a.event?.at || 0) - (b.event?.at || 0))) absorbHook(r.state.liveHooks, q.event);
205
+ for (const [id, s] of Object.entries(r.state.liveHooks.sessions)) if (s.lastAt < Date.now() - 24 * 3600e3) delete r.state.liveHooks.sessions[id];
206
+ } else delete r.state.liveHooks;
207
+ if (!config.projects) {
208
+ for (const f of Object.values(r.state.files)) { f.projectName = null; if (f.live) f.live.projectName = null; }
209
+ for (const s of Object.values(r.state.liveHooks?.sessions || {})) s.projectName = null;
210
+ }
211
+ const live = config.live ? buildLive(r.state, { projects: config.projects }) : { tier: 1, devNow: Date.now(), sessions: [] };
212
+ atomicWrite(p.history, r.state);
213
+ atomicWrite(p.snapshot, { ...r.snapshot, collection: r.stats });
214
+ atomicWrite(path.join(p.dir, 'live.json'), live);
215
+ for (const q of queued) try { fs.unlinkSync(q.file); } catch {}
216
+ config.lastCollect = r.snapshot.collectedAt; await saveConfig(p, config);
217
+ if (config.sync && config.writeToken && projectionSettings === JSON.stringify([config.homes, config.timezone, config.projects, config.live, config.writeToken])) {
218
+ const background = args.hookWorker || args.command === 'watch';
219
+ const pending = []; let syncError = null;
220
+ if (!background || Date.now() - (config.lastStatsPush || 0) >= 60000) {
221
+ pending.push((async () => {
222
+ try {
223
+ await request(config, '/ingest', { source: 'codex', device: config.device, snapshot: { ...r.snapshot, collection: r.stats } }, config.writeToken);
224
+ config.lastStatsPush = Date.now();
225
+ } catch (error) { syncError ||= error; }
226
+ })());
227
+ }
228
+ const fingerprint = crypto.createHash('sha256').update(JSON.stringify({ tier: live.tier, sessions: live.sessions })).digest('hex');
229
+ if ((config.live || config.liveClearPending) && (fingerprint !== config.lastLiveFingerprint || Date.now() - (config.lastLivePush || 0) >= 60000)) {
230
+ config.lastLiveSequence = (config.lastLiveSequence || 0) + 1;
231
+ await saveConfig(p, config);
232
+ const seq = config.lastLiveSequence;
233
+ pending.push((async () => {
234
+ try {
235
+ const wait = (config.lastLivePush || 0) + 2000 - Date.now();
236
+ if (args.hookWorker && wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
237
+ const latest = configFor(args, p);
238
+ // 两秒合并窗口内也可能暂停 / 换空间 / 关闭实时;尚未发出的请求应取消。
239
+ if (!latest.sync || projectionSettings !== JSON.stringify([latest.homes, latest.timezone, latest.projects, latest.live, latest.writeToken])) return;
240
+ await request(config, `/live?source=codex&dev=${config.device}`, { ...live, seq }, config.writeToken);
241
+ config.lastLivePush = Date.now(); config.lastLiveFingerprint = fingerprint; config.liveClearPending = false;
242
+ } catch (error) { syncError ||= error; }
243
+ })());
244
+ }
245
+ await Promise.all(pending); config.syncError = !!syncError;
246
+ await saveConfig(p, config);
247
+ if (syncError) throw syncError;
248
+ }
249
+ return { ...r.snapshot, collection: r.stats };
250
+ } finally { unlock(); }
251
+ }
252
+ async function main(argv = process.argv.slice(2)) {
253
+ const args = parse(argv), p = paths(args);
254
+ if (args.command === 'help' || args.command === '--help') {
255
+ console.log('Codex 用量\ninit:初始化本机采集(默认不同步)\ncollect:增量采集;--rebuild:重算\nsummary:查看统计;--json:导出纯数字快照\nstatus:查看采集与同步状态\nquota:主动读取额度;--codex-binary 指定 Codex\nquota-auto-on/off:开启或关闭每五分钟读取额度\nlive-on/off:开启或关闭匿名实时状态;live:查看实时投影\ndevices:列出已接入电脑\nforget:删除 --device 指定的其他电脑数据\nconnect:创建读取凭证并开启同步\ncredentials:显示手机使用的读取凭证\npause:暂停同步\nresume:恢复已绑定的同步\njoin:从标准输入读取另一台电脑生成的加入串\nlink:生成供自己另一台电脑使用的加入串\ninstall:安装事件钩子(之后需在 Codex 中信任)\nuninstall:仅移除本工具安装的钩子,保留本机记录\nstart:后台启动并确认运行;watch:在前台每 60 秒补充采集;stop:请求停止后台采集\n通用选项:--home 目录(可重复)、--state-dir 目录、--timezone 时区、--endpoint 服务地址、--projects on|off');
256
+ return;
257
+ }
258
+ const config = configFor(args, p);
259
+ const output = (data) => { if (!args.quiet) console.log(typeof data === 'string' ? data : JSON.stringify(data, null, 2)); };
260
+ if (args.command === 'hook') {
261
+ // 先在父进程丢掉敏感字段,原始输入既不落盘,也不出现在进程参数中。
262
+ let text = '', overflow = false;
263
+ for await (const chunk of process.stdin) { if (!overflow) { text += chunk; if (text.length > 2 * 1024 * 1024) { overflow = true; text = ''; } } }
264
+ if (!fs.existsSync(p.config)) { process.stdout.write('{}\n'); return; }
265
+ let event = null; try { if (config.live && !overflow) event = sanitizeHook(JSON.parse(text), config.salt, { projects: config.projects }); } catch {}
266
+ atomicWrite(path.join(p.dir, 'events', `${Date.now()}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.json`), event || { scan: true, at: Date.now() });
267
+ let workerRunning = false;
268
+ try { const lease = read(path.join(p.dir, 'event-worker.lock'), null); if (lease?.pid) { process.kill(lease.pid, 0); workerRunning = true; } } catch {}
269
+ if (!workerRunning) spawnEventWorker(p);
270
+ process.stdout.write('{}\n');
271
+ return;
272
+ }
273
+ if (args.command === 'init') { await saveConfig(p, config); output('本机采集已初始化,手机同步尚未开启'); return; }
274
+ if (args.command === 'collect') {
275
+ if (args.hookWorker) {
276
+ let release;
277
+ try { release = lock(path.join(p.dir, 'event-worker.lock')); } catch (error) { if (error.code === 'LOCK_BUSY') return; throw error; }
278
+ try {
279
+ for (let i = 0; i < 600; i++) {
280
+ if (!pendingEvents(p)) return;
281
+ try { await scan(args, p, configFor(args, p)); }
282
+ catch (error) { if (error.code !== 'LOCK_BUSY') throw error; await new Promise((resolve) => setTimeout(resolve, 100)); }
283
+ }
284
+ } finally { release(); if (pendingEvents(p)) spawnEventWorker(p); }
285
+ return;
286
+ }
287
+ output(await scan(args, p, config)); return;
288
+ }
289
+ if (args.command === 'live-on' || args.command === 'live-off') {
290
+ config.live = args.command === 'live-on'; config.liveClearPending = !config.live;
291
+ await saveConfig(p, config); await scan(args, p, config);
292
+ output(config.live ? '实时状态已开启;手机同步沿用当前开关' : '实时状态已关闭'); return;
293
+ }
294
+ if (args.command === 'live') { output(read(path.join(p.dir, 'live.json'), { tier: 1, sessions: [] })); return; }
295
+ if (args.command === 'quota') {
296
+ const unlock = lock(p.lock);
297
+ try {
298
+ const state = read(p.history, null) || { ...initialState(), salt: config.salt };
299
+ config.salt = state.salt;
300
+ const result = await readQuotaHomes({ binary: args.codexBinary || config.quotaBinary, homes: config.homes, salt: config.writeToken || state.salt, profileSalt: state.salt,
301
+ previous: read(path.join(p.dir, 'quota.json'), null) });
302
+ atomicWrite(p.history, state); await saveConfig(p, config); atomicWrite(path.join(p.dir, 'quota.json'), result);
303
+ const snapshot = read(p.snapshot, null);
304
+ if (snapshot && result.supported) atomicWrite(p.snapshot, { ...snapshot, quotas: result.quotas });
305
+ output(result);
306
+ } finally { unlock(); }
307
+ return;
308
+ }
309
+ if (args.command === 'quota-auto-on' || args.command === 'quota-auto-off') {
310
+ config.quotaAuto = args.command === 'quota-auto-on';
311
+ if (args.codexBinary) config.quotaBinary = args.codexBinary;
312
+ if (config.quotaAuto) config.lastQuotaAttempt = 0;
313
+ await saveConfig(p, config); if (config.quotaAuto) await scan(args, p, config);
314
+ output(config.quotaAuto ? '额度自动读取已开启,每五分钟检查一次;请保持后台采集运行' : '额度自动读取已关闭'); return;
315
+ }
316
+ if (['summary', 'export'].includes(args.command)) {
317
+ const snapshot = read(p.snapshot, null);
318
+ if (!snapshot) throw new Error('还没有用量记录,请先运行 collect');
319
+ output(snapshot); return;
320
+ }
321
+ if (args.command === 'status') {
322
+ const snapshot = read(p.snapshot, null);
323
+ output({ initialized: fs.existsSync(p.config), sync: config.sync, paired: !!config.writeToken, live: config.live,
324
+ quotaAuto: config.quotaAuto === true, quotaStatus: config.quotaStatus || null, syncError: config.syncError || null,
325
+ collectedAt: snapshot?.collectedAt || null, timezone: config.timezone, projects: config.projects,
326
+ coverage: snapshot?.coverage || [], diagnostics: snapshot?.diagnostics || null, collection: snapshot?.collection || null,
327
+ hooksInstalled: !!config.installed?.length, hooksTrust: config.installed?.length ? 'check-in-codex' : null });
328
+ return;
329
+ }
330
+ if (args.command === 'connect') {
331
+ if (!config.writeToken) {
332
+ const result = await request(config, '/space', {});
333
+ if (!result.spaceId || !result.writeToken || !result.readToken) throw new Error('服务返回的接入信息不完整');
334
+ Object.assign(config, { spaceId: result.spaceId, writeToken: result.writeToken, readToken: result.readToken });
335
+ }
336
+ config.sync = true; await saveConfig(p, config);
337
+ output('手机同步已开启。运行 collect 上传统计,再运行 credentials 查看读取凭证'); return;
338
+ }
339
+ if (args.command === 'credentials') {
340
+ if (!config.readToken) throw new Error('尚未接入手机,请先运行 connect');
341
+ output(config.readToken); return;
342
+ }
343
+ if (args.command === 'link') {
344
+ if (!config.writeToken) throw new Error('尚未接入手机,请先运行 connect');
345
+ output(Buffer.from(JSON.stringify({ v: 1, endpoint: config.endpoint, spaceId: config.spaceId, writeToken: config.writeToken, readToken: config.readToken })).toString('base64url')); return;
346
+ }
347
+ if (args.command === 'devices') {
348
+ if (!config.readToken) throw new Error('尚未接入手机,请先运行 connect');
349
+ const result = await request(config, '/s?source=codex&lite=1', undefined, config.readToken);
350
+ output((result.merged?.devices || []).map((d) => ({ device: d.device, current: d.device === config.device,
351
+ collectedAt: d.collectedAt, receivedAt: d.receivedAt, timezone: d.timezone, coverage: d.coverage })));
352
+ return;
353
+ }
354
+ if (args.command === 'forget') {
355
+ if (!config.writeToken) throw new Error('尚未接入手机,请先运行 connect');
356
+ if (!args.device || !/^[a-f0-9]{4,32}$/.test(args.device)) throw new Error('请使用 --device 指定要删除的设备标识');
357
+ if (args.device === config.device) throw new Error('不能删除正在采集的本机,请先暂停同步并在另一台电脑操作');
358
+ const result = await request(config, '/forget', { source: 'codex', device: args.device }, config.writeToken);
359
+ output({ removed: result.removed, liveRemoved: result.liveRemoved || 0 }); return;
360
+ }
361
+ if (args.command === 'join') {
362
+ let text = ''; for await (const part of process.stdin) { text += part; if (text.length > 4096) throw new Error('加入串过长'); }
363
+ let data; try { data = JSON.parse(Buffer.from(text.trim(), 'base64url').toString('utf8')); } catch { throw new Error('加入串无效'); }
364
+ if (data.v !== 1 || endpoint(data.endpoint) !== config.endpoint || typeof data.spaceId !== 'string' || typeof data.writeToken !== 'string' || typeof data.readToken !== 'string') throw new Error('加入串与当前服务不匹配');
365
+ // 用只读请求验证后再保存,未打印写入凭证。
366
+ await request({ ...config, endpoint: data.endpoint }, '/s?source=codex&lite=1', undefined, data.readToken);
367
+ Object.assign(config, { spaceId: data.spaceId, writeToken: data.writeToken, readToken: data.readToken, sync: true });
368
+ await saveConfig(p, config); output('已加入同一个用量空间'); return;
369
+ }
370
+ if (args.command === 'pause' || args.command === 'resume') {
371
+ if (args.command === 'resume' && !config.writeToken) throw new Error('尚未接入手机,请先运行 connect');
372
+ config.sync = args.command === 'resume'; await saveConfig(p, config); output(config.sync ? '手机同步已恢复' : '手机同步已暂停,本机统计继续保留'); return;
373
+ }
374
+ if (args.command === 'install') {
375
+ config.installed = installHooks(config, p); await saveConfig(p, config);
376
+ output('事件钩子已安装。请在 Codex 中检查并信任新增钩子,信任后才会自动采集'); return;
377
+ }
378
+ if (args.command === 'uninstall') {
379
+ uninstallHooks(config); config.installed = []; config.sync = false; await saveConfig(p, config);
380
+ atomicWrite(path.join(p.dir, 'stop.json'), { at: Date.now() }); output('已移除本工具的事件钩子并暂停同步,本机记录仍保留'); return;
381
+ }
382
+ if (args.command === 'stop') { atomicWrite(path.join(p.dir, 'stop.json'), { at: Date.now() }); output('已请求停止后台采集'); return; }
383
+ if (args.command === 'start') {
384
+ if (!fs.existsSync(p.config)) throw new Error('请先运行 init 初始化本机采集');
385
+ const watchLock = path.join(p.dir, 'watch.lock'), previous = read(watchLock, null);
386
+ if (previous?.pid) {
387
+ try { process.kill(previous.pid, 0); output('后台采集已在运行'); return; }
388
+ catch (e) { if (e.code !== 'ESRCH') throw e; }
389
+ }
390
+ const child = spawn(process.execPath, [__filename, 'watch', '--state-dir', p.dir, '--quiet'], { detached: true, stdio: 'ignore', windowsHide: true });
391
+ let failed = false; child.once('error', () => { failed = true; }); child.unref();
392
+ for (let i = 0; i < 40 && !failed; i++) {
393
+ if (read(watchLock, null)?.pid === child.pid) { output('后台采集已启动'); return; }
394
+ await new Promise((resolve) => setTimeout(resolve, 50));
395
+ }
396
+ throw new Error('未能确认后台采集启动,请运行 watch 查看状态');
397
+ }
398
+ if (args.command === 'watch') {
399
+ const release = lock(path.join(p.dir, 'watch.lock')), startedAt = Date.now();
400
+ let stopped = false;
401
+ process.once('SIGINT', () => { stopped = true; }); process.once('SIGTERM', () => { stopped = true; });
402
+ try {
403
+ while (!stopped) {
404
+ if ((read(path.join(p.dir, 'stop.json'), {})?.at || 0) >= startedAt) break;
405
+ try { await scan(args, p, configFor(args, p)); } catch { if (!args.quiet) console.error('本轮采集未完成,将在下一轮重试'); }
406
+ for (let i = 0; i < 60 && !stopped; i++) {
407
+ if ((read(path.join(p.dir, 'stop.json'), {})?.at || 0) >= startedAt) { stopped = true; break; }
408
+ await new Promise((resolve) => setTimeout(resolve, 1000));
409
+ }
410
+ }
411
+ } finally { release(); }
412
+ return;
413
+ }
414
+ throw new Error('无法识别命令,请运行 help 查看用法');
415
+ }
416
+ if (require.main === module) main().catch((e) => {
417
+ if (process.argv[2] === 'hook') { process.stdout.write('{}\n'); return; }
418
+ if (process.argv.includes('--quiet')) return;
419
+ console.error(e.message && !/ENOENT|EACCES|ENOSPC|fetch failed/.test(e.message) ? e.message : '操作未完成,请检查本机目录、网络与可用空间后重试');
420
+ process.exitCode = 1;
421
+ });
422
+ module.exports = { parse, paths, endpoint, configFor, lock, installHooks, uninstallHooks, scan, request, main, hookCommand };
@@ -0,0 +1,294 @@
1
+ 'use strict';
2
+
3
+ // 日志只在本机解析;持久化投影不含正文、代码、命令、工具参数或登录凭证。
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const crypto = require('node:crypto');
7
+ const zlib = require('node:zlib');
8
+ const { spawn } = require('node:child_process');
9
+ const { absorbLog } = require('./live.cjs');
10
+
11
+ const VERSION = 1;
12
+ const MODEL = /^[a-zA-Z0-9._-]{1,80}$/;
13
+ const LIMIT = /^[a-zA-Z0-9._-]{1,64}$/;
14
+ const n = (x) => Number.isSafeInteger(x) && x >= 0 ? x : null;
15
+ const hash = (salt, value) => crypto.createHmac('sha256', salt).update(String(value)).digest('hex').slice(0, 24);
16
+ const emptyUsage = () => ({ input: 0, output: 0, cached: 0, reasoning: 0, total: 0 });
17
+ function usage(u) {
18
+ if (!u || n(u.input_tokens) === null || n(u.output_tokens) === null) return null;
19
+ const input = n(u.input_tokens), output = n(u.output_tokens);
20
+ const cached = n(u.cached_input_tokens), reasoning = n(u.reasoning_output_tokens);
21
+ const total = input + output;
22
+ if (!Number.isSafeInteger(total) || (n(u.total_tokens) !== null && u.total_tokens !== total)) return null;
23
+ if (cached > input || reasoning > output) return null;
24
+ return { input, output, cached, reasoning, total };
25
+ }
26
+ function addUsage(to, u) {
27
+ for (const k of Object.keys(emptyUsage())) {
28
+ // 可选细项缺失时保留未知;总量不受它们影响。
29
+ to[k] = to[k] === null || u[k] === null ? null : to[k] + u[k];
30
+ }
31
+ }
32
+ function quota(raw, at) {
33
+ if (!raw || !Number.isFinite(at)) return null;
34
+ const id = raw.limit_id || raw.limitId || 'codex';
35
+ if (typeof id !== 'string' || !LIMIT.test(id)) return null;
36
+ const window = (w) => {
37
+ if (!w || typeof w.used_percent !== 'number' && typeof w.usedPercent !== 'number') return null;
38
+ const usedPercent = w.used_percent ?? w.usedPercent;
39
+ const windowDurationMins = w.window_minutes ?? w.windowDurationMins;
40
+ const resetsAt = w.resets_at ?? w.resetsAt;
41
+ if (!Number.isFinite(usedPercent) || usedPercent < 0 || usedPercent > 100 ||
42
+ !Number.isFinite(windowDurationMins) || windowDurationMins <= 0 || n(resetsAt) === null || resetsAt <= 0) return null;
43
+ return { usedPercent, windowDurationMins, resetsAt };
44
+ };
45
+ const primary = window(raw.primary), secondary = window(raw.secondary);
46
+ return primary || secondary ? { id, observedAt: at, primary, secondary } : null;
47
+ }
48
+ const formatters = new Map();
49
+ function dateAt(at, timezone) {
50
+ if (!formatters.has(timezone)) formatters.set(timezone, new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' }));
51
+ const parts = formatters.get(timezone).formatToParts(at);
52
+ const get = (key) => parts.find((x) => x.type === key).value;
53
+ return `${get('year')}-${get('month')}-${get('day')}`;
54
+ }
55
+ function initialState() {
56
+ return { version: VERSION, salt: crypto.randomBytes(32).toString('hex'), files: {} };
57
+ }
58
+ function projection() {
59
+ return { offset: 0, identity: '', head: '', session: null, project: null, projectName: null,
60
+ source: 'unknown', version: null, subagent: false, fork: false, model: 'unknown', turn: null,
61
+ records: {}, legacy: {}, turns: {}, prompts: {}, activity: {}, quotas: {},
62
+ cumulative: null, diagnostics: { invalidRows: 0, invalidUsage: 0, missingIdentity: 0 }, lastAt: 0 };
63
+ }
64
+ function processRow(file, row, salt, options = {}) {
65
+ const p = row && row.payload;
66
+ if (!p || typeof p !== 'object') return;
67
+ const at = Date.parse(row.timestamp || p.timestamp);
68
+ if (row.type === 'session_meta') {
69
+ const id = p.id || p.session_id;
70
+ if (typeof id === 'string') file.session = hash(salt, `session:${id}`);
71
+ if (typeof p.cwd === 'string') {
72
+ file.project = hash(salt, `project:${p.cwd.replace(/\\/g, '/').replace(/\/$/, '')}`);
73
+ if (options.projects) file.projectName = p.cwd.replace(/\\/g, '/').split('/').filter(Boolean).pop()?.slice(0, 40) || null;
74
+ }
75
+ const src = p.source;
76
+ file.subagent = !!(src && typeof src === 'object' && src.subagent) || !!p.parent_thread_id;
77
+ file.fork = !!(p.forked_from_id || p.forked_from);
78
+ file.source = file.subagent ? 'subagent' : ['cli', 'vscode', 'exec', 'appServer'].includes(src) ? src : 'unknown';
79
+ file.version = typeof p.cli_version === 'string' && /^[a-zA-Z0-9.+_-]{1,64}$/.test(p.cli_version) ? p.cli_version : null;
80
+ return;
81
+ }
82
+ if (row.type === 'turn_context') {
83
+ file.turn = typeof p.turn_id === 'string' ? hash(salt, `turn:${p.turn_id}`) : file.turn;
84
+ file.model = typeof p.model === 'string' && MODEL.test(p.model) ? p.model : 'unknown';
85
+ return;
86
+ }
87
+ if (!Number.isFinite(at) || !file.session) return;
88
+ file.lastAt = Math.max(file.lastAt, at);
89
+ const turn = typeof p.turn_id === 'string' ? hash(salt, `turn:${p.turn_id}`) : file.turn;
90
+ const base = { at, session: file.session, project: file.project, model: file.model, turn, subagent: file.subagent };
91
+ if (options.live) absorbLog(file, row, { ...base, projectName: options.projects ? file.projectName : null }, (value) => hash(salt, value));
92
+ if (row.type === 'token_usage_record') {
93
+ const u = usage(p.usage);
94
+ if (!u) { file.diagnostics.invalidUsage++; return; }
95
+ if (typeof p.response_id !== 'string') { file.diagnostics.missingIdentity++; return; }
96
+ const key = hash(salt, `response:${p.response_id}`);
97
+ file.records[key] = { ...base, ...(typeof p.session_id === 'string' ? { session: hash(salt, `session:${p.session_id}`) } : {}), usage: u };
98
+ file.activity[String(Math.floor(at / 60000))] = 1;
99
+ }
100
+ if (row.type !== 'event_msg') return;
101
+ if (p.type === 'token_count') {
102
+ const q = quota(p.rate_limits, at);
103
+ if (q) file.quotas[q.id] = q;
104
+ const total = usage(p.info?.total_token_usage), last = usage(p.info?.last_token_usage);
105
+ if (!total) return;
106
+ const previous = file.cumulative;
107
+ // 累计计数完全相同的通知不能重复统计;计数回落视为新代,绝不产生负数。
108
+ if (previous && total.input === previous.input && total.output === previous.output) return;
109
+ let u;
110
+ if (previous && total.input >= previous.input && total.output >= previous.output) {
111
+ u = emptyUsage();
112
+ for (const k of Object.keys(u)) u[k] = total[k] === null || previous[k] === null ? null : Math.max(0, total[k] - previous[k]);
113
+ } else {
114
+ // 恢复或分叉首条累计值可能含继承历史,只认这次响应的 last。
115
+ u = last;
116
+ }
117
+ file.cumulative = total;
118
+ if (!u) { file.diagnostics.invalidUsage++; return; }
119
+ const key = hash(salt, `legacy:${file.session}:${turn || ''}:${total.input}:${total.output}`);
120
+ file.legacy[key] = { ...base, usage: u };
121
+ file.activity[String(Math.floor(at / 60000))] = 1;
122
+ }
123
+ if (p.type === 'task_started' && turn) {
124
+ file.turn = turn;
125
+ file.turns[turn] = { at, session: file.session, subagent: file.subagent };
126
+ }
127
+ if (p.type === 'user_message' || p.type === 'item_completed' && p.item?.type === 'UserMessage') {
128
+ // 只判断结构,不读正文;有事件身份才去重,没有身份按轮计一次。
129
+ const id = p.item?.id || turn;
130
+ if (id) file.prompts[hash(salt, `prompt:${file.session}:${id}`)] = { at, subagent: file.subagent };
131
+ file.activity[String(Math.floor(at / 60000))] = 1;
132
+ }
133
+ }
134
+
135
+ async function discover(roots) {
136
+ const result = [], skipped = { compressedUnavailable: 0, unreadableDirectories: 0 };
137
+ async function walk(dir) {
138
+ let entries;
139
+ try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); }
140
+ catch (e) { if (e.code !== 'ENOENT') skipped.unreadableDirectories++; return; }
141
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
142
+ const p = path.join(dir, entry.name);
143
+ if (entry.isDirectory()) await walk(p);
144
+ else if (entry.isFile() && /\.jsonl(?:\.gz|\.zst)?$/.test(entry.name)) result.push(p);
145
+ }
146
+ }
147
+ for (const root of roots) {
148
+ await walk(path.join(root, 'sessions'));
149
+ await walk(path.join(root, 'archived_sessions'));
150
+ }
151
+ return { paths: [...new Set(result)], skipped };
152
+ }
153
+
154
+ async function readFile(filePath, old, salt, options) {
155
+ const stat = await fs.promises.stat(filePath);
156
+ const compressed = /\.(gz|zst)$/.test(filePath);
157
+ const handle = await fs.promises.open(filePath, 'r');
158
+ const first = Buffer.alloc(Math.min(256, stat.size));
159
+ await handle.read(first, 0, first.length, 0);
160
+ await handle.close();
161
+ const head = crypto.createHash('sha256').update(first).digest('hex');
162
+ const identity = `${stat.dev}:${stat.ino}`;
163
+ // 小文件首段会随追加变化;只有旧文件已超过 256 字节才用首段判断替换。
164
+ const reset = !old || old.identity !== identity || old.offset > stat.size || old.offset >= 256 && old.head !== head || old.projectsEnabled !== (options.projects === true) || old.liveEnabled !== (options.live === true) || old.profile !== options.profile;
165
+ const f = reset ? projection() : structuredClone(old);
166
+ f.identity = identity; f.head = head; f.projectsEnabled = options.projects === true; f.liveEnabled = options.live === true; f.profile = options.profile;
167
+ if (!reset && (compressed ? f.size === stat.size && f.mtime === stat.mtimeMs : f.offset === stat.size)) return { file: f, rows: 0 };
168
+ if (compressed && !reset) Object.assign(f, projection(), { identity, head, projectsEnabled: options.projects === true, liveEnabled: options.live === true, profile: options.profile });
169
+ const start = compressed ? 0 : f.offset;
170
+ if (!stat.size) return { file: f, rows: 0 };
171
+ let stream, child, childDone;
172
+ if (filePath.endsWith('.zst')) {
173
+ child = spawn(options.zstd || 'zstd', ['-d', '-q', '-c', filePath], { stdio: ['ignore', 'pipe', 'ignore'] });
174
+ childDone = new Promise((resolve, reject) => { child.on('error', reject); child.on('close', (code) => code === 0 ? resolve() : reject(new Error('压缩日志解码失败'))); });
175
+ // 先挂拒绝处理,稍后统一等待,避免流结束前出现未处理拒绝。
176
+ childDone.catch(() => {});
177
+ stream = child.stdout;
178
+ } else {
179
+ stream = fs.createReadStream(filePath, { start, end: stat.size - 1 });
180
+ if (filePath.endsWith('.gz')) stream = stream.pipe(zlib.createGunzip());
181
+ }
182
+ let pending = Buffer.alloc(0), rows = 0, bytes = start;
183
+ for await (const chunk of stream) {
184
+ pending = Buffer.concat([pending, chunk]);
185
+ let begin = 0, end;
186
+ while ((end = pending.indexOf(10, begin)) !== -1) {
187
+ const line = pending.subarray(begin, end);
188
+ bytes += end - begin + 1; begin = end + 1;
189
+ if (!line.length) continue;
190
+ let row;
191
+ try { row = JSON.parse(line.toString('utf8')); } catch { f.diagnostics.invalidRows++; continue; }
192
+ processRow(f, row, salt, options); rows++;
193
+ }
194
+ pending = pending.subarray(begin);
195
+ if (pending.length > 32 * 1024 * 1024) throw new Error('单条日志超过读取上限');
196
+ }
197
+ if (childDone) await childDone;
198
+ f.offset = compressed ? stat.size : bytes;
199
+ f.size = stat.size; f.mtime = stat.mtimeMs;
200
+ return { file: f, rows };
201
+ }
202
+
203
+ async function collect(roots, previous = initialState(), options = {}) {
204
+ if (previous.version !== VERSION || typeof previous.salt !== 'string') throw new Error('历史格式不兼容,请执行重算');
205
+ const state = structuredClone(previous);
206
+ const found = await discover(roots);
207
+ const stats = { files: found.paths.length, scanned: 0, rows: 0, unreadableFiles: 0, ...found.skipped };
208
+ const profiles = roots.map((root) => ({ root: path.resolve(root), id: hash(state.salt, `profile:${path.resolve(root)}`) }));
209
+ for (const p of found.paths) {
210
+ const key = hash(state.salt, `file:${p}`);
211
+ try {
212
+ const profile = profiles.filter((entry) => p.startsWith(entry.root + path.sep)).sort((a, b) => b.root.length - a.root.length)[0]?.id;
213
+ const r = await readFile(p, state.files[key], state.salt, { ...options, profile });
214
+ state.files[key] = r.file; stats.rows += r.rows; stats.scanned += r.rows > 0 ? 1 : 0;
215
+ } catch (e) {
216
+ if (e.code === 'ENOENT' && p.endsWith('.zst')) stats.compressedUnavailable++;
217
+ else stats.unreadableFiles++;
218
+ }
219
+ }
220
+ // 旧文件被上游清理后仍保留数字历史;归档副本由响应身份去重。
221
+ return { state, stats, snapshot: summarize(state, { ...options, activeProfiles: profiles.map((p) => p.id) }) };
222
+ }
223
+
224
+ function summarize(state, options = {}) {
225
+ const timezone = options.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
226
+ const now = options.now || Date.now();
227
+ const cutoff = now - 400 * 864e5;
228
+ const records = new Map(), legacy = new Map(), turns = new Map(), prompts = new Map(), projects = new Map();
229
+ const sessions = new Map(), quotas = new Map(), activity = new Set(), modernTurns = new Set(), modernSessions = new Set();
230
+ const diagnostics = { invalidRows: 0, invalidUsage: 0, missingIdentity: 0, unknownFiles: 0, legacyRecords: 0, unassignedLegacy: 0 };
231
+ const coverage = new Map();
232
+ for (const f of Object.values(state.files)) {
233
+ for (const k of ['invalidRows', 'invalidUsage', 'missingIdentity']) diagnostics[k] += f.diagnostics[k];
234
+ if (!f.session || !f.version) diagnostics.unknownFiles++;
235
+ const ckey = `${f.source}:${f.version || 'unknown'}`;
236
+ coverage.set(ckey, { source: f.source, version: f.version, files: (coverage.get(ckey)?.files || 0) + 1 });
237
+ for (const [key, r] of Object.entries(f.records)) {
238
+ if (!records.has(key) || r.at > records.get(key).at || r.at === records.get(key).at && r.usage.total > records.get(key).usage.total) records.set(key, r);
239
+ modernSessions.add(r.session);
240
+ if (r.turn) modernTurns.add(r.turn);
241
+ }
242
+ for (const [key, r] of Object.entries(f.legacy)) if (!legacy.has(key) || r.at < legacy.get(key).at) legacy.set(key, r);
243
+ for (const [key, r] of Object.entries(f.turns)) if (!turns.has(key) || r.at < turns.get(key).at) turns.set(key, r);
244
+ for (const [key, r] of Object.entries(f.prompts)) if (!prompts.has(key) || r.at < prompts.get(key).at) prompts.set(key, r);
245
+ for (const minute of Object.keys(f.activity)) activity.add(Number(minute));
246
+ if (f.project) projects.set(f.project, options.projects ? f.projectName : null);
247
+ if (!options.activeProfiles || options.activeProfiles.includes(f.profile)) for (const q of Object.values(f.quotas)) {
248
+ const key = `${f.profile || 'default'}:${q.id}`;
249
+ if (!quotas.has(key) || q.observedAt > quotas.get(key).observedAt) quotas.set(key, { ...q, profile: f.profile || null });
250
+ }
251
+ }
252
+ const events = [...records.values()];
253
+ for (const r of legacy.values()) {
254
+ if (r.turn && modernTurns.has(r.turn)) continue;
255
+ if (!r.turn && modernSessions.has(r.session)) { diagnostics.unassignedLegacy++; continue; }
256
+ events.push(r); diagnostics.legacyRecords++;
257
+ }
258
+ const days = new Map();
259
+ const day = (at) => {
260
+ const date = dateAt(at, timezone);
261
+ if (!days.has(date)) days.set(date, { date, ...emptyUsage(), responses: 0, turns: 0, sessions: 0, prompts: 0, activeMinutes: 0, models: {}, projects: {} });
262
+ return days.get(date);
263
+ };
264
+ for (const r of events.sort((a, b) => a.at - b.at)) {
265
+ if (r.at < cutoff || r.at > now + 60000) continue;
266
+ const d = day(r.at); addUsage(d, r.usage); d.responses++;
267
+ if (!d.models[r.model]) d.models[r.model] = { ...emptyUsage(), responses: 0 };
268
+ addUsage(d.models[r.model], r.usage); d.models[r.model].responses++;
269
+ if (r.project) {
270
+ if (!d.projects[r.project]) d.projects[r.project] = { ...emptyUsage(), responses: 0, ...(options.projects && projects.get(r.project) ? { name: projects.get(r.project) } : {}) };
271
+ addUsage(d.projects[r.project], r.usage); d.projects[r.project].responses++;
272
+ }
273
+ if (!r.subagent) sessions.set(`${r.session}:${d.date}`, r.at);
274
+ }
275
+ for (const t of turns.values()) if (!t.subagent && t.at >= cutoff && t.at <= now + 60000) {
276
+ const d = day(t.at); d.turns++; sessions.set(`${t.session}:${d.date}`, t.at);
277
+ }
278
+ for (const t of prompts.values()) if (!t.subagent && t.at >= cutoff && t.at <= now + 60000) day(t.at).prompts++;
279
+ for (const at of sessions.values()) day(at).sessions++;
280
+ for (const minute of activity) if (minute * 60000 >= cutoff && minute * 60000 <= now + 60000) day(minute * 60000).activeMinutes++;
281
+ return { v: 1, collectedAt: now, timezone, privacy: { projects: options.projects === true }, coverage: [...coverage.values()], diagnostics,
282
+ sessionCount: new Set([...sessions.keys()].map((key) => key.split(':')[0])).size,
283
+ days: [...days.values()].sort((a, b) => a.date.localeCompare(b.date)),
284
+ quotas: [...quotas.values()].map((q) => ({ ...q, account: null, origin: 'log', expired: [q.primary, q.secondary].filter(Boolean).every((w) => w.resetsAt * 1000 <= now) })) };
285
+ }
286
+
287
+ function atomicWrite(file, data) {
288
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
289
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
290
+ try { fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600, flag: 'wx' }); fs.renameSync(tmp, file); }
291
+ finally { try { fs.unlinkSync(tmp); } catch {} }
292
+ }
293
+
294
+ module.exports = { VERSION, usage, quota, dateAt, initialState, processRow, projection, collect, summarize, atomicWrite, hash };
package/lib/live.cjs ADDED
@@ -0,0 +1,237 @@
1
+ 'use strict';
2
+ // 实时投影只保存匿名身份、时间与固定分类。原始钩子对象不能落盘或传给子进程。
3
+ const crypto = require('node:crypto');
4
+ const HASH = /^[a-f0-9]{24}$/;
5
+ const MODEL = /^[a-zA-Z0-9._-]{1,64}$/;
6
+ const EVENTS = ['SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PermissionRequest', 'Stop', 'Interrupt', 'PreCompact', 'SubagentStart', 'SubagentStop'];
7
+ const KEEP = 24 * 3600e3;
8
+ const anon = (salt, value) => crypto.createHmac('sha256', salt).update(String(value)).digest('hex').slice(0, 24);
9
+ const finite = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0;
10
+ const cleanName = (s) => typeof s === 'string' ? s.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 40) : null;
11
+ function category(name) {
12
+ if (typeof name !== 'string') return 'o';
13
+ if (/^(Bash|exec_command|write_stdin|shell|shell_command)$/.test(name)) return 's';
14
+ if (/^(apply_patch|Edit|Write)$/.test(name)) return 'e';
15
+ if (/^(Read|Glob|Grep|read_file)$/.test(name)) return 'r';
16
+ if (/^(spawn_agent|Agent|Task)$/.test(name)) return 'a';
17
+ if (/web|search_query|fetch_url/i.test(name)) return 'w';
18
+ if (/browser|chrome|cua_/i.test(name)) return 'b';
19
+ if (/device|simulator|android|ios_/i.test(name)) return 'd';
20
+ if (/^mcp__/.test(name)) return 'm';
21
+ if (/request_user_input|AskUserQuestion/.test(name)) return 'q';
22
+ return 'o';
23
+ }
24
+ function sanitizeHook(raw, salt, options = {}, now = Date.now()) {
25
+ if (!raw || !EVENTS.includes(raw.hook_event_name) || typeof raw.session_id !== 'string' || !raw.session_id || !finite(now)) return null;
26
+ const r = { event: raw.hook_event_name, at: now, session: anon(salt, `session:${raw.session_id}`),
27
+ turn: typeof raw.turn_id === 'string' && raw.turn_id ? anon(salt, `turn:${raw.turn_id}`) : null,
28
+ tool: typeof raw.tool_use_id === 'string' ? anon(salt, `tool:${raw.tool_use_id}`) : null,
29
+ category: category(raw.tool_name), model: typeof raw.model === 'string' && MODEL.test(raw.model) ? raw.model : null };
30
+ if (typeof raw.cwd === 'string') {
31
+ const normalized = raw.cwd.replace(/\\/g, '/').replace(/\/$/, '');
32
+ r.project = anon(salt, `project:${normalized}`);
33
+ if (options.projects) r.projectName = cleanName(normalized.split('/').filter(Boolean).pop());
34
+ }
35
+ // 不解析工具结果正文,只有明确的结构化错误信号才标记失败。
36
+ if (raw.hook_event_name === 'PostToolUse') {
37
+ const response = raw.tool_response;
38
+ r.failed = raw.is_error === true || response?.is_error === true || response?.isError === true ||
39
+ typeof response?.exit_code === 'number' && response.exit_code !== 0;
40
+ }
41
+ if (['SubagentStart', 'SubagentStop'].includes(r.event) && typeof raw.agent_id === 'string') r.agent = anon(salt, `agent:${raw.agent_id}`);
42
+ return r;
43
+ }
44
+ function createSession(context) {
45
+ return { session: context.session, project: context.project || null, projectName: context.projectName || null,
46
+ model: context.model || null, state: 'lost', changedAt: context.at, changeKind: 'start', startAt: context.at,
47
+ lastAt: context.at, endAt: null, permSince: null, turn: null, nextN: 1, turns: {}, agents: {}, evidence: 'log' };
48
+ }
49
+ function turnFor(s, key, at) {
50
+ if (!key) return null;
51
+ if (!s.turns[key]) s.turns[key] = { key, n: s.nextN++, s: at, e: null, end: null, perm: 0,
52
+ waitingSince: null, tools: {}, compactions: 0, lastAt: at };
53
+ return s.turns[key];
54
+ }
55
+ function transition(s, state, kind, at) {
56
+ if (at < s.changedAt) return;
57
+ s.state = state; s.changeKind = kind; s.changedAt = at;
58
+ if (state === 'ended') s.endAt = at;
59
+ }
60
+ function finish(s, t, end, at) {
61
+ if (t && (!t.e || at >= t.lastAt)) {
62
+ if (t.waitingSince) t.perm += Math.max(0, at - t.waitingSince);
63
+ t.waitingSince = null; t.e = at; t.end = end; t.lastAt = at;
64
+ }
65
+ if (at >= s.changedAt) s.permSince = null;
66
+ transition(s, 'input', end === 'interrupt' ? 'interrupt' : end === 'error' ? 'error' : 'done', at);
67
+ }
68
+ function observe(s, event) {
69
+ const at = event.at;
70
+ if (!finite(at) || !HASH.test(event.session || '')) return;
71
+ s.startAt = Math.min(s.startAt, at); s.lastAt = Math.max(s.lastAt, at);
72
+ if (event.project) s.project = event.project;
73
+ if (event.projectName) s.projectName = event.projectName;
74
+ if (event.model) s.model = event.model;
75
+ const key = event.turn || s.turn;
76
+ const t = turnFor(s, key, at);
77
+ if (event.turn && at >= s.changedAt) s.turn = event.turn;
78
+ if (event.event === 'SessionStart') {
79
+ // 打开已有会话不代表它正在运行;保留有证据的在途轮,否则只记可见。
80
+ if (!t || t.e) transition(s, 'input', 'resume', at);
81
+ }
82
+ if (event.event === 'UserPromptSubmit' || event.event === 'task_started') {
83
+ const previous = Object.values(s.turns).find((x) => x.key !== key && !x.e);
84
+ if (previous && at >= previous.lastAt) { previous.e = at; previous.end = 'interrupt'; previous.lastAt = at; }
85
+ if (at >= s.changedAt) { s.endAt = null; s.permSince = null; }
86
+ if (!t?.e) transition(s, 'running', 'turn', at);
87
+ }
88
+ if (event.event === 'PermissionRequest' && t && !t.e && at >= s.changedAt) {
89
+ if (!t.waitingSince) t.waitingSince = at;
90
+ s.permSince = t.waitingSince; transition(s, 'perm', 'perm', at);
91
+ }
92
+ if (event.event === 'PreToolUse' || event.event === 'PostToolUse') {
93
+ if (t && event.tool) {
94
+ const tool = t.tools[event.tool] || { category: event.category || 'o', s: at, e: null, failed: false };
95
+ if (event.event === 'PreToolUse') tool.s = Math.min(tool.s, at);
96
+ else { tool.e = Math.max(tool.e || 0, at); tool.failed = !!event.failed; }
97
+ t.tools[event.tool] = tool;
98
+ }
99
+ // PreToolUse 可能紧邻授权请求且异步乱序;只有结果能证明等待已结束。
100
+ if (event.event === 'PostToolUse' && t && !t.e && at >= s.changedAt) {
101
+ if (t.waitingSince) { t.perm += Math.max(0, at - t.waitingSince); t.waitingSince = null; }
102
+ s.permSince = null; transition(s, 'running', s.state === 'perm' ? 'approved' : 'turn', at);
103
+ } else if (event.event === 'PreToolUse' && t && !t.e && s.state !== 'perm') transition(s, event.category === 'q' ? 'input' : 'running', 'turn', at);
104
+ }
105
+ if (event.event === 'Stop' || event.event === 'task_complete') finish(s, t, 'ok', at);
106
+ if (event.event === 'Interrupt' || event.event === 'turn_aborted') finish(s, t, 'interrupt', at);
107
+ if (event.event === 'turn_failed') finish(s, t, 'error', at);
108
+ if (event.event === 'SessionEnd') {
109
+ if (t && !t.e && at >= t.lastAt) { t.e = at; t.end = 'ended'; t.lastAt = at; }
110
+ if (at >= s.changedAt) s.permSince = null;
111
+ transition(s, 'ended', 'end', at);
112
+ }
113
+ if (event.event === 'PreCompact' && t) t.compactions++;
114
+ if (event.event === 'SubagentStart' && event.agent) s.agents[event.agent] ||= { s: at, e: null };
115
+ if (event.event === 'SubagentStop' && event.agent && s.agents[event.agent]) s.agents[event.agent].e = at;
116
+ if (t) t.lastAt = Math.max(t.lastAt, at);
117
+ const keys = Object.keys(s.turns).sort((a, b) => s.turns[a].s - s.turns[b].s);
118
+ for (const key of keys.slice(0, Math.max(0, keys.length - 60))) delete s.turns[key];
119
+ }
120
+ function absorbHook(store, event) {
121
+ if (!event || !EVENTS.includes(event.event) || !HASH.test(event.session || '') || !finite(event.at)) return;
122
+ // 即使本机队列文件被改过,也只接收结构投影,不能把任意文本混入历史。
123
+ event = { event: event.event, session: event.session, at: event.at,
124
+ turn: HASH.test(event.turn || '') ? event.turn : null, tool: HASH.test(event.tool || '') ? event.tool : null,
125
+ project: HASH.test(event.project || '') ? event.project : null, projectName: cleanName(event.projectName),
126
+ category: 'reasawbdmqo'.includes(event.category) && event.category?.length === 1 ? event.category : 'o',
127
+ model: MODEL.test(event.model || '') ? event.model : null, failed: event.failed === true,
128
+ agent: HASH.test(event.agent || '') ? event.agent : null };
129
+ store.sessions ||= {};
130
+ const s = store.sessions[event.session] ||= createSession(event);
131
+ s.evidence = 'hook'; observe(s, event);
132
+ }
133
+ function absorbLog(file, row, context, anonymize) {
134
+ if (!context.session || !finite(context.at)) return;
135
+ if (!file.live || file.live.session !== context.session) file.live = createSession(context);
136
+ const p = row.payload || {}, s = file.live;
137
+ if (context.model && context.model !== 'unknown') s.model = context.model;
138
+ let event = null;
139
+ if (row.type === 'event_msg') {
140
+ if (p.type === 'thread_goal_updated' && ['active', 'paused', 'blocked', 'usageLimited', 'budgetLimited', 'complete'].includes(p.goal?.status)) {
141
+ if (!s.goalUpdatedAt || context.at >= s.goalUpdatedAt) {
142
+ s.goalStatus = p.goal.status; s.goalUpdatedAt = context.at;
143
+ s.goalCompleteAt = p.goal.status === 'complete' ? context.at : null;
144
+ s.lastAt = Math.max(s.lastAt, context.at);
145
+ }
146
+ }
147
+ if (p.type === 'thread_goal_cleared' && (!s.goalUpdatedAt || context.at >= s.goalUpdatedAt)) {
148
+ s.goalStatus = null; s.goalUpdatedAt = context.at; s.goalCompleteAt = null;
149
+ }
150
+ if (['task_started', 'task_complete', 'turn_aborted'].includes(p.type)) event = { event: p.type };
151
+ if (p.type === 'turn_failed' || p.type === 'error' && p.fatal === true) event = { event: 'turn_failed' };
152
+ if (p.type === 'item_completed') {
153
+ const item = p.item || {};
154
+ const categories = { CommandExecution: 's', FileChange: 'e', McpToolCall: 'm', WebSearch: 'w', ImageView: 'r' };
155
+ if (categories[item.type] && typeof item.id === 'string') event = { event: 'PostToolUse', category: categories[item.type], tool: anonymize(`tool:${item.id}`),
156
+ failed: item.status === 'failed' || typeof item.exit_code === 'number' && item.exit_code !== 0 };
157
+ if (item.type === 'ContextCompaction') event = { event: 'PreCompact' };
158
+ }
159
+ }
160
+ if (row.type === 'token_usage_record') {
161
+ // token 本身由同一文件的去重响应表投影;这里仅记录最近活动,不能用它断言一轮完成。
162
+ s.lastAt = Math.max(s.lastAt, context.at);
163
+ }
164
+ if (event) observe(s, { ...context, ...event });
165
+ }
166
+ function mergeSession(a, b) {
167
+ if (!a) return structuredClone(b);
168
+ const latest = b.lastAt > a.lastAt || b.lastAt === a.lastAt && b.evidence === 'hook' ? b : a;
169
+ const merged = { ...latest, startAt: Math.min(a.startAt, b.startAt), turns: {} };
170
+ for (const source of [a, b]) for (const [key, t] of Object.entries(source.turns)) {
171
+ const prev = merged.turns[key];
172
+ if (!prev) merged.turns[key] = structuredClone(t);
173
+ else {
174
+ const newer = t.lastAt >= prev.lastAt ? t : prev;
175
+ merged.turns[key] = { ...newer, n: Math.max(prev.n, t.n), s: Math.min(prev.s, t.s),
176
+ e: Math.max(prev.e || 0, t.e || 0) || null, tools: { ...prev.tools, ...t.tools }, perm: Math.max(prev.perm, t.perm), compactions: Math.max(prev.compactions, t.compactions) };
177
+ }
178
+ }
179
+ merged.agents = { ...a.agents, ...b.agents };
180
+ const goal = (a.goalUpdatedAt || 0) > (b.goalUpdatedAt || 0) ? a : b;
181
+ if (goal.goalUpdatedAt) { merged.goalStatus = goal.goalStatus; merged.goalUpdatedAt = goal.goalUpdatedAt; merged.goalCompleteAt = goal.goalCompleteAt; }
182
+ return merged;
183
+ }
184
+ function sequence(tools) {
185
+ const groups = [];
186
+ for (const t of tools) { const prev = groups.at(-1); if (prev && prev[0] === t.category && prev[1] < 9999) prev[1]++; else groups.push([t.category, 1]); }
187
+ return groups.slice(-40).map((g) => g.join('')).join('');
188
+ }
189
+ function buildLive(state, options = {}) {
190
+ const now = options.now || Date.now(), sessions = new Map(), records = new Map();
191
+ for (const f of Object.values(state.files || {})) {
192
+ if (f.live) sessions.set(f.live.session, mergeSession(sessions.get(f.live.session), f.live));
193
+ for (const [id, r] of Object.entries(f.records || {})) if (!records.has(id) || r.at >= records.get(id).at) records.set(id, r);
194
+ }
195
+ for (const s of Object.values(state.liveHooks?.sessions || {})) sessions.set(s.session, mergeSession(sessions.get(s.session), s));
196
+ const tokenTotals = new Map();
197
+ for (const r of records.values()) {
198
+ if (r.at < now - KEEP || !r.turn) continue;
199
+ const key = `${r.session}:${r.turn}`, total = tokenTotals.get(key) || { input: 0, output: 0 };
200
+ total.input += r.usage.input; total.output += r.usage.output; tokenTotals.set(key, total);
201
+ }
202
+ const payload = [];
203
+ for (const s of sessions.values()) {
204
+ if (s.lastAt < now - KEEP || s.lastAt > now + 300000) continue;
205
+ const turns = Object.values(s.turns).sort((a, b) => a.s - b.s).slice(-60).map((t, i) => {
206
+ const tools = Object.values(t.tools).sort((a, b) => a.s - b.s);
207
+ const { input = 0, output = 0 } = tokenTotals.get(`${s.session}:${t.key}`) || {};
208
+ const longest = tools.filter((x) => x.e).sort((a, b) => (b.e - b.s) - (a.e - a.s))[0];
209
+ return { id: t.key.slice(0, 16), n: t.n, s: t.s, e: t.e, end: t.end, perm: t.perm + (t.waitingSince ? Math.max(0, now - t.waitingSince) : 0),
210
+ tools: tools.length, seq: sequence(tools), cells: tools.slice(-90).map((x) => x.category).join(''),
211
+ long: longest ? [longest.category, longest.e - longest.s] : null, in: input, out: output, cw: 0,
212
+ err: tools.filter((x) => x.failed).length, cmp: t.compactions };
213
+ });
214
+ const current = Object.values(s.turns).find((t) => t.key === s.turn);
215
+ const lastTools = Object.values(current?.tools || {}).sort((a, b) => a.s - b.s);
216
+ let failures = 0; for (const tool of [...lastTools].reverse()) { if (!tool.failed) break; failures++; }
217
+ payload.push({ sid: s.session.slice(0, 16), proj: s.project?.slice(0, 16) || null,
218
+ state: s.state, changedAt: s.changedAt, changeKind: s.changeKind, startAt: s.startAt, lastAt: s.lastAt,
219
+ // 扫描器仍在运行不能证明 Codex 会话仍在运行;心跳只使用真实事件时刻。
220
+ beatAt: s.lastAt, endAt: s.endAt, permSince: s.permSince, model: s.model, turns,
221
+ tools: turns.reduce((n, t) => n + t.tools, 0), errors: turns.reduce((n, t) => n + t.err, 0),
222
+ compactions: turns.reduce((n, t) => n + t.cmp, 0), fs: failures, subs: Object.values(s.agents).map((a) => [a.s, a.e || 0, 0]),
223
+ ...(s.goalStatus ? { goalStatus: s.goalStatus, goalCompleteAt: s.goalCompleteAt || null } : {}),
224
+ ...(options.projects && s.projectName ? { pname: s.projectName } : {}) });
225
+ }
226
+ payload.sort((a, b) => Number(['ended', 'lost'].includes(a.state)) - Number(['ended', 'lost'].includes(b.state)) || b.lastAt - a.lastAt);
227
+ const body = { tier: options.projects ? 2 : 1, devNow: now, sessions: payload.slice(0, 40) };
228
+ // 保留每个会话的当前轮,再裁旧轮;绝不因为体积大而整批永远上传失败。
229
+ while (Buffer.byteLength(JSON.stringify(body)) > 60 * 1024) {
230
+ const largest = body.sessions.filter((s) => s.turns.length > 1).sort((a, b) => b.turns.length - a.turns.length)[0];
231
+ if (largest) { largest.turns.shift(); largest.clippedTurns = (largest.clippedTurns || 0) + 1; }
232
+ else if (body.sessions.length > 1) body.sessions.pop();
233
+ else break;
234
+ }
235
+ return body;
236
+ }
237
+ module.exports = { EVENTS, category, sanitizeHook, createSession, observe, absorbHook, absorbLog, buildLive };
package/lib/quota.cjs ADDED
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+ const { spawn } = require('node:child_process');
3
+ const readline = require('node:readline');
4
+ const path = require('node:path');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const { quota, hash } = require('./collect.cjs');
8
+
9
+ // 只建立协议连接并读取额度,不创建、恢复或修改会话,不读取登录凭证文件。
10
+ function readQuota({ binary = process.env.NUMABLE_CODEX_BINARY || 'codex', home, salt, timeoutMs = 15000, prefixArgs = [] } = {}) {
11
+ if (!salt) return Promise.reject(new Error('读取额度缺少本机匿名化配置'));
12
+ return new Promise((resolve, reject) => {
13
+ const child = spawn(binary, [...prefixArgs, 'app-server', '--listen', 'stdio://'], {
14
+ env: { ...process.env, ...(home ? { CODEX_HOME: home } : {}) }, stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true,
15
+ });
16
+ const lines = readline.createInterface({ input: child.stdout });
17
+ let done = false;
18
+ const timer = setTimeout(() => finish(new Error('额度读取超时,请稍后重试')), timeoutMs);
19
+ const finish = (error, result) => {
20
+ if (done) return; done = true;
21
+ clearTimeout(timer); lines.close(); child.stdin.end();
22
+ const killTimer = setTimeout(() => child.kill(), 1000); killTimer.unref();
23
+ child.once('close', () => clearTimeout(killTimer));
24
+ error ? reject(error) : resolve(result);
25
+ };
26
+ const send = (message) => { if (!done) child.stdin.write(JSON.stringify(message) + '\n'); };
27
+ child.once('error', () => finish(new Error('Codex 命令无法启动,请检查安装或指定可执行文件')));
28
+ child.stdin.on('error', () => finish(new Error('Codex 协议连接已关闭,请检查安装后重试')));
29
+ child.once('close', () => { if (!done) finish(new Error('Codex 在返回额度前退出')); });
30
+ lines.on('line', (line) => {
31
+ if (done || line.length > 1024 * 1024) return;
32
+ let r; try { r = JSON.parse(line); } catch { return; }
33
+ if (r.id === 1) {
34
+ if (r.error) return finish(new Error('当前 Codex 不支持额度协议初始化'));
35
+ send({ method: 'initialized', params: {} });
36
+ send({ id: 2, method: 'account/rateLimits/read' });
37
+ }
38
+ if (r.id === 2) {
39
+ if (r.error) return finish(new Error('当前账号无法读取额度,请检查 Codex 登录状态'));
40
+ const result = r.result || {}, observedAt = Date.now();
41
+ const buckets = result.rateLimitsByLimitId && typeof result.rateLimitsByLimitId === 'object'
42
+ ? Object.entries(result.rateLimitsByLimitId).map(([key, bucket]) => ({ ...bucket, limitId: bucket?.limitId || key }))
43
+ : [result.rateLimits];
44
+ const account = typeof result.accountId === 'string' && result.accountId ? hash(salt, `account:${result.accountId}`) : null;
45
+ const quotas = buckets.map((bucket) => quota(bucket, observedAt)).filter(Boolean).map((q) => ({ ...q, account, origin: 'rpc' }));
46
+ finish(null, { observedAt, scope: hash(salt, 'quota-scope'), supported: quotas.length > 0, quotas });
47
+ }
48
+ });
49
+ send({ id: 1, method: 'initialize', params: { clientInfo: { name: 'numable_codex_usage', version: '0.1.0' }, capabilities: { experimentalApi: true } } });
50
+ });
51
+ }
52
+ async function readQuotaHomes({ homes, salt, profileSalt = salt, previous, ...options }) {
53
+ const readOne = async (home) => {
54
+ try { return await readQuota({ ...options, home, salt }); }
55
+ catch (error) {
56
+ if (options.binary || process.env.NUMABLE_CODEX_BINARY || process.platform !== 'darwin') throw error;
57
+ const relative = 'ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex';
58
+ const candidates = [path.join('/Applications', relative), path.join(os.homedir(), 'Applications', relative)];
59
+ for (const binary of candidates) if (fs.existsSync(binary)) {
60
+ try { return await readQuota({ ...options, binary, home, salt }); } catch {}
61
+ }
62
+ throw error;
63
+ }
64
+ };
65
+ const results = await Promise.allSettled(homes.map(readOne));
66
+ const quotas = [];
67
+ let failures = 0;
68
+ results.forEach((r, i) => {
69
+ const profile = hash(profileSalt, `profile:${path.resolve(homes[i])}`);
70
+ if (r.status !== 'fulfilled') {
71
+ failures++;
72
+ if (previous?.scope === hash(salt, 'quota-scope')) quotas.push(...(previous.quotas || []).filter((q) => q.profile === profile));
73
+ return;
74
+ }
75
+ quotas.push(...r.value.quotas.map((q) => ({ ...q, profile })));
76
+ });
77
+ return { observedAt: Date.now(), scope: hash(salt, 'quota-scope'), supported: quotas.length > 0,
78
+ failures, profileCount: homes.length, quotas };
79
+ }
80
+ module.exports = { readQuota, readQuotaHomes };
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "numable-codex-usage",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "在本机统计 Codex 用量,并按需同步到 Numable",
5
+ "license": "MIT",
6
+ "engines": { "node": ">=18.17" },
7
+ "bin": { "numable-codex": "cli.cjs" },
8
+ "files": ["cli.cjs", "lib/", "README.md"],
9
+ "scripts": { "test": "node --test ../test/codex-*.test.cjs" }
10
+ }