@adversity/coding-tool-x 3.1.1 → 3.1.2

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/web/assets/{ConfigTemplates-ZrK_s7ma.js → ConfigTemplates-DvcbKKdS.js} +1 -1
  3. package/dist/web/assets/Home-BJKPCBuk.css +1 -0
  4. package/dist/web/assets/Home-Cw-F_Wnu.js +1 -0
  5. package/dist/web/assets/{PluginManager-BD7QUZbU.js → PluginManager-jy_4GVxI.js} +1 -1
  6. package/dist/web/assets/{ProjectList-DRb1DuHV.js → ProjectList-Df1-NcNr.js} +1 -1
  7. package/dist/web/assets/{SessionList-lZ0LKzfT.js → SessionList-UWcZtC2r.js} +1 -1
  8. package/dist/web/assets/{SkillManager-C1xG5B4Q.js → SkillManager-IRdseMKB.js} +1 -1
  9. package/dist/web/assets/{Terminal-DksBo_lM.js → Terminal-BasTyDut.js} +1 -1
  10. package/dist/web/assets/{WorkspaceManager-Burx7XOo.js → WorkspaceManager-D-D2kK1V.js} +1 -1
  11. package/dist/web/assets/index-CoB3zF0K.css +1 -0
  12. package/dist/web/assets/index-CryrSLv8.js +2 -0
  13. package/dist/web/index.html +2 -2
  14. package/package.json +1 -1
  15. package/src/config/default.js +2 -0
  16. package/src/config/model-metadata.js +415 -0
  17. package/src/config/model-pricing.js +23 -93
  18. package/src/server/api/opencode-channels.js +84 -6
  19. package/src/server/api/opencode-proxy.js +41 -32
  20. package/src/server/api/opencode-sessions.js +4 -62
  21. package/src/server/api/settings.js +111 -0
  22. package/src/server/codex-proxy-server.js +6 -4
  23. package/src/server/gemini-proxy-server.js +6 -4
  24. package/src/server/index.js +13 -4
  25. package/src/server/opencode-proxy-server.js +1197 -86
  26. package/src/server/proxy-server.js +6 -4
  27. package/src/server/services/codex-sessions.js +105 -6
  28. package/src/server/services/env-checker.js +24 -1
  29. package/src/server/services/env-manager.js +29 -1
  30. package/src/server/services/opencode-channels.js +3 -1
  31. package/src/server/services/opencode-sessions.js +486 -218
  32. package/src/server/services/opencode-settings-manager.js +172 -36
  33. package/src/server/services/response-decoder.js +21 -0
  34. package/src/server/websocket-server.js +24 -5
  35. package/dist/web/assets/Home-B8YfhZ3c.js +0 -1
  36. package/dist/web/assets/Home-Di2qsylF.css +0 -1
  37. package/dist/web/assets/index-Ufv5rCa5.css +0 -1
  38. package/dist/web/assets/index-lAkrRC3h.js +0 -2
@@ -1,15 +1,24 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const crypto = require('crypto');
4
+ const { execFileSync } = require('child_process');
4
5
  const { NATIVE_PATHS, PATHS } = require('../../config/paths');
5
6
 
6
7
  /**
7
8
  * OpenCode 会话服务
8
- * 读取 OpenCode CLI 的原生会话数据
9
+ * 读取 OpenCode SQLite 会话数据
9
10
  */
10
11
 
11
12
  const PROJECT_ORDER_FILE = path.join(PATHS.base, 'opencode-project-order.json');
12
13
  const SESSION_ORDER_FILE = path.join(PATHS.base, 'opencode-session-order.json');
14
+ const OPENCODE_DB_PATH = path.join(NATIVE_PATHS.opencode.data, 'opencode.db');
15
+ const COUNTS_CACHE_TTL_MS = 30 * 1000;
16
+ const EMPTY_COUNTS = Object.freeze({ projectCount: 0, sessionCount: 0 });
17
+
18
+ let countsCache = {
19
+ expiresAt: 0,
20
+ value: EMPTY_COUNTS
21
+ };
13
22
 
