@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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/dist/agent-service-client.d.ts +18 -0
  4. package/dist/agent-service-client.js +108 -0
  5. package/dist/auth-store.d.ts +16 -0
  6. package/dist/auth-store.js +106 -0
  7. package/dist/cli.d.ts +24 -0
  8. package/dist/cli.js +354 -0
  9. package/dist/course-client.d.ts +46 -0
  10. package/dist/course-client.js +143 -0
  11. package/dist/doctor.d.ts +15 -0
  12. package/dist/doctor.js +85 -0
  13. package/dist/file-candidates.d.ts +34 -0
  14. package/dist/file-candidates.js +173 -0
  15. package/dist/index.d.ts +19 -0
  16. package/dist/index.js +19 -0
  17. package/dist/log.d.ts +20 -0
  18. package/dist/log.js +154 -0
  19. package/dist/path-env.d.ts +8 -0
  20. package/dist/path-env.js +42 -0
  21. package/dist/redaction.d.ts +24 -0
  22. package/dist/redaction.js +158 -0
  23. package/dist/run-dispatcher.d.ts +43 -0
  24. package/dist/run-dispatcher.js +294 -0
  25. package/dist/run-queue.d.ts +11 -0
  26. package/dist/run-queue.js +26 -0
  27. package/dist/runtime-capabilities.d.ts +3 -0
  28. package/dist/runtime-capabilities.js +42 -0
  29. package/dist/runtime-profile.d.ts +8 -0
  30. package/dist/runtime-profile.js +213 -0
  31. package/dist/runtimes/acp-stream.d.ts +96 -0
  32. package/dist/runtimes/acp-stream.js +488 -0
  33. package/dist/runtimes/claude-code.d.ts +41 -0
  34. package/dist/runtimes/claude-code.js +353 -0
  35. package/dist/runtimes/codex.d.ts +44 -0
  36. package/dist/runtimes/codex.js +332 -0
  37. package/dist/runtimes/deepseek-tui.d.ts +50 -0
  38. package/dist/runtimes/deepseek-tui.js +701 -0
  39. package/dist/runtimes/engine.d.ts +52 -0
  40. package/dist/runtimes/engine.js +127 -0
  41. package/dist/runtimes/fake.d.ts +13 -0
  42. package/dist/runtimes/fake.js +45 -0
  43. package/dist/runtimes/gemini.d.ts +39 -0
  44. package/dist/runtimes/gemini.js +251 -0
  45. package/dist/runtimes/hermes-agent.d.ts +61 -0
  46. package/dist/runtimes/hermes-agent.js +173 -0
  47. package/dist/runtimes/index.d.ts +15 -0
  48. package/dist/runtimes/index.js +74 -0
  49. package/dist/runtimes/kimi.d.ts +35 -0
  50. package/dist/runtimes/kimi.js +335 -0
  51. package/dist/runtimes/ndjson-stream.d.ts +51 -0
  52. package/dist/runtimes/ndjson-stream.js +207 -0
  53. package/dist/runtimes/openclaw-acp.d.ts +52 -0
  54. package/dist/runtimes/openclaw-acp.js +872 -0
  55. package/dist/runtimes/probe.d.ts +17 -0
  56. package/dist/runtimes/probe.js +54 -0
  57. package/dist/runtimes/runtime-errors.d.ts +20 -0
  58. package/dist/runtimes/runtime-errors.js +95 -0
  59. package/dist/runtimes/text-cap.d.ts +7 -0
  60. package/dist/runtimes/text-cap.js +25 -0
  61. package/dist/transcript.d.ts +13 -0
  62. package/dist/transcript.js +46 -0
  63. package/dist/types.d.ts +199 -0
  64. package/dist/types.js +17 -0
  65. package/dist/workspace.d.ts +23 -0
  66. package/dist/workspace.js +54 -0
  67. package/package.json +40 -0
