@botlearn-course/daemon 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/agent-service-client.d.ts +18 -0
- package/dist/agent-service-client.js +108 -0
- package/dist/auth-store.d.ts +16 -0
- package/dist/auth-store.js +106 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.js +354 -0
- package/dist/course-client.d.ts +46 -0
- package/dist/course-client.js +143 -0
- package/dist/doctor.d.ts +15 -0
- package/dist/doctor.js +85 -0
- package/dist/file-candidates.d.ts +34 -0
- package/dist/file-candidates.js +173 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +19 -0
- package/dist/log.d.ts +20 -0
- package/dist/log.js +154 -0
- package/dist/path-env.d.ts +8 -0
- package/dist/path-env.js +42 -0
- package/dist/redaction.d.ts +24 -0
- package/dist/redaction.js +158 -0
- package/dist/run-dispatcher.d.ts +43 -0
- package/dist/run-dispatcher.js +294 -0
- package/dist/run-queue.d.ts +11 -0
- package/dist/run-queue.js +26 -0
- package/dist/runtime-capabilities.d.ts +3 -0
- package/dist/runtime-capabilities.js +42 -0
- package/dist/runtime-profile.d.ts +8 -0
- package/dist/runtime-profile.js +213 -0
- package/dist/runtimes/acp-stream.d.ts +96 -0
- package/dist/runtimes/acp-stream.js +488 -0
- package/dist/runtimes/claude-code.d.ts +41 -0
- package/dist/runtimes/claude-code.js +353 -0
- package/dist/runtimes/codex.d.ts +44 -0
- package/dist/runtimes/codex.js +332 -0
- package/dist/runtimes/deepseek-tui.d.ts +50 -0
- package/dist/runtimes/deepseek-tui.js +701 -0
- package/dist/runtimes/engine.d.ts +52 -0
- package/dist/runtimes/engine.js +127 -0
- package/dist/runtimes/fake.d.ts +13 -0
- package/dist/runtimes/fake.js +45 -0
- package/dist/runtimes/gemini.d.ts +39 -0
- package/dist/runtimes/gemini.js +251 -0
- package/dist/runtimes/hermes-agent.d.ts +61 -0
- package/dist/runtimes/hermes-agent.js +173 -0
- package/dist/runtimes/index.d.ts +15 -0
- package/dist/runtimes/index.js +74 -0
- package/dist/runtimes/kimi.d.ts +35 -0
- package/dist/runtimes/kimi.js +335 -0
- package/dist/runtimes/ndjson-stream.d.ts +51 -0
- package/dist/runtimes/ndjson-stream.js +207 -0
- package/dist/runtimes/openclaw-acp.d.ts +52 -0
- package/dist/runtimes/openclaw-acp.js +872 -0
- package/dist/runtimes/probe.d.ts +17 -0
- package/dist/runtimes/probe.js +54 -0
- package/dist/runtimes/runtime-errors.d.ts +20 -0
- package/dist/runtimes/runtime-errors.js +95 -0
- package/dist/runtimes/text-cap.d.ts +7 -0
- package/dist/runtimes/text-cap.js +25 -0
- package/dist/transcript.d.ts +13 -0
- package/dist/transcript.js +46 -0
- package/dist/types.d.ts +199 -0
- package/dist/types.js +17 -0
- package/dist/workspace.d.ts +23 -0
- package/dist/workspace.js +54 -0
- package/package.json +40 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AcpRuntimeAdapter, } from "./acp-stream.js";
|
|
4
|
+
import { firstExistingPath, readCommandVersion, resolveCommandOnPath, resolveHomePath, } from "./probe.js";
|
|
5
|
+
import { wrapEngineAdapter, } from "./engine.js";
|
|
6
|
+
/**
|
|
7
|
+
* `hermes-acp` 不在 PATH 上时的已知绝对位置。上游 `scripts/install.sh`
|
|
8
|
+
* (curl|bash 安装器)把私有 virtualenv 装到 `~/.hermes/hermes-agent/venv/`,
|
|
9
|
+
* 只把用户可见的 `hermes` 命令软链进 `~/.local/bin/` —— `hermes-acp` 入口
|
|
10
|
+
* 留在 venv 里,没有回退就会漏掉所有按 README 推荐脚本安装的用户。
|
|
11
|
+
*/
|
|
12
|
+
const HERMES_ACP_FALLBACK_RELATIVE_PATHS = [
|
|
13
|
+
path.join(".hermes", "hermes-agent", "venv", "bin", "hermes-acp"),
|
|
14
|
+
];
|
|
15
|
+
const HERMES_ACP_FALLBACK_SYSTEM_PATHS = ["/opt/hermes/hermes-agent/venv/bin/hermes-acp"];
|
|
16
|
+
/**
|
|
17
|
+
* 解析 `hermes-acp` 可执行文件:env 覆盖 → PATH → 安装脚本的私有 venv 位置。
|
|
18
|
+
*/
|
|
19
|
+
export function resolveHermesAcpCommand(deps = {}) {
|
|
20
|
+
const explicit = (deps.env ?? process.env).BOTLEARN_HERMES_AGENT_BIN;
|
|
21
|
+
if (explicit && explicit.length > 0)
|
|
22
|
+
return explicit;
|
|
23
|
+
const onPath = resolveCommandOnPath("hermes-acp", deps);
|
|
24
|
+
if (onPath)
|
|
25
|
+
return onPath;
|
|
26
|
+
return firstExistingPath([
|
|
27
|
+
...HERMES_ACP_FALLBACK_RELATIVE_PATHS.map((p) => resolveHomePath(p, deps)),
|
|
28
|
+
...HERMES_ACP_FALLBACK_SYSTEM_PATHS,
|
|
29
|
+
], deps);
|
|
30
|
+
}
|
|
31
|
+
export function probeHermesAgent(deps = {}) {
|
|
32
|
+
const command = resolveHermesAcpCommand(deps);
|
|
33
|
+
if (!command)
|
|
34
|
+
return { available: false };
|
|
35
|
+
return {
|
|
36
|
+
available: true,
|
|
37
|
+
path: command,
|
|
38
|
+
version: readCommandVersion(command, [], deps) ?? undefined,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Hermes Agent 引擎。驱动 `hermes-acp`(`pip install "hermes-agent[acp]"`
|
|
43
|
+
* 附带的 ACP stdio adapter),复用用户本机 `~/.hermes` 的已登录凭据与配置
|
|
44
|
+
* (不再切换 HERMES_HOME)。
|
|
45
|
+
*
|
|
46
|
+
* ## systemContext 注入
|
|
47
|
+
*
|
|
48
|
+
* hermes 从 spawn cwd 向上发现 `AGENTS.md`。cwd 即本 run 的隔离工作区,
|
|
49
|
+
* spawn 前把 `opts.systemContext` 原子写入 `<cwd>/AGENTS.md`。注意这是
|
|
50
|
+
* **首轮注入**:hermes 把 system prompt 持久化在会话库里,续轮不重读该文件
|
|
51
|
+
* (course run 单轮执行,不受影响)。
|
|
52
|
+
*
|
|
53
|
+
* ## 权限姿态
|
|
54
|
+
*
|
|
55
|
+
* `HERMES_INTERACTIVE=1` 让 hermes 把危险工具调用经 ACP
|
|
56
|
+
* `session/request_permission` 反向请求。course run 在 owner 本机的隔离
|
|
57
|
+
* 工作区内执行,视为 owner 信任 —— 一律选 `allow_*` 选项。
|
|
58
|
+
*/
|
|
59
|
+
export class HermesAgentAdapter extends AcpRuntimeAdapter {
|
|
60
|
+
id = "hermes-agent";
|
|
61
|
+
explicitBinary;
|
|
62
|
+
resolvedBinary = null;
|
|
63
|
+
constructor(opts) {
|
|
64
|
+
super(opts?.logger);
|
|
65
|
+
this.explicitBinary = opts?.binary ?? process.env.BOTLEARN_HERMES_AGENT_BIN;
|
|
66
|
+
}
|
|
67
|
+
resolveBinary() {
|
|
68
|
+
if (this.explicitBinary)
|
|
69
|
+
return this.explicitBinary;
|
|
70
|
+
if (this.resolvedBinary)
|
|
71
|
+
return this.resolvedBinary;
|
|
72
|
+
this.resolvedBinary = resolveHermesAcpCommand() ?? "hermes-acp";
|
|
73
|
+
return this.resolvedBinary;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* hermes-acp 无位置参数 —— ACP 是纯 stdio JSON-RPC。不转发
|
|
77
|
+
* `opts.extraArgs`:hermes-acp 不接受运行时配置 flag,模型等配置在
|
|
78
|
+
* 用户 `~/.hermes` 的 `.env` / config.yaml 里。
|
|
79
|
+
*/
|
|
80
|
+
buildArgs(_opts) {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
spawnEnv(_opts) {
|
|
84
|
+
return {
|
|
85
|
+
...process.env,
|
|
86
|
+
// 保持 ACP stdout 无 ANSI 码。
|
|
87
|
+
NO_COLOR: "1",
|
|
88
|
+
// 危险工具调用走 ACP request_permission。
|
|
89
|
+
HERMES_INTERACTIVE: "1",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** spawn 前把 systemContext 原子写入 `<cwd>/AGENTS.md`(tmp 0600 + rename)。 */
|
|
93
|
+
prepareTurn(opts) {
|
|
94
|
+
mkdirSync(opts.cwd, { recursive: true, mode: 0o700 });
|
|
95
|
+
const target = path.join(opts.cwd, "AGENTS.md");
|
|
96
|
+
const tmp = path.join(opts.cwd, `.AGENTS.md.${process.pid}.tmp`);
|
|
97
|
+
writeFileSync(tmp, opts.systemContext ?? "", { mode: 0o600 });
|
|
98
|
+
renameSync(tmp, target);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* 把 ACP `session/update` 通知翻译成 StreamBlock + assistant 文本。
|
|
102
|
+
*
|
|
103
|
+
* `agent_thought_chunk` 刻意只映射为 `thinking.updated` 状态事件、不发
|
|
104
|
+
* block:其 payload 没有可渲染字段,发 `system` block 只会产生空块。
|
|
105
|
+
* 其余未知类型按 `other` 透传,便于下游自行 introspect。
|
|
106
|
+
*/
|
|
107
|
+
onUpdate(params, ctx) {
|
|
108
|
+
const update = params.update ?? {};
|
|
109
|
+
const kind = typeof update.sessionUpdate === "string" ? update.sessionUpdate : "";
|
|
110
|
+
if (kind === "agent_thought_chunk") {
|
|
111
|
+
ctx.emitStatus({ kind: "thinking", phase: "updated", label: "Thinking" });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
let blockKind = "other";
|
|
115
|
+
let assistantTextSeen = false;
|
|
116
|
+
if (kind === "agent_message_chunk") {
|
|
117
|
+
const content = update.content;
|
|
118
|
+
if (content && content.type === "text" && typeof content.text === "string") {
|
|
119
|
+
ctx.appendAssistantText(content.text);
|
|
120
|
+
assistantTextSeen = content.text.length > 0;
|
|
121
|
+
}
|
|
122
|
+
blockKind = "assistant_text";
|
|
123
|
+
}
|
|
124
|
+
else if (kind === "tool_call" || kind === "tool_call_update") {
|
|
125
|
+
blockKind = "tool_use";
|
|
126
|
+
}
|
|
127
|
+
else if (kind === "user_message_chunk") {
|
|
128
|
+
blockKind = "other";
|
|
129
|
+
}
|
|
130
|
+
// 状态提示先于 block 发出,让下游先看到带 label 的 thinking 帧。
|
|
131
|
+
const status = hermesStatusEvent(kind, update, assistantTextSeen);
|
|
132
|
+
if (status)
|
|
133
|
+
ctx.emitStatus(status);
|
|
134
|
+
ctx.emitBlock({ raw: params, kind: blockKind, seq: ctx.seq });
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* owner 信任:选第一个 `kind` 以 `allow_` 开头的选项,没有再退回第一个
|
|
138
|
+
* 选项;连选项都没有则 cancel(ACP DeniedOutcome 不携带 reason)。
|
|
139
|
+
*/
|
|
140
|
+
async onPermissionRequest(req, _opts) {
|
|
141
|
+
const options = Array.isArray(req.options) ? req.options : [];
|
|
142
|
+
const allow = options.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow_")) ?? options[0];
|
|
143
|
+
if (allow?.optionId) {
|
|
144
|
+
return { outcome: { outcome: "selected", optionId: allow.optionId } };
|
|
145
|
+
}
|
|
146
|
+
return { outcome: { outcome: "cancelled" } };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* 把 ACP `session/update` payload 映射为状态事件。只返回带 label 或
|
|
151
|
+
* 表达 dispatcher 无法从 block kind 推断的转变的事件。
|
|
152
|
+
*/
|
|
153
|
+
function hermesStatusEvent(kind, update, assistantTextSeen) {
|
|
154
|
+
// `agent_thought_chunk` 在 onUpdate 内联处理(status-only 路径)。
|
|
155
|
+
if (kind === "tool_call" || kind === "tool_call_update") {
|
|
156
|
+
const tool = update.toolCall;
|
|
157
|
+
const name = typeof tool?.name === "string" && tool.name ? tool.name : "tool";
|
|
158
|
+
return { kind: "thinking", phase: "updated", label: name };
|
|
159
|
+
}
|
|
160
|
+
if (kind === "agent_message_chunk" && assistantTextSeen) {
|
|
161
|
+
return { kind: "thinking", phase: "stopped" };
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
export const hermesAgentModule = {
|
|
166
|
+
id: "hermes-agent",
|
|
167
|
+
displayName: "Hermes Agent",
|
|
168
|
+
binary: "hermes-acp",
|
|
169
|
+
envVar: "BOTLEARN_HERMES_AGENT_BIN",
|
|
170
|
+
installHint: 'pip install "hermes-agent[acp]" (or set BOTLEARN_HERMES_AGENT_BIN)',
|
|
171
|
+
probe: async () => probeHermesAgent(),
|
|
172
|
+
create: () => wrapEngineAdapter("hermes-agent", new HermesAgentAdapter()),
|
|
173
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CourseRuntime, RuntimeModule, RuntimeProbeEntry } from "../types.js";
|
|
2
|
+
export declare const DEFAULT_RUNTIME_ID = "codex";
|
|
3
|
+
export declare const RUNTIME_MODULES: readonly RuntimeModule[];
|
|
4
|
+
export declare function fakeRuntimeEnabled(): boolean;
|
|
5
|
+
export declare function getRuntimeModule(id: string): RuntimeModule | null;
|
|
6
|
+
export declare function listRuntimeIds(opts?: {
|
|
7
|
+
includeHidden?: boolean;
|
|
8
|
+
}): string[];
|
|
9
|
+
export declare function envVarForRuntime(id: string): string;
|
|
10
|
+
export declare function createRuntime(id: string): CourseRuntime;
|
|
11
|
+
export declare function detectRuntimes(opts?: {
|
|
12
|
+
includeHidden?: boolean;
|
|
13
|
+
}): Promise<RuntimeProbeEntry[]>;
|
|
14
|
+
/** capabilities 上报用:available 且非 hidden;fake 仅在显式启用时加入。 */
|
|
15
|
+
export declare function detectAvailableRuntimeIds(): Promise<string[]>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { claudeCodeModule } from "./claude-code.js";
|
|
2
|
+
import { codexModule } from "./codex.js";
|
|
3
|
+
import { deepseekTuiModule } from "./deepseek-tui.js";
|
|
4
|
+
import { fakeModule } from "./fake.js";
|
|
5
|
+
import { geminiModule } from "./gemini.js";
|
|
6
|
+
import { hermesAgentModule } from "./hermes-agent.js";
|
|
7
|
+
import { kimiCliModule } from "./kimi.js";
|
|
8
|
+
import { openclawAcpModule } from "./openclaw-acp.js";
|
|
9
|
+
export const DEFAULT_RUNTIME_ID = "codex";
|
|
10
|
+
export const RUNTIME_MODULES = [
|
|
11
|
+
codexModule,
|
|
12
|
+
claudeCodeModule,
|
|
13
|
+
geminiModule,
|
|
14
|
+
openclawAcpModule,
|
|
15
|
+
kimiCliModule,
|
|
16
|
+
deepseekTuiModule,
|
|
17
|
+
hermesAgentModule,
|
|
18
|
+
fakeModule,
|
|
19
|
+
];
|
|
20
|
+
const BY_ID = new Map(RUNTIME_MODULES.map((m) => [m.id, m]));
|
|
21
|
+
// fake runtime 仅测试/演示用:显式开关,避免误入 capabilities 上报。
|
|
22
|
+
export function fakeRuntimeEnabled() {
|
|
23
|
+
return process.env.BOTLEARN_DAEMON_ENABLE_FAKE_RUNTIME === "1";
|
|
24
|
+
}
|
|
25
|
+
export function getRuntimeModule(id) {
|
|
26
|
+
return BY_ID.get(id) ?? null;
|
|
27
|
+
}
|
|
28
|
+
export function listRuntimeIds(opts = {}) {
|
|
29
|
+
return RUNTIME_MODULES.filter((m) => opts.includeHidden || !m.hidden).map((m) => m.id);
|
|
30
|
+
}
|
|
31
|
+
export function envVarForRuntime(id) {
|
|
32
|
+
const mod = getRuntimeModule(id);
|
|
33
|
+
if (mod?.envVar)
|
|
34
|
+
return mod.envVar;
|
|
35
|
+
return `BOTLEARN_${id.toUpperCase().replace(/-/g, "_")}_BIN`;
|
|
36
|
+
}
|
|
37
|
+
export function createRuntime(id) {
|
|
38
|
+
const mod = getRuntimeModule(id);
|
|
39
|
+
if (!mod)
|
|
40
|
+
throw new Error(`unknown runtime: ${id}`);
|
|
41
|
+
return mod.create();
|
|
42
|
+
}
|
|
43
|
+
export async function detectRuntimes(opts = {}) {
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const mod of RUNTIME_MODULES) {
|
|
46
|
+
if (mod.hidden && !opts.includeHidden)
|
|
47
|
+
continue;
|
|
48
|
+
let result;
|
|
49
|
+
try {
|
|
50
|
+
result = await mod.probe();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
result = { available: false };
|
|
54
|
+
}
|
|
55
|
+
const entry = {
|
|
56
|
+
id: mod.id,
|
|
57
|
+
displayName: mod.displayName,
|
|
58
|
+
binary: mod.binary,
|
|
59
|
+
result,
|
|
60
|
+
};
|
|
61
|
+
if (mod.installHint)
|
|
62
|
+
entry.installHint = mod.installHint;
|
|
63
|
+
out.push(entry);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/** capabilities 上报用:available 且非 hidden;fake 仅在显式启用时加入。 */
|
|
68
|
+
export async function detectAvailableRuntimeIds() {
|
|
69
|
+
const entries = await detectRuntimes();
|
|
70
|
+
const ids = entries.filter((e) => e.result.available).map((e) => e.id);
|
|
71
|
+
if (fakeRuntimeEnabled())
|
|
72
|
+
ids.push(fakeModule.id);
|
|
73
|
+
return ids;
|
|
74
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { NdjsonStreamAdapter, type NdjsonEventCtx } from "./ndjson-stream.js";
|
|
2
|
+
import { type ProbeDeps } from "./probe.js";
|
|
3
|
+
import { type EngineRunOptions, type EngineRunResult } from "./engine.js";
|
|
4
|
+
import type { Logger } from "../log.js";
|
|
5
|
+
import type { RuntimeModule, RuntimeProbe } from "../types.js";
|
|
6
|
+
/** 在 PATH 上解析 kimi 可执行文件。 */
|
|
7
|
+
export declare function resolveKimiCommand(deps?: ProbeDeps): string | null;
|
|
8
|
+
/** 探测 kimi CLI 是否安装并读取版本。 */
|
|
9
|
+
export declare function probeKimi(deps?: ProbeDeps): RuntimeProbe;
|
|
10
|
+
/**
|
|
11
|
+
* Kimi CLI adapter — spawn:
|
|
12
|
+
*
|
|
13
|
+
* kimi [--work-dir <cwd>] --print --output-format stream-json --session <sid> --afk --prompt <text>
|
|
14
|
+
*
|
|
15
|
+
* `--session <sid>` 会 resume 已有会话或用该 id 新建会话,所以首轮由
|
|
16
|
+
* adapter 在客户端生成 UUID 并持久化供后续轮次使用。kimi 没有 Codex 式的
|
|
17
|
+
* per-invocation AGENTS.md 载体,动态 systemContext 走 prompt 的
|
|
18
|
+
* <system-reminder> 前缀。注意 kimi 会冻结每个会话的系统提示 ——
|
|
19
|
+
* 若复用会话,systemContext 更新可能需要新会话才生效。
|
|
20
|
+
*/
|
|
21
|
+
export declare class KimiAdapter extends NdjsonStreamAdapter {
|
|
22
|
+
readonly id: "kimi-cli";
|
|
23
|
+
private readonly explicitBinary;
|
|
24
|
+
private resolvedBinary;
|
|
25
|
+
constructor(opts?: {
|
|
26
|
+
binary?: string;
|
|
27
|
+
logger?: Logger;
|
|
28
|
+
});
|
|
29
|
+
run(opts: EngineRunOptions): Promise<EngineRunResult>;
|
|
30
|
+
protected resolveBinary(): string;
|
|
31
|
+
protected buildArgs(opts: EngineRunOptions): string[];
|
|
32
|
+
protected spawnEnv(opts: EngineRunOptions): NodeJS.ProcessEnv;
|
|
33
|
+
protected handleEvent(raw: unknown, ctx: NdjsonEventCtx): void;
|
|
34
|
+
}
|
|
35
|
+
export declare const kimiCliModule: RuntimeModule;
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// 权限姿态:--afk(自动批准全部工具调用)——daemon 无审批中继,
|
|
2
|
+
// 且每个 run 的工作区已按 agent_run_id 隔离。
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { NdjsonStreamAdapter } from "./ndjson-stream.js";
|
|
6
|
+
import { readCommandVersion, resolveCommandOnPath } from "./probe.js";
|
|
7
|
+
import { wrapEngineAdapter, } from "./engine.js";
|
|
8
|
+
function isValidKimiSessionId(sessionId) {
|
|
9
|
+
if (sessionId.length === 0 || sessionId.length > 512)
|
|
10
|
+
return false;
|
|
11
|
+
if (sessionId.startsWith("-"))
|
|
12
|
+
return false;
|
|
13
|
+
for (const ch of sessionId) {
|
|
14
|
+
const code = ch.codePointAt(0);
|
|
15
|
+
if (code === undefined || code < 0x20 || code === 0x7f)
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
function invalidKimiSessionIdError() {
|
|
21
|
+
return "kimi-cli: invalid sessionId (expected non-control text not starting with '-')";
|
|
22
|
+
}
|
|
23
|
+
const KIMI_EXTRA_FLAGS_WITH_VALUE = new Set([
|
|
24
|
+
"--add-dir",
|
|
25
|
+
"--agent",
|
|
26
|
+
"--agent-file",
|
|
27
|
+
"--config",
|
|
28
|
+
"--config-file",
|
|
29
|
+
"--max-ralph-iterations",
|
|
30
|
+
"--max-retries-per-step",
|
|
31
|
+
"--max-steps-per-turn",
|
|
32
|
+
"--mcp-config",
|
|
33
|
+
"--mcp-config-file",
|
|
34
|
+
"--model",
|
|
35
|
+
"--skills-dir",
|
|
36
|
+
"-m",
|
|
37
|
+
]);
|
|
38
|
+
const KIMI_EXTRA_BOOLEAN_FLAGS = new Set([
|
|
39
|
+
"--afk",
|
|
40
|
+
"--auto-approve",
|
|
41
|
+
"--debug",
|
|
42
|
+
"--no-thinking",
|
|
43
|
+
"--plan",
|
|
44
|
+
"--thinking",
|
|
45
|
+
"--verbose",
|
|
46
|
+
"--yes",
|
|
47
|
+
"--yolo",
|
|
48
|
+
"-y",
|
|
49
|
+
]);
|
|
50
|
+
// adapter 独占的 flag:daemon 依赖 kimi 的非交互 stream-json 契约、
|
|
51
|
+
// cwd 隔离、prompt 位置与会话路由,不允许 extraArgs 覆盖。
|
|
52
|
+
const KIMI_ADAPTER_OWNED_FLAGS = new Set([
|
|
53
|
+
"--acp",
|
|
54
|
+
"--command",
|
|
55
|
+
"--continue",
|
|
56
|
+
"--final-message-only",
|
|
57
|
+
"--help",
|
|
58
|
+
"--input-format",
|
|
59
|
+
"--output-format",
|
|
60
|
+
"--print",
|
|
61
|
+
"--prompt",
|
|
62
|
+
"--quiet",
|
|
63
|
+
"--resume",
|
|
64
|
+
"--session",
|
|
65
|
+
"--version",
|
|
66
|
+
"--wire",
|
|
67
|
+
"--work-dir",
|
|
68
|
+
"-C",
|
|
69
|
+
"-S",
|
|
70
|
+
"-V",
|
|
71
|
+
"-c",
|
|
72
|
+
"-h",
|
|
73
|
+
"-p",
|
|
74
|
+
"-r",
|
|
75
|
+
"-w",
|
|
76
|
+
]);
|
|
77
|
+
const kimiWorkDirSupport = new Map();
|
|
78
|
+
function flagName(arg) {
|
|
79
|
+
if (!arg.startsWith("-"))
|
|
80
|
+
return arg;
|
|
81
|
+
const eq = arg.indexOf("=");
|
|
82
|
+
return eq === -1 ? arg : arg.slice(0, eq);
|
|
83
|
+
}
|
|
84
|
+
function nextValue(args, index) {
|
|
85
|
+
const next = args[index + 1];
|
|
86
|
+
if (typeof next !== "string")
|
|
87
|
+
return undefined;
|
|
88
|
+
if (!next.startsWith("-"))
|
|
89
|
+
return next;
|
|
90
|
+
// 负数值(如 -1)不是 flag。
|
|
91
|
+
return /^-\d/.test(next) ? next : undefined;
|
|
92
|
+
}
|
|
93
|
+
// allowlist 式净化(四个 CLI adapter 中最严格):未知 flag 连同其值一起丢弃。
|
|
94
|
+
function sanitizeKimiExtraArgs(extraArgs) {
|
|
95
|
+
if (!extraArgs?.length)
|
|
96
|
+
return [];
|
|
97
|
+
const out = [];
|
|
98
|
+
for (let i = 0; i < extraArgs.length; i += 1) {
|
|
99
|
+
const arg = extraArgs[i];
|
|
100
|
+
const name = flagName(arg);
|
|
101
|
+
if (KIMI_ADAPTER_OWNED_FLAGS.has(name)) {
|
|
102
|
+
if (!arg.includes("=") && nextValue(extraArgs, i) !== undefined)
|
|
103
|
+
i += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (KIMI_EXTRA_FLAGS_WITH_VALUE.has(name)) {
|
|
107
|
+
if (arg.includes("=")) {
|
|
108
|
+
out.push(arg);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const value = nextValue(extraArgs, i);
|
|
112
|
+
if (value !== undefined) {
|
|
113
|
+
out.push(arg, value);
|
|
114
|
+
i += 1;
|
|
115
|
+
}
|
|
116
|
+
// 缺值的 value flag 直接丢弃,避免传出非法 argv。
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (KIMI_EXTRA_BOOLEAN_FLAGS.has(name)) {
|
|
120
|
+
out.push(arg);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (arg.startsWith("-") && !arg.includes("=") && nextValue(extraArgs, i) !== undefined) {
|
|
124
|
+
i += 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
function parseKimiVersionMajor(version) {
|
|
130
|
+
const match = version?.match(/\b(\d+)\.(\d+)\.(\d+)\b/);
|
|
131
|
+
if (!match)
|
|
132
|
+
return null;
|
|
133
|
+
return Number.parseInt(match[1], 10);
|
|
134
|
+
}
|
|
135
|
+
// `--work-dir` 只有较新的 kimi 才支持:用 `--help` 输出探测(5s 超时),
|
|
136
|
+
// 探测失败时退回「主版本号 ≥ 1」的启发式;结果按二进制路径缓存。
|
|
137
|
+
function probeKimiSupportsWorkDir(command) {
|
|
138
|
+
const cached = kimiWorkDirSupport.get(command);
|
|
139
|
+
if (cached !== undefined)
|
|
140
|
+
return cached;
|
|
141
|
+
let supported = false;
|
|
142
|
+
try {
|
|
143
|
+
const help = execFileSync(command, ["--help"], {
|
|
144
|
+
encoding: "utf8",
|
|
145
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
146
|
+
timeout: 5000,
|
|
147
|
+
});
|
|
148
|
+
supported = /(?:^|\s)--work-dir(?:[=\s,]|$)/.test(help);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
const major = parseKimiVersionMajor(readCommandVersion(command));
|
|
152
|
+
supported = major !== null && major >= 1;
|
|
153
|
+
}
|
|
154
|
+
kimiWorkDirSupport.set(command, supported);
|
|
155
|
+
return supported;
|
|
156
|
+
}
|
|
157
|
+
/** 在 PATH 上解析 kimi 可执行文件。 */
|
|
158
|
+
export function resolveKimiCommand(deps = {}) {
|
|
159
|
+
return resolveCommandOnPath("kimi", deps);
|
|
160
|
+
}
|
|
161
|
+
/** 探测 kimi CLI 是否安装并读取版本。 */
|
|
162
|
+
export function probeKimi(deps = {}) {
|
|
163
|
+
const command = resolveKimiCommand(deps);
|
|
164
|
+
if (!command)
|
|
165
|
+
return { available: false };
|
|
166
|
+
return {
|
|
167
|
+
available: true,
|
|
168
|
+
path: command,
|
|
169
|
+
version: readCommandVersion(command, [], deps) ?? undefined,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Kimi CLI adapter — spawn:
|
|
174
|
+
*
|
|
175
|
+
* kimi [--work-dir <cwd>] --print --output-format stream-json --session <sid> --afk --prompt <text>
|
|
176
|
+
*
|
|
177
|
+
* `--session <sid>` 会 resume 已有会话或用该 id 新建会话,所以首轮由
|
|
178
|
+
* adapter 在客户端生成 UUID 并持久化供后续轮次使用。kimi 没有 Codex 式的
|
|
179
|
+
* per-invocation AGENTS.md 载体,动态 systemContext 走 prompt 的
|
|
180
|
+
* <system-reminder> 前缀。注意 kimi 会冻结每个会话的系统提示 ——
|
|
181
|
+
* 若复用会话,systemContext 更新可能需要新会话才生效。
|
|
182
|
+
*/
|
|
183
|
+
export class KimiAdapter extends NdjsonStreamAdapter {
|
|
184
|
+
id = "kimi-cli";
|
|
185
|
+
explicitBinary;
|
|
186
|
+
resolvedBinary = null;
|
|
187
|
+
constructor(opts) {
|
|
188
|
+
super(opts?.logger);
|
|
189
|
+
this.explicitBinary = opts?.binary ?? process.env.BOTLEARN_KIMI_CLI_BIN;
|
|
190
|
+
}
|
|
191
|
+
async run(opts) {
|
|
192
|
+
if (opts.sessionId && !isValidKimiSessionId(opts.sessionId)) {
|
|
193
|
+
// 返回空 newSessionId 作为删除信号(不抛),让调用方丢弃过期条目。
|
|
194
|
+
return { text: "", newSessionId: "", error: invalidKimiSessionIdError() };
|
|
195
|
+
}
|
|
196
|
+
const sessionId = opts.sessionId || randomUUID();
|
|
197
|
+
return super.run({ ...opts, sessionId });
|
|
198
|
+
}
|
|
199
|
+
resolveBinary() {
|
|
200
|
+
if (this.explicitBinary)
|
|
201
|
+
return this.explicitBinary;
|
|
202
|
+
if (this.resolvedBinary)
|
|
203
|
+
return this.resolvedBinary;
|
|
204
|
+
this.resolvedBinary = resolveKimiCommand() ?? "kimi";
|
|
205
|
+
return this.resolvedBinary;
|
|
206
|
+
}
|
|
207
|
+
buildArgs(opts) {
|
|
208
|
+
const sessionId = opts.sessionId || randomUUID();
|
|
209
|
+
if (!isValidKimiSessionId(sessionId))
|
|
210
|
+
throw new Error(invalidKimiSessionIdError());
|
|
211
|
+
const args = ["--print", "--output-format", "stream-json", "--session", sessionId, "--afk"];
|
|
212
|
+
if (probeKimiSupportsWorkDir(this.resolveBinary()))
|
|
213
|
+
args.unshift("--work-dir", opts.cwd);
|
|
214
|
+
args.push(...sanitizeKimiExtraArgs(opts.extraArgs));
|
|
215
|
+
args.push("--prompt", promptWithSystemContext(opts.text, opts.systemContext));
|
|
216
|
+
return args;
|
|
217
|
+
}
|
|
218
|
+
spawnEnv(opts) {
|
|
219
|
+
return {
|
|
220
|
+
...super.spawnEnv(opts),
|
|
221
|
+
// 无论用户终端设置如何都保持 stream-json 干净。
|
|
222
|
+
FORCE_COLOR: "0",
|
|
223
|
+
NO_COLOR: "1",
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
handleEvent(raw, ctx) {
|
|
227
|
+
const obj = raw;
|
|
228
|
+
const status = kimiStatusEvent(obj);
|
|
229
|
+
if (status)
|
|
230
|
+
ctx.emitStatus(status);
|
|
231
|
+
ctx.emitBlock(normalizeBlock(obj, ctx.seq));
|
|
232
|
+
const sessionId = kimiSessionId(obj);
|
|
233
|
+
if (sessionId)
|
|
234
|
+
ctx.state.newSessionId = sessionId;
|
|
235
|
+
if (obj.role === "assistant") {
|
|
236
|
+
const text = extractText(obj.content);
|
|
237
|
+
if (text) {
|
|
238
|
+
ctx.appendAssistantText(text);
|
|
239
|
+
// 每条 assistant 消息整体覆盖 finalText —— 最后一条获胜。
|
|
240
|
+
ctx.state.finalText = text;
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const err = kimiErrorText(obj);
|
|
245
|
+
if (err)
|
|
246
|
+
ctx.state.errorText = err;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function promptWithSystemContext(text, systemContext) {
|
|
250
|
+
if (!systemContext)
|
|
251
|
+
return text;
|
|
252
|
+
return `<system-reminder>\n${systemContext}\n</system-reminder>\n\n${text}`;
|
|
253
|
+
}
|
|
254
|
+
function extractText(content) {
|
|
255
|
+
if (typeof content === "string")
|
|
256
|
+
return content;
|
|
257
|
+
if (!Array.isArray(content))
|
|
258
|
+
return "";
|
|
259
|
+
return content
|
|
260
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
261
|
+
.map((part) => part.text)
|
|
262
|
+
.join("");
|
|
263
|
+
}
|
|
264
|
+
function hasThinking(content) {
|
|
265
|
+
return Array.isArray(content)
|
|
266
|
+
? content.some((part) => part?.type === "think" && typeof part.think === "string" && part.think)
|
|
267
|
+
: false;
|
|
268
|
+
}
|
|
269
|
+
function firstToolName(toolCalls) {
|
|
270
|
+
const name = toolCalls?.find((t) => typeof t.function?.name === "string")?.function?.name;
|
|
271
|
+
return name || "tool";
|
|
272
|
+
}
|
|
273
|
+
function kimiSessionId(obj) {
|
|
274
|
+
return typeof obj.session_id === "string" && obj.session_id ? obj.session_id : undefined;
|
|
275
|
+
}
|
|
276
|
+
function kimiErrorText(obj) {
|
|
277
|
+
if (typeof obj.error === "string" && obj.error)
|
|
278
|
+
return obj.error;
|
|
279
|
+
if (obj.error && typeof obj.error === "object") {
|
|
280
|
+
const message = obj.error.message;
|
|
281
|
+
if (typeof message === "string" && message)
|
|
282
|
+
return message;
|
|
283
|
+
}
|
|
284
|
+
if (obj.type === "error" && typeof obj.message === "string" && obj.message) {
|
|
285
|
+
return obj.message;
|
|
286
|
+
}
|
|
287
|
+
if (obj.severity === "error") {
|
|
288
|
+
return [obj.title, obj.body].filter(Boolean).join(": ") || "kimi-cli error";
|
|
289
|
+
}
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
function kimiStatusEvent(obj) {
|
|
293
|
+
if (obj.role === "assistant" && hasThinking(obj.content)) {
|
|
294
|
+
return { kind: "thinking", phase: "started", label: "Thinking" };
|
|
295
|
+
}
|
|
296
|
+
if (obj.role === "assistant" && obj.tool_calls?.length) {
|
|
297
|
+
return { kind: "thinking", phase: "updated", label: firstToolName(obj.tool_calls) };
|
|
298
|
+
}
|
|
299
|
+
if (obj.role === "assistant" && extractText(obj.content)) {
|
|
300
|
+
return { kind: "thinking", phase: "stopped" };
|
|
301
|
+
}
|
|
302
|
+
if (obj.role === "tool") {
|
|
303
|
+
return { kind: "thinking", phase: "updated", label: "Tool result" };
|
|
304
|
+
}
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
function normalizeBlock(obj, seq) {
|
|
308
|
+
let kind = "other";
|
|
309
|
+
if (obj.role === "assistant") {
|
|
310
|
+
if (obj.tool_calls?.length)
|
|
311
|
+
kind = "tool_use";
|
|
312
|
+
else if (extractText(obj.content))
|
|
313
|
+
kind = "assistant_text";
|
|
314
|
+
else if (hasThinking(obj.content))
|
|
315
|
+
kind = "other";
|
|
316
|
+
}
|
|
317
|
+
else if (obj.role === "tool") {
|
|
318
|
+
kind = "tool_result";
|
|
319
|
+
}
|
|
320
|
+
else if (obj.file_path && typeof obj.content === "string") {
|
|
321
|
+
kind = "other";
|
|
322
|
+
}
|
|
323
|
+
else if (obj.category || obj.severity) {
|
|
324
|
+
kind = "system";
|
|
325
|
+
}
|
|
326
|
+
return { raw: obj, kind, seq };
|
|
327
|
+
}
|
|
328
|
+
export const kimiCliModule = {
|
|
329
|
+
id: "kimi-cli",
|
|
330
|
+
displayName: "Kimi CLI",
|
|
331
|
+
binary: "kimi",
|
|
332
|
+
envVar: "BOTLEARN_KIMI_CLI_BIN",
|
|
333
|
+
probe: async () => probeKimi(),
|
|
334
|
+
create: () => wrapEngineAdapter("kimi-cli", new KimiAdapter()),
|
|
335
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type EngineAdapter, type EngineRunOptions, type EngineRunResult, type RuntimeStatusEvent, type StreamBlock } from "./engine.js";
|
|
2
|
+
import type { Logger } from "../log.js";
|
|
3
|
+
/** 单轮执行期间穿过事件回调的可变状态;基类据此组装最终 EngineRunResult。 */
|
|
4
|
+
export interface NdjsonRunState {
|
|
5
|
+
/** 供 resume 持久化的会话 id。以传入的 sessionId 起始。 */
|
|
6
|
+
newSessionId: string;
|
|
7
|
+
/** 终态 "result"/"completed" 事件报告的最终文本(若有)。 */
|
|
8
|
+
finalText: string;
|
|
9
|
+
/** 流式 assistant 文本块;finalText 为空时拼接兜底。 */
|
|
10
|
+
assistantTextChunks: string[];
|
|
11
|
+
assistantTextBytes: number;
|
|
12
|
+
/** 命中单轮文本上限后为 true;后续块直接丢弃。 */
|
|
13
|
+
assistantTextCapped: boolean;
|
|
14
|
+
costUsd?: number;
|
|
15
|
+
errorText?: string;
|
|
16
|
+
/** 部分 CLI 会报告用量(如 Codex turn.completed.usage);仅本地诊断用。 */
|
|
17
|
+
usage?: {
|
|
18
|
+
inputCacheHitTokens?: number;
|
|
19
|
+
inputCacheMissTokens?: number;
|
|
20
|
+
outputTokens?: number;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** ndjson 分发循环递给子类的逐事件上下文。 */
|
|
24
|
+
export interface NdjsonEventCtx {
|
|
25
|
+
state: NdjsonRunState;
|
|
26
|
+
/** 本轮 1-based 序号,与 onBlock 看到的一致。 */
|
|
27
|
+
seq: number;
|
|
28
|
+
emitBlock: (block: StreamBlock) => void;
|
|
29
|
+
/** 推入流式 assistant 文本(尊重单轮字节上限);子类勿直接 push state。 */
|
|
30
|
+
appendAssistantText: (text: string) => void;
|
|
31
|
+
/** 转发 typing/thinking 状态事件;回调抛错会被吞掉。 */
|
|
32
|
+
emitStatus: (event: RuntimeStatusEvent) => void;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 输出 newline-delimited JSON 的 CLI adapter 公共骨架。子类只需提供:
|
|
36
|
+
* - resolveBinary() — 要 spawn 的可执行文件
|
|
37
|
+
* - buildArgs() — argv 尾部(不含二进制本身)
|
|
38
|
+
* - handleEvent() — 如何解释一条已解析的 JSON 事件
|
|
39
|
+
* spawn、abort、stderr 收集、行切分与 exit-code 错误合成由基类处理。
|
|
40
|
+
*/
|
|
41
|
+
export declare abstract class NdjsonStreamAdapter implements EngineAdapter {
|
|
42
|
+
abstract readonly id: string;
|
|
43
|
+
protected readonly log: Logger;
|
|
44
|
+
constructor(logger?: Logger);
|
|
45
|
+
protected abstract resolveBinary(opts: EngineRunOptions): string;
|
|
46
|
+
protected abstract buildArgs(opts: EngineRunOptions): string[];
|
|
47
|
+
protected abstract handleEvent(obj: unknown, ctx: NdjsonEventCtx): void;
|
|
48
|
+
/** 覆盖以调整 env(FORCE_COLOR=0、NO_COLOR=1 等)。 */
|
|
49
|
+
protected spawnEnv(_opts: EngineRunOptions): NodeJS.ProcessEnv;
|
|
50
|
+
run(opts: EngineRunOptions): Promise<EngineRunResult>;
|
|
51
|
+
}
|