lumencode 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +8 -8
  2. package/lib/server.js +104 -93
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  </p>
12
12
 
13
13
  <p align="center">
14
- 支持 <b>Claude Code · Codex · OpenCode</b> 三大 AI 编码工具 · 600+ 模型定价 · AI 贡献度归因 · 按项目独立汇报 · 自定义时间范围 · 一键飞书/钉钉周报
14
+ <b>AI 编码助手使用分析</b> —— 精确到每一行代码的 AI 归因 · 三工具统一 · 智能周报生成
15
15
  </p>
16
16
 
17
17
  <p align="center">
@@ -30,12 +30,12 @@
30
30
 
31
31
  | 场景 | 用 lumencode 解决 |
32
32
  |------|----------------------|
33
- | **写周报** | 选周报 点「工作汇报 复制」→ 粘贴飞书/钉钉。**3 秒搞定。** |
34
- | **证明 AI ROI** | 「67% 提交有 AI 参与,AI 辅助新增 4,200 行,费用 $12.5」**有数据,有底气。** |
35
- | **按项目汇报** | 配置多项目后,选择单个项目生成独立工作汇报,方便向不同项目负责人对齐 |
36
- | **对齐 Sprint 周期** | 除日/周/月外,支持自定义起止日期,不再被固定周期限制 |
37
- | **理解使用习惯** | 哪个项目用得最多?哪个模型最费 Token?什么时段是编码高峰?**一目了然。** |
38
- | **追踪 AI 成本** | 内置 **600+ 模型定价**(含 GLM、Kimi、Qwen、DeepSeek 等),自动算出等效 API 花销 |
33
+ | **精确量化 AI 贡献** | 不是模糊的"大概写了不少",而是精确到「4,200 行代码中 3,180 行由 AI 辅助完成」。**每一行都有数可查。** |
34
+ | **证明 AI ROI** | 周报自动生成:「本周 AI 辅助 12 个提交,节省约 8 小时编码时间,Token 花费 $18.5」。**老板一看就懂。** |
35
+ | **写周报/月报** | 选周期 → 点「工作汇报 → 复制」→ 粘贴飞书/钉钉。**3 秒搞定。** |
36
+ | **按项目汇报** | 多项目并行时,选择单个项目生成独立汇报,方便向不同项目负责人对齐 |
37
+ | **对齐 Sprint 周期** | 支持自定义起止日期,不再被日/周/月固定周期限制 |
38
+ | **追踪 AI 成本** | 600+ 模型内置定价(含 GLM、Kimi、Qwen、DeepSeek),自动算出等效 API 花销 |
39
39
 
40
40
  ---
41
41
 
@@ -63,8 +63,8 @@ npx lumencode serve
63
63
 
64
64
  | 亮点 | 说明 |
65
65
  |------|------|
66
+ | 🎯 **行级 AI 归因** | 通过 hook 步骤追踪,精确识别每一行代码的 AI 参与度。不是"这个提交 AI 帮忙了",而是"这行代码是 AI 写的" |
66
67
  | 🌐 **三工具统一** | Claude Code / Codex / OpenCode 数据全自动汇总,左侧标签一键切换 |
67
- | 🤖 **AI 贡献度量化** | 识别 `Co-Authored-By: Claude` 等签名,多层归因引擎量化 AI 在你代码中的实际占比 |
68
68
  | 📝 **自然语言工作汇报** | 详报/简报一键生成,支持标准 Markdown / 飞书 / 钉钉三种格式,每个板块附诊断解读 |
69
69
  | 📂 **按项目独立汇报** | 右侧面板选择项目,生成该项目的独立工作汇报(commits + AI 交互量 + 热点文件) |
70
70
  | 📅 **自定义时间范围** | 除日/周/月外,支持选择任意起止日期,方便对齐 Sprint 周期 |
package/lib/server.js CHANGED
@@ -7,7 +7,7 @@ import { generateWorkReport, generateFeishuCard } from './report.js';
7
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
- import { invalidateGitCache, getGitStatsForMultipleReposAsync, finalizeGitStats } from './git.js';
10
+ import { invalidateGitCache, getGitStatsForMultipleReposAsync, finalizeGitStats, computeAIContribution, computeCommitTypes, computeFileHotspots } from './git.js';
11
11
  import { identifyBillingBlocks } from './blocks.js';
12
12
  import { detectAvailableTools, parseAllEnabledTools } from './parsers/index.js';
13
13
  import { isAssistantRecord, getInputTokens, getOutputTokens } from './record-utils.js';
