lumencode 1.3.0 → 1.3.2

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.
package/lib/server.js CHANGED
@@ -3,16 +3,18 @@ import { readFileSync, existsSync } from 'fs';
3
3
  import { join, extname, resolve, sep } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { saveConfig } from './config.js';
6
- import { generateWorkReport, generateFeishuCard } from './report.js';
6
+ import { parseRepoPaths } from './path-utils.js';
7
+ import { generateWorkReport, generateFeishuCard, generateBossReport } from './report.js';
8
+ import { classifyRecord } from './scenario.js';
7
9
  import { collectAllRecords, filterRecordsByPeriod, groupBySessions, computeUsageStats, computeTrendData, computePrevPeriodRange } from './aggregate.js';
8
10
  import { normalizeProjectPath } from './aggregate.js';
9
11
  import { invalidateFileCache } from './cache.js';
10
- import { invalidateGitCache, getGitStatsForMultipleReposAsync, finalizeGitStats } from './git.js';
12
+ import { invalidateGitCache, getGitStatsForMultipleReposAsync, finalizeGitStats, computeAIContribution, computeCommitTypes, computeFileHotspots } from './git.js';
11
13
  import { identifyBillingBlocks } from './blocks.js';
12
- import { detectAvailableTools, parseAllEnabledTools } from './parsers/index.js';
13
- import { isAssistantRecord, getInputTokens, getOutputTokens } from './record-utils.js';
14
- import { StepTracker } from './step-tracker.js';
15
- import { disableHooks, enableHooks, getHooksStatus, HOOK_TOOLS, initStepTracking } from './hooks-manager.js';
14
+ import { detectAvailableTools, parseAllEnabledTools } from './parsers/index.js';
15
+ import { isAssistantRecord, getInputTokens, getOutputTokens } from './record-utils.js';
16
+ import { StepTracker } from './step-tracker.js';
17
+ import { disableHooks, enableHooks, getHooksStatus, HOOK_TOOLS, initStepTracking } from './hooks-manager.js';
16
18
 
17
19
  // basename 提取,兼容不同路径格式
