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