14
23
  function ensureParentDir(filePath) {
15
24
  const dir = path.dirname(filePath);
@@ -34,27 +43,6 @@ function writeJsonSafe(filePath, data) {
34
43
  fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
35
44
  }
36
45
 
37
- function copyDirectoryRecursive(sourceDir, targetDir) {
38
- if (!fs.existsSync(sourceDir)) {
39
- return;
40
- }
41
-
42
- if (!fs.existsSync(targetDir)) {
43
- fs.mkdirSync(targetDir, { recursive: true });
44
- }
45
-
46
- const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
47
- for (const entry of entries) {
48
- const sourcePath = path.join(sourceDir, entry.name);
49
- const targetPath = path.join(targetDir, entry.name);
50
- if (entry.isDirectory()) {
51
- copyDirectoryRecursive(sourcePath, targetPath);
52
- } else {
53
- fs.copyFileSync(sourcePath, targetPath);
54
- }
55
- }
56
- }
57
-
58
46
  function sortByOrder(items, order, fallbackCompare) {
59
47
  const fallbackSorted = [...items].sort(fallbackCompare);
60
48
  if (!Array.isArray(order) || order.length === 0) {
@@ -72,6 +60,17 @@ function sortByOrder(items, order, fallbackCompare) {
72
60
  });
73
61
  }
74
62
 
63
+ function parseJsonMaybe(raw, fallback = null) {
64
+ if (typeof raw !== 'string') {
65
+ return fallback;
66
+ }
67
+ try {
68
+ return JSON.parse(raw);
69
+ } catch (err) {
70
+ return fallback;
71
+ }
72
+ }
73
+
75
74
  function extractTextContent(content) {
76
75
  if (typeof content === 'string') {
77
76
  return content;
@@ -86,6 +85,116 @@ function extractTextContent(content) {
86
85
  return '';
87
86
  }
88
87
 
88
+ function extractTextFromPartData(partData) {
89
+ if (!partData || typeof partData !== 'object') {
90
+ return '';
91
+ }
92
+
93
+ if (typeof partData.text === 'string' && partData.text.trim()) {
94
+ return partData.text.trim();
95
+ }
96
+
97
+ if (typeof partData.content === 'string' && partData.content.trim()) {
98
+ return partData.content.trim();
99
+ }
100
+
101
+ if (Array.isArray(partData.content)) {
102
+ return partData.content
103
+ .filter(item => item && item.type === 'text' && typeof item.text === 'string')
104
+ .map(item => item.text)
105
+ .join('\n')
106
+ .trim();
107
+ }
108
+
109
+ return '';
110
+ }
111
+
112
+ function extractTextFromMessageData(messageData) {
113
+ if (!messageData || typeof messageData !== 'object') {
114
+ return '';
115
+ }
116
+
117
+ const contentText = extractTextContent(messageData.content);
118
+ if (contentText) {
119
+ return contentText;
120
+ }
121
+
122
+ if (typeof messageData.text === 'string' && messageData.text.trim()) {
123
+ return messageData.text.trim();
124
+ }
125
+
126
+ return '';
127
+ }
128
+
129
+ function normalizeTimestampMs(input) {
130
+ const value = Number(input);
131
+ if (!Number.isFinite(value) || value <= 0) {
132
+ return null;
133
+ }
134
+ return value > 1e12 ? value : value * 1000;
135
+ }
136
+
137
+ function toIsoTime(input) {
138
+ const ts = normalizeTimestampMs(input);
139
+ if (!ts) {
140
+ return null;
141
+ }
142
+ try {
143
+ return new Date(ts).toISOString();
144
+ } catch (err) {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function sqlQuote(value) {
150
+ if (value === null || value === undefined) {
151
+ return 'NULL';
152
+ }
153
+ if (typeof value === 'number') {
154
+ return Number.isFinite(value) ? String(Math.trunc(value)) : 'NULL';
155
+ }
156
+ if (typeof value === 'boolean') {
157
+ return value ? '1' : '0';
158
+ }
159
+ return `'${String(value).replace(/'/g, "''")}'`;
160
+ }
161
+
162
+ function runSqliteQuery(sql) {
163
+ if (!isOpenCodeInstalled()) {
164
+ return [];
165
+ }
166
+
167
+ try {
168
+ const output = execFileSync('sqlite3', ['-json', OPENCODE_DB_PATH, sql], {
169
+ encoding: 'utf8',
170
+ stdio: ['ignore', 'pipe', 'pipe'],
171
+ maxBuffer: 10 * 1024 * 1024
172
+ }).trim();
173
+
174
+ if (!output) {
175
+ return [];
176
+ }
177
+
178
+ const parsed = JSON.parse(output);
179
+ return Array.isArray(parsed) ? parsed : [];
180
+ } catch (err) {
181
+ console.error('[OpenCode Sessions] SQLite query failed:', err.message);
182
+ return [];
183
+ }
184
+ }
185
+
186
+ function runSqliteExec(sql) {
187
+ if (!isOpenCodeInstalled()) {
188
+ throw new Error('OpenCode CLI not installed');
189
+ }
190
+
191
+ execFileSync('sqlite3', [OPENCODE_DB_PATH, sql], {
192
+ encoding: 'utf8',
193
+ stdio: ['ignore', 'pipe', 'pipe'],
194
+ maxBuffer: 10 * 1024 * 1024
195
+ });
196
+ }
197
+
89
198
  function buildContext(text, keyword, contextLength = 35) {
90
199
  if (!text || !keyword) {
91
200
  return null;
@@ -96,24 +205,24 @@ function buildContext(text, keyword, contextLength = 35) {
96
205
  ? parsedContextLength
97
206
  : 35;
98
207
 
99
- const lowerText = text.toLowerCase();
100
- const lowerKeyword = keyword.toLowerCase();
208
+ const lowerText = String(text).toLowerCase();
209
+ const lowerKeyword = String(keyword).toLowerCase();
101
210
  const index = lowerText.indexOf(lowerKeyword);
102
211
  if (index === -1) {
103
212
  return null;
104
213
  }
105
214
 
106
215
  const start = Math.max(0, index - safeContextLength);
107
- const end = Math.min(text.length, index + keyword.length + safeContextLength);
108
- let context = text.slice(start, end);
216
+ const end = Math.min(lowerText.length, index + lowerKeyword.length + safeContextLength);
217
+ let context = String(text).slice(start, end);
109
218
  if (start > 0) context = `...${context}`;
110
- if (end < text.length) context = `${context}...`;
219
+ if (end < String(text).length) context = `${context}...`;
111
220
  return context;
112
221
  }
113
222
 
114
223
  // 检查 OpenCode 是否安装
115
224
  function isOpenCodeInstalled() {
116
- return fs.existsSync(NATIVE_PATHS.opencode.data);
225
+ return fs.existsSync(OPENCODE_DB_PATH);
117
226
  }
118
227
 
119
228
  // 获取 OpenCode 数据目录
@@ -121,25 +230,16 @@ function getOpenCodeDataDir() {
121
230
  return NATIVE_PATHS.opencode.data;
122
231
  }
123
232
 
124
- // 获取会话存储目录
233
+ // 兼容导出:保留旧路径函数
125
234
  function getSessionsDir() {
126
235
  return path.join(getOpenCodeDataDir(), 'storage', 'session');
127
236
  }
128
237
 
129
- // 获取项目存储目录
238
+ // 兼容导出:保留旧路径函数
130
239
  function getProjectsDir() {
131
240
  return path.join(getOpenCodeDataDir(), 'storage', 'project');
132
241
  }
133
242
 
134
- // 获取消息存储目录
135
- function getMessagesRootDir() {
136
- return NATIVE_PATHS.opencode.messages;
137
- }
138
-
139
- function getMessageDir(sessionId) {
140
- return path.join(getMessagesRootDir(), sessionId);
141
- }
142
-
143
243
  function getProjectOrder() {
144
244
  const order = readJsonSafe(PROJECT_ORDER_FILE, []);
145
245
  return Array.isArray(order) ? order : [];
@@ -202,43 +302,156 @@ function removeProjectFromOrder(projectId) {
202
302
  }
203
303
  }
204
304
 
205
- function getProjectEntries() {
206
- const projectsDir = getProjectsDir();
207
- if (!fs.existsSync(projectsDir)) {
208
- return [];
209
- }
305
+ function invalidateProjectAndSessionCountsCache() {
306
+ countsCache.expiresAt = 0;
307
+ }
210
308
 
211
- const files = fs.readdirSync(projectsDir).filter(file => file.endsWith('.json'));
212
- const entries = [];
213
- for (const file of files) {
214
- const filePath = path.join(projectsDir, file);
215
- const data = readJsonSafe(filePath, null);
216
- if (!data || !data.id) {
217
- continue;
218
- }
219
- entries.push({ filePath, data });
220
- }
221
- return entries;
309
+ function queryProjectAndSessionCounts() {
310
+ const rows = runSqliteQuery(`
311
+ SELECT
312
+ (SELECT COUNT(*) FROM project) AS project_count,
313
+ (SELECT COUNT(*) FROM session WHERE time_archived IS NULL) AS session_count
314
+ `);
315
+
316
+ const row = rows[0] || {};
317
+ return {
318
+ projectCount: Number(row.project_count) || 0,
319
+ sessionCount: Number(row.session_count) || 0
320
+ };
321
+ }
322
+
323
+ function getProjectRows() {
324
+ return runSqliteQuery(`
325
+ SELECT
326
+ p.id,
327
+ p.worktree,
328
+ p.name,
329
+ p.time_created,
330
+ p.time_updated,
331
+ COALESCE(s.session_count, 0) AS session_count
332
+ FROM project p
333
+ LEFT JOIN (
334
+ SELECT project_id, COUNT(*) AS session_count
335
+ FROM session
336
+ WHERE time_archived IS NULL
337
+ GROUP BY project_id
338
+ ) s ON s.project_id = p.id
339
+ `);
340
+ }
341
+
342
+ function getSessionRowsByProjectId(projectId) {
343
+ return runSqliteQuery(`
344
+ SELECT
345
+ s.id,
346
+ s.project_id,
347
+ s.parent_id,
348
+ s.slug,
349
+ s.directory,
350
+ s.title,
351
+ s.version,
352
+ s.share_url,
353
+ s.summary_additions,
354
+ s.summary_deletions,
355
+ s.summary_files,
356
+ s.summary_diffs,
357
+ s.revert,
358
+ s.permission,
359
+ s.time_created,
360
+ s.time_updated,
361
+ s.time_compacting,
362
+ s.time_archived
363
+ FROM session s
364
+ WHERE s.project_id = ${sqlQuote(projectId)}
365
+ AND s.time_archived IS NULL
366
+ ORDER BY s.time_updated DESC
367
+ `);
368
+ }
369
+
370
+ function getSessionRowById(sessionId) {
371
+ const rows = runSqliteQuery(`
372
+ SELECT
373
+ s.id,
374
+ s.project_id,
375
+ s.parent_id,
376
+ s.slug,
377
+ s.directory,
378
+ s.title,
379
+ s.version,
380
+ s.share_url,
381
+ s.summary_additions,
382
+ s.summary_deletions,
383
+ s.summary_files,
384
+ s.summary_diffs,
385
+ s.revert,
386
+ s.permission,
387
+ s.time_created,
388
+ s.time_updated,
389
+ s.time_compacting,
390
+ s.time_archived
391
+ FROM session s
392
+ WHERE s.id = ${sqlQuote(sessionId)}
393
+ LIMIT 1
394
+ `);
395
+
396
+ return rows[0] || null;
397
+ }
398
+
399
+ function getMessageRowsBySessionId(sessionId) {
400
+ return runSqliteQuery(`
401
+ SELECT
402
+ id,
403
+ session_id,
404
+ time_created,
405
+ time_updated,
406
+ data
407
+ FROM message
408
+ WHERE session_id = ${sqlQuote(sessionId)}
409
+ ORDER BY time_created ASC
410
+ `);
411
+ }
412
+
413
+ function getPartRowsBySessionId(sessionId) {
414
+ return runSqliteQuery(`
415
+ SELECT
416
+ id,
417
+ message_id,
418
+ session_id,
419
+ time_created,
420
+ time_updated,
421
+ data
422
+ FROM part
423
+ WHERE session_id = ${sqlQuote(sessionId)}
424
+ ORDER BY time_created ASC
425
+ `);
426
+ }
427
+
428
+ function normalizeSession(session, projectId = null) {
429
+ return {
430
+ sessionId: session.id,
431
+ projectName: projectId || session.project_id,
432
+ mtime: toIsoTime(session.time_updated) || new Date().toISOString(),
433
+ size: 0,
434
+ filePath: '',
435
+ gitBranch: null,
436
+ firstMessage: session.title || session.slug || null,
437
+ forkedFrom: null,
438
+ directory: session.directory,
439
+ slug: session.slug,
440
+ source: 'opencode'
441
+ };
222
442
  }
223
443
 
224
444
  // 获取所有项目
225
445
  function getProjects() {
226
- const projects = [];
227
- const entries = getProjectEntries();
228
-
229
- for (const entry of entries) {
230
- const project = entry.data;
231
- const projectSessions = getSessionsByProjectId(project.id);
232
- projects.push({
233
- name: project.id,
234
- displayName: project.id,
235
- fullPath: project.worktree || '/',
236
- path: project.worktree || '/',
237
- sessionCount: projectSessions.length,
238
- lastUsed: project.time?.updated || project.time?.created || 0,
239
- source: 'opencode'
240
- });
241
- }
446
+ const projects = getProjectRows().map((project) => ({
447
+ name: project.id,
448
+ displayName: project.name || project.id,
449
+ fullPath: project.worktree || '/',
450
+ path: project.worktree || '/',
451
+ sessionCount: Number(project.session_count) || 0,
452
+ lastUsed: Number(project.time_updated) || Number(project.time_created) || 0,
453
+ source: 'opencode'
454
+ }));
242
455
 
243
456
  return sortByOrder(
244
457
  projects,
@@ -249,30 +462,13 @@ function getProjects() {
249
462
 
250
463
  // 根据项目ID获取会话列表
251
464
  function getSessionsByProjectId(projectId) {
252
- const sessionsDir = path.join(getSessionsDir(), projectId);
253
- const sessions = [];
254
-
255
- if (!fs.existsSync(sessionsDir)) {
256
- return sessions;
257
- }
258
-
259
- const files = fs.readdirSync(sessionsDir).filter(file => file.endsWith('.json'));
260
- for (const file of files) {
261
- const filePath = path.join(sessionsDir, file);
262
- try {
263
- const content = fs.readFileSync(filePath, 'utf8');
264
- const session = JSON.parse(content);
265
- sessions.push(normalizeSession(session, filePath, projectId));
266
- } catch (err) {
267
- console.error(`[OpenCode Sessions] Failed to parse session file ${file}:`, err);
268
- }
269
- }
465
+ const sessions = getSessionRowsByProjectId(projectId).map(session => normalizeSession(session, projectId));
466
+ const order = getSessionOrder(projectId);
270
467
 
271
468
  const fallbackSorted = sessions.sort(
272
469
  (a, b) => new Date(b.mtime).getTime() - new Date(a.mtime).getTime()
273
470
  );
274
471
 
275
- const order = getSessionOrder(projectId);
276
472
  if (order.length === 0) {
277
473
  return fallbackSorted;
278
474
  }
@@ -288,101 +484,115 @@ function getSessionsByProjectId(projectId) {
288
484
  });
289
485
  }
290
486
 
291
- // 归一化会话格式(与 Claude Code 格式一致)
292
- function normalizeSession(session, filePath, projectId = null) {
293
- const mtime = session.time?.updated
294
- ? new Date(session.time.updated).toISOString()
295
- : new Date().toISOString();
487
+ // 根据项目名获取会话列表
488
+ function getSessionsByProject(projectName) {
489
+ return getSessionsByProjectId(projectName);
490
+ }
296
491
 
297
- let size = 0;
298
- try {
299
- if (filePath && fs.existsSync(filePath)) {
300
- const stats = fs.statSync(filePath);
301
- size = stats.size;
302
- }
303
- } catch (err) {
304
- // 忽略错误
492
+ function getSessionLocation(sessionId) {
493
+ const session = getSessionRowById(sessionId);
494
+ if (!session) {
495
+ return null;
305
496
  }
306
-
307
497
  return {
308
- sessionId: session.id,
309
- projectName: projectId,
310
- mtime,
311
- size,
312
- filePath: filePath || '',
313
- gitBranch: null,
314
- firstMessage: session.title || session.slug || null,
315
- forkedFrom: null,
316
- directory: session.directory,
317
- slug: session.slug,
318
- source: 'opencode'
498
+ projectId: session.project_id,
499
+ sessionData: session
319
500
  };
320
501
  }
321
502
 
322
- // 根据项目名获取会话列表
323
- function getSessionsByProject(projectName) {
324
- return getSessionsByProjectId(projectName);
503
+ // 根据会话ID获取会话详情
504
+ function getSessionById(sessionId) {
505
+ const location = getSessionLocation(sessionId);
506
+ if (!location) {
507
+ return null;
508
+ }
509
+
510
+ return normalizeSession(location.sessionData, location.projectId);
325
511
  }
326
512
 
327
- function getSessionLocation(sessionId) {
328
- const sessionsRoot = getSessionsDir();
329
- if (!fs.existsSync(sessionsRoot)) {
330
- return null;
513
+ function buildSessionMessages(sessionId) {
514
+ const messages = getMessageRowsBySessionId(sessionId);
515
+ const parts = getPartRowsBySessionId(sessionId);
516
+
517
+ const partsByMessageId = new Map();
518
+ for (const part of parts) {
519
+ if (!partsByMessageId.has(part.message_id)) {
520
+ partsByMessageId.set(part.message_id, []);
521
+ }
522
+ partsByMessageId.get(part.message_id).push(part);
331
523
  }
332
524
 
333
- const projectDirs = fs.readdirSync(sessionsRoot, { withFileTypes: true });
334
- for (const projectDir of projectDirs) {
335
- if (!projectDir.isDirectory()) continue;
525
+ const converted = [];
336
526
 
337
- const projectPath = path.join(sessionsRoot, projectDir.name);
338
- const directPath = path.join(projectPath, `${sessionId}.json`);
339
- if (fs.existsSync(directPath)) {
340
- const sessionData = readJsonSafe(directPath, null);
341
- if (sessionData && sessionData.id === sessionId) {
342
- return { projectId: projectDir.name, sessionPath: directPath, sessionData };
343
- }
527
+ for (const row of messages) {
528
+ const messageData = parseJsonMaybe(row.data, {});
529
+ const role = messageData?.role;
530
+ if (role !== 'user' && role !== 'assistant') {
531
+ continue;
344
532
  }
345
533
 
346
- const files = fs.readdirSync(projectPath).filter(file => file.endsWith('.json'));
347
- for (const file of files) {
348
- const sessionPath = path.join(projectPath, file);
349
- const sessionData = readJsonSafe(sessionPath, null);
350
- if (sessionData && sessionData.id === sessionId) {
351
- return { projectId: projectDir.name, sessionPath, sessionData };
534
+ const messageParts = partsByMessageId.get(row.id) || [];
535
+ const partTexts = [];
536
+ for (const part of messageParts) {
537
+ const partData = parseJsonMaybe(part.data, null);
538
+ const text = extractTextFromPartData(partData);
539
+ if (text) {
540
+ partTexts.push(text);
352
541
  }
353
542
  }
543
+
544
+ const fallbackText = extractTextFromMessageData(messageData);
545
+ const content = partTexts.join('\n').trim() || fallbackText || '[空消息]';
546
+
547
+ const timestamp = toIsoTime(
548
+ messageData?.time?.created || row.time_created || row.time_updated
549
+ );
550
+
551
+ converted.push({
552
+ type: role,
553
+ role,
554
+ content,
555
+ timestamp,
556
+ model: role === 'assistant'
557
+ ? (messageData?.model?.modelID || messageData?.modelID || messageData?.model || 'opencode')
558
+ : null
559
+ });
354
560
  }
355
561
 
356
- return null;
562
+ return converted;
357
563
  }
358
564
 
359
- // 根据会话ID获取会话详情
360
- function getSessionById(sessionId) {
565
+ function getSessionMessages(sessionId) {
361
566
  const location = getSessionLocation(sessionId);
362
567
  if (!location) {
363
- return null;
568
+ throw new Error('Session not found');
364
569
  }
365
570
 
366
- return normalizeSession(location.sessionData, location.sessionPath, location.projectId);
571
+ return buildSessionMessages(sessionId).map(({ type, content, timestamp, model }) => ({
572
+ type,
573
+ content,
574
+ timestamp,
575
+ model
576
+ }));
367
577
  }
368
578
 
369
579
  // 获取项目和会话数量统计
370
580
  function getProjectAndSessionCounts() {
371
- try {
372
- const projects = getProjects();
373
- let sessionCount = 0;
374
-
375
- for (const project of projects) {
376
- sessionCount += project.sessionCount || 0;
377
- }
581
+ const now = Date.now();
582
+ if (countsCache.expiresAt > now) {
583
+ return countsCache.value;
584
+ }
378
585
 
379
- return {
380
- projectCount: projects.length,
381
- sessionCount
586
+ try {
587
+ const counts = queryProjectAndSessionCounts();
588
+ countsCache = {
589
+ value: counts,
590
+ expiresAt: now + COUNTS_CACHE_TTL_MS
382
591
  };
592
+ return counts;
383
593
  } catch (err) {
384
594
  console.error('[OpenCode Sessions] Failed to get counts:', err);
385
- return { projectCount: 0, sessionCount: 0 };
595
+ return countsCache.value || EMPTY_COUNTS;
386
596
  }
387
597
  }
388
598
 
@@ -420,12 +630,10 @@ function deleteSession(sessionId) {
420
630
  throw new Error('Session not found');
421
631
  }
422
632
 
423
- fs.unlinkSync(location.sessionPath);
424
-
425
- const messageDir = getMessageDir(sessionId);
426
- if (fs.existsSync(messageDir)) {
427
- fs.rmSync(messageDir, { recursive: true, force: true });
428
- }
633
+ runSqliteExec(`
634
+ PRAGMA foreign_keys = ON;
635
+ DELETE FROM session WHERE id = ${sqlQuote(sessionId)};
636
+ `);
429
637
 
430
638
  try {
431
639
  const { deleteAlias } = require('./alias');
@@ -450,6 +658,7 @@ function deleteSession(sessionId) {
450
658
  // ignore fork relation cleanup errors
451
659
  }
452
660
 
661
+ invalidateProjectAndSessionCountsCache();
453
662
  return { success: true, projectName: location.projectId, sessionId };
454
663
  }
455
664
 
@@ -459,28 +668,99 @@ function forkSession(sessionId) {
459
668
  throw new Error('Session not found');
460
669
  }
461
670
 
462
- const now = new Date().toISOString();
463
- const newSessionId = crypto.randomUUID();
464
671
  const source = location.sessionData;
465
- const nextSession = {
466
- ...source,
467
- id: newSessionId,
468
- time: {
469
- ...(source.time || {}),
470
- created: now,
471
- updated: now
672
+ const messages = getMessageRowsBySessionId(sessionId);
673
+ const parts = getPartRowsBySessionId(sessionId);
674
+ const now = Date.now();
675
+ const newSessionId = `ses_${crypto.randomUUID().replace(/-/g, '')}`;
676
+
677
+ const messageIdMap = new Map();
678
+ for (const message of messages) {
679
+ messageIdMap.set(message.id, `msg_${crypto.randomUUID().replace(/-/g, '')}`);
680
+ }
681
+
682
+ const statements = [];
683
+ statements.push('PRAGMA foreign_keys = ON;');
684
+ statements.push('BEGIN IMMEDIATE;');
685
+
686
+ statements.push(`
687
+ INSERT INTO session (
688
+ id, project_id, parent_id, slug, directory, title, version, share_url,
689
+ summary_additions, summary_deletions, summary_files, summary_diffs,
690
+ revert, permission, time_created, time_updated, time_compacting, time_archived
691
+ ) VALUES (
692
+ ${sqlQuote(newSessionId)},
693
+ ${sqlQuote(source.project_id)},
694
+ ${sqlQuote(source.parent_id)},
695
+ ${sqlQuote(source.slug)},
696
+ ${sqlQuote(source.directory)},
697
+ ${sqlQuote(source.title)},
698
+ ${sqlQuote(source.version)},
699
+ ${sqlQuote(source.share_url)},
700
+ ${sqlQuote(source.summary_additions)},
701
+ ${sqlQuote(source.summary_deletions)},
702
+ ${sqlQuote(source.summary_files)},
703
+ ${sqlQuote(source.summary_diffs)},
704
+ ${sqlQuote(source.revert)},
705
+ ${sqlQuote(source.permission)},
706
+ ${sqlQuote(now)},
707
+ ${sqlQuote(now)},
708
+ ${sqlQuote(source.time_compacting)},
709
+ NULL
710
+ );
711
+ `);
712
+
713
+ for (const message of messages) {
714
+ const newMessageId = messageIdMap.get(message.id);
715
+ const messageData = parseJsonMaybe(message.data, null);
716
+
717
+ let serializedData = message.data;
718
+ if (messageData && typeof messageData === 'object') {
719
+ if (typeof messageData.parentID === 'string' && messageIdMap.has(messageData.parentID)) {
720
+ messageData.parentID = messageIdMap.get(messageData.parentID);
721
+ }
722
+ if (typeof messageData.id === 'string') {
723
+ messageData.id = newMessageId;
724
+ }
725
+ serializedData = JSON.stringify(messageData);
472
726
  }
473
- };
474
727
 
475
- const targetPath = path.join(path.dirname(location.sessionPath), `${newSessionId}.json`);
476
- fs.writeFileSync(targetPath, JSON.stringify(nextSession, null, 2), 'utf8');
728
+ statements.push(`
729
+ INSERT INTO message (id, session_id, time_created, time_updated, data)
730
+ VALUES (
731
+ ${sqlQuote(newMessageId)},
732
+ ${sqlQuote(newSessionId)},
733
+ ${sqlQuote(message.time_created)},
734
+ ${sqlQuote(message.time_updated)},
735
+ ${sqlQuote(serializedData)}
736
+ );
737
+ `);
738
+ }
739
+
740
+ for (const part of parts) {
741
+ const newPartId = `prt_${crypto.randomUUID().replace(/-/g, '')}`;
742
+ const targetMessageId = messageIdMap.get(part.message_id);
743
+ if (!targetMessageId) {
744
+ continue;
745
+ }
477
746
 
478
- const sourceMessageDir = getMessageDir(sessionId);
479
- const targetMessageDir = getMessageDir(newSessionId);
480
- if (fs.existsSync(sourceMessageDir)) {
481
- copyDirectoryRecursive(sourceMessageDir, targetMessageDir);
747
+ statements.push(`
748
+ INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
749
+ VALUES (
750
+ ${sqlQuote(newPartId)},
751
+ ${sqlQuote(targetMessageId)},
752
+ ${sqlQuote(newSessionId)},
753
+ ${sqlQuote(part.time_created)},
754
+ ${sqlQuote(part.time_updated)},
755
+ ${sqlQuote(part.data)}
756
+ );
757
+ `);
482
758
  }
483
759
 
760
+ statements.push('COMMIT;');
761
+
762
+ runSqliteExec(statements.join('\n'));
763
+
484
764
  try {
485
765
  const { getForkRelations, saveForkRelations } = require('./sessions');
486
766
  const relations = getForkRelations();
@@ -493,35 +773,32 @@ function forkSession(sessionId) {
493
773
  const existingOrder = getSessionOrder(location.projectId);
494
774
  saveSessionOrder(location.projectId, [newSessionId, ...existingOrder.filter(id => id !== newSessionId)]);
495
775
 
776
+ invalidateProjectAndSessionCountsCache();
496
777
  return {
497
778
  success: true,
498
779
  newSessionId,
499
780
  forkedFrom: sessionId,
500
781
  projectName: location.projectId,
501
- newFilePath: targetPath
782
+ newFilePath: null
502
783
  };
503
784
  }
504
785
 
505
786
  function deleteProject(projectId) {
506
- const projectSessionDir = path.join(getSessionsDir(), projectId);
507
- if (!fs.existsSync(projectSessionDir)) {
787
+ const projectRows = runSqliteQuery(`
788
+ SELECT id FROM project WHERE id = ${sqlQuote(projectId)} LIMIT 1
789
+ `);
790
+
791
+ if (projectRows.length === 0) {
508
792
  throw new Error('Project not found');
509
793
  }
510
794
 
511
- const sessionFiles = fs.readdirSync(projectSessionDir).filter(file => file.endsWith('.json'));
512
- const deletedSessionIds = [];
795
+ const sessionRows = runSqliteQuery(`
796
+ SELECT id FROM session WHERE project_id = ${sqlQuote(projectId)}
797
+ `);
513
798
 
514
- for (const file of sessionFiles) {
515
- const sessionPath = path.join(projectSessionDir, file);
516
- const session = readJsonSafe(sessionPath, null);
517
- const sessionId = session?.id || path.basename(file, '.json');
518
- deletedSessionIds.push(sessionId);
519
-
520
- const messageDir = getMessageDir(sessionId);
521
- if (fs.existsSync(messageDir)) {
522
- fs.rmSync(messageDir, { recursive: true, force: true });
523
- }
799
+ const deletedSessionIds = sessionRows.map(row => row.id);
524
800
 
801
+ for (const sessionId of deletedSessionIds) {
525
802
  try {
526
803
  const { deleteAlias } = require('./alias');
527
804
  deleteAlias(sessionId);
@@ -530,14 +807,10 @@ function deleteProject(projectId) {
530
807
  }
531
808
  }
532
809
 
533
- fs.rmSync(projectSessionDir, { recursive: true, force: true });
534
-
535
- const projectEntries = getProjectEntries();
536
- for (const entry of projectEntries) {
537
- if (entry.data.id === projectId) {
538
- fs.rmSync(entry.filePath, { force: true });
539
- }
540
- }
810
+ runSqliteExec(`
811
+ PRAGMA foreign_keys = ON;
812
+ DELETE FROM project WHERE id = ${sqlQuote(projectId)};
813
+ `);
541
814
 
542
815
  removeProjectFromOrder(projectId);
543
816
 
@@ -555,6 +828,7 @@ function deleteProject(projectId) {
555
828
  // ignore relation cleanup errors
556
829
  }
557
830
 
831
+ invalidateProjectAndSessionCountsCache();
558
832
  return {
559
833
  success: true,
560
834
  projectName: projectId,
@@ -589,6 +863,7 @@ function searchSessions(keyword, contextLength = 35, projectFilter = null) {
589
863
  session.slug,
590
864
  session.directory
591
865
  ];
866
+
592
867
  for (const text of quickChecks) {
593
868
  const context = buildContext(text, searchKeyword, contextLength);
594
869
  if (context) {
@@ -600,26 +875,18 @@ function searchSessions(keyword, contextLength = 35, projectFilter = null) {
600
875
  }
601
876
  }
602
877
 
603
- const messageDir = getMessageDir(session.sessionId);
604
- if (fs.existsSync(messageDir)) {
605
- const messageFiles = fs.readdirSync(messageDir)
606
- .filter(file => file.endsWith('.json'))
607
- .sort();
608
- for (const messageFile of messageFiles) {
609
- const messagePath = path.join(messageDir, messageFile);
610
- const message = readJsonSafe(messagePath, null);
611
- if (!message) continue;
612
-
613
- const text = extractTextContent(message.content);
614
- const context = buildContext(text, searchKeyword, contextLength);
615
- if (!context) continue;
616
-
617
- matches.push({
618
- role: message.role === 'user' ? 'user' : 'assistant',
619
- context,
620
- timestamp: message.time?.created || null
621
- });
878
+ const sessionMessages = buildSessionMessages(session.sessionId);
879
+ for (const message of sessionMessages) {
880
+ const context = buildContext(message.content, searchKeyword, contextLength);
881
+ if (!context) {
882
+ continue;
622
883
  }
884
+
885
+ matches.push({
886
+ role: message.role,
887
+ context,
888
+ timestamp: message.timestamp
889
+ });
623
890
  }
624
891
 
625
892
  if (matches.length > 0) {
@@ -651,6 +918,7 @@ module.exports = {
651
918
  getSessionsByProject,
652
919
  getSessionsByProjectId,
653
920
  getSessionById,
921
+ getSessionMessages,
654
922
  getRecentSessions,
655
923
  normalizeSession,
656
924
  getProjectAndSessionCounts,