@cat-factory/app 0.6.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,128 +1,128 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { parseOutputOutline, sliceSource } from '~/utils/agentOutput'
3
-
4
- describe('parseOutputOutline', () => {
5
- it('splits on headings and builds a ToC', () => {
6
- const out = parseOutputOutline(
7
- ['# Overview', 'intro text', '', '## Findings', '- a', '- b'].join('\n'),
8
- )
9
- expect(out.hasToc).toBe(true)
10
- expect(out.minDepth).toBe(1)
11
- expect(out.sections.map((s) => s.title)).toEqual(['Overview', 'Findings'])
12
- expect(out.sections[0]!.depth).toBe(1)
13
- expect(out.sections[1]!.depth).toBe(2)
14
- const findings = out.sections[1]!.bodyHtml
15
- // top-level blocks now carry data-src-* anchors, so match the open tag loosely
16
- expect(findings).toContain('<ul')
17
- expect(findings).toContain('<li>a</li>')
18
- })
19
-
20
- it('keeps text before the first heading as an untitled preamble (no ToC entry)', () => {
21
- const out = parseOutputOutline('loose intro\n\n## Section')
22
- expect(out.sections[0]!.title).toBe('')
23
- expect(out.sections[0]!.depth).toBe(0)
24
- expect(out.sections[0]!.bodyHtml).toContain('loose intro')
25
- expect(out.sections.filter((s) => s.depth > 0)).toHaveLength(1)
26
- })
27
-
28
- it('reports no ToC when there are no headings', () => {
29
- const out = parseOutputOutline('just a paragraph of prose')
30
- expect(out.hasToc).toBe(false)
31
- expect(out.sections).toHaveLength(1)
32
- })
33
-
34
- it('gives every section a unique id even with duplicate titles', () => {
35
- const out = parseOutputOutline('## Risks\na\n## Risks\nb')
36
- const ids = out.sections.map((s) => s.id)
37
- expect(new Set(ids).size).toBe(ids.length)
38
- })
39
-
40
- it('captures fenced code verbatim without treating its lines as headings', () => {
41
- const out = parseOutputOutline('## Code\n```ts\nconst x = 1\n# not a heading\n```')
42
- expect(out.sections).toHaveLength(1)
43
- const body = out.sections[0]!.bodyHtml
44
- expect(body).toContain('<pre>')
45
- expect(body).toContain('# not a heading')
46
- })
47
-
48
- it('escapes raw HTML rather than injecting it (html: false)', () => {
49
- const out = parseOutputOutline('## S\n<img src=x onerror=alert(1)>')
50
- const body = out.sections[0]!.bodyHtml
51
- expect(body).not.toContain('<img')
52
- expect(body).toContain('&lt;img')
53
- })
54
-
55
- it('renders inline marks and decorates links to open safely in a new tab', () => {
56
- const out = parseOutputOutline('## S\nsee **bold** and [site](https://example.com)')
57
- const body = out.sections[0]!.bodyHtml
58
- expect(body).toContain('<strong>bold</strong>')
59
- expect(body).toContain('href="https://example.com"')
60
- expect(body).toContain('target="_blank"')
61
- expect(body).toContain('rel="noopener noreferrer"')
62
- })
63
-
64
- it('does not create a link for javascript: URLs (markdown-it validateLink)', () => {
65
- // validateLink rejects the scheme, so it stays inert plain text — never an <a>.
66
- const out = parseOutputOutline('## S\n[x](javascript:alert(1))')
67
- const body = out.sections[0]!.bodyHtml
68
- expect(body).not.toContain('<a ')
69
- expect(body).not.toContain('href')
70
- })
71
-
72
- it('tolerates empty / nullish input', () => {
73
- expect(parseOutputOutline('').sections).toHaveLength(0)
74
- expect(parseOutputOutline(undefined as unknown as string).sections).toHaveLength(0)
75
- })
76
- })
77
-
78
- describe('sliceSource', () => {
79
- it('returns the verbatim line range (0-based, end-exclusive)', () => {
80
- const text = ['line0', 'line1', 'line2', 'line3'].join('\n')
81
- expect(sliceSource(text, 1, 3)).toBe('line1\nline2')
82
- expect(sliceSource(text, 0, 1)).toBe('line0')
83
- })
84
-
85
- it('tolerates nullish input', () => {
86
- expect(sliceSource(undefined as unknown as string, 0, 1)).toBe('')
87
- })
88
- })
89
-
90
- describe('source-line stamping (approval-mode block anchors)', () => {
91
- // Find a rendered block by its text and round-trip its `data-src-*` range back
92
- // through `sliceSource` against the ORIGINAL output — this is the contract the
93
- // approval reader relies on to quote the agent's own markdown back to it.
94
- const blockFor = (output: string, contains: string) => {
95
- const { sections } = parseOutputOutline(output)
96
- const host = document.createElement('div')
97
- host.innerHTML = sections.map((s) => s.bodyHtml).join('\n')
98
- const el = Array.from(host.querySelectorAll('[data-src-start]')).find((e) =>
99
- (e.textContent ?? '').includes(contains),
100
- )
101
- if (!el) throw new Error(`no source-stamped block containing "${contains}"`)
102
- return {
103
- start: Number(el.getAttribute('data-src-start')),
104
- end: Number(el.getAttribute('data-src-end')),
105
- }
106
- }
107
-
108
- it('round-trips a paragraph block to its original source lines', () => {
109
- const output = ['## Summary', '', 'First paragraph.', '', 'Second paragraph here.'].join('\n')
110
- const { start, end } = blockFor(output, 'First paragraph.')
111
- expect(sliceSource(output, start, end)).toBe('First paragraph.')
112
- })
113
-
114
- it('round-trips a multi-line fenced code block verbatim', () => {
115
- const code = ['```ts', 'const x = 1', 'const y = 2', '```'].join('\n')
116
- const output = ['## Code', '', code].join('\n')
117
- const { start, end } = blockFor(output, 'const x = 1')
118
- expect(sliceSource(output, start, end)).toBe(code)
119
- })
120
-
121
- it('stamps top-level blocks only (a comment targets a whole block, not a nested item)', () => {
122
- const output = ['Intro paragraph.', '', '- item one', '- item two'].join('\n')
123
- const { start, end } = blockFor(output, 'item one')
124
- // The whole list is the top-level block, so the slice spans both items.
125
- expect(sliceSource(output, start, end)).toContain('- item one')
126
- expect(sliceSource(output, start, end)).toContain('- item two')
127
- })
128
- })
1
+ import { describe, it, expect } from 'vitest'
2
+ import { parseOutputOutline, sliceSource } from '~/utils/agentOutput'
3
+
4
+ describe('parseOutputOutline', () => {
5
+ it('splits on headings and builds a ToC', () => {
6
+ const out = parseOutputOutline(
7
+ ['# Overview', 'intro text', '', '## Findings', '- a', '- b'].join('\n'),
8
+ )
9
+ expect(out.hasToc).toBe(true)
10
+ expect(out.minDepth).toBe(1)
11
+ expect(out.sections.map((s) => s.title)).toEqual(['Overview', 'Findings'])
12
+ expect(out.sections[0]!.depth).toBe(1)
13
+ expect(out.sections[1]!.depth).toBe(2)
14
+ const findings = out.sections[1]!.bodyHtml
15
+ // top-level blocks now carry data-src-* anchors, so match the open tag loosely
16
+ expect(findings).toContain('<ul')
17
+ expect(findings).toContain('<li>a</li>')
18
+ })
19
+
20
+ it('keeps text before the first heading as an untitled preamble (no ToC entry)', () => {
21
+ const out = parseOutputOutline('loose intro\n\n## Section')
22
+ expect(out.sections[0]!.title).toBe('')
23
+ expect(out.sections[0]!.depth).toBe(0)
24
+ expect(out.sections[0]!.bodyHtml).toContain('loose intro')
25
+ expect(out.sections.filter((s) => s.depth > 0)).toHaveLength(1)
26
+ })
27
+
28
+ it('reports no ToC when there are no headings', () => {
29
+ const out = parseOutputOutline('just a paragraph of prose')
30
+ expect(out.hasToc).toBe(false)
31
+ expect(out.sections).toHaveLength(1)
32
+ })
33
+
34
+ it('gives every section a unique id even with duplicate titles', () => {
35
+ const out = parseOutputOutline('## Risks\na\n## Risks\nb')
36
+ const ids = out.sections.map((s) => s.id)
37
+ expect(new Set(ids).size).toBe(ids.length)
38
+ })
39
+
40
+ it('captures fenced code verbatim without treating its lines as headings', () => {
41
+ const out = parseOutputOutline('## Code\n```ts\nconst x = 1\n# not a heading\n```')
42
+ expect(out.sections).toHaveLength(1)
43
+ const body = out.sections[0]!.bodyHtml
44
+ expect(body).toContain('<pre>')
45
+ expect(body).toContain('# not a heading')
46
+ })
47
+
48
+ it('escapes raw HTML rather than injecting it (html: false)', () => {
49
+ const out = parseOutputOutline('## S\n<img src=x onerror=alert(1)>')
50
+ const body = out.sections[0]!.bodyHtml
51
+ expect(body).not.toContain('<img')
52
+ expect(body).toContain('&lt;img')
53
+ })
54
+
55
+ it('renders inline marks and decorates links to open safely in a new tab', () => {
56
+ const out = parseOutputOutline('## S\nsee **bold** and [site](https://example.com)')
57
+ const body = out.sections[0]!.bodyHtml
58
+ expect(body).toContain('<strong>bold</strong>')
59
+ expect(body).toContain('href="https://example.com"')
60
+ expect(body).toContain('target="_blank"')
61
+ expect(body).toContain('rel="noopener noreferrer"')
62
+ })
63
+
64
+ it('does not create a link for javascript: URLs (markdown-it validateLink)', () => {
65
+ // validateLink rejects the scheme, so it stays inert plain text — never an <a>.
66
+ const out = parseOutputOutline('## S\n[x](javascript:alert(1))')
67
+ const body = out.sections[0]!.bodyHtml
68
+ expect(body).not.toContain('<a ')
69
+ expect(body).not.toContain('href')
70
+ })
71
+
72
+ it('tolerates empty / nullish input', () => {
73
+ expect(parseOutputOutline('').sections).toHaveLength(0)
74
+ expect(parseOutputOutline(undefined as unknown as string).sections).toHaveLength(0)
75
+ })
76
+ })
77
+
78
+ describe('sliceSource', () => {
79
+ it('returns the verbatim line range (0-based, end-exclusive)', () => {
80
+ const text = ['line0', 'line1', 'line2', 'line3'].join('\n')
81
+ expect(sliceSource(text, 1, 3)).toBe('line1\nline2')
82
+ expect(sliceSource(text, 0, 1)).toBe('line0')
83
+ })
84
+
85
+ it('tolerates nullish input', () => {
86
+ expect(sliceSource(undefined as unknown as string, 0, 1)).toBe('')
87
+ })
88
+ })
89
+
90
+ describe('source-line stamping (approval-mode block anchors)', () => {
91
+ // Find a rendered block by its text and round-trip its `data-src-*` range back
92
+ // through `sliceSource` against the ORIGINAL output — this is the contract the
93
+ // approval reader relies on to quote the agent's own markdown back to it.
94
+ const blockFor = (output: string, contains: string) => {
95
+ const { sections } = parseOutputOutline(output)
96
+ const host = document.createElement('div')
97
+ host.innerHTML = sections.map((s) => s.bodyHtml).join('\n')
98
+ const el = Array.from(host.querySelectorAll('[data-src-start]')).find((e) =>
99
+ (e.textContent ?? '').includes(contains),
100
+ )
101
+ if (!el) throw new Error(`no source-stamped block containing "${contains}"`)
102
+ return {
103
+ start: Number(el.getAttribute('data-src-start')),
104
+ end: Number(el.getAttribute('data-src-end')),
105
+ }
106
+ }
107
+
108
+ it('round-trips a paragraph block to its original source lines', () => {
109
+ const output = ['## Summary', '', 'First paragraph.', '', 'Second paragraph here.'].join('\n')
110
+ const { start, end } = blockFor(output, 'First paragraph.')
111
+ expect(sliceSource(output, start, end)).toBe('First paragraph.')
112
+ })
113
+
114
+ it('round-trips a multi-line fenced code block verbatim', () => {
115
+ const code = ['```ts', 'const x = 1', 'const y = 2', '```'].join('\n')
116
+ const output = ['## Code', '', code].join('\n')
117
+ const { start, end } = blockFor(output, 'const x = 1')
118
+ expect(sliceSource(output, start, end)).toBe(code)
119
+ })
120
+
121
+ it('stamps top-level blocks only (a comment targets a whole block, not a nested item)', () => {
122
+ const output = ['Intro paragraph.', '', '- item one', '- item two'].join('\n')
123
+ const { start, end } = blockFor(output, 'item one')
124
+ // The whole list is the top-level block, so the slice spans both items.
125
+ expect(sliceSource(output, start, end)).toContain('- item one')
126
+ expect(sliceSource(output, start, end)).toContain('- item two')
127
+ })
128
+ })
@@ -1,173 +1,173 @@
1
- // Turn an agent's prose output (markdown) into a heading-delimited outline so it
2
- // can be read in the dedicated reader overlay: a navigable table of contents on
3
- // one side, collapsible sections on the other.
4
- //
5
- // Markdown → HTML is done by `markdown-it` (a mature CommonMark parser) with
6
- // `html: false`, so it is secure by default: any raw HTML in the agent's output
7
- // is escaped rather than injected, and its `validateLink` blocks dangerous URL
8
- // schemes — no separate sanitizer needed for the LLM-generated text we feed it.
9
- // This module only adds the one thing markdown-it doesn't: SEGMENTATION — split
10
- // the rendered document at each heading into sections we can collapse
11
- // independently and link from a ToC. That split is done over the parsed DOM, so
12
- // it is independent of markdown-it's token internals.
13
- import MarkdownIt from 'markdown-it'
14
-
15
- /**
16
- * Stamp every TOP-LEVEL block element with its source line range
17
- * (`data-src-start`/`data-src-end`, 0-based, end-exclusive — straight from
18
- * markdown-it's `token.map`). The approval-mode reader uses these to let a human
19
- * comment on a specific block and quote that block's verbatim raw markdown back to
20
- * the agent on a "request changes" re-run. Only top-level blocks are tagged (depth
21
- * tracked over the flat token stream) so a comment targets a whole paragraph/list/
22
- * heading rather than a nested fragment.
23
- */
24
- function sourceLinePlugin(md: MarkdownIt): void {
25
- md.core.ruler.push('source_lines', (state) => {
26
- let depth = 0
27
- for (const token of state.tokens) {
28
- const atTopLevel = depth === 0
29
- // Annotate a top-level block's opening token (nesting 1) or a self-contained
30
- // block token (nesting 0, e.g. fence/hr/code_block/html_block).
31
- if (atTopLevel && token.block && token.type !== 'inline' && token.map && token.nesting >= 0) {
32
- token.attrSet('data-src-start', String(token.map[0]))
33
- token.attrSet('data-src-end', String(token.map[1]))
34
- }
35
- if (token.nesting === 1) depth++
36
- else if (token.nesting === -1) depth--
37
- }
38
- return true
39
- })
40
- }
41
-
42
- /**
43
- * The verbatim raw-markdown source of a block, given the original output text and a
44
- * 0-based, end-exclusive line range (as captured from `data-src-start/end`).
45
- */
46
- export function sliceSource(output: string, start: number, end: number): string {
47
- return (output ?? '').split('\n').slice(start, end).join('\n')
48
- }
49
-
50
- /** One heading-delimited section. `depth` 0 / empty `title` is the preamble that
51
- * precedes the first heading (rendered, but never shown in the ToC). */
52
- export interface OutputSection {
53
- id: string
54
- depth: number
55
- /** Plain-text heading, for the ToC. */
56
- title: string
57
- /** Inline-rendered heading HTML (code/bold/… preserved), for the section header. */
58
- titleHtml: string
59
- /** HTML of everything under this heading up to the next one. */
60
- bodyHtml: string
61
- }
62
-
63
- export interface OutputOutline {
64
- sections: OutputSection[]
65
- /** True once there is at least one real heading worth a ToC entry. */
66
- hasToc: boolean
67
- /** Shallowest heading depth present, so the ToC can indent relative to it. */
68
- minDepth: number
69
- }
70
-
71
- const md = new MarkdownIt({
72
- html: false, // secure by default: escape raw HTML rather than render it
73
- linkify: true, // turn bare URLs into links
74
- breaks: true, // single newlines → <br>, matching how agents lay out prose
75
- typographer: true,
76
- }).use(sourceLinePlugin)
77
-
78
- const HEADINGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6'])
79
- const LINK_CLASS = 'text-indigo-300 underline decoration-indigo-500/40 hover:text-indigo-200'
80
-
81
- function slugify(title: string, used: Set<string>): string {
82
- const base =
83
- title
84
- .toLowerCase()
85
- .replace(/[^a-z0-9]+/g, '-')
86
- .replace(/^-+|-+$/g, '') || 'section'
87
- let slug = base
88
- let n = 2
89
- while (used.has(slug)) slug = `${base}-${n++}`
90
- used.add(slug)
91
- return slug
92
- }
93
-
94
- /** Make every link open safely in a new tab and pick up the reader's link style. */
95
- function decorateLinks(root: HTMLElement): void {
96
- for (const a of Array.from(root.querySelectorAll('a'))) {
97
- a.setAttribute('target', '_blank')
98
- a.setAttribute('rel', 'noopener noreferrer')
99
- a.setAttribute('class', LINK_CLASS)
100
- }
101
- }
102
-
103
- /** Build the heading-based outline for an agent's prose output. */
104
- export function parseOutputOutline(text: string): OutputOutline {
105
- const source = text ?? ''
106
- if (!source.trim()) return { sections: [], hasToc: false, minDepth: 1 }
107
-
108
- const html = md.render(source)
109
-
110
- // No DOM (SSR / non-browser): fall back to one un-segmented section. The reader
111
- // overlay is client-only, so this path is effectively dead outside any runtime
112
- // without `document`.
113
- if (typeof document === 'undefined') {
114
- return {
115
- sections: [{ id: 'overview', depth: 0, title: '', titleHtml: '', bodyHtml: html }],
116
- hasToc: false,
117
- minDepth: 1,
118
- }
119
- }
120
-
121
- const root = document.createElement('div')
122
- root.innerHTML = html
123
- decorateLinks(root)
124
-
125
- const used = new Set<string>()
126
- const sections: OutputSection[] = []
127
- let current: OutputSection | null = null
128
- let body: HTMLElement | null = null
129
-
130
- const flush = () => {
131
- if (current && body) current.bodyHtml = body.innerHTML.trim()
132
- if (current) sections.push(current)
133
- }
134
-
135
- for (const node of Array.from(root.childNodes)) {
136
- const el = node.nodeType === 1 ? (node as HTMLElement) : null
137
- if (el && HEADINGS.has(el.tagName)) {
138
- flush()
139
- const title = (el.textContent ?? '').trim()
140
- current = {
141
- id: slugify(title, used),
142
- depth: Number(el.tagName[1]),
143
- title,
144
- titleHtml: el.innerHTML,
145
- bodyHtml: '',
146
- }
147
- body = document.createElement('div')
148
- } else {
149
- if (!current) {
150
- // Content before the first heading → untitled preamble section.
151
- current = {
152
- id: slugify('overview', used),
153
- depth: 0,
154
- title: '',
155
- titleHtml: '',
156
- bodyHtml: '',
157
- }
158
- body = document.createElement('div')
159
- }
160
- body!.appendChild(node.cloneNode(true))
161
- }
162
- }
163
- flush()
164
-
165
- // A preamble that turned out to hold nothing renderable is noise — drop it.
166
- const cleaned = sections.filter((s) => s.title || s.bodyHtml)
167
- const headed = cleaned.filter((s) => s.depth > 0)
168
- return {
169
- sections: cleaned,
170
- hasToc: headed.length > 0,
171
- minDepth: headed.length ? Math.min(...headed.map((s) => s.depth)) : 1,
172
- }
173
- }
1
+ // Turn an agent's prose output (markdown) into a heading-delimited outline so it
2
+ // can be read in the dedicated reader overlay: a navigable table of contents on
3
+ // one side, collapsible sections on the other.
4
+ //
5
+ // Markdown → HTML is done by `markdown-it` (a mature CommonMark parser) with
6
+ // `html: false`, so it is secure by default: any raw HTML in the agent's output
7
+ // is escaped rather than injected, and its `validateLink` blocks dangerous URL
8
+ // schemes — no separate sanitizer needed for the LLM-generated text we feed it.
9
+ // This module only adds the one thing markdown-it doesn't: SEGMENTATION — split
10
+ // the rendered document at each heading into sections we can collapse
11
+ // independently and link from a ToC. That split is done over the parsed DOM, so
12
+ // it is independent of markdown-it's token internals.
13
+ import MarkdownIt from 'markdown-it'
14
+
15
+ /**
16
+ * Stamp every TOP-LEVEL block element with its source line range
17
+ * (`data-src-start`/`data-src-end`, 0-based, end-exclusive — straight from
18
+ * markdown-it's `token.map`). The approval-mode reader uses these to let a human
19
+ * comment on a specific block and quote that block's verbatim raw markdown back to
20
+ * the agent on a "request changes" re-run. Only top-level blocks are tagged (depth
21
+ * tracked over the flat token stream) so a comment targets a whole paragraph/list/
22
+ * heading rather than a nested fragment.
23
+ */
24
+ function sourceLinePlugin(md: MarkdownIt): void {
25
+ md.core.ruler.push('source_lines', (state) => {
26
+ let depth = 0
27
+ for (const token of state.tokens) {
28
+ const atTopLevel = depth === 0
29
+ // Annotate a top-level block's opening token (nesting 1) or a self-contained
30
+ // block token (nesting 0, e.g. fence/hr/code_block/html_block).
31
+ if (atTopLevel && token.block && token.type !== 'inline' && token.map && token.nesting >= 0) {
32
+ token.attrSet('data-src-start', String(token.map[0]))
33
+ token.attrSet('data-src-end', String(token.map[1]))
34
+ }
35
+ if (token.nesting === 1) depth++
36
+ else if (token.nesting === -1) depth--
37
+ }
38
+ return true
39
+ })
40
+ }
41
+
42
+ /**
43
+ * The verbatim raw-markdown source of a block, given the original output text and a
44
+ * 0-based, end-exclusive line range (as captured from `data-src-start/end`).
45
+ */
46
+ export function sliceSource(output: string, start: number, end: number): string {
47
+ return (output ?? '').split('\n').slice(start, end).join('\n')
48
+ }
49
+
50
+ /** One heading-delimited section. `depth` 0 / empty `title` is the preamble that
51
+ * precedes the first heading (rendered, but never shown in the ToC). */
52
+ export interface OutputSection {
53
+ id: string
54
+ depth: number
55
+ /** Plain-text heading, for the ToC. */
56
+ title: string
57
+ /** Inline-rendered heading HTML (code/bold/… preserved), for the section header. */
58
+ titleHtml: string
59
+ /** HTML of everything under this heading up to the next one. */
60
+ bodyHtml: string
61
+ }
62
+
63
+ export interface OutputOutline {
64
+ sections: OutputSection[]
65
+ /** True once there is at least one real heading worth a ToC entry. */
66
+ hasToc: boolean
67
+ /** Shallowest heading depth present, so the ToC can indent relative to it. */
68
+ minDepth: number
69
+ }
70
+
71
+ const md = new MarkdownIt({
72
+ html: false, // secure by default: escape raw HTML rather than render it
73
+ linkify: true, // turn bare URLs into links
74
+ breaks: true, // single newlines → <br>, matching how agents lay out prose
75
+ typographer: true,
76
+ }).use(sourceLinePlugin)
77
+
78
+ const HEADINGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6'])
79
+ const LINK_CLASS = 'text-indigo-300 underline decoration-indigo-500/40 hover:text-indigo-200'
80
+
81
+ function slugify(title: string, used: Set<string>): string {
82
+ const base =
83
+ title
84
+ .toLowerCase()
85
+ .replace(/[^a-z0-9]+/g, '-')
86
+ .replace(/^-+|-+$/g, '') || 'section'
87
+ let slug = base
88
+ let n = 2
89
+ while (used.has(slug)) slug = `${base}-${n++}`
90
+ used.add(slug)
91
+ return slug
92
+ }
93
+
94
+ /** Make every link open safely in a new tab and pick up the reader's link style. */
95
+ function decorateLinks(root: HTMLElement): void {
96
+ for (const a of Array.from(root.querySelectorAll('a'))) {
97
+ a.setAttribute('target', '_blank')
98
+ a.setAttribute('rel', 'noopener noreferrer')
99
+ a.setAttribute('class', LINK_CLASS)
100
+ }
101
+ }
102
+
103
+ /** Build the heading-based outline for an agent's prose output. */
104
+ export function parseOutputOutline(text: string): OutputOutline {
105
+ const source = text ?? ''
106
+ if (!source.trim()) return { sections: [], hasToc: false, minDepth: 1 }
107
+
108
+ const html = md.render(source)
109
+
110
+ // No DOM (SSR / non-browser): fall back to one un-segmented section. The reader
111
+ // overlay is client-only, so this path is effectively dead outside any runtime
112
+ // without `document`.
113
+ if (typeof document === 'undefined') {
114
+ return {
115
+ sections: [{ id: 'overview', depth: 0, title: '', titleHtml: '', bodyHtml: html }],
116
+ hasToc: false,
117
+ minDepth: 1,
118
+ }
119
+ }
120
+
121
+ const root = document.createElement('div')
122
+ root.innerHTML = html
123
+ decorateLinks(root)
124
+
125
+ const used = new Set<string>()
126
+ const sections: OutputSection[] = []
127
+ let current: OutputSection | null = null
128
+ let body: HTMLElement | null = null
129
+
130
+ const flush = () => {
131
+ if (current && body) current.bodyHtml = body.innerHTML.trim()
132
+ if (current) sections.push(current)
133
+ }
134
+
135
+ for (const node of Array.from(root.childNodes)) {
136
+ const el = node.nodeType === 1 ? (node as HTMLElement) : null
137
+ if (el && HEADINGS.has(el.tagName)) {
138
+ flush()
139
+ const title = (el.textContent ?? '').trim()
140
+ current = {
141
+ id: slugify(title, used),
142
+ depth: Number(el.tagName[1]),
143
+ title,
144
+ titleHtml: el.innerHTML,
145
+ bodyHtml: '',
146
+ }
147
+ body = document.createElement('div')
148
+ } else {
149
+ if (!current) {
150
+ // Content before the first heading → untitled preamble section.
151
+ current = {
152
+ id: slugify('overview', used),
153
+ depth: 0,
154
+ title: '',
155
+ titleHtml: '',
156
+ bodyHtml: '',
157
+ }
158
+ body = document.createElement('div')
159
+ }
160
+ body!.appendChild(node.cloneNode(true))
161
+ }
162
+ }
163
+ flush()
164
+
165
+ // A preamble that turned out to hold nothing renderable is noise — drop it.
166
+ const cleaned = sections.filter((s) => s.title || s.bodyHtml)
167
+ const headed = cleaned.filter((s) => s.depth > 0)
168
+ return {
169
+ sections: cleaned,
170
+ hasToc: headed.length > 0,
171
+ minDepth: headed.length ? Math.min(...headed.map((s) => s.depth)) : 1,
172
+ }
173
+ }