feat(curator): switch Hermes Curator to DeepSeek V4-Pro via deepseek_local adapter

A/B test (2026-05-05) showed DeepSeek V4-Pro is 2-3x faster and ~20x cheaper
than Sonnet for style/lexicon pattern analysis, with comparable quality.
Adds adapters/deepseek-paperclip-adapter/ package, documents adapter requirements
(env injection, run-id headers), updates CLAUDE.md with adapter integration notes,
and records lessons from ערר 1200-25 (block order for 1xxx, "להלן מתוך" pattern,
expanded factual background, bridge planning analysis, flat heading structure).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 05:58:52 +00:00
parent d81c3c37ab
commit 45341a0bc8
16 changed files with 1380 additions and 7 deletions

View File

@@ -0,0 +1,99 @@
/**
* DeepSeek (via Hermes) — external Paperclip adapter.
*
* Loaded by Paperclip's plugin-loader. Contract:
* The package's main module must export createServerAdapter() returning
* a single ServerAdapterModule object with all fields wired in.
*
* Runtime: spawns the local `hermes` CLI with HERMES_HOME pinned to a
* DeepSeek profile that defines model.base_url=https://api.deepseek.com/v1
* and model.key_env=DEEPSEEK_API_KEY.
*/
import {
ADAPTER_TYPE,
ADAPTER_LABEL,
DEEPSEEK_MODELS,
DEFAULT_PROFILE_HOME,
} from "./shared/constants.js";
import { execute } from "./server/execute.js";
import { testEnvironment } from "./server/test.js";
import { sessionCodec } from "./server/session-codec.js";
import { listSkills, syncSkills } from "./server/skills.js";
const AGENT_CONFIGURATION_DOC = `# DeepSeek (via Hermes) — Agent Configuration
DeepSeek-pinned variant of the Hermes adapter. Runs the local \`hermes\` CLI
with \`HERMES_HOME\` pointed at a DeepSeek profile (\`config.yaml\` declares
\`base_url=https://api.deepseek.com/v1\` and \`key_env=DEEPSEEK_API_KEY\`).
## Prerequisites
- Hermes Agent installed (\`pip install hermes-agent\`) — \`hermes --version\` works.
- DeepSeek profile dir exists (default: \`/home/chaim/.hermes/profiles/deepseek\`)
with \`config.yaml\` + \`.env\` (containing \`DEEPSEEK_API_KEY\`).
## Core Configuration
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| model | string | \`deepseek-v4-pro\` | DeepSeek model id (\`deepseek-v4-pro\` or \`deepseek-v4-flash\`). |
| provider | string | \`custom\` | Hermes provider name. The DeepSeek profile defines \`provider: custom\` so \`custom\` is the right value. |
| hermesProfileHome | string | \`/home/chaim/.hermes/profiles/deepseek\` | Absolute path to a Hermes profile dir. Set per-agent if you maintain multiple DeepSeek profiles. |
| timeoutSec | number | 1800 | Execution timeout in seconds. |
| graceSec | number | 30 | SIGTERM grace period in seconds. |
## Tools / Workspace
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| toolsets | string | (profile default) | Comma-separated toolsets to enable. |
| persistSession | boolean | true | Resume sessions across heartbeats via \`--resume\`. |
| 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 the hermes binary. |
| verbose | boolean | false | Enable verbose Hermes logs. |
| extraArgs | string[] | [] | Extra CLI args appended after standard flags. |
| env | object | {} | Extra environment variables passed to Hermes. \`HERMES_HOME\` here overrides \`hermesProfileHome\`. |
| promptTemplate | string | (default) | Override the default Paperclip wakeup prompt. |
| paperclipApiUrl | string | \`http://127.0.0.1:3100/api\` | Paperclip API URL injected into the prompt template. |
## Available template variables
\`{{agentId}}\`, \`{{agentName}}\`, \`{{companyId}}\`, \`{{companyName}}\`,
\`{{runId}}\`, \`{{taskId}}\`, \`{{taskTitle}}\`, \`{{taskBody}}\`,
\`{{commentId}}\`, \`{{wakeReason}}\`, \`{{projectName}}\`, \`{{paperclipApiUrl}}\`.
`;
export function createServerAdapter() {
return {
type: ADAPTER_TYPE,
label: ADAPTER_LABEL,
models: DEEPSEEK_MODELS,
agentConfigurationDoc: AGENT_CONFIGURATION_DOC,
execute,
testEnvironment,
sessionCodec,
listSkills,
syncSkills,
// Capability flags
supportsLocalAgentJwt: true,
supportsInstructionsBundle: false,
requiresMaterializedRuntimeSkills: false,
};
}
// Also export the loose constants for any caller that wants to inspect
// the package without invoking createServerAdapter (e.g., test harnesses).
export const type = ADAPTER_TYPE;
export const label = ADAPTER_LABEL;
export const models = DEEPSEEK_MODELS;
export const agentConfigurationDoc = AGENT_CONFIGURATION_DOC;
export const defaultProfileHome = DEFAULT_PROFILE_HOME;

