arkaos 2.86.0 → 2.88.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.86.0
1
+ 2.88.0
@@ -1,14 +1,153 @@
1
1
  <script setup lang="ts">
2
- import type { HealthCheck } from '~/types'
2
+ // PR70 v2.87.0 Health page polish.
3
+ // - 30s auto-refresh (paused while tab is hidden) + manual refresh
4
+ // - Last-checked timestamp in header
5
+ // - Severity-aware rendering (fail = red, warn = yellow)
6
+ // - Copy-fix button when a check has a fix command
7
+ // - Healthy banner ignores warnings (only blocking failures matter)
8
+
9
+ interface HealthCheck {
10
+ name: string
11
+ passed: boolean
12
+ fix: string
13
+ severity: 'fail' | 'warn'
14
+ }
15
+
16
+ interface HealthPayload {
17
+ checks: HealthCheck[]
18
+ passed: number
19
+ total: number
20
+ failed_blocking: number
21
+ warning_count: number
22
+ healthy: boolean
23
+ ts: string
24
+ }
3
25
 
4
26
  const { fetchApi } = useApi()
27
+ const toast = useToast()
28
+
29
+ const {
30
+ data,
31
+ status,
32
+ error,
33
+ refresh,
34
+ } = await fetchApi<HealthPayload>('/api/health')
35
+
36
+ // ─── Auto-refresh ───────────────────────────────────────────────────────
37
+
38
+ let pollTimer: ReturnType<typeof setInterval> | null = null
39
+
40
+ function startPolling() {
41
+ stopPolling()
42
+ pollTimer = setInterval(() => {
43
+ refresh()
44
+ }, 30_000)
45
+ }
46
+
47
+ function stopPolling() {
48
+ if (pollTimer !== null) {
49
+ clearInterval(pollTimer)
50
+ pollTimer = null
51
+ }
52
+ }
53
+
54
+ function handleVisibility() {
55
+ if (typeof document === 'undefined') return
56
+ if (document.hidden) {
57
+ stopPolling()
58
+ } else {
59
+ refresh()
60
+ startPolling()
61
+ }
62
+ }
63
+
64
+ onMounted(() => {
65
+ if (typeof document !== 'undefined') {
66
+ document.addEventListener('visibilitychange', handleVisibility)
67
+ }
68
+ startPolling()
69
+ })
70
+
71
+ onBeforeUnmount(() => {
72
+ stopPolling()
73
+ if (typeof document !== 'undefined') {
74
+ document.removeEventListener('visibilitychange', handleVisibility)
75
+ }
76
+ })
77
+
78
+ // ─── Copy fix ───────────────────────────────────────────────────────────
79
+
80
+ const copied = ref<string | null>(null)
81
+ let copyTimer: ReturnType<typeof setTimeout> | null = null
82
+
83
+ async function copyFix(check: HealthCheck) {
84
+ if (!check.fix) return
85
+ if (typeof navigator === 'undefined' || !navigator.clipboard) {
86
+ toast.add({ title: 'Clipboard unavailable', color: 'warning' })
87
+ return
88
+ }
89
+ try {
90
+ await navigator.clipboard.writeText(check.fix)
91
+ copied.value = check.name
92
+ if (copyTimer) clearTimeout(copyTimer)
93
+ copyTimer = setTimeout(() => { copied.value = null; copyTimer = null }, 1500)
94
+ toast.add({
95
+ title: 'Fix copied',
96
+ description: check.fix,
97
+ color: 'success',
98
+ })
99
+ } catch (err) {
100
+ toast.add({
101
+ title: 'Copy failed',
102
+ description: err instanceof Error ? err.message : 'unknown error',
103
+ color: 'error',
104
+ })
105
+ }
106
+ }
5
107
 
