/**
* Markdown export of one transcript view: the /export command's pure
* formatter. Deterministic and side-effect free — the runner owns the file
* write, so tests drive the builder with folded views directly.
*
* @module @deepseek-ai/dsh-code/render/export
*/
import { assertNever } from '@deepseek-ai/dsh-util-values'
import { fileLabels, imageLabels, type TranscriptView } from './projection.ts'
import { t } from '../i18n.ts'
/**
* Render the transcript as a standalone markdown document.
* @param view - the folded transcript view to export.
* @param sessionId - the full session identity for the header.
* @returns the complete markdown text.
*/
/** Both user and queued rows export the same attachment label block. */
const attachmentLabels = (entry: { images?: readonly unknown[]; files?: readonly unknown[] }): string =>
[imageLabels(entry.images as never), fileLabels(entry.files as never)].filter(label => label !== '').join('\n')
export function buildExportMarkdown(view: TranscriptView, sessionId: string): string {
const out: string[] = [
view.title === ''
? `# dsh session ${sessionId}`
: `# ${view.title}`,
`> session ${sessionId}`,
'',
]
// The effective system prompt (v3 surface nodes) heads the export in a
// collapsed block: visible when audited, out of the way when scrolled.
if (view.systemPrompt !== '') {
out.push('system prompt
', '', view.systemPrompt, '', ' ', '')
}
for (const entry of view.entries) {
switch (entry.kind) {
case 'user':
if (entry.notice) {
out.push(`> ⤷ context: ${entry.text}`, '')
} else {
const attachments = attachmentLabels(entry)
// How the prompt was delivered is part of the record: a queued or
// steered message reads differently from one typed into an idle
// composer. Ordinary prompts keep the bare heading.
const marker = entry.delivery === undefined ? '' : ` (${t(entry.delivery === 'queued' ? 'entry.delivery.queued' : 'entry.delivery.steered')})`
out.push(`## user${marker}`, '', entry.text, ...(attachments === '' ? [] : [attachments]), '')
}
break
case 'assistant':
if (entry.reasoning !== '') {
out.push('thinking
', '', entry.reasoning, '', ' ', '')
}
out.push('## assistant', '', entry.text, '')
break
case 'tool':
out.push(`### tool \`${entry.name}\``, '')
if (entry.preview !== '') out.push(`- args: ${entry.preview}`)
if (entry.subs.length > 0) {
for (const sub of entry.subs) {
const label = sub.preview === '' ? sub.name : `${sub.name} ${sub.preview}`
out.push(`- dispatch: ${sub.state === 'error' ? 'error' : sub.state === 'running' ? 'running' : 'ok'} · ${label}${sub.summary === '' ? '' : ` · ${sub.summary}`}`)
}
if (entry.subsDropped > 0) out.push(`- dispatch: … ${entry.subsDropped} earlier dispatch${entry.subsDropped === 1 ? '' : 'es'}`)
}
if (entry.summary !== '') out.push(`- ${entry.state === 'error' ? 'error' : 'result'}: ${entry.summary}`)
out.push('')
break
case 'command':
out.push(`### /${entry.name}${entry.args === '' ? '' : ` ${entry.args}`}`, '')
if (entry.summary !== '') out.push(`- ${entry.state === 'error' ? 'error' : 'result'}: ${entry.summary}`)
out.push('')
break
case 'error':
out.push(`> ⨯ ${entry.text}`, '')
break
case 'turn-marker':
out.push(`> ${entry.text}`, '')
break
case 'compaction':
out.push(entry.ok
? `> compacted ~${entry.tokens} tokens`
: `> compaction failed: ${entry.error}`, '')
break
case 'retry':
out.push(`> retry ${entry.attempt}/${entry.max} (${entry.code})`, '')
break
case 'files':
out.push(`> files changed: ${entry.paths.join(', ')}`, '')
break
case 'workflow':
out.push(`### workflow \`${entry.name}\` (${entry.state})`, '')
for (const member of entry.members) {
const phase = member.phase === '' ? '' : ` [${member.phase}]`
out.push(`- ${member.outcome}: ${member.label}${phase}`)
}
if (entry.membersDropped > 0) out.push(`- … ${entry.membersDropped} earlier member${entry.membersDropped === 1 ? '' : 's'}`)
out.push('')
break
case 'pending':
// Codex PendingSteer: queued prompts export like ordinary user rows.
out.push('## user', '', entry.text, ...(attachmentLabels(entry) === '' ? [] : [attachmentLabels(entry)]), '')
break
default:
assertNever(entry, 'transcript entry kind')
}
}
if (view.streaming !== '') out.push('## assistant (streaming)', '', view.streaming, '')
const { stats } = view
out.push('---', '')
out.push(`- model: ${view.model === '' ? '(none yet)' : view.model}`)
out.push(`- turns: ${stats.turns} · steps: ${stats.steps}`)
out.push(`- tokens: ↑${stats.usage.uncachedInputTokens} ↓${stats.usage.outputTokens} · cache read ${stats.usage.cacheReadTokens} · cache write ${stats.usage.cacheWriteTokens}`)
out.push(`- todos: ${view.todos.length}`)
return out.join('\n')
}