diff --git a/Dockerfile b/Dockerfile index cc503d98b6a..ea2961c49be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,7 @@ COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/ COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/ COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/ COPY packages/adapters/grok-local/package.json packages/adapters/grok-local/ +COPY packages/adapters/hermes-local/package.json packages/adapters/hermes-local/ COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/ COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/ COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/ diff --git a/packages/adapters/hermes-local/README.md b/packages/adapters/hermes-local/README.md index eab4ebfd5be..6d77eb44123 100644 --- a/packages/adapters/hermes-local/README.md +++ b/packages/adapters/hermes-local/README.md @@ -210,10 +210,10 @@ Paperclip UI can display both managed and native skills in one view. ## Development ```bash -git clone https://github.com/paperclipai/hermes-paperclip-adapter -cd hermes-paperclip-adapter -npm install -npm run build +git clone https://github.com/paperclipai/paperclip +cd paperclip/packages/adapters/hermes-local +pnpm install +pnpm build ``` ## License diff --git a/packages/adapters/hermes-local/package.json b/packages/adapters/hermes-local/package.json index e3fa0ebc973..29d0ff6f059 100644 --- a/packages/adapters/hermes-local/package.json +++ b/packages/adapters/hermes-local/package.json @@ -26,7 +26,11 @@ ".": "./src/index.ts", "./server": "./src/server/index.ts", "./ui": "./src/ui/index.ts", - "./cli": "./src/cli/index.ts" + "./cli": "./src/cli/index.ts", + "./ui-parser": "./ui-parser.cjs" + }, + "paperclip": { + "adapterUiParser": "1.0.0" }, "publishConfig": { "access": "public", @@ -46,7 +50,8 @@ "./cli": { "types": "./dist/cli/index.d.ts", "import": "./dist/cli/index.js" - } + }, + "./ui-parser": "./ui-parser.cjs" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -54,6 +59,7 @@ }, "files": [ "dist", + "ui-parser.cjs", "README.md", "LICENSE" ], @@ -64,6 +70,8 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:command-resolution": "vitest run src/server/command-resolution.test.ts", + "prepack": "node scripts/prepare-publish-package.mjs", + "postpack": "node scripts/restore-dev-package.mjs", "clean": "rm -rf dist" }, "dependencies": { diff --git a/packages/adapters/hermes-local/scripts/prepare-publish-package.mjs b/packages/adapters/hermes-local/scripts/prepare-publish-package.mjs new file mode 100644 index 00000000000..b004c53f8b1 --- /dev/null +++ b/packages/adapters/hermes-local/scripts/prepare-publish-package.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const packageDir = resolve(scriptDir, ".."); +const packageJsonPath = join(packageDir, "package.json"); +const devPackageJsonPath = join(packageDir, "package.dev.json"); + +function findRepoRoot(startDir) { + let current = startDir; + while (current !== dirname(current)) { + if (existsSync(join(current, "pnpm-workspace.yaml"))) { + return current; + } + current = dirname(current); + } + return null; +} + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +function collectWorkspaceVersions(repoRoot) { + const roots = ["packages", "server", "ui", "cli"]; + const versions = new Map(); + + function walk(relDir) { + const absDir = join(repoRoot, relDir); + if (!existsSync(absDir)) return; + + const pkgPath = join(absDir, "package.json"); + if (existsSync(pkgPath)) { + const pkg = readJson(pkgPath); + if (pkg.name && pkg.version) { + versions.set(pkg.name, pkg.version); + } + return; + } + + for (const entry of readdirSync(absDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git") continue; + walk(join(relDir, entry.name)); + } + } + + for (const root of roots) { + walk(root); + } + + return versions; +} + +function rewriteWorkspaceDeps(deps, workspaceVersions) { + if (!deps) return deps; + return Object.fromEntries( + Object.entries(deps).map(([name, version]) => { + if (typeof version !== "string" || !version.startsWith("workspace:")) { + return [name, version]; + } + + const resolvedVersion = workspaceVersions.get(name); + if (!resolvedVersion) { + throw new Error(`Cannot resolve workspace dependency ${name} for publish package`); + } + return [name, resolvedVersion]; + }), + ); +} + +const pkg = readJson(packageJsonPath); +const publishConfig = pkg.publishConfig ?? {}; + +if (existsSync(devPackageJsonPath)) { + throw new Error(`Refusing to overwrite existing ${devPackageJsonPath}`); +} + +if (!publishConfig.exports) { + throw new Error(`${pkg.name} is missing publishConfig.exports`); +} + +const repoRoot = findRepoRoot(packageDir); +const workspaceVersions = repoRoot ? collectWorkspaceVersions(repoRoot) : new Map(); + +renameSync(packageJsonPath, devPackageJsonPath); + +const nextPublishConfig = { ...publishConfig }; +delete nextPublishConfig.exports; +delete nextPublishConfig.main; +delete nextPublishConfig.types; + +const publishPkg = { + ...pkg, + exports: publishConfig.exports, + main: publishConfig.main, + types: publishConfig.types, + publishConfig: nextPublishConfig, + dependencies: rewriteWorkspaceDeps(pkg.dependencies, workspaceVersions), + optionalDependencies: rewriteWorkspaceDeps(pkg.optionalDependencies, workspaceVersions), + peerDependencies: rewriteWorkspaceDeps(pkg.peerDependencies, workspaceVersions), +}; + +writeFileSync(packageJsonPath, `${JSON.stringify(publishPkg, null, 2)}\n`); diff --git a/packages/adapters/hermes-local/scripts/restore-dev-package.mjs b/packages/adapters/hermes-local/scripts/restore-dev-package.mjs new file mode 100644 index 00000000000..33e24fc04fb --- /dev/null +++ b/packages/adapters/hermes-local/scripts/restore-dev-package.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import { existsSync, renameSync, rmSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const packageDir = resolve(scriptDir, ".."); +const packageJsonPath = join(packageDir, "package.json"); +const devPackageJsonPath = join(packageDir, "package.dev.json"); + +if (existsSync(devPackageJsonPath)) { + rmSync(packageJsonPath, { force: true }); + renameSync(devPackageJsonPath, packageJsonPath); +} diff --git a/packages/adapters/hermes-local/src/ui/parse-stdout.ts b/packages/adapters/hermes-local/src/ui/parse-stdout.ts index f8b85d7532c..6e48b40ed47 100644 --- a/packages/adapters/hermes-local/src/ui/parse-stdout.ts +++ b/packages/adapters/hermes-local/src/ui/parse-stdout.ts @@ -72,6 +72,7 @@ function parseToolCompletionLine( let verbAndDetail = durationMatch ? cleaned.slice(0, cleaned.lastIndexOf(durationMatch[0])).trim() : cleaned; + verbAndDetail = verbAndDetail.replace(/^\p{Emoji_Presentation}\s*/u, ""); // Check for error suffixes const hasError = /\[(?:exit \d+|error|full)\]/.test(verbAndDetail) || diff --git a/packages/adapters/hermes-local/ui-parser.cjs b/packages/adapters/hermes-local/ui-parser.cjs new file mode 100644 index 00000000000..618c37606c8 --- /dev/null +++ b/packages/adapters/hermes-local/ui-parser.cjs @@ -0,0 +1,193 @@ +"use strict"; + +const TOOL_OUTPUT_PREFIX = "\u250a"; + +function stripKaomoji(text) { + return text.replace(/[(][^()]{2,20}[)]\s*/gu, "").trim(); +} + +function isAssistantToolLine(stripped) { + return /^\u250a\s*\u{1f4ac}/u.test(stripped); +} + +function extractAssistantText(line) { + return line.replace(/^[\s\u250a]*\u{1f4ac}\s*/u, "").trim(); +} + +function parseToolCompletionLine(line) { + let cleaned = line.trim().replace(/^\[done\]\s*/, ""); + if (!cleaned.startsWith(TOOL_OUTPUT_PREFIX)) return null; + + cleaned = cleaned.slice(TOOL_OUTPUT_PREFIX.length); + cleaned = stripKaomoji(cleaned).trim(); + + const durationMatch = cleaned.match(/([\d.]+s)\s*(?:\([\d.]+s\))?\s*$/); + const duration = durationMatch ? durationMatch[1] : ""; + const verbAndDetail = durationMatch + ? cleaned.slice(0, cleaned.lastIndexOf(durationMatch[0])).trim() + : cleaned; + const detailWithoutEmoji = verbAndDetail.replace(/^\p{Emoji_Presentation}\s*/u, ""); + + const hasError = /\[(?:exit \d+|error|full)\]/.test(detailWithoutEmoji) || + /\[error\]\s*$/.test(cleaned); + + const parts = detailWithoutEmoji.match(/^(\S+)\s+(.*)/); + if (!parts) { + return { name: "tool", detail: detailWithoutEmoji, duration, hasError }; + } + + const verb = parts[1]; + const detail = parts[2].trim(); + const nameMap = { + "$": "shell", + exec: "shell", + terminal: "shell", + search: "search", + fetch: "fetch", + crawl: "crawl", + navigate: "browser", + snapshot: "browser", + click: "browser", + type: "browser", + scroll: "browser", + back: "browser", + press: "browser", + close: "browser", + images: "browser", + vision: "browser", + read: "read", + write: "write", + patch: "patch", + grep: "search", + find: "search", + plan: "plan", + recall: "recall", + proc: "process", + delegate: "delegate", + todo: "todo", + memory: "memory", + clarify: "clarify", + session_search: "recall", + code: "execute", + execute: "execute", + web_search: "search", + web_extract: "fetch", + browser_navigate: "browser", + browser_click: "browser", + browser_type: "browser", + browser_snapshot: "browser", + browser_vision: "browser", + browser_scroll: "browser", + browser_press: "browser", + browser_back: "browser", + browser_close: "browser", + browser_get_images: "browser", + read_file: "read", + write_file: "write_file", + search_files: "search", + patch_file: "patch", + execute_code: "execute", + }; + + return { + name: nameMap[verb.toLowerCase()] || verb, + detail, + duration, + hasError, + }; +} + +let toolCallCounter = 0; + +function syntheticToolUseId() { + toolCallCounter += 1; + return `hermes-tool-${toolCallCounter}`; +} + +function isThinkingLine(line) { + return ( + line.includes("\u{1f4ad}") || + line.startsWith("") || + line.startsWith("") || + line.startsWith("Thinking:") + ); +} + +function parseStdoutLine(line, ts) { + const trimmed = line.trim(); + if (!trimmed) return []; + + if (trimmed.startsWith("[hermes]") || trimmed.startsWith("[paperclip]")) { + return [{ kind: "system", ts, text: trimmed }]; + } + + if (trimmed.startsWith("[tool]")) { + return []; + } + + if (/^\[\d{4}-\d{2}-\d{2}T/.test(trimmed)) { + return [{ kind: "stderr", ts, text: trimmed }]; + } + + if (/^\p{Emoji_Presentation}\s*(Completed|Running|Error)?\s*$/u.test(trimmed)) { + return []; + } + + if (trimmed.startsWith("session_id:")) { + return [{ kind: "system", ts, text: trimmed }]; + } + + if (trimmed.includes(TOOL_OUTPUT_PREFIX)) { + if (isAssistantToolLine(trimmed)) { + return [{ kind: "assistant", ts, text: extractAssistantText(trimmed) }]; + } + + const toolInfo = parseToolCompletionLine(trimmed); + if (toolInfo) { + const id = syntheticToolUseId(); + const detailText = toolInfo.duration + ? `${toolInfo.detail} ${toolInfo.duration}` + : toolInfo.detail; + + return [ + { + kind: "tool_call", + ts, + name: toolInfo.name, + input: { detail: toolInfo.detail }, + toolUseId: id, + }, + { + kind: "tool_result", + ts, + toolUseId: id, + content: detailText, + isError: toolInfo.hasError, + }, + ]; + } + + const escapedPrefix = TOOL_OUTPUT_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const stripped = trimmed + .replace(/^\[done\]\s*/, "") + .replace(new RegExp(`^${escapedPrefix}\\s*`), "") + .trim(); + return [{ kind: "stdout", ts, text: stripped }]; + } + + if (isThinkingLine(trimmed)) { + return [{ kind: "thinking", ts, text: trimmed.replace(/^\u{1f4ad}\s*/u, "") }]; + } + + if ( + trimmed.startsWith("Error:") || + trimmed.startsWith("ERROR:") || + trimmed.startsWith("Traceback") + ) { + return [{ kind: "stderr", ts, text: trimmed }]; + } + + return [{ kind: "assistant", ts, text: trimmed }]; +} + +module.exports = { parseStdoutLine };