@geoqiao/pi-usage 0.1.0

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.
Files changed (62) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +222 -0
  3. package/bin/pi-usage.js +69 -0
  4. package/data/models.dev-LICENSE +21 -0
  5. package/data/prices.json +2678 -0
  6. package/extensions/usage-report.js +36 -0
  7. package/package.json +51 -0
  8. package/src/analytics.js +189 -0
  9. package/src/collect.js +34 -0
  10. package/src/network.js +25 -0
  11. package/src/report.js +43 -0
  12. package/vendor/vibe-usage/NOTICE.md +58 -0
  13. package/vendor/vibe-usage/src/cindy-roots.js +85 -0
  14. package/vendor/vibe-usage/src/claude-roots.js +165 -0
  15. package/vendor/vibe-usage/src/cline-roots.js +40 -0
  16. package/vendor/vibe-usage/src/codex-roots.js +46 -0
  17. package/vendor/vibe-usage/src/craft-roots.js +15 -0
  18. package/vendor/vibe-usage/src/extra-roots.js +312 -0
  19. package/vendor/vibe-usage/src/parsers/aggregate.js +196 -0
  20. package/vendor/vibe-usage/src/parsers/alma.js +94 -0
  21. package/vendor/vibe-usage/src/parsers/amp.js +156 -0
  22. package/vendor/vibe-usage/src/parsers/antigravity-db.js +359 -0
  23. package/vendor/vibe-usage/src/parsers/antigravity.js +530 -0
  24. package/vendor/vibe-usage/src/parsers/cindy-ledger.js +157 -0
  25. package/vendor/vibe-usage/src/parsers/claude-code.js +372 -0
  26. package/vendor/vibe-usage/src/parsers/cline.js +92 -0
  27. package/vendor/vibe-usage/src/parsers/codex-cache.js +138 -0
  28. package/vendor/vibe-usage/src/parsers/codex.js +1198 -0
  29. package/vendor/vibe-usage/src/parsers/contract.js +55 -0
  30. package/vendor/vibe-usage/src/parsers/copilot-cli.js +128 -0
  31. package/vendor/vibe-usage/src/parsers/craft-agent.js +21 -0
  32. package/vendor/vibe-usage/src/parsers/cursor.js +262 -0
  33. package/vendor/vibe-usage/src/parsers/dimagent.js +127 -0
  34. package/vendor/vibe-usage/src/parsers/droid.js +113 -0
  35. package/vendor/vibe-usage/src/parsers/dsh.js +563 -0
  36. package/vendor/vibe-usage/src/parsers/fs-utils.js +36 -0
  37. package/vendor/vibe-usage/src/parsers/gemini-cli.js +190 -0
  38. package/vendor/vibe-usage/src/parsers/grok.js +395 -0
  39. package/vendor/vibe-usage/src/parsers/hermes.js +123 -0
  40. package/vendor/vibe-usage/src/parsers/index.js +61 -0
  41. package/vendor/vibe-usage/src/parsers/kimi-code.js +467 -0
  42. package/vendor/vibe-usage/src/parsers/kiro.js +788 -0
  43. package/vendor/vibe-usage/src/parsers/mcode.js +182 -0
  44. package/vendor/vibe-usage/src/parsers/mimocode.js +88 -0
  45. package/vendor/vibe-usage/src/parsers/omp.js +10 -0
  46. package/vendor/vibe-usage/src/parsers/openclaw.js +142 -0
  47. package/vendor/vibe-usage/src/parsers/opencode.js +151 -0
  48. package/vendor/vibe-usage/src/parsers/pi-coding-agent.js +27 -0
  49. package/vendor/vibe-usage/src/parsers/pi-session-jsonl.js +166 -0
  50. package/vendor/vibe-usage/src/parsers/qwen-code.js +122 -0
  51. package/vendor/vibe-usage/src/parsers/roo-code.js +123 -0
  52. package/vendor/vibe-usage/src/parsers/sqlite.js +148 -0
  53. package/vendor/vibe-usage/src/parsers/trae-cli.js +171 -0
  54. package/vendor/vibe-usage/src/parsers/workbuddy.js +322 -0
  55. package/vendor/vibe-usage/src/parsers/zcode.js +115 -0
  56. package/vendor/vibe-usage/src/pi-roots.js +125 -0
  57. package/vendor/vibe-usage/src/tools.js +422 -0
  58. package/vendor/vibe-usage/src/workbuddy-roots.js +22 -0
  59. package/vendor/vibe-usage/upstream-files.json +48 -0
  60. package/web/report.css +10 -0
  61. package/web/report.html +81 -0
  62. package/web/report.js +310 -0
