@vibe-cafe/vibe-usage 0.10.27 → 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 +3 -2
- package/package.json +1 -1
- package/src/cola-roots.js +16 -0
- package/src/daemon-service.js +2 -0
- package/src/hermes-roots.js +59 -0
- package/src/parsers/cola.js +17 -0
- package/src/parsers/dsh.js +97 -61
- package/src/parsers/hermes.js +14 -48
- package/src/parsers/index.js +2 -0
- package/src/parsers/pi-session-jsonl.js +44 -8
- package/src/tools.js +10 -1
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 |
|
|
@@ -80,8 +81,8 @@ npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short
|
|
|
80
81
|
| MiMoCode | `$MIMOCODE_HOME/data/mimocode.db`, `$XDG_DATA_HOME/mimocode/mimocode.db`, or `~/.local/share/mimocode/mimocode.db` (SQLite; exact input, output, reasoning, and cache-read tokens from assistant messages; honors `MIMOCODE_DB`; cache-write tokens are included in input usage) |
|
|
81
82
|
| Amp | `~/.local/share/amp/threads/`; cache-creation tokens are included in input usage |
|
|
82
83
|
| Droid | `~/.factory/sessions/` |
|
|
83
|
-
| DeepSeek Harness | `$DSH_HOME/sessions/` (default `~/.dsh`, fixture/relocation override: `VIBE_USAGE_DSH_SESSIONS`). Reads
|
|
84
|
-
| Hermes
|
|
84
|
+
| DeepSeek Harness | `$DSH_HOME/sessions/` (default `~/.dsh`, fixture/relocation override: `VIBE_USAGE_DSH_SESSIONS`). Reads V0–V3 logs, including `session.v3.jsonl.zstd` from DSH `0.1.5-alpha.2`, with multi-frame Zstandard support (Node ≥ 22.15 built-in, `zstd` CLI fallback) and plain JSONL support. Each session uses its highest `session[.vN].jsonl[.zstd]` generation once, so frozen pre-migration logs are not double-counted. Usage comes from `assistant/message`: cache writes join uncached input, cache reads remain separate, and reasoning is split out of inclusive output. Fork history uses V0/V1 `seedLength` or V2/V3's last `session/end-seed` tagged `inherited: true`, and is skipped only when the parent copy confirms it; missing parents retain the sole local history. Unknown versions warn and protect sync state. |
|
|
85
|
+
| 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
|
@@ -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
|
+
}
|
package/src/daemon-service.js
CHANGED
|
@@ -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
|
+
}
|
package/src/parsers/dsh.js
CHANGED
|
@@ -7,13 +7,14 @@ import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
|
7
7
|
|
|
8
8
|
const SOURCE = 'dsh';
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
const
|
|
10
|
+
// Verified against deepseek-ai/deepseek-harness tag dsh-v0.1.5-alpha.2:
|
|
11
|
+
// packages/session/session-format/src/filename.ts, session-format-v0-to-v1,
|
|
12
|
+
// session-format-v1-to-v2, session-format-v2-to-v3, and core/session/src/types.ts.
|
|
13
|
+
// V0/V1 use header.seedLength; V2/V3 use a tagged inherited end-seed marker.
|
|
14
|
+
// Re-check the format before accepting another version; never silently read a
|
|
15
|
+
// frozen predecessor when a newer generation is present.
|
|
16
|
+
const MAX_SESSION_FORMAT_VERSION = 3;
|
|
17
|
+
const SESSION_FILENAME = /^session(?:\.v([1-9][0-9]*))?\.jsonl(\.zstd)?$/;
|
|
17
18
|
|
|
18
19
|
// Safety cap for a single session log. DSH stores many small zstd frames per
|
|
19
20
|
// file; anything beyond this is either a runaway log or not a session file.
|
|
@@ -198,18 +199,10 @@ function isUserMessageRecord(rec) {
|
|
|
198
199
|
* ... possibly a resumed/forked seed replay, then ...
|
|
199
200
|
* {"type":"user/message"|"assistant/message","time":...,"data":{...}}
|
|
200
201
|
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
* a trailing marker would make "skip everything before the last marker"
|
|
206
|
-
* discard the session's entire real history.
|
|
207
|
-
*
|
|
208
|
-
* Fork/subagent lineage is encoded separately in the immutable header.
|
|
209
|
-
* `parentSession` identifies the source and `seedLength` is the exact number
|
|
210
|
-
* of leading event seqs inherited from it. Only those seqs are skipped, and
|
|
211
|
-
* only while the parent file is also present, so a missing/corrupt source
|
|
212
|
-
* fails open instead of dropping the sole local copy of its usage.
|
|
202
|
+
* V0/V1 use header.seedLength. Their untagged end-seed markers can appear
|
|
203
|
+
* after real history and must never be treated as a replay boundary. V2/V3
|
|
204
|
+
* instead require isSeeded and use the LAST end-seed with data.inherited=true.
|
|
205
|
+
* Only a proven inherited prefix also present in the parent is skipped.
|
|
213
206
|
*
|
|
214
207
|
* Only user/message (source.kind === 'user') and assistant/message records
|
|
215
208
|
* are kept in the model — they are the only records that produce usage
|
|
@@ -220,7 +213,7 @@ function isUserMessageRecord(rec) {
|
|
|
220
213
|
* session_projcache totals DSH itself maintains), so reasoning is split out of
|
|
221
214
|
* output before aggregation, like the Pi-family parsers.
|
|
222
215
|
*/
|
|
223
|
-
function buildSessionModel(text) {
|
|
216
|
+
function buildSessionModel(text, fileVersion) {
|
|
224
217
|
const lines = text.split('\n');
|
|
225
218
|
|
|
226
219
|
let header = null;
|
|
@@ -240,16 +233,23 @@ function buildSessionModel(text) {
|
|
|
240
233
|
if (!header || typeof header.id !== 'string' || header.id.length === 0) {
|
|
241
234
|
throw new Error('missing session header record');
|
|
242
235
|
}
|
|
243
|
-
if (header.version
|
|
236
|
+
if (!Number.isInteger(header.version) || header.version < 0 || header.version > MAX_SESSION_FORMAT_VERSION) {
|
|
244
237
|
const error = new Error(
|
|
245
238
|
'session ' + header.id + ' uses format version ' + header.version +
|
|
246
|
-
' (parser supports ' +
|
|
239
|
+
' (parser supports 0–' + MAX_SESSION_FORMAT_VERSION + ')',
|
|
247
240
|
);
|
|
248
241
|
error.code = 'UNSUPPORTED_FORMAT_VERSION';
|
|
249
242
|
throw error;
|
|
250
243
|
}
|
|
244
|
+
if (header.version !== fileVersion) {
|
|
245
|
+
throw new Error('session header format version ' + header.version + ' disagrees with filename version ' + fileVersion);
|
|
246
|
+
}
|
|
247
|
+
if (header.version >= 2 && typeof header.isSeeded !== 'boolean') {
|
|
248
|
+
throw new Error('format v' + header.version + ' session header lacks isSeeded');
|
|
249
|
+
}
|
|
251
250
|
|
|
252
251
|
const messages = [];
|
|
252
|
+
let inheritedSeq = null;
|
|
253
253
|
for (let i = 0; i < lines.length; i++) {
|
|
254
254
|
if (lines[i].length === 0) continue;
|
|
255
255
|
let rec;
|
|
@@ -263,8 +263,13 @@ function buildSessionModel(text) {
|
|
|
263
263
|
if (timeMs == null) continue;
|
|
264
264
|
const seq = Number.isSafeInteger(rec.seq) && rec.seq >= 0 ? rec.seq : null;
|
|
265
265
|
|
|
266
|
+
if (header.version >= 2 && rec.type === 'session/end-seed' && rec.data?.inherited === true) {
|
|
267
|
+
if (seq == null) throw new Error('inherited end-seed marker lacks a valid seq');
|
|
268
|
+
inheritedSeq = seq;
|
|
269
|
+
}
|
|
270
|
+
|
|
266
271
|
if (isUserMessageRecord(rec)) {
|
|
267
|
-
messages.push({ seq, role: 'user', timeMs, usage: null, model: null });
|
|
272
|
+
messages.push({ seq, messageId: messageId(rec.data.id), role: 'user', timeMs, usage: null, model: null });
|
|
268
273
|
continue;
|
|
269
274
|
}
|
|
270
275
|
if (!isUsageRecord(rec)) continue;
|
|
@@ -273,6 +278,7 @@ function buildSessionModel(text) {
|
|
|
273
278
|
// usage block is missing; the model keeps it so timing survives.
|
|
274
279
|
messages.push({
|
|
275
280
|
seq,
|
|
281
|
+
messageId: messageId(rec.data.message?.id),
|
|
276
282
|
role: 'assistant',
|
|
277
283
|
timeMs,
|
|
278
284
|
usage: parseUsage(rec.data.usage),
|
|
@@ -283,13 +289,18 @@ function buildSessionModel(text) {
|
|
|
283
289
|
});
|
|
284
290
|
}
|
|
285
291
|
|
|
292
|
+
if (header.version >= 2 && header.isSeeded !== (inheritedSeq !== null)) {
|
|
293
|
+
throw new Error('isSeeded disagrees with the inherited end-seed marker');
|
|
294
|
+
}
|
|
295
|
+
|
|
286
296
|
return {
|
|
297
|
+
formatVersion: header.version,
|
|
287
298
|
sessionId: header.id,
|
|
288
299
|
parentSessionId:
|
|
289
300
|
typeof header.parentSession === 'string' && header.parentSession
|
|
290
301
|
? header.parentSession
|
|
291
302
|
: null,
|
|
292
|
-
seedLength:
|
|
303
|
+
seedLength: header.version >= 2 ? (inheritedSeq ?? 0) :
|
|
293
304
|
Number.isSafeInteger(header.seedLength) && header.seedLength > 0
|
|
294
305
|
? header.seedLength
|
|
295
306
|
: 0,
|
|
@@ -298,6 +309,10 @@ function buildSessionModel(text) {
|
|
|
298
309
|
};
|
|
299
310
|
}
|
|
300
311
|
|
|
312
|
+
function messageId(value) {
|
|
313
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
314
|
+
}
|
|
315
|
+
|
|
301
316
|
/** Record wall-clock time in epoch ms; null when absent/invalid. */
|
|
302
317
|
function recordTimeMs(rec) {
|
|
303
318
|
const t = rec.time;
|
|
@@ -337,9 +352,10 @@ function sameUsage(left, right) {
|
|
|
337
352
|
/**
|
|
338
353
|
* Number of leading child messages inherited from a parent seed.
|
|
339
354
|
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
355
|
+
* seedLength is normalized from the version's durable inheritance boundary.
|
|
356
|
+
* Each skipped message must still exist in the selected parent copy. Format
|
|
357
|
+
* migrations renumber events, but preserve existing message ids; mixed-format
|
|
358
|
+
* parents therefore need matching ids in order, never a token-count heuristic.
|
|
343
359
|
* Missing, invalid, or divergent records fail open so usage is not lost.
|
|
344
360
|
*/
|
|
345
361
|
function replaySkipCount(child, parent) {
|
|
@@ -347,21 +363,30 @@ function replaySkipCount(child, parent) {
|
|
|
347
363
|
let parentIndex = 0;
|
|
348
364
|
let previousSeq = -1;
|
|
349
365
|
let count = 0;
|
|
366
|
+
const mixedVersions = child.formatVersion !== parent.formatVersion;
|
|
350
367
|
for (const message of child.messages) {
|
|
351
368
|
if (message.seq == null || message.seq <= previousSeq) return 0;
|
|
352
369
|
previousSeq = message.seq;
|
|
353
370
|
if (message.seq >= child.seedLength) break;
|
|
354
371
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
parent.messages[parentIndex].
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
372
|
+
if (mixedVersions) {
|
|
373
|
+
if (!message.messageId) return 0;
|
|
374
|
+
while (parentIndex < parent.messages.length && parent.messages[parentIndex].messageId !== message.messageId) {
|
|
375
|
+
parentIndex++;
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
while (
|
|
379
|
+
parentIndex < parent.messages.length &&
|
|
380
|
+
parent.messages[parentIndex].seq != null &&
|
|
381
|
+
parent.messages[parentIndex].seq < message.seq
|
|
382
|
+
) {
|
|
383
|
+
parentIndex++;
|
|
384
|
+
}
|
|
361
385
|
}
|
|
362
386
|
const source = parent.messages[parentIndex];
|
|
363
387
|
if (
|
|
364
|
-
source
|
|
388
|
+
!source ||
|
|
389
|
+
(!mixedVersions && source.seq !== message.seq) ||
|
|
365
390
|
source.role !== message.role ||
|
|
366
391
|
source.model !== message.model ||
|
|
367
392
|
!sameUsage(source.usage, message.usage)
|
|
@@ -397,7 +422,27 @@ function modelToResult(model, skipCount) {
|
|
|
397
422
|
return { entries, events };
|
|
398
423
|
}
|
|
399
424
|
|
|
400
|
-
/**
|
|
425
|
+
/** Pick the highest canonical generation per session, like DSH persistence. */
|
|
426
|
+
function selectSessionFile(sessionPath) {
|
|
427
|
+
let selected = null;
|
|
428
|
+
for (const entry of readdirSync(sessionPath, { withFileTypes: true })) {
|
|
429
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
430
|
+
const match = SESSION_FILENAME.exec(entry.name);
|
|
431
|
+
if (!match) continue;
|
|
432
|
+
const version = Number(match[1] || 0);
|
|
433
|
+
if (!Number.isSafeInteger(version)) throw new Error('invalid format version in ' + entry.name);
|
|
434
|
+
const compressed = Boolean(match[2]);
|
|
435
|
+
if (!selected || version > selected.version || (version === selected.version && compressed)) {
|
|
436
|
+
selected = { file: join(sessionPath, entry.name), version, compressed };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (selected && selected.version > MAX_SESSION_FORMAT_VERSION) {
|
|
440
|
+
throw new Error('format version ' + selected.version + ' is not supported; update Vibe Usage');
|
|
441
|
+
}
|
|
442
|
+
return selected;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** List session.jsonl[.zstd] (V0) and session.vN.jsonl[.zstd] (V1+). */
|
|
401
446
|
function listSessionFiles(sessionsDir, onFailure) {
|
|
402
447
|
const files = [];
|
|
403
448
|
const projectKeys = readdirSync(sessionsDir, { withFileTypes: true })
|
|
@@ -419,22 +464,14 @@ function listSessionFiles(sessionsDir, onFailure) {
|
|
|
419
464
|
for (const sessionDir of sessionDirs) {
|
|
420
465
|
if (!sessionDir.isDirectory()) continue;
|
|
421
466
|
const sessionPath = join(projectDir, sessionDir.name);
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
if (error?.code !== 'ENOENT') {
|
|
431
|
-
onFailure(
|
|
432
|
-
'dsh: cannot inspect ' + relative(sessionsDir, file) +
|
|
433
|
-
' (' + (error?.code || error?.message || 'stat failed') + ')',
|
|
434
|
-
);
|
|
435
|
-
break;
|
|
436
|
-
}
|
|
437
|
-
}
|
|
467
|
+
try {
|
|
468
|
+
const selected = selectSessionFile(sessionPath);
|
|
469
|
+
if (selected) files.push(selected);
|
|
470
|
+
} catch (error) {
|
|
471
|
+
onFailure(
|
|
472
|
+
'dsh: cannot read session directory ' + relative(sessionsDir, sessionPath) +
|
|
473
|
+
' (' + (error?.message || error?.code || 'read failed') + ')',
|
|
474
|
+
);
|
|
438
475
|
}
|
|
439
476
|
}
|
|
440
477
|
}
|
|
@@ -444,17 +481,15 @@ function listSessionFiles(sessionsDir, onFailure) {
|
|
|
444
481
|
/**
|
|
445
482
|
* DeepSeek Harness (dsh) parser.
|
|
446
483
|
*
|
|
447
|
-
* Reads $DSH_HOME/sessions/<project-key
|
|
484
|
+
* Reads $DSH_HOME/sessions/<project-key>/<id>/session[.vN].jsonl[.zstd]
|
|
448
485
|
* (default ~/.dsh, fixture/relocation override VIBE_USAGE_DSH_SESSIONS).
|
|
449
486
|
* Zstandard session logs are multi-frame; node:zlib zstd (Node >= 22.15)
|
|
450
487
|
* decodes one frame per call, so the buffer is walked frame-by-frame, with a
|
|
451
488
|
* `zstd` CLI fallback for older Node.
|
|
452
489
|
*
|
|
453
490
|
* Replay handling: `header.parentSession` identifies a fork/subagent source,
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
* present. Files without either field, and children whose parent is missing,
|
|
457
|
-
* are counted in full. `session/end-seed` positions are never used.
|
|
491
|
+
* with a version-specific inheritance boundary (see buildSessionModel).
|
|
492
|
+
* Children whose parent is missing are counted in full.
|
|
458
493
|
*/
|
|
459
494
|
export async function parse() {
|
|
460
495
|
const sessionsDir = getDshSessionsDir();
|
|
@@ -485,10 +520,10 @@ export async function parse() {
|
|
|
485
520
|
return result;
|
|
486
521
|
}
|
|
487
522
|
|
|
488
|
-
// sessionId ->
|
|
489
|
-
//
|
|
523
|
+
// sessionId -> newest generation, then largest copy within that generation.
|
|
524
|
+
// Migrations can shrink a log by embedding chunks, so size alone is unsafe.
|
|
490
525
|
const perSession = new Map();
|
|
491
|
-
for (const { file, compressed } of files) {
|
|
526
|
+
for (const { file, compressed, version } of files) {
|
|
492
527
|
let text;
|
|
493
528
|
try {
|
|
494
529
|
const stat = statSync(file);
|
|
@@ -510,7 +545,7 @@ export async function parse() {
|
|
|
510
545
|
|
|
511
546
|
let model;
|
|
512
547
|
try {
|
|
513
|
-
model = buildSessionModel(text);
|
|
548
|
+
model = buildSessionModel(text, version);
|
|
514
549
|
} catch (error) {
|
|
515
550
|
recordFailure(
|
|
516
551
|
'dsh: skipping ' + relative(process.cwd(), file) + ' (' + error.message + ')',
|
|
@@ -520,7 +555,8 @@ export async function parse() {
|
|
|
520
555
|
|
|
521
556
|
const weight = text.length;
|
|
522
557
|
const previous = perSession.get(model.sessionId);
|
|
523
|
-
if (!previous ||
|
|
558
|
+
if (!previous || model.formatVersion > previous.model.formatVersion ||
|
|
559
|
+
(model.formatVersion === previous.model.formatVersion && weight > previous.weight)) {
|
|
524
560
|
perSession.set(model.sessionId, { model, weight });
|
|
525
561
|
}
|
|
526
562
|
}
|
package/src/parsers/hermes.js
CHANGED
|
@@ -1,24 +1,22 @@
|
|
|
1
|
-
import {
|
|
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
|
-
*
|
|
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 =
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
}
|
package/src/parsers/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
111
|
+
const rawTimestamp = obj.timestamp || message.timestamp;
|
|
112
|
+
const timestamp = new Date(rawTimestamp || 0);
|
|
102
113
|
if (Number.isNaN(timestamp.getTime())) continue;
|
|
103
|
-
const
|
|
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
|
|
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
|
|
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(
|
|
407
|
+
dataDir: join(getHermesHome(), 'state.db'),
|
|
408
|
+
detectDataDirs: findHermesDataDirs,
|
|
400
409
|
},
|
|
401
410
|
{
|
|
402
411
|
name: 'Kiro',
|