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>
353 lines
13 KiB
JavaScript
353 lines
13 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|