token-studio 4.8.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 (139) hide show
  1. package/.nvmrc +1 -0
  2. package/CHANGELOG.md +89 -0
  3. package/Dockerfile +17 -0
  4. package/LICENSE +22 -0
  5. package/NOTICE.md +21 -0
  6. package/PRIVACY.md +68 -0
  7. package/README.en.md +220 -0
  8. package/README.md +220 -0
  9. package/config/collectors.json +54 -0
  10. package/data/.gitkeep +1 -0
  11. package/docker-compose.yml +17 -0
  12. package/docs/assets/.gitkeep +1 -0
  13. package/docs/assets/token-studio-v44-dashboard.png +0 -0
  14. package/docs/assets/token-studio-v44-live.png +0 -0
  15. package/docs/assets/token-studio-v44-review-mobile.png +0 -0
  16. package/docs/assets/token-studio-v44-review.png +0 -0
  17. package/docs/assets/token-studio-v45-dashboard.png +0 -0
  18. package/docs/assets/token-studio-v45-live.png +0 -0
  19. package/docs/assets/token-studio-v45-review-mobile.png +0 -0
  20. package/docs/assets/token-studio-v45-review.png +0 -0
  21. package/docs/blog-case-study.md +34 -0
  22. package/docs/collector-support-matrix.md +65 -0
  23. package/docs/competitive-notes.md +87 -0
  24. package/docs/demo-data/README.md +12 -0
  25. package/docs/demo-data/token-studio-v2-demo.json +146 -0
  26. package/docs/demo-flow.md +39 -0
  27. package/docs/first-run.md +95 -0
  28. package/docs/local-collectors.md +49 -0
  29. package/docs/public-launch-checklist.md +45 -0
  30. package/docs/resume-bullets.md +7 -0
  31. package/docs/statusline.md +52 -0
  32. package/index.html +16 -0
  33. package/package.json +36 -0
  34. package/render.yaml +17 -0
  35. package/src/auto-attribution.mjs +396 -0
  36. package/src/ccusage-bridge.mjs +74 -0
  37. package/src/ccusage-import.mjs +415 -0
  38. package/src/cli.mjs +643 -0
  39. package/src/client/dashboard/App.jsx +1734 -0
  40. package/src/client/dashboard/annotation-presets.js +138 -0
  41. package/src/client/dashboard/attribution.js +328 -0
  42. package/src/client/dashboard/components-charts.jsx +622 -0
  43. package/src/client/dashboard/components-tables.jsx +1531 -0
  44. package/src/client/dashboard/components-top.jsx +307 -0
  45. package/src/client/dashboard/import-budget.js +41 -0
  46. package/src/client/dashboard/model-usage.js +108 -0
  47. package/src/client/dashboard/onboarding.js +80 -0
  48. package/src/client/dashboard/styles.css +2606 -0
  49. package/src/client/live/LiveApp.jsx +226 -0
  50. package/src/client/live/styles.css +446 -0
  51. package/src/client/main.jsx +20 -0
  52. package/src/client/review/ReviewApp.jsx +507 -0
  53. package/src/client/review/closure-progress.js +165 -0
  54. package/src/client/review/markdown-report.js +401 -0
  55. package/src/client/review/model-strategy.js +273 -0
  56. package/src/client/review/roi-advisor.js +255 -0
  57. package/src/client/review/roi-evidence.js +78 -0
  58. package/src/client/review/savings-simulator.js +252 -0
  59. package/src/client/review/sections-1.jsx +277 -0
  60. package/src/client/review/sections-2.jsx +927 -0
  61. package/src/client/review/styles.css +2321 -0
  62. package/src/client/review/utils.js +345 -0
  63. package/src/client/shared/utils.js +236 -0
  64. package/src/closure-check.mjs +537 -0
  65. package/src/closure-import.mjs +646 -0
  66. package/src/collect.mjs +247 -0
  67. package/src/collector-config.mjs +82 -0
  68. package/src/collector-registry.mjs +333 -0
  69. package/src/collectors/claude-code.mjs +355 -0
  70. package/src/collectors/codex.mjs +418 -0
  71. package/src/collectors/copilot.mjs +19 -0
  72. package/src/collectors/cursor.mjs +23 -0
  73. package/src/collectors/gemini.mjs +530 -0
  74. package/src/collectors/goose.mjs +15 -0
  75. package/src/collectors/hermes.mjs +206 -0
  76. package/src/collectors/kimi.mjs +15 -0
  77. package/src/collectors/openclaw.mjs +400 -0
  78. package/src/collectors/opencode.mjs +349 -0
  79. package/src/collectors/qwen.mjs +15 -0
  80. package/src/collectors/structured-usage.mjs +437 -0
  81. package/src/collectors/utils.mjs +93 -0
  82. package/src/db.mjs +1397 -0
  83. package/src/demo-seed.mjs +39 -0
  84. package/src/dev.mjs +43 -0
  85. package/src/live.mjs +428 -0
  86. package/src/model-policy.mjs +147 -0
  87. package/src/pricing.mjs +434 -0
  88. package/src/privacy-check.mjs +126 -0
  89. package/src/server.mjs +1240 -0
  90. package/src/source-health.mjs +195 -0
  91. package/src/statusline.mjs +156 -0
  92. package/src/terminal-report.mjs +245 -0
  93. package/src/update-pricing.mjs +8 -0
  94. package/test/annotation-presets.test.mjs +137 -0
  95. package/test/api-annotations.test.mjs +202 -0
  96. package/test/api-auto-attribution.test.mjs +169 -0
  97. package/test/api-source-health.test.mjs +109 -0
  98. package/test/api-v2.test.mjs +278 -0
  99. package/test/api-v43.test.mjs +151 -0
  100. package/test/api-v44.test.mjs +128 -0
  101. package/test/attribution-summary.test.mjs +164 -0
  102. package/test/auto-attribution.test.mjs +116 -0
  103. package/test/ccusage-bridge.test.mjs +36 -0
  104. package/test/ccusage-import.test.mjs +93 -0
  105. package/test/cli-v43.test.mjs +64 -0
  106. package/test/cli-v45.test.mjs +34 -0
  107. package/test/cli-v46.test.mjs +129 -0
  108. package/test/cli-v47.test.mjs +98 -0
  109. package/test/closure-check.test.mjs +202 -0
  110. package/test/closure-import.test.mjs +263 -0
  111. package/test/collector-config.test.mjs +25 -0
  112. package/test/collector-registry.test.mjs +56 -0
  113. package/test/csv.test.mjs +19 -0
  114. package/test/db-annotations.test.mjs +186 -0
  115. package/test/db-v2.test.mjs +200 -0
  116. package/test/db-v4.test.mjs +178 -0
  117. package/test/experimental-collectors.test.mjs +103 -0
  118. package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
  119. package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
  120. package/test/fixtures/collectors/goose/usage.jsonl +2 -0
  121. package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
  122. package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
  123. package/test/import-budget.test.mjs +40 -0
  124. package/test/live.test.mjs +256 -0
  125. package/test/markdown-report.test.mjs +193 -0
  126. package/test/model-policy.test.mjs +34 -0
  127. package/test/model-strategy.test.mjs +116 -0
  128. package/test/model-usage.test.mjs +99 -0
  129. package/test/official-pricing.test.mjs +70 -0
  130. package/test/onboarding.test.mjs +55 -0
  131. package/test/privacy-check.test.mjs +33 -0
  132. package/test/review-closure-progress.test.mjs +99 -0
  133. package/test/roi-advisor.test.mjs +188 -0
  134. package/test/roi-evidence.test.mjs +48 -0
  135. package/test/roi-summary.test.mjs +101 -0
  136. package/test/savings-simulator.test.mjs +141 -0
  137. package/test/source-health.test.mjs +62 -0
  138. package/test/statusline.test.mjs +148 -0
  139. package/vite.config.js +23 -0
