vibeusage 0.2.8 โ†’ 0.2.10

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
@@ -91,6 +91,8 @@ npx --yes vibeusage status
91
91
 
92
92
  - `VIBESCORE_HTTP_TIMEOUT_MS`: CLI HTTP timeout in ms (default `20000`, `0` disables, clamped to `1000..120000`).
93
93
  - `VITE_VIBESCORE_HTTP_TIMEOUT_MS`: Dashboard request timeout in ms (default `15000`, `0` disables, clamped to `1000..30000`).
94
+ - `VIBEUSAGE_ROLLUP_ENABLED`: Currently ignored; rollup aggregation is disabled in code until the daily rollup table is deployed.
95
+ - `VIBESCORE_ROLLUP_ENABLED`: Legacy alias for `VIBEUSAGE_ROLLUP_ENABLED` (ignored).
94
96
  - `GEMINI_HOME`: Override Gemini CLI home (defaults to `~/.gemini`).
95
97
 
96
98
  ## ๐Ÿงฐ Troubleshooting
@@ -170,6 +172,16 @@ npm run validate:copy
170
172
  npm run smoke
171
173
  ```
172
174
 
175
+ ### Architecture Canvas Focus
176
+
177
+ ```bash
178
+ # Generate a focused canvas for a top-level module
179
+ node scripts/ops/architecture-canvas.cjs --focus src
180
+
181
+ # Alias: --module
182
+ node scripts/ops/architecture-canvas.cjs --module dashboard
183
+ ```
184
+
173
185
  ## ๐Ÿ“œ License
174
186
 
175
187
  This project is licensed under the [MIT License](LICENSE).
package/README.zh-CN.md CHANGED
@@ -91,6 +91,8 @@ npx --yes vibeusage status
91
91
 
92
92
  - `VIBESCORE_HTTP_TIMEOUT_MS`๏ผšCLI ่ฏทๆฑ‚่ถ…ๆ—ถ๏ผˆๆฏซ็ง’๏ผŒ้ป˜่ฎค `20000`๏ผŒ`0` ่กจ็คบๅ…ณ้—ญ๏ผŒ่Œƒๅ›ด `1000..120000`๏ผ‰ใ€‚
93
93
  - `VITE_VIBESCORE_HTTP_TIMEOUT_MS`๏ผšDashboard ่ฏทๆฑ‚่ถ…ๆ—ถ๏ผˆๆฏซ็ง’๏ผŒ้ป˜่ฎค `15000`๏ผŒ`0` ่กจ็คบๅ…ณ้—ญ๏ผŒ่Œƒๅ›ด `1000..30000`๏ผ‰ใ€‚
94
+ - `VIBEUSAGE_ROLLUP_ENABLED`๏ผšๅฝ“ๅ‰่ขซๅฟฝ็•ฅ๏ผŒrollup ่šๅˆๅœจไปฃ็ ๅฑ‚็ฆ็”จ๏ผŒ็ญ‰ๅพ… rollup ่กจ้ƒจ็ฝฒๅฎŒๆˆๅŽๅ†ๆขๅคใ€‚
95
+ - `VIBESCORE_ROLLUP_ENABLED`๏ผš`VIBEUSAGE_ROLLUP_ENABLED` ็š„ๅ…ผๅฎนๅˆซๅ๏ผˆๅŒๆ ทๆ— ๆ•ˆ๏ผ‰ใ€‚
94
96
  - `GEMINI_HOME`๏ผš่ฆ†็›– Gemini CLI ็š„ home๏ผˆ้ป˜่ฎค `~/.gemini`๏ผ‰ใ€‚
95
97
 
96
98
  ## ๐Ÿงฐ ๅธธ่ง้—ฎ้ข˜
@@ -170,6 +172,16 @@ npm run validate:copy
170
172
  npm run smoke
171
173
  ```
172
174
 
175
+ ### ๆžถๆž„็”ปๅธƒ่š็„ฆ
176
+
177
+ ```bash
178
+ # ็”ŸๆˆๆŒ‡ๅฎš้กถๅฑ‚ๆจกๅ—็š„่š็„ฆ็”ปๅธƒ
179
+ node scripts/ops/architecture-canvas.cjs --focus src
180
+
181
+ # ๅˆซๅ๏ผš--module
182
+ node scripts/ops/architecture-canvas.cjs --module dashboard
183
+ ```
184
+
173
185
  ## ๐Ÿ“œ ๅผ€ๆบๅ่ฎฎ
