@cat-factory/app 0.6.0 → 0.7.3

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.
Files changed (33) hide show
  1. package/LICENSE +21 -21
  2. package/app/components/board/ContextPicker.vue +367 -367
  3. package/app/components/gates/GateResultView.vue +90 -12
  4. package/app/components/layout/SideBar.vue +11 -0
  5. package/app/components/observability/StepMetricsBar.vue +102 -102
  6. package/app/components/observability/StepModelActivity.vue +49 -0
  7. package/app/components/panels/ObservabilityPanel.vue +1 -1
  8. package/app/components/panels/StepMetadataCard.vue +4 -16
  9. package/app/components/panels/StepRunMeta.vue +105 -0
  10. package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -178
  11. package/app/components/panels/inspector/TaskRunSettings.vue +77 -0
  12. package/app/components/recurring/RecurrenceEditor.vue +124 -124
  13. package/app/components/settings/IssueTrackerWritebackPanel.vue +103 -0
  14. package/app/components/testing/TestReportWindow.vue +17 -8
  15. package/app/composables/useBlockQueries.ts +154 -154
  16. package/app/composables/useContextLinking.ts +65 -65
  17. package/app/composables/useFrameResize.ts +54 -54
  18. package/app/pages/index.vue +2 -0
  19. package/app/stores/documents.ts +176 -176
  20. package/app/stores/services.ts +87 -87
  21. package/app/stores/tracker.ts +39 -27
  22. package/app/stores/ui.ts +12 -0
  23. package/app/types/documents.ts +104 -104
  24. package/app/types/domain.ts +5 -1
  25. package/app/types/execution.ts +18 -0
  26. package/app/types/github.ts +173 -173
  27. package/app/types/services.ts +27 -27
  28. package/app/types/tasks.ts +82 -82
  29. package/app/types/tracker.ts +27 -18
  30. package/app/utils/agentOutput.spec.ts +128 -128
  31. package/app/utils/agentOutput.ts +173 -173
  32. package/app/utils/observability.ts +52 -52
  33. package/package.json +6 -1
@@ -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
+ }
@@ -1,52 +1,52 @@
1
- // Formatting + derivation helpers for the LLM observability surfaces (inline step
2
- // rollups + the drill-down panel). Kept here so the components stay declarative and
3
- // the number-crunching is unit-testable.
4
-
5
- import type { StepMetrics } from '~/types/execution'
6
-
7
- /** Compact token count: 1234 → "1.2k", 980 → "980", 2_500_000 → "2.5M". */
8
- export function formatTokens(n: number): string {
9
- if (n < 1000) return `${n}`
10
- if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
11
- return `${(n / 1_000_000).toFixed(1)}M`
12
- }
13
-
14
- /** Compact duration: 850 → "850ms", 1500 → "1.5s", 90_000 → "1m 30s". */
15
- export function formatMs(ms: number): string {
16
- if (ms < 1000) return `${Math.round(ms)}ms`
17
- const totalSec = ms / 1000
18
- if (totalSec < 60) return `${totalSec.toFixed(totalSec < 10 ? 1 : 0)}s`
19
- const m = Math.floor(totalSec / 60)
20
- const sec = Math.round(totalSec % 60)
21
- return sec ? `${m}m ${sec}s` : `${m}m`
22
- }
23
-
24
- /** A ratio (0..1) as a whole-number percentage. */
25
- export function pct(ratio: number): number {
26
- return Math.round(ratio * 100)
27
- }
28
-
29
- /**
30
- * Output-limit headroom for a step's rollup: the fraction of the output ceiling the
31
- * closest call consumed (0..1), or null when the ceiling is unknown. 1 (or any
32
- * truncated call) means a call hit the limit and was cut short.
33
- */
34
- export function headroomRatio(
35
- m: Pick<StepMetrics, 'peakCompletionTokens' | 'maxOutputTokens'>,
36
- ): number | null {
37
- if (m.maxOutputTokens == null || m.maxOutputTokens <= 0) return null
38
- return Math.min(1, m.peakCompletionTokens / m.maxOutputTokens)
39
- }
40
-
41
- /** Share of a step's latency spent in transport/proxy overhead (0..1), or null. */
42
- export function transportRatio(m: Pick<StepMetrics, 'upstreamMs' | 'overheadMs'>): number | null {
43
- const total = m.upstreamMs + m.overheadMs
44
- return total > 0 ? m.overheadMs / total : null
45
- }
46
-
47
- /** Tailwind text/bg colour for an output-headroom level (green → amber → red). */
48
- export function headroomColor(ratio: number | null, truncated: boolean): string {
49
- if (truncated || (ratio != null && ratio >= 0.98)) return 'text-rose-400'
50
- if (ratio != null && ratio >= 0.8) return 'text-amber-400'
51
- return 'text-emerald-400'
52
- }
1
+ // Formatting + derivation helpers for the LLM observability surfaces (inline step
2
+ // rollups + the drill-down panel). Kept here so the components stay declarative and
3
+ // the number-crunching is unit-testable.
4
+
5
+ import type { StepMetrics } from '~/types/execution'
6
+
7
+ /** Compact token count: 1234 → "1.2k", 980 → "980", 2_500_000 → "2.5M". */
8
+ export function formatTokens(n: number): string {
9
+ if (n < 1000) return `${n}`
10
+ if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
11
+ return `${(n / 1_000_000).toFixed(1)}M`
12
+ }
13
+
14
+ /** Compact duration: 850 → "850ms", 1500 → "1.5s", 90_000 → "1m 30s". */
15
+ export function formatMs(ms: number): string {
16
+ if (ms < 1000) return `${Math.round(ms)}ms`
17
+ const totalSec = ms / 1000
18
+ if (totalSec < 60) return `${totalSec.toFixed(totalSec < 10 ? 1 : 0)}s`
19
+ const m = Math.floor(totalSec / 60)
20
+ const sec = Math.round(totalSec % 60)
21
+ return sec ? `${m}m ${sec}s` : `${m}m`
22
+ }
23
+
24
+ /** A ratio (0..1) as a whole-number percentage. */
25
+ export function pct(ratio: number): number {
26
+ return Math.round(ratio * 100)
27
+ }
28
+
29
+ /**
30
+ * Output-limit headroom for a step's rollup: the fraction of the output ceiling the
31
+ * closest call consumed (0..1), or null when the ceiling is unknown. 1 (or any
32
+ * truncated call) means a call hit the limit and was cut short.
33
+ */
34
+ export function headroomRatio(
35
+ m: Pick<StepMetrics, 'peakCompletionTokens' | 'maxOutputTokens'>,
36
+ ): number | null {
37
+ if (m.maxOutputTokens == null || m.maxOutputTokens <= 0) return null
38
+ return Math.min(1, m.peakCompletionTokens / m.maxOutputTokens)
39
+ }
40
+
41
+ /** Share of a step's latency spent in transport/proxy overhead (0..1), or null. */
42
+ export function transportRatio(m: Pick<StepMetrics, 'upstreamMs' | 'overheadMs'>): number | null {
43
+ const total = m.upstreamMs + m.overheadMs
44
+ return total > 0 ? m.overheadMs / total : null
45
+ }
46
+
47
+ /** Tailwind text/bg colour for an output-headroom level (green → amber → red). */
48
+ export function headroomColor(ratio: number | null, truncated: boolean): string {
49
+ if (truncated || (ratio != null && ratio >= 0.98)) return 'text-rose-400'
50
+ if (ratio != null && ratio >= 0.8) return 'text-amber-400'
51
+ return 'text-emerald-400'
52
+ }
package/package.json CHANGED
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.6.0",
3
+ "version": "0.7.3",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/kibertoad/cat-factory.git",
7
+ "directory": "frontend/app"
8
+ },
4
9
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
10
  "files": [
6
11
  "app",