@vibe-cafe/vibe-usage 0.10.29 → 0.10.31

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
@@ -38,7 +38,7 @@ npx @vibe-cafe/vibe-usage init # Re-run setup via browser login (also ho
38
38
  npx @vibe-cafe/vibe-usage init --manual-key <vbu_...> # Skip browser, use pre-issued key (CI/headless)
39
39
  npx @vibe-cafe/vibe-usage sync # Manual sync
40
40
  npx @vibe-cafe/vibe-usage sync --extra-codex-home /path/to/.codex # Add another Codex Home for this run only
41
- npx @vibe-cafe/vibe-usage summary # Print last 7 days as markdown (cost / tokens / by model / by project)
41
+ npx @vibe-cafe/vibe-usage summary # Print last 7 days as markdown (cost / tokens / by tool / by model / by project)
42
42
  npx @vibe-cafe/vibe-usage summary --days N # Same, over the last N days (1-90)
43
43
  npx @vibe-cafe/vibe-usage daemon # Continuous sync (every 30m, foreground)
44
44
  npx @vibe-cafe/vibe-usage daemon install # Install background service (systemd/launchd/Task Scheduler)
@@ -63,7 +63,7 @@ npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short
63
63
  | 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. |
64
64
  | 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. |
65
65
  | 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. |
66
- | Codex CLI | `$CODEX_HOME/sessions/` and `$CODEX_HOME/archived_sessions/` (default `~/.codex`), plus an optional temporary `--extra-codex-home`, legacy `codexExtraHome`, or explicitly added Codex/Multica roots; 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 |
66
+ | Codex CLI | `$CODEX_HOME/sessions/` and `$CODEX_HOME/archived_sessions/` (default `~/.codex`), plus an optional temporary `--extra-codex-home`, legacy `codexExtraHome`, or explicitly added Codex/Multica roots; a versioned local index avoids re-reading unchanged rollouts and reads only safe append tails for ordinary sessions, same-session continuation files are combined without counting exact live/archive/cross-root copies twice, and fork/sub-agent replay remains excluded |
67
67
  | Cola | `~/.cola/sessions/<scope>/*.jsonl` (or `$COLA_DATA_DIR/sessions/`), verified with Cola 1.4.4. Reads assistant token usage and session timing through the shared Pi reader; cache writes join input, cache reads remain separate, and reasoning is split from output. Copied transcripts with new session headers are deduplicated using the original record metadata and attributed to the earliest available session copy. Project names come from `cwd`, never channel/scope names. |
68
68
  | Grok | `$GROK_HOME/sessions/<encoded-cwd>/<session-id>/` (default `~/.grok`) plus explicitly added Grok Homes; token usage from `updates.jsonl` `turn_completed.usage` (per-model `modelUsage`, cache reads, reasoning); project from `summary.json` cwd; copied sessions keep the more complete local record |
69
69
  | GitHub Copilot CLI | `~/.copilot/session-state/*/events.jsonl` |
@@ -71,7 +71,7 @@ npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short
71
71
  | Cursor | `state.vscdb` (SQLite, reads `cursorAuth/accessToken`, fetches CSV from `cursor.com`); cloud data is stamped with a fixed `cursor-cloud` hostname so multi-machine setups don't double-count |
72
72
  | DimAgent | `$DIMCODE_HOME/dimcode.sqlite` (default `~/.dimcode/v2/dimcode.sqlite`); exact usage from `usage_ledger`, with forked ledger/history copies deduplicated |
73
73
  | Gemini CLI | `~/.gemini/tmp/<project_hash>/chats/session-*.jsonl` (current line-delimited format) and legacy `session-*.json`; recurses into nested subagent sessions |
74
- | OpenCode | `~/.local/share/opencode/opencode.db` (SQLite, `json_extract` query) |
74
+ | OpenCode | `~/.local/share/opencode/opencode.db` (SQLite), with `storage/message/` as the legacy alternative; supports explicitly added data roots |
75
75
  | OpenClaw | `~/.openclaw/agents/`, `~/.openclaw-<profile>/agents/` (profile deployments); cache-creation/cache-write tokens are included in input usage |
76
76
  | Oh My Pi | `~/.omp/agent/sessions/`, `~/.omp/profiles/*/agent/sessions/`, and `$XDG_DATA_HOME/omp/{sessions,profiles/*/sessions}`; recognizes OMP's `$PI_CODING_AGENT_DIR`, current v3 title slots and path/hashed session directories, deduplicates copied records, includes cache writes in input, and splits reasoning from OMP's inclusive output count |
77
77
  | pi | `~/.pi/agent/sessions/` or `$PI_CODING_AGENT_DIR/sessions/`, plus the session directory Pi itself was pointed at via `PI_CODING_AGENT_SESSION_DIR` or `sessionDir` in `~/.pi/agent/settings.json`, plus explicitly added `pi-coding-agent` roots for stores only reachable through `pi --session <file>` (fixture/relocation override: `VIBE_USAGE_PI_SESSION_DIRS`). Cache writes are included in input usage; reasoning is read from Pi's `usage.reasoning` (legacy `usage.reasoningTokens` still accepted) and split out of the inclusive output total |
@@ -84,7 +84,7 @@ npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short
84
84
  | DeepSeek Harness | `$DSH_HOME/sessions/` (default `~/.dsh`, fixture/relocation override: `VIBE_USAGE_DSH_SESSIONS`). Reads V0–V3 logs, including `session.v3.jsonl.zstd` from DSH `0.1.5-alpha.2`, with multi-frame Zstandard support (Node ≥ 22.15 built-in, `zstd` CLI fallback) and plain JSONL support. Each session uses its highest `session[.vN].jsonl[.zstd]` generation once, so frozen pre-migration logs are not double-counted. Usage comes from `assistant/message`: cache writes join uncached input, cache reads remain separate, and reasoning is split out of inclusive output. Fork history uses V0/V1 `seedLength` or V2/V3's last `session/end-seed` tagged `inherited: true`, and is skipped only when the parent copy confirms it; missing parents retain the sole local history. Unknown versions warn and protect sync state. |
85
85
  | Hermes (CLI / Desktop) | `<home>/state.db` + `<home>/profiles/<name>/state.db` (SQLite, multi-profile). Home: `$HERMES_HOME`, otherwise `~/.hermes` on macOS/Linux or `%LOCALAPPDATA%\hermes` on Windows (falls back to an existing `~/.hermes` only when the Windows native root is absent). Cache writes join input; reasoning is separated from inclusive output. Usage is currently a cumulative session total attributed to session start: a session spanning several days does **not** yet provide an accurate daily breakdown. |
