From 23a0a116d586ecf04394e918b9ca010be34a637f Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 23 Jun 2026 00:59:37 +0000 Subject: [PATCH] Add Hermes adapter workspace package Co-Authored-By: Paperclip --- cli/package.json | 1 + cli/src/adapters/registry.ts | 7 + package.json | 1 + packages/adapters/hermes-local/LICENSE | 21 + packages/adapters/hermes-local/README.md | 228 +++++++ packages/adapters/hermes-local/package.json | 80 +++ .../hermes-local/src/cli/format-event.ts | 61 ++ .../adapters/hermes-local/src/cli/index.ts | 5 + .../adapters/hermes-local/src/index.test.ts | 16 + packages/adapters/hermes-local/src/index.ts | 115 ++++ .../src/server/command-resolution.test.ts | 48 ++ .../src/server/detect-model.test.ts | 186 ++++++ .../hermes-local/src/server/detect-model.ts | 217 +++++++ .../hermes-local/src/server/execute.ts | 592 ++++++++++++++++++ .../adapters/hermes-local/src/server/index.ts | 48 ++ .../hermes-local/src/server/skills.ts | 226 +++++++ .../adapters/hermes-local/src/server/test.ts | 387 ++++++++++++ .../hermes-local/src/shared/constants.ts | 104 +++ .../hermes-local/src/ui/build-config.ts | 87 +++ .../adapters/hermes-local/src/ui/index.ts | 7 + .../hermes-local/src/ui/parse-stdout.ts | 282 +++++++++ packages/adapters/hermes-local/tsconfig.json | 8 + .../adapters/hermes-local/vitest.config.ts | 8 + server/package.json | 2 +- server/src/__tests__/adapter-registry.test.ts | 2 +- server/src/adapters/registry.ts | 22 +- ui/package.json | 2 +- ui/src/adapters/hermes-local/index.ts | 4 +- 28 files changed, 2747 insertions(+), 20 deletions(-) create mode 100644 packages/adapters/hermes-local/LICENSE create mode 100644 packages/adapters/hermes-local/README.md create mode 100644 packages/adapters/hermes-local/package.json create mode 100644 packages/adapters/hermes-local/src/cli/format-event.ts create mode 100644 packages/adapters/hermes-local/src/cli/index.ts create mode 100644 packages/adapters/hermes-local/src/index.test.ts create mode 100644 packages/adapters/hermes-local/src/index.ts create mode 100644 packages/adapters/hermes-local/src/server/command-resolution.test.ts create mode 100644 packages/adapters/hermes-local/src/server/detect-model.test.ts create mode 100644 packages/adapters/hermes-local/src/server/detect-model.ts create mode 100644 packages/adapters/hermes-local/src/server/execute.ts create mode 100644 packages/adapters/hermes-local/src/server/index.ts create mode 100644 packages/adapters/hermes-local/src/server/skills.ts create mode 100644 packages/adapters/hermes-local/src/server/test.ts create mode 100644 packages/adapters/hermes-local/src/shared/constants.ts create mode 100644 packages/adapters/hermes-local/src/ui/build-config.ts create mode 100644 packages/adapters/hermes-local/src/ui/index.ts create mode 100644 packages/adapters/hermes-local/src/ui/parse-stdout.ts create mode 100644 packages/adapters/hermes-local/tsconfig.json create mode 100644 packages/adapters/hermes-local/vitest.config.ts diff --git a/cli/package.json b/cli/package.json index ed894750eb6..7c383a46313 100644 --- a/cli/package.json +++ b/cli/package.json @@ -44,6 +44,7 @@ "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", "@paperclipai/adapter-grok-local": "workspace:*", + "@paperclipai/hermes-paperclip-adapter": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index 31dfe0d0af7..ca64465491e 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -6,6 +6,7 @@ import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli"; import { printCursorCloudEvent } from "@paperclipai/adapter-cursor-cloud/cli"; import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli"; import { printGrokStreamEvent } from "@paperclipai/adapter-grok-local/cli"; +import { printHermesStreamEvent } from "@paperclipai/hermes-paperclip-adapter/cli"; import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli"; import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli"; @@ -57,6 +58,11 @@ const grokLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printGrokStreamEvent, }; +const hermesLocalCLIAdapter: CLIAdapterModule = { + type: "hermes_local", + formatStdoutEvent: printHermesStreamEvent, +}; + const openclawGatewayCLIAdapter: CLIAdapterModule = { type: "openclaw_gateway", formatStdoutEvent: printOpenClawGatewayStreamEvent, @@ -73,6 +79,7 @@ const adaptersByType = new Map( cursorCloudCLIAdapter, geminiLocalCLIAdapter, grokLocalCLIAdapter, + hermesLocalCLIAdapter, openclawGatewayCLIAdapter, processCLIAdapter, httpCLIAdapter, diff --git a/package.json b/package.json index bcea76002e1..af788767985 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "check:no-git-push": "node scripts/check-no-git-push.mjs", "test:check-no-git-push": "node --test scripts/check-no-git-push.test.mjs", "docs:dev": "cd docs && npx mintlify dev", + "smoke:hermes-gateway-join": "./scripts/smoke/hermes-gateway-join.sh", "smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh", "smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh", "smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh", diff --git a/packages/adapters/hermes-local/LICENSE b/packages/adapters/hermes-local/LICENSE new file mode 100644 index 00000000000..5700b997827 --- /dev/null +++ b/packages/adapters/hermes-local/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Nous Research + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/adapters/hermes-local/README.md b/packages/adapters/hermes-local/README.md new file mode 100644 index 00000000000..eab4ebfd5be --- /dev/null +++ b/packages/adapters/hermes-local/README.md @@ -0,0 +1,228 @@ +# Paperclip Adapter for Hermes Agent + +A [Paperclip](https://paperclip.ing) adapter that lets you run [Hermes Agent](https://github.com/NousResearch/hermes-agent) as a managed employee in a Paperclip company. + +Hermes Agent is a full-featured AI agent by [Nous Research](https://nousresearch.com) with 30+ native tools, persistent memory, session persistence, 80+ skills, MCP support, and multi-provider model access. + +## Key Features + +This adapter provides: + +- **8 inference providers** — Anthropic, OpenRouter, OpenAI, Nous, OpenAI Codex, ZAI, Kimi Coding, MiniMax +- **Skills integration** — Scans both Paperclip-managed and Hermes-native skills (`~/.hermes/skills/`), with sync/list/resolve APIs +- **Structured transcript parsing** — Raw Hermes stdout is parsed into typed `TranscriptEntry` objects so Paperclip renders proper tool cards with status icons and expand/collapse +- **Rich post-processing** — Converts Hermes ASCII banners, setext headings, and `+--+` table borders into clean GFM markdown +- **Comment-driven wakes** — Agents wake to respond to issue comments, not just task assignments +- **Auto model detection** — Reads `~/.hermes/config.yaml` to pre-populate the UI with the user's configured model +- **Session codec** — Structured validation and migration of session state across heartbeats +- **Benign stderr reclassification** — MCP init messages and structured logs are reclassified so they don't appear as errors in the UI +- **Session source tagging** — Sessions are tagged as `tool` source so they don't clutter the user's interactive history +- **Filesystem checkpoints** — Optional `--checkpoints` for rollback safety +- **Thinking effort control** — Passes `--reasoning-effort` for thinking/reasoning models + +### Hermes Agent Capabilities + +| Feature | Claude Code | Codex | Hermes Agent | +|---------|------------|-------|-------------| +| Persistent memory | ❌ | ❌ | ✅ Remembers across sessions | +| Native tools | ~5 | ~5 | 30+ (terminal, file, web, browser, vision, git, etc.) | +| Skills system | ❌ | ❌ | ✅ 80+ loadable skills | +| Session search | ❌ | ❌ | ✅ FTS5 search over past conversations | +| Sub-agent delegation | ❌ | ❌ | ✅ Parallel sub-tasks | +| Context compression | ❌ | ❌ | ✅ Auto-compresses long conversations | +| MCP client | ❌ | ❌ | ✅ Connect to any MCP server | +| Multi-provider | Anthropic only | OpenAI only | ✅ 8 providers out of the box | + +## Installation + +```bash +npm install @paperclipai/hermes-paperclip-adapter +``` + +### Prerequisites + +- [Hermes Agent](https://github.com/NousResearch/hermes-agent) installed (`pip install hermes-agent`) +- Python 3.10+ +- At least one LLM API key (Anthropic, OpenRouter, or OpenAI) + +## Quick Start + +### 1. Register the adapter in your Paperclip server + +Add to your Paperclip server's adapter registry (`server/src/adapters/registry.ts`): + +```typescript +import * as hermesLocal from "@paperclipai/hermes-paperclip-adapter"; +import { + execute, + testEnvironment, + detectModel, + listSkills, + syncSkills, + sessionCodec, +} from "@paperclipai/hermes-paperclip-adapter/server"; + +registry.set("hermes_local", { + ...hermesLocal, + execute, + testEnvironment, + detectModel, + listSkills, + syncSkills, + sessionCodec, +}); +``` + +### 2. Create a Hermes agent in Paperclip + +In the Paperclip UI or via API, create an agent with adapter type `hermes_local`: + +```json +{ + "name": "Hermes Engineer", + "adapterType": "hermes_local", + "adapterConfig": { + "model": "anthropic/claude-sonnet-4", + "maxIterations": 50, + "timeoutSec": 300, + "persistSession": true, + "enabledToolsets": ["terminal", "file", "web"] + } +} +``` + +### 3. Assign work + +Create issues in Paperclip and assign them to your Hermes agent. On each heartbeat, Hermes will: + +1. Receive the task instructions +2. Use its full tool suite to complete the work +3. Report results back to Paperclip +4. Persist session state for continuity + +## Configuration Reference + +### Core + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `model` | string | `anthropic/claude-sonnet-4` | Model in `provider/model` format | +| `provider` | string | *(auto-detected)* | API provider: `auto`, `openrouter`, `nous`, `openai-codex`, `zai`, `kimi-coding`, `minimax`, `minimax-cn` | +| `timeoutSec` | number | `300` | Execution timeout in seconds | +| `graceSec` | number | `10` | Grace period before SIGKILL | + +### Tools + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `toolsets` | string | *(all)* | Comma-separated toolsets to enable (e.g. `"terminal,file,web"`) | + +Available toolsets: `terminal`, `file`, `web`, `browser`, `code_execution`, `vision`, `mcp`, `creative`, `productivity` + +### Session & Workspace + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `persistSession` | boolean | `true` | Resume sessions across heartbeats | +| `worktreeMode` | boolean | `false` | Git worktree isolation | +| `checkpoints` | boolean | `false` | Enable filesystem checkpoints for rollback | + +### Advanced + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `hermesCommand` | string | `hermes` | Custom CLI binary path | +| `verbose` | boolean | `false` | Enable verbose output | +| `quiet` | boolean | `true` | Quiet mode (clean output, no banner/spinner) | +| `extraArgs` | string[] | `[]` | Additional CLI arguments | +| `env` | object | `{}` | Extra environment variables | +| `promptTemplate` | string | *(built-in)* | Custom prompt template | +| `paperclipApiUrl` | string | `http://127.0.0.1:3100/api` | Paperclip API base URL | + +### Prompt Template Variables + +Use `{{variable}}` syntax in `promptTemplate`: + +| Variable | Description | +|----------|-------------| +| `{{agentId}}` | Paperclip agent ID | +| `{{agentName}}` | Agent display name | +| `{{companyId}}` | Company ID | +| `{{companyName}}` | Company name | +| `{{runId}}` | Current heartbeat run ID | +| `{{taskId}}` | Assigned task/issue ID | +| `{{taskTitle}}` | Task title | +| `{{taskBody}}` | Task instructions | +| `{{projectName}}` | Project name | +| `{{paperclipApiUrl}}` | Paperclip API base URL | +| `{{commentId}}` | Comment ID (when woken by a comment) | +| `{{wakeReason}}` | Reason this run was triggered | + +Conditional sections: + +- `{{#taskId}}...{{/taskId}}` — included only when a task is assigned +- `{{#noTask}}...{{/noTask}}` — included only when no task (heartbeat check) +- `{{#commentId}}...{{/commentId}}` — included only when woken by a comment + +## Architecture + +``` +Paperclip Hermes Agent +┌──────────────────┐ ┌──────────────────┐ +│ Heartbeat │ │ │ +│ Scheduler │───execute()──▶│ hermes chat -q │ +│ │ │ │ +│ Issue System │ │ 30+ Tools │ +│ Comment Wakes │◀──results─────│ Memory System │ +│ │ │ Session DB │ +│ Cost Tracking │ │ Skills │ +│ │ │ MCP Client │ +│ Skill Sync │◀──snapshot────│ ~/.hermes/skills│ +│ Org Chart │ │ │ +└──────────────────┘ └──────────────────┘ +``` + +The adapter spawns Hermes Agent's CLI in single-query mode (`-q`). Hermes +processes the task using its full tool suite, then exits. The adapter: + +1. **Captures** stdout/stderr and parses token usage, session IDs, and cost +2. **Parses** raw output into structured `TranscriptEntry` objects (tool cards with status icons) +3. **Post-processes** Hermes ASCII formatting (banners, setext headings, table borders) into clean GFM markdown +4. **Reclassifies** benign stderr (MCP init, structured logs) so they don't show as errors +5. **Tags** sessions as `tool` source to keep them separate from interactive usage +6. **Reports** results back to Paperclip with cost, usage, and session state + +Session persistence works via Hermes's `--resume` flag — each run picks +up where the last one left off, maintaining conversation context, +memories, and tool state across heartbeats. The `sessionCodec` validates +and migrates session state between runs. + +### Skills Integration + +The adapter scans two skill sources and merges them: + +- **Paperclip-managed skills** — bundled with the adapter, togglable from the UI +- **Hermes-native skills** — from `~/.hermes/skills/`, read-only, always loaded + +The `listSkills` / `syncSkills` APIs expose a unified snapshot so the +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 +``` + +## License + +MIT — see [LICENSE](LICENSE) + +## Links + +- [Hermes Agent](https://github.com/NousResearch/hermes-agent) — The AI agent this adapter runs +- [Paperclip](https://github.com/paperclipai/paperclip) — The orchestration platform +- [Nous Research](https://nousresearch.com) — The team behind Hermes +- [Paperclip Docs](https://paperclip.ing/docs) — Paperclip documentation diff --git a/packages/adapters/hermes-local/package.json b/packages/adapters/hermes-local/package.json new file mode 100644 index 00000000000..145ac1f9074 --- /dev/null +++ b/packages/adapters/hermes-local/package.json @@ -0,0 +1,80 @@ +{ + "name": "@paperclipai/hermes-paperclip-adapter", + "version": "0.3.0", + "description": "Paperclip adapter for Hermes Agent — run Hermes as a managed employee in a Paperclip company", + "type": "module", + "license": "MIT", + "author": "Paperclip", + "repository": { + "type": "git", + "url": "https://github.com/paperclipai/paperclip", + "directory": "packages/adapters/hermes-local" + }, + "bugs": { + "url": "https://github.com/paperclipai/paperclip/issues" + }, + "homepage": "https://github.com/paperclipai/paperclip/tree/master/packages/adapters/hermes-local#readme", + "keywords": [ + "paperclip", + "hermes", + "hermes-agent", + "ai-agent", + "adapter", + "orchestration" + ], + "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", + "provenance": true + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "lint": "eslint src/", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:command-resolution": "vitest run src/server/command-resolution.test.ts", + "clean": "rm -rf dist" + }, + "dependencies": { + "@paperclipai/adapter-utils": "workspace:*", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/node": "^22.19.21", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=20.0.0" + } +} 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 00000000000..5f44bbeb4d8 --- /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 00000000000..2177ef66279 --- /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.test.ts b/packages/adapters/hermes-local/src/index.test.ts new file mode 100644 index 00000000000..e59772f381e --- /dev/null +++ b/packages/adapters/hermes-local/src/index.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "vitest"; + +import { createServerAdapter } from "./index.js"; + +test("root package export exposes Paperclip external adapter entrypoint", () => { + const adapter = createServerAdapter(); + + expect(adapter.type).toBe("hermes_local"); + expect(typeof adapter.execute).toBe("function"); + expect(typeof adapter.testEnvironment).toBe("function"); + expect(typeof adapter.sessionCodec?.deserialize).toBe("function"); + expect(adapter.supportsLocalAgentJwt).toBe(true); + expect(adapter.supportsInstructionsBundle).toBe(true); + expect(adapter.instructionsPathKey).toBe("instructionsFilePath"); + expect(typeof adapter.detectModel).toBe("function"); +}); diff --git a/packages/adapters/hermes-local/src/index.ts b/packages/adapters/hermes-local/src/index.ts new file mode 100644 index 00000000000..3619684c3ef --- /dev/null +++ b/packages/adapters/hermes-local/src/index.ts @@ -0,0 +1,115 @@ +/** + * 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 type { ServerAdapterModule } from "@paperclipai/adapter-utils"; + +import { ADAPTER_TYPE, ADAPTER_LABEL } from "./shared/constants.js"; +import { + execute, + testEnvironment, + sessionCodec, + listSkills, + syncSkills, + detectModel, +} from "./server/index.js"; + +export const type = ADAPTER_TYPE; +export const label = ADAPTER_LABEL; + +/** + * Models available through Hermes Agent. + * + * Hermes supports any model via any provider. The Paperclip UI should + * prefer detectModel() plus manual entry over curated placeholder models, + * since Hermes availability depends on the user's local configuration. + */ +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 | (Hermes configured default) | Optional explicit model in provider/model format. Leave blank to use Hermes's configured default model. | +| 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) +`; + +/** + * External adapter plugin entrypoint expected by Paperclip's adapter manager. + */ +export function createServerAdapter(): ServerAdapterModule { + return { + type, + execute, + testEnvironment, + sessionCodec, + listSkills, + syncSkills, + models, + supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: false, + agentConfigurationDoc, + detectModel, + }; +} diff --git a/packages/adapters/hermes-local/src/server/command-resolution.test.ts b/packages/adapters/hermes-local/src/server/command-resolution.test.ts new file mode 100644 index 00000000000..0462ad5a0c4 --- /dev/null +++ b/packages/adapters/hermes-local/src/server/command-resolution.test.ts @@ -0,0 +1,48 @@ +import os from "node:os"; +import path from "node:path"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { expect, test } from "vitest"; + +import { HERMES_CLI } from "../shared/constants.js"; +import { resolveHermesCommand } from "./execute.js"; +import { testEnvironment } from "./test.js"; + +test("resolveHermesCommand prefers hermesCommand over command", () => { + expect(resolveHermesCommand({ hermesCommand: "hermes_maximus", command: "hermes_backup" })) + .toBe("hermes_maximus"); +}); + +test("resolveHermesCommand falls back to command before default hermes binary", () => { + expect(resolveHermesCommand({ command: "hermes_maximus" })).toBe("hermes_maximus"); + expect(resolveHermesCommand({})).toBe(HERMES_CLI); +}); + +test("testEnvironment accepts config.command when hermesCommand is absent", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "hermes-command-resolution-")); + const cliPath = path.join(tempDir, "fake-hermes"); + + try { + await writeFile( + cliPath, + "#!/bin/sh\necho fake-hermes 1.2.3\n", + "utf8", + ); + await chmod(cliPath, 0o755); + + const result = await testEnvironment({ + companyId: "company-test", + adapterType: "hermes_local", + config: { + command: cliPath, + }, + }); + + expect(result.status).not.toBe("fail"); + expect(result.checks.some((check) => check.code === "hermes_cli_not_found")).toBe(false); + expect(result.checks.some( + (check) => check.code === "hermes_version" && check.message.includes("fake-hermes 1.2.3"), + )).toBe(true); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); diff --git a/packages/adapters/hermes-local/src/server/detect-model.test.ts b/packages/adapters/hermes-local/src/server/detect-model.test.ts new file mode 100644 index 00000000000..05ee2d8fc33 --- /dev/null +++ b/packages/adapters/hermes-local/src/server/detect-model.test.ts @@ -0,0 +1,186 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; + +import { parseModelFromConfig, resolveProvider } from "./detect-model.js"; +import { testEnvironment } from "./test.js"; + +const providerEnvKeys = [ + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "ZAI_API_KEY", + "KIMI_API_KEY", + "MINIMAX_API_KEY", +]; + +const previousEnv = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + HOMEDRIVE: process.env.HOMEDRIVE, + HOMEPATH: process.env.HOMEPATH, + ...Object.fromEntries(providerEnvKeys.map((key) => [key, process.env[key]])), +}; + +afterEach(async () => { + for (const [key, value] of Object.entries(previousEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); + +test("parseModelFromConfig tracks api_key presence without exposing the raw secret", () => { + const parsed = parseModelFromConfig([ + "model:", + " default: oca/gpt-5.4", + " provider: custom", + " base_url: https://example.invalid/litellm", + " api_key: super-secret-value", + "", + ].join("\n")); + + expect(parsed).toBeTruthy(); + expect(parsed?.hasApiKey).toBe(true); + expect(Object.hasOwn(parsed ?? {}, "apiKey")).toBe(false); +}); + +test("resolveProvider does not fall through to model inference when Hermes config provider is unsupported but matches the requested model", () => { + expect(resolveProvider({ + explicitProvider: undefined, + detectedProvider: "custom", + detectedModel: "oca/gpt-5.4", + detectedBaseUrl: "https://example.invalid/litellm", + detectedHasApiKey: true, + model: "oca/gpt-5.4", + })).toEqual({ + provider: "auto", + resolvedFrom: "hermesConfigUnsupported:custom", + }); +}); + +test("resolveProvider also defers to Hermes runtime when the matching config omits provider but includes runtime signals", () => { + expect(resolveProvider({ + explicitProvider: undefined, + detectedProvider: "", + detectedModel: "oca/gpt-5.4", + detectedBaseUrl: "https://example.invalid/litellm", + detectedHasApiKey: true, + model: "oca/gpt-5.4", + })).toEqual({ + provider: "auto", + resolvedFrom: "hermesConfigRuntime", + }); +}); + +test("resolveProvider still infers from the requested model when Hermes config is for a different model", () => { + expect(resolveProvider({ + explicitProvider: undefined, + detectedProvider: "custom", + detectedModel: "oca/gpt-5.4", + detectedBaseUrl: "https://example.invalid/litellm", + detectedHasApiKey: true, + model: "claude-sonnet-4", + })).toEqual({ + provider: "anthropic", + resolvedFrom: "modelInference", + }); +}); + +async function withHermesHomeConfig( + configLines: string[], + fn: () => Promise, +) { + const tempHome = await mkdtemp(join(tmpdir(), "hermes-paperclip-adapter-")); + const hermesDir = join(tempHome, ".hermes"); + const configPath = join(hermesDir, "config.yaml"); + + await mkdir(hermesDir, { recursive: true }); + await writeFile(configPath, `${configLines.join("\n")}\n`, "utf8"); + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + delete process.env.HOMEDRIVE; + delete process.env.HOMEPATH; + for (const key of providerEnvKeys) { + delete process.env[key]; + } + + try { + await fn(); + } finally { + await rm(tempHome, { recursive: true, force: true }); + } +} + +test("testEnvironment does not warn about missing API keys when Hermes config provides a supported provider api_key", async () => { + await withHermesHomeConfig([ + "model:", + " default: openrouter/gpt-4.1-mini", + " provider: openrouter", + " api_key: test-secret", + ], async () => { + const result = await testEnvironment({ + companyId: "company-test", + adapterType: "hermes_local", + config: { + hermesCommand: "python3", + model: "openrouter/gpt-4.1-mini", + }, + }); + + const codes = result.checks.map((check) => check.code); + + expect(codes.includes("hermes_no_api_keys")).toBe(false); + expect(result.status).toBe("pass"); + }); +}); + +test("testEnvironment describes provider-omitted runtime config without inventing provider auto", async () => { + await withHermesHomeConfig([ + "model:", + " default: oca/gpt-5.4", + " base_url: https://example.invalid/litellm", + " api_key: test-secret", + ], async () => { + const result = await testEnvironment({ + companyId: "company-test", + adapterType: "hermes_local", + config: { + hermesCommand: "python3", + model: "oca/gpt-5.4", + }, + }); + + const apiKeyCheck = result.checks.find((check) => check.code === "hermes_api_key_in_config"); + expect(apiKeyCheck).toBeTruthy(); + expect(apiKeyCheck?.message).toMatch(/without an explicit provider/i); + expect(apiKeyCheck?.message).not.toMatch(/provider "auto"/i); + }); +}); + +test("testEnvironment does not warn about missing API keys when Hermes config provides a custom provider base_url and api_key", async () => { + await withHermesHomeConfig([ + "model:", + " default: oca/gpt-5.4", + " provider: custom", + " base_url: https://example.invalid/litellm", + " api_key: test-secret", + ], async () => { + const result = await testEnvironment({ + companyId: "company-test", + adapterType: "hermes_local", + config: { + hermesCommand: "python3", + model: "oca/gpt-5.4", + }, + }); + + const codes = result.checks.map((check) => check.code); + + expect(codes.includes("hermes_no_api_keys")).toBe(false); + expect(result.status).toBe("pass"); + }); +}); 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 00000000000..fc92ca46012 --- /dev/null +++ b/packages/adapters/hermes-local/src/server/detect-model.ts @@ -0,0 +1,217 @@ +/** + * Detect the current model and provider from the user's Hermes config. + * + * Reads ~/.hermes/config.yaml and extracts the default model, + * provider, base_url, api_key presence, and api_mode settings. + * + * Also provides provider resolution logic that merges explicit config, + * Hermes config detection, and model-name prefix inference. + */ + +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { MODEL_PREFIX_PROVIDER_HINTS, VALID_PROVIDERS } from "../shared/constants.js"; + +export interface DetectedModel { + /** Model name from config (e.g. "gpt-5.4", "anthropic/claude-sonnet-4") */ + model: string; + /** Provider name from config (e.g. "copilot", "zai"). May be empty. */ + provider: string; + /** Base URL override from config (e.g. "https://api.githubcopilot.com"). May be empty. */ + baseUrl: string; + /** Whether Hermes config includes a non-empty API key. */ + hasApiKey: boolean; + /** API mode from config (e.g. "chat_completions", "codex_responses"). May be empty. */ + apiMode: string; + /** Where the detection came from */ + source: "config"; +} + +/** + * Read the Hermes config file and extract the default model config. + */ +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, model.provider, model.base_url, model.api_key, and model.api_mode + * 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 baseUrl = ""; + let hasApiKey = false; + let apiMode = ""; + 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 (key === "base_url") baseUrl = val; + if (key === "api_key") hasApiKey = val.length > 0; + if (key === "api_mode") apiMode = val; + } + } + } + + if (!model) return null; + + return { model, provider, baseUrl, hasApiKey, apiMode, source: "config" }; +} + +/** + * Infer a provider from the model name using prefix-based hints. + * + * For example: + * "gpt-5.4" → "copilot" + * "claude-sonnet-4" → "anthropic" + * "glm-5-turbo" → "zai" + * + * Returns undefined if no hint matches (caller should fall back to "auto"). + */ +export function inferProviderFromModel(model: string): string | undefined { + const lower = model.toLowerCase(); + + // Strip provider/ prefix if present (e.g. "anthropic/claude-sonnet-4") + const bareName = lower.includes("/") ? lower.split("/").pop()! : lower; + + for (const [prefix, hint] of MODEL_PREFIX_PROVIDER_HINTS) { + if (bareName.startsWith(prefix)) { + return hint; + } + } + + return undefined; +} + +/** + * Resolve the correct provider for a model, using a priority chain: + * + * 1. Explicit provider from adapterConfig (highest priority — user override) + * 2. Provider from Hermes config file — ONLY if the config model matches + * the requested model (otherwise the config provider is for a different model) + * 3. If Hermes config matches the requested model but uses runtime settings that + * the adapter cannot represent directly, return "auto" and let Hermes resolve it itself + * 4. Provider inferred from model name prefix + * 5. "auto" (let Hermes figure it out — lowest priority) + * + * Always returns a valid provider string. + * The `resolvedFrom` field indicates which source was used, useful for logging. + */ +export function resolveProvider(options: { + /** Explicit provider from adapterConfig (user override) */ + explicitProvider?: string | null; + /** Provider detected from Hermes config file */ + detectedProvider?: string; + /** Model name from Hermes config file (to check consistency) */ + detectedModel?: string; + /** Base URL detected from Hermes config file */ + detectedBaseUrl?: string; + /** Whether Hermes config includes a non-empty API key */ + detectedHasApiKey?: boolean; + /** API mode detected from Hermes config file */ + detectedApiMode?: string; + /** Model name to infer from if no explicit/detected provider */ + model?: string; +}): { provider: string; resolvedFrom: string } { + const { + explicitProvider, + detectedProvider, + detectedModel, + detectedBaseUrl, + detectedHasApiKey, + detectedApiMode, + model, + } = options; + + // 1. Explicit provider from adapterConfig — user override, always wins + if (explicitProvider && (VALID_PROVIDERS as readonly string[]).includes(explicitProvider)) { + return { provider: explicitProvider, resolvedFrom: "adapterConfig" }; + } + + const supportedProviders = VALID_PROVIDERS as readonly string[]; + const configMatchesRequestedModel = + !!detectedModel && + !!model && + detectedModel.toLowerCase() === model.toLowerCase(); + + // 2. Provider from Hermes config file — but ONLY if the config model matches + // the requested model. Otherwise the config provider is for a different model + // and would cause exactly the kind of routing bug we're fixing. + if ( + configMatchesRequestedModel && + !!detectedProvider && + supportedProviders.includes(detectedProvider) + ) { + return { provider: detectedProvider, resolvedFrom: "hermesConfig" }; + } + + const hasRuntimeSignals = !!detectedBaseUrl || !!detectedHasApiKey || !!detectedApiMode; + + // 3a. Matching Hermes config with an unsupported provider (for example "custom") + // should not fall through to model-name inference, because that can route to + // the wrong provider entirely. Defer back to Hermes's own runtime resolution. + if (configMatchesRequestedModel && !!detectedProvider && !supportedProviders.includes(detectedProvider)) { + return { + provider: "auto", + resolvedFrom: `hermesConfigUnsupported:${detectedProvider}`, + }; + } + + // 3b. Matching Hermes config may omit provider entirely while still specifying + // enough runtime information (base_url, api_key, api_mode) for Hermes itself. + // In that case, also defer to Hermes instead of doing a wrong prefix inference. + if (configMatchesRequestedModel && !detectedProvider && hasRuntimeSignals) { + return { + provider: "auto", + resolvedFrom: "hermesConfigRuntime", + }; + } + + // 4. Infer from model name prefix + if (model) { + const inferred = inferProviderFromModel(model); + if (inferred) { + return { provider: inferred, resolvedFrom: "modelInference" }; + } + } + + // 5. Let Hermes auto-detect + return { provider: "auto", resolvedFrom: "auto" }; +} 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 00000000000..50dda178aa7 --- /dev/null +++ b/packages/adapters/hermes-local/src/server/execute.ts @@ -0,0 +1,592 @@ +/** + * 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 + * --yolo bypass dangerous-command approval prompts (agents have no TTY) + * --source session source tag for filtering + */ + +import fs from "node:fs/promises"; +import path from "node:path"; + +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, + DEFAULT_MODEL, + VALID_PROVIDERS, +} from "../shared/constants.js"; + +import { + detectModel, + resolveProvider, +} from "./detect-model.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; +} + +export function resolveHermesCommand(config: Record): string { + return cfgString(config.hermesCommand) || cfgString(config.command) || HERMES_CLI; +} + +// --------------------------------------------------------------------------- +// 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 + +0. First, check out the issue so others know you are working on it: + \`curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" "{{paperclipApiUrl}}/issues/{{taskId}}/checkout"\` +1. Work on the task using your tools +2. When done, mark the issue as completed: + \`curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" "{{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 -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" "{{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 -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" "{{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 -H "Authorization: Bearer $PAPERCLIP_API_KEY" "{{paperclipApiUrl}}/issues/{{taskId}}/comments/{{commentId}}"\` + +You do NOT need to be assigned to this issue to comment on it. +Address the comment, POST a reply if needed, then continue working. +{{/commentId}} + +{{#noTask}} +## Heartbeat Wake — Check for Work + +First, check your environment for context: + \`echo "TASK_ID=$PAPERCLIP_TASK_ID WAKE_REASON=$PAPERCLIP_WAKE_REASON WAKE_COMMENT_ID=$PAPERCLIP_WAKE_COMMENT_ID"\` + +1. List ALL open issues assigned to you (todo, backlog, in_progress): + \`curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" "{{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 -H "Authorization: Bearer $PAPERCLIP_API_KEY" "{{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 -H "Authorization: Bearer $PAPERCLIP_API_KEY" "{{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 -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" "{{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; + + // BUG FIX: Read task/comment data from ctx.context (wake context), not ctx.config (adapter config). + // ctx.config only contains adapterConfig (model, provider, timeout); ctx.context has the wake payload. + const context = (ctx as any).context || {}; + const taskId = cfgString(context.taskId) || cfgString(context.issueId) || cfgString(ctx.config?.taskId); + const taskTitle = cfgString(context.taskTitle) || cfgString(ctx.config?.taskTitle) || ""; + const taskBody = cfgString(context.taskBody) || cfgString(ctx.config?.taskBody) || ""; + const commentId = cfgString(context.commentId) || cfgString(context.wakeCommentId) || cfgString(ctx.config?.commentId) || ""; + const wakeReason = cfgString(context.wakeReason) || cfgString(ctx.config?.wakeReason) || ""; + const agentName = ctx.agent?.name || "Hermes Agent"; + const companyName = cfgString(context.companyName) || cfgString(ctx.config?.companyName) || ""; + const projectName = cfgString(context.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 cleaned = cleanResponse(stdout); + if (cleaned.length > 0) { + result.response = cleaned; + } + } + + // 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.config ?? ctx.agent?.adapterConfig ?? {}) as Record; + + // ── Resolve configuration ────────────────────────────────────────────── + const hermesCmd = resolveHermesCommand(config); + const model = cfgString(config.model) || DEFAULT_MODEL; + const timeoutSec = cfgNumber(config.timeoutSec) || DEFAULT_TIMEOUT_SEC; + const graceSec = cfgNumber(config.graceSec) || DEFAULT_GRACE_SEC; + const maxTurns = cfgNumber(config.maxTurnsPerRun); + 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; + + // ── Resolve provider (defense in depth) ──────────────────────────────── + // Priority chain: + // 1. Explicit provider in adapterConfig (user override) + // 2. Provider from ~/.hermes/config.yaml (detected at runtime) + // 3. Provider inferred from model name prefix + // 4. "auto" (let Hermes decide) + // + // This ensures that even if the agent was created before provider tracking + // was added, or if the model was changed without updating provider, the + // correct provider is still used. + let detectedConfig: Awaited> | null = null; + const explicitProvider = cfgString(config.provider); + + if (!explicitProvider) { + try { + detectedConfig = await detectModel(); + } catch { + // Non-fatal — detection failure shouldn't block execution + } + } + + const { provider: resolvedProvider, resolvedFrom } = resolveProvider({ + explicitProvider, + detectedProvider: detectedConfig?.provider, + detectedModel: detectedConfig?.model, + detectedBaseUrl: detectedConfig?.baseUrl, + detectedHasApiKey: detectedConfig?.hasApiKey, + detectedApiMode: detectedConfig?.apiMode, + model, + }); + + // ── Load agent instructions file (Paperclip instruction bundles) ────── + // Built-in adapters (claude_local, codex_local, gemini_local) read the + // instructionsFilePath from adapterConfig and inject it into the agent + // prompt. The hermes adapter was missing this — so curated instruction + // files (AGENTS.md, SOUL.md, HEARTBEAT.md, TOOLS.md) were never read. + const instructionsFilePath = cfgString(config.instructionsFilePath); + let agentInstructions = ""; + if (instructionsFilePath) { + try { + agentInstructions = await fs.readFile(instructionsFilePath, "utf-8"); + const loadedInstructionsLength = agentInstructions.length; + const instructionsFileDir = path.dirname(instructionsFilePath); + agentInstructions += `\nThe above agent instructions were loaded from ${instructionsFilePath}. Resolve any relative file references from ${instructionsFileDir}/.`; + await ctx.onLog( + "stdout", + `[hermes] Loaded agent instructions from ${instructionsFilePath} (${loadedInstructionsLength} chars)\n`, + ); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + // Non-fatal: log to stdout with an explicit "Warning:" prefix so the + // Paperclip UI doesn't render this as a red error (stderr output is + // surfaced as an error signal even when execution continues). + await ctx.onLog( + "stdout", + `[hermes] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`, + ); + } + } + + // ── Build prompt ─────────────────────────────────────────────────────── + let prompt = buildPrompt(ctx, config); + if (agentInstructions) { + prompt = agentInstructions + "\n\n---\n\n" + prompt; + } + + // ── Build command args ───────────────────────────────────────────────── + // Use -Q (quiet) to get clean output: just response + session_id line + const useQuiet = cfgBoolean(config.quiet) === true; // default false + const args: string[] = ["chat", "-q", prompt]; + if (useQuiet) args.push("-Q"); + + if (model) { + args.push("-m", model); + } + + // Always pass --provider when we have a resolved one (not "auto"). + // "auto" means Hermes will decide on its own — no need to pass it. + if (resolvedProvider !== "auto") { + args.push("--provider", resolvedProvider); + } + + if (toolsets) { + args.push("-t", toolsets); + } + + if (maxTurns && maxTurns > 0) { + args.push("--max-turns", String(maxTurns)); + } + + 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"); + + // Bypass Hermes dangerous-command approval prompts. + // Paperclip agents run as non-interactive subprocesses with no TTY, + // so approval prompts would always timeout and deny legitimate commands + // (curl, python3 -c, etc.). Agents operate in a sandbox — the approval + // system is designed for human-attended interactive sessions. + args.push("--yolo"); + + // 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; + + // BUG FIX: Inject authToken as PAPERCLIP_API_KEY (matches adapter-claude-local behavior) + if ((ctx as any).authToken) env.PAPERCLIP_API_KEY = (ctx as any).authToken; + + // BUG FIX: Read task context from ctx.context (wake context), not ctx.config (adapter config) + const ctxContext = (ctx as any).context || {}; + const envTaskId = cfgString(ctxContext.taskId) || cfgString(ctxContext.issueId) || cfgString(ctx.config?.taskId); + if (envTaskId) env.PAPERCLIP_TASK_ID = envTaskId; + const envWakeReason = cfgString(ctxContext.wakeReason) || cfgString(ctx.config?.wakeReason); + if (envWakeReason) env.PAPERCLIP_WAKE_REASON = envWakeReason; + const envCommentId = cfgString(ctxContext.commentId) || cfgString(ctxContext.wakeCommentId) || cfgString(ctx.config?.commentId); + if (envCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = envCommentId; + + 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}, provider=${resolvedProvider} [${resolvedFrom}], timeout=${timeoutSec}s${maxTurns ? `, max_turns=${maxTurns}` : ""})\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: resolvedProvider, + model, + }; + + 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 00000000000..1d48afb950f --- /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, parseModelFromConfig, resolveProvider, inferProviderFromModel } 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 00000000000..6a9ea3c0d7b --- /dev/null +++ b/packages/adapters/hermes-local/src/server/skills.ts @@ -0,0 +1,226 @@ +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: "company_managed", + originLabel: "Managed by Paperclip", + readOnly: false, + sourcePath: entry.source, + targetPath: null, + detail: desired + ? "Will be available on the next run via Hermes skill loading." + : 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; runtimeName?: string | null }>, +): 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 00000000000..b75ce020cad --- /dev/null +++ b/packages/adapters/hermes-local/src/server/test.ts @@ -0,0 +1,387 @@ +/** + * 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 { readFileSync } from "node:fs"; +import { promisify } from "node:util"; + +import { HERMES_CLI, DEFAULT_MODEL, ADAPTER_TYPE, VALID_PROVIDERS } from "../shared/constants.js"; +import { detectModel, resolveProvider, inferProviderFromModel } from "./detect-model.js"; +import { resolveHermesCommand } from "./execute.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", + }; +} + +async function checkApiKeys( + config: Record, + detectedConfig: Awaited> | null, +): Promise { + // 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), then ~/.hermes/.env (Hermes local config). + 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; + } + + // Also read ~/.hermes/.env — Hermes stores API keys there by default and does + // not export them to the parent process, so Paperclip's process.env won't + // contain them. Parsing this file ensures the environment test reports + // accurate results for keys that Hermes already knows about. + const hermesEnvKeys: Record = {}; + try { + const homeDir = process.env.HOME || process.env.USERPROFILE || "/root"; + const hermesEnvPath = `${homeDir}/.hermes/.env`; + const content = readFileSync(hermesEnvPath, "utf-8"); + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eqIdx = trimmed.indexOf("="); + if (eqIdx > 0) { + const key = trimmed.substring(0, eqIdx).trim(); + const value = trimmed.substring(eqIdx + 1).trim(); + if (value.length > 0) hermesEnvKeys[key] = value; + } + } + } catch { + // ~/.hermes/.env may not exist — that's fine + } + + const has = (key: string): boolean => + !!(resolvedEnv[key] ?? process.env[key] ?? hermesEnvKeys[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"); + const hasKimi = has("KIMI_API_KEY"); + const hasMiniMax = has("MINIMAX_API_KEY"); + + const providers: string[] = []; + if (hasAnthropic) providers.push("Anthropic"); + if (hasOpenRouter) providers.push("OpenRouter"); + if (hasOpenAI) providers.push("OpenAI"); + if (hasZai) providers.push("Z.AI"); + if (hasKimi) providers.push("Kimi"); + if (hasMiniMax) providers.push("MiniMax"); + + if (providers.length > 0) { + return { + level: "info", + message: `API keys found: ${providers.join(", ")}`, + code: "hermes_api_keys_found", + }; + } + + const requestedModel = asString(config.model); + + const supportedProviders = VALID_PROVIDERS as readonly string[]; + const modelMatchesRequested = + !!detectedConfig?.model && + (!requestedModel || detectedConfig.model.toLowerCase() === requestedModel.toLowerCase()); + + const matchingHermesConfigApiKey = + !!detectedConfig?.hasApiKey && + modelMatchesRequested; + + if (matchingHermesConfigApiKey && detectedConfig) { + const providerLabel = detectedConfig.provider.trim(); + + if (!providerLabel) { + return { + level: "info", + message: "Hermes config includes an API key for the requested model via ~/.hermes/config.yaml without an explicit provider", + hint: "Skipping the built-in API-key warning because Hermes can use model.api_key from the local Hermes config.", + code: "hermes_api_key_in_config", + }; + } + + if (!supportedProviders.includes(providerLabel)) { + return { + level: "info", + message: `Hermes config includes runtime settings for unsupported adapter provider "${providerLabel}" via ~/.hermes/config.yaml`, + hint: "Skipping the built-in API-key warning because Hermes can resolve this provider at runtime.", + code: "hermes_custom_provider_config", + }; + } + + return { + level: "info", + message: `Hermes config includes an API key for provider "${providerLabel}" via ~/.hermes/config.yaml`, + hint: "Skipping the built-in API-key warning because Hermes can use model.api_key from the local Hermes config.", + code: "hermes_api_key_in_config", + }; + } + + return { + level: "warn", + message: "No LLM API keys found in environment", + hint: "Set API keys in the agent's env secrets or ~/.hermes/.env. Hermes supports: ANTHROPIC_API_KEY, OPENROUTER_API_KEY, OPENAI_API_KEY, ZAI_API_KEY, KIMI_API_KEY, MINIMAX_API_KEY", + code: "hermes_no_api_keys", + }; +} + +/** + * Check provider/model consistency. + * Warns if the configured provider might be wrong for the model. + */ +async function checkProviderConsistency( + config: Record, + detectedConfig: Awaited> | null, +): Promise { + const model = asString(config.model); + if (!model) return null; + + const explicitProvider = asString(config.provider); + + const { provider: resolved, resolvedFrom } = resolveProvider({ + explicitProvider, + detectedProvider: detectedConfig?.provider, + detectedModel: detectedConfig?.model, + detectedBaseUrl: detectedConfig?.baseUrl, + detectedHasApiKey: detectedConfig?.hasApiKey, + detectedApiMode: detectedConfig?.apiMode, + model, + }); + + // If provider was explicitly set but doesn't match what Hermes config says, + // that's worth flagging. + if (explicitProvider && detectedConfig?.provider && explicitProvider !== detectedConfig.provider) { + return { + level: "warn", + message: `Provider mismatch: adapterConfig has "${explicitProvider}" but ~/.hermes/config.yaml has "${detectedConfig.provider}". Using adapterConfig value.`, + hint: `Model "${model}" may not work correctly with provider "${explicitProvider}". Consider aligning with your Hermes config or removing the explicit provider to use auto-detection.`, + code: "hermes_provider_mismatch", + }; + } + + // If Hermes config matches the requested model but uses an adapter-unsupported + // provider such as "custom", do not report a false provider inference. + if (!explicitProvider && resolvedFrom.startsWith("hermesConfigUnsupported:")) { + const unsupportedProvider = resolvedFrom.split(":", 2)[1] || detectedConfig?.provider || "unknown"; + return { + level: "info", + message: `Hermes config uses unsupported adapter provider "${unsupportedProvider}" for model "${model}" — deferring to Hermes auto-detection`, + hint: "Paperclip will avoid model-name provider inference here and let Hermes resolve the provider from ~/.hermes/config.yaml at runtime.", + code: "hermes_provider_unsupported", + }; + } + + // If matching Hermes config provides runtime signals without an explicit provider, + // also defer to Hermes rather than inventing a provider from the model name. + if (!explicitProvider && resolvedFrom === "hermesConfigRuntime") { + return { + level: "info", + message: `Hermes config provides runtime settings for model "${model}" without an explicit adapter provider — deferring to Hermes auto-detection`, + hint: "Paperclip will avoid model-name provider inference here and let Hermes resolve the provider from ~/.hermes/config.yaml at runtime.", + code: "hermes_provider_runtime_config", + }; + } + + // If provider was auto-detected (not explicitly set), log what was resolved + if (!explicitProvider && resolvedFrom !== "auto") { + return { + level: "info", + message: `Provider auto-detected as "${resolved}" (from ${resolvedFrom}) for model "${model}"`, + code: "hermes_provider_detected", + }; + } + + // If we couldn't resolve any provider, warn + if (resolvedFrom === "auto" && !explicitProvider) { + return { + level: "warn", + message: `Could not determine provider for model "${model}" — will use Hermes auto-detection`, + hint: "Set an explicit provider in the agent config or ensure ~/.hermes/config.yaml has a matching provider for this model.", + code: "hermes_provider_unknown", + }; + } + + return null; +} + +// --------------------------------------------------------------------------- +// Main test +// --------------------------------------------------------------------------- + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const config = (ctx.config ?? {}) as Record; + const command = resolveHermesCommand(config); + 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. Detect Hermes config once for the remaining checks. + let detectedConfig: Awaited> | null = null; + try { + detectedConfig = await detectModel(); + } catch { + // Non-fatal + } + + // 6. API keys (check config.env — server resolves secrets before calling us) + const apiKeyCheck = await checkApiKeys(config, detectedConfig); + if (apiKeyCheck) checks.push(apiKeyCheck); + + // 7. Provider/model consistency + const providerCheck = await checkProviderConsistency(config, detectedConfig); + if (providerCheck) checks.push(providerCheck); + + // 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 00000000000..25f57cd86e4 --- /dev/null +++ b/packages/adapters/hermes-local/src/shared/constants.ts @@ -0,0 +1,104 @@ +/** + * 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 = 1800; + +/** Grace period after SIGTERM before SIGKILL (seconds). */ +export const DEFAULT_GRACE_SEC = 10; + +/** + * Default model to use if none specified. + * + * Use "auto" so that Hermes resolves the model from the user's local + * ~/.hermes/config.yaml — preventing the adapter from overriding a + * user's configured default (e.g. MiniMax, OpenRouter, etc.) with a + * hardcoded Anthropic model during Paperclip onboarding. + */ +export const DEFAULT_MODEL = "auto"; + +/** + * Valid --provider choices for the hermes CLI. + * Must stay in sync with `hermes chat --help`. + */ +export const VALID_PROVIDERS = [ + "auto", + "openrouter", + "nous", + "openai-codex", + "copilot", + "copilot-acp", + "anthropic", + "huggingface", + "zai", + "kimi-coding", + "minimax", + "minimax-cn", + "kilocode", +] as const; + +/** + * Model-name prefix → provider hint mapping. + * Used when no explicit provider is configured and we need to infer + * the correct provider from the model string alone. + * + * Keys are lowercased prefix patterns; values must be valid provider names. + * Longer prefixes are matched first (order matters). + */ +export const MODEL_PREFIX_PROVIDER_HINTS: [string, string][] = [ + // OpenAI-native models + ["gpt-4", "openai-codex"], + ["gpt-5", "copilot"], + ["o1-", "openai-codex"], + ["o3-", "openai-codex"], + ["o4-", "openai-codex"], + // Anthropic models + ["claude", "anthropic"], + // Google models (via openrouter or direct) + ["gemini", "auto"], + // Nous models + ["hermes-", "nous"], + // Z.AI / GLM models + ["glm-", "zai"], + // Kimi / Moonshot + ["moonshot", "kimi-coding"], + ["kimi", "kimi-coding"], + // MiniMax + ["minimax", "minimax"], + // DeepSeek + ["deepseek", "auto"], + // Meta Llama + ["llama", "auto"], + // Qwen + ["qwen", "auto"], + // Mistral + ["mistral", "auto"], + // HuggingFace models (org/model format) + ["huggingface/", "huggingface"], +]; + +/** 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 00000000000..ef9c5fb67a3 --- /dev/null +++ b/packages/adapters/hermes-local/src/ui/build-config.ts @@ -0,0 +1,87 @@ +/** + * Build adapter configuration from UI form values. + * + * Translates Paperclip's CreateConfigValues into the adapterConfig + * object stored in the agent record. + * + * NOTE: Provider resolution happens at runtime in execute.ts, not here. + * The UI may or may not pass a provider field. If it does, we persist it + * as the user's explicit override. If not, execute.ts will detect it from + * ~/.hermes/config.yaml at runtime. + */ + +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 + if (v.model.trim()) { + ac.model = v.model.trim(); + } + + // NOTE: Provider is NOT set here because the Paperclip UI form + // (CreateConfigValues) does not expose a provider field. + // Instead, provider is resolved at runtime in execute.ts using + // a priority chain: + // 1. adapterConfig.provider (if set via API directly) + // 2. ~/.hermes/config.yaml detection + // 3. Model-name prefix inference + // 4. "auto" fallback + // This ensures correct provider routing even for agents created + // before provider tracking existed. + + // Execution limits — let the user configure these from the Paperclip UI. + // timeoutSec: wall-clock kill timeout for the hermes child process. + // maxTurnsPerRun: maps to Hermes's --max-turns (agent tool-calling iterations). + ac.timeoutSec = DEFAULT_TIMEOUT_SEC; + if (v.maxTurnsPerRun > 0) { + ac.maxTurnsPerRun = v.maxTurnsPerRun; + // Scale timeout to match: ~20s per tool turn is generous headroom. + // Never go below the default (1800s / 30 min). + ac.timeoutSec = Math.max(DEFAULT_TIMEOUT_SEC, v.maxTurnsPerRun * 20); + } + + // 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 00000000000..3a49f112093 --- /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 00000000000..f8b85d7532c --- /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 00000000000..e1b71318a68 --- /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/packages/adapters/hermes-local/vitest.config.ts b/packages/adapters/hermes-local/vitest.config.ts new file mode 100644 index 00000000000..ad7d95889ad --- /dev/null +++ b/packages/adapters/hermes-local/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + exclude: ["dist/**", "node_modules/**"], + }, +}); diff --git a/server/package.json b/server/package.json index 1c9d1cb2763..6552252221b 100644 --- a/server/package.json +++ b/server/package.json @@ -51,6 +51,7 @@ "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", "@paperclipai/adapter-grok-local": "workspace:*", + "@paperclipai/hermes-paperclip-adapter": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", @@ -69,7 +70,6 @@ "drizzle-orm": "^0.45.2", "embedded-postgres": "^18.1.0-beta.16", "express": "^5.1.0", - "hermes-paperclip-adapter": "^0.3.0", "jsdom": "^28.1.0", "multer": "^2.1.1", "open": "^11.0.0", diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 983011fe8ea..a1ccaf11be0 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -10,7 +10,7 @@ const hermesExecuteMock = vi.hoisted(() => })), ); -vi.mock("hermes-paperclip-adapter/server", () => ({ +vi.mock("@paperclipai/hermes-paperclip-adapter/server", () => ({ execute: hermesExecuteMock, testEnvironment: async () => ({ adapterType: "hermes_local", diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 315f02dd74d..a67768cd59d 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -134,11 +134,11 @@ import { listSkills as hermesListSkills, syncSkills as hermesSyncSkills, detectModel as detectModelFromHermes, -} from "hermes-paperclip-adapter/server"; +} from "@paperclipai/hermes-paperclip-adapter/server"; import { agentConfigurationDoc as hermesAgentConfigurationDoc, models as hermesModels, -} from "hermes-paperclip-adapter"; +} from "@paperclipai/hermes-paperclip-adapter"; import { BUILTIN_ADAPTER_TYPES } from "./builtin-adapter-types.js"; import { buildExternalAdapters } from "./plugin-loader.js"; import { getDisabledAdapterTypes } from "../services/adapter-plugin-store.js"; @@ -453,19 +453,11 @@ const piLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: piAgentConfigurationDoc, }; -// hermes-paperclip-adapter v0.2.0 predates the authToken field; cast is -// intentional until hermes ships a matching AdapterExecutionContext type. -const executeHermesLocal = hermesExecute as unknown as ServerAdapterModule["execute"]; -// hermes-paperclip-adapter v0.2.0 still depends on the published @paperclipai/adapter-utils -// that ships the "paperclip_required" origin; casts bridge until hermes upgrades. -const listHermesSkills = hermesListSkills as unknown as ServerAdapterModule["listSkills"]; -const syncHermesSkills = hermesSyncSkills as unknown as ServerAdapterModule["syncSkills"]; - const hermesLocalAdapter: ServerAdapterModule = { type: "hermes_local", execute: async (ctx) => { const normalizedCtx = normalizeHermesConfig(ctx); - if (!normalizedCtx.authToken) return executeHermesLocal(normalizedCtx); + if (!normalizedCtx.authToken) return hermesExecute(normalizedCtx); const existingConfig = (normalizedCtx.agent.adapterConfig ?? {}) as Record; const existingEnv = @@ -510,12 +502,12 @@ const hermesLocalAdapter: ServerAdapterModule = { }, }; - return executeHermesLocal(patchedCtx); + return hermesExecute(patchedCtx); }, - testEnvironment: (ctx) => hermesTestEnvironment(normalizeHermesConfig(ctx) as never), + testEnvironment: (ctx) => hermesTestEnvironment(normalizeHermesConfig(ctx)), sessionCodec: hermesSessionCodec, - listSkills: listHermesSkills, - syncSkills: syncHermesSkills, + listSkills: hermesListSkills, + syncSkills: hermesSyncSkills, models: hermesModels, supportsLocalAgentJwt: true, supportsInstructionsBundle: false, diff --git a/ui/package.json b/ui/package.json index e20a5751ad6..8a428c1b462 100644 --- a/ui/package.json +++ b/ui/package.json @@ -41,6 +41,7 @@ "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", "@paperclipai/adapter-grok-local": "workspace:*", + "@paperclipai/hermes-paperclip-adapter": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", @@ -52,7 +53,6 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "hermes-paperclip-adapter": "^0.3.0", "i18next": "^26.3.1", "lexical": "0.35.0", "lucide-react": "^0.577.0", diff --git a/ui/src/adapters/hermes-local/index.ts b/ui/src/adapters/hermes-local/index.ts index a74491748b0..fdc49f9c641 100644 --- a/ui/src/adapters/hermes-local/index.ts +++ b/ui/src/adapters/hermes-local/index.ts @@ -1,6 +1,6 @@ import type { UIAdapterModule } from "../types"; -import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui"; -import { buildHermesConfig } from "hermes-paperclip-adapter/ui"; +import { parseHermesStdoutLine } from "@paperclipai/hermes-paperclip-adapter/ui"; +import { buildHermesConfig } from "@paperclipai/hermes-paperclip-adapter/ui"; import { SchemaConfigFields } from "../schema-config-fields"; export const hermesLocalUIAdapter: UIAdapterModule = {