my harness for niri
1
fork

Configure Feed

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

i love webui slop

+2079 -201
+23 -1
apps/web/src/App.tsx
··· 1 1 import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react" 2 2 import { createChatClient, type StreamEvent } from "@niri/chat-client" 3 3 import { MarkdownBlock } from "./MarkdownBlock" 4 + import { MetricsWorkbench } from "./MetricsWorkbench" 4 5 5 6 type Entry = 6 7 | { id: number; kind: "info"; text: string } ··· 59 60 export function App() { 60 61 const clientId = useMemo(() => createClientId(), []) 61 62 const client = useMemo(() => createChatClient({ baseUrl, clientId }), [clientId]) 63 + const [view, setView] = useState<"metrics" | "chat">(() => (window.location.hash === "#chat" ? "chat" : "metrics")) 62 64 63 65 const [entries, setEntries] = useState<Entry[]>([]) 64 66 const [running, setRunning] = useState<boolean | null>(null) ··· 107 109 setCollapsedToolIds(new Set<number>()) 108 110 }, []) 109 111 112 + const switchView = useCallback((next: "metrics" | "chat") => { 113 + setView(next) 114 + window.history.replaceState(null, "", next === "chat" ? "#chat" : "#metrics") 115 + }, []) 116 + 110 117 useEffect(() => { 111 118 const controller = new AbortController() 112 119 ··· 175 182 ) 176 183 177 184 return ( 178 - <main className="app"> 185 + <div className="shell"> 186 + <nav className="top-nav" aria-label="primary"> 187 + <button type="button" className={view === "metrics" ? "is-active" : ""} onClick={() => switchView("metrics")}> 188 + metrics 189 + </button> 190 + <button type="button" className={view === "chat" ? "is-active" : ""} onClick={() => switchView("chat")}> 191 + chat 192 + </button> 193 + </nav> 194 + 195 + {view === "metrics" ? ( 196 + <MetricsWorkbench /> 197 + ) : ( 198 + <main className="app"> 179 199 <header className="header"> 180 200 <h1>niri chat</h1> 181 201 <p className="status"> ··· 275 295 </button> 276 296 </form> 277 297 </main> 298 + )} 299 + </div> 278 300 ) 279 301 }
+860
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 PromptResponseMetric = BaseMetric & { 30 + type: "prompt_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 | PromptResponseMetric | UsageMetric | SummarizationMetric 73 + 74 + type MetricsPage = { 75 + memories: MemoryMetric[] 76 + summarization: SummarizationMetric[] 77 + prompt_response: PromptResponseMetric[] 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 DetailState = 122 + | { kind: "idle" } 123 + | { kind: "loading"; label: string } 124 + | { kind: "error"; text: string } 125 + | { kind: "memory"; memory: MemoryDetail; prompt?: PromptDetail } 126 + | { kind: "metric"; metric: unknown } 127 + 128 + type ToolPanelState = 129 + | { kind: "idle" } 130 + | { kind: "loading"; label: string } 131 + | { kind: "error"; text: string } 132 + | { kind: "ready"; label: string; traces: ToolTrace[] } 133 + 134 + type MemoryPair = { 135 + memory: MemoryMetric 136 + prompt?: PromptMetric | PromptResponseMetric 137 + secondsApart?: number 138 + overlap: number 139 + shared: string[] 140 + issue: "ok" | "loose" | "missing" 141 + } 142 + 143 + type ToolTrace = { 144 + id: string 145 + name: string 146 + args: string 147 + result?: string 148 + } 149 + 150 + const baseUrl = import.meta.env.VITE_NIRI_BASE_URL ?? "" 151 + const METRICS_POLL_INTERVAL_MS = 5_000 152 + 153 + const stopWords = new Set([ 154 + "a", 155 + "an", 156 + "and", 157 + "are", 158 + "as", 159 + "at", 160 + "be", 161 + "but", 162 + "by", 163 + "for", 164 + "from", 165 + "have", 166 + "i", 167 + "in", 168 + "is", 169 + "it", 170 + "me", 171 + "my", 172 + "of", 173 + "on", 174 + "or", 175 + "that", 176 + "the", 177 + "this", 178 + "to", 179 + "was", 180 + "with", 181 + "you", 182 + "your", 183 + ]) 184 + 185 + const formatNumber = (value: number | undefined): string => 186 + typeof value === "number" && Number.isFinite(value) ? value.toLocaleString() : "0" 187 + 188 + const timeLabel = (iso: string): string => { 189 + const date = new Date(iso) 190 + if (Number.isNaN(date.getTime())) return iso 191 + return new Intl.DateTimeFormat(undefined, { 192 + month: "short", 193 + day: "2-digit", 194 + hour: "2-digit", 195 + minute: "2-digit", 196 + }).format(date) 197 + } 198 + 199 + const shortTime = (iso: string): string => { 200 + const date = new Date(iso) 201 + if (Number.isNaN(date.getTime())) return iso 202 + return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(date) 203 + } 204 + 205 + const textContent = (content: unknown): string => { 206 + if (typeof content === "string") return content 207 + if (!Array.isArray(content)) return "" 208 + return content 209 + .flatMap((part) => { 210 + if (!part || typeof part !== "object") return [] 211 + const record = part as Record<string, unknown> 212 + return typeof record.text === "string" ? [record.text] : [] 213 + }) 214 + .join("\n") 215 + } 216 + 217 + const toolResultMarkdown = (result: string): string => { 218 + const trimmed = result.trim() 219 + if (!trimmed) return "```text\n(no output)\n```" 220 + if (/^(```|#{1,6}\s|- |\* |\d+\. |> |\|)/m.test(trimmed)) return trimmed 221 + return `\`\`\`text\n${trimmed}\n\`\`\`` 222 + } 223 + 224 + const extractToolTraces = (prompt: PromptDetail | undefined): ToolTrace[] => { 225 + const messages = [...(prompt?.messages ?? []), ...(prompt?.response ? [prompt.response] : [])] 226 + const traces: ToolTrace[] = [] 227 + const byId = new Map<string, ToolTrace>() 228 + 229 + for (const message of messages) { 230 + if (Array.isArray(message.tool_calls)) { 231 + for (const rawCall of message.tool_calls) { 232 + if (!rawCall || typeof rawCall !== "object") continue 233 + const call = rawCall as Record<string, unknown> 234 + const fn = call.function && typeof call.function === "object" ? (call.function as Record<string, unknown>) : {} 235 + const id = typeof call.id === "string" ? call.id : `tool-${traces.length + 1}` 236 + const trace: ToolTrace = { 237 + id, 238 + name: typeof fn.name === "string" ? fn.name : "tool", 239 + args: typeof fn.arguments === "string" ? fn.arguments : "", 240 + } 241 + traces.push(trace) 242 + byId.set(id, trace) 243 + } 244 + } 245 + 246 + if (message.role === "tool") { 247 + const id = typeof message.tool_call_id === "string" ? message.tool_call_id : "" 248 + const result = textContent(message.content) 249 + const existing = byId.get(id) 250 + if (existing) { 251 + existing.result = result 252 + } else { 253 + traces.push({ 254 + id: id || `tool-result-${traces.length + 1}`, 255 + name: "tool result", 256 + args: "", 257 + result, 258 + }) 259 + } 260 + } 261 + } 262 + 263 + return traces 264 + } 265 + 266 + const lastUserMessage = (messages: Message[] | undefined): string => { 267 + if (!messages) return "" 268 + for (let i = messages.length - 1; i >= 0; i--) { 269 + const message = messages[i] 270 + if (message?.role === "user") { 271 + const text = textContent(message.content).trim() 272 + if (text) return text 273 + } 274 + } 275 + return "" 276 + } 277 + 278 + const tokens = (value: string | undefined): string[] => { 279 + if (!value) return [] 280 + return value 281 + .toLowerCase() 282 + .replace(/https?:\/\/\S+/g, " ") 283 + .replace(/[^a-z0-9\s'-]+/g, " ") 284 + .split(/\s+/) 285 + .map((token) => token.replace(/^['-]+|['-]+$/g, "")) 286 + .filter((token) => token.length > 2 && !stopWords.has(token)) 287 + } 288 + 289 + const overlapFor = (left: string | undefined, right: string | undefined): { score: number; shared: string[] } => { 290 + const leftTokens = new Set(tokens(left)) 291 + const rightTokens = new Set(tokens(right)) 292 + if (leftTokens.size === 0 || rightTokens.size === 0) return { score: 0, shared: [] } 293 + 294 + const shared = [...leftTokens].filter((token) => rightTokens.has(token)) 295 + return { 296 + score: shared.length / Math.max(1, Math.min(leftTokens.size, rightTokens.size)), 297 + shared: shared.slice(0, 8), 298 + } 299 + } 300 + 301 + const fetchJson = async <T,>(path: string, signal?: AbortSignal): Promise<T> => { 302 + const res = await fetch(`${baseUrl}${path}`, { signal }) 303 + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim()) 304 + return (await res.json()) as T 305 + } 306 + 307 + const normalizeMetricsPage = (page: MetricsPageInput): MetricsPage => ({ 308 + memories: Array.isArray(page.memories) ? page.memories : [], 309 + summarization: Array.isArray(page.summarization) ? page.summarization : [], 310 + prompt_response: Array.isArray(page.prompt_response) ? page.prompt_response : [], 311 + prompt: Array.isArray(page.prompt) ? page.prompt : [], 312 + usage: Array.isArray(page.usage) ? page.usage : [], 313 + discord: Array.isArray(page.discord) ? page.discord : [], 314 + limit: typeof page.limit === "number" ? page.limit : 100, 315 + nextCursor: page.nextCursor ?? {}, 316 + hasMore: page.hasMore ?? {}, 317 + }) 318 + 319 + function buildMetricsUrl(search: string): string { 320 + const params = new URLSearchParams() 321 + params.set("limit", "100") 322 + if (search.trim()) params.set("q", search.trim()) 323 + return `/metrics?${params.toString()}` 324 + } 325 + 326 + function closestPromptPath(timestamp: string, prompts: PromptMetric[]): string | undefined { 327 + const usageTime = new Date(timestamp).getTime() 328 + if (!Number.isFinite(usageTime)) return undefined 329 + 330 + let best: PromptMetric | undefined 331 + let bestDelta = Number.POSITIVE_INFINITY 332 + for (const prompt of prompts) { 333 + const promptTime = new Date(prompt.timestamp).getTime() 334 + if (!Number.isFinite(promptTime) || promptTime > usageTime) continue 335 + const delta = usageTime - promptTime 336 + if (delta < bestDelta) { 337 + best = prompt 338 + bestDelta = delta 339 + } 340 + } 341 + return best?.detailPath 342 + } 343 + 344 + function TokenTrace({ 345 + usage, 346 + promptResponses, 347 + prompts, 348 + onOpenTurn, 349 + }: { 350 + usage: UsageMetric[] 351 + promptResponses: PromptResponseMetric[] 352 + prompts: PromptMetric[] 353 + onOpenTurn: (path: string) => void 354 + }) { 355 + const points = useMemo(() => { 356 + const usagePoints = usage.map((item) => ({ 357 + id: `u-${item.id}`, 358 + timestamp: item.timestamp, 359 + usage: item.usage, 360 + model: undefined as string | undefined, 361 + detailPath: closestPromptPath(item.timestamp, prompts) ?? item.detailPath, 362 + })) 363 + const responsePoints = promptResponses 364 + .filter((item) => item.usage) 365 + .map((item) => ({ 366 + id: `pr-${item.id}`, 367 + timestamp: item.timestamp, 368 + usage: item.usage, 369 + model: item.model, 370 + detailPath: item.detailPath, 371 + })) 372 + return [...usagePoints, ...responsePoints] 373 + .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) 374 + .slice(-80) 375 + }, [promptResponses, prompts, usage]) 376 + 377 + const maxTotal = Math.max(1, ...points.map((point) => point.usage?.total_tokens ?? 0)) 378 + const latest = points[points.length - 1] 379 + const totals = points.reduce( 380 + (sum, point) => ({ 381 + prompt: sum.prompt + (point.usage?.prompt_tokens ?? 0), 382 + completion: sum.completion + (point.usage?.completion_tokens ?? 0), 383 + total: sum.total + (point.usage?.total_tokens ?? 0), 384 + }), 385 + { prompt: 0, completion: 0, total: 0 }, 386 + ) 387 + const average = points.length ? Math.round(totals.total / points.length) : 0 388 + 389 + return ( 390 + <section className="metric-panel metric-token-panel" aria-label="token usage"> 391 + <div className="panel-head"> 392 + <div> 393 + <h2>Token Usage</h2> 394 + <p>{points.length ? `${points.length} recent completions` : "No usage rows yet"}</p> 395 + </div> 396 + <div className="token-readout"> 397 + <span>{formatNumber(latest?.usage?.total_tokens)}</span> 398 + <small>latest total</small> 399 + </div> 400 + </div> 401 + 402 + <div className="token-strip" aria-label="recent token totals"> 403 + {points.map((point) => { 404 + const prompt = point.usage?.prompt_tokens ?? 0 405 + const completion = point.usage?.completion_tokens ?? 0 406 + const total = point.usage?.total_tokens ?? prompt + completion 407 + return ( 408 + <button 409 + key={point.id} 410 + className="token-bar" 411 + type="button" 412 + onClick={() => onOpenTurn(point.detailPath)} 413 + title={`${timeLabel(point.timestamp)} total ${formatNumber(total)} prompt ${formatNumber(prompt)} completion ${formatNumber(completion)}`} 414 + style={{ height: `${Math.max(8, Math.round((total / maxTotal) * 100))}%` }} 415 + > 416 + <span className="token-bar-prompt" style={{ height: `${total ? (prompt / total) * 100 : 0}%` }} /> 417 + <span className="token-bar-completion" style={{ height: `${total ? (completion / total) * 100 : 0}%` }} /> 418 + </button> 419 + ) 420 + })} 421 + </div> 422 + 423 + <div className="token-ledger"> 424 + <div> 425 + <span>{formatNumber(totals.prompt)}</span> 426 + <small>prompt</small> 427 + </div> 428 + <div> 429 + <span>{formatNumber(totals.completion)}</span> 430 + <small>completion</small> 431 + </div> 432 + <div> 433 + <span>{formatNumber(average)}</span> 434 + <small>avg total</small> 435 + </div> 436 + </div> 437 + </section> 438 + ) 439 + } 440 + 441 + function MemoryReview({ 442 + pairs, 443 + selectedId, 444 + reviewOnly, 445 + onReviewOnlyChange, 446 + onSelect, 447 + }: { 448 + pairs: MemoryPair[] 449 + selectedId?: number 450 + reviewOnly: boolean 451 + onReviewOnlyChange: (value: boolean) => void 452 + onSelect: (pair: MemoryPair) => void 453 + }) { 454 + const visible = reviewOnly ? pairs.filter((pair) => pair.issue !== "ok") : pairs 455 + 456 + return ( 457 + <section className="metric-panel memory-panel" aria-label="memory prompt alignment"> 458 + <div className="panel-head"> 459 + <div> 460 + <h2>Memory Fit</h2> 461 + <p>{visible.length} recalls matched against nearby prompts</p> 462 + </div> 463 + <label className="switch-row"> 464 + <input type="checkbox" checked={reviewOnly} onChange={(event) => onReviewOnlyChange(event.target.checked)} /> 465 + review only 466 + </label> 467 + </div> 468 + 469 + <div className="memory-table" role="table"> 470 + <div className="memory-row memory-row-head" role="row"> 471 + <span>time</span> 472 + <span>fit</span> 473 + <span>memory query</span> 474 + <span>near prompt</span> 475 + </div> 476 + {visible.map((pair) => ( 477 + <button 478 + key={pair.memory.id} 479 + type="button" 480 + className={`memory-row ${selectedId === pair.memory.id ? "is-selected" : ""} issue-${pair.issue}`} 481 + onClick={() => onSelect(pair)} 482 + role="row" 483 + > 484 + <span>{shortTime(pair.memory.timestamp)}</span> 485 + <span> 486 + {pair.issue === "missing" ? "no prompt" : `${Math.round(pair.overlap * 100)}%`} 487 + {pair.secondsApart != null ? <small>{Math.abs(pair.secondsApart)}s</small> : null} 488 + </span> 489 + <span>{pair.memory.queryPreview ?? "empty memory query"}</span> 490 + <span>{pair.prompt?.lastUserMessage ?? "no matching prompt"}</span> 491 + </button> 492 + ))} 493 + </div> 494 + </section> 495 + ) 496 + } 497 + 498 + function ToolTracePanel({ state }: { state: ToolPanelState }) { 499 + return ( 500 + <section className="metric-panel tool-panel" aria-label="tool calls"> 501 + <div className="panel-head"> 502 + <div> 503 + <h2>Tool Calls</h2> 504 + <p> 505 + {state.kind === "ready" 506 + ? `${state.traces.length} calls from ${state.label}` 507 + : state.kind === "loading" 508 + ? state.label 509 + : "Recent prompt tool activity"} 510 + </p> 511 + </div> 512 + </div> 513 + 514 + <div className="tool-panel-body"> 515 + {state.kind === "idle" ? <p className="empty-note">No prompt loaded yet.</p> : null} 516 + {state.kind === "loading" ? <p className="empty-note">Loading tools.</p> : null} 517 + {state.kind === "error" ? <p className="empty-note">tools unavailable: {state.text}</p> : null} 518 + {state.kind === "ready" && state.traces.length === 0 ? ( 519 + <p className="empty-note">No tool calls in this prompt context.</p> 520 + ) : null} 521 + {state.kind === "ready" && state.traces.length > 0 ? ( 522 + <div className="tool-trace-list"> 523 + {state.traces.map((tool) => ( 524 + <article key={tool.id} className="tool-trace"> 525 + <details> 526 + <summary> 527 + <span>{tool.name}</span> 528 + <small>{tool.id}</small> 529 + </summary> 530 + {tool.args ? <pre className="tool-args">{tool.args}</pre> : null} 531 + <div className="tool-result"> 532 + <MarkdownBlock content={toolResultMarkdown(tool.result ?? "")} /> 533 + </div> 534 + </details> 535 + </article> 536 + ))} 537 + </div> 538 + ) : null} 539 + </div> 540 + </section> 541 + ) 542 + } 543 + 544 + function DetailPane({ detail }: { detail: DetailState }) { 545 + if (detail.kind === "idle") { 546 + return ( 547 + <aside className="detail-pane"> 548 + <h2>Review Detail</h2> 549 + <p>Select a memory row to inspect the retrieved chunks beside the prompt that caused the recall.</p> 550 + </aside> 551 + ) 552 + } 553 + 554 + if (detail.kind === "loading") { 555 + return ( 556 + <aside className="detail-pane"> 557 + <h2>{detail.label}</h2> 558 + <p>Loading detail.</p> 559 + </aside> 560 + ) 561 + } 562 + 563 + if (detail.kind === "error") { 564 + return ( 565 + <aside className="detail-pane detail-error"> 566 + <h2>Detail Error</h2> 567 + <p>{detail.text}</p> 568 + </aside> 569 + ) 570 + } 571 + 572 + if (detail.kind === "metric") { 573 + return ( 574 + <aside className="detail-pane"> 575 + <h2>Metric Detail</h2> 576 + <pre>{JSON.stringify(detail.metric, null, 2)}</pre> 577 + </aside> 578 + ) 579 + } 580 + 581 + const promptText = lastUserMessage(detail.prompt?.messages) 582 + 583 + return ( 584 + <aside className="detail-pane"> 585 + <h2>Recall #{detail.memory.id}</h2> 586 + <dl className="detail-meta"> 587 + <div> 588 + <dt>time</dt> 589 + <dd>{timeLabel(detail.memory.timestamp)}</dd> 590 + </div> 591 + <div> 592 + <dt>chunks</dt> 593 + <dd>{detail.memory.results.length}</dd> 594 + </div> 595 + </dl> 596 + 597 + <section className="detail-section"> 598 + <h3>Prompt</h3> 599 + <MarkdownBlock content={promptText || detail.memory.query} /> 600 + </section> 601 + 602 + <section className="detail-section"> 603 + <h3>Memory Query</h3> 604 + <MarkdownBlock content={detail.memory.query} /> 605 + </section> 606 + 607 + <section className="detail-section"> 608 + <h3>Retrieved Chunks</h3> 609 + {detail.memory.results.map((result) => ( 610 + <article key={result.chunkId} className="memory-hit"> 611 + <div> 612 + <strong>{result.title}</strong> 613 + <span>{result.kind} / {result.source}</span> 614 + </div> 615 + <p>{result.preview}</p> 616 + </article> 617 + ))} 618 + </section> 619 + </aside> 620 + ) 621 + } 622 + 623 + function BucketRail({ 624 + metrics, 625 + onOpenMetric, 626 + }: { 627 + metrics: MetricsPage 628 + onOpenMetric: (path: string) => void 629 + }) { 630 + const rows: Array<{ label: string; count: number; items: Array<MetricItem | DiscordMetric> }> = [ 631 + { label: "prompt_response", count: metrics.prompt_response.length, items: metrics.prompt_response.slice(0, 6) }, 632 + { label: "prompt", count: metrics.prompt.length, items: metrics.prompt.slice(0, 6) }, 633 + { label: "summarization", count: metrics.summarization.length, items: metrics.summarization.slice(0, 6) }, 634 + { label: "discord", count: metrics.discord.length, items: metrics.discord.slice(0, 6) }, 635 + ] 636 + 637 + return ( 638 + <section className="bucket-rail" aria-label="raw metric buckets"> 639 + {rows.map((bucket) => ( 640 + <section key={bucket.label}> 641 + <header> 642 + <h3>{bucket.label}</h3> 643 + <span>{bucket.count}</span> 644 + </header> 645 + <div className="bucket-list"> 646 + {bucket.items.map((item) => ( 647 + <button key={`${item.type}-${item.id}`} type="button" onClick={() => onOpenMetric(item.detailPath)}> 648 + <span>{shortTime(item.timestamp)}</span> 649 + <strong> 650 + {item.type === "prompt_response" 651 + ? item.responsePreview || `${item.model ?? "model"} response` 652 + : item.type === "prompt" 653 + ? item.lastUserMessage || "prompt" 654 + : item.type === "summarization" 655 + ? item.summaryPreview || item.method || "summary" 656 + : item.type === "discord" 657 + ? item.contentPreview || item.authorUsername || "discord" 658 + : item.type} 659 + </strong> 660 + </button> 661 + ))} 662 + </div> 663 + </section> 664 + ))} 665 + </section> 666 + ) 667 + } 668 + 669 + export function MetricsWorkbench() { 670 + const [metrics, setMetrics] = useState<MetricsPage | null>(null) 671 + const [error, setError] = useState<string | null>(null) 672 + const [loading, setLoading] = useState(true) 673 + const [search, setSearch] = useState("") 674 + const [query, setQuery] = useState("") 675 + const [reviewOnly, setReviewOnly] = useState(false) 676 + const [detail, setDetail] = useState<DetailState>({ kind: "idle" }) 677 + const [toolPanel, setToolPanel] = useState<ToolPanelState>({ kind: "idle" }) 678 + const [toolSourcePath, setToolSourcePath] = useState<string | null>(null) 679 + const [live, setLive] = useState(true) 680 + const [lastUpdated, setLastUpdated] = useState<string | null>(null) 681 + 682 + const loadMetrics = useCallback((signal?: AbortSignal, options?: { silent?: boolean }) => { 683 + if (!options?.silent) setLoading(true) 684 + setError(null) 685 + fetchJson<MetricsPageInput>(buildMetricsUrl(query), signal) 686 + .then((page) => { 687 + setMetrics(normalizeMetricsPage(page)) 688 + setLastUpdated(new Date().toISOString()) 689 + }) 690 + .catch((err) => { 691 + if (signal?.aborted) return 692 + setError(err instanceof Error ? err.message : String(err)) 693 + }) 694 + .finally(() => { 695 + if (!signal?.aborted) setLoading(false) 696 + }) 697 + }, [query]) 698 + 699 + useEffect(() => { 700 + const controller = new AbortController() 701 + loadMetrics(controller.signal) 702 + let interval: ReturnType<typeof setInterval> | undefined 703 + let pollController: AbortController | null = null 704 + 705 + if (live) { 706 + interval = setInterval(() => { 707 + pollController?.abort() 708 + pollController = new AbortController() 709 + loadMetrics(pollController.signal, { silent: true }) 710 + }, METRICS_POLL_INTERVAL_MS) 711 + } 712 + 713 + return () => { 714 + controller.abort() 715 + pollController?.abort() 716 + if (interval) clearInterval(interval) 717 + } 718 + }, [live, loadMetrics]) 719 + 720 + const pairs = useMemo<MemoryPair[]>(() => { 721 + if (!metrics) return [] 722 + const prompts = [...metrics.prompt_response, ...metrics.prompt] 723 + .filter((prompt) => prompt.lastUserMessage) 724 + .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) 725 + 726 + return metrics.memories.map((memory) => { 727 + const memoryTime = new Date(memory.timestamp).getTime() 728 + const prompt = prompts.find((item) => new Date(item.timestamp).getTime() >= memoryTime) ?? prompts[prompts.length - 1] 729 + const promptTime = prompt ? new Date(prompt.timestamp).getTime() : Number.NaN 730 + const secondsApart = prompt && Number.isFinite(promptTime) ? Math.round((promptTime - memoryTime) / 1000) : undefined 731 + const overlap = overlapFor(memory.queryPreview, prompt?.lastUserMessage) 732 + const issue = !prompt ? "missing" : overlap.score < 0.16 ? "loose" : "ok" 733 + return { memory, prompt, secondsApart, overlap: overlap.score, shared: overlap.shared, issue } 734 + }) 735 + }, [metrics]) 736 + 737 + const latestToolMetric = useMemo<PromptMetric | PromptResponseMetric | undefined>(() => { 738 + if (!metrics) return undefined 739 + return metrics.prompt_response[0] ?? metrics.prompt[0] 740 + }, [metrics]) 741 + 742 + useEffect(() => { 743 + if (!latestToolMetric) return 744 + if (detail.kind === "memory") return 745 + if (toolSourcePath === latestToolMetric.detailPath) return 746 + 747 + const controller = new AbortController() 748 + const label = latestToolMetric.type === "prompt_response" ? `response #${latestToolMetric.id}` : `prompt #${latestToolMetric.id}` 749 + setToolPanel({ kind: "loading", label }) 750 + 751 + fetchJson<PromptDetail>(latestToolMetric.detailPath, controller.signal) 752 + .then((prompt) => { 753 + setToolPanel({ kind: "ready", label, traces: extractToolTraces(prompt) }) 754 + setToolSourcePath(latestToolMetric.detailPath) 755 + }) 756 + .catch((err) => { 757 + if (controller.signal.aborted) return 758 + setToolPanel({ kind: "error", text: err instanceof Error ? err.message : String(err) }) 759 + }) 760 + 761 + return () => controller.abort() 762 + }, [detail.kind, latestToolMetric, toolSourcePath]) 763 + 764 + const selectedMemoryId = detail.kind === "memory" ? detail.memory.id : undefined 765 + 766 + const selectPair = useCallback(async (pair: MemoryPair) => { 767 + setDetail({ kind: "loading", label: `Recall #${pair.memory.id}` }) 768 + const toolLabel = pair.prompt 769 + ? pair.prompt.type === "prompt_response" 770 + ? `matched response #${pair.prompt.id}` 771 + : `matched prompt #${pair.prompt.id}` 772 + : `recall #${pair.memory.id}` 773 + setToolPanel({ kind: "loading", label: toolLabel }) 774 + try { 775 + const [memory, prompt] = await Promise.all([ 776 + fetchJson<MemoryDetail>(pair.memory.detailPath), 777 + pair.prompt ? fetchJson<PromptDetail>(pair.prompt.detailPath) : Promise.resolve(undefined), 778 + ]) 779 + setDetail({ kind: "memory", memory, prompt }) 780 + setToolPanel({ kind: "ready", label: toolLabel, traces: extractToolTraces(prompt) }) 781 + setToolSourcePath(pair.prompt?.detailPath ?? null) 782 + } catch (err) { 783 + setDetail({ kind: "error", text: err instanceof Error ? err.message : String(err) }) 784 + setToolPanel({ kind: "error", text: err instanceof Error ? err.message : String(err) }) 785 + } 786 + }, []) 787 + 788 + const openMetric = useCallback(async (path: string) => { 789 + setDetail({ kind: "loading", label: "Metric detail" }) 790 + try { 791 + setDetail({ kind: "metric", metric: await fetchJson<unknown>(path) }) 792 + } catch (err) { 793 + setDetail({ kind: "error", text: err instanceof Error ? err.message : String(err) }) 794 + } 795 + }, []) 796 + 797 + return ( 798 + <main className="metrics-app"> 799 + <header className="metrics-header"> 800 + <div> 801 + <h1>Metrics</h1> 802 + <p>Token pressure and memory retrieval checks.</p> 803 + </div> 804 + <form 805 + className="metrics-search" 806 + onSubmit={(event) => { 807 + event.preventDefault() 808 + setQuery(search.trim()) 809 + }} 810 + > 811 + <input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="filter metrics" aria-label="filter metrics" /> 812 + <button type="submit">filter</button> 813 + <button type="button" onClick={() => loadMetrics()}> 814 + refresh 815 + </button> 816 + <button type="button" onClick={() => { 817 + setSearch("") 818 + setQuery("") 819 + }}> 820 + clear 821 + </button> 822 + </form> 823 + <div className="live-controls"> 824 + <button type="button" className={live ? "is-live" : ""} onClick={() => setLive((value) => !value)}> 825 + {live ? "live" : "paused"} 826 + </button> 827 + <span>{lastUpdated ? `updated ${shortTime(lastUpdated)}` : "not updated yet"}</span> 828 + </div> 829 + </header> 830 + 831 + {error ? <p className="metrics-error">metrics unavailable: {error}</p> : null} 832 + {loading && !metrics ? <p className="metrics-loading">loading metrics</p> : null} 833 + 834 + {metrics ? ( 835 + <div className="metrics-grid"> 836 + <div className="metrics-main"> 837 + <TokenTrace 838 + usage={metrics.usage} 839 + promptResponses={metrics.prompt_response} 840 + prompts={metrics.prompt} 841 + onOpenTurn={openMetric} 842 + /> 843 + <MemoryReview 844 + pairs={pairs} 845 + selectedId={selectedMemoryId} 846 + reviewOnly={reviewOnly} 847 + onReviewOnlyChange={setReviewOnly} 848 + onSelect={selectPair} 849 + /> 850 + <ToolTracePanel state={toolPanel} /> 851 + </div> 852 + <div className="metrics-side"> 853 + <DetailPane detail={detail} /> 854 + <BucketRail metrics={metrics} onOpenMetric={openMetric} /> 855 + </div> 856 + </div> 857 + ) : null} 858 + </main> 859 + ) 860 + }
+561 -67
apps/web/src/styles.css
··· 1 1 :root { 2 2 color-scheme: dark; 3 3 font-family: "JetBrains Mono", "Iosevka", "SFMono-Regular", ui-monospace, monospace; 4 - background: #0d1017; 5 - color: #eef2ff; 4 + background: #151412; 5 + color: #efeee8; 6 6 } 7 7 8 8 * { ··· 12 12 body { 13 13 margin: 0; 14 14 min-height: 100vh; 15 - background: #0d1017; 15 + background: 16 + linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0) 220px), 17 + #151412; 18 + } 19 + 20 + button, 21 + input { 22 + font: inherit; 23 + } 24 + 25 + button { 26 + cursor: pointer; 27 + } 28 + 29 + .shell { 30 + min-height: 100vh; 31 + } 32 + 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); 45 + } 46 + 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; 54 + } 55 + 56 + .top-nav button:hover, 57 + .top-nav button.is-active { 58 + border-color: #645d50; 59 + background: #24211d; 60 + color: #f5f1e8; 16 61 } 17 62 18 63 .app { 19 - max-width: 900px; 64 + max-width: 920px; 20 65 margin: 0 auto; 21 - min-height: 100vh; 66 + min-height: calc(100vh - 44px); 22 67 padding: 1.25rem; 23 68 display: grid; 24 69 grid-template-rows: auto 1fr auto; 25 70 gap: 1rem; 26 71 } 27 72 28 - .header h1 { 73 + .header h1, 74 + .metrics-header h1 { 29 75 margin: 0; 30 - font-size: 1.3rem; 76 + font-size: 1.1rem; 77 + letter-spacing: 0; 31 78 } 32 79 33 - .status { 80 + .status, 81 + .metrics-header p, 82 + .panel-head p, 83 + .detail-pane p { 34 84 margin: 0.25rem 0 0; 35 - color: #c5d0ff; 85 + color: #aaa39a; 36 86 } 37 87 38 88 .toggles { ··· 41 91 gap: 0.75rem; 42 92 flex-wrap: wrap; 43 93 align-items: center; 44 - color: #c5d0ff; 45 - font-size: 0.9rem; 94 + color: #aaa39a; 95 + font-size: 0.88rem; 96 + } 97 + 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; 46 106 } 47 107 48 - .toggles button { 49 - border-radius: 8px; 50 - border: 1px solid #2d344a; 51 - background: #141a2b; 52 - color: inherit; 53 - padding: 0.35rem 0.65rem; 54 - font: inherit; 55 - cursor: pointer; 108 + .toggles button:hover, 109 + .composer button:hover, 110 + .metrics-search button:hover { 111 + border-color: #8d7b55; 56 112 } 57 113 58 - .toggles button:hover { 59 - border-color: #4d4fb2; 114 + .feed, 115 + .metric-panel, 116 + .detail-pane, 117 + .bucket-rail { 118 + border: 1px solid #34302a; 119 + border-radius: 8px; 120 + background: #1c1a17; 60 121 } 61 122 62 123 .feed { 63 - border: 1px solid #252a3a; 64 - border-radius: 12px; 65 124 padding: 0.9rem; 66 125 overflow: auto; 67 - background: #111522; 68 126 } 69 127 70 128 .entry { 71 129 padding: 0.7rem; 72 - border-radius: 8px; 130 + border-radius: 7px; 73 131 margin-bottom: 0.7rem; 74 132 border: 1px solid transparent; 75 133 } ··· 79 137 margin-bottom: 0.35rem; 80 138 } 81 139 82 - .entry pre { 140 + .entry pre, 141 + .detail-pane pre { 83 142 margin: 0; 84 143 white-space: pre-wrap; 85 144 word-break: break-word; ··· 95 154 } 96 155 97 156 .entry-header button { 98 - border-radius: 8px; 99 - border: 1px solid #6a561f; 100 - background: #2f2613; 101 - color: #fce8a6; 157 + border-radius: 6px; 158 + border: 1px solid #6b5a2d; 159 + background: #2c2619; 160 + color: #f0d98d; 102 161 padding: 0.25rem 0.5rem; 103 - font: inherit; 104 - cursor: pointer; 105 - } 106 - 107 - .entry-header button:hover { 108 - border-color: #a88935; 109 162 } 110 163 111 164 .markdown { ··· 128 181 } 129 182 130 183 .markdown a { 131 - color: #9bc8ff; 184 + color: #7fc6a4; 132 185 } 133 186 134 187 .markdown :not(pre) > code { 135 - background: #1a2133; 136 - border: 1px solid #2f3c5c; 137 - border-radius: 6px; 188 + background: #24211d; 189 + border: 1px solid #3a342c; 190 + border-radius: 5px; 138 191 padding: 0.1rem 0.3rem; 139 192 } 140 193 141 194 .markdown pre { 142 195 padding: 0.65rem; 143 - border-radius: 8px; 144 - border: 1px solid #2a3145; 145 - background: #0d1321; 196 + border-radius: 7px; 197 + border: 1px solid #34302a; 198 + background: #12110f; 146 199 overflow: auto; 147 200 } 148 201 149 202 .entry-user { 150 - background: #12223a; 151 - border-color: #1f385f; 203 + background: #1c2925; 204 + border-color: #2c5146; 152 205 } 153 206 154 207 .entry-incoming { 155 - background: #123334; 156 - border-color: #25605f; 208 + background: #202722; 209 + border-color: #3b5947; 157 210 } 158 211 159 212 .entry-niri { 160 - background: #281436; 161 - border-color: #4f2b67; 213 + background: #241f28; 214 + border-color: #514761; 162 215 } 163 216 164 217 .entry-tool { 165 - background: #2b230f; 166 - border-color: #58471a; 218 + background: #272313; 219 + border-color: #5c501f; 167 220 } 168 221 169 222 .entry-thinking, 170 223 .entry-info { 171 - background: #1a1f2e; 172 - border-color: #2b3249; 173 - color: #c5d0ff; 224 + background: #20201e; 225 + border-color: #383833; 226 + color: #c6c0b6; 174 227 } 175 228 176 - .entry-error { 177 - background: #38151a; 178 - border-color: #68222b; 179 - color: #ffd5da; 229 + .entry-error, 230 + .metrics-error, 231 + .detail-error { 232 + background: #331d1c; 233 + border-color: #73332f; 234 + color: #ffd8d2; 180 235 } 181 236 182 237 .composer { ··· 186 241 } 187 242 188 243 .composer input, 189 - .composer button { 190 - border-radius: 8px; 191 - border: 1px solid #2d344a; 192 - background: #141a2b; 244 + .metrics-search input { 245 + border-radius: 6px; 246 + border: 1px solid #484237; 247 + background: #201d19; 193 248 color: inherit; 194 249 padding: 0.65rem 0.8rem; 195 - font: inherit; 196 250 } 197 251 198 252 .composer button { 199 - cursor: pointer; 200 - background: #2d2f6a; 201 - border-color: #4d4fb2; 253 + background: #314c3f; 254 + border-color: #507966; 202 255 } 203 256 204 257 .composer button:disabled { 205 - opacity: 0.65; 258 + opacity: 0.62; 206 259 cursor: default; 207 260 } 208 261 ··· 210 263 background: transparent; 211 264 padding: 0; 212 265 } 266 + 267 + .metrics-app { 268 + width: min(1680px, 100%); 269 + margin: 0 auto; 270 + min-height: calc(100vh - 44px); 271 + padding: 1rem; 272 + } 273 + 274 + .metrics-header { 275 + display: grid; 276 + grid-template-columns: minmax(0, 1fr) auto auto; 277 + align-items: end; 278 + gap: 1rem; 279 + padding: 0.75rem 0 1rem; 280 + } 281 + 282 + .metrics-search { 283 + display: grid; 284 + grid-template-columns: minmax(16rem, 24rem) auto auto auto; 285 + gap: 0.5rem; 286 + } 287 + 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 + } 296 + 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; 304 + } 305 + 306 + .live-controls button.is-live { 307 + border-color: #5da37e; 308 + background: #1d3028; 309 + color: #b9f0d3; 310 + } 311 + 312 + .live-controls span { 313 + white-space: nowrap; 314 + font-size: 0.82rem; 315 + } 316 + 317 + .metrics-loading, 318 + .metrics-error { 319 + border: 1px solid #34302a; 320 + border-radius: 8px; 321 + padding: 0.75rem; 322 + } 323 + 324 + .metrics-grid { 325 + display: grid; 326 + grid-template-columns: minmax(0, 1fr) minmax(380px, 0.42fr); 327 + gap: 1rem; 328 + align-items: start; 329 + } 330 + 331 + .metrics-main, 332 + .metrics-side { 333 + display: grid; 334 + gap: 1rem; 335 + min-width: 0; 336 + } 337 + 338 + .metric-panel, 339 + .detail-pane, 340 + .bucket-rail { 341 + min-width: 0; 342 + overflow: hidden; 343 + } 344 + 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; 353 + } 354 + 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; 362 + } 363 + 364 + .token-readout { 365 + text-align: right; 366 + } 367 + 368 + .token-readout span, 369 + .token-ledger span { 370 + display: block; 371 + font-size: 1.35rem; 372 + color: #f2e1a3; 373 + } 374 + 375 + .token-readout small, 376 + .token-ledger small, 377 + .memory-row small, 378 + .memory-hit span { 379 + color: #aaa39a; 380 + } 381 + 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; 389 + } 390 + 391 + .token-bar { 392 + flex: 1 1 4px; 393 + min-width: 3px; 394 + max-width: 18px; 395 + display: flex; 396 + flex-direction: column-reverse; 397 + padding: 0; 398 + border: 0; 399 + border-radius: 3px 3px 0 0; 400 + background: #353028; 401 + overflow: hidden; 402 + } 403 + 404 + .token-bar:hover { 405 + outline: 1px solid #f2e1a3; 406 + } 407 + 408 + .token-bar-prompt { 409 + display: block; 410 + background: #5da37e; 411 + } 412 + 413 + .token-bar-completion { 414 + display: block; 415 + background: #c7a84d; 416 + } 417 + 418 + .token-ledger { 419 + display: grid; 420 + grid-template-columns: repeat(3, 1fr); 421 + gap: 1px; 422 + background: #34302a; 423 + } 424 + 425 + .token-ledger div { 426 + padding: 0.75rem 0.9rem; 427 + background: #1c1a17; 428 + } 429 + 430 + .switch-row { 431 + display: flex; 432 + align-items: center; 433 + gap: 0.45rem; 434 + color: #c6c0b6; 435 + white-space: nowrap; 436 + } 437 + 438 + .memory-table { 439 + max-height: 560px; 440 + overflow: auto; 441 + } 442 + 443 + .memory-row { 444 + width: 100%; 445 + display: grid; 446 + grid-template-columns: 5.5rem 5rem minmax(0, 1fr) minmax(0, 1fr); 447 + gap: 0.75rem; 448 + align-items: start; 449 + padding: 0.6rem 0.9rem; 450 + border: 0; 451 + border-bottom: 1px solid #2d2924; 452 + background: transparent; 453 + color: inherit; 454 + text-align: left; 455 + } 456 + 457 + .memory-row:not(.memory-row-head):hover, 458 + .memory-row.is-selected { 459 + background: #24211d; 460 + } 461 + 462 + .memory-row-head { 463 + position: sticky; 464 + top: 0; 465 + z-index: 1; 466 + color: #aaa39a; 467 + background: #1c1a17; 468 + font-size: 0.78rem; 469 + text-transform: uppercase; 470 + } 471 + 472 + .memory-row span { 473 + min-width: 0; 474 + overflow: hidden; 475 + text-overflow: ellipsis; 476 + } 477 + 478 + .memory-row span:nth-child(3), 479 + .memory-row span:nth-child(4) { 480 + white-space: nowrap; 481 + } 482 + 483 + .memory-row span:nth-child(2) { 484 + color: #7fc6a4; 485 + } 486 + 487 + .memory-row.issue-loose span:nth-child(2) { 488 + color: #f2c66d; 489 + } 490 + 491 + .memory-row.issue-missing span:nth-child(2) { 492 + color: #ff9f91; 493 + } 494 + 495 + .memory-row small { 496 + display: block; 497 + margin-top: 0.15rem; 498 + } 499 + 500 + .detail-pane { 501 + max-height: 680px; 502 + overflow: auto; 503 + padding: 0.9rem; 504 + } 505 + 506 + .detail-meta { 507 + display: grid; 508 + grid-template-columns: 1fr 1fr; 509 + gap: 1px; 510 + margin: 0.8rem 0; 511 + background: #34302a; 512 + } 513 + 514 + .detail-meta div { 515 + padding: 0.6rem; 516 + background: #1c1a17; 517 + } 518 + 519 + .detail-meta dt { 520 + color: #aaa39a; 521 + font-size: 0.76rem; 522 + text-transform: uppercase; 523 + } 524 + 525 + .detail-meta dd { 526 + margin: 0.15rem 0 0; 527 + } 528 + 529 + .detail-section { 530 + padding-top: 0.9rem; 531 + margin-top: 0.9rem; 532 + border-top: 1px solid #34302a; 533 + } 534 + 535 + .memory-hit { 536 + padding: 0.65rem 0; 537 + border-bottom: 1px solid #2d2924; 538 + } 539 + 540 + .memory-hit strong { 541 + display: block; 542 + margin-bottom: 0.15rem; 543 + } 544 + 545 + .memory-hit p { 546 + margin: 0.4rem 0 0; 547 + color: #d8d3ca; 548 + line-height: 1.45; 549 + } 550 + 551 + .empty-note { 552 + margin: 0.45rem 0 0; 553 + color: #aaa39a; 554 + } 555 + 556 + .tool-trace-list { 557 + display: grid; 558 + gap: 0.45rem; 559 + margin-top: 0.55rem; 560 + } 561 + 562 + .tool-panel-body { 563 + max-height: 430px; 564 + overflow: auto; 565 + padding: 0.75rem 0.9rem; 566 + } 567 + 568 + .tool-panel .tool-trace-list { 569 + margin-top: 0; 570 + } 571 + 572 + .tool-trace { 573 + border: 1px solid #34302a; 574 + border-radius: 7px; 575 + background: #171512; 576 + overflow: hidden; 577 + } 578 + 579 + .tool-trace summary { 580 + display: flex; 581 + justify-content: space-between; 582 + align-items: center; 583 + gap: 0.75rem; 584 + padding: 0.55rem 0.65rem; 585 + cursor: pointer; 586 + } 587 + 588 + .tool-trace summary span, 589 + .tool-trace summary small { 590 + min-width: 0; 591 + overflow: hidden; 592 + text-overflow: ellipsis; 593 + white-space: nowrap; 594 + } 595 + 596 + .tool-trace summary small { 597 + color: #aaa39a; 598 + } 599 + 600 + .tool-args { 601 + margin: 0; 602 + padding: 0.6rem 0.65rem; 603 + border-top: 1px solid #2d2924; 604 + color: #d9cfb7; 605 + background: #201d19; 606 + white-space: pre-wrap; 607 + word-break: break-word; 608 + } 609 + 610 + .tool-result { 611 + border-top: 1px solid #2d2924; 612 + padding: 0.65rem; 613 + } 614 + 615 + .bucket-rail { 616 + padding: 0.75rem 0.9rem; 617 + } 618 + 619 + .bucket-rail > section + section { 620 + margin-top: 0.9rem; 621 + padding-top: 0.9rem; 622 + border-top: 1px solid #34302a; 623 + } 624 + 625 + .bucket-rail header { 626 + display: flex; 627 + justify-content: space-between; 628 + align-items: center; 629 + gap: 1rem; 630 + margin-bottom: 0.45rem; 631 + } 632 + 633 + .bucket-rail header span { 634 + color: #aaa39a; 635 + } 636 + 637 + .bucket-list { 638 + display: grid; 639 + gap: 0.25rem; 640 + } 641 + 642 + .bucket-list button { 643 + display: grid; 644 + grid-template-columns: 4.5rem minmax(0, 1fr); 645 + gap: 0.55rem; 646 + align-items: start; 647 + border: 0; 648 + border-radius: 5px; 649 + background: transparent; 650 + color: inherit; 651 + padding: 0.35rem 0.25rem; 652 + text-align: left; 653 + } 654 + 655 + .bucket-list button:hover { 656 + background: #24211d; 657 + } 658 + 659 + .bucket-list span { 660 + color: #aaa39a; 661 + } 662 + 663 + .bucket-list strong { 664 + min-width: 0; 665 + overflow: hidden; 666 + text-overflow: ellipsis; 667 + white-space: nowrap; 668 + font-weight: 500; 669 + } 670 + 671 + @media (max-width: 1120px) { 672 + .metrics-grid { 673 + grid-template-columns: 1fr; 674 + } 675 + 676 + .metrics-side { 677 + grid-template-columns: 1fr; 678 + } 679 + } 680 + 681 + @media (max-width: 760px) { 682 + .metrics-app, 683 + .app { 684 + padding: 0.75rem; 685 + } 686 + 687 + .metrics-header { 688 + grid-template-columns: 1fr; 689 + } 690 + 691 + .metrics-search { 692 + grid-template-columns: 1fr; 693 + } 694 + 695 + .memory-row { 696 + grid-template-columns: 4.5rem 4rem minmax(0, 1fr); 697 + } 698 + 699 + .memory-row span:nth-child(4) { 700 + display: none; 701 + } 702 + 703 + .composer { 704 + grid-template-columns: 1fr; 705 + } 706 + }
+1
apps/web/vite.config.ts
··· 20 20 "/trigger": backendTarget, 21 21 "/chat": backendTarget, 22 22 "/status": backendTarget, 23 + "/metrics": backendTarget, 23 24 }, 24 25 }, 25 26 preview: {
+2 -84
bun.lock
··· 10 10 "better-sqlite3": "^12.8.0", 11 11 "discord.js": "^14.25.1", 12 12 "fastify": "^5.8.4", 13 - "ink": "^6.3.0", 14 - "ink-text-input": "^6.0.0", 15 13 "node-pty": "^1.1.0", 16 14 "openai": "^6.33.0", 17 - "react": "^19.2.0", 18 15 }, 19 16 "devDependencies": { 20 17 "@types/better-sqlite3": "^7.6.13", 21 18 "@types/node": "^25.5.0", 22 - "@types/react": "^19.2.2", 23 19 "dotenv-cli": "^11.0.0", 24 20 "tsx": "^4.21.0", 25 21 "typescript": "^6.0.2", ··· 51 47 }, 52 48 }, 53 49 "packages": { 54 - "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], 55 - 56 50 "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], 57 51 58 52 "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], ··· 295 289 296 290 "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], 297 291 298 - "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], 299 - 300 - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], 301 - 302 - "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], 303 - 304 292 "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], 305 - 306 - "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], 307 293 308 294 "avvio": ["avvio@9.2.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ=="], 309 295 ··· 330 316 "caniuse-lite": ["caniuse-lite@1.0.30001786", "", {}, "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA=="], 331 317 332 318 "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], 333 - 334 - "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], 335 319 336 320 "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], 337 321 ··· 343 327 344 328 "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], 345 329 346 - "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], 347 - 348 - "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], 349 - 350 - "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], 351 - 352 - "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], 353 - 354 330 "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], 355 331 356 332 "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], 357 333 358 334 "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], 359 335 360 - "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], 361 - 362 336 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], 363 337 364 338 "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], ··· 393 367 394 368 "electron-to-chromium": ["electron-to-chromium@1.5.332", "", {}, "sha512-7OOtytmh/rINMLwaFTbcMVvYXO3AUm029X0LcyfYk0B557RlPkdpTpnH9+htMlfu5dKwOmT0+Zs2Aw+lnn6TeQ=="], 395 369 396 - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], 397 - 398 370 "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], 399 371 400 - "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], 401 - 402 - "es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], 403 - 404 372 "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], 405 373 406 374 "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], 407 375 408 376 "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], 409 377 410 - "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], 378 + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], 411 379 412 380 "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], 413 381 ··· 445 413 446 414 "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], 447 415 448 - "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], 449 - 450 416 "get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="], 451 417 452 418 "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], ··· 469 435 470 436 "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], 471 437 472 - "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], 473 - 474 438 "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], 475 439 476 440 "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], 477 441 478 - "ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="], 479 - 480 - "ink-text-input": ["ink-text-input@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" }, "peerDependencies": { "ink": ">=5", "react": ">=18" } }, "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw=="], 481 - 482 442 "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], 483 443 484 444 "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], ··· 489 449 490 450 "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], 491 451 492 - "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], 493 - 494 452 "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], 495 - 496 - "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], 497 453 498 454 "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], 499 455 ··· 615 571 616 572 "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], 617 573 618 - "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], 619 - 620 574 "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], 621 575 622 576 "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], ··· 644 598 "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], 645 599 646 600 "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], 647 - 648 - "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], 649 601 650 602 "openai": ["openai@6.33.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw=="], 651 603 ··· 653 605 654 606 "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], 655 607 656 - "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], 657 - 658 608 "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], 659 609 660 610 "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], ··· 688 638 "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], 689 639 690 640 "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], 691 - 692 - "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], 693 641 694 642 "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], 695 643 ··· 711 659 712 660 "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], 713 661 714 - "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], 715 - 716 662 "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], 717 663 718 664 "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], ··· 741 687 742 688 "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], 743 689 744 - "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], 690 + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], 745 691 746 692 "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], 747 693 748 694 "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], 749 - 750 - "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], 751 695 752 696 "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], 753 697 ··· 757 701 758 702 "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], 759 703 760 - "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], 761 - 762 704 "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], 763 705 764 - "string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], 765 - 766 706 "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], 767 707 768 708 "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], 769 709 770 - "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], 771 - 772 710 "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], 773 711 774 712 "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], 775 713 776 714 "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], 777 - 778 - "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], 779 715 780 716 "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], 781 717 782 718 "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], 783 - 784 - "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], 785 719 786 720 "thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="], 787 721 ··· 803 737 804 738 "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], 805 739 806 - "type-fest": ["type-fest@5.5.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="], 807 - 808 740 "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], 809 741 810 742 "undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], ··· 836 768 "vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], 837 769 838 770 "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 839 - 840 - "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], 841 - 842 - "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], 843 771 844 772 "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], 845 773 ··· 847 775 848 776 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], 849 777 850 - "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], 851 - 852 778 "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], 853 779 854 780 "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], ··· 865 791 866 792 "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], 867 793 868 - "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], 869 - 870 - "ink-text-input/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], 871 - 872 794 "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], 873 795 874 - "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], 875 - 876 796 "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], 877 - 878 - "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], 879 797 } 880 798 }
+78 -5
src/memory.ts
··· 308 308 return last.content 309 309 } 310 310 311 + function discordChannelLabel(channelId: string | null, fallbackContext: string | null, isDm: boolean): string { 312 + if (isDm) return "DM" 313 + 314 + const fallback = fallbackContext 315 + ?.replace(/^context:\s*/i, "") 316 + .replace(/\s*\(\d+\)\s*$/, "") 317 + .trim() 318 + 319 + if (channelId) { 320 + try { 321 + const row = getDb() 322 + .prepare("select guild_id, guild_name, channel_name, is_dm from discord_channels where channel_id = ?") 323 + .get(channelId) as 324 + | { 325 + guild_id: string | null 326 + guild_name: string | null 327 + channel_name: string | null 328 + is_dm: number 329 + } 330 + | undefined 331 + 332 + if (row?.is_dm) return "DM" 333 + if (row) { 334 + const guild = row.guild_name ?? row.guild_id 335 + const channel = row.channel_name ?? channelId 336 + if (guild && channel) return `${guild}/#${channel}` 337 + if (channel) return `#${channel}` 338 + } 339 + } catch { 340 + // If the main db is unavailable in tests or scripts, keep the parsed context. 341 + } 342 + } 343 + 344 + if (fallback) return fallback 345 + return channelId ? `#${channelId}` : "channel" 346 + } 347 + 348 + function conciseDiscordMemoryQuery(raw: string): string | null { 349 + const withoutWakeEnvelope = raw.replace(/^\[(wake|incoming|harness restarted)[^\n]*\]\s*/gi, "").trim() 350 + if (!/\[discord\/(?:dm|channel)\]/i.test(withoutWakeEnvelope)) return null 351 + 352 + const blocks = withoutWakeEnvelope 353 + .split(/\n\s*\n/g) 354 + .map((block) => block.trim()) 355 + .filter(Boolean) 356 + const headerBlock = blocks[0] ?? withoutWakeEnvelope 357 + const message = blocks.length > 1 ? blocks.slice(1).join("\n\n").trim() : "" 358 + 359 + const lines = headerBlock.split("\n").map((line) => line.trim()).filter(Boolean) 360 + const discordLine = lines.find((line) => /^\[discord\/(?:dm|channel)\]/i.test(line)) ?? "" 361 + const contextLine = lines.find((line) => /^context:\s*/i.test(line)) ?? null 362 + const isDm = /\[discord\/dm\]/i.test(discordLine) 363 + const author = discordLine.match(/@(\S+)/)?.[1] 364 + const context = contextLine?.replace(/^context:\s*/i, "").trim() ?? "" 365 + const dmChannelId = context.match(/^DM\s+(\d+)/i)?.[1] ?? null 366 + const namedChannelId = context.match(/\((\d+)\)\s*$/)?.[1] ?? null 367 + const channelId = dmChannelId ?? namedChannelId 368 + const location = discordChannelLabel(channelId, contextLine, isDm) 369 + 370 + const parts = [ 371 + author ? `@${author}` : null, 372 + location, 373 + message || null, 374 + ].filter((part): part is string => Boolean(part?.trim())) 375 + 376 + return parts.length ? parts.join("\n") : null 377 + } 378 + 379 + function memoryQueryForUserMessage(raw: string): string { 380 + return conciseDiscordMemoryQuery(raw) ?? raw 381 + } 382 + 311 383 function normalizeSearchInput(raw: string): string { 312 384 const withoutWakeEnvelope = raw.replace(/^\[(wake|incoming|harness restarted)[^\n]*\]\s*/gi, "").trim() 313 385 ··· 321 393 return bodyCandidate 322 394 .replace(/^\[discord\/[^\]]+\]\s*@\S+\s+in\s+\d+(?:\s+\(\d+\))?\s*/gi, "") 323 395 .replace(/^\[[^\]]+\]\s*/g, "") 324 - .replace(/@[a-z0-9_.-]+/gi, " ") 396 + .replace(/@([a-z0-9_.-]+)/gi, " $1 ") 325 397 .replace(/\b\d{6,}\b/g, " ") 326 398 .replace(/[^\p{L}\p{N}\s'-]+/gu, " ") 327 399 .toLowerCase() ··· 636 708 ): Promise<{ messages: Message[]; recalledChunkIds: number[] }> { 637 709 const latestUser = latestUserMessage(conversation) 638 710 if (!latestUser) return { messages: conversation, recalledChunkIds: [] } 711 + const memoryQuery = memoryQueryForUserMessage(latestUser) 639 712 640 713 await syncMemoryIndex() 641 714 642 - const profile = buildSearchProfile(latestUser) 715 + const profile = buildSearchProfile(memoryQuery) 643 716 if (profile.tokens.length === 0) return { messages: conversation, recalledChunkIds: [] } 644 717 645 718 const hits = searchMemory(profile, cooldowns, currentTurn, MEMORY_RECALL_MAX_CHUNKS) 646 719 if (hits.length === 0) return { messages: conversation, recalledChunkIds: [] } 647 720 if (!shouldInjectHits(hits, profile)) { 648 721 console.log( 649 - `[memory] skipped query=${JSON.stringify(trimForPrompt(normalizeText(latestUser), 120))} personQuery=${profile.personQuery} eventQuery=${profile.eventQuery} reason=weak-match`, 722 + `[memory] skipped query=${JSON.stringify(trimForPrompt(normalizeText(memoryQuery), 120))} personQuery=${profile.personQuery} eventQuery=${profile.eventQuery} reason=weak-match`, 650 723 ) 651 724 return { messages: conversation, recalledChunkIds: [] } 652 725 } 653 726 654 727 const recallContent = buildMemoryRecallMessage(hits) 655 728 console.log( 656 - `[memory] recalled query=${JSON.stringify(trimForPrompt(normalizeText(latestUser), 120))} personQuery=${profile.personQuery} eventQuery=${profile.eventQuery}\n${recallContent}`, 729 + `[memory] recalled query=${JSON.stringify(trimForPrompt(normalizeText(memoryQuery), 120))} personQuery=${profile.personQuery} eventQuery=${profile.eventQuery}\n${recallContent}`, 657 730 ) 658 731 659 732 recordMetric({ 660 733 type: "memory", 661 - query: latestUser, 734 + query: memoryQuery, 662 735 results: hits.map(toMemorySearchResult), 663 736 }) 664 737
+470 -38
src/metrics.ts
··· 2 2 import path from "path" 3 3 import { fileURLToPath } from "url" 4 4 import type OpenAI from "openai" 5 + import { getDb } from "./db.js" 5 6 import type { Message } from "./types.js" 6 7 import type { MemorySearchResult } from "./memory.js" 7 8 ··· 14 15 messages: Message[] 15 16 } 16 17 18 + export interface PromptResponseMetric extends BaseMetricEvent { 19 + type: "prompt_response" 20 + promptMetricId?: number 21 + model: string 22 + toolChoice?: string 23 + messages: Message[] 24 + response: OpenAI.Chat.ChatCompletionMessage 25 + usage?: OpenAI.Completions.CompletionUsage 26 + } 27 + 17 28 export interface MemoryMetric extends BaseMetricEvent { 18 29 type: "memory" 19 30 query: string ··· 33 44 usage: OpenAI.Completions.CompletionUsage 34 45 } 35 46 36 - export type MetricEvent = PromptMetric | MemoryMetric | CompactionMetric | UsageMetric 47 + export type MetricEvent = PromptMetric | PromptResponseMetric | MemoryMetric | CompactionMetric | UsageMetric 37 48 38 - export interface MetricEventSummary extends BaseMetricEvent { 49 + export type MetricListType = "prompt_response" | "summarization" | "memory" | "prompt" | "usage" | "discord" 50 + export type MetricBucketName = "memories" | "summarization" | "prompt_response" | "prompt" | "usage" | "discord" 51 + 52 + export type MetricListItem = 53 + | (BaseMetricListItem & { 54 + type: "prompt_response" 55 + promptMetricId?: number 56 + model?: string 57 + toolChoice?: string 58 + messageCount?: number 59 + lastUserMessage?: string 60 + responsePreview?: string 61 + toolCallCount?: number 62 + usage?: OpenAI.Completions.CompletionUsage 63 + }) 64 + | (BaseMetricListItem & { 65 + type: "summarization" 66 + method?: string 67 + before?: number 68 + after?: number 69 + savedTokens?: number 70 + summaryPreview?: string 71 + summaryChars?: number 72 + }) 73 + | (BaseMetricListItem & { 74 + type: "memory" 75 + queryPreview?: string 76 + resultCount?: number 77 + }) 78 + | (BaseMetricListItem & { 79 + type: "prompt" 80 + messageCount?: number 81 + lastUserMessage?: string 82 + }) 83 + | (BaseMetricListItem & { 84 + type: "usage" 85 + usage?: OpenAI.Completions.CompletionUsage 86 + }) 87 + 88 + export interface BaseMetricListItem extends BaseMetricEvent { 39 89 id: number 40 - type: MetricEvent["type"] 41 - // Metadata for the feed 42 - messageCount?: number 43 - resultCount?: number 44 - method?: string 45 - before?: number 46 - after?: number 47 - usage?: OpenAI.Completions.CompletionUsage 90 + sourceType: MetricEvent["type"] 91 + detailPath: string 92 + } 93 + 94 + export interface MetricsQuery { 95 + limit?: number 96 + cursor?: number 97 + cursors?: Partial<Record<MetricBucketName, string | number>> 98 + type?: MetricListType[] 99 + includeRaw?: boolean 100 + q?: string 101 + from?: string 102 + to?: string 103 + } 104 + 105 + export interface DiscordMetricListItem extends BaseMetricEvent { 106 + id: string 107 + type: "discord" 108 + sourceType: "discord" 109 + detailPath: string 110 + messageId: string 111 + channelId: string 112 + guildId?: string 113 + authorId?: string 114 + authorUsername?: string 115 + contentPreview?: string 116 + isDm: boolean 117 + mentionsBot: boolean 118 + isFromBot: boolean 119 + } 120 + 121 + export interface MetricsPage { 122 + memories: MetricListItem[] 123 + summarization: MetricListItem[] 124 + prompt_response: MetricListItem[] 125 + prompt: MetricListItem[] 126 + usage: MetricListItem[] 127 + discord: DiscordMetricListItem[] 128 + limit: number 129 + nextCursor: Partial<Record<MetricBucketName, string | number>> 130 + hasMore: Partial<Record<MetricBucketName, boolean>> 131 + filters: { 132 + type?: MetricListType[] 133 + includeRaw: boolean 134 + q?: string 135 + from?: string 136 + to?: string 137 + } 48 138 } 49 139 50 140 export type MetricEventInput = 51 141 | Omit<PromptMetric, "timestamp"> 142 + | Omit<PromptResponseMetric, "timestamp"> 52 143 | Omit<MemoryMetric, "timestamp"> 53 144 | Omit<CompactionMetric, "timestamp"> 54 145 | Omit<UsageMetric, "timestamp"> ··· 79 170 ); 80 171 create index if not exists idx_metrics_type on metrics(type); 81 172 create index if not exists idx_metrics_created on metrics(createdAt desc); 173 + create index if not exists idx_metrics_type_id on metrics(type, id desc); 82 174 `) 83 175 console.log("[metrics] ready") 84 176 } 85 177 86 - export function recordMetric(event: MetricEventInput): void { 178 + export function recordMetric(event: MetricEventInput): number | null { 87 179 const timestamp = new Date().toISOString() 88 180 89 181 if (db) { ··· 96 188 if (events.length > MAX_IN_MEMORY) { 97 189 events.shift() 98 190 } 191 + return fullEvent.id 99 192 } catch (err) { 100 193 console.error("[metrics] failed to record to db:", err) 101 194 } 102 195 } 196 + return null 103 197 } 104 198 105 - export function getMetrics(limit = 100): MetricEventSummary[] { 106 - if (db) { 107 - try { 108 - const rows = db.prepare("select id, type, payload, createdAt from metrics order by createdAt desc limit ?").all(limit) as MetricRow[] 109 - return rows.map((row) => { 110 - const payload = JSON.parse(row.payload) 111 - const summary: MetricEventSummary = { 112 - id: row.id, 113 - type: row.type as any, 114 - timestamp: row.createdAt, 115 - } 199 + const DEFAULT_METRIC_LIMIT = 100 200 + const MAX_METRIC_LIMIT = 200 201 + const DEFAULT_LIST_TYPES: MetricListType[] = ["memory", "summarization", "prompt_response", "prompt", "usage", "discord"] 202 + const METRIC_TYPE_TO_SOURCE: Partial<Record<MetricListType, MetricEvent["type"]>> = { 203 + prompt_response: "prompt_response", 204 + summarization: "compaction", 205 + memory: "memory", 206 + prompt: "prompt", 207 + usage: "usage", 208 + } 209 + const METRIC_TYPE_TO_BUCKET: Record<MetricListType, MetricBucketName> = { 210 + memory: "memories", 211 + summarization: "summarization", 212 + prompt_response: "prompt_response", 213 + prompt: "prompt", 214 + usage: "usage", 215 + discord: "discord", 216 + } 116 217 117 - if (row.type === "prompt") { 118 - summary.messageCount = payload.messages?.length 119 - } else if (row.type === "memory") { 120 - summary.resultCount = payload.results?.length 121 - } else if (row.type === "compaction") { 122 - summary.method = payload.method 123 - summary.before = payload.before 124 - summary.after = payload.after 125 - } else if (row.type === "usage") { 126 - summary.usage = payload.usage 127 - } 218 + function clampLimit(limit: number | undefined): number { 219 + if (!Number.isFinite(limit) || !limit) return DEFAULT_METRIC_LIMIT 220 + return Math.max(1, Math.min(MAX_METRIC_LIMIT, Math.floor(limit))) 221 + } 222 + 223 + function normalizeListTypes(type: MetricListType[] | undefined, includeRaw: boolean): MetricListType[] { 224 + const allowed = new Set<MetricListType>(DEFAULT_LIST_TYPES) 225 + if (!type || type.length === 0) return [...DEFAULT_LIST_TYPES] 226 + 227 + const normalized: MetricListType[] = [] 228 + for (const item of type) { 229 + if (!allowed.has(item)) continue 230 + if (!normalized.includes(item)) normalized.push(item) 231 + } 232 + return normalized.length ? normalized : [...DEFAULT_LIST_TYPES] 233 + } 234 + 235 + function preview(value: unknown, maxChars = 240): string | undefined { 236 + if (typeof value !== "string") return undefined 237 + const normalized = value.replace(/\s+/g, " ").trim() 238 + if (!normalized) return undefined 239 + if (normalized.length <= maxChars) return normalized 240 + return `${normalized.slice(0, maxChars - 3).trimEnd()}...` 241 + } 242 + 243 + function messageText(content: unknown): string | undefined { 244 + if (typeof content === "string") return content 245 + if (!Array.isArray(content)) return undefined 246 + 247 + const parts = content.flatMap((part) => { 248 + if (!part || typeof part !== "object") return [] 249 + const record = part as Record<string, unknown> 250 + return typeof record.text === "string" ? [record.text] : [] 251 + }) 252 + return parts.length ? parts.join("\n") : undefined 253 + } 254 + 255 + function lastUserMessage(messages: unknown): string | undefined { 256 + if (!Array.isArray(messages)) return undefined 257 + 258 + for (let i = messages.length - 1; i >= 0; i--) { 259 + const message = messages[i] as { role?: unknown; content?: unknown } | undefined 260 + if (message?.role !== "user") continue 261 + const text = messageText(message.content) 262 + if (text) return preview(text) 263 + } 264 + return undefined 265 + } 266 + 267 + function parseMetricRow(row: MetricRow): MetricListItem | null { 268 + const payload = JSON.parse(row.payload) as Partial<MetricEvent> & Record<string, unknown> 269 + const base: BaseMetricListItem = { 270 + id: row.id, 271 + sourceType: row.type as MetricEvent["type"], 272 + timestamp: row.createdAt, 273 + detailPath: `/metrics/${row.id}`, 274 + } 275 + 276 + if (row.type === "prompt_response") { 277 + const response = payload.response as OpenAI.Chat.ChatCompletionMessage | undefined 278 + return { 279 + ...base, 280 + type: "prompt_response", 281 + promptMetricId: typeof payload.promptMetricId === "number" ? payload.promptMetricId : undefined, 282 + model: typeof payload.model === "string" ? payload.model : undefined, 283 + toolChoice: typeof payload.toolChoice === "string" ? payload.toolChoice : undefined, 284 + messageCount: Array.isArray(payload.messages) ? payload.messages.length : undefined, 285 + lastUserMessage: lastUserMessage(payload.messages), 286 + responsePreview: preview(messageText(response?.content)), 287 + toolCallCount: Array.isArray(response?.tool_calls) ? response.tool_calls.length : undefined, 288 + usage: payload.usage as OpenAI.Completions.CompletionUsage | undefined, 289 + } 290 + } 128 291 129 - return summary 130 - }) 131 - } catch (err) { 132 - console.error("[metrics] failed to fetch from db:", err) 292 + if (row.type === "compaction") { 293 + const before = typeof payload.before === "number" ? payload.before : undefined 294 + const after = typeof payload.after === "number" ? payload.after : undefined 295 + const summary = typeof payload.summary === "string" ? payload.summary : undefined 296 + return { 297 + ...base, 298 + type: "summarization", 299 + method: typeof payload.method === "string" ? payload.method : undefined, 300 + before, 301 + after, 302 + savedTokens: typeof before === "number" && typeof after === "number" ? before - after : undefined, 303 + summaryPreview: preview(summary), 304 + summaryChars: summary?.length, 133 305 } 134 306 } 135 - return [] 307 + 308 + if (row.type === "memory") { 309 + return { 310 + ...base, 311 + type: "memory", 312 + queryPreview: preview(payload.query), 313 + resultCount: Array.isArray(payload.results) ? payload.results.length : undefined, 314 + } 315 + } 316 + 317 + if (row.type === "prompt") { 318 + return { 319 + ...base, 320 + type: "prompt", 321 + messageCount: Array.isArray(payload.messages) ? payload.messages.length : undefined, 322 + lastUserMessage: lastUserMessage(payload.messages), 323 + } 324 + } 325 + 326 + if (row.type === "usage") { 327 + return { 328 + ...base, 329 + type: "usage", 330 + usage: payload.usage as OpenAI.Completions.CompletionUsage | undefined, 331 + } 332 + } 333 + 334 + return null 335 + } 336 + 337 + function metricCursor(query: MetricsQuery, bucket: MetricBucketName): number | undefined { 338 + const raw = query.cursors?.[bucket] ?? query.cursor 339 + const value = typeof raw === "string" ? parseInt(raw, 10) : raw 340 + return Number.isFinite(value) ? Math.floor(value as number) : undefined 341 + } 342 + 343 + function queryMetricBucket( 344 + type: MetricListType, 345 + bucket: MetricBucketName, 346 + query: MetricsQuery, 347 + limit: number, 348 + ): { items: MetricListItem[]; nextCursor?: number; hasMore: boolean } { 349 + const sourceType = METRIC_TYPE_TO_SOURCE[type] 350 + if (!sourceType || !db) return { items: [], hasMore: false } 351 + 352 + try { 353 + const where = ["type = ?"] 354 + const params: Array<string | number> = [sourceType] 355 + const cursor = metricCursor(query, bucket) 356 + 357 + if (cursor) { 358 + where.push("id < ?") 359 + params.push(cursor) 360 + } 361 + if (query.from) { 362 + where.push("createdAt >= ?") 363 + params.push(query.from) 364 + } 365 + if (query.to) { 366 + where.push("createdAt <= ?") 367 + params.push(query.to) 368 + } 369 + if (query.q?.trim()) { 370 + where.push("payload like ?") 371 + params.push(`%${query.q.trim()}%`) 372 + } 373 + 374 + const rows = db 375 + .prepare( 376 + `select id, type, payload, createdAt 377 + from metrics 378 + where ${where.join(" and ")} 379 + order by id desc 380 + limit ?`, 381 + ) 382 + .all(...params, limit + 1) as MetricRow[] 383 + const pageRows = rows.slice(0, limit) 384 + const items = pageRows.flatMap((row) => { 385 + const item = parseMetricRow(row) 386 + return item ? [item] : [] 387 + }) 388 + const last = pageRows[pageRows.length - 1] 389 + return { 390 + items, 391 + nextCursor: rows.length > limit && last ? last.id : undefined, 392 + hasMore: rows.length > limit, 393 + } 394 + } catch (err) { 395 + console.error(`[metrics] failed to fetch ${type} bucket:`, err) 396 + return { items: [], hasMore: false } 397 + } 398 + } 399 + 400 + type DiscordMessageMetricRow = { 401 + message_id: string 402 + channel_id: string 403 + guild_id: string | null 404 + author_id: string | null 405 + author_username: string | null 406 + content: string 407 + created_at: string 408 + is_dm: number 409 + mentions_bot: number 410 + is_from_bot: number 411 + } 412 + 413 + function queryDiscordBucket( 414 + query: MetricsQuery, 415 + limit: number, 416 + ): { items: DiscordMetricListItem[]; nextCursor?: string; hasMore: boolean } { 417 + try { 418 + const niriDb = getDb() 419 + const where: string[] = [] 420 + const params: Array<string | number> = [] 421 + const cursor = query.cursors?.discord 422 + 423 + if (typeof cursor === "string" && cursor.trim()) { 424 + where.push("created_at < ?") 425 + params.push(cursor) 426 + } 427 + if (query.from) { 428 + where.push("created_at >= ?") 429 + params.push(query.from) 430 + } 431 + if (query.to) { 432 + where.push("created_at <= ?") 433 + params.push(query.to) 434 + } 435 + if (query.q?.trim()) { 436 + where.push("(content like ? or author_username like ? or channel_id like ?)") 437 + const like = `%${query.q.trim()}%` 438 + params.push(like, like, like) 439 + } 440 + 441 + const rows = niriDb 442 + .prepare( 443 + `select message_id, channel_id, guild_id, author_id, author_username, content, 444 + created_at, is_dm, mentions_bot, is_from_bot 445 + from discord_messages 446 + ${where.length ? `where ${where.join(" and ")}` : ""} 447 + order by created_at desc 448 + limit ?`, 449 + ) 450 + .all(...params, limit + 1) as DiscordMessageMetricRow[] 451 + const pageRows = rows.slice(0, limit) 452 + const items = pageRows.map((row) => ({ 453 + id: row.message_id, 454 + type: "discord" as const, 455 + sourceType: "discord" as const, 456 + timestamp: row.created_at, 457 + detailPath: `/metrics/discord/${row.message_id}`, 458 + messageId: row.message_id, 459 + channelId: row.channel_id, 460 + ...(row.guild_id ? { guildId: row.guild_id } : {}), 461 + ...(row.author_id ? { authorId: row.author_id } : {}), 462 + ...(row.author_username ? { authorUsername: row.author_username } : {}), 463 + contentPreview: preview(row.content), 464 + isDm: row.is_dm === 1, 465 + mentionsBot: row.mentions_bot === 1, 466 + isFromBot: row.is_from_bot === 1, 467 + })) 468 + const last = pageRows[pageRows.length - 1] 469 + return { 470 + items, 471 + nextCursor: rows.length > limit && last ? last.created_at : undefined, 472 + hasMore: rows.length > limit, 473 + } 474 + } catch (err) { 475 + if (err instanceof Error && err.message === "Database not initialized") return { items: [], hasMore: false } 476 + console.error("[metrics] failed to fetch discord bucket:", err) 477 + return { items: [], hasMore: false } 478 + } 479 + } 480 + 481 + export function getMetrics(query: MetricsQuery = {}): MetricsPage { 482 + const limit = clampLimit(query.limit) 483 + const includeRaw = query.includeRaw === true 484 + const types = normalizeListTypes(query.type, includeRaw) 485 + const selectedBuckets = new Set(types.map((type) => METRIC_TYPE_TO_BUCKET[type])) 486 + const nextCursor: Partial<Record<MetricBucketName, string | number>> = {} 487 + const hasMore: Partial<Record<MetricBucketName, boolean>> = {} 488 + const emptyMetricBucket: MetricListItem[] = [] 489 + 490 + const readMetricBucket = (type: MetricListType): MetricListItem[] => { 491 + const bucket = METRIC_TYPE_TO_BUCKET[type] 492 + if (!selectedBuckets.has(bucket)) return emptyMetricBucket 493 + const result = queryMetricBucket(type, bucket, query, limit) 494 + if (result.nextCursor != null) nextCursor[bucket] = result.nextCursor 495 + hasMore[bucket] = result.hasMore 496 + return result.items 497 + } 498 + 499 + const readDiscordBucket = (): DiscordMetricListItem[] => { 500 + if (!selectedBuckets.has("discord")) return [] 501 + const result = queryDiscordBucket(query, limit) 502 + if (result.nextCursor != null) nextCursor.discord = result.nextCursor 503 + hasMore.discord = result.hasMore 504 + return result.items 505 + } 506 + 507 + return { 508 + memories: readMetricBucket("memory"), 509 + summarization: readMetricBucket("summarization"), 510 + prompt_response: readMetricBucket("prompt_response"), 511 + prompt: readMetricBucket("prompt"), 512 + usage: readMetricBucket("usage"), 513 + discord: readDiscordBucket(), 514 + limit, 515 + nextCursor, 516 + hasMore, 517 + filters: { 518 + type: types, 519 + includeRaw, 520 + q: query.q, 521 + from: query.from, 522 + to: query.to, 523 + }, 524 + } 136 525 } 137 526 138 527 export function getMetricDetail(id: number): (MetricEvent & { id: number }) | null { ··· 149 538 } 150 539 return null 151 540 } 541 + 542 + export function getDiscordMetricDetail(id: string): (DiscordMetricListItem & { raw: unknown }) | null { 543 + try { 544 + const row = getDb() 545 + .prepare( 546 + `select message_id, channel_id, guild_id, author_id, author_username, content, 547 + created_at, is_dm, mentions_bot, is_from_bot, raw_json 548 + from discord_messages 549 + where message_id = ?`, 550 + ) 551 + .get(id) as (DiscordMessageMetricRow & { raw_json: string }) | undefined 552 + if (!row) return null 553 + 554 + let raw: unknown = null 555 + try { 556 + raw = JSON.parse(row.raw_json) 557 + } catch { 558 + raw = row.raw_json 559 + } 560 + 561 + return { 562 + id: row.message_id, 563 + type: "discord", 564 + sourceType: "discord", 565 + timestamp: row.created_at, 566 + detailPath: `/metrics/discord/${row.message_id}`, 567 + messageId: row.message_id, 568 + channelId: row.channel_id, 569 + ...(row.guild_id ? { guildId: row.guild_id } : {}), 570 + ...(row.author_id ? { authorId: row.author_id } : {}), 571 + ...(row.author_username ? { authorUsername: row.author_username } : {}), 572 + contentPreview: preview(row.content), 573 + isDm: row.is_dm === 1, 574 + mentionsBot: row.mentions_bot === 1, 575 + isFromBot: row.is_from_bot === 1, 576 + raw, 577 + } 578 + } catch (err) { 579 + if (err instanceof Error && err.message === "Database not initialized") return null 580 + console.error("[metrics] failed to fetch discord detail:", err) 581 + return null 582 + } 583 + }
+19 -3
src/runner/loop.ts
··· 353 353 stream_options: { include_usage: true }, 354 354 } as const 355 355 356 - recordMetric({ type: "prompt", messages: request.messages }) 356 + const promptMetricId = recordMetric({ type: "prompt", messages: request.messages }) 357 357 358 358 try { 359 359 const stream = await apiClient.chat.completions.create(streamedRequest) 360 - return consumeCompletionStream(stream as AsyncIterable<OpenAI.Chat.ChatCompletionChunk>) 360 + const result = await consumeCompletionStream(stream as AsyncIterable<OpenAI.Chat.ChatCompletionChunk>) 361 + recordPromptResponse(request, result, promptMetricId) 362 + return result 361 363 } catch (err) { 362 364 if (shouldRetryWithoutStreamUsage(err)) { 363 365 const stream = await apiClient.chat.completions.create({ 364 366 ...request, 365 367 stream: true, 366 368 } as const) 367 - return consumeCompletionStream(stream as AsyncIterable<OpenAI.Chat.ChatCompletionChunk>) 369 + const result = await consumeCompletionStream(stream as AsyncIterable<OpenAI.Chat.ChatCompletionChunk>) 370 + recordPromptResponse(request, result, promptMetricId) 371 + return result 368 372 } 369 373 throw err 370 374 } ··· 454 458 function addAssistantMessage(convId: number, state: LoopState, msg: OpenAI.Chat.ChatCompletionMessage): void { 455 459 state.conversation.push(msg) 456 460 logMessage(convId, msg.role, msg.content ?? "", msg.tool_calls ?? undefined) 461 + } 462 + 463 + function recordPromptResponse(request: CompletionRequest, result: CompletionTurnResult, promptMetricId: number | null): void { 464 + recordMetric({ 465 + type: "prompt_response", 466 + promptMetricId: promptMetricId ?? undefined, 467 + model: request.model, 468 + toolChoice: request.tool_choice, 469 + messages: request.messages, 470 + response: result.message, 471 + usage: result.usage, 472 + }) 457 473 } 458 474 459 475 /**
+65 -3
src/server.ts
··· 11 11 import { fromCron } from "./triggers/cron.js" 12 12 import { fromChat } from "./triggers/chat.js" 13 13 import { subscribe } from "./stream.js" 14 - import { getMetrics, getMetricDetail } from "./metrics.js" 14 + import { getMetrics, getMetricDetail, getDiscordMetricDetail } from "./metrics.js" 15 + import type { MetricListType } from "./metrics.js" 15 16 import type { UserMessage } from "./types.js" 16 17 17 18 const SRC_DIR = dirname(fileURLToPath(import.meta.url)) ··· 25 26 Math.min(200, parseInt(process.env.DISCORD_BATCH_MAX_MESSAGES ?? "40", 10) || 40), 26 27 ) 27 28 const DISCORD_BATCH_SCAN = (process.env.DISCORD_BATCH_SCAN ?? "true").trim().toLowerCase() !== "false" 29 + const METRIC_LIST_TYPES = new Set<MetricListType>(["prompt_response", "summarization", "memory", "prompt", "usage", "discord"]) 30 + const METRIC_TYPE_ALIASES: Record<string, MetricListType> = { 31 + compaction: "summarization", 32 + memory: "memory", 33 + memories: "memory", 34 + summary: "summarization", 35 + summaries: "summarization", 36 + prompt_response: "prompt_response", 37 + "prompt-response": "prompt_response", 38 + completion: "prompt_response", 39 + } 40 + 41 + function parseMetricTypes(raw: string | undefined): MetricListType[] | undefined { 42 + if (!raw?.trim()) return undefined 43 + 44 + const types: MetricListType[] = [] 45 + for (const item of raw.split(",")) { 46 + const normalized = item.trim().toLowerCase() 47 + const type = METRIC_TYPE_ALIASES[normalized] ?? normalized 48 + if (!METRIC_LIST_TYPES.has(type as MetricListType)) continue 49 + if (!types.includes(type as MetricListType)) types.push(type as MetricListType) 50 + } 51 + return types.length ? types : undefined 52 + } 28 53 29 54 export function createServer() { 30 55 const app = Fastify({ logger: false }) ··· 179 204 })) 180 205 181 206 app.get("/metrics", async (req) => { 182 - const { limit } = req.query as { limit?: string } 183 - return getMetrics(limit ? parseInt(limit, 10) : 100) 207 + const query = req.query as { 208 + limit?: string 209 + cursor?: string 210 + type?: string 211 + includeRaw?: string 212 + q?: string 213 + from?: string 214 + to?: string 215 + cursor_memories?: string 216 + cursor_summarization?: string 217 + cursor_prompt_response?: string 218 + cursor_prompt?: string 219 + cursor_usage?: string 220 + cursor_discord?: string 221 + } 222 + return getMetrics({ 223 + limit: query.limit ? parseInt(query.limit, 10) : undefined, 224 + cursor: query.cursor ? parseInt(query.cursor, 10) : undefined, 225 + cursors: { 226 + memories: query.cursor_memories, 227 + summarization: query.cursor_summarization, 228 + prompt_response: query.cursor_prompt_response, 229 + prompt: query.cursor_prompt, 230 + usage: query.cursor_usage, 231 + discord: query.cursor_discord, 232 + }, 233 + type: parseMetricTypes(query.type), 234 + includeRaw: query.includeRaw === "true" || query.includeRaw === "1", 235 + q: query.q, 236 + from: query.from, 237 + to: query.to, 238 + }) 239 + }) 240 + 241 + app.get("/metrics/discord/:id", async (req, reply) => { 242 + const { id } = req.params as { id: string } 243 + const metric = getDiscordMetricDetail(id) 244 + if (!metric) return reply.code(404).send({ error: "discord metric not found" }) 245 + return metric 184 246 }) 185 247 186 248 app.get("/metrics/:id", async (req, reply) => {