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 +54 -38
- package/index.js +128 -48
- package/lib/aggregate.js +40 -6
- package/lib/cache.js +8 -0
- package/lib/git.js +75 -20
- package/lib/parsers/claude.js +321 -316
- package/lib/parsers/codex.js +360 -316
- package/lib/parsers/opencode.js +236 -216
- package/lib/record-utils.js +36 -35
- package/lib/report.js +53 -16
- package/lib/server.js +191 -30
- package/package.json +1 -1
- package/public/api.js +6 -0
- package/public/app.js +827 -636
- package/public/charts.js +285 -95
- package/public/config.js +22 -21
- package/public/git-insights.js +39 -113
- package/public/index.html +728 -341
- package/public/style.css +829 -1701
- 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
|
@@ -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;
|
|
@@ -92,54 +97,60 @@ async function buildReportData(period, dateArg, config, effectiveIncludeProjects
|
|
|
92
97
|
return null;
|
|
93
98
|
}
|
|
94
99
|
|
|
95
|
-
|
|
96
|
-
const
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
108
|
-
if (toolRepos.length
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
139
|
+
const trendDataPromise = Promise.resolve(computeTrendData(toolRecords, period, dateArg));
|
|
133
140
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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
|
-
|
|
155
|
-
|
|
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:
|
|
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
|
@@ -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
|
-
|
|
337
|
-
|
|
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
|
|
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="${
|
|
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
|
}
|