arkaos 2.93.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.93.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>
@@ -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">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.93.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.93.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()