arkaos 3.71.1 → 3.72.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.71.1
1
+ 3.72.0
@@ -80,6 +80,13 @@ const links = [[{
80
80
  onSelect: () => {
81
81
  open.value = false
82
82
  }
83
+ }, {
84
+ label: 'Dreaming',
85
+ icon: 'i-lucide-sparkles',
86
+ to: '/cognition',
87
+ onSelect: () => {
88
+ open.value = false
89
+ }
83
90
  }], [{
84
91
  label: 'Health',
85
92
  icon: 'i-lucide-heart-pulse',
@@ -0,0 +1,311 @@
1
+ <script setup lang="ts">
2
+ // v3.72.0 — Cognition page: monitor what Dreaming has been learning.
3
+ // Read-only view over the insights the Cognitive Layer already writes to
4
+ // <vault>/Projects/ArkaOS/Dreams. Backend: /api/cognition/{insights,status}.
5
+
6
+ import { marked } from 'marked'
7
+
8
+ definePageMeta({ layout: 'default' })
9
+
10
+ interface Insight {
11
+ date: string
12
+ title: string
13
+ confidence: string
14
+ sources: string[]
15
+ tags: string[]
16
+ body: string
17
+ }
18
+ interface Status {
19
+ today: number
20
+ week: number
21
+ total: number
22
+ by_confidence: { high: number, medium: number, low: number }
23
+ vault_configured: boolean
24
+ last_date: string | null
25
+ }
26
+
27
+ const { apiBase } = useApi()
28
+
29
+ const days = ref(7)
30
+ const confidenceFilter = ref<'all' | 'high' | 'medium' | 'low'>('all')
31
+ const tagFilter = ref('')
32
+ const insights = ref<Insight[]>([])
33
+ const status = ref<Status | null>(null)
34
+ const available = ref(true)
35
+ const loading = ref(true)
36
+ const expanded = ref<Set<string>>(new Set())
37
+
38
+ const windowOptions = [
39
+ { label: 'Today', value: 1 },
40
+ { label: 'Last 7 days', value: 7 },
41
+ { label: 'Last 30 days', value: 30 }
42
+ ]
43
+ const confidenceOptions = [
44
+ { label: 'All confidence', value: 'all' },
45
+ { label: 'High', value: 'high' },
46
+ { label: 'Medium', value: 'medium' },
47
+ { label: 'Low', value: 'low' }
48
+ ]
49
+
50
+ async function refresh() {
51
+ loading.value = true
52
+ try {
53
+ const [s, i] = await Promise.all([
54
+ $fetch<Status>(`${apiBase}/api/cognition/status`),
55
+ $fetch<{ insights: Insight[], available: boolean }>(
56
+ `${apiBase}/api/cognition/insights?days=${days.value}`
57
+ )
58
+ ])
59
+ status.value = s
60
+ insights.value = i.insights
61
+ available.value = i.available
62
+ } catch {
63
+ available.value = false
64
+ } finally {
65
+ loading.value = false
66
+ }
67
+ }
68
+
69
+ onMounted(refresh)
70
+ // Reset the tag filter when the window changes — a tag may not exist in the
71
+ // new window, which would otherwise strand the user on an empty result.
72
+ watch(days, () => {
73
+ tagFilter.value = ''
74
+ refresh()
75
+ })
76
+
77
+ const allTags = computed(() => {
78
+ const set = new Set<string>()
79
+ for (const i of insights.value) for (const t of i.tags) set.add(t)
80
+ return [...set].sort()
81
+ })
82
+
83
+ const filtered = computed(() => insights.value.filter((i) => {
84
+ if (confidenceFilter.value !== 'all' && i.confidence !== confidenceFilter.value) return false
85
+ if (tagFilter.value && !i.tags.includes(tagFilter.value)) return false
86
+ return true
87
+ }))
88
+
89
+ function confidenceColor(c: string): 'success' | 'warning' | 'neutral' {
90
+ if (c === 'high') return 'success'
91
+ if (c === 'low') return 'neutral'
92
+ return 'warning'
93
+ }
94
+
95
+ function toggle(key: string) {
96
+ const next = new Set(expanded.value)
97
+ if (next.has(key)) next.delete(key)
98
+ else next.add(key)
99
+ expanded.value = next
100
+ }
101
+
102
+ function renderBody(body: string): string {
103
+ if (!body) return ''
104
+ try {
105
+ return marked.parse(body, { async: false }) as string
106
+ } catch {
107
+ return body
108
+ }
109
+ }
110
+
111
+ const isActive = computed(() => {
112
+ if (!status.value?.last_date) return false
113
+ const last = new Date(status.value.last_date + 'T00:00:00Z').getTime()
114
+ return Date.now() - last < 3 * 86_400_000
115
+ })
116
+ </script>
117
+
118
+ <template>
119
+ <UDashboardPanel id="cognition">
120
+ <template #header>
121
+ <UDashboardNavbar title="Dreaming">
122
+ <template #leading>
123
+ <UDashboardSidebarCollapse />
124
+ </template>
125
+ <template #right>
126
+ <UBadge
127
+ :color="isActive ? 'success' : 'neutral'"
128
+ variant="soft"
129
+ size="sm"
130
+ >
131
+ <UIcon
132
+ :name="isActive ? 'i-lucide-activity' : 'i-lucide-moon'"
133
+ class="size-3 mr-1"
134
+ />
135
+ {{ isActive ? 'Active' : 'Idle' }}
136
+ <span v-if="status?.last_date" class="ml-1 opacity-70">· {{ status.last_date }}</span>
137
+ </UBadge>
138
+ </template>
139
+ </UDashboardNavbar>
140
+ </template>
141
+
142
+ <template #body>
143
+ <div class="flex flex-col gap-4 p-4">
144
+ <p class="text-sm text-muted -mt-1">
145
+ What the Cognitive Layer has been learning — insights surfaced by
146
+ Dreaming from your vault and sessions.
147
+ </p>
148
+
149
+ <!-- Stats -->
150
+ <div v-if="status?.vault_configured" class="grid grid-cols-2 sm:grid-cols-4 gap-3">
151
+ <div class="rounded-lg border border-default bg-elevated/10 p-3">
152
+ <div class="text-2xl font-semibold tabular-nums">
153
+ {{ status.today }}
154
+ </div>
155
+ <div class="text-xs text-muted">
156
+ today
157
+ </div>
158
+ </div>
159
+ <div class="rounded-lg border border-default bg-elevated/10 p-3">
160
+ <div class="text-2xl font-semibold tabular-nums">
161
+ {{ status.week }}
162
+ </div>
163
+ <div class="text-xs text-muted">
164
+ last 7 days
165
+ </div>
166
+ </div>
167
+ <div class="rounded-lg border border-default bg-elevated/10 p-3">
168
+ <div class="text-2xl font-semibold tabular-nums">
169
+ {{ status.total }}
170
+ </div>
171
+ <div class="text-xs text-muted">
172
+ total insights
173
+ </div>
174
+ </div>
175
+ <div class="rounded-lg border border-default bg-elevated/10 p-3 flex flex-col justify-center gap-1">
176
+ <div class="flex items-center gap-2 text-xs">
177
+ <UBadge color="success" variant="soft" size="xs">
178
+ high {{ status.by_confidence.high }}
179
+ </UBadge>
180
+ <UBadge color="warning" variant="soft" size="xs">
181
+ med {{ status.by_confidence.medium }}
182
+ </UBadge>
183
+ <UBadge color="neutral" variant="soft" size="xs">
184
+ low {{ status.by_confidence.low }}
185
+ </UBadge>
186
+ </div>
187
+ </div>
188
+ </div>
189
+
190
+ <!-- Filters -->
191
+ <div class="flex flex-wrap items-center gap-2">
192
+ <USelect
193
+ v-model="days"
194
+ :items="windowOptions"
195
+ size="sm"
196
+ class="w-40"
197
+ />
198
+ <USelect
199
+ v-model="confidenceFilter"
200
+ :items="confidenceOptions"
201
+ size="sm"
202
+ class="w-44"
203
+ />
204
+ <USelect
205
+ v-if="allTags.length"
206
+ v-model="tagFilter"
207
+ :items="[{ label: 'All tags', value: '' }, ...allTags.map(t => ({ label: '#' + t, value: t }))]"
208
+ size="sm"
209
+ class="w-40"
210
+ />
211
+ <UButton
212
+ size="sm"
213
+ variant="ghost"
214
+ icon="i-lucide-refresh-cw"
215
+ :loading="loading"
216
+ @click="refresh"
217
+ >
218
+ Refresh
219
+ </UButton>
220
+ </div>
221
+
222
+ <!-- Feed -->
223
+ <div v-if="loading" class="text-sm text-muted py-8 text-center">
224
+ <UIcon name="i-lucide-loader" class="animate-spin size-5 mx-auto mb-2" />
225
+ Loading insights…
226
+ </div>
227
+ <div
228
+ v-else-if="!available || !status?.vault_configured"
229
+ class="rounded-lg border border-dashed border-default p-10 text-center text-muted"
230
+ >
231
+ <UIcon name="i-lucide-moon-star" class="size-8 mx-auto mb-3 opacity-40" />
232
+ <p class="text-sm">
233
+ No vault connected, or Dreaming hasn't run yet.
234
+ </p>
235
+ <p class="text-xs mt-1 opacity-70">
236
+ Configure a vault and let the Cognitive Layer dream — insights show up here.
237
+ </p>
238
+ </div>
239
+ <div
240
+ v-else-if="filtered.length === 0"
241
+ class="rounded-lg border border-dashed border-default p-10 text-center text-muted"
242
+ >
243
+ <UIcon name="i-lucide-search-x" class="size-7 mx-auto mb-3 opacity-40" />
244
+ <p class="text-sm">
245
+ No insights match these filters.
246
+ </p>
247
+ </div>
248
+ <ul v-else class="flex flex-col gap-3">
249
+ <li
250
+ v-for="(ins, idx) in filtered"
251
+ :key="`${ins.date}-${idx}`"
252
+ class="rounded-lg border border-default bg-elevated/10 overflow-hidden"
253
+ >
254
+ <button
255
+ class="w-full flex items-start gap-3 p-3 text-left hover:bg-elevated/20 transition-colors"
256
+ @click="toggle(`${ins.date}-${idx}`)"
257
+ >
258
+ <UBadge
259
+ :color="confidenceColor(ins.confidence)"
260
+ variant="soft"
261
+ size="xs"
262
+ class="mt-0.5 shrink-0 uppercase"
263
+ >
264
+ {{ ins.confidence }}
265
+ </UBadge>
266
+ <div class="flex-1 min-w-0">
267
+ <div class="font-medium truncate">
268
+ {{ ins.title }}
269
+ </div>
270
+ <div class="flex items-center gap-2 mt-1 flex-wrap">
271
+ <span class="text-xs text-muted tabular-nums">{{ ins.date }}</span>
272
+ <UBadge
273
+ v-for="t in ins.tags"
274
+ :key="t"
275
+ variant="subtle"
276
+ size="xs"
277
+ >
278
+ #{{ t }}
279
+ </UBadge>
280
+ </div>
281
+ </div>
282
+ <UIcon
283
+ :name="expanded.has(`${ins.date}-${idx}`) ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
284
+ class="size-4 shrink-0 text-muted mt-0.5"
285
+ />
286
+ </button>
287
+ <div v-if="expanded.has(`${ins.date}-${idx}`)" class="px-3 pb-3 border-t border-default/60 pt-3">
288
+ <!-- Trusted content: the operator's own Dreaming output from
289
+ their local vault, served over the localhost-only API —
290
+ same trust boundary as the persona bio renderer. -->
291
+ <!-- eslint-disable-next-line vue/no-v-html -->
292
+ <div class="prose prose-sm dark:prose-invert max-w-none text-sm" v-html="renderBody(ins.body)" />
293
+ <div v-if="ins.sources.length" class="flex items-center gap-1.5 flex-wrap mt-3 pt-2 border-t border-default/40">
294
+ <span class="text-[11px] text-muted">sources:</span>
295
+ <UBadge
296
+ v-for="s in ins.sources"
297
+ :key="s"
298
+ variant="subtle"
299
+ size="xs"
300
+ color="info"
301
+ >
302
+ {{ s }}
303
+ </UBadge>
304
+ </div>
305
+ </div>
306
+ </li>
307
+ </ul>
308
+ </div>
309
+ </template>
310
+ </UDashboardPanel>
311
+ </template>
@@ -1,4 +1,7 @@
1
1
  <script setup lang="ts">
2
+ // PR92d v3.42.0 — primary color picker.
3
+ import { THEME_COLOR_OPTIONS, useThemeColor } from '~/composables/useThemeColor'
4
+
2
5
  interface ProfileResponse {
3
6
  version: string
4
7
  name: string
@@ -22,7 +25,7 @@ const {
22
25
  data: profile,
23
26
  status: profileStatus,
24
27
  error: profileError,
25
- refresh: refreshProfile,
28
+ refresh: refreshProfile
26
29
  } = await fetchApi<ProfileResponse>('/api/profile')
27
30
 
28
31
  const profileDraft = ref({
@@ -32,7 +35,7 @@ const profileDraft = ref({
32
35
  market: profile.value?.market ?? '',
33
36
  language: profile.value?.language ?? 'en',
34
37
  vaultPath: profile.value?.vaultPath ?? '',
35
- projectsDir: profile.value?.projectsDir ?? '',
38
+ projectsDir: profile.value?.projectsDir ?? ''
36
39
  })
37
40
 
38
41
  watch(profile, (p) => {
@@ -44,7 +47,7 @@ watch(profile, (p) => {
44
47
  market: p.market,
45
48
  language: p.language,
46
49
  vaultPath: p.vaultPath,
47
- projectsDir: p.projectsDir,
50
+ projectsDir: p.projectsDir
48
51
  }
49
52
  }, { immediate: true })
50
53
 
@@ -68,7 +71,7 @@ async function testVault() {
68
71
  if (profileDraft.value.vaultPath !== profile.value?.vaultPath) {
69
72
  await $fetch(`${apiBase}/api/profile`, {
70
73
  method: 'POST',
71
- body: { vaultPath: profileDraft.value.vaultPath },
74
+ body: { vaultPath: profileDraft.value.vaultPath }
72
75
  })
73
76
  }
74
77
  vaultStatus.value = await $fetch<VaultStatus>(`${apiBase}/api/settings/vault`)
@@ -76,13 +79,13 @@ async function testVault() {
76
79
  title: vaultStatus.value.exists ? 'Vault reachable' : 'Vault not found',
77
80
  description: vaultStatus.value.vault_path || 'Set a path first',
78
81
  color: vaultStatus.value.exists ? 'success' : 'warning',
79
- icon: vaultStatus.value.exists ? 'i-lucide-check-circle' : 'i-lucide-alert-circle',
82
+ icon: vaultStatus.value.exists ? 'i-lucide-check-circle' : 'i-lucide-alert-circle'
80
83
  })
81
84
  } catch (err) {
82
85
  toast.add({
83
86
  title: 'Test failed',
84
87
  description: err instanceof Error ? err.message : 'unknown error',
85
- color: 'error',
88
+ color: 'error'
86
89
  })
87
90
  } finally {
88
91
  testingVault.value = false
@@ -94,19 +97,19 @@ async function saveProfile() {
94
97
  try {
95
98
  await $fetch<ProfileResponse>(`${apiBase}/api/profile`, {
96
99
  method: 'POST',
97
- body: profileDraft.value,
100
+ body: profileDraft.value
98
101
  })
99
102
  await refreshProfile()
100
103
  toast.add({
101
104
  title: 'Profile saved',
102
105
  description: 'Settings written to ~/.arkaos/profile.json',
103
- color: 'success',
106
+ color: 'success'
104
107
  })
105
108
  } catch (err) {
106
109
  toast.add({
107
110
  title: 'Save failed',
108
111
  description: err instanceof Error ? err.message : 'unknown error',
109
- color: 'error',
112
+ color: 'error'
110
113
  })
111
114
  } finally {
112
115
  savingProfile.value = false
@@ -115,7 +118,7 @@ async function saveProfile() {
115
118
 
116
119
  const languageOptions = [
117
120
  { label: 'English', value: 'en' },
118
- { label: 'Português', value: 'pt' },
121
+ { label: 'Português', value: 'pt' }
119
122
  ]
120
123
 
121
124
  const roleOptions = [
@@ -125,16 +128,23 @@ const roleOptions = [
125
128
  { label: 'Engineer', value: 'engineer' },
126
129
  { label: 'Designer', value: 'designer' },
127
130
  { label: 'Operator', value: 'operator' },
128
- { label: 'Consultant', value: 'consultant' },
131
+ { label: 'Consultant', value: 'consultant' }
129
132
  ]
130
133
 
131
134
  // ─── API Keys (preserved from earlier) ──────────────────────────────────
132
135
 
136
+ interface KeyRow {
137
+ key: string
138
+ provider: string
139
+ configured: boolean
140
+ used_for?: string
141
+ }
142
+
133
143
  const {
134
144
  data: keysData,
135
145
  status: keysStatus,
136
- refresh: refreshKeys,
137
- } = fetchApi<any>('/api/keys')
146
+ refresh: refreshKeys
147
+ } = fetchApi<{ keys: KeyRow[] }>('/api/keys')
138
148
 
139
149
  const keys = computed(() => keysData.value?.keys ?? [])
140
150
 
@@ -154,13 +164,13 @@ async function saveKey() {
154
164
  try {
155
165
  await $fetch(`${apiBase}/api/keys`, {
156
166
  method: 'POST',
157
- body: { key: keyName, value: newValue.value },
167
+ body: { key: keyName, value: newValue.value }
158
168
  })
159
169
  newKey.value = ''
160
170
  newValue.value = ''
161
171
  customKeyName.value = ''
162
172
  await refreshKeys()
163
- } catch {}
173
+ } catch { /* best-effort; surfaced via list refresh */ }
164
174
  saving.value = false
165
175
  }
166
176
 
@@ -169,7 +179,7 @@ async function deleteKey(keyName: string) {
169
179
  try {
170
180
  await $fetch(`${apiBase}/api/keys/${keyName}`, { method: 'DELETE' })
171
181
  await refreshKeys()
172
- } catch {}
182
+ } catch { /* best-effort; surfaced via list refresh */ }
173
183
  deletingKey.value = null
174
184
  }
175
185
 
@@ -177,7 +187,7 @@ const keyOptions = [
177
187
  { label: 'OPENAI_API_KEY', value: 'OPENAI_API_KEY' },
178
188
  { label: 'FAL_API_KEY', value: 'FAL_API_KEY' },
179
189
  { label: 'GOOGLE_API_KEY', value: 'GOOGLE_API_KEY' },
180
- { label: 'Custom...', value: 'custom' },
190
+ { label: 'Custom...', value: 'custom' }
181
191
  ]
182
192
 
183
193
  // ─── PR63b v2.89.0 — MCPs / Hooks / Plugins / Theme sections ────────────
@@ -231,11 +241,8 @@ const colorMode = useColorMode()
231
241
  const themeOptions = [
232
242
  { label: 'System (auto)', value: 'system' },
233
243
  { label: 'Light', value: 'light' },
234
- { label: 'Dark', value: 'dark' },
244
+ { label: 'Dark', value: 'dark' }
235
245
  ]
236
-
237
- // PR92d v3.42.0 — primary color picker.
238
- import { THEME_COLOR_OPTIONS, useThemeColor } from '~/composables/useThemeColor'
239
246
  const themeColor = useThemeColor()
240
247
  const themeColorOptions = THEME_COLOR_OPTIONS
241
248
 
@@ -256,19 +263,52 @@ function formatInstalledAt(iso: string): string {
256
263
 
257
264
  // ─── Section nav ────────────────────────────────────────────────────────
258
265
 
259
- type SectionId = 'profile' | 'projects' | 'keys' | 'mcps' | 'hooks' | 'plugins' | 'theme'
260
-
261
- const sections: { id: SectionId; label: string; icon: string }[] = [
262
- { id: 'profile', label: 'Profile', icon: 'i-lucide-user-circle' },
263
- { id: 'projects', label: 'Projects', icon: 'i-lucide-folders' },
264
- { id: 'keys', label: 'API Keys', icon: 'i-lucide-key' },
265
- { id: 'mcps', label: 'MCPs', icon: 'i-lucide-plug-2' },
266
- { id: 'hooks', label: 'Hooks', icon: 'i-lucide-webhook' },
267
- { id: 'plugins', label: 'Plugins', icon: 'i-lucide-puzzle' },
268
- { id: 'theme', label: 'Theme', icon: 'i-lucide-palette' },
266
+ type SectionId = 'profile' | 'projects' | 'keys' | 'mcps' | 'hooks' | 'plugins' | 'theme' | 'updates'
267
+
268
+ const sections: { id: SectionId, label: string, icon: string }[] = [
269
+ { id: 'profile', label: 'Profile', icon: 'i-lucide-user-circle' },
270
+ { id: 'projects', label: 'Projects', icon: 'i-lucide-folders' },
271
+ { id: 'keys', label: 'API Keys', icon: 'i-lucide-key' },
272
+ { id: 'mcps', label: 'MCPs', icon: 'i-lucide-plug-2' },
273
+ { id: 'hooks', label: 'Hooks', icon: 'i-lucide-webhook' },
274
+ { id: 'plugins', label: 'Plugins', icon: 'i-lucide-puzzle' },
275
+ { id: 'theme', label: 'Theme', icon: 'i-lucide-palette' },
276
+ { id: 'updates', label: 'Updates', icon: 'i-lucide-download' }
269
277
  ]
270
278
 
271
279
  const activeSection = ref<SectionId>('profile')
280
+
281
+ // ─── Updates (v3.72.0) — version check + one-click core update ──────────
282
+ const ver = ref<{ current: string, latest: string | null, update_available: boolean } | null>(null)
283
+ const checkingVer = ref(false)
284
+ const updating = ref(false)
285
+ const updateResult = ref<{ ok: boolean, output: string } | null>(null)
286
+
287
+ async function checkVersion() {
288
+ checkingVer.value = true
289
+ try {
290
+ ver.value = await $fetch(`${apiBase}/api/system/version`)
291
+ } catch {
292
+ ver.value = null
293
+ } finally {
294
+ checkingVer.value = false
295
+ }
296
+ }
297
+
298
+ async function runUpdate() {
299
+ updating.value = true
300
+ updateResult.value = null
301
+ try {
302
+ updateResult.value = await $fetch(`${apiBase}/api/system/update`, { method: 'POST' })
303
+ } catch (e) {
304
+ updateResult.value = { ok: false, output: e instanceof Error ? e.message : String(e) }
305
+ } finally {
306
+ updating.value = false
307
+ checkVersion()
308
+ }
309
+ }
310
+
311
+ onMounted(checkVersion)
272
312
  </script>
273
313
 
274
314
  <template>
@@ -299,8 +339,8 @@ const activeSection = ref<SectionId>('profile')
299
339
  <span>{{ s.label }}</span>
300
340
  </button>
301
341
  <p class="text-xs text-muted px-3 mt-6">
302
- 7 sections. Profile + Projects edit data; everything else is
303
- read-only diagnostics until an explicit edit endpoint lands.
342
+ 8 sections. Profile + Projects edit data; Updates runs the core
343
+ update; everything else is read-only diagnostics.
304
344
  </p>
305
345
  </nav>
306
346
 
@@ -308,7 +348,9 @@ const activeSection = ref<SectionId>('profile')
308
348
  <div>
309
349
  <!-- Profile -->
310
350
  <section v-if="activeSection === 'profile'">
311
- <h2 class="text-lg font-semibold mb-1">Profile</h2>
351
+ <h2 class="text-lg font-semibold mb-1">
352
+ Profile
353
+ </h2>
312
354
  <p class="text-sm text-muted mb-6">
313
355
  Your identity, role, and language. Stored locally at
314
356
  <code class="font-mono text-xs">~/.arkaos/profile.json</code>.
@@ -448,7 +490,9 @@ const activeSection = ref<SectionId>('profile')
448
490
 
449
491
  <!-- Projects -->
450
492
  <section v-else-if="activeSection === 'projects'">
451
- <h2 class="text-lg font-semibold mb-1">Project directories</h2>
493
+ <h2 class="text-lg font-semibold mb-1">
494
+ Project directories
495
+ </h2>
452
496
  <p class="text-sm text-muted mb-6">
453
497
  Directories the sync engine scans for projects.
454
498
  Comma-separated absolute paths (e.g.
@@ -500,7 +544,9 @@ const activeSection = ref<SectionId>('profile')
500
544
 
501
545
  <!-- API Keys -->
502
546
  <section v-else-if="activeSection === 'keys'">
503
- <h2 class="text-lg font-semibold mb-1">API Keys</h2>
547
+ <h2 class="text-lg font-semibold mb-1">
548
+ API Keys
549
+ </h2>
504
550
  <p class="text-sm text-muted mb-6">
505
551
  Configure API keys for external services. Keys are stored
506
552
  locally at <code class="font-mono text-xs">~/.arkaos/keys.json</code>.
@@ -508,11 +554,18 @@ const activeSection = ref<SectionId>('profile')
508
554
 
509
555
  <UCard class="mb-6">
510
556
  <div class="space-y-4">
511
- <p class="text-xs font-semibold text-muted uppercase tracking-wider">Add API Key</p>
557
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider">
558
+ Add API Key
559
+ </p>
512
560
  <div class="grid grid-cols-1 md:grid-cols-3 gap-3 items-end">
513
561
  <div>
514
562
  <label class="text-xs text-muted mb-1 block">Provider</label>
515
- <USelect v-model="newKey" :items="keyOptions" class="w-full" placeholder="Select key..." />
563
+ <USelect
564
+ v-model="newKey"
565
+ :items="keyOptions"
566
+ class="w-full"
567
+ placeholder="Select key..."
568
+ />
516
569
  </div>
517
570
  <div v-if="isCustom">
518
571
  <label class="text-xs text-muted mb-1 block">Key Name</label>
@@ -520,7 +573,12 @@ const activeSection = ref<SectionId>('profile')
520
573
  </div>
521
574
  <div :class="isCustom ? '' : 'md:col-span-1'">
522
575
  <label class="text-xs text-muted mb-1 block">Value</label>
523
- <UInput v-model="newValue" type="password" class="w-full" placeholder="sk-..." />
576
+ <UInput
577
+ v-model="newValue"
578
+ type="password"
579
+ class="w-full"
580
+ placeholder="sk-..."
581
+ />
524
582
  </div>
525
583
  <div>
526
584
  <UButton
@@ -560,9 +618,17 @@ const activeSection = ref<SectionId>('profile')
560
618
  variant="subtle"
561
619
  size="xs"
562
620
  />
563
- <UBadge v-else label="Not Set" color="neutral" variant="outline" size="xs" />
621
+ <UBadge
622
+ v-else
623
+ label="Not Set"
624
+ color="neutral"
625
+ variant="outline"
626
+ size="xs"
627
+ />
564
628
  </div>
565
- <p v-if="k.used_for" class="text-xs text-muted mt-0.5">{{ k.used_for }}</p>
629
+ <p v-if="k.used_for" class="text-xs text-muted mt-0.5">
630
+ {{ k.used_for }}
631
+ </p>
566
632
  <p v-if="k.masked_value && k.configured" class="text-xs font-mono text-muted/60 mt-0.5">
567
633
  {{ k.masked_value }}
568
634
  </p>
@@ -585,7 +651,9 @@ const activeSection = ref<SectionId>('profile')
585
651
  <!-- MCPs -->
586
652
  <section v-else-if="activeSection === 'mcps'">
587
653
  <div class="flex items-baseline justify-between mb-1">
588
- <h2 class="text-lg font-semibold">MCPs</h2>
654
+ <h2 class="text-lg font-semibold">
655
+ MCPs
656
+ </h2>
589
657
  <UButton
590
658
  label="Refresh"
591
659
  variant="ghost"
@@ -601,7 +669,9 @@ const activeSection = ref<SectionId>('profile')
601
669
  </p>
602
670
  <div v-if="!mcps.length" class="rounded-lg border border-default p-6 text-center">
603
671
  <UIcon name="i-lucide-plug-2" class="size-10 text-muted mx-auto mb-2" />
604
- <p class="text-sm text-muted">No MCP servers configured.</p>
672
+ <p class="text-sm text-muted">
673
+ No MCP servers configured.
674
+ </p>
605
675
  </div>
606
676
  <div v-else class="space-y-2">
607
677
  <div
@@ -632,7 +702,9 @@ const activeSection = ref<SectionId>('profile')
632
702
  <!-- Hooks -->
633
703
  <section v-else-if="activeSection === 'hooks'">
634
704
  <div class="flex items-baseline justify-between mb-1">
635
- <h2 class="text-lg font-semibold">Hooks</h2>
705
+ <h2 class="text-lg font-semibold">
706
+ Hooks
707
+ </h2>
636
708
  <UButton
637
709
  label="Refresh"
638
710
  variant="ghost"
@@ -657,7 +729,9 @@ const activeSection = ref<SectionId>('profile')
657
729
  </div>
658
730
  <div v-if="!hooks.length" class="rounded-lg border border-default p-6 text-center">
659
731
  <UIcon name="i-lucide-webhook" class="size-10 text-muted mx-auto mb-2" />
660
- <p class="text-sm text-muted">No hooks wired in settings.json.</p>
732
+ <p class="text-sm text-muted">
733
+ No hooks wired in settings.json.
734
+ </p>
661
735
  </div>
662
736
  <div v-else class="space-y-3">
663
737
  <div
@@ -691,7 +765,9 @@ const activeSection = ref<SectionId>('profile')
691
765
  <!-- Plugins -->
692
766
  <section v-else-if="activeSection === 'plugins'">
693
767
  <div class="flex items-baseline justify-between mb-1">
694
- <h2 class="text-lg font-semibold">Plugins</h2>
768
+ <h2 class="text-lg font-semibold">
769
+ Plugins
770
+ </h2>
695
771
  <UButton
696
772
  label="Refresh"
697
773
  variant="ghost"
@@ -707,7 +783,9 @@ const activeSection = ref<SectionId>('profile')
707
783
  </p>
708
784
  <div v-if="!plugins.length" class="rounded-lg border border-default p-6 text-center">
709
785
  <UIcon name="i-lucide-puzzle" class="size-10 text-muted mx-auto mb-2" />
710
- <p class="text-sm text-muted">No plugins installed.</p>
786
+ <p class="text-sm text-muted">
787
+ No plugins installed.
788
+ </p>
711
789
  <p class="text-xs text-muted mt-2">
712
790
  Try <code class="font-mono">/plugin marketplace add andreagroferreira/arka-os</code>
713
791
  from Claude Code.
@@ -724,8 +802,18 @@ const activeSection = ref<SectionId>('profile')
724
802
  <div class="flex items-center gap-2 mb-0.5">
725
803
  <span class="text-sm font-semibold">{{ p.name }}</span>
726
804
  <UBadge :label="p.marketplace" variant="outline" size="xs" />
727
- <UBadge v-if="p.scope" :label="p.scope" variant="soft" size="xs" />
728
- <UBadge v-if="p.version" :label="`v${p.version}`" variant="subtle" size="xs" />
805
+ <UBadge
806
+ v-if="p.scope"
807
+ :label="p.scope"
808
+ variant="soft"
809
+ size="xs"
810
+ />
811
+ <UBadge
812
+ v-if="p.version"
813
+ :label="`v${p.version}`"
814
+ variant="subtle"
815
+ size="xs"
816
+ />
729
817
  </div>
730
818
  <p v-if="p.installed_at" class="text-xs text-muted">
731
819
  Installed {{ formatInstalledAt(p.installed_at) }}
@@ -737,7 +825,9 @@ const activeSection = ref<SectionId>('profile')
737
825
 
738
826
  <!-- Theme -->
739
827
  <section v-else-if="activeSection === 'theme'">
740
- <h2 class="text-lg font-semibold mb-1">Theme</h2>
828
+ <h2 class="text-lg font-semibold mb-1">
829
+ Theme
830
+ </h2>
741
831
  <p class="text-sm text-muted mb-6">
742
832
  Light / dark / system (follows OS preference).
743
833
  Stored locally by your browser.
@@ -779,7 +869,7 @@ const activeSection = ref<SectionId>('profile')
779
869
  'bg-rose-500': opt.value === 'rose',
780
870
  'bg-amber-500': opt.value === 'amber',
781
871
  'bg-teal-500': opt.value === 'teal',
782
- 'bg-cyan-500': opt.value === 'cyan',
872
+ 'bg-cyan-500': opt.value === 'cyan'
783
873
  }"
784
874
  />
785
875
  {{ opt.label }}
@@ -789,6 +879,80 @@ const activeSection = ref<SectionId>('profile')
789
879
  </div>
790
880
  </UCard>
791
881
  </section>
882
+
883
+ <section v-else-if="activeSection === 'updates'">
884
+ <h2 class="text-lg font-semibold mb-1">
885
+ Updates
886
+ </h2>
887
+ <p class="text-sm text-muted mb-6">
888
+ Keep ArkaOS current. The button runs the core update
889
+ (<code class="text-xs">npx arkaos@latest update</code>); finish
890
+ the project sync by running <code class="text-xs">/arka update</code>
891
+ in Claude Code.
892
+ </p>
893
+ <UCard>
894
+ <div class="space-y-4">
895
+ <div class="flex items-center gap-3 flex-wrap">
896
+ <div class="text-sm">
897
+ Installed:
898
+ <UBadge :label="`v${ver?.current ?? '—'}`" variant="subtle" size="sm" />
899
+ </div>
900
+ <div class="text-sm">
901
+ Latest:
902
+ <UBadge
903
+ :label="ver?.latest ? `v${ver.latest}` : '—'"
904
+ :color="ver?.update_available ? 'warning' : 'success'"
905
+ variant="subtle"
906
+ size="sm"
907
+ />
908
+ </div>
909
+ <UButton
910
+ size="xs"
911
+ variant="ghost"
912
+ icon="i-lucide-refresh-cw"
913
+ :loading="checkingVer"
914
+ @click="checkVersion"
915
+ >
916
+ Check
917
+ </UButton>
918
+ </div>
919
+
920
+ <div v-if="ver?.update_available" class="flex items-center gap-3">
921
+ <UButton
922
+ icon="i-lucide-download"
923
+ color="primary"
924
+ :loading="updating"
925
+ @click="runUpdate"
926
+ >
927
+ {{ updating ? 'Updating…' : `Update to v${ver.latest}` }}
928
+ </UButton>
929
+ <span class="text-xs text-muted">Runs the core update, then asks you to finish in Claude Code.</span>
930
+ </div>
931
+ <p v-else-if="ver && !ver.update_available" class="text-sm text-success flex items-center gap-1.5">
932
+ <UIcon name="i-lucide-check-circle" class="size-4" />
933
+ You're on the latest version.
934
+ </p>
935
+ <p v-else class="text-sm text-muted">
936
+ Couldn't reach the version service.
937
+ </p>
938
+
939
+ <div
940
+ v-if="updateResult"
941
+ class="rounded-lg border p-3 text-xs"
942
+ :class="updateResult.ok ? 'border-success/40 bg-success/5' : 'border-error/40 bg-error/5'"
943
+ >
944
+ <div class="flex items-center gap-1.5 font-medium mb-1">
945
+ <UIcon :name="updateResult.ok ? 'i-lucide-check-circle' : 'i-lucide-alert-triangle'" class="size-4" />
946
+ {{ updateResult.ok ? 'Core updated' : 'Update failed' }}
947
+ </div>
948
+ <p v-if="updateResult.ok" class="text-muted">
949
+ Now run <code>/arka update</code> in Claude Code to sync all projects (step 2).
950
+ </p>
951
+ <pre class="mt-2 whitespace-pre-wrap font-mono text-[11px] text-muted max-h-48 overflow-y-auto">{{ updateResult.output }}</pre>
952
+ </div>
953
+ </div>
954
+ </UCard>
955
+ </section>
792
956
  </div>
793
957
  </div>
794
958
  </template>
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Cross-OS autostart for the ArkaOS dashboard (v3.72.0, feature #2).
3
+ *
4
+ * Opt-in: `npx arkaos autostart enable|disable|status`. Installs a per-OS
5
+ * boot item that runs `scripts/start-dashboard.sh` (macOS/Linux) or
6
+ * `scripts/start-dashboard.ps1` (Windows) — the same launcher the
7
+ * `dashboard` command uses, which already starts the Python API + the
8
+ * dashboard UI (preferring the production build, falling back to dev).
9
+ *
10
+ * ESM, Node + Bun, no interactive prompts. The unit-generation (`unitFor`)
11
+ * is pure so it can be unit-tested without touching the OS.
12
+ */
13
+
14
+ import { execSync } from "node:child_process";
15
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ export const AUTOSTART_LABEL = "io.wizardingcode.arkaos.dashboard";
21
+ const SERVICE_NAME = "arkaos-dashboard.service";
22
+
23
+ function defaultRepoRoot() {
24
+ return join(dirname(fileURLToPath(import.meta.url)), "..");
25
+ }
26
+
27
+ /** Pure: the boot unit for a given platform. Throws on unsupported OS. */
28
+ export function unitFor(os, { repoRoot, home }) {
29
+ if (os === "darwin") {
30
+ const startScript = `${repoRoot}/scripts/start-dashboard.sh`;
31
+ const log = `${home}/.arkaos/logs/dashboard-autostart.log`;
32
+ const content = `<?xml version="1.0" encoding="UTF-8"?>
33
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
34
+ <plist version="1.0">
35
+ <dict>
36
+ <key>Label</key>
37
+ <string>${AUTOSTART_LABEL}</string>
38
+ <key>ProgramArguments</key>
39
+ <array>
40
+ <string>/bin/bash</string>
41
+ <string>${startScript}</string>
42
+ </array>
43
+ <key>EnvironmentVariables</key>
44
+ <dict>
45
+ <key>ARKAOS_ROOT</key>
46
+ <string>${repoRoot}</string>
47
+ </dict>
48
+ <key>RunAtLoad</key>
49
+ <true/>
50
+ <key>StandardOutPath</key>
51
+ <string>${log}</string>
52
+ <key>StandardErrorPath</key>
53
+ <string>${log}</string>
54
+ </dict>
55
+ </plist>
56
+ `;
57
+ return {
58
+ kind: "launchd",
59
+ path: join(home, "Library", "LaunchAgents", `${AUTOSTART_LABEL}.plist`),
60
+ content,
61
+ };
62
+ }
63
+
64
+ if (os === "linux") {
65
+ const startScript = `${repoRoot}/scripts/start-dashboard.sh`;
66
+ const content = `[Unit]
67
+ Description=ArkaOS Dashboard (Python API + UI)
68
+ After=network.target
69
+
70
+ [Service]
71
+ Type=simple
72
+ Environment=ARKAOS_ROOT=${repoRoot}
73
+ ExecStart=/bin/bash ${startScript}
74
+ Restart=on-failure
75
+
76
+ [Install]
77
+ WantedBy=default.target
78
+ `;
79
+ return {
80
+ kind: "systemd",
81
+ path: join(home, ".config", "systemd", "user", SERVICE_NAME),
82
+ content,
83
+ };
84
+ }
85
+
86
+ if (os === "win32") {
87
+ const ps1 = join(repoRoot, "scripts", "start-dashboard.ps1");
88
+ const content = `@echo off
89
+ rem ArkaOS dashboard autostart (login).
90
+ powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${ps1}"
91
+ `;
92
+ return {
93
+ kind: "startup",
94
+ path: join(
95
+ home,
96
+ "AppData", "Roaming", "Microsoft", "Windows",
97
+ "Start Menu", "Programs", "Startup",
98
+ "arkaos-dashboard.cmd",
99
+ ),
100
+ content,
101
+ };
102
+ }
103
+
104
+ throw new Error(`unsupported platform for autostart: ${os}`);
105
+ }
106
+
107
+ function _silent(cmd) {
108
+ try {
109
+ execSync(cmd, { stdio: "pipe" });
110
+ return true;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ export function enable({ repoRoot = defaultRepoRoot() } = {}) {
117
+ const unit = unitFor(process.platform, { repoRoot, home: homedir() });
118
+ mkdirSync(dirname(unit.path), { recursive: true });
119
+ mkdirSync(join(homedir(), ".arkaos", "logs"), { recursive: true });
120
+ writeFileSync(unit.path, unit.content, "utf8");
121
+ if (unit.kind === "launchd") {
122
+ _silent(`launchctl unload "${unit.path}"`);
123
+ if (!_silent(`launchctl load -w "${unit.path}"`)) {
124
+ return { ok: false, path: unit.path,
125
+ message: `Plist written but launchctl load failed — it will still run at next login.` };
126
+ }
127
+ } else if (unit.kind === "systemd") {
128
+ _silent("systemctl --user daemon-reload");
129
+ if (!_silent(`systemctl --user enable --now ${SERVICE_NAME}`)) {
130
+ return { ok: false, path: unit.path,
131
+ message: `Service written but systemctl enable failed — check 'systemctl --user' availability.` };
132
+ }
133
+ }
134
+ // startup: writing the .cmd is sufficient; it runs on next login.
135
+ return { ok: true, path: unit.path,
136
+ message: `Autostart enabled (${unit.kind}). The dashboard will start on login.` };
137
+ }
138
+
139
+ export function disable() {
140
+ const unit = unitFor(process.platform, { repoRoot: defaultRepoRoot(), home: homedir() });
141
+ if (unit.kind === "launchd") _silent(`launchctl unload "${unit.path}"`);
142
+ else if (unit.kind === "systemd") _silent(`systemctl --user disable --now ${SERVICE_NAME}`);
143
+ if (existsSync(unit.path)) rmSync(unit.path);
144
+ return { ok: true, path: unit.path, message: "Autostart disabled." };
145
+ }
146
+
147
+ export function status() {
148
+ let unit;
149
+ try {
150
+ unit = unitFor(process.platform, { repoRoot: defaultRepoRoot(), home: homedir() });
151
+ } catch (err) {
152
+ return { installed: false, supported: false, message: err.message };
153
+ }
154
+ return {
155
+ installed: existsSync(unit.path),
156
+ supported: true,
157
+ kind: unit.kind,
158
+ path: unit.path,
159
+ };
160
+ }
161
+
162
+ export async function autostart(args = []) {
163
+ const action = (args[0] || "status").toLowerCase();
164
+ if (action === "enable") {
165
+ const r = enable();
166
+ console.log(` ${r.ok ? "✓" : "⚠"} ${r.message}\n ${r.path}`);
167
+ } else if (action === "disable") {
168
+ const r = disable();
169
+ console.log(` ✓ ${r.message}`);
170
+ } else if (action === "status") {
171
+ const s = status();
172
+ if (!s.supported) console.log(` Autostart not supported here: ${s.message}`);
173
+ else console.log(` Autostart: ${s.installed ? "ENABLED" : "disabled"} (${s.kind})\n ${s.path}`);
174
+ } else {
175
+ console.error(` Unknown autostart action: ${action}. Use enable | disable | status.`);
176
+ process.exitCode = 1;
177
+ }
178
+ }
package/installer/cli.js CHANGED
@@ -45,6 +45,7 @@ Usage:
45
45
  npx arkaos migrate Migrate from v1 to v2
46
46
  npx arkaos migrate-user-data Move user data (~/.claude/skills/arka/ → ~/.arkaos/)
47
47
  npx arkaos dashboard Start monitoring dashboard
48
+ npx arkaos autostart <enable|disable|status> Start dashboard on boot
48
49
  npx arkaos keys Manage API keys (OpenAI, fal.ai, etc.)
49
50
  npx arkaos doctor Run health checks
50
51
  npx arkaos uninstall Remove ArkaOS
@@ -101,6 +102,12 @@ async function main() {
101
102
  await update();
102
103
  break;
103
104
 
105
+ case "autostart": {
106
+ const { autostart } = await import("./autostart.js");
107
+ await autostart(positionals.slice(1));
108
+ break;
109
+ }
110
+
104
111
  case "uninstall":
105
112
  const { uninstall } = await import("./uninstall.js");
106
113
  await uninstall();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.71.1",
3
+ "version": "3.72.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.71.1"
3
+ version = "3.72.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"}
@@ -20,7 +20,7 @@ from typing import Optional
20
20
  ARKAOS_ROOT = Path(os.environ.get("ARKAOS_ROOT", Path(__file__).resolve().parent.parent))
21
21
  sys.path.insert(0, str(ARKAOS_ROOT))
22
22
 
23
- from fastapi import FastAPI, Query, WebSocket, WebSocketDisconnect
23
+ from fastapi import FastAPI, Query, Request, WebSocket, WebSocketDisconnect
24
24
  from fastapi.middleware.cors import CORSMiddleware
25
25
 
26
26
  app = FastAPI(title="ArkaOS Dashboard API", version="2.2.0")
@@ -2259,6 +2259,160 @@ def workflow_update_yaml(workflow_id: str, body: dict):
2259
2259
  return {"updated": True, "id": workflow_id, "file": str(target)}
2260
2260
 
2261
2261
 
2262
+ # ============================================================================
2263
+ # v3.72.0 — Cognition: surface Dreaming insights (read-only) to the dashboard.
2264
+ # Reads existing markdown insights from <vault>/Projects/ArkaOS/Dreams via
2265
+ # core.cognition.dreams_reader. No new engine — pure read.
2266
+ # ============================================================================
2267
+
2268
+
2269
+ def _dreams_dir():
2270
+ """Resolve the Dreams folder from the user's vault, or None."""
2271
+ from core.runtime.path_resolver import load_profile, ProfileMissingError
2272
+ try:
2273
+ profile = load_profile()
2274
+ except ProfileMissingError:
2275
+ return None
2276
+ except Exception: # noqa: BLE001 — never break the endpoint on profile errors
2277
+ return None
2278
+ vault = getattr(profile, "vault_path", None)
2279
+ return (Path(vault) / "Projects" / "ArkaOS" / "Dreams") if vault else None
2280
+
2281
+
2282
+ def _insight_to_dict(ins) -> dict:
2283
+ return {
2284
+ "date": ins.date,
2285
+ "title": ins.title,
2286
+ "confidence": ins.confidence,
2287
+ "sources": list(ins.sources),
2288
+ "tags": list(ins.tags),
2289
+ "body": ins.body,
2290
+ }
2291
+
2292
+
2293
+ @app.get("/api/cognition/insights")
2294
+ def cognition_insights(days: int = 7):
2295
+ """Recent Dreaming insights within the given window. Never 500s."""
2296
+ dreams = _dreams_dir()
2297
+ if dreams is None:
2298
+ return {"insights": [], "available": False}
2299
+ from core.cognition.dreams_reader import list_insights
2300
+ items = list_insights(dreams, since_days=max(1, int(days)))
2301
+ return {"insights": [_insight_to_dict(i) for i in items], "available": True}
2302
+
2303
+
2304
+ @app.get("/api/cognition/status")
2305
+ def cognition_status():
2306
+ """Insight counts + confidence breakdown + last activity date."""
2307
+ dreams = _dreams_dir()
2308
+ if dreams is None:
2309
+ return {
2310
+ "today": 0, "week": 0, "total": 0,
2311
+ "by_confidence": {"high": 0, "medium": 0, "low": 0},
2312
+ "vault_configured": False, "last_date": None,
2313
+ }
2314
+ from core.cognition.dreams_reader import list_insights
2315
+ all_items = list_insights(dreams, since_days=36500)
2316
+ by_conf = {"high": 0, "medium": 0, "low": 0}
2317
+ for i in all_items:
2318
+ by_conf[i.confidence if i.confidence in by_conf else "medium"] += 1
2319
+ return {
2320
+ "today": len(list_insights(dreams, since_days=1)),
2321
+ "week": len(list_insights(dreams, since_days=7)),
2322
+ "total": len(all_items),
2323
+ "by_confidence": by_conf,
2324
+ "vault_configured": True,
2325
+ "last_date": all_items[0].date if all_items else None,
2326
+ }
2327
+
2328
+
2329
+ # ============================================================================
2330
+ # v3.72.0 — System: version check + one-click core update (feature #3).
2331
+ # ============================================================================
2332
+
2333
+ _npm_latest_cache = {"version": None, "ts": 0.0}
2334
+
2335
+
2336
+ def _current_version() -> str:
2337
+ root = os.environ.get("ARKAOS_ROOT") or str(Path(__file__).resolve().parent.parent)
2338
+ try:
2339
+ return (Path(root) / "VERSION").read_text(encoding="utf-8").strip()
2340
+ except OSError:
2341
+ return "0.0.0"
2342
+
2343
+
2344
+ def _npm_latest_version():
2345
+ import subprocess
2346
+ import time
2347
+ now = time.time()
2348
+ if _npm_latest_cache["version"] and now - _npm_latest_cache["ts"] < 600:
2349
+ return _npm_latest_cache["version"]
2350
+ try:
2351
+ out = subprocess.run(
2352
+ ["npm", "view", "arkaos", "version"],
2353
+ capture_output=True, text=True, timeout=20,
2354
+ )
2355
+ latest = (out.stdout or "").strip() or None
2356
+ except Exception: # noqa: BLE001
2357
+ latest = None
2358
+ if latest:
2359
+ _npm_latest_cache["version"] = latest
2360
+ _npm_latest_cache["ts"] = now
2361
+ return latest
2362
+
2363
+
2364
+ def _is_newer(latest: str, current: str) -> bool:
2365
+ import re
2366
+ def _parts(v):
2367
+ nums = [int(n) for n in re.findall(r"\d+", v)[:3]]
2368
+ return nums or [0]
2369
+ try:
2370
+ return _parts(latest) > _parts(current)
2371
+ except Exception: # noqa: BLE001
2372
+ return False
2373
+
2374
+
2375
+ def _run_core_update() -> dict:
2376
+ import subprocess
2377
+ try:
2378
+ out = subprocess.run(
2379
+ ["npx", "arkaos@latest", "update"],
2380
+ capture_output=True, text=True, timeout=600,
2381
+ )
2382
+ tail = ((out.stdout or "") + (out.stderr or ""))[-2000:]
2383
+ return {"ok": out.returncode == 0, "output": tail}
2384
+ except Exception as exc: # noqa: BLE001
2385
+ return {"ok": False, "output": f"update failed: {exc}"}
2386
+
2387
+
2388
+ @app.get("/api/system/version")
2389
+ def system_version():
2390
+ """Current installed version vs the latest on npm."""
2391
+ current = _current_version()
2392
+ latest = _npm_latest_version()
2393
+ return {
2394
+ "current": current,
2395
+ "latest": latest,
2396
+ "update_available": bool(latest) and _is_newer(latest, current),
2397
+ }
2398
+
2399
+
2400
+ @app.post("/api/system/update")
2401
+ def system_update(request: Request):
2402
+ """Run `npx arkaos@latest update` (update step 1). Step 2 (project
2403
+ sync via /arka update) is a Claude Code action the UI prompts for.
2404
+
2405
+ A regular POST is CORS-protected; as defense-in-depth for an endpoint
2406
+ that runs an update, reject any explicitly non-localhost origin (an
2407
+ empty origin — local CLI / same-origin — is allowed).
2408
+ """
2409
+ origin = request.headers.get("origin", "")
2410
+ if origin and not _terminal_origin_ok(origin):
2411
+ from fastapi import HTTPException
2412
+ raise HTTPException(status_code=403, detail="origin not allowed")
2413
+ return _run_core_update()
2414
+
2415
+
2262
2416
  # ============================================================================
2263
2417
  # PR99a v3.67.0 — Terminal PTY WebSocket + REST.
2264
2418
  #