@vibe-cafe/vibe-usage 0.10.12 → 0.10.14
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 +5 -4
- package/package.json +1 -1
- package/src/cindy-roots.js +85 -0
- package/src/parsers/antigravity-db.js +76 -2
- package/src/parsers/antigravity.js +10 -3
- package/src/parsers/cindy-ledger.js +156 -0
- package/src/parsers/codex.js +7 -1
- package/src/parsers/dsh.js +174 -65
- package/src/parsers/pi-coding-agent.js +3 -1
- package/src/parsers/sqlite.js +44 -16
- package/src/parsers/trae-cli.js +104 -78
- package/src/tools.js +7 -0
package/README.md
CHANGED
|
@@ -53,6 +53,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
53
53
|
|------|---------------|
|
|
54
54
|
| Alma | Electron app-data `alma/chat_threads.db` (macOS: `~/Library/Application Support/alma/chat_threads.db`; fixture/relocation override: `VIBE_USAGE_ALMA_DB`). Reads the `usage_records` ledger plus workspace names without selecting chat bodies, message metadata, provider credentials, or full workspace paths. Provider-prefixed model identifiers are normalized to their final model segment. Cache writes are included in input usage. The ledger contains assistant responses only, so Alma emits token buckets without session timing. |
|
|
55
55
|
| 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. |
|
|
56
|
+
| 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. |
|
|
56
57
|
| Codex CLI | `$CODEX_HOME/sessions/` and `$CODEX_HOME/archived_sessions/` (default `~/.codex`), plus an optional temporary `--extra-codex-home` or manually persisted `codexExtraHome`; 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 |
|
|
57
58
|
| Grok | `$GROK_HOME/sessions/<encoded-cwd>/<session-id>/` (default `~/.grok`); token usage from `updates.jsonl` `turn_completed.usage` (per-model `modelUsage`, cache reads, reasoning); project from `summary.json` cwd; honors `GROK_HOME` |
|
|
58
59
|
| GitHub Copilot CLI | `~/.copilot/session-state/*/events.jsonl` |
|
|
@@ -69,13 +70,13 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
69
70
|
| 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) |
|
|
70
71
|
| Amp | `~/.local/share/amp/threads/`; cache-creation tokens are included in input usage |
|
|
71
72
|
| Droid | `~/.factory/sessions/` |
|
|
72
|
-
| DeepSeek Harness | `$DSH_HOME/sessions/` (default `~/.dsh`, fixture/relocation override: `VIBE_USAGE_DSH_SESSIONS`). Reads multi-frame Zstandard `session.jsonl.zstd` logs (built-in `node:zlib` zstd on Node ≥ 22.15, `zstd` CLI fallback) and plain `session.jsonl` logs. Usage comes from `assistant/message`: cache writes join uncached input, cache reads remain separate, and reasoning is split out of inclusive output.
|
|
73
|
+
| DeepSeek Harness | `$DSH_HOME/sessions/` (default `~/.dsh`, fixture/relocation override: `VIBE_USAGE_DSH_SESSIONS`). Reads multi-frame Zstandard `session.jsonl.zstd` logs (built-in `node:zlib` zstd on Node ≥ 22.15, `zstd` CLI fallback) and plain `session.jsonl` logs. Usage comes from `assistant/message`: cache writes join uncached input, cache reads remain separate, and reasoning is split out of inclusive output. Fork/subagent history is de-duplicated from the immutable header: `parentSession` identifies the source and `seedLength` gives the exact leading event boundary. Inherited messages are skipped only when matching source seqs remain in the parent file; missing parents fail open. `session/end-seed` positions are not used because resumes can append the marker after real history. |
|
|
73
74
|
| Hermes | `~/.hermes/state.db` + `~/.hermes/profiles/<name>/state.db` (SQLite, multi-profile) |
|
|
74
75
|
| 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` |
|
|
75
76
|
| 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 |
|
|
76
77
|
| Roo Code | `<host>/User/globalStorage/rooveterinaryinc.roo-cline/{tasks/_index.json,tasks/<id>/{history_item,ui_messages}.json}` (walks all VSCode-fork hosts) |
|
|
77
|
-
| Trae CLI | macOS: `~/Library/Caches/trae-cli/sessions/`; Windows: `%LOCALAPPDATA%/trae-cli/cache/sessions/`; Linux: `~/.cache/trae-cli/sessions/` (CLI telemetry only; Trae IDE/Trae Work chats are not supported) |
|
|
78
|
-
| Antigravity | App 2.0 `~/.gemini/antigravity/conversations/*.db` and `agy` CLI `~/.gemini/antigravity-cli/conversations/*.db` are parsed offline (tokens, real model display name, project, sessions)
|
|
78
|
+
| Trae CLI | macOS: `~/Library/Caches/trae-cli/sessions/`; Windows: `%LOCALAPPDATA%/trae-cli/cache/sessions/`; Linux: `~/.cache/trae-cli/sessions/` (CLI telemetry only; Trae IDE/Trae Work chats are not supported). Token usage is summed per unique LLM call (`model.stream.eino`, plus `model.generate` failovers); nested duplicate spans that share a session `traceID` are not max-merged. `traces.jsonl` / `events.jsonl` are streamed line-by-line so a multi-hundred-MB events file cannot hit Node's string-length limit. |
|
|
79
|
+
| Antigravity | App 2.0 `~/.gemini/antigravity/conversations/*.db` and `agy` CLI `~/.gemini/antigravity-cli/conversations/*.db` are parsed offline (tokens, real model display name when present, project, sessions). Gemini 3.7 CLI blobs omit `chatStartMetadata.createdAt` and `modelDisplayName`; usage still comes from `gen_metadata`, timestamps fall back to `steps.metadata` at the same idx, and the model name falls back to `responseModel`. Legacy App `.pb` history falls back to Connect RPC while the language server is running |
|
|
79
80
|
| WorkBuddy | Current releases: `~/.workbuddy-ai/projects/**/*.jsonl`; legacy releases: `~/.workbuddy/projects/**/*.jsonl` (fixture/relocation override: `VIBE_USAGE_WORKBUDDY_DIRS`). Reads usage-bearing completed assistant and `function_call` records, using the routed model identifier exposed as `providerData.requestModelId`. Splits cache reads and reasoning from inclusive input/output totals, deduplicates copied record IDs, and extracts local session timing without uploading message content. |
|
|
80
81
|
| ZCode | `~/.zcode/cli/db/db.sqlite` (SQLite; reads the `message` table for per-message tokens, model, and project `cwd`/`root`, joined to `session.directory`) |
|
|
81
82
|
|
|
@@ -83,7 +84,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
83
84
|
|
|
84
85
|
- Parses local session logs from each AI coding tool
|
|
85
86
|
- Aggregates token usage into 30-minute buckets
|
|
86
|
-
- Extracts session metadata where the source safely exposes user/assistant timing: active time (AI generation time, excluding queue/TTFT wait), total duration, and message counts. Alma intentionally emits buckets only
|
|
87
|
+
- Extracts session metadata where the source safely exposes user/assistant timing: active time (AI generation time, excluding queue/TTFT wait), total duration, and message counts. Alma intentionally emits buckets only; Cindy's daily-ledger augmentation adds no timing data to the native Codex/pi sessions because doing so would require reading Cindy chat records.
|
|
87
88
|
- Uploads buckets + sessions to your vibecafe.ai dashboard (always gzip-compressed, ~94% smaller)
|
|
88
89
|
- Incremental upload: every parser emits a complete local snapshot, then only buckets/sessions that are new or changed since the last successful upload are sent — a quiet machine uploads nothing. Upload state remains in `~/.vibe-usage/state.json`; failed or still-indexing parsers retain their prior state, while deleted local logs are pruned. Deleting the state file triggers a one-time full re-upload, and `reset` clears it automatically after deleting cloud data
|
|
89
90
|
- Incremental Codex parsing: a versioned, disposable cache under `~/.vibe-usage/cache/codex/` stores per-rollout aggregate results and parser continuation state. Unchanged rollouts require no raw-log reads; an ordinary append reads only the new tail; forks, sub-agents, replacements, truncations, and failed safety checks fall back to the full correctness path. A bounded rolling audit occasionally re-reads one historical file. Very large first-time indexes checkpoint before the Mac app timeout and resume on the next sync instead of restarting
|
package/package.json
CHANGED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { delimiter, dirname, join, posix, resolve, win32 } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
|
|
5
|
+
function unique(values) {
|
|
6
|
+
return [...new Set(values)];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Cindy keeps Mainland China and Global installs in separate Electron user-data
|
|
11
|
+
* roots. Scan both because the two editions can be installed side by side.
|
|
12
|
+
*/
|
|
13
|
+
export function getCindyDataRoots(
|
|
14
|
+
env = process.env,
|
|
15
|
+
platform = process.platform,
|
|
16
|
+
home = homedir(),
|
|
17
|
+
) {
|
|
18
|
+
const override = env.VIBE_USAGE_CINDY_DIRS?.trim();
|
|
19
|
+
if (override) {
|
|
20
|
+
return unique(
|
|
21
|
+
override
|
|
22
|
+
.split(delimiter)
|
|
23
|
+
.map((value) => value.trim())
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.map((value) => resolve(value)),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const pathImpl = platform === 'win32' ? win32 : posix;
|
|
30
|
+
let base;
|
|
31
|
+
if (platform === 'darwin') {
|
|
32
|
+
base = pathImpl.join(home, 'Library', 'Application Support');
|
|
33
|
+
} else if (platform === 'win32') {
|
|
34
|
+
base = env.APPDATA?.trim() || pathImpl.join(home, 'AppData', 'Roaming');
|
|
35
|
+
} else {
|
|
36
|
+
base = env.XDG_CONFIG_HOME?.trim() || pathImpl.join(home, '.config');
|
|
37
|
+
}
|
|
38
|
+
return [pathImpl.join(base, 'CindyGlobal'), pathImpl.join(base, 'Cindy')];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function canonicalPath(value) {
|
|
42
|
+
try {
|
|
43
|
+
return realpathSync(value);
|
|
44
|
+
} catch {
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Find every active per-owner `cindy-<owner>.db` database. */
|
|
50
|
+
export function findCindyDbPaths(options = {}) {
|
|
51
|
+
const roots = getCindyDataRoots(options.env, options.platform, options.home);
|
|
52
|
+
const paths = [];
|
|
53
|
+
|
|
54
|
+
for (const root of roots) {
|
|
55
|
+
let stat;
|
|
56
|
+
try {
|
|
57
|
+
stat = statSync(root);
|
|
58
|
+
} catch {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (stat.isFile()) {
|
|
63
|
+
if (root.endsWith('.db')) paths.push(canonicalPath(root));
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!stat.isDirectory()) continue;
|
|
67
|
+
|
|
68
|
+
let entries;
|
|
69
|
+
try {
|
|
70
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
71
|
+
} catch {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (!entry.isFile() || !/^cindy-.+\.db$/.test(entry.name)) continue;
|
|
76
|
+
paths.push(canonicalPath(join(root, entry.name)));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return unique(paths).sort();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function findCindyDataDirs(options = {}) {
|
|
84
|
+
return unique(findCindyDbPaths(options).map(dirname)).filter(existsSync);
|
|
85
|
+
}
|
|
@@ -26,6 +26,10 @@ import { queryDbJsonSnapshotOnLock, sqliteUnavailableError, isSqliteUnavailableE
|
|
|
26
26
|
* createdAt (Timestamp) = 9.4 → seconds = 9.4.1
|
|
27
27
|
* responseModel = field 19 ("gemini-3-flash-a" / "gemini-default")
|
|
28
28
|
* modelDisplayName = field 21 ("Gemini 3.5 Flash (High/Medium/Low)")
|
|
29
|
+
*
|
|
30
|
+
* Gemini 3.7 CLI blobs still have usage (4) and responseModel (19) but omit
|
|
31
|
+
* 9.4 and 21. Timestamps are recovered from steps.metadata field 1.1 at the
|
|
32
|
+
* same gen_metadata.idx; the model name falls back to responseModel.
|
|
29
33
|
*/
|
|
30
34
|
|
|
31
35
|
// ── Minimal protobuf wire-format decoder (no dependency) ──────────────
|
|
@@ -146,6 +150,40 @@ export function parseGenMetadataBlob(buf) {
|
|
|
146
150
|
};
|
|
147
151
|
}
|
|
148
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Extract createdAt seconds from a steps.metadata blob, regardless of step
|
|
155
|
+
* source. Gemini 3.7 CLI gen_metadata blobs no longer carry chatStartMetadata
|
|
156
|
+
* (field 9.4); the step timestamp at field 1.1 is the remaining clock.
|
|
157
|
+
*
|
|
158
|
+
* @param {Buffer} buf
|
|
159
|
+
* @returns {Date|null}
|
|
160
|
+
*/
|
|
161
|
+
export function parseStepTimestamp(buf) {
|
|
162
|
+
const meta = decodeMessage(buf);
|
|
163
|
+
const createdAt = firstMessage(meta, 1);
|
|
164
|
+
const seconds = createdAt ? firstVarint(createdAt, 1) : undefined;
|
|
165
|
+
if (!seconds) return null;
|
|
166
|
+
const timestamp = new Date(seconds * 1000);
|
|
167
|
+
return Number.isNaN(timestamp.getTime()) ? null : timestamp;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Prefer the blob's own createdAt; if Gemini 3.7 omitted it, use the step
|
|
172
|
+
* with the same idx. idx-join is exact on current CLI stores (every
|
|
173
|
+
* gen_metadata idx exists in steps).
|
|
174
|
+
*
|
|
175
|
+
* @param {{timestamp: Date|null, idx?: number}} rec
|
|
176
|
+
* @param {Map<number, Date>} stepTimestampsByIdx
|
|
177
|
+
* @returns {Date|null}
|
|
178
|
+
*/
|
|
179
|
+
export function resolveUsageTimestamp(rec, stepTimestampsByIdx) {
|
|
180
|
+
if (rec?.timestamp && !Number.isNaN(rec.timestamp.getTime())) return rec.timestamp;
|
|
181
|
+
if (rec?.idx == null || !stepTimestampsByIdx) return null;
|
|
182
|
+
const stepTs = stepTimestampsByIdx.get(rec.idx);
|
|
183
|
+
if (stepTs && !Number.isNaN(stepTs.getTime())) return stepTs;
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
149
187
|
// ── SQLite store reading ──────────────────────────────────────────────
|
|
150
188
|
|
|
151
189
|
function queryCascadeDb(conversationsDir, cascadeId, sql) {
|
|
@@ -182,7 +220,7 @@ export function listDbCascades(conversationsDir) {
|
|
|
182
220
|
export function readDbUsageRecords(conversationsDir, cascadeId) {
|
|
183
221
|
let rows;
|
|
184
222
|
try {
|
|
185
|
-
rows = queryCascadeDb(conversationsDir, cascadeId, 'SELECT hex(data) AS h FROM gen_metadata ORDER BY idx');
|
|
223
|
+
rows = queryCascadeDb(conversationsDir, cascadeId, 'SELECT idx, hex(data) AS h FROM gen_metadata ORDER BY idx');
|
|
186
224
|
} catch (err) {
|
|
187
225
|
if (isSqliteUnavailableError(err)) throw err;
|
|
188
226
|
return [];
|
|
@@ -196,11 +234,47 @@ export function readDbUsageRecords(conversationsDir, cascadeId) {
|
|
|
196
234
|
} catch {
|
|
197
235
|
continue; // one malformed blob must not kill the rest
|
|
198
236
|
}
|
|
199
|
-
if (rec)
|
|
237
|
+
if (rec) {
|
|
238
|
+
rec.idx = Number.isFinite(Number(row.idx)) ? Number(row.idx) : null;
|
|
239
|
+
records.push(rec);
|
|
240
|
+
}
|
|
200
241
|
}
|
|
201
242
|
return records;
|
|
202
243
|
}
|
|
203
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Map steps.idx → createdAt for every step that has a timestamp, including
|
|
247
|
+
* system/tool steps that parseStepMetadata skips. Used to timestamp 3.7
|
|
248
|
+
* gen_metadata rows that no longer embed chatStartMetadata.
|
|
249
|
+
*/
|
|
250
|
+
export function readDbStepTimestamps(conversationsDir, cascadeId) {
|
|
251
|
+
let rows;
|
|
252
|
+
try {
|
|
253
|
+
rows = queryCascadeDb(
|
|
254
|
+
conversationsDir,
|
|
255
|
+
cascadeId,
|
|
256
|
+
'SELECT idx, hex(metadata) AS h FROM steps WHERE metadata IS NOT NULL ORDER BY idx',
|
|
257
|
+
);
|
|
258
|
+
} catch (err) {
|
|
259
|
+
if (isSqliteUnavailableError(err)) throw err;
|
|
260
|
+
return new Map();
|
|
261
|
+
}
|
|
262
|
+
const byIdx = new Map();
|
|
263
|
+
for (const row of rows) {
|
|
264
|
+
if (!row.h) continue;
|
|
265
|
+
let ts;
|
|
266
|
+
try {
|
|
267
|
+
ts = parseStepTimestamp(Buffer.from(row.h, 'hex'));
|
|
268
|
+
} catch {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (!ts) continue;
|
|
272
|
+
const idx = Number(row.idx);
|
|
273
|
+
if (Number.isFinite(idx)) byIdx.set(idx, ts);
|
|
274
|
+
}
|
|
275
|
+
return byIdx;
|
|
276
|
+
}
|
|
277
|
+
|
|
204
278
|
/**
|
|
205
279
|
* Read the workspace URI for a cascade from trajectory_metadata_blob.
|
|
206
280
|
* Structure: field 1 = workspaces[0], 1.1 = workspaceFolderAbsoluteUri.
|
|
@@ -3,7 +3,7 @@ import { readdirSync } from 'node:fs';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
6
|
-
import { listDbCascades, readDbUsageRecords, readDbWorkspaceUri, readDbSessionEvents } from './antigravity-db.js';
|
|
6
|
+
import { listDbCascades, readDbUsageRecords, readDbWorkspaceUri, readDbSessionEvents, readDbStepTimestamps, resolveUsageTimestamp } from './antigravity-db.js';
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
|
|
@@ -327,18 +327,25 @@ export async function parse() {
|
|
|
327
327
|
for (const cascadeId of listDbCascades(dir)) {
|
|
328
328
|
const records = readDbUsageRecords(dir, cascadeId);
|
|
329
329
|
const project = projectFromUri(readDbWorkspaceUri(dir, cascadeId)) || 'unknown';
|
|
330
|
+
const stepTimestampsByIdx = records.some((rec) => !rec.timestamp || isNaN(rec.timestamp.getTime()))
|
|
331
|
+
? readDbStepTimestamps(dir, cascadeId)
|
|
332
|
+
: new Map();
|
|
330
333
|
|
|
331
334
|
if (records.length > 0) {
|
|
332
335
|
dbHandled.add(cascadeId);
|
|
333
336
|
for (const rec of records) {
|
|
334
337
|
if (rec.responseId && seenResponseIds.has(rec.responseId)) continue;
|
|
335
338
|
if (rec.responseId) seenResponseIds.add(rec.responseId);
|
|
336
|
-
|
|
339
|
+
// Gemini 3.7 CLI blobs dropped chatStartMetadata.createdAt (9.4.1)
|
|
340
|
+
// and modelDisplayName (21). Usage is still in field 4; clock is
|
|
341
|
+
// recovered from steps.metadata at the same idx.
|
|
342
|
+
const timestamp = resolveUsageTimestamp(rec, stepTimestampsByIdx);
|
|
343
|
+
if (!timestamp || isNaN(timestamp.getTime())) continue;
|
|
337
344
|
entries.push({
|
|
338
345
|
source: SOURCE,
|
|
339
346
|
model: modelFromRecord(rec),
|
|
340
347
|
project,
|
|
341
|
-
timestamp
|
|
348
|
+
timestamp,
|
|
342
349
|
inputTokens: toSafeNumber(rec.inputTokens),
|
|
343
350
|
outputTokens: toSafeNumber(rec.outputTokens),
|
|
344
351
|
cachedInputTokens: toSafeNumber(rec.cacheReadTokens),
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { findCindyDbPaths } from '../cindy-roots.js';
|
|
2
|
+
import { aggregateToBuckets } from './aggregate.js';
|
|
3
|
+
import { toCount } from './fs-utils.js';
|
|
4
|
+
import {
|
|
5
|
+
isSqliteUnavailableError,
|
|
6
|
+
queryDbJsonSnapshot,
|
|
7
|
+
sqliteUnavailableError,
|
|
8
|
+
} from './sqlite.js';
|
|
9
|
+
|
|
10
|
+
const CINDY_USAGE_SQL = `
|
|
11
|
+
SELECT
|
|
12
|
+
day,
|
|
13
|
+
agent_kind AS agentKind,
|
|
14
|
+
model,
|
|
15
|
+
SUM(input_tokens) AS inputTokens,
|
|
16
|
+
SUM(output_tokens) AS outputTokens,
|
|
17
|
+
SUM(cache_read_tokens) AS cacheReadTokens,
|
|
18
|
+
SUM(cache_create_tokens) AS cacheCreateTokens
|
|
19
|
+
FROM daily_model_usage
|
|
20
|
+
GROUP BY day, agent_kind, model
|
|
21
|
+
ORDER BY day, agent_kind, model
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
/** Cindy stores its ledger day as local-time YYYY-MM-DD. */
|
|
25
|
+
export function dateFromCindyDay(value) {
|
|
26
|
+
if (typeof value !== 'string') return null;
|
|
27
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
28
|
+
if (!match) return null;
|
|
29
|
+
const year = Number(match[1]);
|
|
30
|
+
const month = Number(match[2]);
|
|
31
|
+
const day = Number(match[3]);
|
|
32
|
+
const date = new Date(year, month - 1, day);
|
|
33
|
+
if (
|
|
34
|
+
date.getFullYear() !== year
|
|
35
|
+
|| date.getMonth() !== month - 1
|
|
36
|
+
|| date.getDate() !== day
|
|
37
|
+
) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return date;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function skippedResult(error) {
|
|
44
|
+
const message = error?.message || String(error);
|
|
45
|
+
let reason = 'read failed';
|
|
46
|
+
if (/database is locked/i.test(message)) reason = 'database is locked';
|
|
47
|
+
else if (/no such column/i.test(message)) reason = 'incompatible database schema';
|
|
48
|
+
else if (/unable to open|SQLITE_CANTOPEN/i.test(message)) reason = 'database unavailable';
|
|
49
|
+
return {
|
|
50
|
+
buckets: [],
|
|
51
|
+
sessions: [],
|
|
52
|
+
skipped: true,
|
|
53
|
+
warnings: [`cindy: cannot read usage database (${reason})`],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CINDY_HARNESS_SOURCES = {
|
|
58
|
+
codex: 'codex',
|
|
59
|
+
pi: 'pi-coding-agent',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read Cindy's daily ledger for a harness that does not already expose the
|
|
64
|
+
* same raw logs to Vibe Usage. Cindy's Claude Code SDK writes ordinary
|
|
65
|
+
* ~/.claude transcripts, so Claude stays owned by the claude-code parser and
|
|
66
|
+
* is deliberately absent from this map.
|
|
67
|
+
*/
|
|
68
|
+
export function readCindyHarnessUsage(agentKind) {
|
|
69
|
+
const source = CINDY_HARNESS_SOURCES[agentKind];
|
|
70
|
+
if (!source) throw new TypeError(`Unsupported Cindy harness: ${agentKind}`);
|
|
71
|
+
|
|
72
|
+
const dbPaths = findCindyDbPaths();
|
|
73
|
+
if (dbPaths.length === 0) return { buckets: [], sessions: [] };
|
|
74
|
+
|
|
75
|
+
const rows = [];
|
|
76
|
+
for (const dbPath of dbPaths) {
|
|
77
|
+
try {
|
|
78
|
+
rows.push(...queryDbJsonSnapshot(dbPath, CINDY_USAGE_SQL, {
|
|
79
|
+
tempPrefix: 'vibe-usage-cindy-',
|
|
80
|
+
}));
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (isSqliteUnavailableError(error)) throw sqliteUnavailableError('Cindy');
|
|
83
|
+
// Cindy versions before the daily ledger was introduced have no usage
|
|
84
|
+
// rows to import. A second, current regional/account database may still
|
|
85
|
+
// be readable, so skip only this legacy database.
|
|
86
|
+
if (/no such table:\s*daily_model_usage/i.test(error?.message || '')) continue;
|
|
87
|
+
return skippedResult(error);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const entries = [];
|
|
92
|
+
for (const row of rows) {
|
|
93
|
+
if (row.agentKind !== agentKind) continue;
|
|
94
|
+
const timestamp = dateFromCindyDay(row.day);
|
|
95
|
+
if (!timestamp) continue;
|
|
96
|
+
const inputTokens = toCount(row.inputTokens) + toCount(row.cacheCreateTokens);
|
|
97
|
+
const outputTokens = toCount(row.outputTokens);
|
|
98
|
+
const cachedInputTokens = toCount(row.cacheReadTokens);
|
|
99
|
+
if (inputTokens + outputTokens + cachedInputTokens === 0) continue;
|
|
100
|
+
|
|
101
|
+
entries.push({
|
|
102
|
+
source,
|
|
103
|
+
model: typeof row.model === 'string' && row.model.trim()
|
|
104
|
+
? row.model.trim()
|
|
105
|
+
: `${source}-unknown`,
|
|
106
|
+
project: 'unknown',
|
|
107
|
+
timestamp,
|
|
108
|
+
inputTokens,
|
|
109
|
+
outputTokens,
|
|
110
|
+
cachedInputTokens,
|
|
111
|
+
reasoningOutputTokens: 0,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
buckets: aggregateToBuckets(entries),
|
|
117
|
+
sessions: [],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Merge the optional Cindy ledger into the native harness snapshot. A failed
|
|
123
|
+
* Cindy read marks the whole source skipped so its previously uploaded rows
|
|
124
|
+
* are not pruned from incremental state.
|
|
125
|
+
*/
|
|
126
|
+
export function mergeCindyHarnessUsage(nativeResult, cindyResult) {
|
|
127
|
+
const warnings = [
|
|
128
|
+
...(nativeResult.warnings || []),
|
|
129
|
+
...(cindyResult.warnings || []),
|
|
130
|
+
];
|
|
131
|
+
if (nativeResult.skipped || cindyResult.skipped) {
|
|
132
|
+
return {
|
|
133
|
+
...nativeResult,
|
|
134
|
+
buckets: [],
|
|
135
|
+
sessions: [],
|
|
136
|
+
skipped: true,
|
|
137
|
+
warnings,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const entries = [];
|
|
142
|
+
for (const bucket of [...nativeResult.buckets, ...cindyResult.buckets]) {
|
|
143
|
+
const timestamp = new Date(bucket.bucketStart);
|
|
144
|
+
if (Number.isNaN(timestamp.getTime())) continue;
|
|
145
|
+
entries.push({
|
|
146
|
+
...bucket,
|
|
147
|
+
timestamp,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
...nativeResult,
|
|
152
|
+
buckets: aggregateToBuckets(entries),
|
|
153
|
+
sessions: nativeResult.sessions || [],
|
|
154
|
+
warnings,
|
|
155
|
+
};
|
|
156
|
+
}
|
package/src/parsers/codex.js
CHANGED
|
@@ -11,6 +11,7 @@ import { join } from 'node:path';
|
|
|
11
11
|
import { createInterface } from 'node:readline';
|
|
12
12
|
import { createHash } from 'node:crypto';
|
|
13
13
|
import { aggregateToBuckets } from './aggregate.js';
|
|
14
|
+
import { mergeCindyHarnessUsage, readCindyHarnessUsage } from './cindy-ledger.js';
|
|
14
15
|
import {
|
|
15
16
|
codexSessionDirs,
|
|
16
17
|
resolveCodexHomes,
|
|
@@ -796,7 +797,7 @@ function mergeFileResults(results) {
|
|
|
796
797
|
return { buckets: aggregateToBuckets(entries), sessions };
|
|
797
798
|
}
|
|
798
799
|
|
|
799
|
-
|
|
800
|
+
async function parseNativeCodex({ codexExtraHome } = {}) {
|
|
800
801
|
if (codexExtraHome?.trim()) {
|
|
801
802
|
const validation = validateExtraCodexHome(codexExtraHome);
|
|
802
803
|
if (!validation.ok) {
|
|
@@ -1019,3 +1020,8 @@ export async function parse({ codexExtraHome } = {}) {
|
|
|
1019
1020
|
|
|
1020
1021
|
return { ...mergeFileResults(results), cache: cacheStats };
|
|
1021
1022
|
}
|
|
1023
|
+
|
|
1024
|
+
export async function parse(options = {}) {
|
|
1025
|
+
const nativeResult = await parseNativeCodex(options);
|
|
1026
|
+
return mergeCindyHarnessUsage(nativeResult, readCindyHarnessUsage('codex'));
|
|
1027
|
+
}
|
package/src/parsers/dsh.js
CHANGED
|
@@ -190,33 +190,40 @@ function isUserMessageRecord(rec) {
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
/**
|
|
193
|
-
*
|
|
193
|
+
* Build a session model from one decompressed session log.
|
|
194
194
|
*
|
|
195
195
|
* Layout (DeepSeek Harness session-persistence-jsonl):
|
|
196
|
-
* line 0: {"type":"session","version":0,"id":...,"createdAt":...,"cwd"
|
|
196
|
+
* line 0: {"type":"session","version":0,"id":...,"createdAt":...,"cwd":...,
|
|
197
|
+
* "parentSession":...?, ...}
|
|
197
198
|
* ... possibly a resumed/forked seed replay, then ...
|
|
198
|
-
* {"type":"
|
|
199
|
-
* {"type":"assistant/message","data":{"turn","step","message":{"source":
|
|
200
|
-
* {"kind":"model","provider","model"},...},"usage":{"inputTokens",
|
|
201
|
-
* "outputTokens","cacheReadTokens","cacheWriteTokens",
|
|
202
|
-
* "reasoningTokens"}},...}
|
|
199
|
+
* {"type":"user/message"|"assistant/message","time":...,"data":{...}}
|
|
203
200
|
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
201
|
+
* DSH (developer preview) writes session/end-seed records in three situations:
|
|
202
|
+
* right after session creation (empty seed), at each resume boundary, and
|
|
203
|
+
* appended at the END of a file when that session becomes the seed for a
|
|
204
|
+
* further resume. The marker's position is therefore NOT a replay boundary —
|
|
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.
|
|
213
|
+
*
|
|
214
|
+
* Only user/message (source.kind === 'user') and assistant/message records
|
|
215
|
+
* are kept in the model — they are the only records that produce usage
|
|
216
|
+
* entries or timing events. Their seq is retained so the header's seed
|
|
217
|
+
* boundary can be applied without inspecting or hashing message content.
|
|
208
218
|
*
|
|
209
219
|
* usage.outputTokens includes reasoningTokens (verified against the
|
|
210
220
|
* session_projcache totals DSH itself maintains), so reasoning is split out of
|
|
211
221
|
* output before aggregation, like the Pi-family parsers.
|
|
212
222
|
*/
|
|
213
|
-
function
|
|
214
|
-
const entries = [];
|
|
215
|
-
const events = [];
|
|
223
|
+
function buildSessionModel(text) {
|
|
216
224
|
const lines = text.split('\n');
|
|
217
225
|
|
|
218
226
|
let header = null;
|
|
219
|
-
let endSeedIndex = -1;
|
|
220
227
|
for (let i = 0; i < lines.length; i++) {
|
|
221
228
|
if (lines[i].length === 0) continue;
|
|
222
229
|
let rec;
|
|
@@ -225,9 +232,8 @@ function parseSessionText(text) {
|
|
|
225
232
|
} catch {
|
|
226
233
|
continue; // torn final line: keep the complete records
|
|
227
234
|
}
|
|
228
|
-
if (rec && typeof rec === 'object') {
|
|
229
|
-
|
|
230
|
-
if (rec.type === 'session/end-seed') endSeedIndex = i;
|
|
235
|
+
if (rec && typeof rec === 'object' && header === null && rec.type === 'session') {
|
|
236
|
+
header = rec;
|
|
231
237
|
}
|
|
232
238
|
}
|
|
233
239
|
|
|
@@ -243,11 +249,8 @@ function parseSessionText(text) {
|
|
|
243
249
|
throw error;
|
|
244
250
|
}
|
|
245
251
|
|
|
246
|
-
const
|
|
247
|
-
const project = projectFromCwd(header.cwd);
|
|
248
|
-
|
|
252
|
+
const messages = [];
|
|
249
253
|
for (let i = 0; i < lines.length; i++) {
|
|
250
|
-
if (i <= endSeedIndex) continue;
|
|
251
254
|
if (lines[i].length === 0) continue;
|
|
252
255
|
let rec;
|
|
253
256
|
try {
|
|
@@ -256,51 +259,142 @@ function parseSessionText(text) {
|
|
|
256
259
|
continue;
|
|
257
260
|
}
|
|
258
261
|
if (!rec || typeof rec !== 'object') continue;
|
|
259
|
-
const
|
|
260
|
-
if (
|
|
262
|
+
const timeMs = recordTimeMs(rec);
|
|
263
|
+
if (timeMs == null) continue;
|
|
264
|
+
const seq = Number.isSafeInteger(rec.seq) && rec.seq >= 0 ? rec.seq : null;
|
|
261
265
|
|
|
262
266
|
if (isUserMessageRecord(rec)) {
|
|
263
|
-
|
|
267
|
+
messages.push({ seq, role: 'user', timeMs, usage: null, model: null });
|
|
264
268
|
continue;
|
|
265
269
|
}
|
|
266
270
|
if (!isUsageRecord(rec)) continue;
|
|
267
271
|
|
|
268
272
|
// Every assistant/message marks the end of a billable step, even when its
|
|
269
|
-
// usage block is missing.
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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,
|
|
273
|
+
// usage block is missing; the model keeps it so timing survives.
|
|
274
|
+
messages.push({
|
|
275
|
+
seq,
|
|
276
|
+
role: 'assistant',
|
|
277
|
+
timeMs,
|
|
278
|
+
usage: parseUsage(rec.data.usage),
|
|
279
|
+
model:
|
|
280
|
+
typeof rec.data.message?.source?.model === 'string' && rec.data.message.source.model
|
|
281
|
+
? rec.data.message.source.model
|
|
282
|
+
: 'unknown',
|
|
300
283
|
});
|
|
301
284
|
}
|
|
302
285
|
|
|
303
|
-
return {
|
|
286
|
+
return {
|
|
287
|
+
sessionId: header.id,
|
|
288
|
+
parentSessionId:
|
|
289
|
+
typeof header.parentSession === 'string' && header.parentSession
|
|
290
|
+
? header.parentSession
|
|
291
|
+
: null,
|
|
292
|
+
seedLength:
|
|
293
|
+
Number.isSafeInteger(header.seedLength) && header.seedLength > 0
|
|
294
|
+
? header.seedLength
|
|
295
|
+
: 0,
|
|
296
|
+
cwd: header.cwd,
|
|
297
|
+
messages,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Record wall-clock time in epoch ms; null when absent/invalid. */
|
|
302
|
+
function recordTimeMs(rec) {
|
|
303
|
+
const t = rec.time;
|
|
304
|
+
if (typeof t === 'number' && Number.isFinite(t)) return t;
|
|
305
|
+
if (typeof t === 'string' && t.trim()) {
|
|
306
|
+
const d = new Date(t);
|
|
307
|
+
return Number.isNaN(d.getTime()) ? null : d.getTime();
|
|
308
|
+
}
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Usage numbers from an assistant/message usage block, or null when empty. */
|
|
313
|
+
function parseUsage(usage) {
|
|
314
|
+
if (!usage || typeof usage !== 'object') return null;
|
|
315
|
+
// Harness counts are disjoint. The common bucket model has no cache-write
|
|
316
|
+
// column, so cache writes join uncached input, matching the other parsers.
|
|
317
|
+
const inputTokens = toCount(usage.inputTokens) + toCount(usage.cacheWriteTokens);
|
|
318
|
+
const cachedInputTokens = toCount(usage.cacheReadTokens);
|
|
319
|
+
const totalOutputTokens = toCount(usage.outputTokens);
|
|
320
|
+
const reasoningOutputTokens = Math.min(totalOutputTokens, toCount(usage.reasoningTokens));
|
|
321
|
+
const outputTokens = totalOutputTokens - reasoningOutputTokens;
|
|
322
|
+
if (inputTokens + cachedInputTokens + reasoningOutputTokens + outputTokens === 0) return null;
|
|
323
|
+
return { inputTokens, outputTokens, cachedInputTokens, reasoningOutputTokens };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Token-accounting equality for a copied assistant record. */
|
|
327
|
+
function sameUsage(left, right) {
|
|
328
|
+
if (left == null || right == null) return left === right;
|
|
329
|
+
return (
|
|
330
|
+
left.inputTokens === right.inputTokens &&
|
|
331
|
+
left.outputTokens === right.outputTokens &&
|
|
332
|
+
left.cachedInputTokens === right.cachedInputTokens &&
|
|
333
|
+
left.reasoningOutputTokens === right.reasoningOutputTokens
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Number of leading child messages inherited from a parent seed.
|
|
339
|
+
*
|
|
340
|
+
* `header.seedLength` is DSH's durable fork-lineage boundary: event seqs below
|
|
341
|
+
* it came from the parent, while later seqs belong to the child. Each skipped
|
|
342
|
+
* message must still exist at the same seq in the selected parent copy.
|
|
343
|
+
* Missing, invalid, or divergent records fail open so usage is not lost.
|
|
344
|
+
*/
|
|
345
|
+
function replaySkipCount(child, parent) {
|
|
346
|
+
if (child.seedLength <= 0 || child.messages.length === 0) return 0;
|
|
347
|
+
let parentIndex = 0;
|
|
348
|
+
let previousSeq = -1;
|
|
349
|
+
let count = 0;
|
|
350
|
+
for (const message of child.messages) {
|
|
351
|
+
if (message.seq == null || message.seq <= previousSeq) return 0;
|
|
352
|
+
previousSeq = message.seq;
|
|
353
|
+
if (message.seq >= child.seedLength) break;
|
|
354
|
+
|
|
355
|
+
while (
|
|
356
|
+
parentIndex < parent.messages.length &&
|
|
357
|
+
parent.messages[parentIndex].seq != null &&
|
|
358
|
+
parent.messages[parentIndex].seq < message.seq
|
|
359
|
+
) {
|
|
360
|
+
parentIndex++;
|
|
361
|
+
}
|
|
362
|
+
const source = parent.messages[parentIndex];
|
|
363
|
+
if (
|
|
364
|
+
source?.seq !== message.seq ||
|
|
365
|
+
source.role !== message.role ||
|
|
366
|
+
source.model !== message.model ||
|
|
367
|
+
!sameUsage(source.usage, message.usage)
|
|
368
|
+
) {
|
|
369
|
+
return 0;
|
|
370
|
+
}
|
|
371
|
+
parentIndex++;
|
|
372
|
+
count++;
|
|
373
|
+
}
|
|
374
|
+
return count;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Fold a (possibly replay-trimmed) model into flat usage entries + timing events. */
|
|
378
|
+
function modelToResult(model, skipCount) {
|
|
379
|
+
const sessionId = model.sessionId;
|
|
380
|
+
const project = projectFromCwd(model.cwd);
|
|
381
|
+
const entries = [];
|
|
382
|
+
const events = [];
|
|
383
|
+
for (let i = skipCount; i < model.messages.length; i++) {
|
|
384
|
+
const msg = model.messages[i];
|
|
385
|
+
const timestamp = new Date(msg.timeMs);
|
|
386
|
+
events.push({ sessionId, source: SOURCE, project, timestamp, role: msg.role });
|
|
387
|
+
if (msg.usage) {
|
|
388
|
+
entries.push({
|
|
389
|
+
source: SOURCE,
|
|
390
|
+
model: msg.model || 'unknown',
|
|
391
|
+
project,
|
|
392
|
+
timestamp,
|
|
393
|
+
...msg.usage,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return { entries, events };
|
|
304
398
|
}
|
|
305
399
|
|
|
306
400
|
/** List session log files under a DSH sessions root (session.jsonl[.zstd]). */
|
|
@@ -355,6 +449,12 @@ function listSessionFiles(sessionsDir, onFailure) {
|
|
|
355
449
|
* Zstandard session logs are multi-frame; node:zlib zstd (Node >= 22.15)
|
|
356
450
|
* decodes one frame per call, so the buffer is walked frame-by-frame, with a
|
|
357
451
|
* `zstd` CLI fallback for older Node.
|
|
452
|
+
*
|
|
453
|
+
* Replay handling: `header.parentSession` identifies a fork/subagent source,
|
|
454
|
+
* and `header.seedLength` is the exact count of leading event seqs inherited
|
|
455
|
+
* from it. Those records are skipped only when the parent file is also
|
|
456
|
+
* present. Files without either field, and children whose parent is missing,
|
|
457
|
+
* are counted in full. `session/end-seed` positions are never used.
|
|
358
458
|
*/
|
|
359
459
|
export async function parse() {
|
|
360
460
|
const sessionsDir = getDshSessionsDir();
|
|
@@ -385,7 +485,9 @@ export async function parse() {
|
|
|
385
485
|
return result;
|
|
386
486
|
}
|
|
387
487
|
|
|
388
|
-
|
|
488
|
+
// sessionId -> most complete model (largest decompressed log wins, so a
|
|
489
|
+
// session copied between project dirs is counted once).
|
|
490
|
+
const perSession = new Map();
|
|
389
491
|
for (const { file, compressed } of files) {
|
|
390
492
|
let text;
|
|
391
493
|
try {
|
|
@@ -406,9 +508,9 @@ export async function parse() {
|
|
|
406
508
|
continue;
|
|
407
509
|
}
|
|
408
510
|
|
|
409
|
-
let
|
|
511
|
+
let model;
|
|
410
512
|
try {
|
|
411
|
-
|
|
513
|
+
model = buildSessionModel(text);
|
|
412
514
|
} catch (error) {
|
|
413
515
|
recordFailure(
|
|
414
516
|
'dsh: skipping ' + relative(process.cwd(), file) + ' (' + error.message + ')',
|
|
@@ -417,17 +519,24 @@ export async function parse() {
|
|
|
417
519
|
}
|
|
418
520
|
|
|
419
521
|
const weight = text.length;
|
|
420
|
-
const previous = perSession.get(
|
|
522
|
+
const previous = perSession.get(model.sessionId);
|
|
421
523
|
if (!previous || weight > previous.weight) {
|
|
422
|
-
perSession.set(
|
|
524
|
+
perSession.set(model.sessionId, { model, weight });
|
|
423
525
|
}
|
|
424
526
|
}
|
|
425
527
|
|
|
426
528
|
const entries = [];
|
|
427
529
|
const eventsBySession = new Map();
|
|
428
|
-
for (const
|
|
429
|
-
|
|
430
|
-
|
|
530
|
+
for (const { model } of perSession.values()) {
|
|
531
|
+
// seedLength supplies the exact inherited boundary; matching source seqs
|
|
532
|
+
// prove the selected parent copy still contains what the child inherited.
|
|
533
|
+
// Missing/corrupt parents fail open so the child remains the local copy.
|
|
534
|
+
const parent =
|
|
535
|
+
model.parentSessionId == null ? null : perSession.get(model.parentSessionId);
|
|
536
|
+
const skip = parent ? replaySkipCount(model, parent.model) : 0;
|
|
537
|
+
const { entries: fileEntries, events: fileEvents } = modelToResult(model, skip);
|
|
538
|
+
entries.push(...fileEntries);
|
|
539
|
+
for (const event of fileEvents) {
|
|
431
540
|
if (!eventsBySession.has(event.sessionId)) eventsBySession.set(event.sessionId, []);
|
|
432
541
|
eventsBySession.get(event.sessionId).push(event);
|
|
433
542
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { getPiSessionDirs } from '../pi-roots.js';
|
|
2
|
+
import { mergeCindyHarnessUsage, readCindyHarnessUsage } from './cindy-ledger.js';
|
|
2
3
|
import { parsePiSessionJsonl } from './pi-session-jsonl.js';
|
|
3
4
|
|
|
4
5
|
/** Parse the official Pi agent's Pi-compatible JSONL sessions. */
|
|
5
6
|
export async function parse() {
|
|
6
|
-
|
|
7
|
+
const nativeResult = await parsePiSessionJsonl({
|
|
7
8
|
source: 'pi-coding-agent',
|
|
8
9
|
sessionsDirs: getPiSessionDirs(),
|
|
9
10
|
});
|
|
11
|
+
return mergeCindyHarnessUsage(nativeResult, readCindyHarnessUsage('pi'));
|
|
10
12
|
}
|
package/src/parsers/sqlite.js
CHANGED
|
@@ -17,8 +17,12 @@ const require = createRequire(import.meta.url);
|
|
|
17
17
|
* If neither is available, throws an Error whose message contains "ENOENT" so
|
|
18
18
|
* callers can surface an "Install sqlite3" hint, matching the previous behavior.
|
|
19
19
|
*/
|
|
20
|
-
export function queryDbJson(
|
|
21
|
-
|
|
20
|
+
export function queryDbJson(
|
|
21
|
+
dbPath,
|
|
22
|
+
sql,
|
|
23
|
+
{ timeout = 30000, maxBuffer = 100 * 1024 * 1024, readOnly = true } = {},
|
|
24
|
+
) {
|
|
25
|
+
const db = openNodeSqlite(dbPath, readOnly);
|
|
22
26
|
if (db) {
|
|
23
27
|
try {
|
|
24
28
|
return db.prepare(sql).all();
|
|
@@ -55,12 +59,22 @@ function getNodeSqlite() {
|
|
|
55
59
|
return nodeSqlite;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
function openNodeSqlite(dbPath) {
|
|
62
|
+
function openNodeSqlite(dbPath, readOnly = true) {
|
|
59
63
|
const mod = getNodeSqlite();
|
|
60
64
|
if (!mod || !mod.DatabaseSync) return null;
|
|
65
|
+
let db;
|
|
61
66
|
try {
|
|
62
|
-
|
|
67
|
+
db = new mod.DatabaseSync(dbPath, { readOnly });
|
|
68
|
+
// Writable access is used only for disposable snapshots whose WAL metadata
|
|
69
|
+
// may need initialization. Keep the SQL connection itself read-only.
|
|
70
|
+
if (!readOnly) db.exec('PRAGMA query_only = ON');
|
|
71
|
+
return db;
|
|
63
72
|
} catch {
|
|
73
|
+
try {
|
|
74
|
+
db?.close();
|
|
75
|
+
} catch {
|
|
76
|
+
// Ignore cleanup failure while falling back to the sqlite3 CLI.
|
|
77
|
+
}
|
|
64
78
|
return null;
|
|
65
79
|
}
|
|
66
80
|
}
|
|
@@ -94,6 +108,31 @@ export function isLockError(err) {
|
|
|
94
108
|
return !!err && typeof err.message === 'string' && /database is locked/i.test(err.message);
|
|
95
109
|
}
|
|
96
110
|
|
|
111
|
+
function querySnapshot(dbPath, sql, { tempPrefix, opts } = {}) {
|
|
112
|
+
const snapshotDir = mkdtempSync(join(tmpdir(), tempPrefix || 'vibe-usage-sqlite-'));
|
|
113
|
+
const queryPath = join(snapshotDir, basename(dbPath));
|
|
114
|
+
try {
|
|
115
|
+
copyFileSync(dbPath, queryPath);
|
|
116
|
+
for (const suffix of ['-shm', '-wal']) {
|
|
117
|
+
const companion = `${dbPath}${suffix}`;
|
|
118
|
+
if (existsSync(companion)) copyFileSync(companion, `${queryPath}${suffix}`);
|
|
119
|
+
}
|
|
120
|
+
return queryDbJson(queryPath, sql, { ...opts, readOnly: false });
|
|
121
|
+
} finally {
|
|
122
|
+
rmSync(snapshotDir, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Query a disposable writable snapshot. Some WAL-mode databases cannot be
|
|
128
|
+
* opened read-only until SQLite has initialized their shared-memory metadata;
|
|
129
|
+
* doing that only in the temp copy keeps the source application database
|
|
130
|
+
* untouched and works without a sqlite3 binary on Node >= 22.5.
|
|
131
|
+
*/
|
|
132
|
+
export function queryDbJsonSnapshot(dbPath, sql, options = {}) {
|
|
133
|
+
return querySnapshot(dbPath, sql, options);
|
|
134
|
+
}
|
|
135
|
+
|
|
97
136
|
/**
|
|
98
137
|
* Run a query, and if the source app holds a write lock on the database, copy
|
|
99
138
|
* the DB (plus its -wal/-shm companions) to a temp dir and re-query the
|
|
@@ -104,17 +143,6 @@ export function queryDbJsonSnapshotOnLock(dbPath, sql, { tempPrefix = 'vibe-usag
|
|
|
104
143
|
return queryDbJson(dbPath, sql, opts);
|
|
105
144
|
} catch (err) {
|
|
106
145
|
if (!isLockError(err)) throw err;
|
|
107
|
-
|
|
108
|
-
const queryPath = join(snapshotDir, basename(dbPath));
|
|
109
|
-
try {
|
|
110
|
-
copyFileSync(dbPath, queryPath);
|
|
111
|
-
for (const suffix of ['-shm', '-wal']) {
|
|
112
|
-
const companion = `${dbPath}${suffix}`;
|
|
113
|
-
if (existsSync(companion)) copyFileSync(companion, `${queryPath}${suffix}`);
|
|
114
|
-
}
|
|
115
|
-
return queryDbJson(queryPath, sql, opts);
|
|
116
|
-
} finally {
|
|
117
|
-
rmSync(snapshotDir, { recursive: true, force: true });
|
|
118
|
-
}
|
|
146
|
+
return querySnapshot(dbPath, sql, { tempPrefix, opts });
|
|
119
147
|
}
|
|
120
148
|
}
|
package/src/parsers/trae-cli.js
CHANGED
|
@@ -1,22 +1,86 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createReadStream, existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { findTraeCliDataDirs } from '../tools.js';
|
|
4
5
|
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
5
6
|
import { readJsonSafe, projectFromPath } from './fs-utils.js';
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
// Trae writes each LLM call as several nested spans that share one session-level
|
|
9
|
+
// traceID and copy the same usage onto every layer:
|
|
10
|
+
// model.stream.eino (authoritative: includes reasoning tokens)
|
|
11
|
+
// model.real_call (duplicate)
|
|
12
|
+
// model.call (duplicate)
|
|
13
|
+
// model.generate is a separate failover call (different model), not a duplicate.
|
|
14
|
+
// Counting every layer would 3x; merging by traceID with max() collapses a
|
|
15
|
+
// whole session of sequential calls into a single request. Keep one unique
|
|
16
|
+
// layer per call, then SUM.
|
|
17
|
+
const PRIMARY_LLM_CATEGORY = 'model.stream.eino';
|
|
18
|
+
const FAILOVER_LLM_CATEGORY = 'model.generate';
|
|
19
|
+
const FALLBACK_LLM_CATEGORIES = ['model.real_call', 'model.call'];
|
|
20
|
+
|
|
21
|
+
function tagMapFrom(tags) {
|
|
22
|
+
const tagMap = {};
|
|
23
|
+
if (!Array.isArray(tags)) return tagMap;
|
|
24
|
+
for (const t of tags) {
|
|
25
|
+
if (t && typeof t === 'object' && t.key) tagMap[t.key] = t.value;
|
|
26
|
+
}
|
|
27
|
+
return tagMap;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function spanCategory(tagMap) {
|
|
31
|
+
return typeof tagMap['span.category'] === 'string' ? tagMap['span.category'] : '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function usageFromTagMap(tagMap) {
|
|
35
|
+
const inputTokens = Math.max(0, Number(tagMap['usage.input_tokens']) || 0);
|
|
36
|
+
const outputTokens = Math.max(0, Number(tagMap['usage.output_tokens']) || 0);
|
|
37
|
+
const cacheReadTokens = Math.max(0, Number(tagMap['usage.cache_read_tokens']) || 0);
|
|
38
|
+
const reasoningTokens = Math.max(0, Number(tagMap['usage.reasoning_tokens']) || 0);
|
|
39
|
+
return { inputTokens, outputTokens, cacheReadTokens, reasoningTokens };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hasUsage(usage) {
|
|
43
|
+
return usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.reasoningTokens > 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Pick the unique LLM-call spans from a session's traces.
|
|
48
|
+
* Prefer model.stream.eino (+ model.generate failovers). If a session has no
|
|
49
|
+
* primary layer (older traces), fall back to model.real_call, then model.call.
|
|
50
|
+
* @param {{category: string, usage: object, model: string|null, startTime: number}[]} spans
|
|
51
|
+
*/
|
|
52
|
+
export function selectTraeUsageSpans(spans) {
|
|
53
|
+
const withUsage = spans.filter((s) => hasUsage(s.usage));
|
|
54
|
+
const primary = withUsage.filter((s) => s.category === PRIMARY_LLM_CATEGORY);
|
|
55
|
+
const failover = withUsage.filter((s) => s.category === FAILOVER_LLM_CATEGORY);
|
|
56
|
+
if (primary.length > 0 || failover.length > 0) return primary.concat(failover);
|
|
57
|
+
for (const cat of FALLBACK_LLM_CATEGORIES) {
|
|
58
|
+
const subset = withUsage.filter((s) => s.category === cat);
|
|
59
|
+
if (subset.length > 0) return subset;
|
|
60
|
+
}
|
|
61
|
+
return withUsage;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Stream a JSONL file line by line. Skips missing files and malformed lines. */
|
|
65
|
+
export async function forEachJsonl(path, onObj) {
|
|
66
|
+
if (!existsSync(path)) return;
|
|
67
|
+
const stream = createReadStream(path, { encoding: 'utf8' });
|
|
68
|
+
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
8
69
|
try {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
70
|
+
for await (const line of lines) {
|
|
71
|
+
const trimmed = line.trim();
|
|
72
|
+
if (!trimmed) continue;
|
|
73
|
+
let obj;
|
|
74
|
+
try {
|
|
75
|
+
obj = JSON.parse(trimmed);
|
|
76
|
+
} catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (obj && typeof obj === 'object') onObj(obj);
|
|
80
|
+
}
|
|
81
|
+
} finally {
|
|
82
|
+
lines.close();
|
|
83
|
+
stream.destroy();
|
|
20
84
|
}
|
|
21
85
|
}
|
|
22
86
|
|
|
@@ -43,79 +107,41 @@ export async function parse() {
|
|
|
43
107
|
const project = projectFromPath(sessionJson.metadata?.cwd);
|
|
44
108
|
const fallbackModel = sessionJson.metadata?.model_name || 'trae-unknown';
|
|
45
109
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const model = tagMap['model.name'] || tagMap['semantic.name'] || null;
|
|
61
|
-
const inputTokens = Math.max(0, Number(tagMap['usage.input_tokens']) || 0);
|
|
62
|
-
const outputTokens = Math.max(0, Number(tagMap['usage.output_tokens']) || 0);
|
|
63
|
-
const cacheReadTokens = Math.max(0, Number(tagMap['usage.cache_read_tokens']) || 0);
|
|
64
|
-
const reasoningTokens = Math.max(0, Number(tagMap['usage.reasoning_tokens']) || 0);
|
|
65
|
-
|
|
66
|
-
if (inputTokens + outputTokens + cacheReadTokens + reasoningTokens === 0) {
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (!tracesMap.has(line.traceID)) {
|
|
71
|
-
tracesMap.set(line.traceID, {
|
|
72
|
-
model,
|
|
73
|
-
inputTokens,
|
|
74
|
-
outputTokens,
|
|
75
|
-
cacheReadTokens,
|
|
76
|
-
reasoningTokens,
|
|
77
|
-
startTime: Number(line.startTime) || 0,
|
|
78
|
-
});
|
|
79
|
-
} else {
|
|
80
|
-
// Merge spans under the same traceID by selecting the max values
|
|
81
|
-
const existing = tracesMap.get(line.traceID);
|
|
82
|
-
if (model) {
|
|
83
|
-
existing.model = model;
|
|
84
|
-
}
|
|
85
|
-
existing.inputTokens = Math.max(existing.inputTokens, inputTokens);
|
|
86
|
-
existing.outputTokens = Math.max(existing.outputTokens, outputTokens);
|
|
87
|
-
existing.cacheReadTokens = Math.max(existing.cacheReadTokens, cacheReadTokens);
|
|
88
|
-
existing.reasoningTokens = Math.max(existing.reasoningTokens, reasoningTokens);
|
|
89
|
-
if (line.startTime) {
|
|
90
|
-
existing.startTime = existing.startTime ? Math.min(existing.startTime, Number(line.startTime)) : Number(line.startTime);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
110
|
+
const spans = [];
|
|
111
|
+
await forEachJsonl(join(sessionPath, 'traces.jsonl'), (line) => {
|
|
112
|
+
const tagMap = tagMapFrom(line.tags);
|
|
113
|
+
const usage = usageFromTagMap(tagMap);
|
|
114
|
+
if (!hasUsage(usage)) return;
|
|
115
|
+
const startTime = Number(line.startTime);
|
|
116
|
+
if (!Number.isFinite(startTime) || startTime <= 0) return;
|
|
117
|
+
spans.push({
|
|
118
|
+
category: spanCategory(tagMap),
|
|
119
|
+
model: tagMap['model.name'] || tagMap['semantic.name'] || null,
|
|
120
|
+
startTime,
|
|
121
|
+
usage,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
94
124
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (!Number.isFinite(startTime) || startTime <= 0) continue;
|
|
100
|
-
const timestamp = new Date(startTime / 1000);
|
|
125
|
+
for (const span of selectTraeUsageSpans(spans)) {
|
|
126
|
+
// Trae startTime is microseconds; Date expects milliseconds.
|
|
127
|
+
const timestamp = new Date(span.startTime / 1000);
|
|
128
|
+
if (Number.isNaN(timestamp.getTime())) continue;
|
|
101
129
|
entries.push({
|
|
102
130
|
source: 'trae-cli',
|
|
103
|
-
model:
|
|
131
|
+
model: span.model || fallbackModel,
|
|
104
132
|
project,
|
|
105
133
|
timestamp,
|
|
106
|
-
inputTokens:
|
|
107
|
-
outputTokens:
|
|
108
|
-
cachedInputTokens:
|
|
109
|
-
reasoningOutputTokens:
|
|
134
|
+
inputTokens: span.usage.inputTokens,
|
|
135
|
+
outputTokens: span.usage.outputTokens,
|
|
136
|
+
cachedInputTokens: span.usage.cacheReadTokens,
|
|
137
|
+
reasoningOutputTokens: span.usage.reasoningTokens,
|
|
110
138
|
});
|
|
111
139
|
}
|
|
112
140
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
for (const line of eventLines) {
|
|
116
|
-
if (!line.created_at) continue;
|
|
141
|
+
await forEachJsonl(join(sessionPath, 'events.jsonl'), (line) => {
|
|
142
|
+
if (!line.created_at) return;
|
|
117
143
|
const timestamp = new Date(line.created_at);
|
|
118
|
-
if (Number.isNaN(timestamp.getTime()))
|
|
144
|
+
if (Number.isNaN(timestamp.getTime())) return;
|
|
119
145
|
|
|
120
146
|
if (line.agent_start) {
|
|
121
147
|
events.push({
|
|
@@ -134,7 +160,7 @@ export async function parse() {
|
|
|
134
160
|
role: 'assistant',
|
|
135
161
|
});
|
|
136
162
|
}
|
|
137
|
-
}
|
|
163
|
+
});
|
|
138
164
|
}
|
|
139
165
|
}
|
|
140
166
|
|
package/src/tools.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
|
2
2
|
import { isAbsolute, join, posix, resolve, win32 } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { findClaudeCodeDataDirs } from './claude-roots.js';
|
|
5
|
+
import { findCindyDataDirs, getCindyDataRoots } from './cindy-roots.js';
|
|
5
6
|
import { codexSessionDirs, resolveCodexHomes } from './codex-roots.js';
|
|
6
7
|
import { findClineDataDirs } from './cline-roots.js';
|
|
7
8
|
import { findCraftDataDirs } from './craft-roots.js';
|
|
@@ -223,6 +224,12 @@ export const TOOLS = [
|
|
|
223
224
|
dataDir: getAlmaDbPath(),
|
|
224
225
|
detectDataDirs: () => [getAlmaDbPath()].filter(existsSync),
|
|
225
226
|
},
|
|
227
|
+
{
|
|
228
|
+
name: 'Cindy',
|
|
229
|
+
id: 'cindy',
|
|
230
|
+
dataDir: getCindyDataRoots()[0],
|
|
231
|
+
detectDataDirs: findCindyDataDirs,
|
|
232
|
+
},
|
|
226
233
|
{
|
|
227
234
|
name: 'Claude Code',
|
|
228
235
|
id: 'claude-code',
|