diff --git a/Dockerfile b/Dockerfile index 8b65b0e210..38b1aedd18 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,7 @@ COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/ 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/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/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index ce89e0e80b..9337fad0b8 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -287,6 +287,12 @@ export interface ServerAdapterModule { * without knowing provider-specific credential paths or API shapes. */ getQuotaWindows?: () => Promise; + /** + * Optional: detect the currently configured model from local config files. + * Returns the detected model/provider and the config source, or null if + * the adapter does not support detection or no config is found. + */ + detectModel?: () => Promise<{ model: string; provider: string; source: string } | null>; } // --------------------------------------------------------------------------- diff --git a/packages/adapters/hermes-local/package.json b/packages/adapters/hermes-local/package.json new file mode 100644 index 0000000000..ec60435475 --- /dev/null +++ b/packages/adapters/hermes-local/package.json @@ -0,0 +1,61 @@ +{ + "name": "@paperclipai/adapter-hermes-local", + "version": "0.3.1", + "license": "MIT", + "homepage": "https://github.com/paperclipai/paperclip", + "bugs": { + "url": "https://github.com/paperclipai/paperclip/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/paperclipai/paperclip", + "directory": "packages/adapters/hermes-local" + }, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./server": "./src/server/index.ts", + "./ui": "./src/ui/index.ts", + "./cli": "./src/cli/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./ui": { + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.js" + }, + "./cli": { + "types": "./dist/cli/index.d.ts", + "import": "./dist/cli/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist", + "skills" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@paperclipai/adapter-utils": "workspace:*", + "picocolors": "^1.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.3" + } +} diff --git a/packages/adapters/hermes-local/src/cli/format-event.ts b/packages/adapters/hermes-local/src/cli/format-event.ts new file mode 100644 index 0000000000..5f44bbeb4d --- /dev/null +++ b/packages/adapters/hermes-local/src/cli/format-event.ts @@ -0,0 +1,61 @@ +/** + * CLI output formatting for Hermes Agent adapter. + * + * Pretty-prints Hermes output lines in the terminal when running + * Paperclip's CLI tools. + */ + +import pc from "picocolors"; + +/** + * Format a Hermes Agent stdout event for terminal display. + * + * @param raw Raw stdout line from Hermes + * @param debug If true, show extra metadata with color coding + */ +export function printHermesStreamEvent(raw: string, debug: boolean): void { + const line = raw.trim(); + if (!line) return; + + if (!debug) { + console.log(line); + return; + } + + // Adapter log lines + if (line.startsWith("[hermes]")) { + console.log(pc.blue(line)); + return; + } + + // Tool output (โ”Š prefix) + if (line.startsWith("โ”Š")) { + console.log(pc.cyan(line)); + return; + } + + // Thinking + if (line.includes("๐Ÿ’ญ") || line.startsWith("")) { + console.log(pc.dim(line)); + return; + } + + // Errors + if ( + line.startsWith("Error:") || + line.startsWith("ERROR:") || + line.startsWith("Traceback") + ) { + console.log(pc.red(line)); + return; + } + + // Session info + if (/session/i.test(line) && /id|saved|resumed/i.test(line)) { + console.log(pc.green(line)); + return; + } + + // Default: gray in debug mode + console.log(pc.gray(line)); +} diff --git a/packages/adapters/hermes-local/src/cli/index.ts b/packages/adapters/hermes-local/src/cli/index.ts new file mode 100644 index 0000000000..2177ef6627 --- /dev/null +++ b/packages/adapters/hermes-local/src/cli/index.ts @@ -0,0 +1,5 @@ +/** + * CLI module exports โ€” used by Paperclip's CLI for terminal formatting. + */ + +export { printHermesStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/hermes-local/src/index.ts b/packages/adapters/hermes-local/src/index.ts new file mode 100644 index 0000000000..0abc4c2ff6 --- /dev/null +++ b/packages/adapters/hermes-local/src/index.ts @@ -0,0 +1,83 @@ +/** + * Hermes Agent adapter for Paperclip. + * + * Runs Hermes Agent (https://github.com/NousResearch/hermes-agent) + * as a managed employee in a Paperclip company. Hermes Agent is a + * full-featured AI agent with 30+ native tools, persistent memory, + * skills, session persistence, and MCP support. + * + * @packageDocumentation + */ + +import { ADAPTER_TYPE, ADAPTER_LABEL } from "./shared/constants.js"; + +export const type = ADAPTER_TYPE; +export const label = ADAPTER_LABEL; + +/** + * Models available through Hermes Agent. + * + * Hermes supports any model via any provider โ€” the list is empty + * and the UI uses detectModel() + free-text input instead. + */ +export const models: { id: string; label: string }[] = []; + +/** + * Documentation shown in the Paperclip UI when configuring a Hermes agent. + */ +export const agentConfigurationDoc = `# Hermes Agent Configuration + +Hermes Agent is a full-featured AI agent by Nous Research with 30+ native +tools, persistent memory, session persistence, skills, and MCP support. + +## Prerequisites + +- Python 3.10+ installed +- Hermes Agent installed: \`pip install hermes-agent\` +- At least one LLM API key configured in ~/.hermes/.env + +## Core Configuration + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| model | string | anthropic/claude-sonnet-4 | Model to use (provider/model format) | +| provider | string | (auto) | API provider: auto, openrouter, nous, openai-codex, zai, kimi-coding, minimax, minimax-cn. Usually not needed โ€” Hermes auto-detects from model name. | +| timeoutSec | number | 300 | Execution timeout in seconds | +| graceSec | number | 10 | Grace period after SIGTERM before SIGKILL | + +## Tool Configuration + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| toolsets | string | (all) | Comma-separated toolsets to enable (e.g. "terminal,file,web") | + +## Session & Workspace + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| persistSession | boolean | true | Resume sessions across heartbeats | +| worktreeMode | boolean | false | Use git worktree for isolated changes | +| checkpoints | boolean | false | Enable filesystem checkpoints | + +## Advanced + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| hermesCommand | string | hermes | Path to hermes CLI binary | +| verbose | boolean | false | Enable verbose output | +| extraArgs | string[] | [] | Additional CLI arguments | +| env | object | {} | Extra environment variables | +| promptTemplate | string | (default) | Custom prompt template with {{variable}} placeholders | + +## Available Template Variables + +- \`{{agentId}}\` โ€” Paperclip agent ID +- \`{{agentName}}\` โ€” Agent display name +- \`{{companyId}}\` โ€” Paperclip company ID +- \`{{companyName}}\` โ€” Company display name +- \`{{runId}}\` โ€” Current heartbeat run ID +- \`{{taskId}}\` โ€” Current task/issue ID (if assigned) +- \`{{taskTitle}}\` โ€” Task title (if assigned) +- \`{{taskBody}}\` โ€” Task description (if assigned) +- \`{{projectName}}\` โ€” Project name (if scoped to a project) +`; diff --git a/packages/adapters/hermes-local/src/server/detect-model.ts b/packages/adapters/hermes-local/src/server/detect-model.ts new file mode 100644 index 0000000000..2ecabbf498 --- /dev/null +++ b/packages/adapters/hermes-local/src/server/detect-model.ts @@ -0,0 +1,77 @@ +/** + * Detect the current model from the user's Hermes config. + * + * Reads ~/.hermes/config.yaml and extracts the default model + * and provider settings. + */ + +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +export interface DetectedModel { + model: string; + provider: string; + source: "config"; +} + +/** + * Read the Hermes config file and extract the default model. + */ +export async function detectModel( + configPath?: string, +): Promise { + const filePath = configPath ?? join(homedir(), ".hermes", "config.yaml"); + + let content: string; + try { + content = await readFile(filePath, "utf-8"); + } catch { + return null; + } + + return parseModelFromConfig(content); +} + +/** + * Parse model.default and model.provider from raw YAML content. + * Uses simple regex parsing to avoid a YAML dependency. + */ +export function parseModelFromConfig(content: string): DetectedModel | null { + const lines = content.split("\n"); + let model = ""; + let provider = ""; + let inModelSection = false; + let modelSectionIndent = 0; + + for (const line of lines) { + const trimmed = line.trimEnd(); + const indent = line.length - line.trimStart().length; + + // Track model: section (indent 0) + if (/^model:\s*$/.test(trimmed) && indent === 0) { + inModelSection = true; + modelSectionIndent = 0; + continue; + } + + // We left the model section if indent drops back to the section level or below + if (inModelSection && indent <= modelSectionIndent && trimmed && !trimmed.startsWith("#")) { + inModelSection = false; + } + + if (inModelSection) { + const match = trimmed.match(/^\s*(\w+)\s*:\s*(.+)$/); + if (match) { + const key = match[1]; + const val = match[2].trim().replace(/#.*$/, "").trim().replace(/^['"]|['"]$/g, ""); + if (key === "default") model = val; + if (key === "provider") provider = val; + } + } + } + + if (!model) return null; + + return { model, provider, source: "config" }; +} diff --git a/packages/adapters/hermes-local/src/server/execute.ts b/packages/adapters/hermes-local/src/server/execute.ts new file mode 100644 index 0000000000..77bce887b7 --- /dev/null +++ b/packages/adapters/hermes-local/src/server/execute.ts @@ -0,0 +1,500 @@ +/** + * Server-side execution logic for the Hermes Agent adapter. + * + * Spawns `hermes chat -q "..." -Q` as a child process, streams output, + * and returns structured results to Paperclip. + * + * Verified CLI flags (hermes chat): + * -q/--query single query (non-interactive) + * -Q/--quiet quiet mode (no banner/spinner, only response + session_id) + * -m/--model model name (e.g. anthropic/claude-sonnet-4) + * -t/--toolsets comma-separated toolsets to enable + * --provider inference provider (auto, openrouter, nous, etc.) + * -r/--resume resume session by ID + * -w/--worktree isolated git worktree + * -v/--verbose verbose output + * --checkpoints filesystem checkpoints + */ + +import type { + AdapterExecutionContext, + AdapterExecutionResult, + UsageSummary, +} from "@paperclipai/adapter-utils"; + +import { + runChildProcess, + buildPaperclipEnv, + renderTemplate, + ensureAbsoluteDirectory, +} from "@paperclipai/adapter-utils/server-utils"; + +import { + HERMES_CLI, + DEFAULT_TIMEOUT_SEC, + DEFAULT_GRACE_SEC, + VALID_PROVIDERS, +} from "../shared/constants.js"; + +// --------------------------------------------------------------------------- +// Config helpers +// --------------------------------------------------------------------------- + +function cfgString(v: unknown): string | undefined { + return typeof v === "string" && v.length > 0 ? v : undefined; +} +function cfgNumber(v: unknown): number | undefined { + return typeof v === "number" ? v : undefined; +} +function cfgBoolean(v: unknown): boolean | undefined { + return typeof v === "boolean" ? v : undefined; +} +function cfgStringArray(v: unknown): string[] | undefined { + return Array.isArray(v) && v.every((i) => typeof i === "string") + ? (v as string[]) + : undefined; +} + +// --------------------------------------------------------------------------- +// Wake-up prompt builder +// --------------------------------------------------------------------------- + +const DEFAULT_PROMPT_TEMPLATE = `You are "{{agentName}}", an AI agent employee in a Paperclip-managed company. + +IMPORTANT: Use \`terminal\` tool with \`curl\` for ALL Paperclip API calls (web_extract and browser cannot access localhost). + +Your Paperclip identity: + Agent ID: {{agentId}} + Company ID: {{companyId}} + API Base: {{paperclipApiUrl}} + +{{#taskId}} +## Assigned Task + +Issue ID: {{taskId}} +Title: {{taskTitle}} + +{{taskBody}} + +## Workflow + +1. Work on the task using your tools +2. When done, mark the issue as completed: + \`curl -s -X PATCH "{{paperclipApiUrl}}/issues/{{taskId}}" -H "Content-Type: application/json" -d '{"status":"done"}'\` +3. Post a completion comment on the issue summarizing what you did: + \`curl -s -X POST "{{paperclipApiUrl}}/issues/{{taskId}}/comments" -H "Content-Type: application/json" -d '{"body":"DONE: "}'\` +4. If this issue has a parent (check the issue body or comments for references like TRA-XX), post a brief notification on the parent issue so the parent owner knows: + \`curl -s -X POST "{{paperclipApiUrl}}/issues/PARENT_ISSUE_ID/comments" -H "Content-Type: application/json" -d '{"body":"{{agentName}} completed {{taskId}}. Summary: "}'\` +{{/taskId}} + +{{#commentId}} +## Comment on This Issue + +Someone commented. Read it: + \`curl -s "{{paperclipApiUrl}}/issues/{{taskId}}/comments/{{commentId}}" | python3 -m json.tool\` + +Address the comment, POST a reply if needed, then continue working. +{{/commentId}} + +{{#noTask}} +## Heartbeat Wake โ€” Check for Work + +1. List ALL open issues assigned to you (todo, backlog, in_progress): + \`curl -s "{{paperclipApiUrl}}/companies/{{companyId}}/issues?assigneeAgentId={{agentId}}" | python3 -c "import sys,json;issues=json.loads(sys.stdin.read());[print(f'{i[\"identifier\"]} {i[\"status\"]:>12} {i[\"priority\"]:>6} {i[\"title\"]}') for i in issues if i['status'] not in ('done','cancelled')]" \` + +2. If issues found, pick the highest priority one that is not done/cancelled and work on it: + - Read the issue details: \`curl -s "{{paperclipApiUrl}}/issues/ISSUE_ID"\` + - Do the work in the project directory: {{projectName}} + - When done, mark complete and post a comment (see Workflow steps 2-4 above) + +3. If no issues assigned to you, check for unassigned issues: + \`curl -s "{{paperclipApiUrl}}/companies/{{companyId}}/issues?status=backlog" | python3 -c "import sys,json;issues=json.loads(sys.stdin.read());[print(f'{i[\"identifier\"]} {i[\"title\"]}') for i in issues if not i.get('assigneeAgentId')]" \` + If you find a relevant issue, assign it to yourself: + \`curl -s -X PATCH "{{paperclipApiUrl}}/issues/ISSUE_ID" -H "Content-Type: application/json" -d '{"assigneeAgentId":"{{agentId}}","status":"todo"}'\` + +4. If truly nothing to do, report briefly what you checked. +{{/noTask}}`; + +function buildPrompt( + ctx: AdapterExecutionContext, + config: Record, +): string { + const template = cfgString(config.promptTemplate) || DEFAULT_PROMPT_TEMPLATE; + + const taskId = cfgString(ctx.config?.taskId); + const taskTitle = cfgString(ctx.config?.taskTitle) || ""; + const taskBody = cfgString(ctx.config?.taskBody) || ""; + const commentId = cfgString(ctx.config?.commentId) || ""; + const wakeReason = cfgString(ctx.config?.wakeReason) || ""; + const agentName = ctx.agent?.name || "Hermes Agent"; + const companyName = cfgString(ctx.config?.companyName) || ""; + const projectName = cfgString(ctx.config?.projectName) || ""; + + // Build API URL โ€” ensure it has the /api path + let paperclipApiUrl = + cfgString(config.paperclipApiUrl) || + process.env.PAPERCLIP_API_URL || + "http://127.0.0.1:3100/api"; + // Ensure /api suffix + if (!paperclipApiUrl.endsWith("/api")) { + paperclipApiUrl = paperclipApiUrl.replace(/\/+$/, "") + "/api"; + } + + const vars: Record = { + agentId: ctx.agent?.id || "", + agentName, + companyId: ctx.agent?.companyId || "", + companyName, + runId: ctx.runId || "", + taskId: taskId || "", + taskTitle, + taskBody, + commentId, + wakeReason, + projectName, + paperclipApiUrl, + }; + + // Handle conditional sections: {{#key}}...{{/key}} + let rendered = template; + + // {{#taskId}}...{{/taskId}} โ€” include if task is assigned + rendered = rendered.replace( + /\{\{#taskId\}\}([\s\S]*?)\{\{\/taskId\}\}/g, + taskId ? "$1" : "", + ); + + // {{#noTask}}...{{/noTask}} โ€” include if no task + rendered = rendered.replace( + /\{\{#noTask\}\}([\s\S]*?)\{\{\/noTask\}\}/g, + taskId ? "" : "$1", + ); + + // {{#commentId}}...{{/commentId}} โ€” include if comment exists + rendered = rendered.replace( + /\{\{#commentId\}\}([\s\S]*?)\{\{\/commentId\}\}/g, + commentId ? "$1" : "", + ); + + // Replace remaining {{variable}} placeholders + return renderTemplate(rendered, vars); +} + +// --------------------------------------------------------------------------- +// Output parsing +// --------------------------------------------------------------------------- + +/** Regex to extract session ID from Hermes quiet-mode output: "session_id: " */ +const SESSION_ID_REGEX = /^session_id:\s*(\S+)/m; + +/** Regex for legacy session output format */ +const SESSION_ID_REGEX_LEGACY = /session[_ ](?:id|saved)[:\s]+([a-zA-Z0-9_-]+)/i; + +/** Regex to extract token usage from Hermes output. */ +const TOKEN_USAGE_REGEX = + /tokens?[:\s]+(\d+)\s*(?:input|in)\b.*?(\d+)\s*(?:output|out)\b/i; + +/** Regex to extract cost from Hermes output. */ +const COST_REGEX = /(?:cost|spent)[:\s]*\$?([\d.]+)/i; + +interface ParsedOutput { + sessionId?: string; + response?: string; + usage?: UsageSummary; + costUsd?: number; + errorMessage?: string; +} + +// --------------------------------------------------------------------------- +// Response cleaning +// --------------------------------------------------------------------------- + +/** Strip noise lines from a Hermes response (tool output, system messages, etc.) */ +function cleanResponse(raw: string): string { + return raw + .split("\n") + .filter((line) => { + const t = line.trim(); + if (!t) return true; // keep blank lines for paragraph separation + if (t.startsWith("[tool]") || t.startsWith("[hermes]") || t.startsWith("[paperclip]")) return false; + if (t.startsWith("session_id:")) return false; + if (/^\[\d{4}-\d{2}-\d{2}T/.test(t)) return false; + if (/^\[done\]\s*โ”Š/.test(t)) return false; + if (/^โ”Š\s*[\p{Emoji_Presentation}]/u.test(t) && !/^โ”Š\s*๐Ÿ’ฌ/.test(t)) return false; + if (/^\p{Emoji_Presentation}\s*(Completed|Running|Error)?\s*$/u.test(t)) return false; + return true; + }) + .map((line) => { + let t = line.replace(/^[\s]*โ”Š\s*๐Ÿ’ฌ\s*/, "").trim(); + t = t.replace(/^\[done\]\s*/, "").trim(); + return t; + }) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +// --------------------------------------------------------------------------- +// Output parsing +// --------------------------------------------------------------------------- + +function parseHermesOutput(stdout: string, stderr: string): ParsedOutput { + const combined = stdout + "\n" + stderr; + const result: ParsedOutput = {}; + + // In quiet mode, Hermes outputs: + // + // + // session_id: + const sessionMatch = stdout.match(SESSION_ID_REGEX); + if (sessionMatch?.[1]) { + result.sessionId = sessionMatch?.[1] ?? null; + // The response is everything before the session_id line + const sessionLineIdx = stdout.lastIndexOf("\nsession_id:"); + if (sessionLineIdx > 0) { + result.response = cleanResponse(stdout.slice(0, sessionLineIdx)); + } + } else { + // Legacy format (non-quiet mode) + const legacyMatch = combined.match(SESSION_ID_REGEX_LEGACY); + if (legacyMatch?.[1]) { + result.sessionId = legacyMatch?.[1] ?? null; + } + // In non-quiet mode, extract clean response from stdout by + // filtering out tool lines, system messages, and noise + const cleanLines = stdout + .split("\n") + .filter((line) => { + const t = line.trim(); + if (!t) return false; + if (t.startsWith("[tool]") || t.startsWith("[hermes]") || t.startsWith("[paperclip]")) return false; + if (t.startsWith("session_id:")) return false; + if (/^\[\d{4}-\d{2}-\d{2}T/.test(t)) return false; // timestamp logs + if (/^\[done\]\s*โ”Š/.test(t)) return false; // done tool lines + if (/^โ”Š\s*[\p{Emoji_Presentation}]/u.test(t) && !/^โ”Š\s*๐Ÿ’ฌ/.test(t)) return false; // tool โ”Š lines (not assistant) + if (/^\p{Emoji_Presentation}\s*(Completed|Running|Error)?\s*$/u.test(t)) return false; // spinner remnants + return true; + }) + .map((line) => { + // Strip โ”Š ๐Ÿ’ฌ prefix from assistant lines + let t = line.replace(/^[\s]*โ”Š\s*๐Ÿ’ฌ\s*/, "").trim(); + // Strip [done] prefix + t = t.replace(/^\[done\]\s*/, "").trim(); + return t; + }); + if (cleanLines.length > 0) { + result.response = cleanLines.join("\n"); + } + } + + // Extract token usage + const usageMatch = combined.match(TOKEN_USAGE_REGEX); + if (usageMatch) { + result.usage = { + inputTokens: parseInt(usageMatch[1], 10) || 0, + outputTokens: parseInt(usageMatch[2], 10) || 0, + }; + } + + // Extract cost + const costMatch = combined.match(COST_REGEX); + if (costMatch?.[1]) { + result.costUsd = parseFloat(costMatch[1]); + } + + // Check for error patterns in stderr + if (stderr.trim()) { + const errorLines = stderr + .split("\n") + .filter((line) => /error|exception|traceback|failed/i.test(line)) + .filter((line) => !/INFO|DEBUG|warn/i.test(line)); // skip log-level noise + if (errorLines.length > 0) { + result.errorMessage = errorLines.slice(0, 5).join("\n"); + } + } + + return result; +} + +// --------------------------------------------------------------------------- +// Main execute +// --------------------------------------------------------------------------- + +export async function execute( + ctx: AdapterExecutionContext, +): Promise { + const config = (ctx.agent?.adapterConfig ?? {}) as Record; + + // โ”€โ”€ Resolve configuration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const hermesCmd = cfgString(config.hermesCommand) || HERMES_CLI; + const model = cfgString(config.model); + const provider = cfgString(config.provider); + const timeoutSec = cfgNumber(config.timeoutSec) || DEFAULT_TIMEOUT_SEC; + const graceSec = cfgNumber(config.graceSec) || DEFAULT_GRACE_SEC; + const toolsets = cfgString(config.toolsets) || cfgStringArray(config.enabledToolsets)?.join(","); + const extraArgs = cfgStringArray(config.extraArgs); + const persistSession = cfgBoolean(config.persistSession) !== false; + const worktreeMode = cfgBoolean(config.worktreeMode) === true; + const checkpoints = cfgBoolean(config.checkpoints) === true; + + // โ”€โ”€ Build prompt โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const prompt = buildPrompt(ctx, config); + + // โ”€โ”€ Build command args โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Use -Q (quiet) to get clean output: just response + session_id line + const useQuiet = cfgBoolean(config.quiet) !== false; // default true + const args: string[] = ["chat", "-q", prompt]; + if (useQuiet) args.push("-Q"); + + if (model) args.push("-m", model); + + // Only pass --provider if it's a valid Hermes provider choice. + if (provider && (VALID_PROVIDERS as readonly string[]).includes(provider)) { + args.push("--provider", provider); + } + + if (toolsets) { + args.push("-t", toolsets); + } + + if (worktreeMode) args.push("-w"); + if (checkpoints) args.push("--checkpoints"); + if (cfgBoolean(config.verbose) === true) args.push("-v"); + + // Tag sessions as "tool" source so they don't clutter the user's session history. + // Requires hermes-agent >= PR #3255 (feat/session-source-tag). + args.push("--source", "tool"); + + // Session resume + const prevSessionId = cfgString( + (ctx.runtime?.sessionParams as Record | null)?.sessionId, + ); + if (persistSession && prevSessionId) { + args.push("--resume", prevSessionId); + } + + if (extraArgs?.length) { + args.push(...extraArgs); + } + + // โ”€โ”€ Build environment โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const env: Record = { + ...(process.env as Record), + ...buildPaperclipEnv(ctx.agent), + }; + + if (ctx.runId) env.PAPERCLIP_RUN_ID = ctx.runId; + const taskId = cfgString(ctx.config?.taskId); + if (taskId) env.PAPERCLIP_TASK_ID = taskId; + + const userEnv = config.env as Record | undefined; + if (userEnv && typeof userEnv === "object") { + Object.assign(env, userEnv); + } + + // โ”€โ”€ Resolve working directory โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const cwd = + cfgString(config.cwd) || cfgString(ctx.config?.workspaceDir) || "."; + try { + await ensureAbsoluteDirectory(cwd); + } catch { + // Non-fatal + } + + // โ”€โ”€ Log start โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + await ctx.onLog( + "stdout", + `[hermes] Starting Hermes Agent (model=${model ?? "configured-default"}, timeout=${timeoutSec}s)\n`, + ); + if (prevSessionId) { + await ctx.onLog( + "stdout", + `[hermes] Resuming session: ${prevSessionId}\n`, + ); + } + + // โ”€โ”€ Execute โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Hermes writes non-error noise to stderr (MCP init, INFO logs, etc). + // Paperclip renders all stderr as red/error in the UI. + // Wrap onLog to reclassify benign stderr lines as stdout. + const wrappedOnLog = async (stream: "stdout" | "stderr", chunk: string) => { + if (stream === "stderr") { + const trimmed = chunk.trimEnd(); + // Benign patterns that should NOT appear as errors: + // - Structured log lines: [timestamp] INFO/DEBUG/WARN: ... + // - MCP server registration messages + // - Python import/site noise + const isBenign = /^\[?\d{4}[-/]\d{2}[-/]\d{2}T/.test(trimmed) || // structured timestamps + /^[A-Z]+:\s+(INFO|DEBUG|WARN|WARNING)\b/.test(trimmed) || // log levels + /Successfully registered all tools/.test(trimmed) || + /MCP [Ss]erver/.test(trimmed) || + /tool registered successfully/.test(trimmed) || + /Application initialized/.test(trimmed); + if (isBenign) { + return ctx.onLog("stdout", chunk); + } + } + return ctx.onLog(stream, chunk); + }; + + const result = await runChildProcess(ctx.runId, hermesCmd, args, { + cwd, + env, + timeoutSec, + graceSec, + onLog: wrappedOnLog, + }); + + // โ”€โ”€ Parse output โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const parsed = parseHermesOutput(result.stdout || "", result.stderr || ""); + + await ctx.onLog( + "stdout", + `[hermes] Exit code: ${result.exitCode ?? "null"}, timed out: ${result.timedOut}\n`, + ); + if (parsed.sessionId) { + await ctx.onLog("stdout", `[hermes] Session: ${parsed.sessionId}\n`); + } + + // โ”€โ”€ Build result โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const executionResult: AdapterExecutionResult = { + exitCode: result.exitCode, + signal: result.signal, + timedOut: result.timedOut, + provider: provider || null, + model: model || null, + }; + + if (parsed.errorMessage) { + executionResult.errorMessage = parsed.errorMessage; + } + + if (parsed.usage) { + executionResult.usage = parsed.usage; + } + + if (parsed.costUsd !== undefined) { + executionResult.costUsd = parsed.costUsd; + } + + // Summary from agent response + if (parsed.response) { + executionResult.summary = parsed.response.slice(0, 2000); + } + + // Set resultJson so Paperclip can persist run metadata (used for UI display + auto-comments) + executionResult.resultJson = { + result: parsed.response || "", + session_id: parsed.sessionId || null, + usage: parsed.usage || null, + cost_usd: parsed.costUsd ?? null, + }; + + // Store session ID for next run + if (persistSession && parsed.sessionId) { + executionResult.sessionParams = { sessionId: parsed.sessionId }; + executionResult.sessionDisplayId = parsed.sessionId.slice(0, 16); + } + + return executionResult; +} diff --git a/packages/adapters/hermes-local/src/server/index.ts b/packages/adapters/hermes-local/src/server/index.ts new file mode 100644 index 0000000000..d30de7c210 --- /dev/null +++ b/packages/adapters/hermes-local/src/server/index.ts @@ -0,0 +1,48 @@ +/** + * Server-side adapter module exports. + */ + +export { execute } from "./execute.js"; +export { testEnvironment } from "./test.js"; +export { detectModel } from "./detect-model.js"; +export { + listHermesSkills as listSkills, + syncHermesSkills as syncSkills, + resolveHermesDesiredSkillNames as resolveDesiredSkillNames, +} from "./skills.js"; + +import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +/** + * Session codec for structured validation and migration of session parameters. + * + * Hermes Agent uses a single `sessionId` for cross-heartbeat session continuity + * via the `--resume` CLI flag. The codec validates and normalizes this field. + */ +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw: unknown) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record; + const sessionId = + readNonEmptyString(record.sessionId) ?? + readNonEmptyString(record.session_id); + if (!sessionId) return null; + return { sessionId }; + }, + serialize(params: Record | null) { + if (!params) return null; + const sessionId = + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id); + if (!sessionId) return null; + return { sessionId }; + }, + getDisplayId(params: Record | null) { + if (!params) return null; + return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id); + }, +}; diff --git a/packages/adapters/hermes-local/src/server/skills.ts b/packages/adapters/hermes-local/src/server/skills.ts new file mode 100644 index 0000000000..640b044323 --- /dev/null +++ b/packages/adapters/hermes-local/src/server/skills.ts @@ -0,0 +1,228 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { + AdapterSkillContext, + AdapterSkillEntry, + AdapterSkillSnapshot, +} from "@paperclipai/adapter-utils"; +import { + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, +} from "@paperclipai/adapter-utils/server-utils"; +import { fileURLToPath } from "node:url"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function resolveHermesHome(config: Record): string { + const env = + typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) + ? (config.env as Record) + : {}; + const configuredHome = asString(env.HOME); + return configuredHome ? path.resolve(configuredHome) : os.homedir(); +} + +interface SkillFrontmatter { + name?: string; + description?: string; + version?: string; + category?: string; + metadata?: Record; +} + +function parseSkillFrontmatter(content: string): SkillFrontmatter { + const match = content.match(/^---\s*\n([\s\S]*?)\n---/); + if (!match) return {}; + const frontmatter: Record = {}; + for (const line of match[1].split("\n")) { + const idx = line.indexOf(":"); + if (idx === -1) continue; + const key = line.slice(0, idx).trim(); + let val: unknown = line.slice(idx + 1).trim(); + // Strip quotes + if (typeof val === "string" && ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")))) { + val = val.slice(1, -1); + } + frontmatter[key] = val; + } + return frontmatter as SkillFrontmatter; +} + +async function scanHermesSkills( + skillsHome: string, +): Promise { + const entries: AdapterSkillEntry[] = []; + + try { + const categories = await fs.readdir(skillsHome, { withFileTypes: true }); + for (const cat of categories) { + if (!cat.isDirectory()) continue; + const catPath = path.join(skillsHome, cat.name); + + // Check if the category directory itself has a SKILL.md (top-level skill) + const topLevelSkillMd = path.join(catPath, "SKILL.md"); + if (await fs.stat(topLevelSkillMd).catch(() => null)) { + entries.push(await buildSkillEntry(cat.name, topLevelSkillMd, cat.name)); + } + + // Scan for sub-skills + const items = await fs.readdir(catPath, { withFileTypes: true }).catch(() => []); + for (const item of items) { + if (!item.isDirectory()) continue; + const skillMd = path.join(catPath, item.name, "SKILL.md"); + if (await fs.stat(skillMd).catch(() => null)) { + const key = item.name; + entries.push(await buildSkillEntry(key, skillMd, `${cat.name}/${item.name}`)); + } + } + } + } catch { + // ~/.hermes/skills/ doesn't exist โ€” no skills available + } + + return entries.sort((a, b) => a.key.localeCompare(b.key)); +} + +async function buildSkillEntry( + key: string, + skillMdPath: string, + categoryPath: string, +): Promise { + let description: string | null = null; + try { + const content = await fs.readFile(skillMdPath, "utf8"); + const fm = parseSkillFrontmatter(content); + description = fm.description ?? null; + } catch { + // ignore + } + + return { + key, + runtimeName: key, + desired: true, // Hermes loads all available skills + managed: false, + state: "installed", + origin: "user_installed", + originLabel: "Hermes skill", + locationLabel: `~/.hermes/skills/${categoryPath}`, + readOnly: true, // Hermes manages its own skills โ€” Paperclip can't toggle them + sourcePath: skillMdPath, + targetPath: null, + detail: description, + }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +async function buildHermesSkillSnapshot(config: Record): Promise { + const home = resolveHermesHome(config); + const hermesSkillsHome = path.join(home, ".hermes", "skills"); + + // 1. Scan Paperclip-managed skills (bundled with the adapter) + const paperclipEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, paperclipEntries); + const desiredSet = new Set(desiredSkills); + const availableByKey = new Map(paperclipEntries.map((e) => [e.key, e])); + + // 2. Scan Hermes's own skills from ~/.hermes/skills/ + const hermesSkillEntries = await scanHermesSkills(hermesSkillsHome); + const hermesKeys = new Set(hermesSkillEntries.map((e) => e.key)); + + // 3. Merge: Paperclip skills first (ephemeral), then Hermes skills + const entries: AdapterSkillEntry[] = []; + const warnings: string[] = []; + + // Paperclip-managed skills + for (const entry of paperclipEntries) { + const desired = desiredSet.has(entry.key); + entries.push({ + key: entry.key, + runtimeName: entry.runtimeName, + desired, + managed: true, + state: desired ? "configured" : "available", + origin: entry.required ? "paperclip_required" : "company_managed", + originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip", + readOnly: false, + sourcePath: entry.source, + targetPath: null, + detail: desired + ? "Will be available on the next run via Hermes skill loading." + : null, + required: Boolean(entry.required), + requiredReason: entry.requiredReason ?? null, + }); + } + + // Hermes-installed skills (read-only, always loaded) + for (const entry of hermesSkillEntries) { + // Skip if Paperclip already manages a skill with the same key + if (availableByKey.has(entry.key)) continue; + entries.push(entry); + } + + // Check for desired skills that don't exist + for (const desiredSkill of desiredSkills) { + if (availableByKey.has(desiredSkill) || hermesKeys.has(desiredSkill)) continue; + warnings.push( + `Desired skill "${desiredSkill}" is not available in Paperclip or Hermes skills.`, + ); + entries.push({ + key: desiredSkill, + runtimeName: null, + desired: true, + managed: true, + state: "missing", + origin: "external_unknown", + originLabel: "External or unavailable", + readOnly: false, + sourcePath: null, + targetPath: null, + detail: + "Cannot find this skill in Paperclip or ~/.hermes/skills/.", + }); + } + + return { + adapterType: "hermes_local", + supported: true, + mode: "persistent", + desiredSkills, + entries, + warnings, + }; +} + +export async function listHermesSkills( + ctx: AdapterSkillContext, +): Promise { + return buildHermesSkillSnapshot(ctx.config); +} + +export async function syncHermesSkills( + ctx: AdapterSkillContext, + _desiredSkills: string[], +): Promise { + // Hermes manages its own skill loading โ€” sync is a no-op. + // Return the current snapshot so the UI stays in sync. + return buildHermesSkillSnapshot(ctx.config); +} + +export function resolveHermesDesiredSkillNames( + config: Record, + availableEntries: Array<{ key: string; required?: boolean }>, +): string[] { + return resolvePaperclipDesiredSkillNames(config, availableEntries); +} diff --git a/packages/adapters/hermes-local/src/server/test.ts b/packages/adapters/hermes-local/src/server/test.ts new file mode 100644 index 0000000000..a69d964303 --- /dev/null +++ b/packages/adapters/hermes-local/src/server/test.ts @@ -0,0 +1,225 @@ +/** + * Environment test for the Hermes Agent adapter. + * + * Verifies that Hermes Agent is installed, accessible, and configured + * before allowing the adapter to be used. + */ + +import type { + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, + AdapterEnvironmentCheck, +} from "@paperclipai/adapter-utils"; + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { HERMES_CLI, ADAPTER_TYPE } from "../shared/constants.js"; + +const execFileAsync = promisify(execFile); + +function asString(v: unknown): string | undefined { + return typeof v === "string" ? v : undefined; +} + +// --------------------------------------------------------------------------- +// Checks +// --------------------------------------------------------------------------- + +async function checkCliInstalled( + command: string, +): Promise { + try { + // Try to run the command to see if it exists + await execFileAsync(command, ["--version"], { timeout: 10_000 }); + return null; // OK โ€” it ran successfully + } catch (err: unknown) { + const e = err as NodeJS.ErrnoException; + if (e.code === "ENOENT") { + return { + level: "error", + message: `Hermes CLI "${command}" not found in PATH`, + hint: "Install Hermes Agent: pip install hermes-agent", + code: "hermes_cli_not_found", + }; + } + // Command exists but --version might have failed for some reason + // Still consider it installed + return null; + } +} + +async function checkCliVersion( + command: string, +): Promise { + try { + const { stdout } = await execFileAsync(command, ["--version"], { + timeout: 10_000, + }); + const version = stdout.trim(); + if (version) { + return { + level: "info", + message: `Hermes Agent version: ${version}`, + code: "hermes_version", + }; + } + return { + level: "warn", + message: "Could not determine Hermes Agent version", + code: "hermes_version_unknown", + }; + } catch { + return { + level: "warn", + message: + "Could not determine Hermes Agent version (hermes --version failed)", + hint: "Make sure the hermes CLI is properly installed and functional", + code: "hermes_version_failed", + }; + } +} + +async function checkPython(): Promise { + try { + const { stdout } = await execFileAsync("python3", ["--version"], { + timeout: 5_000, + }); + const version = stdout.trim(); + const match = version.match(/(\d+)\.(\d+)/); + if (match) { + const major = parseInt(match[1], 10); + const minor = parseInt(match[2], 10); + if (major < 3 || (major === 3 && minor < 10)) { + return { + level: "error", + message: `Python ${version} found โ€” Hermes requires Python 3.10+`, + hint: "Upgrade Python to 3.10 or later", + code: "hermes_python_old", + }; + } + } + return null; // OK + } catch { + return { + level: "warn", + message: "python3 not found in PATH", + hint: "Hermes Agent requires Python 3.10+. Install it from python.org", + code: "hermes_python_missing", + }; + } +} + +function checkModel( + config: Record, +): AdapterEnvironmentCheck | null { + const model = asString(config.model); + if (!model) { + return { + level: "info", + message: "No model specified โ€” Hermes will use its configured default model", + hint: "Set a model explicitly in Paperclip only if you want to override your local Hermes configuration.", + code: "hermes_configured_default_model", + }; + } + return { + level: "info", + message: `Model: ${model}`, + code: "hermes_model_configured", + }; +} + +function checkApiKeys( + config: Record, +): AdapterEnvironmentCheck | null { + // The server resolves secret refs into config.env before calling testEnvironment, + // so we check config.env first (adapter-configured secrets), then fall back to + // process.env (server/host environment). This mirrors how the Claude adapter does it. + const envConfig = (config.env ?? {}) as Record; + const resolvedEnv: Record = {}; + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string" && value.length > 0) resolvedEnv[key] = value; + } + + const has = (key: string): boolean => + !!(resolvedEnv[key] ?? process.env[key]); + + const hasAnthropic = has("ANTHROPIC_API_KEY"); + const hasOpenRouter = has("OPENROUTER_API_KEY"); + const hasOpenAI = has("OPENAI_API_KEY"); + const hasZai = has("ZAI_API_KEY"); + + if (!hasAnthropic && !hasOpenRouter && !hasOpenAI && !hasZai) { + return { + level: "warn", + message: "No LLM API keys found in environment", + hint: "Set ANTHROPIC_API_KEY, OPENROUTER_API_KEY, OPENAI_API_KEY, or ZAI_API_KEY in the agent's env secrets. Hermes may also have keys configured in ~/.hermes/.env", + code: "hermes_no_api_keys", + }; + } + + const providers: string[] = []; + if (hasAnthropic) providers.push("Anthropic"); + if (hasOpenRouter) providers.push("OpenRouter"); + if (hasOpenAI) providers.push("OpenAI"); + if (hasZai) providers.push("Z.AI"); + + return { + level: "info", + message: `API keys found: ${providers.join(", ")}`, + code: "hermes_api_keys_found", + }; +} + +// --------------------------------------------------------------------------- +// Main test +// --------------------------------------------------------------------------- + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const config = (ctx.config ?? {}) as Record; + const command = asString(config.hermesCommand) || HERMES_CLI; + const checks: AdapterEnvironmentCheck[] = []; + + // 1. CLI installed? + const cliCheck = await checkCliInstalled(command); + if (cliCheck) { + checks.push(cliCheck); + if (cliCheck.level === "error") { + return { + adapterType: ADAPTER_TYPE, + status: "fail", + checks, + testedAt: new Date().toISOString(), + }; + } + } + + // 2. CLI version + const versionCheck = await checkCliVersion(command); + if (versionCheck) checks.push(versionCheck); + + // 3. Python available? + const pythonCheck = await checkPython(); + if (pythonCheck) checks.push(pythonCheck); + + // 4. Model config + const modelCheck = checkModel(config); + if (modelCheck) checks.push(modelCheck); + + // 5. API keys (check config.env โ€” server resolves secrets before calling us) + const apiKeyCheck = checkApiKeys(config); + if (apiKeyCheck) checks.push(apiKeyCheck); + + // Determine overall status + const hasErrors = checks.some((c) => c.level === "error"); + const hasWarnings = checks.some((c) => c.level === "warn"); + + return { + adapterType: ADAPTER_TYPE, + status: hasErrors ? "fail" : hasWarnings ? "warn" : "pass", + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/hermes-local/src/shared/constants.ts b/packages/adapters/hermes-local/src/shared/constants.ts new file mode 100644 index 0000000000..3e0d1659ac --- /dev/null +++ b/packages/adapters/hermes-local/src/shared/constants.ts @@ -0,0 +1,52 @@ +/** + * Shared constants for the Hermes Agent adapter. + */ + +/** Adapter type identifier registered with Paperclip. */ +export const ADAPTER_TYPE = "hermes_local"; + +/** Human-readable label shown in the Paperclip UI. */ +export const ADAPTER_LABEL = "Hermes Agent"; + +/** Default CLI binary name. */ +export const HERMES_CLI = "hermes"; + +/** Default timeout for a single execution run (seconds). */ +export const DEFAULT_TIMEOUT_SEC = 300; + +/** Grace period after SIGTERM before SIGKILL (seconds). */ +export const DEFAULT_GRACE_SEC = 10; + +/** Default model to use if none specified. */ +export const DEFAULT_MODEL = "anthropic/claude-sonnet-4"; + +/** + * Valid --provider choices for the hermes CLI. + * When not specified, Hermes auto-detects from model name. + */ +export const VALID_PROVIDERS = [ + "auto", + "openrouter", + "nous", + "openai-codex", + "zai", + "kimi-coding", + "minimax", + "minimax-cn", +] as const; + +/** Regex to extract session ID from Hermes CLI output. */ +export const SESSION_ID_REGEX = /session[_ ](?:id|saved)[:\s]+([a-zA-Z0-9_-]+)/i; + +/** Regex to extract token usage from Hermes output. */ +export const TOKEN_USAGE_REGEX = + /tokens?[:\s]+(\d+)\s*(?:input|in)\b.*?(\d+)\s*(?:output|out)\b/i; + +/** Regex to extract cost from Hermes output. */ +export const COST_REGEX = /(?:cost|spent)[:\s]*\$?([\d.]+)/i; + +/** Prefix used by Hermes for tool output lines. */ +export const TOOL_OUTPUT_PREFIX = "โ”Š"; + +/** Prefix for Hermes thinking blocks. */ +export const THINKING_PREFIX = "๐Ÿ’ญ"; diff --git a/packages/adapters/hermes-local/src/ui/build-config.ts b/packages/adapters/hermes-local/src/ui/build-config.ts new file mode 100644 index 0000000000..f46a0a3207 --- /dev/null +++ b/packages/adapters/hermes-local/src/ui/build-config.ts @@ -0,0 +1,64 @@ +/** + * Build adapter configuration from UI form values. + * + * Translates Paperclip's CreateConfigValues into the adapterConfig + * object stored in the agent record. + */ + +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; + +import { + DEFAULT_TIMEOUT_SEC, +} from "../shared/constants.js"; + +/** + * Build a Hermes Agent adapter config from the Paperclip UI form values. + */ +export function buildHermesConfig( + v: CreateConfigValues, +): Record { + const ac: Record = {}; + + // Model โ€” only set if explicitly provided; absence means Hermes uses its configured default. + if (v.model?.trim()) { + ac.model = v.model.trim(); + } + + // Execution limits + ac.timeoutSec = DEFAULT_TIMEOUT_SEC; + // maxTurnsPerRun maps to Hermes's max_turns (set via config, not CLI flag) + + // Session persistence (default: on) + ac.persistSession = true; + + // Working directory + if (v.cwd) { + ac.cwd = v.cwd; + } + + // Custom hermes binary path + if (v.command) { + ac.hermesCommand = v.command; + } + + // Extra CLI arguments + if (v.extraArgs) { + ac.extraArgs = v.extraArgs.split(/\s+/).filter(Boolean); + } + + // Thinking/reasoning effort + if (v.thinkingEffort) { + const existing = (ac.extraArgs as string[]) || []; + existing.push("--reasoning-effort", String(v.thinkingEffort)); + ac.extraArgs = existing; + } + + // Prompt template + if (v.promptTemplate) { + ac.promptTemplate = v.promptTemplate; + } + + // Heartbeat config is handled by Paperclip itself + + return ac; +} diff --git a/packages/adapters/hermes-local/src/ui/index.ts b/packages/adapters/hermes-local/src/ui/index.ts new file mode 100644 index 0000000000..3a49f11209 --- /dev/null +++ b/packages/adapters/hermes-local/src/ui/index.ts @@ -0,0 +1,7 @@ +/** + * UI module exports โ€” used by Paperclip's dashboard for run viewing + * and agent configuration forms. + */ + +export { parseHermesStdoutLine } from "./parse-stdout.js"; +export { buildHermesConfig } from "./build-config.js"; diff --git a/packages/adapters/hermes-local/src/ui/parse-stdout.ts b/packages/adapters/hermes-local/src/ui/parse-stdout.ts new file mode 100644 index 0000000000..f8b85d7532 --- /dev/null +++ b/packages/adapters/hermes-local/src/ui/parse-stdout.ts @@ -0,0 +1,282 @@ +/** + * Parse Hermes Agent stdout into TranscriptEntry objects for the Paperclip UI. + * + * Hermes CLI quiet-mode output patterns: + * Assistant: " โ”Š ๐Ÿ’ฌ {text}" + * Tool (TTY): " โ”Š {emoji} {verb:9} {detail} {duration}" + * Tool (pipe): " [done] โ”Š {emoji} {verb:9} {detail} {duration} ({total})" + * System: "[hermes] ..." + * + * We emit structured tool_call/tool_result pairs so Paperclip renders proper + * tool cards (with status icons, expand/collapse) instead of raw stdout blocks. + */ + +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; + +import { TOOL_OUTPUT_PREFIX } from "../shared/constants.js"; + +// โ”€โ”€ Kaomoji / noise stripping โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Strip kawaii faces and decorative emoji from a tool summary line. + * Leaves meaningful emoji (๐Ÿ’ป for terminal, ๐Ÿ” for search, etc.) intact + * by only stripping parenthesized kaomoji like (๏ฝกโ—•โ€ฟโ—•๏ฝก). + */ +function stripKaomoji(text: string): string { + // Strip parenthesized kaomoji faces: (๏ฝกโ—•โ€ฟโ—•๏ฝก), (โ˜…ฯ‰โ˜…), etc. + return text.replace(/[(][^()]{2,20}[)]\s*/gu, "").trim(); +} + +// โ”€โ”€ Line classification โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** Check if a โ”Š line is an assistant message (โ”Š ๐Ÿ’ฌ ...). */ +function isAssistantToolLine(stripped: string): boolean { + return /^โ”Š\s*๐Ÿ’ฌ/.test(stripped); +} + +/** Extract assistant text from a โ”Š ๐Ÿ’ฌ line. */ +function extractAssistantText(line: string): string { + return line.replace(/^[\sโ”Š]*๐Ÿ’ฌ\s*/, "").trim(); +} + +/** + * Parse a tool completion line into structured data. + * + * Handles both TTY and pipe formats: + * TTY: โ”Š ๐Ÿ’ป $ curl -s "..." 0.1s + * Pipe: [done] โ”Š ๐Ÿ’ป $ curl -s "..." 0.1s (0.5s) + */ +function parseToolCompletionLine( + line: string, +): { name: string; detail: string; duration: string; hasError: boolean } | null { + // Strip leading whitespace and [done] prefix + let cleaned = line.trim().replace(/^\[done\]\s*/, ""); + + // Must start with โ”Š + if (!cleaned.startsWith(TOOL_OUTPUT_PREFIX)) return null; + + // Remove โ”Š prefix and any leading kaomoji face + cleaned = cleaned.slice(TOOL_OUTPUT_PREFIX.length); + cleaned = stripKaomoji(cleaned).trim(); + + // Now format is: "{emoji} {verb:9} {detail} {duration}" or "{emoji} {verb:9} {detail} {duration} ({total})" + // Example: "๐Ÿ’ป $ curl -s ..." or "๐Ÿ” search pattern 0.1s" + // The verb+detail are separated by whitespace, duration is at the end + + // Match: emoji + verb + detail + duration + // Duration pattern: N.Ns (possibly followed by (N.Ns)) + const durationMatch = cleaned.match(/([\d.]+s)\s*(?:\([\d.]+s\))?\s*$/); + const duration = durationMatch ? durationMatch[1] : ""; + + // Remove duration from the end to get verb + detail + let verbAndDetail = durationMatch + ? cleaned.slice(0, cleaned.lastIndexOf(durationMatch[0])).trim() + : cleaned; + + // Check for error suffixes + const hasError = /\[(?:exit \d+|error|full)\]/.test(verbAndDetail) || + /\[error\]\s*$/.test(cleaned); + + // The first token (after emoji) is the verb, rest is detail + // Verbs are always a single word or symbol ($ for terminal) + const parts = verbAndDetail.match(/^(\S+)\s+(.*)/); + if (!parts) { + return { name: "tool", detail: verbAndDetail, duration, hasError }; + } + + const verb = parts[1]; + const detail = parts[2].trim(); + + // Map Hermes verbs to readable tool names + const nameMap: Record = { + "$": "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", + }; + + const name = nameMap[verb.toLowerCase()] || verb; + + return { name, detail, duration, hasError }; +} + +// โ”€โ”€ Synthetic tool ID generation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +let toolCallCounter = 0; + +/** + * Generate a synthetic toolUseId for pairing tool_call with tool_result. + * Paperclip uses this to match them in normalizeTranscript. + */ +function syntheticToolUseId(): string { + return `hermes-tool-${++toolCallCounter}`; +} + +// โ”€โ”€ Thinking detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function isThinkingLine(line: string): boolean { + return ( + line.includes("๐Ÿ’ญ") || + line.startsWith("") || + line.startsWith("") || + line.startsWith("Thinking:") + ); +} + +// โ”€โ”€ Main parser โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Parse a single line of Hermes stdout into transcript entries. + * + * Emits structured tool_call/tool_result pairs (with synthetic IDs) so + * Paperclip renders proper tool cards with status icons and expand/collapse. + * + * @param line Raw stdout line from Hermes CLI + * @param ts ISO timestamp for the entry + * @returns Array of TranscriptEntry objects (may be empty) + */ +export function parseHermesStdoutLine( + line: string, + ts: string, +): TranscriptEntry[] { + const trimmed = line.trim(); + if (!trimmed) return []; + + // โ”€โ”€ System/adapter messages โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (trimmed.startsWith("[hermes]") || trimmed.startsWith("[paperclip]")) { + return [{ kind: "system", ts, text: trimmed }]; + } + + // โ”€โ”€ Non-quiet mode tool start lines: [tool] (kaomoji) emoji verb ... โ”€โ”€ + // These are redundant โ€” the tool_call/tool_result pair arrives later from + // the โ”Š completion line. Skip them to avoid duplicate entries. + if (trimmed.startsWith("[tool]")) { + return []; + } + + // โ”€โ”€ MCP / server init noise reclassified from stderr by wrappedOnLog โ”€โ”€ + // Pattern: [2026-03-25T10:40:53.941Z] INFO: ... + // Emit as stderr so Paperclip groups them into the amber accordion. + if (/^\[\d{4}-\d{2}-\d{2}T/.test(trimmed)) { + return [{ kind: "stderr", ts, text: trimmed }]; + } + + // โ”€โ”€ Standalone spinner remnants: "๐Ÿ’ป Completed", "๐Ÿ’ป\nCompleted", etc. โ”€ + // These are non-quiet mode spinner frame leftovers โ€” skip them. + if (/^\p{Emoji_Presentation}\s*(Completed|Running|Error)?\s*$/u.test(trimmed)) { + return []; + } + + // โ”€โ”€ Session info line โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (trimmed.startsWith("session_id:")) { + return [{ kind: "system", ts, text: trimmed }]; + } + + // โ”€โ”€ Quiet-mode tool/message lines (prefixed with โ”Š) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (trimmed.includes(TOOL_OUTPUT_PREFIX)) { + // Assistant message: โ”Š ๐Ÿ’ฌ {text} + if (isAssistantToolLine(trimmed)) { + return [{ kind: "assistant", ts, text: extractAssistantText(trimmed) }]; + } + + // Tool completion: โ”Š {emoji} {verb} {detail} {duration} + const toolInfo = parseToolCompletionLine(trimmed); + if (toolInfo) { + const id = syntheticToolUseId(); + const detailText = toolInfo.duration + ? `${toolInfo.detail} ${toolInfo.duration}` + : toolInfo.detail; + + return [ + { + kind: "tool_call" as const, + ts, + name: toolInfo.name, + input: { detail: toolInfo.detail }, + toolUseId: id, + }, + { + kind: "tool_result" as const, + ts, + toolUseId: id, + content: detailText, + isError: toolInfo.hasError, + }, + ] as TranscriptEntry[]; + } + + // Fallback: raw โ”Š line that doesn't match tool format + const stripped = trimmed + .replace(/^\[done\]\s*/, "") + .replace(new RegExp(`^${TOOL_OUTPUT_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`), "") + .trim(); + return [{ kind: "stdout", ts, text: stripped }]; + } + + // โ”€โ”€ Thinking blocks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (isThinkingLine(trimmed)) { + return [ + { + kind: "thinking", + ts, + text: trimmed.replace(/^๐Ÿ’ญ\s*/, ""), + }, + ]; + } + + // โ”€โ”€ Error output โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if ( + trimmed.startsWith("Error:") || + trimmed.startsWith("ERROR:") || + trimmed.startsWith("Traceback") + ) { + return [{ kind: "stderr", ts, text: trimmed }]; + } + + // โ”€โ”€ Regular assistant output โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + return [{ kind: "assistant", ts, text: trimmed }]; +} diff --git a/packages/adapters/hermes-local/tsconfig.json b/packages/adapters/hermes-local/tsconfig.json new file mode 100644 index 0000000000..e1b71318a6 --- /dev/null +++ b/packages/adapters/hermes-local/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/server/package.json b/server/package.json index c4053237fb..c50246f63d 100644 --- a/server/package.json +++ b/server/package.json @@ -44,6 +44,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.888.0", + "@paperclipai/adapter-hermes-local": "workspace:*", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", @@ -65,7 +66,6 @@ "drizzle-orm": "^0.38.4", "embedded-postgres": "^18.1.0-beta.16", "express": "^5.1.0", - "hermes-paperclip-adapter": "0.1.1", "jsdom": "^28.1.0", "multer": "^2.0.2", "open": "^11.0.0", diff --git a/server/src/adapters/index.ts b/server/src/adapters/index.ts index 8d86eb5242..8be40a51e0 100644 --- a/server/src/adapters/index.ts +++ b/server/src/adapters/index.ts @@ -1,4 +1,4 @@ -export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter } from "./registry.js"; +export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter, detectAdapterModel } from "./registry.js"; export type { ServerAdapterModule, AdapterExecutionContext, diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 67a8e95ba2..874a7b277a 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -70,11 +70,14 @@ import { execute as hermesExecute, testEnvironment as hermesTestEnvironment, sessionCodec as hermesSessionCodec, -} from "hermes-paperclip-adapter/server"; + listSkills as hermesListSkills, + syncSkills as hermesSyncSkills, +} from "@paperclipai/adapter-hermes-local/server"; import { agentConfigurationDoc as hermesAgentConfigurationDoc, models as hermesModels, -} from "hermes-paperclip-adapter"; +} from "@paperclipai/adapter-hermes-local"; +import { detectModel as detectModelFromHermes } from "@paperclipai/adapter-hermes-local/server"; import { processAdapter } from "./process/index.js"; import { httpAdapter } from "./http/index.js"; @@ -176,9 +179,12 @@ const hermesLocalAdapter: ServerAdapterModule = { execute: hermesExecute, testEnvironment: hermesTestEnvironment, sessionCodec: hermesSessionCodec, + listSkills: hermesListSkills, + syncSkills: hermesSyncSkills, models: hermesModels, supportsLocalAgentJwt: true, agentConfigurationDoc: hermesAgentConfigurationDoc, + detectModel: () => detectModelFromHermes(), }; const adaptersByType = new Map( @@ -215,7 +221,16 @@ export async function listAdapterModels(type: string): Promise<{ id: string; lab return adapter.models ?? []; } -export function listServerAdapters(): ServerAdapterModule[] { +export async function detectAdapterModel( + type: string, +): Promise<{ model: string | null; provider: string | null; source: string | null } | null> { + const adapter = adaptersByType.get(type); + if (!adapter?.detectModel) return null; + const detected = await adapter.detectModel(); + return detected ? { model: detected.model, provider: detected.provider, source: detected.source } : null; +} + +export function listServerAdapters() { return Array.from(adaptersByType.values()); } diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index f642eb10f5..e414fca6da 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -44,7 +44,7 @@ import { } from "../services/index.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; -import { findServerAdapter, listAdapterModels } from "../adapters/index.js"; +import { findServerAdapter, listAdapterModels, detectAdapterModel } from "../adapters/index.js"; import { redactEventPayload } from "../redaction.js"; import { redactCurrentUserValue } from "../log-redaction.js"; import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js"; @@ -671,6 +671,15 @@ export function agentRoutes(db: Db) { res.json(models); }); + router.get("/companies/:companyId/adapters/:type/detect-model", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const type = req.params.type as string; + + const detected = await detectAdapterModel(type); + res.json(detected ?? { model: null, provider: null, source: null }); + }); + router.post( "/companies/:companyId/adapters/:type/test-environment", validate(testAdapterEnvironmentSchema), diff --git a/ui/package.json b/ui/package.json index c2471b4be3..92993eba38 100644 --- a/ui/package.json +++ b/ui/package.json @@ -40,6 +40,7 @@ "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", + "@paperclipai/adapter-hermes-local": "workspace:*", "@paperclipai/shared": "workspace:*", "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/typography": "^0.5.19", diff --git a/ui/src/adapters/hermes-local/config-fields.tsx b/ui/src/adapters/hermes-local/config-fields.tsx new file mode 100644 index 0000000000..4b80704365 --- /dev/null +++ b/ui/src/adapters/hermes-local/config-fields.tsx @@ -0,0 +1,49 @@ +import type { AdapterConfigFieldsProps } from "../types"; +import { + Field, + DraftInput, +} from "../../components/agent-config-primitives"; +import { ChoosePathButton } from "../../components/PathInstructionsModal"; + +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; +const instructionsFileHint = + "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; + +export function HermesLocalConfigFields({ + isCreate, + values, + set, + config, + eff, + mark, + hideInstructionsFile, +}: AdapterConfigFieldsProps) { + if (hideInstructionsFile) return null; + return ( + +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+
+ ); +} diff --git a/ui/src/adapters/hermes-local/index.ts b/ui/src/adapters/hermes-local/index.ts new file mode 100644 index 0000000000..ef6b3a0baa --- /dev/null +++ b/ui/src/adapters/hermes-local/index.ts @@ -0,0 +1,12 @@ +import type { UIAdapterModule } from "../types"; +import { parseHermesStdoutLine } from "@paperclipai/adapter-hermes-local/ui"; +import { HermesLocalConfigFields } from "./config-fields"; +import { buildHermesConfig } from "@paperclipai/adapter-hermes-local/ui"; + +export const hermesLocalUIAdapter: UIAdapterModule = { + type: "hermes_local", + label: "Hermes Agent", + parseStdoutLine: parseHermesStdoutLine, + ConfigFields: HermesLocalConfigFields, + buildAdapterConfig: buildHermesConfig, +}; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index fc7be2cf47..02e947f87e 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -5,6 +5,7 @@ import { cursorLocalUIAdapter } from "./cursor"; import { geminiLocalUIAdapter } from "./gemini-local"; import { openCodeLocalUIAdapter } from "./opencode-local"; import { piLocalUIAdapter } from "./pi-local"; +import { hermesLocalUIAdapter } from "./hermes-local"; import { openClawGatewayUIAdapter } from "./openclaw-gateway"; import { processUIAdapter } from "./process"; import { httpUIAdapter } from "./http"; @@ -15,6 +16,7 @@ const uiAdapters: UIAdapterModule[] = [ geminiLocalUIAdapter, openCodeLocalUIAdapter, piLocalUIAdapter, + hermesLocalUIAdapter, cursorLocalUIAdapter, openClawGatewayUIAdapter, processUIAdapter, diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index ccaf15c0cc..b81a59e92d 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -27,6 +27,12 @@ export interface AdapterModel { label: string; } +export interface DetectedAdapterModel { + model: string | null; + provider: string | null; + source: string | null; +} + export interface ClaudeLoginResult { exitCode: number | null; signal: string | null; @@ -159,6 +165,10 @@ export const agentsApi = { api.get( `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`, ), + detectModel: (companyId: string, type: string) => + api.get( + `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`, + ), testEnvironment: ( companyId: string, type: string, diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 1810e9a84e..3b58664257 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -248,9 +248,26 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } if (overlay.adapterType !== undefined) { patch.adapterType = overlay.adapterType; - // When adapter type changes, send only the new config โ€” don't merge - // with old config since old adapter fields are meaningless for the new type - patch.adapterConfig = overlay.adapterConfig; + // When adapter type changes, replace adapter-specific fields but preserve + // adapter-agnostic fields (env, promptTemplate, etc.) that are shared + // across all adapter types. + const existing = (agent.adapterConfig ?? {}) as Record; + const adapterAgnosticKeys = [ + "env", + "promptTemplate", + "instructionsFilePath", + "cwd", + "timeoutSec", + "graceSec", + "bootstrapPromptTemplate", + ]; + const preserved: Record = {}; + for (const key of adapterAgnosticKeys) { + if (key in existing) { + preserved[key] = existing[key]; + } + } + patch.adapterConfig = { ...preserved, ...overlay.adapterConfig }; } else if (Object.keys(overlay.adapterConfig).length > 0) { const existing = (agent.adapterConfig ?? {}) as Record; patch.adapterConfig = { ...existing, ...overlay.adapterConfig }; @@ -296,9 +313,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) { adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "gemini_local" || + adapterType === "hermes_local" || adapterType === "opencode_local" || adapterType === "pi_local" || adapterType === "cursor"; + const isHermesLocal = adapterType === "hermes_local"; const showLegacyWorkingDirectoryField = isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config }); const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); @@ -315,6 +334,22 @@ export function AgentConfigForm(props: AgentConfigFormProps) { enabled: Boolean(selectedCompanyId), }); const models = fetchedModels ?? externalModels ?? []; + const { + data: detectedModelData, + refetch: refetchDetectedModel, + } = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.agents.detectModel(selectedCompanyId, adapterType) + : ["agents", "none", "detect-model", adapterType], + queryFn: () => { + if (!selectedCompanyId) { + throw new Error("Select a company to detect the Hermes model"); + } + return agentsApi.detectModel(selectedCompanyId, adapterType); + }, + enabled: Boolean(selectedCompanyId && isHermesLocal), + }); + const detectedModel = detectedModelData?.model ?? null; const { data: companyAgents = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"], @@ -688,6 +723,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? "codex" : adapterType === "gemini_local" ? "gemini" + : adapterType === "hermes_local" + ? "hermes" : adapterType === "pi_local" ? "pi" : adapterType === "cursor" @@ -709,9 +746,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } open={modelOpen} onOpenChange={setModelOpen} - allowDefault={adapterType !== "opencode_local"} - required={adapterType === "opencode_local"} + allowDefault={adapterType !== "opencode_local" && adapterType !== "hermes_local"} + required={adapterType === "opencode_local" || adapterType === "hermes_local"} groupByProvider={adapterType === "opencode_local"} + creatable={adapterType === "hermes_local"} + detectedModel={adapterType === "hermes_local" ? detectedModel : null} + onDetectModel={adapterType === "hermes_local" + ? async () => { + const result = await refetchDetectedModel(); + return result.data?.model ?? null; + } + : undefined} /> {fetchedModelsError && (

@@ -976,7 +1021,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe /* ---- Internal sub-components ---- */ -const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]); +const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]); /** Display list includes all real adapter types plus UI-only coming-soon entries. */ const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [ @@ -1293,6 +1338,9 @@ function ModelDropdown({ allowDefault, required, groupByProvider, + creatable, + detectedModel, + onDetectModel, }: { models: AdapterModel[]; value: string; @@ -1302,9 +1350,19 @@ function ModelDropdown({ allowDefault: boolean; required: boolean; groupByProvider: boolean; + creatable?: boolean; + detectedModel?: string | null; + onDetectModel?: () => Promise; }) { const [modelSearch, setModelSearch] = useState(""); + const [detectingModel, setDetectingModel] = useState(false); const selected = models.find((m) => m.id === value); + const manualModel = modelSearch.trim(); + const canCreateManualModel = Boolean( + creatable && + manualModel && + !models.some((m) => m.id.toLowerCase() === manualModel.toLowerCase()), + ); const filteredModels = useMemo(() => { return models.filter((m) => { if (!modelSearch.trim()) return true; @@ -1341,6 +1399,21 @@ function ModelDropdown({ })); }, [filteredModels, groupByProvider]); + async function handleDetectModel() { + if (!onDetectModel) return; + setDetectingModel(true); + try { + const nextModel = await onDetectModel(); + if (nextModel) { + onChange(nextModel); + onOpenChange(false); + setModelSearch(""); + } + } finally { + setDetectingModel(false); + } + } + return ( - - setModelSearch(e.target.value)} - autoFocus - /> +

+ setModelSearch(e.target.value)} + /> + {modelSearch && ( + + )} +
+ {onDetectModel && !detectedModel && !modelSearch.trim() && ( + + )} + {value && !models.some((m) => m.id === value) && ( + + )} + {detectedModel && detectedModel !== value && ( + + )}
{allowDefault && ( + )} {groupedModels.map((group) => (
{groupByProvider && ( @@ -1392,6 +1546,7 @@ function ModelDropdown({ )} {group.entries.map((m) => (
diff --git a/ui/src/components/HermesIcon.tsx b/ui/src/components/HermesIcon.tsx new file mode 100644 index 0000000000..91efd55e2b --- /dev/null +++ b/ui/src/components/HermesIcon.tsx @@ -0,0 +1,43 @@ +import { cn } from "../lib/utils"; + +interface HermesIconProps { + className?: string; +} + +/** + * Hermes caduceus icon โ€” winged staff with two intertwined serpents. + * Replaces the generic Zap icon for the hermes_local adapter type. + * + * โš•๏ธ inspired but as the proper caduceus (Hermes' symbol): staff + two snakes + wings. + */ +export function HermesIcon({ className }: HermesIconProps) { + return ( + + {/* Central staff */} + + {/* Left serpent curves */} + + {/* Right serpent curves */} + + {/* Snake heads facing outward */} + + + {/* Wings at top of staff */} + + + {/* Wing feather details */} + + + {/* Staff sphere at top */} + + + ); +} diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index 15114bf73a..aaaf7c6d19 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -21,6 +21,7 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; +import { HermesIcon } from "./HermesIcon"; type AdvancedAdapterType = | "claude_local" @@ -29,7 +30,8 @@ type AdvancedAdapterType = | "opencode_local" | "pi_local" | "cursor" - | "openclaw_gateway"; + | "openclaw_gateway" + | "hermes_local"; const ADVANCED_ADAPTER_OPTIONS: Array<{ value: AdvancedAdapterType; @@ -64,6 +66,12 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{ icon: OpenCodeLogoIcon, desc: "Local multi-provider agent", }, + { + value: "hermes_local", + label: "Hermes Agent", + icon: HermesIcon, + desc: "Local multi-provider agent", + }, { value: "pi_local", label: "Pi", diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index cd28af9fde..b3ec724e65 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -56,12 +56,14 @@ import { ChevronDown, X } from "lucide-react"; +import { HermesIcon } from "./HermesIcon"; type Step = 1 | 2 | 3 | 4; type AdapterType = | "claude_local" | "codex_local" | "gemini_local" + | "hermes_local" | "opencode_local" | "pi_local" | "cursor" @@ -208,6 +210,7 @@ export function OnboardingWizard() { adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "gemini_local" || + adapterType === "hermes_local" || adapterType === "opencode_local" || adapterType === "pi_local" || adapterType === "cursor"; @@ -217,6 +220,8 @@ export function OnboardingWizard() { ? "codex" : adapterType === "gemini_local" ? "gemini" + : adapterType === "hermes_local" + ? "hermes" : adapterType === "pi_local" ? "pi" : adapterType === "cursor" @@ -843,6 +848,12 @@ export function OnboardingWizard() { icon: MousePointer2, desc: "Local Cursor agent" }, + { + value: "hermes_local" as const, + label: "Hermes Agent", + icon: HermesIcon, + desc: "Local multi-provider agent" + }, { value: "openclaw_gateway" as const, label: "OpenClaw Gateway", @@ -902,6 +913,7 @@ export function OnboardingWizard() { {(adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "gemini_local" || + adapterType === "hermes_local" || adapterType === "opencode_local" || adapterType === "pi_local" || adapterType === "cursor") && ( diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index 70694f73c1..e66bf4d8a9 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -64,6 +64,7 @@ export const adapterLabels: Record = { opencode_local: "OpenCode (local)", openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", + hermes_local: "Hermes Agent", process: "Process", http: "HTTP", }; diff --git a/ui/src/components/transcript/RunTranscriptView.tsx b/ui/src/components/transcript/RunTranscriptView.tsx index cd52dbc11a..f39167f871 100644 --- a/ui/src/components/transcript/RunTranscriptView.tsx +++ b/ui/src/components/transcript/RunTranscriptView.tsx @@ -72,6 +72,26 @@ type TranscriptBlock = status: "running" | "completed" | "error"; }>; } + | { + type: "tool_group"; + ts: string; + endTs?: string; + items: Array<{ + ts: string; + endTs?: string; + name: string; + input: unknown; + result?: string; + isError?: boolean; + status: "running" | "completed" | "error"; + }>; + } + | { + type: "stderr_group"; + ts: string; + endTs?: string; + lines: Array<{ ts: string; text: string }>; + } | { type: "stdout"; ts: string; @@ -325,6 +345,48 @@ function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] { return grouped; } +/** Group consecutive non-command tool blocks into a single tool_group accordion. */ +function groupToolBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] { + const grouped: TranscriptBlock[] = []; + let pending: Array["items"][number]> = []; + let groupTs: string | null = null; + let groupEndTs: string | undefined; + + const flush = () => { + if (pending.length === 0 || !groupTs) return; + grouped.push({ + type: "tool_group", + ts: groupTs, + endTs: groupEndTs, + items: pending, + }); + pending = []; + groupTs = null; + groupEndTs = undefined; + }; + + for (const block of blocks) { + if (block.type === "tool" && !isCommandTool(block.name, block.input)) { + if (!groupTs) groupTs = block.ts; + groupEndTs = block.endTs ?? block.ts; + pending.push({ + ts: block.ts, + endTs: block.endTs, + name: block.name, + input: block.input, + result: block.result, + isError: block.isError, + status: block.status, + }); + continue; + } + flush(); + grouped.push(block); + } + flush(); + return grouped; +} + export function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] { const blocks: TranscriptBlock[] = []; const pendingToolBlocks = new Map>(); @@ -437,13 +499,19 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole if (shouldHideNiceModeStderr(entry.text)) { continue; } - blocks.push({ - type: "event", - ts: entry.ts, - label: "stderr", - tone: "error", - text: entry.text, - }); + // Batch consecutive stderr entries into a single group + const prev = blocks[blocks.length - 1]; + if (prev && prev.type === "stderr_group") { + prev.lines.push({ ts: entry.ts, text: entry.text }); + prev.endTs = entry.ts; + } else { + blocks.push({ + type: "stderr_group", + ts: entry.ts, + endTs: entry.ts, + lines: [{ ts: entry.ts, text: entry.text }], + }); + } continue; } @@ -508,7 +576,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole } } - return groupCommandBlocks(blocks); + return groupToolBlocks(groupCommandBlocks(blocks)); } function TranscriptMessageBlock({ @@ -805,6 +873,139 @@ function TranscriptCommandGroup({ ); } +function TranscriptToolGroup({ + block, + density, +}: { + block: Extract; + density: TranscriptDensity; +}) { + const [open, setOpen] = useState(false); + const compact = density === "compact"; + const runningItem = [...block.items].reverse().find((item) => item.status === "running"); + const hasError = block.items.some((item) => item.status === "error"); + const isRunning = Boolean(runningItem); + const uniqueNames = [...new Set(block.items.map((item) => item.name))]; + const toolLabel = + uniqueNames.length === 1 + ? humanizeLabel(uniqueNames[0]) + : `${uniqueNames.length} tools`; + const title = isRunning + ? `Using ${toolLabel}` + : block.items.length === 1 + ? `Used ${toolLabel}` + : `Used ${toolLabel} (${block.items.length} calls)`; + const subtitle = runningItem + ? summarizeToolInput(runningItem.name, runningItem.input, density) + : null; + const statusTone = isRunning + ? "text-cyan-700 dark:text-cyan-300" + : "text-foreground/70"; + + return ( +
+
{ if (hasSelectedText()) return; setOpen((v) => !v); }} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }} + > +
+ {block.items.slice(0, Math.min(block.items.length, 3)).map((item, index) => { + const isItemRunning = item.status === "running"; + const isItemError = item.status === "error"; + return ( + 0 && "-ml-1.5", + isItemRunning + ? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300" + : isItemError + ? "border-red-500/25 bg-red-500/[0.08] text-red-600 dark:text-red-300" + : "border-border/70 bg-background text-foreground/55", + isItemRunning && "animate-pulse", + )} + > + + + ); + })} +
+
+
+ {title} +
+ {subtitle && ( +
+ {subtitle} +
+ )} +
+ +
+ {open && ( +
+ {block.items.map((item, index) => ( +
+
+ + + + + {humanizeLabel(item.name)} + + + {item.status === "running" ? "Running" : item.status === "error" ? "Errored" : "Completed"} + +
+
+
+
Input
+
+                    {formatToolPayload(item.input) || ""}
+                  
+
+ {item.result && ( +
+
Result
+
+                      {formatToolPayload(item.result)}
+                    
+
+ )} +
+
+ ))} +
+ )} +
+ ); +} + function TranscriptActivityRow({ block, density, @@ -883,6 +1084,43 @@ function TranscriptEventRow({ ); } +function TranscriptStderrGroup({ + block, + density, +}: { + block: Extract; + density: TranscriptDensity; +}) { + const [open, setOpen] = useState(false); + const compact = density === "compact"; + return ( +
+
setOpen((v) => !v)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }} + > + + {block.lines.length} log {block.lines.length === 1 ? "line" : "lines"} + + {open ? : } +
+ {open && ( +
+          {block.lines.map((line, i) => (
+            
+              {i > 0 ? "\n" : ""}
+              {line.text}
+            
+          ))}
+        
+ )} +
+ ); +} + function TranscriptStdoutRow({ block, density, @@ -1003,6 +1241,8 @@ export function RunTranscriptView({ )} {block.type === "tool" && } {block.type === "command_group" && } + {block.type === "tool_group" && } + {block.type === "stderr_group" && } {block.type === "stdout" && ( )} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index ecee1a2026..8b7f2cd744 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -25,6 +25,8 @@ export const queryKeys = { configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const, adapterModels: (companyId: string, adapterType: string) => ["agents", companyId, "adapter-models", adapterType] as const, + detectModel: (companyId: string, adapterType: string) => + ["agents", companyId, "detect-model", adapterType] as const, }, issues: { list: (companyId: string) => ["issues", companyId] as const, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index c0bed88686..e280064286 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1075,10 +1075,28 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin const isLive = run.status === "running" || run.status === "queued"; const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" }; const StatusIcon = statusInfo.icon; - const summary = run.resultJson + const summaryRaw = run.resultJson ? String((run.resultJson as Record).summary ?? (run.resultJson as Record).result ?? "") : run.error ?? ""; + // Extract a clean 2-3 line excerpt: first non-empty, non-header, non-list-mark lines + const summary = useMemo(() => { + if (!summaryRaw) return ""; + const lines = summaryRaw + .replace(/^#{1,6}\s+/gm, "") + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith("---") && !l.startsWith("|") && !l.startsWith("```")); + const excerpt: string[] = []; + let chars = 0; + for (const line of lines) { + if (excerpt.length >= 3 || chars + line.length > 280) break; + excerpt.push(line); + chars += line.length; + } + return excerpt.join(" "); + }, [summaryRaw]); + return (
@@ -2351,6 +2369,7 @@ function AgentSkillsTab({ const queryClient = useQueryClient(); const [skillDraft, setSkillDraft] = useState([]); const [lastSavedSkills, setLastSavedSkills] = useState([]); + const [unmanagedOpen, setUnmanagedOpen] = useState(false); const lastSavedSkillsRef = useRef([]); const hasHydratedSkillSnapshotRef = useRef(false); const skipNextSkillAutosaveRef = useRef(true); @@ -2680,12 +2699,19 @@ function AgentSkillsTab({ {unmanagedSkillRows.length > 0 && (
-
+
setUnmanagedOpen((v) => !v)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setUnmanagedOpen((v) => !v); } }} + > - User-installed skills, not managed by Paperclip + ({unmanagedSkillRows.length}) User-installed skills, not managed by Paperclip + {unmanagedOpen ? : }
- {unmanagedSkillRows.map(renderSkillRow)} + {unmanagedOpen && unmanagedSkillRows.map(renderSkillRow)}
)} diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index b14112c426..a157f77772 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -26,6 +26,7 @@ const adapterLabels: Record = { gemini_local: "Gemini", opencode_local: "OpenCode", cursor: "Cursor", + hermes_local: "Hermes", openclaw_gateway: "OpenClaw Gateway", process: "Process", http: "HTTP", diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index e288babeec..ec0d5e1dec 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -20,11 +20,12 @@ const adapterLabels: Record = { pi_local: "Pi (local)", openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", + hermes_local: "Hermes Agent", process: "Process", http: "HTTP", }; -const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]); +const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]); function dateTime(value: string) { return new Date(value).toLocaleString(); diff --git a/ui/src/pages/NewAgent.tsx b/ui/src/pages/NewAgent.tsx index b8787be2fb..f62a1c34ac 100644 --- a/ui/src/pages/NewAgent.tsx +++ b/ui/src/pages/NewAgent.tsx @@ -35,7 +35,9 @@ const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set = { gemini_local: "Gemini", opencode_local: "OpenCode", cursor: "Cursor", + hermes_local: "Hermes", openclaw_gateway: "OpenClaw Gateway", process: "Process", http: "HTTP",