@@ -0,0 +1,113 @@
1
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
+ import { join, basename, dirname } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
+
6
+ const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions');
7
+
8
+ function findJsonlFiles(dir) {
9
+ const results = [];
10
+ if (!existsSync(dir)) return results;
11
+
12
+ try {
13
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
14
+ const fullPath = join(dir, entry.name);
15
+ if (entry.isDirectory()) {
16
+ for (const nested of findJsonlFiles(fullPath)) results.push(nested);
17
+ } else if (entry.name.endsWith('.jsonl') && !entry.name.endsWith('.settings.json')) {
18
+ results.push(fullPath);
19
+ }
20
+ }
21
+ } catch {
22
+ }
23
+
24
+ return results;
25
+ }
26
+
27
+ function extractProjectFromSlug(slug) {
28
+ const parts = slug.split('-').filter(Boolean);
29
+ return parts.length > 0 ? parts[parts.length - 1] : 'unknown';
30
+ }
31
+
32
+ function toSafeNumber(value) {
33
+ const n = Number(value);
34
+ return Number.isFinite(n) ? n : 0;
35
+ }
36
+
37
+ export async function parse() {
38
+ const entries = [];
39
+ const sessionEvents = [];
40
+ const sessionFiles = findJsonlFiles(DROID_SESSIONS_DIR);
41
+
42
+ for (const filePath of sessionFiles) {
43
+ const sessionId = basename(filePath, '.jsonl');
44
+ const slug = basename(dirname(filePath));
45
+ const project = extractProjectFromSlug(slug);
46
+ let firstMessageTimestamp = null;
47
+
48
+ let content;
49
+ try {
50
+ content = readFileSync(filePath, 'utf-8');
51
+ } catch {
52
+ continue;
53
+ }
54
+
55
+ for (const line of content.split('\n')) {
56
+ if (!line.trim()) continue;
57
+
58
+ let obj;
59
+ try {
60
+ obj = JSON.parse(line);
61
+ } catch {
62
+ continue;
63
+ }
64
+
65
+ if (obj.type !== 'message') continue;
66
+ if (!obj.timestamp) continue;
67
+
68
+ const ts = new Date(obj.timestamp);
69
+ if (isNaN(ts.getTime())) continue;
70
+
71
+ if (firstMessageTimestamp === null) firstMessageTimestamp = ts;
72
+
73
+ sessionEvents.push({
74
+ sessionId,
75
+ source: 'droid',
76
+ project,
77
+ timestamp: ts,
78
+ role: obj.message?.role === 'user' ? 'user' : 'assistant',
79
+ });
80
+ }
81
+
82
+ const settingsPath = join(dirname(filePath), `${sessionId}.settings.json`);
83
+ if (!existsSync(settingsPath) || firstMessageTimestamp === null) continue;
84
+
85
+ let settings;
86
+ try {
87
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
88
+ } catch {
89
+ continue;
90
+ }
91
+
92
+ const tokenUsage = settings?.tokenUsage;
93
+ if (!tokenUsage) continue;
94
+
95
+ const cacheReadTokens = toSafeNumber(tokenUsage.cacheReadTokens);
96
+ const thinkingTokens = toSafeNumber(tokenUsage.thinkingTokens);
97
+ const inputTokens = Math.max(0, toSafeNumber(tokenUsage.inputTokens) - cacheReadTokens);
98
+ const outputTokens = Math.max(0, toSafeNumber(tokenUsage.outputTokens) - thinkingTokens);
99
+
100
+ entries.push({
101
+ source: 'droid',
102
+ model: settings.model || 'unknown',
103
+ project,
104
+ timestamp: firstMessageTimestamp,
105
+ inputTokens,
106
+ outputTokens,
107
+ cachedInputTokens: cacheReadTokens,
108
+ reasoningOutputTokens: thinkingTokens,
109
+ });
110
+ }
111
+
112
+ return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
113
+ }
@@ -0,0 +1,563 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
3
+ import { basename, join, relative } from 'node:path';
4
+ import zlib from 'node:zlib';
5
+ import { getDshSessionsDir } from '../tools.js';
6
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
7
+
8
+ const SOURCE = 'dsh';
9
+
10
+ // DeepSeek Harness session-log format version this parser understands. DeepSeek
11
+ // Harness is currently in developer preview and is iterating rapidly — THERE
12
+ // WILL BE COMPATIBILITY-BREAKING CHANGES. When the CLI bumps the header
13
+ // `version` field, bump this constant (and the record-shape mapping below)
14
+ // after re-checking the on-disk format instead of guessing against stale
15
+ // assumptions.
16
+ const SESSION_FORMAT_VERSION = 0;
17
+
18
+ // Safety cap for a single session log. DSH stores many small zstd frames per
19
+ // file; anything beyond this is either a runaway log or not a session file.
20
+ const MAX_SESSION_FILE_BYTES = 256 * 1024 * 1024;
21
+
22
+ // Maximum decompressed size for one session log, for both decoder paths.
23
+ const MAX_DECOMPRESSED_SESSION_BYTES = 512 * 1024 * 1024;
24
+
25
+ // Zstandard frame magic (0xFD2FB528 little-endian) and the skippable-frame
26
+ // magic range (0x184D2A50–0x184D2A5F), per RFC 8878.
27
+ const ZSTD_MAGIC = 0xfd2fb528;
28
+ const SKIPPABLE_MAGIC_MIN = 0x184d2a50;
29
+ const SKIPPABLE_MAGIC_MAX = 0x184d2a5f;
30
+
31
+ const MAX_WARNINGS = 20;
32
+
33
+ /**
34
+ * Split concatenated Zstandard input into independently decodable frame ranges.
35
+ *
36
+ * DSH writes one frame for the header and one per durable append batch. Node's
37
+ * one-shot zstd API decodes only one standard frame, so each standard frame is
38
+ * returned as an independent `{ start, end }` range. Complete skippable frames
39
+ * are omitted without joining the standard frames around them. An incomplete
40
+ * tail is ignored, matching DSH's append-recovery boundary.
41
+ *
42
+ * @param {Buffer} buffer
43
+ * @returns {{ start: number, end: number }[]}
44
+ */
45
+ export function splitZstdFrames(buffer) {
46
+ const frames = [];
47
+ let pos = 0;
48
+ while (pos < buffer.length) {
49
+ if (pos + 4 > buffer.length) break;
50
+ const magic = buffer.readUInt32LE(pos);
51
+ if (magic >= SKIPPABLE_MAGIC_MIN && magic <= SKIPPABLE_MAGIC_MAX) {
52
+ if (pos + 8 > buffer.length) break;
53
+ const end = pos + 8 + buffer.readUInt32LE(pos + 4);
54
+ if (end > buffer.length) break;
55
+ pos = end;
56
+ continue;
57
+ }
58
+ if (magic !== ZSTD_MAGIC) {
59
+ throw new Error('invalid Zstandard frame magic at byte ' + pos);
60
+ }
61
+
62
+ const start = pos;
63
+ pos += 4;
64
+ if (pos >= buffer.length) break;
65
+ const descriptor = buffer[pos++];
66
+ if ((descriptor & 0x18) !== 0) {
67
+ throw new Error('reserved Zstandard frame-header bit at byte ' + (pos - 1));
68
+ }
69
+ const singleSegment = (descriptor & 0x20) !== 0;
70
+ const checksum = (descriptor & 0x04) !== 0;
71
+ const dictionaryFlag = descriptor & 0x03;
72
+ const contentSizeFlag = descriptor >>> 6;
73
+
74
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
75
+ const contentSizeBytes = contentSizeFlag === 0
76
+ ? (singleSegment ? 1 : 0)
77
+ : 1 << contentSizeFlag;
78
+ const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
79
+ if (pos + remainingHeaderBytes > buffer.length) break;
80
+ pos += remainingHeaderBytes;
81
+
82
+ for (;;) {
83
+ if (pos + 3 > buffer.length) return frames;
84
+ const blockHeader = buffer.readUIntLE(pos, 3);
85
+ pos += 3;
86
+ const lastBlock = (blockHeader & 1) !== 0;
87
+ const blockType = (blockHeader >>> 1) & 0x03;
88
+ const blockSize = blockHeader >>> 3;
89
+ if (blockType === 0x03) {
90
+ throw new Error('reserved Zstandard block type at byte ' + (pos - 3));
91
+ }
92
+ // An RLE block stores one encoded byte; blockSize is its decoded size.
93
+ const payloadBytes = blockType === 0x01 ? 1 : blockSize;
94
+ if (pos + payloadBytes > buffer.length) return frames;
95
+ pos += payloadBytes;
96
+ if (lastBlock) break;
97
+ }
98
+
99
+ if (checksum) {
100
+ if (pos + 4 > buffer.length) return frames;
101
+ pos += 4;
102
+ }
103
+ frames.push({ start, end: pos });
104
+ }
105
+ return frames;
106
+ }
107
+
108
+ const hasBuiltinZstd = typeof zlib.zstdDecompressSync === 'function';
109
+ let zstdCliProbe = null;
110
+ function hasZstdCli() {
111
+ if (zstdCliProbe !== null) return zstdCliProbe;
112
+ try {
113
+ execFileSync('zstd', ['--version'], { stdio: 'ignore', timeout: 5000 });
114
+ zstdCliProbe = true;
115
+ } catch {
116
+ zstdCliProbe = false;
117
+ }
118
+ return zstdCliProbe;
119
+ }
120
+
121
+ const ZSTD_HINT =
122
+ 'decompress with node:zlib zstd (Node >= 22.15) or install the zstd CLI';
123
+
124
+ /** Decompress the complete frames captured from one DSH session log. */
125
+ function decompressSessionLog(buffer, file) {
126
+ const frames = splitZstdFrames(buffer);
127
+ if (frames.length === 0) {
128
+ throw new Error('no complete zstd frames found in ' + relative(process.cwd(), file));
129
+ }
130
+
131
+ if (hasBuiltinZstd) {
132
+ const parts = [];
133
+ let remaining = MAX_DECOMPRESSED_SESSION_BYTES;
134
+ for (const { start, end } of frames) {
135
+ if (remaining <= 0) throw new Error('decompressed session log is too large');
136
+ const part = zlib.zstdDecompressSync(buffer.subarray(start, end), {
137
+ maxOutputLength: remaining,
138
+ });
139
+ parts.push(part);
140
+ remaining -= part.length;
141
+ }
142
+ return Buffer.concat(parts).toString('utf8');
143
+ }
144
+
145
+ if (!hasZstdCli()) {
146
+ const error = new Error('zstd unavailable for ' + file + ': ' + ZSTD_HINT);
147
+ error.code = 'ENOENT';
148
+ throw error;
149
+ }
150
+
151
+ const first = frames[0];
152
+ const last = frames.at(-1);
153
+ const contiguous = frames.every((frame, index) =>
154
+ index === 0 || frame.start === frames[index - 1].end
155
+ );
156
+ const completeInput = contiguous
157
+ ? buffer.subarray(first.start, last.end)
158
+ : Buffer.concat(frames.map(({ start, end }) => buffer.subarray(start, end)));
159
+ return execFileSync('zstd', ['-d', '-c'], {
160
+ input: completeInput,
161
+ maxBuffer: MAX_DECOMPRESSED_SESSION_BYTES,
162
+ stdio: ['pipe', 'pipe', 'ignore'],
163
+ }).toString('utf8');
164
+ }
165
+
166
+ function projectFromCwd(cwd) {
167
+ if (typeof cwd !== 'string') return 'unknown';
168
+ const trimmed = cwd.trim().replace(/[\\/]+$/, '');
169
+ if (!trimmed) return 'unknown';
170
+ const name = basename(trimmed.replace(/\\/g, '/'));
171
+ return name || 'unknown';
172
+ }
173
+
174
+ function toCount(value) {
175
+ const n = Number(value);
176
+ return Number.isFinite(n) && n > 0 ? n : 0;
177
+ }
178
+
179
+ function isUsageRecord(rec) {
180
+ return rec.type === 'assistant/message' && rec.data && typeof rec.data === 'object';
181
+ }
182
+
183
+ function isUserMessageRecord(rec) {
184
+ return (
185
+ rec.type === 'user/message' &&
186
+ rec.data &&
187
+ typeof rec.data === 'object' &&
188
+ rec.data.source?.kind === 'user'
189
+ );
190
+ }
191
+
192
+ /**
193
+ * Build a session model from one decompressed session log.
194
+ *
195
+ * Layout (DeepSeek Harness session-persistence-jsonl):
196
+ * line 0: {"type":"session","version":0,"id":...,"createdAt":...,"cwd":...,
197
+ * "parentSession":...?, ...}
198
+ * ... possibly a resumed/forked seed replay, then ...
199
+ * {"type":"user/message"|"assistant/message","time":...,"data":{...}}
200
+ *
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.
218
+ *
219
+ * usage.outputTokens includes reasoningTokens (verified against the
220
+ * session_projcache totals DSH itself maintains), so reasoning is split out of
221
+ * output before aggregation, like the Pi-family parsers.
222
+ */
223
+ function buildSessionModel(text) {
224
+ const lines = text.split('\n');
225
+
226
+ let header = null;
227
+ for (let i = 0; i < lines.length; i++) {
228
+ if (lines[i].length === 0) continue;
229
+ let rec;
230
+ try {
231
+ rec = JSON.parse(lines[i]);
232
+ } catch {
233
+ continue; // torn final line: keep the complete records
234
+ }
235
+ if (rec && typeof rec === 'object' && header === null && rec.type === 'session') {
236
+ header = rec;
237
+ }
238
+ }
239
+
240
+ if (!header || typeof header.id !== 'string' || header.id.length === 0) {
241
+ throw new Error('missing session header record');
242
+ }
243
+ if (header.version !== SESSION_FORMAT_VERSION) {
244
+ const error = new Error(
245
+ 'session ' + header.id + ' uses format version ' + header.version +
246
+ ' (parser supports ' + SESSION_FORMAT_VERSION + ')',
247
+ );
248
+ error.code = 'UNSUPPORTED_FORMAT_VERSION';
249
+ throw error;
250
+ }
251
+
252
+ const messages = [];
253
+ for (let i = 0; i < lines.length; i++) {
254
+ if (lines[i].length === 0) continue;
255
+ let rec;
256
+ try {
257
+ rec = JSON.parse(lines[i]);
258
+ } catch {
259
+ continue;
260
+ }
261
+ if (!rec || typeof rec !== 'object') continue;
262
+ const timeMs = recordTimeMs(rec);
263
+ if (timeMs == null) continue;
264
+ const seq = Number.isSafeInteger(rec.seq) && rec.seq >= 0 ? rec.seq : null;
265
+
266
+ if (isUserMessageRecord(rec)) {
267
+ messages.push({ seq, role: 'user', timeMs, usage: null, model: null });
268
+ continue;
269
+ }
270
+ if (!isUsageRecord(rec)) continue;
271
+
272
+ // Every assistant/message marks the end of a billable step, even when its
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',
283
+ });
284
+ }
285
+
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 };
398
+ }
399
+
400
+ /** List session log files under a DSH sessions root (session.jsonl[.zstd]). */
401
+ function listSessionFiles(sessionsDir, onFailure) {
402
+ const files = [];
403
+ const projectKeys = readdirSync(sessionsDir, { withFileTypes: true })
404
+ .sort((a, b) => a.name.localeCompare(b.name));
405
+ for (const projectKey of projectKeys) {
406
+ if (!projectKey.isDirectory()) continue;
407
+ const projectDir = join(sessionsDir, projectKey.name);
408
+ let sessionDirs;
409
+ try {
410
+ sessionDirs = readdirSync(projectDir, { withFileTypes: true })
411
+ .sort((a, b) => a.name.localeCompare(b.name));
412
+ } catch (error) {
413
+ onFailure(
414
+ 'dsh: cannot read project directory ' + projectKey.name +
415
+ ' (' + (error?.code || error?.message || 'read failed') + ')',
416
+ );
417
+ continue;
418
+ }
419
+ for (const sessionDir of sessionDirs) {
420
+ if (!sessionDir.isDirectory()) continue;
421
+ const sessionPath = join(projectDir, sessionDir.name);
422
+ for (const name of ['session.jsonl.zstd', 'session.jsonl']) {
423
+ const file = join(sessionPath, name);
424
+ try {
425
+ if (statSync(file).isFile()) {
426
+ files.push({ file, compressed: name.endsWith('.zstd') });
427
+ break;
428
+ }
429
+ } catch (error) {
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
+ }
438
+ }
439
+ }
440
+ }
441
+ return files;
442
+ }
443
+
444
+ /**
445
+ * DeepSeek Harness (dsh) parser.
446
+ *
447
+ * Reads $DSH_HOME/sessions/<project-key>/session-<id>/session.jsonl.zstd
448
+ * (default ~/.dsh, fixture/relocation override VIBE_USAGE_DSH_SESSIONS).
449
+ * Zstandard session logs are multi-frame; node:zlib zstd (Node >= 22.15)
450
+ * decodes one frame per call, so the buffer is walked frame-by-frame, with a
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.
458
+ */
459
+ export async function parse() {
460
+ const sessionsDir = getDshSessionsDir();
461
+ if (!existsSync(sessionsDir)) return { buckets: [], sessions: [] };
462
+
463
+ const warnings = [];
464
+ let anyFailure = false;
465
+ const recordFailure = (message) => {
466
+ anyFailure = true;
467
+ if (warnings.length < MAX_WARNINGS && !warnings.includes(message)) {
468
+ warnings.push(message);
469
+ }
470
+ };
471
+
472
+ let files;
473
+ try {
474
+ files = listSessionFiles(sessionsDir, recordFailure);
475
+ } catch (error) {
476
+ recordFailure(
477
+ 'dsh: cannot read sessions directory ' + sessionsDir +
478
+ ' (' + (error?.code || error?.message || 'read failed') + ')',
479
+ );
480
+ return { buckets: [], sessions: [], skipped: true, warnings };
481
+ }
482
+ if (files.length === 0) {
483
+ const result = { buckets: [], sessions: [] };
484
+ if (anyFailure) Object.assign(result, { skipped: true, warnings });
485
+ return result;
486
+ }
487
+
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();
491
+ for (const { file, compressed } of files) {
492
+ let text;
493
+ try {
494
+ const stat = statSync(file);
495
+ if (!stat.isFile()) throw new Error('session log is no longer a file');
496
+ if (stat.size > MAX_SESSION_FILE_BYTES) {
497
+ throw new Error('session log too large (' + stat.size + ' bytes)');
498
+ }
499
+ const buffer = readFileSync(file);
500
+ if (buffer.length < stat.size) throw new Error('session log changed while reading');
501
+ const snapshot = buffer.length === stat.size ? buffer : buffer.subarray(0, stat.size);
502
+ text = compressed ? decompressSessionLog(snapshot, file) : snapshot.toString('utf8');
503
+ } catch (error) {
504
+ const reason = error?.code === 'ENOENT' && !hasBuiltinZstd && compressed
505
+ ? ZSTD_HINT
506
+ : error?.message || String(error);
507
+ recordFailure('dsh: skipping ' + relative(process.cwd(), file) + ' (' + reason + ')');
508
+ continue;
509
+ }
510
+
511
+ let model;
512
+ try {
513
+ model = buildSessionModel(text);
514
+ } catch (error) {
515
+ recordFailure(
516
+ 'dsh: skipping ' + relative(process.cwd(), file) + ' (' + error.message + ')',
517
+ );
518
+ continue;
519
+ }
520
+
521
+ const weight = text.length;
522
+ const previous = perSession.get(model.sessionId);
523
+ if (!previous || weight > previous.weight) {
524
+ perSession.set(model.sessionId, { model, weight });
525
+ }
526
+ }
527
+
528
+ const entries = [];
529
+ const eventsBySession = new Map();
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
+ for (const entry of fileEntries) entries.push(entry);
539
+ for (const event of fileEvents) {
540
+ if (!eventsBySession.has(event.sessionId)) eventsBySession.set(event.sessionId, []);
541
+ eventsBySession.get(event.sessionId).push(event);
542
+ }
543
+ }
544
+
545
+ // Only sessions with at least one real user prompt are meaningful timing
546
+ // data; assistant-only logs (e.g. plugin-driven sessions) are skipped.
547
+ const events = [];
548
+ for (const sessionEvents of eventsBySession.values()) {
549
+ if (sessionEvents.some((event) => event.role === 'user')) {
550
+ for (const event of sessionEvents) events.push(event);
551
+ }
552
+ }
553
+
554
+ const result = {
555
+ buckets: aggregateToBuckets(entries),
556
+ sessions: extractSessions(events),
557
+ };
558
+ if (warnings.length > 0 || anyFailure) {
559
+ result.skipped = anyFailure;
560
+ result.warnings = warnings;
561
+ }
562
+ return result;
563
+ }