174
186
 
175
187
  ๆœฌ้กน็›ฎๅŸบไบŽ [MIT](LICENSE) ๅ่ฎฎๅผ€ๆบใ€‚
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeusage",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "Codex CLI token usage tracker (macOS-first, notify-driven).",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -20,6 +20,8 @@
20
20
  "copy:pull": "node scripts/copy-sync.cjs pull",
21
21
  "copy:push": "node scripts/copy-sync.cjs push",
22
22
  "architecture:canvas": "node scripts/ops/architecture-canvas.cjs",
23
+ "architecture:canvas:focus": "node scripts/ops/architecture-canvas.cjs --focus",
24
+ "architecture:canvas:list-modules": "node scripts/ops/architecture-canvas.cjs --list-modules",
23
25
  "validate:guardrails": "node scripts/validate-architecture-guardrails.cjs"
24
26
  },
25
27
  "bin": {
@@ -42,12 +42,12 @@ const {
42
42
  const { renderLocalReport, renderAuthTransition, renderSuccessBox } = require('../lib/init-flow');
43
43
 
44
44
  const ASCII_LOGO = [
45
- 'โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—',
46
- 'โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ• โ–ˆโ–ˆโ•”โ•โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•',
47
- 'โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—',
48
- 'โ•šโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ• โ•šโ•โ•โ•โ•โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ•',
49
- ' โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ• โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—',
50
- ' โ•šโ•โ•โ•โ• โ•šโ•โ•โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ•โ•โ•šโ•โ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ• โ•šโ•โ•โ•šโ•โ•โ•โ•โ•โ•โ•'
45
+ 'โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—',
46
+ 'โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ• โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•',
47
+ 'โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—',
48
+ 'โ•šโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ• โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ•šโ•โ•โ•โ•โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ•',
49
+ ' โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ• โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—',
50
+ ' โ•šโ•โ•โ•โ• โ•šโ•โ•โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ•โ•โ•šโ•โ• โ•šโ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ•โ•'
51
51
  ].join('\n');
52
52
 
53
53
  const DIVIDER = '----------------------------------------------';
@@ -6,7 +6,7 @@ const { ensureDir } = require('./fs');
6
6
 
7
7
  const DEFAULT_PLUGIN_NAME = 'vibeusage-tracker.js';
8
8
  const PLUGIN_MARKER = 'VIBEUSAGE_TRACKER_PLUGIN';
9
- const DEFAULT_EVENT = 'session.idle';
9
+ const DEFAULT_EVENT = 'session.updated';
10
10
 
11
11
  function resolveOpencodeConfigDir({ home = os.homedir(), env = process.env } = {}) {
12
12
  const explicit = typeof env.OPENCODE_CONFIG_DIR === 'string' ? env.OPENCODE_CONFIG_DIR.trim() : '';
@@ -88,7 +88,9 @@ function hasPluginMarker(text) {
88
88
  }
89
89
 
90
90
  module.exports = {
91
+ DEFAULT_EVENT,
91
92
  DEFAULT_PLUGIN_NAME,
93
+ PLUGIN_MARKER,
92
94
  resolveOpencodeConfigDir,
93
95
  resolveOpencodePluginDir,
94
96
  buildOpencodePlugin,
@@ -0,0 +1,202 @@
1
+ const fs = require('node:fs/promises');
2
+ const os = require('node:os');
3
+ const path = require('node:path');
4
+
5
+ const { listOpencodeMessageFiles, parseOpencodeIncremental } = require('./rollout');
6
+
7
+ const BUCKET_SEPARATOR = '|';
8
+ const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
9
+
10
+ function formatHourKey(date) {
11
+ return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(
12
+ date.getUTCDate()
13
+ ).padStart(2, '0')}T${String(date.getUTCHours()).padStart(2, '0')}:${String(
14
+ date.getUTCMinutes() >= 30 ? 30 : 0
15
+ ).padStart(2, '0')}:00`;
16
+ }
17
+
18
+ function toBig(value) {
19
+ if (typeof value === 'bigint') return value;
20
+ if (typeof value === 'number') return BigInt(value);
21
+ if (typeof value === 'string' && value.trim()) return BigInt(value);
22
+ return 0n;
23
+ }
24
+
25
+ function addTotals(target, delta) {
26
+ target.input_tokens += toBig(delta.input_tokens);
27
+ target.cached_input_tokens += toBig(delta.cached_input_tokens);
28
+ target.output_tokens += toBig(delta.output_tokens);
29
+ target.reasoning_output_tokens += toBig(delta.reasoning_output_tokens);
30
+ target.total_tokens += toBig(delta.total_tokens);
31
+ }
32
+
33
+ async function buildLocalHourlyTotals({ storageDir, source = 'opencode' }) {
34
+ const messageFiles = await listOpencodeMessageFiles(storageDir);
35
+ const queuePath = path.join(os.tmpdir(), `vibeusage-opencode-audit-${process.pid}-${Date.now()}.jsonl`);
36
+ const cursors = { version: 1, files: {}, hourly: null, opencode: null };
37
+
38
+ await parseOpencodeIncremental({ messageFiles, cursors, queuePath, source });
39
+ await fs.rm(queuePath, { force: true }).catch(() => {});
40
+
41
+ const byHour = new Map();
42
+ let minDay = null;
43
+ let maxDay = null;
44
+
45
+ for (const [key, bucket] of Object.entries(cursors.hourly?.buckets || {})) {
46
+ const [bucketSource, , hourStart] = String(key).split(BUCKET_SEPARATOR);
47
+ if (bucketSource !== source || !hourStart) continue;
48
+ const dt = new Date(hourStart);
49
+ if (!Number.isFinite(dt.getTime())) continue;
50
+ const hourKey = formatHourKey(dt);
51
+ const dayKey = hourKey.slice(0, 10);
52
+
53
+ if (!minDay || dayKey < minDay) minDay = dayKey;
54
+ if (!maxDay || dayKey > maxDay) maxDay = dayKey;
55
+
56
+ const totals = byHour.get(hourKey) || {
57
+ input_tokens: 0n,
58
+ cached_input_tokens: 0n,
59
+ output_tokens: 0n,
60
+ reasoning_output_tokens: 0n,
61
+ total_tokens: 0n
62
+ };
63
+ addTotals(totals, bucket.totals || {});
64
+ byHour.set(hourKey, totals);
65
+ }
66
+
67
+ return { byHour, minDay, maxDay };
68
+ }
69
+
70
+ function normalizeServerRows(rows) {
71
+ const map = new Map();
72
+ for (const row of rows || []) {
73
+ if (!row || !row.hour) continue;
74
+ map.set(row.hour, {
75
+ missing: Boolean(row.missing),
76
+ totals: {
77
+ input_tokens: toBig(row.input_tokens),
78
+ cached_input_tokens: toBig(row.cached_input_tokens),
79
+ output_tokens: toBig(row.output_tokens),
80
+ reasoning_output_tokens: toBig(row.reasoning_output_tokens),
81
+ total_tokens: toBig(row.total_tokens)
82
+ }
83
+ });
84
+ }
85
+ return map;
86
+ }
87
+
88
+ function diffTotals(local, server) {
89
+ return {
90
+ input_tokens: local.input_tokens - server.input_tokens,
91
+ cached_input_tokens: local.cached_input_tokens - server.cached_input_tokens,
92
+ output_tokens: local.output_tokens - server.output_tokens,
93
+ reasoning_output_tokens: local.reasoning_output_tokens - server.reasoning_output_tokens,
94
+ total_tokens: local.total_tokens - server.total_tokens
95
+ };
96
+ }
97
+
98
+ function maxAbsDelta(delta) {
99
+ return [
100
+ delta.input_tokens,
101
+ delta.cached_input_tokens,
102
+ delta.output_tokens,
103
+ delta.reasoning_output_tokens,
104
+ delta.total_tokens
105
+ ].reduce((acc, value) => {
106
+ const abs = value < 0n ? -value : value;
107
+ return abs > acc ? abs : acc;
108
+ }, 0n);
109
+ }
110
+
111
+ function isValidDay(value) {
112
+ if (!DAY_RE.test(value)) return false;
113
+ const dt = new Date(`${value}T00:00:00.000Z`);
114
+ return Number.isFinite(dt.getTime());
115
+ }
116
+
117
+ function listDays(from, to) {
118
+ if (!isValidDay(from) || !isValidDay(to)) return [];
119
+ if (from > to) return [];
120
+ const out = [];
121
+ const start = new Date(`${from}T00:00:00.000Z`);
122
+ const end = new Date(`${to}T00:00:00.000Z`);
123
+ for (let dt = start; dt <= end; dt = new Date(dt.getTime() + 24 * 60 * 60 * 1000)) {
124
+ const day = `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, '0')}-${String(
125
+ dt.getUTCDate()
126
+ ).padStart(2, '0')}`;
127
+ out.push(day);
128
+ }
129
+ return out;
130
+ }
131
+
132
+ async function auditOpencodeUsage({ storageDir, from, to, fetchHourly, includeMissing = false }) {
133
+ const local = await buildLocalHourlyTotals({ storageDir, source: 'opencode' });
134
+ if (!local.minDay || !local.maxDay) {
135
+ throw new Error('No local opencode data found');
136
+ }
137
+
138
+ const fromDay = from || local.minDay;
139
+ const toDay = to || local.maxDay;
140
+ const days = listDays(fromDay, toDay);
141
+ if (days.length === 0) {
142
+ throw new Error('Invalid date range for audit');
143
+ }
144
+
145
+ const diffs = [];
146
+ let matched = 0;
147
+ let mismatched = 0;
148
+ let incomplete = 0;
149
+ let maxDelta = 0n;
150
+
151
+ for (const day of days) {
152
+ const server = await fetchHourly(day);
153
+ const serverByHour = normalizeServerRows(server?.data || []);
154
+ for (let h = 0; h < 24; h++) {
155
+ for (const m of [0, 30]) {
156
+ const hourKey = `${day}T${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`;
157
+ const localTotals = local.byHour.get(hourKey) || {
158
+ input_tokens: 0n,
159
+ cached_input_tokens: 0n,
160
+ output_tokens: 0n,
161
+ reasoning_output_tokens: 0n,
162
+ total_tokens: 0n
163
+ };
164
+ const serverEntry = serverByHour.get(hourKey) || null;
165
+ if (serverEntry?.missing && !includeMissing) {
166
+ incomplete += 1;
167
+ continue;
168
+ }
169
+ const serverTotals = serverEntry?.totals || {
170
+ input_tokens: 0n,
171
+ cached_input_tokens: 0n,
172
+ output_tokens: 0n,
173
+ reasoning_output_tokens: 0n,
174
+ total_tokens: 0n
175
+ };
176
+ const delta = diffTotals(localTotals, serverTotals);
177
+ const deltaMax = maxAbsDelta(delta);
178
+ if (deltaMax === 0n) {
179
+ matched += 1;
180
+ } else {
181
+ mismatched += 1;
182
+ if (deltaMax > maxDelta) maxDelta = deltaMax;
183
+ diffs.push({ hour: hourKey, local: localTotals, server: serverTotals, delta });
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ return {
190
+ summary: {
191
+ days: days.length,
192
+ slots: days.length * 48,
193
+ matched,
194
+ mismatched,
195
+ incomplete,
196
+ maxDelta
197
+ },
198
+ diffs
199
+ };
200
+ }
201
+
202
+ module.exports = { auditOpencodeUsage, buildLocalHourlyTotals };
@@ -287,6 +287,8 @@ async function parseOpencodeIncremental({ messageFiles, cursors, queuePath, onPr
287
287
  const files = Array.isArray(messageFiles) ? messageFiles : [];
288
288
  const totalFiles = files.length;
289
289
  const hourlyState = normalizeHourlyState(cursors?.hourly);
290
+ const opencodeState = normalizeOpencodeState(cursors?.opencode);
291
+ const messageIndex = opencodeState.messages;
290
292
  const touchedBuckets = new Set();
291
293
  const defaultSource = normalizeSourceInput(source) || 'opencode';
292
294
 
@@ -324,10 +326,14 @@ async function parseOpencodeIncremental({ messageFiles, cursors, queuePath, onPr
324
326
  continue;
325
327
  }
326
328
 
327
- const lastTotals = prev && prev.inode === inode ? prev.lastTotals || null : null;
329
+ const fallbackTotals = prev && typeof prev.lastTotals === 'object' ? prev.lastTotals : null;
330
+ const fallbackMessageKey =
331
+ prev && typeof prev.messageKey === 'string' && prev.messageKey.trim() ? prev.messageKey.trim() : null;
328
332
  const result = await parseOpencodeMessageFile({
329
333
  filePath,
330
- lastTotals,
334
+ messageIndex,
335
+ fallbackTotals,
336
+ fallbackMessageKey,
331
337
  hourlyState,
332
338
  touchedBuckets,
333
339
  source: fileSource
@@ -338,12 +344,20 @@ async function parseOpencodeIncremental({ messageFiles, cursors, queuePath, onPr
338
344
  size,
339
345
  mtimeMs,
340
346
  lastTotals: result.lastTotals,
347
+ messageKey: result.messageKey || null,
341
348
  updatedAt: new Date().toISOString()
342
349
  };
343
350
 
344
351
  filesProcessed += 1;
345
352
  eventsAggregated += result.eventsAggregated;
346
353
 
354
+ if (result.messageKey && result.shouldUpdate) {
355
+ messageIndex[result.messageKey] = {
356
+ lastTotals: result.lastTotals,
357
+ updatedAt: new Date().toISOString()
358
+ };
359
+ }
360
+
347
361
  if (cb) {
348
362
  cb({
349
363
  index: idx + 1,
@@ -359,6 +373,8 @@ async function parseOpencodeIncremental({ messageFiles, cursors, queuePath, onPr
359
373
  const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
360
374
  hourlyState.updatedAt = new Date().toISOString();
361
375
  cursors.hourly = hourlyState;
376
+ opencodeState.updatedAt = new Date().toISOString();
377
+ cursors.opencode = opencodeState;
362
378
 
363
379
  return { filesProcessed, eventsAggregated, bucketsQueued };
364
380
  }
@@ -550,35 +566,84 @@ async function parseGeminiFile({
550
566
  };
551
567
  }
552
568
 
553
- async function parseOpencodeMessageFile({ filePath, lastTotals, hourlyState, touchedBuckets, source }) {
569
+ async function parseOpencodeMessageFile({
570
+ filePath,
571
+ messageIndex,
572
+ fallbackTotals,
573
+ fallbackMessageKey,
574
+ hourlyState,
575
+ touchedBuckets,
576
+ source
577
+ }) {
578
+ const fallbackKey =
579
+ typeof fallbackMessageKey === 'string' && fallbackMessageKey.trim() ? fallbackMessageKey.trim() : null;
580
+ const legacyTotals = fallbackTotals && typeof fallbackTotals === 'object' ? fallbackTotals : null;
581
+ const fallbackEntry = messageIndex && fallbackKey ? messageIndex[fallbackKey] : null;
582
+ const fallbackLastTotals =
583
+ fallbackEntry && typeof fallbackEntry.lastTotals === 'object' ? fallbackEntry.lastTotals : legacyTotals;
584
+
554
585
  const raw = await fs.readFile(filePath, 'utf8').catch(() => '');
555
- if (!raw.trim()) return { lastTotals, eventsAggregated: 0 };
586
+ if (!raw.trim()) {
587
+ return {
588
+ messageKey: fallbackKey,
589
+ lastTotals: fallbackLastTotals,
590
+ eventsAggregated: 0,
591
+ shouldUpdate: false
592
+ };
593
+ }
556
594
 
557
595
  let msg;
558
596
  try {
559
597
  msg = JSON.parse(raw);
560
598
  } catch (_e) {
561
- return { lastTotals, eventsAggregated: 0 };
599
+ return {
600
+ messageKey: fallbackKey,
601
+ lastTotals: fallbackLastTotals,
602
+ eventsAggregated: 0,
603
+ shouldUpdate: false
604
+ };
562
605
  }
563
606
 
607
+ const messageKey = deriveOpencodeMessageKey(msg, filePath);
608
+ const prev = messageIndex && messageKey ? messageIndex[messageKey] : null;
609
+ const lastTotals = prev && typeof prev.lastTotals === 'object' ? prev.lastTotals : legacyTotals;
610
+
564
611
  const currentTotals = normalizeOpencodeTokens(msg?.tokens);
565
- if (!currentTotals) return { lastTotals, eventsAggregated: 0 };
612
+ if (!currentTotals) {
613
+ return { messageKey, lastTotals, eventsAggregated: 0, shouldUpdate: false };
614
+ }
566
615
 
567
616
  const delta = diffGeminiTotals(currentTotals, lastTotals);
568
- if (!delta || isAllZeroUsage(delta)) return { lastTotals: currentTotals, eventsAggregated: 0 };
617
+ if (!delta || isAllZeroUsage(delta)) {
618
+ return { messageKey, lastTotals: currentTotals, eventsAggregated: 0, shouldUpdate: true };
619
+ }
569
620
 
570
621
  const timestampMs = coerceEpochMs(msg?.time?.completed) || coerceEpochMs(msg?.time?.created);
571
- if (!timestampMs) return { lastTotals, eventsAggregated: 0 };
622
+ if (!timestampMs) {
623
+ return {
624
+ messageKey,
625
+ lastTotals,
626
+ eventsAggregated: 0,
627
+ shouldUpdate: Boolean(lastTotals)
628
+ };
629
+ }
572
630
 
573
631
  const tsIso = new Date(timestampMs).toISOString();
574
632
  const bucketStart = toUtcHalfHourStart(tsIso);
575
- if (!bucketStart) return { lastTotals, eventsAggregated: 0 };
633
+ if (!bucketStart) {
634
+ return {
635
+ messageKey,
636
+ lastTotals,
637
+ eventsAggregated: 0,
638
+ shouldUpdate: Boolean(lastTotals)
639
+ };
640
+ }
576
641
 
577
- const model = normalizeModelInput(msg?.modelID) || DEFAULT_MODEL;
642
+ const model = normalizeModelInput(msg?.modelID || msg?.model || msg?.modelId) || DEFAULT_MODEL;
578
643
  const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
579
644
  addTotals(bucket.totals, delta);
580
645
  touchedBuckets.add(bucketKey(source, model, bucketStart));
581
- return { lastTotals: currentTotals, eventsAggregated: 1 };
646
+ return { messageKey, lastTotals: currentTotals, eventsAggregated: 1, shouldUpdate: true };
582
647
  }
583
648
 
584
649
  async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets }) {
@@ -982,6 +1047,27 @@ function normalizeHourlyState(raw) {
982
1047
  };
983
1048
  }
984
1049
 
1050
+ function normalizeOpencodeState(raw) {
1051
+ const state = raw && typeof raw === 'object' ? raw : {};
1052
+ const messages = state.messages && typeof state.messages === 'object' ? state.messages : {};
1053
+ return {
1054
+ messages,
1055
+ updatedAt: typeof state.updatedAt === 'string' ? state.updatedAt : null
1056
+ };
1057
+ }
1058
+
1059
+ function normalizeMessageKeyPart(value) {
1060
+ if (typeof value !== 'string') return '';
1061
+ return value.trim();
1062
+ }
1063
+
1064
+ function deriveOpencodeMessageKey(msg, fallback) {
1065
+ const sessionId = normalizeMessageKeyPart(msg?.sessionID || msg?.sessionId || msg?.session_id);
1066
+ const messageId = normalizeMessageKeyPart(msg?.id || msg?.messageID || msg?.messageId);
1067
+ if (sessionId && messageId) return `${sessionId}|${messageId}`;
1068
+ return fallback;
1069
+ }
1070
+
985
1071
  function getHourlyBucket(state, source, model, hourStart) {
986
1072
  const buckets = state.buckets;
987
1073
  const normalizedSource = normalizeSourceInput(source) || DEFAULT_SOURCE;