codexmate 0.1.24 → 0.1.27

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 (50) hide show
  1. package/cli/session-usage.js +10 -4
  2. package/cli.js +737 -200
  3. package/lib/cli-sessions.js +1 -1
  4. package/package.json +3 -3
  5. package/plugins/prompt-templates/computed.mjs +1 -1
  6. package/plugins/prompt-templates/index.mjs +3 -3
  7. package/plugins/prompt-templates/manifest.mjs +5 -3
  8. package/plugins/prompt-templates/methods.mjs +9 -2
  9. package/plugins/registry.mjs +5 -2
  10. package/plugins/text-tools/computed.mjs +23 -0
  11. package/plugins/text-tools/index.mjs +14 -0
  12. package/plugins/text-tools/manifest.mjs +20 -0
  13. package/plugins/text-tools/methods.mjs +24 -0
  14. package/plugins/text-tools/overview.mjs +12 -0
  15. package/plugins/text-tools/ownership.mjs +7 -0
  16. package/tools/dev/launch.js +147 -0
  17. package/web-ui/app.js +15 -5
  18. package/web-ui/index.html +1 -2
  19. package/web-ui/logic.markdown-editor.mjs +5 -57
  20. package/web-ui/logic.prompts-token-count.mjs +49 -0
  21. package/web-ui/logic.sessions.mjs +30 -15
  22. package/web-ui/modules/app.computed.dashboard.mjs +1 -0
  23. package/web-ui/modules/app.computed.prompts.mjs +18 -0
  24. package/web-ui/modules/app.computed.session.mjs +3 -1
  25. package/web-ui/modules/app.methods.navigation.mjs +79 -3
  26. package/web-ui/modules/app.methods.prompts-editor.mjs +6 -64
  27. package/web-ui/modules/app.methods.session-actions.mjs +10 -4
  28. package/web-ui/modules/app.methods.session-browser.mjs +34 -45
  29. package/web-ui/modules/app.methods.session-timeline.mjs +1 -0
  30. package/web-ui/modules/app.methods.web-ui-preferences.mjs +7 -1
  31. package/web-ui/modules/i18n/locales/en.mjs +20 -1
  32. package/web-ui/modules/i18n/locales/ja.mjs +20 -1
  33. package/web-ui/modules/i18n/locales/vi.mjs +20 -1
  34. package/web-ui/modules/i18n/locales/zh-tw.mjs +20 -1
  35. package/web-ui/modules/i18n/locales/zh.mjs +20 -1
  36. package/web-ui/modules/plugins.computed.mjs +8 -2
  37. package/web-ui/modules/plugins.methods.mjs +8 -2
  38. package/web-ui/partials/index/layout-header.html +23 -6
  39. package/web-ui/partials/index/panel-plugins.html +45 -5
  40. package/web-ui/partials/index/panel-prompts.html +16 -17
  41. package/web-ui/partials/index/panel-sessions.html +19 -4
  42. package/web-ui/res/web-ui-render.precompiled.js +193 -93
  43. package/web-ui/session-helpers.mjs +1 -2
  44. package/web-ui/source-bundle.cjs +1 -0
  45. package/web-ui/styles/base-theme.css +24 -0
  46. package/web-ui/styles/modals-core.css +48 -0
  47. package/web-ui/styles/prompts-editor.css +3 -66
  48. package/web-ui/styles/sessions-list.css +1 -1
  49. package/web-ui/styles/sessions-preview.css +56 -7
  50. package/web-ui/styles/sessions-toolbar-trash.css +1 -7
package/cli.js CHANGED
@@ -64,6 +64,18 @@ const {
64
64
  parseMaxMessagesValue,
65
65
  resolveMaxMessagesValue
66
66
  } = require('./lib/cli-session-utils');
67
+ let DatabaseSync = null;
68
+ function getNodeSqliteDatabaseSync() {
69
+ if (DatabaseSync !== null) {
70
+ return DatabaseSync;
71
+ }
72
+ try {
73
+ DatabaseSync = require('node:sqlite').DatabaseSync || false;
74
+ } catch (_) {
75
+ DatabaseSync = false;
76
+ }
77
+ return DatabaseSync;
78
+ }
67
79
  const { createMcpStdioServer } = require('./lib/mcp-stdio');
