arkaos 3.2.0 → 3.4.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
- 3.2.0
1
+ 3.4.0
@@ -0,0 +1,111 @@
1
+ """AI-powered persona draft from a free-text description (PR83a v3.3.0).
2
+
3
+ Sibling to `core/personas/builder.PersonaBuilder` but does NOT require
4
+ indexed content. Useful when:
5
+
6
+ - The operator wants to model a persona quickly without ingesting sources
7
+ - A YouTuber / author isn't yet in the knowledge base
8
+ - The persona is a synthetic archetype rather than a real person
9
+
10
+ Reuses the same JSON schema and parsing as the vector-driven builder so
11
+ the resulting Persona is interchangeable.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import uuid
17
+ from dataclasses import dataclass
18
+ from datetime import datetime, timezone
19
+
20
+ from core.personas.builder import (
21
+ _PERSONA_SYSTEM_PROMPT,
22
+ _extract_json_object,
23
+ )
24
+ from core.personas.schema import (
25
+ Persona,
26
+ PersonaBigFive,
27
+ PersonaCommunication,
28
+ PersonaDISC,
29
+ PersonaEnneagram,
30
+ )
31
+ from core.runtime.llm_provider import LLMProvider, LLMUnavailable, get_llm_provider
32
+
33
+
34
+ _DESCRIPTION_MIN_CHARS = 20
35
+
36
+
37
+ class PersonaDraftError(RuntimeError):
38
+ """LLM produced unusable output or could not be reached."""
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class PersonaDraftResult:
43
+ persona: Persona
44
+ provider_name: str
45
+ raw_response: str
46
+
47
+
48
+ def draft_persona_from_description(
49
+ description: str,
50
+ *,
51
+ name: str,
52
+ source_label: str = "",
53
+ provider: LLMProvider | None = None,
54
+ ) -> PersonaDraftResult:
55
+ """Generate a Persona draft from a free-text description."""
56
+ description = (description or "").strip()
57
+ if not name or not name.strip():
58
+ raise PersonaDraftError("name must not be empty")
59
+ if len(description) < _DESCRIPTION_MIN_CHARS:
60
+ raise PersonaDraftError(
61
+ f"description must be at least {_DESCRIPTION_MIN_CHARS} characters"
62
+ )
63
+ llm = provider or get_llm_provider()
64
+ prompt = _build_prompt(name.strip(), description)
65
+ try:
66
+ resp = llm.complete(prompt, max_tokens=3000, system=_PERSONA_SYSTEM_PROMPT)
67
+ except LLMUnavailable as exc:
68
+ raise PersonaDraftError(str(exc)) from exc
69
+ persona = _parse(name.strip(), source_label.strip() or name.strip(), resp.text)
70
+ return PersonaDraftResult(
71
+ persona=persona, provider_name=llm.name(), raw_response=resp.text,
72
+ )
73
+
74
+
75
+ def _build_prompt(name: str, description: str) -> str:
76
+ return (
77
+ f"Person: {name}\n\n"
78
+ f"Description provided by the operator:\n{description}\n\n"
79
+ "Build the persona purely from the description above. If a field is "
80
+ "not implied, choose the closest neutral default rather than "
81
+ "fabricating. NEVER invent quotes — leave key_quotes empty if no "
82
+ "quotes are present in the description."
83
+ )
84
+
85
+
86
+ def _parse(name: str, source_label: str, raw: str) -> Persona:
87
+ data = _extract_json_object(raw)
88
+ if data is None:
89
+ raise PersonaDraftError(
90
+ f"LLM did not return a JSON object; raw response: {raw[:200]!r}"
91
+ )
92
+ try:
93
+ return Persona(
94
+ id=str(uuid.uuid4()),
95
+ name=name,
96
+ title=str(data.get("title") or ""),
97
+ tagline=str(data.get("tagline") or ""),
98
+ source=source_label,
99
+ disc=PersonaDISC(**(data.get("disc") or {})),
100
+ enneagram=PersonaEnneagram(**(data.get("enneagram") or {})),
101
+ big_five=PersonaBigFive(**(data.get("big_five") or {})),
102
+ mbti=str(data.get("mbti") or "").upper() or "INTJ",
103
+ mental_models=[str(x) for x in (data.get("mental_models") or [])],
104
+ expertise_domains=[str(x) for x in (data.get("expertise_domains") or [])],
105
+ frameworks=[str(x) for x in (data.get("frameworks") or [])],
106
+ key_quotes=[str(x) for x in (data.get("key_quotes") or [])],
107
+ communication=PersonaCommunication(**(data.get("communication") or {})),
108
+ created_at=datetime.now(timezone.utc).isoformat(),
109
+ )
110
+ except (TypeError, ValueError) as exc:
111
+ raise PersonaDraftError(f"persona schema mismatch: {exc}") from exc
@@ -37,6 +37,12 @@ const sourceLineCount = computed(() =>
37
37
  .length,
38
38
  )
39
39
 
40
+ // PR83a v3.3.0 — Mode 3: build from a free-text description (no chunks).
41
+ type Mode = 'sources' | 'existing' | 'description'
42
+ const mode = ref<Mode>('sources')
43
+ const description = ref('')
44
+ const descriptionLength = computed(() => description.value.trim().length)
45
+
40
46
  // ─── Step 2 state ────────────────────────────────────────────────────────
41
47
  const ingestJobs = ref<Array<{
42
48
  source: string
@@ -81,7 +87,14 @@ const tierOptions = [
81
87
 
82
88
 
83
89
  async function startIngest() {
84
- if (skipIngest.value) {
90
+ if (mode.value === 'description') {
91
+ // PR83a — no ingest, no chunks. Build directly from description.
92
+ if (descriptionLength.value < 20 || !name.value.trim()) return
93
+ step.value = 3
94
+ await runDescriptionBuild()
95
+ return
96
+ }
97
+ if (mode.value === 'existing' || skipIngest.value) {
85
98
  // Jump straight to step 3 — operator says content is already indexed.
86
99
  step.value = 3
87
100
  await runBuild()
@@ -166,6 +179,36 @@ onBeforeUnmount(() => {
166
179
  // ─── Step 3: build the persona draft ────────────────────────────────────
167
180
 
168
181
 
182
+ async function runDescriptionBuild() {
183
+ building.value = true
184
+ buildError.value = null
185
+ draft.value = null
186
+ try {
187
+ const res = await $fetch<{ persona: Persona, provider_name: string, error?: string }>(
188
+ `${apiBase}/api/personas/draft`,
189
+ {
190
+ method: 'POST',
191
+ body: {
192
+ name: name.value.trim(),
193
+ description: description.value.trim(),
194
+ source_label: sourceLabel.value.trim() || name.value.trim(),
195
+ },
196
+ },
197
+ )
198
+ if ('error' in res && typeof (res as any).error === 'string') {
199
+ throw new Error((res as any).error)
200
+ }
201
+ draft.value = res.persona
202
+ chunksUsed.value = 0
203
+ step.value = 4
204
+ } catch (err) {
205
+ buildError.value = err instanceof Error ? err.message : 'unknown error'
206
+ } finally {
207
+ building.value = false
208
+ }
209
+ }
210
+
211
+
169
212
  async function runBuild() {
170
213
  building.value = true
171
214
  buildError.value = null
@@ -308,7 +351,28 @@ function backToStep1() {
308
351
  />
309
352
  </UFormField>
310
353
 
354
+ <UFormField label="How should we generate this persona?">
355
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-2">
356
+ <button
357
+ v-for="m in ([
358
+ { key: 'sources', title: 'Ingest sources', desc: 'YouTube, articles, PDFs — best fidelity' },
359
+ { key: 'existing', title: 'Existing chunks', desc: 'Use what is already indexed' },
360
+ { key: 'description', title: 'From description', desc: 'No sources — pure description' },
361
+ ] as const)"
362
+ :key="m.key"
363
+ type="button"
364
+ class="text-left rounded-lg border p-3 transition-colors"
365
+ :class="mode === m.key ? 'border-primary bg-primary/5' : 'border-default hover:border-primary/40'"
366
+ @click="mode = m.key"
367
+ >
368
+ <p class="text-sm font-semibold">{{ m.title }}</p>
369
+ <p class="text-xs text-muted mt-1">{{ m.desc }}</p>
370
+ </button>
371
+ </div>
372
+ </UFormField>
373
+
311
374
  <UFormField
375
+ v-if="mode === 'sources'"
312
376
  label="Sources (one URL per line)"
313
377
  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
378
  >
@@ -317,28 +381,56 @@ function backToStep1() {
317
381
  :rows="6"
318
382
  placeholder="https://www.youtube.com/watch?v=...&#10;https://example.com/article&#10;https://example.com/paper.pdf"
319
383
  class="w-full font-mono text-sm"
320
- :disabled="skipIngest"
321
384
  />
322
385
  </UFormField>
323
386
 
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"
387
+ <UFormField
388
+ v-else-if="mode === 'description'"
389
+ label="Description"
390
+ help="Plain-text description of the person — their style, beliefs, what they do, how they talk. The LLM uses this verbatim. Minimum 20 characters."
391
+ >
392
+ <UTextarea
393
+ v-model="description"
394
+ :rows="6"
395
+ placeholder="A direct-response copywriter who treats offers as the only true growth lever. Punchy, allergic to fluff. Loves Hormozi-style hooks."
396
+ class="w-full"
329
397
  />
398
+ </UFormField>
399
+
400
+ <div
401
+ v-if="mode === 'sources'"
402
+ class="flex items-center justify-between text-xs text-muted"
403
+ >
404
+ <span>{{ sourceLineCount }} source{{ sourceLineCount === 1 ? '' : 's' }} detected</span>
405
+ </div>
406
+
407
+ <div v-else-if="mode === 'existing'" class="text-xs text-muted">
408
+ We will search the vector DB for chunks tagged with this name and synthesise from what we find. Make sure you've ingested content for this person first.
409
+ </div>
410
+
411
+ <div v-else-if="mode === 'description'" class="text-xs text-muted">
412
+ {{ descriptionLength }} character{{ descriptionLength === 1 ? '' : 's' }} ·
413
+ {{ descriptionLength >= 20 ? 'ready' : `${20 - descriptionLength} more needed` }}
330
414
  </div>
331
415
 
332
416
  <div class="flex justify-end gap-2 pt-4">
333
417
  <UButton
334
- :label="skipIngest ? 'Generate from existing knowledge' : `Index ${sourceLineCount} source${sourceLineCount === 1 ? '' : 's'} & build`"
418
+ :label="(
419
+ mode === 'sources' ? `Index ${sourceLineCount} source${sourceLineCount === 1 ? '' : 's'} & build`
420
+ : mode === 'existing' ? 'Generate from existing knowledge'
421
+ : 'Generate from description'
422
+ )"
335
423
  icon="i-lucide-arrow-right"
336
- :disabled="!name.trim() || (!skipIngest && sourceLineCount === 0) || sourceLineCount > 50"
424
+ :disabled="(
425
+ !name.trim()
426
+ || (mode === 'sources' && (sourceLineCount === 0 || sourceLineCount > 50))
427
+ || (mode === 'description' && descriptionLength < 20)
428
+ )"
337
429
  size="md"
338
430
  @click="startIngest"
339
431
  />
340
432
  </div>
341
- <p v-if="sourceLineCount > 50" class="text-xs text-red-400">
433
+ <p v-if="mode === 'sources' && sourceLineCount > 50" class="text-xs text-red-400">
342
434
  Over the 50-source cap. Trim the list before continuing.
343
435
  </p>
344
436
  </div>
@@ -138,6 +138,7 @@ const tierColor = (tier: number) => {
138
138
  }
139
139
 
140
140
  const columns: TableColumn<Agent>[] = [
141
+ { id: 'select', header: '' },
141
142
  { accessorKey: 'name', header: 'Name' },
142
143
  { accessorKey: 'role', header: 'Role' },
143
144
  { accessorKey: 'department', header: 'Department' },
@@ -155,6 +156,78 @@ const columns: TableColumn<Agent>[] = [
155
156
  function goToAgent(id: string) {
156
157
  navigateTo(`/agents/${id}`)
157
158
  }
159
+
160
+ // PR83b v3.4.0 — bulk selection + delete.
161
+ const confirmDialog = useConfirmDialog()
162
+ const selected = ref<Set<string>>(new Set())
163
+ const bulkDeleting = ref(false)
164
+
165
+ function toggleSelected(id: string) {
166
+ if (selected.value.has(id)) selected.value.delete(id)
167
+ else selected.value.add(id)
168
+ selected.value = new Set(selected.value)
169
+ }
170
+
171
+ function toggleAllVisible() {
172
+ const visibleIds = paginatedAgents.value.map((a) => a.id)
173
+ const allSelected = visibleIds.every((id) => selected.value.has(id))
174
+ const next = new Set(selected.value)
175
+ for (const id of visibleIds) {
176
+ if (allSelected) next.delete(id)
177
+ else next.add(id)
178
+ }
179
+ selected.value = next
180
+ }
181
+
182
+ const allVisibleSelected = computed(() => {
183
+ const visibleIds = paginatedAgents.value.map((a) => a.id)
184
+ return visibleIds.length > 0 && visibleIds.every((id) => selected.value.has(id))
185
+ })
186
+
187
+ function clearSelection() {
188
+ selected.value = new Set()
189
+ }
190
+
191
+ async function bulkDelete() {
192
+ if (selected.value.size === 0) return
193
+ const ids = Array.from(selected.value)
194
+ const ok = await confirmDialog({
195
+ title: `Delete ${ids.length} agent${ids.length === 1 ? '' : 's'}?`,
196
+ description: 'YAML files will be removed from disk. This cannot be undone. Tier 0 agents are protected and will be skipped.',
197
+ confirmLabel: `Delete ${ids.length}`,
198
+ cancelLabel: 'Cancel',
199
+ variant: 'danger',
200
+ })
201
+ if (!ok) return
202
+ bulkDeleting.value = true
203
+ const results = await Promise.allSettled(
204
+ ids.map((id) =>
205
+ $fetch<{ deleted?: boolean, error?: string }>(`${apiBase}/api/agents/${id}`, {
206
+ method: 'DELETE',
207
+ }),
208
+ ),
209
+ )
210
+ const successes = results.filter(
211
+ (r) => r.status === 'fulfilled' && r.value.deleted,
212
+ ).length
213
+ const failures = ids.length - successes
214
+ if (successes > 0) {
215
+ toast.add({
216
+ title: `Deleted ${successes} agent${successes === 1 ? '' : 's'}`,
217
+ description: failures > 0 ? `${failures} skipped (Tier 0 or missing)` : undefined,
218
+ color: failures > 0 ? 'warning' : 'success',
219
+ })
220
+ } else {
221
+ toast.add({
222
+ title: 'Nothing deleted',
223
+ description: 'All targets were protected or missing.',
224
+ color: 'error',
225
+ })
226
+ }
227
+ clearSelection()
228
+ bulkDeleting.value = false
229
+ await refreshAll()
230
+ }
158
231
  </script>
159
232
 
160
233
  <template>
@@ -233,6 +306,21 @@ function goToAgent(id: string) {
233
306
  td: 'border-b border-default'
234
307
  }"
235
308
  >
309
+ <template #select-header>
310
+ <UCheckbox
311
+ :model-value="allVisibleSelected"
312
+ aria-label="Select all visible"
313
+ @update:model-value="toggleAllVisible"
314
+ />
315
+ </template>
316
+ <template #select-cell="{ row }">
317
+ <UCheckbox
318
+ :model-value="selected.has(row.original.id)"
319
+ :aria-label="`Select ${row.original.name}`"
320
+ @update:model-value="() => toggleSelected(row.original.id)"
321
+ @click.stop
322
+ />
323
+ </template>
236
324
  <template #name-cell="{ row }">
237
325
  <button class="text-left font-medium text-primary hover:underline" @click="goToAgent(row.original.id)">
238
326
  {{ row.original.name }}
@@ -277,6 +365,39 @@ function goToAgent(id: string) {
277
365
  </template>
278
366
  </UTable>
279
367
 
368
+ <Transition
369
+ enter-active-class="transition ease-out duration-150"
370
+ enter-from-class="translate-y-4 opacity-0"
371
+ enter-to-class="translate-y-0 opacity-100"
372
+ leave-active-class="transition ease-in duration-100"
373
+ leave-from-class="translate-y-0 opacity-100"
374
+ leave-to-class="translate-y-4 opacity-0"
375
+ >
376
+ <div
377
+ v-if="selected.size > 0"
378
+ class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 rounded-xl border border-default bg-elevated shadow-lg px-4 py-2"
379
+ >
380
+ <span class="text-sm font-semibold">
381
+ {{ selected.size }} selected
382
+ </span>
383
+ <UButton
384
+ label="Clear"
385
+ variant="ghost"
386
+ size="xs"
387
+ @click="clearSelection"
388
+ />
389
+ <div class="h-5 w-px bg-default" />
390
+ <UButton
391
+ label="Delete"
392
+ icon="i-lucide-trash-2"
393
+ color="error"
394
+ size="sm"
395
+ :loading="bulkDeleting"
396
+ @click="bulkDelete"
397
+ />
398
+ </div>
399
+ </Transition>
400
+
280
401
  <div v-if="totalPages > 1" class="flex items-center justify-center mt-6">
281
402
  <UPagination
282
403
  :page="page"
@@ -8,7 +8,7 @@
8
8
  import type { TableColumn } from '@nuxt/ui'
9
9
  import type { Persona } from '~/types'
10
10
 
11
- const { fetchApi } = useApi()
11
+ const { fetchApi, apiBase } = useApi()
12
12
 
13
13
  const { data, status, error, refresh } = await fetchApi<{
14
14
  personas: Persona[]
@@ -118,6 +118,7 @@ function agentCount(personaId: string): number {
118
118
  }
119
119
 
120
120
  const columns: TableColumn<Persona>[] = [
121
+ { id: 'select', header: '' },
121
122
  { accessorKey: 'name', header: 'Name' },
122
123
  { accessorKey: 'title', header: 'Title' },
123
124
  { accessorKey: 'source', header: 'Source' },
@@ -131,6 +132,78 @@ const columns: TableColumn<Persona>[] = [
131
132
  function goToPersona(id: string) {
132
133
  navigateTo(`/personas/${id}`)
133
134
  }
135
+
136
+ // PR83b v3.4.0 — bulk selection + delete.
137
+ const toast = useToast()
138
+ const confirmDialog = useConfirmDialog()
139
+ const selected = ref<Set<string>>(new Set())
140
+ const bulkDeleting = ref(false)
141
+
142
+ function toggleSelected(id: string) {
143
+ if (selected.value.has(id)) selected.value.delete(id)
144
+ else selected.value.add(id)
145
+ selected.value = new Set(selected.value)
146
+ }
147
+
148
+ function toggleAllVisible() {
149
+ const visibleIds = paginatedPersonas.value.map((p) => p.id)
150
+ const allSelected = visibleIds.every((id) => selected.value.has(id))
151
+ const next = new Set(selected.value)
152
+ for (const id of visibleIds) {
153
+ if (allSelected) next.delete(id)
154
+ else next.add(id)
155
+ }
156
+ selected.value = next
157
+ }
158
+
159
+ const allVisibleSelected = computed(() => {
160
+ const visibleIds = paginatedPersonas.value.map((p) => p.id)
161
+ return visibleIds.length > 0 && visibleIds.every((id) => selected.value.has(id))
162
+ })
163
+
164
+ function clearSelection() {
165
+ selected.value = new Set()
166
+ }
167
+
168
+ async function bulkDelete() {
169
+ if (selected.value.size === 0) return
170
+ const ids = Array.from(selected.value)
171
+ const ok = await confirmDialog({
172
+ title: `Delete ${ids.length} persona${ids.length === 1 ? '' : 's'}?`,
173
+ description: 'Personas will be removed from the JSON store and the Obsidian vault. This cannot be undone.',
174
+ confirmLabel: `Delete ${ids.length}`,
175
+ cancelLabel: 'Cancel',
176
+ variant: 'danger',
177
+ })
178
+ if (!ok) return
179
+ bulkDeleting.value = true
180
+ const results = await Promise.allSettled(
181
+ ids.map((id) =>
182
+ $fetch<{ deleted?: boolean, error?: string }>(`${apiBase}/api/personas/${id}`, {
183
+ method: 'DELETE',
184
+ }),
185
+ ),
186
+ )
187
+ const successes = results.filter(
188
+ (r) => r.status === 'fulfilled' && r.value.deleted,
189
+ ).length
190
+ const failures = ids.length - successes
191
+ if (successes > 0) {
192
+ toast.add({
193
+ title: `Deleted ${successes} persona${successes === 1 ? '' : 's'}`,
194
+ description: failures > 0 ? `${failures} failed` : undefined,
195
+ color: failures > 0 ? 'warning' : 'success',
196
+ })
197
+ } else {
198
+ toast.add({
199
+ title: 'Nothing deleted',
200
+ color: 'error',
201
+ })
202
+ }
203
+ clearSelection()
204
+ bulkDeleting.value = false
205
+ await refreshAll()
206
+ }
134
207
  </script>
135
208
 
136
209
  <template>
@@ -218,6 +291,21 @@ function goToPersona(id: string) {
218
291
  }"
219
292
  @select="(row: Persona) => goToPersona(row.id)"
220
293
  >
294
+ <template #select-header>
295
+ <UCheckbox
296
+ :model-value="allVisibleSelected"
297
+ aria-label="Select all visible"
298
+ @update:model-value="toggleAllVisible"
299
+ />
300
+ </template>
301
+ <template #select-cell="{ row }">
302
+ <UCheckbox
303
+ :model-value="selected.has(row.original.id)"
304
+ :aria-label="`Select ${row.original.name}`"
305
+ @update:model-value="() => toggleSelected(row.original.id)"
306
+ @click.stop
307
+ />
308
+ </template>
221
309
  <template #name-cell="{ row }">
222
310
  <span class="font-medium">{{ row.original.name }}</span>
223
311
  </template>
@@ -282,6 +370,39 @@ function goToPersona(id: string) {
282
370
  </template>
283
371
  </UTable>
284
372
 
373
+ <Transition
374
+ enter-active-class="transition ease-out duration-150"
375
+ enter-from-class="translate-y-4 opacity-0"
376
+ enter-to-class="translate-y-0 opacity-100"
377
+ leave-active-class="transition ease-in duration-100"
378
+ leave-from-class="translate-y-0 opacity-100"
379
+ leave-to-class="translate-y-4 opacity-0"
380
+ >
381
+ <div
382
+ v-if="selected.size > 0"
383
+ class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 rounded-xl border border-default bg-elevated shadow-lg px-4 py-2"
384
+ >
385
+ <span class="text-sm font-semibold">
386
+ {{ selected.size }} selected
387
+ </span>
388
+ <UButton
389
+ label="Clear"
390
+ variant="ghost"
391
+ size="xs"
392
+ @click="clearSelection"
393
+ />
394
+ <div class="h-5 w-px bg-default" />
395
+ <UButton
396
+ label="Delete"
397
+ icon="i-lucide-trash-2"
398
+ color="error"
399
+ size="sm"
400
+ :loading="bulkDeleting"
401
+ @click="bulkDelete"
402
+ />
403
+ </div>
404
+ </Transition>
405
+
285
406
  <div v-if="totalPages > 1" class="flex items-center justify-center mt-6">
286
407
  <UPagination
287
408
  :page="page"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.2.0",
3
+ "version": "3.4.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 = "3.2.0"
3
+ version = "3.4.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"}
@@ -1094,6 +1094,55 @@ def persona_clone(persona_id: str, body: dict = {}):
1094
1094
  return {"agent_id": agent_id, "department": department, "file": f"departments/{department}/agents/{agent_id}.yaml"}
1095
1095
 
1096
1096
 
1097
+ @app.delete("/api/agents/{agent_id}")
1098
+ def agent_delete(agent_id: str):
1099
+ """PR83b v3.4.0 — delete an agent's YAML file.
1100
+
1101
+ Refuses to delete Tier 0 (C-Suite) agents — those are governance
1102
+ fixtures and need direct YAML removal to make the intent explicit.
1103
+
1104
+ Resolves the YAML location two ways:
1105
+ 1. From the cached registry (covers seeded agents)
1106
+ 2. By scanning departments/*/agents/<id>.yaml (covers
1107
+ freshly-created agents that aren't in the registry yet)
1108
+ """
1109
+ yaml_file = _resolve_agent_yaml(agent_id)
1110
+ if yaml_file is None:
1111
+ return {"error": "Agent not found"}
1112
+ tier = _agent_tier_from_yaml(yaml_file)
1113
+ if tier == 0:
1114
+ return {"error": "Cannot delete Tier 0 (C-Suite) agents from the dashboard"}
1115
+ try:
1116
+ yaml_file.unlink()
1117
+ except OSError as exc:
1118
+ return {"error": f"delete failed: {exc}"}
1119
+ return {"deleted": True, "id": agent_id, "yaml_path": str(yaml_file)}
1120
+
1121
+
1122
+ def _resolve_agent_yaml(agent_id: str) -> Optional[Path]:
1123
+ # 1. Check the cached registry first.
1124
+ for a in _load_agents():
1125
+ if a.get("id") == agent_id:
1126
+ candidate = ARKAOS_ROOT / a.get("file", "")
1127
+ if candidate.exists():
1128
+ return candidate
1129
+ # 2. Filesystem scan — covers freshly-created files.
1130
+ dept_root = ARKAOS_ROOT / "departments"
1131
+ if dept_root.exists():
1132
+ for path in dept_root.glob(f"*/agents/{agent_id}.yaml"):
1133
+ return path
1134
+ return None
1135
+
1136
+
1137
+ def _agent_tier_from_yaml(yaml_file: Path) -> int:
1138
+ try:
1139
+ import yaml as _yaml
1140
+ raw = _yaml.safe_load(yaml_file.read_text(encoding="utf-8")) or {}
1141
+ except Exception:
1142
+ return 99
1143
+ return int(raw.get("tier") or 99)
1144
+
1145
+
1097
1146
  @app.delete("/api/personas/{persona_id}")
