@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.
- package/LICENSE +22 -0
- package/README.md +222 -0
- package/bin/pi-usage.js +69 -0
- package/data/models.dev-LICENSE +21 -0
- package/data/prices.json +2678 -0
- package/extensions/usage-report.js +36 -0
- package/package.json +51 -0
- package/src/analytics.js +189 -0
- package/src/collect.js +34 -0
- package/src/network.js +25 -0
- package/src/report.js +43 -0
- package/vendor/vibe-usage/NOTICE.md +58 -0
- package/vendor/vibe-usage/src/cindy-roots.js +85 -0
- package/vendor/vibe-usage/src/claude-roots.js +165 -0
- package/vendor/vibe-usage/src/cline-roots.js +40 -0
- package/vendor/vibe-usage/src/codex-roots.js +46 -0
- package/vendor/vibe-usage/src/craft-roots.js +15 -0
- package/vendor/vibe-usage/src/extra-roots.js +312 -0
- package/vendor/vibe-usage/src/parsers/aggregate.js +196 -0
- package/vendor/vibe-usage/src/parsers/alma.js +94 -0
- package/vendor/vibe-usage/src/parsers/amp.js +156 -0
- package/vendor/vibe-usage/src/parsers/antigravity-db.js +359 -0
- package/vendor/vibe-usage/src/parsers/antigravity.js +530 -0
- package/vendor/vibe-usage/src/parsers/cindy-ledger.js +157 -0
- package/vendor/vibe-usage/src/parsers/claude-code.js +372 -0
- package/vendor/vibe-usage/src/parsers/cline.js +92 -0
- package/vendor/vibe-usage/src/parsers/codex-cache.js +138 -0
- package/vendor/vibe-usage/src/parsers/codex.js +1198 -0
- package/vendor/vibe-usage/src/parsers/contract.js +55 -0
- package/vendor/vibe-usage/src/parsers/copilot-cli.js +128 -0
- package/vendor/vibe-usage/src/parsers/craft-agent.js +21 -0
- package/vendor/vibe-usage/src/parsers/cursor.js +262 -0
- package/vendor/vibe-usage/src/parsers/dimagent.js +127 -0
- package/vendor/vibe-usage/src/parsers/droid.js +113 -0
- package/vendor/vibe-usage/src/parsers/dsh.js +563 -0
- package/vendor/vibe-usage/src/parsers/fs-utils.js +36 -0
- package/vendor/vibe-usage/src/parsers/gemini-cli.js +190 -0
- package/vendor/vibe-usage/src/parsers/grok.js +395 -0
- package/vendor/vibe-usage/src/parsers/hermes.js +123 -0
- package/vendor/vibe-usage/src/parsers/index.js +61 -0
- package/vendor/vibe-usage/src/parsers/kimi-code.js +467 -0
- package/vendor/vibe-usage/src/parsers/kiro.js +788 -0
- package/vendor/vibe-usage/src/parsers/mcode.js +182 -0
- package/vendor/vibe-usage/src/parsers/mimocode.js +88 -0
- package/vendor/vibe-usage/src/parsers/omp.js +10 -0
- package/vendor/vibe-usage/src/parsers/openclaw.js +142 -0
- package/vendor/vibe-usage/src/parsers/opencode.js +151 -0
- package/vendor/vibe-usage/src/parsers/pi-coding-agent.js +27 -0
- package/vendor/vibe-usage/src/parsers/pi-session-jsonl.js +166 -0
- package/vendor/vibe-usage/src/parsers/qwen-code.js +122 -0
- package/vendor/vibe-usage/src/parsers/roo-code.js +123 -0
- package/vendor/vibe-usage/src/parsers/sqlite.js +148 -0
- package/vendor/vibe-usage/src/parsers/trae-cli.js +171 -0
- package/vendor/vibe-usage/src/parsers/workbuddy.js +322 -0
- package/vendor/vibe-usage/src/parsers/zcode.js +115 -0
- package/vendor/vibe-usage/src/pi-roots.js +125 -0
- package/vendor/vibe-usage/src/tools.js +422 -0
- package/vendor/vibe-usage/src/workbuddy-roots.js +22 -0
- package/vendor/vibe-usage/upstream-files.json +48 -0
- package/web/report.css +10 -0
- package/web/report.html +81 -0
- package/web/report.js +310 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { basename, join, relative } from 'node:path';
|
|
3
|
+
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
4
|
+
import { projectFromCwd, toCount } from './fs-utils.js';
|
|
5
|
+
import { requestTypeFor, mergeRequestTypes } from '../../../../src/analytics.js';
|
|
6
|
+
|
|
7
|
+
const MAX_WARNINGS = 20;
|
|
8
|
+
|
|
9
|
+
function warn(ctx, message) {
|
|
10
|
+
ctx.incomplete = true;
|
|
11
|
+
if (ctx.warnings.length < MAX_WARNINGS) ctx.warnings.push(message);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function findJsonlFiles(dir, includeFile, ctx) {
|
|
15
|
+
if (!existsSync(dir)) return [];
|
|
16
|
+
let children;
|
|
17
|
+
try {
|
|
18
|
+
children = readdirSync(dir, { withFileTypes: true });
|
|
19
|
+
} catch (err) {
|
|
20
|
+
warn(ctx, `${ctx.source}: cannot read directory ${dir}: ${err.message}`);
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const files = [];
|
|
25
|
+
for (const child of children) {
|
|
26
|
+
const filePath = join(dir, child.name);
|
|
27
|
+
if (child.isDirectory()) {
|
|
28
|
+
for (const nested of findJsonlFiles(filePath, includeFile, ctx)) files.push(nested);
|
|
29
|
+
} else if (child.name.endsWith('.jsonl') && includeFile(filePath)) {
|
|
30
|
+
files.push(filePath);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return files;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function projectFromFirstDir(filePath, sessionsDir) {
|
|
37
|
+
const first = relative(sessionsDir, filePath).split(/[\\/]/)[0];
|
|
38
|
+
if (!first) return 'unknown';
|
|
39
|
+
return first.split('-').filter(Boolean).at(-1) || 'unknown';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Configured stores can overlap: an ancestor and its descendant, or two paths
|
|
43
|
+
// that resolve to the same place through a symlink. Record-level dedup only
|
|
44
|
+
// covers entries carrying an `id`, so the same anonymous record would be
|
|
45
|
+
// counted once per path that reaches it. Collapse on the canonical file path
|
|
46
|
+
// instead, which also folds symlinked duplicates of a single file.
|
|
47
|
+
function canonicalFilePath(filePath) {
|
|
48
|
+
try {
|
|
49
|
+
return realpathSync.native(filePath);
|
|
50
|
+
} catch {
|
|
51
|
+
return filePath;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function parsePiSessionJsonl({
|
|
56
|
+
source,
|
|
57
|
+
sessionsDirs,
|
|
58
|
+
includeFile = () => true,
|
|
59
|
+
projectFromPath = projectFromFirstDir,
|
|
60
|
+
}) {
|
|
61
|
+
const ctx = { source, warnings: [], incomplete: false };
|
|
62
|
+
const entriesById = new Map();
|
|
63
|
+
const anonymousEntries = [];
|
|
64
|
+
const eventsById = new Map();
|
|
65
|
+
const anonymousEvents = [];
|
|
66
|
+
const seenFiles = new Set();
|
|
67
|
+
|
|
68
|
+
for (const sessionsDir of sessionsDirs) {
|
|
69
|
+
for (const filePath of findJsonlFiles(sessionsDir, includeFile, ctx)) {
|
|
70
|
+
const canonical = canonicalFilePath(filePath);
|
|
71
|
+
if (seenFiles.has(canonical)) continue;
|
|
72
|
+
seenFiles.add(canonical);
|
|
73
|
+
|
|
74
|
+
let content;
|
|
75
|
+
try {
|
|
76
|
+
content = readFileSync(filePath, 'utf8');
|
|
77
|
+
} catch (err) {
|
|
78
|
+
warn(ctx, `${source}: cannot read ${filePath}: ${err.message}`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let sessionId = basename(filePath, '.jsonl');
|
|
83
|
+
let project = projectFromPath(filePath, sessionsDir) || 'unknown';
|
|
84
|
+
|
|
85
|
+
for (const line of content.split('\n')) {
|
|
86
|
+
if (!line.trim()) continue;
|
|
87
|
+
let obj;
|
|
88
|
+
try {
|
|
89
|
+
obj = JSON.parse(line);
|
|
90
|
+
} catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (obj.type === 'session') {
|
|
95
|
+
if (obj.id) sessionId = String(obj.id);
|
|
96
|
+
if (obj.cwd) project = projectFromCwd(obj.cwd);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (obj.type !== 'message' || !obj.message) continue;
|
|
100
|
+
|
|
101
|
+
const message = obj.message;
|
|
102
|
+
const timestamp = new Date(obj.timestamp || message.timestamp || 0);
|
|
103
|
+
if (Number.isNaN(timestamp.getTime())) continue;
|
|
104
|
+
const recordId = obj.id ? `${sessionId}:${obj.id}` : null;
|
|
105
|
+
|
|
106
|
+
if (message.role === 'user' || message.role === 'assistant' || message.role === 'toolResult') {
|
|
107
|
+
const event = {
|
|
108
|
+
sessionId,
|
|
109
|
+
source,
|
|
110
|
+
project,
|
|
111
|
+
timestamp,
|
|
112
|
+
role: message.role === 'user' ? 'user' : 'assistant',
|
|
113
|
+
};
|
|
114
|
+
if (recordId) eventsById.set(recordId, event);
|
|
115
|
+
else anonymousEvents.push(event);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (message.role !== 'assistant' || !message.usage) continue;
|
|
119
|
+
const usage = message.usage;
|
|
120
|
+
const inputTokens = toCount(usage.input) + toCount(usage.cacheWrite);
|
|
121
|
+
// Pi's Usage type names this field `reasoning` (a documented subset of
|
|
122
|
+
// `output`); older/adjacent stores wrote `reasoningTokens`. Reading only
|
|
123
|
+
// the latter left every Pi reasoning token inside outputTokens.
|
|
124
|
+
const reasoningOutputTokens = toCount(usage.reasoning ?? usage.reasoningTokens);
|
|
125
|
+
// OMP/Pi usage.output includes reasoning; the shared bucket contract
|
|
126
|
+
// stores non-reasoning output and reasoning separately.
|
|
127
|
+
const outputTokens = Math.max(0, toCount(usage.output) - reasoningOutputTokens);
|
|
128
|
+
const cachedInputTokens = toCount(usage.cacheRead);
|
|
129
|
+
const score = inputTokens + outputTokens + cachedInputTokens + reasoningOutputTokens;
|
|
130
|
+
if (score === 0) continue;
|
|
131
|
+
|
|
132
|
+
const entry = {
|
|
133
|
+
source,
|
|
134
|
+
requestType: requestTypeFor(message),
|
|
135
|
+
model: message.model || message.modelId || obj.model || obj.modelId || 'unknown',
|
|
136
|
+
project,
|
|
137
|
+
timestamp,
|
|
138
|
+
inputTokens,
|
|
139
|
+
outputTokens,
|
|
140
|
+
cachedInputTokens,
|
|
141
|
+
reasoningOutputTokens,
|
|
142
|
+
};
|
|
143
|
+
if (!recordId) {
|
|
144
|
+
anonymousEntries.push(entry);
|
|
145
|
+
} else {
|
|
146
|
+
const current = entriesById.get(recordId);
|
|
147
|
+
const requestType = mergeRequestTypes(current?.entry.requestType, entry.requestType);
|
|
148
|
+
if (!current || score > current.score) entriesById.set(recordId, { score, entry: { ...entry, requestType } });
|
|
149
|
+
else current.entry.requestType = requestType;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const entries = [
|
|
156
|
+
...anonymousEntries,
|
|
157
|
+
...[...entriesById.values()].map(({ entry }) => entry),
|
|
158
|
+
];
|
|
159
|
+
const events = [...anonymousEvents, ...eventsById.values()];
|
|
160
|
+
return {
|
|
161
|
+
buckets: aggregateToBuckets(entries),
|
|
162
|
+
sessions: extractSessions(events),
|
|
163
|
+
...(ctx.incomplete ? { skipped: true } : {}),
|
|
164
|
+
...(ctx.warnings.length > 0 ? { warnings: ctx.warnings } : {}),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, basename, sep } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Qwen Code parser (Gemini CLI fork).
|
|
8
|
+
* JSONL at ~/.qwen/tmp/<project_id>/chats/<sessionId>.jsonl
|
|
9
|
+
* Token fields: usageMetadata.{promptTokenCount, candidatesTokenCount,
|
|
10
|
+
* cachedContentTokenCount, thoughtsTokenCount}
|
|
11
|
+
* Note: promptTokenCount INCLUDES cachedContentTokenCount (needs normalization).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const QWEN_TMP_DIR = join(homedir(), '.qwen', 'tmp');
|
|
15
|
+
|
|
16
|
+
function findSessionFiles(baseDir) {
|
|
17
|
+
const results = [];
|
|
18
|
+
if (!existsSync(baseDir)) return results;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
|
|
22
|
+
if (!entry.isDirectory()) continue;
|
|
23
|
+
const chatsDir = join(baseDir, entry.name, 'chats');
|
|
24
|
+
if (!existsSync(chatsDir)) continue;
|
|
25
|
+
try {
|
|
26
|
+
for (const f of readdirSync(chatsDir)) {
|
|
27
|
+
if (f.endsWith('.jsonl')) {
|
|
28
|
+
results.push(join(chatsDir, f));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
return results;
|
|
37
|
+
}
|
|
38
|
+
return results;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function extractProject(cwd, filePath) {
|
|
42
|
+
if (cwd) {
|
|
43
|
+
const parts = cwd.split('/').filter(Boolean);
|
|
44
|
+
if (parts.length > 0) return parts[parts.length - 1];
|
|
45
|
+
}
|
|
46
|
+
const tmpPrefix = QWEN_TMP_DIR + sep;
|
|
47
|
+
if (filePath.startsWith(tmpPrefix)) {
|
|
48
|
+
const relative = filePath.slice(tmpPrefix.length);
|
|
49
|
+
const projectId = relative.split(sep)[0];
|
|
50
|
+
if (projectId) return projectId;
|
|
51
|
+
}
|
|
52
|
+
return 'unknown';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function parse() {
|
|
56
|
+
const sessionFiles = findSessionFiles(QWEN_TMP_DIR);
|
|
57
|
+
if (sessionFiles.length === 0) return { buckets: [], sessions: [] };
|
|
58
|
+
|
|
59
|
+
const entries = [];
|
|
60
|
+
const sessionEvents = [];
|
|
61
|
+
const seenUuids = new Set();
|
|
62
|
+
|
|
63
|
+
for (const filePath of sessionFiles) {
|
|
64
|
+
let content;
|
|
65
|
+
try {
|
|
66
|
+
content = readFileSync(filePath, 'utf-8');
|
|
67
|
+
} catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const line of content.split('\n')) {
|
|
72
|
+
if (!line.trim()) continue;
|
|
73
|
+
try {
|
|
74
|
+
const obj = JSON.parse(line);
|
|
75
|
+
|
|
76
|
+
const timestamp = obj.timestamp;
|
|
77
|
+
if (!timestamp) continue;
|
|
78
|
+
const ts = new Date(timestamp);
|
|
79
|
+
if (isNaN(ts.getTime())) continue;
|
|
80
|
+
|
|
81
|
+
if (obj.type === 'user' || obj.type === 'assistant') {
|
|
82
|
+
sessionEvents.push({
|
|
83
|
+
sessionId: filePath,
|
|
84
|
+
source: 'qwen-code',
|
|
85
|
+
project: extractProject(obj.cwd, filePath),
|
|
86
|
+
timestamp: ts,
|
|
87
|
+
role: obj.type === 'user' ? 'user' : 'assistant',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (obj.type !== 'assistant') continue;
|
|
92
|
+
const usage = obj.usageMetadata;
|
|
93
|
+
if (!usage) continue;
|
|
94
|
+
if (usage.promptTokenCount == null && usage.candidatesTokenCount == null) continue;
|
|
95
|
+
|
|
96
|
+
const uuid = obj.uuid;
|
|
97
|
+
if (uuid) {
|
|
98
|
+
if (seenUuids.has(uuid)) continue;
|
|
99
|
+
seenUuids.add(uuid);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const cached = usage.cachedContentTokenCount || 0;
|
|
103
|
+
const thoughts = usage.thoughtsTokenCount || 0;
|
|
104
|
+
|
|
105
|
+
entries.push({
|
|
106
|
+
source: 'qwen-code',
|
|
107
|
+
model: obj.model || 'unknown',
|
|
108
|
+
project: extractProject(obj.cwd, filePath),
|
|
109
|
+
timestamp: ts,
|
|
110
|
+
inputTokens: (usage.promptTokenCount || 0) - cached,
|
|
111
|
+
outputTokens: (usage.candidatesTokenCount || 0) - thoughts,
|
|
112
|
+
cachedInputTokens: cached,
|
|
113
|
+
reasoningOutputTokens: thoughts,
|
|
114
|
+
});
|
|
115
|
+
} catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
|
|
122
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
5
|
+
import { readJsonSafe, projectFromPath } from './fs-utils.js';
|
|
6
|
+
|
|
7
|
+
const EXTENSION_ID = 'rooveterinaryinc.roo-cline';
|
|
8
|
+
|
|
9
|
+
const HOSTS = ['Code', 'Cursor', 'Windsurf', 'VSCodium', 'Code - Insiders', 'Trae', 'Trae CN'];
|
|
10
|
+
|
|
11
|
+
function getHostRoots() {
|
|
12
|
+
const out = [];
|
|
13
|
+
if (process.platform === 'darwin') {
|
|
14
|
+
const base = join(homedir(), 'Library', 'Application Support');
|
|
15
|
+
for (const h of HOSTS) out.push(join(base, h));
|
|
16
|
+
} else if (process.platform === 'win32') {
|
|
17
|
+
const appData = process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming');
|
|
18
|
+
for (const h of HOSTS) out.push(join(appData, h));
|
|
19
|
+
} else {
|
|
20
|
+
const xdg = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config');
|
|
21
|
+
for (const h of HOSTS) out.push(join(xdg, h));
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function findRooCodeExtensionDirs() {
|
|
27
|
+
const dirs = [];
|
|
28
|
+
for (const root of getHostRoots()) {
|
|
29
|
+
const ext = join(root, 'User', 'globalStorage', EXTENSION_ID);
|
|
30
|
+
try {
|
|
31
|
+
if (statSync(ext).isDirectory()) dirs.push(ext);
|
|
32
|
+
} catch {
|
|
33
|
+
// not installed in this host; skip
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return dirs;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Read all HistoryItems from `_index.json` if present, else fall back to
|
|
40
|
+
// scanning per-task `history_item.json` files (Roo migrated to per-task
|
|
41
|
+
// files in 2025; the index is a cache).
|
|
42
|
+
function readHistoryItems(extDir) {
|
|
43
|
+
const tasksDir = join(extDir, 'tasks');
|
|
44
|
+
const indexPath = join(tasksDir, '_index.json');
|
|
45
|
+
const index = readJsonSafe(indexPath);
|
|
46
|
+
if (index && Array.isArray(index.entries)) return index.entries;
|
|
47
|
+
|
|
48
|
+
const items = [];
|
|
49
|
+
let names;
|
|
50
|
+
try { names = readdirSync(tasksDir, { withFileTypes: true }); } catch { return items; }
|
|
51
|
+
for (const entry of names) {
|
|
52
|
+
if (!entry.isDirectory() || entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
|
|
53
|
+
const item = readJsonSafe(join(tasksDir, entry.name, 'history_item.json'));
|
|
54
|
+
if (item && typeof item === 'object') items.push(item);
|
|
55
|
+
}
|
|
56
|
+
return items;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function parse() {
|
|
60
|
+
const extDirs = findRooCodeExtensionDirs();
|
|
61
|
+
if (extDirs.length === 0) return { buckets: [], sessions: [] };
|
|
62
|
+
|
|
63
|
+
const entries = [];
|
|
64
|
+
const events = [];
|
|
65
|
+
|
|
66
|
+
for (const extDir of extDirs) {
|
|
67
|
+
const items = readHistoryItems(extDir);
|
|
68
|
+
if (!items.length) continue;
|
|
69
|
+
|
|
70
|
+
for (const item of items) {
|
|
71
|
+
try {
|
|
72
|
+
if (!item || typeof item !== 'object' || !item.id) continue;
|
|
73
|
+
const taskId = String(item.id);
|
|
74
|
+
const project = projectFromPath(item.workspace);
|
|
75
|
+
// Roo doesn't store modelId; the profile name (apiConfigName) is the
|
|
76
|
+
// best fallback — users typically name profiles after the model.
|
|
77
|
+
const fallbackModel = (item.apiConfigName && String(item.apiConfigName).trim()) || 'roo-unknown';
|
|
78
|
+
|
|
79
|
+
const messages = readJsonSafe(join(extDir, 'tasks', taskId, 'ui_messages.json'));
|
|
80
|
+
if (!Array.isArray(messages)) continue;
|
|
81
|
+
|
|
82
|
+
for (const msg of messages) {
|
|
83
|
+
if (!msg || typeof msg !== 'object') continue;
|
|
84
|
+
const ts = Number(msg.ts);
|
|
85
|
+
if (!Number.isFinite(ts)) continue;
|
|
86
|
+
const timestamp = new Date(ts);
|
|
87
|
+
|
|
88
|
+
if (msg.type === 'say' && msg.say === 'api_req_started') {
|
|
89
|
+
let info = null;
|
|
90
|
+
try { info = JSON.parse(msg.text); } catch { /* skip */ }
|
|
91
|
+
if (!info) continue;
|
|
92
|
+
|
|
93
|
+
const inputTokens = Math.max(0, Number(info.tokensIn) || 0);
|
|
94
|
+
const outputTokens = Math.max(0, Number(info.tokensOut) || 0);
|
|
95
|
+
const cacheWrites = Math.max(0, Number(info.cacheWrites) || 0);
|
|
96
|
+
const cacheReads = Math.max(0, Number(info.cacheReads) || 0);
|
|
97
|
+
if (inputTokens + outputTokens + cacheWrites + cacheReads === 0) continue;
|
|
98
|
+
|
|
99
|
+
const model = (info.model && String(info.model).trim()) || fallbackModel;
|
|
100
|
+
|
|
101
|
+
entries.push({
|
|
102
|
+
source: 'roo-code',
|
|
103
|
+
model,
|
|
104
|
+
project,
|
|
105
|
+
timestamp,
|
|
106
|
+
inputTokens: inputTokens + cacheWrites,
|
|
107
|
+
outputTokens,
|
|
108
|
+
cachedInputTokens: cacheReads,
|
|
109
|
+
reasoningOutputTokens: 0,
|
|
110
|
+
});
|
|
111
|
+
events.push({ sessionId: taskId, source: 'roo-code', project, timestamp, role: 'assistant' });
|
|
112
|
+
} else if (msg.type === 'ask' || (msg.type === 'say' && msg.say === 'user_feedback')) {
|
|
113
|
+
events.push({ sessionId: taskId, source: 'roo-code', project, timestamp, role: 'user' });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// Skip this task; keep going for the rest of the history.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(events) };
|
|
123
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { basename, join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Run a SQL query against a SQLite database and return rows as plain objects
|
|
11
|
+
* (column name → value), mirroring the shape of `sqlite3 -json` output.
|
|
12
|
+
*
|
|
13
|
+
* Prefers Node's built-in `node:sqlite` (available on Node >= 22.5, no external
|
|
14
|
+
* binary needed — important on Windows where the `sqlite3` CLI is rarely on
|
|
15
|
+
* PATH). Falls back to shelling out to the `sqlite3` CLI on older Node.
|
|
16
|
+
*
|
|
17
|
+
* If neither is available, throws an Error whose message contains "ENOENT" so
|
|
18
|
+
* callers can surface an "Install sqlite3" hint, matching the previous behavior.
|
|
19
|
+
*/
|
|
20
|
+
export function queryDbJson(
|
|
21
|
+
dbPath,
|
|
22
|
+
sql,
|
|
23
|
+
{ timeout = 30000, maxBuffer = 100 * 1024 * 1024, readOnly = true } = {},
|
|
24
|
+
) {
|
|
25
|
+
const db = openNodeSqlite(dbPath, readOnly);
|
|
26
|
+
if (db) {
|
|
27
|
+
try {
|
|
28
|
+
return db.prepare(sql).all();
|
|
29
|
+
} finally {
|
|
30
|
+
db.close();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return queryViaCli(dbPath, sql, { timeout, maxBuffer });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let nodeSqlite; // undefined = not tried, null = unavailable
|
|
37
|
+
|
|
38
|
+
function getNodeSqlite() {
|
|
39
|
+
if (nodeSqlite !== undefined) return nodeSqlite;
|
|
40
|
+
try {
|
|
41
|
+
// Suppress the one-time "SQLite is an experimental feature" ExperimentalWarning
|
|
42
|
+
// on Node versions where node:sqlite is still flagged experimental.
|
|
43
|
+
const prevEmit = process.emitWarning;
|
|
44
|
+
process.emitWarning = (warning, ...rest) => {
|
|
45
|
+
const opts = rest[0];
|
|
46
|
+
const type = typeof opts === 'object' && opts ? opts.type : opts;
|
|
47
|
+
const name = typeof warning === 'object' && warning ? warning.name : undefined;
|
|
48
|
+
if ((type === 'ExperimentalWarning' || name === 'ExperimentalWarning') && String(warning).includes('SQLite')) return;
|
|
49
|
+
return prevEmit.call(process, warning, ...rest);
|
|
50
|
+
};
|
|
51
|
+
try {
|
|
52
|
+
nodeSqlite = require('node:sqlite');
|
|
53
|
+
} finally {
|
|
54
|
+
process.emitWarning = prevEmit;
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
nodeSqlite = null;
|
|
58
|
+
}
|
|
59
|
+
return nodeSqlite;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function openNodeSqlite(dbPath, readOnly = true) {
|
|
63
|
+
const mod = getNodeSqlite();
|
|
64
|
+
if (!mod || !mod.DatabaseSync) return null;
|
|
65
|
+
let db;
|
|
66
|
+
try {
|
|
67
|
+
db = new mod.DatabaseSync(dbPath, { readOnly });
|
|
68
|
+
// Writable access is used only for disposable snapshots whose WAL metadata
|
|
69
|
+
// may need initialization. Keep the SQL connection itself read-only.
|
|
70
|
+
if (!readOnly) db.exec('PRAGMA query_only = ON');
|
|
71
|
+
return db;
|
|
72
|
+
} catch {
|
|
73
|
+
try {
|
|
74
|
+
db?.close();
|
|
75
|
+
} catch {
|
|
76
|
+
// Ignore cleanup failure while falling back to the sqlite3 CLI.
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function queryViaCli(dbPath, sql, { timeout, maxBuffer }) {
|
|
83
|
+
const out = execFileSync('sqlite3', ['-json', dbPath, sql], {
|
|
84
|
+
encoding: 'utf-8',
|
|
85
|
+
maxBuffer,
|
|
86
|
+
timeout,
|
|
87
|
+
});
|
|
88
|
+
const trimmed = out.trim();
|
|
89
|
+
if (!trimmed || trimmed === '[]') return [];
|
|
90
|
+
return JSON.parse(trimmed);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Standard "sqlite3 unavailable" hint, reused by every SQLite-backed parser. */
|
|
94
|
+
export function sqliteUnavailableError(label) {
|
|
95
|
+
return new Error(`sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync ${label} data.`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** True when the error is the "no sqlite3" hint (node:sqlite absent + CLI absent). */
|
|
99
|
+
export function isSqliteUnavailableError(err) {
|
|
100
|
+
return !!err && (
|
|
101
|
+
err.code === 'ENOENT'
|
|
102
|
+
|| err.status === 127
|
|
103
|
+
|| /ENOENT|sqlite3.*not found/i.test(err?.message || '')
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function isLockError(err) {
|
|
108
|
+
return !!err && typeof err.message === 'string' && /database is locked/i.test(err.message);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function querySnapshot(dbPath, sql, { tempPrefix, opts } = {}) {
|
|
112
|
+
const snapshotDir = mkdtempSync(join(tmpdir(), tempPrefix || 'vibe-usage-sqlite-'));
|
|
113
|
+
const queryPath = join(snapshotDir, basename(dbPath));
|
|
114
|
+
try {
|
|
115
|
+
copyFileSync(dbPath, queryPath);
|
|
116
|
+
for (const suffix of ['-shm', '-wal']) {
|
|
117
|
+
const companion = `${dbPath}${suffix}`;
|
|
118
|
+
if (existsSync(companion)) copyFileSync(companion, `${queryPath}${suffix}`);
|
|
119
|
+
}
|
|
120
|
+
return queryDbJson(queryPath, sql, { ...opts, readOnly: false });
|
|
121
|
+
} finally {
|
|
122
|
+
rmSync(snapshotDir, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Query a disposable writable snapshot. Some WAL-mode databases cannot be
|
|
128
|
+
* opened read-only until SQLite has initialized their shared-memory metadata;
|
|
129
|
+
* doing that only in the temp copy keeps the source application database
|
|
130
|
+
* untouched and works without a sqlite3 binary on Node >= 22.5.
|
|
131
|
+
*/
|
|
132
|
+
export function queryDbJsonSnapshot(dbPath, sql, options = {}) {
|
|
133
|
+
return querySnapshot(dbPath, sql, options);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Run a query, and if the source app holds a write lock on the database, copy
|
|
138
|
+
* the DB (plus its -wal/-shm companions) to a temp dir and re-query the
|
|
139
|
+
* snapshot. Shared by Cursor / Antigravity / Kiro.
|
|
140
|
+
*/
|
|
141
|
+
export function queryDbJsonSnapshotOnLock(dbPath, sql, { tempPrefix = 'vibe-usage-sqlite', opts } = {}) {
|
|
142
|
+
try {
|
|
143
|
+
return queryDbJson(dbPath, sql, opts);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (!isLockError(err)) throw err;
|
|
146
|
+
return querySnapshot(dbPath, sql, { tempPrefix, opts });
|
|
147
|
+
}
|
|
148
|
+
}
|