mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
Add Hermes adapter workspace package
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
1a3e398107
commit
23a0a116d5
28 files changed
+2747
-20
No files matched your search
@@ -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:*",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Nous Research
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user