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,40 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ applyBudgetTemplate,
5
+ buildCcusageBridgeCommand,
6
+ defaultResetAnchor
7
+ } from '../src/client/dashboard/import-budget.js';
8
+
9
+ test('ccusage bridge command builder only emits explicit local CLI commands', () => {
10
+ assert.equal(
11
+ buildCcusageBridgeCommand({ report: 'blocks' }),
12
+ 'npx token-studio import-usage --format=ccusage-cli --report=blocks --dry-run --yes'
13
+ );
14
+ assert.equal(
15
+ buildCcusageBridgeCommand({ report: 'weekly', apply: true }),
16
+ 'npx token-studio import-usage --format=ccusage-cli --report=weekly --apply --yes'
17
+ );
18
+ assert.equal(
19
+ buildCcusageBridgeCommand({ report: 'unknown' }),
20
+ 'npx token-studio import-usage --format=ccusage-cli --report=session --dry-run --yes'
21
+ );
22
+ });
23
+
24
+ test('budget templates fill editable fixed-window guardrail fields', () => {
25
+ const reset = defaultResetAnchor(new Date('2026-06-17T02:13:45Z'));
26
+ assert.equal(reset, '2026-06-17T02:13');
27
+
28
+ const next = applyBudgetTemplate(
29
+ { tokenBudget: '500000' },
30
+ { label: 'Codex 5h', source: 'Codex CLI', windowType: 'fixed', windowMinutes: 300, warningThreshold: 0.7 },
31
+ new Date('2026-06-17T02:13:45Z')
32
+ );
33
+ assert.equal(next.source, 'Codex CLI');
34
+ assert.equal(next.label, 'Codex 5h');
35
+ assert.equal(next.windowType, 'fixed');
36
+ assert.equal(next.windowMinutes, 300);
37
+ assert.equal(next.warningThreshold, 0.7);
38
+ assert.equal(next.resetAnchor, '2026-06-17T02:13');
39
+ assert.equal(next.tokenBudget, '500000');
40
+ });
@@ -0,0 +1,256 @@
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 { buildLiveGuardrails, buildLiveSnapshot } from '../src/live.mjs';
8
+ import { openDb, upsertTokenEvent } from '../src/db.mjs';
9
+
10
+ test('live snapshot uses recent token events for burn rate and cache hit', () => {
11
+ const snapshot = buildLiveSnapshot({
12
+ now: new Date('2026-06-17T02:15:00Z'),
13
+ windowMinutes: 15,
14
+ sessions: [{
15
+ device: 'demo',
16
+ source: 'Codex CLI',
17
+ sessionId: 'old',
18
+ lastActivity: '2026-06-17T01:00:00Z',
19
+ totalTokens: 99999
20
+ }],
21
+ tokenEvents: [{
22
+ eventId: 'e1',
23
+ device: 'demo',
24
+ source: 'Cursor',
25
+ sessionId: 's1',
26
+ timestamp: '2026-06-17T02:10:00Z',
27
+ model: 'gpt-5.3-codex',
28
+ inputTokens: 1000,
29
+ outputTokens: 250,
30
+ cacheReadTokens: 500
31
+ }]
32
+ });
33
+ assert.equal(snapshot.status, 'active');
34
+ assert.equal(snapshot.totals.totalTokens, 1750);
35
+ assert.equal(snapshot.totals.burnRateTokensPerHour, 7000);
36
+ assert.equal(snapshot.bySource[0].key, 'Cursor');
37
+ assert.equal(snapshot.activeSessions.length, 0);
38
+ assert.ok(snapshot.totals.cacheHitRate > 0);
39
+ });
40
+
41
+ test('live snapshot reports idle empty state', () => {
42
+ const snapshot = buildLiveSnapshot({
43
+ now: new Date('2026-06-17T02:15:00Z'),
44
+ sessions: [],
45
+ tokenEvents: []
46
+ });
47
+ assert.equal(snapshot.status, 'idle');
48
+ assert.equal(snapshot.totals.totalTokens, 0);
49
+ assert.deepEqual(snapshot.byModel, []);
50
+ });
51
+
52
+ test('live guardrails warn on burn rate, low cache hit, low output/input and unpriced models', () => {
53
+ const snapshot = buildLiveSnapshot({
54
+ now: new Date('2026-06-17T02:15:00Z'),
55
+ windowMinutes: 15,
56
+ tokenEvents: [{
57
+ eventId: 'e1',
58
+ device: 'demo',
59
+ source: 'Codex CLI',
60
+ sessionId: 's1',
61
+ timestamp: '2026-06-17T02:10:00Z',
62
+ model: 'gpt-5.3-codex-spark',
63
+ inputTokens: 20_000,
64
+ outputTokens: 500,
65
+ cacheReadTokens: 0
66
+ }]
67
+ });
68
+ const types = snapshot.warnings.map(item => item.type).sort();
69
+ assert.deepEqual(types, [
70
+ 'high-burn-rate',
71
+ 'low-cache-hit',
72
+ 'low-output-input-ratio',
73
+ 'unpriced-model-active'
74
+ ].sort());
75
+ assert.equal(snapshot.guardrails.tokenBudgetPerHour, 50_000);
76
+ });
77
+
78
+ test('live guardrail thresholds can be overridden', () => {
79
+ const warnings = buildLiveGuardrails({
80
+ totals: {
81
+ inputTokens: 20_000,
82
+ outputTokens: 500,
83
+ cacheReadTokens: 0,
84
+ totalTokens: 20_500,
85
+ burnRateTokensPerHour: 60_000,
86
+ cacheHitRate: 50
87
+ },
88
+ byModel: [{ key: 'gpt-5.3-codex', totalTokens: 20_500 }]
89
+ }, {
90
+ tokenBudgetPerHour: 100_000,
91
+ minCacheHitRate: 0.1,
92
+ minOutputInputRatio: 0.01,
93
+ highInputTokens: 10_000
94
+ });
95
+ assert.deepEqual(warnings, []);
96
+ });
97
+
98
+ test('live snapshot builds budget windows and budget warnings', () => {
99
+ const snapshot = buildLiveSnapshot({
100
+ now: new Date('2026-06-17T02:15:00Z'),
101
+ windowMinutes: 15,
102
+ budgetProfiles: [{
103
+ id: 1,
104
+ source: 'Codex CLI',
105
+ label: 'Codex 15m',
106
+ windowMinutes: 15,
107
+ tokenBudget: 10_000,
108
+ costBudgetUSD: 0,
109
+ enabled: true
110
+ }],
111
+ tokenEvents: [{
112
+ eventId: 'e1',
113
+ device: 'demo',
114
+ source: 'Codex CLI',
115
+ sessionId: 's1',
116
+ timestamp: '2026-06-17T02:10:00Z',
117
+ model: 'gpt-5.3-codex',
118
+ inputTokens: 9_000,
119
+ outputTokens: 1_000
120
+ }]
121
+ });
122
+ assert.equal(snapshot.budgetWindows.length, 1);
123
+ assert.equal(snapshot.budgetWindows[0].status, 'exceeded');
124
+ assert.ok(snapshot.warnings.some(item => item.type === 'budget-exceeded'));
125
+ });
126
+
127
+ test('live snapshot warns when current pace will exceed custom budget', () => {
128
+ const snapshot = buildLiveSnapshot({
129
+ now: new Date('2026-06-17T02:15:00Z'),
130
+ windowMinutes: 15,
131
+ budgetProfiles: [{
132
+ id: 1,
133
+ source: 'Codex CLI',
134
+ label: 'Codex 15m',
135
+ windowMinutes: 15,
136
+ tokenBudget: 12_000,
137
+ enabled: true
138
+ }],
139
+ tokenEvents: [{
140
+ eventId: 'e1',
141
+ device: 'demo',
142
+ source: 'Codex CLI',
143
+ sessionId: 's1',
144
+ timestamp: '2026-06-17T02:12:00Z',
145
+ model: 'gpt-5.3-codex',
146
+ inputTokens: 8_000,
147
+ outputTokens: 1_000
148
+ }]
149
+ });
150
+ assert.equal(snapshot.budgetWindows[0].status, 'over-pace');
151
+ assert.ok(snapshot.warnings.some(item => item.type === 'over-budget-pace'));
152
+ });
153
+
154
+ test('live snapshot supports fixed budget reset windows and custom near threshold', () => {
155
+ const snapshot = buildLiveSnapshot({
156
+ now: new Date('2026-06-17T02:15:00Z'),
157
+ windowMinutes: 15,
158
+ budgetProfiles: [{
159
+ id: 1,
160
+ source: 'Codex CLI',
161
+ label: 'Codex fixed hour',
162
+ windowType: 'fixed',
163
+ windowMinutes: 60,
164
+ resetAnchor: '2026-06-17T00:00:00Z',
165
+ warningThreshold: 0.2,
166
+ tokenBudget: 10_000,
167
+ enabled: true
168
+ }],
169
+ tokenEvents: [{
170
+ eventId: 'e1',
171
+ device: 'demo',
172
+ source: 'Codex CLI',
173
+ sessionId: 's1',
174
+ timestamp: '2026-06-17T02:10:00Z',
175
+ model: 'gpt-5.3-codex',
176
+ inputTokens: 1_800,
177
+ outputTokens: 400
178
+ }]
179
+ });
180
+ const window = snapshot.budgetWindows[0];
181
+ assert.equal(window.windowType, 'fixed');
182
+ assert.equal(window.windowStart, '2026-06-17T02:00:00.000Z');
183
+ assert.equal(window.windowEnd, '2026-06-17T03:00:00.000Z');
184
+ assert.equal(window.resetInMinutes, 45);
185
+ assert.equal(window.warningThreshold, 0.2);
186
+ assert.equal(window.status, 'near-limit');
187
+ assert.ok(snapshot.warnings.some(item => item.type === 'near-budget-limit'));
188
+ });
189
+
190
+
191
+ test('live API returns guardrails and warnings from temporary SQLite', async () => {
192
+ const dir = mkdtempSync(join(tmpdir(), 'token-studio-live-api-'));
193
+ const dbPath = join(dir, 'usage.sqlite');
194
+ const port = 6100 + Math.floor(Math.random() * 1000);
195
+ const db = openDb(dbPath);
196
+ try {
197
+ upsertTokenEvent(db, {
198
+ eventId: 'live-api-warning',
199
+ device: 'devbox',
200
+ source: 'Codex CLI',
201
+ sessionId: 's1',
202
+ timestamp: new Date().toISOString(),
203
+ model: 'gpt-5.3-codex-spark',
204
+ inputTokens: 20_000,
205
+ outputTokens: 100
206
+ });
207
+ } finally {
208
+ db.close();
209
+ }
210
+
211
+ const child = spawn(process.execPath, ['src/server.mjs'], {
212
+ cwd: process.cwd(),
213
+ env: {
214
+ ...process.env,
215
+ PORT: String(port),
216
+ DB_PATH: dbPath,
217
+ SCHEDULED_COLLECT_ENABLED: 'false'
218
+ },
219
+ stdio: ['ignore', 'pipe', 'pipe'],
220
+ windowsHide: true
221
+ });
222
+
223
+ try {
224
+ await waitForLiveApi(port);
225
+ const response = await fetch(`http://127.0.0.1:${port}/api/live`);
226
+ if (!response.ok) assert.fail(await response.text());
227
+ const body = await response.json();
228
+ assert.equal(body.guardrails.tokenBudgetPerHour, 50_000);
229
+ assert.ok(body.warnings.some(item => item.type === 'high-burn-rate'));
230
+ } finally {
231
+ await stopChild(child);
232
+ rmSync(dir, { recursive: true, force: true });
233
+ }
234
+ });
235
+
236
+ async function waitForLiveApi(port) {
237
+ const start = Date.now();
238
+ while (Date.now() - start < 5000) {
239
+ try {
240
+ const response = await fetch(`http://127.0.0.1:${port}/api/live`);
241
+ if (response.ok) return;
242
+ } catch {
243
+ // Retry while the server starts.
244
+ }
245
+ await new Promise(resolve => setTimeout(resolve, 100));
246
+ }
247
+ throw new Error('live API did not start in time');
248
+ }
249
+
250
+ function stopChild(child) {
251
+ if (child.exitCode != null) return Promise.resolve();
252
+ return new Promise(resolve => {
253
+ child.once('close', resolve);
254
+ child.kill();
255
+ });
256
+ }
@@ -0,0 +1,193 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import {
4
+ buildMarkdownReviewReport,
5
+ buildModelRows,
6
+ buildReviewReportFilename
7
+ } from '../src/client/review/markdown-report.js';
8
+
9
+ const period = {
10
+ pretty: '2026 年 6 月',
11
+ start: '2026-06-01',
12
+ end: '2026-06-12'
13
+ };
14
+
15
+ test('buildMarkdownReviewReport renders the fixed weekly review structure', () => {
16
+ const report = buildMarkdownReviewReport({
17
+ period,
18
+ daily: [],
19
+ sessions: [],
20
+ roiAdvice: [],
21
+ generatedAt: new Date(2026, 5, 12, 9, 30)
22
+ });
23
+
24
+ assert.match(report, /^# Token Studio Weekly Review/);
25
+ assert.match(report, /## 1\. 本期总览/);
26
+ assert.match(report, /## 8\. 本周行动状态/);
27
+ assert.match(report, /## 10\. 口径说明/);
28
+ assert.match(report, /## 6\. 节省模拟/);
29
+ assert.match(report, /官方公开 token 单价换算,不是供应商账单/);
30
+ assert.match(report, /不读取、不导出对话正文/);
31
+ });
32
+
33
+ test('buildMarkdownReviewReport includes advisor action workflow status', () => {
34
+ const report = buildMarkdownReviewReport({
35
+ period,
36
+ daily: [],
37
+ sessions: [],
38
+ advisorActions: [{
39
+ periodStart: '2026-06-01',
40
+ periodEnd: '2026-06-12',
41
+ status: 'done',
42
+ category: '节省模拟',
43
+ title: '测试验证改用轻量模型',
44
+ action: '下周测试验证默认先用轻量模型',
45
+ sourceRule: 'savings:test'
46
+ }]
47
+ });
48
+
49
+ assert.match(report, /已完成 \| 节省模拟 \| 测试验证改用轻量模型 \| 下周测试验证默认先用轻量模型/);
50
+ assert.match(report, /不证明真实因果节省/);
51
+ });
52
+
53
+ test('buildMarkdownReviewReport includes unattributed work and advisor actions', () => {
54
+ const report = buildMarkdownReviewReport({
55
+ period,
56
+ daily: [{
57
+ usageDate: '2026-06-10',
58
+ source: 'Codex CLI',
59
+ model: 'gpt-5.5',
60
+ totalTokens: 1000,
61
+ inputTokens: 800,
62
+ outputTokens: 100,
63
+ cacheReadTokens: 50,
64
+ costUSD: 1
65
+ }],
66
+ sessions: [{
67
+ sessionId: 's1',
68
+ projectPath: 'D:\\AIResume',
69
+ taskType: '未分类',
70
+ outputStatus: '未标注',
71
+ totalTokens: 1000,
72
+ costUSD: 1
73
+ }],
74
+ roiAdvice: [{
75
+ title: '先补齐高成本会话',
76
+ category: '补标注',
77
+ impact: '高',
78
+ recommendation: '补用途和价值',
79
+ reason: '缺少归因字段',
80
+ evidence: '1 个 session 未归因',
81
+ action: '先标注最高成本 session'
82
+ }],
83
+ generatedAt: new Date(2026, 5, 12, 9, 30)
84
+ });
85
+
86
+ assert.match(report, /未归因 session \| 1/);
87
+ assert.match(report, /建议分类:补标注/);
88
+ assert.match(report, /先标注最高成本 session/);
89
+ assert.match(report, /补齐 D:\\AIResume 的 session 标注/);
90
+ });
91
+
92
+ test('buildMarkdownReviewReport lists highest-cost review attribution gaps first', () => {
93
+ const report = buildMarkdownReviewReport({
94
+ period,
95
+ daily: [],
96
+ sessions: [
97
+ {
98
+ sessionId: 'low-cost-gap',
99
+ projectAlias: 'Low Cost',
100
+ taskType: '未分类',
101
+ outputStatus: '未标注',
102
+ totalTokens: 100,
103
+ costUSD: 0.02,
104
+ lastActivity: '2026-06-09'
105
+ },
106
+ {
107
+ sessionId: 'high-cost-gap',
108
+ projectAlias: 'Token Studio',
109
+ taskType: '功能开发',
110
+ outputStatus: '已完成',
111
+ workPurpose: '未说明',
112
+ workStage: '未说明',
113
+ valueLevel: '未评估',
114
+ totalTokens: 5000,
115
+ costUSD: 2.5,
116
+ lastActivity: '2026-06-12'
117
+ }
118
+ ]
119
+ });
120
+
121
+ const highIndex = report.indexOf('high-cost-gap');
122
+ const lowIndex = report.indexOf('low-cost-gap');
123
+ assert.match(report, /### 高成本待补齐归因/);
124
+ assert.ok(highIndex > -1);
125
+ assert.ok(lowIndex > -1);
126
+ assert.ok(highIndex < lowIndex);
127
+ assert.match(report, /工作目的、工作阶段、产出价值/);
128
+ assert.match(report, /Token Studio \| high-cost-gap \| 工作目的、工作阶段、产出价值 \| missing \| 5,000 \| \$2\.50 \| 2026-06-12/);
129
+ });
130
+
131
+ test('buildMarkdownReviewReport includes published output links without fetching content', () => {
132
+ const report = buildMarkdownReviewReport({
133
+ period,
134
+ daily: [],
135
+ sessions: [{
136
+ sessionId: 'published',
137
+ projectAlias: 'Token Studio',
138
+ taskType: '功能开发',
139
+ outputStatus: '已发布',
140
+ outputType: 'PR',
141
+ outputLabel: 'v3.1 PR',
142
+ outputUrl: 'https://example.com/pr/1',
143
+ totalTokens: 100,
144
+ costUSD: 0.1
145
+ }]
146
+ });
147
+
148
+ assert.match(report, /已发布 \| PR \| v3\.1 PR \| Token Studio \| \[v3\.1 PR\]\(https:\/\/example.com\/pr\/1\)/);
149
+ });
150
+
151
+ test('buildMarkdownReviewReport keeps unpriced model wording and escapes spreadsheet formula prefixes', () => {
152
+ const report = buildMarkdownReviewReport({
153
+ period,
154
+ daily: [{
155
+ usageDate: '2026-06-10',
156
+ source: 'Codex CLI',
157
+ model: '=IMPORTXML("https://example.com")',
158
+ totalTokens: 100,
159
+ inputTokens: 80,
160
+ outputTokens: 20,
161
+ cacheReadTokens: 0,
162
+ costUSD: 0
163
+ }],
164
+ sessions: [{
165
+ sessionId: 's1',
166
+ projectAlias: '+cmd|danger',
167
+ taskType: '功能开发',
168
+ outputStatus: '已完成',
169
+ totalTokens: 100,
170
+ costUSD: 0
171
+ }]
172
+ });
173
+
174
+ assert.match(report, /未定价\/无官方价/);
175
+ assert.match(report, /'\=IMPORTXML/);
176
+ assert.match(report, /'\+cmd\\\|danger/);
177
+ });
178
+
179
+ test('buildModelRows aggregates by model and source', () => {
180
+ const rows = buildModelRows([
181
+ { source: 'Codex CLI', model: 'gpt-5.5', totalTokens: 100, costUSD: 1 },
182
+ { source: 'Codex CLI', model: 'gpt-5.5', totalTokens: 50, costUSD: 0.5 },
183
+ { source: 'Claude Code', model: 'claude-sonnet', totalTokens: 25, costUSD: 0.1 }
184
+ ]);
185
+
186
+ assert.equal(rows[0].model, 'gpt-5.5');
187
+ assert.equal(rows[0].totalTokens, 150);
188
+ assert.equal(rows[0].share, 150 / 175);
189
+ });
190
+
191
+ test('buildReviewReportFilename uses the active period end date', () => {
192
+ assert.equal(buildReviewReportFilename(period), 'token-studio-review-2026-06-12.md');
193
+ });
@@ -0,0 +1,34 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { buildModelPolicy, formatModelPolicy, formatModelPolicyMarkdown } from '../src/model-policy.mjs';
4
+
5
+ test('model policy builds executable local rules without LLM calls', () => {
6
+ const policy = buildModelPolicy({
7
+ generatedAt: new Date('2026-06-17T00:00:00Z'),
8
+ sessions: [
9
+ { workPurpose: '测试验证', workStage: '验证', valueLevel: '中', outputStatus: '已完成', totalTokens: 1200 },
10
+ { workPurpose: '功能开发', workStage: '实现', valueLevel: '高', outputStatus: '已发布', totalTokens: 5000 }
11
+ ]
12
+ });
13
+ assert.equal(policy.rules.length, 3);
14
+ const markdown = formatModelPolicyMarkdown(policy);
15
+ assert.match(markdown, /Token Studio Model Policy/);
16
+ assert.match(markdown, /Testing|测试|light/i);
17
+ assert.doesNotMatch(markdown, /prompt|response body/i);
18
+ });
19
+
20
+ test('model policy formats Claude and AGENTS snippets without writing files', () => {
21
+ const policy = buildModelPolicy({
22
+ generatedAt: new Date('2026-06-17T00:00:00Z'),
23
+ sessions: [
24
+ { workPurpose: '上下文整理', workStage: '探索', valueLevel: '低', outputStatus: '进行中', totalTokens: 3000 }
25
+ ]
26
+ });
27
+ const claude = formatModelPolicy(policy, 'claude-md');
28
+ const agents = formatModelPolicy(policy, 'agents-md');
29
+ assert.match(claude, /Claude Code/);
30
+ assert.match(claude, /lightweight models/);
31
+ assert.match(agents, /Agent Policy/);
32
+ assert.match(agents, /Do not automatically edit/);
33
+ assert.doesNotMatch(`${claude}\n${agents}`, /secret prompt|private response|actual transcript/i);
34
+ });
@@ -0,0 +1,116 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import {
4
+ buildModelRowsFromSessions,
5
+ buildModelStrategy,
6
+ hasStrategyAnnotation
7
+ } from '../src/client/review/model-strategy.js';
8
+
9
+ const sessions = [
10
+ {
11
+ sessionId: 'explore-heavy',
12
+ model: 'gpt-5.5',
13
+ taskType: '技术调研',
14
+ workStage: '探索',
15
+ valueLevel: '中',
16
+ outputStatus: '进行中',
17
+ totalTokens: 1000,
18
+ costUSD: 10,
19
+ pricingStatus: 'priced'
20
+ },
21
+ {
22
+ sessionId: 'ship-light',
23
+ model: 'deepseek-v4-pro',
24
+ taskType: '功能开发',
25
+ workStage: '发布',
26
+ valueLevel: '关键',
27
+ outputStatus: '已发布',
28
+ totalTokens: 500,
29
+ costUSD: 0.1,
30
+ pricingStatus: 'priced'
31
+ },
32
+ {
33
+ sessionId: 'waste-mid',
34
+ model: 'claude-sonnet-4-6',
35
+ taskType: '技术调研',
36
+ workStage: '验证',
37
+ valueLevel: '低',
38
+ outputStatus: '已废弃',
39
+ totalTokens: 400,
40
+ costUSD: 2,
41
+ pricingStatus: 'priced'
42
+ },
43
+ {
44
+ sessionId: 'unlabeled',
45
+ model: 'gpt-5.3-codex-spark',
46
+ taskType: '未分类',
47
+ workStage: '未说明',
48
+ valueLevel: '未评估',
49
+ outputStatus: '未标注',
50
+ totalTokens: 300,
51
+ costUSD: 0,
52
+ pricingStatus: 'unpriced'
53
+ }
54
+ ];
55
+
56
+ test('hasStrategyAnnotation requires at least one strategy field', () => {
57
+ assert.equal(hasStrategyAnnotation(sessions[0]), true);
58
+ assert.equal(hasStrategyAnnotation(sessions[3]), false);
59
+ });
60
+
61
+ test('buildModelRowsFromSessions aggregates model usage and risk shares', () => {
62
+ const rows = buildModelRowsFromSessions(sessions);
63
+ const heavy = rows.find(row => row.model === 'gpt-5.5');
64
+ const risky = rows.find(row => row.model === 'claude-sonnet-4-6');
65
+
66
+ assert.equal(rows[0].model, 'gpt-5.5');
67
+ assert.equal(heavy.tier, 'heavy');
68
+ assert.equal(heavy.totalTokens, 1000);
69
+ assert.equal(risky.riskShare, 1);
70
+ });
71
+
72
+ test('buildModelStrategy groups by task stage and value', () => {
73
+ const strategy = buildModelStrategy({ sessions });
74
+
75
+ assert.equal(strategy.coverage.sessionCount, 4);
76
+ assert.equal(strategy.coverage.annotatedSessionCount, 3);
77
+ assert.equal(strategy.byTaskType[0].key, '技术调研');
78
+ assert.equal(strategy.byTaskType[0].topModel, 'gpt-5.5');
79
+ assert.equal(strategy.byStage.some(row => row.key === '发布'), true);
80
+ assert.equal(strategy.byValue.some(row => row.key === '关键'), true);
81
+ });
82
+
83
+ test('buildModelStrategy builds a light mid heavy model playbook from annotations', () => {
84
+ const strategy = buildModelStrategy({ sessions });
85
+
86
+ assert.equal(strategy.playbook.length, 3);
87
+ const light = strategy.playbook.find(row => row.id === 'light-default');
88
+ const mid = strategy.playbook.find(row => row.id === 'mid-implementation');
89
+ const heavy = strategy.playbook.find(row => row.id === 'heavy-review');
90
+
91
+ assert.equal(light.label, '轻量默认');
92
+ assert.equal(light.evidenceState, '已观察');
93
+ assert.equal(light.sessionCount, 2);
94
+ assert.equal(light.topModel, 'gpt-5.5');
95
+ assert.equal(mid.label, '中模型实现');
96
+ assert.equal(mid.sessionCount, 1);
97
+ assert.equal(heavy.label, '重模型审查');
98
+ assert.equal(heavy.sessionCount, 1);
99
+ assert.match(heavy.action, /关键发布/);
100
+ });
101
+
102
+ test('buildModelStrategy emits model policy recommendations', () => {
103
+ const strategy = buildModelStrategy({ sessions });
104
+ const ids = strategy.recommendations.map(item => item.id);
105
+
106
+ assert.equal(ids.includes('light-model-for-exploration'), true);
107
+ assert.equal(ids.includes('keep-high-value-pattern'), true);
108
+ assert.equal(ids.some(id => id.startsWith('risk-claude-sonnet')), true);
109
+ });
110
+
111
+ test('buildModelStrategy asks for labels when coverage is low', () => {
112
+ const strategy = buildModelStrategy({ sessions: [sessions[3], { ...sessions[3], sessionId: 'u2' }] });
113
+ assert.equal(strategy.coverage.annotatedSessionCount, 0);
114
+ assert.equal(strategy.playbook[0].evidenceState, '待标注验证');
115
+ assert.equal(strategy.recommendations[0].id, 'label-before-model-policy');
116
+ });