@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,182 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { projectFromPath } from './fs-utils.js';
3
+ import { aggregateToBuckets } from './aggregate.js';
4
+ import {
5
+ queryDbJsonSnapshotOnLock,
6
+ isSqliteUnavailableError,
7
+ sqliteUnavailableError,
8
+ } from './sqlite.js';
9
+ import { getMcodeDbPath } from '../tools.js';
10
+
11
+ const SOURCE = 'mcode';
12
+
13
+ // Strict column allow-list. The mcode token table also stores a `raw` JSON
14
+ // payload (and the sessions table stores `record_json` / `extra_data_json`)
15
+ // that contains message bodies — we never select those, neither in this
16
+ // parser nor in any test fixture.
17
+ const TOKEN_COLUMNS = [
18
+ 'session_id',
19
+ 'model',
20
+ 'ts',
21
+ 'input_tokens',
22
+ 'output_tokens',
23
+ 'reasoning_tokens',
24
+ 'cache_read_tokens',
25
+ 'cache_write_tokens',
26
+ ];
27
+
28
+ // `local_runtime_sessions` carries both `workspace_dir` (per-session scratch
29
+ // dir, always present) and `project_workspace_dir` (the project root when one
30
+ // is known). We pick the project column first, then the workspace, then
31
+ // fall back to "unknown". Never read `record_json` / `extra_data_json`.
32
+ const SESSION_COLUMNS = ['session_id', 'workspace_dir', 'project_workspace_dir'];
33
+
34
+ // Keep token rows and their project metadata in one SQLite statement. Separate
35
+ // reads can observe different WAL snapshots while mcode is writing, causing a
36
+ // token row to be uploaded once as "unknown" and again under its real project.
37
+ const USAGE_SQL = `
38
+ SELECT
39
+ ${TOKEN_COLUMNS.map(column => `token.${column}`).join(', ')},
40
+ session.workspace_dir,
41
+ session.project_workspace_dir
42
+ FROM local_runtime_token_usage AS token
43
+ LEFT JOIN local_runtime_sessions AS session
44
+ ON session.session_id = token.session_id
45
+ `;
46
+
47
+ /**
48
+ * Resolve the mcode runtime-state SQLite database. Mirrors the precedence used
49
+ * by sibling tools (MiMoCode, DimAgent): explicit env var wins, then a
50
+ * tool-specific HOME, then the default layout.
51
+ *
52
+ * Defaults to `<homedir()>/.minimax/v2/sqlite/runtime-state.sqlite`, which is
53
+ * where the mcode CLI keeps its WAL database on macOS / Linux.
54
+ */
55
+ export function resolveMcodeDbPath(env = process.env) {
56
+ return getMcodeDbPath(env);
57
+ }
58
+
59
+ /**
60
+ * Token count (ms vs seconds). The mcode runtime writes ts as integer
61
+ * milliseconds — confirmed against the live schema (`typeof(ts)=integer`,
62
+ * values ≈ 1.787e12 for 2026-08-29). Stay defensive: anything < 1e12 is
63
+ * treated as seconds and scaled up.
64
+ */
65
+ function tsToDate(value) {
66
+ const n = Number(value);
67
+ if (!Number.isFinite(n)) return null;
68
+ const ms = n < 1e12 ? n * 1000 : n;
69
+ const d = new Date(ms);
70
+ return Number.isNaN(d.getTime()) ? null : d;
71
+ }
72
+
73
+
74
+
75
+ function toNonNegative(value) {
76
+ const n = Number(value);
77
+ if (!Number.isFinite(n) || n < 0) return 0;
78
+ return n;
79
+ }
80
+
81
+ function dbHasColumns(dbPath, table, columns) {
82
+ const info = queryDbJsonSnapshotOnLock(
83
+ dbPath,
84
+ `PRAGMA table_info(${table})`,
85
+ { tempPrefix: 'vibe-usage-mcode' },
86
+ );
87
+ const present = new Set(info.map(row => String(row.name)));
88
+ return columns.every(col => present.has(col));
89
+ }
90
+
91
+ export async function parse() {
92
+ const dbPath = resolveMcodeDbPath();
93
+ if (!existsSync(dbPath)) return { buckets: [], sessions: [] };
94
+
95
+ // Schema guard: every allow-listed column must exist. If the mcode
96
+ // runtime ever renames / drops a column, fail soft (skipped) so the
97
+ // incremental sync keeps the last good upload state for this source.
98
+ let schemaOk;
99
+ try {
100
+ schemaOk =
101
+ dbHasColumns(dbPath, 'local_runtime_token_usage', TOKEN_COLUMNS) &&
102
+ dbHasColumns(dbPath, 'local_runtime_sessions', SESSION_COLUMNS);
103
+ } catch (err) {
104
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('mcode');
105
+ return { buckets: [], sessions: [], skipped: true };
106
+ }
107
+ if (!schemaOk) {
108
+ return { buckets: [], sessions: [], skipped: true };
109
+ }
110
+
111
+ // Read tokens and session project metadata from one statement/snapshot.
112
+ let usageRows;
113
+ try {
114
+ usageRows = queryDbJsonSnapshotOnLock(dbPath, USAGE_SQL, {
115
+ tempPrefix: 'vibe-usage-mcode',
116
+ });
117
+ } catch (err) {
118
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('mcode');
119
+ return { buckets: [], sessions: [], skipped: true };
120
+ }
121
+
122
+ const entries = [];
123
+
124
+ for (const row of usageRows) {
125
+ const sessionId = row.session_id != null ? String(row.session_id) : '';
126
+ if (!sessionId) continue;
127
+ const ts = tsToDate(row.ts);
128
+ if (!ts) continue;
129
+
130
+ // MCode stores output and reasoning as separate counters. Its own
131
+ // summary code computes total = input + output + reasoning, so do not
132
+ // subtract reasoning from output here.
133
+ const inputRaw = toNonNegative(row.input_tokens);
134
+ const cacheWrite = toNonNegative(row.cache_write_tokens);
135
+ const outputRaw = toNonNegative(row.output_tokens);
136
+ const reasoningRaw = toNonNegative(row.reasoning_tokens);
137
+ const cacheRead = toNonNegative(row.cache_read_tokens);
138
+
139
+ const inputTokens = inputRaw + cacheWrite;
140
+ const reasoningOutputTokens = reasoningRaw;
141
+ const outputTokens = outputRaw;
142
+ const cachedInputTokens = cacheRead;
143
+
144
+ if (
145
+ inputTokens +
146
+ outputTokens +
147
+ cachedInputTokens +
148
+ reasoningOutputTokens ===
149
+ 0
150
+ ) {
151
+ continue;
152
+ }
153
+
154
+ const projectPath = row.project_workspace_dir || row.workspace_dir;
155
+ const project = projectPath ? projectFromPath(String(projectPath)) : 'unknown';
156
+ const model = row.model != null && String(row.model).trim()
157
+ ? String(row.model).trim()
158
+ : 'unknown';
159
+
160
+ entries.push({
161
+ source: SOURCE,
162
+ model,
163
+ project,
164
+ timestamp: ts,
165
+ inputTokens,
166
+ outputTokens,
167
+ cachedInputTokens,
168
+ reasoningOutputTokens,
169
+ });
170
+ }
171
+
172
+ return {
173
+ buckets: aggregateToBuckets(entries),
174
+ // The token ledger contains assistant usage rows only. Reconstructing
175
+ // user prompts would require reading message payloads, which this parser
176
+ // deliberately never selects, so mcode emits buckets only like Alma.
177
+ sessions: [],
178
+ };
179
+ }
180
+
181
+ // Re-export for tests / external consumers.
182
+ export { SOURCE as MCODE_SOURCE };
@@ -0,0 +1,88 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { basename } from 'node:path';
3
+ import { getMimocodeDbPath } from '../tools.js';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
+ import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
6
+
7
+ export { getMimocodeDbPath as resolveMimocodeDbPath };
8
+
9
+ export async function parse() {
10
+ const dbPath = getMimocodeDbPath();
11
+ if (!existsSync(dbPath)) return { buckets: [], sessions: [] };
12
+
13
+ let rows;
14
+ try {
15
+ const hasExternalImports = queryDbJson(dbPath, `
16
+ SELECT 1
17
+ FROM sqlite_master
18
+ WHERE type = 'table' AND name = 'external_import'
19
+ LIMIT 1
20
+ `).length > 0;
21
+ const externalImportJoin = hasExternalImports
22
+ ? 'LEFT JOIN external_import ON external_import.session_id = message.session_id'
23
+ : '';
24
+ const externalImportFilter = hasExternalImports
25
+ ? 'WHERE external_import.session_id IS NULL'
26
+ : '';
27
+ rows = queryDbJson(dbPath, `
28
+ SELECT
29
+ message.session_id AS sessionID,
30
+ message.time_created AS created,
31
+ message.data AS data,
32
+ session.directory AS directory
33
+ FROM message
34
+ JOIN session ON session.id = message.session_id
35
+ ${externalImportJoin}
36
+ ${externalImportFilter}
37
+ `);
38
+ } catch (err) {
39
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('MiMoCode');
40
+ throw err;
41
+ }
42
+
43
+ const entries = [];
44
+ const events = [];
45
+ for (const row of rows) {
46
+ let data;
47
+ try {
48
+ data = typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
49
+ } catch {
50
+ continue;
51
+ }
52
+ if (data?.role !== 'user' && data?.role !== 'assistant') continue;
53
+
54
+ const timestamp = new Date(data.time?.created ?? row.created);
55
+ if (Number.isNaN(timestamp.getTime())) continue;
56
+ const project = row.directory ? basename(row.directory) : 'unknown';
57
+ events.push({
58
+ sessionId: row.sessionID || 'unknown',
59
+ source: 'mimocode',
60
+ project,
61
+ timestamp,
62
+ role: data.role,
63
+ });
64
+
65
+ const tokens = data.tokens;
66
+ if (data.role !== 'assistant' || !data.modelID || !tokens) continue;
67
+ const inputTokens = (Number(tokens.input) || 0) + (Number(tokens.cache?.write) || 0);
68
+ const outputTokens = Number(tokens.output) || 0;
69
+ const reasoningOutputTokens = Number(tokens.reasoning) || 0;
70
+ const cachedInputTokens = Number(tokens.cache?.read) || 0;
71
+ if (inputTokens + outputTokens + reasoningOutputTokens + cachedInputTokens <= 0) continue;
72
+ entries.push({
73
+ source: 'mimocode',
74
+ model: data.modelID,
75
+ project,
76
+ timestamp,
77
+ inputTokens,
78
+ outputTokens,
79
+ cachedInputTokens,
80
+ reasoningOutputTokens,
81
+ });
82
+ }
83
+
84
+ return {
85
+ buckets: aggregateToBuckets(entries),
86
+ sessions: extractSessions(events),
87
+ };
88
+ }
@@ -0,0 +1,10 @@
1
+ import { getOmpSessionDirs } from '../pi-roots.js';
2
+ import { parsePiSessionJsonl } from './pi-session-jsonl.js';
3
+
4
+ /** Parse Oh My Pi's Pi-compatible JSONL sessions, including profiles/XDG. */
5
+ export async function parse() {
6
+ return parsePiSessionJsonl({
7
+ source: 'omp',
8
+ sessionsDirs: getOmpSessionDirs(),
9
+ });
10
+ }
@@ -0,0 +1,142 @@
1
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
+
6
+ // OpenClaw stores data at ~/.openclaw/agents/<agentId>/sessions/*.jsonl
7
+ // Profile deployments use ~/.openclaw-<profile>/agents/...
8
+ // Legacy paths: ~/.clawdbot, ~/.moltbot, ~/.moldbot
9
+ function getPossibleRoots() {
10
+ const override = process.env.VIBE_USAGE_OPENCLAW_DIRS?.trim();
11
+ if (override) return override.split(process.platform === 'win32' ? ';' : ':').filter(Boolean);
12
+ const home = homedir();
13
+ const roots = [
14
+ join(home, '.clawdbot'),
15
+ join(home, '.moltbot'),
16
+ join(home, '.moldbot'),
17
+ ];
18
+ try {
19
+ for (const entry of readdirSync(home, { withFileTypes: true })) {
20
+ if (!entry.isDirectory()) continue;
21
+ if (entry.name === '.openclaw' || /^\.openclaw-.+/.test(entry.name)) {
22
+ roots.push(join(home, entry.name));
23
+ }
24
+ }
25
+ } catch {
26
+ // ignore read errors
27
+ }
28
+ return roots;
29
+ }
30
+
31
+ /** Normalize usage fields — OpenClaw supports multiple naming conventions */
32
+ function getTokens(usage, ...keys) {
33
+ for (const key of keys) {
34
+ const value = Number(usage[key]);
35
+ if (Number.isFinite(value) && value > 0) return value;
36
+ }
37
+ return 0;
38
+ }
39
+
40
+ export async function parse() {
41
+ const entries = [];
42
+ const sessionEvents = [];
43
+
44
+ for (const root of getPossibleRoots()) {
45
+ const agentsDir = join(root, 'agents');
46
+ if (!existsSync(agentsDir)) continue;
47
+
48
+ let agentDirs;
49
+ try {
50
+ agentDirs = readdirSync(agentsDir, { withFileTypes: true })
51
+ .filter(d => d.isDirectory());
52
+ } catch {
53
+ continue;
54
+ }
55
+
56
+ for (const agentDir of agentDirs) {
57
+ const project = agentDir.name;
58
+ const sessionsDir = join(agentsDir, agentDir.name, 'sessions');
59
+ if (!existsSync(sessionsDir)) continue;
60
+
61
+ let files;
62
+ try {
63
+ files = readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl'));
64
+ } catch {
65
+ continue;
66
+ }
67
+
68
+ for (const file of files) {
69
+ const filePath = join(sessionsDir, file);
70
+
71
+ let content;
72
+ try {
73
+ content = readFileSync(filePath, 'utf-8');
74
+ } catch {
75
+ continue;
76
+ }
77
+
78
+ for (const line of content.split('\n')) {
79
+ if (!line.trim()) continue;
80
+ try {
81
+ const obj = JSON.parse(line);
82
+
83
+ if (obj.type !== 'message') continue;
84
+ const msg = obj.message;
85
+ if (!msg) continue;
86
+
87
+ const timestamp = obj.timestamp || msg.timestamp;
88
+ if (!timestamp) continue;
89
+ const ts = new Date(typeof timestamp === 'number' ? timestamp : timestamp);
90
+ if (isNaN(ts.getTime())) continue;
91
+
92
+ sessionEvents.push({
93
+ sessionId: filePath,
94
+ source: 'openclaw',
95
+ project,
96
+ timestamp: ts,
97
+ role: msg.role === 'user' ? 'user' : 'assistant',
98
+ });
99
+
100
+ if (msg.role !== 'assistant') continue;
101
+ const usage = msg.usage;
102
+ if (!usage) continue;
103
+
104
+ const inputTokens = getTokens(
105
+ usage,
106
+ 'input',
107
+ 'inputTokens',
108
+ 'input_tokens',
109
+ 'promptTokens',
110
+ 'prompt_tokens',
111
+ );
112
+ const cacheWriteTokens = getTokens(
113
+ usage,
114
+ 'cacheCreation',
115
+ 'cacheCreationInputTokens',
116
+ 'cacheWrite',
117
+ 'cache_creation',
118
+ 'cache_write',
119
+ 'cache_creation_input_tokens',
120
+ 'cache_write_input_tokens',
121
+ );
122
+
123
+ entries.push({
124
+ source: 'openclaw',
125
+ model: msg.model || obj.model || 'unknown',
126
+ project,
127
+ timestamp: ts,
128
+ inputTokens: inputTokens + cacheWriteTokens,
129
+ outputTokens: getTokens(usage, 'output', 'outputTokens', 'output_tokens', 'completionTokens', 'completion_tokens'),
130
+ cachedInputTokens: getTokens(usage, 'cacheRead', 'cache_read', 'cache_read_input_tokens'),
131
+ reasoningOutputTokens: 0,
132
+ });
133
+ } catch {
134
+ continue;
135
+ }
136
+ }
137
+ }
138
+ }
139
+ }
140
+
141
+ return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
142
+ }
@@ -0,0 +1,151 @@
1
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
+ import { join, basename } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
+ import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
6
+
7
+ const DATA_DIR = join(homedir(), '.local', 'share', 'opencode');
8
+ const DB_PATH = join(DATA_DIR, 'opencode.db');
9
+ const MESSAGES_DIR = join(DATA_DIR, 'storage', 'message');
10
+
11
+ /**
12
+ * Parse opencode usage data.
13
+ * Tries SQLite database first (opencode >= v0.2), falls back to legacy JSON files.
14
+ */
15
+ export async function parse() {
16
+ if (existsSync(DB_PATH)) {
17
+ try {
18
+ return parseFromSqlite();
19
+ } catch (err) {
20
+ process.stderr.write(`warn: opencode sqlite parse failed (${err.message}), trying legacy json...\n`);
21
+ }
22
+ }
23
+ return parseFromJson();
24
+ }
25
+
26
+ function parseFromSqlite() {
27
+ const query = `SELECT
28
+ session_id as sessionID,
29
+ json_extract(data, '$.role') as role,
30
+ json_extract(data, '$.time.created') as created,
31
+ json_extract(data, '$.modelID') as modelID,
32
+ json_extract(data, '$.tokens') as tokens,
33
+ json_extract(data, '$.path.root') as rootPath
34
+ FROM message`;
35
+
36
+ let rows;
37
+ try {
38
+ rows = queryDbJson(DB_PATH, query);
39
+ } catch (err) {
40
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('OpenCode');
41
+ throw err;
42
+ }
43
+ if (!rows.length) return { buckets: [], sessions: [] };
44
+
45
+ const entries = [];
46
+ const sessionEvents = [];
47
+ for (const row of rows) {
48
+ const timestamp = new Date(row.created);
49
+ if (isNaN(timestamp.getTime())) continue;
50
+
51
+ const project = row.rootPath ? basename(row.rootPath) : 'unknown';
52
+ const sessionId = row.sessionID || 'unknown';
53
+
54
+ sessionEvents.push({
55
+ sessionId,
56
+ source: 'opencode',
57
+ project,
58
+ timestamp,
59
+ role: row.role === 'user' ? 'user' : 'assistant',
60
+ });
61
+
62
+ if (!row.modelID) continue;
63
+ let tokens;
64
+ try {
65
+ tokens = typeof row.tokens === 'string' ? JSON.parse(row.tokens) : row.tokens;
66
+ } catch {
67
+ continue;
68
+ }
69
+ if (!tokens || (!tokens.input && !tokens.output)) continue;
70
+
71
+ entries.push({
72
+ source: 'opencode',
73
+ model: row.modelID || 'unknown',
74
+ project,
75
+ timestamp,
76
+ inputTokens: tokens.input || 0,
77
+ outputTokens: tokens.output || 0,
78
+ cachedInputTokens: tokens.cache?.read || 0,
79
+ reasoningOutputTokens: tokens.reasoning || 0,
80
+ });
81
+ }
82
+
83
+ return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
84
+ }
85
+
86
+ function parseFromJson() {
87
+ if (!existsSync(MESSAGES_DIR)) return { buckets: [], sessions: [] };
88
+
89
+ const entries = [];
90
+ const sessionEvents = [];
91
+ let sessionDirs;
92
+ try {
93
+ sessionDirs = readdirSync(MESSAGES_DIR, { withFileTypes: true })
94
+ .filter(d => d.isDirectory() && d.name.startsWith('ses_'));
95
+ } catch {
96
+ return { buckets: [], sessions: [] };
97
+ }
98
+
99
+ for (const sessionDir of sessionDirs) {
100
+ const sessionPath = join(MESSAGES_DIR, sessionDir.name);
101
+ let msgFiles;
102
+ try {
103
+ msgFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
104
+ } catch {
105
+ continue;
106
+ }
107
+
108
+ for (const file of msgFiles) {
109
+ const filePath = join(sessionPath, file);
110
+
111
+ let data;
112
+ try {
113
+ data = JSON.parse(readFileSync(filePath, 'utf-8'));
114
+ } catch {
115
+ continue;
116
+ }
117
+
118
+ const timestamp = new Date(data.time?.created);
119
+ if (isNaN(timestamp.getTime())) continue;
120
+
121
+ const rootPath = data.path?.root;
122
+ const project = rootPath ? basename(rootPath) : 'unknown';
123
+
124
+ sessionEvents.push({
125
+ sessionId: sessionDir.name,
126
+ source: 'opencode',
127
+ project,
128
+ timestamp,
129
+ role: data.role === 'user' ? 'user' : 'assistant',
130
+ });
131
+
132
+ if (!data.modelID) continue;
133
+ const tokens = data.tokens;
134
+ if (!tokens) continue;
135
+ if (!tokens.input && !tokens.output) continue;
136
+
137
+ entries.push({
138
+ source: 'opencode',
139
+ model: data.modelID || 'unknown',
140
+ project,
141
+ timestamp,
142
+ inputTokens: tokens.input || 0,
143
+ outputTokens: tokens.output || 0,
144
+ cachedInputTokens: tokens.cache?.read || 0,
145
+ reasoningOutputTokens: tokens.reasoning || 0,
146
+ });
147
+ }
148
+ }
149
+
150
+ return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
151
+ }
@@ -0,0 +1,27 @@
1
+ import { normalizeExtraRoot, piSessionsDir } from '../extra-roots.js';
2
+ import { getPiSessionDirs } from '../pi-roots.js';
3
+ import { mergeCindyHarnessUsage, readCindyHarnessUsage } from './cindy-ledger.js';
4
+ import { parsePiSessionJsonl } from './pi-session-jsonl.js';
5
+
6
+ /** Parse the official Pi agent's Pi-compatible JSONL sessions. */
7
+ export async function parse({ extraRoots = [] } = {}) {
8
+ for (const root of extraRoots) {
9
+ // piSessionsDir re-resolves the root's shape, so an agent home that lost
10
+ // its `sessions/` child is caught here too, not just a root that vanished.
11
+ if (piSessionsDir(root) !== null) continue;
12
+ // An explicitly configured root that is momentarily unreadable is not
13
+ // proof that its usage disappeared, so skip instead of reporting empty.
14
+ return {
15
+ buckets: [],
16
+ sessions: [],
17
+ skipped: true,
18
+ warnings: [`pi-coding-agent: 额外根目录不可用,已跳过本次 Pi 同步: ${normalizeExtraRoot(root)}`],
19
+ };
20
+ }
21
+
22
+ const nativeResult = await parsePiSessionJsonl({
23
+ source: 'pi-coding-agent',
24
+ sessionsDirs: getPiSessionDirs(extraRoots),
25
+ });
26
+ return mergeCindyHarnessUsage(nativeResult, readCindyHarnessUsage('pi'));
27
+ }