From 92f48cd1412d11e412e536a0357c7760ac33be37 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Sun, 19 Jul 2026 16:17:45 +0000 Subject: [PATCH 1/5] feat(sessions): add KiloCode session source --- cli.js | 327 ++++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 287 insertions(+), 40 deletions(-) diff --git a/cli.js b/cli.js index ab9858f7..2e55b862 100644 --- a/cli.js +++ b/cli.js @@ -227,6 +227,8 @@ const CLAUDE_MD_FILE_NAME = 'CLAUDE.md'; const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects'); const CODEBUDDY_DIR = path.join(os.homedir(), '.codebuddy'); const CODEBUDDY_PROJECTS_DIR = path.join(CODEBUDDY_DIR, 'projects'); +const KILOCODE_DIR = path.join(os.homedir(), '.kilocode'); +const KILO_DIR = path.join(os.homedir(), '.kilo'); const CODEXMATE_DIR = path.join(os.homedir(), '.codexmate'); const PROVIDER_CACHE_FILE_GROUPS = Object.freeze({ claude: [ @@ -770,7 +772,8 @@ let g_sessionFileLookupCache = { codex: new Map(), claude: new Map(), gemini: new Map(), - codebuddy: new Map() + codebuddy: new Map(), + kilocode: new Map() }; let g_exactMessageCountCache = new Map(); let g_modelsCache = new Map(); @@ -1816,6 +1819,39 @@ function getCodeBuddyProjectsDir() { return resolveExistingDir(candidates, CODEBUDDY_PROJECTS_DIR); } +function getKiloCodeSessionRoots() { + const candidates = []; + const push = (value) => { + if (typeof value !== 'string' || !value.trim()) return; + const expanded = expandHomePath(value.trim()); + if (expanded && !candidates.includes(expanded)) candidates.push(expanded); + }; + push(process.env.KILOCODE_SESSIONS_DIR); + push(process.env.KILO_SESSIONS_DIR); + for (const home of [process.env.KILOCODE_HOME, process.env.KILO_HOME]) { + if (!home) continue; + push(path.join(home, 'sessions')); + push(path.join(home, 'session')); + push(path.join(home, 'storage', 'sessions')); + } + const xdgData = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); + const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); + for (const base of [ + path.join(xdgData, 'kilocode'), + path.join(xdgData, 'kilo'), + path.join(xdgData, 'opencode'), + path.join(xdgConfig, 'kilocode'), + path.join(xdgConfig, 'kilo'), + KILOCODE_DIR, + KILO_DIR + ]) { + push(path.join(base, 'sessions')); + push(path.join(base, 'session')); + push(path.join(base, 'storage', 'sessions')); + } + return candidates.filter((item, index) => item && candidates.indexOf(item) === index && fs.existsSync(item)); +} + function getCodexmateDerivedSessionsRoot(target) { if (target === 'claude') { return CODEXMATE_DERIVED_CLAUDE_DIR; @@ -3777,7 +3813,11 @@ async function countConversationMessagesInFile(filePath, source) { return cached; } - if (source === 'gemini') { + if (source === 'kilocode') { + extracted = await extractSessionDetailPreviewFromFile(filePath, source, messageLimit, { preview }); + } else if (source === 'kilocode') { + extracted = await extractMessagesFromFile(filePath, source, { maxMessages }); + } else if (source === 'gemini') { let json; try { json = JSON.parse(fs.readFileSync(filePath, 'utf-8')); @@ -4012,7 +4052,7 @@ async function hydrateSessionTrashEntries(entries, options = {}) { return await resolveSessionTrashEntryExactMessageCount(normalizedEntry); }); - if (source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy') { + if (source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'kilocode') { return hydratedEntries.filter((entry) => entry.source === source); } return hydratedEntries; @@ -4030,7 +4070,7 @@ async function hydrateSessionItemsExactMessageCount(items) { ? 'claude' : (item.source === 'codex' ? 'codex' - : (item.source === 'gemini' ? 'gemini' : (item.source === 'codebuddy' ? 'codebuddy' : ''))); + : (item.source === 'gemini' ? 'gemini' : (item.source === 'codebuddy' ? 'codebuddy' : (params.source === 'kilocode' ? 'kilocode' : '')))); const filePath = typeof item.filePath === 'string' ? item.filePath : ''; if (!source || !filePath || !fs.existsSync(filePath)) { return item; @@ -4078,7 +4118,7 @@ async function readSessionMessageCounts(params = {}) { ? 'claude' : (item.source === 'codex' ? 'codex' - : (item.source === 'gemini' ? 'gemini' : (item.source === 'codebuddy' ? 'codebuddy' : ''))); + : (item.source === 'gemini' ? 'gemini' : (item.source === 'codebuddy' ? 'codebuddy' : (params.source === 'kilocode' ? 'kilocode' : '')))); const filePath = typeof item.filePath === 'string' ? item.filePath : ''; if (!source || !filePath || !fs.existsSync(filePath)) { return { key }; @@ -4678,7 +4718,7 @@ function getSessionInventoryCache(cacheKey, forceRefresh = false) { } function registerSessionFileLookupEntries(source, sessions = []) { - const normalizedSource = source === 'claude' || source === 'gemini' || source === 'codebuddy' + const normalizedSource = source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'kilocode' ? source : 'codex'; const store = g_sessionFileLookupCache[normalizedSource]; @@ -4719,7 +4759,7 @@ function setSessionInventoryCache(cacheKey, source, value) { } function listSessionInventoryBySource(source, limit, scanOptions = {}, options = {}) { - const normalizedSource = source === 'claude' || source === 'gemini' || source === 'codebuddy' + const normalizedSource = source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'kilocode' ? source : 'codex'; const forceRefresh = !!options.forceRefresh; @@ -4735,7 +4775,9 @@ function listSessionInventoryBySource(source, limit, scanOptions = {}, options = ? listGeminiSessions(limit, scanOptions) : (normalizedSource === 'codebuddy' ? listCodeBuddySessions(limit, scanOptions) - : listCodexSessions(limit, scanOptions))); + : (normalizedSource === 'kilocode' + ? listKiloCodeSessions(limit, scanOptions) + : listCodexSessions(limit, scanOptions)))); setSessionInventoryCache(cacheKey, normalizedSource, sessions); return sessions; } @@ -4747,7 +4789,8 @@ function invalidateSessionListCache() { codex: new Map(), claude: new Map(), gemini: new Map(), - codebuddy: new Map() + codebuddy: new Map(), + kilocode: new Map() }; } @@ -5526,6 +5569,186 @@ function parseCodeBuddySessionSummary(filePath, options = {}) { }; } + +function normalizeKiloCodeMessageRole(value) { + const role = normalizeRole(value); + if (role === 'user' || role === 'assistant' || role === 'system') return role; + const raw = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (raw === 'tool' || raw === 'error' || raw === 'info') return 'system'; + return ''; +} + +function extractKiloCodeMessageText(value) { + if (value === undefined || value === null) return ''; + if (typeof value === 'string') return value; + if (Array.isArray(value)) return extractMessageText(value); + if (typeof value === 'object') { + if (typeof value.text === 'string') return value.text; + if (typeof value.content === 'string') return value.content; + if (typeof value.message === 'string') return value.message; + if (Array.isArray(value.parts)) return extractMessageText(value.parts); + if (Array.isArray(value.content)) return extractMessageText(value.content); + if (value.message && typeof value.message === 'object') return extractKiloCodeMessageText(value.message.content ?? value.message.text ?? value.message); + } + return ''; +} + +function readKiloCodeRecordMessages(record) { + if (!record || typeof record !== 'object') return []; + const out = []; + const push = (roleRaw, textRaw, timestampRaw) => { + const role = normalizeKiloCodeMessageRole(roleRaw); + const text = extractKiloCodeMessageText(textRaw); + if (!role || !text) return; + out.push({ role, text, timestamp: timestampRaw ?? record.timestamp ?? record.time ?? record.createdAt ?? record.updatedAt }); + }; + + if (record.type === 'response_item' && record.payload && record.payload.type === 'message') { + push(record.payload.role, record.payload.content, record.timestamp); + } + if (record.info && typeof record.info === 'object') { + const partText = Array.isArray(record.parts) ? extractKiloCodeMessageText(record.parts) : ''; + push(record.info.role, partText || record.info.content || record.info.text, record.info.time?.created ?? record.info.createdAt ?? record.timestamp); + } + if (record.type === 'message') { + push(record.role ?? record.message?.role, record.content ?? record.message?.content ?? record.text ?? record.message, record.timestamp); + } + if (record.role || record.message || record.content || record.text) { + push(record.role ?? record.type, record.content ?? record.text ?? record.message, record.timestamp); + } + return out; +} + +function extractKiloCodeMessageFromRecord(record, state, lineIndex = -1) { + if (!record || typeof record !== 'object') return; + if (record.timestamp || record.updatedAt || record.time) { + state.updatedAt = toIsoTime(record.timestamp ?? record.updatedAt ?? record.time, state.updatedAt); + } + if (record.type === 'session_meta' && record.payload) { + state.sessionId = record.payload.id || record.payload.sessionID || state.sessionId; + state.cwd = record.payload.cwd || record.payload.directory || state.cwd; + } + if (!state.sessionId) state.sessionId = record.sessionID || record.sessionId || record.id || state.sessionId; + if (!state.cwd) state.cwd = record.cwd || record.directory || record.location?.directory || state.cwd; + for (const msg of readKiloCodeRecordMessages(record)) { + if (!canAppendMessage(state)) break; + state.messages.push({ + role: msg.role, + text: msg.text, + timestamp: toIsoTime(msg.timestamp, ''), + recordLineIndex: Number.isInteger(lineIndex) ? lineIndex : -1 + }); + } +} + +function recordHasKiloCodeMessage(record) { + return readKiloCodeRecordMessages(record).length > 0; +} + +function parseKiloCodeSessionSummary(filePath, options = {}) { + const summaryReadBytes = Number.isFinite(Number(options.summaryReadBytes)) + ? Math.max(1024, Math.floor(Number(options.summaryReadBytes))) + : SESSION_SUMMARY_READ_BYTES; + let stat; + try { stat = fs.statSync(filePath); } catch (_) { return null; } + let records = []; + if (filePath.endsWith('.jsonl')) { + records = parseJsonlHeadRecords(filePath, summaryReadBytes).concat(parseJsonlTailRecords(filePath, summaryReadBytes)); + } else if (filePath.endsWith('.json')) { + const json = readJsonFile(filePath, null); + if (json && Array.isArray(json.messages)) records = json.messages; + else if (json && Array.isArray(json.items)) records = json.items; + else if (json && typeof json === 'object') records = [json]; + } + if (!records.length) return null; + const state = { sessionId: path.basename(filePath).replace(/\.jsonl?$/i, ''), cwd: '', updatedAt: stat.mtime.toISOString(), messages: [], maxMessages: 80 }; + let createdAt = ''; + let provider = 'kilocode'; + let model = ''; + const models = []; + for (let i = 0; i < records.length; i += 1) { + const record = records[i]; + extractKiloCodeMessageFromRecord(record, state, i); + if (!createdAt && (record?.timestamp || record?.createdAt || record?.info?.time?.created)) { + createdAt = toIsoTime(record.timestamp ?? record.createdAt ?? record.info?.time?.created, createdAt); + } + provider = readExplicitSessionProviderFromRecord(record) || provider; + for (const recordModel of readSessionModelsFromRecord(record)) { + if (!models.includes(recordModel)) models.push(recordModel); + } + model = models[0] || model; + } + const filtered = removeLeadingSystemMessage(state.messages); + const firstUser = filtered.find(item => item.role === 'user' && item.text); + return { + source: 'kilocode', + sourceLabel: 'KiloCode VSCode', + provider, + model, + models, + sessionId: state.sessionId, + title: firstUser ? truncateText(firstUser.text) : state.sessionId, + cwd: state.cwd, + createdAt, + updatedAt: state.updatedAt || stat.mtime.toISOString(), + messageCount: filtered.length, + totalTokens: 0, + contextWindow: 0, + inputTokens: 0, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + __messageCountExact: false, + filePath, + keywords: [], + capabilities: { code: true, vscode: true } + }; +} + +function collectKiloCodeSessionFiles(rootDir, maxFiles = 5000) { + if (!rootDir || !fs.existsSync(rootDir)) return []; + const stack = [rootDir]; + const files = []; + while (stack.length && files.length < maxFiles) { + const dir = stack.pop(); + let entries = []; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { continue; } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === '.git') continue; + stack.push(full); + continue; + } + if (!entry.isFile()) continue; + if (/\.(jsonl|json)$/i.test(entry.name)) files.push(full); + if (files.length >= maxFiles) break; + } + } + return files; +} + +function listKiloCodeSessions(limit, options = {}) { + const targetCount = Math.max(limit * (Number(options.scanFactor) || SESSION_SCAN_FACTOR), Number(options.minFiles) || SESSION_SCAN_MIN_FILES); + const filesMeta = []; + for (const root of getKiloCodeSessionRoots()) { + for (const filePath of collectKiloCodeSessionFiles(root, targetCount)) { + try { filesMeta.push({ filePath, mtime: fs.statSync(filePath).mtimeMs || 0 }); } catch (_) { } + if (filesMeta.length >= targetCount) break; + } + if (filesMeta.length >= targetCount) break; + } + filesMeta.sort((a, b) => b.mtime - a.mtime); + const sessions = []; + for (const item of filesMeta) { + const summary = parseKiloCodeSessionSummary(item.filePath, options); + if (summary) sessions.push(summary); + if (sessions.length >= limit) break; + } + return mergeAndLimitSessions(sessions, limit); +} + function extractGeminiMessageText(content) { if (typeof content === 'string') { return content; @@ -6120,7 +6343,7 @@ function listCodeBuddySessions(limit, options = {}) { } async function listAllSessions(params = {}) { - const source = params.source === 'codex' || params.source === 'claude' || params.source === 'gemini' || params.source === 'codebuddy' + const source = params.source === 'codex' || params.source === 'claude' || params.source === 'gemini' || params.source === 'codebuddy' || params.source === 'kilocode' ? params.source : 'all'; const rawLimit = Number(params.limit); @@ -6170,6 +6393,9 @@ async function listAllSessions(params = {}) { if (source === 'all' || source === 'codebuddy') { sessions = sessions.concat(listSessionInventoryBySource('codebuddy', limit, scanOptions, { forceRefresh })); } + if (source === 'all' || source === 'kilocode') { + sessions = sessions.concat(listSessionInventoryBySource('kilocode', limit, scanOptions, { forceRefresh })); + } if (hasPathFilter) { sessions = sessions.filter(item => matchesSessionPathFilter(item, normalizedPathFilter)); @@ -6265,10 +6491,10 @@ async function exportSessionUsage(params = {}) { function listSessionPaths(params = {}) { const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : ''; - if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'all') { + if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'kilocode' && source !== 'all') { return []; } - const validSource = source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy' ? source : 'all'; + const validSource = source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'kilocode' ? source : 'all'; const rawLimit = Number(params.limit); const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(rawLimit, MAX_SESSION_PATH_LIST_SIZE)) @@ -6302,6 +6528,9 @@ function listSessionPaths(params = {}) { if (validSource === 'all' || validSource === 'codebuddy') { sessions = sessions.concat(listSessionInventoryBySource('codebuddy', gatherLimit, scanOptions, { forceRefresh })); } + if (validSource === 'all' || validSource === 'kilocode') { + sessions = sessions.concat(listSessionInventoryBySource('kilocode', gatherLimit, scanOptions, { forceRefresh })); + } const dedupedPaths = []; const seen = new Set(); @@ -6327,7 +6556,7 @@ function listSessionPaths(params = {}) { } function resolveSessionFilePath(source, filePath, sessionId) { - const normalizedSource = source === 'claude' || source === 'gemini' || source === 'codebuddy' + const normalizedSource = source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'kilocode' ? source : 'codex'; const homeDir = process && process.env && process.env.HOME ? process.env.HOME : ''; @@ -6339,7 +6568,9 @@ function resolveSessionFilePath(source, filePath, sessionId) { ? [getGeminiTmpDir()] : (normalizedSource === 'codebuddy' ? [getCodeBuddyProjectsDir()] - : [getCodexSessionsDir(), derivedCodexDir])); + : (normalizedSource === 'kilocode' + ? getKiloCodeSessionRoots() + : [getCodexSessionsDir(), derivedCodexDir]))); const availableRoots = roots.filter((dirPath) => dirPath && fs.existsSync(dirPath)); if (availableRoots.length === 0) { return ''; @@ -6392,6 +6623,13 @@ function resolveSessionFilePath(source, filePath, sessionId) { if (filesMeta.length >= 5000) break; } matchedFile = filesMeta.find(item => path.basename(item, '.json').toLowerCase() === targetId) || ''; + } else if (normalizedSource === 'kilocode') { + const files = []; + for (const rootPath of availableRoots) { + files.push(...collectKiloCodeSessionFiles(rootPath, 5000)); + if (files.length >= 5000) break; + } + matchedFile = files.find(item => path.basename(item).replace(/\.jsonl?$/i, '').toLowerCase() === targetId) || ''; } else { const files = []; for (const rootPath of availableRoots) { @@ -6904,7 +7142,7 @@ function buildSessionSummaryFallback(source, filePath, sessionId = '') { contextWindow: 0, filePath, keywords: [], - capabilities: source === 'claude' || source === 'gemini' || source === 'codebuddy' ? { code: true } : {} + capabilities: source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'kilocode' ? { code: true } : {} }; } @@ -6946,7 +7184,7 @@ function normalizeSessionTrashEntry(entry) { ? 'codex' : (entry.source === 'gemini' ? 'gemini' - : (entry.source === 'codebuddy' ? 'codebuddy' : ''))); + : (entry.source === 'codebuddy' ? 'codebuddy' : (params.source === 'kilocode' ? 'kilocode' : '')))); const trashId = typeof entry.trashId === 'string' ? entry.trashId.trim() : ''; if (!source || !trashId || trashId.includes('/') || trashId.includes('\\') || trashId.includes('\0')) { return null; @@ -7280,7 +7518,7 @@ async function listSessionTrashItems(params = {}) { purgeExpiredSessionTrashEntries(params.retentionDays); } const allEntries = readSessionTrashEntries(); - let items = source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy' + let items = source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'kilocode' ? allEntries.filter((entry) => entry.source === source) : allEntries.slice(); items.sort((a, b) => { @@ -7466,7 +7704,7 @@ async function trashSessionData(params = {}) { ? 'codex' : (params.source === 'gemini' ? 'gemini' - : (params.source === 'codebuddy' ? 'codebuddy' : ''))); + : (params.source === 'codebuddy' ? 'codebuddy' : (params.source === 'kilocode' ? 'kilocode' : '')))); if (!source) { return { error: 'Invalid source' }; } @@ -7577,7 +7815,7 @@ async function deleteSessionData(params = {}) { ? 'codex' : (params.source === 'gemini' ? 'gemini' - : (params.source === 'codebuddy' ? 'codebuddy' : ''))); + : (params.source === 'codebuddy' ? 'codebuddy' : (params.source === 'kilocode' ? 'kilocode' : '')))); if (!source) { return { error: 'Invalid source' }; } @@ -8091,6 +8329,7 @@ function recordHasCodeBuddyMessage(record) { function recordHasMessage(record, source) { if (source === 'codex') return recordHasCodexMessage(record); if (source === 'codebuddy') return recordHasCodeBuddyMessage(record); + if (source === 'kilocode') return recordHasKiloCodeMessage(record); return recordHasClaudeMessage(record); } @@ -8111,6 +8350,8 @@ function extractMessagesFromRecords(records, source, options = {}) { extractCodexMessageFromRecord(record, state, lineIndex); } else if (source === 'codebuddy') { extractCodeBuddyMessageFromRecord(record, state, lineIndex); + } else if (source === 'kilocode') { + extractKiloCodeMessageFromRecord(record, state, lineIndex); } else { extractClaudeMessageFromRecord(record, state, lineIndex); } @@ -8204,7 +8445,7 @@ async function readSessionDetail(params = {}) { ? 'codex' : (params.source === 'gemini' ? 'gemini' - : (params.source === 'codebuddy' ? 'codebuddy' : ''))); + : (params.source === 'codebuddy' ? 'codebuddy' : (params.source === 'kilocode' ? 'kilocode' : '')))); if (!source) { return { error: 'Invalid source' }; } @@ -8222,7 +8463,9 @@ async function readSessionDetail(params = {}) { const preview = params.preview === true || params.preview === 'true'; let extracted; - if (source === 'gemini') { + if (source === 'kilocode') { + extracted = await extractSessionDetailPreviewFromFile(filePath, source, messageLimit, { preview }); + } else if (source === 'gemini') { let json; try { json = JSON.parse(fs.readFileSync(filePath, 'utf-8')); @@ -8266,7 +8509,7 @@ async function readSessionDetail(params = {}) { ? 'Codex' : (source === 'claude' ? 'Claude Code' - : (source === 'gemini' ? 'Gemini CLI' : 'CodeBuddy Code')); + : (source === 'gemini' ? 'Gemini CLI' : (source === 'kilocode' ? 'KiloCode VSCode' : 'CodeBuddy Code'))); const clippedMessages = Array.isArray(extracted.messages) ? extracted.messages : []; const hasExactTotalMessages = Number.isFinite(extracted.totalMessages); const startIndex = hasExactTotalMessages @@ -8333,7 +8576,7 @@ async function readSessionPlain(params = {}) { ? 'codex' : (params.source === 'gemini' ? 'gemini' - : (params.source === 'codebuddy' ? 'codebuddy' : ''))); + : (params.source === 'codebuddy' ? 'codebuddy' : (params.source === 'kilocode' ? 'kilocode' : '')))); if (!source) { return { error: 'Invalid source' }; } @@ -8353,7 +8596,9 @@ async function readSessionPlain(params = {}) { ); let extracted; - if (source === 'gemini') { + if (source === 'kilocode') { + extracted = await extractSessionDetailPreviewFromFile(filePath, source, messageLimit, { preview }); + } else if (source === 'gemini') { let json; try { json = JSON.parse(fs.readFileSync(filePath, 'utf-8')); @@ -8407,7 +8652,7 @@ async function readSessionPlain(params = {}) { ? 'Codex' : (source === 'claude' ? 'Claude Code' - : (source === 'gemini' ? 'Gemini CLI' : 'CodeBuddy Code')); + : (source === 'gemini' ? 'Gemini CLI' : (source === 'kilocode' ? 'KiloCode VSCode' : 'CodeBuddy Code'))); const messages = removeLeadingSystemMessage(Array.isArray(extracted.messages) ? extracted.messages : []); const text = buildSessionPlainText(messages); @@ -8429,7 +8674,7 @@ async function exportSessionData(params = {}) { ? 'codex' : (params.source === 'gemini' ? 'gemini' - : (params.source === 'codebuddy' ? 'codebuddy' : ''))); + : (params.source === 'codebuddy' ? 'codebuddy' : (params.source === 'kilocode' ? 'kilocode' : '')))); if (!source) { return { error: 'Invalid source' }; } @@ -8441,7 +8686,9 @@ async function exportSessionData(params = {}) { } let extracted; - if (source === 'gemini') { + if (source === 'kilocode') { + extracted = await extractSessionDetailPreviewFromFile(filePath, source, messageLimit, { preview }); + } else if (source === 'gemini') { let json; try { json = JSON.parse(fs.readFileSync(filePath, 'utf-8')); @@ -8503,7 +8750,7 @@ async function exportSessionData(params = {}) { ? 'Codex' : (source === 'claude' ? 'Claude Code' - : (source === 'gemini' ? 'Gemini CLI' : 'CodeBuddy Code')); + : (source === 'gemini' ? 'Gemini CLI' : (source === 'kilocode' ? 'KiloCode VSCode' : 'CodeBuddy Code'))); const truncated = !!extracted.truncated; const maxMessagesLabel = maxMessages === Infinity ? 'all' : maxMessages; const markdown = buildSessionMarkdown({ @@ -13253,8 +13500,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser case 'list-sessions': { const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : ''; - if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'all') { - result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' }; + if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'kilocode' && source !== 'all') { + result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, kilocode, or all' }; } else { result = { sessions: await listSessionBrowse(params), @@ -13267,8 +13514,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser { const usageParams = isPlainObject(params) ? params : {}; const source = typeof usageParams.source === 'string' ? usageParams.source.trim().toLowerCase() : ''; - if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'all') { - result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' }; + if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'kilocode' && source !== 'all') { + result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, kilocode, or all' }; } else { result = { sessions: await listSessionUsage({ @@ -13284,8 +13531,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser { const usageParams = isPlainObject(params) ? params : {}; const source = typeof usageParams.source === 'string' ? usageParams.source.trim().toLowerCase() : ''; - if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'all') { - result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' }; + if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'kilocode' && source !== 'all') { + result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, kilocode, or all' }; } else { result = await exportSessionUsage({ ...usageParams, @@ -13297,8 +13544,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser case 'list-session-paths': { const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : ''; - if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'all') { - result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' }; + if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'kilocode' && source !== 'all') { + result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, kilocode, or all' }; } else { result = { paths: listSessionPaths(params) @@ -15557,7 +15804,7 @@ function buildMcpClaudeSettingsPayload() { function normalizeMcpSource(value) { const source = typeof value === 'string' ? value.trim().toLowerCase() : ''; if (!source) return ''; - if (source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'all') { + if (source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy' || source === 'kilocode' || source === 'all') { return source; } return null; @@ -15870,7 +16117,7 @@ function createWorkflowToolCatalog() { handler: async (args = {}) => { const source = normalizeMcpSource(args.source); if (source === null) { - return { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' }; + return { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, kilocode, or all' }; } return { source: source || 'all', @@ -17990,7 +18237,7 @@ function createMcpTools(options = {}) { const input = args && typeof args === 'object' ? args : {}; const source = normalizeMcpSource(input.source); if (source === null) { - return { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' }; + return { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, kilocode, or all' }; } const normalizedInput = { ...input, @@ -18450,7 +18697,7 @@ function createMcpResources() { contents: [{ uri, mimeType: 'application/json', - text: JSON.stringify({ error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' }, null, 2) + text: JSON.stringify({ error: 'Invalid source. Must be codex, claude, gemini, codebuddy, kilocode, or all' }, null, 2) }] }; } From 5cab7f92380b9c970c382932826c6070460e06a4 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Sun, 19 Jul 2026 17:11:13 +0000 Subject: [PATCH 2/5] feat(sessions): read KiloCode history offline --- cli.js | 302 +++++++++++++++++- tests/unit/kilocode-session-browser.test.mjs | 99 ++++++ web-ui/app.js | 12 +- web-ui/modules/app.computed.dashboard.mjs | 1 + web-ui/modules/app.computed.session.mjs | 3 +- .../modules/app.methods.session-browser.mjs | 22 +- web-ui/modules/i18n/locales/en.mjs | 2 + web-ui/modules/i18n/locales/ja.mjs | 2 + web-ui/modules/i18n/locales/vi.mjs | 2 + web-ui/modules/i18n/locales/zh-tw.mjs | 2 + web-ui/modules/i18n/locales/zh.mjs | 2 + web-ui/partials/index/layout-header.html | 6 +- web-ui/res/web-ui-render.precompiled.js | 6 +- 13 files changed, 433 insertions(+), 28 deletions(-) create mode 100644 tests/unit/kilocode-session-browser.test.mjs diff --git a/cli.js b/cli.js index 2e55b862..fada7b03 100644 --- a/cli.js +++ b/cli.js @@ -1852,6 +1852,43 @@ function getKiloCodeSessionRoots() { return candidates.filter((item, index) => item && candidates.indexOf(item) === index && fs.existsSync(item)); } +function getKiloCodeDataRoots() { + const candidates = []; + const push = (value) => { + if (typeof value !== 'string' || !value.trim()) return; + const expanded = expandHomePath(value.trim()); + if (expanded && !candidates.includes(expanded)) candidates.push(expanded); + }; + push(process.env.KILOCODE_DATA_DIR); + push(process.env.KILO_DATA_DIR); + for (const home of [process.env.KILOCODE_HOME, process.env.KILO_HOME]) { + if (!home) continue; + push(home); + push(path.join(home, 'data')); + } + const xdgData = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); + push(path.join(xdgData, 'kilo')); + push(path.join(xdgData, 'kilocode')); + return candidates.filter((item, index) => item && candidates.indexOf(item) === index && fs.existsSync(item)); +} + +function getKiloCodeDatabaseFiles() { + const files = []; + const seen = new Set(); + for (const root of getKiloCodeDataRoots()) { + let entries = []; + try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch (_) { continue; } + for (const entry of entries) { + if (!entry.isFile() || !/^kilo(?:-.+)?\.db$/i.test(entry.name)) continue; + const full = path.join(root, entry.name); + if (seen.has(full)) continue; + seen.add(full); + files.push(full); + } + } + return files; +} + function getCodexmateDerivedSessionsRoot(target) { if (target === 'claude') { return CODEXMATE_DERIVED_CLAUDE_DIR; @@ -5619,6 +5656,217 @@ function readKiloCodeRecordMessages(record) { return out; } +function parseKiloCodeJson(value, fallback = null) { + if (value && typeof value === 'object') return value; + if (typeof value !== 'string' || !value.trim()) return fallback; + try { return JSON.parse(value); } catch (_) { return fallback; } +} + +function summarizeKiloCodeToolContent(item) { + if (!item || typeof item !== 'object') return ''; + const name = typeof item.name === 'string' && item.name ? item.name : 'tool'; + const status = item.state && typeof item.state === 'object' && typeof item.state.status === 'string' + ? item.state.status + : ''; + const parts = [`[tool:${name}${status ? ` ${status}` : ''}]`]; + const result = item.state && typeof item.state === 'object' ? item.state.result : ''; + if (typeof result === 'string' && result.trim()) parts.push(truncateText(result.trim(), 240)); + return parts.join(' '); +} + +function extractKiloCodeStructuredMessageText(message) { + if (!message || typeof message !== 'object') return ''; + const type = typeof message.type === 'string' ? message.type : ''; + if (type === 'assistant' && Array.isArray(message.content)) { + const parts = []; + for (const item of message.content) { + if (!item || typeof item !== 'object') continue; + if ((item.type === 'text' || item.type === 'reasoning') && typeof item.text === 'string' && item.text.trim()) { + parts.push(item.text.trim()); + } else if (item.type === 'tool') { + const toolText = summarizeKiloCodeToolContent(item); + if (toolText) parts.push(toolText); + } + } + return parts.join('\n\n'); + } + if (type === 'shell') { + const command = typeof message.command === 'string' ? message.command.trim() : ''; + const output = typeof message.output === 'string' ? message.output.trim() : ''; + return [command ? `$ ${command}` : '', output ? truncateText(output, 1200) : ''].filter(Boolean).join('\n'); + } + if (typeof message.text === 'string') return message.text; + if (typeof message.summary === 'string' && message.summary.trim()) return message.summary; + return extractKiloCodeMessageText(message.content ?? message.message ?? message.data ?? ''); +} + +function normalizeKiloCodeStructuredMessage(row) { + if (!row || typeof row !== 'object') return null; + const data = parseKiloCodeJson(row.data, null); + const message = data && typeof data === 'object' + ? { ...data, id: data.id || row.id, type: data.type || row.type } + : { id: row.id, type: row.type, text: typeof row.data === 'string' ? row.data : '' }; + const type = typeof message.type === 'string' ? message.type : ''; + const role = type === 'assistant' + ? 'assistant' + : (type === 'user' + ? 'user' + : (type === 'system' || type === 'synthetic' || type === 'shell' || type === 'compaction' || type.endsWith('-switched') ? 'system' : normalizeKiloCodeMessageRole(type))); + const text = extractKiloCodeStructuredMessageText(message); + if (!role || !text) return null; + const timeCreated = message.time && typeof message.time === 'object' ? message.time.created : row.time_created; + return { + role, + text, + timestamp: toIsoTime(timeCreated, ''), + id: message.id || row.id || '', + recordLineIndex: Number.isInteger(row.seq) ? row.seq : -1 + }; +} + +function runKiloCodeSqlitePython(dbPath, mode, options = {}) { + if (!dbPath || !fs.existsSync(dbPath)) return null; + const script = String.raw` +import json, sqlite3, sys + +db_path, mode = sys.argv[1], sys.argv[2] +session_id = sys.argv[3] if len(sys.argv) > 3 else '' +limit = int(sys.argv[4]) if len(sys.argv) > 4 and sys.argv[4].isdigit() else 200 +conn = sqlite3.connect('file:' + db_path + '?mode=ro', uri=True) +conn.row_factory = sqlite3.Row +try: + tables = {r['name'] for r in conn.execute("select name from sqlite_master where type='table'")} + if 'session' not in tables or 'session_message' not in tables: + print('[]') + elif mode == 'list': + rows = conn.execute(""" + select s.id, s.directory, s.path, s.title, s.model, s.cost, + s.tokens_input, s.tokens_output, s.tokens_reasoning, + s.tokens_cache_read, s.tokens_cache_write, + s.time_created, s.time_updated, + count(m.id) as message_count, + max(coalesce(m.time_created, 0)) as last_message_time + from session s + left join session_message m on m.session_id = s.id + where s.time_archived is null + group by s.id + order by max(coalesce(m.time_created, s.time_updated, s.time_created, 0)) desc + limit ? + """, (limit,)).fetchall() + print(json.dumps([dict(r) for r in rows])) + elif mode == 'messages': + rows = conn.execute(""" + select id, session_id, type, seq, time_created, time_updated, data + from session_message + where session_id = ? + order by coalesce(seq, time_created, 0), time_created, id + """, (session_id,)).fetchall() + print(json.dumps([dict(r) for r in rows])) + elif mode == 'session': + row = conn.execute(""" + select id, directory, path, title, model, cost, + tokens_input, tokens_output, tokens_reasoning, + tokens_cache_read, tokens_cache_write, + time_created, time_updated + from session + where id = ? + """, (session_id,)).fetchone() + print(json.dumps(dict(row) if row else None)) + else: + print('[]') +finally: + conn.close() +`; + try { + const output = execFileSync('python3', ['-c', script, dbPath, mode, String(options.sessionId || ''), String(options.limit || 200)], { + encoding: 'utf8', + maxBuffer: 8 * 1024 * 1024, + timeout: 15000 + }); + return JSON.parse(output || 'null'); + } catch (_) { + return null; + } +} + +function parseKiloCodeModelRef(value) { + const parsed = parseKiloCodeJson(value, null); + if (!parsed || typeof parsed !== 'object') return { provider: 'kilocode', model: '' }; + return { + provider: typeof parsed.providerID === 'string' && parsed.providerID ? parsed.providerID : 'kilocode', + model: typeof parsed.id === 'string' ? parsed.id : '' + }; +} + +function toKiloCodeDbSessionSummary(row, dbPath) { + if (!row || typeof row !== 'object') return null; + const sessionId = typeof row.id === 'string' ? row.id : ''; + if (!sessionId) return null; + const modelRef = parseKiloCodeModelRef(row.model); + return { + source: 'kilocode', + sourceLabel: 'KiloCode VSCode', + provider: modelRef.provider, + model: modelRef.model, + models: modelRef.model ? [modelRef.model] : [], + sessionId, + title: typeof row.title === 'string' && row.title.trim() ? row.title.trim() : sessionId, + cwd: typeof row.directory === 'string' ? row.directory : (typeof row.path === 'string' ? row.path : ''), + createdAt: toIsoTime(row.time_created, ''), + updatedAt: toIsoTime(row.last_message_time || row.time_updated || row.time_created, ''), + messageCount: Number.isFinite(Number(row.message_count)) ? Math.max(0, Math.floor(Number(row.message_count))) : 0, + totalTokens: Number(row.tokens_input || 0) + Number(row.tokens_output || 0) + Number(row.tokens_reasoning || 0), + contextWindow: 0, + inputTokens: Number(row.tokens_input || 0), + cachedInputTokens: Number(row.tokens_cache_read || 0), + cacheCreationInputTokens: Number(row.tokens_cache_write || 0), + outputTokens: Number(row.tokens_output || 0), + reasoningOutputTokens: Number(row.tokens_reasoning || 0), + __messageCountExact: true, + filePath: dbPath, + keywords: [], + capabilities: { code: true, vscode: true, database: true } + }; +} + +function listKiloCodeDatabaseSessions(limit) { + const sessions = []; + const lookupStore = g_sessionFileLookupCache.kilocode; + for (const dbPath of getKiloCodeDatabaseFiles()) { + const rows = runKiloCodeSqlitePython(dbPath, 'list', { limit: Math.max(limit * 2, 200) }); + if (!Array.isArray(rows)) continue; + for (const row of rows) { + const summary = toKiloCodeDbSessionSummary(row, dbPath); + if (!summary) continue; + if (lookupStore instanceof Map) lookupStore.set(summary.sessionId.toLowerCase(), dbPath); + sessions.push(summary); + if (sessions.length >= limit) break; + } + if (sessions.length >= limit) break; + } + return sessions; +} + +function readKiloCodeDatabaseSessionDetail(dbPath, sessionId, messageLimit = DEFAULT_SESSION_DETAIL_MESSAGES) { + if (!dbPath || !sessionId || !fs.existsSync(dbPath)) return null; + const sessionRow = runKiloCodeSqlitePython(dbPath, 'session', { sessionId }); + if (!sessionRow || typeof sessionRow !== 'object') return null; + const rows = runKiloCodeSqlitePython(dbPath, 'messages', { sessionId, limit: Math.max(messageLimit * 4, 200) }); + if (!Array.isArray(rows)) return null; + const messages = rows.map(normalizeKiloCodeStructuredMessage).filter(Boolean); + const filtered = removeLeadingSystemMessage(messages); + const clipped = filtered.length > messageLimit; + return { + sessionId, + cwd: typeof sessionRow.directory === 'string' ? sessionRow.directory : '', + updatedAt: toIsoTime(sessionRow.time_updated || sessionRow.time_created, ''), + totalMessages: filtered.length, + clipped, + truncated: clipped, + messages: clipped ? filtered.slice(-messageLimit) : filtered + }; +} + function extractKiloCodeMessageFromRecord(record, state, lineIndex = -1) { if (!record || typeof record !== 'object') return; if (record.timestamp || record.updatedAt || record.time) { @@ -5731,6 +5979,8 @@ function collectKiloCodeSessionFiles(rootDir, maxFiles = 5000) { function listKiloCodeSessions(limit, options = {}) { const targetCount = Math.max(limit * (Number(options.scanFactor) || SESSION_SCAN_FACTOR), Number(options.minFiles) || SESSION_SCAN_MIN_FILES); + const sessions = listKiloCodeDatabaseSessions(targetCount); + const seenIds = new Set(sessions.map(item => String(item.sessionId || '').toLowerCase()).filter(Boolean)); const filesMeta = []; for (const root of getKiloCodeSessionRoots()) { for (const filePath of collectKiloCodeSessionFiles(root, targetCount)) { @@ -5740,11 +5990,16 @@ function listKiloCodeSessions(limit, options = {}) { if (filesMeta.length >= targetCount) break; } filesMeta.sort((a, b) => b.mtime - a.mtime); - const sessions = []; + const lookupStore = g_sessionFileLookupCache.kilocode; for (const item of filesMeta) { const summary = parseKiloCodeSessionSummary(item.filePath, options); - if (summary) sessions.push(summary); - if (sessions.length >= limit) break; + if (!summary) continue; + const idKey = String(summary.sessionId || '').toLowerCase(); + if (idKey && seenIds.has(idKey)) continue; + if (idKey) seenIds.add(idKey); + if (lookupStore instanceof Map && idKey) lookupStore.set(idKey, item.filePath); + sessions.push(summary); + if (sessions.length >= targetCount) break; } return mergeAndLimitSessions(sessions, limit); } @@ -6569,7 +6824,7 @@ function resolveSessionFilePath(source, filePath, sessionId) { : (normalizedSource === 'codebuddy' ? [getCodeBuddyProjectsDir()] : (normalizedSource === 'kilocode' - ? getKiloCodeSessionRoots() + ? getKiloCodeSessionRoots().concat(getKiloCodeDataRoots()) : [getCodexSessionsDir(), derivedCodexDir]))); const availableRoots = roots.filter((dirPath) => dirPath && fs.existsSync(dirPath)); if (availableRoots.length === 0) { @@ -6624,12 +6879,21 @@ function resolveSessionFilePath(source, filePath, sessionId) { } matchedFile = filesMeta.find(item => path.basename(item, '.json').toLowerCase() === targetId) || ''; } else if (normalizedSource === 'kilocode') { - const files = []; - for (const rootPath of availableRoots) { - files.push(...collectKiloCodeSessionFiles(rootPath, 5000)); - if (files.length >= 5000) break; + for (const dbPath of getKiloCodeDatabaseFiles()) { + const detail = readKiloCodeDatabaseSessionDetail(dbPath, sessionId.trim(), 1); + if (detail) { + matchedFile = dbPath; + break; + } + } + if (!matchedFile) { + const files = []; + for (const rootPath of availableRoots) { + files.push(...collectKiloCodeSessionFiles(rootPath, 5000)); + if (files.length >= 5000) break; + } + matchedFile = files.find(item => path.basename(item).replace(/\.jsonl?$/i, '').toLowerCase() === targetId) || ''; } - matchedFile = files.find(item => path.basename(item).replace(/\.jsonl?$/i, '').toLowerCase() === targetId) || ''; } else { const files = []; for (const rootPath of availableRoots) { @@ -8415,6 +8679,8 @@ async function extractMessagesFromFile(filePath, source, options = {}) { extractCodexMessageFromRecord(record, state, currentLineIndex); } else if (source === 'codebuddy') { extractCodeBuddyMessageFromRecord(record, state, currentLineIndex); + } else if (source === 'kilocode') { + extractKiloCodeMessageFromRecord(record, state, currentLineIndex); } else { extractClaudeMessageFromRecord(record, state, currentLineIndex); } @@ -8464,7 +8730,8 @@ async function readSessionDetail(params = {}) { let extracted; if (source === 'kilocode') { - extracted = await extractSessionDetailPreviewFromFile(filePath, source, messageLimit, { preview }); + extracted = readKiloCodeDatabaseSessionDetail(filePath, params.sessionId, messageLimit) + || await extractSessionDetailPreviewFromFile(filePath, source, messageLimit, { preview }); } else if (source === 'gemini') { let json; try { @@ -8597,7 +8864,8 @@ async function readSessionPlain(params = {}) { let extracted; if (source === 'kilocode') { - extracted = await extractSessionDetailPreviewFromFile(filePath, source, messageLimit, { preview }); + extracted = readKiloCodeDatabaseSessionDetail(filePath, params.sessionId, maxMessages === Infinity ? MAX_EXPORT_MESSAGES : maxMessages) + || await extractMessagesFromFile(filePath, source, { maxMessages }); } else if (source === 'gemini') { let json; try { @@ -8687,7 +8955,8 @@ async function exportSessionData(params = {}) { let extracted; if (source === 'kilocode') { - extracted = await extractSessionDetailPreviewFromFile(filePath, source, messageLimit, { preview }); + extracted = readKiloCodeDatabaseSessionDetail(filePath, params.sessionId, maxMessages === Infinity ? MAX_EXPORT_MESSAGES : maxMessages) + || await extractMessagesFromFile(filePath, source, { maxMessages }); } else if (source === 'gemini') { let json; try { @@ -11936,11 +12205,12 @@ function resolveExportOutputPath(outputPath, defaultFileName) { } function printExportSessionUsage() { - console.log('\n用法: codexmate export-session --source (--session-id |--file ) [--output ] [--max-messages ]'); + console.log('\n用法: codexmate export-session --source (--session-id |--file ) [--output ] [--max-messages ]'); console.log('\n示例:'); console.log(' codexmate export-session --source codex --session-id 123456'); console.log(' codexmate export-session --source claude --file "~/.claude/projects/demo/session.jsonl"'); console.log(' codexmate export-session --source codebuddy --file "~/.codebuddy/projects/demo/session.jsonl"'); + console.log(' codexmate export-session --source kilocode --session-id ses_123 --file "~/.local/share/kilo/kilo.db"'); console.log(' codexmate export-session --source codex --session-id 123456 --max-messages=all'); } @@ -12008,8 +12278,8 @@ function parseExportSessionArgs(args = []) { } const normalizedSource = options.source.trim().toLowerCase(); - if (normalizedSource && normalizedSource !== 'codex' && normalizedSource !== 'claude') { - errors.push('参数 --source 仅支持 codex 或 claude'); + if (normalizedSource && normalizedSource !== 'codex' && normalizedSource !== 'claude' && normalizedSource !== 'gemini' && normalizedSource !== 'codebuddy' && normalizedSource !== 'kilocode') { + errors.push('参数 --source 仅支持 codex、claude、gemini、codebuddy 或 kilocode'); } options.source = normalizedSource; @@ -18947,7 +19217,7 @@ function printMainHelp() { console.log(' 注: follow-up 自动排队仅支持 linux/android/netbsd/openbsd/darwin/freebsd 且 stdin 必须是 TTY,其他平台会报错'); console.log(' codexmate qwen [参数...] 等同于 qwen --yolo'); console.log(' codexmate mcp [serve] [--transport stdio] [--allow-write|--read-only]'); - console.log(' codexmate export-session --source (--session-id |--file ) [--output ] [--max-messages ]'); + console.log(' codexmate export-session --source (--session-id |--file ) [--output ] [--max-messages ]'); console.log(' codexmate convert-session --from --to (--session-id |--file ) [--output ] [--max-messages ]'); console.log(' codexmate zip <路径> [--max:级别] 压缩(系统 zip 优先,其次 zip-lib)'); console.log(' codexmate unzip [输出目录] 解压(zip-lib)'); diff --git a/tests/unit/kilocode-session-browser.test.mjs b/tests/unit/kilocode-session-browser.test.mjs new file mode 100644 index 00000000..2a164b1e --- /dev/null +++ b/tests/unit/kilocode-session-browser.test.mjs @@ -0,0 +1,99 @@ +import assert from 'assert'; +import test from 'node:test'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { execFileSync } from 'child_process'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..', '..'); + +function createKiloDb(dbPath) { + const script = String.raw` +import json, sqlite3, sys +path = sys.argv[1] +conn = sqlite3.connect(path) +try: + conn.executescript(''' + create table session ( + id text primary key, + project_id text not null default 'p1', + directory text not null, + path text, + title text not null, + version text not null default 'test', + model text, + cost real not null default 0, + tokens_input integer not null default 0, + tokens_output integer not null default 0, + tokens_reasoning integer not null default 0, + tokens_cache_read integer not null default 0, + tokens_cache_write integer not null default 0, + time_created integer, + time_updated integer, + time_archived integer + ); + create table session_message ( + id text primary key, + session_id text not null, + type text not null, + seq integer, + time_created integer, + time_updated integer, + data text not null + ); + ''') + conn.execute('insert into session (id, directory, title, model, tokens_input, tokens_output, time_created, time_updated) values (?, ?, ?, ?, ?, ?, ?, ?)', ( + 'ses_kilo_test', '/tmp/kilo-project', 'Kilo test session', json.dumps({'providerID':'openai','id':'gpt-test'}), 12, 34, 1750000000000, 1750000003000 + )) + messages = [ + ('m1', 'user', 1, {'type':'user','text':'hello from kilo','time':{'created':1750000001000}}), + ('m2', 'assistant', 2, {'type':'assistant','model':{'providerID':'openai','id':'gpt-test'},'content':[{'type':'text','id':'t1','text':'hi from assistant'},{'type':'tool','id':'tool1','name':'read','state':{'status':'completed','result':'ok'}}], 'time':{'created':1750000002000}}), + ('m3', 'shell', 3, {'type':'shell','command':'echo ok','output':'ok','time':{'created':1750000003000}}), + ] + for mid, typ, seq, data in messages: + conn.execute('insert into session_message (id, session_id, type, seq, time_created, time_updated, data) values (?, ?, ?, ?, ?, ?, ?)', (mid, 'ses_kilo_test', typ, seq, 1750000000000 + seq * 1000, 1750000000000 + seq * 1000, json.dumps(data))) + conn.commit() +finally: + conn.close() +`; + execFileSync('python3', ['-c', script, dbPath], { stdio: 'pipe' }); +} + +test('export-session reads KiloCode SQLite history without adding a package dependency', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codexmate-kilo-db-')); + const xdgData = path.join(tmpDir, 'xdg-data'); + const kiloDir = path.join(xdgData, 'kilo'); + const dbPath = path.join(kiloDir, 'kilo.db'); + const outputPath = path.join(tmpDir, 'out.md'); + fs.mkdirSync(kiloDir, { recursive: true }); + + try { + createKiloDb(dbPath); + execFileSync(process.execPath, [ + path.join(repoRoot, 'cli.js'), + 'export-session', + '--source', 'kilocode', + '--session-id', 'ses_kilo_test', + '--file', dbPath, + '--output', outputPath, + '--max-messages', 'all' + ], { + cwd: repoRoot, + env: { ...process.env, XDG_DATA_HOME: xdgData }, + encoding: 'utf8', + stdio: 'pipe' + }); + + const md = fs.readFileSync(outputPath, 'utf8'); + assert(md.includes('KiloCode VSCode')); + assert(md.includes('hello from kilo')); + assert(md.includes('hi from assistant')); + assert(md.includes('[tool:read completed] ok')); + assert(md.includes('$ echo ok')); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); diff --git a/web-ui/app.js b/web-ui/app.js index 3cc152c7..03177b8e 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -202,19 +202,25 @@ document.addEventListener('DOMContentLoaded', () => { all: [], codex: [], claude: [], - gemini: [] + gemini: [], + codebuddy: [], + kilocode: [] }, sessionPathOptionsLoadedMap: { all: false, codex: false, claude: false, - gemini: false + gemini: false, + codebuddy: false, + kilocode: false }, sessionPathRequestSeqMap: { all: 0, codex: 0, claude: 0, - gemini: 0 + gemini: 0, + codebuddy: 0, + kilocode: 0 }, sessionExporting: {}, sessionConverting: {}, diff --git a/web-ui/modules/app.computed.dashboard.mjs b/web-ui/modules/app.computed.dashboard.mjs index f531f525..f3af0d1e 100644 --- a/web-ui/modules/app.computed.dashboard.mjs +++ b/web-ui/modules/app.computed.dashboard.mjs @@ -203,6 +203,7 @@ export function createDashboardComputed() { if (this.sessionFilterSource === 'claude') return this.t('dashboard.sessionSource.claude'); if (this.sessionFilterSource === 'gemini') return this.t('dashboard.sessionSource.gemini'); if (this.sessionFilterSource === 'codebuddy') return this.t('dashboard.sessionSource.codebuddy'); + if (this.sessionFilterSource === 'kilocode') return this.t('dashboard.sessionSource.kilocode'); return this.t('dashboard.sessionSource.all'); }, inspectorSessionPathLabel() { diff --git a/web-ui/modules/app.computed.session.mjs b/web-ui/modules/app.computed.session.mjs index 78aff3e9..3ead2c88 100644 --- a/web-ui/modules/app.computed.session.mjs +++ b/web-ui/modules/app.computed.session.mjs @@ -307,7 +307,8 @@ export function createSessionComputed() { { value: "codex", label: this.t("sessions.source.codex") }, { value: "claude", label: this.t("sessions.source.claudeCode") }, { value: "gemini", label: this.t("sessions.source.gemini") }, - { value: "codebuddy", label: this.t("sessions.source.codebuddy") } + { value: "codebuddy", label: this.t("sessions.source.codebuddy") }, + { value: "kilocode", label: this.t("sessions.source.kilocode") } ]; }, activeSessionExportKey() { diff --git a/web-ui/modules/app.methods.session-browser.mjs b/web-ui/modules/app.methods.session-browser.mjs index cfaa979b..bbca3d96 100644 --- a/web-ui/modules/app.methods.session-browser.mjs +++ b/web-ui/modules/app.methods.session-browser.mjs @@ -120,7 +120,11 @@ export function createSessionBrowserMethods(options = {}) { syncSessionPathOptionsForSource(source, nextOptions, mergeWithExisting = false) { const targetSource = source === 'claude' ? 'claude' - : (source === 'gemini' ? 'gemini' : (source === 'all' ? 'all' : 'codex')); + : (source === 'gemini' + ? 'gemini' + : (source === 'codebuddy' + ? 'codebuddy' + : (source === 'kilocode' ? 'kilocode' : (source === 'all' ? 'all' : 'codex')))); const current = Array.isArray(this.sessionPathOptionsMap[targetSource]) ? this.sessionPathOptionsMap[targetSource] : []; @@ -137,7 +141,11 @@ export function createSessionBrowserMethods(options = {}) { refreshSessionPathOptions(source) { const targetSource = source === 'claude' ? 'claude' - : (source === 'gemini' ? 'gemini' : (source === 'all' ? 'all' : 'codex')); + : (source === 'gemini' + ? 'gemini' + : (source === 'codebuddy' + ? 'codebuddy' + : (source === 'kilocode' ? 'kilocode' : (source === 'all' ? 'all' : 'codex')))); const base = Array.isArray(this.sessionPathOptionsMap[targetSource]) ? [...this.sessionPathOptionsMap[targetSource]] : []; @@ -161,7 +169,11 @@ export function createSessionBrowserMethods(options = {}) { async loadSessionPathOptions(options = {}) { const source = options.source === 'claude' ? 'claude' - : (options.source === 'gemini' ? 'gemini' : (options.source === 'all' ? 'all' : 'codex')); + : (options.source === 'gemini' + ? 'gemini' + : (options.source === 'codebuddy' + ? 'codebuddy' + : (options.source === 'kilocode' ? 'kilocode' : (options.source === 'all' ? 'all' : 'codex')))); const forceRefresh = !!options.forceRefresh; const loaded = !!this.sessionPathOptionsLoadedMap[source]; if (!forceRefresh && loaded) { @@ -507,7 +519,9 @@ export function createSessionBrowserMethods(options = {}) { ? this.t('sessions.source.claudeCode') : (this.sessionFilterSource === 'gemini' ? this.t('sessions.source.gemini') - : (this.sessionFilterSource === 'codebuddy' ? this.t('sessions.source.codebuddy') : this.sessionFilterSource))); + : (this.sessionFilterSource === 'codebuddy' + ? this.t('sessions.source.codebuddy') + : (this.sessionFilterSource === 'kilocode' ? this.t('sessions.source.kilocode') : this.sessionFilterSource)))); chips.push({ key: 'source', title: this.t('sessions.filters.source'), value: label }); } if (this.sessionPathFilter) { diff --git a/web-ui/modules/i18n/locales/en.mjs b/web-ui/modules/i18n/locales/en.mjs index 262b2da3..029db819 100644 --- a/web-ui/modules/i18n/locales/en.mjs +++ b/web-ui/modules/i18n/locales/en.mjs @@ -327,6 +327,7 @@ const en = Object.freeze({ 'dashboard.sessionSource.claude': 'Claude Code', 'dashboard.sessionSource.gemini': 'Gemini CLI', 'dashboard.sessionSource.codebuddy': 'CodeBuddy Code', + 'dashboard.sessionSource.kilocode': 'KiloCode VSCode', 'dashboard.sessionSource.all': 'All', 'dashboard.sessionPath.all': 'All paths', 'dashboard.sessionQuery.unsupported': 'Unsupported source', @@ -653,6 +654,7 @@ const en = Object.freeze({ 'sessions.source.claudeCode': 'Claude Code', 'sessions.source.gemini': 'Gemini CLI', 'sessions.source.codebuddy': 'CodeBuddy Code', + 'sessions.source.kilocode': 'KiloCode VSCode', 'sessions.loadingList': 'Loading sessions...', 'sessions.empty': 'No sessions found', 'sessions.unknownTime': 'unknown time', diff --git a/web-ui/modules/i18n/locales/ja.mjs b/web-ui/modules/i18n/locales/ja.mjs index fba2ab2a..30345dbc 100644 --- a/web-ui/modules/i18n/locales/ja.mjs +++ b/web-ui/modules/i18n/locales/ja.mjs @@ -329,6 +329,7 @@ const ja = Object.freeze({ 'dashboard.sessionSource.claude': 'Claude Code', 'dashboard.sessionSource.gemini': 'Gemini CLI', 'dashboard.sessionSource.codebuddy': 'CodeBuddy Code', + 'dashboard.sessionSource.kilocode': 'KiloCode VSCode', 'dashboard.sessionSource.all': 'すべて', 'dashboard.sessionPath.all': 'すべてのパス', 'dashboard.sessionQuery.unsupported': '現在のソースは非対応', @@ -655,6 +656,7 @@ const ja = Object.freeze({ 'sessions.source.claudeCode': 'Claude Code', 'sessions.source.gemini': 'Gemini', 'sessions.source.codebuddy': 'CodeBuddy', + 'sessions.source.kilocode': 'KiloCode VSCode', 'sessions.loadingList': 'セッション一覧を読み込み中...', 'sessions.empty': 'セッションがありません', 'sessions.unknownTime': '不明な時間', diff --git a/web-ui/modules/i18n/locales/vi.mjs b/web-ui/modules/i18n/locales/vi.mjs index a4c882a4..6304d212 100644 --- a/web-ui/modules/i18n/locales/vi.mjs +++ b/web-ui/modules/i18n/locales/vi.mjs @@ -623,6 +623,7 @@ const vi = Object.freeze({ 'dashboard.sessionSource.claude': 'Claude Code', 'dashboard.sessionSource.gemini': 'Gemini CLI', 'dashboard.sessionSource.codebuddy': 'CodeBuddy Code', + 'dashboard.sessionSource.kilocode': 'KiloCode VSCode', 'dashboard.sessionSource.all': 'Tất cả', 'dashboard.sessionPath.all': 'Tất cả đường dẫn', 'dashboard.sessionQuery.unsupported': 'Nguồn không được hỗ trợ', @@ -858,6 +859,7 @@ const vi = Object.freeze({ 'sessions.source.claudeCode': 'Claude Code', 'sessions.source.gemini': 'Gemini CLI', 'sessions.source.codebuddy': 'CodeBuddy Code', + 'sessions.source.kilocode': 'KiloCode VSCode', 'sessions.loadingList': 'Đang tải phiên làm việc...', 'sessions.empty': 'Không tìm thấy phiên nào', 'sessions.unknownTime': 'thời gian không rõ', diff --git a/web-ui/modules/i18n/locales/zh-tw.mjs b/web-ui/modules/i18n/locales/zh-tw.mjs index 9f05bae0..a7281f8d 100644 --- a/web-ui/modules/i18n/locales/zh-tw.mjs +++ b/web-ui/modules/i18n/locales/zh-tw.mjs @@ -327,6 +327,7 @@ const zhTw = Object.freeze({ 'dashboard.sessionSource.claude': 'Claude Code', 'dashboard.sessionSource.gemini': 'Gemini CLI', 'dashboard.sessionSource.codebuddy': 'CodeBuddy Code', + 'dashboard.sessionSource.kilocode': 'KiloCode VSCode', 'dashboard.sessionSource.all': '全部', 'dashboard.sessionPath.all': '全部路徑', 'dashboard.sessionQuery.unsupported': '目前來源不支持', @@ -653,6 +654,7 @@ const zhTw = Object.freeze({ 'sessions.source.claudeCode': 'Claude Code', 'sessions.source.gemini': 'Gemini CLI', 'sessions.source.codebuddy': 'CodeBuddy Code', + 'sessions.source.kilocode': 'KiloCode VSCode', 'sessions.loadingList': '會話載入中...', 'sessions.empty': '暫無可用會話記錄', 'sessions.unknownTime': '未知時間', diff --git a/web-ui/modules/i18n/locales/zh.mjs b/web-ui/modules/i18n/locales/zh.mjs index fad8ce5d..b50dc5a8 100644 --- a/web-ui/modules/i18n/locales/zh.mjs +++ b/web-ui/modules/i18n/locales/zh.mjs @@ -327,6 +327,7 @@ const zh = Object.freeze({ 'dashboard.sessionSource.claude': 'Claude Code', 'dashboard.sessionSource.gemini': 'Gemini CLI', 'dashboard.sessionSource.codebuddy': 'CodeBuddy Code', + 'dashboard.sessionSource.kilocode': 'KiloCode VSCode', 'dashboard.sessionSource.all': '全部', 'dashboard.sessionPath.all': '全部路径', 'dashboard.sessionQuery.unsupported': '当前来源不支持', @@ -653,6 +654,7 @@ const zh = Object.freeze({ 'sessions.source.claudeCode': 'Claude Code', 'sessions.source.gemini': 'Gemini CLI', 'sessions.source.codebuddy': 'CodeBuddy Code', + 'sessions.source.kilocode': 'KiloCode VSCode', 'sessions.loadingList': '会话加载中...', 'sessions.empty': '暂无可用会话记录', 'sessions.unknownTime': '未知时间', diff --git a/web-ui/partials/index/layout-header.html b/web-ui/partials/index/layout-header.html index 5015a3e3..5070941e 100644 --- a/web-ui/partials/index/layout-header.html +++ b/web-ui/partials/index/layout-header.html @@ -278,7 +278,7 @@
{{ t('side.sessions.browser') }}
{{ t('side.sessions.browser.meta') }} - {{ t('sessions.sourceLabel', { value: (sessionFilterSource === 'all' ? t('sessions.source.all') : (sessionFilterSource === 'claude' ? 'Claude Code' : (sessionFilterSource === 'gemini' ? 'Gemini CLI' : (sessionFilterSource === 'codebuddy' ? 'CodeBuddy Code' : 'Codex')))) }) }} + {{ t('sessions.sourceLabel', { value: (sessionFilterSource === 'all' ? t('sessions.source.all') : (sessionFilterSource === 'claude' ? t('sessions.source.claudeCode') : (sessionFilterSource === 'gemini' ? t('sessions.source.gemini') : (sessionFilterSource === 'codebuddy' ? t('sessions.source.codebuddy') : (sessionFilterSource === 'kilocode' ? t('sessions.source.kilocode') : t('sessions.source.codex')))))) }) }}
+ + +
{{ sessionStandaloneText }}
diff --git a/web-ui/res/web-ui-render.precompiled.js b/web-ui/res/web-ui-render.precompiled.js index 1ed050eb..f747c745 100644 --- a/web-ui/res/web-ui-render.precompiled.js +++ b/web-ui/res/web-ui-render.precompiled.js @@ -3101,6 +3101,25 @@ return function render(_ctx, _cache) { ? (_openBlock(), _createElementBlock("span", { key: 0 }, " · " + _toDisplayString(_ctx.sessionStandaloneSourceLabel), 1 /* TEXT */)) : _createCommentVNode("v-if", true) ]), + _createElementVNode("div", { class: "session-standalone-actions" }, [ + _createElementVNode("button", { + class: "btn-tool", + type: "button", + onClick: $event => (_ctx.exportSession(_ctx.activeSession)), + disabled: !_ctx.activeSession + }, _toDisplayString(_ctx.t('sessions.export')), 9 /* TEXT, PROPS */, ["onClick", "disabled"]), + _createElementVNode("button", { + class: "btn-tool", + type: "button", + onClick: _ctx.copySessionStandaloneText, + disabled: _ctx.sessionStandaloneText === '' + }, _toDisplayString(_ctx.t('common.copy')), 9 /* TEXT, PROPS */, ["onClick", "disabled"]), + _createElementVNode("button", { + class: "btn-tool", + type: "button", + onClick: _ctx.openSessionBrowserHome + }, _toDisplayString(_ctx.t('sessions.backToBrowser')), 9 /* TEXT, PROPS */, ["onClick"]) + ]), _createElementVNode("pre", { class: "session-standalone-text" }, _toDisplayString(_ctx.sessionStandaloneText), 1 /* TEXT */) ])) ])) diff --git a/web-ui/styles/sessions-list.css b/web-ui/styles/sessions-list.css index 4ab2b992..3156a352 100644 --- a/web-ui/styles/sessions-list.css +++ b/web-ui/styles/sessions-list.css @@ -392,6 +392,13 @@ letter-spacing: -0.01em; } +.session-standalone-actions { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-sm); +} + .session-standalone-text { white-space: pre-wrap; font-family: var(--font-family-body);