arkaos 3.78.0 → 4.0.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.
Files changed (77) hide show
  1. package/VERSION +1 -1
  2. package/config/agent-allowlists/laravel.yaml +1 -0
  3. package/config/agent-allowlists/node.yaml +1 -0
  4. package/config/agent-allowlists/nuxt.yaml +1 -0
  5. package/config/agent-allowlists/python.yaml +1 -0
  6. package/core/agents/__pycache__/registry_gen.cpython-313.pyc +0 -0
  7. package/core/agents/__pycache__/schema.cpython-313.pyc +0 -0
  8. package/core/agents/registry_gen.py +6 -1
  9. package/core/agents/schema.py +4 -0
  10. package/core/cognition/__pycache__/reorganizer.cpython-313.pyc +0 -0
  11. package/core/cognition/reorganizer.py +37 -7
  12. package/core/governance/__pycache__/design_system_lint.cpython-313.pyc +0 -0
  13. package/core/governance/__pycache__/design_system_lint_cli.cpython-313.pyc +0 -0
  14. package/core/knowledge/__pycache__/agent_match.cpython-313.pyc +0 -0
  15. package/core/knowledge/__pycache__/chunker.cpython-313.pyc +0 -0
  16. package/core/knowledge/__pycache__/ingest.cpython-313.pyc +0 -0
  17. package/core/knowledge/__pycache__/sources.cpython-313.pyc +0 -0
  18. package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
  19. package/core/knowledge/agent_match.py +114 -0
  20. package/core/knowledge/chunker.py +45 -0
  21. package/core/knowledge/ingest.py +156 -78
  22. package/core/knowledge/sources.py +138 -0
  23. package/core/knowledge/vector_store.py +52 -0
  24. package/core/squads/__pycache__/loader.cpython-313.pyc +0 -0
  25. package/core/squads/loader.py +25 -0
  26. package/core/sync/__pycache__/agent_provisioner.cpython-313.pyc +0 -0
  27. package/core/sync/agent_provisioner.py +19 -8
  28. package/dashboard/app/components/KnowledgeSourcesList.vue +40 -13
  29. package/dashboard/app/pages/cognition.vue +9 -4
  30. package/dashboard/app/pages/knowledge/[id].vue +669 -0
  31. package/dashboard/app/pages/knowledge/index.vue +1281 -0
  32. package/dashboard/app/types/index.d.ts +1 -1
  33. package/departments/brand/agents/ux-designer.yaml +15 -1
  34. package/departments/brand/agents/ux-researcher.yaml +73 -0
  35. package/departments/brand/agents/ux-strategist.yaml +72 -0
  36. package/departments/dev/agents/ai-engineering/ai-engineering-lead.yaml +76 -0
  37. package/departments/dev/agents/architect.yaml +9 -3
  38. package/departments/dev/agents/backend-core/laravel-eng.yaml +76 -0
  39. package/departments/dev/agents/backend-core/node-ts-eng.yaml +76 -0
  40. package/departments/dev/agents/backend-core/python-eng.yaml +76 -0
  41. package/departments/dev/agents/backend-dev.yaml +10 -4
  42. package/departments/dev/agents/data-platform/etl-eng.yaml +74 -0
  43. package/departments/dev/agents/dba.yaml +7 -3
  44. package/departments/dev/references/backend-knowledge-and-tools.md +70 -0
  45. package/departments/ecom/agents/retention-manager.yaml +13 -1
  46. package/departments/leadership/agents/culture-coach.yaml +20 -0
  47. package/departments/leadership/agents/hr-specialist.yaml +18 -0
  48. package/departments/leadership/agents/leadership-director.yaml +10 -0
  49. package/departments/org/agents/chief-of-staff.yaml +76 -0
  50. package/departments/org/agents/coo.yaml +11 -0
  51. package/departments/org/agents/okr-steward.yaml +71 -0
  52. package/departments/org/agents/org-designer.yaml +23 -0
  53. package/departments/org/skills/okr-cadence/SKILL.md +34 -0
  54. package/departments/org/skills/principles-audit/SKILL.md +36 -0
  55. package/departments/pm/agents/pm-director.yaml +21 -8
  56. package/departments/pm/agents/product-owner.yaml +24 -2
  57. package/departments/pm/agents/scrum-master.yaml +21 -0
  58. package/departments/pm/agents/strategic-pm.yaml +72 -0
  59. package/departments/pm/skills/discovery-plan/SKILL.md +7 -1
  60. package/departments/quality/agents/cqo.yaml +8 -0
  61. package/departments/saas/agents/cs-manager.yaml +19 -2
  62. package/departments/saas/agents/growth-engineer.yaml +14 -1
  63. package/departments/saas/agents/metrics-analyst.yaml +17 -1
  64. package/departments/saas/agents/revops-lead.yaml +73 -0
  65. package/departments/saas/skills/leaky-bucket/SKILL.md +28 -0
  66. package/departments/saas/skills/voc-loop/SKILL.md +29 -0
  67. package/departments/sales/agents/sales-director.yaml +9 -0
  68. package/departments/sales/agents/sdr.yaml +72 -0
  69. package/departments/strategy/agents/decision-quality.yaml +72 -0
  70. package/departments/strategy/agents/strategy-director.yaml +13 -0
  71. package/departments/strategy/skills/premortem/SKILL.md +33 -0
  72. package/knowledge/agents-registry-v2.json +1218 -78
  73. package/package.json +1 -1
  74. package/pyproject.toml +1 -1
  75. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  76. package/scripts/dashboard-api.py +376 -13
  77. package/dashboard/app/pages/knowledge.vue +0 -918
