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.
- package/README.md +271 -0
- package/data/pricing.json +3708 -0
- package/index.js +266 -0
- package/lib/aggregate.js +626 -0
- package/lib/attribution.js +137 -0
- package/lib/blocks.js +86 -0
- package/lib/cache.js +18 -0
- package/lib/config.js +91 -0
- package/lib/git.js +1106 -0
- package/lib/models/usage-record.js +46 -0
- package/lib/parser.js +160 -0
- package/lib/parsers/base.js +67 -0
- package/lib/parsers/claude.js +316 -0
- package/lib/parsers/codex.js +316 -0
- package/lib/parsers/index.js +151 -0
- package/lib/parsers/opencode.js +216 -0
- package/lib/pricing-loader.js +287 -0
- package/lib/record-utils.js +35 -0
- package/lib/report.js +1446 -0
- package/lib/scenario.js +183 -0
- package/lib/server.js +412 -0
- package/lib/table.js +67 -0
- package/package.json +44 -0
- package/public/api.js +109 -0
- package/public/app.js +647 -0
- package/public/charts.js +197 -0
- package/public/config.js +141 -0
- package/public/export.js +282 -0
- package/public/fonts/inter-0.woff2 +0 -0
- package/public/fonts/inter-1.woff2 +0 -0
- package/public/fonts/inter-2.woff2 +0 -0
- package/public/fonts/inter-3.woff2 +0 -0
- package/public/fonts/inter.css +28 -0
- package/public/git-insights.js +123 -0
- package/public/index.html +347 -0
- package/public/style.css +1864 -0
- package/public/ui-state.js +103 -0
- package/public/utils.js +71 -0
- package/public/vendor/alpine.min.js +5 -0
- package/public/vendor/chart.umd.min.js +20 -0
- package/public/work-report.js +118 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
|
|
2
|
+
import { join, basename, dirname } from 'path';
|
|
3
|
+
import { BaseParser } from './base.js';
|
|
4
|
+
import { createUsageRecord } from '../models/usage-record.js';
|
|
5
|
+
|
|
6
|
+
export class CodexParser extends BaseParser {
|
|
7
|
+
getInfo() {
|
|
8
|
+
return {
|
|
9
|
+
name: 'codex',
|
|
10
|
+
displayName: 'OpenAI Codex',
|
|
11
|
+
defaultDir: '~/.codex',
|
|
12
|
+
envVar: 'CODEX_HOME',
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async detect(config) {
|
|
17
|
+
const dir = this.getDataDir(config);
|
|
18
|
+
if (!dir) return false;
|
|
19
|
+
try {
|
|
20
|
+
const sessionsDir = join(dir, 'sessions');
|
|
21
|
+
return statSync(sessionsDir).isDirectory();
|
|
22
|
+
} catch {
|
|
23
|
+
try {
|
|
24
|
+
const archivedDir = join(dir, 'archived_sessions');
|
|
25
|
+
return statSync(archivedDir).isDirectory();
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async parse(config, options = {}) {
|
|
33
|
+
const dir = this.getDataDir(config);
|
|
34
|
+
const records = [];
|
|
35
|
+
if (!dir) return records;
|
|
36
|
+
|
|
37
|
+
const files = this._collectJsonlFiles(dir);
|
|
38
|
+
const parsedSessionIds = new Set();
|
|
39
|
+
|
|
40
|
+
for (const filePath of files) {
|
|
41
|
+
try {
|
|
42
|
+
const fileRecords = this._parseFile(filePath);
|
|
43
|
+
for (const r of fileRecords) {
|
|
44
|
+
if (r.sessionId) parsedSessionIds.add(r.sessionId);
|
|
45
|
+
records.push(r);
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.warn(`Codex 解析文件失败: ${filePath}`, err.message);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 从 state DB 的 threads 表补充缺失的会话(JSONL 被清理/归档的场景)
|
|
53
|
+
const fallbackRecords = await this._parseStateDb(dir, parsedSessionIds);
|
|
54
|
+
records.push(...fallbackRecords);
|
|
55
|
+
|
|
56
|
+
return records;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_collectJsonlFiles(dir) {
|
|
60
|
+
const files = [];
|
|
61
|
+
const sessionsDir = join(dir, 'sessions');
|
|
62
|
+
try {
|
|
63
|
+
if (statSync(sessionsDir).isDirectory()) {
|
|
64
|
+
files.push(...this._walkDir(sessionsDir));
|
|
65
|
+
}
|
|
66
|
+
} catch {}
|
|
67
|
+
|
|
68
|
+
const archivedDir = join(dir, 'archived_sessions');
|
|
69
|
+
try {
|
|
70
|
+
if (statSync(archivedDir).isDirectory()) {
|
|
71
|
+
const archived = readdirSync(archivedDir).filter(f => f.endsWith('.jsonl'));
|
|
72
|
+
for (const f of archived) {
|
|
73
|
+
files.push(join(archivedDir, f));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch {}
|
|
77
|
+
|
|
78
|
+
return files;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_walkDir(dir) {
|
|
82
|
+
const results = [];
|
|
83
|
+
try {
|
|
84
|
+
const entries = readdirSync(dir);
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
const fullPath = join(dir, entry);
|
|
87
|
+
try {
|
|
88
|
+
const stat = statSync(fullPath);
|
|
89
|
+
if (stat.isDirectory()) {
|
|
90
|
+
results.push(...this._walkDir(fullPath));
|
|
91
|
+
} else if (entry.endsWith('.jsonl')) {
|
|
92
|
+
results.push(fullPath);
|
|
93
|
+
}
|
|
94
|
+
} catch {}
|
|
95
|
+
}
|
|
96
|
+
} catch {}
|
|
97
|
+
return results;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_looksLikeNonProject(name) {
|
|
101
|
+
if (!name || name.length < 2) return true;
|
|
102
|
+
// 日期格式:2026-05-20, 20260520
|
|
103
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(name)) return true;
|
|
104
|
+
if (/^\d{8}$/.test(name)) return true;
|
|
105
|
+
// 纯数字(如 20)
|
|
106
|
+
if (/^\d+$/.test(name)) return true;
|
|
107
|
+
// Hash:16-64 位十六进制
|
|
108
|
+
if (/^[0-9a-f]{16,64}$/i.test(name)) return true;
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
_inferProject(filePath) {
|
|
113
|
+
let dir = dirname(filePath);
|
|
114
|
+
const root = dirname(dirname(filePath));
|
|
115
|
+
while (dir !== root && dir !== dirname(dir)) {
|
|
116
|
+
const dirName = basename(dir);
|
|
117
|
+
if (dirName === 'sessions' || dirName === 'archived_sessions') {
|
|
118
|
+
return '';
|
|
119
|
+
}
|
|
120
|
+
if (!this._looksLikeNonProject(dirName)) {
|
|
121
|
+
return dirName;
|
|
122
|
+
}
|
|
123
|
+
dir = dirname(dir);
|
|
124
|
+
}
|
|
125
|
+
return '';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_parseFile(filePath) {
|
|
129
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
130
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
131
|
+
|
|
132
|
+
let sessionId = '';
|
|
133
|
+
let currentModel = '';
|
|
134
|
+
let lastTokenUsage = null;
|
|
135
|
+
let project = '';
|
|
136
|
+
const records = [];
|
|
137
|
+
const pendingToolCalls = [];
|
|
138
|
+
const userTexts = [];
|
|
139
|
+
|
|
140
|
+
for (const line of lines) {
|
|
141
|
+
try {
|
|
142
|
+
const event = JSON.parse(line);
|
|
143
|
+
|
|
144
|
+
if (event.type === 'session_meta' && event.payload) {
|
|
145
|
+
if (event.payload.id) sessionId = event.payload.id;
|
|
146
|
+
if (event.payload.project) {
|
|
147
|
+
project = event.payload.project;
|
|
148
|
+
} else if (event.payload.cwd) {
|
|
149
|
+
project = event.payload.cwd.replace(/\\/g, '/');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (event.type === 'turn_context' && event.payload?.model) {
|
|
154
|
+
currentModel = event.payload.model;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 提取用户消息文本(用于场景分类)
|
|
158
|
+
if (event.type === 'response_item' && event.payload?.role === 'user') {
|
|
159
|
+
const text = this._extractText(event.payload.content);
|
|
160
|
+
if (text && !text.startsWith('<system-reminder') && !text.startsWith('# AGENTS.md')) {
|
|
161
|
+
userTexts.push(text);
|
|
162
|
+
records.push(createUsageRecord({
|
|
163
|
+
timestamp: event.timestamp || new Date().toISOString(),
|
|
164
|
+
tool: 'codex',
|
|
165
|
+
sessionId: sessionId || basename(filePath, '.jsonl'),
|
|
166
|
+
model: '',
|
|
167
|
+
inputTokens: 0,
|
|
168
|
+
outputTokens: 0,
|
|
169
|
+
project: project || this._inferProject(filePath),
|
|
170
|
+
metadata: { type: 'user', text },
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 收集工具调用
|
|
176
|
+
if (event.type === 'response_item' && event.payload?.type === 'function_call') {
|
|
177
|
+
pendingToolCalls.push({ name: event.payload.name || 'unknown' });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (event.type === 'event_msg' && event.payload?.type === 'token_count') {
|
|
181
|
+
const info = event.payload.info;
|
|
182
|
+
if (!info || !info.total_token_usage) continue;
|
|
183
|
+
|
|
184
|
+
const total = info.total_token_usage;
|
|
185
|
+
const current = {
|
|
186
|
+
input: total.input_tokens || 0,
|
|
187
|
+
output: total.output_tokens || 0,
|
|
188
|
+
cachedInput: total.cached_input_tokens || 0,
|
|
189
|
+
cacheCreation: total.cache_creation_input_tokens || 0,
|
|
190
|
+
reasoningOutput: total.reasoning_output_tokens || 0,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
let delta = { ...current };
|
|
194
|
+
if (lastTokenUsage) {
|
|
195
|
+
delta.input = Math.max(0, current.input - lastTokenUsage.input);
|
|
196
|
+
delta.output = Math.max(0, current.output - lastTokenUsage.output);
|
|
197
|
+
delta.cachedInput = Math.max(0, current.cachedInput - lastTokenUsage.cachedInput);
|
|
198
|
+
delta.cacheCreation = Math.max(0, current.cacheCreation - lastTokenUsage.cacheCreation);
|
|
199
|
+
}
|
|
200
|
+
lastTokenUsage = current;
|
|
201
|
+
|
|
202
|
+
if (delta.input > 0 || delta.output > 0) {
|
|
203
|
+
records.push(createUsageRecord({
|
|
204
|
+
timestamp: event.timestamp || new Date().toISOString(),
|
|
205
|
+
tool: 'codex',
|
|
206
|
+
sessionId: sessionId || basename(filePath, '.jsonl'),
|
|
207
|
+
model: currentModel || 'gpt-5',
|
|
208
|
+
inputTokens: delta.input,
|
|
209
|
+
outputTokens: delta.output,
|
|
210
|
+
cacheReadTokens: delta.cachedInput,
|
|
211
|
+
cacheWriteTokens: delta.cacheCreation,
|
|
212
|
+
costUSD: null,
|
|
213
|
+
project: project || this._inferProject(filePath),
|
|
214
|
+
metadata: {
|
|
215
|
+
type: 'assistant',
|
|
216
|
+
toolCalls: pendingToolCalls.splice(0),
|
|
217
|
+
reasoningOutputTokens: delta.reasoningOutput,
|
|
218
|
+
isFallback: !currentModel,
|
|
219
|
+
},
|
|
220
|
+
}));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch {}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return records;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
_extractText(content) {
|
|
230
|
+
if (!content) return '';
|
|
231
|
+
if (typeof content === 'string') return content.trim();
|
|
232
|
+
if (Array.isArray(content)) {
|
|
233
|
+
return content
|
|
234
|
+
.filter(c => c && (c.type === 'input_text' || c.type === 'text'))
|
|
235
|
+
.map(c => c.text || '')
|
|
236
|
+
.join(' ')
|
|
237
|
+
.trim();
|
|
238
|
+
}
|
|
239
|
+
return '';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 从 state_*.sqlite 的 threads 表提取 JSONL 已丢失的会话元数据
|
|
243
|
+
async _parseStateDb(dir, alreadyParsed) {
|
|
244
|
+
const records = [];
|
|
245
|
+
try {
|
|
246
|
+
// 查找最新版本的 state DB
|
|
247
|
+
const entries = readdirSync(dir);
|
|
248
|
+
const stateDbs = entries
|
|
249
|
+
.filter(f => /^state_\d+\.sqlite$/.test(f))
|
|
250
|
+
.sort()
|
|
251
|
+
.reverse();
|
|
252
|
+
if (stateDbs.length === 0) return records;
|
|
253
|
+
|
|
254
|
+
const dbPath = join(dir, stateDbs[0]);
|
|
255
|
+
if (!existsSync(dbPath)) return records;
|
|
256
|
+
|
|
257
|
+
const initSqlJs = (await import('sql.js')).default;
|
|
258
|
+
const SQL = await initSqlJs();
|
|
259
|
+
const dbBuf = readFileSync(dbPath);
|
|
260
|
+
const db = new SQL.Database(dbBuf);
|
|
261
|
+
|
|
262
|
+
const rows = db.exec(
|
|
263
|
+
`SELECT id, cwd, tokens_used, title, git_branch, model,
|
|
264
|
+
created_at_ms, updated_at_ms, first_user_message, archived
|
|
265
|
+
FROM threads`
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
if (rows[0]) {
|
|
269
|
+
for (const [sid, cwd, tokens, title, gitBranch, model, createdMs, updatedMs, firstMsg, archived] of rows[0].values) {
|
|
270
|
+
if (!sid || alreadyParsed.has(sid)) continue;
|
|
271
|
+
const project = (cwd || '').replace(/\\/g, '/');
|
|
272
|
+
const ts = createdMs ? new Date(createdMs).toISOString() : '';
|
|
273
|
+
const tsEnd = updatedMs ? new Date(updatedMs).toISOString() : ts;
|
|
274
|
+
|
|
275
|
+
// User record
|
|
276
|
+
records.push(createUsageRecord({
|
|
277
|
+
timestamp: ts,
|
|
278
|
+
tool: 'codex',
|
|
279
|
+
sessionId: sid,
|
|
280
|
+
model: '',
|
|
281
|
+
inputTokens: 0,
|
|
282
|
+
outputTokens: 0,
|
|
283
|
+
project,
|
|
284
|
+
metadata: { type: 'user', text: firstMsg || title || '', _fromStateDb: true },
|
|
285
|
+
}));
|
|
286
|
+
// Assistant record with total tokens
|
|
287
|
+
if (tokens > 0) {
|
|
288
|
+
records.push(createUsageRecord({
|
|
289
|
+
timestamp: tsEnd,
|
|
290
|
+
tool: 'codex',
|
|
291
|
+
sessionId: sid,
|
|
292
|
+
model: model || '',
|
|
293
|
+
inputTokens: tokens,
|
|
294
|
+
outputTokens: 0,
|
|
295
|
+
project,
|
|
296
|
+
metadata: { type: 'assistant', _fromStateDb: true, gitBranch },
|
|
297
|
+
}));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
db.close();
|
|
302
|
+
} catch {}
|
|
303
|
+
return records;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async getVersion(config) {
|
|
307
|
+
const dir = this.getDataDir(config);
|
|
308
|
+
if (!dir) return null;
|
|
309
|
+
try {
|
|
310
|
+
const data = JSON.parse(readFileSync(join(dir, 'version.json'), 'utf8'));
|
|
311
|
+
return data.latest_version || null;
|
|
312
|
+
} catch {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { BaseParser } from './base.js';
|
|
2
|
+
|
|
3
|
+
// 解析器注册表 - 新增工具时在此注册
|
|
4
|
+
const PARSER_REGISTRY = [];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 注册解析器类
|
|
8
|
+
* @param {typeof BaseParser} ParserClass
|
|
9
|
+
*/
|
|
10
|
+
export function registerParser(ParserClass) {
|
|
11
|
+
if (!(ParserClass.prototype instanceof BaseParser)) {
|
|
12
|
+
throw new Error('注册的解析器必须继承 BaseParser');
|
|
13
|
+
}
|
|
14
|
+
PARSER_REGISTRY.push(ParserClass);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 获取所有已注册的解析器实例
|
|
19
|
+
* @returns {BaseParser[]}
|
|
20
|
+
*/
|
|
21
|
+
export function getAllParsers() {
|
|
22
|
+
return PARSER_REGISTRY.map(P => new P());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 检测哪些工具可用(数据目录存在)
|
|
27
|
+
* @param {Object} config
|
|
28
|
+
* @returns {Promise<Array<{name, displayName, detected, dataDir}>>}
|
|
29
|
+
*/
|
|
30
|
+
export async function detectAvailableTools(config) {
|
|
31
|
+
const results = [];
|
|
32
|
+
for (const P of PARSER_REGISTRY) {
|
|
33
|
+
const parser = new P();
|
|
34
|
+
const info = parser.getInfo();
|
|
35
|
+
const dataDir = parser.getDataDir(config);
|
|
36
|
+
let detected = false;
|
|
37
|
+
try {
|
|
38
|
+
detected = await parser.detect(config);
|
|
39
|
+
} catch {
|
|
40
|
+
detected = false;
|
|
41
|
+
}
|
|
42
|
+
let version = null;
|
|
43
|
+
try {
|
|
44
|
+
version = await parser.getVersion(config);
|
|
45
|
+
} catch {}
|
|
46
|
+
results.push({
|
|
47
|
+
name: info.name,
|
|
48
|
+
displayName: info.displayName,
|
|
49
|
+
detected,
|
|
50
|
+
dataDir,
|
|
51
|
+
version,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return results;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 获取用户启用的工具列表
|
|
59
|
+
* @param {Object} config
|
|
60
|
+
* @param {Array} availableTools - detectAvailableTools 的结果
|
|
61
|
+
* @returns {Array<string>} 工具名称列表
|
|
62
|
+
*/
|
|
63
|
+
export function getEnabledTools(config, availableTools) {
|
|
64
|
+
if (config.enabledTools && config.enabledTools.length > 0) {
|
|
65
|
+
return config.enabledTools;
|
|
66
|
+
}
|
|
67
|
+
// 默认启用所有检测到的工具
|
|
68
|
+
return availableTools.filter(t => t.detected).map(t => t.name);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 解析指定工具的数据
|
|
73
|
+
* @param {string} toolName
|
|
74
|
+
* @param {Object} config
|
|
75
|
+
* @param {Object} options
|
|
76
|
+
* @returns {Promise<Array>}
|
|
77
|
+
*/
|
|
78
|
+
export async function parseTool(toolName, config, options = {}) {
|
|
79
|
+
for (const P of PARSER_REGISTRY) {
|
|
80
|
+
const parser = new P();
|
|
81
|
+
if (parser.getInfo().name === toolName) {
|
|
82
|
+
return parser.parse(config, options);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
throw new Error(`未找到工具解析器: ${toolName}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 统一的项目名规范化:取路径最后一段作为项目名
|
|
89
|
+
function normalizeProjectToBase(projectPath) {
|
|
90
|
+
if (!projectPath) return '';
|
|
91
|
+
const normalized = projectPath.replace(/\\/g, '/').replace(/\/$/, '');
|
|
92
|
+
const parts = normalized.split('/');
|
|
93
|
+
return parts[parts.length - 1] || '';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 解析所有已启用工具的数据
|
|
98
|
+
* @param {Object} config
|
|
99
|
+
* @param {Object} options
|
|
100
|
+
* @returns {Promise<{records: Array, toolBreakdown: Object}>}
|
|
101
|
+
*/
|
|
102
|
+
export async function parseAllEnabledTools(config, options = {}) {
|
|
103
|
+
const available = await detectAvailableTools(config);
|
|
104
|
+
const enabled = getEnabledTools(config, available);
|
|
105
|
+
let allRecords = [];
|
|
106
|
+
const toolBreakdown = {};
|
|
107
|
+
|
|
108
|
+
for (const toolName of enabled) {
|
|
109
|
+
try {
|
|
110
|
+
const records = await parseTool(toolName, config, options);
|
|
111
|
+
allRecords.push(...records);
|
|
112
|
+
toolBreakdown[toolName] = {
|
|
113
|
+
recordCount: records.length,
|
|
114
|
+
sessionCount: new Set(records.map(r => r.sessionId)).size,
|
|
115
|
+
};
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.warn(`解析工具 ${toolName} 失败:`, err.message);
|
|
118
|
+
toolBreakdown[toolName] = { recordCount: 0, sessionCount: 0, error: err.message };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 统一按 includeProjects 过滤
|
|
123
|
+
// Claude 记录已在解析器中通过 encoded 目录名精确匹配,直接放行
|
|
124
|
+
// Codex/OpenCode 记录通过 basename 匹配
|
|
125
|
+
if (options.includeProjects && options.includeProjects.length > 0) {
|
|
126
|
+
const allowedBases = new Set(options.includeProjects.map(p => normalizeProjectToBase(p)));
|
|
127
|
+
allRecords = allRecords.filter(r => {
|
|
128
|
+
if (r.tool === 'claude') return true;
|
|
129
|
+
const base = normalizeProjectToBase(r.project);
|
|
130
|
+
return !base || allowedBases.has(base);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 过滤后重新计算 toolBreakdown
|
|
135
|
+
const toolGroups = {};
|
|
136
|
+
for (const r of allRecords) {
|
|
137
|
+
const t = r.tool || 'claude';
|
|
138
|
+
if (!toolGroups[t]) toolGroups[t] = { recordCount: 0, sessions: new Set() };
|
|
139
|
+
toolGroups[t].recordCount++;
|
|
140
|
+
if (r.sessionId) toolGroups[t].sessions.add(r.sessionId);
|
|
141
|
+
}
|
|
142
|
+
const filteredBreakdown = {};
|
|
143
|
+
for (const [t, g] of Object.entries(toolGroups)) {
|
|
144
|
+
filteredBreakdown[t] = { recordCount: g.recordCount, sessionCount: g.sessions.size };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 按时间戳排序
|
|
148
|
+
allRecords.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
|
|
149
|
+
|
|
150
|
+
return { records: allRecords, toolBreakdown: filteredBreakdown };
|
|
151
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { BaseParser } from './base.js';
|
|
4
|
+
import { createUsageRecord } from '../models/usage-record.js';
|
|
5
|
+
|
|
6
|
+
// SQLite 数据库文件大小上限(500MB),超过则跳过解析避免 OOM
|
|
7
|
+
const MAX_DB_SIZE = 500 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
export class OpencodeParser extends BaseParser {
|
|
10
|
+
getInfo() {
|
|
11
|
+
return {
|
|
12
|
+
name: 'opencode',
|
|
13
|
+
displayName: 'OpenCode',
|
|
14
|
+
defaultDir: '~/.local/share/opencode',
|
|
15
|
+
envVar: 'OPENCODE_DATA_DIR',
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async detect(config) {
|
|
20
|
+
const dir = this.getDataDir(config);
|
|
21
|
+
if (!dir) return false;
|
|
22
|
+
try {
|
|
23
|
+
return existsSync(join(dir, 'opencode.db'));
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async parse(config, options = {}) {
|
|
30
|
+
const dir = this.getDataDir(config);
|
|
31
|
+
const records = [];
|
|
32
|
+
if (!dir) return records;
|
|
33
|
+
|
|
34
|
+
const dbPath = join(dir, 'opencode.db');
|
|
35
|
+
if (!existsSync(dbPath)) return records;
|
|
36
|
+
|
|
37
|
+
// 检查文件大小,避免大文件导致 Array buffer allocation failed
|
|
38
|
+
const fileSize = statSync(dbPath).size;
|
|
39
|
+
if (fileSize > MAX_DB_SIZE) {
|
|
40
|
+
console.warn(`OpenCode: opencode.db 过大 (${(fileSize / 1024 / 1024).toFixed(0)}MB),跳过解析`);
|
|
41
|
+
return records;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const initSqlJs = (await import('sql.js')).default;
|
|
46
|
+
const SQL = await initSqlJs();
|
|
47
|
+
const dbBuf = readFileSync(dbPath);
|
|
48
|
+
const db = new SQL.Database(dbBuf);
|
|
49
|
+
|
|
50
|
+
// 读取 session -> project 映射
|
|
51
|
+
const sessionMap = {};
|
|
52
|
+
// 不同版本 schema 不同,探测可用列
|
|
53
|
+
let sessCols;
|
|
54
|
+
try {
|
|
55
|
+
const colInfo = db.exec("PRAGMA table_info(session)");
|
|
56
|
+
sessCols = colInfo[0] ? colInfo[0].values.map(r => r[1]) : [];
|
|
57
|
+
} catch { sessCols = []; }
|
|
58
|
+
const hasPath = sessCols.includes('path');
|
|
59
|
+
const hasDir = sessCols.includes('directory');
|
|
60
|
+
const sessSelect = hasDir
|
|
61
|
+
? `SELECT id, directory${hasPath ? ', path' : ''} FROM session`
|
|
62
|
+
: 'SELECT id FROM session';
|
|
63
|
+
try {
|
|
64
|
+
const sessRows = db.exec(sessSelect);
|
|
65
|
+
if (sessRows[0]) {
|
|
66
|
+
for (const row of sessRows[0].values) {
|
|
67
|
+
const id = row[0];
|
|
68
|
+
const dir = hasDir ? (row[1] || '') : '';
|
|
69
|
+
const p = hasPath ? (row[2] || '') : '';
|
|
70
|
+
sessionMap[id] = (p || dir || '').replace(/\\/g, '/');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch {}
|
|
74
|
+
|
|
75
|
+
// 读取所有 message
|
|
76
|
+
const msgRows = db.exec(
|
|
77
|
+
"SELECT id, session_id, time_created, data FROM message ORDER BY time_created"
|
|
78
|
+
);
|
|
79
|
+
if (!msgRows[0]) { db.close(); return records; }
|
|
80
|
+
|
|
81
|
+
// 计算 delta tokens(message 中 tokens 是累计值)
|
|
82
|
+
let lastTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
83
|
+
|
|
84
|
+
for (const [msgId, sessionId, timeCreated, dataStr] of msgRows[0].values) {
|
|
85
|
+
let data;
|
|
86
|
+
try { data = JSON.parse(dataStr); } catch { continue; }
|
|
87
|
+
|
|
88
|
+
const role = data.role || '';
|
|
89
|
+
const timestamp = new Date(timeCreated).toISOString();
|
|
90
|
+
const project = sessionMap[sessionId] || '';
|
|
91
|
+
const model = data.modelID || data.model?.modelID || '';
|
|
92
|
+
|
|
93
|
+
// User messages for scenario classification
|
|
94
|
+
if (role === 'user') {
|
|
95
|
+
const text = this._extractUserText(db, msgId);
|
|
96
|
+
if (text) {
|
|
97
|
+
records.push(createUsageRecord({
|
|
98
|
+
timestamp,
|
|
99
|
+
tool: 'opencode',
|
|
100
|
+
sessionId: sessionId || '',
|
|
101
|
+
model: '',
|
|
102
|
+
inputTokens: 0,
|
|
103
|
+
outputTokens: 0,
|
|
104
|
+
project,
|
|
105
|
+
metadata: { type: 'user', text },
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Assistant messages with token usage
|
|
112
|
+
if (role === 'assistant' && data.tokens) {
|
|
113
|
+
const t = data.tokens;
|
|
114
|
+
const current = {
|
|
115
|
+
input: t.input || 0,
|
|
116
|
+
output: t.output || 0,
|
|
117
|
+
cacheRead: t.cache?.read || 0,
|
|
118
|
+
cacheWrite: t.cache?.write || 0,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const delta = {
|
|
122
|
+
input: Math.max(0, current.input - lastTokens.input),
|
|
123
|
+
output: Math.max(0, current.output - lastTokens.output),
|
|
124
|
+
cacheRead: Math.max(0, current.cacheRead - lastTokens.cacheRead),
|
|
125
|
+
cacheWrite: Math.max(0, current.cacheWrite - lastTokens.cacheWrite),
|
|
126
|
+
};
|
|
127
|
+
lastTokens = current;
|
|
128
|
+
|
|
129
|
+
// Collect tool calls from parts
|
|
130
|
+
const toolCalls = this._extractToolCalls(db, msgId);
|
|
131
|
+
|
|
132
|
+
if (delta.input > 0 || delta.output > 0) {
|
|
133
|
+
records.push(createUsageRecord({
|
|
134
|
+
timestamp,
|
|
135
|
+
tool: 'opencode',
|
|
136
|
+
sessionId: sessionId || '',
|
|
137
|
+
model,
|
|
138
|
+
inputTokens: delta.input,
|
|
139
|
+
outputTokens: delta.output,
|
|
140
|
+
cacheReadTokens: delta.cacheRead,
|
|
141
|
+
cacheWriteTokens: delta.cacheWrite,
|
|
142
|
+
costUSD: data.cost ?? null,
|
|
143
|
+
project,
|
|
144
|
+
metadata: {
|
|
145
|
+
type: 'assistant',
|
|
146
|
+
toolCalls,
|
|
147
|
+
reasoningOutputTokens: t.reasoning || 0,
|
|
148
|
+
},
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
db.close();
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.warn('OpenCode parse error:', err.message);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return records;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_extractUserText(db, msgId) {
|
|
163
|
+
try {
|
|
164
|
+
const rows = db.exec(
|
|
165
|
+
"SELECT data FROM part WHERE message_id = ? AND json_extract(data, '$.type') = 'text'",
|
|
166
|
+
[msgId]
|
|
167
|
+
);
|
|
168
|
+
if (rows[0]?.values?.length) {
|
|
169
|
+
const parts = [];
|
|
170
|
+
for (const [dataStr] of rows[0].values) {
|
|
171
|
+
const d = JSON.parse(dataStr);
|
|
172
|
+
if (d.text) parts.push(d.text);
|
|
173
|
+
}
|
|
174
|
+
return parts.join(' ').trim();
|
|
175
|
+
}
|
|
176
|
+
} catch {}
|
|
177
|
+
return '';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_extractToolCalls(db, msgId) {
|
|
181
|
+
const calls = [];
|
|
182
|
+
try {
|
|
183
|
+
const rows = db.exec(
|
|
184
|
+
"SELECT data FROM part WHERE message_id = ? AND json_extract(data, '$.type') = 'tool'",
|
|
185
|
+
[msgId]
|
|
186
|
+
);
|
|
187
|
+
if (rows[0]?.values) {
|
|
188
|
+
for (const [dataStr] of rows[0].values) {
|
|
189
|
+
const d = JSON.parse(dataStr);
|
|
190
|
+
const name = d.name || d.tool || 'unknown';
|
|
191
|
+
calls.push({ name });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} catch {}
|
|
195
|
+
return calls;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async getVersion(config) {
|
|
199
|
+
const dir = this.getDataDir(config);
|
|
200
|
+
if (!dir) return null;
|
|
201
|
+
const dbPath = join(dir, 'opencode.db');
|
|
202
|
+
if (!existsSync(dbPath)) return null;
|
|
203
|
+
try {
|
|
204
|
+
if (statSync(dbPath).size > MAX_DB_SIZE) return null;
|
|
205
|
+
const initSqlJs = (await import('sql.js')).default;
|
|
206
|
+
const SQL = await initSqlJs();
|
|
207
|
+
const dbBuf = readFileSync(dbPath);
|
|
208
|
+
const db = new SQL.Database(dbBuf);
|
|
209
|
+
const rows = db.exec("SELECT value FROM settings WHERE key = 'version'");
|
|
210
|
+
db.close();
|
|
211
|
+
return rows[0]?.values?.[0]?.[0] || null;
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|