@geoqiao/pi-usage 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +222 -0
  3. package/bin/pi-usage.js +69 -0
  4. package/data/models.dev-LICENSE +21 -0
  5. package/data/prices.json +2678 -0
  6. package/extensions/usage-report.js +36 -0
  7. package/package.json +51 -0
  8. package/src/analytics.js +189 -0
  9. package/src/collect.js +34 -0
  10. package/src/network.js +25 -0
  11. package/src/report.js +43 -0
  12. package/vendor/vibe-usage/NOTICE.md +58 -0
  13. package/vendor/vibe-usage/src/cindy-roots.js +85 -0
  14. package/vendor/vibe-usage/src/claude-roots.js +165 -0
  15. package/vendor/vibe-usage/src/cline-roots.js +40 -0
  16. package/vendor/vibe-usage/src/codex-roots.js +46 -0
  17. package/vendor/vibe-usage/src/craft-roots.js +15 -0
  18. package/vendor/vibe-usage/src/extra-roots.js +312 -0
  19. package/vendor/vibe-usage/src/parsers/aggregate.js +196 -0
  20. package/vendor/vibe-usage/src/parsers/alma.js +94 -0
  21. package/vendor/vibe-usage/src/parsers/amp.js +156 -0
  22. package/vendor/vibe-usage/src/parsers/antigravity-db.js +359 -0
  23. package/vendor/vibe-usage/src/parsers/antigravity.js +530 -0
  24. package/vendor/vibe-usage/src/parsers/cindy-ledger.js +157 -0
  25. package/vendor/vibe-usage/src/parsers/claude-code.js +372 -0
  26. package/vendor/vibe-usage/src/parsers/cline.js +92 -0
  27. package/vendor/vibe-usage/src/parsers/codex-cache.js +138 -0
  28. package/vendor/vibe-usage/src/parsers/codex.js +1198 -0
  29. package/vendor/vibe-usage/src/parsers/contract.js +55 -0
  30. package/vendor/vibe-usage/src/parsers/copilot-cli.js +128 -0
  31. package/vendor/vibe-usage/src/parsers/craft-agent.js +21 -0
  32. package/vendor/vibe-usage/src/parsers/cursor.js +262 -0
  33. package/vendor/vibe-usage/src/parsers/dimagent.js +127 -0
  34. package/vendor/vibe-usage/src/parsers/droid.js +113 -0
  35. package/vendor/vibe-usage/src/parsers/dsh.js +563 -0
  36. package/vendor/vibe-usage/src/parsers/fs-utils.js +36 -0
  37. package/vendor/vibe-usage/src/parsers/gemini-cli.js +190 -0
  38. package/vendor/vibe-usage/src/parsers/grok.js +395 -0
  39. package/vendor/vibe-usage/src/parsers/hermes.js +123 -0
  40. package/vendor/vibe-usage/src/parsers/index.js +61 -0
  41. package/vendor/vibe-usage/src/parsers/kimi-code.js +467 -0
  42. package/vendor/vibe-usage/src/parsers/kiro.js +788 -0
  43. package/vendor/vibe-usage/src/parsers/mcode.js +182 -0
  44. package/vendor/vibe-usage/src/parsers/mimocode.js +88 -0
  45. package/vendor/vibe-usage/src/parsers/omp.js +10 -0
  46. package/vendor/vibe-usage/src/parsers/openclaw.js +142 -0
  47. package/vendor/vibe-usage/src/parsers/opencode.js +151 -0
  48. package/vendor/vibe-usage/src/parsers/pi-coding-agent.js +27 -0
  49. package/vendor/vibe-usage/src/parsers/pi-session-jsonl.js +166 -0
  50. package/vendor/vibe-usage/src/parsers/qwen-code.js +122 -0
  51. package/vendor/vibe-usage/src/parsers/roo-code.js +123 -0
  52. package/vendor/vibe-usage/src/parsers/sqlite.js +148 -0
  53. package/vendor/vibe-usage/src/parsers/trae-cli.js +171 -0
  54. package/vendor/vibe-usage/src/parsers/workbuddy.js +322 -0
  55. package/vendor/vibe-usage/src/parsers/zcode.js +115 -0
  56. package/vendor/vibe-usage/src/pi-roots.js +125 -0
  57. package/vendor/vibe-usage/src/tools.js +422 -0
  58. package/vendor/vibe-usage/src/workbuddy-roots.js +22 -0
  59. package/vendor/vibe-usage/upstream-files.json +48 -0
  60. package/web/report.css +10 -0
  61. package/web/report.html +81 -0
  62. package/web/report.js +310 -0
@@ -0,0 +1,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
+ }