import { derived, type Readable } from 'svelte/store' import { messages, chatFor, currentSession, 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 // Deep link to an artifact this entry references — a recorded knowledge doc // or a looked-up entity — so the operator can open it directly instead of // having to navigate there by hand. Rendered as a clickable chip in the // timeline (F5). `slug` is an entity slug (e.g. "document:nomos/…"). link?: { kind: 'knowledge' | 'entity'; slug: 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 } // A knowledge doc slug as printed in upsert_knowledge's result text — mirrors // cmd/nomos/store.go's knowledgeSlugRe (e.g. "document:nomos/some-finding"). const KNOWLEDGE_SLUG_RE = /[a-z]+:nomos\/[a-z0-9-]+/ // An entity slug looks like "type:name" (host:strong, lxc:caddy); a bare UUID // or free text doesn't, so we only deep-link when it does. const ENTITY_SLUG_RE = /^[a-z][a-z0-9_]*:[^\s]+$/ // entityLinkFromArgs pulls a navigable slug out of a get_entity-style call's // args so its activity entry can link straight to that entity's window (F5). function entityLinkFromArgs(args: unknown): ActivityEntry['link'] | undefined { if (!args || typeof args !== 'object') return undefined const slug = (args as Record)?.slug_or_id if (typeof slug === 'string' && ENTITY_SLUG_RE.test(slug)) { return { kind: 'entity', slug } } return undefined } // knowledgeLinkFromResult extracts the created doc's slug from an // upsert_knowledge result so the "Recorded: …" entry links to the doc (F5). function knowledgeLinkFromResult(result: unknown): ActivityEntry['link'] | undefined { const s = typeof result === 'string' ? result : JSON.stringify(result ?? '') const m = s.match(KNOWLEDGE_SLUG_RE) return m ? { kind: 'knowledge', slug: m[0] } : undefined } // 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. // // Timestamps are REAL where the data has them and FROZEN where it doesn't: // - persisted tool calls use their message's created_at (a true time); // - steps use their started_at when present; // - only genuinely-live entries (a tool call on the in-flight message that // has no created_at yet) fall back to wall-clock, and that value is frozen // into `frozen` on FIRST sight so a re-derive (the 3s poller re-sets // `messages` every tick) reads the same value instead of marching every // entry forward. Before this, every entry's time was // `now - (len - index) * 1000` — fabricated at render time and churning // every poll (P0.2). `frozen` is owned by the caller and lives across // re-derivations; pass a fresh Map for a purity test. export function computeActivityLog( $msgs: ChatMessage[], $steps: PlanStep[], $task: Session | null, frozen: Map ): ActivityEntry[] { const entries: ActivityEntry[] = [] const now = Date.now() // freeze: prefer a real persisted time; else reuse a value already pinned // for this id; else pin wall-clock now and remember it. const freeze = (id: string, real?: number | null): number => { if (real && real > 0) return real const hit = frozen.get(id) if (hit !== undefined) return hit frozen.set(id, now) return now } const tsOf = (iso?: string): number | undefined => iso ? new Date(iso).getTime() || undefined : undefined // 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: freeze(s.id, tsOf(s.started_at)), 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. // // Generation awareness (plan 2026-08-03 F4): a re-proposed task persists // every generation's propose_plan/update_plan_step calls. Without scoping, // the timeline rendered N "Proposed plan" entries and inferred // currentStepSeq from superseded generations — tools landed under the wrong // (current-gen) step and it read as "several plans, some never run." So: // only the LAST propose_plan is the live plan; earlier ones collapse to a // single "Earlier plan revised" marker, and update_plan_step step-tracking // only applies to the current generation. let lastPlanMi = -1 let lastPlanTi = -1 let planCount = 0 for (let mi = 0; mi < $msgs.length; mi++) { for (let ti = 0; ti < $msgs[mi].tools.length; ti++) { if ($msgs[mi].tools[ti].name === 'propose_plan') { planCount++ lastPlanMi = mi lastPlanTi = ti } } } const revised = planCount > 1 let currentStepSeq = 0 let entryIdx = 0 // No plan at all (plan-less Q&A) → treat the whole transcript as current. let sawCurrentPlan = planCount === 0 let emittedRevised = false for (let mi = 0; mi < $msgs.length; mi++) { const msgTs = tsOf($msgs[mi].created_at) for (let ti = 0; ti < $msgs[mi].tools.length; ti++) { const t = $msgs[mi].tools[ti] const isLastPlan = mi === lastPlanMi && ti === lastPlanTi if (isLastPlan) sawCurrentPlan = true // Track current step ONLY from the current generation's // update_plan_step calls; a superseded generation's seqs would tag // tools with the wrong (current-gen) step. if (sawCurrentPlan && 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 } // Skip superseded-generation propose_plan entries; emit one collapsed // "revised" marker so a re-proposal stays visible without reading as a // second active plan. if (t.name === 'propose_plan' && !isLastPlan) { if (revised && !emittedRevised) { emittedRevised = true entries.push({ id: `plan_revised_${mi}_${ti}`, type: 'plan', description: 'Earlier plan revised', timestamp: freeze(`plan_revised_${mi}_${ti}`, msgTs), status: 'done' }) } continue } const label = toolActivityLabel(t) const stepTag = currentStepSeq > 0 ? currentStepSeq : undefined const id = t.id ?? `tool_${mi}_${entryIdx++}` if (t.type === 'tool_use') { entries.push({ id, type: 'tool_running', description: label, args: summarizeArgs(t.args), timestamp: freeze(id, msgTs), 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) if (t.name === 'get_entity' || t.name === 'get_entity_knowledge') { running.link = entityLinkFromArgs(t.args) } } 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, 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: freeze(id, msgTs), toolName: t.name, stepSeq: stepTag, indent: stepTag != null, status: t.error ? 'failed' : 'done', link: t.name === 'get_entity' || t.name === 'get_entity_knowledge' ? entityLinkFromArgs(t.args) : undefined }) } } } } // Knowledge recorded — detect from upsert_knowledge tool results for (let mi = 0; mi < $msgs.length; mi++) { const msgTs = tsOf($msgs[mi].created_at) 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 : '' const kid = `knowledge_${mi}_${t.id ?? ''}` entries.push({ id: kid, type: 'knowledge', description: title ? `Recorded: ${title.slice(0, 60)}` : 'Recorded knowledge', timestamp: freeze(kid, msgTs), status: 'done', link: knowledgeLinkFromResult(t.result) }) } } } // Task completion if ($task?.outcome) { entries.push({ id: 'complete', type: 'complete', description: $task.summary || `Task ${$task.outcome}`, timestamp: freeze('complete'), 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 } // A per-store freeze map: the first time a live entry (no real timestamp // yet) is seen, its wall-clock time is pinned here so the 3s poller's // re-derivation can't march it forward. Owned here, outside the derivation, // so it survives re-runs. The per-session path has its own Map keyed by id. const frozenTimestamps = new Map() // Live execution output for whichever session the global "current session" // view is on — used to attach streaming `run` output to the global activityLog // (the per-window activityLogFor has its own). Follows currentSession via a // derived setup function so the subscription moves to the right session's // store when the operator switches tasks. const currentLiveOutput = derived( currentSession, ($sid, set) => { if (!$sid) { set(null) return } return liveExecutionOutputFor($sid).subscribe(set) }, null as LiveExecutionOutput | null ) export const activityLog = derived( [messages, planSteps, currentTask, currentLiveOutput], ([$msgs, $steps, $task, $live]) => withLiveOutput(computeActivityLog($msgs, $steps, $task, frozenTimestamps), $live) ) // 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 } // One freeze map per session window (entry ids are UUIDs, but the synthetic // 'goal'/'complete' ids collide across sessions, so each window keeps its own). const sessionFrozenTimestamps = new Map>() export function activityLogFor(sessionId: string): Readable { const chat = chatFor(sessionId) const ws = workspaceFor(sessionId) const task = taskFor(sessionId) const live = liveExecutionOutputFor(sessionId) let frozen = sessionFrozenTimestamps.get(sessionId) if (!frozen) { frozen = new Map() sessionFrozenTimestamps.set(sessionId, frozen) } return derived([chat.messages, ws.planSteps, task, live], ([$msgs, $steps, $task, $live]) => withLiveOutput(computeActivityLog($msgs, $steps, $task, frozen), $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()) } } // ── toolResultSummary ──────────────────────────────────────────────────── // A one-line, humanized summary of a tool's RESULT (the Claude-Code-style // "exit 0 · " / "host:hubris (healthy)" affordance) so each tool line // in the inline trace reads as an outcome instead of a raw JSON blob. Empty // for a still-running call (no result yet) or an errored one (the error is // surfaced separately). Best-effort by tool name; the fallback is the first // non-empty line of the stringified result, truncated — never blank (the // expandable raw detail is always one click away). function resultAsString(r: unknown): string { if (r == null) return '' if (typeof r === 'string') return r try { return JSON.stringify(r) } catch { return String(r) } } function firstLine(s: string, max = 80): string { const line = s .split(/\r?\n/) .map((l) => l.trim()) .find((l) => l.length > 0) ?? '' return line.length > max ? `${line.slice(0, max - 1)}…` : line } function resultArray(r: unknown): unknown[] | null { if (Array.isArray(r)) return r if (r && typeof r === 'object') { const o = r as Record for (const k of [ 'entities', 'results', 'relations', 'steps', 'items', 'containers', 'docs', 'questions', 'signals', 'events', 'patterns', 'skills' ]) { if (Array.isArray(o[k])) return o[k] as unknown[] } } return null } function num(v: unknown): number | null { return typeof v === 'number' && Number.isFinite(v) ? v : null } function plural(n: number, singular: string, pluralForm = `${singular}s`): string { return `${n} ${n === 1 ? singular : pluralForm}` } export function toolResultSummary(t: ToolCallResult): string { if (t.type === 'tool_use') return '' // still running if (t.error) return '' // error surfaced separately const args = t.args ?? {} const str = (v: unknown): string => (typeof v === 'string' ? v : '') const obj = (r: unknown): Record | null => r && typeof r === 'object' && !Array.isArray(r) ? (r as Record) : null switch (t.name) { case 'run': { const s = resultAsString(t.result) const m = s.match(/exit (?:status )?(\d+)/i) const tag = m ? `exit ${m[1]}` : /error/i.test(s) ? 'error' : 'ok' const rest = firstLine(s.replace(/[\s\S]*exit (?:status )?\d+/i, ''), 60) return rest ? `${tag} · ${rest}` : tag } case 'get_entity': { const o = obj(t.result) const slug = str(o?.slug) || str(args.slug_or_id) const health = str(o?.health) || str(o?.state) return [slug, health && `(${health})`].filter(Boolean).join(' ') || 'found' } case 'get_relations': { const a = resultArray(t.result) return a ? plural(a.length, 'relation') : 'done' } case 'list_entities': case 'list_lxcs': { const a = resultArray(t.result) if (!a) return 'done' return t.name === 'list_lxcs' ? plural(a.length, 'container') : plural(a.length, 'entity', 'entities') } case 'get_health_summary': { const o = obj(t.result) const h = (o?.health && obj(o.health)) || o if (h) { const parts = ['healthy', 'degraded', 'down', 'unknown'] .map((k) => { const n = num((h as Record)[k]) return n != null ? `${k} ${n}` : null }) .filter((p): p is string => p != null) if (parts.length) return parts.join(' · ') } return 'done' } case 'get_state_snapshot': { const o = obj(t.result) const drift = num(o?.drift ?? o?.drift_count) return drift != null ? plural(drift, 'drift') : 'done' } case 'search_knowledge': case 'get_entity_knowledge': case 'get_patterns': case 'get_skills': { const a = resultArray(t.result) return a ? plural(a.length, 'result') : firstLine(resultAsString(t.result)) || 'done' } case 'upsert_knowledge': { const m = resultAsString(t.result).match(/[a-z]+:nomos\/[a-z0-9-]+/) return m ? `recorded ${m[0]}` : 'recorded' } case 'update_plan_step': { const seq = num(args.seq) const status = str(args.status) if (seq != null && status) return `step ${seq} → ${status}` return status || 'updated' } case 'propose_plan': { const a = resultArray(t.result) ?? resultArray(args.steps) return a ? plural(a.length, 'step') : 'planned' } case 'set_goal': return 'goal set' case 'complete_task': return str(args.outcome) || 'complete' case 'ask_operator': return 'asked' case 'ping_service': { const s = resultAsString(t.result).toLowerCase() return /ok|reachable|up|healthy/.test(s) ? 'reachable' : firstLine(s, 40) || 'done' } case 'get_execution_status': { const o = obj(t.result) return str(o?.state) || firstLine(resultAsString(t.result), 40) || 'done' } default: return firstLine(resultAsString(t.result)) || 'done' } }