6
- const { data, status, error, refresh } = await fetchApi<{ checks: HealthCheck[], passed: number, total: number }>('/api/health')
108
+ onBeforeUnmount(() => {
109
+ if (copyTimer) clearTimeout(copyTimer)
110
+ })
7
111
 
8
- const checks = computed(() => data.value?.checks ?? [])
112
+ // ─── Format helpers ─────────────────────────────────────────────────────
113
+
114
+ function formatTs(iso: string | undefined): string {
115
+ if (!iso) return ''
116
+ try {
117
+ return new Intl.DateTimeFormat('en-US', {
118
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
119
+ }).format(new Date(iso))
120
+ } catch {
121
+ return iso
122
+ }
123
+ }
124
+
125
+ type CheckStatus = 'pass' | 'warn' | 'fail'
126
+
127
+ function statusOf(c: HealthCheck): CheckStatus {
128
+ if (c.passed) return 'pass'
129
+ return c.severity === 'warn' ? 'warn' : 'fail'
130
+ }
131
+
132
+ const STATUS_META: Record<CheckStatus, { icon: string; color: string; label: string }> = {
133
+ pass: { icon: 'i-lucide-check-circle', color: 'text-green-500', label: 'Pass' },
134
+ warn: { icon: 'i-lucide-alert-circle', color: 'text-yellow-500', label: 'Warn' },
135
+ fail: { icon: 'i-lucide-x-circle', color: 'text-red-500', label: 'Fail' },
136
+ }
137
+
138
+ function statusBadgeColor(s: CheckStatus): 'success' | 'warning' | 'error' {
139
+ return s === 'pass' ? 'success' : s === 'warn' ? 'warning' : 'error'
140
+ }
141
+
142
+ // ─── Aggregate display ──────────────────────────────────────────────────
143
+
144
+ const checks = computed<HealthCheck[]>(() => data.value?.checks ?? [])
9
145
  const passed = computed(() => data.value?.passed ?? 0)
10
146
  const total = computed(() => data.value?.total ?? 0)
11
- const allPassed = computed(() => passed.value === total.value && total.value > 0)
147
+ const failedBlocking = computed(() => data.value?.failed_blocking ?? 0)
148
+ const warningCount = computed(() => data.value?.warning_count ?? 0)
149
+ const allPassed = computed(() => failedBlocking.value === 0 && warningCount.value === 0 && total.value > 0)
150
+ const someWarnings = computed(() => failedBlocking.value === 0 && warningCount.value > 0)
12
151
  </script>
13
152
 
14
153
  <template>
