my harness for niri
1import { useCallback, useEffect, useMemo, useState } from "react"
2import { MarkdownBlock } from "./MarkdownBlock"
3
4type Usage = {
5 prompt_tokens?: number
6 completion_tokens?: number
7 total_tokens?: number
8}
9
10type BaseMetric = {
11 id: number
12 sourceType: string
13 timestamp: string
14 detailPath: string
15}
16
17type MemoryMetric = BaseMetric & {
18 type: "memory"
19 queryPreview?: string
20 resultCount?: number
21}
22
23type PromptMetric = BaseMetric & {
24 type: "prompt"
25 messageCount?: number
26 lastUserMessage?: string
27}
28
29type ResponseMetric = BaseMetric & {
30 type: "response"
31 promptMetricId?: number
32 model?: string
33 toolChoice?: string
34 messageCount?: number
35 lastUserMessage?: string
36 responsePreview?: string
37 toolCallCount?: number
38 usage?: Usage
39}
40
41type UsageMetric = BaseMetric & {
42 type: "usage"
43 usage?: Usage
44}
45
46type 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
56type 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
72type MetricItem = MemoryMetric | PromptMetric | ResponseMetric | UsageMetric | SummarizationMetric
73
74type MetricsPage = {
75 memories: MemoryMetric[]
76 summarization: SummarizationMetric[]
77 response: ResponseMetric[]
78 prompt: PromptMetric[]
79 usage: UsageMetric[]
80 discord: DiscordMetric[]
81 limit: number
82 nextCursor: Record<string, string | number | undefined>
83 hasMore: Record<string, boolean | undefined>
84}
85
86type MetricsPageInput = Partial<MetricsPage>
87
88type 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
98type MemoryDetail = {
99 id: number
100 type: "memory"
101 timestamp: string
102 query: string
103 results: MemorySearchResult[]
104}
105
106type Message = {
107 role?: string
108 content?: unknown
109 tool_call_id?: string
110 tool_calls?: unknown
111}
112
113type PromptDetail = {
114 id: number
115 type: "prompt" | "prompt_response"
116 timestamp: string
117 messages?: Message[]
118 response?: Message
119}
120
121type TurnDetail = {
122 id: number
123 timestamp: string
124 model?: string
125 usage?: Usage
126 promptText: string
127 responseText?: string
128 toolTraces: ToolTrace[]
129}
130
131type DetailState =
132 | { kind: "idle" }
133 | { kind: "loading"; label: string }
134 | { kind: "error"; text: string }
135 | { kind: "memory"; memory: MemoryDetail; prompt?: PromptDetail }
136 | { kind: "turn"; turn: TurnDetail }
137 | { kind: "metric"; metric: unknown }
138
139type MemoryPair = {
140 memory: MemoryMetric
141 prompt?: PromptMetric | ResponseMetric
142 secondsApart?: number
143 overlap: number
144 shared: string[]
145 issue: "ok" | "loose" | "missing"
146}
147
148type ToolTrace = {
149 id: string
150 name: string
151 args: string
152 result?: string
153}
154
155const baseUrl = import.meta.env.VITE_NIRI_BASE_URL ?? ""
156const METRICS_POLL_INTERVAL_MS = 5_000
157
158const stopWords = new Set([
159 "a",
160 "an",
161 "and",
162 "are",
163 "as",
164 "at",
165 "be",
166 "but",
167 "by",
168 "for",
169 "from",
170 "have",
171 "i",
172 "in",
173 "is",
174 "it",
175 "me",
176 "my",
177 "of",
178 "on",
179 "or",
180 "that",
181 "the",
182 "this",
183 "to",
184 "was",
185 "with",
186 "you",
187 "your",
188])
189
190const formatNumber = (value: number | undefined): string =>
191 typeof value === "number" && Number.isFinite(value) ? value.toLocaleString() : "0"
192
193const timeLabel = (iso: string): string => {
194 const date = new Date(iso)
195 if (Number.isNaN(date.getTime())) return iso
196 return new Intl.DateTimeFormat(undefined, {
197 month: "short",
198 day: "2-digit",
199 hour: "2-digit",
200 minute: "2-digit",
201 }).format(date)
202}
203
204const shortTime = (iso: string): string => {
205 const date = new Date(iso)
206 if (Number.isNaN(date.getTime())) return iso
207 return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(date)
208}
209
210const textContent = (content: unknown): string => {
211 if (typeof content === "string") return content
212 if (!Array.isArray(content)) return ""
213 return content
214 .flatMap((part) => {
215 if (!part || typeof part !== "object") return []
216 const record = part as Record<string, unknown>
217 return typeof record.text === "string" ? [record.text] : []
218 })
219 .join("\n")
220}
221
222const toolResultMarkdown = (result: string): string => {
223 const trimmed = result.trim()
224 if (!trimmed) return "```text\n(no output)\n```"
225 if (/^(```|#{1,6}\s|- |\* |\d+\. |> |\|)/m.test(trimmed)) return trimmed
226 return `\`\`\`text\n${trimmed}\n\`\`\``
227}
228
229const extractToolTraces = (prompt: PromptDetail | undefined): ToolTrace[] => {
230 const messages = [...(prompt?.messages ?? []), ...(prompt?.response ? [prompt.response] : [])]
231 const traces: ToolTrace[] = []
232 const byId = new Map<string, ToolTrace>()
233
234 for (const message of messages) {
235 if (Array.isArray(message.tool_calls)) {
236 for (const rawCall of message.tool_calls) {
237 if (!rawCall || typeof rawCall !== "object") continue
238 const call = rawCall as Record<string, unknown>
239 const fn = call.function && typeof call.function === "object" ? (call.function as Record<string, unknown>) : {}
240 const id = typeof call.id === "string" ? call.id : `tool-${traces.length + 1}`
241 const trace: ToolTrace = {
242 id,
243 name: typeof fn.name === "string" ? fn.name : "tool",
244 args: typeof fn.arguments === "string" ? fn.arguments : "",
245 }
246 traces.push(trace)
247 byId.set(id, trace)
248 }
249 }
250
251 if (message.role === "tool") {
252 const id = typeof message.tool_call_id === "string" ? message.tool_call_id : ""
253 const result = textContent(message.content)
254 const existing = byId.get(id)
255 if (existing) {
256 existing.result = result
257 } else {
258 traces.push({
259 id: id || `tool-result-${traces.length + 1}`,
260 name: "tool result",
261 args: "",
262 result,
263 })
264 }
265 }
266 }
267
268 return traces
269}
270
271const lastUserMessage = (messages: Message[] | undefined): string => {
272 if (!messages) return ""
273 for (let i = messages.length - 1; i >= 0; i--) {
274 const message = messages[i]
275 if (message?.role === "user") {
276 const text = textContent(message.content).trim()
277 if (text) return text
278 }
279 }
280 return ""
281}
282
283const tokens = (value: string | undefined): string[] => {
284 if (!value) return []
285 return value
286 .toLowerCase()
287 .replace(/https?:\/\/\S+/g, " ")
288 .replace(/[^a-z0-9\s'-]+/g, " ")
289 .split(/\s+/)
290 .map((token) => token.replace(/^['-]+|['-]+$/g, ""))
291 .filter((token) => token.length > 2 && !stopWords.has(token))
292}
293
294const overlapFor = (left: string | undefined, right: string | undefined): { score: number; shared: string[] } => {
295 const leftTokens = new Set(tokens(left))
296 const rightTokens = new Set(tokens(right))
297 if (leftTokens.size === 0 || rightTokens.size === 0) return { score: 0, shared: [] }
298
299 const shared = [...leftTokens].filter((token) => rightTokens.has(token))
300 return {
301 score: shared.length / Math.max(1, Math.min(leftTokens.size, rightTokens.size)),
302 shared: shared.slice(0, 8),
303 }
304}
305
306const fetchJson = async <T,>(path: string, signal?: AbortSignal): Promise<T> => {
307 const res = await fetch(`${baseUrl}${path}`, { signal })
308 if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim())
309 return (await res.json()) as T
310}
311
312const normalizeMetricsPage = (page: MetricsPageInput): MetricsPage => ({
313 memories: Array.isArray(page.memories) ? page.memories : [],
314 summarization: Array.isArray(page.summarization) ? page.summarization : [],
315 response: Array.isArray(page.response) ? page.response : [],
316 prompt: Array.isArray(page.prompt) ? page.prompt : [],
317 usage: Array.isArray(page.usage) ? page.usage : [],
318 discord: Array.isArray(page.discord) ? page.discord : [],
319 limit: typeof page.limit === "number" ? page.limit : 100,
320 nextCursor: page.nextCursor ?? {},
321 hasMore: page.hasMore ?? {},
322})
323
324function buildMetricsUrl(search: string): string {
325 const params = new URLSearchParams()
326 params.set("limit", "100")
327 if (search.trim()) params.set("q", search.trim())
328 return `/metrics?${params.toString()}`
329}
330
331function closestResponsePath(timestamp: string, responses: ResponseMetric[]): string | undefined {
332 const usageTime = new Date(timestamp).getTime()
333 if (!Number.isFinite(usageTime)) return undefined
334
335 let best: ResponseMetric | undefined
336 let bestDelta = Number.POSITIVE_INFINITY
337 for (const r of responses) {
338 const t = new Date(r.timestamp).getTime()
339 if (!Number.isFinite(t) || t > usageTime) continue
340 const delta = usageTime - t
341 if (delta < bestDelta) {
342 best = r
343 bestDelta = delta
344 }
345 }
346 return best?.detailPath
347}
348
349function TokenTrace({
350 usage,
351 responses,
352 onOpenTurn,
353 latestPromptText,
354}: {
355 usage: UsageMetric[]
356 responses: ResponseMetric[]
357 onOpenTurn: (path: string) => void
358 latestPromptText?: string
359}) {
360 const points = useMemo(() => {
361 const usagePoints = usage.map((item) => ({
362 id: `u-${item.id}`,
363 timestamp: item.timestamp,
364 usage: item.usage,
365 model: undefined as string | undefined,
366 detailPath: closestResponsePath(item.timestamp, responses) ?? item.detailPath,
367 }))
368 const responsePoints = responses
369 .filter((item) => item.usage)
370 .map((item) => ({
371 id: `r-${item.id}`,
372 timestamp: item.timestamp,
373 usage: item.usage,
374 model: item.model,
375 detailPath: item.detailPath,
376 }))
377 return [...usagePoints, ...responsePoints]
378 .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
379 .slice(-80)
380 }, [responses, usage])
381
382 const maxTotal = Math.max(1, ...points.map((point) => point.usage?.total_tokens ?? 0))
383 const latest = points[points.length - 1]
384 const totals = points.reduce(
385 (sum, point) => ({
386 prompt: sum.prompt + (point.usage?.prompt_tokens ?? 0),
387 completion: sum.completion + (point.usage?.completion_tokens ?? 0),
388 total: sum.total + (point.usage?.total_tokens ?? 0),
389 }),
390 { prompt: 0, completion: 0, total: 0 },
391 )
392 const average = points.length ? Math.round(totals.total / points.length) : 0
393
394 return (
395 <section className="metric-panel metric-token-panel" aria-label="token usage">
396 <div className="panel-head">
397 <div>
398 <h2>Token Usage</h2>
399 <p>{points.length ? `${points.length} recent completions` : "No usage rows yet"}</p>
400 </div>
401 <div className="token-readout">
402 <span>{formatNumber(latest?.usage?.total_tokens)}</span>
403 <small>latest total</small>
404 </div>
405 </div>
406
407 <div className="token-strip" aria-label="recent token totals">
408 {points.map((point) => {
409 const prompt = point.usage?.prompt_tokens ?? 0
410 const completion = point.usage?.completion_tokens ?? 0
411 const total = point.usage?.total_tokens ?? prompt + completion
412 return (
413 <button
414 key={point.id}
415 className="token-bar"
416 type="button"
417 onClick={() => onOpenTurn(point.detailPath)}
418 title={`${timeLabel(point.timestamp)} total ${formatNumber(total)} prompt ${formatNumber(prompt)} completion ${formatNumber(completion)}`}
419 style={{ height: `${Math.max(8, Math.round((total / maxTotal) * 100))}%` }}
420 >
421 <span className="token-bar-prompt" style={{ height: `${total ? (prompt / total) * 100 : 0}%` }} />
422 <span className="token-bar-completion" style={{ height: `${total ? (completion / total) * 100 : 0}%` }} />
423 </button>
424 )
425 })}
426 </div>
427
428 <div className="token-ledger">
429 <div>
430 <span>{formatNumber(totals.prompt)}</span>
431 <small>prompt</small>
432 </div>
433 <div>
434 <span>{formatNumber(totals.completion)}</span>
435 <small>completion</small>
436 </div>
437 <div>
438 <span>{formatNumber(average)}</span>
439 <small>avg total</small>
440 </div>
441 </div>
442
443 {latestPromptText && (
444 <div className="latest-prompt">
445 <small>latest prompt</small>
446 <p>{latestPromptText}</p>
447 </div>
448 )}
449 </section>
450 )
451}
452
453function MemoryReview({
454 pairs,
455 selectedId,
456 reviewOnly,
457 onReviewOnlyChange,
458 onSelect,
459}: {
460 pairs: MemoryPair[]
461 selectedId?: number
462 reviewOnly: boolean
463 onReviewOnlyChange: (value: boolean) => void
464 onSelect: (pair: MemoryPair) => void
465}) {
466 const visible = reviewOnly ? pairs.filter((pair) => pair.issue !== "ok") : pairs
467
468 return (
469 <section className="metric-panel memory-panel" aria-label="memory prompt alignment">
470 <div className="panel-head">
471 <div>
472 <h2>Memory Fit</h2>
473 <p>{visible.length} recalls matched against nearby prompts</p>
474 </div>
475 <label className="switch-row">
476 <input type="checkbox" checked={reviewOnly} onChange={(event) => onReviewOnlyChange(event.target.checked)} />
477 review only
478 </label>
479 </div>
480
481 <div className="memory-table" role="table">
482 <div className="memory-row memory-row-head" role="row">
483 <span>time</span>
484 <span>fit</span>
485 <span>memory query</span>
486 <span>near prompt</span>
487 </div>
488 {visible.map((pair) => (
489 <button
490 key={pair.memory.id}
491 type="button"
492 className={`memory-row ${selectedId === pair.memory.id ? "is-selected" : ""} issue-${pair.issue}`}
493 onClick={() => onSelect(pair)}
494 role="row"
495 >
496 <span>{shortTime(pair.memory.timestamp)}</span>
497 <span>
498 {pair.issue === "missing" ? "no prompt" : `${Math.round(pair.overlap * 100)}%`}
499 {pair.secondsApart != null ? <small>{Math.abs(pair.secondsApart)}s</small> : null}
500 </span>
501 <span>{pair.memory.queryPreview ?? "empty memory query"}</span>
502 <span>{pair.prompt?.lastUserMessage ?? "no matching prompt"}</span>
503 </button>
504 ))}
505 </div>
506 </section>
507 )
508}
509
510function DetailPane({ detail }: { detail: DetailState }) {
511 if (detail.kind === "idle") {
512 return (
513 <aside className="detail-pane">
514 <h2>Turn Detail</h2>
515 <p>Click a bar in the chart or a response in the rail to inspect the turn.</p>
516 </aside>
517 )
518 }
519
520 if (detail.kind === "loading") {
521 return (
522 <aside className="detail-pane">
523 <h2>{detail.label}</h2>
524 <p>Loading.</p>
525 </aside>
526 )
527 }
528
529 if (detail.kind === "error") {
530 return (
531 <aside className="detail-pane detail-error">
532 <h2>Error</h2>
533 <p>{detail.text}</p>
534 </aside>
535 )
536 }
537
538 if (detail.kind === "turn") {
539 const { turn } = detail
540 return (
541 <aside className="detail-pane">
542 <h2>Turn #{turn.id}</h2>
543 <dl className="detail-meta">
544 {turn.model ? <div><dt>model</dt><dd>{turn.model}</dd></div> : null}
545 <div><dt>time</dt><dd>{timeLabel(turn.timestamp)}</dd></div>
546 {turn.usage ? (
547 <>
548 <div><dt>prompt</dt><dd>{formatNumber(turn.usage.prompt_tokens)} tok</dd></div>
549 <div><dt>completion</dt><dd>{formatNumber(turn.usage.completion_tokens)} tok</dd></div>
550 </>
551 ) : null}
552 </dl>
553
554 <section className="detail-section">
555 <h3>Prompt</h3>
556 <MarkdownBlock content={turn.promptText || "(no prompt)"} />
557 </section>
558
559 {turn.toolTraces.length > 0 ? (
560 <section className="detail-section">
561 <h3>Tool Calls ({turn.toolTraces.length})</h3>
562 <div className="tool-trace-list">
563 {turn.toolTraces.map((tool) => (
564 <article key={tool.id} className="tool-trace">
565 <details>
566 <summary>
567 <span>{tool.name}</span>
568 <small>{tool.id}</small>
569 </summary>
570 {tool.args ? <pre className="tool-args">{tool.args}</pre> : null}
571 {tool.result !== undefined ? (
572 <div className="tool-result">
573 <MarkdownBlock content={toolResultMarkdown(tool.result)} />
574 </div>
575 ) : null}
576 </details>
577 </article>
578 ))}
579 </div>
580 </section>
581 ) : null}
582
583 {turn.responseText ? (
584 <section className="detail-section">
585 <h3>Response</h3>
586 <MarkdownBlock content={turn.responseText} />
587 </section>
588 ) : null}
589 </aside>
590 )
591 }
592
593 if (detail.kind === "metric") {
594 return (
595 <aside className="detail-pane">
596 <h2>Metric Detail</h2>
597 <pre>{JSON.stringify(detail.metric, null, 2)}</pre>
598 </aside>
599 )
600 }
601
602 const promptText = lastUserMessage(detail.prompt?.messages)
603
604 return (
605 <aside className="detail-pane">
606 <h2>Recall #{detail.memory.id}</h2>
607 <dl className="detail-meta">
608 <div>
609 <dt>time</dt>
610 <dd>{timeLabel(detail.memory.timestamp)}</dd>
611 </div>
612 <div>
613 <dt>chunks</dt>
614 <dd>{detail.memory.results.length}</dd>
615 </div>
616 </dl>
617
618 <section className="detail-section">
619 <h3>Prompt</h3>
620 <MarkdownBlock content={promptText || detail.memory.query} />
621 </section>
622
623 <section className="detail-section">
624 <h3>Memory Query</h3>
625 <MarkdownBlock content={detail.memory.query} />
626 </section>
627
628 <section className="detail-section">
629 <h3>Retrieved Chunks</h3>
630 {detail.memory.results.map((result) => (
631 <article key={result.chunkId} className="memory-hit">
632 <div>
633 <strong>{result.title}</strong>
634 <span>{result.kind} / {result.source}</span>
635 </div>
636 <p>{result.preview}</p>
637 </article>
638 ))}
639 </section>
640 </aside>
641 )
642}
643
644function BucketRail({
645 metrics,
646 onOpenMetric,
647}: {
648 metrics: MetricsPage
649 onOpenMetric: (path: string) => void
650}) {
651 const rows: Array<{ label: string; count: number; items: Array<MetricItem | DiscordMetric> }> = [
652 { label: "response", count: metrics.response.length, items: metrics.response.slice(0, 6) },
653 { label: "summarization", count: metrics.summarization.length, items: metrics.summarization.slice(0, 6) },
654 { label: "discord", count: metrics.discord.length, items: metrics.discord.slice(0, 6) },
655 ]
656
657 return (
658 <section className="bucket-rail" aria-label="raw metric buckets">
659 {rows.map((bucket) => (
660 <section key={bucket.label}>
661 <header>
662 <h3>{bucket.label}</h3>
663 <span>{bucket.count}</span>
664 </header>
665 <div className="bucket-list">
666 {bucket.items.map((item) => (
667 <button key={`${item.type}-${item.id}`} type="button" onClick={() => onOpenMetric(item.detailPath)}>
668 <span>{shortTime(item.timestamp)}</span>
669 <strong>
670 {item.type === "response"
671 ? item.responsePreview || `${item.model ?? "model"}`
672 : item.type === "summarization"
673 ? item.summaryPreview || item.method || "summary"
674 : item.type === "discord"
675 ? item.contentPreview || item.authorUsername || "discord"
676 : item.type}
677 </strong>
678 </button>
679 ))}
680 </div>
681 </section>
682 ))}
683 </section>
684 )
685}
686
687export function MetricsWorkbench() {
688 const [metrics, setMetrics] = useState<MetricsPage | null>(null)
689 const [error, setError] = useState<string | null>(null)
690 const [loading, setLoading] = useState(true)
691 const [search, setSearch] = useState("")
692 const [query, setQuery] = useState("")
693 const [reviewOnly, setReviewOnly] = useState(false)
694 const [detail, setDetail] = useState<DetailState>({ kind: "idle" })
695 const [live, setLive] = useState(true)
696 const [lastUpdated, setLastUpdated] = useState<string | null>(null)
697
698 const loadMetrics = useCallback((signal?: AbortSignal, options?: { silent?: boolean }) => {
699 if (!options?.silent) setLoading(true)
700 setError(null)
701 fetchJson<MetricsPageInput>(buildMetricsUrl(query), signal)
702 .then((page) => {
703 setMetrics(normalizeMetricsPage(page))
704 setLastUpdated(new Date().toISOString())
705 })
706 .catch((err) => {
707 if (signal?.aborted) return
708 setError(err instanceof Error ? err.message : String(err))
709 })
710 .finally(() => {
711 if (!signal?.aborted) setLoading(false)
712 })
713 }, [query])
714
715 useEffect(() => {
716 const controller = new AbortController()
717 loadMetrics(controller.signal)
718 let interval: ReturnType<typeof setInterval> | undefined
719 let pollController: AbortController | null = null
720
721 if (live) {
722 interval = setInterval(() => {
723 pollController?.abort()
724 pollController = new AbortController()
725 loadMetrics(pollController.signal, { silent: true })
726 }, METRICS_POLL_INTERVAL_MS)
727 }
728
729 return () => {
730 controller.abort()
731 pollController?.abort()
732 if (interval) clearInterval(interval)
733 }
734 }, [live, loadMetrics])
735
736 const pairs = useMemo<MemoryPair[]>(() => {
737 if (!metrics) return []
738 const prompts = [...metrics.response, ...metrics.prompt]
739 .filter((prompt) => prompt.lastUserMessage)
740 .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
741
742 return metrics.memories.map((memory) => {
743 const memoryTime = new Date(memory.timestamp).getTime()
744 const prompt = prompts.find((item) => new Date(item.timestamp).getTime() >= memoryTime) ?? prompts[prompts.length - 1]
745 const promptTime = prompt ? new Date(prompt.timestamp).getTime() : Number.NaN
746 const secondsApart = prompt && Number.isFinite(promptTime) ? Math.round((promptTime - memoryTime) / 1000) : undefined
747 const overlap = overlapFor(memory.queryPreview, prompt?.lastUserMessage)
748 const issue = !prompt ? "missing" : overlap.score < 0.16 ? "loose" : "ok"
749 return { memory, prompt, secondsApart, overlap: overlap.score, shared: overlap.shared, issue }
750 })
751 }, [metrics])
752
753 const latestPromptText = useMemo(() => {
754 return metrics?.response[0]?.lastUserMessage ?? undefined
755 }, [metrics])
756
757 const selectedMemoryId = detail.kind === "memory" ? detail.memory.id : undefined
758
759 const selectPair = useCallback(async (pair: MemoryPair) => {
760 setDetail({ kind: "loading", label: `Recall #${pair.memory.id}` })
761 try {
762 const [memory, prompt] = await Promise.all([
763 fetchJson<MemoryDetail>(pair.memory.detailPath),
764 pair.prompt ? fetchJson<PromptDetail>(pair.prompt.detailPath) : Promise.resolve(undefined),
765 ])
766 setDetail({ kind: "memory", memory, prompt })
767 } catch (err) {
768 setDetail({ kind: "error", text: err instanceof Error ? err.message : String(err) })
769 }
770 }, [])
771
772 const openMetric = useCallback(async (path: string) => {
773 setDetail({ kind: "loading", label: "Turn detail" })
774 try {
775 const raw = await fetchJson<Record<string, unknown>>(path)
776 if (raw?.type === "prompt_response") {
777 const msgs = Array.isArray(raw.messages) ? (raw.messages as Message[]) : []
778 const response = raw.response as Message | undefined
779 const promptText = lastUserMessage(msgs)
780 const responseText = textContent(response?.content) || undefined
781 const fakeDetail: PromptDetail = {
782 id: raw.id as number,
783 type: "prompt_response",
784 timestamp: raw.timestamp as string,
785 messages: msgs,
786 response,
787 }
788 setDetail({
789 kind: "turn",
790 turn: {
791 id: raw.id as number,
792 timestamp: raw.timestamp as string,
793 model: typeof raw.model === "string" ? raw.model : undefined,
794 usage: raw.usage as Usage | undefined,
795 promptText: promptText || "(no prompt)",
796 responseText,
797 toolTraces: extractToolTraces(fakeDetail),
798 },
799 })
800 } else {
801 setDetail({ kind: "metric", metric: raw })
802 }
803 } catch (err) {
804 setDetail({ kind: "error", text: err instanceof Error ? err.message : String(err) })
805 }
806 }, [])
807
808 return (
809 <main className="metrics-app">
810 <header className="metrics-header">
811 <div>
812 <h1>Metrics</h1>
813 <p>Token pressure and memory retrieval checks.</p>
814 </div>
815 <form
816 className="metrics-search"
817 onSubmit={(event) => {
818 event.preventDefault()
819 setQuery(search.trim())
820 }}
821 >
822 <input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="filter metrics" aria-label="filter metrics" />
823 <button type="submit">filter</button>
824 <button type="button" onClick={() => loadMetrics()}>
825 refresh
826 </button>
827 <button type="button" onClick={() => {
828 setSearch("")
829 setQuery("")
830 }}>
831 clear
832 </button>
833 </form>
834 <div className="live-controls">
835 <button type="button" className={live ? "is-live" : ""} onClick={() => setLive((value) => !value)}>
836 {live ? "live" : "paused"}
837 </button>
838 <span>{lastUpdated ? `updated ${shortTime(lastUpdated)}` : "not updated yet"}</span>
839 </div>
840 </header>
841
842 {error ? <p className="metrics-error">metrics unavailable: {error}</p> : null}
843 {loading && !metrics ? <p className="metrics-loading">loading metrics</p> : null}
844
845 {metrics ? (
846 <div className="metrics-grid">
847 <div className="metrics-main">
848 <TokenTrace
849 usage={metrics.usage}
850 responses={metrics.response}
851 onOpenTurn={openMetric}
852 latestPromptText={latestPromptText}
853 />
854 <MemoryReview
855 pairs={pairs}
856 selectedId={selectedMemoryId}
857 reviewOnly={reviewOnly}
858 onReviewOnlyChange={setReviewOnly}
859 onSelect={selectPair}
860 />
861 </div>
862 <div className="metrics-side">
863 <DetailPane detail={detail} />
864 <BucketRail metrics={metrics} onOpenMetric={openMetric} />
865 </div>
866 </div>
867 ) : null}
868 </main>
869 )
870}