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