@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,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
|
+
}
|