@@ -89,9 +89,9 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
89
89
  const REPORT_CACHE_TTL = 30_000; // 30s
90
90
  const REPORT_CACHE_MAX_SIZE = 50;
91
91
 
92
- function getReportCacheKey(period, date, tool, customStart, customEnd, format) {
93
- return `${period}|${date}|${tool || 'all'}|${customStart || ''}|${customEnd || ''}|${format || 'json'}`;
94
- }
92
+ function getReportCacheKey(period, date, tool, customStart, customEnd) {
93
+ return `${period}|${date}|${tool || 'all'}|${customStart || ''}|${customEnd || ''}`;
94
+ }
95
95
 
96
96
  function getCachedReport(cacheKey) {
97
97
  const cached = _reportCache.get(cacheKey);
@@ -166,20 +166,80 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
166
166
  return null;
167
167
  }
168
168
 
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,
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,
174
174
  includeProjects,
175
175
  });
176
176
  _parsedCache = result;
177
177
  _parsedCacheKey = `${config.claudeDir}|${includeProjects?.join(',') || ''}`;
178
- _parsedCacheExpire = Date.now() + PARSED_CACHE_TTL;
179
- return result;
180
- }
181
-
182
- const server = createServer(async (req, res) => {
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) => {
183
243
  const url = new URL(req.url, `http://localhost:${PORT}`);
184
244
 
185
245
  // 安全响应头
@@ -312,21 +372,26 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
312
372
 
313
373
  try {
314
374
  // 查询结果缓存:相同条件直接返回缓存
315
- const reportCacheKey = getReportCacheKey(period, date, tool, customStart, customEnd, format);
316
- let data = getCachedReport(reportCacheKey);
317
- if (data) {
318
- res.writeHead(200, {
319
- 'Content-Type': 'application/json',
320
- 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
321
- 'X-Cache': 'HIT',
322
- });
323
- res.end(JSON.stringify(data));
324
- return;
325
- }
326
-
327
- const parsed = await getOrParse(config, includeProjects);
328
- data = await buildReportData(period, date, config, includeProjects, tool, parsed, { customStart, customEnd });
329
- if (!data) {
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) {
330
395
  res.writeHead(200, { 'Content-Type': 'application/json' });
331
396
  res.end(JSON.stringify({
332
397
  error: '未找到数据',
@@ -352,10 +417,11 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
352
417
  let projUsageStats = data.usageStats;
353
418
  let projGitStats = data.gitStats;
354
419
  let projPrevStats = data.prevStats;
355
- let projectName = '';
356
-
357
- if (project) {
358
- const { records: allRecords } = parsed;
420
+ let projectName = '';
421
+
422
+ if (project) {
423
+ if (!parsed) parsed = await getOrParse(config, includeProjects);
424
+ const { records: allRecords } = parsed;
359
425
  const toolRecords = tool !== 'all' ? allRecords.filter(r => r.tool === tool) : allRecords;
360
426
  // basename 匹配
361
427
  const projRecords = toolRecords.filter(r => {
@@ -375,25 +441,8 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
375
441
  projPrevStats = prevProjFiltered.length > 0 ? computeUsageStats(prevProjFiltered, config.scenarioKeywords, config.costMode) : null;
376
442
 
377
443
  // 单项目 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
- }
396
- }
444
+ projGitStats = deriveProjectGitStats(data.gitStats, project, pStart, pEnd, config.aiAttribution);
445
+ }
397
446
 
398
447
  const markdown = generateWorkReport(projUsageStats, projGitStats, period, data.start, data.end, projPrevStats, { level, platform, tool, projectName });
399
448
  res.writeHead(200, {
@@ -404,52 +453,14 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
404
453
  return;
405
454
  }
406
455
 
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
- }
456
+ const responseData = prepareJsonReportData(data, tool);
414
457
 
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);
446
-
447
- res.writeHead(200, {
458
+ res.writeHead(200, {
448
459
  'Content-Type': 'application/json',
449
460
  'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
450
461
  'X-Cache': 'MISS',
451
462
  });
452
- res.end(JSON.stringify(data));
463
+ res.end(JSON.stringify(responseData));
453
464
  } catch (err) {
454
465
  res.writeHead(500, { 'Content-Type': 'application/json' });
455
466
  console.error('API error:', err.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lumencode",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "LumenCode — AI 编码助手使用报告工具,从 JSONL 日志和 Git 仓库提取 AI 贡献度、效率与使用指标,支持 Web 可视化和命令行两种模式",
5
5
  "type": "module",
6
6
  "bin": {