arkaos 4.1.1 → 4.3.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.
Files changed (47) hide show
  1. package/README.md +1 -1
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +1 -1
  4. package/arka/skills/flow/SKILL.md +15 -0
  5. package/arka/skills/fusion/SKILL.md +91 -0
  6. package/config/constitution.yaml +15 -7
  7. package/config/hooks/session-start.sh +22 -0
  8. package/config/models.yaml +70 -0
  9. package/core/fusion/__init__.py +5 -0
  10. package/core/fusion/__pycache__/__init__.cpython-313.pyc +0 -0
  11. package/core/fusion/__pycache__/cli.cpython-313.pyc +0 -0
  12. package/core/fusion/__pycache__/engine.cpython-313.pyc +0 -0
  13. package/core/fusion/cli.py +37 -0
  14. package/core/fusion/engine.py +153 -0
  15. package/core/governance/__pycache__/redo_counter.cpython-313.pyc +0 -0
  16. package/core/governance/redo_counter.py +81 -0
  17. package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
  18. package/core/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
  19. package/core/hooks/pre_tool_use.py +55 -3
  20. package/core/obsidian/__pycache__/relator.cpython-313.pyc +0 -0
  21. package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
  22. package/core/runtime/__pycache__/llm_provider.cpython-314.pyc +0 -0
  23. package/core/runtime/__pycache__/model_router.cpython-313.pyc +0 -0
  24. package/core/runtime/__pycache__/model_router_cli.cpython-313.pyc +0 -0
  25. package/core/runtime/__pycache__/ollama_discovery.cpython-313.pyc +0 -0
  26. package/core/runtime/__pycache__/openrouter_provider.cpython-313.pyc +0 -0
  27. package/core/runtime/__pycache__/openrouter_provider.cpython-314.pyc +0 -0
  28. package/core/runtime/llm_provider.py +10 -1
  29. package/core/runtime/model_router.py +204 -0
  30. package/core/runtime/model_router_cli.py +151 -0
  31. package/core/runtime/ollama_discovery.py +96 -0
  32. package/core/runtime/openrouter_provider.py +172 -0
  33. package/core/sync/__pycache__/__init__.cpython-312.pyc +0 -0
  34. package/core/sync/__pycache__/engine.cpython-312.pyc +0 -0
  35. package/core/sync/__pycache__/manifest.cpython-312.pyc +0 -0
  36. package/core/workflow/__pycache__/frontend_gate.cpython-313.pyc +0 -0
  37. package/core/workflow/frontend_gate.py +162 -0
  38. package/dashboard/app/layouts/default.vue +7 -0
  39. package/dashboard/app/pages/models.vue +404 -0
  40. package/installer/autostart.js +16 -2
  41. package/installer/cli.js +23 -0
  42. package/installer/keys.js +1 -0
  43. package/package.json +1 -1
  44. package/pyproject.toml +1 -1
  45. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  46. package/scripts/dashboard-api.py +79 -0
  47. package/scripts/start-dashboard.sh +25 -1
