mirror of
https://github.com/paperclipai/hermes-paperclip-adapter.git
synced 2026-11-07 06:15:59 -10:00
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:
commit
edfb917337
17 files changed
+1326
No files matched your search
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
coverage/
|
||||
.DS_Store
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Generated
+66
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* CLI module exports — used by Paperclip's CLI for terminal formatting.
|
||||
*/
|
||||
|
||||
export { printHermesStreamEvent } from "./format-event.js";
|
||||
@@ -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)
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Server-side adapter module exports.
|
||||
*/
|
||||
|
||||
export { execute } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 }];
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user