arkaos 3.16.0 → 3.18.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.16.0
1
+ 3.18.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
@@ -61,6 +61,10 @@ class Persona(BaseModel):
61
61
  # Communication
62
62
  communication: PersonaCommunication = Field(default_factory=PersonaCommunication)
63
63
 
64
+ # PR86d v3.18.0 — long-form Markdown bio. Free-text field, never
65
+ # parsed; rendered in the dashboard with `marked` for preview.
66
+ bio_md: str = ""
67
+
64
68
  # Metadata
65
69
  created_at: str = ""
66
70
  updated_at: str = ""
@@ -58,6 +58,7 @@ interface AgentDraft {
58
58
  avoid: string[]
59
59
  }
60
60
  linked_personas: string[]
61
+ bio_md: string
61
62
  }
62
63
 
63
64
  const draft = ref<AgentDraft | null>(null)
@@ -88,6 +89,7 @@ watch(
88
89
  avoid: agent.communication?.avoid ?? [],
89
90
  },
90
91
  linked_personas: agent.linked_personas ?? [],
92
+ bio_md: agent.bio_md ?? '',
91
93
  }
92
94
  dirty.value = false
93
95
  } else if (!open) {
@@ -323,6 +325,7 @@ async function save() {
323
325
  },
324
326
  communication: draft.value.communication,
325
327
  linked_personas: draft.value.linked_personas,
328
+ bio_md: draft.value.bio_md,
326
329
  }