1098
1147
  def persona_delete(persona_id: str):
1099
1148
  mgr = _get_persona_manager()
@@ -1827,6 +1876,43 @@ def _build_agent_yaml(
1827
1876
  return payload
1828
1877
 
1829
1878
 
1879
+ # --- AI persona draft from description (PR83a v3.3.0) ---
1880
+
1881
+ @app.post("/api/personas/draft")
1882
+ def personas_draft(body: dict):
1883
+ """Generate a Persona draft from a free-text description (no vector DB).
1884
+
1885
+ Body: {
1886
+ "description": "...", # min 20 chars
1887
+ "name": "Alex Carter", # required
1888
+ "source_label": "..." # optional
1889
+ }
1890
+ Returns: {"persona": {...}, "provider_name": "..."}
1891
+
1892
+ Sibling to /api/personas/build (which requires indexed chunks). Useful
1893
+ when the operator wants a quick draft without ingesting sources first.
1894
+ The result is NOT saved — operator reviews + POSTs to /api/personas.
1895
+ """
1896
+ from core.personas.description_drafter import (
1897
+ PersonaDraftError,
1898
+ draft_persona_from_description,
1899
+ )
1900
+
1901
+ description = (body.get("description") or "").strip()
1902
+ name = (body.get("name") or "").strip()
1903
+ source_label = (body.get("source_label") or "").strip()
1904
+ try:
1905
+ res = draft_persona_from_description(
1906
+ description, name=name, source_label=source_label,
1907
+ )
1908
+ except PersonaDraftError as exc:
1909
+ return {"error": str(exc)}
1910
+ return {
1911
+ "persona": res.persona.model_dump(),
1912
+ "provider_name": res.provider_name,
1913
+ }
1914
+
1915
+
1830
1916
  # --- AI agent draft from description (PR82b v3.1.0) ---
1831
1917
 
1832
1918
  @app.post("/api/agents/draft")