arkaos 2.91.0 → 2.93.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.91.0
1
+ 2.93.0
@@ -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>
@@ -0,0 +1,515 @@
1
+ <script setup lang="ts">
2
+ // PR74 v2.92.0 — Persona detail + edit drawer.
3
+ //
4
+ // Click a persona card on the list → this drawer opens with every
5
+ // field visible. Toggle to Edit mode to mutate any field, then save
6
+ // via PUT /api/personas/{id} (writes to both JSON store + Obsidian).
7
+
8
+ import type { Persona } from '~/types'
9
+
10
+ interface DetailResponse extends Persona {
11
+ _source_store?: 'obsidian' | 'json'
12
+ _obsidian_path?: string
13
+ }
14
+
15
+ const props = defineProps<{
16
+ modelValue: boolean
17
+ personaId: string | null
18
+ }>()
19
+
20
+ const emit = defineEmits<{
21
+ (e: 'update:modelValue', value: boolean): void
22
+ (e: 'saved', persona: Persona): void
23
+ (e: 'deleted', personaId: string): void
24
+ }>()
25
+
26
+ const { apiBase } = useApi()
27
+ const toast = useToast()
28
+ const confirmDialog = useConfirmDialog()
29
+
30
+ const detail = ref<DetailResponse | null>(null)
31
+ const editing = ref(false)
32
+ const draft = ref<Persona | null>(null)
33
+ const saving = ref(false)
34
+ const deleting = ref(false)
35
+ const loading = ref(false)
36
+ const loadError = ref<string | null>(null)
37
+
38
+ watch(
39
+ () => [props.modelValue, props.personaId] as const,
40
+ async ([open, id]) => {
41
+ if (!open || !id) {
42
+ detail.value = null
43
+ editing.value = false
44
+ draft.value = null
45
+ loadError.value = null
46
+ return
47
+ }
48
+ await loadDetail(id)
49
+ },
50
+ )
51
+
52
+ async function loadDetail(id: string) {
53
+ loading.value = true
54
+ loadError.value = null
55
+ try {
56
+ const data = await $fetch<DetailResponse | { error: string }>(
57
+ `${apiBase}/api/personas/${id}`,
58
+ )
59
+ if ('error' in data && data.error) {
60
+ loadError.value = data.error
61
+ detail.value = null
62
+ } else {
63
+ detail.value = data as DetailResponse
64
+ }
65
+ } catch (err) {
66
+ loadError.value = err instanceof Error ? err.message : 'unknown error'
67
+ } finally {
68
+ loading.value = false
69
+ }
70
+ }
71
+
72
+ function startEdit() {
73
+ if (!detail.value) return
74
+ draft.value = JSON.parse(JSON.stringify(detail.value)) as Persona
75
+ editing.value = true
76
+ }
77
+
78
+ function cancelEdit() {
79
+ draft.value = null
80
+ editing.value = false
81
+ }
82
+
83
+ async function saveEdit() {
84
+ if (!draft.value || !props.personaId) return
85
+ saving.value = true
86
+ try {
87
+ const res = await $fetch<{
88
+ id: string
89
+ updated: boolean
90
+ json_written: boolean
91
+ obsidian_path: string | null
92
+ error?: string
93
+ }>(`${apiBase}/api/personas/${props.personaId}`, {
94
+ method: 'PUT',
95
+ body: draft.value,
96
+ })
97
+ if (res.error) throw new Error(res.error)
98
+ toast.add({
99
+ title: 'Persona saved',
100
+ description: res.obsidian_path
101
+ ? `Wrote ${res.obsidian_path.split('/').slice(-2).join('/')}`
102
+ : 'Saved to JSON store',
103
+ color: 'success',
104
+ })
105
+ emit('saved', draft.value)
106
+ detail.value = { ...detail.value, ...draft.value } as DetailResponse
107
+ editing.value = false
108
+ } catch (err) {
109
+ toast.add({
110
+ title: 'Save failed',
111
+ description: err instanceof Error ? err.message : 'unknown error',
112
+ color: 'error',
113
+ })
114
+ } finally {
115
+ saving.value = false
116
+ }
117
+ }
118
+
119
+ async function deletePersona() {
120
+ if (!props.personaId) return
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
+ })
129
+ if (!ok) return
130
+ deleting.value = true
131
+ try {
132
+ await $fetch(`${apiBase}/api/personas/${props.personaId}`, {
133
+ method: 'DELETE',
134
+ })
135
+ toast.add({
136
+ title: 'Persona deleted',
137
+ description: detail.value?.name ?? '',
138
+ color: 'success',
139
+ })
140
+ emit('deleted', props.personaId)
141
+ emit('update:modelValue', false)
142
+ } catch (err) {
143
+ toast.add({
144
+ title: 'Delete failed',
145
+ description: err instanceof Error ? err.message : 'unknown error',
146
+ color: 'error',
147
+ })
148
+ } finally {
149
+ deleting.value = false
150
+ }
151
+ }
152
+
153
+ async function closeDrawer() {
154
+ if (editing.value && !saving.value) {
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
163
+ }
164
+ cancelEdit()
165
+ emit('update:modelValue', false)
166
+ }
167
+
168
+ function listToCsv(list: string[] | undefined): string {
169
+ return (list ?? []).join(', ')
170
+ }
171
+
172
+ function csvToList(value: string): string[] {
173
+ return value.split(',').map((s) => s.trim()).filter(Boolean)
174
+ }
175
+
176
+ const mbtiOptions = [
177
+ 'INTJ', 'INTP', 'ENTJ', 'ENTP',
178
+ 'INFJ', 'INFP', 'ENFJ', 'ENFP',
179
+ 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ',
180
+ 'ISTP', 'ISFP', 'ESTP', 'ESFP',
181
+ ].map((t) => ({ label: t, value: t }))
182
+
183
+ const discOptions = [
184
+ { label: 'D — Dominance', value: 'D' },
185
+ { label: 'I — Influence', value: 'I' },
186
+ { label: 'S — Steadiness', value: 'S' },
187
+ { label: 'C — Conscientiousness', value: 'C' },
188
+ ]
189
+
190
+ const vocabOptions = [
191
+ { label: 'Lay (no jargon)', value: 'lay' },
192
+ { label: 'Specialist (industry terms)', value: 'specialist' },
193
+ { label: 'Expert (research-level)', value: 'expert' },
194
+ ]
195
+ </script>
196
+
197
+ <template>
198
+ <USlideover
199
+ :open="modelValue"
200
+ :ui="{ content: 'max-w-2xl w-full' }"
201
+ @update:open="(v) => v ? null : closeDrawer()"
202
+ >
203
+ <template #content>
204
+ <UCard
205
+ :ui="{
206
+ root: 'h-full flex flex-col rounded-none',
207
+ body: 'flex-1 overflow-y-auto',
208
+ }"
209
+ >
210
+ <template #header>
211
+ <div class="flex items-start justify-between gap-3">
212
+ <div class="min-w-0">
213
+ <h2 class="text-xl font-bold truncate">
214
+ {{ detail?.name ?? 'Persona' }}
215
+ </h2>
216
+ <div class="flex items-center gap-2 mt-1 flex-wrap">
217
+ <UBadge
218
+ v-if="detail?._source_store === 'obsidian'"
219
+ label="From Obsidian"
220
+ icon="i-lucide-file-text"
221
+ color="primary"
222
+ variant="subtle"
223
+ size="xs"
224
+ />
225
+ <UBadge
226
+ v-else-if="detail?._source_store === 'json'"
227
+ label="JSON store"
228
+ variant="outline"
229
+ size="xs"
230
+ />
231
+ <span
232
+ v-if="detail?._obsidian_path"
233
+ class="text-xs text-muted font-mono truncate"
234
+ :title="detail._obsidian_path"
235
+ >
236
+ {{ detail._obsidian_path.split('/').slice(-2).join('/') }}
237
+ </span>
238
+ </div>
239
+ </div>
240
+ <div class="flex items-center gap-1 shrink-0">
241
+ <UButton
242
+ v-if="!editing"
243
+ icon="i-lucide-pencil"
244
+ variant="ghost"
245
+ size="sm"
246
+ aria-label="Edit persona"
247
+ @click="startEdit"
248
+ />
249
+ <UButton
250
+ v-if="!editing"
251
+ icon="i-lucide-trash-2"
252
+ color="error"
253
+ variant="ghost"
254
+ size="sm"
255
+ :loading="deleting"
256
+ aria-label="Delete persona"
257
+ @click="deletePersona"
258
+ />
259
+ <UButton
260
+ icon="i-lucide-x"
261
+ variant="ghost"
262
+ size="sm"
263
+ aria-label="Close"
264
+ @click="closeDrawer"
265
+ />
266
+ </div>
267
+ </div>
268
+ </template>
269
+
270
+ <div v-if="loading" class="flex items-center justify-center py-12">
271
+ <UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-muted" />
272
+ </div>
273
+
274
+ <div v-else-if="loadError" class="flex flex-col items-center justify-center gap-3 py-12">
275
+ <UIcon name="i-lucide-alert-triangle" class="size-10 text-red-500" />
276
+ <p class="text-sm text-muted">{{ loadError }}</p>
277
+ </div>
278
+
279
+ <div v-else-if="detail && !editing" class="space-y-6">
280
+ <p v-if="detail.tagline" class="text-base italic text-muted">
281
+ "{{ detail.tagline }}"
282
+ </p>
283
+
284
+ <section v-if="detail.title || detail.source">
285
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">Identity</h3>
286
+ <dl class="grid grid-cols-3 gap-2 text-sm">
287
+ <dt class="text-muted">Title</dt>
288
+ <dd class="col-span-2">{{ detail.title || '—' }}</dd>
289
+ <dt class="text-muted">Source</dt>
290
+ <dd class="col-span-2 font-mono text-xs">{{ detail.source || '—' }}</dd>
291
+ </dl>
292
+ </section>
293
+
294
+ <section>
295
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">Behavioural DNA</h3>
296
+ <dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
297
+ <dt class="text-muted">MBTI</dt>
298
+ <dd>{{ detail.mbti || '—' }}</dd>
299
+ <dt class="text-muted">DISC</dt>
300
+ <dd>
301
+ {{ detail.disc?.primary || '—' }}{{ detail.disc?.secondary ? `/${detail.disc.secondary}` : '' }}
302
+ </dd>
303
+ <dt class="text-muted">Enneagram</dt>
304
+ <dd>
305
+ {{ detail.enneagram?.type ?? '—' }}w{{ detail.enneagram?.wing ?? '?' }}
306
+ </dd>
307
+ </dl>
308
+ <div class="mt-3 space-y-1.5">
309
+ <div
310
+ v-for="trait in ([
311
+ ['Openness', detail.big_five?.openness ?? 0],
312
+ ['Conscientiousness', detail.big_five?.conscientiousness ?? 0],
313
+ ['Extraversion', detail.big_five?.extraversion ?? 0],
314
+ ['Agreeableness', detail.big_five?.agreeableness ?? 0],
315
+ ['Neuroticism', detail.big_five?.neuroticism ?? 0],
316
+ ] as Array<[string, number]>)"
317
+ :key="trait[0]"
318
+ class="flex items-center gap-3"
319
+ >
320
+ <span class="text-xs text-muted w-36 shrink-0">{{ trait[0] }}</span>
321
+ <div class="flex-1 h-2 rounded-full bg-muted/15 overflow-hidden">
322
+ <div class="h-2 rounded-full bg-primary" :style="{ width: `${trait[1]}%` }" />
323
+ </div>
324
+ <span class="text-xs font-mono w-10 text-right">{{ trait[1] }}</span>
325
+ </div>
326
+ </div>
327
+ </section>
328
+
329
+ <section v-if="detail.mental_models?.length">
330
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
331
+ Mental models ({{ detail.mental_models.length }})
332
+ </h3>
333
+ <div class="flex flex-wrap gap-1.5">
334
+ <UBadge
335
+ v-for="m in detail.mental_models"
336
+ :key="m"
337
+ :label="m"
338
+ variant="outline"
339
+ size="xs"
340
+ />
341
+ </div>
342
+ </section>
343
+
344
+ <section v-if="detail.expertise_domains?.length">
345
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
346
+ Expertise ({{ detail.expertise_domains.length }})
347
+ </h3>
348
+ <div class="flex flex-wrap gap-1.5">
349
+ <UBadge
350
+ v-for="e in detail.expertise_domains"
351
+ :key="e"
352
+ :label="e"
353
+ variant="soft"
354
+ size="xs"
355
+ />
356
+ </div>
357
+ </section>
358
+
359
+ <section v-if="detail.frameworks?.length">
360
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
361
+ Frameworks ({{ detail.frameworks.length }})
362
+ </h3>
363
+ <div class="flex flex-wrap gap-1.5">
364
+ <UBadge
365
+ v-for="f in detail.frameworks"
366
+ :key="f"
367
+ :label="f"
368
+ variant="outline"
369
+ size="xs"
370
+ />
371
+ </div>
372
+ </section>
373
+
374
+ <section v-if="detail.key_quotes?.length">
375
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
376
+ Key quotes ({{ detail.key_quotes.length }})
377
+ </h3>
378
+ <ul class="space-y-2">
379
+ <li
380
+ v-for="q in detail.key_quotes"
381
+ :key="q"
382
+ class="text-sm italic text-muted border-l-2 border-primary/30 pl-3"
383
+ >
384
+ "{{ q }}"
385
+ </li>
386
+ </ul>
387
+ </section>
388
+
389
+ <section v-if="detail.communication">
390
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">Communication</h3>
391
+ <dl class="grid grid-cols-3 gap-2 text-sm">
392
+ <dt class="text-muted">Tone</dt>
393
+ <dd class="col-span-2">{{ detail.communication.tone || '—' }}</dd>
394
+ <dt class="text-muted">Vocabulary</dt>
395
+ <dd class="col-span-2">{{ detail.communication.vocabulary_level || '—' }}</dd>
396
+ </dl>
397
+ </section>
398
+ </div>
399
+
400
+ <div v-else-if="draft" class="space-y-5">
401
+ <section class="space-y-3">
402
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Identity</h3>
403
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
404
+ <UFormField label="Name" required>
405
+ <UInput v-model="draft.name" class="w-full" />
406
+ </UFormField>
407
+ <UFormField label="Title">
408
+ <UInput v-model="draft.title" class="w-full" />
409
+ </UFormField>
410
+ <UFormField label="Source">
411
+ <UInput v-model="draft.source" class="w-full" />
412
+ </UFormField>
413
+ <UFormField label="Tagline">
414
+ <UInput v-model="draft.tagline" class="w-full" />
415
+ </UFormField>
416
+ </div>
417
+ </section>
418
+
419
+ <section class="space-y-3">
420
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Behavioural DNA</h3>
421
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-3">
422
+ <UFormField label="MBTI">
423
+ <USelect v-model="draft.mbti" :items="mbtiOptions" class="w-full" />
424
+ </UFormField>
425
+ <UFormField label="DISC primary">
426
+ <USelect v-model="draft.disc.primary" :items="discOptions" class="w-full" />
427
+ </UFormField>
428
+ <UFormField label="Enneagram">
429
+ <UInput v-model.number="draft.enneagram.type" type="number" :min="1" :max="9" class="w-full" />
430
+ </UFormField>
431
+ <UFormField label="Wing">
432
+ <UInput v-model.number="draft.enneagram.wing" type="number" :min="1" :max="9" class="w-full" />
433
+ </UFormField>
434
+ </div>
435
+ <div class="space-y-2">
436
+ <div
437
+ v-for="key in (['openness', 'conscientiousness', 'extraversion', 'agreeableness', 'neuroticism'] as const)"
438
+ :key="key"
439
+ class="flex items-center gap-3"
440
+ >
441
+ <label class="text-xs text-muted w-36 shrink-0 capitalize">{{ key }}</label>
442
+ <UInput
443
+ v-model.number="draft.big_five[key]"
444
+ type="number"
445
+ :min="0"
446
+ :max="100"
447
+ class="w-20"
448
+ />
449
+ <input
450
+ v-model.number="draft.big_five[key]"
451
+ type="range"
452
+ :min="0"
453
+ :max="100"
454
+ class="flex-1"
455
+ />
456
+ </div>
457
+ </div>
458
+ </section>
459
+
460
+ <section class="space-y-3">
461
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Knowledge</h3>
462
+ <UFormField label="Mental models" help="comma-separated">
463
+ <UInput
464
+ :model-value="listToCsv(draft.mental_models)"
465
+ @update:model-value="(v: string) => draft && (draft.mental_models = csvToList(v))"
466
+ class="w-full"
467
+ />
468
+ </UFormField>
469
+ <UFormField label="Expertise domains" help="comma-separated">
470
+ <UInput
471
+ :model-value="listToCsv(draft.expertise_domains)"
472
+ @update:model-value="(v: string) => draft && (draft.expertise_domains = csvToList(v))"
473
+ class="w-full"
474
+ />
475
+ </UFormField>
476
+ <UFormField label="Frameworks" help="comma-separated">
477
+ <UInput
478
+ :model-value="listToCsv(draft.frameworks)"
479
+ @update:model-value="(v: string) => draft && (draft.frameworks = csvToList(v))"
480
+ class="w-full"
481
+ />
482
+ </UFormField>
483
+ </section>
484
+
485
+ <section class="space-y-3">
486
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Communication</h3>
487
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
488
+ <UFormField label="Tone">
489
+ <UInput v-model="draft.communication.tone" class="w-full" />
490
+ </UFormField>
491
+ <UFormField label="Vocabulary level">
492
+ <USelect v-model="draft.communication.vocabulary_level" :items="vocabOptions" class="w-full" />
493
+ </UFormField>
494
+ </div>
495
+ </section>
496
+ </div>
497
+
498
+ <template #footer>
499
+ <div v-if="editing" class="flex justify-end gap-2">
500
+ <UButton label="Cancel" variant="ghost" :disabled="saving" @click="cancelEdit" />
501
+ <UButton
502
+ label="Save"
503
+ icon="i-lucide-check"
504
+ :loading="saving"
505
+ @click="saveEdit"
506
+ />
507
+ </div>
508
+ <p v-else class="text-xs text-muted text-right">
509
+ Click ✏️ to edit. Saves to JSON store + Obsidian vault when configured.
510
+ </p>
511
+ </template>
512
+ </UCard>
513
+ </template>
514
+ </USlideover>
515
+ </template>
@@ -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
+ }
@@ -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
  }
