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/README.md CHANGED
@@ -11,22 +11,29 @@
11
11
  </p>
12
12
 
13
13
  <p align="center">
14
- 支持 <b>Claude Code · Codex · OpenCode</b> 三大 AI 编码工具 · 600+ 模型定价 · AI 贡献度归因 · 一键飞书/钉钉周报
14
+ 支持 <b>Claude Code · Codex · OpenCode</b> 三大 AI 编码工具 · 600+ 模型定价 · AI 贡献度归因 · 按项目独立汇报 · 自定义时间范围 · 一键飞书/钉钉周报
15
15
  </p>
16
16
 
17
17
  <p align="center">
18
18
  <a href="README_EN.md">English</a> · <a href="#命令用法">命令</a> · <a href="#常见问题">FAQ</a> · <a href="#更新日志">更新日志</a>
19
19
  </p>
20
+ <div align="center">
21
+ <img src="doc/数据分析页面.png" alt="LumenCode Dashboard" width="800">
22
+ </div>
23
+
20
24
  ---
21
25
 
26
+
22
27
  ## 它解决什么问题?
23
28
 
24
- > 「这周 AI 帮你写了多少代码?」「订阅这些工具值不值?」—— 与其每周手算,不如一条命令搞定。
29
+ > AI 帮你写了多少代码?」「订阅这些工具值不值?」—— 与其手算,不如一条命令搞定。
25
30
 
26
31
  | 场景 | 用 lumencode 解决 |
27
32
  |------|----------------------|
28
33
  | **写周报** | 选周报 → 点「工作汇报 → 复制」→ 粘贴飞书/钉钉。**3 秒搞定。** |
29
- | **证明 AI ROI** | 「本周 67% 提交有 AI 参与,AI 辅助新增 4,200 行,费用 $12.5」**有数据,有底气。** |
34
+ | **证明 AI ROI** | 67% 提交有 AI 参与,AI 辅助新增 4,200 行,费用 $12.5」**有数据,有底气。** |
35
+ | **按项目汇报** | 配置多项目后,选择单个项目生成独立工作汇报,方便向不同项目负责人对齐 |
36
+ | **对齐 Sprint 周期** | 除日/周/月外,支持自定义起止日期,不再被固定周期限制 |
30
37
  | **理解使用习惯** | 哪个项目用得最多?哪个模型最费 Token?什么时段是编码高峰?**一目了然。** |
31
38
  | **追踪 AI 成本** | 内置 **600+ 模型定价**(含 GLM、Kimi、Qwen、DeepSeek 等),自动算出等效 API 花销 |
32
39
 
@@ -49,64 +56,62 @@ npx lumencode serve
49
56
 
50
57
  ## 产品亮点
51
58
 
59
+ <div align="center">
60
+ <img src="doc/核心能力.png" alt="LumenCode 核心能力" width="720">
61
+ </div>
62
+
63
+
52
64
  | 亮点 | 说明 |
53
65
  |------|------|
54
66
  | 🌐 **三工具统一** | Claude Code / Codex / OpenCode 数据全自动汇总,左侧标签一键切换 |
55
67
  | 🤖 **AI 贡献度量化** | 识别 `Co-Authored-By: Claude` 等签名,多层归因引擎量化 AI 在你代码中的实际占比 |
56
68
  | 📝 **自然语言工作汇报** | 详报/简报一键生成,支持标准 Markdown / 飞书 / 钉钉三种格式,每个板块附诊断解读 |
69
+ | 📂 **按项目独立汇报** | 右侧面板选择项目,生成该项目的独立工作汇报(commits + AI 交互量 + 热点文件) |
70
+ | 📅 **自定义时间范围** | 除日/周/月外,支持选择任意起止日期,方便对齐 Sprint 周期 |
57
71
  | 💰 **精确费用估算** | 600+ 模型本地定价(含 GLM/Kimi/Qwen/DeepSeek)+ Portkey API 兜底,未知模型不计费而非乱算 |
58
72
  | 📦 **零配置开箱即用** | 首次运行自动检测工具目录、推导项目路径 |
59
73
  | 🔍 **数据钻取** | 点击任意图表下钻明细,从汇总数据到具体会话/提交一气呵成 |
60
74
  | 📈 **趋势与洞察** | 周报/月报附峰值日识别、连续活跃分析、工具使用五类分布(编辑/阅读/执行/规划/研究) |
61
- | 🌙 **暗色模式** | 亮/暗主题一键切换,全图表自适配 |
75
+ | 🌙 **亮/暗主题** | 亮色/暗色主题一键切换,全图表自适配 |
62
76
 
63
77
  ---
64
78
 
65
79
  ## 产品截图
66
80
 
67
- ### 多工具汇总视图
81
+ ### 数据分析总览
68
82
 
