arkaos 2.84.0 → 2.86.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.84.0
1
+ 2.86.0
@@ -2,12 +2,73 @@
2
2
  import type { TableColumn } from '@nuxt/ui'
3
3
  import type { Agent } from '~/types'
4
4
 
5
- const { fetchApi } = useApi()
5
+ const { fetchApi, apiBase } = useApi()
6
+ const toast = useToast()
6
7
 
7
8
  const { data, status, error, refresh } = await fetchApi<{ agents: Agent[], total: number }>('/api/agents')
8
9
 
10
+ // PR69 v2.86.0 — per-department activity from PR47 telemetry.
11
+ // Used to badge agents whose department has run recently and to
12
+ // surface "no activity yet" hint when a department's never been
13
+ // invoked. Failure-tolerant — returns empty if telemetry unavailable.
14
+ interface ActivityRow {
15
+ call_count: number
16
+ total_cost_usd: number | null
17
+ total_tokens_in: number
18
+ total_tokens_out: number
19
+ }
20
+
21
+ const {
22
+ data: activityData,
23
+ refresh: refreshActivity,
24
+ } = fetchApi<{ by_department: Record<string, ActivityRow>, period: string }>(
25
+ '/api/agents/activity?period=week',
26
+ )
27
+
9
28
  const agents = computed(() => data.value?.agents ?? [])
10
29
 
30
+ function deptActivity(dept: string): ActivityRow | undefined {
31
+ return activityData.value?.by_department?.[dept]
32
+ }
33
+
34
+ const copied = ref<string | null>(null)
35
+ let copyTimer: ReturnType<typeof setTimeout> | null = null
36
+
37
+ async function copyAgentMention(agent: Agent) {
38
+ if (typeof navigator === 'undefined' || !navigator.clipboard) {
39
+ toast.add({ title: 'Clipboard unavailable', color: 'warning' })
40
+ return
41
+ }
42
+ // The most useful copy for an operator: a ready-to-paste mention
43
+ // that names the agent + their role so the orchestrator can dispatch.
44
+ const text = `Use ${agent.name} (${agent.role}, dept ${agent.department}, tier ${agent.tier}) for this task.`
45
+ try {
46
+ await navigator.clipboard.writeText(text)
47
+ copied.value = agent.id
48
+ if (copyTimer) clearTimeout(copyTimer)
49
+ copyTimer = setTimeout(() => { copied.value = null; copyTimer = null }, 1500)
50
+ toast.add({
51
+ title: 'Copied',
52
+ description: `${agent.name} mention ready to paste.`,
53
+ color: 'success',
54
+ })
55
+ } catch (err) {
56
+ toast.add({
57
+ title: 'Copy failed',
58
+ description: err instanceof Error ? err.message : 'unknown error',
59
+ color: 'error',
60
+ })
61
+ }
62
+ }
63
+
64
+ onBeforeUnmount(() => {
65
+ if (copyTimer) clearTimeout(copyTimer)
66
+ })
67
+
68
+ async function refreshAll() {
69
+ await Promise.all([refresh(), refreshActivity()])
70
+ }
71
+
11
72
  const search = ref('')
12
73
  const departmentFilter = ref('all')
13
74
  const tierFilter = ref('all')
@@ -77,35 +138,18 @@ const tierColor = (tier: number) => {
77
138
  }
78
139
 
79
140
  const columns: TableColumn<Agent>[] = [
80
- {
81
- accessorKey: 'name',
82
- header: 'Name'
83
- },
84
- {
85
- accessorKey: 'role',
86
- header: 'Role'
87
- },
88
- {
89
- accessorKey: 'department',
90
- header: 'Department'
91
- },
92
- {
93
- accessorKey: 'tier',
94
- header: 'Tier'
95
- },
141
+ { accessorKey: 'name', header: 'Name' },
142
+ { accessorKey: 'role', header: 'Role' },
143
+ { accessorKey: 'department', header: 'Department' },
144
+ { accessorKey: 'tier', header: 'Tier' },
96
145
  {
97
146
  accessorFn: (row: Agent) => row.disc?.primary ?? '-',
98
147
  id: 'disc',
99
- header: 'DISC'
100
- },
101
- {
102
- accessorKey: 'mbti',
103
- header: 'MBTI'
148
+ header: 'DISC',
104
149
  },
105
- {
106
- id: 'actions',
107
- header: ''
108
- }
150
+ { accessorKey: 'mbti', header: 'MBTI' },
151
+ { id: 'activity', header: 'Activity (7d)' },
152
+ { id: 'actions', header: '' },
109
153
  ]
