feat: turn JSONL logging with live web viewer (v2) (#567)

Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Cameron
2026-03-11 23:24:54 -07:00
committed by GitHub
parent 593b28892c
commit 6ecd78e294
10 changed files with 1098 additions and 5 deletions

1
.gitignore vendored
View File

@@ -60,3 +60,4 @@ bun.lock
# Telegram MTProto session data (contains auth secrets)
data/telegram-mtproto/
logs/

View File

@@ -16,7 +16,7 @@
"setup": "tsx src/setup.ts",
"dev": "tsx src/main.ts",
"build": "tsc",
"postbuild": "cp -r src/looms/*.txt dist/looms/ && cp src/api/portal.html dist/api/portal.html && node scripts/fix-bin-permissions.mjs",
"postbuild": "cp -r src/looms/*.txt dist/looms/ && cp src/api/portal.html dist/api/portal.html && cp src/core/turn-viewer.html dist/core/ && node scripts/fix-bin-permissions.mjs",
"prepare": "npx patch-package || true",
"prepublishOnly": "npm run build && npm run test:run",
"start": "node dist/main.js",

View File

@@ -5,6 +5,8 @@
import * as http from 'http';
import * as fs from 'fs';
import { readFile } from 'node:fs/promises';
import * as crypto from 'node:crypto';
import { validateApiKey } from './auth.js';
import type { SendMessageResponse, ChatRequest, ChatResponse, AsyncChatResponse, PairingListResponse, PairingApproveRequest, PairingApproveResponse } from './types.js';
import { listPairingRequests, approvePairingCode } from '../pairing/store.js';
@@ -18,6 +20,7 @@ import {
buildErrorResponse, buildModelList, validateChatRequest,
} from './openai-compat.js';
import type { OpenAIChatRequest } from './openai-compat.js';
import { getTurnViewerHtml } from '../core/turn-viewer.js';
import { createLogger } from '../logger.js';
@@ -38,8 +41,9 @@ type ResolvedChatRequest = {
interface ServerOptions {
port: number;
apiKey: string;
host?: string; // Bind address (default: 127.0.0.1 for security)
host?: string; // Bind address (default: 127.0.0.1 for security)
corsOrigin?: string; // CORS origin (default: same-origin only)
turnLogFiles?: Record<string, string>; // agentName -> filePath; enables GET /turns viewer
stores?: Map<string, Store>; // Agent stores for management endpoints
agentChannels?: Map<string, string[]>; // Channel IDs per agent name
sessionInvalidators?: Map<string, (key?: string) => void>; // Invalidate live sessions after store writes
@@ -49,6 +53,155 @@ interface ServerOptions {
* Create and start the HTTP API server
*/
export function createApiServer(deliverer: AgentRouter, options: ServerOptions): http.Server {
// ── Turn viewer SSE infrastructure ──────────────────────────────────────
interface SSEClient {
res: http.ServerResponse;
sentCount: number;
lastTurnId?: string;
}
const sseClientsByAgent = new Map<string, Set<SSEClient>>();
const broadcastQueues = new Map<string, Promise<void>>();
function getTurnId(turn: unknown): string | undefined {
if (!turn || typeof turn !== 'object') return undefined;
const id = (turn as { turnId?: unknown }).turnId;
return typeof id === 'string' && id.trim() ? id : undefined;
}
async function readTurns(filePath: string): Promise<unknown[]> {
try {
const content = await readFile(filePath, 'utf8');
return content.split('\n').filter(l => l.trim()).map(l => {
try { return JSON.parse(l); } catch { return null; }
}).filter(Boolean);
} catch { return []; }
}
async function broadcastNewTurns(agentName: string, filePath: string): Promise<void> {
const clients = sseClientsByAgent.get(agentName);
if (!clients || clients.size === 0) return;
const allTurns = await readTurns(filePath);
const currentLastTurnId = getTurnId(allTurns[allTurns.length - 1]);
for (const client of clients) {
// If the log was cleared, reset the client snapshot and UI.
if (allTurns.length === 0) {
const shouldReset = client.sentCount !== 0 || !!client.lastTurnId;
client.sentCount = 0;
client.lastTurnId = undefined;
if (shouldReset) {
const payload = `data: ${JSON.stringify({ type: 'init', turns: [] })}\n\n`;
try { client.res.write(payload); } catch { clients.delete(client); }
}
continue;
}
if (client.lastTurnId === currentLastTurnId && client.sentCount === allTurns.length) {
continue;
}
let turnsToAppend: unknown[] | null = null;
if (client.lastTurnId) {
let previousIndex = -1;
for (let i = allTurns.length - 1; i >= 0; i--) {
if (getTurnId(allTurns[i]) === client.lastTurnId) {
previousIndex = i;
break;
}
}
if (previousIndex >= 0) {
turnsToAppend = allTurns.slice(previousIndex + 1);
}
} else if (client.sentCount < allTurns.length) {
// Fallback for older records without turnId.
turnsToAppend = allTurns.slice(client.sentCount);
}
const appendTurns = turnsToAppend ?? [];
const shouldResync = turnsToAppend === null
|| (appendTurns.length === 0 && (client.sentCount !== allTurns.length || client.lastTurnId !== currentLastTurnId));
const payload = shouldResync
? `data: ${JSON.stringify({ type: 'init', turns: allTurns })}\n\n`
: appendTurns.length > 0
? `data: ${JSON.stringify({ type: 'append', turns: appendTurns })}\n\n`
: null;
if (payload) {
try {
client.res.write(payload);
} catch {
clients.delete(client);
continue;
}
}
client.sentCount = allTurns.length;
client.lastTurnId = currentLastTurnId;
}
}
function enqueueBroadcast(agentName: string, filePath: string): void {
const prev = broadcastQueues.get(filePath) ?? Promise.resolve();
const next = prev.then(() => broadcastNewTurns(agentName, filePath)).catch(() => {});
broadcastQueues.set(filePath, next);
}
const watchers = new Map<string, fs.FSWatcher>();
function ensureWatching(agentName: string, filePath: string): void {
if (watchers.has(filePath)) return;
let watcher: fs.FSWatcher;
try {
watcher = fs.watch(filePath, { persistent: false }, (eventType) => {
if (eventType === 'rename') {
// Inode replaced (trim via atomic rename on Linux). Restart watcher.
watcher.close();
watchers.delete(filePath);
setTimeout(() => {
const clients = sseClientsByAgent.get(agentName);
if (clients && clients.size > 0) {
enqueueBroadcast(agentName, filePath);
ensureWatching(agentName, filePath);
}
}, 200);
return;
}
enqueueBroadcast(agentName, filePath);
});
} catch {
setTimeout(() => {
const clients = sseClientsByAgent.get(agentName);
if (clients && clients.size > 0) ensureWatching(agentName, filePath);
}, 2000);
return;
}
watcher.on('error', () => {
watcher.close();
watchers.delete(filePath);
// Auto-restart watcher after trim (inode replacement on Linux)
setTimeout(() => {
const clients = sseClientsByAgent.get(agentName);
if (clients && clients.size > 0) ensureWatching(agentName, filePath);
}, 500);
});
watchers.set(filePath, watcher);
}
function maybeUnwatch(filePath: string, clients: Set<SSEClient>): void {
if (clients.size === 0 && watchers.has(filePath)) {
watchers.get(filePath)!.close();
watchers.delete(filePath);
broadcastQueues.delete(filePath);
}
}
if (options.turnLogFiles) {
for (const agentName of Object.keys(options.turnLogFiles)) {
sseClientsByAgent.set(agentName, new Set());
}
}
const server = http.createServer(async (req, res) => {
// Set CORS headers (configurable origin, defaults to same-origin for security)
const corsOrigin = options.corsOrigin || req.headers.origin || 'null';
@@ -70,6 +223,63 @@ export function createApiServer(deliverer: AgentRouter, options: ServerOptions):
return;
}
// Turn viewer routes
if (options.turnLogFiles && req.method === 'GET') {
const agentNames = Object.keys(options.turnLogFiles);
const parsedUrl = new URL(req.url ?? '/', `http://localhost`);
const validateTurnAuth = (): boolean => {
if (validateApiKey(req.headers, options.apiKey)) return true;
const qKey = parsedUrl.searchParams.get('key') || '';
if (!qKey) return false;
const a = Buffer.from(qKey);
const b = Buffer.from(options.apiKey);
return a.length === b.length && crypto.timingSafeEqual(a, b);
};
if (parsedUrl.pathname === '/turns') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(getTurnViewerHtml(agentNames));
return;
}
if (parsedUrl.pathname === '/turns/data') {
if (!validateTurnAuth()) { sendError(res, 401, 'Unauthorized'); return; }
const agentName = parsedUrl.searchParams.get('agent') || agentNames[0];
const filePath = options.turnLogFiles[agentName];
if (!filePath) { res.writeHead(404); res.end('Unknown agent'); return; }
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(await readTurns(filePath)));
return;
}
if (parsedUrl.pathname === '/turns/stream') {
if (!validateTurnAuth()) { sendError(res, 401, 'Unauthorized'); return; }
const agentName = parsedUrl.searchParams.get('agent') || agentNames[0];
const filePath = options.turnLogFiles[agentName];
if (!filePath) { res.writeHead(404); res.end('Unknown agent'); return; }
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-store',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
const allTurns = await readTurns(filePath);
res.write(`data: ${JSON.stringify({ type: 'init', turns: allTurns })}\n\n`);
const clients = sseClientsByAgent.get(agentName)!;
const client: SSEClient = {
res,
sentCount: allTurns.length,
lastTurnId: getTurnId(allTurns[allTurns.length - 1]),
};
clients.add(client);
ensureWatching(agentName, filePath);
req.on('close', () => {
clients.delete(client);
maybeUnwatch(filePath, clients);
});
return;
}
}
// Route: POST /api/v1/messages (unified: supports both text and files)
if (req.url === '/api/v1/messages' && req.method === 'POST') {
try {

View File

@@ -102,6 +102,10 @@ export interface AgentConfig {
display?: DisplayConfig;
allowedTools?: string[]; // Per-agent tool whitelist (overrides global/env ALLOWED_TOOLS)
disallowedTools?: string[]; // Per-agent tool blocklist (overrides global/env DISALLOWED_TOOLS)
logging?: {
turnLogFile?: string; // Path to JSONL file for turn logging (one record per agent turn)
maxTurns?: number; // Max turns to retain in the log file (default: 1000, oldest trimmed)
};
};
/** Security settings */
security?: {
@@ -195,6 +199,10 @@ export interface LettaBotConfig {
display?: DisplayConfig; // Show tool calls / reasoning in channel output
allowedTools?: string[]; // Global tool whitelist (overridden by per-agent, falls back to ALLOWED_TOOLS env)
disallowedTools?: string[]; // Global tool blocklist (overridden by per-agent, falls back to DISALLOWED_TOOLS env)
logging?: {
turnLogFile?: string; // Global turn log file (overridden by per-agent)
maxTurns?: number; // Global maxTurns default (overridden by per-agent)
};
};
// Polling - system-level background checks (Gmail, etc.)

View File

@@ -10,7 +10,7 @@ import { access, unlink, realpath, stat, constants } from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { extname, resolve, join } from 'node:path';
import type { ChannelAdapter } from '../channels/types.js';
import type { BotConfig, InboundMessage, TriggerContext, StreamMsg } from './types.js';
import type { BotConfig, InboundMessage, TriggerContext, TriggerType, StreamMsg } from './types.js';
import { formatApiErrorForUser } from './errors.js';
import { formatToolCallDisplay, formatReasoningDisplay, formatQuestionsForChannel } from './display.js';
import type { AgentSession } from './interfaces.js';
@@ -24,6 +24,7 @@ import { parseDirectives, stripActionsBlock, type Directive } from './directives
import { resolveEmoji } from './emoji.js';
import { SessionManager } from './session-manager.js';
import { createDisplayPipeline, type DisplayEvent, type CompleteEvent, type ErrorEvent } from './display-pipeline.js';
import { TurnLogger, TurnAccumulator, generateTurnId, type TurnRecord } from './turn-logger.js';
import { createLogger } from '../logger.js';
@@ -233,11 +234,15 @@ export class LettaBot implements AgentSession {
private conversationOverrides: Set<string> = new Set();
private readonly sessionManager: SessionManager;
private readonly turnLogger: TurnLogger | null;
constructor(config: BotConfig) {
this.config = config;
mkdirSync(config.workingDir, { recursive: true });
this.store = new Store('lettabot-agent.json', config.agentName);
this.turnLogger = config.logging?.turnLogFile
? new TurnLogger(config.logging.turnLogFile, config.logging.maxTurns)
: null;
if (config.reuseSession === false) {
log.warn('Session reuse disabled (conversations.reuseSession=false): each foreground/background message uses a fresh SDK subprocess (~5s overhead per turn).');
}
@@ -1172,7 +1177,7 @@ export class LettaBot implements AgentSession {
private async processMessage(msg: InboundMessage, adapter: ChannelAdapter, retried = false): Promise<void> {
// Track timing and last target
const debugTiming = !!process.env.LETTABOT_DEBUG_TIMING;
const t0 = debugTiming ? performance.now() : 0;
const t0 = performance.now();
const lap = (label: string) => {
log.debug(`${label}: ${(performance.now() - t0).toFixed(0)}ms`);
};
@@ -1278,6 +1283,10 @@ export class LettaBot implements AgentSession {
adapter.sendTypingIndicator(msg.chatId).catch(() => {});
}, 4000);
const turnId = this.turnLogger ? generateTurnId() : '';
const turnAcc = this.turnLogger ? new TurnAccumulator() : null;
let turnWritten = false;
try {
let firstChunkLogged = false;
const pipeline = createDisplayPipeline(run.stream(), {
@@ -1286,6 +1295,7 @@ export class LettaBot implements AgentSession {
});
for await (const event of pipeline) {
turnAcc?.feed(event);
// Check for /cancel before processing each event
if (this.cancelledKeys.has(convKey)) {
log.info(`Stream cancelled by /cancel (key=${convKey})`);
@@ -1627,6 +1637,24 @@ export class LettaBot implements AgentSession {
} finally {
clearInterval(typingInterval);
adapter.stopTypingIndicator?.(msg.chatId)?.catch(() => {});
// Write turn record (even partial turns on cancel/error)
if (this.turnLogger && turnAcc && !turnWritten) {
turnWritten = true;
const { events, output } = turnAcc.finalize();
this.turnLogger.write({
ts: new Date().toISOString(),
turnId,
trigger: 'user_message' as const,
channel: msg.channel,
chatId: msg.chatId,
userId: msg.userId,
input: typeof messageToSend === 'string' ? messageToSend : '[multimodal]',
events,
output: output || response,
durationMs: Math.round(performance.now() - t0),
error: lastErrorDetail?.message,
}).catch(() => {});
}
}
lap('stream complete');
@@ -1784,8 +1812,14 @@ export class LettaBot implements AgentSession {
const convKey = this.resolveHeartbeatConversationKey();
const acquired = await this.acquireLock(convKey);
const sendT0 = performance.now();
const sendTurnId = this.turnLogger ? generateTurnId() : '';
const sendTurnAcc = this.turnLogger ? new TurnAccumulator() : null;
let sendTurnWritten = false;
try {
let retried = false;
while (true) {
const { stream } = await this.sessionManager.runSession(text, { convKey, retried });
@@ -1796,6 +1830,7 @@ export class LettaBot implements AgentSession {
let usedMessageCli = false;
let lastErrorDetail: StreamErrorDetail | undefined;
for await (const msg of stream()) {
sendTurnAcc?.feedRaw(msg);
if (msg.type === 'tool_call') {
this.sessionManager.syncTodoToolCall(msg);
if (isSilent && msg.toolName === 'Bash') {
@@ -1930,6 +1965,21 @@ export class LettaBot implements AgentSession {
if (this.config.reuseSession === false) {
this.sessionManager.invalidateSession(convKey);
}
// Write turn record
if (this.turnLogger && sendTurnAcc && !sendTurnWritten) {
sendTurnWritten = true;
const { events, output } = sendTurnAcc.finalize();
this.turnLogger.write({
ts: new Date().toISOString(),
turnId: sendTurnId,
trigger: context?.type ?? 'heartbeat',
channel: context?.sourceChannel,
input: text,
events,
output,
durationMs: Math.round(performance.now() - sendT0),
}).catch(() => {});
}
this.releaseLock(convKey, acquired);
}
}
@@ -1944,20 +1994,42 @@ export class LettaBot implements AgentSession {
): AsyncGenerator<StreamMsg> {
const convKey = this.resolveHeartbeatConversationKey();
const acquired = await this.acquireLock(convKey);
const streamT0 = performance.now();
const streamTurnId = this.turnLogger ? generateTurnId() : '';
const streamTurnAcc = this.turnLogger ? new TurnAccumulator() : null;
let streamTurnError: string | undefined;
try {
const { stream } = await this.sessionManager.runSession(text, { convKey });
try {
yield* stream();
for await (const msg of stream()) {
streamTurnAcc?.feedRaw(msg);
yield msg;
}
} catch (error) {
this.sessionManager.invalidateSession(convKey);
streamTurnError = error instanceof Error ? error.message : String(error);
throw error;
}
} finally {
if (this.config.reuseSession === false) {
this.sessionManager.invalidateSession(convKey);
}
if (this.turnLogger && streamTurnAcc) {
const { events, output } = streamTurnAcc.finalize();
this.turnLogger.write({
ts: new Date().toISOString(),
turnId: streamTurnId,
trigger: context?.type ?? 'heartbeat',
channel: context?.sourceChannel,
input: text,
events,
output,
durationMs: Math.round(performance.now() - streamT0),
error: streamTurnError,
}).catch(() => {});
}
this.releaseLock(convKey, acquired);
}
}

222
src/core/turn-logger.ts Normal file
View File

@@ -0,0 +1,222 @@
/**
* Turn logger -- writes one JSONL record per agent turn.
*
* TurnLogger handles file I/O with write serialization and bounded retention.
* TurnAccumulator collects DisplayEvents during a turn and produces a TurnRecord.
*/
import { appendFile, readFile, writeFile, rename } from 'node:fs/promises';
import { mkdirSync, readFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { randomUUID } from 'node:crypto';
import { createLogger } from '../logger.js';
import type { DisplayEvent } from './display-pipeline.js';
import type { TriggerType, StreamMsg } from './types.js';
const log = createLogger('TurnLogger');
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type TurnEvent =
| { type: 'reasoning'; content: string }
| { type: 'tool_call'; id: string; name: string; args: unknown }
| { type: 'tool_result'; id: string; content: string; isError: boolean };
export interface TurnRecord {
ts: string;
turnId: string;
trigger: TriggerType;
channel?: string;
chatId?: string;
userId?: string;
input: string;
events: TurnEvent[];
output: string;
durationMs?: number;
error?: string;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEFAULT_MAX_TURNS = 1000;
const MAX_TOOL_RESULT_BYTES = 4 * 1024;
// ---------------------------------------------------------------------------
// TurnAccumulator
// ---------------------------------------------------------------------------
/**
* Collects DisplayEvents during a single agent turn and produces a TurnRecord.
* Works with the DisplayPipeline's event types -- no raw StreamMsg handling.
*/
export class TurnAccumulator {
private _events: TurnEvent[] = [];
private _output = '';
// For feedRaw() only (sendToAgent/streamToAgent paths)
private _reasoningAcc = '';
private _lastRawType: string | null = null;
/** Feed a DisplayEvent from the pipeline. */
feed(event: DisplayEvent): void {
switch (event.type) {
case 'reasoning':
// Pipeline already accumulates and flushes reasoning chunks,
// so each reasoning event is a complete block.
this._events.push({ type: 'reasoning', content: event.content });
break;
case 'tool_call':
this._events.push({
type: 'tool_call',
id: event.id,
name: event.name,
args: event.args,
});
break;
case 'tool_result': {
const content = event.content.length > MAX_TOOL_RESULT_BYTES
? event.content.slice(0, MAX_TOOL_RESULT_BYTES) + '…[truncated]'
: event.content;
this._events.push({
type: 'tool_result',
id: event.toolCallId,
content,
isError: event.isError,
});
break;
}
case 'text':
this._output += event.delta;
break;
}
}
/**
* Feed a raw StreamMsg (for sendToAgent/streamToAgent which don't use DisplayPipeline).
* Handles reasoning accumulation inline since there's no pipeline to do it.
*/
feedRaw(msg: StreamMsg): void {
// Flush reasoning on transition away from reasoning
if (this._lastRawType === 'reasoning' && msg.type !== 'reasoning' && this._reasoningAcc.trim()) {
this._events.push({ type: 'reasoning', content: this._reasoningAcc.trim() });
this._reasoningAcc = '';
}
switch (msg.type) {
case 'reasoning':
this._reasoningAcc += msg.content || '';
break;
case 'tool_call':
this._events.push({
type: 'tool_call',
id: msg.toolCallId || '',
name: msg.toolName || 'unknown',
args: (msg as any).toolInput ?? (msg as any).rawArguments ?? null,
});
break;
case 'tool_result': {
const raw = (msg as any).content ?? '';
const str = typeof raw === 'string' ? raw : JSON.stringify(raw);
this._events.push({
type: 'tool_result',
id: msg.toolCallId || '',
content: str.length > MAX_TOOL_RESULT_BYTES
? str.slice(0, MAX_TOOL_RESULT_BYTES) + '…[truncated]'
: str,
isError: !!msg.isError,
});
break;
}
case 'assistant':
this._output += msg.content || '';
break;
}
if (msg.type !== 'stream_event') this._lastRawType = msg.type;
}
/** Return the accumulated events and output text. */
finalize(): { events: TurnEvent[]; output: string } {
// Flush trailing reasoning
if (this._reasoningAcc.trim()) {
this._events.push({ type: 'reasoning', content: this._reasoningAcc.trim() });
this._reasoningAcc = '';
}
return { events: this._events, output: this._output };
}
}
// ---------------------------------------------------------------------------
// TurnLogger
// ---------------------------------------------------------------------------
export class TurnLogger {
private filePath: string;
private maxTurns: number;
private ready = false;
private lineCount = 0;
private writeQueue = Promise.resolve();
constructor(filePath: string, maxTurns = DEFAULT_MAX_TURNS) {
this.filePath = filePath;
if (!Number.isInteger(maxTurns) || maxTurns <= 0) {
throw new Error(`TurnLogger: maxTurns must be a positive integer (got ${maxTurns})`);
}
this.maxTurns = maxTurns;
try {
mkdirSync(dirname(filePath), { recursive: true });
this.ready = true;
try {
const existing = readFileSync(filePath, 'utf8');
this.lineCount = existing.split('\n').filter(l => l.trim()).length;
} catch {
this.lineCount = 0;
}
} catch (err) {
log.warn(`Failed to create log directory for ${filePath}:`, err instanceof Error ? err.message : err);
}
}
/** Serialized write -- prevents concurrent trim races. */
async write(record: TurnRecord): Promise<void> {
if (!this.ready) return;
this.writeQueue = this.writeQueue.then(() => this._write(record)).catch(() => {});
return this.writeQueue;
}
private async _write(record: TurnRecord): Promise<void> {
try {
await appendFile(this.filePath, JSON.stringify(record) + '\n');
this.lineCount++;
if (this.lineCount > this.maxTurns) {
await this.trim();
}
} catch (err) {
log.warn(`Failed to write turn record:`, err instanceof Error ? err.message : err);
}
}
private async trim(): Promise<void> {
try {
const content = await readFile(this.filePath, 'utf8');
const lines = content.split('\n').filter(l => l.trim());
const trimmed = lines.slice(lines.length - this.maxTurns).join('\n') + '\n';
const tmp = this.filePath + '.tmp';
await writeFile(tmp, trimmed);
await rename(tmp, this.filePath);
this.lineCount = this.maxTurns;
} catch (err) {
log.warn(`Failed to trim turn log:`, err instanceof Error ? err.message : err);
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Generate a unique turn ID. */
export function generateTurnId(): string {
return randomUUID();
}

510
src/core/turn-viewer.html Normal file
View File

@@ -0,0 +1,510 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Turn Viewer</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0f1117;--surface:#1a1d27;--surface2:#22263a;--border:#2e3348;
--accent:#6c8ef7;--accent2:#a78bfa;
--text:#e2e6f0;--text2:#8b92a8;--text3:#5a6080;
--green:#4ade80;--red:#f87171;--yellow:#fbbf24;
--rb:#1e1a2e;--rbd:#5b4daa;
--tb:#172434;--tbd:#2563eb;
--trb:#1a2d1a;--trbd:#16a34a;
--treb:#2d1a1a;--trebd:#dc2626;
}
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;height:100vh;display:flex;flex-direction:column;overflow:hidden}
.header{padding:12px 20px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;flex-shrink:0}
.header h1{font-size:16px;font-weight:600}
.file-path{font-size:11px;color:var(--text3);font-family:monospace}
.turn-count{font-size:12px;color:var(--text2);margin-left:auto}
.status-dot{width:8px;height:8px;border-radius:50%;background:var(--green);flex-shrink:0}
.status-dot.stale{background:var(--text3)}
.status-dot.live{animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.filters{padding:10px 20px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-wrap:wrap;flex-shrink:0}
.filter-label{font-size:11px;color:var(--text3);text-transform:uppercase;letter-spacing:.05em}
.filter-group{display:flex;gap:4px;align-items:center}
.tab{padding:4px 12px;border-radius:20px;border:1px solid var(--border);background:transparent;color:var(--text2);font-size:13px;cursor:pointer;transition:all .15s}
.tab:hover{border-color:var(--accent);color:var(--text)}
.tab.active{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}
.search-input{padding:4px 12px;border-radius:20px;border:1px solid var(--border);background:var(--surface2);color:var(--text);font-size:13px;outline:none;width:180px}
.search-input:focus{border-color:var(--accent)}
.search-input::placeholder{color:var(--text3)}
.table-wrapper{flex:1;overflow-y:auto}
table{width:100%;border-collapse:collapse}
thead{position:sticky;top:0;z-index:10;background:var(--surface2)}
th{padding:9px 14px;text-align:left;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--text3);border-bottom:1px solid var(--border);white-space:nowrap}
td{padding:9px 14px;border-bottom:1px solid var(--border);vertical-align:top}
tr:hover td{background:var(--surface2);cursor:pointer}
.col-time{width:155px;color:var(--text2);font-size:12px;font-family:monospace;white-space:nowrap}
.col-trigger{width:130px}
.col-channel{width:100px}
.col-events{width:65px;text-align:center;color:var(--text2);font-size:12px}
.col-duration{width:80px;text-align:right;color:var(--text2);font-size:12px;font-family:monospace;white-space:nowrap}
.truncated{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:260px;color:var(--text2);font-size:13px}
.empty-state{padding:60px;text-align:center;color:var(--text3)}
.empty-state p{margin-top:8px;font-size:13px}
.badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;letter-spacing:.03em}
.badge-user_message{background:#1e3a5f;color:#60a5fa}
.badge-heartbeat{background:#3b2d14;color:#fbbf24}
.badge-cron{background:#2d1e3a;color:#c084fc}
.badge-webhook{background:#2d1e1e;color:#f87171}
.badge-feed{background:#1e2d3a;color:#38bdf8}
.badge-channel{background:var(--surface2);color:var(--text2);border:1px solid var(--border)}
.detail-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;display:none}
.detail-overlay.open{display:block}
.detail-panel{position:fixed;top:0;right:0;bottom:0;width:660px;max-width:90vw;background:var(--surface);border-left:1px solid var(--border);overflow-y:auto;z-index:101;transform:translateX(100%);transition:transform .2s ease;display:flex;flex-direction:column}
.detail-panel.open{transform:translateX(0)}
.detail-header{padding:14px 18px;border-bottom:1px solid var(--border);display:flex;align-items:flex-start;gap:10px;flex-shrink:0;background:var(--surface);position:sticky;top:0;z-index:5}
.detail-meta{flex:1;min-width:0}
.detail-ts{font-size:12px;color:var(--text3);font-family:monospace}
.detail-trigger-row{display:flex;gap:8px;align-items:center;margin-top:4px;flex-wrap:wrap}
.close-btn{background:none;border:none;color:var(--text3);font-size:20px;cursor:pointer;padding:0 4px;line-height:1;flex-shrink:0}
.close-btn:hover{color:var(--text)}
.detail-body{padding:18px;display:flex;flex-direction:column;gap:18px}
.section{display:flex;flex-direction:column;gap:8px}
.section-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--text3)}
.msg-box{background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:12px 14px;font-size:13px;color:var(--text);line-height:1.6;white-space:pre-wrap;word-break:break-word;max-height:280px;overflow-y:auto}
.msg-box.output{border-color:var(--accent2);background:#1a1a2e}
.events-list{display:flex;flex-direction:column;gap:6px}
.event{border-radius:8px;overflow:hidden}
.event-hdr{padding:7px 12px;display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;cursor:pointer;user-select:none}
.event-hdr:hover{filter:brightness(1.1)}
.event-toggle{font-size:10px;margin-left:auto;opacity:.7}
.event-bdy{padding:10px 12px;font-size:12px;font-family:'SF Mono','Fira Code',monospace;white-space:pre-wrap;word-break:break-word;line-height:1.5;max-height:360px;overflow-y:auto}
.ev-reasoning{background:var(--rb);border:1px solid var(--rbd)}
.ev-reasoning .event-hdr{background:var(--rbd);color:#c4b5fd}
.ev-reasoning .event-bdy{color:#ddd6fe;font-family:inherit;font-style:italic;font-size:13px}
.ev-tool_call{background:var(--tb);border:1px solid var(--tbd)}
.ev-tool_call .event-hdr{background:var(--tbd);color:#93c5fd}
.ev-tool_call .event-bdy{color:#bfdbfe}
.ev-tool_result{background:var(--trb);border:1px solid var(--trbd)}
.ev-tool_result .event-hdr{background:var(--trbd);color:#bbf7d0}
.ev-tool_result .event-bdy{color:#d1fae5}
.ev-tool_result.is-error{background:var(--treb);border-color:var(--trebd)}
.ev-tool_result.is-error .event-hdr{background:var(--trebd);color:#fecaca}
.ev-tool_result.is-error .event-bdy{color:#fee2e2}
.no-events{color:var(--text3);font-size:13px;font-style:italic}
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
::-webkit-scrollbar-thumb:hover{background:var(--text3)}
.auth-overlay{position:fixed;inset:0;background:var(--bg);z-index:200;display:flex;align-items:center;justify-content:center}
.auth-box{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:28px 24px;width:340px}
.auth-box h2{font-size:16px;font-weight:600;margin-bottom:18px;color:var(--text)}
.auth-box label{display:block;font-size:12px;color:var(--text3);margin-bottom:6px}
.auth-box input{width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;font-family:monospace;outline:none}
.auth-box input:focus{border-color:var(--accent)}
.auth-box button{margin-top:12px;width:100%;padding:9px;background:var(--accent);color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer}
.auth-box button:hover{opacity:.9}
.auth-err{color:#f87171;font-size:12px;margin-top:8px;display:none}
</style>
</head>
<body>
<div class="auth-overlay" id="authOverlay">
<div class="auth-box">
<h2>Turn Viewer</h2>
<label for="apiKeyInput">API Key</label>
<input type="password" id="apiKeyInput" placeholder="Paste your API key" autocomplete="off"
onkeydown="if(event.key==='Enter')document.getElementById('authSubmit').click()">
<button id="authSubmit">Connect</button>
<div class="auth-err" id="authErr">Invalid API key</div>
</div>
</div>
<div class="header">
<h1>Turn Viewer</h1>
<span class="file-path" id="agentLabel" style="display:__LABEL_DISPLAY__">__DEFAULT_AGENT__</span>
<select id="agentSelect" style="display:__SELECT_DISPLAY__;background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:3px 8px;font-size:13px;cursor:pointer;outline:none">
__AGENT_OPTIONS__
</select>
<span class="turn-count" id="turnCount"></span>
<div class="status-dot stale" id="statusDot" title="Connecting&hellip;"></div>
</div>
<div class="filters">
<span class="filter-label">Trigger</span>
<div class="filter-group" id="triggerTabs">
<button class="tab active" data-trigger="all">All</button>
<button class="tab" data-trigger="user_message">User Message</button>
<button class="tab" data-trigger="heartbeat">Heartbeat</button>
<button class="tab" data-trigger="cron">Cron</button>
<button class="tab" data-trigger="webhook">Webhook</button>
<button class="tab" data-trigger="feed">Feed</button>
</div>
<div class="filter-group" id="channelFilterGroup" style="display:none">
<span class="filter-label">Channel</span>
<div id="channelTabs"></div>
</div>
<div class="filter-group" style="margin-left:auto">
<input class="search-input" id="searchInput" type="text" placeholder="Search input / output&hellip;">
</div>
</div>
<div class="table-wrapper" id="tableWrapper">
<table>
<thead>
<tr>
<th class="col-time">Time</th>
<th class="col-trigger">Trigger</th>
<th class="col-channel">Channel</th>
<th class="col-events">Events</th>
<th class="col-duration">Duration</th>
<th>Input</th>
<th>Output</th>
</tr>
</thead>
<tbody id="tableBody"></tbody>
</table>
<div class="empty-state" id="emptyState" style="display:none">
<div style="font-size:32px">&#x1F4CB;</div>
<p id="emptyMsg">Waiting for turns&hellip;</p>
</div>
</div>
<div class="detail-overlay" id="detailOverlay"></div>
<div class="detail-panel" id="detailPanel">
<div class="detail-header">
<div class="detail-meta">
<div class="detail-ts" id="detailTs"></div>
<div class="detail-trigger-row">
<span id="detailTrigger"></span>
<span id="detailChannel"></span>
<span style="font-size:11px;color:var(--text3)" id="detailUserId"></span>
<span style="font-size:11px;color:var(--text3);margin-left:auto;font-family:monospace" id="detailDuration"></span>
</div>
<div style="font-size:10px;color:var(--text3);font-family:monospace;margin-top:2px" id="detailTurnId"></div>
</div>
<button class="close-btn" id="closeBtn">&#x2715;</button>
</div>
<div class="detail-body">
<div class="section">
<div class="section-label">Input</div>
<div class="msg-box" id="detailInput"></div>
</div>
<div class="section">
<div class="section-label">Events (<span id="detailEventsCount">0</span>)</div>
<div class="events-list" id="detailEvents"></div>
</div>
<div class="section">
<div class="section-label">Output</div>
<div class="msg-box output" id="detailOutput"></div>
</div>
</div>
</div>
<script>
(function() {
var AGENT_NAMES = __AGENT_NAMES_JSON__;
var activeAgent = AGENT_NAMES[0] || '';
var allTurns = [];
var activeTrigger = 'all';
var activeChannel = 'all';
var searchQuery = '';
var openTurnId = null;
var apiKey = sessionStorage.getItem('tv_key') || '';
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function fmtTime(ts) {
if (!ts) return '\u2014';
var d = new Date(ts);
if (isNaN(d.getTime())) return ts;
var p = function(n){return n<10?'0'+n:''+n;};
var mo = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return mo[d.getMonth()]+' '+p(d.getDate())+' '+p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds());
}
function triggerBadge(t) {
return '<span class="badge badge-'+esc(t||'unknown')+'">'+esc(t||'unknown')+'</span>';
}
function channelBadge(c) {
return c ? '<span class="badge badge-channel">'+esc(c)+'</span>' : '';
}
function fmtDuration(ms) {
if (ms == null) return '<span style="color:var(--text3)">\u2014</span>';
if (ms < 1000) return ms + 'ms';
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
return Math.floor(ms / 60000) + 'm ' + ((ms % 60000) / 1000).toFixed(0) + 's';
}
function trunc(str, n) {
n = n||120;
if (!str) return '<span style="color:var(--text3);font-style:italic">\u2014</span>';
str = str.trim();
return str.length > n ? esc(str.slice(0,n))+'<span style="color:var(--text3)">\u2026</span>' : esc(str);
}
function getChannels() {
var seen = {};
allTurns.forEach(function(t){ if (t.trigger==='user_message'&&t.channel) seen[t.channel]=true; });
return Object.keys(seen).sort();
}
function filterTurns() {
return allTurns.filter(function(t) {
if (activeTrigger !== 'all' && t.trigger !== activeTrigger) return false;
if (activeTrigger === 'user_message' && activeChannel !== 'all' && t.channel !== activeChannel) return false;
if (searchQuery) {
var q = searchQuery.toLowerCase();
if (((t.input||'').toLowerCase().indexOf(q)===-1) && ((t.output||'').toLowerCase().indexOf(q)===-1)) return false;
}
return true;
});
}
function setTurns(turns) {
allTurns = turns.slice().reverse(); // newest first
render();
}
function render() {
renderTriggerTabs();
renderChannelTabs();
renderTable();
}
function renderTriggerTabs() {
document.querySelectorAll('#triggerTabs .tab').forEach(function(btn) {
btn.classList.toggle('active', btn.getAttribute('data-trigger') === activeTrigger);
});
}
function renderChannelTabs() {
var group = document.getElementById('channelFilterGroup');
var container = document.getElementById('channelTabs');
if (activeTrigger !== 'user_message') { group.style.display = 'none'; return; }
var channels = getChannels();
if (!channels.length) { group.style.display = 'none'; return; }
group.style.display = 'flex';
container.innerHTML = ['all'].concat(channels).map(function(ch) {
return '<button class="tab'+(activeChannel===ch?' active':'')+'" data-channel="'+esc(ch)+'">'+(ch==='all'?'All':esc(ch))+'</button>';
}).join('');
container.querySelectorAll('[data-channel]').forEach(function(btn) {
btn.addEventListener('click', function() {
activeChannel = btn.getAttribute('data-channel');
renderChannelTabs();
renderTable();
});
});
}
function renderTable() {
var turns = filterTurns();
var tbody = document.getElementById('tableBody');
var empty = document.getElementById('emptyState');
document.getElementById('turnCount').textContent =
allTurns.length === turns.length ? allTurns.length+' turns' : turns.length+' / '+allTurns.length+' turns';
if (!turns.length) {
tbody.innerHTML = '';
empty.style.display = 'block';
document.getElementById('emptyMsg').textContent =
allTurns.length === 0 ? 'Waiting for turns\u2026' : 'No turns match the current filter.';
return;
}
empty.style.display = 'none';
tbody.innerHTML = turns.map(function(t, i) {
return '<tr data-idx="'+i+'">'+
'<td class="col-time">'+fmtTime(t.ts)+'</td>'+
'<td class="col-trigger">'+triggerBadge(t.trigger)+'</td>'+
'<td class="col-channel">'+channelBadge(t.channel)+'</td>'+
'<td class="col-events">'+((t.events||[]).length)+'</td>'+
'<td class="col-duration">'+fmtDuration(t.durationMs)+'</td>'+
'<td><div class="truncated">'+trunc(t.input)+'</div></td>'+
'<td><div class="truncated">'+trunc(t.output)+'</div></td>'+
'</tr>';
}).join('');
tbody.querySelectorAll('tr').forEach(function(row) {
row.addEventListener('click', function() {
openDetail(turns[parseInt(row.getAttribute('data-idx'),10)]);
});
});
// Re-open detail panel if it was open and the turn still exists
if (openTurnId) {
var match = turns.find(function(t){ return t.turnId === openTurnId; });
if (match) openDetail(match);
else openTurnId = null;
}
}
function renderEvent(e, i) {
if (e.type === 'reasoning') {
return '<div class="event ev-reasoning">'+
'<div class="event-hdr"><span>&#x1F4AD; Reasoning #'+(i+1)+'</span><span class="event-toggle">&#x25B2;</span></div>'+
'<div class="event-bdy">'+esc(e.content||'')+'</div></div>';
}
if (e.type === 'tool_call') {
var args=''; try{args=JSON.stringify(e.args,null,2);}catch(ex){args=String(e.args);}
return '<div class="event ev-tool_call">'+
'<div class="event-hdr"><span>&#x2699;&#xFE0F; '+esc(e.name||'unknown')+'</span>'+
'<span style="font-family:monospace;opacity:.5;font-size:11px">'+esc((e.id||'').slice(0,12))+'</span>'+
'<span class="event-toggle">&#x25B2;</span></div>'+
'<div class="event-bdy">'+esc(args)+'</div></div>';
}
if (e.type === 'tool_result') {
var errCls=e.isError?' is-error':'', icon=e.isError?'&#x274C;':'&#x2705;';
return '<div class="event ev-tool_result'+errCls+'">'+
'<div class="event-hdr"><span>'+icon+' Result'+(e.isError?' (error)':'')+'</span>'+
'<span style="font-family:monospace;opacity:.5;font-size:11px">'+esc((e.id||'').slice(0,12))+'</span>'+
'<span class="event-toggle">&#x25B2;</span></div>'+
'<div class="event-bdy">'+esc(e.content||'')+'</div></div>';
}
return '<div style="border:1px solid var(--border);border-radius:8px;padding:8px 12px;color:var(--text3);font-size:12px">Unknown event: '+esc(e.type)+'</div>';
}
function openDetail(turn) {
if (!turn) return;
openTurnId = turn.turnId;
document.getElementById('detailTs').textContent = fmtTime(turn.ts);
document.getElementById('detailTrigger').innerHTML = triggerBadge(turn.trigger);
document.getElementById('detailChannel').innerHTML = channelBadge(turn.channel);
document.getElementById('detailUserId').textContent = turn.userId ? 'user: '+turn.userId : '';
document.getElementById('detailDuration').innerHTML = turn.durationMs != null ? fmtDuration(turn.durationMs) : '';
document.getElementById('detailTurnId').textContent = turn.turnId ? 'turn: '+turn.turnId : '';
document.getElementById('detailInput').textContent = turn.input||'(empty)';
document.getElementById('detailOutput').textContent = turn.output||'(empty)';
var events = turn.events||[];
document.getElementById('detailEventsCount').textContent = events.length;
var evList = document.getElementById('detailEvents');
evList.innerHTML = events.length===0
? '<div class="no-events">No events recorded for this turn.</div>'
: events.map(renderEvent).join('');
evList.querySelectorAll('.event-hdr').forEach(function(h) {
h.addEventListener('click', function() {
var body=h.nextElementSibling, toggle=h.querySelector('.event-toggle');
var hidden=body.style.display==='none';
body.style.display=hidden?'':'none';
toggle.innerHTML=hidden?'&#x25B2;':'&#x25BC;';
});
});
document.getElementById('detailPanel').classList.add('open');
document.getElementById('detailOverlay').classList.add('open');
}
function closeDetail() {
openTurnId = null;
document.getElementById('detailPanel').classList.remove('open');
document.getElementById('detailOverlay').classList.remove('open');
}
document.getElementById('closeBtn').addEventListener('click', closeDetail);
document.getElementById('detailOverlay').addEventListener('click', closeDetail);
document.addEventListener('keydown', function(e){ if(e.key==='Escape') closeDetail(); });
document.querySelectorAll('#triggerTabs .tab').forEach(function(btn) {
btn.addEventListener('click', function() {
activeTrigger = btn.getAttribute('data-trigger');
activeChannel = 'all';
render();
});
});
document.getElementById('searchInput').addEventListener('input', function(e) {
searchQuery = e.target.value;
renderTable();
});
// ── Agent selector ─────────────────────────────────────────────────────────
var agentSelect = document.getElementById('agentSelect');
if (agentSelect) {
agentSelect.addEventListener('change', function() {
activeAgent = agentSelect.value;
allTurns = [];
activeTrigger = 'all';
activeChannel = 'all';
openTurnId = null;
render();
switchAgent();
});
}
// ── Live data via SSE ──────────────────────────────────────────────────────
var dot = document.getElementById('statusDot');
var currentEs = null;
function connect() {
if (currentEs) { currentEs.close(); currentEs = null; }
var params = new URLSearchParams();
if (activeAgent) params.set('agent', activeAgent);
params.set('key', apiKey);
var url = '/turns/stream?' + params.toString();
var es = new EventSource(url);
currentEs = es;
es.onopen = function() {
dot.className = 'status-dot live';
dot.title = 'Live \u2014 auto-updating';
};
es.onmessage = function(e) {
try {
var msg = JSON.parse(e.data);
if (msg.type === 'init') { setTurns(msg.turns); }
else if (msg.type === 'append' && msg.turns && msg.turns.length > 0) {
allTurns = msg.turns.concat(allTurns);
render();
}
} catch(ex) {}
};
es.onerror = function() {
if (es !== currentEs) return; // stale, ignore
dot.className = 'status-dot stale';
dot.title = 'Disconnected \u2014 reconnecting\u2026';
es.close();
currentEs = null;
setTimeout(connect, 3000);
};
}
function switchAgent() {
var params = new URLSearchParams();
if (activeAgent) params.set('agent', activeAgent);
var url = '/turns/data?' + params.toString();
fetch(url, { headers: { 'X-Api-Key': apiKey } })
.then(function(r) {
if (r.status === 401) { showAuth(); throw new Error('Unauthorized'); }
return r.json();
})
.then(setTurns)
.catch(function(){});
connect();
}
function showAuth() {
document.getElementById('authOverlay').style.display = 'flex';
}
function hideAuth() {
document.getElementById('authOverlay').style.display = 'none';
}
document.getElementById('authSubmit').addEventListener('click', function() {
var k = document.getElementById('apiKeyInput').value.trim();
if (!k) return;
apiKey = k;
sessionStorage.setItem('tv_key', k);
document.getElementById('authErr').style.display = 'none';
hideAuth();
switchAgent();
});
// Initial load — show login if no key stored, otherwise load directly
if (apiKey) {
hideAuth();
switchAgent();
} else {
showAuth();
}
})();
</script>
</body>
</html>

38
src/core/turn-viewer.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* Turn viewer SPA — served by the API server at GET /turns.
*
* The HTML lives in turn-viewer.html (same directory).
* getTurnViewerHtml() injects agent metadata via simple token substitution
* so the template file can be edited and linted independently.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
let TEMPLATE: string;
try {
TEMPLATE = readFileSync(join(__dirname, 'turn-viewer.html'), 'utf8');
} catch {
throw new Error('turn-viewer.html not found -- did you run npm run build?');
}
function escHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
export function getTurnViewerHtml(agentNames: string[]): string {
const multiAgent = agentNames.length > 1;
const defaultAgent = agentNames[0] ?? '';
// Escape </script> to prevent XSS if an agent name contains it
const agentNamesJson = JSON.stringify(agentNames).replace(/<\/script>/gi, '<\\/script>');
const agentOptions = agentNames.map(n => `<option value="${escHtml(n)}">${escHtml(n)}</option>`).join('');
return TEMPLATE
.replaceAll('__AGENT_NAMES_JSON__', agentNamesJson)
.replaceAll('__DEFAULT_AGENT__', escHtml(defaultAgent))
.replaceAll('__LABEL_DISPLAY__', multiAgent ? 'none' : 'inline')
.replaceAll('__SELECT_DISPLAY__', multiAgent ? 'inline-block' : 'none')
.replaceAll('__AGENT_OPTIONS__', agentOptions);
}

View File

@@ -192,6 +192,12 @@ export interface BotConfig {
sendFileMaxSize?: number; // Max file size in bytes for <send-file> (default: 50MB)
sendFileCleanup?: boolean; // Allow <send-file cleanup="true"> to delete files after send (default: false)
// Logging
logging?: {
turnLogFile?: string; // Path to JSONL file for turn logging (one record per agent turn)
maxTurns?: number; // Max turns to retain in the log file (default: 1000, oldest trimmed)
};
// Cron
cronStorePath?: string; // Resolved cron store path (per-agent in multi-agent mode)

View File

@@ -259,6 +259,25 @@ async function main() {
const isMultiAgent = agents.length > 1;
log.info(`${agents.length} agent(s) configured: ${agents.map(a => a.name).join(', ')}`);
// Validate agent names are unique
const agentNames = agents.map(a => a.name);
const duplicateAgentName = agentNames.find((n, i) => agentNames.indexOf(n) !== i);
if (duplicateAgentName) {
log.error(`Multiple agents share the same name: "${duplicateAgentName}". Each agent must have a unique name.`);
process.exit(1);
}
// Validate no two agents share the same turnLogFile
const turnLogFilePaths = agents
.map(a => (a.features?.logging ?? yamlConfig.features?.logging)?.turnLogFile)
.filter((p): p is string => !!p)
.map(p => resolve(p));
const duplicateTurnLog = turnLogFilePaths.find((p, i) => turnLogFilePaths.indexOf(p) !== i);
if (duplicateTurnLog) {
log.error(`Multiple agents share the same turnLogFile: "${duplicateTurnLog}". Each agent must use a unique log file path.`);
process.exit(1);
}
// Validate at least one agent has channels
const totalChannels = agents.reduce((sum, a) => sum + Object.keys(a.channels).length, 0);
if (totalChannels === 0) {
@@ -351,6 +370,7 @@ async function main() {
maxSessions: agentConfig.conversations?.maxSessions,
reuseSession: agentConfig.conversations?.reuseSession,
redaction: agentConfig.security?.redaction,
logging: agentConfig.features?.logging ?? yamlConfig.features?.logging,
cronStorePath,
skills: {
cronEnabled: agentConfig.features?.cron ?? globalConfig.cronEnabled,
@@ -530,11 +550,17 @@ async function main() {
const apiPort = parseInt(process.env.PORT || '8080', 10);
const apiHost = process.env.API_HOST || (isContainerDeploy ? '0.0.0.0' : undefined); // Container platforms need 0.0.0.0 for health checks
const apiCorsOrigin = process.env.API_CORS_ORIGIN; // undefined = same-origin only
const turnLogFiles: Record<string, string> = {};
for (const a of agents) {
const logging = a.features?.logging ?? yamlConfig.features?.logging;
if (logging?.turnLogFile) turnLogFiles[a.name] = logging.turnLogFile;
}
const apiServer = createApiServer(gateway, {
port: apiPort,
apiKey: apiKey,
host: apiHost,
corsOrigin: apiCorsOrigin,
turnLogFiles: Object.keys(turnLogFiles).length > 0 ? turnLogFiles : undefined,
stores: agentStores,
agentChannels: agentChannelMap,
sessionInvalidators,