@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,196 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ // Shared bucket/session aggregation used by every parser. Lives in its own
4
+ // module (not the parser registry in index.js) so parsers never have to import
5
+ // the registry that imports them — that circular dependency only worked because
6
+ // these functions are hoisted declarations. Keeping them here breaks the cycle.
7
+ //
8
+ // Data model: parsers emit flat per-message "entries" (token usage) and
9
+ // per-message "events" (timing), and these two functions fold them into the
10
+ // bucket/session shapes described in AGENTS.md.
11
+
12
+ export function roundToHalfHour(date) {
13
+ const d = new Date(date);
14
+ d.setMinutes(d.getMinutes() < 30 ? 0 : 30, 0, 0);
15
+ return d;
16
+ }
17
+
18
+ // Server column limits (usage_buckets: model varchar(100), project varchar(200)).
19
+ // Anything longer aborts the whole INSERT chunk with 22001, so clamp here.
20
+ const MODEL_MAX_LENGTH = 100;
21
+ const PROJECT_MAX_LENGTH = 200;
22
+
23
+ // Server token columns are bigint — a single fractional/NaN value aborts the
24
+ // whole INSERT chunk with 22P02, taking every other tool's rows in the batch
25
+ // down with it.
26
+ function toTokenCount(value) {
27
+ const n = Number(value);
28
+ if (!Number.isFinite(n) || n <= 0) return 0;
29
+ return Math.round(n);
30
+ }
31
+
32
+ export function aggregateToBuckets(entries) {
33
+ const map = new Map();
34
+
35
+ for (const e of entries) {
36
+ const model = String(e.model || 'unknown').slice(0, MODEL_MAX_LENGTH);
37
+ const project = String(e.project || 'unknown').slice(0, PROJECT_MAX_LENGTH);
38
+ const bucketStart = roundToHalfHour(e.timestamp).toISOString();
39
+ const requestType = e.requestType || 'other';
40
+ const key = JSON.stringify([e.source, model, project, e.hostname || '', bucketStart, requestType]);
41
+
42
+ if (!map.has(key)) {
43
+ map.set(key, {
44
+ source: e.source,
45
+ ...(requestType !== 'other' ? { requestType } : {}),
46
+ model,
47
+ project,
48
+ // Cloud-sourced parsers (cursor) pre-set a fixed hostname sentinel; it
49
+ // must survive aggregation, or sync.js stamps the machine hostname and
50
+ // every machine gets its own duplicate row server-side.
51
+ ...(e.hostname ? { hostname: e.hostname } : {}),
52
+ bucketStart,
53
+ inputTokens: 0,
54
+ outputTokens: 0,
55
+ cachedInputTokens: 0,
56
+ reasoningOutputTokens: 0,
57
+ });
58
+ }
59
+
60
+ const b = map.get(key);
61
+ b.inputTokens += e.inputTokens || 0;
62
+ b.outputTokens += e.outputTokens || 0;
63
+ b.cachedInputTokens += e.cachedInputTokens || 0;
64
+ b.reasoningOutputTokens += e.reasoningOutputTokens || 0;
65
+ }
66
+
67
+ // Clamp after summation, not per entry — rounding each entry first would
68
+ // discard sub-integer values instead of letting them accumulate.
69
+ return Array.from(map.values()).map((b) => {
70
+ const inputTokens = toTokenCount(b.inputTokens);
71
+ const outputTokens = toTokenCount(b.outputTokens);
72
+ const cachedInputTokens = toTokenCount(b.cachedInputTokens);
73
+ const reasoningOutputTokens = toTokenCount(b.reasoningOutputTokens);
74
+ return {
75
+ ...b,
76
+ inputTokens,
77
+ outputTokens,
78
+ cachedInputTokens,
79
+ reasoningOutputTokens,
80
+ totalTokens: inputTokens + outputTokens + reasoningOutputTokens,
81
+ };
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Incremental session aggregation for parsers whose event stream is already
87
+ * chronological. `extractSessions()` below keeps the sorting fallback for
88
+ * parsers that emit mixed or out-of-order sessions.
89
+ */
90
+ export function createSessionAccumulator() {
91
+ return {
92
+ ordered: true,
93
+ first: null,
94
+ last: null,
95
+ lastTimestampMs: null,
96
+ activeSeconds: 0,
97
+ turnStartMs: null,
98
+ turnEndMs: null,
99
+ waitingForFirstResponse: false,
100
+ messageCount: 0,
101
+ userMessageCount: 0,
102
+ userPromptHours: new Array(24).fill(0),
103
+ };
104
+ }
105
+
106
+ function commitTurn(accumulator) {
107
+ const { turnStartMs, turnEndMs } = accumulator;
108
+ if (turnStartMs !== null && turnEndMs !== null && turnEndMs > turnStartMs) {
109
+ accumulator.activeSeconds += Math.round((turnEndMs - turnStartMs) / 1000);
110
+ }
111
+ }
112
+
113
+ export function accumulateSessionEvent(accumulator, event) {
114
+ const timestampMs = event.timestamp.getTime();
115
+ if (accumulator.lastTimestampMs !== null && timestampMs < accumulator.lastTimestampMs) {
116
+ accumulator.ordered = false;
117
+ }
118
+ if (accumulator.first === null) accumulator.first = event;
119
+ accumulator.last = event;
120
+ accumulator.lastTimestampMs = timestampMs;
121
+ accumulator.messageCount++;
122
+
123
+ if (event.role === 'user') {
124
+ commitTurn(accumulator);
125
+ accumulator.turnStartMs = null;
126
+ accumulator.turnEndMs = null;
127
+ accumulator.waitingForFirstResponse = true;
128
+ accumulator.userMessageCount++;
129
+ accumulator.userPromptHours[event.timestamp.getUTCHours()]++;
130
+ } else if (accumulator.waitingForFirstResponse) {
131
+ accumulator.turnStartMs = timestampMs;
132
+ accumulator.turnEndMs = timestampMs;
133
+ accumulator.waitingForFirstResponse = false;
134
+ } else if (accumulator.turnStartMs !== null) {
135
+ accumulator.turnEndMs = timestampMs;
136
+ }
137
+ }
138
+
139
+ export function sessionAccumulatorIsOrdered(accumulator) {
140
+ return accumulator.ordered;
141
+ }
142
+
143
+ export function finalizeSessionAccumulator(accumulator, sessionId, projectOverride) {
144
+ if (accumulator.first === null || accumulator.last === null) return null;
145
+ if (!accumulator.ordered) {
146
+ throw new TypeError('Session accumulator received out-of-order events');
147
+ }
148
+
149
+ let activeSeconds = accumulator.activeSeconds;
150
+ const { turnStartMs, turnEndMs } = accumulator;
151
+ if (turnStartMs !== null && turnEndMs !== null && turnEndMs > turnStartMs) {
152
+ activeSeconds += Math.round((turnEndMs - turnStartMs) / 1000);
153
+ }
154
+
155
+ const first = accumulator.first;
156
+ const last = accumulator.last;
157
+ const sessionHash = createHash('sha256').update(sessionId).digest('hex').slice(0, 16);
158
+ return {
159
+ source: first.source,
160
+ project: projectOverride || first.project || 'unknown',
161
+ sessionHash,
162
+ firstMessageAt: first.timestamp.toISOString(),
163
+ lastMessageAt: last.timestamp.toISOString(),
164
+ durationSeconds: Math.round((last.timestamp - first.timestamp) / 1000),
165
+ activeSeconds,
166
+ messageCount: accumulator.messageCount,
167
+ userMessageCount: accumulator.userMessageCount,
168
+ userPromptHours: accumulator.userPromptHours,
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Extract session metadata from timing events.
174
+ * Each event: { sessionId, source, project, timestamp: Date, role: 'user'|'assistant' }
175
+ *
176
+ * Turn = first AI response → last AI response before next user prompt.
177
+ * activeSeconds = sum(generation durations), excluding queue/TTFT wait.
178
+ * durationSeconds = wall clock from first to last message.
179
+ */
180
+ export function extractSessions(events) {
181
+ const groups = new Map();
182
+ for (const event of events) {
183
+ if (!groups.has(event.sessionId)) groups.set(event.sessionId, []);
184
+ groups.get(event.sessionId).push(event);
185
+ }
186
+
187
+ const sessions = [];
188
+ for (const [sessionId, sessionEvents] of groups) {
189
+ sessionEvents.sort((a, b) => a.timestamp - b.timestamp);
190
+ const accumulator = createSessionAccumulator();
191
+ for (const event of sessionEvents) accumulateSessionEvent(accumulator, event);
192
+ const session = finalizeSessionAccumulator(accumulator, sessionId);
193
+ if (session) sessions.push(session);
194
+ }
195
+ return sessions;
196
+ }
@@ -0,0 +1,94 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { basename } from 'node:path';
3
+ import { getAlmaDbPath } from '../tools.js';
4
+ import { aggregateToBuckets } from './aggregate.js';
5
+ import { toCount } from './fs-utils.js';
6
+ import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
7
+
8
+ export { getAlmaDbPath as resolveAlmaDbPath };
9
+
10
+ export function normalizeAlmaModel(value) {
11
+ if (typeof value !== 'string') return 'unknown';
12
+ const model = value.trim();
13
+ if (!model) return 'unknown';
14
+ const separator = model.lastIndexOf(':');
15
+ if (separator === -1) return model;
16
+ return model.slice(separator + 1).trim() || 'unknown';
17
+ }
18
+
19
+ const ALMA_USAGE_SQL = `
20
+ SELECT
21
+ usage_records.model AS model,
22
+ usage_records.timestamp AS timestamp,
23
+ usage_records.input_tokens AS inputTokens,
24
+ usage_records.output_tokens AS outputTokens,
25
+ usage_records.cached_input_tokens AS cachedInputTokens,
26
+ usage_records.reasoning_tokens AS reasoningOutputTokens,
27
+ usage_records.cache_write_input_tokens AS cacheWriteInputTokens,
28
+ workspaces.name AS workspaceName
29
+ FROM usage_records
30
+ LEFT JOIN chat_threads ON chat_threads.id = usage_records.thread_id
31
+ LEFT JOIN workspaces ON workspaces.id = chat_threads.workspace_id
32
+ `;
33
+
34
+ function safeWorkspaceName(value) {
35
+ if (typeof value !== 'string') return 'unknown';
36
+ const normalized = value.trim().replace(/\\/g, '/').replace(/\/+$/, '');
37
+ return basename(normalized) || 'unknown';
38
+ }
39
+
40
+ function skippedResult(error) {
41
+ const message = error?.message || String(error);
42
+ let reason = message;
43
+ if (/database is locked/i.test(message)) reason = 'database is locked';
44
+ else if (/no such (table|column)/i.test(message)) reason = 'incompatible database schema';
45
+ return {
46
+ buckets: [],
47
+ sessions: [],
48
+ skipped: true,
49
+ warnings: [`alma: cannot read usage database (${reason})`],
50
+ };
51
+ }
52
+
53
+ export async function parse() {
54
+ const dbPath = getAlmaDbPath();
55
+ if (!existsSync(dbPath)) return { buckets: [], sessions: [] };
56
+
57
+ let rows;
58
+ try {
59
+ rows = queryDbJson(dbPath, ALMA_USAGE_SQL);
60
+ } catch (error) {
61
+ if (isSqliteUnavailableError(error)) throw sqliteUnavailableError('Alma');
62
+ return skippedResult(error);
63
+ }
64
+
65
+ const entries = [];
66
+ for (const row of rows) {
67
+ const timestamp = new Date(row.timestamp);
68
+ if (Number.isNaN(timestamp.getTime())) continue;
69
+
70
+ const inputTokens = toCount(row.inputTokens) + toCount(row.cacheWriteInputTokens);
71
+ const outputTokens = toCount(row.outputTokens);
72
+ const cachedInputTokens = toCount(row.cachedInputTokens);
73
+ const reasoningOutputTokens = toCount(row.reasoningOutputTokens);
74
+ if (inputTokens + outputTokens + cachedInputTokens + reasoningOutputTokens === 0) continue;
75
+
76
+ entries.push({
77
+ source: 'alma',
78
+ model: normalizeAlmaModel(row.model),
79
+ project: safeWorkspaceName(row.workspaceName),
80
+ timestamp,
81
+ inputTokens,
82
+ outputTokens,
83
+ cachedInputTokens,
84
+ reasoningOutputTokens,
85
+ });
86
+ }
87
+
88
+ return {
89
+ buckets: aggregateToBuckets(entries),
90
+ // Alma's usage ledger contains assistant responses only. Reconstructing
91
+ // user turns would require reading chat records outside the usage contract.
92
+ sessions: [],
93
+ };
94
+ }
@@ -0,0 +1,156 @@
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
+ function resolveThreadsDir() {
7
+ if (process.env.AMP_DATA_DIR) return process.env.AMP_DATA_DIR;
8
+ if (process.env.XDG_DATA_HOME) return join(process.env.XDG_DATA_HOME, 'amp', 'threads');
9
+ return join(homedir(), '.local', 'share', 'amp', 'threads');
10
+ }
11
+
12
+ function findThreadFiles(dir) {
13
+ const results = [];
14
+ if (!existsSync(dir)) return results;
15
+
16
+ try {
17
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
18
+ const fullPath = join(dir, entry.name);
19
+ if (entry.isDirectory()) {
20
+ for (const nested of findThreadFiles(fullPath)) results.push(nested);
21
+ } else if (entry.isFile() && entry.name.startsWith('T-') && entry.name.endsWith('.json')) {
22
+ results.push(fullPath);
23
+ }
24
+ }
25
+ } catch {
26
+ }
27
+
28
+ return results;
29
+ }
30
+
31
+ function setMessageTimestamp(map, messageId, timestamp) {
32
+ if (!Number.isInteger(messageId)) return;
33
+ const current = map.get(messageId);
34
+ if (!current || timestamp < current) {
35
+ map.set(messageId, timestamp);
36
+ }
37
+ }
38
+
39
+ function buildMessageTimestampMap(events) {
40
+ const map = new Map();
41
+ if (!Array.isArray(events)) return map;
42
+
43
+ for (const event of events) {
44
+ const ts = new Date(event?.timestamp);
45
+ if (isNaN(ts.getTime())) continue;
46
+
47
+ setMessageTimestamp(map, event.fromMessageId, ts);
48
+ setMessageTimestamp(map, event.toMessageId, ts);
49
+ }
50
+
51
+ return map;
52
+ }
53
+
54
+ export async function parse() {
55
+ const threadsDir = resolveThreadsDir();
56
+ const threadFiles = findThreadFiles(threadsDir);
57
+ if (threadFiles.length === 0) return { buckets: [], sessions: [] };
58
+
59
+ const entries = [];
60
+ const sessionEvents = [];
61
+
62
+ for (const filePath of threadFiles) {
63
+ let thread;
64
+ try {
65
+ thread = JSON.parse(readFileSync(filePath, 'utf-8'));
66
+ } catch {
67
+ continue;
68
+ }
69
+
70
+ const sessionId = thread?.id || filePath;
71
+ const messages = Array.isArray(thread?.messages) ? thread.messages : [];
72
+ const ledgerEvents = Array.isArray(thread?.usageLedger?.events) ? thread.usageLedger.events : [];
73
+ const hasLedger = ledgerEvents.length > 0;
74
+
75
+ if (hasLedger) {
76
+ for (const event of ledgerEvents) {
77
+ const ts = new Date(event?.timestamp);
78
+ if (isNaN(ts.getTime())) continue;
79
+
80
+ const inputTokens = event?.tokens?.input || 0;
81
+ const outputTokens = event?.tokens?.output || 0;
82
+
83
+ const toMessage = Number.isInteger(event.toMessageId) ? messages[event.toMessageId] : null;
84
+ const cacheReadInputTokens = toMessage?.usage?.cacheReadInputTokens || 0;
85
+ const cacheCreationInputTokens = toMessage?.usage?.cacheCreationInputTokens || 0;
86
+ if (
87
+ inputTokens === 0
88
+ && outputTokens === 0
89
+ && cacheReadInputTokens === 0
90
+ && cacheCreationInputTokens === 0
91
+ ) continue;
92
+
93
+ entries.push({
94
+ source: 'amp',
95
+ model: event?.model || 'unknown',
96
+ project: 'unknown',
97
+ timestamp: ts,
98
+ inputTokens: inputTokens + cacheCreationInputTokens,
99
+ outputTokens,
100
+ cachedInputTokens: cacheReadInputTokens,
101
+ reasoningOutputTokens: 0,
102
+ });
103
+ }
104
+ } else {
105
+ for (const message of messages) {
106
+ const usage = message?.usage;
107
+ if (!usage) continue;
108
+
109
+ const ts = new Date(message?.timestamp || thread?.created);
110
+ if (isNaN(ts.getTime())) continue;
111
+
112
+ const inputTokens = usage.inputTokens || 0;
113
+ const outputTokens = usage.outputTokens || 0;
114
+ const cacheCreationInputTokens = usage.cacheCreationInputTokens || 0;
115
+ if (
116
+ inputTokens === 0
117
+ && outputTokens === 0
118
+ && (usage.cacheReadInputTokens || 0) === 0
119
+ && cacheCreationInputTokens === 0
120
+ ) continue;
121
+
122
+ entries.push({
123
+ source: 'amp',
124
+ model: usage.model || 'unknown',
125
+ project: 'unknown',
126
+ timestamp: ts,
127
+ inputTokens: inputTokens + cacheCreationInputTokens,
128
+ outputTokens,
129
+ cachedInputTokens: usage.cacheReadInputTokens || 0,
130
+ reasoningOutputTokens: 0,
131
+ });
132
+ }
133
+ }
134
+
135
+ const messageTsMap = buildMessageTimestampMap(ledgerEvents);
136
+ const baseTimestamp = new Date(thread?.created);
137
+ const hasBaseTimestamp = !isNaN(baseTimestamp.getTime());
138
+
139
+ for (let i = 0; i < messages.length; i++) {
140
+ const message = messages[i];
141
+ const mappedTs = messageTsMap.get(i);
142
+ const ts = mappedTs || (hasBaseTimestamp ? baseTimestamp : null);
143
+ if (!ts || isNaN(ts.getTime())) continue;
144
+
145
+ sessionEvents.push({
146
+ sessionId,
147
+ source: 'amp',
148
+ project: 'unknown',
149
+ timestamp: ts,
150
+ role: message?.role === 'user' ? 'user' : 'assistant',
151
+ });
152
+ }
153
+ }
154
+
155
+ return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
156
+ }