diff --git a/package.json b/package.json index 247e04e..0b9d91e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/run-capsule", - "version": "0.3.0", + "version": "0.4.0", "description": "Turn any agent run's trace into a shareable video — code/terminal/screen/conversation capsule animations, recorded headless and uploaded to a temp link. Secrets are redacted before upload. Consumer of @tangle-network/agent-eval/storyboard.", "type": "module", "license": "MIT", diff --git a/src/artifacts.ts b/src/artifacts.ts index 19ee29f..c2f9310 100644 --- a/src/artifacts.ts +++ b/src/artifacts.ts @@ -99,6 +99,26 @@ export interface NarrationResult { /** A short voiceover script derived from the run: the ask, the key beats, the * outcome. Kept terse — it's narration, not a transcript. When a verdict is * given, it closes on the gate result so the VO matches the scoreboard shot. */ +/** Make a string safe to SPEAK: strip code fences, `var = value` assignments, + * axis tags like "(X)", markdown punctuation and approximation tildes, then + * keep just the first sentence. Without this the TTS reads raw source — e.g. + * "H_wall = 60" becomes the meaningless "H is 60". Narration is prose, not code. */ +function speakable(s: string, maxLen = 150): string { + let t = s + .replace(/```[\s\S]*?```/g, ' ') // code blocks + .replace(/\b[A-Za-z_]\w*\s*=\s*-?\d[\d.]*\b/g, ' ') // var = number + .replace(/\(\s*[XYZ]\s*\)/gi, ' ') // axis tags (X) (Y) (Z) + .replace(/[`*_#>{}[\]|]/g, ' ') // markdown / code punctuation + .replace(/~/g, '') // approximation tilde + .replace(/\s+/g, ' ') + .trim() + const m = /^(.*?[.!?])(\s|$)/.exec(t) // first sentence only + if (m?.[1]) t = m[1] + if (t.length > maxLen) t = `${t.slice(0, maxLen).replace(/\s+\S*$/, '')}…` + return t.replace(/[.!?]+$/, '').trim() // drop trailing punctuation; templates add their own + +} + export function buildNarrationScript( spans: readonly Span[], title: string, @@ -106,18 +126,18 @@ export function buildNarrationScript( ): string { const ev = reduceToSemanticEvents(spans) const ask = ev.find((e) => e.kind === 'understood_task')?.summary - const reply = [...ev].reverse().find((e) => e.kind === 'agent_reply')?.summary const edits = ev.filter((e) => e.kind === 'edited_code').length const cmds = ev.filter((e) => e.kind === 'ran_command').length const fails = ev.filter((e) => e.kind === 'observed_failure').length - const parts: string[] = [`${title}.`] - if (ask) parts.push(`The task: ${ask}.`) + const parts: string[] = [`${speakable(title)}.`] + // The brief, cleaned to its first plain sentence — NOT the raw agent reply + // (which is source code and reads as gibberish through TTS). + if (ask) parts.push(`The task: ${speakable(ask)}.`) const did: string[] = [] if (edits) did.push(`${edits} code ${edits === 1 ? 'edit' : 'edits'}`) if (cmds) did.push(`${cmds} ${cmds === 1 ? 'command' : 'commands'}`) if (fails) did.push(`recovering from ${fails} ${fails === 1 ? 'failure' : 'failures'}`) if (did.length) parts.push(`The agent worked through ${did.join(', ')}.`) - if (reply) parts.push(reply) if (result) { const total = Object.keys(result.checks).length const passed = Object.values(result.checks).filter(Boolean).length diff --git a/src/renderers.test.ts b/src/renderers.test.ts index ee9c575..d84fc0d 100644 --- a/src/renderers.test.ts +++ b/src/renderers.test.ts @@ -274,6 +274,20 @@ describe('buildNarrationScript (closes on the verdict when scored)', () => { ] expect(buildNarrationScript(spans, 'House')).not.toMatch(/geometry gate/i) }) + + it('speaks clean prose — never raw code: no "H_wall = 60", no axis tags, drops the source reply', () => { + // Regression for the 3/10 VO: TTS was reading the brief's "(X)/(Y)" + the + // agent's raw .scad reply, so it said the meaningless "H is 60". + const spans: Span[] = [ + { spanId: 'b', runId: 'r', kind: 'llm', name: 'brief', model: 'm', messages: [{ role: 'user', content: 'Write OpenSCAD for a two-story house. Requirements: footprint 80 (X) by 60 (Y), H_wall = 60 units.' }], startedAt: 0, endedAt: 1, status: 'ok' } as Span, + { spanId: 'a', runId: 'r', kind: 'llm', name: 'reply', model: 'm', messages: [{ role: 'user', content: 'x' }], output: 'W=80; D=60; H_wall=60; difference(){ cube([W,D,H_wall]); }', startedAt: 2, endedAt: 3, status: 'ok' } as Span, + ] + const vo = buildNarrationScript(spans, 'Agent designs a house', { resolved: true, score: 1, checks: { a: true, b: true } }) + expect(vo).not.toMatch(/\([XYZ]\)|H_wall|W\s*=\s*80|=\s*60/) // no raw code / axis tags + expect(vo).not.toContain('difference(') // the source reply is dropped, not spoken + expect(vo).toContain('two-story house') // the cleaned brief survives + expect(vo).toContain('2 of 2 checks') // verdict still lands + }) }) describe('redactSpans (P0: never publish a live credential)', () => {