@@ -0,0 +1,195 @@
1
+ const SOURCE_MATCHERS = {
2
+ claude: ['claude', 'anthropic'],
3
+ codex: ['codex', 'openai'],
4
+ gemini: ['gemini'],
5
+ opencode: ['opencode', 'open code'],
6
+ openclaw: ['openclaw', 'clawdbot', 'moltbot', 'moldbot'],
7
+ hermes: ['hermes'],
8
+ cursor: ['cursor'],
9
+ copilot: ['copilot'],
10
+ qwen: ['qwen'],
11
+ kimi: ['kimi', 'moonshot'],
12
+ goose: ['goose'],
13
+ ccusage: ['import:ccusage', 'ccusage']
14
+ };
15
+
16
+ export function buildSourceHealth({
17
+ collectors = [],
18
+ dailyRows = [],
19
+ sessionRows = [],
20
+ eventRows = [],
21
+ runs = []
22
+ } = {}) {
23
+ const daily = groupSourceRows(dailyRows, 'dailyRows');
24
+ const sessions = groupSourceRows(sessionRows, 'sessions');
25
+ const events = groupSourceRows(eventRows, 'tokenEvents');
26
+ const latestRuns = latestRunBySource(runs);
27
+
28
+ return collectors.map(collector => {
29
+ const matchedKeys = matchingSourceKeys(collector, [
30
+ ...daily.keys(),
31
+ ...sessions.keys(),
32
+ ...events.keys(),
33
+ ...latestRuns.keys()
34
+ ]);
35
+ const stats = mergeStats(matchedKeys, { daily, sessions, events });
36
+ const run = firstRun(matchedKeys, latestRuns);
37
+ const lastSeenAt = latestDate([
38
+ stats.latestDailyAt,
39
+ stats.latestSessionAt,
40
+ stats.latestEventAt,
41
+ run?.collectedAt
42
+ ]);
43
+
44
+ return {
45
+ id: collector.id,
46
+ label: collector.label,
47
+ supportStatus: collector.supportStatus,
48
+ coverageTier: coverageTier(collector.supportStatus),
49
+ privacyLevel: collector.privacyLevel,
50
+ defaultEnabled: Boolean(collector.defaultEnabled),
51
+ detected: Boolean(collector.detected),
52
+ detectedRootCount: Array.isArray(collector.existingRoots) ? collector.existingRoots.length : 0,
53
+ configuredRootCount: Array.isArray(collector.configuredRoots) ? collector.configuredRoots.length : 0,
54
+ readsConversationContent: Boolean(collector.readsConversationContent),
55
+ tokenReliability: collector.tokenReliability || 'unknown',
56
+ fixtureBacked: Boolean(collector.fixtureBacked),
57
+ auditRecommended: Boolean(collector.auditRecommended),
58
+ dataFields: collector.dataFields || [],
59
+ workflow: workflowFor(collector),
60
+ commandHint: commandHintFor(collector),
61
+ matchedSources: matchedKeys,
62
+ dailyRows: stats.dailyRows,
63
+ sessions: stats.sessions,
64
+ tokenEvents: stats.tokenEvents,
65
+ totalTokens: stats.totalTokens,
66
+ lastSeenAt,
67
+ lastRunStatus: run?.status || null,
68
+ lastRunAt: run?.collectedAt || null,
69
+ health: healthStatus({ collector, stats, run, lastSeenAt }),
70
+ note: collector.note || null
71
+ };
72
+ });
73
+ }
74
+
75
+ function groupSourceRows(rows, countField) {
76
+ const map = new Map();
77
+ for (const row of rows) {
78
+ const key = normalizeKey(row.source);
79
+ if (!key) continue;
80
+ const current = map.get(key) || emptyStats();
81
+ current[countField] += Number(row.count ?? row[countField] ?? 0);
82
+ current.totalTokens += Number(row.totalTokens || 0);
83
+ if (row.latestDailyAt) current.latestDailyAt = latestDate([current.latestDailyAt, row.latestDailyAt]);
84
+ if (row.latestSessionAt) current.latestSessionAt = latestDate([current.latestSessionAt, row.latestSessionAt]);
85
+ if (row.latestEventAt) current.latestEventAt = latestDate([current.latestEventAt, row.latestEventAt]);
86
+ map.set(key, current);
87
+ }
88
+ return map;
89
+ }
90
+
91
+ function latestRunBySource(runs) {
92
+ const map = new Map();
93
+ for (const run of runs) {
94
+ const key = normalizeKey(run.source);
95
+ if (!key) continue;
96
+ const existing = map.get(key);
97
+ if (!existing || new Date(run.collectedAt || 0) > new Date(existing.collectedAt || 0)) {
98
+ map.set(key, {
99
+ source: run.source,
100
+ status: run.status || null,
101
+ collectedAt: run.collectedAt || null
102
+ });
103
+ }
104
+ }
105
+ return map;
106
+ }
107
+
108
+ function matchingSourceKeys(collector, keys) {
109
+ const unique = [...new Set(keys)].filter(Boolean);
110
+ const patterns = SOURCE_MATCHERS[collector.id] || [collector.id, collector.label];
111
+ const normalizedPatterns = patterns.map(normalizeKey).filter(Boolean);
112
+ return unique.filter(key => normalizedPatterns.some(pattern => key.includes(pattern) || pattern.includes(key)));
113
+ }
114
+
115
+ function mergeStats(keys, groups) {
116
+ const merged = emptyStats();
117
+ for (const key of keys) {
118
+ for (const group of Object.values(groups)) {
119
+ const row = group.get(key);
120
+ if (!row) continue;
121
+ merged.dailyRows += row.dailyRows;
122
+ merged.sessions += row.sessions;
123
+ merged.tokenEvents += row.tokenEvents;
124
+ merged.totalTokens += row.totalTokens;
125
+ merged.latestDailyAt = latestDate([merged.latestDailyAt, row.latestDailyAt]);
126
+ merged.latestSessionAt = latestDate([merged.latestSessionAt, row.latestSessionAt]);
127
+ merged.latestEventAt = latestDate([merged.latestEventAt, row.latestEventAt]);
128
+ }
129
+ }
130
+ return merged;
131
+ }
132
+
133
+ function firstRun(keys, latestRuns) {
134
+ return keys
135
+ .map(key => latestRuns.get(key))
136
+ .filter(Boolean)
137
+ .sort((a, b) => new Date(b.collectedAt || 0) - new Date(a.collectedAt || 0))[0] || null;
138
+ }
139
+
140
+ function emptyStats() {
141
+ return {
142
+ dailyRows: 0,
143
+ sessions: 0,
144
+ tokenEvents: 0,
145
+ totalTokens: 0,
146
+ latestDailyAt: null,
147
+ latestSessionAt: null,
148
+ latestEventAt: null
149
+ };
150
+ }
151
+
152
+ function coverageTier(status) {
153
+ if (status === 'stable') return 'native stable';
154
+ if (status === 'experimental') return 'experimental';
155
+ if (status === 'import-only') return 'ccusage import-bridge';
156
+ return 'detected-only';
157
+ }
158
+
159
+ function workflowFor(collector) {
160
+ if (collector.supportStatus === 'stable') return 'Native collector can write structured token usage after explicit collect.';
161
+ if (collector.supportStatus === 'experimental') return 'Run collector audit first; only explicit token fields are eligible.';
162
+ if (collector.supportStatus === 'import-only') return 'Import saved ccusage JSON or run explicit ccusage CLI bridge from terminal.';
163
+ return 'Presence detection only; no usage rows are written.';
164
+ }
165
+
166
+ function commandHintFor(collector) {
167
+ if (collector.id === 'ccusage') {
168
+ return 'npx token-studio import-usage --format=ccusage-cli --report=session --dry-run --yes';
169
+ }
170
+ if (collector.supportStatus === 'stable') return `npx token-studio collect --sources=${collector.id}`;
171
+ if (collector.supportStatus === 'experimental') return 'npx token-studio collectors --audit --json';
172
+ return 'npx token-studio collectors --json';
173
+ }
174
+
175
+ function healthStatus({ collector, stats, run, lastSeenAt }) {
176
+ if (stats.sessions || stats.tokenEvents || stats.dailyRows) return 'has-data';
177
+ if (run?.status === 'error') return 'last-run-error';
178
+ if (collector.detected) return 'detected-no-data';
179
+ if (collector.supportStatus === 'import-only') return 'import-ready';
180
+ if (lastSeenAt) return 'seen';
181
+ return 'not-detected';
182
+ }
183
+
184
+ function latestDate(values) {
185
+ const dates = values
186
+ .filter(Boolean)
187
+ .map(value => new Date(value))
188
+ .filter(date => Number.isFinite(date.getTime()))
189
+ .sort((a, b) => b.getTime() - a.getTime());
190
+ return dates[0]?.toISOString() || null;
191
+ }
192
+
193
+ function normalizeKey(value) {
194
+ return String(value || '').trim().toLowerCase();
195
+ }
@@ -0,0 +1,156 @@
1
+ import { listAdvisorActions, listBudgetProfiles, listTokenEvents } from './db.mjs';
2
+ import { buildLiveSnapshot } from './live.mjs';
3
+
4
+ export function buildStatuslineSnapshot(db, {
5
+ now = new Date(),
6
+ windowMinutes = 15,
7
+ source = 'all'
8
+ } = {}) {
9
+ const sourceFilter = normalizeSourceFilter(source);
10
+ const tokenEvents = listTokenEvents(db, { limit: 5000 })
11
+ .filter(event => sourceMatches(event.source, sourceFilter));
12
+ const budgetProfiles = listBudgetProfiles(db)
13
+ .filter(profile => profile.enabled)
14
+ .filter(profile => sourceFilter === 'all' || !profile.source || sourceMatches(profile.source, sourceFilter));
15
+ const live = buildLiveSnapshot({
16
+ tokenEvents,
17
+ budgetProfiles,
18
+ now,
19
+ windowMinutes
20
+ });
21
+ const openAdvisorActions = listAdvisorActions(db)
22
+ .filter(action => action.status === 'open')
23
+ .length;
24
+ const highestBudgetShare = Math.max(0, ...live.budgetWindows.map(window => Math.max(
25
+ Number(window.tokenShare || 0),
26
+ Number(window.costShare || 0)
27
+ )));
28
+ const resetInMinutes = live.budgetWindows.length
29
+ ? Math.min(...live.budgetWindows.map(window => Number(window.resetInMinutes || 0)))
30
+ : null;
31
+
32
+ return {
33
+ generatedAt: live.generatedAt,
34
+ source: sourceFilter,
35
+ windowMinutes: live.windowMinutes,
36
+ status: live.status,
37
+ totals: {
38
+ totalTokens: live.totals.totalTokens,
39
+ burnRateTokensPerHour: live.totals.burnRateTokensPerHour,
40
+ cacheHitRate: live.totals.cacheHitRate,
41
+ costUSD: live.totals.costUSD
42
+ },
43
+ budget: {
44
+ windows: live.budgetWindows,
45
+ status: budgetStatus(live.budgetWindows),
46
+ highestShare: highestBudgetShare,
47
+ resetInMinutes
48
+ },
49
+ warnings: live.warnings.map(warning => ({
50
+ type: warning.type,
51
+ level: warning.level,
52
+ message: warning.message,
53
+ evidence: warning.evidence,
54
+ action: warning.action
55
+ })),
56
+ openAdvisorActions
57
+ };
58
+ }
59
+
60
+ export function buildEmptyStatuslineSnapshot({
61
+ now = new Date(),
62
+ windowMinutes = 15,
63
+ source = 'all',
64
+ warning = null
65
+ } = {}) {
66
+ const sourceFilter = normalizeSourceFilter(source);
67
+ return {
68
+ generatedAt: new Date(now).toISOString(),
69
+ source: sourceFilter,
70
+ windowMinutes,
71
+ status: 'missing-db',
72
+ totals: {
73
+ totalTokens: 0,
74
+ burnRateTokensPerHour: 0,
75
+ cacheHitRate: 0,
76
+ costUSD: 0
77
+ },
78
+ budget: {
79
+ windows: [],
80
+ status: 'none',
81
+ highestShare: 0,
82
+ resetInMinutes: null
83
+ },
84
+ warnings: warning ? [{
85
+ type: 'missing-db',
86
+ level: 'low',
87
+ message: warning,
88
+ evidence: 'No local SQLite database was available.',
89
+ action: 'Run demo, import ccusage JSON, or start real collection explicitly before using statusline data.'
90
+ }] : [],
91
+ openAdvisorActions: 0
92
+ };
93
+ }
94
+
95
+ export function formatStatuslineText(snapshot, { maxWidth = 100 } = {}) {
96
+ const totals = snapshot.totals || {};
97
+ const budget = snapshot.budget || {};
98
+ const warningTypes = (snapshot.warnings || []).map(warning => warning.type);
99
+ const warn = warningTypes.includes('missing-db') ? 'no-db'
100
+ : warningTypes.includes('unpriced-model-active') ? 'unpriced'
101
+ : warningTypes.length ? warningTypes[0] : 'ok';
102
+ const reset = budget.resetInMinutes == null ? '-' : `${Math.round(budget.resetInMinutes)}m`;
103
+ const budgetText = budget.windows?.length
104
+ ? `${budget.status}:${Math.round(Number(budget.highestShare || 0) * 100)}%`
105
+ : 'none';
106
+ const text = [
107
+ 'TS',
108
+ `tok=${compactInt(totals.totalTokens)}`,
109
+ `burn=${compactInt(totals.burnRateTokensPerHour)}/h`,
110
+ `cache=${Number(totals.cacheHitRate || 0).toFixed(0)}%`,
111
+ `actions=${snapshot.openAdvisorActions || 0}`,
112
+ `budget=${budgetText}`,
113
+ `reset=${reset}`,
114
+ `warn=${warn}`
115
+ ].join(' ');
116
+ return truncateStatusline(text, maxWidth);
117
+ }
118
+
119
+ function budgetStatus(windows) {
120
+ if (windows.some(window => window.status === 'exceeded')) return 'exceeded';
121
+ if (windows.some(window => window.status === 'over-pace')) return 'over-pace';
122
+ if (windows.some(window => window.status === 'near-limit')) return 'near-limit';
123
+ if (windows.length) return 'ok';
124
+ return 'none';
125
+ }
126
+
127
+ function normalizeSourceFilter(source) {
128
+ const value = String(source || 'all').trim().toLowerCase();
129
+ if (['all', 'claude', 'codex'].includes(value)) return value;
130
+ throw new Error('--source must be all, claude, or codex');
131
+ }
132
+
133
+ function sourceMatches(source, filter) {
134
+ if (filter === 'all') return true;
135
+ const value = String(source || '').toLowerCase();
136
+ if (filter === 'claude') return value.includes('claude') || value.includes('anthropic');
137
+ if (filter === 'codex') return value.includes('codex') || value.includes('openai');
138
+ return false;
139
+ }
140
+
141
+ function compactInt(value) {
142
+ const number = Math.round(Number(value || 0));
143
+ if (number >= 1_000_000) return `${trimFixed(number / 1_000_000)}M`;
144
+ if (number >= 1_000) return `${trimFixed(number / 1_000)}k`;
145
+ return String(number);
146
+ }
147
+
148
+ function trimFixed(value) {
149
+ return value.toFixed(value >= 10 ? 0 : 1).replace(/\.0$/, '');
150
+ }
151
+
152
+ function truncateStatusline(text, maxWidth) {
153
+ const width = Math.max(40, Number(maxWidth) || 100);
154
+ if (text.length <= width) return text;
155
+ return `${text.slice(0, Math.max(0, width - 3)).trimEnd()}...`;
156
+ }
@@ -0,0 +1,245 @@
1
+ import { listAdvisorActions, listBudgetProfiles } from './db.mjs';
2
+ import { buildLiveSnapshot } from './live.mjs';
3
+
4
+ export function buildTerminalReport(db, { period = 'week', now = new Date() } = {}) {
5
+ const range = periodRange(period, now);
6
+ const daily = queryDaily(db, range);
7
+ const sessions = querySessions(db, range);
8
+ const tokenEvents = queryTokenEvents(db);
9
+ const budgetProfiles = listBudgetProfiles(db).filter(profile => profile.enabled);
10
+ const live = buildLiveSnapshot({ sessions, tokenEvents, budgetProfiles, now });
11
+ const advisorActions = listAdvisorActions(db, {
12
+ periodStart: range.start,
13
+ periodEnd: range.end
14
+ });
15
+ const totals = sumDaily(daily);
16
+ return {
17
+ period: range,
18
+ totals,
19
+ topProjects: topProjects(sessions).slice(0, 8),
20
+ topModels: topModels(daily).slice(0, 8),
21
+ budgetWindows: live.budgetWindows || [],
22
+ budgetWarnings: (live.warnings || []).filter(warning => warning.type?.includes('budget')),
23
+ advisorActions: advisorActions.slice(0, 8)
24
+ };
25
+ }
26
+
27
+ export function formatTerminalReport(report, format = 'table') {
28
+ if (format === 'json') return JSON.stringify(report, null, 2);
29
+ if (format === 'markdown') return markdownReport(report);
30
+ return tableReport(report);
31
+ }
32
+
33
+ function queryDaily(db, range) {
34
+ if (range.id === 'all') {
35
+ return db.prepare(`
36
+ SELECT device, source, usage_date AS usageDate, model,
37
+ input_tokens AS inputTokens,
38
+ output_tokens AS outputTokens,
39
+ cache_creation_tokens AS cacheCreationTokens,
40
+ cache_read_tokens AS cacheReadTokens,
41
+ reasoning_output_tokens AS reasoningOutputTokens,
42
+ total_tokens AS totalTokens,
43
+ cost_usd AS costUSD
44
+ FROM daily_usage
45
+ `).all();
46
+ }
47
+ return db.prepare(`
48
+ SELECT device, source, usage_date AS usageDate, model,
49
+ input_tokens AS inputTokens,
50
+ output_tokens AS outputTokens,
51
+ cache_creation_tokens AS cacheCreationTokens,
52
+ cache_read_tokens AS cacheReadTokens,
53
+ reasoning_output_tokens AS reasoningOutputTokens,
54
+ total_tokens AS totalTokens,
55
+ cost_usd AS costUSD
56
+ FROM daily_usage
57
+ WHERE usage_date >= ? AND usage_date <= ?
58
+ `).all(range.start, range.end);
59
+ }
60
+
61
+ function querySessions(db, range) {
62
+ const base = `
63
+ SELECT device, source, session_id AS sessionId,
64
+ last_activity AS lastActivity,
65
+ project_path AS projectPath,
66
+ input_tokens AS inputTokens,
67
+ output_tokens AS outputTokens,
68
+ cache_creation_tokens AS cacheCreationTokens,
69
+ cache_read_tokens AS cacheReadTokens,
70
+ reasoning_output_tokens AS reasoningOutputTokens,
71
+ total_tokens AS totalTokens,
72
+ cost_usd AS costUSD
73
+ FROM session_usage
74
+ `;
75
+ if (range.id === 'all') return db.prepare(base).all();
76
+ return db.prepare(`${base} WHERE substr(last_activity, 1, 10) >= ? AND substr(last_activity, 1, 10) <= ?`)
77
+ .all(range.start, range.end);
78
+ }
79
+
80
+ function queryTokenEvents(db) {
81
+ return db.prepare(`
82
+ SELECT event_id AS eventId, device, source, session_id AS sessionId,
83
+ timestamp, model,
84
+ input_tokens AS inputTokens,
85
+ output_tokens AS outputTokens,
86
+ cache_read_tokens AS cacheReadTokens,
87
+ cache_creation_tokens AS cacheCreationTokens,
88
+ reasoning_tokens AS reasoningTokens,
89
+ tool_category AS toolCategory,
90
+ file_extension AS fileExtension
91
+ FROM token_events
92
+ ORDER BY timestamp DESC
93
+ LIMIT 5000
94
+ `).all();
95
+ }
96
+
97
+ function periodRange(period, now) {
98
+ const id = String(period || 'week').toLowerCase();
99
+ const endDate = new Date(now);
100
+ const startDate = new Date(now);
101
+ if (id === 'month') startDate.setUTCDate(endDate.getUTCDate() - 29);
102
+ else if (id === '90d') startDate.setUTCDate(endDate.getUTCDate() - 89);
103
+ else if (id === 'all') return { id: 'all', label: 'all', start: '', end: '' };
104
+ else startDate.setUTCDate(endDate.getUTCDate() - 6);
105
+ return { id, label: id, start: formatDate(startDate), end: formatDate(endDate) };
106
+ }
107
+
108
+ function sumDaily(rows) {
109
+ return rows.reduce((acc, row) => {
110
+ acc.totalTokens += row.totalTokens || 0;
111
+ acc.inputTokens += row.inputTokens || 0;
112
+ acc.outputTokens += row.outputTokens || 0;
113
+ acc.cacheReadTokens += row.cacheReadTokens || 0;
114
+ acc.cacheCreationTokens += row.cacheCreationTokens || 0;
115
+ acc.reasoningOutputTokens += row.reasoningOutputTokens || 0;
116
+ acc.costUSD += row.costUSD || 0;
117
+ return acc;
118
+ }, {
119
+ totalTokens: 0,
120
+ inputTokens: 0,
121
+ outputTokens: 0,
122
+ cacheReadTokens: 0,
123
+ cacheCreationTokens: 0,
124
+ reasoningOutputTokens: 0,
125
+ costUSD: 0
126
+ });
127
+ }
128
+
129
+ function topProjects(rows) {
130
+ const map = new Map();
131
+ for (const row of rows) {
132
+ const key = row.projectPath || '<unknown>';
133
+ const acc = map.get(key) || { project: key, sessions: 0, totalTokens: 0, costUSD: 0 };
134
+ acc.sessions += 1;
135
+ acc.totalTokens += row.totalTokens || 0;
136
+ acc.costUSD += row.costUSD || 0;
137
+ map.set(key, acc);
138
+ }
139
+ return [...map.values()].sort((a, b) => b.totalTokens - a.totalTokens);
140
+ }
141
+
142
+ function topModels(rows) {
143
+ const map = new Map();
144
+ for (const row of rows) {
145
+ const key = `${row.model || '<unknown>'}::${row.source || 'unknown'}`;
146
+ const acc = map.get(key) || { model: row.model || '<unknown>', source: row.source || 'unknown', totalTokens: 0, costUSD: 0 };
147
+ acc.totalTokens += row.totalTokens || 0;
148
+ acc.costUSD += row.costUSD || 0;
149
+ map.set(key, acc);
150
+ }
151
+ return [...map.values()].sort((a, b) => b.totalTokens - a.totalTokens);
152
+ }
153
+
154
+ function tableReport(report) {
155
+ return [
156
+ `Token Studio ROI Report (${report.period.id === 'all' ? 'all' : `${report.period.start}..${report.period.end}`})`,
157
+ '',
158
+ `tokens=${formatInt(report.totals.totalTokens)} official_price=${money(report.totals.costUSD)}`,
159
+ '',
160
+ 'Top projects',
161
+ ...report.topProjects.slice(0, 5).map(row => `- ${row.project}: ${formatInt(row.totalTokens)} tokens, ${money(row.costUSD)}`),
162
+ '',
163
+ 'Top models',
164
+ ...report.topModels.slice(0, 5).map(row => `- ${row.model} (${row.source}): ${formatInt(row.totalTokens)} tokens, ${money(row.costUSD)}`),
165
+ '',
166
+ 'Budget risks',
167
+ ...(report.budgetWarnings.length ? report.budgetWarnings.map(w => `- ${w.level}: ${w.message} — ${w.evidence}`) : ['- none']),
168
+ '',
169
+ 'Advisor actions',
170
+ ...(report.advisorActions.length ? report.advisorActions.map(a => `- [${a.status}] ${a.title}: ${a.action}`) : ['- none'])
171
+ ].join('\n');
172
+ }
173
+
174
+ function markdownReport(report) {
175
+ return [
176
+ '# Token Studio ROI Terminal Report',
177
+ '',
178
+ `- Period: ${safe(report.period.id === 'all' ? 'all' : `${report.period.start}..${report.period.end}`)}`,
179
+ `- Total tokens: ${formatInt(report.totals.totalTokens)}`,
180
+ `- Official price conversion: ${money(report.totals.costUSD)}`,
181
+ '',
182
+ '## Top Projects',
183
+ '',
184
+ report.topProjects.length ? mdTable(
185
+ ['Project', 'Sessions', 'Tokens', 'Official Price'],
186
+ report.topProjects.slice(0, 8).map(row => [row.project, row.sessions, formatInt(row.totalTokens), money(row.costUSD)])
187
+ ) : 'No project data.',
188
+ '',
189
+ '## Top Models',
190
+ '',
191
+ report.topModels.length ? mdTable(
192
+ ['Model', 'Source', 'Tokens', 'Official Price'],
193
+ report.topModels.slice(0, 8).map(row => [row.model, row.source, formatInt(row.totalTokens), money(row.costUSD)])
194
+ ) : 'No model data.',
195
+ '',
196
+ '## Budget Risks',
197
+ '',
198
+ report.budgetWarnings.length ? report.budgetWarnings.map(w => `- **${safe(w.level)}** ${safe(w.message)}: ${safe(w.evidence)}. ${safe(w.action)}`).join('\n') : '- No active budget risk.',
199
+ '',
200
+ '## Advisor Actions',
201
+ '',
202
+ report.advisorActions.length ? mdTable(
203
+ ['Status', 'Category', 'Title', 'Action'],
204
+ report.advisorActions.map(row => [row.status, row.category, row.title, row.action])
205
+ ) : 'No advisor actions for this period.',
206
+ '',
207
+ '## Notes',
208
+ '',
209
+ '- Costs are official public token-price conversions, not provider invoices.',
210
+ '- Completed actions are tracked as review workflow state; they do not prove causal savings.'
211
+ ].join('\n');
212
+ }
213
+
214
+ function mdTable(headers, rows) {
215
+ return [
216
+ `| ${headers.map(cell).join(' | ')} |`,
217
+ `| ${headers.map(() => '---').join(' | ')} |`,
218
+ ...rows.map(row => `| ${row.map(cell).join(' | ')} |`)
219
+ ].join('\n');
220
+ }
221
+
222
+ function cell(value) {
223
+ const text = safe(value);
224
+ return (/^[=+\-@\t\r]/.test(text) ? `'${text}` : text).replace(/\|/g, '\\|');
225
+ }
226
+
227
+ function safe(value) {
228
+ return String(value ?? '').replace(/\r?\n/g, ' ').replace(/\s+/g, ' ').trim();
229
+ }
230
+
231
+ function formatDate(date) {
232
+ return [
233
+ date.getUTCFullYear(),
234
+ String(date.getUTCMonth() + 1).padStart(2, '0'),
235
+ String(date.getUTCDate()).padStart(2, '0')
236
+ ].join('-');
237
+ }
238
+
239
+ function formatInt(value) {
240
+ return Math.round(Number(value || 0)).toLocaleString('en-US');
241
+ }
242
+
243
+ function money(value) {
244
+ return `$${Number(value || 0).toFixed(4)}`;
245
+ }
@@ -0,0 +1,8 @@
1
+ import { resolve } from 'node:path';
2
+ import { loadPricing } from './pricing.mjs';
3
+
4
+ process.env.PRICING_REFRESH = '1';
5
+
6
+ const pricingCachePath = resolve(process.cwd(), 'data', 'official-pricing.json');
7
+ const pricing = await loadPricing(pricingCachePath);
8
+ console.log(`[pricing] official-docs verifiedAt=${pricing.verifiedAt} models=${pricing.models.length}`);