1// Generate scrollable HTML transcript from OpenCode session 2// Usage: ptnode pt0/peatsite/octranscripts/epOcToHtmlAI.mjs <sessionId> [--startAtStr=X] 4import { writeFileSync, mkdirSync } from 'fs' 5import { join, dirname } from 'path' 6import { fileURLToPath } from 'url' 12 ansi, mdToTerminal, renderToolOutput, extractSeanceTranscript, 13 flattenSessionParts, wrapLine, isTableRow, isTableSep, partContainsStr, 16const __dirname = dirname(fileURLToPath(import.meta.url)) 20const inlineCodeToHtml = (text) => escapeHtml(text).replace(/`([^`]+)`/g, '<span style="color:var(--teal)">$1</span>') 22const tableToHtml = (tableLines) => { 23 const rows = tableLines.filter(l => !isTableSep(l)).map(l => 24 l.trim().slice(1, -1).split('|').map(c => c.trim()) 26 if (!rows.length) return '' 27 const [header, ...body] = rows 28 const ths = header.map(c => `<th>${inlineCodeToHtml(c)}</th>`).join('') 29 const trs = body.map(r => `<tr>${r.map(c => `<td>${inlineCodeToHtml(c)}</td>`).join('')}</tr>`).join('\n') 30 return `<table class="md-table"><thead><tr>${ths}</tr></thead><tbody>${trs}</tbody></table>` 33const mdToHtmlBlock = async (text, width) => { 34 const lines = text.split('\n') 35 /** @type {Array<{type: string, lines: string[]}>} */ 37 let /** @type {string[]} */ current = [], inTable = false 39 const flushCurrent = () => { 40 if (current.length) { chunks.push({type: inTable ? 'table' : 'text', lines: current}); current = [] } 43 for (const line of lines) { 44 const lineIsTable = isTableRow(line) || isTableSep(line) 45 if (lineIsTable !== inTable) { flushCurrent(); inTable = lineIsTable } 51 for (const chunk of chunks) { 52 if (chunk.type === 'table') { 53 parts.push(tableToHtml(chunk.lines)) 55 const formatted = await mdToTerminal(chunk.lines.join('\n'), Infinity) 59 return parts.join('\n') 62const generateHtml = async (sess, messages, partsByMsg) => { 64 const allParts = flattenSessionParts(messages, partsByMsg) 65 const contentLines = [] 67 for (const p of allParts) { 68 if (p.type === 'step-start' || p.type === 'step-finish' || p.type === 'compaction' || p.type === 'patch') continue 70 if (p.role === 'user' && p.type === 'text') { 71 if (!p.text?.trim()) continue 72 const mode = p.agent || 'plan' 74 contentLines.push(`<div class="message user-msg">`) 75 contentLines.push(` <div class="mode-bar ${mode}"></div>`) 76 contentLines.push(` <div class="user-text">${userText}</div>`) 77 contentLines.push(`</div>`) 78 } else if (p.role === 'assistant') { 79 if (p.type === 'text') { 80 if (!p.text?.trim()) continue 81 const html = await mdToHtmlBlock(p.text.trim(), width) 82 contentLines.push(`<div class="message assistant-msg">`) 83 contentLines.push(` <div class="assistant-text">${html}</div>`) 84 contentLines.push(`</div>`) 85 } else if (p.type === 'tool') { 86 const seanceTranscript = extractSeanceTranscript(p) 87 const toolLines = await renderToolOutput(p, width) 89 const isDiffLine = (h) => h.includes('class="diff-') 90 const joined = toolHtmlParts.reduce((acc, line, i) => { 91 const prev = toolHtmlParts[i - 1] 92 const sep = (isDiffLine(line) && prev && isDiffLine(prev)) ? '' : '\n' 93 return acc + sep + line 95 contentLines.push(`<div class="tool-call">${joined}</div>`) 97 if (seanceTranscript) { 98 const formatted = await mdToTerminal(seanceTranscript, width - 2) 99 const wrappedLines = formatted.split('\n').flatMap(l => wrapLine(` ${l}`, width, ' ')) 101 contentLines.push(`<div class="seance-block"><code><seance></code>\n${seanceHtml}\n<code></seance></code></div>`) 107 return `<!DOCTYPE html> 110 <meta charset="UTF-8"> 111 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 116<!--TRANSCRIPT_START--> 117 <div class="transcript-container"> 118${contentLines.join('\n')} 126 * @param {string} sessionId 127 * @param {{ startAtStr?: string | null }} [options] 129export const generateTranscriptHtml = async (sessionId, { startAtStr } = {}) => { 131 if (!data) throw new Error(`Session not found: ${sessionId}`) 132 const { session: sess, partsByMsg } = data 133 let { messages } = data 136 const startIdx = messages.findIndex(m => { 137 const parts = partsByMsg[m.id] || [] 138 return parts.some(p => partContainsStr(p, startAtStr)) 140 if (startIdx === -1) throw new Error(`startAtStr not found: "${startAtStr}"`) 141 messages = messages.slice(startIdx) 144 const html = await generateHtml(sess, messages, partsByMsg) 146 return redactFn ? redactFn(html) : html 149const main = async () => { 150 const args = process.argv.slice(2) 151 const sessionId = args.find(a => !a.startsWith('--')) 152 const startAtArg = args.find(a => a.startsWith('--startAtStr=')) 153 const startAtStr = startAtArg ? startAtArg.slice('--startAtStr='.length) : null 156 console.log(`Usage: ptnode pt0/peatsite/octranscripts/epOcToHtmlAI.mjs <sessionId> [--startAtStr="..."] 158Generates a scrollable HTML transcript from an OpenCode session. 161 --startAtStr=X Start transcript from first message containing X 164 ptnode pt0/peatsite/octranscripts/epOcToHtmlAI.mjs ses_abc123 165 ptnode pt0/peatsite/octranscripts/epOcToHtmlAI.mjs ses_abc123 --startAtStr="fix the bug" 170 if (startAtStr) console.log(`Starting from: "${truncateStr(startAtStr, 50)}"`) 172 mkdirSync(__dirname, { recursive: true }) 173 const baseName = sessionId.startsWith('ses_') ? sessionId : `ses_${sessionId}` 174 const htmlPath = join(__dirname, `${baseName}.html`) 176 console.log('Generating HTML transcript...') 177 const html = await generateTranscriptHtml(sessionId, { startAtStr }) 178 writeFileSync(htmlPath, html)