lumencode 0.4.3 → 1.0.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 +54 -38
- package/index.js +79 -10
- package/lib/aggregate.js +40 -6
- package/lib/cache.js +8 -0
- package/lib/git.js +24 -2
- package/lib/report.js +14 -14
- package/lib/server.js +523 -412
- package/package.json +1 -1
- package/public/api.js +6 -0
- package/public/app.js +697 -535
- package/public/charts.js +278 -130
- package/public/config.js +21 -21
- package/public/git-insights.js +39 -113
- package/public/index.html +728 -347
- package/public/style.css +829 -1702
- package/public/ui-state.js +8 -67
- package/public/utils.js +10 -0
- package/public/work-report.js +1 -22
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
|
-
>
|
|
29
|
+
> 「AI 帮你写了多少代码?」「订阅这些工具值不值?」—— 与其手算,不如一条命令搞定。
|
|
25
30
|
|
|
26
31
|
| 场景 | 用 lumencode 解决 |
|
|
27
32
|
|------|----------------------|
|
|
28
33
|
| **写周报** | 选周报 → 点「工作汇报 → 复制」→ 粘贴飞书/钉钉。**3 秒搞定。** |
|
|
29
|
-
| **证明 AI ROI** |
|
|
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
|
-
>
|
|
70
|
-
|
|
71
|
-

|
|
72
|
-
|
|
73
|
-
### 单工具报告
|
|
74
|
-
|
|
75
|
-
> 切换左侧标签即可单独查看任一工具的数据。
|
|
83
|
+
> 左侧数据源面板一键切换工具,主区域汇总 Token 消耗、费用、模型分布、AI 贡献度归因。
|
|
76
84
|
|
|
77
85
|
<table>
|
|
78
86
|
<tr>
|
|
79
|
-
<td><img src="doc
|
|
80
|
-
<td><img src="doc
|
|
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"
|
|
92
|
-
<td
|
|
91
|
+
<td align="center">汇总指标 + Token 趋势</td>
|
|
92
|
+
<td align="center">项目分布 + 时段分布 + 会话列表</td>
|
|
93
93
|
</tr>
|
|
94
94
|
</table>
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+

|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
### 多工具维度
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
100
|
+
> 切换到「全部工具」视图,查看跨工具的汇总数据与对比分析。
|
|
101
|
+
|
|
102
|
+

|
|
103
|
+
|
|
104
|
+
### 项目分布 & 会话记录
|
|
105
|
+
|
|
106
|
+
> 按项目统计 Token、费用、会话数,点击下钻查看单条会话明细。
|
|
107
|
+
|
|
108
|
+

|
|
109
|
+
|
|
110
|
+
### 场景分析
|
|
111
|
+
|
|
112
|
+
> 按工作类型分类(编码 / 测试 / 调试 / 文档 / 审查 / 规划),附匹配关键词示例。
|
|
113
|
+
|
|
114
|
+

|
|
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
|
-

