mingdao-harness 0.3.1 → 0.4.0

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/src/cli.js CHANGED
@@ -79,6 +79,7 @@ const HELP_LINES = [
79
79
  [' mingdao --journal 新会话带上最近会话日志(默认不注入,新会话全新开始)', null],
80
80
  [' mingdao --resume 从会话列表选择恢复', null],
81
81
  [' mingdao --model <模型名> 指定模型,例如 deepseek-v4-pro', null],
82
+ [' mingdao --preset <名> 应用智能体预设(工具白名单/权限/参数,v0.4.0 契约化)', null],
82
83
  [' mingdao init 初始化配置向导', null],
83
84
  [' mingdao update [--check] 一键自更新(git 安装形态;--check 只对比版本)', null],
84
85
  [' mingdao rollback 回滚到上次 update 之前的提交', null],
@@ -125,6 +126,8 @@ function parseArgs(/** @type {any} */ argv) {
125
126
  else if (a === '-v' || a === '--version') opts.version = true;
126
127
  else if (a === '-c' || a === '--continue') opts.continueSession = true;
127
128
  else if (a === '--journal') opts.journal = true;
129
+ else if (a === '-p' || a === '--preset') opts.preset = argv[++i];
130
+ else if (a.startsWith('--preset=')) opts.preset = a.slice(9);
128
131
  else if (a === '-r' || a === '--resume') opts.resume = true;
129
132
  else if (a === '--init' || a === 'init') opts.init = true;
130
133
  else if (a === '-m' || a === '--model') opts.model = argv[++i];
@@ -350,6 +353,29 @@ async function main() {
350
353
  // 模型回退链(向导允许跳过模型选择 → cfg.model 可缺省):参数 > config > 该服务商首个预设模型 > flash
351
354
  let modelName = opts.model || cfg.model || /** @type {any} */ (PROVIDERS)[cfg.provider]?.models?.[0] || 'deepseek-v4-flash';
352
355
  const io = createIO();
356
+ const workingDir = process.cwd();
357
+
358
+ // v0.4.0 Agent Preset:--preset <名> 应用声明式预设(工具白名单/权限/模型/参数 + 系统提示定制段)。
359
+ // 预设覆盖优先级:CLI 显式参数 > 预设 > config.json。
360
+ // 会话级 overlay:不改写 cfg(否则 REPL /model、/think 的 saveConfig 会把预设字段持久化进 config.json)。
361
+ let activePreset = /** @type {any} */ (null);
362
+ let presetBlock = '';
363
+ let presetOverlay = /** @type {Record<string, any>} */ ({});
364
+ if (opts.preset) {
365
+ const { loadPreset, presetConfigOverrides, presetSystemBlock, listPresets } = await import('./presets.js');
366
+ activePreset = loadPreset(workingDir, opts.preset);
367
+ if (!activePreset) {
368
+ const names = listPresets(workingDir).map((/** @type {any} */ p) => p.name).join(', ') || '(无可用预设)';
369
+ io.print(style(`⚠ 预设 "${opts.preset}" 不存在。可用:${names}`, C.yellow));
370
+ } else {
371
+ presetOverlay = { ...presetConfigOverrides(activePreset), presetName: activePreset.name };
372
+ if (!opts.model && presetOverlay.model) modelName = presetOverlay.model;
373
+ presetBlock = presetSystemBlock(activePreset);
374
+ io.print(style(`▣ 已应用智能体预设:${activePreset.name}${activePreset.label ? '(' + activePreset.label + ')' : ''}`, C.cyan));
375
+ }
376
+ }
377
+ // agent 使用的配置 = cfg + 预设 overlay(presetTools/permission/参数按预设生效,cfg 本体保持干净)
378
+ const agentCfg = Object.keys(presetOverlay).length ? { ...cfg, ...presetOverlay } : cfg;
353
379
 
354
380
  const pc0 = resolveProviderConfig(cfg, modelName);
355
381
  if (!pc0.apiKey) {
@@ -367,8 +393,13 @@ async function main() {
367
393
  }
368
394
 
369
395
  let provider = await createProvider(cfg, modelName);
370
- const permission = createPermission(cfg.permission ?? 'ask', io);
371
- const workingDir = process.cwd();
396
+ const permission = createPermission(agentCfg.permission ?? 'ask', io);
397
+ // v0.4.0 契约化:挂载 config.tools 声明式第三方工具(幂等,重启生效)
398
+ {
399
+ const { mountConfigTools } = await import('./tools/index.js');
400
+ const mounted = mountConfigTools(cfg);
401
+ if (mounted.length) io.print(style(`🔧 已挂载声明式工具(config.tools):${mounted.join(', ')}`, C.dim));
402
+ }
372
403
  // 会话级 undo 备份仓:模型切换、子代理均共享,撤销记录不丢失
373
404
  const sessionUndoStore = { backups: new Map() };
374
405
  // MCP 服务器:后台启动(不阻塞交互),就绪后工具自动出现在后续轮次
@@ -410,7 +441,7 @@ async function main() {
410
441
  io,
411
442
  modelName,
412
443
  workingDir,
413
- cfg,
444
+ cfg: agentCfg,
414
445
  undoStore: sessionUndoStore,
415
446
  mcp: mcpFacade,
416
447
  sessionRef,
@@ -440,7 +471,7 @@ async function main() {
440
471
  const turnIo = jsonMode ? createIO({ quiet: true }) : io;
441
472
  const session = createSession(home);
442
473
  const messages = [
443
- { role: 'system', content: buildSystemPrompt({ modelName, workingDir, withJournal }) },
474
+ { role: 'system', content: buildSystemPrompt({ modelName, workingDir, withJournal, presetBlock }) },
444
475
  { role: 'user', content: question },
445
476
  ];
446
477
  let oneShotPersisted = messages.length;
@@ -451,7 +482,7 @@ async function main() {
451
482
  io: turnIo,
452
483
  modelName,
453
484
  workingDir,
454
- cfg,
485
+ cfg: agentCfg,
455
486
  undoStore: sessionUndoStore,
456
487
  mcp: mcpFacade,
457
488
  sessionRef: oneShotRef,
@@ -528,7 +559,7 @@ async function main() {
528
559
 
529
560
  // —— 交互式 TUI(Phase C C2:已抽取至 commands/repl.js) ——
530
561
  const { runRepl } = await import('./commands/repl.js');
531
- await runRepl({ io, cfg, home, pc0, opts, modelName, provider, permission, workingDir, sessionUndoStore, mcpFacade, mcpManager, sessionRef, agent, preset, withJournal, tuiState });
562
+ await runRepl({ io, cfg, agentCfg, home, pc0, opts, modelName, provider, permission, workingDir, sessionUndoStore, mcpFacade, mcpManager, sessionRef, agent, preset, withJournal, presetBlock, tuiState });
532
563
  return;
533
564
 
534
565
  }
@@ -98,6 +98,7 @@ const HELP_LINES = [
98
98
  ['会话内命令', C.bold + C.yellow],
99
99
  [' /help 显示帮助 /clear 清空上下文', null],
100
100
  [' /model <名> 切换模型 /mode pro/flash 快捷切换', null],
101
+ [' /preset 列出/切换智能体预设(v0.4.0 契约化)', null],
101
102
  [' /compact 压缩上下文 /plan 计划模式(先计划后执行)', null],
102
103
  [' /init 生成 AGENTS.md /memory add <内容> 追加用户记忆', null],
103
104
  [' /skills 列出技能 /status 会话状态 · /cost 累计费用', null],
@@ -140,6 +141,10 @@ async function generatePlan(provider, modelName, task) {
140
141
  */
141
142
  export async function runRepl(ctx) {
142
143
  const { io, cfg, home, pc0, opts, permission, workingDir, sessionUndoStore, mcpFacade, sessionRef, preset, withJournal, tuiState } = ctx;
144
+ let presetBlock = ctx.presetBlock || ''; // v0.4.0 Agent Preset 系统提示定制段(/preset 可切换)
145
+ let permissionNow = permission; // /preset 可换权限模式
146
+ // v0.4.0 Agent Preset:agent 用 agentCfg(预设 overlay 会话级生效);cfg 保持干净供 saveConfig 持久化
147
+ let agentCfg = ctx.agentCfg || cfg;
143
148
  let mcpManager = ctx.mcpManager;
144
149
  let modelName = ctx.modelName;
145
150
  let provider = ctx.provider;
@@ -216,7 +221,7 @@ export async function runRepl(ctx) {
216
221
  sessionRef.name = path.basename(session.file);
217
222
  io.print(style(`会话 ${path.basename(session.file)}`, C.dim));
218
223
 
219
- const systemPrompt = buildSystemPrompt({ modelName, workingDir, withJournal });
224
+ const systemPrompt = buildSystemPrompt({ modelName, workingDir, withJournal, presetBlock });
220
225
  // 恢复会话时刷新 system prompt(用户记忆 / AGENTS.md / 技能清单 / 时间戳以当前为准),
221
226
  // 旧 system 消息保留在会话文件中,不影响追加历史。
222
227
  const loadedMsgs = session.messages || [];
@@ -258,11 +263,11 @@ export async function runRepl(ctx) {
258
263
  }
259
264
  agent = createAgent({
260
265
  provider,
261
- permission,
266
+ permission: permissionNow,
262
267
  io,
263
268
  modelName,
264
269
  workingDir,
265
- cfg,
270
+ cfg: agentCfg, // v0.4.0:预设 overlay 恒生效(/model 后白名单不丢)
266
271
  undoStore: sessionUndoStore,
267
272
  mcp: mcpFacade,
268
273
  sessionRef,
@@ -271,7 +276,7 @@ export async function runRepl(ctx) {
271
276
  tuiState.persisted = msgs.length;
272
277
  },
273
278
  });
274
- messages[0] = { role: 'system', content: buildSystemPrompt({ workingDir, withJournal }) };
279
+ messages[0] = { role: 'system', content: buildSystemPrompt({ workingDir, withJournal, presetBlock }) };
275
280
  if (!silent) {
276
281
  const p2 = modelPreset(modelName);
277
282
  io.print(style(`✓ 已切换到 ${C.bold}${modelName}${C.reset}${p2 ? `(${p2.label})` : ''}`, C.green));
@@ -309,6 +314,38 @@ export async function runRepl(ctx) {
309
314
  } catch {}
310
315
  tuiState.persisted = messages.length;
311
316
  io.print('已清空上下文(会话文件已同步重置)。');
317
+ } else if (cmd === '/preset') {
318
+ // v0.4.0 Agent Preset:列出/切换声明式智能体预设(工具白名单/权限/参数 + 系统提示定制段)
319
+ const { listPresets, loadPreset, presetConfigOverrides, presetSystemBlock } = await import('../presets.js');
320
+ if (!arg) {
321
+ const ps = listPresets(workingDir);
322
+ if (!ps.length) io.print(style('(无可用预设。目录:<项目>/.mingdao/presets/、~/.mingdao/presets/、内置 presets/)', C.dim));
323
+ else for (const p of ps) io.print(` - ${p.name}(${p.label || '无标签'},${p.source === 'project' ? '项目' : p.source === 'user' ? '用户' : '内置'})${p.description ? ':' + p.description : ''}`);
324
+ continue;
325
+ }
326
+ const pname = arg.split(/\s+/)[0];
327
+ const p = loadPreset(workingDir, pname);
328
+ if (!p) {
329
+ io.print(style(`预设 "${pname}" 不存在(/preset 列出全部)。`, C.yellow));
330
+ continue;
331
+ }
332
+ const over = presetConfigOverrides(p);
333
+ // 应用覆盖:权限/参数/工具白名单/模型建议——只进 agentCfg(会话级 overlay),
334
+ // 绝不改写 cfg:否则后续 /model、/think 的 saveConfig 会把预设字段持久化进 config.json。
335
+ agentCfg = { ...cfg, ...over, presetName: p.name };
336
+ if (over.model) await switchToModel(over.model, { silent: true, persist: false });
337
+ presetBlock = presetSystemBlock(p);
338
+ permissionNow = createPermission(agentCfg.permission ?? 'ask', io);
339
+ agent = createAgent({
340
+ provider, permission: permissionNow, io, modelName, workingDir, cfg: agentCfg,
341
+ undoStore: sessionUndoStore, mcp: mcpFacade, sessionRef,
342
+ onCompact: (/** @type {any} */ msgs) => {
343
+ rewriteSession(session.file, msgs);
344
+ tuiState.persisted = msgs.length;
345
+ },
346
+ });
347
+ messages[0] = { role: 'system', content: buildSystemPrompt({ workingDir, withJournal, presetBlock }) };
348
+ io.print(style(`▣ 已应用智能体预设:${p.name}${p.label ? '(' + p.label + ')' : ''}${over.presetTools ? `,工具白名单 ${over.presetTools.length} 个` : ''}`, C.green));
312
349
  } else if (cmd === '/model') {
313
350
  if (!arg) {
314
351
  io.print(`当前模型:${modelName}`);
@@ -336,7 +373,7 @@ export async function runRepl(ctx) {
336
373
  saveConfig(cfg);
337
374
  } else { io.print(style('无效取值:low|high|max|off', C.red)); continue; }
338
375
  agent = createAgent({
339
- provider, permission, io, modelName, workingDir, cfg,
376
+ provider, permission: permissionNow, io, modelName, workingDir, cfg: agentCfg,
340
377
  undoStore: sessionUndoStore, mcp: mcpFacade, sessionRef,
341
378
  onCompact: (/** @type {any} */ msgs) => {
342
379
  rewriteSession(session.file, msgs);
package/src/compact.js CHANGED
@@ -65,7 +65,9 @@ export async function compactConversation(/** @type {any} */ { messages, budget,
65
65
  total += t;
66
66
  }
67
67
  const trigger = Number.isFinite(Number(triggerRatio)) ? Number(triggerRatio) : DEFAULT_TRIGGER_RATIO;
68
- if (total <= budget * trigger) return null;
68
+ // force(v0.3.2 边缘检测):逼近窗口时即便启发式计数低估(total 未达触发线)也强制压缩——
69
+ // 非 DeepSeek 模型启发式计数误差可达 ±2 倍,等它越过触发线时真实 prompt 可能已到窗口边缘。
70
+ if (total <= budget * trigger && !force) return null;
69
71
  // 保留边界:保留段(boundary..end)≤ budget×TARGET_RATIO(system 恒保留)
70
72
  let keepTokens = sizes[0] ?? 0;
71
73
  let boundary = messages.length;
package/src/index.js CHANGED
@@ -1,13 +1,35 @@
1
1
  // MingDao-Harness 公共 API:供第三方程序/插件以库形式复用核心能力。
2
+ // 稳定性契约(v0.4.0 起):@stable 在 minor 版本内保持向后兼容;@experimental 可能变更。
3
+ // 详细契约与示例见 docs/DEVELOPER.md。
2
4
 
5
+ // —— @stable:Agent 内核 ——
3
6
  export { createAgent } from './agent.js';
7
+ export { createPermission } from './permissions.js';
8
+ export { createIO, style, C } from './ui.js';
9
+
10
+ // —— @stable:Provider 与模型 ——
4
11
  export { createProvider, resolveProviderConfig } from './providers/index.js';
5
- export { toolSchemas, dispatch } from './tools/index.js';
12
+ export { MODELS, PROVIDERS, modelPreset, providerPreset } from './models.js';
13
+ export { resolveModelCaps, safeBudget, isLocalBaseUrl } from './model-caps.js';
14
+
15
+ // —— @stable:工具(含 v0.4.0 第三方注册)——
16
+ export { toolSchemas, dispatch, registerTool, listRegisteredTools, mountConfigTools, buildToolSchemas } from './tools/index.js';
17
+
18
+ // —— @stable:Agent Preset(v0.4.0)——
19
+ export {
20
+ listPresets,
21
+ loadPreset,
22
+ validatePreset,
23
+ presetConfigOverrides,
24
+ presetSystemBlock,
25
+ presetDirs,
26
+ } from './presets.js';
27
+
28
+ // —— @stable:上下文与压缩 ——
6
29
  export { trimMessages, approxTokens, clampText, TOOL_RESULT_LIMIT } from './context.js';
7
30
  export { compactConversation, summarizeConversation } from './compact.js';
8
- export { createPermission } from './permissions.js';
9
- export { MODELS, PROVIDERS, modelPreset, providerPreset } from './models.js';
10
- export { createIO, style, C } from './ui.js';
31
+
32
+ // —— @stable:配置与凭证 ——
11
33
  export {
12
34
  mingdaoHome,
13
35
  ensureHome,
@@ -26,8 +48,12 @@ export {
26
48
  maskKey,
27
49
  resolveApiKey,
28
50
  } from './credentials.js';
51
+
52
+ // —— @stable:计价与计量 ——
29
53
  export { estimateCost, estimateCostLabel, isPeakHour, PRICE_DATA_AS_OF } from './pricing.js';
30
54
  export { countTokens, heuristicTokens, makeTokenCounter, isTokenizable } from './tokenizer.js';
55
+
56
+ // —— @experimental:更新/审计/技能/会话(接口可能调整)——
31
57
  export { updateCheck, mingdaoUpdate, mingdaoRollback, findRepoRoot } from './update.js';
32
58
  export { writeAudit, listAudit, redactSecrets, auditFile } from './audit.js';
33
59
  export { trustSkill, skillDirHash, readSourceMeta } from './skill-lib.js';
@@ -0,0 +1,69 @@
1
+ // 模型能力解析(v0.3.2 本地模型自适应):
2
+ // 把「模型能装多少上下文、单次最多输出多少、是否本地部署」收敛成单一来源,
3
+ // 供预算推导、超时、工具截断统一引用——避免各层各自猜一份 128000 默认,
4
+ // 本地小模型(窗口小/内存少)自动收紧预算与超时,不撑爆、不误杀。
5
+ import { modelPreset } from './models.js';
6
+
7
+ // 兜底:未知模型默认上下文窗口。本地小模型宁可保守(不撑爆)也不乐观。
8
+ export const UNKNOWN_LOCAL_WINDOW = 32768;
9
+ export const UNKNOWN_REMOTE_WINDOW = 128000;
10
+ export const DEFAULT_MAX_OUTPUT = 8192;
11
+ // 输出余量:prompt 预算必须给模型输出留足空间,否则 prompt+output 越过窗口 → 服务端截断/拒绝。
12
+ export const OUTPUT_HEADROOM = 2048;
13
+ // 舒适区:prompt 预算最多占窗口 75%——逼近 75% 以上时 prefill 时间陡增(长上下文 dequant 开销),
14
+ // 留 25% 给输出 + 抗抖缓冲,从根上避免「prompt 到窗口边缘 → 首 token 等 200s+ 被客户端掐断」。
15
+ export const COMFORT_RATIO = 0.75;
16
+ // 边缘比:模型上报的真实 prompt_tokens 逼近窗口 85% 即视为「边缘」,本回合结束强制激进压缩。
17
+ export const EDGE_RATIO = 0.85;
18
+
19
+ /** 判断 baseUrl 是否指向本机/内网(本地推理框架部署)。 */
20
+ export function isLocalBaseUrl(/** @type {any} */ baseUrl) {
21
+ try {
22
+ const u = new URL(String(baseUrl || ''));
23
+ const h = u.hostname.toLowerCase();
24
+ if (!h) return false;
25
+ if (h === 'localhost' || h === '::1') return true;
26
+ const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
27
+ if (!m) return false;
28
+ const a = Number(m[1]);
29
+ const b = Number(m[2]);
30
+ return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * 解析模型能力。优先级:customModels.<name>.contextWindow/maxOutputTokens > 内置 preset > 兜底。
38
+ * @param {any} cfg
39
+ * @param {string} modelName
40
+ * @returns {{ contextWindow: number, maxOutputTokens: number, isLocal: boolean, budgetTokens: number|null, preset: any }}
41
+ */
42
+ export function resolveModelCaps(/** @type {any} */ cfg, /** @type {any} */ modelName) {
43
+ const preset = modelPreset(modelName);
44
+ const cm = (cfg?.customModels || {})[modelName] || {};
45
+ const baseUrl = cm.baseUrl || cfg?.baseUrl || '';
46
+ const isLocal = isLocalBaseUrl(baseUrl);
47
+ const contextWindow =
48
+ Number(cm.contextWindow) > 0
49
+ ? Number(cm.contextWindow)
50
+ : preset?.contextWindow || (isLocal ? UNKNOWN_LOCAL_WINDOW : UNKNOWN_REMOTE_WINDOW);
51
+ const maxOutputTokens =
52
+ Number(cm.maxOutputTokens) > 0
53
+ ? Number(cm.maxOutputTokens)
54
+ : preset?.maxOutputTokens || Math.min(DEFAULT_MAX_OUTPUT, Math.max(1024, Math.floor(contextWindow / 8)));
55
+ const budgetTokens = preset?.budgetTokens || null;
56
+ return { contextWindow, maxOutputTokens, isLocal, budgetTokens, preset };
57
+ }
58
+
59
+ /**
60
+ * 安全 prompt 预算:min(用户配置/预设, 窗口×75% 舒适区, 窗口−输出−余量)。
61
+ * 保证 prompt + maxOutput + 余量 ≤ contextWindow,且 prompt 不越舒适区(prefill 不爆炸)。
62
+ */
63
+ export function safeBudget(/** @type {any} */ cfg, /** @type {any} */ caps) {
64
+ const ceiling = Math.max(1024, caps.contextWindow - caps.maxOutputTokens - OUTPUT_HEADROOM);
65
+ const comfort = Math.max(1024, Math.floor(caps.contextWindow * COMFORT_RATIO));
66
+ const configured = Number(cfg?.contextBudget) > 0 ? Number(cfg.contextBudget) : null;
67
+ const base = configured ?? caps.budgetTokens ?? comfort;
68
+ return Math.max(1024, Math.min(base, ceiling, comfort));
69
+ }
package/src/presets.js ADDED
@@ -0,0 +1,154 @@
1
+ // Agent Preset(v0.4.0 契约化):声明式智能体预设——把「系统提示 + 工具集 + 权限 + 模型 + 参数」
2
+ // 打包成一个可安装、可复用、可分享的 JSON 单元,让开发者/用户不改源码就能定制自己的智能体。
3
+ //
4
+ // 发现顺序(同名后者遮蔽前者):
5
+ // 1. 项目级 <工作目录>/.mingdao/presets/*.json
6
+ // 2. 用户级 <mingdao-home>/presets/*.json
7
+ // 3. 内置 随 npm 包分发的 presets/ 目录(只读参考实现)
8
+ //
9
+ // 预设字段(全部可选,缺省时保持当前配置不变):
10
+ // name 唯一名(必填,字母/数字/-/_,1-64)
11
+ // label 展示名(可选,默认 name)
12
+ // description 一句话用途
13
+ // systemPrompt 追加到系统提示的定制段(角色/规则/上下文约定)
14
+ // tools 工具白名单(数组,省略=不限制)
15
+ // permission 权限模式 ask/auto/readonly(省略=当前配置)
16
+ // model 建议模型(省略=当前模型)
17
+ // temperature / maxOutputTokens / maxRounds / contextBudget 参数覆盖
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { mingdaoHome, ensureHome } from './config.js';
22
+
23
+ const PRESET_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
24
+ // 合法字段白名单:未知字段报错(防拼写错误静默失效——契约化核心)
25
+ const KNOWN_FIELDS = new Set([
26
+ 'name', 'label', 'description', 'systemPrompt', 'tools',
27
+ 'permission', 'model', 'temperature', 'maxOutputTokens', 'maxRounds', 'contextBudget',
28
+ ]);
29
+ const PERMISSION_MODES = ['ask', 'auto', 'readonly'];
30
+
31
+ /** 内置预设目录(随 npm 包分发,只读参考)。 */
32
+ export function builtinPresetDir() {
33
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'presets');
34
+ }
35
+
36
+ /** 发现目录与 source 标签对齐(遮蔽顺序:项目 → 用户 → 内置)。 */
37
+ function presetLocations(/** @type {any} */ workingDir) {
38
+ const locs = [];
39
+ if (workingDir) locs.push({ dir: path.join(String(workingDir), '.mingdao', 'presets'), source: 'project' });
40
+ locs.push({ dir: path.join(mingdaoHome(), 'presets'), source: 'user' });
41
+ locs.push({ dir: builtinPresetDir(), source: 'builtin' });
42
+ return locs;
43
+ }
44
+
45
+ /** @param {any} workingDir 预设发现目录(按遮蔽顺序:项目 → 用户 → 内置)。 */
46
+ export function presetDirs(/** @type {any} */ workingDir) {
47
+ return presetLocations(workingDir).map((/** @type {any} */ l) => l.dir);
48
+ }
49
+
50
+ /** @param {any} obj 校验预设对象,返回 { ok, errors: string[] }。 */
51
+ export function validatePreset(/** @type {any} */ obj) {
52
+ const errors = [];
53
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return { ok: false, errors: ['预设必须是 JSON 对象'] };
54
+ const name = String(obj.name ?? '').trim();
55
+ if (!name) errors.push('缺少 name 字段');
56
+ else if (!PRESET_NAME_RE.test(name)) errors.push(`name 非法(${PRESET_NAME_RE}):${name}`);
57
+ for (const k of Object.keys(obj)) {
58
+ if (!KNOWN_FIELDS.has(k)) errors.push(`未知字段:${k}(合法:${[...KNOWN_FIELDS].join('/')})`);
59
+ }
60
+ if (obj.systemPrompt !== undefined && typeof obj.systemPrompt !== 'string') errors.push('systemPrompt 必须是字符串');
61
+ if (obj.tools !== undefined && (!Array.isArray(obj.tools) || obj.tools.some((/** @type {any} */ t) => typeof t !== 'string'))) {
62
+ errors.push('tools 必须是字符串数组');
63
+ }
64
+ if (obj.permission !== undefined && !PERMISSION_MODES.includes(String(obj.permission))) {
65
+ errors.push(`permission 必须是 ${PERMISSION_MODES.join('/')}`);
66
+ }
67
+ for (const k of ['temperature', 'maxOutputTokens', 'maxRounds', 'contextBudget']) {
68
+ if (obj[k] !== undefined && !(Number.isFinite(Number(obj[k])) && Number(obj[k]) > 0)) {
69
+ errors.push(`${k} 必须是正数`);
70
+ }
71
+ }
72
+ return { ok: errors.length === 0, errors };
73
+ }
74
+
75
+ /**
76
+ * 列出全部可用预设(发现顺序:项目遮蔽用户遮蔽内置,同名只留前者)。
77
+ * 返回 [{ name, label, description, source: 'project'|'user'|'builtin', file }]
78
+ */
79
+ export function listPresets(/** @type {any} */ workingDir) {
80
+ ensureHome();
81
+ const seen = new Map();
82
+ const order = /** @type {string[]} */ ([]);
83
+ for (const { dir, source } of presetLocations(workingDir)) {
84
+ let files = [];
85
+ try {
86
+ files = fs.readdirSync(dir).filter((/** @type {any} */ f) => f.endsWith('.json'));
87
+ } catch {
88
+ continue;
89
+ }
90
+ for (const f of files) {
91
+ if (seen.has(f)) continue;
92
+ seen.set(f, { dir, source });
93
+ order.push(f);
94
+ }
95
+ }
96
+ const out = [];
97
+ for (const f of order) {
98
+ const { dir, source } = seen.get(f);
99
+ try {
100
+ const obj = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
101
+ const v = validatePreset(obj);
102
+ if (!v.ok) continue; // 非法预设跳过并静默(不阻塞会话);diagnose 可查
103
+ out.push({
104
+ name: String(obj.name),
105
+ label: String(obj.label || obj.name),
106
+ description: String(obj.description || ''),
107
+ source,
108
+ file: path.join(dir, f),
109
+ ...(obj.systemPrompt ? { systemPrompt: obj.systemPrompt } : {}),
110
+ ...(Array.isArray(obj.tools) ? { tools: obj.tools } : {}),
111
+ ...(obj.permission ? { permission: String(obj.permission) } : {}),
112
+ ...(obj.model ? { model: String(obj.model) } : {}),
113
+ });
114
+ } catch {
115
+ // JSON 解析失败:跳过
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * 按名解析单个预设(含全部字段),找不到返回 null。
123
+ * @param {any} workingDir @param {any} name
124
+ */
125
+ export function loadPreset(/** @type {any} */ workingDir, /** @type {any} */ name) {
126
+ const all = listPresets(workingDir);
127
+ const hit = all.find((/** @type {any} */ p) => p.name === name || path.basename(String(p.file), '.json') === name);
128
+ if (!hit) return null;
129
+ try {
130
+ return JSON.parse(fs.readFileSync(hit.file, 'utf8'));
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * 预设 → cfg 覆盖:只返回预设声明的参数键(其余键保持调用方当前配置)。
138
+ * tools 单独走 cfg.presetTools(白名单在 agent 的 toolsFor 处生效)。
139
+ */
140
+ export function presetConfigOverrides(/** @type {any} */ preset) {
141
+ const out = /** @type {Record<string, any>} */ ({});
142
+ for (const k of ['permission', 'model', 'temperature', 'maxOutputTokens', 'maxRounds', 'contextBudget']) {
143
+ if (preset && preset[k] !== undefined) out[k] = preset[k];
144
+ }
145
+ if (preset && Array.isArray(preset.tools)) out.presetTools = [...preset.tools];
146
+ return out;
147
+ }
148
+
149
+ /** 预设系统提示定制段(无则空串),插入系统提示 BASE 之后。 */
150
+ export function presetSystemBlock(/** @type {any} */ preset) {
151
+ const s = preset && typeof preset.systemPrompt === 'string' ? preset.systemPrompt.trim() : '';
152
+ if (!s) return '';
153
+ return `\n\n<preset_rules>\n${s}\n</preset_rules>`;
154
+ }
package/src/prompts.js CHANGED
@@ -30,8 +30,8 @@ function loadFile(/** @type {any} */ p, /** @type {any} */ cap) {
30
30
  }
31
31
  }
32
32
 
33
- /** @param {{ workingDir: any, withJournal?: boolean, projectMemory?: string, [key: string]: any }} opts */
34
- export function buildSystemPrompt({ workingDir, withJournal = false, projectMemory }) {
33
+ /** @param {{ workingDir: any, withJournal?: boolean, projectMemory?: string, presetBlock?: string, [key: string]: any }} opts */
34
+ export function buildSystemPrompt({ workingDir, withJournal = false, projectMemory, presetBlock }) {
35
35
  // 前缀字节稳定性(评估 P1-1/P1-2,四份评估一致的最高价值项):
36
36
  // 系统提示不含「当前模型」「当前日期」等易变字段——DeepSeek 上下文缓存按前缀字节匹配,
37
37
  // 路由 pro⇄flash 翻转或跨天会改变前缀 → 整段历史按未命中价重计(命中价的 30 倍)。
@@ -40,6 +40,9 @@ export function buildSystemPrompt({ workingDir, withJournal = false, projectMemo
40
40
 
41
41
  当前工作目录:${workingDir}`;
42
42
 
43
+ // v0.4.0 Agent Preset:预设定制的角色/规则段(会话内恒定,前缀稳定)
44
+ if (presetBlock) prompt += presetBlock;
45
+
43
46
  // 用户级记忆(~/.mingdao/AGENTS.md,/memory add 手动追加 + 会话结束自动提炼)
44
47
  const memory = loadFile(path.join(mingdaoHome(), 'AGENTS.md'), 8000);
45
48
  if (memory) prompt += `\n\n<user_memory>\n${memory}\n</user_memory>`;
@@ -15,6 +15,7 @@ import { chat as openaiChat } from './openai-compatible.js';
15
15
  import { modelPreset, providerPreset } from '../models.js';
16
16
  import { mingdaoHome } from '../config.js';
17
17
  import { resolveApiKey } from '../credentials.js';
18
+ import { isLocalBaseUrl } from '../model-caps.js';
18
19
 
19
20
  export function resolveProviderConfig(/** @type {any} */ cfg, /** @type {any} */ modelName) {
20
21
  // 自定义模型(config.customModels,WebUI 可增删改):优先于内置预设
@@ -56,7 +57,7 @@ function isTransient(/** @type {any} */ err) {
56
57
  return /timeout|超时|ECONNRESET|fetch failed/i.test(String(err?.message || ''));
57
58
  }
58
59
 
59
- export async function createProvider(/** @type {any} */ cfg, /** @type {any} */ modelName, { timeoutMs = 300000, retries = 2 } = {}) {
60
+ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */ modelName, /** @type {{ timeoutMs?: number, retries?: number }} */ { timeoutMs, retries = 2 } = {}) {
60
61
  const pc = resolveProviderConfig(cfg, modelName);
61
62
 
62
63
  // 自定义 Provider 模块优先(仅普通自定义端点;custom:<模型名> 走 OpenAI 兼容直连)
@@ -74,6 +75,18 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
74
75
  throw new Error(`服务商 "${pc.name}" 缺少 baseUrl,请运行 mingdao init 重新配置。`);
75
76
  }
76
77
 
78
+ // v0.3.2 本地模型自适应:本地推理框架(CPU/GPU 有限)长上下文 prefill 极慢(诊断实测 127k 上下文
79
+ // 首 token 需 196s+,q8 dequant 下 prefill 仅 ~165 tok/s)。默认超时按是否本地分层:
80
+ // - 首 token 等待:本地 600s / 远程 300s(覆盖慢 prefill,而非 189s 被掐断)
81
+ // - 流式空闲:有帧后 120s 无新帧即断(真正挂死才断,慢速吐字不误杀)
82
+ // - 总量:本地 30min / 远程 10min(长生成不误杀)
83
+ // 均可用 cfg.timeout.firstTokenMs / streamIdleMs / totalMs 覆盖。
84
+ const isLocal = isLocalBaseUrl(pc.baseUrl);
85
+ const tCfg = cfg?.timeout || {};
86
+ const firstTokenMs = Number(tCfg.firstTokenMs) > 0 ? Number(tCfg.firstTokenMs) : (isLocal ? 600000 : 300000);
87
+ const streamIdleMs = Number(tCfg.streamIdleMs) > 0 ? Number(tCfg.streamIdleMs) : 120000;
88
+ const totalMs = Number(tCfg.totalMs) > 0 ? Number(tCfg.totalMs) : (Number(timeoutMs) > 0 ? Number(timeoutMs) : (isLocal ? 1800000 : 600000));
89
+
77
90
  return {
78
91
  name: pc.name,
79
92
  config: pc,
@@ -82,10 +95,32 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
82
95
  for (;;) {
83
96
  const ac = new AbortController();
84
97
  let timedOut = false; // 审计 P2-6:用标志而非 name/字符串匹配识别内部超时
85
- const timer = setTimeout(() => {
98
+ // 总量护栏:整次请求(prefill+生成)的绝对上限
99
+ const totalTimer = setTimeout(() => {
100
+ timedOut = true;
101
+ ac.abort(new Error(`请求总时长超限(${Math.round(totalMs / 1000)}s),已中断`));
102
+ }, totalMs);
103
+ // 首 token 等待:prefill 阶段无任何帧到达即断(覆盖长上下文慢 prefill)
104
+ let firstTokenTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (setTimeout(() => {
86
105
  timedOut = true;
87
- ac.abort(new Error('请求超时'));
88
- }, timeoutMs);
106
+ ac.abort(new Error(`首 token 等待超限(${Math.round(firstTokenMs / 1000)}s,本地模型长上下文 prefill 可能很慢)——可调大 config.timeout.firstTokenMs,或拆分任务/压缩上下文`));
107
+ }, firstTokenMs));
108
+ // 流式空闲:有帧后 120s 无新帧即断;每收到一帧重置
109
+ let idleTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (null);
110
+ const armIdle = () => {
111
+ if (idleTimer) clearTimeout(idleTimer);
112
+ idleTimer = setTimeout(() => {
113
+ timedOut = true;
114
+ ac.abort(new Error(`流式响应空闲超限(${Math.round(streamIdleMs / 1000)}s 无新数据)`));
115
+ }, streamIdleMs);
116
+ };
117
+ const onActivity = () => {
118
+ if (firstTokenTimer) {
119
+ clearTimeout(firstTokenTimer);
120
+ firstTokenTimer = null;
121
+ }
122
+ armIdle();
123
+ };
89
124
  // 转发外部信号(用户 Ctrl+C 中断),避免被内部超时信号覆盖
90
125
  const onUserAbort = () => ac.abort(opts.signal?.reason);
91
126
  if (opts.signal?.aborted) onUserAbort();
@@ -97,20 +132,24 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
97
132
  apiKey: pc.apiKey,
98
133
  signal: ac.signal,
99
134
  includeUsage: cfg?.includeUsage !== false,
135
+ onActivity,
100
136
  });
101
137
  } catch (err) {
102
138
  // 内部超时经 abort 抛出,用标志识别(审计 P2-6);用户 Ctrl+C 的中断不算超时、不重试
103
139
  const transient = (timedOut && !opts.signal?.aborted) || isTransient(err);
104
140
  if (!transient || attempt >= retries) throw err;
105
141
  attempt += 1;
106
- // 指数退避 + 尊重 Retry-After(评估 P3-1):基础 1s/2s,服务端指定时取其值(封顶 30s)
142
+ // 首 token 等待超时通常不是偶发网络抖动(是模型/上下文慢),重试价值低但保留一次机会;
143
+ // 其余瞬态错误指数退避 + 尊重 Retry-After(评估 P3-1):基础 1s/2s,封顶 30s
107
144
  let backoff = 1000 * attempt;
108
145
  const ra = Number((/** @type {any} */ (err))?.headers?.get?.('retry-after'));
109
146
  if (Number.isFinite(ra) && ra > 0) backoff = Math.max(backoff, ra * 1000);
110
147
  backoff = Math.min(backoff, 30000);
111
148
  await sleep(backoff);
112
149
  } finally {
113
- clearTimeout(timer);
150
+ clearTimeout(totalTimer);
151
+ if (firstTokenTimer) clearTimeout(firstTokenTimer);
152
+ if (idleTimer) clearTimeout(idleTimer);
114
153
  opts.signal?.removeEventListener('abort', onUserAbort);
115
154
  }
116
155
  }
@@ -6,7 +6,7 @@
6
6
  * @typedef {Error & { status?: number, headers?: Headers }} ApiError
7
7
  */
8
8
 
9
- export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages, tools, temperature, maxTokens, signal, onDelta, includeUsage = true, responseFormat, reasoningEffort }) {
9
+ export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages, tools, temperature, maxTokens, signal, onDelta, onActivity, includeUsage = true, responseFormat, reasoningEffort }) {
10
10
  const url = String(baseUrl).replace(/\/+$/, '') + '/chat/completions';
11
11
  const payload = /** @type {Record<string, any>} */ ({ model, messages });
12
12
  if (temperature != null) payload.temperature = temperature;
@@ -62,7 +62,7 @@ export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages
62
62
  if (!json) throw new Error(`[${model}] 响应解析失败。`);
63
63
  return parseNonStream(json, onDelta);
64
64
  }
65
- return parseStream(res.body, onDelta);
65
+ return parseStream(res.body, onDelta, onActivity);
66
66
  }
67
67
 
68
68
  export function parseNonStream(/** @type {any} */ json, /** @type {any} */ onDelta) {
@@ -81,7 +81,9 @@ export function parseNonStream(/** @type {any} */ json, /** @type {any} */ onDel
81
81
  }
82
82
 
83
83
  // 解析 SSE 流:处理跨 chunk 断行、增量 content / reasoning_content / tool_calls。
84
- export async function parseStream(/** @type {any} */ body, /** @type {any} */ onDelta) {
84
+ // onActivity:每收到一个有效 SSE 数据帧即回调(含 usage-only 帧),供上层做「首 token 等待/流式空闲」超时仲裁——
85
+ // 长上下文 prefill 时服务端可能 200s+ 无正文,靠「有帧到达」而非「有正文」判定存活,避免误杀慢 prefill。
86
+ export async function parseStream(/** @type {any} */ body, /** @type {any} */ onDelta, /** @type {any} */ onActivity) {
85
87
  const reader = body.getReader();
86
88
  const decoder = new TextDecoder();
87
89
  let buf = '';
@@ -151,6 +153,9 @@ export async function parseStream(/** @type {any} */ body, /** @type {any} */ on
151
153
  while ((nl = buf.indexOf('\n')) >= 0) {
152
154
  const line = buf.slice(0, nl);
153
155
  buf = buf.slice(nl + 1);
156
+ // 有帧到达即视为「活着」:prefill 阶段服务端可能先发 usage-only/空帧,正文迟迟不来,
157
+ // 靠帧到达刷新上层流式空闲计时器(首 token 等待则仍由「无任何帧」触发)
158
+ if (line.trim()) onActivity?.();
154
159
  if (handleLine(line)) break;
155
160
  }
156
161
  }
@@ -53,6 +53,9 @@ export async function runWorkerTask(id, question, { permission, model, offpeak }
53
53
  }
54
54
  const provider = await createProvider(cfg, modelName);
55
55
  const io = createIO({ quiet: true });
56
+ // v0.4.0 契约化:后台任务同样挂载 config.tools 声明式工具(幂等)
57
+ const { mountConfigTools } = await import('../tools/index.js');
58
+ mountConfigTools(cfg);
56
59
  const permissionObj = createPermission(perm, io);
57
60
  /** @type {any} */
58
61
  let mcpManager = null;