diff --git a/Packs/pai-core-install/src/skills/CORE/Tools/heartbeat-triage.sh b/Packs/pai-core-install/src/skills/CORE/Tools/heartbeat-triage.sh new file mode 100755 index 000000000..9ce71917c --- /dev/null +++ b/Packs/pai-core-install/src/skills/CORE/Tools/heartbeat-triage.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +# PAI Heartbeat Triage — invoked by IronClaw on a cron schedule. +# Lightweight: uses haiku with no tools/hooks to decide if escalation is needed. + +STATE_DIR="$HOME/.claude/MEMORY/STATE" +STATE_FILE="$STATE_DIR/daemon-state.json" +LOG_DIR="$HOME/.claude/logs" + +mkdir -p "$STATE_DIR" "$LOG_DIR" + +# Bootstrap state file on first run +if [[ ! -f "$STATE_FILE" ]]; then + printf '{"last_check":0,"status":"idle","escalation_count":0}\n' > "$STATE_FILE" +fi + +prev_state=$(cat "$STATE_FILE") +last_check=$(echo "$prev_state" | jq -r '.last_check // 0') +now=$(date +%s) + +# -- Gather signals since last check -- +# Recent failures from the last hour of logs +recent_failures=$(find "$LOG_DIR" -name '*.error' -newer "$STATE_FILE" -exec tail -1 {} + 2>/dev/null | head -20 || true) + +# New learnings captured since last heartbeat +learnings="" +if [[ -d "$HOME/.claude/MEMORY" ]]; then + learnings=$(find "$HOME/.claude/MEMORY" -name '*.md' -newer "$STATE_FILE" -exec basename {} \; 2>/dev/null | head -10 || true) +fi + +# -- Triage via lightweight Claude session (no tools, no hooks) -- +triage_prompt="Previous state: $prev_state +Time since last check: $(( now - last_check ))s +Recent failures: ${recent_failures:-none} +New learnings: ${learnings:-none} + +Respond with ONLY valid JSON: {\"action\":\"idle|escalate\",\"reason\":\"...\",\"priority\":\"low|medium|high\"}" + +triage_result=$(claude --print \ + --model haiku \ + --tools '' \ + --output-format text \ + --setting-sources '' \ + --system-prompt "You are a PAI system health triage agent. Analyze signals and decide: idle (nothing actionable) or escalate (needs a full session). Be conservative — only escalate for real issues." \ + "$triage_prompt" 2>/dev/null) || triage_result='{"action":"idle","reason":"triage call failed","priority":"low"}' + +# -- Persist updated state -- +action=$(echo "$triage_result" | jq -r '.action // "idle"') +reason=$(echo "$triage_result" | jq -r '.reason // "no reason"') +esc_count=$(echo "$prev_state" | jq -r '.escalation_count // 0') + +if [[ "$action" == "escalate" ]]; then + esc_count=$(( esc_count + 1 )) +fi + +jq -n \ + --argjson now "$now" \ + --arg status "$action" \ + --arg reason "$reason" \ + --argjson esc_count "$esc_count" \ + '{last_check: $now, status: $status, last_reason: $reason, escalation_count: $esc_count}' \ + > "$STATE_FILE" + +# -- Escalate: spawn a full Claude session with hooks enabled -- +if [[ "$action" == "escalate" ]]; then + nohup claude --print \ + --model sonnet \ + --system-prompt "PAI escalation session. Triage reason: $reason. Investigate and resolve." \ + "Heartbeat triage escalated. Reason: $reason. Recent failures: ${recent_failures:-none}" \ + >> "$LOG_DIR/escalation-$(date +%Y%m%d-%H%M%S).log" 2>&1 & +fi diff --git a/Packs/pai-core-install/src/skills/CORE/docs/HEARTBEAT.md b/Packs/pai-core-install/src/skills/CORE/docs/HEARTBEAT.md new file mode 100644 index 000000000..2b0513596 --- /dev/null +++ b/Packs/pai-core-install/src/skills/CORE/docs/HEARTBEAT.md @@ -0,0 +1,85 @@ +# PAI Heartbeat Triage + +Periodic health check for the PAI system, designed to be invoked by IronClaw's cron scheduler. + +## Overview + +The heartbeat pattern separates **triage** (cheap, fast, read-only) from **action** (full session with tools and hooks). This keeps background monitoring costs minimal while preserving the ability to escalate when something actually needs attention. + +``` +IronClaw cron --> heartbeat-triage.sh --> lightweight Claude (haiku, no tools) + | + idle? done. + | + escalate? --> full Claude session (sonnet, hooks enabled) +``` + +## IronClaw Routine Configuration + +Register the heartbeat as an IronClaw routine: + +```json +{ + "id": "pai-heartbeat", + "schedule": "*/15 * * * *", + "command": "Packs/pai-core-install/src/skills/CORE/Tools/heartbeat-triage.sh", + "timeout_seconds": 30, + "retry": { + "max_attempts": 1, + "backoff_ms": 0 + }, + "tags": ["health", "triage"] +} +``` + +The 15-minute interval balances responsiveness with API cost. Adjust based on system activity. + +## Invocation Modes + +| Aspect | Lightweight Triage | Full Escalation | +|---|---|---| +| **Model** | haiku | sonnet | +| **Tools** | Disabled (`--tools ''`) | All available | +| **Hooks** | Disabled (`--setting-sources ''`) | All enabled | +| **Output** | JSON decision only | Investigative session | +| **Cost** | Minimal | Standard | +| **Duration** | < 5 seconds | Variable | +| **Trigger** | IronClaw cron | Triage script (background) | + +## State Persistence + +Triage state lives at `~/.claude/MEMORY/STATE/daemon-state.json`: + +```json +{ + "last_check": 1708185600, + "status": "idle", + "last_reason": "no actionable signals", + "escalation_count": 0 +} +``` + +| Field | Purpose | +|---|---| +| `last_check` | Unix timestamp of last triage run | +| `status` | Result of last triage (`idle` or `escalate`) | +| `last_reason` | Human-readable explanation from triage model | +| `escalation_count` | Running total of escalations for observability | + +The state file also serves as a timestamp reference -- `find -newer` uses it to scope signal gathering to only what's changed since the last check. + +## Security Model + +**Triage session (haiku):** +- Cannot execute tools (`--tools ''`) +- Cannot trigger hooks (`--setting-sources ''`) +- Read-only analysis of log snippets and file names +- Can only output a JSON verdict + +**Escalated session (sonnet):** +- Full tool access for investigation and remediation +- All security hooks active (SecurityValidator, etc.) +- Runs as a background process with output logged +- Standard PAI permission model applies + +This two-tier design ensures that the high-frequency triage path has zero write capability, while escalated sessions get the full security stack. diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/.gitignore b/Packs/pai-telos-skill/src/DashboardTemplate/.gitignore index c2b5c41e2..e92dcc5f5 100755 --- a/Packs/pai-telos-skill/src/DashboardTemplate/.gitignore +++ b/Packs/pai-telos-skill/src/DashboardTemplate/.gitignore @@ -13,6 +13,7 @@ coverage # logs logs +!app/api/**/logs/ *.log report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/agents/[id]/page.tsx b/Packs/pai-telos-skill/src/DashboardTemplate/App/agents/[id]/page.tsx new file mode 100644 index 000000000..6fba430bf --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/agents/[id]/page.tsx @@ -0,0 +1,229 @@ +"use client" + +import { useState, useEffect } from "react" +import { useParams, useRouter } from "next/navigation" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Bot, ArrowLeft, XCircle, RotateCcw } from "lucide-react" +import type { PAIAgentDetail, AgentEvent, AgentStatus } from "@/types/pai" + +const statusVariant: Record = { + completed: "success", + in_progress: "primary", + pending: "warning", + failed: "destructive", + stuck: "secondary", +} + +function formatDate(iso?: string): string { + if (!iso) return "-" + return new Date(iso).toLocaleString() +} + +function formatDuration(secs?: number): string { + if (secs == null) return "-" + if (secs < 60) return `${Math.round(secs)}s` + if (secs < 3600) return `${Math.floor(secs / 60)}m ${Math.round(secs % 60)}s` + return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m` +} + +export default function AgentDetailPage() { + const params = useParams() + const router = useRouter() + const id = params.id as string + + const [agent, setAgent] = useState(null) + const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(true) + const [offline, setOffline] = useState(false) + + useEffect(() => { + async function fetchData() { + try { + const res = await fetch(`/api/pai/agents/${id}`) + if (!res.ok) { + setOffline(true) + return + } + const data = await res.json() as { agent: PAIAgentDetail; events: AgentEvent[] } + setAgent(data.agent) + setEvents(data.events) + setOffline(false) + } catch { + setOffline(true) + } finally { + setLoading(false) + } + } + fetchData() + }, [id]) + + const handleCancel = async () => { + if (!window.confirm("Cancel this agent?")) return + try { + await fetch(`/api/pai/agents/${id}`, { method: "DELETE" }) + router.push("/agents") + } catch { + // Ignore - user will see the job is still active + } + } + + if (loading) { + return ( +
+

Loading agent details...

