@vibe-cafe/vibe-usage 0.10.28 → 0.10.29

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
@@ -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,7 +82,7 @@ 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
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
88
  | Roo Code | `<host>/User/globalStorage/rooveterinaryinc.roo-cline/{tasks/_index.json,tasks/<id>/{history_item,ui_messages}.json}` (walks all VSCode-fork hosts) |
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.29",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -0,0 +1,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,8 @@ 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
+ 'COLA_DATA_DIR',
119
+ 'HERMES_HOME',
118
120
  'MCODE_HOME',
119
121
  'MIMOCODE_HOME',
120
122
  '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
+ }
@@ -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/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',