arkaos 3.22.0 → 3.24.0

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.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.22.0
1
+ 3.24.0
@@ -48,6 +48,13 @@ const links = [[{
48
48
  onSelect: () => {
49
49
  open.value = false
50
50
  }
51
+ }, {
52
+ label: 'Workflows',
53
+ icon: 'i-lucide-workflow',
54
+ to: '/workflows',
55
+ onSelect: () => {
56
+ open.value = false
57
+ }
51
58
  }, {
52
59
  label: 'Knowledge',
53
60
  icon: 'i-lucide-brain',
@@ -247,6 +247,17 @@ function onCloned(agentId: string) {
247
247
  navigateTo(`/agents/${agentId}`)
248
248
  }
249
249
 
250
+ // PR88a v3.23.0 — Compare with linked agent.
251
+ const compareWithOptions = computed(() =>
252
+ linkedAgentIds.value.map((aid) => ({
253
+ label: `Compare with ${aid}`,
254
+ icon: 'i-lucide-columns-2',
255
+ onSelect: () => navigateTo(
256
+ `/personas/compare-with-agent?persona=${personaId}&agent=${aid}`,
257
+ ),
258
+ })),
259
+ )
260
+
250
261
  // PR84c v3.9.0 — Auto-fill empty lists in one go.
251
262
  const autofilling = ref(false)
252
263
 
@@ -496,6 +507,18 @@ const vocabOptions = [
496
507
  :aria-label="favs.isPersonaFavorite(detail.id) ? 'Unfavorite' : 'Favorite'"
497
508
  @click="favs.toggle('personas', detail.id)"
498
509
  />
510
+ <UDropdownMenu
511
+ v-if="compareWithOptions.length > 0"
512
+ :items="compareWithOptions"
513
+ >
514
+ <UButton
515
+ label="Compare"
516
+ icon="i-lucide-columns-2"
517
+ variant="soft"
518
+ size="sm"
519
+ trailing-icon="i-lucide-chevron-down"
520
+ />
521
+ </UDropdownMenu>
499
522
  <UButton
500
523
  label="Clone to Agent"
501
524
  icon="i-lucide-copy-plus"
@@ -0,0 +1,218 @@
1
+ <script setup lang="ts">
2
+ // PR88a v3.23.0 — Compare a persona against one of its linked agents.
3
+ //
4
+ // Driven by `?persona=p&agent=a`. Useful to see how an agent diverged
5
+ // from the persona it was cloned from (or whose DNA inspired it).
6
+
7
+ const route = useRoute()
8
+ const { fetchApi } = useApi()
9
+
10
+ const personaId = computed(() => String(route.query.persona ?? ''))
11
+ const agentId = computed(() => String(route.query.agent ?? ''))
12
+
13
+ interface PersonaDetail {
14
+ id: string
15
+ name?: string
16
+ title?: string
17
+ source?: string
18
+ mbti?: string
19
+ disc?: { primary?: string, secondary?: string }
20
+ enneagram?: { type?: number, wing?: number }
21
+ big_five?: {
22
+ openness?: number
23
+ conscientiousness?: number
24
+ extraversion?: number
25
+ agreeableness?: number
26
+ neuroticism?: number
27
+ }
28
+ mental_models?: string[]
29
+ expertise_domains?: string[]
30
+ frameworks?: string[]
31
+ communication?: { tone?: string, vocabulary_level?: string, avoid?: string[] }
32
+ }
33
+
34
+ interface AgentDetail {
35
+ id: string
36
+ name?: string
37
+ role?: string
38
+ department?: string
39
+ mbti?: string
40
+ disc?: { primary?: string, secondary?: string }
41
+ enneagram?: { type?: number, wing?: number }
42
+ big_five?: PersonaDetail['big_five']
43
+ mental_models?: { primary?: string[], secondary?: string[] }
44
+ expertise?: { domains?: string[], frameworks?: string[] }
45
+ communication?: { tone?: string, vocabulary_level?: string, avoid?: string[] }
46
+ }
47
+
48
+ const { data: persona, status: pStatus } = fetchApi<PersonaDetail>(
49
+ () => personaId.value ? `/api/personas/${personaId.value}` : '',
50
+ )
51
+ const { data: agent, status: aStatus } = fetchApi<AgentDetail>(
52
+ () => agentId.value ? `/api/agents/${agentId.value}` : '',
53
+ )
54
+
55
+ const loading = computed(() => pStatus.value === 'pending' || aStatus.value === 'pending')
56
+ const errorMsg = computed(() => {
57
+ if (!personaId.value || !agentId.value) {
58
+ return 'Pass ?persona=p&agent=a'
59
+ }
60
+ if (persona.value && (persona.value as any).error) return `Persona: ${(persona.value as any).error}`
61
+ if (agent.value && (agent.value as any).error) return `Agent: ${(agent.value as any).error}`
62
+ return null
63
+ })
64
+
65
+ function diffClass(left: unknown, right: unknown): string {
66
+ return left !== right
67
+ ? 'bg-yellow-500/10 border-yellow-500/30'
68
+ : ''
69
+ }
70
+ function listDiffClass(left: unknown[] | undefined, right: unknown[] | undefined): string {
71
+ const a = JSON.stringify([...(left ?? [])].sort())
72
+ const b = JSON.stringify([...(right ?? [])].sort())
73
+ return a !== b ? 'bg-yellow-500/10 border-yellow-500/30' : ''
74
+ }
75
+
76
+ const bigFiveKeys = ['openness', 'conscientiousness', 'extraversion', 'agreeableness', 'neuroticism'] as const
77
+ </script>
78
+
79
+ <template>
80
+ <UDashboardPanel id="persona-vs-agent">
81
+ <template #header>
82
+ <UDashboardNavbar title="Persona vs Agent">
83
+ <template #leading>
84
+ <UButton
85
+ v-if="personaId"
86
+ icon="i-lucide-arrow-left"
87
+ variant="ghost"
88
+ size="sm"
89
+ :to="`/personas/${personaId}`"
90
+ aria-label="Back to persona"
91
+ />
92
+ </template>
93
+ <template #trailing>
94
+ <UBadge label="diverge view" variant="subtle" size="sm" />
95
+ </template>
96
+ </UDashboardNavbar>
97
+ </template>
98
+
99
+ <template #body>
100
+ <div v-if="errorMsg" class="p-6 text-center text-sm text-error">
101
+ {{ errorMsg }}
102
+ </div>
103
+ <div v-else-if="loading" class="p-6 text-center text-sm text-muted">
104
+ <UIcon name="i-lucide-loader-2" class="size-4 animate-spin inline" /> Loading…
105
+ </div>
106
+ <div v-else-if="persona && agent" class="space-y-4 max-w-6xl">
107
+ <section class="grid grid-cols-2 gap-3">
108
+ <NuxtLink :to="`/personas/${persona.id}`" class="rounded-lg border border-default p-4 hover:border-primary/40">
109
+ <p class="text-xs text-muted uppercase tracking-wide">Persona</p>
110
+ <h2 class="text-xl font-bold">{{ persona.name }}</h2>
111
+ <p class="text-sm text-muted">{{ persona.title || '—' }}</p>
112
+ </NuxtLink>
113
+ <NuxtLink :to="`/agents/${agent.id}`" class="rounded-lg border border-default p-4 hover:border-primary/40">
114
+ <p class="text-xs text-muted uppercase tracking-wide">Agent</p>
115
+ <h2 class="text-xl font-bold">{{ agent.name }}</h2>
116
+ <p class="text-sm text-muted">{{ agent.role }} · {{ agent.department }}</p>
117
+ </NuxtLink>
118
+ </section>
119
+
120
+ <h3 class="text-sm font-semibold uppercase tracking-wide text-muted pt-2">Behavioural DNA</h3>
121
+ <div class="grid grid-cols-2 gap-3">
122
+ <div :class="['rounded-lg border p-3', diffClass(persona.mbti, agent.mbti)]">
123
+ <p class="text-xs text-muted">MBTI</p>
124
+ <p class="text-lg font-mono font-bold">{{ persona.mbti ?? '—' }}</p>
125
+ </div>
126
+ <div :class="['rounded-lg border p-3', diffClass(persona.mbti, agent.mbti)]">
127
+ <p class="text-xs text-muted">MBTI</p>
128
+ <p class="text-lg font-mono font-bold">{{ agent.mbti ?? '—' }}</p>
129
+ </div>
130
+ <div :class="['rounded-lg border p-3', diffClass(`${persona.disc?.primary}/${persona.disc?.secondary}`, `${agent.disc?.primary}/${agent.disc?.secondary}`)]">
131
+ <p class="text-xs text-muted">DISC</p>
132
+ <p class="text-lg font-mono font-bold">{{ persona.disc?.primary ?? '?' }}/{{ persona.disc?.secondary ?? '?' }}</p>
133
+ </div>
134
+ <div :class="['rounded-lg border p-3', diffClass(`${persona.disc?.primary}/${persona.disc?.secondary}`, `${agent.disc?.primary}/${agent.disc?.secondary}`)]">
135
+ <p class="text-xs text-muted">DISC</p>
136
+ <p class="text-lg font-mono font-bold">{{ agent.disc?.primary ?? '?' }}/{{ agent.disc?.secondary ?? '?' }}</p>
137
+ </div>
138
+ <div :class="['rounded-lg border p-3', diffClass(`${persona.enneagram?.type}w${persona.enneagram?.wing}`, `${agent.enneagram?.type}w${agent.enneagram?.wing}`)]">
139
+ <p class="text-xs text-muted">Enneagram</p>
140
+ <p class="text-lg font-mono font-bold">{{ persona.enneagram?.type ?? '?' }}w{{ persona.enneagram?.wing ?? '?' }}</p>
141
+ </div>
142
+ <div :class="['rounded-lg border p-3', diffClass(`${persona.enneagram?.type}w${persona.enneagram?.wing}`, `${agent.enneagram?.type}w${agent.enneagram?.wing}`)]">
143
+ <p class="text-xs text-muted">Enneagram</p>
144
+ <p class="text-lg font-mono font-bold">{{ agent.enneagram?.type ?? '?' }}w{{ agent.enneagram?.wing ?? '?' }}</p>
145
+ </div>
146
+ </div>
147
+
148
+ <h3 class="text-sm font-semibold uppercase tracking-wide text-muted pt-2">Big Five (OCEAN)</h3>
149
+ <div class="space-y-1">
150
+ <div v-for="k in bigFiveKeys" :key="k" class="grid grid-cols-2 gap-3">
151
+ <div :class="['rounded-lg border p-2 flex items-center gap-3', diffClass(persona.big_five?.[k], agent.big_five?.[k])]">
152
+ <span class="text-xs text-muted w-36 shrink-0 capitalize">{{ k }}</span>
153
+ <span class="font-mono text-sm">{{ persona.big_five?.[k] ?? '—' }}</span>
154
+ </div>
155
+ <div :class="['rounded-lg border p-2 flex items-center gap-3', diffClass(persona.big_five?.[k], agent.big_five?.[k])]">
156
+ <span class="text-xs text-muted w-36 shrink-0 capitalize">{{ k }}</span>
157
+ <span class="font-mono text-sm">{{ agent.big_five?.[k] ?? '—' }}</span>
158
+ </div>
159
+ </div>
160
+ </div>
161
+
162
+ <h3 class="text-sm font-semibold uppercase tracking-wide text-muted pt-2">Expertise domains</h3>
163
+ <div class="grid grid-cols-2 gap-3">
164
+ <div :class="['rounded-lg border p-3', listDiffClass(persona.expertise_domains, agent.expertise?.domains)]">
165
+ <ul class="list-disc list-inside text-sm space-y-1">
166
+ <li v-for="d in persona.expertise_domains" :key="d">{{ d }}</li>
167
+ <li v-if="!persona.expertise_domains?.length" class="list-none text-muted italic">none</li>
168
+ </ul>
169
+ </div>
170
+ <div :class="['rounded-lg border p-3', listDiffClass(persona.expertise_domains, agent.expertise?.domains)]">
171
+ <ul class="list-disc list-inside text-sm space-y-1">
172
+ <li v-for="d in agent.expertise?.domains" :key="d">{{ d }}</li>
173
+ <li v-if="!agent.expertise?.domains?.length" class="list-none text-muted italic">none</li>
174
+ </ul>
175
+ </div>
176
+ </div>
177
+
178
+ <h3 class="text-sm font-semibold uppercase tracking-wide text-muted pt-2">Frameworks</h3>
179
+ <div class="grid grid-cols-2 gap-3">
180
+ <div :class="['rounded-lg border p-3', listDiffClass(persona.frameworks, agent.expertise?.frameworks)]">
181
+ <ul class="list-disc list-inside text-sm space-y-1">
182
+ <li v-for="f in persona.frameworks" :key="f">{{ f }}</li>
183
+ <li v-if="!persona.frameworks?.length" class="list-none text-muted italic">none</li>
184
+ </ul>
185
+ </div>
186
+ <div :class="['rounded-lg border p-3', listDiffClass(persona.frameworks, agent.expertise?.frameworks)]">
187
+ <ul class="list-disc list-inside text-sm space-y-1">
188
+ <li v-for="f in agent.expertise?.frameworks" :key="f">{{ f }}</li>
189
+ <li v-if="!agent.expertise?.frameworks?.length" class="list-none text-muted italic">none</li>
190
+ </ul>
191
+ </div>
192
+ </div>
193
+
194
+ <h3 class="text-sm font-semibold uppercase tracking-wide text-muted pt-2">Mental models</h3>
195
+ <div class="grid grid-cols-2 gap-3">
196
+ <div :class="['rounded-lg border p-3', listDiffClass(persona.mental_models, agent.mental_models?.primary)]">
197
+ <p class="text-xs text-muted mb-2">Persona — flat list</p>
198
+ <ul class="list-disc list-inside text-sm space-y-1">
199
+ <li v-for="m in persona.mental_models" :key="m">{{ m }}</li>
200
+ <li v-if="!persona.mental_models?.length" class="list-none text-muted italic">none</li>
201
+ </ul>
202
+ </div>
203
+ <div :class="['rounded-lg border p-3', listDiffClass(persona.mental_models, agent.mental_models?.primary)]">
204
+ <p class="text-xs text-muted mb-2">Agent — primary</p>
205
+ <ul class="list-disc list-inside text-sm space-y-1">
206
+ <li v-for="m in agent.mental_models?.primary" :key="m">{{ m }}</li>
207
+ <li v-if="!agent.mental_models?.primary?.length" class="list-none text-muted italic">none</li>
208
+ </ul>
209
+ </div>
210
+ </div>
211
+
212
+ <p class="text-xs text-muted pt-4 italic">
213
+ Cells with a yellow tint differ between persona and agent.
214
+ </p>
215
+ </div>
216
+ </template>
217
+ </UDashboardPanel>
218
+ </template>
@@ -0,0 +1,191 @@
1
+ <script setup lang="ts">
2
+ // PR88b v3.24.0 — Workflows browser.
3
+ //
4
+ // Lists every YAML workflow under departments/*/workflows/*.yaml with
5
+ // metadata pulled from the file's frontmatter-ish top keys. Selecting
6
+ // a row opens a side panel with the raw YAML for inspection.
7
+
8
+ import type { TableColumn } from '@nuxt/ui'
9
+
10
+ interface Workflow {
11
+ id: string
12
+ name: string
13
+ description: string
14
+ department: string
15
+ tier: string
16
+ command: string
17
+ phases_count: number
18
+ file: string
19
+ content: string
20
+ }
21
+
22
+ const { fetchApi } = useApi()
23
+ const { data, status, error, refresh } = await fetchApi<{ workflows: Workflow[] }>('/api/workflows')
24
+
25
+ const workflows = computed(() => data.value?.workflows ?? [])
26
+ const search = ref('')
27
+ const deptFilter = ref<'all' | string>('all')
28
+ const selected = ref<Workflow | null>(null)
29
+
30
+ const departments = computed(() => {
31
+ const set = new Set<string>()
32
+ for (const w of workflows.value) if (w.department) set.add(w.department)
33
+ return [
34
+ { label: 'All departments', value: 'all' },
35
+ ...Array.from(set).sort().map((d) => ({ label: d, value: d })),
36
+ ]
37
+ })
38
+
39
+ const filtered = computed(() => {
40
+ let result = workflows.value
41
+ if (deptFilter.value !== 'all') {
42
+ result = result.filter((w) => w.department === deptFilter.value)
43
+ }
44
+ const q = search.value.toLowerCase().trim()
45
+ if (q) {
46
+ result = result.filter((w) =>
47
+ w.name.toLowerCase().includes(q)
48
+ || w.description.toLowerCase().includes(q)
49
+ || w.command.toLowerCase().includes(q)
50
+ || w.id.toLowerCase().includes(q),
51
+ )
52
+ }
53
+ return result
54
+ })
55
+
56
+ const tierColor = (tier: string) => {
57
+ const m: Record<string, 'primary' | 'success' | 'warning' | 'neutral'> = {
58
+ enterprise: 'primary',
59
+ focused: 'success',
60
+ specialist: 'warning',
61
+ }
62
+ return m[tier] ?? 'neutral'
63
+ }
64
+
65
+ const columns: TableColumn<Workflow>[] = [
66
+ { accessorKey: 'name', header: 'Name' },
67
+ { accessorKey: 'department', header: 'Department' },
68
+ { accessorKey: 'tier', header: 'Tier' },
69
+ { accessorKey: 'command', header: 'Command' },
70
+ { accessorKey: 'phases_count', header: 'Phases' },
71
+ ]
72
+ </script>
73
+
74
+ <template>
75
+ <UDashboardPanel id="workflows">
76
+ <template #header>
77
+ <UDashboardNavbar title="Workflows">
78
+ <template #leading>
79
+ <UDashboardSidebarCollapse />
80
+ </template>
81
+ <template #trailing>
82
+ <UBadge v-if="workflows.length" :label="String(workflows.length)" variant="subtle" />
83
+ </template>
84
+ </UDashboardNavbar>
85
+ </template>
86
+
87
+ <template #body>
88
+ <DashboardState
89
+ :status="status"
90
+ :error="error"
91
+ :empty="!workflows.length"
92
+ empty-title="No workflows found"
93
+ empty-description="YAML workflows live under departments/*/workflows/."
94
+ empty-icon="i-lucide-workflow"
95
+ loading-label="Scanning workflows"
96
+ :on-retry="() => refresh()"
97
+ >
98
+ <div class="flex flex-wrap items-center gap-3 mb-4">
99
+ <UInput
100
+ v-model="search"
101
+ class="max-w-sm"
102
+ icon="i-lucide-search"
103
+ placeholder="Search name, command, description…"
104
+ />
105
+ <USelect
106
+ v-model="deptFilter"
107
+ :items="departments"
108
+ placeholder="Department"
109
+ class="min-w-44"
110
+ />
111
+ <span class="ml-auto text-xs text-muted">
112
+ {{ filtered.length }} workflow{{ filtered.length === 1 ? '' : 's' }}
113
+ </span>
114
+ </div>
115
+
116
+ <div class="grid grid-cols-1 xl:grid-cols-[1.4fr_1fr] gap-4">
117
+ <UTable
118
+ :data="filtered"
119
+ :columns="columns"
120
+ :loading="status === 'pending'"
121
+ :ui="{
122
+ tbody: '[&>tr]:cursor-pointer [&>tr]:hover:bg-elevated/50 [&>tr]:transition-colors',
123
+ th: 'py-2 first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
124
+ td: 'border-b border-default',
125
+ }"
126
+ @select="(row: { original: Workflow }) => selected = row.original"
127
+ >
128
+ <template #name-cell="{ row }">
129
+ <div class="min-w-0">
130
+ <p class="font-medium truncate">{{ row.original.name }}</p>
131
+ <p class="text-xs text-muted font-mono truncate">{{ row.original.id }}</p>
132
+ </div>
133
+ </template>
134
+ <template #department-cell="{ row }">
135
+ <UBadge :label="row.original.department" variant="subtle" size="sm" />
136
+ </template>
137
+ <template #tier-cell="{ row }">
138
+ <UBadge
139
+ v-if="row.original.tier"
140
+ :label="row.original.tier"
141
+ :color="tierColor(row.original.tier)"
142
+ variant="subtle"
143
+ size="sm"
144
+ />
145
+ <span v-else class="text-xs text-muted">—</span>
146
+ </template>
147
+ <template #command-cell="{ row }">
148
+ <code v-if="row.original.command" class="text-xs font-mono">{{ row.original.command }}</code>
149
+ <span v-else class="text-xs text-muted">—</span>
150
+ </template>
151
+ <template #phases_count-cell="{ row }">
152
+ <span class="font-mono text-sm">{{ row.original.phases_count }}</span>
153
+ </template>
154
+ </UTable>
155
+
156
+ <div
157
+ v-if="selected"
158
+ class="rounded-lg border border-default overflow-hidden flex flex-col"
159
+ >
160
+ <div class="px-4 py-3 border-b border-default bg-elevated/30">
161
+ <div class="flex items-start justify-between gap-3">
162
+ <div class="min-w-0">
163
+ <p class="text-xs text-muted uppercase tracking-wide">YAML preview</p>
164
+ <p class="font-semibold truncate">{{ selected.name }}</p>
165
+ <p class="text-xs text-muted font-mono truncate">{{ selected.file }}</p>
166
+ </div>
167
+ <UButton
168
+ icon="i-lucide-x"
169
+ variant="ghost"
170
+ size="xs"
171
+ aria-label="Close preview"
172
+ @click="selected = null"
173
+ />
174
+ </div>
175
+ <p v-if="selected.description" class="text-xs text-muted mt-2">
176
+ {{ selected.description }}
177
+ </p>
178
+ </div>
179
+ <pre class="p-4 text-xs font-mono overflow-x-auto whitespace-pre">{{ selected.content }}</pre>
180
+ </div>
181
+ <div
182
+ v-else
183
+ class="rounded-lg border border-dashed border-default p-8 text-center text-sm text-muted self-start"
184
+ >
185
+ Click a row to preview its YAML.
186
+ </div>
187
+ </div>
188
+ </DashboardState>
189
+ </template>
190
+ </UDashboardPanel>
191
+ </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.22.0",
3
+ "version": "3.24.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "3.22.0"
3
+ version = "3.24.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -1387,6 +1387,46 @@ def agent_export_to_vault(agent_id: str):
1387
1387
  return {"exported": True, "path": str(res.path), "vault_path": str(res.vault_path)}
1388
1388
 
1389
1389
 
1390
+ # --- Workflows (PR88b v3.24.0) ---
1391
+
1392
+ @app.get("/api/workflows")
1393
+ def workflows_list():
1394
+ """Scan departments/*/workflows/*.yaml and return metadata + content."""
1395
+ out: list[dict] = []
1396
+ dept_root = ARKAOS_ROOT / "departments"
1397
+ if not dept_root.exists():
1398
+ return {"workflows": []}
1399
+ try:
1400
+ import yaml as _yaml
1401
+ except ImportError:
1402
+ return {"workflows": [], "error": "PyYAML unavailable"}
1403
+ for path in sorted(dept_root.glob("*/workflows/*.yaml")):
1404
+ try:
1405
+ content = path.read_text(encoding="utf-8")
1406
+ except OSError:
1407
+ continue
1408
+ try:
1409
+ raw = _yaml.safe_load(content) or {}
1410
+ except Exception: # noqa: BLE001
1411
+ raw = {}
1412
+ if not isinstance(raw, dict):
1413
+ continue
1414
+ phases = raw.get("phases") if isinstance(raw.get("phases"), list) else []
1415
+ rel = path.relative_to(ARKAOS_ROOT).as_posix()
1416
+ out.append({
1417
+ "id": str(raw.get("id") or path.stem),
1418
+ "name": str(raw.get("name") or path.stem),
1419
+ "description": str(raw.get("description") or ""),
1420
+ "department": str(raw.get("department") or path.parent.parent.name),
1421
+ "tier": str(raw.get("tier") or ""),
1422
+ "command": str(raw.get("command") or ""),
1423
+ "phases_count": len(phases),
1424
+ "file": rel,
1425
+ "content": content,
1426
+ })
1427
+ return {"workflows": out}
1428
+
1429
+
1390
1430
  # --- Sidebar stats widget (PR87d v3.22.0) ---
1391
1431
 
1392
1432
  @app.get("/api/sidebar-stats")