diff --git a/packages/adapters/hermes/gateway-ui-parser.cjs b/packages/adapters/hermes/gateway-ui-parser.cjs index 1cba319c8d..4654dbdef8 100644 --- a/packages/adapters/hermes/gateway-ui-parser.cjs +++ b/packages/adapters/hermes/gateway-ui-parser.cjs @@ -1,5 +1,11 @@ "use strict"; +function stripAnsi(text) { + return text + .replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "") + .replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, ""); +} + function safeJsonParse(text) { try { return JSON.parse(text); @@ -18,7 +24,8 @@ function asString(value) { } function parseStdoutLine(line, ts) { - const trimmed = line.trim(); + const cleaned = stripAnsi(line); + const trimmed = cleaned.trim(); if (!trimmed) return []; const eventMatch = trimmed.match(/^\[hermes-gateway:event\]\s+run=([^\s]+)\s+event=([^\s]+)\s+data=(.*)$/s); @@ -27,7 +34,7 @@ function parseStdoutLine(line, ts) { const data = asRecord(safeJsonParse(eventMatch[3])); if (eventName === "message.delta") { const delta = asString(data && data.delta) || asString(data && data.text_delta); - return delta ? [{ kind: "assistant", ts, text: delta, delta: true }] : []; + return delta ? [{ kind: "assistant", ts, text: stripAnsi(delta), delta: true }] : []; } if (eventName === "run.failed" || eventName === "run.error") { const message = asString(data && data.error) || asString(data && data.message) || "Hermes run failed"; @@ -43,7 +50,7 @@ function parseStdoutLine(line, ts) { return [{ kind: "system", ts, text: trimmed.replace(/^\[hermes-gateway\]\s*/, "") }]; } - return [{ kind: "stdout", ts, text: line }]; + return [{ kind: "stdout", ts, text: cleaned }]; } module.exports = { parseStdoutLine }; diff --git a/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts b/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts index df9db752dd..2837bea15e 100644 --- a/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts +++ b/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts @@ -1,5 +1,15 @@ import type { TranscriptEntry } from "@paperclipai/adapter-utils"; +/** + * Strip ANSI escape sequences (CSI, OSC) from terminal text. + * Same pattern used in claude-local adapter quota.ts. + */ +function stripAnsi(text: string): string { + return text + .replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "") + .replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, ""); +} + function safeJsonParse(text: string): unknown { try { return JSON.parse(text); @@ -18,7 +28,8 @@ function asString(value: unknown): string { } export function parseHermesGatewayStdoutLine(line: string, ts: string): TranscriptEntry[] { - const trimmed = line.trim(); + const cleaned = stripAnsi(line); + const trimmed = cleaned.trim(); if (!trimmed) return []; const eventMatch = trimmed.match(/^\[hermes-gateway:event\]\s+run=([^\s]+)\s+event=([^\s]+)\s+data=(.*)$/s); @@ -27,7 +38,7 @@ export function parseHermesGatewayStdoutLine(line: string, ts: string): Transcri const data = asRecord(safeJsonParse(eventMatch[3])); if (eventName === "message.delta") { const delta = asString(data?.delta) || asString(data?.text_delta); - return delta ? [{ kind: "assistant", ts, text: delta, delta: true }] : []; + return delta ? [{ kind: "assistant", ts, text: stripAnsi(delta), delta: true }] : []; } if (eventName === "run.failed" || eventName === "run.error") { const message = asString(data?.error) || asString(data?.message) || "Hermes run failed"; @@ -43,5 +54,5 @@ export function parseHermesGatewayStdoutLine(line: string, ts: string): Transcri return [{ kind: "system", ts, text: trimmed.replace(/^\[hermes-gateway\]\s*/, "") }]; } - return [{ kind: "stdout", ts, text: line }]; + return [{ kind: "stdout", ts, text: cleaned }]; } diff --git a/packages/adapters/hermes/src/ui/parse-stdout.test.ts b/packages/adapters/hermes/src/ui/parse-stdout.test.ts new file mode 100644 index 0000000000..8e25cf8cd7 --- /dev/null +++ b/packages/adapters/hermes/src/ui/parse-stdout.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { parseHermesStdoutLine } from "./parse-stdout.js"; + +const TS = "2026-06-29T12:00:00.000Z"; + +describe("parseHermesStdoutLine — ANSI stripping", () => { + it("strips 24-bit foreground + background color CSI sequences", () => { + const result = parseHermesStdoutLine( + "\x1b[38;2;255;255;255;48;2;19;87;20m+r = curl(\"POST\", \"/api/issues/d7b08cc5/comments\",\x1b[0m", + TS, + ); + expect(result.length).toBeGreaterThan(0); + for (const entry of result) { + for (const v of Object.values(entry)) { + if (typeof v === "string") { + expect(v).not.toMatch(/\x1b\[/); + } + } + } + }); + + it("strips bold yellow CSI sequence from Hermes header", () => { + const result = parseHermesStdoutLine("\x1b[1;38;2;255;215;0m- Hermes\x1b[0m", TS); + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty("text", "- Hermes"); + }); + + it("strips light text CSI sequence", () => { + const result = parseHermesStdoutLine( + "\x1b[38;2;255;248;220mAll done. Now let me verify.\x1b[0m", + TS, + ); + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty("text", "All done. Now let me verify."); + }); + + it("passes through clean text unchanged", () => { + const result = parseHermesStdoutLine("Normal text without ANSI", TS); + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty("text", "Normal text without ANSI"); + }); + + it("strips multiple CSI sequences on a single line", () => { + const result = parseHermesStdoutLine( + "\x1b[38;2;255;255;255;48;2;19;87;20m+ \"priority\": \"highest\",\x1b[0m \x1b[38;2;255;255;255;48;2;19;87;20m+r = curl(\"PATCH\", ...\x1b[0m", + TS, + ); + expect(result.length).toBeGreaterThan(0); + for (const entry of result) { + for (const v of Object.values(entry)) { + if (typeof v === "string") { + expect(v).not.toMatch(/\x1b\[/); + } + } + } + }); + + it("still parses tool completion lines correctly after stripping", () => { + const result = parseHermesStdoutLine("\u250a \u{1f50d} search \"pattern\" 0.5s", TS); + expect(result.length).toBeGreaterThanOrEqual(2); + const toolCall = result.find((e) => e.kind === "tool_call"); + expect(toolCall?.name).toBe("search"); + }); + + it("still parses shell tool lines correctly after stripping", () => { + const result = parseHermesStdoutLine("\u250a $ ls -la 0.3s", TS); + const toolCall = result.find((e) => e.kind === "tool_call"); + expect(toolCall?.name).toBe("shell"); + }); + + it("strips OSC title sequences", () => { + const result = parseHermesStdoutLine("\x1b]0;Terminal Title\x07Actual content", TS); + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty("text", "Actual content"); + }); + + it("handles empty lines after ANSI stripping", () => { + const result = parseHermesStdoutLine("\x1b[0m", TS); + expect(result).toHaveLength(0); + }); +}); diff --git a/packages/adapters/hermes/src/ui/parse-stdout.ts b/packages/adapters/hermes/src/ui/parse-stdout.ts index 6e48b40ed4..1589843ed9 100644 --- a/packages/adapters/hermes/src/ui/parse-stdout.ts +++ b/packages/adapters/hermes/src/ui/parse-stdout.ts @@ -1,3 +1,13 @@ +/** + * Strip ANSI escape sequences (CSI, OSC) from terminal text. + * Same pattern used in claude-local adapter quota.ts. + */ +function stripAnsi(text: string): string { + return text + .replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "") + .replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, ""); +} + /** * Parse Hermes Agent stdout into TranscriptEntry objects for the Paperclip UI. * @@ -184,7 +194,7 @@ export function parseHermesStdoutLine( line: string, ts: string, ): TranscriptEntry[] { - const trimmed = line.trim(); + const trimmed = stripAnsi(line).trim(); if (!trimmed) return []; // ── System/adapter messages ──────────────────────────────────────────── diff --git a/packages/adapters/hermes/ui-parser.cjs b/packages/adapters/hermes/ui-parser.cjs index 618c37606c..48d1ac9062 100644 --- a/packages/adapters/hermes/ui-parser.cjs +++ b/packages/adapters/hermes/ui-parser.cjs @@ -1,5 +1,11 @@ "use strict"; +function stripAnsi(text) { + return text + .replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "") + .replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, ""); +} + const TOOL_OUTPUT_PREFIX = "\u250a"; function stripKaomoji(text) { @@ -114,7 +120,7 @@ function isThinkingLine(line) { } function parseStdoutLine(line, ts) { - const trimmed = line.trim(); + const trimmed = stripAnsi(line).trim(); if (!trimmed) return []; if (trimmed.startsWith("[hermes]") || trimmed.startsWith("[paperclip]")) {