arkaos 2.91.0 → 2.92.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.92.0
@@ -0,0 +1,508 @@
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
+
29
+ const detail = ref<DetailResponse | null>(null)
30
+ const editing = ref(false)
31
+ const draft = ref<Persona | null>(null)
32
+ const saving = ref(false)
33
+ const deleting = ref(false)
34
+ const loading = ref(false)
35
+ const loadError = ref<string | null>(null)
36
+
37
+ watch(
38
+ () => [props.modelValue, props.personaId] as const,
39
+ async ([open, id]) => {
40
+ if (!open || !id) {
41
+ detail.value = null
42
+ editing.value = false
43
+ draft.value = null
44
+ loadError.value = null
45
+ return
46
+ }
47
+ await loadDetail(id)
48
+ },
49
+ )
50
+
51
+ async function loadDetail(id: string) {
52
+ loading.value = true
53
+ loadError.value = null
54
+ try {
55
+ const data = await $fetch<DetailResponse | { error: string }>(
56
+ `${apiBase}/api/personas/${id}`,
57
+ )
58
+ if ('error' in data && data.error) {
59
+ loadError.value = data.error
60
+ detail.value = null
61
+ } else {
62
+ detail.value = data as DetailResponse
63
+ }
64
+ } catch (err) {
65
+ loadError.value = err instanceof Error ? err.message : 'unknown error'
66
+ } finally {
67
+ loading.value = false
68
+ }
69
+ }
70
+
71
+ function startEdit() {
72
+ if (!detail.value) return
73
+ draft.value = JSON.parse(JSON.stringify(detail.value)) as Persona
74
+ editing.value = true
75
+ }
76
+
77
+ function cancelEdit() {
78
+ draft.value = null
79
+ editing.value = false
80
+ }
81
+
82
+ async function saveEdit() {
83
+ if (!draft.value || !props.personaId) return
84
+ saving.value = true
85
+ try {
86
+ const res = await $fetch<{
87
+ id: string
88
+ updated: boolean
89
+ json_written: boolean
90
+ obsidian_path: string | null
91
+ error?: string
92
+ }>(`${apiBase}/api/personas/${props.personaId}`, {
93
+ method: 'PUT',
94
+ body: draft.value,
95
+ })
96
+ if (res.error) throw new Error(res.error)
97
+ toast.add({
98
+ title: 'Persona saved',
99
+ description: res.obsidian_path
100
+ ? `Wrote ${res.obsidian_path.split('/').slice(-2).join('/')}`
101
+ : 'Saved to JSON store',
102
+ color: 'success',
103
+ })
104
+ emit('saved', draft.value)
105
+ detail.value = { ...detail.value, ...draft.value } as DetailResponse
106
+ editing.value = false
107
+ } catch (err) {
108
+ toast.add({
109
+ title: 'Save failed',
110
+ description: err instanceof Error ? err.message : 'unknown error',
111
+ color: 'error',
112
+ })
113
+ } finally {
114
+ saving.value = false
115
+ }
116
+ }
117
+
118
+ async function deletePersona() {
119
+ 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
+ )
126
+ if (!ok) return
127
+ deleting.value = true
128
+ try {
129
+ await $fetch(`${apiBase}/api/personas/${props.personaId}`, {
130
+ method: 'DELETE',
131
+ })
132
+ toast.add({
133
+ title: 'Persona deleted',
134
+ description: detail.value?.name ?? '',
135
+ color: 'success',
136
+ })
137
+ emit('deleted', props.personaId)
138
+ emit('update:modelValue', false)
139
+ } catch (err) {
140
+ toast.add({
141
+ title: 'Delete failed',
142
+ description: err instanceof Error ? err.message : 'unknown error',
143
+ color: 'error',
144
+ })
145
+ } finally {
146
+ deleting.value = false
147
+ }
148
+ }
149
+
150
+ function closeDrawer() {
151
+ if (editing.value && !saving.value) {
152
+ if (typeof window !== 'undefined'
153
+ && !window.confirm('Discard unsaved edits?')) {
154
+ return
155
+ }
156
+ }
157
+ cancelEdit()
158
+ emit('update:modelValue', false)
159
+ }
160
+
161
+ function listToCsv(list: string[] | undefined): string {
162
+ return (list ?? []).join(', ')
163
+ }
164
+
165
+ function csvToList(value: string): string[] {
166
+ return value.split(',').map((s) => s.trim()).filter(Boolean)
167
+ }
168
+
169
+ const mbtiOptions = [
170
+ 'INTJ', 'INTP', 'ENTJ', 'ENTP',
171
+ 'INFJ', 'INFP', 'ENFJ', 'ENFP',
172
+ 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ',
173
+ 'ISTP', 'ISFP', 'ESTP', 'ESFP',
174
+ ].map((t) => ({ label: t, value: t }))
175
+
176
+ const discOptions = [
177
+ { label: 'D — Dominance', value: 'D' },
178
+ { label: 'I — Influence', value: 'I' },
179
+ { label: 'S — Steadiness', value: 'S' },
180
+ { label: 'C — Conscientiousness', value: 'C' },
181
+ ]
182
+
183
+ const vocabOptions = [
184
+ { label: 'Lay (no jargon)', value: 'lay' },
185
+ { label: 'Specialist (industry terms)', value: 'specialist' },
186
+ { label: 'Expert (research-level)', value: 'expert' },
187
+ ]
188
+ </script>
189
+
190
+ <template>
191
+ <USlideover
192
+ :open="modelValue"
193
+ :ui="{ content: 'max-w-2xl w-full' }"
194
+ @update:open="(v) => v ? null : closeDrawer()"
195
+ >
196
+ <template #content>
197
+ <UCard
198
+ :ui="{
199
+ root: 'h-full flex flex-col rounded-none',
200
+ body: 'flex-1 overflow-y-auto',
201
+ }"
202
+ >
203
+ <template #header>
204
+ <div class="flex items-start justify-between gap-3">
205
+ <div class="min-w-0">
206
+ <h2 class="text-xl font-bold truncate">
207
+ {{ detail?.name ?? 'Persona' }}
208
+ </h2>
209
+ <div class="flex items-center gap-2 mt-1 flex-wrap">
210
+ <UBadge
211
+ v-if="detail?._source_store === 'obsidian'"
212
+ label="From Obsidian"
213
+ icon="i-lucide-file-text"
214
+ color="primary"
215
+ variant="subtle"
216
+ size="xs"
217
+ />
218
+ <UBadge
219
+ v-else-if="detail?._source_store === 'json'"
220
+ label="JSON store"
221
+ variant="outline"
222
+ size="xs"
223
+ />
224
+ <span
225
+ v-if="detail?._obsidian_path"
226
+ class="text-xs text-muted font-mono truncate"
227
+ :title="detail._obsidian_path"
228
+ >
229
+ {{ detail._obsidian_path.split('/').slice(-2).join('/') }}
230
+ </span>
231
+ </div>
232
+ </div>
233
+ <div class="flex items-center gap-1 shrink-0">
234
+ <UButton
235
+ v-if="!editing"
236
+ icon="i-lucide-pencil"
237
+ variant="ghost"
238
+ size="sm"
239
+ aria-label="Edit persona"
240
+ @click="startEdit"
241
+ />
242
+ <UButton
243
+ v-if="!editing"
244
+ icon="i-lucide-trash-2"
245
+ color="error"
246
+ variant="ghost"
247
+ size="sm"
248
+ :loading="deleting"
249
+ aria-label="Delete persona"
250
+ @click="deletePersona"
251
+ />
252
+ <UButton
253
+ icon="i-lucide-x"
254
+ variant="ghost"
255
+ size="sm"
256
+ aria-label="Close"
257
+ @click="closeDrawer"
258
+ />
259
+ </div>
260
+ </div>
261
+ </template>
262
+
263
+ <div v-if="loading" class="flex items-center justify-center py-12">
264
+ <UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-muted" />
265
+ </div>
266
+
267
+ <div v-else-if="loadError" class="flex flex-col items-center justify-center gap-3 py-12">
268
+ <UIcon name="i-lucide-alert-triangle" class="size-10 text-red-500" />
269
+ <p class="text-sm text-muted">{{ loadError }}</p>
270
+ </div>
271
+
272
+ <div v-else-if="detail && !editing" class="space-y-6">
273
+ <p v-if="detail.tagline" class="text-base italic text-muted">
274
+ "{{ detail.tagline }}"
275
+ </p>
276
+
277
+ <section v-if="detail.title || detail.source">
278
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">Identity</h3>
279
+ <dl class="grid grid-cols-3 gap-2 text-sm">
280
+ <dt class="text-muted">Title</dt>
281
+ <dd class="col-span-2">{{ detail.title || '—' }}</dd>
282
+ <dt class="text-muted">Source</dt>
283
+ <dd class="col-span-2 font-mono text-xs">{{ detail.source || '—' }}</dd>
284
+ </dl>
285
+ </section>
286
+
287
+ <section>
288
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">Behavioural DNA</h3>
289
+ <dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
290
+ <dt class="text-muted">MBTI</dt>
291
+ <dd>{{ detail.mbti || '—' }}</dd>
292
+ <dt class="text-muted">DISC</dt>
293
+ <dd>
294
+ {{ detail.disc?.primary || '—' }}{{ detail.disc?.secondary ? `/${detail.disc.secondary}` : '' }}
295
+ </dd>
296
+ <dt class="text-muted">Enneagram</dt>
297
+ <dd>
298
+ {{ detail.enneagram?.type ?? '—' }}w{{ detail.enneagram?.wing ?? '?' }}
299
+ </dd>
300
+ </dl>
301
+ <div class="mt-3 space-y-1.5">
302
+ <div
303
+ v-for="trait in ([
304
+ ['Openness', detail.big_five?.openness ?? 0],
305
+ ['Conscientiousness', detail.big_five?.conscientiousness ?? 0],
306
+ ['Extraversion', detail.big_five?.extraversion ?? 0],
307
+ ['Agreeableness', detail.big_five?.agreeableness ?? 0],
308
+ ['Neuroticism', detail.big_five?.neuroticism ?? 0],
309
+ ] as Array<[string, number]>)"
310
+ :key="trait[0]"
311
+ class="flex items-center gap-3"
312
+ >
313
+ <span class="text-xs text-muted w-36 shrink-0">{{ trait[0] }}</span>
314
+ <div class="flex-1 h-2 rounded-full bg-muted/15 overflow-hidden">
315
+ <div class="h-2 rounded-full bg-primary" :style="{ width: `${trait[1]}%` }" />
316
+ </div>
317
+ <span class="text-xs font-mono w-10 text-right">{{ trait[1] }}</span>
318
+ </div>
319
+ </div>
320
+ </section>
321
+
322
+ <section v-if="detail.mental_models?.length">
323
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
324
+ Mental models ({{ detail.mental_models.length }})
325
+ </h3>
326
+ <div class="flex flex-wrap gap-1.5">
327
+ <UBadge
328
+ v-for="m in detail.mental_models"
329
+ :key="m"
330
+ :label="m"
331
+ variant="outline"
332
+ size="xs"
333
+ />
334
+ </div>
335
+ </section>
336
+
337
+ <section v-if="detail.expertise_domains?.length">
338
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
339
+ Expertise ({{ detail.expertise_domains.length }})
340
+ </h3>
341
+ <div class="flex flex-wrap gap-1.5">
342
+ <UBadge
343
+ v-for="e in detail.expertise_domains"
344
+ :key="e"
345
+ :label="e"
346
+ variant="soft"
347
+ size="xs"
348
+ />
349
+ </div>
350
+ </section>
351
+
352
+ <section v-if="detail.frameworks?.length">
353
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
354
+ Frameworks ({{ detail.frameworks.length }})
355
+ </h3>
356
+ <div class="flex flex-wrap gap-1.5">
357
+ <UBadge
358
+ v-for="f in detail.frameworks"
359
+ :key="f"
360
+ :label="f"
361
+ variant="outline"
362
+ size="xs"
363
+ />
364
+ </div>
365
+ </section>
366
+
367
+ <section v-if="detail.key_quotes?.length">
368
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
369
+ Key quotes ({{ detail.key_quotes.length }})
370
+ </h3>
371
+ <ul class="space-y-2">
372
+ <li
373
+ v-for="q in detail.key_quotes"
374
+ :key="q"
375
+ class="text-sm italic text-muted border-l-2 border-primary/30 pl-3"
376
+ >
377
+ "{{ q }}"
378
+ </li>
379
+ </ul>
380
+ </section>
381
+
382
+ <section v-if="detail.communication">
383
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">Communication</h3>
384
+ <dl class="grid grid-cols-3 gap-2 text-sm">
385
+ <dt class="text-muted">Tone</dt>
386
+ <dd class="col-span-2">{{ detail.communication.tone || '—' }}</dd>
387
+ <dt class="text-muted">Vocabulary</dt>
388
+ <dd class="col-span-2">{{ detail.communication.vocabulary_level || '—' }}</dd>
389
+ </dl>
390
+ </section>
391
+ </div>
392
+
393
+ <div v-else-if="draft" class="space-y-5">
394
+ <section class="space-y-3">
395
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Identity</h3>
396
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
397
+ <UFormField label="Name" required>
398
+ <UInput v-model="draft.name" class="w-full" />
399
+ </UFormField>
400
+ <UFormField label="Title">
401
+ <UInput v-model="draft.title" class="w-full" />
402
+ </UFormField>
403
+ <UFormField label="Source">
404
+ <UInput v-model="draft.source" class="w-full" />
405
+ </UFormField>
406
+ <UFormField label="Tagline">
407
+ <UInput v-model="draft.tagline" class="w-full" />
408
+ </UFormField>
409
+ </div>
410
+ </section>
411
+
412
+ <section class="space-y-3">
413
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Behavioural DNA</h3>
414
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-3">
415
+ <UFormField label="MBTI">
416
+ <USelect v-model="draft.mbti" :items="mbtiOptions" class="w-full" />
417
+ </UFormField>
418
+ <UFormField label="DISC primary">
419
+ <USelect v-model="draft.disc.primary" :items="discOptions" class="w-full" />
420
+ </UFormField>
421
+ <UFormField label="Enneagram">
422
+ <UInput v-model.number="draft.enneagram.type" type="number" :min="1" :max="9" class="w-full" />
423
+ </UFormField>
424
+ <UFormField label="Wing">
425
+ <UInput v-model.number="draft.enneagram.wing" type="number" :min="1" :max="9" class="w-full" />
426
+ </UFormField>
427
+ </div>
428
+ <div class="space-y-2">
429
+ <div
430
+ v-for="key in (['openness', 'conscientiousness', 'extraversion', 'agreeableness', 'neuroticism'] as const)"
431
+ :key="key"
432
+ class="flex items-center gap-3"
433
+ >
434
+ <label class="text-xs text-muted w-36 shrink-0 capitalize">{{ key }}</label>
435
+ <UInput
436
+ v-model.number="draft.big_five[key]"
437
+ type="number"
438
+ :min="0"
439
+ :max="100"
440
+ class="w-20"
441
+ />
442
+ <input
443
+ v-model.number="draft.big_five[key]"
444
+ type="range"
445
+ :min="0"
446
+ :max="100"
447
+ class="flex-1"
448
+ />
449
+ </div>
450
+ </div>
451
+ </section>
452
+
453
+ <section class="space-y-3">
454
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Knowledge</h3>
455
+ <UFormField label="Mental models" help="comma-separated">
456
+ <UInput
457
+ :model-value="listToCsv(draft.mental_models)"
458
+ @update:model-value="(v: string) => draft && (draft.mental_models = csvToList(v))"
459
+ class="w-full"
460
+ />
461
+ </UFormField>
462
+ <UFormField label="Expertise domains" help="comma-separated">
463
+ <UInput
464
+ :model-value="listToCsv(draft.expertise_domains)"
465
+ @update:model-value="(v: string) => draft && (draft.expertise_domains = csvToList(v))"
466
+ class="w-full"
467
+ />
468
+ </UFormField>
469
+ <UFormField label="Frameworks" help="comma-separated">
470
+ <UInput
471
+ :model-value="listToCsv(draft.frameworks)"
472
+ @update:model-value="(v: string) => draft && (draft.frameworks = csvToList(v))"
473
+ class="w-full"
474
+ />
475
+ </UFormField>
476
+ </section>
477
+
478
+ <section class="space-y-3">
479
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Communication</h3>
480
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
481
+ <UFormField label="Tone">
482
+ <UInput v-model="draft.communication.tone" class="w-full" />
483
+ </UFormField>
484
+ <UFormField label="Vocabulary level">
485
+ <USelect v-model="draft.communication.vocabulary_level" :items="vocabOptions" class="w-full" />
486
+ </UFormField>
487
+ </div>
488
+ </section>
489
+ </div>
490
+
491
+ <template #footer>
492
+ <div v-if="editing" class="flex justify-end gap-2">
493
+ <UButton label="Cancel" variant="ghost" :disabled="saving" @click="cancelEdit" />
494
+ <UButton
495
+ label="Save"
496
+ icon="i-lucide-check"
497
+ :loading="saving"
498
+ @click="saveEdit"
499
+ />
500
+ </div>
501
+ <p v-else class="text-xs text-muted text-right">
502
+ Click ✏️ to edit. Saves to JSON store + Obsidian vault when configured.
503
+ </p>
504
+ </template>
505
+ </UCard>
506
+ </template>
507
+ </USlideover>
508
+ </template>
@@ -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.92.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.92.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")