lumencode 1.1.0 → 1.3.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 +41 -0
- package/hooks/claude-post-tool-batch.js +51 -0
- package/hooks/codex-hook.js +56 -0
- package/hooks/init-steps.js +10 -0
- package/hooks/install-codex.js +9 -0
- package/hooks/install.js +14 -0
- package/hooks/opencode-hook.js +45 -0
- package/hooks/post-tool-use.js +42 -0
- package/index.js +236 -22
- package/lib/aggregate.js +27 -9
- package/lib/attribution.js +13 -0
- package/lib/capture-recorder.js +141 -0
- package/lib/config.js +26 -2
- package/lib/git-attribution-candidates.js +37 -0
- package/lib/git-attribution-options.js +105 -0
- package/lib/git-paths.js +41 -0
- package/lib/git.js +581 -129
- package/lib/hooks-manager.js +379 -0
- package/lib/line-blame.js +140 -0
- package/lib/parser.js +40 -18
- package/lib/parsers/base.js +69 -67
- package/lib/parsers/claude.js +51 -53
- package/lib/parsers/codex.js +21 -9
- package/lib/parsers/index.js +153 -151
- package/lib/parsers/opencode.js +28 -20
- package/lib/report.js +3 -3
- package/lib/server.js +242 -29
- package/lib/step-schema.js +217 -0
- package/lib/step-tracker.js +323 -0
- package/package.json +8 -2
- package/public/api.js +21 -0
- package/public/app.js +127 -2
- package/public/config.js +2 -0
- package/public/git-insights.js +19 -0
- package/public/index.html +69 -0
- package/public/style.css +85 -1
package/lib/parsers/base.js
CHANGED
|
@@ -1,67 +1,69 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
*
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
*
|
|
33
|
-
* @
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
1
|
+
import { homedir } from 'os';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AI 工具日志解析器基类
|
|
5
|
+
* 所有具体解析器必须继承此类并实现抽象方法
|
|
6
|
+
*/
|
|
7
|
+
export class BaseParser {
|
|
8
|
+
constructor() {
|
|
9
|
+
if (this.constructor === BaseParser) {
|
|
10
|
+
throw new Error('BaseParser 是抽象类,不能直接实例化');
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 返回工具元信息
|
|
16
|
+
* @returns {{name: string, displayName: string, defaultDir: string, envVar: string}}
|
|
17
|
+
*/
|
|
18
|
+
getInfo() {
|
|
19
|
+
throw new Error(`${this.constructor.name}.getInfo() 未实现`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 检测该工具的数据目录是否存在且有有效数据
|
|
24
|
+
* @param {Object} config - 完整配置对象
|
|
25
|
+
* @returns {Promise<boolean>}
|
|
26
|
+
*/
|
|
27
|
+
async detect(config) {
|
|
28
|
+
throw new Error(`${this.constructor.name}.detect() 未实现`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 解析日志文件,返回 UsageRecord[]
|
|
33
|
+
* @param {Object} config - 完整配置对象
|
|
34
|
+
* @param {Object} options - 解析选项
|
|
35
|
+
* @returns {Promise<Array>}
|
|
36
|
+
*/
|
|
37
|
+
async parse(config, options = {}) {
|
|
38
|
+
throw new Error(`${this.constructor.name}.parse() 未实现`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 获取该工具的数据目录路径(从配置或环境变量)
|
|
43
|
+
* @param {Object} config
|
|
44
|
+
* @returns {string|null}
|
|
45
|
+
*/
|
|
46
|
+
getDataDir(config) {
|
|
47
|
+
const info = this.getInfo();
|
|
48
|
+
const configKey = `${info.name}Dir`;
|
|
49
|
+
if (config[configKey] && config[configKey] !== '') {
|
|
50
|
+
return config[configKey];
|
|
51
|
+
}
|
|
52
|
+
const envVal = process.env[info.envVar];
|
|
53
|
+
if (envVal) return envVal;
|
|
54
|
+
const home = homedir();
|
|
55
|
+
if (home) {
|
|
56
|
+
return home + info.defaultDir.replace(/^~/, '');
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 获取工具版本号(子类可覆写)
|
|
63
|
+
* @param {Object} config
|
|
64
|
+
* @returns {Promise<string|null>}
|
|
65
|
+
*/
|
|
66
|
+
async getVersion(config) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
package/lib/parsers/claude.js
CHANGED
|
@@ -1,29 +1,34 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
|
|
2
|
-
import { join, dirname, basename } from 'path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
1
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
|
|
2
|
+
import { join, dirname, basename } from 'path';
|
|
3
|
+
import { execFileSync } from 'child_process';
|
|
4
|
+
import { BaseParser } from './base.js';
|
|
5
|
+
import { createUsageRecord } from '../models/usage-record.js';
|
|
6
|
+
import { getCachedFileRecords } from '../cache.js';
|
|
7
|
+
import { getProjectDisplayName } from '../aggregate.js';
|
|
8
|
+
|
|
9
|
+
const gitRootCache = new Map();
|
|
10
|
+
|
|
11
|
+
function normalizePath(value) {
|
|
12
|
+
return String(value || '').replace(/\\/g, '/').replace(/\/$/, '');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolveGitRoot(cwd) {
|
|
16
|
+
const normalized = normalizePath(cwd);
|
|
17
|
+
if (!normalized) return '';
|
|
18
|
+
if (gitRootCache.has(normalized)) return gitRootCache.get(normalized);
|
|
19
|
+
let root = normalized;
|
|
20
|
+
try {
|
|
21
|
+
root = normalizePath(execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
22
|
+
cwd: normalized,
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
25
|
+
}).trim());
|
|
26
|
+
} catch {
|
|
27
|
+
root = normalized;
|
|
28
|
+
}
|
|
29
|
+
gitRootCache.set(normalized, root);
|
|
30
|
+
return root;
|
|
31
|
+
}
|
|
27
32
|
|
|
28
33
|
export class ClaudeParser extends BaseParser {
|
|
29
34
|
getInfo() {
|
|
@@ -48,7 +53,7 @@ export class ClaudeParser extends BaseParser {
|
|
|
48
53
|
|
|
49
54
|
async parse(config, options = {}) {
|
|
50
55
|
const dir = this.getDataDir(config);
|
|
51
|
-
const { excludeProjects = []
|
|
56
|
+
const { excludeProjects = [] } = options;
|
|
52
57
|
const records = [];
|
|
53
58
|
|
|
54
59
|
if (!dir) return records;
|
|
@@ -64,27 +69,15 @@ export class ClaudeParser extends BaseParser {
|
|
|
64
69
|
return records;
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
// includeProjects
|
|
68
|
-
|
|
69
|
-
? includeProjects.map(p => p.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, ''))
|
|
70
|
-
: null;
|
|
71
|
-
const encodedIncludes = normalizedIp
|
|
72
|
-
? new Set(normalizedIp.map(p => encodeProjectPath(p)))
|
|
73
|
-
: null;
|
|
74
|
-
// encoded → 原始 includeProject 路径的反查表
|
|
75
|
-
const encodedToOriginal = normalizedIp
|
|
76
|
-
? new Map(normalizedIp.map(p => [encodeProjectPath(p), p]))
|
|
77
|
-
: null;
|
|
72
|
+
// includeProjects 过滤交由外层 parseAllEnabledTools 按 basename 统一处理
|
|
73
|
+
// 不在内层按 encoded 路径精确匹配,避免路径编码差异导致 session 全部丢失
|
|
78
74
|
|
|
79
75
|
for (const projDir of dirs) {
|
|
80
|
-
if (encodedIncludes && !encodedIncludes.has(projDir)) continue;
|
|
81
|
-
|
|
82
76
|
const projPath = join(projectsDir, projDir);
|
|
83
77
|
const allEntries = readdirSync(projPath);
|
|
84
78
|
const jsonlFiles = allEntries.filter(f => f.endsWith('.jsonl') && !f.includes('subagents'));
|
|
85
79
|
|
|
86
|
-
|
|
87
|
-
const projName = (encodedToOriginal && encodedToOriginal.get(projDir)) || decodeProjectName(projDir);
|
|
80
|
+
const projName = getProjectDisplayName(projDir);
|
|
88
81
|
|
|
89
82
|
// 并行读取所有 JSONL 文件(减少串行 IO 等待)
|
|
90
83
|
const fileResults = await Promise.all(
|
|
@@ -97,14 +90,14 @@ export class ClaudeParser extends BaseParser {
|
|
|
97
90
|
for (const r of fileRecords) {
|
|
98
91
|
result.push(this._convertToUsageRecord(r, projName, sessionIdFromFile));
|
|
99
92
|
}
|
|
100
|
-
} catch {}
|
|
93
|
+
} catch (e) { console.warn(`[claude] 读取文件记录失败: ${filePath}`, e.message); }
|
|
101
94
|
// 子 agent 日志
|
|
102
95
|
try {
|
|
103
96
|
const subRecords = this._parseSubagentFiles(dirname(filePath));
|
|
104
97
|
for (const r of subRecords) {
|
|
105
98
|
result.push(this._convertToUsageRecord(r, projName, sessionIdFromFile));
|
|
106
99
|
}
|
|
107
|
-
} catch {}
|
|
100
|
+
} catch (e) { console.warn(`[claude] 解析子agent失败: ${filePath}`, e.message); }
|
|
108
101
|
return result;
|
|
109
102
|
})
|
|
110
103
|
);
|
|
@@ -128,7 +121,7 @@ export class ClaudeParser extends BaseParser {
|
|
|
128
121
|
for (const r of subRecords) {
|
|
129
122
|
records.push(this._convertToUsageRecord(r, projName, uuidDir));
|
|
130
123
|
}
|
|
131
|
-
} catch {}
|
|
124
|
+
} catch (e) { console.warn(`[claude] 扫描UUID子目录subagent失败: ${uuidDir}`, e.message); }
|
|
132
125
|
}
|
|
133
126
|
}
|
|
134
127
|
}
|
|
@@ -136,8 +129,13 @@ export class ClaudeParser extends BaseParser {
|
|
|
136
129
|
return records;
|
|
137
130
|
}
|
|
138
131
|
|
|
139
|
-
_convertToUsageRecord(raw, projectDir, fallbackSessionId = '') {
|
|
140
|
-
|
|
132
|
+
_convertToUsageRecord(raw, projectDir, fallbackSessionId = '') {
|
|
133
|
+
// 优先将 cwd 归一到 Git 根目录,避免 monorepo 子目录会话与仓库提交失配
|
|
134
|
+
let projectName = projectDir || '';
|
|
135
|
+
if (raw.cwd) {
|
|
136
|
+
projectName = resolveGitRoot(raw.cwd) || projectName;
|
|
137
|
+
}
|
|
138
|
+
return createUsageRecord({
|
|
141
139
|
timestamp: raw.timestamp || '',
|
|
142
140
|
tool: 'claude',
|
|
143
141
|
sessionId: raw.sessionId || fallbackSessionId || '',
|
|
@@ -147,7 +145,7 @@ export class ClaudeParser extends BaseParser {
|
|
|
147
145
|
cacheReadTokens: raw.tokens?.cacheRead || 0,
|
|
148
146
|
cacheWriteTokens: raw.tokens?.cacheCreate || 0,
|
|
149
147
|
costUSD: raw.costUSD ?? null,
|
|
150
|
-
project:
|
|
148
|
+
project: projectName,
|
|
151
149
|
metadata: {
|
|
152
150
|
type: raw.type,
|
|
153
151
|
role: raw.role,
|
|
@@ -182,9 +180,9 @@ export class ClaudeParser extends BaseParser {
|
|
|
182
180
|
if (obj.isApiErrorMessage === true) continue;
|
|
183
181
|
records.push(this._normalizeRawRecord(obj));
|
|
184
182
|
}
|
|
185
|
-
} catch {}
|
|
183
|
+
} catch (e) { /* JSONL 单行解析失败,跳过 */ }
|
|
186
184
|
}
|
|
187
|
-
} catch {}
|
|
185
|
+
} catch (e) { console.warn(`[claude] 读取subagent文件失败: ${file}`, e.message); }
|
|
188
186
|
}
|
|
189
187
|
|
|
190
188
|
for (const r of records) {
|
|
@@ -220,7 +218,7 @@ export class ClaudeParser extends BaseParser {
|
|
|
220
218
|
sessionId: entry.sessionId,
|
|
221
219
|
cwd: entry.projectPath || '',
|
|
222
220
|
gitBranch: entry.gitBranch || '',
|
|
223
|
-
project: '',
|
|
221
|
+
project: projName || '',
|
|
224
222
|
tokens: { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 },
|
|
225
223
|
isSidechain: false,
|
|
226
224
|
isSubagent: false,
|
|
@@ -242,7 +240,7 @@ export class ClaudeParser extends BaseParser {
|
|
|
242
240
|
sessionId: entry.sessionId,
|
|
243
241
|
cwd: entry.projectPath || '',
|
|
244
242
|
gitBranch: entry.gitBranch || '',
|
|
245
|
-
project: '',
|
|
243
|
+
project: projName || '',
|
|
246
244
|
tokens: { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 },
|
|
247
245
|
isSidechain: false,
|
|
248
246
|
isSubagent: false,
|
|
@@ -255,7 +253,7 @@ export class ClaudeParser extends BaseParser {
|
|
|
255
253
|
});
|
|
256
254
|
}
|
|
257
255
|
}
|
|
258
|
-
} catch {}
|
|
256
|
+
} catch (e) { console.warn(`[claude] 解析sessions-index失败: ${projPath}`, e.message); }
|
|
259
257
|
return records;
|
|
260
258
|
}
|
|
261
259
|
|
package/lib/parsers/codex.js
CHANGED
|
@@ -6,6 +6,7 @@ import { createUsageRecord } from '../models/usage-record.js';
|
|
|
6
6
|
// 文件级解析缓存:基于 mtime
|
|
7
7
|
const _codexFileCache = new Map();
|
|
8
8
|
const CODEX_CACHE_MAX = 200;
|
|
9
|
+
const CODEX_MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
|
9
10
|
|
|
10
11
|
function getCachedCodexParse(filePath, parseFn) {
|
|
11
12
|
try {
|
|
@@ -97,22 +98,23 @@ export class CodexParser extends BaseParser {
|
|
|
97
98
|
if (statSync(sessionsDir).isDirectory()) {
|
|
98
99
|
files.push(...this._walkDir(sessionsDir));
|
|
99
100
|
}
|
|
100
|
-
} catch {}
|
|
101
|
+
} catch (e) { console.warn("[codex] parse error", e.message); }
|
|
101
102
|
|
|
102
103
|
const archivedDir = join(dir, 'archived_sessions');
|
|
103
104
|
try {
|
|
104
|
-
if (statSync(archivedDir).isDirectory()) {
|
|
105
|
+
if (existsSync(archivedDir) && statSync(archivedDir).isDirectory()) {
|
|
105
106
|
const archived = readdirSync(archivedDir).filter(f => f.endsWith('.jsonl'));
|
|
106
107
|
for (const f of archived) {
|
|
107
108
|
files.push(join(archivedDir, f));
|
|
108
109
|
}
|
|
109
110
|
}
|
|
110
|
-
} catch {}
|
|
111
|
+
} catch (e) { console.warn("[codex] parse error", e.message); }
|
|
111
112
|
|
|
112
113
|
return files;
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
_walkDir(dir) {
|
|
116
|
+
_walkDir(dir, depth = 0) {
|
|
117
|
+
if (depth > 10) return [];
|
|
116
118
|
const results = [];
|
|
117
119
|
try {
|
|
118
120
|
const entries = readdirSync(dir);
|
|
@@ -121,13 +123,13 @@ export class CodexParser extends BaseParser {
|
|
|
121
123
|
try {
|
|
122
124
|
const stat = statSync(fullPath);
|
|
123
125
|
if (stat.isDirectory()) {
|
|
124
|
-
results.push(...this._walkDir(fullPath));
|
|
126
|
+
results.push(...this._walkDir(fullPath, depth + 1));
|
|
125
127
|
} else if (entry.endsWith('.jsonl')) {
|
|
126
128
|
results.push(fullPath);
|
|
127
129
|
}
|
|
128
|
-
} catch {}
|
|
130
|
+
} catch (e) { console.warn("[codex] parse error", e.message); }
|
|
129
131
|
}
|
|
130
|
-
} catch {}
|
|
132
|
+
} catch (e) { console.warn("[codex] parse error", e.message); }
|
|
131
133
|
return results;
|
|
132
134
|
}
|
|
133
135
|
|
|
@@ -160,6 +162,11 @@ export class CodexParser extends BaseParser {
|
|
|
160
162
|
}
|
|
161
163
|
|
|
162
164
|
_parseFile(filePath) {
|
|
165
|
+
const { size } = statSync(filePath);
|
|
166
|
+
if (size > CODEX_MAX_FILE_SIZE) {
|
|
167
|
+
console.warn(`[codex] 文件过大 (${(size / 1024 / 1024).toFixed(0)}MB),跳过: ${filePath}`);
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
163
170
|
const content = readFileSync(filePath, 'utf-8');
|
|
164
171
|
const lines = content.split('\n').filter(l => l.trim());
|
|
165
172
|
|
|
@@ -254,7 +261,7 @@ export class CodexParser extends BaseParser {
|
|
|
254
261
|
}));
|
|
255
262
|
}
|
|
256
263
|
}
|
|
257
|
-
} catch {}
|
|
264
|
+
} catch (e) { console.warn("[codex] parse error", e.message); }
|
|
258
265
|
}
|
|
259
266
|
|
|
260
267
|
return records;
|
|
@@ -294,6 +301,11 @@ export class CodexParser extends BaseParser {
|
|
|
294
301
|
let rows = useCache ? _codexStateCache.rows : null;
|
|
295
302
|
|
|
296
303
|
if (!rows) {
|
|
304
|
+
const dbStat = statSync(dbPath);
|
|
305
|
+
if (dbStat.size > CODEX_MAX_FILE_SIZE) {
|
|
306
|
+
console.warn(`[codex] state.db 过大 (${(dbStat.size / 1024 / 1024).toFixed(0)}MB),跳过`);
|
|
307
|
+
return records;
|
|
308
|
+
}
|
|
297
309
|
const initSqlJs = (await import('sql.js')).default;
|
|
298
310
|
const SQL = await initSqlJs();
|
|
299
311
|
const dbBuf = readFileSync(dbPath);
|
|
@@ -343,7 +355,7 @@ export class CodexParser extends BaseParser {
|
|
|
343
355
|
}));
|
|
344
356
|
}
|
|
345
357
|
}
|
|
346
|
-
} catch {}
|
|
358
|
+
} catch (e) { console.warn("[codex] parse error", e.message); }
|
|
347
359
|
return records;
|
|
348
360
|
}
|
|
349
361
|
|