arkaos 3.35.0 → 3.37.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.35.0
1
+ 3.37.0
@@ -143,13 +143,55 @@ await favs.load()
143
143
  const favoritesOnly = ref(false)
144
144
 
145
145
  // PR87b v3.20.0 — import .md persona files.
146
+ // PR91b v3.36.0 — extended with URL import.
146
147
  const importInput = ref<HTMLInputElement | null>(null)
147
148
  const importing = ref(false)
149
+ const urlImportOpen = ref(false)
150
+ const urlImportText = ref('')
148
151
 
149
152
  function triggerImport() {
150
153
  importInput.value?.click()
151
154
  }
152
155
 
156
+ async function runUrlImport() {
157
+ const urls = urlImportText.value
158
+ .split('\n')
159
+ .map((s) => s.trim())
160
+ .filter(Boolean)
161
+ if (urls.length === 0) return
162
+ importing.value = true
163
+ try {
164
+ const res = await $fetch<{
165
+ imported: number
166
+ failed: number
167
+ results: Array<{ filename: string, status: string, id?: string, error?: string }>
168
+ error?: string
169
+ }>(`${apiBase}/api/personas/import`, { method: 'POST', body: { urls } })
170
+ if (res.error) throw new Error(res.error)
171
+ toast.add({
172
+ title: res.imported > 0
173
+ ? `Imported ${res.imported} persona${res.imported === 1 ? '' : 's'}`
174
+ : 'Nothing imported',
175
+ description: res.failed > 0 ? `${res.failed} failed` : undefined,
176
+ color: res.imported > 0 && res.failed === 0
177
+ ? 'success'
178
+ : res.imported > 0 ? 'warning' : 'error',
179
+ icon: 'i-lucide-globe',
180
+ })
181
+ await refreshAll()
182
+ urlImportText.value = ''
183
+ urlImportOpen.value = false
184
+ } catch (err) {
185
+ toast.add({
186
+ title: 'URL import failed',
187
+ description: err instanceof Error ? err.message : 'unknown error',
188
+ color: 'error',
189
+ })
190
+ } finally {
191
+ importing.value = false
192
+ }
193
+ }
194
+
153
195
  async function onImportFiles(event: Event) {
154
196
  const target = event.target as HTMLInputElement
155
197
  const files = Array.from(target.files ?? [])
@@ -312,14 +354,21 @@ async function undoTrashIds(ids: string[]) {
312
354
  />
313
355
  </template>
314
356
  <template #right>
315
- <UButton
316
- label="Import .md"
317
- icon="i-lucide-file-up"
318
- variant="soft"
319
- size="sm"
320
- :loading="importing"
321
- @click="triggerImport"
322
- />
357
+ <UDropdownMenu
358
+ :items="[
359
+ { label: 'Pick .md files…', icon: 'i-lucide-file-up', onSelect: triggerImport },
360
+ { label: 'From URLs…', icon: 'i-lucide-globe', onSelect: () => urlImportOpen = true },
361
+ ]"
362
+ >
363
+ <UButton
364
+ label="Import"
365
+ icon="i-lucide-file-up"
366
+ variant="soft"
367
+ size="sm"
368
+ :loading="importing"
369
+ trailing-icon="i-lucide-chevron-down"
370
+ />
371
+ </UDropdownMenu>
323
372
  <input
324
373
  ref="importInput"
325
374
  type="file"
@@ -328,6 +377,38 @@ async function undoTrashIds(ids: string[]) {
328
377
  class="hidden"
329
378
  @change="onImportFiles"
330
379
  />
380
+ <UModal v-model:open="urlImportOpen" title="Import from URLs">
381
+ <template #content>
382
+ <UCard>
383
+ <template #header>
384
+ <h2 class="text-lg font-bold">Import personas from URLs</h2>
385
+ <p class="text-xs text-muted mt-0.5">
386
+ One raw .md URL per line. Files must have YAML
387
+ frontmatter with <code>type: persona</code>.
388
+ </p>
389
+ </template>
390
+ <UTextarea
391
+ v-model="urlImportText"
392
+ :rows="6"
393
+ placeholder="https://raw.githubusercontent.com/owner/repo/main/personas/alex.md"
394
+ class="w-full font-mono text-sm"
395
+ />
396
+ <template #footer>
397
+ <div class="flex items-center justify-end gap-2">
398
+ <UButton label="Cancel" variant="ghost" :disabled="importing" @click="urlImportOpen = false" />
399
+ <UButton
400
+ label="Import"
401
+ icon="i-lucide-globe"
402
+ color="primary"
403
+ :loading="importing"
404
+ :disabled="!urlImportText.trim()"
405
+ @click="runUrlImport"
406
+ />
407
+ </div>
408
+ </template>
409
+ </UCard>
410
+ </template>
411
+ </UModal>
331
412
  <UButton
332
413
  label="New Persona"
333
414
  icon="i-lucide-plus"
@@ -7,6 +7,14 @@
7
7
 
8
8
  import type { TableColumn } from '@nuxt/ui'
9
9
 
10
+ interface WorkflowPhase {
11
+ id: string
12
+ name: string
13
+ description: string
14
+ gate_type: string
15
+ agent_count: number
16
+ }
17
+
10
18
  interface Workflow {
11
19
  id: string
12
20
  name: string
@@ -15,6 +23,7 @@ interface Workflow {
15
23
  tier: string
16
24
  command: string
17
25
  phases_count: number
26
+ phases: WorkflowPhase[]
18
27
  file: string
19
28
  content: string
20
29
  }
@@ -35,7 +44,16 @@ interface WorkflowRun {
35
44
  }
36
45
  const runs = ref<WorkflowRun[]>([])
37
46
  const runsLoading = ref(false)
38
- const sidePanelTab = ref<'yaml' | 'runs'>('yaml')
47
+ const sidePanelTab = ref<'flow' | 'yaml' | 'runs'>('flow')
48
+
49
+ function gateColor(gateType: string): 'primary' | 'warning' | 'error' | 'neutral' {
50
+ const m: Record<string, 'primary' | 'warning' | 'error' | 'neutral'> = {
51
+ user_approval: 'warning',
52
+ quality_gate: 'error',
53
+ automatic: 'primary',
54
+ }
55
+ return m[gateType] ?? 'neutral'
56
+ }
39
57
 
40
58
  async function loadRuns(id: string) {
41
59
  runsLoading.value = true
@@ -152,7 +170,7 @@ const columns: TableColumn<Workflow>[] = [
152
170
  th: 'py-2 first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
153
171
  td: 'border-b border-default',
154
172
  }"
155
- @select="(row: { original: Workflow }) => { selected = row.original; sidePanelTab = 'yaml'; runs = []; loadRuns(row.original.id) }"
173
+ @select="(row: { original: Workflow }) => { selected = row.original; sidePanelTab = 'flow'; runs = []; loadRuns(row.original.id) }"
156
174
  >
157
175
  <template #name-cell="{ row }">
158
176
  <div class="min-w-0">
@@ -205,6 +223,14 @@ const columns: TableColumn<Workflow>[] = [
205
223
  {{ selected.description }}
206
224
  </p>
207
225
  <div class="flex items-center gap-1 mt-3 text-xs">
226
+ <button
227
+ type="button"
228
+ class="px-2 py-1 rounded-md transition-colors"
229
+ :class="sidePanelTab === 'flow' ? 'bg-elevated/60 text-default font-semibold' : 'text-muted hover:text-default'"
230
+ @click="sidePanelTab = 'flow'"
231
+ >
232
+ Flow
233
+ </button>
208
234
  <button
209
235
  type="button"
210
236
  class="px-2 py-1 rounded-md transition-colors"
@@ -223,7 +249,43 @@ const columns: TableColumn<Workflow>[] = [
223
249
  </button>
224
250
  </div>
225
251
  </div>
226
- <div v-if="sidePanelTab === 'yaml'" class="overflow-x-auto">
252
+ <div v-if="sidePanelTab === 'flow'" class="p-4">
253
+ <ol v-if="selected.phases.length > 0" class="relative border-l border-default ml-2 space-y-3">
254
+ <li
255
+ v-for="(ph, idx) in selected.phases"
256
+ :key="ph.id || idx"
257
+ class="ml-4"
258
+ >
259
+ <span class="absolute -left-1.5 size-3 rounded-full bg-primary border border-primary/60" />
260
+ <div class="rounded-lg border border-default p-3 bg-elevated/20">
261
+ <div class="flex items-center gap-2 flex-wrap">
262
+ <span class="font-mono text-xs text-muted">{{ idx + 1 }}.</span>
263
+ <span class="font-semibold">{{ ph.name || ph.id }}</span>
264
+ <UBadge
265
+ v-if="ph.gate_type"
266
+ :label="ph.gate_type"
267
+ :color="gateColor(ph.gate_type)"
268
+ variant="subtle"
269
+ size="xs"
270
+ />
271
+ <UBadge
272
+ v-if="ph.agent_count > 0"
273
+ :label="`${ph.agent_count} agent${ph.agent_count === 1 ? '' : 's'}`"
274
+ variant="outline"
275
+ size="xs"
276
+ />
277
+ </div>
278
+ <p v-if="ph.description" class="text-xs text-muted mt-1">
279
+ {{ ph.description }}
280
+ </p>
281
+ </div>
282
+ </li>
283
+ </ol>
284
+ <div v-else class="py-6 text-center text-sm text-muted">
285
+ No phases defined.
286
+ </div>
287
+ </div>
288
+ <div v-else-if="sidePanelTab === 'yaml'" class="overflow-x-auto">
227
289
  <pre class="p-4 text-xs font-mono whitespace-pre">{{ selected.content }}</pre>
228
290
  </div>
229
291
  <div v-else class="p-4">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.35.0",
3
+ "version": "3.37.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.35.0"
3
+ version = "3.37.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"}
@@ -1848,12 +1848,33 @@ def workflows_list():
1848
1848
  "tier": str(raw.get("tier") or ""),
1849
1849
  "command": str(raw.get("command") or ""),
1850
1850
  "phases_count": len(phases),
1851
+ "phases": _summarise_phases(phases), # PR91c v3.37.0
1851
1852
  "file": rel,
1852
1853
  "content": content,
1853
1854
  })
1854
1855
  return {"workflows": out}
1855
1856
 
1856
1857
 
1858
+ def _summarise_phases(phases: list) -> list[dict]:
1859
+ """PR91c v3.37.0 — distil each phase down to what the flow stepper needs."""
1860
+ out: list[dict] = []
1861
+ for p in phases:
1862
+ if not isinstance(p, dict):
1863
+ continue
1864
+ gate = p.get("gate") if isinstance(p.get("gate"), dict) else {}
1865
+ agents = p.get("agents") if isinstance(p.get("agents"), list) else []
1866
+ out.append({
1867
+ "id": str(p.get("id") or ""),
1868
+ "name": str(p.get("name") or ""),
1869
+ "description": str(p.get("description") or ""),
1870
+ "gate_type": str(gate.get("type") or ""),
1871
+ "agent_count": sum(
1872
+ 1 for a in agents if isinstance(a, dict) and a.get("agent_id")
1873
+ ),
1874
+ })
1875
+ return out
1876
+
1877
+
1857
1878
  # --- Sidebar stats widget (PR87d v3.22.0) ---
1858
1879
 
1859
1880
  @app.get("/api/sidebar-stats")
@@ -2004,16 +2025,24 @@ def global_search(q: str = "", limit: int = 20):
2004
2025
  def personas_import(body: dict):
2005
2026
  """Import persona Markdown files (frontmatter + body) into the store.
2006
2027
 
2007
- Body: {"files": [{"name": "Alex.md", "content": "<full file body>"}]}
2008
- Returns: {imported, failed, results: [{filename, status, id?, error?}]}
2028
+ Body:
2029
+ {"files": [{"name": "Alex.md", "content": "..."}]} # local picker
2030
+ {"urls": ["https://example.com/raw.md", ...]} # remote import
2031
+
2032
+ Both keys are accepted; URLs are fetched server-side (PR91b
2033
+ v3.36.0) and converted into the same `{name, content}` shape
2034
+ before processing.
2009
2035
 
2010
- Each file MUST have YAML frontmatter with ``type: persona``. Files
2011
- lacking the frontmatter are flagged as failed without partial
2012
- side-effects.
2036
+ Returns: {imported, failed, results: [{filename, status, id?, error?}]}
2013
2037
  """
2014
- files = body.get("files") or []
2015
- if not isinstance(files, list):
2038
+ raw_files = body.get("files")
2039
+ raw_urls = body.get("urls")
2040
+ if raw_files is not None and not isinstance(raw_files, list):
2016
2041
  return {"error": "files must be a list"}
2042
+ if raw_urls is not None and not isinstance(raw_urls, list):
2043
+ return {"error": "urls must be a list"}
2044
+ files = list(raw_files or [])
2045
+ urls = list(raw_urls or [])
2017
2046
  mgr = _get_persona_manager()
2018
2047
  if not mgr:
2019
2048
  return {"error": "Persona manager unavailable"}
@@ -2022,6 +2051,10 @@ def personas_import(body: dict):
2022
2051
 
2023
2052
  from core.personas.obsidian_store import ObsidianPersonaStore
2024
2053
 
2054
+ # Resolve URLs into {name, content} entries.
2055
+ if urls:
2056
+ files.extend(_fetch_url_entries(urls))
2057
+
2025
2058
  imported = 0
2026
2059
  failed = 0
2027
2060
  results: list[dict] = []
@@ -2032,6 +2065,14 @@ def personas_import(body: dict):
2032
2065
  continue
2033
2066
  filename = str(entry.get("name") or "")
2034
2067
  content = str(entry.get("content") or "")
2068
+ # Carry forward URL-fetch errors so the operator sees them.
2069
+ if entry.get("fetch_error"):
2070
+ failed += 1
2071
+ results.append({
2072
+ "filename": filename, "status": "failed",
2073
+ "error": str(entry["fetch_error"]),
2074
+ })
2075
+ continue
2035
2076
  if not content.strip():
2036
2077
  failed += 1
2037
2078
  results.append({"filename": filename, "status": "failed", "error": "empty content"})
@@ -2058,6 +2099,38 @@ def personas_import(body: dict):
2058
2099
  return {"imported": imported, "failed": failed, "results": results}
2059
2100
 
2060
2101
 
2102
+ def _fetch_url_entries(urls: list[str]) -> list[dict]:
2103
+ """Fetch each URL and return ``{name, content}`` entries. PR91b."""
2104
+ import urllib.error
2105
+ import urllib.parse
2106
+ import urllib.request
2107
+ out: list[dict] = []
2108
+ for raw in urls:
2109
+ url = str(raw or "").strip()
2110
+ if not url:
2111
+ continue
2112
+ parsed = urllib.parse.urlparse(url)
2113
+ if parsed.scheme not in ("http", "https"):
2114
+ out.append({"name": url, "fetch_error": "scheme must be http(s)"})
2115
+ continue
2116
+ try:
2117
+ req = urllib.request.Request(url, headers={"User-Agent": "ArkaOS/persona-import"})
2118
+ with urllib.request.urlopen(req, timeout=10) as resp:
2119
+ content = resp.read().decode("utf-8", errors="replace")
2120
+ except urllib.error.URLError as exc:
2121
+ out.append({"name": url, "fetch_error": f"fetch failed: {exc.reason}"})
2122
+ continue
2123
+ except (TimeoutError, OSError) as exc:
2124
+ out.append({"name": url, "fetch_error": f"fetch failed: {exc}"})
2125
+ continue
2126
+ # Derive a filename from the URL path.
2127
+ name = (parsed.path.rsplit("/", 1)[-1] or "imported.md").strip()
2128
+ if not name.endswith(".md"):
2129
+ name = f"{name or 'imported'}.md"
2130
+ out.append({"name": name, "content": content})
2131
+ return out
2132
+
2133
+
2061
2134
  # --- Trash / Undo (PR85b v3.12.0) ---
2062
2135
 
2063
2136
  @app.get("/api/trash")