hermes/backend/message-filter.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

94 lines
3.8 KiB
TypeScript

/**
* message-filter.ts — outgoing message sanitization
*
* 1. Brand replace: OpenClaw → Titan, openclaw emoji → 🐶
* 2. Path scrub: strip /home/openclaw/ prefix from tool content
* 3. Smart truncation: single truncator
* - SQL results: row count summary + first N lines
* - Other: first 400 chars + …[N more chars]
*/
// ── 1. Brand replace ────────────────────────────────────────────────────────
const OPENCLAW_EMOJIS = /🐾|🦞|🤖|🦅/gu;
const OPENCLAW_NAME = /\bOpenClaw\b/g;
function brandReplace(str: string): string {
return str.replace(OPENCLAW_EMOJIS, '🐶').replace(OPENCLAW_NAME, 'Titan');
}
// ── 2. Path scrub ────────────────────────────────────────────────────────────
const PATH_ABS = /\/home\/openclaw\//g;
const PATH_TILDE = /~\/\.openclaw\//g;
const PATH_DOT_CLAW = /\.openclaw\//g;
function scrubPaths(str: string): string {
return str.replace(PATH_ABS, '').replace(PATH_TILDE, '').replace(PATH_DOT_CLAW, '');
}
// ── 3. Smart truncation ──────────────────────────────────────────────────────
const SOFT_LIMIT = 80;
const SQL_ROWS = 8;
function looksLikeSqlResult(str: string): boolean {
const lines = str.split('\n').filter(l => l.trim());
if (lines.length < 2) return false;
const tabLines = lines.filter(l => l.includes('\t') || (l.match(/\|/g) || []).length >= 2);
return tabLines.length > lines.length * 0.5;
}
function truncateSql(str: string): string {
const lines = str.split('\n').filter(l => l.trim());
const total = lines.length;
if (total <= SQL_ROWS + 1) return str;
return lines.slice(0, SQL_ROWS).join('\n') + `\n… [${total - SQL_ROWS} more rows]`;
}
function smartTruncate(str: unknown): string {
if (typeof str !== 'string') {
try { str = JSON.stringify(str); } catch (_) { return String(str); }
}
const s = str as string;
if (s.length <= SOFT_LIMIT) return s;
if (looksLikeSqlResult(s)) return truncateSql(s);
return s.slice(0, 40) + '…' + s.slice(-40);
}
// ── Public API ───────────────────────────────────────────────────────────────
export function filterText(str: string | null | undefined): string | null | undefined {
if (str == null) return str;
return scrubPaths(brandReplace(String(str)));
}
function extractSql(cmd: string): string | null {
const m = cmd.match(/(?:mysql|mariadb)\b[^"']*-e\s+["']([^"']+)["']/s);
if (m) return m[1].trim().replace(/\\`/g, '').replace(/`/g, '');
return null;
}
function prettifyArgs(val: unknown): string {
if (typeof val === 'string') {
try { val = JSON.parse(val); } catch (_) { return val; }
}
if (val && typeof val === 'object') {
const obj = val as Record<string, unknown>;
if (typeof obj.command === 'string') {
return extractSql(obj.command) || obj.command;
}
const keys = Object.keys(obj);
if (keys.length === 1 && typeof obj[keys[0]] === 'string') return obj[keys[0]] as string;
try { return JSON.stringify(val); } catch (_) { return '[non-serializable]'; }
}
return String(val);
}
export function filterValue(val: unknown): string | null | undefined {
if (val == null) return val as null | undefined;
return smartTruncate(scrubPaths(brandReplace(prettifyArgs(val))));
}
export { brandReplace, scrubPaths, smartTruncate };