@vibe-cafe/vibe-usage 0.10.10 → 0.10.12
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 +1 -1
- package/package.json +1 -1
- package/src/api.js +56 -42
- package/src/parsers/aggregate.js +157 -0
- package/src/parsers/alma.js +18 -15
- package/src/parsers/amp.js +1 -1
- package/src/parsers/antigravity-db.js +8 -31
- package/src/parsers/antigravity.js +1 -1
- package/src/parsers/claude-code.js +24 -22
- package/src/parsers/cline.js +4 -14
- package/src/parsers/codex.js +1 -1
- package/src/parsers/contract.js +55 -0
- package/src/parsers/copilot-cli.js +1 -1
- package/src/parsers/cursor.js +12 -34
- package/src/parsers/dimagent.js +7 -13
- package/src/parsers/droid.js +1 -1
- package/src/parsers/dsh.js +1 -1
- package/src/parsers/fs-utils.js +36 -0
- package/src/parsers/gemini-cli.js +1 -1
- package/src/parsers/grok.js +3 -17
- package/src/parsers/hermes.js +3 -5
- package/src/parsers/index.js +1 -148
- package/src/parsers/kimi-code.js +1 -1
- package/src/parsers/kiro.js +6 -39
- package/src/parsers/mimocode.js +3 -5
- package/src/parsers/openclaw.js +1 -1
- package/src/parsers/opencode.js +3 -5
- package/src/parsers/pi-session-jsonl.js +6 -16
- package/src/parsers/qwen-code.js +1 -1
- package/src/parsers/roo-code.js +4 -14
- package/src/parsers/sqlite.js +46 -0
- package/src/parsers/trae-cli.js +3 -13
- package/src/parsers/workbuddy.js +1 -1
- package/src/parsers/zcode.js +3 -5
- package/src/state.js +14 -3
- package/src/summary.js +12 -42
- package/src/sync.js +110 -38
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
51
51
|
|
|
52
52
|
| Tool | Data Location |
|
|
53
53
|
|------|---------------|
|
|
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. Cache writes are included in input usage. The ledger contains assistant responses only, so Alma emits token buckets without session timing. |
|
|
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
56
|
| 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
57
|
| 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` |
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -231,75 +231,89 @@ function _jsonRequest(apiUrl, path, method, body, timeoutMs) {
|
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
/**
|
|
234
|
-
* GET
|
|
235
|
-
*
|
|
236
|
-
*
|
|
234
|
+
* Authenticated GET returning parsed JSON. Shared by summary and settings so
|
|
235
|
+
* callers don't hand-roll their own https request + error mapping.
|
|
236
|
+
* - 401 → Error('UNAUTHORIZED') with statusCode 401
|
|
237
|
+
* - other non-2xx / timeout / bad JSON → Error with statusCode
|
|
237
238
|
* @param {string} apiUrl
|
|
238
239
|
* @param {string} apiKey
|
|
239
|
-
* @
|
|
240
|
+
* @param {string} path
|
|
241
|
+
* @param {{timeoutMs?: number}} [opts]
|
|
242
|
+
* @returns {Promise<any>}
|
|
240
243
|
*/
|
|
241
|
-
export
|
|
242
|
-
const wait = retry.sleep ?? sleep;
|
|
243
|
-
const random = retry.random ?? Math.random;
|
|
244
|
-
|
|
245
|
-
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
246
|
-
try {
|
|
247
|
-
return await fetchSettingsOnce(apiUrl, apiKey);
|
|
248
|
-
} catch (err) {
|
|
249
|
-
if (err.message === 'UNAUTHORIZED') throw err;
|
|
250
|
-
// Retrying a permanent client response cannot make it valid. 429 is the
|
|
251
|
-
// exception: it is transient load shedding and benefits from backoff.
|
|
252
|
-
if (err.statusCode >= 400 && err.statusCode < 500 && err.statusCode !== 429) {
|
|
253
|
-
return null;
|
|
254
|
-
}
|
|
255
|
-
if (attempt < MAX_RETRIES - 1) {
|
|
256
|
-
await wait(retryDelayMs(attempt, random));
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
return null;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function fetchSettingsOnce(apiUrl, apiKey) {
|
|
244
|
+
export function getJson(apiUrl, apiKey, path, { timeoutMs = 15_000 } = {}) {
|
|
264
245
|
return new Promise((resolve, reject) => {
|
|
265
|
-
const url = new URL(
|
|
246
|
+
const url = new URL(path, apiUrl);
|
|
266
247
|
const mod = url.protocol === 'https:' ? https : http;
|
|
267
248
|
|
|
268
249
|
const req = mod.request(url, {
|
|
269
250
|
method: 'GET',
|
|
270
|
-
timeout:
|
|
271
|
-
headers: {
|
|
272
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
273
|
-
},
|
|
251
|
+
timeout: timeoutMs,
|
|
252
|
+
headers: { 'Authorization': `Bearer ${apiKey}` },
|
|
274
253
|
}, (res) => {
|
|
275
254
|
let data = '';
|
|
276
255
|
res.on('data', (chunk) => { data += chunk; });
|
|
277
256
|
res.on('end', () => {
|
|
278
257
|
if (res.statusCode === 401) {
|
|
279
|
-
|
|
258
|
+
const err = new Error('UNAUTHORIZED');
|
|
259
|
+
err.statusCode = 401;
|
|
260
|
+
reject(err);
|
|
280
261
|
return;
|
|
281
262
|
}
|
|
282
263
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
283
|
-
const err = new Error(`HTTP ${res.statusCode}: ${data}`);
|
|
264
|
+
const err = new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`);
|
|
284
265
|
err.statusCode = res.statusCode;
|
|
285
266
|
reject(err);
|
|
286
267
|
return;
|
|
287
268
|
}
|
|
288
269
|
try {
|
|
289
|
-
|
|
290
|
-
if (typeof settings?.uploadProject !== 'boolean') {
|
|
291
|
-
reject(new Error('Invalid settings response'));
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
resolve(settings);
|
|
270
|
+
resolve(JSON.parse(data));
|
|
295
271
|
} catch {
|
|
296
|
-
reject(new Error('Invalid
|
|
272
|
+
reject(new Error('Invalid JSON response'));
|
|
297
273
|
}
|
|
298
274
|
});
|
|
299
275
|
});
|
|
300
276
|
|
|
301
277
|
req.on('error', reject);
|
|
302
|
-
req.on('timeout', () => { req.destroy(new Error(
|
|
278
|
+
req.on('timeout', () => { req.destroy(); reject(new Error(`Request timed out (${timeoutMs}ms)`)); });
|
|
303
279
|
req.end();
|
|
304
280
|
});
|
|
305
281
|
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* GET user settings from the vibecafe API.
|
|
285
|
+
* Returns null after transient failures are exhausted. A 401 remains distinct
|
|
286
|
+
* so callers can surface invalid credentials instead of calling it an outage.
|
|
287
|
+
* @param {string} apiUrl
|
|
288
|
+
* @param {string} apiKey
|
|
289
|
+
* @returns {Promise<{uploadProject: boolean} | null>}
|
|
290
|
+
*/
|
|
291
|
+
export async function fetchSettings(apiUrl, apiKey, retry = {}) {
|
|
292
|
+
const wait = retry.sleep ?? sleep;
|
|
293
|
+
const random = retry.random ?? Math.random;
|
|
294
|
+
|
|
295
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
296
|
+
try {
|
|
297
|
+
return await fetchSettingsOnce(apiUrl, apiKey);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
if (err.message === 'UNAUTHORIZED') throw err;
|
|
300
|
+
// Retrying a permanent client response cannot make it valid. 429 is the
|
|
301
|
+
// exception: it is transient load shedding and benefits from backoff.
|
|
302
|
+
if (err.statusCode >= 400 && err.statusCode < 500 && err.statusCode !== 429) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
if (attempt < MAX_RETRIES - 1) {
|
|
306
|
+
await wait(retryDelayMs(attempt, random));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function fetchSettingsOnce(apiUrl, apiKey) {
|
|
314
|
+
const settings = await getJson(apiUrl, apiKey, '/api/usage/settings', { timeoutMs: 10_000 });
|
|
315
|
+
if (typeof settings?.uploadProject !== 'boolean') {
|
|
316
|
+
throw new Error('Invalid settings response');
|
|
317
|
+
}
|
|
318
|
+
return settings;
|
|
319
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// Shared bucket/session aggregation used by every parser. Lives in its own
|
|
4
|
+
// module (not the parser registry in index.js) so parsers never have to import
|
|
5
|
+
// the registry that imports them — that circular dependency only worked because
|
|
6
|
+
// these functions are hoisted declarations. Keeping them here breaks the cycle.
|
|
7
|
+
//
|
|
8
|
+
// Data model: parsers emit flat per-message "entries" (token usage) and
|
|
9
|
+
// per-message "events" (timing), and these two functions fold them into the
|
|
10
|
+
// bucket/session shapes described in AGENTS.md.
|
|
11
|
+
|
|
12
|
+
export function roundToHalfHour(date) {
|
|
13
|
+
const d = new Date(date);
|
|
14
|
+
d.setMinutes(d.getMinutes() < 30 ? 0 : 30, 0, 0);
|
|
15
|
+
return d;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Server column limits (usage_buckets: model varchar(100), project varchar(200)).
|
|
19
|
+
// Anything longer aborts the whole INSERT chunk with 22001, so clamp here.
|
|
20
|
+
const MODEL_MAX_LENGTH = 100;
|
|
21
|
+
const PROJECT_MAX_LENGTH = 200;
|
|
22
|
+
|
|
23
|
+
// Server token columns are bigint — a single fractional/NaN value aborts the
|
|
24
|
+
// whole INSERT chunk with 22P02, taking every other tool's rows in the batch
|
|
25
|
+
// down with it.
|
|
26
|
+
function toTokenCount(value) {
|
|
27
|
+
const n = Number(value);
|
|
28
|
+
if (!Number.isFinite(n) || n <= 0) return 0;
|
|
29
|
+
return Math.round(n);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function aggregateToBuckets(entries) {
|
|
33
|
+
const map = new Map();
|
|
34
|
+
|
|
35
|
+
for (const e of entries) {
|
|
36
|
+
const model = String(e.model || 'unknown').slice(0, MODEL_MAX_LENGTH);
|
|
37
|
+
const project = String(e.project || 'unknown').slice(0, PROJECT_MAX_LENGTH);
|
|
38
|
+
const bucketStart = roundToHalfHour(e.timestamp).toISOString();
|
|
39
|
+
const key = `${e.source}|${model}|${project}|${e.hostname || ''}|${bucketStart}`;
|
|
40
|
+
|
|
41
|
+
if (!map.has(key)) {
|
|
42
|
+
map.set(key, {
|
|
43
|
+
source: e.source,
|
|
44
|
+
model,
|
|
45
|
+
project,
|
|
46
|
+
// Cloud-sourced parsers (cursor) pre-set a fixed hostname sentinel; it
|
|
47
|
+
// must survive aggregation, or sync.js stamps the machine hostname and
|
|
48
|
+
// every machine gets its own duplicate row server-side.
|
|
49
|
+
...(e.hostname ? { hostname: e.hostname } : {}),
|
|
50
|
+
bucketStart,
|
|
51
|
+
inputTokens: 0,
|
|
52
|
+
outputTokens: 0,
|
|
53
|
+
cachedInputTokens: 0,
|
|
54
|
+
reasoningOutputTokens: 0,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const b = map.get(key);
|
|
59
|
+
b.inputTokens += e.inputTokens || 0;
|
|
60
|
+
b.outputTokens += e.outputTokens || 0;
|
|
61
|
+
b.cachedInputTokens += e.cachedInputTokens || 0;
|
|
62
|
+
b.reasoningOutputTokens += e.reasoningOutputTokens || 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Clamp after summation, not per entry — rounding each entry first would
|
|
66
|
+
// discard sub-integer values instead of letting them accumulate.
|
|
67
|
+
return Array.from(map.values()).map((b) => {
|
|
68
|
+
const inputTokens = toTokenCount(b.inputTokens);
|
|
69
|
+
const outputTokens = toTokenCount(b.outputTokens);
|
|
70
|
+
const cachedInputTokens = toTokenCount(b.cachedInputTokens);
|
|
71
|
+
const reasoningOutputTokens = toTokenCount(b.reasoningOutputTokens);
|
|
72
|
+
return {
|
|
73
|
+
...b,
|
|
74
|
+
inputTokens,
|
|
75
|
+
outputTokens,
|
|
76
|
+
cachedInputTokens,
|
|
77
|
+
reasoningOutputTokens,
|
|
78
|
+
totalTokens: inputTokens + outputTokens + reasoningOutputTokens,
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Extract session metadata from timing events.
|
|
85
|
+
* Each event: { sessionId, source, project, timestamp: Date, role: 'user'|'assistant' }
|
|
86
|
+
*
|
|
87
|
+
* Turn = first AI response → last AI response before next user prompt.
|
|
88
|
+
* activeSeconds = sum(generation durations), excluding queue/TTFT wait.
|
|
89
|
+
* durationSeconds = wall clock from first to last message.
|
|
90
|
+
*/
|
|
91
|
+
export function extractSessions(events) {
|
|
92
|
+
const groups = new Map();
|
|
93
|
+
for (const e of events) {
|
|
94
|
+
if (!groups.has(e.sessionId)) groups.set(e.sessionId, []);
|
|
95
|
+
groups.get(e.sessionId).push(e);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const sessions = [];
|
|
99
|
+
for (const [sessionId, sessionEvents] of groups) {
|
|
100
|
+
sessionEvents.sort((a, b) => a.timestamp - b.timestamp);
|
|
101
|
+
|
|
102
|
+
const first = sessionEvents[0];
|
|
103
|
+
const last = sessionEvents[sessionEvents.length - 1];
|
|
104
|
+
const durationSeconds = Math.round((last.timestamp - first.timestamp) / 1000);
|
|
105
|
+
|
|
106
|
+
let activeSeconds = 0;
|
|
107
|
+
let turnStart = null;
|
|
108
|
+
let turnEnd = null;
|
|
109
|
+
let waitingForFirstResponse = false;
|
|
110
|
+
|
|
111
|
+
for (const event of sessionEvents) {
|
|
112
|
+
if (event.role === 'user') {
|
|
113
|
+
if (turnStart !== null && turnEnd !== null && turnEnd > turnStart) {
|
|
114
|
+
activeSeconds += Math.round((turnEnd - turnStart) / 1000);
|
|
115
|
+
}
|
|
116
|
+
turnStart = null;
|
|
117
|
+
turnEnd = null;
|
|
118
|
+
waitingForFirstResponse = true;
|
|
119
|
+
} else if (waitingForFirstResponse) {
|
|
120
|
+
turnStart = event.timestamp;
|
|
121
|
+
turnEnd = event.timestamp;
|
|
122
|
+
waitingForFirstResponse = false;
|
|
123
|
+
} else if (turnStart !== null) {
|
|
124
|
+
turnEnd = event.timestamp;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (turnStart !== null && turnEnd !== null && turnEnd > turnStart) {
|
|
128
|
+
activeSeconds += Math.round((turnEnd - turnStart) / 1000);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const userPromptHours = new Array(24).fill(0);
|
|
132
|
+
let userMessageCount = 0;
|
|
133
|
+
for (const event of sessionEvents) {
|
|
134
|
+
if (event.role === 'user') {
|
|
135
|
+
userMessageCount++;
|
|
136
|
+
userPromptHours[event.timestamp.getUTCHours()]++;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const sessionHash = createHash('sha256').update(sessionId).digest('hex').slice(0, 16);
|
|
141
|
+
|
|
142
|
+
sessions.push({
|
|
143
|
+
source: first.source,
|
|
144
|
+
project: first.project || 'unknown',
|
|
145
|
+
sessionHash,
|
|
146
|
+
firstMessageAt: first.timestamp.toISOString(),
|
|
147
|
+
lastMessageAt: last.timestamp.toISOString(),
|
|
148
|
+
durationSeconds,
|
|
149
|
+
activeSeconds,
|
|
150
|
+
messageCount: sessionEvents.length,
|
|
151
|
+
userMessageCount,
|
|
152
|
+
userPromptHours,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return sessions;
|
|
157
|
+
}
|
package/src/parsers/alma.js
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { basename } from 'node:path';
|
|
3
3
|
import { getAlmaDbPath } from '../tools.js';
|
|
4
|
-
import { aggregateToBuckets } from './
|
|
5
|
-
import {
|
|
4
|
+
import { aggregateToBuckets } from './aggregate.js';
|
|
5
|
+
import { toCount } from './fs-utils.js';
|
|
6
|
+
import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
|
|
6
7
|
|
|
7
8
|
export { getAlmaDbPath as resolveAlmaDbPath };
|
|
8
9
|
|
|
10
|
+
export function normalizeAlmaModel(value) {
|
|
11
|
+
if (typeof value !== 'string') return 'unknown';
|
|
12
|
+
const model = value.trim();
|
|
13
|
+
if (!model) return 'unknown';
|
|
14
|
+
const separator = model.lastIndexOf(':');
|
|
15
|
+
if (separator === -1) return model;
|
|
16
|
+
return model.slice(separator + 1).trim() || 'unknown';
|
|
17
|
+
}
|
|
18
|
+
|
|
9
19
|
const ALMA_USAGE_SQL = `
|
|
10
20
|
SELECT
|
|
11
21
|
usage_records.model AS model,
|
|
@@ -21,11 +31,6 @@ const ALMA_USAGE_SQL = `
|
|
|
21
31
|
LEFT JOIN workspaces ON workspaces.id = chat_threads.workspace_id
|
|
22
32
|
`;
|
|
23
33
|
|
|
24
|
-
function tokenCount(value) {
|
|
25
|
-
const number = Number(value);
|
|
26
|
-
return Number.isFinite(number) && number > 0 ? number : 0;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
34
|
function safeWorkspaceName(value) {
|
|
30
35
|
if (typeof value !== 'string') return 'unknown';
|
|
31
36
|
const normalized = value.trim().replace(/\\/g, '/').replace(/\/+$/, '');
|
|
@@ -53,9 +58,7 @@ export async function parse() {
|
|
|
53
58
|
try {
|
|
54
59
|
rows = queryDbJson(dbPath, ALMA_USAGE_SQL);
|
|
55
60
|
} catch (error) {
|
|
56
|
-
if (error
|
|
57
|
-
throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync Alma data.');
|
|
58
|
-
}
|
|
61
|
+
if (isSqliteUnavailableError(error)) throw sqliteUnavailableError('Alma');
|
|
59
62
|
return skippedResult(error);
|
|
60
63
|
}
|
|
61
64
|
|
|
@@ -64,15 +67,15 @@ export async function parse() {
|
|
|
64
67
|
const timestamp = new Date(row.timestamp);
|
|
65
68
|
if (Number.isNaN(timestamp.getTime())) continue;
|
|
66
69
|
|
|
67
|
-
const inputTokens =
|
|
68
|
-
const outputTokens =
|
|
69
|
-
const cachedInputTokens =
|
|
70
|
-
const reasoningOutputTokens =
|
|
70
|
+
const inputTokens = toCount(row.inputTokens) + toCount(row.cacheWriteInputTokens);
|
|
71
|
+
const outputTokens = toCount(row.outputTokens);
|
|
72
|
+
const cachedInputTokens = toCount(row.cachedInputTokens);
|
|
73
|
+
const reasoningOutputTokens = toCount(row.reasoningOutputTokens);
|
|
71
74
|
if (inputTokens + outputTokens + cachedInputTokens + reasoningOutputTokens === 0) continue;
|
|
72
75
|
|
|
73
76
|
entries.push({
|
|
74
77
|
source: 'alma',
|
|
75
|
-
model: row.model
|
|
78
|
+
model: normalizeAlmaModel(row.model),
|
|
76
79
|
project: safeWorkspaceName(row.workspaceName),
|
|
77
80
|
timestamp,
|
|
78
81
|
inputTokens,
|
package/src/parsers/amp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { aggregateToBuckets, extractSessions } from './
|
|
4
|
+
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
5
5
|
|
|
6
6
|
function resolveThreadsDir() {
|
|
7
7
|
if (process.env.AMP_DATA_DIR) return process.env.AMP_DATA_DIR;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { tmpdir } from 'node:os';
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
3
2
|
import { join } from 'node:path';
|
|
4
|
-
import {
|
|
3
|
+
import { queryDbJsonSnapshotOnLock, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* Offline reader for Antigravity SQLite conversation stores.
|
|
@@ -149,38 +148,16 @@ export function parseGenMetadataBlob(buf) {
|
|
|
149
148
|
|
|
150
149
|
// ── SQLite store reading ──────────────────────────────────────────────
|
|
151
150
|
|
|
152
|
-
function isLockError(err) {
|
|
153
|
-
return err && typeof err.message === 'string' && /database is locked/i.test(err.message);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function isSqliteUnavailableError(err) {
|
|
157
|
-
return err && typeof err.message === 'string' && /ENOENT|sqlite3.*not found/i.test(err.message);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
151
|
function queryCascadeDb(conversationsDir, cascadeId, sql) {
|
|
161
152
|
const dbPath = join(conversationsDir, `${cascadeId}.db`);
|
|
162
153
|
try {
|
|
163
|
-
|
|
154
|
+
// The App can hold the live DB open; queryDbJsonSnapshotOnLock copies the
|
|
155
|
+
// WAL set to a temp dir and retries on "database is locked" so one active
|
|
156
|
+
// cascade does not make the whole Antigravity parser go empty.
|
|
157
|
+
return queryDbJsonSnapshotOnLock(dbPath, sql, { tempPrefix: 'vibe-usage-antigravity-' });
|
|
164
158
|
} catch (err) {
|
|
165
|
-
if (isSqliteUnavailableError(err))
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
if (!isLockError(err)) throw err;
|
|
169
|
-
|
|
170
|
-
// The App can hold the live DB open. Query a WAL-consistent snapshot so
|
|
171
|
-
// one active cascade does not make the whole Antigravity parser go empty.
|
|
172
|
-
const snapshotDir = mkdtempSync(join(tmpdir(), 'vibe-usage-antigravity-'));
|
|
173
|
-
const snapshotPath = join(snapshotDir, `${cascadeId}.db`);
|
|
174
|
-
try {
|
|
175
|
-
copyFileSync(dbPath, snapshotPath);
|
|
176
|
-
for (const suffix of ['-shm', '-wal']) {
|
|
177
|
-
const companion = `${dbPath}${suffix}`;
|
|
178
|
-
if (existsSync(companion)) copyFileSync(companion, `${snapshotPath}${suffix}`);
|
|
179
|
-
}
|
|
180
|
-
return queryDbJson(snapshotPath, sql);
|
|
181
|
-
} finally {
|
|
182
|
-
rmSync(snapshotDir, { recursive: true, force: true });
|
|
183
|
-
}
|
|
159
|
+
if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('Antigravity');
|
|
160
|
+
throw err;
|
|
184
161
|
}
|
|
185
162
|
}
|
|
186
163
|
|
|
@@ -2,7 +2,7 @@ import { execSync } from 'node:child_process';
|
|
|
2
2
|
import { readdirSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
-
import { aggregateToBuckets, extractSessions } from './
|
|
5
|
+
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
6
6
|
import { listDbCascades, readDbUsageRecords, readDbWorkspaceUri, readDbSessionEvents } from './antigravity-db.js';
|
|
7
7
|
|
|
8
8
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createReadStream, readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { createInterface } from 'node:readline';
|
|
3
3
|
import { join, basename, sep } from 'node:path';
|
|
4
|
-
import { aggregateToBuckets, extractSessions } from './
|
|
4
|
+
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
5
|
+
import { projectFromCwd, toCount } from './fs-utils.js';
|
|
5
6
|
import { getClaudeRoots } from '../claude-roots.js';
|
|
6
7
|
|
|
7
8
|
const MAX_WARNINGS = 20;
|
|
@@ -49,19 +50,6 @@ function projectFromRelative(relative) {
|
|
|
49
50
|
return parts.at(-1) || 'unknown';
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
/** Works for Unix and Windows cwd values regardless of the current OS. */
|
|
53
|
-
function projectFromCwd(cwd, fallback) {
|
|
54
|
-
if (typeof cwd !== 'string') return fallback;
|
|
55
|
-
const trimmed = cwd.trim().replace(/[\\/]+$/, '');
|
|
56
|
-
if (!trimmed) return fallback;
|
|
57
|
-
return trimmed.split(/[\\/]/).filter(Boolean).at(-1) || fallback;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function toCount(value) {
|
|
61
|
-
const n = Number(value);
|
|
62
|
-
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
53
|
function cacheCreationTokens(usage) {
|
|
66
54
|
const direct = toCount(usage.cache_creation_input_tokens);
|
|
67
55
|
const breakdown = usage.cache_creation || {};
|
|
@@ -207,7 +195,7 @@ async function scanProjectCandidate(candidate) {
|
|
|
207
195
|
if (usageScore === 0) return;
|
|
208
196
|
|
|
209
197
|
entries.push({
|
|
210
|
-
|
|
198
|
+
dedupeKey: usageDedupeKey(obj),
|
|
211
199
|
usageScore,
|
|
212
200
|
source: 'claude-code',
|
|
213
201
|
model,
|
|
@@ -251,22 +239,36 @@ async function scanBestCandidate(candidates, scanner, ctx) {
|
|
|
251
239
|
return null;
|
|
252
240
|
}
|
|
253
241
|
|
|
242
|
+
// One API call is written as several assistant lines - one per content block -
|
|
243
|
+
// that share `message.id`/`requestId` and repeat the same `usage` object, so a
|
|
244
|
+
// per-line key counts the same call once per block. Streaming also emits an
|
|
245
|
+
// early partial line (lower `output_tokens`) before the final one under that
|
|
246
|
+
// same id. Keying on the call identity collapses both, and the existing
|
|
247
|
+
// highest-usageScore wins rule then keeps the final, complete payload.
|
|
248
|
+
// Records without either id (older logs) fall back to the line uuid.
|
|
249
|
+
function usageDedupeKey(obj) {
|
|
250
|
+
const messageId = typeof obj.message?.id === 'string' ? obj.message.id.trim() : '';
|
|
251
|
+
const requestId = typeof obj.requestId === 'string' ? obj.requestId.trim() : '';
|
|
252
|
+
if (messageId || requestId) return `call:${messageId}\u0000${requestId}`;
|
|
253
|
+
return typeof obj.uuid === 'string' && obj.uuid ? obj.uuid : null;
|
|
254
|
+
}
|
|
255
|
+
|
|
254
256
|
function mergeUsageEntry(ctx, entry) {
|
|
255
|
-
if (!entry.
|
|
257
|
+
if (!entry.dedupeKey) {
|
|
256
258
|
ctx.anonymousEntries.push(entry);
|
|
257
259
|
return;
|
|
258
260
|
}
|
|
259
|
-
const current = ctx.
|
|
260
|
-
// Claude sometimes copies the same
|
|
261
|
+
const current = ctx.entriesByKey.get(entry.dedupeKey);
|
|
262
|
+
// Claude sometimes copies the same record into another session with zeroed
|
|
261
263
|
// usage. Keep the most complete payload, independent of directory order.
|
|
262
264
|
if (!current || entry.usageScore > current.usageScore) {
|
|
263
|
-
ctx.
|
|
265
|
+
ctx.entriesByKey.set(entry.dedupeKey, entry);
|
|
264
266
|
}
|
|
265
267
|
}
|
|
266
268
|
|
|
267
269
|
export async function parse() {
|
|
268
270
|
const ctx = {
|
|
269
|
-
|
|
271
|
+
entriesByKey: new Map(),
|
|
270
272
|
anonymousEntries: [],
|
|
271
273
|
sessionEvents: [],
|
|
272
274
|
warnings: [],
|
|
@@ -295,8 +297,8 @@ export async function parse() {
|
|
|
295
297
|
|
|
296
298
|
const entries = [
|
|
297
299
|
...ctx.anonymousEntries,
|
|
298
|
-
...ctx.
|
|
299
|
-
].map(({
|
|
300
|
+
...ctx.entriesByKey.values(),
|
|
301
|
+
].map(({ dedupeKey: _dedupeKey, usageScore: _usageScore, ...entry }) => entry);
|
|
300
302
|
|
|
301
303
|
return {
|
|
302
304
|
buckets: aggregateToBuckets(entries),
|
package/src/parsers/cline.js
CHANGED
|
@@ -1,19 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { aggregateToBuckets, extractSessions } from './
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
4
|
+
import { readJsonSafe, projectFromPath } from './fs-utils.js';
|
|
4
5
|
import { findClineDataDirs } from '../cline-roots.js';
|
|
5
6
|
|
|
6
|
-
function readJsonSafe(path) {
|
|
7
|
-
try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
function projectFromPath(absPath) {
|
|
11
|
-
if (!absPath || typeof absPath !== 'string') return 'unknown';
|
|
12
|
-
const trimmed = absPath.replace(/[\\/]+$/, '');
|
|
13
|
-
const name = basename(trimmed);
|
|
14
|
-
return name || 'unknown';
|
|
15
|
-
}
|
|
16
|
-
|
|
17
7
|
export async function parse() {
|
|
18
8
|
const extDirs = findClineDataDirs();
|
|
19
9
|
if (extDirs.length === 0) return { buckets: [], sessions: [] };
|
package/src/parsers/codex.js
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { createInterface } from 'node:readline';
|
|
12
12
|
import { createHash } from 'node:crypto';
|
|
13
|
-
import { aggregateToBuckets } from './
|
|
13
|
+
import { aggregateToBuckets } from './aggregate.js';
|
|
14
14
|
import {
|
|
15
15
|
codexSessionDirs,
|
|
16
16
|
resolveCodexHomes,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser result contract.
|
|
3
|
+
*
|
|
4
|
+
* Every parser exports an async parse() returning either
|
|
5
|
+
* { buckets: object[], sessions: object[], skipped?: boolean, warnings?: string[], indexing?: object }
|
|
6
|
+
* or a legacy bare buckets array.
|
|
7
|
+
*
|
|
8
|
+
* buckets entries are the aggregateToBuckets() output shape
|
|
9
|
+
* ({ source, model, project, hostname?, bucketStart, inputTokens, ... }).
|
|
10
|
+
* sessions entries are the extractSessions() output shape
|
|
11
|
+
* ({ source, project, sessionHash, firstMessageAt, ... }).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Normalize a raw parser return value into a validated shape. Every emitted
|
|
16
|
+
* item's source must match the registry key the parser is registered under;
|
|
17
|
+
* rejecting a typo keeps that parser out of state pruning and prevents a
|
|
18
|
+
* server-side dropped source from silently discarding its prior upload state.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} source registry key
|
|
21
|
+
* @param {unknown} result raw return value
|
|
22
|
+
* @returns {{ buckets: object[], sessions: object[], skipped: boolean, warnings: string[], indexing?: object }}
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeParserResult(source, result) {
|
|
25
|
+
const buckets = Array.isArray(result) ? result : result?.buckets;
|
|
26
|
+
const sessions = Array.isArray(result) ? [] : (result?.sessions || []);
|
|
27
|
+
if (!Array.isArray(buckets) || !Array.isArray(sessions)) {
|
|
28
|
+
throw new TypeError('Parser returned an invalid result');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const bucket of buckets) {
|
|
32
|
+
if (bucket?.source !== source) {
|
|
33
|
+
throw new TypeError(
|
|
34
|
+
'parser ' + source + ' emitted a bucket with source=' + JSON.stringify(bucket?.source),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
for (const session of sessions) {
|
|
39
|
+
if (session?.source !== source) {
|
|
40
|
+
throw new TypeError(
|
|
41
|
+
'parser ' + source + ' emitted a session with source=' + JSON.stringify(session?.source),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const warnings = Array.isArray(result?.warnings) ? result.warnings.slice() : [];
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
buckets,
|
|
50
|
+
sessions,
|
|
51
|
+
skipped: result?.skipped === true,
|
|
52
|
+
warnings,
|
|
53
|
+
...(result?.indexing ? { indexing: result.indexing } : {}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { basename, join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { aggregateToBuckets, extractSessions } from './
|
|
4
|
+
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
5
5
|
|
|
6
6
|
const SESSION_STATE_DIR = join(homedir(), '.copilot', 'session-state');
|
|
7
7
|
|