/** * hud-builder.ts — HUD event construction helpers * * Builds structured { type: 'hud', event: ... } messages per HUD-PROTOCOL.md. * Used by gateway.ts (live turns) and session-watcher.ts (history replay). */ const OPENCLAW_ROOT = `/home/openclaw/.openclaw/`; // ── Inline brand/path filter (mirrors message-filter.ts, no circular dep) ──── const _BRAND_EMOJIS = /🐾|🦞|🤖|🦅/gu; const _BRAND_NAME = /\bOpenClaw\b/g; const _PATH_ABS = /\/home\/openclaw\//g; const _PATH_TILDE = /~\/\.openclaw\//g; const _PATH_DOT = /\.openclaw(?:[-/]|(?=\s|$|&&|;|'|"))/g; // Strip internal npm binary paths (e.g. .npm-global/lib/node_modules/.openclaw-XXXXX/dist/foo.js) const _PATH_NPM_BIN = /(?:\.npm-global\/lib\/node_modules\/[^\s'"]+|node_modules\/\.openclaw-[^\s'"]+)/g; // DB / service credentials const _DB_PASS_INLINE = /-p\S+/g; // -pPASSWORD (mariadb/mysql/psql style) const _DB_PASS_SPACE = /(-p)\s+\S+/g; // -p PASSWORD const _DB_USER_SPACE = /(-u)\s+\S+/g; // -u USERNAME const _DB_OPT_PASS = /(--password=)\S+/g; // --password=xxx const _DB_OPT_USER = /(--user=)\S+/g; // --user=xxx const _URI_CREDS = /([\w+.-]+:\/\/)[^:@/\s]+:[^@\s]+(@)/g; // scheme://user:pass@ function hudFilter(s: string): string { return s .replace(_BRAND_EMOJIS, '🐶') .replace(_BRAND_NAME, 'Titan') .replace(_PATH_ABS, '') .replace(_PATH_TILDE, '') .replace(_PATH_NPM_BIN, '[internal]') .replace(_PATH_DOT, '') .replace(_DB_PASS_INLINE, '-p***') .replace(_DB_PASS_SPACE, '$1 ***') .replace(_DB_USER_SPACE, '$1 ***') .replace(_DB_OPT_PASS, '$1***') .replace(_DB_OPT_USER, '$1***') .replace(_URI_CREDS, '$1***:***$2'); } export interface FileArea { startLine: number; endLine: number; } export interface FileMeta { path: string; viewerPath: string; area?: FileArea; } // ── Path helpers ───────────────────────────────────────────────────────────── export function toViewerPath(raw: string): string { if (!raw) return raw; let p = raw.trim(); if (p.startsWith(OPENCLAW_ROOT)) p = p.slice(OPENCLAW_ROOT.length); if (p.startsWith('~/.openclaw/')) p = p.slice('~/.openclaw/'.length); if (p.startsWith('.openclaw/')) p = p.slice('.openclaw/'.length); return p; } export function makeFileMeta(rawPath: string, area?: FileArea): FileMeta { return { path: rawPath, viewerPath: toViewerPath(rawPath), ...(area ? { area } : {}) }; } // ── Unified result resolver ────────────────────────────────────────────────── /** * Dispatch raw tool output to the appropriate result builder. * Single source of truth — replaces duplicated dispatch blocks in * gateway.ts (live + history) and session-watcher.ts (JSONL replay). */ export function resolveToolResult( tool: string, args: Record, raw: string, ): Record { const fileTools = ['read', 'write', 'edit', 'append']; if (fileTools.includes(tool)) return buildFileOpResult(tool, args, raw); if (tool === 'exec') return buildExecResult(raw); if (tool === 'web_fetch' || tool === 'web_search') return buildWebResult(args.url || args.query || null, raw); return buildGenericResult(raw); } // ── Result builders ─────────────────────────────────────────────────────────── /** * Build structured result for file-op tools. * Called from gateway (live) — we don't have file content, just what the agent passed/got. */ export function buildFileOpResult( tool: string, args: Record, rawResult: string | null, ): Record { const rawPath: string = args.path || args.file_path || ''; const text = rawResult || ''; const bytes = Buffer.byteLength(text, 'utf8'); const lineCount = text ? text.split('\n').length : 0; if (tool === 'read') { const offset: number = Number(args.offset) || 1; const limit: number = Number(args.limit) || lineCount; const area: FileArea = { startLine: offset, endLine: offset + Math.max(lineCount - 1, 0) }; return { ok: true, file: makeFileMeta(rawPath, area), area, text: hudFilter(text).slice(0, 2000), // cap stored text bytes, truncated: bytes > 2000, }; } if (tool === 'write') { const area: FileArea = { startLine: 1, endLine: lineCount }; return { ok: true, file: makeFileMeta(rawPath, area), area, bytes }; } if (tool === 'edit') { // We don't have the resulting line numbers from gateway — approximate const area: FileArea = { startLine: 1, endLine: lineCount || 1 }; return { ok: true, file: makeFileMeta(rawPath, area), area }; } if (tool === 'append') { const area: FileArea = { startLine: 1, endLine: lineCount }; return { ok: true, file: makeFileMeta(rawPath, area), area, bytes }; } return { ok: true, raw: text.slice(0, 500) }; } export function buildExecResult(rawResult: string | null): Record { const stdout = hudFilter(rawResult || ''); const truncated = stdout.length > 1000; // Extract file paths mentioned in output const pathMatches = stdout.match(/(?:^|\s)((?:~\/|\/home\/openclaw\/|\.\/)\S+)/gm) || []; const mentionedPaths: FileMeta[] = pathMatches .map(m => m.trim()) .filter(p => p.length > 3) .slice(0, 5) .map(p => makeFileMeta(p)); return { ok: true, exitCode: 0, stdout: stdout.slice(0, 1000), truncated, ...(mentionedPaths.length ? { mentionedPaths } : {}), }; } export function buildWebResult(url: string | null, rawResult: string | null): Record { const text = hudFilter(rawResult || ''); return { ok: true, url: url || '', text: text.slice(0, 500), truncated: text.length > 500 }; } export function buildGenericResult(rawResultOrOk: string | boolean | null, meta?: string): Record { if (typeof rawResultOrOk === 'boolean') { // Called with (ok, meta) — result content was stripped by gateway const summary = meta ? hudFilter(meta).slice(0, 200) : (rawResultOrOk ? 'ok' : 'error'); return { ok: rawResultOrOk, summary, truncated: false }; } const text = hudFilter(rawResultOrOk || ''); return { ok: true, summary: text.slice(0, 200), truncated: text.length > 200 }; } // ── Args builders ───────────────────────────────────────────────────────────── export function buildToolArgs(tool: string, rawArgs: any): Record { if (!rawArgs) return {}; const args = typeof rawArgs === 'string' ? (() => { try { return JSON.parse(rawArgs); } catch { return { raw: rawArgs }; } })() : rawArgs; const fileTools = ['read', 'write', 'edit', 'append']; if (fileTools.includes(tool)) { const rawPath: string = args.path || args.file_path || ''; return { path: rawPath, viewerPath: toViewerPath(rawPath), operation: tool, ...(tool === 'read' && args.offset ? { offset: args.offset } : {}), ...(tool === 'read' && args.limit ? { limit: args.limit } : {}), }; } if (tool === 'exec') return { command: hudFilter(args.command || String(args)) }; if (tool === 'web_fetch') return { url: args.url || '', maxChars: args.maxChars }; if (tool === 'web_search') return { query: hudFilter(args.query || '') }; // Generic — pass through top-level keys, capped + filtered const out: Record = {}; for (const [k, v] of Object.entries(args).slice(0, 8)) { out[k] = typeof v === 'string' ? hudFilter(v).slice(0, 200) : v; } return out; } // ── HUD event factory ───────────────────────────────────────────────────────── export type HudEventKind = | 'turn_start' | 'turn_end' | 'think_start' | 'think_end' | 'tool_start' | 'tool_end' | 'received'; export type ReceivedSubtype = | 'new_session' | 'agent_switch' | 'stop' | 'kill' | 'handover' | 'reconnect' | 'message'; export interface HudEvent { type: 'hud'; event: HudEventKind; id: string; correlationId?: string; parentId?: string; ts: number; replay?: boolean; // event-specific tool?: string; toolCallId?: string; // OpenClaw-assigned toolCallId — separate from correlationId args?: Record; result?: Record; durationMs?: number; subtype?: ReceivedSubtype; label?: string; payload?: Record; } function makeHudEvent(event: HudEventKind, extra: Partial = {}): HudEvent { return { type: 'hud', event, id: crypto.randomUUID(), ts: Date.now(), ...extra, }; } export const hud = { turnStart: (correlationId: string, replay = false): HudEvent => makeHudEvent('turn_start', { correlationId, ...(replay ? { replay } : {}) }), turnEnd: (correlationId: string, startTs: number, replay = false): HudEvent => makeHudEvent('turn_end', { correlationId, durationMs: Date.now() - startTs, ...(replay ? { replay } : {}) }), thinkStart: (correlationId: string, parentId: string, replay = false): HudEvent => makeHudEvent('think_start', { correlationId, parentId, ...(replay ? { replay } : {}) }), thinkEnd: (correlationId: string, parentId: string, startTs: number, replay = false): HudEvent => makeHudEvent('think_end', { correlationId, parentId, durationMs: Date.now() - startTs, ...(replay ? { replay } : {}) }), toolStart: (correlationId: string, parentId: string, tool: string, args: Record, replay = false, toolCallId?: string): HudEvent => makeHudEvent('tool_start', { correlationId, parentId, tool, args, ...(toolCallId ? { toolCallId } : {}), ...(replay ? { replay } : {}) }), toolEnd: (correlationId: string, parentId: string, tool: string, result: Record, startTs: number, replay = false, toolCallId?: string): HudEvent => makeHudEvent('tool_end', { correlationId, parentId, tool, result, durationMs: Date.now() - startTs, ...(toolCallId ? { toolCallId } : {}), ...(replay ? { replay } : {}) }), received: (subtype: ReceivedSubtype, label: string, payload?: Record, replay = false): HudEvent => makeHudEvent('received', { subtype, label, ...(payload ? { payload } : {}), ...(replay ? { replay } : {}) }), };