@vibe-cafe/vibe-usage 0.10.13 → 0.10.15

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
@@ -8,10 +8,10 @@ Track your AI coding tool token usage and sync to [vibecafe.ai](https://vibecafe
8
8
  npx @vibe-cafe/vibe-usage
9
9
  ```
10
10
 
11
- That's it. The CLI opens [vibecafe.ai/usage/device](https://vibecafe.ai/usage/device) in your browser; sign in, confirm the verification code shown in your terminal, click 「确认链接」, and the CLI receives an API key automatically.
11
+ That's it. On first setup, the CLI asks whether project names and the device name may leave the machine (both default to **No**), then opens [vibecafe.ai/usage/device](https://vibecafe.ai/usage/device) in your browser. Sign in, confirm the verification code shown in your terminal, click 「确认链接」, and the CLI receives an API key automatically.
12
12
 
13
13
  After approval, it will:
14
- 1. Save your API key to `~/.vibe-usage/config.json`
14
+ 1. Save your API key and local privacy choices to `~/.vibe-usage/config.json`
15
15
  2. Detect installed AI coding tools
16
16
  3. Run an initial sync of your usage data
17
17
  4. Prompt you to enable the background daemon for continuous syncing (recommended)
@@ -32,6 +32,8 @@ npx @vibe-cafe/vibe-usage init # Re-run setup via browser login
32
32
  npx @vibe-cafe/vibe-usage init --manual-key <vbu_...> # Skip browser, use pre-issued key (CI/headless)
33
33
  npx @vibe-cafe/vibe-usage sync # Manual sync
34
34
  npx @vibe-cafe/vibe-usage sync --extra-codex-home /path/to/.codex # Add another Codex Home for this run only
35
+ npx @vibe-cafe/vibe-usage config set uploadProject false # Never upload project names
36
+ npx @vibe-cafe/vibe-usage config set uploadHostname false # Use an opaque per-install device id
35
37
  npx @vibe-cafe/vibe-usage summary # Print last 7 days as markdown (cost / tokens / by model / by project)
36
38
  npx @vibe-cafe/vibe-usage summary --days N # Same, over the last N days (1-90)
37
39
  npx @vibe-cafe/vibe-usage daemon # Continuous sync (every 30m, foreground)
@@ -53,6 +55,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
53
55
  |------|---------------|
54
56
  | Alma | Electron app-data `alma/chat_threads.db` (macOS: `~/Library/Application Support/alma/chat_threads.db`; fixture/relocation override: `VIBE_USAGE_ALMA_DB`). Reads the `usage_records` ledger plus workspace names without selecting chat bodies, message metadata, provider credentials, or full workspace paths. Provider-prefixed model identifiers are normalized to their final model segment. Cache writes are included in input usage. The ledger contains assistant responses only, so Alma emits token buckets without session timing. |
55
57
  | Claude Code + Claude Desktop Code/Cowork | Claude Code data in `~/.claude/projects/` (tokens + sessions) and `~/.claude/transcripts/` (sessions only), plus Claude Desktop Cowork's per-session `.claude/projects/` directories. Also scans `$CLAUDE_CONFIG_DIR` and data-bearing `~/.claude-*` profiles. All variants use the existing `claude-code` source; the parser selects the most complete copy of each session so shared/copied transcripts are not counted twice. Logs are streamed and cache creation tokens are included in input usage. |
58
+ | Cindy | Per-owner SQLite ledgers in the two regional Electron user-data roots: macOS `~/Library/Application Support/{CindyGlobal,Cindy}/cindy-*.db`, Windows `%APPDATA%\{CindyGlobal,Cindy}\cindy-*.db`, Linux `${XDG_CONFIG_HOME:-~/.config}/{CindyGlobal,Cindy}/cindy-*.db` (fixture/relocation override: `VIBE_USAGE_CINDY_DIRS`). Cindy-launched Claude Code already writes ordinary `~/.claude` transcripts, so it remains attributed to **Claude Code** and is not read again. Cindy's otherwise-private Codex and Pi daily/model ledger rows augment the existing **Codex** and **pi** sources. Currency rows are summed and cache creation joins input; chat messages, credentials, costs, and owner ids are never selected. The ledger adds token buckets only, without project or session timing. |
56
59
  | Codex CLI | `$CODEX_HOME/sessions/` and `$CODEX_HOME/archived_sessions/` (default `~/.codex`), plus an optional temporary `--extra-codex-home` or manually persisted `codexExtraHome`; a versioned local index avoids re-reading unchanged rollouts and reads only safe append tails for ordinary sessions, while fork/sub-agent replay matching, duplicate suppression, and live/archive/cross-root deduplication retain their existing semantics |
57
60
  | Grok | `$GROK_HOME/sessions/<encoded-cwd>/<session-id>/` (default `~/.grok`); token usage from `updates.jsonl` `turn_completed.usage` (per-model `modelUsage`, cache reads, reasoning); project from `summary.json` cwd; honors `GROK_HOME` |
58
61
  | GitHub Copilot CLI | `~/.copilot/session-state/*/events.jsonl` |
@@ -74,8 +77,8 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
74
77
  | Kiro | Kiro CLI native event streams `~/.kiro/sessions/cli/*.jsonl` (estimated tokens from message text: input = prompt + tool results, output = reply + tool calls, reasoning = thinking, cacheRead = re-sent context; thinking-block signatures excluded). Falls back to `~/Library/Application Support/kiro-cli/data.sqlite3` / `~/.local/share/kiro-cli/data.sqlite3` + optional `~/.kiro_sessions/*.json` archives, then IDE `q-client.log` whole-credit deltas as `kiro-credits` (floored cumulative diff — the server stores token counts as bigint); legacy IDE `dev_data/devdata.sqlite` token telemetry is opt-in with `VIBE_USAGE_KIRO_LEGACY_TOKENS=1` |
75
78
  | Cline | Standalone `~/.cline/` plus `<host>/User/globalStorage/saoudrizwan.claude-dev/` across VSCode-fork hosts; migrated copies are deduplicated and empty leftover extension stores no longer count as installed |
76
79
  | Roo Code | `<host>/User/globalStorage/rooveterinaryinc.roo-cline/{tasks/_index.json,tasks/<id>/{history_item,ui_messages}.json}` (walks all VSCode-fork hosts) |
77
- | Trae CLI | macOS: `~/Library/Caches/trae-cli/sessions/`; Windows: `%LOCALAPPDATA%/trae-cli/cache/sessions/`; Linux: `~/.cache/trae-cli/sessions/` (CLI telemetry only; Trae IDE/Trae Work chats are not supported) |
78
- | Antigravity | App 2.0 `~/.gemini/antigravity/conversations/*.db` and `agy` CLI `~/.gemini/antigravity-cli/conversations/*.db` are parsed offline (tokens, real model display name, project, sessions); legacy App `.pb` history falls back to Connect RPC while the language server is running |
80
+ | Trae CLI | macOS: `~/Library/Caches/trae-cli/sessions/`; Windows: `%LOCALAPPDATA%/trae-cli/cache/sessions/`; Linux: `~/.cache/trae-cli/sessions/` (CLI telemetry only; Trae IDE/Trae Work chats are not supported). Token usage is summed per unique LLM call (`model.stream.eino`, plus `model.generate` failovers); nested duplicate spans that share a session `traceID` are not max-merged. `traces.jsonl` / `events.jsonl` are streamed line-by-line so a multi-hundred-MB events file cannot hit Node's string-length limit. |
81
+ | Antigravity | App 2.0 `~/.gemini/antigravity/conversations/*.db` and `agy` CLI `~/.gemini/antigravity-cli/conversations/*.db` are parsed offline (tokens, real model display name when present, project, sessions). Gemini 3.7 CLI blobs omit `chatStartMetadata.createdAt` and `modelDisplayName`; usage still comes from `gen_metadata`, timestamps fall back to `steps.metadata` at the same idx, and the model name falls back to `responseModel`. Legacy App `.pb` history falls back to Connect RPC while the language server is running |
79
82
  | WorkBuddy | Current releases: `~/.workbuddy-ai/projects/**/*.jsonl`; legacy releases: `~/.workbuddy/projects/**/*.jsonl` (fixture/relocation override: `VIBE_USAGE_WORKBUDDY_DIRS`). Reads usage-bearing completed assistant and `function_call` records, using the routed model identifier exposed as `providerData.requestModelId`. Splits cache reads and reasoning from inclusive input/output totals, deduplicates copied record IDs, and extracts local session timing without uploading message content. |
80
83
  | ZCode | `~/.zcode/cli/db/db.sqlite` (SQLite; reads the `message` table for per-message tokens, model, and project `cwd`/`root`, joined to `session.directory`) |
81
84
 
@@ -83,7 +86,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
83
86
 
84
87
  - Parses local session logs from each AI coding tool
85
88
  - Aggregates token usage into 30-minute buckets
86
- - Extracts session metadata where the source safely exposes user/assistant timing: active time (AI generation time, excluding queue/TTFT wait), total duration, and message counts. Alma intentionally emits buckets only because its usage ledger contains assistant responses and the parser does not read chat records.
89
+ - Extracts session metadata where the source safely exposes user/assistant timing: active time (AI generation time, excluding queue/TTFT wait), total duration, and message counts. Alma intentionally emits buckets only; Cindy's daily-ledger augmentation adds no timing data to the native Codex/pi sessions because doing so would require reading Cindy chat records.
87
90
  - Uploads buckets + sessions to your vibecafe.ai dashboard (always gzip-compressed, ~94% smaller)
88
91
  - Incremental upload: every parser emits a complete local snapshot, then only buckets/sessions that are new or changed since the last successful upload are sent — a quiet machine uploads nothing. Upload state remains in `~/.vibe-usage/state.json`; failed or still-indexing parsers retain their prior state, while deleted local logs are pruned. Deleting the state file triggers a one-time full re-upload, and `reset` clears it automatically after deleting cloud data
89
92
  - Incremental Codex parsing: a versioned, disposable cache under `~/.vibe-usage/cache/codex/` stores per-rollout aggregate results and parser continuation state. Unchanged rollouts require no raw-log reads; an ordinary append reads only the new tail; forks, sub-agents, replacements, truncations, and failed safety checks fall back to the full correctness path. A bounded rolling audit occasionally re-reads one historical file. Very large first-time indexes checkpoint before the Mac app timeout and resume on the next sync instead of restarting
@@ -154,10 +157,40 @@ Config stored at `~/.vibe-usage/config.json` (dev: `config.dev.json`).
154
157
  |-----|-------------|
155
158
  | `apiKey` | Your API key (starts with `vbu_`) |
156
159
  | `apiUrl` | Server URL (default: `https://vibecafe.ai`) |
157
- | `hostname` | Stable device name for usage tracking (set at init, reused across syncs) |
160
+ | `hostname` | Stable device name or user-chosen alias; stays local when `uploadHostname=false` |
161
+ | `uploadProject` | Local project-name control. `false` always wins over the Web setting |
162
+ | `uploadHostname` | Local device-name control. `false` replaces the name at the final network boundary |
163
+ | `deviceId` | Generated opaque per-install identity used when `uploadHostname=false` |
158
164
  | `codexExtraHome` | Optional additional Codex Home scanned together with `$CODEX_HOME` / `~/.codex` |
159
165
 
160
- The `hostname` is captured once during `init` and reused for all future syncs. This prevents macOS mDNS hostname changes (e.g., `MacBook-Pro` `MacBook-Pro-2`) from creating duplicate device entries. To change it manually:
166
+ New setups default both local upload controls to `false`. Existing configs without
167
+ these keys retain their previous behavior until you choose a value: the Web
168
+ project-name setting remains authoritative, and the configured device name is
169
+ uploaded.
170
+
171
+ ```bash
172
+ # Local false cannot be overridden by a later Web setting.
173
+ npx @vibe-cafe/vibe-usage config set uploadProject false
174
+
175
+ # Replaces the device name in buckets, sessions, and sync metadata with a
176
+ # persistent random id such as device-0011223344556677.
177
+ npx @vibe-cafe/vibe-usage config set uploadHostname false
178
+ ```
179
+
180
+ The sanitization happens after every parser and before hashing or HTTP
181
+ serialization. `cursor-cloud` remains a fixed, non-identifying sentinel so
182
+ Cursor account exports still deduplicate across computers.
183
+
184
+ These controls prevent future transmissions; they do not silently delete data
185
+ already stored in the cloud. To remove previously uploaded identifiers, run
186
+ `vibe-usage reset` after enabling both controls. A full reset deletes the
187
+ account's existing usage before re-uploading the logs available on this
188
+ computer, so coordinate first if the account syncs multiple computers.
189
+
190
+ When device-name upload is enabled, `hostname` is captured once during `init`
191
+ and reused for all future syncs. This prevents macOS mDNS hostname changes
192
+ (for example, `MacBook-Pro` → `MacBook-Pro-2`) from creating duplicate device
193
+ entries. It can also be set to a non-identifying alias:
161
194
 
162
195
  ```bash
163
196
  npx @vibe-cafe/vibe-usage config set hostname my-device-name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.13",
3
+ "version": "0.10.15",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -0,0 +1,85 @@
1
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
2
+ import { delimiter, dirname, join, posix, resolve, win32 } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ function unique(values) {
6
+ return [...new Set(values)];
7
+ }
8
+
9
+ /**
10
+ * Cindy keeps Mainland China and Global installs in separate Electron user-data
11
+ * roots. Scan both because the two editions can be installed side by side.
12
+ */
13
+ export function getCindyDataRoots(
14
+ env = process.env,
15
+ platform = process.platform,
16
+ home = homedir(),
17
+ ) {
18
+ const override = env.VIBE_USAGE_CINDY_DIRS?.trim();
19
+ if (override) {
20
+ return unique(
21
+ override
22
+ .split(delimiter)
23
+ .map((value) => value.trim())
24
+ .filter(Boolean)
25
+ .map((value) => resolve(value)),
26
+ );
27
+ }
28
+
29
+ const pathImpl = platform === 'win32' ? win32 : posix;
30
+ let base;
31
+ if (platform === 'darwin') {
32
+ base = pathImpl.join(home, 'Library', 'Application Support');
33
+ } else if (platform === 'win32') {
34
+ base = env.APPDATA?.trim() || pathImpl.join(home, 'AppData', 'Roaming');
35
+ } else {
36
+ base = env.XDG_CONFIG_HOME?.trim() || pathImpl.join(home, '.config');
37
+ }
38
+ return [pathImpl.join(base, 'CindyGlobal'), pathImpl.join(base, 'Cindy')];
39
+ }
40
+
41
+ function canonicalPath(value) {
42
+ try {
43
+ return realpathSync(value);
44
+ } catch {
45
+ return value;
46
+ }
47
+ }
48
+
49
+ /** Find every active per-owner `cindy-<owner>.db` database. */
50
+ export function findCindyDbPaths(options = {}) {
51
+ const roots = getCindyDataRoots(options.env, options.platform, options.home);
52
+ const paths = [];
53
+
54
+ for (const root of roots) {
55
+ let stat;
56
+ try {
57
+ stat = statSync(root);
58
+ } catch {
59
+ continue;
60
+ }
61
+
62
+ if (stat.isFile()) {
63
+ if (root.endsWith('.db')) paths.push(canonicalPath(root));
64
+ continue;
65
+ }
66
+ if (!stat.isDirectory()) continue;
67
+
68
+ let entries;
69
+ try {
70
+ entries = readdirSync(root, { withFileTypes: true });
71
+ } catch {
72
+ continue;
73
+ }
74
+ for (const entry of entries) {
75
+ if (!entry.isFile() || !/^cindy-.+\.db$/.test(entry.name)) continue;
76
+ paths.push(canonicalPath(join(root, entry.name)));
77
+ }
78
+ }
79
+
80
+ return unique(paths).sort();
81
+ }
82
+
83
+ export function findCindyDataDirs(options = {}) {
84
+ return unique(findCindyDbPaths(options).map(dirname)).filter(existsSync);
85
+ }
package/src/index.js CHANGED
@@ -24,6 +24,8 @@ async function showStatus() {
24
24
  if (config.codexExtraHome) {
25
25
  console.log(` Extra Codex Home: ${config.codexExtraHome}`);
26
26
  }
27
+ console.log(` Project names: ${config.uploadProject === false ? 'hidden locally' : 'server setting'}`);
28
+ console.log(` Device name: ${config.uploadHostname === false ? 'anonymous device id' : 'uploaded'}`);
27
29
  }
28
30
 
29
31
  console.log('\n Detected tools:');
@@ -48,7 +50,15 @@ async function showStatus() {
48
50
  console.log();
49
51
  }
50
52
 
51
- const VALID_CONFIG_KEYS = ['apiKey', 'apiUrl', 'hostname', 'codexExtraHome'];
53
+ const BOOLEAN_CONFIG_KEYS = new Set(['uploadProject', 'uploadHostname']);
54
+ const VALID_CONFIG_KEYS = [
55
+ 'apiKey',
56
+ 'apiUrl',
57
+ 'hostname',
58
+ 'uploadProject',
59
+ 'uploadHostname',
60
+ 'codexExtraHome',
61
+ ];
52
62
 
53
63
  function handleConfig(args) {
54
64
  const sub = args[0];
@@ -81,6 +91,14 @@ function handleConfig(args) {
81
91
  console.error(`Valid keys: ${VALID_CONFIG_KEYS.join(', ')}`);
82
92
  process.exit(1);
83
93
  }
94
+ if (BOOLEAN_CONFIG_KEYS.has(key)) {
95
+ const normalized = value.toLowerCase();
96
+ if (normalized !== 'true' && normalized !== 'false') {
97
+ console.error(`${key} must be true or false.`);
98
+ process.exit(1);
99
+ }
100
+ value = normalized === 'true';
101
+ }
84
102
  if (key === 'codexExtraHome' && value !== '') {
85
103
  const validation = validateExtraCodexHome(value);
86
104
  if (!validation.ok) {
@@ -233,6 +251,8 @@ export async function run(rawArgs) {
233
251
  npx @vibe-cafe/vibe-usage config get <key> Get a config value
234
252
  npx @vibe-cafe/vibe-usage config set <key> <value> Set a config value
235
253
  npx @vibe-cafe/vibe-usage config set codexExtraHome <path> Persist another Codex Home
254
+ npx @vibe-cafe/vibe-usage config set uploadProject false Never upload project names
255
+ npx @vibe-cafe/vibe-usage config set uploadHostname false Replace the device name with an anonymous id
236
256
  npx @vibe-cafe/vibe-usage help Show this help
237
257
  `);
