264 lines
11 KiB
TypeScript
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 } : {}) }),
|
|
};
|