agenttally 0.5.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 +30 -0
- package/agenttally.mjs +1145 -0
- package/package.json +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# agenttally
|
|
2
|
+
|
|
3
|
+
AI coding 用量与配额追踪——单文件、零依赖。采集 Claude Code / Codex / Cursor / Qwen Code / Kimi CLI / CodeBuddy 的本地用量与订阅配额(5h/7d 窗口),同步到云端,微信小程序随时查看 + 配额恢复提醒 + 排行榜。
|
|
4
|
+
|
|
5
|
+
## 快速开始
|
|
6
|
+
|
|
7
|
+
小程序「绑定设备」页会生成专属命令,复制到电脑终端运行即可:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx agenttally init --token <小程序生成> # 绑定+配额捕获+首次同步+后台守护,一次完成
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
也可以不带 token 运行 `npx agenttally init`,按提示在小程序输入验证码。
|
|
14
|
+
|
|
15
|
+
## 命令
|
|
16
|
+
|
|
17
|
+
| 命令 | 作用 |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `init` | 绑定设备(默认连官方后端,`--api-url` 可自建) |
|
|
20
|
+
| `sync` | 手动采集并上报一次(用量 + 配额) |
|
|
21
|
+
| `status` | 显示配置与检测到的工具 |
|
|
22
|
+
| `daemon install\|uninstall\|status` | 后台定时同步(launchd/systemd,每 30 分钟) |
|
|
23
|
+
| `statusline install\|uninstall` | 捕获 Claude Code 配额(链式透传原 statusline,不破坏现有配置) |
|
|
24
|
+
| `update` | 自更新 |
|
|
25
|
+
|
|
26
|
+
## 数据说明
|
|
27
|
+
|
|
28
|
+
只解析本地日志的 token 计数与配额百分比。**项目名、对话内容、文件路径一概不上传**——云端没有这些维度。数据为自报口径,适合可见性与趋势,不适合结算。
|
|
29
|
+
|
|
30
|
+
要求 Node ≥ 20(Cursor 采集需 ≥ 22.5,缺失时自动跳过该项)。
|
package/agenttally.mjs
ADDED
|
@@ -0,0 +1,1145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// agenttally — AI coding 用量+配额采集 CLI(单文件、零依赖、自托管更新)
|
|
3
|
+
//
|
|
4
|
+
// agenttally init [--api-url URL] 设备绑定(device flow)
|
|
5
|
+
// agenttally sync 采集并上报一次(用量 + 配额)
|
|
6
|
+
// agenttally status 显示配置、检测到的工具、state 概况
|
|
7
|
+
// agenttally daemon install|uninstall|status 后台定时同步(launchd/systemd)
|
|
8
|
+
// agenttally statusline install|uninstall|status Claude Code 配额捕获(链式透传)
|
|
9
|
+
// agenttally update 从后端自更新
|
|
10
|
+
//
|
|
11
|
+
// 安装:npx agenttally init(Node >= 20;Cursor 采集需 >= 22.5,缺失时自动跳过)
|
|
12
|
+
import {
|
|
13
|
+
readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync,
|
|
14
|
+
renameSync, chmodSync, copyFileSync, rmSync, mkdtempSync, unlinkSync,
|
|
15
|
+
} from 'node:fs';
|
|
16
|
+
import { homedir, hostname as osHostname, tmpdir, platform } from 'node:os';
|
|
17
|
+
import { join, basename, sep } from 'node:path';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { gzipSync } from 'node:zlib';
|
|
20
|
+
import { execSync } from 'node:child_process';
|
|
21
|
+
|
|
22
|
+
export const VERSION = '0.5.0';
|
|
23
|
+
const DEFAULT_API_URL = 'https://to50.cn';
|
|
24
|
+
|
|
25
|
+
const HOME = homedir();
|
|
26
|
+
const VM_DIR = join(HOME, '.agenttally');
|
|
27
|
+
const CONFIG_FILE = join(VM_DIR, 'config.json');
|
|
28
|
+
const STATE_FILE = join(VM_DIR, 'state.json');
|
|
29
|
+
const CAPTURE_FILE = join(VM_DIR, 'claude-rate-limits.json');
|
|
30
|
+
const WRAPPER = join(VM_DIR, 'statusline.sh');
|
|
31
|
+
const ORIGINAL_FILE = join(VM_DIR, 'statusline-original');
|
|
32
|
+
const CLAUDE_SETTINGS = join(HOME, '.claude', 'settings.json');
|
|
33
|
+
const SELF_PATH = process.argv[1];
|
|
34
|
+
const STALE_SECONDS = 30 * 60;
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------- utils
|
|
37
|
+
|
|
38
|
+
const readJson = (p, fallback = null) => {
|
|
39
|
+
try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return fallback; }
|
|
40
|
+
};
|
|
41
|
+
const writeJson = (p, obj) => {
|
|
42
|
+
mkdirSync(VM_DIR, { recursive: true });
|
|
43
|
+
const tmp = p + '.tmp';
|
|
44
|
+
writeFileSync(tmp, JSON.stringify(obj, null, 2));
|
|
45
|
+
renameSync(tmp, p);
|
|
46
|
+
};
|
|
47
|
+
const sha16 = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
48
|
+
const cleanHostname = () => osHostname().replace(/\.local$/, '');
|
|
49
|
+
|
|
50
|
+
function* walkJsonl(dir) {
|
|
51
|
+
if (!existsSync(dir)) return;
|
|
52
|
+
let entries;
|
|
53
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
54
|
+
for (const e of entries) {
|
|
55
|
+
const full = join(dir, e.name);
|
|
56
|
+
if (e.isDirectory()) yield* walkJsonl(full);
|
|
57
|
+
else if (e.name.endsWith('.jsonl')) yield full;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------- config / state
|
|
62
|
+
|
|
63
|
+
function loadConfig() { return readJson(CONFIG_FILE); }
|
|
64
|
+
function loadState() { return readJson(STATE_FILE, { buckets: {}, sessions: {} }); }
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------- 聚合(30 分钟桶 / 会话提取)
|
|
67
|
+
|
|
68
|
+
function halfHourISO(date) {
|
|
69
|
+
const d = new Date(date);
|
|
70
|
+
d.setMinutes(d.getMinutes() < 30 ? 0 : 30, 0, 0);
|
|
71
|
+
return d.toISOString();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// entry: {source, model, project, hostname?, timestamp, inputTokens, outputTokens,
|
|
75
|
+
// cachedInputTokens, cacheCreationTokens, reasoningOutputTokens}
|
|
76
|
+
function toBuckets(entries, defaultHostname) {
|
|
77
|
+
const map = new Map();
|
|
78
|
+
for (const e of entries) {
|
|
79
|
+
const host = e.hostname || defaultHostname;
|
|
80
|
+
const bucketStart = halfHourISO(e.timestamp);
|
|
81
|
+
const key = `${e.source}|${e.model}|${e.project}|${host}|${bucketStart}`;
|
|
82
|
+
let b = map.get(key);
|
|
83
|
+
if (!b) {
|
|
84
|
+
b = { source: e.source, model: e.model, project: e.project, hostname: host, bucketStart,
|
|
85
|
+
inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, cacheCreationTokens: 0,
|
|
86
|
+
reasoningOutputTokens: 0, totalTokens: 0 };
|
|
87
|
+
map.set(key, b);
|
|
88
|
+
}
|
|
89
|
+
b.inputTokens += e.inputTokens || 0;
|
|
90
|
+
b.outputTokens += e.outputTokens || 0;
|
|
91
|
+
b.cachedInputTokens += e.cachedInputTokens || 0;
|
|
92
|
+
b.cacheCreationTokens += e.cacheCreationTokens || 0;
|
|
93
|
+
b.reasoningOutputTokens += e.reasoningOutputTokens || 0;
|
|
94
|
+
b.totalTokens += (e.inputTokens || 0) + (e.outputTokens || 0) + (e.reasoningOutputTokens || 0);
|
|
95
|
+
}
|
|
96
|
+
return [...map.values()];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// event: {sessionId, source, project, timestamp(Date), role: user|assistant}
|
|
100
|
+
// activeSeconds = 每个 turn 内首条 AI 响应→末条 AI 响应的累计(排除排队/首 token 等待)
|
|
101
|
+
function toSessions(events, defaultHostname) {
|
|
102
|
+
const bySession = new Map();
|
|
103
|
+
for (const e of events) {
|
|
104
|
+
let g = bySession.get(e.sessionId);
|
|
105
|
+
if (!g) bySession.set(e.sessionId, (g = []));
|
|
106
|
+
g.push(e);
|
|
107
|
+
}
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const [sessionId, evs] of bySession) {
|
|
110
|
+
evs.sort((a, b) => a.timestamp - b.timestamp);
|
|
111
|
+
const first = evs[0], last = evs[evs.length - 1];
|
|
112
|
+
|
|
113
|
+
let active = 0, turnStart = null, turnEnd = null, awaitingAI = false;
|
|
114
|
+
const hours = new Array(24).fill(0);
|
|
115
|
+
let userCount = 0;
|
|
116
|
+
for (const e of evs) {
|
|
117
|
+
if (e.role === 'user') {
|
|
118
|
+
if (turnStart !== null && turnEnd > turnStart) active += Math.round((turnEnd - turnStart) / 1000);
|
|
119
|
+
turnStart = turnEnd = null;
|
|
120
|
+
awaitingAI = true;
|
|
121
|
+
userCount++;
|
|
122
|
+
hours[e.timestamp.getUTCHours()]++;
|
|
123
|
+
} else if (awaitingAI) {
|
|
124
|
+
turnStart = turnEnd = e.timestamp;
|
|
125
|
+
awaitingAI = false;
|
|
126
|
+
} else if (turnStart !== null) {
|
|
127
|
+
turnEnd = e.timestamp;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (turnStart !== null && turnEnd > turnStart) active += Math.round((turnEnd - turnStart) / 1000);
|
|
131
|
+
|
|
132
|
+
out.push({
|
|
133
|
+
source: first.source,
|
|
134
|
+
project: first.project || 'unknown',
|
|
135
|
+
hostname: defaultHostname,
|
|
136
|
+
sessionHash: sha16(sessionId),
|
|
137
|
+
firstMessageAt: first.timestamp.toISOString(),
|
|
138
|
+
lastMessageAt: last.timestamp.toISOString(),
|
|
139
|
+
durationSeconds: Math.round((last.timestamp - first.timestamp) / 1000),
|
|
140
|
+
activeSeconds: active,
|
|
141
|
+
messageCount: evs.length,
|
|
142
|
+
userMessageCount: userCount,
|
|
143
|
+
userPromptHours: hours,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------- 解析器: Claude Code
|
|
150
|
+
// 格式事实:~/.claude/projects/<dash-encoded-path>/<sessionId>.jsonl,每行一个事件,
|
|
151
|
+
// assistant 事件带 message.usage{input_tokens, output_tokens, cache_read_input_tokens},
|
|
152
|
+
// 事件有 uuid(重试/续传会重复,需去重)。$CLAUDE_CONFIG_DIR 可整体搬迁,双根都扫。
|
|
153
|
+
|
|
154
|
+
function claudeRoots() {
|
|
155
|
+
const roots = [join(HOME, '.claude')];
|
|
156
|
+
let cfg = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
157
|
+
if (cfg) {
|
|
158
|
+
if (cfg.startsWith('~')) cfg = join(HOME, cfg.slice(1));
|
|
159
|
+
if (!roots.includes(cfg)) roots.push(cfg);
|
|
160
|
+
}
|
|
161
|
+
return roots;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// CC 家族通用解析(CodeBuddy 等克隆了 Claude Code 的存储范式,可参数化复用)
|
|
165
|
+
function parseClaudeFamily(roots, source) {
|
|
166
|
+
const entries = [], events = [];
|
|
167
|
+
const seenFiles = new Set(), seenSessions = new Set();
|
|
168
|
+
// 去重必须按 message.id:流式回复会把同一条消息写成多行(同 message.id、
|
|
169
|
+
// 各自独立 uuid、usage 重复),按 uuid 去重会 ~1.9x 高估;--resume 复制
|
|
170
|
+
// 历史也会跨文件重复同一 message.id。无 id 时退回 uuid。末次出现覆盖
|
|
171
|
+
// (流式末行才是最终 usage)。
|
|
172
|
+
const entryByMsg = new Map();
|
|
173
|
+
|
|
174
|
+
for (const root of roots) {
|
|
175
|
+
const projectsDir = join(root, 'projects');
|
|
176
|
+
for (const file of walkJsonl(projectsDir)) {
|
|
177
|
+
const rel = file.startsWith(projectsDir + sep) ? file.slice(projectsDir.length + 1) : file;
|
|
178
|
+
if (seenFiles.has(rel)) continue; // 同一会话被两个根都覆盖时只算一次
|
|
179
|
+
seenFiles.add(rel);
|
|
180
|
+
|
|
181
|
+
const dirSeg = rel.split(sep)[0] || '';
|
|
182
|
+
const segs = dirSeg.split('-').filter(Boolean);
|
|
183
|
+
const project = segs.length ? segs[segs.length - 1] : 'unknown';
|
|
184
|
+
const sessionId = basename(file, '.jsonl');
|
|
185
|
+
seenSessions.add(sessionId);
|
|
186
|
+
|
|
187
|
+
let text;
|
|
188
|
+
try { text = readFileSync(file, 'utf8'); } catch { continue; }
|
|
189
|
+
for (const line of text.split('\n')) {
|
|
190
|
+
if (!line) continue;
|
|
191
|
+
let obj;
|
|
192
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
193
|
+
const ts = obj.timestamp ? new Date(obj.timestamp) : null;
|
|
194
|
+
if (!ts || isNaN(ts)) continue;
|
|
195
|
+
|
|
196
|
+
if (obj.type === 'user' || obj.type === 'assistant' || obj.type === 'tool_use' || obj.type === 'tool_result') {
|
|
197
|
+
events.push({ sessionId, source, project, timestamp: ts, role: obj.type === 'user' ? 'user' : 'assistant' });
|
|
198
|
+
}
|
|
199
|
+
if (obj.type !== 'assistant') continue;
|
|
200
|
+
const usage = obj.message?.usage;
|
|
201
|
+
if (!usage || (usage.input_tokens == null && usage.output_tokens == null)) continue;
|
|
202
|
+
const dedupKey = obj.message.id || obj.uuid;
|
|
203
|
+
if (!dedupKey) continue;
|
|
204
|
+
const entry = {
|
|
205
|
+
source,
|
|
206
|
+
model: obj.message.model || 'unknown',
|
|
207
|
+
project,
|
|
208
|
+
timestamp: ts,
|
|
209
|
+
inputTokens: usage.input_tokens || 0,
|
|
210
|
+
outputTokens: usage.output_tokens || 0,
|
|
211
|
+
cachedInputTokens: usage.cache_read_input_tokens || 0,
|
|
212
|
+
// 缓存写入按 1.25x input 计费——漏掉会显著低估成本
|
|
213
|
+
cacheCreationTokens: usage.cache_creation_input_tokens || 0,
|
|
214
|
+
reasoningOutputTokens: 0,
|
|
215
|
+
};
|
|
216
|
+
const prev = entryByMsg.get(dedupKey);
|
|
217
|
+
if (prev) Object.assign(prev, entry); // 末次覆盖:流式最后一行才是该消息最终 usage
|
|
218
|
+
else entryByMsg.set(dedupKey, entry);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// transcripts/ 只有会话时间线(无 token),补充未覆盖的会话
|
|
223
|
+
for (const file of walkJsonl(join(root, 'transcripts'))) {
|
|
224
|
+
const sessionId = basename(file, '.jsonl');
|
|
225
|
+
if (seenSessions.has(sessionId)) continue;
|
|
226
|
+
seenSessions.add(sessionId);
|
|
227
|
+
let text;
|
|
228
|
+
try { text = readFileSync(file, 'utf8'); } catch { continue; }
|
|
229
|
+
for (const line of text.split('\n')) {
|
|
230
|
+
if (!line) continue;
|
|
231
|
+
let obj;
|
|
232
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
233
|
+
const ts = obj.timestamp ? new Date(obj.timestamp) : null;
|
|
234
|
+
if (!ts || isNaN(ts)) continue;
|
|
235
|
+
if (obj.type === 'user' || obj.type === 'assistant' || obj.type === 'tool_use' || obj.type === 'tool_result') {
|
|
236
|
+
events.push({ sessionId, source, project: 'unknown', timestamp: ts, role: obj.type === 'user' ? 'user' : 'assistant' });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
entries.push(...entryByMsg.values());
|
|
242
|
+
return { entries, events };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const parseClaudeCode = () => parseClaudeFamily(claudeRoots(), 'claude-code');
|
|
246
|
+
|
|
247
|
+
// CodeBuddy CLI(腾讯):存储范式与 Claude Code 一致。格式事实(实测 2.106):
|
|
248
|
+
// home = $CODEBUDDY_CONFIG_DIR || ~/.codebuddy;会话 projects/<压缩工作目录>/<id>.jsonl;
|
|
249
|
+
// usage 字段 Anthropic 风格 input_tokens/output_tokens。
|
|
250
|
+
function codebuddyRoots() {
|
|
251
|
+
const cfg = process.env.CODEBUDDY_CONFIG_DIR?.trim();
|
|
252
|
+
return [cfg || join(HOME, '.codebuddy')];
|
|
253
|
+
}
|
|
254
|
+
const parseCodeBuddy = () => parseClaudeFamily(codebuddyRoots(), 'codebuddy');
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------- 解析器: Codex
|
|
257
|
+
// 格式事实:~/.codex/{sessions,archived_sessions}/YYYY/MM/DD/rollout-*.jsonl。
|
|
258
|
+
// token 在 event_msg.payload.type=token_count,优先 info.last_token_usage(增量),
|
|
259
|
+
// 否则用 info.total_token_usage 做差分。OpenAI 口径 input 含 cached、output 含
|
|
260
|
+
// reasoning,归一成互不重叠。fork 的会话(session_meta.forked_from_id)会把源会话
|
|
261
|
+
// 整个重放(含全部 token_count),需跳过源会话 token_count 总数对应的前缀,否则双计。
|
|
262
|
+
|
|
263
|
+
const CODEX_DIRS = [join(HOME, '.codex', 'sessions'), join(HOME, '.codex', 'archived_sessions')];
|
|
264
|
+
|
|
265
|
+
function codexIndexFile(file) {
|
|
266
|
+
let sessionId = null, forkedFromId = null, project = 'unknown', tokenCounts = 0;
|
|
267
|
+
let text;
|
|
268
|
+
try { text = readFileSync(file, 'utf8'); } catch { return null; }
|
|
269
|
+
for (const line of text.split('\n')) {
|
|
270
|
+
if (!line) continue;
|
|
271
|
+
let obj;
|
|
272
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
273
|
+
if (obj.type === 'session_meta' && obj.payload) {
|
|
274
|
+
sessionId = obj.payload.id || sessionId;
|
|
275
|
+
forkedFromId = obj.payload.forked_from_id || null;
|
|
276
|
+
const repo = obj.payload.git?.repository_url;
|
|
277
|
+
if (repo) {
|
|
278
|
+
const m = repo.match(/([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
279
|
+
if (m) project = m[1];
|
|
280
|
+
} else if (obj.payload.cwd) {
|
|
281
|
+
project = obj.payload.cwd.split('/').pop() || 'unknown';
|
|
282
|
+
}
|
|
283
|
+
} else if (obj.type === 'event_msg' && obj.payload?.type === 'token_count') {
|
|
284
|
+
tokenCounts++;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return { sessionId, forkedFromId, project, tokenCounts, text };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function parseCodex() {
|
|
291
|
+
const entries = [], events = [];
|
|
292
|
+
const files = CODEX_DIRS.flatMap((d) => [...walkJsonl(d)]);
|
|
293
|
+
if (!files.length) return { entries, events };
|
|
294
|
+
|
|
295
|
+
// pass 1: 每个会话的 token_count 总数(fork 前缀跳过量)
|
|
296
|
+
const meta = new Map();
|
|
297
|
+
const countsById = new Map();
|
|
298
|
+
for (const f of files) {
|
|
299
|
+
const m = codexIndexFile(f);
|
|
300
|
+
if (!m) continue;
|
|
301
|
+
meta.set(f, m);
|
|
302
|
+
if (m.sessionId) countsById.set(m.sessionId, m.tokenCounts);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// pass 2: 提取
|
|
306
|
+
for (const f of files) {
|
|
307
|
+
const m = meta.get(f);
|
|
308
|
+
if (!m) continue;
|
|
309
|
+
const skipPrefix = m.forkedFromId ? (countsById.get(m.forkedFromId) ?? 0) : 0;
|
|
310
|
+
const sessionKey = m.sessionId || f; // 同会话可能同时在 sessions/ 与 archived/,按 id 归并
|
|
311
|
+
let seenTokenCounts = 0;
|
|
312
|
+
let ctxModel = 'unknown';
|
|
313
|
+
const prevTotal = new Map();
|
|
314
|
+
|
|
315
|
+
for (const line of m.text.split('\n')) {
|
|
316
|
+
if (!line) continue;
|
|
317
|
+
let obj;
|
|
318
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
319
|
+
const inReplay = seenTokenCounts < skipPrefix;
|
|
320
|
+
const ts = obj.timestamp ? new Date(obj.timestamp) : null;
|
|
321
|
+
|
|
322
|
+
if (ts && !isNaN(ts) && !(inReplay && obj.type !== 'session_meta')) {
|
|
323
|
+
const isUser = obj.type === 'turn_context' || obj.type === 'session_meta';
|
|
324
|
+
events.push({ sessionId: sessionKey, source: 'codex', project: m.project, timestamp: ts, role: isUser ? 'user' : 'assistant' });
|
|
325
|
+
}
|
|
326
|
+
if (obj.type === 'turn_context' && obj.payload?.model) { ctxModel = obj.payload.model; continue; }
|
|
327
|
+
if (obj.type !== 'event_msg' || obj.payload?.type !== 'token_count' || !obj.payload.info) continue;
|
|
328
|
+
if (!ts || isNaN(ts)) continue;
|
|
329
|
+
|
|
330
|
+
const info = obj.payload.info;
|
|
331
|
+
const isReplayed = seenTokenCounts < skipPrefix;
|
|
332
|
+
seenTokenCounts++;
|
|
333
|
+
|
|
334
|
+
let usage = info.last_token_usage;
|
|
335
|
+
if (!usage && info.total_token_usage) {
|
|
336
|
+
const key = info.model || obj.payload.model || ctxModel || '';
|
|
337
|
+
const prev = prevTotal.get(key), curr = info.total_token_usage;
|
|
338
|
+
usage = prev ? {
|
|
339
|
+
input_tokens: (curr.input_tokens || 0) - (prev.input_tokens || 0),
|
|
340
|
+
output_tokens: (curr.output_tokens || 0) - (prev.output_tokens || 0),
|
|
341
|
+
cached_input_tokens: (curr.cached_input_tokens || 0) - (prev.cached_input_tokens || 0),
|
|
342
|
+
reasoning_output_tokens: (curr.reasoning_output_tokens || 0) - (prev.reasoning_output_tokens || 0),
|
|
343
|
+
} : curr;
|
|
344
|
+
prevTotal.set(key, { ...curr }); // replay 也要推进基线,fork 后首个差分才正确
|
|
345
|
+
}
|
|
346
|
+
if (!usage || isReplayed) continue;
|
|
347
|
+
|
|
348
|
+
const cached = usage.cached_input_tokens || usage.cache_read_input_tokens || 0;
|
|
349
|
+
const reasoning = usage.reasoning_output_tokens || 0;
|
|
350
|
+
entries.push({
|
|
351
|
+
source: 'codex',
|
|
352
|
+
model: info.model || obj.payload.model || ctxModel || 'unknown',
|
|
353
|
+
project: m.project,
|
|
354
|
+
timestamp: ts,
|
|
355
|
+
inputTokens: Math.max(0, (usage.input_tokens || 0) - cached),
|
|
356
|
+
outputTokens: Math.max(0, (usage.output_tokens || 0) - reasoning),
|
|
357
|
+
cachedInputTokens: cached,
|
|
358
|
+
reasoningOutputTokens: reasoning,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return { entries, events };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------- 解析器: Cursor
|
|
366
|
+
// 格式事实:登录态 JWT 存在 state.vscdb 的 ItemTable['cursorAuth/accessToken'];
|
|
367
|
+
// 用量从 cursor.com 的 CSV 导出接口拉(云端账号级数据,按天粒度)。
|
|
368
|
+
// Cursor 运行中持有写锁 → 拷 WAL 三件套到临时目录再读。
|
|
369
|
+
// hostname 固定 'cursor-cloud':多台机器拉的是同一账号数据,避免按主机双计。
|
|
370
|
+
|
|
371
|
+
function cursorDbPath() {
|
|
372
|
+
const rel = join('User', 'globalStorage', 'state.vscdb');
|
|
373
|
+
const p = process.env.CURSOR_STATE_DB?.trim();
|
|
374
|
+
if (p) return existsSync(p) ? p : null;
|
|
375
|
+
const candidates = platform() === 'darwin'
|
|
376
|
+
? [join(HOME, 'Library', 'Application Support', 'Cursor', rel)]
|
|
377
|
+
: platform() === 'win32'
|
|
378
|
+
? [join(process.env.APPDATA || join(HOME, 'AppData', 'Roaming'), 'Cursor', rel)]
|
|
379
|
+
: [join(process.env.XDG_CONFIG_HOME || join(HOME, '.config'), 'Cursor', rel)];
|
|
380
|
+
return candidates.find(existsSync) || null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function cursorToken(dbPath) {
|
|
384
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
385
|
+
const query = (p) => {
|
|
386
|
+
const db = new DatabaseSync(p, { readOnly: true });
|
|
387
|
+
try {
|
|
388
|
+
const row = db.prepare(`SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken' LIMIT 1`).get();
|
|
389
|
+
return typeof row?.value === 'string' ? row.value.trim() || null : null;
|
|
390
|
+
} finally { db.close(); }
|
|
391
|
+
};
|
|
392
|
+
try {
|
|
393
|
+
return query(dbPath);
|
|
394
|
+
} catch {
|
|
395
|
+
// 写锁:拷贝 db+wal+shm 到临时目录再读
|
|
396
|
+
const tmp = mkdtempSync(join(tmpdir(), 'agenttally-cursor-'));
|
|
397
|
+
try {
|
|
398
|
+
const copy = join(tmp, 'state.vscdb');
|
|
399
|
+
copyFileSync(dbPath, copy);
|
|
400
|
+
for (const sfx of ['-wal', '-shm']) if (existsSync(dbPath + sfx)) copyFileSync(dbPath + sfx, copy + sfx);
|
|
401
|
+
return query(copy);
|
|
402
|
+
} catch { return null; }
|
|
403
|
+
finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function parseCsvText(text) {
|
|
408
|
+
const rows = []; let field = '', row = [], q = false;
|
|
409
|
+
for (let i = 0; i < text.length; i++) {
|
|
410
|
+
const c = text[i];
|
|
411
|
+
if (q) {
|
|
412
|
+
if (c === '"') { if (text[i + 1] === '"') { field += '"'; i++; } else q = false; }
|
|
413
|
+
else field += c;
|
|
414
|
+
} else if (c === '"') q = true;
|
|
415
|
+
else if (c === ',') { row.push(field); field = ''; }
|
|
416
|
+
else if (c === '\n') { row.push(field); rows.push(row); field = ''; row = []; }
|
|
417
|
+
else if (c !== '\r') field += c;
|
|
418
|
+
}
|
|
419
|
+
if (field !== '' || row.length) { row.push(field); rows.push(row); }
|
|
420
|
+
return rows;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function parseCursor() {
|
|
424
|
+
const out = { entries: [], events: [] };
|
|
425
|
+
const dbPath = cursorDbPath();
|
|
426
|
+
if (!dbPath) return out;
|
|
427
|
+
const token = await cursorToken(dbPath);
|
|
428
|
+
if (!token) return out;
|
|
429
|
+
|
|
430
|
+
// JWT sub 用于 cookie 变体(某些部署只认 sub::token 形式)
|
|
431
|
+
let sub = null;
|
|
432
|
+
try {
|
|
433
|
+
const b64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
434
|
+
sub = JSON.parse(Buffer.from(b64.padEnd(Math.ceil(b64.length / 4) * 4, '='), 'base64')).sub || null;
|
|
435
|
+
} catch {}
|
|
436
|
+
|
|
437
|
+
const url = 'https://cursor.com/api/dashboard/export-usage-events-csv?strategy=tokens';
|
|
438
|
+
const attempts = [
|
|
439
|
+
{ Authorization: `Bearer ${token}` },
|
|
440
|
+
{ Cookie: `WorkosCursorSessionToken=${token}` },
|
|
441
|
+
...(sub ? [{ Cookie: `WorkosCursorSessionToken=${sub}::${token}` }] : []),
|
|
442
|
+
];
|
|
443
|
+
let csv = null;
|
|
444
|
+
for (const headers of attempts) {
|
|
445
|
+
try {
|
|
446
|
+
const r = await fetch(url, { headers: { Accept: 'text/csv,*/*;q=0.8', ...headers }, signal: AbortSignal.timeout(10_000) });
|
|
447
|
+
if (r.ok) { csv = await r.text(); break; }
|
|
448
|
+
} catch { return out; } // 网络/超时:静默跳过,daemon 下别刷屏
|
|
449
|
+
}
|
|
450
|
+
if (!csv) { console.error('cursor: auth failed (re-login in Cursor app to refresh token)'); return out; }
|
|
451
|
+
|
|
452
|
+
const rows = parseCsvText(csv);
|
|
453
|
+
if (rows.length < 2) return out;
|
|
454
|
+
const header = rows[0].map((h) => h.trim());
|
|
455
|
+
const col = (n) => header.indexOf(n);
|
|
456
|
+
const [iDate, iModel, iInW, iInNo, iCache, iOut] =
|
|
457
|
+
[col('Date'), col('Model'), col('Input (w/ Cache Write)'), col('Input (w/o Cache Write)'), col('Cache Read'), col('Output Tokens')];
|
|
458
|
+
if (iDate < 0 || iModel < 0) return out;
|
|
459
|
+
|
|
460
|
+
const num = (v) => { const n = Number(String(v ?? '').replace(/,/g, '').trim()); return Number.isFinite(n) && n > 0 ? Math.round(n) : 0; };
|
|
461
|
+
for (let r = 1; r < rows.length; r++) {
|
|
462
|
+
const row = rows[r];
|
|
463
|
+
const dateStr = (row[iDate] || '').trim();
|
|
464
|
+
const model = (row[iModel] || '').trim();
|
|
465
|
+
if (!dateStr || !model) continue;
|
|
466
|
+
const ts = /^\d{4}-\d{2}-\d{2}$/.test(dateStr) ? new Date(dateStr + 'T00:00:00Z') : new Date(dateStr);
|
|
467
|
+
if (isNaN(ts)) continue;
|
|
468
|
+
const input = (iInW >= 0 ? num(row[iInW]) : 0) + (iInNo >= 0 ? num(row[iInNo]) : 0);
|
|
469
|
+
const cache = iCache >= 0 ? num(row[iCache]) : 0;
|
|
470
|
+
const output = iOut >= 0 ? num(row[iOut]) : 0;
|
|
471
|
+
if (input + cache + output === 0) continue;
|
|
472
|
+
out.entries.push({
|
|
473
|
+
source: 'cursor', model, project: 'unknown', hostname: 'cursor-cloud',
|
|
474
|
+
timestamp: ts, inputTokens: input, outputTokens: output, cachedInputTokens: cache, reasoningOutputTokens: 0,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
return out;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------- 配额采集(claude statusline 捕获 + codex 会话扫描)
|
|
481
|
+
|
|
482
|
+
function quotaClaude() {
|
|
483
|
+
if (!existsSync(CAPTURE_FILE)) return { provider: 'claude_code', status: 'not_configured' };
|
|
484
|
+
const d = readJson(CAPTURE_FILE);
|
|
485
|
+
if (!d) return { provider: 'claude_code', status: 'error' };
|
|
486
|
+
const age = Math.max(0, Math.floor(Date.now() / 1000 - (d.captured_at || 0)));
|
|
487
|
+
const quotas = [];
|
|
488
|
+
for (const [key, w] of [['five_hour', d.five_hour], ['seven_day', d.seven_day]]) {
|
|
489
|
+
if (!w) continue;
|
|
490
|
+
const used = w.used_percentage ?? w.utilization;
|
|
491
|
+
if (used == null) continue;
|
|
492
|
+
quotas.push({
|
|
493
|
+
key,
|
|
494
|
+
used_percent: Math.round(used * 10) / 10,
|
|
495
|
+
resets_at: typeof w.resets_at === 'number' ? new Date(w.resets_at * 1000).toISOString() : w.resets_at || null,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
if (!quotas.length) return { provider: 'claude_code', status: 'no_data' };
|
|
499
|
+
return {
|
|
500
|
+
provider: 'claude_code', status: 'supported', source: 'claude-code-statusline',
|
|
501
|
+
source_state: age > STALE_SECONDS ? 'stale' : 'confirmed',
|
|
502
|
+
fetched_at: new Date((d.captured_at || 0) * 1000).toISOString(), quotas,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function quotaCodex() {
|
|
507
|
+
const root = process.env.CODEX_HOME ? join(process.env.CODEX_HOME, 'sessions') : join(HOME, '.codex', 'sessions');
|
|
508
|
+
if (!existsSync(root)) return { provider: 'codex', status: 'no_data' };
|
|
509
|
+
const recent = [...walkJsonl(root)]
|
|
510
|
+
.map((f) => ({ f, m: statSync(f).mtimeMs }))
|
|
511
|
+
.sort((a, b) => b.m - a.m)
|
|
512
|
+
.slice(0, 40);
|
|
513
|
+
const win = (w, fallbackMin) => {
|
|
514
|
+
if (!w || w.used_percent == null) return null;
|
|
515
|
+
let resets = null;
|
|
516
|
+
if (w.resets_at) resets = new Date(typeof w.resets_at === 'number' ? w.resets_at * 1000 : Date.parse(w.resets_at)).toISOString();
|
|
517
|
+
else if (w.resets_in_seconds != null) resets = new Date(Date.now() + w.resets_in_seconds * 1000).toISOString();
|
|
518
|
+
return { used: Math.round(w.used_percent * 10) / 10, resets, minutes: w.window_minutes ?? fallbackMin };
|
|
519
|
+
};
|
|
520
|
+
for (const { f } of recent) {
|
|
521
|
+
let lines;
|
|
522
|
+
try { lines = readFileSync(f, 'utf8').split('\n'); } catch { continue; }
|
|
523
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
524
|
+
if (!lines[i].includes('rate_limits')) continue;
|
|
525
|
+
let obj;
|
|
526
|
+
try { obj = JSON.parse(lines[i]); } catch { continue; }
|
|
527
|
+
const rl = obj?.payload?.rate_limits;
|
|
528
|
+
if (!rl) continue;
|
|
529
|
+
const ts = obj.timestamp ? Date.parse(obj.timestamp) : statSync(f).mtimeMs;
|
|
530
|
+
const quotas = [];
|
|
531
|
+
for (const w of [win(rl.primary, 300), win(rl.secondary, 10080)]) {
|
|
532
|
+
if (w) quotas.push({ key: w.minutes >= 2880 ? 'seven_day' : 'five_hour', used_percent: w.used, resets_at: w.resets });
|
|
533
|
+
}
|
|
534
|
+
if (!quotas.length) continue;
|
|
535
|
+
const age = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
|
536
|
+
return {
|
|
537
|
+
provider: 'codex', status: 'supported', source: 'codex-sessions-jsonl',
|
|
538
|
+
source_state: age > STALE_SECONDS ? 'stale' : 'confirmed',
|
|
539
|
+
fetched_at: new Date(ts).toISOString(), quotas,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return { provider: 'codex', status: 'no_data' };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ---------------------------------------------------------------- 解析器: Qwen Code(国产,gemini-cli 系)
|
|
547
|
+
// 格式事实:~/.qwen/tmp/<project_id>/chats/*.jsonl,每行 {timestamp, type:user|assistant,
|
|
548
|
+
// model, cwd, uuid, usageMetadata:{promptTokenCount, candidatesTokenCount,
|
|
549
|
+
// cachedContentTokenCount, thoughtsTokenCount}}。注意 prompt 含 cached、candidates 含
|
|
550
|
+
// thoughts,需归一为互不重叠口径。
|
|
551
|
+
|
|
552
|
+
function parseQwenCode() {
|
|
553
|
+
const entries = [], events = [];
|
|
554
|
+
const base = join(HOME, '.qwen', 'tmp');
|
|
555
|
+
if (!existsSync(base)) return { entries, events };
|
|
556
|
+
const seenUuids = new Set();
|
|
557
|
+
|
|
558
|
+
for (const projDir of readdirSync(base, { withFileTypes: true })) {
|
|
559
|
+
if (!projDir.isDirectory()) continue;
|
|
560
|
+
const chats = join(base, projDir.name, 'chats');
|
|
561
|
+
if (!existsSync(chats)) continue;
|
|
562
|
+
for (const f of readdirSync(chats)) {
|
|
563
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
564
|
+
const file = join(chats, f);
|
|
565
|
+
let text;
|
|
566
|
+
try { text = readFileSync(file, 'utf8'); } catch { continue; }
|
|
567
|
+
for (const line of text.split('\n')) {
|
|
568
|
+
if (!line.trim()) continue;
|
|
569
|
+
let obj;
|
|
570
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
571
|
+
const ts = obj.timestamp ? new Date(obj.timestamp) : null;
|
|
572
|
+
if (!ts || isNaN(ts)) continue;
|
|
573
|
+
const project = obj.cwd ? (obj.cwd.split('/').filter(Boolean).pop() || projDir.name) : projDir.name;
|
|
574
|
+
|
|
575
|
+
if (obj.type === 'user' || obj.type === 'assistant') {
|
|
576
|
+
events.push({ sessionId: file, source: 'qwen-code', project, timestamp: ts, role: obj.type === 'user' ? 'user' : 'assistant' });
|
|
577
|
+
}
|
|
578
|
+
if (obj.type !== 'assistant') continue;
|
|
579
|
+
const u = obj.usageMetadata;
|
|
580
|
+
if (!u || (u.promptTokenCount == null && u.candidatesTokenCount == null)) continue;
|
|
581
|
+
if (obj.uuid) {
|
|
582
|
+
if (seenUuids.has(obj.uuid)) continue;
|
|
583
|
+
seenUuids.add(obj.uuid);
|
|
584
|
+
}
|
|
585
|
+
const cached = u.cachedContentTokenCount || 0;
|
|
586
|
+
const thoughts = u.thoughtsTokenCount || 0;
|
|
587
|
+
entries.push({
|
|
588
|
+
source: 'qwen-code', model: obj.model || 'unknown', project, timestamp: ts,
|
|
589
|
+
inputTokens: Math.max(0, (u.promptTokenCount || 0) - cached),
|
|
590
|
+
outputTokens: Math.max(0, (u.candidatesTokenCount || 0) - thoughts),
|
|
591
|
+
cachedInputTokens: cached,
|
|
592
|
+
cacheCreationTokens: 0,
|
|
593
|
+
reasoningOutputTokens: thoughts,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return { entries, events };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ---------------------------------------------------------------- 解析器: Kimi CLI(国产,wire protocol)
|
|
602
|
+
// 格式事实:~/.kimi/sessions/<md5(workdir)>/<session-id>/wire.jsonl。
|
|
603
|
+
// 1.9 行 {timestamp(秒,float), message:{type,payload}};legacy {type,payload}。
|
|
604
|
+
// token 在 type=StatusUpdate 的 payload.token_usage:{input_other, output,
|
|
605
|
+
// input_cache_read, input_cache_creation}(增量值,按 payload.message_id 去重)。
|
|
606
|
+
// 模型名不在事件里:读 ~/.kimi/config.toml 的 default_model;
|
|
607
|
+
// 项目名:~/.kimi/kimi.json work_dirs[].path 的 md5 反查。
|
|
608
|
+
|
|
609
|
+
function parseKimiCode() {
|
|
610
|
+
const entries = [], events = [];
|
|
611
|
+
const sessionsDir = join(HOME, '.kimi', 'sessions');
|
|
612
|
+
if (!existsSync(sessionsDir)) return { entries, events };
|
|
613
|
+
|
|
614
|
+
// 项目映射 + 默认模型
|
|
615
|
+
const projectByHash = new Map();
|
|
616
|
+
const kimiJson = readJson(join(HOME, '.kimi', 'kimi.json'));
|
|
617
|
+
for (const w of kimiJson?.work_dirs || []) {
|
|
618
|
+
if (typeof w?.path === 'string' && w.path) {
|
|
619
|
+
projectByHash.set(createHash('md5').update(w.path).digest('hex'), w.path.split('/').filter(Boolean).pop() || w.path);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
let defaultModel = 'unknown';
|
|
623
|
+
try {
|
|
624
|
+
const toml = readFileSync(join(HOME, '.kimi', 'config.toml'), 'utf8');
|
|
625
|
+
defaultModel = toml.match(/^\s*default_model\s*=\s*["']([^"']+)["']/m)?.[1]
|
|
626
|
+
|| toml.match(/^\s*\[models\.(?:"([^"]+)"|([A-Za-z0-9_-]+))\]/m)?.slice(1).find(Boolean)
|
|
627
|
+
|| 'unknown';
|
|
628
|
+
} catch {}
|
|
629
|
+
|
|
630
|
+
const USER_TYPES = new Set(['TurnBegin', 'UserMessage', 'user_message', 'Input']);
|
|
631
|
+
const seenMsgIds = new Set();
|
|
632
|
+
|
|
633
|
+
for (const wd of existsSync(sessionsDir) ? readdirSync(sessionsDir, { withFileTypes: true }) : []) {
|
|
634
|
+
if (!wd.isDirectory()) continue;
|
|
635
|
+
const project = projectByHash.get(wd.name) || wd.name;
|
|
636
|
+
const wdPath = join(sessionsDir, wd.name);
|
|
637
|
+
for (const sess of readdirSync(wdPath, { withFileTypes: true })) {
|
|
638
|
+
if (!sess.isDirectory()) continue;
|
|
639
|
+
const wire = join(wdPath, sess.name, 'wire.jsonl');
|
|
640
|
+
if (!existsSync(wire)) continue;
|
|
641
|
+
let text;
|
|
642
|
+
try { text = readFileSync(wire, 'utf8'); } catch { continue; }
|
|
643
|
+
|
|
644
|
+
let model = defaultModel, lastTs = null;
|
|
645
|
+
for (const line of text.split('\n')) {
|
|
646
|
+
if (!line.trim()) continue;
|
|
647
|
+
let raw;
|
|
648
|
+
try { raw = JSON.parse(line); } catch { continue; }
|
|
649
|
+
const env2 = raw.message || raw;
|
|
650
|
+
const type = env2.type || raw.type;
|
|
651
|
+
const payload = env2.payload || raw.payload;
|
|
652
|
+
if (!payload) continue;
|
|
653
|
+
if (typeof raw.timestamp === 'number') lastTs = raw.timestamp * 1000;
|
|
654
|
+
else if (typeof payload.timestamp === 'number') lastTs = payload.timestamp * 1000;
|
|
655
|
+
if (payload.model) model = payload.model;
|
|
656
|
+
|
|
657
|
+
if (lastTs) {
|
|
658
|
+
events.push({ sessionId: wire, source: 'kimi-code', project, timestamp: new Date(lastTs), role: USER_TYPES.has(type) ? 'user' : 'assistant' });
|
|
659
|
+
}
|
|
660
|
+
if (type !== 'StatusUpdate') continue;
|
|
661
|
+
const tu = payload.token_usage;
|
|
662
|
+
if (!tu || (!tu.input_other && !tu.output)) continue;
|
|
663
|
+
if (payload.message_id) {
|
|
664
|
+
if (seenMsgIds.has(payload.message_id)) continue;
|
|
665
|
+
seenMsgIds.add(payload.message_id);
|
|
666
|
+
}
|
|
667
|
+
entries.push({
|
|
668
|
+
source: 'kimi-code', model, project,
|
|
669
|
+
timestamp: lastTs ? new Date(lastTs) : new Date(),
|
|
670
|
+
inputTokens: tu.input_other || 0,
|
|
671
|
+
outputTokens: tu.output || 0,
|
|
672
|
+
cachedInputTokens: tu.input_cache_read || 0,
|
|
673
|
+
cacheCreationTokens: tu.input_cache_creation || 0,
|
|
674
|
+
reasoningOutputTokens: 0,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return { entries, events };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Cursor 月度配额:复用 state.vscdb 的 token 调 usage-summary(实测 cookie 变体可用)
|
|
683
|
+
async function quotaCursor() {
|
|
684
|
+
const dbPath = cursorDbPath();
|
|
685
|
+
if (!dbPath) return { provider: 'cursor', status: 'not_configured' };
|
|
686
|
+
let token;
|
|
687
|
+
try { token = await cursorToken(dbPath); } catch { token = null; }
|
|
688
|
+
if (!token) return { provider: 'cursor', status: 'not_configured' };
|
|
689
|
+
|
|
690
|
+
let sub = null;
|
|
691
|
+
try {
|
|
692
|
+
const b64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
693
|
+
sub = JSON.parse(Buffer.from(b64.padEnd(Math.ceil(b64.length / 4) * 4, '='), 'base64')).sub || null;
|
|
694
|
+
} catch {}
|
|
695
|
+
|
|
696
|
+
const attempts = [
|
|
697
|
+
{ Authorization: `Bearer ${token}` },
|
|
698
|
+
...(sub ? [{ Cookie: `WorkosCursorSessionToken=${sub}::${token}` }] : []),
|
|
699
|
+
{ Cookie: `WorkosCursorSessionToken=${token}` },
|
|
700
|
+
];
|
|
701
|
+
for (const headers of attempts) {
|
|
702
|
+
try {
|
|
703
|
+
const r = await fetch('https://cursor.com/api/usage-summary', { headers, signal: AbortSignal.timeout(8000) });
|
|
704
|
+
if (!r.ok) continue;
|
|
705
|
+
const d = await r.json();
|
|
706
|
+
const plan = d.individualUsage?.plan;
|
|
707
|
+
if (!plan?.enabled) return { provider: 'cursor', status: 'no_data' };
|
|
708
|
+
return {
|
|
709
|
+
provider: 'cursor', status: 'supported', source: 'cursor.com/usage-summary',
|
|
710
|
+
source_state: 'confirmed', fetched_at: new Date().toISOString(),
|
|
711
|
+
plan: d.membershipType || null,
|
|
712
|
+
quotas: [{
|
|
713
|
+
key: 'monthly',
|
|
714
|
+
used_percent: Math.round((plan.totalPercentUsed ?? (plan.limit ? plan.used / plan.limit * 100 : 0)) * 10) / 10,
|
|
715
|
+
resets_at: d.billingCycleEnd || null,
|
|
716
|
+
}],
|
|
717
|
+
};
|
|
718
|
+
} catch { return { provider: 'cursor', status: 'no_data' }; } // 网络问题静默
|
|
719
|
+
}
|
|
720
|
+
return { provider: 'cursor', status: 'no_data' };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// ---------------------------------------------------------------- sync
|
|
724
|
+
|
|
725
|
+
async function apiPost(cfg, path, body, gzip = false) {
|
|
726
|
+
const payload = Buffer.from(JSON.stringify(body));
|
|
727
|
+
const r = await fetch(cfg.apiUrl + path, {
|
|
728
|
+
method: 'POST',
|
|
729
|
+
headers: {
|
|
730
|
+
'Content-Type': 'application/json',
|
|
731
|
+
Authorization: `Bearer ${cfg.apiKey}`,
|
|
732
|
+
...(gzip ? { 'Content-Encoding': 'gzip' } : {}),
|
|
733
|
+
},
|
|
734
|
+
body: gzip ? gzipSync(payload) : payload,
|
|
735
|
+
signal: AbortSignal.timeout(30_000),
|
|
736
|
+
});
|
|
737
|
+
const data = await r.json().catch(() => ({}));
|
|
738
|
+
if (!r.ok) throw new Error(`${path} -> ${r.status} ${JSON.stringify(data)}`);
|
|
739
|
+
return data;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const bucketKey = (b) => `${b.source}|${b.model}|${b.project}|${b.hostname}|${b.bucketStart}`;
|
|
743
|
+
const bucketHash = (b) => sha16([b.inputTokens, b.outputTokens, b.cachedInputTokens, b.cacheCreationTokens, b.reasoningOutputTokens, b.totalTokens].join(' '));
|
|
744
|
+
const sessionKey = (s) => `${s.source}|${s.sessionHash}`;
|
|
745
|
+
const sessionHashOf = (s) => sha16([s.lastMessageAt, s.durationSeconds, s.activeSeconds, s.messageCount, s.userMessageCount].join(' '));
|
|
746
|
+
|
|
747
|
+
async function cmdSync() {
|
|
748
|
+
const cfg = loadConfig();
|
|
749
|
+
if (!cfg?.apiKey || !cfg?.apiUrl) {
|
|
750
|
+
console.error('not configured — run: agenttally init');
|
|
751
|
+
process.exitCode = 1;
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const host = cfg.hostname || cleanHostname();
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
// 1. 采集
|
|
758
|
+
const results = [];
|
|
759
|
+
for (const [name, fn] of [['claude-code', parseClaudeCode], ['codex', parseCodex], ['cursor', parseCursor], ['qwen-code', parseQwenCode], ['kimi-code', parseKimiCode], ['codebuddy', parseCodeBuddy]]) {
|
|
760
|
+
try {
|
|
761
|
+
const r = await fn();
|
|
762
|
+
results.push(r);
|
|
763
|
+
console.log(` ${name.padEnd(12)} ${r.entries.length} entries · ${r.events.length} events`);
|
|
764
|
+
} catch (e) {
|
|
765
|
+
console.error(` ${name}: ${e.message}`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
const allEntries = results.flatMap((r) => r.entries);
|
|
769
|
+
const allEvents = results.flatMap((r) => r.events);
|
|
770
|
+
// 项目名永不上云(产品决策:用户全程零感知此维度)
|
|
771
|
+
for (const e of allEntries) e.project = 'private';
|
|
772
|
+
for (const e of allEvents) e.project = 'private';
|
|
773
|
+
const buckets = toBuckets(allEntries, host);
|
|
774
|
+
const sessions = toSessions(allEvents, host);
|
|
775
|
+
|
|
776
|
+
// 2. 增量 diff + 缩水保护
|
|
777
|
+
// Claude Code 默认 30 天清理 transcript;某天文件被部分删除后重算的桶会变小,
|
|
778
|
+
// 覆盖式 upsert 会把服务端完整历史覆盖成残缺值。老桶(>3 天)数值缩水视为
|
|
779
|
+
// 清理痕迹跳过上传(服务端保留完整值);近期桶/增长/全新桶正常上报。
|
|
780
|
+
// 全局口径修正需要重灌时用 AGENTTALLY_ALLOW_SHRINK=1。
|
|
781
|
+
const state = loadState();
|
|
782
|
+
const stateEntry = (v) => (typeof v === 'string' ? { h: v, t: -1 } : v || null); // 兼容旧格式
|
|
783
|
+
const allowShrink = process.env.AGENTTALLY_ALLOW_SHRINK === '1';
|
|
784
|
+
const SHRINK_AGE_MS = 3 * 24 * 3600 * 1000;
|
|
785
|
+
let shrinkSkipped = 0;
|
|
786
|
+
const newBuckets = buckets.filter((b) => {
|
|
787
|
+
const prev = stateEntry(state.buckets[bucketKey(b)]);
|
|
788
|
+
if (!prev) return true;
|
|
789
|
+
if (prev.h === bucketHash(b)) return false;
|
|
790
|
+
const grand = b.totalTokens + b.cachedInputTokens + b.cacheCreationTokens;
|
|
791
|
+
if (!allowShrink && prev.t >= 0 && grand < prev.t * 0.95 &&
|
|
792
|
+
Date.now() - new Date(b.bucketStart) > SHRINK_AGE_MS) {
|
|
793
|
+
shrinkSkipped++;
|
|
794
|
+
return false;
|
|
795
|
+
}
|
|
796
|
+
return true;
|
|
797
|
+
});
|
|
798
|
+
const newSessions = sessions.filter((s) => {
|
|
799
|
+
const prev = stateEntry(state.sessions[sessionKey(s)]);
|
|
800
|
+
return !prev || prev.h !== sessionHashOf(s);
|
|
801
|
+
});
|
|
802
|
+
console.log(` total ${buckets.length} buckets (${newBuckets.length} changed${shrinkSkipped ? `, ${shrinkSkipped} shrink-skipped` : ''}) · ${sessions.length} sessions (${newSessions.length} changed)`);
|
|
803
|
+
|
|
804
|
+
// 3. 分批上报(每批成功后即提交 state,失败下次重试)
|
|
805
|
+
let up = 0;
|
|
806
|
+
const batches = [];
|
|
807
|
+
for (let i = 0; i < newBuckets.length; i += 100) batches.push({ buckets: newBuckets.slice(i, i + 100), sessions: [] });
|
|
808
|
+
for (let i = 0; i < newSessions.length; i += 500) {
|
|
809
|
+
const slot = Math.floor(i / 500);
|
|
810
|
+
if (batches[slot]) batches[slot].sessions = newSessions.slice(i, i + 500);
|
|
811
|
+
else batches.push({ buckets: [], sessions: newSessions.slice(i, i + 500) });
|
|
812
|
+
}
|
|
813
|
+
for (const batch of batches) {
|
|
814
|
+
const body = { buckets: batch.buckets };
|
|
815
|
+
if (batch.sessions.length) body.sessions = batch.sessions;
|
|
816
|
+
await apiPost(cfg, '/api/usage/ingest', body, true);
|
|
817
|
+
for (const b of batch.buckets) {
|
|
818
|
+
state.buckets[bucketKey(b)] = { h: bucketHash(b), t: b.totalTokens + b.cachedInputTokens + b.cacheCreationTokens };
|
|
819
|
+
}
|
|
820
|
+
for (const s of batch.sessions) state.sessions[sessionKey(s)] = { h: sessionHashOf(s), t: -1 };
|
|
821
|
+
writeJson(STATE_FILE, state);
|
|
822
|
+
up += batch.buckets.length;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// 4. state 剪枝:本地不再产出的 key 移除,防无限膨胀
|
|
826
|
+
const liveB = new Set(buckets.map(bucketKey)), liveS = new Set(sessions.map(sessionKey));
|
|
827
|
+
for (const k of Object.keys(state.buckets)) if (!liveB.has(k)) delete state.buckets[k];
|
|
828
|
+
for (const k of Object.keys(state.sessions)) if (!liveS.has(k)) delete state.sessions[k];
|
|
829
|
+
writeJson(STATE_FILE, state);
|
|
830
|
+
|
|
831
|
+
// 5. 配额上报(独立通道,失败不影响用量)
|
|
832
|
+
const providers = [quotaClaude(), quotaCodex(), await quotaCursor()].filter((p) => p.status === 'supported');
|
|
833
|
+
if (providers.length) {
|
|
834
|
+
try {
|
|
835
|
+
const r = await apiPost(cfg, '/api/rate-limits/ingest', { hostname: host, providers });
|
|
836
|
+
console.log(` quota: ${providers.map((p) => p.provider).join(', ')} → ingested ${r.ingested}`);
|
|
837
|
+
} catch (e) {
|
|
838
|
+
console.error(` quota: ${e.message}`);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
console.log(`✓ synced ${up} buckets · quota ${providers.length} providers`);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ---------------------------------------------------------------- init (device flow)
|
|
845
|
+
|
|
846
|
+
// 一行命令 onboarding:小程序生成 token → 兑换 key → statusline → 首次同步 → daemon
|
|
847
|
+
async function initWithToken(apiUrl, token) {
|
|
848
|
+
const host = cleanHostname();
|
|
849
|
+
let d;
|
|
850
|
+
try {
|
|
851
|
+
const r = await fetch(`${apiUrl}/api/usage/device/redeem`, {
|
|
852
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
853
|
+
body: JSON.stringify({ token, hostname: host, clientName: `agenttally-cli/${VERSION}` }),
|
|
854
|
+
signal: AbortSignal.timeout(15000),
|
|
855
|
+
});
|
|
856
|
+
d = await r.json();
|
|
857
|
+
} catch (e) {
|
|
858
|
+
console.error('连接服务器失败: ' + e.message);
|
|
859
|
+
process.exitCode = 1;
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
if (!d.apiKey) {
|
|
863
|
+
console.error(d.error || '绑定失败');
|
|
864
|
+
process.exitCode = 1;
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
writeJson(CONFIG_FILE, { apiKey: d.apiKey, apiUrl, hostname: host });
|
|
868
|
+
writeJson(STATE_FILE, { buckets: {}, sessions: {} });
|
|
869
|
+
console.log('✓ 1/4 设备已绑定到你的账号');
|
|
870
|
+
|
|
871
|
+
// Claude 配额需要 statusline 拦截器(链式透传,原配置备份在 settings.json.agenttally-backup)
|
|
872
|
+
if (existsSync(join(HOME, '.claude'))) {
|
|
873
|
+
try { cmdStatusline('install'); console.log('✓ 2/4 Claude 配额捕获已安装(可用 statusline uninstall 还原)'); }
|
|
874
|
+
catch (e) { console.error(' 2/4 statusline 安装失败(不影响其他功能): ' + e.message); }
|
|
875
|
+
} else {
|
|
876
|
+
console.log('- 2/4 未检测到 Claude Code,跳过配额捕获');
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
console.log(' 3/4 首次同步中…');
|
|
880
|
+
await cmdSync();
|
|
881
|
+
|
|
882
|
+
try { cmdDaemon('install'); console.log('✓ 4/4 后台同步已安装(每 30 分钟,daemon uninstall 可卸载)'); }
|
|
883
|
+
catch (e) { console.error(' 4/4 daemon 安装失败(可稍后 agenttally daemon install): ' + e.message); }
|
|
884
|
+
|
|
885
|
+
console.log('\n🎉 完成!回到小程序即可看到你的数据。');
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
async function cmdInit(args) {
|
|
889
|
+
const urlIdx = args.indexOf('--api-url');
|
|
890
|
+
const apiUrl = ((urlIdx >= 0 && args[urlIdx + 1])
|
|
891
|
+
|| process.env.AGENTTALLY_API_URL || loadConfig()?.apiUrl || DEFAULT_API_URL).replace(/\/+$/, '');
|
|
892
|
+
const tIdx = args.indexOf('--token');
|
|
893
|
+
if (tIdx >= 0 && args[tIdx + 1]) return initWithToken(apiUrl, args[tIdx + 1]);
|
|
894
|
+
const host = cleanHostname();
|
|
895
|
+
const r = await fetch(`${apiUrl}/api/usage/device/code`, {
|
|
896
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
897
|
+
body: JSON.stringify({ clientName: `agenttally-cli/${VERSION}`, hostname: host }),
|
|
898
|
+
});
|
|
899
|
+
const dev = await r.json();
|
|
900
|
+
if (!dev.deviceCode) { console.error('device/code failed: ' + JSON.stringify(dev)); process.exitCode = 1; return; }
|
|
901
|
+
|
|
902
|
+
console.log(`\n 在小程序「我的 → 绑定设备」输入验证码: ${dev.userCode}\n (或打开 ${dev.verificationUriComplete})\n 等待确认中…`);
|
|
903
|
+
|
|
904
|
+
const deadline = Date.now() + (dev.expiresIn || 900) * 1000;
|
|
905
|
+
const interval = (dev.interval || 5) * 1000;
|
|
906
|
+
while (Date.now() < deadline) {
|
|
907
|
+
await new Promise((res) => setTimeout(res, interval));
|
|
908
|
+
let poll;
|
|
909
|
+
try {
|
|
910
|
+
const pr = await fetch(`${apiUrl}/api/usage/device/poll`, {
|
|
911
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
912
|
+
body: JSON.stringify({ deviceCode: dev.deviceCode }),
|
|
913
|
+
});
|
|
914
|
+
poll = await pr.json();
|
|
915
|
+
} catch { continue; }
|
|
916
|
+
if (poll.apiKey) {
|
|
917
|
+
writeJson(CONFIG_FILE, { apiKey: poll.apiKey, apiUrl, hostname: host });
|
|
918
|
+
// 换绑后必须全量重传:旧 state 记录的是上一个账号的已传状态
|
|
919
|
+
writeJson(STATE_FILE, { buckets: {}, sessions: {} });
|
|
920
|
+
console.log('✓ 绑定成功,开始首次同步…');
|
|
921
|
+
await cmdSync();
|
|
922
|
+
console.log('\n建议安装后台同步: agenttally daemon install');
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
if (poll.error && poll.error !== 'authorization_pending') {
|
|
926
|
+
console.error('绑定失败: ' + poll.error);
|
|
927
|
+
process.exitCode = 1;
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
console.error('验证码已过期,请重新 init');
|
|
932
|
+
process.exitCode = 1;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// ---------------------------------------------------------------- daemon (launchd / systemd)
|
|
936
|
+
|
|
937
|
+
const LAUNCHD_LABEL = 'ai.agenttally.sync';
|
|
938
|
+
const launchdPlist = () => join(HOME, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
|
|
939
|
+
const systemdDir = () => join(HOME, '.config', 'systemd', 'user');
|
|
940
|
+
|
|
941
|
+
function cmdDaemon(sub) {
|
|
942
|
+
const nodeBin = process.execPath;
|
|
943
|
+
// npx 缓存会被清理,daemon 必须指向稳定副本
|
|
944
|
+
let selfPath = SELF_PATH;
|
|
945
|
+
if (sub === 'install') {
|
|
946
|
+
const stable = join(VM_DIR, 'bin', 'agenttally.mjs');
|
|
947
|
+
mkdirSync(join(VM_DIR, 'bin'), { recursive: true });
|
|
948
|
+
copyFileSync(SELF_PATH, stable);
|
|
949
|
+
selfPath = stable;
|
|
950
|
+
}
|
|
951
|
+
if (platform() === 'darwin') {
|
|
952
|
+
const plist = launchdPlist();
|
|
953
|
+
if (sub === 'install') {
|
|
954
|
+
mkdirSync(join(HOME, 'Library', 'LaunchAgents'), { recursive: true });
|
|
955
|
+
writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
|
|
956
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
957
|
+
<plist version="1.0"><dict>
|
|
958
|
+
<key>Label</key><string>${LAUNCHD_LABEL}</string>
|
|
959
|
+
<key>ProgramArguments</key><array>
|
|
960
|
+
<string>${nodeBin}</string><string>${selfPath}</string><string>sync</string>
|
|
961
|
+
</array>
|
|
962
|
+
<key>StartInterval</key><integer>1800</integer>
|
|
963
|
+
<key>RunAtLoad</key><true/>
|
|
964
|
+
<key>StandardOutPath</key><string>${join(VM_DIR, 'daemon.log')}</string>
|
|
965
|
+
<key>StandardErrorPath</key><string>${join(VM_DIR, 'daemon.log')}</string>
|
|
966
|
+
</dict></plist>\n`);
|
|
967
|
+
try { execSync(`launchctl unload ${plist} 2>/dev/null`); } catch {}
|
|
968
|
+
execSync(`launchctl load ${plist}`);
|
|
969
|
+
console.log(`✓ launchd installed (${LAUNCHD_LABEL}, every 30m)`);
|
|
970
|
+
} else if (sub === 'uninstall') {
|
|
971
|
+
try { execSync(`launchctl unload ${plist} 2>/dev/null`); } catch {}
|
|
972
|
+
if (existsSync(plist)) unlinkSync(plist);
|
|
973
|
+
console.log('✓ launchd removed');
|
|
974
|
+
} else {
|
|
975
|
+
let loaded = false;
|
|
976
|
+
try { loaded = execSync('launchctl list').toString().includes(LAUNCHD_LABEL); } catch {}
|
|
977
|
+
console.log(JSON.stringify({ platform: 'darwin', installed: existsSync(plist), loaded }, null, 2));
|
|
978
|
+
}
|
|
979
|
+
} else {
|
|
980
|
+
const svc = join(systemdDir(), 'agenttally.service');
|
|
981
|
+
const tmr = join(systemdDir(), 'agenttally.timer');
|
|
982
|
+
if (sub === 'install') {
|
|
983
|
+
mkdirSync(systemdDir(), { recursive: true });
|
|
984
|
+
writeFileSync(svc, `[Unit]\nDescription=agenttally sync\n\n[Service]\nType=oneshot\nExecStart=${nodeBin} ${selfPath} sync\n`);
|
|
985
|
+
writeFileSync(tmr, `[Unit]\nDescription=agenttally sync timer\n\n[Timer]\nOnBootSec=2min\nOnUnitActiveSec=30min\n\n[Install]\nWantedBy=timers.target\n`);
|
|
986
|
+
execSync('systemctl --user daemon-reload && systemctl --user enable --now agenttally.timer');
|
|
987
|
+
console.log('✓ systemd user timer installed (every 30m)');
|
|
988
|
+
} else if (sub === 'uninstall') {
|
|
989
|
+
try { execSync('systemctl --user disable --now agenttally.timer 2>/dev/null'); } catch {}
|
|
990
|
+
for (const f of [svc, tmr]) if (existsSync(f)) unlinkSync(f);
|
|
991
|
+
try { execSync('systemctl --user daemon-reload'); } catch {}
|
|
992
|
+
console.log('✓ systemd timer removed');
|
|
993
|
+
} else {
|
|
994
|
+
let active = '';
|
|
995
|
+
try { active = execSync('systemctl --user is-active agenttally.timer').toString().trim(); } catch {}
|
|
996
|
+
console.log(JSON.stringify({ platform: platform(), installed: existsSync(tmr), active }, null, 2));
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// ---------------------------------------------------------------- statusline 拦截器(捕获 Claude 配额,链式透传原命令)
|
|
1002
|
+
|
|
1003
|
+
const WRAPPER_BODY = `#!/bin/bash
|
|
1004
|
+
# agenttally statusline interceptor — 捕获 rate_limits 后原样转交原命令
|
|
1005
|
+
payload="$(cat)"
|
|
1006
|
+
VM_DIR="$HOME/.agenttally"
|
|
1007
|
+
OUT="$VM_DIR/claude-rate-limits.json"
|
|
1008
|
+
parsed=$(printf '%s' "$payload" | node -e '
|
|
1009
|
+
let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{
|
|
1010
|
+
try{const o=JSON.parse(s);const rl=o&&o.rate_limits;
|
|
1011
|
+
if(!rl||(rl.five_hour==null&&rl.seven_day==null))process.exit(2);
|
|
1012
|
+
process.stdout.write(JSON.stringify({five_hour:rl.five_hour??null,seven_day:rl.seven_day??null,model_id:(o.model&&o.model.id)||null,captured_at:Math.floor(Date.now()/1000)}));
|
|
1013
|
+
}catch(e){process.exit(2)}})' 2>/dev/null)
|
|
1014
|
+
if [ -n "$parsed" ]; then
|
|
1015
|
+
tmp=$(mktemp "$VM_DIR/.claude-rate-limits.XXXXXX") && printf '%s' "$parsed" > "$tmp" 2>/dev/null && mv -f "$tmp" "$OUT" 2>/dev/null
|
|
1016
|
+
fi
|
|
1017
|
+
if [ -f "$VM_DIR/statusline-original" ]; then
|
|
1018
|
+
ORIGINAL="$(cat "$VM_DIR/statusline-original")"
|
|
1019
|
+
[ -n "$ORIGINAL" ] && printf '%s' "$payload" | exec sh -c "$ORIGINAL"
|
|
1020
|
+
fi
|
|
1021
|
+
`;
|
|
1022
|
+
|
|
1023
|
+
function cmdStatusline(sub) {
|
|
1024
|
+
if (sub === 'install') {
|
|
1025
|
+
mkdirSync(VM_DIR, { recursive: true });
|
|
1026
|
+
const settings = readJson(CLAUDE_SETTINGS, {});
|
|
1027
|
+
const current = settings.statusLine?.command || '';
|
|
1028
|
+
if (current.includes('.agenttally/statusline.sh')) {
|
|
1029
|
+
writeFileSync(WRAPPER, WRAPPER_BODY); chmodSync(WRAPPER, 0o755);
|
|
1030
|
+
console.log('already installed (wrapper refreshed)');
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
writeFileSync(CLAUDE_SETTINGS + '.agenttally-backup', JSON.stringify(settings, null, 2));
|
|
1034
|
+
writeFileSync(ORIGINAL_FILE, current);
|
|
1035
|
+
writeFileSync(WRAPPER, WRAPPER_BODY); chmodSync(WRAPPER, 0o755);
|
|
1036
|
+
settings.statusLine = { type: 'command', command: `bash "${WRAPPER}"`, padding: settings.statusLine?.padding ?? 0 };
|
|
1037
|
+
writeJson(CLAUDE_SETTINGS, settings);
|
|
1038
|
+
console.log(`✓ installed (original chained: ${current || 'none'})`);
|
|
1039
|
+
} else if (sub === 'uninstall') {
|
|
1040
|
+
const settings = readJson(CLAUDE_SETTINGS, {});
|
|
1041
|
+
if (!(settings.statusLine?.command || '').includes('.agenttally/statusline.sh')) return console.log('not installed');
|
|
1042
|
+
const original = existsSync(ORIGINAL_FILE) ? readFileSync(ORIGINAL_FILE, 'utf8') : '';
|
|
1043
|
+
if (original) settings.statusLine = { type: 'command', command: original, padding: settings.statusLine?.padding ?? 0 };
|
|
1044
|
+
else delete settings.statusLine;
|
|
1045
|
+
writeJson(CLAUDE_SETTINGS, settings);
|
|
1046
|
+
console.log(`✓ uninstalled (restored: ${original || 'none'})`);
|
|
1047
|
+
} else {
|
|
1048
|
+
const settings = readJson(CLAUDE_SETTINGS, {});
|
|
1049
|
+
const capture = readJson(CAPTURE_FILE);
|
|
1050
|
+
console.log(JSON.stringify({
|
|
1051
|
+
installed: (settings.statusLine?.command || '').includes('.agenttally/statusline.sh'),
|
|
1052
|
+
capture_age_seconds: capture ? Math.floor(Date.now() / 1000 - capture.captured_at) : null,
|
|
1053
|
+
}, null, 2));
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// ---------------------------------------------------------------- update(自托管更新通道)
|
|
1058
|
+
|
|
1059
|
+
async function cmdUpdate() {
|
|
1060
|
+
const cfg = loadConfig();
|
|
1061
|
+
if (!cfg?.apiUrl) { console.error('not configured — run: agenttally init'); process.exitCode = 1; return; }
|
|
1062
|
+
const r = await fetch(`${cfg.apiUrl}/cli/version`);
|
|
1063
|
+
const { version } = await r.json();
|
|
1064
|
+
if (version === VERSION) { console.log(`already latest (${VERSION})`); return; }
|
|
1065
|
+
const code = await (await fetch(`${cfg.apiUrl}/cli/agenttally.mjs`)).text();
|
|
1066
|
+
if (!code.includes('agenttally')) { console.error('downloaded file looks wrong, abort'); process.exitCode = 1; return; }
|
|
1067
|
+
// 必须同时更新所有副本:npx 场景下 SELF_PATH 是 npx 缓存,
|
|
1068
|
+
// daemon 跑的是 ~/.agenttally/bin 稳定副本——只写其一另一边会永久滞留旧版
|
|
1069
|
+
const stable = join(VM_DIR, 'bin', 'agenttally.mjs');
|
|
1070
|
+
const targets = [...new Set([SELF_PATH, ...(existsSync(stable) ? [stable] : [])])];
|
|
1071
|
+
let ok = 0;
|
|
1072
|
+
for (const t of targets) {
|
|
1073
|
+
try {
|
|
1074
|
+
writeFileSync(t + '.new', code);
|
|
1075
|
+
renameSync(t + '.new', t);
|
|
1076
|
+
ok++;
|
|
1077
|
+
} catch (e) {
|
|
1078
|
+
console.error(` 跳过 ${t}: ${e.message}`); // npx 缓存只读等情况不致命
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
console.log(`✓ updated ${VERSION} → ${version} (${ok}/${targets.length} 副本)`);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// ---------------------------------------------------------------- status
|
|
1085
|
+
|
|
1086
|
+
async function cmdStatus() {
|
|
1087
|
+
const cfg = loadConfig();
|
|
1088
|
+
const state = loadState();
|
|
1089
|
+
const tools = {
|
|
1090
|
+
'claude-code': claudeRoots().some((r) => existsSync(join(r, 'projects'))),
|
|
1091
|
+
codex: CODEX_DIRS.some(existsSync),
|
|
1092
|
+
cursor: !!cursorDbPath(),
|
|
1093
|
+
'qwen-code': existsSync(join(HOME, '.qwen', 'tmp')),
|
|
1094
|
+
'kimi-code': existsSync(join(HOME, '.kimi', 'sessions')),
|
|
1095
|
+
codebuddy: codebuddyRoots().some((r) => existsSync(join(r, 'projects'))),
|
|
1096
|
+
};
|
|
1097
|
+
console.log(JSON.stringify({
|
|
1098
|
+
version: VERSION,
|
|
1099
|
+
configured: !!cfg?.apiKey,
|
|
1100
|
+
apiUrl: cfg?.apiUrl || null,
|
|
1101
|
+
hostname: cfg?.hostname || cleanHostname(),
|
|
1102
|
+
tools,
|
|
1103
|
+
state: { buckets: Object.keys(state.buckets).length, sessions: Object.keys(state.sessions).length },
|
|
1104
|
+
quota: [quotaClaude(), quotaCodex(), await quotaCursor()].map((p) => `${p.provider}:${p.status}`),
|
|
1105
|
+
}, null, 2));
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// ---------------------------------------------------------------- main
|
|
1109
|
+
|
|
1110
|
+
async function cmdScan() {
|
|
1111
|
+
for (const [name, fn] of [['claude-code', parseClaudeCode], ['codex', parseCodex], ['cursor', parseCursor], ['qwen-code', parseQwenCode], ['kimi-code', parseKimiCode], ['codebuddy', parseCodeBuddy]]) {
|
|
1112
|
+
try {
|
|
1113
|
+
const r = await fn();
|
|
1114
|
+
const tok = r.entries.reduce((a, e) => a + (e.inputTokens || 0) + (e.outputTokens || 0) + (e.cachedInputTokens || 0) + (e.cacheCreationTokens || 0) + (e.reasoningOutputTokens || 0), 0);
|
|
1115
|
+
console.log(` ${name.padEnd(12)} ${String(r.entries.length).padStart(6)} entries ${(tok / 1e6).toFixed(1).padStart(9)}M tokens`);
|
|
1116
|
+
} catch (e) {
|
|
1117
|
+
console.log(` ${name.padEnd(12)} error: ${e.message}`);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
1123
|
+
try {
|
|
1124
|
+
if (cmd === 'init') await cmdInit(rest);
|
|
1125
|
+
else if (cmd === 'sync' || cmd === 'report') await cmdSync();
|
|
1126
|
+
else if (cmd === 'scan') await cmdScan();
|
|
1127
|
+
else if (cmd === 'status') await cmdStatus();
|
|
1128
|
+
else if (cmd === 'daemon') cmdDaemon(rest[0] || 'status');
|
|
1129
|
+
else if (cmd === 'statusline') cmdStatusline(rest[0] || 'status');
|
|
1130
|
+
else if (cmd === 'update') await cmdUpdate();
|
|
1131
|
+
else if (cmd === 'version' || cmd === '--version') console.log(VERSION);
|
|
1132
|
+
else {
|
|
1133
|
+
console.log(`agenttally ${VERSION} — AI coding 用量+配额追踪
|
|
1134
|
+
init --api-url <url> 绑定设备(小程序输入验证码)
|
|
1135
|
+
sync 采集并上报一次
|
|
1136
|
+
scan 只采集不上报,查看本机检测结果
|
|
1137
|
+
status 显示配置与检测到的工具
|
|
1138
|
+
daemon install 安装后台同步(30 分钟,launchd/systemd)
|
|
1139
|
+
statusline install 捕获 Claude Code 配额(链式透传原 statusline)
|
|
1140
|
+
update 自更新`);
|
|
1141
|
+
}
|
|
1142
|
+
} catch (e) {
|
|
1143
|
+
console.error('error: ' + e.message);
|
|
1144
|
+
process.exitCode = 1;
|
|
1145
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agenttally",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "AI coding 用量与配额追踪 CLI —— 单文件零依赖,采集 Claude Code / Codex / Cursor 本地用量,手机小程序随时查看配额",
|
|
5
|
+
"bin": {
|
|
6
|
+
"agenttally": "./agenttally.mjs"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"agenttally.mjs",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20.0.0"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"ai",
|
|
18
|
+
"claude-code",
|
|
19
|
+
"codex",
|
|
20
|
+
"cursor",
|
|
21
|
+
"token",
|
|
22
|
+
"usage",
|
|
23
|
+
"quota"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT"
|
|
26
|
+
}
|