327
330
  const res = await $fetch<{
328
331
  id: string
@@ -653,6 +656,18 @@ const vocabOptions = [
653
656
  </UFormField>
654
657
  </section>
655
658
 
659
+ <section class="space-y-3">
660
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">
661
+ Bio (Markdown)
662
+ </h3>
663
+ <MarkdownEditor
664
+ :model-value="draft.bio_md"
665
+ :rows="10"
666
+ placeholder="A free-text Markdown bio for this agent — context, voice samples, internal notes."
667
+ @update:model-value="(v: string) => { if (draft) { draft.bio_md = v; markDirty() } }"
668
+ />
669
+ </section>
670
+
656
671
  <section class="space-y-3">
657
672
  <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">
658
673
  Linked personas
@@ -0,0 +1,74 @@
1
+ <script setup lang="ts">
2
+ // PR86d v3.18.0 — Markdown editor with Edit / Preview tabs.
3
+ //
4
+ // Standalone component used in agent + persona edit forms. The model
5
+ // value is the raw Markdown string. Preview is rendered via marked
6
+ // (already a deps after this PR).
7
+
8
+ import { marked } from 'marked'
9
+
10
+ const props = defineProps<{
11
+ modelValue: string
12
+ placeholder?: string
13
+ rows?: number
14
+ }>()
15
+ const emit = defineEmits<{
16
+ (e: 'update:modelValue', value: string): void
17
+ }>()
18
+
19
+ const tab = ref<'edit' | 'preview'>('edit')
20
+
21
+ const value = computed({
22
+ get: () => props.modelValue ?? '',
23
+ set: (v: string) => emit('update:modelValue', v),
24
+ })
25
+
26
+ const html = computed(() => {
27
+ if (!value.value.trim()) {
28
+ return '<p class="text-muted italic">Nothing to preview yet.</p>'
29
+ }
30
+ try {
31
+ return marked.parse(value.value, { breaks: true, gfm: true })
32
+ } catch {
33
+ return '<p class="text-error">Markdown parse failed.</p>'
34
+ }
35
+ })
36
+ </script>
37
+
38
+ <template>
39
+ <div class="space-y-2">
40
+ <div class="flex items-center gap-1 text-xs">
41
+ <button
42
+ type="button"
43
+ class="px-2 py-1 rounded-md transition-colors"
44
+ :class="tab === 'edit' ? 'bg-elevated/60 text-default font-semibold' : 'text-muted hover:text-default'"
45
+ @click="tab = 'edit'"
46
+ >
47
+ Edit
48
+ </button>
49
+ <button
50
+ type="button"
51
+ class="px-2 py-1 rounded-md transition-colors"
52
+ :class="tab === 'preview' ? 'bg-elevated/60 text-default font-semibold' : 'text-muted hover:text-default'"
53
+ @click="tab = 'preview'"
54
+ >
55
+ Preview
56
+ </button>
57
+ <span class="ml-auto text-xs text-muted">
58
+ Markdown · GFM · {{ value.length }} char{{ value.length === 1 ? '' : 's' }}
59
+ </span>
60
+ </div>
61
+ <UTextarea
62
+ v-if="tab === 'edit'"
63
+ v-model="value"
64
+ :rows="rows ?? 8"
65
+ :placeholder="placeholder ?? 'Write a Markdown bio…\n\n# Heading\n- bullets\n**bold**'"
66
+ class="w-full font-mono text-sm"
67
+ />
68
+ <div
69
+ v-else
70
+ class="rounded-lg border border-default bg-elevated/10 p-4 min-h-[12rem] prose prose-sm dark:prose-invert max-w-none"
71
+ v-html="html"
72
+ />
73
+ </div>
74
+ </template>
@@ -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.
@@ -64,6 +65,45 @@ function formatRelative(iso: string | null): string {
64
65
  const favs = useFavorites()
65
66
  await favs.load()
66
67
 
68
+ // PR86d v3.18.0 — render Markdown bio.
69
+ import { marked } from 'marked'
70
+ function markedHtml(src: string): string {
71
+ if (!src?.trim()) return ''
72
+ try {
73
+ return marked.parse(src, { breaks: true, gfm: true }) as string
74
+ } catch {
75
+ return ''
76
+ }
77
+ }
78
+
79
+ // PR86c v3.17.0 — export to Obsidian.
80
+ const exporting = ref(false)
81
+ async function exportToVault() {
82
+ if (!agent.value) return
83
+ exporting.value = true
84
+ try {
85
+ const res = await $fetch<{ exported?: boolean, path?: string, error?: string }>(
86
+ `${apiBase}/api/agents/${agentId}/export`,
87
+ { method: 'POST' },
88
+ )
89
+ if (res.error) throw new Error(res.error)
90
+ toast.add({
91
+ title: 'Exported to Obsidian',
92
+ description: res.path ? res.path.split('/').slice(-3).join('/') : undefined,
93
+ color: 'success',
94
+ icon: 'i-lucide-file-text',
95
+ })
96
+ } catch (err) {
97
+ toast.add({
98
+ title: 'Export failed',
99
+ description: err instanceof Error ? err.message : 'unknown error',
100
+ color: 'error',
101
+ })
102
+ } finally {
103
+ exporting.value = false
104
+ }
105
+ }
106
+
67
107
  // PR76 — edit drawer state
68
108
  const editOpen = ref(false)
69
109
 
@@ -282,6 +322,14 @@ function formatTokens(n: number): string {
282
322
  :aria-label="favs.isAgentFavorite(agent.id) ? 'Unfavorite' : 'Favorite'"
283
323
  @click="favs.toggle('agents', agent.id)"
284
324
  />
325
+ <UButton
326
+ label="Export"
327
+ icon="i-lucide-file-text"
328
+ variant="soft"
329
+ size="sm"
330
+ :loading="exporting"
331
+ @click="exportToVault"
332
+ />
285
333
  <UButton
286
334
  label="Edit"
287
335
  icon="i-lucide-pencil"
@@ -381,6 +429,20 @@ function formatTokens(n: number): string {
381
429
  </div>
382
430
  </section>
383
431
 
432
+ <!-- ===== BIO (PR86d) ===== -->
433
+ <section
434
+ v-if="(agent as any).bio_md"
435
+ class="rounded-xl border border-default bg-elevated/10 p-5"
436
+ >
437
+ <h3 class="text-sm font-semibold uppercase tracking-wide text-muted mb-3">
438
+ Bio
439
+ </h3>
440
+ <div
441
+ class="prose prose-sm dark:prose-invert max-w-none"
442
+ v-html="markedHtml((agent as any).bio_md)"
443
+ />
444
+ </section>
445
+
384
446
  <AgentEditDrawer
385
447
  v-model="editOpen"
386
448
  :agent="agent"
@@ -230,6 +230,17 @@ const suggestingField = ref<SuggestField | null>(null)
230
230
  const favs = useFavorites()
231
231
  await favs.load()
232
232
 
233
+ // PR86d v3.18.0 — render Markdown bio.
234
+ import { marked } from 'marked'
235
+ function markedHtml(src: string): string {
236
+ if (!src?.trim()) return ''
237
+ try {
238
+ return marked.parse(src, { breaks: true, gfm: true }) as string
239
+ } catch {
240
+ return ''
241
+ }
242
+ }
243
+
233
244
  // PR85a v3.11.0 — Clone to Agent dialog.
234
245
  const cloneOpen = ref(false)
235
246
  function onCloned(agentId: string) {
@@ -560,6 +571,20 @@ const vocabOptions = [
560
571
  </div>
561
572
  </section>
562
573
 
574
+ <!-- BIO (PR86d) -->
575
+ <section
576
+ v-if="(detail as any).bio_md"
577
+ class="rounded-xl border border-default bg-elevated/10 p-5"
578
+ >
579
+ <h3 class="text-sm font-semibold uppercase tracking-wide text-muted mb-3">
580
+ Bio
581
+ </h3>
582
+ <div
583
+ class="prose prose-sm dark:prose-invert max-w-none"
584
+ v-html="markedHtml((detail as any).bio_md)"
585
+ />
586
+ </section>
587
+
563
588
  <!-- TABS -->
564
589
  <UTabs :items="tabs" default-value="dna" class="w-full">
565
590
  <template #content="{ item }">
@@ -937,6 +962,18 @@ const vocabOptions = [
937
962
  </UFormField>
938
963
  </section>
939
964
 
965
+ <section class="space-y-3">
966
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">
967
+ Bio (Markdown)
968
+ </h3>
969
+ <MarkdownEditor
970
+ :model-value="(draft as any).bio_md ?? ''"
971
+ :rows="10"
972
+ placeholder="A free-text Markdown bio for this persona — voice samples, context, references."
973
+ @update:model-value="(v: string) => { if (draft) { (draft as any).bio_md = v; markDirty() } }"
974
+ />
975
+ </section>
976
+
940
977
  <section class="space-y-3">
941
978
  <div class="flex items-center justify-between">
942
979
  <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Key quotes</h3>
@@ -21,6 +21,7 @@
21
21
  "@vueuse/core": "^14.2.1",
22
22
  "@vueuse/nuxt": "^14.2.1",
23
23
  "date-fns": "^4.1.0",
24
+ "marked": "^15.0.0",
24
25
  "nuxt": "^4.4.2",
25
26
  "scule": "^1.3.0",
26
27
  "tailwindcss": "^4.2.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.16.0",
3
+ "version": "3.18.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.16.0"
3
+ version = "3.18.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"}
@@ -451,6 +451,9 @@ def agent_update(agent_id: str, body: dict):
451
451
  raw["communication"] = comm
452
452
  if "linked_personas" in body:
453
453
  raw["linked_personas"] = _agent_str_list(body["linked_personas"])
454
+ # PR86d v3.18.0 — Markdown bio (long-form free text).
455
+ if "bio_md" in body and isinstance(body["bio_md"], str):
456
+ raw["bio_md"] = body["bio_md"]
454
457
 
455
458
  try:
456
459
  tmp = yaml_file.with_suffix(yaml_file.suffix + ".tmp")
@@ -1101,6 +1104,7 @@ def persona_update(persona_id: str, body: dict):
1101
1104
  communication=PersonaCommunication(
1102
1105
  **(merged.get("communication", {}) or {}),
1103
1106
  ),
1107
+ bio_md=merged.get("bio_md", "") or "",
1104
1108
  created_at=merged.get("created_at", ""),
1105
1109
  )
1106
1110
 
@@ -1361,6 +1365,28 @@ def persona_delete(persona_id: str):
1361
1365
  return {"error": "Persona not found"}
1362
1366
 
1363
1367
 
1368
+ # --- Agent → Obsidian export (PR86c v3.17.0) ---
1369
+
1370
+ @app.post("/api/agents/{agent_id}/export")
1371
+ def agent_export_to_vault(agent_id: str):
1372
+ """Write the agent profile as Markdown under <vault>/Agents/<id>.md."""
1373
+ detail = agent_detail(agent_id)
1374
+ if "error" in detail:
1375
+ return detail
1376
+ try:
1377
+ from core.agents.obsidian_export import (
1378
+ AgentExportError,
1379
+ export_agent_to_vault,
1380
+ )
1381
+ except Exception as exc: # noqa: BLE001
1382
+ return {"error": f"export module unavailable: {exc}"}
1383
+ try:
1384
+ res = export_agent_to_vault(detail)
1385
+ except AgentExportError as exc:
1386
+ return {"error": str(exc)}
1387
+ return {"exported": True, "path": str(res.path), "vault_path": str(res.vault_path)}
1388
+
1389
+
1364
1390
  # --- Favorites (PR86a v3.15.0) ---
1365
1391
 
1366
1392
  @app.get("/api/favorites")