18
20
  function getProjectBaseName(p) {
@@ -39,42 +41,42 @@ const MIME = {
39
41
  '.ico': 'image/x-icon',
40
42
  };
41
43
 
42
- export function startServer(config, effectiveIncludeProjects, buildReportData, configPath) {
43
- function computeIncludeProjects(cfg) {
44
- if (cfg.repos && cfg.repos.length > 0) {
45
- return cfg.repos.map(r => normalizeProjectPath(r));
46
- }
47
- return null;
48
- }
49
-
50
- function getHookProjectRoots(cfg) {
51
- if (!Array.isArray(cfg.repos)) return [];
52
- return [...new Set(cfg.repos.map(r => normalizeProjectPath(String(r || '').trim())).filter(Boolean))];
53
- }
54
-
55
- function getConfiguredHooksStatus(cfg) {
56
- const projectRoots = getHookProjectRoots(cfg);
57
- const projects = projectRoots.map(root => getHooksStatus(root));
58
- const total = projects.length;
59
- const enabledCount = (tool) => projects.filter(p => p[tool]?.enabled).length;
60
- const stepsReadyCount = projects.filter(p => p.stepsInitialized).length;
61
- const toolStatus = (tool) => ({
62
- enabled: total > 0 && enabledCount(tool) === total,
63
- enabledCount: enabledCount(tool),
64
- total,
65
- });
66
-
67
- return {
68
- targetMode: 'configured-projects',
69
- projectCount: total,
70
- projects,
71
- stepsInitialized: total > 0 && stepsReadyCount === total,
72
- stepsReadyCount,
73
- claude: toolStatus(HOOK_TOOLS.CLAUDE),
74
- codex: toolStatus(HOOK_TOOLS.CODEX),
75
- opencode: toolStatus(HOOK_TOOLS.OPENCODE),
76
- };
77
- }
44
+ export function startServer(config, effectiveIncludeProjects, buildReportData, configPath) {
45
+ function computeIncludeProjects(cfg) {
46
+ if (cfg.repos && cfg.repos.length > 0) {
47
+ return cfg.repos.map(r => normalizeProjectPath(r));
48
+ }
49
+ return null;
50
+ }
51
+
52
+ function getHookProjectRoots(cfg) {
53
+ if (!Array.isArray(cfg.repos)) return [];
54
+ return [...new Set(cfg.repos.map(r => normalizeProjectPath(String(r || '').trim())).filter(Boolean))];
55
+ }
56
+
57
+ function getConfiguredHooksStatus(cfg) {
58
+ const projectRoots = getHookProjectRoots(cfg);
59
+ const projects = projectRoots.map(root => getHooksStatus(root));
60
+ const total = projects.length;
61
+ const enabledCount = (tool) => projects.filter(p => p[tool]?.enabled).length;
62
+ const stepsReadyCount = projects.filter(p => p.stepsInitialized).length;
63
+ const toolStatus = (tool) => ({
64
+ enabled: total > 0 && enabledCount(tool) === total,
65
+ enabledCount: enabledCount(tool),
66
+ total,
67
+ });
68
+
69
+ return {
70
+ targetMode: 'configured-projects',
71
+ projectCount: total,
72
+ projects,
73
+ stepsInitialized: total > 0 && stepsReadyCount === total,
74
+ stepsReadyCount,
75
+ claude: toolStatus(HOOK_TOOLS.CLAUDE),
76
+ codex: toolStatus(HOOK_TOOLS.CODEX),
77
+ opencode: toolStatus(HOOK_TOOLS.OPENCODE),
78
+ };
79
+ }
78
80
 
79
81
  const PORT = process.env.LUMENCODE_PORT || 4567;
80
82
 
@@ -89,8 +91,8 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
89
91
  const REPORT_CACHE_TTL = 30_000; // 30s
90
92
  const REPORT_CACHE_MAX_SIZE = 50;
91
93
 
92
- function getReportCacheKey(period, date, tool, customStart, customEnd, format) {
93
- return `${period}|${date}|${tool || 'all'}|${customStart || ''}|${customEnd || ''}|${format || 'json'}`;
94
+ function getReportCacheKey(period, date, tool, customStart, customEnd) {
95
+ return `${period}|${date}|${tool || 'all'}|${customStart || ''}|${customEnd || ''}`;
94
96
  }
95
97
 
96
98
  function getCachedReport(cacheKey) {
@@ -109,55 +111,55 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
109
111
  }
110
112
  }
111
113
 
112
- function invalidateReportCache() {
113
- _reportCache.clear();
114
- }
115
-
116
- function writeJson(res, statusCode, data) {
117
- res.writeHead(statusCode, {
118
- 'Content-Type': 'application/json',
119
- 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
120
- });
121
- res.end(JSON.stringify(data));
122
- }
123
-
124
- function parseHookTools(value) {
125
- if (!value) return [HOOK_TOOLS.CLAUDE, HOOK_TOOLS.CODEX, HOOK_TOOLS.OPENCODE];
126
- const tools = [];
127
- for (const raw of String(value).split(',')) {
128
- const tool = raw.trim().toLowerCase();
129
- if (!tool) continue;
130
- if (tool === 'claude' || tool === 'claude-code') tools.push(HOOK_TOOLS.CLAUDE);
131
- else if (tool === 'codex') tools.push(HOOK_TOOLS.CODEX);
132
- else if (tool === 'opencode' || tool === 'open-code') tools.push(HOOK_TOOLS.OPENCODE);
133
- }
134
- return [...new Set(tools)];
135
- }
136
-
137
- function readJsonBody(req, res, callback) {
138
- let body = '';
139
- let bodySize = 0;
140
- const MAX_BODY = 1024 * 1024; // 1MB
141
- req.on('data', chunk => {
142
- bodySize += chunk.length;
143
- if (bodySize > MAX_BODY) { req.destroy(); return; }
144
- body += chunk;
145
- });
146
- req.on('end', () => {
147
- if (bodySize > MAX_BODY) {
148
- writeJson(res, 413, { error: '请求体过大' });
149
- return;
150
- }
151
- try {
152
- Promise.resolve(callback(body ? JSON.parse(body) : {})).catch(err => {
153
- console.error('API error:', err.message);
154
- writeJson(res, 500, { error: err.message || '服务器内部错误' });
155
- });
156
- } catch {
157
- writeJson(res, 400, { error: 'JSON 解析失败' });
158
- }
159
- });
160
- }
114
+ function invalidateReportCache() {
115
+ _reportCache.clear();
116
+ }
117
+
118
+ function writeJson(res, statusCode, data) {
119
+ res.writeHead(statusCode, {
120
+ 'Content-Type': 'application/json',
121
+ 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
122
+ });
123
+ res.end(JSON.stringify(data));
124
+ }
125
+
126
+ function parseHookTools(value) {
127
+ if (!value) return [HOOK_TOOLS.CLAUDE, HOOK_TOOLS.CODEX, HOOK_TOOLS.OPENCODE];
128
+ const tools = [];
129
+ for (const raw of String(value).split(',')) {
130
+ const tool = raw.trim().toLowerCase();
131
+ if (!tool) continue;
132
+ if (tool === 'claude' || tool === 'claude-code') tools.push(HOOK_TOOLS.CLAUDE);
133
+ else if (tool === 'codex') tools.push(HOOK_TOOLS.CODEX);
134
+ else if (tool === 'opencode' || tool === 'open-code') tools.push(HOOK_TOOLS.OPENCODE);
135
+ }
136
+ return [...new Set(tools)];
137
+ }
138
+
139
+ function readJsonBody(req, res, callback) {
140
+ let body = '';
141
+ let bodySize = 0;
142
+ const MAX_BODY = 1024 * 1024; // 1MB
143
+ req.on('data', chunk => {
144
+ bodySize += chunk.length;
145
+ if (bodySize > MAX_BODY) { req.destroy(); return; }
146
+ body += chunk;
147
+ });
148
+ req.on('end', () => {
149
+ if (bodySize > MAX_BODY) {
150
+ writeJson(res, 413, { error: '请求体过大' });
151
+ return;
152
+ }
153
+ try {
154
+ Promise.resolve(callback(body ? JSON.parse(body) : {})).catch(err => {
155
+ console.error('API error:', err.message);
156
+ writeJson(res, 500, { error: err.message || '服务器内部错误' });
157
+ });
158
+ } catch {
159
+ writeJson(res, 400, { error: 'JSON 解析失败' });
160
+ }
161
+ });
162
+ }
161
163
 
162
164
  function getCachedParse(config, includeProjects) {
163
165
  const key = `${config.claudeDir}|${includeProjects?.join(',') || ''}`;
@@ -179,6 +181,66 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
179
181
  return result;
180
182
  }
181
183
 
184
+ function deriveProjectGitStats(gitStats, project, start, end, attributionOptions) {
185
+ if (!gitStats?.commitList?.length || !project) return null;
186
+ const windowEnd = end + 'T23:59:59';
187
+ const commitList = gitStats.commitList.filter(c => {
188
+ return getProjectBaseName(c.repo) === project && (c.date || '') >= start && (c.date || '') <= windowEnd;
189
+ });
190
+ if (commitList.length === 0) return null;
191
+
192
+ return {
193
+ commits: commitList.length,
194
+ filesChanged: new Set(commitList.flatMap(c => (c.files || []).map(f => f.path))).size,
195
+ linesAdded: commitList.reduce((s, c) => s + (c.linesAdded || 0), 0),
196
+ linesDeleted: commitList.reduce((s, c) => s + (c.linesDeleted || 0), 0),
197
+ commitList,
198
+ commitTypes: computeCommitTypes(commitList),
199
+ fileHotspots: computeFileHotspots(commitList, 10),
200
+ aiContribution: computeAIContribution(commitList, null, attributionOptions),
201
+ attributionSummary: gitStats.attributionSummary,
202
+ };
203
+ }
204
+
205
+ function prepareJsonReportData(baseData, tool) {
206
+ const data = { ...baseData };
207
+
208
+ if (tool !== 'all' && data.gitStats?.aiContributionByTool) {
209
+ const toolAi = data.gitStats.aiContributionByTool[tool];
210
+ if (toolAi) {
211
+ data.gitStats = { ...data.gitStats, aiContribution: toolAi };
212
+ }
213
+ }
214
+
215
+ if (data.usageStats?.models) {
216
+ const modelEntries = Object.entries(data.usageStats.models)
217
+ .sort((a, b) => b[1].cost - a[1].cost);
218
+ const totalCost = modelEntries.reduce((s, [, d]) => s + (d.cost || 0), 0);
219
+ const cacheRead = data.usageStats.cacheRead || 0;
220
+ const cacheCreate = data.usageStats.cacheCreate || 0;
221
+ let cacheSaving = 0;
222
+ if (totalCost > 0 && (cacheRead + cacheCreate) > 0) {
223
+ const totalInput = data.usageStats.inputTokens || 1;
224
+ const avgInputCostPerToken = totalCost / (totalInput + data.usageStats.outputTokens + cacheRead + cacheCreate || 1);
225
+ cacheSaving = Math.round(cacheRead * avgInputCostPerToken * 0.9 * 100) / 100;
226
+ }
227
+ data.costBreakdown = {
228
+ models: modelEntries.map(([name, d]) => ({
229
+ name,
230
+ cost: d.cost || 0,
231
+ mode: d.costMode || 'unknown',
232
+ requests: d.count,
233
+ inputTokens: d.inputTokens,
234
+ outputTokens: d.outputTokens,
235
+ })),
236
+ cacheSaving,
237
+ total: Math.round(totalCost * 100) / 100,
238
+ };
239
+ }
240
+
241
+ return data;
242
+ }
243
+
182
244
  const server = createServer(async (req, res) => {
183
245
  const url = new URL(req.url, `http://localhost:${PORT}`);
184
246
 
@@ -189,60 +251,60 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
189
251
  res.setHeader('X-XSS-Protection', '1; mode=block');
190
252
  res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
191
253
 
192
- // API endpoint
193
- if (url.pathname === '/api/hooks') {
194
- try {
195
- if (req.method === 'GET') {
196
- writeJson(res, 200, getConfiguredHooksStatus(config));
197
- return;
198
- }
199
-
200
- if (req.method === 'POST') {
201
- readJsonBody(req, res, async (body) => {
202
- const action = body.action || 'enable';
203
- const tools = parseHookTools(body.tools || url.searchParams.get('tools'));
204
- if (tools.length === 0) {
205
- writeJson(res, 400, { error: '未选择支持的 hooks 工具' });
206
- return;
207
- }
208
- const projectRoots = getHookProjectRoots(config);
209
- if (projectRoots.length === 0) {
210
- writeJson(res, 400, { error: '请先在设置中添加项目路径,页面开启 hooks 只作用于设置内配置的项目。' });
211
- return;
212
- }
213
- const stepTracking = [];
214
- const results = [];
215
- for (const projectRoot of projectRoots) {
216
- let projectStepTracking = null;
217
- if (action !== 'disable') {
218
- projectStepTracking = await initStepTracking(projectRoot);
219
- stepTracking.push({ projectRoot, ...projectStepTracking });
220
- }
221
- const projectResults = action === 'disable'
222
- ? disableHooks(projectRoot, tools, { backup: true })
223
- : enableHooks(projectRoot, tools, { backup: true });
224
- results.push({ projectRoot, stepTracking: projectStepTracking, results: projectResults });
225
- }
226
- writeJson(res, 200, {
227
- success: true,
228
- action,
229
- stepTracking,
230
- results,
231
- status: getConfiguredHooksStatus(config),
232
- });
233
- });
234
- return;
235
- }
236
-
237
- writeJson(res, 405, { error: 'Method not allowed' });
238
- } catch (err) {
239
- console.error('API error:', err.message);
240
- writeJson(res, 500, { error: err.message || '服务器内部错误' });
241
- }
242
- return;
243
- }
244
-
245
- if (url.pathname === '/api/tools') {
254
+ // API endpoint
255
+ if (url.pathname === '/api/hooks') {
256
+ try {
257
+ if (req.method === 'GET') {
258
+ writeJson(res, 200, getConfiguredHooksStatus(config));
259
+ return;
260
+ }
261
+
262
+ if (req.method === 'POST') {
263
+ readJsonBody(req, res, async (body) => {
264
+ const action = body.action || 'enable';
265
+ const tools = parseHookTools(body.tools || url.searchParams.get('tools'));
266
+ if (tools.length === 0) {
267
+ writeJson(res, 400, { error: '未选择支持的 hooks 工具' });
268
+ return;
269
+ }
270
+ const projectRoots = getHookProjectRoots(config);
271
+ if (projectRoots.length === 0) {
272
+ writeJson(res, 400, { error: '请先在设置中添加项目路径,页面开启 hooks 只作用于设置内配置的项目。' });
273
+ return;
274
+ }
275
+ const stepTracking = [];
276
+ const results = [];
277
+ for (const projectRoot of projectRoots) {
278
+ let projectStepTracking = null;
279
+ if (action !== 'disable') {
280
+ projectStepTracking = await initStepTracking(projectRoot);
281
+ stepTracking.push({ projectRoot, ...projectStepTracking });
282
+ }
283
+ const projectResults = action === 'disable'
284
+ ? disableHooks(projectRoot, tools, { backup: true })
285
+ : enableHooks(projectRoot, tools, { backup: true });
286
+ results.push({ projectRoot, stepTracking: projectStepTracking, results: projectResults });
287
+ }
288
+ writeJson(res, 200, {
289
+ success: true,
290
+ action,
291
+ stepTracking,
292
+ results,
293
+ status: getConfiguredHooksStatus(config),
294
+ });
295
+ });
296
+ return;
297
+ }
298
+
299
+ writeJson(res, 405, { error: 'Method not allowed' });
300
+ } catch (err) {
301
+ console.error('API error:', err.message);
302
+ writeJson(res, 500, { error: err.message || '服务器内部错误' });
303
+ }
304
+ return;
305
+ }
306
+
307
+ if (url.pathname === '/api/tools') {
246
308
  try {
247
309
  const tools = await detectAvailableTools(config);
248
310
  const enabled = config.enabledTools || tools.filter(t => t.detected).map(t => t.name);
@@ -312,20 +374,25 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
312
374
 
313
375
  try {
314
376
  // 查询结果缓存:相同条件直接返回缓存
315
- const reportCacheKey = getReportCacheKey(period, date, tool, customStart, customEnd, format);
377
+ const reportCacheKey = getReportCacheKey(period, date, tool, customStart, customEnd);
316
378
  let data = getCachedReport(reportCacheKey);
317
- if (data) {
379
+ if (data && format !== 'work') {
380
+ const responseData = prepareJsonReportData(data, tool);
318
381
  res.writeHead(200, {
319
382
  'Content-Type': 'application/json',
320
383
  'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
321
384
  'X-Cache': 'HIT',
322
385
  });
323
- res.end(JSON.stringify(data));
386
+ res.end(JSON.stringify(responseData));
324
387
  return;
325
388
  }
326
389
 
327
- const parsed = await getOrParse(config, includeProjects);
328
- data = await buildReportData(period, date, config, includeProjects, tool, parsed, { customStart, customEnd });
390
+ let parsed = null;
391
+ if (!data) {
392
+ parsed = await getOrParse(config, includeProjects);
393
+ data = await buildReportData(period, date, config, includeProjects, tool, parsed, { customStart, customEnd });
394
+ if (data) setCachedReport(reportCacheKey, data);
395
+ }
329
396
  if (!data) {
330
397
  res.writeHead(200, { 'Content-Type': 'application/json' });
331
398
  res.end(JSON.stringify({
@@ -355,6 +422,7 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
355
422
  let projectName = '';
356
423
 
357
424
  if (project) {
425
+ if (!parsed) parsed = await getOrParse(config, includeProjects);
358
426
  const { records: allRecords } = parsed;
359
427
  const toolRecords = tool !== 'all' ? allRecords.filter(r => r.tool === tool) : allRecords;
360
428
  // basename 匹配
@@ -375,27 +443,15 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
375
443
  projPrevStats = prevProjFiltered.length > 0 ? computeUsageStats(prevProjFiltered, config.scenarioKeywords, config.costMode) : null;
376
444
 
377
445
  // 单项目 Git 统计
378
- if (config.repos?.length > 0) {
379
- const matchedRepo = config.repos.find(r => getProjectBaseName(r) === project);
380
- if (matchedRepo) {
381
- try {
382
- const { getGitStatsAsync } = await import('./git.js');
383
- const sessions = groupBySessions(projFiltered);
384
- const { finalizeGitStats, getGitStatsForMultipleReposAsync } = await import('./git.js');
385
- let repoGit = await getGitStatsForMultipleReposAsync([matchedRepo], pStart, pEnd + 'T23:59:59');
386
- repoGit = await finalizeGitStats(repoGit, sessions, {
387
- attribution: config.aiAttribution,
388
- stepTracking: config.stepTracking,
389
- });
390
- projGitStats = repoGit;
391
- } catch (e) { console.warn("[server] error", e.message); }
392
- }
393
- } else {
394
- projGitStats = null;
395
- }
446
+ projGitStats = deriveProjectGitStats(data.gitStats, project, pStart, pEnd, config.aiAttribution);
396
447
  }
397
448
 
398
- const markdown = generateWorkReport(projUsageStats, projGitStats, period, data.start, data.end, projPrevStats, { level, platform, tool, projectName });
449
+ let markdown;
450
+ if (level === 'boss') {
451
+ markdown = generateBossReport(projUsageStats, projGitStats, period, data.start, data.end, projPrevStats, platform);
452
+ } else {
453
+ markdown = generateWorkReport(projUsageStats, projGitStats, period, data.start, data.end, projPrevStats, { level, platform, tool, projectName });
454
+ }
399
455
  res.writeHead(200, {
400
456
  'Content-Type': 'text/plain; charset=utf-8',
401
457
  'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
@@ -404,52 +460,14 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
404
460
  return;
405
461
  }
406
462
 
407
- // 按工具替换 aiContribution
408
- if (tool !== 'all' && data.gitStats?.aiContributionByTool) {
409
- const toolAi = data.gitStats.aiContributionByTool[tool];
410
- if (toolAi) {
411
- data.gitStats.aiContribution = toolAi;
412
- }
413
- }
414
-
415
- // 添加费用分解
416
- if (data.usageStats?.models) {
417
- const modelEntries = Object.entries(data.usageStats.models)
418
- .sort((a, b) => b[1].cost - a[1].cost);
419
- const totalCost = modelEntries.reduce((s, [, d]) => s + (d.cost || 0), 0);
420
- // 缓存节省 = (inputTokens - cacheRead) * avgInputRate - 已通过 cacheRead 低价体现
421
- // 简化:缓存节省 = cacheRead * avgInputRate * (1 - cacheReadRate/inputRate)
422
- const cacheRead = data.usageStats.cacheRead || 0;
423
- const cacheCreate = data.usageStats.cacheCreate || 0;
424
- let cacheSaving = 0;
425
- if (totalCost > 0 && (cacheRead + cacheCreate) > 0) {
426
- const totalInput = data.usageStats.inputTokens || 1;
427
- const avgInputCostPerToken = totalCost / (totalInput + data.usageStats.outputTokens + cacheRead + cacheCreate || 1);
428
- cacheSaving = Math.round(cacheRead * avgInputCostPerToken * 0.9 * 100) / 100;
429
- }
430
- data.costBreakdown = {
431
- models: modelEntries.map(([name, d]) => ({
432
- name,
433
- cost: d.cost || 0,
434
- mode: d.costMode || 'unknown',
435
- requests: d.count,
436
- inputTokens: d.inputTokens,
437
- outputTokens: d.outputTokens,
438
- })),
439
- cacheSaving,
440
- total: Math.round(totalCost * 100) / 100,
441
- };
442
- }
443
-
444
- // 写入查询结果缓存
445
- setCachedReport(reportCacheKey, data);
463
+ const responseData = prepareJsonReportData(data, tool);
446
464
 
447
465
  res.writeHead(200, {
448
466
  'Content-Type': 'application/json',
449
467
  'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
450
468
  'X-Cache': 'MISS',
451
469
  });
452
- res.end(JSON.stringify(data));
470
+ res.end(JSON.stringify(responseData));
453
471
  } catch (err) {
454
472
  res.writeHead(500, { 'Content-Type': 'application/json' });
455
473
  console.error('API error:', err.message);
@@ -480,10 +498,10 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
480
498
  const extEnd = new Date(end);
481
499
  extEnd.setDate(extEnd.getDate() + 2);
482
500
  const gitStats = await getGitStatsForMultipleReposAsync(sessionRepos, start, extEnd.toISOString().slice(0, 10) + 'T23:59:59');
483
- await finalizeGitStats(gitStats, sessions, {
484
- attribution: config.aiAttribution,
485
- stepTracking: config.stepTracking,
486
- });
501
+ await finalizeGitStats(gitStats, sessions, {
502
+ attribution: config.aiAttribution,
503
+ stepTracking: config.stepTracking,
504
+ });
487
505
  }
488
506
  } catch (e) { console.warn("[server] error", e.message); }
489
507
  }
@@ -545,21 +563,14 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
545
563
  }
546
564
  result = Object.values(dailyMap).sort((a, b) => a.date.localeCompare(b.date));
547
565
  } else if (dimension === 'scenario') {
566
+ // 复用 classifyRecord 确保与统计逻辑一致
548
567
  const matched = filtered.filter(r => {
549
- const text = r.metadata?.text || r.text || '';
550
- const type = r.metadata?.type || r.type;
551
- return type === 'user' && text;
568
+ const classified = classifyRecord(r, config.scenarioKeywords);
569
+ return !!classified[key];
552
570
  });
553
571
  for (const r of matched) {
554
572
  const text = r.metadata?.text || r.text || '';
555
- const lower = text.toLowerCase();
556
- const keywords = config.scenarioKeywords?.[key] || [];
557
- for (const kw of keywords) {
558
- if (lower.includes(kw.toLowerCase())) {
559
- result.push({ text: text.slice(0, 200), timestamp: r.timestamp, project: r.project });
560
- break;
561
- }
562
- }
573
+ result.push({ text: text.slice(0, 200), timestamp: r.timestamp, project: r.project });
563
574
  if (result.length >= 10) break;
564
575
  }
565
576
  }
@@ -633,10 +644,25 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
633
644
  if (newConfig.codexDir !== undefined) { if (!validatePath(newConfig.codexDir)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'codexDir 格式无效' })); return; } config.codexDir = newConfig.codexDir; }
634
645
  if (newConfig.opencodeDir !== undefined) { if (!validatePath(newConfig.opencodeDir)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'opencodeDir 格式无效' })); return; } config.opencodeDir = newConfig.opencodeDir; }
635
646
  if (newConfig.enabledTools !== undefined) config.enabledTools = newConfig.enabledTools;
636
- if (newConfig.repos !== undefined) { if (!Array.isArray(newConfig.repos)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'repos 格式无效' })); return; } config.repos = newConfig.repos; }
637
- if (newConfig.excludeProjects !== undefined) config.excludeProjects = newConfig.excludeProjects;
638
- if (newConfig.scenarioKeywords !== undefined) config.scenarioKeywords = newConfig.scenarioKeywords;
639
- if (newConfig.stepTracking !== undefined) config.stepTracking = newConfig.stepTracking;
647
+ if (newConfig.repos !== undefined) config.repos = parseRepoPaths(newConfig.repos);
648
+ if (newConfig.excludeProjects !== undefined) config.excludeProjects = parseRepoPaths(newConfig.excludeProjects);
649
+ if (newConfig.scenarioKeywords !== undefined) {
650
+ // 服务端校验:限制关键词长度和数量
651
+ const sk = newConfig.scenarioKeywords;
652
+ if (typeof sk === 'object' && sk !== null) {
653
+ const sanitized = {};
654
+ for (const [scene, words] of Object.entries(sk)) {
655
+ if (!Array.isArray(words)) continue;
656
+ sanitized[scene] = words
657
+ .map(w => String(w).trim())
658
+ .filter(w => w.length > 0 && w.length <= 100)
659
+ .filter(w => !/[\x00-\x1f\x7f]/.test(w))
660
+ .slice(0, 50);
661
+ }
662
+ config.scenarioKeywords = sanitized;
663
+ }
664
+ }
665
+ if (newConfig.stepTracking !== undefined) config.stepTracking = newConfig.stepTracking;
640
666
  invalidateFileCache();
641
667
  invalidateGitCache();
642
668
  _parsedCache = null; // 配置变更后清除解析缓存
@@ -658,21 +684,21 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
658
684
  }
