@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,36 @@
1
+ import { fileURLToPath } from 'node:url';
2
+
3
+ export default function usageReport(pi) {
4
+ let running;
5
+ pi.on('session_shutdown', () => running?.abort());
6
+ pi.registerCommand('usage-report', {
7
+ description: '生成本地用量 HTML 报告:/usage-report [天数,默认 90];不上传统计数据',
8
+ handler: async (args, ctx) => {
9
+ const days = args.trim() || '90';
10
+ if (!/^\d+$/.test(days) || Number(days) < 1 || Number(days) > 3660) {
11
+ ctx.ui.notify('用法:/usage-report [1–3660];高级筛选与本地价格覆盖请使用 pi-usage --help。', 'error');
12
+ return;
13
+ }
14
+ if (running) { ctx.ui.notify('报告正在生成,请等待完成。', 'warning'); return; }
15
+ const controller = new AbortController();
16
+ running = controller;
17
+ ctx.ui.setStatus('pi-usage', '正在生成本地用量报告…');
18
+ try {
19
+ const result = await pi.exec(process.execPath, [fileURLToPath(new URL('../bin/pi-usage.js', import.meta.url)), '--days', days], {
20
+ cwd: ctx.cwd, signal: controller.signal, timeout: 900_000,
21
+ });
22
+ if (result.code !== 0 || result.killed) throw new Error('报告生成失败或已取消;请运行 pi-usage 查看错误。');
23
+ const file = result.stdout.trim().split('\n').at(-1);
24
+ // UI only. Never send the report, data or costs to the conversation/model.
25
+ ctx.ui.notify(`本地报告:${file}`, 'info');
26
+ ctx.ui.setWidget('pi-usage-report', [`本地用量报告:${file}`]);
27
+ if (result.stderr.includes('注意:')) ctx.ui.notify('部分数据源不完整,请查看报告内的数据源状态。', 'warning');
28
+ } catch (error) {
29
+ if (!controller.signal.aborted) ctx.ui.notify(error.message, 'error');
30
+ } finally {
31
+ if (running === controller) running = undefined;
32
+ ctx.ui.setStatus('pi-usage', undefined);
33
+ }
34
+ },
35
+ });
36
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@geoqiao/pi-usage",
3
+ "version": "0.1.0",
4
+ "description": "AI coding usage analytics with private, self-contained HTML reports. No usage uploads.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "pi-package",
9
+ "usage",
10
+ "tokens",
11
+ "analytics",
12
+ "local-first"
13
+ ],
14
+ "files": [
15
+ "bin",
16
+ "src",
17
+ "web",
18
+ "data",
19
+ "vendor/vibe-usage/src",
20
+ "vendor/vibe-usage/NOTICE.md",
21
+ "vendor/vibe-usage/upstream-files.json",
22
+ "extensions",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "bin": {
27
+ "pi-usage": "bin/pi-usage.js"
28
+ },
29
+ "pi": {
30
+ "extensions": [
31
+ "./extensions/usage-report.js"
32
+ ]
33
+ },
34
+ "engines": {
35
+ "node": ">=22.15"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/geoqiao/pi-tools.git",
40
+ "directory": "packages/pi-usage"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "provenance": true
45
+ },
46
+ "scripts": {
47
+ "test": "node --test test/*.test.js vendor/vibe-usage/test/*.test.js",
48
+ "typecheck": "node scripts/check.js",
49
+ "pack:check": "npm pack --dry-run"
50
+ }
51
+ }
@@ -0,0 +1,189 @@
1
+ // Shared by Node and the self-contained report. No filesystem or network access.
2
+ export const TOKEN_FIELDS = ['inputTokens', 'cachedInputTokens', 'outputTokens', 'reasoningOutputTokens'];
3
+ export const DIMENSIONS = ['source', 'model', 'project', 'hostname', 'requestType'];
4
+ export const REQUEST_TYPES = { non_tool: '非工具调用请求', tool: '含工具调用请求', other: '其他(无法判定)' };
5
+ export const QUANTILES = [0.25, 0.5, 0.75, 0.9];
6
+
7
+ export function requestTypeFor(message) {
8
+ const stop = message.stopReason ?? message.stop_reason;
9
+ if (['toolUse', 'tool_use'].includes(stop) || (Array.isArray(message.content) && message.content.some(block => ['toolCall', 'tool_use', 'server_tool_use'].includes(block?.type)))) return 'tool';
10
+ return Array.isArray(message.content) && ['stop', 'end_turn', 'stop_sequence'].includes(stop) ? 'non_tool' : 'other';
11
+ }
12
+
13
+ // Multiple fragments/copies can carry one request's usage. Positive tool evidence wins.
14
+ export function mergeRequestTypes(a, b) {
15
+ return [a, b].includes('tool') ? 'tool' : [a, b].includes('non_tool') ? 'non_tool' : 'other';
16
+ }
17
+
18
+ const dateFormatters = new Map();
19
+ export function dateKey(value, timeZone) {
20
+ if (!dateFormatters.has(timeZone)) dateFormatters.set(timeZone, new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }));
21
+ const parts = dateFormatters.get(timeZone).formatToParts(new Date(value));
22
+ const get = type => parts.find(p => p.type === type).value;
23
+ return `${get('year')}-${get('month')}-${get('day')}`;
24
+ }
25
+
26
+ export function shiftDate(day, amount) {
27
+ const d = new Date(`${day}T12:00:00Z`);
28
+ d.setUTCDate(d.getUTCDate() + amount);
29
+ return d.toISOString().slice(0, 10);
30
+ }
31
+
32
+ export function quantile(values, p) {
33
+ if (!values.length) return null;
34
+ const sorted = [...values].sort((a, b) => a - b);
35
+ const index = (sorted.length - 1) * p;
36
+ const lower = Math.floor(index);
37
+ return sorted[lower] + (sorted[Math.ceil(index)] - sorted[lower]) * (index - lower);
38
+ }
39
+
40
+ export function validatePrices(models) {
41
+ if (!models || typeof models !== 'object' || Array.isArray(models)) throw new Error('价格表 models 必须是对象');
42
+ for (const [id, rate] of Object.entries(models)) {
43
+ if (!id || !rate || typeof rate !== 'object' || Array.isArray(rate)) throw new Error(`无效价格:${id}`);
44
+ for (const key of ['input', 'output', 'cacheRead', 'reasoning']) {
45
+ if (key === 'cacheRead' && rate[key] === null) continue;
46
+ if (typeof rate[key] !== 'number' || !Number.isFinite(rate[key]) || rate[key] < 0) throw new Error(`${id}.${key} 必须是非负有限数值(美元 / 百万 token)`);
47
+ }
48
+ }
49
+ return models;
50
+ }
51
+
52
+ export function findRate(model, models) {
53
+ // Whole identifiers only: never discard service-tier suffixes or match by substring.
54
+ if (Object.hasOwn(models, model)) return models[model];
55
+ const matches = Object.keys(models).filter(id => id.toLowerCase() === model.toLowerCase());
56
+ return matches.length === 1 ? models[matches[0]] : null;
57
+ }
58
+
59
+ export function priceTokens(row, rate) {
60
+ if (!rate || (row.cachedInputTokens > 0 && rate.cacheRead == null)) return null;
61
+ const costs = {
62
+ inputCost: row.inputTokens * rate.input / 1e6,
63
+ cacheCost: row.cachedInputTokens * (rate.cacheRead ?? 0) / 1e6,
64
+ outputCost: row.outputTokens * rate.output / 1e6,
65
+ reasoningCost: row.reasoningOutputTokens * rate.reasoning / 1e6,
66
+ };
67
+ return { ...costs, estimatedCost: Object.values(costs).reduce((a, b) => a + b, 0) };
68
+ }
69
+
70
+ function text(value, fallback = 'unknown') {
71
+ return typeof value === 'string' && value ? value.slice(0, 500) : fallback;
72
+ }
73
+ function count(value) {
74
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) throw new Error('无效计数:必须是非负安全整数');
75
+ return value;
76
+ }
77
+ function iso(value) {
78
+ if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) throw new Error('无效时间戳');
79
+ return new Date(value).toISOString();
80
+ }
81
+
82
+ export function normalizeData(raw, { timeZone, hostname = 'unknown', prices }) {
83
+ if (!Array.isArray(raw?.buckets) || !Array.isArray(raw?.sessions)) throw new Error('输入需要 buckets 和 sessions 数组');
84
+ // Allow-list fields: no prompts, message bodies, credentials or arbitrary parser payloads in exports.
85
+ const buckets = raw.buckets.map(row => {
86
+ const b = Object.fromEntries(DIMENSIONS.map(k => [k, text(row[k], k === 'hostname' ? hostname : 'unknown')]));
87
+ if (row.requestType != null && !Object.hasOwn(REQUEST_TYPES, row.requestType)) throw new Error('无效请求类型');
88
+ b.requestType = row.requestType ?? 'other';
89
+ b.bucketStart = iso(row.bucketStart);
90
+ b.date = dateKey(b.bucketStart, timeZone);
91
+ for (const k of TOKEN_FIELDS) b[k] = count(row[k] ?? 0);
92
+ b.totalTokens = count(b.inputTokens + b.outputTokens + b.reasoningOutputTokens);
93
+ b.allTokens = count(b.totalTokens + b.cachedInputTokens);
94
+ const priced = priceTokens(b, findRate(b.model, prices));
95
+ return { ...b, ...(priced || { inputCost: null, cacheCost: null, outputCost: null, reasoningCost: null, estimatedCost: null }) };
96
+ });
97
+ const sessions = raw.sessions.map(row => {
98
+ const s = Object.fromEntries(['source', 'project', 'hostname'].map(k => [k, text(row[k], k === 'hostname' ? hostname : 'unknown')]));
99
+ s.firstMessageAt = iso(row.firstMessageAt);
100
+ s.lastMessageAt = iso(row.lastMessageAt);
101
+ if (s.lastMessageAt < s.firstMessageAt) throw new Error('会话结束时间早于开始时间');
102
+ s.date = dateKey(s.firstMessageAt, timeZone);
103
+ s.sessionHash = text(row.sessionHash, 'unknown');
104
+ for (const k of ['durationSeconds', 'activeSeconds', 'messageCount', 'userMessageCount']) s[k] = count(row[k] ?? 0);
105
+ return s;
106
+ });
107
+ return { buckets, sessions };
108
+ }
109
+
110
+ export function summarize(rows) {
111
+ const out = Object.fromEntries([...TOKEN_FIELDS, 'totalTokens', 'allTokens', 'knownCost', 'pricedTokens', 'inputCost', 'cacheCost', 'outputCost', 'reasoningCost'].map(k => [k, 0]));
112
+ out.unpricedRows = 0;
113
+ for (const row of rows) {
114
+ for (const k of [...TOKEN_FIELDS, 'totalTokens', 'allTokens']) out[k] += row[k];
115
+ if (row.estimatedCost == null && row.allTokens > 0) out.unpricedRows++;
116
+ else {
117
+ out.knownCost += row.estimatedCost || 0;
118
+ out.pricedTokens += row.allTokens;
119
+ for (const k of ['inputCost', 'cacheCost', 'outputCost', 'reasoningCost']) out[k] += row[k] || 0;
120
+ }
121
+ }
122
+ out.estimatedCost = out.unpricedRows ? null : out.knownCost;
123
+ out.coverage = out.allTokens ? out.pricedTokens / out.allTokens : null;
124
+ return out;
125
+ }
126
+
127
+ export function groupRows(rows, key) {
128
+ const groups = new Map();
129
+ for (const row of rows) {
130
+ const id = typeof key === 'function' ? key(row) : row[key];
131
+ if (!groups.has(id)) groups.set(id, []);
132
+ groups.get(id).push(row);
133
+ }
134
+ return [...groups].map(([name, items]) => ({ name, ...summarize(items) }));
135
+ }
136
+
137
+ export function detailRows(rows) {
138
+ const keys = ['date', ...DIMENSIONS];
139
+ return groupRows(rows, row => JSON.stringify(keys.map(key => row[key]))).map(({ name, ...totals }) => {
140
+ const values = JSON.parse(name);
141
+ return { ...Object.fromEntries(keys.map((key, i) => [key, values[i]])), ...totals };
142
+ });
143
+ }
144
+
145
+ export function selectData(data, filters) {
146
+ const matches = row => row.date >= filters.from && row.date <= filters.to
147
+ && DIMENSIONS.every(k => !filters[k] || row[k] === filters[k]);
148
+ return {
149
+ buckets: data.buckets.filter(matches),
150
+ // Upstream sessions have no model: suppress instead of inventing a join.
151
+ sessions: filters.model || filters.requestType ? [] : data.sessions.filter(matches),
152
+ };
153
+ }
154
+
155
+ export function dailyRows(rows, from, to, includeZero = false) {
156
+ const days = new Map(groupRows(rows, 'date').map(r => [r.name, { ...r, recorded: true }]));
157
+ if (includeZero) {
158
+ for (let day = from; day <= to; day = shiftDate(day, 1)) {
159
+ if (!days.has(day)) days.set(day, { name: day, ...summarize([]), recorded: false });
160
+ }
161
+ }
162
+ return [...days.values()].sort((a, b) => a.name.localeCompare(b.name));
163
+ }
164
+
165
+ export function percentileRows(days) {
166
+ return [...TOKEN_FIELDS, 'totalTokens', 'allTokens', 'estimatedCost'].map(metric => {
167
+ // Never present partial known-cost subtotals as the distribution of full daily cost.
168
+ const values = days.map(d => d[metric]).filter(v => v != null);
169
+ return { metric, sampleDays: values.length, min: quantile(values, 0), max: quantile(values, 1), values: QUANTILES.map(p => quantile(values, p)) };
170
+ });
171
+ }
172
+
173
+ export function simulateModels(days, modelIds, prices) {
174
+ return [...new Set(modelIds)].sort().map(model => {
175
+ const rate = findRate(model, prices);
176
+ const repriced = days.map(day => priceTokens(day, rate)?.estimatedCost ?? null);
177
+ const complete = rate && repriced.length && repriced.every(v => v != null);
178
+ return { model, rate, sampleDays: days.length, min: complete ? quantile(repriced, 0) : null, max: complete ? quantile(repriced, 1) : null, values: QUANTILES.map(p => complete ? quantile(repriced, p) : null) };
179
+ });
180
+ }
181
+
182
+ export function toCsv(rows, columns) {
183
+ const cell = value => {
184
+ let s = value == null ? '' : String(value);
185
+ if (typeof value === 'string' && /^[\s]*[=+\-@\t\r\n]/.test(s)) s = `'${s}`;
186
+ return `"${s.replaceAll('"', '""')}"`;
187
+ };
188
+ return '\uFEFF' + [columns.map(cell).join(','), ...rows.map(r => columns.map(k => cell(r[k])).join(','))].join('\r\n') + '\r\n';
189
+ }
package/src/collect.js ADDED
@@ -0,0 +1,34 @@
1
+ import { hostname } from 'node:os';
2
+ import { parsers } from '../vendor/vibe-usage/src/parsers/index.js';
3
+ import { normalizeParserResult } from '../vendor/vibe-usage/src/parsers/contract.js';
4
+ import { normalizeData } from './analytics.js';
5
+
6
+ export const SOURCES = Object.keys(parsers);
7
+
8
+ export async function collect({ sources = SOURCES, timeZone, prices, onProgress = () => {} }) {
9
+ const result = { buckets: [], sessions: [], statuses: [] };
10
+ for (const source of sources) {
11
+ if (!Object.hasOwn(parsers, source)) throw new Error(`未知数据源:${source}`);
12
+ onProgress(source);
13
+ try {
14
+ const raw = normalizeParserResult(source, await parsers[source]());
15
+ const data = normalizeData(raw, { timeZone, prices, hostname: hostname() });
16
+ result.buckets.push(...data.buckets);
17
+ result.sessions.push(...data.sessions);
18
+ result.statuses.push({
19
+ source, state: raw.skipped ? 'partial' : data.buckets.length || data.sessions.length ? 'ok' : 'empty',
20
+ buckets: data.buckets.length, sessions: data.sessions.length,
21
+ warningCount: raw.warnings.length,
22
+ // Do not embed arbitrary upstream exception strings or private filesystem paths.
23
+ note: raw.indexing ? 'Codex 索引尚未完成;再次生成报告可从本地缓存继续。'
24
+ : raw.skipped || raw.warnings.length ? '部分记录不可读取或数据源暂不可用;请检查本地文件权限、工具版本或来源登录状态。' : '',
25
+ });
26
+ } catch {
27
+ result.statuses.push({ source, state: 'error', buckets: 0, sessions: 0, warningCount: 1,
28
+ note: '解析失败;请检查数据源版本、文件权限、SQLite / zstd 支持;Cursor 请检查登录状态。' });
29
+ }
30
+ // Let CLI/host process pending cancellation between synchronous legacy parsers.
31
+ await new Promise(resolve => setImmediate(resolve));
32
+ }
33
+ return result;
34
+ }
package/src/network.js ADDED
@@ -0,0 +1,25 @@
1
+ // The only network boundary: retrieve source data, never send collected usage.
2
+ export async function sourceFetch(input, options = {}) {
3
+ if (process.env.PI_USAGE_OFFLINE === '1') throw new Error('离线模式:已禁用数据源网络请求');
4
+ const url = new URL(input);
5
+ const method = (options.method || 'GET').toUpperCase();
6
+ const cursor = url.origin === 'https://cursor.com'
7
+ && url.pathname === '/api/dashboard/export-usage-events-csv'
8
+ && url.search === '?strategy=tokens'
9
+ && method === 'GET' && options.body == null;
10
+ const rpc = url.protocol === 'http:' && url.hostname === '127.0.0.1'
11
+ && /^\/exa\.language_server_pb\.LanguageServerService\/(GetWorkspaceInfos|GetCascadeTrajectory)$/.test(url.pathname)
12
+ && !url.search && method === 'POST';
13
+ if (url.username || url.password || url.hash || (!cursor && !rpc)) {
14
+ throw new Error('已阻止非数据源请求');
15
+ }
16
+ if (rpc) {
17
+ const body = JSON.parse(options.body || '{}');
18
+ const keys = Object.keys(body);
19
+ if (url.pathname.endsWith('/GetWorkspaceInfos') ? keys.length !== 0
20
+ : keys.length !== 1 || typeof body.cascadeId !== 'string') {
21
+ throw new Error('已阻止非只读 RPC 参数');
22
+ }
23
+ }
24
+ return fetch(url, { ...options, redirect: 'error' });
25
+ }
package/src/report.js ADDED
@@ -0,0 +1,43 @@
1
+ import { readFile, writeFile, mkdir, mkdtemp, readdir } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { createHash } from 'node:crypto';
5
+ import { toCsv } from './analytics.js';
6
+
7
+ export const DETAIL_COLUMNS = ['date', 'source', 'model', 'project', 'hostname', 'requestType', 'bucketStart', 'inputTokens', 'cachedInputTokens', 'outputTokens', 'reasoningOutputTokens', 'totalTokens', 'allTokens', 'inputCost', 'cacheCost', 'outputCost', 'reasoningCost', 'estimatedCost'];
8
+ export const SESSION_COLUMNS = ['source', 'project', 'hostname', 'sessionHash', 'firstMessageAt', 'lastMessageAt', 'durationSeconds', 'activeSeconds', 'messageCount', 'userMessageCount'];
9
+
10
+ export async function renderReport(data) {
11
+ const load = path => readFile(new URL(path, import.meta.url), 'utf8');
12
+ const [template, css, analytics, app] = await Promise.all([
13
+ load('../web/report.html'), load('../web/report.css'), load('./analytics.js'), load('../web/report.js'),
14
+ ]);
15
+ const serialized = JSON.stringify(data).replaceAll('<', '\\u003c').replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029');
16
+ const js = `(() => {\n${analytics.replace(/^export /gm, '')}\nconst DATA = ${serialized};\n${app}\n})();`;
17
+ const hash = createHash('sha256').update(js).digest('base64');
18
+ // Hash-authorized script; no network, frames, forms, remote fonts or images.
19
+ const csp = `default-src 'none'; script-src 'sha256-${hash}'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; base-uri 'none'; form-action 'none'; object-src 'none'`;
20
+ return template.replace('<!--CSP-->', () => `<meta http-equiv="Content-Security-Policy" content="${csp}">`)
21
+ .replace('/*STYLE*/', () => css).replace('/*SCRIPT*/', () => js);
22
+ }
23
+
24
+ export async function writeReport(data, out) {
25
+ let directory;
26
+ if (out) {
27
+ directory = resolve(out);
28
+ await mkdir(directory, { recursive: true, mode: 0o700 });
29
+ if ((await readdir(directory)).length) throw new Error('输出目录必须为空,避免覆盖已有报告');
30
+ } else {
31
+ const root = join(homedir(), '.pi', 'usage', 'reports');
32
+ await mkdir(root, { recursive: true, mode: 0o700 });
33
+ directory = await mkdtemp(join(root, 'report-'));
34
+ }
35
+ // Exclusive writes and HTML last: an interrupted run never exposes a finished-looking report.
36
+ const save = (name, content) => writeFile(join(directory, name), content, { flag: 'wx', mode: 0o600 });
37
+ const html = await renderReport(data);
38
+ await save('usage.json', JSON.stringify(data) + '\n');
39
+ await save('details.csv', toCsv(data.buckets, DETAIL_COLUMNS));
40
+ await save('sessions.csv', toCsv(data.sessions, SESSION_COLUMNS));
41
+ await save('index.html', html);
42
+ return join(directory, 'index.html');
43
+ }
@@ -0,0 +1,58 @@
1
+ # Vibe Usage parser attribution
2
+
3
+ Source: https://github.com/vibe-cafe/vibe-usage
4
+ Version: 0.10.21
5
+ Commit: `8f8d88fd70612b3853363eb0bd2ff3ba6ae2ef79`
6
+ License: MIT (declared in upstream package.json and README; that commit has no standalone LICENSE file).
7
+ Attribution: Vibe Usage contributors / vibe-cafe.
8
+
9
+ Only the transitive parser dependency closure is included (46 source files).
10
+ The upload API, sync orchestrator, account configuration, daemon, reset, CLI router and
11
+ server-backed summary are deliberately absent. This is an independent package, not an official VibeCafé client.
12
+
13
+ Local patches:
14
+
15
+ | File | Change |
16
+ |---|---|
17
+ | src/parsers/cursor.js | Fixed cursor.com export URL; use the restricted sourceFetch boundary; reject redirects. |
18
+ | src/parsers/antigravity.js | Route loopback read RPC through the same restricted boundary. |
19
+ | src/parsers/codex-cache.js | Separate PI_USAGE_CACHE_DIR / ~/.pi/usage/cache from the upstream upload client's storage; parser algorithm v4 invalidates pre-classification results and tails. |
20
+ | src/parsers/codex.js | Classify complete per-request response intervals; validate optional completion-ledger usage/IDs; retain unknown for ambiguous/cumulative evidence. Preserve requestType through file and tail aggregation without changing usage/replay accounting. |
21
+ | src/parsers/zcode.js | Join tool parts by message_id using EXISTS; require finish=stop for non-tool responses; retain unknown with missing part schema or malformed evidence. |
22
+ | src/parsers/kimi-code.js | Match current step UUID/turn/usage and legacy completed-step evidence; isolate retry/compaction/subagent output and merge classification across existing message-ID deduplication. |
23
+ | src/parsers/aggregate.js | Preserve requestType in bucket grouping; use collision-safe tuple keys. |
24
+ | src/parsers/pi-session-jsonl.js | Classify response usage by tool evidence / completion; preserve classification across duplicate records. |
25
+ | src/parsers/claude-code.js | Classify response usage; merge tool evidence across content fragments without counting usage again. |
26
+
27
+ `upstream-files.json` preserves original source SHA-256 hashes. Other source files are copied
28
+ unchanged. Retained upstream parser tests use PI_USAGE_CACHE_DIR instead of VIBE_USAGE_CACHE_DIR.
29
+ Upload/sync tests do not apply and are not included. Tests are not in the published tarball.
30
+
31
+ ## Classification evidence
32
+
33
+ - Codex: OpenAI Codex commit `89208f09f819c0ff00c2608a33422a5f6b885c76`, `codex-rs/core/src/session/turn.rs` (`ResponseEvent::Completed`, `drain_in_flight`, `send_token_count_event`) and `session/mod.rs` (`record_observed_response_completed`). Completed output items precede per-response usage; tool results are drained before token_count. The newer token_usage_record is used only as corroborating completion evidence, not added again as billed usage. Compaction/cumulative bookkeeping is not assumed to represent one response.
34
+ - Legacy Kimi: MoonshotAI/kimi-cli commit `86f136422a0aae6b217ea49e7ea1d2e8a1defcd2`, `src/kimi_cli/soul/kimisoul.py`: StepBegin / StepRetry bound an attempt; StatusUpdate.token_usage is emitted after kosong.step returns the response and before awaiting tool results. SubagentEvent is a separate nested stream.
35
+ - Current Kimi: locally verified wire structure: context.append_loop_event step.begin/step.end share UUID, turnId and step; tool.call has stepUuid; step.end.usage matches the following usage.record. No inference from usageScope alone; session-scoped compaction records without this link remain unknown.
36
+ - ZCode: local message/part schema; tool part.message_id links to the assistant message. tool_usage execution metrics are not model token usage.
37
+
38
+ Only synthetic records are retained in regression tests; no private log bodies are vendored.
39
+
40
+ ## MIT permission notice
41
+
42
+ Permission is hereby granted, free of charge, to any person obtaining a copy
43
+ of this software and associated documentation files (the "Software"), to deal
44
+ in the Software without restriction, including without limitation the rights
45
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
46
+ copies of the Software, and to permit persons to whom the Software is
47
+ furnished to do so, subject to the following conditions:
48
+
49
+ The above copyright notice and this permission notice shall be included in all
50
+ copies or substantial portions of the Software.
51
+
52
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
53
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
54
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
55
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
56
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
57
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
58
+ SOFTWARE.
@@ -0,0 +1,85 @@
1
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
2
+ import { delimiter, dirname, join, posix, resolve, win32 } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ function unique(values) {
6
+ return [...new Set(values)];
7
+ }
8
+
9
+ /**
10
+ * Cindy keeps Mainland China and Global installs in separate Electron user-data
11
+ * roots. Scan both because the two editions can be installed side by side.
12
+ */
13
+ export function getCindyDataRoots(
14
+ env = process.env,
15
+ platform = process.platform,
16
+ home = homedir(),
17
+ ) {
18
+ const override = env.VIBE_USAGE_CINDY_DIRS?.trim();
19
+ if (override) {
20
+ return unique(
21
+ override
22
+ .split(delimiter)
23
+ .map((value) => value.trim())
24
+ .filter(Boolean)
25
+ .map((value) => resolve(value)),
26
+ );
27
+ }
28
+
29
+ const pathImpl = platform === 'win32' ? win32 : posix;
30
+ let base;
31
+ if (platform === 'darwin') {
32
+ base = pathImpl.join(home, 'Library', 'Application Support');
33
+ } else if (platform === 'win32') {
34
+ base = env.APPDATA?.trim() || pathImpl.join(home, 'AppData', 'Roaming');
35
+ } else {
36
+ base = env.XDG_CONFIG_HOME?.trim() || pathImpl.join(home, '.config');
37
+ }
38
+ return [pathImpl.join(base, 'CindyGlobal'), pathImpl.join(base, 'Cindy')];
39
+ }
40
+
41
+ function canonicalPath(value) {
42
+ try {
43
+ return realpathSync(value);
44
+ } catch {
45
+ return value;
46
+ }
47
+ }
48
+
49
+ /** Find every active per-owner `cindy-<owner>.db` database. */
50
+ export function findCindyDbPaths(options = {}) {
51
+ const roots = getCindyDataRoots(options.env, options.platform, options.home);
52
+ const paths = [];
53
+
54
+ for (const root of roots) {
55
+ let stat;
56
+ try {
57
+ stat = statSync(root);
58
+ } catch {
59
+ continue;
60
+ }
61
+
62
+ if (stat.isFile()) {
63
+ if (root.endsWith('.db')) paths.push(canonicalPath(root));
64
+ continue;
65
+ }
66
+ if (!stat.isDirectory()) continue;
67
+
68
+ let entries;
69
+ try {
70
+ entries = readdirSync(root, { withFileTypes: true });
71
+ } catch {
72
+ continue;
73
+ }
74
+ for (const entry of entries) {
75
+ if (!entry.isFile() || !/^cindy-.+\.db$/.test(entry.name)) continue;
76
+ paths.push(canonicalPath(join(root, entry.name)));
77
+ }
78
+ }
79
+
80
+ return unique(paths).sort();
81
+ }
82
+
83
+ export function findCindyDataDirs(options = {}) {
84
+ return unique(findCindyDbPaths(options).map(dirname)).filter(existsSync);
85
+ }