arkaos 3.13.0 → 3.15.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.13.0
1
+ 3.15.0
@@ -0,0 +1,91 @@
1
+ """Favorites store for agents and personas (PR86a v3.15.0).
2
+
3
+ Single JSON file at ``~/.arkaos/favorites.json`` shaped as
4
+ ``{"agents": ["<id>", ...], "personas": ["<id>", ...]}``.
5
+
6
+ Survives across sessions, mutated by the dashboard. No tier-0 protection
7
+ needed — favouriting is read-only intent.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+ from typing import Literal
15
+
16
+ FavoriteKind = Literal["agents", "personas"]
17
+ _VALID_KINDS: tuple[str, ...] = ("agents", "personas")
18
+
19
+
20
+ def _store_path() -> Path:
21
+ return Path.home() / ".arkaos" / "favorites.json"
22
+
23
+
24
+ def _load() -> dict[str, list[str]]:
25
+ path = _store_path()
26
+ if not path.exists():
27
+ return {"agents": [], "personas": []}
28
+ try:
29
+ data = json.loads(path.read_text(encoding="utf-8"))
30
+ except (OSError, json.JSONDecodeError):
31
+ return {"agents": [], "personas": []}
32
+ if not isinstance(data, dict):
33
+ return {"agents": [], "personas": []}
34
+ return {
35
+ "agents": [str(x) for x in (data.get("agents") or []) if isinstance(x, str)],
36
+ "personas": [str(x) for x in (data.get("personas") or []) if isinstance(x, str)],
37
+ }
38
+
39
+
40
+ def _save(state: dict[str, list[str]]) -> None:
41
+ path = _store_path()
42
+ path.parent.mkdir(parents=True, exist_ok=True)
43
+ tmp = path.with_suffix(".json.tmp")
44
+ tmp.write_text(json.dumps(state, indent=2), encoding="utf-8")
45
+ tmp.replace(path)
46
+
47
+
48
+ def list_favorites() -> dict[str, list[str]]:
49
+ """Return the current favourites payload."""
50
+ return _load()
51
+
52
+
53
+ def is_favorite(kind: str, item_id: str) -> bool:
54
+ if kind not in _VALID_KINDS:
55
+ return False
56
+ state = _load()
57
+ return item_id in state.get(kind, [])
58
+
59
+
60
+ def toggle(kind: str, item_id: str) -> dict:
61
+ """Flip the favourite state. Returns ``{kind, id, favorited}``."""
62
+ if kind not in _VALID_KINDS:
63
+ return {"error": f"unknown kind: {kind!r}"}
64
+ if not item_id:
65
+ return {"error": "id is required"}
66
+ state = _load()
67
+ bucket = state.setdefault(kind, [])
68
+ if item_id in bucket:
69
+ bucket.remove(item_id)
70
+ favorited = False
71
+ else:
72
+ bucket.append(item_id)
73
+ favorited = True
74
+ state[kind] = bucket
75
+ _save(state)
76
+ return {"kind": kind, "id": item_id, "favorited": favorited}
77
+
78
+
79
+ def set_favorite(kind: str, item_id: str, favorited: bool) -> dict:
80
+ """Force a specific state. Useful for tests / bulk operations."""
81
+ if kind not in _VALID_KINDS:
82
+ return {"error": f"unknown kind: {kind!r}"}
83
+ state = _load()
84
+ bucket = state.setdefault(kind, [])
85
+ if favorited and item_id not in bucket:
86
+ bucket.append(item_id)
87
+ elif not favorited and item_id in bucket:
88
+ bucket.remove(item_id)
89
+ state[kind] = bucket
90
+ _save(state)
91
+ return {"kind": kind, "id": item_id, "favorited": favorited}
@@ -0,0 +1,154 @@
1
+ <script setup lang="ts">
2
+ // PR85d v3.14.0 — global search command palette.
3
+ //
4
+ // Opens via `/` shortcut. Debounced fetch against /api/search hits
5
+ // agents, personas, departments, commands. Navigate by Enter or click.
6
+
7
+ const { searchOpen } = useDashboard()
8
+ const { apiBase } = useApi()
9
+ const router = useRouter()
10
+
11
+ interface SearchResult {
12
+ kind: 'agent' | 'persona' | 'department' | 'command'
13
+ id: string
14
+ label: string
15
+ sublabel: string
16
+ to: string
17
+ }
18
+
19
+ const query = ref('')
20
+ const results = ref<SearchResult[]>([])
21
+ const loading = ref(false)
22
+ let abortCtl: AbortController | null = null
23
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null
24
+
25
+ watch(query, (q) => {
26
+ if (debounceTimer) clearTimeout(debounceTimer)
27
+ if (abortCtl) abortCtl.abort()
28
+ if (!q.trim()) {
29
+ results.value = []
30
+ loading.value = false
31
+ return
32
+ }
33
+ debounceTimer = setTimeout(async () => {
34
+ loading.value = true
35
+ abortCtl = new AbortController()
36
+ try {
37
+ const res = await $fetch<{ results: SearchResult[] }>(
38
+ `${apiBase}/api/search`,
39
+ { query: { q, limit: 20 }, signal: abortCtl.signal },
40
+ )
41
+ results.value = res.results ?? []
42
+ } catch {
43
+ results.value = []
44
+ } finally {
45
+ loading.value = false
46
+ }
47
+ }, 180)
48
+ })
49
+
50
+ watch(searchOpen, (open) => {
51
+ if (!open) {
52
+ query.value = ''
53
+ results.value = []
54
+ }
55
+ })
56
+
57
+ function pickResult(r: SearchResult) {
58
+ searchOpen.value = false
59
+ router.push(r.to)
60
+ }
61
+
62
+ const kindMeta: Record<SearchResult['kind'], { icon: string, color: string }> = {
63
+ agent: { icon: 'i-lucide-users', color: 'text-primary' },
64
+ persona: { icon: 'i-lucide-user-plus', color: 'text-emerald-500' },
65
+ department: { icon: 'i-lucide-folder-tree', color: 'text-amber-500' },
66
+ command: { icon: 'i-lucide-terminal', color: 'text-blue-500' },
67
+ }
68
+ </script>
69
+
70
+ <template>
71
+ <UModal
72
+ v-model:open="searchOpen"
73
+ title="Search"
74
+ :ui="{ content: 'max-w-2xl' }"
75
+ >
76
+ <template #content>
77
+ <UCard :ui="{ body: 'p-0' }">
78
+ <template #header>
79
+ <div class="flex items-center gap-3">
80
+ <UIcon name="i-lucide-search" class="size-5 text-muted shrink-0" />
81
+ <UInput
82
+ v-model="query"
83
+ placeholder="Search agents, personas, departments, commands…"
84
+ size="lg"
85
+ autofocus
86
+ :ui="{ root: 'flex-1', base: 'border-0 shadow-none ring-0 focus:ring-0 px-0' }"
87
+ />
88
+ <kbd class="px-1.5 py-0.5 rounded bg-elevated/50 text-xs font-mono text-muted shrink-0">
89
+ esc
90
+ </kbd>
91
+ </div>
92
+ </template>
93
+
94
+ <div class="max-h-[60vh] overflow-y-auto">
95
+ <div v-if="loading" class="p-6 text-center text-sm text-muted">
96
+ <UIcon name="i-lucide-loader-2" class="size-4 animate-spin inline" />
97
+ Searching…
98
+ </div>
99
+ <div
100
+ v-else-if="!query.trim()"
101
+ class="p-6 text-center text-sm text-muted"
102
+ >
103
+ Start typing to search across the whole workspace.
104
+ </div>
105
+ <div
106
+ v-else-if="results.length === 0"
107
+ class="p-6 text-center text-sm text-muted"
108
+ >
109
+ No matches for <span class="font-mono">{{ query }}</span>.
110
+ </div>
111
+ <ul v-else class="divide-y divide-default">
112
+ <li
113
+ v-for="r in results"
114
+ :key="`${r.kind}:${r.id}`"
115
+ class="px-4 py-2.5 hover:bg-elevated/40 cursor-pointer transition-colors"
116
+ @click="pickResult(r)"
117
+ >
118
+ <div class="flex items-center gap-3">
119
+ <UIcon
120
+ :name="kindMeta[r.kind].icon"
121
+ class="size-4 shrink-0"
122
+ :class="kindMeta[r.kind].color"
123
+ />
124
+ <div class="min-w-0 flex-1">
125
+ <p class="text-sm font-semibold truncate">{{ r.label }}</p>
126
+ <p class="text-xs text-muted truncate">{{ r.sublabel }}</p>
127
+ </div>
128
+ <UBadge
129
+ :label="r.kind"
130
+ variant="subtle"
131
+ size="xs"
132
+ class="capitalize shrink-0"
133
+ />
134
+ </div>
135
+ </li>
136
+ </ul>
137
+ </div>
138
+
139
+ <template #footer>
140
+ <div class="text-xs text-muted flex items-center gap-3">
141
+ <span>
142
+ <kbd class="px-1.5 py-0.5 rounded bg-elevated/50 font-mono">/</kbd>
143
+ opens this
144
+ </span>
145
+ <span>
146
+ <kbd class="px-1.5 py-0.5 rounded bg-elevated/50 font-mono">esc</kbd>
147
+ closes
148
+ </span>
149
+ </div>
150
+ </template>
151
+ </UCard>
152
+ </template>
153
+ </UModal>
154
+ </template>
@@ -25,6 +25,7 @@ const groups = [
25
25
  {
26
26
  title: 'Actions',
27
27
  items: [
28
+ { keys: ['/'], label: 'Open global search' },
28
29
  { keys: ['n'], label: 'New (context-aware — agent / persona)' },
29
30
  { keys: ['?'], label: 'Toggle this help' },
30
31
  ],
@@ -5,6 +5,8 @@ const _useDashboard = () => {
5
5
  const router = useRouter()
6
6
  const route = useRoute()
7
7
  const shortcutsHelpOpen = useState('shortcutsHelpOpen', () => false)
8
+ // PR85d v3.14.0 — global search palette state.
9
+ const searchOpen = useState('searchOpen', () => false)
8
10
 
9
11
  function contextualNew() {
10
12
  const path = route.path
@@ -27,9 +29,10 @@ const _useDashboard = () => {
27
29
  'g-r': () => router.push('/trash'),
28
30
  n: () => contextualNew(),
29
31
  '?': () => { shortcutsHelpOpen.value = !shortcutsHelpOpen.value },
32
+ '/': () => { searchOpen.value = true },
30
33
  })
31
34
 
32
- return { shortcutsHelpOpen }
35
+ return { shortcutsHelpOpen, searchOpen }
33
36
  }
34
37
 
35
38
  export const useDashboard = createSharedComposable(_useDashboard)
@@ -0,0 +1,73 @@
1
+ // PR86a v3.15.0 — shared favourites state + toggle helper.
2
+ //
3
+ // Single source of truth for the dashboard so the star button in
4
+ // agents/personas detail pages stays in sync with the filter chip
5
+ // on the index tables.
6
+
7
+ import { createSharedComposable } from '@vueuse/core'
8
+
9
+ interface FavoritesPayload {
10
+ agents: string[]
11
+ personas: string[]
12
+ }
13
+
14
+ const _useFavorites = () => {
15
+ const { apiBase } = useApi()
16
+ const toast = useToast()
17
+ const state = useState<FavoritesPayload>('favorites', () => ({
18
+ agents: [],
19
+ personas: [],
20
+ }))
21
+ const loaded = useState<boolean>('favoritesLoaded', () => false)
22
+
23
+ async function load(force = false) {
24
+ if (loaded.value && !force) return
25
+ try {
26
+ const res = await $fetch<FavoritesPayload>(`${apiBase}/api/favorites`)
27
+ state.value = {
28
+ agents: res.agents ?? [],
29
+ personas: res.personas ?? [],
30
+ }
31
+ loaded.value = true
32
+ } catch {
33
+ // Best-effort — leave defaults
34
+ loaded.value = true
35
+ }
36
+ }
37
+
38
+ function isAgentFavorite(id: string): boolean {
39
+ return state.value.agents.includes(id)
40
+ }
41
+
42
+ function isPersonaFavorite(id: string): boolean {
43
+ return state.value.personas.includes(id)
44
+ }
45
+
46
+ async function toggle(kind: 'agents' | 'personas', id: string) {
47
+ try {
48
+ const res = await $fetch<{ favorited?: boolean, error?: string }>(
49
+ `${apiBase}/api/favorites/${kind}/${id}`,
50
+ { method: 'POST' },
51
+ )
52
+ if (res.error) throw new Error(res.error)
53
+ const bucket = state.value[kind]
54
+ if (res.favorited && !bucket.includes(id)) {
55
+ state.value = { ...state.value, [kind]: [...bucket, id] }
56
+ } else if (!res.favorited) {
57
+ state.value = { ...state.value, [kind]: bucket.filter((x) => x !== id) }
58
+ }
59
+ return res.favorited
60
+ } catch (err) {
61
+ toast.add({
62
+ title: 'Favorite toggle failed',
63
+ description: err instanceof Error ? err.message : 'unknown error',
64
+ color: 'error',
65
+ })
66
+ return null
67
+ }
68
+ }
69
+
70
+ return { state, load, isAgentFavorite, isPersonaFavorite, toggle, loaded }
71
+ }
72
+
73
+ export const useFavorites = createSharedComposable(_useFavorites)
@@ -133,5 +133,6 @@ const links = [[{
133
133
 
134
134
  <slot />
135
135
  <KeyboardShortcutsHelp />
136
+ <GlobalSearch />
136
137
  </UDashboardGroup>
137
138
  </template>
@@ -59,6 +59,10 @@ function formatRelative(iso: string | null): string {
59
59
  return `${months}mo ago`
60
60
  }
61
61
 
62
+ // PR86a v3.15.0 — favorites.
63
+ const favs = useFavorites()
64
+ await favs.load()
65
+
62
66
  // PR76 — edit drawer state
63
67
  const editOpen = ref(false)
64
68
 
@@ -268,12 +272,22 @@ function formatTokens(n: number): string {
268
272
  </h1>
269
273
  <p class="text-base md:text-lg text-muted mt-0.5">{{ agent.role }}</p>
270
274
  </div>
271
- <UButton
272
- label="Edit"
273
- icon="i-lucide-pencil"
274
- size="sm"
275
- @click="openEditor"
276
- />
275
+ <div class="flex items-center gap-2">
276
+ <UButton
277
+ icon="i-lucide-star"
278
+ :color="favs.isAgentFavorite(agent.id) ? 'warning' : 'neutral'"
279
+ :variant="favs.isAgentFavorite(agent.id) ? 'soft' : 'ghost'"
280
+ size="sm"
281
+ :aria-label="favs.isAgentFavorite(agent.id) ? 'Unfavorite' : 'Favorite'"
282
+ @click="favs.toggle('agents', agent.id)"
283
+ />
284
+ <UButton
285
+ label="Edit"
286
+ icon="i-lucide-pencil"
287
+ size="sm"
288
+ @click="openEditor"
289
+ />
290
+ </div>
277
291
  </div>
278
292
  <div class="flex flex-wrap items-center gap-2 pt-1">
279
293
  <UBadge :label="agent.department" variant="subtle" />
@@ -111,6 +111,10 @@ const filteredAgents = computed(() => {
111
111
  result = result.filter(agent => String(agent.tier) === tierFilter.value)
112
112
  }
113
113
 
114
+ if (favoritesOnly.value) {
115
+ result = result.filter(agent => favs.isAgentFavorite(agent.id))
116
+ }
117
+
114
118
  return result
115
119
  })
116
120
 
@@ -139,6 +143,7 @@ const tierColor = (tier: number) => {
139
143
 
140
144
  const columns: TableColumn<Agent>[] = [
141
145
  { id: 'select', header: '' },
146
+ { id: 'favorite', header: '' },
142
147
  { accessorKey: 'name', header: 'Name' },
143
148
  { accessorKey: 'role', header: 'Role' },
144
149
  { accessorKey: 'department', header: 'Department' },
@@ -157,6 +162,11 @@ function goToAgent(id: string) {
157
162
  navigateTo(`/agents/${id}`)
158
163
  }
159
164
 
165
+ // PR86a v3.15.0 — favorites.
166
+ const favs = useFavorites()
167
+ await favs.load()
168
+ const favoritesOnly = ref(false)
169
+
160
170
  // PR83b v3.4.0 — bulk selection + delete.
161
171
  // PR84b v3.8.0 — bulk move department.
162
172
  const confirmDialog = useConfirmDialog()
@@ -371,6 +381,15 @@ async function undoTrashIds(ids: string[]) {
371
381
  aria-label="Filter by tier"
372
382
  />
373
383
 
384
+ <UButton
385
+ :label="favoritesOnly ? 'All' : 'Favorites'"
386
+ :icon="favoritesOnly ? 'i-lucide-star' : 'i-lucide-star'"
387
+ :color="favoritesOnly ? 'warning' : 'neutral'"
388
+ :variant="favoritesOnly ? 'soft' : 'outline'"
389
+ size="sm"
390
+ @click="favoritesOnly = !favoritesOnly"
391
+ />
392
+
374
393
  <span class="ml-auto text-xs text-muted">
375
394
  {{ totalFiltered }} agent{{ totalFiltered !== 1 ? 's' : '' }}
376
395
  </span>
@@ -404,6 +423,16 @@ async function undoTrashIds(ids: string[]) {
404
423
  @click.stop
405
424
  />
406
425
  </template>
426
+ <template #favorite-cell="{ row }">
427
+ <UButton
428
+ :icon="favs.isAgentFavorite(row.original.id) ? 'i-lucide-star' : 'i-lucide-star'"
429
+ :color="favs.isAgentFavorite(row.original.id) ? 'warning' : 'neutral'"
430
+ :variant="favs.isAgentFavorite(row.original.id) ? 'soft' : 'ghost'"
431
+ size="xs"
432
+ :aria-label="favs.isAgentFavorite(row.original.id) ? 'Unfavorite' : 'Favorite'"
433
+ @click.stop="favs.toggle('agents', row.original.id)"
434
+ />
435
+ </template>
407
436
  <template #name-cell="{ row }">
408
437
  <button class="text-left font-medium text-primary hover:underline" @click="goToAgent(row.original.id)">
409
438
  {{ row.original.name }}
@@ -226,6 +226,10 @@ 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
+ // PR86a v3.15.0 — favorites.
230
+ const favs = useFavorites()
231
+ await favs.load()
232
+
229
233
  // PR85a v3.11.0 — Clone to Agent dialog.
230
234
  const cloneOpen = ref(false)
231
235
  function onCloned(agentId: string) {
@@ -473,6 +477,14 @@ const vocabOptions = [
473
477
  </p>
474
478
  </div>
475
479
  <div class="flex items-center gap-2">
480
+ <UButton
481
+ icon="i-lucide-star"
482
+ :color="favs.isPersonaFavorite(detail.id) ? 'warning' : 'neutral'"
483
+ :variant="favs.isPersonaFavorite(detail.id) ? 'soft' : 'ghost'"
484
+ size="sm"
485
+ :aria-label="favs.isPersonaFavorite(detail.id) ? 'Unfavorite' : 'Favorite'"
486
+ @click="favs.toggle('personas', detail.id)"
487
+ />
476
488
  <UButton
477
489
  label="Clone to Agent"
478
490
  icon="i-lucide-copy-plus"
@@ -77,6 +77,9 @@ const filteredPersonas = computed<Persona[]>(() => {
77
77
  return src === sourceFilter.value
78
78
  })
79
79
  }
80
+ if (favoritesOnly.value) {
81
+ result = result.filter((p) => favs.isPersonaFavorite(p.id))
82
+ }
80
83
  return result
81
84
  })
82
85
 
@@ -119,6 +122,7 @@ function agentCount(personaId: string): number {
119
122
 
120
123
  const columns: TableColumn<Persona>[] = [
121
124
  { id: 'select', header: '' },
125
+ { id: 'favorite', header: '' },
122
126
  { accessorKey: 'name', header: 'Name' },
123
127
  { accessorKey: 'title', header: 'Title' },
124
128
  { accessorKey: 'source', header: 'Source' },
@@ -133,6 +137,11 @@ function goToPersona(id: string) {
133
137
  navigateTo(`/personas/${id}`)
134
138
  }
135
139
 
140
+ // PR86a v3.15.0 — favorites.
141
+ const favs = useFavorites()
142
+ await favs.load()
143
+ const favoritesOnly = ref(false)
144
+
136
145
  // PR83b v3.4.0 — bulk selection + delete.
137
146
  const toast = useToast()
138
147
  const confirmDialog = useConfirmDialog()
@@ -299,6 +308,15 @@ async function undoTrashIds(ids: string[]) {
299
308
  aria-label="Filter by source store"
300
309
  />
301
310
 
311
+ <UButton
312
+ :label="favoritesOnly ? 'All' : 'Favorites'"
313
+ icon="i-lucide-star"
314
+ :color="favoritesOnly ? 'warning' : 'neutral'"
315
+ :variant="favoritesOnly ? 'soft' : 'outline'"
316
+ size="sm"
317
+ @click="favoritesOnly = !favoritesOnly"
318
+ />
319
+
302
320
  <span class="ml-auto text-xs text-muted">
303
321
  {{ totalFiltered }} persona{{ totalFiltered !== 1 ? 's' : '' }}
304
322
  </span>
@@ -333,6 +351,16 @@ async function undoTrashIds(ids: string[]) {
333
351
  @click.stop
334
352
  />
335
353
  </template>
354
+ <template #favorite-cell="{ row }">
355
+ <UButton
356
+ icon="i-lucide-star"
357
+ :color="favs.isPersonaFavorite(row.original.id) ? 'warning' : 'neutral'"
358
+ :variant="favs.isPersonaFavorite(row.original.id) ? 'soft' : 'ghost'"
359
+ size="xs"
360
+ :aria-label="favs.isPersonaFavorite(row.original.id) ? 'Unfavorite' : 'Favorite'"
361
+ @click.stop="favs.toggle('personas', row.original.id)"
362
+ />
363
+ </template>
336
364
  <template #name-cell="{ row }">
337
365
  <span class="font-medium">{{ row.original.name }}</span>
338
366
  </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.13.0",
3
+ "version": "3.15.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.13.0"
3
+ version = "3.15.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"}
@@ -1331,6 +1331,113 @@ def persona_delete(persona_id: str):
1331
1331
  return {"error": "Persona not found"}
1332
1332
 
1333
1333
 
1334
+ # --- Favorites (PR86a v3.15.0) ---
1335
+
1336
+ @app.get("/api/favorites")
1337
+ def favorites_list():
1338
+ from core import favorites as _fav
1339
+ return _fav.list_favorites()
1340
+
1341
+
1342
+ @app.post("/api/favorites/{kind}/{item_id}")
1343
+ def favorites_toggle(kind: str, item_id: str):
1344
+ from core import favorites as _fav
1345
+ return _fav.toggle(kind, item_id)
1346
+
1347
+
1348
+ # --- Global search (PR85d v3.14.0) ---
1349
+
1350
+ @app.get("/api/search")
1351
+ def global_search(q: str = "", limit: int = 20):
1352
+ """Fuzzy substring search across agents, personas, departments, commands.
1353
+
1354
+ Returns a flat list of result objects with a shape the dashboard
1355
+ UCommandPalette can render directly:
1356
+ {results: [{kind, id, label, sublabel, to}]}
1357
+ """
1358
+ query = (q or "").strip().lower()
1359
+ if not query:
1360
+ return {"results": []}
1361
+ results: list[dict] = []
1362
+
1363
+ # Agents
1364
+ for a in _load_agents():
1365
+ haystack = " ".join(filter(None, [
1366
+ str(a.get("name") or ""),
1367
+ str(a.get("role") or ""),
1368
+ str(a.get("department") or ""),
1369
+ str(a.get("id") or ""),
1370
+ ])).lower()
1371
+ if query in haystack:
1372
+ results.append({
1373
+ "kind": "agent",
1374
+ "id": a.get("id"),
1375
+ "label": a.get("name") or a.get("id"),
1376
+ "sublabel": f"{a.get('role') or '—'} · {a.get('department') or '—'}",
1377
+ "to": f"/agents/{a.get('id')}",
1378
+ })
1379
+
1380
+ # Personas
1381
+ mgr = _get_persona_manager()
1382
+ if mgr:
1383
+ try:
1384
+ for p in (mgr.list() or []):
1385
+ haystack = " ".join(filter(None, [
1386
+ str(p.get("name") or ""),
1387
+ str(p.get("title") or ""),
1388
+ str(p.get("source") or ""),
1389
+ str(p.get("mbti") or ""),
1390
+ str(p.get("id") or ""),
1391
+ ])).lower()
1392
+ if query in haystack:
1393
+ results.append({
1394
+ "kind": "persona",
1395
+ "id": p.get("id"),
1396
+ "label": p.get("name") or p.get("id"),
1397
+ "sublabel": f"{p.get('title') or '—'} · {p.get('mbti') or '—'}",
1398
+ "to": f"/personas/{p.get('id')}",
1399
+ })
1400
+ except Exception:
1401
+ pass
1402
+
1403
+ # Departments — derive distinct list from agents
1404
+ seen_depts: set[str] = set()
1405
+ for a in _load_agents():
1406
+ d = a.get("department")
1407
+ if d:
1408
+ seen_depts.add(d)
1409
+ for d in sorted(seen_depts):
1410
+ if query in d.lower():
1411
+ results.append({
1412
+ "kind": "department",
1413
+ "id": d,
1414
+ "label": d.capitalize(),
1415
+ "sublabel": "Department",
1416
+ "to": f"/agents?department={d}",
1417
+ })
1418
+
1419
+ # Commands
1420
+ try:
1421
+ for c in _load_commands():
1422
+ haystack = " ".join(filter(None, [
1423
+ str(c.get("command") or ""),
1424
+ str(c.get("description") or ""),
1425
+ str(c.get("department") or ""),
1426
+ ])).lower()
1427
+ if query in haystack:
1428
+ results.append({
1429
+ "kind": "command",
1430
+ "id": c.get("command"),
1431
+ "label": c.get("command"),
1432
+ "sublabel": c.get("description") or c.get("department") or "",
1433
+ "to": "/commands",
1434
+ })
1435
+ except Exception:
1436
+ pass
1437
+
1438
+ return {"results": results[: max(0, int(limit))]}
1439
+
1440
+
1334
1441
  # --- Trash / Undo (PR85b v3.12.0) ---
1335
1442
 
1336
1443
  @app.get("/api/trash")