238
258
  break;
package/src/init.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import { execFile } from 'node:child_process';
3
- import { hostname as osHostname, platform } from 'node:os';
3
+ import { platform } from 'node:os';
4
4
  import { loadConfig, saveConfig } from './config.js';
5
5
  import { ingest, requestDeviceCode, pollDeviceCode } from './api.js';
6
- import { runSync } from './sync.js';
6
+ import { resolveOptionalBoolean, resolveSyncHostname, runSync } from './sync.js';
7
7
  import { detectInstalledTools } from './tools.js';
8
8
  import { bigHeader, success, failure, warn, arrow, link, dim, divider } from './output.js';
9
9
 
@@ -30,6 +30,14 @@ function isDaemonPlatform() {
30
30
  return process.platform === 'linux' || process.platform === 'darwin';
31
31
  }
32
32
 
33
+ async function resolvePrivacyChoice(existingValue, key, question) {
34
+ const configured = resolveOptionalBoolean(existingValue, key);
35
+ if (configured !== undefined) return configured;
36
+ if (!process.stdin.isTTY) return false;
37
+ const answer = (await prompt(question)).toLowerCase();
38
+ return answer === 'y' || answer === 'yes';
39
+ }
40
+
33
41
  export async function runInit(options = {}) {
34
42
  const { apiKey: providedKey, codexExtraHome } = options;
35
43
 
@@ -51,7 +59,31 @@ export async function runInit(options = {}) {
51
59
  }
52
60
 
53
61
  const apiUrl = process.env.VIBE_USAGE_API_URL || 'https://vibecafe.ai';
54
- const host = existing?.hostname || osHostname().replace(/\.local$/, '');
62
+ let uploadProject;
63
+ let uploadHostname;
64
+ let draftConfig;
65
+ let host;
66
+ try {
67
+ uploadProject = await resolvePrivacyChoice(
68
+ existing?.uploadProject,
69
+ 'uploadProject',
70
+ '上传项目名以查看按项目统计?项目名可能包含客户或内部代号。 [y/N] ',
71
+ );
72
+ uploadHostname = await resolvePrivacyChoice(
73
+ existing?.uploadHostname,
74
+ 'uploadHostname',
75
+ '上传设备名以区分电脑?选择否将使用匿名设备 ID。 [y/N] ',
76
+ );
77
+ draftConfig = {
78
+ ...(existing || {}),
79
+ uploadProject,
80
+ uploadHostname,
81
+ };
82
+ host = resolveSyncHostname(draftConfig).hostname;
83
+ } catch (err) {
84
+ console.error(failure(err.message));
85
+ process.exit(1);
86
+ }
55
87
 
56
88
  let apiKey;
57
89
  if (providedKey) {
@@ -77,10 +109,9 @@ export async function runInit(options = {}) {
77
109
  }
78
110
 
79
111
  const config = {
112
+ ...draftConfig,
80
113
  apiKey,
81
114
  apiUrl,
82
- hostname: host,
83
- ...(existing?.codexExtraHome ? { codexExtraHome: existing.codexExtraHome } : {}),
84
115
  };
85
116
  saveConfig(config);
86
117
 
@@ -26,6 +26,10 @@ import { queryDbJsonSnapshotOnLock, sqliteUnavailableError, isSqliteUnavailableE
26
26
  * createdAt (Timestamp) = 9.4 → seconds = 9.4.1
27
27
  * responseModel = field 19 ("gemini-3-flash-a" / "gemini-default")
28
28
  * modelDisplayName = field 21 ("Gemini 3.5 Flash (High/Medium/Low)")
29
+ *
30
+ * Gemini 3.7 CLI blobs still have usage (4) and responseModel (19) but omit
31
+ * 9.4 and 21. Timestamps are recovered from steps.metadata field 1.1 at the
32
+ * same gen_metadata.idx; the model name falls back to responseModel.
29
33
  */
30
34
 
31
35
  // ── Minimal protobuf wire-format decoder (no dependency) ──────────────
@@ -146,6 +150,40 @@ export function parseGenMetadataBlob(buf) {
146
150
  };
147
151
  }
148
152
 
153
+ /**
154
+ * Extract createdAt seconds from a steps.metadata blob, regardless of step
155
+ * source. Gemini 3.7 CLI gen_metadata blobs no longer carry chatStartMetadata
156
+ * (field 9.4); the step timestamp at field 1.1 is the remaining clock.
157
+ *
158
+ * @param {Buffer} buf
159
+ * @returns {Date|null}
160
+ */
161
+ export function parseStepTimestamp(buf) {
162
+ const meta = decodeMessage(buf);
163
+ const createdAt = firstMessage(meta, 1);
164
+ const seconds = createdAt ? firstVarint(createdAt, 1) : undefined;
165
+ if (!seconds) return null;
166
+ const timestamp = new Date(seconds * 1000);
167
+ return Number.isNaN(timestamp.getTime()) ? null : timestamp;
168
+ }
169
+
170
+ /**
171
+ * Prefer the blob's own createdAt; if Gemini 3.7 omitted it, use the step
172
+ * with the same idx. idx-join is exact on current CLI stores (every
173
+ * gen_metadata idx exists in steps).
174
+ *
175
+ * @param {{timestamp: Date|null, idx?: number}} rec
176
+ * @param {Map<number, Date>} stepTimestampsByIdx
177
+ * @returns {Date|null}
178
+ */
179
+ export function resolveUsageTimestamp(rec, stepTimestampsByIdx) {
180
+ if (rec?.timestamp && !Number.isNaN(rec.timestamp.getTime())) return rec.timestamp;
181
+ if (rec?.idx == null || !stepTimestampsByIdx) return null;
182
+ const stepTs = stepTimestampsByIdx.get(rec.idx);
183
+ if (stepTs && !Number.isNaN(stepTs.getTime())) return stepTs;
184
+ return null;
185
+ }
186
+
149
187
  // ── SQLite store reading ──────────────────────────────────────────────
150
188
 
151
189
  function queryCascadeDb(conversationsDir, cascadeId, sql) {
@@ -182,7 +220,7 @@ export function listDbCascades(conversationsDir) {
182
220
  export function readDbUsageRecords(conversationsDir, cascadeId) {
183
221
  let rows;
184
222
  try {
185
- rows = queryCascadeDb(conversationsDir, cascadeId, 'SELECT hex(data) AS h FROM gen_metadata ORDER BY idx');
223
+ rows = queryCascadeDb(conversationsDir, cascadeId, 'SELECT idx, hex(data) AS h FROM gen_metadata ORDER BY idx');
186
224
  } catch (err) {
187
225
  if (isSqliteUnavailableError(err)) throw err;
188
226
  return [];
@@ -196,11 +234,47 @@ export function readDbUsageRecords(conversationsDir, cascadeId) {
196
234
  } catch {
197
235
  continue; // one malformed blob must not kill the rest
198
236
  }
199
- if (rec) records.push(rec);
237
+ if (rec) {
238
+ rec.idx = Number.isFinite(Number(row.idx)) ? Number(row.idx) : null;
239
+ records.push(rec);
240
+ }
200
241
  }
201
242
  return records;
202
243
  }
203
244
 
245
+ /**
246
+ * Map steps.idx → createdAt for every step that has a timestamp, including
247
+ * system/tool steps that parseStepMetadata skips. Used to timestamp 3.7
248
+ * gen_metadata rows that no longer embed chatStartMetadata.
249
+ */
250
+ export function readDbStepTimestamps(conversationsDir, cascadeId) {
251
+ let rows;
252
+ try {
253
+ rows = queryCascadeDb(
254
+ conversationsDir,
255
+ cascadeId,
256
+ 'SELECT idx, hex(metadata) AS h FROM steps WHERE metadata IS NOT NULL ORDER BY idx',
257
+ );
258
+ } catch (err) {
259
+ if (isSqliteUnavailableError(err)) throw err;
260
+ return new Map();
261
+ }
262
+ const byIdx = new Map();
263
+ for (const row of rows) {
264
+ if (!row.h) continue;
265
+ let ts;
266
+ try {
267
+ ts = parseStepTimestamp(Buffer.from(row.h, 'hex'));
268
+ } catch {
269
+ continue;
270
+ }
271
+ if (!ts) continue;
272
+ const idx = Number(row.idx);
273
+ if (Number.isFinite(idx)) byIdx.set(idx, ts);
274
+ }
275
+ return byIdx;
276
+ }
277
+
204
278
  /**
205
279
  * Read the workspace URI for a cascade from trajectory_metadata_blob.
206
280
  * Structure: field 1 = workspaces[0], 1.1 = workspaceFolderAbsoluteUri.
@@ -3,7 +3,7 @@ import { readdirSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { aggregateToBuckets, extractSessions } from './aggregate.js';
6
- import { listDbCascades, readDbUsageRecords, readDbWorkspaceUri, readDbSessionEvents } from './antigravity-db.js';
6
+ import { listDbCascades, readDbUsageRecords, readDbWorkspaceUri, readDbSessionEvents, readDbStepTimestamps, resolveUsageTimestamp } from './antigravity-db.js';
7
7
 
8
8
 
9
9
 
@@ -327,18 +327,25 @@ export async function parse() {
327
327
  for (const cascadeId of listDbCascades(dir)) {
328
328
  const records = readDbUsageRecords(dir, cascadeId);
329
329
  const project = projectFromUri(readDbWorkspaceUri(dir, cascadeId)) || 'unknown';
330
+ const stepTimestampsByIdx = records.some((rec) => !rec.timestamp || isNaN(rec.timestamp.getTime()))
331
+ ? readDbStepTimestamps(dir, cascadeId)
332
+ : new Map();
330
333
 
331
334
  if (records.length > 0) {
332
335
  dbHandled.add(cascadeId);
333
336
  for (const rec of records) {
334
337
  if (rec.responseId && seenResponseIds.has(rec.responseId)) continue;
335
338
  if (rec.responseId) seenResponseIds.add(rec.responseId);
336
- if (!rec.timestamp || isNaN(rec.timestamp.getTime())) continue;
339
+ // Gemini 3.7 CLI blobs dropped chatStartMetadata.createdAt (9.4.1)
340
+ // and modelDisplayName (21). Usage is still in field 4; clock is
341
+ // recovered from steps.metadata at the same idx.
342
+ const timestamp = resolveUsageTimestamp(rec, stepTimestampsByIdx);
343
+ if (!timestamp || isNaN(timestamp.getTime())) continue;
337
344
  entries.push({
338
345
  source: SOURCE,
339
346
  model: modelFromRecord(rec),
340
347
  project,
341
- timestamp: rec.timestamp,
348
+ timestamp,
342
349
  inputTokens: toSafeNumber(rec.inputTokens),
343
350
  outputTokens: toSafeNumber(rec.outputTokens),
344
351
  cachedInputTokens: toSafeNumber(rec.cacheReadTokens),
@@ -0,0 +1,156 @@
1
+ import { findCindyDbPaths } from '../cindy-roots.js';
2
+ import { aggregateToBuckets } from './aggregate.js';
3
+ import { toCount } from './fs-utils.js';
4
+ import {
5
+ isSqliteUnavailableError,
6
+ queryDbJsonSnapshot,
7
+ sqliteUnavailableError,
8
+ } from './sqlite.js';
9
+
10
+ const CINDY_USAGE_SQL = `
11
+ SELECT
12
+ day,
13
+ agent_kind AS agentKind,
14
+ model,
15
+ SUM(input_tokens) AS inputTokens,
16
+ SUM(output_tokens) AS outputTokens,
17
+ SUM(cache_read_tokens) AS cacheReadTokens,
18
+ SUM(cache_create_tokens) AS cacheCreateTokens
19
+ FROM daily_model_usage
20
+ GROUP BY day, agent_kind, model
21
+ ORDER BY day, agent_kind, model
22
+ `;
23
+
24
+ /** Cindy stores its ledger day as local-time YYYY-MM-DD. */
25
+ export function dateFromCindyDay(value) {
26
+ if (typeof value !== 'string') return null;
27
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
28
+ if (!match) return null;
29
+ const year = Number(match[1]);
30
+ const month = Number(match[2]);
31
+ const day = Number(match[3]);
32
+ const date = new Date(year, month - 1, day);
33
+ if (
34
+ date.getFullYear() !== year
35
+ || date.getMonth() !== month - 1
36
+ || date.getDate() !== day
37
+ ) {
38
+ return null;
39
+ }
40
+ return date;
41
+ }
42
+
43
+ function skippedResult(error) {
44
+ const message = error?.message || String(error);
45
+ let reason = 'read failed';
46
+ if (/database is locked/i.test(message)) reason = 'database is locked';
47
+ else if (/no such column/i.test(message)) reason = 'incompatible database schema';
48
+ else if (/unable to open|SQLITE_CANTOPEN/i.test(message)) reason = 'database unavailable';
49
+ return {
50
+ buckets: [],
51
+ sessions: [],
52
+ skipped: true,
53
+ warnings: [`cindy: cannot read usage database (${reason})`],
54
+ };
55
+ }
56
+
57
+ const CINDY_HARNESS_SOURCES = {
58
+ codex: 'codex',
59
+ pi: 'pi-coding-agent',
60
+ };
61
+
62
+ /**
63
+ * Read Cindy's daily ledger for a harness that does not already expose the
64
+ * same raw logs to Vibe Usage. Cindy's Claude Code SDK writes ordinary
65
+ * ~/.claude transcripts, so Claude stays owned by the claude-code parser and
66
+ * is deliberately absent from this map.
67
+ */
68
+ export function readCindyHarnessUsage(agentKind) {
69
+ const source = CINDY_HARNESS_SOURCES[agentKind];
70
+ if (!source) throw new TypeError(`Unsupported Cindy harness: ${agentKind}`);
71
+
72
+ const dbPaths = findCindyDbPaths();
73
+ if (dbPaths.length === 0) return { buckets: [], sessions: [] };
74
+
75
+ const rows = [];
76
+ for (const dbPath of dbPaths) {
77
+ try {
78
+ rows.push(...queryDbJsonSnapshot(dbPath, CINDY_USAGE_SQL, {
79
+ tempPrefix: 'vibe-usage-cindy-',
80
+ }));
81
+ } catch (error) {
82
+ if (isSqliteUnavailableError(error)) throw sqliteUnavailableError('Cindy');
83
+ // Cindy versions before the daily ledger was introduced have no usage
84
+ // rows to import. A second, current regional/account database may still
85
+ // be readable, so skip only this legacy database.
86
+ if (/no such table:\s*daily_model_usage/i.test(error?.message || '')) continue;
87
+ return skippedResult(error);
88
+ }
89
+ }
90
+
91
+ const entries = [];
92
+ for (const row of rows) {
93
+ if (row.agentKind !== agentKind) continue;
94
+ const timestamp = dateFromCindyDay(row.day);
95
+ if (!timestamp) continue;
96
+ const inputTokens = toCount(row.inputTokens) + toCount(row.cacheCreateTokens);
97
+ const outputTokens = toCount(row.outputTokens);
98
+ const cachedInputTokens = toCount(row.cacheReadTokens);
99
+ if (inputTokens + outputTokens + cachedInputTokens === 0) continue;
100
+
101
+ entries.push({
102
+ source,
103
+ model: typeof row.model === 'string' && row.model.trim()
104
+ ? row.model.trim()
105
+ : `${source}-unknown`,
106
+ project: 'unknown',
107
+ timestamp,
108
+ inputTokens,
109
+ outputTokens,
110
+ cachedInputTokens,
111
+ reasoningOutputTokens: 0,
112
+ });
113
+ }
114
+
115
+ return {
116
+ buckets: aggregateToBuckets(entries),
117
+ sessions: [],
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Merge the optional Cindy ledger into the native harness snapshot. A failed
123
+ * Cindy read marks the whole source skipped so its previously uploaded rows
124
+ * are not pruned from incremental state.
125
+ */
126
+ export function mergeCindyHarnessUsage(nativeResult, cindyResult) {
127
+ const warnings = [
128
+ ...(nativeResult.warnings || []),
129
+ ...(cindyResult.warnings || []),
130
+ ];
131
+ if (nativeResult.skipped || cindyResult.skipped) {
132
+ return {
133
+ ...nativeResult,
134
+ buckets: [],
135
+ sessions: [],
136
+ skipped: true,
137
+ warnings,
138
+ };
139
+ }
140
+
141
+ const entries = [];
142
+ for (const bucket of [...nativeResult.buckets, ...cindyResult.buckets]) {
143
+ const timestamp = new Date(bucket.bucketStart);
144
+ if (Number.isNaN(timestamp.getTime())) continue;
145
+ entries.push({
146
+ ...bucket,
147
+ timestamp,
148
+ });
149
+ }
150
+ return {
151
+ ...nativeResult,
152
+ buckets: aggregateToBuckets(entries),
153
+ sessions: nativeResult.sessions || [],
154
+ warnings,
155
+ };
156
+ }
@@ -11,6 +11,7 @@ import { join } from 'node:path';
11
11
  import { createInterface } from 'node:readline';
12
12
  import { createHash } from 'node:crypto';
13
13
  import { aggregateToBuckets } from './aggregate.js';
14
+ import { mergeCindyHarnessUsage, readCindyHarnessUsage } from './cindy-ledger.js';
14
15
  import {
15
16
  codexSessionDirs,
16
17
  resolveCodexHomes,
@@ -796,7 +797,7 @@ function mergeFileResults(results) {
796
797
  return { buckets: aggregateToBuckets(entries), sessions };
797
798
  }
798
799
 
799
- export async function parse({ codexExtraHome } = {}) {
800
+ async function parseNativeCodex({ codexExtraHome } = {}) {
800
801
  if (codexExtraHome?.trim()) {
801
802
  const validation = validateExtraCodexHome(codexExtraHome);
802
803
  if (!validation.ok) {
@@ -1019,3 +1020,8 @@ export async function parse({ codexExtraHome } = {}) {
1019
1020
 
1020
1021
  return { ...mergeFileResults(results), cache: cacheStats };
1021
1022
  }
1023
+
1024
+ export async function parse(options = {}) {
1025
+ const nativeResult = await parseNativeCodex(options);
1026
+ return mergeCindyHarnessUsage(nativeResult, readCindyHarnessUsage('codex'));
1027
+ }
@@ -1,10 +1,12 @@
1
1
  import { getPiSessionDirs } from '../pi-roots.js';
2
+ import { mergeCindyHarnessUsage, readCindyHarnessUsage } from './cindy-ledger.js';
2
3
  import { parsePiSessionJsonl } from './pi-session-jsonl.js';
3
4
 
4
5
  /** Parse the official Pi agent's Pi-compatible JSONL sessions. */
5
6
  export async function parse() {
6
- return parsePiSessionJsonl({
7
+ const nativeResult = await parsePiSessionJsonl({
7
8
  source: 'pi-coding-agent',
8
9
  sessionsDirs: getPiSessionDirs(),
9
10
  });
11
+ return mergeCindyHarnessUsage(nativeResult, readCindyHarnessUsage('pi'));
10
12
  }
@@ -17,8 +17,12 @@ const require = createRequire(import.meta.url);
17
17
  * If neither is available, throws an Error whose message contains "ENOENT" so
18
18
  * callers can surface an "Install sqlite3" hint, matching the previous behavior.
19
19
  */
20
- export function queryDbJson(dbPath, sql, { timeout = 30000, maxBuffer = 100 * 1024 * 1024 } = {}) {
21
- const db = openNodeSqlite(dbPath);
20
+ export function queryDbJson(
21
+ dbPath,
22
+ sql,
23
+ { timeout = 30000, maxBuffer = 100 * 1024 * 1024, readOnly = true } = {},
24
+ ) {
25
+ const db = openNodeSqlite(dbPath, readOnly);
22
26
  if (db) {
23
27
  try {
24
28
  return db.prepare(sql).all();
@@ -55,12 +59,22 @@ function getNodeSqlite() {
55
59
  return nodeSqlite;
56
60
  }
57
61
 
58
- function openNodeSqlite(dbPath) {
62
+ function openNodeSqlite(dbPath, readOnly = true) {
59
63
  const mod = getNodeSqlite();
60
64
  if (!mod || !mod.DatabaseSync) return null;
65
+ let db;
61
66
  try {
62
- return new mod.DatabaseSync(dbPath, { readOnly: true });
67
+ db = new mod.DatabaseSync(dbPath, { readOnly });
68
+ // Writable access is used only for disposable snapshots whose WAL metadata
69
+ // may need initialization. Keep the SQL connection itself read-only.
70
+ if (!readOnly) db.exec('PRAGMA query_only = ON');
71
+ return db;
63
72
  } catch {
73
+ try {
74
+ db?.close();
75
+ } catch {
76
+ // Ignore cleanup failure while falling back to the sqlite3 CLI.
77
+ }
64
78
  return null;
65
79
  }
66
80
  }
@@ -94,6 +108,31 @@ export function isLockError(err) {
94
108
  return !!err && typeof err.message === 'string' && /database is locked/i.test(err.message);
95
109
  }
96
110
 
111
+ function querySnapshot(dbPath, sql, { tempPrefix, opts } = {}) {
112
+ const snapshotDir = mkdtempSync(join(tmpdir(), tempPrefix || 'vibe-usage-sqlite-'));
113
+ const queryPath = join(snapshotDir, basename(dbPath));
114
+ try {
115
+ copyFileSync(dbPath, queryPath);
116
+ for (const suffix of ['-shm', '-wal']) {
117
+ const companion = `${dbPath}${suffix}`;
118
+ if (existsSync(companion)) copyFileSync(companion, `${queryPath}${suffix}`);
119
+ }
120
+ return queryDbJson(queryPath, sql, { ...opts, readOnly: false });
121
+ } finally {
122
+ rmSync(snapshotDir, { recursive: true, force: true });
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Query a disposable writable snapshot. Some WAL-mode databases cannot be
128
+ * opened read-only until SQLite has initialized their shared-memory metadata;
129
+ * doing that only in the temp copy keeps the source application database
130
+ * untouched and works without a sqlite3 binary on Node >= 22.5.
131
+ */
132
+ export function queryDbJsonSnapshot(dbPath, sql, options = {}) {
133
+ return querySnapshot(dbPath, sql, options);
134
+ }
135
+
97
136
  /**
98
137
  * Run a query, and if the source app holds a write lock on the database, copy
99
138
  * the DB (plus its -wal/-shm companions) to a temp dir and re-query the
@@ -104,17 +143,6 @@ export function queryDbJsonSnapshotOnLock(dbPath, sql, { tempPrefix = 'vibe-usag
104
143
  return queryDbJson(dbPath, sql, opts);
105
144
  } catch (err) {
106
145
  if (!isLockError(err)) throw err;
107
- const snapshotDir = mkdtempSync(join(tmpdir(), tempPrefix));
108
- const queryPath = join(snapshotDir, basename(dbPath));
109
- try {
110
- copyFileSync(dbPath, queryPath);
111
- for (const suffix of ['-shm', '-wal']) {
112
- const companion = `${dbPath}${suffix}`;
113
- if (existsSync(companion)) copyFileSync(companion, `${queryPath}${suffix}`);
114
- }
115
- return queryDbJson(queryPath, sql, opts);
116
- } finally {
117
- rmSync(snapshotDir, { recursive: true, force: true });
118
- }
146
+ return querySnapshot(dbPath, sql, { tempPrefix, opts });
119
147
  }
120
148
  }
@@ -1,22 +1,86 @@
1
- import { readFileSync, readdirSync } from 'node:fs';
1
+ import { createReadStream, existsSync, readdirSync } from 'node:fs';
2
+ import { createInterface } from 'node:readline';
2
3
  import { join } from 'node:path';
3
4
  import { findTraeCliDataDirs } from '../tools.js';
4
5
  import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
6
  import { readJsonSafe, projectFromPath } from './fs-utils.js';
6
7
 
7
- function parseJsonlSafe(path) {
8
+ // Trae writes each LLM call as several nested spans that share one session-level
9
+ // traceID and copy the same usage onto every layer:
10
+ // model.stream.eino (authoritative: includes reasoning tokens)
11
+ // model.real_call (duplicate)
12
+ // model.call (duplicate)
13
+ // model.generate is a separate failover call (different model), not a duplicate.
14
+ // Counting every layer would 3x; merging by traceID with max() collapses a
15
+ // whole session of sequential calls into a single request. Keep one unique
16
+ // layer per call, then SUM.
17
+ const PRIMARY_LLM_CATEGORY = 'model.stream.eino';
18
+ const FAILOVER_LLM_CATEGORY = 'model.generate';
19
+ const FALLBACK_LLM_CATEGORIES = ['model.real_call', 'model.call'];
20
+
21
+ function tagMapFrom(tags) {
22
+ const tagMap = {};
23
+ if (!Array.isArray(tags)) return tagMap;
24
+ for (const t of tags) {
25
+ if (t && typeof t === 'object' && t.key) tagMap[t.key] = t.value;
26
+ }
27
+ return tagMap;
28
+ }
29
+
30
+ function spanCategory(tagMap) {
31
+ return typeof tagMap['span.category'] === 'string' ? tagMap['span.category'] : '';
32
+ }
33
+
34
+ function usageFromTagMap(tagMap) {
35
+ const inputTokens = Math.max(0, Number(tagMap['usage.input_tokens']) || 0);
36
+ const outputTokens = Math.max(0, Number(tagMap['usage.output_tokens']) || 0);
37
+ const cacheReadTokens = Math.max(0, Number(tagMap['usage.cache_read_tokens']) || 0);
38
+ const reasoningTokens = Math.max(0, Number(tagMap['usage.reasoning_tokens']) || 0);
39
+ return { inputTokens, outputTokens, cacheReadTokens, reasoningTokens };
40
+ }
41
+
42
+ function hasUsage(usage) {
43
+ return usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.reasoningTokens > 0;
44
+ }
45
+
46
+ /**
47
+ * Pick the unique LLM-call spans from a session's traces.
48
+ * Prefer model.stream.eino (+ model.generate failovers). If a session has no
49
+ * primary layer (older traces), fall back to model.real_call, then model.call.
50
+ * @param {{category: string, usage: object, model: string|null, startTime: number}[]} spans
51
+ */
52
+ export function selectTraeUsageSpans(spans) {
53
+ const withUsage = spans.filter((s) => hasUsage(s.usage));
54
+ const primary = withUsage.filter((s) => s.category === PRIMARY_LLM_CATEGORY);
55
+ const failover = withUsage.filter((s) => s.category === FAILOVER_LLM_CATEGORY);
56
+ if (primary.length > 0 || failover.length > 0) return primary.concat(failover);
57
+ for (const cat of FALLBACK_LLM_CATEGORIES) {
58
+ const subset = withUsage.filter((s) => s.category === cat);
59
+ if (subset.length > 0) return subset;
60
+ }
61
+ return withUsage;
62
+ }
63
+
64
+ /** Stream a JSONL file line by line. Skips missing files and malformed lines. */
65
+ export async function forEachJsonl(path, onObj) {
66
+ if (!existsSync(path)) return;
67
+ const stream = createReadStream(path, { encoding: 'utf8' });
68
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
8
69
  try {
9
- const content = readFileSync(path, 'utf-8');
10
- return content
11
- .split('\n')
12
- .map(line => line.trim())
13
- .filter(line => line.length > 0)
14
- .map(line => {
15
- try { return JSON.parse(line); } catch { return null; }
16
- })
17
- .filter(Boolean);
18
- } catch {
19
- return [];
70
+ for await (const line of lines) {
71
+ const trimmed = line.trim();
72
+ if (!trimmed) continue;
73
+ let obj;
74
+ try {
75
+ obj = JSON.parse(trimmed);
76
+ } catch {
77
+ continue;
78
+ }
79
+ if (obj && typeof obj === 'object') onObj(obj);
80
+ }
81
+ } finally {
82
+ lines.close();
83
+ stream.destroy();
20
84
  }
21
85
  }
22
86
 
@@ -43,79 +107,41 @@ export async function parse() {
43
107
  const project = projectFromPath(sessionJson.metadata?.cwd);
44
108
  const fallbackModel = sessionJson.metadata?.model_name || 'trae-unknown';
45
109
 
46
- // 1. Parse traces.jsonl for token usage
47
- const traceLines = parseJsonlSafe(join(sessionPath, 'traces.jsonl'));
48
- const tracesMap = new Map();
49
-
50
- for (const line of traceLines) {
51
- if (!line.traceID) continue;
52
- const tags = Array.isArray(line.tags) ? line.tags : [];
53
- const tagMap = {};
54
- for (const t of tags) {
55
- if (t && typeof t === 'object' && t.key) {
56
- tagMap[t.key] = t.value;
57
- }
58
- }
59
-
60
- const model = tagMap['model.name'] || tagMap['semantic.name'] || null;
61
- const inputTokens = Math.max(0, Number(tagMap['usage.input_tokens']) || 0);
62
- const outputTokens = Math.max(0, Number(tagMap['usage.output_tokens']) || 0);
63
- const cacheReadTokens = Math.max(0, Number(tagMap['usage.cache_read_tokens']) || 0);
64
- const reasoningTokens = Math.max(0, Number(tagMap['usage.reasoning_tokens']) || 0);
65
-
66
- if (inputTokens + outputTokens + cacheReadTokens + reasoningTokens === 0) {
67
- continue;
68
- }
69
-
70
- if (!tracesMap.has(line.traceID)) {
71
- tracesMap.set(line.traceID, {
72
- model,
73
- inputTokens,
74
- outputTokens,
75
- cacheReadTokens,
76
- reasoningTokens,
77
- startTime: Number(line.startTime) || 0,
78
- });
79
- } else {
80
- // Merge spans under the same traceID by selecting the max values
81
- const existing = tracesMap.get(line.traceID);
82
- if (model) {
83
- existing.model = model;
84
- }
85
- existing.inputTokens = Math.max(existing.inputTokens, inputTokens);
86
- existing.outputTokens = Math.max(existing.outputTokens, outputTokens);
87
- existing.cacheReadTokens = Math.max(existing.cacheReadTokens, cacheReadTokens);
88
- existing.reasoningTokens = Math.max(existing.reasoningTokens, reasoningTokens);
89
- if (line.startTime) {
90
- existing.startTime = existing.startTime ? Math.min(existing.startTime, Number(line.startTime)) : Number(line.startTime);
91
- }
92
- }
93
- }
110
+ const spans = [];
111
+ await forEachJsonl(join(sessionPath, 'traces.jsonl'), (line) => {
112
+ const tagMap = tagMapFrom(line.tags);
113
+ const usage = usageFromTagMap(tagMap);
114
+ if (!hasUsage(usage)) return;
115
+ const startTime = Number(line.startTime);
116
+ if (!Number.isFinite(startTime) || startTime <= 0) return;
117
+ spans.push({
118
+ category: spanCategory(tagMap),
119
+ model: tagMap['model.name'] || tagMap['semantic.name'] || null,
120
+ startTime,
121
+ usage,
122
+ });
123
+ });
94
124
 
95
- // Convert trace map to vibe-usage entries
96
- for (const trace of tracesMap.values()) {
97
- // Convert microsecond startTime to milliseconds for Date constructor
98
- const startTime = Number(trace.startTime);
99
- if (!Number.isFinite(startTime) || startTime <= 0) continue;
100
- const timestamp = new Date(startTime / 1000);
125
+ for (const span of selectTraeUsageSpans(spans)) {
126
+ // Trae startTime is microseconds; Date expects milliseconds.
127
+ const timestamp = new Date(span.startTime / 1000);
128
+ if (Number.isNaN(timestamp.getTime())) continue;
101
129
  entries.push({
102
130
  source: 'trae-cli',
103
- model: trace.model || fallbackModel,
131
+ model: span.model || fallbackModel,
104
132
  project,
105
133
  timestamp,
106
- inputTokens: trace.inputTokens,
107
- outputTokens: trace.outputTokens,
108
- cachedInputTokens: trace.cacheReadTokens,
109
- reasoningOutputTokens: trace.reasoningTokens,
134
+ inputTokens: span.usage.inputTokens,
135
+ outputTokens: span.usage.outputTokens,
136
+ cachedInputTokens: span.usage.cacheReadTokens,
137
+ reasoningOutputTokens: span.usage.reasoningTokens,
110
138
  });
111
139
  }
112
140
 
113
- // 2. Parse events.jsonl for user and assistant timings
114
- const eventLines = parseJsonlSafe(join(sessionPath, 'events.jsonl'));
115
- for (const line of eventLines) {
116
- if (!line.created_at) continue;
141
+ await forEachJsonl(join(sessionPath, 'events.jsonl'), (line) => {
142
+ if (!line.created_at) return;
117
143
  const timestamp = new Date(line.created_at);
118
- if (Number.isNaN(timestamp.getTime())) continue;
144
+ if (Number.isNaN(timestamp.getTime())) return;
119
145
 
120
146
  if (line.agent_start) {
121
147
  events.push({
@@ -134,7 +160,7 @@ export async function parse() {
134
160
  role: 'assistant',
135
161
  });
136
162
  }
137
- }
163
+ });
138
164
  }
139
165
  }
140
166
 
package/src/reset.js CHANGED
@@ -1,8 +1,7 @@
1
1
  import { createInterface } from 'node:readline';
2
- import { hostname as getHostname } from 'node:os';
3
- import { loadConfig } from './config.js';
2
+ import { loadConfig, saveConfig } from './config.js';
4
3
  import { deleteAllData } from './api.js';
5
- import { runSync } from './sync.js';
4
+ import { resolveSyncHostname, runSync } from './sync.js';
6
5
  import { clearState } from './state.js';
7
6
  import { success, failure, arrow, link, dim } from './output.js';
8
7
 
@@ -34,10 +33,18 @@ export async function runReset(args = [], deps = {}) {
34
33
  process.exit(1);
35
34
  }
36
35
 
37
- // Target the hostname persisted at init — the same one sync.js uploads
38
- // under. A fresh os.hostname() can have drifted since (macOS mDNS adds -2
39
- // suffixes), which would delete zero rows, or another machine's rows.
40
- const currentHost = config.hostname || getHostname().replace(/\.local$/, '');
36
+ // Target the exact privacy-safe identity sync.js uploads under. This keeps
37
+ // `reset --local` aligned with both the stable configured hostname and the
38
+ // anonymous per-install device id used when hostname upload is disabled.
39
+ let hostIdentity;
40
+ try {
41
+ hostIdentity = resolveSyncHostname(config);
42
+ if (hostIdentity.changed) saveConfig(config);
43
+ } catch (err) {
44
+ console.error(failure(err.message));
45
+ process.exit(1);
46
+ }
47
+ const currentHost = hostIdentity.hostname;
41
48
  const apiUrl = config.apiUrl || 'https://vibecafe.ai';
42
49
 
43
50
  if (hostOnly) {
package/src/sync.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { hostname as osHostname } from 'node:os';
2
+ import { randomBytes } from 'node:crypto';
2
3
  import { loadConfig, saveConfig } from './config.js';
3
4
  import {
4
5
  loadState, saveState, pruneState,
@@ -14,13 +15,83 @@ import { success, failure, warn, arrow, link, dim } from './output.js';
14
15
  const BATCH_SIZE = 100;
15
16
  const SESSION_BATCH_SIZE = 500;
16
17
 
18
+ const ANONYMOUS_DEVICE_ID_PATTERN = /^device-[0-9a-f]{16}$/;
19
+ const SHARED_HOSTNAME_SENTINELS = new Set(['cursor-cloud']);
20
+
21
+ export function resolveOptionalBoolean(value, key) {
22
+ if (value === undefined) return undefined;
23
+ if (typeof value === 'boolean') return value;
24
+ const error = new Error(`配置 ${key} 必须是 true 或 false。`);
25
+ error.code = 'INVALID_CONFIG';
26
+ throw error;
27
+ }
28
+
29
+ export function resolveSyncHostname(config, {
30
+ systemHostname = () => osHostname().replace(/\.local$/, ''),
31
+ createDeviceId = () => `device-${randomBytes(8).toString('hex')}`,
32
+ } = {}) {
33
+ const uploadHostname = resolveOptionalBoolean(config.uploadHostname, 'uploadHostname') ?? true;
34
+ const previousHostname = typeof config.hostname === 'string' && config.hostname.trim()
35
+ ? config.hostname.trim()
36
+ : undefined;
37
+
38
+ if (!uploadHostname) {
39
+ const existingDeviceId = typeof config.deviceId === 'string'
40
+ ? config.deviceId.trim().toLowerCase()
41
+ : '';
42
+ const hostname = ANONYMOUS_DEVICE_ID_PATTERN.test(existingDeviceId)
43
+ ? existingDeviceId
44
+ : createDeviceId();
45
+ const changed = config.deviceId !== hostname;
46
+ if (changed) config.deviceId = hostname;
47
+ return { hostname, previousHostname, uploadHostname, changed };
48
+ }
49
+
50
+ const hostname = previousHostname || systemHostname();
51
+ const changed = config.hostname !== hostname;
52
+ if (changed) config.hostname = hostname;
53
+ return { hostname, previousHostname, uploadHostname, changed };
54
+ }
55
+
56
+ export function applyHostnamePrivacy(records, hostname, uploadHostname) {
57
+ for (const record of records) {
58
+ if (uploadHostname) {
59
+ if (!record.hostname) record.hostname = hostname;
60
+ } else if (!SHARED_HOSTNAME_SENTINELS.has(record.hostname)) {
61
+ record.hostname = hostname;
62
+ }
63
+ }
64
+ }
65
+
66
+ // A hostname privacy toggle changes the server bucket key. Carry unchanged
67
+ // local state across that key change so enabling privacy does not re-upload
68
+ // all historical buckets beside their older server rows. Changed buckets still
69
+ // upload under the anonymous id; `reset` remains the explicit way to remove
70
+ // identifiers that were uploaded before the local control was enabled.
71
+ export function migrateHiddenHostnameState(state, buckets, previousHostname, hostname) {
72
+ if (!previousHostname || previousHostname === hostname) return false;
73
+ let changed = false;
74
+ for (const bucket of buckets) {
75
+ if (bucket.hostname !== hostname) continue;
76
+ const oldKey = bucketKey({ ...bucket, hostname: previousHostname });
77
+ const newKey = bucketKey(bucket);
78
+ const currentHash = bucketHash(bucket);
79
+ if (state.buckets[oldKey] !== currentHash) continue;
80
+ if (!(newKey in state.buckets)) state.buckets[newKey] = currentHash;
81
+ delete state.buckets[oldKey];
82
+ changed = true;
83
+ }
84
+ return changed;
85
+ }
86
+
17
87
  function formatBytes(bytes) {
18
88
  if (bytes < 1024) return `${bytes}B`;
19
89
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
20
90
  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
21
91
  }
22
92
 
23
- export function resolveUploadProjectSetting(settings) {
93
+ export function resolveUploadProjectSetting(settings, localSetting) {
94
+ if (localSetting === false) return false;
24
95
  if (typeof settings?.uploadProject !== 'boolean') {
25
96
  const error = new Error('SETTINGS_UNAVAILABLE');
26
97
  error.code = 'SETTINGS_UNAVAILABLE';
@@ -90,6 +161,18 @@ export async function runSync({
90
161
  saveConfig(config);
91
162
  }
92
163
 
164
+ let localUploadProject;
165
+ let hostIdentity;
166
+ try {
167
+ localUploadProject = resolveOptionalBoolean(config.uploadProject, 'uploadProject');
168
+ hostIdentity = resolveSyncHostname(config);
169
+ if (hostIdentity.changed) saveConfig(config);
170
+ } catch (err) {
171
+ console.error(failure(err.message));
172
+ if (throws) throw err;
173
+ process.exit(1);
174
+ }
175
+
93
176
  // Privacy is a required input, not an optional hint. If the settings API is
94
177
  // unavailable, treating it as `false` changes every project-bearing item's
95
178
  // incremental identity to `unknown` and can trigger a full-history upload.
@@ -97,36 +180,45 @@ export async function runSync({
97
180
  // no-op: no data upload and no state mutation.
98
181
  const apiUrl = config.apiUrl || 'https://vibecafe.ai';
99
182
  let uploadProject;
100
- try {
101
- const settings = await fetchSettings(apiUrl, config.apiKey);
102
- uploadProject = resolveUploadProjectSetting(settings);
103
- // Scope the cached privacy choice to the server that returned it. Reusing
104
- // the value after `apiUrl` changes could expose project names to a
105
- // different server during its first settings outage.
106
- if (
107
- config.lastUploadProject !== uploadProject
108
- || config.lastUploadProjectApiUrl !== apiUrl
109
- ) {
110
- config.lastUploadProject = uploadProject;
111
- config.lastUploadProjectApiUrl = apiUrl;
112
- saveConfig(config);
113
- }
114
- } catch (err) {
115
- if (err.message === 'UNAUTHORIZED') {
116
- console.error(failure('API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。'));
117
- if (throws) throw err;
118
- process.exit(1);
119
- }
120
- // Settings endpoint unreachable (not auth): degrade to the last confirmed
121
- // choice for this same server rather than hard-aborting every upload.
122
- const cachedUploadProject = resolveCachedUploadProjectSetting(config, apiUrl);
123
- if (typeof cachedUploadProject === 'boolean') {
124
- uploadProject = cachedUploadProject;
125
- if (!quiet) console.log(warn('设置接口不可用,沿用上次的项目名设置。'));
126
- } else {
127
- console.error(failure('暂时无法读取上传设置,本次同步已安全取消(未上传数据)。请稍后重试。'));
128
- if (throws) throw err;
129
- process.exit(1);
183
+ if (localUploadProject === false) {
184
+ // A local deny is authoritative and needs no server round trip. This is
185
+ // both fail-closed and usable when the settings endpoint is unavailable.
186
+ uploadProject = false;
187
+ } else {
188
+ try {
189
+ const settings = await fetchSettings(apiUrl, config.apiKey);
190
+ uploadProject = resolveUploadProjectSetting(settings, localUploadProject);
191
+ // Scope the cached privacy choice to the server that returned it. Reusing
192
+ // the value after `apiUrl` changes could expose project names to a
193
+ // different server during its first settings outage.
194
+ if (
195
+ config.lastUploadProject !== uploadProject
196
+ || config.lastUploadProjectApiUrl !== apiUrl
197
+ ) {
198
+ config.lastUploadProject = uploadProject;
199
+ config.lastUploadProjectApiUrl = apiUrl;
200
+ saveConfig(config);
201
+ }
202
+ } catch (err) {
203
+ if (err.message === 'UNAUTHORIZED') {
204
+ console.error(failure('API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。'));
205
+ if (throws) throw err;
206
+ process.exit(1);
207
+ }
208
+ // Settings endpoint unreachable (not auth): degrade to the last confirmed
209
+ // choice for this same server rather than hard-aborting every upload.
210
+ const cachedUploadProject = resolveCachedUploadProjectSetting(config, apiUrl);
211
+ if (typeof cachedUploadProject === 'boolean') {
212
+ uploadProject = resolveUploadProjectSetting(
213
+ { uploadProject: cachedUploadProject },
214
+ localUploadProject,
215
+ );
216
+ if (!quiet) console.log(warn('设置接口不可用,沿用上次的项目名设置。'));
217
+ } else {
218
+ console.error(failure('暂时无法读取上传设置,本次同步已安全取消(未上传数据)。请稍后重试。'));
219
+ if (throws) throw err;
220
+ process.exit(1);
221
+ }
130
222
  }
131
223
  }
132
224
 
@@ -221,23 +313,24 @@ export async function runSync({
221
313
  }
222
314
  }
223
315
 
224
- let host = config.hostname;
225
- if (!host) {
226
- host = osHostname().replace(/\.local$/, '');
227
- config.hostname = host;
228
- saveConfig(config);
229
- }
230
- // Cloud-sourced parsers (e.g. cursor) pre-set their own hostname sentinel so
231
- // the same account data isn't stored as separate rows per machine.
232
- for (const b of allBuckets) if (!b.hostname) b.hostname = host;
233
- for (const s of allSessions) if (!s.hostname) s.hostname = host;
316
+ const host = hostIdentity.hostname;
317
+ // Cloud-backed parsers use explicit non-identifying sentinels (currently
318
+ // `cursor-cloud`) so the same account data deduplicates across computers.
319
+ // Every other hostname is assigned here, at the final network boundary.
320
+ applyHostnamePrivacy(allBuckets, host, hostIdentity.uploadHostname);
321
+ applyHostnamePrivacy(allSessions, host, hostIdentity.uploadHostname);
234
322
 
235
323
  if (!quiet) {
236
324
  if (uploadProject) {
237
- console.log(dim(' 项目名: 上传(可在 Web 设置中关闭)'));
325
+ console.log(dim(' 项目名: 上传(本机或 Web 设置均可关闭)'));
238
326
  } else {
239
327
  console.log(dim(' 项目名: 已隐藏'));
240
328
  }
329
+ console.log(dim(
330
+ hostIdentity.uploadHostname
331
+ ? ' 设备名: 上传'
332
+ : ` 设备名: 已替换为匿名 ID (${host})`,
333
+ ));
241
334
  }
242
335
  if (!uploadProject) {
243
336
  for (const b of allBuckets) b.project = 'unknown';
@@ -253,6 +346,13 @@ export async function runSync({
253
346
  // Missing/corrupt state.json => empty maps => one-time full upload, then
254
347
  // incremental forever after.
255
348
  const state = loadState();
349
+ const migratedHostnameState = !hostIdentity.uploadHostname
350
+ && migrateHiddenHostnameState(
351
+ state,
352
+ allBuckets,
353
+ hostIdentity.previousHostname,
354
+ host,
355
+ );
256
356
  const changedBuckets = [];
257
357
  const changedSessions = [];
258
358
  const liveBucketKeys = new Set();
@@ -290,7 +390,7 @@ export async function runSync({
290
390
  const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
291
391
  pruneState(state, liveBucketKeys, liveSessionKeys, okSources);
292
392
  const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
293
- if (pruned > 0) saveState(state);
393
+ if (pruned > 0 || migratedHostnameState) saveState(state);
294
394
 
295
395
  if (changedBuckets.length === 0 && changedSessions.length === 0) {
296
396
  if (!quiet) console.log(dim('无新增数据。'));
package/src/tools.js CHANGED
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, statSync } from 'node:fs';
2
2
  import { isAbsolute, join, posix, resolve, win32 } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { findClaudeCodeDataDirs } from './claude-roots.js';
5
+ import { findCindyDataDirs, getCindyDataRoots } from './cindy-roots.js';
5
6
  import { codexSessionDirs, resolveCodexHomes } from './codex-roots.js';
6
7
  import { findClineDataDirs } from './cline-roots.js';
7
8
  import { findCraftDataDirs } from './craft-roots.js';
@@ -223,6 +224,12 @@ export const TOOLS = [
223
224
  dataDir: getAlmaDbPath(),
224
225
  detectDataDirs: () => [getAlmaDbPath()].filter(existsSync),
225
226
  },
227
+ {
228
+ name: 'Cindy',
229
+ id: 'cindy',
230
+ dataDir: getCindyDataRoots()[0],
231
+ detectDataDirs: findCindyDataDirs,
232
+ },
226
233
  {
227
234
  name: 'Claude Code',
228
235
  id: 'claude-code',