+
+ ) + } + + if (offline || !agent) { + return ( +
+ + + + +

IronClaw is not running

+

Start IronClaw to manage agents

+ + cd ~/ironclaw && cargo run + +
+
+
+ ) + } + + return ( +
+ + + {/* Agent Header */} + + +
+
+ + {agent.title} + {agent.status.replace("_", " ")} + + {agent.description && ( +

{agent.description}

+ )} +
+
+ {(agent.status === "pending" || agent.status === "in_progress") && ( + + )} + {(agent.status === "failed" || agent.status === "stuck") && ( + + )} +
+
+
+ +
+
+

Created

+

{formatDate(agent.createdAt)}

+
+
+

Started

+

{formatDate(agent.startedAt)}

+
+
+

Completed

+

{formatDate(agent.completedAt)}

+
+
+

Duration

+

{formatDuration(agent.elapsedSecs)}

+
+
+
+
+ + {/* Transition Timeline */} + {agent.transitions.length > 0 && ( + + + State Transitions + + +
+ {agent.transitions.map((t, i) => ( +
+
+
+ {i < agent.transitions.length - 1 && ( +
+ )} +
+
+

+ {t.from} → {t.to} +

+

{formatDate(t.timestamp)}

+ {t.reason && ( +

{t.reason}

+ )} +
+
+ ))} +
+ + + )} + + {/* Event Timeline */} + {events.length > 0 && ( + + + Events + + +
+ {events.map((event, i) => ( +
+
+
+
+
+
+ {event.eventType} + {formatDate(event.timestamp)} +
+ {event.data != null && ( +
+                        {typeof event.data === "string" ? event.data : JSON.stringify(event.data, null, 2)}
+                      
+ )} +
+
+ ))} +
+ + + )} +
+ ) +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/agents/page.tsx b/Packs/pai-telos-skill/src/DashboardTemplate/App/agents/page.tsx new file mode 100644 index 000000000..7cd2b4183 --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/agents/page.tsx @@ -0,0 +1,184 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { useRouter } from "next/navigation" +import { Card, CardContent } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Table, + TableHeader, + TableBody, + TableRow, + TableHead, + TableCell, +} from "@/components/ui/table" +import { Bot, Plus } from "lucide-react" +import { JobSpawnPanel } from "@/components/job-spawn-panel" +import type { PAIAgent, AgentSummary, AgentStatus } from "@/types/pai" + +const statusVariant: Record = { + completed: "success", + in_progress: "primary", + pending: "warning", + failed: "destructive", + stuck: "secondary", +} + +function formatDuration(secs?: number): string { + if (secs == null) return "-" + if (secs < 60) return `${Math.round(secs)}s` + if (secs < 3600) return `${Math.floor(secs / 60)}m ${Math.round(secs % 60)}s` + return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m` +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString() +} + +export default function AgentsPage() { + const router = useRouter() + const [summary, setSummary] = useState(null) + const [agents, setAgents] = useState([]) + const [loading, setLoading] = useState(true) + const [offline, setOffline] = useState(false) + const [showSpawnPanel, setShowSpawnPanel] = useState(false) + + const fetchData = useCallback(async () => { + try { + const res = await fetch("/api/pai/agents") + if (!res.ok) { + setOffline(true) + return + } + const data = await res.json() as { agents: PAIAgent[]; summary: AgentSummary | null; offline?: boolean } + if (data.offline) { + setOffline(true) + return + } + setSummary(data.summary) + setAgents(data.agents) + setOffline(false) + } catch { + setOffline(true) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData() + }, [fetchData]) + + if (loading) { + return ( +
+

Loading agents...

+
+ ) + } + + if (offline) { + return ( +
+

+ + Active Agents +

+

IronClaw background agents and jobs

+ + + +

IronClaw is not running

+

Start IronClaw to manage agents

+ + cd ~/ironclaw && cargo run + +
+
+
+ ) + } + + return ( +
+
+
+

+ + Active Agents +

+

IronClaw background agents and jobs

+
+ +
+ + {/* Summary cards */} + {summary && ( +
+ {([ + { label: "Total", value: summary.total, color: "text-gray-900 dark:text-gray-100" }, + { label: "Running", value: summary.inProgress, color: "text-[#2e7de9]" }, + { label: "Pending", value: summary.pending, color: "text-[#f0a020]" }, + { label: "Completed", value: summary.completed, color: "text-[#33b579]" }, + { label: "Failed", value: summary.failed, color: "text-[#f52a65]" }, + ] as const).map((stat) => ( + + +

{stat.label}

+

{stat.value}

+
+
+ ))} +
+ )} + + {/* Jobs table */} + + + + + Title + Status + Created + Duration + + + + {agents.length === 0 ? ( + + + No agents found + + + ) : ( + agents.map((agent) => ( + router.push(`/agents/${agent.id}`)} + > + {agent.title} + + {agent.status.replace("_", " ")} + + {formatDate(agent.createdAt)} + {formatDuration(agent.elapsedSecs)} + + )) + )} + +
+
+ + setShowSpawnPanel(false)} + onCreated={fetchData} + /> +
+ ) +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/api/cc-mirror/variants/[name]/route.ts b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/cc-mirror/variants/[name]/route.ts new file mode 100644 index 000000000..ba399ca7f --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/cc-mirror/variants/[name]/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server" +import { isValidVariantName, updateVariantModels } from "@/lib/cc-mirror" + +export const dynamic = "force-dynamic" + +interface UpdateBody { + fast?: string + standard?: string + smart?: string + timeoutMultiplier?: string +} + +/** PUT /api/cc-mirror/variants/:name — update model tiers for a variant. */ +export async function PUT( + request: Request, + { params }: { params: Promise<{ name: string }> } +) { + try { + const { name } = await params + + if (!isValidVariantName(name)) { + return NextResponse.json( + { error: "Invalid variant name" }, + { status: 400 } + ) + } + + const body = (await request.json()) as UpdateBody + const fast = body.fast + const standard = body.standard + const smart = body.smart + + if (!fast || !standard || !smart) { + return NextResponse.json( + { error: "All three model tiers (fast, standard, smart) are required" }, + { status: 400 } + ) + } + + const result = updateVariantModels( + name, + { fast, standard, smart }, + body.timeoutMultiplier + ) + + if (!result.success) { + const status = result.error?.includes("not found") ? 404 : 500 + return NextResponse.json({ error: result.error }, { status }) + } + + return NextResponse.json({ + success: true, + message: `Updated model tiers for ${name}`, + }) + } catch (error) { + console.error("Failed to update CC-Mirror variant:", error) + return NextResponse.json( + { error: "Failed to update variant" }, + { status: 500 } + ) + } +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/api/cc-mirror/variants/route.ts b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/cc-mirror/variants/route.ts new file mode 100644 index 000000000..5b66d4dc7 --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/cc-mirror/variants/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server" +import { listVariants } from "@/lib/cc-mirror" + +export const dynamic = "force-dynamic" + +/** GET /api/cc-mirror/variants — list all variants with model tier config. */ +export async function GET() { + try { + const variants = listVariants() + return NextResponse.json({ variants }) + } catch (error) { + console.error("Failed to list CC-Mirror variants:", error) + return NextResponse.json( + { error: "Failed to list variants" }, + { status: 500 } + ) + } +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/agents/[id]/route.ts b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/agents/[id]/route.ts new file mode 100644 index 000000000..7f5823476 --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/agents/[id]/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server" +import { ironclawFetch } from "@/lib/ironclaw" +import { toAgentDetail, toAgentEvent, toActionResponse } from "@/lib/mappers" +import type { JobDetail, JobEvent, ActionResponse } from "@/types/ironclaw" + +export const dynamic = "force-dynamic" + +/** GET /api/pai/agents/:id — return agent detail with events. */ +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + try { + const [jobRes, eventsRes] = await Promise.all([ + ironclawFetch(`/api/jobs/${id}`), + ironclawFetch(`/api/jobs/${id}/events`), + ]) + + if (!jobRes.ok) { + return NextResponse.json({ error: "Agent not found" }, { status: jobRes.status }) + } + + const jobData: JobDetail = await jobRes.json() + const agent = toAgentDetail(jobData) + + let events: unknown[] = [] + if (eventsRes.ok) { + const eventsData: JobEvent[] = await eventsRes.json() + events = eventsData.map(toAgentEvent) + } + + return NextResponse.json({ agent, events }) + } catch (error) { + console.error("Failed to fetch agent detail:", error) + return NextResponse.json({ error: "Failed to fetch agent" }, { status: 500 }) + } +} + +/** DELETE /api/pai/agents/:id — cancel an agent. */ +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + try { + const res = await ironclawFetch(`/api/jobs/${id}`, { method: "DELETE" }) + if (!res.ok) { + return NextResponse.json({ error: "Failed to cancel agent" }, { status: res.status }) + } + const data: ActionResponse = await res.json() + return NextResponse.json(toActionResponse(data)) + } catch (error) { + console.error("Failed to cancel agent:", error) + return NextResponse.json({ error: "Failed to cancel agent" }, { status: 500 }) + } +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/agents/route.ts b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/agents/route.ts new file mode 100644 index 000000000..3268fd2b5 --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/agents/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server" +import { ironclawFetch } from "@/lib/ironclaw" +import { toAgent, toAgentSummary } from "@/lib/mappers" +import type { Job, JobSummary } from "@/types/ironclaw" + +export const dynamic = "force-dynamic" + +/** GET /api/pai/agents — return agents with optional status filter and summary. */ +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url) + const status = searchParams.get("status") + const limit = searchParams.get("limit") + const includeSummary = searchParams.get("summary") !== "false" + + // Build IronClaw query + const params = new URLSearchParams() + if (status) params.set("status", status) + if (limit) params.set("limit", limit) + const query = params.toString() ? `?${params.toString()}` : "" + + const results: { agents: unknown[]; summary: unknown } = { agents: [], summary: null } + + const [jobsRes, summaryRes] = await Promise.all([ + ironclawFetch(`/api/jobs${query}`), + includeSummary ? ironclawFetch("/api/jobs/summary") : null, + ]) + + if (!jobsRes.ok) { + return NextResponse.json({ agents: [], summary: null, offline: true }) + } + + const jobsRaw: unknown = await jobsRes.json() + const jobsList: Job[] = Array.isArray(jobsRaw) + ? jobsRaw + : (jobsRaw as Record).jobs as Job[] ?? [] + results.agents = jobsList.map(toAgent) + + if (summaryRes?.ok) { + const summaryData: JobSummary = await summaryRes.json() + results.summary = toAgentSummary(summaryData) + } + + return NextResponse.json(results) + } catch (error) { + console.error("Failed to fetch agents:", error) + return NextResponse.json({ agents: [], summary: null, offline: true }) + } +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/extensions/route.ts b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/extensions/route.ts new file mode 100644 index 000000000..e21ad3dcc --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/extensions/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server" +import { ironclawFetch } from "@/lib/ironclaw" +import { toExtension } from "@/lib/mappers" +import type { Extension } from "@/types/ironclaw" + +export const dynamic = "force-dynamic" + +/** GET /api/pai/extensions — return mapped extensions list. */ +export async function GET() { + try { + const res = await ironclawFetch("/api/extensions") + if (!res.ok) { + return NextResponse.json({ extensions: [], offline: true }) + } + const raw: unknown = await res.json() + const list: Extension[] = Array.isArray(raw) + ? raw + : (raw as Record).extensions as Extension[] ?? [] + + return NextResponse.json({ extensions: list.map(toExtension) }) + } catch (error) { + console.error("Failed to fetch extensions:", error) + return NextResponse.json({ extensions: [], offline: true }) + } +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/heartbeat/route.ts b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/heartbeat/route.ts new file mode 100644 index 000000000..a252e729b --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/heartbeat/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server" +import { readDaemonState, isHeartbeatStale } from "@/lib/daemon-state" + +export const dynamic = "force-dynamic" + +/** GET /api/pai/heartbeat — return latest daemon heartbeat state. */ +export async function GET() { + try { + const state = readDaemonState() + if (!state) { + return NextResponse.json({ + configured: false, + state: null, + stale: false, + }) + } + return NextResponse.json({ + configured: true, + state, + stale: isHeartbeatStale(state), + }) + } catch (error) { + console.error("Failed to read daemon state:", error) + return NextResponse.json( + { error: "Failed to read daemon state" }, + { status: 500 } + ) + } +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/logs/route.ts b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/logs/route.ts new file mode 100644 index 000000000..d3a31bae2 --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/logs/route.ts @@ -0,0 +1,141 @@ +import { NextResponse } from "next/server" +import { readdir, stat, readFile } from "fs/promises" +import { join } from "path" +import { homedir } from "os" + +export const dynamic = "force-dynamic" + +/** Shape of a single parsed log entry. */ +interface LogEntry { + timestamp: string + level: string + message: string + source: string +} + +const DEBUG_DIR = join(homedir(), ".claude", "debug") +const MAX_FILES = 5 +const MAX_LINES_PER_FILE = 100 +const MAX_ENTRIES = 500 + +/** + * Common log format: `2026-02-17T00:11:48.123Z [LEVEL] message` + * Also handles: `2026-02-17T00:11:48Z [LEVEL] message` + */ +const LOG_LINE_RE = + /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?)\s+\[(\w+)]\s+(.*)$/ + +/** Try JSON parse, then regex, then raw text. */ +function parseLine(raw: string, source: string): LogEntry | null { + const trimmed = raw.trim() + if (!trimmed) return null + + // Attempt 1: JSON object + if (trimmed.startsWith("{")) { + try { + const obj: unknown = JSON.parse(trimmed) + if (typeof obj === "object" && obj !== null) { + const rec = obj as Record + return { + timestamp: String(rec["timestamp"] ?? rec["ts"] ?? rec["time"] ?? new Date().toISOString()), + level: String(rec["level"] ?? rec["severity"] ?? "info").toLowerCase(), + message: String(rec["message"] ?? rec["msg"] ?? trimmed), + source, + } + } + } catch { + // Fall through to regex + } + } + + // Attempt 2: common log format via regex + const match = LOG_LINE_RE.exec(trimmed) + if (match) { + const ts = match[1] ?? new Date().toISOString() + const level = (match[2] ?? "info").toLowerCase() + const msg = match[3] ?? trimmed + return { timestamp: ts, level, message: msg, source } + } + + // Attempt 3: raw text + return { + timestamp: new Date().toISOString(), + level: "info", + message: trimmed, + source, + } +} + +/** Return the last N lines from a string. */ +function lastNLines(content: string, n: number): string[] { + const lines = content.split("\n") + return lines.slice(-n) +} + +/** GET /api/pai/logs -- return parsed entries from the most recent PAI debug logs. */ +export async function GET() { + try { + let dirEntries: string[] + try { + dirEntries = await readdir(DEBUG_DIR) + } catch { + // Directory doesn't exist or is unreadable -- return empty + return NextResponse.json({ entries: [], total: 0 }) + } + + // Stat each file and collect mtime + const fileStats: Array<{ name: string; mtimeMs: number }> = [] + for (const name of dirEntries) { + const fullPath = join(DEBUG_DIR, name) + try { + const s = await stat(fullPath) + if (s.isFile()) { + fileStats.push({ name, mtimeMs: s.mtimeMs }) + } + } catch { + // Skip unreadable entries + } + } + + // Sort by mtime descending, take top N + fileStats.sort((a, b) => b.mtimeMs - a.mtimeMs) + const recentFiles = fileStats.slice(0, MAX_FILES) + + // Parse entries from each file + const allEntries: LogEntry[] = [] + for (const file of recentFiles) { + try { + const content = await readFile(join(DEBUG_DIR, file.name), "utf-8") + const tail = lastNLines(content, MAX_LINES_PER_FILE) + for (const line of tail) { + const entry = parseLine(line, file.name) + if (entry) { + allEntries.push(entry) + } + } + } catch { + // Skip files that can't be read + } + } + + // Sort by timestamp descending, cap at MAX_ENTRIES + allEntries.sort((a, b) => { + const ta = new Date(a.timestamp).getTime() + const tb = new Date(b.timestamp).getTime() + if (Number.isNaN(ta) && Number.isNaN(tb)) return 0 + if (Number.isNaN(ta)) return 1 + if (Number.isNaN(tb)) return -1 + return tb - ta + }) + + const entries = allEntries.slice(0, MAX_ENTRIES) + + return NextResponse.json({ entries, total: entries.length }) + } catch (error) { + console.error("Failed to read PAI debug logs:", error) + return NextResponse.json( + { error: "Failed to read PAI debug logs" }, + { status: 500 } + ) + } +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/memory/route.ts b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/memory/route.ts new file mode 100644 index 000000000..8a879cf21 --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/memory/route.ts @@ -0,0 +1,149 @@ +import { NextResponse } from "next/server" +import fs from "fs" +import path from "path" +import os from "os" + +export const dynamic = "force-dynamic" + +const MEMORY_DIR = path.join(os.homedir(), ".claude", "MEMORY") +const LEARNINGS_FILE = path.join(MEMORY_DIR, "LEARNING", "learnings.jsonl") + +/** Categories to scan for .md learning files. */ +const SCAN_CATEGORIES = ["ALGORITHM", "SYSTEM", "FAILURES", "SYNTHESIS"] as const + +interface Learning { + id: string + text: string + concept?: string + tier?: string + timestamp?: string + source?: string +} + +interface MemoryFile { + name: string + path: string + category: string +} + +/** + * Parse a single JSONL line into a Learning, returning undefined on failure. + * Handles `noUncheckedIndexedAccess` by validating required fields. + */ +function parseLearningLine(line: string): Learning | undefined { + try { + const raw: unknown = JSON.parse(line) + if (typeof raw !== "object" || raw === null) return undefined + + const obj = raw as Record + const id = obj["id"] + const content = obj["content"] ?? obj["title"] + + if (typeof id !== "string" || typeof content !== "string") return undefined + + const learning: Learning = { + id, + text: content, + } + + const concept = obj["concept"] + if (typeof concept === "string") learning.concept = concept + + const tier = obj["tier"] + if (typeof tier === "string") learning.tier = tier + + const timestamp = obj["created_at"] + if (typeof timestamp === "string") learning.timestamp = timestamp + + const source = obj["context"] + if (typeof source === "string") learning.source = source + + return learning + } catch { + return undefined + } +} + +/** + * Read learnings.jsonl and return the last N entries. + */ +function readLearnings(limit: number): Learning[] { + if (!fs.existsSync(LEARNINGS_FILE)) return [] + + const content = fs.readFileSync(LEARNINGS_FILE, "utf-8") + const lines = content.split("\n").filter((l) => l.trim().length > 0) + + const learnings: Learning[] = [] + for (const line of lines) { + const parsed = parseLearningLine(line) + if (parsed) learnings.push(parsed) + } + + return learnings.slice(-limit) +} + +/** + * Recursively collect .md files from a directory. + * For FAILURES, specifically looks for CONTEXT.md files. + */ +function collectMdFiles( + dir: string, + category: string, + basePath: string, +): MemoryFile[] { + if (!fs.existsSync(dir)) return [] + + const files: MemoryFile[] = [] + const entries = fs.readdirSync(dir, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + files.push(...collectMdFiles(fullPath, category, basePath)) + } else if (entry.name.endsWith(".md")) { + files.push({ + name: entry.name, + path: path.relative(basePath, fullPath), + category, + }) + } + } + + return files +} + +/** + * Scan all LEARNING subdirectories for .md files. + */ +function scanLearningFiles(): MemoryFile[] { + const learningDir = path.join(MEMORY_DIR, "LEARNING") + const files: MemoryFile[] = [] + + for (const category of SCAN_CATEGORIES) { + const categoryDir = path.join(learningDir, category) + files.push(...collectMdFiles(categoryDir, category, MEMORY_DIR)) + } + + return files +} + +/** GET /api/pai/memory -- return PAI native memory data. */ +export async function GET() { + try { + const learnings = readLearnings(50) + const files = scanLearningFiles() + + return NextResponse.json({ + learningCount: learnings.length, + learnings, + fileCount: files.length, + files, + }) + } catch (error) { + console.error("Failed to read PAI memory:", error) + return NextResponse.json( + { error: "Failed to read PAI memory" }, + { status: 500 }, + ) + } +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/routines/[id]/route.ts b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/routines/[id]/route.ts new file mode 100644 index 000000000..c0e364a61 --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/routines/[id]/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server" +import { ironclawFetch } from "@/lib/ironclaw" +import { toRoutineDetail, toActionResponse } from "@/lib/mappers" +import type { RoutineDetail, ActionResponse } from "@/types/ironclaw" + +export const dynamic = "force-dynamic" + +/** GET /api/pai/routines/:id — return routine detail. */ +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + try { + const res = await ironclawFetch(`/api/routines/${id}`) + if (!res.ok) { + return NextResponse.json({ error: "Routine not found" }, { status: res.status }) + } + const data: RoutineDetail = await res.json() + return NextResponse.json(toRoutineDetail(data)) + } catch (error) { + console.error("Failed to fetch routine detail:", error) + return NextResponse.json({ error: "Failed to fetch routine" }, { status: 500 }) + } +} + +/** POST /api/pai/routines/:id — toggle or trigger a routine. */ +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const { searchParams } = new URL(request.url) + const action = searchParams.get("action") // "toggle" | "trigger" + + const endpoint = action === "trigger" + ? `/api/routines/${id}/trigger` + : `/api/routines/${id}/toggle` + + try { + const res = await ironclawFetch(endpoint, { method: "POST" }) + if (!res.ok) { + return NextResponse.json({ error: `Failed to ${action} routine` }, { status: res.status }) + } + const data: ActionResponse = await res.json() + return NextResponse.json(toActionResponse(data)) + } catch (error) { + console.error(`Failed to ${action} routine:`, error) + return NextResponse.json({ error: `Failed to ${action} routine` }, { status: 500 }) + } +} + +/** DELETE /api/pai/routines/:id — delete a routine. */ +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + try { + const res = await ironclawFetch(`/api/routines/${id}`, { method: "DELETE" }) + if (!res.ok) { + return NextResponse.json({ error: "Failed to delete routine" }, { status: res.status }) + } + const data: ActionResponse = await res.json() + return NextResponse.json(toActionResponse(data)) + } catch (error) { + console.error("Failed to delete routine:", error) + return NextResponse.json({ error: "Failed to delete routine" }, { status: 500 }) + } +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/routines/route.ts b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/routines/route.ts new file mode 100644 index 000000000..f44b43bbc --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/routines/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server" +import { ironclawFetch } from "@/lib/ironclaw" +import { toRoutine, toRoutineSummary } from "@/lib/mappers" +import type { Routine, RoutineSummary } from "@/types/ironclaw" + +export const dynamic = "force-dynamic" + +/** GET /api/pai/routines — return routines with summary. */ +export async function GET() { + try { + const [routinesRes, summaryRes] = await Promise.all([ + ironclawFetch("/api/routines"), + ironclawFetch("/api/routines/summary"), + ]) + + if (!routinesRes.ok) { + return NextResponse.json({ routines: [], summary: null, offline: true }) + } + + const routinesRaw: unknown = await routinesRes.json() + const routinesList: Routine[] = Array.isArray(routinesRaw) + ? routinesRaw + : (routinesRaw as Record).routines as Routine[] ?? [] + + const routines = routinesList.map(toRoutine) + let summary = null + + if (summaryRes.ok) { + const summaryData: RoutineSummary = await summaryRes.json() + summary = toRoutineSummary(summaryData) + } + + return NextResponse.json({ routines, summary }) + } catch (error) { + console.error("Failed to fetch routines:", error) + return NextResponse.json({ routines: [], summary: null, offline: true }) + } +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/security-events/route.ts b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/security-events/route.ts new file mode 100644 index 000000000..19dea0cbd --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/security-events/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from "next/server" +import fs from "fs" +import path from "path" +import os from "os" + +export const dynamic = "force-dynamic" + +interface SecurityEvent { + timestamp: string + session_id: string + event_type: string + tool: string + category: string + target: string + pattern_matched?: string + reason?: string + action_taken: string +} + +/** Public shape — session_id stripped to avoid information disclosure. */ +type SafeSecurityEvent = Omit + +const SECURITY_DIR = path.join(os.homedir(), ".claude", "MEMORY", "SECURITY") + +/** Recursively find all security-*.jsonl files, sorted newest first. */ +function findSecurityFiles(dir: string, limit: number): string[] { + const files: { path: string; mtime: number }[] = [] + + function walk(d: string) { + if (!fs.existsSync(d)) return + for (const entry of fs.readdirSync(d, { withFileTypes: true })) { + const full = path.join(d, entry.name) + if (entry.isDirectory()) { + walk(full) + } else if (entry.name.startsWith("security-") && entry.name.endsWith(".jsonl")) { + const stat = fs.statSync(full) + files.push({ path: full, mtime: stat.mtimeMs }) + } + } + } + + walk(dir) + files.sort((a, b) => b.mtime - a.mtime) + return files.slice(0, limit).map(f => f.path) +} + +/** GET /api/pai/security-events — return recent security events. */ +export async function GET(request: Request) { + try { + const url = new URL(request.url) + const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200) + + const filePaths = findSecurityFiles(SECURITY_DIR, limit) + const events: SafeSecurityEvent[] = [] + + for (const fp of filePaths) { + try { + const raw = fs.readFileSync(fp, "utf-8").trim() + if (!raw) continue + const parsed = JSON.parse(raw) as SecurityEvent + const { session_id: _, ...safeEvent } = parsed + events.push(safeEvent) + } catch { + // Skip unreadable files + } + } + + return NextResponse.json({ events }) + } catch (error) { + console.error("Failed to read security events:", error) + return NextResponse.json({ events: [] }) + } +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/security-status/route.ts b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/security-status/route.ts new file mode 100644 index 000000000..1440f3873 --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/security-status/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server" +import fs from "fs" +import path from "path" +import os from "os" + +export const dynamic = "force-dynamic" + +const CLAUDE_DIR = path.join(os.homedir(), ".claude") +const OVERRIDE_PATH = path.join(CLAUDE_DIR, "SECURITY_OVERRIDE") +const HOOKS_DIR = path.join(CLAUDE_DIR, "hooks") + +interface HookStatus { + state: "healthy" | "degraded" | "missing" + hookCount: number +} + +function checkHookHealth(): HookStatus { + if (!fs.existsSync(HOOKS_DIR)) { + return { state: "missing", hookCount: 0 } + } + + try { + const entries = fs.readdirSync(HOOKS_DIR) + const hookFiles = entries.filter(e => e.endsWith(".js") || e.endsWith(".ts") || e.endsWith(".sh")) + if (hookFiles.length === 0) { + return { state: "missing", hookCount: 0 } + } + return { state: "healthy", hookCount: hookFiles.length } + } catch { + return { state: "degraded", hookCount: 0 } + } +} + +/** GET /api/pai/security-status — escape hatch + hook health. */ +export async function GET() { + try { + const overrideActive = fs.existsSync(OVERRIDE_PATH) + const hooks = checkHookHealth() + + return NextResponse.json({ + overrideActive, + overridePath: OVERRIDE_PATH, + hooks, + }) + } catch (error) { + console.error("Failed to check security status:", error) + return NextResponse.json( + { error: "Failed to check security status" }, + { status: 500 } + ) + } +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/settings-audit/route.ts b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/settings-audit/route.ts new file mode 100644 index 000000000..fcde9f283 --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/settings-audit/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server" +import { readSettingsAuditLog, logSettingsChange } from "@/lib/settings-governance" + +export const dynamic = "force-dynamic" + +/** GET /api/pai/settings-audit — return recent settings change audit entries. */ +export async function GET(request: Request) { + try { + const url = new URL(request.url) + const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200) + const entries = readSettingsAuditLog(limit) + return NextResponse.json({ entries }) + } catch (error) { + console.error("Failed to read settings audit log:", error) + return NextResponse.json({ entries: [] }) + } +} + +/** POST /api/pai/settings-audit — log a critical settings change. */ +export async function POST(request: Request) { + try { + const body = (await request.json()) as { + setting?: string + oldValue?: unknown + newValue?: unknown + } + if (!body.setting) { + return NextResponse.json({ error: "Missing setting field" }, { status: 400 }) + } + logSettingsChange({ + timestamp: new Date().toISOString(), + setting: body.setting, + oldValue: body.oldValue ?? "unknown", + newValue: body.newValue ?? "unknown", + source: "dashboard", + }) + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Failed to log settings change:", error) + return NextResponse.json({ error: "Failed to log" }, { status: 500 }) + } +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/settings/route.ts b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/settings/route.ts new file mode 100644 index 000000000..f28e42a95 --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/settings/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server" +import fs from "fs" +import path from "path" +import os from "os" + +export const dynamic = "force-dynamic" + +const CLAUDE_DIR = path.join(os.homedir(), ".claude") +const GLOBAL_SETTINGS_PATH = path.join(CLAUDE_DIR, "settings.json") +const LOCAL_SETTINGS_PATH = path.join(CLAUDE_DIR, "settings.local.json") + +const SENSITIVE_PATTERNS = [/token/i, /secret/i, /password/i, /key/i, /auth/i] + +/** Returns true if a settings key does NOT match any sensitive pattern. */ +function isSafeKey(key: string): boolean { + return !SENSITIVE_PATTERNS.some((p) => p.test(key)) +} + +/** Recursively strip sensitive keys from a settings object. */ +function filterSensitive(obj: Record): Record { + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + if (!isSafeKey(key)) continue + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + result[key] = filterSensitive(value as Record) + } else { + result[key] = value + } + } + return result +} + +/** Read and parse a JSON settings file. Returns empty object if missing or invalid. */ +function readSettingsFile(filePath: string): Record { + if (!fs.existsSync(filePath)) return {} + try { + const raw = fs.readFileSync(filePath, "utf-8") + return JSON.parse(raw) as Record + } catch { + return {} + } +} + +/** GET /api/pai/settings — return PAI settings with sensitive keys filtered out. */ +export async function GET() { + try { + const globalRaw = readSettingsFile(GLOBAL_SETTINGS_PATH) + const localRaw = readSettingsFile(LOCAL_SETTINGS_PATH) + + return NextResponse.json({ + global: filterSensitive(globalRaw), + local: filterSensitive(localRaw), + globalPath: GLOBAL_SETTINGS_PATH, + localPath: LOCAL_SETTINGS_PATH, + }) + } catch (error) { + console.error("Failed to read PAI settings:", error) + return NextResponse.json( + { error: "Failed to read PAI settings" }, + { status: 500 } + ) + } +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/summary/route.ts b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/summary/route.ts new file mode 100644 index 000000000..40452aae6 --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/api/pai/summary/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server" +import { getJobsSummary, getRoutinesSummary } from "@/lib/ironclaw" +import { toAgentSummary, toRoutineSummary } from "@/lib/mappers" + +export const dynamic = "force-dynamic" + +/** GET /api/pai/summary — aggregated agent + routine summaries (mapped). */ +export async function GET() { + try { + const [jobs, routines] = await Promise.all([ + getJobsSummary(), + getRoutinesSummary(), + ]) + return NextResponse.json({ + agents: toAgentSummary(jobs), + routines: toRoutineSummary(routines), + }) + } catch (error) { + console.error("PAI summary error:", error) + return NextResponse.json( + { agents: null, routines: null, offline: true }, + { status: 200 } + ) + } +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/ask/page.tsx b/Packs/pai-telos-skill/src/DashboardTemplate/App/ask/page.tsx index 0f1d1724a..5fc307c14 100755 --- a/Packs/pai-telos-skill/src/DashboardTemplate/App/ask/page.tsx +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/ask/page.tsx @@ -1,20 +1,135 @@ "use client" -import { useState } from "react" +import { useState, useEffect, useCallback, useRef } from "react" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { MessageSquare, Send, Bot, User } from "lucide-react" +import { useIronclawSSE } from "@/lib/use-ironclaw-sse" +import type { PAIStreamEvent, PAIChatThread } from "@/types/pai" interface Message { - role: "user" | "assistant" + role: "user" | "assistant" | "system" content: string timestamp: Date + tools?: { name: string; status: "started" | "completed" | "error" }[] + isStreaming?: boolean +} + +interface ToolIndicator { + name: string + status: "started" | "completed" | "error" } export default function AskPage() { const [messages, setMessages] = useState([]) const [input, setInput] = useState("") const [isLoading, setIsLoading] = useState(false) + const [isThinking, setIsThinking] = useState(false) + const [threadId, setThreadId] = useState(undefined) + const [threads, setThreads] = useState([]) + const [activeTools, setActiveTools] = useState([]) + const [pendingApproval, setPendingApproval] = useState | null>(null) + const [sseEnabled, setSseEnabled] = useState(false) + const messagesEndRef = useRef(null) + + // Phase 3 deferral: chat operations stay on IronClaw proxy until PAI chat API routes are added + useEffect(() => { + fetch("/api/ironclaw/chat/threads") + .then(r => { + if (!r.ok) return { threads: [] } + return r.json() + }) + .then((data: { threads?: PAIChatThread[] }) => { + setThreads(data.threads ?? []) + }) + .catch(() => { + // IronClaw offline — threads unavailable + }) + }, []) + + // Auto-scroll on new messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) + }, [messages, isThinking, activeTools]) + + const handleSSEEvent = useCallback((event: PAIStreamEvent) => { + switch (event.type) { + case "response": + case "stream_chunk": { + const content = typeof event.data.content === "string" ? event.data.content : "" + setMessages(prev => { + const last = prev[prev.length - 1] + if (last?.role === "assistant" && last.isStreaming) { + return [...prev.slice(0, -1), { ...last, content: last.content + content }] + } + return [...prev, { role: "assistant", content, timestamp: new Date(), isStreaming: true }] + }) + break + } + case "thinking": + setIsThinking(true) + break + case "tool_started": + setActiveTools(prev => [...prev, { name: String(event.data.name ?? "unknown"), status: "started" as const }]) + break + case "tool_completed": + setActiveTools(prev => + prev.map(t => + t.name === String(event.data.name ?? "") ? { ...t, status: "completed" as const } : t + ) + ) + break + case "approval_needed": + setPendingApproval(event.data) + break + case "error": + setMessages(prev => [ + ...prev, + { role: "system", content: `Error: ${String(event.data.message ?? "Unknown error")}`, timestamp: new Date() }, + ]) + break + case "status": + if (event.data.state === "completed") { + setIsLoading(false) + setIsThinking(false) + setActiveTools([]) + setMessages(prev => { + const last = prev[prev.length - 1] + if (last?.isStreaming) { + return [...prev.slice(0, -1), { ...last, isStreaming: false }] + } + return prev + }) + } + break + } + }, []) + + useIronclawSSE({ threadId, onEvent: handleSSEEvent, enabled: sseEnabled }) + + const handleApproval = async (approved: boolean) => { + if (!pendingApproval) return + try { + await fetch("/api/ironclaw/chat/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ approval_id: pendingApproval.approval_id, approved }), + }) + } catch { + // Approval request failed + } + setPendingApproval(null) + } + + const handleNewThread = () => { + setThreadId(undefined) + setMessages([]) + setActiveTools([]) + setPendingApproval(null) + setIsThinking(false) + setIsLoading(false) + setSseEnabled(false) + } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -26,56 +141,71 @@ export default function AskPage() { timestamp: new Date(), } - setMessages((prev) => [...prev, userMessage]) + setMessages(prev => [...prev, userMessage]) setInput("") setIsLoading(true) + setSseEnabled(true) try { const response = await fetch("/api/chat", { method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - message: userMessage.content, - }), + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: userMessage.content, thread_id: threadId }), }) + if (response.status === 502) { + setMessages(prev => [ + ...prev, + { role: "system", content: "IronClaw is not running. Start IronClaw to use the PAI agent.", timestamp: new Date() }, + ]) + setIsLoading(false) + setSseEnabled(false) + return + } + if (!response.ok) { throw new Error("Failed to get response") } - const data = await response.json() + const data = await response.json() as { response?: string; thread_id?: string } - const assistantMessage: Message = { - role: "assistant", - content: data.response, - timestamp: new Date(), + if (data.thread_id && !threadId) { + setThreadId(data.thread_id) } - setMessages((prev) => [...prev, assistantMessage]) - } catch (error) { - console.error("Error:", error) - const errorMessage: Message = { - role: "assistant", - content: "Sorry, I encountered an error. Please try again.", - timestamp: new Date(), - } - setMessages((prev) => [...prev, errorMessage]) + // If SSE didn't already stream a response, use poll result + setMessages(prev => { + const last = prev[prev.length - 1] + if (last?.role === "assistant") { + // SSE already delivered response + return prev + } + return [ + ...prev, + { role: "assistant", content: data.response ?? "", timestamp: new Date() }, + ] + }) + } catch { + setMessages(prev => [ + ...prev, + { role: "system", content: "Sorry, I encountered an error. Please try again.", timestamp: new Date() }, + ]) } finally { setIsLoading(false) + setIsThinking(false) + setActiveTools([]) } } return (
-

+

- Ask AI + Ask PAI

-

- Chat with Claude Haiku 4.5 to get instant answers +

+ Chat with your PAI agent for context-aware answers

@@ -84,15 +214,46 @@ export default function AskPage() { Chat Interface - Claude Haiku 4.5 + PAI Agent + {/* Thread Selector */} +
+ + +
+ + {/* Tool Execution Indicators */} + {activeTools.length > 0 && ( +
+ {activeTools.map((tool, i) => ( + + {tool.status === "started" ? "\u26A1" : "\u2713"} {tool.name} + + ))} +
+ )} + {/* Messages Container */} -
+
{messages.length === 0 ? ( -
+

Start a conversation by typing a message below

@@ -106,9 +267,11 @@ export default function AskPage() { message.role === "user" ? "justify-end" : "justify-start" }`} > - {message.role === "assistant" && ( + {message.role !== "user" && (
-
+
@@ -117,17 +280,24 @@ export default function AskPage() { className={`rounded-lg px-4 py-3 max-w-[80%] ${ message.role === "user" ? "bg-[#2e7de9] text-white" - : "bg-white border border-gray-200" + : message.role === "system" + ? "bg-[#f0a020]/10 border border-[#f0a020]/30" + : "bg-white border border-gray-200 dark:bg-[#1a1d2e] dark:border-gray-700/50" }`} > -
+
{message.content} + {message.isStreaming && ( + + )}
{message.timestamp.toLocaleTimeString()} @@ -143,14 +313,30 @@ export default function AskPage() {
)) )} - {isLoading && ( + + {/* Thinking Indicator */} + {isThinking && ( +
+
+
+ +
+
+
+ Thinking... +
+
+ )} + + {/* Loading dots (when waiting but not thinking) */} + {isLoading && !isThinking && (
-
+
@@ -159,8 +345,35 @@ export default function AskPage() {
)} + +
+ {/* Approval Flow */} + {pendingApproval && ( + + +

+ Agent needs approval: {String(pendingApproval.description ?? "Proceed?")} +

+
+ + +
+
+
+ )} + {/* Input Form */}
setInput(e.target.value)} placeholder="Type your message..." disabled={isLoading} - className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#2e7de9] focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed" + className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#2e7de9] focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed dark:bg-[#1a1d2e] dark:border-gray-600 dark:text-gray-200 dark:placeholder-gray-500 dark:disabled:bg-gray-800" /> +
+ + + + {/* Extensions Grid */} + {extensions.length === 0 ? ( + + + +

No extensions installed

+

Use the form above to install an extension

+
+
+ ) : ( +
+ {extensions.map((ext) => ( + + +
+
+

{ext.name}

+ {ext.description && ( +

{ext.description}

+ )} +
+
+ + {ext.kind} +
+
+ + {/* Tools */} + {ext.tools.length > 0 && ( +
+ {ext.tools.map((tool) => ( + + {tool} + + ))} +
+ )} + + {/* Actions */} +
+
+ {!ext.authenticated && ( + + )} + {ext.url && ( + + )} +
+ +
+
+
+ ))} +
+ )} + + )} +
+ ) +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/governance/page.tsx b/Packs/pai-telos-skill/src/DashboardTemplate/App/governance/page.tsx new file mode 100644 index 000000000..d926e8272 --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/governance/page.tsx @@ -0,0 +1,289 @@ +"use client" + +import { useState, useEffect } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { + Table, + TableHeader, + TableBody, + TableRow, + TableHead, + TableCell, +} from "@/components/ui/table" +import { Shield, AlertTriangle, Activity, BookOpen, Circle } from "lucide-react" + +interface SecurityEvent { + timestamp: string + event_type: string + tool: string + target: string + pattern_matched?: string + reason?: string +} + +interface SecurityStatus { + overrideActive: boolean + overridePath: string + hooks: { + state: "healthy" | "degraded" | "missing" + hookCount: number + } +} + +interface AuditEntry { + timestamp: string + setting: string + oldValue: unknown + newValue: unknown +} + +const HOOK_STATE_META: Record = { + healthy: { label: "Healthy", color: "#33b579", badgeVariant: "success" }, + degraded: { label: "Degraded", color: "#f0a020", badgeVariant: "warning" }, + missing: { label: "No Hooks", color: "#f52a65", badgeVariant: "destructive" }, +} + +const GOVERNANCE_TABLE = [ + { domain: "Safety", outer: "8 compiled rules, injection detection", inner: "YAML patterns, PreToolUse", dashboard: "IronClaw: editable (guarded). PAI: read-only" }, + { domain: "Sandbox", outer: "WASM isolation, 3 policy levels", inner: "N/A", dashboard: "IronClaw: editable (guarded)" }, + { domain: "Settings", outer: "Daemon config (timeouts, limits)", inner: "Cognitive config (models, memory)", dashboard: "Each controls its own" }, + { domain: "Extensions", outer: "Extension lifecycle (install/remove/auth)", inner: "N/A", dashboard: "Write ops through IronClaw" }, + { domain: "Memory", outer: "Agent execution logs", inner: "Learnings, state, work", dashboard: "Each controls its own" }, + { domain: "Logs", outer: "Daemon + agent logs", inner: "Hook + security audit logs", dashboard: "Each generates its own" }, +] + +function formatTimestamp(ts: string): string { + const d = new Date(ts) + return d.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }) +} + +export default function GovernancePage() { + const [securityStatus, setSecurityStatus] = useState(null) + const [events, setEvents] = useState([]) + const [auditEntries, setAuditEntries] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + Promise.all([ + fetch("/api/pai/security-status").then(r => r.ok ? r.json() : null), + fetch("/api/pai/security-events?limit=50").then(r => r.ok ? r.json() : { events: [] }), + fetch("/api/pai/settings-audit?limit=20").then(r => r.ok ? r.json() : { entries: [] }), + ]) + .then(([statusData, eventsData, auditData]) => { + setSecurityStatus(statusData as SecurityStatus | null) + setEvents((eventsData as { events: SecurityEvent[] }).events) + setAuditEntries((auditData as { entries: AuditEntry[] }).entries) + }) + .catch(() => { + // Governance data unavailable + }) + .finally(() => setLoading(false)) + }, []) + + if (loading) { + return ( +
+

Loading governance data...

+
+ ) + } + + const hookMeta = HOOK_STATE_META[securityStatus?.hooks.state ?? "missing"] ?? HOOK_STATE_META["missing"] + + return ( +
+
+

+ + Governance +

+
+

+ Security visibility and authority model +

+ {/* Hook Health Badge */} +
+ + + Hooks: {hookMeta.label} + {securityStatus?.hooks.hookCount + ? ` (${securityStatus.hooks.hookCount})` + : ""} + +
+
+
+ + {/* Escape Hatch Alert */} + {securityStatus?.overrideActive && ( + + + +
+

Security Override Active

+

+ PAI SecurityValidator is bypassed. All commands execute without pattern checking. +

+ + Remove {securityStatus.overridePath} to restore protection. + +
+
+
+ )} + +
+ {/* Security Events */} + + + + + Security Events + + + + {events.length === 0 ? ( +
+ +

No security events recorded

+
+ ) : ( +
+ + + + Time + Decision + Tool + Target + + + + {events.map((event, i) => ( + + + {formatTimestamp(event.timestamp)} + + + + {event.event_type} + + + {event.tool} + + {event.target} + + + ))} + +
+
+ )} +
+
+ + {/* Settings Audit Log */} + + + + + Settings Audit Log + + + + {auditEntries.length === 0 ? ( +
+ +

No settings changes recorded

+

+ Changes to critical IronClaw settings will appear here +

+
+ ) : ( +
+ + + + Time + Setting + Old + New + + + + {auditEntries.map((entry, i) => ( + + + {formatTimestamp(entry.timestamp)} + + {entry.setting} + {String(entry.oldValue)} + {String(entry.newValue)} + + ))} + +
+
+ )} +
+
+
+ + {/* Governance Reference */} + + + + + Authority Model Reference + + + +

+ IronClaw is the outer authority (Rust hard boundaries). PAI is the inner authority (contextual YAML policy). + The dashboard respects this hierarchy — critical IronClaw settings require confirmation before changes. +

+ + + + Domain + IronClaw (Outer) + PAI (Inner) + Dashboard + + + + {GOVERNANCE_TABLE.map(row => ( + + {row.domain} + {row.outer} + {row.inner} + {row.dashboard} + + ))} + +
+
+
+
+ ) +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/inference/page.tsx b/Packs/pai-telos-skill/src/DashboardTemplate/App/inference/page.tsx new file mode 100644 index 000000000..a95c62059 --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/inference/page.tsx @@ -0,0 +1,245 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Zap, Pencil, Save, X } from "lucide-react" + +interface VariantModels { + fast: string + standard: string + smart: string +} + +interface VariantInfo { + name: string + provider: string + baseUrl: string + models: VariantModels + timeoutMultiplier: string +} + +const TIER_LABELS: { key: keyof VariantModels; label: string; hint: string }[] = [ + { key: "fast", label: "Fast (Haiku)", hint: "Quick classification, simple generation" }, + { key: "standard", label: "Standard (Sonnet)", hint: "Balanced reasoning, typical analysis" }, + { key: "smart", label: "Smart (Opus)", hint: "Deep reasoning, strategic decisions" }, +] + +export default function InferencePage() { + const [variants, setVariants] = useState([]) + const [loading, setLoading] = useState(true) + const [editingVariant, setEditingVariant] = useState(null) + const [editModels, setEditModels] = useState({ fast: "", standard: "", smart: "" }) + const [editTimeout, setEditTimeout] = useState("1") + const [isSaving, setIsSaving] = useState(false) + const [saveResult, setSaveResult] = useState<{ variant: string; status: "success" | "error" } | null>(null) + + const fetchVariants = useCallback(async () => { + try { + const res = await fetch("/api/cc-mirror/variants") + if (!res.ok) return + const data = (await res.json()) as { variants: VariantInfo[] } + setVariants(data.variants) + } catch { + // Fetch failed + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void fetchVariants() + }, [fetchVariants]) + + // Clear save feedback after 3s + useEffect(() => { + if (!saveResult) return + const timer = setTimeout(() => setSaveResult(null), 3000) + return () => clearTimeout(timer) + }, [saveResult]) + + const startEdit = (variant: VariantInfo) => { + setEditingVariant(variant.name) + setEditModels({ ...variant.models }) + setEditTimeout(variant.timeoutMultiplier) + } + + const cancelEdit = () => { + setEditingVariant(null) + } + + const handleSave = async (name: string) => { + setIsSaving(true) + try { + const res = await fetch(`/api/cc-mirror/variants/${name}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + fast: editModels.fast, + standard: editModels.standard, + smart: editModels.smart, + timeoutMultiplier: editTimeout, + }), + }) + if (res.ok) { + setSaveResult({ variant: name, status: "success" }) + setEditingVariant(null) + await fetchVariants() + } else { + setSaveResult({ variant: name, status: "error" }) + } + } catch { + setSaveResult({ variant: name, status: "error" }) + } finally { + setIsSaving(false) + } + } + + const providerColor = (provider: string): string => { + switch (provider) { + case "openrouter": return "#9854f1" + case "ollama": return "#33b579" + default: return "#2e7de9" + } + } + + if (loading) { + return ( +
+

+ + Inference +

+

Configure LLM models for each CC-Mirror variant

+
Loading...
+
+ ) + } + + return ( +
+

+ + Inference +

+

Configure LLM models for each CC-Mirror variant

+ + {variants.length === 0 ? ( + + + +

No CC-Mirror variants found

+

+ Install CC-Mirror to configure alternative LLM providers +

+ + npm install -g cc-mirror + +
+
+ ) : ( +
+ {variants.map((variant) => { + const isEditing = editingVariant === variant.name + const justSaved = saveResult?.variant === variant.name + + return ( + + +
+ + {variant.name} + + {variant.provider} + + {justSaved && ( + + {saveResult.status === "success" ? "Saved" : "Error"} + + )} + + {!isEditing && ( + + )} +
+

{variant.baseUrl}

+
+ +
+ {TIER_LABELS.map(({ key, label, hint }) => ( +
+
+
{label}
+
{hint}
+
+ {isEditing ? ( + + setEditModels((prev) => ({ ...prev, [key]: e.target.value })) + } + className="flex-1 px-3 py-1.5 text-sm font-mono border rounded-md focus:outline-none focus:ring-2 focus:ring-[#2e7de9]/40 dark:bg-[#1a1d2e] dark:border-gray-600 dark:text-gray-200 dark:placeholder-gray-500" + placeholder="model-name/variant" + /> + ) : ( + + {variant.models[key] || "\u2014"} + + )} +
+ ))} + + {/* Timeout multiplier */} +
+
+
Timeout multiplier
+
Scale default timeouts
+
+ {isEditing ? ( + setEditTimeout(e.target.value)} + className="w-20 px-3 py-1.5 text-sm font-mono border rounded-md focus:outline-none focus:ring-2 focus:ring-[#2e7de9]/40 dark:bg-[#1a1d2e] dark:border-gray-600 dark:text-gray-200" + placeholder="1" + /> + ) : ( + + {variant.timeoutMultiplier}x + + )} +
+
+ + {isEditing && ( +
+ + +
+ )} +
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/logs/page.tsx b/Packs/pai-telos-skill/src/DashboardTemplate/App/logs/page.tsx new file mode 100644 index 000000000..e7f08bd97 --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/logs/page.tsx @@ -0,0 +1,376 @@ +"use client" + +import { useState, useEffect, useRef, useCallback } from "react" +import { Card, CardContent } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" +import { ScrollText, Pause, Play, Trash2, RefreshCw } from "lucide-react" +import { cn } from "@/lib/utils" +import type { LogEntry, LogLevel } from "@/types/ironclaw" + +/** PAI log entry shape from GET /api/pai/logs */ +interface PaiLogEntry { + timestamp: string + level: "debug" | "info" | "warn" | "error" + message: string + source: string +} + +type PaiLogLevel = PaiLogEntry["level"] + +const levelVariant: Record = { + trace: "secondary", + debug: "secondary", + info: "primary", + warn: "warning", + error: "destructive", +} + +/** PAI levels are a subset of LogLevel -- reuse the same variant map */ +const paiLevelVariant: Record = { + debug: "secondary", + info: "primary", + warn: "warning", + error: "destructive", +} + +const MAX_ENTRIES = 500 + +export default function LogsPage() { + // ── IronClaw SSE state (unchanged) ────────────────────────────────── + const [entries, setEntries] = useState([]) + const [levelFilter, setLevelFilter] = useState("all") + const [targetFilter, setTargetFilter] = useState("") + const [paused, setPaused] = useState(false) + const [loading, setLoading] = useState(true) + const [offline, setOffline] = useState(false) + const [autoScroll, setAutoScroll] = useState(true) + + const logContainerRef = useRef(null) + const pausedRef = useRef(false) + const entriesRef = useRef([]) + + // ── PAI logs state ────────────────────────────────────────────────── + const [paiLogs, setPaiLogs] = useState([]) + const [paiLoading, setPaiLoading] = useState(false) + const [paiLevelFilter, setPaiLevelFilter] = useState("all") + + // ── IronClaw SSE logic (unchanged) ────────────────────────────────── + + // Keep pausedRef in sync + useEffect(() => { + pausedRef.current = paused + }, [paused]) + + // Fetch initial history + connect SSE + useEffect(() => { + let eventSource: EventSource | null = null + let cancelled = false + + // Connect SSE for live log streaming + // (IronClaw doesn't have a REST logs endpoint -- SSE only) + eventSource = new EventSource("/api/ironclaw/logs/events") + + const handleLogEvent = (event: MessageEvent) => { + if (cancelled) return + try { + const raw = JSON.parse(event.data as string) as Record + // IronClaw sends levels in UPPERCASE ("INFO"), normalize to lowercase + const entry: LogEntry = { + ...raw, + level: (typeof raw.level === "string" ? raw.level.toLowerCase() : "info") as LogLevel, + } as LogEntry + entriesRef.current = [...entriesRef.current.slice(-(MAX_ENTRIES - 1)), entry] + if (!pausedRef.current) { + setEntries([...entriesRef.current]) + } + } catch { + // Ignore parse errors + } + } + + eventSource.addEventListener("log", handleLogEvent) + eventSource.addEventListener("message", handleLogEvent) + + eventSource.onopen = () => { + if (!cancelled) { + setOffline(false) + setLoading(false) + } + } + + eventSource.onerror = () => { + if (!cancelled) { + setOffline(true) + setLoading(false) + } + } + + // Mark as loaded after a short delay if SSE connects + const timer = setTimeout(() => { + if (!cancelled) setLoading(false) + }, 2000) + + return () => { + cancelled = true + clearTimeout(timer) + eventSource?.close() + } + }, []) + + // Auto-scroll on entries update + useEffect(() => { + if (autoScroll && logContainerRef.current) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight + } + }, [entries, autoScroll]) + + // Detect user scroll + const handleScroll = useCallback(() => { + const el = logContainerRef.current + if (!el) return + const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 50 + setAutoScroll(atBottom) + }, []) + + // Resume from paused: sync entries from ref + const togglePause = useCallback(() => { + setPaused((prev) => { + const next = !prev + if (!next) { + // Resuming -- sync state from ref + setEntries([...entriesRef.current]) + } + return next + }) + }, []) + + const clearEntries = useCallback(() => { + entriesRef.current = [] + setEntries([]) + }, []) + + // Client-side filtering (IronClaw) + const filteredEntries = entries.filter((entry) => { + if (levelFilter !== "all" && entry.level !== levelFilter) return false + if (targetFilter && !entry.target.toLowerCase().includes(targetFilter.toLowerCase())) return false + return true + }) + + // ── PAI logs logic ────────────────────────────────────────────────── + + const fetchPaiLogs = useCallback(async () => { + setPaiLoading(true) + try { + const res = await fetch("/api/pai/logs") + if (!res.ok) throw new Error("Failed to fetch PAI logs") + const data = (await res.json()) as { entries: PaiLogEntry[]; total: number } + setPaiLogs(data.entries) + } catch { + // Silently handle -- user can retry with refresh button + } finally { + setPaiLoading(false) + } + }, []) + + // Fetch PAI logs on mount + useEffect(() => { + void fetchPaiLogs() + }, [fetchPaiLogs]) + + // Client-side filtering (PAI) + const filteredPaiLogs = paiLogs.filter((entry) => { + if (paiLevelFilter !== "all" && entry.level !== paiLevelFilter) return false + return true + }) + + // ── Render ────────────────────────────────────────────────────────── + + if (loading && !offline) { + return ( +
+

+ + Logs +

+

PAI debug logs and IronClaw system logs

+
Loading...
+
+ ) + } + + return ( +
+

+ + Logs +

+

PAI debug logs and IronClaw system logs

+ + + + IronClaw Logs + PAI Logs + + + {/* ── IronClaw Logs Tab ── */} + + {offline ? ( + + + +

IronClaw is not running

+

Start IronClaw to view logs

+ + cd ~/ironclaw && cargo run + +
+
+ ) : ( + <> + {/* Controls */} + + +
+ + setTargetFilter(e.target.value)} + className="px-3 py-1.5 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-[#2e7de9]/40 dark:bg-[#1a1d2e] dark:border-gray-600 dark:text-gray-200 dark:placeholder-gray-500" + /> + + + {entries.length} entries +
+
+
+ + {/* Log entries */} + + +
+ {filteredEntries.length === 0 ? ( +
+

No log entries

+
+ ) : ( + filteredEntries.map((entry, idx) => ( +
+ + {entry.level} + + + {new Date(entry.timestamp).toLocaleTimeString()} + + + {entry.target} + + + {entry.message} + +
+ )) + )} +
+
+
+ + )} +
+ + {/* ── PAI Logs Tab ── */} + + {/* Controls */} + + +
+ + + {filteredPaiLogs.length} entries +
+
+
+ + {/* Log entries */} + + +
+ {filteredPaiLogs.length === 0 ? ( +
+

No PAI log entries

+
+ ) : ( + filteredPaiLogs.map((entry, idx) => ( +
+ + {entry.level} + + + {new Date(entry.timestamp).toLocaleTimeString()} + + + {entry.source} + + + {entry.message} + +
+ )) + )} +
+
+
+
+
+
+ ) +} diff --git a/Packs/pai-telos-skill/src/DashboardTemplate/App/memory/page.tsx b/Packs/pai-telos-skill/src/DashboardTemplate/App/memory/page.tsx new file mode 100644 index 000000000..9dd33009c --- /dev/null +++ b/Packs/pai-telos-skill/src/DashboardTemplate/App/memory/page.tsx @@ -0,0 +1,374 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Database, Edit3, Save, X, Brain } from "lucide-react" +import { FileTree } from "@/components/file-tree" +import type { MemoryEntry, MemoryNode, MemoryContent } from "@/types/ironclaw" + +/** PAI learning entry from /api/pai/memory */ +interface PaiLearning { + id: string + text: string + concept?: string + tier?: string +} + +/** PAI memory file from /api/pai/memory */ +interface PaiMemoryFile { + name: string + path: string + category: string +} + +/** Build a nested tree from IronClaw's flat entries list */ +function buildTree(entries: MemoryEntry[]): MemoryNode[] { + const root: MemoryNode[] = [] + const dirMap = new Map() + + // Sort so directories come before their children + const sorted = [...entries].sort((a, b) => a.path.localeCompare(b.path)) + + for (const entry of sorted) { + const parts = entry.path.split("/") + const name = parts[parts.length - 1] ?? entry.path + const node: MemoryNode = { name, path: entry.path, is_dir: entry.is_dir, children: [] } + + if (entry.is_dir) { + dirMap.set(entry.path, node) + } + + // Find parent directory + const parentPath = parts.slice(0, -1).join("/") + const parent = parentPath ? dirMap.get(parentPath) : undefined + + if (parent) { + parent.children.push(node) + } else { + root.push(node) + } + } + + return root +} + +export default function MemoryPage() { + // --- IronClaw state (unchanged) --- + const [tree, setTree] = useState([]) + const [selectedPath, setSelectedPath] = useState(null) + const [content, setContent] = useState(null) + const [isEditing, setIsEditing] = useState(false) + const [editContent, setEditContent] = useState("") + const [loading, setLoading] = useState(true) + const [offline, setOffline] = useState(false) + const [saving, setSaving] = useState(false) + + // --- PAI memory state --- + const [paiLearnings, setPaiLearnings] = useState([]) + const [paiFiles, setPaiFiles] = useState([]) + const [paiLoading, setPaiLoading] = useState(true) + const [paiLearningCount, setPaiLearningCount] = useState(0) + const [paiFileCount, setPaiFileCount] = useState(0) + + // Fetch PAI memory on mount + useEffect(() => { + let cancelled = false + async function fetchPaiMemory() { + try { + const res = await fetch("/api/pai/memory") + if (!res.ok) return + const data = await res.json() as { + learningCount?: number + learnings?: PaiLearning[] + fileCount?: number + files?: PaiMemoryFile[] + } + if (!cancelled) { + setPaiLearnings(data.learnings ?? []) + setPaiFiles(data.files ?? []) + setPaiLearningCount(data.learningCount ?? 0) + setPaiFileCount(data.fileCount ?? 0) + } + } catch { + // PAI memory fetch failed silently + } finally { + if (!cancelled) setPaiLoading(false) + } + } + void fetchPaiMemory() + return () => { cancelled = true } + }, []) + + // Fetch IronClaw tree on mount + useEffect(() => { + let cancelled = false + async function fetchTree() { + try { + const res = await fetch("/api/ironclaw/memory/tree") + if (!res.ok) { + setOffline(true) + return + } + const data = await res.json() as { entries?: MemoryEntry[] } + if (!cancelled && data.entries) { + setTree(buildTree(data.entries)) + } + } catch { + if (!cancelled) setOffline(true) + } finally { + if (!cancelled) setLoading(false) + } + } + void fetchTree() + return () => { cancelled = true } + }, []) + + // Fetch content when selectedPath changes + useEffect(() => { + if (!selectedPath) { + setContent(null) + return + } + let cancelled = false + async function fetchContent() { + try { + const res = await fetch(`/api/ironclaw/memory/read?path=${encodeURIComponent(selectedPath!)}`) + if (!res.ok) return + const data = await res.json() as MemoryContent + if (!cancelled) { + setContent(data) + setIsEditing(false) + } + } catch { + // Silently handle fetch errors + } + } + void fetchContent() + return () => { cancelled = true } + }, [selectedPath]) + + const handleSave = useCallback(async () => { + if (!selectedPath || !content) return + setSaving(true) + try { + const res = await fetch("/api/ironclaw/memory/write", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: selectedPath, content: editContent }), + }) + if (res.ok) { + const refreshRes = await fetch(`/api/ironclaw/memory/read?path=${encodeURIComponent(selectedPath)}`) + if (refreshRes.ok) { + const data = await refreshRes.json() as MemoryContent + setContent(data) + } + setIsEditing(false) + } + } catch { + // Silently handle save errors + } finally { + setSaving(false) + } + }, [selectedPath, content, editContent]) + + // Page-level loading: only while initial PAI fetch is running + if (paiLoading) { + return ( +
+

+ + Memory +

+

PAI and IronClaw memory systems

+
Loading...
+
+ ) + } + + return ( +
+

+ + Memory +

+

PAI and IronClaw memory systems

+ + + + + + PAI Memory + + + + IronClaw Memory + + + + {/* ===== PAI Memory Tab ===== */} + +
+ + + + Recent Learnings + {paiLearningCount} + + + + {paiLearnings.length === 0 ? ( +

No learnings recorded yet

+ ) : ( +
+ {paiLearnings.map((learning) => ( +
+

{learning.text}

+
+ {learning.concept && ( + {learning.concept} + )} + {learning.tier && ( + {learning.tier} + )} +
+
+ ))} +
+ )} +
+
+ + + + + Memory Files + {paiFileCount} + + + + {paiFiles.length === 0 ? ( +

No memory files found

+ ) : ( +
+ {paiFiles.map((file) => ( +
+ {file.category} + + {file.name} + +
+ ))} +
+ )} +
+
+
+
+ + {/* ===== IronClaw Memory Tab (existing functionality, unchanged) ===== */} + + {loading && !offline ? ( +
Loading IronClaw memory...
+ ) : offline ? ( + + + +

IronClaw is not running

+

Start IronClaw to browse memory

+ + cd ~/ironclaw && cargo run + +
+
+ ) : ( +
+ {/* Left panel -- file tree */} + + + Files + + + {tree.length === 0 ? ( +

No files in memory

+ ) : ( + + )} +
+
+ + {/* Right panel -- content viewer / editor */} + + {content ? ( + <> + +
+ {content.path} +
+ + Updated {new Date(content.updated_at).toLocaleString()} + +
+
+
+ {isEditing ? ( + <> + + + + ) : ( + + )} +
+
+ + {isEditing ? ( +