@@ -0,0 +1,404 @@
1
+ <script setup lang="ts">
2
+ // Model Fabric (PR-C) — who runs what, what this machine offers, where
3
+ // tokens flow. Backed by /api/models, /api/models/role, /api/models/usage.
4
+
5
+ interface ResolvedRole {
6
+ role: string
7
+ provider: string
8
+ model: string
9
+ effort: string
10
+ source: string
11
+ }
12
+
13
+ interface OllamaModel {
14
+ name: string
15
+ size_gb: number
16
+ family: string
17
+ parameter_size: string
18
+ }
19
+
20
+ interface ModelsOverview {
21
+ source: string
22
+ config_path: string
23
+ roles: ResolvedRole[]
24
+ providers: Record<string, { type: string }>
25
+ aliases: Record<string, Record<string, string>>
26
+ fusion: { judge: Record<string, string>, panel: Array<Record<string, string>> }
27
+ ollama: { installed: boolean, running: boolean, host: string, models: OllamaModel[] }
28
+ keys: { openrouter: boolean, anthropic: boolean }
29
+ }
30
+
31
+ interface BreakdownRow {
32
+ total_cost_usd: number | null
33
+ total_tokens_in: number
34
+ total_tokens_out: number
35
+ call_count: number
36
+ }
37
+
38
+ interface UsageSummary {
39
+ period: string
40
+ call_count: number
41
+ total_cost_usd: number | null
42
+ total_tokens_in: number
43
+ total_tokens_out: number
44
+ total_cached_tokens: number
45
+ by_model: Record<string, BreakdownRow>
46
+ by_provider: Record<string, BreakdownRow>
47
+ }
48
+
49
+ type Period = 'today' | 'week' | 'month' | 'all'
50
+
51
+ const { fetchApi, apiBase } = useApi()
52
+ const toast = useToast()
53
+
54
+ const {
55
+ data: overview,
56
+ status,
57
+ error,
58
+ refresh,
59
+ } = fetchApi<ModelsOverview>('/api/models')
60
+
61
+ const period = ref<Period>('week')
62
+ const periodOptions: { label: string, value: Period }[] = [
63
+ { label: 'Today', value: 'today' },
64
+ { label: '7 days', value: 'week' },
65
+ { label: '30 days', value: 'month' },
66
+ { label: 'All time', value: 'all' },
67
+ ]
68
+ const { data: usage, refresh: refreshUsage } = fetchApi<UsageSummary>(
69
+ '/api/models/usage',
70
+ { query: computed(() => ({ period: period.value })) },
71
+ )
72
+ watch(period, () => refreshUsage())
73
+
74
+ // ─── Provider lanes (signature element: live availability strip) ────────
75
+
76
+ const QUALITY_ROLES = new Set(['design', 'review', 'architecture', 'strategy', 'quality_gate'])
77
+
78
+ const laneStyles: Record<string, { badge: string, dot: string }> = {
79
+ runtime: { badge: 'text-primary bg-primary/10', dot: 'bg-primary' },
80
+ ollama: { badge: 'text-amber-600 dark:text-amber-400 bg-amber-500/10', dot: 'bg-amber-500' },
81
+ openrouter: { badge: 'text-violet-600 dark:text-violet-400 bg-violet-500/10', dot: 'bg-violet-500' },
82
+ anthropic: { badge: 'text-sky-600 dark:text-sky-400 bg-sky-500/10', dot: 'bg-sky-500' },
83
+ }
84
+
85
+ function laneStyle(provider: string) {
86
+ return laneStyles[provider] ?? { badge: 'text-muted bg-muted/10', dot: 'bg-muted' }
87
+ }
88
+
89
+ interface Lane {
90
+ id: string
91
+ label: string
92
+ live: boolean
93
+ detail: string
94
+ }
95
+
96
+ const lanes = computed<Lane[]>(() => {
97
+ const o = overview.value
98
+ if (!o) return []
99
+ const ollama = o.ollama ?? { installed: false, running: false, models: [] }
100
+ const keys = o.keys ?? { openrouter: false, anthropic: false }
101
+ const ollamaDetail = ollama.running
102
+ ? `${(ollama.models ?? []).length} local models`
103
+ : ollama.installed ? 'installed, not running' : 'not installed'
104
+ return [
105
+ { id: 'runtime', label: 'Runtime', live: true, detail: 'active CLI session' },
106
+ { id: 'ollama', label: 'Ollama', live: ollama.running, detail: ollamaDetail },
107
+ { id: 'openrouter', label: 'OpenRouter', live: keys.openrouter, detail: keys.openrouter ? 'key configured' : 'no key' },
108
+ { id: 'anthropic', label: 'Anthropic', live: keys.anthropic, detail: keys.anthropic ? 'key configured' : 'no key' },
109
+ ]
110
+ })
111
+
112
+ // ─── Inline role re-routing ──────────────────────────────────────────────
113
+
114
+ const editingRole = ref<string | null>(null)
115
+ const editTarget = ref('')
116
+ const saving = ref(false)
117
+
118
+ const targetOptions = computed(() => {
119
+ const o = overview.value
120
+ if (!o) return []
121
+ const options: { label: string, value: string }[] = []
122
+ for (const alias of ['best', 'default', 'fast']) {
123
+ options.push({ label: `runtime / ${alias}`, value: `runtime/${alias}` })
124
+ }
125
+ for (const m of o.ollama?.models ?? []) {
126
+ options.push({ label: `ollama / ${m.name}`, value: `ollama/${m.name}` })
127
+ }
128
+ if (o.keys?.anthropic) {
129
+ for (const alias of ['best', 'default', 'fast']) {
130
+ options.push({ label: `anthropic / ${alias}`, value: `anthropic/${alias}` })
131
+ }
132
+ }
133
+ if (o.keys?.openrouter) {
134
+ options.push({ label: 'openrouter / (type model id)', value: 'openrouter/' })
135
+ }
136
+ return options
137
+ })
138
+
139
+ function startEdit(row: ResolvedRole) {
140
+ editingRole.value = row.role
141
+ editTarget.value = `${row.provider}/${row.model}`
142
+ }
143
+
144
+ async function saveEdit(role: string) {
145
+ if (!editTarget.value.includes('/')) {
146
+ toast.add({ title: 'Target must be provider/model', color: 'error' })
147
+ return
148
+ }
149
+ saving.value = true
150
+ try {
151
+ await $fetch(`${apiBase.value}/api/models/role`, {
152
+ method: 'POST',
153
+ body: { role, target: editTarget.value },
154
+ })
155
+ toast.add({ title: `${role} re-routed`, description: editTarget.value, color: 'success', icon: 'i-lucide-route' })
156
+ editingRole.value = null
157
+ await refresh()
158
+ } catch (err) {
159
+ toast.add({
160
+ title: 'Re-route failed',
161
+ description: err instanceof Error ? err.message : 'unknown error',
162
+ color: 'error',
163
+ })
164
+ } finally {
165
+ saving.value = false
166
+ }
167
+ }
168
+
169
+ async function useForMechanical(model: OllamaModel) {
170
+ editTarget.value = `ollama/${model.name}`
171
+ await saveEdit('mechanical')
172
+ }
173
+
174
+ // ─── Usage bars ──────────────────────────────────────────────────────────
175
+
176
+ const usageRows = computed<Array<[string, BreakdownRow]>>(() =>
177
+ Object.entries(usage.value?.by_model ?? {}).sort(
178
+ (a, b) => (b[1].total_tokens_in + b[1].total_tokens_out) - (a[1].total_tokens_in + a[1].total_tokens_out),
179
+ ).slice(0, 10),
180
+ )
181
+
182
+ const maxUsageTokens = computed(() => {
183
+ const max = usageRows.value.reduce(
184
+ (acc, [, r]) => Math.max(acc, r.total_tokens_in + r.total_tokens_out), 0,
185
+ )
186
+ return max > 0 ? max : 1
187
+ })
188
+
189
+ function formatTokens(value: number): string {
190
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
191
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
192
+ return value.toString()
193
+ }
194
+
195
+ function formatCost(value: number | null): string {
196
+ if (value === null || value === undefined) return 'n/a'
197
+ if (value === 0) return '$0'
198
+ if (value < 0.01) return `$${value.toFixed(4)}`
199
+ return `$${value.toFixed(2)}`
200
+ }
201
+ </script>
202
+
203
+ <template>
204
+ <UDashboardPanel id="models">
205
+ <template #header>
206
+ <UDashboardNavbar title="Models">
207
+ <template #right>
208
+ <UButton
209
+ label="Refresh"
210
+ variant="ghost"
211
+ icon="i-lucide-refresh-cw"
212
+ size="sm"
213
+ @click="() => { refresh(); refreshUsage() }"
214
+ />
215
+ </template>
216
+ </UDashboardNavbar>
217
+ </template>
218
+
219
+ <template #body>
220
+ <DashboardState
221
+ :status="status"
222
+ :error="error"
223
+ loading-label="Loading model fabric"
224
+ :on-retry="() => refresh()"
225
+ >
226
+ <div class="space-y-6">
227
+ <!-- Provider lanes: live availability strip -->
228
+ <UCard>
229
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
230
+ <div
231
+ v-for="lane in lanes"
232
+ :key="lane.id"
233
+ class="flex items-start gap-3"
234
+ >
235
+ <span class="relative flex h-2.5 w-2.5 mt-1.5">
236
+ <span
237
+ v-if="lane.live"
238
+ class="animate-ping absolute inline-flex h-full w-full rounded-full opacity-60 motion-reduce:hidden"
239
+ :class="laneStyle(lane.id).dot"
240
+ />
241
+ <span
242
+ class="relative inline-flex rounded-full h-2.5 w-2.5"
243
+ :class="lane.live ? laneStyle(lane.id).dot : 'bg-muted/40'"
244
+ />
245
+ </span>
246
+ <div>
247
+ <p class="text-sm font-semibold">{{ lane.label }}</p>
248
+ <p class="text-xs text-muted">{{ lane.detail }}</p>
249
+ </div>
250
+ </div>
251
+ </div>
252
+ </UCard>
253
+
254
+ <!-- Role routing -->
255
+ <UCard>
256
+ <template #header>
257
+ <div class="flex items-center justify-between">
258
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider">
259
+ Role routing
260
+ </p>
261
+ <p class="text-xs text-muted font-mono hidden md:block">{{ overview?.config_path }}</p>
262
+ </div>
263
+ </template>
264
+ <div class="divide-y divide-default">
265
+ <div
266
+ v-for="row in overview?.roles ?? []"
267
+ :key="row.role"
268
+ class="flex items-center gap-3 py-2.5"
269
+ >
270
+ <UIcon
271
+ :name="QUALITY_ROLES.has(row.role) ? 'i-lucide-gem' : 'i-lucide-cog'"
272
+ class="size-4 shrink-0"
273
+ :class="QUALITY_ROLES.has(row.role) ? 'text-primary' : 'text-muted'"
274
+ />
275
+ <span class="w-32 shrink-0 text-sm font-medium">{{ row.role }}</span>
276
+ <template v-if="editingRole === row.role">
277
+ <UInputMenu
278
+ v-model="editTarget"
279
+ :items="targetOptions"
280
+ value-key="value"
281
+ create-item
282
+ size="sm"
283
+ class="flex-1 max-w-xs"
284
+ placeholder="provider/model"
285
+ @create="(item: string) => { editTarget = item }"
286
+ />
287
+ <UButton size="xs" label="Save" :loading="saving" @click="saveEdit(row.role)" />
288
+ <UButton size="xs" variant="ghost" label="Cancel" @click="editingRole = null" />
289
+ </template>
290
+ <template v-else>
291
+ <span
292
+ class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
293
+ :class="laneStyle(row.provider).badge"
294
+ >
295
+ {{ row.provider }}
296
+ </span>
297
+ <span class="text-sm font-mono truncate">
298
+ {{ row.model || '(unset)' }}
299
+ </span>
300
+ <UBadge variant="subtle" size="sm" color="neutral">{{ row.effort }}</UBadge>
301
+ <UButton
302
+ icon="i-lucide-pencil"
303
+ variant="ghost"
304
+ size="xs"
305
+ class="ml-auto"
306
+ :aria-label="`Edit ${row.role} routing`"
307
+ @click="startEdit(row)"
308
+ />
309
+ </template>
310
+ </div>
311
+ </div>
312
+ </UCard>
313
+
314
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
315
+ <!-- Local Ollama models -->
316
+ <UCard>
317
+ <template #header>
318
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider">
319
+ Local models (Ollama)
320
+ </p>
321
+ </template>
322
+ <div v-if="overview?.ollama?.running && (overview.ollama?.models ?? []).length" class="divide-y divide-default">
323
+ <div
324
+ v-for="m in (overview.ollama?.models ?? [])"
325
+ :key="m.name"
326
+ class="flex items-center gap-3 py-2"
327
+ >
328
+ <UIcon name="i-lucide-hard-drive" class="size-4 text-amber-500 shrink-0" />
329
+ <div class="min-w-0 flex-1">
330
+ <p class="text-sm font-mono truncate">{{ m.name }}</p>
331
+ <p class="text-xs text-muted">
332
+ {{ m.family || 'unknown family' }}
333
+ <template v-if="m.parameter_size"> · {{ m.parameter_size }}</template>
334
+ <template v-if="m.size_gb"> · {{ m.size_gb }} GB</template>
335
+ <template v-if="!m.size_gb"> · cloud-proxied</template>
336
+ </p>
337
+ </div>
338
+ <UButton
339
+ size="xs"
340
+ variant="soft"
341
+ label="Use for mechanical"
342
+ :loading="saving"
343
+ @click="useForMechanical(m)"
344
+ />
345
+ </div>
346
+ </div>
347
+ <div v-else-if="overview?.ollama?.installed" class="py-6 text-center">
348
+ <p class="text-sm text-muted">Ollama is installed but not running.</p>
349
+ <p class="text-xs text-muted mt-1 font-mono">ollama serve</p>
350
+ </div>
351
+ <div v-else class="py-6 text-center">
352
+ <p class="text-sm text-muted">No local models yet.</p>
353
+ <p class="text-xs text-muted mt-1">
354
+ Install Ollama to run free local models for mechanical work and fusion panels.
355
+ </p>
356
+ </div>
357
+ </UCard>
358
+
359
+ <!-- Usage by model -->
360
+ <UCard>
361
+ <template #header>
362
+ <div class="flex items-center justify-between">
363
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider">
364
+ Usage by model
365
+ </p>
366
+ <USelect v-model="period" :items="periodOptions" size="xs" class="min-w-24" aria-label="Usage period" />
367
+ </div>
368
+ </template>
369
+ <div v-if="usageRows.length" class="space-y-3">
370
+ <div class="flex items-baseline justify-between">
371
+ <p class="text-sm">
372
+ <span class="font-semibold">{{ usage?.call_count ?? 0 }}</span>
373
+ <span class="text-muted"> calls · </span>
374
+ <span class="font-semibold">{{ formatTokens((usage?.total_tokens_in ?? 0) + (usage?.total_tokens_out ?? 0)) }}</span>
375
+ <span class="text-muted"> tokens</span>
376
+ </p>
377
+ <p class="text-sm font-semibold">{{ formatCost(usage?.total_cost_usd ?? null) }}</p>
378
+ </div>
379
+ <div v-for="[name, row] in usageRows" :key="name" class="space-y-1">
380
+ <div class="flex items-center justify-between text-xs">
381
+ <span class="font-mono truncate">{{ name || '(unattributed)' }}</span>
382
+ <span class="text-muted shrink-0 ml-2">
383
+ {{ formatTokens(row.total_tokens_in + row.total_tokens_out) }} · {{ row.call_count }} calls
384
+ </span>
385
+ </div>
386
+ <div class="h-1.5 rounded-full bg-muted/20 overflow-hidden">
387
+ <div
388
+ class="h-full rounded-full bg-primary"
389
+ :style="{ width: `${Math.max(2, ((row.total_tokens_in + row.total_tokens_out) / maxUsageTokens) * 100)}%` }"
390
+ />
391
+ </div>
392
+ </div>
393
+ </div>
394
+ <div v-else class="py-6 text-center">
395
+ <p class="text-sm text-muted">No usage recorded for this period.</p>
396
+ <p class="text-xs text-muted mt-1">Calls made through the Model Fabric land here automatically.</p>
397
+ </div>
398
+ </UCard>
399
+ </div>
400
+ </div>
401
+ </DashboardState>
402
+ </template>
403
+ </UDashboardPanel>
404
+ </template>
@@ -29,6 +29,9 @@ export function unitFor(os, { repoRoot, home }) {
29
29
  if (os === "darwin") {
30
30
  const startScript = `${repoRoot}/scripts/start-dashboard.sh`;
31
31
  const log = `${home}/.arkaos/logs/dashboard-autostart.log`;
32
+ // AbandonProcessGroup: the launcher backgrounds the API/UI and exits;
33
+ // without it launchd kills the whole process group on exit and the
34
+ // dashboard dies seconds after boot. `ensure` keeps re-loads idempotent.
32
35
  const content = `<?xml version="1.0" encoding="UTF-8"?>
33
36
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
34
37
  <plist version="1.0">
@@ -39,14 +42,19 @@ export function unitFor(os, { repoRoot, home }) {
39
42
  <array>
40
43
  <string>/bin/bash</string>
41
44
  <string>${startScript}</string>
45
+ <string>ensure</string>
42
46
  </array>
43
47
  <key>EnvironmentVariables</key>
44
48
  <dict>
45
49
  <key>ARKAOS_ROOT</key>
46
50
  <string>${repoRoot}</string>
51
+ <key>ARKAOS_NO_BROWSER</key>
52
+ <string>1</string>
47
53
  </dict>
48
54
  <key>RunAtLoad</key>
49
55
  <true/>
56
+ <key>AbandonProcessGroup</key>
57
+ <true/>
50
58
  <key>StandardOutPath</key>
51
59
  <string>${log}</string>
52
60
  <key>StandardErrorPath</key>
@@ -63,14 +71,19 @@ export function unitFor(os, { repoRoot, home }) {
63
71
 
64
72
  if (os === "linux") {
65
73
  const startScript = `${repoRoot}/scripts/start-dashboard.sh`;
74
+ // oneshot + RemainAfterExit: the launcher backgrounds the API/UI and
75
+ // exits; Type=simple would mark the unit dead on exit and systemd would
76
+ // reap the children with the cgroup. `ensure` keeps restarts idempotent.
66
77
  const content = `[Unit]
67
78
  Description=ArkaOS Dashboard (Python API + UI)
68
79
  After=network.target
69
80
 
70
81
  [Service]
71
- Type=simple
82
+ Type=oneshot
83
+ RemainAfterExit=yes
72
84
  Environment=ARKAOS_ROOT=${repoRoot}
73
- ExecStart=/bin/bash ${startScript}
85
+ Environment=ARKAOS_NO_BROWSER=1
86
+ ExecStart=/bin/bash ${startScript} ensure
74
87
  Restart=on-failure
75
88
 
76
89
  [Install]
@@ -87,6 +100,7 @@ WantedBy=default.target
87
100
  const ps1 = join(repoRoot, "scripts", "start-dashboard.ps1");
88
101
  const content = `@echo off
89
102
  rem ArkaOS dashboard autostart (login).
103
+ set ARKAOS_NO_BROWSER=1
90
104
  powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${ps1}"
91
105
  `;
92
106
  return {
package/installer/cli.js CHANGED
@@ -25,6 +25,9 @@ const { values, positionals } = parseArgs({
25
25
  // `values.fix` rather than as a free positional under strict:false.
26
26
  // Eliminates the dead-branch fallback flagged by Marta in PR2's QG.
27
27
  fix: { type: "boolean" },
28
+ // Model Fabric PR-A — `npx arkaos models [--json] [set ... --effort max]`
29
+ json: { type: "boolean" },
30
+ effort: { type: "string" },
28
31
  },
29
32
  allowPositionals: true,
30
33
  strict: false,
@@ -51,6 +54,8 @@ Usage:
51
54
  npx arkaos dashboard Start monitoring dashboard
52
55
  npx arkaos autostart <enable|disable|status> Start dashboard on boot
53
56
  npx arkaos keys Manage API keys (OpenAI, fal.ai, etc.)
57
+ npx arkaos models Model Fabric: which model runs each role
58
+ npx arkaos models set <role> <provider>/<model> Re-route a role
54
59
  npx arkaos doctor Run health checks
55
60
  npx arkaos uninstall Remove ArkaOS
56
61
 
@@ -154,6 +159,24 @@ async function main() {
154
159
  break;
155
160
  }
156
161
 
162
+ case "models": {
163
+ const { execSync } = await import("node:child_process");
164
+ const repoRootModels = join(__dirname, "..");
165
+ const pyModels = getArkaosPython();
166
+ if (!pyModels) { console.error("No Python found. Run: npx arkaos install"); process.exit(1); }
167
+ const modelArgs = positionals.slice(1).map((a) => `"${a}"`).join(" ");
168
+ const effortFlag = values.effort ? ` --effort "${values.effort}"` : "";
169
+ const jsonFlag = values.json ? " --json" : "";
170
+ try {
171
+ execSync(`"${pyModels}" -m core.runtime.model_router_cli ${modelArgs}${effortFlag}${jsonFlag}`, {
172
+ stdio: "inherit",
173
+ cwd: repoRootModels,
174
+ env: { ...process.env, ARKAOS_ROOT: repoRootModels, PYTHONPATH: repoRootModels },
175
+ });
176
+ } catch { process.exit(1); }
177
+ break;
178
+ }
179
+
157
180
  case "index": {
158
181
  const { execSync } = await import("node:child_process");
159
182
  const indexArgs = positionals.slice(1).join(" ");
package/installer/keys.js CHANGED
@@ -6,6 +6,7 @@ const KEYS_PATH = join(homedir(), ".arkaos", "keys.json");
6
6
 
7
7
  const PROVIDERS = {
8
8
  OPENAI_API_KEY: { name: "OpenAI", used_for: "Whisper transcription, embeddings, GPT" },
9
+ OPENROUTER_API_KEY: { name: "OpenRouter", used_for: "Model Fabric: one key, hundreds of models (Kimi, DeepSeek, GPT, Gemini) for roles + fusion" },
9
10
  GOOGLE_API_KEY: { name: "Google", used_for: "Gemini API, Nano Banana, Google Cloud AI" },
10
11
  FAL_API_KEY: { name: "fal.ai", used_for: "Image generation, video generation" },
11
12
  MAGIC_API_KEY: { name: "21st.dev Magic", used_for: "Frontend UI/UX component generation (Magic MCP)" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "4.1.1",
3
+ "version": "4.3.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 = "4.1.1"
3
+ version = "4.3.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"}
@@ -864,6 +864,85 @@ def commands(dept: Optional[str] = Query(None), q: Optional[str] = Query(None)):
864
864
  return {"commands": data, "total": len(data)}
865
865
 
866
866
 
867
+ @app.get("/api/models")
868
+ def models_overview():
869
+ """Model Fabric: roles, providers, Ollama discovery, key presence."""
870
+ from core.runtime.model_router import USER_CONFIG_PATH, load_config, resolve_all
871
+ from core.runtime.ollama_discovery import discover
872
+
873
+ config, source = load_config()
874
+ keys_path = Path.home() / ".arkaos" / "keys.json"
875
+ try:
876
+ keys = json.loads(keys_path.read_text(encoding="utf-8"))
877
+ except (OSError, json.JSONDecodeError):
878
+ keys = {}
879
+ return {
880
+ "source": source,
881
+ "config_path": str(USER_CONFIG_PATH),
882
+ "roles": [item.model_dump() for item in resolve_all()],
883
+ "providers": {
884
+ name: {"type": spec.get("type", "")}
885
+ for name, spec in config.providers.items()
886
+ },
887
+ "aliases": config.aliases,
888
+ "fusion": config.fusion.model_dump(),
889
+ "ollama": discover().to_dict(),
890
+ "keys": {
891
+ "openrouter": bool(
892
+ os.environ.get("OPENROUTER_API_KEY")
893
+ or keys.get("OPENROUTER_API_KEY")
894
+ ),
895
+ "anthropic": bool(os.environ.get("ANTHROPIC_API_KEY")),
896
+ },
897
+ }
898
+
899
+
900
+ @app.post("/api/models/role")
901
+ async def models_set_role(request: Request):
902
+ """Re-route a role: {role, target: 'provider/model', effort?}."""
903
+ from core.runtime.model_router import set_role
904
+
905
+ body = await request.json()
906
+ role = str(body.get("role", "")).strip()
907
+ target = str(body.get("target", "")).strip()
908
+ effort = body.get("effort") or None
909
+ if not role or not target:
910
+ return JSONResponse(
911
+ {"error": "role and target (provider/model) are required"},
912
+ status_code=422,
913
+ )
914
+ try:
915
+ item = set_role(role, target, effort=effort)
916
+ except ValueError as exc:
917
+ return JSONResponse({"error": str(exc)}, status_code=422)
918
+ return item.model_dump()
919
+
920
+
921
+ @app.get("/api/models/usage")
922
+ def models_usage(period: str = "today"):
923
+ """Per-model/provider/category token + cost aggregation."""
924
+ from core.runtime.llm_cost_telemetry import VALID_PERIODS, summarise
925
+
926
+ if period not in VALID_PERIODS:
927
+ return JSONResponse(
928
+ {"error": f"period must be one of {list(VALID_PERIODS)}"},
929
+ status_code=422,
930
+ )
931
+ summary = summarise(period=period)
932
+ return {
933
+ "period": summary.period,
934
+ "call_count": summary.call_count,
935
+ "total_cost_usd": summary.total_cost_usd,
936
+ "total_tokens_in": summary.total_tokens_in,
937
+ "total_tokens_out": summary.total_tokens_out,
938
+ "total_cached_tokens": summary.total_cached_tokens,
939
+ "cache_hit_rate": summary.cache_hit_rate,
940
+ "by_model": summary.by_model,
941
+ "by_provider": summary.by_provider,
942
+ "by_category": summary.by_category,
943
+ }
944
+
945
+
867
946
  @app.get("/api/budget")
868
947
  def budget_all():
869
948
  mgr = _get_budget_manager()
@@ -10,6 +10,30 @@ VENV_PYTHON="$HOME/.arkaos/venv/bin/python"
10
10
 
11
11
  mkdir -p "$HOME/.arkaos"
12
12
 
13
+ # ── Args ──
14
+ # `ensure` — idempotent mode: exit 0 if API+UI already healthy,
15
+ # otherwise fall through to a full start. Safe to call from
16
+ # SessionStart hooks and launchd/systemd boot units.
17
+ # `--no-browser` — do not open the browser after start (also via
18
+ # ARKAOS_NO_BROWSER=1 env, for boot/hook contexts).
19
+ MODE="start"
20
+ for arg in "$@"; do
21
+ case "$arg" in
22
+ ensure) MODE="ensure" ;;
23
+ --no-browser) ARKAOS_NO_BROWSER=1 ;;
24
+ esac
25
+ done
26
+
27
+ if [ "$MODE" = "ensure" ] && [ -f "$PORT_FILE" ]; then
28
+ # shellcheck disable=SC1090
29
+ source "$PORT_FILE"
30
+ if curl -sf --max-time 2 "http://localhost:${API_PORT:-0}/api/overview" >/dev/null 2>&1 \
31
+ && curl -sf --max-time 2 "http://localhost:${UI_PORT:-0}/" >/dev/null 2>&1; then
32
+ echo " ✓ Dashboard already running (API :${API_PORT}, UI :${UI_PORT})"
33
+ exit 0
34
+ fi
35
+ fi
36
+
13
37
  # ── Venv guard (PR2 v3.73.1 — Force Specialist Dispatch dogfood) ──
14
38
  # Previously the dashboard fell back to ambient `python3` when the venv
15
39
  # wasn't available. That hid broken-venv conditions (Homebrew patch
@@ -133,7 +157,7 @@ echo " or kill \$(cat $PID_FILE)"
133
157
  echo ""
134
158
 
135
159
  # Wait for UI to be ready
136
- if [ -n "$UI_PID" ]; then
160
+ if [ -n "$UI_PID" ] && [ -z "${ARKAOS_NO_BROWSER:-}" ]; then
137
161
  sleep 5
138
162
  # Open browser
139
163
  if command -v open >/dev/null 2>&1; then