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,138 @@
1
+ const STORAGE_KEY = 'token-studio.annotation-presets.v1';
2
+ const MAX_RECENT_PROJECTS = 8;
3
+ const TEMPLATE_FIELDS = ['projectAlias', 'taskType', 'outputStatus', 'workPurpose', 'workStage', 'valueLevel'];
4
+
5
+ export const EMPTY_ANNOTATION_PRESETS = {
6
+ recentProjects: [],
7
+ lastTemplate: null
8
+ };
9
+
10
+ export const QUICK_ANNOTATION_TEMPLATES = [
11
+ {
12
+ id: 'explore-validate',
13
+ label: '探索验证',
14
+ description: '调研、测试、上下文整理',
15
+ values: {
16
+ taskType: '技术调研',
17
+ outputStatus: '进行中',
18
+ workPurpose: '技术调研',
19
+ workStage: '探索',
20
+ valueLevel: '中'
21
+ }
22
+ },
23
+ {
24
+ id: 'build-feature',
25
+ label: '功能实现',
26
+ description: '开发或修复中的有效工作',
27
+ values: {
28
+ taskType: '功能开发',
29
+ outputStatus: '已完成',
30
+ workPurpose: '功能开发',
31
+ workStage: '实现',
32
+ valueLevel: '高'
33
+ }
34
+ },
35
+ {
36
+ id: 'ship-output',
37
+ label: '发布产出',
38
+ description: '已发布、可展示或可复用',
39
+ values: {
40
+ taskType: '功能开发',
41
+ outputStatus: '已发布',
42
+ workPurpose: '功能开发',
43
+ workStage: '发布',
44
+ valueLevel: '关键'
45
+ }
46
+ },
47
+ {
48
+ id: 'stop-loss',
49
+ label: '废弃止损',
50
+ description: '方向不继续投入',
51
+ values: {
52
+ taskType: '技术调研',
53
+ outputStatus: '已废弃',
54
+ workPurpose: '技术调研',
55
+ workStage: '探索',
56
+ valueLevel: '低'
57
+ }
58
+ }
59
+ ];
60
+
61
+ export function readAnnotationPresets(storage = globalThis.localStorage) {
62
+ try {
63
+ const raw = storage?.getItem(STORAGE_KEY);
64
+ if (!raw) return { ...EMPTY_ANNOTATION_PRESETS };
65
+ return normalizePresetState(JSON.parse(raw));
66
+ } catch {
67
+ return { ...EMPTY_ANNOTATION_PRESETS };
68
+ }
69
+ }
70
+
71
+ export function writeAnnotationPresets(state, storage = globalThis.localStorage) {
72
+ try {
73
+ storage?.setItem(STORAGE_KEY, JSON.stringify(normalizePresetState(state)));
74
+ } catch {
75
+ // Presets are convenience-only. Ignore storage failures in private modes.
76
+ }
77
+ }
78
+
79
+ export function rememberAnnotationPreset(state, values = {}) {
80
+ const current = normalizePresetState(state);
81
+ const template = normalizeTemplate(values);
82
+ const projectAlias = template.projectAlias;
83
+ const recentProjects = projectAlias
84
+ ? [projectAlias, ...current.recentProjects.filter(item => item !== projectAlias)].slice(0, MAX_RECENT_PROJECTS)
85
+ : current.recentProjects;
86
+
87
+ return {
88
+ recentProjects,
89
+ lastTemplate: hasTemplateValues(template) ? template : current.lastTemplate
90
+ };
91
+ }
92
+
93
+ export function applyAnnotationTemplate(form, template, { includeProjectAlias = true } = {}) {
94
+ if (!template) return { ...form };
95
+ const next = { ...form };
96
+ for (const field of TEMPLATE_FIELDS) {
97
+ if (field === 'projectAlias' && !includeProjectAlias) continue;
98
+ if (template[field]) next[field] = template[field];
99
+ }
100
+ return next;
101
+ }
102
+
103
+ function normalizePresetState(value = {}) {
104
+ const recentProjects = Array.isArray(value.recentProjects)
105
+ ? uniqueStrings(value.recentProjects).slice(0, MAX_RECENT_PROJECTS)
106
+ : [];
107
+ const lastTemplate = value.lastTemplate ? normalizeTemplate(value.lastTemplate) : null;
108
+ return {
109
+ recentProjects,
110
+ lastTemplate: lastTemplate && hasTemplateValues(lastTemplate) ? lastTemplate : null
111
+ };
112
+ }
113
+
114
+ function normalizeTemplate(values = {}) {
115
+ const template = {};
116
+ for (const field of TEMPLATE_FIELDS) {
117
+ const value = normalizeText(values[field]);
118
+ if (value) template[field] = value;
119
+ }
120
+ return template;
121
+ }
122
+
123
+ function hasTemplateValues(template = {}) {
124
+ return TEMPLATE_FIELDS.some(field => Boolean(template[field]));
125
+ }
126
+
127
+ function uniqueStrings(values = []) {
128
+ const out = [];
129
+ for (const value of values) {
130
+ const text = normalizeText(value);
131
+ if (text && !out.includes(text)) out.push(text);
132
+ }
133
+ return out;
134
+ }
135
+
136
+ function normalizeText(value) {
137
+ return String(value ?? '').trim();
138
+ }
@@ -0,0 +1,328 @@
1
+ const DEFAULT_TASK_TYPE = '未分类';
2
+ const DEFAULT_OUTPUT_STATUS = '未标注';
3
+ const DEFAULT_WORK_PURPOSE = '未说明';
4
+ const DEFAULT_WORK_STAGE = '未说明';
5
+ const DEFAULT_VALUE_LEVEL = '未评估';
6
+ const STATUS_IN_PROGRESS = '进行中';
7
+ const STATUS_COMPLETED = '已完成';
8
+ const STATUS_PUBLISHED = '已发布';
9
+ const STATUS_DISCARDED = '已废弃';
10
+
11
+ export const ATTRIBUTION_STATUS_ROWS = [
12
+ { id: 'published', label: '已发布', outputStatus: '已发布', tone: 'published' },
13
+ { id: 'completed', label: '已完成', outputStatus: '已完成', tone: 'completed' },
14
+ { id: 'inProgress', label: '进行中', outputStatus: '进行中', tone: 'progress' },
15
+ { id: 'discarded', label: '已废弃', outputStatus: '已废弃', tone: 'discarded' },
16
+ { id: 'unattributed', label: '未归因', tone: 'unattributed' }
17
+ ];
18
+
19
+ export function isUnattributedSession(session = {}) {
20
+ return (session.taskType || DEFAULT_TASK_TYPE) === DEFAULT_TASK_TYPE
21
+ || (session.outputStatus || DEFAULT_OUTPUT_STATUS) === DEFAULT_OUTPUT_STATUS;
22
+ }
23
+
24
+ export function isReviewUnattributedSession(session = {}) {
25
+ return isUnattributedSession(session)
26
+ || (session.workPurpose || DEFAULT_WORK_PURPOSE) === DEFAULT_WORK_PURPOSE
27
+ || (session.workStage || DEFAULT_WORK_STAGE) === DEFAULT_WORK_STAGE
28
+ || (session.valueLevel || DEFAULT_VALUE_LEVEL) === DEFAULT_VALUE_LEVEL;
29
+ }
30
+
31
+ export function buildUnattributedSessions(sessions = []) {
32
+ return sessions
33
+ .filter(isUnattributedSession)
34
+ .sort((a, b) => (b.totalTokens || 0) - (a.totalTokens || 0));
35
+ }
36
+
37
+ export function buildReviewUnattributedSessions(sessions = []) {
38
+ return sessions
39
+ .filter(isReviewUnattributedSession)
40
+ .sort((a, b) => (b.totalTokens || 0) - (a.totalTokens || 0));
41
+ }
42
+
43
+ export function buildPendingConfirmationSessions(sessions = []) {
44
+ return sessions
45
+ .filter(session =>
46
+ isReviewUnattributedSession(session)
47
+ || session.attributionQuality === 'auto-low'
48
+ || Boolean(session.autoSuggestion && !session.autoSuggestion.canApply)
49
+ )
50
+ .sort((a, b) => (b.costUSD || 0) - (a.costUSD || 0)
51
+ || (b.totalTokens || 0) - (a.totalTokens || 0));
52
+ }
53
+
54
+ export function buildReviewAttributionProgress(sessions = []) {
55
+ const total = aggregateSessions(sessions);
56
+ const unattributed = buildReviewUnattributedSessions(sessions);
57
+ const unattributedTotal = aggregateSessions(unattributed);
58
+ return {
59
+ sessionCount: total.sessionCount,
60
+ attributedSessionCount: total.sessionCount - unattributedTotal.sessionCount,
61
+ unattributedSessionCount: unattributedTotal.sessionCount,
62
+ totalTokens: total.totalTokens,
63
+ attributedTokens: Math.max(0, total.totalTokens - unattributedTotal.totalTokens),
64
+ unattributedTokens: unattributedTotal.totalTokens,
65
+ costUSD: total.costUSD,
66
+ unattributedCostUSD: unattributedTotal.costUSD,
67
+ completionShare: total.sessionCount ? (total.sessionCount - unattributedTotal.sessionCount) / total.sessionCount : 0,
68
+ tokenCompletionShare: total.totalTokens ? (total.totalTokens - unattributedTotal.totalTokens) / total.totalTokens : 0
69
+ };
70
+ }
71
+
72
+ export function buildReviewAttributionChecklist(sessions = [], { limit = 10, generatedAt = new Date() } = {}) {
73
+ const rows = buildReviewUnattributedSessions(sessions).slice(0, Math.max(1, limit));
74
+ const lines = [
75
+ '# Token Studio 归因工作清单',
76
+ '',
77
+ `- 生成时间:${formatDateTime(generatedAt)}`,
78
+ '- 口径:仅使用本地结构化用量和现有标注,不包含对话正文。',
79
+ '- 用法:按优先级打开对应 session,人工核对项目、任务、目的、阶段、价值和产出状态后再保存。',
80
+ ''
81
+ ];
82
+
83
+ if (!rows.length) {
84
+ return [
85
+ ...lines,
86
+ '当前筛选没有待补齐归因的 session。'
87
+ ].join('\n');
88
+ }
89
+
90
+ return [
91
+ ...lines,
92
+ markdownTable(
93
+ ['优先级', '项目', 'Session', '缺失字段', 'Tokens', '官方价', '模型', '来源', '最后活动'],
94
+ rows.map((session, index) => [
95
+ index + 1,
96
+ sessionProjectLabel(session),
97
+ session.sessionId || '',
98
+ missingReviewAttributionFields(session).join('、'),
99
+ formatInt(session.totalTokens || 0),
100
+ session.costUSD > 0 ? money(session.costUSD) : '未定价/无官方价',
101
+ session.model || session.pricingModel || '',
102
+ session.source || '',
103
+ session.lastActivity || ''
104
+ ])
105
+ )
106
+ ].join('\n');
107
+ }
108
+
109
+ export function missingReviewAttributionFields(session = {}) {
110
+ const fields = [];
111
+ if ((session.taskType || DEFAULT_TASK_TYPE) === DEFAULT_TASK_TYPE) fields.push('任务类型');
112
+ if ((session.outputStatus || DEFAULT_OUTPUT_STATUS) === DEFAULT_OUTPUT_STATUS) fields.push('产出状态');
113
+ if ((session.workPurpose || DEFAULT_WORK_PURPOSE) === DEFAULT_WORK_PURPOSE) fields.push('工作目的');
114
+ if ((session.workStage || DEFAULT_WORK_STAGE) === DEFAULT_WORK_STAGE) fields.push('工作阶段');
115
+ if ((session.valueLevel || DEFAULT_VALUE_LEVEL) === DEFAULT_VALUE_LEVEL) fields.push('产出价值');
116
+ return fields;
117
+ }
118
+
119
+ export function buildAttributionStatusSummary(sessions = []) {
120
+ const total = aggregateSessions(sessions);
121
+ return ATTRIBUTION_STATUS_ROWS.map(row => {
122
+ const matching = row.id === 'unattributed'
123
+ ? sessions.filter(isUnattributedSession)
124
+ : sessions.filter(session => (session.outputStatus || DEFAULT_OUTPUT_STATUS) === row.outputStatus);
125
+ const aggregate = aggregateSessions(matching);
126
+ return {
127
+ ...row,
128
+ ...aggregate,
129
+ share: total.totalTokens ? aggregate.totalTokens / total.totalTokens : 0
130
+ };
131
+ });
132
+ }
133
+
134
+ export function buildRiskDistribution(sessions = []) {
135
+ const total = aggregateSessions(sessions);
136
+ const groups = [
137
+ { id: 'unattributed', label: '未归因', tone: 'unattributed', sessions: sessions.filter(isUnattributedSession) },
138
+ { id: 'inProgress', label: '进行中', tone: 'progress', sessions: sessions.filter(session => session.outputStatus === STATUS_IN_PROGRESS) },
139
+ { id: 'discarded', label: '已废弃', tone: 'discarded', sessions: sessions.filter(session => session.outputStatus === STATUS_DISCARDED) }
140
+ ];
141
+ return groups.map(row => {
142
+ const aggregate = aggregateSessions(row.sessions);
143
+ return {
144
+ id: row.id,
145
+ label: row.label,
146
+ tone: row.tone,
147
+ ...aggregate,
148
+ share: total.totalTokens ? aggregate.totalTokens / total.totalTokens : 0
149
+ };
150
+ });
151
+ }
152
+
153
+ export function buildProjectRoiRows(sessions = []) {
154
+ const rows = new Map();
155
+ for (const session of sessions) {
156
+ const project = sessionProjectLabel(session);
157
+ if (!rows.has(project)) {
158
+ rows.set(project, {
159
+ project,
160
+ sessionCount: 0,
161
+ totalTokens: 0,
162
+ costUSD: 0,
163
+ publishedTokens: 0,
164
+ publishedCostUSD: 0,
165
+ completedTokens: 0,
166
+ completedCostUSD: 0,
167
+ inProgressTokens: 0,
168
+ inProgressCostUSD: 0,
169
+ discardedTokens: 0,
170
+ discardedCostUSD: 0,
171
+ unattributedTokens: 0,
172
+ unattributedCostUSD: 0,
173
+ publishedCount: 0,
174
+ completedCount: 0,
175
+ inProgressCount: 0,
176
+ discardedCount: 0,
177
+ unattributedCount: 0
178
+ });
179
+ }
180
+ const row = rows.get(project);
181
+ const tokens = session.totalTokens || 0;
182
+ const cost = session.costUSD || 0;
183
+ row.sessionCount += 1;
184
+ row.totalTokens += tokens;
185
+ row.costUSD += cost;
186
+
187
+ if (session.outputStatus === STATUS_PUBLISHED) {
188
+ row.publishedTokens += tokens;
189
+ row.publishedCostUSD += cost;
190
+ row.publishedCount += 1;
191
+ }
192
+ if (session.outputStatus === STATUS_COMPLETED) {
193
+ row.completedTokens += tokens;
194
+ row.completedCostUSD += cost;
195
+ row.completedCount += 1;
196
+ }
197
+ if (session.outputStatus === STATUS_IN_PROGRESS) {
198
+ row.inProgressTokens += tokens;
199
+ row.inProgressCostUSD += cost;
200
+ row.inProgressCount += 1;
201
+ }
202
+ if (session.outputStatus === STATUS_DISCARDED) {
203
+ row.discardedTokens += tokens;
204
+ row.discardedCostUSD += cost;
205
+ row.discardedCount += 1;
206
+ }
207
+ if (isUnattributedSession(session)) {
208
+ row.unattributedTokens += tokens;
209
+ row.unattributedCostUSD += cost;
210
+ row.unattributedCount += 1;
211
+ }
212
+ }
213
+
214
+ return Array.from(rows.values())
215
+ .map(row => ({
216
+ ...row,
217
+ productiveTokens: row.publishedTokens + row.completedTokens,
218
+ productiveCostUSD: row.publishedCostUSD + row.completedCostUSD,
219
+ productiveShare: row.totalTokens ? (row.publishedTokens + row.completedTokens) / row.totalTokens : 0,
220
+ riskShare: row.totalTokens ? (row.discardedTokens + row.unattributedTokens) / row.totalTokens : 0
221
+ }))
222
+ .sort((a, b) => b.totalTokens - a.totalTokens);
223
+ }
224
+
225
+ export function buildWeeklyReview(sessions = [], { today = null, days = 7 } = {}) {
226
+ const now = parseDate(today) || new Date();
227
+ const start = new Date(now);
228
+ start.setHours(0, 0, 0, 0);
229
+ start.setDate(start.getDate() - Math.max(1, days) + 1);
230
+ const end = new Date(now);
231
+ end.setHours(23, 59, 59, 999);
232
+
233
+ const weeklySessions = sessions.filter(session => {
234
+ const date = parseDate(session.lastActivity);
235
+ return date && date >= start && date <= end;
236
+ });
237
+ const projectRows = buildProjectRoiRows(weeklySessions);
238
+ const discardedSessions = weeklySessions.filter(session => session.outputStatus === STATUS_DISCARDED);
239
+ const publishedOutputs = weeklySessions
240
+ .filter(session => session.outputStatus === STATUS_PUBLISHED && session.outputUrl)
241
+ .sort((a, b) => (b.totalTokens || 0) - (a.totalTokens || 0));
242
+
243
+ return {
244
+ startDate: formatDate(start),
245
+ endDate: formatDate(end),
246
+ totals: aggregateSessions(weeklySessions),
247
+ highCostProjects: projectRows.slice(0, 5),
248
+ discarded: aggregateSessions(discardedSessions),
249
+ unattributedQueue: buildUnattributedSessions(weeklySessions).slice(0, 8),
250
+ publishedOutputs: publishedOutputs.slice(0, 8)
251
+ };
252
+ }
253
+
254
+ export function aggregateSessions(sessions = []) {
255
+ return sessions.reduce((acc, session) => {
256
+ acc.sessionCount += 1;
257
+ acc.totalTokens += session.totalTokens || 0;
258
+ acc.inputTokens += session.inputTokens || 0;
259
+ acc.outputTokens += session.outputTokens || 0;
260
+ acc.costUSD += session.costUSD || 0;
261
+ return acc;
262
+ }, {
263
+ sessionCount: 0,
264
+ totalTokens: 0,
265
+ inputTokens: 0,
266
+ outputTokens: 0,
267
+ costUSD: 0
268
+ });
269
+ }
270
+
271
+ export function sessionProjectLabel(session = {}) {
272
+ if (session.projectAlias) return session.projectAlias;
273
+ if (session.projectPath && session.projectPath !== 'Unknown Project') return session.projectPath;
274
+ if (session.sessionId) return session.sessionId.split('/').slice(-1)[0] || session.sessionId;
275
+ return '未归档项目';
276
+ }
277
+
278
+ function parseDate(value) {
279
+ if (!value) return null;
280
+ const text = String(value).slice(0, 10);
281
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
282
+ if (!match) return null;
283
+ return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
284
+ }
285
+
286
+ function formatDate(value) {
287
+ return [
288
+ value.getFullYear(),
289
+ String(value.getMonth() + 1).padStart(2, '0'),
290
+ String(value.getDate()).padStart(2, '0')
291
+ ].join('-');
292
+ }
293
+
294
+ function formatDateTime(value) {
295
+ const d = value instanceof Date ? value : new Date(value);
296
+ if (Number.isNaN(d.getTime())) return '';
297
+ return `${formatDate(d)} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
298
+ }
299
+
300
+ function markdownTable(headers, rows) {
301
+ return [
302
+ `| ${headers.map(markdownCell).join(' | ')} |`,
303
+ `| ${headers.map(() => '---').join(' | ')} |`,
304
+ ...rows.map(row => `| ${row.map(markdownCell).join(' | ')} |`)
305
+ ].join('\n');
306
+ }
307
+
308
+ function markdownCell(value) {
309
+ const text = String(value ?? '')
310
+ .replace(/\r?\n/g, ' ')
311
+ .replace(/\s+/g, ' ')
312
+ .trim();
313
+ const formulaSafe = /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
314
+ return formulaSafe.replace(/\|/g, '\\|');
315
+ }
316
+
317
+ function formatInt(value) {
318
+ return new Intl.NumberFormat('zh-CN').format(Math.round(Number(value || 0)));
319
+ }
320
+
321
+ function money(value) {
322
+ return new Intl.NumberFormat('en-US', {
323
+ style: 'currency',
324
+ currency: 'USD',
325
+ minimumFractionDigits: 2,
326
+ maximumFractionDigits: 2
327
+ }).format(Number(value || 0));
328
+ }