feat: initial Paperclip adapter for Hermes Agent

Implements the Paperclip ServerAdapterModule interface to run Hermes Agent
as a managed employee in a Paperclip company.

Modules:
- server/execute.ts — spawns hermes CLI, handles sessions, parses output
- server/test.ts — environment checks (CLI, Python, API keys)
- ui/parse-stdout.ts — stdout → TranscriptEntry[] for run viewer
- ui/build-config.ts — UI form → adapterConfig
- cli/format-event.ts — terminal output formatting

Features:
- Session persistence across heartbeats via --resume
- Configurable model, provider, toolsets, timeout
- Conditional prompt templates (task vs heartbeat wake)
- Token usage and cost extraction from output
- Git worktree isolation mode
- Full environment test suite
This commit is contained in:
teknium1 committed 2026-03-10 17:17:07 -07:00
commit edfb917337
17 files changed
+1326

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
*.tsbuildinfo
.env
.env.*
!.env.example
coverage/
.DS_Store
+49
View File
@@ -0,0 +1,49 @@
# Hermes Paperclip Adapter — Development Guide
## Overview
This is a Paperclip adapter that runs Hermes Agent as a managed employee.
It implements the `ServerAdapterModule` interface from `@paperclipai/adapter-utils`.
## Structure
```
src/
├── index.ts # Root: type, label, models, agentConfigurationDoc
├── shared/constants.ts # Shared constants (regex, defaults)
├── server/
│ ├── index.ts # Re-exports execute + testEnvironment
│ ├── execute.ts # Core execution (spawn hermes CLI)
│ └── test.ts # Environment checks (CLI, Python, API keys)
├── ui/
│ ├── index.ts # Re-exports
│ ├── parse-stdout.ts # Hermes stdout → TranscriptEntry[]
│ └── build-config.ts # UI form → adapterConfig
└── cli/
├── index.ts # Re-exports
└── format-event.ts # Terminal output formatting
```
## Key Interfaces
The adapter implements `ServerAdapterModule`:
- `execute(ctx)` — spawns `hermes chat -q "..."`, returns `AdapterExecutionResult`
- `testEnvironment(ctx)` — checks CLI, Python, API keys
- `models` — list of available LLM models
- `agentConfigurationDoc` — markdown docs for the config form
## Build
```bash
npm install
npm run build # tsc → dist/
npm run typecheck # type checking only
```
## Testing against a local Paperclip instance
1. Build this adapter: `npm run build`
2. In your Paperclip repo, add this as a local dependency
3. Register in `server/src/adapters/registry.ts`
4. Create an agent with `adapterType: "hermes_local"`
5. Trigger a heartbeat and observe logs
+21
View File
@@ -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.
+178
View File
@@ -0,0 +1,178 @@
# 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.
## Why Hermes Agent?
| 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 | ✅ Anthropic, OpenAI, OpenRouter, Google |
## Installation
```bash
npm install @nousresearch/paperclip-adapter-hermes
```
### 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 "@nousresearch/paperclip-adapter-hermes";
import { execute, testEnvironment } from "@nousresearch/paperclip-adapter-hermes/server";
registry.set("hermes_local", {
...hermesLocal,
execute,
testEnvironment,
});
```
### 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 override |
| `maxIterations` | number | `50` | Max tool-calling iterations per run |
| `timeoutSec` | number | `300` | Execution timeout in seconds |
| `graceSec` | number | `10` | Grace period before SIGKILL |
### Tools
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabledToolsets` | string[] | *(all)* | Toolsets to enable |
| `disabledToolsets` | string[] | `[]` | Toolsets to disable |
Available toolsets: `terminal`, `file`, `web`, `browser`, `code_execution`, `vision`, `mcp`, `creative`, `productivity`
### Session & Memory
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `persistSession` | boolean | `true` | Resume sessions across heartbeats |
| `skipMemory` | boolean | `false` | Disable persistent memory |
| `worktreeMode` | boolean | `false` | Git worktree isolation |
### Advanced
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `hermesCommand` | string | `hermes` | Custom CLI binary path |
| `contextFiles` | string[] | `[]` | Extra context files to load |
| `extraArgs` | string[] | `[]` | Additional CLI arguments |
| `env` | object | `{}` | Extra environment variables |
| `promptTemplate` | string | *(built-in)* | Custom prompt template |
### 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 |
Conditional sections:
- `{{#taskId}}...{{/taskId}}` — included only when a task is assigned
- `{{#noTask}}...{{/noTask}}` — included only when no task (heartbeat check)
## Architecture
```
Paperclip Hermes Agent
┌──────────────────┐ ┌──────────────────┐
│ Heartbeat │ │ │
│ Scheduler │───execute()──▶│ hermes chat -q │
│ │ │ │
│ Issue System │ │ 30+ Tools │
│ │◀──results─────│ Memory System │
│ Cost Tracking │ │ Session DB │
│ │ │ Skills │
│ Org Chart │ │ MCP Client │
└──────────────────┘ └──────────────────┘
```
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
captures stdout/stderr, parses token usage and session IDs, and reports
results back to Paperclip.
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.
## Development
```bash
git clone https://github.com/NousResearch/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
+66
View File
@@ -0,0 +1,66 @@
{
"name": "@nousresearch/paperclip-adapter-hermes",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@nousresearch/paperclip-adapter-hermes",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@paperclipai/adapter-utils": "^0.3.0",
"picocolors": "^1.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@paperclipai/adapter-utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-0.3.0.tgz",
"integrity": "sha512-B202BryVaVlTJANukbiYIVVa0fNNwmnx1BCNjCpXZITaHKoQ0bxX6eWNjpssIGhyVBYgNY6JW+eyFxLiKmJiuw=="
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@nousresearch/paperclip-adapter-hermes",
"version": "0.1.0",
"description": "Paperclip adapter for Hermes Agent — run Hermes as a managed employee in a Paperclip company",
"type": "module",
"license": "MIT",
"author": "Nous Research",
"repository": {
"type": "git",
"url": "https://github.com/NousResearch/hermes-paperclip-adapter"
},
"keywords": [
"paperclip",
"hermes",
"hermes-agent",
"ai-agent",
"adapter",
"orchestration"
],
"exports": {
".": "./dist/index.js",
"./server": "./dist/server/index.js",
"./ui": "./dist/ui/index.js",
"./cli": "./dist/cli/index.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@paperclipai/adapter-utils": "^0.3.0",
"picocolors": "^1.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0"
},
"engines": {
"node": ">=20.0.0"
}
}
+61
View File
@@ -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("<thinking>")) {
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));
}
+5
View File
@@ -0,0 +1,5 @@
/**
* CLI module exports — used by Paperclip's CLI for terminal formatting.
*/
export { printHermesStreamEvent } from "./format-event.js";
+94
View File
@@ -0,0 +1,94 @@
/**
* Hermes Agent adapter for Paperclip.
*
* Runs Hermes Agent (https://github.com/NousResearch/hermes-agent)
* as a managed employee in a Paperclip company. Hermes Agent is a
* full-featured AI agent with 30+ native tools, persistent memory,
* skills, session persistence, and MCP support.
*
* @packageDocumentation
*/
import { ADAPTER_TYPE, ADAPTER_LABEL } from "./shared/constants.js";
export const type = ADAPTER_TYPE;
export const label = ADAPTER_LABEL;
/**
* Models available through Hermes Agent.
*
* Hermes supports any model via OpenRouter, Anthropic, or OpenAI APIs.
* This list contains commonly used defaults — the actual available models
* depend on the user's configured API keys and provider.
*/
export const models: { id: string; label: string }[] = [
{ id: "anthropic/claude-sonnet-4", label: "Claude Sonnet 4 (Anthropic)" },
{ id: "anthropic/claude-opus-4", label: "Claude Opus 4 (Anthropic)" },
{ id: "openai/gpt-4.1", label: "GPT-4.1 (OpenAI)" },
{ id: "openai/o3", label: "o3 (OpenAI)" },
{ id: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro (Google)" },
{ id: "deepseek/deepseek-r1", label: "DeepSeek R1" },
{ id: "anthropic/claude-haiku-3.5", label: "Claude Haiku 3.5 (Anthropic)" },
];
/**
* Documentation shown in the Paperclip UI when configuring a Hermes agent.
*/
export const agentConfigurationDoc = `# Hermes Agent Configuration
Hermes Agent is a full-featured AI agent by Nous Research with 30+ native
tools, persistent memory, session persistence, skills, and MCP support.
## Prerequisites
- Python 3.10+ installed
- Hermes Agent installed: \`pip install hermes-agent\`
- At least one LLM API key configured in ~/.hermes/.env
## Core Configuration
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| model | string | anthropic/claude-sonnet-4 | Model to use (provider/model format) |
| provider | string | (auto) | API provider: anthropic, openrouter, openai |
| maxIterations | number | 50 | Maximum tool-calling iterations per run |
| timeoutSec | number | 300 | Execution timeout in seconds |
| graceSec | number | 10 | Grace period after SIGTERM before SIGKILL |
## Tool Configuration
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| enabledToolsets | string[] | (all) | Toolsets to enable: terminal, file, web, browser, etc. |
| disabledToolsets | string[] | [] | Toolsets to disable |
## Session & Memory
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| persistSession | boolean | true | Resume sessions across heartbeats |
| skipMemory | boolean | false | Disable persistent memory |
| worktreeMode | boolean | false | Use git worktree for isolated changes |
## Advanced
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| hermesCommand | string | hermes | Path to hermes CLI binary |
| contextFiles | string[] | [] | Extra context files to load |
| 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)
`;
+336
View File
@@ -0,0 +1,336 @@
/**
* Server-side execution logic for the Hermes Agent adapter.
*
* Spawns `hermes chat -q "..."` as a child process, streams output,
* and returns structured results to Paperclip.
*/
import type {
AdapterExecutionContext,
AdapterExecutionResult,
UsageSummary,
} from "@paperclipai/adapter-utils";
import {
runChildProcess,
buildPaperclipEnv,
renderTemplate,
ensureAbsoluteDirectory,
asString as _asString,
asNumber as _asNumber,
asBoolean as _asBoolean,
asStringArray as _asStringArray,
} from "@paperclipai/adapter-utils/server-utils";
import {
HERMES_CLI,
DEFAULT_TIMEOUT_SEC,
DEFAULT_GRACE_SEC,
DEFAULT_MAX_ITERATIONS,
DEFAULT_MODEL,
SESSION_ID_REGEX,
TOKEN_USAGE_REGEX,
COST_REGEX,
ADAPTER_TYPE,
} from "../shared/constants.js";
// ---------------------------------------------------------------------------
// Config helpers (wrapping adapter-utils versions with optional semantics)
// ---------------------------------------------------------------------------
function cfgString(v: unknown): string | undefined {
return typeof v === "string" && v.length > 0 ? v : undefined;
}
function cfgNumber(v: unknown): number | undefined {
return typeof v === "number" ? v : undefined;
}
function cfgBoolean(v: unknown): boolean | undefined {
return typeof v === "boolean" ? v : undefined;
}
function cfgStringArray(v: unknown): string[] | undefined {
return Array.isArray(v) && v.every((i) => typeof i === "string")
? (v as string[])
: undefined;
}
// ---------------------------------------------------------------------------
// Wake-up prompt builder
// ---------------------------------------------------------------------------
const DEFAULT_PROMPT_TEMPLATE = `You are an AI agent working as an employee in a company managed by Paperclip.
Your agent ID is {{agentId}} and you work for company {{companyId}}.
Your agent name is "{{agentName}}".
{{#taskId}}
You have been assigned a task:
Issue ID: {{taskId}}
Title: {{taskTitle}}
Instructions:
{{taskBody}}
When you are done, report your results clearly. If you made code changes,
summarize what you changed and why.
{{/taskId}}
{{#noTask}}
You have been woken by a heartbeat. Check for any pending work:
1. Use your tools to check the current state of your project
2. Look for any issues or improvements you can make
3. If there is nothing to do, report that briefly
API URL: {{paperclipApiUrl}}
{{/noTask}}`;
function buildPrompt(
ctx: AdapterExecutionContext,
config: Record<string, unknown>,
): string {
const template = cfgString(config.promptTemplate) || DEFAULT_PROMPT_TEMPLATE;
const taskId = cfgString(ctx.config?.taskId);
const taskTitle = cfgString(ctx.config?.taskTitle) || "";
const taskBody = cfgString(ctx.config?.taskBody) || "";
const agentName = ctx.agent?.name || "Hermes Agent";
const companyName = cfgString(ctx.config?.companyName) || "";
const projectName = cfgString(ctx.config?.projectName) || "";
const paperclipApiUrl =
cfgString(config.paperclipApiUrl) ||
process.env.PAPERCLIP_API_URL ||
"http://localhost:3100/api";
const vars: Record<string, unknown> = {
agentId: ctx.agent?.id || "",
agentName,
companyId: ctx.agent?.companyId || "",
companyName,
runId: ctx.runId || "",
taskId: taskId || "",
taskTitle,
taskBody,
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",
);
// Replace remaining {{variable}} placeholders
return renderTemplate(rendered, vars);
}
// ---------------------------------------------------------------------------
// Output parsing
// ---------------------------------------------------------------------------
interface ParsedOutput {
sessionId?: string;
usage?: UsageSummary;
costUsd?: number;
errorMessage?: string;
}
function parseHermesOutput(stdout: string, stderr: string): ParsedOutput {
const combined = stdout + "\n" + stderr;
const result: ParsedOutput = {};
// Extract session ID (Hermes prints it on exit)
const sessionMatch = combined.match(SESSION_ID_REGEX);
if (sessionMatch?.[1]) {
result.sessionId = sessionMatch[1];
}
// 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));
if (errorLines.length > 0) {
result.errorMessage = errorLines.slice(0, 5).join("\n");
}
}
return result;
}
// ---------------------------------------------------------------------------
// Main execute
// ---------------------------------------------------------------------------
export async function execute(
ctx: AdapterExecutionContext,
): Promise<AdapterExecutionResult> {
const config = (ctx.agent?.adapterConfig ?? {}) as Record<string, unknown>;
// ── Resolve configuration ──────────────────────────────────────────────
const hermesCmd = cfgString(config.hermesCommand) || HERMES_CLI;
const model = cfgString(config.model) || DEFAULT_MODEL;
const provider = cfgString(config.provider);
const maxIterations =
cfgNumber(config.maxIterations) || DEFAULT_MAX_ITERATIONS;
const timeoutSec = cfgNumber(config.timeoutSec) || DEFAULT_TIMEOUT_SEC;
const graceSec = cfgNumber(config.graceSec) || DEFAULT_GRACE_SEC;
const enabledToolsets = cfgStringArray(config.enabledToolsets);
const disabledToolsets = cfgStringArray(config.disabledToolsets);
const contextFiles = cfgStringArray(config.contextFiles);
const extraArgs = cfgStringArray(config.extraArgs);
const persistSession = cfgBoolean(config.persistSession) !== false; // default true
const skipMemory = cfgBoolean(config.skipMemory) === true;
const worktreeMode = cfgBoolean(config.worktreeMode) === true;
const quietMode = cfgBoolean(config.quietMode) !== false; // default true
// ── Build prompt ───────────────────────────────────────────────────────
const prompt = buildPrompt(ctx, config);
// ── Build command args ─────────────────────────────────────────────────
const args: string[] = ["chat", "-q", prompt];
args.push("--model", model);
if (provider) args.push("--provider", provider);
args.push("--max-iterations", String(maxIterations));
if (quietMode) args.push("--quiet");
if (skipMemory) args.push("--skip-memory");
if (worktreeMode) args.push("-w");
if (enabledToolsets?.length) {
args.push("--toolsets", enabledToolsets.join(","));
}
if (disabledToolsets?.length) {
args.push("--disable-toolsets", disabledToolsets.join(","));
}
if (contextFiles?.length) {
for (const f of contextFiles) {
args.push("--context-file", f);
}
}
// Session resume: if we have a previous session, resume it
const prevSessionId = cfgString(
(ctx.runtime?.sessionParams as Record<string, unknown> | null)?.sessionId,
);
if (persistSession && prevSessionId) {
args.push("--resume", prevSessionId);
}
if (extraArgs?.length) {
args.push(...extraArgs);
}
// ── Build environment ──────────────────────────────────────────────────
const env: Record<string, string> = {
...(process.env as Record<string, string>),
...buildPaperclipEnv(ctx.agent),
};
// Pass Paperclip context via environment
if (ctx.runId) env.PAPERCLIP_RUN_ID = ctx.runId;
const taskId = cfgString(ctx.config?.taskId);
if (taskId) env.PAPERCLIP_TASK_ID = taskId;
// Merge user-specified env vars
const userEnv = config.env as Record<string, string> | 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: let the process start and fail with a better error
}
// ── Log start ──────────────────────────────────────────────────────────
await ctx.onLog(
"stdout",
`[hermes] Starting Hermes Agent (model=${model})\n`,
);
await ctx.onLog(
"stdout",
`[hermes] Command: ${hermesCmd} ${args.slice(0, 3).join(" ")} ...\n`,
);
if (prevSessionId) {
await ctx.onLog(
"stdout",
`[hermes] Resuming session: ${prevSessionId}\n`,
);
}
// ── Execute ────────────────────────────────────────────────────────────
const result = await runChildProcess(ctx.runId, hermesCmd, args, {
cwd,
env,
timeoutSec,
graceSec,
onLog: ctx.onLog,
});
// ── Parse output ───────────────────────────────────────────────────────
const parsed = parseHermesOutput(result.stdout || "", result.stderr || "");
await ctx.onLog(
"stdout",
`[hermes] Exit code: ${result.exitCode ?? "null"}, timed out: ${result.timedOut ?? false}\n`,
);
// ── Build result ───────────────────────────────────────────────────────
const executionResult: AdapterExecutionResult = {
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
provider: provider || "anthropic",
model,
};
if (parsed.errorMessage) {
executionResult.errorMessage = parsed.errorMessage;
}
if (parsed.usage) {
executionResult.usage = parsed.usage;
}
if (parsed.costUsd !== undefined) {
executionResult.costUsd = parsed.costUsd;
}
// Store session ID for next run via sessionParams
if (persistSession && parsed.sessionId) {
executionResult.sessionParams = { sessionId: parsed.sessionId };
executionResult.sessionDisplayId = parsed.sessionId.slice(0, 16);
}
return executionResult;
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Server-side adapter module exports.
*/
export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";
+208
View File
@@ -0,0 +1,208 @@
/**
* Environment test for the Hermes Agent adapter.
*
* Verifies that Hermes Agent is installed, accessible, and configured
* before allowing the adapter to be used.
*/
import type {
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
AdapterEnvironmentCheck,
} from "@paperclipai/adapter-utils";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { HERMES_CLI, DEFAULT_MODEL, ADAPTER_TYPE } from "../shared/constants.js";
const execFileAsync = promisify(execFile);
function asString(v: unknown): string | undefined {
return typeof v === "string" ? v : undefined;
}
// ---------------------------------------------------------------------------
// Checks
// ---------------------------------------------------------------------------
async function checkCliInstalled(
command: string,
): Promise<AdapterEnvironmentCheck | null> {
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<AdapterEnvironmentCheck | null> {
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<AdapterEnvironmentCheck | null> {
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<string, unknown>,
): AdapterEnvironmentCheck | null {
const model = asString(config.model);
if (!model) {
return {
level: "info",
message: `No model specified — will use default: ${DEFAULT_MODEL}`,
code: "hermes_default_model",
};
}
return {
level: "info",
message: `Model: ${model}`,
code: "hermes_model_configured",
};
}
function checkApiKeys(): AdapterEnvironmentCheck | null {
const hasAnthropic = !!process.env.ANTHROPIC_API_KEY;
const hasOpenRouter = !!process.env.OPENROUTER_API_KEY;
const hasOpenAI = !!process.env.OPENAI_API_KEY;
if (!hasAnthropic && !hasOpenRouter && !hasOpenAI) {
return {
level: "warn",
message: "No LLM API keys found in environment",
hint: "Set ANTHROPIC_API_KEY, OPENROUTER_API_KEY, or OPENAI_API_KEY. Hermes may also have keys configured in ~/.hermes/.env",
code: "hermes_no_api_keys",
};
}
const providers: string[] = [];
if (hasAnthropic) providers.push("Anthropic");
if (hasOpenRouter) providers.push("OpenRouter");
if (hasOpenAI) providers.push("OpenAI");
return {
level: "info",
message: `API keys found: ${providers.join(", ")}`,
code: "hermes_api_keys_found",
};
}
// ---------------------------------------------------------------------------
// Main test
// ---------------------------------------------------------------------------
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const config = (ctx.config ?? {}) as Record<string, unknown>;
const command = asString(config.hermesCommand) || HERMES_CLI;
const checks: AdapterEnvironmentCheck[] = [];
// 1. CLI installed?
const cliCheck = await checkCliInstalled(command);
if (cliCheck) {
checks.push(cliCheck);
if (cliCheck.level === "error") {
return {
adapterType: ADAPTER_TYPE,
status: "fail",
checks,
testedAt: new Date().toISOString(),
};
}
}
// 2. CLI version
const versionCheck = await checkCliVersion(command);
if (versionCheck) checks.push(versionCheck);
// 3. Python available?
const pythonCheck = await checkPython();
if (pythonCheck) checks.push(pythonCheck);
// 4. Model config
const modelCheck = checkModel(config);
if (modelCheck) checks.push(modelCheck);
// 5. API keys
const apiKeyCheck = checkApiKeys();
if (apiKeyCheck) checks.push(apiKeyCheck);
// Determine overall status
const hasErrors = checks.some((c) => c.level === "error");
const hasWarnings = checks.some((c) => c.level === "warn");
return {
adapterType: ADAPTER_TYPE,
status: hasErrors ? "fail" : hasWarnings ? "warn" : "pass",
checks,
testedAt: new Date().toISOString(),
};
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Shared constants for the Hermes Agent adapter.
*/
/** Adapter type identifier registered with Paperclip. */
export const ADAPTER_TYPE = "hermes_local";
/** Human-readable label shown in the Paperclip UI. */
export const ADAPTER_LABEL = "Hermes Agent";
/** Default CLI binary name. */
export const HERMES_CLI = "hermes";
/** Default timeout for a single execution run (seconds). */
export const DEFAULT_TIMEOUT_SEC = 300;
/** Grace period after SIGTERM before SIGKILL (seconds). */
export const DEFAULT_GRACE_SEC = 10;
/** Default maximum tool-calling iterations per run. */
export const DEFAULT_MAX_ITERATIONS = 50;
/** Default model to use if none specified. */
export const DEFAULT_MODEL = "anthropic/claude-sonnet-4";
/** 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 = "💭";
/** Known Hermes error patterns. */
export const ERROR_PATTERNS = [
/error[:\s]+(.+)/i,
/exception[:\s]+(.+)/i,
/failed[:\s]+(.+)/i,
/api[_ ]?error[:\s]+(.+)/i,
] as const;
+64
View File
@@ -0,0 +1,64 @@
/**
* Build adapter configuration from UI form values.
*
* Translates Paperclip's CreateConfigValues into the adapterConfig
* object stored in the agent record.
*/
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import {
DEFAULT_MODEL,
DEFAULT_TIMEOUT_SEC,
DEFAULT_MAX_ITERATIONS,
} from "../shared/constants.js";
/**
* Build a Hermes Agent adapter config from the Paperclip UI form values.
*/
export function buildHermesConfig(
v: CreateConfigValues,
): Record<string, unknown> {
const ac: Record<string, unknown> = {};
// Model
ac.model = v.model || DEFAULT_MODEL;
// Execution limits
ac.timeoutSec = DEFAULT_TIMEOUT_SEC;
ac.maxIterations = v.maxTurnsPerRun || DEFAULT_MAX_ITERATIONS;
// 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;
}
+7
View File
@@ -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";
+108
View File
@@ -0,0 +1,108 @@
/**
* Parse Hermes Agent stdout into TranscriptEntry objects for the Paperclip UI.
*
* Hermes CLI output follows patterns:
* - Tool output lines start with "┊"
* - Thinking blocks are wrapped in markers
* - Regular assistant text is the final response
* - Session info, warnings, and errors have recognizable prefixes
*/
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
import { TOOL_OUTPUT_PREFIX } from "../shared/constants.js";
/**
* Detect if a line is a Hermes tool call log.
*
* Hermes outputs tool activity like:
* ┊ terminal: ls -la
* ┊ read_file: /path/to/file (1-50)
* ┊ search_files: "pattern" in .
*/
function isToolLine(line: string): boolean {
return line.startsWith(TOOL_OUTPUT_PREFIX);
}
/**
* Parse a tool output line into tool name and content.
*/
function parseToolLine(line: string): { tool: string; content: string } | null {
// Remove the "┊ " prefix
const stripped = line.slice(TOOL_OUTPUT_PREFIX.length).trim();
// Match "tool_name: content" or "tool_name(args): content"
const match = stripped.match(/^([a-z_]+)(?:\([^)]*\))?[:\s]+(.*)$/i);
if (match) {
return { tool: match[1], content: match[2] };
}
return { tool: "unknown", content: stripped };
}
/**
* Detect thinking block markers.
*/
function isThinkingLine(line: string): boolean {
return (
line.includes("💭") ||
line.startsWith("<thinking>") ||
line.startsWith("</thinking>") ||
line.startsWith("Thinking:")
);
}
/**
* Parse a single line of Hermes stdout into transcript entries.
*
* Uses the Paperclip TranscriptEntry discriminated union with `kind` field.
*
* @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]")) {
return [{ kind: "system", ts, text: trimmed }];
}
// ── Tool output ────────────────────────────────────────────────────────
if (isToolLine(trimmed)) {
const parsed = parseToolLine(trimmed);
if (parsed) {
// Map to stdout since tool_result requires toolUseId which we don't
// have from raw CLI output
return [{ kind: "stdout", ts, text: `[${parsed.tool}] ${parsed.content}` }];
}
}
// ── 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 }];
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}