import { derived, type Readable } from 'svelte/store' import { messages, chatFor, type ChatMessage, type ToolCallResult } from './chat' import { planSteps, currentTask, workspaceFor, taskFor } from './workspace' import { liveExecutionOutputFor, type LiveExecutionOutput } from './execstream' import type { PlanStep, Session } from '$lib/api' export { type ToolCallResult } export interface ActivityEntry { id: string type: | 'goal' | 'plan' | 'step_running' | 'step_done' | 'step_failed' | 'tool_running' | 'tool_done' | 'tool_error' | 'knowledge' | 'complete' | 'question' | 'error' description: string detail?: string args?: string timestamp: number toolName?: string stepSeq?: number indent?: boolean status: 'running' | 'done' | 'failed' // Command output streaming in while a `run` tool call is still executing. // Distinct from `detail`, which is only populated once the tool_result // arrives — for an auto-run that is the moment the command finishes. liveOutput?: string } // Detail text is kept full-length (not hard-truncated to a preview snippet) // so the expanded view has something worth pretty-printing — capped only as // a safety net against pathological payloads (a full fleet dump, etc). const DETAIL_MAX = 8000 function summarizeArgs(args: unknown): string | undefined { if (!args || typeof args !== 'object' || Array.isArray(args)) return undefined if (Object.keys(args).length === 0) return undefined try { return JSON.stringify(args) } catch { return undefined } } function stringifyResult(result: unknown): string { const s = typeof result === 'string' ? result : JSON.stringify(result ?? '') return s.length > DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}\n… truncated` : s } // Pure derivation, parameterized so it can back both the global "current // session" activityLog below and a per-session activityLogFor(sessionId) for // a floating task window. function computeActivityLog( $msgs: ChatMessage[], $steps: PlanStep[], $task: Session | null ): ActivityEntry[] { const entries: ActivityEntry[] = [] const now = Date.now() // Goal if ($task?.goal) { entries.push({ id: 'goal', type: 'goal', description: $task.goal, timestamp: 0, status: 'done' }) } // Plan steps for (const s of $steps) { if (s.status === 'pending') continue const stepLabel = s.title || `Step ${s.seq}` entries.push({ id: s.id, type: s.status === 'running' ? 'step_running' : s.status === 'done' ? 'step_done' : 'step_failed', description: `Step ${s.seq}: ${stepLabel}`, detail: s.detail || undefined, timestamp: s.started_at ? new Date(s.started_at).getTime() : now, status: s.status === 'running' ? 'running' : s.status === 'done' ? 'done' : 'failed' }) } // Tool calls (from messages). Tag each tool with the plan step that's // currently running when it fires. let currentStepSeq = 0 let entryIdx = 0 for (let mi = 0; mi < $msgs.length; mi++) { for (const t of $msgs[mi].tools) { // Track current step from update_plan_step calls if (t.type === 'tool_use' && t.name === 'update_plan_step') { const s = typeof t.args?.seq === 'number' ? t.args.seq : undefined const status = typeof t.args?.status === 'string' ? t.args.status : undefined if (s && status === 'running') currentStepSeq = s } else if (t.name === 'set_goal' || t.name === 'propose_plan' || t.name === 'complete_task') { currentStepSeq = 0 } const label = toolActivityLabel(t) const stepTag = currentStepSeq > 0 ? currentStepSeq : undefined if (t.type === 'tool_use') { entries.push({ id: t.id ?? `tool_${mi}_${entryIdx++}`, type: 'tool_running', description: label, args: summarizeArgs(t.args), timestamp: now - ($msgs.length - mi) * 1000, toolName: t.name, stepSeq: stepTag, indent: stepTag != null, status: 'running' }) } else if (t.type === 'tool_result') { const running = entries.find( (e) => e.type === 'tool_running' && e.id === t.id && e.status === 'running' ) if (running && t.error) { running.type = 'tool_error' running.status = 'failed' running.description = `${label}: ${t.error.slice(0, 80)}` running.detail = t.error } else if (running) { running.type = 'tool_done' running.status = 'done' running.detail = stringifyResult(t.result) } else { // Historical/persisted tool calls arrive as one merged record (args // + result on the same object, see mergeToolCalls in chat.ts) rather // than a separate tool_use/tool_result pair — there's never a // "running" entry to attach to, so this branch has to build the // full entry itself. It used to fall back to the raw tool name // (e.g. "get_entity") instead of the humanized label here. entries.push({ id: t.id ?? `tool_${mi}_${entryIdx++}`, type: t.error ? 'tool_error' : 'tool_done', description: t.error ? `${label}: ${t.error.slice(0, 80)}` : label, detail: t.error ? t.error : stringifyResult(t.result), args: summarizeArgs(t.args), timestamp: now - ($msgs.length - mi) * 1000, toolName: t.name, stepSeq: stepTag, indent: stepTag != null, status: t.error ? 'failed' : 'done' }) } } } } // Knowledge recorded — detect from upsert_knowledge tool results for (let mi = 0; mi < $msgs.length; mi++) { for (const t of $msgs[mi].tools) { if (t.type === 'tool_result' && t.name === 'upsert_knowledge' && !t.error) { const title = typeof t.args?.title === 'string' ? t.args.title : '' entries.push({ id: `knowledge_${mi}`, type: 'knowledge', description: title ? `Recorded: ${title.slice(0, 60)}` : 'Recorded knowledge', timestamp: now - ($msgs.length - mi) * 1000, status: 'done' }) } } } // Task completion if ($task?.outcome) { entries.push({ id: 'complete', type: 'complete', description: $task.summary || `Task ${$task.outcome}`, timestamp: now, status: $task.outcome === 'failure' ? 'failed' : 'done' }) } // Note: approval entries were removed from activityLog (2026-07-15). // They were always `status: 'running'` and never transitioned to 'done' // (the derived store builds from tool-call text, not execution status), // which caused the AgentIndicator to latch onto a stale "Approval: ..." // entry and never clear — even after the session completed. Approvals // are tracked via the REST /approvals endpoint (context.ts, Ops.svelte) // and rendered as inline approval cards in the chat (or Ops page), not // in the activity log. // Sort oldest first entries.sort((a, b) => a.timestamp - b.timestamp) return entries } export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, $steps, $task]) => computeActivityLog($msgs, $steps, $task) ) // Attach streaming output to the `run` entry that is currently executing. // Nomos runs tools sequentially, so the last still-running run entry is the // one the output belongs to. function withLiveOutput( entries: ActivityEntry[], live: LiveExecutionOutput | null ): ActivityEntry[] { if (!live?.output) return entries for (let i = entries.length - 1; i >= 0; i--) { const e = entries[i] if (e.type === 'tool_running' && e.status === 'running' && e.toolName === 'run') { entries[i] = { ...e, liveOutput: live.output } break } } return entries } export function activityLogFor(sessionId: string): Readable { const chat = chatFor(sessionId) const ws = workspaceFor(sessionId) const task = taskFor(sessionId) const live = liveExecutionOutputFor(sessionId) return derived([chat.messages, ws.planSteps, task, live], ([$msgs, $steps, $task, $live]) => withLiveOutput(computeActivityLog($msgs, $steps, $task), $live) ) } // Humanized, past/present-tense description of what a tool call is doing // ("Check execution", "Research: …") rather than its raw wire name. Exported // so the chat's agent trace can read as a thinking log instead of an API log. export function toolActivityLabel(t: ToolCallResult): string { const args = t.args ?? {} const str = (v: unknown): string => (typeof v === 'string' ? v : '') switch (t.name) { case 'set_goal': return 'Set goal' case 'propose_plan': return 'Proposed plan' case 'search_knowledge': return `Research: ${str(args.query)}` case 'get_entity': return `Lookup: ${str(args.slug_or_id)}` case 'get_entity_knowledge': return 'Check prior knowledge' case 'get_relations': return 'Check relationships' case 'list_lxcs': return 'List containers' case 'list_entities': return 'List entities' case 'get_health_summary': return 'Fleet health' case 'get_state_snapshot': return 'State snapshot' case 'run': { const purpose = str(args.purpose) const target = str(args.target) if (purpose) return purpose if (target) return `Run on ${target}` return 'Run command' } case 'get_execution_status': return 'Check execution' case 'update_plan_step': return 'Update plan' case 'upsert_knowledge': return 'Record knowledge' case 'complete_task': return 'Complete task' case 'ping_service': return 'Check service' case 'ask_operator': return 'Ask operator' // Unmapped tool (new/uncommon) — humanize the raw name rather than // showing it verbatim, e.g. "revoke_execution" -> "Revoke execution". default: return t.name.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase()) } }