@vibescore/tracker 0.2.0 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibescore/tracker",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Codex CLI token usage tracker (macOS-first, notify-driven).",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -14,6 +14,7 @@ const {
14
14
  loadEveryCodeNotifyOriginal
15
15
  } = require('../lib/codex-config');
16
16
  const { upsertClaudeHook, buildClaudeHookCommand } = require('../lib/claude-config');
17
+ const { resolveOpencodeConfigDir, upsertOpencodePlugin } = require('../lib/opencode-config');
17
18
  const { beginBrowserAuth } = require('../lib/browser-auth');
18
19
  const {
19
20
  issueDeviceTokenWithPassword,
@@ -162,6 +163,12 @@ async function cmdInit(argv) {
162
163
  });
163
164
  }
164
165
 
166
+ const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
167
+ const opencodeResult = await upsertOpencodePlugin({
168
+ configDir: opencodeConfigDir,
169
+ notifyPath
170
+ });
171
+
165
172
  process.stdout.write(
166
173
  [
167
174
  'Installed:',
@@ -186,6 +193,11 @@ async function cmdInit(argv) {
186
193
  ? `- Claude hooks: updated (${claudeSettingsPath})`
187
194
  : `- Claude hooks: already set (${claudeSettingsPath})`
188
195
  : '- Claude hooks: skipped (~/.claude not found)',
196
+ opencodeResult?.skippedReason === 'config-missing'
197
+ ? '- Opencode plugin: skipped (config dir missing)'
198
+ : opencodeResult?.changed
199
+ ? `- Opencode plugin: updated (${opencodeConfigDir})`
200
+ : `- Opencode plugin: already set (${opencodeConfigDir})`,
189
201
  deviceToken ? `- Device token: stored (${maskSecret(deviceToken)})` : '- Device token: not configured (set VIBESCORE_DEVICE_TOKEN and re-run init)',
190
202
  ''
191
203
  ].join('\n')
@@ -306,7 +318,12 @@ try {
306
318
 
307
319
  // Chain the original notify if present (Codex/Every Code only).
308
320
  try {
309
- const originalPath = source === 'every-code' ? codeOriginalPath : source === 'claude' ? null : codexOriginalPath;
321
+ const originalPath =
322
+ source === 'every-code'
323
+ ? codeOriginalPath
324
+ : source === 'claude' || source === 'opencode'
325
+ ? null
326
+ : codexOriginalPath;
310
327
  if (originalPath) {
311
328
  const original = JSON.parse(fs.readFileSync(originalPath, 'utf8'));
312
329
  const cmd = Array.isArray(original?.notify) ? original.notify : null;
@@ -421,7 +438,7 @@ async function installLocalTrackerApp({ appDir }) {
421
438
 
422
439
  function spawnInitSync({ trackerBinPath, packageName }) {
423
440
  const fallbackPkg = packageName || '@vibescore/tracker';
424
- const argv = ['sync'];
441
+ const argv = ['sync', '--drain'];
425
442
  const hasLocalRuntime = typeof trackerBinPath === 'string' && fssync.existsSync(trackerBinPath);
426
443
  const cmd = hasLocalRuntime
427
444
  ? [process.execPath, trackerBinPath, ...argv]
@@ -5,6 +5,7 @@ const fs = require('node:fs/promises');
5
5
  const { readJson } = require('../lib/fs');
6
6
  const { readCodexNotify, readEveryCodeNotify } = require('../lib/codex-config');
7
7
  const { isClaudeHookConfigured, buildClaudeHookCommand } = require('../lib/claude-config');
8
+ const { resolveOpencodeConfigDir, isOpencodePluginInstalled } = require('../lib/opencode-config');
8
9
  const { normalizeState: normalizeUploadState } = require('../lib/upload-throttle');
9
10
  const { collectTrackerDiagnostics } = require('../lib/diagnostics');
10
11
 
@@ -31,6 +32,7 @@ async function cmdStatus(argv = []) {
31
32
  const codeHome = process.env.CODE_HOME || path.join(home, '.code');
32
33
  const codeConfigPath = path.join(codeHome, 'config.toml');
33
34
  const claudeSettingsPath = path.join(home, '.claude', 'settings.json');
35
+ const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
34
36
  const claudeHookCommand = buildClaudeHookCommand(path.join(home, '.vibescore', 'bin', 'notify.cjs'));
35
37
 
36
38
  const config = await readJson(configPath);
@@ -53,6 +55,7 @@ async function cmdStatus(argv = []) {
53
55
  settingsPath: claudeSettingsPath,
54
56
  hookCommand: claudeHookCommand
55
57
  });
58
+ const opencodePluginConfigured = await isOpencodePluginInstalled({ configDir: opencodeConfigDir });
56
59
 
57
60
  const lastUpload = uploadThrottle.lastSuccessMs
58
61
  ? parseEpochMsToIso(uploadThrottle.lastSuccessMs)
@@ -88,6 +91,7 @@ async function cmdStatus(argv = []) {
88
91
  `- Codex notify: ${notifyConfigured ? JSON.stringify(codexNotify) : 'unset'}`,
89
92
  `- Every Code notify: ${everyCodeConfigured ? JSON.stringify(everyCodeNotify) : 'unset'}`,
90
93
  `- Claude hooks: ${claudeHookConfigured ? 'set' : 'unset'}`,
94
+ `- Opencode plugin: ${opencodePluginConfigured ? 'set' : 'unset'}`,
91
95
  ''
92
96
  ]
93
97
  .filter(Boolean)
@@ -8,9 +8,11 @@ const {
8
8
  listRolloutFiles,
9
9
  listClaudeProjectFiles,
10
10
  listGeminiSessionFiles,
11
+ listOpencodeMessageFiles,
11
12
  parseRolloutIncremental,
12
13
  parseClaudeIncremental,
13
- parseGeminiIncremental
14
+ parseGeminiIncremental,
15
+ parseOpencodeIncremental
14
16
  } = require('../lib/rollout');
15
17
  const { drainQueueToCloud } = require('../lib/uploader');
16
18
  const { createProgress, renderBar, formatNumber, formatBytes } = require('../lib/progress');
@@ -54,6 +56,9 @@ async function cmdSync(argv) {
54
56
  const claudeProjectsDir = path.join(home, '.claude', 'projects');
55
57
  const geminiHome = process.env.GEMINI_HOME || path.join(home, '.gemini');
56
58
  const geminiTmpDir = path.join(geminiHome, 'tmp');
59
+ const xdgDataHome = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share');
60
+ const opencodeHome = process.env.OPENCODE_HOME || path.join(xdgDataHome, 'opencode');
61
+ const opencodeStorageDir = path.join(opencodeHome, 'storage');
57
62
 
58
63
  const sources = [
59
64
  { source: 'codex', sessionsDir: path.join(codexHome, 'sessions') },
@@ -136,6 +141,29 @@ async function cmdSync(argv) {
136
141
  });
137
142
  }
138
143
 
144
+ const opencodeFiles = await listOpencodeMessageFiles(opencodeStorageDir);
145
+ let opencodeResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
146
+ if (opencodeFiles.length > 0) {
147
+ if (progress?.enabled) {
148
+ progress.start(`Parsing Opencode ${renderBar(0)} 0/${formatNumber(opencodeFiles.length)} files | buckets 0`);
149
+ }
150
+ opencodeResult = await parseOpencodeIncremental({
151
+ messageFiles: opencodeFiles,
152
+ cursors,
153
+ queuePath,
154
+ onProgress: (p) => {
155
+ if (!progress?.enabled) return;
156
+ const pct = p.total > 0 ? p.index / p.total : 1;
157
+ progress.update(
158
+ `Parsing Opencode ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(
159
+ p.total
160
+ )} files | buckets ${formatNumber(p.bucketsQueued)}`
161
+ );
162
+ },
163
+ source: 'opencode'
164
+ });
165
+ }
166
+
139
167
  cursors.updatedAt = new Date().toISOString();
140
168
  await writeJson(cursorsPath, cursors);
141
169
 
@@ -261,8 +289,16 @@ async function cmdSync(argv) {
261
289
  });
262
290
 
263
291
  if (!opts.auto) {
264
- const totalParsed = parseResult.filesProcessed + claudeResult.filesProcessed + geminiResult.filesProcessed;
265
- const totalBuckets = parseResult.bucketsQueued + claudeResult.bucketsQueued + geminiResult.bucketsQueued;
292
+ const totalParsed =
293
+ parseResult.filesProcessed +
294
+ claudeResult.filesProcessed +
295
+ geminiResult.filesProcessed +
296
+ opencodeResult.filesProcessed;
297
+ const totalBuckets =
298
+ parseResult.bucketsQueued +
299
+ claudeResult.bucketsQueued +
300
+ geminiResult.bucketsQueued +
301
+ opencodeResult.bucketsQueued;
266
302
  process.stdout.write(
267
303
  [
268
304
  'Sync finished:',
@@ -4,6 +4,7 @@ const fs = require('node:fs/promises');
4
4
 
5
5
  const { restoreCodexNotify, restoreEveryCodeNotify } = require('../lib/codex-config');
6
6
  const { removeClaudeHook, buildClaudeHookCommand } = require('../lib/claude-config');
7
+ const { resolveOpencodeConfigDir, removeOpencodePlugin } = require('../lib/opencode-config');
7
8
 
8
9
  async function cmdUninstall(argv) {
9
10
  const opts = parseArgs(argv);
@@ -14,6 +15,7 @@ async function cmdUninstall(argv) {
14
15
  const codeHome = process.env.CODE_HOME || path.join(home, '.code');
15
16
  const codeConfigPath = path.join(codeHome, 'config.toml');
16
17
  const claudeSettingsPath = path.join(home, '.claude', 'settings.json');
18
+ const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
17
19
  const notifyPath = path.join(binDir, 'notify.cjs');
18
20
  const notifyOriginalPath = path.join(trackerDir, 'codex_notify_original.json');
19
21
  const codeNotifyOriginalPath = path.join(trackerDir, 'code_notify_original.json');
@@ -24,6 +26,7 @@ async function cmdUninstall(argv) {
24
26
  const codexConfigExists = await isFile(codexConfigPath);
25
27
  const codeConfigExists = await isFile(codeConfigPath);
26
28
  const claudeConfigExists = await isFile(claudeSettingsPath);
29
+ const opencodeConfigExists = await isDir(opencodeConfigDir);
27
30
  const codexRestore = codexConfigExists
28
31
  ? await restoreCodexNotify({
29
32
  codexConfigPath,
@@ -41,6 +44,9 @@ async function cmdUninstall(argv) {
41
44
  const claudeRemove = claudeConfigExists
42
45
  ? await removeClaudeHook({ settingsPath: claudeSettingsPath, hookCommand: claudeHookCommand })
43
46
  : { removed: false, skippedReason: 'config-missing' };
47
+ const opencodeRemove = opencodeConfigExists
48
+ ? await removeOpencodePlugin({ configDir: opencodeConfigDir })
49
+ : { removed: false, skippedReason: 'config-missing' };
44
50
 
45
51
  // Remove installed notify handler.
46
52
  await fs.unlink(notifyPath).catch(() => {});
@@ -76,6 +82,15 @@ async function cmdUninstall(argv) {
76
82
  ? '- Claude hooks: no change'
77
83
  : '- Claude hooks: skipped'
78
84
  : '- Claude hooks: skipped (settings.json not found)',
85
+ opencodeConfigExists
86
+ ? opencodeRemove?.removed
87
+ ? `- Opencode plugin removed: ${opencodeConfigDir}`
88
+ : opencodeRemove?.skippedReason === 'plugin-missing'
89
+ ? '- Opencode plugin: no change'
90
+ : opencodeRemove?.skippedReason === 'unexpected-content'
91
+ ? '- Opencode plugin: skipped (unexpected content)'
92
+ : '- Opencode plugin: skipped'
93
+ : `- Opencode plugin: skipped (${opencodeConfigDir} not found)`,
79
94
  opts.purge ? `- Purged: ${path.join(home, '.vibescore')}` : '- Purge: skipped (use --purge)',
80
95
  ''
81
96
  ].join('\n')
@@ -102,3 +117,12 @@ async function isFile(p) {
102
117
  return false;
103
118
  }
104
119
  }
120
+
121
+ async function isDir(p) {
122
+ try {
123
+ const st = await fs.stat(p);
124
+ return st.isDirectory();
125
+ } catch (_e) {
126
+ return false;
127
+ }
128
+ }
@@ -5,6 +5,7 @@ const fs = require('node:fs/promises');
5
5
  const { readJson } = require('./fs');
6
6
  const { readCodexNotify, readEveryCodeNotify } = require('./codex-config');
7
7
  const { isClaudeHookConfigured, buildClaudeHookCommand } = require('./claude-config');
8
+ const { resolveOpencodeConfigDir, isOpencodePluginInstalled } = require('./opencode-config');
8
9
  const { normalizeState: normalizeUploadState } = require('./upload-throttle');
9
10
 
10
11
  async function collectTrackerDiagnostics({
@@ -24,6 +25,7 @@ async function collectTrackerDiagnostics({
24
25
  const codexConfigPath = path.join(codexHome, 'config.toml');
25
26
  const codeConfigPath = path.join(codeHome, 'config.toml');
26
27
  const claudeConfigPath = path.join(home, '.claude', 'settings.json');
28
+ const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
27
29
 
28
30
  const config = await readJson(configPath);
29
31
  const cursors = await readJson(cursorsPath);
@@ -49,6 +51,7 @@ async function collectTrackerDiagnostics({
49
51
  settingsPath: claudeConfigPath,
50
52
  hookCommand: claudeHookCommand
51
53
  });
54
+ const opencodePluginConfigured = await isOpencodePluginInstalled({ configDir: opencodeConfigDir });
52
55
 
53
56
  const lastSuccessAt = uploadThrottle.lastSuccessMs ? new Date(uploadThrottle.lastSuccessMs).toISOString() : null;
54
57
  const autoRetryAt = parseEpochMsToIso(autoRetry?.retryAtMs);
@@ -68,7 +71,8 @@ async function collectTrackerDiagnostics({
68
71
  codex_config: redactValue(codexConfigPath, home),
69
72
  code_home: redactValue(codeHome, home),
70
73
  code_config: redactValue(codeConfigPath, home),
71
- claude_config: redactValue(claudeConfigPath, home)
74
+ claude_config: redactValue(claudeConfigPath, home),
75
+ opencode_config: redactValue(opencodeConfigDir, home)
72
76
  },
73
77
  config: {
74
78
  base_url: typeof config?.baseUrl === 'string' ? config.baseUrl : null,
@@ -93,7 +97,8 @@ async function collectTrackerDiagnostics({
93
97
  codex_notify: codexNotify,
94
98
  every_code_notify_configured: everyCodeConfigured,
95
99
  every_code_notify: everyCodeNotify,
96
- claude_hook_configured: claudeHookConfigured
100
+ claude_hook_configured: claudeHookConfigured,
101
+ opencode_plugin_configured: opencodePluginConfigured
97
102
  },
98
103
  upload: {
99
104
  last_success_at: lastSuccessAt,
@@ -0,0 +1,98 @@
1
+ const os = require('node:os');
2
+ const path = require('node:path');
3
+ const fs = require('node:fs/promises');
4
+
5
+ const { ensureDir } = require('./fs');
6
+
7
+ const DEFAULT_PLUGIN_NAME = 'vibescore-tracker.js';
8
+ const PLUGIN_MARKER = 'VIBESCORE_TRACKER_PLUGIN';
9
+ const DEFAULT_EVENT = 'session.idle';
10
+
11
+ function resolveOpencodeConfigDir({ home = os.homedir(), env = process.env } = {}) {
12
+ const explicit = typeof env.OPENCODE_CONFIG_DIR === 'string' ? env.OPENCODE_CONFIG_DIR.trim() : '';
13
+ if (explicit) return path.resolve(explicit);
14
+ const xdg = typeof env.XDG_CONFIG_HOME === 'string' ? env.XDG_CONFIG_HOME.trim() : '';
15
+ const base = xdg || path.join(home, '.config');
16
+ return path.join(base, 'opencode');
17
+ }
18
+
19
+ function resolveOpencodePluginDir({ configDir }) {
20
+ return path.join(configDir, 'plugin');
21
+ }
22
+
23
+ function buildOpencodePlugin({ notifyPath }) {
24
+ const safeNotifyPath = typeof notifyPath === 'string' ? notifyPath : '';
25
+ return `// ${PLUGIN_MARKER}\n` +
26
+ `const notifyPath = ${JSON.stringify(safeNotifyPath)};\n` +
27
+ `export const VibeScorePlugin = async ({ $ }) => {\n` +
28
+ ` return {\n` +
29
+ ` event: async ({ event }) => {\n` +
30
+ ` if (!event || event.type !== ${JSON.stringify(DEFAULT_EVENT)}) return;\n` +
31
+ ` try {\n` +
32
+ ` if (!notifyPath) return;\n` +
33
+ ` const proc = $\\\`/usr/bin/env node ${'${notifyPath}'} --source=opencode\\\`;\n` +
34
+ ` if (proc && typeof proc.catch === 'function') proc.catch(() => {});\n` +
35
+ ` } catch (_) {}\n` +
36
+ ` }\n` +
37
+ ` };\n` +
38
+ `};\n`;
39
+ }
40
+
41
+ async function upsertOpencodePlugin({
42
+ configDir,
43
+ notifyPath,
44
+ pluginName = DEFAULT_PLUGIN_NAME
45
+ }) {
46
+ if (!configDir) return { changed: false, pluginPath: null, skippedReason: 'config-missing' };
47
+ const pluginDir = resolveOpencodePluginDir({ configDir });
48
+ const pluginPath = path.join(pluginDir, pluginName);
49
+ const next = buildOpencodePlugin({ notifyPath });
50
+ const existing = await fs.readFile(pluginPath, 'utf8').catch(() => null);
51
+
52
+ if (existing === next) {
53
+ return { changed: false, pluginPath, skippedReason: null };
54
+ }
55
+
56
+ await ensureDir(pluginDir);
57
+
58
+ let backupPath = null;
59
+ if (existing != null) {
60
+ backupPath = `${pluginPath}.bak.${new Date().toISOString().replace(/[:.]/g, '-')}`;
61
+ await fs.copyFile(pluginPath, backupPath).catch(() => {});
62
+ }
63
+
64
+ await fs.writeFile(pluginPath, next, 'utf8');
65
+ return { changed: true, pluginPath, backupPath, skippedReason: null };
66
+ }
67
+
68
+ async function removeOpencodePlugin({ configDir, pluginName = DEFAULT_PLUGIN_NAME }) {
69
+ if (!configDir) return { removed: false, skippedReason: 'config-missing' };
70
+ const pluginPath = path.join(resolveOpencodePluginDir({ configDir }), pluginName);
71
+ const existing = await fs.readFile(pluginPath, 'utf8').catch(() => null);
72
+ if (existing == null) return { removed: false, skippedReason: 'plugin-missing' };
73
+ if (!hasPluginMarker(existing)) return { removed: false, skippedReason: 'unexpected-content' };
74
+ await fs.unlink(pluginPath).catch(() => {});
75
+ return { removed: true, skippedReason: null };
76
+ }
77
+
78
+ async function isOpencodePluginInstalled({ configDir, pluginName = DEFAULT_PLUGIN_NAME }) {
79
+ if (!configDir) return false;
80
+ const pluginPath = path.join(resolveOpencodePluginDir({ configDir }), pluginName);
81
+ const existing = await fs.readFile(pluginPath, 'utf8').catch(() => null);
82
+ if (!existing) return false;
83
+ return hasPluginMarker(existing);
84
+ }
85
+
86
+ function hasPluginMarker(text) {
87
+ return typeof text === 'string' && text.includes(PLUGIN_MARKER);
88
+ }
89
+
90
+ module.exports = {
91
+ DEFAULT_PLUGIN_NAME,
92
+ resolveOpencodeConfigDir,
93
+ resolveOpencodePluginDir,
94
+ buildOpencodePlugin,
95
+ upsertOpencodePlugin,
96
+ removeOpencodePlugin,
97
+ isOpencodePluginInstalled
98
+ };
@@ -61,6 +61,14 @@ async function listGeminiSessionFiles(tmpDir) {
61
61
  return out;
62
62
  }
63
63
 
64
+ async function listOpencodeMessageFiles(storageDir) {
65
+ const out = [];
66
+ const messageDir = path.join(storageDir, 'message');
67
+ await walkOpencodeMessages(messageDir, out);
68
+ out.sort((a, b) => a.localeCompare(b));
69
+ return out;
70
+ }
71
+
64
72
  async function parseRolloutIncremental({ rolloutFiles, cursors, queuePath, onProgress, source }) {
65
73
  await ensureDir(path.dirname(queuePath));
66
74
  let filesProcessed = 0;
@@ -270,6 +278,91 @@ async function parseGeminiIncremental({ sessionFiles, cursors, queuePath, onProg
270
278
  return { filesProcessed, eventsAggregated, bucketsQueued };
271
279
  }
272
280
 
281
+ async function parseOpencodeIncremental({ messageFiles, cursors, queuePath, onProgress, source }) {
282
+ await ensureDir(path.dirname(queuePath));
283
+ let filesProcessed = 0;
284
+ let eventsAggregated = 0;
285
+
286
+ const cb = typeof onProgress === 'function' ? onProgress : null;
287
+ const files = Array.isArray(messageFiles) ? messageFiles : [];
288
+ const totalFiles = files.length;
289
+ const hourlyState = normalizeHourlyState(cursors?.hourly);
290
+ const touchedBuckets = new Set();
291
+ const defaultSource = normalizeSourceInput(source) || 'opencode';
292
+
293
+ if (!cursors.files || typeof cursors.files !== 'object') {
294
+ cursors.files = {};
295
+ }
296
+
297
+ for (let idx = 0; idx < files.length; idx++) {
298
+ const entry = files[idx];
299
+ const filePath = typeof entry === 'string' ? entry : entry?.path;
300
+ if (!filePath) continue;
301
+ const fileSource =
302
+ typeof entry === 'string' ? defaultSource : normalizeSourceInput(entry?.source) || defaultSource;
303
+ const st = await fs.stat(filePath).catch(() => null);
304
+ if (!st || !st.isFile()) continue;
305
+
306
+ const key = filePath;
307
+ const prev = cursors.files[key] || null;
308
+ const inode = st.ino || 0;
309
+ const size = Number.isFinite(st.size) ? st.size : 0;
310
+ const mtimeMs = Number.isFinite(st.mtimeMs) ? st.mtimeMs : 0;
311
+ const unchanged = prev && prev.inode === inode && prev.size === size && prev.mtimeMs === mtimeMs;
312
+ if (unchanged) {
313
+ filesProcessed += 1;
314
+ if (cb) {
315
+ cb({
316
+ index: idx + 1,
317
+ total: totalFiles,
318
+ filePath,
319
+ filesProcessed,
320
+ eventsAggregated,
321
+ bucketsQueued: touchedBuckets.size
322
+ });
323
+ }
324
+ continue;
325
+ }
326
+
327
+ const lastTotals = prev && prev.inode === inode ? prev.lastTotals || null : null;
328
+ const result = await parseOpencodeMessageFile({
329
+ filePath,
330
+ lastTotals,
331
+ hourlyState,
332
+ touchedBuckets,
333
+ source: fileSource
334
+ });
335
+
336
+ cursors.files[key] = {
337
+ inode,
338
+ size,
339
+ mtimeMs,
340
+ lastTotals: result.lastTotals,
341
+ updatedAt: new Date().toISOString()
342
+ };
343
+
344
+ filesProcessed += 1;
345
+ eventsAggregated += result.eventsAggregated;
346
+
347
+ if (cb) {
348
+ cb({
349
+ index: idx + 1,
350
+ total: totalFiles,
351
+ filePath,
352
+ filesProcessed,
353
+ eventsAggregated,
354
+ bucketsQueued: touchedBuckets.size
355
+ });
356
+ }
357
+ }
358
+
359
+ const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
360
+ hourlyState.updatedAt = new Date().toISOString();
361
+ cursors.hourly = hourlyState;
362
+
363
+ return { filesProcessed, eventsAggregated, bucketsQueued };
364
+ }
365
+
273
366
  async function parseRolloutFile({
274
367
  filePath,
275
368
  startOffset,
@@ -457,6 +550,37 @@ async function parseGeminiFile({
457
550
  };
458
551
  }
459
552
 
553
+ async function parseOpencodeMessageFile({ filePath, lastTotals, hourlyState, touchedBuckets, source }) {
554
+ const raw = await fs.readFile(filePath, 'utf8').catch(() => '');
555
+ if (!raw.trim()) return { lastTotals, eventsAggregated: 0 };
556
+
557
+ let msg;
558
+ try {
559
+ msg = JSON.parse(raw);
560
+ } catch (_e) {
561
+ return { lastTotals, eventsAggregated: 0 };
562
+ }
563
+
564
+ const currentTotals = normalizeOpencodeTokens(msg?.tokens);
565
+ if (!currentTotals) return { lastTotals, eventsAggregated: 0 };
566
+
567
+ const delta = diffGeminiTotals(currentTotals, lastTotals);
568
+ if (!delta || isAllZeroUsage(delta)) return { lastTotals: currentTotals, eventsAggregated: 0 };
569
+
570
+ const timestampMs = coerceEpochMs(msg?.time?.completed) || coerceEpochMs(msg?.time?.created);
571
+ if (!timestampMs) return { lastTotals, eventsAggregated: 0 };
572
+
573
+ const tsIso = new Date(timestampMs).toISOString();
574
+ const bucketStart = toUtcHalfHourStart(tsIso);
575
+ if (!bucketStart) return { lastTotals, eventsAggregated: 0 };
576
+
577
+ const model = normalizeModelInput(msg?.modelID) || DEFAULT_MODEL;
578
+ const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
579
+ addTotals(bucket.totals, delta);
580
+ touchedBuckets.add(bucketKey(source, model, bucketStart));
581
+ return { lastTotals: currentTotals, eventsAggregated: 1 };
582
+ }
583
+
460
584
  async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets }) {
461
585
  if (!touchedBuckets || touchedBuckets.size === 0) return 0;
462
586
 
@@ -984,6 +1108,23 @@ function normalizeGeminiTokens(tokens) {
984
1108
  };
985
1109
  }
986
1110
 
1111
+ function normalizeOpencodeTokens(tokens) {
1112
+ if (!tokens || typeof tokens !== 'object') return null;
1113
+ const input = toNonNegativeInt(tokens.input);
1114
+ const output = toNonNegativeInt(tokens.output);
1115
+ const reasoning = toNonNegativeInt(tokens.reasoning);
1116
+ const cached = toNonNegativeInt(tokens.cache?.read);
1117
+ const total = input + output + reasoning;
1118
+
1119
+ return {
1120
+ input_tokens: input,
1121
+ cached_input_tokens: cached,
1122
+ output_tokens: output,
1123
+ reasoning_output_tokens: reasoning,
1124
+ total_tokens: total
1125
+ };
1126
+ }
1127
+
987
1128
  function sameGeminiTotals(a, b) {
988
1129
  if (!a || !b) return false;
989
1130
  return (
@@ -1125,6 +1266,13 @@ function toNonNegativeInt(v) {
1125
1266
  return Math.floor(n);
1126
1267
  }
1127
1268
 
1269
+ function coerceEpochMs(v) {
1270
+ const n = Number(v);
1271
+ if (!Number.isFinite(n) || n <= 0) return 0;
1272
+ if (n < 1e12) return Math.floor(n * 1000);
1273
+ return Math.floor(n);
1274
+ }
1275
+
1128
1276
  async function safeReadDir(dir) {
1129
1277
  try {
1130
1278
  return await fs.readdir(dir, { withFileTypes: true });
@@ -1145,11 +1293,25 @@ async function walkClaudeProjects(dir, out) {
1145
1293
  }
1146
1294
  }
1147
1295
 
1296
+ async function walkOpencodeMessages(dir, out) {
1297
+ const entries = await safeReadDir(dir);
1298
+ for (const entry of entries) {
1299
+ const fullPath = path.join(dir, entry.name);
1300
+ if (entry.isDirectory()) {
1301
+ await walkOpencodeMessages(fullPath, out);
1302
+ continue;
1303
+ }
1304
+ if (entry.isFile() && entry.name.startsWith('msg_') && entry.name.endsWith('.json')) out.push(fullPath);
1305
+ }
1306
+ }
1307
+
1148
1308
  module.exports = {
1149
1309
  listRolloutFiles,
1150
1310
  listClaudeProjectFiles,
1151
1311
  listGeminiSessionFiles,
1312
+ listOpencodeMessageFiles,
1152
1313
  parseRolloutIncremental,
1153
1314
  parseClaudeIncremental,
1154
- parseGeminiIncremental
1315
+ parseGeminiIncremental,
1316
+ parseOpencodeIncremental
1155
1317
  };