|
| 1 | +/** |
| 2 | + * Pure trace → Markdown serializer for /export-with-traces. |
| 3 | + * |
| 4 | + * Consumes the trace store's per-run event log (trace/recorder.js) — an |
| 5 | + * append-only, compaction-immune record whose tool results are the RAW structured |
| 6 | + * values (pre-truncated by the recorder, never `_wrapUntrusted`-wrapped). That is |
| 7 | + * the right source for a tool chain; `this.conversations` is not (it is compacted, |
| 8 | + * enriched, and wrapped — see the closed PR #348 review). |
| 9 | + * |
| 10 | + * This renders the TOOL CHAIN: user/assistant/planner prose, tool calls (name, |
| 11 | + * args, result), and errors — in order. Screenshot / note / vision_sub_call events |
| 12 | + * are recorded but deliberately not rendered; the file says so in its footer, so it |
| 13 | + * never claims to be a complete record. The complete record is the Traces-page JSON. |
| 14 | + * |
| 15 | + * Pure and browser-neutral → unit-tested in test/run.js without a DOM or IndexedDB. |
| 16 | + * |
| 17 | + * @param {Array<{run: object, events: Array}>} runsWithEvents chronological runs, |
| 18 | + * each with its ordered event list. |
| 19 | + */ |
| 20 | + |
| 21 | +const ARGS_LIMIT = 300; |
| 22 | +const RESULT_LIMIT = 600; |
| 23 | +const FOOTER = '_Screenshots, notes and vision sub-calls are recorded but not rendered here — see the Traces page for the complete record._'; |
| 24 | + |
| 25 | +function oneLine(t) { return String(t ?? '').replace(/\s+/g, ' ').trim(); } |
| 26 | +function humanSize(n) { return n >= 1024 ? `${(n / 1024).toFixed(1)}kb` : `${n}b`; } |
| 27 | + |
| 28 | +function truncate(text, limit) { |
| 29 | + const s = String(text ?? ''); |
| 30 | + if (s.length <= limit) return s; |
| 31 | + return `${s.slice(0, limit)}… +${humanSize(s.length - limit)} truncated`; |
| 32 | +} |
| 33 | + |
| 34 | +// Wrap text in a fenced code block that survives content which is ITSELF fenced. |
| 35 | +// Planner responses usually arrive already wrapped in ```json … ```; naively |
| 36 | +// re-fencing them produces ```\n```json\n…, which no Markdown renderer parses. |
| 37 | +// So: unwrap a single enclosing fence (keeping its language hint), then choose a |
| 38 | +// fence longer than any backtick run left inside, per CommonMark, so nothing can |
| 39 | +// close the block early. |
| 40 | +function fencedBlock(content) { |
| 41 | + let body = String(content ?? '').trim(); |
| 42 | + let info = ''; |
| 43 | + const wrapped = body.match(/^```([^\n]*)\n([\s\S]*?)\n```$/); |
| 44 | + if (wrapped) { info = wrapped[1].trim(); body = wrapped[2].trim(); } |
| 45 | + const longestRun = (body.match(/`+/g) || []).reduce((n, s) => Math.max(n, s.length), 0); |
| 46 | + const fence = '`'.repeat(Math.max(3, longestRun + 1)); |
| 47 | + return `${fence}${info}\n${body}\n${fence}`; |
| 48 | +} |
| 49 | + |
| 50 | +function stringifyArgs(args) { |
| 51 | + if (args == null) return ''; |
| 52 | + const s = typeof args === 'string' ? args : JSON.stringify(args); |
| 53 | + return truncate(oneLine(s), ARGS_LIMIT); |
| 54 | +} |
| 55 | + |
| 56 | +// A trace tool result is a RAW value: a structured object ({success,error,...}), |
| 57 | +// a string, or the recorder's large-result marker { _truncated, length, head }. |
| 58 | +function renderResult(result) { |
| 59 | + if (result == null) return { text: '(no result recorded)', failed: false }; |
| 60 | + if (typeof result === 'object' && result._truncated) { |
| 61 | + return { |
| 62 | + text: `${truncate(oneLine(String(result.head ?? '')), RESULT_LIMIT)} [recorder-truncated, ${humanSize(result.length || 0)} total]`, |
| 63 | + failed: false, |
| 64 | + }; |
| 65 | + } |
| 66 | + const failed = typeof result === 'object' ? (result.success === false || !!result.error) : false; |
| 67 | + const s = typeof result === 'string' ? result : JSON.stringify(result); |
| 68 | + return { text: truncate(oneLine(s), RESULT_LIMIT), failed }; |
| 69 | +} |
| 70 | + |
| 71 | +export function tracesToMarkdown(runsWithEvents, { title = 'WebBrain Conversation — tool chain' } = {}) { |
| 72 | + const runs = Array.isArray(runsWithEvents) ? runsWithEvents : []; |
| 73 | + let md = `# ${title}\n\n`; |
| 74 | + let turnCount = 0; |
| 75 | + let toolCount = 0; |
| 76 | + |
| 77 | + for (const entry of runs) { |
| 78 | + if (!entry || !entry.run) continue; |
| 79 | + turnCount += 1; |
| 80 | + const run = entry.run; |
| 81 | + const user = oneLine(run.userMessage || ''); |
| 82 | + const meta = [run.model, run.status].filter(Boolean).join(' · '); |
| 83 | + md += `## Turn ${turnCount}${user ? ` — ${user}` : ''}\n`; |
| 84 | + if (meta) md += `_${meta}_\n`; |
| 85 | + md += '\n'; |
| 86 | + |
| 87 | + const events = Array.isArray(entry.events) ? [...entry.events].sort((a, b) => (a?.seq || 0) - (b?.seq || 0)) : []; |
| 88 | + for (const ev of events) { |
| 89 | + const d = (ev && ev.data) || {}; |
| 90 | + if (ev.kind === 'llm_response') { |
| 91 | + const content = String(d.content || '').trim(); |
| 92 | + if (!content) continue; |
| 93 | + // Plan-before-Act runs record the planner call with phase:'planner'; keep |
| 94 | + // it (derails often start in the plan) but label it and preserve its shape. |
| 95 | + if (d.phase === 'planner') { |
| 96 | + md += `**Planner:**\n${fencedBlock(content)}\n`; |
| 97 | + } else { |
| 98 | + md += `**WebBrain:** ${oneLine(content)}\n`; |
| 99 | + } |
| 100 | + } else if (ev.kind === 'tool') { |
| 101 | + toolCount += 1; |
| 102 | + const { text, failed } = renderResult(d.result); |
| 103 | + md += `- 🔧 \`${d.name || 'tool'}\`(${stringifyArgs(d.args)}) → ${failed ? '✗ ' : ''}${text}\n`; |
| 104 | + } else if (ev.kind === 'error') { |
| 105 | + md += `- ⚠️ error${d.phase ? ` (${d.phase})` : ''}: ${oneLine(d.message || '')}\n`; |
| 106 | + } |
| 107 | + // screenshot / note / vision_sub_call intentionally omitted — see FOOTER. |
| 108 | + } |
| 109 | + md += '\n'; |
| 110 | + } |
| 111 | + |
| 112 | + md += `${FOOTER}\n`; |
| 113 | + return { markdown: md, turnCount, toolCount }; |
| 114 | +} |
0 commit comments