hermes/backend/hud-builder.ts
Nico ccee249618 v0.6.42: Hermes chat UI — Vue3/TS/Vite, audio STT/TTS, sidebar rail, MCP event loop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:35:10 +02:00

264 lines
11 KiB
TypeScript

/**
* 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<string, any>,
raw: string,
): Record<string, any> {
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<string, any>,
rawResult: string | null,
): Record<string, any> {
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<string, any> {
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<string, any> {
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<string, any> {
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<string, any> {
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<string, any> = {};
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<string, any>;
result?: Record<string, any>;
durationMs?: number;
subtype?: ReceivedSubtype;
label?: string;
payload?: Record<string, any>;
}
function makeHudEvent(event: HudEventKind, extra: Partial<HudEvent> = {}): 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<string, any>, 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<string, any>, 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<string, any>, replay = false): HudEvent =>
makeHudEvent('received', { subtype, label, ...(payload ? { payload } : {}), ...(replay ? { replay } : {}) }),
};