@@ -9,6 +9,23 @@ const { data, status, error, refresh } = fetchApi<{ personas: Persona[]; total:
9
9
 
10
10
  const personas = computed(() => data.value?.personas ?? [])
11
11
 
12
+ // PR74 v2.92.0 — detail/edit drawer state
13
+ const detailOpen = ref(false)
14
+ const detailPersonaId = ref<string | null>(null)
15
+
16
+ function openDetail(persona: Persona) {
17
+ detailPersonaId.value = persona.id
18
+ detailOpen.value = true
19
+ }
20
+
21
+ async function onDetailSaved() {
22
+ await refresh()
23
+ }
24
+
25
+ async function onDetailDeleted(_id: string) {
26
+ await refresh()
27
+ }
28
+
12
29
  // --- Creation mode ---
13
30
  // PR62 v2.79.0 — three modes: list (default), wizard (AI builder), manual.
14
31
  // The wizard is the new primary path; manual stays as fallback for
@@ -517,12 +534,24 @@ function discColor(disc: string): string {
517
534
  />
518
535
  </div>
519
536
 
537
+ <!-- PR74 v2.92.0 — detail/edit drawer -->
538
+ <PersonaDetailDrawer
539
+ v-model="detailOpen"
540
+ :persona-id="detailPersonaId"
541
+ @saved="onDetailSaved"
542
+ @deleted="onDetailDeleted"
543
+ />
544
+
520
545
  <!-- Personas Grid -->
521
546
  <div v-if="personas.length" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
522
547
  <UCard
523
548
  v-for="persona in personas"
524
549
  :key="persona.id"
525
- class="group flex flex-col"
550
+ class="group flex flex-col cursor-pointer hover:border-primary/40 transition-colors"
551
+ role="button"
552
+ tabindex="0"
553
+ @click="openDetail(persona)"
554
+ @keydown.enter="openDetail(persona)"
526
555
  >
527
556
  <div class="flex flex-col gap-3 flex-1">
528
557
  <!-- Header -->
@@ -583,7 +612,7 @@ function discColor(disc: string): string {
583
612
  </div>
584
613
 
585
614
  <!-- Actions -->
586
- <div class="pt-3 mt-auto border-t border-default space-y-3">
615
+ <div class="pt-3 mt-auto border-t border-default space-y-3" @click.stop>
587
616
  <div class="flex gap-2">
588
617
  <UButton
589
618
  label="Clone to Agent"
@@ -591,7 +620,7 @@ function discColor(disc: string): string {
591
620
  size="sm"
592
621
  variant="solid"
593
622
  class="flex-1"
594
- @click="toggleClone(persona)"
623
+ @click.stop="toggleClone(persona)"
595
624
  />
596
625
  <UButton
597
626
  icon="i-lucide-trash-2"
@@ -600,7 +629,7 @@ function discColor(disc: string): string {
600
629
  color="error"
601
630
  :loading="deleting === persona.id"
602
631
  aria-label="Delete persona"
603
- @click="deletePersona(persona)"
632
+ @click.stop="deletePersona(persona)"
604
633
  />
605
634
  </div>
606
635
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.91.0",
3
+ "version": "2.93.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.91.0"
3
+ version = "2.93.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"}
@@ -785,13 +785,118 @@ def _obsidian_store_available() -> bool:
785
785
 
786
786
  @app.get("/api/personas/{persona_id}")
787
787
  def persona_detail(persona_id: str):
788
+ """PR74 v2.92.0 — detail endpoint now checks the Obsidian vault
789
+ in addition to the JSON store, so vault-only personas (Alex
790
+ Hormozi, Naval, etc.) resolve correctly.
791
+ """
792
+ # Try Obsidian first — it's the source of truth on conflicts.
793
+ try:
794
+ from core.personas.obsidian_store import ObsidianPersonaStore
795
+ ob_store = ObsidianPersonaStore()
796
+ if ob_store.available:
797
+ for p in ob_store.list_all():
798
+ if p.id == persona_id:
799
+ payload = p.model_dump()
800
+ payload["_source_store"] = "obsidian"
801
+ payload["_obsidian_path"] = str(
802
+ ob_store.personas_dir / f"{p.name}.md"
803
+ )
804
+ return payload
805
+ except Exception:
806
+ pass
807
+
788
808
  mgr = _get_persona_manager()
789
809
  if not mgr:
790
810
  return {"error": "Persona manager unavailable"}
791
811
  p = mgr.get(persona_id)
792
812
  if not p:
793
813
  return {"error": "Persona not found"}
794
- return p.model_dump()
814
+ payload = p.model_dump()
815
+ payload["_source_store"] = "json"
816
+ return payload
817
+
818
+
819
+ @app.put("/api/personas/{persona_id}")
820
+ def persona_update(persona_id: str, body: dict):
821
+ """PR74 v2.92.0 — update an existing persona. Writes to both the
822
+ JSON store (when the persona exists there) and the Obsidian vault
823
+ (when configured). Best-effort: a vault write failure does not
824
+ abort the JSON-side success and vice versa.
825
+
826
+ The persona name can change; in that case the old Obsidian file
827
+ is left in place (operator can delete it manually) and a new one
828
+ is created with the updated name.
829
+ """
830
+ from core.personas.schema import (
831
+ Persona, PersonaDISC, PersonaEnneagram, PersonaBigFive, PersonaCommunication,
832
+ )
833
+
834
+ # Start from existing data so partial-update bodies don't wipe fields.
835
+ existing = persona_detail(persona_id)
836
+ if "error" in existing:
837
+ return existing
838
+ merged = {**existing, **{k: v for k, v in body.items() if v is not None}}
839
+
840
+ name = merged.get("name", "Unknown")
841
+ new_id = (
842
+ merged.get("id")
843
+ or name.lower().replace(" ", "-").replace(".", "")
844
+ )
845
+
846
+ updated = Persona(
847
+ id=new_id,
848
+ name=name,
849
+ title=merged.get("title", ""),
850
+ tagline=merged.get("tagline", ""),
851
+ source=merged.get("source", name),
852
+ disc=PersonaDISC(**(merged.get("disc", {}) or {})),
853
+ enneagram=PersonaEnneagram(**(merged.get("enneagram", {}) or {})),
854
+ big_five=PersonaBigFive(**(merged.get("big_five", {}) or {})),
855
+ mbti=merged.get("mbti", "INTJ"),
856
+ mental_models=merged.get("mental_models", []) or [],
857
+ expertise_domains=merged.get("expertise_domains", []) or [],
858
+ frameworks=merged.get("frameworks", []) or [],
859
+ key_quotes=merged.get("key_quotes", []) or [],
860
+ communication=PersonaCommunication(
861
+ **(merged.get("communication", {}) or {}),
862
+ ),
863
+ created_at=merged.get("created_at", ""),
864
+ )
865
+
866
+ # JSON store — only if the persona originally lived there.
867
+ json_written = False
868
+ if existing.get("_source_store") != "obsidian":
869
+ mgr = _get_persona_manager()
870
+ if mgr:
871
+ try:
872
+ mgr.update(persona_id, updated.model_dump())
873
+ json_written = True
874
+ except Exception:
875
+ # Fall through to create if update isn't supported.
876
+ try:
877
+ mgr.create(updated)
878
+ json_written = True
879
+ except Exception:
880
+ json_written = False
881
+
882
+ # Obsidian — always overwrite when vault is configured.
883
+ obsidian_path: str | None = None
884
+ try:
885
+ from core.personas.obsidian_store import ObsidianPersonaStore
886
+ ob_store = ObsidianPersonaStore()
887
+ if ob_store.available or ob_store._vault_path is not None:
888
+ written = ob_store.write(updated)
889
+ if written is not None:
890
+ obsidian_path = str(written)
891
+ except Exception:
892
+ pass
893
+
894
+ return {
895
+ "id": updated.id,
896
+ "updated": True,
897
+ "json_written": json_written,
898
+ "obsidian_path": obsidian_path,
899
+ }
795
900
 
796
901
 
797
902
  @app.post("/api/personas")