import type { Message, Part } from '@opencode-ai/sdk/v2'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getCurrentIntlLocale } from '@/lib/i18n'; import { isVSCodeRuntime, openDesktopPath, revealDesktopPath, saveDesktopMarkdownFile } from '@/lib/desktop'; import { getRevealLabelKey } from '@/lib/utils'; type SessionMessageRecord = { info: Message; parts: Part[] }; export type ChildSessionExport = { title: string; agent?: string; records: SessionMessageRecord[]; children: ChildSessionExport[]; }; function formatTimestamp(timestamp: number | undefined): string { if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) { return ''; } const date = new Date(timestamp); if (Number.isNaN(date.getTime())) { return ''; } const monthPart = date.toLocaleString(getCurrentIntlLocale(), { month: 'short' }); const dayPart = date.getDate(); const yearPart = date.getFullYear(); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${monthPart} ${dayPart}, ${yearPart}, ${hours}:${minutes}`; } function formatAssistantModel(record: SessionMessageRecord): string { if (record.info.role === 'user') { return ''; } const providerID = typeof record.info.providerID === 'string' ? record.info.providerID.trim() : ''; const modelID = typeof record.info.modelID === 'string' ? record.info.modelID.trim() : ''; if (providerID && modelID) { return `${providerID}/${modelID}`; } return modelID || providerID; } function formatMessageHeader(record: SessionMessageRecord): string { const label = record.info.role === 'user' ? 'User' : 'Assistant'; const timestamp = formatTimestamp(record.info.time?.created); const assistantModel = formatAssistantModel(record); const details = timestamp && assistantModel ? `${timestamp} (${assistantModel})` : (timestamp || assistantModel); return details ? `**${label}**\n\n*${details}*` : `**${label}**`; } function extractTextFromParts(parts: Part[]): string { return parts .filter((p): p is Part & { type: 'text'; text: string } => p.type === 'text' && typeof p.text === 'string') .map((p) => p.text) .join(''); } function formatMessageAsMarkdown(record: SessionMessageRecord): string { const role = formatMessageHeader(record); const text = extractTextFromParts(record.parts).trim(); if (!text) return ''; return `${role}\n\n${text}`; } function formatChildSessionAsMarkdown(child: ChildSessionExport, depth: number): string { const heading = '#'.repeat(Math.min(depth + 1, 6)); const agentLabel = child.agent ? ` — ${child.agent}` : ''; const childHeader = `${heading} Sub-agent: ${child.title}${agentLabel}\n\n---\n\n`; const childBody = child.records .map(formatMessageAsMarkdown) .filter(Boolean) .join('\n\n---\n\n'); const parts = [childHeader + childBody]; for (const grandchild of child.children) { parts.push(formatChildSessionAsMarkdown(grandchild, depth + 1)); } return parts.join('\n\n---\n\n'); } export function formatSessionAsMarkdown( messages: SessionMessageRecord[], sessionTitle?: string | null, childSessions?: ChildSessionExport[], ): string { const title = sessionTitle?.trim() || 'Session'; const date = new Date().toISOString().split('T')[0]; const header = `# ${title}\n\n*Exported on ${date}*\n\n---\n\n`; const body = messages .map(formatMessageAsMarkdown) .filter(Boolean) .join('\n\n---\n\n'); let result = header + body; if (childSessions && childSessions.length > 0) { const childMarkdown = childSessions .map((child) => formatChildSessionAsMarkdown(child, 1)) .join('\n\n---\n\n'); result += '\n\n---\n\n' + childMarkdown; } return result; } export function downloadAsMarkdown(content: string, filename: string): void { const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filename; document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); URL.revokeObjectURL(url); } export async function saveAsMarkdownDesktop(content: string, filename: string): Promise { const desktopPath = await saveDesktopMarkdownFile(filename, content); if (desktopPath) { return desktopPath; } if (!isVSCodeRuntime()) { return null; } try { const payload = await getRegisteredRuntimeAPIs()?.vscode?.saveMarkdown?.({ fileName: filename, content }) as { saved?: boolean; path?: string } | undefined; if (!payload) { return null; } if (payload.saved !== true) { return null; } const savedPath = typeof payload.path === 'string' ? payload.path.trim() : ''; return savedPath.length > 0 ? savedPath : null; } catch { return null; } } export async function revealExportedMarkdown(path: string): Promise { const runtimeFiles = getRegisteredRuntimeAPIs()?.files; if (runtimeFiles?.revealPath) { try { const result = await runtimeFiles.revealPath(path); return Boolean(result?.success); } catch { return false; } } if (await revealDesktopPath(path)) { return true; } return openDesktopPath(path); } export function getExportRevealLabelKey() { return getRevealLabelKey(); } export function buildExportFilename(sessionTitle?: string | null): string { const base = sessionTitle?.trim() || 'session'; const safe = base .normalize('NFKC') .toLowerCase() .replace(/[^\p{L}\p{N}]+/gu, '-') .replace(/^-+|-+$/g, '') .slice(0, 60); const normalizedBase = safe || 'session'; const date = new Date().toISOString().split('T')[0]; return `${normalizedBase}-${date}.md`; }