@@ -0,0 +1,17 @@
1
+ import { execFileSync } from "node:child_process";
2
+ /** Injection seam for PATH resolution + version probes, so tests can stub syscalls. */
3
+ export interface ProbeDeps {
4
+ platform?: NodeJS.Platform;
5
+ env?: NodeJS.ProcessEnv;
6
+ homeDir?: string;
7
+ execFileSyncFn?: typeof execFileSync;
8
+ existsSyncFn?: (p: string) => boolean;
9
+ }
10
+ /** Resolve a command name on PATH via `which`/`where`; returns null when missing. */
11
+ export declare function resolveCommandOnPath(command: string, deps?: ProbeDeps): string | null;
12
+ /** Return the first path in `candidates` that exists on disk, or null. */
13
+ export declare function firstExistingPath(candidates: string[], deps?: ProbeDeps): string | null;
14
+ /** Run `<command> [...args] --version` and return the first output line, or null. */
15
+ export declare function readCommandVersion(command: string, args?: string[], deps?: ProbeDeps): string | null;
16
+ /** Join `relativePath` against HOME (falls back to empty when unset). */
17
+ export declare function resolveHomePath(relativePath: string, deps?: ProbeDeps): string;
@@ -0,0 +1,54 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ function normalizeExecOutput(raw) {
5
+ return Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw ?? "");
6
+ }
7
+ /** Resolve a command name on PATH via `which`/`where`; returns null when missing. */
8
+ export function resolveCommandOnPath(command, deps = {}) {
9
+ const platform = deps.platform ?? process.platform;
10
+ const env = deps.env ?? process.env;
11
+ const execFn = deps.execFileSyncFn ?? execFileSync;
12
+ const locator = platform === "win32" ? "where" : "which";
13
+ try {
14
+ const out = normalizeExecOutput(execFn(locator, [command], {
15
+ stdio: ["ignore", "pipe", "ignore"],
16
+ env,
17
+ }));
18
+ const resolved = out.trim().split(/\r?\n/)[0];
19
+ return resolved || null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ /** Return the first path in `candidates` that exists on disk, or null. */
26
+ export function firstExistingPath(candidates, deps = {}) {
27
+ const exists = deps.existsSyncFn ?? existsSync;
28
+ for (const c of candidates) {
29
+ if (exists(c))
30
+ return c;
31
+ }
32
+ return null;
33
+ }
34
+ /** Run `<command> [...args] --version` and return the first output line, or null. */
35
+ export function readCommandVersion(command, args = [], deps = {}) {
36
+ const env = deps.env ?? process.env;
37
+ const execFn = deps.execFileSyncFn ?? execFileSync;
38
+ try {
39
+ const out = normalizeExecOutput(execFn(command, [...args, "--version"], {
40
+ stdio: ["ignore", "pipe", "pipe"],
41
+ env,
42
+ timeout: 5000,
43
+ }));
44
+ return out.trim().split(/\r?\n/)[0] || null;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /** Join `relativePath` against HOME (falls back to empty when unset). */
51
+ export function resolveHomePath(relativePath, deps = {}) {
52
+ const home = deps.homeDir ?? deps.env?.HOME ?? process.env.HOME ?? "";
53
+ return path.join(home, relativePath);
54
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Runtime CLIs sometimes report authentication failures as ordinary final
3
+ * text. Keep this intentionally narrow so normal model replies about auth do
4
+ * not get reclassified unless they look like a top-level CLI/API failure.
5
+ */
6
+ export declare function looksLikeRuntimeAuthFailure(text: string): boolean;
7
+ export declare function looksLikeUsageLimit(text: string): boolean;
8
+ /**
9
+ * Pull the reset hint straight out of the runtime's own words — never compute it
10
+ * locally, since the runtime already knows the user's plan window and timezone
11
+ * and we do not. Returns a ready-to-render fragment: an absolute clock time like
12
+ * "2pm (America/New_York)", a relative "in 3h 42m", or null when the runtime
13
+ * gave no hint.
14
+ */
15
+ export declare function extractUsageLimitReset(text: string): string | null;
16
+ /**
17
+ * Compose the calm, user-facing line for a usage/rate-limit error. Callers
18
+ * should gate on {@link looksLikeUsageLimit} first.
19
+ */
20
+ export declare function formatUsageLimitMessage(text: string, runtime?: string): string;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Runtime CLIs sometimes report authentication failures as ordinary final
3
+ * text. Keep this intentionally narrow so normal model replies about auth do
4
+ * not get reclassified unless they look like a top-level CLI/API failure.
5
+ */
6
+ export function looksLikeRuntimeAuthFailure(text) {
7
+ const s = text.trim();
8
+ if (!s)
9
+ return false;
10
+ return (/^(Failed to authenticate|Authentication failed|Invalid API key|Invalid Anthropic API key)\b/i.test(s) ||
11
+ /^API Error:\s*4\d\d\b/i.test(s) ||
12
+ /\b(API Error:\s*4\d\d|Request not allowed|invalid x-api-key)\b/i.test(s) ||
13
+ /^(Unauthorized|Forbidden)(?:\b|:)/i.test(s));
14
+ }
15
+ /**
16
+ * Usage / rate-limit exhaustion is reported by runtimes as ordinary error text,
17
+ * not a distinct status. Claude Code emits "Claude usage limit reached. Your
18
+ * limit will reset at 2pm (America/New_York)"; Codex emits "You've reached your
19
+ * 5-hour message limit. Try again in 3h 42m." or a bare
20
+ * `{"type":"error","error":{"type":"usage_limit_reached",...}}` blob. Detect it
21
+ * so the dispatcher can surface a calm, reset-time-forward sentence instead of a
22
+ * red "Runtime error" wrapped in an exit code and an error_ref.
23
+ */
24
+ const USAGE_LIMIT_PATTERNS = [
25
+ /usage[_\s-]?limit[_\s-]?reached/i,
26
+ /\b\d+-hour (?:message )?limit\b/i,
27
+ /you'?ve (?:hit|reached) your (?:usage |message |daily |weekly )*limit/i,
28
+ /\binsufficient_quota\b/i,
29
+ /\bquota (?:exceeded|exhausted|reached)\b/i,
30
+ /\brate[_\s-]?limit(?:_?(?:exceeded|error)|ed|\s+(?:reached|exceeded|hit))/i,
31
+ ];
32
+ export function looksLikeUsageLimit(text) {
33
+ const s = text?.trim();
34
+ if (!s)
35
+ return false;
36
+ return USAGE_LIMIT_PATTERNS.some((re) => re.test(s));
37
+ }
38
+ /**
39
+ * Pull the reset hint straight out of the runtime's own words — never compute it
40
+ * locally, since the runtime already knows the user's plan window and timezone
41
+ * and we do not. Returns a ready-to-render fragment: an absolute clock time like
42
+ * "2pm (America/New_York)", a relative "in 3h 42m", or null when the runtime
43
+ * gave no hint.
44
+ */
45
+ export function extractUsageLimitReset(text) {
46
+ const s = (text ?? "").trim();
47
+ if (!s)
48
+ return null;
49
+ // Absolute: "...reset at 2pm (America/New_York)" /
50
+ // "reset at approximately 11:00 PM Europe/Berlin time"
51
+ const at = s.match(/reset(?:s|ting)?\s+at\s+(?:approximately\s+)?(.+?)(?:\s+time\b)?\s*(?:[.,\n]|$)/i);
52
+ if (at?.[1])
53
+ return collapseWs(at[1]);
54
+ // Relative: "Try again in 3h 42m" / "please try again after 5 minutes"
55
+ const rel = s.match(/try again (?:in|after)\s+(.+?)\s*(?:[.,\n]|$)/i);
56
+ if (rel?.[1])
57
+ return `in ${collapseWs(rel[1])}`;
58
+ // Legacy Claude headless pipe form: "...reached|1719345600"
59
+ const pipe = s.match(/reached\s*\|\s*(\d{10,13})/);
60
+ if (pipe?.[1]) {
61
+ const ms = pipe[1].length >= 13 ? Number(pipe[1]) : Number(pipe[1]) * 1000;
62
+ if (Number.isFinite(ms))
63
+ return `${new Date(ms).toISOString().replace("T", " ").slice(0, 16)} UTC`;
64
+ }
65
+ return null;
66
+ }
67
+ /**
68
+ * Compose the calm, user-facing line for a usage/rate-limit error. Callers
69
+ * should gate on {@link looksLikeUsageLimit} first.
70
+ */
71
+ export function formatUsageLimitMessage(text, runtime) {
72
+ const who = runtimeLabel(runtime);
73
+ const reset = extractUsageLimitReset(text);
74
+ if (reset) {
75
+ const when = /^in\s/i.test(reset) ? `resets ${reset}` : `resets at ${reset}`;
76
+ return `${who} usage limit reached — ${when}.`;
77
+ }
78
+ return `${who} usage limit reached — please try again later.`;
79
+ }
80
+ function runtimeLabel(runtime) {
81
+ switch ((runtime ?? "").toLowerCase()) {
82
+ case "claude-code":
83
+ case "claude":
84
+ return "Claude Code";
85
+ case "codex":
86
+ return "Codex";
87
+ case "gemini":
88
+ return "Gemini";
89
+ default:
90
+ return "Agent runtime";
91
+ }
92
+ }
93
+ function collapseWs(s) {
94
+ return s.replace(/\s+/g, " ").trim();
95
+ }
@@ -0,0 +1,7 @@
1
+ /** Return the UTF-8 byte length of a string. */
2
+ export declare function utf8ByteLength(text: string): number;
3
+ /**
4
+ * Slice a string to at most `maxBytes` UTF-8 bytes without splitting a code
5
+ * point. Used by runtime adapters whose safety caps are byte-oriented.
6
+ */
7
+ export declare function sliceUtf8Bytes(text: string, maxBytes: number): string;
@@ -0,0 +1,25 @@
1
+ import { Buffer } from "node:buffer";
2
+ /** Return the UTF-8 byte length of a string. */
3
+ export function utf8ByteLength(text) {
4
+ return Buffer.byteLength(text, "utf8");
5
+ }
6
+ /**
7
+ * Slice a string to at most `maxBytes` UTF-8 bytes without splitting a code
8
+ * point. Used by runtime adapters whose safety caps are byte-oriented.
9
+ */
10
+ export function sliceUtf8Bytes(text, maxBytes) {
11
+ if (maxBytes <= 0)
12
+ return "";
13
+ if (utf8ByteLength(text) <= maxBytes)
14
+ return text;
15
+ let used = 0;
16
+ let out = "";
17
+ for (const ch of text) {
18
+ const n = utf8ByteLength(ch);
19
+ if (used + n > maxBytes)
20
+ break;
21
+ out += ch;
22
+ used += n;
23
+ }
24
+ return out;
25
+ }
@@ -0,0 +1,13 @@
1
+ import type { RuntimeBlock } from "./types.js";
2
+ /**
3
+ * Transcript writer:块和最终回复追加写入 transcript.jsonl,供本地诊断与回放。
4
+ * 所有 text/raw 落盘前脱敏;raw 只进本地 transcript,不上 wire。
5
+ */
6
+ export declare class TranscriptWriter {
7
+ private readonly file;
8
+ constructor(file: string);
9
+ writeBlock(block: RuntimeBlock): void;
10
+ writeFinal(text: string): void;
11
+ private append;
12
+ get path(): string;
13
+ }
@@ -0,0 +1,46 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { redactSecretString, redactSecretsDeep, truncateText } from "./redaction.js";
4
+ // raw 深度脱敏后再序列化;序列化超过 8KiB 时整体降级为截断字符串(宁可丢结构不可撑爆磁盘)。
5
+ const RAW_SERIALIZED_MAX_CHARS = 8 * 1024;
6
+ /**
7
+ * Transcript writer:块和最终回复追加写入 transcript.jsonl,供本地诊断与回放。
8
+ * 所有 text/raw 落盘前脱敏;raw 只进本地 transcript,不上 wire。
9
+ */
10
+ export class TranscriptWriter {
11
+ file;
12
+ constructor(file) {
13
+ this.file = file;
14
+ mkdirSync(path.dirname(file), { recursive: true });
15
+ }
16
+ writeBlock(block) {
17
+ const record = { type: "block", kind: block.kind };
18
+ if (block.text !== undefined)
19
+ record.text = redactSecretString(block.text);
20
+ if (block.raw !== undefined)
21
+ record.raw = sanitizeRaw(block.raw);
22
+ this.append(record);
23
+ }
24
+ writeFinal(text) {
25
+ this.append({ type: "message", role: "assistant", text: redactSecretString(text) });
26
+ }
27
+ append(record) {
28
+ appendFileSync(this.file, `${JSON.stringify({ ...record, ts: new Date().toISOString() })}\n`, "utf8");
29
+ }
30
+ get path() {
31
+ return this.file;
32
+ }
33
+ }
34
+ function sanitizeRaw(raw) {
35
+ const redacted = redactSecretsDeep(raw);
36
+ let serialized;
37
+ try {
38
+ serialized = JSON.stringify(redacted) ?? "";
39
+ }
40
+ catch {
41
+ return truncateText(String(redacted), RAW_SERIALIZED_MAX_CHARS);
42
+ }
43
+ if (serialized.length <= RAW_SERIALIZED_MAX_CHARS)
44
+ return redacted;
45
+ return truncateText(serialized, RAW_SERIALIZED_MAX_CHARS);
46
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Course-native 协议与 runtime 契约(spec: docs/specs/lightweight-course-daemon-package.md)。
3
+ *
4
+ * 本包不依赖 BotCord Hub/room/owner-chat 语义;wire 类型与后端
5
+ * `backend/botlearn_course/schemas.py` 的 daemon 契约严格对齐。
6
+ */
7
+ /** `GET /daemon/runs/next` 下发的 run.start 载荷(snake_case,与后端 RunStartPayloadOut 一致)。 */
8
+ export interface RunStartPayload {
9
+ agent_run_id: string;
10
+ course_run_id: string;
11
+ lesson_id: string | null;
12
+ task_id: string | null;
13
+ agent_instance_id: string | null;
14
+ runtime: {
15
+ id?: string;
16
+ model?: string;
17
+ reasoning_effort?: string;
18
+ thinking?: boolean;
19
+ web_search?: boolean;
20
+ [key: string]: unknown;
21
+ };
22
+ input: {
23
+ kind?: string;
24
+ text?: string;
25
+ locale?: string;
26
+ };
27
+ context: {
28
+ instructions?: string[];
29
+ metadata?: Record<string, unknown>;
30
+ [key: string]: unknown;
31
+ };
32
+ limits: {
33
+ timeout_seconds?: number;
34
+ max_output_chars?: number;
35
+ max_tool_calls?: number;
36
+ [key: string]: unknown;
37
+ };
38
+ }
39
+ /**
40
+ * `POST /daemon/runs/{id}/events` 接受的事件类型。
41
+ * 与后端 DaemonRunEventIn.type 的 Literal 严格一致 —— 发送其他类型会得到 422。
42
+ */
43
+ export type RunEventType = "run.accepted" | "run.started" | "run.block" | "run.message" | "run.completed" | "run.failed" | "run.cancelled";
44
+ export interface RunEvent {
45
+ type: RunEventType;
46
+ /** Stable across retries; Course Service deduplicates within the current worker attempt. */
47
+ event_id?: string;
48
+ /** 1-based 单调递增。后端把 0/缺省视为「未设置」并自行计算,所以客户端 seq 必须从 1 开始。 */
49
+ seq?: number;
50
+ role?: "assistant" | "user" | "system";
51
+ text?: string;
52
+ error?: string;
53
+ payload?: Record<string, unknown>;
54
+ }
55
+ /** `POST /daemon/runs/{id}/files` 的文件候选(与后端 DaemonRunFileIn 一致)。 */
56
+ export interface RunFileCandidate {
57
+ event?: "created" | "modified" | "deleted" | "upload_completed" | "upload_failed";
58
+ /** workspace 相对路径,正斜杠;不得包含 `..`、绝对路径或反斜杠(后端 400)。 */
59
+ path: string;
60
+ name?: string;
61
+ mime_type?: string;
62
+ size_bytes?: number;
63
+ sha256?: string;
64
+ preview_text?: string;
65
+ }
66
+ export interface RunFileRecord extends RunFileCandidate {
67
+ id: string;
68
+ agent_run_id: string;
69
+ storage_url?: string | null;
70
+ status: "candidate" | "saved" | "ignored" | "failed";
71
+ }
72
+ export interface RuntimeProfileArchiveFile {
73
+ path: string;
74
+ content: string;
75
+ sha256?: string;
76
+ size?: number;
77
+ }
78
+ export interface RuntimeProfileSkillPackage {
79
+ id: string;
80
+ version: string;
81
+ digest: string;
82
+ archiveManifest: {
83
+ name?: string;
84
+ skillMd: string;
85
+ files?: RuntimeProfileArchiveFile[];
86
+ };
87
+ }
88
+ export interface CourseRuntimeProfile {
89
+ schemaVersion: "botlearn-course-runtime-profile/0.1";
90
+ profileId: string;
91
+ profileHash: string;
92
+ courseVersionId: string;
93
+ promptPack: {
94
+ id: string;
95
+ version: string;
96
+ digest: string;
97
+ systemInstructions: string;
98
+ };
99
+ skillPackages: RuntimeProfileSkillPackage[];
100
+ requiredCapabilities: string[];
101
+ }
102
+ export interface AppliedRunRuntimeProfile {
103
+ profileId: string;
104
+ profileHash: string;
105
+ promptPackPath: string;
106
+ skillsRoot: string;
107
+ skillRefs: string[];
108
+ }
109
+ /** runtime adapter 产出的归一化块。wire 上只透传 text 与 kind;raw 仅进本地 transcript。 */
110
+ export interface RuntimeBlock {
111
+ kind: "text_delta" | "text" | "thinking" | "tool_call" | "tool_result" | "status" | "error";
112
+ text?: string;
113
+ raw?: unknown;
114
+ }
115
+ export interface RuntimeAuthProbe {
116
+ checked: boolean;
117
+ ok: boolean;
118
+ message?: string;
119
+ }
120
+ /** doctor / capabilities 使用的探测结果。 */
121
+ export interface RuntimeProbe {
122
+ available: boolean;
123
+ path?: string;
124
+ version?: string;
125
+ auth?: RuntimeAuthProbe;
126
+ }
127
+ /** runtime 执行期间向 dispatcher 汇报的通道。adapter 不直接触碰 Course API。 */
128
+ export interface CourseRuntimeSink {
129
+ block(block: RuntimeBlock): Promise<void>;
130
+ message(text: string): Promise<void>;
131
+ file(file: RunFileCandidate): Promise<void>;
132
+ }
133
+ /** 一次 run 的本地执行上下文:服务器 payload + daemon 本地准备产物。 */
134
+ export interface RunExecution {
135
+ payload: RunStartPayload;
136
+ /** 本 run 的隔离工作区目录(runtime 的 cwd)。 */
137
+ workspaceDir: string;
138
+ }
139
+ export interface CourseRuntime {
140
+ id: string;
141
+ probe?(): Promise<RuntimeProbe>;
142
+ /**
143
+ * 执行一次课程任务。最终回答走 `sink.message`;中间块走 `sink.block`。
144
+ * 失败必须 reject(由 dispatcher 归一化为 run.failed);必须尊重 `signal`。
145
+ */
146
+ run(run: RunExecution, sink: CourseRuntimeSink, signal: AbortSignal): Promise<void>;
147
+ }
148
+ /** runtime 执行失败(dispatcher 折叠为 run.failed)。 */
149
+ export declare class RuntimeExecutionError extends Error {
150
+ readonly errorType: "runtime_error" | "runtime_unavailable" | "timeout";
151
+ readonly failure?: Partial<RuntimeFailureSummary> | undefined;
152
+ constructor(message: string, errorType?: "runtime_error" | "runtime_unavailable" | "timeout", failure?: Partial<RuntimeFailureSummary> | undefined);
153
+ }
154
+ /** 本地诊断用的失败摘要(脱敏后可入日志/transcript,不上报 wire)。 */
155
+ export interface RuntimeFailureSummary {
156
+ agent_run_id: string;
157
+ runtime: string;
158
+ cwd?: string;
159
+ command?: string[];
160
+ exit_code?: number | null;
161
+ signal?: string | null;
162
+ duration_ms?: number;
163
+ stderr_tail?: string;
164
+ stdout_tail?: string;
165
+ error_name?: string;
166
+ error_message?: string;
167
+ }
168
+ export interface RuntimeModule {
169
+ id: string;
170
+ displayName: string;
171
+ /** PATH 上的规范二进制名,doctor 展示用。 */
172
+ binary: string;
173
+ /** 覆盖二进制路径的环境变量;缺省为 `BOTLEARN_<ID>_BIN`(id 大写、`-`→`_`)。 */
174
+ envVar?: string;
175
+ /** 快速探测(安装/版本)。必须廉价:login/capabilities 上报会频繁调用,不得触发真实模型调用。 */
176
+ probe(): Promise<RuntimeProbe>;
177
+ /** 登录态探测,可能昂贵(如真实调用一次 CLI)。仅 doctor 按需调用。 */
178
+ probeAuth?(): Promise<RuntimeAuthProbe>;
179
+ create(): CourseRuntime;
180
+ /** probe 不可用时 doctor 显示的安装提示。 */
181
+ installHint?: string;
182
+ /** 仅测试/demo 用(如 fake),不进入 capabilities 上报,除非显式启用。 */
183
+ hidden?: boolean;
184
+ }
185
+ export interface RuntimeProbeEntry {
186
+ id: string;
187
+ displayName: string;
188
+ binary: string;
189
+ result: RuntimeProbe;
190
+ installHint?: string;
191
+ }
192
+ export interface DaemonAuth {
193
+ daemonId: string;
194
+ userId: string;
195
+ label: string;
196
+ accessToken: string;
197
+ refreshToken: string;
198
+ courseApiUrl: string;
199
+ }
package/dist/types.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Course-native 协议与 runtime 契约(spec: docs/specs/lightweight-course-daemon-package.md)。
3
+ *
4
+ * 本包不依赖 BotCord Hub/room/owner-chat 语义;wire 类型与后端
5
+ * `backend/botlearn_course/schemas.py` 的 daemon 契约严格对齐。
6
+ */
7
+ /** runtime 执行失败(dispatcher 折叠为 run.failed)。 */
8
+ export class RuntimeExecutionError extends Error {
9
+ errorType;
10
+ failure;
11
+ constructor(message, errorType = "runtime_error", failure) {
12
+ super(message);
13
+ this.errorType = errorType;
14
+ this.failure = failure;
15
+ this.name = "RuntimeExecutionError";
16
+ }
17
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 每 run 隔离工作区(spec §4):
3
+ *
4
+ * ```
5
+ * <daemonHome>/workspaces/<agent_run_id>/
6
+ * work/ # runtime 的 cwd,文件候选只从这里扫描
7
+ * transcript.jsonl
8
+ * ```
9
+ *
10
+ * agent_run_id 来自服务端,但用作本地路径段前必须过白名单校验,防路径注入。
11
+ */
12
+ export declare const SAFE_ID_PATTERN: RegExp;
13
+ export declare function assertSafeId(value: string, field: string): void;
14
+ export declare function workspacesRoot(): string;
15
+ export declare function runRootDir(agentRunId: string): string;
16
+ /** runtime 的 cwd。 */
17
+ export declare function runWorkspaceDir(agentRunId: string): string;
18
+ export declare function transcriptPath(agentRunId: string): string;
19
+ export declare function runtimeProfileDir(agentRunId: string): string;
20
+ export declare function ensureRunWorkspace(agentRunId: string): {
21
+ rootDir: string;
22
+ workspaceDir: string;
23
+ };
@@ -0,0 +1,54 @@
1
+ import { chmodSync, mkdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { daemonHome } from "./auth-store.js";
4
+ /**
5
+ * 每 run 隔离工作区(spec §4):
6
+ *
7
+ * ```
8
+ * <daemonHome>/workspaces/<agent_run_id>/
9
+ * work/ # runtime 的 cwd,文件候选只从这里扫描
10
+ * transcript.jsonl
11
+ * ```
12
+ *
13
+ * agent_run_id 来自服务端,但用作本地路径段前必须过白名单校验,防路径注入。
14
+ */
15
+ export const SAFE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
16
+ export function assertSafeId(value, field) {
17
+ if (!SAFE_ID_PATTERN.test(value)) {
18
+ throw new Error(`unsafe ${field}: must match ${SAFE_ID_PATTERN}`);
19
+ }
20
+ }
21
+ export function workspacesRoot() {
22
+ return path.join(daemonHome(), "workspaces");
23
+ }
24
+ export function runRootDir(agentRunId) {
25
+ assertSafeId(agentRunId, "agent_run_id");
26
+ return path.join(workspacesRoot(), agentRunId);
27
+ }
28
+ /** runtime 的 cwd。 */
29
+ export function runWorkspaceDir(agentRunId) {
30
+ return path.join(runRootDir(agentRunId), "work");
31
+ }
32
+ export function transcriptPath(agentRunId) {
33
+ return path.join(runRootDir(agentRunId), "transcript.jsonl");
34
+ }
35
+ export function runtimeProfileDir(agentRunId) {
36
+ return path.join(runRootDir(agentRunId), "runtime-profile");
37
+ }
38
+ // recursive mkdir 只对新建目录生效 mode,已存在目录需 best-effort 收紧。
39
+ function mkdirTolerant(dir) {
40
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
41
+ try {
42
+ chmodSync(dir, 0o700);
43
+ }
44
+ catch {
45
+ // Windows 等不支持 chmod 时忽略。
46
+ }
47
+ }
48
+ export function ensureRunWorkspace(agentRunId) {
49
+ const rootDir = runRootDir(agentRunId);
50
+ const workspaceDir = runWorkspaceDir(agentRunId);
51
+ mkdirTolerant(rootDir);
52
+ mkdirTolerant(workspaceDir);
53
+ return { rootDir, workspaceDir };
54
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@botlearn-course/daemon",
3
+ "version": "0.0.1",
4
+ "description": "Lightweight BotLearn Course daemon: run course tasks on your own machine with your own agent runtime (BYOA).",
5
+ "type": "module",
6
+ "bin": {
7
+ "botlearn-course-daemon": "dist/cli.js"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/botlearn-ai/botlearn-course.git",
14
+ "directory": "packages/course-daemon"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.build.json",
18
+ "type-check": "tsc -p tsconfig.json --noEmit",
19
+ "test": "vitest run",
20
+ "test:release": "node --test scripts/*.test.mjs",
21
+ "test:watch": "vitest",
22
+ "prepublishOnly": "tsc -p tsconfig.build.json"
23
+ },
24
+ "files": [
25
+ "dist/",
26
+ "README.md"
27
+ ],
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "license": "MIT",
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^22.0.0",
37
+ "typescript": "^5.4.0",
38
+ "vitest": "^4.0.18"
39
+ }
40
+ }