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
package/src/server.mjs ADDED
@@ -0,0 +1,1240 @@
1
+ import { copyFileSync, createReadStream, existsSync, mkdirSync } from 'node:fs';
2
+ import { spawn } from 'node:child_process';
3
+ import { createServer } from 'node:http';
4
+ import { dirname, extname, join, resolve } from 'node:path';
5
+ import { URL } from 'node:url';
6
+ import {
7
+ attachOfficialPricing,
8
+ officialPricingMetadata
9
+ } from './pricing.mjs';
10
+ import {
11
+ AUTO_ATTRIBUTION_THRESHOLD,
12
+ buildAutoAttributionPlan
13
+ } from './auto-attribution.mjs';
14
+ import {
15
+ ANNOTATION_SOURCES,
16
+ ADVISOR_ACTION_STATUSES,
17
+ BUDGET_WINDOW_TYPES,
18
+ DEFAULT_SESSION_ANNOTATION,
19
+ OUTPUT_STATUSES,
20
+ OUTPUT_TYPES,
21
+ PROJECT_ALIAS_MATCH_TYPES,
22
+ TASK_TYPES,
23
+ VALUE_LEVELS,
24
+ WORK_PURPOSES,
25
+ WORK_STAGES,
26
+ applyAutoSessionAnnotations,
27
+ batchUpsertSessionAnnotations,
28
+ defaultDbPath,
29
+ deleteProjectAliasRule,
30
+ deleteAdvisorAction,
31
+ deleteBudgetProfile,
32
+ deleteSessionAnnotation,
33
+ deleteSessionOutput,
34
+ exportAnnotationData,
35
+ importAnnotationData,
36
+ linkWorkItemSessions,
37
+ listProjectAliasRules,
38
+ listAdvisorActions,
39
+ listBudgetProfiles,
40
+ listTokenEvents,
41
+ listWorkItems,
42
+ matchProjectAliasRule,
43
+ openDb,
44
+ recordRun,
45
+ deleteWorkItem,
46
+ undoAutoSessionAnnotations,
47
+ upsertAdvisorAction,
48
+ upsertBudgetProfile,
49
+ upsertDaily,
50
+ upsertProjectAliasRule,
51
+ upsertSession,
52
+ upsertSessionAnnotation,
53
+ upsertSessionOutput,
54
+ upsertWorkItem
55
+ } from './db.mjs';
56
+ import { loadCollectorConfig } from './collector-config.mjs';
57
+ import { detectCollectors } from './collector-registry.mjs';
58
+ import { runPrivacyCheck } from './privacy-check.mjs';
59
+ import { buildModelPolicy, formatModelPolicyMarkdown } from './model-policy.mjs';
60
+ import { buildLiveSnapshot } from './live.mjs';
61
+ import { applyCcusageImport, parseCcusageJsonText, planCcusageImport } from './ccusage-import.mjs';
62
+ import { buildSourceHealth } from './source-health.mjs';
63
+
64
+ const port = Number(process.env.PORT || 4173);
65
+ const host = process.env.HOST || process.env.BIND_HOST || '127.0.0.1';
66
+ const staticDir = existsSync(resolve(process.cwd(), 'dist'))
67
+ ? resolve(process.cwd(), 'dist')
68
+ : resolve(process.cwd(), 'public');
69
+ const dbPath = process.env.DB_PATH || defaultDbPath;
70
+ const db = openDb(dbPath);
71
+ let activeCollection = null;
72
+ let collectionState = {
73
+ status: 'idle',
74
+ message: '尚未启动采集',
75
+ startedAt: null,
76
+ finishedAt: null,
77
+ exitCode: null,
78
+ stdout: '',
79
+ stderr: '',
80
+ backup: null
81
+ };
82
+
83
+ const server = createServer((req, res) => {
84
+ const url = new URL(req.url, `http://${req.headers.host}`);
85
+ if (url.pathname.startsWith('/api/')) {
86
+ handleApi(req, url, res);
87
+ return;
88
+ }
89
+ serveStatic(url.pathname, res);
90
+ });
91
+
92
+ server.listen(port, host, () => {
93
+ const displayHost = host === '0.0.0.0' || host === '::' ? 'localhost' : host;
94
+ console.log(`Token Studio ROI: http://${displayHost}:${port} (listening on ${host})`);
95
+ startScheduledCollect();
96
+ });
97
+
98
+ function handleApi(req, url, res) {
99
+ if (url.pathname === '/api/summary') {
100
+ sendJson(res, {
101
+ totals: one(`
102
+ SELECT
103
+ COALESCE(SUM(total_tokens), 0) AS totalTokens,
104
+ COALESCE(SUM(input_tokens), 0) AS inputTokens,
105
+ COALESCE(SUM(output_tokens), 0) AS outputTokens,
106
+ COALESCE(SUM(cache_creation_tokens + cache_read_tokens + cached_input_tokens), 0) AS cacheTokens,
107
+ COALESCE(SUM(reasoning_output_tokens), 0) AS reasoningTokens,
108
+ COALESCE(SUM(cost_usd), 0) AS costUSD
109
+ FROM daily_usage
110
+ `),
111
+ bySource: all(`
112
+ SELECT source, device,
113
+ SUM(total_tokens) AS totalTokens,
114
+ SUM(input_tokens) AS inputTokens,
115
+ SUM(output_tokens) AS outputTokens,
116
+ SUM(cost_usd) AS costUSD
117
+ FROM daily_usage
118
+ GROUP BY source, device
119
+ ORDER BY totalTokens DESC
120
+ `),
121
+ byDay: all(`
122
+ SELECT usage_date AS date, source, SUM(total_tokens) AS totalTokens, SUM(cost_usd) AS costUSD
123
+ FROM daily_usage
124
+ GROUP BY usage_date, source
125
+ ORDER BY usage_date
126
+ `),
127
+ byModel: all(`
128
+ SELECT source, model, SUM(total_tokens) AS totalTokens, SUM(cost_usd) AS costUSD
129
+ FROM daily_usage
130
+ WHERE model != ''
131
+ GROUP BY source, model
132
+ ORDER BY totalTokens DESC
133
+ LIMIT 20
134
+ `),
135
+ topSessions: all(`
136
+ SELECT device, source, session_id AS sessionId, last_activity AS lastActivity,
137
+ project_path AS projectPath, total_tokens AS totalTokens, cost_usd AS costUSD
138
+ FROM session_usage
139
+ ORDER BY total_tokens DESC
140
+ LIMIT 30
141
+ `),
142
+ runs: all(`
143
+ SELECT device, source, status, message, collected_at AS collectedAt
144
+ FROM collection_runs
145
+ ORDER BY id DESC
146
+ LIMIT 20
147
+ `)
148
+ });
149
+ return;
150
+ }
151
+ if (url.pathname === '/api/data') {
152
+ const aliasRules = listProjectAliasRules(db);
153
+ const enabledAliasRules = aliasRules.filter(rule => rule.enabled);
154
+ const rawSessions = all(`
155
+ SELECT s.device, s.source,
156
+ s.session_id AS sessionId,
157
+ s.last_activity AS lastActivity,
158
+ s.project_path AS projectPath,
159
+ s.input_tokens AS inputTokens,
160
+ s.output_tokens AS outputTokens,
161
+ s.cache_creation_tokens AS cacheCreationTokens,
162
+ s.cache_read_tokens AS cacheReadTokens,
163
+ s.cached_input_tokens AS cachedInputTokens,
164
+ s.reasoning_output_tokens AS reasoningOutputTokens,
165
+ s.total_tokens AS totalTokens,
166
+ s.cost_usd AS costUSD,
167
+ a.project_alias AS manualProjectAlias,
168
+ COALESCE(a.task_type, '未分类') AS taskType,
169
+ COALESCE(a.output_status, '未标注') AS outputStatus,
170
+ COALESCE(a.work_purpose, '未说明') AS workPurpose,
171
+ COALESCE(a.work_stage, '未说明') AS workStage,
172
+ COALESCE(a.value_level, '未评估') AS valueLevel,
173
+ a.note,
174
+ a.annotation_source AS annotationSource,
175
+ a.annotation_confidence AS annotationConfidence,
176
+ a.annotation_reason AS annotationReason,
177
+ a.auto_version AS autoVersion,
178
+ a.auto_run_id AS autoRunId,
179
+ a.auto_updated_at AS autoUpdatedAt,
180
+ a.updated_at AS annotationUpdatedAt,
181
+ o.output_url AS outputUrl,
182
+ o.output_label AS outputLabel,
183
+ COALESCE(o.output_type, '未分类') AS outputType,
184
+ o.updated_at AS outputUpdatedAt
185
+ FROM session_usage s
186
+ LEFT JOIN session_annotations a
187
+ ON a.device = s.device
188
+ AND a.source = s.source
189
+ AND a.session_id = s.session_id
190
+ LEFT JOIN session_outputs o
191
+ ON o.device = s.device
192
+ AND o.source = s.source
193
+ AND o.session_id = s.session_id
194
+ ORDER BY s.total_tokens DESC
195
+ `);
196
+ const rawRuns = all(`
197
+ SELECT id, device, source, status, message,
198
+ collected_at AS collectedAt
199
+ FROM collection_runs
200
+ ORDER BY id DESC
201
+ `);
202
+
203
+ // Normalize sessions
204
+ const sessions = rawSessions.map(s => {
205
+ const projectPath = (s.projectPath && s.projectPath !== 'Unknown Project')
206
+ ? s.projectPath
207
+ : (s.sessionId ? s.sessionId.split('/').slice(-1)[0] || s.sessionId : null);
208
+ const ruleProjectAlias = matchProjectAliasRule(projectPath, enabledAliasRules);
209
+ const manualProjectAlias = s.manualProjectAlias || null;
210
+ const model = modelFromSessionId(s.sessionId);
211
+ return {
212
+ ...s,
213
+ ...DEFAULT_SESSION_ANNOTATION,
214
+ model,
215
+ lastActivity: s.lastActivity ? s.lastActivity.slice(0, 10) : null,
216
+ projectPath,
217
+ projectAlias: manualProjectAlias || ruleProjectAlias || null,
218
+ manualProjectAlias,
219
+ ruleProjectAlias,
220
+ taskType: s.taskType || DEFAULT_SESSION_ANNOTATION.taskType,
221
+ outputStatus: s.outputStatus || DEFAULT_SESSION_ANNOTATION.outputStatus,
222
+ workPurpose: s.workPurpose || DEFAULT_SESSION_ANNOTATION.workPurpose,
223
+ workStage: s.workStage || DEFAULT_SESSION_ANNOTATION.workStage,
224
+ valueLevel: s.valueLevel || DEFAULT_SESSION_ANNOTATION.valueLevel,
225
+ note: s.note || null,
226
+ annotationSource: s.annotationSource || null,
227
+ annotationConfidence: s.annotationConfidence == null ? null : Number(s.annotationConfidence),
228
+ annotationReason: s.annotationReason || null,
229
+ autoVersion: s.autoVersion || null,
230
+ autoRunId: s.autoRunId || null,
231
+ autoUpdatedAt: s.autoUpdatedAt || null,
232
+ attributionQuality: attributionQuality(s.annotationSource, s.annotationConfidence, s.annotationUpdatedAt),
233
+ annotationUpdatedAt: s.annotationUpdatedAt || null,
234
+ outputUrl: s.outputUrl || null,
235
+ outputLabel: s.outputLabel || null,
236
+ outputType: s.outputType || DEFAULT_SESSION_ANNOTATION.outputType,
237
+ outputUpdatedAt: s.outputUpdatedAt || null
238
+ };
239
+ });
240
+
241
+ // Build (device, source) -> projectPath map for enriching daily rows
242
+ // Use the project with the most tokens for each (device, source) pair
243
+ const projMap = new Map();
244
+ for (const s of rawSessions) {
245
+ const proj = (s.projectPath && s.projectPath !== 'Unknown Project')
246
+ ? s.projectPath
247
+ : (s.sessionId ? s.sessionId.split('/').slice(-1)[0] || s.sessionId : null);
248
+ if (!proj) continue;
249
+ const key = `${s.device}::${s.source}`;
250
+ const cur = projMap.get(key);
251
+ if (!cur || s.totalTokens > cur.tokens) {
252
+ projMap.set(key, { project: proj, tokens: s.totalTokens });
253
+ }
254
+ }
255
+
256
+ const rawDaily = all(`
257
+ SELECT rowid AS id, device, source,
258
+ usage_date AS usageDate, model,
259
+ input_tokens AS inputTokens,
260
+ output_tokens AS outputTokens,
261
+ cache_creation_tokens AS cacheCreationTokens,
262
+ cache_read_tokens AS cacheReadTokens,
263
+ cached_input_tokens AS cachedInputTokens,
264
+ reasoning_output_tokens AS reasoningOutputTokens,
265
+ total_tokens AS totalTokens,
266
+ cost_usd AS costUSD
267
+ FROM daily_usage
268
+ ORDER BY usage_date DESC
269
+ `);
270
+ const daily = rawDaily.map(d => attachOfficialPricing({
271
+ ...d,
272
+ projectPath: projMap.get(`${d.device}::${d.source}`)?.project || null
273
+ }, d.model, providerFromSource(d.source)));
274
+ const pricedSessions = sessions.map(s => attachOfficialPricing(
275
+ s,
276
+ s.model,
277
+ providerFromSource(s.source)
278
+ ));
279
+
280
+ sendJson(res, {
281
+ meta: {
282
+ taskTypes: TASK_TYPES,
283
+ outputStatuses: OUTPUT_STATUSES,
284
+ workPurposes: WORK_PURPOSES,
285
+ workStages: WORK_STAGES,
286
+ valueLevels: VALUE_LEVELS,
287
+ annotationSources: ANNOTATION_SOURCES,
288
+ budgetWindowTypes: BUDGET_WINDOW_TYPES,
289
+ advisorActionStatuses: ADVISOR_ACTION_STATUSES,
290
+ outputTypes: OUTPUT_TYPES,
291
+ projectAliasMatchTypes: PROJECT_ALIAS_MATCH_TYPES,
292
+ projectAliasRules: aliasRules,
293
+ demoMode: process.env.TOKEN_STUDIO_DEMO_MODE === '1',
294
+ officialPricing: officialPricingMetadata(daily),
295
+ sourceHealth: sourceHealth()
296
+ },
297
+ daily,
298
+ sessions: pricedSessions,
299
+ workItems: listWorkItems(db),
300
+ budgetProfiles: listBudgetProfiles(db),
301
+ advisorActions: listAdvisorActions(db),
302
+ tokenEvents: listTokenEvents(db, { limit: 1000 }),
303
+ // Normalize runs: strip newlines from messages, shorten device names
304
+ runs: rawRuns.map(r => ({
305
+ ...r,
306
+ message: r.message ? r.message.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim() : '',
307
+ device: r.device ? r.device.replace(/\.local$/, '').replace(/^(.{30}).+$/, '$1…') : r.device
308
+ }))
309
+ });
310
+ return;
311
+ }
312
+ if (url.pathname === '/api/collectors' && req.method === 'GET') {
313
+ const collectors = detectCollectors();
314
+ sendJson(res, { collectors, sourceHealth: sourceHealth(collectors) });
315
+ return;
316
+ }
317
+ if (url.pathname === '/api/source-health' && req.method === 'GET') {
318
+ if (!validateLocalRead(req, res, '来源健康接口')) return;
319
+ sendJson(res, { sources: sourceHealth() });
320
+ return;
321
+ }
322
+ if (url.pathname === '/api/budget-profiles' && req.method === 'GET') {
323
+ if (!validateLocalRead(req, res, '预算配置接口')) return;
324
+ sendJson(res, { profiles: listBudgetProfiles(db), windowTypes: BUDGET_WINDOW_TYPES });
325
+ return;
326
+ }
327
+ if (url.pathname === '/api/budget-profiles' && req.method === 'POST') {
328
+ handleBudgetProfileUpsert(req, res);
329
+ return;
330
+ }
331
+ if (url.pathname === '/api/budget-profiles' && req.method === 'DELETE') {
332
+ handleBudgetProfileDelete(req, url, res);
333
+ return;
334
+ }
335
+ if (url.pathname === '/api/advisor-actions' && req.method === 'GET') {
336
+ if (!validateLocalRead(req, res, '建议行动接口')) return;
337
+ sendJson(res, {
338
+ actions: listAdvisorActions(db, {
339
+ periodStart: url.searchParams.get('periodStart'),
340
+ periodEnd: url.searchParams.get('periodEnd')
341
+ }),
342
+ statuses: ADVISOR_ACTION_STATUSES
343
+ });
344
+ return;
345
+ }
346
+ if (url.pathname === '/api/advisor-actions' && req.method === 'POST') {
347
+ handleAdvisorActionUpsert(req, res);
348
+ return;
349
+ }
350
+ if (url.pathname.startsWith('/api/advisor-actions/') && req.method === 'DELETE') {
351
+ handleAdvisorActionDelete(req, url, res);
352
+ return;
353
+ }
354
+ if (url.pathname === '/api/live' && req.method === 'GET') {
355
+ if (!validateLocalRead(req, res, '实时监控接口')) return;
356
+ sendJson(res, buildLiveSnapshot({
357
+ sessions: liveSessions(),
358
+ tokenEvents: liveTokenEvents(),
359
+ budgetProfiles: listBudgetProfiles(db).filter(profile => profile.enabled),
360
+ runs: all(`
361
+ SELECT device, source, status, message, collected_at AS collectedAt
362
+ FROM collection_runs
363
+ ORDER BY id DESC
364
+ LIMIT 5
365
+ `),
366
+ windowMinutes: Number(url.searchParams.get('windowMinutes') || 15)
367
+ }));
368
+ return;
369
+ }
370
+ if (url.pathname === '/api/privacy-check' && req.method === 'GET') {
371
+ if (!validateLocalRead(req, res, '隐私检查接口')) return;
372
+ sendJson(res, runPrivacyCheck());
373
+ return;
374
+ }
375
+ if (url.pathname === '/api/model-policy.md' && req.method === 'GET') {
376
+ if (!validateLocalRead(req, res, '模型策略接口')) return;
377
+ const { sessions } = buildAutoAttributionContext();
378
+ sendText(res, formatModelPolicyMarkdown(buildModelPolicy({ sessions })), 'text/markdown; charset=utf-8');
379
+ return;
380
+ }
381
+ if (url.pathname === '/api/work-items' && req.method === 'GET') {
382
+ sendJson(res, { workItems: listWorkItems(db) });
383
+ return;
384
+ }
385
+ if (url.pathname === '/api/work-items' && req.method === 'POST') {
386
+ handleWorkItemUpsert(req, res);
387
+ return;
388
+ }
389
+ if (url.pathname === '/api/work-items/link-sessions' && req.method === 'POST') {
390
+ handleWorkItemLinkSessions(req, res);
391
+ return;
392
+ }
393
+ if (url.pathname.startsWith('/api/work-items/') && req.method === 'DELETE') {
394
+ handleWorkItemDelete(req, url, res);
395
+ return;
396
+ }
397
+ if (url.pathname === '/api/project-alias-rules' && req.method === 'GET') {
398
+ sendJson(res, { rules: listProjectAliasRules(db), matchTypes: PROJECT_ALIAS_MATCH_TYPES });
399
+ return;
400
+ }
401
+ if (url.pathname === '/api/auto-attribution/suggestions' && req.method === 'GET') {
402
+ handleAutoAttributionSuggestions(req, url, res);
403
+ return;
404
+ }
405
+ if (url.pathname === '/api/auto-attribution/apply' && req.method === 'POST') {
406
+ handleAutoAttributionApply(req, res);
407
+ return;
408
+ }
409
+ if (url.pathname === '/api/auto-attribution/undo' && req.method === 'POST') {
410
+ handleAutoAttributionUndo(req, res);
411
+ return;
412
+ }
413
+ if (url.pathname === '/api/project-alias-rules' && req.method === 'POST') {
414
+ handleProjectAliasRuleUpsert(req, res);
415
+ return;
416
+ }
417
+ if (url.pathname === '/api/project-alias-rules' && req.method === 'DELETE') {
418
+ handleProjectAliasRuleDelete(req, url, res);
419
+ return;
420
+ }
421
+ if (url.pathname === '/api/session-annotations/batch' && req.method === 'POST') {
422
+ handleSessionAnnotationBatch(req, res);
423
+ return;
424
+ }
425
+ if (url.pathname === '/api/session-annotations' && req.method === 'POST') {
426
+ handleSessionAnnotationUpsert(req, res);
427
+ return;
428
+ }
429
+ if (url.pathname === '/api/session-annotations' && req.method === 'DELETE') {
430
+ handleSessionAnnotationDelete(req, url, res);
431
+ return;
432
+ }
433
+ if (url.pathname === '/api/session-outputs' && req.method === 'POST') {
434
+ handleSessionOutputUpsert(req, res);
435
+ return;
436
+ }
437
+ if (url.pathname === '/api/session-outputs' && req.method === 'DELETE') {
438
+ handleSessionOutputDelete(req, url, res);
439
+ return;
440
+ }
441
+ if (url.pathname === '/api/backup' && req.method === 'POST') {
442
+ handleBackup(req, res);
443
+ return;
444
+ }
445
+ if (url.pathname === '/api/export/annotations' && req.method === 'GET') {
446
+ handleExportAnnotations(req, res);
447
+ return;
448
+ }
449
+ if (url.pathname === '/api/import/annotations' && req.method === 'POST') {
450
+ handleImportAnnotations(req, res);
451
+ return;
452
+ }
453
+ if (url.pathname === '/api/import/ccusage-json' && req.method === 'POST') {
454
+ handleImportCcusageJson(req, res);
455
+ return;
456
+ }
457
+ if (url.pathname === '/api/ingest' && req.method === 'POST') {
458
+ handleIngest(req, res);
459
+ return;
460
+ }
461
+ if (url.pathname === '/api/collect' && req.method === 'POST') {
462
+ handleCollect(req, res);
463
+ return;
464
+ }
465
+ if (url.pathname === '/api/collect/status') {
466
+ sendJson(res, collectionState);
467
+ return;
468
+ }
469
+ sendJson(res, { error: 'Not found' }, 404);
470
+ }
471
+
472
+ async function handleProjectAliasRuleUpsert(req, res) {
473
+ if (!validateLocalJsonWrite(req, res, '项目别名规则接口')) return;
474
+
475
+ try {
476
+ const rule = upsertProjectAliasRule(db, await readJson(req, 64 * 1024));
477
+ sendJson(res, { ok: true, rule: { ...rule, enabled: Boolean(rule.enabled) } });
478
+ } catch (error) {
479
+ sendJson(res, { error: error.message }, 400);
480
+ }
481
+ }
482
+
483
+ async function handleBudgetProfileUpsert(req, res) {
484
+ if (!validateLocalJsonWrite(req, res, '预算配置接口')) return;
485
+
486
+ try {
487
+ const profile = upsertBudgetProfile(db, await readJson(req, 64 * 1024));
488
+ sendJson(res, { ok: true, profile });
489
+ } catch (error) {
490
+ sendJson(res, { error: error.message }, 400);
491
+ }
492
+ }
493
+
494
+ async function handleBudgetProfileDelete(req, url, res) {
495
+ if (!validateLocalJsonWrite(req, res, '预算配置接口')) return;
496
+
497
+ try {
498
+ const payload = req.headers['content-length'] === '0'
499
+ ? Object.fromEntries(url.searchParams.entries())
500
+ : { ...Object.fromEntries(url.searchParams.entries()), ...await readJson(req, 64 * 1024) };
501
+ const deleted = deleteBudgetProfile(db, payload);
502
+ sendJson(res, { ok: true, deleted });
503
+ } catch (error) {
504
+ sendJson(res, { error: error.message }, 400);
505
+ }
506
+ }
507
+
508
+ async function handleAdvisorActionUpsert(req, res) {
509
+ if (!validateLocalJsonWrite(req, res, '建议行动接口')) return;
510
+
511
+ try {
512
+ const action = upsertAdvisorAction(db, await readJson(req, 128 * 1024));
513
+ sendJson(res, { ok: true, action });
514
+ } catch (error) {
515
+ sendJson(res, { error: error.message }, 400);
516
+ }
517
+ }
518
+
519
+ async function handleAdvisorActionDelete(req, url, res) {
520
+ if (!validateLocalJsonWrite(req, res, '建议行动接口')) return;
521
+
522
+ try {
523
+ const id = url.pathname.split('/').pop();
524
+ const deleted = deleteAdvisorAction(db, { id });
525
+ sendJson(res, { ok: true, deleted });
526
+ } catch (error) {
527
+ sendJson(res, { error: error.message }, 400);
528
+ }
529
+ }
530
+
531
+ async function handleWorkItemUpsert(req, res) {
532
+ if (!validateLocalJsonWrite(req, res, '工作项接口')) return;
533
+
534
+ try {
535
+ const workItem = upsertWorkItem(db, await readJson(req, 128 * 1024));
536
+ sendJson(res, { ok: true, workItem });
537
+ } catch (error) {
538
+ sendJson(res, { error: error.message }, 400);
539
+ }
540
+ }
541
+
542
+ async function handleWorkItemLinkSessions(req, res) {
543
+ if (!validateLocalJsonWrite(req, res, '工作项绑定接口')) return;
544
+
545
+ try {
546
+ const result = linkWorkItemSessions(db, await readJson(req, 256 * 1024));
547
+ sendJson(res, { ok: true, ...result });
548
+ } catch (error) {
549
+ sendJson(res, { error: error.message }, 400);
550
+ }
551
+ }
552
+
553
+ async function handleWorkItemDelete(req, url, res) {
554
+ if (!validateLocalJsonWrite(req, res, '工作项接口')) return;
555
+
556
+ try {
557
+ const id = url.pathname.split('/').pop();
558
+ const deleted = deleteWorkItem(db, { id });
559
+ sendJson(res, { ok: true, deleted });
560
+ } catch (error) {
561
+ sendJson(res, { error: error.message }, 400);
562
+ }
563
+ }
564
+
565
+ async function handleProjectAliasRuleDelete(req, url, res) {
566
+ if (!validateLocalJsonWrite(req, res, '项目别名规则接口')) return;
567
+
568
+ try {
569
+ const payload = req.headers['content-length'] === '0'
570
+ ? Object.fromEntries(url.searchParams.entries())
571
+ : { ...Object.fromEntries(url.searchParams.entries()), ...await readJson(req, 64 * 1024) };
572
+ const deleted = deleteProjectAliasRule(db, payload);
573
+ sendJson(res, { ok: true, deleted });
574
+ } catch (error) {
575
+ sendJson(res, { error: error.message }, 400);
576
+ }
577
+ }
578
+
579
+ function handleAutoAttributionSuggestions(req, url, res) {
580
+ if (!validateLocalRead(req, res, '自动归因建议接口')) return;
581
+
582
+ const threshold = parseThreshold(url.searchParams.get('threshold'));
583
+ const { sessions, projectAliasRules } = buildAutoAttributionContext();
584
+ const plan = buildAutoAttributionPlan({ sessions, projectAliasRules, threshold });
585
+ sendJson(res, { ok: true, plan });
586
+ }
587
+
588
+ async function handleAutoAttributionApply(req, res) {
589
+ if (!validateLocalJsonWrite(req, res, '自动归因接口')) return;
590
+
591
+ try {
592
+ const payload = await readJson(req, 256 * 1024);
593
+ const threshold = parseThreshold(payload.threshold);
594
+ const requested = identitySet(payload.sessions);
595
+ const { sessions, projectAliasRules } = buildAutoAttributionContext();
596
+ const plan = buildAutoAttributionPlan({ sessions, projectAliasRules, threshold });
597
+ const suggestions = plan.suggestions.filter(item =>
598
+ item.canApply && (!requested || requested.has(identityKey(item)))
599
+ );
600
+ if (!suggestions.length) {
601
+ sendJson(res, { ok: true, applied: 0, skippedLowConfidence: plan.lowConfidenceCount, skippedProtected: 0, runId: null, backup: null, plan });
602
+ return;
603
+ }
604
+ const backup = createDbBackup({ reason: 'auto-attribution' });
605
+ const result = applyAutoSessionAnnotations(db, suggestions, {
606
+ threshold,
607
+ runId: payload.runId || undefined
608
+ });
609
+ sendJson(res, { ok: true, ...result, backup, plan });
610
+ } catch (error) {
611
+ sendJson(res, { error: error.message }, 400);
612
+ }
613
+ }
614
+
615
+ async function handleAutoAttributionUndo(req, res) {
616
+ if (!validateLocalJsonWrite(req, res, '自动归因撤销接口')) return;
617
+
618
+ try {
619
+ const payload = await readJson(req, 64 * 1024);
620
+ const backup = createDbBackup({ reason: 'auto-attribution-undo' });
621
+ const deleted = undoAutoSessionAnnotations(db, payload);
622
+ sendJson(res, { ok: true, deleted, runId: payload.runId || payload.autoRunId || payload.auto_run_id, backup });
623
+ } catch (error) {
624
+ sendJson(res, { error: error.message }, 400);
625
+ }
626
+ }
627
+
628
+ async function handleSessionAnnotationBatch(req, res) {
629
+ if (!validateLocalJsonWrite(req, res, '批量标注接口')) return;
630
+
631
+ try {
632
+ const result = batchUpsertSessionAnnotations(db, await readJson(req, 512 * 1024));
633
+ sendJson(res, { ok: true, ...result });
634
+ } catch (error) {
635
+ sendJson(res, { error: error.message }, 400);
636
+ }
637
+ }
638
+
639
+ async function handleSessionAnnotationUpsert(req, res) {
640
+ if (!validateLocalJsonWrite(req, res, '标注接口')) return;
641
+
642
+ try {
643
+ const annotation = upsertSessionAnnotation(db, await readJson(req, 64 * 1024));
644
+ sendJson(res, { ok: true, annotation });
645
+ } catch (error) {
646
+ sendJson(res, { error: error.message }, 400);
647
+ }
648
+ }
649
+
650
+ async function handleSessionAnnotationDelete(req, url, res) {
651
+ if (!validateLocalJsonWrite(req, res, '标注接口')) return;
652
+
653
+ try {
654
+ const payload = req.headers['content-length'] === '0'
655
+ ? Object.fromEntries(url.searchParams.entries())
656
+ : { ...Object.fromEntries(url.searchParams.entries()), ...await readJson(req, 64 * 1024) };
657
+ const deleted = deleteSessionAnnotation(db, payload);
658
+ sendJson(res, { ok: true, deleted });
659
+ } catch (error) {
660
+ sendJson(res, { error: error.message }, 400);
661
+ }
662
+ }
663
+
664
+ async function handleSessionOutputUpsert(req, res) {
665
+ if (!validateLocalJsonWrite(req, res, '产出链接接口')) return;
666
+
667
+ try {
668
+ const output = upsertSessionOutput(db, await readJson(req, 64 * 1024));
669
+ sendJson(res, { ok: true, output });
670
+ } catch (error) {
671
+ sendJson(res, { error: error.message }, 400);
672
+ }
673
+ }
674
+
675
+ async function handleSessionOutputDelete(req, url, res) {
676
+ if (!validateLocalJsonWrite(req, res, '产出链接接口')) return;
677
+
678
+ try {
679
+ const payload = req.headers['content-length'] === '0'
680
+ ? Object.fromEntries(url.searchParams.entries())
681
+ : { ...Object.fromEntries(url.searchParams.entries()), ...await readJson(req, 64 * 1024) };
682
+ const deleted = deleteSessionOutput(db, payload);
683
+ sendJson(res, { ok: true, deleted });
684
+ } catch (error) {
685
+ sendJson(res, { error: error.message }, 400);
686
+ }
687
+ }
688
+
689
+ async function handleBackup(req, res) {
690
+ if (!validateLocalJsonWrite(req, res, '备份接口')) return;
691
+
692
+ try {
693
+ await readJson(req, 64 * 1024);
694
+ const backup = createDbBackup({ reason: 'manual' });
695
+ sendJson(res, { ok: true, backup });
696
+ } catch (error) {
697
+ sendJson(res, { error: error.message }, 500);
698
+ }
699
+ }
700
+
701
+ function handleExportAnnotations(req, res) {
702
+ if (!validateLocalRead(req, res, '导出接口')) return;
703
+ sendJson(res, exportAnnotationData(db));
704
+ }
705
+
706
+ async function handleImportAnnotations(req, res) {
707
+ if (!validateLocalJsonWrite(req, res, '导入接口')) return;
708
+
709
+ try {
710
+ const result = importAnnotationData(db, await readJson(req, 5 * 1024 * 1024));
711
+ sendJson(res, { ok: true, imported: result });
712
+ } catch (error) {
713
+ sendJson(res, { error: error.message }, 400);
714
+ }
715
+ }
716
+
717
+ async function handleImportCcusageJson(req, res) {
718
+ if (!validateLocalJsonWrite(req, res, 'ccusage 导入接口')) return;
719
+
720
+ try {
721
+ const body = await readJson(req, 8 * 1024 * 1024);
722
+ const input = body.payload != null
723
+ ? parseCcusageJsonText(JSON.stringify(body.payload))
724
+ : parseCcusageJsonText(body.text || body.json || '');
725
+ const plan = planCcusageImport(input, {
726
+ device: body.device || process.env.COLLECT_DEVICE || 'imported'
727
+ });
728
+ const summary = {
729
+ ok: true,
730
+ mode: body.apply ? 'apply' : 'dry-run',
731
+ detectedShape: plan.detectedShape,
732
+ daily: plan.daily.length,
733
+ sessions: plan.sessions.length,
734
+ tokenEvents: plan.tokenEvents.length,
735
+ warnings: plan.warnings,
736
+ run: plan.run
737
+ };
738
+ if (body.apply) {
739
+ const backup = createDbBackup({ reason: 'ccusage-import' });
740
+ summary.applied = applyCcusageImport(db, plan);
741
+ summary.backup = backup;
742
+ }
743
+ sendJson(res, summary);
744
+ } catch (error) {
745
+ sendJson(res, { error: error.message }, 400);
746
+ }
747
+ }
748
+
749
+ function handleCollect(req, res) {
750
+ if (!validateLocalJsonWrite(req, res, '采集接口')) return;
751
+
752
+ try {
753
+ const started = startCollection({ reason: 'manual', requireBackup: true });
754
+ if (!started) {
755
+ sendJson(res, { ...collectionState, error: '采集正在运行' }, 409);
756
+ return;
757
+ }
758
+ sendJson(res, collectionState, 202);
759
+ } catch (error) {
760
+ sendJson(res, { error: error.message }, 500);
761
+ }
762
+ }
763
+
764
+ function startCollection({ reason = 'manual', requireBackup = false } = {}) {
765
+ if (activeCollection) {
766
+ return false;
767
+ }
768
+
769
+ const backup = requireBackup ? createDbBackup({ reason: reason === 'scheduled' ? 'scheduled-collect' : 'collect' }) : null;
770
+ const args = ['src/collect.mjs'];
771
+ const device = collectionDevice();
772
+ if (device) args.push('--device', device);
773
+ if (process.env.DB_PATH) args.push('--db', process.env.DB_PATH);
774
+
775
+ const child = spawn(process.execPath, args, {
776
+ cwd: process.cwd(),
777
+ env: process.env,
778
+ windowsHide: true
779
+ });
780
+
781
+ activeCollection = child;
782
+ let stdout = '';
783
+ let stderr = '';
784
+ const startedAt = new Date().toISOString();
785
+ collectionState = {
786
+ status: 'running',
787
+ message: reason === 'scheduled' ? '正在定时采集本机用量' : '正在采集本机用量',
788
+ startedAt,
789
+ finishedAt: null,
790
+ exitCode: null,
791
+ stdout: '',
792
+ stderr: '',
793
+ backup
794
+ };
795
+
796
+ child.stdout.setEncoding('utf8');
797
+ child.stderr.setEncoding('utf8');
798
+ child.stdout.on('data', chunk => { stdout += chunk; });
799
+ child.stderr.on('data', chunk => { stderr += chunk; });
800
+
801
+ child.on('error', error => {
802
+ activeCollection = null;
803
+ collectionState = {
804
+ ...collectionState,
805
+ status: 'error',
806
+ message: error.message,
807
+ finishedAt: new Date().toISOString(),
808
+ stderr: error.message,
809
+ backup
810
+ };
811
+ });
812
+
813
+ child.on('close', code => {
814
+ activeCollection = null;
815
+ collectionState = {
816
+ status: code === 0 ? 'ok' : 'error',
817
+ message: code === 0 ? '采集完成' : '采集失败',
818
+ exitCode: code,
819
+ startedAt,
820
+ finishedAt: new Date().toISOString(),
821
+ stdout: trimOutput(stdout),
822
+ stderr: trimOutput(stderr),
823
+ backup
824
+ };
825
+ });
826
+
827
+ return true;
828
+ }
829
+
830
+ function startScheduledCollect() {
831
+ const schedule = scheduledCollectConfig();
832
+ if (!schedule.enabled) return;
833
+
834
+ console.log(`[collect:schedule] enabled interval=${schedule.intervalSeconds}s runOnStart=${schedule.runOnStart}`);
835
+
836
+ const run = () => {
837
+ try {
838
+ const started = startCollection({ reason: 'scheduled', requireBackup: true });
839
+ if (!started) console.log('[collect:schedule] skipped because a collection is already running');
840
+ } catch (error) {
841
+ console.log(`[collect:schedule] skipped because backup failed: ${error.message}`);
842
+ }
843
+ };
844
+
845
+ if (schedule.runOnStart) {
846
+ setTimeout(run, 1000);
847
+ }
848
+
849
+ setInterval(run, schedule.intervalSeconds * 1000);
850
+ }
851
+
852
+ function scheduledCollectConfig() {
853
+ const config = loadCollectorConfig().scheduledCollect || {};
854
+ const enabled = envBool('SCHEDULED_COLLECT_ENABLED', config.enabled ?? false);
855
+ const intervalSeconds = Math.max(
856
+ 10,
857
+ envNumber('SCHEDULED_COLLECT_INTERVAL_SECONDS',
858
+ envNumber('COLLECT_INTERVAL_SECONDS', config.intervalSeconds ?? 300))
859
+ );
860
+ const runOnStart = envBool('SCHEDULED_COLLECT_RUN_ON_START', config.runOnStart ?? false);
861
+ return { enabled, intervalSeconds, runOnStart };
862
+ }
863
+
864
+ function collectionDevice() {
865
+ const config = loadCollectorConfig().scheduledCollect || {};
866
+ return process.env.COLLECT_DEVICE || process.env.SCHEDULED_COLLECT_DEVICE || config.device || null;
867
+ }
868
+
869
+ function envBool(name, fallback) {
870
+ const value = process.env[name];
871
+ if (value == null || value === '') return Boolean(fallback);
872
+ return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
873
+ }
874
+
875
+ function envNumber(name, fallback) {
876
+ const value = Number(process.env[name]);
877
+ return Number.isFinite(value) && value > 0 ? value : Number(fallback);
878
+ }
879
+
880
+ async function handleIngest(req, res) {
881
+ if (!isJsonRequest(req)) {
882
+ sendJson(res, { error: 'Content-Type must be application/json' }, 415);
883
+ return;
884
+ }
885
+
886
+ const expectedToken = process.env.INGEST_TOKEN;
887
+ if (expectedToken) {
888
+ const actualToken = (req.headers.authorization || '').replace(/^Bearer\s+/i, '');
889
+ if (actualToken !== expectedToken) {
890
+ sendJson(res, { error: 'Unauthorized' }, 401);
891
+ return;
892
+ }
893
+ }
894
+
895
+ try {
896
+ const payload = await readJson(req);
897
+ const dailyRows = Array.isArray(payload.daily) ? payload.daily : [];
898
+ const sessionRows = Array.isArray(payload.sessions) ? payload.sessions : [];
899
+ const runRows = Array.isArray(payload.runs) ? payload.runs : [];
900
+
901
+ db.exec('BEGIN');
902
+ try {
903
+ dailyRows.forEach((row) => upsertDaily(db, row));
904
+ sessionRows.forEach((row) => upsertSession(db, row));
905
+ runRows.forEach((row) => recordRun(db, row));
906
+ db.exec('COMMIT');
907
+ } catch (error) {
908
+ db.exec('ROLLBACK');
909
+ throw error;
910
+ }
911
+
912
+ sendJson(res, { ok: true, daily: dailyRows.length, sessions: sessionRows.length, runs: runRows.length });
913
+ } catch (error) {
914
+ sendJson(res, { error: error.message }, 400);
915
+ }
916
+ }
917
+
918
+ function serveStatic(pathname, res) {
919
+ const filePath = pathname === '/' ? join(staticDir, 'index.html')
920
+ : pathname === '/review' ? join(staticDir, 'index.html')
921
+ : pathname === '/live' ? join(staticDir, 'index.html')
922
+ : join(staticDir, pathname);
923
+ if (!filePath.startsWith(staticDir) || !existsSync(filePath)) {
924
+ res.writeHead(404);
925
+ res.end('Not found');
926
+ return;
927
+ }
928
+ res.writeHead(200, { 'content-type': contentType(filePath) });
929
+ createReadStream(filePath).pipe(res);
930
+ }
931
+
932
+ function one(sql) {
933
+ return db.prepare(sql).get();
934
+ }
935
+
936
+ function all(sql) {
937
+ return db.prepare(sql).all();
938
+ }
939
+
940
+ function liveSessions() {
941
+ return all(`
942
+ SELECT device, source, session_id AS sessionId, last_activity AS lastActivity,
943
+ project_path AS projectPath,
944
+ input_tokens AS inputTokens,
945
+ output_tokens AS outputTokens,
946
+ cache_creation_tokens AS cacheCreationTokens,
947
+ cache_read_tokens AS cacheReadTokens,
948
+ cached_input_tokens AS cachedInputTokens,
949
+ reasoning_output_tokens AS reasoningOutputTokens,
950
+ total_tokens AS totalTokens,
951
+ cost_usd AS costUSD
952
+ FROM session_usage
953
+ ORDER BY last_activity DESC
954
+ LIMIT 100
955
+ `).map(session => ({
956
+ ...session,
957
+ model: modelFromSessionId(session.sessionId),
958
+ cacheReadTokens: Number(session.cacheReadTokens || 0) + Number(session.cachedInputTokens || 0)
959
+ }));
960
+ }
961
+
962
+ function liveTokenEvents() {
963
+ return all(`
964
+ SELECT event_id AS eventId, device, source, session_id AS sessionId,
965
+ timestamp, model,
966
+ input_tokens AS inputTokens,
967
+ output_tokens AS outputTokens,
968
+ cache_read_tokens AS cacheReadTokens,
969
+ cache_creation_tokens AS cacheCreationTokens,
970
+ reasoning_tokens AS reasoningTokens,
971
+ tool_category AS toolCategory,
972
+ file_extension AS fileExtension
973
+ FROM token_events
974
+ ORDER BY timestamp DESC
975
+ LIMIT 500
976
+ `);
977
+ }
978
+
979
+ function sourceHealth(collectors = detectCollectors()) {
980
+ return buildSourceHealth({
981
+ collectors,
982
+ dailyRows: all(`
983
+ SELECT source,
984
+ COUNT(*) AS count,
985
+ COALESCE(SUM(total_tokens), 0) AS totalTokens,
986
+ MAX(usage_date) AS latestDailyAt
987
+ FROM daily_usage
988
+ GROUP BY source
989
+ `),
990
+ sessionRows: all(`
991
+ SELECT source,
992
+ COUNT(*) AS count,
993
+ COALESCE(SUM(total_tokens), 0) AS totalTokens,
994
+ MAX(last_activity) AS latestSessionAt
995
+ FROM session_usage
996
+ GROUP BY source
997
+ `),
998
+ eventRows: all(`
999
+ SELECT source,
1000
+ COUNT(*) AS count,
1001
+ COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_creation_tokens + reasoning_tokens), 0) AS totalTokens,
1002
+ MAX(timestamp) AS latestEventAt
1003
+ FROM token_events
1004
+ GROUP BY source
1005
+ `),
1006
+ runs: all(`
1007
+ SELECT source, status, collected_at AS collectedAt
1008
+ FROM collection_runs
1009
+ ORDER BY id DESC
1010
+ `)
1011
+ });
1012
+ }
1013
+
1014
+ function buildAutoAttributionContext() {
1015
+ const aliasRules = listProjectAliasRules(db);
1016
+ const enabledAliasRules = aliasRules.filter(rule => rule.enabled);
1017
+ const rawSessions = all(`
1018
+ SELECT s.device, s.source,
1019
+ s.session_id AS sessionId,
1020
+ s.last_activity AS lastActivity,
1021
+ s.project_path AS projectPath,
1022
+ s.input_tokens AS inputTokens,
1023
+ s.output_tokens AS outputTokens,
1024
+ s.cache_creation_tokens AS cacheCreationTokens,
1025
+ s.cache_read_tokens AS cacheReadTokens,
1026
+ s.cached_input_tokens AS cachedInputTokens,
1027
+ s.reasoning_output_tokens AS reasoningOutputTokens,
1028
+ s.total_tokens AS totalTokens,
1029
+ s.cost_usd AS costUSD,
1030
+ a.project_alias AS manualProjectAlias,
1031
+ COALESCE(a.task_type, '未分类') AS taskType,
1032
+ COALESCE(a.output_status, '未标注') AS outputStatus,
1033
+ COALESCE(a.work_purpose, '未说明') AS workPurpose,
1034
+ COALESCE(a.work_stage, '未说明') AS workStage,
1035
+ COALESCE(a.value_level, '未评估') AS valueLevel,
1036
+ a.note,
1037
+ a.annotation_source AS annotationSource,
1038
+ a.annotation_confidence AS annotationConfidence,
1039
+ a.annotation_reason AS annotationReason,
1040
+ a.auto_version AS autoVersion,
1041
+ a.auto_run_id AS autoRunId,
1042
+ a.auto_updated_at AS autoUpdatedAt,
1043
+ a.updated_at AS annotationUpdatedAt,
1044
+ o.output_url AS outputUrl,
1045
+ o.output_label AS outputLabel,
1046
+ COALESCE(o.output_type, '未分类') AS outputType,
1047
+ o.updated_at AS outputUpdatedAt
1048
+ FROM session_usage s
1049
+ LEFT JOIN session_annotations a
1050
+ ON a.device = s.device
1051
+ AND a.source = s.source
1052
+ AND a.session_id = s.session_id
1053
+ LEFT JOIN session_outputs o
1054
+ ON o.device = s.device
1055
+ AND o.source = s.source
1056
+ AND o.session_id = s.session_id
1057
+ ORDER BY s.total_tokens DESC
1058
+ `);
1059
+ const sessions = rawSessions.map(s => {
1060
+ const projectPath = normalizeProjectPath(s.projectPath, s.sessionId);
1061
+ const ruleProjectAlias = matchProjectAliasRule(projectPath, enabledAliasRules);
1062
+ const model = modelFromSessionId(s.sessionId);
1063
+ return attachOfficialPricing({
1064
+ ...s,
1065
+ ...DEFAULT_SESSION_ANNOTATION,
1066
+ model,
1067
+ lastActivity: s.lastActivity ? s.lastActivity.slice(0, 10) : null,
1068
+ projectPath,
1069
+ projectAlias: s.manualProjectAlias || ruleProjectAlias || null,
1070
+ manualProjectAlias: s.manualProjectAlias || null,
1071
+ ruleProjectAlias,
1072
+ taskType: s.taskType || DEFAULT_SESSION_ANNOTATION.taskType,
1073
+ outputStatus: s.outputStatus || DEFAULT_SESSION_ANNOTATION.outputStatus,
1074
+ workPurpose: s.workPurpose || DEFAULT_SESSION_ANNOTATION.workPurpose,
1075
+ workStage: s.workStage || DEFAULT_SESSION_ANNOTATION.workStage,
1076
+ valueLevel: s.valueLevel || DEFAULT_SESSION_ANNOTATION.valueLevel,
1077
+ note: s.note || null,
1078
+ annotationSource: s.annotationSource || null,
1079
+ annotationConfidence: s.annotationConfidence == null ? null : Number(s.annotationConfidence),
1080
+ annotationReason: s.annotationReason || null,
1081
+ autoVersion: s.autoVersion || null,
1082
+ autoRunId: s.autoRunId || null,
1083
+ autoUpdatedAt: s.autoUpdatedAt || null,
1084
+ attributionQuality: attributionQuality(s.annotationSource, s.annotationConfidence, s.annotationUpdatedAt),
1085
+ annotationUpdatedAt: s.annotationUpdatedAt || null,
1086
+ outputUrl: s.outputUrl || null,
1087
+ outputLabel: s.outputLabel || null,
1088
+ outputType: s.outputType || DEFAULT_SESSION_ANNOTATION.outputType,
1089
+ outputUpdatedAt: s.outputUpdatedAt || null
1090
+ }, model, providerFromSource(s.source));
1091
+ });
1092
+ return { sessions, projectAliasRules: aliasRules };
1093
+ }
1094
+
1095
+ function normalizeProjectPath(projectPath, sessionId) {
1096
+ return (projectPath && projectPath !== 'Unknown Project')
1097
+ ? projectPath
1098
+ : (sessionId ? sessionId.split('/').slice(-1)[0] || sessionId : null);
1099
+ }
1100
+
1101
+ function attributionQuality(source, confidence, updatedAt) {
1102
+ if (!updatedAt) return 'missing';
1103
+ if (source === 'auto') return Number(confidence || 0) >= AUTO_ATTRIBUTION_THRESHOLD ? 'auto-high' : 'auto-low';
1104
+ return 'manual';
1105
+ }
1106
+
1107
+ function parseThreshold(value) {
1108
+ const parsed = Number(value ?? AUTO_ATTRIBUTION_THRESHOLD);
1109
+ if (!Number.isFinite(parsed)) return AUTO_ATTRIBUTION_THRESHOLD;
1110
+ return Math.max(0, Math.min(100, Math.round(parsed)));
1111
+ }
1112
+
1113
+ function identitySet(rows) {
1114
+ if (!Array.isArray(rows) || !rows.length) return null;
1115
+ return new Set(rows.map(identityKey));
1116
+ }
1117
+
1118
+ function identityKey(row = {}) {
1119
+ return `${row.device || ''}::${row.source || ''}::${row.sessionId || row.session_id || ''}`;
1120
+ }
1121
+
1122
+ function providerFromSource(source) {
1123
+ const value = String(source || '').toLowerCase();
1124
+ if (value.includes('codex') || value.includes('openai')) return 'openai';
1125
+ if (value.includes('claude') || value.includes('anthropic')) return 'anthropic';
1126
+ if (value.includes('deepseek')) return 'deepseek';
1127
+ if (value.includes('mimo') || value.includes('xiaomi')) return 'xiaomi';
1128
+ return null;
1129
+ }
1130
+
1131
+ function modelFromSessionId(sessionId) {
1132
+ const text = String(sessionId || '').trim();
1133
+ if (!text) return null;
1134
+ if (text.startsWith('local:')) return text.split(':').at(-1) || null;
1135
+ return null;
1136
+ }
1137
+
1138
+ function sendJson(res, value, status = 200) {
1139
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
1140
+ res.end(JSON.stringify(value));
1141
+ }
1142
+
1143
+ function sendText(res, value, contentType = 'text/plain; charset=utf-8', status = 200) {
1144
+ res.writeHead(status, { 'content-type': contentType });
1145
+ res.end(value);
1146
+ }
1147
+
1148
+ function createDbBackup({ reason = 'manual' } = {}) {
1149
+ const createdAt = new Date().toISOString();
1150
+ const stamp = createdAt.replace(/[:.]/g, '-');
1151
+ const safeReason = String(reason || 'manual').replace(/[^a-z0-9-]+/gi, '-').toLowerCase();
1152
+ const backupDir = process.env.BACKUP_DIR || join(dirname(dbPath), 'backups');
1153
+ mkdirSync(backupDir, { recursive: true });
1154
+ db.exec('PRAGMA wal_checkpoint(FULL)');
1155
+ const fileName = `usage-${stamp}-${safeReason}.sqlite`;
1156
+ const backupPath = join(backupDir, fileName);
1157
+ copyFileSync(dbPath, backupPath);
1158
+ return { createdAt, path: backupPath, fileName };
1159
+ }
1160
+
1161
+ function trimOutput(value) {
1162
+ const text = String(value || '').trim();
1163
+ return text.length > 12000 ? `${text.slice(-12000)}` : text;
1164
+ }
1165
+
1166
+ function isLoopback(address = '') {
1167
+ const value = String(address || '').toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
1168
+ return value.startsWith('127.')
1169
+ || value === '::1'
1170
+ || value.startsWith('::ffff:127.')
1171
+ || value === 'localhost';
1172
+ }
1173
+
1174
+ function validateLocalJsonWrite(req, res, label) {
1175
+ if (!validateLocalRead(req, res, label)) return false;
1176
+ if (!isJsonRequest(req)) {
1177
+ sendJson(res, { error: 'Content-Type must be application/json' }, 415);
1178
+ return false;
1179
+ }
1180
+ return true;
1181
+ }
1182
+
1183
+ function validateLocalRead(req, res, label) {
1184
+ if (!isLoopback(req.socket.remoteAddress)) {
1185
+ sendJson(res, { error: `${label}仅允许本机访问` }, 403);
1186
+ return false;
1187
+ }
1188
+ if (!isLocalOrigin(req.headers.origin)) {
1189
+ sendJson(res, { error: `${label}仅允许来自本机页面` }, 403);
1190
+ return false;
1191
+ }
1192
+ return true;
1193
+ }
1194
+
1195
+ function isLocalOrigin(origin) {
1196
+ if (!origin) return true;
1197
+ try {
1198
+ const { hostname } = new URL(origin);
1199
+ return isLoopback(hostname);
1200
+ } catch {
1201
+ return false;
1202
+ }
1203
+ }
1204
+
1205
+ function isJsonRequest(req) {
1206
+ const contentType = req.headers['content-type'] || '';
1207
+ return /^application\/json(?:\s*;|$)/i.test(contentType);
1208
+ }
1209
+
1210
+ function readJson(req, maxBytes = 50 * 1024 * 1024) {
1211
+ return new Promise((resolveRequest, rejectRequest) => {
1212
+ let body = '';
1213
+ req.setEncoding('utf8');
1214
+ req.on('data', (chunk) => {
1215
+ body += chunk;
1216
+ if (body.length > maxBytes) {
1217
+ rejectRequest(new Error('请求体过大'));
1218
+ req.destroy();
1219
+ }
1220
+ });
1221
+ req.on('end', () => {
1222
+ try {
1223
+ resolveRequest(JSON.parse(body || '{}'));
1224
+ } catch (error) {
1225
+ rejectRequest(error);
1226
+ }
1227
+ });
1228
+ req.on('error', rejectRequest);
1229
+ });
1230
+ }
1231
+
1232
+ function contentType(filePath) {
1233
+ const types = {
1234
+ '.html': 'text/html; charset=utf-8',
1235
+ '.css': 'text/css; charset=utf-8',
1236
+ '.js': 'application/javascript; charset=utf-8',
1237
+ '.jsx': 'application/javascript; charset=utf-8'
1238
+ };
1239
+ return types[extname(filePath)] || 'application/octet-stream';
1240
+ }