coding-tool-x 3.8.0 → 3.8.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 (53) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/README.md +4 -6
  3. package/dist/web/assets/{Analytics-8dRv1aYW.js → Analytics-COEUXmd4.js} +1 -1
  4. package/dist/web/assets/{ConfigTemplates-BQRXSICO.js → ConfigTemplates-DLLkS53H.js} +1 -1
  5. package/dist/web/assets/{Home-BtzWhKIa.js → Home-DCEyLzwX.js} +1 -1
  6. package/dist/web/assets/{PluginManager-BTa-Ygf6.js → PluginManager-Oe1ZHoMO.js} +1 -1
  7. package/dist/web/assets/{ProjectList-F_cB8ldA.js → ProjectList-CWjuAFof.js} +1 -1
  8. package/dist/web/assets/{SessionList-LOrbPQvu.js → SessionList-v3ehGg9U.js} +1 -1
  9. package/dist/web/assets/{SkillManager-BzI_t-yV.js → SkillManager-DUF_qP5S.js} +1 -1
  10. package/dist/web/assets/{WorkspaceManager-Db9RGiz7.js → WorkspaceManager-CjAWBosO.js} +1 -1
  11. package/dist/web/assets/index-D5hyHPj7.js +2 -0
  12. package/dist/web/index.html +1 -1
  13. package/docs/superpowers/plans/2026-08-10-codex-conversion-compatibility.md +251 -0
  14. package/docs/superpowers/plans/2026-08-11-windows-command-window-hiding.md +90 -0
  15. package/docs/superpowers/specs/2026-08-10-codex-conversion-compatibility-design.md +61 -0
  16. package/docs/superpowers/specs/2026-08-11-windows-command-window-hiding-design.md +44 -0
  17. package/package.json +2 -2
  18. package/src/commands/update.js +1 -1
  19. package/src/config/paths.js +1 -0
  20. package/src/server/api/codex-sessions.js +14 -14
  21. package/src/server/api/gemini-sessions.js +52 -14
  22. package/src/server/api/omp-projects.js +2 -2
  23. package/src/server/api/omp-sessions.js +61 -23
  24. package/src/server/api/sessions.js +44 -2
  25. package/src/server/opencode-proxy-server.js +55 -1037
  26. package/src/server/services/claude-wire.js +694 -0
  27. package/src/server/services/codex-sessions.js +41 -233
  28. package/src/server/services/codex-wire.js +219 -0
  29. package/src/server/services/dashboard-snapshot-worker.js +5 -5
  30. package/src/server/services/gemini-sessions.js +67 -157
  31. package/src/server/services/gemini-wire.js +759 -0
  32. package/src/server/services/model-detector.js +49 -131
  33. package/src/server/services/omp-channels.js +23 -4
  34. package/src/server/services/omp-gateway.js +29 -1
  35. package/src/server/services/omp-sessions.js +102 -105
  36. package/src/server/services/omp-settings-manager.js +27 -3
  37. package/src/server/services/opencode-gateway-adapters.js +22 -1147
  38. package/src/server/services/opencode-normalization.js +546 -0
  39. package/src/server/services/opencode-sessions.js +339 -546
  40. package/src/server/services/session-converter.js +4 -4
  41. package/src/server/services/session-history-adapters/claude.js +235 -0
  42. package/src/server/services/session-history-adapters/codex.js +158 -0
  43. package/src/server/services/session-history-adapters/gemini.js +292 -0
  44. package/src/server/services/session-history-adapters/index.js +20 -0
  45. package/src/server/services/session-history-adapters/omp.js +256 -0
  46. package/src/server/services/session-history-index.js +930 -0
  47. package/src/server/services/session-history-worker.js +99 -0
  48. package/src/server/services/session-snapshot-worker.js +7 -7
  49. package/src/server/services/sessions.js +167 -775
  50. package/src/server/services/speed-test.js +85 -303
  51. package/src/server/services/sqlite-connection.js +78 -0
  52. package/src/server/services/workspace-service.js +3 -3
  53. package/dist/web/assets/index-DIWJmyL6.js +0 -2
@@ -1,12 +1,12 @@
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
+ const { DatabaseSync } = require('node:sqlite');
5
5
  const { NATIVE_PATHS, PATHS } = require('../../config/paths');
6
6
 
7
7
  /**
8
8
  * OpenCode 会话服务
9
- * 读取 OpenCode SQLite 会话数据
9
+ * 读取 OpenCode SQLite 会话数据(node:sqlite / DatabaseSync)
10
10
  */
11
11
 
12
12
  const PROJECT_ORDER_FILE = PATHS.opencodeProjectOrder;
@@ -146,79 +146,48 @@ function toIsoTime(input) {
146
146
  }
147
147
  }
148
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
- }
149
+ // ---------------------------------------------------------------------------
150
+ // Database helpers (node:sqlite / DatabaseSync)
151
+ // ---------------------------------------------------------------------------
161
152
 
162
- function runSqliteQuery(sql) {
163
- if (!isOpenCodeInstalled()) {
164
- return [];
165
- }
153
+ function _openCodeDb() {
154
+ if (!isOpenCodeInstalled()) return null;
155
+ const db = new DatabaseSync(OPENCODE_DB_PATH, { readOnly: false, timeout: 5000 });
156
+ db.exec('PRAGMA foreign_keys = ON');
157
+ return db;
158
+ }
166
159
 
160
+ function _query(sql, ...params) {
161
+ const db = _openCodeDb();
162
+ if (!db) return [];
167
163
  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
- windowsHide: true
173
- }).trim();
174
-
175
- if (!output) {
176
- return [];
177
- }
178
-
179
- const parsed = JSON.parse(output);
180
- return Array.isArray(parsed) ? parsed : [];
164
+ return db.prepare(sql).all(...params);
181
165
  } catch (err) {
182
166
  console.error('[OpenCode Sessions] SQLite query failed:', err.message);
183
167
  return [];
184
168
  }
