arkaos 2.78.0 → 2.79.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.78.0
1
+ 2.79.0
@@ -0,0 +1,504 @@
1
+ <script setup lang="ts">
2
+ import type { Persona } from '~/types'
3
+
4
+ // PR62 v2.79.0 — 4-step AI-powered persona wizard.
5
+ // Replaces the original "fill a 30-field form" UX with:
6
+ // 1. Sources → operator pastes URLs / picks files, optionally types a name
7
+ // 2. Ingest → existing /api/knowledge/ingest-bulk fans the URLs out into
8
+ // background jobs and shows progress via WebSocket
9
+ // 3. Build → /api/personas/build (PR57) reads the indexed chunks and
10
+ // returns a draft Persona; operator reviews + edits
11
+ // 4. Save → POST /api/personas (manual create path), optional
12
+ // clone-to-agent at the end
13
+ //
14
+ // The wizard NEVER auto-saves — every transition is operator-confirmed.
15
+
16
+ const { apiBase } = useApi()
17
+ const toast = useToast()
18
+
19
+ const emit = defineEmits<{
20
+ (e: 'completed', persona: Persona): void
21
+ (e: 'cancelled'): void
22
+ }>()
23
+
24
+ type Step = 1 | 2 | 3 | 4
25
+ const step = ref<Step>(1)
26
+
27
+ // ─── Step 1 state ────────────────────────────────────────────────────────
28
+ const name = ref('')
29
+ const sourceLabel = ref('')
30
+ const sources = ref('')
31
+ const skipIngest = ref(false)
32
+ const sourceLineCount = computed(() =>
33
+ sources.value
34
+ .split('\n')
35
+ .map((s) => s.trim())
36
+ .filter((s) => s.length > 0)
37
+ .length,
38
+ )
39
+
40
+ // ─── Step 2 state ────────────────────────────────────────────────────────
41
+ const ingestJobs = ref<Array<{
42
+ source: string
43
+ job_id?: string
44
+ status: 'queued' | 'processing' | 'completed' | 'failed'
45
+ progress: number
46
+ error?: string
47
+ }>>([])
48
+ let ws: WebSocket | null = null
49
+ const allIngestComplete = computed(() =>
50
+ ingestJobs.value.length > 0
51
+ && ingestJobs.value.every((j) => j.status === 'completed' || j.status === 'failed'),
52
+ )
53
+ const ingestCompletedCount = computed(() =>
54
+ ingestJobs.value.filter((j) => j.status === 'completed').length,
55
+ )
56
+
57
+ // ─── Step 3 state ────────────────────────────────────────────────────────
58
+ const draft = ref<Persona | null>(null)
59
+ const building = ref(false)
60
+ const buildError = ref<string | null>(null)
61
+ const chunksUsed = ref<number | null>(null)
62
+
63
+ // ─── Step 4 state ────────────────────────────────────────────────────────
64
+ const saving = ref(false)
65
+ const saveAndClone = ref(false)
66
+ const cloneDept = ref('strategy')
67
+ const cloneTier = ref<'1' | '2' | '3'>('2')
68
+
69
+ const departmentOptions = [
70
+ 'dev', 'marketing', 'brand', 'finance', 'strategy', 'ecom', 'kb', 'ops',
71
+ 'pm', 'saas', 'landing', 'content', 'community', 'sales', 'leadership', 'org',
72
+ ].map((d) => ({ label: d, value: d }))
73
+
74
+ const tierOptions = [
75
+ { label: 'Tier 1 — Squad Lead', value: '1' },
76
+ { label: 'Tier 2 — Specialist', value: '2' },
77
+ { label: 'Tier 3 — Support', value: '3' },
78
+ ]
79
+
80
+ // ─── Step 1 → 2 transition ───────────────────────────────────────────────
81
+
82
+
83
+ async function startIngest() {
84
+ if (skipIngest.value) {
85
+ // Jump straight to step 3 — operator says content is already indexed.
86
+ step.value = 3
87
+ await runBuild()
88
+ return
89
+ }
90
+ const cleaned = sources.value
91
+ .split('\n')
92
+ .map((s) => s.trim())
93
+ .filter((s) => s.length > 0)
94
+ if (cleaned.length === 0 || !name.value.trim()) return
95
+ step.value = 2
96
+ ingestJobs.value = cleaned.map((source) => ({
97
+ source,
98
+ status: 'queued',
99
+ progress: 0,
100
+ }))
101
+ try {
102
+ const res = await $fetch<{ jobs: Array<{ source: string, job_id?: string, error?: string }>, count: number }>(
103
+ `${apiBase}/api/knowledge/ingest-bulk`,
104
+ { method: 'POST', body: { sources: cleaned } },
105
+ )
106
+ res.jobs.forEach((j) => {
107
+ const row = ingestJobs.value.find((r) => r.source === j.source)
108
+ if (!row) return
109
+ if (j.error) {
110
+ row.status = 'failed'
111
+ row.error = j.error
112
+ } else if (j.job_id) {
113
+ row.job_id = j.job_id
114
+ row.status = 'processing'
115
+ }
116
+ })
117
+ connectWebSocket()
118
+ } catch (err) {
119
+ toast.add({
120
+ title: 'Ingest failed',
121
+ description: err instanceof Error ? err.message : 'unknown error',
122
+ color: 'error',
123
+ })
124
+ step.value = 1
125
+ }
126
+ }
127
+
128
+
129
+ function connectWebSocket() {
130
+ if (ws && ws.readyState === WebSocket.OPEN) return
131
+ const wsUrl = apiBase.replace('http://', 'ws://').replace('https://', 'wss://') + '/ws/tasks'
132
+ ws = new WebSocket(wsUrl)
133
+ ws.onmessage = (event) => {
134
+ try {
135
+ const data = JSON.parse(event.data)
136
+ const row = ingestJobs.value.find((j) => j.job_id === data.job_id)
137
+ if (!row) return
138
+ if (data.type === 'job_progress') {
139
+ row.progress = data.progress
140
+ row.status = 'processing'
141
+ } else if (data.type === 'job_complete') {
142
+ row.status = 'completed'
143
+ row.progress = 100
144
+ } else if (data.type === 'job_failed') {
145
+ row.status = 'failed'
146
+ row.error = data.error
147
+ }
148
+ } catch { /* ignore malformed messages */ }
149
+ }
150
+ }
151
+
152
+
153
+ function disconnectWebSocket() {
154
+ if (ws) {
155
+ try { ws.close() } catch { /* already closed */ }
156
+ ws = null
157
+ }
158
+ }
159
+
160
+
161
+ onBeforeUnmount(() => {
162
+ disconnectWebSocket()
163
+ })
164
+
165
+
166
+ // ─── Step 3: build the persona draft ────────────────────────────────────
167
+
168
+
169
+ async function runBuild() {
170
+ building.value = true
171
+ buildError.value = null
172
+ draft.value = null
173
+ try {
174
+ const res = await $fetch<{ persona: Persona, chunks_used: number, provider_name: string }>(
175
+ `${apiBase}/api/personas/build`,
176
+ {
177
+ method: 'POST',
178
+ body: {
179
+ name: name.value.trim(),
180
+ source_label: sourceLabel.value.trim() || name.value.trim(),
181
+ },
182
+ },
183
+ )
184
+ if ('error' in res && typeof (res as any).error === 'string') {
185
+ throw new Error((res as any).error)
186
+ }
187
+ draft.value = res.persona
188
+ chunksUsed.value = res.chunks_used
189
+ step.value = 4
190
+ } catch (err) {
191
+ buildError.value = err instanceof Error ? err.message : 'unknown error'
192
+ } finally {
193
+ building.value = false
194
+ }
195
+ }
196
+
197
+
198
+ // ─── Step 4: save + optional clone ──────────────────────────────────────
199
+
200
+
201
+ async function savePersona() {
202
+ if (!draft.value) return
203
+ saving.value = true
204
+ try {
205
+ const created = await $fetch<Persona>(`${apiBase}/api/personas`, {
206
+ method: 'POST',
207
+ body: draft.value,
208
+ })
209
+ if (saveAndClone.value && cloneDept.value && cloneTier.value) {
210
+ await $fetch(`${apiBase}/api/personas/${created.id}/clone`, {
211
+ method: 'POST',
212
+ body: { department: cloneDept.value, tier: Number(cloneTier.value) },
213
+ })
214
+ }
215
+ toast.add({
216
+ title: saveAndClone.value ? 'Persona saved + agent cloned' : 'Persona saved',
217
+ description: `${created.name} is now in your board.`,
218
+ color: 'success',
219
+ })
220
+ emit('completed', created)
221
+ } catch (err) {
222
+ toast.add({
223
+ title: 'Save failed',
224
+ description: err instanceof Error ? err.message : 'unknown error',
225
+ color: 'error',
226
+ })
227
+ } finally {
228
+ saving.value = false
229
+ }
230
+ }
231
+
232
+
233
+ // ─── Auto-advance when ingest completes ─────────────────────────────────
234
+
235
+
236
+ watch(allIngestComplete, async (done) => {
237
+ if (done && step.value === 2 && ingestCompletedCount.value > 0) {
238
+ step.value = 3
239
+ await runBuild()
240
+ }
241
+ })
242
+
243
+
244
+ function cancel() {
245
+ disconnectWebSocket()
246
+ emit('cancelled')
247
+ }
248
+
249
+
250
+ function backToStep1() {
251
+ disconnectWebSocket()
252
+ step.value = 1
253
+ ingestJobs.value = []
254
+ }
255
+ </script>
256
+
257
+ <template>
258
+ <UCard>
259
+ <template #header>
260
+ <div class="flex items-center justify-between">
261
+ <div>
262
+ <h3 class="text-lg font-semibold">AI Persona Builder</h3>
263
+ <p class="text-sm text-muted mt-1">
264
+ Step {{ step }} of 4 — {{ {
265
+ 1: 'Sources',
266
+ 2: 'Indexing',
267
+ 3: 'Generating DNA',
268
+ 4: 'Review & save',
269
+ }[step] }}
270
+ </p>
271
+ </div>
272
+ <UButton
273
+ label="Cancel"
274
+ variant="ghost"
275
+ color="neutral"
276
+ size="sm"
277
+ @click="cancel"
278
+ />
279
+ </div>
280
+ </template>
281
+
282
+ <!-- Progress indicator -->
283
+ <div class="flex items-center gap-2 mb-6">
284
+ <div
285
+ v-for="s in [1, 2, 3, 4] as Step[]"
286
+ :key="s"
287
+ class="flex-1 h-1 rounded-full"
288
+ :class="s <= step ? 'bg-primary' : 'bg-muted/30'"
289
+ />
290
+ </div>
291
+
292
+ <!-- Step 1: Sources -->
293
+ <div v-if="step === 1" class="space-y-4">
294
+ <UFormField label="Person name" required>
295
+ <UInput
296
+ v-model="name"
297
+ placeholder="e.g. Alex Hormozi"
298
+ size="lg"
299
+ class="w-full"
300
+ />
301
+ </UFormField>
302
+
303
+ <UFormField label="Source label (optional)" help="How this person should appear in the persona's source field. Defaults to the name above.">
304
+ <UInput
305
+ v-model="sourceLabel"
306
+ placeholder="e.g. Alex Hormozi — $100M Offers / $100M Leads"
307
+ class="w-full"
308
+ />
309
+ </UFormField>
310
+
311
+ <UFormField
312
+ label="Sources (one URL per line)"
313
+ help="YouTube videos, articles, PDFs, blog posts about this person. The builder will search the indexed chunks and synthesise their behavioural DNA. Up to 50 sources per batch."
314
+ >
315
+ <UTextarea
316
+ v-model="sources"
317
+ :rows="6"
318
+ placeholder="https://www.youtube.com/watch?v=...&#10;https://example.com/article&#10;https://example.com/paper.pdf"
319
+ class="w-full font-mono text-sm"
320
+ :disabled="skipIngest"
321
+ />
322
+ </UFormField>
323
+
324
+ <div class="flex items-center justify-between text-xs text-muted">
325
+ <span>{{ sourceLineCount }} source{{ sourceLineCount === 1 ? '' : 's' }} detected</span>
326
+ <UCheckbox
327
+ v-model="skipIngest"
328
+ label="Skip ingest — content for this person is already in the knowledge base"
329
+ />
330
+ </div>
331
+
332
+ <div class="flex justify-end gap-2 pt-4">
333
+ <UButton
334
+ :label="skipIngest ? 'Generate from existing knowledge' : `Index ${sourceLineCount} source${sourceLineCount === 1 ? '' : 's'} & build`"
335
+ icon="i-lucide-arrow-right"
336
+ :disabled="!name.trim() || (!skipIngest && sourceLineCount === 0) || sourceLineCount > 50"
337
+ size="md"
338
+ @click="startIngest"
339
+ />
340
+ </div>
341
+ <p v-if="sourceLineCount > 50" class="text-xs text-red-400">
342
+ Over the 50-source cap. Trim the list before continuing.
343
+ </p>
344
+ </div>
345
+
346
+ <!-- Step 2: Ingest progress -->
347
+ <div v-else-if="step === 2" class="space-y-4">
348
+ <p class="text-sm text-muted">
349
+ Indexing {{ ingestJobs.length }} source{{ ingestJobs.length === 1 ? '' : 's' }} into the knowledge base.
350
+ This auto-advances when complete.
351
+ </p>
352
+ <div class="space-y-2">
353
+ <div
354
+ v-for="(job, idx) in ingestJobs"
355
+ :key="idx"
356
+ class="rounded-lg border border-default p-3"
357
+ >
358
+ <div class="flex items-center gap-3">
359
+ <UIcon
360
+ :name="{
361
+ queued: 'i-lucide-clock',
362
+ processing: 'i-lucide-loader-2 animate-spin',
363
+ completed: 'i-lucide-check-circle',
364
+ failed: 'i-lucide-x-circle',
365
+ }[job.status]"
366
+ :class="{
367
+ queued: 'text-muted',
368
+ processing: 'text-primary',
369
+ completed: 'text-green-500',
370
+ failed: 'text-red-500',
371
+ }[job.status]"
372
+ class="size-4 shrink-0"
373
+ />
374
+ <div class="flex-1 min-w-0">
375
+ <p class="text-sm font-mono truncate">{{ job.source }}</p>
376
+ <UProgress
377
+ v-if="job.status === 'processing' || job.status === 'queued'"
378
+ :value="job.progress"
379
+ :max="100"
380
+ size="xs"
381
+ class="mt-1"
382
+ />
383
+ <p v-if="job.error" class="text-xs text-red-400 mt-1">{{ job.error }}</p>
384
+ </div>
385
+ <span class="text-xs text-muted">{{ job.progress }}%</span>
386
+ </div>
387
+ </div>
388
+ </div>
389
+ </div>
390
+
391
+ <!-- Step 3: Building -->
392
+ <div v-else-if="step === 3" class="space-y-4">
393
+ <div v-if="building" class="flex flex-col items-center gap-4 py-12">
394
+ <UIcon name="i-lucide-loader-2" class="size-12 animate-spin text-primary" />
395
+ <p class="text-sm text-muted">
396
+ Reading indexed chunks about <strong>{{ name }}</strong>…
397
+ </p>
398
+ <p class="text-xs text-muted">
399
+ The builder searches the vector store, joins the top chunks, and asks the configured LLM to extract a behavioural-DNA persona.
400
+ </p>
401
+ </div>
402
+ <div v-else-if="buildError" class="rounded-lg border border-red-500/20 bg-red-500/5 p-4">
403
+ <div class="flex items-start gap-3">
404
+ <UIcon name="i-lucide-alert-circle" class="size-5 text-red-500 mt-0.5 shrink-0" />
405
+ <div class="flex-1">
406
+ <p class="text-sm font-medium text-red-400">Build failed</p>
407
+ <p class="text-xs text-muted mt-1">{{ buildError }}</p>
408
+ </div>
409
+ </div>
410
+ <div class="flex gap-2 mt-3">
411
+ <UButton label="Retry" variant="outline" size="sm" @click="runBuild" />
412
+ <UButton label="Back to sources" variant="ghost" size="sm" @click="backToStep1" />
413
+ </div>
414
+ </div>
415
+ </div>
416
+
417
+ <!-- Step 4: Review & save -->
418
+ <div v-else-if="step === 4 && draft" class="space-y-4">
419
+ <div class="rounded-lg border border-green-500/20 bg-green-500/5 p-3">
420
+ <p class="text-sm text-green-400">
421
+ <UIcon name="i-lucide-sparkles" class="size-4 inline" />
422
+ Built from <strong>{{ chunksUsed }}</strong> knowledge chunk{{ chunksUsed === 1 ? '' : 's' }}. Edit any field below before saving.
423
+ </p>
424
+ </div>
425
+
426
+ <fieldset class="space-y-3">
427
+ <legend class="text-xs font-bold uppercase tracking-widest text-muted mb-2">Identity</legend>
428
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
429
+ <UFormField label="Name">
430
+ <UInput v-model="draft.name" class="w-full" />
431
+ </UFormField>
432
+ <UFormField label="Title">
433
+ <UInput v-model="draft.title" class="w-full" />
434
+ </UFormField>
435
+ </div>
436
+ <UFormField label="Tagline">
437
+ <UInput v-model="draft.tagline" class="w-full" />
438
+ </UFormField>
439
+ </fieldset>
440
+
441
+ <fieldset class="space-y-3">
442
+ <legend class="text-xs font-bold uppercase tracking-widest text-muted mb-2">Behavioural DNA</legend>
443
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-3">
444
+ <UFormField label="MBTI">
445
+ <UInput v-model="draft.mbti" class="w-full" />
446
+ </UFormField>
447
+ <UFormField label="DISC primary">
448
+ <UInput v-model="draft.disc.primary" class="w-full" />
449
+ </UFormField>
450
+ <UFormField label="Enneagram type">
451
+ <UInput v-model.number="draft.enneagram.type" type="number" :min="1" :max="9" class="w-full" />
452
+ </UFormField>
453
+ <UFormField label="Enneagram wing">
454
+ <UInput v-model.number="draft.enneagram.wing" type="number" :min="1" :max="9" class="w-full" />
455
+ </UFormField>
456
+ </div>
457
+ </fieldset>
458
+
459
+ <fieldset class="space-y-3">
460
+ <legend class="text-xs font-bold uppercase tracking-widest text-muted mb-2">Knowledge</legend>
461
+ <UFormField label="Mental models" help="comma-separated">
462
+ <UInput
463
+ :model-value="draft.mental_models.join(', ')"
464
+ @update:model-value="(v: string) => draft && (draft.mental_models = v.split(',').map(s => s.trim()).filter(Boolean))"
465
+ class="w-full"
466
+ />
467
+ </UFormField>
468
+ <UFormField label="Expertise domains" help="comma-separated">
469
+ <UInput
470
+ :model-value="draft.expertise_domains.join(', ')"
471
+ @update:model-value="(v: string) => draft && (draft.expertise_domains = v.split(',').map(s => s.trim()).filter(Boolean))"
472
+ class="w-full"
473
+ />
474
+ </UFormField>
475
+ </fieldset>
476
+
477
+ <fieldset class="space-y-3">
478
+ <legend class="text-xs font-bold uppercase tracking-widest text-muted mb-2">Save options</legend>
479
+ <UCheckbox
480
+ v-model="saveAndClone"
481
+ label="Also clone to an agent immediately"
482
+ />
483
+ <div v-if="saveAndClone" class="grid grid-cols-2 gap-3 pl-6">
484
+ <UFormField label="Department">
485
+ <USelect v-model="cloneDept" :items="departmentOptions" class="w-full" />
486
+ </UFormField>
487
+ <UFormField label="Tier">
488
+ <USelect v-model="cloneTier" :items="tierOptions" class="w-full" />
489
+ </UFormField>
490
+ </div>
491
+ </fieldset>
492
+
493
+ <div class="flex justify-end gap-2 pt-4">
494
+ <UButton label="Back" variant="ghost" @click="backToStep1" />
495
+ <UButton
496
+ :label="saveAndClone ? 'Save & clone' : 'Save persona'"
497
+ icon="i-lucide-check"
498
+ :loading="saving"
499
+ @click="savePersona"
500
+ />
501
+ </div>
502
+ </div>
503
+ </UCard>
504
+ </template>
@@ -9,8 +9,30 @@ const { data, status, error, refresh } = fetchApi<{ personas: Persona[]; total:
9
9
 
10
10
  const personas = computed(() => data.value?.personas ?? [])
11
11
 
12
- // --- Form visibility ---
13
- const showForm = ref(false)
12
+ // --- Creation mode ---
13
+ // PR62 v2.79.0 — three modes: list (default), wizard (AI builder), manual.
14
+ // The wizard is the new primary path; manual stays as fallback for
15
+ // operators who want to type every DNA field by hand.
16
+ type CreateMode = 'list' | 'wizard' | 'manual'
17
+ const createMode = ref<CreateMode>('list')
18
+ const showForm = computed(() => createMode.value === 'manual')
19
+
20
+ function startWizard() {
21
+ createMode.value = 'wizard'
22
+ }
23
+
24
+ function startManual() {
25
+ createMode.value = 'manual'
26
+ }
27
+
28
+ function cancelCreation() {
29
+ createMode.value = 'list'
30
+ }
31
+
32
+ async function onWizardComplete() {
33
+ createMode.value = 'list'
34
+ await refresh()
35
+ }
14
36
 
15
37
  // --- Form state ---
16
38
  function defaultForm() {
@@ -216,11 +238,29 @@ function discColor(disc: string): string {
216
238
 
217
239
  <template #right>
218
240
  <UButton
219
- :label="showForm ? 'Cancel' : 'New Persona'"
220
- :icon="showForm ? 'i-lucide-x' : 'i-lucide-plus'"
221
- :variant="showForm ? 'ghost' : 'solid'"
241
+ v-if="createMode === 'list'"
242
+ label="AI Builder"
243
+ icon="i-lucide-sparkles"
244
+ color="primary"
245
+ size="sm"
246
+ @click="startWizard"
247
+ />
248
+ <UButton
249
+ v-if="createMode === 'list'"
250
+ label="Manual"
251
+ icon="i-lucide-plus"
252
+ variant="outline"
253
+ size="sm"
254
+ class="ml-2"
255
+ @click="startManual"
256
+ />
257
+ <UButton
258
+ v-else
259
+ label="Back to list"
260
+ icon="i-lucide-arrow-left"
261
+ variant="ghost"
222
262
  size="sm"
223
- @click="showForm = !showForm"
263
+ @click="cancelCreation"
224
264
  />
225
265
  </template>
226
266
  </UDashboardNavbar>
@@ -242,7 +282,15 @@ function discColor(disc: string): string {
242
282
 
243
283
  <!-- Content -->
244
284
  <template v-else>
245
- <!-- Create Persona Form -->
285
+ <!-- PR62: AI Persona Wizard -->
286
+ <PersonaWizard
287
+ v-if="createMode === 'wizard'"
288
+ class="mb-8"
289
+ @completed="onWizardComplete"
290
+ @cancelled="cancelCreation"
291
+ />
292
+
293
+ <!-- Manual create form (legacy / fallback) -->
246
294
  <UCard v-if="showForm" class="mb-8">
247
295
  <form @submit.prevent="createPersona" class="space-y-8 p-2">
248
296
  <!-- Identity -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.78.0",
3
+ "version": "2.79.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.78.0"
3
+ version = "2.79.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"}