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,141 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { buildSavingsSimulation } from '../src/client/review/savings-simulator.js';
4
+ import { buildMarkdownReviewReport } from '../src/client/review/markdown-report.js';
5
+
6
+ const period = {
7
+ pretty: '2026 年 6 月',
8
+ start: '2026-06-01',
9
+ end: '2026-06-17'
10
+ };
11
+
12
+ test('savings simulator suggests downgrading heavy exploration and testing work', () => {
13
+ const simulation = buildSavingsSimulation({
14
+ sessions: [{
15
+ sessionId: 'heavy-test',
16
+ source: 'Codex CLI',
17
+ model: 'gpt-5.5',
18
+ pricingStatus: 'priced',
19
+ workPurpose: '测试验证',
20
+ workStage: '验证',
21
+ valueLevel: '中',
22
+ outputStatus: '进行中',
23
+ inputTokens: 900_000,
24
+ outputTokens: 80_000,
25
+ totalTokens: 980_000,
26
+ costUSD: 6
27
+ }]
28
+ });
29
+
30
+ assert.equal(simulation.suggestions.length, 1);
31
+ assert.equal(simulation.suggestions[0].suggestedTier, 'light');
32
+ assert.ok(simulation.suggestions[0].savingsUSD > 0);
33
+ assert.match(simulation.suggestions[0].why, /方向|试错|重模型|最高成本|高单价/);
34
+ });
35
+
36
+ test('savings simulator does not downgrade high-value published work', () => {
37
+ const simulation = buildSavingsSimulation({
38
+ sessions: [{
39
+ sessionId: 'published',
40
+ source: 'Claude Code',
41
+ model: 'claude-opus-4-8',
42
+ pricingStatus: 'priced',
43
+ workPurpose: '功能开发',
44
+ workStage: '发布',
45
+ valueLevel: '高',
46
+ outputStatus: '已发布',
47
+ inputTokens: 500_000,
48
+ outputTokens: 60_000,
49
+ totalTokens: 560_000,
50
+ costUSD: 4
51
+ }]
52
+ });
53
+
54
+ assert.deepEqual(simulation.suggestions, []);
55
+ });
56
+
57
+ test('savings simulator excludes unpriced models from dollar savings', () => {
58
+ const simulation = buildSavingsSimulation({
59
+ sessions: [{
60
+ sessionId: 'spark',
61
+ source: 'Codex CLI',
62
+ model: 'gpt-5.3-codex-spark',
63
+ pricingStatus: 'unpriced',
64
+ pricingReason: 'research preview without official USD price',
65
+ workPurpose: '测试验证',
66
+ workStage: '探索',
67
+ valueLevel: '低',
68
+ outputStatus: '已废弃',
69
+ inputTokens: 500_000,
70
+ outputTokens: 10_000,
71
+ totalTokens: 510_000,
72
+ costUSD: 0
73
+ }]
74
+ });
75
+
76
+ assert.deepEqual(simulation.suggestions, []);
77
+ assert.equal(simulation.unpriced.sessionCount, 1);
78
+ assert.equal(simulation.unpriced.models[0], 'gpt-5.3-codex-spark');
79
+ });
80
+
81
+ test('savings simulator ranks low-value abandoned high-cost sessions first', () => {
82
+ const simulation = buildSavingsSimulation({
83
+ sessions: [
84
+ {
85
+ sessionId: 'explore',
86
+ source: 'Claude Code',
87
+ model: 'claude-opus-4-8',
88
+ pricingStatus: 'priced',
89
+ workPurpose: '技术调研',
90
+ workStage: '探索',
91
+ valueLevel: '中',
92
+ outputStatus: '进行中',
93
+ inputTokens: 100_000,
94
+ outputTokens: 20_000,
95
+ totalTokens: 120_000,
96
+ costUSD: 1
97
+ },
98
+ {
99
+ sessionId: 'waste',
100
+ source: 'Claude Code',
101
+ model: 'claude-opus-4-8',
102
+ pricingStatus: 'priced',
103
+ workPurpose: '功能开发',
104
+ workStage: '实现',
105
+ valueLevel: '低',
106
+ outputStatus: '已废弃',
107
+ inputTokens: 1_000_000,
108
+ outputTokens: 50_000,
109
+ totalTokens: 1_050_000,
110
+ costUSD: 8
111
+ }
112
+ ]
113
+ });
114
+
115
+ assert.match(simulation.suggestions[0].id, /low-value/);
116
+ assert.ok(simulation.suggestions[0].savingsUSD > simulation.suggestions.at(-1).savingsUSD);
117
+ });
118
+
119
+ test('markdown report includes savings simulation without invoice wording', () => {
120
+ const savingsSimulation = buildSavingsSimulation({
121
+ sessions: [{
122
+ sessionId: 'heavy-test',
123
+ source: 'Codex CLI',
124
+ model: 'gpt-5.5',
125
+ pricingStatus: 'priced',
126
+ workPurpose: '测试验证',
127
+ workStage: '验证',
128
+ valueLevel: '中',
129
+ outputStatus: '进行中',
130
+ inputTokens: 900_000,
131
+ outputTokens: 80_000,
132
+ totalTokens: 980_000,
133
+ costUSD: 6
134
+ }]
135
+ });
136
+ const report = buildMarkdownReviewReport({ period, savingsSimulation });
137
+
138
+ assert.match(report, /## 6\. 节省模拟/);
139
+ assert.match(report, /官方价换算节省模拟只用于比较模型策略,不是供应商账单/);
140
+ assert.match(report, /不承诺真实账单节省/);
141
+ });
@@ -0,0 +1,62 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { buildSourceHealth } from '../src/source-health.mjs';
4
+
5
+ test('source health summarizes coverage without exposing local paths', () => {
6
+ const rows = buildSourceHealth({
7
+ collectors: [{
8
+ id: 'codex',
9
+ label: 'Codex CLI',
10
+ supportStatus: 'stable',
11
+ privacyLevel: 'metadata-only',
12
+ defaultEnabled: true,
13
+ detected: true,
14
+ existingRoots: ['X:/workspace/sample-codex-root'],
15
+ configuredRoots: ['X:/workspace/sample-codex-root'],
16
+ readsConversationContent: false,
17
+ tokenReliability: 'native-token-fields',
18
+ dataFields: ['input_tokens']
19
+ }, {
20
+ id: 'ccusage',
21
+ label: 'ccusage Import Bridge',
22
+ supportStatus: 'import-only',
23
+ privacyLevel: 'metadata-only',
24
+ defaultEnabled: false,
25
+ detected: false,
26
+ existingRoots: [],
27
+ configuredRoots: [],
28
+ readsConversationContent: false,
29
+ tokenReliability: 'external-json-token-fields',
30
+ dataFields: ['input_tokens']
31
+ }],
32
+ sessionRows: [{
33
+ source: 'Codex CLI',
34
+ count: 2,
35
+ totalTokens: 1200,
36
+ latestSessionAt: '2026-06-17T02:00:00Z'
37
+ }],
38
+ eventRows: [{
39
+ source: 'import:ccusage-cli',
40
+ count: 3,
41
+ totalTokens: 3000,
42
+ latestEventAt: '2026-06-17T03:00:00Z'
43
+ }],
44
+ runs: [{
45
+ source: 'import:ccusage-cli',
46
+ status: 'ok',
47
+ collectedAt: '2026-06-17T03:05:00Z'
48
+ }]
49
+ });
50
+
51
+ const codex = rows.find(row => row.id === 'codex');
52
+ assert.equal(codex.health, 'has-data');
53
+ assert.equal(codex.sessions, 2);
54
+ assert.equal(codex.detectedRootCount, 1);
55
+ assert.equal(JSON.stringify(codex).includes('sample-codex-root'), false);
56
+
57
+ const ccusage = rows.find(row => row.id === 'ccusage');
58
+ assert.equal(ccusage.coverageTier, 'ccusage import-bridge');
59
+ assert.equal(ccusage.tokenEvents, 3);
60
+ assert.equal(ccusage.lastRunStatus, 'ok');
61
+ assert.match(ccusage.commandHint, /npx token-studio import-usage/);
62
+ });
@@ -0,0 +1,148 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawn } from 'node:child_process';
4
+ import { mkdtempSync, rmSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import {
8
+ openDb,
9
+ upsertAdvisorAction,
10
+ upsertBudgetProfile,
11
+ upsertTokenEvent
12
+ } from '../src/db.mjs';
13
+ import { buildStatuslineSnapshot, formatStatuslineText } from '../src/statusline.mjs';
14
+
15
+ test('statusline snapshot summarizes recent tokens, budget and advisor actions', () => {
16
+ const { dir, dbPath } = seedStatuslineDb('2026-06-17T02:12:00Z');
17
+ const db = openDb(dbPath);
18
+ try {
19
+ const snapshot = buildStatuslineSnapshot(db, {
20
+ now: new Date('2026-06-17T02:15:00Z'),
21
+ windowMinutes: 15,
22
+ source: 'codex'
23
+ });
24
+ assert.equal(snapshot.status, 'active');
25
+ assert.equal(snapshot.totals.totalTokens, 1600);
26
+ assert.equal(snapshot.budget.status, 'exceeded');
27
+ assert.equal(snapshot.openAdvisorActions, 1);
28
+ assert.ok(snapshot.budget.resetInMinutes > 0);
29
+ assert.ok(snapshot.warnings.some(warning => warning.type === 'budget-exceeded'));
30
+ assert.ok(snapshot.warnings.some(warning => warning.type === 'unpriced-model-active'));
31
+
32
+ const text = formatStatuslineText(snapshot, { maxWidth: 70 });
33
+ assert.ok(text.length <= 70);
34
+ assert.match(text, /^TS /);
35
+ assert.match(text, /tok=1\.6k/);
36
+ assert.match(text, /actions=1/);
37
+ } finally {
38
+ db.close();
39
+ rmSync(dir, { recursive: true, force: true });
40
+ }
41
+ });
42
+
43
+ test('statusline CLI prints stable JSON and compact text', async () => {
44
+ const { dir, dbPath } = seedStatuslineDb(new Date().toISOString());
45
+ try {
46
+ const json = await runCli(['statusline', '--db', dbPath, '--format=json', '--window-minutes=15', '--source=codex']);
47
+ assert.equal(json.code, 0, json.stderr);
48
+ const snapshot = JSON.parse(json.stdout);
49
+ assert.equal(snapshot.source, 'codex');
50
+ assert.equal(snapshot.openAdvisorActions, 1);
51
+ assert.equal(snapshot.budget.windows.length, 1);
52
+
53
+ const text = await runCli(['statusline', '--db', dbPath, '--format=text', '--window-minutes=15', '--source=codex', '--max-width=60']);
54
+ assert.equal(text.code, 0, text.stderr);
55
+ assert.ok(text.stdout.trim().length <= 60);
56
+ assert.match(text.stdout, /^TS /);
57
+ } finally {
58
+ rmSync(dir, { recursive: true, force: true });
59
+ }
60
+ });
61
+
62
+ test('statusline CLI handles missing SQLite as an empty read-only state', async () => {
63
+ const dir = mkdtempSync(join(tmpdir(), 'token-studio-statusline-missing-'));
64
+ const dbPath = join(dir, 'missing.sqlite');
65
+ try {
66
+ const text = await runCli(['statusline', '--db', dbPath, '--format=text']);
67
+ assert.equal(text.code, 0, text.stderr);
68
+ assert.match(text.stdout, /warn=no-db/);
69
+
70
+ const json = await runCli(['statusline', '--db', dbPath, '--format=json']);
71
+ assert.equal(json.code, 0, json.stderr);
72
+ const snapshot = JSON.parse(json.stdout);
73
+ assert.equal(snapshot.status, 'missing-db');
74
+ assert.equal(snapshot.totals.totalTokens, 0);
75
+ assert.equal(snapshot.warnings[0].type, 'missing-db');
76
+ } finally {
77
+ rmSync(dir, { recursive: true, force: true });
78
+ }
79
+ });
80
+
81
+ test('statusline help includes integration snippets', async () => {
82
+ const help = await runCli(['statusline', '--help']);
83
+ assert.equal(help.code, 0, help.stderr);
84
+ assert.match(help.stdout, /Claude Code statusline command/);
85
+ assert.match(help.stdout, /tmux/);
86
+ assert.match(help.stdout, /PowerShell prompt/);
87
+ assert.match(help.stdout, /only reads local SQLite/);
88
+ });
89
+
90
+ function seedStatuslineDb(timestamp) {
91
+ const dir = mkdtempSync(join(tmpdir(), 'token-studio-statusline-'));
92
+ const dbPath = join(dir, 'usage.sqlite');
93
+ const resetAnchor = new Date(new Date(timestamp).getTime() - 10 * 60 * 1000).toISOString();
94
+ const db = openDb(dbPath);
95
+ try {
96
+ upsertTokenEvent(db, {
97
+ eventId: `statusline-${timestamp}`,
98
+ device: 'devbox',
99
+ source: 'Codex CLI',
100
+ sessionId: 's1',
101
+ timestamp,
102
+ model: 'gpt-5.3-codex-spark',
103
+ inputTokens: 1200,
104
+ outputTokens: 100,
105
+ cacheReadTokens: 300
106
+ });
107
+ upsertBudgetProfile(db, {
108
+ source: 'Codex CLI',
109
+ label: 'Codex 15m',
110
+ windowType: 'fixed',
111
+ windowMinutes: 15,
112
+ resetAnchor,
113
+ warningThreshold: 0.7,
114
+ tokenBudget: 1000
115
+ });
116
+ upsertAdvisorAction(db, {
117
+ periodStart: '2026-06-17',
118
+ periodEnd: '2026-06-17',
119
+ category: '模型切换',
120
+ title: '测试验证改用轻量模型',
121
+ action: '下周测试验证默认先用轻量模型',
122
+ evidence: 'statusline fixture',
123
+ sourceRule: 'statusline:test'
124
+ });
125
+ } finally {
126
+ db.close();
127
+ }
128
+ return { dir, dbPath };
129
+ }
130
+
131
+ function runCli(argv) {
132
+ return new Promise(resolve => {
133
+ const child = spawn(process.execPath, ['src/cli.mjs', ...argv], {
134
+ cwd: process.cwd(),
135
+ env: process.env,
136
+ stdio: ['ignore', 'pipe', 'pipe'],
137
+ windowsHide: true
138
+ });
139
+ let stdout = '';
140
+ let stderr = '';
141
+ child.stdout.setEncoding('utf8');
142
+ child.stderr.setEncoding('utf8');
143
+ child.stdout.on('data', chunk => { stdout += chunk; });
144
+ child.stderr.on('data', chunk => { stderr += chunk; });
145
+ child.on('close', code => resolve({ code, stdout, stderr }));
146
+ child.on('error', error => resolve({ code: 1, stdout, stderr: `${stderr}${error.message}` }));
147
+ });
148
+ }
package/vite.config.js ADDED
@@ -0,0 +1,23 @@
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ build: {
7
+ rollupOptions: {
8
+ output: {
9
+ manualChunks(id) {
10
+ if (id.includes('node_modules/react') || id.includes('node_modules/react-dom')) return 'react';
11
+ if (id.includes('node_modules/echarts')) return 'echarts';
12
+ return null;
13
+ }
14
+ }
15
+ },
16
+ chunkSizeWarningLimit: 1500
17
+ },
18
+ server: {
19
+ proxy: {
20
+ '/api': `http://127.0.0.1:${process.env.API_PORT || 4173}`
21
+ }
22
+ }
23
+ });