diff --git a/Dockerfile b/Dockerfile index 109305a323..4e3a271529 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,7 @@ COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/ COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/ COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/ COPY packages/adapters/grok-local/package.json packages/adapters/grok-local/ +COPY packages/adapters/kimi-local/package.json packages/adapters/kimi-local/ COPY packages/adapters/hermes/package.json packages/adapters/hermes/ COPY packages/adapters/hermes-gateway/package.json packages/adapters/hermes-gateway/ COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/ @@ -86,7 +87,7 @@ WORKDIR /app # (the single most expensive layer: four CLI toolchains + apt, per arch) can # never hit the layer cache and rebuilds on every build. RUN echo "cli-tools-epoch: ${CLI_TOOLS_CACHE_EPOCH}" \ - && npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai @google/gemini-cli@latest \ + && npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai @google/gemini-cli@latest @moonshot-ai/kimi-code@latest \ && apt-get update \ && apt-get install -y --no-install-recommends openssh-client jq \ && rm -rf /var/lib/apt/lists/* \ diff --git a/cli/package.json b/cli/package.json index 6d5cc10a21..9be7fa2c04 100644 --- a/cli/package.json +++ b/cli/package.json @@ -43,6 +43,7 @@ "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", "@paperclipai/adapter-grok-local": "workspace:*", + "@paperclipai/adapter-kimi-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index f30e32eafb..7dfa12db95 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -5,6 +5,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 { printKimiStreamEvent } from "@paperclipai/adapter-kimi-local/cli"; import { formatStdoutEvent as printHermesGatewayStreamEvent } from "@paperclipai/hermes-paperclip-adapter/gateway/cli"; import { printHermesStreamEvent } from "@paperclipai/hermes-paperclip-adapter/cli"; import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; @@ -53,6 +54,11 @@ const grokLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printGrokStreamEvent, }; +const kimiLocalCLIAdapter: CLIAdapterModule = { + type: "kimi_local", + formatStdoutEvent: printKimiStreamEvent, +}; + const hermesGatewayCLIAdapter: CLIAdapterModule = { type: "hermes_gateway", formatStdoutEvent: printHermesGatewayStreamEvent, @@ -78,6 +84,7 @@ const adaptersByType = new Map( cursorCloudCLIAdapter, geminiLocalCLIAdapter, grokLocalCLIAdapter, + kimiLocalCLIAdapter, hermesGatewayCLIAdapter, hermesLocalCLIAdapter, openclawGatewayCLIAdapter, diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts index 6d6677e424..3f81da6c6b 100644 --- a/cli/src/commands/client/agent.ts +++ b/cli/src/commands/client/agent.ts @@ -93,7 +93,7 @@ interface CreatedAgentKey { } interface SkillsInstallSummary { - tool: "codex" | "claude"; + tool: "codex" | "claude" | "kimi"; target: string; linked: string[]; removed: string[]; @@ -115,10 +115,16 @@ function claudeSkillsHome(): string { return path.join(base, "skills"); } +function kimiSkillsHome(): string { + const fromEnv = process.env.KIMI_CODE_HOME?.trim(); + const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".kimi-code"); + return path.join(base, "skills"); +} + async function installSkillsForTarget( sourceSkillsDir: string, targetSkillsDir: string, - tool: "codex" | "claude", + tool: "codex" | "claude" | "kimi", ): Promise { const summary: SkillsInstallSummary = { tool, @@ -771,7 +777,7 @@ export function registerAgentCommands(program: Command): void { .option("--key-name ", "API key label", "local-cli") .option( "--no-install-skills", - "Skip installing Paperclip skills into ~/.codex/skills and ~/.claude/skills", + "Skip installing Paperclip skills into ~/.codex/skills, ~/.claude/skills, and ~/.kimi-code/skills", ) .action(async (agentRef: string, opts: AgentLocalCliOptions) => { try { @@ -803,6 +809,7 @@ export function registerAgentCommands(program: Command): void { installSummaries.push( await installSkillsForTarget(skillsDir, codexSkillsHome(), "codex"), await installSkillsForTarget(skillsDir, claudeSkillsHome(), "claude"), + await installSkillsForTarget(skillsDir, kimiSkillsHome(), "kimi"), ); } diff --git a/docs/adapters/kimi-local.md b/docs/adapters/kimi-local.md new file mode 100644 index 0000000000..78cf7cba81 --- /dev/null +++ b/docs/adapters/kimi-local.md @@ -0,0 +1,84 @@ +--- +title: Kimi Code CLI +summary: Kimi Code CLI local adapter setup and configuration +--- + +The `kimi_local` adapter runs the Kimi Code CLI (`kimi`) locally. It has two execution engines: the default **ACP engine** (`kimi acp`, streaming transcript with live tool status, matching `claude_local`/`gemini_local`) and a **CLI lane** (`kimi -p --output-format stream-json`) used as an automatic fallback. It supports session persistence, per-run skill delivery via `--skills-dir`, thinking-effort control, and structured output parsing. + +## Prerequisites + +- Kimi Code CLI installed (`kimi` command available; npm package `@moonshot-ai/kimi-code`) +- Authentication configured via one of: + - `kimi login` (OAuth device flow; credentials stored under `$KIMI_CODE_HOME`, default `~/.kimi-code/`) + - A provider configured in Kimi's `config.toml` (`[providers.]`) + - The `KIMI_MODEL_NAME` + `KIMI_MODEL_API_KEY` environment pair (optionally `KIMI_MODEL_BASE_URL`, `KIMI_MODEL_PROVIDER_TYPE`), set in the adapter env or server shell + +## Configuration Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `engine` | string | No | Execution engine: `acp` (default; streaming ACP lane via `kimi acp`), `cli` (headless `kimi -p` lane), or unset/`auto` (ACP with automatic CLI fallback when ACP prerequisites fail). | +| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) | +| `model` | string | No | Kimi model alias (`provider/model`). Defaults to `kimi-code/kimi-for-coding`. When empty, Kimi uses `default_model` from its own `config.toml`. | +| `promptTemplate` | string | No | Prompt used for all runs | +| `instructionsFilePath` | string | No | Markdown instructions file prepended to the prompt. Sibling files in the same directory (`HEARTBEAT.md`, `SOUL.md`, `TOOLS.md`) are made readable via `--add-dir` on local runs. | +| `effort` | string | No | Thinking effort (`low` \| `medium` \| `high` \| `max`). **CLI lane only**: forwarded as `KIMI_MODEL_THINKING_EFFORT` for effort-capable models (currently `kimi-code/k3`); `medium` maps to `high` since Kimi has no medium tier. Ignored for models without `support_efforts`, and not forwarded on the default ACP engine lane — pin `engine: cli` when effort control matters. | +| `command` | string | No | CLI command override. Defaults to `kimi`. | +| `extraArgs` | string[] | No | Additional CLI arguments appended to every run | +| `env` | object | No | Environment variables (supports secret refs) | +| `timeoutSec` | number | No | Process timeout (0 = no timeout) | +| `graceSec` | number | No | Grace period before force-kill | + +## Execution Engine + +By default the adapter runs Kimi through the **ACP engine** (`kimi acp`, an Agent Client Protocol server over stdio), the same shared engine used by `claude_local`, `codex_local`, and `gemini_local`. ACP streams the transcript live: assistant text arrives as deltas and tool calls report a `pending`/`completed` status, so the issue thread updates continuously instead of in bursts. + +Engine selection (`engine` config field): + +- unset or `auto`: use ACP when its prerequisites pass (Node >= 20, resolvable `kimi acp` command, a bidirectional process target), otherwise fall back to the CLI lane with a diagnostic note. +- `acp`: require ACP; startup failures surface as run errors rather than falling back. +- `cli`: pin the headless CLI lane described below. + +The ACP lane reuses the shared acpx session codec, transcript parser, and CLI event formatter, so sessions, transcripts, and logs render identically to the other ACP adapters. + +## Headless Execution (CLI lane) + +Runs execute as `kimi -p --output-format stream-json` (plus `-m ` when configured and `-r ` when resuming). On local runs the adapter also passes `--add-dir ` so the agent can read sibling instruction files, and `--skills-dir ` when skills are desired (see below). The prompt is passed as an argument, not stdin. The adapter sets a headless-safe environment (`CI=1`, `NO_COLOR=1`, `KIMI_CODE_NO_AUTO_UPDATE=1`, and `TERM=dumb` when unset) so unattended heartbeats never block on interactive prompts, theme detection, or update preflight; user-configured env values always win. + +## Instructions Bundle + +When `instructionsFilePath` points at a managed instruction bundle, the entry file (e.g. `AGENTS.md`) is prepended to the prompt along with a directive that names its sibling files (`HEARTBEAT.md`, `SOUL.md`, `TOOLS.md`). On local runs the containing directory is exposed to Kimi via `--add-dir`, so the agent can actually open those companion files instead of only seeing the entry file. + +## Thinking Effort + +The `effort` field applies to the **headless CLI lane only** (`engine: cli`, or the automatic fallback when ACP prerequisites fail). On the default ACP engine lane it is currently **not forwarded**: Kimi's ACP interface exposes a separate `thinking` config option that Paperclip does not wire yet, so an effort configured on an ACP-lane agent leaves Kimi's own default behavior in place. Pin `engine: cli` when thinking-effort control matters. On the CLI lane, `effort` is forwarded as the `KIMI_MODEL_THINKING_EFFORT` operational override, which applies to Kimi providers including managed OAuth models. Kimi has no per-invocation effort flag and no `medium` tier, so `medium` is mapped to `high`; `low`, `high`, and `max` pass through. Effort is only sent for models that advertise `support_efforts` (currently `kimi-code/k3`) to avoid provider rejections; extend `EFFORT_CAPABLE_MODELS` in the adapter as more models gain support. + +## Session Persistence + +The adapter captures the Kimi session id from the trailing `session.resume_hint` meta event and persists it between heartbeats. On the next wake, it resumes the existing conversation with `-r ` so the agent retains context. + +Session resume is cwd-aware: if the working directory changed since the last run, a fresh session starts instead. + +If resume fails with an unknown/unrecoverable session error, the adapter automatically retries with a fresh session. + +## Skills Delivery + +Desired Paperclip skills are delivered from a dedicated per-run directory passed via `--skills-dir`, so skills load reliably and in isolation without writing into the shared `~/.kimi-code/skills` home. On remote runs the skills snapshot is synced to the target and `--skills-dir` points at that isolated copy — Paperclip never overwrites `$KIMI_CODE_HOME/skills`, so skills installed by the operator or other agents are left intact. `--skills-dir` is only passed when at least one skill is desired, so unconfigured agents keep Kimi's default skill discovery. + +### Control-plane skill + +`paperclipai agent local-cli -C ` installs the Paperclip control-plane skills into `~/.kimi-code/skills` (honoring `KIMI_CODE_HOME`), alongside the existing `~/.codex/skills` and `~/.claude/skills` targets. Kimi auto-discovers this home on every run, so the agent has the control-plane API reference (issue/comment/interaction routes) from turn one rather than rediscovering endpoints by trial and error. Pass `--no-install-skills` to skip. This is independent of the per-run `--skills-dir` delivery above, which only applies when an agent has explicitly configured skills. + +## Environment Test + +Use the "Test Environment" button in the UI to validate the adapter config. It checks: + +- Kimi CLI is installed and accessible (`kimi --version`) +- Working directory is absolute and available (auto-created if missing and permitted) +- Auth availability (OAuth credential/config files under `$KIMI_CODE_HOME`, or the `KIMI_MODEL_NAME` + `KIMI_MODEL_API_KEY` env pair) +- A live hello probe (`kimi -p "Respond with hello." --output-format stream-json`) to verify CLI readiness + +## Notes + +- Both execution engines are supported: the ACP engine (`kimi acp`, default) and the headless CLI lane (fallback / `engine=cli`). +- Available model aliases on a standard install: `kimi-code/kimi-for-coding` (K2.7 Coding), `kimi-code/kimi-for-coding-highspeed` (K2.7 Coding Highspeed), `kimi-code/k3` (K3). diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index 6fdf1723f3..55b0dcc525 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -21,6 +21,7 @@ When a heartbeat fires, Paperclip: | [Claude Code](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally, with a native ACP engine when available | | [Codex](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally, with a native ACP engine when available | | [Gemini CLI](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental — adapter package exists, not yet in stable type enum) | +| [Kimi Code CLI](/adapters/kimi-local) | `kimi_local` | Runs Kimi Code CLI locally through ACP, with headless `-p` mode as a fallback | | OpenCode | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | | Cursor | `cursor` | Runs Cursor in background mode | | Pi | `pi_local` | Runs an embedded Pi agent locally | diff --git a/packages/adapter-utils/src/acpx-engine/constants.ts b/packages/adapter-utils/src/acpx-engine/constants.ts index 86368f5f97..11f3aa2249 100644 --- a/packages/adapter-utils/src/acpx-engine/constants.ts +++ b/packages/adapter-utils/src/acpx-engine/constants.ts @@ -9,6 +9,7 @@ export const ACPX_ADAPTER_AGENT_IDS = { claude_local: "claude", codex_local: "codex", gemini_local: "gemini", + kimi_local: "kimi", custom_acp: "custom", } as const; diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index cf12a37efb..fc3aa6ffd5 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -642,6 +642,11 @@ async function resolveBuiltInAgentCommand(input: { if (agent === "gemini") { return { command: "gemini --acp", shellCommand: "gemini --acp" }; } + if (agent === "kimi") { + // Kimi Code exposes its ACP server via the `kimi acp` subcommand (stdio), + // rather than a flag (gemini) or a dedicated bin (claude/codex). + return { command: "kimi acp", shellCommand: "kimi acp" }; + } const binName = agent === "claude" ? "claude-agent-acp" : agent === "codex" ? "codex-acp" : null; if (!binName) return null; if (executionTargetIsRemote) { diff --git a/packages/adapter-utils/src/session-compaction.ts b/packages/adapter-utils/src/session-compaction.ts index 1e7dd6ac47..3764155c40 100644 --- a/packages/adapter-utils/src/session-compaction.ts +++ b/packages/adapter-utils/src/session-compaction.ts @@ -43,6 +43,7 @@ export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([ "cursor", "gemini_local", "hermes_local", + "kimi_local", "opencode_local", "pi_local", ]); @@ -73,6 +74,11 @@ export const ADAPTER_SESSION_MANAGEMENT: Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function stringifyUnknown(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +export function printKimiStreamEvent(raw: string, _debug: boolean): void { + const line = raw.trim(); + if (!line) return; + + let parsed: Record | null = null; + try { + parsed = JSON.parse(line) as Record; + } catch { + console.log(line); + return; + } + + if (asString(parsed.type).startsWith("acpx.")) { + printAcpxStreamEvent(line, _debug); + return; + } + + const role = asString(parsed.role).trim().toLowerCase(); + + if (role === "assistant") { + const content = asString(parsed.content).trim(); + if (content) console.log(pc.green(`assistant: ${content}`)); + const toolCalls = Array.isArray(parsed.tool_calls) ? parsed.tool_calls : []; + for (const callRaw of toolCalls) { + const call = asRecord(callRaw); + if (!call) continue; + const fn = asRecord(call.function); + const name = asString(fn?.name, asString(call.name, "tool")).trim() || "tool"; + console.log(pc.yellow(`tool_call: ${name}`)); + const argsRaw = fn?.arguments ?? call.arguments; + if (argsRaw === undefined) continue; + if (typeof argsRaw === "string") { + try { + console.log(pc.gray(stringifyUnknown(JSON.parse(argsRaw)))); + } catch { + console.log(pc.gray(argsRaw)); + } + } else { + console.log(pc.gray(stringifyUnknown(argsRaw))); + } + } + return; + } + + if (role === "tool") { + console.log(pc.cyan("tool_result")); + const content = asString(parsed.content) || stringifyUnknown(parsed.content); + if (content) console.log(pc.gray(content)); + return; + } + + if (role === "meta") { + const type = asString(parsed.type).trim(); + if (type === "session.resume_hint") { + const sessionId = asString(parsed.session_id).trim(); + if (sessionId) console.log(pc.blue(`Kimi session: ${sessionId}`)); + } + return; + } + + if (role === "error" || asString(parsed.type).trim().toLowerCase() === "error") { + const text = + asString(parsed.content) || + asString(parsed.message) || + asString(parsed.error) || + "Kimi error"; + console.log(pc.red(`error: ${text}`)); + return; + } + + console.log(line); +} diff --git a/packages/adapters/kimi-local/src/cli/index.ts b/packages/adapters/kimi-local/src/cli/index.ts new file mode 100644 index 0000000000..d3b8f24b6f --- /dev/null +++ b/packages/adapters/kimi-local/src/cli/index.ts @@ -0,0 +1 @@ +export { printKimiStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/kimi-local/src/index.ts b/packages/adapters/kimi-local/src/index.ts new file mode 100644 index 0000000000..a27aded28a --- /dev/null +++ b/packages/adapters/kimi-local/src/index.ts @@ -0,0 +1,84 @@ +import { buildSandboxNpmInstallCommand } from "@paperclipai/adapter-utils"; + +export const type = "kimi_local"; +export const label = "Kimi Code CLI (local)"; + +export const SANDBOX_INSTALL_COMMAND = buildSandboxNpmInstallCommand("@moonshot-ai/kimi-code"); + +export const DEFAULT_KIMI_LOCAL_MODEL = "kimi-code/kimi-for-coding"; + +export const models = [ + { id: DEFAULT_KIMI_LOCAL_MODEL, label: "K2.7 Coding" }, + { id: "kimi-code/kimi-for-coding-highspeed", label: "K2.7 Coding Highspeed" }, + { id: "kimi-code/k3", label: "K3" }, +]; + +/** + * Kimi thinking-effort tiers. Kimi's catalog exposes these via each model's + * `support_efforts`; note there is no "medium" tier (Kimi collapses it onto + * "high"). Sending an effort a model does not support makes the provider + * reject the request, so effort is only forwarded for effort-capable models. + */ +export const KIMI_SUPPORTED_EFFORTS = ["low", "high", "max"] as const; +export type KimiEffort = (typeof KIMI_SUPPORTED_EFFORTS)[number]; + +/** + * Models that advertise `support_efforts` in Kimi's model catalog. Keep in + * sync with `models` above; only these accept KIMI_MODEL_THINKING_EFFORT. + */ +export const EFFORT_CAPABLE_MODELS = new Set(["kimi-code/k3"]); + +export function modelSupportsEffort(model: string): boolean { + return EFFORT_CAPABLE_MODELS.has(model.trim()); +} + +/** + * Map a Paperclip effort value onto Kimi's supported thinking-effort set. + * Returns null for values Kimi cannot honor so the caller leaves Kimi's own + * default_effort in place instead of forwarding an invalid tier. + */ +export function resolveKimiThinkingEffort(effort: string): KimiEffort | null { + const normalized = effort.trim().toLowerCase(); + if (!normalized) return null; + if (normalized === "medium") return "high"; + return (KIMI_SUPPORTED_EFFORTS as readonly string[]).includes(normalized) + ? (normalized as KimiEffort) + : null; +} + +export const agentConfigurationDoc = `# kimi_local agent configuration + +Adapter: kimi_local + +Use when: +- You want Paperclip to run the Kimi Code CLI (kimi) locally on the host machine +- You want Kimi sessions resumed across heartbeats with -r +- You want Paperclip skills injected into the Kimi skills home without polluting the agent workspace + +Don't use when: +- You need webhook-style external invocation (use http or openclaw_gateway) +- You only need a one-shot script without an AI coding agent loop (use process) +- Kimi Code CLI is not installed on the machine that runs Paperclip + +Core fields: +- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) +- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt. Sibling files in the same directory (HEARTBEAT.md, SOUL.md, TOOLS.md) are made readable via --add-dir for local runs. +- promptTemplate (string, optional): run prompt template +- model (string, optional): Kimi model alias (provider/model). Defaults to kimi-code/kimi-for-coding. +- effort (string, optional): thinking effort (low | medium | high | max). CLI lane only (engine=cli or the automatic fallback): forwarded as KIMI_MODEL_THINKING_EFFORT for effort-capable models (currently kimi-code/k3); "medium" maps to "high" since Kimi has no medium tier. Ignored for models without support_efforts, and NOT forwarded on the default ACP engine lane (Kimi ACP exposes a separate "thinking" option that is not wired yet) — pin engine=cli when effort control matters. +- command (string, optional): defaults to "kimi" +- extraArgs (string[], optional): additional CLI args +- env (object, optional): KEY=VALUE environment variables + +Operational fields: +- timeoutSec (number, optional): run timeout in seconds +- graceSec (number, optional): SIGTERM grace period in seconds + +Notes: +- The adapter defaults to the ACP engine (\`kimi acp\`) and falls back to the headless CLI lane when ACP prerequisites are unavailable. Set \`engine\` to \`acp\` or \`cli\` to require a specific lane. +- CLI-lane runs use \`kimi -p\` with \`--output-format stream-json\` for non-interactive headless execution; the prompt is passed as an argument, not stdin. +- The adapter sets a headless-safe environment (CI=1, NO_COLOR=1, KIMI_CODE_NO_AUTO_UPDATE=1) so unattended runs never wait on interactive prompts or update preflight. +- Sessions resume with \`-r \` when the stored session cwd matches the current cwd; the session id is captured from the trailing session.resume_hint meta event. +- Desired Paperclip skills are delivered to local runs via \`--skills-dir\` pointing at a per-run managed directory, so skills load reliably without polluting the user's \`~/.kimi-code/skills\` home. Remote runs sync skills into the remote skills home. +- Authentication uses \`kimi login\` (OAuth device flow), providers configured in Kimi's config.toml, or the KIMI_MODEL_NAME + KIMI_MODEL_API_KEY environment pair. +`; diff --git a/packages/adapters/kimi-local/src/server/acp.test.ts b/packages/adapters/kimi-local/src/server/acp.test.ts new file mode 100644 index 0000000000..b88c66cd13 --- /dev/null +++ b/packages/adapters/kimi-local/src/server/acp.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + buildKimiAcpConfig, + nodeVersionMeetsKimiAcpMinimum, + resolveKimiExecutionEngine, +} from "./acp.js"; + +describe("resolveKimiExecutionEngine", () => { + it("defaults to ACP (non-explicit) when engine is unset", () => { + expect(resolveKimiExecutionEngine({})).toEqual({ engine: "acp", explicit: false }); + }); + + it("honors an explicit engine=acp", () => { + expect(resolveKimiExecutionEngine({ engine: "acp" })).toEqual({ engine: "acp", explicit: true }); + }); + + it("honors an explicit engine=cli", () => { + expect(resolveKimiExecutionEngine({ engine: "CLI" })).toEqual({ engine: "cli", explicit: true }); + }); + + it("treats unknown values as the non-explicit ACP default", () => { + expect(resolveKimiExecutionEngine({ engine: "nonsense" })).toEqual({ engine: "acp", explicit: false }); + }); +}); + +describe("buildKimiAcpConfig", () => { + it("targets the kimi agent and derives the `kimi acp` server command from `command`", () => { + const out = buildKimiAcpConfig({ command: "kimi", cwd: "/work" }); + expect(out.agent).toBe("kimi"); + expect(out.agentCommand).toBe("kimi acp"); + expect(out.mode).toBe("persistent"); + expect(out.cwd).toBe("/work"); + }); + + it("prefers an explicit agentCommand override", () => { + const out = buildKimiAcpConfig({ command: "kimi", agentCommand: "/opt/kimi acp --foo" }); + expect(out.agentCommand).toBe("/opt/kimi acp --foo"); + }); + + it("drops the model when it equals the default so ACP uses the agent default", () => { + const out = buildKimiAcpConfig({ model: "kimi-code/kimi-for-coding" }); + expect("model" in out).toBe(false); + }); + + it("keeps a non-default model", () => { + const out = buildKimiAcpConfig({ model: "kimi-code/k3" }); + expect(out.model).toBe("kimi-code/k3"); + }); + + it("strips CLI-lane effort so ACP is not sent the unsupported `effort` control", () => { + const out = buildKimiAcpConfig({ model: "kimi-code/k3", effort: "high", thinkingEffort: "high" }); + expect("effort" in out).toBe(false); + expect("thinkingEffort" in out).toBe(false); + }); + + it("opts into the shared engine's verbose-backend handling", () => { + const out = buildKimiAcpConfig({ command: "kimi" }); + expect(out.summaryStrategy).toBe("lastOutputSegment"); + expect(out.coalescePlaceholderToolUpdates).toBe(true); + }); +}); + +describe("nodeVersionMeetsKimiAcpMinimum", () => { + it("accepts Node >= 20", () => { + expect(nodeVersionMeetsKimiAcpMinimum("v22.0.0")).toBe(true); + expect(nodeVersionMeetsKimiAcpMinimum("v20.0.0")).toBe(true); + }); + it("rejects Node < 20", () => { + expect(nodeVersionMeetsKimiAcpMinimum("v18.19.0")).toBe(false); + }); +}); diff --git a/packages/adapters/kimi-local/src/server/acp.ts b/packages/adapters/kimi-local/src/server/acp.ts new file mode 100644 index 0000000000..e6a8b8b523 --- /dev/null +++ b/packages/adapters/kimi-local/src/server/acp.ts @@ -0,0 +1,392 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, + AdapterExecutionContext, + AdapterExecutionResult, +} from "@paperclipai/adapter-utils"; +import { + ensureAdapterExecutionTargetCommandResolvable, + readAdapterExecutionTarget, + resolveAdapterExecutionTargetCwd, +} from "@paperclipai/adapter-utils/execution-target"; +import { + DEFAULT_ACP_ENGINE_MODE, + DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS, + DEFAULT_ACP_ENGINE_PERMISSION_MODE, + DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS, +} from "@paperclipai/adapter-utils/acpx-engine/constants"; +import type { AcpxEngineExecutorOptions } from "@paperclipai/adapter-utils/acpx-engine/execute"; +import { + asNumber, + asString, + parseObject, +} from "@paperclipai/adapter-utils/server-utils"; +import { DEFAULT_KIMI_LOCAL_MODEL } from "../index.js"; + +const moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const packageRootDir = path.resolve(moduleDir, "../.."); +const MIN_ACP_NODE_VERSION = "20.0.0"; + +export type KimiExecutionEngine = "cli" | "acp"; + +export interface KimiEngineSelection { + engine: KimiExecutionEngine; + explicit: boolean; + fallbackReason?: string; +} + +type KimiEngineResolutionInput = + Pick & + Partial>; + +type KimiAcpExecutorOptions = Omit< + AcpxEngineExecutorOptions, + "adapterType" | "moduleDir" | "packageRootDir" +>; + +type KimiAcpExecutor = (ctx: AdapterExecutionContext) => Promise; + +function normalizeEngine(value: unknown): KimiEngineSelection { + const raw = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (raw === "acp") return { engine: "acp", explicit: true }; + if (raw === "cli") return { engine: "cli", explicit: true }; + return { engine: "acp", explicit: false }; +} + +export function resolveKimiExecutionEngine(config: Record): KimiEngineSelection { + return normalizeEngine(config.engine); +} + +export async function resolveKimiExecutionEngineForRun( + input: KimiEngineResolutionInput, +): Promise { + const selection = normalizeEngine(input.config.engine); + if (selection.explicit || selection.engine !== "acp") return selection; + + const fallbackReason = await defaultKimiAcpFallbackReason(input); + if (!fallbackReason) return selection; + return { engine: "cli", explicit: false, fallbackReason }; +} + +export function formatKimiAcpFallbackMessage(reason: string): string { + return `[paperclip] Kimi ACP default unavailable; falling back to Kimi CLI. ${reason} Set engine=acp to require ACP or engine=cli to silence this fallback.\n`; +} + +function firstNonEmptyString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value !== "string") continue; + const trimmed = value.trim(); + if (trimmed.length > 0) return trimmed; + } + return undefined; +} + +export function buildKimiAcpConfig(config: Record): Record { + const configuredAgentCommand = firstNonEmptyString(config.agentCommand, config.acpAgentCommand); + const configuredKimiCommand = firstNonEmptyString(config.command); + const agentCommand = configuredAgentCommand ?? (configuredKimiCommand ? `${configuredKimiCommand} acp` : undefined); + const stateDir = firstNonEmptyString(config.stateDir, config.acpStateDir); + const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE; + const permissionMode = + firstNonEmptyString(config.permissionMode, config.acpPermissionMode) ?? + DEFAULT_ACP_ENGINE_PERMISSION_MODE; + const nonInteractivePermissions = + firstNonEmptyString(config.nonInteractivePermissions, config.acpNonInteractivePermissions) ?? + DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS; + const warmHandleIdleMs = + config.warmHandleIdleMs ?? + config.acpWarmHandleIdleMs ?? + DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS; + + const next: Record = { + ...config, + agent: "kimi", + mode, + permissionMode, + nonInteractivePermissions, + warmHandleIdleMs, + ...(agentCommand ? { agentCommand } : {}), + ...(stateDir ? { stateDir } : {}), + }; + const model = asString(next.model, "").trim(); + if (!model || model === DEFAULT_KIMI_LOCAL_MODEL) delete next.model; + // Kimi's ACP backend advertises a `thinking` config option, not `effort`. + // The shared acpx engine only knows the `effort` control key, which Kimi + // rejects with ACP_BACKEND_UNSUPPORTED_CONTROL and fails the session. Drop + // the CLI-lane effort fields so the ACP session is not configured with an + // unsupported control; thinking-effort control remains available on the CLI + // lane via KIMI_MODEL_THINKING_EFFORT. + delete next.effort; + delete next.thinkingEffort; + // Kimi streams ~16k text deltas and one tool_call update per argument token + // per run (~50x a comparable Claude run). Opt into the shared engine's + // verbose-backend handling: the auto-posted run summary becomes the last + // output segment instead of the full narration dump, and placeholder-titled + // in-progress tool updates are coalesced out of the run log. + next.summaryStrategy = "lastOutputSegment"; + next.coalescePlaceholderToolUpdates = true; + return next; +} + +function withKimiAcpDefaults(options: KimiAcpExecutorOptions): AcpxEngineExecutorOptions { + return { + ...options, + adapterType: "kimi_local", + moduleDir, + packageRootDir, + }; +} + +export function createKimiAcpExecutor(options: KimiAcpExecutorOptions = {}): KimiAcpExecutor { + let executor: KimiAcpExecutor | null = null; + return async (ctx) => { + let currentExecutor = executor; + if (!currentExecutor) { + const { createAcpxEngineExecutor } = await import("@paperclipai/adapter-utils/acpx-engine/execute"); + currentExecutor = createAcpxEngineExecutor(withKimiAcpDefaults(options)); + executor = currentExecutor; + } + return currentExecutor({ + ...ctx, + config: buildKimiAcpConfig(ctx.config), + }); + }; +} + +function parseVersion(version: string): [number, number, number] { + const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)/); + if (!match) return [0, 0, 0]; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +export function nodeVersionMeetsKimiAcpMinimum(version = process.version): boolean { + const [major, minor, patch] = parseVersion(version); + const [minMajor, minMinor, minPatch] = parseVersion(MIN_ACP_NODE_VERSION); + if (major !== minMajor) return major > minMajor; + if (minor !== minMinor) return minor > minMinor; + return patch >= minPatch; +} + +async function pathExists(candidate: string): Promise { + return fs.access(candidate).then(() => true).catch(() => false); +} + +function hasPathSeparator(command: string): boolean { + return command.includes("/") || command.includes("\\"); +} + +function firstShellToken(command: string): string | null { + const trimmed = command.trim(); + if (!trimmed) return null; + if (trimmed.startsWith("'") || trimmed.startsWith("\"")) return null; + return trimmed.split(/\s+/, 1)[0] ?? null; +} + +async function findCommandOnPath(binName: string, pathValue = process.env.PATH ?? ""): Promise { + for (const segment of pathValue.split(path.delimiter)) { + if (!segment) continue; + const candidate = path.join(segment, binName); + if (await pathExists(candidate)) return candidate; + } + return null; +} + +function resolveConfigPath(config: Record): string { + const envConfig = parseObject(config.env); + return typeof envConfig.PATH === "string" && envConfig.PATH.trim().length > 0 + ? envConfig.PATH + : process.env.PATH ?? ""; +} + +async function commandIsResolvable( + command: string, + pathValue = process.env.PATH ?? "", + input?: KimiEngineResolutionInput, +): Promise { + const token = firstShellToken(command); + if (!token) return true; + const target = readAdapterExecutionTarget({ + executionTarget: input?.executionTarget, + legacyRemoteExecution: input?.executionTransport?.remoteExecution, + }); + if (target?.kind === "remote") { + try { + await ensureAdapterExecutionTargetCommandResolvable( + token, + target, + resolveAdapterExecutionTargetCwd(target, asString(input?.config.cwd, ""), process.cwd()), + process.env, + ); + return true; + } catch { + return false; + } + } + if (path.isAbsolute(token) || hasPathSeparator(token)) return pathExists(token); + return (await findCommandOnPath(token, pathValue)) !== null; +} + +function resolveKimiAcpCommand(config: Record): string { + const configured = firstNonEmptyString(config.agentCommand, config.acpAgentCommand); + if (configured) return configured; + const kimiCommand = firstNonEmptyString(config.command) ?? "kimi"; + return `${kimiCommand} acp`; +} + +function sandboxTargetHasProcessSessionBridge( + target: ReturnType, +): boolean { + return target?.kind === "remote" && target.transport === "sandbox" && Boolean(target.runner); +} + +async function defaultKimiAcpFallbackReason( + input: KimiEngineResolutionInput, +): Promise { + const target = readAdapterExecutionTarget({ + executionTarget: input.executionTarget, + legacyRemoteExecution: input.executionTransport?.remoteExecution, + }); + if (target?.kind === "remote" && !sandboxTargetHasProcessSessionBridge(target)) { + if (target.transport === "sandbox") { + return "Kimi ACP requires a bidirectional remote process target; this sandbox exposes only one-shot command execution."; + } + return "Kimi ACP supports sandbox remote targets only; this run targets a non-sandbox remote environment."; + } + if (!nodeVersionMeetsKimiAcpMinimum()) { + return `Node ${process.version} does not satisfy Kimi ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`; + } + const command = resolveKimiAcpCommand(input.config); + if (!(await commandIsResolvable(command, resolveConfigPath(input.config), input))) { + return `Kimi ACP command is not available: ${command}.`; + } + return null; +} + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function isNonEmpty(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +export async function testKimiAcpEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const target = ctx.executionTarget ?? null; + const targetIsRemote = target?.kind === "remote"; + + checks.push({ + code: "kimi_engine_selected", + level: "info", + message: "Execution engine selected: ACP.", + hint: "Set engine=cli to use the existing Kimi CLI lane.", + }); + + if (targetIsRemote) { + checks.push({ + code: "kimi_acp_remote_target", + level: "info", + message: "Kimi ACP will run against the remote execution environment.", + hint: "Remote ACP requires a bidirectional process target such as SSH or Paperclip's sandbox process-session bridge.", + }); + } + + const cwd = asString(config.cwd, process.cwd()); + try { + await fs.mkdir(cwd, { recursive: true }); + checks.push({ + code: "kimi_acp_cwd_valid", + level: "info", + message: `Working directory is valid: ${cwd}`, + }); + } catch (err) { + checks.push({ + code: "kimi_acp_cwd_invalid", + level: "error", + message: err instanceof Error ? err.message : "Invalid working directory", + detail: cwd, + }); + } + + checks.push({ + code: nodeVersionMeetsKimiAcpMinimum() ? "kimi_acp_node_supported" : "kimi_acp_node_unsupported", + level: nodeVersionMeetsKimiAcpMinimum() ? "info" : "error", + message: nodeVersionMeetsKimiAcpMinimum() + ? `Node ${process.version} satisfies ACP runtime requirements.` + : `Node ${process.version} does not satisfy ACP runtime requirements.`, + hint: nodeVersionMeetsKimiAcpMinimum() + ? undefined + : `Run Kimi ACP with Node >=${MIN_ACP_NODE_VERSION} or switch engine=cli.`, + }); + + const command = resolveKimiAcpCommand(config); + const commandResolvable = await commandIsResolvable(command, resolveConfigPath(config), { + config, + executionTarget: ctx.executionTarget, + }); + checks.push({ + code: commandResolvable ? "kimi_acp_command_resolvable" : "kimi_acp_command_missing", + level: commandResolvable ? "info" : "error", + message: commandResolvable + ? `Kimi ACP command is executable: ${command}` + : `Kimi ACP command is not available: ${command}`, + hint: commandResolvable + ? undefined + : "Install the Kimi Code CLI with ACP support, or set agentCommand to a valid Kimi ACP server command.", + }); + + const envConfig = parseObject(config.env); + const considerHostEnv = !targetIsRemote; + const configModelName = envConfig.KIMI_MODEL_NAME; + const hostModelName = considerHostEnv ? process.env.KIMI_MODEL_NAME : undefined; + const configModelKey = envConfig.KIMI_MODEL_API_KEY; + const hostModelKey = considerHostEnv ? process.env.KIMI_MODEL_API_KEY : undefined; + if ( + (isNonEmpty(configModelName) && isNonEmpty(configModelKey)) || + (isNonEmpty(hostModelName) && isNonEmpty(hostModelKey)) + ) { + const source = + isNonEmpty(configModelName) && isNonEmpty(configModelKey) ? "adapter config env" : "server environment"; + checks.push({ + code: "kimi_acp_credentials_detected", + level: "info", + message: "Kimi credentials are set for ACP authentication.", + detail: `KIMI_MODEL_NAME + KIMI_MODEL_API_KEY detected in ${source}.`, + }); + } else if (!targetIsRemote) { + checks.push({ + code: "kimi_acp_credentials_not_detected", + level: "warn", + message: "No Kimi ACP credentials were detected.", + hint: "Run `kimi login` (OAuth) or set the KIMI_MODEL_NAME + KIMI_MODEL_API_KEY environment pair before starting a Kimi ACP agent.", + }); + } + + const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE; + const warmHandleIdleMs = asNumber( + config.warmHandleIdleMs ?? config.acpWarmHandleIdleMs, + DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS, + ); + checks.push({ + code: "kimi_acp_runtime_scaffold", + level: "info", + message: "Kimi ACP runtime execution is available through the shared ACP engine.", + detail: `mode=${mode}; warmHandleIdleMs=${warmHandleIdleMs}`, + }); + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/kimi-local/src/server/execute.test.ts b/packages/adapters/kimi-local/src/server/execute.test.ts new file mode 100644 index 0000000000..dafbc212b5 --- /dev/null +++ b/packages/adapters/kimi-local/src/server/execute.test.ts @@ -0,0 +1,412 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; + +const ensureRuntimeInstalledMock = vi.hoisted(() => vi.fn(async () => {})); +const ensureCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const prepareRuntimeMock = vi.hoisted(() => vi.fn(async () => ({ + workspaceRemoteDir: null, + restoreWorkspace: async () => {}, +}))); +const resolveCommandForLogsMock = vi.hoisted(() => vi.fn(async () => "kimi")); +const runProcessMock = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ + adapterExecutionTargetIsRemote: () => false, + adapterExecutionTargetRemoteCwd: (_target: unknown, cwd: string) => cwd, + overrideAdapterExecutionTargetRemoteCwd: (target: unknown, _cwd: string) => target, + adapterExecutionTargetSessionIdentity: () => ({ kind: "local" }), + adapterExecutionTargetSessionMatches: () => true, + adapterExecutionTargetUsesManagedHome: () => false, + adapterExecutionTargetUsesPaperclipBridge: () => false, + describeAdapterExecutionTarget: () => "local", + ensureAdapterExecutionTargetCommandResolvable: ensureCommandMock, + ensureAdapterExecutionTargetRuntimeCommandInstalled: ensureRuntimeInstalledMock, + prepareAdapterExecutionTargetRuntime: prepareRuntimeMock, + readAdapterExecutionTarget: ({ executionTarget }: { executionTarget?: unknown }) => executionTarget ?? { kind: "local" }, + readAdapterExecutionTargetHomeDir: async () => null, + resolveAdapterExecutionTargetCommandForLogs: resolveCommandForLogsMock, + resolveAdapterExecutionTargetTimeoutSec: (_target: unknown, timeoutSec: number) => timeoutSec, + runAdapterExecutionTargetProcess: runProcessMock, + runAdapterExecutionTargetShellCommand: async () => ({ exitCode: 0, stdout: "", stderr: "" }), + startAdapterExecutionTargetPaperclipBridge: async () => null, +})); + +import { execute } from "./execute.js"; + +const tempRoots: string[] = []; + +async function makeTempRoot() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-kimi-local-")); + tempRoots.push(root); + return root; +} + +function makeContext(root: string, overrides: Partial = {}): AdapterExecutionContext { + const ctx: AdapterExecutionContext = { + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Kimi Agent", + adapterType: "kimi_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { cwd: root }, + context: {}, + authToken: "run-token", + onLog: async () => {}, + ...overrides, + }; + // Default these CLI-lane tests to the CLI engine so they never depend on + // whether `kimi` is resolvable on PATH (ACP is the runtime default). Tests + // that need ACP can set engine explicitly in their config override. + ctx.config = { engine: "cli", ...ctx.config }; + return ctx; +} + +const KIMI_STDOUT = [ + JSON.stringify({ role: "assistant", content: "done" }), + JSON.stringify({ + role: "meta", + type: "session.resume_hint", + session_id: "session_abc-123", + command: "kimi -r session_abc-123", + }), +].join("\n"); + +describe("kimi_local execute", () => { + beforeEach(() => { + ensureRuntimeInstalledMock.mockClear(); + ensureCommandMock.mockClear(); + prepareRuntimeMock.mockClear(); + resolveCommandForLogsMock.mockClear(); + runProcessMock.mockReset(); + }); + + afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); + }); + + it("runs kimi headless with stream-json and captures the session id from the meta event", async () => { + const root = await makeTempRoot(); + let seenArgs: string[] = []; + let seenEnv: Record = {}; + runProcessMock.mockImplementation(async (_runId, _target, _command, args, options) => { + seenArgs = args; + seenEnv = options.env; + return { exitCode: 0, signal: null, timedOut: false, stdout: KIMI_STDOUT, stderr: "" }; + }); + + const result = await execute(makeContext(root)); + + expect(seenArgs[0]).toBe("--output-format"); + expect(seenArgs[1]).toBe("stream-json"); + expect(seenArgs).not.toContain("-m"); + expect(seenArgs).not.toContain("-r"); + expect(seenArgs[seenArgs.length - 2]).toBe("-p"); + expect(seenEnv.CI).toBe("1"); + expect(seenEnv.NO_COLOR).toBe("1"); + expect(seenEnv.KIMI_CODE_NO_AUTO_UPDATE).toBe("1"); + expect(result).toMatchObject({ + exitCode: 0, + errorMessage: null, + summary: "done", + sessionId: "session_abc-123", + sessionDisplayId: "session_abc-123", + }); + expect(result.sessionParams).toMatchObject({ + sessionId: "session_abc-123", + cwd: root, + }); + }); + + it("forwards streamed stdout lines to onEvent as assistant + tool_call runtime events", async () => { + const root = await makeTempRoot(); + const events: Array<{ eventType: string; message?: string; payload?: Record }> = []; + const stream = + `${JSON.stringify({ role: "assistant", content: "Here is my plan" })}\n` + + `${JSON.stringify({ + role: "assistant", + content: "running", + tool_calls: [{ type: "function", id: "t1", function: { name: "Bash", arguments: "{}" } }], + })}\n`; + runProcessMock.mockImplementation(async (_runId, _target, _command, _args, options) => { + // Split mid-line so the wrapper's newline buffering is exercised across chunks. + await options.onLog("stdout", stream.slice(0, 20)); + await options.onLog("stdout", stream.slice(20)); + return { exitCode: 0, signal: null, timedOut: false, stdout: stream, stderr: "" }; + }); + + await execute(makeContext(root, { onEvent: async (event) => { events.push(event); } })); + + expect(events).toContainEqual({ + eventType: "assistant", + stream: "stdout", + message: "Here is my plan", + payload: { content: "Here is my plan" }, + }); + expect(events).toContainEqual({ eventType: "tool_call", stream: "stdout", payload: { toolName: "Bash" } }); + }); + + it("forwards the final stdout line to onEvent even without a trailing newline", async () => { + const root = await makeTempRoot(); + const events: Array<{ eventType: string; payload?: Record }> = []; + // Kimi can close stdout after a valid event with no trailing newline; the + // forwarder must flush it so the last tool call reaches live status. + const stream = `${JSON.stringify({ + role: "assistant", + tool_calls: [{ type: "function", id: "t9", function: { name: "Read", arguments: "{}" } }], + })}`; + runProcessMock.mockImplementation(async (_runId, _target, _command, _args, options) => { + await options.onLog("stdout", stream); + return { exitCode: 0, signal: null, timedOut: false, stdout: stream, stderr: "" }; + }); + + await execute(makeContext(root, { onEvent: async (event) => { events.push(event); } })); + + expect(events).toContainEqual({ eventType: "tool_call", stream: "stdout", payload: { toolName: "Read" } }); + }); + + it("passes -m only when a model is configured", async () => { + const root = await makeTempRoot(); + let seenArgs: string[] = []; + runProcessMock.mockImplementation(async (_runId, _target, _command, args) => { + seenArgs = args; + return { exitCode: 0, signal: null, timedOut: false, stdout: KIMI_STDOUT, stderr: "" }; + }); + + await execute(makeContext(root, { config: { cwd: root, model: "kimi-code/k3" } })); + + expect(seenArgs).toContain("-m"); + expect(seenArgs[seenArgs.indexOf("-m") + 1]).toBe("kimi-code/k3"); + }); + + it("resumes with -r when the stored session cwd matches the run cwd", async () => { + const root = await makeTempRoot(); + let seenArgs: string[] = []; + runProcessMock.mockImplementation(async (_runId, _target, _command, args) => { + seenArgs = args; + return { exitCode: 0, signal: null, timedOut: false, stdout: KIMI_STDOUT, stderr: "" }; + }); + + await execute(makeContext(root, { + runtime: { + sessionId: "session_abc-123", + sessionParams: { sessionId: "session_abc-123", cwd: root }, + sessionDisplayId: "session_abc-123", + taskKey: null, + }, + })); + + expect(seenArgs).toContain("-r"); + expect(seenArgs[seenArgs.indexOf("-r") + 1]).toBe("session_abc-123"); + }); + + it("starts a fresh session when the stored session cwd differs", async () => { + const root = await makeTempRoot(); + let seenArgs: string[] = []; + runProcessMock.mockImplementation(async (_runId, _target, _command, args) => { + seenArgs = args; + return { exitCode: 0, signal: null, timedOut: false, stdout: KIMI_STDOUT, stderr: "" }; + }); + + await execute(makeContext(root, { + runtime: { + sessionId: "session_abc-123", + sessionParams: { sessionId: "session_abc-123", cwd: "/some/other/dir" }, + sessionDisplayId: "session_abc-123", + taskKey: null, + }, + })); + + expect(seenArgs).not.toContain("-r"); + }); + + it("retries fresh when the resume session is unrecoverable", async () => { + const root = await makeTempRoot(); + const seenArgLists: string[][] = []; + runProcessMock.mockImplementation(async (_runId, _target, _command, args) => { + seenArgLists.push(args); + if (seenArgLists.length === 1) { + return { exitCode: 1, signal: null, timedOut: false, stdout: "", stderr: "Error: unknown session 'session_stale'" }; + } + return { exitCode: 0, signal: null, timedOut: false, stdout: KIMI_STDOUT, stderr: "" }; + }); + + const result = await execute(makeContext(root, { + runtime: { + sessionId: "session_stale", + sessionParams: { sessionId: "session_stale", cwd: root }, + sessionDisplayId: "session_stale", + taskKey: null, + }, + })); + + expect(runProcessMock).toHaveBeenCalledTimes(2); + expect(seenArgLists[0]).toContain("-r"); + expect(seenArgLists[1]).not.toContain("-r"); + expect(result).toMatchObject({ exitCode: 0, sessionId: "session_abc-123" }); + }); + + it("maps auth failures to the kimi_auth_required error code", async () => { + const root = await makeTempRoot(); + runProcessMock.mockImplementation(async () => ({ + exitCode: 1, + signal: null, + timedOut: false, + stdout: "", + stderr: "Error: 401 Unauthorized — run kimi login to authenticate", + })); + + const result = await execute(makeContext(root)); + + expect(result.exitCode).toBe(1); + expect(result.errorCode).toBe("kimi_auth_required"); + expect(result.errorMessage).toBeTruthy(); + }); + + it("reports a timeout when the process exceeds timeoutSec", async () => { + const root = await makeTempRoot(); + runProcessMock.mockImplementation(async () => ({ + exitCode: null, + signal: "SIGTERM", + timedOut: true, + stdout: "", + stderr: "", + })); + + const result = await execute(makeContext(root, { config: { cwd: root, timeoutSec: 5 } })); + + expect(result.timedOut).toBe(true); + expect(result.errorMessage).toContain("5s"); + }); + + it("reports failure when the process is killed by a signal without timing out", async () => { + const root = await makeTempRoot(); + runProcessMock.mockImplementation(async () => ({ + exitCode: null, + signal: "SIGKILL", + timedOut: false, + stdout: "", + stderr: "", + })); + + const result = await execute(makeContext(root)); + + expect(result.timedOut).toBe(false); + expect(result.errorMessage).toContain("SIGKILL"); + expect(result.errorMessage).not.toBeNull(); + }); + + it("preserves user-configured headless env values", async () => { + const root = await makeTempRoot(); + let seenEnv: Record = {}; + runProcessMock.mockImplementation(async (_runId, _target, _command, _args, options) => { + seenEnv = options.env; + return { exitCode: 0, signal: null, timedOut: false, stdout: KIMI_STDOUT, stderr: "" }; + }); + + await execute(makeContext(root, { + config: { + cwd: root, + env: { + CI: "0", + NO_COLOR: "0", + KIMI_CODE_NO_AUTO_UPDATE: "0", + TERM: "xterm-256color", + }, + }, + })); + + expect(seenEnv.CI).toBe("0"); + expect(seenEnv.NO_COLOR).toBe("0"); + expect(seenEnv.KIMI_CODE_NO_AUTO_UPDATE).toBe("0"); + expect(seenEnv.TERM).toBe("xterm-256color"); + }); + + it("forwards configured effort as KIMI_MODEL_THINKING_EFFORT for effort-capable models", async () => { + const root = await makeTempRoot(); + let seenEnv: Record = {}; + runProcessMock.mockImplementation(async (_runId, _target, _command, _args, options) => { + seenEnv = options.env; + return { exitCode: 0, signal: null, timedOut: false, stdout: KIMI_STDOUT, stderr: "" }; + }); + + await execute(makeContext(root, { config: { cwd: root, model: "kimi-code/k3", effort: "high" } })); + + expect(seenEnv.KIMI_MODEL_THINKING_EFFORT).toBe("high"); + }); + + it("maps the medium effort tier onto high since Kimi has no medium", async () => { + const root = await makeTempRoot(); + let seenEnv: Record = {}; + runProcessMock.mockImplementation(async (_runId, _target, _command, _args, options) => { + seenEnv = options.env; + return { exitCode: 0, signal: null, timedOut: false, stdout: KIMI_STDOUT, stderr: "" }; + }); + + await execute(makeContext(root, { config: { cwd: root, model: "kimi-code/k3", effort: "medium" } })); + + expect(seenEnv.KIMI_MODEL_THINKING_EFFORT).toBe("high"); + }); + + it("does not forward effort for models without support_efforts", async () => { + const root = await makeTempRoot(); + let seenEnv: Record = {}; + runProcessMock.mockImplementation(async (_runId, _target, _command, _args, options) => { + seenEnv = options.env; + return { exitCode: 0, signal: null, timedOut: false, stdout: KIMI_STDOUT, stderr: "" }; + }); + + await execute(makeContext(root, { + config: { cwd: root, model: "kimi-code/kimi-for-coding", effort: "high" }, + })); + + expect(seenEnv.KIMI_MODEL_THINKING_EFFORT).toBeUndefined(); + }); + + it("adds --add-dir for the instructions directory and names sibling files in the prompt", async () => { + const root = await makeTempRoot(); + const instructionsDir = path.join(root, "instructions"); + await fs.mkdir(instructionsDir, { recursive: true }); + const instructionsFilePath = path.join(instructionsDir, "AGENTS.md"); + await fs.writeFile(instructionsFilePath, "# Role\nYou are the lead agent.\n"); + + let seenArgs: string[] = []; + runProcessMock.mockImplementation(async (_runId, _target, _command, args) => { + seenArgs = args; + return { exitCode: 0, signal: null, timedOut: false, stdout: KIMI_STDOUT, stderr: "" }; + }); + + await execute(makeContext(root, { config: { cwd: root, instructionsFilePath } })); + + expect(seenArgs).toContain("--add-dir"); + expect(seenArgs[seenArgs.indexOf("--add-dir") + 1]).toBe(instructionsDir); + const prompt = seenArgs[seenArgs.length - 1]; + expect(prompt).toContain("./HEARTBEAT.md"); + expect(prompt).toContain("./SOUL.md"); + expect(prompt).toContain("./TOOLS.md"); + }); + + it("does not pass --skills-dir when no skills are desired", async () => { + const root = await makeTempRoot(); + let seenArgs: string[] = []; + runProcessMock.mockImplementation(async (_runId, _target, _command, args) => { + seenArgs = args; + return { exitCode: 0, signal: null, timedOut: false, stdout: KIMI_STDOUT, stderr: "" }; + }); + + await execute(makeContext(root, { config: { cwd: root, model: "kimi-code/k3" } })); + + expect(seenArgs).not.toContain("--skills-dir"); + }); +}); diff --git a/packages/adapters/kimi-local/src/server/execute.ts b/packages/adapters/kimi-local/src/server/execute.ts new file mode 100644 index 0000000000..1b6f332db9 --- /dev/null +++ b/packages/adapters/kimi-local/src/server/execute.ts @@ -0,0 +1,726 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { + adapterExecutionTargetIsRemote, + adapterExecutionTargetRemoteCwd, + overrideAdapterExecutionTargetRemoteCwd, + adapterExecutionTargetSessionIdentity, + adapterExecutionTargetSessionMatches, + adapterExecutionTargetUsesManagedHome, + adapterExecutionTargetUsesPaperclipBridge, + describeAdapterExecutionTarget, + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetRuntimeCommandInstalled, + prepareAdapterExecutionTargetRuntime, + readAdapterExecutionTarget, + resolveAdapterExecutionTargetTimeoutSec, + resolveAdapterExecutionTargetCommandForLogs, + runAdapterExecutionTargetProcess, + startAdapterExecutionTargetPaperclipBridge, +} from "@paperclipai/adapter-utils/execution-target"; +import { + asNumber, + asString, + asStringArray, + buildPaperclipEnv, + buildInvocationEnvForLogs, + ensureAbsoluteDirectory, + joinPromptSections, + ensurePathInEnv, + refreshPaperclipWorkspaceEnvForExecution, + readPaperclipRuntimeSkillEntries, + readPaperclipIssueWorkModeFromContext, + resolvePaperclipDesiredSkillNames, + parseObject, + renderTemplate, + renderPaperclipWakePrompt, + isPaperclipRecoveryWakePayload, + stringifyPaperclipWakePayload, + DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, +} from "@paperclipai/adapter-utils/server-utils"; +import { + SANDBOX_INSTALL_COMMAND, + modelSupportsEffort, + resolveKimiThinkingEffort, +} from "../index.js"; +import { + describeKimiFailure, + detectKimiAuthRequired, + extractKimiRuntimeEvents, + isKimiSessionUnrecoverableError, + isKimiTransientNetworkError, + parseKimiJsonl, +} from "./parse.js"; +import { + createKimiAcpExecutor, + formatKimiAcpFallbackMessage, + resolveKimiExecutionEngineForRun, +} from "./acp.js"; +import { firstNonEmptyLine } from "./utils.js"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +const executeKimiAcp = createKimiAcpExecutor(); + +/** + * Wrap `onLog` so each complete kimi stream-json stdout line is also mapped to + * `onEvent` runtime events (assistant snippet, tool name). This keeps the raw + * run log intact while lighting up the issue-thread activity indicator, which + * reads `currentToolName` / `lastAssistantSnippet` / `lastEventAt` derived from + * `onEvent` rather than from the raw log stream. Stdout arrives in arbitrary + * chunks, so lines are buffered and split on newlines. `flush` must be called + * once the process exits so the final line reaches `onEvent` even when kimi + * closes stdout without a trailing newline (otherwise the last assistant + * message or tool call would be missing from live status). + */ +function createKimiEventForwardingLog( + onLog: AdapterExecutionContext["onLog"], + onEvent: AdapterExecutionContext["onEvent"], +): { log: AdapterExecutionContext["onLog"]; flush: () => Promise } { + if (!onEvent) return { log: onLog, flush: async () => {} }; + let buffer = ""; + const emitLine = async (raw: string): Promise => { + const line = raw.trim(); + if (!line) return; + for (const event of extractKimiRuntimeEvents(line)) { + await onEvent({ eventType: event.eventType, stream: "stdout", message: event.message, payload: event.payload }); + } + }; + return { + log: async (stream, chunk) => { + await onLog(stream, chunk); + if (stream !== "stdout") return; + buffer += chunk; + let newlineIndex: number; + while ((newlineIndex = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + await emitLine(line); + } + }, + flush: async () => { + const remaining = buffer; + buffer = ""; + await emitLine(remaining); + }, + }; +} + +function hasNonEmptyEnvValue(env: Record, key: string): boolean { + const raw = env[key]; + return typeof raw === "string" && raw.trim().length > 0; +} + +function resolveKimiBillingType(env: Record): "api" | "subscription" { + return hasNonEmptyEnvValue(env, "KIMI_MODEL_API_KEY") ? "api" : "subscription"; +} + +/** + * Headless-safe environment for unattended `kimi -p` runs. CI=1 disables + * theme detection, NO_COLOR=1 keeps stdout parseable, and + * KIMI_CODE_NO_AUTO_UPDATE=1 skips the update preflight. User-configured + * values always win. + */ +function buildKimiHeadlessEnv(env: Record): Record { + const next = { ...env }; + if (!next.CI?.trim()) next.CI = "1"; + if (!next.NO_COLOR?.trim()) next.NO_COLOR = "1"; + if (!next.KIMI_CODE_NO_AUTO_UPDATE?.trim()) next.KIMI_CODE_NO_AUTO_UPDATE = "1"; + if (!next.TERM?.trim()) next.TERM = "dumb"; + return next; +} + +function buildKimiRuntimeEnv(env: Record): Record { + return Object.fromEntries( + Object.entries(ensurePathInEnv({ ...process.env, ...buildKimiHeadlessEnv(env) })).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); +} + +function renderPaperclipEnvNote(env: Record): string { + const paperclipKeys = Object.keys(env) + .filter((key) => key.startsWith("PAPERCLIP_")) + .sort(); + if (paperclipKeys.length === 0) return ""; + return [ + "Paperclip runtime note:", + `The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`, + "Do not assume these variables are missing without checking your shell environment.", + "", + "", + ].join("\n"); +} + +function renderApiAccessNote(env: Record): string { + if (!hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY")) return ""; + return [ + "Paperclip API access note:", + "Use shell commands with curl to make Paperclip API requests when needed.", + "Include X-Paperclip-Run-Id on mutating requests.", + "", + "", + ].join("\n"); +} + +async function buildKimiSkillsDir( + config: Record, +): Promise { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-kimi-skills-")); + const target = path.join(tmp, "skills"); + await fs.mkdir(target, { recursive: true }); + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredNames = new Set(resolvePaperclipDesiredSkillNames(config, availableEntries)); + for (const entry of availableEntries) { + if (!desiredNames.has(entry.key)) continue; + await fs.symlink(entry.source, path.join(target, entry.runtimeName)); + } + return target; +} + +export async function execute(ctx: AdapterExecutionContext): Promise { + const engineSelection = await resolveKimiExecutionEngineForRun(ctx); + if (engineSelection.engine === "acp") { + try { + return await executeKimiAcp(ctx); + } catch (err) { + // An explicitly requested ACP engine surfaces its failure; the default + // (auto) selection falls back to the CLI lane with a diagnostic note. + if (engineSelection.explicit) throw err; + const reason = err instanceof Error ? err.message : String(err); + await ctx.onLog("stderr", formatKimiAcpFallbackMessage(`Kimi ACP startup failed: ${reason}`)); + } + } else if (!engineSelection.explicit && engineSelection.fallbackReason) { + await ctx.onLog("stderr", formatKimiAcpFallbackMessage(engineSelection.fallbackReason)); + } + + const { runId, agent, runtime, config, context, onLog, onMeta, onEvent, onSpawn, authToken } = ctx; + const executionTarget = readAdapterExecutionTarget({ + executionTarget: ctx.executionTarget, + legacyRemoteExecution: ctx.executionTransport?.remoteExecution, + }); + const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget); + + const promptTemplate = asString( + config.promptTemplate, + DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + ); + const command = asString(config.command, "kimi"); + const model = asString(config.model, "").trim(); + + const workspaceContext = parseObject(context.paperclipWorkspace); + const workspaceCwd = asString(workspaceContext.cwd, ""); + const workspaceSource = asString(workspaceContext.source, ""); + const workspaceId = asString(workspaceContext.workspaceId, ""); + const workspaceRepoUrl = asString(workspaceContext.repoUrl, ""); + const workspaceRepoRef = asString(workspaceContext.repoRef, ""); + const agentHome = asString(workspaceContext.agentHome, ""); + const workspaceHints = Array.isArray(context.paperclipWorkspaces) + ? context.paperclipWorkspaces.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const configuredCwd = asString(config.cwd, ""); + const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; + const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; + const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + const kimiSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredKimiSkillNames = resolvePaperclipDesiredSkillNames(config, kimiSkillEntries); + const envConfig = parseObject(config.env); + + const hasExplicitApiKey = + typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; + const env: Record = { ...buildPaperclipEnv(agent) }; + env.PAPERCLIP_RUN_ID = runId; + const wakeTaskId = + (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || + (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || + null; + const wakeReason = + typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0 + ? context.wakeReason.trim() + : null; + const wakeCommentId = + (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) || + (typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) || + null; + const approvalId = + typeof context.approvalId === "string" && context.approvalId.trim().length > 0 + ? context.approvalId.trim() + : null; + const approvalStatus = + typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0 + ? context.approvalStatus.trim() + : null; + const linkedIssueIds = Array.isArray(context.issueIds) + ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : []; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); + const issueWorkMode = readPaperclipIssueWorkModeFromContext(context); + if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; + if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode; + if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; + if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; + if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; + if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; + if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; + refreshPaperclipWorkspaceEnvForExecution({ + env, + envConfig, + workspaceCwd: effectiveWorkspaceCwd, + workspaceSource, + workspaceId, + workspaceRepoUrl, + workspaceRepoRef, + workspaceHints, + agentHome, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, + }); + if (!hasExplicitApiKey && authToken) { + env.PAPERCLIP_API_KEY = authToken; + } + // Forward configured thinking effort as KIMI_MODEL_THINKING_EFFORT. Kimi has + // no per-invocation effort flag; this env var is an operational override that + // applies to Kimi providers (managed OAuth models included). Only send it for + // models that advertise support_efforts, and never clobber an explicit value. + const configuredEffort = asString(config.effort, "").trim(); + if (configuredEffort && modelSupportsEffort(model) && !hasNonEmptyEnvValue(env, "KIMI_MODEL_THINKING_EFFORT")) { + const kimiEffort = resolveKimiThinkingEffort(configuredEffort); + if (kimiEffort) env.KIMI_MODEL_THINKING_EFFORT = kimiEffort; + } + const runtimeEnv = buildKimiRuntimeEnv(env); + const billingType = resolveKimiBillingType(runtimeEnv); + const timeoutSec = resolveAdapterExecutionTargetTimeoutSec( + executionTarget, + asNumber(config.timeoutSec, 0), + ); + const graceSec = asNumber(config.graceSec, 20); + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ + runId, + target: executionTarget, + installCommand: ctx.runtimeCommandSpec?.installCommand, + detectCommand: ctx.runtimeCommandSpec?.detectCommand, + cwd, + env: runtimeEnv, + timeoutSec, + graceSec, + onLog, + }); + await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, { + installCommand: SANDBOX_INSTALL_COMMAND, + timeoutSec, + }); + const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + let restoreRemoteWorkspace: (() => Promise) | null = null; + let localSkillsDir: string | null = null; + let remoteSkillsDir: string | null = null; + let remoteRuntimeRootDir: string | null = null; + let paperclipBridge: Awaited> = null; + + if (executionTargetIsRemote) { + try { + localSkillsDir = await buildKimiSkillsDir(config); + await onLog( + "stdout", + `[paperclip] Syncing workspace and Kimi runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`, + ); + const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({ + runId, + target: executionTarget, + adapterKey: "kimi", + timeoutSec, + workspaceLocalDir: cwd, + installCommand: SANDBOX_INSTALL_COMMAND, + detectCommand: command, + onProgress: (line) => onLog("stdout", line), + onRuntimeProgress: ctx.onRuntimeProgress, + assets: [{ + key: "skills", + localDir: localSkillsDir, + followSymlinks: true, + }], + }); + restoreRemoteWorkspace = () => + preparedExecutionTargetRuntime.restoreWorkspace((line) => onLog("stdout", line)); + effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir ?? effectiveExecutionCwd; + refreshPaperclipWorkspaceEnvForExecution({ + env, + envConfig, + workspaceCwd: effectiveWorkspaceCwd, + workspaceSource, + workspaceId, + workspaceRepoUrl, + workspaceRepoRef, + workspaceHints, + agentHome, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, + }); + remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir; + const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget); + const managedRemoteHomeDir = + managedHome && preparedExecutionTargetRuntime.runtimeRootDir + ? preparedExecutionTargetRuntime.runtimeRootDir + : null; + if (managedRemoteHomeDir) { + env.HOME = managedRemoteHomeDir; + } + // Deliver the synced skills snapshot via --skills-dir (see buildArgs) + // from its isolated per-run location instead of copying it over the + // shared $KIMI_CODE_HOME/skills home. Overwriting the shared home would + // delete Kimi skills installed by the operator or other agents that + // Paperclip does not own. + if (desiredKimiSkillNames.length > 0 && preparedExecutionTargetRuntime.assetDirs.skills) { + remoteSkillsDir = preparedExecutionTargetRuntime.assetDirs.skills; + } + } catch (error) { + await Promise.allSettled([ + restoreRemoteWorkspace?.(), + localSkillsDir ? fs.rm(path.dirname(localSkillsDir), { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(), + ]); + throw error; + } + } + const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd); + if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) { + paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({ + runId, + target: runtimeExecutionTarget, + runtimeRootDir: remoteRuntimeRootDir, + adapterKey: "kimi", + timeoutSec, + hostApiToken: env.PAPERCLIP_API_KEY, + onLog, + }); + if (paperclipBridge) { + Object.assign(env, paperclipBridge.env); + } + } + + // Local runs deliver desired skills via `--skills-dir` (see buildArgs) from a + // dedicated per-run directory, rather than symlinking into the user's + // ~/.kimi-code/skills home. This keeps skill loading reliable and isolated + // without polluting the operator's Kimi install. Remote runs sync skills into + // the remote skills home above, so this only applies to local execution. + if (!executionTargetIsRemote && desiredKimiSkillNames.length > 0) { + localSkillsDir = await buildKimiSkillsDir(config); + await onLog( + "stderr", + `[paperclip] Prepared ${desiredKimiSkillNames.length} Kimi skill(s) for --skills-dir delivery.\n`, + ); + } + + const runtimeSessionParams = parseObject(runtime.sessionParams); + const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); + const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); + const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution); + const canResumeSession = + runtimeSessionId.length > 0 && + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) && + adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget); + const sessionId = canResumeSession ? runtimeSessionId : null; + if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) { + await onLog( + "stdout", + `[paperclip] Kimi session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`, + ); + } else if (runtimeSessionId && !canResumeSession) { + await onLog( + "stdout", + `[paperclip] Kimi session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`, + ); + } + + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : ""; + let instructionsPrefix = ""; + if (instructionsFilePath) { + try { + const instructionsContents = await fs.readFile(instructionsFilePath, "utf8"); + instructionsPrefix = + `${instructionsContents}\n\n` + + `The above agent instructions were loaded from ${instructionsFilePath}. ` + + `Resolve any relative file references from ${instructionsDir}. ` + + `This base directory is authoritative for sibling instruction files such as ` + + `./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.\n\n`; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + await onLog( + "stdout", + `[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`, + ); + } + } + const commandNotes = (() => { + const notes: string[] = ["Prompt is passed to Kimi via -p for non-interactive execution."]; + notes.push("Added --output-format stream-json for structured headless output."); + notes.push("Set headless env (CI=1, NO_COLOR=1, KIMI_CODE_NO_AUTO_UPDATE=1) so unattended runs skip interactive prompts and update preflight."); + if (hasNonEmptyEnvValue(env, "KIMI_MODEL_THINKING_EFFORT")) { + notes.push(`Set KIMI_MODEL_THINKING_EFFORT=${env.KIMI_MODEL_THINKING_EFFORT} for model ${model}.`); + } + const effectiveSkillsDir = executionTargetIsRemote ? remoteSkillsDir : localSkillsDir; + if (effectiveSkillsDir) { + notes.push(`Loading ${desiredKimiSkillNames.length} desired skill(s) via --skills-dir ${effectiveSkillsDir}.`); + } + if (!executionTargetIsRemote && instructionsFilePath) { + notes.push(`Added --add-dir ${path.dirname(instructionsFilePath)} so sibling instruction files are readable.`); + } + if (!instructionsFilePath) return notes; + if (instructionsPrefix.length > 0) { + notes.push( + `Loaded agent instructions from ${instructionsFilePath}`, + `Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`, + ); + return notes; + } + notes.push( + `Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`, + ); + return notes; + })(); + + const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); + const templateData = { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }; + const renderedBootstrapPrompt = + !sessionId && bootstrapPromptTemplate.trim().length > 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; + const renderedPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake) + ? "" + : renderTemplate(promptTemplate, templateData); + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const paperclipEnvNote = renderPaperclipEnvNote(env); + const apiAccessNote = renderApiAccessNote(env); + const prompt = joinPromptSections([ + instructionsPrefix, + renderedBootstrapPrompt, + wakePrompt, + sessionHandoffNote, + paperclipEnvNote, + apiAccessNote, + renderedPrompt, + ]); + const promptMetrics = { + promptChars: prompt.length, + instructionsChars: instructionsPrefix.length, + bootstrapPromptChars: renderedBootstrapPrompt.length, + wakePromptChars: wakePrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length, + heartbeatPromptChars: renderedPrompt.length, + }; + + const buildArgs = (resumeSessionId: string | null) => { + const args = ["--output-format", "stream-json"]; + if (resumeSessionId) args.push("-r", resumeSessionId); + if (model) args.push("-m", model); + // Make the agent instructions directory readable so Kimi can open sibling + // instruction files (./HEARTBEAT.md, ./SOUL.md, ./TOOLS.md) referenced by + // the prepended entry file. Local-only: the directory is a host path that + // is not synced to remote execution targets. + if (!executionTargetIsRemote && instructionsFilePath) { + args.push("--add-dir", path.dirname(instructionsFilePath)); + } + // Load desired Paperclip skills from the dedicated per-run directory + // (local snapshot, or the synced remote snapshot) instead of the shared + // skills home. Only passed when skills are desired so unconfigured agents + // keep Kimi's default skill discovery. + const effectiveSkillsDir = executionTargetIsRemote ? remoteSkillsDir : localSkillsDir; + if (effectiveSkillsDir) { + args.push("--skills-dir", effectiveSkillsDir); + } + if (extraArgs.length > 0) args.push(...extraArgs); + args.push("-p", prompt); + return args; + }; + + const runAttempt = async (resumeSessionId: string | null) => { + const args = buildArgs(resumeSessionId); + const invocationEnv = buildKimiHeadlessEnv(env); + const invocationRuntimeEnv = buildKimiRuntimeEnv(env); + const loggedEnv = buildInvocationEnvForLogs(invocationEnv, { + runtimeEnv: invocationRuntimeEnv, + includeRuntimeKeys: ["HOME"], + resolvedCommand, + }); + if (onMeta) { + await onMeta({ + adapterType: "kimi_local", + command: resolvedCommand, + cwd: effectiveExecutionCwd, + commandNotes, + commandArgs: args.map((value, index) => ( + index === args.length - 1 ? `` : value + )), + env: loggedEnv, + prompt, + promptMetrics, + context, + }); + } + + const eventForwarder = createKimiEventForwardingLog(onLog, onEvent); + const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, { + cwd, + env: invocationEnv, + timeoutSec, + graceSec, + onSpawn, + onRuntimeProgress: ctx.onRuntimeProgress, + onLog: eventForwarder.log, + runLogTail: paperclipBridge?.runLogTail, + }); + await eventForwarder.flush(); + return { + proc, + parsed: parseKimiJsonl(proc.stdout), + }; + }; + + const toResult = ( + attempt: { + proc: { + exitCode: number | null; + signal: string | null; + timedOut: boolean; + stdout: string; + stderr: string; + }; + parsed: ReturnType; + }, + clearSessionOnMissingSession = false, + isRetry = false, + ): AdapterExecutionResult => { + const authMeta = detectKimiAuthRequired({ + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + }); + const networkUnavailable = isKimiTransientNetworkError(attempt.proc.stdout, attempt.proc.stderr); + + if (attempt.proc.timedOut) { + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: true, + errorMessage: `Timed out after ${timeoutSec}s`, + errorCode: authMeta.requiresAuth + ? "kimi_auth_required" + : networkUnavailable + ? "kimi_network_unavailable" + : null, + clearSession: clearSessionOnMissingSession, + }; + } + + const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; + const stderrLine = firstNonEmptyLine(attempt.proc.stderr); + const structuredFailure = describeKimiFailure({ + errorMessage: attempt.parsed.errorMessage, + stderr: attempt.proc.stderr, + }); + const fallbackErrorMessage = + parsedError || + structuredFailure || + stderrLine || + (attempt.proc.signal + ? `Kimi was terminated by signal ${attempt.proc.signal}` + : `Kimi exited with code ${attempt.proc.exitCode ?? -1}`); + // A null exit code means the process never exited normally (e.g. killed by + // a signal). Timeouts are handled earlier; treat any other non-zero or + // null exit as a failure so a signaled kill is never reported as success. + const failed = attempt.proc.exitCode === null || attempt.proc.exitCode !== 0; + + // On retry, don't fall back to old session ID — the old session was stale + const canFallbackToRuntimeSession = !isRetry; + const resolvedSessionId = attempt.parsed.sessionId + ?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null); + const resolvedSessionParams = resolvedSessionId + ? ({ + sessionId: resolvedSessionId, + cwd: effectiveExecutionCwd, + ...(workspaceId ? { workspaceId } : {}), + ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), + ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), + ...(executionTargetIsRemote + ? { + remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget), + } + : {}), + } as Record) + : null; + const resultJson: Record = { + toolCalls: attempt.parsed.toolCalls, + toolResults: attempt.parsed.toolResults, + ...(failed ? { stderr: attempt.proc.stderr } : {}), + }; + + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: false, + errorMessage: failed ? fallbackErrorMessage : null, + errorCode: failed && authMeta.requiresAuth + ? "kimi_auth_required" + : failed && networkUnavailable + ? "kimi_network_unavailable" + : null, + sessionId: resolvedSessionId, + sessionParams: resolvedSessionParams, + sessionDisplayId: resolvedSessionId, + provider: "moonshot", + biller: "moonshot", + model: model || null, + billingType, + resultJson, + summary: attempt.parsed.summary, + clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId), + }; + }; + + try { + const initial = await runAttempt(sessionId); + if ( + sessionId && + !initial.proc.timedOut && + (initial.proc.exitCode ?? 0) !== 0 && + isKimiSessionUnrecoverableError(initial.proc.stdout, initial.proc.stderr) + ) { + await onLog( + "stdout", + `[paperclip] Kimi resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`, + ); + const retry = await runAttempt(null); + return toResult(retry, true, true); + } + + return toResult(initial); + } finally { + await Promise.all([ + paperclipBridge?.stop(), + restoreRemoteWorkspace?.(), + localSkillsDir ? fs.rm(path.dirname(localSkillsDir), { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(), + ]); + } +} diff --git a/packages/adapters/kimi-local/src/server/index.ts b/packages/adapters/kimi-local/src/server/index.ts new file mode 100644 index 0000000000..e9c968f4f8 --- /dev/null +++ b/packages/adapters/kimi-local/src/server/index.ts @@ -0,0 +1,76 @@ +import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; +import { sessionCodec as acpxSessionCodec } from "@paperclipai/adapter-utils/acpx-engine/session-codec"; + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw: unknown) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record; + const sessionId = + readNonEmptyString(record.sessionId) ?? + readNonEmptyString(record.session_id) ?? + readNonEmptyString(record.sessionID); + if (!sessionId) return acpxSessionCodec.deserialize(raw); + const cwd = + readNonEmptyString(record.cwd) ?? + readNonEmptyString(record.workdir) ?? + readNonEmptyString(record.folder); + const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id); + const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url); + const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + serialize(params: Record | null) { + if (!params) return null; + const sessionId = + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.sessionID); + if (!sessionId) return acpxSessionCodec.serialize(params); + const cwd = + readNonEmptyString(params.cwd) ?? + readNonEmptyString(params.workdir) ?? + readNonEmptyString(params.folder); + const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id); + const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url); + const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + getDisplayId(params: Record | null) { + if (!params) return null; + return ( + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.sessionID) ?? + acpxSessionCodec.getDisplayId?.(params) ?? + null + ); + }, +}; + +export * from "./acp.js"; +export { execute } from "./execute.js"; +export { listKimiSkills, syncKimiSkills } from "./skills.js"; +export { testEnvironment } from "./test.js"; +export { + parseKimiJsonl, + isKimiSessionUnrecoverableError, + isKimiTransientNetworkError, + describeKimiFailure, + detectKimiAuthRequired, +} from "./parse.js"; diff --git a/packages/adapters/kimi-local/src/server/parse.test.ts b/packages/adapters/kimi-local/src/server/parse.test.ts new file mode 100644 index 0000000000..6ddf80dc2e --- /dev/null +++ b/packages/adapters/kimi-local/src/server/parse.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; +import { + buildKimiRunSummary, + detectKimiAuthRequired, + extractKimiRuntimeEvents, + isKimiSessionUnrecoverableError, + isKimiTransientNetworkError, + parseKimiJsonl, +} from "./parse.js"; + +describe("extractKimiRuntimeEvents", () => { + it("maps assistant content to an assistant snippet event", () => { + const events = extractKimiRuntimeEvents('{"role":"assistant","content":"Here is my plan"}'); + expect(events).toEqual([ + { eventType: "assistant", message: "Here is my plan", payload: { content: "Here is my plan" } }, + ]); + }); + + it("maps tool_calls to tool_call events carrying the tool name", () => { + const line = + '{"role":"assistant","content":"running","tool_calls":[{"type":"function","id":"t1","function":{"name":"Bash","arguments":"{}"}}]}'; + const events = extractKimiRuntimeEvents(line); + expect(events).toEqual([ + { eventType: "assistant", message: "running", payload: { content: "running" } }, + { eventType: "tool_call", payload: { toolName: "Bash" } }, + ]); + }); + + it("emits nothing for tool results, meta, and malformed lines", () => { + expect(extractKimiRuntimeEvents('{"role":"tool","tool_call_id":"t1","content":"done"}')).toEqual([]); + expect(extractKimiRuntimeEvents('{"role":"meta","type":"session.resume_hint","session_id":"s"}')).toEqual([]); + expect(extractKimiRuntimeEvents("not json")).toEqual([]); + }); +}); + +describe("parseKimiJsonl", () => { + it("collects assistant text from content events", () => { + const stdout = [ + '{"role":"assistant","content":"PAPERCLIP_ADAPTER_TEST_OK"}', + '{"role":"meta","type":"session.resume_hint","session_id":"session_769ddab9-0a25-4edd-99f4-cdfebdc90879","command":"kimi -r session_769ddab9-0a25-4edd-99f4-cdfebdc90879","content":"To resume this session: kimi -r session_769ddab9-0a25-4edd-99f4-cdfebdc90879"}', + ].join("\n"); + + const parsed = parseKimiJsonl(stdout); + + expect(parsed.summary).toBe("PAPERCLIP_ADAPTER_TEST_OK"); + expect(parsed.sessionId).toBe("session_769ddab9-0a25-4edd-99f4-cdfebdc90879"); + expect(parsed.errorMessage).toBeNull(); + }); + + it("parses tool calls with JSON-encoded arguments strings", () => { + const stdout = [ + '{"role":"assistant","tool_calls":[{"type":"function","id":"tool_8c1OWyRBe68OMTbWY6NqnkMm","function":{"name":"Read","arguments":"{\\"path\\":\\"probe.txt\\"}"}}]}', + ].join("\n"); + + const parsed = parseKimiJsonl(stdout); + + expect(parsed.toolCalls).toEqual([ + { id: "tool_8c1OWyRBe68OMTbWY6NqnkMm", name: "Read", arguments: { path: "probe.txt" } }, + ]); + expect(parsed.summary).toBe(""); + }); + + it("keeps raw arguments when the string is not valid JSON", () => { + const stdout = [ + '{"role":"assistant","tool_calls":[{"type":"function","id":"tool_1","function":{"name":"Bash","arguments":"not json"}}]}', + ].join("\n"); + + const parsed = parseKimiJsonl(stdout); + + expect(parsed.toolCalls).toEqual([ + { id: "tool_1", name: "Bash", arguments: "not json" }, + ]); + }); + + it("collects tool results keyed by tool_call_id", () => { + const stdout = [ + '{"role":"assistant","tool_calls":[{"type":"function","id":"tool_8c1OWyRBe68OMTbWY6NqnkMm","function":{"name":"Read","arguments":"{\\"path\\":\\"probe.txt\\"}"}}]}', + '{"role":"tool","tool_call_id":"tool_8c1OWyRBe68OMTbWY6NqnkMm","content":"1\\thello paperclip"}', + ].join("\n"); + + const parsed = parseKimiJsonl(stdout); + + expect(parsed.toolResults).toEqual([ + { toolCallId: "tool_8c1OWyRBe68OMTbWY6NqnkMm", content: "1\thello paperclip" }, + ]); + }); + + it("captures the session id from the trailing meta resume hint", () => { + const stdout = [ + '{"role":"assistant","content":"done"}', + '{"role":"meta","type":"session.resume_hint","session_id":"session_abc","command":"kimi -r session_abc","content":"To resume this session: kimi -r session_abc"}', + ].join("\n"); + + expect(parseKimiJsonl(stdout).sessionId).toBe("session_abc"); + }); + + it("uses only the last assistant content as the run summary", () => { + // Intermediate "thinking out loud" lines must not be auto-posted as issue comments. + const stdout = [ + '{"role":"assistant","content":"Let me get oriented and check the PRs…"}', + '{"role":"assistant","tool_calls":[{"type":"function","id":"t1","function":{"name":"Bash","arguments":"{}"}}]}', + '{"role":"tool","tool_call_id":"t1","content":"ok"}', + '{"role":"assistant","content":"## Update\\n\\n- Merged PR #25\\n- Burn-in continues"}', + ].join("\n"); + + expect(parseKimiJsonl(stdout).summary).toBe("## Update\n\n- Merged PR #25\n- Burn-in continues"); + }); + + it("buildKimiRunSummary picks the last non-empty message", () => { + expect(buildKimiRunSummary(["first", "second", ""])).toBe("second"); + expect(buildKimiRunSummary([])).toBe(""); + }); + + it("skips malformed lines without failing the parse", () => { + const stdout = [ + "not json at all", + '{"role":"assistant","content":"visible"}', + "{broken json", + "", + ].join("\n"); + + const parsed = parseKimiJsonl(stdout); + + expect(parsed.summary).toBe("visible"); + expect(parsed.sessionId).toBeNull(); + }); + + it("ignores meta events without a session id", () => { + const stdout = '{"role":"meta","type":"some.other_meta","content":"noise"}'; + expect(parseKimiJsonl(stdout).sessionId).toBeNull(); + }); +}); + +describe("detectKimiAuthRequired", () => { + it("flags device-flow login hints", () => { + const result = detectKimiAuthRequired({ + stdout: "", + stderr: "Not authenticated. Run `kimi login` to authenticate with a device code.", + }); + expect(result.requiresAuth).toBe(true); + }); + + it("flags 401 unauthorized responses", () => { + const result = detectKimiAuthRequired({ + stdout: "", + stderr: "Error: 401 Unauthorized", + }); + expect(result.requiresAuth).toBe(true); + }); + + it("does not flag ordinary output", () => { + const result = detectKimiAuthRequired({ + stdout: '{"role":"assistant","content":"hello"}', + stderr: "", + }); + expect(result.requiresAuth).toBe(false); + }); +}); + +describe("isKimiTransientNetworkError", () => { + it("matches DNS failures", () => { + expect(isKimiTransientNetworkError("", "Error: getaddrinfo ENOTFOUND api.moonshot.cn")).toBe(true); + }); + + it("matches EAI_AGAIN", () => { + expect(isKimiTransientNetworkError("", "getaddrinfo EAI_AGAIN api.moonshot.cn")).toBe(true); + }); + + it("matches fetch failed", () => { + expect(isKimiTransientNetworkError("", "TypeError: fetch failed")).toBe(true); + }); + + it("does not match unrelated stderr", () => { + expect(isKimiTransientNetworkError("", "Some other error")).toBe(false); + }); +}); + +describe("isKimiSessionUnrecoverableError", () => { + it("matches unknown session", () => { + expect(isKimiSessionUnrecoverableError("", "Error: unknown session 'session_abc'")).toBe(true); + }); + + it("matches session not found", () => { + expect(isKimiSessionUnrecoverableError("", "session session_abc not found on disk")).toBe(true); + }); + + it("matches failed to resume", () => { + expect(isKimiSessionUnrecoverableError("", "failed to resume session session_abc")).toBe(true); + }); + + it("does not match unrelated stderr", () => { + expect(isKimiSessionUnrecoverableError("", "Some other error")).toBe(false); + }); + + it("does not match transient network errors (those go to isKimiTransientNetworkError)", () => { + expect(isKimiSessionUnrecoverableError("", "Error: getaddrinfo ENOTFOUND api.moonshot.cn")).toBe(false); + }); +}); diff --git a/packages/adapters/kimi-local/src/server/parse.ts b/packages/adapters/kimi-local/src/server/parse.ts new file mode 100644 index 0000000000..007e79d6a8 --- /dev/null +++ b/packages/adapters/kimi-local/src/server/parse.ts @@ -0,0 +1,236 @@ +import { asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils"; + +export interface ParsedKimiToolCall { + id: string | null; + name: string; + arguments: unknown; +} + +export interface ParsedKimiToolResult { + toolCallId: string | null; + content: string; +} + +export interface ParsedKimiJsonl { + sessionId: string | null; + summary: string; + toolCalls: ParsedKimiToolCall[]; + toolResults: ParsedKimiToolResult[]; + errorMessage: string | null; +} + +/** + * Kimi tool_call `function.arguments` is a JSON-encoded string. Parse it when + * possible so downstream consumers see structured input; fall back to the raw + * string when it is not valid JSON. + */ +function parseToolCallArguments(value: unknown): unknown { + if (typeof value !== "string") return value ?? {}; + const trimmed = value.trim(); + if (!trimmed) return {}; + try { + return JSON.parse(trimmed); + } catch { + return value; + } +} + +function errorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = parseObject(value); + const message = + asString(rec.message, "").trim() || + asString(rec.error, "").trim() || + asString(rec.detail, "").trim() || + asString(rec.code, "").trim(); + if (message) return message; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +/** + * Build the run summary that Paperclip may auto-post as an issue comment. + * + * Kimi emits many intermediate assistant content lines during a multi-step + * agent loop ("Let me check…", tool plans, etc.). Joining all of them produced + * 50k+ character issue dumps. Prefer the last non-empty assistant content — + * typically the final board-facing wrap-up after the last tool turn. + */ +export function buildKimiRunSummary(assistantContents: string[]): string { + for (let i = assistantContents.length - 1; i >= 0; i -= 1) { + const text = (assistantContents[i] ?? "").trim(); + if (text) return text; + } + return ""; +} + +/** + * Parse `kimi -p ... --output-format stream-json` stdout. + * + * Verified event shapes (kimi 0.27.0): + * - {"role":"assistant","content":"..."} + * - {"role":"assistant","tool_calls":[{"type":"function","id":"...","function":{"name":"...","arguments":"{...}"}}]} + * - {"role":"tool","tool_call_id":"...","content":"..."} + * - {"role":"meta","type":"session.resume_hint","session_id":"...","command":"kimi -r ...","content":"..."} + */ +export function parseKimiJsonl(stdout: string): ParsedKimiJsonl { + let sessionId: string | null = null; + let errorMessage: string | null = null; + const textParts: string[] = []; + const toolCalls: ParsedKimiToolCall[] = []; + const toolResults: ParsedKimiToolResult[] = []; + + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + + const event = parseJson(line); + if (!event) continue; + + const role = asString(event.role, "").trim().toLowerCase(); + + if (role === "assistant") { + const content = asString(event.content, "").trim(); + if (content) textParts.push(content); + const calls = Array.isArray(event.tool_calls) ? event.tool_calls : []; + for (const callRaw of calls) { + const call = parseObject(callRaw); + const fn = parseObject(call.function); + const name = asString(fn.name, asString(call.name, "")).trim(); + if (!name) continue; + toolCalls.push({ + id: asString(call.id, "").trim() || null, + name, + arguments: parseToolCallArguments(fn.arguments ?? call.arguments), + }); + } + continue; + } + + if (role === "tool") { + toolResults.push({ + toolCallId: asString(event.tool_call_id, "").trim() || null, + content: asString(event.content, ""), + }); + continue; + } + + if (role === "meta") { + const type = asString(event.type, "").trim(); + if (type === "session.resume_hint") { + sessionId = asString(event.session_id, "").trim() || sessionId; + } + continue; + } + + // Defensive: kimi has no dedicated error event in the verified schema, but + // tolerate {"role":"error"} / {"type":"error"} lines if they ever appear. + const type = asString(event.type, "").trim().toLowerCase(); + if (role === "error" || type === "error") { + const text = errorText(event.error ?? event.message ?? event.content ?? event.detail).trim(); + if (text) errorMessage = text; + } + } + + return { + sessionId, + summary: buildKimiRunSummary(textParts), + toolCalls, + toolResults, + errorMessage, + }; +} + +export interface KimiRuntimeEvent { + eventType: string; + message?: string; + payload?: Record; +} + +/** + * Map a single `kimi -p ... --output-format stream-json` line to live runtime + * events for `onEvent`, which drive the issue-thread activity indicator + * (`currentToolName` / `lastAssistantSnippet` / `lastEventAt`). + * + * Kimi emits complete messages in bursts with no token-level streaming, so + * without this the issue thread never sees a tool name or assistant snippet and + * sits on a stale "no output for N s" line while the model thinks. Tool results + * are intentionally omitted so the last meaningful "Using X" / assistant snippet + * is not overwritten by a generic label; the raw run-log stream keeps the + * activity timer fresh across tool execution. + */ +export function extractKimiRuntimeEvents(line: string): KimiRuntimeEvent[] { + const event = parseJson(line); + if (!event) return []; + const role = asString(event.role, "").trim().toLowerCase(); + if (role !== "assistant") return []; + + const events: KimiRuntimeEvent[] = []; + const content = asString(event.content, "").trim(); + if (content) { + events.push({ eventType: "assistant", message: content, payload: { content } }); + } + const calls = Array.isArray(event.tool_calls) ? event.tool_calls : []; + for (const callRaw of calls) { + const call = parseObject(callRaw); + const fn = parseObject(call.function); + const name = asString(fn.name, asString(call.name, "")).trim(); + if (name) { + events.push({ eventType: "tool_call", payload: { toolName: name } }); + } + } + return events; +} + +function normalizedHaystack(stdout: string, stderr: string): string { + return `${stdout}\n${stderr}` + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n"); +} + +export function isKimiSessionUnrecoverableError(stdout: string, stderr: string): boolean { + return /unknown\s+session|session(?:\s+.*)?\s+not\s+found|resume\s+.*\s+not\s+found|cannot\s+resume|failed\s+to\s+resume|invalid\s+session/i.test( + normalizedHaystack(stdout, stderr), + ); +} + +export function isKimiTransientNetworkError(stdout: string, stderr: string): boolean { + return /ENOTFOUND|EAI_AGAIN|ECONNRESET|ECONNREFUSED|ETIMEDOUT|fetch\s+failed|socket\s+hang\s+up/i.test( + normalizedHaystack(stdout, stderr), + ); +} + +export function describeKimiFailure(input: { + errorMessage?: string | null; + stderr?: string; +}): string | null { + const detail = + (typeof input.errorMessage === "string" ? input.errorMessage.trim() : "") || + (input.stderr ?? "") + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) || + ""; + if (!detail) return null; + const clean = detail.replace(/\s+/g, " ").trim(); + const max = 240; + return `Kimi run failed: ${clean.length > max ? `${clean.slice(0, max - 1)}…` : clean}`; +} + +const KIMI_AUTH_REQUIRED_RE = + /(?:\bkimi\s+login\b|\blogin\s+required\b|not\s+(?:logged\s+in|authenticated)|\b401\b|unauthorized|device\s+code|authentication\s+(?:required|failed)|invalid\s+api[_ ]?key)/i; + +export function detectKimiAuthRequired(input: { + stdout: string; + stderr: string; +}): { requiresAuth: boolean } { + const requiresAuth = normalizedHaystack(input.stdout, input.stderr) + .split(/\r?\n/) + .some((line) => KIMI_AUTH_REQUIRED_RE.test(line)); + return { requiresAuth }; +} diff --git a/packages/adapters/kimi-local/src/server/skills.ts b/packages/adapters/kimi-local/src/server/skills.ts new file mode 100644 index 0000000000..5938b96315 --- /dev/null +++ b/packages/adapters/kimi-local/src/server/skills.ts @@ -0,0 +1,87 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + AdapterSkillContext, + AdapterSkillSnapshot, +} from "@paperclipai/adapter-utils"; +import { + buildPersistentSkillSnapshot, + ensurePaperclipSkillSymlink, + readPaperclipRuntimeSkillEntries, + readInstalledSkillTargets, + resolvePaperclipDesiredSkillNames, +} from "@paperclipai/adapter-utils/server-utils"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +/** + * Resolve the Kimi skills home, honoring KIMI_CODE_HOME (adapter config env + * first, then the server process env) and falling back to ~/.kimi-code/skills. + */ +function resolveKimiSkillsHome(config: Record) { + const env = + typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) + ? (config.env as Record) + : {}; + const kimiCodeHome = asString(env.KIMI_CODE_HOME) ?? asString(process.env.KIMI_CODE_HOME); + if (kimiCodeHome) return path.join(path.resolve(kimiCodeHome), "skills"); + const configuredHome = asString(env.HOME); + const home = configuredHome ? path.resolve(configuredHome) : os.homedir(); + return path.join(home, ".kimi-code", "skills"); +} + +async function buildKimiSkillSnapshot(config: Record): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const skillsHome = resolveKimiSkillsHome(config); + const installed = await readInstalledSkillTargets(skillsHome); + return buildPersistentSkillSnapshot({ + adapterType: "kimi_local", + availableEntries, + desiredSkills, + installed, + skillsHome, + locationLabel: "~/.kimi-code/skills", + missingDetail: "Configured but not currently linked into the Kimi skills home.", + externalConflictDetail: "Skill name is occupied by an external installation.", + externalDetail: "Installed outside Paperclip management.", + }); +} + +export async function listKimiSkills(ctx: AdapterSkillContext): Promise { + return buildKimiSkillSnapshot(ctx.config); +} + +export async function syncKimiSkills( + ctx: AdapterSkillContext, + desiredSkills: string[], +): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); + const desiredSet = new Set(desiredSkills); + const skillsHome = resolveKimiSkillsHome(ctx.config); + await fs.mkdir(skillsHome, { recursive: true }); + const installed = await readInstalledSkillTargets(skillsHome); + const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry])); + + for (const available of availableEntries) { + if (!desiredSet.has(available.key)) continue; + const target = path.join(skillsHome, available.runtimeName); + await ensurePaperclipSkillSymlink(available.source, target); + } + + for (const [name, installedEntry] of installed.entries()) { + const available = availableByRuntimeName.get(name); + if (!available) continue; + if (desiredSet.has(available.key)) continue; + if (installedEntry.targetPath !== available.source) continue; + await fs.unlink(path.join(skillsHome, name)).catch(() => {}); + } + + return buildKimiSkillSnapshot(ctx.config); +} diff --git a/packages/adapters/kimi-local/src/server/test.test.ts b/packages/adapters/kimi-local/src/server/test.test.ts new file mode 100644 index 0000000000..b6e96bf53f --- /dev/null +++ b/packages/adapters/kimi-local/src/server/test.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +const ensureDirectoryMock = vi.hoisted(() => vi.fn(async () => {})); +const ensureCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const maybeInstallMock = vi.hoisted(() => vi.fn(async () => null)); +const runProcessMock = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ + describeAdapterExecutionTarget: () => "local", + ensureAdapterExecutionTargetCommandResolvable: ensureCommandMock, + ensureAdapterExecutionTargetDirectory: ensureDirectoryMock, + maybeRunSandboxInstallCommand: maybeInstallMock, + resolveAdapterExecutionTargetCwd: (_target: unknown, configuredCwd: string, fallbackCwd: string) => + configuredCwd || fallbackCwd, + runAdapterExecutionTargetProcess: runProcessMock, +})); + +import { testEnvironment } from "./test.js"; + +describe("kimi_local testEnvironment", () => { + beforeEach(() => { + ensureDirectoryMock.mockClear(); + ensureCommandMock.mockClear(); + maybeInstallMock.mockClear(); + runProcessMock.mockReset(); + // Keep auth detection deterministic on hosts that really have kimi set up. + vi.stubEnv("KIMI_MODEL_NAME", ""); + vi.stubEnv("KIMI_MODEL_API_KEY", ""); + vi.stubEnv("KIMI_CODE_HOME", ""); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("reports a healthy host with a working version and hello probe", async () => { + runProcessMock + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "kimi 0.27.0\n", + stderr: "", + }) + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ role: "assistant", content: "hello" }), + JSON.stringify({ role: "meta", type: "session.resume_hint", session_id: "session_1" }), + ].join("\n"), + stderr: "", + }); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "kimi_local", + config: { + engine: "cli", + command: "kimi", + cwd: "/tmp/project", + env: { + KIMI_MODEL_NAME: "kimi-code/kimi-for-coding", + KIMI_MODEL_API_KEY: "test-key", + }, + }, + }); + + expect(result.status).toBe("pass"); + expect(result.checks.map((check: { code: string }) => check.code)).toEqual( + expect.arrayContaining([ + "kimi_command_resolvable", + "kimi_version_detected", + "kimi_auth_detected", + "kimi_hello_probe_passed", + ]), + ); + expect(runProcessMock).toHaveBeenNthCalledWith( + 1, + expect.any(String), + null, + "kimi", + ["--version"], + expect.any(Object), + ); + expect(runProcessMock).toHaveBeenNthCalledWith( + 2, + expect.any(String), + null, + "kimi", + expect.arrayContaining([ + "--output-format", + "stream-json", + "-p", + "Respond with hello.", + ]), + expect.any(Object), + ); + }); + + it("passes -m to the hello probe when a model is configured", async () => { + runProcessMock + .mockResolvedValueOnce({ exitCode: 0, signal: null, timedOut: false, stdout: "kimi 0.27.0\n", stderr: "" }) + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: JSON.stringify({ role: "assistant", content: "hello" }), + stderr: "", + }); + + await testEnvironment({ + companyId: "company-1", + adapterType: "kimi_local", + config: { + engine: "cli", + command: "kimi", + cwd: "/tmp/project", + model: "kimi-code/k3", + env: { KIMI_MODEL_NAME: "kimi-code/k3", KIMI_MODEL_API_KEY: "test-key" }, + }, + }); + + expect(runProcessMock).toHaveBeenNthCalledWith( + 2, + expect.any(String), + null, + "kimi", + expect.arrayContaining(["-m", "kimi-code/k3"]), + expect.any(Object), + ); + }); + + it("downgrades missing auth and auth probe failures to warnings", async () => { + runProcessMock + .mockResolvedValueOnce({ exitCode: 0, signal: null, timedOut: false, stdout: "kimi 0.27.0\n", stderr: "" }) + .mockResolvedValueOnce({ + exitCode: 1, + signal: null, + timedOut: false, + stdout: "", + stderr: "Not authenticated. Run `kimi login` to authenticate with a device code.", + }); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "kimi_local", + config: { + engine: "cli", + command: "kimi", + cwd: "/tmp/project", + env: { KIMI_CODE_HOME: "/nonexistent-kimi-home-for-test" }, + }, + }); + + expect(result.status).toBe("warn"); + expect(result.checks.map((check: { code: string }) => check.code)).toEqual( + expect.arrayContaining([ + "kimi_auth_missing", + "kimi_hello_probe_auth_required", + ]), + ); + }); +}); diff --git a/packages/adapters/kimi-local/src/server/test.ts b/packages/adapters/kimi-local/src/server/test.ts new file mode 100644 index 0000000000..551194d655 --- /dev/null +++ b/packages/adapters/kimi-local/src/server/test.ts @@ -0,0 +1,342 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { + asNumber, + asString, + asStringArray, + ensurePathInEnv, + parseObject, +} from "@paperclipai/adapter-utils/server-utils"; +import { + ensureAdapterExecutionTargetCommandResolvable, + maybeRunSandboxInstallCommand, + ensureAdapterExecutionTargetDirectory, + runAdapterExecutionTargetProcess, + describeAdapterExecutionTarget, + resolveAdapterExecutionTargetCwd, +} from "@paperclipai/adapter-utils/execution-target"; +import { SANDBOX_INSTALL_COMMAND } from "../index.js"; +import { resolveKimiExecutionEngineForRun, testKimiAcpEnvironment } from "./acp.js"; +import { detectKimiAuthRequired, parseKimiJsonl } from "./parse.js"; +import { firstNonEmptyLine } from "./utils.js"; + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function isNonEmpty(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function commandLooksLike(command: string, expected: string): boolean { + const base = path.basename(command).toLowerCase(); + return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`; +} + +function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null { + const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout); + if (!raw) return null; + const clean = raw.replace(/\s+/g, " ").trim(); + const max = 240; + return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean; +} + +async function pathExists(candidate: string): Promise { + return fs.access(candidate).then(() => true).catch(() => false); +} + +/** + * config.toml exists on any configured install and is not by itself proof of + * auth material. Only treat it as such when a [providers.*] table carries a + * non-empty api_key (direct API-key auth) — the OAuth dirs are checked + * separately. + */ +const PROVIDERS_TABLE_RE = /^\s*\[providers\.[^\]]+\]/m; +const PROVIDER_API_KEY_RE = /^\s*api_key\s*=\s*"[^"]+"/m; + +async function configTomlHasProviderKey(configPath: string): Promise { + try { + const contents = await fs.readFile(configPath, "utf8"); + return PROVIDERS_TABLE_RE.test(contents) && PROVIDER_API_KEY_RE.test(contents); + } catch { + return false; + } +} + +/** + * Detect Kimi authentication material on the local host: OAuth credentials + * under $KIMI_CODE_HOME (default ~/.kimi-code) or a config.toml with a keyed + * provider, plus the KIMI_MODEL_NAME + KIMI_MODEL_API_KEY env pair (config + * env or server env). + */ +async function detectLocalKimiAuth(env: Record): Promise { + if ( + (isNonEmpty(env.KIMI_MODEL_NAME) || isNonEmpty(process.env.KIMI_MODEL_NAME)) && + (isNonEmpty(env.KIMI_MODEL_API_KEY) || isNonEmpty(process.env.KIMI_MODEL_API_KEY)) + ) { + return "KIMI_MODEL_NAME + KIMI_MODEL_API_KEY environment"; + } + const kimiCodeHome = + (isNonEmpty(env.KIMI_CODE_HOME) && env.KIMI_CODE_HOME.trim()) || + (isNonEmpty(process.env.KIMI_CODE_HOME) && process.env.KIMI_CODE_HOME.trim()) || + path.join(os.homedir(), ".kimi-code"); + for (const candidate of [path.join(kimiCodeHome, "credentials"), path.join(kimiCodeHome, "oauth")]) { + if (await pathExists(candidate)) { + return `${candidate} (kimi login OAuth)`; + } + } + const configPath = path.join(kimiCodeHome, "config.toml"); + if (await configTomlHasProviderKey(configPath)) { + return `${configPath} ([providers.*] api_key)`; + } + return null; +} + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const engineSelection = await resolveKimiExecutionEngineForRun({ + config: parseObject(ctx.config), + executionTarget: ctx.executionTarget, + }); + if (engineSelection.engine === "acp") { + return testKimiAcpEnvironment(ctx); + } + + const checks: AdapterEnvironmentCheck[] = []; + if (!engineSelection.explicit && engineSelection.fallbackReason) { + checks.push({ + code: "kimi_acp_default_fallback", + level: "warn", + message: "Kimi ACP default is unavailable; testing the Kimi CLI fallback lane.", + detail: engineSelection.fallbackReason, + hint: "Fix the ACP prerequisite to use the default ACP lane, or set engine=cli to pin the CLI lane.", + }); + } + const config = parseObject(ctx.config); + const command = asString(config.command, "kimi"); + const target = ctx.executionTarget ?? null; + const targetIsRemote = target?.kind === "remote"; + const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd()); + const targetLabel = targetIsRemote + ? ctx.environmentName ?? describeAdapterExecutionTarget(target) + : null; + const runId = `kimi-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`; + + if (targetLabel) { + checks.push({ + code: "kimi_environment_target", + level: "info", + message: `Probing inside environment: ${targetLabel}`, + }); + } + + try { + await ensureAdapterExecutionTargetDirectory(runId, target, cwd, { + cwd, + env: {}, + createIfMissing: true, + }); + checks.push({ + code: "kimi_cwd_valid", + level: "info", + message: `Working directory is valid: ${cwd}`, + }); + } catch (err) { + checks.push({ + code: "kimi_cwd_invalid", + level: "error", + message: err instanceof Error ? err.message : "Invalid working directory", + detail: cwd, + }); + } + + const envConfig = parseObject(config.env); + const env: Record = {}; + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; + } + const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + const installCheck = await maybeRunSandboxInstallCommand({ + runId, + target, + adapterKey: "kimi", + installCommand: SANDBOX_INSTALL_COMMAND, + detectCommand: command, + env, + }); + if (installCheck) checks.push(installCheck); + try { + await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv); + checks.push({ + code: "kimi_command_resolvable", + level: "info", + message: `Command is executable: ${command}`, + }); + } catch (err) { + checks.push({ + code: "kimi_command_unresolvable", + level: "error", + message: err instanceof Error ? err.message : "Command is not executable", + detail: command, + }); + } + + const canRunProbe = + checks.every((check) => check.code !== "kimi_cwd_invalid" && check.code !== "kimi_command_unresolvable"); + + if (canRunProbe && commandLooksLike(command, "kimi")) { + const versionProbe = await runAdapterExecutionTargetProcess( + runId, + target, + command, + ["--version"], + { + cwd, + env, + timeoutSec: 15, + graceSec: 5, + onLog: async () => {}, + }, + ); + const versionLine = firstNonEmptyLine(versionProbe.stdout) || firstNonEmptyLine(versionProbe.stderr); + if (!versionProbe.timedOut && (versionProbe.exitCode ?? 1) === 0) { + checks.push({ + code: "kimi_version_detected", + level: "info", + message: `Kimi CLI detected${versionLine ? `: ${versionLine.replace(/\s+/g, " ").trim().slice(0, 120)}` : "."}`, + }); + } else { + checks.push({ + code: "kimi_version_probe_failed", + level: "warn", + message: versionProbe.timedOut + ? "`kimi --version` timed out." + : "`kimi --version` did not exit cleanly.", + ...(versionLine ? { detail: versionLine } : {}), + }); + } + } + + const authSource = targetIsRemote + ? ((isNonEmpty(env.KIMI_MODEL_NAME) && isNonEmpty(env.KIMI_MODEL_API_KEY)) + ? "KIMI_MODEL_NAME + KIMI_MODEL_API_KEY adapter env" + : null) + : await detectLocalKimiAuth(env); + if (authSource) { + checks.push({ + code: "kimi_auth_detected", + level: "info", + message: "Kimi authentication material detected.", + detail: `Source: ${authSource}.`, + }); + } else { + checks.push({ + code: "kimi_auth_missing", + level: "warn", + message: "No Kimi authentication detected.", + hint: "Run `kimi login` (OAuth device flow) on the target host, or set KIMI_MODEL_NAME + KIMI_MODEL_API_KEY in the adapter env.", + }); + } + + if (canRunProbe) { + if (!commandLooksLike(command, "kimi")) { + checks.push({ + code: "kimi_hello_probe_skipped_custom_command", + level: "info", + message: "Skipped hello probe because command is not `kimi`.", + detail: command, + hint: "Use the `kimi` CLI command to run the automatic installation and auth probe.", + }); + } else { + const model = asString(config.model, "").trim(); + const helloProbeTimeoutSec = Math.max(1, asNumber(config.helloProbeTimeoutSec, 60)); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + + const args = ["--output-format", "stream-json"]; + if (model) args.push("-m", model); + if (extraArgs.length > 0) args.push(...extraArgs); + args.push("-p", "Respond with hello."); + + const probe = await runAdapterExecutionTargetProcess( + runId, + target, + command, + args, + { + cwd, + env, + timeoutSec: helloProbeTimeoutSec, + graceSec: 5, + onLog: async () => {}, + }, + ); + const parsed = parseKimiJsonl(probe.stdout); + const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage); + const authMeta = detectKimiAuthRequired({ + stdout: probe.stdout, + stderr: probe.stderr, + }); + + if (probe.timedOut) { + checks.push({ + code: "kimi_hello_probe_timed_out", + level: "warn", + message: "Kimi hello probe timed out.", + hint: "Retry the probe. If this persists, verify Kimi can run `kimi -p \"Respond with hello.\"` from this directory manually.", + }); + } else if ((probe.exitCode ?? 1) === 0) { + const summary = parsed.summary.trim(); + const hasHello = /\bhello\b/i.test(summary); + checks.push({ + code: hasHello ? "kimi_hello_probe_passed" : "kimi_hello_probe_unexpected_output", + level: hasHello ? "info" : "warn", + message: hasHello + ? "Kimi hello probe succeeded." + : "Kimi probe ran but did not return `hello` as expected.", + ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}), + ...(hasHello + ? {} + : { + hint: "Try `kimi -p \"Respond with hello.\" --output-format stream-json` manually to inspect full output.", + }), + }); + } else if (authMeta.requiresAuth) { + checks.push({ + code: "kimi_hello_probe_auth_required", + level: "warn", + message: "Kimi CLI is installed, but authentication is not ready.", + ...(detail ? { detail } : {}), + hint: "Run `kimi login` or configure KIMI_MODEL_NAME + KIMI_MODEL_API_KEY in adapter env/shell, then retry the probe.", + }); + } else { + checks.push({ + code: "kimi_hello_probe_failed", + level: "error", + message: "Kimi hello probe failed.", + ...(detail ? { detail } : {}), + hint: "Run `kimi -p \"Respond with hello.\" --output-format stream-json` manually in this working directory to debug.", + }); + } + } + } + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/kimi-local/src/server/utils.ts b/packages/adapters/kimi-local/src/server/utils.ts new file mode 100644 index 0000000000..a4fdc6a39b --- /dev/null +++ b/packages/adapters/kimi-local/src/server/utils.ts @@ -0,0 +1,8 @@ +export function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} diff --git a/packages/adapters/kimi-local/src/ui/build-config.test.ts b/packages/adapters/kimi-local/src/ui/build-config.test.ts new file mode 100644 index 0000000000..5c1ee34e21 --- /dev/null +++ b/packages/adapters/kimi-local/src/ui/build-config.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; +import { buildKimiLocalConfig } from "./build-config.js"; + +function makeValues(overrides: Partial = {}): CreateConfigValues { + return { + adapterType: "kimi_local", + cwd: "", + instructionsFilePath: "", + promptTemplate: "", + model: "", + thinkingEffort: "", + chrome: false, + dangerouslySkipPermissions: true, + search: false, + fastMode: false, + dangerouslyBypassSandbox: false, + command: "", + args: "", + extraArgs: "", + envVars: "", + envBindings: {}, + url: "", + bootstrapPrompt: "", + payloadTemplateJson: "", + workspaceStrategyType: "project_primary", + workspaceBaseRef: "", + workspaceBranchTemplate: "", + worktreeParentDir: "", + runtimeServicesJson: "", + maxTurnsPerRun: 1000, + heartbeatEnabled: false, + intervalSec: 300, + ...overrides, + }; +} + +describe("buildKimiLocalConfig", () => { + it("defaults the model to kimi-code/kimi-for-coding when unset", () => { + const config = buildKimiLocalConfig(makeValues()); + + expect(config.model).toBe("kimi-code/kimi-for-coding"); + expect(config.timeoutSec).toBe(0); + expect(config.graceSec).toBe(15); + }); + + it("persists an explicit model and command", () => { + const config = buildKimiLocalConfig(makeValues({ + model: "kimi-code/k3", + command: "/usr/local/bin/kimi", + })); + + expect(config.model).toBe("kimi-code/k3"); + expect(config.command).toBe("/usr/local/bin/kimi"); + }); + + it("persists cwd, instructionsFilePath, and extra args", () => { + const config = buildKimiLocalConfig(makeValues({ + cwd: "/tmp/project", + instructionsFilePath: "/tmp/project/AGENTS.md", + extraArgs: "--add-dir /tmp/other, --plan", + })); + + expect(config.cwd).toBe("/tmp/project"); + expect(config.instructionsFilePath).toBe("/tmp/project/AGENTS.md"); + expect(config.extraArgs).toEqual(["--add-dir /tmp/other", "--plan"]); + }); + + it("merges legacy env vars with secret bindings", () => { + const config = buildKimiLocalConfig(makeValues({ + envVars: "KIMI_MODEL_NAME=kimi-code/k3\n# comment\nINVALID LINE", + envBindings: { + KIMI_MODEL_API_KEY: { type: "secret_ref", secretId: "secret-1" }, + }, + })); + + expect(config.env).toEqual({ + KIMI_MODEL_NAME: { type: "plain", value: "kimi-code/k3" }, + KIMI_MODEL_API_KEY: { type: "secret_ref", secretId: "secret-1" }, + }); + }); +}); diff --git a/packages/adapters/kimi-local/src/ui/build-config.ts b/packages/adapters/kimi-local/src/ui/build-config.ts new file mode 100644 index 0000000000..dc51828c9e --- /dev/null +++ b/packages/adapters/kimi-local/src/ui/build-config.ts @@ -0,0 +1,73 @@ +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; +import { DEFAULT_KIMI_LOCAL_MODEL } from "../index.js"; + +function parseCommaArgs(value: string): string[] { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseEnvVars(text: string): Record { + const env: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + env[key] = value; + } + return env; +} + +function parseEnvBindings(bindings: unknown): Record { + if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; + const env: Record = {}; + for (const [key, raw] of Object.entries(bindings)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (typeof raw === "string") { + env[key] = { type: "plain", value: raw }; + continue; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue; + const rec = raw as Record; + if (rec.type === "plain" && typeof rec.value === "string") { + env[key] = { type: "plain", value: rec.value }; + continue; + } + if (rec.type === "secret_ref" && typeof rec.secretId === "string") { + env[key] = { + type: "secret_ref", + secretId: rec.secretId, + ...(typeof rec.version === "number" || rec.version === "latest" + ? { version: rec.version } + : {}), + }; + } + } + return env; +} + +export function buildKimiLocalConfig(v: CreateConfigValues): Record { + const ac: Record = {}; + if (v.cwd) ac.cwd = v.cwd; + if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath; + ac.model = v.model || DEFAULT_KIMI_LOCAL_MODEL; + ac.timeoutSec = 0; + ac.graceSec = 15; + const env = parseEnvBindings(v.envBindings); + const legacy = parseEnvVars(v.envVars); + for (const [key, value] of Object.entries(legacy)) { + if (!Object.prototype.hasOwnProperty.call(env, key)) { + env[key] = { type: "plain", value }; + } + } + if (Object.keys(env).length > 0) ac.env = env; + + if (v.command) ac.command = v.command; + if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); + return ac; +} diff --git a/packages/adapters/kimi-local/src/ui/index.ts b/packages/adapters/kimi-local/src/ui/index.ts new file mode 100644 index 0000000000..ce2757e132 --- /dev/null +++ b/packages/adapters/kimi-local/src/ui/index.ts @@ -0,0 +1,2 @@ +export { parseKimiStdoutLine } from "./parse-stdout.js"; +export { buildKimiLocalConfig } from "./build-config.js"; diff --git a/packages/adapters/kimi-local/src/ui/parse-stdout.test.ts b/packages/adapters/kimi-local/src/ui/parse-stdout.test.ts new file mode 100644 index 0000000000..d071bb1d1e --- /dev/null +++ b/packages/adapters/kimi-local/src/ui/parse-stdout.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { parseKimiStdoutLine } from "./parse-stdout.js"; + +const ts = "2026-07-19T00:00:00.000Z"; + +describe("parseKimiStdoutLine ACP delegation", () => { + it("delegates acpx.* events to the shared acpx transcript parser", () => { + const line = JSON.stringify({ type: "acpx.tool_call", name: "Terminal", status: "pending", text: "Terminal (pending)" }); + const entries = parseKimiStdoutLine(line, ts); + // The shared parser produces a structured entry, not the raw stdout fallback. + expect(entries.length).toBeGreaterThan(0); + expect(entries.every((e) => e.kind !== "stdout")).toBe(true); + }); + + it("still parses native kimi role events (no acpx type)", () => { + const line = JSON.stringify({ role: "assistant", content: "hi" }); + expect(parseKimiStdoutLine(line, ts)).toEqual([{ kind: "assistant", ts, text: "hi" }]); + }); +}); + +describe("parseKimiStdoutLine", () => { + it("renders assistant content as an assistant transcript entry", () => { + const line = JSON.stringify({ + role: "assistant", + content: "PAPERCLIP_ADAPTER_TEST_OK", + }); + expect(parseKimiStdoutLine(line, ts)).toEqual([ + { kind: "assistant", ts, text: "PAPERCLIP_ADAPTER_TEST_OK" }, + ]); + }); + + it("renders assistant tool_calls with parsed arguments", () => { + const line = JSON.stringify({ + role: "assistant", + tool_calls: [{ + type: "function", + id: "tool_8c1OWyRBe68OMTbWY6NqnkMm", + function: { name: "Read", arguments: "{\"path\":\"probe.txt\"}" }, + }], + }); + expect(parseKimiStdoutLine(line, ts)).toEqual([ + { + kind: "tool_call", + ts, + name: "Read", + input: { path: "probe.txt" }, + toolUseId: "tool_8c1OWyRBe68OMTbWY6NqnkMm", + }, + ]); + }); + + it("renders tool results as tool_result entries", () => { + const line = JSON.stringify({ + role: "tool", + tool_call_id: "tool_8c1OWyRBe68OMTbWY6NqnkMm", + content: "1\thello paperclip", + }); + expect(parseKimiStdoutLine(line, ts)).toEqual([ + { + kind: "tool_result", + ts, + toolUseId: "tool_8c1OWyRBe68OMTbWY6NqnkMm", + content: "1\thello paperclip", + isError: false, + }, + ]); + }); + + it("surfaces the meta session resume hint as session info", () => { + const line = JSON.stringify({ + role: "meta", + type: "session.resume_hint", + session_id: "session_769ddab9-0a25-4edd-99f4-cdfebdc90879", + command: "kimi -r session_769ddab9-0a25-4edd-99f4-cdfebdc90879", + content: "To resume this session: kimi -r session_769ddab9-0a25-4edd-99f4-cdfebdc90879", + }); + expect(parseKimiStdoutLine(line, ts)).toEqual([ + { kind: "system", ts, text: "session: session_769ddab9-0a25-4edd-99f4-cdfebdc90879" }, + ]); + }); + + it("ignores unrecognized meta lines", () => { + const line = JSON.stringify({ role: "meta", type: "progress", content: "working" }); + expect(parseKimiStdoutLine(line, ts)).toEqual([]); + }); + + it("passes non-JSON lines through as stdout", () => { + expect(parseKimiStdoutLine("plain output line", ts)).toEqual([ + { kind: "stdout", ts, text: "plain output line" }, + ]); + }); +}); diff --git a/packages/adapters/kimi-local/src/ui/parse-stdout.ts b/packages/adapters/kimi-local/src/ui/parse-stdout.ts new file mode 100644 index 0000000000..279b73bb15 --- /dev/null +++ b/packages/adapters/kimi-local/src/ui/parse-stdout.ts @@ -0,0 +1,114 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; +import { parseAcpxStdoutLine } from "@paperclipai/adapter-utils/acpx-engine/ui"; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function stringifyUnknown(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function parseToolCallArguments(value: unknown): unknown { + if (typeof value !== "string") return value ?? {}; + const trimmed = value.trim(); + if (!trimmed) return {}; + try { + return JSON.parse(trimmed); + } catch { + return value; + } +} + +/** + * Map one `kimi -p ... --output-format stream-json` stdout line to transcript + * entries. Verified shapes (kimi 0.27.0): assistant content, assistant + * tool_calls (arguments is a JSON-encoded string), tool results, and a + * trailing meta session.resume_hint event. + */ +export function parseKimiStdoutLine(line: string, ts: string): TranscriptEntry[] { + const parsed = asRecord(safeJsonParse(line)); + if (!parsed) { + return [{ kind: "stdout", ts, text: line }]; + } + + // ACP-lane runs emit acpx.* events (streaming text deltas, tool-call status + // lifecycle); delegate those to the shared acpx transcript parser. + if (asString(parsed.type).startsWith("acpx.")) { + return parseAcpxStdoutLine(line, ts); + } + + const role = asString(parsed.role).trim().toLowerCase(); + + if (role === "assistant") { + const entries: TranscriptEntry[] = []; + const content = asString(parsed.content).trim(); + if (content) entries.push({ kind: "assistant", ts, text: content }); + const toolCalls = Array.isArray(parsed.tool_calls) ? parsed.tool_calls : []; + for (const callRaw of toolCalls) { + const call = asRecord(callRaw); + if (!call) continue; + const fn = asRecord(call.function); + const name = asString(fn?.name, asString(call.name, "tool")).trim() || "tool"; + entries.push({ + kind: "tool_call", + ts, + name, + input: parseToolCallArguments(fn?.arguments ?? call.arguments), + toolUseId: asString(call.id).trim() || undefined, + }); + } + return entries; + } + + if (role === "tool") { + const toolUseId = asString(parsed.tool_call_id).trim() || "tool_result"; + const content = asString(parsed.content) || stringifyUnknown(parsed.content); + return [{ + kind: "tool_result", + ts, + toolUseId, + content, + isError: false, + }]; + } + + if (role === "meta") { + const type = asString(parsed.type).trim(); + if (type === "session.resume_hint") { + const sessionId = asString(parsed.session_id).trim(); + return sessionId ? [{ kind: "system", ts, text: `session: ${sessionId}` }] : []; + } + return []; + } + + if (role === "error" || asString(parsed.type).trim().toLowerCase() === "error") { + const text = + asString(parsed.content) || + asString(parsed.message) || + asString(parsed.error) || + "Kimi error"; + return [{ kind: "stderr", ts, text }]; + } + + return [{ kind: "stdout", ts, text: line }]; +} diff --git a/packages/adapters/kimi-local/tsconfig.json b/packages/adapters/kimi-local/tsconfig.json new file mode 100644 index 0000000000..2f355cfeeb --- /dev/null +++ b/packages/adapters/kimi-local/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/adapters/kimi-local/vitest.config.ts b/packages/adapters/kimi-local/vitest.config.ts new file mode 100644 index 0000000000..f624398e8d --- /dev/null +++ b/packages/adapters/kimi-local/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index d466081235..4b8aba6f25 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -37,6 +37,7 @@ export const AGENT_ADAPTER_TYPES = [ "grok_local", "hermes_gateway", "hermes_local", + "kimi_local", "opencode_local", "pi_local", "cursor", diff --git a/packages/shared/src/environment-support.test.ts b/packages/shared/src/environment-support.test.ts index 0a2c72b364..94926016ae 100644 --- a/packages/shared/src/environment-support.test.ts +++ b/packages/shared/src/environment-support.test.ts @@ -42,6 +42,30 @@ describe("isSandboxProviderSupportedForAdapter", () => { }), ]); }); + + it("treats kimi_local as a remote-managed local adapter", () => { + expect(adapterSupportsRemoteManagedEnvironments("kimi_local")).toBe(true); + expect(supportedEnvironmentDriversForAdapter("kimi_local")).toEqual(["local", "ssh", "sandbox"]); + expect( + isSandboxProviderSupportedForAdapter("kimi_local", "fake-plugin", ["fake-plugin"]), + ).toBe(true); + }); + + it("includes kimi_local sandbox support in environment capabilities", () => { + const capabilities = getEnvironmentCapabilities(["kimi_local"], { + sandboxProviders: { + "fake-plugin": { displayName: "Fake Plugin" }, + }, + }); + + expect(capabilities.adapters).toEqual([ + expect.objectContaining({ + adapterType: "kimi_local", + drivers: expect.objectContaining({ sandbox: "supported", ssh: "supported" }), + sandboxProviders: expect.objectContaining({ "fake-plugin": "supported" }), + }), + ]); + }); }); describe("getEnvironmentCapabilities reusable leases default", () => { diff --git a/packages/shared/src/environment-support.ts b/packages/shared/src/environment-support.ts index e08e32d76c..5bb5a076ea 100644 --- a/packages/shared/src/environment-support.ts +++ b/packages/shared/src/environment-support.ts @@ -70,6 +70,7 @@ const REMOTE_MANAGED_ADAPTERS = new Set([ "cursor", "gemini_local", "grok_local", + "kimi_local", "opencode_local", "pi_local", ]); diff --git a/packages/shared/src/telemetry/generated/paperclip-telemetry.ts b/packages/shared/src/telemetry/generated/paperclip-telemetry.ts index 281b657da9..32e9e07576 100644 --- a/packages/shared/src/telemetry/generated/paperclip-telemetry.ts +++ b/packages/shared/src/telemetry/generated/paperclip-telemetry.ts @@ -11,7 +11,7 @@ agent_role: ("ceo" | "cto" | "cmo" | "cfo" | "security" | "engineer" | "designer } export interface PaperclipAgentTaskCompletedDimensions { -adapter_type: ("process" | "http" | "acpx_local" | "claude_local" | "codex_local" | "cursor_cloud" | "gemini_local" | "hermes_gateway" | "hermes_local" | "opencode_local" | "pi_local" | "cursor" | "openclaw_gateway" | "grok_local" | "other") +adapter_type: ("process" | "http" | "acpx_local" | "claude_local" | "codex_local" | "cursor_cloud" | "gemini_local" | "hermes_gateway" | "hermes_local" | "opencode_local" | "pi_local" | "cursor" | "openclaw_gateway" | "grok_local" | "kimi_local" | "other") agent_id: string agent_role: ("ceo" | "cto" | "cmo" | "cfo" | "security" | "engineer" | "designer" | "pm" | "qa" | "devops" | "researcher" | "general" | "other") model?: string @@ -32,7 +32,7 @@ goal_level: ("company" | "team" | "agent" | "task" | "other") } export interface PaperclipInstallCompletedDimensions { -adapter_type: ("process" | "http" | "acpx_local" | "claude_local" | "codex_local" | "cursor_cloud" | "gemini_local" | "hermes_gateway" | "hermes_local" | "opencode_local" | "pi_local" | "cursor" | "openclaw_gateway" | "grok_local" | "other") +adapter_type: ("process" | "http" | "acpx_local" | "claude_local" | "codex_local" | "cursor_cloud" | "gemini_local" | "hermes_gateway" | "hermes_local" | "opencode_local" | "pi_local" | "cursor" | "openclaw_gateway" | "grok_local" | "kimi_local" | "other") } export interface PaperclipInstallStartedDimensions { @@ -186,6 +186,7 @@ export const PAPERCLIP_ENUM_DESCRIPTIONS = { "cursor": "Agent runtime uses the Cursor adapter.", "openclaw_gateway": "Agent runtime uses the OpenClaw gateway adapter.", "grok_local": "Agent runtime uses the local Grok adapter.", + "kimi_local": "Agent runtime uses the local Kimi adapter.", "other": "Fallback when the adapter type is unknown or not represented by the tracked enum." }, "agent_role": { @@ -239,6 +240,7 @@ export const PAPERCLIP_ENUM_DESCRIPTIONS = { "cursor": "Agent runtime uses the Cursor adapter.", "openclaw_gateway": "Agent runtime uses the OpenClaw gateway adapter.", "grok_local": "Agent runtime uses the local Grok adapter.", + "kimi_local": "Agent runtime uses the local Kimi adapter.", "other": "Fallback when the adapter type is unknown or not represented by the tracked enum." } }, diff --git a/scripts/release-package-manifest.json b/scripts/release-package-manifest.json index cd061d627e..b13049d2bd 100644 --- a/scripts/release-package-manifest.json +++ b/scripts/release-package-manifest.json @@ -44,6 +44,11 @@ "name": "@paperclipai/adapter-hermes-gateway", "publishFromCi": false }, + { + "dir": "packages/adapters/kimi-local", + "name": "@paperclipai/adapter-kimi-local", + "publishFromCi": true + }, { "dir": "packages/adapters/opencode-local", "name": "@paperclipai/adapter-opencode-local", diff --git a/server/package.json b/server/package.json index 7098eeb6ed..bfb75db352 100644 --- a/server/package.json +++ b/server/package.json @@ -51,6 +51,7 @@ "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", "@paperclipai/adapter-grok-local": "workspace:*", + "@paperclipai/adapter-kimi-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index aaae9fc89e..7765859679 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -218,6 +218,24 @@ describe("adapter routes", () => { supportsAcp: false, }); + const kimiAdapter = res.body.find((a: any) => a.type === "kimi_local"); + expect(kimiAdapter).toBeDefined(); + expect(kimiAdapter.capabilities).toMatchObject({ + supportsInstructionsBundle: true, + supportsSkills: true, + supportsLocalAgentJwt: true, + requiresMaterializedRuntimeSkills: true, + supportsAcp: true, + }); + expect(kimiAdapter.acp).toMatchObject({ + agentId: "kimi", + skillsMode: "ephemeral", + prerequisites: { + nodeRange: ">=20.0.0", + packages: ["@moonshot-ai/kimi-code"], + }, + }); + const hermesLocal = res.body.find((a: any) => a.type === "hermes_local"); expect(hermesLocal).toBeDefined(); expect(hermesLocal.source).toBe("builtin"); diff --git a/server/src/__tests__/environment-execution-target.test.ts b/server/src/__tests__/environment-execution-target.test.ts index 662cf4c071..b9d96aabc3 100644 --- a/server/src/__tests__/environment-execution-target.test.ts +++ b/server/src/__tests__/environment-execution-target.test.ts @@ -258,13 +258,14 @@ describe("resolveEnvironmentExecutionTarget", () => { }); }); - it("resolves sandbox targets for every remote-managed adapter, including grok_local", async () => { + it("resolves sandbox targets for every remote-managed adapter, including grok_local and kimi_local", async () => { for (const adapterType of [ "claude_local", "codex_local", "cursor", "gemini_local", "grok_local", + "kimi_local", "opencode_local", "pi_local", ]) { @@ -358,6 +359,42 @@ describe("resolveEnvironmentExecutionTarget", () => { }); }); + it("resolves SSH execution targets for kimi_local", async () => { + mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({ + driver: "ssh", + config: { + host: "ssh.example.test", + port: 22, + username: "paperclip", + remoteWorkspacePath: "/srv/paperclip", + privateKey: "PRIVATE KEY", + knownHosts: "[ssh.example.test]:22 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }); + + const target = await resolveEnvironmentExecutionTarget({ + db: {} as never, + companyId: "company-1", + adapterType: "kimi_local", + environment: { + id: "env-ssh-1", + driver: "ssh", + config: {}, + }, + leaseId: "lease-ssh-1", + leaseMetadata: {}, + lease: null, + environmentRuntime: null, + }); + + expect(target).toMatchObject({ + kind: "remote", + transport: "ssh", + remoteCwd: "/srv/paperclip", + }); + }); + it("resolves SSH execution targets in bridge mode", async () => { mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({ driver: "ssh", diff --git a/server/src/__tests__/kimi-local-skill-sync.test.ts b/server/src/__tests__/kimi-local-skill-sync.test.ts new file mode 100644 index 0000000000..7bb904252a --- /dev/null +++ b/server/src/__tests__/kimi-local-skill-sync.test.ts @@ -0,0 +1,51 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + listKimiSkills, + syncKimiSkills, +} from "@paperclipai/adapter-kimi-local/server"; + +async function makeTempDir(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +describe("kimi local skill sync", () => { + const paperclipKey = "paperclipai/paperclip/paperclip"; + const cleanupDirs = new Set(); + + afterEach(async () => { + await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); + }); + + it("reports configured Paperclip skills and installs them into the Kimi skills home", async () => { + const kimiCodeHome = await makeTempDir("paperclip-kimi-skill-sync-"); + cleanupDirs.add(kimiCodeHome); + + const ctx = { + agentId: "agent-1", + companyId: "company-1", + adapterType: "kimi_local", + config: { + env: { + KIMI_CODE_HOME: kimiCodeHome, + }, + paperclipSkillSync: { + desiredSkills: [paperclipKey], + }, + }, + } as const; + + const before = await listKimiSkills(ctx); + expect(before.adapterType).toBe("kimi_local"); + expect(before.mode).toBe("persistent"); + expect(before.desiredSkills).toContain(paperclipKey); + expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing"); + + const after = await syncKimiSkills(ctx, [paperclipKey]); + expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed"); + expect((await fs.lstat(path.join(kimiCodeHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true); + }); +}); diff --git a/server/src/adapters/builtin-adapter-types.ts b/server/src/adapters/builtin-adapter-types.ts index 505162f7ed..aaaf28cabd 100644 --- a/server/src/adapters/builtin-adapter-types.ts +++ b/server/src/adapters/builtin-adapter-types.ts @@ -11,6 +11,7 @@ export const BUILTIN_ADAPTER_TYPES = new Set([ "grok_local", "hermes_gateway", "hermes_local", + "kimi_local", "openclaw_gateway", "opencode_local", "pi_local", diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index dd5f539bcb..f71352dedb 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -88,6 +88,17 @@ import { agentConfigurationDoc as grokAgentConfigurationDoc, models as grokModels, } from "@paperclipai/adapter-grok-local"; +import { + execute as kimiExecute, + listKimiSkills, + syncKimiSkills, + testEnvironment as kimiTestEnvironment, + sessionCodec as kimiSessionCodec, +} from "@paperclipai/adapter-kimi-local/server"; +import { + agentConfigurationDoc as kimiAgentConfigurationDoc, + models as kimiModels, +} from "@paperclipai/adapter-kimi-local"; import { createHermesGatewayServerAdapter, createHermesLocalServerAdapter, @@ -412,6 +423,32 @@ const grokLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: grokAgentConfigurationDoc, }; +const kimiLocalAdapter: ServerAdapterModule = { + type: "kimi_local", + execute: kimiExecute, + testEnvironment: kimiTestEnvironment, + acp: { + agentId: "kimi", + skillsMode: "ephemeral", + prerequisites: { + nodeRange: ">=20.0.0", + packages: ["@moonshot-ai/kimi-code"], + }, + }, + listSkills: listKimiSkills, + syncSkills: syncKimiSkills, + sessionCodec: kimiSessionCodec, + sessionManagement: getAdapterSessionManagement("kimi_local") ?? undefined, + models: kimiModels, + supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, + getRuntimeCommandSpec: (config) => + buildNpmRuntimeCommandSpec(config, "kimi", "@moonshot-ai/kimi-code"), + agentConfigurationDoc: kimiAgentConfigurationDoc, +}; + const hermesGatewayAdapter = createHermesGatewayServerAdapter(); const hermesLocalAdapter = createHermesLocalServerAdapter(); @@ -488,6 +525,7 @@ function registerBuiltInAdapters() { cursorLocalAdapter, geminiLocalAdapter, grokLocalAdapter, + kimiLocalAdapter, hermesGatewayAdapter, hermesLocalAdapter, openclawGatewayAdapter, diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 14e4a6a49b..e5f6d356ce 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -149,6 +149,7 @@ import { import type { AdapterAuthSessionOwnerResponse } from "@paperclipai/shared"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; +import { DEFAULT_KIMI_LOCAL_MODEL } from "@paperclipai/adapter-kimi-local"; import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local"; import { requireOpenCodeModelId } from "@paperclipai/adapter-opencode-local/server"; import { @@ -276,6 +277,7 @@ export function agentRoutes( codex_local: "instructionsFilePath", droid_local: "instructionsFilePath", gemini_local: "instructionsFilePath", + kimi_local: "instructionsFilePath", opencode_local: "instructionsFilePath", cursor: "instructionsFilePath", pi_local: "instructionsFilePath", @@ -1850,6 +1852,10 @@ export function agentRoutes( next.model = DEFAULT_GEMINI_LOCAL_MODEL; return ensureGatewayDeviceKey(adapterType, next); } + if (adapterType === "kimi_local" && !asNonEmptyString(next.model)) { + next.model = DEFAULT_KIMI_LOCAL_MODEL; + return ensureGatewayDeviceKey(adapterType, next); + } if (adapterType === "opencode_local" && !asNonEmptyString(next.model)) { next.model = DEFAULT_OPENCODE_LOCAL_MODEL; return ensureGatewayDeviceKey(adapterType, next); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index f3770d504e..ecc17fc4e5 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -770,6 +770,10 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record { expect(getAdapterLabel("cursor")).toBe("Cursor"); expect(getAdapterLabel("gemini_local")).toBe("Gemini CLI"); expect(getAdapterLabel("grok_local")).toBe("Grok Build"); + expect(getAdapterLabel("kimi_local")).toBe("Kimi Code"); expect(getAdapterLabel("hermes_local")).toBe("Hermes"); expect(getAdapterLabel("hermes_gateway")).toBe("Hermes Gateway"); expect(getAdapterLabel("opencode_local")).toBe("OpenCode"); @@ -22,6 +23,7 @@ describe("adapter display registry", () => { cursor: "Cursor", gemini_local: "Gemini CLI", grok_local: "Grok Build", + kimi_local: "Kimi Code", hermes_local: "Hermes", hermes_gateway: "Hermes Gateway", opencode_local: "OpenCode", diff --git a/ui/src/adapters/adapter-display-registry.ts b/ui/src/adapters/adapter-display-registry.ts index 4b7013fc06..91e3899947 100644 --- a/ui/src/adapters/adapter-display-registry.ts +++ b/ui/src/adapters/adapter-display-registry.ts @@ -10,6 +10,7 @@ import { Bot, Code, Gem, + Moon, MousePointer2, Sparkles, Terminal, @@ -89,6 +90,11 @@ const adapterDisplayMap: Record = { description: "Grok Build harness", icon: Bot, }, + kimi_local: { + label: "Kimi Code", + description: "Kimi Code CLI harness", + icon: Moon, + }, hermes_gateway: { label: "Hermes Gateway", description: "Remote Hermes API server", diff --git a/ui/src/adapters/kimi-local/config-fields.tsx b/ui/src/adapters/kimi-local/config-fields.tsx new file mode 100644 index 0000000000..351a4cad4d --- /dev/null +++ b/ui/src/adapters/kimi-local/config-fields.tsx @@ -0,0 +1,51 @@ +import type { AdapterConfigFieldsProps } from "../types"; +import { + DraftInput, + Field, +} from "../../components/agent-config-primitives"; +import { ChoosePathButton } from "../../components/PathInstructionsModal"; + +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; +const instructionsFileHint = + "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Prepended to the Kimi prompt at runtime."; + +export function KimiLocalConfigFields({ + isCreate, + values, + set, + config, + eff, + mark, + hideInstructionsFile, +}: AdapterConfigFieldsProps) { + if (hideInstructionsFile) return null; + return ( + <> + +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+
+ + ); +} diff --git a/ui/src/adapters/kimi-local/index.ts b/ui/src/adapters/kimi-local/index.ts new file mode 100644 index 0000000000..9b9b287609 --- /dev/null +++ b/ui/src/adapters/kimi-local/index.ts @@ -0,0 +1,20 @@ +import type { UIAdapterModule } from "../types"; +import { parseKimiStdoutLine } from "@paperclipai/adapter-kimi-local/ui"; +import { KimiLocalConfigFields } from "./config-fields"; +import { buildKimiLocalConfig } from "@paperclipai/adapter-kimi-local/ui"; + +export const kimiLocalUIAdapter: UIAdapterModule = { + type: "kimi_local", + label: "Kimi Code", + parseStdoutLine: parseKimiStdoutLine, + ConfigFields: KimiLocalConfigFields, + buildAdapterConfig: buildKimiLocalConfig, + // Kimi streams token-level deltas (~16k per run, ~50x a comparable Claude + // run): keep a wide transcript window so heartbeats don't drop rendered + // content mid-run, and render live reasoning as a scrollable log instead of + // the one-line ticker. + transcriptPresentation: { + maxVisibleEntries: 400, + liveReasoningView: "scrollLog", + }, +}; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index c4d6b009c0..38345e24fa 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -5,6 +5,7 @@ import { cursorCloudUIAdapter } from "./cursor-cloud"; import { cursorLocalUIAdapter } from "./cursor"; import { geminiLocalUIAdapter } from "./gemini-local"; import { grokLocalUIAdapter } from "./grok-local"; +import { kimiLocalUIAdapter } from "./kimi-local"; import { hermesGatewayUIAdapter } from "./hermes-gateway"; import { hermesLocalUIAdapter } from "./hermes-local"; import { openCodeLocalUIAdapter } from "./opencode-local"; @@ -57,6 +58,7 @@ function registerBuiltInUIAdapters() { cursorCloudUIAdapter, geminiLocalUIAdapter, grokLocalUIAdapter, + kimiLocalUIAdapter, hermesGatewayUIAdapter, hermesLocalUIAdapter, openCodeLocalUIAdapter, diff --git a/ui/src/adapters/transcript.test.ts b/ui/src/adapters/transcript.test.ts index 56627f328a..768909311c 100644 --- a/ui/src/adapters/transcript.test.ts +++ b/ui/src/adapters/transcript.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildTranscript, type RunLogChunk } from "./transcript"; import { grokLocalUIAdapter } from "./grok-local"; +import { kimiLocalUIAdapter } from "./kimi-local"; import type { UIAdapterModule } from "./types"; describe("buildTranscript", () => { @@ -199,4 +200,31 @@ describe("buildTranscript", () => { { kind: "system", ts, text: "stop_reason=EndTurn session=sess-1" }, ]); }); + + it("coalesces kimi_local ACP text deltas into one assistant entry", () => { + const entries = buildTranscript( + [ + { ts, stream: "stdout", chunk: `${JSON.stringify({ type: "acpx.text_delta", channel: "output", text: "Hello " })}\n` }, + { ts, stream: "stdout", chunk: `${JSON.stringify({ type: "acpx.text_delta", channel: "output", text: "world" })}\n` }, + { ts, stream: "stdout", chunk: `${JSON.stringify({ type: "acpx.result", summary: "done", stopReason: "end_turn" })}\n` }, + ], + kimiLocalUIAdapter, + ); + + expect(entries).toEqual([ + { kind: "assistant", ts, text: "Hello world", delta: true }, + { + kind: "result", + ts, + text: "done", + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + costUsd: 0, + subtype: "end_turn", + isError: false, + errors: [], + }, + ]); + }); }); diff --git a/ui/src/adapters/use-adapter-capabilities.ts b/ui/src/adapters/use-adapter-capabilities.ts index 78d81f5d33..55abb33ded 100644 --- a/ui/src/adapters/use-adapter-capabilities.ts +++ b/ui/src/adapters/use-adapter-capabilities.ts @@ -22,6 +22,7 @@ const KNOWN_DEFAULTS: Record = { cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, supportsAcp: false }, gemini_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, supportsAcp: true }, grok_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false, supportsAcp: false }, + kimi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false, supportsAcp: true }, opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, supportsAcp: false }, pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false, supportsAcp: false }, openclaw_gateway: ALL_FALSE, diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index 60ed57ca94..c36cc362fd 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -742,6 +742,28 @@ describe("AgentConfigForm environment selector", () => { expect(selector?.textContent).toContain("E2B · sandbox"); }); + it("shows the environment override for Kimi local agents", async () => { + const result = await renderForm( + [ + makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), + makeEnvironment({ + id: "sandbox-1", + name: "E2B", + driver: "sandbox", + config: { provider: "e2b" }, + }), + ], + { adapterType: "kimi_local" }, + ); + roots.push(result.root); + + const text = result.container.textContent ?? ""; + const selector = result.container.querySelector("select"); + + expect(text).toContain("Environment override"); + expect(selector?.textContent).toContain("E2B · sandbox"); + }); + it("keeps an existing non-runnable override visible so it can be cleared", async () => { const result = await renderForm( [ diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index aded4092e3..c7e9ee8cac 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -21,6 +21,7 @@ import { assetsApi } from "../api/assets"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; +import { DEFAULT_KIMI_LOCAL_MODEL } from "@paperclipai/adapter-kimi-local"; import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local"; import { Popover, @@ -200,6 +201,15 @@ const claudeThinkingEffortOptions = [ { id: "high", label: "High" }, ] as const; +// Kimi exposes low/high/max (no "medium") via each model's support_efforts; +// the kimi_local adapter maps a legacy "medium" onto "high" at runtime. +const kimiThinkingEffortOptions = [ + { id: "", label: "Auto" }, + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + { id: "max", label: "Max" }, +] as const; + const MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS = 2; const MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP = 10; const MAX_TURN_CONTINUATION_DEFAULT_DELAY_SEC = 1; @@ -1097,7 +1107,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? cursorModeOptions : adapterType === "opencode_local" ? openCodeThinkingEffortOptions - : claudeThinkingEffortOptions; + : adapterType === "kimi_local" + ? kimiThinkingEffortOptions + : claudeThinkingEffortOptions; const currentThinkingEffort = isCreate ? val!.thinkingEffort : adapterType === "codex_local" @@ -1452,6 +1464,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; } else if (t === "gemini_local") { nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL; + } else if (t === "kimi_local") { + nextValues.model = DEFAULT_KIMI_LOCAL_MODEL; } else if (t === "cursor") { nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL; } else if (t === "opencode_local") { @@ -1469,6 +1483,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { model: t === "gemini_local" ? DEFAULT_GEMINI_LOCAL_MODEL + : t === "kimi_local" + ? DEFAULT_KIMI_LOCAL_MODEL : t === "opencode_local" ? DEFAULT_OPENCODE_LOCAL_MODEL : t === "cursor" @@ -1583,6 +1599,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { claude_local: "claude", codex_local: "codex", gemini_local: "gemini", + kimi_local: "kimi", pi_local: "pi", cursor: "agent", opencode_local: "opencode", diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 8c23e267e8..3d7267c54b 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -44,6 +44,7 @@ import { buildNewAgentRuntimeConfig } from "../lib/new-agent-runtime-config"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; +import { DEFAULT_KIMI_LOCAL_MODEL } from "@paperclipai/adapter-kimi-local"; import { DEFAULT_OPENCODE_LOCAL_MODEL, isValidOpenCodeModelId } from "@paperclipai/adapter-opencode-local"; import { canGoBackFromOnboardingStep, @@ -658,6 +659,7 @@ function OnboardingWizardInner({ adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "gemini_local" || + adapterType === "kimi_local" || adapterType === "opencode_local" || adapterType === "pi_local" || adapterType === "cursor"; @@ -717,6 +719,7 @@ function OnboardingWizardInner({ claude_local: "claude", codex_local: "codex", gemini_local: "gemini", + kimi_local: "kimi", pi_local: "pi", cursor: "agent", opencode_local: "opencode", @@ -929,6 +932,8 @@ function OnboardingWizardInner({ model: adapterType === "gemini_local" ? model || DEFAULT_GEMINI_LOCAL_MODEL + : adapterType === "kimi_local" + ? model || DEFAULT_KIMI_LOCAL_MODEL : adapterType === "cursor" ? model || DEFAULT_CURSOR_LOCAL_MODEL : adapterType === "opencode_local" @@ -1863,6 +1868,10 @@ function OnboardingWizardInner({ setModel(DEFAULT_GEMINI_LOCAL_MODEL); return; } + if (nextType === "kimi_local" && !model) { + setModel(DEFAULT_KIMI_LOCAL_MODEL); + return; + } if (nextType === "cursor" && !model) { setModel(DEFAULT_CURSOR_LOCAL_MODEL); return; @@ -2063,6 +2072,8 @@ function OnboardingWizardInner({ ? `${effectiveAdapterCommand} exec --json -` : adapterType === "gemini_local" ? `${effectiveAdapterCommand} --output-format json "Respond with hello."` + : adapterType === "kimi_local" + ? `${effectiveAdapterCommand} -p "Respond with hello." --output-format stream-json` : adapterType === "opencode_local" ? `${effectiveAdapterCommand} run --format json "Respond with hello."` : `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`} @@ -2074,6 +2085,7 @@ function OnboardingWizardInner({ {adapterType === "cursor" || adapterType === "codex_local" || adapterType === "gemini_local" || + adapterType === "kimi_local" || adapterType === "opencode_local" ? (

If auth fails, set{" "} @@ -2082,6 +2094,8 @@ function OnboardingWizardInner({ ? "CURSOR_API_KEY" : adapterType === "gemini_local" ? "GEMINI_API_KEY" + : adapterType === "kimi_local" + ? "KIMI_MODEL_NAME + KIMI_MODEL_API_KEY" : "OPENAI_API_KEY"} {" "} in env or run{" "} @@ -2092,6 +2106,8 @@ function OnboardingWizardInner({ ? "codex login" : adapterType === "gemini_local" ? "gemini auth" + : adapterType === "kimi_local" + ? "kimi login" : "opencode auth login"} . diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index 242ecf058c..3ac1e9ca26 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -23,6 +23,7 @@ const ENABLED_INVITE_ADAPTERS = new Set([ "claude_local", "codex_local", "gemini_local", + "kimi_local", "opencode_local", "pi_local", "cursor", diff --git a/ui/src/pages/NewAgent.tsx b/ui/src/pages/NewAgent.tsx index 78a69a1b6c..8fcc815865 100644 --- a/ui/src/pages/NewAgent.tsx +++ b/ui/src/pages/NewAgent.tsx @@ -40,6 +40,7 @@ import { buildPermissionsForTrustPreset, getTrustPreset } from "../lib/trust-pol import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; +import { DEFAULT_KIMI_LOCAL_MODEL } from "@paperclipai/adapter-kimi-local"; import { DEFAULT_OPENCODE_LOCAL_MODEL, isValidOpenCodeModelId } from "@paperclipai/adapter-opencode-local"; function createValuesForAdapterType( @@ -52,6 +53,8 @@ function createValuesForAdapterType( DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; } else if (adapterType === "gemini_local") { nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL; + } else if (adapterType === "kimi_local") { + nextValues.model = DEFAULT_KIMI_LOCAL_MODEL; } else if (adapterType === "cursor") { nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL; } else if (adapterType === "opencode_local") { diff --git a/vitest.config.ts b/vitest.config.ts index 10649e7c93..f4dda31b46 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ "packages/adapters/cursor-local", "packages/adapters/gemini-local", "packages/adapters/grok-local", + "packages/adapters/kimi-local", "packages/adapters/openclaw-gateway", "packages/adapters/opencode-local", "packages/adapters/pi-local",