@vibe-cafe/vibe-usage 0.10.10 → 0.10.12

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.
@@ -1,8 +1,8 @@
1
- import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
1
+ import { existsSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
- import { homedir, tmpdir } from 'node:os';
4
- import { aggregateToBuckets } from './index.js';
5
- import { queryDbJson } from './sqlite.js';
3
+ import { homedir } from 'node:os';
4
+ import { aggregateToBuckets } from './aggregate.js';
5
+ import { queryDbJsonSnapshotOnLock, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
6
6
 
7
7
  const STATE_DB_RELATIVE = join('User', 'globalStorage', 'state.vscdb');
8
8
  const ACCESS_TOKEN_KEY = 'cursorAuth/accessToken';
@@ -42,41 +42,19 @@ export function getCursorStateDbPath() {
42
42
  }
43
43
 
44
44
  function readAccessToken(dbPath) {
45
- let snapshotDir = null;
46
- let queryPath = dbPath;
47
- try {
48
- return queryAccessToken(queryPath);
49
- } catch (err) {
50
- // Cursor app holds a write lock; copy WAL set to a temp dir and retry
51
- if (!isLockError(err)) throw err;
52
- snapshotDir = mkdtempSync(join(tmpdir(), 'vibe-usage-cursor-'));
53
- queryPath = join(snapshotDir, 'state.vscdb');
54
- copyFileSync(dbPath, queryPath);
55
- for (const suffix of ['-shm', '-wal']) {
56
- const companion = `${dbPath}${suffix}`;
57
- if (existsSync(companion)) copyFileSync(companion, `${queryPath}${suffix}`);
58
- }
59
- try {
60
- return queryAccessToken(queryPath);
61
- } finally {
62
- rmSync(snapshotDir, { recursive: true, force: true });
63
- }
64
- }
65
- }
66
-
67
- function queryAccessToken(dbPath) {
45
+ // Cursor app holds a write lock; queryDbJsonSnapshotOnLock copies the WAL set
46
+ // to a temp dir and retries on "database is locked".
68
47
  const sql = `SELECT value FROM ItemTable WHERE key = '${ACCESS_TOKEN_KEY}' LIMIT 1`;
69
- const rows = queryDbJson(dbPath, sql, { maxBuffer: 4 * 1024 * 1024, timeout: 15000 });
48
+ const rows = queryDbJsonSnapshotOnLock(dbPath, sql, {
49
+ tempPrefix: 'vibe-usage-cursor-',
50
+ opts: { maxBuffer: 4 * 1024 * 1024, timeout: 15000 },
51
+ });
70
52
  const value = rows[0]?.value;
71
53
  if (typeof value !== 'string') return null;
72
54
  const t = value.trim();
73
55
  return t || null;
74
56
  }
75
57
 
76
- function isLockError(err) {
77
- return err && typeof err.message === 'string' && /database is locked/i.test(err.message);
78
- }
79
-
80
58
  function decodeJwtSub(token) {
81
59
  const payload = token.split('.')[1];
82
60
  if (!payload) return null;
@@ -197,8 +175,8 @@ export async function parse() {
197
175
  try {
198
176
  token = readAccessToken(dbPath);
199
177
  } catch (err) {
200
- if (err && typeof err.message === 'string' && err.message.includes('ENOENT')) {
201
- throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync Cursor data.');
178
+ if (isSqliteUnavailableError(err)) {
179
+ throw sqliteUnavailableError('Cursor');
202
180
  }
203
181
  throw err;
204
182
  }
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { aggregateToBuckets, extractSessions } from './index.js';
3
- import { queryDbJson } from './sqlite.js';
2
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
3
+ import { toCount } from './fs-utils.js';
4
+ import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
4
5
  import { getDimAgentDbPath } from '../tools.js';
5
6
 
6
7
  const SOURCE = 'dimagent';
@@ -12,11 +13,6 @@ function projectName(cwd) {
12
13
  return parts.at(-1) || 'unknown';
13
14
  }
14
15
 
15
- function tokenCount(value) {
16
- const count = Number(value);
17
- return Number.isFinite(count) && count > 0 ? count : 0;
18
- }
19
-
20
16
  function usageSignature(row) {
21
17
  return [
22
18
  row.runId || '',
@@ -55,10 +51,10 @@ function parseUsageRows(rows) {
55
51
  const timestamp = new Date(row.createdAt);
56
52
  if (Number.isNaN(timestamp.getTime())) continue;
57
53
 
58
- const promptTokens = tokenCount(usage.promptTokens);
59
- const cachedInputTokens = tokenCount(usage.cacheReadTokens);
54
+ const promptTokens = toCount(usage.promptTokens);
55
+ const cachedInputTokens = toCount(usage.cacheReadTokens);
60
56
  const inputTokens = Math.max(0, promptTokens - cachedInputTokens);
61
- const outputTokens = tokenCount(usage.completionTokens);
57
+ const outputTokens = toCount(usage.completionTokens);
62
58
  if (inputTokens + outputTokens + cachedInputTokens === 0) continue;
63
59
 
64
60
  entries.push({
@@ -80,9 +76,7 @@ function queryDb(dbPath, sql) {
80
76
  try {
81
77
  return queryDbJson(dbPath, sql);
82
78
  } catch (err) {
83
- if (err.code === 'ENOENT' || err.status === 127 || err.message?.includes('ENOENT')) {
84
- throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync DimAgent data.');
85
- }
79
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('DimAgent');
86
80
  throw err;
87
81
  }
88
82
  }
@@ -1,7 +1,7 @@
1
1
  import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
2
  import { join, basename, dirname } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { aggregateToBuckets, extractSessions } from './index.js';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
5
 
6
6
  const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions');
7
7
 
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
3
3
  import { basename, join, relative } from 'node:path';
4
4
  import zlib from 'node:zlib';
5
5
  import { getDshSessionsDir } from '../tools.js';
6
- import { aggregateToBuckets, extractSessions } from './index.js';
6
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
7
7
 
8
8
  const SOURCE = 'dsh';
9
9
 
@@ -0,0 +1,36 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { basename } from 'node:path';
3
+
4
+ // Small shared filesystem/parsing helpers used across parsers. Keeping them in
5
+ // one place removes the same ~10-line functions copied into every parser.
6
+
7
+ /** Read and parse a JSON file, returning null on any failure. */
8
+ export function readJsonSafe(path) {
9
+ try {
10
+ return JSON.parse(readFileSync(path, 'utf-8'));
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ /** Last path component (project name), or 'unknown'. */
17
+ export function projectFromPath(absPath) {
18
+ if (!absPath || typeof absPath !== 'string') return 'unknown';
19
+ const trimmed = absPath.replace(/[\\/]+$/, '');
20
+ const name = basename(trimmed);
21
+ return name || 'unknown';
22
+ }
23
+
24
+ /** Last path component of a cwd value (works for both Unix and Windows paths). */
25
+ export function projectFromCwd(cwd, fallback = 'unknown') {
26
+ if (typeof cwd !== 'string') return fallback;
27
+ const trimmed = cwd.trim().replace(/[\\/]+$/, '');
28
+ if (!trimmed) return fallback;
29
+ return trimmed.split(/[\\/]/).filter(Boolean).at(-1) || fallback;
30
+ }
31
+
32
+ /** Coerce a token count to a finite positive number, else 0. */
33
+ export function toCount(value) {
34
+ const n = Number(value);
35
+ return Number.isFinite(n) && n > 0 ? n : 0;
36
+ }
@@ -1,7 +1,7 @@
1
1
  import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
2
  import { join, basename } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { aggregateToBuckets, extractSessions } from './index.js';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
5
 
6
6
  const TMP_DIR = join(homedir(), '.gemini', 'tmp');
7
7
 
@@ -1,8 +1,9 @@
1
1
  import { createReadStream, existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { createInterface } from 'node:readline';
3
- import { basename, join } from 'node:path';
3
+ import { join } from 'node:path';
4
4
  import { findGrokDataDirs, getGrokSessionsDir } from '../tools.js';
5
- import { aggregateToBuckets, extractSessions } from './index.js';
5
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
6
+ import { readJsonSafe, projectFromPath } from './fs-utils.js';
6
7
 
7
8
  const SOURCE = 'grok';
8
9
 
@@ -23,21 +24,6 @@ const SOURCE = 'grok';
23
24
  * reads), matching Codex/Copilot so totalTokens does not double-count cache.
24
25
  */
25
26
 
26
- function readJsonSafe(path) {
27
- try {
28
- return JSON.parse(readFileSync(path, 'utf-8'));
29
- } catch {
30
- return null;
31
- }
32
- }
33
-
34
- function projectFromPath(absPath) {
35
- if (!absPath || typeof absPath !== 'string') return 'unknown';
36
- const trimmed = absPath.replace(/[\\/]+$/, '');
37
- const name = basename(trimmed);
38
- return name || 'unknown';
39
- }
40
-
41
27
  /** Decode a sessions group dirname; fall back to basename after decode. */
42
28
  function projectFromGroupDir(groupName, groupPath) {
43
29
  const cwdFile = join(groupPath, '.cwd');
@@ -1,8 +1,8 @@
1
1
  import { existsSync, readdirSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { aggregateToBuckets, extractSessions } from './index.js';
5
- import { queryDbJson } from './sqlite.js';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
+ import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
6
6
 
7
7
  const HERMES_HOME = process.env.HERMES_HOME || join(homedir(), '.hermes');
8
8
 
@@ -37,9 +37,7 @@ export async function parse() {
37
37
  FROM sessions
38
38
  WHERE input_tokens > 0 OR output_tokens > 0`);
39
39
  } catch (err) {
40
- if (err.message && err.message.includes('ENOENT')) {
41
- throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync Hermes data.');
42
- }
40
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('Hermes');
43
41
  throw err;
44
42
  }
45
43
 
@@ -1,4 +1,3 @@
1
- import { createHash } from 'node:crypto';
2
1
  import { parse as parseClaudeCode } from './claude-code.js';
3
2
  import { parse as parseCline } from './cline.js';
4
3
  import { parse as parseCodex } from './codex.js';
@@ -57,150 +56,4 @@ export const parsers = {
57
56
  'zcode': parseZcode,
58
57
  };
59
58
 
60
-
61
- export function roundToHalfHour(date) {
62
- const d = new Date(date);
63
- d.setMinutes(d.getMinutes() < 30 ? 0 : 30, 0, 0);
64
- return d;
65
- }
66
-
67
- // Server column limits (usage_buckets: model varchar(100), project varchar(200)).
68
- // Anything longer aborts the whole INSERT chunk with 22001, so clamp here.
69
- const MODEL_MAX_LENGTH = 100;
70
- const PROJECT_MAX_LENGTH = 200;
71
-
72
- // Server token columns are bigint — a single fractional/NaN value aborts the
73
- // whole INSERT chunk with 22P02, taking every other tool's rows in the batch
74
- // down with it.
75
- function toTokenCount(value) {
76
- const n = Number(value);
77
- if (!Number.isFinite(n) || n <= 0) return 0;
78
- return Math.round(n);
79
- }
80
-
81
- export function aggregateToBuckets(entries) {
82
- const map = new Map();
83
-
84
- for (const e of entries) {
85
- const model = String(e.model || 'unknown').slice(0, MODEL_MAX_LENGTH);
86
- const project = String(e.project || 'unknown').slice(0, PROJECT_MAX_LENGTH);
87
- const bucketStart = roundToHalfHour(e.timestamp).toISOString();
88
- const key = `${e.source}|${model}|${project}|${e.hostname || ''}|${bucketStart}`;
89
-
90
- if (!map.has(key)) {
91
- map.set(key, {
92
- source: e.source,
93
- model,
94
- project,
95
- // Cloud-sourced parsers (cursor) pre-set a fixed hostname sentinel; it
96
- // must survive aggregation, or sync.js stamps the machine hostname and
97
- // every machine gets its own duplicate row server-side.
98
- ...(e.hostname ? { hostname: e.hostname } : {}),
99
- bucketStart,
100
- inputTokens: 0,
101
- outputTokens: 0,
102
- cachedInputTokens: 0,
103
- reasoningOutputTokens: 0,
104
- });
105
- }
106
-
107
- const b = map.get(key);
108
- b.inputTokens += e.inputTokens || 0;
109
- b.outputTokens += e.outputTokens || 0;
110
- b.cachedInputTokens += e.cachedInputTokens || 0;
111
- b.reasoningOutputTokens += e.reasoningOutputTokens || 0;
112
- }
113
-
114
- // Clamp after summation, not per entry — rounding each entry first would
115
- // discard sub-integer values instead of letting them accumulate.
116
- return Array.from(map.values()).map((b) => {
117
- const inputTokens = toTokenCount(b.inputTokens);
118
- const outputTokens = toTokenCount(b.outputTokens);
119
- const cachedInputTokens = toTokenCount(b.cachedInputTokens);
120
- const reasoningOutputTokens = toTokenCount(b.reasoningOutputTokens);
121
- return {
122
- ...b,
123
- inputTokens,
124
- outputTokens,
125
- cachedInputTokens,
126
- reasoningOutputTokens,
127
- totalTokens: inputTokens + outputTokens + reasoningOutputTokens,
128
- };
129
- });
130
- }
131
-
132
- /**
133
- * Extract session metadata from timing events.
134
- * Each event: { sessionId, source, project, timestamp: Date, role: 'user'|'assistant' }
135
- *
136
- * Turn = first AI response → last AI response before next user prompt.
137
- * activeSeconds = sum(generation durations), excluding queue/TTFT wait.
138
- * durationSeconds = wall clock from first to last message.
139
- */
140
- export function extractSessions(events) {
141
- const groups = new Map();
142
- for (const e of events) {
143
- if (!groups.has(e.sessionId)) groups.set(e.sessionId, []);
144
- groups.get(e.sessionId).push(e);
145
- }
146
-
147
- const sessions = [];
148
- for (const [sessionId, sessionEvents] of groups) {
149
- sessionEvents.sort((a, b) => a.timestamp - b.timestamp);
150
-
151
- const first = sessionEvents[0];
152
- const last = sessionEvents[sessionEvents.length - 1];
153
- const durationSeconds = Math.round((last.timestamp - first.timestamp) / 1000);
154
-
155
- let activeSeconds = 0;
156
- let turnStart = null;
157
- let turnEnd = null;
158
- let waitingForFirstResponse = false;
159
-
160
- for (const event of sessionEvents) {
161
- if (event.role === 'user') {
162
- if (turnStart !== null && turnEnd !== null && turnEnd > turnStart) {
163
- activeSeconds += Math.round((turnEnd - turnStart) / 1000);
164
- }
165
- turnStart = null;
166
- turnEnd = null;
167
- waitingForFirstResponse = true;
168
- } else if (waitingForFirstResponse) {
169
- turnStart = event.timestamp;
170
- turnEnd = event.timestamp;
171
- waitingForFirstResponse = false;
172
- } else if (turnStart !== null) {
173
- turnEnd = event.timestamp;
174
- }
175
- }
176
- if (turnStart !== null && turnEnd !== null && turnEnd > turnStart) {
177
- activeSeconds += Math.round((turnEnd - turnStart) / 1000);
178
- }
179
-
180
- const userPromptHours = new Array(24).fill(0);
181
- let userMessageCount = 0;
182
- for (const event of sessionEvents) {
183
- if (event.role === 'user') {
184
- userMessageCount++;
185
- userPromptHours[event.timestamp.getUTCHours()]++;
186
- }
187
- }
188
-
189
- const sessionHash = createHash('sha256').update(sessionId).digest('hex').slice(0, 16);
190
-
191
- sessions.push({
192
- source: first.source,
193
- project: first.project || 'unknown',
194
- sessionHash,
195
- firstMessageAt: first.timestamp.toISOString(),
196
- lastMessageAt: last.timestamp.toISOString(),
197
- durationSeconds,
198
- activeSeconds,
199
- messageCount: sessionEvents.length,
200
- userMessageCount,
201
- userPromptHours,
202
- });
203
- }
204
-
205
- return sessions;
206
- }
59
+ export { roundToHalfHour, aggregateToBuckets, extractSessions } from './aggregate.js';
@@ -2,7 +2,7 @@ import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
2
  import { join, basename } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { createHash } from 'node:crypto';
5
- import { aggregateToBuckets, extractSessions } from './index.js';
5
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
6
6
 
7
7
  /**
8
8
  * Kimi Code CLI parser. MoonshotAI/kimi-cli (a.k.a. "Kimi Code").
@@ -1,18 +1,15 @@
1
1
  import {
2
- copyFileSync,
3
2
  createReadStream,
4
3
  existsSync,
5
- mkdtempSync,
6
4
  readFileSync,
7
5
  readdirSync,
8
- rmSync,
9
6
  statSync,
10
7
  } from 'node:fs';
11
8
  import { createInterface } from 'node:readline';
12
9
  import { dirname, join, resolve } from 'node:path';
13
- import { homedir, tmpdir } from 'node:os';
14
- import { aggregateToBuckets } from './index.js';
15
- import { queryDbJson } from './sqlite.js';
10
+ import { homedir } from 'node:os';
11
+ import { aggregateToBuckets } from './aggregate.js';
12
+ import { queryDbJsonSnapshotOnLock, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
16
13
 
17
14
  const KIRO_AGENT_RELATIVE = join('User', 'globalStorage', 'kiro.kiroagent');
18
15
  const KIRO_USER_RELATIVE = 'User';
@@ -122,37 +119,9 @@ export function getKiroCliSessionsDir() {
122
119
  return existsSync(def) ? def : null;
123
120
  }
124
121
 
125
- function isLockError(err) {
126
- return err && typeof err.message === 'string' && /database is locked/i.test(err.message);
127
- }
128
-
129
- function queryDb(dbPath, sql) {
130
- return queryDbJson(dbPath, sql);
131
- }
132
-
133
- function queryDbSnapshotOnLock(dbPath, sql) {
134
- try {
135
- return queryDb(dbPath, sql);
136
- } catch (err) {
137
- if (!isLockError(err)) throw err;
138
- const snapshotDir = mkdtempSync(join(tmpdir(), 'vibe-usage-kiro-'));
139
- const queryPath = join(snapshotDir, 'data.sqlite3');
140
- copyFileSync(dbPath, queryPath);
141
- for (const suffix of ['-shm', '-wal']) {
142
- const companion = `${dbPath}${suffix}`;
143
- if (existsSync(companion)) copyFileSync(companion, `${queryPath}${suffix}`);
144
- }
145
- try {
146
- return queryDb(queryPath, sql);
147
- } finally {
148
- rmSync(snapshotDir, { recursive: true, force: true });
149
- }
150
- }
151
- }
152
-
153
122
  function queryOptionalDb(dbPath, sql) {
154
123
  try {
155
- return queryDbSnapshotOnLock(dbPath, sql);
124
+ return queryDbJsonSnapshotOnLock(dbPath, sql, { tempPrefix: 'vibe-usage-kiro-' });
156
125
  } catch (err) {
157
126
  const msg = err && typeof err.message === 'string' ? err.message : '';
158
127
  if (/no such table|no such column/i.test(msg)) return [];
@@ -167,7 +136,7 @@ const TOKENS_SQL =
167
136
  'ORDER BY id ASC';
168
137
 
169
138
  function readLegacyDb(dbPath) {
170
- return queryDbSnapshotOnLock(dbPath, TOKENS_SQL);
139
+ return queryDbJsonSnapshotOnLock(dbPath, TOKENS_SQL, { tempPrefix: 'vibe-usage-kiro-' });
171
140
  }
172
141
 
173
142
  // Legacy Kiro dev telemetry fallback. This is opt-in because recent Kiro builds
@@ -790,9 +759,7 @@ export async function parse() {
790
759
  return { buckets: aggregateToBuckets(estimateEntries), sessions: [] };
791
760
  }
792
761
  } catch (err) {
793
- if (err && typeof err.message === 'string' && err.message.includes('ENOENT')) {
794
- throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync Kiro CLI data.');
795
- }
762
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('Kiro CLI');
796
763
  throw err;
797
764
  }
798
765
 
@@ -1,8 +1,8 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { basename } from 'node:path';
3
3
  import { getMimocodeDbPath } from '../tools.js';
4
- import { aggregateToBuckets, extractSessions } from './index.js';
5
- import { queryDbJson } from './sqlite.js';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
+ import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
6
6
 
7
7
  export { getMimocodeDbPath as resolveMimocodeDbPath };
8
8
 
@@ -36,9 +36,7 @@ export async function parse() {
36
36
  ${externalImportFilter}
37
37
  `);
38
38
  } catch (err) {
39
- if (err.status === 127 || err.message?.includes('ENOENT')) {
40
- throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync MiMoCode data.');
41
- }
39
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('MiMoCode');
42
40
  throw err;
43
41
  }
44
42
 
@@ -1,7 +1,7 @@
1
1
  import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { aggregateToBuckets, extractSessions } from './index.js';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
5
 
6
6
  // OpenClaw stores data at ~/.openclaw/agents/<agentId>/sessions/*.jsonl
7
7
  // Profile deployments use ~/.openclaw-<profile>/agents/...
@@ -1,8 +1,8 @@
1
1
  import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
2
  import { join, basename } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { aggregateToBuckets, extractSessions } from './index.js';
5
- import { queryDbJson } from './sqlite.js';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
+ import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
6
6
 
7
7
  const DATA_DIR = join(homedir(), '.local', 'share', 'opencode');
8
8
  const DB_PATH = join(DATA_DIR, 'opencode.db');
@@ -37,9 +37,7 @@ function parseFromSqlite() {
37
37
  try {
38
38
  rows = queryDbJson(DB_PATH, query);
39
39
  } catch (err) {
40
- if (err.status === 127 || (err.message && err.message.includes('ENOENT'))) {
41
- throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync opencode data.');
42
- }
40
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('OpenCode');
43
41
  throw err;
44
42
  }
45
43
  if (!rows.length) return { buckets: [], sessions: [] };
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { basename, join, relative } from 'node:path';
3
- import { aggregateToBuckets, extractSessions } from './index.js';
3
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
4
+ import { projectFromCwd, toCount } from './fs-utils.js';
4
5
 
5
6
  const MAX_WARNINGS = 20;
6
7
 
@@ -28,17 +29,6 @@ function findJsonlFiles(dir, includeFile, ctx) {
28
29
  return files;
29
30
  }
30
31
 
31
- function tokenCount(value) {
32
- const number = Number(value);
33
- return Number.isFinite(number) && number > 0 ? number : 0;
34
- }
35
-
36
- export function projectFromCwd(cwd) {
37
- if (typeof cwd !== 'string') return 'unknown';
38
- const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean);
39
- return parts.at(-1) || 'unknown';
40
- }
41
-
42
32
  export function projectFromFirstDir(filePath, sessionsDir) {
43
33
  const first = relative(sessionsDir, filePath).split(/[\\/]/)[0];
44
34
  if (!first) return 'unknown';
@@ -105,12 +95,12 @@ export async function parsePiSessionJsonl({
105
95
 
106
96
  if (message.role !== 'assistant' || !message.usage) continue;
107
97
  const usage = message.usage;
108
- const inputTokens = tokenCount(usage.input) + tokenCount(usage.cacheWrite);
109
- const reasoningOutputTokens = tokenCount(usage.reasoningTokens);
98
+ const inputTokens = toCount(usage.input) + toCount(usage.cacheWrite);
99
+ const reasoningOutputTokens = toCount(usage.reasoningTokens);
110
100
  // OMP/Pi usage.output includes reasoning; the shared bucket contract
111
101
  // stores non-reasoning output and reasoning separately.
112
- const outputTokens = Math.max(0, tokenCount(usage.output) - reasoningOutputTokens);
113
- const cachedInputTokens = tokenCount(usage.cacheRead);
102
+ const outputTokens = Math.max(0, toCount(usage.output) - reasoningOutputTokens);
103
+ const cachedInputTokens = toCount(usage.cacheRead);
114
104
  const score = inputTokens + outputTokens + cachedInputTokens + reasoningOutputTokens;
115
105
  if (score === 0) continue;
116
106
 
@@ -1,7 +1,7 @@
1
1
  import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
2
  import { join, basename, sep } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { aggregateToBuckets, extractSessions } from './index.js';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
5
 
6
6
  /**
7
7
  * Qwen Code parser (Gemini CLI fork).
@@ -1,7 +1,8 @@
1
- import { readFileSync, readdirSync, statSync } from 'node:fs';
2
- import { basename, join } from 'node:path';
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { aggregateToBuckets, extractSessions } from './index.js';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
+ import { readJsonSafe, projectFromPath } from './fs-utils.js';
5
6
 
6
7
  const EXTENSION_ID = 'rooveterinaryinc.roo-cline';
7
8
 
@@ -35,17 +36,6 @@ export function findRooCodeExtensionDirs() {
35
36
  return dirs;
36
37
  }
37
38
 
38
- function readJsonSafe(path) {
39
- try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
40
- }
41
-
42
- function projectFromPath(absPath) {
43
- if (!absPath || typeof absPath !== 'string') return 'unknown';
44
- const trimmed = absPath.replace(/[\\/]+$/, '');
45
- const name = basename(trimmed);
46
- return name || 'unknown';
47
- }
48
-
49
39
  // Read all HistoryItems from `_index.json` if present, else fall back to
50
40
  // scanning per-task `history_item.json` files (Roo migrated to per-task
51
41
  // files in 2025; the index is a cache).