@vibescore/tracker 0.0.7 → 0.0.8
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 +22 -6
- package/README.zh-CN.md +22 -6
- package/package.json +1 -1
- package/src/commands/init.js +56 -11
- package/src/commands/status.js +8 -0
- package/src/commands/sync.js +29 -3
- package/src/commands/uninstall.js +14 -0
- package/src/lib/claude-config.js +190 -0
- package/src/lib/diagnostics.js +11 -2
- package/src/lib/rollout.js +188 -19
- package/src/lib/uploader.js +13 -4
package/README.md
CHANGED
|
@@ -13,7 +13,11 @@ _Real-time AI Analytics for Codex CLI_
|
|
|
13
13
|
|
|
14
14
|
[**English**](README.md) • [**中文说明**](README.zh-CN.md)
|
|
15
15
|
|
|
16
|
-
[**Documentation**](docs/) • [**Dashboard**](dashboard/) • [**Backend API**](BACKEND_API.md)
|
|
16
|
+
[**Documentation**](docs/) • [**Dashboard**](dashboard/) • [**Backend API**](BACKEND_API.md) • [**Dashboard API**](docs/dashboard/api.md)
|
|
17
|
+
|
|
18
|
+
<br/>
|
|
19
|
+
|
|
20
|
+
<img src="docs/screenshots/dashboard.png" width="900" alt="VibeScore Dashboard Preview"/>
|
|
17
21
|
|
|
18
22
|
</div>
|
|
19
23
|
|
|
@@ -36,39 +40,51 @@ We believe your code and thoughts are your own. VibeScore is built with strict p
|
|
|
36
40
|
|
|
37
41
|
## 🚀 Key Features
|
|
38
42
|
|
|
39
|
-
- 📡 **Live Sniffer**: Real-time interception of Codex CLI pipes
|
|
43
|
+
- 📡 **Live Sniffer & Auto-Sync**: Real-time interception of Codex CLI pipes with **automatic background synchronization**. Once initialized, your tokens are tracked and synced without any manual commands.
|
|
40
44
|
- 🧭 **Multi-source Ingestion**: Supports Codex CLI and Every Code (tagged as `source=every-code`) without modifying Every Code.
|
|
41
45
|
- 📊 **Matrix Dashboard**: A high-performance React + Vite dashboard featuring heatmaps, trend charts, and live logs.
|
|
42
46
|
- ⚡ **AI Analytics**: Deep analysis of Input/Output tokens, with dedicated tracking for Cached and Reasoning components.
|
|
43
47
|
- 🔒 **Identity Core**: Robust authentication and permission management to secure your development data.
|
|
44
48
|
|
|
49
|
+
### 🌌 Visual Preview
|
|
50
|
+
|
|
51
|
+
<img src="docs/screenshots/landing.png" width="900" alt="VibeScore Landing Preview"/>
|
|
52
|
+
|
|
45
53
|
## 🛠️ Quick Start
|
|
46
54
|
|
|
47
55
|
### Installation
|
|
48
56
|
|
|
49
|
-
Initialize your environment
|
|
57
|
+
Initialize your environment once and forget it. VibeScore handles all synchronization in the background automatically.
|
|
50
58
|
|
|
51
59
|
```bash
|
|
52
60
|
npx --yes @vibescore/tracker init
|
|
53
61
|
```
|
|
54
62
|
|
|
55
|
-
Note: If `~/.code/config.toml` exists (or `CODE_HOME`), `init` also configures Every Code `notify` automatically
|
|
63
|
+
Note: If `~/.code/config.toml` exists (or `CODE_HOME`), `init` also configures Every Code `notify` automatically. No further user intervention is required for data sync.
|
|
56
64
|
|
|
57
65
|
### Sync & Status
|
|
58
66
|
|
|
67
|
+
````bash
|
|
68
|
+
While sync happens automatically, you can manually trigger a synchronization or check status anytime:
|
|
69
|
+
|
|
59
70
|
```bash
|
|
60
|
-
#
|
|
71
|
+
# Manually sync latest local session data (Optional)
|
|
61
72
|
npx --yes @vibescore/tracker sync
|
|
62
73
|
|
|
63
74
|
# Check current link status
|
|
64
75
|
npx --yes @vibescore/tracker status
|
|
65
|
-
|
|
76
|
+
````
|
|
66
77
|
|
|
67
78
|
### Sources
|
|
68
79
|
|
|
69
80
|
- Codex CLI logs: `~/.codex/sessions/**/rollout-*.jsonl` (override with `CODEX_HOME`)
|
|
70
81
|
- Every Code logs: `~/.code/sessions/**/rollout-*.jsonl` (override with `CODE_HOME`)
|
|
71
82
|
|
|
83
|
+
## 🔧 Environment Variables
|
|
84
|
+
|
|
85
|
+
- `VIBESCORE_HTTP_TIMEOUT_MS`: CLI HTTP timeout in ms (default `20000`, `0` disables, clamped to `1000..120000`).
|
|
86
|
+
- `VITE_VIBESCORE_HTTP_TIMEOUT_MS`: Dashboard request timeout in ms (default `15000`, `0` disables, clamped to `1000..30000`).
|
|
87
|
+
|
|
72
88
|
## 🧰 Troubleshooting
|
|
73
89
|
|
|
74
90
|
### Streak shows 0 days while totals look correct
|
package/README.zh-CN.md
CHANGED
|
@@ -13,7 +13,11 @@ _Codex CLI 实时 AI 分析工具_
|
|
|
13
13
|
|
|
14
14
|
[**English**](README.md) • [**中文说明**](README.zh-CN.md)
|
|
15
15
|
|
|
16
|
-
[**文档**](docs/) • [**控制台**](dashboard/) • [**后端接口**](BACKEND_API.md)
|
|
16
|
+
[**文档**](docs/) • [**控制台**](dashboard/) • [**后端接口**](BACKEND_API.md) • [**Dashboard API**](docs/dashboard/api.md)
|
|
17
|
+
|
|
18
|
+
<br/>
|
|
19
|
+
|
|
20
|
+
<img src="docs/screenshots/dashboard.png" width="900" alt="VibeScore 控制台预览"/>
|
|
17
21
|
|
|
18
22
|
</div>
|
|
19
23
|
|
|
@@ -36,39 +40,51 @@ _Codex CLI 实时 AI 分析工具_
|
|
|
36
40
|
|
|
37
41
|
## 🚀 核心功能
|
|
38
42
|
|
|
39
|
-
- 📡
|
|
43
|
+
- 📡 **自动嗅探与同步 (Auto-Sync)**: 实时监听 Codex CLI 管道并具备**全自动后台同步**功能。初始化后,你的 Token 产出将自动追踪并同步,无需手动执行脚本。
|
|
40
44
|
- 🧭 **多来源采集**:支持 Codex CLI 与 Every Code(标记为 `source=every-code`),无需修改 Every Code 客户端。
|
|
41
45
|
- 📊 **Matrix Dashboard (矩阵控制台)**: 基于 React + Vite 的高性能仪表盘,具备热力图、趋势图与实时日志。
|
|
42
46
|
- ⚡ **AI Analytics (AI 分析)**: 深度分析 Input/Output Token,支持缓存 (Cached) 与推理 (Reasoning) 部分的分离监控。
|
|
43
47
|
- 🔒 **Identity Core (身份核心)**: 完备的身份验证与权限管理,保护你的开发数据资产。
|
|
44
48
|
|
|
49
|
+
### 🌌 视觉预览
|
|
50
|
+
|
|
51
|
+
<img src="docs/screenshots/landing.png" width="900" alt="VibeScore 落地页预览"/>
|
|
52
|
+
|
|
45
53
|
## 🛠️ 快速开始
|
|
46
54
|
|
|
47
55
|
### 安装
|
|
48
56
|
|
|
49
|
-
|
|
57
|
+
只需一次初始化,即可变身为“自动驾驶”模式。VibeScore 会在后台处理所有数据同步,你只需专注开发。
|
|
50
58
|
|
|
51
59
|
```bash
|
|
52
60
|
npx --yes @vibescore/tracker init
|
|
53
61
|
```
|
|
54
62
|
|
|
55
|
-
说明:若存在 `~/.code/config.toml`(或 `CODE_HOME`),`init` 会自动配置 Every Code 的 `notify
|
|
63
|
+
说明:若存在 `~/.code/config.toml`(或 `CODE_HOME`),`init` 会自动配置 Every Code 的 `notify`。配置完成后,数据同步完全自动化,无需后续人工干预。
|
|
56
64
|
|
|
57
65
|
### 同步与状态查看
|
|
58
66
|
|
|
67
|
+
````bash
|
|
68
|
+
虽然同步是自动完成的,但你仍可以随时手动触发同步或查看状态:
|
|
69
|
+
|
|
59
70
|
```bash
|
|
60
|
-
#
|
|
71
|
+
# 手动同步最新的本地会话数据 (可选)
|
|
61
72
|
npx --yes @vibescore/tracker sync
|
|
62
73
|
|
|
63
74
|
# 查看当前连接状态
|
|
64
75
|
npx --yes @vibescore/tracker status
|
|
65
|
-
|
|
76
|
+
````
|
|
66
77
|
|
|
67
78
|
### 日志来源
|
|
68
79
|
|
|
69
80
|
- Codex CLI 日志:`~/.codex/sessions/**/rollout-*.jsonl`(可用 `CODEX_HOME` 覆盖)
|
|
70
81
|
- Every Code 日志:`~/.code/sessions/**/rollout-*.jsonl`(可用 `CODE_HOME` 覆盖)
|
|
71
82
|
|
|
83
|
+
## 🔧 环境变量
|
|
84
|
+
|
|
85
|
+
- `VIBESCORE_HTTP_TIMEOUT_MS`:CLI 请求超时(毫秒,默认 `20000`,`0` 表示关闭,范围 `1000..120000`)。
|
|
86
|
+
- `VITE_VIBESCORE_HTTP_TIMEOUT_MS`:Dashboard 请求超时(毫秒,默认 `15000`,`0` 表示关闭,范围 `1000..30000`)。
|
|
87
|
+
|
|
72
88
|
## 🧰 常见问题
|
|
73
89
|
|
|
74
90
|
### Streak 显示 0 天但总量正常
|
package/package.json
CHANGED
package/src/commands/init.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const os = require('node:os');
|
|
2
2
|
const path = require('node:path');
|
|
3
3
|
const fs = require('node:fs/promises');
|
|
4
|
+
const fssync = require('node:fs');
|
|
5
|
+
const cp = require('node:child_process');
|
|
4
6
|
|
|
5
7
|
const { ensureDir, writeFileAtomic, readJson, writeJson, chmod600IfPossible } = require('../lib/fs');
|
|
6
8
|
const { prompt, promptHidden } = require('../lib/prompt');
|
|
@@ -10,9 +12,9 @@ const {
|
|
|
10
12
|
upsertEveryCodeNotify,
|
|
11
13
|
loadEveryCodeNotifyOriginal
|
|
12
14
|
} = require('../lib/codex-config');
|
|
15
|
+
const { upsertClaudeHook, buildClaudeHookCommand } = require('../lib/claude-config');
|
|
13
16
|
const { beginBrowserAuth } = require('../lib/browser-auth');
|
|
14
17
|
const { issueDeviceTokenWithPassword, issueDeviceTokenWithAccessToken } = require('../lib/insforge');
|
|
15
|
-
const { cmdSync } = require('./sync');
|
|
16
18
|
|
|
17
19
|
async function cmdInit(argv) {
|
|
18
20
|
const opts = parseArgs(argv);
|
|
@@ -113,6 +115,18 @@ async function cmdInit(argv) {
|
|
|
113
115
|
codeChained = await loadEveryCodeNotifyOriginal(codeNotifyOriginalPath);
|
|
114
116
|
}
|
|
115
117
|
|
|
118
|
+
const claudeDir = path.join(home, '.claude');
|
|
119
|
+
const claudeSettingsPath = path.join(claudeDir, 'settings.json');
|
|
120
|
+
const claudeDirExists = await isDir(claudeDir);
|
|
121
|
+
let claudeResult = null;
|
|
122
|
+
if (claudeDirExists) {
|
|
123
|
+
const claudeHookCommand = buildClaudeHookCommand(notifyPath);
|
|
124
|
+
claudeResult = await upsertClaudeHook({
|
|
125
|
+
settingsPath: claudeSettingsPath,
|
|
126
|
+
hookCommand: claudeHookCommand
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
116
130
|
process.stdout.write(
|
|
117
131
|
[
|
|
118
132
|
'Installed:',
|
|
@@ -132,16 +146,21 @@ async function cmdInit(argv) {
|
|
|
132
146
|
? '- Every Code notify: chained (original preserved)'
|
|
133
147
|
: '- Every Code notify: no original'
|
|
134
148
|
: null,
|
|
149
|
+
claudeDirExists
|
|
150
|
+
? claudeResult?.changed
|
|
151
|
+
? `- Claude hooks: updated (${claudeSettingsPath})`
|
|
152
|
+
: `- Claude hooks: already set (${claudeSettingsPath})`
|
|
153
|
+
: '- Claude hooks: skipped (~/.claude not found)',
|
|
135
154
|
deviceToken ? `- Device token: stored (${maskSecret(deviceToken)})` : '- Device token: not configured (set VIBESCORE_DEVICE_TOKEN and re-run init)',
|
|
136
155
|
''
|
|
137
156
|
].join('\n')
|
|
138
157
|
);
|
|
139
158
|
|
|
140
159
|
try {
|
|
141
|
-
|
|
160
|
+
spawnInitSync({ trackerBinPath, packageName: '@vibescore/tracker' });
|
|
142
161
|
} catch (err) {
|
|
143
162
|
const msg = err && err.message ? err.message : 'unknown error';
|
|
144
|
-
process.stderr.write(`Initial sync failed: ${msg}\n`);
|
|
163
|
+
process.stderr.write(`Initial sync spawn failed: ${msg}\n`);
|
|
145
164
|
}
|
|
146
165
|
}
|
|
147
166
|
|
|
@@ -241,15 +260,17 @@ try {
|
|
|
241
260
|
}
|
|
242
261
|
} catch (_) {}
|
|
243
262
|
|
|
244
|
-
// Chain the original notify if present.
|
|
263
|
+
// Chain the original notify if present (Codex/Every Code only).
|
|
245
264
|
try {
|
|
246
|
-
const originalPath = source === 'every-code' ? codeOriginalPath : codexOriginalPath;
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
265
|
+
const originalPath = source === 'every-code' ? codeOriginalPath : source === 'claude' ? null : codexOriginalPath;
|
|
266
|
+
if (originalPath) {
|
|
267
|
+
const original = JSON.parse(fs.readFileSync(originalPath, 'utf8'));
|
|
268
|
+
const cmd = Array.isArray(original?.notify) ? original.notify : null;
|
|
269
|
+
if (cmd && cmd.length > 0 && !isSelfNotify(cmd)) {
|
|
270
|
+
const args = cmd.slice(1);
|
|
271
|
+
if (payloadArgs.length > 0) args.push(...payloadArgs);
|
|
272
|
+
spawnDetached([cmd[0], ...args]);
|
|
273
|
+
}
|
|
253
274
|
}
|
|
254
275
|
} catch (_) {}
|
|
255
276
|
|
|
@@ -324,6 +345,15 @@ async function isFile(p) {
|
|
|
324
345
|
}
|
|
325
346
|
}
|
|
326
347
|
|
|
348
|
+
async function isDir(p) {
|
|
349
|
+
try {
|
|
350
|
+
const st = await fs.stat(p);
|
|
351
|
+
return st.isDirectory();
|
|
352
|
+
} catch (_e) {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
327
357
|
async function installLocalTrackerApp({ appDir }) {
|
|
328
358
|
// Copy the current package's runtime (bin + src) into ~/.vibescore so notify can run sync without npx.
|
|
329
359
|
const packageRoot = path.resolve(__dirname, '../..');
|
|
@@ -345,6 +375,21 @@ async function installLocalTrackerApp({ appDir }) {
|
|
|
345
375
|
await copyRuntimeDependencies({ from: nodeModulesFrom, to: nodeModulesTo });
|
|
346
376
|
}
|
|
347
377
|
|
|
378
|
+
function spawnInitSync({ trackerBinPath, packageName }) {
|
|
379
|
+
const fallbackPkg = packageName || '@vibescore/tracker';
|
|
380
|
+
const argv = ['sync'];
|
|
381
|
+
const hasLocalRuntime = typeof trackerBinPath === 'string' && fssync.existsSync(trackerBinPath);
|
|
382
|
+
const cmd = hasLocalRuntime
|
|
383
|
+
? [process.execPath, trackerBinPath, ...argv]
|
|
384
|
+
: ['npx', '--yes', fallbackPkg, ...argv];
|
|
385
|
+
const child = cp.spawn(cmd[0], cmd.slice(1), {
|
|
386
|
+
detached: true,
|
|
387
|
+
stdio: 'ignore',
|
|
388
|
+
env: process.env
|
|
389
|
+
});
|
|
390
|
+
child.unref();
|
|
391
|
+
}
|
|
392
|
+
|
|
348
393
|
async function copyRuntimeDependencies({ from, to }) {
|
|
349
394
|
try {
|
|
350
395
|
const st = await fs.stat(from);
|
package/src/commands/status.js
CHANGED
|
@@ -4,6 +4,7 @@ const fs = require('node:fs/promises');
|
|
|
4
4
|
|
|
5
5
|
const { readJson } = require('../lib/fs');
|
|
6
6
|
const { readCodexNotify, readEveryCodeNotify } = require('../lib/codex-config');
|
|
7
|
+
const { isClaudeHookConfigured, buildClaudeHookCommand } = require('../lib/claude-config');
|
|
7
8
|
const { normalizeState: normalizeUploadState } = require('../lib/upload-throttle');
|
|
8
9
|
const { collectTrackerDiagnostics } = require('../lib/diagnostics');
|
|
9
10
|
|
|
@@ -29,6 +30,8 @@ async function cmdStatus(argv = []) {
|
|
|
29
30
|
const codexConfigPath = path.join(codexHome, 'config.toml');
|
|
30
31
|
const codeHome = process.env.CODE_HOME || path.join(home, '.code');
|
|
31
32
|
const codeConfigPath = path.join(codeHome, 'config.toml');
|
|
33
|
+
const claudeSettingsPath = path.join(home, '.claude', 'settings.json');
|
|
34
|
+
const claudeHookCommand = buildClaudeHookCommand(path.join(home, '.vibescore', 'bin', 'notify.cjs'));
|
|
32
35
|
|
|
33
36
|
const config = await readJson(configPath);
|
|
34
37
|
const cursors = await readJson(cursorsPath);
|
|
@@ -46,6 +49,10 @@ async function cmdStatus(argv = []) {
|
|
|
46
49
|
const notifyConfigured = Array.isArray(codexNotify) && codexNotify.length > 0;
|
|
47
50
|
const everyCodeNotify = await readEveryCodeNotify(codeConfigPath);
|
|
48
51
|
const everyCodeConfigured = Array.isArray(everyCodeNotify) && everyCodeNotify.length > 0;
|
|
52
|
+
const claudeHookConfigured = await isClaudeHookConfigured({
|
|
53
|
+
settingsPath: claudeSettingsPath,
|
|
54
|
+
hookCommand: claudeHookCommand
|
|
55
|
+
});
|
|
49
56
|
|
|
50
57
|
const lastUpload = uploadThrottle.lastSuccessMs
|
|
51
58
|
? parseEpochMsToIso(uploadThrottle.lastSuccessMs)
|
|
@@ -80,6 +87,7 @@ async function cmdStatus(argv = []) {
|
|
|
80
87
|
autoRetryLine,
|
|
81
88
|
`- Codex notify: ${notifyConfigured ? JSON.stringify(codexNotify) : 'unset'}`,
|
|
82
89
|
`- Every Code notify: ${everyCodeConfigured ? JSON.stringify(everyCodeNotify) : 'unset'}`,
|
|
90
|
+
`- Claude hooks: ${claudeHookConfigured ? 'set' : 'unset'}`,
|
|
83
91
|
''
|
|
84
92
|
]
|
|
85
93
|
.filter(Boolean)
|
package/src/commands/sync.js
CHANGED
|
@@ -4,7 +4,7 @@ const fs = require('node:fs/promises');
|
|
|
4
4
|
const cp = require('node:child_process');
|
|
5
5
|
|
|
6
6
|
const { ensureDir, readJson, writeJson, openLock } = require('../lib/fs');
|
|
7
|
-
const { listRolloutFiles, parseRolloutIncremental } = require('../lib/rollout');
|
|
7
|
+
const { listRolloutFiles, listClaudeProjectFiles, parseRolloutIncremental, parseClaudeIncremental } = require('../lib/rollout');
|
|
8
8
|
const { drainQueueToCloud } = require('../lib/uploader');
|
|
9
9
|
const { createProgress, renderBar, formatNumber, formatBytes } = require('../lib/progress');
|
|
10
10
|
const { syncHeartbeat } = require('../lib/vibescore-api');
|
|
@@ -44,6 +44,7 @@ async function cmdSync(argv) {
|
|
|
44
44
|
|
|
45
45
|
const codexHome = process.env.CODEX_HOME || path.join(home, '.codex');
|
|
46
46
|
const codeHome = process.env.CODE_HOME || path.join(home, '.code');
|
|
47
|
+
const claudeProjectsDir = path.join(home, '.claude', 'projects');
|
|
47
48
|
|
|
48
49
|
const sources = [
|
|
49
50
|
{ source: 'codex', sessionsDir: path.join(codexHome, 'sessions') },
|
|
@@ -80,6 +81,29 @@ async function cmdSync(argv) {
|
|
|
80
81
|
}
|
|
81
82
|
});
|
|
82
83
|
|
|
84
|
+
const claudeFiles = await listClaudeProjectFiles(claudeProjectsDir);
|
|
85
|
+
let claudeResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
|
|
86
|
+
if (claudeFiles.length > 0) {
|
|
87
|
+
if (progress?.enabled) {
|
|
88
|
+
progress.start(`Parsing Claude ${renderBar(0)} 0/${formatNumber(claudeFiles.length)} files | buckets 0`);
|
|
89
|
+
}
|
|
90
|
+
claudeResult = await parseClaudeIncremental({
|
|
91
|
+
projectFiles: claudeFiles,
|
|
92
|
+
cursors,
|
|
93
|
+
queuePath,
|
|
94
|
+
onProgress: (p) => {
|
|
95
|
+
if (!progress?.enabled) return;
|
|
96
|
+
const pct = p.total > 0 ? p.index / p.total : 1;
|
|
97
|
+
progress.update(
|
|
98
|
+
`Parsing Claude ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} files | buckets ${formatNumber(
|
|
99
|
+
p.bucketsQueued
|
|
100
|
+
)}`
|
|
101
|
+
);
|
|
102
|
+
},
|
|
103
|
+
source: 'claude'
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
83
107
|
cursors.updatedAt = new Date().toISOString();
|
|
84
108
|
await writeJson(cursorsPath, cursors);
|
|
85
109
|
|
|
@@ -205,11 +229,13 @@ async function cmdSync(argv) {
|
|
|
205
229
|
});
|
|
206
230
|
|
|
207
231
|
if (!opts.auto) {
|
|
232
|
+
const totalParsed = parseResult.filesProcessed + claudeResult.filesProcessed;
|
|
233
|
+
const totalBuckets = parseResult.bucketsQueued + claudeResult.bucketsQueued;
|
|
208
234
|
process.stdout.write(
|
|
209
235
|
[
|
|
210
236
|
'Sync finished:',
|
|
211
|
-
`- Parsed files: ${
|
|
212
|
-
`- New 30-min buckets queued: ${
|
|
237
|
+
`- Parsed files: ${totalParsed}`,
|
|
238
|
+
`- New 30-min buckets queued: ${totalBuckets}`,
|
|
213
239
|
deviceToken
|
|
214
240
|
? `- Uploaded: ${uploadResult.inserted} inserted, ${uploadResult.skipped} skipped`
|
|
215
241
|
: '- Uploaded: skipped (no device token)',
|
|
@@ -3,6 +3,7 @@ const path = require('node:path');
|
|
|
3
3
|
const fs = require('node:fs/promises');
|
|
4
4
|
|
|
5
5
|
const { restoreCodexNotify, restoreEveryCodeNotify } = require('../lib/codex-config');
|
|
6
|
+
const { removeClaudeHook, buildClaudeHookCommand } = require('../lib/claude-config');
|
|
6
7
|
|
|
7
8
|
async function cmdUninstall(argv) {
|
|
8
9
|
const opts = parseArgs(argv);
|
|
@@ -12,14 +13,17 @@ async function cmdUninstall(argv) {
|
|
|
12
13
|
const codexConfigPath = path.join(home, '.codex', 'config.toml');
|
|
13
14
|
const codeHome = process.env.CODE_HOME || path.join(home, '.code');
|
|
14
15
|
const codeConfigPath = path.join(codeHome, 'config.toml');
|
|
16
|
+
const claudeSettingsPath = path.join(home, '.claude', 'settings.json');
|
|
15
17
|
const notifyPath = path.join(binDir, 'notify.cjs');
|
|
16
18
|
const notifyOriginalPath = path.join(trackerDir, 'codex_notify_original.json');
|
|
17
19
|
const codeNotifyOriginalPath = path.join(trackerDir, 'code_notify_original.json');
|
|
18
20
|
const codexNotifyCmd = ['/usr/bin/env', 'node', notifyPath];
|
|
19
21
|
const codeNotifyCmd = ['/usr/bin/env', 'node', notifyPath, '--source=every-code'];
|
|
22
|
+
const claudeHookCommand = buildClaudeHookCommand(notifyPath);
|
|
20
23
|
|
|
21
24
|
const codexConfigExists = await isFile(codexConfigPath);
|
|
22
25
|
const codeConfigExists = await isFile(codeConfigPath);
|
|
26
|
+
const claudeConfigExists = await isFile(claudeSettingsPath);
|
|
23
27
|
const codexRestore = codexConfigExists
|
|
24
28
|
? await restoreCodexNotify({
|
|
25
29
|
codexConfigPath,
|
|
@@ -34,6 +38,9 @@ async function cmdUninstall(argv) {
|
|
|
34
38
|
notifyCmd: codeNotifyCmd
|
|
35
39
|
})
|
|
36
40
|
: { restored: false, skippedReason: 'config-missing' };
|
|
41
|
+
const claudeRemove = claudeConfigExists
|
|
42
|
+
? await removeClaudeHook({ settingsPath: claudeSettingsPath, hookCommand: claudeHookCommand })
|
|
43
|
+
: { removed: false, skippedReason: 'config-missing' };
|
|
37
44
|
|
|
38
45
|
// Remove installed notify handler.
|
|
39
46
|
await fs.unlink(notifyPath).catch(() => {});
|
|
@@ -62,6 +69,13 @@ async function cmdUninstall(argv) {
|
|
|
62
69
|
? '- Every Code notify: skipped (no backup; not installed)'
|
|
63
70
|
: '- Every Code notify: no change'
|
|
64
71
|
: '- Every Code notify: skipped (config.toml not found)',
|
|
72
|
+
claudeConfigExists
|
|
73
|
+
? claudeRemove?.removed
|
|
74
|
+
? `- Claude hooks removed: ${claudeSettingsPath}`
|
|
75
|
+
: claudeRemove?.skippedReason === 'hook-missing'
|
|
76
|
+
? '- Claude hooks: no change'
|
|
77
|
+
: '- Claude hooks: skipped'
|
|
78
|
+
: '- Claude hooks: skipped (settings.json not found)',
|
|
65
79
|
opts.purge ? `- Purged: ${path.join(home, '.vibescore')}` : '- Purge: skipped (use --purge)',
|
|
66
80
|
''
|
|
67
81
|
].join('\n')
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
const fs = require('node:fs/promises');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
const { ensureDir, readJson, writeJson } = require('./fs');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_EVENT = 'SessionEnd';
|
|
7
|
+
|
|
8
|
+
async function upsertClaudeHook({ settingsPath, hookCommand, event = DEFAULT_EVENT }) {
|
|
9
|
+
const existing = await readJson(settingsPath);
|
|
10
|
+
const settings = normalizeSettings(existing);
|
|
11
|
+
const hooks = normalizeHooks(settings.hooks);
|
|
12
|
+
const entries = normalizeEntries(hooks[event]);
|
|
13
|
+
|
|
14
|
+
const normalized = normalizeEntriesForCommand(entries, hookCommand);
|
|
15
|
+
if (normalized.changed) {
|
|
16
|
+
const nextHooks = { ...hooks, [event]: normalized.entries };
|
|
17
|
+
const nextSettings = { ...settings, hooks: nextHooks };
|
|
18
|
+
const backupPath = await writeClaudeSettings({ settingsPath, settings: nextSettings });
|
|
19
|
+
return { changed: true, backupPath };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (hasHook(entries, hookCommand)) {
|
|
23
|
+
return { changed: false, backupPath: null };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const nextEntries = entries.concat([{ hooks: [{ type: 'command', command: hookCommand }] }]);
|
|
27
|
+
const nextHooks = { ...hooks, [event]: nextEntries };
|
|
28
|
+
const nextSettings = { ...settings, hooks: nextHooks };
|
|
29
|
+
|
|
30
|
+
const backupPath = await writeClaudeSettings({ settingsPath, settings: nextSettings });
|
|
31
|
+
return { changed: true, backupPath };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function removeClaudeHook({ settingsPath, hookCommand, event = DEFAULT_EVENT }) {
|
|
35
|
+
const existing = await readJson(settingsPath);
|
|
36
|
+
if (!existing) return { removed: false, skippedReason: 'settings-missing' };
|
|
37
|
+
|
|
38
|
+
const settings = normalizeSettings(existing);
|
|
39
|
+
const hooks = normalizeHooks(settings.hooks);
|
|
40
|
+
const entries = normalizeEntries(hooks[event]);
|
|
41
|
+
if (entries.length === 0) return { removed: false, skippedReason: 'hook-missing' };
|
|
42
|
+
|
|
43
|
+
let removed = false;
|
|
44
|
+
const nextEntries = [];
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
const res = stripHookFromEntry(entry, hookCommand);
|
|
47
|
+
if (res.removed) removed = true;
|
|
48
|
+
if (res.entry) nextEntries.push(res.entry);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!removed) return { removed: false, skippedReason: 'hook-missing' };
|
|
52
|
+
|
|
53
|
+
const nextHooks = { ...hooks };
|
|
54
|
+
if (nextEntries.length > 0) nextHooks[event] = nextEntries;
|
|
55
|
+
else delete nextHooks[event];
|
|
56
|
+
|
|
57
|
+
const nextSettings = { ...settings };
|
|
58
|
+
if (Object.keys(nextHooks).length > 0) nextSettings.hooks = nextHooks;
|
|
59
|
+
else delete nextSettings.hooks;
|
|
60
|
+
|
|
61
|
+
const backupPath = await writeClaudeSettings({ settingsPath, settings: nextSettings });
|
|
62
|
+
return { removed: true, skippedReason: null, backupPath };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function isClaudeHookConfigured({ settingsPath, hookCommand, event = DEFAULT_EVENT }) {
|
|
66
|
+
const settings = await readJson(settingsPath);
|
|
67
|
+
if (!settings || typeof settings !== 'object') return false;
|
|
68
|
+
const hooks = settings.hooks;
|
|
69
|
+
if (!hooks || typeof hooks !== 'object') return false;
|
|
70
|
+
const entries = normalizeEntries(hooks[event]);
|
|
71
|
+
return hasHook(entries, hookCommand);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildClaudeHookCommand(notifyPath) {
|
|
75
|
+
const cmd = typeof notifyPath === 'string' ? notifyPath : '';
|
|
76
|
+
return `/usr/bin/env node ${quoteArg(cmd)} --source=claude`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeSettings(raw) {
|
|
80
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeHooks(raw) {
|
|
84
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizeEntries(raw) {
|
|
88
|
+
return Array.isArray(raw) ? raw.slice() : [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeCommand(cmd) {
|
|
92
|
+
if (Array.isArray(cmd)) return cmd.map((v) => String(v)).join('\u0000');
|
|
93
|
+
if (typeof cmd === 'string') return cmd.trim();
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hasHook(entries, hookCommand) {
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
100
|
+
if (entry.command && commandsEqual(entry.command, hookCommand)) return true;
|
|
101
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
102
|
+
for (const hook of hooks) {
|
|
103
|
+
if (hook && commandsEqual(hook.command, hookCommand)) return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stripHookFromEntry(entry, hookCommand) {
|
|
110
|
+
if (!entry || typeof entry !== 'object') return { entry, removed: false };
|
|
111
|
+
|
|
112
|
+
if (entry.command) {
|
|
113
|
+
if (commandsEqual(entry.command, hookCommand)) return { entry: null, removed: true };
|
|
114
|
+
return { entry, removed: false };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : null;
|
|
118
|
+
if (!hooks) return { entry, removed: false };
|
|
119
|
+
|
|
120
|
+
const nextHooks = hooks.filter((hook) => !commandsEqual(hook?.command, hookCommand));
|
|
121
|
+
if (nextHooks.length === hooks.length) return { entry, removed: false };
|
|
122
|
+
if (nextHooks.length === 0) return { entry: null, removed: true };
|
|
123
|
+
|
|
124
|
+
return { entry: { ...entry, hooks: nextHooks }, removed: true };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeEntriesForCommand(entries, hookCommand) {
|
|
128
|
+
let changed = false;
|
|
129
|
+
const nextEntries = entries.map((entry) => {
|
|
130
|
+
if (!entry || typeof entry !== 'object') return entry;
|
|
131
|
+
if (entry.command && commandsEqual(entry.command, hookCommand)) {
|
|
132
|
+
if (entry.type !== 'command') {
|
|
133
|
+
changed = true;
|
|
134
|
+
return { ...entry, type: 'command' };
|
|
135
|
+
}
|
|
136
|
+
return entry;
|
|
137
|
+
}
|
|
138
|
+
if (!Array.isArray(entry.hooks)) return entry;
|
|
139
|
+
let hooksChanged = false;
|
|
140
|
+
const nextHooks = entry.hooks.map((hook) => {
|
|
141
|
+
if (hook && commandsEqual(hook.command, hookCommand)) {
|
|
142
|
+
if (hook.type !== 'command') {
|
|
143
|
+
hooksChanged = true;
|
|
144
|
+
return { ...hook, type: 'command' };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return hook;
|
|
148
|
+
});
|
|
149
|
+
if (!hooksChanged) return entry;
|
|
150
|
+
changed = true;
|
|
151
|
+
return { ...entry, hooks: nextHooks };
|
|
152
|
+
});
|
|
153
|
+
return { entries: nextEntries, changed };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function commandsEqual(a, b) {
|
|
157
|
+
const left = normalizeCommand(a);
|
|
158
|
+
const right = normalizeCommand(b);
|
|
159
|
+
return Boolean(left && right && left === right);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function quoteArg(value) {
|
|
163
|
+
const v = typeof value === 'string' ? value : '';
|
|
164
|
+
if (!v) return '""';
|
|
165
|
+
if (/^[A-Za-z0-9_\-./:@]+$/.test(v)) return v;
|
|
166
|
+
return `"${v.replace(/"/g, '\\"')}"`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function writeClaudeSettings({ settingsPath, settings }) {
|
|
170
|
+
await ensureDir(path.dirname(settingsPath));
|
|
171
|
+
let backupPath = null;
|
|
172
|
+
try {
|
|
173
|
+
const st = await fs.stat(settingsPath);
|
|
174
|
+
if (st && st.isFile()) {
|
|
175
|
+
backupPath = `${settingsPath}.bak.${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
176
|
+
await fs.copyFile(settingsPath, backupPath);
|
|
177
|
+
}
|
|
178
|
+
} catch (_e) {
|
|
179
|
+
// Ignore missing file.
|
|
180
|
+
}
|
|
181
|
+
await writeJson(settingsPath, settings);
|
|
182
|
+
return backupPath;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
upsertClaudeHook,
|
|
187
|
+
removeClaudeHook,
|
|
188
|
+
isClaudeHookConfigured,
|
|
189
|
+
buildClaudeHookCommand
|
|
190
|
+
};
|
package/src/lib/diagnostics.js
CHANGED
|
@@ -4,6 +4,7 @@ const fs = require('node:fs/promises');
|
|
|
4
4
|
|
|
5
5
|
const { readJson } = require('./fs');
|
|
6
6
|
const { readCodexNotify, readEveryCodeNotify } = require('./codex-config');
|
|
7
|
+
const { isClaudeHookConfigured, buildClaudeHookCommand } = require('./claude-config');
|
|
7
8
|
const { normalizeState: normalizeUploadState } = require('./upload-throttle');
|
|
8
9
|
|
|
9
10
|
async function collectTrackerDiagnostics({
|
|
@@ -22,6 +23,7 @@ async function collectTrackerDiagnostics({
|
|
|
22
23
|
const autoRetryPath = path.join(trackerDir, 'auto.retry.json');
|
|
23
24
|
const codexConfigPath = path.join(codexHome, 'config.toml');
|
|
24
25
|
const codeConfigPath = path.join(codeHome, 'config.toml');
|
|
26
|
+
const claudeConfigPath = path.join(home, '.claude', 'settings.json');
|
|
25
27
|
|
|
26
28
|
const config = await readJson(configPath);
|
|
27
29
|
const cursors = await readJson(cursorsPath);
|
|
@@ -42,6 +44,11 @@ async function collectTrackerDiagnostics({
|
|
|
42
44
|
const everyCodeNotifyRaw = await readEveryCodeNotify(codeConfigPath);
|
|
43
45
|
const everyCodeConfigured = Array.isArray(everyCodeNotifyRaw) && everyCodeNotifyRaw.length > 0;
|
|
44
46
|
const everyCodeNotify = everyCodeConfigured ? everyCodeNotifyRaw.map((v) => redactValue(v, home)) : null;
|
|
47
|
+
const claudeHookCommand = buildClaudeHookCommand(path.join(home, '.vibescore', 'bin', 'notify.cjs'));
|
|
48
|
+
const claudeHookConfigured = await isClaudeHookConfigured({
|
|
49
|
+
settingsPath: claudeConfigPath,
|
|
50
|
+
hookCommand: claudeHookCommand
|
|
51
|
+
});
|
|
45
52
|
|
|
46
53
|
const lastSuccessAt = uploadThrottle.lastSuccessMs ? new Date(uploadThrottle.lastSuccessMs).toISOString() : null;
|
|
47
54
|
const autoRetryAt = parseEpochMsToIso(autoRetry?.retryAtMs);
|
|
@@ -60,7 +67,8 @@ async function collectTrackerDiagnostics({
|
|
|
60
67
|
codex_home: redactValue(codexHome, home),
|
|
61
68
|
codex_config: redactValue(codexConfigPath, home),
|
|
62
69
|
code_home: redactValue(codeHome, home),
|
|
63
|
-
code_config: redactValue(codeConfigPath, home)
|
|
70
|
+
code_config: redactValue(codeConfigPath, home),
|
|
71
|
+
claude_config: redactValue(claudeConfigPath, home)
|
|
64
72
|
},
|
|
65
73
|
config: {
|
|
66
74
|
base_url: typeof config?.baseUrl === 'string' ? config.baseUrl : null,
|
|
@@ -84,7 +92,8 @@ async function collectTrackerDiagnostics({
|
|
|
84
92
|
codex_notify_configured: notifyConfigured,
|
|
85
93
|
codex_notify: codexNotify,
|
|
86
94
|
every_code_notify_configured: everyCodeConfigured,
|
|
87
|
-
every_code_notify: everyCodeNotify
|
|
95
|
+
every_code_notify: everyCodeNotify,
|
|
96
|
+
claude_hook_configured: claudeHookConfigured
|
|
88
97
|
},
|
|
89
98
|
upload: {
|
|
90
99
|
last_success_at: lastSuccessAt,
|
package/src/lib/rollout.js
CHANGED
|
@@ -6,7 +6,8 @@ const readline = require('node:readline');
|
|
|
6
6
|
const { ensureDir } = require('./fs');
|
|
7
7
|
|
|
8
8
|
const DEFAULT_SOURCE = 'codex';
|
|
9
|
-
const
|
|
9
|
+
const DEFAULT_MODEL = 'unknown';
|
|
10
|
+
const BUCKET_SEPARATOR = '|';
|
|
10
11
|
|
|
11
12
|
async function listRolloutFiles(sessionsDir) {
|
|
12
13
|
const out = [];
|
|
@@ -36,6 +37,13 @@ async function listRolloutFiles(sessionsDir) {
|
|
|
36
37
|
return out;
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
async function listClaudeProjectFiles(projectsDir) {
|
|
41
|
+
const out = [];
|
|
42
|
+
await walkClaudeProjects(projectsDir, out);
|
|
43
|
+
out.sort((a, b) => a.localeCompare(b));
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
async function parseRolloutIncremental({ rolloutFiles, cursors, queuePath, onProgress, source }) {
|
|
40
48
|
await ensureDir(path.dirname(queuePath));
|
|
41
49
|
let filesProcessed = 0;
|
|
@@ -107,6 +115,72 @@ async function parseRolloutIncremental({ rolloutFiles, cursors, queuePath, onPro
|
|
|
107
115
|
return { filesProcessed, eventsAggregated, bucketsQueued };
|
|
108
116
|
}
|
|
109
117
|
|
|
118
|
+
async function parseClaudeIncremental({ projectFiles, cursors, queuePath, onProgress, source }) {
|
|
119
|
+
await ensureDir(path.dirname(queuePath));
|
|
120
|
+
let filesProcessed = 0;
|
|
121
|
+
let eventsAggregated = 0;
|
|
122
|
+
|
|
123
|
+
const cb = typeof onProgress === 'function' ? onProgress : null;
|
|
124
|
+
const files = Array.isArray(projectFiles) ? projectFiles : [];
|
|
125
|
+
const totalFiles = files.length;
|
|
126
|
+
const hourlyState = normalizeHourlyState(cursors?.hourly);
|
|
127
|
+
const touchedBuckets = new Set();
|
|
128
|
+
const defaultSource = normalizeSourceInput(source) || 'claude';
|
|
129
|
+
|
|
130
|
+
if (!cursors.files || typeof cursors.files !== 'object') {
|
|
131
|
+
cursors.files = {};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (let idx = 0; idx < files.length; idx++) {
|
|
135
|
+
const entry = files[idx];
|
|
136
|
+
const filePath = typeof entry === 'string' ? entry : entry?.path;
|
|
137
|
+
if (!filePath) continue;
|
|
138
|
+
const fileSource =
|
|
139
|
+
typeof entry === 'string' ? defaultSource : normalizeSourceInput(entry?.source) || defaultSource;
|
|
140
|
+
const st = await fs.stat(filePath).catch(() => null);
|
|
141
|
+
if (!st || !st.isFile()) continue;
|
|
142
|
+
|
|
143
|
+
const key = filePath;
|
|
144
|
+
const prev = cursors.files[key] || null;
|
|
145
|
+
const inode = st.ino || 0;
|
|
146
|
+
const startOffset = prev && prev.inode === inode ? prev.offset || 0 : 0;
|
|
147
|
+
|
|
148
|
+
const result = await parseClaudeFile({
|
|
149
|
+
filePath,
|
|
150
|
+
startOffset,
|
|
151
|
+
hourlyState,
|
|
152
|
+
touchedBuckets,
|
|
153
|
+
source: fileSource
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
cursors.files[key] = {
|
|
157
|
+
inode,
|
|
158
|
+
offset: result.endOffset,
|
|
159
|
+
updatedAt: new Date().toISOString()
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
filesProcessed += 1;
|
|
163
|
+
eventsAggregated += result.eventsAggregated;
|
|
164
|
+
|
|
165
|
+
if (cb) {
|
|
166
|
+
cb({
|
|
167
|
+
index: idx + 1,
|
|
168
|
+
total: totalFiles,
|
|
169
|
+
filePath,
|
|
170
|
+
filesProcessed,
|
|
171
|
+
eventsAggregated,
|
|
172
|
+
bucketsQueued: touchedBuckets.size
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
|
|
178
|
+
hourlyState.updatedAt = new Date().toISOString();
|
|
179
|
+
cursors.hourly = hourlyState;
|
|
180
|
+
|
|
181
|
+
return { filesProcessed, eventsAggregated, bucketsQueued };
|
|
182
|
+
}
|
|
183
|
+
|
|
110
184
|
async function parseRolloutFile({
|
|
111
185
|
filePath,
|
|
112
186
|
startOffset,
|
|
@@ -169,15 +243,59 @@ async function parseRolloutFile({
|
|
|
169
243
|
const bucketStart = toUtcHalfHourStart(tokenTimestamp);
|
|
170
244
|
if (!bucketStart) continue;
|
|
171
245
|
|
|
172
|
-
const bucket = getHourlyBucket(hourlyState, source, bucketStart);
|
|
246
|
+
const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
|
|
173
247
|
addTotals(bucket.totals, delta);
|
|
174
|
-
touchedBuckets.add(bucketKey(source, bucketStart));
|
|
248
|
+
touchedBuckets.add(bucketKey(source, model, bucketStart));
|
|
175
249
|
eventsAggregated += 1;
|
|
176
250
|
}
|
|
177
251
|
|
|
178
252
|
return { endOffset, lastTotal: totals, lastModel: model, eventsAggregated };
|
|
179
253
|
}
|
|
180
254
|
|
|
255
|
+
async function parseClaudeFile({ filePath, startOffset, hourlyState, touchedBuckets, source }) {
|
|
256
|
+
const st = await fs.stat(filePath).catch(() => null);
|
|
257
|
+
if (!st || !st.isFile()) return { endOffset: startOffset, eventsAggregated: 0 };
|
|
258
|
+
|
|
259
|
+
const endOffset = st.size;
|
|
260
|
+
if (startOffset >= endOffset) return { endOffset, eventsAggregated: 0 };
|
|
261
|
+
|
|
262
|
+
const stream = fssync.createReadStream(filePath, { encoding: 'utf8', start: startOffset });
|
|
263
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
264
|
+
|
|
265
|
+
let eventsAggregated = 0;
|
|
266
|
+
for await (const line of rl) {
|
|
267
|
+
if (!line || !line.includes('\"usage\"')) continue;
|
|
268
|
+
let obj;
|
|
269
|
+
try {
|
|
270
|
+
obj = JSON.parse(line);
|
|
271
|
+
} catch (_e) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const usage = obj?.message?.usage || obj?.usage;
|
|
276
|
+
if (!usage || typeof usage !== 'object') continue;
|
|
277
|
+
|
|
278
|
+
const model = normalizeModelInput(obj?.message?.model || obj?.model) || DEFAULT_MODEL;
|
|
279
|
+
const tokenTimestamp = typeof obj?.timestamp === 'string' ? obj.timestamp : null;
|
|
280
|
+
if (!tokenTimestamp) continue;
|
|
281
|
+
|
|
282
|
+
const delta = normalizeClaudeUsage(usage);
|
|
283
|
+
if (!delta || isAllZeroUsage(delta)) continue;
|
|
284
|
+
|
|
285
|
+
const bucketStart = toUtcHalfHourStart(tokenTimestamp);
|
|
286
|
+
if (!bucketStart) continue;
|
|
287
|
+
|
|
288
|
+
const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
|
|
289
|
+
addTotals(bucket.totals, delta);
|
|
290
|
+
touchedBuckets.add(bucketKey(source, model, bucketStart));
|
|
291
|
+
eventsAggregated += 1;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
rl.close();
|
|
295
|
+
stream.close?.();
|
|
296
|
+
return { endOffset, eventsAggregated };
|
|
297
|
+
}
|
|
298
|
+
|
|
181
299
|
async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets }) {
|
|
182
300
|
if (!touchedBuckets || touchedBuckets.size === 0) return 0;
|
|
183
301
|
|
|
@@ -185,14 +303,17 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
|
|
|
185
303
|
for (const bucketStart of touchedBuckets) {
|
|
186
304
|
const parsedKey = parseBucketKey(bucketStart);
|
|
187
305
|
const source = parsedKey.source || DEFAULT_SOURCE;
|
|
306
|
+
const model = parsedKey.model || DEFAULT_MODEL;
|
|
188
307
|
const hourStart = parsedKey.hourStart;
|
|
189
|
-
const bucket =
|
|
308
|
+
const bucket =
|
|
309
|
+
hourlyState.buckets[bucketKey(source, model, hourStart)] || hourlyState.buckets[bucketStart];
|
|
190
310
|
if (!bucket || !bucket.totals) continue;
|
|
191
311
|
const key = totalsKey(bucket.totals);
|
|
192
312
|
if (bucket.queuedKey === key) continue;
|
|
193
313
|
toAppend.push(
|
|
194
314
|
JSON.stringify({
|
|
195
315
|
source,
|
|
316
|
+
model,
|
|
196
317
|
hour_start: hourStart,
|
|
197
318
|
input_tokens: bucket.totals.input_tokens,
|
|
198
319
|
cached_input_tokens: bucket.totals.cached_input_tokens,
|
|
@@ -213,26 +334,35 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
|
|
|
213
334
|
|
|
214
335
|
function normalizeHourlyState(raw) {
|
|
215
336
|
const state = raw && typeof raw === 'object' ? raw : {};
|
|
337
|
+
const version = Number(state.version || 1);
|
|
338
|
+
if (!Number.isFinite(version) || version < 2) {
|
|
339
|
+
return {
|
|
340
|
+
version: 2,
|
|
341
|
+
buckets: {},
|
|
342
|
+
updatedAt: null
|
|
343
|
+
};
|
|
344
|
+
}
|
|
216
345
|
const rawBuckets = state.buckets && typeof state.buckets === 'object' ? state.buckets : {};
|
|
217
346
|
const buckets = {};
|
|
218
347
|
for (const [key, value] of Object.entries(rawBuckets)) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
buckets[
|
|
348
|
+
const parsed = parseBucketKey(key);
|
|
349
|
+
const hourStart = parsed.hourStart;
|
|
350
|
+
if (!hourStart) continue;
|
|
351
|
+
const normalizedKey = bucketKey(parsed.source, parsed.model, hourStart);
|
|
352
|
+
buckets[normalizedKey] = value;
|
|
224
353
|
}
|
|
225
354
|
return {
|
|
226
|
-
version:
|
|
355
|
+
version: 2,
|
|
227
356
|
buckets,
|
|
228
357
|
updatedAt: typeof state.updatedAt === 'string' ? state.updatedAt : null
|
|
229
358
|
};
|
|
230
359
|
}
|
|
231
360
|
|
|
232
|
-
function getHourlyBucket(state, source, hourStart) {
|
|
361
|
+
function getHourlyBucket(state, source, model, hourStart) {
|
|
233
362
|
const buckets = state.buckets;
|
|
234
363
|
const normalizedSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
|
|
235
|
-
const
|
|
364
|
+
const normalizedModel = normalizeModelInput(model) || DEFAULT_MODEL;
|
|
365
|
+
const key = bucketKey(normalizedSource, normalizedModel, hourStart);
|
|
236
366
|
let bucket = buckets[key];
|
|
237
367
|
if (!bucket || typeof bucket !== 'object') {
|
|
238
368
|
bucket = { totals: initTotals(), queuedKey: null };
|
|
@@ -298,16 +428,25 @@ function toUtcHalfHourStart(ts) {
|
|
|
298
428
|
return bucketStart.toISOString();
|
|
299
429
|
}
|
|
300
430
|
|
|
301
|
-
function bucketKey(source, hourStart) {
|
|
431
|
+
function bucketKey(source, model, hourStart) {
|
|
302
432
|
const safeSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
|
|
303
|
-
|
|
433
|
+
const safeModel = normalizeModelInput(model) || DEFAULT_MODEL;
|
|
434
|
+
return `${safeSource}${BUCKET_SEPARATOR}${safeModel}${BUCKET_SEPARATOR}${hourStart}`;
|
|
304
435
|
}
|
|
305
436
|
|
|
306
437
|
function parseBucketKey(key) {
|
|
307
|
-
if (typeof key !== 'string') return { source: DEFAULT_SOURCE, hourStart: '' };
|
|
308
|
-
const
|
|
309
|
-
if (
|
|
310
|
-
|
|
438
|
+
if (typeof key !== 'string') return { source: DEFAULT_SOURCE, model: DEFAULT_MODEL, hourStart: '' };
|
|
439
|
+
const first = key.indexOf(BUCKET_SEPARATOR);
|
|
440
|
+
if (first <= 0) return { source: DEFAULT_SOURCE, model: DEFAULT_MODEL, hourStart: key };
|
|
441
|
+
const second = key.indexOf(BUCKET_SEPARATOR, first + 1);
|
|
442
|
+
if (second <= 0) {
|
|
443
|
+
return { source: key.slice(0, first), model: DEFAULT_MODEL, hourStart: key.slice(first + 1) };
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
source: key.slice(0, first),
|
|
447
|
+
model: key.slice(first + 1, second),
|
|
448
|
+
hourStart: key.slice(second + 1)
|
|
449
|
+
};
|
|
311
450
|
}
|
|
312
451
|
|
|
313
452
|
function normalizeSourceInput(value) {
|
|
@@ -316,6 +455,12 @@ function normalizeSourceInput(value) {
|
|
|
316
455
|
return trimmed.length > 0 ? trimmed : null;
|
|
317
456
|
}
|
|
318
457
|
|
|
458
|
+
function normalizeModelInput(value) {
|
|
459
|
+
if (typeof value !== 'string') return null;
|
|
460
|
+
const trimmed = value.trim();
|
|
461
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
462
|
+
}
|
|
463
|
+
|
|
319
464
|
function extractTokenCount(obj) {
|
|
320
465
|
const payload = obj?.payload;
|
|
321
466
|
if (!payload) return null;
|
|
@@ -377,6 +522,16 @@ function normalizeUsage(u) {
|
|
|
377
522
|
return out;
|
|
378
523
|
}
|
|
379
524
|
|
|
525
|
+
function normalizeClaudeUsage(u) {
|
|
526
|
+
return {
|
|
527
|
+
input_tokens: toNonNegativeInt(u?.input_tokens),
|
|
528
|
+
cached_input_tokens: toNonNegativeInt(u?.cache_read_input_tokens),
|
|
529
|
+
output_tokens: toNonNegativeInt(u?.output_tokens),
|
|
530
|
+
reasoning_output_tokens: 0,
|
|
531
|
+
total_tokens: toNonNegativeInt(u?.input_tokens) + toNonNegativeInt(u?.output_tokens)
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
380
535
|
function isNonEmptyObject(v) {
|
|
381
536
|
return Boolean(v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length > 0);
|
|
382
537
|
}
|
|
@@ -421,7 +576,21 @@ async function safeReadDir(dir) {
|
|
|
421
576
|
}
|
|
422
577
|
}
|
|
423
578
|
|
|
579
|
+
async function walkClaudeProjects(dir, out) {
|
|
580
|
+
const entries = await safeReadDir(dir);
|
|
581
|
+
for (const entry of entries) {
|
|
582
|
+
const fullPath = path.join(dir, entry.name);
|
|
583
|
+
if (entry.isDirectory()) {
|
|
584
|
+
await walkClaudeProjects(fullPath, out);
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(fullPath);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
424
591
|
module.exports = {
|
|
425
592
|
listRolloutFiles,
|
|
426
|
-
|
|
593
|
+
listClaudeProjectFiles,
|
|
594
|
+
parseRolloutIncremental,
|
|
595
|
+
parseClaudeIncremental
|
|
427
596
|
};
|
package/src/lib/uploader.js
CHANGED
|
@@ -6,7 +6,8 @@ const { ensureDir, readJson, writeJson } = require('./fs');
|
|
|
6
6
|
const { ingestHourly } = require('./vibescore-api');
|
|
7
7
|
|
|
8
8
|
const DEFAULT_SOURCE = 'codex';
|
|
9
|
-
const
|
|
9
|
+
const DEFAULT_MODEL = 'unknown';
|
|
10
|
+
const BUCKET_SEPARATOR = '|';
|
|
10
11
|
|
|
11
12
|
async function drainQueueToCloud({ baseUrl, deviceToken, queuePath, queueStatePath, maxBatches, batchSize, onProgress }) {
|
|
12
13
|
await ensureDir(require('node:path').dirname(queueStatePath));
|
|
@@ -74,8 +75,10 @@ async function readBatch(queuePath, startOffset, maxBuckets) {
|
|
|
74
75
|
const hourStart = typeof bucket?.hour_start === 'string' ? bucket.hour_start : null;
|
|
75
76
|
if (!hourStart) continue;
|
|
76
77
|
const source = normalizeSource(bucket?.source) || DEFAULT_SOURCE;
|
|
78
|
+
const model = normalizeModel(bucket?.model) || DEFAULT_MODEL;
|
|
77
79
|
bucket.source = source;
|
|
78
|
-
|
|
80
|
+
bucket.model = model;
|
|
81
|
+
bucketMap.set(bucketKey(source, model, hourStart), bucket);
|
|
79
82
|
linesRead += 1;
|
|
80
83
|
if (linesRead >= maxBuckets) break;
|
|
81
84
|
}
|
|
@@ -94,8 +97,8 @@ async function safeFileSize(p) {
|
|
|
94
97
|
}
|
|
95
98
|
}
|
|
96
99
|
|
|
97
|
-
function bucketKey(source, hourStart) {
|
|
98
|
-
return `${source}${
|
|
100
|
+
function bucketKey(source, model, hourStart) {
|
|
101
|
+
return `${source}${BUCKET_SEPARATOR}${model}${BUCKET_SEPARATOR}${hourStart}`;
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
function normalizeSource(value) {
|
|
@@ -104,4 +107,10 @@ function normalizeSource(value) {
|
|
|
104
107
|
return trimmed.length > 0 ? trimmed : null;
|
|
105
108
|
}
|
|
106
109
|
|
|
110
|
+
function normalizeModel(value) {
|
|
111
|
+
if (typeof value !== 'string') return null;
|
|
112
|
+
const trimmed = value.trim();
|
|
113
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
107
116
|
module.exports = { drainQueueToCloud };
|