arkaos 2.80.0 → 2.82.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
- 2.80.0
1
+ 2.82.0
@@ -0,0 +1,9 @@
1
+ """Profile management for ArkaOS (~/.arkaos/profile.json)."""
2
+
3
+ from core.profile.manager import (
4
+ Profile,
5
+ ProfileManager,
6
+ DEFAULT_PROFILE_PATH,
7
+ )
8
+
9
+ __all__ = ["Profile", "ProfileManager", "DEFAULT_PROFILE_PATH"]
@@ -0,0 +1,165 @@
1
+ """Profile manager — safe read/write of ~/.arkaos/profile.json (PR63 v2.81.0).
2
+
3
+ The profile is operator-local user data:
4
+ - identity (name, company, role)
5
+ - market context (language, market)
6
+ - filesystem context (projectsDir, vaultPath)
7
+ - timestamps (created, updated)
8
+
9
+ Used by:
10
+ - Sync engine (`core/sync/engine.py`) to discover project directories
11
+ from `projectsDir`
12
+ - Dashboard Settings page (PR63) for editing
13
+ - Various skills to greet by name and route by market
14
+
15
+ Lives at ``~/.arkaos/profile.json`` per ADR
16
+ `docs/adr/2026-04-17-user-data-separation.md`. The manager NEVER
17
+ raises on disk errors — read returns a default ``Profile``, write
18
+ swallows OSError so a failed save is logged but doesn't break the
19
+ caller.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from dataclasses import dataclass, field, asdict
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import Any, Optional
29
+
30
+
31
+ DEFAULT_PROFILE_PATH = Path.home() / ".arkaos" / "profile.json"
32
+
33
+ # Fields the API accepts in a POST payload — anything else is ignored
34
+ # so callers can't sneak in arbitrary JSON.
35
+ _WRITABLE_FIELDS = frozenset({
36
+ "name", "language", "market", "role", "company",
37
+ "projectsDir", "vaultPath",
38
+ })
39
+
40
+
41
+ @dataclass
42
+ class Profile:
43
+ """Operator profile stored at ``~/.arkaos/profile.json``."""
44
+
45
+ version: str = "2"
46
+ name: str = ""
47
+ language: str = "en"
48
+ market: str = ""
49
+ role: str = ""
50
+ company: str = ""
51
+ projectsDir: str = ""
52
+ vaultPath: str = ""
53
+ created: str = ""
54
+ updated: str = ""
55
+
56
+ @classmethod
57
+ def from_dict(cls, data: dict) -> "Profile":
58
+ """Build a Profile from a JSON dict, dropping unknown keys."""
59
+ if not isinstance(data, dict):
60
+ return cls()
61
+ known = {
62
+ f.name: data[f.name]
63
+ for f in cls.__dataclass_fields__.values() # type: ignore[attr-defined]
64
+ if f.name in data and data[f.name] is not None
65
+ }
66
+ # Pydantic-free defensive conversion: every field must be a string.
67
+ for key, value in list(known.items()):
68
+ if not isinstance(value, str):
69
+ known[key] = str(value)
70
+ return cls(**known)
71
+
72
+ def to_dict(self) -> dict:
73
+ return asdict(self)
74
+
75
+
76
+ class ProfileManager:
77
+ """Read / patch / write the operator profile.
78
+
79
+ Always returns a ``Profile`` (the dataclass) — even when the file
80
+ doesn't exist or is unparseable. The default Profile carries empty
81
+ strings everywhere; the dashboard renders that as a setup CTA.
82
+ """
83
+
84
+ def __init__(self, path: Path | None = None) -> None:
85
+ self._path = path or DEFAULT_PROFILE_PATH
86
+
87
+ @property
88
+ def path(self) -> Path:
89
+ return self._path
90
+
91
+ def read(self) -> Profile:
92
+ """Return the current profile, or a default Profile on any error."""
93
+ if not self._path.exists():
94
+ return Profile()
95
+ try:
96
+ data = json.loads(self._path.read_text(encoding="utf-8"))
97
+ except (json.JSONDecodeError, OSError):
98
+ return Profile()
99
+ return Profile.from_dict(data)
100
+
101
+ def patch(self, updates: dict[str, Any]) -> Profile:
102
+ """Merge ``updates`` into the stored profile and persist.
103
+
104
+ - Drops any key not in ``_WRITABLE_FIELDS``.
105
+ - Coerces values to strings (the schema is all-string).
106
+ - Bumps ``updated`` to the current UTC timestamp.
107
+ - Initialises ``created`` if absent.
108
+ - Atomic write (.tmp + os.replace).
109
+ - Returns the new Profile.
110
+ """
111
+ current = self.read()
112
+ sanitized = {
113
+ k: ("" if v is None else str(v))
114
+ for k, v in updates.items()
115
+ if k in _WRITABLE_FIELDS
116
+ }
117
+ merged = {**current.to_dict(), **sanitized}
118
+ now = datetime.now(timezone.utc).isoformat()
119
+ merged["updated"] = now
120
+ if not merged.get("created"):
121
+ merged["created"] = now
122
+ merged["version"] = "2"
123
+ self._write(merged)
124
+ return Profile.from_dict(merged)
125
+
126
+ def _write(self, data: dict) -> None:
127
+ try:
128
+ self._path.parent.mkdir(parents=True, exist_ok=True)
129
+ tmp = self._path.with_suffix(self._path.suffix + ".tmp")
130
+ tmp.write_text(
131
+ json.dumps(data, indent=2, ensure_ascii=False),
132
+ encoding="utf-8",
133
+ )
134
+ tmp.replace(self._path)
135
+ except OSError:
136
+ # Caller still gets a Profile back from patch(); persistence
137
+ # failure is logged via stderr by upstream callers when
138
+ # appropriate. We never raise.
139
+ return
140
+
141
+
142
+ # ─── Helpers ────────────────────────────────────────────────────────────
143
+
144
+
145
+ def parse_projects_dirs(value: str) -> list[str]:
146
+ """Split the free-text ``projectsDir`` field into individual paths.
147
+
148
+ The historical schema stored e.g.
149
+ "/Users/foo/Herd para Laravel, /Users/foo/Work para Nuxt"
150
+ so the parser walks the comma-separated segments and keeps anything
151
+ that starts with ``/`` (POSIX absolute) or ``~/`` (home-relative).
152
+ """
153
+ if not value:
154
+ return []
155
+ out: list[str] = []
156
+ for raw in value.split(","):
157
+ token = raw.strip()
158
+ if not token:
159
+ continue
160
+ # First whitespace-delimited word that looks like a path wins.
161
+ for word in token.split():
162
+ if word.startswith("/") or word.startswith("~/"):
163
+ out.append(word)
164
+ break
165
+ return out
@@ -1,36 +1,203 @@
1
1
  <script setup lang="ts">
2
+ // PR65 v2.82.0 — Budget rebuild backed by the PR47 LLM cost telemetry.
3
+ //
4
+ // The legacy /api/budget endpoint surfaces tokens-only ops counts. PR65
5
+ // adds /api/llm-costs (full PR47 CostSummary) and /api/llm-costs/trend
6
+ // (daily rollups), so this page can now show real cost USD by
7
+ // provider / model / category plus a 7-day spend chart.
8
+
9
+ interface BreakdownRow {
10
+ total_cost_usd: number | null
11
+ any_cost_known?: boolean
12
+ total_tokens_in: number
13
+ total_tokens_out: number
14
+ total_cached_tokens: number
15
+ call_count: number
16
+ cache_hit_rate: number
17
+ }
18
+
19
+ interface CostSummary {
20
+ period: string
21
+ total_cost_usd: number | null
22
+ total_tokens_in: number
23
+ total_tokens_out: number
24
+ total_cached_tokens: number
25
+ cache_hit_rate: number
26
+ call_count: number
27
+ by_provider: Record<string, BreakdownRow>
28
+ by_model: Record<string, BreakdownRow>
29
+ by_category: Record<string, BreakdownRow>
30
+ by_session: Array<{
31
+ session_id: string
32
+ call_count: number
33
+ total_tokens_in: number
34
+ total_tokens_out: number
35
+ total_cost_usd: number | null
36
+ }>
37
+ advisories: string[]
38
+ corrupt_line_count: number
39
+ }
40
+
41
+ interface TrendDay {
42
+ date: string
43
+ cost_usd: number | null
44
+ tokens_in: number
45
+ tokens_out: number
46
+ call_count: number
47
+ }
48
+
49
+ interface TrendResponse {
50
+ days: TrendDay[]
51
+ period_days: number
52
+ }
53
+
54
+ type Period = 'today' | 'week' | 'month' | 'all'
55
+
2
56
  const { fetchApi } = useApi()
57
+ const period = ref<Period>('today')
58
+
59
+ const periodOptions: { label: string; value: Period }[] = [
60
+ { label: 'Today', value: 'today' },
61
+ { label: '7 days', value: 'week' },
62
+ { label: '30 days', value: 'month' },
63
+ { label: 'All time', value: 'all' },
64
+ ]
65
+
66
+ const {
67
+ data: costs,
68
+ status,
69
+ error,
70
+ refresh,
71
+ } = fetchApi<CostSummary>(
72
+ '/api/llm-costs',
73
+ { query: computed(() => ({ period: period.value })) },
74
+ )
75
+
76
+ const {
77
+ data: trend,
78
+ refresh: refreshTrend,
79
+ } = fetchApi<TrendResponse>('/api/llm-costs/trend?days=7')
80
+
81
+ watch(period, async () => {
82
+ await refresh()
83
+ })
3
84
 
4
- const { data, status, error, refresh } = fetchApi<any>('/api/budget')
85
+ // ─── View tabs ───────────────────────────────────────────────────────────
5
86
 
6
- const summary = computed(() => data.value?.summary ?? { total_tokens: 0, total_ops: 0, active_departments: 0, estimated_cost_usd: 0 })
7
- const departments = computed(() => data.value?.departments ?? [])
8
- const tiers = computed(() => data.value?.tiers ?? [])
87
+ type View = 'category' | 'provider' | 'model'
9
88
 
10
- const showLimits = ref(false)
89
+ const view = ref<View>('category')
11
90
 
12
- const tierLabels: Record<number, string> = {
13
- 0: 'C-Suite (Unlimited)',
14
- 1: 'Squad Leads',
15
- 2: 'Specialists',
16
- 3: 'Support'
91
+ const viewTabs = [
92
+ { label: 'By category', value: 'category' as const },
93
+ { label: 'By provider', value: 'provider' as const },
94
+ { label: 'By model', value: 'model' as const },
95
+ ]
96
+
97
+ const breakdownRows = computed<Array<[string, BreakdownRow]>>(() => {
98
+ const source = (() => {
99
+ switch (view.value) {
100
+ case 'category': return costs.value?.by_category ?? {}
101
+ case 'provider': return costs.value?.by_provider ?? {}
102
+ case 'model': return costs.value?.by_model ?? {}
103
+ }
104
+ })()
105
+ return Object.entries(source).sort((a, b) => {
106
+ const ca = a[1].total_cost_usd ?? 0
107
+ const cb = b[1].total_cost_usd ?? 0
108
+ return cb - ca
109
+ })
110
+ })
111
+
112
+ const viewEmpty = computed(() =>
113
+ breakdownRows.value.length === 0
114
+ || (
115
+ view.value === 'category'
116
+ && breakdownRows.value.length === 1
117
+ && breakdownRows.value[0]?.[0] === ''
118
+ ),
119
+ )
120
+
121
+ const maxCostForBar = computed(() => {
122
+ const max = breakdownRows.value.reduce(
123
+ (acc, [, row]) => Math.max(acc, row.total_cost_usd ?? 0),
124
+ 0,
125
+ )
126
+ return max > 0 ? max : 1
127
+ })
128
+
129
+ function formatCost(value: number | null): string {
130
+ if (value === null || value === undefined) return 'n/a'
131
+ if (value === 0) return '$0'
132
+ if (value < 0.01) return `$${value.toFixed(4)}`
133
+ return `$${value.toFixed(2)}`
134
+ }
135
+
136
+ function formatTokens(value: number): string {
137
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
138
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
139
+ return value.toString()
140
+ }
141
+
142
+ function formatLabel(key: string): string {
143
+ if (!key) return '(base / uncategorised)'
144
+ return key
145
+ }
146
+
147
+ // ─── Trend chart ────────────────────────────────────────────────────────
148
+
149
+ const maxTrendCost = computed(() => {
150
+ if (!trend.value?.days?.length) return 1
151
+ const max = trend.value.days.reduce(
152
+ (acc, d) => Math.max(acc, d.cost_usd ?? 0),
153
+ 0,
154
+ )
155
+ return max > 0 ? max : 1
156
+ })
157
+
158
+ function trendBarHeight(day: TrendDay): string {
159
+ const cost = day.cost_usd ?? 0
160
+ const percent = Math.min(100, (cost / maxTrendCost.value) * 100)
161
+ return `${Math.max(percent, day.call_count > 0 ? 4 : 0)}%`
162
+ }
163
+
164
+ function trendDayLabel(iso: string): string {
165
+ try {
166
+ const d = new Date(iso + 'T00:00:00Z')
167
+ return new Intl.DateTimeFormat('en-US', {
168
+ weekday: 'short',
169
+ }).format(d)
170
+ } catch {
171
+ return iso
172
+ }
173
+ }
174
+
175
+ async function refreshAll() {
176
+ await Promise.all([refresh(), refreshTrend()])
17
177
  }
18
178
  </script>
19
179
 
20
180
  <template>
21
181
  <UDashboardPanel id="budget">
22
182
  <template #header>
23
- <UDashboardNavbar title="Usage & Budget">
183
+ <UDashboardNavbar title="Usage &amp; Budget">
24
184
  <template #leading>
25
185
  <UDashboardSidebarCollapse />
26
186
  </template>
27
187
  <template #right>
188
+ <USelect
189
+ v-model="period"
190
+ :items="periodOptions"
191
+ size="sm"
192
+ class="w-32"
193
+ />
28
194
  <UButton
29
195
  label="Refresh"
30
196
  variant="ghost"
31
197
  icon="i-lucide-refresh-cw"
32
198
  size="sm"
33
- @click="refresh()"
199
+ class="ml-2"
200
+ @click="refreshAll"
34
201
  />
35
202
  </template>
36
203
  </UDashboardNavbar>
@@ -41,88 +208,173 @@ const tierLabels: Record<number, string> = {
41
208
  :status="status"
42
209
  :error="error"
43
210
  loading-label="Loading budget"
44
- :on-retry="() => refresh()"
211
+ :on-retry="() => refreshAll()"
45
212
  >
46
- <div class="space-y-6">
47
- <!-- Monthly Summary -->
48
- <UCard>
49
- <div class="space-y-3">
50
- <p class="text-xs font-semibold text-muted uppercase tracking-wider">This Month's Usage</p>
51
- <div class="flex flex-wrap items-baseline gap-6">
213
+ <div class="space-y-6">
214
+ <!-- Top-line summary -->
215
+ <UCard>
216
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
52
217
  <div>
53
- <span class="text-3xl font-bold">{{ summary.total_tokens.toLocaleString() }}</span>
54
- <span class="text-sm text-muted ml-1">tokens</span>
218
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">
219
+ Total cost
220
+ </p>
221
+ <p class="text-2xl font-bold">
222
+ {{ formatCost(costs?.total_cost_usd ?? null) }}
223
+ </p>
55
224
  </div>
56
225
  <div>
57
- <span class="text-xl font-semibold">{{ summary.total_ops }}</span>
58
- <span class="text-sm text-muted ml-1">operations</span>
226
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">
227
+ Calls
228
+ </p>
229
+ <p class="text-2xl font-bold">{{ costs?.call_count ?? 0 }}</p>
59
230
  </div>
60
231
  <div>
61
- <span class="text-xl font-semibold">{{ summary.active_departments }}</span>
62
- <span class="text-sm text-muted ml-1">departments active</span>
232
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">
233
+ Tokens in / out
234
+ </p>
235
+ <p class="text-lg font-semibold">
236
+ {{ formatTokens(costs?.total_tokens_in ?? 0) }} /
237
+ {{ formatTokens(costs?.total_tokens_out ?? 0) }}
238
+ </p>
63
239
  </div>
64
- <div v-if="summary.estimated_cost_usd > 0">
65
- <span class="text-sm text-muted">Est. cost: ~${{ summary.estimated_cost_usd.toFixed(4) }}</span>
240
+ <div>
241
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">
242
+ Cache hit rate
243
+ </p>
244
+ <p class="text-2xl font-bold">
245
+ {{ ((costs?.cache_hit_rate ?? 0) * 100).toFixed(1) }}%
246
+ </p>
66
247
  </div>
67
248
  </div>
68
- </div>
69
- </UCard>
70
-
71
- <!-- Department Breakdown -->
72
- <div v-if="departments.length">
73
- <h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-4">Usage by Department</h2>
74
- <div class="space-y-3">
75
- <div
76
- v-for="dept in departments"
77
- :key="dept.department"
78
- class="flex items-center gap-4"
79
- >
80
- <span class="w-28 text-sm font-medium truncate">{{ dept.department }}</span>
81
- <div class="flex-1 h-3 rounded-full bg-muted/20 overflow-hidden">
249
+ </UCard>
250
+
251
+ <!-- 7-day trend (inline bar chart) -->
252
+ <UCard v-if="trend?.days?.length">
253
+ <div>
254
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-4">
255
+ Last 7 days
256
+ </p>
257
+ <div class="flex items-end gap-2 h-32">
82
258
  <div
83
- class="h-3 rounded-full bg-primary transition-none"
84
- :style="{ width: `${dept.percent}%` }"
85
- />
259
+ v-for="day in trend.days"
260
+ :key="day.date"
261
+ class="flex-1 flex flex-col items-center gap-1"
262
+ :title="`${day.date} — ${formatCost(day.cost_usd)} (${day.call_count} calls)`"
263
+ >
264
+ <div class="w-full h-full flex items-end">
265
+ <div
266
+ class="w-full rounded-t transition-none"
267
+ :class="day.call_count > 0 ? 'bg-primary' : 'bg-muted/20'"
268
+ :style="{ height: trendBarHeight(day) }"
269
+ />
270
+ </div>
271
+ <span class="text-xs text-muted">{{ trendDayLabel(day.date) }}</span>
272
+ </div>
86
273
  </div>
87
- <span class="w-24 text-right text-sm font-mono">{{ dept.tokens.toLocaleString() }}</span>
88
- <span class="w-16 text-right text-xs text-muted">{{ dept.operations }} ops</span>
89
274
  </div>
90
- </div>
91
- </div>
92
-
93
- <!-- Empty state -->
94
- <div v-else class="flex flex-col items-center justify-center gap-4 py-12">
95
- <UIcon name="i-lucide-bar-chart-3" class="size-12 text-muted" />
96
- <p class="text-sm text-muted">No usage data yet.</p>
97
- <p class="text-xs text-muted">Token usage is tracked automatically when ArkaOS processes prompts and indexes knowledge.</p>
98
- </div>
275
+ </UCard>
99
276
 
100
- <!-- System Limits (collapsible) -->
101
- <div class="pt-4 border-t border-default">
102
- <button
103
- class="flex items-center gap-2 text-xs text-muted hover:text-highlighted transition-colors"
104
- @click="showLimits = !showLimits"
277
+ <!-- Advisories -->
278
+ <UCard
279
+ v-if="costs?.advisories?.length"
280
+ class="border-yellow-500/30 bg-yellow-500/5"
105
281
  >
106
- <UIcon :name="showLimits ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'" class="size-3" />
107
- System Limits
108
- </button>
109
-
110
- <div v-if="showLimits" class="mt-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
111
- <div
112
- v-for="tier in tiers"
113
- :key="tier.tier"
114
- class="rounded-lg border border-default p-3"
115
- >
116
- <p class="text-xs font-semibold">Tier {{ tier.tier }}</p>
117
- <p class="text-xs text-muted">{{ tierLabels[tier.tier] ?? '' }}</p>
118
- <p class="text-xs text-muted mt-1">
119
- <template v-if="tier.is_unlimited">Unlimited</template>
120
- <template v-else>{{ (tier.allocated ?? 0).toLocaleString() }} tokens/month</template>
282
+ <div class="flex items-start gap-3">
283
+ <UIcon
284
+ name="i-lucide-alert-triangle"
285
+ class="size-5 text-yellow-500 mt-0.5 shrink-0"
286
+ />
287
+ <div>
288
+ <p class="text-sm font-semibold text-yellow-500 mb-2">
289
+ Advisories
290
+ </p>
291
+ <ul class="space-y-1 text-sm">
292
+ <li v-for="a in costs.advisories" :key="a">
293
+ {{ a }}
294
+ </li>
295
+ </ul>
296
+ </div>
297
+ </div>
298
+ </UCard>
299
+
300
+ <!-- Breakdown views -->
301
+ <div>
302
+ <UTabs
303
+ v-model="view"
304
+ :items="viewTabs"
305
+ :ui="{ list: 'mb-4' }"
306
+ />
307
+
308
+ <div v-if="viewEmpty" class="flex flex-col items-center justify-center gap-3 py-12 rounded-lg border border-default">
309
+ <UIcon name="i-lucide-bar-chart-3" class="size-10 text-muted" />
310
+ <p class="text-sm text-muted">
311
+ {{ view === 'category'
312
+ ? "No category-aware spend yet. Orchestration layers can set ARKA_CALL_CATEGORY before LLM calls to attribute spend (PR60)."
313
+ : "No spend data yet for this view." }}
121
314
  </p>
122
315
  </div>
316
+
317
+ <div v-else class="space-y-2">
318
+ <div
319
+ v-for="[key, row] in breakdownRows"
320
+ :key="key"
321
+ class="flex items-center gap-3 rounded-lg border border-default p-3"
322
+ >
323
+ <span class="w-48 text-sm font-mono truncate" :title="formatLabel(key)">
324
+ {{ formatLabel(key) }}
325
+ </span>
326
+ <div class="flex-1 h-3 rounded-full bg-muted/10 overflow-hidden">
327
+ <div
328
+ class="h-3 rounded-full bg-primary"
329
+ :style="{
330
+ width: `${Math.max(2, ((row.total_cost_usd ?? 0) / maxCostForBar) * 100)}%`,
331
+ }"
332
+ />
333
+ </div>
334
+ <span class="w-20 text-right text-sm font-mono">
335
+ {{ formatCost(row.total_cost_usd) }}
336
+ </span>
337
+ <span class="w-16 text-right text-xs text-muted">
338
+ {{ row.call_count }} calls
339
+ </span>
340
+ <span class="w-20 text-right text-xs text-muted">
341
+ {{ formatTokens(row.total_tokens_in + row.total_tokens_out) }} tok
342
+ </span>
343
+ </div>
344
+ </div>
123
345
  </div>
346
+
347
+ <!-- Top sessions -->
348
+ <UCard v-if="costs?.by_session?.length">
349
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-3">
350
+ Top sessions
351
+ </p>
352
+ <div class="space-y-2">
353
+ <div
354
+ v-for="s in costs.by_session"
355
+ :key="s.session_id || 'unknown'"
356
+ class="flex items-center gap-3 text-sm"
357
+ >
358
+ <span class="w-48 font-mono text-xs truncate">
359
+ {{ s.session_id || '(unknown)' }}
360
+ </span>
361
+ <span class="flex-1 text-muted">{{ s.call_count }} calls</span>
362
+ <span class="w-20 text-right font-mono">{{ formatCost(s.total_cost_usd) }}</span>
363
+ <span class="w-20 text-right text-xs text-muted">
364
+ {{ formatTokens(s.total_tokens_in + s.total_tokens_out) }} tok
365
+ </span>
366
+ </div>
367
+ </div>
368
+ </UCard>
369
+
370
+ <!-- Note for corrupt rows -->
371
+ <p
372
+ v-if="costs?.corrupt_line_count"
373
+ class="text-xs text-muted text-center"
374
+ >
375
+ Skipped {{ costs.corrupt_line_count }} corrupt JSONL line(s) in telemetry.
376
+ </p>
124
377
  </div>
125
- </div>
126
378
  </DashboardState>
127
379
  </template>
128
380
  </UDashboardPanel>
@@ -1,26 +1,126 @@
1
1
  <script setup lang="ts">
2
+ interface ProfileResponse {
3
+ version: string
4
+ name: string
5
+ language: string
6
+ market: string
7
+ role: string
8
+ company: string
9
+ projectsDir: string
10
+ vaultPath: string
11
+ created: string
12
+ updated: string
13
+ projects_dirs_list: string[]
14
+ }
15
+
2
16
  const { fetchApi, apiBase } = useApi()
17
+ const toast = useToast()
18
+
19
+ // ─── Profile (PR63 v2.81.0) ─────────────────────────────────────────────
20
+
21
+ const {
22
+ data: profile,
23
+ status: profileStatus,
24
+ error: profileError,
25
+ refresh: refreshProfile,
26
+ } = await fetchApi<ProfileResponse>('/api/profile')
27
+
28
+ const profileDraft = ref({
29
+ name: profile.value?.name ?? '',
30
+ company: profile.value?.company ?? '',
31
+ role: profile.value?.role ?? '',
32
+ market: profile.value?.market ?? '',
33
+ language: profile.value?.language ?? 'en',
34
+ vaultPath: profile.value?.vaultPath ?? '',
35
+ projectsDir: profile.value?.projectsDir ?? '',
36
+ })
37
+
38
+ watch(profile, (p) => {
39
+ if (!p) return
40
+ profileDraft.value = {
41
+ name: p.name,
42
+ company: p.company,
43
+ role: p.role,
44
+ market: p.market,
45
+ language: p.language,
46
+ vaultPath: p.vaultPath,
47
+ projectsDir: p.projectsDir,
48
+ }
49
+ }, { immediate: true })
50
+
51
+ const savingProfile = ref(false)
52
+
53
+ async function saveProfile() {
54
+ savingProfile.value = true
55
+ try {
56
+ await $fetch<ProfileResponse>(`${apiBase}/api/profile`, {
57
+ method: 'POST',
58
+ body: profileDraft.value,
59
+ })
60
+ await refreshProfile()
61
+ toast.add({
62
+ title: 'Profile saved',
63
+ description: 'Settings written to ~/.arkaos/profile.json',
64
+ color: 'success',
65
+ })
66
+ } catch (err) {
67
+ toast.add({
68
+ title: 'Save failed',
69
+ description: err instanceof Error ? err.message : 'unknown error',
70
+ color: 'error',
71
+ })
72
+ } finally {
73
+ savingProfile.value = false
74
+ }
75
+ }
76
+
77
+ const languageOptions = [
78
+ { label: 'English', value: 'en' },
79
+ { label: 'Português', value: 'pt' },
80
+ ]
3
81
 
4
- const { data, status, error, refresh } = fetchApi<any>('/api/keys')
82
+ const roleOptions = [
83
+ { label: 'Founder', value: 'founder' },
84
+ { label: 'CTO', value: 'cto' },
85
+ { label: 'CEO', value: 'ceo' },
86
+ { label: 'Engineer', value: 'engineer' },
87
+ { label: 'Designer', value: 'designer' },
88
+ { label: 'Operator', value: 'operator' },
89
+ { label: 'Consultant', value: 'consultant' },
90
+ ]
5
91
 
6
- const keys = computed(() => data.value?.keys ?? [])
92
+ // ─── API Keys (preserved from earlier) ──────────────────────────────────
93
+
94
+ const {
95
+ data: keysData,
96
+ status: keysStatus,
97
+ refresh: refreshKeys,
98
+ } = fetchApi<any>('/api/keys')
99
+
100
+ const keys = computed(() => keysData.value?.keys ?? [])
7
101
 
8
102
  const newKey = ref('')
9
103
  const newValue = ref('')
104
+ const customKeyName = ref('')
10
105
  const saving = ref(false)
11
106
  const deletingKey = ref<string | null>(null)
12
107
 
108
+ const isCustom = computed(() => newKey.value === 'custom')
109
+ const effectiveKeyName = computed(() => isCustom.value ? customKeyName.value : newKey.value)
110
+
13
111
  async function saveKey() {
14
- if (!newKey.value || !newValue.value) return
112
+ const keyName = effectiveKeyName.value
113
+ if (!keyName || !newValue.value) return
15
114
  saving.value = true
16
115
  try {
17
116
  await $fetch(`${apiBase}/api/keys`, {
18
117
  method: 'POST',
19
- body: { key: newKey.value, value: newValue.value },
118
+ body: { key: keyName, value: newValue.value },
20
119
  })
21
120
  newKey.value = ''
22
121
  newValue.value = ''
23
- await refresh()
122
+ customKeyName.value = ''
123
+ await refreshKeys()
24
124
  } catch {}
25
125
  saving.value = false
26
126
  }
@@ -29,7 +129,7 @@ async function deleteKey(keyName: string) {
29
129
  deletingKey.value = keyName
30
130
  try {
31
131
  await $fetch(`${apiBase}/api/keys/${keyName}`, { method: 'DELETE' })
32
- await refresh()
132
+ await refreshKeys()
33
133
  } catch {}
34
134
  deletingKey.value = null
35
135
  }
@@ -41,9 +141,17 @@ const keyOptions = [
41
141
  { label: 'Custom...', value: 'custom' },
42
142
  ]
43
143
 
44
- const isCustom = computed(() => newKey.value === 'custom')
45
- const customKeyName = ref('')
46
- const effectiveKeyName = computed(() => isCustom.value ? customKeyName.value : newKey.value)
144
+ // ─── Section nav ────────────────────────────────────────────────────────
145
+
146
+ type SectionId = 'profile' | 'projects' | 'keys'
147
+
148
+ const sections: { id: SectionId; label: string; icon: string }[] = [
149
+ { id: 'profile', label: 'Profile', icon: 'i-lucide-user-circle' },
150
+ { id: 'projects', label: 'Projects', icon: 'i-lucide-folders' },
151
+ { id: 'keys', label: 'API Keys', icon: 'i-lucide-key' },
152
+ ]
153
+
154
+ const activeSection = ref<SectionId>('profile')
47
155
  </script>
48
156
 
49
157
  <template>
@@ -57,88 +165,250 @@ const effectiveKeyName = computed(() => isCustom.value ? customKeyName.value : n
57
165
  </template>
58
166
 
59
167
  <template #body>
60
- <div class="space-y-8">
61
- <!-- API Keys Section -->
168
+ <div class="grid grid-cols-1 md:grid-cols-[14rem_1fr] gap-6">
169
+ <!-- Section nav -->
170
+ <nav class="space-y-1" aria-label="Settings sections">
171
+ <button
172
+ v-for="s in sections"
173
+ :key="s.id"
174
+ type="button"
175
+ class="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm text-left transition-colors"
176
+ :class="activeSection === s.id
177
+ ? 'bg-primary/10 text-primary font-medium'
178
+ : 'text-muted hover:bg-elevated/50'"
179
+ @click="activeSection = s.id"
180
+ >
181
+ <UIcon :name="s.icon" class="size-4" />
182
+ <span>{{ s.label }}</span>
183
+ </button>
184
+ <p class="text-xs text-muted px-3 mt-6">
185
+ More sections (MCPs, Hooks, Plugins, Theme) coming in PR63b.
186
+ </p>
187
+ </nav>
188
+
189
+ <!-- Section content -->
62
190
  <div>
63
- <h2 class="text-lg font-semibold mb-1">API Keys</h2>
64
- <p class="text-sm text-muted mb-6">Configure API keys for external services. Keys are stored locally at ~/.arkaos/keys.json.</p>
65
-
66
- <!-- Add Key Form -->
67
- <UCard class="mb-6">
68
- <div class="space-y-4">
69
- <p class="text-xs font-semibold text-muted uppercase tracking-wider">Add API Key</p>
70
- <div class="grid grid-cols-1 md:grid-cols-3 gap-3 items-end">
71
- <div>
72
- <label class="text-xs text-muted mb-1 block">Provider</label>
73
- <USelect v-model="newKey" :items="keyOptions" class="w-full" placeholder="Select key..." />
74
- </div>
75
- <div v-if="isCustom">
76
- <label class="text-xs text-muted mb-1 block">Key Name</label>
77
- <UInput v-model="customKeyName" class="w-full" placeholder="MY_CUSTOM_KEY" />
191
+ <!-- Profile -->
192
+ <section v-if="activeSection === 'profile'">
193
+ <h2 class="text-lg font-semibold mb-1">Profile</h2>
194
+ <p class="text-sm text-muted mb-6">
195
+ Your identity, role, and language. Stored locally at
196
+ <code class="font-mono text-xs">~/.arkaos/profile.json</code>.
197
+ </p>
198
+
199
+ <DashboardState
200
+ :status="profileStatus"
201
+ :error="profileError"
202
+ loading-label="Loading profile"
203
+ :on-retry="() => refreshProfile()"
204
+ >
205
+ <UCard>
206
+ <div class="space-y-4">
207
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
208
+ <UFormField label="Name">
209
+ <UInput
210
+ v-model="profileDraft.name"
211
+ placeholder="André Agro Ferreira"
212
+ class="w-full"
213
+ />
214
+ </UFormField>
215
+ <UFormField label="Company">
216
+ <UInput
217
+ v-model="profileDraft.company"
218
+ placeholder="WizardingCode"
219
+ class="w-full"
220
+ />
221
+ </UFormField>
222
+ </div>
223
+
224
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
225
+ <UFormField label="Role">
226
+ <USelect
227
+ v-model="profileDraft.role"
228
+ :items="roleOptions"
229
+ placeholder="Select role"
230
+ class="w-full"
231
+ />
232
+ </UFormField>
233
+ <UFormField label="Language">
234
+ <USelect
235
+ v-model="profileDraft.language"
236
+ :items="languageOptions"
237
+ class="w-full"
238
+ />
239
+ </UFormField>
240
+ </div>
241
+
242
+ <UFormField
243
+ label="Market"
244
+ help="Comma-separated list of markets you operate in (free text)."
245
+ >
246
+ <UInput
247
+ v-model="profileDraft.market"
248
+ placeholder="Portugal, Europa, Emirados Árabes Unidos"
249
+ class="w-full"
250
+ />
251
+ </UFormField>
252
+
253
+ <UFormField
254
+ label="Vault path"
255
+ help="Where your Obsidian vault lives. Used by the KB-first hook."
256
+ >
257
+ <UInput
258
+ v-model="profileDraft.vaultPath"
259
+ placeholder="/Users/you/Documents/Vault"
260
+ class="w-full font-mono text-sm"
261
+ />
262
+ </UFormField>
263
+
264
+ <div class="flex justify-end pt-2">
265
+ <UButton
266
+ label="Save profile"
267
+ icon="i-lucide-check"
268
+ :loading="savingProfile"
269
+ @click="saveProfile"
270
+ />
271
+ </div>
78
272
  </div>
79
- <div :class="isCustom ? '' : 'md:col-span-1'">
80
- <label class="text-xs text-muted mb-1 block">Value</label>
81
- <UInput v-model="newValue" type="password" class="w-full" placeholder="sk-..." />
273
+ </UCard>
274
+ </DashboardState>
275
+ </section>
276
+
277
+ <!-- Projects -->
278
+ <section v-else-if="activeSection === 'projects'">
279
+ <h2 class="text-lg font-semibold mb-1">Project directories</h2>
280
+ <p class="text-sm text-muted mb-6">
281
+ Directories the sync engine scans for projects.
282
+ Comma-separated absolute paths (e.g.
283
+ <code class="font-mono text-xs">~/Herd</code>,
284
+ <code class="font-mono text-xs">~/Work</code>).
285
+ </p>
286
+
287
+ <UCard>
288
+ <div class="space-y-4">
289
+ <UFormField
290
+ label="projectsDir"
291
+ help="Free text. Each comma-separated segment's leading absolute path is consumed by the sync engine."
292
+ >
293
+ <UTextarea
294
+ v-model="profileDraft.projectsDir"
295
+ :rows="3"
296
+ placeholder="/Users/you/Herd para projectos laravel, /Users/you/Work para projectos Nuxt e Python"
297
+ class="w-full font-mono text-sm"
298
+ />
299
+ </UFormField>
300
+
301
+ <div v-if="profile?.projects_dirs_list?.length" class="rounded-lg border border-default p-3">
302
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-2">
303
+ Currently parsed
304
+ </p>
305
+ <ul class="space-y-1">
306
+ <li
307
+ v-for="dir in profile.projects_dirs_list"
308
+ :key="dir"
309
+ class="flex items-center gap-2 text-sm"
310
+ >
311
+ <UIcon name="i-lucide-folder" class="size-4 text-muted" />
312
+ <code class="font-mono text-xs">{{ dir }}</code>
313
+ </li>
314
+ </ul>
82
315
  </div>
83
- <div>
316
+
317
+ <div class="flex justify-end pt-2">
84
318
  <UButton
85
- label="Save Key"
86
- icon="i-lucide-key"
87
- :loading="saving"
88
- :disabled="!effectiveKeyName || !newValue"
89
- @click="() => { newKey = effectiveKeyName; saveKey() }"
90
- block
319
+ label="Save directories"
320
+ icon="i-lucide-check"
321
+ :loading="savingProfile"
322
+ @click="saveProfile"
91
323
  />
92
324
  </div>
93
325
  </div>
94
- </div>
95
- </UCard>
96
-
97
- <!-- Keys List -->
98
- <div v-if="status === 'pending'" class="flex items-center justify-center py-8">
99
- <UIcon name="i-lucide-loader-2" class="size-6 animate-spin text-muted" />
100
- </div>
101
-
102
- <div v-else class="space-y-2">
103
- <div
104
- v-for="k in keys"
105
- :key="k.key"
106
- class="flex items-center gap-4 p-3 rounded-lg border border-default"
326
+ </UCard>
327
+ </section>
328
+
329
+ <!-- API Keys -->
330
+ <section v-else-if="activeSection === 'keys'">
331
+ <h2 class="text-lg font-semibold mb-1">API Keys</h2>
332
+ <p class="text-sm text-muted mb-6">
333
+ Configure API keys for external services. Keys are stored
334
+ locally at <code class="font-mono text-xs">~/.arkaos/keys.json</code>.
335
+ </p>
336
+
337
+ <UCard class="mb-6">
338
+ <div class="space-y-4">
339
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider">Add API Key</p>
340
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-3 items-end">
341
+ <div>
342
+ <label class="text-xs text-muted mb-1 block">Provider</label>
343
+ <USelect v-model="newKey" :items="keyOptions" class="w-full" placeholder="Select key..." />
344
+ </div>
345
+ <div v-if="isCustom">
346
+ <label class="text-xs text-muted mb-1 block">Key Name</label>
347
+ <UInput v-model="customKeyName" class="w-full" placeholder="MY_CUSTOM_KEY" />
348
+ </div>
349
+ <div :class="isCustom ? '' : 'md:col-span-1'">
350
+ <label class="text-xs text-muted mb-1 block">Value</label>
351
+ <UInput v-model="newValue" type="password" class="w-full" placeholder="sk-..." />
352
+ </div>
353
+ <div>
354
+ <UButton
355
+ label="Save Key"
356
+ icon="i-lucide-key"
357
+ :loading="saving"
358
+ :disabled="!effectiveKeyName || !newValue"
359
+ block
360
+ @click="saveKey"
361
+ />
362
+ </div>
363
+ </div>
364
+ </div>
365
+ </UCard>
366
+
367
+ <DashboardState
368
+ :status="keysStatus"
369
+ :empty="!keys.length"
370
+ empty-title="No keys configured"
371
+ empty-icon="i-lucide-key"
372
+ loading-label="Loading API keys"
107
373
  >
108
- <div class="flex-1 min-w-0">
109
- <div class="flex items-center gap-2">
110
- <span class="text-sm font-mono font-medium">{{ k.key }}</span>
111
- <UBadge :label="k.provider" variant="subtle" size="xs" />
112
- <UBadge
113
- v-if="k.configured"
114
- label="Configured"
115
- color="success"
116
- variant="subtle"
117
- size="xs"
118
- />
119
- <UBadge
120
- v-else
121
- label="Not Set"
122
- color="neutral"
123
- variant="outline"
374
+ <div class="space-y-2">
375
+ <div
376
+ v-for="k in keys"
377
+ :key="k.key"
378
+ class="flex items-center gap-4 p-3 rounded-lg border border-default"
379
+ >
380
+ <div class="flex-1 min-w-0">
381
+ <div class="flex items-center gap-2">
382
+ <span class="text-sm font-mono font-medium">{{ k.key }}</span>
383
+ <UBadge :label="k.provider" variant="subtle" size="xs" />
384
+ <UBadge
385
+ v-if="k.configured"
386
+ label="Configured"
387
+ color="success"
388
+ variant="subtle"
389
+ size="xs"
390
+ />
391
+ <UBadge v-else label="Not Set" color="neutral" variant="outline" size="xs" />
392
+ </div>
393
+ <p v-if="k.used_for" class="text-xs text-muted mt-0.5">{{ k.used_for }}</p>
394
+ <p v-if="k.masked_value && k.configured" class="text-xs font-mono text-muted/60 mt-0.5">
395
+ {{ k.masked_value }}
396
+ </p>
397
+ </div>
398
+ <UButton
399
+ v-if="k.configured && k.masked_value !== '(from environment)'"
400
+ icon="i-lucide-trash-2"
401
+ variant="ghost"
402
+ color="error"
124
403
  size="xs"
404
+ :loading="deletingKey === k.key"
405
+ aria-label="Delete key"
406
+ @click="deleteKey(k.key)"
125
407
  />
126
408
  </div>
127
- <p v-if="k.used_for" class="text-xs text-muted mt-0.5">{{ k.used_for }}</p>
128
- <p v-if="k.masked_value && k.configured" class="text-xs font-mono text-muted/60 mt-0.5">{{ k.masked_value }}</p>
129
409
  </div>
130
- <UButton
131
- v-if="k.configured && k.masked_value !== '(from environment)'"
132
- icon="i-lucide-trash-2"
133
- variant="ghost"
134
- color="error"
135
- size="xs"
136
- :loading="deletingKey === k.key"
137
- @click="deleteKey(k.key)"
138
- aria-label="Delete key"
139
- />
140
- </div>
141
- </div>
410
+ </DashboardState>
411
+ </section>
142
412
  </div>
143
413
  </div>
144
414
  </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.80.0",
3
+ "version": "2.82.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 = "2.80.0"
3
+ version = "2.82.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"}
@@ -729,6 +729,139 @@ def persona_build(body: dict):
729
729
  }
730
730
 
731
731
 
732
+ # --- LLM Costs (PR65 v2.82.0) ---
733
+
734
+ @app.get("/api/llm-costs")
735
+ def llm_costs(period: str = "today"):
736
+ """Aggregated LLM cost summary backed by the PR47 telemetry pipeline.
737
+
738
+ `period` ∈ {today, week, month, all}. Returns the same shape the
739
+ `/arka costs` CLI returns — by_provider, by_model, by_category
740
+ (PR47), top sessions, advisories. The Budget page consumes this
741
+ to show category-aware spend instead of the legacy
742
+ /api/budget tokens-only view.
743
+ """
744
+ try:
745
+ from core.runtime.llm_cost_telemetry import summarise, VALID_PERIODS
746
+ except Exception as exc: # pragma: no cover - import guard
747
+ return {"error": f"telemetry unavailable: {exc}"}
748
+ if period not in VALID_PERIODS:
749
+ return {"error": f"period must be one of {list(VALID_PERIODS)}"}
750
+ summary = summarise(period=period)
751
+ return {
752
+ "period": summary.period,
753
+ "total_cost_usd": summary.total_cost_usd,
754
+ "total_tokens_in": summary.total_tokens_in,
755
+ "total_tokens_out": summary.total_tokens_out,
756
+ "total_cached_tokens": summary.total_cached_tokens,
757
+ "cache_hit_rate": summary.cache_hit_rate,
758
+ "call_count": summary.call_count,
759
+ "by_provider": summary.by_provider,
760
+ "by_model": summary.by_model,
761
+ "by_category": summary.by_category,
762
+ "by_session": summary.by_session,
763
+ "advisories": summary.advisories,
764
+ "corrupt_line_count": summary.corrupt_line_count,
765
+ }
766
+
767
+
768
+ @app.get("/api/llm-costs/trend")
769
+ def llm_costs_trend(days: int = 7):
770
+ """Day-by-day rolling totals from the cost telemetry.
771
+
772
+ Returns ``{"days": [{"date": "YYYY-MM-DD", "cost_usd": x.xx,
773
+ "tokens_in": N, "tokens_out": N, "call_count": N}]}`` so the
774
+ Budget page can render a 7-day trend chart with @unovis/vue.
775
+ Cap `days` to 90 to keep response size bounded.
776
+ """
777
+ from datetime import datetime, timedelta, timezone
778
+ from core.runtime.llm_cost_telemetry import read_entries
779
+ # `days or 7` would treat 0 as "use default", which contradicts the
780
+ # documented floor-at-1 behaviour. Cast first, then clamp.
781
+ try:
782
+ days_int = int(days) if days is not None else 7
783
+ except (TypeError, ValueError):
784
+ days_int = 7
785
+ capped_days = max(1, min(days_int, 90))
786
+ today = datetime.now(timezone.utc).date()
787
+ buckets: dict[str, dict] = {}
788
+ # Seed every day so the chart shows zeros instead of gaps.
789
+ for offset in range(capped_days):
790
+ d = today - timedelta(days=capped_days - 1 - offset)
791
+ buckets[d.isoformat()] = {
792
+ "date": d.isoformat(),
793
+ "cost_usd": 0.0,
794
+ "cost_known": False,
795
+ "tokens_in": 0,
796
+ "tokens_out": 0,
797
+ "call_count": 0,
798
+ }
799
+ cutoff = today - timedelta(days=capped_days - 1)
800
+ for entry in read_entries():
801
+ raw_ts = entry.get("ts") or ""
802
+ if not isinstance(raw_ts, str):
803
+ continue
804
+ try:
805
+ ts = datetime.fromisoformat(raw_ts.replace("Z", "+00:00"))
806
+ except ValueError:
807
+ continue
808
+ d = ts.date()
809
+ if d < cutoff:
810
+ continue
811
+ key = d.isoformat()
812
+ if key not in buckets:
813
+ continue
814
+ b = buckets[key]
815
+ b["tokens_in"] += int(entry.get("tokens_in") or 0)
816
+ b["tokens_out"] += int(entry.get("tokens_out") or 0)
817
+ b["call_count"] += 1
818
+ cost = entry.get("estimated_cost_usd")
819
+ if cost is not None:
820
+ b["cost_usd"] += float(cost)
821
+ b["cost_known"] = True
822
+ out_days = list(buckets.values())
823
+ for b in out_days:
824
+ b["cost_usd"] = round(b["cost_usd"], 6) if b.pop("cost_known") else None
825
+ return {"days": out_days, "period_days": capped_days}
826
+
827
+
828
+ # --- Profile (PR63 v2.81.0) ---
829
+
830
+ @app.get("/api/profile")
831
+ def profile_get():
832
+ """Return the operator profile from ~/.arkaos/profile.json.
833
+
834
+ Always returns a profile object (default empty strings when the
835
+ file doesn't exist yet) so the dashboard can render a setup form
836
+ instead of an error.
837
+ """
838
+ from core.profile import ProfileManager
839
+ from core.profile.manager import parse_projects_dirs
840
+ profile = ProfileManager().read()
841
+ payload = profile.to_dict()
842
+ # Convenience: split projectsDir into a list for the UI.
843
+ payload["projects_dirs_list"] = parse_projects_dirs(profile.projectsDir)
844
+ return payload
845
+
846
+
847
+ @app.post("/api/profile")
848
+ def profile_post(body: dict):
849
+ """Patch the operator profile.
850
+
851
+ Only the writable fields are honoured (name, language, market,
852
+ role, company, projectsDir, vaultPath). Unknown keys are silently
853
+ dropped. Returns the updated profile.
854
+ """
855
+ if not isinstance(body, dict):
856
+ return {"error": "body must be an object"}
857
+ from core.profile import ProfileManager
858
+ from core.profile.manager import parse_projects_dirs
859
+ updated = ProfileManager().patch(body)
860
+ payload = updated.to_dict()
861
+ payload["projects_dirs_list"] = parse_projects_dirs(updated.projectsDir)
862
+ return payload
863
+
864
+
732
865
  # --- API Keys ---
733
866
 
734
867
  @app.get("/api/keys")