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,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
+ }