arkaos 3.3.0 → 3.5.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.3.0
1
+ 3.5.0
@@ -0,0 +1,111 @@
1
+ """AI-assisted single-string suggester (PR83c v3.5.0).
2
+
3
+ Sibling to `core/agents/field_suggester` but for fields that hold ONE
4
+ string value (not a list). Used by the ✨ Generate button next to
5
+ fields like `communication.tone`, `communication.preferred_format`,
6
+ and `communication.language`.
7
+
8
+ Where the list suggester APPENDS, this one REPLACES. The caller is
9
+ expected to swap the field's existing value with the result.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass
16
+
17
+ from core.runtime.llm_provider import LLMProvider, LLMUnavailable, get_llm_provider
18
+
19
+
20
+ _VALID_FIELDS: tuple[str, ...] = ("tone", "preferred_format", "language")
21
+
22
+ _SYSTEM = (
23
+ "You suggest concise single-string values for behavioural agent and "
24
+ "persona profile fields. Return ONLY the value as plain text — no "
25
+ "JSON, no fences, no quotes, no explanations."
26
+ )
27
+
28
+ _FIELD_HINTS: dict[str, str] = {
29
+ "tone": (
30
+ "Return 2-4 adjectives separated by commas describing the "
31
+ "profile's voice (e.g. 'Direct, analytical, crisp'). "
32
+ "Max 60 characters."
33
+ ),
34
+ "preferred_format": (
35
+ "Return a short noun phrase listing the formats this profile "
36
+ "prefers (e.g. 'Briefs, tables, ASCII diagrams'). Max 80 characters."
37
+ ),
38
+ "language": (
39
+ "Return a comma-separated list of IETF language tags this profile "
40
+ "writes in (e.g. 'en' or 'en, pt-PT'). Max 20 characters."
41
+ ),
42
+ }
43
+
44
+ _MAX_LEN: dict[str, int] = {"tone": 60, "preferred_format": 80, "language": 20}
45
+
46
+
47
+ class StringSuggestionError(RuntimeError):
48
+ """LLM produced unusable output or could not be reached."""
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class StringSuggestionResult:
53
+ value: str
54
+ provider_name: str
55
+
56
+
57
+ def suggest_string_field(
58
+ field: str,
59
+ context: dict,
60
+ *,
61
+ provider: LLMProvider | None = None,
62
+ ) -> StringSuggestionResult:
63
+ """Return an AI-suggested single-string value for `field`."""
64
+ if field not in _VALID_FIELDS:
65
+ raise StringSuggestionError(f"unknown field: {field!r}")
66
+ llm = provider or get_llm_provider()
67
+ prompt = _build_prompt(field, context)
68
+ try:
69
+ resp = llm.complete(prompt, max_tokens=200, system=_SYSTEM)
70
+ except LLMUnavailable as exc:
71
+ raise StringSuggestionError(str(exc)) from exc
72
+ value = _clean(resp.text, field)
73
+ if not value:
74
+ raise StringSuggestionError("LLM returned an empty value")
75
+ return StringSuggestionResult(value=value, provider_name=llm.name())
76
+
77
+
78
+ def _build_prompt(field: str, context: dict) -> str:
79
+ name = (context.get("name") or "").strip() or "the entity"
80
+ role = (context.get("role") or context.get("title") or "").strip()
81
+ department = (context.get("department") or "").strip()
82
+ current = (context.get("current") or "").strip()
83
+ lines = [f"Suggest a {field.replace('_', ' ')} for {name}."]
84
+ if role:
85
+ lines.append(f"Role: {role}.")
86
+ if department:
87
+ lines.append(f"Department: {department}.")
88
+ if current:
89
+ lines.append(f"Current value (to be replaced, do not repeat verbatim): {current}.")
90
+ lines.append(_FIELD_HINTS[field])
91
+ return "\n".join(lines)
92
+
93
+
94
+ def _clean(text: str, field: str) -> str:
95
+ """Strip fences, quotes, leading bullets and trim to the field's max."""
96
+ cleaned = text.strip()
97
+ cleaned = re.sub(r"^```(?:\w+)?\s*|\s*```$", "", cleaned, flags=re.MULTILINE)
98
+ cleaned = cleaned.strip()
99
+ # Remove surrounding quotes the LLM may add.
100
+ if len(cleaned) >= 2 and cleaned[0] in '"\'`' and cleaned[-1] == cleaned[0]:
101
+ cleaned = cleaned[1:-1].strip()
102
+ # Drop a leading bullet/numbering.
103
+ cleaned = re.sub(r"^[-*•·]\s+", "", cleaned)
104
+ cleaned = re.sub(r"^\d+[.)]\s+", "", cleaned)
105
+ # Single-line: collapse internal whitespace, drop trailing punctuation.
106
+ cleaned = " ".join(cleaned.split())
107
+ cleaned = cleaned.rstrip(".")
108
+ max_len = _MAX_LEN[field]
109
+ if len(cleaned) > max_len:
110
+ cleaned = cleaned[:max_len].rstrip(", ").rstrip()
111
+ return cleaned
@@ -115,6 +115,52 @@ function csvToList(value: string): string[] {
115
115
  type SuggestField = 'mental_models_primary' | 'frameworks' | 'expertise_domains' | 'communication_avoid'
116
116
  const suggestingField = ref<SuggestField | null>(null)
117
117
 
118
+ // PR83c v3.5.0 — single-string suggester.
119
+ type StringField = 'tone' | 'preferred_format'
120
+ const suggestingString = ref<StringField | null>(null)
121
+
122
+ async function suggestString(field: StringField) {
123
+ if (!draft.value || !props.agent) return
124
+ const current
125
+ = field === 'tone' ? draft.value.communication.tone : draft.value.communication.preferred_format
126
+ suggestingString.value = field
127
+ try {
128
+ const res = await $fetch<{ value: string, provider_name: string, error?: string }>(
129
+ `${apiBase}/api/agents/suggest-string`,
130
+ {
131
+ method: 'POST',
132
+ body: {
133
+ field,
134
+ context: {
135
+ name: props.agent.name,
136
+ role: props.agent.role,
137
+ department: props.agent.department,
138
+ current,
139
+ },
140
+ },
141
+ },
142
+ )
143
+ if (res.error) throw new Error(res.error)
144
+ if (field === 'tone') draft.value.communication.tone = res.value
145
+ else draft.value.communication.preferred_format = res.value
146
+ markDirty()
147
+ toast.add({
148
+ title: 'Generated',
149
+ description: `via ${res.provider_name}`,
150
+ color: 'success',
151
+ icon: 'i-lucide-sparkles',
152
+ })
153
+ } catch (err) {
154
+ toast.add({
155
+ title: 'Generate failed',
156
+ description: err instanceof Error ? err.message : 'unknown error',
157
+ color: 'error',
158
+ })
159
+ } finally {
160
+ suggestingString.value = null
161
+ }
162
+ }
163
+
118
164
  async function suggest(field: SuggestField) {
119
165
  if (!draft.value || !props.agent) return
120
166
  const backendField = field === 'mental_models_primary' ? 'mental_models' : field
@@ -424,6 +470,18 @@ const vocabOptions = [
424
470
  <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Communication</h3>
425
471
  <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
426
472
  <UFormField label="Tone">
473
+ <template #hint>
474
+ <UButton
475
+ label="Generate"
476
+ icon="i-lucide-sparkles"
477
+ size="xs"
478
+ color="primary"
479
+ variant="soft"
480
+ :loading="suggestingString === 'tone'"
481
+ :disabled="suggestingString !== null"
482
+ @click="suggestString('tone')"
483
+ />
484
+ </template>
427
485
  <UInput v-model="draft.communication.tone" class="w-full" @update:model-value="markDirty" />
428
486
  </UFormField>
429
487
  <UFormField label="Vocabulary level">
@@ -435,6 +493,18 @@ const vocabOptions = [
435
493
  />
436
494
  </UFormField>
437
495
  <UFormField label="Preferred format">
496
+ <template #hint>
497
+ <UButton
498
+ label="Generate"
499
+ icon="i-lucide-sparkles"
500
+ size="xs"
501
+ color="primary"
502
+ variant="soft"
503
+ :loading="suggestingString === 'preferred_format'"
504
+ :disabled="suggestingString !== null"
505
+ @click="suggestString('preferred_format')"
506
+ />
507
+ </template>
438
508
  <UInput v-model="draft.communication.preferred_format" class="w-full" @update:model-value="markDirty" />
439
509
  </UFormField>
440
510
  <UFormField label="Language">
@@ -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"
@@ -88,6 +88,56 @@ const draft = ref<AgentDraft>({
88
88
 
89
89
  const saving = ref(false)
90
90
 
91
+ // PR83c v3.5.0 — single-string suggester.
92
+ type StringField = 'tone' | 'preferred_format'
93
+ const suggestingString = ref<StringField | null>(null)
94
+
95
+ async function suggestString(field: StringField) {
96
+ if (!draft.value.name.trim() || !draft.value.role.trim()) {
97
+ toast.add({
98
+ title: 'Add a name and role first',
99
+ color: 'warning',
100
+ })
101
+ return
102
+ }
103
+ const current = field === 'tone' ? draft.value.comm_tone : draft.value.comm_format
104
+ suggestingString.value = field
105
+ try {
106
+ const res = await $fetch<{ value: string, provider_name: string, error?: string }>(
107
+ `${apiBase}/api/agents/suggest-string`,
108
+ {
109
+ method: 'POST',
110
+ body: {
111
+ field,
112
+ context: {
113
+ name: draft.value.name,
114
+ role: draft.value.role,
115
+ department: draft.value.department,
116
+ current,
117
+ },
118
+ },
119
+ },
120
+ )
121
+ if (res.error) throw new Error(res.error)
122
+ if (field === 'tone') draft.value.comm_tone = res.value
123
+ else draft.value.comm_format = res.value
124
+ toast.add({
125
+ title: 'Generated',
126
+ description: `via ${res.provider_name}`,
127
+ color: 'success',
128
+ icon: 'i-lucide-sparkles',
129
+ })
130
+ } catch (err) {
131
+ toast.add({
132
+ title: 'Generate failed',
133
+ description: err instanceof Error ? err.message : 'unknown error',
134
+ color: 'error',
135
+ })
136
+ } finally {
137
+ suggestingString.value = null
138
+ }
139
+ }
140
+
91
141
  // PR82b v3.1.0 — AI draft from description.
92
142
  const description = ref('')
93
143
  const drafting = ref(false)
@@ -544,12 +594,36 @@ const bigFiveKeys = ['openness', 'conscientiousness', 'extraversion', 'agreeable
544
594
  <h3 class="text-sm font-semibold uppercase tracking-wide text-muted">Communication</h3>
545
595
  <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
546
596
  <UFormField label="Tone">
597
+ <template #hint>
598
+ <UButton
599
+ label="Generate"
600
+ icon="i-lucide-sparkles"
601
+ size="xs"
602
+ color="primary"
603
+ variant="soft"
604
+ :loading="suggestingString === 'tone'"
605
+ :disabled="suggestingString !== null"
606
+ @click="suggestString('tone')"
607
+ />
608
+ </template>
547
609
  <UInput v-model="draft.comm_tone" class="w-full" placeholder="Analytical, calm" />
548
610
  </UFormField>
549
611
  <UFormField label="Vocabulary level">
550
612
  <USelect v-model="draft.comm_vocab" :items="vocabOptions" class="w-full" />
551
613
  </UFormField>
552
614
  <UFormField label="Preferred format">
615
+ <template #hint>
616
+ <UButton
617
+ label="Generate"
618
+ icon="i-lucide-sparkles"
619
+ size="xs"
620
+ color="primary"
621
+ variant="soft"
622
+ :loading="suggestingString === 'preferred_format'"
623
+ :disabled="suggestingString !== null"
624
+ @click="suggestString('preferred_format')"
625
+ />
626
+ </template>
553
627
  <UInput v-model="draft.comm_format" class="w-full" placeholder="Briefs, tables, charts" />
554
628
  </UFormField>
555
629
  <UFormField label="Language">
@@ -226,6 +226,48 @@ function csvToList(value: string): string[] {
226
226
  type SuggestField = 'mental_models' | 'frameworks' | 'expertise_domains' | 'communication_avoid' | 'key_quotes'
227
227
  const suggestingField = ref<SuggestField | null>(null)
228
228
 
229
+ // PR83c v3.5.0 — single-string suggester (tone for personas).
230
+ const suggestingString = ref<'tone' | null>(null)
231
+
232
+ async function suggestString(field: 'tone') {
233
+ if (!draft.value || !detail.value) return
234
+ const current = draft.value.communication.tone
235
+ suggestingString.value = field
236
+ try {
237
+ const res = await $fetch<{ value: string, provider_name: string, error?: string }>(
238
+ `${apiBase}/api/personas/suggest-string`,
239
+ {
240
+ method: 'POST',
241
+ body: {
242
+ field,
243
+ context: {
244
+ name: detail.value.name,
245
+ title: detail.value.title,
246
+ current,
247
+ },
248
+ },
249
+ },
250
+ )
251
+ if (res.error) throw new Error(res.error)
252
+ draft.value.communication.tone = res.value
253
+ markDirty()
254
+ toast.add({
255
+ title: 'Generated',
256
+ description: `via ${res.provider_name}`,
257
+ color: 'success',
258
+ icon: 'i-lucide-sparkles',
259
+ })
260
+ } catch (err) {
261
+ toast.add({
262
+ title: 'Generate failed',
263
+ description: err instanceof Error ? err.message : 'unknown error',
264
+ color: 'error',
265
+ })
266
+ } finally {
267
+ suggestingString.value = null
268
+ }
269
+ }
270
+
229
271
  async function suggest(field: SuggestField) {
230
272
  if (!draft.value || !detail.value) return
231
273
  const current
@@ -754,6 +796,18 @@ const vocabOptions = [
754
796
  <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Communication</h3>
755
797
  <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
756
798
  <UFormField label="Tone">
799
+ <template #hint>
800
+ <UButton
801
+ label="Generate"
802
+ icon="i-lucide-sparkles"
803
+ size="xs"
804
+ color="primary"
805
+ variant="soft"
806
+ :loading="suggestingString === 'tone'"
807
+ :disabled="suggestingString !== null"
808
+ @click="suggestString('tone')"
809
+ />
810
+ </template>
757
811
  <UInput v-model="draft.communication.tone" class="w-full" @update:model-value="markDirty" />
758
812
  </UFormField>
759
813
  <UFormField label="Vocabulary level">
@@ -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.3.0",
3
+ "version": "3.5.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.3.0"
3
+ version = "3.5.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()
@@ -1900,6 +1949,33 @@ def agents_draft(body: dict):
1900
1949
  return {"draft": res.draft, "provider_name": res.provider_name}
1901
1950
 
1902
1951
 
1952
+ # --- AI single-string suggester (PR83c v3.5.0) ---
1953
+
1954
+ @app.post("/api/agents/suggest-string")
1955
+ def agents_suggest_string(body: dict):
1956
+ """Suggest a single-string value (tone, preferred_format, language)."""
1957
+ return _do_string_suggest(body, source="agent")
1958
+
1959
+
1960
+ @app.post("/api/personas/suggest-string")
1961
+ def personas_suggest_string(body: dict):
1962
+ return _do_string_suggest(body, source="persona")
1963
+
1964
+
1965
+ def _do_string_suggest(body: dict, *, source: str) -> dict:
1966
+ from core.agents.string_suggester import (
1967
+ StringSuggestionError,
1968
+ suggest_string_field,
1969
+ )
1970
+ field = (body.get("field") or "").strip()
1971
+ context = body.get("context") or {}
1972
+ try:
1973
+ res = suggest_string_field(field, context)
1974
+ except StringSuggestionError as exc:
1975
+ return {"error": str(exc)}
1976
+ return {"value": res.value, "provider_name": res.provider_name, "source": source}
1977
+
1978
+
1903
1979
  # --- AI list-field suggester (PR81 v2.99.0) ---
1904
1980
 
1905
1981
  @app.post("/api/agents/suggest")