185
169
  }
186
170
 
187
- function runSqliteExec(sql) {
188
- if (!isOpenCodeInstalled()) {
189
- throw new Error('OpenCode CLI not installed');
190
- }
191
-
192
- execFileSync('sqlite3', [OPENCODE_DB_PATH, sql], {
193
- encoding: 'utf8',
194
- stdio: ['ignore', 'pipe', 'pipe'],
195
- maxBuffer: 10 * 1024 * 1024,
196
- windowsHide: true
197
- });
171
+ function _exec(sql, ...params) {
172
+ const db = _openCodeDb();
173
+ if (!db) throw new Error('OpenCode CLI not installed');
174
+ db.prepare(sql).run(...params);
198
175
  }
199
176
 
200
177
  function buildContext(text, keyword, contextLength = 35) {
201
178
  if (!text || !keyword) {
202
179
  return null;
203
180
  }
204
-
205
- const parsedContextLength = Number(contextLength);
206
- const safeContextLength = Number.isFinite(parsedContextLength) && parsedContextLength >= 0
207
- ? parsedContextLength
208
- : 35;
209
-
210
- const lowerText = String(text).toLowerCase();
211
- const lowerKeyword = String(keyword).toLowerCase();
212
- const index = lowerText.indexOf(lowerKeyword);
213
- if (index === -1) {
181
+ const lower = text.toLowerCase();
182
+ const idx = lower.indexOf(keyword.toLowerCase());
183
+ if (idx === -1) {
214
184
  return null;
215
185
  }
216
-
217
- const start = Math.max(0, index - safeContextLength);
218
- const end = Math.min(lowerText.length, index + lowerKeyword.length + safeContextLength);
219
- let context = String(text).slice(start, end);
220
- if (start > 0) context = `...${context}`;
221
- if (end < String(text).length) context = `${context}...`;
186
+ const start = Math.max(0, idx - contextLength);
187
+ const end = Math.min(text.length, idx + keyword.length + contextLength);
188
+ let context = text.slice(start, end);
189
+ if (start > 0) context = '...' + context;
190
+ if (end < text.length) context = context + '...';
222
191
  return context;
223
192
  }
224
193
 
@@ -227,122 +196,95 @@ function isOpenCodeInstalled() {
227
196
  return fs.existsSync(OPENCODE_DB_PATH);
228
197
  }
229
198
 
230
- // 获取 OpenCode 数据目录
231
- function getOpenCodeDataDir() {
232
- return NATIVE_PATHS.opencode.data;
233
- }
234
-
235
- // 兼容导出:保留旧路径函数
236
- function getSessionsDir() {
237
- return path.join(getOpenCodeDataDir(), 'storage', 'session');
199
+ function invalidateProjectAndSessionCountsCache() {
200
+ countsCache = { expiresAt: 0, value: EMPTY_COUNTS };
238
201
  }
239
202
 
240
- // 兼容导出:保留旧路径函数
241
- function getProjectsDir() {
242
- return path.join(getOpenCodeDataDir(), 'storage', 'project');
243
- }
203
+ // ---------------------------------------------------------------------------
204
+ // Project / session order (file-based)
205
+ // ---------------------------------------------------------------------------
244
206
 
245
207
  function getProjectOrder() {
246
- const order = readJsonSafe(PROJECT_ORDER_FILE, []);
247
- return Array.isArray(order) ? order : [];
208
+ return readJsonSafe(PROJECT_ORDER_FILE, []).order || [];
248
209
  }
249
210
 
250
211
  function saveProjectOrder(order) {
251
- if (!Array.isArray(order)) {
252
- throw new Error('order must be an array');
253
- }
254
- writeJsonSafe(PROJECT_ORDER_FILE, order);
255
- return { success: true };
256
- }
257
-
258
- function getSessionOrderMap() {
259
- const map = readJsonSafe(SESSION_ORDER_FILE, {});
260
- return map && typeof map === 'object' ? map : {};
212
+ writeJsonSafe(PROJECT_ORDER_FILE, { order: Array.isArray(order) ? order : [] });
261
213
  }
262
214
 
263
- function saveSessionOrderMap(map) {
264
- writeJsonSafe(SESSION_ORDER_FILE, map);
215
+ function removeProjectFromOrder(projectId) {
216
+ const current = getProjectOrder();
217
+ saveProjectOrder(current.filter(id => id !== projectId));
265
218
  }
266
219
 
267
220
  function getSessionOrder(projectId) {
268
- const map = getSessionOrderMap();
269
- const order = map[projectId];
270
- return Array.isArray(order) ? order : [];
221
+ const all = readJsonSafe(SESSION_ORDER_FILE, {}).order || {};
222
+ return all[projectId] || [];
271
223
  }
272
224
 
273
225
  function saveSessionOrder(projectId, order) {
274
- if (!projectId) {
275
- throw new Error('projectId is required');
276
- }
277
- if (!Array.isArray(order)) {
278
- throw new Error('order must be an array');
279
- }
280
-
281
- const map = getSessionOrderMap();
282
- map[projectId] = order;
283
- saveSessionOrderMap(map);
284
- return { success: true };
226
+ const all = readJsonSafe(SESSION_ORDER_FILE, {}).order || {};
227
+ all[projectId] = Array.isArray(order) ? order : [];
228
+ writeJsonSafe(SESSION_ORDER_FILE, { order: all });
285
229
  }
286
230
 
287
231
  function removeSessionFromOrder(projectId, sessionId) {
288
- const map = getSessionOrderMap();
289
- if (!Array.isArray(map[projectId])) {
290
- return;
291
- }
292
- map[projectId] = map[projectId].filter(id => id !== sessionId);
293
- saveSessionOrderMap(map);
232
+ const current = getSessionOrder(projectId);
233
+ saveSessionOrder(projectId, current.filter(id => id !== sessionId));
294
234
  }
295
235
 
296
- function removeProjectFromOrder(projectId) {
297
- const currentOrder = getProjectOrder().filter(name => name !== projectId);
298
- saveProjectOrder(currentOrder);
236
+ // ---------------------------------------------------------------------------
237
+ // Metadata helpers
238
+ // ---------------------------------------------------------------------------
299
239
 
300
- const map = getSessionOrderMap();
301
- if (map[projectId] !== undefined) {
302
- delete map[projectId];
303
- saveSessionOrderMap(map);
240
+ function getProjectDisplayName(project) {
241
+ if (!project) return 'Unknown';
242
+ const worktree = project.worktree || '';
243
+ if (worktree) {
244
+ const parsed = parseJsonMaybe(project.data);
245
+ if (parsed && typeof parsed.name === 'string' && parsed.name.trim()) {
246
+ return parsed.name.trim();
247
+ }
248
+ return path.basename(worktree);
304
249
  }
250
+ return project.id || 'Unknown';
305
251
  }
306
252
 
307
- function invalidateProjectAndSessionCountsCache() {
308
- countsCache.expiresAt = 0;
253
+ function getSessionLocation(sessionId) {
254
+ const row = getSessionRowById(sessionId);
255
+ if (!row) return null;
256
+ return { projectId: row.project_id, directory: row.directory, sessionData: row };
309
257
  }
310
258
 
259
+ // ---------------------------------------------------------------------------
260
+ // Database query functions (parameterized)
261
+ // ---------------------------------------------------------------------------
262
+
311
263
  function queryProjectAndSessionCounts() {
312
- const rows = runSqliteQuery(`
264
+ const rows = _query(`
313
265
  SELECT
314
266
  (SELECT COUNT(*) FROM project) AS project_count,
315
267
  (SELECT COUNT(*) FROM session WHERE time_archived IS NULL) AS session_count
316
268
  `);
317
-
318
- const row = rows[0] || {};
319
- return {
320
- projectCount: Number(row.project_count) || 0,
321
- sessionCount: Number(row.session_count) || 0
322
- };
269
+ return rows.length > 0 ? rows[0] : { project_count: 0, session_count: 0 };
323
270
  }
324
271
 
325
272
  function getProjectRows() {
326
- return runSqliteQuery(`
273
+ return _query(`
327
274
  SELECT
328
275
  p.id,
329
276
  p.worktree,
330
- p.name,
331
277
  p.time_created,
332
278
  p.time_updated,
333
- COALESCE(s.session_count, 0) AS session_count
279
+ p.time_archived,
280
+ p.data
334
281
  FROM project p
335
- LEFT JOIN (
336
- SELECT project_id, COUNT(*) AS session_count
337
- FROM session
338
- WHERE time_archived IS NULL
339
- GROUP BY project_id
340
- ) s ON s.project_id = p.id
282
+ ORDER BY p.time_updated DESC
341
283
  `);
342
284
  }
343
285
 
344
286
  function getSessionRowsByProjectId(projectId) {
345
- return runSqliteQuery(`
287
+ return _query(`
346
288
  SELECT
347
289
  s.id,
348
290
  s.project_id,
@@ -361,28 +303,16 @@ function getSessionRowsByProjectId(projectId) {
361
303
  s.time_created,
362
304
  s.time_updated,
363
305
  s.time_compacting,
364
- s.time_archived,
365
- (
366
- COALESCE((
367
- SELECT SUM(length(CAST(COALESCE(m.data, '') AS BLOB)))
368
- FROM message m
369
- WHERE m.session_id = s.id
370
- ), 0) +
371
- COALESCE((
372
- SELECT SUM(length(CAST(COALESCE(p.data, '') AS BLOB)))
373
- FROM part p
374
- WHERE p.session_id = s.id
375
- ), 0)
376
- ) AS size
306
+ s.time_archived
377
307
  FROM session s
378
- WHERE s.project_id = ${sqlQuote(projectId)}
308
+ WHERE s.project_id = ?
379
309
  AND s.time_archived IS NULL
380
310
  ORDER BY s.time_updated DESC
381
- `);
311
+ `, projectId);
382
312
  }
383
313
 
384
314
  function getSessionRowById(sessionId) {
385
- const rows = runSqliteQuery(`
315
+ const rows = _query(`
386
316
  SELECT
387
317
  s.id,
388
318
  s.project_id,
@@ -401,29 +331,16 @@ function getSessionRowById(sessionId) {
401
331
  s.time_created,
402
332
  s.time_updated,
403
333
  s.time_compacting,
404
- s.time_archived,
405
- (
406
- COALESCE((
407
- SELECT SUM(length(CAST(COALESCE(m.data, '') AS BLOB)))
408
- FROM message m
409
- WHERE m.session_id = s.id
410
- ), 0) +
411
- COALESCE((
412
- SELECT SUM(length(CAST(COALESCE(p.data, '') AS BLOB)))
413
- FROM part p
414
- WHERE p.session_id = s.id
415
- ), 0)
416
- ) AS size
334
+ s.time_archived
417
335
  FROM session s
418
- WHERE s.id = ${sqlQuote(sessionId)}
336
+ WHERE s.id = ?
419
337
  LIMIT 1
420
- `);
421
-
422
- return rows[0] || null;
338
+ `, sessionId);
339
+ return rows.length > 0 ? rows[0] : null;
423
340
  }
424
341
 
425
342
  function getMessageRowsBySessionId(sessionId) {
426
- return runSqliteQuery(`
343
+ return _query(`
427
344
  SELECT
428
345
  id,
429
346
  session_id,
@@ -431,13 +348,13 @@ function getMessageRowsBySessionId(sessionId) {
431
348
  time_updated,
432
349
  data
433
350
  FROM message
434
- WHERE session_id = ${sqlQuote(sessionId)}
351
+ WHERE session_id = ?
435
352
  ORDER BY time_created ASC
436
- `);
353
+ `, sessionId);
437
354
  }
438
355
 
439
356
  function getPartRowsBySessionId(sessionId) {
440
- return runSqliteQuery(`
357
+ return _query(`
441
358
  SELECT
442
359
  id,
443
360
  message_id,
@@ -446,72 +363,49 @@ function getPartRowsBySessionId(sessionId) {
446
363
  time_updated,
447
364
  data
448
365
  FROM part
449
- WHERE session_id = ${sqlQuote(sessionId)}
366
+ WHERE session_id = ?
450
367
  ORDER BY time_created ASC
451
- `);
368
+ `, sessionId);
452
369
  }
453
370
 
454
- function normalizeSession(session, projectId = null) {
455
- const size = Number(session?.size);
371
+ function normalizeSession(session, projectId) {
456
372
  return {
457
373
  sessionId: session.id,
458
- projectName: projectId || session.project_id,
459
374
  mtime: toIsoTime(session.time_updated) || new Date().toISOString(),
460
- size: Number.isFinite(size) && size > 0 ? size : 0,
461
- filePath: '',
375
+ size: 0, // SQLite doesn't track file size natively
376
+ filePath: `opencode://${projectId}/${session.id}`,
462
377
  gitBranch: null,
463
- firstMessage: session.title || session.slug || null,
464
- forkedFrom: null,
465
- directory: session.directory,
466
- slug: session.slug,
467
- source: 'opencode'
378
+ firstMessage: session.title || null,
379
+ forkedFrom: session.parent_id || null,
380
+ source: 'opencode',
381
+ directory: session.directory || null,
382
+ slug: session.slug || null,
383
+ model: null,
384
+ provider: null,
385
+ projectName: projectId,
386
+ projectFullPath: null
468
387
  };
469
388
  }
470
389
 
471
- function isSessionLikeName(name) {
472
- return /^ses_[a-z0-9_-]+$/i.test(name);
473
- }
474
-
475
- function getProjectDisplayName(project) {
476
- const rawName = typeof project?.name === 'string' ? project.name.trim() : '';
477
- const rawId = typeof project?.id === 'string' ? project.id.trim() : '';
478
- const worktree = typeof project?.worktree === 'string' ? project.worktree.trim() : '';
479
-
480
- // OpenCode 的 project.name 可能为空或异常写成会话ID,优先回退为目录名。
481
- if (rawName && rawName !== rawId && !isSessionLikeName(rawName)) {
482
- return rawName;
483
- }
484
-
485
- if (worktree) {
486
- const dirName = path.basename(worktree);
487
- if (dirName && dirName !== path.sep && dirName !== '.' && dirName !== '..') {
488
- return dirName;
489
- }
490
- }
390
+ // ---------------------------------------------------------------------------
391
+ // Public API
392
+ // ---------------------------------------------------------------------------
491
393
 
492
- return rawName || rawId;
493
- }
494
-
495
- // 获取所有项目
496
394
  function getProjects(_options = {}) {
497
395
  const projects = getProjectRows().map((project) => ({
498
396
  name: project.id,
499
397
  displayName: getProjectDisplayName(project),
500
398
  fullPath: project.worktree || '/',
501
399
  path: project.worktree || '/',
502
- sessionCount: Number(project.session_count) || 0,
503
- lastUsed: Number(project.time_updated) || Number(project.time_created) || 0,
400
+ sessionCount: 0,
401
+ lastUsed: toIsoTime(project.time_updated),
504
402
  source: 'opencode'
505
403
  }));
506
404
 
507
- return sortByOrder(
508
- projects,
509
- getProjectOrder(),
510
- (a, b) => (b.lastUsed || 0) - (a.lastUsed || 0)
511
- );
405
+ const order = getProjectOrder();
406
+ return sortByOrder(projects, order, (a, b) => (b.lastUsed || '').localeCompare(a.lastUsed || ''));
512
407
  }
513
408
 
514
- // 根据项目ID获取会话列表
515
409
  function getSessionsByProjectId(projectId, _options = {}) {
516
410
  const sessions = getSessionRowsByProjectId(projectId).map(session => normalizeSession(session, projectId));
517
411
  const order = getSessionOrder(projectId);
@@ -520,156 +414,86 @@ function getSessionsByProjectId(projectId, _options = {}) {
520
414
  (a, b) => new Date(b.mtime).getTime() - new Date(a.mtime).getTime()
521
415
  );
522
416
 
523
- if (order.length === 0) {
524
- return fallbackSorted;
525
- }
526
-
527
- const orderMap = new Map(order.map((id, idx) => [id, idx]));
528
- return [...fallbackSorted].sort((a, b) => {
529
- const aIndex = orderMap.has(a.sessionId) ? orderMap.get(a.sessionId) : Number.MAX_SAFE_INTEGER;
530
- const bIndex = orderMap.has(b.sessionId) ? orderMap.get(b.sessionId) : Number.MAX_SAFE_INTEGER;
531
- if (aIndex === bIndex) {
532
- return new Date(b.mtime).getTime() - new Date(a.mtime).getTime();
533
- }
534
- return aIndex - bIndex;
535
- });
536
- }
537
-
538
- // 根据项目名获取会话列表
539
- function getSessionsByProject(projectName, options = {}) {
540
- return getSessionsByProjectId(projectName, options);
541
- }
542
-
543
- function getSessionLocation(sessionId) {
544
- const session = getSessionRowById(sessionId);
545
- if (!session) {
546
- return null;
547
- }
548
- return {
549
- projectId: session.project_id,
550
- sessionData: session
551
- };
552
- }
553
-
554
- // 根据会话ID获取会话详情
555
- function getSessionById(sessionId) {
556
- const location = getSessionLocation(sessionId);
557
- if (!location) {
558
- return null;
559
- }
560
-
561
- return normalizeSession(location.sessionData, location.projectId);
417
+ return sortByOrder(fallbackSorted, order, (a, b) =>
418
+ new Date(b.mtime).getTime() - new Date(a.mtime).getTime()
419
+ );
562
420
  }
563
421
 
564
- function buildSessionMessages(sessionId) {
565
- const messages = getMessageRowsBySessionId(sessionId);
566
- const parts = getPartRowsBySessionId(sessionId);
422
+ function searchSessions(keyword) {
423
+ if (!keyword || !keyword.trim()) return [];
424
+ const lowerKeyword = keyword.toLowerCase();
567
425
 
568
- const partsByMessageId = new Map();
569
- for (const part of parts) {
570
- if (!partsByMessageId.has(part.message_id)) {
571
- partsByMessageId.set(part.message_id, []);
572
- }
573
- partsByMessageId.get(part.message_id).push(part);
574
- }
426
+ const allSessions = [];
427
+ const projects = getProjectRows();
428
+ for (const project of projects) {
429
+ const sessions = getSessionRowsByProjectId(project.id);
430
+ for (const session of sessions) {
431
+ const messages = getMessageRowsBySessionId(session.id);
432
+ const parts = getPartRowsBySessionId(session.id);
433
+
434
+ const matchedMessages = [];
435
+ const partsByMessageId = new Map();
436
+ for (const part of parts) {
437
+ const existing = partsByMessageId.get(part.message_id) || [];
438
+ existing.push(part);
439
+ partsByMessageId.set(part.message_id, existing);
440
+ }
575
441
 
576
- const converted = [];
442
+ for (let i = 0; i < messages.length; i++) {
443
+ const message = messages[i];
444
+ const data = parseJsonMaybe(message.data, null);
445
+ let text = '';
446
+
447
+ if (data && data.role === 'user') {
448
+ text = extractTextFromMessageData(data);
449
+ } else {
450
+ const messageParts = partsByMessageId.get(message.id) || [];
451
+ for (const part of messageParts) {
452
+ const partData = parseJsonMaybe(part.data, null);
453
+ text += (text ? '\n' : '') + extractTextFromPartData(partData);
454
+ }
455
+ }
577
456
 
578
- for (const row of messages) {
579
- const messageData = parseJsonMaybe(row.data, {});
580
- const role = messageData?.role;
581
- if (role !== 'user' && role !== 'assistant') {
582
- continue;
583
- }
457
+ if (text.toLowerCase().includes(lowerKeyword)) {
458
+ matchedMessages.push({
459
+ messageIndex: i,
460
+ role: data ? (data.role || 'unknown') : 'unknown',
461
+ context: buildContext(text, keyword),
462
+ timestamp: toIsoTime(message.time_created)
463
+ });
464
+ }
465
+ }
584
466
 
585
- const messageParts = partsByMessageId.get(row.id) || [];
586
- const partTexts = [];
587
- for (const part of messageParts) {
588
- const partData = parseJsonMaybe(part.data, null);
589
- const text = extractTextFromPartData(partData);
590
- if (text) {
591
- partTexts.push(text);
467
+ if (matchedMessages.length > 0) {
468
+ allSessions.push({
469
+ sessionId: session.id,
470
+ projectName: project.id,
471
+ projectDisplayName: getProjectDisplayName(project),
472
+ firstMessage: session.title || null,
473
+ matches: matchedMessages,
474
+ matchCount: matchedMessages.length,
475
+ source: 'opencode'
476
+ });
592
477
  }
593
478
  }
594
-
595
- const fallbackText = extractTextFromMessageData(messageData);
596
- const content = partTexts.join('\n').trim() || fallbackText || '[空消息]';
597
-
598
- const timestamp = toIsoTime(
599
- messageData?.time?.created || row.time_created || row.time_updated
600
- );
601
-
602
- converted.push({
603
- type: role,
604
- role,
605
- content,
606
- timestamp,
607
- model: role === 'assistant'
608
- ? (messageData?.model?.modelID || messageData?.modelID || messageData?.model || 'opencode')
609
- : null
610
- });
611
- }
612
-
613
- return converted;
614
- }
615
-
616
- function getSessionMessages(sessionId) {
617
- const location = getSessionLocation(sessionId);
618
- if (!location) {
619
- throw new Error('Session not found');
620
- }
621
-
622
- return buildSessionMessages(sessionId).map(({ type, content, timestamp, model }) => ({
623
- type,
624
- content,
625
- timestamp,
626
- model
627
- }));
628
- }
629
-
630
- // 获取项目和会话数量统计
631
- function getProjectAndSessionCounts(options = {}) {
632
- const now = Date.now();
633
- if (options.force) {
634
- countsCache.expiresAt = 0;
635
- }
636
- if (!options.force && countsCache.expiresAt > now) {
637
- return countsCache.value;
638
479
  }
639
480
 
640
- try {
641
- const counts = queryProjectAndSessionCounts();
642
- countsCache = {
643
- value: counts,
644
- expiresAt: now + COUNTS_CACHE_TTL_MS
645
- };
646
- return counts;
647
- } catch (err) {
648
- console.error('[OpenCode Sessions] Failed to get counts:', err);
649
- return countsCache.value || EMPTY_COUNTS;
650
- }
481
+ return allSessions.sort((a, b) => b.matchCount - a.matchCount);
651
482
  }
652
483
 
653
484
  function getRecentSessions(limit = 5) {
654
- const projects = getProjects();
655
- const { loadAliases } = require('./alias');
656
- const { getForkRelations } = require('./sessions');
657
- const aliases = loadAliases();
658
- const forkRelations = getForkRelations();
659
485
  const allSessions = [];
660
486
 
487
+ const projects = getProjectRows();
661
488
  for (const project of projects) {
662
- const sessions = getSessionsByProjectId(project.name);
663
- sessions.forEach(session => {
489
+ const sessions = getSessionRowsByProjectId(project.id);
490
+ for (const session of sessions) {
664
491
  allSessions.push({
665
- ...session,
666
- alias: aliases[session.sessionId] || null,
667
- forkedFrom: forkRelations[session.sessionId] || null,
668
- projectName: project.name,
669
- projectDisplayName: project.displayName,
670
- projectFullPath: project.fullPath
492
+ ...normalizeSession(session, project.id),
493
+ projectDisplayName: getProjectDisplayName(project),
494
+ projectFullPath: project.worktree || null
671
495
  });
672
- });
496
+ }
673
497
  }
674
498
 
675
499
  return allSessions
@@ -677,6 +501,66 @@ function getRecentSessions(limit = 5) {
677
501
  .slice(0, limit);
678
502
  }
679
503
 
504
+ function getSessionById(sessionId) {
505
+ const location = getSessionLocation(sessionId);
506
+ if (!location) return null;
507
+
508
+ const messages = buildSessionMessages(sessionId);
509
+ return {
510
+ sessionId,
511
+ mtime: toIsoTime(location.sessionData.time_updated) || new Date().toISOString(),
512
+ size: 0,
513
+ filePath: `opencode://${location.projectId}/${sessionId}`,
514
+ source: 'opencode',
515
+ directory: location.directory || null,
516
+ projectName: location.projectId,
517
+ messages
518
+ };
519
+ }
520
+
521
+ function buildSessionMessages(sessionId) {
522
+ const messages = getMessageRowsBySessionId(sessionId);
523
+ const parts = getPartRowsBySessionId(sessionId);
524
+
525
+ const partsByMessageId = new Map();
526
+ for (const part of parts) {
527
+ const existing = partsByMessageId.get(part.message_id) || [];
528
+ existing.push(part);
529
+ partsByMessageId.set(part.message_id, existing);
530
+ }
531
+
532
+ return messages.map((message) => {
533
+ const data = parseJsonMaybe(message.data, null);
534
+ const messageParts = partsByMessageId.get(message.id) || [];
535
+
536
+ let content = '';
537
+ if (data && data.role === 'user') {
538
+ content = extractTextFromMessageData(data);
539
+ } else if (messageParts.length > 0) {
540
+ content = messageParts
541
+ .map(part => {
542
+ const partData = parseJsonMaybe(part.data, null);
543
+ return extractTextFromPartData(partData);
544
+ })
545
+ .filter(Boolean)
546
+ .join('\n');
547
+ }
548
+
549
+ return {
550
+ id: message.id,
551
+ role: data ? (data.role || 'unknown') : 'unknown',
552
+ type: data ? (data.role || 'unknown') : 'unknown',
553
+ content,
554
+ timestamp: toIsoTime(message.time_created),
555
+ model: data ? (data.model || null) : null,
556
+ parts: messageParts.map(part => ({
557
+ id: part.id,
558
+ data: parseJsonMaybe(part.data, null)
559
+ }))
560
+ };
561
+ });
562
+ }
563
+
680
564
  // 删除会话
681
565
  function deleteSession(sessionId) {
682
566
  const location = getSessionLocation(sessionId);
@@ -684,10 +568,10 @@ function deleteSession(sessionId) {
684
568
  throw new Error('Session not found');
685
569
  }
686
570
 
687
- runSqliteExec(`
688
- PRAGMA foreign_keys = ON;
689
- DELETE FROM session WHERE id = ${sqlQuote(sessionId)};
690
- `);
571
+ _exec(
572
+ `DELETE FROM session WHERE id = ?`,
573
+ sessionId
574
+ );
691
575
 
692
576
  try {
693
577
  const { deleteAlias } = require('./alias');
@@ -727,93 +611,93 @@ function forkSession(sessionId) {
727
611
  const parts = getPartRowsBySessionId(sessionId);
728
612
  const now = Date.now();
729
613
  const newSessionId = `ses_${crypto.randomUUID().replace(/-/g, '')}`;
614
+ const db = _openCodeDb();
615
+ if (!db) throw new Error('OpenCode CLI not installed');
730
616
 
731
617
  const messageIdMap = new Map();
732
618
  for (const message of messages) {
733
619
  messageIdMap.set(message.id, `msg_${crypto.randomUUID().replace(/-/g, '')}`);
734
620
  }
735
621
 
736
- const statements = [];
737
- statements.push('PRAGMA foreign_keys = ON;');
738
- statements.push('BEGIN IMMEDIATE;');
739
-
740
- statements.push(`
741
- INSERT INTO session (
742
- id, project_id, parent_id, slug, directory, title, version, share_url,
743
- summary_additions, summary_deletions, summary_files, summary_diffs,
744
- revert, permission, time_created, time_updated, time_compacting, time_archived
745
- ) VALUES (
746
- ${sqlQuote(newSessionId)},
747
- ${sqlQuote(source.project_id)},
748
- ${sqlQuote(source.parent_id)},
749
- ${sqlQuote(source.slug)},
750
- ${sqlQuote(source.directory)},
751
- ${sqlQuote(source.title)},
752
- ${sqlQuote(source.version)},
753
- ${sqlQuote(source.share_url)},
754
- ${sqlQuote(source.summary_additions)},
755
- ${sqlQuote(source.summary_deletions)},
756
- ${sqlQuote(source.summary_files)},
757
- ${sqlQuote(source.summary_diffs)},
758
- ${sqlQuote(source.revert)},
759
- ${sqlQuote(source.permission)},
760
- ${sqlQuote(now)},
761
- ${sqlQuote(now)},
762
- ${sqlQuote(source.time_compacting)},
763
- NULL
764
- );
765
- `);
622
+ db.exec('BEGIN IMMEDIATE');
766
623
 
767
- for (const message of messages) {
768
- const newMessageId = messageIdMap.get(message.id);
769
- const messageData = parseJsonMaybe(message.data, null);
624
+ try {
625
+ db.prepare(`
626
+ INSERT INTO session (
627
+ id, project_id, parent_id, slug, directory, title, version, share_url,
628
+ summary_additions, summary_deletions, summary_files, summary_diffs,
629
+ revert, permission, time_created, time_updated, time_compacting, time_archived
630
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
631
+ `).run(
632
+ newSessionId,
633
+ source.project_id,
634
+ source.parent_id,
635
+ source.slug,
636
+ source.directory,
637
+ source.title,
638
+ source.version,
639
+ source.share_url,
640
+ source.summary_additions,
641
+ source.summary_deletions,
642
+ source.summary_files,
643
+ source.summary_diffs,
644
+ source.revert,
645
+ source.permission,
646
+ now,
647
+ now,
648
+ source.time_compacting
649
+ );
770
650
 
771
- let serializedData = message.data;
772
- if (messageData && typeof messageData === 'object') {
773
- if (typeof messageData.parentID === 'string' && messageIdMap.has(messageData.parentID)) {
774
- messageData.parentID = messageIdMap.get(messageData.parentID);
775
- }
776
- if (typeof messageData.id === 'string') {
777
- messageData.id = newMessageId;
651
+ const insertMessageStmt = db.prepare(
652
+ `INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)`
653
+ );
654
+ for (const message of messages) {
655
+ const newMessageId = messageIdMap.get(message.id);
656
+ const messageData = parseJsonMaybe(message.data, null);
657
+
658
+ let serializedData = message.data;
659
+ if (messageData && typeof messageData === 'object') {
660
+ if (typeof messageData.parentID === 'string' && messageIdMap.has(messageData.parentID)) {
661
+ messageData.parentID = messageIdMap.get(messageData.parentID);
662
+ }
663
+ if (typeof messageData.id === 'string') {
664
+ messageData.id = newMessageId;
665
+ }
666
+ serializedData = JSON.stringify(messageData);
778
667
  }
779
- serializedData = JSON.stringify(messageData);
780
- }
781
668
 
782
- statements.push(`
783
- INSERT INTO message (id, session_id, time_created, time_updated, data)
784
- VALUES (
785
- ${sqlQuote(newMessageId)},
786
- ${sqlQuote(newSessionId)},
787
- ${sqlQuote(message.time_created)},
788
- ${sqlQuote(message.time_updated)},
789
- ${sqlQuote(serializedData)}
669
+ insertMessageStmt.run(
670
+ newMessageId,
671
+ newSessionId,
672
+ message.time_created,
673
+ message.time_updated,
674
+ serializedData
790
675
  );
791
- `);
792
- }
793
-
794
- for (const part of parts) {
795
- const newPartId = `prt_${crypto.randomUUID().replace(/-/g, '')}`;
796
- const targetMessageId = messageIdMap.get(part.message_id);
797
- if (!targetMessageId) {
798
- continue;
799
676
  }
800
677
 
801
- statements.push(`
802
- INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
803
- VALUES (
804
- ${sqlQuote(newPartId)},
805
- ${sqlQuote(targetMessageId)},
806
- ${sqlQuote(newSessionId)},
807
- ${sqlQuote(part.time_created)},
808
- ${sqlQuote(part.time_updated)},
809
- ${sqlQuote(part.data)}
678
+ const insertPartStmt = db.prepare(
679
+ `INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)`
680
+ );
681
+ for (const part of parts) {
682
+ const newPartId = `prt_${crypto.randomUUID().replace(/-/g, '')}`;
683
+ const targetMessageId = messageIdMap.get(part.message_id);
684
+ if (!targetMessageId) continue;
685
+
686
+ insertPartStmt.run(
687
+ newPartId,
688
+ targetMessageId,
689
+ newSessionId,
690
+ part.time_created,
691
+ part.time_updated,
692
+ part.data
810
693
  );
811
- `);
812
- }
813
-
814
- statements.push('COMMIT;');
694
+ }
815
695
 
816
- runSqliteExec(statements.join('\n'));
696
+ db.exec('COMMIT');
697
+ } catch (err) {
698
+ db.exec('ROLLBACK');
699
+ throw new Error('Failed to fork session: ' + err.message);
700
+ }
817
701
 
818
702
  try {
819
703
  const { getForkRelations, saveForkRelations } = require('./sessions');
@@ -821,165 +705,74 @@ function forkSession(sessionId) {
821
705
  relations[newSessionId] = sessionId;
822
706
  saveForkRelations(relations);
823
707
  } catch (err) {
824
- // ignore fork relation save errors
708
+ // ignore
825
709
  }
826
710
 
827
- const existingOrder = getSessionOrder(location.projectId);
828
- saveSessionOrder(location.projectId, [newSessionId, ...existingOrder.filter(id => id !== newSessionId)]);
829
-
830
711
  invalidateProjectAndSessionCountsCache();
831
- return {
832
- success: true,
833
- newSessionId,
834
- forkedFrom: sessionId,
835
- projectName: location.projectId,
836
- newFilePath: null
837
- };
712
+ return { success: true, sessionId: newSessionId, forkedFrom: sessionId };
838
713
  }
839
714
 
840
715
  function deleteProject(projectId) {
841
- const projectRows = runSqliteQuery(`
842
- SELECT id FROM project WHERE id = ${sqlQuote(projectId)} LIMIT 1
843
- `);
716
+ const projectRows = _query(
717
+ `SELECT id FROM project WHERE id = ? LIMIT 1`,
718
+ projectId
719
+ );
844
720
 
845
721
  if (projectRows.length === 0) {
846
722
  throw new Error('Project not found');
847
723
  }
848
724
 
849
- const sessionRows = runSqliteQuery(`
850
- SELECT id FROM session WHERE project_id = ${sqlQuote(projectId)}
851
- `);
725
+ const sessionRows = _query(
726
+ `SELECT id FROM session WHERE project_id = ?`,
727
+ projectId
728
+ );
852
729
 
853
730
  const deletedSessionIds = sessionRows.map(row => row.id);
854
731
 
855
- for (const sessionId of deletedSessionIds) {
856
- try {
857
- const { deleteAlias } = require('./alias');
858
- deleteAlias(sessionId);
859
- } catch (err) {
860
- // ignore alias cleanup errors
861
- }
862
- }
863
-
864
- runSqliteExec(`
865
- PRAGMA foreign_keys = ON;
866
- DELETE FROM project WHERE id = ${sqlQuote(projectId)};
867
- `);
868
-
869
- removeProjectFromOrder(projectId);
870
-
871
732
  try {
872
- const { getForkRelations, saveForkRelations } = require('./sessions');
873
- const deletedSet = new Set(deletedSessionIds);
874
- const relations = getForkRelations();
875
- Object.keys(relations).forEach((key) => {
876
- if (deletedSet.has(key) || deletedSet.has(relations[key])) {
877
- delete relations[key];
878
- }
733
+ const { deleteAlias } = require('./alias');
734
+ deletedSessionIds.forEach(id => {
735
+ try { deleteAlias(id); } catch (_) {}
879
736
  });
880
- saveForkRelations(relations);
881
- } catch (err) {
882
- // ignore relation cleanup errors
883
- }
737
+ } catch (_) {}
738
+
739
+ _exec(`DELETE FROM project WHERE id = ?`, projectId);
884
740
 
741
+ removeProjectFromOrder(projectId);
885
742
  invalidateProjectAndSessionCountsCache();
886
- return {
887
- success: true,
888
- projectName: projectId,
889
- deletedCount: deletedSessionIds.length
890
- };
743
+ return { success: true, projectId, deletedSessions: deletedSessionIds.length };
891
744
  }
892
745
 
893
- // 搜索会话
894
- function searchSessions(keyword, contextLength = 35, projectFilter = null) {
895
- if (!keyword || !String(keyword).trim()) {
896
- return [];
746
+ function getProjectAndSessionCounts(options = {}) {
747
+ if (options.force) {
748
+ invalidateProjectAndSessionCountsCache();
749
+ } else if (countsCache.expiresAt > Date.now()) {
750
+ return countsCache.value;
897
751
  }
898
752
 
899
- const searchKeyword = String(keyword).trim();
900
- const projects = getProjects();
901
- const { loadAliases } = require('./alias');
902
- const aliases = loadAliases();
903
- const results = [];
904
-
905
- for (const project of projects) {
906
- if (projectFilter && project.name !== projectFilter) {
907
- continue;
908
- }
909
-
910
- const sessions = getSessionsByProjectId(project.name);
911
- for (const session of sessions) {
912
- const matches = [];
913
-
914
- const quickChecks = [
915
- session.sessionId,
916
- session.firstMessage,
917
- session.slug,
918
- session.directory
919
- ];
920
-
921
- for (const text of quickChecks) {
922
- const context = buildContext(text, searchKeyword, contextLength);
923
- if (context) {
924
- matches.push({
925
- role: 'assistant',
926
- context,
927
- timestamp: session.mtime
928
- });
929
- }
930
- }
931
-
932
- const sessionMessages = buildSessionMessages(session.sessionId);
933
- for (const message of sessionMessages) {
934
- const context = buildContext(message.content, searchKeyword, contextLength);
935
- if (!context) {
936
- continue;
937
- }
938
-
939
- matches.push({
940
- role: message.role,
941
- context,
942
- timestamp: message.timestamp
943
- });
944
- }
945
-
946
- if (matches.length > 0) {
947
- results.push({
948
- sessionId: session.sessionId,
949
- projectName: project.name,
950
- projectDisplayName: project.displayName,
951
- projectFullPath: project.fullPath,
952
- alias: aliases[session.sessionId] || null,
953
- matchCount: matches.length,
954
- matches: matches.slice(0, 5),
955
- source: 'opencode'
956
- });
957
- }
958
- }
959
- }
753
+ const row = queryProjectAndSessionCounts();
754
+ const result = {
755
+ projectCount: Number(row.project_count) || 0,
756
+ sessionCount: Number(row.session_count) || 0
757
+ };
960
758
 
961
- return results.sort((a, b) => b.matchCount - a.matchCount);
759
+ countsCache = { expiresAt: Date.now() + COUNTS_CACHE_TTL_MS, value: result };
760
+ return result;
962
761
  }
963
762
 
964
763
  module.exports = {
965
764
  isOpenCodeInstalled,
966
- getOpenCodeDataDir,
967
- getSessionsDir,
968
- getProjectsDir,
969
765
  getProjects,
970
- getProjectOrder,
971
- saveProjectOrder,
972
- getSessionsByProject,
973
766
  getSessionsByProjectId,
974
767
  getSessionById,
975
- getSessionMessages,
768
+ searchSessions,
976
769
  getRecentSessions,
977
- normalizeSession,
978
- getProjectAndSessionCounts,
979
770
  deleteSession,
980
- deleteProject,
981
771
  forkSession,
982
- getSessionOrder,
772
+ deleteProject,
773
+ saveProjectOrder,
983
774
  saveSessionOrder,
984
- searchSessions
775
+ getProjectAndSessionCounts,
776
+ _query, // exposed for testing
777
+ _openCodeDb // exposed for testing
985
778
  };