arkaos 3.14.0 → 3.16.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.14.0
1
+ 3.16.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}
@@ -367,7 +367,8 @@ def _current_category() -> str:
367
367
 
368
368
  PR60 v2.77.0 — orchestration layers can set
369
369
  ``ARKA_CALL_CATEGORY=skill:<slug>`` /
370
- ``subagent:<dept>`` / ``plugin:<id>`` / ``mcp:<server>`` before
370
+ ``subagent:<dept>`` or ``subagent:<dept>:<agent_id>`` /
371
+ ``plugin:<id>`` / ``mcp:<server>`` before
371
372
  invoking the provider so `/arka costs --by-category` (PR47) can
372
373
  attribute spend. Returns "" when unset, which lands in the base
373
374
  bucket (backward-compatible).
@@ -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)
@@ -27,9 +27,10 @@ const deptActivity = computed<ActivityRow | null>(() =>
27
27
  (activityData.value?.by_department?.[agent.value?.department ?? ''] ?? null),
28
28
  )
29
29
 
30
- // PR83d v3.6.0 — activity strip (30d, dept-level + last_used + rank)
30
+ // PR83d v3.6.0 + PR86b v3.16.0 — activity strip (30d, agent or dept scope)
31
31
  interface ActivityStrip {
32
32
  period: string
33
+ scope: 'agent' | 'department'
33
34
  department: string
34
35
  calls: number
35
36
  cost_usd: number | null
@@ -59,6 +60,10 @@ function formatRelative(iso: string | null): string {
59
60
  return `${months}mo ago`
60
61
  }
61
62
 
63
+ // PR86a v3.15.0 — favorites.
64
+ const favs = useFavorites()
65
+ await favs.load()
66
+
62
67
  // PR76 — edit drawer state
63
68
  const editOpen = ref(false)
64
69
 
@@ -268,12 +273,22 @@ function formatTokens(n: number): string {
268
273
  </h1>
269
274
  <p class="text-base md:text-lg text-muted mt-0.5">{{ agent.role }}</p>
270
275
  </div>
271
- <UButton
272
- label="Edit"
273
- icon="i-lucide-pencil"
274
- size="sm"
275
- @click="openEditor"
276
- />
276
+ <div class="flex items-center gap-2">
277
+ <UButton
278
+ icon="i-lucide-star"
279
+ :color="favs.isAgentFavorite(agent.id) ? 'warning' : 'neutral'"
280
+ :variant="favs.isAgentFavorite(agent.id) ? 'soft' : 'ghost'"
281
+ size="sm"
282
+ :aria-label="favs.isAgentFavorite(agent.id) ? 'Unfavorite' : 'Favorite'"
283
+ @click="favs.toggle('agents', agent.id)"
284
+ />
285
+ <UButton
286
+ label="Edit"
287
+ icon="i-lucide-pencil"
288
+ size="sm"
289
+ @click="openEditor"
290
+ />
291
+ </div>
277
292
  </div>
278
293
  <div class="flex flex-wrap items-center gap-2 pt-1">
279
294
  <UBadge :label="agent.department" variant="subtle" />
@@ -332,7 +347,7 @@ function formatTokens(n: number): string {
332
347
  <div class="flex items-center gap-2">
333
348
  <UIcon name="i-lucide-activity" class="size-4 text-primary" />
334
349
  <span class="font-semibold uppercase tracking-wide text-muted text-xs">
335
- 30d activity (dept)
350
+ 30d activity ({{ activityStrip.scope === 'agent' ? 'agent' : 'dept' }})
336
351
  </span>
337
352
  </div>
338
353
  <div class="flex items-center gap-2">
@@ -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.14.0",
3
+ "version": "3.16.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.14.0"
3
+ version = "3.16.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"}
@@ -220,11 +220,17 @@ def agents_activity(period: str = "week"):
220
220
 
221
221
  @app.get("/api/agents/{agent_id}/activity-strip")
222
222
  def agent_activity_strip(agent_id: str, period: str = "month"):
223
- """PR83d v3.6.0 — compact activity payload for the agent hero strip.
223
+ """PR83d v3.6.0 + PR86b v3.16.0 — compact activity payload.
224
+
225
+ When telemetry is tagged ``subagent:<dept>:<agent_id>``, the response
226
+ prefers per-agent stats and includes a ``scope: "agent"`` flag.
227
+ Otherwise it falls back to department-level stats with
228
+ ``scope: "department"`` (the original PR83d behaviour).
224
229
 
225
230
  Returns:
226
231
  {
227
232
  "period": "month",
233
+ "scope": "agent" | "department",
228
234
  "department": "<dept>",
229
235
  "calls": <int>,
230
236
  "cost_usd": <float|null>,
@@ -233,9 +239,6 @@ def agent_activity_strip(agent_id: str, period: str = "month"):
233
239
  "dept_rank": <1-based int>|null,
234
240
  "dept_count": <int>
235
241
  }
236
-
237
- All values reflect the agent's DEPARTMENT (per-agent attribution
238
- isn't tracked yet — see PR47 telemetry).
239
242
  """
240
243
  agents = _load_agents()
241
244
  base = None
@@ -259,34 +262,51 @@ def agent_activity_strip(agent_id: str, period: str = "month"):
259
262
  period = "month"
260
263
 
261
264
  summary = summarise(period=period)
262
- dept_costs: list[tuple[str, float]] = []
263
- target_row: dict | None = None
265
+ dept_costs_map: dict[str, float] = {}
266
+ dept_row: dict | None = None
267
+ agent_row: dict | None = None
264
268
  for category, row in (summary.by_category or {}).items():
265
269
  if not isinstance(category, str) or not category.startswith("subagent:"):
266
270
  continue
267
- cat_dept = category.split(":", 1)[1] or "unknown"
271
+ # subagent:<dept> OR subagent:<dept>:<agent_id>
272
+ parts = category.split(":", 2)
273
+ cat_dept = parts[1] if len(parts) >= 2 and parts[1] else "unknown"
274
+ cat_agent = parts[2] if len(parts) == 3 else None
268
275
  cost = row.get("total_cost_usd")
269
- dept_costs.append((cat_dept, float(cost) if isinstance(cost, (int, float)) else 0.0))
270
- if cat_dept == dept:
271
- target_row = row
272
-
273
- dept_costs.sort(key=lambda t: t[1], reverse=True)
276
+ cost_f = float(cost) if isinstance(cost, (int, float)) else 0.0
277
+ dept_costs_map[cat_dept] = dept_costs_map.get(cat_dept, 0.0) + cost_f
278
+ if cat_dept == dept and cat_agent is None:
279
+ dept_row = row
280
+ if cat_dept == dept and cat_agent == agent_id:
281
+ agent_row = row
282
+
283
+ dept_costs = sorted(dept_costs_map.items(), key=lambda t: t[1], reverse=True)
274
284
  dept_rank: Optional[int] = None
275
285
  for idx, (d, _) in enumerate(dept_costs, start=1):
276
286
  if d == dept:
277
287
  dept_rank = idx
278
288
  break
279
289
 
290
+ use_agent_scope = agent_row is not None
291
+ scope = "agent" if use_agent_scope else "department"
292
+ target_row = agent_row if use_agent_scope else dept_row
293
+
280
294
  entries, _ = _load_slice(None, _period_cutoff(period, now=None))
281
295
  last_used: Optional[str] = None
296
+ expect_cats = (
297
+ {f"subagent:{dept}:{agent_id}"}
298
+ if use_agent_scope
299
+ else {f"subagent:{dept}"}
300
+ )
282
301
  for entry in reversed(entries):
283
302
  cat = entry.get("category") or ""
284
- if isinstance(cat, str) and cat == f"subagent:{dept}":
303
+ if isinstance(cat, str) and cat in expect_cats:
285
304
  last_used = entry.get("ts")
286
305
  break
287
306
 
288
307
  return {
289
308
  "period": period,
309
+ "scope": scope,
290
310
  "department": dept,
291
311
  "calls": int(target_row.get("call_count", 0)) if target_row else 0,
292
312
  "cost_usd": (
@@ -302,6 +322,16 @@ def agent_activity_strip(agent_id: str, period: str = "month"):
302
322
  }
303
323
 
304
324
 
325
+ @app.get("/api/agents/{agent_id}/activity")
326
+ def agent_activity_detail(agent_id: str, period: str = "month"):
327
+ """PR86b v3.16.0 — alias for /activity-strip. Same payload shape.
328
+
329
+ Exposed so callers can use a more natural name when they want the
330
+ full activity row rather than the compact hero strip.
331
+ """
332
+ return agent_activity_strip(agent_id, period)
333
+
334
+
305
335
  @app.get("/api/agents/{agent_id}")
306
336
  def agent_detail(agent_id: str):
307
337
  """Get full agent detail including YAML data."""
@@ -1331,6 +1361,20 @@ def persona_delete(persona_id: str):
1331
1361
  return {"error": "Persona not found"}
1332
1362
 
1333
1363
 
1364
+ # --- Favorites (PR86a v3.15.0) ---
1365
+
1366
+ @app.get("/api/favorites")
1367
+ def favorites_list():
1368
+ from core import favorites as _fav
1369
+ return _fav.list_favorites()
1370
+
1371
+
1372
+ @app.post("/api/favorites/{kind}/{item_id}")
1373
+ def favorites_toggle(kind: str, item_id: str):
1374
+ from core import favorites as _fav
1375
+ return _fav.toggle(kind, item_id)
1376
+
1377
+
1334
1378
  # --- Global search (PR85d v3.14.0) ---
1335
1379
 
1336
1380
  @app.get("/api/search")