arkaos 3.15.0 → 3.17.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.15.0
1
+ 3.17.0
@@ -0,0 +1,171 @@
1
+ """Export agent profiles to the Obsidian vault (PR86c v3.17.0).
2
+
3
+ Writes a Markdown file with YAML frontmatter + readable sections to
4
+ ``<vault>/Agents/<id>.md``. Sibling to ``core/personas/obsidian_store``
5
+ but write-only — agents are still source-of-truth in their YAML files.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from core.profile import ProfileManager
14
+
15
+
16
+ _AGENTS_SUBDIR = "Agents"
17
+
18
+
19
+ class AgentExportError(RuntimeError):
20
+ """Raised when the vault is missing or the write fails."""
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class AgentExportResult:
25
+ path: Path
26
+ vault_path: Path
27
+
28
+
29
+ def export_agent_to_vault(agent: dict) -> AgentExportResult:
30
+ """Render `agent` as Markdown and write it to the configured vault.
31
+
32
+ `agent` is the YAML-loaded dict for the agent (the same shape
33
+ returned by ``/api/agents/{id}``).
34
+ """
35
+ if not isinstance(agent, dict) or not agent.get("id"):
36
+ raise AgentExportError("agent payload must include 'id'")
37
+ vault = _resolve_vault_path()
38
+ if vault is None:
39
+ raise AgentExportError(
40
+ "Obsidian vault is not configured (set vaultPath in profile)"
41
+ )
42
+ target_dir = vault / _AGENTS_SUBDIR
43
+ target_dir.mkdir(parents=True, exist_ok=True)
44
+ target = target_dir / f"{agent['id']}.md"
45
+ md = _render(agent)
46
+ tmp = target.with_suffix(target.suffix + ".tmp")
47
+ try:
48
+ tmp.write_text(md, encoding="utf-8")
49
+ tmp.replace(target)
50
+ except OSError as exc:
51
+ raise AgentExportError(f"write failed: {exc}") from exc
52
+ return AgentExportResult(path=target, vault_path=vault)
53
+
54
+
55
+ def _resolve_vault_path() -> Path | None:
56
+ try:
57
+ profile = ProfileManager().read()
58
+ except Exception: # noqa: BLE001
59
+ return None
60
+ if not profile.vaultPath:
61
+ return None
62
+ path = Path(profile.vaultPath).expanduser()
63
+ return path if path.exists() else None
64
+
65
+
66
+ def _render(agent: dict) -> str:
67
+ """Compose the Markdown body — YAML frontmatter + readable sections."""
68
+ fm_lines = [
69
+ "---",
70
+ "type: agent",
71
+ f"id: {agent.get('id', '')}",
72
+ f"name: {_yaml_str(agent.get('name', ''))}",
73
+ f"role: {_yaml_str(agent.get('role', ''))}",
74
+ f"department: {agent.get('department', '')}",
75
+ f"tier: {agent.get('tier', '')}",
76
+ f"model: {agent.get('model', '')}",
77
+ "---",
78
+ "",
79
+ ]
80
+ sections: list[str] = []
81
+ sections.append(f"# {agent.get('name') or agent.get('id', '(unnamed)')}")
82
+ if agent.get("role"):
83
+ sections.append(f"*{agent['role']}* · `{agent.get('department', '')}`")
84
+ sections.append("")
85
+
86
+ dna = agent.get("behavioral_dna") or {}
87
+ if dna:
88
+ sections.append("## Behavioural DNA")
89
+ disc = dna.get("disc") or {}
90
+ enn = dna.get("enneagram") or {}
91
+ bf = dna.get("big_five") or {}
92
+ mbti = dna.get("mbti")
93
+ mbti_type = mbti.get("type") if isinstance(mbti, dict) else mbti
94
+ sections.append(f"- **DISC:** {disc.get('primary', '?')}/{disc.get('secondary', '?')}")
95
+ sections.append(f"- **Enneagram:** {enn.get('type', '?')}w{enn.get('wing', '?')}")
96
+ sections.append(f"- **MBTI:** {mbti_type or '—'}")
97
+ if bf:
98
+ ocean = " · ".join(
99
+ f"{k[:1].upper()}{int(v)}"
100
+ for k, v in bf.items()
101
+ if isinstance(v, (int, float))
102
+ )
103
+ if ocean:
104
+ sections.append(f"- **OCEAN:** {ocean}")
105
+ sections.append("")
106
+
107
+ exp = agent.get("expertise") or {}
108
+ if exp:
109
+ sections.append("## Expertise")
110
+ domains = exp.get("domains") or []
111
+ frameworks = exp.get("frameworks") or []
112
+ if domains:
113
+ sections.append("**Domains**")
114
+ sections.extend(f"- {d}" for d in domains)
115
+ sections.append("")
116
+ if frameworks:
117
+ sections.append("**Frameworks**")
118
+ sections.extend(f"- {f}" for f in frameworks)
119
+ sections.append("")
120
+ sections.append(f"*Depth: {exp.get('depth', '—')} · {exp.get('years_equivalent', '—')}y equivalent*")
121
+ sections.append("")
122
+
123
+ mm = agent.get("mental_models") or {}
124
+ if mm:
125
+ primary = mm.get("primary") or []
126
+ secondary = mm.get("secondary") or []
127
+ if primary or secondary:
128
+ sections.append("## Mental Models")
129
+ if primary:
130
+ sections.append("**Primary**")
131
+ sections.extend(f"- {m}" for m in primary)
132
+ sections.append("")
133
+ if secondary:
134
+ sections.append("**Secondary**")
135
+ sections.extend(f"- {m}" for m in secondary)
136
+ sections.append("")
137
+
138
+ comm = agent.get("communication") or {}
139
+ if comm and any(comm.values()):
140
+ sections.append("## Communication")
141
+ if comm.get("tone"):
142
+ sections.append(f"- **Tone:** {comm['tone']}")
143
+ if comm.get("vocabulary_level"):
144
+ sections.append(f"- **Vocabulary:** {comm['vocabulary_level']}")
145
+ if comm.get("preferred_format"):
146
+ sections.append(f"- **Preferred format:** {comm['preferred_format']}")
147
+ if comm.get("language"):
148
+ sections.append(f"- **Language:** {comm['language']}")
149
+ avoid = comm.get("avoid") or []
150
+ if avoid:
151
+ sections.append(f"- **Avoid:** {', '.join(avoid)}")
152
+ sections.append("")
153
+
154
+ linked = agent.get("linked_personas") or []
155
+ if linked:
156
+ sections.append("## Linked Personas")
157
+ sections.extend(f"- [[{p}]]" for p in linked)
158
+ sections.append("")
159
+
160
+ sections.append("")
161
+ sections.append("*Generated by ArkaOS — source of truth lives in the YAML file.*")
162
+ return "\n".join(fm_lines + sections)
163
+
164
+
165
+ def _yaml_str(value: object) -> str:
166
+ """Quote a YAML scalar if it contains special characters."""
167
+ s = "" if value is None else str(value)
168
+ if any(c in s for c in ":#'\"\n"):
169
+ escaped = s.replace('"', '\\"')
170
+ return f'"{escaped}"'
171
+ return s
@@ -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).
@@ -9,7 +9,8 @@
9
9
  const route = useRoute()
10
10
  const agentId = route.params.id as string
11
11
 
12
- const { fetchApi } = useApi()
12
+ const { fetchApi, apiBase } = useApi()
13
+ const toast = useToast()
13
14
  const { data: agent, status, error, refresh } = fetchApi<any>(`/api/agents/${agentId}`)
14
15
 
15
16
  // Per-department activity (PR69 endpoint) for the stats row.
@@ -27,9 +28,10 @@ const deptActivity = computed<ActivityRow | null>(() =>
27
28
  (activityData.value?.by_department?.[agent.value?.department ?? ''] ?? null),
28
29
  )
29
30
 
30
- // PR83d v3.6.0 — activity strip (30d, dept-level + last_used + rank)
31
+ // PR83d v3.6.0 + PR86b v3.16.0 — activity strip (30d, agent or dept scope)
31
32
  interface ActivityStrip {
32
33
  period: string
34
+ scope: 'agent' | 'department'
33
35
  department: string
34
36
  calls: number
35
37
  cost_usd: number | null
@@ -63,6 +65,34 @@ function formatRelative(iso: string | null): string {
63
65
  const favs = useFavorites()
64
66
  await favs.load()
65
67
 
68
+ // PR86c v3.17.0 — export to Obsidian.
69
+ const exporting = ref(false)
70
+ async function exportToVault() {
71
+ if (!agent.value) return
72
+ exporting.value = true
73
+ try {
74
+ const res = await $fetch<{ exported?: boolean, path?: string, error?: string }>(
75
+ `${apiBase}/api/agents/${agentId}/export`,
76
+ { method: 'POST' },
77
+ )
78
+ if (res.error) throw new Error(res.error)
79
+ toast.add({
80
+ title: 'Exported to Obsidian',
81
+ description: res.path ? res.path.split('/').slice(-3).join('/') : undefined,
82
+ color: 'success',
83
+ icon: 'i-lucide-file-text',
84
+ })
85
+ } catch (err) {
86
+ toast.add({
87
+ title: 'Export failed',
88
+ description: err instanceof Error ? err.message : 'unknown error',
89
+ color: 'error',
90
+ })
91
+ } finally {
92
+ exporting.value = false
93
+ }
94
+ }
95
+
66
96
  // PR76 — edit drawer state
67
97
  const editOpen = ref(false)
68
98
 
@@ -281,6 +311,14 @@ function formatTokens(n: number): string {
281
311
  :aria-label="favs.isAgentFavorite(agent.id) ? 'Unfavorite' : 'Favorite'"
282
312
  @click="favs.toggle('agents', agent.id)"
283
313
  />
314
+ <UButton
315
+ label="Export"
316
+ icon="i-lucide-file-text"
317
+ variant="soft"
318
+ size="sm"
319
+ :loading="exporting"
320
+ @click="exportToVault"
321
+ />
284
322
  <UButton
285
323
  label="Edit"
286
324
  icon="i-lucide-pencil"
@@ -346,7 +384,7 @@ function formatTokens(n: number): string {
346
384
  <div class="flex items-center gap-2">
347
385
  <UIcon name="i-lucide-activity" class="size-4 text-primary" />
348
386
  <span class="font-semibold uppercase tracking-wide text-muted text-xs">
349
- 30d activity (dept)
387
+ 30d activity ({{ activityStrip.scope === 'agent' ? 'agent' : 'dept' }})
350
388
  </span>
351
389
  </div>
352
390
  <div class="flex items-center gap-2">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.15.0",
3
+ "version": "3.17.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.15.0"
3
+ version = "3.17.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,28 @@ def persona_delete(persona_id: str):
1331
1361
  return {"error": "Persona not found"}
1332
1362
 
1333
1363
 
1364
+ # --- Agent → Obsidian export (PR86c v3.17.0) ---
1365
+
1366
+ @app.post("/api/agents/{agent_id}/export")
1367
+ def agent_export_to_vault(agent_id: str):
1368
+ """Write the agent profile as Markdown under <vault>/Agents/<id>.md."""
1369
+ detail = agent_detail(agent_id)
1370
+ if "error" in detail:
1371
+ return detail
1372
+ try:
1373
+ from core.agents.obsidian_export import (
1374
+ AgentExportError,
1375
+ export_agent_to_vault,
1376
+ )
1377
+ except Exception as exc: # noqa: BLE001
1378
+ return {"error": f"export module unavailable: {exc}"}
1379
+ try:
1380
+ res = export_agent_to_vault(detail)
1381
+ except AgentExportError as exc:
1382
+ return {"error": str(exc)}
1383
+ return {"exported": True, "path": str(res.path), "vault_path": str(res.vault_path)}
1384
+
1385
+
1334
1386
  # --- Favorites (PR86a v3.15.0) ---
1335
1387
 
1336
1388
  @app.get("/api/favorites")