@botlearn-course/daemon 0.0.1 → 0.0.2

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.
@@ -0,0 +1,339 @@
1
+ import { existsSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { MAX_PROGRESS_EVENTS_PER_ATTEMPT, tryNormalizeProgressReport, } from "../mcp/report-progress.js";
6
+ export const DEEPSEEK_PROGRESS_TOOL_ALIASES = new Set([
7
+ "report_progress",
8
+ "mcp_botlearn_report_progress",
9
+ "mcp__botlearn__report_progress",
10
+ ]);
11
+ export const DEEPSEEK_PROGRESS_SYSTEM_INSTRUCTION = [
12
+ "BotLearn execution progress reporting:",
13
+ "- Use report_progress only when a meaningful user-visible execution phase starts or completes.",
14
+ "- Use status in_progress at phase start and completed only when that execution phase actually ends.",
15
+ "- Short tasks need no progress report; do not report every command, file read, or retry.",
16
+ "- Keep summary concise and user-facing. Never include hidden reasoning, chain of thought, secrets, tokens, prompts, raw command output, or large code excerpts.",
17
+ "- completed means only that an execution phase ended. It does not complete a course task, checkpoint, learning objective, or human review.",
18
+ "- If report_progress fails, continue the main task and do not bypass it through another tool.",
19
+ ].join("\n");
20
+ export class ProgressMcpConfigError extends Error {
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = "ProgressMcpConfigError";
24
+ }
25
+ }
26
+ export function createDeepseekProgressState() {
27
+ return {
28
+ emitted: 0,
29
+ lastProgressKey: null,
30
+ callIds: new Set(),
31
+ limitReported: false,
32
+ invalid: 0,
33
+ duplicate: 0,
34
+ overLimit: 0,
35
+ };
36
+ }
37
+ /** Return only adapter-side drops; emitted blocks are counted by RunDispatcher after reporting. */
38
+ export function deepseekProgressDispositions(state) {
39
+ if (state.invalid + state.duplicate + state.overLimit === 0)
40
+ return undefined;
41
+ return {
42
+ invalid: state.invalid,
43
+ deduplicated: state.duplicate,
44
+ over_limit: state.overLimit,
45
+ };
46
+ }
47
+ export function progressSystemContext(systemContext) {
48
+ const context = systemContext?.trim();
49
+ return context
50
+ ? `${context}\n\n${DEEPSEEK_PROGRESS_SYSTEM_INSTRUCTION}`
51
+ : DEEPSEEK_PROGRESS_SYSTEM_INSTRUCTION;
52
+ }
53
+ /**
54
+ * Recognize a progress tool start and emit only a typed, whitelisted block. Invalid,
55
+ * duplicate, and over-budget calls remain handled so no generic tool card leaks arguments.
56
+ */
57
+ export function adaptDeepseekProgressStarted(payload, seq, state) {
58
+ const tool = extractTool(payload);
59
+ if (!tool.name || !DEEPSEEK_PROGRESS_TOOL_ALIASES.has(tool.name)) {
60
+ return { matched: false };
61
+ }
62
+ for (const id of tool.ids)
63
+ state.callIds.add(id);
64
+ const progress = tryNormalizeProgressReport(tool.arguments);
65
+ if (!progress) {
66
+ state.invalid += 1;
67
+ return { matched: true };
68
+ }
69
+ const key = progressKey(progress);
70
+ if (state.lastProgressKey === key) {
71
+ state.duplicate += 1;
72
+ return { matched: true };
73
+ }
74
+ if (state.emitted >= MAX_PROGRESS_EVENTS_PER_ATTEMPT) {
75
+ state.overLimit += 1;
76
+ if (!state.limitReported) {
77
+ state.limitReported = true;
78
+ return { matched: true, limitExceeded: true };
79
+ }
80
+ return { matched: true };
81
+ }
82
+ state.lastProgressKey = key;
83
+ state.emitted += 1;
84
+ return { matched: true, block: { kind: "progress", seq, progress } };
85
+ }
86
+ /** Suppress the result for a recognized progress call, including result envelopes with only ids. */
87
+ export function isDeepseekProgressCompletion(payload, state) {
88
+ const tool = extractTool(payload);
89
+ const namedProgress = Boolean(tool.name && DEEPSEEK_PROGRESS_TOOL_ALIASES.has(tool.name));
90
+ const matchingIds = tool.ids.filter((id) => state.callIds.has(id));
91
+ for (const id of matchingIds)
92
+ state.callIds.delete(id);
93
+ return namedProgress || matchingIds.length > 0;
94
+ }
95
+ /**
96
+ * DeepSeek MCP auto-injection uses a stdio server launched through `env -i`, so it cannot
97
+ * inherit Course or model credentials from the DeepSeek process.
98
+ */
99
+ export function progressMcpAutoInjectionSupported(platform = process.platform) {
100
+ // DeepSeek overlays config.env on an inherited process environment. Windows has no
101
+ // env -i equivalent in the fixed runtime, so fail closed instead of handing provider
102
+ // credentials to the progress MCP process.
103
+ return platform !== "win32";
104
+ }
105
+ export function resolveExistingDeepseekMcpConfig(env = process.env, home = homedir()) {
106
+ const explicit = env.DEEPSEEK_MCP_CONFIG?.trim();
107
+ if (explicit)
108
+ return resolveMcpPath(explicit, home);
109
+ const defaultConfigPath = path.join(home, ".deepseek", "config.toml");
110
+ const explicitConfigPath = env.DEEPSEEK_CONFIG_PATH?.trim()
111
+ ? resolveMcpPath(env.DEEPSEEK_CONFIG_PATH.trim(), home)
112
+ : null;
113
+ const configPath = explicitConfigPath
114
+ && (existsSync(explicitConfigPath) || !existsSync(defaultConfigPath))
115
+ ? explicitConfigPath
116
+ : defaultConfigPath;
117
+ const configured = readConfiguredMcpPath(configPath, home, env.DEEPSEEK_PROFILE?.trim());
118
+ if (configured)
119
+ return configured;
120
+ const defaultPath = path.join(home, ".deepseek", "mcp.json");
121
+ return existsSync(defaultPath) ? defaultPath : null;
122
+ }
123
+ /** Create an ephemeral config that preserves a user's existing MCP servers and settings. */
124
+ export function createProgressMcpConfig(options = {}) {
125
+ const platform = options.platform ?? process.platform;
126
+ if (!progressMcpAutoInjectionSupported(platform)) {
127
+ throw new ProgressMcpConfigError("report_progress MCP auto-injection is unavailable on Windows because clean child env isolation cannot be guaranteed");
128
+ }
129
+ const serverPath = fileURLToPath(new URL("../mcp/report-progress-server.js", import.meta.url));
130
+ const baseConfigPath = options.baseConfigPath === undefined
131
+ ? resolveExistingDeepseekMcpConfig()
132
+ : options.baseConfigPath;
133
+ const baseConfig = loadBaseMcpConfig(baseConfigPath);
134
+ const baseServers = mergeMcpServerFields(baseConfig);
135
+ if (Object.hasOwn(baseServers, "botlearn")) {
136
+ throw new ProgressMcpConfigError("DeepSeek MCP server key 'botlearn' is reserved for BotLearn progress reporting");
137
+ }
138
+ const dir = mkdtempSync(path.join(tmpdir(), "botlearn-progress-mcp-"));
139
+ const configPath = path.join(dir, "mcp.json");
140
+ const stagingPath = path.join(dir, ".mcp.json.tmp");
141
+ const minimalPath = "/usr/bin:/bin";
142
+ const baseSettings = { ...baseConfig };
143
+ delete baseSettings.servers;
144
+ delete baseSettings.mcpServers;
145
+ const config = {
146
+ ...baseSettings,
147
+ servers: {
148
+ ...baseServers,
149
+ botlearn: {
150
+ command: "/usr/bin/env",
151
+ args: ["-i", `PATH=${minimalPath}`, process.execPath, serverPath],
152
+ env: {},
153
+ disabled: false,
154
+ enabled: true,
155
+ required: false,
156
+ },
157
+ },
158
+ };
159
+ try {
160
+ writeFileSync(stagingPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
161
+ renameSync(stagingPath, configPath);
162
+ return { dir, path: configPath };
163
+ }
164
+ catch (error) {
165
+ rmSync(dir, { recursive: true, force: true });
166
+ throw error;
167
+ }
168
+ }
169
+ function loadBaseMcpConfig(configPath) {
170
+ if (!configPath)
171
+ return {};
172
+ try {
173
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
174
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
175
+ throw new Error("root must be a JSON object");
176
+ }
177
+ const record = parsed;
178
+ if (record.servers !== undefined
179
+ && (!record.servers || typeof record.servers !== "object" || Array.isArray(record.servers))) {
180
+ throw new Error("servers must be a JSON object");
181
+ }
182
+ if (record.mcpServers !== undefined
183
+ && (!record.mcpServers
184
+ || typeof record.mcpServers !== "object"
185
+ || Array.isArray(record.mcpServers))) {
186
+ throw new Error("mcpServers must be a JSON object");
187
+ }
188
+ return record;
189
+ }
190
+ catch (error) {
191
+ const message = error instanceof Error ? error.message : String(error);
192
+ throw new ProgressMcpConfigError(`Cannot preserve existing DeepSeek MCP config ${configPath}: ${message}`);
193
+ }
194
+ }
195
+ function mergeMcpServerFields(config) {
196
+ const servers = objectField(config, "servers") ?? {};
197
+ const compatibleServers = objectField(config, "mcpServers") ?? {};
198
+ const duplicate = Object.keys(servers).find((name) => Object.hasOwn(compatibleServers, name));
199
+ if (duplicate) {
200
+ throw new ProgressMcpConfigError(`DeepSeek MCP server '${duplicate}' is defined in both servers and mcpServers`);
201
+ }
202
+ return { ...compatibleServers, ...servers };
203
+ }
204
+ function readConfiguredMcpPath(configPath, home, selectedProfile) {
205
+ if (!existsSync(configPath))
206
+ return null;
207
+ let raw;
208
+ try {
209
+ raw = readFileSync(configPath, "utf8");
210
+ }
211
+ catch (error) {
212
+ const message = error instanceof Error ? error.message : String(error);
213
+ throw new ProgressMcpConfigError(`Cannot read DeepSeek config ${configPath}: ${message}`);
214
+ }
215
+ let section = "root";
216
+ let rootValue = null;
217
+ let profileValue = null;
218
+ for (const line of raw.split(/\r?\n/)) {
219
+ const trimmed = line.trim();
220
+ if (!trimmed || trimmed.startsWith("#"))
221
+ continue;
222
+ const table = /^\[\s*([^\]]+?)\s*\]\s*(?:#.*)?$/.exec(trimmed);
223
+ if (table) {
224
+ const profile = parseProfileTableName(table[1] ?? "", configPath);
225
+ section = selectedProfile && profile === selectedProfile ? "selected_profile" : "other";
226
+ continue;
227
+ }
228
+ if (section === "other")
229
+ continue;
230
+ const match = /^mcp_config_path\s*=\s*(.*?)\s*$/.exec(trimmed);
231
+ if (!match)
232
+ continue;
233
+ const value = parseTomlPathValue(match[1] ?? "", configPath);
234
+ const resolved = value ? resolveMcpPath(value, home) : null;
235
+ if (section === "selected_profile")
236
+ profileValue = resolved;
237
+ else
238
+ rootValue = resolved;
239
+ }
240
+ return profileValue ?? rootValue;
241
+ }
242
+ function parseProfileTableName(raw, configPath) {
243
+ const bare = /^profiles\s*\.\s*([A-Za-z0-9_-]+)$/.exec(raw);
244
+ if (bare)
245
+ return bare[1] ?? null;
246
+ const doubleQuoted = /^profiles\s*\.\s*("(?:\\.|[^"\\])*")$/.exec(raw);
247
+ if (doubleQuoted) {
248
+ try {
249
+ return JSON.parse(doubleQuoted[1]);
250
+ }
251
+ catch (error) {
252
+ const message = error instanceof Error ? error.message : String(error);
253
+ throw new ProgressMcpConfigError(`Invalid profile table in ${configPath}: ${message}`);
254
+ }
255
+ }
256
+ const singleQuoted = /^profiles\s*\.\s*'([^']*)'$/.exec(raw);
257
+ return singleQuoted?.[1] ?? null;
258
+ }
259
+ function parseTomlPathValue(raw, configPath) {
260
+ const doubleQuoted = /^("(?:\\.|[^"\\])*")\s*(?:#.*)?$/.exec(raw);
261
+ if (doubleQuoted) {
262
+ try {
263
+ return JSON.parse(doubleQuoted[1]);
264
+ }
265
+ catch (error) {
266
+ const message = error instanceof Error ? error.message : String(error);
267
+ throw new ProgressMcpConfigError(`Invalid mcp_config_path in ${configPath}: ${message}`);
268
+ }
269
+ }
270
+ const singleQuoted = /^'([^']*)'\s*(?:#.*)?$/.exec(raw);
271
+ if (singleQuoted)
272
+ return singleQuoted[1] ?? "";
273
+ return raw.replace(/\s+#.*$/, "").trim();
274
+ }
275
+ function resolveMcpPath(value, home) {
276
+ if (value === "~")
277
+ return home;
278
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
279
+ return path.join(home, value.slice(2));
280
+ }
281
+ return path.resolve(value);
282
+ }
283
+ export function cleanupProgressMcpConfig(config) {
284
+ if (config)
285
+ rmSync(config.dir, { recursive: true, force: true });
286
+ }
287
+ function progressKey(progress) {
288
+ return `${progress.status}\0${progress.summary}`;
289
+ }
290
+ function extractTool(payload) {
291
+ if (!payload || typeof payload !== "object")
292
+ return { ids: [] };
293
+ const root = payload;
294
+ const embedded = objectField(root, "payload");
295
+ const candidates = [
296
+ root,
297
+ objectField(root, "tool"),
298
+ embedded,
299
+ objectField(embedded, "tool"),
300
+ ].filter((candidate) => candidate !== undefined);
301
+ let name;
302
+ let args;
303
+ for (const candidate of candidates) {
304
+ name ??= stringField(candidate, "name") ?? stringField(candidate, "tool_name");
305
+ if (args === undefined) {
306
+ if (Object.hasOwn(candidate, "input"))
307
+ args = candidate.input;
308
+ else if (Object.hasOwn(candidate, "arguments"))
309
+ args = candidate.arguments;
310
+ }
311
+ }
312
+ const ids = new Set();
313
+ for (const candidate of candidates) {
314
+ for (const key of ["id", "call_id", "tool_call_id", "item_id"]) {
315
+ const value = stringField(candidate, key);
316
+ if (value)
317
+ ids.add(value);
318
+ }
319
+ const item = objectField(candidate, "item");
320
+ const itemId = stringField(item, "id");
321
+ if (itemId)
322
+ ids.add(itemId);
323
+ }
324
+ return {
325
+ ...(name ? { name } : {}),
326
+ ...(args !== undefined ? { arguments: args } : {}),
327
+ ids: [...ids],
328
+ };
329
+ }
330
+ function objectField(value, key) {
331
+ const field = value?.[key];
332
+ return field && typeof field === "object" && !Array.isArray(field)
333
+ ? field
334
+ : undefined;
335
+ }
336
+ function stringField(value, key) {
337
+ const field = value?.[key];
338
+ return typeof field === "string" ? field : undefined;
339
+ }
@@ -15,6 +15,12 @@ export class TranscriptWriter {
15
15
  }
