This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

refactoring

+2586 -1638
+9
.env.example
··· 2 2 OPENAI_BASE_URL=https://api.openai.com/v1 3 3 OPENAI_API_KEY= 4 4 MODEL= 5 + # Optional User-Agent override for all OpenAI-compatible clients. 6 + # Provider-specific *_OPENAI_USER_AGENT values override this default. 7 + OPENAI_USER_AGENT= 5 8 # Enable/disable model reasoning traces (thinking) across requests + stream output. 6 9 ENABLE_THINKING=true 7 10 ··· 13 16 EMBEDDING_BASE_URL=https://openrouter.ai/api/v1 14 17 EMBEDDING_MODEL=google/gemini-embedding-2-preview 15 18 EMBEDDING_DIMENSIONS=3072 19 + EMBEDDING_OPENAI_USER_AGENT= 16 20 17 21 # Used when the primary endpoint is unreachable or rate-limited (429/5xx). 18 22 # Supports any OpenAI-compatible provider (OpenRouter, LM Studio, etc.). ··· 24 28 # Optional headers 25 29 FALLBACK_OPENAI_REFERER= 26 30 FALLBACK_OPENAI_TITLE= 31 + FALLBACK_OPENAI_USER_AGENT= 32 + # Optional summary provider User-Agent override when SUMMARY_* provider is configured. 33 + SUMMARY_OPENAI_USER_AGENT= 27 34 # Context window enforcement for fallback. Defaults to true for localhost, false otherwise. 28 35 FALLBACK_ENFORCE_CONTEXT_LIMIT= 29 36 FALLBACK_N_CTX=4096 ··· 36 43 # Display name for the agent, used in the summarizer prompt and grounding context. 37 44 # Defaults to "niri" when unset. 38 45 # AGENT_NAME=niri 46 + # Stable worker id reported to the control panel. 47 + # NIRI_AGENT_ID=niri 39 48 40 49 # Leave unset for raw local shell access. Set both values to route shell tools 41 50 # through docker exec instead; NIRI_USER must match the Dockerfile user.
+842 -251
apps/web/src/App.tsx
··· 1 1 import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react" 2 - import { createChatClient, type StreamEvent } from "@niri/chat-client" 3 - import { MarkdownBlock } from "./MarkdownBlock" 4 - import { MetricsWorkbench } from "./MetricsWorkbench" 2 + import ReactMarkdown from "react-markdown" 3 + import remarkGfm from "remark-gfm" 4 + import hljs from "highlight.js/lib/core" 5 + import bash from "highlight.js/lib/languages/bash" 6 + import css from "highlight.js/lib/languages/css" 7 + import go from "highlight.js/lib/languages/go" 8 + import json from "highlight.js/lib/languages/json" 9 + import markdown from "highlight.js/lib/languages/markdown" 10 + import python from "highlight.js/lib/languages/python" 11 + import rust from "highlight.js/lib/languages/rust" 12 + import sql from "highlight.js/lib/languages/sql" 13 + import typescript from "highlight.js/lib/languages/typescript" 14 + 15 + hljs.registerLanguage("bash", bash) 16 + hljs.registerLanguage("css", css) 17 + hljs.registerLanguage("go", go) 18 + hljs.registerLanguage("json", json) 19 + hljs.registerLanguage("markdown", markdown) 20 + hljs.registerLanguage("python", python) 21 + hljs.registerLanguage("rust", rust) 22 + hljs.registerLanguage("sql", sql) 23 + hljs.registerLanguage("typescript", typescript) 24 + 25 + type Panel = { 26 + id: string 27 + name: string 28 + baseUrl: string 29 + } 30 + 31 + type Agent = { 32 + id: string 33 + name: string 34 + baseUrl: string 35 + status: string 36 + lastSeenAt?: string 37 + lastSeq: number 38 + } 39 + 40 + type WorkerStatus = { 41 + agentId?: string 42 + running?: boolean 43 + idle?: boolean 44 + tokenCount?: number 45 + contextSize?: number 46 + processStartedAt?: string 47 + uptimeMs?: number 48 + error?: string 49 + } 50 + 51 + type Compaction = { 52 + agentId: string 53 + seq: number 54 + eventId: string 55 + metricId?: number 56 + timestamp: string 57 + method?: string 58 + before?: number 59 + after?: number 60 + savedTokens?: number 61 + summary?: string 62 + } 63 + 64 + type WorkerEvent = { 65 + id: string 66 + agentId: string 67 + seq: number 68 + type: string 69 + createdAt: string 70 + payload: unknown 71 + } 72 + 73 + type Overview = { 74 + agent: Agent 75 + status: WorkerStatus 76 + compactions: Compaction[] 77 + } 78 + 79 + type ChatLine = { 80 + key: string 81 + seq: number 82 + at: string 83 + kind: "agent" | "user" | "tool" | "note" | "context" | "error" 84 + label: string 85 + text: string 86 + detail?: string 87 + } 88 + 89 + const PANEL_COOKIE = "niri_control_panels" 90 + const WORKER_EVENT_TYPES = [ 91 + "worker.hello", 92 + "worker.heartbeat", 93 + "runner.status", 94 + "stream.event", 95 + "conversation.started", 96 + "conversation.message", 97 + "conversation.ended", 98 + ] 99 + 100 + function cookieValue(name: string): string | null { 101 + const prefix = `${name}=` 102 + const item = document.cookie.split("; ").find((part) => part.startsWith(prefix)) 103 + return item ? decodeURIComponent(item.slice(prefix.length)) : null 104 + } 105 + 106 + function writeCookie(name: string, value: string): void { 107 + document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=31536000; Path=/; SameSite=Lax` 108 + } 109 + 110 + function createId(prefix: string): string { 111 + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { 112 + return `${prefix}-${crypto.randomUUID()}` 113 + } 114 + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` 115 + } 116 + 117 + function cleanBaseUrl(raw: string): string { 118 + return raw.trim().replace(/\/+$/, "") 119 + } 120 + 121 + function urlFor(panel: Panel, path: string): string { 122 + const base = panel.baseUrl || window.location.origin 123 + return new URL(path, base.endsWith("/") ? base : `${base}/`).toString() 124 + } 125 + 126 + function readPanels(): Panel[] { 127 + const raw = cookieValue(PANEL_COOKIE) 128 + if (raw) { 129 + try { 130 + const parsed = JSON.parse(raw) as Panel[] 131 + if (Array.isArray(parsed) && parsed.length > 0) { 132 + return parsed 133 + .filter((panel) => typeof panel.id === "string" && typeof panel.baseUrl === "string") 134 + .map((panel) => ({ 135 + id: panel.id, 136 + name: panel.name || panel.baseUrl || "this control", 137 + baseUrl: cleanBaseUrl(panel.baseUrl), 138 + })) 139 + } 140 + } catch { 141 + // Ignore old or malformed cookies. 142 + } 143 + } 144 + 145 + return [ 146 + { 147 + id: "same-origin", 148 + name: "this control", 149 + baseUrl: "", 150 + }, 151 + ] 152 + } 153 + 154 + function savePanels(panels: Panel[]): void { 155 + writeCookie(PANEL_COOKIE, JSON.stringify(panels)) 156 + } 157 + 158 + async function requestJson<T>(url: string, init?: RequestInit): Promise<T> { 159 + const res = await fetch(url, { 160 + ...init, 161 + headers: { 162 + ...(init?.body ? { "content-type": "application/json" } : {}), 163 + ...(init?.headers ?? {}), 164 + }, 165 + }) 166 + const text = await res.text() 167 + const data = text ? JSON.parse(text) : null 168 + if (!res.ok) { 169 + const message = data && typeof data === "object" && "error" in data ? String(data.error) : `${res.status} ${res.statusText}` 170 + throw new Error(message) 171 + } 172 + return data as T 173 + } 174 + 175 + function eventPayloadObject(event: WorkerEvent): Record<string, unknown> { 176 + return event.payload && typeof event.payload === "object" ? (event.payload as Record<string, unknown>) : {} 177 + } 178 + 179 + function formatTime(value: string | undefined): string { 180 + if (!value) return "" 181 + const date = new Date(value) 182 + if (Number.isNaN(date.getTime())) return "" 183 + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) 184 + } 185 + 186 + function formatDuration(ms: number | undefined): string { 187 + if (!ms || ms < 0) return "just started" 188 + const totalSeconds = Math.floor(ms / 1000) 189 + const hours = Math.floor(totalSeconds / 3600) 190 + const minutes = Math.floor((totalSeconds % 3600) / 60) 191 + if (hours > 0) return `${hours}h ${minutes}m` 192 + if (minutes > 0) return `${minutes}m` 193 + return `${Math.max(1, totalSeconds)}s` 194 + } 195 + 196 + function formatNumber(value: number | undefined): string { 197 + if (typeof value !== "number" || !Number.isFinite(value)) return "0" 198 + return new Intl.NumberFormat().format(value) 199 + } 200 + 201 + function jsonToolLabel(value: unknown): string | null { 202 + if (Array.isArray(value)) { 203 + if (value.length === 0) return "[]" 204 + const first = value[0] 205 + if (first && typeof first === "object" && "item_id" in first) return `discord_inbox ${value.length} item${value.length === 1 ? "" : "s"}` 206 + if (first && typeof first === "object" && "message_id" in first) return `discord_messages ${value.length}` 207 + return `json array ${value.length}` 208 + } 209 + 210 + if (!value || typeof value !== "object") return null 211 + const record = value as Record<string, unknown> 212 + if ("sent_message_id" in record) return `discord_send ${record.ok === true ? "ok" : "result"}` 213 + if ("item_id" in record && "status" in record) return `discord_mark ${String(record.status)}` 214 + if ("scanned_channels" in record) return `discord_scan ${formatNumber(Number(record.fetched_messages))} messages` 215 + if ("ok" in record) return record.ok === true ? "ok" : "tool result" 216 + return null 217 + } 218 + 219 + function toolLabel(text: string): string { 220 + const first = text.split("\n").find(Boolean) 221 + if (!first) return "tool" 222 + if (first.trim().startsWith("{") || first.trim().startsWith("[")) { 223 + try { 224 + return jsonToolLabel(JSON.parse(text)) ?? "tool result" 225 + } catch { 226 + return "tool result" 227 + } 228 + } 229 + if (first.length <= 82) return first 230 + return `${first.slice(0, 79)}...` 231 + } 232 + 233 + function discordContextLine(text: string): Pick<ChatLine, "kind" | "label" | "text" | "detail"> { 234 + const stats = text.match(/new_messages=(\d+).*?channels=(\d+).*?pending_inbox=(\d+)/s) 235 + const recent = text.match(/recent messages:\s*([\s\S]*?)(?:\n\npending preview:|$)/) 236 + const newMessages = stats?.[1] ?? "some" 237 + const channels = stats?.[2] ?? "?" 238 + const pending = stats?.[3] ?? "?" 239 + return { 240 + kind: "context", 241 + label: "discord context", 242 + text: `${newMessages} recent messages across ${channels} channel${channels === "1" ? "" : "s"} - ${pending} pending`, 243 + detail: recent?.[1]?.trim() || text.trim(), 244 + } 245 + } 246 + 247 + function isDiscordContextEnvelope(envelopeName: string): boolean { 248 + return ( 249 + envelopeName.startsWith("incoming") || 250 + envelopeName.startsWith("discord batch") || 251 + envelopeName.startsWith("discord context") 252 + ) 253 + } 254 + 255 + function userLineFromContent(content: string, fallbackLabel = "you"): Pick<ChatLine, "kind" | "label" | "text" | "detail"> { 256 + const trimmed = content.trim() 257 + const envelope = trimmed.match(/^\[([^\]]+)\]\s*([\s\S]*)$/) 258 + const envelopeName = envelope?.[1]?.toLowerCase() ?? "" 259 + const body = envelope?.[2]?.trim() ?? trimmed 260 + 261 + if (isDiscordContextEnvelope(envelopeName)) return discordContextLine(body) 262 + if (envelopeName.startsWith("system")) { 263 + return { 264 + kind: "context", 265 + label: "system", 266 + text: body.split("\n").find(Boolean)?.trim() || "system event", 267 + detail: body, 268 + } 269 + } 270 + if (envelopeName.startsWith("wake")) { 271 + return { 272 + kind: "note", 273 + label: "wake", 274 + text: envelope?.[1] ?? "wake", 275 + detail: body, 276 + } 277 + } 278 + if (envelopeName.startsWith("harness restarted")) { 279 + return { 280 + kind: "note", 281 + label: "harness", 282 + text: "restarted", 283 + detail: body, 284 + } 285 + } 286 + 287 + return { 288 + kind: "user", 289 + label: fallbackLabel, 290 + text: trimmed, 291 + } 292 + } 293 + 294 + function linesFromEvents(events: WorkerEvent[]): ChatLine[] { 295 + const lines: ChatLine[] = [] 296 + const sorted = [...events].sort((a, b) => a.seq - b.seq) 297 + const streamUserTexts = new Set<string>() 298 + 299 + for (const event of sorted) { 300 + if (event.type !== "stream.event") continue 301 + const payload = eventPayloadObject(event) 302 + if (payload.type === "user" && typeof payload.text === "string" && payload.text.trim()) { 303 + streamUserTexts.add(payload.text.trim()) 304 + } 305 + } 306 + 307 + for (const event of sorted) { 308 + const payload = eventPayloadObject(event) 309 + 310 + if (event.type === "stream.event" && payload.type === "user" && typeof payload.text === "string") { 311 + const text = payload.text.trim() 312 + if (!text) continue 313 + const clean = userLineFromContent(text, String(payload.source ?? "you")) 314 + lines.push({ 315 + key: event.id, 316 + seq: event.seq, 317 + at: event.createdAt, 318 + kind: clean.kind, 319 + label: clean.label === "chat" ? "you" : clean.label, 320 + text: clean.text, 321 + detail: clean.detail, 322 + }) 323 + continue 324 + } 325 + 326 + if (event.type === "conversation.started") { 327 + lines.push({ 328 + key: event.id, 329 + seq: event.seq, 330 + at: event.createdAt, 331 + kind: "note", 332 + label: "session", 333 + text: `started from ${String(payload.source ?? "unknown")}`, 334 + }) 335 + continue 336 + } 337 + 338 + if (event.type === "conversation.ended") { 339 + lines.push({ 340 + key: event.id, 341 + seq: event.seq, 342 + at: event.createdAt, 343 + kind: "note", 344 + label: "session", 345 + text: `ended with ${formatNumber(typeof payload.tokens === "number" ? payload.tokens : undefined)} tokens`, 346 + }) 347 + continue 348 + } 5 349 6 - type Entry = 7 - | { id: number; kind: "info"; text: string } 8 - | { id: number; kind: "error"; text: string } 9 - | { id: number; kind: "user"; text: string } 10 - | { id: number; kind: "incoming"; source: string; text: string } 11 - | { id: number; kind: "text"; text: string } 12 - | { id: number; kind: "thinking"; text: string } 13 - | { id: number; kind: "tool"; name: string; args: Record<string, unknown>; result: string } 350 + if (event.type !== "conversation.message") continue 14 351 15 - type WithoutId<T> = T extends { id: number } ? Omit<T, "id"> : never 352 + const role = String(payload.role ?? "") 353 + const content = String(payload.content ?? "") 354 + if (!content.trim()) continue 16 355 17 - type NewEntry = WithoutId<Entry> 356 + if (role === "assistant") { 357 + lines.push({ 358 + key: event.id, 359 + seq: event.seq, 360 + at: String(payload.createdAt ?? event.createdAt), 361 + kind: "agent", 362 + label: "agent", 363 + text: content, 364 + }) 365 + continue 366 + } 18 367 19 - const toolSummary = (name: string, args: Record<string, unknown>): string => { 20 - switch (name) { 21 - case "shell": 22 - return `$ ${String(args.command ?? "")}` 23 - case "read_file": { 24 - const start = args.start_line ? `:${String(args.start_line)}` : "" 25 - const end = args.end_line ? `-${String(args.end_line)}` : "" 26 - return `read ${String(args.path ?? "")}${start}${end}` 368 + if (role === "tool") { 369 + lines.push({ 370 + key: event.id, 371 + seq: event.seq, 372 + at: String(payload.createdAt ?? event.createdAt), 373 + kind: "tool", 374 + label: toolLabel(content), 375 + text: content, 376 + }) 377 + continue 27 378 } 28 - case "edit_file": 29 - return `edit ${String(args.path ?? "")}` 30 - case "memory_search": 31 - return `memory ${String(args.query ?? "")}` 32 - case "rest": 33 - return `rest${args.note ? ` ${String(args.note)}` : ""}` 34 - default: 35 - return `${name} ${JSON.stringify(args)}` 379 + 380 + if (role === "user") { 381 + if (streamUserTexts.has(content.trim())) continue 382 + if (content.trim().startsWith("[wake]")) continue 383 + const clean = userLineFromContent(content) 384 + lines.push({ 385 + key: event.id, 386 + seq: event.seq, 387 + at: String(payload.createdAt ?? event.createdAt), 388 + kind: clean.kind, 389 + label: clean.label, 390 + text: clean.text, 391 + detail: clean.detail, 392 + }) 393 + } 394 + } 395 + 396 + return lines 397 + } 398 + 399 + function overviewStatusText(status: WorkerStatus | null): string { 400 + if (!status) return "not checked" 401 + if (status.error) return "unreachable" 402 + if (status.running) return status.idle ? "waiting" : "awake" 403 + return "resting" 404 + } 405 + 406 + function codeLanguage(text: string): string | undefined { 407 + const trimmed = text.trim() 408 + if (!trimmed) return undefined 409 + if (trimmed.startsWith("{") || trimmed.startsWith("[")) return "json" 410 + if (/\.(tsx?|jsx?|mjs|cjs)\b/.test(trimmed)) return "typescript" 411 + if (/\.(css|html|json|md|py|rs|go|sh|bash|zsh)\b/.test(trimmed)) { 412 + const ext = trimmed.match(/\.(css|html|json|md|py|rs|go|sh|bash|zsh)\b/)?.[1] 413 + if (ext === "py") return "python" 414 + if (ext === "rs") return "rust" 415 + if (ext === "md") return "markdown" 416 + if (ext === "sh" || ext === "zsh") return "bash" 417 + return ext 36 418 } 419 + if (/\b(import|export|const|let|function|type|interface)\b/.test(trimmed)) return "typescript" 420 + if (/\b(select|insert|update|create table|from|where)\b/i.test(trimmed)) return "sql" 421 + return undefined 37 422 } 38 423 39 - const formatNumber = (value: number): string => 40 - value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value.toFixed(2) 424 + function HighlightedCode({ text, language: requestedLanguage }: { text: string; language?: string }) { 425 + const language = requestedLanguage ?? codeLanguage(text) 426 + let html: string 427 + try { 428 + html = language && hljs.getLanguage(language) ? hljs.highlight(text, { language }).value : hljs.highlightAuto(text).value 429 + } catch { 430 + html = text.replace(/[&<>"']/g, (char) => { 431 + if (char === "&") return "&amp;" 432 + if (char === "<") return "&lt;" 433 + if (char === ">") return "&gt;" 434 + if (char === "\"") return "&quot;" 435 + return "&#39;" 436 + }) 437 + } 41 438 42 - const formatUsage = (event: Extract<StreamEvent, { type: "usage" }>): string => { 43 - const parts: string[] = [] 44 - if (typeof event.tokensPerSecond === "number") parts.push(`${formatNumber(event.tokensPerSecond)} tok/s`) 45 - if (typeof event.completionTokens === "number") parts.push(`${event.completionTokens} out`) 46 - if (typeof event.promptTokens === "number") parts.push(`${event.promptTokens} ctx`) 47 - if (typeof event.totalTokens === "number") parts.push(`${event.totalTokens} total`) 48 - if (typeof event.elapsedMs === "number") parts.push(`${(event.elapsedMs / 1000).toFixed(1)}s`) 49 - return parts.length ? parts.join(" · ") : "usage unavailable" 439 + return ( 440 + <pre className="code-block"> 441 + <code className={language ? `language-${language}` : undefined} dangerouslySetInnerHTML={{ __html: html }} /> 442 + </pre> 443 + ) 50 444 } 51 445 52 - const hiddenToolSummary = (result: string): string => { 53 - const normalized = result || "(no output)" 54 - const lines = normalized.split("\n") 55 - if (lines.length <= 1) return lines[0] ?? "(no output)" 56 - return `${lines[0] ?? "(no output)"}\n… ${lines.length - 1} more lines hidden` 446 + function MarkdownBody({ text }: { text: string }) { 447 + return ( 448 + <div className="markdown-body"> 449 + <ReactMarkdown 450 + remarkPlugins={[remarkGfm]} 451 + components={{ 452 + pre({ children }) { 453 + return <>{children}</> 454 + }, 455 + code({ className, children }) { 456 + const match = /language-([a-z0-9_-]+)/i.exec(className ?? "") 457 + const code = String(children).replace(/\n$/, "") 458 + if (match) return <HighlightedCode text={code} language={match[1]} /> 459 + return <code className={className}>{children}</code> 460 + }, 461 + }} 462 + > 463 + {text} 464 + </ReactMarkdown> 465 + </div> 466 + ) 57 467 } 58 468 59 - const toToolMarkdown = (result: string): string => { 60 - const normalized = result || "(no output)" 61 - return normalized.includes("```") ? normalized : `\`\`\`text\n${normalized}\n\`\`\`` 469 + function prefixForLine(line: ChatLine, agentName: string): string { 470 + if (line.kind === "agent") return agentName 471 + if (line.kind === "user") return "you" 472 + if (line.kind === "tool") return "tool" 473 + if (line.kind === "context" && line.label.includes("discord")) return "discord" 474 + return line.label 62 475 } 63 476 64 - const baseUrl = import.meta.env.VITE_NIRI_BASE_URL ?? "" 477 + function TerminalMessage({ 478 + line, 479 + agentName, 480 + expanded, 481 + onToggle, 482 + }: { 483 + line: ChatLine 484 + agentName: string 485 + expanded: boolean 486 + onToggle: (key: string) => void 487 + }) { 488 + const prefix = prefixForLine(line, agentName) 65 489 66 - const createClientId = (): string => { 67 - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { 68 - return `web-${crypto.randomUUID()}` 490 + if (line.kind === "tool") { 491 + return ( 492 + <article className="terminal-message terminal-message-tool"> 493 + <div className="terminal-row"> 494 + <span className="terminal-prefix terminal-prefix-tool">tool:</span> 495 + <div className="terminal-body"> 496 + <button type="button" className="terminal-command" onClick={() => onToggle(line.key)}> 497 + <span>{line.label}</span> 498 + <small>{expanded ? "hide result" : "show result"}</small> 499 + </button> 500 + {expanded ? ( 501 + <div className="terminal-block terminal-block-tool"> 502 + <HighlightedCode text={line.text} /> 503 + </div> 504 + ) : null} 505 + </div> 506 + <time className="terminal-time">{formatTime(line.at)}</time> 507 + </div> 508 + </article> 509 + ) 510 + } 511 + 512 + if (line.kind === "context" || line.detail) { 513 + return ( 514 + <article className={`terminal-message terminal-message-${line.kind}`}> 515 + <div className="terminal-row"> 516 + <span className={`terminal-prefix terminal-prefix-${line.kind}`}>{prefix}:</span> 517 + <div className="terminal-body"> 518 + <p className="terminal-summary-text">{line.text}</p> 519 + {line.detail ? ( 520 + <> 521 + <button type="button" className="terminal-inline-action" onClick={() => onToggle(line.key)}> 522 + {expanded ? "hide" : "show"} 523 + </button> 524 + {expanded ? ( 525 + <div className="terminal-block"> 526 + <MarkdownBody text={line.detail} /> 527 + </div> 528 + ) : null} 529 + </> 530 + ) : null} 531 + </div> 532 + <time className="terminal-time">{formatTime(line.at)}</time> 533 + </div> 534 + </article> 535 + ) 69 536 } 70 - return `web-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` 537 + 538 + return ( 539 + <article className={`terminal-message terminal-message-${line.kind}`}> 540 + <div className="terminal-row"> 541 + <span className={`terminal-prefix terminal-prefix-${line.kind}`}>{prefix}:</span> 542 + <div className="terminal-body"> 543 + <MarkdownBody text={line.text} /> 544 + </div> 545 + <time className="terminal-time">{formatTime(line.at)}</time> 546 + </div> 547 + </article> 548 + ) 71 549 } 72 550 73 551 export function App() { 74 - const clientId = useMemo(() => createClientId(), []) 75 - const client = useMemo(() => createChatClient({ baseUrl, clientId }), [clientId]) 76 - const [view, setView] = useState<"metrics" | "chat">(() => (window.location.hash === "#chat" ? "chat" : "metrics")) 77 - 78 - const [entries, setEntries] = useState<Entry[]>([]) 79 - const [running, setRunning] = useState<boolean | null>(null) 552 + const [panels, setPanels] = useState<Panel[]>(() => readPanels()) 553 + const [panelDraft, setPanelDraft] = useState("") 554 + const [panelNameDraft, setPanelNameDraft] = useState("") 555 + const [agentsByPanel, setAgentsByPanel] = useState<Record<string, Agent[]>>({}) 556 + const [selectedKey, setSelectedKey] = useState("") 557 + const [events, setEvents] = useState<WorkerEvent[]>([]) 558 + const [overview, setOverview] = useState<Overview | null>(null) 80 559 const [input, setInput] = useState("") 81 - const [sending, setSending] = useState(false) 82 - const [showThinking, setShowThinking] = useState(false) 83 - const [showAllTools, setShowAllTools] = useState(false) 84 - const [collapsedToolIds, setCollapsedToolIds] = useState<Set<number>>(() => new Set<number>()) 85 - const nextId = useRef(0) 560 + const [busy, setBusy] = useState(false) 561 + const [error, setError] = useState("") 562 + const [openCompaction, setOpenCompaction] = useState<string | null>(null) 563 + const [expandedTools, setExpandedTools] = useState<Set<string>>(() => new Set()) 564 + const streamRef = useRef<EventSource | null>(null) 86 565 87 - const push = useCallback((entry: NewEntry): number => { 88 - const id = nextId.current++ 89 - setEntries((prev) => [...prev, { ...entry, id }]) 90 - return id 91 - }, []) 566 + const selected = useMemo(() => { 567 + if (!selectedKey) return null 568 + const [panelId, agentId] = selectedKey.split("::") 569 + const panel = panels.find((item) => item.id === panelId) 570 + const agent = panel ? agentsByPanel[panel.id]?.find((item) => item.id === agentId) : undefined 571 + return panel && agent ? { panel, agent } : null 572 + }, [agentsByPanel, panels, selectedKey]) 92 573 93 - const appendStreamText = useCallback((kind: "text" | "thinking", text: string) => { 94 - setEntries((prev) => { 95 - const last = prev[prev.length - 1] 96 - if (last?.kind === kind) { 97 - return [...prev.slice(0, -1), { ...last, text: last.text + text }] 98 - } 574 + const chatLines = useMemo(() => linesFromEvents(events), [events]) 99 575 100 - const id = nextId.current++ 101 - return [...prev, { id, kind, text }] 576 + const mergeEvents = useCallback((incoming: WorkerEvent[]) => { 577 + setEvents((prev) => { 578 + const byId = new Map(prev.map((event) => [event.id, event])) 579 + for (const event of incoming) byId.set(event.id, event) 580 + return [...byId.values()].sort((a, b) => a.seq - b.seq) 102 581 }) 103 582 }, []) 104 583 105 - const toggleTool = useCallback((id: number) => { 106 - setCollapsedToolIds((prev) => { 107 - const next = new Set(prev) 108 - if (next.has(id)) { 109 - next.delete(id) 110 - } else { 111 - next.add(id) 112 - } 113 - return next 114 - }) 584 + const persistPanels = useCallback((next: Panel[]) => { 585 + setPanels(next) 586 + savePanels(next) 115 587 }, []) 116 588 117 - const collapseAllTools = useCallback(() => { 118 - setCollapsedToolIds(new Set(entries.filter((entry) => entry.kind === "tool").map((entry) => entry.id))) 119 - }, [entries]) 589 + const loadAgents = useCallback(async () => { 590 + const next: Record<string, Agent[]> = {} 591 + const failures: string[] = [] 120 592 121 - const expandAllTools = useCallback(() => { 122 - setCollapsedToolIds(new Set<number>()) 123 - }, []) 593 + await Promise.all( 594 + panels.map(async (panel) => { 595 + try { 596 + const data = await requestJson<{ agents: Agent[] }>(urlFor(panel, "/agents")) 597 + next[panel.id] = data.agents ?? [] 598 + } catch (err) { 599 + failures.push(`${panel.name}: ${err instanceof Error ? err.message : String(err)}`) 600 + next[panel.id] = [] 601 + } 602 + }), 603 + ) 604 + 605 + setAgentsByPanel(next) 606 + setError(failures[0] ?? "") 124 607 125 - const switchView = useCallback((next: "metrics" | "chat") => { 126 - setView(next) 127 - window.history.replaceState(null, "", next === "chat" ? "#chat" : "#metrics") 128 - }, []) 608 + if (!selectedKey) { 609 + const firstPanel = panels.find((panel) => next[panel.id]?.length) 610 + const firstAgent = firstPanel ? next[firstPanel.id]?.[0] : undefined 611 + if (firstPanel && firstAgent) setSelectedKey(`${firstPanel.id}::${firstAgent.id}`) 612 + } 613 + }, [panels, selectedKey]) 614 + 615 + const loadSelected = useCallback(async () => { 616 + if (!selected) return 617 + setError("") 618 + try { 619 + const [overviewData, eventData] = await Promise.all([ 620 + requestJson<Overview>(urlFor(selected.panel, `/agents/${encodeURIComponent(selected.agent.id)}/overview`)), 621 + requestJson<{ events: WorkerEvent[] }>( 622 + urlFor(selected.panel, `/agents/${encodeURIComponent(selected.agent.id)}/events?tail=1&limit=1000&view=chat`), 623 + ), 624 + ]) 625 + setOverview(overviewData) 626 + setEvents(eventData.events ?? []) 627 + } catch (err) { 628 + setError(err instanceof Error ? err.message : String(err)) 629 + setOverview(null) 630 + setEvents([]) 631 + } 632 + }, [selected]) 129 633 130 634 useEffect(() => { 131 - const controller = new AbortController() 635 + void loadAgents() 636 + }, [loadAgents]) 132 637 133 - client 134 - .stream({ 135 - signal: controller.signal, 136 - onEvent: (event: StreamEvent) => { 137 - if (event.type === "thinking") { 138 - appendStreamText("thinking", event.text) 139 - return 140 - } 638 + useEffect(() => { 639 + void loadSelected() 640 + }, [loadSelected]) 141 641 142 - if (event.type === "tool") { 143 - const id = push({ kind: "tool", name: event.name, args: event.args, result: event.result }) 144 - setCollapsedToolIds((prev) => { 145 - const next = new Set(prev) 146 - next.add(id) 147 - return next 148 - }) 149 - return 150 - } 642 + useEffect(() => { 643 + if (!selected) return 644 + streamRef.current?.close() 151 645 152 - if (event.type === "user") { 153 - if (event.clientId === clientId) return 154 - push({ kind: "incoming", source: event.source, text: event.text }) 155 - return 156 - } 646 + const source = new EventSource( 647 + urlFor(selected.panel, `/agents/${encodeURIComponent(selected.agent.id)}/stream?after_seq=${selected.agent.lastSeq}`), 648 + ) 649 + streamRef.current = source 157 650 158 - if (event.type === "usage") { 159 - push({ kind: "info", text: `stats: ${formatUsage(event)}` }) 160 - return 161 - } 651 + const onWorkerEvent = (raw: MessageEvent) => { 652 + try { 653 + const event = JSON.parse(raw.data) as WorkerEvent 654 + mergeEvents([event]) 655 + if (event.type === "metric.recorded") void loadSelected() 656 + } catch { 657 + // Ignore keepalives or malformed events. 658 + } 659 + } 162 660 163 - appendStreamText("text", event.text) 164 - }, 165 - }) 166 - .catch((err) => { 167 - if (controller.signal.aborted) return 168 - push({ kind: "error", text: err instanceof Error ? err.message : String(err) }) 169 - }) 661 + for (const type of WORKER_EVENT_TYPES) { 662 + source.addEventListener(type, onWorkerEvent as EventListener) 663 + } 664 + source.onerror = () => setError(`stream lost for ${selected.agent.name}`) 170 665 171 - return () => controller.abort() 172 - }, [appendStreamText, client, clientId, push]) 666 + return () => { 667 + for (const type of WORKER_EVENT_TYPES) { 668 + source.removeEventListener(type, onWorkerEvent as EventListener) 669 + } 670 + source.close() 671 + } 672 + }, [loadSelected, mergeEvents, selected]) 173 673 174 674 useEffect(() => { 175 - client 176 - .getStatus() 177 - .then((status) => setRunning(status.running)) 178 - .catch((err) => push({ kind: "error", text: err instanceof Error ? err.message : String(err) })) 179 - }, [client, push]) 675 + if (!selected) return 676 + const timer = setInterval(() => { 677 + requestJson<Overview>(urlFor(selected.panel, `/agents/${encodeURIComponent(selected.agent.id)}/overview`)) 678 + .then(setOverview) 679 + .catch((err) => setError(err instanceof Error ? err.message : String(err))) 680 + }, 10_000) 681 + return () => clearInterval(timer) 682 + }, [selected]) 180 683 181 - const onSubmit = useCallback( 684 + const addPanel = useCallback( 685 + (event: FormEvent<HTMLFormElement>) => { 686 + event.preventDefault() 687 + const baseUrl = cleanBaseUrl(panelDraft) 688 + if (!baseUrl) return 689 + const next = [ 690 + ...panels, 691 + { 692 + id: createId("panel"), 693 + name: panelNameDraft.trim() || baseUrl, 694 + baseUrl, 695 + }, 696 + ] 697 + persistPanels(next) 698 + setPanelDraft("") 699 + setPanelNameDraft("") 700 + }, 701 + [panelDraft, panelNameDraft, panels, persistPanels], 702 + ) 703 + 704 + const removePanel = useCallback( 705 + (panelId: string) => { 706 + const next = panels.filter((panel) => panel.id !== panelId) 707 + persistPanels(next.length ? next : readPanels()) 708 + if (selectedKey.startsWith(`${panelId}::`)) { 709 + setSelectedKey("") 710 + setEvents([]) 711 + setOverview(null) 712 + } 713 + }, 714 + [panels, persistPanels, selectedKey], 715 + ) 716 + 717 + const sendMessage = useCallback( 182 718 async (event: FormEvent<HTMLFormElement>) => { 183 719 event.preventDefault() 184 - const message = input.trim() 185 - if (!message || sending) return 720 + if (!selected) return 721 + const content = input.trim() 722 + if (!content || busy) return 186 723 187 - setSending(true) 724 + setBusy(true) 725 + setError("") 188 726 setInput("") 189 - push({ kind: "user", text: message }) 190 - 191 727 try { 192 - await client.send(message) 728 + await requestJson(urlFor(selected.panel, `/agents/${encodeURIComponent(selected.agent.id)}/events`), { 729 + method: "POST", 730 + body: JSON.stringify({ content }), 731 + }) 732 + await loadSelected() 193 733 } catch (err) { 194 - push({ kind: "error", text: err instanceof Error ? err.message : String(err) }) 734 + setError(err instanceof Error ? err.message : String(err)) 735 + setInput(content) 195 736 } finally { 196 - setSending(false) 737 + setBusy(false) 197 738 } 198 739 }, 199 - [client, input, push, sending], 740 + [busy, input, loadSelected, selected], 200 741 ) 201 742 202 - return ( 203 - <div className="shell"> 204 - <nav className="top-nav" aria-label="primary"> 205 - <button type="button" className={view === "metrics" ? "is-active" : ""} onClick={() => switchView("metrics")}> 206 - metrics 207 - </button> 208 - <button type="button" className={view === "chat" ? "is-active" : ""} onClick={() => switchView("chat")}> 209 - chat 210 - </button> 211 - </nav> 743 + const toggleTool = useCallback((key: string) => { 744 + setExpandedTools((prev) => { 745 + const next = new Set(prev) 746 + if (next.has(key)) next.delete(key) 747 + else next.add(key) 748 + return next 749 + }) 750 + }, []) 212 751 213 - {view === "metrics" ? ( 214 - <MetricsWorkbench /> 215 - ) : ( 216 - <main className="app"> 217 - <header className="header"> 218 - <h1>niri chat</h1> 219 - <p className="status"> 220 - {running === null ? "checking status…" : running ? "niri is awake" : "niri is sleeping — your message will wake her"} 221 - </p> 222 - <div className="toggles"> 223 - <label> 224 - <input type="checkbox" checked={showThinking} onChange={(event) => setShowThinking(event.target.checked)} /> show thinking 225 - </label> 226 - <label> 227 - <input type="checkbox" checked={showAllTools} onChange={(event) => setShowAllTools(event.target.checked)} /> expand all tool calls 228 - </label> 229 - <button type="button" onClick={collapseAllTools}>collapse tools</button> 230 - <button type="button" onClick={expandAllTools}>expand tools</button> 231 - </div> 232 - </header> 752 + const compactions = overview?.compactions ?? [] 753 + const status = overview?.status ?? null 233 754 234 - <section className="feed" aria-live="polite"> 235 - {entries.map((entry) => { 236 - if (entry.kind === "thinking") { 237 - if (!showThinking) { 238 - return ( 239 - <article key={entry.id} className="entry entry-info"> 240 - ⟨ thinking — {entry.text.split("\n").length} lines ⟩ 241 - </article> 242 - ) 243 - } 755 + return ( 756 + <main className="place"> 757 + <aside className="connections" aria-label="control panels"> 758 + <header> 759 + <h1>niri</h1> 760 + <button type="button" onClick={() => void loadAgents()}> 761 + refresh 762 + </button> 763 + </header> 244 764 245 - return ( 246 - <article key={entry.id} className="entry entry-thinking"> 247 - <strong>thinking</strong> 248 - <MarkdownBlock content={entry.text} /> 249 - </article> 250 - ) 251 - } 252 - 253 - if (entry.kind === "tool") { 254 - const collapsed = !showAllTools && collapsedToolIds.has(entry.id) 765 + <form className="connect-form" onSubmit={addPanel}> 766 + <input 767 + value={panelNameDraft} 768 + onChange={(event) => setPanelNameDraft(event.target.value)} 769 + placeholder="name" 770 + aria-label="control panel name" 771 + /> 772 + <input 773 + value={panelDraft} 774 + onChange={(event) => setPanelDraft(event.target.value)} 775 + placeholder="https://control.example" 776 + aria-label="control panel url" 777 + /> 778 + <button type="submit">connect</button> 779 + </form> 255 780 256 - return ( 257 - <article key={entry.id} className="entry entry-tool"> 258 - <div className="entry-header"> 259 - <strong>tool #{entry.id}: {toolSummary(entry.name, entry.args)}</strong> 260 - <button type="button" onClick={() => toggleTool(entry.id)}> 261 - {collapsed ? "expand" : "collapse"} 781 + <div className="agent-list"> 782 + {panels.map((panel) => ( 783 + <section key={panel.id}> 784 + <div className="panel-line"> 785 + <span>{panel.name}</span> 786 + {panel.id !== "same-origin" ? ( 787 + <button type="button" onClick={() => removePanel(panel.id)} aria-label={`remove ${panel.name}`}> 788 + remove 262 789 </button> 263 - </div> 264 - {collapsed ? <pre>{hiddenToolSummary(entry.result)}</pre> : <MarkdownBlock content={toToolMarkdown(entry.result)} />} 265 - </article> 266 - ) 267 - } 790 + ) : null} 791 + </div> 792 + {(agentsByPanel[panel.id] ?? []).map((agent) => { 793 + const key = `${panel.id}::${agent.id}` 794 + return ( 795 + <button 796 + key={key} 797 + type="button" 798 + className={key === selectedKey ? "agent-button is-current" : "agent-button"} 799 + onClick={() => setSelectedKey(key)} 800 + > 801 + <span>{agent.name}</span> 802 + <small>{agent.status}</small> 803 + </button> 804 + ) 805 + })} 806 + {(agentsByPanel[panel.id] ?? []).length === 0 ? <p className="quiet">no agents</p> : null} 807 + </section> 808 + ))} 809 + </div> 810 + </aside> 268 811 269 - if (entry.kind === "text") { 270 - return ( 271 - <article key={entry.id} className="entry entry-niri"> 272 - <strong>niri</strong> 273 - <MarkdownBlock content={entry.text} /> 274 - </article> 275 - ) 276 - } 812 + <section className="thread" aria-label="agent conversation"> 813 + <header className="thread-head"> 814 + <div> 815 + <h2>{selected ? selected.agent.name : "choose an agent"}</h2> 816 + <p> 817 + {overviewStatusText(status)} 818 + {status?.processStartedAt ? ` since ${formatTime(status.processStartedAt)}` : ""} 819 + </p> 820 + </div> 821 + <div className="readout" aria-label="current token status"> 822 + <span>{formatNumber(status?.contextSize)} ctx</span> 823 + <span>{formatNumber(status?.tokenCount)} total</span> 824 + <span>{formatDuration(status?.uptimeMs)}</span> 825 + </div> 826 + </header> 277 827 278 - if (entry.kind === "incoming") { 279 - return ( 280 - <article key={entry.id} className="entry entry-incoming"> 281 - <strong>{entry.source}</strong> 282 - <MarkdownBlock content={entry.text} /> 283 - </article> 284 - ) 285 - } 828 + {error ? <p className="error-line">{error}</p> : null} 286 829 287 - if (entry.kind === "user") { 288 - return ( 289 - <article key={entry.id} className="entry entry-user"> 290 - <strong>you</strong> 291 - <pre>{entry.text}</pre> 292 - </article> 293 - ) 294 - } 830 + <div className="messages"> 831 + {chatLines.length === 0 ? ( 832 + <p className="empty">No mirrored history yet. Send a message or open the stream to start collecting it.</p> 833 + ) : ( 834 + chatLines.map((line) => { 835 + const expanded = expandedTools.has(line.key) 836 + return ( 837 + <TerminalMessage 838 + key={line.key} 839 + line={line} 840 + agentName={selected?.agent.name ?? "agent"} 841 + expanded={expanded} 842 + onToggle={toggleTool} 843 + /> 844 + ) 845 + }) 846 + )} 847 + </div> 295 848 296 - return ( 297 - <article key={entry.id} className={`entry ${entry.kind === "error" ? "entry-error" : "entry-info"}`}> 298 - {entry.kind === "error" ? `error: ${entry.text}` : entry.text} 299 - </article> 300 - ) 301 - })} 849 + <form className="composer" onSubmit={sendMessage}> 850 + <textarea 851 + value={input} 852 + onChange={(event) => setInput(event.target.value)} 853 + disabled={!selected || busy} 854 + placeholder={selected ? `message ${selected.agent.name}` : "choose an agent first"} 855 + aria-label="message" 856 + rows={3} 857 + /> 858 + <button type="submit" disabled={!selected || busy || !input.trim()}> 859 + {busy ? "sending" : "send"} 860 + </button> 861 + </form> 302 862 </section> 303 863 304 - <form className="composer" onSubmit={onSubmit}> 305 - <input 306 - value={input} 307 - onChange={(event) => setInput(event.target.value)} 308 - placeholder="send a message to niri" 309 - aria-label="message" 310 - /> 311 - <button type="submit" disabled={sending || !input.trim()}> 312 - {sending ? "sending…" : "send"} 313 - </button> 314 - </form> 864 + <aside className="notes" aria-label="agent notes"> 865 + <section> 866 + <h2>status</h2> 867 + <dl> 868 + <div> 869 + <dt>state</dt> 870 + <dd>{overviewStatusText(status)}</dd> 871 + </div> 872 + <div> 873 + <dt>context</dt> 874 + <dd>{formatNumber(status?.contextSize)}</dd> 875 + </div> 876 + <div> 877 + <dt>tokens</dt> 878 + <dd>{formatNumber(status?.tokenCount)}</dd> 879 + </div> 880 + <div> 881 + <dt>uptime</dt> 882 + <dd>{formatDuration(status?.uptimeMs)}</dd> 883 + </div> 884 + </dl> 885 + </section> 886 + 887 + <section> 888 + <h2>recent compactions</h2> 889 + {compactions.length === 0 ? <p className="quiet">none mirrored yet</p> : null} 890 + <div className="compactions"> 891 + {compactions.map((item) => { 892 + const key = item.eventId 893 + const open = openCompaction === key 894 + return ( 895 + <article key={key}> 896 + <button type="button" onClick={() => setOpenCompaction(open ? null : key)}> 897 + <span>{formatTime(item.timestamp)}</span> 898 + <strong>{formatNumber(item.savedTokens)} saved</strong> 899 + <small>{item.method ?? "compaction"}</small> 900 + </button> 901 + {open ? <MarkdownBody text={item.summary || "(no summary stored)"} /> : null} 902 + </article> 903 + ) 904 + })} 905 + </div> 906 + </section> 907 + </aside> 315 908 </main> 316 - )} 317 - </div> 318 909 ) 319 910 }
-19
apps/web/src/MarkdownBlock.tsx
··· 1 - import ReactMarkdown from "react-markdown" 2 - import remarkGfm from "remark-gfm" 3 - import rehypeHighlight from "rehype-highlight" 4 - 5 - export function MarkdownBlock({ content }: { content: string }) { 6 - return ( 7 - <div className="markdown"> 8 - <ReactMarkdown 9 - remarkPlugins={[remarkGfm]} 10 - rehypePlugins={[rehypeHighlight]} 11 - components={{ 12 - a: ({ node: _node, ...props }) => <a {...props} target="_blank" rel="noreferrer" />, 13 - }} 14 - > 15 - {content} 16 - </ReactMarkdown> 17 - </div> 18 - ) 19 - }
-870
apps/web/src/MetricsWorkbench.tsx
··· 1 - import { useCallback, useEffect, useMemo, useState } from "react" 2 - import { MarkdownBlock } from "./MarkdownBlock" 3 - 4 - type Usage = { 5 - prompt_tokens?: number 6 - completion_tokens?: number 7 - total_tokens?: number 8 - } 9 - 10 - type BaseMetric = { 11 - id: number 12 - sourceType: string 13 - timestamp: string 14 - detailPath: string 15 - } 16 - 17 - type MemoryMetric = BaseMetric & { 18 - type: "memory" 19 - queryPreview?: string 20 - resultCount?: number 21 - } 22 - 23 - type PromptMetric = BaseMetric & { 24 - type: "prompt" 25 - messageCount?: number 26 - lastUserMessage?: string 27 - } 28 - 29 - type ResponseMetric = BaseMetric & { 30 - type: "response" 31 - promptMetricId?: number 32 - model?: string 33 - toolChoice?: string 34 - messageCount?: number 35 - lastUserMessage?: string 36 - responsePreview?: string 37 - toolCallCount?: number 38 - usage?: Usage 39 - } 40 - 41 - type UsageMetric = BaseMetric & { 42 - type: "usage" 43 - usage?: Usage 44 - } 45 - 46 - type SummarizationMetric = BaseMetric & { 47 - type: "summarization" 48 - method?: string 49 - before?: number 50 - after?: number 51 - savedTokens?: number 52 - summaryPreview?: string 53 - summaryChars?: number 54 - } 55 - 56 - type DiscordMetric = { 57 - id: string 58 - type: "discord" 59 - sourceType: "discord" 60 - timestamp: string 61 - detailPath: string 62 - messageId: string 63 - channelId: string 64 - guildId?: string 65 - authorUsername?: string 66 - contentPreview?: string 67 - isDm: boolean 68 - mentionsBot: boolean 69 - isFromBot: boolean 70 - } 71 - 72 - type MetricItem = MemoryMetric | PromptMetric | ResponseMetric | UsageMetric | SummarizationMetric 73 - 74 - type MetricsPage = { 75 - memories: MemoryMetric[] 76 - summarization: SummarizationMetric[] 77 - response: ResponseMetric[] 78 - prompt: PromptMetric[] 79 - usage: UsageMetric[] 80 - discord: DiscordMetric[] 81 - limit: number 82 - nextCursor: Record<string, string | number | undefined> 83 - hasMore: Record<string, boolean | undefined> 84 - } 85 - 86 - type MetricsPageInput = Partial<MetricsPage> 87 - 88 - type MemorySearchResult = { 89 - chunkId: number 90 - kind: string 91 - path: string 92 - source: string 93 - title: string 94 - headingPath: string | null 95 - preview: string 96 - } 97 - 98 - type MemoryDetail = { 99 - id: number 100 - type: "memory" 101 - timestamp: string 102 - query: string 103 - results: MemorySearchResult[] 104 - } 105 - 106 - type Message = { 107 - role?: string 108 - content?: unknown 109 - tool_call_id?: string 110 - tool_calls?: unknown 111 - } 112 - 113 - type PromptDetail = { 114 - id: number 115 - type: "prompt" | "prompt_response" 116 - timestamp: string 117 - messages?: Message[] 118 - response?: Message 119 - } 120 - 121 - type TurnDetail = { 122 - id: number 123 - timestamp: string 124 - model?: string 125 - usage?: Usage 126 - promptText: string 127 - responseText?: string 128 - toolTraces: ToolTrace[] 129 - } 130 - 131 - type DetailState = 132 - | { kind: "idle" } 133 - | { kind: "loading"; label: string } 134 - | { kind: "error"; text: string } 135 - | { kind: "memory"; memory: MemoryDetail; prompt?: PromptDetail } 136 - | { kind: "turn"; turn: TurnDetail } 137 - | { kind: "metric"; metric: unknown } 138 - 139 - type MemoryPair = { 140 - memory: MemoryMetric 141 - prompt?: PromptMetric | ResponseMetric 142 - secondsApart?: number 143 - overlap: number 144 - shared: string[] 145 - issue: "ok" | "loose" | "missing" 146 - } 147 - 148 - type ToolTrace = { 149 - id: string 150 - name: string 151 - args: string 152 - result?: string 153 - } 154 - 155 - const baseUrl = import.meta.env.VITE_NIRI_BASE_URL ?? "" 156 - const METRICS_POLL_INTERVAL_MS = 5_000 157 - 158 - const stopWords = new Set([ 159 - "a", 160 - "an", 161 - "and", 162 - "are", 163 - "as", 164 - "at", 165 - "be", 166 - "but", 167 - "by", 168 - "for", 169 - "from", 170 - "have", 171 - "i", 172 - "in", 173 - "is", 174 - "it", 175 - "me", 176 - "my", 177 - "of", 178 - "on", 179 - "or", 180 - "that", 181 - "the", 182 - "this", 183 - "to", 184 - "was", 185 - "with", 186 - "you", 187 - "your", 188 - ]) 189 - 190 - const formatNumber = (value: number | undefined): string => 191 - typeof value === "number" && Number.isFinite(value) ? value.toLocaleString() : "0" 192 - 193 - const timeLabel = (iso: string): string => { 194 - const date = new Date(iso) 195 - if (Number.isNaN(date.getTime())) return iso 196 - return new Intl.DateTimeFormat(undefined, { 197 - month: "short", 198 - day: "2-digit", 199 - hour: "2-digit", 200 - minute: "2-digit", 201 - }).format(date) 202 - } 203 - 204 - const shortTime = (iso: string): string => { 205 - const date = new Date(iso) 206 - if (Number.isNaN(date.getTime())) return iso 207 - return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(date) 208 - } 209 - 210 - const textContent = (content: unknown): string => { 211 - if (typeof content === "string") return content 212 - if (!Array.isArray(content)) return "" 213 - return content 214 - .flatMap((part) => { 215 - if (!part || typeof part !== "object") return [] 216 - const record = part as Record<string, unknown> 217 - return typeof record.text === "string" ? [record.text] : [] 218 - }) 219 - .join("\n") 220 - } 221 - 222 - const toolResultMarkdown = (result: string): string => { 223 - const trimmed = result.trim() 224 - if (!trimmed) return "```text\n(no output)\n```" 225 - if (/^(```|#{1,6}\s|- |\* |\d+\. |> |\|)/m.test(trimmed)) return trimmed 226 - return `\`\`\`text\n${trimmed}\n\`\`\`` 227 - } 228 - 229 - const extractToolTraces = (prompt: PromptDetail | undefined): ToolTrace[] => { 230 - const messages = [...(prompt?.messages ?? []), ...(prompt?.response ? [prompt.response] : [])] 231 - const traces: ToolTrace[] = [] 232 - const byId = new Map<string, ToolTrace>() 233 - 234 - for (const message of messages) { 235 - if (Array.isArray(message.tool_calls)) { 236 - for (const rawCall of message.tool_calls) { 237 - if (!rawCall || typeof rawCall !== "object") continue 238 - const call = rawCall as Record<string, unknown> 239 - const fn = call.function && typeof call.function === "object" ? (call.function as Record<string, unknown>) : {} 240 - const id = typeof call.id === "string" ? call.id : `tool-${traces.length + 1}` 241 - const trace: ToolTrace = { 242 - id, 243 - name: typeof fn.name === "string" ? fn.name : "tool", 244 - args: typeof fn.arguments === "string" ? fn.arguments : "", 245 - } 246 - traces.push(trace) 247 - byId.set(id, trace) 248 - } 249 - } 250 - 251 - if (message.role === "tool") { 252 - const id = typeof message.tool_call_id === "string" ? message.tool_call_id : "" 253 - const result = textContent(message.content) 254 - const existing = byId.get(id) 255 - if (existing) { 256 - existing.result = result 257 - } else { 258 - traces.push({ 259 - id: id || `tool-result-${traces.length + 1}`, 260 - name: "tool result", 261 - args: "", 262 - result, 263 - }) 264 - } 265 - } 266 - } 267 - 268 - return traces 269 - } 270 - 271 - const lastUserMessage = (messages: Message[] | undefined): string => { 272 - if (!messages) return "" 273 - for (let i = messages.length - 1; i >= 0; i--) { 274 - const message = messages[i] 275 - if (message?.role === "user") { 276 - const text = textContent(message.content).trim() 277 - if (text) return text 278 - } 279 - } 280 - return "" 281 - } 282 - 283 - const tokens = (value: string | undefined): string[] => { 284 - if (!value) return [] 285 - return value 286 - .toLowerCase() 287 - .replace(/https?:\/\/\S+/g, " ") 288 - .replace(/[^a-z0-9\s'-]+/g, " ") 289 - .split(/\s+/) 290 - .map((token) => token.replace(/^['-]+|['-]+$/g, "")) 291 - .filter((token) => token.length > 2 && !stopWords.has(token)) 292 - } 293 - 294 - const overlapFor = (left: string | undefined, right: string | undefined): { score: number; shared: string[] } => { 295 - const leftTokens = new Set(tokens(left)) 296 - const rightTokens = new Set(tokens(right)) 297 - if (leftTokens.size === 0 || rightTokens.size === 0) return { score: 0, shared: [] } 298 - 299 - const shared = [...leftTokens].filter((token) => rightTokens.has(token)) 300 - return { 301 - score: shared.length / Math.max(1, Math.min(leftTokens.size, rightTokens.size)), 302 - shared: shared.slice(0, 8), 303 - } 304 - } 305 - 306 - const fetchJson = async <T,>(path: string, signal?: AbortSignal): Promise<T> => { 307 - const res = await fetch(`${baseUrl}${path}`, { signal }) 308 - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim()) 309 - return (await res.json()) as T 310 - } 311 - 312 - const normalizeMetricsPage = (page: MetricsPageInput): MetricsPage => ({ 313 - memories: Array.isArray(page.memories) ? page.memories : [], 314 - summarization: Array.isArray(page.summarization) ? page.summarization : [], 315 - response: Array.isArray(page.response) ? page.response : [], 316 - prompt: Array.isArray(page.prompt) ? page.prompt : [], 317 - usage: Array.isArray(page.usage) ? page.usage : [], 318 - discord: Array.isArray(page.discord) ? page.discord : [], 319 - limit: typeof page.limit === "number" ? page.limit : 100, 320 - nextCursor: page.nextCursor ?? {}, 321 - hasMore: page.hasMore ?? {}, 322 - }) 323 - 324 - function buildMetricsUrl(search: string): string { 325 - const params = new URLSearchParams() 326 - params.set("limit", "100") 327 - if (search.trim()) params.set("q", search.trim()) 328 - return `/metrics?${params.toString()}` 329 - } 330 - 331 - function closestResponsePath(timestamp: string, responses: ResponseMetric[]): string | undefined { 332 - const usageTime = new Date(timestamp).getTime() 333 - if (!Number.isFinite(usageTime)) return undefined 334 - 335 - let best: ResponseMetric | undefined 336 - let bestDelta = Number.POSITIVE_INFINITY 337 - for (const r of responses) { 338 - const t = new Date(r.timestamp).getTime() 339 - if (!Number.isFinite(t) || t > usageTime) continue 340 - const delta = usageTime - t 341 - if (delta < bestDelta) { 342 - best = r 343 - bestDelta = delta 344 - } 345 - } 346 - return best?.detailPath 347 - } 348 - 349 - function TokenTrace({ 350 - usage, 351 - responses, 352 - onOpenTurn, 353 - latestPromptText, 354 - }: { 355 - usage: UsageMetric[] 356 - responses: ResponseMetric[] 357 - onOpenTurn: (path: string) => void 358 - latestPromptText?: string 359 - }) { 360 - const points = useMemo(() => { 361 - const usagePoints = usage.map((item) => ({ 362 - id: `u-${item.id}`, 363 - timestamp: item.timestamp, 364 - usage: item.usage, 365 - model: undefined as string | undefined, 366 - detailPath: closestResponsePath(item.timestamp, responses) ?? item.detailPath, 367 - })) 368 - const responsePoints = responses 369 - .filter((item) => item.usage) 370 - .map((item) => ({ 371 - id: `r-${item.id}`, 372 - timestamp: item.timestamp, 373 - usage: item.usage, 374 - model: item.model, 375 - detailPath: item.detailPath, 376 - })) 377 - return [...usagePoints, ...responsePoints] 378 - .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) 379 - .slice(-80) 380 - }, [responses, usage]) 381 - 382 - const maxTotal = Math.max(1, ...points.map((point) => point.usage?.total_tokens ?? 0)) 383 - const latest = points[points.length - 1] 384 - const totals = points.reduce( 385 - (sum, point) => ({ 386 - prompt: sum.prompt + (point.usage?.prompt_tokens ?? 0), 387 - completion: sum.completion + (point.usage?.completion_tokens ?? 0), 388 - total: sum.total + (point.usage?.total_tokens ?? 0), 389 - }), 390 - { prompt: 0, completion: 0, total: 0 }, 391 - ) 392 - const average = points.length ? Math.round(totals.total / points.length) : 0 393 - 394 - return ( 395 - <section className="metric-panel metric-token-panel" aria-label="token usage"> 396 - <div className="panel-head"> 397 - <div> 398 - <h2>Token Usage</h2> 399 - <p>{points.length ? `${points.length} recent completions` : "No usage rows yet"}</p> 400 - </div> 401 - <div className="token-readout"> 402 - <span>{formatNumber(latest?.usage?.total_tokens)}</span> 403 - <small>latest total</small> 404 - </div> 405 - </div> 406 - 407 - <div className="token-strip" aria-label="recent token totals"> 408 - {points.map((point) => { 409 - const prompt = point.usage?.prompt_tokens ?? 0 410 - const completion = point.usage?.completion_tokens ?? 0 411 - const total = point.usage?.total_tokens ?? prompt + completion 412 - return ( 413 - <button 414 - key={point.id} 415 - className="token-bar" 416 - type="button" 417 - onClick={() => onOpenTurn(point.detailPath)} 418 - title={`${timeLabel(point.timestamp)} total ${formatNumber(total)} prompt ${formatNumber(prompt)} completion ${formatNumber(completion)}`} 419 - style={{ height: `${Math.max(8, Math.round((total / maxTotal) * 100))}%` }} 420 - > 421 - <span className="token-bar-prompt" style={{ height: `${total ? (prompt / total) * 100 : 0}%` }} /> 422 - <span className="token-bar-completion" style={{ height: `${total ? (completion / total) * 100 : 0}%` }} /> 423 - </button> 424 - ) 425 - })} 426 - </div> 427 - 428 - <div className="token-ledger"> 429 - <div> 430 - <span>{formatNumber(totals.prompt)}</span> 431 - <small>prompt</small> 432 - </div> 433 - <div> 434 - <span>{formatNumber(totals.completion)}</span> 435 - <small>completion</small> 436 - </div> 437 - <div> 438 - <span>{formatNumber(average)}</span> 439 - <small>avg total</small> 440 - </div> 441 - </div> 442 - 443 - {latestPromptText && ( 444 - <div className="latest-prompt"> 445 - <small>latest prompt</small> 446 - <p>{latestPromptText}</p> 447 - </div> 448 - )} 449 - </section> 450 - ) 451 - } 452 - 453 - function MemoryReview({ 454 - pairs, 455 - selectedId, 456 - reviewOnly, 457 - onReviewOnlyChange, 458 - onSelect, 459 - }: { 460 - pairs: MemoryPair[] 461 - selectedId?: number 462 - reviewOnly: boolean 463 - onReviewOnlyChange: (value: boolean) => void 464 - onSelect: (pair: MemoryPair) => void 465 - }) { 466 - const visible = reviewOnly ? pairs.filter((pair) => pair.issue !== "ok") : pairs 467 - 468 - return ( 469 - <section className="metric-panel memory-panel" aria-label="memory prompt alignment"> 470 - <div className="panel-head"> 471 - <div> 472 - <h2>Memory Fit</h2> 473 - <p>{visible.length} recalls matched against nearby prompts</p> 474 - </div> 475 - <label className="switch-row"> 476 - <input type="checkbox" checked={reviewOnly} onChange={(event) => onReviewOnlyChange(event.target.checked)} /> 477 - review only 478 - </label> 479 - </div> 480 - 481 - <div className="memory-table" role="table"> 482 - <div className="memory-row memory-row-head" role="row"> 483 - <span>time</span> 484 - <span>fit</span> 485 - <span>memory query</span> 486 - <span>near prompt</span> 487 - </div> 488 - {visible.map((pair) => ( 489 - <button 490 - key={pair.memory.id} 491 - type="button" 492 - className={`memory-row ${selectedId === pair.memory.id ? "is-selected" : ""} issue-${pair.issue}`} 493 - onClick={() => onSelect(pair)} 494 - role="row" 495 - > 496 - <span>{shortTime(pair.memory.timestamp)}</span> 497 - <span> 498 - {pair.issue === "missing" ? "no prompt" : `${Math.round(pair.overlap * 100)}%`} 499 - {pair.secondsApart != null ? <small>{Math.abs(pair.secondsApart)}s</small> : null} 500 - </span> 501 - <span>{pair.memory.queryPreview ?? "empty memory query"}</span> 502 - <span>{pair.prompt?.lastUserMessage ?? "no matching prompt"}</span> 503 - </button> 504 - ))} 505 - </div> 506 - </section> 507 - ) 508 - } 509 - 510 - function DetailPane({ detail }: { detail: DetailState }) { 511 - if (detail.kind === "idle") { 512 - return ( 513 - <aside className="detail-pane"> 514 - <h2>Turn Detail</h2> 515 - <p>Click a bar in the chart or a response in the rail to inspect the turn.</p> 516 - </aside> 517 - ) 518 - } 519 - 520 - if (detail.kind === "loading") { 521 - return ( 522 - <aside className="detail-pane"> 523 - <h2>{detail.label}</h2> 524 - <p>Loading.</p> 525 - </aside> 526 - ) 527 - } 528 - 529 - if (detail.kind === "error") { 530 - return ( 531 - <aside className="detail-pane detail-error"> 532 - <h2>Error</h2> 533 - <p>{detail.text}</p> 534 - </aside> 535 - ) 536 - } 537 - 538 - if (detail.kind === "turn") { 539 - const { turn } = detail 540 - return ( 541 - <aside className="detail-pane"> 542 - <h2>Turn #{turn.id}</h2> 543 - <dl className="detail-meta"> 544 - {turn.model ? <div><dt>model</dt><dd>{turn.model}</dd></div> : null} 545 - <div><dt>time</dt><dd>{timeLabel(turn.timestamp)}</dd></div> 546 - {turn.usage ? ( 547 - <> 548 - <div><dt>prompt</dt><dd>{formatNumber(turn.usage.prompt_tokens)} tok</dd></div> 549 - <div><dt>completion</dt><dd>{formatNumber(turn.usage.completion_tokens)} tok</dd></div> 550 - </> 551 - ) : null} 552 - </dl> 553 - 554 - <section className="detail-section"> 555 - <h3>Prompt</h3> 556 - <MarkdownBlock content={turn.promptText || "(no prompt)"} /> 557 - </section> 558 - 559 - {turn.toolTraces.length > 0 ? ( 560 - <section className="detail-section"> 561 - <h3>Tool Calls ({turn.toolTraces.length})</h3> 562 - <div className="tool-trace-list"> 563 - {turn.toolTraces.map((tool) => ( 564 - <article key={tool.id} className="tool-trace"> 565 - <details> 566 - <summary> 567 - <span>{tool.name}</span> 568 - <small>{tool.id}</small> 569 - </summary> 570 - {tool.args ? <pre className="tool-args">{tool.args}</pre> : null} 571 - {tool.result !== undefined ? ( 572 - <div className="tool-result"> 573 - <MarkdownBlock content={toolResultMarkdown(tool.result)} /> 574 - </div> 575 - ) : null} 576 - </details> 577 - </article> 578 - ))} 579 - </div> 580 - </section> 581 - ) : null} 582 - 583 - {turn.responseText ? ( 584 - <section className="detail-section"> 585 - <h3>Response</h3> 586 - <MarkdownBlock content={turn.responseText} /> 587 - </section> 588 - ) : null} 589 - </aside> 590 - ) 591 - } 592 - 593 - if (detail.kind === "metric") { 594 - return ( 595 - <aside className="detail-pane"> 596 - <h2>Metric Detail</h2> 597 - <pre>{JSON.stringify(detail.metric, null, 2)}</pre> 598 - </aside> 599 - ) 600 - } 601 - 602 - const promptText = lastUserMessage(detail.prompt?.messages) 603 - 604 - return ( 605 - <aside className="detail-pane"> 606 - <h2>Recall #{detail.memory.id}</h2> 607 - <dl className="detail-meta"> 608 - <div> 609 - <dt>time</dt> 610 - <dd>{timeLabel(detail.memory.timestamp)}</dd> 611 - </div> 612 - <div> 613 - <dt>chunks</dt> 614 - <dd>{detail.memory.results.length}</dd> 615 - </div> 616 - </dl> 617 - 618 - <section className="detail-section"> 619 - <h3>Prompt</h3> 620 - <MarkdownBlock content={promptText || detail.memory.query} /> 621 - </section> 622 - 623 - <section className="detail-section"> 624 - <h3>Memory Query</h3> 625 - <MarkdownBlock content={detail.memory.query} /> 626 - </section> 627 - 628 - <section className="detail-section"> 629 - <h3>Retrieved Chunks</h3> 630 - {detail.memory.results.map((result) => ( 631 - <article key={result.chunkId} className="memory-hit"> 632 - <div> 633 - <strong>{result.title}</strong> 634 - <span>{result.kind} / {result.source}</span> 635 - </div> 636 - <p>{result.preview}</p> 637 - </article> 638 - ))} 639 - </section> 640 - </aside> 641 - ) 642 - } 643 - 644 - function BucketRail({ 645 - metrics, 646 - onOpenMetric, 647 - }: { 648 - metrics: MetricsPage 649 - onOpenMetric: (path: string) => void 650 - }) { 651 - const rows: Array<{ label: string; count: number; items: Array<MetricItem | DiscordMetric> }> = [ 652 - { label: "response", count: metrics.response.length, items: metrics.response.slice(0, 6) }, 653 - { label: "summarization", count: metrics.summarization.length, items: metrics.summarization.slice(0, 6) }, 654 - { label: "discord", count: metrics.discord.length, items: metrics.discord.slice(0, 6) }, 655 - ] 656 - 657 - return ( 658 - <section className="bucket-rail" aria-label="raw metric buckets"> 659 - {rows.map((bucket) => ( 660 - <section key={bucket.label}> 661 - <header> 662 - <h3>{bucket.label}</h3> 663 - <span>{bucket.count}</span> 664 - </header> 665 - <div className="bucket-list"> 666 - {bucket.items.map((item) => ( 667 - <button key={`${item.type}-${item.id}`} type="button" onClick={() => onOpenMetric(item.detailPath)}> 668 - <span>{shortTime(item.timestamp)}</span> 669 - <strong> 670 - {item.type === "response" 671 - ? item.responsePreview || `${item.model ?? "model"}` 672 - : item.type === "summarization" 673 - ? item.summaryPreview || item.method || "summary" 674 - : item.type === "discord" 675 - ? item.contentPreview || item.authorUsername || "discord" 676 - : item.type} 677 - </strong> 678 - </button> 679 - ))} 680 - </div> 681 - </section> 682 - ))} 683 - </section> 684 - ) 685 - } 686 - 687 - export function MetricsWorkbench() { 688 - const [metrics, setMetrics] = useState<MetricsPage | null>(null) 689 - const [error, setError] = useState<string | null>(null) 690 - const [loading, setLoading] = useState(true) 691 - const [search, setSearch] = useState("") 692 - const [query, setQuery] = useState("") 693 - const [reviewOnly, setReviewOnly] = useState(false) 694 - const [detail, setDetail] = useState<DetailState>({ kind: "idle" }) 695 - const [live, setLive] = useState(true) 696 - const [lastUpdated, setLastUpdated] = useState<string | null>(null) 697 - 698 - const loadMetrics = useCallback((signal?: AbortSignal, options?: { silent?: boolean }) => { 699 - if (!options?.silent) setLoading(true) 700 - setError(null) 701 - fetchJson<MetricsPageInput>(buildMetricsUrl(query), signal) 702 - .then((page) => { 703 - setMetrics(normalizeMetricsPage(page)) 704 - setLastUpdated(new Date().toISOString()) 705 - }) 706 - .catch((err) => { 707 - if (signal?.aborted) return 708 - setError(err instanceof Error ? err.message : String(err)) 709 - }) 710 - .finally(() => { 711 - if (!signal?.aborted) setLoading(false) 712 - }) 713 - }, [query]) 714 - 715 - useEffect(() => { 716 - const controller = new AbortController() 717 - loadMetrics(controller.signal) 718 - let interval: ReturnType<typeof setInterval> | undefined 719 - let pollController: AbortController | null = null 720 - 721 - if (live) { 722 - interval = setInterval(() => { 723 - pollController?.abort() 724 - pollController = new AbortController() 725 - loadMetrics(pollController.signal, { silent: true }) 726 - }, METRICS_POLL_INTERVAL_MS) 727 - } 728 - 729 - return () => { 730 - controller.abort() 731 - pollController?.abort() 732 - if (interval) clearInterval(interval) 733 - } 734 - }, [live, loadMetrics]) 735 - 736 - const pairs = useMemo<MemoryPair[]>(() => { 737 - if (!metrics) return [] 738 - const prompts = [...metrics.response, ...metrics.prompt] 739 - .filter((prompt) => prompt.lastUserMessage) 740 - .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) 741 - 742 - return metrics.memories.map((memory) => { 743 - const memoryTime = new Date(memory.timestamp).getTime() 744 - const prompt = prompts.find((item) => new Date(item.timestamp).getTime() >= memoryTime) ?? prompts[prompts.length - 1] 745 - const promptTime = prompt ? new Date(prompt.timestamp).getTime() : Number.NaN 746 - const secondsApart = prompt && Number.isFinite(promptTime) ? Math.round((promptTime - memoryTime) / 1000) : undefined 747 - const overlap = overlapFor(memory.queryPreview, prompt?.lastUserMessage) 748 - const issue = !prompt ? "missing" : overlap.score < 0.16 ? "loose" : "ok" 749 - return { memory, prompt, secondsApart, overlap: overlap.score, shared: overlap.shared, issue } 750 - }) 751 - }, [metrics]) 752 - 753 - const latestPromptText = useMemo(() => { 754 - return metrics?.response[0]?.lastUserMessage ?? undefined 755 - }, [metrics]) 756 - 757 - const selectedMemoryId = detail.kind === "memory" ? detail.memory.id : undefined 758 - 759 - const selectPair = useCallback(async (pair: MemoryPair) => { 760 - setDetail({ kind: "loading", label: `Recall #${pair.memory.id}` }) 761 - try { 762 - const [memory, prompt] = await Promise.all([ 763 - fetchJson<MemoryDetail>(pair.memory.detailPath), 764 - pair.prompt ? fetchJson<PromptDetail>(pair.prompt.detailPath) : Promise.resolve(undefined), 765 - ]) 766 - setDetail({ kind: "memory", memory, prompt }) 767 - } catch (err) { 768 - setDetail({ kind: "error", text: err instanceof Error ? err.message : String(err) }) 769 - } 770 - }, []) 771 - 772 - const openMetric = useCallback(async (path: string) => { 773 - setDetail({ kind: "loading", label: "Turn detail" }) 774 - try { 775 - const raw = await fetchJson<Record<string, unknown>>(path) 776 - if (raw?.type === "prompt_response") { 777 - const msgs = Array.isArray(raw.messages) ? (raw.messages as Message[]) : [] 778 - const response = raw.response as Message | undefined 779 - const promptText = lastUserMessage(msgs) 780 - const responseText = textContent(response?.content) || undefined 781 - const fakeDetail: PromptDetail = { 782 - id: raw.id as number, 783 - type: "prompt_response", 784 - timestamp: raw.timestamp as string, 785 - messages: msgs, 786 - response, 787 - } 788 - setDetail({ 789 - kind: "turn", 790 - turn: { 791 - id: raw.id as number, 792 - timestamp: raw.timestamp as string, 793 - model: typeof raw.model === "string" ? raw.model : undefined, 794 - usage: raw.usage as Usage | undefined, 795 - promptText: promptText || "(no prompt)", 796 - responseText, 797 - toolTraces: extractToolTraces(fakeDetail), 798 - }, 799 - }) 800 - } else { 801 - setDetail({ kind: "metric", metric: raw }) 802 - } 803 - } catch (err) { 804 - setDetail({ kind: "error", text: err instanceof Error ? err.message : String(err) }) 805 - } 806 - }, []) 807 - 808 - return ( 809 - <main className="metrics-app"> 810 - <header className="metrics-header"> 811 - <div> 812 - <h1>Metrics</h1> 813 - <p>Token pressure and memory retrieval checks.</p> 814 - </div> 815 - <form 816 - className="metrics-search" 817 - onSubmit={(event) => { 818 - event.preventDefault() 819 - setQuery(search.trim()) 820 - }} 821 - > 822 - <input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="filter metrics" aria-label="filter metrics" /> 823 - <button type="submit">filter</button> 824 - <button type="button" onClick={() => loadMetrics()}> 825 - refresh 826 - </button> 827 - <button type="button" onClick={() => { 828 - setSearch("") 829 - setQuery("") 830 - }}> 831 - clear 832 - </button> 833 - </form> 834 - <div className="live-controls"> 835 - <button type="button" className={live ? "is-live" : ""} onClick={() => setLive((value) => !value)}> 836 - {live ? "live" : "paused"} 837 - </button> 838 - <span>{lastUpdated ? `updated ${shortTime(lastUpdated)}` : "not updated yet"}</span> 839 - </div> 840 - </header> 841 - 842 - {error ? <p className="metrics-error">metrics unavailable: {error}</p> : null} 843 - {loading && !metrics ? <p className="metrics-loading">loading metrics</p> : null} 844 - 845 - {metrics ? ( 846 - <div className="metrics-grid"> 847 - <div className="metrics-main"> 848 - <TokenTrace 849 - usage={metrics.usage} 850 - responses={metrics.response} 851 - onOpenTurn={openMetric} 852 - latestPromptText={latestPromptText} 853 - /> 854 - <MemoryReview 855 - pairs={pairs} 856 - selectedId={selectedMemoryId} 857 - reviewOnly={reviewOnly} 858 - onReviewOnlyChange={setReviewOnly} 859 - onSelect={selectPair} 860 - /> 861 - </div> 862 - <div className="metrics-side"> 863 - <DetailPane detail={detail} /> 864 - <BucketRail metrics={metrics} onOpenMetric={openMetric} /> 865 - </div> 866 - </div> 867 - ) : null} 868 - </main> 869 - ) 870 - }
-1
apps/web/src/main.tsx
··· 1 1 import { StrictMode } from "react" 2 2 import { createRoot } from "react-dom/client" 3 3 import { App } from "./App" 4 - import "highlight.js/styles/github-dark.css" 5 4 import "./styles.css" 6 5 7 6 const root = document.getElementById("root")
+480 -477
apps/web/src/styles.css
··· 1 1 :root { 2 2 color-scheme: dark; 3 - font-family: "JetBrains Mono", "Iosevka", "SFMono-Regular", ui-monospace, monospace; 4 - background: #151412; 5 - color: #efeee8; 3 + font-family: 4 + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 5 + background: #000; 6 + color: #ededed; 7 + --bg: #000; 8 + --panel: #050505; 9 + --panel-soft: #0b0b0b; 10 + --line: #262626; 11 + --line-soft: #171717; 12 + --ink: #ededed; 13 + --muted: #8a8a8a; 14 + --quiet: #5f5f5f; 15 + --agent: #d787ff; 16 + --you: #6bd7cf; 17 + --tool: #e5c07b; 18 + --context: #777; 19 + --green: #7ee787; 20 + --cyan: #79c0ff; 21 + --pink: #ff7eb6; 22 + --violet: #b18cff; 23 + --amber: #e5c07b; 24 + --danger-bg: #160609; 25 + --danger-ink: #ff8f9a; 6 26 } 7 27 8 28 * { 9 29 box-sizing: border-box; 10 30 } 11 31 32 + html, 33 + body, 34 + #root { 35 + min-height: 100vh; 36 + background: #000; 37 + } 38 + 12 39 body { 13 40 margin: 0; 14 - min-height: 100vh; 15 - background: 16 - linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0) 220px), 17 - #151412; 41 + overflow: hidden; 18 42 } 19 43 20 44 button, 21 - input { 45 + input, 46 + textarea { 22 47 font: inherit; 23 48 } 24 49 25 50 button { 26 51 cursor: pointer; 27 - } 28 - 29 - .shell { 30 - min-height: 100vh; 52 + border: 1px solid #303030; 53 + border-radius: 6px; 54 + background: #0c0c0c; 55 + color: var(--ink); 56 + padding: 7px 10px; 31 57 } 32 58 33 - .top-nav { 34 - position: sticky; 35 - top: 0; 36 - z-index: 5; 37 - height: 44px; 38 - display: flex; 39 - align-items: center; 40 - gap: 0.35rem; 41 - padding: 0 1rem; 42 - border-bottom: 1px solid #34302a; 43 - background: rgba(21, 20, 18, 0.92); 44 - backdrop-filter: blur(12px); 59 + button:hover:not(:disabled) { 60 + border-color: #555; 61 + background: #151515; 45 62 } 46 63 47 - .top-nav button { 48 - min-width: 5.5rem; 49 - height: 30px; 50 - border: 1px solid transparent; 51 - border-radius: 6px; 52 - background: transparent; 53 - color: #aaa39a; 64 + button:disabled { 65 + cursor: default; 66 + opacity: 0.46; 54 67 } 55 68 56 - .top-nav button:hover, 57 - .top-nav button.is-active { 58 - border-color: #645d50; 59 - background: #24211d; 60 - color: #f5f1e8; 69 + h1, 70 + h2, 71 + p { 72 + margin: 0; 61 73 } 62 74 63 - .app { 64 - max-width: 920px; 65 - margin: 0 auto; 66 - min-height: calc(100vh - 44px); 67 - padding: 1.25rem; 68 - display: grid; 69 - grid-template-rows: auto 1fr auto; 70 - gap: 1rem; 75 + h1 { 76 + font-size: 18px; 77 + font-weight: 700; 78 + letter-spacing: 0; 71 79 } 72 80 73 - .header h1, 74 - .metrics-header h1 { 75 - margin: 0; 76 - font-size: 1.1rem; 81 + h2 { 82 + font-size: 15px; 83 + font-weight: 700; 77 84 letter-spacing: 0; 78 85 } 79 86 80 - .status, 81 - .metrics-header p, 82 - .panel-head p, 83 - .detail-pane p { 84 - margin: 0.25rem 0 0; 85 - color: #aaa39a; 87 + .place { 88 + height: 100vh; 89 + display: grid; 90 + grid-template-columns: 260px minmax(0, 1fr) 286px; 91 + background: #000; 92 + color: var(--ink); 86 93 } 87 94 88 - .toggles { 89 - margin-top: 0.75rem; 90 - display: flex; 91 - gap: 0.75rem; 92 - flex-wrap: wrap; 93 - align-items: center; 94 - color: #aaa39a; 95 - font-size: 0.88rem; 95 + .connections, 96 + .notes, 97 + .thread { 98 + min-width: 0; 99 + background: #000; 96 100 } 97 101 98 - .toggles button, 99 - .composer button, 100 - .metrics-search button { 101 - border-radius: 6px; 102 - border: 1px solid #484237; 103 - background: #25211c; 104 - color: #efeee8; 105 - padding: 0.38rem 0.65rem; 102 + .connections { 103 + border-right: 1px solid var(--line); 106 104 } 107 105 108 - .toggles button:hover, 109 - .composer button:hover, 110 - .metrics-search button:hover { 111 - border-color: #8d7b55; 106 + .notes { 107 + border-left: 1px solid var(--line); 112 108 } 113 109 114 - .feed, 115 - .metric-panel, 116 - .detail-pane, 117 - .bucket-rail { 118 - border: 1px solid #34302a; 119 - border-radius: 8px; 120 - background: #1c1a17; 110 + .connections header, 111 + .thread-head { 112 + min-height: 58px; 113 + display: flex; 114 + align-items: center; 115 + justify-content: space-between; 116 + gap: 14px; 117 + border-bottom: 1px solid var(--line); 121 118 } 122 119 123 - .feed { 124 - padding: 0.9rem; 125 - overflow: auto; 120 + .connections header { 121 + padding: 0 16px; 126 122 } 127 123 128 - .entry { 129 - padding: 0.7rem; 130 - border-radius: 7px; 131 - margin-bottom: 0.7rem; 132 - border: 1px solid transparent; 124 + .connect-form { 125 + display: grid; 126 + gap: 8px; 127 + padding: 14px 16px; 128 + border-bottom: 1px solid var(--line); 133 129 } 134 130 135 - .entry strong { 136 - display: block; 137 - margin-bottom: 0.35rem; 131 + input, 132 + textarea { 133 + width: 100%; 134 + border: 1px solid #303030; 135 + border-radius: 6px; 136 + background: #020202; 137 + color: var(--ink); 138 + padding: 9px 10px; 139 + outline: none; 138 140 } 139 141 140 - .entry pre, 141 - .detail-pane pre { 142 - margin: 0; 143 - white-space: pre-wrap; 144 - word-break: break-word; 145 - font: inherit; 142 + input::placeholder, 143 + textarea::placeholder { 144 + color: #666; 146 145 } 147 146 148 - .entry-header { 149 - display: flex; 150 - justify-content: space-between; 151 - align-items: center; 152 - gap: 0.6rem; 153 - margin-bottom: 0.4rem; 147 + input:focus, 148 + textarea:focus { 149 + border-color: #6f6f6f; 150 + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.08); 154 151 } 155 152 156 - .entry-header button { 157 - border-radius: 6px; 158 - border: 1px solid #6b5a2d; 159 - background: #2c2619; 160 - color: #f0d98d; 161 - padding: 0.25rem 0.5rem; 153 + textarea { 154 + line-height: 1.55; 162 155 } 163 156 164 - .markdown { 165 - line-height: 1.45; 157 + .agent-list { 158 + padding: 12px 0; 166 159 } 167 160 168 - .markdown > :first-child { 169 - margin-top: 0; 161 + .agent-list section + section { 162 + margin-top: 16px; 170 163 } 171 164 172 - .markdown > :last-child { 173 - margin-bottom: 0; 165 + .panel-line { 166 + display: flex; 167 + align-items: center; 168 + justify-content: space-between; 169 + gap: 8px; 170 + padding: 0 16px 7px; 171 + color: var(--quiet); 172 + font-size: 12px; 174 173 } 175 174 176 - .markdown p, 177 - .markdown ul, 178 - .markdown ol, 179 - .markdown blockquote { 180 - margin: 0.45rem 0; 175 + .panel-line button { 176 + border: 0; 177 + background: transparent; 178 + color: #b36f6f; 179 + padding: 0; 181 180 } 182 181 183 - .markdown a { 184 - color: #7fc6a4; 182 + .agent-button { 183 + width: 100%; 184 + display: grid; 185 + grid-template-columns: minmax(0, 1fr) auto; 186 + align-items: baseline; 187 + gap: 10px; 188 + border: 0; 189 + border-radius: 0; 190 + border-left: 2px solid transparent; 191 + background: transparent; 192 + color: var(--ink); 193 + padding: 10px 16px 10px 14px; 194 + text-align: left; 185 195 } 186 196 187 - .markdown :not(pre) > code { 188 - background: #24211d; 189 - border: 1px solid #3a342c; 190 - border-radius: 5px; 191 - padding: 0.1rem 0.3rem; 197 + .agent-button.is-current { 198 + border-left-color: var(--agent); 199 + background: #090909; 192 200 } 193 201 194 - .markdown pre { 195 - padding: 0.65rem; 196 - border-radius: 7px; 197 - border: 1px solid #34302a; 198 - background: #12110f; 199 - overflow: auto; 202 + .agent-button span { 203 + min-width: 0; 204 + overflow: hidden; 205 + text-overflow: ellipsis; 206 + white-space: nowrap; 200 207 } 201 208 202 - .entry-user { 203 - background: #1c2925; 204 - border-color: #2c5146; 209 + .agent-button small, 210 + .quiet, 211 + .thread-head p, 212 + dt, 213 + .compactions small { 214 + color: var(--muted); 205 215 } 206 216 207 - .entry-incoming { 208 - background: #202722; 209 - border-color: #3b5947; 217 + .quiet { 218 + padding: 8px 16px; 219 + font-size: 12px; 220 + line-height: 1.45; 210 221 } 211 222 212 - .entry-niri { 213 - background: #241f28; 214 - border-color: #514761; 223 + .thread { 224 + height: 100vh; 225 + display: grid; 226 + grid-template-rows: auto auto minmax(0, 1fr) auto; 215 227 } 216 228 217 - .entry-tool { 218 - background: #272313; 219 - border-color: #5c501f; 229 + .thread-head { 230 + padding: 0 28px; 220 231 } 221 232 222 - .entry-thinking, 223 - .entry-info { 224 - background: #20201e; 225 - border-color: #383833; 226 - color: #c6c0b6; 233 + .thread-head > div:first-child { 234 + min-width: 0; 227 235 } 228 236 229 - .entry-error, 230 - .metrics-error, 231 - .detail-error { 232 - background: #331d1c; 233 - border-color: #73332f; 234 - color: #ffd8d2; 237 + .thread-head p { 238 + margin-top: 4px; 239 + font-size: 12px; 235 240 } 236 241 237 - .composer { 238 - display: grid; 239 - grid-template-columns: 1fr auto; 240 - gap: 0.6rem; 242 + .readout { 243 + display: flex; 244 + gap: 16px; 245 + color: var(--quiet); 246 + font-size: 12px; 247 + white-space: nowrap; 241 248 } 242 249 243 - .composer input, 244 - .metrics-search input { 245 - border-radius: 6px; 246 - border: 1px solid #484237; 247 - background: #201d19; 248 - color: inherit; 249 - padding: 0.65rem 0.8rem; 250 + .error-line { 251 + padding: 10px 28px; 252 + border-bottom: 1px solid #351014; 253 + background: var(--danger-bg); 254 + color: var(--danger-ink); 255 + font-size: 13px; 250 256 } 251 257 252 - .composer button { 253 - background: #314c3f; 254 - border-color: #507966; 258 + .messages { 259 + min-height: 0; 260 + overflow: auto; 261 + padding: 18px 28px 34px; 262 + scroll-padding-bottom: 26px; 255 263 } 256 264 257 - .composer button:disabled { 258 - opacity: 0.62; 259 - cursor: default; 265 + .empty { 266 + max-width: 780px; 267 + color: var(--muted); 268 + font-size: 14px; 269 + line-height: 1.65; 260 270 } 261 271 262 - .markdown pre code.hljs { 263 - background: transparent; 264 - padding: 0; 272 + .terminal-message { 273 + width: 100%; 274 + margin: 0 0 15px; 265 275 } 266 276 267 - .metrics-app { 268 - width: min(1680px, 100%); 269 - margin: 0 auto; 270 - min-height: calc(100vh - 44px); 271 - padding: 1rem; 277 + .terminal-message + .terminal-message-note, 278 + .terminal-message + .terminal-message-context { 279 + margin-top: 20px; 272 280 } 273 281 274 - .metrics-header { 282 + .terminal-row { 275 283 display: grid; 276 - grid-template-columns: minmax(0, 1fr) auto auto; 277 - align-items: end; 278 - gap: 1rem; 279 - padding: 0.75rem 0 1rem; 284 + grid-template-columns: max-content minmax(0, 1fr) auto; 285 + align-items: start; 286 + gap: 0 0.75ch; 280 287 } 281 288 282 - .metrics-search { 283 - display: grid; 284 - grid-template-columns: minmax(16rem, 24rem) auto auto auto; 285 - gap: 0.5rem; 289 + .terminal-prefix { 290 + line-height: 1.58; 291 + font-weight: 700; 292 + white-space: nowrap; 286 293 } 287 294 288 - .live-controls { 289 - display: flex; 290 - align-items: center; 291 - justify-content: flex-end; 292 - gap: 0.6rem; 293 - min-height: 2.35rem; 294 - color: #aaa39a; 295 + .terminal-prefix-agent { 296 + color: var(--agent); 295 297 } 296 298 297 - .live-controls button { 298 - min-width: 4.8rem; 299 - border: 1px solid #484237; 300 - border-radius: 999px; 301 - background: #25211c; 302 - color: #efeee8; 303 - padding: 0.34rem 0.65rem; 299 + .terminal-prefix-user { 300 + color: var(--you); 304 301 } 305 302 306 - .live-controls button.is-live { 307 - border-color: #5da37e; 308 - background: #1d3028; 309 - color: #b9f0d3; 303 + .terminal-prefix-tool { 304 + color: var(--tool); 310 305 } 311 306 312 - .live-controls span { 313 - white-space: nowrap; 314 - font-size: 0.82rem; 307 + .terminal-prefix-context, 308 + .terminal-prefix-note { 309 + color: var(--context); 310 + font-weight: 600; 315 311 } 316 312 317 - .metrics-loading, 318 - .metrics-error { 319 - border: 1px solid #34302a; 320 - border-radius: 8px; 321 - padding: 0.75rem; 313 + .terminal-prefix-error { 314 + color: var(--danger-ink); 322 315 } 323 316 324 - .metrics-grid { 325 - display: grid; 326 - grid-template-columns: minmax(0, 1fr) minmax(380px, 0.42fr); 327 - gap: 1rem; 328 - align-items: start; 317 + .terminal-body { 318 + min-width: 0; 319 + color: #ececec; 329 320 } 330 321 331 - .metrics-main, 332 - .metrics-side { 333 - display: grid; 334 - gap: 1rem; 335 - min-width: 0; 322 + .terminal-message-user .terminal-body { 323 + color: #e9fff9; 336 324 } 337 325 338 - .metric-panel, 339 - .detail-pane, 340 - .bucket-rail { 341 - min-width: 0; 342 - overflow: hidden; 326 + .terminal-message-context .terminal-body, 327 + .terminal-message-note .terminal-body { 328 + color: #8b8b8b; 343 329 } 344 330 345 - .panel-head { 346 - min-height: 66px; 347 - display: flex; 348 - justify-content: space-between; 349 - gap: 1rem; 350 - align-items: center; 351 - padding: 0.85rem 0.9rem; 352 - border-bottom: 1px solid #34302a; 331 + .terminal-time { 332 + padding-top: 3px; 333 + margin-left: 18px; 334 + color: #484848; 335 + font-size: 11px; 336 + line-height: 1.4; 337 + white-space: nowrap; 353 338 } 354 339 355 - .panel-head h2, 356 - .detail-pane h2, 357 - .bucket-rail h3, 358 - .detail-section h3 { 359 - margin: 0; 360 - font-size: 0.92rem; 361 - letter-spacing: 0; 340 + .terminal-summary-text { 341 + display: inline; 342 + color: inherit; 343 + line-height: 1.58; 362 344 } 363 345 364 - .token-readout { 365 - text-align: right; 346 + .terminal-command { 347 + display: inline-flex; 348 + max-width: 100%; 349 + align-items: baseline; 350 + gap: 12px; 351 + border: 0; 352 + border-radius: 0; 353 + background: transparent; 354 + color: var(--tool); 355 + padding: 0; 356 + text-align: left; 357 + line-height: 1.58; 366 358 } 367 359 368 - .token-readout span, 369 - .token-ledger span { 370 - display: block; 371 - font-size: 1.35rem; 372 - color: #f2e1a3; 360 + .terminal-command:hover:not(:disabled) { 361 + background: transparent; 362 + color: #f0d28f; 373 363 } 374 364 375 - .token-readout small, 376 - .token-ledger small, 377 - .memory-row small, 378 - .memory-hit span { 379 - color: #aaa39a; 365 + .terminal-command span { 366 + min-width: 0; 367 + overflow: hidden; 368 + text-overflow: ellipsis; 369 + white-space: nowrap; 380 370 } 381 371 382 - .token-strip { 383 - height: 190px; 384 - display: flex; 385 - align-items: end; 386 - gap: 2px; 387 - padding: 1rem 0.9rem 0.7rem; 388 - border-bottom: 1px solid #34302a; 372 + .terminal-command small { 373 + flex: 0 0 auto; 374 + color: #777; 375 + font-size: 12px; 376 + font-weight: 400; 389 377 } 390 378 391 - .token-bar { 392 - flex: 1 1 4px; 393 - min-width: 3px; 394 - max-width: 18px; 395 - display: flex; 396 - flex-direction: column-reverse; 379 + .terminal-inline-action { 380 + margin-left: 10px; 381 + border: 0; 382 + border-radius: 0; 383 + background: transparent; 384 + color: #7eb6d9; 397 385 padding: 0; 398 - border: 0; 399 - border-radius: 3px 3px 0 0; 400 - background: #353028; 401 - overflow: hidden; 386 + line-height: 1.58; 387 + text-decoration: underline; 388 + text-decoration-color: #335266; 389 + text-underline-offset: 3px; 402 390 } 403 391 404 - .token-bar:hover { 405 - outline: 1px solid #f2e1a3; 392 + .terminal-inline-action:hover:not(:disabled) { 393 + background: transparent; 394 + color: #a7d8f2; 406 395 } 407 396 408 - .token-bar-prompt { 409 - display: block; 410 - background: #5da37e; 397 + .terminal-block { 398 + margin: 8px 0 0; 399 + border-left: 1px solid #526a6a; 400 + padding-left: 12px; 411 401 } 412 402 413 - .token-bar-completion { 414 - display: block; 415 - background: #c7a84d; 403 + .terminal-block-tool { 404 + border-left-color: #655b36; 416 405 } 417 406 418 - .token-ledger { 419 - display: grid; 420 - grid-template-columns: repeat(3, 1fr); 421 - gap: 1px; 422 - background: #34302a; 407 + .markdown-body { 408 + max-width: none; 409 + color: inherit; 410 + font-size: 14px; 411 + line-height: 1.58; 423 412 } 424 413 425 - .token-ledger div { 426 - padding: 0.75rem 0.9rem; 427 - background: #1c1a17; 414 + .terminal-message-agent .markdown-body { 415 + color: #ededed; 428 416 } 429 417 430 - .latest-prompt { 431 - padding: 0.75rem 0.9rem; 432 - border-top: 1px solid #34302a; 418 + .terminal-message-user .markdown-body { 419 + color: #e9fff9; 433 420 } 434 421 435 - .latest-prompt small { 436 - color: #aaa39a; 437 - font-size: 0.76rem; 438 - text-transform: uppercase; 422 + .markdown-body > :first-child { 423 + margin-top: 0; 439 424 } 440 425 441 - .latest-prompt p { 442 - margin: 0.35rem 0 0; 443 - color: #d8d3ca; 444 - white-space: nowrap; 445 - overflow: hidden; 446 - text-overflow: ellipsis; 426 + .markdown-body > :last-child { 427 + margin-bottom: 0; 447 428 } 448 429 449 - .switch-row { 450 - display: flex; 451 - align-items: center; 452 - gap: 0.45rem; 453 - color: #c6c0b6; 454 - white-space: nowrap; 430 + .markdown-body p, 431 + .markdown-body ul, 432 + .markdown-body ol, 433 + .markdown-body blockquote, 434 + .markdown-body pre, 435 + .markdown-body table { 436 + margin: 0 0 12px; 455 437 } 456 438 457 - .memory-table { 458 - max-height: 560px; 459 - overflow: auto; 439 + .markdown-body ul, 440 + .markdown-body ol { 441 + padding-left: 22px; 460 442 } 461 443 462 - .memory-row { 463 - width: 100%; 464 - display: grid; 465 - grid-template-columns: 5.5rem 5rem minmax(0, 1fr) minmax(0, 1fr); 466 - gap: 0.75rem; 467 - align-items: start; 468 - padding: 0.6rem 0.9rem; 469 - border: 0; 470 - border-bottom: 1px solid #2d2924; 471 - background: transparent; 472 - color: inherit; 473 - text-align: left; 444 + .markdown-body li + li { 445 + margin-top: 4px; 474 446 } 475 447 476 - .memory-row:not(.memory-row-head):hover, 477 - .memory-row.is-selected { 478 - background: #24211d; 448 + .markdown-body a { 449 + color: #d7d7d7; 450 + text-decoration-color: #777; 451 + text-underline-offset: 3px; 479 452 } 480 453 481 - .memory-row-head { 482 - position: sticky; 483 - top: 0; 484 - z-index: 1; 485 - color: #aaa39a; 486 - background: #1c1a17; 487 - font-size: 0.78rem; 488 - text-transform: uppercase; 454 + .markdown-body code:not(pre code) { 455 + border: 1px solid #2b2b2b; 456 + border-radius: 4px; 457 + background: #111; 458 + color: #f0f0f0; 459 + padding: 1px 5px; 460 + font: inherit; 461 + font-size: 0.94em; 489 462 } 490 463 491 - .memory-row span { 492 - min-width: 0; 493 - overflow: hidden; 494 - text-overflow: ellipsis; 464 + .markdown-body pre, 465 + .code-block { 466 + width: 100%; 467 + max-height: 520px; 468 + overflow: auto; 469 + border: 0; 470 + border-left: 1px solid #526a6a; 471 + border-radius: 0; 472 + background: #111; 473 + padding: 10px 12px; 474 + color: #ededed; 475 + font: inherit; 476 + font-size: 13px; 477 + line-height: 1.58; 495 478 } 496 479 497 - .memory-row span:nth-child(3), 498 - .memory-row span:nth-child(4) { 499 - white-space: nowrap; 480 + .code-block { 481 + margin: 0; 482 + white-space: pre; 500 483 } 501 484 502 - .memory-row span:nth-child(2) { 503 - color: #7fc6a4; 485 + .markdown-body pre { 486 + margin-top: 6px; 504 487 } 505 488 506 - .memory-row.issue-loose span:nth-child(2) { 507 - color: #f2c66d; 489 + .terminal-block .code-block { 490 + border-left: 0; 491 + background: #0d0d0d; 508 492 } 509 493 510 - .memory-row.issue-missing span:nth-child(2) { 511 - color: #ff9f91; 494 + .markdown-body pre code, 495 + .code-block code { 496 + background: transparent; 497 + border: 0; 498 + padding: 0; 499 + color: inherit; 500 + font: inherit; 512 501 } 513 502 514 - .memory-row small { 515 - display: block; 516 - margin-top: 0.15rem; 503 + .markdown-body blockquote { 504 + border-left: 1px solid #454545; 505 + color: #aaa; 506 + padding-left: 12px; 517 507 } 518 508 519 - .detail-pane { 520 - max-height: 680px; 521 - overflow: auto; 522 - padding: 0.9rem; 509 + .markdown-body table { 510 + width: 100%; 511 + border-collapse: collapse; 512 + font-size: 13px; 523 513 } 524 514 525 - .detail-meta { 526 - display: grid; 527 - grid-template-columns: 1fr 1fr; 528 - gap: 1px; 529 - margin: 0.8rem 0; 530 - background: #34302a; 515 + .markdown-body th, 516 + .markdown-body td { 517 + border: 1px solid #252525; 518 + padding: 7px 9px; 531 519 } 532 520 533 - .detail-meta div { 534 - padding: 0.6rem; 535 - background: #1c1a17; 521 + .markdown-body th { 522 + background: #0d0d0d; 536 523 } 537 524 538 - .detail-meta dt { 539 - color: #aaa39a; 540 - font-size: 0.76rem; 541 - text-transform: uppercase; 525 + .hljs-keyword, 526 + .hljs-selector-tag, 527 + .hljs-subst { 528 + color: var(--pink); 542 529 } 543 530 544 - .detail-meta dd { 545 - margin: 0.15rem 0 0; 531 + .hljs-string, 532 + .hljs-title, 533 + .hljs-section, 534 + .hljs-name, 535 + .hljs-attribute { 536 + color: var(--green); 546 537 } 547 538 548 - .detail-section { 549 - padding-top: 0.9rem; 550 - margin-top: 0.9rem; 551 - border-top: 1px solid #34302a; 539 + .hljs-number, 540 + .hljs-literal, 541 + .hljs-symbol, 542 + .hljs-bullet { 543 + color: var(--violet); 552 544 } 553 545 554 - .memory-hit { 555 - padding: 0.65rem 0; 556 - border-bottom: 1px solid #2d2924; 546 + .hljs-built_in, 547 + .hljs-type, 548 + .hljs-class .hljs-title, 549 + .hljs-variable, 550 + .hljs-template-variable { 551 + color: var(--cyan); 557 552 } 558 553 559 - .memory-hit strong { 560 - display: block; 561 - margin-bottom: 0.15rem; 554 + .hljs-comment, 555 + .hljs-quote, 556 + .hljs-deletion, 557 + .hljs-meta { 558 + color: #777; 562 559 } 563 560 564 - .memory-hit p { 565 - margin: 0.4rem 0 0; 566 - color: #d8d3ca; 567 - line-height: 1.45; 561 + .hljs-addition { 562 + color: var(--green); 568 563 } 569 564 570 - .empty-note { 571 - margin: 0.45rem 0 0; 572 - color: #aaa39a; 565 + .hljs-params, 566 + .hljs-attr { 567 + color: var(--amber); 573 568 } 574 569 575 - .tool-trace-list { 570 + .composer { 576 571 display: grid; 577 - gap: 0.45rem; 578 - margin-top: 0.55rem; 579 - } 580 - 581 - .tool-panel-body { 582 - max-height: 430px; 583 - overflow: auto; 584 - padding: 0.75rem 0.9rem; 572 + grid-template-columns: minmax(0, 1fr) auto; 573 + gap: 12px; 574 + padding: 14px 28px 18px; 575 + border-top: 1px solid var(--line); 576 + background: #000; 585 577 } 586 578 587 - .tool-panel .tool-trace-list { 588 - margin-top: 0; 579 + .composer textarea { 580 + min-height: 76px; 581 + max-height: 190px; 582 + resize: vertical; 583 + background: #020202; 589 584 } 590 585 591 - .tool-trace { 592 - border: 1px solid #34302a; 593 - border-radius: 7px; 594 - background: #171512; 595 - overflow: hidden; 586 + .composer button { 587 + align-self: end; 588 + min-width: 88px; 589 + height: 40px; 590 + background: #ededed; 591 + border-color: #ededed; 592 + color: #000; 593 + font-weight: 700; 596 594 } 597 595 598 - .tool-trace summary { 599 - display: flex; 600 - justify-content: space-between; 601 - align-items: center; 602 - gap: 0.75rem; 603 - padding: 0.55rem 0.65rem; 604 - cursor: pointer; 596 + .notes { 597 + padding: 0 16px 22px; 598 + overflow: auto; 605 599 } 606 600 607 - .tool-trace summary span, 608 - .tool-trace summary small { 609 - min-width: 0; 610 - overflow: hidden; 611 - text-overflow: ellipsis; 612 - white-space: nowrap; 601 + .notes section { 602 + padding: 20px 0; 603 + border-bottom: 1px solid var(--line); 613 604 } 614 605 615 - .tool-trace summary small { 616 - color: #aaa39a; 606 + .notes h2 { 607 + margin-bottom: 13px; 608 + color: #dcdcdc; 617 609 } 618 610 619 - .tool-args { 611 + dl { 620 612 margin: 0; 621 - padding: 0.6rem 0.65rem; 622 - border-top: 1px solid #2d2924; 623 - color: #d9cfb7; 624 - background: #201d19; 625 - white-space: pre-wrap; 626 - word-break: break-word; 627 613 } 628 614 629 - .tool-result { 630 - border-top: 1px solid #2d2924; 631 - padding: 0.65rem; 615 + dl div { 616 + display: grid; 617 + grid-template-columns: 72px minmax(0, 1fr); 618 + gap: 10px; 619 + padding: 6px 0; 632 620 } 633 621 634 - .bucket-rail { 635 - padding: 0.75rem 0.9rem; 636 - } 637 - 638 - .bucket-rail > section + section { 639 - margin-top: 0.9rem; 640 - padding-top: 0.9rem; 641 - border-top: 1px solid #34302a; 622 + dt { 623 + font-size: 12px; 642 624 } 643 625 644 - .bucket-rail header { 645 - display: flex; 646 - justify-content: space-between; 647 - align-items: center; 648 - gap: 1rem; 649 - margin-bottom: 0.45rem; 626 + dd { 627 + margin: 0; 628 + color: #dedede; 650 629 } 651 630 652 - .bucket-rail header span { 653 - color: #aaa39a; 631 + .compactions { 632 + display: grid; 633 + gap: 10px; 654 634 } 655 635 656 - .bucket-list { 657 - display: grid; 658 - gap: 0.25rem; 636 + .compactions article { 637 + border-top: 1px solid var(--line-soft); 638 + padding-top: 10px; 659 639 } 660 640 661 - .bucket-list button { 641 + .compactions button { 642 + width: 100%; 662 643 display: grid; 663 - grid-template-columns: 4.5rem minmax(0, 1fr); 664 - gap: 0.55rem; 665 - align-items: start; 644 + grid-template-columns: 48px minmax(0, 1fr); 645 + gap: 3px 10px; 666 646 border: 0; 667 - border-radius: 5px; 647 + border-radius: 0; 668 648 background: transparent; 669 - color: inherit; 670 - padding: 0.35rem 0.25rem; 649 + color: var(--ink); 650 + padding: 0; 671 651 text-align: left; 672 652 } 673 653 674 - .bucket-list button:hover { 675 - background: #24211d; 654 + .compactions button:hover:not(:disabled) { 655 + background: transparent; 676 656 } 677 657 678 - .bucket-list span { 679 - color: #aaa39a; 680 - } 681 - 682 - .bucket-list strong { 658 + .compactions strong, 659 + .compactions small { 683 660 min-width: 0; 684 661 overflow: hidden; 685 662 text-overflow: ellipsis; 686 663 white-space: nowrap; 687 - font-weight: 500; 664 + } 665 + 666 + .compactions small { 667 + grid-column: 2; 688 668 } 689 669 690 670 @media (max-width: 1120px) { 691 - .metrics-grid { 692 - grid-template-columns: 1fr; 671 + .place { 672 + grid-template-columns: 230px minmax(0, 1fr); 693 673 } 694 674 695 - .metrics-side { 696 - grid-template-columns: 1fr; 675 + .notes { 676 + display: none; 697 677 } 698 678 } 699 679 700 680 @media (max-width: 760px) { 701 - .metrics-app, 702 - .app { 703 - padding: 0.75rem; 681 + body { 682 + overflow: auto; 704 683 } 705 684 706 - .metrics-header { 685 + .place { 686 + min-height: 100vh; 687 + height: auto; 707 688 grid-template-columns: 1fr; 708 689 } 709 690 710 - .metrics-search { 711 - grid-template-columns: 1fr; 691 + .connections { 692 + border-right: 0; 693 + border-bottom: 1px solid var(--line); 694 + } 695 + 696 + .agent-list { 697 + max-height: 220px; 698 + overflow: auto; 699 + } 700 + 701 + .thread { 702 + min-height: 72vh; 703 + height: auto; 704 + } 705 + 706 + .thread-head, 707 + .composer { 708 + padding-left: 16px; 709 + padding-right: 16px; 712 710 } 713 711 714 - .memory-row { 715 - grid-template-columns: 4.5rem 4rem minmax(0, 1fr); 712 + .messages { 713 + padding: 16px 16px 24px; 716 714 } 717 715 718 - .memory-row span:nth-child(4) { 716 + .readout, 717 + .terminal-time { 719 718 display: none; 719 + } 720 + 721 + .terminal-row { 722 + grid-template-columns: max-content minmax(0, 1fr); 720 723 } 721 724 722 725 .composer {
+3
apps/web/vite.config.ts
··· 13 13 14 14 return { 15 15 envDir: REPO_ROOT, 16 + base: mode === "production" ? "/ui/" : "/", 16 17 plugins: [react()], 17 18 server: { 18 19 host: true, 19 20 proxy: { 21 + "/agents": backendTarget, 22 + "/health": backendTarget, 20 23 "/trigger": backendTarget, 21 24 "/chat": backendTarget, 22 25 "/status": backendTarget,
+5 -1
package.json
··· 10 10 ], 11 11 "scripts": { 12 12 "start": "dotenv -o -- tsx src/index.ts", 13 + "start:worker": "dotenv -o -- tsx src/index.ts", 14 + "start:control": "dotenv -o -- tsx src/control/index.ts", 13 15 "start:local": "NIRI_ENV=local dotenv -o -- tsx src/index.ts", 14 16 "dev": "dotenv -o -- tsx watch src/index.ts", 17 + "dev:worker": "dotenv -o -- tsx watch src/index.ts", 18 + "dev:control": "dotenv -o -- tsx watch src/control/index.ts", 15 19 "dev:local": "NIRI_ENV=local dotenv -o -- tsx watch src/index.ts", 16 - "test": "node --import tsx --test src/**/*.test.ts", 20 + "test": "NIRI_ENV=local node --import tsx --test src/**/*.test.ts", 17 21 "chat": "dotenv -o -- tsx scripts/chat.ts", 18 22 "chat:local": "NIRI_ENV=local dotenv -o -- tsx scripts/chat.ts", 19 23 "dev:web": "npm run dev --workspace @niri/web",
+15
src/agent-config.ts
··· 1 + import path from "path" 2 + 3 + /** Stable id used by the control plane to distinguish this worker. */ 4 + export const AGENT_ID = (process.env.NIRI_AGENT_ID ?? process.env.AGENT_ID ?? "niri").trim() || "niri" 5 + 6 + /** Repository fallback home used when a local OS home is unavailable. */ 7 + export const REPO_HOME_DIR = path.resolve(process.cwd(), "home") 8 + 9 + /** 10 + * Harness home for soul, memories, and local databases. 11 + * 12 + * This is intentionally independent from where model-facing tools execute. 13 + * Set NIRI_HOME on each worker VM to give that agent her own persistent home. 14 + */ 15 + export const NIRI_HOME = path.resolve(process.env.NIRI_HOME ?? process.env.HOME ?? REPO_HOME_DIR)
+127
src/awp/outbox.ts
··· 1 + import { randomUUID } from "crypto" 2 + import { AGENT_ID } from "../agent-config" 3 + import { getDb } from "../db" 4 + import type { WorkerEvent, WorkerEventType } from "./types" 5 + 6 + type WorkerEventListener = (event: WorkerEvent) => void 7 + 8 + const listeners = new Set<WorkerEventListener>() 9 + let tableReady = false 10 + let warnedUnavailable = false 11 + 12 + function ensureTable(): void { 13 + if (tableReady) return 14 + 15 + getDb().exec(` 16 + create table if not exists worker_events ( 17 + seq integer primary key autoincrement, 18 + id text not null unique, 19 + agent_id text not null, 20 + type text not null, 21 + payload text not null, 22 + created_at text not null 23 + ); 24 + 25 + create index if not exists idx_worker_events_agent_seq 26 + on worker_events(agent_id, seq); 27 + `) 28 + 29 + tableReady = true 30 + } 31 + 32 + function decodeWorkerEvent(row: { 33 + seq: number 34 + id: string 35 + agent_id: string 36 + type: string 37 + payload: string 38 + created_at: string 39 + }): WorkerEvent { 40 + let payload: unknown 41 + try { 42 + payload = JSON.parse(row.payload) 43 + } catch { 44 + payload = row.payload 45 + } 46 + 47 + return { 48 + id: row.id, 49 + agentId: row.agent_id, 50 + seq: row.seq, 51 + type: row.type as WorkerEventType, 52 + createdAt: row.created_at, 53 + payload, 54 + } 55 + } 56 + 57 + export function publishWorkerEvent(type: WorkerEventType, payload: unknown): WorkerEvent { 58 + const id = randomUUID() 59 + const createdAt = new Date().toISOString() 60 + let event: WorkerEvent = { 61 + id, 62 + agentId: AGENT_ID, 63 + seq: 0, 64 + type, 65 + createdAt, 66 + payload, 67 + } 68 + 69 + try { 70 + ensureTable() 71 + const result = getDb() 72 + .prepare("insert into worker_events (id, agent_id, type, payload, created_at) values (?, ?, ?, ?, ?)") 73 + .run(id, AGENT_ID, type, JSON.stringify(payload), createdAt) 74 + event = { 75 + ...event, 76 + seq: Number(result.lastInsertRowid), 77 + } 78 + } catch (err) { 79 + const message = err instanceof Error ? err.message : String(err) 80 + if (message !== "Database not initialized" && !warnedUnavailable) { 81 + warnedUnavailable = true 82 + console.warn(`[awp] worker event outbox unavailable: ${message}`) 83 + } 84 + } 85 + 86 + for (const listener of listeners) listener(event) 87 + return event 88 + } 89 + 90 + export function listWorkerEvents(afterSeq = 0, limit = 500, mode: "after" | "tail" = "after"): WorkerEvent[] { 91 + ensureTable() 92 + const cappedLimit = Math.max(1, Math.min(1000, Math.trunc(limit) || 500)) 93 + const rows = 94 + mode === "tail" 95 + ? getDb() 96 + .prepare( 97 + `select seq, id, agent_id, type, payload, created_at 98 + from worker_events 99 + order by seq desc 100 + limit ?`, 101 + ) 102 + .all(cappedLimit) 103 + .reverse() 104 + : getDb() 105 + .prepare( 106 + `select seq, id, agent_id, type, payload, created_at 107 + from worker_events 108 + where seq > ? 109 + order by seq asc 110 + limit ?`, 111 + ) 112 + .all(Math.max(0, Math.trunc(afterSeq) || 0), cappedLimit) 113 + 114 + return (rows as Array<{ 115 + seq: number 116 + id: string 117 + agent_id: string 118 + type: string 119 + payload: string 120 + created_at: string 121 + }>).map(decodeWorkerEvent) 122 + } 123 + 124 + export function subscribeWorkerEvents(listener: WorkerEventListener): () => void { 125 + listeners.add(listener) 126 + return () => listeners.delete(listener) 127 + }
+70
src/awp/types.ts
··· 1 + import type { StreamEvent } from "../stream" 2 + import type { MetricEvent } from "../metrics" 3 + import type { UserMessage } from "../types" 4 + 5 + export type WorkerEventType = 6 + | "worker.hello" 7 + | "worker.heartbeat" 8 + | "runner.status" 9 + | "stream.event" 10 + | "metric.recorded" 11 + | "conversation.started" 12 + | "conversation.message" 13 + | "conversation.ended" 14 + 15 + export interface WorkerEvent<T = unknown> { 16 + id: string 17 + agentId: string 18 + seq: number 19 + type: WorkerEventType 20 + createdAt: string 21 + payload: T 22 + } 23 + 24 + export type WorkerStatus = { 25 + agentId: string 26 + running: boolean 27 + idle: boolean 28 + } 29 + 30 + export type EnqueueEventCommand = { 31 + type: "event.enqueue" 32 + event: UserMessage 33 + options?: { 34 + onlyIfWaiting?: boolean 35 + priority?: boolean 36 + } 37 + } 38 + 39 + export type WorkerShutdownCommand = { 40 + type: "worker.shutdown" 41 + } 42 + 43 + export type ControlCommand = EnqueueEventCommand | WorkerShutdownCommand 44 + 45 + export type StreamWorkerEventPayload = StreamEvent 46 + 47 + export type MetricRecordedPayload = MetricEvent & { 48 + id: number 49 + } 50 + 51 + export type ConversationStartedPayload = { 52 + conversationId: number 53 + source: string 54 + startedAt: string 55 + } 56 + 57 + export type ConversationMessagePayload = { 58 + conversationId: number 59 + role: string 60 + content: string 61 + toolCalls?: unknown 62 + toolCallId?: string 63 + createdAt: string 64 + } 65 + 66 + export type ConversationEndedPayload = { 67 + conversationId: number 68 + tokens: number 69 + endedAt: string 70 + }
+5 -2
src/container/config.ts
··· 1 1 import path from "path" 2 + import { REPO_HOME_DIR as CONFIG_REPO_HOME_DIR } from "../agent-config" 2 3 3 4 const configuredContainerName = (process.env.NIRI_CONTAINER ?? "").trim() 4 5 const configuredContainerUser = (process.env.NIRI_USER ?? "").trim() ··· 10 11 /** Linux user inside the container used for command execution. */ 11 12 export const CONTAINER_USER = configuredContainerUser || "niri" 12 13 /** Repository fallback home used when a local OS home is unavailable. */ 13 - export const REPO_HOME_DIR = path.resolve(process.cwd(), "home") 14 + export const REPO_HOME_DIR = CONFIG_REPO_HOME_DIR 14 15 /** Harness home for soul, memories, and local databases. */ 15 - export const HOME_DIR = USE_DOCKER_SHELL ? REPO_HOME_DIR : path.resolve(process.env.HOME ?? REPO_HOME_DIR) 16 + export const HOME_DIR = path.resolve( 17 + (process.env.NIRI_HOME ?? "").trim() || (USE_DOCKER_SHELL ? REPO_HOME_DIR : (process.env.HOME ?? REPO_HOME_DIR)), 18 + ) 16 19 17 20 /** Absolute image root allowed for `image_tool` operations. */ 18 21 export const IMAGE_ROOT = (() => {
+253
src/control/db.ts
··· 1 + import Database from "better-sqlite3" 2 + import fs from "fs" 3 + import path from "path" 4 + import { NIRI_HOME } from "../agent-config" 5 + 6 + const CONTROL_DB_PATH = path.resolve(process.env.NIRI_CONTROL_DB ?? path.join(NIRI_HOME, "control.db")) 7 + 8 + let db: Database.Database 9 + 10 + export type AgentRecord = { 11 + id: string 12 + name: string 13 + baseUrl: string 14 + token?: string 15 + status: string 16 + lastSeenAt?: string 17 + lastSeq: number 18 + } 19 + 20 + type AgentRow = { 21 + id: string 22 + name: string 23 + base_url: string 24 + token: string | null 25 + status: string 26 + last_seen_at: string | null 27 + last_seq: number 28 + } 29 + 30 + function agentFromRow(row: AgentRow): AgentRecord { 31 + return { 32 + id: row.id, 33 + name: row.name, 34 + baseUrl: row.base_url, 35 + ...(row.token ? { token: row.token } : {}), 36 + status: row.status, 37 + ...(row.last_seen_at ? { lastSeenAt: row.last_seen_at } : {}), 38 + lastSeq: row.last_seq, 39 + } 40 + } 41 + 42 + export function initControlDb(): void { 43 + fs.mkdirSync(path.dirname(CONTROL_DB_PATH), { recursive: true }) 44 + db = new Database(CONTROL_DB_PATH) 45 + db.pragma("journal_mode = WAL") 46 + db.exec(` 47 + create table if not exists agents ( 48 + id text primary key, 49 + name text not null, 50 + base_url text not null, 51 + token text, 52 + status text not null default 'unknown', 53 + last_seen_at text, 54 + last_seq integer not null default 0, 55 + created_at text not null default (datetime('now')), 56 + updated_at text not null default (datetime('now')) 57 + ); 58 + 59 + create table if not exists worker_events ( 60 + agent_id text not null, 61 + seq integer not null, 62 + id text not null, 63 + type text not null, 64 + payload text not null, 65 + created_at text not null, 66 + received_at text not null, 67 + primary key (agent_id, seq) 68 + ); 69 + 70 + create unique index if not exists idx_worker_events_id 71 + on worker_events(agent_id, id); 72 + create index if not exists idx_worker_events_type 73 + on worker_events(type, received_at desc); 74 + `) 75 + console.log(`[control] db ready at ${CONTROL_DB_PATH}`) 76 + } 77 + 78 + export function upsertAgent(input: { 79 + id: string 80 + name?: string 81 + baseUrl: string 82 + token?: string 83 + }): AgentRecord { 84 + const id = input.id.trim() 85 + const baseUrl = input.baseUrl.trim().replace(/\/+$/, "") 86 + if (!id) throw new Error("agent id is required") 87 + if (!baseUrl) throw new Error("agent baseUrl is required") 88 + 89 + const name = input.name?.trim() || id 90 + db.prepare( 91 + `insert into agents (id, name, base_url, token, status, updated_at) 92 + values (?, ?, ?, ?, 'unknown', ?) 93 + on conflict(id) do update set 94 + name = excluded.name, 95 + base_url = excluded.base_url, 96 + token = excluded.token, 97 + updated_at = excluded.updated_at`, 98 + ).run(id, name, baseUrl, input.token?.trim() || null, new Date().toISOString()) 99 + 100 + return getAgent(id)! 101 + } 102 + 103 + export function listAgents(): AgentRecord[] { 104 + const rows = db 105 + .prepare("select id, name, base_url, token, status, last_seen_at, last_seq from agents order by id asc") 106 + .all() as AgentRow[] 107 + return rows.map(agentFromRow) 108 + } 109 + 110 + export function getAgent(id: string): AgentRecord | null { 111 + const row = db 112 + .prepare("select id, name, base_url, token, status, last_seen_at, last_seq from agents where id = ?") 113 + .get(id) as AgentRow | undefined 114 + return row ? agentFromRow(row) : null 115 + } 116 + 117 + export function updateAgentStatus(id: string, status: string, lastSeq?: number): void { 118 + const now = new Date().toISOString() 119 + db.prepare( 120 + `update agents 121 + set status = ?, 122 + last_seen_at = ?, 123 + last_seq = max(last_seq, ?), 124 + updated_at = ? 125 + where id = ?`, 126 + ).run(status, now, Math.max(0, Math.trunc(lastSeq ?? 0)), now, id) 127 + } 128 + 129 + export function recordWorkerEvent(event: { 130 + agentId: string 131 + seq: number 132 + id: string 133 + type: string 134 + payload: unknown 135 + createdAt: string 136 + }): void { 137 + const receivedAt = new Date().toISOString() 138 + db.prepare( 139 + `insert or ignore into worker_events (agent_id, seq, id, type, payload, created_at, received_at) 140 + values (?, ?, ?, ?, ?, ?, ?)`, 141 + ).run(event.agentId, event.seq, event.id, event.type, JSON.stringify(event.payload), event.createdAt, receivedAt) 142 + updateAgentStatus(event.agentId, "online", event.seq) 143 + } 144 + 145 + export function listMirroredEvents(agentId: string, afterSeq = 0, limit = 500, mode: "after" | "tail" = "after"): unknown[] { 146 + const cappedLimit = Math.max(1, Math.min(1000, Math.trunc(limit) || 500)) 147 + const rows = 148 + mode === "tail" 149 + ? db 150 + .prepare( 151 + `select agent_id, seq, id, type, payload, created_at 152 + from worker_events 153 + where agent_id = ? 154 + order by seq desc 155 + limit ?`, 156 + ) 157 + .all(agentId, cappedLimit) 158 + .reverse() 159 + : db 160 + .prepare( 161 + `select agent_id, seq, id, type, payload, created_at 162 + from worker_events 163 + where agent_id = ? and seq > ? 164 + order by seq asc 165 + limit ?`, 166 + ) 167 + .all(agentId, Math.max(0, Math.trunc(afterSeq) || 0), cappedLimit) 168 + 169 + return (rows as Array<{ 170 + agent_id: string 171 + seq: number 172 + id: string 173 + type: string 174 + payload: string 175 + created_at: string 176 + }>).map((row) => { 177 + let payload: unknown 178 + try { 179 + payload = JSON.parse(row.payload) 180 + } catch { 181 + payload = row.payload 182 + } 183 + return { 184 + agentId: row.agent_id, 185 + seq: row.seq, 186 + id: row.id, 187 + type: row.type, 188 + createdAt: row.created_at, 189 + payload, 190 + } 191 + }) 192 + } 193 + 194 + export type MirroredCompaction = { 195 + agentId: string 196 + seq: number 197 + eventId: string 198 + metricId?: number 199 + timestamp: string 200 + method?: string 201 + before?: number 202 + after?: number 203 + savedTokens?: number 204 + summary?: string 205 + } 206 + 207 + export function listRecentCompactions(agentId: string, limit = 20): MirroredCompaction[] { 208 + const cappedLimit = Math.max(1, Math.min(100, Math.trunc(limit) || 20)) 209 + const rows = db 210 + .prepare( 211 + `select agent_id, seq, id, payload, created_at 212 + from worker_events 213 + where agent_id = ? and type = 'metric.recorded' 214 + order by seq desc 215 + limit 500`, 216 + ) 217 + .all(agentId) as Array<{ 218 + agent_id: string 219 + seq: number 220 + id: string 221 + payload: string 222 + created_at: string 223 + }> 224 + 225 + const compactions: MirroredCompaction[] = [] 226 + for (const row of rows) { 227 + let payload: Record<string, unknown> 228 + try { 229 + payload = JSON.parse(row.payload) as Record<string, unknown> 230 + } catch { 231 + continue 232 + } 233 + if (payload.type !== "compaction") continue 234 + 235 + const before = typeof payload.before === "number" ? payload.before : undefined 236 + const after = typeof payload.after === "number" ? payload.after : undefined 237 + compactions.push({ 238 + agentId: row.agent_id, 239 + seq: row.seq, 240 + eventId: row.id, 241 + metricId: typeof payload.id === "number" ? payload.id : undefined, 242 + timestamp: typeof payload.timestamp === "string" ? payload.timestamp : row.created_at, 243 + method: typeof payload.method === "string" ? payload.method : undefined, 244 + before, 245 + after, 246 + savedTokens: before !== undefined && after !== undefined ? before - after : undefined, 247 + summary: typeof payload.summary === "string" ? payload.summary : undefined, 248 + }) 249 + if (compactions.length >= cappedLimit) break 250 + } 251 + 252 + return compactions 253 + }
+78
src/control/index.ts
··· 1 + import { initControlDb, upsertAgent } from "./db" 2 + import { createControlServer } from "./server" 3 + 4 + const PORT = Number.parseInt(process.env.CONTROL_PORT ?? process.env.PORT ?? "3001", 10) 5 + 6 + type ConfiguredAgent = { 7 + id: string 8 + name?: string 9 + baseUrl: string 10 + token?: string 11 + } 12 + 13 + function parseConfiguredAgents(): ConfiguredAgent[] { 14 + const json = process.env.NIRI_AGENTS_JSON?.trim() 15 + if (json) { 16 + const parsed = JSON.parse(json) as ConfiguredAgent[] 17 + if (!Array.isArray(parsed)) throw new Error("NIRI_AGENTS_JSON must be an array") 18 + return parsed 19 + } 20 + 21 + const compact = process.env.NIRI_AGENTS?.trim() 22 + if (compact) { 23 + return compact 24 + .split(",") 25 + .map((entry) => entry.trim()) 26 + .filter(Boolean) 27 + .map((entry) => { 28 + const [idPart, baseUrlPart] = entry.split("=", 2) 29 + const id = idPart?.trim() ?? "" 30 + const baseUrl = baseUrlPart?.trim() ?? "" 31 + if (!id || !baseUrl) throw new Error(`invalid NIRI_AGENTS entry: ${entry}`) 32 + return { id, baseUrl } 33 + }) 34 + } 35 + 36 + const defaultUrl = process.env.NIRI_AGENT_URL?.trim() 37 + if (defaultUrl) { 38 + return [ 39 + { 40 + id: process.env.NIRI_AGENT_ID?.trim() || "niri", 41 + baseUrl: defaultUrl, 42 + }, 43 + ] 44 + } 45 + 46 + return [] 47 + } 48 + 49 + async function main() { 50 + console.log("[control] starting up...") 51 + initControlDb() 52 + 53 + for (const agent of parseConfiguredAgents()) { 54 + upsertAgent(agent) 55 + console.log(`[control] registered ${agent.id} -> ${agent.baseUrl}`) 56 + } 57 + 58 + const server = createControlServer() 59 + await server.listen({ port: PORT, host: "0.0.0.0" }) 60 + console.log(`[control] listening on :${PORT}`) 61 + 62 + let shuttingDown = false 63 + async function gracefulShutdown(sig: string) { 64 + if (shuttingDown) return 65 + shuttingDown = true 66 + console.log(`\n[control] ${sig} received, shutting down...`) 67 + await server.close() 68 + process.exit(0) 69 + } 70 + 71 + process.on("SIGINT", () => gracefulShutdown("SIGINT")) 72 + process.on("SIGTERM", () => gracefulShutdown("SIGTERM")) 73 + } 74 + 75 + main().catch((err) => { 76 + console.error("[control] fatal:", err) 77 + process.exit(1) 78 + })
+430
src/control/server.ts
··· 1 + import Fastify from "fastify" 2 + import type { FastifyReply } from "fastify" 3 + import fastifyStatic from "@fastify/static" 4 + import { existsSync } from "node:fs" 5 + import { dirname, join } from "node:path" 6 + import { fileURLToPath } from "node:url" 7 + import { 8 + getAgent, 9 + listAgents, 10 + listMirroredEvents, 11 + listRecentCompactions, 12 + recordWorkerEvent, 13 + upsertAgent, 14 + updateAgentStatus, 15 + } from "./db" 16 + import type { ControlCommand, WorkerEvent } from "../awp/types" 17 + import type { UserMessage } from "../types" 18 + 19 + const SRC_DIR = dirname(fileURLToPath(import.meta.url)) 20 + const WEB_DIST_DIR = join(SRC_DIR, "..", "..", "apps", "web", "dist") 21 + 22 + function agentAuthHeaders(token?: string): Record<string, string> { 23 + return token ? { authorization: `Bearer ${token}` } : {} 24 + } 25 + 26 + function parseJsonOrText(text: string): unknown { 27 + try { 28 + return JSON.parse(text) 29 + } catch { 30 + return text 31 + } 32 + } 33 + 34 + async function fetchWorkerJson(agent: { baseUrl: string; token?: string }, path: string, init: RequestInit = {}): Promise<unknown> { 35 + const res = await fetch(`${agent.baseUrl}${path}`, { 36 + ...init, 37 + headers: { 38 + ...agentAuthHeaders(agent.token), 39 + ...(init.body ? { "content-type": "application/json" } : {}), 40 + ...(init.headers ?? {}), 41 + }, 42 + }) 43 + const text = await res.text() 44 + const data = parseJsonOrText(text) 45 + if (!res.ok) { 46 + const detail = typeof data === "object" && data && "error" in data ? String((data as { error: unknown }).error) : text 47 + throw new Error(detail || `${res.status} ${res.statusText}`) 48 + } 49 + return data 50 + } 51 + 52 + function looksLikeWorkerEvent(value: unknown): value is WorkerEvent { 53 + if (!value || typeof value !== "object") return false 54 + const event = value as Partial<WorkerEvent> 55 + return ( 56 + typeof event.id === "string" && 57 + typeof event.agentId === "string" && 58 + typeof event.seq === "number" && 59 + typeof event.type === "string" && 60 + typeof event.createdAt === "string" 61 + ) 62 + } 63 + 64 + function normalizeWorkerEvent(event: WorkerEvent, agentId?: string): WorkerEvent { 65 + if (!agentId || event.agentId === agentId) return event 66 + return { ...event, agentId } 67 + } 68 + 69 + function mirrorWorkerEvent(raw: unknown, agentId?: string): void { 70 + if (!looksLikeWorkerEvent(raw)) return 71 + recordWorkerEvent(normalizeWorkerEvent(raw, agentId)) 72 + } 73 + 74 + function mirrorWorkerEvents(raw: unknown, agentId?: string): void { 75 + if (!raw || typeof raw !== "object" || !("events" in raw)) return 76 + const events = (raw as { events?: unknown }).events 77 + if (!Array.isArray(events)) return 78 + for (const event of events) mirrorWorkerEvent(event, agentId) 79 + } 80 + 81 + function compactWorkerEventForChat(raw: unknown): WorkerEvent | null { 82 + if (!looksLikeWorkerEvent(raw)) return null 83 + const payload = raw.payload && typeof raw.payload === "object" ? (raw.payload as Record<string, unknown>) : {} 84 + 85 + if (raw.type === "conversation.started" || raw.type === "conversation.ended") return raw 86 + 87 + if (raw.type === "stream.event") { 88 + const type = typeof payload.type === "string" ? payload.type : "" 89 + if (type !== "user") return null 90 + return { 91 + ...raw, 92 + payload: { 93 + type, 94 + source: payload.source, 95 + text: payload.text, 96 + triggeredAt: payload.triggeredAt, 97 + clientId: payload.clientId, 98 + }, 99 + } 100 + } 101 + 102 + if (raw.type !== "conversation.message") return null 103 + 104 + const role = typeof payload.role === "string" ? payload.role : "" 105 + const content = typeof payload.content === "string" ? payload.content : "" 106 + if (!content.trim()) return null 107 + if (role === "user" && content.trim().startsWith("[wake]")) return null 108 + if (role === "user" && content.trim().startsWith("[incoming")) return null 109 + if (role !== "assistant" && role !== "tool" && role !== "user") return null 110 + 111 + return { 112 + ...raw, 113 + payload: { 114 + role, 115 + content: role === "tool" ? compactText(content, 2400) : content, 116 + createdAt: payload.createdAt, 117 + }, 118 + } 119 + } 120 + 121 + function compactText(text: string, maxChars: number): string { 122 + if (text.length <= maxChars) return text 123 + return `${text.slice(0, maxChars).trimEnd()}\n\n... truncated for the control panel ...` 124 + } 125 + 126 + function compactEventsForChat(events: unknown[]): WorkerEvent[] { 127 + const compacted: WorkerEvent[] = [] 128 + for (const event of events) { 129 + const compact = compactWorkerEventForChat(event) 130 + if (compact) compacted.push(compact) 131 + } 132 + return compacted 133 + } 134 + 135 + function splitSseBlocks(buffer: string): { blocks: string[]; rest: string } { 136 + const normalized = buffer.replace(/\r\n/g, "\n") 137 + const parts = normalized.split("\n\n") 138 + const rest = parts.pop() ?? "" 139 + return { blocks: parts, rest } 140 + } 141 + 142 + function mirrorSseBlock(block: string, agentId?: string): void { 143 + const dataLines = block 144 + .split("\n") 145 + .filter((line) => line.startsWith("data:")) 146 + .map((line) => line.slice(5).trimStart()) 147 + if (dataLines.length === 0) return 148 + 149 + try { 150 + mirrorWorkerEvent(JSON.parse(dataLines.join("\n")), agentId) 151 + } catch { 152 + // Ignore malformed upstream data; the raw SSE still reaches clients. 153 + } 154 + } 155 + 156 + async function proxyWorkerStream(agentId: string, reply: FastifyReply, afterSeq: number): Promise<void> { 157 + const agent = getAgent(agentId) 158 + if (!agent) { 159 + reply.code(404).send({ error: "agent not found" }) 160 + return 161 + } 162 + 163 + reply.hijack() 164 + 165 + const downstream = reply.raw 166 + downstream.writeHead(200, { 167 + "content-type": "text/event-stream", 168 + "cache-control": "no-cache", 169 + connection: "keep-alive", 170 + "x-accel-buffering": "no", 171 + }) 172 + downstream.write(":ok\n\n") 173 + 174 + let res: Response 175 + const controller = new AbortController() 176 + downstream.on("close", () => controller.abort()) 177 + try { 178 + res = await fetch(`${agent.baseUrl}/awp/stream?after_seq=${afterSeq}`, { 179 + headers: agentAuthHeaders(agent.token), 180 + signal: controller.signal, 181 + }) 182 + } catch (err) { 183 + downstream.write(`event: error\ndata: ${JSON.stringify({ error: err instanceof Error ? err.message : String(err) })}\n\n`) 184 + downstream.end() 185 + return 186 + } 187 + 188 + if (!res.ok || !res.body) { 189 + downstream.write(`event: error\ndata: ${JSON.stringify({ error: `${res.status} ${res.statusText}` })}\n\n`) 190 + downstream.end() 191 + return 192 + } 193 + 194 + const reader = res.body.getReader() 195 + const decoder = new TextDecoder() 196 + let buffer = "" 197 + 198 + try { 199 + while (!downstream.destroyed) { 200 + const { done, value } = await reader.read() 201 + if (done) break 202 + const chunk = decoder.decode(value, { stream: true }) 203 + downstream.write(chunk) 204 + buffer += chunk 205 + const parsed = splitSseBlocks(buffer) 206 + buffer = parsed.rest 207 + for (const block of parsed.blocks) mirrorSseBlock(block, agentId) 208 + } 209 + } catch (err) { 210 + if (!downstream.destroyed && !(err instanceof Error && err.name === "AbortError")) { 211 + downstream.write(`event: error\ndata: ${JSON.stringify({ error: err instanceof Error ? err.message : String(err) })}\n\n`) 212 + } 213 + } finally { 214 + controller.abort() 215 + reader.releaseLock() 216 + if (!downstream.destroyed) downstream.end() 217 + } 218 + } 219 + 220 + function chatEventFromBody(agentId: string, body: unknown): UserMessage | null { 221 + if (!body || typeof body !== "object") return null 222 + const b = body as Record<string, unknown> 223 + const content = typeof b.content === "string" ? b.content.trim() : "" 224 + if (!content) return null 225 + return { 226 + source: "chat", 227 + triggeredAt: new Date().toISOString(), 228 + content, 229 + raw: body, 230 + clientId: typeof b.clientId === "string" ? b.clientId : `control-${agentId}`, 231 + } 232 + } 233 + 234 + export function createControlServer() { 235 + const app = Fastify({ logger: false }) 236 + 237 + app.addHook("onRequest", async (_req, reply) => { 238 + reply.header("access-control-allow-origin", "*") 239 + reply.header("access-control-allow-methods", "GET,POST,OPTIONS") 240 + reply.header("access-control-allow-headers", "content-type,authorization") 241 + }) 242 + 243 + app.options("/*", async (_req, reply) => reply.code(204).send()) 244 + 245 + if (existsSync(WEB_DIST_DIR)) { 246 + app.register(fastifyStatic, { 247 + root: WEB_DIST_DIR, 248 + prefix: "/ui/", 249 + index: "index.html", 250 + }) 251 + 252 + app.get("/ui", async (_req, reply) => reply.sendFile("index.html")) 253 + } else { 254 + app.get("/ui", async (_req, reply) => { 255 + reply.code(503) 256 + return { error: "web ui is not built yet. run `npm run build:web` first." } 257 + }) 258 + } 259 + 260 + app.get("/agents", async () => ({ 261 + agents: listAgents().map(({ token: _token, ...agent }) => agent), 262 + })) 263 + 264 + app.post("/agents", async (req, reply) => { 265 + const body = req.body as Record<string, unknown> 266 + try { 267 + const agent = upsertAgent({ 268 + id: String(body.id ?? ""), 269 + name: typeof body.name === "string" ? body.name : undefined, 270 + baseUrl: String(body.baseUrl ?? body.base_url ?? ""), 271 + token: typeof body.token === "string" ? body.token : undefined, 272 + }) 273 + const { token: _token, ...safeAgent } = agent 274 + return reply.send({ ok: true, agent: safeAgent }) 275 + } catch (err) { 276 + return reply.code(400).send({ error: err instanceof Error ? err.message : String(err) }) 277 + } 278 + }) 279 + 280 + app.get("/agents/:id/status", async (req, reply) => { 281 + const { id } = req.params as { id: string } 282 + const agent = getAgent(id) 283 + if (!agent) return reply.code(404).send({ error: "agent not found" }) 284 + 285 + try { 286 + const status = await fetchWorkerJson(agent, "/awp/status") 287 + updateAgentStatus(id, "online") 288 + return status 289 + } catch (err) { 290 + updateAgentStatus(id, "offline") 291 + return reply.code(502).send({ error: err instanceof Error ? err.message : String(err) }) 292 + } 293 + }) 294 + 295 + app.get("/agents/:id/overview", async (req, reply) => { 296 + const { id } = req.params as { id: string } 297 + const agent = getAgent(id) 298 + if (!agent) return reply.code(404).send({ error: "agent not found" }) 299 + 300 + let workerStatus: unknown = null 301 + try { 302 + workerStatus = await fetchWorkerJson(agent, "/awp/status") 303 + updateAgentStatus(id, "online") 304 + } catch (err) { 305 + updateAgentStatus(id, "offline") 306 + workerStatus = { error: err instanceof Error ? err.message : String(err) } 307 + } 308 + 309 + const { token: _token, ...safeAgent } = getAgent(id) ?? agent 310 + return { 311 + agent: safeAgent, 312 + status: workerStatus, 313 + compactions: listRecentCompactions(id, 12), 314 + } 315 + }) 316 + 317 + app.post("/agents/:id/events", async (req, reply) => { 318 + const { id } = req.params as { id: string } 319 + const agent = getAgent(id) 320 + if (!agent) return reply.code(404).send({ error: "agent not found" }) 321 + 322 + const body = 323 + req.body && typeof req.body === "object" 324 + ? (req.body as Partial<ControlCommand> | Record<string, unknown>) 325 + : {} 326 + let command: ControlCommand 327 + if ("type" in body && body.type === "event.enqueue") { 328 + command = body as ControlCommand 329 + } else { 330 + const event = chatEventFromBody(id, body) 331 + if (!event) return reply.code(400).send({ error: "expected content or event.enqueue command" }) 332 + command = { 333 + type: "event.enqueue", 334 + event, 335 + } 336 + } 337 + 338 + if (command.type !== "event.enqueue" || !command.event) { 339 + return reply.code(400).send({ error: "expected content or event.enqueue command" }) 340 + } 341 + 342 + try { 343 + const previousLastSeq = agent.lastSeq 344 + const result = await fetchWorkerJson(agent, "/awp/events", { 345 + method: "POST", 346 + body: JSON.stringify(command), 347 + }) 348 + try { 349 + const remote = await fetchWorkerJson(agent, `/awp/events?after_seq=${previousLastSeq}&limit=1000`) 350 + mirrorWorkerEvents(remote, id) 351 + } catch (err) { 352 + console.warn(`[control] failed to sync events from ${id}: ${err instanceof Error ? err.message : String(err)}`) 353 + } 354 + updateAgentStatus(id, "online") 355 + return result 356 + } catch (err) { 357 + updateAgentStatus(id, "offline") 358 + return reply.code(502).send({ error: err instanceof Error ? err.message : String(err) }) 359 + } 360 + }) 361 + 362 + app.post("/agents/:id/shutdown", async (req, reply) => { 363 + const { id } = req.params as { id: string } 364 + const agent = getAgent(id) 365 + if (!agent) return reply.code(404).send({ error: "agent not found" }) 366 + 367 + try { 368 + const result = await fetchWorkerJson(agent, "/awp/shutdown", { method: "POST" }) 369 + updateAgentStatus(id, "online") 370 + return result 371 + } catch (err) { 372 + updateAgentStatus(id, "offline") 373 + return reply.code(502).send({ error: err instanceof Error ? err.message : String(err) }) 374 + } 375 + }) 376 + 377 + app.get("/agents/:id/events", async (req, reply) => { 378 + const { id } = req.params as { id: string } 379 + const agent = getAgent(id) 380 + if (!agent) return reply.code(404).send({ error: "agent not found" }) 381 + const query = req.query as { after_seq?: string; limit?: string; tail?: string; view?: string } 382 + const afterSeq = Number.parseInt(query.after_seq ?? "0", 10) || 0 383 + const limit = Number.parseInt(query.limit ?? "500", 10) || 500 384 + const mode = query.tail === "1" || query.tail === "true" ? "tail" : "after" 385 + 386 + try { 387 + const remote = 388 + mode === "tail" 389 + ? await fetchWorkerJson(agent, `/awp/events?tail=1&limit=${limit}`) 390 + : await fetchWorkerJson(agent, `/awp/events?after_seq=${afterSeq}&limit=${limit}`) 391 + mirrorWorkerEvents(remote, id) 392 + updateAgentStatus(id, "online") 393 + } catch (err) { 394 + updateAgentStatus(id, "offline") 395 + console.warn(`[control] failed to sync events from ${id}: ${err instanceof Error ? err.message : String(err)}`) 396 + } 397 + 398 + const events = listMirroredEvents( 399 + id, 400 + afterSeq, 401 + limit, 402 + mode, 403 + ) 404 + 405 + return { 406 + agentId: id, 407 + events: query.view === "chat" ? compactEventsForChat(events) : events, 408 + } 409 + }) 410 + 411 + app.get("/agents/:id/compactions", async (req, reply) => { 412 + const { id } = req.params as { id: string } 413 + if (!getAgent(id)) return reply.code(404).send({ error: "agent not found" }) 414 + const query = req.query as { limit?: string } 415 + return { 416 + agentId: id, 417 + compactions: listRecentCompactions(id, Number.parseInt(query.limit ?? "20", 10) || 20), 418 + } 419 + }) 420 + 421 + app.get("/agents/:id/stream", async (req, reply) => { 422 + const { id } = req.params as { id: string } 423 + const query = req.query as { after_seq?: string } 424 + await proxyWorkerStream(id, reply, Number.parseInt(query.after_seq ?? "0", 10) || 0) 425 + }) 426 + 427 + app.get("/health", async () => ({ ok: true })) 428 + 429 + return app 430 + }
+24 -2
src/db.ts
··· 2 2 import fs from "fs" 3 3 import path from "path" 4 4 import * as sqliteVec from "sqlite-vec" 5 + import { publishWorkerEvent } from "./awp/outbox" 5 6 import { HOME_DIR } from "./container/config" 6 7 7 8 const DB_PATH = path.join(HOME_DIR, "niri.db") ··· 236 237 export function startConversation(source: string, startedAt: string): number { 237 238 const stmt = db.prepare("insert into conversations (startedAt, source) values (?, ?)") 238 239 const result = stmt.run(startedAt, source) 239 - return result.lastInsertRowid as number 240 + const conversationId = result.lastInsertRowid as number 241 + publishWorkerEvent("conversation.started", { 242 + conversationId, 243 + source, 244 + startedAt, 245 + }) 246 + return conversationId 240 247 } 241 248 242 249 export function logMessage( ··· 246 253 toolCalls?: unknown, 247 254 toolCallId?: string, 248 255 ): void { 256 + const createdAt = new Date().toISOString() 249 257 const stmt = db.prepare( 250 - "insert into messages (convId, role, content, toolCalls, toolCallId) values (?, ?, ?, ?, ?)", 258 + "insert into messages (convId, role, content, toolCalls, toolCallId, createdAt) values (?, ?, ?, ?, ?, ?)", 251 259 ) 252 260 stmt.run( 253 261 convId, ··· 255 263 content, 256 264 toolCalls ? JSON.stringify(toolCalls) : null, 257 265 toolCallId ?? null, 266 + createdAt, 258 267 ) 268 + publishWorkerEvent("conversation.message", { 269 + conversationId: convId, 270 + role, 271 + content, 272 + ...(toolCalls ? { toolCalls } : {}), 273 + ...(toolCallId ? { toolCallId } : {}), 274 + createdAt, 275 + }) 259 276 } 260 277 261 278 export function endConversation(id: number, tokens: number): void { 262 279 db.prepare("update conversations set tokens = ? where id = ?").run(tokens, id) 280 + publishWorkerEvent("conversation.ended", { 281 + conversationId: id, 282 + tokens, 283 + endedAt: new Date().toISOString(), 284 + }) 263 285 } 264 286 265 287 export function getDb(): Database.Database {
+7 -4
src/embeddings.ts
··· 1 1 import OpenAI from "openai" 2 2 import { MEMORY_EMBEDDING_DIMENSIONS } from "./db" 3 + import { openAIHeaders, openAIUserAgent } from "./openai-headers" 3 4 4 5 export const EMBEDDING_MODEL = process.env.EMBEDDING_MODEL ?? "google/gemini-embedding-2-preview" 5 6 export const EMBEDDING_DIMENSIONS = parseInt( ··· 9 10 10 11 const EMBEDDING_BASE_URL = process.env.EMBEDDING_BASE_URL ?? "https://openrouter.ai/api/v1" 11 12 const EMBEDDING_API_KEY = process.env.EMBEDDING_API_KEY 13 + const embeddingHeaders = openAIHeaders([ 14 + ["HTTP-Referer", process.env.EMBEDDING_OPENAI_REFERER], 15 + ["X-Title", process.env.EMBEDDING_TITLE], 16 + ["User-Agent", openAIUserAgent(process.env.EMBEDDING_OPENAI_USER_AGENT)], 17 + ]) 12 18 13 19 const embeddingClient = EMBEDDING_API_KEY 14 20 ? new OpenAI({ 15 21 baseURL: EMBEDDING_BASE_URL, 16 22 apiKey: EMBEDDING_API_KEY, 17 - defaultHeaders: { 18 - ...(process.env.EMBEDDING_OPENAI_REFERER ? { "HTTP-Referer": process.env.EMBEDDING_OPENAI_REFERER } : {}), 19 - ...(process.env.EMBEDDING_TITLE ? { "X-Title": process.env.EMBEDDING_TITLE } : {}), 20 - }, 23 + defaultHeaders: embeddingHeaders, 21 24 }) 22 25 : null 23 26
+2
src/metrics.ts
··· 2 2 import fs from "fs" 3 3 import path from "path" 4 4 import type OpenAI from "openai" 5 + import { publishWorkerEvent } from "./awp/outbox" 5 6 import { HOME_DIR } from "./container/config" 6 7 import { getDb } from "./db" 7 8 import type { Message } from "./types" ··· 256 257 if (events.length > MAX_IN_MEMORY) { 257 258 events.shift() 258 259 } 260 + publishWorkerEvent("metric.recorded", fullEvent) 259 261 return fullEvent.id 260 262 } catch (err) { 261 263 console.error("[metrics] failed to record to db:", err)
+28
src/openai-headers.test.ts
··· 1 + import assert from "node:assert/strict" 2 + import test from "node:test" 3 + import { envHeaderValue, openAIHeaders, openAIUserAgent } from "./openai-headers" 4 + 5 + test("envHeaderValue trims blank env header values", () => { 6 + assert.equal(envHeaderValue(undefined), undefined) 7 + assert.equal(envHeaderValue(" "), undefined) 8 + assert.equal(envHeaderValue(" niri/1.0 "), "niri/1.0") 9 + }) 10 + 11 + test("openAIUserAgent uses provider-specific env before the global default", (t) => { 12 + const previous = process.env.OPENAI_USER_AGENT 13 + t.after(() => { 14 + if (previous === undefined) delete process.env.OPENAI_USER_AGENT 15 + else process.env.OPENAI_USER_AGENT = previous 16 + }) 17 + 18 + process.env.OPENAI_USER_AGENT = " global-agent/1.0 " 19 + 20 + assert.equal(openAIUserAgent(), "global-agent/1.0") 21 + assert.equal(openAIUserAgent(" provider-agent/2.0 "), "provider-agent/2.0") 22 + assert.equal(openAIUserAgent(" "), "global-agent/1.0") 23 + }) 24 + 25 + test("openAIHeaders omits empty values and returns undefined when empty", () => { 26 + assert.equal(openAIHeaders([["User-Agent", " "]]), undefined) 27 + assert.deepEqual(openAIHeaders([["User-Agent", " niri/1.0 "]]), { "User-Agent": "niri/1.0" }) 28 + })
+17
src/openai-headers.ts
··· 1 + export function envHeaderValue(value: string | undefined): string | undefined { 2 + const trimmed = value?.trim() 3 + return trimmed ? trimmed : undefined 4 + } 5 + 6 + export function openAIUserAgent(providerValue?: string): string | undefined { 7 + return envHeaderValue(providerValue) ?? envHeaderValue(process.env.OPENAI_USER_AGENT) 8 + } 9 + 10 + export function openAIHeaders(entries: readonly (readonly [string, string | undefined])[]): Record<string, string> | undefined { 11 + const headers: Record<string, string> = {} 12 + for (const [name, value] of entries) { 13 + const headerValue = envHeaderValue(value) 14 + if (headerValue) headers[name] = headerValue 15 + } 16 + return Object.keys(headers).length ? headers : undefined 17 + }
+19
src/runner/index.ts
··· 9 9 10 10 let eventResolvers: Array<(event: UserMessage) => void> = [] 11 11 let shutdownResolve: (() => void) | null = null 12 + const PROCESS_STARTED_AT = new Date().toISOString() 12 13 13 14 const state: RunnerStateInternal = { 14 15 contextSize: 0, ··· 35 36 /** Returns whether the runner is currently blocked in wait or wait_then_continue. */ 36 37 export function isWaitingForEvent(): boolean { 37 38 return eventResolvers.length > 0 39 + } 40 + 41 + export function getRunnerStatus(): { 42 + running: boolean 43 + idle: boolean 44 + tokenCount: number 45 + contextSize: number 46 + processStartedAt: string 47 + uptimeMs: number 48 + } { 49 + return { 50 + running: state.running, 51 + idle: isWaitingForEvent(), 52 + tokenCount: state.tokenCount, 53 + contextSize: state.contextSize, 54 + processStartedAt: PROCESS_STARTED_AT, 55 + uptimeMs: Math.max(0, Date.now() - new Date(PROCESS_STARTED_AT).getTime()), 56 + } 38 57 } 39 58 40 59 type EnqueueOptions = {
+3
src/runner/presence.ts
··· 1 + import { publishWorkerEvent } from "../awp/outbox" 2 + 1 3 export type RunnerPresence = "awake" | "resting" 2 4 3 5 type Listener = (presence: RunnerPresence) => void ··· 8 10 export function setRunnerPresence(presence: RunnerPresence): void { 9 11 if (presence === currentPresence) return 10 12 currentPresence = presence 13 + publishWorkerEvent("runner.status", { presence }) 11 14 for (const listener of listeners) listener(presence) 12 15 } 13 16
+15 -8
src/runner/util.ts
··· 4 4 import OpenAI from "openai" 5 5 import { HOME_DIR } from "../container/config" 6 6 import { imageRootForModelInput } from "../container/index" 7 + import { openAIHeaders, openAIUserAgent } from "../openai-headers" 7 8 import type { Message } from "../types" 8 9 import type { ImageDetail } from "./types" 9 10 import type { ToolArgs } from "./loop-shared" ··· 83 84 (SUMMARY_BASE === process.env.LMSTUDIO_BASE_URL ? process.env.LMSTUDIO_API_KEY : undefined) ?? 84 85 process.env.OPENAI_API_KEY ?? 85 86 (SUMMARY_BASE && isLikelyLocalBase(SUMMARY_BASE) ? "lm-studio" : "") 86 - const fallbackHeaders: Record<string, string> = {} 87 - if (process.env.FALLBACK_OPENAI_REFERER) fallbackHeaders["HTTP-Referer"] = process.env.FALLBACK_OPENAI_REFERER 88 - if (process.env.FALLBACK_OPENAI_TITLE) fallbackHeaders["X-Title"] = process.env.FALLBACK_OPENAI_TITLE 89 - const summaryHeaders: Record<string, string> = {} 90 - if (process.env.SUMMARY_OPENAI_REFERER) summaryHeaders["HTTP-Referer"] = process.env.SUMMARY_OPENAI_REFERER 91 - if (process.env.SUMMARY_OPENAI_TITLE) summaryHeaders["X-Title"] = process.env.SUMMARY_OPENAI_TITLE 87 + const primaryHeaders = openAIHeaders([["User-Agent", openAIUserAgent()]]) 88 + const fallbackHeaders = openAIHeaders([ 89 + ["HTTP-Referer", process.env.FALLBACK_OPENAI_REFERER], 90 + ["X-Title", process.env.FALLBACK_OPENAI_TITLE], 91 + ["User-Agent", openAIUserAgent(process.env.FALLBACK_OPENAI_USER_AGENT)], 92 + ]) 93 + const summaryHeaders = openAIHeaders([ 94 + ["HTTP-Referer", process.env.SUMMARY_OPENAI_REFERER], 95 + ["X-Title", process.env.SUMMARY_OPENAI_TITLE], 96 + ["User-Agent", openAIUserAgent(process.env.SUMMARY_OPENAI_USER_AGENT)], 97 + ]) 92 98 93 99 if (!USE_FALLBACK && !MODEL) { 94 100 throw new Error("MODEL is required unless fallback is forced (NIRI_ENV=local).") ··· 115 121 : new OpenAI({ 116 122 baseURL: API_BASE, 117 123 apiKey: process.env.OPENAI_API_KEY!, 124 + defaultHeaders: primaryHeaders, 118 125 }) 119 126 120 127 export const fallbackClient = new OpenAI({ 121 128 baseURL: FALLBACK_BASE, 122 129 apiKey: fallbackApiKey || "lm-studio", // Keep LM Studio default when running against localhost. 123 - defaultHeaders: Object.keys(fallbackHeaders).length ? fallbackHeaders : undefined, 130 + defaultHeaders: fallbackHeaders, 124 131 }) 125 132 126 133 export const summaryClient = ··· 128 135 ? new OpenAI({ 129 136 baseURL: SUMMARY_BASE, 130 137 apiKey: summaryApiKey, 131 - defaultHeaders: Object.keys(summaryHeaders).length ? summaryHeaders : undefined, 138 + defaultHeaders: summaryHeaders, 132 139 }) 133 140 : null 134 141
+152 -3
src/server.ts
··· 3 3 import { existsSync } from "node:fs" 4 4 import { dirname, join } from "node:path" 5 5 import { fileURLToPath } from "node:url" 6 - import { wake, isRunning, isWaitingForEvent, enqueueEvent } from "./runner/index" 6 + import { AGENT_ID } from "./agent-config" 7 + import { listWorkerEvents, publishWorkerEvent, subscribeWorkerEvents } from "./awp/outbox" 8 + import type { ControlCommand } from "./awp/types" 9 + import { wake, isRunning, isWaitingForEvent, enqueueEvent, shutdown, getRunnerStatus } from "./runner/index" 7 10 import { buildDiscordBatchDigest, scanDiscordChannels } from "./discord/state" 8 11 import { handleDiscordIngress } from "./discord/pipeline" 9 12 import { fromBsky } from "./triggers/bsky" ··· 38 41 "prompt-response": "response", 39 42 completion: "response", 40 43 } 44 + const TRIGGER_SOURCES = new Set(["discord", "bsky", "webhook", "cron", "chat"]) 41 45 42 46 function parseMetricTypes(raw: string | undefined): MetricListType[] | undefined { 43 47 if (!raw?.trim()) return undefined ··· 113 117 discordBatchTimer = null 114 118 }) 115 119 120 + publishWorkerEvent("worker.hello", { 121 + agentId: AGENT_ID, 122 + pid: process.pid, 123 + startedAt: new Date().toISOString(), 124 + }) 125 + 126 + function isUserMessage(value: unknown): value is UserMessage { 127 + if (!value || typeof value !== "object") return false 128 + const event = value as Partial<UserMessage> 129 + return ( 130 + typeof event.content === "string" && 131 + typeof event.triggeredAt === "string" && 132 + typeof event.source === "string" && 133 + TRIGGER_SOURCES.has(event.source) 134 + ) 135 + } 136 + 137 + function dispatchEvent(event: UserMessage, options: { onlyIfWaiting?: boolean; priority?: boolean } = {}): boolean { 138 + if (isRunning()) return enqueueEvent(event, options) 139 + if (options.onlyIfWaiting) return false 140 + void wake(event) 141 + return true 142 + } 143 + 144 + app.get("/awp/status", async () => ({ 145 + agentId: AGENT_ID, 146 + ...getRunnerStatus(), 147 + })) 148 + 149 + app.post("/awp/events", async (req, reply) => { 150 + const body = 151 + req.body && typeof req.body === "object" 152 + ? (req.body as Partial<ControlCommand> | UserMessage) 153 + : ({} as Partial<ControlCommand>) 154 + const isEnqueueCommand = "type" in body && body.type === "event.enqueue" 155 + const event = isEnqueueCommand ? body.event : body 156 + const options = isEnqueueCommand ? body.options : undefined 157 + 158 + if (!isUserMessage(event)) { 159 + return reply.code(400).send({ error: "expected a UserMessage or event.enqueue command" }) 160 + } 161 + 162 + const accepted = dispatchEvent(event, options) 163 + return reply.send({ 164 + ok: accepted, 165 + agentId: AGENT_ID, 166 + running: isRunning(), 167 + }) 168 + }) 169 + 170 + app.get("/awp/events", async (req) => { 171 + const query = req.query as { after_seq?: string; limit?: string; tail?: string } 172 + const mode = query.tail === "1" || query.tail === "true" ? "tail" : "after" 173 + return { 174 + agentId: AGENT_ID, 175 + events: listWorkerEvents( 176 + Number.parseInt(query.after_seq ?? "0", 10) || 0, 177 + Number.parseInt(query.limit ?? "500", 10) || 500, 178 + mode, 179 + ), 180 + } 181 + }) 182 + 183 + app.post("/awp/shutdown", async (_req, reply) => { 184 + await shutdown() 185 + return reply.send({ ok: true, agentId: AGENT_ID }) 186 + }) 187 + 188 + app.get("/awp/stream", (req, reply) => { 189 + const query = req.query as { after_seq?: string; limit?: string } 190 + const afterSeq = Math.max(0, Number.parseInt(query.after_seq ?? "0", 10) || 0) 191 + const limit = Math.max(1, Math.min(1000, Number.parseInt(query.limit ?? "500", 10) || 500)) 192 + 193 + reply.hijack() 194 + 195 + const res = reply.raw 196 + res.writeHead(200, { 197 + "content-type": "text/event-stream", 198 + "cache-control": "no-cache", 199 + connection: "keep-alive", 200 + "x-accel-buffering": "no", 201 + }) 202 + 203 + res.write(":ok\n\n") 204 + 205 + const liveBuffer: ReturnType<typeof listWorkerEvents> = [] 206 + let replaying = true 207 + let lastSeq = afterSeq 208 + const seenIds = new Set<string>() 209 + 210 + const send = (event: ReturnType<typeof listWorkerEvents>[number]) => { 211 + if (seenIds.has(event.id)) return 212 + seenIds.add(event.id) 213 + if (event.seq > 0) lastSeq = Math.max(lastSeq, event.seq) 214 + try { 215 + res.write(`id: ${event.seq}\n`) 216 + res.write(`event: ${event.type}\n`) 217 + res.write(`data: ${JSON.stringify(event)}\n\n`) 218 + } catch { 219 + // connection closed 220 + } 221 + } 222 + 223 + const unsubscribe = subscribeWorkerEvents((event) => { 224 + if (replaying) { 225 + liveBuffer.push(event) 226 + return 227 + } 228 + send(event) 229 + }) 230 + 231 + try { 232 + for (const event of listWorkerEvents(afterSeq, limit)) send(event) 233 + } catch (err) { 234 + send( 235 + publishWorkerEvent("worker.heartbeat", { 236 + error: `failed to replay worker events: ${err instanceof Error ? err.message : String(err)}`, 237 + }), 238 + ) 239 + } 240 + 241 + replaying = false 242 + for (const event of liveBuffer.sort((a, b) => a.seq - b.seq)) { 243 + if (event.seq === 0 || event.seq > lastSeq || !seenIds.has(event.id)) send(event) 244 + } 245 + 246 + const keepalive = setInterval(() => { 247 + try { 248 + publishWorkerEvent("worker.heartbeat", { 249 + agentId: AGENT_ID, 250 + running: isRunning(), 251 + idle: isWaitingForEvent(), 252 + }) 253 + res.write(":ping\n\n") 254 + } catch { 255 + clearInterval(keepalive) 256 + unsubscribe() 257 + } 258 + }, 15_000) 259 + 260 + req.raw.on("close", () => { 261 + clearInterval(keepalive) 262 + unsubscribe() 263 + }) 264 + }) 265 + 116 266 if (existsSync(WEB_DIST_DIR)) { 117 267 app.register(fastifyStatic, { 118 268 root: WEB_DIST_DIR, ··· 201 351 }) 202 352 203 353 app.get("/status", async () => ({ 204 - running: isRunning(), 205 - idle: isWaitingForEvent(), 354 + ...getRunnerStatus(), 206 355 })) 207 356 208 357 app.get("/metrics", async (req) => {
+2
src/stream.ts
··· 1 1 import type { StreamEvent } from "@niri/chat-client" 2 + import { publishWorkerEvent } from "./awp/outbox" 2 3 3 4 export type { StreamEvent } 4 5 ··· 28 29 } 29 30 buffer.push(event) 30 31 if (buffer.length > BUFFER_SIZE) buffer.shift() 32 + publishWorkerEvent("stream.event", event) 31 33 for (const fn of listeners) fn(event) 32 34 } 33 35