68
80
  const {
69
81
  validateWorkflowDefinition,
@@ -212,6 +224,8 @@ const CLAUDE_MD_FILE_NAME = 'CLAUDE.md';
212
224
  const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
213
225
  const CODEBUDDY_DIR = path.join(os.homedir(), '.codebuddy');
214
226
  const CODEBUDDY_PROJECTS_DIR = path.join(CODEBUDDY_DIR, 'projects');
227
+ const WORKBUDDY_DIR = path.join(os.homedir(), '.workbuddy-ai');
228
+ const WORKBUDDY_PROJECTS_DIR = path.join(WORKBUDDY_DIR, 'projects');
215
229
  const CODEXMATE_DIR = path.join(os.homedir(), '.codexmate');
216
230
  const PROVIDER_CACHE_FILE_GROUPS = Object.freeze({
217
231
  claude: [
@@ -247,6 +261,9 @@ const GEMINI_DIR = path.join(os.homedir(), '.gemini');
247
261
  const GEMINI_TMP_DIR = path.join(GEMINI_DIR, 'tmp');
248
262
  const PI_DIR = path.join(os.homedir(), '.pi');
249
263
  const PI_SESSIONS_DIR = path.join(PI_DIR, 'agent', 'sessions');
264
+ const OPENCODE_DATA_DIR = path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'opencode');
265
+ const OPENCODE_DB_VIRTUAL_ROOT = path.join(OPENCODE_DATA_DIR, 'sessions');
266
+ const OPENCODE_DB_READ_LIMIT = 2000;
250
267
  const RECENT_CONFIGS_FILE = path.join(CONFIG_DIR, 'recent-configs.json');
251
268
  const WORKFLOW_DEFINITIONS_FILE = path.join(CONFIG_DIR, 'codexmate-workflows.json');
252
269
  const WORKFLOW_RUNS_FILE = path.join(CONFIG_DIR, 'codexmate-workflow-runs.jsonl');
@@ -271,6 +288,7 @@ const SESSION_LIST_CACHE_TTL_MS = 4000;
271
288
  const SESSION_SUMMARY_READ_BYTES = 256 * 1024;
272
289
  const SESSION_CONTENT_READ_BYTES = SESSION_SUMMARY_READ_BYTES;
273
290
  const SESSION_PREVIEW_MESSAGE_TEXT_MAX_LENGTH = 4000;
291
+ const SESSION_QUERY_MATCH_POSITIONS_LIMIT = 500;
274
292
  const EXACT_MESSAGE_COUNT_CACHE_MAX_ENTRIES = 800;
275
293
  const DEFAULT_CONTENT_SCAN_LIMIT = 50;
276
294
  const SESSION_SCAN_FACTOR = 4;
@@ -752,6 +770,7 @@ let g_sessionFileLookupCache = {
752
770
  claude: new Map(),
753
771
  gemini: new Map(),
754
772
  codebuddy: new Map(),
773
+ workbuddy: new Map(),
755
774
  pi: new Map()
756
775
  };
757
776
  let g_exactMessageCountCache = new Map();
@@ -1012,7 +1031,7 @@ function normalizeSidebarCollapsedPreference(value) {
1012
1031
 
1013
1032
  function normalizeSessionFilterSourcePreference(value) {
1014
1033
  const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
1015
- if (normalized === 'all' || normalized === 'claude' || normalized === 'gemini' || normalized === 'codebuddy' || normalized === 'pi') return normalized;
1034
+ if (normalized === 'all' || normalized === 'claude' || normalized === 'gemini' || normalized === 'codebuddy' || normalized === 'workbuddy' || normalized === 'pi') return normalized;
1016
1035
  return 'codex';
1017
1036
  }
1018
1037
 
@@ -1098,7 +1117,10 @@ function normalizeWebUiPreferences(value = {}) {
1098
1117
  skillsTargetApp: navigation.skillsTargetApp === 'claude' || navigation.skillsTargetApp === 'pi'
1099
1118
  ? navigation.skillsTargetApp
1100
1119
  : 'codex',
1101
- promptTemplatesMode: navigation.promptTemplatesMode === 'manage' ? 'manage' : 'compose'
1120
+ promptTemplatesMode: navigation.promptTemplatesMode === 'manage' ? 'manage' : 'compose',
1121
+ pluginsActiveId: typeof navigation.pluginsActiveId === 'string' && navigation.pluginsActiveId.trim()
1122
+ ? navigation.pluginsActiveId.trim()
1123
+ : 'prompt-templates'
1102
1124
  }
1103
1125
  };
1104
1126
  }
@@ -1192,7 +1214,6 @@ function getApiToolConfigWriteTarget(action) {
1192
1214
  const opencodeWriteActions = new Set([
1193
1215
  'apply-opencode-config',
1194
1216
  'update-opencode-selection',
1195
- 'apply-opencode-agents-file'
1196
1217
  ]);
1197
1218
  const kilocodeWriteActions = new Set([
1198
1219
  'apply-kilocode-config',
@@ -2111,6 +2132,16 @@ function getCodeBuddyProjectsDir() {
2111
2132
  return resolveExistingDir(candidates, CODEBUDDY_PROJECTS_DIR);
2112
2133
  }
2113
2134
 
2135
+ function getWorkBuddyProjectsDir() {
2136
+ const candidates = [];
2137
+ const envHome = process.env.WORKBUDDY_AI_HOME_DIR || process.env.WORKBUDDY_HOME;
2138
+ if (envHome) {
2139
+ candidates.push(path.join(envHome, 'projects'));
2140
+ }
2141
+ candidates.push(WORKBUDDY_PROJECTS_DIR);
2142
+ return resolveExistingDir(candidates, WORKBUDDY_PROJECTS_DIR);
2143
+ }
2144
+
2114
2145
  function getPiSessionsDir() {
2115
2146
  const candidates = [];
2116
2147
  const envPiHome = process.env.PI_HOME;
@@ -2125,6 +2156,250 @@ function getPiSessionsDir() {
2125
2156
  return resolveExistingDir(candidates, PI_SESSIONS_DIR);
2126
2157
  }
2127
2158
 
2159
+ function getOpencodeDataDir() {
2160
+ const candidates = [];
2161
+ const envOpencodeData = process.env.OPENCODE_DATA_HOME || process.env.OPENCODE_DATA_DIR;
2162
+ if (envOpencodeData) {
2163
+ candidates.push(envOpencodeData);
2164
+ }
2165
+ const xdgData = process.env.XDG_DATA_HOME;
2166
+ if (xdgData) {
2167
+ candidates.push(path.join(xdgData, 'opencode'));
2168
+ }
2169
+ candidates.push(OPENCODE_DATA_DIR);
2170
+ return resolveExistingDir(candidates, OPENCODE_DATA_DIR);
2171
+ }
2172
+
2173
+ function getOpencodeDbFile() {
2174
+ const dataDir = getOpencodeDataDir();
2175
+ return path.join(dataDir, 'opencode.db');
2176
+ }
2177
+
2178
+ function buildOpencodeVirtualSessionPath(sessionId) {
2179
+ const id = typeof sessionId === 'string' ? sessionId.trim() : '';
2180
+ if (!id) return '';
2181
+ return path.join(OPENCODE_DB_VIRTUAL_ROOT, `${id}.jsonl`);
2182
+ }
2183
+
2184
+ function parseOpencodeVirtualSessionId(filePath) {
2185
+ const raw = typeof filePath === 'string' ? filePath.trim() : '';
2186
+ if (!raw) return '';
2187
+ const base = path.basename(raw, path.extname(raw));
2188
+ return base && base.startsWith('ses_') ? base : '';
2189
+ }
2190
+
2191
+ function isOpencodeDbAvailable() {
2192
+ const Database = getNodeSqliteDatabaseSync();
2193
+ const dbFile = getOpencodeDbFile();
2194
+ return !!(Database && dbFile && fs.existsSync(dbFile));
2195
+ }
2196
+
2197
+ function withOpencodeDb(callback, fallback = null) {
2198
+ const Database = getNodeSqliteDatabaseSync();
2199
+ const dbFile = getOpencodeDbFile();
2200
+ if (!Database || !dbFile || !fs.existsSync(dbFile)) {
2201
+ return fallback;
2202
+ }
2203
+ let db;
2204
+ try {
2205
+ db = new Database(dbFile, { readOnly: true });
2206
+ return callback(db, dbFile);
2207
+ } catch (_) {
2208
+ return fallback;
2209
+ } finally {
2210
+ if (db) {
2211
+ try { db.close(); } catch (_) { }
2212
+ }
2213
+ }
2214
+ }
2215
+
2216
+ function parseJsonObjectSafe(value) {
2217
+ if (!value || typeof value !== 'string') return null;
2218
+ try {
2219
+ const parsed = JSON.parse(value);
2220
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
2221
+ } catch (_) {
2222
+ return null;
2223
+ }
2224
+ }
2225
+
2226
+ function toOpencodeIsoTime(value, fallback = '') {
2227
+ const numeric = Number(value);
2228
+ if (Number.isFinite(numeric) && numeric > 0) {
2229
+ return toIsoTime(numeric, fallback);
2230
+ }
2231
+ return toIsoTime(value, fallback);
2232
+ }
2233
+
2234
+ function readOpencodeModelName(value) {
2235
+ const parsed = typeof value === 'string' ? parseJsonObjectSafe(value) : (value && typeof value === 'object' ? value : null);
2236
+ if (!parsed) return '';
2237
+ return typeof parsed.id === 'string' && parsed.id.trim()
2238
+ ? parsed.id.trim()
2239
+ : (typeof parsed.modelID === 'string' ? parsed.modelID.trim() : '');
2240
+ }
2241
+
2242
+ function readOpencodeProviderName(value) {
2243
+ const parsed = typeof value === 'string' ? parseJsonObjectSafe(value) : (value && typeof value === 'object' ? value : null);
2244
+ if (!parsed) return 'opencode';
2245
+ return typeof parsed.providerID === 'string' && parsed.providerID.trim() ? parsed.providerID.trim() : 'opencode';
2246
+ }
2247
+
2248
+ function extractOpencodeTextFromPartData(data) {
2249
+ const part = typeof data === 'string' ? parseJsonObjectSafe(data) : (data && typeof data === 'object' ? data : null);
2250
+ if (!part) return '';
2251
+ const type = typeof part.type === 'string' ? part.type.trim().toLowerCase() : '';
2252
+ if (type !== 'text' && type !== 'reasoning') return '';
2253
+ return typeof part.text === 'string' ? part.text.trim() : '';
2254
+ }
2255
+
2256
+ function normalizeOpencodeMessageRow(row) {
2257
+ if (!row || typeof row !== 'object') return null;
2258
+ const messageData = parseJsonObjectSafe(row.message_data || row.data || '');
2259
+ const role = normalizeRole((messageData && messageData.role) || row.role || '');
2260
+ if (role !== 'user' && role !== 'assistant' && role !== 'system') return null;
2261
+ const rawParts = typeof row.parts === 'string' && row.parts ? row.parts.split('\n') : [];
2262
+ const textParts = rawParts.map(extractOpencodeTextFromPartData).filter(Boolean);
2263
+ const text = textParts.join('\n').trim();
2264
+ if (!text) return null;
2265
+ const model = messageData ? (messageData.modelID || readOpencodeModelName(messageData.model)) : '';
2266
+ const provider = messageData ? (messageData.providerID || readOpencodeProviderName(messageData.model)) : 'opencode';
2267
+ return {
2268
+ role,
2269
+ text,
2270
+ timestamp: toOpencodeIsoTime(row.time_created || (messageData && messageData.time && messageData.time.created), ''),
2271
+ model: typeof model === 'string' ? model : '',
2272
+ provider: typeof provider === 'string' && provider ? provider : 'opencode'
2273
+ };
2274
+ }
2275
+
2276
+ function queryOpencodeSessionMessages(sessionId, options = {}) {
2277
+ const id = typeof sessionId === 'string' ? sessionId.trim() : '';
2278
+ if (!id) return null;
2279
+ const maxMessages = resolveMaxMessagesValue(options.maxMessages, DEFAULT_SESSION_DETAIL_MESSAGES);
2280
+ const limit = maxMessages === Infinity ? OPENCODE_DB_READ_LIMIT : Math.min(OPENCODE_DB_READ_LIMIT, Math.max(1, Math.floor(maxMessages)));
2281
+ return withOpencodeDb((db) => {
2282
+ const totalRow = db.prepare('select count(*) as count from message where session_id = ?').get(id) || {};
2283
+ const totalMessages = Math.max(0, Math.floor(Number(totalRow.count) || 0));
2284
+ const rows = db.prepare(`
2285
+ select m.id as message_id, m.session_id, m.time_created, m.time_updated, m.data as message_data,
2286
+ group_concat(p.data, char(10)) as parts
2287
+ from (
2288
+ select * from message where session_id = ? order by time_created desc limit ?
2289
+ ) m
2290
+ left join part p on p.message_id = m.id
2291
+ group by m.id
2292
+ order by m.time_created asc
2293
+ `).all(id, limit);
2294
+ const messages = rows.map(normalizeOpencodeMessageRow).filter(Boolean);
2295
+ const sessionRow = db.prepare('select id, directory, path, time_updated from session where id = ?').get(id) || null;
2296
+ return {
2297
+ sessionId: id,
2298
+ cwd: sessionRow && (sessionRow.path || sessionRow.directory) ? (sessionRow.path || sessionRow.directory) : '',
2299
+ updatedAt: sessionRow ? toOpencodeIsoTime(sessionRow.time_updated, '') : '',
2300
+ messages,
2301
+ totalMessages,
2302
+ clipped: maxMessages !== Infinity && totalMessages > messages.length,
2303
+ truncated: maxMessages !== Infinity && totalMessages > messages.length
2304
+ };
2305
+ }, null);
2306
+ }
2307
+
2308
+ function countOpencodeMessages(sessionId) {
2309
+ const id = typeof sessionId === 'string' ? sessionId.trim() : '';
2310
+ if (!id) return null;
2311
+ return withOpencodeDb((db) => {
2312
+ const row = db.prepare('select count(*) as count from message where session_id = ?').get(id) || {};
2313
+ return Math.max(0, Math.floor(Number(row.count) || 0));
2314
+ }, null);
2315
+ }
2316
+
2317
+ function parseOpencodeSessionSummaryRow(row, db) {
2318
+ if (!row || typeof row !== 'object') return null;
2319
+ const sessionId = typeof row.id === 'string' ? row.id.trim() : '';
2320
+ if (!sessionId) return null;
2321
+ const model = readOpencodeModelName(row.model);
2322
+ const provider = readOpencodeProviderName(row.model);
2323
+ let title = typeof row.title === 'string' && row.title.trim() ? row.title.trim() : (row.slug || sessionId);
2324
+ const firstRow = db.prepare(`
2325
+ select m.data as message_data, group_concat(p.data, char(10)) as parts
2326
+ from message m left join part p on p.message_id = m.id
2327
+ where m.session_id = ?
2328
+ group by m.id
2329
+ order by m.time_created asc
2330
+ limit 12
2331
+ `).all(sessionId).map(normalizeOpencodeMessageRow).filter(Boolean).find(item => item.role === 'user' && item.text);
2332
+ if (firstRow && firstRow.text) {
2333
+ title = truncateText(firstRow.text, 120);
2334
+ }
2335
+ const messageCount = Math.max(0, Math.floor(Number(row.message_count) || 0));
2336
+ const inputTokens = readNonNegativeInteger(row.tokens_input) || 0;
2337
+ const outputTokens = readNonNegativeInteger(row.tokens_output) || 0;
2338
+ const reasoningOutputTokens = readNonNegativeInteger(row.tokens_reasoning) || 0;
2339
+ const cachedInputTokens = readNonNegativeInteger(row.tokens_cache_read) || 0;
2340
+ const cacheCreationInputTokens = readNonNegativeInteger(row.tokens_cache_write) || 0;
2341
+ const totalTokens = inputTokens + outputTokens + reasoningOutputTokens + cachedInputTokens + cacheCreationInputTokens;
2342
+ return {
2343
+ source: 'opencode',
2344
+ sourceLabel: 'OpenCode',
2345
+ provider,
2346
+ model,
2347
+ models: model ? [model] : [],
2348
+ sessionId,
2349
+ title,
2350
+ cwd: row.path || row.directory || '',
2351
+ createdAt: toOpencodeIsoTime(row.time_created, ''),
2352
+ updatedAt: toOpencodeIsoTime(row.time_updated, ''),
2353
+ messageCount,
2354
+ totalTokens,
2355
+ contextWindow: 0,
2356
+ inputTokens,
2357
+ cachedInputTokens,
2358
+ cacheCreationInputTokens,
2359
+ outputTokens,
2360
+ reasoningOutputTokens,
2361
+ __messageCountExact: true,
2362
+ filePath: buildOpencodeVirtualSessionPath(sessionId),
2363
+ keywords: [],
2364
+ capabilities: { code: true }
2365
+ };
2366
+ }
2367
+
2368
+ function parseOpencodeSessionSummary(sessionId) {
2369
+ const id = typeof sessionId === 'string' ? sessionId.trim() : '';
2370
+ if (!id) return null;
2371
+ return withOpencodeDb((db) => {
2372
+ const row = db.prepare(`
2373
+ select s.*, (select count(*) from message m where m.session_id = s.id) as message_count
2374
+ from session s where s.id = ?
2375
+ `).get(id);
2376
+ return parseOpencodeSessionSummaryRow(row, db);
2377
+ }, null);
2378
+ }
2379
+
2380
+ function listOpencodeSessions(limit, options = {}) {
2381
+ if (!isOpencodeDbAvailable()) return [];
2382
+ const scanFactor = Number.isFinite(Number(options.scanFactor)) ? Math.max(1, Number(options.scanFactor)) : SESSION_SCAN_FACTOR;
2383
+ const targetCount = Number.isFinite(Number(options.targetCount)) ? Math.max(1, Math.floor(Number(options.targetCount))) : Math.max(1, Math.floor(limit * scanFactor));
2384
+ const scanCount = Number.isFinite(Number(options.scanCount)) ? Math.max(targetCount, Math.floor(Number(options.scanCount))) : Math.max(targetCount, Math.min(SESSION_SCAN_MIN_FILES, MAX_SESSION_LIST_SIZE * SESSION_SCAN_FACTOR));
2385
+ return withOpencodeDb((db) => {
2386
+ const rows = db.prepare(`
2387
+ select s.*, (select count(*) from message m where m.session_id = s.id) as message_count
2388
+ from session s
2389
+ where s.time_archived is null
2390
+ order by s.time_updated desc
2391
+ limit ?
2392
+ `).all(Math.min(OPENCODE_DB_READ_LIMIT, Math.max(scanCount, targetCount)));
2393
+ const sessions = [];
2394
+ for (const row of rows) {
2395
+ const summary = parseOpencodeSessionSummaryRow(row, db);
2396
+ if (summary) sessions.push(summary);
2397
+ if (sessions.length >= targetCount) break;
2398
+ }
2399
+ return mergeAndLimitSessions(sessions, limit);
2400
+ }, []);
2401
+ }
2402
+
2128
2403
  function getCodexmateDerivedSessionsRoot(target) {
2129
2404
  if (target === 'claude') {
2130
2405
  return CODEXMATE_DERIVED_CLAUDE_DIR;
@@ -4081,7 +4356,7 @@ function countConversationMessagesInRecords(records, source) {
4081
4356
  }
4082
4357
  continue;
4083
4358
  }
4084
- if (source === 'codebuddy') {
4359
+ if (source === 'codebuddy' || source === 'workbuddy') {
4085
4360
  if (record && record.type === 'message') {
4086
4361
  const role = normalizeRole(record.role);
4087
4362
  if (role === 'assistant' || role === 'user' || role === 'system') {
@@ -4164,7 +4439,7 @@ async function countConversationMessagesInFile(filePath, source) {
4164
4439
  role = normalizeRole(record.payload.role);
4165
4440
  text = extractMessageText(record.payload.content);
4166
4441
  }
4167
- } else if (source === 'codebuddy') {
4442
+ } else if (source === 'codebuddy' || source === 'workbuddy') {
4168
4443
  if (record && record.type === 'message') {
4169
4444
  role = normalizeRole(record.role);
4170
4445
  if (role === 'assistant' || role === 'user' || role === 'system') {
@@ -4344,15 +4619,7 @@ async function resolveSessionTrashEntryExactMessageCount(entry) {
4344
4619
  }
4345
4620
 
4346
4621
  async function hydrateSessionTrashEntries(entries, options = {}) {
4347
- const source = options.source === 'claude'
4348
- ? 'claude'
4349
- : (options.source === 'codex'
4350
- ? 'codex'
4351
- : (options.source === 'gemini'
4352
- ? 'gemini'
4353
- : (options.source === 'codebuddy'
4354
- ? 'codebuddy'
4355
- : (options.source === 'pi' ? 'pi' : 'all'))));
4622
+ const source = normalizeSessionSourceName(options.source, '') || 'all';
4356
4623
  const hydratedEntries = await mapWithConcurrency(Array.isArray(entries) ? entries : [], 8, async (entry) => {
4357
4624
  const normalizedEntry = normalizeSessionTrashEntry(entry);
4358
4625
  if (!normalizedEntry) {
@@ -4361,7 +4628,7 @@ async function hydrateSessionTrashEntries(entries, options = {}) {
4361
4628
  return await resolveSessionTrashEntryExactMessageCount(normalizedEntry);
4362
4629
  });
4363
4630
 
4364
- if (source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'pi') {
4631
+ if (normalizeSessionSourceName(source, '')) {
4365
4632
  return hydratedEntries.filter((entry) => entry.source === source);
4366
4633
  }
4367
4634
  return hydratedEntries;
@@ -4375,15 +4642,7 @@ async function hydrateSessionItemsExactMessageCount(items) {
4375
4642
  if (item.__messageCountExact === true) {
4376
4643
  return item;
4377
4644
  }
4378
- const source = item.source === 'claude'
4379
- ? 'claude'
4380
- : (item.source === 'codex'
4381
- ? 'codex'
4382
- : (item.source === 'gemini'
4383
- ? 'gemini'
4384
- : (item.source === 'codebuddy'
4385
- ? 'codebuddy'
4386
- : (item.source === 'pi' ? 'pi' : ''))));
4645
+ const source = normalizeSessionSourceName(item.source, '');
4387
4646
  const filePath = typeof item.filePath === 'string' ? item.filePath : '';
4388
4647
  if (!source || !filePath || !fs.existsSync(filePath)) {
4389
4648
  return item;
@@ -4410,6 +4669,40 @@ async function hydrateSessionItemsExactMessageCount(items) {
4410
4669
  });
4411
4670
  }
4412
4671
 
4672
+
4673
+ function normalizeSessionSourceName(value, fallback = '') {
4674
+ const source = typeof value === 'string' ? value.trim().toLowerCase() : '';
4675
+ if (source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'workbuddy' || source === 'pi' || source === 'opencode') {
4676
+ return source;
4677
+ }
4678
+ return fallback;
4679
+ }
4680
+
4681
+ function isSessionSourceOrAll(value) {
4682
+ const source = typeof value === 'string' ? value.trim().toLowerCase() : '';
4683
+ return source === 'all' || !!normalizeSessionSourceName(source, '');
4684
+ }
4685
+
4686
+ function getSessionSourceLabel(source) {
4687
+ if (source === 'claude') return 'Claude Code';
4688
+ if (source === 'gemini') return 'Gemini CLI';
4689
+ if (source === 'codebuddy') return 'CodeBuddy Code';
4690
+ if (source === 'workbuddy') return 'WorkBuddy AI';
4691
+ if (source === 'pi') return 'Pi';
4692
+ if (source === 'opencode') return 'OpenCode';
4693
+ return 'Codex';
4694
+ }
4695
+
4696
+ function getSessionSourceProvider(source) {
4697
+ if (source === 'claude') return 'claude';
4698
+ if (source === 'gemini') return 'gemini';
4699
+ if (source === 'codebuddy') return 'codebuddy';
4700
+ if (source === 'workbuddy') return 'workbuddy';
4701
+ if (source === 'pi') return 'pi';
4702
+ if (source === 'opencode') return 'opencode';
4703
+ return 'codex';
4704
+ }
4705
+
4413
4706
  function getSessionExportKeyForApi(item) {
4414
4707
  const source = item && item.source ? String(item.source).trim() : '';
4415
4708
  const sessionId = item && item.sessionId ? String(item.sessionId) : '';
@@ -4427,20 +4720,21 @@ async function readSessionMessageCounts(params = {}) {
4427
4720
  return undefined;
4428
4721
  }
4429
4722
  const key = getSessionExportKeyForApi(item);
4430
- const source = item.source === 'claude'
4431
- ? 'claude'
4432
- : (item.source === 'codex'
4433
- ? 'codex'
4434
- : (item.source === 'gemini'
4435
- ? 'gemini'
4436
- : (item.source === 'codebuddy'
4437
- ? 'codebuddy'
4438
- : (item.source === 'pi' ? 'pi' : ''))));
4723
+ const source = normalizeSessionSourceName(item.source, '');
4439
4724
  const filePath = typeof item.filePath === 'string' ? item.filePath : '';
4440
- if (!source || !filePath || !fs.existsSync(filePath)) {
4725
+ if (!source) {
4441
4726
  return { key };
4442
4727
  }
4443
- const exactMessageCount = await countConversationMessagesInFile(filePath, source);
4728
+ let exactMessageCount;
4729
+ if (source === 'opencode') {
4730
+ const opencodeSessionId = item.sessionId || parseOpencodeVirtualSessionId(filePath);
4731
+ exactMessageCount = countOpencodeMessages(opencodeSessionId);
4732
+ } else {
4733
+ if (!filePath || !fs.existsSync(filePath)) {
4734
+ return { key };
4735
+ }
4736
+ exactMessageCount = await countConversationMessagesInFile(filePath, source);
4737
+ }
4444
4738
  if (!Number.isFinite(Number(exactMessageCount))) {
4445
4739
  return { key };
4446
4740
  }
@@ -4570,6 +4864,10 @@ function createSessionQueryScanState(tokens, options = {}) {
4570
4864
  const snippetLimit = Number.isFinite(Number(options.snippetLimit))
4571
4865
  ? Math.max(0, Number(options.snippetLimit))
4572
4866
  : 0;
4867
+ const positionsLimitRaw = Number(options.positionsLimit);
4868
+ const positionsLimit = Number.isFinite(positionsLimitRaw) && positionsLimitRaw > 0
4869
+ ? Math.floor(positionsLimitRaw)
4870
+ : 0;
4573
4871
 
4574
4872
  return {
4575
4873
  tokens,
@@ -4577,8 +4875,10 @@ function createSessionQueryScanState(tokens, options = {}) {
4577
4875
  roleFilter,
4578
4876
  maxMatches,
4579
4877
  snippetLimit,
4878
+ positionsLimit,
4580
4879
  count: 0,
4581
4880
  snippets: [],
4881
+ positions: [],
4582
4882
  leadingSystem: roleFilter !== 'system'
4583
4883
  };
4584
4884
  }
@@ -4610,6 +4910,13 @@ function consumeSessionQueryMessage(state, message) {
4610
4910
  if (state.snippetLimit > 0 && state.snippets.length < state.snippetLimit) {
4611
4911
  state.snippets.push(truncateText(text));
4612
4912
  }
4913
+ if (state.positionsLimit > 0 && state.positions.length < state.positionsLimit) {
4914
+ state.positions.push({
4915
+ lineIndex: Number.isInteger(message.recordLineIndex) ? message.recordLineIndex : -1,
4916
+ role,
4917
+ timestamp: typeof message.timestamp === 'string' ? message.timestamp : ''
4918
+ });
4919
+ }
4613
4920
  return state.count >= state.maxMatches;
4614
4921
  }
4615
4922
 
@@ -4617,7 +4924,8 @@ function buildSessionQueryScanResult(state) {
4617
4924
  return {
4618
4925
  hit: !!(state && state.count > 0),
4619
4926
  count: state && Number.isFinite(state.count) ? state.count : 0,
4620
- snippets: state && Array.isArray(state.snippets) ? state.snippets : []
4927
+ snippets: state && Array.isArray(state.snippets) ? state.snippets : [],
4928
+ positions: state && Array.isArray(state.positions) ? state.positions : []
4621
4929
  };
4622
4930
  }
4623
4931
 
@@ -4625,12 +4933,14 @@ function scanSessionContentForQueryInRecords(records, source, state) {
4625
4933
  if (!Array.isArray(records) || !state) {
4626
4934
  return buildSessionQueryScanResult(state);
4627
4935
  }
4628
-
4629
- for (const record of records) {
4936
+ for (let recordIndex = 0; recordIndex < records.length; recordIndex++) {
4937
+ const record = records[recordIndex];
4630
4938
  const message = extractMessageFromRecord(record, source);
4631
4939
  if (!message) {
4632
4940
  continue;
4633
4941
  }
4942
+ message.recordLineIndex = recordIndex;
4943
+ message.timestamp = toIsoTime(record && record.timestamp, '');
4634
4944
  if (consumeSessionQueryMessage(state, message)) {
4635
4945
  break;
4636
4946
  }
@@ -4641,12 +4951,12 @@ function scanSessionContentForQueryInRecords(records, source, state) {
4641
4951
 
4642
4952
  async function scanSessionContentForQuery(session, tokens, options = {}) {
4643
4953
  if (!session || !Array.isArray(tokens) || tokens.length === 0) {
4644
- return { hit: false, count: 0, snippets: [] };
4954
+ return { hit: false, count: 0, snippets: [], positions: [] };
4645
4955
  }
4646
4956
 
4647
4957
  const filePath = resolveSessionFilePath(session.source, session.filePath, session.sessionId);
4648
4958
  if (!filePath) {
4649
- return { hit: false, count: 0, snippets: [] };
4959
+ return { hit: false, count: 0, snippets: [], positions: [] };
4650
4960
  }
4651
4961
 
4652
4962
  const rawMaxBytes = Number(options.maxBytes);
@@ -4654,8 +4964,17 @@ async function scanSessionContentForQuery(session, tokens, options = {}) {
4654
4964
  ? Math.max(1024, rawMaxBytes)
4655
4965
  : 0;
4656
4966
  const state = createSessionQueryScanState(tokens, options);
4967
+ if (session.source === 'opencode') {
4968
+ const extracted = queryOpencodeSessionMessages(session.sessionId || parseOpencodeVirtualSessionId(filePath), { maxMessages: Infinity });
4969
+ const messages = extracted && Array.isArray(extracted.messages) ? extracted.messages : [];
4970
+ for (let i = 0; i < messages.length; i += 1) {
4971
+ const message = { ...messages[i], recordLineIndex: i };
4972
+ if (consumeSessionQueryMessage(state, message)) break;
4973
+ }
4974
+ return buildSessionQueryScanResult(state);
4975
+ }
4657
4976
  if (session.source === 'gemini') {
4658
- if (state.roleFilter !== 'all') {
4977
+ if (state.roleFilter !== 'all' || state.positionsLimit > 0) {
4659
4978
  let json;
4660
4979
  try {
4661
4980
  json = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
@@ -4669,7 +4988,12 @@ async function scanSessionContentForQuery(session, tokens, options = {}) {
4669
4988
  if (!role) continue;
4670
4989
  const text = extractMessageText(extractGeminiMessageText(entry.content ?? entry.message ?? entry.text));
4671
4990
  if (!text) continue;
4672
- if (consumeSessionQueryMessage(state, { role, text })) {
4991
+ if (consumeSessionQueryMessage(state, {
4992
+ role,
4993
+ text,
4994
+ timestamp: toIsoTime(entry.timestamp ?? entry.time ?? entry.at, ''),
4995
+ recordLineIndex: -1
4996
+ })) {
4673
4997
  break;
4674
4998
  }
4675
4999
  }
@@ -4709,7 +5033,10 @@ async function scanSessionContentForQuery(session, tokens, options = {}) {
4709
5033
  rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
4710
5034
 
4711
5035
  let bytesRead = 0;
5036
+ let lineIndex = 0;
4712
5037
  for await (const line of rl) {
5038
+ const currentLineIndex = lineIndex;
5039
+ lineIndex += 1;
4713
5040
  if (maxBytes > 0 && bytesRead >= maxBytes) {
4714
5041
  break;
4715
5042
  }
@@ -4731,6 +5058,8 @@ async function scanSessionContentForQuery(session, tokens, options = {}) {
4731
5058
  if (!message) {
4732
5059
  continue;
4733
5060
  }
5061
+ message.recordLineIndex = currentLineIndex;
5062
+ message.timestamp = toIsoTime(record.timestamp, '');
4734
5063
  if (consumeSessionQueryMessage(state, message)) {
4735
5064
  break;
4736
5065
  }
@@ -4949,13 +5278,7 @@ function setSessionListCache(cacheKey, value) {
4949
5278
  }
4950
5279
 
4951
5280
  function buildSessionInventoryCacheKey(source, limit, options = {}) {
4952
- const normalizedSource = source === 'claude'
4953
- ? 'claude'
4954
- : (source === 'gemini'
4955
- ? 'gemini'
4956
- : (source === 'codebuddy'
4957
- ? 'codebuddy'
4958
- : (source === 'pi' ? 'pi' : 'codex')));
5281
+ const normalizedSource = normalizeSessionSourceName(source, 'codex');
4959
5282
  const normalizedLimit = Number.isFinite(Number(limit))
4960
5283
  ? Math.max(1, Math.floor(Number(limit)))
4961
5284
  : 1;
@@ -5041,7 +5364,7 @@ function getSessionInventoryCache(cacheKey, forceRefresh = false) {
5041
5364
  }
5042
5365
 
5043
5366
  function registerSessionFileLookupEntries(source, sessions = []) {
5044
- const normalizedSource = source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'pi'
5367
+ const normalizedSource = source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'workbuddy' || source === 'pi' || source === 'opencode'
5045
5368
  ? source
5046
5369
  : 'codex';
5047
5370
  const store = g_sessionFileLookupCache[normalizedSource];
@@ -5082,7 +5405,7 @@ function setSessionInventoryCache(cacheKey, source, value) {
5082
5405
  }
5083
5406
 
5084
5407
  function listSessionInventoryBySource(source, limit, scanOptions = {}, options = {}) {
5085
- const normalizedSource = source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'pi'
5408
+ const normalizedSource = source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'workbuddy' || source === 'pi' || source === 'opencode'
5086
5409
  ? source
5087
5410
  : 'codex';
5088
5411
  const forceRefresh = !!options.forceRefresh;
@@ -5098,9 +5421,13 @@ function listSessionInventoryBySource(source, limit, scanOptions = {}, options =
5098
5421
  ? listGeminiSessions(limit, scanOptions)
5099
5422
  : (normalizedSource === 'codebuddy'
5100
5423
  ? listCodeBuddySessions(limit, scanOptions)
5101
- : (normalizedSource === 'pi'
5102
- ? listPiSessions(limit, scanOptions)
5103
- : listCodexSessions(limit, scanOptions))));
5424
+ : (normalizedSource === 'workbuddy'
5425
+ ? listWorkBuddySessions(limit, scanOptions)
5426
+ : (normalizedSource === 'pi'
5427
+ ? listPiSessions(limit, scanOptions)
5428
+ : (normalizedSource === 'opencode'
5429
+ ? listOpencodeSessions(limit, scanOptions)
5430
+ : listCodexSessions(limit, scanOptions))))));
5104
5431
  setSessionInventoryCache(cacheKey, normalizedSource, sessions);
5105
5432
  return sessions;
5106
5433
  }
@@ -5113,7 +5440,9 @@ function invalidateSessionListCache() {
5113
5440
  claude: new Map(),
5114
5441
  gemini: new Map(),
5115
5442
  codebuddy: new Map(),
5116
- pi: new Map()
5443
+ workbuddy: new Map(),
5444
+ pi: new Map(),
5445
+ opencode: new Map()
5117
5446
  };
5118
5447
  }
5119
5448
 
@@ -5347,13 +5676,7 @@ function readSessionProviderFromRecord(record, source = '') {
5347
5676
  if (provider) {
5348
5677
  return provider;
5349
5678
  }
5350
- return source === 'claude'
5351
- ? 'claude'
5352
- : (source === 'gemini'
5353
- ? 'gemini'
5354
- : (source === 'codebuddy'
5355
- ? 'codebuddy'
5356
- : (source === 'pi' ? 'pi' : 'codex')));
5679
+ return getSessionSourceProvider(source);
5357
5680
  }
5358
5681
 
5359
5682
  function applySessionUsageSummaryFromRecord(state, record, source) {
@@ -5898,6 +6221,175 @@ function parseCodeBuddySessionSummary(filePath, options = {}) {
5898
6221
  };
5899
6222
  }
5900
6223
 
6224
+ // WorkBuddy AI 会话记录与 CodeBuddy 同族(type:"message" + 顶层 role/content,
6225
+ // 且存在 __codebuddyLocal 字段),标题来自 ai-title 记录,模型名位于 providerData.model,
6226
+ // token 用量位于嵌套 message.usage。
6227
+ function parseWorkBuddySessionSummary(filePath, options = {}) {
6228
+ const summaryReadBytes = Number.isFinite(Number(options.summaryReadBytes))
6229
+ ? Math.max(1024, Math.floor(Number(options.summaryReadBytes)))
6230
+ : SESSION_SUMMARY_READ_BYTES;
6231
+ const titleReadBytes = Number.isFinite(Number(options.titleReadBytes))
6232
+ ? Math.max(1024, Math.floor(Number(options.titleReadBytes)))
6233
+ : SESSION_TITLE_READ_BYTES;
6234
+ const records = parseJsonlHeadRecords(filePath, summaryReadBytes);
6235
+ if (records.length === 0) {
6236
+ return null;
6237
+ }
6238
+
6239
+ let stat;
6240
+ try {
6241
+ stat = fs.statSync(filePath);
6242
+ } catch (_) {
6243
+ return null;
6244
+ }
6245
+
6246
+ let sessionId = path.basename(filePath, '.jsonl');
6247
+ let cwd = '';
6248
+ let firstPrompt = '';
6249
+ let aiTitle = '';
6250
+ let messageCount = 0;
6251
+ let totalTokens = 0;
6252
+ let contextWindow = 0;
6253
+ let inputTokens = 0;
6254
+ let cachedInputTokens = 0;
6255
+ let cacheCreationInputTokens = 0;
6256
+ let outputTokens = 0;
6257
+ let reasoningOutputTokens = 0;
6258
+ let provider = 'workbuddy';
6259
+ let model = '';
6260
+ const models = [];
6261
+ const usageState = { totalTokens, contextWindow, inputTokens, cachedInputTokens, cacheCreationInputTokens, outputTokens, reasoningOutputTokens };
6262
+ const previewMessages = [];
6263
+ let createdAt = '';
6264
+ let updatedAt = stat.mtime.toISOString();
6265
+
6266
+ for (const record of records) {
6267
+ if (!createdAt && record && record.timestamp) {
6268
+ createdAt = toIsoTime(record.timestamp, createdAt);
6269
+ }
6270
+ if (record && record.timestamp) {
6271
+ updatedAt = updateLatestIso(updatedAt, record.timestamp);
6272
+ }
6273
+
6274
+ applySessionUsageSummaryFromRecord(usageState, record, 'workbuddy');
6275
+ totalTokens = usageState.totalTokens || 0;
6276
+ contextWindow = usageState.contextWindow || 0;
6277
+ inputTokens = usageState.inputTokens || 0;
6278
+ cachedInputTokens = usageState.cachedInputTokens || 0;
6279
+ cacheCreationInputTokens = usageState.cacheCreationInputTokens || 0;
6280
+ outputTokens = usageState.outputTokens || 0;
6281
+ reasoningOutputTokens = usageState.reasoningOutputTokens || 0;
6282
+
6283
+ if (record && typeof record.sessionId === 'string' && record.sessionId.trim()) {
6284
+ sessionId = record.sessionId.trim();
6285
+ }
6286
+ if (!cwd && record && typeof record.cwd === 'string' && record.cwd.trim()) {
6287
+ cwd = record.cwd.trim();
6288
+ }
6289
+
6290
+ provider = readExplicitSessionProviderFromRecord(record) || provider;
6291
+ const recordModels = readSessionModelsFromRecord(record);
6292
+ for (const recordModel of recordModels) {
6293
+ if (!models.includes(recordModel)) {
6294
+ models.push(recordModel);
6295
+ }
6296
+ }
6297
+ model = recordModels[0] || model;
6298
+
6299
+ if (record && record.type === 'ai-title' && typeof record.aiTitle === 'string' && record.aiTitle.trim()) {
6300
+ aiTitle = record.aiTitle.trim();
6301
+ }
6302
+
6303
+ if (record && record.type === 'message') {
6304
+ const role = normalizeRole(record.role);
6305
+ if (role === 'assistant' || role === 'user' || role === 'system') {
6306
+ const content = record.message?.content ?? record.content ?? '';
6307
+ previewMessages.push({
6308
+ role,
6309
+ text: extractMessageText(content)
6310
+ });
6311
+ }
6312
+ }
6313
+ }
6314
+
6315
+ const tailRecords = parseJsonlTailRecords(filePath, summaryReadBytes);
6316
+ for (const record of tailRecords) {
6317
+ applySessionUsageSummaryFromRecord(usageState, record, 'workbuddy');
6318
+ totalTokens = usageState.totalTokens || 0;
6319
+ contextWindow = usageState.contextWindow || 0;
6320
+ inputTokens = usageState.inputTokens || 0;
6321
+ cachedInputTokens = usageState.cachedInputTokens || 0;
6322
+ cacheCreationInputTokens = usageState.cacheCreationInputTokens || 0;
6323
+ outputTokens = usageState.outputTokens || 0;
6324
+ reasoningOutputTokens = usageState.reasoningOutputTokens || 0;
6325
+ provider = readExplicitSessionProviderFromRecord(record) || provider;
6326
+ const recordModels = readSessionModelsFromRecord(record);
6327
+ for (const recordModel of recordModels) {
6328
+ if (!models.includes(recordModel)) {
6329
+ models.push(recordModel);
6330
+ }
6331
+ }
6332
+ model = recordModels[0] || model;
6333
+ }
6334
+
6335
+ const filteredPreviewMessages = removeLeadingSystemMessage(previewMessages);
6336
+ messageCount = filteredPreviewMessages.length;
6337
+ const firstUser = filteredPreviewMessages.find(item => item.role === 'user' && item.text);
6338
+ if (firstUser) {
6339
+ firstPrompt = truncateText(firstUser.text);
6340
+ }
6341
+
6342
+ if (!firstPrompt) {
6343
+ const titleRecords = parseJsonlHeadRecords(filePath, titleReadBytes);
6344
+ const titleMessages = [];
6345
+ for (const record of titleRecords) {
6346
+ if (record && record.type === 'message') {
6347
+ const role = normalizeRole(record.role);
6348
+ if (role === 'assistant' || role === 'user' || role === 'system') {
6349
+ const content = record.message?.content ?? record.content ?? '';
6350
+ titleMessages.push({
6351
+ role,
6352
+ text: extractMessageText(content)
6353
+ });
6354
+ }
6355
+ }
6356
+ }
6357
+
6358
+ const filteredTitleMessages = removeLeadingSystemMessage(titleMessages);
6359
+ const titleUser = filteredTitleMessages.find(item => item.role === 'user' && item.text);
6360
+ if (titleUser) {
6361
+ firstPrompt = truncateText(titleUser.text);
6362
+ }
6363
+ }
6364
+
6365
+ messageCount = Math.max(0, messageCount);
6366
+
6367
+ return {
6368
+ source: 'workbuddy',
6369
+ sourceLabel: 'WorkBuddy AI',
6370
+ provider,
6371
+ model,
6372
+ models,
6373
+ sessionId,
6374
+ title: aiTitle || firstPrompt || sessionId,
6375
+ cwd,
6376
+ createdAt,
6377
+ updatedAt,
6378
+ messageCount,
6379
+ totalTokens,
6380
+ contextWindow,
6381
+ inputTokens,
6382
+ cachedInputTokens,
6383
+ cacheCreationInputTokens,
6384
+ outputTokens,
6385
+ reasoningOutputTokens,
6386
+ __messageCountExact: isSessionSummaryMessageCountExact(stat, summaryReadBytes),
6387
+ filePath,
6388
+ keywords: [],
6389
+ capabilities: { code: true }
6390
+ };
6391
+ }
6392
+
5901
6393
  function extractGeminiMessageText(content) {
5902
6394
  if (typeof content === 'string') {
5903
6395
  return content;
@@ -6662,6 +7154,54 @@ function listCodeBuddySessions(limit, options = {}) {
6662
7154
  return mergeAndLimitSessions(sessions, limit);
6663
7155
  }
6664
7156
 
7157
+ function listWorkBuddySessions(limit, options = {}) {
7158
+ const projectsDir = getWorkBuddyProjectsDir();
7159
+ if (!fs.existsSync(projectsDir)) {
7160
+ return [];
7161
+ }
7162
+
7163
+ const scanFactor = Number.isFinite(Number(options.scanFactor))
7164
+ ? Math.max(1, Number(options.scanFactor))
7165
+ : SESSION_SCAN_FACTOR;
7166
+ const minFiles = Number.isFinite(Number(options.minFiles))
7167
+ ? Math.max(1, Number(options.minFiles))
7168
+ : Math.min(SESSION_SCAN_MIN_FILES, MAX_SESSION_LIST_SIZE * SESSION_SCAN_FACTOR);
7169
+ const targetCount = Number.isFinite(Number(options.targetCount))
7170
+ ? Math.max(1, Math.floor(Number(options.targetCount)))
7171
+ : Math.max(1, Math.floor(limit * scanFactor));
7172
+ const scanCount = Number.isFinite(Number(options.scanCount))
7173
+ ? Math.max(targetCount, Math.floor(Number(options.scanCount)))
7174
+ : Math.max(targetCount, minFiles);
7175
+ const maxFilesScanned = Number.isFinite(Number(options.maxFilesScanned))
7176
+ ? Math.max(scanCount, Math.floor(Number(options.maxFilesScanned)))
7177
+ : Math.max(scanCount * 2, minFiles);
7178
+ const summaryReadBytes = Number.isFinite(Number(options.summaryReadBytes))
7179
+ ? Math.max(1024, Math.floor(Number(options.summaryReadBytes)))
7180
+ : SESSION_SUMMARY_READ_BYTES;
7181
+ const titleReadBytes = Number.isFinite(Number(options.titleReadBytes))
7182
+ ? Math.max(1024, Math.floor(Number(options.titleReadBytes)))
7183
+ : SESSION_TITLE_READ_BYTES;
7184
+
7185
+ const files = collectRecentJsonlFiles(projectsDir, {
7186
+ returnCount: scanCount,
7187
+ maxFilesScanned
7188
+ });
7189
+ const sessions = [];
7190
+ for (const filePath of files) {
7191
+ const summary = parseWorkBuddySessionSummary(filePath, {
7192
+ summaryReadBytes,
7193
+ titleReadBytes
7194
+ });
7195
+ if (summary) {
7196
+ sessions.push(summary);
7197
+ }
7198
+ if (sessions.length >= targetCount) {
7199
+ break;
7200
+ }
7201
+ }
7202
+ return mergeAndLimitSessions(sessions, limit);
7203
+ }
7204
+
6665
7205
  function listPiSessions(limit, options = {}) {
6666
7206
  const sessionsDir = getPiSessionsDir();
6667
7207
  if (!fs.existsSync(sessionsDir)) {
@@ -6711,9 +7251,7 @@ function listPiSessions(limit, options = {}) {
6711
7251
  }
6712
7252
 
6713
7253
  async function listAllSessions(params = {}) {
6714
- const source = params.source === 'codex' || params.source === 'claude' || params.source === 'gemini' || params.source === 'codebuddy' || params.source === 'pi'
6715
- ? params.source
6716
- : 'all';
7254
+ const source = normalizeSessionSourceName(params.source, '') || 'all';
6717
7255
  const rawLimit = Number(params.limit);
6718
7256
  const limit = Number.isFinite(rawLimit)
6719
7257
  ? Math.max(1, Math.min(rawLimit, MAX_SESSION_LIST_SIZE))
@@ -6761,9 +7299,15 @@ async function listAllSessions(params = {}) {
6761
7299
  if (source === 'all' || source === 'codebuddy') {
6762
7300
  sessions = sessions.concat(listSessionInventoryBySource('codebuddy', limit, scanOptions, { forceRefresh }));
6763
7301
  }
7302
+ if (source === 'all' || source === 'workbuddy') {
7303
+ sessions = sessions.concat(listSessionInventoryBySource('workbuddy', limit, scanOptions, { forceRefresh }));
7304
+ }
6764
7305
  if (source === 'all' || source === 'pi') {
6765
7306
  sessions = sessions.concat(listSessionInventoryBySource('pi', limit, scanOptions, { forceRefresh }));
6766
7307
  }
7308
+ if (source === 'all' || source === 'opencode') {
7309
+ sessions = sessions.concat(listSessionInventoryBySource('opencode', limit, scanOptions, { forceRefresh }));
7310
+ }
6767
7311
 
6768
7312
  if (hasPathFilter) {
6769
7313
  sessions = sessions.filter(item => matchesSessionPathFilter(item, normalizedPathFilter));
@@ -6786,9 +7330,7 @@ async function listAllSessions(params = {}) {
6786
7330
  }
6787
7331
 
6788
7332
  async function listAllSessionsData(params = {}) {
6789
- const source = params.source === 'codex' || params.source === 'claude'
6790
- ? params.source
6791
- : 'all';
7333
+ const source = normalizeSessionSourceName(params.source, '') || 'all';
6792
7334
  const rawLimit = Number(params.limit);
6793
7335
  const limit = Number.isFinite(rawLimit)
6794
7336
  ? Math.max(1, Math.min(rawLimit, MAX_SESSION_LIST_SIZE))
@@ -6845,6 +7387,7 @@ async function listSessionUsage(params = {}) {
6845
7387
  parseCodexSessionSummary,
6846
7388
  parseClaudeSessionSummary,
6847
7389
  parseCodeBuddySessionSummary,
7390
+ parseWorkBuddySessionSummary,
6848
7391
  parseGeminiSessionSummary,
6849
7392
  parsePiSessionSummary,
6850
7393
  MAX_SESSION_USAGE_LIST_SIZE,
@@ -6860,10 +7403,10 @@ async function exportSessionUsage(params = {}) {
6860
7403
 
6861
7404
  function listSessionPaths(params = {}) {
6862
7405
  const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : '';
6863
- if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'pi' && source !== 'all') {
7406
+ if (source && !isSessionSourceOrAll(source)) {
6864
7407
  return [];
6865
7408
  }
6866
- const validSource = source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'pi' ? source : 'all';
7409
+ const validSource = normalizeSessionSourceName(source, '') || (source === 'all' ? 'all' : 'all');
6867
7410
  const rawLimit = Number(params.limit);
6868
7411
  const limit = Number.isFinite(rawLimit)
6869
7412
  ? Math.max(1, Math.min(rawLimit, MAX_SESSION_PATH_LIST_SIZE))
@@ -6897,9 +7440,15 @@ function listSessionPaths(params = {}) {
6897
7440
  if (validSource === 'all' || validSource === 'codebuddy') {
6898
7441
  sessions = sessions.concat(listSessionInventoryBySource('codebuddy', gatherLimit, scanOptions, { forceRefresh }));
6899
7442
  }
7443
+ if (validSource === 'all' || validSource === 'workbuddy') {
7444
+ sessions = sessions.concat(listSessionInventoryBySource('workbuddy', gatherLimit, scanOptions, { forceRefresh }));
7445
+ }
6900
7446
  if (validSource === 'all' || validSource === 'pi') {
6901
7447
  sessions = sessions.concat(listSessionInventoryBySource('pi', gatherLimit, scanOptions, { forceRefresh }));
6902
7448
  }
7449
+ if (validSource === 'all' || validSource === 'opencode') {
7450
+ sessions = sessions.concat(listSessionInventoryBySource('opencode', gatherLimit, scanOptions, { forceRefresh }));
7451
+ }
6903
7452
 
6904
7453
  const dedupedPaths = [];
6905
7454
  const seen = new Set();
@@ -6925,9 +7474,13 @@ function listSessionPaths(params = {}) {
6925
7474
  }
6926
7475
 
6927
7476
  function resolveSessionFilePath(source, filePath, sessionId) {
6928
- const normalizedSource = source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'pi'
6929
- ? source
6930
- : 'codex';
7477
+ const normalizedSource = normalizeSessionSourceName(source, 'codex');
7478
+ if (normalizedSource === 'opencode') {
7479
+ const id = typeof sessionId === 'string' && sessionId.trim()
7480
+ ? sessionId.trim()
7481
+ : parseOpencodeVirtualSessionId(filePath);
7482
+ return id && parseOpencodeSessionSummary(id) ? buildOpencodeVirtualSessionPath(id) : '';
7483
+ }
6931
7484
  const homeDir = process && process.env && process.env.HOME ? process.env.HOME : '';
6932
7485
  const derivedCodexDir = homeDir ? `${homeDir}/.codexmate/sessions/derived/codex` : '';
6933
7486
  const derivedClaudeDir = homeDir ? `${homeDir}/.codexmate/sessions/derived/claude` : '';
@@ -6937,9 +7490,11 @@ function resolveSessionFilePath(source, filePath, sessionId) {
6937
7490
  ? [getGeminiTmpDir()]
6938
7491
  : (normalizedSource === 'codebuddy'
6939
7492
  ? [getCodeBuddyProjectsDir()]
6940
- : (normalizedSource === 'pi'
6941
- ? [getPiSessionsDir()]
6942
- : [getCodexSessionsDir(), derivedCodexDir])));
7493
+ : (normalizedSource === 'workbuddy'
7494
+ ? [getWorkBuddyProjectsDir()]
7495
+ : (normalizedSource === 'pi'
7496
+ ? [getPiSessionsDir()]
7497
+ : [getCodexSessionsDir(), derivedCodexDir]))));
6943
7498
  const availableRoots = roots.filter((dirPath) => dirPath && fs.existsSync(dirPath));
6944
7499
  if (availableRoots.length === 0) {
6945
7500
  return '';
@@ -7485,23 +8040,11 @@ function moveFileSync(sourcePath, targetPath) {
7485
8040
 
7486
8041
  function buildSessionSummaryFallback(source, filePath, sessionId = '') {
7487
8042
  const resolvedSessionId = sessionId || path.basename(filePath, '.jsonl');
7488
- const sourceLabel = source === 'claude'
7489
- ? 'Claude Code'
7490
- : (source === 'gemini'
7491
- ? 'Gemini CLI'
7492
- : (source === 'codebuddy'
7493
- ? 'CodeBuddy Code'
7494
- : (source === 'pi' ? 'Pi' : 'Codex')));
8043
+ const sourceLabel = getSessionSourceLabel(source);
7495
8044
  return {
7496
8045
  source,
7497
8046
  sourceLabel,
7498
- provider: source === 'claude'
7499
- ? 'claude'
7500
- : (source === 'gemini'
7501
- ? 'gemini'
7502
- : (source === 'codebuddy'
7503
- ? 'codebuddy'
7504
- : (source === 'pi' ? 'pi' : 'codex'))),
8047
+ provider: getSessionSourceProvider(source),
7505
8048
  sessionId: resolvedSessionId,
7506
8049
  title: resolvedSessionId,
7507
8050
  cwd: '',
@@ -7512,7 +8055,7 @@ function buildSessionSummaryFallback(source, filePath, sessionId = '') {
7512
8055
  contextWindow: 0,
7513
8056
  filePath,
7514
8057
  keywords: [],
7515
- capabilities: source === 'claude' || source === 'gemini' || source === 'codebuddy' ? { code: true } : {}
8058
+ capabilities: source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'workbuddy' || source === 'opencode' ? { code: true } : {}
7516
8059
  };
7517
8060
  }
7518
8061
 
@@ -7556,7 +8099,9 @@ function normalizeSessionTrashEntry(entry) {
7556
8099
  ? 'gemini'
7557
8100
  : (entry.source === 'codebuddy'
7558
8101
  ? 'codebuddy'
7559
- : (entry.source === 'pi' ? 'pi' : ''))));
8102
+ : (entry.source === 'workbuddy'
8103
+ ? 'workbuddy'
8104
+ : (entry.source === 'pi' ? 'pi' : '')))));
7560
8105
  const trashId = typeof entry.trashId === 'string' ? entry.trashId.trim() : '';
7561
8106
  if (!source || !trashId || trashId.includes('/') || trashId.includes('\\') || trashId.includes('\0')) {
7562
8107
  return null;
@@ -7577,7 +8122,9 @@ function normalizeSessionTrashEntry(entry) {
7577
8122
  ? 'Gemini CLI'
7578
8123
  : (source === 'codebuddy'
7579
8124
  ? 'CodeBuddy Code'
7580
- : (source === 'pi' ? 'Pi' : 'Codex'))),
8125
+ : (source === 'workbuddy'
8126
+ ? 'WorkBuddy AI'
8127
+ : (source === 'pi' ? 'Pi' : 'Codex')))),
7581
8128
  sessionId: sessionId || trashId,
7582
8129
  title: typeof entry.title === 'string' && entry.title.trim() ? entry.title.trim() : (sessionId || trashId),
7583
8130
  cwd: typeof entry.cwd === 'string' ? entry.cwd : '',
@@ -7593,7 +8140,7 @@ function normalizeSessionTrashEntry(entry) {
7593
8140
  originalFilePath: typeof entry.originalFilePath === 'string' ? entry.originalFilePath : '',
7594
8141
  provider: typeof entry.provider === 'string' && entry.provider.trim()
7595
8142
  ? entry.provider.trim()
7596
- : (source === 'claude' ? 'claude' : (source === 'gemini' ? 'gemini' : (source === 'codebuddy' ? 'codebuddy' : (source === 'pi' ? 'pi' : 'codex')))),
8143
+ : (source === 'claude' ? 'claude' : (source === 'gemini' ? 'gemini' : (source === 'codebuddy' ? 'codebuddy' : (source === 'workbuddy' ? 'workbuddy' : (source === 'pi' ? 'pi' : 'codex'))))),
7597
8144
  keywords: normalizeKeywords(entry.keywords),
7598
8145
  capabilities: normalizeCapabilities(entry.capabilities),
7599
8146
  claudeIndexPath: typeof entry.claudeIndexPath === 'string' ? entry.claudeIndexPath : '',
@@ -7686,7 +8233,9 @@ function buildSessionTrashEntry(summary, options = {}) {
7686
8233
  ? 'gemini'
7687
8234
  : (options.source === 'codebuddy'
7688
8235
  ? 'codebuddy'
7689
- : (options.source === 'pi' ? 'pi' : 'codex')));
8236
+ : (options.source === 'workbuddy'
8237
+ ? 'workbuddy'
8238
+ : (options.source === 'pi' ? 'pi' : 'codex'))));
7690
8239
  const sessionId = options.sessionId || summary.sessionId || path.basename(options.originalFilePath || summary.filePath || '', '.jsonl');
7691
8240
  const claudeIndexEntry = options.claudeIndexEntry && typeof options.claudeIndexEntry === 'object' && !Array.isArray(options.claudeIndexEntry)
7692
8241
  ? options.claudeIndexEntry
@@ -7700,7 +8249,9 @@ function buildSessionTrashEntry(summary, options = {}) {
7700
8249
  ? 'Gemini CLI'
7701
8250
  : (source === 'codebuddy'
7702
8251
  ? 'CodeBuddy Code'
7703
- : (source === 'pi' ? 'Pi' : 'Codex')));
8252
+ : (source === 'workbuddy'
8253
+ ? 'WorkBuddy AI'
8254
+ : (source === 'pi' ? 'Pi' : 'Codex'))));
7704
8255
  const fallbackTitle = truncateText(
7705
8256
  (claudeIndexEntry && (claudeIndexEntry.summary || claudeIndexEntry.firstPrompt)) || sessionId,
7706
8257
  120
@@ -7737,7 +8288,7 @@ function buildSessionTrashEntry(summary, options = {}) {
7737
8288
  originalFilePath: options.originalFilePath || summary.filePath || '',
7738
8289
  provider: (claudeIndexEntry && typeof claudeIndexEntry.provider === 'string' && claudeIndexEntry.provider.trim())
7739
8290
  ? claudeIndexEntry.provider.trim()
7740
- : (summary.provider || (source === 'claude' ? 'claude' : (source === 'gemini' ? 'gemini' : (source === 'codebuddy' ? 'codebuddy' : (source === 'pi' ? 'pi' : 'codex'))))),
8291
+ : (summary.provider || (source === 'claude' ? 'claude' : (source === 'gemini' ? 'gemini' : (source === 'codebuddy' ? 'codebuddy' : (source === 'workbuddy' ? 'workbuddy' : (source === 'pi' ? 'pi' : 'codex')))))),
7741
8292
  keywords: normalizedClaudeKeywords.length > 0 ? normalizedClaudeKeywords : normalizedSummaryKeywords,
7742
8293
  capabilities: Object.keys(normalizedClaudeCapabilities).length > 0
7743
8294
  ? normalizedClaudeCapabilities
@@ -7758,7 +8309,9 @@ function resolveSessionRestoreTarget(entry) {
7758
8309
  ? getGeminiTmpDir()
7759
8310
  : (normalized.source === 'codebuddy'
7760
8311
  ? getCodeBuddyProjectsDir()
7761
- : (normalized.source === 'pi' ? getPiSessionsDir() : getCodexSessionsDir())));
8312
+ : (normalized.source === 'workbuddy'
8313
+ ? getWorkBuddyProjectsDir()
8314
+ : (normalized.source === 'pi' ? getPiSessionsDir() : getCodexSessionsDir()))));
7762
8315
  const originalFilePath = typeof normalized.originalFilePath === 'string' ? normalized.originalFilePath.trim() : '';
7763
8316
  if (!root || !originalFilePath) {
7764
8317
  return '';
@@ -7892,15 +8445,7 @@ function upsertClaudeSessionIndexEntry(indexPath, sessionFilePath, entry) {
7892
8445
  }
7893
8446
 
7894
8447
  async function listSessionTrashItems(params = {}) {
7895
- const source = params.source === 'claude'
7896
- ? 'claude'
7897
- : (params.source === 'codex'
7898
- ? 'codex'
7899
- : (params.source === 'gemini'
7900
- ? 'gemini'
7901
- : (params.source === 'codebuddy'
7902
- ? 'codebuddy'
7903
- : (params.source === 'pi' ? 'pi' : 'all'))));
8448
+ const source = normalizeSessionSourceName(params.source, '') || 'all';
7904
8449
  const countOnly = params.countOnly === true;
7905
8450
  const rawLimit = Number(params.limit);
7906
8451
  const limit = Number.isFinite(rawLimit)
@@ -7910,7 +8455,7 @@ async function listSessionTrashItems(params = {}) {
7910
8455
  purgeExpiredSessionTrashEntries(params.retentionDays);
7911
8456
  }
7912
8457
  const allEntries = readSessionTrashEntries();
7913
- let items = source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'pi'
8458
+ let items = normalizeSessionSourceName(source, '')
7914
8459
  ? allEntries.filter((entry) => entry.source === source)
7915
8460
  : allEntries.slice();
7916
8461
  items.sort((a, b) => {
@@ -8090,18 +8635,13 @@ async function purgeSessionTrashItems(params = {}) {
8090
8635
  }
8091
8636
 
8092
8637
  async function trashSessionData(params = {}) {
8093
- const source = params.source === 'claude'
8094
- ? 'claude'
8095
- : (params.source === 'codex'
8096
- ? 'codex'
8097
- : (params.source === 'gemini'
8098
- ? 'gemini'
8099
- : (params.source === 'codebuddy'
8100
- ? 'codebuddy'
8101
- : (params.source === 'pi' ? 'pi' : ''))));
8638
+ const source = normalizeSessionSourceName(params.source, '');
8102
8639
  if (!source) {
8103
8640
  return { error: 'Invalid source' };
8104
8641
  }
8642
+ if (source === 'opencode') {
8643
+ return { error: 'OpenCode DB-backed sessions cannot be deleted from CodexMate' };
8644
+ }
8105
8645
 
8106
8646
  const filePath = resolveSessionFilePath(source, getSessionFileArg(params), params.sessionId);
8107
8647
  if (!filePath) {
@@ -8114,7 +8654,9 @@ async function trashSessionData(params = {}) {
8114
8654
  ? parseGeminiSessionSummary(filePath)
8115
8655
  : (source === 'codebuddy'
8116
8656
  ? parseCodeBuddySessionSummary(filePath)
8117
- : (source === 'pi' ? parsePiSessionSummary(filePath) : parseCodexSessionSummary(filePath)))))
8657
+ : (source === 'workbuddy'
8658
+ ? parseWorkBuddySessionSummary(filePath)
8659
+ : (source === 'pi' ? parsePiSessionSummary(filePath) : parseCodexSessionSummary(filePath))))))
8118
8660
  || buildSessionSummaryFallback(source, filePath, params.sessionId);
8119
8661
  const exactMessageCount = await countConversationMessagesInFile(filePath, source);
8120
8662
  if (Number.isFinite(Number(exactMessageCount))) {
@@ -8205,18 +8747,13 @@ async function trashSessionData(params = {}) {
8205
8747
  }
8206
8748
 
8207
8749
  async function deleteSessionData(params = {}) {
8208
- const source = params.source === 'claude'
8209
- ? 'claude'
8210
- : (params.source === 'codex'
8211
- ? 'codex'
8212
- : (params.source === 'gemini'
8213
- ? 'gemini'
8214
- : (params.source === 'codebuddy'
8215
- ? 'codebuddy'
8216
- : (params.source === 'pi' ? 'pi' : ''))));
8750
+ const source = normalizeSessionSourceName(params.source, '');
8217
8751
  if (!source) {
8218
8752
  return { error: 'Invalid source' };
8219
8753
  }
8754
+ if (source === 'opencode') {
8755
+ return { error: 'OpenCode DB-backed sessions cannot be deleted from CodexMate' };
8756
+ }
8220
8757
 
8221
8758
  const filePath = resolveSessionFilePath(source, getSessionFileArg(params), params.sessionId);
8222
8759
  if (!filePath) {
@@ -8764,7 +9301,7 @@ function recordHasPiMessage(record) {
8764
9301
 
8765
9302
  function recordHasMessage(record, source) {
8766
9303
  if (source === 'codex') return recordHasCodexMessage(record);
8767
- if (source === 'codebuddy') return recordHasCodeBuddyMessage(record);
9304
+ if (source === 'codebuddy' || source === 'workbuddy') return recordHasCodeBuddyMessage(record);
8768
9305
  if (source === 'pi') return recordHasPiMessage(record);
8769
9306
  return recordHasClaudeMessage(record);
8770
9307
  }
@@ -8784,7 +9321,7 @@ function extractMessagesFromRecords(records, source, options = {}) {
8784
9321
  const record = records[lineIndex];
8785
9322
  if (source === 'codex') {
8786
9323
  extractCodexMessageFromRecord(record, state, lineIndex);
8787
- } else if (source === 'codebuddy') {
9324
+ } else if (source === 'codebuddy' || source === 'workbuddy') {
8788
9325
  extractCodeBuddyMessageFromRecord(record, state, lineIndex);
8789
9326
  } else if (source === 'pi') {
8790
9327
  extractPiMessageFromRecord(record, state, lineIndex);
@@ -8849,7 +9386,7 @@ async function extractMessagesFromFile(filePath, source, options = {}) {
8849
9386
 
8850
9387
  if (source === 'codex') {
8851
9388
  extractCodexMessageFromRecord(record, state, currentLineIndex);
8852
- } else if (source === 'codebuddy') {
9389
+ } else if (source === 'codebuddy' || source === 'workbuddy') {
8853
9390
  extractCodeBuddyMessageFromRecord(record, state, currentLineIndex);
8854
9391
  } else if (source === 'pi') {
8855
9392
  extractPiMessageFromRecord(record, state, currentLineIndex);
@@ -8877,15 +9414,7 @@ async function extractMessagesFromFile(filePath, source, options = {}) {
8877
9414
  }
8878
9415
 
8879
9416
  async function readSessionDetail(params = {}) {
8880
- const source = params.source === 'claude'
8881
- ? 'claude'
8882
- : (params.source === 'codex'
8883
- ? 'codex'
8884
- : (params.source === 'gemini'
8885
- ? 'gemini'
8886
- : (params.source === 'codebuddy'
8887
- ? 'codebuddy'
8888
- : (params.source === 'pi' ? 'pi' : ''))));
9417
+ const source = normalizeSessionSourceName(params.source, '');
8889
9418
  if (!source) {
8890
9419
  return { error: 'Invalid source' };
8891
9420
  }
@@ -8901,9 +9430,30 @@ async function readSessionDetail(params = {}) {
8901
9430
  ? Math.max(1, Math.min(rawLimit, MAX_SESSION_DETAIL_MESSAGES))
8902
9431
  : DEFAULT_SESSION_DETAIL_MESSAGES;
8903
9432
  const preview = params.preview === true || params.preview === 'true';
9433
+ const rawSessionQuery = typeof params.query === 'string' ? params.query.trim() : '';
9434
+ const sessionQueryTokens = rawSessionQuery
9435
+ ? expandSessionQueryTokens(normalizeQueryTokens(rawSessionQuery))
9436
+ : [];
9437
+ let sessionQueryMatch = null;
9438
+ if (sessionQueryTokens.length > 0) {
9439
+ sessionQueryMatch = await scanSessionContentForQuery(
9440
+ { source, filePath, sessionId: params.sessionId },
9441
+ sessionQueryTokens,
9442
+ {
9443
+ maxMatches: Number.MAX_SAFE_INTEGER,
9444
+ positionsLimit: SESSION_QUERY_MATCH_POSITIONS_LIMIT
9445
+ }
9446
+ );
9447
+ }
8904
9448
 
8905
9449
  let extracted;
8906
- if (source === 'gemini') {
9450
+ if (source === 'opencode') {
9451
+ const opencodeSessionId = params.sessionId || parseOpencodeVirtualSessionId(filePath);
9452
+ extracted = queryOpencodeSessionMessages(opencodeSessionId, { maxMessages: messageLimit });
9453
+ if (!extracted) {
9454
+ return { error: 'Failed to parse session file' };
9455
+ }
9456
+ } else if (source === 'gemini') {
8907
9457
  let json;
8908
9458
  try {
8909
9459
  json = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
@@ -8943,13 +9493,7 @@ async function readSessionDetail(params = {}) {
8943
9493
  extracted = await extractSessionDetailPreviewFromFile(filePath, source, messageLimit, { preview });
8944
9494
  }
8945
9495
  const sessionId = extracted.sessionId || params.sessionId || path.basename(filePath, source === 'gemini' ? '.json' : '.jsonl');
8946
- const sourceLabel = source === 'codex'
8947
- ? 'Codex'
8948
- : (source === 'claude'
8949
- ? 'Claude Code'
8950
- : (source === 'gemini'
8951
- ? 'Gemini CLI'
8952
- : (source === 'pi' ? 'Pi' : 'CodeBuddy Code')));
9496
+ const sourceLabel = getSessionSourceLabel(source);
8953
9497
  const clippedMessages = Array.isArray(extracted.messages) ? extracted.messages : [];
8954
9498
  const hasExactTotalMessages = Number.isFinite(extracted.totalMessages);
8955
9499
  const startIndex = hasExactTotalMessages
@@ -8993,6 +9537,13 @@ async function readSessionDetail(params = {}) {
8993
9537
  ? extracted.clipped
8994
9538
  : (hasExactTotalMessages ? extracted.totalMessages > indexedMessages.length : false),
8995
9539
  messageLimit,
9540
+ ...(sessionQueryMatch ? {
9541
+ match: {
9542
+ hit: !!sessionQueryMatch.hit,
9543
+ count: Number.isFinite(sessionQueryMatch.count) ? sessionQueryMatch.count : 0,
9544
+ positions: Array.isArray(sessionQueryMatch.positions) ? sessionQueryMatch.positions : []
9545
+ }
9546
+ } : {}),
8996
9547
  messages: indexedMessages,
8997
9548
  filePath,
8998
9549
  ...(typeof buildSessionNativeStatus === 'function'
@@ -9010,15 +9561,7 @@ async function readSessionDetail(params = {}) {
9010
9561
  }
9011
9562
 
9012
9563
  async function readSessionPlain(params = {}) {
9013
- const source = params.source === 'claude'
9014
- ? 'claude'
9015
- : (params.source === 'codex'
9016
- ? 'codex'
9017
- : (params.source === 'gemini'
9018
- ? 'gemini'
9019
- : (params.source === 'codebuddy'
9020
- ? 'codebuddy'
9021
- : (params.source === 'pi' ? 'pi' : ''))));
9564
+ const source = normalizeSessionSourceName(params.source, '');
9022
9565
  if (!source) {
9023
9566
  return { error: 'Invalid source' };
9024
9567
  }
@@ -9038,7 +9581,13 @@ async function readSessionPlain(params = {}) {
9038
9581
  );
9039
9582
 
9040
9583
  let extracted;
9041
- if (source === 'gemini') {
9584
+ if (source === 'opencode') {
9585
+ const opencodeSessionId = params.sessionId || parseOpencodeVirtualSessionId(filePath);
9586
+ extracted = queryOpencodeSessionMessages(opencodeSessionId, { maxMessages });
9587
+ if (!extracted) {
9588
+ return { error: 'Failed to parse session file' };
9589
+ }
9590
+ } else if (source === 'gemini') {
9042
9591
  let json;
9043
9592
  try {
9044
9593
  json = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
@@ -9088,13 +9637,7 @@ async function readSessionPlain(params = {}) {
9088
9637
  }
9089
9638
 
9090
9639
  const sessionId = extracted.sessionId || params.sessionId || path.basename(filePath, source === 'gemini' ? '.json' : '.jsonl');
9091
- const sourceLabel = source === 'codex'
9092
- ? 'Codex'
9093
- : (source === 'claude'
9094
- ? 'Claude Code'
9095
- : (source === 'gemini'
9096
- ? 'Gemini CLI'
9097
- : (source === 'pi' ? 'Pi' : 'CodeBuddy Code')));
9640
+ const sourceLabel = getSessionSourceLabel(source);
9098
9641
  const messages = removeLeadingSystemMessage(Array.isArray(extracted.messages) ? extracted.messages : []);
9099
9642
  const text = buildSessionPlainText(messages);
9100
9643
 
@@ -9110,15 +9653,7 @@ async function readSessionPlain(params = {}) {
9110
9653
  }
9111
9654
 
9112
9655
  async function exportSessionData(params = {}) {
9113
- const source = params.source === 'claude'
9114
- ? 'claude'
9115
- : (params.source === 'codex'
9116
- ? 'codex'
9117
- : (params.source === 'gemini'
9118
- ? 'gemini'
9119
- : (params.source === 'codebuddy'
9120
- ? 'codebuddy'
9121
- : (params.source === 'pi' ? 'pi' : ''))));
9656
+ const source = normalizeSessionSourceName(params.source, '');
9122
9657
  if (!source) {
9123
9658
  return { error: 'Invalid source' };
9124
9659
  }
@@ -9130,7 +9665,13 @@ async function exportSessionData(params = {}) {
9130
9665
  }
9131
9666
 
9132
9667
  let extracted;
9133
- if (source === 'gemini') {
9668
+ if (source === 'opencode') {
9669
+ const opencodeSessionId = params.sessionId || parseOpencodeVirtualSessionId(filePath);
9670
+ extracted = queryOpencodeSessionMessages(opencodeSessionId, { maxMessages });
9671
+ if (!extracted) {
9672
+ return { error: 'Failed to parse session file' };
9673
+ }
9674
+ } else if (source === 'gemini') {
9134
9675
  let json;
9135
9676
  try {
9136
9677
  json = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
@@ -9188,13 +9729,7 @@ async function exportSessionData(params = {}) {
9188
9729
 
9189
9730
  const sessionId = extracted.sessionId || params.sessionId || path.basename(filePath, source === 'gemini' ? '.json' : '.jsonl');
9190
9731
  const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_');
9191
- const sourceLabel = source === 'codex'
9192
- ? 'Codex'
9193
- : (source === 'claude'
9194
- ? 'Claude Code'
9195
- : (source === 'gemini'
9196
- ? 'Gemini CLI'
9197
- : (source === 'pi' ? 'Pi' : 'CodeBuddy Code')));
9732
+ const sourceLabel = getSessionSourceLabel(source);
9198
9733
  const truncated = !!extracted.truncated;
9199
9734
  const maxMessagesLabel = maxMessages === Infinity ? 'all' : maxMessages;
9200
9735
  const markdown = buildSessionMarkdown({
@@ -11082,7 +11617,8 @@ function runKilocodeCommand(args = [], options = {}) {
11082
11617
  const child = spawn(bin, finalArgs, {
11083
11618
  detached: true,
11084
11619
  stdio: 'ignore',
11085
- windowsHide: true
11620
+ windowsHide: true,
11621
+ shell: process.platform === 'win32'
11086
11622
  });
11087
11623
  child.unref();
11088
11624
  return { success: true, bin, args: finalArgs, pid: child.pid };
@@ -11090,7 +11626,8 @@ function runKilocodeCommand(args = [], options = {}) {
11090
11626
  return new Promise((resolve, reject) => {
11091
11627
  const child = spawn(bin, finalArgs, {
11092
11628
  stdio: 'inherit',
11093
- windowsHide: false
11629
+ windowsHide: false,
11630
+ shell: process.platform === 'win32'
11094
11631
  });
11095
11632
  child.on('error', reject);
11096
11633
  child.on('exit', (code, signal) => {
@@ -12379,7 +12916,7 @@ function resolveExportOutputPath(outputPath, defaultFileName) {
12379
12916
  }
12380
12917
 
12381
12918
  function printExportSessionUsage() {
12382
- console.log('\n用法: codexmate export-session --source <codex|claude|gemini|codebuddy|pi> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
12919
+ console.log('\n用法: codexmate export-session --source <codex|claude|gemini|codebuddy|workbuddy|pi|opencode> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
12383
12920
  console.log('\n示例:');
12384
12921
  console.log(' codexmate export-session --source codex --session-id 123456');
12385
12922
  console.log(' codexmate export-session --source claude --file "~/.claude/projects/demo/session.jsonl"');
@@ -12451,8 +12988,8 @@ function parseExportSessionArgs(args = []) {
12451
12988
  }
12452
12989
 
12453
12990
  const normalizedSource = options.source.trim().toLowerCase();
12454
- if (normalizedSource && normalizedSource !== 'codex' && normalizedSource !== 'claude' && normalizedSource !== 'pi') {
12455
- errors.push('参数 --source 仅支持 codex、claude 或 pi');
12991
+ if (normalizedSource && !isSessionSourceOrAll(normalizedSource)) {
12992
+ errors.push('参数 --source 仅支持 codex、claude、gemini、codebuddy、workbuddy、pi 或 opencode');
12456
12993
  }
12457
12994
  options.source = normalizedSource;
12458
12995
 
@@ -12525,7 +13062,7 @@ async function cmdExportSession(args = []) {
12525
13062
 
12526
13063
  function printAnalyticsUsage() {
12527
13064
  console.log('\n用法:');
12528
- console.log(' codexmate analytics export [--format csv|json] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--model <MODEL>] [--source <codex|claude|gemini|codebuddy|pi|all>] [--output <PATH|->] [-o <PATH|->]');
13065
+ console.log(' codexmate analytics export [--format csv|json] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--model <MODEL>] [--source <codex|claude|gemini|codebuddy|workbuddy|pi|opencode|all>] [--output <PATH|->] [-o <PATH|->]');
12529
13066
  console.log('');
12530
13067
  }
12531
13068
 
@@ -13880,8 +14417,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
13880
14417
  case 'list-sessions':
13881
14418
  {
13882
14419
  const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : '';
13883
- if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'pi' && source !== 'all') {
13884
- result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, pi, or all' };
14420
+ if (source && !isSessionSourceOrAll(source)) {
14421
+ result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, workbuddy, pi, opencode, or all' };
13885
14422
  } else {
13886
14423
  result = {
13887
14424
  sessions: await listSessionBrowse(params),
@@ -13894,8 +14431,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
13894
14431
  {
13895
14432
  const usageParams = isPlainObject(params) ? params : {};
13896
14433
  const source = typeof usageParams.source === 'string' ? usageParams.source.trim().toLowerCase() : '';
13897
- if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'pi' && source !== 'all') {
13898
- result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, pi, or all' };
14434
+ if (source && !isSessionSourceOrAll(source)) {
14435
+ result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, workbuddy, pi, opencode, or all' };
13899
14436
  } else {
13900
14437
  result = {
13901
14438
  sessions: await listSessionUsage({
@@ -13911,8 +14448,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
13911
14448
  {
13912
14449
  const usageParams = isPlainObject(params) ? params : {};
13913
14450
  const source = typeof usageParams.source === 'string' ? usageParams.source.trim().toLowerCase() : '';
13914
- if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'pi' && source !== 'all') {
13915
- result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, pi, or all' };
14451
+ if (source && !isSessionSourceOrAll(source)) {
14452
+ result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, workbuddy, pi, opencode, or all' };
13916
14453
  } else {
13917
14454
  result = await exportSessionUsage({
13918
14455
  ...usageParams,
@@ -13924,8 +14461,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
13924
14461
  case 'list-session-paths':
13925
14462
  {
13926
14463
  const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : '';
13927
- if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'pi' && source !== 'all') {
13928
- result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, pi, or all' };
14464
+ if (source && !isSessionSourceOrAll(source)) {
14465
+ result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, workbuddy, pi, opencode, or all' };
13929
14466
  } else {
13930
14467
  result = {
13931
14468
  paths: listSessionPaths(params)
@@ -15436,7 +15973,7 @@ function buildMcpClaudeSettingsPayload() {
15436
15973
  function normalizeMcpSource(value) {
15437
15974
  const source = typeof value === 'string' ? value.trim().toLowerCase() : '';
15438
15975
  if (!source) return '';
15439
- if (source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'pi' || source === 'all') {
15976
+ if (normalizeSessionSourceName(source, '') || source === 'all') {
15440
15977
  return source;
15441
15978
  }
15442
15979
  return null;
@@ -15749,7 +16286,7 @@ function createWorkflowToolCatalog() {
15749
16286
  handler: async (args = {}) => {
15750
16287
  const source = normalizeMcpSource(args.source);
15751
16288
  if (source === null) {
15752
- return { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, pi, or all' };
16289
+ return { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, workbuddy, pi, opencode, or all' };
15753
16290
  }
15754
16291
  return {
15755
16292
  source: source || 'all',
@@ -16115,7 +16652,7 @@ function createMcpTools(options = {}) {
16115
16652
  const input = args && typeof args === 'object' ? args : {};
16116
16653
  const source = normalizeMcpSource(input.source);
16117
16654
  if (source === null) {
16118
- return { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, pi, or all' };
16655
+ return { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, workbuddy, pi, opencode, or all' };
16119
16656
  }
16120
16657
  const normalizedInput = {
16121
16658
  ...input,
@@ -16575,7 +17112,7 @@ function createMcpResources() {
16575
17112
  contents: [{
16576
17113
  uri,
16577
17114
  mimeType: 'application/json',
16578
- text: JSON.stringify({ error: 'Invalid source. Must be codex, claude, gemini, codebuddy, pi, or all' }, null, 2)
17115
+ text: JSON.stringify({ error: 'Invalid source. Must be codex, claude, gemini, codebuddy, workbuddy, pi, opencode, or all' }, null, 2)
16579
17116
  }]
16580
17117
  };
16581
17118
  }
@@ -16824,7 +17361,7 @@ function printMainHelp() {
16824
17361
  console.log(' 注: follow-up 自动排队仅支持 linux/android/netbsd/openbsd/darwin/freebsd 且 stdin 必须是 TTY,其他平台会报错');
16825
17362
  console.log(' codexmate qwen [参数...] 等同于 qwen --yolo');
16826
17363
  console.log(' codexmate mcp [serve] [--transport stdio] [--allow-write|--read-only]');
16827
- console.log(' codexmate export-session --source <codex|claude|gemini|codebuddy|pi> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
17364
+ console.log(' codexmate export-session --source <codex|claude|gemini|codebuddy|workbuddy|pi|opencode> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
16828
17365
  console.log(' codexmate convert-session --from <codex|claude> --to <codex|claude> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
16829
17366
  console.log(' codexmate zip <路径> [--max:级别] 压缩(系统 zip 优先,其次 zip-lib)');
16830
17367
  console.log(' codexmate unzip <zip文件> [输出目录] 解压(zip-lib)');