🌳
pt0/peatsite/octranscripts/epOcToHtmlAI.mjs
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'
11import {
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())
25 )
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[]}>} */
36 const chunks = []
37 let /** @type {string[]} */ current = [], inTable = false
39 const flushCurrent = () => {
40 if (current.length) { chunks.push({type: inTable ? 'table' : 'text', lines: current}); current = [] }
41 }
43 for (const line of lines) {
44 const lineIsTable = isTableRow(line) || isTableSep(line)
45 if (lineIsTable !== inTable) { flushCurrent(); inTable = lineIsTable }
46 current.push(line)
47 }
48 flushCurrent()
50 const parts = []
51 for (const chunk of chunks) {
52 if (chunk.type === 'table') {
53 parts.push(tableToHtml(chunk.lines))
54 } else {
55 const formatted = await mdToTerminal(chunk.lines.join('\n'), Infinity)
56 parts.push(ansiToHtml(formatted))
57 }
58 }
59 return parts.join('\n')
62const generateHtml = async (sess, messages, partsByMsg) => {
63 const width = 120
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'
73 const userText = escapeHtml(p.text.trim())
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)
88 const toolHtmlParts = toolLines.map(l => ansiToHtml(l))
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
94 }, '').slice(1)
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, ' '))
100 const seanceHtml = ansiToHtml(wrappedLines.join('\n'))
101 contentLines.push(`<div class="seance-block"><code>&lt;seance&gt;</code>\n${seanceHtml}\n<code>&lt;/seance&gt;</code></div>`)
102 }
103 }
104 }
105 }
107 return `<!DOCTYPE html>
108<html lang="en">
109<head>
110 <meta charset="UTF-8">
111 <meta name="viewport" content="width=device-width, initial-scale=1.0">
112 <title>${escapeHtml(sess.title || sess.id)}</title>
113 <style>${allTranscriptStyles}</style>
114</head>
115<body>
116<!--TRANSCRIPT_START-->
117 <div class="transcript-container">
118${contentLines.join('\n')}
119 </div>
120<!--TRANSCRIPT_END-->
121</body>
122</html>`
125/**
126 * @param {string} sessionId
127 * @param {{ startAtStr?: string | null }} [options]
128 */
129export const generateTranscriptHtml = async (sessionId, { startAtStr } = {}) => {
130 const data = loadOcSessionData(sessionId, { withTime: true })
131 if (!data) throw new Error(`Session not found: ${sessionId}`)
132 const { session: sess, partsByMsg } = data
133 let { messages } = data
135 if (startAtStr) {
136 const startIdx = messages.findIndex(m => {
137 const parts = partsByMsg[m.id] || []
138 return parts.some(p => partContainsStr(p, startAtStr))
139 })
140 if (startIdx === -1) throw new Error(`startAtStr not found: "${startAtStr}"`)
141 messages = messages.slice(startIdx)
142 }
144 const html = await generateHtml(sess, messages, partsByMsg)
145 const redactFn = await getOssTranscriptRedactFn()
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
155 if (!sessionId) {
156 console.log(`Usage: ptnode pt0/peatsite/octranscripts/epOcToHtmlAI.mjs <sessionId> [--startAtStr="..."]
158Generates a scrollable HTML transcript from an OpenCode session.
160Options:
161 --startAtStr=X Start transcript from first message containing X
163Example:
164 ptnode pt0/peatsite/octranscripts/epOcToHtmlAI.mjs ses_abc123
165 ptnode pt0/peatsite/octranscripts/epOcToHtmlAI.mjs ses_abc123 --startAtStr="fix the bug"
166`)
167 process.exit(1)
168 }
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)
179 console.log(`Output: ${shortPtPath(htmlPath)}`)
183if (isDirectlyRun(import.meta.url)) main()