lumencode 0.4.3

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.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 统一 AI 工具使用记录格式
3
+ * 所有解析器(Claude/Codex/OpenCode)必须输出此格式
4
+ */
5
+ export const USAGE_RECORD_SCHEMA = {
6
+ timestamp: '', // ISO 8601 字符串
7
+ tool: '', // 'claude' | 'codex' | 'opencode'
8
+ sessionId: '', // 会话标识
9
+ model: '', // 标准化模型名
10
+ inputTokens: 0, // 输入 token 数
11
+ outputTokens: 0, // 输出 token 数
12
+ cacheReadTokens: 0, // 缓存读取 token 数(可选)
13
+ cacheWriteTokens: 0,// 缓存写入 token 数(可选)
14
+ costUSD: null, // 费用(美元),null 表示未计算
15
+ project: '', // 项目名称(可选)
16
+ metadata: {}, // 工具特有原始数据透传
17
+ };
18
+
19
+ export function createUsageRecord(overrides = {}) {
20
+ return {
21
+ timestamp: '',
22
+ tool: '',
23
+ sessionId: '',
24
+ model: '',
25
+ inputTokens: 0,
26
+ outputTokens: 0,
27
+ cacheReadTokens: 0,
28
+ cacheWriteTokens: 0,
29
+ costUSD: null,
30
+ project: '',
31
+ metadata: {},
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ export function validateUsageRecord(record) {
37
+ const required = ['timestamp', 'tool', 'sessionId'];
38
+ const missing = required.filter(k => !record[k]);
39
+ if (missing.length > 0) {
40
+ throw new Error(`UsageRecord 缺少必填字段: ${missing.join(', ')}`);
41
+ }
42
+ if (!['claude', 'codex', 'opencode'].includes(record.tool)) {
43
+ throw new Error(`UsageRecord.tool 必须是 'claude' | 'codex' | 'opencode', got: ${record.tool}`);
44
+ }
45
+ return true;
46
+ }
package/lib/parser.js ADDED
@@ -0,0 +1,160 @@
1
+ import { readFileSync, readdirSync, statSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+
4
+ export function parseJsonlFile(filePath) {
5
+ const content = readFileSync(filePath, 'utf-8');
6
+ const records = [];
7
+
8
+ for (const line of content.split('\n')) {
9
+ const trimmed = line.trim();
10
+ if (!trimmed) continue;
11
+ try {
12
+ const obj = JSON.parse(trimmed);
13
+ if (obj.type === 'user' || obj.type === 'assistant') {
14
+ if (obj.isApiErrorMessage === true) continue;
15
+ records.push(normalizeRecord(obj));
16
+ }
17
+ } catch {}
18
+ }
19
+
20
+ return records;
21
+ }
22
+
23
+ // 解析子 agent 日志(subagents/ 目录下的 JSONL 文件)
24
+ export function parseSubagentFiles(sessionDir) {
25
+ const subagentsDir = join(sessionDir, 'subagents');
26
+ const records = [];
27
+
28
+ try {
29
+ if (!statSync(subagentsDir).isDirectory()) return records;
30
+ } catch {
31
+ return records;
32
+ }
33
+
34
+ const files = readdirSync(subagentsDir).filter(f => f.endsWith('.jsonl'));
35
+ for (const file of files) {
36
+ try {
37
+ const filePath = join(subagentsDir, file);
38
+ const subRecords = parseJsonlFile(filePath);
39
+ // 标记为子 agent 记录
40
+ for (const r of subRecords) {
41
+ r.isSubagent = true;
42
+ }
43
+ records.push(...subRecords);
44
+ } catch {}
45
+ }
46
+
47
+ return records;
48
+ }
49
+
50
+ // 自动检测 claudeDir
51
+ export function detectClaudeDir() {
52
+ const home = process.env.HOME || process.env.USERPROFILE;
53
+ if (!home) return null;
54
+
55
+ const candidates = [
56
+ join(home, '.claude'),
57
+ join(home, '.config', 'claude'),
58
+ ];
59
+
60
+ for (const dir of candidates) {
61
+ try {
62
+ const projectsDir = join(dir, 'projects');
63
+ if (statSync(projectsDir).isDirectory()) return dir;
64
+ } catch {}
65
+ }
66
+
67
+ return null;
68
+ }
69
+
70
+ // 从 JSONL 的 cwd 字段自动推导项目路径
71
+ export function deriveProjectPaths(claudeDir, excludeProjects = []) {
72
+ const projectsDir = join(claudeDir, 'projects');
73
+ const paths = new Set();
74
+
75
+ try {
76
+ if (!statSync(projectsDir).isDirectory()) return [];
77
+ } catch {
78
+ return [];
79
+ }
80
+
81
+ const dirs = readdirSync(projectsDir).filter(d => {
82
+ const full = join(projectsDir, d);
83
+ return statSync(full).isDirectory() && !excludeProjects.includes(d);
84
+ });
85
+
86
+ for (const projDir of dirs) {
87
+ const projPath = join(projectsDir, projDir);
88
+ const files = readdirSync(projPath).filter(f => f.endsWith('.jsonl') && !f.includes('subagents'));
89
+
90
+ for (const file of files) {
91
+ try {
92
+ const content = readFileSync(join(projPath, file), 'utf-8');
93
+ for (const line of content.split('\n')) {
94
+ const trimmed = line.trim();
95
+ if (!trimmed) continue;
96
+ try {
97
+ const obj = JSON.parse(trimmed);
98
+ if (obj.cwd) {
99
+ // 只保留真实的文件系统路径
100
+ const cwd = obj.cwd.replace(/\\/g, '/').replace(/\/$/, '');
101
+ if (cwd.startsWith('/') || /^[A-Z]:\//i.test(cwd)) {
102
+ paths.add(cwd);
103
+ }
104
+ }
105
+ } catch {}
106
+ }
107
+ } catch {}
108
+ }
109
+ }
110
+
111
+ return [...paths].sort();
112
+ }
113
+
114
+ function normalizeRecord(obj) {
115
+ const msg = obj.message || {};
116
+ const content = msg.content || '';
117
+ let text = '';
118
+ let toolCalls = [];
119
+
120
+ if (typeof content === 'string') {
121
+ text = content;
122
+ } else if (Array.isArray(content)) {
123
+ for (const c of content) {
124
+ if (!c || typeof c !== 'object') continue;
125
+ if (c.type === 'text') text += (c.text || '');
126
+ if (c.type === 'tool_use') {
127
+ toolCalls.push({ name: c.name || '', input: c.input || {} });
128
+ }
129
+ }
130
+ text = text.trim();
131
+ }
132
+
133
+ const usage = obj.usage || msg.usage || {};
134
+
135
+ return {
136
+ type: obj.type,
137
+ role: msg.role || '',
138
+ timestamp: obj.timestamp || '',
139
+ model: msg.model || '',
140
+ text: text.trim(),
141
+ toolCalls,
142
+ sessionId: obj.sessionId || '',
143
+ cwd: obj.cwd || '',
144
+ gitBranch: obj.gitBranch || '',
145
+ project: '',
146
+ tokens: {
147
+ input: usage.input_tokens || 0,
148
+ output: usage.output_tokens || 0,
149
+ cacheCreate: usage.cache_creation_input_tokens || usage.cache_creation?.ephemeral_5m_input_tokens || 0,
150
+ cacheRead: usage.cache_read_input_tokens || 0,
151
+ },
152
+ isSidechain: obj.isSidechain || false,
153
+ isSubagent: false,
154
+ messageId: msg.id || '',
155
+ requestId: obj.requestId || '',
156
+ costUSD: obj.costUSD ?? null,
157
+ isApiError: obj.isApiErrorMessage || false,
158
+ speed: usage.speed || 'standard',
159
+ };
160
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * AI 工具日志解析器基类
3
+ * 所有具体解析器必须继承此类并实现抽象方法
4
+ */
5
+ export class BaseParser {
6
+ constructor() {
7
+ if (this.constructor === BaseParser) {
8
+ throw new Error('BaseParser 是抽象类,不能直接实例化');
9
+ }
10
+ }
11
+
12
+ /**
13
+ * 返回工具元信息
14
+ * @returns {{name: string, displayName: string, defaultDir: string, envVar: string}}
15
+ */
16
+ getInfo() {
17
+ throw new Error(`${this.constructor.name}.getInfo() 未实现`);
18
+ }
19
+
20
+ /**
21
+ * 检测该工具的数据目录是否存在且有有效数据
22
+ * @param {Object} config - 完整配置对象
23
+ * @returns {Promise<boolean>}
24
+ */
25
+ async detect(config) {
26
+ throw new Error(`${this.constructor.name}.detect() 未实现`);
27
+ }
28
+
29
+ /**
30
+ * 解析日志文件,返回 UsageRecord[]
31
+ * @param {Object} config - 完整配置对象
32
+ * @param {Object} options - 解析选项
33
+ * @returns {Promise<Array>}
34
+ */
35
+ async parse(config, options = {}) {
36
+ throw new Error(`${this.constructor.name}.parse() 未实现`);
37
+ }
38
+
39
+ /**
40
+ * 获取该工具的数据目录路径(从配置或环境变量)
41
+ * @param {Object} config
42
+ * @returns {string|null}
43
+ */
44
+ getDataDir(config) {
45
+ const info = this.getInfo();
46
+ const configKey = `${info.name}Dir`;
47
+ if (config[configKey] && config[configKey] !== '') {
48
+ return config[configKey];
49
+ }
50
+ const envVal = process.env[info.envVar];
51
+ if (envVal) return envVal;
52
+ const home = process.env.HOME || process.env.USERPROFILE;
53
+ if (home) {
54
+ return home + info.defaultDir.replace(/^~/, '');
55
+ }
56
+ return null;
57
+ }
58
+
59
+ /**
60
+ * 获取工具版本号(子类可覆写)
61
+ * @param {Object} config
62
+ * @returns {Promise<string|null>}
63
+ */
64
+ async getVersion(config) {
65
+ return null;
66
+ }
67
+ }
@@ -0,0 +1,316 @@
1
+ import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
2
+ import { join, dirname, basename } from 'path';
3
+ import { BaseParser } from './base.js';
4
+ import { createUsageRecord } from '../models/usage-record.js';
5
+ import { getCachedFileRecords } from '../cache.js';
6
+
7
+ function encodeProjectPath(projectPath) {
8
+ return projectPath
9
+ .replace(/:[\\/]/, '--')
10
+ .replace(/[\\/]/g, '-');
11
+ }
12
+
13
+ function decodeProjectName(dirName) {
14
+ let decoded = dirName
15
+ .replace(/^([A-Z])-/, '$1:/')
16
+ .replace(/--/g, '/')
17
+ .replace(/-/g, '/');
18
+ if (dirName.startsWith('...')) {
19
+ decoded = '.../' + decoded.slice(3);
20
+ }
21
+ decoded = decoded.replace(/\/+$/, '');
22
+ if (/^[A-Z]:$/.test(decoded)) {
23
+ decoded = decoded + ' [空路径]';
24
+ }
25
+ return decoded || '[未知项目]';
26
+ }
27
+
28
+ export class ClaudeParser extends BaseParser {
29
+ getInfo() {
30
+ return {
31
+ name: 'claude',
32
+ displayName: 'Claude Code',
33
+ defaultDir: '~/.claude',
34
+ envVar: 'CLAUDE_CONFIG_DIR',
35
+ };
36
+ }
37
+
38
+ async detect(config) {
39
+ const dir = this.getDataDir(config);
40
+ if (!dir) return false;
41
+ try {
42
+ const projectsDir = join(dir, 'projects');
43
+ return statSync(projectsDir).isDirectory();
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ async parse(config, options = {}) {
50
+ const dir = this.getDataDir(config);
51
+ const { excludeProjects = [], includeProjects = null } = options;
52
+ const records = [];
53
+
54
+ if (!dir) return records;
55
+
56
+ const projectsDir = join(dir, 'projects');
57
+ let dirs;
58
+ try {
59
+ dirs = readdirSync(projectsDir).filter(d => {
60
+ const full = join(projectsDir, d);
61
+ return statSync(full).isDirectory() && !excludeProjects.includes(d);
62
+ });
63
+ } catch {
64
+ return records;
65
+ }
66
+
67
+ // includeProjects 过滤 + 反查正确项目名
68
+ const normalizedIp = includeProjects
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;
78
+
79
+ for (const projDir of dirs) {
80
+ if (encodedIncludes && !encodedIncludes.has(projDir)) continue;
81
+
82
+ const projPath = join(projectsDir, projDir);
83
+ const allEntries = readdirSync(projPath);
84
+ const jsonlFiles = allEntries.filter(f => f.endsWith('.jsonl') && !f.includes('subagents'));
85
+
86
+ // 优先使用 includeProject 原始路径(避免 decodeProjectName 的有损解码)
87
+ const projName = (encodedToOriginal && encodedToOriginal.get(projDir)) || decodeProjectName(projDir);
88
+
89
+ for (const file of jsonlFiles) {
90
+ const filePath = join(projPath, file);
91
+ const sessionIdFromFile = file.replace(/\.jsonl$/, '');
92
+ try {
93
+ const fileRecords = getCachedFileRecords(filePath);
94
+ for (const r of fileRecords) {
95
+ records.push(this._convertToUsageRecord(r, projName, sessionIdFromFile));
96
+ }
97
+ } catch {}
98
+
99
+ // 子 agent 日志
100
+ try {
101
+ const subRecords = this._parseSubagentFiles(dirname(filePath));
102
+ for (const r of subRecords) {
103
+ records.push(this._convertToUsageRecord(r, projName, sessionIdFromFile));
104
+ }
105
+ } catch {}
106
+ }
107
+
108
+ // 当无主 JSONL 文件时,扫描 sessions-index.json 和 UUID 子目录
109
+ if (jsonlFiles.length === 0) {
110
+ // 解析 sessions-index.json 生成会话元数据记录
111
+ const indexRecords = this._parseSessionsIndex(projPath, projName);
112
+ records.push(...indexRecords);
113
+
114
+ // 扫描 UUID 子目录中的 subagent JSONL 文件
115
+ const uuidDirs = allEntries.filter(d => {
116
+ const full = join(projPath, d);
117
+ try { return statSync(full).isDirectory() && /^[0-9a-f]{8}-/i.test(d); } catch { return false; }
118
+ });
119
+ for (const uuidDir of uuidDirs) {
120
+ const sessionDir = join(projPath, uuidDir);
121
+ try {
122
+ const subRecords = this._parseSubagentFiles(sessionDir);
123
+ for (const r of subRecords) {
124
+ records.push(this._convertToUsageRecord(r, projName, uuidDir));
125
+ }
126
+ } catch {}
127
+ }
128
+ }
129
+ }
130
+
131
+ return records;
132
+ }
133
+
134
+ _convertToUsageRecord(raw, projectDir, fallbackSessionId = '') {
135
+ return createUsageRecord({
136
+ timestamp: raw.timestamp || '',
137
+ tool: 'claude',
138
+ sessionId: raw.sessionId || fallbackSessionId || '',
139
+ model: raw.model || '',
140
+ inputTokens: raw.tokens?.input || 0,
141
+ outputTokens: raw.tokens?.output || 0,
142
+ cacheReadTokens: raw.tokens?.cacheRead || 0,
143
+ cacheWriteTokens: raw.tokens?.cacheCreate || 0,
144
+ costUSD: raw.costUSD ?? null,
145
+ project: projectDir || '',
146
+ metadata: {
147
+ type: raw.type,
148
+ role: raw.role,
149
+ text: raw.text,
150
+ toolCalls: raw.toolCalls,
151
+ isSubagent: raw.isSubagent,
152
+ isSidechain: raw.isSidechain,
153
+ speed: raw.speed,
154
+ },
155
+ });
156
+ }
157
+
158
+ _parseSubagentFiles(sessionDir) {
159
+ const subagentsDir = join(sessionDir, 'subagents');
160
+ const records = [];
161
+ try {
162
+ if (!statSync(subagentsDir).isDirectory()) return records;
163
+ } catch {
164
+ return records;
165
+ }
166
+
167
+ const files = readdirSync(subagentsDir).filter(f => f.endsWith('.jsonl'));
168
+ for (const file of files) {
169
+ try {
170
+ const content = readFileSync(join(subagentsDir, file), 'utf-8');
171
+ for (const line of content.split('\n')) {
172
+ const trimmed = line.trim();
173
+ if (!trimmed) continue;
174
+ try {
175
+ const obj = JSON.parse(trimmed);
176
+ if (obj.type === 'user' || obj.type === 'assistant') {
177
+ if (obj.isApiErrorMessage === true) continue;
178
+ records.push(this._normalizeRawRecord(obj));
179
+ }
180
+ } catch {}
181
+ }
182
+ } catch {}
183
+ }
184
+
185
+ for (const r of records) {
186
+ r.isSubagent = true;
187
+ }
188
+ return records;
189
+ }
190
+
191
+ // 解析 sessions-index.json,从会话索引生成元数据记录
192
+ _parseSessionsIndex(projPath, projName) {
193
+ const indexPath = join(projPath, 'sessions-index.json');
194
+ if (!existsSync(indexPath)) return [];
195
+
196
+ const records = [];
197
+ try {
198
+ const raw = JSON.parse(readFileSync(indexPath, 'utf-8'));
199
+ const entries = raw?.entries || [];
200
+ for (const entry of entries) {
201
+ if (!entry.sessionId) continue;
202
+ // 检查对应的 .jsonl 是否还存在,存在则跳过(由主流程解析)
203
+ const jsonlPath = entry.fullPath || join(projPath, entry.sessionId + '.jsonl');
204
+ if (existsSync(jsonlPath)) continue;
205
+
206
+ // 从索引条目创建占位 user+assistant 记录,确保会话被统计
207
+ const ts = entry.created || entry.modified || '';
208
+ records.push({
209
+ type: 'user',
210
+ role: 'user',
211
+ timestamp: ts,
212
+ model: '',
213
+ text: entry.firstPrompt || entry.summary || '',
214
+ toolCalls: [],
215
+ sessionId: entry.sessionId,
216
+ cwd: entry.projectPath || '',
217
+ gitBranch: entry.gitBranch || '',
218
+ project: '',
219
+ tokens: { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 },
220
+ isSidechain: false,
221
+ isSubagent: false,
222
+ messageId: '',
223
+ requestId: '',
224
+ costUSD: null,
225
+ isApiError: false,
226
+ speed: 'standard',
227
+ _fromIndex: true,
228
+ });
229
+ if (entry.messageCount > 0) {
230
+ records.push({
231
+ type: 'assistant',
232
+ role: 'assistant',
233
+ timestamp: entry.modified || ts,
234
+ model: '',
235
+ text: '',
236
+ toolCalls: [],
237
+ sessionId: entry.sessionId,
238
+ cwd: entry.projectPath || '',
239
+ gitBranch: entry.gitBranch || '',
240
+ project: '',
241
+ tokens: { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 },
242
+ isSidechain: false,
243
+ isSubagent: false,
244
+ messageId: '',
245
+ requestId: '',
246
+ costUSD: null,
247
+ isApiError: false,
248
+ speed: 'standard',
249
+ _fromIndex: true,
250
+ });
251
+ }
252
+ }
253
+ } catch {}
254
+ return records;
255
+ }
256
+
257
+ _normalizeRawRecord(obj) {
258
+ const msg = obj.message || {};
259
+ const content = msg.content || '';
260
+ let text = '';
261
+ let toolCalls = [];
262
+
263
+ if (typeof content === 'string') {
264
+ text = content;
265
+ } else if (Array.isArray(content)) {
266
+ for (const c of content) {
267
+ if (!c || typeof c !== 'object') continue;
268
+ if (c.type === 'text') text += (c.text || '');
269
+ if (c.type === 'tool_use') {
270
+ toolCalls.push({ name: c.name || '', input: c.input || {} });
271
+ }
272
+ }
273
+ text = text.trim();
274
+ }
275
+
276
+ const usage = obj.usage || msg.usage || {};
277
+
278
+ return {
279
+ type: obj.type,
280
+ role: msg.role || '',
281
+ timestamp: obj.timestamp || '',
282
+ model: msg.model || '',
283
+ text: text.trim(),
284
+ toolCalls,
285
+ sessionId: obj.sessionId || '',
286
+ cwd: obj.cwd || '',
287
+ gitBranch: obj.gitBranch || '',
288
+ project: '',
289
+ tokens: {
290
+ input: usage.input_tokens || 0,
291
+ output: usage.output_tokens || 0,
292
+ cacheCreate: usage.cache_creation_input_tokens || usage.cache_creation?.ephemeral_5m_input_tokens || 0,
293
+ cacheRead: usage.cache_read_input_tokens || 0,
294
+ },
295
+ isSidechain: obj.isSidechain || false,
296
+ isSubagent: false,
297
+ messageId: msg.id || '',
298
+ requestId: obj.requestId || '',
299
+ costUSD: obj.costUSD ?? null,
300
+ isApiError: obj.isApiErrorMessage || false,
301
+ speed: usage.speed || 'standard',
302
+ };
303
+ }
304
+
305
+ async getVersion() {
306
+ try {
307
+ const { execSync } = await import('child_process');
308
+ const cmd = process.platform === 'win32' ? 'claude --version' : 'claude --version 2>/dev/null';
309
+ const out = execSync(cmd, { encoding: 'utf8', timeout: 5000 }).trim();
310
+ const m = out.match(/(\d+\.\d+\.\d+)/);
311
+ return m ? m[1] : out.split(' ')[0];
312
+ } catch {
313
+ return null;
314
+ }
315
+ }
316
+ }