lumencode 0.4.4 → 1.1.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.
package/lib/report.js CHANGED
@@ -144,7 +144,17 @@ function buildGitNarrative(git, periodName) {
144
144
  const totalLines = ai.totalLinesChanged || 1;
145
145
  const aiLinePct = Math.round((ai.aiLinesChanged / totalLines) * 100);
146
146
  const commitPct = Math.round((ai.aiCommitRatio ?? (ai.aiCommits / git.commits)) * 100);
147
- line += ` 其中 ${aiLinePct}% 代码变更有 AI 参与,${ai.aiCommits}/${git.commits} 提交使用 AI (${commitPct}%)。`;
147
+ const possibleCommitPct = ai.possibleAICommits > 0 ? Math.round((ai.possibleAICommits / git.commits) * 100) : 0;
148
+ const weightedPct = Math.round((ai.weightedAILineRatio || 0) * 100);
149
+ line += ` 高/中置信 AI 提交 ${ai.aiCommits}/${git.commits} (${commitPct}%),`;
150
+ if (ai.possibleAICommits > 0) {
151
+ line += `可能 AI 提交 ${ai.possibleAICommits} (${possibleCommitPct}%),`;
152
+ }
153
+ line += `AI 代码改写占比 ${aiLinePct}%`;
154
+ if (weightedPct > aiLinePct) {
155
+ line += `,加权影响力 ${weightedPct}%`;
156
+ }
157
+ line += '。';
148
158
  }
149
159
 
150
160
  return line;
@@ -433,10 +443,12 @@ function buildGitInsight(git) {
433
443
  const ai = git.aiContribution;
434
444
  const ratio = ai.aiCommitRatio ?? (ai.aiCommits ? ai.aiCommits / git.commits : 0);
435
445
  const commitPct = Math.round(ratio * 100);
446
+ const possiblePct = Math.round((ai.possibleAICommitRatio || 0) * 100);
436
447
  if (!isNaN(commitPct)) {
437
448
  if (commitPct > 80) insights.push('AI 参与度极高,核心代码产出几乎全程 AI 辅助');
438
449
  else if (commitPct > 50) insights.push('AI 参与度较高,超过半数提交有 AI 辅助');
439
- else if (commitPct > 0) insights.push(`AI 参与 ${commitPct}% 的提交,人机协作比例适中`);
450
+ else if (commitPct > 0) insights.push(`高/中置信 AI 参与 ${commitPct}% 的提交,人机协作比例适中`);
451
+ else if (possiblePct > 0) insights.push(`无高/中置信 AI 提交,但 ${possiblePct}% 提交可能受 AI 影响`);
440
452
  }
441
453
  }
442
454
  const netLines = git.linesAdded - git.linesDeleted;
@@ -602,6 +614,10 @@ export function generateReport(usageData, gitData, period, startDate, endDate) {
602
614
  gitTable.addRow(['高置信提交', String(ai.highConfidenceCommits), '']);
603
615
  gitTable.addRow(['AI 命中文件新增行', String(ai.aiFileLinesAdded), '']);
604
616
  gitTable.addRow(['AI 命中文件删除行', String(ai.aiFileLinesDeleted), '']);
617
+ if (ai.possibleAICommits > 0) {
618
+ const possiblePct = Math.round((ai.possibleAICommits / gitData.commits) * 100);
619
+ gitTable.addRow(['可能 AI 提交', `${ai.possibleAICommits}/${gitData.commits}`, `${possiblePct}%`]);
620
+ }
605
621
  gitTable.addRow(['低置信关联提交', String(ai.lowConfidenceCommits), '']);
606
622
  }
607
623
  lines.push(gitTable.render());
