Add Hermes adapter workspace package

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-26 13:53:05 +00:00
1 parent 1a3e398107
commit 23a0a116d5
28 files changed
+2747 -20

No files matched your search

+1
View File
@@ -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:*",
+7
View File
@@ -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<string, CLIAdapterModule>(
cursorCloudCLIAdapter,
geminiLocalCLIAdapter,
grokLocalCLIAdapter,
hermesLocalCLIAdapter,
openclawGatewayCLIAdapter,
processCLIAdapter,
httpCLIAdapter,
+1
View File
@@ -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",
+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.
+228
View File
@@ -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
@@ -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"
}
}
@@ -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,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");
});
+115
View File
@@ -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,
};
}
@@ -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 });
}
});
@@ -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<void>,
) {
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");
});
});
@@ -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<DetectedModel | null> {
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" };
}
@@ -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, unknown>): 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: <your summary here>"}'\`
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: <brief>"}'\`
{{/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, unknown>,
): 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<string, unknown> = {
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: <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:
// <response text>
//
// session_id: <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<AdapterExecutionResult> {
const config = (ctx.config ?? ctx.agent?.adapterConfig ?? {}) as Record<string, unknown>;
// ── 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<ReturnType<typeof detectModel>> | 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<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),
};
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<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
}
// ── 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;
}
@@ -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<string, unknown>;
const sessionId =
readNonEmptyString(record.sessionId) ??
readNonEmptyString(record.session_id);
if (!sessionId) return null;
return { sessionId };
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
const sessionId =
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id);
if (!sessionId) return null;
return { sessionId };
},
getDisplayId(params: Record<string, unknown> | null) {
if (!params) return null;
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
},
};
@@ -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, unknown>): string {
const env =
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
? (config.env as Record<string, unknown>)
: {};
const configuredHome = asString(env.HOME);
return configuredHome ? path.resolve(configuredHome) : os.homedir();
}
interface SkillFrontmatter {
name?: string;
description?: string;
version?: string;
category?: string;
metadata?: Record<string, unknown>;
}
function parseSkillFrontmatter(content: string): SkillFrontmatter {
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!match) return {};
const frontmatter: Record<string, unknown> = {};
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<AdapterSkillEntry[]> {
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<AdapterSkillEntry> {
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<string, unknown>): Promise<AdapterSkillSnapshot> {
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<AdapterSkillSnapshot> {
return buildHermesSkillSnapshot(ctx.config);
}
export async function syncHermesSkills(
ctx: AdapterSkillContext,
_desiredSkills: string[],
): Promise<AdapterSkillSnapshot> {
// 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<string, unknown>,
availableEntries: Array<{ key: string; runtimeName?: string | null }>,
): string[] {
return resolvePaperclipDesiredSkillNames(config, availableEntries);
}
@@ -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<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 — 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<string, unknown>,
detectedConfig: Awaited<ReturnType<typeof detectModel>> | null,
): Promise<AdapterEnvironmentCheck | null> {
// The server resolves secret refs into config.env before calling testEnvironment,
// so we check config.env first (adapter-configured secrets), then fall back to
// process.env (server/host environment), then ~/.hermes/.env (Hermes local config).
const envConfig = (config.env ?? {}) as Record<string, unknown>;
const resolvedEnv: Record<string, string> = {};
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<string, string> = {};
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<string, unknown>,
detectedConfig: Awaited<ReturnType<typeof detectModel>> | null,
): Promise<AdapterEnvironmentCheck | null> {
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<AdapterEnvironmentTestResult> {
const config = (ctx.config ?? {}) as Record<string, unknown>;
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<ReturnType<typeof detectModel>> | 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(),
};
}
@@ -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 = "💭";
@@ -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<string, unknown> {
const ac: Record<string, unknown> = {};
// 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;
}
@@ -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,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<string, string> = {
"$": "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("<thinking>") ||
line.startsWith("</thinking>") ||
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 }];
}
@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
exclude: ["dist/**", "node_modules/**"],
},
});
+1 -1
View File
@@ -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",
@@ -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",
+7 -15
View File
@@ -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<string, unknown>;
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,
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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 = {