69
- > 全工具数据汇总,AI 贡献占比、Token 消耗、费用、活跃时段、模型分布、场景拆分一屏掌握。
70
-
71
- ![全量汇总](doc/全量Ai工具汇总报告.png)
72
-
73
- ### 单工具报告
74
-
75
- > 切换左侧标签即可单独查看任一工具的数据。
83
+ > 左侧数据源面板一键切换工具,主区域汇总 Token 消耗、费用、模型分布、AI 贡献度归因。
76
84
 
77
85
  <table>
78
86
  <tr>
79
- <td><img src="doc/claude_code工具使用报告.png" alt="Claude Code" width="400"></td>
80
- <td><img src="doc/codex使用报告.png" alt="Codex" width="400"></td>
81
- </tr>
82
- <tr>
83
- <td align="center"><b>Claude Code</b></td>
84
- <td align="center"><b>OpenAI Codex</b></td>
85
- </tr>
86
- <tr>
87
- <td><img src="doc/opencode使用报告.png" alt="OpenCode" width="400"></td>
88
- <td></td>
87
+ <td><img src="doc/数据分析页面.png" alt="汇总面板与趋势图" width="400"></td>
88
+ <td><img src="doc/数据分析页面2.png" alt="项目分布与时段分布" width="400"></td>
89
89
  </tr>
90
90
  <tr>
91
- <td align="center"><b>OpenCode</b></td>
92
- <td></td>
91
+ <td align="center">汇总指标 + Token 趋势</td>
92
+ <td align="center">项目分布 + 时段分布 + 会话列表</td>
93
93
  </tr>
94
94
  </table>
95
95
 
96
- ### 场景分析 & 模型分布
96
+ ![AI 贡献度与提交分析](doc/数据分析页面3.png)
97
97
 
98
- > 按工作类型分类(编码 / 测试 / 调试 / 文档 / 审查 / 规划),还能下钻看每个模型的具体 Token 用量。
98
+ ### 多工具维度
99
99
 
100
- <table>
101
- <tr>
102
- <td><img src="doc/工作类型分布_匹配示例.png" alt="场景分析" width="400"></td>
103
- <td><img src="doc/模型使用分布_具体用量.png" alt="模型分布" width="400"></td>
104
- </tr>
105
- <tr>
106
- <td align="center">工作类型分布(含匹配关键词示例)</td>
107
- <td align="center">模型使用分布(含具体 Token 用量)</td>
108
- </tr>
109
- </table>
100
+ > 切换到「全部工具」视图,查看跨工具的汇总数据与对比分析。
101
+
102
+ ![多工具维度](doc/多工具维度.png)
103
+
104
+ ### 项目分布 & 会话记录
105
+
106
+ > 按项目统计 Token、费用、会话数,点击下钻查看单条会话明细。
107
+
108
+ ![项目分布与会话记录](doc/项目分布-会话记录.png)
109
+
110
+ ### 场景分析
111
+
112
+ > 按工作类型分类(编码 / 测试 / 调试 / 文档 / 审查 / 规划),附匹配关键词示例。
113
+
114
+ ![场景分析](doc/工作类型分布_匹配示例.png)
110
115
 
111
116
  ### 工作汇报 · 一键生成可直接发布的周报
112
117
 
@@ -115,6 +120,7 @@ npx lumencode serve
115
120
  - **详报** —— 完整数据 + 洞察解读 + 板块编号,适合周报、月报
116
121
  - **简报** —— 3-5 句话核心摘要,适合日报或群消息
117
122
  - **多平台格式** —— 标准 Markdown / 飞书 / 钉钉,一键切换
123
+ - **按项目生成** —— 右侧面板选择项目,生成该项目的独立汇报
118
124
 
119
125
  <table>
120
126
  <tr>
@@ -127,11 +133,13 @@ npx lumencode serve
127
133
  </tr>
128
134
  </table>
129
135
 
130
- ### 暗色模式
136
+ ### 亮色 / 暗色主题
131
137
 
132
138
  > 全图表配色自适配,长时间阅读不伤眼。
133
139
 
134
- ![暗色模式](doc/暗色模式.png)
140
+ ![亮色模式](doc/浅色模式.png)
141
+
142
+ > 暗色模式为默认主题,上方截图均为暗色模式下的效果。
135
143
 
136
144
  ---
137
145
 
@@ -224,6 +232,14 @@ v0.4.0 起支持 Claude Code、Codex、OpenCode 三种工具,**首次运行自
224
232
 
225
233
  ## 更新日志
226
234
 