86
86
  | 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` |
87
- | 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 |
87
+ | Cline | Current CLI/SDK `~/.cline/data/sessions/*/*.messages.json` plus legacy `~/.cline/{,data/}state/taskHistory.json` and editor extension stores. Honors `CLINE_DIR`, `CLINE_DATA_DIR`, and `CLINE_SESSION_DATA_DIR`; copied history is deduplicated |
88
88
  | Roo Code | `<host>/User/globalStorage/rooveterinaryinc.roo-cline/{tasks/_index.json,tasks/<id>/{history_item,ui_messages}.json}` (walks all VSCode-fork hosts) |
89
89
  | 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. |
90
90
  | Antigravity | Scans App 2.0 `~/.gemini/antigravity/conversations/`, `agy` CLI `~/.gemini/antigravity-cli/conversations/`, and standalone IDE `~/.gemini/antigravity-ide/conversations/`. `.db` stores, including the same paths below explicitly added alternate Homes, are parsed offline (tokens, model, project, sessions). When Gemini blobs omit `chatStartMetadata.createdAt` or `modelDisplayName`, timestamps fall back to `steps.metadata` and model names to `responseModel`. `.pb` history in the default stores requires the corresponding App/IDE language server to be running; when several servers are open, the parser tries the others for unreadable conversations. Unavailable legacy history produces a warning and preserves prior sync state. |
@@ -100,7 +100,7 @@ npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short
100
100
  - 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.
101
101
  - Uploads buckets + sessions to your vibecafe.ai dashboard (always gzip-compressed, ~94% smaller)
102
102
  - 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
103
- - 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
103
+ - 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. Multi-file sessions have a combined cache that rebuilds when any segment changes. A bounded rolling audit occasionally re-reads one historical file or continuation group. Very large first-time indexes checkpoint before the Mac app timeout and resume on the next sync instead of restarting
104
104
  - The Codex parser cache contains derived aggregates and replay metadata, not raw prompt or response text. It is independent of upload state and can be deleted safely (the next sync rebuilds it). `reset` intentionally keeps it so the required full re-upload does not also require a full disk rescan. Set `VIBE_USAGE_CODEX_CACHE=0` to disable the optimization for diagnosis
105
105
  - SQLite-backed tools are read via Node's built-in `node:sqlite` on Node ≥ 22.5 — no `sqlite3` binary needed (works on Windows out of the box); on older Node the CLI falls back to the system `sqlite3` executable
106
106
  - Continuous syncing is on by default: the first run installs a background service (see [Background sync](#background-sync)); the [Vibe Usage Mac app](https://github.com/vibe-cafe/vibe-usage-app) is the menu-bar alternative
@@ -203,6 +203,13 @@ Add isolated runtime data without editing JSON by hand:
203
203
  # <workspace>/<task>/codex-home directories are at most three levels below it.
204
204
  npx @vibe-cafe/vibe-usage config add-root codex /path/to/multica-container
205
205
 
206
+ # Claude Code expects a .claude root containing projects/ or transcripts/.
207
+ # Use the source id claude-code (not claude).
208
+ npx @vibe-cafe/vibe-usage config add-root claude-code /mnt/c/Users/you/.claude
209
+
210
+ # OpenCode accepts a data root containing opencode.db or storage/message/.
211
+ npx @vibe-cafe/vibe-usage config add-root opencode /path/to/other/opencode
212
+
206
213
  # Grok expects a Grok Home containing sessions/.
207
214
  npx @vibe-cafe/vibe-usage config add-root grok /path/to/grok-home
208
215
 
@@ -221,6 +228,8 @@ npx @vibe-cafe/vibe-usage config remove-root grok /path/to/grok-home
221
228
 
222
229
  Default roots are always scanned and existing `codexExtraHome` configurations remain valid. Additional roots are only scanned after they are explicitly added. If a configured root later becomes unavailable, that tool is skipped for the current sync so its incremental upload state is not pruned.
223
230
 
231
+ For OpenCode, each root uses its SQLite database when present; only roots without a database use legacy JSON. Copied records across roots are counted once using session/message ids, keeping the most complete copy. A broken database is reported and preserves sync state instead of silently substituting potentially stale JSON. Existing model names take precedence; nested model fields are only a fallback when the old field is absent. Claude Code retains its existing session and request deduplication rules.
232
+
224
233
  ## Background sync
225
234
 
226
235
  The first `npx @vibe-cafe/vibe-usage` run installs a user-level service (systemd on Linux, launchd on macOS, Task Scheduler on Windows — no admin rights needed) that syncs every 30 minutes and starts automatically on login. Nothing else to do.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.29",
3
+ "version": "0.10.31",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -2,6 +2,8 @@ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
2
2
  import { delimiter, join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
 
5
+ import { validateExtraRoot } from './extra-roots.js';
6
+
5
7
  const MAX_DESKTOP_DISCOVERY_DEPTH = 8;
6
8
  const DESKTOP_NON_SESSION_DIRS = new Set(['rpm', 'skills']);
7
9
 
@@ -106,7 +108,7 @@ export function findClaudeDesktopRoots(
106
108
  * VIBE_USAGE_CLAUDE_DIRS is a test/diagnostic override. It replaces all normal
107
109
  * and Desktop discovery with a path.delimiter-separated root list.
108
110
  */
