feat: add kimi-local adapter for Kimi Code CLI (CLI + ACP engines) (#9967)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Local agent adapters (`claude_local`, `gemini_local`, `grok_local`, …) are the integration surface that lets Paperclip run coding CLIs on the host machine > - The Kimi Code CLI (`kimi`, Moonshot AI) has a documented non-interactive mode, `kimi -p --output-format stream-json` with session resume via `kimi -r`, but Paperclip has no built-in adapter for it > - So Kimi users (especially Kimi membership / OAuth subscribers) cannot onboard their CLI to Paperclip agent teams > - This pull request adds a complete built-in `kimi_local` adapter (both execution engines, session management, instructions + skills delivery, thinking-effort control, environment test, UI and CLI modules, docs) following the established `gemini_local`/`grok_local` package pattern > - Kimi Code ships an ACP server (`kimi acp`), so the adapter runs on Paperclip's shared acpx engine by default (streaming transcript with live tool status, like `claude_local`/`gemini_local`) and falls back to a headless CLI lane (`kimi -p --output-format stream-json`) when ACP prerequisites are unavailable > - The benefit is that Kimi Code becomes a first-class Paperclip agent lane: selectable in the UI, resumable across heartbeats, with the same operating context (instruction bundle, skills, effort) and streaming transcript the other local adapters get ## Linked Issues or Issue Description - Supersedes #9880 (same branch; expanded from the CLI-only lane into a complete adapter with the default ACP engine lane, control-plane skill install, and live transcript wiring) - Refs #9879 (adapter request for Kimi Code CLI, filed with this PR) - Refs #163 (original Kimi support request) Duplicate/related prior PRs, per the dedup search (both appear stale: no updates or maintainer review since May 2026, and both target an older Kimi CLI interface; calling them out for reviewer context per CONTRIBUTING.md): - Refs #6276 (`feat: add kimi-local adapter`): targets an older array-based content format (`{type: think}`/`{type: text}` blocks), not the current documented stream-json schema - Refs #5202 (`feat(adapter): add Kimi CLI local adapter with Wire protocol support`): builds on a `--wire` JSON-RPC interface that current Kimi Code CLI (0.27.0) no longer documents; the current documented headless interface is `-p --output-format stream-json` This PR is a fresh implementation against current master and the currently documented/verified Kimi CLI behavior (see Verification). Happy to fold in anything useful from the earlier attempts if a reviewer prefers. ## What Changed - **New adapter package** `packages/adapters/kimi-local` (`@paperclipai/adapter-kimi-local`), modeled on `gemini-local`/`grok-local`: - `src/server/execute.ts`: spawns `kimi -p <prompt> --output-format stream-json` (argv array, no shell), `-m <model>` only when configured, `-r <sessionId>` when the stored session cwd matches the run cwd, automatic fresh-session retry on unrecoverable-session errors, headless-safe env (`CI=1`, `NO_COLOR=1`, `KIMI_CODE_NO_AUTO_UPDATE=1`, `TERM=dumb`; user-configured values win), full remote (ssh/sandbox) execution lane with runtime install via `@moonshot-ai/kimi-code` - **Instruction bundle delivery**: the prompt path directive now names the sibling instruction files (`./HEARTBEAT.md`, `./SOUL.md`, `./TOOLS.md`) alongside the prepended entry file, and local runs pass `--add-dir <instructions-dir>` so Kimi can actually open them (matching `claude_local`). Without this, only the entry file reached Kimi and agents improvised the operating workflow that `HEARTBEAT.md` documents - **Thinking effort**: a configured `effort` is forwarded as the `KIMI_MODEL_THINKING_EFFORT` operational override (Kimi has no per-invocation effort flag). It is only sent for models that advertise `support_efforts` (currently `kimi-code/k3`) to avoid provider rejections, and `medium` maps to `high` since Kimi has no medium tier (`low`/`high`/`max` pass through) - **Skills delivery**: desired Paperclip skills are delivered via Kimi's `--skills-dir` flag from a dedicated per-run directory (a local snapshot, or the synced snapshot on remote targets), so skills load reliably and in isolation. Paperclip never overwrites the shared `$KIMI_CODE_HOME/skills` home, 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 - **Live run status**: the adapter now forwards each streamed stream-json line to `onEvent` (assistant `content` as an assistant snippet, `tool_calls` as tool-name events), which drives the issue-thread activity indicator (`currentToolName` / `lastAssistantSnippet` / `lastEventAt`). Previously the adapter only wrote the raw run log, so the issue thread showed a stale "no output for N s" line with no tool or reasoning context while Kimi worked. Tool results are omitted so the last meaningful "Using X" / snippet is not overwritten by a generic label - `src/server/parse.ts`: parses the verified Kimi stream-json event shapes (`assistant` text, `assistant.tool_calls` with JSON-string arguments, `tool` results, trailing `meta.session.resume_hint` for session-id capture) plus failure classifiers (`kimi_auth_required`, transient network, unrecoverable session). A signaled exit (null exit code, not a timeout) is now reported as a failure rather than coalesced to success, and the error message names the terminating signal - `src/server/skills.ts`: lists/syncs Paperclip skills for the adapter's skill-management surface - `src/server/test.ts`: environment test covering CLI resolution + `kimi --version`, cwd check, auth detection (OAuth credential dirs, keyed `[providers.*]` in config.toml, or the `KIMI_MODEL_NAME` + `KIMI_MODEL_API_KEY` env pair), and a live hello probe - `src/ui/` (stdout-line parser for transcripts, config builder) and `src/cli/` (stream event formatter) modules - Root metadata: three managed model aliases (`kimi-code/kimi-for-coding`, `kimi-code/kimi-for-coding-highspeed`, `kimi-code/k3`), effort-capable-model metadata (`EFFORT_CAPABLE_MODELS`, effort mapping helpers), `agentConfigurationDoc` - Tests: 101 tests across parse, execute (args building, resume gating, retry, auth error code, timeout, signaled-exit failure, effort forwarding/gating/mapping, `--add-dir` instructions directive, `--skills-dir` gating, `onEvent` runtime-event forwarding), ACP engine (engine resolution, acpx config build, node-version gate), ACP transcript delegation, environment test, UI parse/build-config - **ACP engine lane (default)** (`src/server/acp.ts` + shared `adapter-utils/acpx-engine`): Kimi Code ships an ACP server (`kimi acp`), so `kimi_local` now runs on Paperclip's shared acpx engine by default, matching `claude_local`/`codex_local`/`gemini_local`. The issue-thread transcript streams live (assistant text deltas, tool calls with a `pending`->`completed` status lifecycle) instead of the CLI lane's bursty complete-message output. Registered `kimi_local -> "kimi"` in `ACPX_ADAPTER_AGENT_IDS` and resolved the built-in agent command to `kimi acp`; `execute.ts` dispatches to the ACP executor first with an automatic CLI fallback when ACP prerequisites fail (`engine=acp` requires ACP, `engine=cli` pins the headless lane); `index.ts` falls back to the shared acpx session codec; the UI/CLI delegate `acpx.*` events to the shared acpx transcript parser and event formatter. The headless CLI lane (above) remains as the fallback - **Registration** (one entry each, mirroring existing adapters): server adapter registry + `BUILTIN_ADAPTER_TYPES`, `AGENT_ADAPTER_TYPES` (shared), UI adapter registry + display registry (`Kimi Code`, Moon icon) + capabilities defaults, CLI adapter registry, `Dockerfile` (package copy + `npm install --global @moonshot-ai/kimi-code@latest`), `vitest.config.ts` workspace, `scripts/release-package-manifest.json` - **Behavioral sets** mirroring `gemini_local` (Kimi resumes sessions the same way): `GIT_SENSITIVE_LOCAL_ADAPTER_TYPES`, `SESSIONED_LOCAL_ADAPTERS` (heartbeat + recovery), `REMOTE_MANAGED_ADAPTERS`, ssh/sandbox execution-target allow-lists, `ADAPTER_DEFAULT_RULES_BY_TYPE` (`timeoutSec: 0`, `graceSec: 15`), and `LEGACY_SESSIONED_ADAPTER_TYPES` + `ADAPTER_SESSION_MANAGEMENT` in adapter-utils - **UI touch-points**: New Agent default-model branch, AgentConfigForm command map (`kimi_local: "kimi"`) + model defaults + a Kimi-specific thinking-effort option list (`Low`/`High`/`Max`, reflecting Kimi's tiers rather than borrowing Claude's), OnboardingWizard (command map, model default, `kimi login` / `KIMI_MODEL_NAME + KIMI_MODEL_API_KEY` auth hints, manual-debug command line), InviteLanding enabled adapters - **Control-plane skill install** (`cli/src/commands/client/agent.ts`): `paperclipai agent local-cli` seeded the Paperclip control-plane skills into `~/.codex/skills` and `~/.claude/skills` so Codex/Claude agents auto-discover the API reference every run. Kimi had no equivalent target, so `kimi_local` agents began each session without the control-plane skill and rediscovered routes (e.g. the company-scoped `POST /api/companies/{companyId}/issues`) by trial and error. Added `~/.kimi-code/skills` (honoring `KIMI_CODE_HOME`) as a third install target for parity. Independent of the per-run `--skills-dir` delivery, which only applies to explicitly configured skills. - **Docs**: `docs/adapters/kimi-local.md` (prerequisites, auth options, config fields including `effort`, session resume, instruction bundle, skills delivery, control-plane skill install) + a row in `docs/adapters/overview.md` Out of scope (deliberately): model profiles, built-in agent `allowedAdapterTypes` additions. ## Verification\n\nCurrent-master rebase verification (OpenAI Codex, 2026-08-03): 13 focused files / 231 tests pass; adapter-utils, server, UI, CLI, and Kimi adapter typechecks pass; full repository build and UI token gates pass. The branch is conflict-free against master at head `1249df117c5e12e5771b9a570a6340866450619e`.\n\nAutomated (all from repo root, pnpm 9.15.4, Node 22): - `vitest run packages/adapters/kimi-local`: 89/89 pass (includes coverage for the instruction `--add-dir` directive, effort forwarding/gating/mapping, `--skills-dir` gating, the signaled-exit failure path, and `onEvent` runtime-event forwarding with cross-chunk line buffering) - `vitest run server/src/__tests__/adapter-registry.test.ts server/src/__tests__/adapter-routes.test.ts server/src/services/heartbeat-stop-metadata.test.ts ui/src/adapters/adapter-display-registry.test.ts`: 37/37 pass - `vitest run cli/src/__tests__/skills.test.ts`: 13/13 pass (the control-plane skill install target follows the existing Codex/Claude install path, whose symlink logic is unchanged) - `vitest run packages/shared`: 307/307 pass; `vitest run packages/adapter-utils`: pass except one pre-existing, unrelated failure (`mcp-isolation.integration.test.ts` requires Claude CLI ≥ 2.1.207; host has 2.1.185, fails identically on unmodified master) - `pnpm --filter @paperclipai/adapter-kimi-local typecheck|build`, plus typecheck of `server`, `ui`, `cli`, `adapter-utils`: all clean - `pnpm install --frozen-lockfile`: passes (the PR diff itself contains no lockfile changes, per repo policy; verified against a locally regenerated lockfile) - `node scripts/check-no-git-push.mjs` and `node scripts/check-forbidden-tokens.mjs`: pass - CI note: the `policy` job's release-bootstrap step is expected to stay red until a maintainer bootstraps the first npm publish of `@paperclipai/adapter-kimi-local`; see the CI Note for Maintainers comment. All other contributor-actionable checks are green. Manual end-to-end (real Kimi CLI 0.27.0, OAuth login, dev server on an isolated instance): 1. Server `GET /api/adapters` lists `kimi_local` as builtin with correct capability flags; models endpoint returns the three Kimi models 2. `POST .../adapters/kimi_local/test-environment`: all checks pass, including a live `kimi -p` hello probe 3. Created a `kimi_local` agent and invoked two heartbeats: run 1 spawned `kimi -p ... --output-format stream-json`, Kimi used its `Read` tool, produced the expected answer, and the session id was captured from the `session.resume_hint` meta event; run 2 resumed the **same** Kimi session (`sessionIdBefore == sessionIdAfter`) via `-r` 4. UI: adapter appears in the New Agent dropdown; selecting it shows the Kimi command placeholder, the three models, and the Kimi config fields; the run transcript renders Kimi tool calls via the adapter's stdout parser The instruction-bundle, thinking-effort, and `--skills-dir` changes landed after the manual run above. They are covered by the unit tests listed under Automated, and the Kimi CLI flags they rely on (`--add-dir`, `--skills-dir`, `KIMI_MODEL_THINKING_EFFORT`) were confirmed against the installed Kimi Code CLI 0.27.0 (`kimi --help`, config-file thinking-effort docs). Screenshots (assets branch on the fork, not part of the diff):       ## Risks - Low risk to existing behavior: the change is additive, one new workspace package plus single-entry registrations alongside existing adapters; no existing adapter code paths are modified. - The adapter invokes the locally installed `kimi` CLI; like other local adapters, run behavior depends on the host's Kimi version. The parser is written against the documented/verified 0.27.0 stream-json schema and degrades gracefully (malformed lines are skipped, failures surface as run errors). - `--skills-dir` overrides Kimi's auto-discovery of user and project skills for the run. This is intentional (paperclip-managed agents get a reproducible, isolated skill set), and it is only passed when at least one Paperclip skill is desired, so unconfigured agents keep default discovery. - Thinking effort is only forwarded to models that advertise `support_efforts` (currently `kimi-code/k3`); `EFFORT_CAPABLE_MODELS` must be extended when more Kimi models gain support, otherwise a configured effort is silently ignored for them. - `Dockerfile` now installs `@moonshot-ai/kimi-code@latest` globally alongside the other agent CLIs, so image size increases slightly. - Maintainer action needed for the npm bootstrap gate: the `policy` job's release-bootstrap step fails until the first npm publish of `@paperclipai/adapter-kimi-local` (the gate from #5146 that every new adapter package has passed through). Enrollment with `publishFromCi: true` is required by the manifest validator (dropping the entry, `false`, or `private` are all rejected), so this is intentionally left to a maintainer. Remaining CI lanes are expected to run once it is done. ## Model Used\n\n- **Current-master rebase, conflict adaptation, and registry-parity coverage:** OpenAI, **GPT-5 Codex** (Codex agent; exact serving model ID and context-window size were not exposed to the runtime), with repository, shell, Git, and GitHub tooling. It preserved Hawik’s commit authorship, reconciled ACPX and environment-capability changes, added current registry tests, and ran the verification above.\n- **Adapter implementation and initial review:** Moonshot AI, **Kimi K3 Coding** (latest), via **Kimi Code CLI v0.27.0** (`kimi-code/k3` alias, 1M-token context window, thinking mode, agentic tool use). The CLI agent explored the repo, wrote the adapter implementation (delegated to a coder sub-agent of the same model), ran tests, and drafted the first version of this PR body. A second model-driven review pass (read-only, same model) audited the diff for security/correctness before submission; its findings (shell-quoting hardening, auth-detection false positive, session-compaction registration, test gaps) were fixed and are included. - **Harness-context fixes and review responses:** Anthropic, **Claude Opus 4.8** (`claude-opus-4-8`) via Claude Code. Diagnosed from run logs that Kimi received only the entry instructions file (not the `HEARTBEAT.md`/`SOUL.md`/`TOOLS.md` bundle) and that `effort` was never wired, then implemented the instruction `--add-dir` delivery, `KIMI_MODEL_THINKING_EFFORT` forwarding, and `--skills-dir` skill delivery, added the accompanying tests and docs, and addressed the automated review comments (preserving external skills on remote sync, treating a signaled exit as a failure). Also extended the `paperclipai agent local-cli` installer to seed the control-plane skills into `~/.kimi-code/skills` for Codex/Claude parity, wired `onEvent` runtime events so the issue-thread activity indicator reflects Kimi's tool and reasoning output live, and built the ACP engine lane (`kimi acp` via the shared acpx engine, default) so the transcript streams with live tool status like the other ACP adapters. The Kimi CLI flags, subcommand, and env var relied on here were verified against the installed Kimi Code CLI 0.27.0. - All CLI behaviors claimed here (`-p`, `--output-format stream-json`, `-r` resume, event shapes, `--add-dir`, `--skills-dir`, `KIMI_MODEL_THINKING_EFFORT`) were verified empirically against the installed Kimi CLI, not assumed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green *(only the release-bootstrap step remains red, pending the maintainer npm publish described in Risks)* - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups *(will address all Greptile comments as they arrive)* - [x] I will address all Greptile and reviewer comments before requesting merge --- ## Maintainer Addendum (2026-08-20) The shared acpx-engine and issue-chat changes (run-summary segmentation, placeholder tool-event coalescing, `ISSUE_CHAT_TRANSCRIPT_MAX_VISIBLE_ENTRIES` 30 → 400, live-reasoning UI) have been **extracted to #11761** so the cross-adapter behavior changes review and revert independently — both commits there preserve @hawikk's authorship. This PR is now the kimi-specific adapter only (60 files, +3,793/−8, essentially pure addition); the only shared-engine touch left is the `kimi acp` command resolution. `publishFromCi` is `true` — the package name is bootstrapped on npm. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Dotta <bippadotta@protonmail.com> Co-authored-by: Devin Foley <devin@paperclip.ing>
This commit is contained in:
parent
b83e14ad2c
commit
233c12f029
|
|
@ -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/* \
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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<string, CLIAdapterModule>(
|
|||
cursorCloudCLIAdapter,
|
||||
geminiLocalCLIAdapter,
|
||||
grokLocalCLIAdapter,
|
||||
kimiLocalCLIAdapter,
|
||||
hermesGatewayCLIAdapter,
|
||||
hermesLocalCLIAdapter,
|
||||
openclawGatewayCLIAdapter,
|
||||
|
|
|
|||
|
|
@ -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<SkillsInstallSummary> {
|
||||
const summary: SkillsInstallSummary = {
|
||||
tool,
|
||||
|
|
@ -771,7 +777,7 @@ export function registerAgentCommands(program: Command): void {
|
|||
.option("--key-name <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"),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.<name>]`)
|
||||
- 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 <prompt> --output-format stream-json` (plus `-m <model>` when configured and `-r <sessionId>` when resuming). On local runs the adapter also passes `--add-dir <instructions-dir>` so the agent can read sibling instruction files, and `--skills-dir <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 <session_id>` 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 <agentRef> -C <companyId>` 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).
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<string, AdapterSessionManagement
|
|||
nativeContextManagement: "unknown",
|
||||
defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY,
|
||||
},
|
||||
kimi_local: {
|
||||
supportsSessionResume: true,
|
||||
nativeContextManagement: "unknown",
|
||||
defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY,
|
||||
},
|
||||
opencode_local: {
|
||||
supportsSessionResume: true,
|
||||
nativeContextManagement: "unknown",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
"name": "@paperclipai/adapter-kimi-local",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/adapters/kimi-local"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.21",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import pc from "picocolors";
|
||||
import { printAcpxStreamEvent } from "@paperclipai/adapter-utils/acpx-engine/cli";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
} 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);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { printKimiStreamEvent } from "./format-event.js";
|
||||
|
|
@ -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<string>(["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 <session_id>\` 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.
|
||||
`;
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<AdapterExecutionContext, "config"> &
|
||||
Partial<Pick<AdapterExecutionContext, "executionTarget" | "executionTransport">>;
|
||||
|
||||
type KimiAcpExecutorOptions = Omit<
|
||||
AcpxEngineExecutorOptions,
|
||||
"adapterType" | "moduleDir" | "packageRootDir"
|
||||
>;
|
||||
|
||||
type KimiAcpExecutor = (ctx: AdapterExecutionContext) => Promise<AdapterExecutionResult>;
|
||||
|
||||
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<string, unknown>): KimiEngineSelection {
|
||||
return normalizeEngine(config.engine);
|
||||
}
|
||||
|
||||
export async function resolveKimiExecutionEngineForRun(
|
||||
input: KimiEngineResolutionInput,
|
||||
): Promise<KimiEngineSelection> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown> = {
|
||||
...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<boolean> {
|
||||
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<string | null> {
|
||||
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, unknown>): 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<boolean> {
|
||||
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, unknown>): 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<typeof readAdapterExecutionTarget>,
|
||||
): boolean {
|
||||
return target?.kind === "remote" && target.transport === "sandbox" && Boolean(target.runner);
|
||||
}
|
||||
|
||||
async function defaultKimiAcpFallbackReason(
|
||||
input: KimiEngineResolutionInput,
|
||||
): Promise<string | null> {
|
||||
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<AdapterEnvironmentTestResult> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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> = {}): 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<string, string> = {};
|
||||
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<string, unknown> }> = [];
|
||||
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<string, unknown> }> = [];
|
||||
// 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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void> } {
|
||||
if (!onEvent) return { log: onLog, flush: async () => {} };
|
||||
let buffer = "";
|
||||
const emitLine = async (raw: string): Promise<void> => {
|
||||
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<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveKimiBillingType(env: Record<string, string>): "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<string, string>): Record<string, string> {
|
||||
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<string, string>): Record<string, string> {
|
||||
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, string>): 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, string>): 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<string, unknown>,
|
||||
): Promise<string> {
|
||||
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<AdapterExecutionResult> {
|
||||
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<string, unknown> => 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<string, string> = { ...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<void>) | null = null;
|
||||
let localSkillsDir: string | null = null;
|
||||
let remoteSkillsDir: string | null = null;
|
||||
let remoteRuntimeRootDir: string | null = null;
|
||||
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = 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 ? `<prompt ${prompt.length} chars>` : 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<typeof parseKimiJsonl>;
|
||||
},
|
||||
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<string, unknown>)
|
||||
: null;
|
||||
const resultJson: Record<string, unknown> = {
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown> | 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<string, unknown> | 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";
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
|
@ -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<string, unknown>) {
|
||||
const env =
|
||||
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
|
||||
? (config.env as Record<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||
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<AdapterSkillSnapshot> {
|
||||
return buildKimiSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export async function syncKimiSkills(
|
||||
ctx: AdapterSkillContext,
|
||||
desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
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);
|
||||
}
|
||||
|
|
@ -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",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<string, string>): Promise<string | null> {
|
||||
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<AdapterEnvironmentTestResult> {
|
||||
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<string, string> = {};
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
|
@ -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> = {}): 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
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<string, unknown> {
|
||||
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||
const env: Record<string, unknown> = {};
|
||||
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<string, unknown>;
|
||||
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<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { parseKimiStdoutLine } from "./parse-stdout.js";
|
||||
export { buildKimiLocalConfig } from "./build-config.js";
|
||||
|
|
@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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 }];
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
|
|
@ -37,6 +37,7 @@ export const AGENT_ADAPTER_TYPES = [
|
|||
"grok_local",
|
||||
"hermes_gateway",
|
||||
"hermes_local",
|
||||
"kimi_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"cursor",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ const REMOTE_MANAGED_ADAPTERS = new Set<AgentAdapterType>([
|
|||
"cursor",
|
||||
"gemini_local",
|
||||
"grok_local",
|
||||
"kimi_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
describe("kimi local skill sync", () => {
|
||||
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -770,6 +770,10 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
|
|||
{ path: ["timeoutSec"], value: 0 },
|
||||
{ path: ["graceSec"], value: 15 },
|
||||
],
|
||||
kimi_local: [
|
||||
{ path: ["timeoutSec"], value: 0 },
|
||||
{ path: ["graceSec"], value: 15 },
|
||||
],
|
||||
opencode_local: [
|
||||
{ path: ["timeoutSec"], value: 0 },
|
||||
{ path: ["graceSec"], value: 15 },
|
||||
|
|
|
|||
|
|
@ -470,6 +470,7 @@ const GIT_SENSITIVE_LOCAL_ADAPTER_TYPES = new Set([
|
|||
"gemini_local",
|
||||
"grok_local",
|
||||
"hermes_local",
|
||||
"kimi_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
]);
|
||||
|
|
@ -770,6 +771,7 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([
|
|||
"cursor",
|
||||
"gemini_local",
|
||||
"hermes_local",
|
||||
"kimi_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([
|
|||
"cursor",
|
||||
"gemini_local",
|
||||
"hermes_local",
|
||||
"kimi_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -41,6 +41,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:*",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ describe("adapter display registry", () => {
|
|||
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",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
Bot,
|
||||
Code,
|
||||
Gem,
|
||||
Moon,
|
||||
MousePointer2,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
|
|
@ -89,6 +90,11 @@ const adapterDisplayMap: Record<string, AdapterDisplayInfo> = {
|
|||
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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||
<div className="flex items-center gap-2">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.instructionsFilePath ?? ""
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"instructionsFilePath",
|
||||
String(config.instructionsFilePath ?? ""),
|
||||
)
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ instructionsFilePath: v })
|
||||
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="/absolute/path/to/AGENTS.md"
|
||||
/>
|
||||
<ChoosePathButton />
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const KNOWN_DEFAULTS: Record<string, AdapterCapabilities> = {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" ? (
|
||||
<p className="text-muted-foreground">
|
||||
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"}
|
||||
</span>{" "}
|
||||
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"}
|
||||
</span>
|
||||
.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const ENABLED_INVITE_ADAPTERS = new Set([
|
|||
"claude_local",
|
||||
"codex_local",
|
||||
"gemini_local",
|
||||
"kimi_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"cursor",
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue