| 1 | import { truncate, plural, formatDay, mdEscapePipe, escapeMd } from './util.js'; |
| 2 | import { REPO_URL } from './config.js'; |
| 3 | |
| 4 | const ICONS = { |
| 5 | root: '⬢', |
| 6 | direction: '→', |
| 7 | correction: '↩', |
| 8 | 'scope-change': '⚑', |
| 9 | checkpoint: '◆', |
| 10 | question: '?', |
| 11 | }; |
| 12 | |
| 13 | const MAX_NODE_TEXT = 1500; |
| 14 | |
| 15 | export function renderMarkdown(tree, opts = {}) { |
| 16 | const { projectName, titlesOnly = false, version } = opts; |
| 17 | const { stats, roots, nodes, sessions } = tree; |
| 18 | const lines = []; |
| 19 | |
| 20 | lines.push(`# Prompt Tree: ${escapeMd(projectName)}`); |
| 21 | lines.push(''); |
| 22 | lines.push(`> ${banner(stats)}`); |
| 23 | lines.push(''); |
| 24 | |
| 25 | const root = nodes.find((n) => n.kind === 'root') || nodes[0]; |
| 26 | if (root) { |
| 27 | lines.push('## Goal'); |
| 28 | lines.push(''); |
| 29 | lines.push(blockquote(clip(root.text, 900))); |
| 30 | lines.push(''); |
| 31 | } |
| 32 | |
| 33 | lines.push('## The Path'); |
| 34 | lines.push(''); |
| 35 | const kindsPresent = new Set(nodes.map((n) => n.kind)); |
| 36 | const legendDefs = [ |
| 37 | ['root', '`⬢` root'], |
| 38 | ['direction', '`→` direction'], |
| 39 | ['correction', '`↩` correction'], |
| 40 | ['scope-change', '`⚑` scope change'], |
| 41 | ['checkpoint', '`◆` checkpoint'], |
| 42 | ['question', '`?` question'], |
| 43 | ]; |
| 44 | const legend = legendDefs.filter(([kind]) => kindsPresent.has(kind)).map(([, label]) => label); |
| 45 | if (nodes.some((n) => n.status === 'abandoned')) legend.push('`✗` abandoned'); |
| 46 | if (legend.length) { |
| 47 | lines.push(legend.join(' · ')); |
| 48 | lines.push(''); |
| 49 | } |
| 50 | for (const r of roots) renderNode(r, 0, lines, { titlesOnly }); |
| 51 | lines.push(''); |
| 52 | |
| 53 | const active = sessions.filter((s) => s.prompts.length); |
| 54 | if (active.length > 1) { |
| 55 | lines.push('## Sessions'); |
| 56 | lines.push(''); |
| 57 | lines.push('| # | When | Prompts | Session |'); |
| 58 | lines.push('|---|------|---------|---------|'); |
| 59 | active.forEach((s, i) => { |
| 60 | lines.push( |
| 61 | `| ${i + 1} | ${formatDay(s.firstTs) || ''} | ${s.prompts.length} | ${mdEscapePipe( |
| 62 | escapeMd(s.title || s.sessionId || '') |
| 63 | )} |` |
| 64 | ); |
| 65 | }); |
| 66 | lines.push(''); |
| 67 | } |
| 68 | |
| 69 | lines.push('## Reusable Prompt Pack'); |
| 70 | lines.push(''); |
| 71 | const pack = promptPack(nodes); |
| 72 | const fence = '`'.repeat(Math.max(3, longestRun(pack, '`') + 1)); |
| 73 | lines.push(`${fence}text`); |
| 74 | lines.push(pack); |
| 75 | lines.push(fence); |
| 76 | lines.push(''); |
| 77 | |
| 78 | lines.push('---'); |
| 79 | lines.push(''); |
| 80 | lines.push(`*[treetrace](${REPO_URL})${version ? ` v${version}` : ''} · [schema](${REPO_URL}/blob/main/SCHEMA.md)*`); |
| 81 | lines.push(''); |
| 82 | |
| 83 | return lines.join('\n'); |
| 84 | } |
| 85 | |
| 86 | function banner(stats) { |
| 87 | const parts = [ |
| 88 | `**${plural(stats.promptCount, 'prompt')}**`, |
| 89 | `**${plural(stats.sessionCount, 'session')}**`, |
| 90 | ]; |
| 91 | if (stats.days) parts.push(`**${plural(stats.days, 'day')}**`); |
| 92 | if (stats.corrections) parts.push(plural(stats.corrections, 'correction')); |
| 93 | if (stats.scopeChanges) parts.push(plural(stats.scopeChanges, 'scope change')); |
| 94 | if (stats.abandonedBranches) |
| 95 | parts.push(plural(stats.abandonedBranches, 'abandoned branch', 'abandoned branches')); |
| 96 | if (stats.toolUses) parts.push(`${stats.toolUses.toLocaleString()} tool calls`); |
| 97 | if (stats.filesTouched) parts.push(`${plural(stats.filesTouched, 'file')} touched`); |
| 98 | return parts.join(' · '); |
| 99 | } |
| 100 | |
| 101 | function renderNode(node, depth, lines, opts) { |
| 102 | let cur = node; |
| 103 | for (;;) { |
| 104 | emitNode(cur, depth, lines, opts); |
| 105 | if (cur.children.length === 1) { |
| 106 | cur = cur.children[0]; |
| 107 | continue; |
| 108 | } |
| 109 | for (const child of cur.children) renderNode(child, depth + 1, lines, opts); |
| 110 | return; |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | function emitNode(node, depth, lines, { titlesOnly }) { |
| 115 | const indent = ' '.repeat(depth); |
| 116 | const icon = ICONS[node.kind] || '→'; |
| 117 | const dead = node.status === 'abandoned'; |
| 118 | const safe = escapeMd(node.title); |
| 119 | const title = dead ? `~~${safe}~~ ✗` : node.kind === 'root' ? `**${safe}**` : safe; |
| 120 | const session = node.sessionBoundary ? ` ${dim(`(new session${node.ts ? `, ${formatDay(node.ts)}` : ''})`)}` : ''; |
| 121 | const nudges = node.nudges > 1 ? ` ${dim(`(+${node.nudges} nudges)`)}` : ''; |
| 122 | const reruns = node.reruns ? ` ${dim(`(re-issued ×${node.reruns + 1})`)}` : ''; |
| 123 | |
| 124 | lines.push(`${indent}- \`${icon}\` ${title}${session}${nudges}${reruns}`); |
| 125 | |
| 126 | if (!titlesOnly && node.text.replace(/\s+/g, ' ').trim().length > node.title.replace(/\.\.\.$/, '').length + 12) { |
| 127 | lines.push(`${indent} <details><summary>full prompt</summary>`); |
| 128 | lines.push(''); |
| 129 | lines.push(blockquote(clip(node.text, MAX_NODE_TEXT), indent + ' ')); |
| 130 | lines.push(`${indent} </details>`); |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | function dim(s) { |
| 135 | return `<sub>${s}</sub>`; |
| 136 | } |
| 137 | |
| 138 | function blockquote(text, indent = '') { |
| 139 | return text |
| 140 | .split('\n') |
| 141 | .map((l) => `${indent}> ${l}`) |
| 142 | .join('\n'); |
| 143 | } |
| 144 | |
| 145 | function clip(text, max) { |
| 146 | if (text.length <= max) return escapeMd(text); |
| 147 | return `${escapeMd(text.slice(0, max).trimEnd())}\n\n*[...trimmed, ${text.length - max} more chars]*`; |
| 148 | } |
| 149 | |
| 150 | export function promptPack(nodes) { |
| 151 | const accepted = nodes.filter( |
| 152 | (n) => |
| 153 | n.status !== 'abandoned' && |
| 154 | (n.kind === 'root' || n.kind === 'direction' || n.kind === 'scope-change') |
| 155 | ); |
| 156 | const out = []; |
| 157 | accepted.forEach((n, i) => { |
| 158 | const corrections = n.children?.filter((ch) => ch.kind === 'correction' && ch.status !== 'abandoned') || []; |
| 159 | let entry = `${i + 1}. ${condense(n.text)}`; |
| 160 | for (const corr of corrections) { |
| 161 | entry += `\n (constraint learned along the way: ${condense(corr.text, 220)})`; |
| 162 | } |
| 163 | out.push(entry); |
| 164 | }); |
| 165 | return out.join('\n'); |
| 166 | } |
| 167 | |
| 168 | function condense(text, max = 420) { |
| 169 | return truncate(text.replace(/\s+/g, ' '), max); |
| 170 | } |
| 171 | |
| 172 | function longestRun(text, ch) { |
| 173 | let max = 0; |
| 174 | let cur = 0; |
| 175 | for (const c of text) { |
| 176 | if (c === ch) { cur++; if (cur > max) max = cur; } else cur = 0; |
| 177 | } |
| 178 | return max; |
| 179 | } |