Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
28 changes: 24 additions & 4 deletions src/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,25 +99,45 @@ 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,
result?: NarrationResult,
): 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
Expand Down
14 changes: 14 additions & 0 deletions src/renderers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
Loading