|
|
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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { loadConfig, initConfig, getConfigPath } from './lib/config.js';
|
|
3
3
|
import { collectAllRecords, computeUsageStats, filterRecordsByPeriod, normalizeProjectPath, computeTrendData, computePrevPeriodRange, groupBySessions } from './lib/aggregate.js';
|
|
4
|
-
import { getGitStatsForMultipleReposAsync, invalidateGitCache, finalizeGitStats, computeCommitTypes, computeFileHotspots } from './lib/git.js';
|
|
4
|
+
import { getGitStatsForMultipleReposAsync, getPerRepoGitStats, invalidateGitCache, finalizeGitStats, computeCommitTypes, computeFileHotspots } from './lib/git.js';
|
|
5
5
|
import { invalidateFileCache } from './lib/cache.js';
|
|
6
6
|
import { generateReport, generateWorkReport } from './lib/report.js';
|
|
7
7
|
import { startServer } from './lib/server.js';
|
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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;
|
|
@@ -93,7 +98,7 @@ async function buildReportData(period, dateArg, config, effectiveIncludeProjects
|
|
|
93
98
|
}
|
|
94
99
|
|
|
95
100
|
// 其余逻辑保持不变
|
|
96
|
-
const { filtered, start, end } = filterRecordsByPeriod(toolRecords, period, dateArg);
|
|
101
|
+
const { filtered, start, end } = filterRecordsByPeriod(toolRecords, period, dateArg, { customStart: options.customStart, customEnd: options.customEnd });
|
|
97
102
|
const usageStats = computeUsageStats(filtered, config.scenarioKeywords, config.costMode);
|
|
98
103
|
const sessions = groupBySessions(filtered);
|
|
99
104
|
|
|
@@ -132,7 +137,7 @@ async function buildReportData(period, dateArg, config, effectiveIncludeProjects
|
|
|
132
137
|
const trendData = computeTrendData(toolRecords, period, dateArg);
|
|
133
138
|
|
|
134
139
|
// Previous period stats
|
|
135
|
-
const prevRange = computePrevPeriodRange(period, dateArg);
|
|
140
|
+
const prevRange = computePrevPeriodRange(period, dateArg, { customStart: options.customStart, customEnd: options.customEnd });
|
|
136
141
|
const prevFiltered = toolRecords.filter(r => {
|
|
137
142
|
if (!r.timestamp) return false;
|
|
138
143
|
const date = r.timestamp.slice(0, 10);
|
|
@@ -152,7 +157,71 @@ async function buildReportData(period, dateArg, config, effectiveIncludeProjects
|
|
|
152
157
|
const billingBlocks = identifyBillingBlocks(filtered, config.blockQuota ? 5 : 5, config.costMode);
|
|
153
158
|
|
|
154
159
|
const reposConfigured = !!(config.repos && config.repos.length > 0);
|
|
155
|
-
|
|
160
|
+
|
|
161
|
+
// 合并 toolBreakdown:usageStats 内含 token 粒度数据,parsers 提供 sessionCount
|
|
162
|
+
const statsTB = usageStats.toolBreakdown || {};
|
|
163
|
+
const mergedBreakdown = {};
|
|
164
|
+
// 以 parsers toolBreakdown 为基础(包含所有已启用工具),补充 stats 中的 token 数据
|
|
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
|
+
// 补充 stats 中有但 parsers 无的极端情况
|
|
177
|
+
for (const [name, data] of Object.entries(statsTB)) {
|
|
178
|
+
if (!mergedBreakdown[name]) {
|
|
179
|
+
mergedBreakdown[name] = {
|
|
180
|
+
inputTokens: data.inputTokens || 0,
|
|
181
|
+
outputTokens: data.outputTokens || 0,
|
|
182
|
+
cacheRead: data.cacheRead || 0,
|
|
183
|
+
cacheCreate: data.cacheCreate || 0,
|
|
184
|
+
count: data.count || 0,
|
|
185
|
+
sessionCount: 0,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Per-project details
|
|
191
|
+
const projectDetails = {};
|
|
192
|
+
const projEntries = Object.entries(usageStats.projects || {}).sort((a, b) => b[1].requests - a[1].requests);
|
|
193
|
+
if (reposConfigured && gitStats) {
|
|
194
|
+
const repoMap = await getPerRepoGitStats(
|
|
195
|
+
config.repos.filter(r => projEntries.some(([name]) => {
|
|
196
|
+
const base = r.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop();
|
|
197
|
+
return base === name;
|
|
198
|
+
})),
|
|
199
|
+
start, end + 'T23:59:59'
|
|
200
|
+
);
|
|
201
|
+
for (const [projName, projStats] of projEntries) {
|
|
202
|
+
const matchedRepo = [...repoMap.keys()].find(r => r.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop() === projName);
|
|
203
|
+
const repoGit = matchedRepo ? repoMap.get(matchedRepo) : null;
|
|
204
|
+
const topCommits = (repoGit?.commitList || [])
|
|
205
|
+
.filter(c => c.type === 'feat' || c.type === 'fix')
|
|
206
|
+
.slice(0, 5)
|
|
207
|
+
.map(c => ({ type: c.type, subject: c.subject, scope: c.scope }));
|
|
208
|
+
projectDetails[projName] = {
|
|
209
|
+
usage: projStats,
|
|
210
|
+
git: repoGit ? {
|
|
211
|
+
commits: repoGit.commits, linesAdded: repoGit.linesAdded, linesDeleted: repoGit.linesDeleted,
|
|
212
|
+
filesChanged: repoGit.filesChanged,
|
|
213
|
+
fileHotspots: (repoGit.fileHotspots || []).slice(0, 5),
|
|
214
|
+
} : null,
|
|
215
|
+
topCommits,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
for (const [projName, projStats] of projEntries) {
|
|
220
|
+
projectDetails[projName] = { usage: projStats, git: null, topCommits: [] };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { usageStats, gitStats, reposConfigured, sessions: slimSessions, start, end, trendData, prevStats, billingBlocks, toolBreakdown: mergedBreakdown, projectDetails };
|
|
156
225
|
}
|
|
157
226
|
|
|
158
227
|
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:
|
|
378
|
-
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
|
@@ -501,12 +501,19 @@ export function parseGitLogOutput(output, repo = '') {
|
|
|
501
501
|
return result;
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
+
function sanitizeArg(s) {
|
|
505
|
+
// 移除 shell 特殊字符,防止命令注入
|
|
506
|
+
return String(s || '').replace(/[`$"\\|;&<>!\n\r]/g, '');
|
|
507
|
+
}
|
|
508
|
+
|
|
504
509
|
function buildGitArgs(since, until, author) {
|
|
505
510
|
const sinceFull = since.includes('T') ? since : since + 'T00:00:00';
|
|
506
|
-
const
|
|
511
|
+
const safeSince = sanitizeArg(sinceFull);
|
|
512
|
+
const safeUntil = sanitizeArg(until);
|
|
513
|
+
const authorArg = author ? ` --author="${sanitizeArg(author)}"` : '';
|
|
507
514
|
// 格式:哨兵行(subject) → body 行(可多行) → ENDBODY 行 → numstat 行
|
|
508
515
|
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="${
|
|
516
|
+
return `--all --no-renames ${pretty} --date=iso-strict --numstat --since="${safeSince}" --until="${safeUntil}"${authorArg}`;
|
|
510
517
|
}
|
|
511
518
|
|
|
512
519
|
function mergeGitStats(target, source) {
|
|
@@ -576,6 +583,21 @@ export async function getGitStatsForMultipleReposAsync(repos, since, until) {
|
|
|
576
583
|
return merged;
|
|
577
584
|
}
|
|
578
585
|
|
|
586
|
+
// Per-repo git stats (unmerged), returns Map<repoPath, stats>
|
|
587
|
+
export async function getPerRepoGitStats(repos, since, until) {
|
|
588
|
+
const results = await Promise.all(
|
|
589
|
+
repos.map(async repo => {
|
|
590
|
+
try {
|
|
591
|
+
const stats = await getGitStatsAsync(repo, since, until, getGitAuthor(repo));
|
|
592
|
+
return [repo, stats];
|
|
593
|
+
} catch {
|
|
594
|
+
return [repo, emptyResult()];
|
|
595
|
+
}
|
|
596
|
+
})
|
|
597
|
+
);
|
|
598
|
+
return new Map(results);
|
|
599
|
+
}
|
|
600
|
+
|
|
579
601
|
export function invalidateGitCache() {
|
|
580
602
|
gitCache.clear();
|
|
581
603
|
}
|
package/lib/report.js
CHANGED
|
@@ -1146,16 +1146,17 @@ export function generateWorkReport(usageData, gitData, period, startDate, endDat
|
|
|
1146
1146
|
const opts = typeof options === 'string'
|
|
1147
1147
|
? { level: 'detailed', platform: options }
|
|
1148
1148
|
: { level: 'detailed', platform: 'default', ...options };
|
|
1149
|
-
const { level, platform: fmt, tool } = opts;
|
|
1150
|
-
const
|
|
1149
|
+
const { level, platform: fmt, tool, projectName } = opts;
|
|
1150
|
+
const toolLabel = tool && tool !== 'all' ? toolTitle(tool) : 'AI 编码助手';
|
|
1151
|
+
const titlePrefix = projectName ? `${projectName} · ${toolLabel}` : toolLabel;
|
|
1151
1152
|
|
|
1152
1153
|
// 简报路由
|
|
1153
1154
|
if (level === 'brief') {
|
|
1154
1155
|
return generateBriefReport(usageData, gitData, period, startDate, endDate, prevData, fmt, tool);
|
|
1155
1156
|
}
|
|
1156
1157
|
const lines = [];
|
|
1157
|
-
const periodLabel = period === 'daily' ? '日报' : period === 'weekly' ? '周报' : '月报';
|
|
1158
|
-
const dateLabel = period === 'monthly' ? startDate.slice(0, 7) : period === '
|
|
1158
|
+
const periodLabel = period === 'daily' ? '日报' : period === 'weekly' ? '周报' : period === 'monthly' ? '月报' : '自定义';
|
|
1159
|
+
const dateLabel = period === 'monthly' ? startDate.slice(0, 7) : period === 'daily' ? startDate : `${startDate} ~ ${endDate}`;
|
|
1159
1160
|
|
|
1160
1161
|
lines.push(`# ${titlePrefix} 工作${periodLabel} - ${dateLabel}`);
|
|
1161
1162
|
lines.push('');
|
|
@@ -1291,18 +1292,17 @@ export function generateWorkReport(usageData, gitData, period, startDate, endDat
|
|
|
1291
1292
|
const aiLinePct = Math.round(((gitData.aiContribution?.aiLineRatio ?? gitData.aiContribution?.aiRatio) || 0) * 100);
|
|
1292
1293
|
sectionLines.push(`- 高/中置信 AI 提交 **${totalAI}/${totalCommits}**(${aiLinePct}%),涉及 +${formatInt(aiDetail.totalAIFileAdded)}/-${formatInt(aiDetail.totalAIFileDeleted)} 行`);
|
|
1293
1294
|
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
}
|
|
1297
|
-
if (aiDetail.sessionStrong.length > 0) {
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
sectionLines.push(`- **文件重叠**(${aiDetail.fileOverlap.length} 项):${aiDetail.fileOverlap.map(s => `\`${s}\``).join('、')}`);
|
|
1295
|
+
// 汇总统计(不列出具体 commit subject,工作汇报中无阅读价值)
|
|
1296
|
+
const parts = [];
|
|
1297
|
+
if (aiDetail.explicit.length > 0) parts.push(`显式标记 ${aiDetail.explicit.length} 项`);
|
|
1298
|
+
if (aiDetail.sessionStrong.length > 0) parts.push(`会话强关联 ${aiDetail.sessionStrong.length} 项`);
|
|
1299
|
+
if (aiDetail.fileOverlap.length > 0) parts.push(`文件重叠 ${aiDetail.fileOverlap.length} 项`);
|
|
1300
|
+
if (parts.length > 0) {
|
|
1301
|
+
sectionLines.push(`- 归因方式:${parts.join('、')}`);
|
|
1302
1302
|
}
|
|
1303
1303
|
if (aiDetail.aiFiles.length > 0) {
|
|
1304
|
-
const topFiles = aiDetail.aiFiles.slice(0,
|
|
1305
|
-
const overflow = aiDetail.aiFiles.length >
|
|
1304
|
+
const topFiles = aiDetail.aiFiles.slice(0, 5).join('、');
|
|
1305
|
+
const overflow = aiDetail.aiFiles.length > 5 ? ` 等 ${aiDetail.aiFiles.length} 个` : '';
|
|
1306
1306
|
sectionLines.push(`- **AI 涉及文件**:${topFiles}${overflow}`);
|
|
1307
1307
|
}
|
|
1308
1308
|
sectionLines.push('');
|