16
16
  writeBlock(block) {
17
17
  const record = { type: "block", kind: block.kind };
18
+ if (block.kind === "progress") {
19
+ record.summary = redactSecretString(block.summary);
20
+ record.status = block.status;
21
+ this.append(record);
22
+ return;
23
+ }
18
24
  if (block.text !== undefined)
19
25
  record.text = redactSecretString(block.text);
20
26
  if (block.raw !== undefined)
package/dist/types.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * 本包不依赖 BotCord Hub/room/owner-chat 语义;wire 类型与后端
5
5
  * `backend/botlearn_course/schemas.py` 的 daemon 契约严格对齐。
6
6
  */
7
+ import type { ProgressStatus } from "./mcp/report-progress.js";
7
8
  /** `GET /daemon/runs/next` 下发的 run.start 载荷(snake_case,与后端 RunStartPayloadOut 一致)。 */
8
9
  export interface RunStartPayload {
9
10
  agent_run_id: string;
@@ -106,12 +107,27 @@ export interface AppliedRunRuntimeProfile {
106
107
  skillsRoot: string;
107
108
  skillRefs: string[];
108
109
  }
109
- /** runtime adapter 产出的归一化块。wire 上只透传 text 与 kind;raw 仅进本地 transcript。 */
110
- export interface RuntimeBlock {
110
+ /** runtime adapter 产出的普通归一化块;raw 仅进本地 transcript。 */
111
+ export interface RuntimeContentBlock {
111
112
  kind: "text_delta" | "text" | "thinking" | "tool_call" | "tool_result" | "status" | "error";
112
113
  text?: string;
113
114
  raw?: unknown;
114
115
  }
116
+ /** 已由 provider adapter 严格归一化、无 provider raw envelope 的进度遥测。 */
117
+ export interface RuntimeProgressBlock {
118
+ kind: "progress";
119
+ runtime: string;
120
+ summary: string;
121
+ status: ProgressStatus;
122
+ raw?: never;
123
+ }
124
+ export type RuntimeBlock = RuntimeContentBlock | RuntimeProgressBlock;
125
+ /** runtime adapter 在发出进度块前丢弃的非内容处置计数。 */
126
+ export interface RuntimeProgressDispositions {
127
+ invalid: number;
128
+ deduplicated: number;
129
+ over_limit: number;
130
+ }
115
131
  export interface RuntimeAuthProbe {
116
132
  checked: boolean;
117
133
  ok: boolean;
@@ -129,6 +145,8 @@ export interface CourseRuntimeSink {
129
145
  block(block: RuntimeBlock): Promise<void>;
130
146
  message(text: string): Promise<void>;
131
147
  file(file: RunFileCandidate): Promise<void>;
148
+ /** 可选的 run-scoped 内部遥测;不得包含 summary 或 provider raw envelope。 */
149
+ progressDispositions?(dispositions: RuntimeProgressDispositions): Promise<void>;
132
150
  }
133
151
  /** 一次 run 的本地执行上下文:服务器 payload + daemon 本地准备产物。 */
134
152
  export interface RunExecution {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botlearn-course/daemon",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Lightweight BotLearn Course daemon: run course tasks on your own machine with your own agent runtime (BYOA).",
5
5
  "type": "module",
6
6
  "bin": {