109
- export function getClaudeRoots({ onWarning = () => {} } = {}) {
111
+ export function getClaudeRoots({ onWarning = () => {}, extraRoots = [] } = {}) {
110
112
  const override = process.env.VIBE_USAGE_CLAUDE_DIRS?.trim();
111
113
  const roots = override
112
114
  ? override.split(delimiter).map(expandHome).filter(Boolean)
@@ -133,6 +135,13 @@ export function getClaudeRoots({ onWarning = () => {} } = {}) {
133
135
  }
134
136
  }
135
137
 
138
+ // Append explicit extra roots (e.g. another OS's .claude directory).
139
+ for (const root of extraRoots) {
140
+ const result = validateExtraRoot('claude-code', root);
141
+ if (!result.ok) onWarning(`Claude Code: 额外目录不可用 ${root}: ${result.reason}`);
142
+ if (!roots.includes(result.path)) roots.push(result.path);
143
+ }
144
+
136
145
  const seen = new Set();
137
146
  const unique = [];
138
147
  for (const root of roots) {
@@ -149,9 +158,9 @@ export function getClaudeRoots({ onWarning = () => {} } = {}) {
149
158
  return unique;
150
159
  }
151
160
 
152
- export function findClaudeCodeDataDirs() {
161
+ export function findClaudeCodeDataDirs(extraRoots = []) {
153
162
  const dirs = [];
154
- for (const root of getClaudeRoots()) {
163
+ for (const root of getClaudeRoots({ extraRoots })) {
155
164
  for (const name of ['projects', 'transcripts']) {
156
165
  const candidate = join(root, name);
157
166
  try {
@@ -1,18 +1,10 @@
1
- import { statSync } from 'node:fs';
1
+ import { realpathSync, statSync } from 'node:fs';
2
2
  import { delimiter, join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
 
5
5
  const EXTENSION_ID = 'saoudrizwan.claude-dev';
6
6
  const HOSTS = ['Code', 'Cursor', 'Windsurf', 'VSCodium', 'Code - Insiders', 'Trae', 'Trae CN'];
7
7
 
8
- function hasTaskHistory(root) {
9
- try {
10
- return statSync(join(root, 'state', 'taskHistory.json')).isFile();
11
- } catch {
12
- return false;
13
- }
14
- }
15
-
16
8
  function hostRoots() {
17
9
  const out = [];
18
10
  if (process.platform === 'darwin') {
@@ -28,13 +20,50 @@ function hostRoots() {
28
20
  return out;
29
21
  }
30
22
 
31
- export function findClineDataDirs() {
23
+ /** Both legacy stores and the shared Cline 3.x CLI/extension SDK store. */
24
+ export function findClineStores({ onWarning = () => {} } = {}) {
25
+ function isPath(path, directory = false) {
26
+ try {
27
+ const stat = statSync(path);
28
+ return directory ? stat.isDirectory() : stat.isFile();
29
+ } catch (err) {
30
+ if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') {
31
+ onWarning(`cline: 无法读取数据目录 ${path}: ${err.message}`);
32
+ }
33
+ return false;
34
+ }
35
+ }
36
+ function unique(paths) {
37
+ const seen = new Set();
38
+ return paths.filter(path => {
39
+ let key;
40
+ try { key = realpathSync(path); } catch { key = path; }
41
+ if (seen.has(key)) return false;
42
+ seen.add(key);
43
+ return true;
44
+ });
45
+ }
32
46
  const override = process.env.VIBE_USAGE_CLINE_DIRS?.trim();
33
- const candidates = override
34
- ? override.split(delimiter).map((value) => value.trim()).filter(Boolean)
35
- : [
36
- join(homedir(), '.cline'),
37
- ...hostRoots().map((root) => join(root, 'User', 'globalStorage', EXTENSION_ID)),
38
- ];
39
- return [...new Set(candidates)].filter(hasTaskHistory);
47
+ const home = join(homedir(), '.cline');
48
+ const configuredHome = process.env.CLINE_DIR?.trim() || home;
49
+ const dataDir = process.env.CLINE_DATA_DIR?.trim() || join(configuredHome, 'data');
50
+ const roots = override
51
+ ? override.split(delimiter).map(value => value.trim()).filter(Boolean)
52
+ : [home, configuredHome, dataDir,
53
+ ...hostRoots().map(root => join(root, 'User', 'globalStorage', EXTENSION_ID))];
54
+ // Accept either a Cline home or its data directory. Keep the old standalone
55
+ // and editor stores so upgrading the runtime does not discard old history.
56
+ const dataRoots = unique(roots.flatMap(root => [root, join(root, 'data')]));
57
+ const legacyRoots = dataRoots.filter(root => isPath(join(root, 'state', 'taskHistory.json')));
58
+ const sessionDirs = override
59
+ ? dataRoots.map(root => join(root, 'sessions'))
60
+ : [...dataRoots.map(root => join(root, 'sessions')),
61
+ process.env.CLINE_SESSION_DATA_DIR?.trim()].filter(Boolean);
62
+ const sdkSessionDirs = unique(sessionDirs).filter(dir => isPath(dir, true));
63
+ return { legacyRoots: unique(legacyRoots), sdkSessionDirs };
64
+ }
65
+
66
+ export function findClineDataDirs() {
67
+ const { legacyRoots, sdkSessionDirs } = findClineStores();
68
+ return [...legacyRoots, ...sdkSessionDirs];
40
69
  }
@@ -115,6 +115,9 @@ function escapeXml(value) {
115
115
  // launchd/systemd unit that inherits nothing, so anything the parsers read for
116
116
  // discovery has to be captured into the unit at install time.
117
117
  const PRESERVED_SERVICE_ENV = [
118
+ 'CLINE_DIR',
119
+ 'CLINE_DATA_DIR',
120
+ 'CLINE_SESSION_DATA_DIR',
118
121
  'COLA_DATA_DIR',
119
122
  'HERMES_HOME',
120
123
  'MCODE_HOME',
@@ -1,9 +1,10 @@
1
1
  import { accessSync, closeSync, constants, openSync, readSync, readdirSync, statSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename, join, resolve } from 'node:path';
4
+ import { openCodeStore } from './opencode-roots.js';
4
5
  import { codexSessionDirs } from './codex-roots.js';
5
6
 
6
- export const EXTRA_ROOT_SOURCES = ['antigravity', 'codex', 'grok', 'pi-coding-agent'];
7
+ export const EXTRA_ROOT_SOURCES = ['antigravity', 'claude-code', 'codex', 'grok', 'opencode', 'pi-coding-agent'];
7
8
 
8
9
  // Probing a candidate Pi store has three outcomes, never two: a confirmed
9
10
  // session, a directory proven to hold none, and one that could not be read.
@@ -278,6 +279,11 @@ function probePiSessions(dir, depth = 2) {
278
279
  return unreadable ? PI_SESSIONS_UNREADABLE : PI_SESSIONS_ABSENT;
279
280
  }
280
281
 
282
+ export function claudeProjectsDir(value) {
283
+ const root = normalizeExtraRoot(value);
284
+ return [join(root, 'projects'), join(root, 'transcripts')].filter(isReadableDirectory);
285
+ }
286
+
281
287
  export function validateExtraRoot(source, value) {
282
288
  if (!EXTRA_ROOT_SOURCES.includes(source)) {
283
289
  return { ok: false, path: value, reason: `不支持的工具: ${source}` };
@@ -291,6 +297,22 @@ export function validateExtraRoot(source, value) {
291
297
  reason: '需要是 Codex Home,或包含 */*/codex-home 的 Multica 容器',
292
298
  };
293
299
  }
300
+ if (source === 'claude-code') {
301
+ const dirs = claudeProjectsDir(path);
302
+ return {
303
+ ok: dirs.length > 0,
304
+ path,
305
+ reason: '需要包含 projects/ 或 transcripts/',
306
+ };
307
+ }
308
+ if (source === 'opencode') {
309
+ try {
310
+ return { ok: openCodeStore(path) !== null, path,
311
+ reason: '需要包含可读的 opencode.db 或 storage/message/' };
312
+ } catch (err) {
313
+ return { ok: false, path, reason: `无法读取 OpenCode 目录: ${err.message}` };
314
+ }
315
+ }
294
316
  if (source === 'pi-coding-agent') {
295
317
  // piSessionsDir only returns a directory it has already confirmed by
296
318
  // content, so there is nothing left to re-check here.
package/src/index.js CHANGED
@@ -252,7 +252,7 @@ const FULL_HELP = `
252
252
  ${BARE} init --manual-key <vbu_...> Skip browser, use a pre-issued key (CI/headless)
253
253
  ${BARE} sync Manually sync usage data
254
254
  ${BARE} sync --extra-codex-home <path> Use another Codex Home for this run
255
- ${BARE} summary Print last 7 days as markdown (cost/tokens/model/project)
255
+ ${BARE} summary Print last 7 days as markdown (cost/tokens/tool/model/project)
256
256
  ${BARE} summary --days N Same, but over the last N days (1-90)
257
257
  ${BARE} daemon Continuous sync (every 30m, foreground)
258
258
  ${BARE} daemon install Install background service (systemd/launchd/Task Scheduler)
@@ -269,7 +269,7 @@ const FULL_HELP = `
269
269
  ${BARE} config get <key> Get a config value
270
270
  ${BARE} config set <key> <value> Set a config value
271
271
  ${BARE} config set codexExtraHome <path> Persist another Codex Home
272
- ${BARE} config add-root <tool> <path> Add a Codex, Grok, Antigravity, or Pi data root
272
+ ${BARE} config add-root <tool> <path> Add a Claude Code, Codex, Grok, OpenCode, Antigravity, or Pi data root
273
273
  ${BARE} config remove-root <tool> <path> Remove an added data root
274
274
  ${BARE} config roots Show added data roots as JSON
275
275
  ${BARE} help Show the short help
@@ -0,0 +1,46 @@
1
+ import { accessSync, constants, realpathSync, statSync } from 'node:fs';
2
+ import { delimiter, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ function readable(path, directory) {
6
+ try {
7
+ const stat = statSync(path);
8
+ if (directory ? !stat.isDirectory() : !stat.isFile()) throw new Error(`格式不正确: ${path}`);
9
+ accessSync(path, constants.R_OK);
10
+ return true;
11
+ } catch (err) {
12
+ if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return false;
13
+ throw err;
14
+ }
15
+ }
16
+
17
+ // SQLite wins within each store; JSON is the legacy alternative, not a second
18
+ // copy of the same migrated history. A failed SQLite read must protect state.
19
+ export function openCodeStore(root) {
20
+ const db = join(root, 'opencode.db');
21
+ if (readable(db, false)) return { kind: 'sqlite', path: db };
22
+ const messages = join(root, 'storage', 'message');
23
+ if (readable(messages, true)) return { kind: 'json', path: messages };
24
+ return null;
25
+ }
26
+
27
+ export function getOpenCodeStores({ extraRoots = [], onWarning = () => {} } = {}) {
28
+ const override = process.env.VIBE_USAGE_OPENCODE_DIRS?.trim();
29
+ const defaults = override ? override.split(delimiter).map(p => p.trim()).filter(Boolean)
30
+ : [join(homedir(), '.local', 'share', 'opencode')];
31
+ const seen = new Set(), stores = [];
32
+ for (const root of [...defaults, ...extraRoots]) {
33
+ try {
34
+ const store = openCodeStore(root);
35
+ if (!store) {
36
+ if (extraRoots.includes(root)) onWarning(`OpenCode: 额外目录缺少 opencode.db 或 storage/message/: ${root}`);
37
+ continue;
38
+ }
39
+ const canonical = realpathSync(store.path);
40
+ if (seen.has(canonical)) continue;
41
+ seen.add(canonical);
42
+ stores.push({ ...store, path: canonical });
43
+ } catch (err) { onWarning(`OpenCode: 无法读取数据目录 ${root}: ${err.message}`); }
44
+ }
45
+ return stores;
46
+ }
@@ -331,7 +331,7 @@ function* iterateUsageEntries(ctx) {
331
331
  for (const entry of ctx.entriesByKey.values()) yield entry;
332
332
  }
333
333
 
334
- export async function parse() {
334
+ export async function parse({ extraRoots = [] } = {}) {
335
335
  const ctx = {
336
336
  entriesByKey: new Map(),
337
337
  anonymousEntries: [],
@@ -341,6 +341,7 @@ export async function parse() {
341
341
  };
342
342
  const roots = getClaudeRoots({
343
343
  onWarning: (message) => addWarning(ctx, message),
344
+ extraRoots,
344
345
  });
345
346
  const projectGroups = collectCandidates(roots, 'projects', ctx);
346
347
  const projectSessionIds = new Set();
@@ -0,0 +1,106 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { projectFromCwd, toCount } from './fs-utils.js';
4
+
5
+ // Verified with the shipped cline 3.0.61 / @cline/core 0.0.82. SQLite is only
6
+ // the session index: per-call accounting lives in version-1 messages artifacts.
7
+ // Read canonical artifacts, not DB prompt/metadata columns or provider settings.
8
+ export function readClineSdk(sessionDirs, onWarning) {
9
+ const copies = [];
10
+ for (const dir of sessionDirs) {
11
+ let children;
12
+ try { children = readdirSync(dir, { withFileTypes: true }); }
13
+ catch (err) { onWarning(`cline: 无法读取会话目录 ${dir}: ${err.message}`); continue; }
14
+ for (const child of children) {
15
+ if (!child.isDirectory()) continue;
16
+ const sessionDir = join(dir, child.name);
17
+ let files, manifest;
18
+ try {
19
+ files = readdirSync(sessionDir, { withFileTypes: true })
20
+ .filter(file => file.isFile() && file.name.endsWith('.messages.json'));
21
+ if (!files.length) continue;
22
+ manifest = JSON.parse(readFileSync(join(sessionDir, `${child.name}.json`), 'utf8'));
23
+ if (manifest?.version !== 1 || manifest.session_id !== child.name) {
24
+ throw new Error('unsupported or inconsistent Cline session manifest');
25
+ }
26
+ } catch (err) {
27
+ onWarning(`cline: 无法读取会话目录 ${sessionDir}: ${err.message}`);
28
+ continue;
29
+ }
30
+ for (const file of files) {
31
+ const messagesPath = join(sessionDir, file.name);
32
+ try {
33
+ const payload = JSON.parse(readFileSync(messagesPath, 'utf8'));
34
+ if (payload?.version !== 1 || !Array.isArray(payload.messages)
35
+ || typeof payload.sessionId !== 'string'
36
+ || (payload.sessionId !== child.name && payload.origin?.parentThreadId !== child.name)) {
37
+ throw new Error('unsupported or inconsistent Cline session artifact');
38
+ }
39
+ const project = projectFromCwd(manifest.workspace_root || manifest.cwd);
40
+ // Reduce immediately to accounting/timing metadata. No prompt, response,
41
+ // system prompt, tool arguments, credentials, or stored costs survive.
42
+ const messages = payload.messages.flatMap(message => {
43
+ if (!message || !['user', 'assistant'].includes(message.role)) return [];
44
+ if (message.role === 'user' && (payload.agent !== 'lead'
45
+ || message.metadata?.kind || message.metadata?.userRunSpan === 0
46
+ || ['system', 'status', 'error', 'tool'].includes(message.metadata?.displayRole)
47
+ || (Array.isArray(message.content) && message.content.some(block =>
48
+ block?.type === 'tool_result' || block?.type === 'tool-result')))) return [];
49
+ if (typeof message.ts !== 'number' || !Number.isFinite(message.ts)
50
+ || !Number.isFinite(new Date(message.ts).getTime())) return [];
51
+ // Legacy migration inserts a cumulative metric on an old assistant
52
+ // without a timestamp. Never re-date that summary to the migration.
53
+ const metric = message.role === 'assistant' ? message.metrics : null;
54
+ const input = toCount(metric?.inputTokens);
55
+ const cachedInputTokens = Math.min(input, toCount(metric?.cacheReadTokens));
56
+ return [{
57
+ id: typeof message.id === 'string' ? message.id : null,
58
+ role: message.role,
59
+ timestamp: message.ts,
60
+ model: message.modelInfo?.id || manifest.model || 'cline-unknown',
61
+ inputTokens: input - cachedInputTokens,
62
+ outputTokens: toCount(metric?.outputTokens),
63
+ cachedInputTokens,
64
+ }];
65
+ });
66
+ copies.push({ sessionId: child.name, artifactId: payload.sessionId, project, messages,
67
+ // Restored/forked artifacts retain message ids/timestamps; attribute
68
+ // shared history to the earliest original session deterministically.
69
+ started: Date.parse(manifest.started_at) || 0, messagesPath });
70
+ } catch (err) {
71
+ onWarning(`cline: 无法读取会话 ${messagesPath}: ${err.message}`);
72
+ }
73
+ }
74
+ }
75
+ }
76
+ copies.sort((a, b) => a.started - b.started
77
+ || a.sessionId.localeCompare(b.sessionId) || a.messagesPath.localeCompare(b.messagesPath));
78
+ const records = new Map();
79
+ for (const copy of copies) {
80
+ copy.messages.forEach((message, index) => {
81
+ // Anonymous messages can be deduplicated only within copies of this
82
+ // logical session; equal usage in unrelated sessions remains distinct.
83
+ const identity = message.id || `${copy.artifactId}:${index}`;
84
+ const key = JSON.stringify([identity, message.role, message.timestamp]);
85
+ const next = { ...message, sessionId: copy.sessionId, project: copy.project };
86
+ const old = records.get(key);
87
+ const count = value => value.inputTokens + value.outputTokens + value.cachedInputTokens;
88
+ if (!old) records.set(key, next);
89
+ else if (count(next) > count(old)) {
90
+ records.set(key, { ...next, sessionId: old.sessionId, project: old.project });
91
+ }
92
+ });
93
+ }
94
+ const entries = [], events = [];
95
+ for (const message of records.values()) {
96
+ const base = { source: 'cline', project: message.project, timestamp: new Date(message.timestamp) };
97
+ if (message.role === 'assistant'
98
+ && message.inputTokens + message.outputTokens + message.cachedInputTokens > 0) {
99
+ entries.push({ ...base, model: message.model, inputTokens: message.inputTokens,
100
+ outputTokens: message.outputTokens, cachedInputTokens: message.cachedInputTokens,
101
+ reasoningOutputTokens: 0 });
102
+ }
103
+ events.push({ ...base, sessionId: message.sessionId, role: message.role });
104
+ }
105
+ return { entries, events };
106
+ }
@@ -2,11 +2,13 @@ import { statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { aggregateToBuckets, extractSessions } from './aggregate.js';
4
4
  import { readJsonSafe, projectFromPath } from './fs-utils.js';
5
- import { findClineDataDirs } from '../cline-roots.js';
5
+ import { findClineStores } from '../cline-roots.js';
6
+ import { readClineSdk } from './cline-sdk.js';
6
7
 
7
8
  export async function parse() {
8
- const extDirs = findClineDataDirs();
9
- if (extDirs.length === 0) return { buckets: [], sessions: [] };
9
+ const warnings = [];
10
+ const onWarning = message => warnings.push(message);
11
+ const { legacyRoots: extDirs, sdkSessionDirs } = findClineStores({ onWarning });
10
12
 
11
13
  const entries = [];
12
14
  const events = [];
@@ -88,5 +90,8 @@ export async function parse() {
88
90
  }
89
91
  }
90
92
 
91
- return { buckets: aggregateToBuckets(entries), sessions: extractSessions(events) };
93
+ const sdk = readClineSdk(sdkSessionDirs, onWarning);
94
+ if (warnings.length) return { buckets: [], sessions: [], skipped: true, warnings };
95
+ return { buckets: aggregateToBuckets([...entries, ...sdk.entries]),
96
+ sessions: extractSessions([...events, ...sdk.events]) };
92
97
  }
@@ -14,7 +14,7 @@ import { join } from 'node:path';
14
14
  // separate from ~/.vibe-usage/state.json, whose hashes are the authoritative
15
15
  // record of successful uploads and must remain backward-compatible.
16
16
  export const CODEX_CACHE_SCHEMA_VERSION = 1;
17
- export const CODEX_PARSER_ALGORITHM_VERSION = 3;
17
+ export const CODEX_PARSER_ALGORITHM_VERSION = 4;
18
18
 
19
19
  function hash(value, length = 24) {
20
20
  return createHash('sha256').update(value).digest('hex').slice(0, length);
@@ -0,0 +1,108 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('base64url');
4
+ function pick(value, keys) {
5
+ if (!value || typeof value !== 'object') return value;
6
+ return Object.fromEntries(keys.filter(key => Object.hasOwn(value, key)).map(key => [key, value[key]]));
7
+ }
8
+ const usageKeys = ['input_tokens', 'output_tokens', 'cached_input_tokens', 'cache_read_input_tokens', 'reasoning_output_tokens', 'total_tokens'];
9
+
10
+ // Retain only fields consumed by the existing token/timing parser. Full JSON is
11
+ // hashed transiently to identify exact copies; chat/tool text is never retained.
12
+ function accountingRecord(obj, context) {
13
+ const next = { type: obj.type, timestamp: obj.timestamp };
14
+ const p = obj.payload;
15
+ if (obj.type === 'session_meta' && p) {
16
+ next.payload = pick(p, ['id', 'timestamp', 'cwd', 'forked_from_id', 'parent_thread_id', 'thread_source']);
17
+ if (p.git) next.payload.git = pick(p.git, ['repository_url']);
18
+ if (typeof p.source === 'string') next.payload.source = p.source;
19
+ else if (p.source && typeof p.source === 'object' && 'subagent' in p.source) {
20
+ next.payload.source = { subagent: { thread_spawn: pick(p.source.subagent?.thread_spawn, ['parent_thread_id']) } };
21
+ }
22
+ } else if (obj.type === 'turn_context') {
23
+ next.payload = pick(p, ['model', 'service_tier']);
24
+ } else if (obj.type === 'event_msg' && p) {
25
+ next.payload = pick(p, ['type', 'started_at', 'model']);
26
+ if (p.type === 'token_count') {
27
+ next._tokenFingerprint = hash(p).slice(0, 16);
28
+ next._segmentContext = { ...context };
29
+ if (p.info) next.payload.info = {
30
+ ...pick(p.info, ['model']),
31
+ total_token_usage: pick(p.info.total_token_usage, usageKeys),
32
+ last_token_usage: pick(p.info.last_token_usage, usageKeys),
33
+ };
34
+ } else if (p.type === 'thread_settings_applied') {
35
+ next.payload.thread_settings = pick(p.thread_settings, ['model', 'service_tier']);
36
+ }
37
+ }
38
+ return JSON.stringify(next);
39
+ }
40
+
41
+ /**
42
+ * Merge exact cross-file copies with multiplicity: occurrence N of a record in
43
+ * one file matches occurrence N in another. Repetitions within a file survive.
44
+ * Preserve each file's order (including rewritten-timestamp fork replay); use
45
+ * timestamps only to order independent segments. Contradictory order fails the
46
+ * source instead of inventing a potentially double-counting sequence.
47
+ */
48
+ export async function mergeCodexSegments(members, readLines) {
49
+ const nodes = new Map();
50
+ let canonicalKey = null;
51
+ for (const [fileIndex, file] of members.entries()) {
52
+ const occurrences = new Map();
53
+ let previous = null;
54
+ const context = {};
55
+ for await (const line of readLines(file.filePath, file.snapshotSize)) {
56
+ let obj;
57
+ try { obj = JSON.parse(line); } catch { continue; }
58
+ if (!obj || typeof obj !== 'object') continue;
59
+ const settings = obj.type === 'turn_context' ? obj.payload
60
+ : obj.type === 'event_msg' && obj.payload?.type === 'thread_settings_applied' ? obj.payload.thread_settings : null;
61
+ if (settings?.model) context.model = settings.model;
62
+ if (Object.hasOwn(settings || {}, 'service_tier')) context.serviceTier = settings.service_tier;
63
+ const fingerprint = hash(obj);
64
+ const occurrence = (occurrences.get(fingerprint) || 0) + 1;
65
+ occurrences.set(fingerprint, occurrence);
66
+ const key = `${fingerprint}:${occurrence}`;
67
+ if (fileIndex === 0 && canonicalKey === null && obj.type === 'session_meta') canonicalKey = key;
68
+ if (!nodes.has(key)) nodes.set(key, {
69
+ key, line: accountingRecord(obj, context), time: Date.parse(obj.timestamp),
70
+ ordinal: nodes.size, successors: new Set(), incoming: 0,
71
+ });
72
+ if (previous && previous !== key && !nodes.get(previous).successors.has(key)) {
73
+ nodes.get(previous).successors.add(key);
74
+ nodes.get(key).incoming++;
75
+ }
76
+ previous = key;
77
+ }
78
+ }
79
+ const ready = [...nodes.values()].filter(node => !node.incoming);
80
+ const result = [];
81
+ function compare(a, b) {
82
+ if (a.key === canonicalKey || b.key === canonicalKey) return a.key === canonicalKey ? -1 : 1;
83
+ if (Number.isFinite(a.time) && Number.isFinite(b.time) && a.time !== b.time) return a.time - b.time;
84
+ return a.ordinal - b.ordinal;
85
+ }
86
+ while (ready.length) {
87
+ ready.sort(compare);
88
+ const node = ready.shift();
89
+ result.push(node.line);
90
+ for (const key of node.successors) {
91
+ const next = nodes.get(key);
92
+ if (--next.incoming === 0) ready.push(next);
93
+ }
94
+ }
95
+ if (result.length !== nodes.size) throw new Error('Codex continuation copies have conflicting record order');
96
+ return result;
97
+ }
98
+
99
+ // The disposable group cache is invalidated by ANY member changing, appearing,
100
+ // disappearing, or moving. It never shares the selected physical file's cache.
101
+ export function codexSegmentSignature(members) {
102
+ return {
103
+ size: members.reduce((sum, file) => sum + file.snapshotSize, 0),
104
+ mtimeMs: Math.max(...members.map(file => file.signature.mtimeMs)),
105
+ dev: 'codex-segments',
106
+ ino: hash(members.map(file => [file.filePath, file.signature]).sort((a, b) => a[0].localeCompare(b[0]))),
107
+ };
108
+ }
@@ -10,6 +10,7 @@ import {
10
10
  import { join } from 'node:path';
11
11
  import { createInterface } from 'node:readline';
12
12
  import { createHash } from 'node:crypto';
13
+ import { mergeCodexSegments, codexSegmentSignature } from './codex-segments.js';
13
14
  import { aggregateToBuckets } from './aggregate.js';
14
15
  import { mergeCindyHarnessUsage, readCindyHarnessUsage } from './cindy-ledger.js';
15
16
  import {
@@ -314,7 +315,7 @@ const OWN_TASK_START_WINDOW_MS = 5_000;
314
315
  * full prefix. Together they bound matching to source records that existed at
315
316
  * spawn without over-skipping child work when the parent later grows.
316
317
  */
317
- async function indexSessionFile(filePath, snapshotSize) {
318
+ async function indexSessionFile(filePath, snapshotSize, lines = null) {
318
319
  let sessionId = null;
319
320
  let forkedFromId = null;
320
321
  let parentThreadId = null;
@@ -332,7 +333,7 @@ async function indexSessionFile(filePath, snapshotSize) {
332
333
  let firstTaskBoundary = null;
333
334
  let ownTaskBoundary = null;
334
335
 
335
- for await (const line of readLines(filePath, snapshotSize)) {
336
+ for await (const line of (lines ?? readLines(filePath, snapshotSize))) {
336
337
  if (!line.trim()) continue;
337
338
  try {
338
339
  const obj = JSON.parse(line);
@@ -361,7 +362,7 @@ async function indexSessionFile(filePath, snapshotSize) {
361
362
  }
362
363
  } else if (obj.type === 'event_msg' && obj.payload?.type === 'token_count') {
363
364
  rawTokenCount++;
364
- tokenFingerprints.push(tokenFingerprint(obj.payload));
365
+ tokenFingerprints.push(lines ? obj._tokenFingerprint : tokenFingerprint(obj.payload));
365
366
  if (recordTimestamp == null) {
366
367
  tokenTimes.push(Number.POSITIVE_INFINITY);
367
368
  pendingTokenTimeIndexes.push(tokenTimes.length - 1);
@@ -612,6 +613,7 @@ function mergeBucketLists(lists) {
612
613
  async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
613
614
  previousTail = null,
614
615
  captureTail = false,
616
+ lines = null,
615
617
  } = {}) {
616
618
  const entries = [];
617
619
  const sessionEvents = [];
@@ -632,7 +634,7 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
632
634
  let prevTotal = previousTail?.prevTotal || null;
633
635
  let prevCumulativeTotal = previousTail?.prevCumulativeTotal ?? null;
634
636
  const start = previousTail?.parsedBytes || 0;
635
- for await (const line of readLines(filePath, snapshotSize, start)) {
637
+ for await (const line of (lines ?? readLines(filePath, snapshotSize, start))) {
636
638
  if (!line.trim()) continue;
637
639
  try {
638
640
  const obj = JSON.parse(line);
@@ -750,8 +752,11 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
750
752
  const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
751
753
  if (!timestamp || isNaN(timestamp.getTime())) continue;
752
754
 
753
- const rawModel = info.model || payload.model || turnContextModel || 'unknown';
754
- const model = decorateCodexModel(rawModel, serviceTier, timestamp.getTime());
755
+ const segmentContext = lines ? obj._segmentContext : null;
756
+ const rawModel = info.model || payload.model || segmentContext?.model || turnContextModel || 'unknown';
757
+ const effectiveTier = Object.hasOwn(segmentContext || {}, 'serviceTier')
758
+ ? normalizeCodexServiceTier(segmentContext.serviceTier) : serviceTier;
759
+ const model = decorateCodexModel(rawModel, effectiveTier, timestamp.getTime());
755
760
 
756
761
  // OpenAI API: input_tokens INCLUDES cached, output_tokens INCLUDES reasoning.
757
762
  // Normalize to Anthropic-style semantics where each field is non-overlapping.
@@ -967,13 +972,12 @@ async function parseNativeCodex({ codexExtraHome, extraRoots = [] } = {}) {
967
972
  cacheStats.filesRead++;
968
973
  updateFileCache(file, { header: file.header });
969
974
  } catch {
970
- if (file.strict) {
971
- return {
972
- buckets: [], sessions: [], skipped: true,
973
- warnings: [`codex: 额外根目录读取失败,已保留上次同步数据: ${file.codexHome}`],
974
- };
975
- }
976
- continue;
975
+ // An unreadable header may hide another segment of a known session.
976
+ // Never upload the remaining readable segment as its complete total.
977
+ return {
978
+ buckets: [], sessions: [], skipped: true,
979
+ warnings: ['codex: 会话文件读取失败,已保留上次同步数据'],
980
+ };
977
981
  }
978
982
  }
979
983
  if (file.header.sessionId) {
@@ -1045,8 +1049,55 @@ async function parseNativeCodex({ codexExtraHome, extraRoots = [] } = {}) {
1045
1049
  }
1046
1050
  }
1047
1051
 
1048
- // Select the most complete physical copy exactly as before. Unique ordinary
1049
- // sessions have no full record count, but cannot compete with another copy.
1052
+ // Duplicate ids can be disjoint continuation segments, not just archives.
1053
+ // Keep physical caches intact and give each combined session its own cache.
1054
+ const groupedFiles = [];
1055
+ let groupAudited = false;
1056
+ for (const id of duplicateIds) {
1057
+ const members = candidatesById.get(id);
1058
+ if (members.some(file => !fileMeta.has(file.filePath))) {
1059
+ return { buckets: [], sessions: [], skipped: true,
1060
+ warnings: ['codex: 同一会话的部分文件读取失败,已保留上次同步数据'] };
1061
+ }
1062
+ members.sort((a, b) => (fileMeta.get(b.filePath).parsedRecordCount || 0)
1063
+ - (fileMeta.get(a.filePath).parsedRecordCount || 0) || a.filePath.localeCompare(b.filePath));
1064
+ const selected = members[0];
1065
+ const filePath = `${selected.codexHome}/.vibe-usage-session-${createHash('sha256').update(id).digest('hex')}`;
1066
+ const signature = codexSegmentSignature(members);
1067
+ let cache = members.some(file => auditPaths.has(file.filePath)) ? null
1068
+ : loadCodexFileCache(selected.codexHome, filePath, signature);
1069
+ if (cache?.result && !groupAudited && auditPaths.size === 0
1070
+ && signature.size <= auditMaxBytes()
1071
+ && (cache.lastAuditedAt || 0) <= Date.now() - auditIntervalMs()) {
1072
+ cache = null;
1073
+ groupAudited = true;
1074
+ cacheStats.audited++;
1075
+ }
1076
+ const group = { ...selected, filePath, signature, snapshotSize: signature.size,
1077
+ cache, members, lines: null, appendTail: null, priorTail: null };
1078
+ try {
1079
+ let meta = cache?.index;
1080
+ if (!meta) {
1081
+ group.lines = await mergeCodexSegments(members, readLines);
1082
+ cacheStats.filesRead += members.length;
1083
+ meta = await indexSessionFile(filePath, null, group.lines);
1084
+ updateFileCache(group, { header: group.header, index: meta });
1085
+ } else cacheStats.indexHits++;
1086
+ fileMeta.set(filePath, meta);
1087
+ needsIndex.add(filePath);
1088
+ for (const member of members) fileMeta.delete(member.filePath);
1089
+ groupedFiles.push(group);
1090
+ } catch (err) {
1091
+ return { buckets: [], sessions: [], skipped: true,
1092
+ warnings: [`codex: 无法合并会话分段,已保留上次同步数据: ${err.message}`] };
1093
+ }
1094
+ if (overBudget()) return { buckets: [], sessions: [], skipped: true,
1095
+ indexing: { phase: 'segments', completed: groupedFiles.length, total: duplicateIds.size }, cache: cacheStats };
1096
+ }
1097
+ files.push(...groupedFiles);
1098
+
1099
+ // One index per logical session, including all continuation segments, feeds
1100
+ // the unchanged fork/subagent replay boundary logic.
1050
1101
  const sessionById = new Map();
1051
1102
  for (const file of files) {
1052
1103
  const meta = fileMeta.get(file.filePath);
@@ -1077,11 +1128,16 @@ async function parseNativeCodex({ codexExtraHome, extraRoots = [] } = {}) {
1077
1128
  const previousTail = !needsIndex.has(file.filePath) && !auditPaths.has(file.filePath)
1078
1129
  ? file.appendTail
1079
1130
  : null;
1131
+ if (file.members && !file.lines) {
1132
+ file.lines = await mergeCodexSegments(file.members, readLines);
1133
+ cacheStats.filesRead += file.members.length;
1134
+ }
1080
1135
  const parsed = await parseSessionFile(file.filePath, file.snapshotSize, fm, boundary, {
1081
1136
  previousTail,
1082
1137
  captureTail: !needsIndex.has(file.filePath),
1138
+ lines: file.lines,
1083
1139
  });
1084
- cacheStats.filesRead++;
1140
+ if (!file.members) cacheStats.filesRead++;
1085
1141
  if (previousTail) cacheStats.tailHits++;
1086
1142
  const { tail, ...summary } = parsed;
1087
1143
  if (tail) {
@@ -1098,6 +1154,7 @@ async function parseNativeCodex({ codexExtraHome, extraRoots = [] } = {}) {
1098
1154
  file.priorTail = null;
1099
1155
  if (auditPaths.has(file.filePath)) cacheStats.audited++;
1100
1156
  }
1157
+ file.lines = null; // Never retain the transient combined transcript after parsing.
1101
1158
  results.push(result);
1102
1159
 
1103
1160
  if (overBudget() && i < files.length - 1) {
@@ -1,151 +1,84 @@
1
- import { readdirSync, readFileSync, existsSync } from 'node:fs';
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
2
  import { join, basename } from 'node:path';
3
- import { homedir } from 'node:os';
4
3
  import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
4
  import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
6
-
7
- const DATA_DIR = join(homedir(), '.local', 'share', 'opencode');
8
- const DB_PATH = join(DATA_DIR, 'opencode.db');
9
- const MESSAGES_DIR = join(DATA_DIR, 'storage', 'message');
10
-
11
- /**
12
- * Parse opencode usage data.
13
- * Tries SQLite database first (opencode >= v0.2), falls back to legacy JSON files.
14
- */
15
- export async function parse() {
16
- if (existsSync(DB_PATH)) {
17
- try {
18
- return parseFromSqlite();
19
- } catch (err) {
20
- process.stderr.write(`warn: opencode sqlite parse failed (${err.message}), trying legacy json...\n`);
21
- }
22
- }
23
- return parseFromJson();
24
- }
25
-
26
- function parseFromSqlite() {
27
- const query = `SELECT
28
- session_id as sessionID,
29
- json_extract(data, '$.role') as role,
30
- json_extract(data, '$.time.created') as created,
31
- json_extract(data, '$.modelID') as modelID,
32
- json_extract(data, '$.tokens') as tokens,
33
- json_extract(data, '$.path.root') as rootPath
34
- FROM message`;
35
-
36
- let rows;
37
- try {
38
- rows = queryDbJson(DB_PATH, query);
39
- } catch (err) {
5
+ import { getOpenCodeStores } from '../opencode-roots.js';
6
+
7
+ function readSqlite(path) {
8
+ // Select only accounting/timing metadata, never message text or tool inputs.
9
+ // Keep the existing top-level model/project precedence for old uploads.
10
+ const query = `SELECT id, session_id AS sessionID,
11
+ json_extract(data, '$.role') AS role,
12
+ json_extract(data, '$.time.created') AS created,
13
+ coalesce(json_extract(data, '$.modelID'), json_extract(data, '$.model.modelID')) AS modelID,
14
+ json_extract(data, '$.tokens') AS tokens,
15
+ json_extract(data, '$.path.root') AS rootPath
16
+ FROM message ORDER BY id`;
17
+ try { return queryDbJson(path, query); }
18
+ catch (err) {
40
19
  if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('OpenCode');
41
20
  throw err;
42
21
  }
43
- if (!rows.length) return { buckets: [], sessions: [] };
44
-
45
- const entries = [];
46
- const sessionEvents = [];
47
- for (const row of rows) {
48
- const timestamp = new Date(row.created);
49
- if (isNaN(timestamp.getTime())) continue;
50
-
51
- const project = row.rootPath ? basename(row.rootPath) : 'unknown';
52
- const sessionId = row.sessionID || 'unknown';
53
-
54
- sessionEvents.push({
55
- sessionId,
56
- source: 'opencode',
57
- project,
58
- timestamp,
59
- role: row.role === 'user' ? 'user' : 'assistant',
60
- });
22
+ }
61
23
 
62
- if (!row.modelID) continue;
63
- let tokens;
64
- try {
65
- tokens = typeof row.tokens === 'string' ? JSON.parse(row.tokens) : row.tokens;
66
- } catch {
67
- continue;
24
+ function readJson(path) {
25
+ const rows = [];
26
+ for (const dir of readdirSync(path, { withFileTypes: true })) {
27
+ if (!dir.isDirectory() || !dir.name.startsWith('ses_')) continue;
28
+ const sessionPath = join(path, dir.name);
29
+ for (const file of readdirSync(sessionPath).sort()) {
30
+ if (!file.endsWith('.json')) continue;
31
+ const data = JSON.parse(readFileSync(join(sessionPath, file), 'utf8'));
32
+ rows.push({ id: data.id || basename(file, '.json'), sessionID: dir.name,
33
+ role: data.role, created: data.time?.created,
34
+ modelID: data.modelID || data.model?.modelID,
35
+ tokens: data.tokens, rootPath: data.path?.root });
68
36
  }
69
- if (!tokens || (!tokens.input && !tokens.output)) continue;
70
-
71
- entries.push({
72
- source: 'opencode',
73
- model: row.modelID || 'unknown',
74
- project,
75
- timestamp,
76
- inputTokens: tokens.input || 0,
77
- outputTokens: tokens.output || 0,
78
- cachedInputTokens: tokens.cache?.read || 0,
79
- reasoningOutputTokens: tokens.reasoning || 0,
80
- });
81
37
  }
82
-
83
- return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
38
+ return rows;
84
39
  }
85
40
 
86
- function parseFromJson() {
87
- if (!existsSync(MESSAGES_DIR)) return { buckets: [], sessions: [] };
88
-
89
- const entries = [];
90
- const sessionEvents = [];
91
- let sessionDirs;
92
- try {
93
- sessionDirs = readdirSync(MESSAGES_DIR, { withFileTypes: true })
94
- .filter(d => d.isDirectory() && d.name.startsWith('ses_'));
95
- } catch {
96
- return { buckets: [], sessions: [] };
97
- }
41
+ function tokenSize(row) {
42
+ const t = row.tokens;
43
+ return ['input', 'output', 'reasoning'].reduce((n, key) => n + (Number(t?.[key]) || 0), 0)
44
+ + (Number(t?.cache?.read) || 0);
45
+ }
98
46
 
99
- for (const sessionDir of sessionDirs) {
100
- const sessionPath = join(MESSAGES_DIR, sessionDir.name);
101
- let msgFiles;
47
+ export async function parse({ extraRoots = [] } = {}) {
48
+ const warnings = [];
49
+ const stores = getOpenCodeStores({ extraRoots, onWarning: message => warnings.push(message) });
50
+ const records = new Map();
51
+ for (const store of stores) {
102
52
  try {
103
- msgFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
104
- } catch {
105
- continue;
106
- }
107
-
108
- for (const file of msgFiles) {
109
- const filePath = join(sessionPath, file);
110
-
111
- let data;
112
- try {
113
- data = JSON.parse(readFileSync(filePath, 'utf-8'));
114
- } catch {
115
- continue;
53
+ const rows = store.kind === 'sqlite' ? readSqlite(store.path) : readJson(store.path);
54
+ for (const [index, row] of rows.entries()) {
55
+ const timestamp = new Date(row.created);
56
+ if (!Number.isFinite(timestamp.getTime())) continue;
57
+ if (typeof row.tokens === 'string') row.tokens = JSON.parse(row.tokens);
58
+ const sessionId = row.sessionID || 'unknown';
59
+ // Message ids are unique within an OpenCode session. Across stores,
60
+ // keep the most complete copy; never dedup unrelated equal-sized calls.
61
+ // Missing ids cannot prove that two stores hold the same record.
62
+ const key = JSON.stringify([sessionId, row.id || `${store.path}:${index}`]);
63
+ const old = records.get(key);
64
+ if (!old || tokenSize(row) > tokenSize(old)) records.set(key, { ...row, timestamp, sessionId });
116
65
  }
117
-
118
- const timestamp = new Date(data.time?.created);
119
- if (isNaN(timestamp.getTime())) continue;
120
-
121
- const rootPath = data.path?.root;
122
- const project = rootPath ? basename(rootPath) : 'unknown';
123
-
124
- sessionEvents.push({
125
- sessionId: sessionDir.name,
126
- source: 'opencode',
127
- project,
128
- timestamp,
129
- role: data.role === 'user' ? 'user' : 'assistant',
130
- });
131
-
132
- if (!data.modelID) continue;
133
- const tokens = data.tokens;
134
- if (!tokens) continue;
135
- if (!tokens.input && !tokens.output) continue;
136
-
137
- entries.push({
138
- source: 'opencode',
139
- model: data.modelID || 'unknown',
140
- project,
141
- timestamp,
142
- inputTokens: tokens.input || 0,
143
- outputTokens: tokens.output || 0,
144
- cachedInputTokens: tokens.cache?.read || 0,
145
- reasoningOutputTokens: tokens.reasoning || 0,
146
- });
147
- }
66
+ } catch (err) { warnings.push(`OpenCode: 无法读取 ${store.path}: ${err.message}`); }
148
67
  }
68
+ if (warnings.length) return { buckets: [], sessions: [], skipped: true, warnings };
149
69
 
150
- return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
70
+ const entries = [], events = [];
71
+ for (const row of records.values()) {
72
+ // Keep the existing project derivation and token semantics. Additional roots
73
+ // must not rename previously uploaded projects/models or alter their counts.
74
+ const project = row.rootPath ? basename(row.rootPath) : 'unknown';
75
+ const base = { source: 'opencode', project, timestamp: row.timestamp };
76
+ events.push({ ...base, sessionId: row.sessionId, role: row.role === 'user' ? 'user' : 'assistant' });
77
+ const tokens = row.tokens;
78
+ if (!row.modelID || !tokens || (!tokens.input && !tokens.output)) continue;
79
+ entries.push({ ...base, model: row.modelID,
80
+ inputTokens: tokens.input || 0, outputTokens: tokens.output || 0,
81
+ cachedInputTokens: tokens.cache?.read || 0, reasoningOutputTokens: tokens.reasoning || 0 });
82
+ }
83
+ return { buckets: aggregateToBuckets(entries), sessions: extractSessions(events) };
151
84
  }
package/src/summary.js CHANGED
@@ -1,6 +1,12 @@
1
1
  import { loadConfig } from './config.js';
2
2
  import { getJson } from './api.js';
3
3
  import { failure } from './output.js';
4
+ import { TOOLS } from './tools.js';
5
+
6
+ // Server-side source ids are raw slugs ('claude-code'); TOOLS carries the
7
+ // display names. A source the server knows but this CLI doesn't parse falls
8
+ // back to its id rather than disappearing from the table.
9
+ const TOOL_NAMES = new Map(TOOLS.map(t => [t.id, t.name]));
4
10
 
5
11
  export async function runSummary(args = []) {
6
12
  const days = parseDays(args);
@@ -36,7 +42,7 @@ function parseDays(args) {
36
42
  return v;
37
43
  }
38
44
 
39
- function render(data, days, apiUrl) {
45
+ export function render(data, days, apiUrl) {
40
46
  const buckets = Array.isArray(data?.buckets) ? data.buckets : [];
41
47
  const sessions = Array.isArray(data?.sessions) ? data.sessions : [];
42
48
  const dashboard = `${apiUrl}/usage`;
@@ -47,6 +53,7 @@ function render(data, days, apiUrl) {
47
53
 
48
54
  let totalCost = 0;
49
55
  let totalTokens = 0;
56
+ const bySource = new Map();
50
57
  const byModel = new Map();
51
58
  const byProject = new Map();
52
59
 
@@ -55,6 +62,7 @@ function render(data, days, apiUrl) {
55
62
  const tokens = Number(b.totalTokens ?? 0);
56
63
  totalCost += cost;
57
64
  totalTokens += tokens;
65
+ accumulate(bySource, b.source || 'unknown', { cost, tokens });
58
66
  accumulate(byModel, b.model, { cost, tokens });
59
67
  accumulate(byProject, b.project || 'unknown', { cost, tokens, sessions: 0 });
60
68
  }
@@ -74,6 +82,16 @@ function render(data, days, apiUrl) {
74
82
  lines.push(`**总览**: $${totalCost.toFixed(2)} · ${formatTokens(totalTokens)} tokens · ${sessionsCount} sessions · ${activeHours.toFixed(1)}h active`);
75
83
  lines.push('');
76
84
 
85
+ lines.push('## 按工具');
86
+ lines.push('');
87
+ lines.push('| 工具 | 费用 | Tokens | 占比 |');
88
+ lines.push('|---|---:|---:|---:|');
89
+ for (const [source, { cost, tokens }] of topN(bySource, 'cost', 8)) {
90
+ const pct = totalCost > 0 ? ((cost / totalCost) * 100).toFixed(0) : '0';
91
+ lines.push(`| ${TOOL_NAMES.get(source) || source} | $${cost.toFixed(2)} | ${formatTokens(tokens)} | ${pct}% |`);
92
+ }
93
+ lines.push('');
94
+
77
95
  lines.push('## 按模型');
78
96
  lines.push('');
79
97
  lines.push('| 模型 | 费用 | Tokens | 占比 |');
package/src/tools.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync, statSync } from 'node:fs';
2
2
  import { dirname, isAbsolute, join, posix, resolve, win32 } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
+ import { getOpenCodeStores } from './opencode-roots.js';
4
5
  import { findClaudeCodeDataDirs } from './claude-roots.js';
5
6
  import { findCindyDataDirs, getCindyDataRoots } from './cindy-roots.js';
6
7
  import { codexSessionDirs, resolveCodexHomes } from './codex-roots.js';
@@ -258,7 +259,7 @@ export const TOOLS = [
258
259
  name: 'Claude Code',
259
260
  id: 'claude-code',
260
261
  dataDir: join(homedir(), '.claude', 'projects'),
261
- detectDataDirs: findClaudeCodeDataDirs,
262
+ detectDataDirs: ({ extraRoots } = {}) => findClaudeCodeDataDirs(extraRootList(extraRoots?.['claude-code'])),
262
263
  },
263
264
  {
264
265
  name: 'Codex CLI',
@@ -311,6 +312,9 @@ export const TOOLS = [
311
312
  name: 'OpenCode',
312
313
  id: 'opencode',
313
314
  dataDir: join(homedir(), '.local', 'share', 'opencode'),
315
+ detectDataDirs: ({ extraRoots } = {}) => getOpenCodeStores({
316
+ extraRoots: extraRootList(extraRoots?.opencode),
317
+ }).map(store => store.path),
314
318
  },
315
319
  {
316
320
  name: 'OpenClaw',