@@ -19,11 +158,28 @@ const allPassed = computed(() => passed.value === total.value && total.value > 0
19
158
  <UDashboardSidebarCollapse />
20
159
  </template>
21
160
  <template #trailing>
161
+ <span
162
+ v-if="data?.ts"
163
+ class="text-xs text-muted"
164
+ :title="data.ts"
165
+ >
166
+ Last checked {{ formatTs(data.ts) }}
167
+ </span>
22
168
  <UBadge
23
169
  v-if="data"
24
170
  :label="`${passed}/${total}`"
25
- :color="allPassed ? 'success' : 'warning'"
171
+ :color="allPassed ? 'success' : someWarnings ? 'warning' : 'error'"
26
172
  variant="subtle"
173
+ class="ml-3"
174
+ />
175
+ </template>
176
+ <template #right>
177
+ <UButton
178
+ label="Refresh"
179
+ variant="ghost"
180
+ icon="i-lucide-refresh-cw"
181
+ size="sm"
182
+ @click="refresh()"
27
183
  />
28
184
  </template>
29
185
  </UDashboardNavbar>
@@ -39,45 +195,81 @@ const allPassed = computed(() => passed.value === total.value && total.value > 0
39
195
  loading-label="Loading health checks"
40
196
  :on-retry="() => refresh()"
41
197
  >
42
- <!-- Overall Status -->
198
+ <!-- Overall banner -->
43
199
  <div
44
200
  class="mb-6 rounded-lg border p-6 text-center"
45
- :class="allPassed ? 'border-green-500/30 bg-green-500/5' : 'border-yellow-500/30 bg-yellow-500/5'"
201
+ :class="allPassed
202
+ ? 'border-green-500/30 bg-green-500/5'
203
+ : someWarnings
204
+ ? 'border-yellow-500/30 bg-yellow-500/5'
205
+ : 'border-red-500/30 bg-red-500/5'"
46
206
  >
47
207
  <UIcon
48
- :name="allPassed ? 'i-lucide-check-circle' : 'i-lucide-alert-circle'"
49
- :class="allPassed ? 'text-green-500' : 'text-yellow-500'"
208
+ :name="allPassed
209
+ ? 'i-lucide-check-circle'
210
+ : someWarnings
211
+ ? 'i-lucide-alert-circle'
212
+ : 'i-lucide-x-circle'"
213
+ :class="allPassed
214
+ ? 'text-green-500'
215
+ : someWarnings ? 'text-yellow-500' : 'text-red-500'"
50
216
  class="size-12"
51
217
  />
52
218
  <p class="mt-2 text-lg font-semibold text-highlighted">
53
- {{ allPassed ? 'All Checks Passing' : `${total - passed} Check(s) Failing` }}
219
+ <template v-if="allPassed">All Checks Passing</template>
220
+ <template v-else-if="someWarnings">
221
+ {{ warningCount }} Warning{{ warningCount === 1 ? '' : 's' }}
222
+ </template>
223
+ <template v-else>
224
+ {{ failedBlocking }} Blocking Failure{{ failedBlocking === 1 ? '' : 's' }}
225
+ </template>
226
+ </p>
227
+ <p class="text-sm text-muted">
228
+ {{ passed }} of {{ total }} checks passed
229
+ <template v-if="warningCount && failedBlocking">
230
+ · {{ warningCount }} warn · {{ failedBlocking }} blocking
231
+ </template>
54
232
  </p>
55
- <p class="text-sm text-muted">{{ passed }} of {{ total }} checks passed</p>
56
233
  </div>
57
234
 
58
- <!-- Individual Checks -->
235
+ <!-- Check list -->
59
236
  <div class="space-y-3">
60
237
  <div
61
238
  v-for="check in checks"
62
239
  :key="check.name"
63
- class="flex items-start gap-3 rounded-lg border border-default p-4"
240
+ class="flex items-start gap-3 rounded-lg border p-4"
241
+ :class="{
242
+ 'border-default': check.passed,
243
+ 'border-yellow-500/30 bg-yellow-500/5': !check.passed && check.severity === 'warn',
244
+ 'border-red-500/30 bg-red-500/5': !check.passed && check.severity === 'fail',
245
+ }"
64
246
  >
65
247
  <UIcon
66
- :name="check.passed ? 'i-lucide-check-circle' : 'i-lucide-x-circle'"
67
- :class="check.passed ? 'text-green-500' : 'text-red-500'"
248
+ :name="STATUS_META[statusOf(check)].icon"
249
+ :class="STATUS_META[statusOf(check)].color"
68
250
  class="mt-0.5 size-5 shrink-0"
69
251
  />
70
- <div class="flex-1">
252
+ <div class="flex-1 min-w-0">
71
253
  <h4 class="font-medium text-highlighted">{{ check.name }}</h4>
72
254
  <p v-if="!check.passed && check.fix" class="mt-1 text-sm text-muted">
73
- Fix: {{ check.fix }}
255
+ Fix: <code class="font-mono text-xs">{{ check.fix }}</code>
74
256
  </p>
75
257
  </div>
258
+ <UButton
259
+ v-if="!check.passed && check.fix"
260
+ :icon="copied === check.name ? 'i-lucide-check' : 'i-lucide-copy'"
261
+ :color="copied === check.name ? 'success' : 'neutral'"
262
+ variant="ghost"
263
+ size="xs"
264
+ aria-label="Copy fix command"
265
+ @click="copyFix(check)"
266
+ />
76
267
  <UBadge
77
- :label="check.passed ? 'Pass' : 'Fail'"
78
- :color="check.passed ? 'success' : 'error'"
268
+ :label="STATUS_META[statusOf(check)].label"
269
+ :color="statusBadgeColor(statusOf(check))"
79
270
  variant="subtle"
80
271
  size="sm"
272
+ class="shrink-0"
81
273
  />
82
274
  </div>
83
275
  </div>
@@ -324,6 +324,90 @@ async function handleSearch() {
324
324
  function formatScore(score: number): string {
325
325
  return `${(score * 100).toFixed(0)}%`
326
326
  }
327
+
328
+ // PR71 v2.88.0 — delete all chunks from a given source.
329
+
330
+ const deletingSource = ref<string | null>(null)
331
+
332
+ async function askDeleteSource(source: string) {
333
+ if (!source) return
334
+ if (typeof window === 'undefined') return
335
+ const ok = window.confirm(
336
+ `Delete every indexed chunk from this source?\n\n${source}\n\n`
337
+ + 'This removes the source from search results but does not delete the original file. '
338
+ + 'You can re-ingest the source later if needed.',
339
+ )
340
+ if (!ok) return
341
+ await deleteSource(source)
342
+ }
343
+
344
+ async function deleteSource(source: string) {
345
+ deletingSource.value = source
346
+ try {
347
+ const res = await $fetch<{ deleted?: number, source?: string, error?: string }>(
348
+ `${apiBase}/api/knowledge/sources`,
349
+ { method: 'DELETE', query: { source } },
350
+ )
351
+ if (res.error) {
352
+ toast.add({
353
+ title: 'Delete failed',
354
+ description: res.error,
355
+ color: 'error',
356
+ })
357
+ return
358
+ }
359
+ const deleted = res.deleted ?? 0
360
+ // Drop the matching rows from the in-memory list without a full re-fetch.
361
+ searchResults.value = searchResults.value.filter((r) => r.source !== source)
362
+ searchTotal.value = searchResults.value.length
363
+ // Refresh stats so the chunk count in the header updates.
364
+ if (typeof refresh === 'function') {
365
+ await refresh()
366
+ }
367
+ toast.add({
368
+ title: deleted > 0
369
+ ? `Deleted ${deleted} chunk${deleted === 1 ? '' : 's'}`
370
+ : 'Nothing to delete',
371
+ description: source,
372
+ color: 'success',
373
+ })
374
+ } catch (err) {
375
+ toast.add({
376
+ title: 'Delete failed',
377
+ description: err instanceof Error ? err.message : 'unknown error',
378
+ color: 'error',
379
+ })
380
+ } finally {
381
+ deletingSource.value = null
382
+ }
383
+ }
384
+
385
+ // PR71 — highlight the search query in the preview text.
386
+ // Tolerates malformed regex (escapes special characters) and HTML-
387
+ // escapes the input so v-html'd output is safe from XSS via DB rows.
388
+ function highlightMatches(text: string, query: string): string {
389
+ const safe = escapeHtml(text || '')
390
+ const q = (query || '').trim()
391
+ if (!q) return safe
392
+ const pattern = new RegExp(`(${escapeRegex(q)})`, 'gi')
393
+ return safe.replace(
394
+ pattern,
395
+ '<mark class="bg-primary/20 text-primary rounded px-0.5">$1</mark>',
396
+ )
397
+ }
398
+
399
+ function escapeHtml(value: string): string {
400
+ return value
401
+ .replace(/&/g, '&amp;')
402
+ .replace(/</g, '&lt;')
403
+ .replace(/>/g, '&gt;')
404
+ .replace(/"/g, '&quot;')
405
+ .replace(/'/g, '&#39;')
406
+ }
407
+
408
+ function escapeRegex(value: string): string {
409
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
410
+ }
327
411
  </script>
328
412
 
329
413
  <template>
@@ -771,17 +855,30 @@ function formatScore(score: number): string {
771
855
  {{ result.heading }}
772
856
  </span>
773
857
  </div>
774
- <span class="text-xs text-muted whitespace-nowrap">
775
- Score: {{ formatScore(result.score) }}
776
- </span>
858
+ <div class="flex items-center gap-2 shrink-0">
859
+ <span class="text-xs text-muted whitespace-nowrap">
860
+ Score: {{ formatScore(result.score) }}
861
+ </span>
862
+ <UButton
863
+ v-if="result.source"
864
+ :icon="deletingSource === result.source
865
+ ? 'i-lucide-loader-2'
866
+ : 'i-lucide-trash-2'"
867
+ :loading="deletingSource === result.source"
868
+ variant="ghost"
869
+ color="error"
870
+ size="xs"
871
+ aria-label="Delete all chunks from this source"
872
+ @click.stop="askDeleteSource(result.source)"
873
+ />
874
+ </div>
777
875
  </div>
778
876
  <p v-if="result.source" class="text-xs text-muted mb-1 truncate">
779
877
  <UIcon name="i-lucide-file-text" class="size-3 inline-block mr-1" />
780
878
  {{ result.source }}
781
879
  </p>
782
- <p class="text-sm text-muted line-clamp-3">
783
- {{ result.text || result.content }}
784
- </p>
880
+ <!-- PR71 v2.88.0 — highlight query matches in the preview -->
881
+ <p class="text-sm text-muted line-clamp-3" v-html="highlightMatches(result.text || result.content, searchQuery)" />
785
882
  </div>
786
883
  </div>
787
884
 
@@ -175,6 +175,9 @@ export interface HealthCheck {
175
175
  name: string
176
176
  passed: boolean
177
177
  fix: string
178
+ // PR70 v2.87.0 — backend now tags every check with a severity.
179
+ // 'fail' is must-pass; 'warn' is recommended but non-blocking.
180
+ severity?: 'fail' | 'warn'
178
181
  }
179
182
 
180
183
  export interface Persona {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.86.0",
3
+ "version": "2.88.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.86.0"
3
+ version = "2.88.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"}
@@ -612,20 +612,66 @@ def knowledge_search(q: str = Query(...), top_k: int = Query(5)):
612
612
  return {"results": results, "query": q, "total": len(results)}
613
613
 
614
614
 
615
+ @app.delete("/api/knowledge/sources")
616
+ def knowledge_delete_source(source: str = Query(...)):
617
+ """PR71 v2.88.0 — remove all chunks from a given source.
618
+
619
+ Operators sometimes ingest a noisy / wrong source and want to nuke
620
+ every chunk that came from it without rebuilding the whole vector
621
+ DB. The vector store already exposes `remove_file(source)` —
622
+ this endpoint just exposes it on the wire.
623
+
624
+ Returns ``{deleted: N, source: "..."}``. Refuses empty source
625
+ paths so a runaway client doesn't accidentally request "delete
626
+ everything that has no source".
627
+ """
628
+ clean = (source or "").strip()
629
+ if not clean:
630
+ return {"error": "source query param is required"}
631
+ store = _get_vector_store()
632
+ if not store:
633
+ return {"error": "vector store unavailable", "deleted": 0}
634
+ try:
635
+ deleted = store.remove_file(clean)
636
+ except Exception as exc: # noqa: BLE001 — surface as 200+error
637
+ return {"error": f"delete failed: {exc}", "deleted": 0}
638
+ return {"deleted": int(deleted), "source": clean}
639
+
640
+
615
641
  @app.get("/api/health")
616
642
  def health():
617
- checks = []
643
+ """PR70 v2.87.0 — per-check severity + response timestamp.
644
+
645
+ Each check now carries a `severity` field:
646
+ - "fail" — must-pass; missing breaks ArkaOS
647
+ - "warn" — recommended; missing means a degraded but workable env
648
+
649
+ Response also carries `ts` so the UI can show "last checked".
650
+ Frontend polls every 30s and surfaces copy-fix buttons.
651
+ """
652
+ from datetime import datetime, timezone
653
+
654
+ checks: list[dict] = []
618
655
  arkaos_home = Path.home() / ".arkaos"
619
656
 
620
- def check(name, condition, fix=""):
621
- checks.append({"name": name, "passed": condition, "fix": fix})
657
+ def check(name: str, condition: bool, fix: str = "", severity: str = "fail"):
658
+ checks.append({
659
+ "name": name,
660
+ "passed": condition,
661
+ "fix": fix,
662
+ "severity": severity,
663
+ })
622
664
 
623
- check("install_dir", arkaos_home.exists(), "Run: npx arkaos install")
624
- check("manifest", (arkaos_home / "install-manifest.json").exists(), "Run: npx arkaos install")
665
+ check("install_dir", arkaos_home.exists(), "npx arkaos install")
666
+ check("manifest", (arkaos_home / "install-manifest.json").exists(),
667
+ "npx arkaos install")
625
668
  check("constitution", (ARKAOS_ROOT / "config" / "constitution.yaml").exists())
626
- check("agents_registry", (ARKAOS_ROOT / "knowledge" / "agents-registry-v2.json").exists())
627
- check("commands_registry", (ARKAOS_ROOT / "knowledge" / "commands-registry-v2.json").exists())
628
- check("hooks_dir", (arkaos_home / "config" / "hooks").exists(), "Run: npx arkaos install")
669
+ check("agents_registry",
670
+ (ARKAOS_ROOT / "knowledge" / "agents-registry-v2.json").exists())
671
+ check("commands_registry",
672
+ (ARKAOS_ROOT / "knowledge" / "commands-registry-v2.json").exists())
673
+ check("hooks_dir", (arkaos_home / "config" / "hooks").exists(),
674
+ "npx arkaos install")
629
675
 
630
676
  try:
631
677
  subprocess.run(["python3", "--version"], capture_output=True, timeout=2)
@@ -633,10 +679,34 @@ def health():
633
679
  except Exception:
634
680
  check("python", False, "Install Python 3.11+")
635
681
 
636
- check("knowledge_db", (arkaos_home / "knowledge.db").exists(), "Run: npx arkaos index")
682
+ # Telemetry + knowledge warn-only; missing them is a degraded
683
+ # but workable state (new installs, never-indexed-anything).
684
+ check("knowledge_db", (arkaos_home / "knowledge.db").exists(),
685
+ "Open the Knowledge tab and ingest a source",
686
+ severity="warn")
687
+ check("profile",
688
+ (arkaos_home / "profile.json").exists(),
689
+ "Open Settings → Profile to introduce yourself",
690
+ severity="warn")
637
691
 
638
692
  passed = sum(1 for c in checks if c["passed"])
639
- return {"checks": checks, "passed": passed, "total": len(checks), "healthy": passed == len(checks)}
693
+ failed_blocking = sum(
694
+ 1 for c in checks
695
+ if not c["passed"] and c["severity"] == "fail"
696
+ )
697
+ warning_count = sum(
698
+ 1 for c in checks
699
+ if not c["passed"] and c["severity"] == "warn"
700
+ )
701
+ return {
702
+ "checks": checks,
703
+ "passed": passed,
704
+ "total": len(checks),
705
+ "failed_blocking": failed_blocking,
706
+ "warning_count": warning_count,
707
+ "healthy": failed_blocking == 0,
708
+ "ts": datetime.now(timezone.utc).isoformat(),
709
+ }
640
710
 
641
711
 
642
712
  # --- Personas ---