@@ -0,0 +1,669 @@
1
+ <script setup lang="ts">
2
+ // PR2 — Per-source knowledge detail page.
3
+ //
4
+ // Polymorphic view for a single indexed source (id like `src-xxxx`).
5
+ // Loads GET /api/knowledge/sources/{id}. Renders media (YouTube embed,
6
+ // native video/audio, or download), the full transcript, the chunks this
7
+ // source contributed to the vector store, and a placeholder for PR3 agent
8
+ // attribution. Unknown ids return 404 -> "Source not found" empty state.
9
+
10
+ const route = useRoute()
11
+ const sourceId = route.params.id as string
12
+
13
+ const { fetchApi, apiBase } = useApi()
14
+ const toast = useToast()
15
+
16
+ interface SourceChunk {
17
+ text: string
18
+ heading: string
19
+ metadata: Record<string, unknown>
20
+ }
21
+
22
+ interface SourceDetail {
23
+ id: string
24
+ source: string
25
+ type: '' | 'youtube' | 'web' | 'pdf' | 'audio' | 'video' | 'markdown'
26
+ title: string
27
+ duration: number
28
+ language: string
29
+ thumbnail_path: string
30
+ media_path: string
31
+ transcript: string
32
+ chunk_count: number
33
+ status: string
34
+ error: string
35
+ created_at: string
36
+ updated_at: string
37
+ chunks: SourceChunk[]
38
+ }
39
+
40
+ const { data: source, status, error } = await fetchApi<SourceDetail>(
41
+ `/api/knowledge/sources/${sourceId}`
42
+ )
43
+
44
+ // --- Type badge mapping (mirrors knowledge.vue) ---
45
+ const typeColorMap: Record<string, 'error' | 'primary' | 'warning' | 'success' | 'neutral'> = {
46
+ youtube: 'error',
47
+ web: 'primary',
48
+ pdf: 'warning',
49
+ audio: 'success',
50
+ markdown: 'neutral',
51
+ video: 'error'
52
+ }
53
+ const typeIconMap: Record<string, string> = {
54
+ youtube: 'i-lucide-youtube',
55
+ web: 'i-lucide-globe',
56
+ pdf: 'i-lucide-file-text',
57
+ audio: 'i-lucide-headphones',
58
+ markdown: 'i-lucide-file-code',
59
+ video: 'i-lucide-video'
60
+ }
61
+
62
+ const statusColorMap: Record<string, 'success' | 'error' | 'neutral'> = {
63
+ ready: 'success',
64
+ failed: 'error',
65
+ pending: 'neutral'
66
+ }
67
+
68
+ // --- Derived labels ---
69
+ function sourceLabel(src: string): string {
70
+ if (src.startsWith('http')) {
71
+ try {
72
+ const u = new URL(src)
73
+ return u.hostname + u.pathname
74
+ } catch {
75
+ return src
76
+ }
77
+ }
78
+ return src
79
+ }
80
+
81
+ const headerTitle = computed(() => {
82
+ const t = source.value?.title?.trim()
83
+ if (t) return t
84
+ return source.value ? sourceLabel(source.value.source) : 'Source'
85
+ })
86
+
87
+ const typeLabel = computed(() => {
88
+ const t = source.value?.type
89
+ if (!t) return 'Unknown'
90
+ return t.charAt(0).toUpperCase() + t.slice(1)
91
+ })
92
+
93
+ // --- Media decision ---
94
+ // YouTube: prefer the original embed player (zero-storage, always available)
95
+ // when we can parse a video id from the source URL. Otherwise fall back to
96
+ // the native player when the backend has a stored media file.
97
+ const youtubeEmbedUrl = computed<string | null>(() => {
98
+ if (source.value?.type !== 'youtube') return null
99
+ const src = source.value.source
100
+ if (!src.startsWith('http')) return null
101
+ try {
102
+ const u = new URL(src)
103
+ let id = ''
104
+ if (u.hostname.includes('youtu.be')) {
105
+ id = u.pathname.replace(/^\//, '')
106
+ } else if (u.searchParams.get('v')) {
107
+ id = u.searchParams.get('v') ?? ''
108
+ } else if (u.pathname.startsWith('/embed/')) {
109
+ id = u.pathname.replace('/embed/', '')
110
+ }
111
+ if (!id) return null
112
+ return `https://www.youtube.com/embed/${id}`
113
+ } catch {
114
+ return null
115
+ }
116
+ })
117
+
118
+ const hasStoredMedia = computed(() => Boolean(source.value?.media_path))
119
+ const mediaSrc = computed(() => `${apiBase}/api/knowledge/sources/${sourceId}/media`)
120
+ const downloadUrl = computed(() => `${apiBase}/api/knowledge/sources/${sourceId}/download`)
121
+
122
+ const useNativeVideo = computed(() =>
123
+ !youtubeEmbedUrl.value
124
+ && hasStoredMedia.value
125
+ && (source.value?.type === 'video' || source.value?.type === 'youtube')
126
+ )
127
+ const useNativeAudio = computed(() =>
128
+ !youtubeEmbedUrl.value && hasStoredMedia.value && source.value?.type === 'audio'
129
+ )
130
+ const isExternalSource = computed(() => Boolean(source.value?.source?.startsWith('http')))
131
+
132
+ // --- Transcript helpers ---
133
+ const wordCount = computed(() => {
134
+ const t = source.value?.transcript?.trim()
135
+ if (!t) return 0
136
+ return t.split(/\s+/).length
137
+ })
138
+
139
+ function formatDuration(seconds: number): string {
140
+ if (!seconds || seconds <= 0) return ''
141
+ const s = Math.floor(seconds % 60)
142
+ const m = Math.floor((seconds / 60) % 60)
143
+ const h = Math.floor(seconds / 3600)
144
+ const pad = (n: number) => n.toString().padStart(2, '0')
145
+ if (h > 0) return `${h}:${pad(m)}:${pad(s)}`
146
+ return `${m}:${pad(s)}`
147
+ }
148
+
149
+ async function copyTranscript() {
150
+ const text = source.value?.transcript ?? ''
151
+ if (!text) return
152
+ try {
153
+ await navigator.clipboard.writeText(text)
154
+ toast.add({
155
+ title: 'Transcript copied',
156
+ description: `${wordCount.value} words`,
157
+ color: 'success',
158
+ icon: 'i-lucide-check'
159
+ })
160
+ } catch {
161
+ toast.add({
162
+ title: 'Copy failed',
163
+ description: 'Clipboard is not available in this context.',
164
+ color: 'error'
165
+ })
166
+ }
167
+ }
168
+
169
+ // --- Chunk expand/collapse ---
170
+ const expanded = ref<Set<number>>(new Set())
171
+ function toggleChunk(idx: number) {
172
+ const next = new Set(expanded.value)
173
+ if (next.has(idx)) {
174
+ next.delete(idx)
175
+ } else {
176
+ next.add(idx)
177
+ }
178
+ expanded.value = next
179
+ }
180
+
181
+ // --- Agents: semantic matches + propose-only learning suggestion ---
182
+ interface AgentMatch {
183
+ id: string
184
+ name: string
185
+ department: string
186
+ role: string
187
+ score: number
188
+ matched_terms: string[]
189
+ }
190
+
191
+ interface AgentMatchesResponse {
192
+ matches: AgentMatch[]
193
+ source_id?: string
194
+ count?: number
195
+ reason?: string
196
+ }
197
+
198
+ const agentMatches = ref<AgentMatch[]>([])
199
+ const agentMatchesLoading = ref(false)
200
+ const agentMatchesReason = ref<string | null>(null)
201
+ const proposalPending = ref(false)
202
+
203
+ async function fetchAgentMatches() {
204
+ if (!source.value) return
205
+ agentMatchesLoading.value = true
206
+ agentMatchesReason.value = null
207
+ try {
208
+ const res = await $fetch<AgentMatchesResponse>(
209
+ `${apiBase}/api/knowledge/sources/${sourceId}/agent-matches`,
210
+ { params: { top_n: 5 } }
211
+ )
212
+ agentMatches.value = res.matches ?? []
213
+ agentMatchesReason.value = res.reason ?? null
214
+ } catch {
215
+ agentMatchesReason.value = 'request failed'
216
+ agentMatches.value = []
217
+ } finally {
218
+ agentMatchesLoading.value = false
219
+ }
220
+ }
221
+
222
+ function scorePercent(score: number): number {
223
+ return Math.round(Math.max(0, Math.min(1, score)) * 100)
224
+ }
225
+
226
+ async function generateProposal() {
227
+ if (proposalPending.value) return
228
+ proposalPending.value = true
229
+ try {
230
+ const res = await $fetch<{ proposal_path: string, agents: number }>(
231
+ `${apiBase}/api/knowledge/sources/${sourceId}/agent-proposal`,
232
+ {
233
+ method: 'POST',
234
+ body: { agent_ids: agentMatches.value.map(m => m.id) }
235
+ }
236
+ )
237
+ toast.add({
238
+ title: 'Proposal saved',
239
+ description: res.proposal_path,
240
+ color: 'success',
241
+ icon: 'i-lucide-check'
242
+ })
243
+ } catch {
244
+ toast.add({
245
+ title: 'Could not generate proposal',
246
+ description: 'The proposal request failed. Please try again.',
247
+ color: 'error'
248
+ })
249
+ } finally {
250
+ proposalPending.value = false
251
+ }
252
+ }
253
+
254
+ // Non-blocking: fetch matches once the source resolves on the client.
255
+ onMounted(() => {
256
+ if (source.value) fetchAgentMatches()
257
+ })
258
+ </script>
259
+
260
+ <template>
261
+ <UDashboardPanel id="knowledge-source">
262
+ <template #header>
263
+ <UDashboardNavbar :title="headerTitle">
264
+ <template #leading>
265
+ <UDashboardSidebarCollapse />
266
+ </template>
267
+ <template #trailing>
268
+ <UButton
269
+ label="Back"
270
+ variant="ghost"
271
+ icon="i-lucide-arrow-left"
272
+ to="/knowledge"
273
+ aria-label="Back to knowledge base"
274
+ />
275
+ </template>
276
+ </UDashboardNavbar>
277
+ </template>
278
+
279
+ <template #body>
280
+ <!-- Loading -->
281
+ <div v-if="status === 'pending'" class="flex items-center justify-center py-24">
282
+ <UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-muted" />
283
+ </div>
284
+
285
+ <!-- Not found (404 from API, or no source data resolved) -->
286
+ <div
287
+ v-else-if="(error && (error.statusCode === 404 || error.data?.error === 'not found')) || !source"
288
+ class="flex flex-col items-center justify-center gap-4 py-24"
289
+ >
290
+ <UIcon name="i-lucide-file-x" class="size-12 text-muted" />
291
+ <p class="text-sm text-muted">
292
+ Source not found.
293
+ </p>
294
+ <UButton
295
+ label="Back to Knowledge"
296
+ variant="outline"
297
+ icon="i-lucide-arrow-left"
298
+ to="/knowledge"
299
+ />
300
+ </div>
301
+
302
+ <!-- Error (non-404 failure) -->
303
+ <div
304
+ v-else-if="error"
305
+ class="flex flex-col items-center justify-center gap-4 py-24"
306
+ role="alert"
307
+ >
308
+ <UIcon name="i-lucide-alert-triangle" class="size-12 text-red-500" />
309
+ <p class="text-sm text-muted">
310
+ Failed to load this source.
311
+ </p>
312
+ <UButton
313
+ label="Back to Knowledge"
314
+ variant="outline"
315
+ icon="i-lucide-arrow-left"
316
+ to="/knowledge"
317
+ />
318
+ </div>
319
+
320
+ <!-- Content -->
321
+ <div v-else class="space-y-6 pb-12">
322
+ <!-- ===== HEADER ===== -->
323
+ <section class="rounded-2xl border border-default bg-elevated/10 p-6">
324
+ <div class="flex items-start gap-4">
325
+ <div class="shrink-0 size-12 rounded-xl bg-default/80 border border-default flex items-center justify-center">
326
+ <UIcon
327
+ :name="typeIconMap[source.type] ?? 'i-lucide-file'"
328
+ class="size-6 text-muted"
329
+ />
330
+ </div>
331
+ <div class="flex-1 min-w-0 space-y-2">
332
+ <h1 class="text-2xl font-bold tracking-tight text-highlighted break-words">
333
+ {{ headerTitle }}
334
+ </h1>
335
+ <div class="flex flex-wrap items-center gap-2">
336
+ <UBadge
337
+ :label="typeLabel"
338
+ :icon="typeIconMap[source.type]"
339
+ :color="typeColorMap[source.type] ?? 'neutral'"
340
+ variant="subtle"
341
+ size="sm"
342
+ />
343
+ <UBadge
344
+ :label="source.status || 'unknown'"
345
+ :color="statusColorMap[source.status] ?? 'neutral'"
346
+ variant="subtle"
347
+ size="sm"
348
+ class="capitalize"
349
+ />
350
+ <UBadge
351
+ v-if="source.language"
352
+ :label="source.language"
353
+ variant="outline"
354
+ size="sm"
355
+ />
356
+ <UBadge
357
+ v-if="source.duration > 0"
358
+ :label="formatDuration(source.duration)"
359
+ icon="i-lucide-clock"
360
+ variant="outline"
361
+ size="sm"
362
+ />
363
+ <UBadge
364
+ :label="`${source.chunk_count} chunk${source.chunk_count === 1 ? '' : 's'}`"
365
+ variant="subtle"
366
+ size="sm"
367
+ />
368
+ </div>
369
+ <p class="text-xs text-muted font-mono break-all">
370
+ {{ source.source }}
371
+ </p>
372
+ <p class="text-xs text-muted/60 font-mono select-all">
373
+ {{ source.id }}
374
+ </p>
375
+ </div>
376
+ </div>
377
+ <div
378
+ v-if="source.status === 'failed' && source.error"
379
+ class="mt-4 rounded-lg border border-red-500/20 bg-red-500/5 p-3"
380
+ role="alert"
381
+ >
382
+ <div class="flex items-start gap-2">
383
+ <UIcon name="i-lucide-alert-circle" class="size-4 text-red-500 mt-0.5 shrink-0" />
384
+ <p class="text-sm text-red-400">
385
+ {{ source.error }}
386
+ </p>
387
+ </div>
388
+ </div>
389
+ </section>
390
+
391
+ <!-- ===== MEDIA ===== -->
392
+ <section class="rounded-xl border border-default bg-elevated/10 p-5">
393
+ <div class="flex items-center justify-between gap-3 mb-4">
394
+ <h2 class="text-sm font-semibold uppercase tracking-wide text-muted">
395
+ Media
396
+ </h2>
397
+ <UButton
398
+ v-if="hasStoredMedia"
399
+ label="Download"
400
+ icon="i-lucide-download"
401
+ variant="outline"
402
+ size="sm"
403
+ :to="downloadUrl"
404
+ target="_blank"
405
+ external
406
+ aria-label="Download original media file"
407
+ />
408
+ </div>
409
+
410
+ <!-- YouTube embed (zero-storage, preferred) -->
411
+ <div
412
+ v-if="youtubeEmbedUrl"
413
+ class="relative w-full overflow-hidden rounded-lg bg-black"
414
+ style="aspect-ratio: 16 / 9"
415
+ >
416
+ <iframe
417
+ :src="youtubeEmbedUrl"
418
+ :title="headerTitle"
419
+ class="absolute inset-0 size-full"
420
+ frameborder="0"
421
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
422
+ allowfullscreen
423
+ />
424
+ </div>
425
+
426
+ <!-- Native video player -->
427
+ <video
428
+ v-else-if="useNativeVideo"
429
+ :src="mediaSrc"
430
+ controls
431
+ class="w-full rounded-lg bg-black"
432
+ preload="metadata"
433
+ >
434
+ Your browser does not support the video element.
435
+ </video>
436
+
437
+ <!-- Native audio player -->
438
+ <audio
439
+ v-else-if="useNativeAudio"
440
+ :src="mediaSrc"
441
+ controls
442
+ class="w-full"
443
+ preload="metadata"
444
+ >
445
+ Your browser does not support the audio element.
446
+ </audio>
447
+
448
+ <!-- No media -->
449
+ <div v-else class="flex flex-col items-start gap-2 py-2">
450
+ <p class="text-sm text-muted">
451
+ No media for this source.
452
+ </p>
453
+ <UButton
454
+ v-if="isExternalSource"
455
+ :label="sourceLabel(source.source)"
456
+ icon="i-lucide-external-link"
457
+ variant="link"
458
+ size="sm"
459
+ :to="source.source"
460
+ target="_blank"
461
+ external
462
+ :padded="false"
463
+ />
464
+ </div>
465
+ </section>
466
+
467
+ <!-- ===== TRANSCRIPT ===== -->
468
+ <section class="rounded-xl border border-default bg-elevated/10 p-5">
469
+ <div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
470
+ <h2 class="text-sm font-semibold uppercase tracking-wide text-muted">
471
+ Transcript
472
+ </h2>
473
+ <div class="flex items-center gap-3">
474
+ <span v-if="wordCount > 0" class="text-xs font-mono text-muted">
475
+ {{ wordCount }} words
476
+ <template v-if="source.duration > 0">· {{ formatDuration(source.duration) }}</template>
477
+ </span>
478
+ <UButton
479
+ v-if="source.transcript"
480
+ label="Copy"
481
+ icon="i-lucide-copy"
482
+ variant="ghost"
483
+ size="xs"
484
+ aria-label="Copy transcript to clipboard"
485
+ @click="copyTranscript"
486
+ />
487
+ </div>
488
+ </div>
489
+ <p v-if="!source.transcript" class="text-sm text-muted py-2">
490
+ No transcript available.
491
+ </p>
492
+ <div
493
+ v-else
494
+ class="max-h-96 overflow-y-auto rounded-lg border border-default bg-default/40 p-4"
495
+ >
496
+ <p class="text-sm leading-relaxed whitespace-pre-wrap font-mono text-highlighted/90">
497
+ {{ source.transcript }}
498
+ </p>
499
+ </div>
500
+ </section>
501
+
502
+ <!-- ===== KNOWLEDGE (CHUNKS) ===== -->
503
+ <section class="rounded-xl border border-default bg-elevated/10 p-5">
504
+ <div class="flex items-center justify-between gap-3 mb-4">
505
+ <h2 class="text-sm font-semibold uppercase tracking-wide text-muted">
506
+ Knowledge attributed
507
+ </h2>
508
+ <UBadge
509
+ :label="`${source.chunk_count} chunk${source.chunk_count === 1 ? '' : 's'}`"
510
+ variant="subtle"
511
+ size="sm"
512
+ />
513
+ </div>
514
+ <p v-if="!source.chunks?.length" class="text-sm text-muted py-2">
515
+ No chunks indexed from this source.
516
+ </p>
517
+ <ul v-else class="space-y-2">
518
+ <li
519
+ v-for="(chunk, idx) in source.chunks"
520
+ :key="idx"
521
+ class="rounded-lg border border-default bg-default/30 p-3"
522
+ >
523
+ <button
524
+ type="button"
525
+ class="flex items-start gap-2 w-full text-left"
526
+ :aria-expanded="expanded.has(idx)"
527
+ :aria-label="expanded.has(idx) ? 'Collapse chunk' : 'Expand chunk'"
528
+ @click="toggleChunk(idx)"
529
+ >
530
+ <UIcon
531
+ :name="expanded.has(idx) ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
532
+ class="size-4 text-muted mt-0.5 shrink-0"
533
+ />
534
+ <div class="flex-1 min-w-0">
535
+ <p
536
+ v-if="chunk.heading"
537
+ class="text-sm font-semibold text-highlighted mb-1"
538
+ >
539
+ {{ chunk.heading }}
540
+ </p>
541
+ <p
542
+ class="text-sm text-muted whitespace-pre-wrap"
543
+ :class="expanded.has(idx) ? '' : 'line-clamp-2'"
544
+ >
545
+ {{ chunk.text }}
546
+ </p>
547
+ </div>
548
+ </button>
549
+ </li>
550
+ </ul>
551
+ </section>
552
+
553
+ <!-- ===== AGENTS ===== -->
554
+ <section class="rounded-xl border border-default bg-elevated/10 p-5">
555
+ <div class="flex items-start justify-between gap-3 mb-4 flex-wrap">
556
+ <div class="flex-1 min-w-0 space-y-1">
557
+ <div class="flex items-center gap-2">
558
+ <UIcon name="i-lucide-users" class="size-4 text-muted" />
559
+ <h2 class="text-sm font-semibold uppercase tracking-wide text-muted">
560
+ Agents
561
+ </h2>
562
+ </div>
563
+ <p class="text-sm text-muted">
564
+ Agents whose expertise matches this source — suggested to learn from it.
565
+ </p>
566
+ </div>
567
+ <div class="flex flex-col items-end gap-1">
568
+ <UButton
569
+ label="Generate proposal"
570
+ icon="i-lucide-file-output"
571
+ variant="outline"
572
+ size="sm"
573
+ :loading="proposalPending"
574
+ :disabled="agentMatchesLoading || !agentMatches.length"
575
+ aria-label="Generate a review-only learning proposal for the matched agents"
576
+ @click="generateProposal"
577
+ />
578
+ <p class="text-xs text-muted/70 max-w-xs text-right">
579
+ Generates a review-only proposal — it never edits agent files automatically.
580
+ </p>
581
+ </div>
582
+ </div>
583
+
584
+ <!-- Loading -->
585
+ <div
586
+ v-if="agentMatchesLoading"
587
+ class="flex items-center gap-2 py-4 text-sm text-muted"
588
+ >
589
+ <UIcon name="i-lucide-loader-2" class="size-4 animate-spin" />
590
+ Finding relevant agents…
591
+ </div>
592
+
593
+ <!-- Embedder offline -->
594
+ <p
595
+ v-else-if="agentMatchesReason === 'embedder unavailable'"
596
+ class="text-sm text-muted py-2"
597
+ >
598
+ Semantic matching is offline (vector embeddings unavailable). Install fastembed to enable agent suggestions.
599
+ </p>
600
+
601
+ <!-- No matches / no source text -->
602
+ <p
603
+ v-else-if="!agentMatches.length"
604
+ class="text-sm text-muted py-2"
605
+ >
606
+ No agent suggestions for this source yet.
607
+ </p>
608
+
609
+ <!-- Matches -->
610
+ <ul v-else class="space-y-2">
611
+ <li
612
+ v-for="agent in agentMatches"
613
+ :key="agent.id"
614
+ class="rounded-lg border border-default bg-default/30 p-4"
615
+ >
616
+ <div class="flex items-start justify-between gap-3 flex-wrap">
617
+ <div class="min-w-0 space-y-1">
618
+ <div class="flex items-center gap-2 flex-wrap">
619
+ <NuxtLink
620
+ :to="`/agents/${agent.id}`"
621
+ class="text-sm font-semibold text-highlighted hover:text-primary transition-colors"
622
+ >
623
+ {{ agent.name }}
624
+ </NuxtLink>
625
+ <UBadge
626
+ v-if="agent.department"
627
+ :label="agent.department"
628
+ variant="subtle"
629
+ color="neutral"
630
+ size="xs"
631
+ />
632
+ </div>
633
+ <p v-if="agent.role" class="text-xs text-muted">
634
+ {{ agent.role }}
635
+ </p>
636
+ </div>
637
+ <div class="flex flex-col items-end gap-1 shrink-0 min-w-32">
638
+ <span class="text-xs font-mono text-muted">
639
+ {{ scorePercent(agent.score) }}% match
640
+ </span>
641
+ <UProgress
642
+ :value="scorePercent(agent.score)"
643
+ :max="100"
644
+ size="xs"
645
+ class="w-32"
646
+ :aria-label="`${agent.name} relevance ${scorePercent(agent.score)} percent`"
647
+ />
648
+ </div>
649
+ </div>
650
+ <div
651
+ v-if="agent.matched_terms?.length"
652
+ class="mt-3 flex flex-wrap gap-1.5"
653
+ >
654
+ <UBadge
655
+ v-for="term in agent.matched_terms"
656
+ :key="term"
657
+ :label="term"
658
+ variant="soft"
659
+ color="primary"
660
+ size="xs"
661
+ />
662
+ </div>
663
+ </li>
664
+ </ul>
665
+ </section>
666
+ </div>
667
+ </template>
668
+ </UDashboardPanel>
669
+ </template>