110
154
 
111
155
  function goToAgent(id: string) {
@@ -134,7 +178,7 @@ function goToAgent(id: string) {
134
178
  empty-title="No agents found"
135
179
  empty-icon="i-lucide-users"
136
180
  loading-label="Loading agents"
137
- :on-retry="() => refresh()"
181
+ :on-retry="() => refreshAll()"
138
182
  >
139
183
  <div class="flex flex-wrap items-center gap-3 mb-4">
140
184
  <UInput
@@ -195,8 +239,33 @@ function goToAgent(id: string) {
195
239
  <template #mbti-cell="{ row }">
196
240
  <span class="font-mono text-sm">{{ row.original.mbti || '-' }}</span>
197
241
  </template>
242
+ <template #activity-cell="{ row }">
243
+ <template v-if="deptActivity(row.original.department)">
244
+ <div class="flex items-center gap-2">
245
+ <span class="inline-block size-2 rounded-full bg-green-500" />
246
+ <span class="text-xs font-mono">
247
+ {{ deptActivity(row.original.department)?.call_count ?? 0 }} calls
248
+ </span>
249
+ </div>
250
+ </template>
251
+ <span v-else class="text-xs text-muted">—</span>
252
+ </template>
198
253
  <template #actions-cell="{ row }">
199
- <UButton size="xs" variant="ghost" icon="i-lucide-arrow-right" @click="goToAgent(row.original.id)" />
254
+ <UButton
255
+ :icon="copied === row.original.id ? 'i-lucide-check' : 'i-lucide-copy'"
256
+ :color="copied === row.original.id ? 'success' : 'neutral'"
257
+ variant="ghost"
258
+ size="xs"
259
+ aria-label="Copy agent mention"
260
+ @click.stop="copyAgentMention(row.original)"
261
+ />
262
+ <UButton
263
+ size="xs"
264
+ variant="ghost"
265
+ icon="i-lucide-arrow-right"
266
+ aria-label="Open agent detail"
267
+ @click="goToAgent(row.original.id)"
268
+ />
200
269
  </template>
201
270
  </UTable>
202
271
 
@@ -1,76 +1,194 @@
1
1
  <script setup lang="ts">
2
+ // PR68 v2.85.0 — Commands page: ▶ Copy + ★ Favorites.
3
+ //
4
+ // The previous Commands page was a read-only catalogue (135 rows,
5
+ // search, filter, expand for keywords). Daniel Ek's audit question
6
+ // landed: "what is the job-to-be-done here vs the CLI?" The answer:
7
+ // fast lookup → copy to clipboard → paste back into Claude Code.
8
+ // PR68 makes that flow one-click + adds operator-curated favorites
9
+ // stored locally so the top-of-list is the operator's actual
10
+ // muscle-memory commands.
11
+
2
12
  import type { TableColumn } from '@nuxt/ui'
3
13
  import type { Command } from '~/types'
4
14
 
5
15
  const { fetchApi } = useApi()
16
+ const toast = useToast()
6
17
 
7
- const { data, status, error, refresh } = await fetchApi<{ commands: Command[], total: number }>('/api/commands')
18
+ const {
19
+ data,
20
+ status,
21
+ error,
22
+ refresh,
23
+ } = await fetchApi<{ commands: Command[], total: number }>('/api/commands')
8
24
 
9
- const commands = computed(() => data.value?.commands ?? [])
25
+ // ─── Favorites (persisted in localStorage) ───────────────────────────────
26
+
27
+ const FAVORITES_KEY = 'arkaos_command_favorites'
28
+ const favorites = ref<Set<string>>(new Set())
29
+
30
+ function loadFavorites() {
31
+ if (typeof window === 'undefined') return
32
+ try {
33
+ const raw = window.localStorage.getItem(FAVORITES_KEY)
34
+ if (!raw) return
35
+ const parsed = JSON.parse(raw)
36
+ if (Array.isArray(parsed)) {
37
+ favorites.value = new Set(parsed.filter((v): v is string => typeof v === 'string'))
38
+ }
39
+ } catch { /* corrupt JSON — ignore, start fresh */ }
40
+ }
41
+
42
+ function persistFavorites() {
43
+ if (typeof window === 'undefined') return
44
+ try {
45
+ window.localStorage.setItem(
46
+ FAVORITES_KEY,
47
+ JSON.stringify(Array.from(favorites.value)),
48
+ )
49
+ } catch { /* quota / disabled storage — silent */ }
50
+ }
51
+
52
+ function toggleFavorite(commandId: string) {
53
+ if (favorites.value.has(commandId)) {
54
+ favorites.value.delete(commandId)
55
+ } else {
56
+ favorites.value.add(commandId)
57
+ }
58
+ persistFavorites()
59
+ }
60
+
61
+ onMounted(() => {
62
+ loadFavorites()
63
+ })
64
+
65
+ // ─── Filters + view ─────────────────────────────────────────────────────
10
66
 
11
67
  const search = ref('')
12
68
  const departmentFilter = ref('all')
69
+ const view = ref<'all' | 'favorites'>('all')
13
70
  const page = ref(1)
14
71
  const pageSize = 20
15
72
  const expandedRow = ref<string | null>(null)
16
73
 
74
+ const commands = computed(() => data.value?.commands ?? [])
75
+
17
76
  const departments = computed(() => {
18
- const depts = new Set(commands.value.map(c => c.department))
77
+ const depts = new Set(commands.value.map((c) => c.department))
19
78
  return [
20
79
  { label: 'All Departments', value: 'all' },
21
- ...Array.from(depts).sort().map(d => ({ label: d, value: d }))
80
+ ...Array.from(depts).sort().map((d) => ({ label: d, value: d })),
22
81
  ]
23
82
  })
24
83
 
25
- const filteredCommands = computed(() => {
26
- let result = commands.value
27
- const query = search.value.toLowerCase()
84
+ const baseList = computed<Command[]>(() => {
85
+ if (view.value === 'favorites') {
86
+ return commands.value.filter((c) => favorites.value.has(c.id))
87
+ }
88
+ return commands.value
89
+ })
28
90
 
91
+ const filteredCommands = computed<Command[]>(() => {
92
+ let result = baseList.value
93
+ const query = search.value.toLowerCase().trim()
29
94
  if (query) {
30
- result = result.filter(cmd =>
95
+ result = result.filter((cmd) =>
31
96
  cmd.command.toLowerCase().includes(query)
32
- || cmd.description.toLowerCase().includes(query)
97
+ || cmd.description.toLowerCase().includes(query),
33
98
  )
34
99
  }
35
-
36
100
  if (departmentFilter.value !== 'all') {
37
- result = result.filter(cmd => cmd.department === departmentFilter.value)
101
+ result = result.filter((cmd) => cmd.department === departmentFilter.value)
102
+ }
103
+ // Favorites pinned on top in the "all" view; the favorites-only
104
+ // view doesn't need re-sorting.
105
+ if (view.value === 'all') {
106
+ result = [...result].sort((a, b) => {
107
+ const aFav = favorites.value.has(a.id) ? 0 : 1
108
+ const bFav = favorites.value.has(b.id) ? 0 : 1
109
+ return aFav - bFav
110
+ })
38
111
  }
39
-
40
112
  return result
41
113
  })
42
114
 
43
115
  const totalFiltered = computed(() => filteredCommands.value.length)
44
116
 
45
- const paginatedCommands = computed(() => {
46
- const start = (page.value - 1) * pageSize
47
- return filteredCommands.value.slice(start, start + pageSize)
48
- })
117
+ const paginatedCommands = computed(() =>
118
+ filteredCommands.value.slice(
119
+ (page.value - 1) * pageSize,
120
+ page.value * pageSize,
121
+ ),
122
+ )
49
123
 
50
- const totalPages = computed(() => Math.max(1, Math.ceil(totalFiltered.value / pageSize)))
124
+ const totalPages = computed(() =>
125
+ Math.max(1, Math.ceil(totalFiltered.value / pageSize)),
126
+ )
51
127
 
52
- watch([search, departmentFilter], () => {
128
+ watch([search, departmentFilter, view], () => {
53
129
  page.value = 1
54
130
  })
55
131
 
132
+ // ─── Copy command to clipboard ──────────────────────────────────────────
133
+
134
+ const copied = ref<string | null>(null)
135
+ let copyTimer: ReturnType<typeof setTimeout> | null = null
136
+
137
+ async function copyCommand(cmd: Command) {
138
+ if (typeof navigator === 'undefined' || !navigator.clipboard) {
139
+ toast.add({
140
+ title: 'Clipboard unavailable',
141
+ description: 'Your browser blocked navigator.clipboard.',
142
+ color: 'warning',
143
+ })
144
+ return
145
+ }
146
+ try {
147
+ await navigator.clipboard.writeText(cmd.command)
148
+ copied.value = cmd.id
149
+ if (copyTimer) clearTimeout(copyTimer)
150
+ copyTimer = setTimeout(() => {
151
+ copied.value = null
152
+ copyTimer = null
153
+ }, 1500)
154
+ toast.add({
155
+ title: 'Copied',
156
+ description: cmd.command,
157
+ color: 'success',
158
+ })
159
+ } catch (err) {
160
+ toast.add({
161
+ title: 'Copy failed',
162
+ description: err instanceof Error ? err.message : 'unknown error',
163
+ color: 'error',
164
+ })
165
+ }
166
+ }
167
+
168
+ onBeforeUnmount(() => {
169
+ if (copyTimer) clearTimeout(copyTimer)
170
+ })
171
+
172
+ // ─── Expansion + table ──────────────────────────────────────────────────
173
+
56
174
  function toggleExpand(commandId: string) {
57
175
  expandedRow.value = expandedRow.value === commandId ? null : commandId
58
176
  }
59
177
 
60
178
  const columns: TableColumn<Command>[] = [
61
- {
62
- accessorKey: 'command',
63
- header: 'Command'
64
- },
65
- {
66
- accessorKey: 'department',
67
- header: 'Department'
68
- },
69
- {
70
- accessorKey: 'description',
71
- header: 'Description'
72
- }
179
+ { accessorKey: 'star', header: '' },
180
+ { accessorKey: 'command', header: 'Command' },
181
+ { accessorKey: 'department', header: 'Department' },
182
+ { accessorKey: 'description', header: 'Description' },
183
+ { accessorKey: 'actions', header: '' },
184
+ ]
185
+
186
+ const viewTabs = [
187
+ { label: 'All', value: 'all' as const },
188
+ { label: 'Favorites', value: 'favorites' as const },
73
189
  ]
190
+
191
+ const favoritesCount = computed(() => favorites.value.size)
74
192
  </script>
75
193
 
76
194
  <template>
@@ -81,33 +199,43 @@ const columns: TableColumn<Command>[] = [
81
199
  <UDashboardSidebarCollapse />
82
200
  </template>
83
201
  <template #trailing>
84
- <UBadge v-if="data?.total" :label="data.total" variant="subtle" />
202
+ <UBadge
203
+ v-if="data?.total"
204
+ :label="`${data.total} total`"
205
+ variant="subtle"
206
+ size="xs"
207
+ />
208
+ <UBadge
209
+ v-if="favoritesCount"
210
+ :label="`★ ${favoritesCount}`"
211
+ variant="subtle"
212
+ color="warning"
213
+ size="xs"
214
+ class="ml-2"
215
+ />
85
216
  </template>
86
217
  </UDashboardNavbar>
87
218
  </template>
88
219
 
89
220
  <template #body>
90
- <!-- Loading -->
91
- <div v-if="status === 'pending'" class="flex items-center justify-center py-12">
92
- <UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-muted" />
93
- </div>
94
-
95
- <!-- Error -->
96
- <div v-else-if="error" class="flex flex-col items-center justify-center gap-4 py-12" role="alert">
97
- <UIcon name="i-lucide-alert-triangle" class="size-12 text-red-500" />
98
- <p class="text-sm text-muted">Failed to load commands.</p>
99
- <UButton label="Retry" variant="outline" color="primary" icon="i-lucide-refresh-cw" @click="refresh()" />
100
- </div>
101
-
102
- <!-- Empty -->
103
- <div v-else-if="!commands.length" class="flex flex-col items-center justify-center gap-4 py-12">
104
- <UIcon name="i-lucide-terminal" class="size-12 text-muted" />
105
- <p class="text-sm text-muted">No commands found.</p>
106
- </div>
107
-
108
- <!-- Content -->
109
- <template v-else>
221
+ <DashboardState
222
+ :status="status"
223
+ :error="error"
224
+ :empty="!commands.length"
225
+ empty-title="No commands found"
226
+ empty-icon="i-lucide-terminal"
227
+ loading-label="Loading commands"
228
+ :on-retry="() => refresh()"
229
+ >
230
+ <!-- View tabs + filters -->
110
231
  <div class="flex flex-wrap items-center gap-3 mb-4">
232
+ <UTabs
233
+ :items="viewTabs"
234
+ :model-value="view"
235
+ class="shrink-0"
236
+ @update:model-value="view = $event as 'all' | 'favorites'"
237
+ />
238
+
111
239
  <UInput
112
240
  v-model="search"
113
241
  class="max-w-sm"
@@ -130,51 +258,98 @@ const columns: TableColumn<Command>[] = [
130
258
  </span>
131
259
  </div>
132
260
 
133
- <UTable
134
- :data="paginatedCommands"
135
- :columns="columns"
136
- :loading="status === 'pending'"
137
- class="shrink-0"
138
- :ui="{
139
- base: 'table-fixed border-separate border-spacing-0',
140
- thead: '[&>tr]:bg-elevated/50 [&>tr]:after:content-none',
141
- tbody: '[&>tr]:last:[&>td]:border-b-0 [&>tr]:cursor-pointer [&>tr]:hover:bg-elevated/50 [&>tr]:transition-colors',
142
- th: 'py-2 first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
143
- td: 'border-b border-default'
144
- }"
145
- @select="(row: Command) => toggleExpand(row.id)"
261
+ <!-- Favorites empty state -->
262
+ <div
263
+ v-if="view === 'favorites' && favoritesCount === 0"
264
+ class="flex flex-col items-center justify-center gap-3 py-16 rounded-lg border border-default"
146
265
  >
147
- <template #command-cell="{ row }">
148
- <code class="font-mono text-sm text-primary">{{ row.original.command }}</code>
149
- </template>
150
- <template #department-cell="{ row }">
151
- <UBadge :label="row.original.department" variant="subtle" size="sm" />
152
- </template>
153
- <template #expanded="{ row }">
154
- <div v-if="expandedRow === row.original.id && row.original.keywords?.length" class="px-4 py-3 bg-elevated/30">
155
- <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-2">Keywords</p>
156
- <div class="flex flex-wrap gap-1.5">
157
- <UBadge
158
- v-for="kw in row.original.keywords"
159
- :key="kw"
160
- :label="kw"
161
- variant="outline"
162
- size="xs"
163
- />
164
- </div>
165
- </div>
166
- </template>
167
- </UTable>
168
-
169
- <div v-if="totalPages > 1" class="flex items-center justify-center mt-6">
170
- <UPagination
171
- :page="page"
172
- :total="totalFiltered"
173
- :items-per-page="pageSize"
174
- @update:page="(val) => page = val"
175
- />
266
+ <UIcon name="i-lucide-star" class="size-12 text-muted" />
267
+ <p class="text-sm text-muted">No favorites yet.</p>
268
+ <p class="text-xs text-muted text-center max-w-sm">
269
+ Click the ★ next to any command to pin it here for one-click access.
270
+ </p>
176
271
  </div>
177
- </template>
272
+
273
+ <!-- Table -->
274
+ <template v-else>
275
+ <UTable
276
+ :data="paginatedCommands"
277
+ :columns="columns"
278
+ :loading="status === 'pending'"
279
+ class="shrink-0"
280
+ :ui="{
281
+ base: 'table-fixed border-separate border-spacing-0',
282
+ thead: '[&>tr]:bg-elevated/50 [&>tr]:after:content-none',
283
+ tbody: '[&>tr]:last:[&>td]:border-b-0 [&>tr]:hover:bg-elevated/50 [&>tr]:transition-colors',
284
+ th: 'py-2 first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
285
+ td: 'border-b border-default',
286
+ }"
287
+ >
288
+ <template #star-cell="{ row }">
289
+ <UButton
290
+ :icon="favorites.has(row.original.id) ? 'i-lucide-star' : 'i-lucide-star'"
291
+ :color="favorites.has(row.original.id) ? 'warning' : 'neutral'"
292
+ variant="ghost"
293
+ size="xs"
294
+ :aria-label="favorites.has(row.original.id) ? 'Unfavorite' : 'Favorite'"
295
+ :class="favorites.has(row.original.id) ? '' : 'opacity-30 hover:opacity-100'"
296
+ @click.stop="toggleFavorite(row.original.id)"
297
+ />
298
+ </template>
299
+ <template #command-cell="{ row }">
300
+ <button
301
+ type="button"
302
+ class="text-left w-full"
303
+ @click="toggleExpand(row.original.id)"
304
+ >
305
+ <code class="font-mono text-sm text-primary">{{ row.original.command }}</code>
306
+ </button>
307
+ </template>
308
+ <template #department-cell="{ row }">
309
+ <UBadge :label="row.original.department" variant="subtle" size="sm" />
310
+ </template>
311
+ <template #description-cell="{ row }">
312
+ <span class="text-sm text-muted">{{ row.original.description }}</span>
313
+ </template>
314
+ <template #actions-cell="{ row }">
315
+ <UButton
316
+ :icon="copied === row.original.id ? 'i-lucide-check' : 'i-lucide-copy'"
317
+ :color="copied === row.original.id ? 'success' : 'neutral'"
318
+ variant="ghost"
319
+ size="xs"
320
+ aria-label="Copy command to clipboard"
321
+ @click.stop="copyCommand(row.original)"
322
+ />
323
+ </template>
324
+ <template #expanded="{ row }">
325
+ <div
326
+ v-if="expandedRow === row.original.id && row.original.keywords?.length"
327
+ class="px-4 py-3 bg-elevated/30"
328
+ >
329
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-2">Keywords</p>
330
+ <div class="flex flex-wrap gap-1.5">
331
+ <UBadge
332
+ v-for="kw in row.original.keywords"
333
+ :key="kw"
334
+ :label="kw"
335
+ variant="outline"
336
+ size="xs"
337
+ />
338
+ </div>
339
+ </div>
340
+ </template>
341
+ </UTable>
342
+
343
+ <div v-if="totalPages > 1" class="flex items-center justify-center mt-6">
344
+ <UPagination
345
+ :page="page"
346
+ :total="totalFiltered"
347
+ :items-per-page="pageSize"
348
+ @update:page="(val) => page = val"
349
+ />
350
+ </div>
351
+ </template>
352
+ </DashboardState>
178
353
  </template>
179
354
  </UDashboardPanel>
180
355
  </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.84.0",
3
+ "version": "2.86.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.84.0"
3
+ version = "2.86.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"}
@@ -173,6 +173,51 @@ def agents(dept: Optional[str] = Query(None)):
173
173
  return {"agents": data, "total": len(data)}
174
174
 
175
175
 
176
+ @app.get("/api/agents/activity")
177
+ def agents_activity(period: str = "week"):
178
+ """Per-department activity from the PR47 LLM cost telemetry.
179
+
180
+ Returns ``{by_department: {dev: {call_count, total_cost_usd,
181
+ total_tokens_in, total_tokens_out}}}`` derived from rows whose
182
+ ``category`` field starts with ``subagent:``. Each agent's
183
+ dispatch is currently tagged at the department level — finer
184
+ per-agent attribution will land when orchestrators set
185
+ ``ARKA_CALL_CATEGORY=subagent:<dept>:<agent>``.
186
+ """
187
+ try:
188
+ from core.runtime.llm_cost_telemetry import summarise, VALID_PERIODS
189
+ except Exception: # pragma: no cover - import guard
190
+ return {"by_department": {}, "period": period}
191
+ if period not in VALID_PERIODS:
192
+ period = "week"
193
+ summary = summarise(period=period)
194
+ out: dict[str, dict] = {}
195
+ for category, row in (summary.by_category or {}).items():
196
+ if not isinstance(category, str) or not category.startswith("subagent:"):
197
+ continue
198
+ dept = category.split(":", 1)[1] or "unknown"
199
+ bucket = out.setdefault(dept, {
200
+ "call_count": 0,
201
+ "total_cost_usd": 0.0,
202
+ "any_cost_known": False,
203
+ "total_tokens_in": 0,
204
+ "total_tokens_out": 0,
205
+ })
206
+ bucket["call_count"] += row.get("call_count", 0)
207
+ bucket["total_tokens_in"] += row.get("total_tokens_in", 0)
208
+ bucket["total_tokens_out"] += row.get("total_tokens_out", 0)
209
+ cost = row.get("total_cost_usd")
210
+ if isinstance(cost, (int, float)):
211
+ bucket["total_cost_usd"] += float(cost)
212
+ bucket["any_cost_known"] = True
213
+ for dept, b in out.items():
214
+ if not b.pop("any_cost_known"):
215
+ b["total_cost_usd"] = None
216
+ else:
217
+ b["total_cost_usd"] = round(b["total_cost_usd"], 6)
218
+ return {"by_department": out, "period": period}
219
+
220
+
176
221
  @app.get("/api/agents/{agent_id}")
177
222
  def agent_detail(agent_id: str):
178
223
  """Get full agent detail including YAML data."""