@vibe-cafe/vibe-usage 0.10.28 → 0.10.30

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)
@@ -64,6 +64,7 @@ npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short
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
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 |
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. |
67
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 |
68
69
  | GitHub Copilot CLI | `~/.copilot/session-state/*/events.jsonl` |
69
70
  | CraftAgent | `~/.craft-agent/workspaces/*/sessions/*/.pi-sessions/*.jsonl`; honors `$CRAFT_AGENT_DIR` / `$CRAFTAGENT_DIR`; cache writes are included in input usage |
@@ -81,9 +82,9 @@ npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short
81
82
  | Amp | `~/.local/share/amp/threads/`; cache-creation tokens are included in input usage |
82
83
  | Droid | `~/.factory/sessions/` |
83
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. |
84
- | Hermes | `$HERMES_HOME/state.db` (default `~/.hermes/state.db`) + `profiles/<name>/state.db` (SQLite, multi-profile). 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. |
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. |
85
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` |
86
- | 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 |
87
88
  | Roo Code | `<host>/User/globalStorage/rooveterinaryinc.roo-cline/{tasks/_index.json,tasks/<id>/{history_item,ui_messages}.json}` (walks all VSCode-fork hosts) |
88
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. |
89
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. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.28",
3
+ "version": "0.10.30",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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
  }
@@ -0,0 +1,16 @@
1
+ import { statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ export function getColaSessionsDir() {
6
+ return join(process.env.COLA_DATA_DIR ?? join(homedir(), '.cola'), 'sessions');
7
+ }
8
+
9
+ export function findColaDataDirs() {
10
+ const dir = getColaSessionsDir();
11
+ try {
12
+ return statSync(dir).isDirectory() ? [dir] : [];
13
+ } catch {
14
+ return [];
15
+ }
16
+ }
@@ -115,6 +115,11 @@ 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',
121
+ 'COLA_DATA_DIR',
122
+ 'HERMES_HOME',
118
123
  'MCODE_HOME',
119
124
  'MIMOCODE_HOME',
120
125
  'MIMOCODE_DB',
@@ -0,0 +1,59 @@
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ // Match the Hermes CLI/Desktop layout. Windows installers use LOCALAPPDATA;
6
+ // Desktop falls back to an existing ~/.hermes only before that native root exists.
7
+ export function getHermesHome({ onError = () => {} } = {}) {
8
+ const explicit = process.env.HERMES_HOME?.trim();
9
+ if (explicit) return explicit;
10
+
11
+ const legacy = join(homedir(), '.hermes');
12
+ if (process.platform !== 'win32') return legacy;
13
+
14
+ const localAppData = process.env.LOCALAPPDATA?.trim() || join(homedir(), 'AppData', 'Local');
15
+ const native = join(localAppData, 'hermes');
16
+ return !statIfPresent(native, onError)?.isDirectory()
17
+ && statIfPresent(legacy, onError)?.isDirectory() ? legacy : native;
18
+ }
19
+
20
+ function statIfPresent(path, onError) {
21
+ try {
22
+ return statSync(path);
23
+ } catch (err) {
24
+ if (err.code !== 'ENOENT') onError(err);
25
+ return null;
26
+ }
27
+ }
28
+
29
+ // Parsing must fail on unreadable stores: a partial result would allow sync
30
+ // to prune that profile's previous state. Detection alone is best-effort.
31
+ export function discoverHermesDatabases({ onError = err => { throw err; } } = {}) {
32
+ const home = getHermesHome({ onError });
33
+ const dbs = [];
34
+
35
+ const defaultDb = join(home, 'state.db');
36
+ if (statIfPresent(defaultDb, onError)) dbs.push({ path: defaultDb, profile: 'default' });
37
+
38
+ const profilesDir = join(home, 'profiles');
39
+ let entries;
40
+ try {
41
+ entries = readdirSync(profilesDir, { withFileTypes: true });
42
+ } catch (err) {
43
+ if (err.code !== 'ENOENT') onError(err);
44
+ return dbs;
45
+ }
46
+ for (const entry of entries) {
47
+ if (!entry.isDirectory()) continue;
48
+ const profileDb = join(profilesDir, entry.name, 'state.db');
49
+ if (statIfPresent(profileDb, onError)?.isFile()) {
50
+ dbs.push({ path: profileDb, profile: entry.name });
51
+ }
52
+ }
53
+
54
+ return dbs;
55
+ }
56
+
57
+ export function findHermesDataDirs() {
58
+ return discoverHermesDatabases({ onError: () => {} }).map(db => db.path);
59
+ }
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)
@@ -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
  }
@@ -0,0 +1,17 @@
1
+ import { getColaSessionsDir } from '../cola-roots.js';
2
+ import { parsePiSessionJsonl } from './pi-session-jsonl.js';
3
+
4
+ /** Cola 1.4.4 writes Pi-compatible transcripts under sessions/<scope>/. */
5
+ export async function parse() {
6
+ const result = await parsePiSessionJsonl({
7
+ source: 'cola',
8
+ sessionsDirs: [getColaSessionsDir()],
9
+ // Scope slugs may identify channels or people, not projects. The session
10
+ // header's cwd supplies a project when present; otherwise keep unknown.
11
+ projectFromPath: () => 'unknown',
12
+ deduplicateCopiedSessions: true,
13
+ });
14
+ // A missing part of the store must not overwrite a complete uploaded bucket
15
+ // with a partial sum, even when several projects are hidden behind unknown.
16
+ return result.skipped ? { ...result, buckets: [], sessions: [] } : result;
17
+ }
@@ -1,24 +1,22 @@
1
- import { existsSync, readdirSync, statSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { homedir } from 'node:os';
1
+ import { discoverHermesDatabases } from '../hermes-roots.js';
4
2
  import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
3
  import { toCount } from './fs-utils.js';
6
4
  import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
7
5
 
8
- const HERMES_HOME = process.env.HERMES_HOME || join(homedir(), '.hermes');
9
-
10
6
  /**
11
7
  * Parse Hermes Agent usage data from its SQLite databases.
12
8
  *
13
9
  * Hermes supports multiple profiles — the default profile lives at
14
- * ~/.hermes/state.db, while named profiles live at ~/.hermes/profiles/<name>/state.db.
10
+ * <home>/state.db, while named profiles live at <home>/profiles/<name>/state.db.
11
+ * The home is shared by CLI/Desktop: ~/.hermes on macOS/Linux, LOCALAPPDATA/hermes
12
+ * on Windows, or an explicit HERMES_HOME.
15
13
  * Each profile is an independent HERMES_HOME with its own state.db, so we scan all of them.
16
14
  *
17
15
  * Token buckets come from the sessions table (cumulative per-session totals).
18
16
  * Session timing comes from the messages table (per-message role + timestamp).
19
17
  */
20
18
  export async function parse() {
21
- const dbs = discoverDbPaths(HERMES_HOME);
19
+ const dbs = discoverHermesDatabases();
22
20
  if (dbs.length === 0) return { buckets: [], sessions: [] };
23
21
 
24
22
  const entries = [];
@@ -68,19 +66,15 @@ export async function parse() {
68
66
  });
69
67
  }
70
68
 
71
- let messageRows;
72
- try {
73
- messageRows = queryDb(dbPath, `SELECT
74
- session_id as sessionId,
75
- role,
76
- timestamp
77
- FROM messages
78
- WHERE role IN ('user', 'assistant')
79
- ORDER BY timestamp`);
80
- } catch {
81
- // Messages query failed for this profile — skip its session events
82
- continue;
83
- }
69
+ // A failed query is not an empty session history. Let sync protect this
70
+ // source's previous state instead of uploading/pruning a partial result.
71
+ const messageRows = queryDb(dbPath, `SELECT
72
+ session_id as sessionId,
73
+ role,
74
+ timestamp
75
+ FROM messages
76
+ WHERE role IN ('user', 'assistant')
77
+ ORDER BY timestamp`);
84
78
 
85
79
  for (const row of messageRows) {
86
80
  const timestamp = new Date(row.timestamp * 1000);
@@ -99,34 +93,6 @@ export async function parse() {
99
93
  return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
100
94
  }
101
95
 
102
- function discoverDbPaths(home) {
103
- const dbs = [];
104
-
105
- const defaultDb = join(home, 'state.db');
106
- if (existsSync(defaultDb)) dbs.push({ path: defaultDb, profile: 'default' });
107
-
108
- const profilesDir = join(home, 'profiles');
109
- if (existsSync(profilesDir)) {
110
- let entries;
111
- try {
112
- entries = readdirSync(profilesDir, { withFileTypes: true });
113
- } catch {
114
- return dbs;
115
- }
116
- for (const entry of entries) {
117
- if (!entry.isDirectory()) continue;
118
- const profileDb = join(profilesDir, entry.name, 'state.db');
119
- try {
120
- if (statSync(profileDb).isFile()) dbs.push({ path: profileDb, profile: entry.name });
121
- } catch {
122
- // missing or unreadable — skip
123
- }
124
- }
125
- }
126
-
127
- return dbs;
128
- }
129
-
130
96
  function queryDb(dbPath, sql) {
131
97
  return queryDbJson(dbPath, sql);
132
98
  }
@@ -23,6 +23,7 @@ import { parse as parseKiro } from './kiro.js';
23
23
  import { parse as parseMcode } from './mcode.js';
24
24
  import { parse as parseMimocode } from './mimocode.js';
25
25
  import { parse as parsePiCodingAgent } from './pi-coding-agent.js';
26
+ import { parse as parseCola } from './cola.js';
26
27
  import { parse as parseZcode } from './zcode.js';
27
28
  import { parse as parseTraeCli } from './trae-cli.js';
28
29
  import { parse as parseWorkbuddy } from './workbuddy.js';
@@ -31,6 +32,7 @@ import { parseQoder, parseQoderCn } from './qoder.js';
31
32
  export const parsers = {
32
33
  'claude-code': parseClaudeCode,
33
34
  'codex': parseCodex,
35
+ 'cola': parseCola,
34
36
  'grok': parseGrok,
35
37
  'copilot-cli': parseCopilotCli,
36
38
  'craft-agent': parseCraftAgent,
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
1
+ import { readdirSync, readFileSync, realpathSync } from 'node:fs';
2
2
  import { basename, join, relative } from 'node:path';
3
3
  import { aggregateToBuckets, extractSessions } from './aggregate.js';
4
4
  import { projectFromCwd, toCount } from './fs-utils.js';
@@ -11,12 +11,13 @@ function warn(ctx, message) {
11
11
  }
12
12
 
13
13
  function findJsonlFiles(dir, includeFile, ctx) {
14
- if (!existsSync(dir)) return [];
15
14
  let children;
16
15
  try {
17
16
  children = readdirSync(dir, { withFileTypes: true });
18
17
  } catch (err) {
19
- warn(ctx, `${ctx.source}: cannot read directory ${dir}: ${err.message}`);
18
+ if (err.code !== 'ENOENT') {
19
+ warn(ctx, `${ctx.source}: cannot read directory ${dir}: ${err.message}`);
20
+ }
20
21
  return [];
21
22
  }
22
23
 
@@ -56,6 +57,7 @@ export async function parsePiSessionJsonl({
56
57
  sessionsDirs,
57
58
  includeFile = () => true,
58
59
  projectFromPath = projectFromFirstDir,
60
+ deduplicateCopiedSessions = false,
59
61
  }) {
60
62
  const ctx = { source, warnings: [], incomplete: false };
61
63
  const entriesById = new Map();
@@ -63,6 +65,7 @@ export async function parsePiSessionJsonl({
63
65
  const eventsById = new Map();
64
66
  const anonymousEvents = [];
65
67
  const seenFiles = new Set();
68
+ const recordOwners = new Map();
66
69
 
67
70
  for (const sessionsDir of sessionsDirs) {
68
71
  for (const filePath of findJsonlFiles(sessionsDir, includeFile, ctx)) {
@@ -80,6 +83,8 @@ export async function parsePiSessionJsonl({
80
83
 
81
84
  let sessionId = basename(filePath, '.jsonl');
82
85
  let project = projectFromPath(filePath, sessionsDir) || 'unknown';
86
+ let sessionStartedAt = Infinity;
87
+ let seenHeader = false;
83
88
 
84
89
  for (const line of content.split('\n')) {
85
90
  if (!line.trim()) continue;
@@ -89,18 +94,40 @@ export async function parsePiSessionJsonl({
89
94
  } catch {
90
95
  continue;
91
96
  }
97
+ if (!obj || typeof obj !== 'object') continue;
92
98
 
93
99
  if (obj.type === 'session') {
100
+ if (deduplicateCopiedSessions && seenHeader) continue;
101
+ seenHeader = true;
94
102
  if (obj.id) sessionId = String(obj.id);
95
103
  if (obj.cwd) project = projectFromCwd(obj.cwd);
104
+ const startedAt = new Date(obj.timestamp).getTime();
105
+ sessionStartedAt = Number.isFinite(startedAt) ? startedAt : Infinity;
96
106
  continue;
97
107
  }
98
108
  if (obj.type !== 'message' || !obj.message) continue;
99
109
 
100
110
  const message = obj.message;
101
- const timestamp = new Date(obj.timestamp || message.timestamp || 0);
111
+ const rawTimestamp = obj.timestamp || message.timestamp;
112
+ const timestamp = new Date(rawTimestamp || 0);
102
113
  if (Number.isNaN(timestamp.getTime())) continue;
103
- const recordId = obj.id ? `${sessionId}:${obj.id}` : null;
114
+ const model = message.model || message.modelId || obj.model || obj.modelId || 'unknown';
115
+ // Cola copies a transcript with a new session header but unchanged
116
+ // records. Its short message ids are only unique within a session, so
117
+ // cross-session dedup also requires time, parent, role, and model.
118
+ // All existing Pi-family callers retain sessionId:id identities.
119
+ const recordId = !obj.id ? null : deduplicateCopiedSessions && rawTimestamp
120
+ ? JSON.stringify([obj.id, timestamp.getTime(), obj.parentId ?? null, message.role, model])
121
+ : `${sessionId}:${obj.id}`;
122
+
123
+ if (deduplicateCopiedSessions && recordId) {
124
+ const owner = recordOwners.get(recordId);
125
+ if (!owner || sessionStartedAt < owner.startedAt
126
+ || (sessionStartedAt === owner.startedAt && sessionId < owner.sessionId)
127
+ || (sessionStartedAt === owner.startedAt && sessionId === owner.sessionId && canonical < owner.filePath)) {
128
+ recordOwners.set(recordId, { sessionId, project, startedAt: sessionStartedAt, filePath: canonical });
129
+ }
130
+ }
104
131
 
105
132
  if (message.role === 'user' || message.role === 'assistant' || message.role === 'toolResult') {
106
133
  const event = {
@@ -130,7 +157,7 @@ export async function parsePiSessionJsonl({
130
157
 
131
158
  const entry = {
132
159
  source,
133
- model: message.model || message.modelId || obj.model || obj.modelId || 'unknown',
160
+ model,
134
161
  project,
135
162
  timestamp,
136
163
  inputTokens,
@@ -150,9 +177,18 @@ export async function parsePiSessionJsonl({
150
177
 
151
178
  const entries = [
152
179
  ...anonymousEntries,
153
- ...[...entriesById.values()].map(({ entry }) => entry),
180
+ ...[...entriesById].map(([id, { entry }]) => {
181
+ const owner = recordOwners.get(id);
182
+ return owner ? { ...entry, project: owner.project } : entry;
183
+ }),
184
+ ];
185
+ const events = [
186
+ ...anonymousEvents,
187
+ ...[...eventsById].map(([id, event]) => {
188
+ const owner = recordOwners.get(id);
189
+ return owner ? { ...event, sessionId: owner.sessionId, project: owner.project } : event;
190
+ }),
154
191
  ];
155
- const events = [...anonymousEvents, ...eventsById.values()];
156
192
  return {
157
193
  buckets: aggregateToBuckets(entries),
158
194
  sessions: extractSessions(events),
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
@@ -11,7 +11,9 @@ import {
11
11
  grokSessionsDir,
12
12
  } from './extra-roots.js';
13
13
  import { findClineDataDirs } from './cline-roots.js';
14
+ import { findColaDataDirs, getColaSessionsDir } from './cola-roots.js';
14
15
  import { findCraftDataDirs } from './craft-roots.js';
16
+ import { findHermesDataDirs, getHermesHome } from './hermes-roots.js';
15
17
  import { findOmpDataDirs, findPiDataDirs } from './pi-roots.js';
16
18
  import { findQoderDataDirs, getQoderProjectsDir } from './qoder-roots.js';
17
19
  import { findWorkbuddyDataDirs } from './workbuddy-roots.js';
@@ -266,6 +268,12 @@ export const TOOLS = [
266
268
  findCodexDataDirs(codexExtraHome, extraRootList(extraRoots?.codex))
267
269
  ),
268
270
  },
271
+ {
272
+ name: 'Cola',
273
+ id: 'cola',
274
+ dataDir: getColaSessionsDir(),
275
+ detectDataDirs: findColaDataDirs,
276
+ },
269
277
  {
270
278
  name: 'Grok',
271
279
  id: 'grok',
@@ -396,7 +404,8 @@ export const TOOLS = [
396
404
  {
397
405
  name: 'Hermes',
398
406
  id: 'hermes',
399
- dataDir: join(homedir(), '.hermes', 'state.db'),
407
+ dataDir: join(getHermesHome(), 'state.db'),
408
+ detectDataDirs: findHermesDataDirs,
400
409
  },
401
410
  {
402
411
  name: 'Kiro',