lumencode 1.3.1 → 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, computeAIContribution, computeCommitTypes, computeFileHotspots } 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,9 +91,9 @@ 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) {
93
- return `${period}|${date}|${tool || 'all'}|${customStart || ''}|${customEnd || ''}`;
94
- }
94
+ function getReportCacheKey(period, date, tool, customStart, customEnd) {
95
+ return `${period}|${date}|${tool || 'all'}|${customStart || ''}|${customEnd || ''}`;
96
+ }
95
97
 
96
98
  function getCachedReport(cacheKey) {
97
99
  const cached = _reportCache.get(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(',') || ''}`;
@@ -166,80 +168,80 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
166
168
  return null;
167
169
  }
168
170
 
169
- async function getOrParse(config, includeProjects) {
170
- const cached = getCachedParse(config, includeProjects);
171
- if (cached) return cached;
172
- const result = await parseAllEnabledTools(config, {
173
- excludeProjects: config.excludeProjects,
171
+ async function getOrParse(config, includeProjects) {
172
+ const cached = getCachedParse(config, includeProjects);
173
+ if (cached) return cached;
174
+ const result = await parseAllEnabledTools(config, {
175
+ excludeProjects: config.excludeProjects,
174
176
  includeProjects,
175
177
  });
176
178
  _parsedCache = result;
177
179
  _parsedCacheKey = `${config.claudeDir}|${includeProjects?.join(',') || ''}`;
178
- _parsedCacheExpire = Date.now() + PARSED_CACHE_TTL;
179
- return result;
180
- }
181
-
182
- function deriveProjectGitStats(gitStats, project, start, end, attributionOptions) {
183
- if (!gitStats?.commitList?.length || !project) return null;
184
- const windowEnd = end + 'T23:59:59';
185
- const commitList = gitStats.commitList.filter(c => {
186
- return getProjectBaseName(c.repo) === project && (c.date || '') >= start && (c.date || '') <= windowEnd;
187
- });
188
- if (commitList.length === 0) return null;
189
-
190
- return {
191
- commits: commitList.length,
192
- filesChanged: new Set(commitList.flatMap(c => (c.files || []).map(f => f.path))).size,
193
- linesAdded: commitList.reduce((s, c) => s + (c.linesAdded || 0), 0),
194
- linesDeleted: commitList.reduce((s, c) => s + (c.linesDeleted || 0), 0),
195
- commitList,
196
- commitTypes: computeCommitTypes(commitList),
197
- fileHotspots: computeFileHotspots(commitList, 10),
198
- aiContribution: computeAIContribution(commitList, null, attributionOptions),
199
- attributionSummary: gitStats.attributionSummary,
200
- };
201
- }
202
-
203
- function prepareJsonReportData(baseData, tool) {
204
- const data = { ...baseData };
205
-
206
- if (tool !== 'all' && data.gitStats?.aiContributionByTool) {
207
- const toolAi = data.gitStats.aiContributionByTool[tool];
208
- if (toolAi) {
209
- data.gitStats = { ...data.gitStats, aiContribution: toolAi };
210
- }
211
- }
212
-
213
- if (data.usageStats?.models) {
214
- const modelEntries = Object.entries(data.usageStats.models)
215
- .sort((a, b) => b[1].cost - a[1].cost);
216
- const totalCost = modelEntries.reduce((s, [, d]) => s + (d.cost || 0), 0);
217
- const cacheRead = data.usageStats.cacheRead || 0;
218
- const cacheCreate = data.usageStats.cacheCreate || 0;
219
- let cacheSaving = 0;
220
- if (totalCost > 0 && (cacheRead + cacheCreate) > 0) {
221
- const totalInput = data.usageStats.inputTokens || 1;
222
- const avgInputCostPerToken = totalCost / (totalInput + data.usageStats.outputTokens + cacheRead + cacheCreate || 1);
223
- cacheSaving = Math.round(cacheRead * avgInputCostPerToken * 0.9 * 100) / 100;
224
- }
225
- data.costBreakdown = {
226
- models: modelEntries.map(([name, d]) => ({
227
- name,
228
- cost: d.cost || 0,
229
- mode: d.costMode || 'unknown',
230
- requests: d.count,
231
- inputTokens: d.inputTokens,
232
- outputTokens: d.outputTokens,
233
- })),
234
- cacheSaving,
235
- total: Math.round(totalCost * 100) / 100,
236
- };
237
- }
238
-
239
- return data;
240
- }
241
-
242
- const server = createServer(async (req, res) => {
180
+ _parsedCacheExpire = Date.now() + PARSED_CACHE_TTL;
181
+ return result;
182
+ }
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
+
244
+ const server = createServer(async (req, res) => {
243
245
  const url = new URL(req.url, `http://localhost:${PORT}`);
244
246
 
245
247
  // 安全响应头
@@ -249,60 +251,60 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
249
251
  res.setHeader('X-XSS-Protection', '1; mode=block');
250
252
  res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
251
253
 
252
- // API endpoint
253
- if (url.pathname === '/api/hooks') {
254
- try {
255
- if (req.method === 'GET') {
256
- writeJson(res, 200, getConfiguredHooksStatus(config));
257
- return;
258
- }
259
-
260
- if (req.method === 'POST') {
261
- readJsonBody(req, res, async (body) => {
262
- const action = body.action || 'enable';
263
- const tools = parseHookTools(body.tools || url.searchParams.get('tools'));
264
- if (tools.length === 0) {
265
- writeJson(res, 400, { error: '未选择支持的 hooks 工具' });
266
- return;
267
- }
268
- const projectRoots = getHookProjectRoots(config);
269
- if (projectRoots.length === 0) {
270
- writeJson(res, 400, { error: '请先在设置中添加项目路径,页面开启 hooks 只作用于设置内配置的项目。' });
271
- return;
272
- }
273
- const stepTracking = [];
274
- const results = [];
275
- for (const projectRoot of projectRoots) {
276
- let projectStepTracking = null;
277
- if (action !== 'disable') {
278
- projectStepTracking = await initStepTracking(projectRoot);
279
- stepTracking.push({ projectRoot, ...projectStepTracking });
280
- }
281
- const projectResults = action === 'disable'
282
- ? disableHooks(projectRoot, tools, { backup: true })
283
- : enableHooks(projectRoot, tools, { backup: true });
284
- results.push({ projectRoot, stepTracking: projectStepTracking, results: projectResults });
285
- }
286
- writeJson(res, 200, {
287
- success: true,
288
- action,
289
- stepTracking,
290
- results,
291
- status: getConfiguredHooksStatus(config),
292
- });
293
- });
294
- return;
295
- }
296
-
297
- writeJson(res, 405, { error: 'Method not allowed' });
298
- } catch (err) {
299
- console.error('API error:', err.message);
300
- writeJson(res, 500, { error: err.message || '服务器内部错误' });
301
- }
302
- return;
303
- }
304
-
305
- 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') {
306
308
  try {
307
309
  const tools = await detectAvailableTools(config);
308
310
  const enabled = config.enabledTools || tools.filter(t => t.detected).map(t => t.name);
@@ -372,26 +374,26 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
372
374
 
373
375
  try {
374
376
  // 查询结果缓存:相同条件直接返回缓存
375
- const reportCacheKey = getReportCacheKey(period, date, tool, customStart, customEnd);
376
- let data = getCachedReport(reportCacheKey);
377
- if (data && format !== 'work') {
378
- const responseData = prepareJsonReportData(data, tool);
379
- res.writeHead(200, {
380
- 'Content-Type': 'application/json',
381
- 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
382
- 'X-Cache': 'HIT',
383
- });
384
- res.end(JSON.stringify(responseData));
385
- return;
386
- }
387
-
388
- let parsed = null;
389
- if (!data) {
390
- parsed = await getOrParse(config, includeProjects);
391
- data = await buildReportData(period, date, config, includeProjects, tool, parsed, { customStart, customEnd });
392
- if (data) setCachedReport(reportCacheKey, data);
393
- }
394
- if (!data) {
377
+ const reportCacheKey = getReportCacheKey(period, date, tool, customStart, customEnd);
378
+ let data = getCachedReport(reportCacheKey);
379
+ if (data && format !== 'work') {
380
+ const responseData = prepareJsonReportData(data, tool);
381
+ res.writeHead(200, {
382
+ 'Content-Type': 'application/json',
383
+ 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
384
+ 'X-Cache': 'HIT',
385
+ });
386
+ res.end(JSON.stringify(responseData));
387
+ return;
388
+ }
389
+
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
+ }
396
+ if (!data) {
395
397
  res.writeHead(200, { 'Content-Type': 'application/json' });
396
398
  res.end(JSON.stringify({
397
399
  error: '未找到数据',
@@ -417,11 +419,11 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
417
419
  let projUsageStats = data.usageStats;
418
420
  let projGitStats = data.gitStats;
419
421
  let projPrevStats = data.prevStats;
420
- let projectName = '';
421
-
422
- if (project) {
423
- if (!parsed) parsed = await getOrParse(config, includeProjects);
424
- const { records: allRecords } = parsed;
422
+ let projectName = '';
423
+
424
+ if (project) {
425
+ if (!parsed) parsed = await getOrParse(config, includeProjects);
426
+ const { records: allRecords } = parsed;
425
427
  const toolRecords = tool !== 'all' ? allRecords.filter(r => r.tool === tool) : allRecords;
426
428
  // basename 匹配
427
429
  const projRecords = toolRecords.filter(r => {
@@ -441,10 +443,15 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
441
443
  projPrevStats = prevProjFiltered.length > 0 ? computeUsageStats(prevProjFiltered, config.scenarioKeywords, config.costMode) : null;
442
444
 
443
445
  // 单项目 Git 统计
444
- projGitStats = deriveProjectGitStats(data.gitStats, project, pStart, pEnd, config.aiAttribution);
445
- }
446
+ projGitStats = deriveProjectGitStats(data.gitStats, project, pStart, pEnd, config.aiAttribution);
447
+ }
446
448
 
447
- 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
+ }
448
455
  res.writeHead(200, {
449
456
  'Content-Type': 'text/plain; charset=utf-8',
450
457
  'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
@@ -453,14 +460,14 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
453
460
  return;
454
461
  }
455
462
 
456
- const responseData = prepareJsonReportData(data, tool);
463
+ const responseData = prepareJsonReportData(data, tool);
457
464
 
458
- res.writeHead(200, {
465
+ res.writeHead(200, {
459
466
  'Content-Type': 'application/json',
460
467
  'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
461
468
  'X-Cache': 'MISS',
462
469
  });
463
- res.end(JSON.stringify(responseData));
470
+ res.end(JSON.stringify(responseData));
464
471
  } catch (err) {
465
472
  res.writeHead(500, { 'Content-Type': 'application/json' });
466
473
  console.error('API error:', err.message);
@@ -491,10 +498,10 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
491
498
  const extEnd = new Date(end);
492
499
  extEnd.setDate(extEnd.getDate() + 2);
493
500
  const gitStats = await getGitStatsForMultipleReposAsync(sessionRepos, start, extEnd.toISOString().slice(0, 10) + 'T23:59:59');
494
- await finalizeGitStats(gitStats, sessions, {
495
- attribution: config.aiAttribution,
496
- stepTracking: config.stepTracking,
497
- });
501
+ await finalizeGitStats(gitStats, sessions, {
502
+ attribution: config.aiAttribution,
503
+ stepTracking: config.stepTracking,
504
+ });
498
505
  }
499
506
  } catch (e) { console.warn("[server] error", e.message); }
500
507
  }
@@ -556,21 +563,14 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
556
563
  }
557
564
  result = Object.values(dailyMap).sort((a, b) => a.date.localeCompare(b.date));
558
565
  } else if (dimension === 'scenario') {
566
+ // 复用 classifyRecord 确保与统计逻辑一致
559
567
  const matched = filtered.filter(r => {
560
- const text = r.metadata?.text || r.text || '';
561
- const type = r.metadata?.type || r.type;
562
- return type === 'user' && text;
568
+ const classified = classifyRecord(r, config.scenarioKeywords);
569
+ return !!classified[key];
563
570
  });
564
571
  for (const r of matched) {
565
572
  const text = r.metadata?.text || r.text || '';
566
- const lower = text.toLowerCase();
567
- const keywords = config.scenarioKeywords?.[key] || [];
568
- for (const kw of keywords) {
569
- if (lower.includes(kw.toLowerCase())) {
570
- result.push({ text: text.slice(0, 200), timestamp: r.timestamp, project: r.project });
571
- break;
572
- }
573
- }
573
+ result.push({ text: text.slice(0, 200), timestamp: r.timestamp, project: r.project });
574
574
  if (result.length >= 10) break;
575
575
  }
576
576
  }
@@ -644,10 +644,25 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
644
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; }
645
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; }
646
646
  if (newConfig.enabledTools !== undefined) config.enabledTools = newConfig.enabledTools;
647
- 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; }
648
- if (newConfig.excludeProjects !== undefined) config.excludeProjects = newConfig.excludeProjects;
649
- if (newConfig.scenarioKeywords !== undefined) config.scenarioKeywords = newConfig.scenarioKeywords;
650
- 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;
651
666
  invalidateFileCache();
652
667
  invalidateGitCache();
653
668
  _parsedCache = null; // 配置变更后清除解析缓存
@@ -669,21 +684,21 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
669
684
  }
670
685
 
671
686
  // Step blame stats API
672
- if (url.pathname === '/api/step-stats') {
673
- let stepStats = { stepCount: 0, sessionCount: 0, available: false };
674
- try {
675
- if (config.stepTracking?.enabled !== false) {
676
- for (const repo of config.repos || []) {
677
- const tracker = new StepTracker(repo, { dbPath: config.stepTracking?.dbPath });
678
- if (await tracker.isAvailableAsync()) {
679
- await tracker.open();
680
- stepStats = { ...tracker.getStats(), available: true };
681
- tracker.close();
682
- break;
683
- }
684
- }
685
- }
686
- } 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 */ }
687
702
  res.writeHead(200, {
688
703
  'Content-Type': 'application/json',
689
704
  'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
@@ -738,9 +753,9 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
738
753
  const cyan = '\x1b[96m';
739
754
  const green = '\x1b[92m';
740
755
  const yellow = '\x1b[93m';
741
- const blue = '\x1b[94m';
742
- const dim = '\x1b[2m';
743
- const actualPort = server.address()?.port || PORT;
756
+ const blue = '\x1b[94m';
757
+ const dim = '\x1b[2m';
758
+ const actualPort = server.address()?.port || PORT;
744
759
 
745
760
  const banner = [
746
761
  '',
@@ -763,35 +778,35 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
763
778
  if (configPath) {
764
779
  process.stdout.write(` ${dim}●${R} ${B}Config${R} ${configPath}\n`);
765
780
  }
766
- const repoCount = config.repos?.length || 0;
767
- if (repoCount > 0) {
768
- process.stdout.write(` ${dim}●${R} ${B}Projects${R} ${repoCount} repo(s) detected\n`);
769
- }
770
- const hookStatus = getConfiguredHooksStatus(config);
771
- const hookParts = [
772
- `Claude ${hookStatus.claude.enabledCount}/${hookStatus.projectCount}`,
773
- `Codex ${hookStatus.codex.enabledCount}/${hookStatus.projectCount}`,
774
- `OpenCode ${hookStatus.opencode.enabledCount}/${hookStatus.projectCount}`,
775
- `steps ${hookStatus.stepsReadyCount}/${hookStatus.projectCount}`,
776
- ];
777
- process.stdout.write(` ${dim}●${R} ${B}Hooks${R} ${hookParts.join(' / ')}\n`);
778
- if (hookStatus.projectCount === 0) {
779
- process.stdout.write(` ${yellow}${B}!${R} 未配置项目路径:请先在页面设置中添加项目,页面开启 hooks 只作用于设置内项目。\n`);
780
- } else if (!hookStatus.claude.enabled || !hookStatus.codex.enabled || !hookStatus.opencode.enabled || !hookStatus.stepsInitialized) {
781
- process.stdout.write(` ${yellow}${B}!${R} 行级归因未完整开启:在页面中开启,或进入项目目录运行 ${B}npx lumencode hooks enable${R}。\n`);
782
- }
783
- process.stdout.write('\n');
784
- 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`);
785
800
  process.stdout.write('\n');
786
801
 
787
- // Auto-open browser
788
- if (process.env.LUMENCODE_NO_OPEN !== '1') {
789
- const openCmd = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
790
- import('child_process').then(({ exec }) => {
791
- exec(`${openCmd} http://localhost:${actualPort}`, () => {});
792
- });
793
- }
794
- });
795
-
796
- return server;
797
- }
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
+ }