/**
* Markdown transcript renderer — pure function over {@link RenderInput}.
*
* Design note (from @deepseek-ai/dsh-session/surface): the model-visible
* surface deliberately shadows replaced ranges, so it is the WRONG source for
* a human transcript. This plugin renders append-origin surface events —
* everything the user actually saw, including ranges later compacted away.
*/
import type {
AssistantMessage,
ContentBlock,
ToolResultMessage,
} from '@deepseek-ai/dsh-llm'
import type { RenderInput, LineageNode } from '../types.ts'
import { asAssistant, asToolResult } from '../types.ts'
import { formatDuration } from '../stats.ts'
import { renderLineageMermaid, renderTimelineMermaid } from './mermaid.ts'
import { parseToolArguments, renderToolDiff } from './diff.ts'
import { truncate } from '../util/truncate.ts'
export interface MarkdownRenderOptions {
readonly argCharLimit: number
readonly resultCharLimit: number
}
export const defaultMarkdownOptions: MarkdownRenderOptions = {
argCharLimit: 512,
resultCharLimit: 2048,
}
function fmtTime(epochMs: number): string {
return new Date(epochMs).toISOString().replace('T', ' ').replace(/\.\d+Z$/, 'Z')
}
function id8(id: string): string {
return id.slice(0, 8)
}
function renderHeaderBlock(input: RenderInput): string {
const h = input.header
const rows: Array<[string, string]> = [
['Session', `\`${h.id}\``],
['Project', h.cwd ?? '(no cwd)'],
['Created', h.createdAt ? fmtTime(h.createdAt) : '(unknown)'],
]
if (h.agentPreset) rows.push(['Agent preset', h.agentPreset])
rows.push(['Messages', String(input.totals.messages)])
const failed = input.stats?.failedToolCalls
rows.push(['Tool calls', failed !== undefined && failed > 0 ? `${input.totals.toolCalls} (${failed} failed)` : String(input.totals.toolCalls)])
rows.push([
'Tokens (in/out)',
`${input.totals.inputTokens.toLocaleString('en-US')} / ${input.totals.outputTokens.toLocaleString('en-US')}`,
])
if (input.stats?.durationMs !== undefined && input.stats.durationMs !== null) {
rows.push(['Duration', formatDuration(input.stats.durationMs)])
}
if (input.stats?.cost !== undefined) {
const c = input.stats.cost
rows.push(['Cost ≈', `${c.currency}${c.total.toFixed(4)} (in ${c.currency}${c.input.toFixed(4)} / out ${c.currency}${c.output.toFixed(4)})`])
}
rows.push(['Exported', fmtTime(input.generatedAt)])
rows.push(['Generator', input.generator])
const table = rows.map(([k, v]) => `| ${k} | ${v} |`).join('\n')
return ['# DSH Session Transcript', '', '| Key | Value |', '|---|---|', table, ''].join('\n')
}
function renderLineageNode(node: LineageNode, depth: number): string[] {
const origin = node.origin ? ` (${node.origin})` : ''
const line = `${' '.repeat(depth)}- \`${id8(node.id)}\`${origin} — created ${fmtTime(node.createdAt)}`
return [line, ...node.children.flatMap((child) => renderLineageNode(child, depth + 1))]
}
function renderLineage(input: RenderInput): string | null {
const lineage = input.lineage
if (!lineage) return null
if (lineage.ancestors.length === 0 && lineage.descendants.length === 0) return null
const parts: string[] = ['## Lineage', '']
const graph = renderLineageMermaid(lineage, input.header.id)
if (graph !== null) parts.push('```mermaid', graph, '```', '')
if (lineage.ancestors.length > 0) {
parts.push('Ancestors (root → this session):')
parts.push('')
const chain = lineage.ancestors
.map((a) => `\`${id8(a.id)}\``)
.concat('**this session**')
.join(' → ')
parts.push(chain, '')
}
if (lineage.descendants.length > 0) {
parts.push('Subagent descendants:')
parts.push('')
for (const node of lineage.descendants) {
parts.push(...renderLineageNode(node, 0))
}
parts.push('')
}
return parts.join('\n')
}
function fence(lang: string, body: string): string {
// A body line starting with ``` would close a three-backtick fence early
// and split the block; those bodies get a four-backtick fence so every
// diff or json block stays exactly one block (#5).
const open = /^`{3}/m.test(body) ? '````' : '```'
return `${open}${lang}\n${body}\n${open}`
}
function renderAssistantMessage(
message: AssistantMessage,
options: MarkdownRenderOptions,
usageNote?: string,
): string[] {
const parts: string[] = ['### 🤖 Assistant', '']
const provenance = `*${message.source.provider} / ${message.source.model}*`
parts.push(usageNote ? `${provenance} — ${usageNote}` : provenance, '')
for (const block of message.content) {
if (block.type === 'reasoning') {
parts.push(
'',
'Reasoning
',
'',
block.text.trim(),
'',
' ',
'',
)
} else if (block.type === 'text') {
parts.push(block.text.trim(), '')
} else if (block.type === 'tool-call') {
parts.push(`#### 🔧 Tool Call — \`${block.name}\``, '')
const parsed = parseToolArguments(block.arguments)
const diff = renderToolDiff(block.name, parsed)
if (diff) {
parts.push(fence('diff', truncate(diff, options.resultCharLimit)), '')
} else {
parts.push('**arguments**:', '', fence('json', truncate(block.arguments, options.argCharLimit)), '')
}
}
// Unknown block kinds (merge-extensible union) render as an opaque note.
else {
parts.push(`> *(unsupported content block: ${JSON.stringify((block as ContentBlock).type)})*`, '')
}
}
return parts
}
function renderToolResult(
message: ToolResultMessage,
options: MarkdownRenderOptions,
error?: { name: string; code: string },
): string[] {
const parts: string[] = [
`### 🧾 Tool Result — call \`${id8(String(message.source.callId))}\`${error ? ' ⚠️ ERROR' : ''}`,
'',
]
if (error) {
parts.push(`> Tool failed: \`${error.name}\` (\`${error.code}\`)`, '')
}
for (const block of message.content) {
const inner = block.content
.map((b) => (b.type === 'text' ? b.text : `*(block: ${JSON.stringify(b.type)})*`))
.join('\n')
parts.push(fence('text', truncate(inner, options.resultCharLimit)), '')
if (block.isError) parts.push('> ⚠️ result flagged as error', '')
}
return parts
}
function renderEntry(entry: RenderInput['entries'][number], options: MarkdownRenderOptions): string[] {
const assistant = asAssistant(entry.message)
if (assistant) {
const usageNote = entry.usage
? `tokens: ${entry.usage.inputTokens.toLocaleString('en-US')} in / ${entry.usage.outputTokens.toLocaleString('en-US')} out`
: undefined
return renderAssistantMessage(assistant, options, usageNote)
}
const toolResult = asToolResult(entry.message)
if (toolResult) return renderToolResult(toolResult, options, entry.error)
if (entry.message.role === 'user') {
const parts: string[] = ['### 👤 User', '']
for (const block of entry.message.content) {
if (block.type === 'text') {
parts.push(block.text.trim(), '')
} else {
parts.push(`> *(block: ${JSON.stringify((block as ContentBlock).type)})*`, '')
}
}
return parts
}
return [`> *(unsupported message role: ${JSON.stringify(entry.message.role)})*`, '']
}
function renderTimelineSection(input: RenderInput): string | null {
const gantt = renderTimelineMermaid(input.entries)
if (gantt === null) return null
return ['## Timeline', '', '```mermaid', gantt, '```', ''].join('\n')
}
function renderFilterNote(input: RenderInput): string | null {
if (input.filterNote === undefined) return null
return ['> ⚠ ' + input.filterNote, ''].join('\n')
}
function renderLogOnly(input: RenderInput): string | null {
if (!input.logOnly || input.logOnly.length === 0) return null
const parts: string[] = ['## Log-only Events', '', 'Events that never joined the model surface (command lifecycles, compaction markers, …).', '']
for (const line of input.logOnly) {
const summary = line.summary ? ` — ${line.summary}` : ''
parts.push(`- \`${line.seq}\` · ${fmtTime(line.time)} · \`${line.type}\`${summary}`)
}
parts.push('')
return parts.join('\n')
}
/** Render the complete Markdown transcript. */
export function renderMarkdown(input: RenderInput, options?: Partial): string {
const opts = { ...defaultMarkdownOptions, ...options }
const sections: Array = [
renderHeaderBlock(input),
renderFilterNote(input),
renderLineage(input),
renderTimelineSection(input),
['## Transcript', ''].join('\n'),
input.entries.map((entry) => renderEntry(entry, opts).join('\n')).join('\n'),
renderLogOnly(input),
]
const doc = sections
.filter((section): section is string => section !== null)
.join('\n')
return `${doc.trimEnd()}\n`
}