/** * Paperclip `createServerAdapter()` — the official Paperclip external * adapter contract (introduced in `@paperclipai/adapter-utils`). * * The Paperclip "Install External Adapter" UI runs: * * const mod = await import(pkgName); * const adapter = mod.createServerAdapter(); * adapterRegistry[adapter.type] = adapter; * * So this function MUST be the main entry export, and it MUST return * an object that satisfies the `ServerAdapterModule` interface. * * Pure-HTTP adapters (like this one) handle the tool loop in * TypeScript instead of shelling out to a CLI. We: * 1. Translate Paperclip's `AdapterExecutionContext` into our * `execute()` function's argument shape. * 2. Translate our `AdapterResult` back into the * `AdapterExecutionResult` Paperclip persists. */ import type { AdapterEnvironmentCheck, AdapterEnvironmentTestContext, AdapterEnvironmentTestResult, AdapterExecutionContext, AdapterExecutionResult, AdapterModel, AdapterSkillContext, AdapterSkillEntry, AdapterSkillSnapshot, ServerAdapterModule, HireApprovedPayload, HireApprovedHookResult, } from "@paperclipai/adapter-utils"; import { loadConfig, type ResolvedConfig } from "./config.js"; import { execute as runLoop } from "./execute.js"; import { loadSkills } from "./skills.js"; import { newSession, sessionCodec } from "./session.js"; import { listTools, callTool } from "./tools.js"; import { MiniMaxClient } from "./client.js"; import { computeCost, aggregateUsage, aggregateCost } from "./cost.js"; import { adapterMetadata } from "./adapter.js"; import { agentConfigurationDoc } from "./agent-doc.js"; import { models as modelCatalog } from "./models.js"; import type { AgentContext, ChatMessage, IssueRef, RunContext, TranscriptEvent } from "./types.js"; const isoNow = () => new Date().toISOString(); /** Coerce an unknown config value to a trimmed string, with a fallback. */ function asString(v: unknown, fallback = ""): string { if (typeof v === "string") return v.trim(); return fallback; } function asNumber(v: unknown, fallback: number): number { const n = Number(v); return Number.isFinite(n) ? n : fallback; } function asBool(v: unknown, fallback: boolean): boolean { if (typeof v === "boolean") return v; if (typeof v === "string") return v === "1" || v.toLowerCase() === "true"; return fallback; } /** * Build a resolved config from the user-set agent.adapterConfig plus * the standard Paperclip env vars. */ function buildConfig(ctx: AdapterExecutionContext): ResolvedConfig { const cfg = ctx.config ?? {}; const overrides: Record = { apiKey: asString(cfg.apiKey) || process.env.MINIMAX_API_KEY, baseUrl: asString(cfg.baseUrl) || process.env.MINIMAX_BASE_URL || "https://api.minimax.chat/v1", model: asString(cfg.model) || process.env.MINIMAX_MODEL || "MiniMax-M3", priceInputPer1M: asNumber(cfg.priceInputPer1M, asNumber(process.env.MINIMAX_PRICE_INPUT_PER_1M, 1)), priceOutputPer1M: asNumber(cfg.priceOutputPer1M, asNumber(process.env.MINIMAX_PRICE_OUTPUT_PER_1M, 3)), paperclipApiKey: asString(cfg.paperclipApiKey) || ctx.authToken || process.env.PAPERCLIP_API_KEY || "", paperclipApiUrl: asString(cfg.paperclipApiUrl) || process.env.PAPERCLIP_API_URL || "http://localhost:3100", maxToolCallsPerTurn: asNumber(cfg.maxToolCallsPerTurn, 8), maxTurns: asNumber(cfg.maxTurns, 8), enableStreaming: asBool(cfg.enableStreaming, true), skillsDir: asString(cfg.skillsDir) || process.env.MINIMAX_SKILLS_DIR || "", approvalGated: asBool(cfg.approvalGated, true), workspacePath: asString(cfg.workspacePath) || process.env.MINIMAX_WORKSPACE_PATH || "", }; return loadConfig(overrides as any); } /** * Pull the issue ref out of the Paperclip wake payload that lives in * `ctx.context`. The exact shape comes from * `normalizePaperclipWakePayload` in `@paperclipai/server-utils`; we * don't depend on that helper, just read the fields defensively. */ function readIssueFromContext(context: Record): IssueRef { const issue = (context.issue as Record | null) ?? null; if (!issue) { // Fall back to a generic ref. return { id: asString(context.issueId) || asString(context.runId) || "unknown", }; } return { id: asString(issue.id) || asString(context.issueId) || "unknown", identifier: asString(issue.identifier) || undefined, title: asString(issue.title) || undefined, status: asString(issue.status) || undefined, }; } function readWakeReason(context: Record): string { return asString(context.reason) || asString(context.wakeReason) || "manual"; } /** * Translate the in-memory execute() output into the Paperclip-shaped * AdapterExecutionResult. The mapping: * status 'succeeded' → exitCode 0 * status 'failed' → exitCode 1 + errorMessage * usage → UsageSummary * costUsd → costUsd * sessionState → sessionParams (Record) + sessionDisplayId * transcript → streamed via ctx.onLog */ function toExecutionResult( out: Awaited>, ctx: AdapterExecutionContext, ): AdapterExecutionResult { const usage = out.usage ?? { inputTokens: 0, outputTokens: 0 }; const succeeded = out.status === "succeeded"; const sessionParams = out.sessionState ? sessionCodec.deserialize(out.sessionState) : null; const header = sessionParams?.header as { sessionId?: string } | undefined; const displayId = header?.sessionId?.slice(0, 8) ?? null; return { exitCode: succeeded ? 0 : 1, signal: null, timedOut: false, errorMessage: out.errorMessage ?? null, errorCode: succeeded ? null : "adapter_failed", errorFamily: succeeded ? null : "transient_upstream", usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, cachedInputTokens: usage.cachedInputTokens, }, sessionParams, sessionDisplayId: displayId, provider: "minimax", biller: "minimax", model: out.model, billingType: "api", costUsd: out.costUsd, summary: (out.result ?? "").slice(0, 4000) || "completed", resultJson: { transcript: out.transcript }, }; } /** Stream the transcript events to the live Working panel. */ async function streamTranscript( events: TranscriptEvent[] | undefined, onLog: AdapterExecutionContext["onLog"], ): Promise { if (!events) return; for (const e of events) { const line = formatEvent(e); if (line) await onLog("stdout", line + "\n"); } } function formatEvent(e: TranscriptEvent): string { const ts = (e as { ts?: string }).ts ?? isoNow(); switch (e.kind) { case "init": return `[${ts}] init model=${(e as { model: string }).model}`; case "user": return `[${ts}] > ${((e as { content: string }).content ?? "").slice(0, 2000)}`; case "assistant": return `[${ts}] < ${((e as { content?: string }).content ?? "").slice(0, 2000)}`; case "tool_call": { const x = e as { name: string; input?: unknown }; return `[${ts}] • ${x.name} ${JSON.stringify(x.input ?? {}).slice(0, 500)}`; } case "tool_result": { const x = e as { output: unknown; isError: boolean }; return `[${ts}] ${x.isError ? "✗" : "✓"} ${JSON.stringify(x.output).slice(0, 1000)}`; } case "warning": return `[${ts}] ⚠ ${(e as { message: string }).message}`; case "result": { const x = e as { subtype: string; costUsd: number; inputTokens: number; outputTokens: number }; return `[${ts}] done status=${x.subtype} in=${x.inputTokens} out=${x.outputTokens} cost=$${x.costUsd.toFixed(4)}`; } default: return `[${ts}] ${JSON.stringify(e).slice(0, 500)}`; } } async function executeHeartbeat(ctx: AdapterExecutionContext): Promise { const config = buildConfig(ctx); const issue = readIssueFromContext(ctx.context ?? {}); const wakeReason = readWakeReason(ctx.context ?? {}); // Resume the session if Paperclip passed one back. const previousParams = ctx.runtime?.sessionParams ?? null; const resume = previousParams ? sessionCodec.deserialize(previousParams) : null; const resumeSession = resume ? ({ header: resume.header as any, messages: resume.messages as ChatMessage[], } as any) : undefined; const agent: AgentContext = { id: ctx.agent.id, name: ctx.agent.name, role: asString(ctx.agent.adapterType) || "agent", workspacePath: config.workspacePath, }; const run: RunContext = { id: ctx.runId, agentId: ctx.agent.id, issueId: issue.id, companyId: ctx.agent.companyId, wakeReason, }; const out = await runLoop({ config, agent, run, issue, resumeSessionState: resumeSession, toolFetch: undefined, client: new MiniMaxClient(config), }); await streamTranscript(out.transcript, ctx.onLog); return toExecutionResult(out, ctx); } async function testEnvironment(ctx: AdapterEnvironmentTestContext): Promise { const checks: AdapterEnvironmentCheck[] = []; const config = loadConfig({ apiKey: asString(ctx.config.apiKey) || process.env.MINIMAX_API_KEY, baseUrl: asString(ctx.config.baseUrl) || process.env.MINIMAX_BASE_URL, model: asString(ctx.config.model) || process.env.MINIMAX_MODEL, paperclipApiKey: asString(ctx.config.paperclipApiKey) || process.env.PAPERCLIP_API_KEY, paperclipApiUrl: asString(ctx.config.paperclipApiUrl) || process.env.PAPERCLIP_API_URL, } as any); if (!config.apiKey) { checks.push({ code: "missing_api_key", level: "error", message: "MINIMAX_API_KEY not set", hint: "Set MINIMAX_API_KEY in the agent's adapterConfig or in the Paperclip host's environment.", }); } else { checks.push({ code: "api_key_present", level: "info", message: "MINIMAX_API_KEY is set" }); } try { const client = new MiniMaxClient(config); const r = await client.validate(); if (r.ok) { checks.push({ code: "minimax_connect", level: "info", message: r.message, detail: r.models.length ? `${r.models.length} models visible` : null, }); } else { checks.push({ code: "minimax_connect", level: "error", message: `minimax unreachable: ${r.message}`, hint: "Check the API key, base URL, and outbound network access from the Paperclip host.", }); } } catch (e) { checks.push({ code: "minimax_connect", level: "error", message: `minimax unreachable: ${e instanceof Error ? e.message : String(e)}`, }); } const status: AdapterEnvironmentTestResult["status"] = checks.some((c) => c.level === "error") ? "fail" : checks.some((c) => c.level === "warn") ? "warn" : "pass"; return { adapterType: "minimax", status, checks, testedAt: isoNow(), }; } async function listSkillsSnapshot(ctx: AdapterSkillContext): Promise { const config = loadConfig({} as any); const skills = loadSkills(config.workspacePath, config.skillsDir); const entries: AdapterSkillEntry[] = skills.map((s) => ({ key: s.name, runtimeName: s.name, desired: true, managed: false, state: "available" as const, origin: "external_unknown" as const, locationLabel: s.path, readOnly: true, sourcePath: s.path, })); return { adapterType: ctx.adapterType, supported: true, mode: "persistent", desiredSkills: entries.map((e) => e.key), entries, warnings: [], }; } async function syncSkills(ctx: AdapterSkillContext, desired: string[]): Promise { // Pure-HTTP adapter doesn't own skill files; they're committed to the // workspace by the human. We just report current state. void ctx; void desired; return listSkillsSnapshot(ctx); } async function onHireApproved( payload: HireApprovedPayload, adapterConfig: Record, ): Promise { // We don't have a side-effect to perform; just acknowledge. void payload; void adapterConfig; return { ok: true }; } /** * The Paperclip external-adapter entry point. Returns a module that * the Paperclip plugin loader can register as `adapterRegistry.minimax`. */ export function createServerAdapter(): ServerAdapterModule { return { type: "minimax", execute: executeHeartbeat, testEnvironment, listSkills: listSkillsSnapshot, syncSkills, sessionCodec, supportsLocalAgentJwt: true, models: modelCatalog, listModels: async () => modelCatalog, agentConfigurationDoc, onHireApproved, }; }