235
+ ### v1.0.0 (2026-05-24) — 项目级汇报 & 自定义时间
236
+
237
+ - **按项目独立汇报** — 工作汇报右侧面板新增项目选择器,选定后自动筛选该项目数据,生成独立工作汇报(commits + AI 交互量 + 热点文件)
238
+ - **自定义时间范围** — 新增「自定义」周期选项,支持选择任意起止日期,方便对齐 Sprint 周期
239
+ - **智能日期导航** — 左右箭头根据当前周期自动调整步长:日报 ±1天、周报 ±7天、月报 ±1个月
240
+ - **侧边栏重构** — 版本信息、主题切换、收起按钮统一移至底部,顶部仅保留标题与链接,布局更紧凑
241
+ - **界面重构** — 全新视觉设计,布局更紧凑美观,数据呈现更直观
242
+
227
243
  ### v0.4.0 (2026-05-22) — 多工具统一平台
228
244
 
229
245
  从 Claude Code 单工具报告升级为 AI 编码全栈分析平台。
package/index.js CHANGED
@@ -72,12 +72,17 @@ function loadCliConfig() {
72
72
  return { config, dateArg, effectiveIncludeProjects, configPath };
73
73
  }
74
74
 
75
- async function buildReportData(period, dateArg, config, effectiveIncludeProjects, tool = 'all') {
76
- // 使用新的多工具解析入口
77
- const { records, toolBreakdown } = await parseAllEnabledTools(config, {
78
- excludeProjects: config.excludeProjects,
79
- includeProjects: effectiveIncludeProjects,
80
- });
75
+ async function buildReportData(period, dateArg, config, effectiveIncludeProjects, tool = 'all', preParsed = null, options = {}) {
76
+ // 使用预解析结果或全量解析
77
+ let records, toolBreakdown;
78
+ if (preParsed) {
79
+ ({ records, toolBreakdown } = preParsed);
80
+ } else {
81
+ ({ records, toolBreakdown } = await parseAllEnabledTools(config, {
82
+ excludeProjects: config.excludeProjects,
83
+ includeProjects: effectiveIncludeProjects,
84
+ }));
85
+ }
81
86
 
82
87
  if (records.length === 0) {
83
88
  return null;
@@ -92,54 +97,60 @@ async function buildReportData(period, dateArg, config, effectiveIncludeProjects
92
97
  return null;
93
98
  }
94
99
 
95
- // 其余逻辑保持不变
96
- const { filtered, start, end } = filterRecordsByPeriod(toolRecords, period, dateArg);
97
- const usageStats = computeUsageStats(filtered, config.scenarioKeywords, config.costMode);
98
- const sessions = groupBySessions(filtered);
100
+ const { filtered, start, end } = filterRecordsByPeriod(toolRecords, period, dateArg, { customStart: options.customStart, customEnd: options.customEnd });
101
+ const reposConfigured = !!(config.repos && config.repos.length > 0);
99
102
 
100
- let gitStats = null;
101
- if (config.repos && config.repos.length > 0) {
102
- // 按工具过滤后,只统计该工具覆盖的项目对应的 repos
103
+ // ── 第一层并发:三个独立的同步计算 ──
104
+ const [usageStats, sessions, billingBlocks] = [
105
+ computeUsageStats(filtered, config.scenarioKeywords, config.costMode),
106
+ groupBySessions(filtered),
107
+ identifyBillingBlocks(filtered, config.blockQuota ? 5 : 5, config.costMode),
108
+ ];
109
+
110
+ // ── 第二层并发:gitStats(async) + trendData + prevStats ──
111
+ const gitStatsPromise = (async () => {
112
+ if (!reposConfigured) return null;
103
113
  const coveredBases = new Set(filtered.map(r => {
104
114
  const p = r.project || '';
105
115
  return p.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop();
106
116
  }).filter(Boolean));
107
- const toolRepos = config.repos.filter(r => coveredBases.has(r.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop()));
108
- if (toolRepos.length > 0) {
109
- // 扩展 git 查询窗口 +2 天,以匹配 session 跨天的延迟提交
110
- const extendedEnd = new Date(end);
111
- extendedEnd.setDate(extendedEnd.getDate() + 2);
112
- const extendedEndStr = extendedEnd.toISOString().slice(0, 10) + 'T23:59:59';
113
- gitStats = await getGitStatsForMultipleReposAsync(toolRepos, start, extendedEndStr);
114
- gitStats = finalizeGitStats(gitStats, sessions);
115
- // 归因已完成,过滤 commitList 到原始窗口,重算基础统计
116
- if (gitStats.commitList) {
117
- const windowStart = start;
118
- const windowEnd = end + 'T23:59:59';
119
- const inWindow = gitStats.commitList.filter(c => (c.date || '') >= windowStart && (c.date || '') <= windowEnd);
120
- gitStats.commits = inWindow.length;
121
- gitStats.linesAdded = inWindow.reduce((s, c) => s + (c.linesAdded || 0), 0);
122
- gitStats.linesDeleted = inWindow.reduce((s, c) => s + (c.linesDeleted || 0), 0);
123
- gitStats.filesChanged = new Set(inWindow.flatMap(c => (c.files || []).map(f => f.path))).size;
124
- // commitList 保留全部(含跨天),前端 drill-down 需要
125
- // 但提交类型和热点只基于窗口内
126
- gitStats.commitTypes = computeCommitTypes(inWindow);
127
- gitStats.fileHotspots = computeFileHotspots(inWindow, 10);
128
- }
117
+ let toolRepos = config.repos.filter(r => coveredBases.has(r.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop()));
118
+ if (toolRepos.length === 0) toolRepos = config.repos;
119
+ if (toolRepos.length === 0) return null;
120
+ const extendedEnd = new Date(end);
121
+ extendedEnd.setDate(extendedEnd.getDate() + 2);
122
+ const extendedEndStr = extendedEnd.toISOString().slice(0, 10) + 'T23:59:59';
123
+ let gs = await getGitStatsForMultipleReposAsync(toolRepos, start, extendedEndStr);
124
+ gs = finalizeGitStats(gs, sessions);
125
+ if (gs.commitList) {
126
+ const windowStart = start;
127
+ const windowEnd = end + 'T23:59:59';
128
+ const inWindow = gs.commitList.filter(c => (c.date || '') >= windowStart && (c.date || '') <= windowEnd);
129
+ gs.commits = inWindow.length;
130
+ gs.linesAdded = inWindow.reduce((s, c) => s + (c.linesAdded || 0), 0);
131
+ gs.linesDeleted = inWindow.reduce((s, c) => s + (c.linesDeleted || 0), 0);
132
+ gs.filesChanged = new Set(inWindow.flatMap(c => (c.files || []).map(f => f.path))).size;
133
+ gs.commitTypes = computeCommitTypes(inWindow);
134
+ gs.fileHotspots = computeFileHotspots(inWindow, 10);
129
135
  }
130
- }
136
+ return gs;
137
+ })();
131
138
 
132
- const trendData = computeTrendData(toolRecords, period, dateArg);
139
+ const trendDataPromise = Promise.resolve(computeTrendData(toolRecords, period, dateArg));
133
140
 
134
- // Previous period stats
135
- const prevRange = computePrevPeriodRange(period, dateArg);
136
- const prevFiltered = toolRecords.filter(r => {
137
- if (!r.timestamp) return false;
138
- const date = r.timestamp.slice(0, 10);
139
- return date >= prevRange.start && date <= prevRange.end;
140
- });
141
- const prevStats = prevFiltered.length > 0 ? computeUsageStats(prevFiltered, config.scenarioKeywords, config.costMode) : null;
141
+ const prevStatsPromise = (async () => {
142
+ const prevRange = computePrevPeriodRange(period, dateArg, { customStart: options.customStart, customEnd: options.customEnd });
143
+ const prevFiltered = toolRecords.filter(r => {
144
+ if (!r.timestamp) return false;
145
+ const date = r.timestamp.slice(0, 10);
146
+ return date >= prevRange.start && date <= prevRange.end;
147
+ });
148
+ return prevFiltered.length > 0 ? computeUsageStats(prevFiltered, config.scenarioKeywords, config.costMode) : null;
149
+ })();
150
+
151
+ const [gitStats, trendData, prevStats] = await Promise.all([gitStatsPromise, trendDataPromise, prevStatsPromise]);
142
152
 
153
+ // ── 第三层:依赖 usageStats 的同步派生 ──
143
154
  const slimSessions = sessions.map(s => ({
144
155
  id: s.id,
145
156
  project: s.project,
@@ -149,10 +160,79 @@ async function buildReportData(period, dateArg, config, effectiveIncludeProjects
149
160
  commits: s.commits || [],
150
161
  }));
151
162
 
152
- const billingBlocks = identifyBillingBlocks(filtered, config.blockQuota ? 5 : 5, config.costMode);
163
+ const statsTB = usageStats.toolBreakdown || {};
164
+ const mergedBreakdown = {};
165
+ for (const [name, base] of Object.entries(toolBreakdown)) {
166
+ const s = statsTB[name] || {};
167
+ mergedBreakdown[name] = {
168
+ inputTokens: s.inputTokens || 0,
169
+ outputTokens: s.outputTokens || 0,
170
+ cacheRead: s.cacheRead || 0,
171
+ cacheCreate: s.cacheCreate || 0,
172
+ count: s.count || 0,
173
+ sessionCount: base.sessionCount || 0,
174
+ };
175
+ }
176
+ for (const [name, data] of Object.entries(statsTB)) {
177
+ if (!mergedBreakdown[name]) {
178
+ mergedBreakdown[name] = {
179
+ inputTokens: data.inputTokens || 0,
180
+ outputTokens: data.outputTokens || 0,
181
+ cacheRead: data.cacheRead || 0,
182
+ cacheCreate: data.cacheCreate || 0,
183
+ count: data.count || 0,
184
+ sessionCount: 0,
185
+ };
186
+ }
187
+ }
153
188
 
154
- const reposConfigured = !!(config.repos && config.repos.length > 0);
155
- return { usageStats, gitStats, reposConfigured, sessions: slimSessions, start, end, trendData, prevStats, billingBlocks, toolBreakdown };
189
+ // ── 第四层:projectDetails(从 commitList repo 分组派生,无需再次 git 调用)──
190
+ const projectDetails = {};
191
+ const projEntries = Object.entries(usageStats.projects || {}).sort((a, b) => b[1].requests - a[1].requests);
192
+ if (reposConfigured && gitStats?.commitList?.length) {
193
+ const windowEnd = end + 'T23:59:59';
194
+ const inWindow = gitStats.commitList.filter(c => (c.date || '') >= start && (c.date || '') <= windowEnd);
195
+ const repoGroups = new Map();
196
+ for (const c of inWindow) {
197
+ const base = (c.repo || '').replace(/\\/g, '/').replace(/\/$/, '').split('/').pop();
198
+ if (!base) continue;
199
+ if (!repoGroups.has(base)) repoGroups.set(base, []);
200
+ repoGroups.get(base).push(c);
201
+ }
202
+ for (const [projName, projStats] of projEntries) {
203
+ const repoCommits = repoGroups.get(projName) || [];
204
+ if (repoCommits.length === 0) {
205
+ projectDetails[projName] = { usage: projStats, git: null, topCommits: [] };
206
+ continue;
207
+ }
208
+ const uniqueFiles = new Set();
209
+ let linesAdded = 0, linesDeleted = 0;
210
+ for (const c of repoCommits) {
211
+ linesAdded += c.linesAdded || 0;
212
+ linesDeleted += c.linesDeleted || 0;
213
+ for (const f of c.files || []) uniqueFiles.add(f.path);
214
+ }
215
+ const topCommits = repoCommits
216
+ .filter(c => c.type === 'feat' || c.type === 'fix')
217
+ .slice(0, 5)
218
+ .map(c => ({ type: c.type, subject: c.subject, scope: c.scope }));
219
+ projectDetails[projName] = {
220
+ usage: projStats,
221
+ git: {
222
+ commits: repoCommits.length, linesAdded, linesDeleted,
223
+ filesChanged: uniqueFiles.size,
224
+ fileHotspots: computeFileHotspots(repoCommits, 5),
225
+ },
226
+ topCommits,
227
+ };
228
+ }
229
+ } else {
230
+ for (const [projName, projStats] of projEntries) {
231
+ projectDetails[projName] = { usage: projStats, git: null, topCommits: [] };
232
+ }
233
+ }
234
+
235
+ return { usageStats, gitStats, reposConfigured, sessions: slimSessions, start, end, trendData, prevStats, billingBlocks, toolBreakdown: mergedBreakdown, projectDetails };
156
236
  }
157
237
 
158
238
  if (!command || command === 'help' || command === '--help') {
package/lib/aggregate.js CHANGED
@@ -250,11 +250,12 @@ export function computeUsageStats(records, scenarioKeywords, costMode = 'auto')
250
250
 
251
251
  // Model
252
252
  if (model) {
253
- if (!stats.models[model]) stats.models[model] = { count: 0, outputTokens: 0, inputTokens: 0, cacheRead: 0, cost: 0, costMode: 'unknown' };
253
+ if (!stats.models[model]) stats.models[model] = { count: 0, outputTokens: 0, inputTokens: 0, cacheRead: 0, cacheCreate: 0, cost: 0, costMode: 'unknown' };
254
254
  stats.models[model].count++;
255
255
  stats.models[model].outputTokens += outputTokens;
256
256
  stats.models[model].inputTokens += inputTokens;
257
257
  stats.models[model].cacheRead += cacheRead;
258
+ stats.models[model].cacheCreate += cacheCreate;
258
259
  }
259
260
  }
260
261
 
@@ -275,10 +276,23 @@ export function computeUsageStats(records, scenarioKeywords, costMode = 'auto')
275
276
  const projectName = getProjectBaseName(r.project);
276
277
  if (projectName) {
277
278
  if (!stats.projects[projectName]) {
278
- stats.projects[projectName] = { sessions: new Set(), requests: 0 };
279
+ stats.projects[projectName] = { sessions: new Set(), requests: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreate: 0, estimatedCost: 0, models: {} };
279
280
  }
280
281
  if (isAssistant) {
281
282
  stats.projects[projectName].requests++;
283
+ stats.projects[projectName].inputTokens += inputTokens;
284
+ stats.projects[projectName].outputTokens += outputTokens;
285
+ stats.projects[projectName].cacheRead += cacheRead;
286
+ stats.projects[projectName].cacheCreate += cacheCreate;
287
+ const pricing = model ? resolveModelPricing(model) : { unknown: true };
288
+ const recCost = (r.costUSD != null && r.costUSD > 0) ? r.costUSD : calculateRecordCost(r, pricing);
289
+ stats.projects[projectName].estimatedCost += recCost;
290
+ if (model) {
291
+ if (!stats.projects[projectName].models[model]) stats.projects[projectName].models[model] = { count: 0, inputTokens: 0, outputTokens: 0 };
292
+ stats.projects[projectName].models[model].count++;
293
+ stats.projects[projectName].models[model].inputTokens += inputTokens;
294
+ stats.projects[projectName].models[model].outputTokens += outputTokens;
295
+ }
282
296
  }
283
297
  if (r.sessionId) {
284
298
  stats.projects[projectName].sessions.add(r.sessionId);
@@ -373,16 +387,23 @@ export function computeUsageStats(records, scenarioKeywords, costMode = 'auto')
373
387
 
374
388
  // Convert project session Sets to sizes
375
389
  for (const proj of Object.keys(stats.projects)) {
390
+ const p = stats.projects[proj];
376
391
  stats.projects[proj] = {
377
- sessions: stats.projects[proj].sessions.size,
378
- requests: stats.projects[proj].requests,
392
+ sessions: p.sessions.size,
393
+ requests: p.requests,
394
+ inputTokens: p.inputTokens || 0,
395
+ outputTokens: p.outputTokens || 0,
396
+ cacheRead: p.cacheRead || 0,
397
+ cacheCreate: p.cacheCreate || 0,
398
+ estimatedCost: p.estimatedCost || 0,
399
+ models: p.models || {},
379
400
  };
380
401
  }
381
402
 
382
403
  return stats;
383
404
  }
384
405
 
385
- export function filterRecordsByPeriod(records, period, refDate) {
406
+ export function filterRecordsByPeriod(records, period, refDate, options = {}) {
386
407
  const d = new Date(refDate);
387
408
  let start, end;
388
409
 
@@ -406,6 +427,10 @@ export function filterRecordsByPeriod(records, period, refDate) {
406
427
  start = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`;
407
428
  end = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()).padStart(2, '0')}`;
408
429
  break;
430
+ case 'custom':
431
+ start = options.customStart || formatDate(d);
432
+ end = options.customEnd || start;
433
+ break;
409
434
  default:
410
435
  start = formatDate(d);
411
436
  end = start;
@@ -554,7 +579,7 @@ export function groupBySessions(records) {
554
579
  }).sort((a, b) => b.endTime.localeCompare(a.endTime));
555
580
  }
556
581
 
557
- export function computePrevPeriodRange(period, refDate) {
582
+ export function computePrevPeriodRange(period, refDate, options = {}) {
558
583
  const d = new Date(refDate);
559
584
  switch (period) {
560
585
  case 'daily':
@@ -574,6 +599,15 @@ export function computePrevPeriodRange(period, refDate) {
574
599
  const y = d.getFullYear(), m = d.getMonth();
575
600
  return { start: `${y}-${String(m + 1).padStart(2, '0')}-01`, end: `${y}-${String(m + 1).padStart(2, '0')}-${String(new Date(y, m + 1, 0).getDate()).padStart(2, '0')}` };
576
601
  }
602
+ case 'custom': {
603
+ const cs = options.customStart || formatDate(d);
604
+ const ce = options.customEnd || cs;
605
+ const spanMs = new Date(ce) - new Date(cs) + 86400000; // inclusive days
606
+ const gap = 86400000; // 1 day gap
607
+ const prevEnd = new Date(new Date(cs).getTime() - gap);
608
+ const prevStart = new Date(prevEnd.getTime() - spanMs + 86400000);
609
+ return { start: formatDate(prevStart), end: formatDate(prevEnd) };
610
+ }
577
611
  default:
578
612
  d.setDate(d.getDate() - 1);
579
613
  return { start: formatDate(d), end: formatDate(d) };
package/lib/cache.js CHANGED
@@ -2,6 +2,7 @@ import { statSync } from 'fs';
2
2
  import { parseJsonlFile } from './parser.js';
3
3
 
4
4
  const fileCache = new Map();
5
+ const CACHE_MAX_FILES = 200;
5
6
 
6
7
  export function getCachedFileRecords(filePath) {
7
8
  const { mtimeMs } = statSync(filePath);
@@ -10,6 +11,13 @@ export function getCachedFileRecords(filePath) {
10
11
 
11
12
  const records = parseJsonlFile(filePath);
12
13
  fileCache.set(filePath, { mtime: mtimeMs, records });
14
+
15
+ // LRU eviction
16
+ while (fileCache.size > CACHE_MAX_FILES) {
17
+ const oldest = fileCache.keys().next().value;
18
+ fileCache.delete(oldest);
19
+ }
20
+
13
21
  return records;
14
22
  }
15
23
 
package/lib/git.js CHANGED
@@ -157,6 +157,13 @@ const AI_CONFIDENCE = {
157
157
  HIGH: 'high',
158
158
  };
159
159
 
160
+ const CONFIDENCE_WEIGHTS = {
161
+ [AI_CONFIDENCE.HIGH]: 1.0,
162
+ [AI_CONFIDENCE.MEDIUM]: 0.7,
163
+ [AI_CONFIDENCE.LOW]: 0.2,
164
+ [AI_CONFIDENCE.NONE]: 0,
165
+ };
166
+
160
167
  function isCountedAIConfidence(confidence) {
161
168
  return confidence === AI_CONFIDENCE.HIGH || confidence === AI_CONFIDENCE.MEDIUM;
162
169
  }
@@ -285,6 +292,8 @@ export function detectAICommit(subject = '', author = '', body = '') {
285
292
 
286
293
  export function computeAIContribution(commits, toolFilter = null) {
287
294
  let aiCommits = 0, aiLinesAdded = 0, aiLinesDeleted = 0;
295
+ let possibleAICommits = 0, possibleAILinesAdded = 0, possibleAILinesDeleted = 0;
296
+ let weightedAILinesAdded = 0, weightedAILinesDeleted = 0;
288
297
  let aiCommitLinesAdded = 0, aiCommitLinesDeleted = 0;
289
298
  let aiFileLinesAdded = 0, aiFileLinesDeleted = 0;
290
299
  let highConfidenceCommits = 0, mediumConfidenceCommits = 0, lowConfidenceCommits = 0;
@@ -303,27 +312,39 @@ export function computeAIContribution(commits, toolFilter = null) {
303
312
  else if (confidence === AI_CONFIDENCE.MEDIUM) mediumConfidenceCommits++;
304
313
  else if (confidence === AI_CONFIDENCE.LOW) lowConfidenceCommits++;
305
314
 
315
+ // 计算文件级行数(用于 HIGH/MEDIUM/LOW 各自统计)
316
+ const matchedFiles = new Set((c.aiEvidenceDetails?.matchedFiles || []).map(normalizeCommitFilePath));
317
+ const useMatchedFiles = matchedFiles.size > 0;
318
+ let fileAdded = 0;
319
+ let fileDeleted = 0;
320
+ for (const f of c.files || []) {
321
+ const filePath = normalizeCommitFilePath(f.path);
322
+ if (useMatchedFiles && !matchedFiles.has(filePath)) continue;
323
+ fileAdded += f.added || 0;
324
+ fileDeleted += f.deleted || 0;
325
+ }
326
+ if (!useMatchedFiles && (c.attributionType === 'explicit' || c.attributionType?.startsWith('session_'))) {
327
+ fileAdded = c.linesAdded || 0;
328
+ fileDeleted = c.linesDeleted || 0;
329
+ }
330
+
306
331
  if (isCountedAIConfidence(confidence)) {
307
332
  aiCommits++;
308
333
  aiCommitLinesAdded += c.linesAdded || 0;
309
334
  aiCommitLinesDeleted += c.linesDeleted || 0;
310
-
311
- const matchedFiles = new Set((c.aiEvidenceDetails?.matchedFiles || []).map(normalizeCommitFilePath));
312
- const useMatchedFiles = matchedFiles.size > 0;
313
- let fileAdded = 0;
314
- let fileDeleted = 0;
315
- for (const f of c.files || []) {
316
- const filePath = normalizeCommitFilePath(f.path);
317
- if (useMatchedFiles && !matchedFiles.has(filePath)) continue;
318
- fileAdded += f.added || 0;
319
- fileDeleted += f.deleted || 0;
320
- }
321
- if (!useMatchedFiles && (c.attributionType === 'explicit' || c.attributionType?.startsWith('session_'))) {
322
- fileAdded = c.linesAdded || 0;
323
- fileDeleted = c.linesDeleted || 0;
324
- }
325
335
  aiFileLinesAdded += fileAdded;
326
336
  aiFileLinesDeleted += fileDeleted;
337
+ } else if (confidence === AI_CONFIDENCE.LOW) {
338
+ possibleAICommits++;
339
+ possibleAILinesAdded += fileAdded;
340
+ possibleAILinesDeleted += fileDeleted;
341
+ }
342
+
343
+ // 加权计算:所有归因的 commit 都参与(包括 LOW)
344
+ const weight = CONFIDENCE_WEIGHTS[confidence] || 0;
345
+ if (weight > 0) {
346
+ weightedAILinesAdded += fileAdded * weight;
347
+ weightedAILinesDeleted += fileDeleted * weight;
327
348
  }
328
349
  }
329
350
  aiLinesAdded = aiFileLinesAdded;
@@ -331,20 +352,32 @@ export function computeAIContribution(commits, toolFilter = null) {
331
352
  const total = allCommits.length;
332
353
  const totalLinesChanged = totalLinesAdded + totalLinesDeleted;
333
354
  const aiLinesChanged = aiLinesAdded + aiLinesDeleted;
355
+ const possibleAILinesChanged = possibleAILinesAdded + possibleAILinesDeleted;
356
+ const weightedAILinesChanged = Math.round(weightedAILinesAdded + weightedAILinesDeleted);
334
357
  return {
335
358
  aiCommits,
336
- nonToolCommits: total - aiCommits,
337
- humanCommits: total - aiCommits,
359
+ possibleAICommits,
360
+ nonToolCommits: total - aiCommits - possibleAICommits,
361
+ humanCommits: total - aiCommits - possibleAICommits,
338
362
  aiCommitRatio: total > 0 ? aiCommits / total : 0,
363
+ possibleAICommitRatio: total > 0 ? possibleAICommits / total : 0,
339
364
  aiRatio: totalLinesChanged > 0 ? aiLinesChanged / totalLinesChanged : 0,
365
+ aiLineRatio: totalLinesChanged > 0 ? aiLinesChanged / totalLinesChanged : 0,
366
+ possibleAILineRatio: totalLinesChanged > 0 ? possibleAILinesChanged / totalLinesChanged : 0,
367
+ weightedAILineRatio: totalLinesChanged > 0 ? weightedAILinesChanged / totalLinesChanged : 0,
340
368
  toolFilter: toolFilter || null,
341
369
  aiLinesAdded,
342
370
  aiLinesDeleted,
343
371
  aiLinesChanged,
372
+ possibleAILinesAdded,
373
+ possibleAILinesDeleted,
374
+ possibleAILinesChanged,
375
+ weightedAILinesAdded: Math.round(weightedAILinesAdded),
376
+ weightedAILinesDeleted: Math.round(weightedAILinesDeleted),
377
+ weightedAILinesChanged,
344
378
  totalLinesAdded,
345
379
  totalLinesDeleted,
346
380
  totalLinesChanged,
347
- aiLineRatio: totalLinesChanged > 0 ? aiLinesChanged / totalLinesChanged : 0,
348
381
  aiCommitLinesAdded,
349
382
  aiCommitLinesDeleted,
350
383
  aiFileLinesAdded,
@@ -501,12 +534,19 @@ export function parseGitLogOutput(output, repo = '') {
501
534
  return result;
502
535
  }
503
536
 
537
+ function sanitizeArg(s) {
538
+ // 移除 shell 特殊字符,防止命令注入
539
+ return String(s || '').replace(/[`$"\\|;&<>!\n\r]/g, '');
540
+ }
541
+
504
542
  function buildGitArgs(since, until, author) {
505
543
  const sinceFull = since.includes('T') ? since : since + 'T00:00:00';
506
- const authorArg = author ? ` --author="${author}"` : '';
544
+ const safeSince = sanitizeArg(sinceFull);
545
+ const safeUntil = sanitizeArg(until);
546
+ const authorArg = author ? ` --author="${sanitizeArg(author)}"` : '';
507
547
  // 格式:哨兵行(subject) → body 行(可多行) → ENDBODY 行 → numstat 行
508
548
  const pretty = `--pretty=format:"${COMMIT_SENTINEL}%H|%ad|%ae|%s%n%B${BODY_END}"`;
509
- return `--all --no-renames ${pretty} --date=iso-strict --numstat --since="${sinceFull}" --until="${until}"${authorArg}`;
549
+ return `--all --no-renames ${pretty} --date=iso-strict --numstat --since="${safeSince}" --until="${safeUntil}"${authorArg}`;
510
550
  }
511
551
 
512
552
  function mergeGitStats(target, source) {
@@ -576,6 +616,21 @@ export async function getGitStatsForMultipleReposAsync(repos, since, until) {
576
616
  return merged;
577
617
  }
578
618
 
619
+ // Per-repo git stats (unmerged), returns Map<repoPath, stats>
620
+ export async function getPerRepoGitStats(repos, since, until) {
621
+ const results = await Promise.all(
622
+ repos.map(async repo => {
623
+ try {
624
+ const stats = await getGitStatsAsync(repo, since, until, getGitAuthor(repo));
625
+ return [repo, stats];
626
+ } catch {
627
+ return [repo, emptyResult()];
628
+ }
629
+ })
630
+ );
631
+ return new Map(results);
632
+ }
633
+
579
634
  export function invalidateGitCache() {
580
635
  gitCache.clear();
581
636
  }