vibeusage 0.2.9 → 0.2.11

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.9",
3
+ "version": "0.2.11",
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": {
@@ -1,6 +1,14 @@
1
1
  'use strict';
2
2
 
3
- const { createClient } = require('@insforge/sdk');
3
+ function loadInsforgeSdk() {
4
+ try {
5
+ return require('@insforge/sdk');
6
+ } catch (err) {
7
+ const wrapped = new Error('Missing dependency @insforge/sdk. Please reinstall vibeusage.');
8
+ wrapped.cause = err;
9
+ throw wrapped;
10
+ }
11
+ }
4
12
 
5
13
  function getAnonKey({ env = process.env } = {}) {
6
14
  return (
@@ -44,6 +52,7 @@ function createTimeoutFetch(baseFetch) {
44
52
 
45
53
  function createInsforgeClient({ baseUrl, accessToken } = {}) {
46
54
  if (!baseUrl) throw new Error('Missing baseUrl');
55
+ const { createClient } = loadInsforgeSdk();
47
56
  const anonKey = getAnonKey();
48
57
  return createClient({
49
58
  baseUrl,
@@ -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,86 @@ 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 indexTotals = prev && typeof prev.lastTotals === 'object' ? prev.lastTotals : null;
610
+ const fallbackMatch = !fallbackKey || fallbackKey === messageKey;
611
+ const lastTotals = indexTotals || (fallbackMatch ? fallbackLastTotals : null);
612
+
564
613
  const currentTotals = normalizeOpencodeTokens(msg?.tokens);
565
- if (!currentTotals) return { lastTotals, eventsAggregated: 0 };
614
+ if (!currentTotals) {
615
+ return { messageKey, lastTotals, eventsAggregated: 0, shouldUpdate: false };
616
+ }
566
617
 
567
618
  const delta = diffGeminiTotals(currentTotals, lastTotals);
568
- if (!delta || isAllZeroUsage(delta)) return { lastTotals: currentTotals, eventsAggregated: 0 };
619
+ if (!delta || isAllZeroUsage(delta)) {
620
+ return { messageKey, lastTotals: currentTotals, eventsAggregated: 0, shouldUpdate: true };
621
+ }
569
622
 
570
623
  const timestampMs = coerceEpochMs(msg?.time?.completed) || coerceEpochMs(msg?.time?.created);
571
- if (!timestampMs) return { lastTotals, eventsAggregated: 0 };
624
+ if (!timestampMs) {
625
+ return {
626
+ messageKey,
627
+ lastTotals,
628
+ eventsAggregated: 0,
629
+ shouldUpdate: Boolean(lastTotals)
630
+ };
631
+ }
572
632
 
573
633
  const tsIso = new Date(timestampMs).toISOString();
574
634
  const bucketStart = toUtcHalfHourStart(tsIso);
575
- if (!bucketStart) return { lastTotals, eventsAggregated: 0 };
635
+ if (!bucketStart) {
636
+ return {
637
+ messageKey,
638
+ lastTotals,
639
+ eventsAggregated: 0,
640
+ shouldUpdate: Boolean(lastTotals)
641
+ };
642
+ }
576
643
 
577
644
  const model = normalizeModelInput(msg?.modelID || msg?.model || msg?.modelId) || DEFAULT_MODEL;
578
645
  const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
579
646
  addTotals(bucket.totals, delta);
580
647
  touchedBuckets.add(bucketKey(source, model, bucketStart));
581
- return { lastTotals: currentTotals, eventsAggregated: 1 };
648
+ return { messageKey, lastTotals: currentTotals, eventsAggregated: 1, shouldUpdate: true };
582
649
  }
583
650
 
584
651
  async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets }) {
@@ -982,6 +1049,27 @@ function normalizeHourlyState(raw) {
982
1049
  };
983
1050
  }
984
1051
 
1052
+ function normalizeOpencodeState(raw) {
1053
+ const state = raw && typeof raw === 'object' ? raw : {};
1054
+ const messages = state.messages && typeof state.messages === 'object' ? state.messages : {};
1055
+ return {
1056
+ messages,
1057
+ updatedAt: typeof state.updatedAt === 'string' ? state.updatedAt : null
1058
+ };
1059
+ }
1060
+
1061
+ function normalizeMessageKeyPart(value) {
1062
+ if (typeof value !== 'string') return '';
1063
+ return value.trim();
1064
+ }
1065
+
1066
+ function deriveOpencodeMessageKey(msg, fallback) {
1067
+ const sessionId = normalizeMessageKeyPart(msg?.sessionID || msg?.sessionId || msg?.session_id);
1068
+ const messageId = normalizeMessageKeyPart(msg?.id || msg?.messageID || msg?.messageId);
1069
+ if (sessionId && messageId) return `${sessionId}|${messageId}`;
1070
+ return fallback;
1071
+ }
1072
+
985
1073
  function getHourlyBucket(state, source, model, hourStart) {
986
1074
  const buckets = state.buckets;
987
1075
  const normalizedSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
@@ -1114,10 +1202,12 @@ function normalizeOpencodeTokens(tokens) {
1114
1202
  const output = toNonNegativeInt(tokens.output);
1115
1203
  const reasoning = toNonNegativeInt(tokens.reasoning);
1116
1204
  const cached = toNonNegativeInt(tokens.cache?.read);
1117
- const total = input + output + reasoning;
1205
+ const cacheWrite = toNonNegativeInt(tokens.cache?.write);
1206
+ const inputTokens = input + cacheWrite;
1207
+ const total = inputTokens + output + reasoning;
1118
1208
 
1119
1209
  return {
1120
- input_tokens: input,
1210
+ input_tokens: inputTokens,
1121
1211
  cached_input_tokens: cached,
1122
1212
  output_tokens: output,
1123
1213
  reasoning_output_tokens: reasoning,
@@ -1217,7 +1307,7 @@ function normalizeUsage(u) {
1217
1307
  }
1218
1308
 
1219
1309
  function normalizeClaudeUsage(u) {
1220
- const inputTokens = toNonNegativeInt(u?.input_tokens);
1310
+ const inputTokens = toNonNegativeInt(u?.input_tokens) + toNonNegativeInt(u?.cache_creation_input_tokens);
1221
1311
  const outputTokens = toNonNegativeInt(u?.output_tokens);
1222
1312
  const hasTotal = u && Object.prototype.hasOwnProperty.call(u, 'total_tokens');
1223
1313
  const totalTokens = hasTotal ? toNonNegativeInt(u?.total_tokens) : inputTokens + outputTokens;