@vibe-cafe/vibe-usage 0.10.9 → 0.10.11

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,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
 
@@ -0,0 +1,454 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
3
+ import { basename, join, relative } from 'node:path';
4
+ import zlib from 'node:zlib';
5
+ import { getDshSessionsDir } from '../tools.js';
6
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
7
+
8
+ const SOURCE = 'dsh';
9
+
10
+ // DeepSeek Harness session-log format version this parser understands. DeepSeek
11
+ // Harness is currently in developer preview and is iterating rapidly — THERE
12
+ // WILL BE COMPATIBILITY-BREAKING CHANGES. When the CLI bumps the header
13
+ // `version` field, bump this constant (and the record-shape mapping below)
14
+ // after re-checking the on-disk format instead of guessing against stale
15
+ // assumptions.
16
+ const SESSION_FORMAT_VERSION = 0;
17
+
18
+ // Safety cap for a single session log. DSH stores many small zstd frames per
19
+ // file; anything beyond this is either a runaway log or not a session file.
20
+ const MAX_SESSION_FILE_BYTES = 256 * 1024 * 1024;
21
+
22
+ // Maximum decompressed size for one session log, for both decoder paths.
23
+ const MAX_DECOMPRESSED_SESSION_BYTES = 512 * 1024 * 1024;
24
+
25
+ // Zstandard frame magic (0xFD2FB528 little-endian) and the skippable-frame
26
+ // magic range (0x184D2A50–0x184D2A5F), per RFC 8878.
27
+ const ZSTD_MAGIC = 0xfd2fb528;
28
+ const SKIPPABLE_MAGIC_MIN = 0x184d2a50;
29
+ const SKIPPABLE_MAGIC_MAX = 0x184d2a5f;
30
+
31
+ const MAX_WARNINGS = 20;
32
+
33
+ /**
34
+ * Split concatenated Zstandard input into independently decodable frame ranges.
35
+ *
36
+ * DSH writes one frame for the header and one per durable append batch. Node's
37
+ * one-shot zstd API decodes only one standard frame, so each standard frame is
38
+ * returned as an independent `{ start, end }` range. Complete skippable frames
39
+ * are omitted without joining the standard frames around them. An incomplete
40
+ * tail is ignored, matching DSH's append-recovery boundary.
41
+ *
42
+ * @param {Buffer} buffer
43
+ * @returns {{ start: number, end: number }[]}
44
+ */
45
+ export function splitZstdFrames(buffer) {
46
+ const frames = [];
47
+ let pos = 0;
48
+ while (pos < buffer.length) {
49
+ if (pos + 4 > buffer.length) break;
50
+ const magic = buffer.readUInt32LE(pos);
51
+ if (magic >= SKIPPABLE_MAGIC_MIN && magic <= SKIPPABLE_MAGIC_MAX) {
52
+ if (pos + 8 > buffer.length) break;
53
+ const end = pos + 8 + buffer.readUInt32LE(pos + 4);
54
+ if (end > buffer.length) break;
55
+ pos = end;
56
+ continue;
57
+ }
58
+ if (magic !== ZSTD_MAGIC) {
59
+ throw new Error('invalid Zstandard frame magic at byte ' + pos);
60
+ }
61
+
62
+ const start = pos;
63
+ pos += 4;
64
+ if (pos >= buffer.length) break;
65
+ const descriptor = buffer[pos++];
66
+ if ((descriptor & 0x18) !== 0) {
67
+ throw new Error('reserved Zstandard frame-header bit at byte ' + (pos - 1));
68
+ }
69
+ const singleSegment = (descriptor & 0x20) !== 0;
70
+ const checksum = (descriptor & 0x04) !== 0;
71
+ const dictionaryFlag = descriptor & 0x03;
72
+ const contentSizeFlag = descriptor >>> 6;
73
+
74
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
75
+ const contentSizeBytes = contentSizeFlag === 0
76
+ ? (singleSegment ? 1 : 0)
77
+ : 1 << contentSizeFlag;
78
+ const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
79
+ if (pos + remainingHeaderBytes > buffer.length) break;
80
+ pos += remainingHeaderBytes;
81
+
82
+ for (;;) {
83
+ if (pos + 3 > buffer.length) return frames;
84
+ const blockHeader = buffer.readUIntLE(pos, 3);
85
+ pos += 3;
86
+ const lastBlock = (blockHeader & 1) !== 0;
87
+ const blockType = (blockHeader >>> 1) & 0x03;
88
+ const blockSize = blockHeader >>> 3;
89
+ if (blockType === 0x03) {
90
+ throw new Error('reserved Zstandard block type at byte ' + (pos - 3));
91
+ }
92
+ // An RLE block stores one encoded byte; blockSize is its decoded size.
93
+ const payloadBytes = blockType === 0x01 ? 1 : blockSize;
94
+ if (pos + payloadBytes > buffer.length) return frames;
95
+ pos += payloadBytes;
96
+ if (lastBlock) break;
97
+ }
98
+
99
+ if (checksum) {
100
+ if (pos + 4 > buffer.length) return frames;
101
+ pos += 4;
102
+ }
103
+ frames.push({ start, end: pos });
104
+ }
105
+ return frames;
106
+ }
107
+
108
+ const hasBuiltinZstd = typeof zlib.zstdDecompressSync === 'function';
109
+ let zstdCliProbe = null;
110
+ function hasZstdCli() {
111
+ if (zstdCliProbe !== null) return zstdCliProbe;
112
+ try {
113
+ execFileSync('zstd', ['--version'], { stdio: 'ignore', timeout: 5000 });
114
+ zstdCliProbe = true;
115
+ } catch {
116
+ zstdCliProbe = false;
117
+ }
118
+ return zstdCliProbe;
119
+ }
120
+
121
+ const ZSTD_HINT =
122
+ 'decompress with node:zlib zstd (Node >= 22.15) or install the zstd CLI';
123
+
124
+ /** Decompress the complete frames captured from one DSH session log. */
125
+ function decompressSessionLog(buffer, file) {
126
+ const frames = splitZstdFrames(buffer);
127
+ if (frames.length === 0) {
128
+ throw new Error('no complete zstd frames found in ' + relative(process.cwd(), file));
129
+ }
130
+
131
+ if (hasBuiltinZstd) {
132
+ const parts = [];
133
+ let remaining = MAX_DECOMPRESSED_SESSION_BYTES;
134
+ for (const { start, end } of frames) {
135
+ if (remaining <= 0) throw new Error('decompressed session log is too large');
136
+ const part = zlib.zstdDecompressSync(buffer.subarray(start, end), {
137
+ maxOutputLength: remaining,
138
+ });
139
+ parts.push(part);
140
+ remaining -= part.length;
141
+ }
142
+ return Buffer.concat(parts).toString('utf8');
143
+ }
144
+
145
+ if (!hasZstdCli()) {
146
+ const error = new Error('zstd unavailable for ' + file + ': ' + ZSTD_HINT);
147
+ error.code = 'ENOENT';
148
+ throw error;
149
+ }
150
+
151
+ const first = frames[0];
152
+ const last = frames.at(-1);
153
+ const contiguous = frames.every((frame, index) =>
154
+ index === 0 || frame.start === frames[index - 1].end
155
+ );
156
+ const completeInput = contiguous
157
+ ? buffer.subarray(first.start, last.end)
158
+ : Buffer.concat(frames.map(({ start, end }) => buffer.subarray(start, end)));
159
+ return execFileSync('zstd', ['-d', '-c'], {
160
+ input: completeInput,
161
+ maxBuffer: MAX_DECOMPRESSED_SESSION_BYTES,
162
+ stdio: ['pipe', 'pipe', 'ignore'],
163
+ }).toString('utf8');
164
+ }
165
+
166
+ function projectFromCwd(cwd) {
167
+ if (typeof cwd !== 'string') return 'unknown';
168
+ const trimmed = cwd.trim().replace(/[\\/]+$/, '');
169
+ if (!trimmed) return 'unknown';
170
+ const name = basename(trimmed.replace(/\\/g, '/'));
171
+ return name || 'unknown';
172
+ }
173
+
174
+ function toCount(value) {
175
+ const n = Number(value);
176
+ return Number.isFinite(n) && n > 0 ? n : 0;
177
+ }
178
+
179
+ function isUsageRecord(rec) {
180
+ return rec.type === 'assistant/message' && rec.data && typeof rec.data === 'object';
181
+ }
182
+
183
+ function isUserMessageRecord(rec) {
184
+ return (
185
+ rec.type === 'user/message' &&
186
+ rec.data &&
187
+ typeof rec.data === 'object' &&
188
+ rec.data.source?.kind === 'user'
189
+ );
190
+ }
191
+
192
+ /**
193
+ * Parse one decompressed session log into flat entries/events.
194
+ *
195
+ * Layout (DeepSeek Harness session-persistence-jsonl):
196
+ * line 0: {"type":"session","version":0,"id":...,"createdAt":...,"cwd":...,...}
197
+ * ... possibly a resumed/forked seed replay, then ...
198
+ * {"type":"session/end-seed",...} (absent in fresh sessions)
199
+ * {"type":"assistant/message","data":{"turn","step","message":{"source":
200
+ * {"kind":"model","provider","model"},...},"usage":{"inputTokens",
201
+ * "outputTokens","cacheReadTokens","cacheWriteTokens",
202
+ * "reasoningTokens"}},...}
203
+ *
204
+ * When a session is resumed (or forked) the stored log begins with a replay of
205
+ * the seed history. Everything before the LAST session/end-seed marker is a
206
+ * replay of records that were already counted from their original file, so it
207
+ * must be skipped or the same usage would be counted twice.
208
+ *
209
+ * usage.outputTokens includes reasoningTokens (verified against the
210
+ * session_projcache totals DSH itself maintains), so reasoning is split out of
211
+ * output before aggregation, like the Pi-family parsers.
212
+ */
213
+ function parseSessionText(text) {
214
+ const entries = [];
215
+ const events = [];
216
+ const lines = text.split('\n');
217
+
218
+ let header = null;
219
+ let endSeedIndex = -1;
220
+ for (let i = 0; i < lines.length; i++) {
221
+ if (lines[i].length === 0) continue;
222
+ let rec;
223
+ try {
224
+ rec = JSON.parse(lines[i]);
225
+ } catch {
226
+ continue; // torn final line: keep the complete records
227
+ }
228
+ if (rec && typeof rec === 'object') {
229
+ if (header === null && rec.type === 'session') header = rec;
230
+ if (rec.type === 'session/end-seed') endSeedIndex = i;
231
+ }
232
+ }
233
+
234
+ if (!header || typeof header.id !== 'string' || header.id.length === 0) {
235
+ throw new Error('missing session header record');
236
+ }
237
+ if (header.version !== SESSION_FORMAT_VERSION) {
238
+ const error = new Error(
239
+ 'session ' + header.id + ' uses format version ' + header.version +
240
+ ' (parser supports ' + SESSION_FORMAT_VERSION + ')',
241
+ );
242
+ error.code = 'UNSUPPORTED_FORMAT_VERSION';
243
+ throw error;
244
+ }
245
+
246
+ const sessionId = header.id;
247
+ const project = projectFromCwd(header.cwd);
248
+
249
+ for (let i = 0; i < lines.length; i++) {
250
+ if (i <= endSeedIndex) continue;
251
+ if (lines[i].length === 0) continue;
252
+ let rec;
253
+ try {
254
+ rec = JSON.parse(lines[i]);
255
+ } catch {
256
+ continue;
257
+ }
258
+ if (!rec || typeof rec !== 'object') continue;
259
+ const timestamp = new Date(rec.time);
260
+ if (Number.isNaN(timestamp.getTime())) continue;
261
+
262
+ if (isUserMessageRecord(rec)) {
263
+ events.push({ sessionId, source: SOURCE, project, timestamp, role: 'user' });
264
+ continue;
265
+ }
266
+ if (!isUsageRecord(rec)) continue;
267
+
268
+ // Every assistant/message marks the end of a billable step, even when its
269
+ // usage block is missing.
270
+ events.push({ sessionId, source: SOURCE, project, timestamp, role: 'assistant' });
271
+
272
+ const usage = rec.data.usage;
273
+ if (!usage || typeof usage !== 'object') continue;
274
+ // Harness counts are disjoint. The common bucket model has no cache-write
275
+ // column, so cache writes join uncached input, matching the other parsers.
276
+ const inputTokens = toCount(usage.inputTokens) + toCount(usage.cacheWriteTokens);
277
+ const cachedInputTokens = toCount(usage.cacheReadTokens);
278
+ const totalOutputTokens = toCount(usage.outputTokens);
279
+ const reasoningOutputTokens = Math.min(
280
+ totalOutputTokens,
281
+ toCount(usage.reasoningTokens),
282
+ );
283
+ const outputTokens = totalOutputTokens - reasoningOutputTokens;
284
+ if (inputTokens + cachedInputTokens + reasoningOutputTokens + outputTokens === 0) continue;
285
+
286
+ const model =
287
+ typeof rec.data.message?.source?.model === 'string' && rec.data.message.source.model
288
+ ? rec.data.message.source.model
289
+ : 'unknown';
290
+
291
+ entries.push({
292
+ source: SOURCE,
293
+ model,
294
+ project,
295
+ timestamp,
296
+ inputTokens,
297
+ outputTokens,
298
+ cachedInputTokens,
299
+ reasoningOutputTokens,
300
+ });
301
+ }
302
+
303
+ return { sessionId, entries, events };
304
+ }
305
+
306
+ /** List session log files under a DSH sessions root (session.jsonl[.zstd]). */
307
+ function listSessionFiles(sessionsDir, onFailure) {
308
+ const files = [];
309
+ const projectKeys = readdirSync(sessionsDir, { withFileTypes: true })
310
+ .sort((a, b) => a.name.localeCompare(b.name));
311
+ for (const projectKey of projectKeys) {
312
+ if (!projectKey.isDirectory()) continue;
313
+ const projectDir = join(sessionsDir, projectKey.name);
314
+ let sessionDirs;
315
+ try {
316
+ sessionDirs = readdirSync(projectDir, { withFileTypes: true })
317
+ .sort((a, b) => a.name.localeCompare(b.name));
318
+ } catch (error) {
319
+ onFailure(
320
+ 'dsh: cannot read project directory ' + projectKey.name +
321
+ ' (' + (error?.code || error?.message || 'read failed') + ')',
322
+ );
323
+ continue;
324
+ }
325
+ for (const sessionDir of sessionDirs) {
326
+ if (!sessionDir.isDirectory()) continue;
327
+ const sessionPath = join(projectDir, sessionDir.name);
328
+ for (const name of ['session.jsonl.zstd', 'session.jsonl']) {
329
+ const file = join(sessionPath, name);
330
+ try {
331
+ if (statSync(file).isFile()) {
332
+ files.push({ file, compressed: name.endsWith('.zstd') });
333
+ break;
334
+ }
335
+ } catch (error) {
336
+ if (error?.code !== 'ENOENT') {
337
+ onFailure(
338
+ 'dsh: cannot inspect ' + relative(sessionsDir, file) +
339
+ ' (' + (error?.code || error?.message || 'stat failed') + ')',
340
+ );
341
+ break;
342
+ }
343
+ }
344
+ }
345
+ }
346
+ }
347
+ return files;
348
+ }
349
+
350
+ /**
351
+ * DeepSeek Harness (dsh) parser.
352
+ *
353
+ * Reads $DSH_HOME/sessions/<project-key>/session-<id>/session.jsonl.zstd
354
+ * (default ~/.dsh, fixture/relocation override VIBE_USAGE_DSH_SESSIONS).
355
+ * Zstandard session logs are multi-frame; node:zlib zstd (Node >= 22.15)
356
+ * decodes one frame per call, so the buffer is walked frame-by-frame, with a
357
+ * `zstd` CLI fallback for older Node.
358
+ */
359
+ export async function parse() {
360
+ const sessionsDir = getDshSessionsDir();
361
+ if (!existsSync(sessionsDir)) return { buckets: [], sessions: [] };
362
+
363
+ const warnings = [];
364
+ let anyFailure = false;
365
+ const recordFailure = (message) => {
366
+ anyFailure = true;
367
+ if (warnings.length < MAX_WARNINGS && !warnings.includes(message)) {
368
+ warnings.push(message);
369
+ }
370
+ };
371
+
372
+ let files;
373
+ try {
374
+ files = listSessionFiles(sessionsDir, recordFailure);
375
+ } catch (error) {
376
+ recordFailure(
377
+ 'dsh: cannot read sessions directory ' + sessionsDir +
378
+ ' (' + (error?.code || error?.message || 'read failed') + ')',
379
+ );
380
+ return { buckets: [], sessions: [], skipped: true, warnings };
381
+ }
382
+ if (files.length === 0) {
383
+ const result = { buckets: [], sessions: [] };
384
+ if (anyFailure) Object.assign(result, { skipped: true, warnings });
385
+ return result;
386
+ }
387
+
388
+ const perSession = new Map(); // sessionId -> parsed view (largest complete log wins)
389
+ for (const { file, compressed } of files) {
390
+ let text;
391
+ try {
392
+ const stat = statSync(file);
393
+ if (!stat.isFile()) throw new Error('session log is no longer a file');
394
+ if (stat.size > MAX_SESSION_FILE_BYTES) {
395
+ throw new Error('session log too large (' + stat.size + ' bytes)');
396
+ }
397
+ const buffer = readFileSync(file);
398
+ if (buffer.length < stat.size) throw new Error('session log changed while reading');
399
+ const snapshot = buffer.length === stat.size ? buffer : buffer.subarray(0, stat.size);
400
+ text = compressed ? decompressSessionLog(snapshot, file) : snapshot.toString('utf8');
401
+ } catch (error) {
402
+ const reason = error?.code === 'ENOENT' && !hasBuiltinZstd && compressed
403
+ ? ZSTD_HINT
404
+ : error?.message || String(error);
405
+ recordFailure('dsh: skipping ' + relative(process.cwd(), file) + ' (' + reason + ')');
406
+ continue;
407
+ }
408
+
409
+ let parsed;
410
+ try {
411
+ parsed = parseSessionText(text);
412
+ } catch (error) {
413
+ recordFailure(
414
+ 'dsh: skipping ' + relative(process.cwd(), file) + ' (' + error.message + ')',
415
+ );
416
+ continue;
417
+ }
418
+
419
+ const weight = text.length;
420
+ const previous = perSession.get(parsed.sessionId);
421
+ if (!previous || weight > previous.weight) {
422
+ perSession.set(parsed.sessionId, { ...parsed, weight });
423
+ }
424
+ }
425
+
426
+ const entries = [];
427
+ const eventsBySession = new Map();
428
+ for (const parsed of perSession.values()) {
429
+ entries.push(...parsed.entries);
430
+ for (const event of parsed.events) {
431
+ if (!eventsBySession.has(event.sessionId)) eventsBySession.set(event.sessionId, []);
432
+ eventsBySession.get(event.sessionId).push(event);
433
+ }
434
+ }
435
+
436
+ // Only sessions with at least one real user prompt are meaningful timing
437
+ // data; assistant-only logs (e.g. plugin-driven sessions) are skipped.
438
+ const events = [];
439
+ for (const sessionEvents of eventsBySession.values()) {
440
+ if (sessionEvents.some((event) => event.role === 'user')) {
441
+ events.push(...sessionEvents);
442
+ }
443
+ }
444
+
445
+ const result = {
446
+ buckets: aggregateToBuckets(entries),
447
+ sessions: extractSessions(events),
448
+ };
449
+ if (warnings.length > 0 || anyFailure) {
450
+ result.skipped = anyFailure;
451
+ result.warnings = warnings;
452
+ }
453
+ return result;
454
+ }
@@ -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