@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 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 using low-level hooks to capture every completion event.
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 with a single command:
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; it will not create the file when missing.
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
- # Sync latest local session data
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
- - 📡 **Live Sniffer (实时嗅探)**: 实时监听 Codex CLI 管道,通过底层 Hook 捕获每一次补全事件。
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibescore/tracker",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "description": "Codex CLI token usage tracker (macOS-first, notify-driven).",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -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
- await cmdSync([]);
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
- const original = JSON.parse(fs.readFileSync(originalPath, 'utf8'));
248
- const cmd = Array.isArray(original?.notify) ? original.notify : null;
249
- if (cmd && cmd.length > 0 && !isSelfNotify(cmd)) {
250
- const args = cmd.slice(1);
251
- if (payloadArgs.length > 0) args.push(...payloadArgs);
252
- spawnDetached([cmd[0], ...args]);
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);
@@ -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)
@@ -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: ${parseResult.filesProcessed}`,
212
- `- New 30-min buckets queued: ${parseResult.bucketsQueued}`,
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
+ };
@@ -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,
@@ -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 SOURCE_SEPARATOR = '|';
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 = hourlyState.buckets[bucketKey(source, hourStart)] || hourlyState.buckets[bucketStart];
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
- if (key.includes(SOURCE_SEPARATOR)) {
220
- buckets[key] = value;
221
- continue;
222
- }
223
- buckets[bucketKey(DEFAULT_SOURCE, key)] = value;
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: 1,
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 key = bucketKey(normalizedSource, hourStart);
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
- return `${safeSource}${SOURCE_SEPARATOR}${hourStart}`;
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 idx = key.indexOf(SOURCE_SEPARATOR);
309
- if (idx <= 0) return { source: DEFAULT_SOURCE, hourStart: key };
310
- return { source: key.slice(0, idx), hourStart: key.slice(idx + 1) };
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
- parseRolloutIncremental
593
+ listClaudeProjectFiles,
594
+ parseRolloutIncremental,
595
+ parseClaudeIncremental
427
596
  };
@@ -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 SOURCE_SEPARATOR = '|';
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
- bucketMap.set(bucketKey(source, hourStart), bucket);
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}${SOURCE_SEPARATOR}${hourStart}`;
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 };