View File

@@ -0,0 +1,352 @@
/**
* Server-side execution for the DeepSeek-via-Hermes adapter.
*
* Spawns `hermes chat -q "..." -Q -m <model> --provider custom` with
* HERMES_HOME pinned to a DeepSeek-configured profile so the same machine
* can run other Hermes-based agents on different providers in parallel.
*
* The Hermes CLI loads model.base_url, model.key_env (DEEPSEEK_API_KEY),
* and toolsets from <HERMES_HOME>/config.yaml + <HERMES_HOME>/.env.
*/
import {
runChildProcess,
buildPaperclipEnv,
renderTemplate,
ensureAbsoluteDirectory,
} from "@paperclipai/adapter-utils/server-utils";
import {
HERMES_CLI,
DEFAULT_PROFILE_HOME,
DEFAULT_MODEL,
DEFAULT_PROVIDER,
DEFAULT_TIMEOUT_SEC,
DEFAULT_GRACE_SEC,
SESSION_ID_REGEX,
SESSION_ID_REGEX_LEGACY,
TOKEN_USAGE_REGEX,
COST_REGEX,
} from "../shared/constants.js";
function cfgString(v) {
return typeof v === "string" && v.length > 0 ? v : undefined;
}
function cfgNumber(v) {
return typeof v === "number" ? v : undefined;
}
function cfgBoolean(v) {
return typeof v === "boolean" ? v : undefined;
}
function cfgStringArray(v) {
return Array.isArray(v) && v.every((i) => typeof i === "string") ? v : undefined;
}
const DEFAULT_PROMPT_TEMPLATE = `You are "{{agentName}}", an AI agent employee in a Paperclip-managed company powered by DeepSeek.
IMPORTANT: Use the \`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
1. Work on the task using your tools.
2. When done, mark the issue completed:
\`curl -s -X PATCH "{{paperclipApiUrl}}/issues/{{taskId}}" -H "Content-Type: application/json" -d '{"status":"done"}'\`
3. Post a completion comment summarizing what you did:
\`curl -s -X POST "{{paperclipApiUrl}}/issues/{{taskId}}/comments" -H "Content-Type: application/json" -d '{"body":"DONE: <your summary here>"}'\`
{{/taskId}}
{{#commentId}}
## Comment on This Issue
Someone commented. Read it:
\`curl -s "{{paperclipApiUrl}}/issues/{{taskId}}/comments/{{commentId}}" | python3 -m json.tool\`
Address the comment, POST a reply if needed, then continue working.
{{/commentId}}
{{#noTask}}
## Heartbeat Wake — Check for Work
1. List your open issues:
\`curl -s "{{paperclipApiUrl}}/companies/{{companyId}}/issues?assigneeAgentId={{agentId}}"\`
2. Pick the highest priority and work on it. When done, follow steps 2-3 above.
3. If nothing to do, report briefly what you checked.
{{/noTask}}`;
function buildPrompt(ctx, config) {
const template = cfgString(config.promptTemplate) || DEFAULT_PROMPT_TEMPLATE;
const taskId = cfgString(ctx.context?.taskId);
const taskTitle = cfgString(ctx.context?.taskTitle) || "";
const taskBody = cfgString(ctx.context?.taskBody) || "";
const commentId = cfgString(ctx.context?.commentId) || "";
const wakeReason = cfgString(ctx.context?.wakeReason) || "";
const agentName = ctx.agent?.name || "DeepSeek Agent";
const companyName = cfgString(ctx.context?.companyName) || "";
const projectName = cfgString(ctx.context?.projectName) || "";
let paperclipApiUrl =
cfgString(config.paperclipApiUrl) ||
process.env.PAPERCLIP_API_URL ||
"http://127.0.0.1:3100/api";
if (!paperclipApiUrl.endsWith("/api")) {
paperclipApiUrl = paperclipApiUrl.replace(/\/+$/, "") + "/api";
}
const vars = {
agentId: ctx.agent?.id || "",
agentName,
companyId: ctx.agent?.companyId || "",
companyName,
runId: ctx.runId || "",
taskId: taskId || "",
taskTitle,
taskBody,
commentId,
wakeReason,
projectName,
paperclipApiUrl,
};
let rendered = template;
rendered = rendered.replace(/\{\{#taskId\}\}([\s\S]*?)\{\{\/taskId\}\}/g, taskId ? "$1" : "");
rendered = rendered.replace(/\{\{#noTask\}\}([\s\S]*?)\{\{\/noTask\}\}/g, taskId ? "" : "$1");
rendered = rendered.replace(/\{\{#commentId\}\}([\s\S]*?)\{\{\/commentId\}\}/g, commentId ? "$1" : "");
return renderTemplate(rendered, vars);
}
function cleanResponse(raw) {
return raw
.split("\n")
.filter((line) => {
const t = line.trim();
if (!t) return true;
if (t.startsWith("[tool]") || t.startsWith("[hermes]") || t.startsWith("[paperclip]") || t.startsWith("[deepseek]")) 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();
}
function parseHermesOutput(stdout, stderr) {
const combined = stdout + "\n" + stderr;
const result = {};
const sessionMatch = stdout.match(SESSION_ID_REGEX);
if (sessionMatch?.[1]) {
result.sessionId = sessionMatch[1];
const sessionLineIdx = stdout.lastIndexOf("\nsession_id:");
if (sessionLineIdx > 0) {
result.response = cleanResponse(stdout.slice(0, sessionLineIdx));
}
} else {
const legacyMatch = combined.match(SESSION_ID_REGEX_LEGACY);
if (legacyMatch?.[1]) result.sessionId = legacyMatch[1];
const cleaned = cleanResponse(stdout);
if (cleaned.length > 0) result.response = cleaned;
}
const usageMatch = combined.match(TOKEN_USAGE_REGEX);
if (usageMatch) {
result.usage = {
inputTokens: parseInt(usageMatch[1], 10) || 0,
outputTokens: parseInt(usageMatch[2], 10) || 0,
};
}
const costMatch = combined.match(COST_REGEX);
if (costMatch?.[1]) result.costUsd = parseFloat(costMatch[1]);
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));
if (errorLines.length > 0) result.errorMessage = errorLines.slice(0, 5).join("\n");
}
return result;
}
export async function execute(ctx) {
const config = ctx.agent?.adapterConfig ?? {};
const hermesCmd = cfgString(config.hermesCommand) || HERMES_CLI;
const model = cfgString(config.model) || DEFAULT_MODEL;
const provider = cfgString(config.provider) || DEFAULT_PROVIDER;
const profileHome = cfgString(config.hermesProfileHome) || DEFAULT_PROFILE_HOME;
const timeoutSec = cfgNumber(config.timeoutSec) || DEFAULT_TIMEOUT_SEC;
const graceSec = cfgNumber(config.graceSec) || DEFAULT_GRACE_SEC;
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;
const useQuiet = cfgBoolean(config.quiet) !== false;
const prompt = buildPrompt(ctx, config);
const args = ["chat", "-q", prompt];
if (useQuiet) args.push("-Q");
if (model) args.push("-m", model);
args.push("--provider", provider);
if (toolsets) args.push("-t", toolsets);
if (worktreeMode) args.push("-w");
if (checkpoints) args.push("--checkpoints");
if (cfgBoolean(config.verbose) === true) args.push("-v");
args.push("--source", "tool");
args.push("--yolo");
const prevSessionId = cfgString(ctx.runtime?.sessionParams?.sessionId);
if (persistSession && prevSessionId) args.push("--resume", prevSessionId);
if (extraArgs?.length) args.push(...extraArgs);
// Pin Hermes to the DeepSeek profile by default. The agent can override
// by setting adapter_config.hermesProfileHome or adapter_config.env.HERMES_HOME.
const env = {
...process.env,
...buildPaperclipEnv(ctx.agent),
HERMES_HOME: profileHome,
};
if (ctx.runId) env.PAPERCLIP_RUN_ID = ctx.runId;
const taskId = cfgString(ctx.context?.taskId);
if (taskId) env.PAPERCLIP_TASK_ID = taskId;
// Parity with hermes_local (paperclip-src/server/src/adapters/registry.ts:267):
// inject the per-run agent auth token so the agent can call the Paperclip API.
// Without this, every Paperclip API write from the running agent fails with 401.
//
// Resolve env from the runtime-resolved config (ctx.config.env contains plain
// strings — Paperclip's secrets service unwraps {type:"plain"|"secret_ref", ...}
// bindings before invocation in services/heartbeat.ts:5433-5437).
// Fall back to agent.adapterConfig.env with manual unwrapping for older paths.
function unwrapEnvValue(v) {
if (typeof v === "string") return v;
if (v && typeof v === "object" && !Array.isArray(v)) {
if (v.type === "plain" && typeof v.value === "string") return v.value;
}
return undefined; // skip secret_ref / unknown types — let resolver handle them
}
const resolvedUserEnv =
ctx.config && typeof ctx.config === "object" && ctx.config.env && typeof ctx.config.env === "object" && !Array.isArray(ctx.config.env)
? ctx.config.env
: null;
const rawUserEnv =
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
? config.env
: {};
// Prefer pre-resolved values from ctx.config.env when available; fall back to
// unwrapping raw bindings from agent.adapterConfig.env.
const flattenedUserEnv = {};
for (const [k, v] of Object.entries(rawUserEnv)) {
const resolved = resolvedUserEnv && typeof resolvedUserEnv[k] === "string" ? resolvedUserEnv[k] : unwrapEnvValue(v);
if (typeof resolved === "string") flattenedUserEnv[k] = resolved;
}
const userEnvApiKey = flattenedUserEnv.PAPERCLIP_API_KEY;
const explicitApiKey =
typeof userEnvApiKey === "string" && userEnvApiKey.trim().length > 0;
if (ctx.authToken && !explicitApiKey) env.PAPERCLIP_API_KEY = ctx.authToken;
// Apply unwrapped user env (may override HERMES_HOME, OPENAI_API_KEY, etc.).
Object.assign(env, flattenedUserEnv);
const cwd = cfgString(config.cwd) || cfgString(ctx.config?.workspaceDir) || ".";
try {
await ensureAbsoluteDirectory(cwd);
} catch {
// non-fatal
}
await ctx.onLog(
"stdout",
`[deepseek] Starting Hermes (model=${model}, provider=${provider}, profileHome=${env.HERMES_HOME}, timeout=${timeoutSec}s)\n`,
);
if (prevSessionId) {
await ctx.onLog("stdout", `[deepseek] Resuming session: ${prevSessionId}\n`);
}
// Reclassify benign Hermes stderr lines as stdout so the UI doesn't paint them red.
const wrappedOnLog = async (stream, chunk) => {
if (stream === "stderr") {
const trimmed = chunk.trimEnd();
const isBenign =
/^\[?\d{4}[-/]\d{2}[-/]\d{2}T/.test(trimmed) ||
/^[A-Z]+:\s+(INFO|DEBUG|WARN|WARNING)\b/.test(trimmed) ||
/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);
};
// Forward ctx.onSpawn so Paperclip persists processPid/processGroupId to the
// heartbeat_runs row. Without it, the reaper cannot verify the child is alive
// (run.processPid is null) and treats the run as orphaned during long quiet
// phases (DeepSeek V4-Pro thinking can be silent for 60-90s per turn).
const result = await runChildProcess(ctx.runId, hermesCmd, args, {
cwd,
env,
timeoutSec,
graceSec,
onLog: wrappedOnLog,
onSpawn: ctx.onSpawn,
});
const parsed = parseHermesOutput(result.stdout || "", result.stderr || "");
await ctx.onLog(
"stdout",
`[deepseek] Exit code: ${result.exitCode ?? "null"}, timed out: ${result.timedOut}\n`,
);
if (parsed.sessionId) {
await ctx.onLog("stdout", `[deepseek] Session: ${parsed.sessionId}\n`);
}
const executionResult = {
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
provider,
model,
};
if (parsed.errorMessage) executionResult.errorMessage = parsed.errorMessage;
if (parsed.usage) executionResult.usage = parsed.usage;
if (parsed.costUsd !== undefined) executionResult.costUsd = parsed.costUsd;
if (parsed.response) executionResult.summary = parsed.response.slice(0, 2000);
executionResult.resultJson = {
result: parsed.response || "",
session_id: parsed.sessionId || null,
usage: parsed.usage || null,
cost_usd: parsed.costUsd ?? null,
};
if (persistSession && parsed.sessionId) {
executionResult.sessionParams = { sessionId: parsed.sessionId };
executionResult.sessionDisplayId = parsed.sessionId.slice(0, 16);
}
return executionResult;
}

View File

@@ -0,0 +1,29 @@
/**
* Session codec — Hermes uses a single sessionId for cross-heartbeat continuity
* via the --resume CLI flag. Same shape as the Hermes adapter.
*/
function readNonEmptyString(value) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export const sessionCodec = {
deserialize(raw) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const sessionId =
readNonEmptyString(raw.sessionId) ?? readNonEmptyString(raw.session_id);
if (!sessionId) return null;
return { sessionId };
},
serialize(params) {
if (!params) return null;
const sessionId =
readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
if (!sessionId) return null;
return { sessionId };
},
getDisplayId(params) {
if (!params) return null;
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
},
};

View File

@@ -0,0 +1,171 @@
/**
* Skill snapshot for the DeepSeek-via-Hermes adapter.
*
* Hermes manages its own skills under ~/.hermes/skills/ (global; not per-profile).
* Paperclip-managed skills declared in adapter config are surfaced as
* "company_managed" entries — same behavior as the upstream Hermes adapter.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
import { ADAPTER_TYPE } from "../shared/constants.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
function asString(value) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function parseSkillFrontmatter(content) {
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!match) return {};
const fm = {};
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 = line.slice(idx + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
fm[key] = val;
}
return fm;
}
async function buildSkillEntry(key, skillMdPath, categoryPath) {
let description = null;
try {
const content = await fs.readFile(skillMdPath, "utf8");
description = parseSkillFrontmatter(content).description ?? null;
} catch {
// ignore
}
return {
key,
runtimeName: key,
desired: true,
managed: false,
state: "installed",
origin: "user_installed",
originLabel: "Hermes skill",
locationLabel: `~/.hermes/skills/${categoryPath}`,
readOnly: true,
sourcePath: skillMdPath,
targetPath: null,
detail: description,
};
}
async function scanHermesSkills(skillsHome) {
const entries = [];
try {
const cats = await fs.readdir(skillsHome, { withFileTypes: true });
for (const cat of cats) {
if (!cat.isDirectory()) continue;
const catPath = path.join(skillsHome, cat.name);
const topSkill = path.join(catPath, "SKILL.md");
if (await fs.stat(topSkill).catch(() => null)) {
entries.push(await buildSkillEntry(cat.name, topSkill, cat.name));
}
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)) {
entries.push(await buildSkillEntry(item.name, skillMd, `${cat.name}/${item.name}`));
}
}
}
} catch {
// ~/.hermes/skills/ doesn't exist
}
return entries.sort((a, b) => a.key.localeCompare(b.key));
}
async function buildSnapshot(config) {
const homedir =
asString(config.env?.HOME) ??
process.env.HOME ??
"/home/chaim";
const hermesSkillsHome = path.join(homedir, ".hermes", "skills");
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]));
const hermesSkillEntries = await scanHermesSkills(hermesSkillsHome);
const hermesKeys = new Set(hermesSkillEntries.map((e) => e.key));
const entries = [];
const warnings = [];
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: entry.required ? "paperclip_required" : "company_managed",
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
readOnly: false,
sourcePath: entry.source,
targetPath: null,
detail: desired ? "Will be available on the next run via Hermes skill loading." : null,
required: Boolean(entry.required),
requiredReason: entry.requiredReason ?? null,
});
}
for (const entry of hermesSkillEntries) {
if (availableByKey.has(entry.key)) continue;
entries.push(entry);
}
for (const desired of desiredSkills) {
if (availableByKey.has(desired) || hermesKeys.has(desired)) continue;
warnings.push(`Desired skill "${desired}" is not available in Paperclip or Hermes skills.`);
entries.push({
key: desired,
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: ADAPTER_TYPE,
supported: true,
mode: "persistent",
desiredSkills,
entries,
warnings,
};
}
export async function listSkills(ctx) {
return buildSnapshot(ctx.config);
}
export async function syncSkills(ctx, _desired) {
return buildSnapshot(ctx.config);
}
export function resolveDesiredSkillNames(config, availableEntries) {
return resolvePaperclipDesiredSkillNames(config, availableEntries);
}

View File

@@ -0,0 +1,164 @@
/**
* Environment test for the DeepSeek (via Hermes) adapter.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs/promises";
import path from "node:path";
import {
HERMES_CLI,
ADAPTER_TYPE,
DEFAULT_PROFILE_HOME,
} from "../shared/constants.js";
const execFileAsync = promisify(execFile);
function asString(v) {
return typeof v === "string" ? v : undefined;
}
async function checkCliInstalled(command) {
try {
await execFileAsync(command, ["--version"], { timeout: 10_000 });
return null;
} catch (err) {
if (err && err.code === "ENOENT") {
return {
level: "error",
message: `Hermes CLI "${command}" not found in PATH`,
hint: "Install Hermes Agent: pip install hermes-agent",
code: "deepseek_hermes_cli_not_found",
};
}
return null;
}
}
async function checkProfile(profileHome) {
try {
const stat = await fs.stat(profileHome);
if (!stat.isDirectory()) {
return {
level: "error",
message: `Profile path is not a directory: ${profileHome}`,
hint: "Create the directory or override hermesProfileHome in adapter config.",
code: "deepseek_profile_not_dir",
};
}
} catch {
return {
level: "error",
message: `Hermes profile dir does not exist: ${profileHome}`,
hint: "Create the profile dir with config.yaml + .env (DEEPSEEK_API_KEY).",
code: "deepseek_profile_missing",
};
}
const configPath = path.join(profileHome, "config.yaml");
try {
await fs.stat(configPath);
} catch {
return {
level: "error",
message: `Profile is missing config.yaml: ${configPath}`,
hint: "Add config.yaml with model.default + model.base_url + model.key_env.",
code: "deepseek_profile_no_config",
};
}
return {
level: "info",
message: `Profile resolved: ${profileHome}`,
code: "deepseek_profile_ok",
};
}
async function checkApiKey(profileHome, configEnv) {
// 1. config.env (resolved by Paperclip from secrets)
if (configEnv && typeof configEnv === "object" && asString(configEnv.DEEPSEEK_API_KEY)) {
return {
level: "info",
message: "DEEPSEEK_API_KEY found in adapter env config",
code: "deepseek_api_key_in_config",
};
}
// 2. Profile-local .env
try {
const envFile = path.join(profileHome, ".env");
const text = await fs.readFile(envFile, "utf-8");
if (/^\s*DEEPSEEK_API_KEY=/m.test(text)) {
return {
level: "info",
message: `DEEPSEEK_API_KEY found in ${envFile}`,
code: "deepseek_api_key_in_profile",
};
}
} catch {
// ignore
}
// 3. Process env
if (process.env.DEEPSEEK_API_KEY) {
return {
level: "info",
message: "DEEPSEEK_API_KEY found in Paperclip process env",
code: "deepseek_api_key_in_process",
};
}
return {
level: "error",
message: "DEEPSEEK_API_KEY not found in adapter env, profile .env, or process env",
hint: "Add DEEPSEEK_API_KEY to <HERMES_HOME>/.env or to the agent's env secrets.",
code: "deepseek_api_key_missing",
};
}
export async function testEnvironment(ctx) {
const config = ctx.config ?? {};
const command = asString(config.hermesCommand) || HERMES_CLI;
const profileHome = asString(config.hermesProfileHome) || DEFAULT_PROFILE_HOME;
const checks = [];
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(),
};
}
}
const profileCheck = await checkProfile(profileHome);
checks.push(profileCheck);
if (profileCheck.level === "error") {
return {
adapterType: ADAPTER_TYPE,
status: "fail",
checks,
testedAt: new Date().toISOString(),
};
}
const apiKeyCheck = await checkApiKey(profileHome, config.env);
checks.push(apiKeyCheck);
const model = asString(config.model);
checks.push({
level: "info",
message: model ? `Model: ${model}` : "Using profile default model",
code: "deepseek_model",
});
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(),
};
}

View File

@@ -0,0 +1,36 @@
/**
* Shared constants for the DeepSeek (via Hermes) Paperclip adapter.
*/
export const ADAPTER_TYPE = "deepseek_local";
export const ADAPTER_LABEL = "DeepSeek (via Hermes)";
/** Default Hermes CLI binary name. */
export const HERMES_CLI = "hermes";
/** Default profile directory used as HERMES_HOME if the agent does not override it. */
export const DEFAULT_PROFILE_HOME = "/home/chaim/.hermes/profiles/deepseek";
/** Default model — V4-Pro is the strongest DeepSeek model currently exposed. */
export const DEFAULT_MODEL = "deepseek-v4-pro";
/** DeepSeek profiles in this stack use Hermes' "custom" provider (user-defined in profile config.yaml). */
export const DEFAULT_PROVIDER = "custom";
/** Default timeout (seconds) for one CLI invocation. */
export const DEFAULT_TIMEOUT_SEC = 1800;
/** Grace period (seconds) after SIGTERM before SIGKILL. */
export const DEFAULT_GRACE_SEC = 30;
/** Models that DeepSeek's API currently exposes (verified via /v1/models). */
export const DEEPSEEK_MODELS = [
{ id: "deepseek-v4-pro", label: "DeepSeek V4 Pro" },
{ id: "deepseek-v4-flash", label: "DeepSeek V4 Flash" },
];
/** Regex for extracting session_id from quiet-mode Hermes output. */
export const SESSION_ID_REGEX = /^session_id:\s*(\S+)/m;
export const SESSION_ID_REGEX_LEGACY = /session[_ ](?:id|saved)[:\s]+([a-zA-Z0-9_-]+)/i;
export const TOKEN_USAGE_REGEX = /tokens?[:\s]+(\d+)\s*(?:input|in)\b.*?(\d+)\s*(?:output|out)\b/i;
export const COST_REGEX = /(?:cost|spent)[:\s]*\$?([\d.]+)/i;

View File

@@ -0,0 +1,25 @@
{
"name": "deepseek-paperclip-adapter",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "deepseek-paperclip-adapter",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@paperclipai/adapter-utils": "^2026.325.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@paperclipai/adapter-utils": {
"version": "2026.428.0",
"resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-2026.428.0.tgz",
"integrity": "sha512-kGHpE7rhePPCbnG3OwXbNuHZZuI+XyuFgNSiDnrEeiSbkI2c5XHM2WnWDCZ/NGHULfJW3lWhSxGMFoYqiy38vQ==",
"license": "MIT"
}
}
}

View File

@@ -0,0 +1,21 @@
{
"name": "deepseek-paperclip-adapter",
"version": "0.1.0",
"description": "Paperclip adapter for DeepSeek (V4-Pro / V4-Flash) — runs Hermes Agent locally pinned to a DeepSeek profile",
"type": "module",
"license": "MIT",
"private": true,
"main": "./dist/index.js",
"exports": {
".": "./dist/index.js"
},
"files": [
"dist"
],
"dependencies": {
"@paperclipai/adapter-utils": "^2026.325.0"
},
"engines": {
"node": ">=20.0.0"
}
}