Zion Boggan
repos/TreeTrace/src/render-md.js
zionboggan.com ↗
179 lines · javascript
History for this file →
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
}