@@ -900,7 +916,11 @@ export function generateFeishuCard(usageData, gitData, period, startDate, endDat
900
916
  if (gitData.aiContribution) {
901
917
  const ai = gitData.aiContribution;
902
918
  const commitPct = Math.round((ai.aiCommitRatio ?? (ai.aiCommits / gitData.commits)) * 100);
903
- fields.push({ is_short: true, text: { tag: 'lark_md', content: `**AI 代码改写占比**\n${commitPct}%` } });
919
+ fields.push({ is_short: true, text: { tag: 'lark_md', content: `**AI 提交**\n${ai.aiCommits}/${gitData.commits} (${commitPct}%)` } });
920
+ if (ai.possibleAICommits > 0) {
921
+ const possiblePct = Math.round((ai.possibleAICommits / gitData.commits) * 100);
922
+ fields.push({ is_short: true, text: { tag: 'lark_md', content: `**可能 AI**\n${ai.possibleAICommits} (${possiblePct}%)` } });
923
+ }
904
924
  }
905
925
  }
906
926
  if (usageData.estimatedCost) {
@@ -1024,7 +1044,16 @@ export function generateBriefReport(usageData, gitData, period, startDate, endDa
1024
1044
  const totalLines = ai.totalLinesChanged || 1;
1025
1045
  const aiLinePct = Math.round((ai.aiLinesChanged / totalLines) * 100);
1026
1046
  const commitPct = Math.round((ai.aiCommitRatio ?? (ai.aiCommits / gitData.commits)) * 100);
1027
- lines.push(bullet(`${aiLinePct}% 代码变更有 AI 参与,${ai.aiCommits}/${gitData.commits} 提交使用 AI (${commitPct}%)`));
1047
+ const possibleCommitPct = ai.possibleAICommits > 0 ? Math.round((ai.possibleAICommits / gitData.commits) * 100) : 0;
1048
+ const weightedPct = Math.round((ai.weightedAILineRatio || 0) * 100);
1049
+ let line = `${aiLinePct}% 代码变更有 AI 参与,${ai.aiCommits}/${gitData.commits} 提交使用 AI (${commitPct}%)`;
1050
+ if (ai.possibleAICommits > 0) {
1051
+ line += `,可能 AI 提交 ${ai.possibleAICommits} (${possibleCommitPct}%)`;
1052
+ }
1053
+ if (weightedPct > aiLinePct) {
1054
+ line += `,加权影响力 ${weightedPct}%`;
1055
+ }
1056
+ lines.push(bullet(line));
1028
1057
  }
1029
1058
 
1030
1059
  if (gitData.commitList?.length) {
@@ -1146,16 +1175,17 @@ export function generateWorkReport(usageData, gitData, period, startDate, endDat
1146
1175
  const opts = typeof options === 'string'
1147
1176
  ? { level: 'detailed', platform: options }
1148
1177
  : { level: 'detailed', platform: 'default', ...options };
1149
- const { level, platform: fmt, tool } = opts;
1150
- const titlePrefix = tool && tool !== 'all' ? toolTitle(tool) : 'AI 编码助手';
1178
+ const { level, platform: fmt, tool, projectName } = opts;
1179
+ const toolLabel = tool && tool !== 'all' ? toolTitle(tool) : 'AI 编码助手';
1180
+ const titlePrefix = projectName ? `${projectName} · ${toolLabel}` : toolLabel;
1151
1181
 
1152
1182
  // 简报路由
1153
1183
  if (level === 'brief') {
1154
1184
  return generateBriefReport(usageData, gitData, period, startDate, endDate, prevData, fmt, tool);
1155
1185
  }
1156
1186
  const lines = [];
1157
- const periodLabel = period === 'daily' ? '日报' : period === 'weekly' ? '周报' : '月报';
1158
- const dateLabel = period === 'monthly' ? startDate.slice(0, 7) : period === 'weekly' ? `${startDate} ~ ${endDate}` : startDate;
1187
+ const periodLabel = period === 'daily' ? '日报' : period === 'weekly' ? '周报' : period === 'monthly' ? '月报' : '自定义';
1188
+ const dateLabel = period === 'monthly' ? startDate.slice(0, 7) : period === 'daily' ? startDate : `${startDate} ~ ${endDate}`;
1159
1189
 
1160
1190
  lines.push(`# ${titlePrefix} 工作${periodLabel} - ${dateLabel}`);
1161
1191
  lines.push('');
@@ -1291,18 +1321,25 @@ export function generateWorkReport(usageData, gitData, period, startDate, endDat
1291
1321
  const aiLinePct = Math.round(((gitData.aiContribution?.aiLineRatio ?? gitData.aiContribution?.aiRatio) || 0) * 100);
1292
1322
  sectionLines.push(`- 高/中置信 AI 提交 **${totalAI}/${totalCommits}**(${aiLinePct}%),涉及 +${formatInt(aiDetail.totalAIFileAdded)}/-${formatInt(aiDetail.totalAIFileDeleted)} 行`);
1293
1323
 
1294
- if (aiDetail.explicit.length > 0) {
1295
- sectionLines.push(`- **显式 AI**(${aiDetail.explicit.length} 项):${aiDetail.explicit.map(s => `\`${s}\``).join('、')}`);
1324
+ if (gitData.aiContribution?.possibleAICommits > 0) {
1325
+ const possiblePct = Math.round((gitData.aiContribution.possibleAICommits / totalCommits) * 100);
1326
+ sectionLines.push(`- 可能 AI 提交 **${gitData.aiContribution.possibleAICommits}/${totalCommits}**(${possiblePct}%)`);
1296
1327
  }
1297
- if (aiDetail.sessionStrong.length > 0) {
1298
- sectionLines.push(`- **强关联**(${aiDetail.sessionStrong.length} 项):${aiDetail.sessionStrong.map(s => `\`${s}\``).join('、')}`);
1328
+ if (gitData.aiContribution?.weightedAILineRatio > 0) {
1329
+ sectionLines.push(`- 加权 AI 影响力 **${Math.round(gitData.aiContribution.weightedAILineRatio * 100)}%**`);
1299
1330
  }
1300
- if (aiDetail.fileOverlap.length > 0) {
1301
- sectionLines.push(`- **文件重叠**(${aiDetail.fileOverlap.length} 项):${aiDetail.fileOverlap.map(s => `\`${s}\``).join('、')}`);
1331
+
1332
+ // 汇总统计(不列出具体 commit subject,工作汇报中无阅读价值)
1333
+ const parts = [];
1334
+ if (aiDetail.explicit.length > 0) parts.push(`显式标记 ${aiDetail.explicit.length} 项`);
1335
+ if (aiDetail.sessionStrong.length > 0) parts.push(`会话强关联 ${aiDetail.sessionStrong.length} 项`);
1336
+ if (aiDetail.fileOverlap.length > 0) parts.push(`文件重叠 ${aiDetail.fileOverlap.length} 项`);
1337
+ if (parts.length > 0) {
1338
+ sectionLines.push(`- 归因方式:${parts.join('、')}`);
1302
1339
  }
1303
1340
  if (aiDetail.aiFiles.length > 0) {
1304
- const topFiles = aiDetail.aiFiles.slice(0, 8).map(f => `\`${f}\``).join('、');
1305
- const overflow = aiDetail.aiFiles.length > 8 ? ` 等 ${aiDetail.aiFiles.length} 个` : '';
1341
+ const topFiles = aiDetail.aiFiles.slice(0, 5).join('、');
1342
+ const overflow = aiDetail.aiFiles.length > 5 ? ` 等 ${aiDetail.aiFiles.length} 个` : '';
1306
1343
  sectionLines.push(`- **AI 涉及文件**:${topFiles}${overflow}`);
1307
1344
  }
1308
1345
  sectionLines.push('');
package/lib/server.js CHANGED
@@ -4,12 +4,13 @@ import { join, extname, resolve, sep } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { saveConfig } from './config.js';
6
6
  import { generateWorkReport, generateFeishuCard } from './report.js';
7
- import { collectAllRecords, filterRecordsByPeriod, groupBySessions } from './aggregate.js';
7
+ import { collectAllRecords, filterRecordsByPeriod, groupBySessions, computeUsageStats, computeTrendData, computePrevPeriodRange } from './aggregate.js';
8
8
  import { normalizeProjectPath } from './aggregate.js';
9
9
  import { invalidateFileCache } from './cache.js';
10
10
  import { invalidateGitCache, getGitStatsForMultipleReposAsync, finalizeGitStats } from './git.js';
11
11
  import { identifyBillingBlocks } from './blocks.js';
12
12
  import { detectAvailableTools, parseAllEnabledTools } from './parsers/index.js';
13
+ import { isAssistantRecord, getInputTokens, getOutputTokens } from './record-utils.js';
13
14
 
14
15
  // basename 提取,兼容不同路径格式
15
16
  function getProjectBaseName(p) {
@@ -19,6 +20,13 @@ function getProjectBaseName(p) {
19
20
 
20
21
  const __dirname = fileURLToPath(new URL('..', import.meta.url));
21
22
 
23
+ // 读取应用版本号(必须在 __dirname 定义之后)
24
+ let appVersion = '0.0.0';
25
+ try {
26
+ const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8'));
27
+ appVersion = pkg.version || '0.0.0';
28
+ } catch {}
29
+
22
30
  const MIME = {
23
31
  '.html': 'text/html',
24
32
  '.css': 'text/css',
@@ -39,6 +47,61 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
39
47
 
40
48
  const PORT = process.env.LUMENCODE_PORT || 4567;
41
49
 
50
+ // ── 解析结果级缓存(避免同一秒内多次全量解析) ──
51
+ let _parsedCache = null;
52
+ let _parsedCacheKey = '';
53
+ let _parsedCacheExpire = 0;
54
+ const PARSED_CACHE_TTL = 30_000; // 30s
55
+
56
+ // ── 查询结果缓存(按查询条件缓存 buildReportData 结果) ──
57
+ const _reportCache = new Map();
58
+ const REPORT_CACHE_TTL = 30_000; // 30s
59
+ const REPORT_CACHE_MAX_SIZE = 50;
60
+
61
+ function getReportCacheKey(period, date, tool, customStart, customEnd, format) {
62
+ return `${period}|${date}|${tool || 'all'}|${customStart || ''}|${customEnd || ''}|${format || 'json'}`;
63
+ }
64
+
65
+ function getCachedReport(cacheKey) {
66
+ const cached = _reportCache.get(cacheKey);
67
+ if (cached && Date.now() < cached.expire) return cached.data;
68
+ _reportCache.delete(cacheKey);
69
+ return null;
70
+ }
71
+
72
+ function setCachedReport(cacheKey, data) {
73
+ _reportCache.set(cacheKey, { data, expire: Date.now() + REPORT_CACHE_TTL });
74
+ // LRU: 超出限制时删除最早的条目
75
+ while (_reportCache.size > REPORT_CACHE_MAX_SIZE) {
76
+ const oldest = _reportCache.keys().next().value;
77
+ _reportCache.delete(oldest);
78
+ }
79
+ }
80
+
81
+ function invalidateReportCache() {
82
+ _reportCache.clear();
83
+ }
84
+
85
+ function getCachedParse(config, includeProjects) {
86
+ const key = `${config.claudeDir}|${includeProjects?.join(',') || ''}`;
87
+ const now = Date.now();
88
+ if (_parsedCache && _parsedCacheKey === key && now < _parsedCacheExpire) return _parsedCache;
89
+ return null;
90
+ }
91
+
92
+ async function getOrParse(config, includeProjects) {
93
+ const cached = getCachedParse(config, includeProjects);
94
+ if (cached) return cached;
95
+ const result = await parseAllEnabledTools(config, {
96
+ excludeProjects: config.excludeProjects,
97
+ includeProjects,
98
+ });
99
+ _parsedCache = result;
100
+ _parsedCacheKey = `${config.claudeDir}|${includeProjects?.join(',') || ''}`;
101
+ _parsedCacheExpire = Date.now() + PARSED_CACHE_TTL;
102
+ return result;
103
+ }
104
+
42
105
  const server = createServer(async (req, res) => {
43
106
  const url = new URL(req.url, `http://localhost:${PORT}`);
44
107
 
@@ -46,16 +109,22 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
46
109
  res.setHeader('X-Content-Type-Options', 'nosniff');
47
110
  res.setHeader('X-Frame-Options', 'DENY');
48
111
  res.setHeader('Referrer-Policy', 'no-referrer');
112
+ res.setHeader('X-XSS-Protection', '1; mode=block');
113
+ res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
49
114
 
50
115
  // API endpoint
51
116
  if (url.pathname === '/api/tools') {
52
117
  try {
53
118
  const tools = await detectAvailableTools(config);
54
119
  const enabled = config.enabledTools || tools.filter(t => t.detected).map(t => t.name);
55
- const result = tools.map(({ name, displayName, detected, version }) => ({
56
- name, displayName, detected, version,
57
- enabled: enabled.includes(name),
58
- }));
120
+ const result = {
121
+ appName: 'LumenCode',
122
+ appVersion: 'v' + appVersion,
123
+ tools: tools.map(({ name, displayName, detected, version }) => ({
124
+ name, displayName, detected, version,
125
+ enabled: enabled.includes(name),
126
+ })),
127
+ };
59
128
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
60
129
  res.end(JSON.stringify(result));
61
130
  } catch (err) {
@@ -67,10 +136,40 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
67
136
  }
68
137
 
69
138
  if (url.pathname === '/api/report') {
70
- const period = url.searchParams.get('period') || 'daily';
139
+ const VALID_PERIODS = ['daily', 'weekly', 'monthly', 'custom'];
140
+ const rawPeriod = url.searchParams.get('period') || 'daily';
141
+ if (!VALID_PERIODS.includes(rawPeriod)) {
142
+ res.writeHead(400, { 'Content-Type': 'application/json' });
143
+ res.end(JSON.stringify({ error: `无效的 period 参数,可选值:${VALID_PERIODS.join('/')}` }));
144
+ return;
145
+ }
146
+ const period = rawPeriod;
71
147
  const date = url.searchParams.get('date') || new Date().toISOString().slice(0, 10);
72
148
  const format = url.searchParams.get('format') || 'json';
73
149
  const tool = url.searchParams.get('tool') || 'all';
150
+ const customStart = url.searchParams.get('start') || '';
151
+ const customEnd = url.searchParams.get('end') || '';
152
+ const includeProjects = computeIncludeProjects(config);
153
+
154
+ // Validate custom range
155
+ if (period === 'custom') {
156
+ if (!customStart || !customEnd || !/^\d{4}-\d{2}-\d{2}$/.test(customStart) || !/^\d{4}-\d{2}-\d{2}$/.test(customEnd)) {
157
+ res.writeHead(400, { 'Content-Type': 'application/json' });
158
+ res.end(JSON.stringify({ error: '自定义周期需要 start 和 end 参数 (YYYY-MM-DD)' }));
159
+ return;
160
+ }
161
+ if (customStart > customEnd) {
162
+ res.writeHead(400, { 'Content-Type': 'application/json' });
163
+ res.end(JSON.stringify({ error: '起始日期不能晚于结束日期' }));
164
+ return;
165
+ }
166
+ const spanMs = new Date(customEnd) - new Date(customStart);
167
+ if (spanMs > 90 * 86400000) {
168
+ res.writeHead(400, { 'Content-Type': 'application/json' });
169
+ res.end(JSON.stringify({ error: '自定义周期最长 90 天' }));
170
+ return;
171
+ }
172
+ }
74
173
 
75
174
  // 未配置时返回友好提示
76
175
  if (!config.claudeDir || !existsSync(config.claudeDir)) {
@@ -83,7 +182,21 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
83
182
  }
84
183
 
85
184
  try {
86
- const data = await buildReportData(period, date, config, computeIncludeProjects(config), tool);
185
+ // 查询结果缓存:相同条件直接返回缓存
186
+ const reportCacheKey = getReportCacheKey(period, date, tool, customStart, customEnd, format);
187
+ let data = getCachedReport(reportCacheKey);
188
+ if (data) {
189
+ res.writeHead(200, {
190
+ 'Content-Type': 'application/json',
191
+ 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
192
+ 'X-Cache': 'HIT',
193
+ });
194
+ res.end(JSON.stringify(data));
195
+ return;
196
+ }
197
+
198
+ const parsed = await getOrParse(config, includeProjects);
199
+ data = await buildReportData(period, date, config, includeProjects, tool, parsed, { customStart, customEnd });
87
200
  if (!data) {
88
201
  res.writeHead(200, { 'Content-Type': 'application/json' });
89
202
  res.end(JSON.stringify({
@@ -97,6 +210,7 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
97
210
  const platform = url.searchParams.get('platform') || 'default';
98
211
  const level = url.searchParams.get('level') || 'detailed';
99
212
  const feishuCard = url.searchParams.get('feishuCard') === 'true';
213
+ const project = url.searchParams.get('project') || '';
100
214
 
101
215
  if (feishuCard) {
102
216
  const card = generateFeishuCard(data.usageStats, data.gitStats, period, data.start, data.end, tool);
@@ -105,7 +219,51 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
105
219
  return;
106
220
  }
107
221
 
108
- const markdown = generateWorkReport(data.usageStats, data.gitStats, period, data.start, data.end, data.prevStats, { level, platform, tool });
222
+ // 单项目报告:过滤记录后重新计算统计
223
+ let projUsageStats = data.usageStats;
224
+ let projGitStats = data.gitStats;
225
+ let projPrevStats = data.prevStats;
226
+ let projectName = '';
227
+
228
+ if (project) {
229
+ const { records: allRecords } = parsed;
230
+ const toolRecords = tool !== 'all' ? allRecords.filter(r => r.tool === tool) : allRecords;
231
+ // basename 匹配
232
+ const projRecords = toolRecords.filter(r => {
233
+ return getProjectBaseName(r.project) === project;
234
+ });
235
+ const { filtered: projFiltered, start: pStart, end: pEnd } = filterRecordsByPeriod(projRecords, period, date, { customStart, customEnd });
236
+ projUsageStats = projFiltered.length > 0 ? computeUsageStats(projFiltered, config.scenarioKeywords, config.costMode) : { requestCount: 0, projects: {} };
237
+ projectName = project;
238
+
239
+ // 上一周期
240
+ const prevRange = computePrevPeriodRange(period, date, { customStart, customEnd });
241
+ const prevProjFiltered = projRecords.filter(r => {
242
+ if (!r.timestamp) return false;
243
+ const d = r.timestamp.slice(0, 10);
244
+ return d >= prevRange.start && d <= prevRange.end;
245
+ });
246
+ projPrevStats = prevProjFiltered.length > 0 ? computeUsageStats(prevProjFiltered, config.scenarioKeywords, config.costMode) : null;
247
+
248
+ // 单项目 Git 统计
249
+ if (config.repos?.length > 0) {
250
+ const matchedRepo = config.repos.find(r => getProjectBaseName(r) === project);
251
+ if (matchedRepo) {
252
+ try {
253
+ const { getGitStatsAsync } = await import('./git.js');
254
+ const sessions = groupBySessions(projFiltered);
255
+ const { finalizeGitStats, getGitStatsForMultipleReposAsync } = await import('./git.js');
256
+ let repoGit = await getGitStatsForMultipleReposAsync([matchedRepo], pStart, pEnd + 'T23:59:59');
257
+ repoGit = finalizeGitStats(repoGit, sessions);
258
+ projGitStats = repoGit;
259
+ } catch {}
260
+ }
261
+ } else {
262
+ projGitStats = null;
263
+ }
264
+ }
265
+
266
+ const markdown = generateWorkReport(projUsageStats, projGitStats, period, data.start, data.end, projPrevStats, { level, platform, tool, projectName });
109
267
  res.writeHead(200, {
110
268
  'Content-Type': 'text/plain; charset=utf-8',
111
269
  'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
@@ -151,9 +309,13 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
151
309
  };
152
310
  }
153
311
 
312
+ // 写入查询结果缓存
313
+ setCachedReport(reportCacheKey, data);
314
+
154
315
  res.writeHead(200, {
155
316
  'Content-Type': 'application/json',
156
317
  'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
318
+ 'X-Cache': 'MISS',
157
319
  });
158
320
  res.end(JSON.stringify(data));
159
321
  } catch (err) {
@@ -170,10 +332,7 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
170
332
  const project = url.searchParams.get('project') || '';
171
333
  const tool = url.searchParams.get('tool') || '';
172
334
  try {
173
- const { records: allRecords } = await parseAllEnabledTools(config, {
174
- excludeProjects: config.excludeProjects,
175
- includeProjects: computeIncludeProjects(config),
176
- });
335
+ const { records: allRecords } = await getOrParse(config, computeIncludeProjects(config));
177
336
  const { filtered, start, end } = filterRecordsByPeriod(allRecords, period, date);
178
337
  const tooledRecords = tool ? filtered.filter(r => r.tool === tool) : filtered;
179
338
  // basename 匹配,兼容不同工具的路径格式差异
@@ -183,11 +342,8 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
183
342
  // 附加 commits 信息(若配置了 repos),按覆盖项目过滤,扩展窗口匹配跨天提交
184
343
  if (config.repos?.length) {
185
344
  try {
186
- const coveredBases = new Set(projected.map(r => {
187
- const p = r.project || '';
188
- return p.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop();
189
- }).filter(Boolean));
190
- const sessionRepos = config.repos.filter(r => coveredBases.has(r.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop()));
345
+ const coveredBases = new Set(projected.map(r => getProjectBaseName(r.project)).filter(Boolean));
346
+ const sessionRepos = config.repos.filter(r => coveredBases.has(getProjectBaseName(r)));
191
347
  if (sessionRepos.length > 0) {
192
348
  const extEnd = new Date(end);
193
349
  extEnd.setDate(extEnd.getDate() + 2);
@@ -237,24 +393,20 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
237
393
  const dimension = url.searchParams.get('dimension') || '';
238
394
  const key = url.searchParams.get('key') || '';
239
395
  try {
240
- const { records: allRecords } = await parseAllEnabledTools(config, {
241
- excludeProjects: config.excludeProjects,
242
- includeProjects: computeIncludeProjects(config),
243
- });
396
+ const { records: allRecords } = await getOrParse(config, computeIncludeProjects(config));
244
397
  const { filtered } = filterRecordsByPeriod(allRecords, period, date);
245
398
  let result = [];
246
399
  if (dimension === 'model') {
247
400
  const modelRecords = filtered.filter(r => {
248
- const isAssistant = r.metadata?.type === 'assistant' || (r.tool === 'codex') || (r.tool === 'opencode' && r.metadata?.role !== 'user') || (r.type === 'assistant' && !r.tool);
249
- return isAssistant && (r.model || '') === key;
401
+ return isAssistantRecord(r) && (r.model || '') === key;
250
402
  });
251
403
  const dailyMap = {};
252
404
  for (const r of modelRecords) {
253
405
  const d = r.timestamp.slice(0, 10);
254
406
  if (!dailyMap[d]) dailyMap[d] = { date: d, requests: 0, inputTokens: 0, outputTokens: 0 };
255
407
  dailyMap[d].requests++;
256
- dailyMap[d].inputTokens += r.inputTokens || r.tokens?.input || 0;
257
- dailyMap[d].outputTokens += r.outputTokens || r.tokens?.output || 0;
408
+ dailyMap[d].inputTokens += getInputTokens(r);
409
+ dailyMap[d].outputTokens += getOutputTokens(r);
258
410
  }
259
411
  result = Object.values(dailyMap).sort((a, b) => a.date.localeCompare(b.date));
260
412
  } else if (dimension === 'scenario') {
@@ -291,10 +443,7 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
291
443
  const period = url.searchParams.get('period') || 'daily';
292
444
  const date = url.searchParams.get('date') || new Date().toISOString().slice(0, 10);
293
445
  try {
294
- const { records: allRecords } = await parseAllEnabledTools(config, {
295
- excludeProjects: config.excludeProjects,
296
- includeProjects: computeIncludeProjects(config),
297
- });
446
+ const { records: allRecords } = await getOrParse(config, computeIncludeProjects(config));
298
447
  const { filtered } = filterRecordsByPeriod(allRecords, period, date);
299
448
  const blocks = identifyBillingBlocks(filtered, 5, config.costMode);
300
449
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
@@ -344,7 +493,7 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
344
493
  try {
345
494
  const newConfig = JSON.parse(body);
346
495
  // 路径字段验证:必须是字符串且路径存在或为空
347
- const validatePath = (v) => typeof v === 'string';
496
+ const validatePath = (v) => typeof v === 'string' && !v.includes('..') && !/[`$|;&<>!\n\r]/.test(v) && v.length < 500;
348
497
  if (newConfig.claudeDir !== undefined) { if (!validatePath(newConfig.claudeDir)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'claudeDir 格式无效' })); return; } config.claudeDir = newConfig.claudeDir; }
349
498
  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; }
350
499
  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; }
@@ -354,6 +503,8 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
354
503
  if (newConfig.scenarioKeywords !== undefined) config.scenarioKeywords = newConfig.scenarioKeywords;
355
504
  invalidateFileCache();
356
505
  invalidateGitCache();
506
+ _parsedCache = null; // 配置变更后清除解析缓存
507
+ invalidateReportCache(); // 配置变更后清除查询结果缓存
357
508
  saveConfig(config, configPath);
358
509
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': 'http://localhost:' + PORT });
359
510
  res.end(JSON.stringify({ success: true }));
@@ -379,6 +530,8 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
379
530
 
380
531
  // Static files
381
532
  let filePath = url.pathname === '/' ? '/index.html' : decodeURIComponent(url.pathname);
533
+ // 防止路径遍历:normalize 后检查
534
+ filePath = filePath.replace(/\.\./g, '').replace(/\\/g, '/');
382
535
  const resolved = resolve(__dirname, 'public', filePath.replace(/^\//, ''));
383
536
  const publicDir = resolve(__dirname, 'public');
384
537
 
@@ -400,6 +553,14 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
400
553
  res.end(content);
401
554
  });
402
555
 
556
+ // 防止未处理异常导致进程崩溃
557
+ process.on('uncaughtException', (err) => {
558
+ console.error('Uncaught exception:', err.message);
559
+ });
560
+ process.on('unhandledRejection', (reason) => {
561
+ console.error('Unhandled rejection:', reason);
562
+ });
563
+
403
564
  server.listen(PORT, '127.0.0.1', () => {
404
565
  console.log(`\n LumenCode server running at http://localhost:${PORT}\n`);
405
566
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lumencode",
3
- "version": "0.4.4",
3
+ "version": "1.1.0",
4
4
  "description": "LumenCode — AI 编码助手使用报告工具,从 JSONL 日志和 Git 仓库提取 AI 贡献度、效率与使用指标,支持 Web 可视化和命令行两种模式",
5
5
  "type": "module",
6
6
  "bin": {
package/public/api.js CHANGED
@@ -27,6 +27,7 @@ export function createLatestRequestGuard() {
27
27
  const cache = new Map();
28
28
  const pending = new Map();
29
29
  const DEFAULT_TTL = 30_000;
30
+ const CACHE_MAX_SIZE = 50;
30
31
 
31
32
  export function clearApiCache() {
32
33
  cache.clear();
@@ -49,6 +50,11 @@ export async function cachedFetch(url, options = {}, ttl = DEFAULT_TTL) {
49
50
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
50
51
  const data = await res.json();
51
52
  cache.set(key, { data, expire: Date.now() + ttl });
53
+ // LRU eviction
54
+ while (cache.size > CACHE_MAX_SIZE) {
55
+ const oldest = cache.keys().next().value;
56
+ cache.delete(oldest);
57
+ }
52
58
  return data;
53
59
  } finally {
54
60
  pending.delete(key);