659
685
 
660
686
  // Step blame stats API
661
- if (url.pathname === '/api/step-stats') {
662
- let stepStats = { stepCount: 0, sessionCount: 0, available: false };
663
- try {
664
- if (config.stepTracking?.enabled !== false) {
665
- for (const repo of config.repos || []) {
666
- const tracker = new StepTracker(repo, { dbPath: config.stepTracking?.dbPath });
667
- if (await tracker.isAvailableAsync()) {
668
- await tracker.open();
669
- stepStats = { ...tracker.getStats(), available: true };
670
- tracker.close();
671
- break;
672
- }
673
- }
674
- }
675
- } catch { /* step tracking not available */ }
687
+ if (url.pathname === '/api/step-stats') {
688
+ let stepStats = { stepCount: 0, sessionCount: 0, available: false };
689
+ try {
690
+ if (config.stepTracking?.enabled !== false) {
691
+ for (const repo of config.repos || []) {
692
+ const tracker = new StepTracker(repo, { dbPath: config.stepTracking?.dbPath });
693
+ if (await tracker.isAvailableAsync()) {
694
+ await tracker.open();
695
+ stepStats = { ...tracker.getStats(), available: true };
696
+ tracker.close();
697
+ break;
698
+ }
699
+ }
700
+ }
701
+ } catch { /* step tracking not available */ }
676
702
  res.writeHead(200, {
677
703
  'Content-Type': 'application/json',
678
704
  'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
@@ -727,9 +753,9 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
727
753
  const cyan = '\x1b[96m';
728
754
  const green = '\x1b[92m';
729
755
  const yellow = '\x1b[93m';
730
- const blue = '\x1b[94m';
731
- const dim = '\x1b[2m';
732
- const actualPort = server.address()?.port || PORT;
756
+ const blue = '\x1b[94m';
757
+ const dim = '\x1b[2m';
758
+ const actualPort = server.address()?.port || PORT;
733
759
 
734
760
  const banner = [
735
761
  '',
@@ -752,35 +778,35 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
752
778
  if (configPath) {
753
779
  process.stdout.write(` ${dim}●${R} ${B}Config${R} ${configPath}\n`);
754
780
  }
755
- const repoCount = config.repos?.length || 0;
756
- if (repoCount > 0) {
757
- process.stdout.write(` ${dim}●${R} ${B}Projects${R} ${repoCount} repo(s) detected\n`);
758
- }
759
- const hookStatus = getConfiguredHooksStatus(config);
760
- const hookParts = [
761
- `Claude ${hookStatus.claude.enabledCount}/${hookStatus.projectCount}`,
762
- `Codex ${hookStatus.codex.enabledCount}/${hookStatus.projectCount}`,
763
- `OpenCode ${hookStatus.opencode.enabledCount}/${hookStatus.projectCount}`,
764
- `steps ${hookStatus.stepsReadyCount}/${hookStatus.projectCount}`,
765
- ];
766
- process.stdout.write(` ${dim}●${R} ${B}Hooks${R} ${hookParts.join(' / ')}\n`);
767
- if (hookStatus.projectCount === 0) {
768
- process.stdout.write(` ${yellow}${B}!${R} 未配置项目路径:请先在页面设置中添加项目,页面开启 hooks 只作用于设置内项目。\n`);
769
- } else if (!hookStatus.claude.enabled || !hookStatus.codex.enabled || !hookStatus.opencode.enabled || !hookStatus.stepsInitialized) {
770
- process.stdout.write(` ${yellow}${B}!${R} 行级归因未完整开启:在页面中开启,或进入项目目录运行 ${B}npx lumencode hooks enable${R}。\n`);
771
- }
772
- process.stdout.write('\n');
773
- process.stdout.write(` ${green}${B}✓${R} Server ready at ${blue}${B}http://localhost:${actualPort}${R}\n`);
781
+ const repoCount = config.repos?.length || 0;
782
+ if (repoCount > 0) {
783
+ process.stdout.write(` ${dim}●${R} ${B}Projects${R} ${repoCount} repo(s) detected\n`);
784
+ }
785
+ const hookStatus = getConfiguredHooksStatus(config);
786
+ const hookParts = [
787
+ `Claude ${hookStatus.claude.enabledCount}/${hookStatus.projectCount}`,
788
+ `Codex ${hookStatus.codex.enabledCount}/${hookStatus.projectCount}`,
789
+ `OpenCode ${hookStatus.opencode.enabledCount}/${hookStatus.projectCount}`,
790
+ `steps ${hookStatus.stepsReadyCount}/${hookStatus.projectCount}`,
791
+ ];
792
+ process.stdout.write(` ${dim}●${R} ${B}Hooks${R} ${hookParts.join(' / ')}\n`);
793
+ if (hookStatus.projectCount === 0) {
794
+ process.stdout.write(` ${yellow}${B}!${R} 未配置项目路径:请先在页面设置中添加项目,页面开启 hooks 只作用于设置内项目。\n`);
795
+ } else if (!hookStatus.claude.enabled || !hookStatus.codex.enabled || !hookStatus.opencode.enabled || !hookStatus.stepsInitialized) {
796
+ process.stdout.write(` ${yellow}${B}!${R} 行级归因未完整开启:在页面中开启,或进入项目目录运行 ${B}npx lumencode hooks enable${R}。\n`);
797
+ }
798
+ process.stdout.write('\n');
799
+ process.stdout.write(` ${green}${B}✓${R} Server ready at ${blue}${B}http://localhost:${actualPort}${R}\n`);
774
800
  process.stdout.write('\n');
775
801
 
776
- // Auto-open browser
777
- if (process.env.LUMENCODE_NO_OPEN !== '1') {
778
- const openCmd = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
779
- import('child_process').then(({ exec }) => {
780
- exec(`${openCmd} http://localhost:${actualPort}`, () => {});
781
- });
782
- }
783
- });
784
-
785
- return server;
786
- }
802
+ // Auto-open browser
803
+ if (process.env.LUMENCODE_NO_OPEN !== '1') {
804
+ const openCmd = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
805
+ import('child_process').then(({ exec }) => {
806
+ exec(`${openCmd} http://localhost:${actualPort}`, () => {});
807
+ });
808
+ }
809
+ });
810
+
811
+ return server;
812
+ }