tokenforbes-cli 0.1.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/activity.js +124 -0
- package/bin.js +4 -1
- package/package.json +2 -2
package/activity.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// 读本地 AI 活跃度:会话数/消息数/活跃时长/分时分布。
|
|
2
|
+
// 只数「条数」和「时间戳」,绝不读取、不上传任何对话内容、代码和项目名。
|
|
3
|
+
// 数据源:Claude Code(~/.claude/projects)和 Codex(~/.codex/sessions)的本地会话日志。
|
|
4
|
+
// 注意:本地日志一般只保留最近约 30 天,所以活跃指标最多回填一个月;token 用量走 ccusage 老链路,不受影响。
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import readline from 'node:readline';
|
|
9
|
+
|
|
10
|
+
const ACTIVE_GAP = 5 * 60 * 1000; // 活跃时长口径:相邻两条记录间隔 < 5 分钟,算连续在干活
|
|
11
|
+
const TOTAL_GAP = 60 * 60 * 1000; // 总时长口径:间隔 < 60 分钟,算同一段工作(中间想事、查资料也算)
|
|
12
|
+
const MAX_DAYS = 400; // 与用量上报同口径,只留最近 400 天
|
|
13
|
+
|
|
14
|
+
// 递归找 .jsonl;目录不存在或没权限就当没有,活跃度是锦上添花,绝不因它报错。
|
|
15
|
+
function jsonlFiles(dir, out = []) {
|
|
16
|
+
let entries;
|
|
17
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
18
|
+
for (const e of entries) {
|
|
19
|
+
const p = path.join(dir, e.name);
|
|
20
|
+
if (e.isDirectory()) jsonlFiles(p, out);
|
|
21
|
+
else if (e.name.endsWith('.jsonl')) out.push(p);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 本地时区的 YYYY-MM-DD,和 ccusage daily 的按天口径保持一致。
|
|
27
|
+
function dayKey(ts) {
|
|
28
|
+
const d = new Date(ts);
|
|
29
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function dayOf(days, key) {
|
|
33
|
+
let day = days.get(key);
|
|
34
|
+
if (!day) { day = { sessions: new Set(), userMsgs: 0, aiMsgs: 0, stamps: [] }; days.set(key, day); }
|
|
35
|
+
return day;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Claude Code 日志:一行一个 JSON,type=user/assistant 的行是对话消息。
|
|
39
|
+
// 跳过子 Agent(isSidechain)和系统注入(isMeta);带 toolUseResult 的 user 行是工具结果回传,不算人发的消息。
|
|
40
|
+
async function scanClaude(days) {
|
|
41
|
+
for (const f of jsonlFiles(path.join(os.homedir(), '.claude', 'projects'))) {
|
|
42
|
+
try {
|
|
43
|
+
const rl = readline.createInterface({ input: fs.createReadStream(f) });
|
|
44
|
+
for await (const line of rl) {
|
|
45
|
+
if (!line.includes('"timestamp"')) continue;
|
|
46
|
+
let o; try { o = JSON.parse(line); } catch { continue; }
|
|
47
|
+
if (o.type !== 'user' && o.type !== 'assistant') continue;
|
|
48
|
+
if (o.isSidechain || o.isMeta) continue;
|
|
49
|
+
const ts = Date.parse(o.timestamp);
|
|
50
|
+
if (!ts) continue;
|
|
51
|
+
const day = dayOf(days, dayKey(ts));
|
|
52
|
+
if (o.sessionId) day.sessions.add(o.sessionId);
|
|
53
|
+
if (o.type === 'assistant') day.aiMsgs++;
|
|
54
|
+
else if (!o.toolUseResult) day.userMsgs++;
|
|
55
|
+
day.stamps.push(ts);
|
|
56
|
+
}
|
|
57
|
+
} catch { /* 单个文件读坏了就跳过 */ }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Codex 日志:每行开头都是 {"timestamp":"…"},一个 rollout 文件 = 一个会话。
|
|
62
|
+
// 行可能巨大(工具输出全文),所以不整行 JSON.parse,用字符串特征匹配,只挑需要的行。
|
|
63
|
+
const CODEX_TS = /^\{"timestamp":"([^"]+)"/;
|
|
64
|
+
async function scanCodex(days) {
|
|
65
|
+
for (const f of jsonlFiles(path.join(os.homedir(), '.codex', 'sessions'))) {
|
|
66
|
+
const sessionId = path.basename(f, '.jsonl'); // 文件名含会话 UUID,够当会话标识
|
|
67
|
+
try {
|
|
68
|
+
const rl = readline.createInterface({ input: fs.createReadStream(f) });
|
|
69
|
+
for await (const line of rl) {
|
|
70
|
+
const m = CODEX_TS.exec(line);
|
|
71
|
+
if (!m) continue;
|
|
72
|
+
const ts = Date.parse(m[1]);
|
|
73
|
+
if (!ts) continue;
|
|
74
|
+
const day = dayOf(days, dayKey(ts));
|
|
75
|
+
day.sessions.add(sessionId);
|
|
76
|
+
day.stamps.push(ts);
|
|
77
|
+
// 消息只数 response_item 里 type=message 的行;环境上下文/预置指令是注入的,不算人发的。
|
|
78
|
+
if (!line.includes('"type":"response_item"') || !line.includes('"type":"message"')) continue;
|
|
79
|
+
if (line.includes('"role":"assistant"')) day.aiMsgs++;
|
|
80
|
+
else if (line.includes('"role":"user"')
|
|
81
|
+
&& !line.includes('<environment_context>') && !line.includes('<user_instructions>')) day.userMsgs++;
|
|
82
|
+
}
|
|
83
|
+
} catch { /* 单个文件读坏了就跳过 */ }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 把一天的时间戳折成两种时长(分钟)和 24 小时消息分布。
|
|
88
|
+
function summarize(day) {
|
|
89
|
+
day.stamps.sort((a, b) => a - b);
|
|
90
|
+
let active = 0, total = 0;
|
|
91
|
+
const hours = Array(24).fill(0);
|
|
92
|
+
for (let i = 0; i < day.stamps.length; i++) {
|
|
93
|
+
hours[new Date(day.stamps[i]).getHours()]++;
|
|
94
|
+
if (i === 0) continue;
|
|
95
|
+
const gap = day.stamps[i] - day.stamps[i - 1];
|
|
96
|
+
if (gap < ACTIVE_GAP) active += gap;
|
|
97
|
+
if (gap < TOTAL_GAP) total += gap;
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
sessions: day.sessions.size,
|
|
101
|
+
userMsgs: day.userMsgs,
|
|
102
|
+
aiMsgs: day.aiMsgs,
|
|
103
|
+
activeMin: Math.round(active / 60000),
|
|
104
|
+
totalMin: Math.round(total / 60000),
|
|
105
|
+
hours,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 返回 [{date, sessions, userMsgs, aiMsgs, activeMin, totalMin, hours[24]}],按日期升序。
|
|
110
|
+
// 任何一步出问题都返回 [](绝不让活跃度统计拖垮用量上报)。
|
|
111
|
+
export async function readActivity() {
|
|
112
|
+
try {
|
|
113
|
+
const days = new Map();
|
|
114
|
+
await scanClaude(days);
|
|
115
|
+
await scanCodex(days);
|
|
116
|
+
return [...days]
|
|
117
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
118
|
+
.slice(-MAX_DAYS)
|
|
119
|
+
.map(([date, day]) => ({ date, ...summarize(day) }))
|
|
120
|
+
.filter((d) => d.userMsgs + d.aiMsgs > 0);
|
|
121
|
+
} catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
}
|
package/bin.js
CHANGED
|
@@ -6,6 +6,7 @@ import os from 'node:os';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { exec } from 'node:child_process';
|
|
8
8
|
import { readUsage } from './ccusage.js';
|
|
9
|
+
import { readActivity } from './activity.js';
|
|
9
10
|
import { detectDevice } from './device.js';
|
|
10
11
|
import { daemon, ensureDaemonInstalled } from './daemon.js';
|
|
11
12
|
|
|
@@ -215,10 +216,12 @@ async function uploadDays(days, { server, key, device }) {
|
|
|
215
216
|
return;
|
|
216
217
|
}
|
|
217
218
|
stage = 'upload';
|
|
219
|
+
// 顺手统计活跃度(会话数/消息数/时长/分时),同样只有条数和时间戳;失败返回空数组,绝不影响用量上报。
|
|
220
|
+
const activity = await readActivity();
|
|
218
221
|
const total = days.reduce((s, d) => s + d.input + d.output + (d.cacheCreation || 0) + (d.cacheRead || 0), 0);
|
|
219
222
|
console.log(`\n设备:${device}`);
|
|
220
223
|
console.log(`共 ${days.length} 天、约 ${Math.round(total / 1e4).toLocaleString()} 万 token,正在上传(只传数字,不传内容)…`);
|
|
221
|
-
const res = await postRaw(server + '/api/report', { key, device, days });
|
|
224
|
+
const res = await postRaw(server + '/api/report', { key, device, days, activity });
|
|
222
225
|
if (!res.ok) die(sendFailText(res));
|
|
223
226
|
stage = 'done';
|
|
224
227
|
console.log(`✓ 上报成功!服务器已记下 ${res.json.days} 天的用量。\n`);
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenforbes-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "上报本地 AI 编程工具(Claude Code / Codex 等)的 token 用量到 Token 福布斯排行榜(ceorank.cn)。只读用量数字和设备型号,绝不碰你的代码内容。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"tokenforbes": "./bin.js"
|
|
8
8
|
},
|
|
9
|
-
"files": ["bin.js", "ccusage.js", "device.js", "daemon.js"],
|
|
9
|
+
"files": ["bin.js", "ccusage.js", "device.js", "daemon.js", "activity.js"],
|
|
10
10
|
"engines": {
|
|
11
11
|
"node": ">=18"
|
|
12
12
|
},
|