arkaos 2.92.0 → 2.94.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
- 2.92.0
1
+ 2.94.0
@@ -0,0 +1,388 @@
1
+ <script setup lang="ts">
2
+ // PR76 v2.94.0 — Agent edit drawer.
3
+ //
4
+ // Opens from the agent detail hero. Lets non-technical operators
5
+ // edit the safe-to-mutate fields without touching YAML directly:
6
+ //
7
+ // - Identity (name, role, tier)
8
+ // - Mental models (primary + secondary lists)
9
+ // - Frameworks list
10
+ // - Expertise domains + depth + years
11
+ // - Communication (tone, vocab level, preferred format, avoid)
12
+ // - Linked personas (multi-select from /api/personas)
13
+ //
14
+ // Save → PUT /api/agents/{id} (atomic YAML write on the backend).
15
+ // NEVER edits: id, department, behavioural DNA (DISC/Enneagram/MBTI/
16
+ // Big-Five). Those are intentionally locked because changing them
17
+ // silently invalidates the agent's identity model.
18
+
19
+ import type { Persona } from '~/types'
20
+
21
+ const props = defineProps<{
22
+ modelValue: boolean
23
+ agent: any
24
+ }>()
25
+
26
+ const emit = defineEmits<{
27
+ (e: 'update:modelValue', value: boolean): void
28
+ (e: 'saved'): void
29
+ }>()
30
+
31
+ const { apiBase, fetchApi } = useApi()
32
+ const toast = useToast()
33
+ const confirmDialog = useConfirmDialog()
34
+
35
+ // Persona list — for the linked_personas multi-select.
36
+ const { data: personasData } = fetchApi<{ personas: Persona[] }>('/api/personas')
37
+ const personaOptions = computed(() =>
38
+ (personasData.value?.personas ?? []).map((p) => ({
39
+ label: p.name + (p.title ? ` — ${p.title}` : ''),
40
+ value: p.id,
41
+ })),
42
+ )
43
+
44
+ interface AgentDraft {
45
+ name: string
46
+ role: string
47
+ tier: number
48
+ mental_models: { primary: string[]; secondary: string[] }
49
+ frameworks: string[]
50
+ expertise_domains: string[]
51
+ expertise_depth: string
52
+ expertise_years: number
53
+ communication: {
54
+ tone: string
55
+ vocabulary_level: string
56
+ preferred_format: string
57
+ language: string
58
+ avoid: string[]
59
+ }
60
+ linked_personas: string[]
61
+ }
62
+
63
+ const draft = ref<AgentDraft | null>(null)
64
+ const saving = ref(false)
65
+ const dirty = ref(false)
66
+
67
+ watch(
68
+ () => [props.modelValue, props.agent] as const,
69
+ ([open, agent]) => {
70
+ if (open && agent) {
71
+ draft.value = {
72
+ name: agent.name ?? '',
73
+ role: agent.role ?? '',
74
+ tier: agent.tier ?? 2,
75
+ mental_models: {
76
+ primary: agent.mental_models?.primary ?? [],
77
+ secondary: agent.mental_models?.secondary ?? [],
78
+ },
79
+ frameworks: agent.frameworks ?? [],
80
+ expertise_domains: agent.expertise_domains ?? [],
81
+ expertise_depth: agent.expertise_depth ?? '',
82
+ expertise_years: agent.expertise_years ?? 0,
83
+ communication: {
84
+ tone: agent.communication?.tone ?? '',
85
+ vocabulary_level: agent.communication?.vocabulary_level ?? '',
86
+ preferred_format: agent.communication?.preferred_format ?? '',
87
+ language: agent.communication?.language ?? '',
88
+ avoid: agent.communication?.avoid ?? [],
89
+ },
90
+ linked_personas: agent.linked_personas ?? [],
91
+ }
92
+ dirty.value = false
93
+ } else if (!open) {
94
+ draft.value = null
95
+ dirty.value = false
96
+ }
97
+ },
98
+ { immediate: true },
99
+ )
100
+
101
+ function markDirty() {
102
+ dirty.value = true
103
+ }
104
+
105
+ function listToCsv(list: string[]): string {
106
+ return (list ?? []).join(', ')
107
+ }
108
+
109
+ function csvToList(value: string): string[] {
110
+ return value.split(',').map((s) => s.trim()).filter(Boolean)
111
+ }
112
+
113
+ async function save() {
114
+ if (!draft.value || !props.agent?.id) return
115
+ saving.value = true
116
+ try {
117
+ const payload = {
118
+ name: draft.value.name,
119
+ role: draft.value.role,
120
+ tier: draft.value.tier,
121
+ mental_models: draft.value.mental_models,
122
+ frameworks: draft.value.frameworks,
123
+ expertise_domains: draft.value.expertise_domains,
124
+ expertise: {
125
+ depth: draft.value.expertise_depth,
126
+ years_equivalent: draft.value.expertise_years,
127
+ },
128
+ communication: draft.value.communication,
129
+ linked_personas: draft.value.linked_personas,
130
+ }
131
+ const res = await $fetch<{
132
+ id: string
133
+ updated: boolean
134
+ yaml_path?: string
135
+ error?: string
136
+ }>(`${apiBase}/api/agents/${props.agent.id}`, {
137
+ method: 'PUT',
138
+ body: payload,
139
+ })
140
+ if (res.error) throw new Error(res.error)
141
+ toast.add({
142
+ title: 'Agent saved',
143
+ description: res.yaml_path
144
+ ? `Wrote ${res.yaml_path.split('/').slice(-3).join('/')}`
145
+ : 'YAML updated',
146
+ color: 'success',
147
+ })
148
+ dirty.value = false
149
+ emit('saved')
150
+ emit('update:modelValue', false)
151
+ } catch (err) {
152
+ toast.add({
153
+ title: 'Save failed',
154
+ description: err instanceof Error ? err.message : 'unknown error',
155
+ color: 'error',
156
+ })
157
+ } finally {
158
+ saving.value = false
159
+ }
160
+ }
161
+
162
+ async function tryClose() {
163
+ if (dirty.value && !saving.value) {
164
+ const ok = await confirmDialog({
165
+ title: 'Discard unsaved edits?',
166
+ description: 'Any changes you made to this agent will be lost.',
167
+ confirmLabel: 'Discard',
168
+ cancelLabel: 'Keep editing',
169
+ variant: 'danger',
170
+ })
171
+ if (!ok) return
172
+ }
173
+ emit('update:modelValue', false)
174
+ }
175
+
176
+ const tierOptions = [
177
+ { label: 'Tier 0 — C-Suite', value: 0 },
178
+ { label: 'Tier 1 — Squad Lead', value: 1 },
179
+ { label: 'Tier 2 — Specialist', value: 2 },
180
+ { label: 'Tier 3 — Support', value: 3 },
181
+ ]
182
+ const depthOptions = [
183
+ { label: 'Intermediate', value: 'intermediate' },
184
+ { label: 'Advanced', value: 'advanced' },
185
+ { label: 'Expert', value: 'expert' },
186
+ { label: 'Master', value: 'master' },
187
+ ]
188
+ const vocabOptions = [
189
+ { label: 'Lay (no jargon)', value: 'lay' },
190
+ { label: 'Specialist (industry terms)', value: 'specialist' },
191
+ { label: 'Expert (research-level)', value: 'expert' },
192
+ ]
193
+ </script>
194
+
195
+ <template>
196
+ <USlideover
197
+ :open="modelValue"
198
+ :ui="{ content: 'max-w-2xl w-full' }"
199
+ @update:open="(v) => v ? null : tryClose()"
200
+ >
201
+ <template #content>
202
+ <UCard
203
+ :ui="{
204
+ root: 'h-full flex flex-col rounded-none',
205
+ body: 'flex-1 overflow-y-auto',
206
+ }"
207
+ >
208
+ <template #header>
209
+ <div class="flex items-center justify-between gap-3">
210
+ <div>
211
+ <h2 class="text-xl font-bold">Edit agent</h2>
212
+ <p class="text-sm text-muted mt-0.5">
213
+ {{ props.agent?.name }}
214
+ <span class="text-xs text-muted/60 ml-1">— {{ props.agent?.id }}</span>
215
+ </p>
216
+ </div>
217
+ <UButton
218
+ icon="i-lucide-x"
219
+ variant="ghost"
220
+ size="sm"
221
+ aria-label="Close"
222
+ @click="tryClose"
223
+ />
224
+ </div>
225
+ </template>
226
+
227
+ <div v-if="draft" class="space-y-6">
228
+ <p class="rounded-lg border border-yellow-500/30 bg-yellow-500/5 p-3 text-xs text-muted">
229
+ <UIcon name="i-lucide-info" class="size-3.5 inline" />
230
+ Behavioural DNA (DISC, Enneagram, MBTI, Big Five) is locked here
231
+ on purpose — changing it silently invalidates the agent's
232
+ identity model. Edit it directly in the YAML file when truly
233
+ needed.
234
+ </p>
235
+
236
+ <section class="space-y-3">
237
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Identity</h3>
238
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
239
+ <UFormField label="Name">
240
+ <UInput v-model="draft.name" class="w-full" @update:model-value="markDirty" />
241
+ </UFormField>
242
+ <UFormField label="Role">
243
+ <UInput v-model="draft.role" class="w-full" @update:model-value="markDirty" />
244
+ </UFormField>
245
+ <UFormField label="Tier">
246
+ <USelect
247
+ v-model="draft.tier"
248
+ :items="tierOptions"
249
+ class="w-full"
250
+ @update:model-value="markDirty"
251
+ />
252
+ </UFormField>
253
+ </div>
254
+ </section>
255
+
256
+ <section class="space-y-3">
257
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Mental models</h3>
258
+ <UFormField label="Primary" help="comma-separated">
259
+ <UInput
260
+ :model-value="listToCsv(draft.mental_models.primary)"
261
+ @update:model-value="(v: string) => { if (draft) { draft.mental_models.primary = csvToList(v); markDirty() } }"
262
+ class="w-full"
263
+ />
264
+ </UFormField>
265
+ <UFormField label="Secondary" help="comma-separated">
266
+ <UInput
267
+ :model-value="listToCsv(draft.mental_models.secondary)"
268
+ @update:model-value="(v: string) => { if (draft) { draft.mental_models.secondary = csvToList(v); markDirty() } }"
269
+ class="w-full"
270
+ />
271
+ </UFormField>
272
+ </section>
273
+
274
+ <section class="space-y-3">
275
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Expertise</h3>
276
+ <UFormField label="Domains" help="comma-separated">
277
+ <UInput
278
+ :model-value="listToCsv(draft.expertise_domains)"
279
+ @update:model-value="(v: string) => { if (draft) { draft.expertise_domains = csvToList(v); markDirty() } }"
280
+ class="w-full"
281
+ />
282
+ </UFormField>
283
+ <UFormField label="Frameworks" help="comma-separated">
284
+ <UInput
285
+ :model-value="listToCsv(draft.frameworks)"
286
+ @update:model-value="(v: string) => { if (draft) { draft.frameworks = csvToList(v); markDirty() } }"
287
+ class="w-full"
288
+ />
289
+ </UFormField>
290
+ <div class="grid grid-cols-2 gap-3">
291
+ <UFormField label="Depth">
292
+ <USelect
293
+ v-model="draft.expertise_depth"
294
+ :items="depthOptions"
295
+ class="w-full"
296
+ @update:model-value="markDirty"
297
+ />
298
+ </UFormField>
299
+ <UFormField label="Years (equivalent)">
300
+ <UInput
301
+ v-model.number="draft.expertise_years"
302
+ type="number"
303
+ :min="0"
304
+ :max="60"
305
+ class="w-full"
306
+ @update:model-value="markDirty"
307
+ />
308
+ </UFormField>
309
+ </div>
310
+ </section>
311
+
312
+ <section class="space-y-3">
313
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Communication</h3>
314
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
315
+ <UFormField label="Tone">
316
+ <UInput v-model="draft.communication.tone" class="w-full" @update:model-value="markDirty" />
317
+ </UFormField>
318
+ <UFormField label="Vocabulary level">
319
+ <USelect
320
+ v-model="draft.communication.vocabulary_level"
321
+ :items="vocabOptions"
322
+ class="w-full"
323
+ @update:model-value="markDirty"
324
+ />
325
+ </UFormField>
326
+ <UFormField label="Preferred format">
327
+ <UInput v-model="draft.communication.preferred_format" class="w-full" @update:model-value="markDirty" />
328
+ </UFormField>
329
+ <UFormField label="Language">
330
+ <UInput v-model="draft.communication.language" placeholder="en, pt" class="w-full" @update:model-value="markDirty" />
331
+ </UFormField>
332
+ </div>
333
+ <UFormField label="Avoid (phrases)" help="comma-separated">
334
+ <UInput
335
+ :model-value="listToCsv(draft.communication.avoid)"
336
+ @update:model-value="(v: string) => { if (draft) { draft.communication.avoid = csvToList(v); markDirty() } }"
337
+ class="w-full"
338
+ />
339
+ </UFormField>
340
+ </section>
341
+
342
+ <section class="space-y-3">
343
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">
344
+ Linked personas
345
+ <span class="ml-2 text-[10px] font-normal text-muted normal-case tracking-normal">
346
+ — agent draws from these persona profiles
347
+ </span>
348
+ </h3>
349
+ <USelectMenu
350
+ v-model="draft.linked_personas"
351
+ :items="personaOptions"
352
+ value-key="value"
353
+ multiple
354
+ placeholder="Select personas to link"
355
+ class="w-full"
356
+ @update:model-value="markDirty"
357
+ />
358
+ <p class="text-xs text-muted">
359
+ {{ draft.linked_personas.length }} linked.
360
+ Personas come from the Persona library (auto-synced with your
361
+ Obsidian vault).
362
+ </p>
363
+ </section>
364
+ </div>
365
+
366
+ <template #footer>
367
+ <div class="flex items-center justify-between gap-2">
368
+ <span v-if="dirty" class="text-xs text-yellow-400">
369
+ <UIcon name="i-lucide-circle-dot" class="size-3 inline" />
370
+ Unsaved changes
371
+ </span>
372
+ <span v-else class="text-xs text-muted">No changes</span>
373
+ <div class="flex gap-2">
374
+ <UButton label="Cancel" variant="ghost" :disabled="saving" @click="tryClose" />
375
+ <UButton
376
+ label="Save"
377
+ icon="i-lucide-check"
378
+ :loading="saving"
379
+ :disabled="!dirty"
380
+ @click="save"
381
+ />
382
+ </div>
383
+ </div>
384
+ </template>
385
+ </UCard>
386
+ </template>
387
+ </USlideover>
388
+ </template>
@@ -0,0 +1,61 @@
1
+ <script setup lang="ts">
2
+ // PR75 v2.93.0 — canonical confirm dialog (Nuxt UI v4 pattern).
3
+ //
4
+ // Replaces native window.confirm() calls. Driven by useConfirmDialog()
5
+ // composable, which itself uses useOverlay() to mount this component
6
+ // imperatively. Emits a boolean on close: true = confirm,
7
+ // false = cancel.
8
+ //
9
+ // Per the Nuxt UI v4 docs (https://ui.nuxt.com/docs/composables/use-overlay).
10
+
11
+ interface ConfirmDialogProps {
12
+ title?: string
13
+ description?: string
14
+ confirmLabel?: string
15
+ cancelLabel?: string
16
+ /**
17
+ * Display variant for the confirm button. Use 'error' for destructive
18
+ * actions (delete, etc.) so the dialog visually warns the operator.
19
+ */
20
+ variant?: 'default' | 'danger'
21
+ }
22
+
23
+ const props = withDefaults(defineProps<ConfirmDialogProps>(), {
24
+ title: 'Confirm action',
25
+ description: '',
26
+ confirmLabel: 'Confirm',
27
+ cancelLabel: 'Cancel',
28
+ variant: 'default',
29
+ })
30
+
31
+ const emits = defineEmits<{
32
+ close: [value: boolean]
33
+ }>()
34
+
35
+ const confirmColor = computed(() =>
36
+ props.variant === 'danger' ? 'error' : 'primary',
37
+ )
38
+ </script>
39
+
40
+ <template>
41
+ <UModal
42
+ :title="title"
43
+ :description="description"
44
+ :dismissible="false"
45
+ :ui="{ footer: 'justify-end' }"
46
+ >
47
+ <template #footer>
48
+ <UButton
49
+ :label="cancelLabel"
50
+ color="neutral"
51
+ variant="outline"
52
+ @click="emits('close', false)"
53
+ />
54
+ <UButton
55
+ :label="confirmLabel"
56
+ :color="confirmColor"
57
+ @click="emits('close', true)"
58
+ />
59
+ </template>
60
+ </UModal>
61
+ </template>
@@ -25,6 +25,7 @@ const emit = defineEmits<{
25
25
 
26
26
  const { apiBase } = useApi()
27
27
  const toast = useToast()
28
+ const confirmDialog = useConfirmDialog()
28
29
 
29
30
  const detail = ref<DetailResponse | null>(null)
30
31
  const editing = ref(false)
@@ -117,12 +118,14 @@ async function saveEdit() {
117
118
 
118
119
  async function deletePersona() {
119
120
  if (!props.personaId) return
120
- if (typeof window === 'undefined') return
121
- const ok = window.confirm(
122
- `Delete persona "${detail.value?.name}"?\n\n`
123
- + 'This removes it from the JSON store. The Obsidian file (if any) '
124
- + 'is left in place — delete manually from Obsidian if you want it gone.',
125
- )
121
+ const ok = await confirmDialog({
122
+ title: `Delete persona "${detail.value?.name ?? 'Unknown'}"?`,
123
+ description:
124
+ 'Removes it from the JSON store. The Obsidian file (if any) is '
125
+ + 'left in place — delete manually from Obsidian if you want it gone.',
126
+ confirmLabel: 'Delete persona',
127
+ variant: 'danger',
128
+ })
126
129
  if (!ok) return
127
130
  deleting.value = true
128
131
  try {
@@ -147,12 +150,16 @@ async function deletePersona() {
147
150
  }
148
151
  }
149
152
 
150
- function closeDrawer() {
153
+ async function closeDrawer() {
151
154
  if (editing.value && !saving.value) {
152
- if (typeof window !== 'undefined'
153
- && !window.confirm('Discard unsaved edits?')) {
154
- return
155
- }
155
+ const ok = await confirmDialog({
156
+ title: 'Discard unsaved edits?',
157
+ description: 'Any changes you made will be lost.',
158
+ confirmLabel: 'Discard',
159
+ cancelLabel: 'Keep editing',
160
+ variant: 'danger',
161
+ })
162
+ if (!ok) return
156
163
  }
157
164
  cancelEdit()
158
165
  emit('update:modelValue', false)
@@ -0,0 +1,38 @@
1
+ // PR75 v2.93.0 — confirm-dialog composable.
2
+ //
3
+ // Async wrapper around the canonical Nuxt UI v4 useOverlay pattern.
4
+ // Replaces every window.confirm() call across the dashboard.
5
+ //
6
+ // Usage:
7
+ // const confirm = useConfirmDialog()
8
+ // const ok = await confirm({
9
+ // title: 'Delete persona',
10
+ // description: 'This removes the persona from the JSON store.',
11
+ // confirmLabel: 'Delete',
12
+ // variant: 'danger',
13
+ // })
14
+ // if (ok) { ... }
15
+
16
+ import { ConfirmDialog } from '#components'
17
+
18
+ export interface ConfirmDialogOptions {
19
+ title: string
20
+ description?: string
21
+ confirmLabel?: string
22
+ cancelLabel?: string
23
+ variant?: 'default' | 'danger'
24
+ }
25
+
26
+ export const useConfirmDialog = () => {
27
+ const overlay = useOverlay()
28
+
29
+ return async (options: ConfirmDialogOptions): Promise<boolean> => {
30
+ const modal = overlay.create(ConfirmDialog, {
31
+ destroyOnClose: true,
32
+ props: options,
33
+ })
34
+
35
+ const result = await modal.open()
36
+ return result === true
37
+ }
38
+ }
@@ -1,9 +1,42 @@
1
1
  <script setup lang="ts">
2
+ // PR76 v2.94.0 — Agent detail modernization.
3
+ // Fixes:
4
+ // - UTabs now has default-value so DNA opens on entry
5
+ // - Modern hero: department-tinted gradient + initials avatar + stats
6
+ // - Activity stats row pulled from PR69 /api/agents/activity
7
+ // - Edit toggle wired to AgentEditDrawer (PUT /api/agents/{id})
8
+
2
9
  const route = useRoute()
3
10
  const agentId = route.params.id as string
4
11
 
5
12
  const { fetchApi } = useApi()
6
- const { data: agent, status, error } = fetchApi<any>(`/api/agents/${agentId}`)
13
+ const { data: agent, status, error, refresh } = fetchApi<any>(`/api/agents/${agentId}`)
14
+
15
+ // Per-department activity (PR69 endpoint) for the stats row.
16
+ interface ActivityRow {
17
+ call_count: number
18
+ total_cost_usd: number | null
19
+ total_tokens_in: number
20
+ total_tokens_out: number
21
+ }
22
+ const { data: activityData } = fetchApi<{
23
+ by_department: Record<string, ActivityRow>
24
+ period: string
25
+ }>('/api/agents/activity?period=week')
26
+ const deptActivity = computed<ActivityRow | null>(() =>
27
+ (activityData.value?.by_department?.[agent.value?.department ?? ''] ?? null),
28
+ )
29
+
30
+ // PR76 — edit drawer state
31
+ const editOpen = ref(false)
32
+
33
+ function openEditor() {
34
+ editOpen.value = true
35
+ }
36
+
37
+ async function onAgentSaved() {
38
+ await refresh()
39
+ }
7
40
 
8
41
  // --- Labels & mappings ---
9
42
 
@@ -94,6 +127,55 @@ const tabs = [
94
127
  { label: 'Authority', value: 'authority', icon: 'i-lucide-shield' },
95
128
  { label: 'Expertise', value: 'expertise', icon: 'i-lucide-award' },
96
129
  ]
130
+
131
+ // PR76 — hero helpers
132
+
133
+ const initials = computed<string>(() => {
134
+ const name = agent.value?.name ?? ''
135
+ if (!name) return '·'
136
+ const parts = name.trim().split(/\s+/)
137
+ if (parts.length === 1) return (parts[0] ?? '').slice(0, 2).toUpperCase()
138
+ return ((parts[0]?.[0] ?? '') + (parts[parts.length - 1]?.[0] ?? '')).toUpperCase()
139
+ })
140
+
141
+ // Per-department gradient hex pair (from + to). Picked once per dept
142
+ // so the same dept always renders the same hero tint.
143
+ const DEPT_GRADIENTS: Record<string, [string, string]> = {
144
+ brand: ['from-fuchsia-500/30', 'to-purple-600/10'],
145
+ marketing: ['from-pink-500/30', 'to-rose-600/10'],
146
+ dev: ['from-blue-500/30', 'to-cyan-600/10'],
147
+ ecom: ['from-amber-500/30', 'to-orange-600/10'],
148
+ finance: ['from-emerald-500/30', 'to-green-600/10'],
149
+ strategy: ['from-indigo-500/30', 'to-violet-600/10'],
150
+ kb: ['from-teal-500/30', 'to-cyan-600/10'],
151
+ ops: ['from-slate-500/30', 'to-gray-600/10'],
152
+ pm: ['from-sky-500/30', 'to-blue-600/10'],
153
+ saas: ['from-violet-500/30', 'to-indigo-600/10'],
154
+ landing: ['from-orange-500/30', 'to-red-600/10'],
155
+ content: ['from-rose-500/30', 'to-pink-600/10'],
156
+ community: ['from-yellow-500/30', 'to-amber-600/10'],
157
+ sales: ['from-red-500/30', 'to-orange-600/10'],
158
+ leadership: ['from-purple-500/30', 'to-pink-600/10'],
159
+ org: ['from-gray-500/30', 'to-slate-600/10'],
160
+ }
161
+
162
+ const heroGradientClasses = computed(() => {
163
+ const dept = agent.value?.department ?? ''
164
+ const [from, to] = DEPT_GRADIENTS[dept] ?? ['from-primary/20', 'to-primary/5']
165
+ return `bg-gradient-to-br ${from} ${to}`
166
+ })
167
+
168
+ function formatCost(value: number | null | undefined): string {
169
+ if (value === null || value === undefined) return 'n/a'
170
+ if (value === 0) return '$0'
171
+ if (value < 0.01) return `$${value.toFixed(4)}`
172
+ return `$${value.toFixed(2)}`
173
+ }
174
+ function formatTokens(n: number): string {
175
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
176
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
177
+ return n.toString()
178
+ }
97
179
  </script>
98
180
 
99
181
  <template>
@@ -136,37 +218,87 @@ const tabs = [
136
218
  </div>
137
219
 
138
220
  <!-- Content -->
139
- <div v-else class="space-y-8 pb-12">
140
- <!-- ===== HEADER SECTION ===== -->
141
- <section class="space-y-3">
142
- <h1 class="text-3xl font-bold tracking-tight">{{ agent.name }}</h1>
143
- <p class="text-lg text-muted">{{ agent.role }}</p>
144
-
145
- <div class="flex flex-wrap items-center gap-2">
146
- <UBadge :label="agent.department" variant="subtle" />
147
- <UBadge
148
- :label="`Tier ${agent.tier} — ${tierLabel[agent.tier] ?? ''}`"
149
- variant="subtle"
150
- :color="(tierColor[agent.tier] ?? 'neutral') as any"
151
- />
152
- <UBadge
153
- v-if="agent.expertise_depth"
154
- :label="agent.expertise_depth"
155
- variant="subtle"
156
- :color="(depthColor[agent.expertise_depth] ?? 'neutral') as any"
157
- />
158
- <UBadge
159
- v-if="agent.expertise_years"
160
- :label="`${agent.expertise_years}y experience`"
161
- variant="outline"
162
- />
221
+ <div v-else class="space-y-6 pb-12">
222
+ <!-- ===== HERO ===== -->
223
+ <section
224
+ class="relative overflow-hidden rounded-2xl border border-default p-6 md:p-8"
225
+ :class="heroGradientClasses"
226
+ >
227
+ <div class="flex items-start gap-5">
228
+ <div class="shrink-0 size-20 rounded-2xl bg-default/80 border border-default flex items-center justify-center shadow-lg backdrop-blur-sm">
229
+ <span class="text-2xl font-bold tracking-tight text-highlighted">{{ initials }}</span>
230
+ </div>
231
+ <div class="flex-1 min-w-0 space-y-2">
232
+ <div class="flex items-start justify-between gap-3 flex-wrap">
233
+ <div class="min-w-0">
234
+ <h1 class="text-3xl md:text-4xl font-bold tracking-tight text-highlighted">
235
+ {{ agent.name }}
236
+ </h1>
237
+ <p class="text-base md:text-lg text-muted mt-0.5">{{ agent.role }}</p>
238
+ </div>
239
+ <UButton
240
+ label="Edit"
241
+ icon="i-lucide-pencil"
242
+ size="sm"
243
+ @click="openEditor"
244
+ />
245
+ </div>
246
+ <div class="flex flex-wrap items-center gap-2 pt-1">
247
+ <UBadge :label="agent.department" variant="subtle" />
248
+ <UBadge
249
+ :label="`Tier ${agent.tier} — ${tierLabel[agent.tier] ?? ''}`"
250
+ variant="subtle"
251
+ :color="(tierColor[agent.tier] ?? 'neutral') as any"
252
+ />
253
+ <UBadge
254
+ v-if="agent.expertise_depth"
255
+ :label="agent.expertise_depth"
256
+ variant="subtle"
257
+ :color="(depthColor[agent.expertise_depth] ?? 'neutral') as any"
258
+ />
259
+ <UBadge
260
+ v-if="agent.expertise_years"
261
+ :label="`${agent.expertise_years}y experience`"
262
+ variant="outline"
263
+ />
264
+ <UBadge v-if="agent.mbti" :label="agent.mbti" variant="soft" size="xs" />
265
+ </div>
266
+ <p class="text-xs text-muted/60 font-mono select-all pt-2">{{ agent.id }}</p>
267
+ </div>
163
268
  </div>
269
+ </section>
164
270
 
165
- <p class="text-xs text-muted/60 font-mono select-all">{{ agent.id }}</p>
271
+ <!-- ===== STATS ROW ===== -->
272
+ <section class="grid grid-cols-2 md:grid-cols-4 gap-3">
273
+ <div class="rounded-xl border border-default p-4 bg-elevated/20">
274
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">7d calls (dept)</p>
275
+ <p class="text-2xl font-bold">{{ deptActivity?.call_count ?? 0 }}</p>
276
+ </div>
277
+ <div class="rounded-xl border border-default p-4 bg-elevated/20">
278
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">7d cost</p>
279
+ <p class="text-2xl font-bold">{{ formatCost(deptActivity?.total_cost_usd) }}</p>
280
+ </div>
281
+ <div class="rounded-xl border border-default p-4 bg-elevated/20">
282
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">Tokens (in/out)</p>
283
+ <p class="text-lg font-semibold">
284
+ {{ formatTokens(deptActivity?.total_tokens_in ?? 0) }} /
285
+ {{ formatTokens(deptActivity?.total_tokens_out ?? 0) }}
286
+ </p>
287
+ </div>
288
+ <div class="rounded-xl border border-default p-4 bg-elevated/20">
289
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">Linked personas</p>
290
+ <p class="text-2xl font-bold">{{ agent.linked_personas?.length ?? 0 }}</p>
291
+ </div>
166
292
  </section>
167
293
 
294
+ <AgentEditDrawer
295
+ v-model="editOpen"
296
+ :agent="agent"
297
+ @saved="onAgentSaved"
298
+ />
299
+
168
300
  <!-- ===== TABS ===== -->
169
- <UTabs :items="tabs" class="w-full">
301
+ <UTabs :items="tabs" default-value="dna" class="w-full">
170
302
  <template #content="{ item }">
171
303
  <!-- ===== TAB: DNA ===== -->
172
304
  <div v-if="item.value === 'dna'" class="space-y-6 mt-6">
@@ -336,14 +336,18 @@ const vectorSearchActive = computed(() =>
336
336
 
337
337
  const deletingSource = ref<string | null>(null)
338
338
 
339
+ const confirmDialog = useConfirmDialog()
340
+
339
341
  async function askDeleteSource(source: string) {
340
342
  if (!source) return
341
- if (typeof window === 'undefined') return
342
- const ok = window.confirm(
343
- `Delete every indexed chunk from this source?\n\n${source}\n\n`
344
- + 'This removes the source from search results but does not delete the original file. '
345
- + 'You can re-ingest the source later if needed.',
346
- )
343
+ const ok = await confirmDialog({
344
+ title: 'Delete every indexed chunk from this source?',
345
+ description:
346
+ `${source}\n\nRemoves the source from search results but does NOT `
347
+ + 'delete the original file. You can re-ingest later if needed.',
348
+ confirmLabel: 'Delete chunks',
349
+ variant: 'danger',
350
+ })
347
351
  if (!ok) return
348
352
  await deleteSource(source)
349
353
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.92.0",
3
+ "version": "2.94.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 = "2.92.0"
3
+ version = "2.94.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"}
@@ -258,12 +258,104 @@ def agent_detail(agent_id: str):
258
258
  expertise = raw.get("expertise", {})
259
259
  base["expertise_depth"] = expertise.get("depth", "")
260
260
  base["expertise_years"] = expertise.get("years_equivalent", 0)
261
+ base["frameworks"] = raw.get("frameworks", [])
262
+ base["expertise_domains"] = raw.get("expertise_domains", [])
263
+ # PR76 v2.94.0 — linked_personas: persona IDs the agent
264
+ # draws from. Empty when not yet edited.
265
+ base["linked_personas"] = raw.get("linked_personas", [])
266
+ base["_yaml_path"] = str(yaml_file)
261
267
  except Exception:
262
268
  pass
263
269
 
264
270
  return base
265
271
 
266
272
 
273
+ @app.put("/api/agents/{agent_id}")
274
+ def agent_update(agent_id: str, body: dict):
275
+ """PR76 v2.94.0 — edit an agent. Updates the YAML file with
276
+ editable fields from body. Preserves untouched fields.
277
+ """
278
+ if not isinstance(body, dict):
279
+ return {"error": "body must be an object"}
280
+ agents = _load_agents()
281
+ base = None
282
+ for a in agents:
283
+ if a.get("id") == agent_id:
284
+ base = dict(a)
285
+ break
286
+ if not base:
287
+ return {"error": "Agent not found"}
288
+ yaml_file = ARKAOS_ROOT / base.get("file", "")
289
+ if not yaml_file.exists():
290
+ return {"error": "Agent YAML file missing on disk"}
291
+ try:
292
+ import yaml as _yaml
293
+ except ImportError:
294
+ return {"error": "PyYAML unavailable"}
295
+ try:
296
+ raw = _yaml.safe_load(yaml_file.read_text(encoding="utf-8")) or {}
297
+ except Exception as exc:
298
+ return {"error": f"YAML parse failed: {exc}"}
299
+ if not isinstance(raw, dict):
300
+ return {"error": "agent YAML is not a mapping"}
301
+
302
+ for top_key in ("name", "role"):
303
+ if top_key in body and isinstance(body[top_key], str):
304
+ raw[top_key] = body[top_key]
305
+ if "tier" in body:
306
+ try:
307
+ raw["tier"] = int(body["tier"])
308
+ except (TypeError, ValueError):
309
+ pass
310
+ if "mental_models" in body and isinstance(body["mental_models"], dict):
311
+ mm = raw.setdefault("mental_models", {}) or {}
312
+ for sub in ("primary", "secondary"):
313
+ if sub in body["mental_models"]:
314
+ mm[sub] = _agent_str_list(body["mental_models"][sub])
315
+ raw["mental_models"] = mm
316
+ if "frameworks" in body:
317
+ raw["frameworks"] = _agent_str_list(body["frameworks"])
318
+ if "expertise_domains" in body:
319
+ raw["expertise_domains"] = _agent_str_list(body["expertise_domains"])
320
+ if "expertise" in body and isinstance(body["expertise"], dict):
321
+ expertise = raw.setdefault("expertise", {}) or {}
322
+ if "depth" in body["expertise"]:
323
+ expertise["depth"] = str(body["expertise"]["depth"])
324
+ if "years_equivalent" in body["expertise"]:
325
+ try:
326
+ expertise["years_equivalent"] = int(body["expertise"]["years_equivalent"])
327
+ except (TypeError, ValueError):
328
+ pass
329
+ raw["expertise"] = expertise
330
+ if "communication" in body and isinstance(body["communication"], dict):
331
+ comm = raw.setdefault("communication", {}) or {}
332
+ for key in ("tone", "vocabulary_level", "preferred_format", "language"):
333
+ if key in body["communication"]:
334
+ comm[key] = str(body["communication"][key])
335
+ if "avoid" in body["communication"]:
336
+ comm["avoid"] = _agent_str_list(body["communication"]["avoid"])
337
+ raw["communication"] = comm
338
+ if "linked_personas" in body:
339
+ raw["linked_personas"] = _agent_str_list(body["linked_personas"])
340
+
341
+ try:
342
+ tmp = yaml_file.with_suffix(yaml_file.suffix + ".tmp")
343
+ tmp.write_text(
344
+ _yaml.safe_dump(raw, sort_keys=False, allow_unicode=True, default_flow_style=False),
345
+ encoding="utf-8",
346
+ )
347
+ tmp.replace(yaml_file)
348
+ except OSError as exc:
349
+ return {"error": f"write failed: {exc}"}
350
+ return {"id": agent_id, "updated": True, "yaml_path": str(yaml_file)}
351
+
352
+
353
+ def _agent_str_list(value) -> list[str]:
354
+ if not isinstance(value, list):
355
+ return []
356
+ return [str(item) for item in value if isinstance(item, (str, int, float))]
357
+
358
+
267
359
  @app.get("/api/commands")
268
360
  def commands(dept: Optional[str] = Query(None), q: Optional[str] = Query(None)):
269
361
  data = _load_commands()