arkaos 2.82.0 → 2.83.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 +1 -1
- package/core/profile/__pycache__/manager.cpython-313.pyc +0 -0
- package/core/profile/manager.py +5 -1
- package/dashboard/app/pages/index.vue +276 -77
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/dashboard-api.py +170 -0
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.83.0
|
|
Binary file
|
package/core/profile/manager.py
CHANGED
|
@@ -82,7 +82,11 @@ class ProfileManager:
|
|
|
82
82
|
"""
|
|
83
83
|
|
|
84
84
|
def __init__(self, path: Path | None = None) -> None:
|
|
85
|
-
|
|
85
|
+
# Resolve at call time so HOME changes (tests, multi-tenant
|
|
86
|
+
# daemons) are honoured. DEFAULT_PROFILE_PATH stays as a
|
|
87
|
+
# module-level constant for callers that want the canonical
|
|
88
|
+
# path explicitly.
|
|
89
|
+
self._path = path or (Path.home() / ".arkaos" / "profile.json")
|
|
86
90
|
|
|
87
91
|
@property
|
|
88
92
|
def path(self) -> Path:
|
|
@@ -1,39 +1,139 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
|
|
2
|
+
// PR66 v2.83.0 — Command center.
|
|
3
|
+
//
|
|
4
|
+
// Replaces the 6-stat-card Overview that just counted things you
|
|
5
|
+
// already knew (agents=62, skills=256, ...) with telemetry-driven
|
|
6
|
+
// information the operator actually uses: greeting, today's cost,
|
|
7
|
+
// per-project state, recent incidents, quick actions.
|
|
8
|
+
|
|
9
|
+
interface ProjectRow {
|
|
10
|
+
name: string
|
|
11
|
+
path: string
|
|
12
|
+
stack: string[]
|
|
13
|
+
status: string
|
|
14
|
+
ecosystem: string
|
|
15
|
+
last_commit_days: number | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface IncidentRow {
|
|
19
|
+
ts: string
|
|
20
|
+
tool: string
|
|
21
|
+
reason: string
|
|
22
|
+
cwd: string
|
|
23
|
+
bypass_used: boolean
|
|
24
|
+
kind: 'bypass' | 'blocked'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface QuickAction {
|
|
28
|
+
command: string
|
|
29
|
+
description: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CommandCenterPayload {
|
|
33
|
+
greeting: {
|
|
34
|
+
name: string
|
|
35
|
+
role: string
|
|
36
|
+
company: string
|
|
37
|
+
language: string
|
|
38
|
+
}
|
|
39
|
+
today_cost: {
|
|
40
|
+
total_usd: number | null
|
|
41
|
+
call_count: number
|
|
42
|
+
tokens_in: number
|
|
43
|
+
tokens_out: number
|
|
44
|
+
cache_hit_rate: number
|
|
45
|
+
}
|
|
46
|
+
projects: ProjectRow[]
|
|
47
|
+
recent_incidents: IncidentRow[]
|
|
48
|
+
quick_actions: QuickAction[]
|
|
49
|
+
}
|
|
3
50
|
|
|
4
51
|
const { fetchApi } = useApi()
|
|
5
52
|
|
|
6
|
-
const {
|
|
53
|
+
const {
|
|
54
|
+
data,
|
|
55
|
+
status,
|
|
56
|
+
error,
|
|
57
|
+
refresh,
|
|
58
|
+
} = await fetchApi<CommandCenterPayload>('/api/overview/command-center')
|
|
59
|
+
|
|
60
|
+
const greetingLabel = computed(() => {
|
|
61
|
+
const name = data.value?.greeting?.name?.trim()
|
|
62
|
+
const language = data.value?.greeting?.language ?? 'en'
|
|
63
|
+
if (!name) return language === 'pt' ? 'Olá' : 'Welcome'
|
|
64
|
+
return language === 'pt' ? `Olá, ${name}` : `Hi, ${name}`
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const todayCost = computed(() => data.value?.today_cost)
|
|
68
|
+
|
|
69
|
+
function formatCost(value: number | null | undefined): string {
|
|
70
|
+
if (value === null || value === undefined) return 'n/a'
|
|
71
|
+
if (value === 0) return '$0'
|
|
72
|
+
if (value < 0.01) return `$${value.toFixed(4)}`
|
|
73
|
+
return `$${value.toFixed(2)}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatTokens(n: number): string {
|
|
77
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
|
78
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
|
79
|
+
return n.toString()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function statusColor(status: string): 'success' | 'warning' | 'neutral' | 'error' {
|
|
83
|
+
switch (status) {
|
|
84
|
+
case 'active': return 'success'
|
|
85
|
+
case 'paused': return 'warning'
|
|
86
|
+
case 'archived': return 'neutral'
|
|
87
|
+
case 'error': return 'error'
|
|
88
|
+
default: return 'neutral'
|
|
89
|
+
}
|
|
90
|
+
}
|
|
7
91
|
|
|
8
|
-
|
|
9
|
-
{
|
|
10
|
-
|
|
11
|
-
{
|
|
12
|
-
{
|
|
13
|
-
|
|
14
|
-
{
|
|
15
|
-
|
|
92
|
+
function commitFreshness(days: number | null): { color: string; label: string } {
|
|
93
|
+
if (days === null) return { color: 'text-muted', label: 'no git' }
|
|
94
|
+
if (days === 0) return { color: 'text-green-500', label: 'today' }
|
|
95
|
+
if (days === 1) return { color: 'text-green-500', label: '1 day ago' }
|
|
96
|
+
if (days < 7) return { color: 'text-primary', label: `${days} days ago` }
|
|
97
|
+
if (days < 30) return { color: 'text-yellow-500', label: `${days} days ago` }
|
|
98
|
+
return { color: 'text-red-500', label: `${days} days ago` }
|
|
99
|
+
}
|
|
16
100
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
101
|
+
function formatIncidentTs(iso: string): string {
|
|
102
|
+
if (!iso) return ''
|
|
103
|
+
try {
|
|
104
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
105
|
+
month: 'short',
|
|
106
|
+
day: 'numeric',
|
|
107
|
+
hour: '2-digit',
|
|
108
|
+
minute: '2-digit',
|
|
109
|
+
}).format(new Date(iso))
|
|
110
|
+
} catch {
|
|
111
|
+
return iso
|
|
112
|
+
}
|
|
113
|
+
}
|
|
21
114
|
|
|
22
|
-
function
|
|
23
|
-
|
|
115
|
+
function copyCommand(cmd: string) {
|
|
116
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
|
117
|
+
navigator.clipboard.writeText(cmd).catch(() => { /* ignore */ })
|
|
118
|
+
}
|
|
24
119
|
}
|
|
25
120
|
</script>
|
|
26
121
|
|
|
27
122
|
<template>
|
|
28
123
|
<UDashboardPanel id="overview">
|
|
29
124
|
<template #header>
|
|
30
|
-
<UDashboardNavbar title="
|
|
125
|
+
<UDashboardNavbar title="Command Center">
|
|
31
126
|
<template #leading>
|
|
32
127
|
<UDashboardSidebarCollapse />
|
|
33
128
|
</template>
|
|
34
|
-
|
|
35
129
|
<template #right>
|
|
36
|
-
<
|
|
130
|
+
<UButton
|
|
131
|
+
label="Refresh"
|
|
132
|
+
variant="ghost"
|
|
133
|
+
icon="i-lucide-refresh-cw"
|
|
134
|
+
size="sm"
|
|
135
|
+
@click="refresh()"
|
|
136
|
+
/>
|
|
37
137
|
</template>
|
|
38
138
|
</UDashboardNavbar>
|
|
39
139
|
</template>
|
|
@@ -42,72 +142,171 @@ function formatCurrency(value: number): string {
|
|
|
42
142
|
<DashboardState
|
|
43
143
|
:status="status"
|
|
44
144
|
:error="error"
|
|
45
|
-
loading-label="Loading
|
|
145
|
+
loading-label="Loading command center"
|
|
46
146
|
:on-retry="() => refresh()"
|
|
47
147
|
>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
<!-- Budget Gauge -->
|
|
64
|
-
<div class="mt-6 rounded-lg border border-default p-6">
|
|
65
|
-
<h3 class="mb-4 text-lg font-semibold text-highlighted">Budget</h3>
|
|
66
|
-
<div v-if="budgetUnlimited" class="text-sm text-muted">
|
|
67
|
-
Unlimited budget configured.
|
|
68
|
-
</div>
|
|
69
|
-
<div v-else class="space-y-3">
|
|
70
|
-
<div class="flex items-center justify-between text-sm">
|
|
71
|
-
<span class="text-muted">Used: {{ formatCurrency(budgetUsed) }}</span>
|
|
72
|
-
<span class="text-muted">Allocated: {{ formatCurrency(budgetAllocated) }}</span>
|
|
73
|
-
</div>
|
|
74
|
-
<UProgress :value="budgetPercent" :max="100" size="md" />
|
|
75
|
-
<p class="text-xs text-muted">{{ budgetPercent.toFixed(1) }}% of budget used</p>
|
|
76
|
-
</div>
|
|
77
|
-
</div>
|
|
78
|
-
|
|
79
|
-
<!-- Bottom Row: Tasks + Knowledge -->
|
|
80
|
-
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
81
|
-
<!-- Tasks Summary -->
|
|
82
|
-
<div class="rounded-lg border border-default p-6">
|
|
83
|
-
<h3 class="mb-4 text-lg font-semibold text-highlighted">Tasks</h3>
|
|
84
|
-
<div class="grid grid-cols-3 gap-4">
|
|
85
|
-
<div class="text-center">
|
|
86
|
-
<p class="text-2xl font-semibold text-highlighted">{{ overview?.tasks?.total ?? 0 }}</p>
|
|
87
|
-
<p class="text-xs text-muted">Total</p>
|
|
148
|
+
<div class="space-y-6">
|
|
149
|
+
<!-- Hero: greeting + today's cost -->
|
|
150
|
+
<UCard>
|
|
151
|
+
<div class="flex flex-col gap-2 md:flex-row md:items-baseline md:justify-between">
|
|
152
|
+
<div>
|
|
153
|
+
<h1 class="text-2xl font-bold">{{ greetingLabel }}.</h1>
|
|
154
|
+
<p class="text-sm text-muted mt-1">
|
|
155
|
+
<template v-if="data?.greeting?.role && data?.greeting?.company">
|
|
156
|
+
{{ data.greeting.role }} @ {{ data.greeting.company }}
|
|
157
|
+
</template>
|
|
158
|
+
<template v-else>
|
|
159
|
+
Set your profile in Settings to personalise this view.
|
|
160
|
+
</template>
|
|
161
|
+
</p>
|
|
88
162
|
</div>
|
|
89
|
-
<div class="text-
|
|
90
|
-
<
|
|
91
|
-
|
|
163
|
+
<div v-if="todayCost" class="flex items-baseline gap-6 text-right">
|
|
164
|
+
<div>
|
|
165
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider">
|
|
166
|
+
Today's cost
|
|
167
|
+
</p>
|
|
168
|
+
<p class="text-2xl font-bold">{{ formatCost(todayCost.total_usd) }}</p>
|
|
169
|
+
</div>
|
|
170
|
+
<div>
|
|
171
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider">
|
|
172
|
+
Calls
|
|
173
|
+
</p>
|
|
174
|
+
<p class="text-2xl font-bold">{{ todayCost.call_count }}</p>
|
|
175
|
+
</div>
|
|
176
|
+
<div>
|
|
177
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider">
|
|
178
|
+
Cache
|
|
179
|
+
</p>
|
|
180
|
+
<p class="text-2xl font-bold">
|
|
181
|
+
{{ (todayCost.cache_hit_rate * 100).toFixed(0) }}%
|
|
182
|
+
</p>
|
|
183
|
+
</div>
|
|
92
184
|
</div>
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
185
|
+
</div>
|
|
186
|
+
</UCard>
|
|
187
|
+
|
|
188
|
+
<!-- Two columns: projects + incidents -->
|
|
189
|
+
<div class="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-6">
|
|
190
|
+
<!-- Projects -->
|
|
191
|
+
<div>
|
|
192
|
+
<div class="flex items-baseline justify-between mb-3">
|
|
193
|
+
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted">
|
|
194
|
+
Projects
|
|
195
|
+
</h2>
|
|
196
|
+
<NuxtLink to="/settings" class="text-xs text-muted hover:text-primary">
|
|
197
|
+
Configure dirs →
|
|
198
|
+
</NuxtLink>
|
|
96
199
|
</div>
|
|
200
|
+
<DashboardState
|
|
201
|
+
:status="status"
|
|
202
|
+
:empty="!data?.projects?.length"
|
|
203
|
+
empty-title="No projects discovered yet"
|
|
204
|
+
empty-description="Add your project directories in Settings → Projects."
|
|
205
|
+
empty-icon="i-lucide-folder-open"
|
|
206
|
+
>
|
|
207
|
+
<div class="space-y-2">
|
|
208
|
+
<div
|
|
209
|
+
v-for="p in data?.projects"
|
|
210
|
+
:key="p.name"
|
|
211
|
+
class="rounded-lg border border-default p-3 hover:border-primary/40 transition-colors"
|
|
212
|
+
>
|
|
213
|
+
<div class="flex items-start justify-between gap-3">
|
|
214
|
+
<div class="min-w-0 flex-1">
|
|
215
|
+
<div class="flex items-center gap-2 mb-1">
|
|
216
|
+
<span class="text-sm font-semibold truncate">{{ p.name }}</span>
|
|
217
|
+
<UBadge
|
|
218
|
+
v-if="p.status"
|
|
219
|
+
:label="p.status"
|
|
220
|
+
:color="statusColor(p.status)"
|
|
221
|
+
variant="subtle"
|
|
222
|
+
size="xs"
|
|
223
|
+
/>
|
|
224
|
+
<UBadge
|
|
225
|
+
v-if="p.ecosystem"
|
|
226
|
+
:label="p.ecosystem"
|
|
227
|
+
color="primary"
|
|
228
|
+
variant="outline"
|
|
229
|
+
size="xs"
|
|
230
|
+
/>
|
|
231
|
+
</div>
|
|
232
|
+
<p class="text-xs text-muted font-mono truncate">{{ p.path }}</p>
|
|
233
|
+
<div class="flex items-center gap-2 mt-2 flex-wrap">
|
|
234
|
+
<UBadge
|
|
235
|
+
v-for="s in p.stack"
|
|
236
|
+
:key="s"
|
|
237
|
+
:label="s"
|
|
238
|
+
variant="soft"
|
|
239
|
+
size="xs"
|
|
240
|
+
/>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
<span
|
|
244
|
+
class="text-xs font-mono shrink-0"
|
|
245
|
+
:class="commitFreshness(p.last_commit_days).color"
|
|
246
|
+
>
|
|
247
|
+
{{ commitFreshness(p.last_commit_days).label }}
|
|
248
|
+
</span>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
</DashboardState>
|
|
97
253
|
</div>
|
|
98
|
-
</div>
|
|
99
254
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
<
|
|
255
|
+
<!-- Incidents + Quick actions -->
|
|
256
|
+
<div class="space-y-6">
|
|
257
|
+
<div>
|
|
258
|
+
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted mb-3">
|
|
259
|
+
Recent incidents
|
|
260
|
+
</h2>
|
|
261
|
+
<DashboardState
|
|
262
|
+
:status="status"
|
|
263
|
+
:empty="!data?.recent_incidents?.length"
|
|
264
|
+
empty-title="No incidents"
|
|
265
|
+
empty-description="Bypass uses and flow blocks show up here."
|
|
266
|
+
empty-icon="i-lucide-shield-check"
|
|
267
|
+
>
|
|
268
|
+
<div class="space-y-2">
|
|
269
|
+
<div
|
|
270
|
+
v-for="(i, idx) in data?.recent_incidents"
|
|
271
|
+
:key="idx"
|
|
272
|
+
class="rounded-lg border border-default p-3"
|
|
273
|
+
>
|
|
274
|
+
<div class="flex items-center gap-2 mb-1">
|
|
275
|
+
<UBadge
|
|
276
|
+
:label="i.kind"
|
|
277
|
+
:color="i.kind === 'bypass' ? 'warning' : 'error'"
|
|
278
|
+
variant="subtle"
|
|
279
|
+
size="xs"
|
|
280
|
+
/>
|
|
281
|
+
<span class="text-xs text-muted">{{ formatIncidentTs(i.ts) }}</span>
|
|
282
|
+
</div>
|
|
283
|
+
<p class="text-xs font-mono text-muted truncate" :title="i.reason">
|
|
284
|
+
{{ i.tool }} — {{ i.reason }}
|
|
285
|
+
</p>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
</DashboardState>
|
|
107
289
|
</div>
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
<
|
|
290
|
+
|
|
291
|
+
<div>
|
|
292
|
+
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted mb-3">
|
|
293
|
+
Quick actions
|
|
294
|
+
</h2>
|
|
295
|
+
<div class="space-y-1">
|
|
296
|
+
<button
|
|
297
|
+
v-for="a in data?.quick_actions"
|
|
298
|
+
:key="a.command"
|
|
299
|
+
type="button"
|
|
300
|
+
class="w-full text-left p-3 rounded-lg border border-default hover:border-primary/40 hover:bg-elevated/30 transition-colors"
|
|
301
|
+
@click="copyCommand(a.command)"
|
|
302
|
+
>
|
|
303
|
+
<div class="flex items-center gap-2">
|
|
304
|
+
<code class="text-sm font-mono font-semibold text-primary">{{ a.command }}</code>
|
|
305
|
+
<UIcon name="i-lucide-clipboard" class="size-3 text-muted ml-auto" />
|
|
306
|
+
</div>
|
|
307
|
+
<p class="text-xs text-muted mt-1">{{ a.description }}</p>
|
|
308
|
+
</button>
|
|
309
|
+
</div>
|
|
111
310
|
</div>
|
|
112
311
|
</div>
|
|
113
312
|
</div>
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
Binary file
|
package/scripts/dashboard-api.py
CHANGED
|
@@ -729,6 +729,176 @@ def persona_build(body: dict):
|
|
|
729
729
|
}
|
|
730
730
|
|
|
731
731
|
|
|
732
|
+
# --- Command center (PR66 v2.83.0) ---
|
|
733
|
+
|
|
734
|
+
@app.get("/api/overview/command-center")
|
|
735
|
+
def overview_command_center():
|
|
736
|
+
"""Telemetry-driven overview surfacing what the operator actually needs.
|
|
737
|
+
|
|
738
|
+
Returns greeting, today's cost, project list with stack + last-commit
|
|
739
|
+
+ status, recent enforcement incidents, and suggested quick actions.
|
|
740
|
+
"""
|
|
741
|
+
from core.profile import ProfileManager
|
|
742
|
+
from core.profile.manager import parse_projects_dirs
|
|
743
|
+
from core.runtime.llm_cost_telemetry import summarise
|
|
744
|
+
|
|
745
|
+
profile = ProfileManager().read()
|
|
746
|
+
today_cost = summarise(period="today")
|
|
747
|
+
|
|
748
|
+
return {
|
|
749
|
+
"greeting": {
|
|
750
|
+
"name": profile.name,
|
|
751
|
+
"role": profile.role,
|
|
752
|
+
"company": profile.company,
|
|
753
|
+
"language": profile.language,
|
|
754
|
+
},
|
|
755
|
+
"today_cost": {
|
|
756
|
+
"total_usd": today_cost.total_cost_usd,
|
|
757
|
+
"call_count": today_cost.call_count,
|
|
758
|
+
"tokens_in": today_cost.total_tokens_in,
|
|
759
|
+
"tokens_out": today_cost.total_tokens_out,
|
|
760
|
+
"cache_hit_rate": today_cost.cache_hit_rate,
|
|
761
|
+
},
|
|
762
|
+
"projects": _scan_projects(parse_projects_dirs(profile.projectsDir)),
|
|
763
|
+
"recent_incidents": _recent_incidents(limit=8),
|
|
764
|
+
"quick_actions": [
|
|
765
|
+
{"command": "/arka update", "description": "Sync projects + skills"},
|
|
766
|
+
{"command": "/arka costs", "description": "View detailed LLM cost breakdown"},
|
|
767
|
+
{"command": "/arka conclave", "description": "Convene the personal AI advisory board"},
|
|
768
|
+
{"command": "/dev review", "description": "Run a code review on the current branch"},
|
|
769
|
+
],
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _scan_projects(projects_dirs: list[str]) -> list[dict]:
|
|
774
|
+
"""Read each project descriptor and enrich with last-commit info.
|
|
775
|
+
|
|
776
|
+
Best-effort: never raises. Returns an empty list when descriptors
|
|
777
|
+
or scan directories are missing.
|
|
778
|
+
"""
|
|
779
|
+
from datetime import datetime, timezone
|
|
780
|
+
descriptor_dir = Path.home() / ".arkaos" / "projects"
|
|
781
|
+
if not descriptor_dir.exists():
|
|
782
|
+
return []
|
|
783
|
+
|
|
784
|
+
rows: list[dict] = []
|
|
785
|
+
for entry in sorted(descriptor_dir.iterdir()):
|
|
786
|
+
if entry.is_dir():
|
|
787
|
+
descriptor = entry / "PROJECT.md"
|
|
788
|
+
elif entry.suffix == ".md":
|
|
789
|
+
descriptor = entry
|
|
790
|
+
else:
|
|
791
|
+
continue
|
|
792
|
+
if not descriptor.exists():
|
|
793
|
+
continue
|
|
794
|
+
try:
|
|
795
|
+
data = _parse_descriptor(descriptor)
|
|
796
|
+
except Exception:
|
|
797
|
+
continue
|
|
798
|
+
rows.append(data)
|
|
799
|
+
|
|
800
|
+
# Sort by last_commit_days ascending (most recently active first).
|
|
801
|
+
rows.sort(key=lambda r: (
|
|
802
|
+
r.get("last_commit_days") if r.get("last_commit_days") is not None else 9999,
|
|
803
|
+
r.get("name", ""),
|
|
804
|
+
))
|
|
805
|
+
return rows[:30] # cap to keep payload bounded
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _parse_descriptor(path: Path) -> dict:
|
|
809
|
+
"""Extract frontmatter + last-commit-days from a project descriptor."""
|
|
810
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
811
|
+
fm: dict = {}
|
|
812
|
+
if text.startswith("---\n"):
|
|
813
|
+
end = text.find("\n---", 4)
|
|
814
|
+
if end > 0:
|
|
815
|
+
import yaml as _yaml
|
|
816
|
+
try:
|
|
817
|
+
fm = _yaml.safe_load(text[4:end]) or {}
|
|
818
|
+
except Exception:
|
|
819
|
+
fm = {}
|
|
820
|
+
name = str(fm.get("name") or path.stem)
|
|
821
|
+
project_path = str(fm.get("path") or "")
|
|
822
|
+
stack = fm.get("stack") or []
|
|
823
|
+
if not isinstance(stack, list):
|
|
824
|
+
stack = [str(stack)]
|
|
825
|
+
status = str(fm.get("status") or "unknown")
|
|
826
|
+
ecosystem = str(fm.get("ecosystem") or "")
|
|
827
|
+
last_commit_days = _last_commit_days(project_path) if project_path else None
|
|
828
|
+
return {
|
|
829
|
+
"name": name,
|
|
830
|
+
"path": project_path,
|
|
831
|
+
"stack": [str(s) for s in stack][:6],
|
|
832
|
+
"status": status,
|
|
833
|
+
"ecosystem": ecosystem,
|
|
834
|
+
"last_commit_days": last_commit_days,
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _last_commit_days(project_path: str) -> Optional[int]:
|
|
839
|
+
"""Return days since the last git commit, or None when unknown."""
|
|
840
|
+
import os
|
|
841
|
+
if not project_path or not os.path.isdir(project_path):
|
|
842
|
+
return None
|
|
843
|
+
git_dir = os.path.join(project_path, ".git")
|
|
844
|
+
if not os.path.exists(git_dir):
|
|
845
|
+
return None
|
|
846
|
+
try:
|
|
847
|
+
from datetime import datetime, timezone
|
|
848
|
+
result = subprocess.run(
|
|
849
|
+
["git", "-C", project_path, "log", "-1", "--format=%ct"],
|
|
850
|
+
capture_output=True, text=True, timeout=3, check=False,
|
|
851
|
+
)
|
|
852
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
853
|
+
return None
|
|
854
|
+
committed_at = datetime.fromtimestamp(int(result.stdout.strip()), tz=timezone.utc)
|
|
855
|
+
delta = datetime.now(timezone.utc) - committed_at
|
|
856
|
+
return max(0, delta.days)
|
|
857
|
+
except (OSError, ValueError, subprocess.TimeoutExpired):
|
|
858
|
+
return None
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _recent_incidents(limit: int = 8) -> list[dict]:
|
|
862
|
+
"""Recent enforcement / bypass events from telemetry.
|
|
863
|
+
|
|
864
|
+
Reads the tail of ~/.arkaos/telemetry/enforcement.jsonl and keeps
|
|
865
|
+
rows where the operator hit a bypass or a flow-marker block. The
|
|
866
|
+
UI uses these to show "what went sideways recently".
|
|
867
|
+
"""
|
|
868
|
+
log = Path.home() / ".arkaos" / "telemetry" / "enforcement.jsonl"
|
|
869
|
+
if not log.exists():
|
|
870
|
+
return []
|
|
871
|
+
try:
|
|
872
|
+
text = log.read_text(encoding="utf-8", errors="replace")
|
|
873
|
+
except OSError:
|
|
874
|
+
return []
|
|
875
|
+
rows: list[dict] = []
|
|
876
|
+
# Walk lines in reverse; stop when we've gathered `limit` matches.
|
|
877
|
+
for line in reversed(text.splitlines()):
|
|
878
|
+
if len(rows) >= limit:
|
|
879
|
+
break
|
|
880
|
+
if not line.strip():
|
|
881
|
+
continue
|
|
882
|
+
try:
|
|
883
|
+
entry = json.loads(line)
|
|
884
|
+
except json.JSONDecodeError:
|
|
885
|
+
continue
|
|
886
|
+
# Interesting events: bypass used OR allow=False (a block).
|
|
887
|
+
bypass = bool(entry.get("bypass_used"))
|
|
888
|
+
allowed = entry.get("allow")
|
|
889
|
+
if not bypass and allowed is not False:
|
|
890
|
+
continue
|
|
891
|
+
rows.append({
|
|
892
|
+
"ts": entry.get("ts", ""),
|
|
893
|
+
"tool": entry.get("tool", ""),
|
|
894
|
+
"reason": entry.get("reason", ""),
|
|
895
|
+
"cwd": entry.get("cwd", ""),
|
|
896
|
+
"bypass_used": bypass,
|
|
897
|
+
"kind": "bypass" if bypass else "blocked",
|
|
898
|
+
})
|
|
899
|
+
return rows
|
|
900
|
+
|
|
901
|
+
|
|
732
902
|
# --- LLM Costs (PR65 v2.82.0) ---
|
|
733
903
|
|
|
734
904
|
@app.get("/api/llm-costs")
|