mingdao-harness 0.2.4 → 0.2.6

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 (66) hide show
  1. package/docs/EVALUATION-ROUND2.md +58 -0
  2. package/docs/ROADMAP-NEXT.md +5 -5
  3. package/package.json +2 -2
  4. package/src/agent.js +74 -36
  5. package/src/atomic-write.js +8 -2
  6. package/src/audit.js +2 -2
  7. package/src/batch.js +7 -7
  8. package/src/cachestats.js +9 -9
  9. package/src/cli.js +42 -688
  10. package/src/commands/desktop.js +1 -1
  11. package/src/commands/key.js +1 -1
  12. package/src/commands/repl.js +711 -0
  13. package/src/commands/schedule.js +7 -7
  14. package/src/commands/skill.js +18 -16
  15. package/src/commands/sync.js +5 -5
  16. package/src/commands/update.js +7 -6
  17. package/src/commands/workspace.js +2 -2
  18. package/src/compact.js +6 -6
  19. package/src/config.js +7 -4
  20. package/src/context.js +37 -21
  21. package/src/cost-guard.js +14 -6
  22. package/src/credentials.js +6 -0
  23. package/src/hooks.js +11 -11
  24. package/src/mcp-presets.js +5 -5
  25. package/src/mcp.js +19 -19
  26. package/src/memory.js +16 -16
  27. package/src/model-discovery.js +16 -15
  28. package/src/models.js +4 -4
  29. package/src/notify.js +2 -2
  30. package/src/permissions.js +7 -3
  31. package/src/pricing.js +35 -1
  32. package/src/prompts.js +1 -1
  33. package/src/providers/index.js +7 -7
  34. package/src/providers/openai-compatible.js +13 -11
  35. package/src/routing.js +8 -8
  36. package/src/schedule.js +30 -30
  37. package/src/session-index.js +5 -1
  38. package/src/session.js +11 -2
  39. package/src/skill-lib.js +62 -3
  40. package/src/skill-registry.js +10 -9
  41. package/src/skills.js +14 -12
  42. package/src/sync-server.js +86 -3
  43. package/src/sync.js +53 -20
  44. package/src/tasks/worker.js +126 -0
  45. package/src/tasks.js +14 -14
  46. package/src/titles.js +5 -5
  47. package/src/tokenizer.js +23 -5
  48. package/src/tools/bash.js +12 -12
  49. package/src/tools/fs-tools.js +66 -9
  50. package/src/tools/index.js +7 -7
  51. package/src/ui.js +83 -6
  52. package/src/update.js +8 -8
  53. package/src/web/app.js +16 -5
  54. package/src/web/attachments.js +1 -1
  55. package/src/web/index.html +21 -1
  56. package/src/web/routes/api.js +38 -786
  57. package/src/web/routes/domains/config.js +280 -0
  58. package/src/web/routes/domains/misc.js +129 -0
  59. package/src/web/routes/domains/schedule.js +127 -0
  60. package/src/web/routes/domains/sessions.js +105 -0
  61. package/src/web/routes/domains/skills.js +79 -0
  62. package/src/web/routes/domains/sync.js +101 -0
  63. package/src/web/routes/domains/workspace.js +119 -0
  64. package/src/web/server.js +73 -31
  65. package/src/web/web-io.js +54 -12
  66. package/src/workspace.js +14 -14
package/src/cli.js CHANGED
@@ -111,12 +111,12 @@ const HELP_LINES = [
111
111
  [`配置目录: ${mingdaoHome()}`, C.dim],
112
112
  ];
113
113
 
114
- function printHelpLines(out) {
114
+ function printHelpLines(/** @type {any} */ out) {
115
115
  for (const [text, code] of HELP_LINES) out(code ? style(text, code) : text);
116
116
  }
117
117
 
118
- function parseArgs(argv) {
119
- const opts = { prompt: [], model: null, continueSession: false, resume: false, format: 'text' };
118
+ function parseArgs(/** @type {any} */ argv) {
119
+ const opts = /** @type {Record<string, any>} */ ({ prompt: [], model: null, continueSession: false, resume: false, format: 'text' });
120
120
  for (let i = 0; i < argv.length; i++) {
121
121
  const a = argv[i];
122
122
  if (a === '-h' || a === '--help') opts.help = true;
@@ -135,7 +135,7 @@ function parseArgs(argv) {
135
135
  return opts;
136
136
  }
137
137
 
138
- async function generatePlan(provider, modelName, task) {
138
+ async function generatePlan(/** @type {any} */ provider, /** @type {any} */ modelName, /** @type {any} */ task) {
139
139
  const res = await provider.chat({
140
140
  model: modelName,
141
141
  messages: [
@@ -153,105 +153,7 @@ async function generatePlan(provider, modelName, task) {
153
153
  return res.text || null;
154
154
  }
155
155
 
156
- // —— 后台任务 worker:独立进程执行一轮任务并写状态文件 ——
157
- async function runWorkerTask(id, question, { permission, model, offpeak }) {
158
- const home = ensureHome();
159
- const finish = (patch) => patchTask(home, id, patch);
160
- let mcpFacade = null;
161
- let cfg = null; // 提到 try 外:catch 分支也要读 cfg.notify
162
- try {
163
- cfg = loadConfig();
164
- if (!cfg) throw new Error('未初始化配置,请先运行 mingdao init');
165
- const modelName = model || cfg.model || 'deepseek-v4-flash';
166
- const pc = resolveProviderConfig(cfg, modelName);
167
- if (!pc.apiKey) throw new Error(`模型 ${modelName} 没有可用 API Key`);
168
- const workingDir = process.cwd();
169
- // 后台无交互:ask 权限降级为 readonly 并注明(需要写权限请 mingdao run --permission auto)
170
- let perm = permission || cfg.permission || 'ask';
171
- let note = '';
172
- if (perm === 'ask') {
173
- perm = 'readonly';
174
- note = 'ask 权限下后台任务按只读执行';
175
- }
176
- // 避峰(评估 A2/Kimi P-1):高峰时段(北京工作日 9:00–12:00、14:00–18:00)
177
- // 自动顺延到最近闲时起点(12:00 / 18:00)执行,输入价省 50%
178
- if (offpeak && isPeakHour(new Date())) {
179
- const defer = deferToOffpeak(new Date());
180
- finish({ note: `避峰等待至北京时间 ${defer.toISOString().slice(11, 16)}(闲时起执行,输入价省 50%)` });
181
- await new Promise((r) => setTimeout(r, defer.getTime() - Date.now() + 2000));
182
- finish({ note: '' });
183
- }
184
- const provider = await createProvider(cfg, modelName);
185
- const io = createIO({ quiet: true });
186
- const permissionObj = createPermission(perm, io);
187
- let mcpManager = null;
188
- if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
189
- mcpFacade = {
190
- toolSchemas: () => (mcpManager ? mcpManager.toolSchemas() : []),
191
- call: (n, a) => (mcpManager ? mcpManager.call(n, a) : Promise.reject(new Error('MCP 未就绪'))),
192
- isReadonly: (n) => (mcpManager ? mcpManager.isReadonly(n) : false),
193
- stop: () => {
194
- if (mcpManager) mcpManager.stop();
195
- },
196
- };
197
- startMcpServers(cfg.mcpServers, workingDir).then((m) => (mcpManager = m)).catch(() => {});
198
- }
199
- const sessionRef = { name: null };
200
- const agent = createAgent({
201
- provider,
202
- permission: permissionObj,
203
- io,
204
- modelName,
205
- workingDir,
206
- cfg,
207
- mcp: mcpFacade || undefined,
208
- sessionRef,
209
- onCompact: (msgs) => {
210
- rewriteSession(session.file, msgs);
211
- persistedCount = msgs.length;
212
- },
213
- });
214
- const session = createSession(home);
215
- sessionRef.name = path.basename(session.file);
216
- const messages = [
217
- { role: 'system', content: buildSystemPrompt({ modelName, workingDir }) },
218
- { role: 'user', content: question },
219
- ];
220
- appendMessages(session.file, messages);
221
- let persistedCount = messages.length;
222
- const t0 = Date.now();
223
- const res = await agent.runTurn(messages);
224
- appendMessages(session.file, messages.slice(persistedCount));
225
- if (cfg.autoTitle !== false && res.text) {
226
- try {
227
- const tModel = titleModel(cfg, modelName);
228
- const title = await generateTitle(await helperProvider(cfg, tModel, provider), tModel, question);
229
- if (title) renameSessionFile(fs, path, home, session, title);
230
- } catch {}
231
- }
232
- const finalStatus = res.truncated ? 'failed' : res.aborted ? 'killed' : 'done';
233
- recordUsage(res.perf?.usedModel || modelName, res.usage, res.perf);
234
- finish({
235
- status: finalStatus,
236
- text: (res.text || '').slice(0, 2000),
237
- usage: res.usage,
238
- durationMs: Date.now() - t0,
239
- session: path.basename(session.file),
240
- note,
241
- });
242
- if (cfg.notify !== false && !process.env.MINGDAO_TASK_QUIET_NOTIFY) notifyTaskDone(question, finalStatus === 'killed' ? 'failed' : finalStatus);
243
- try {
244
- await maybeAutoSync();
245
- } catch {}
246
- process.exitCode = res.truncated ? 1 : 0;
247
- } catch (err) {
248
- finish({ status: 'failed', error: String(err?.message || err) });
249
- if (cfg?.notify !== false && !process.env.MINGDAO_TASK_QUIET_NOTIFY) notifyTaskDone(question, 'failed');
250
- process.exitCode = 2;
251
- } finally {
252
- if (mcpFacade) mcpFacade.stop();
253
- }
254
- }
156
+ // —— 后台任务 worker(Phase C C2):已抽取至 src/tasks/worker.js,入口处动态导入 ——
255
157
 
256
158
  async function main() {
257
159
  const opts = parseArgs(process.argv.slice(2));
@@ -270,7 +172,7 @@ async function main() {
270
172
  // 各 handler 返回 true = 已处理;false = 按普通提问继续(保留词劫持防护)。
271
173
  // 质检 M3:显式命令映射(命令 → {module, handler}),模块与命令一一对应、handler 名显式可查
272
174
  {
273
- const dispatchTable = {
175
+ const dispatchTable = /** @type {Record<string, any>} */ ({
274
176
  update: { module: 'update', handler: 'handleUpdateFamily' },
275
177
  rollback: { module: 'update', handler: 'handleUpdateFamily' },
276
178
  batch: { module: 'update', handler: 'handleUpdateFamily' },
@@ -286,7 +188,7 @@ async function main() {
286
188
  sessions: { module: 'skill', handler: 'handleSessions' },
287
189
  key: { module: 'key', handler: 'handleKey' },
288
190
  desktop: { module: 'desktop', handler: 'handleDesktop' },
289
- };
191
+ });
290
192
  const hit = dispatchTable[opts.prompt[0]];
291
193
  if (hit) {
292
194
  const mod = await import(`./commands/${hit.module}.js`);
@@ -297,7 +199,7 @@ async function main() {
297
199
  }
298
200
 
299
201
  // 质检 A9:run/run-worker 共用同一参数解析(此前两份重复且语义微妙不同)
300
- const parseRunArgs = (argv, { questionFlag = false } = {}) => {
202
+ const parseRunArgs = (/** @type {any} */ argv, { questionFlag = false } = {}) => {
301
203
  const out = { question: '', permission: null, model: null, offpeak: false };
302
204
  for (let i = 0; i < argv.length; i++) {
303
205
  const a = argv[i];
@@ -315,10 +217,11 @@ async function main() {
315
217
  return out;
316
218
  };
317
219
 
318
- // 后台任务 worker(内部入口,由 mingdao run 启动)
220
+ // 后台任务 worker(内部入口,由 mingdao run 启动;逻辑在 tasks/worker.js,Phase C C2)
319
221
  if (opts.prompt[0] === 'run-worker') {
320
222
  const id = opts.prompt[1];
321
223
  const { question, permission, model, offpeak } = parseRunArgs(opts.prompt.slice(2));
224
+ const { runWorkerTask } = await import('./tasks/worker.js');
322
225
  await runWorkerTask(id, question, { permission, model, offpeak });
323
226
  return;
324
227
  }
@@ -430,7 +333,7 @@ async function main() {
430
333
  }
431
334
 
432
335
  // 模型回退链(向导允许跳过模型选择 → cfg.model 可缺省):参数 > config > 该服务商首个预设模型 > flash
433
- let modelName = opts.model || cfg.model || PROVIDERS[cfg.provider]?.models?.[0] || 'deepseek-v4-flash';
336
+ let modelName = opts.model || cfg.model || /** @type {any} */ (PROVIDERS)[cfg.provider]?.models?.[0] || 'deepseek-v4-flash';
434
337
  const io = createIO();
435
338
 
436
339
  const pc0 = resolveProviderConfig(cfg, modelName);
@@ -454,28 +357,35 @@ async function main() {
454
357
  // 会话级 undo 备份仓:模型切换、子代理均共享,撤销记录不丢失
455
358
  const sessionUndoStore = { backups: new Map() };
456
359
  // MCP 服务器:后台启动(不阻塞交互),就绪后工具自动出现在后续轮次
457
- let mcpManager = null;
360
+ let mcpManager = /** @type {any} */ (null);
458
361
  const mcpFacade = {
459
362
  toolSchemas: () => (mcpManager ? mcpManager.toolSchemas() : []),
460
- call: (n, a) => (mcpManager ? mcpManager.call(n, a) : Promise.reject(new Error('MCP 未就绪'))),
461
- isReadonly: (n) => (mcpManager ? mcpManager.isReadonly(n) : false),
363
+ call: (/** @type {any} */ n, /** @type {any} */ a) => (mcpManager ? mcpManager.call(n, a) : Promise.reject(new Error('MCP 未就绪'))),
364
+ isReadonly: (/** @type {any} */ n) => (mcpManager ? mcpManager.isReadonly(n) : false),
462
365
  status: () => (mcpManager ? mcpManager.status() : [{ name: '(连接中…)', ok: false, tools: 0, error: '' }]),
463
366
  stop: () => {
464
367
  if (mcpManager) mcpManager.stop();
465
368
  },
466
369
  };
467
370
  if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
468
- startMcpServers(cfg.mcpServers, workingDir)
469
- .then((mgr) => {
470
- mcpManager = mgr;
471
- const ready = mgr.status().filter((s) => s.ok).length;
472
- if (io && !opts.prompt.length) {
473
- io.print(style(`✓ MCP 就绪:${ready}/${mgr.status().length} 个服务器,共 ${mgr.toolSchemas().length} 个工具`, C.dim));
474
- }
475
- })
476
- .catch(() => {});
371
+ // A2:预热——await 连接(6s 超时);超时本会话冻结工具集(不再中途注入,保护前缀缓存)
372
+ mcpManager = await Promise.race([
373
+ startMcpServers(cfg.mcpServers, workingDir).catch(() => null),
374
+ new Promise((/** @type {any} */ r) => setTimeout(() => r(null), 6000)),
375
+ ]);
376
+ if (mcpManager) {
377
+ const ready = mcpManager.status().filter((/** @type {any} */ s) => s.ok).length;
378
+ if (io && !opts.prompt.length) {
379
+ io.print(style(`✓ MCP 就绪:${ready}/${mcpManager.status().length} 个服务器,共 ${mcpManager.toolSchemas().length} 个工具`, C.dim));
380
+ }
381
+ } else if (io && !opts.prompt.length) {
382
+ io.print(style('⚠ MCP 连接超时(6s):本会话不注入 MCP 工具(重启 mingdao 可重试)', C.dim));
383
+ }
477
384
  }
478
385
  const sessionRef = { name: null }; // 会话名在下方 REPL 初始化中回填(审计归因用)
386
+ // 会话文件与落盘游标共享槽:REPL(commands/repl.js)创建会话/更新游标后回填,
387
+ // TUI agent 的 onCompact 经此槽重写会话文件(Phase C C2 抽取后的正确连线,防 ReferenceError 静默吞掉压缩)
388
+ const tuiState = /** @type {{session: any, persisted: number}} */ ({ session: null, persisted: 0 });
479
389
  let agent = createAgent({
480
390
  provider,
481
391
  permission,
@@ -486,10 +396,10 @@ async function main() {
486
396
  undoStore: sessionUndoStore,
487
397
  mcp: mcpFacade,
488
398
  sessionRef,
489
- // 自动压缩后重写会话文件 + 同步落盘游标(session/persisted 在下方 REPL 初始化中声明)
490
- onCompact: (msgs) => {
491
- rewriteSession(session.file, msgs);
492
- persisted = msgs.length;
399
+ // 自动压缩后重写会话文件 + 同步落盘游标(经 tuiState 共享槽与 REPL 会话联动)
400
+ onCompact: (/** @type {any} */ msgs) => {
401
+ if (tuiState.session) rewriteSession(tuiState.session.file, msgs);
402
+ tuiState.persisted = msgs.length;
493
403
  },
494
404
  });
495
405
  const preset = modelPreset(modelName);
@@ -527,7 +437,7 @@ async function main() {
527
437
  undoStore: sessionUndoStore,
528
438
  mcp: mcpFacade,
529
439
  sessionRef: oneShotRef,
530
- onCompact: (msgs) => {
440
+ onCompact: (/** @type {any} */ msgs) => {
531
441
  rewriteSession(session.file, msgs);
532
442
  oneShotPersisted = msgs.length;
533
443
  },
@@ -565,15 +475,15 @@ async function main() {
565
475
  session: session.name,
566
476
  })
567
477
  );
568
- recordUsage(res.perf?.usedModel || modelName, res.usage, res.perf);
478
+ recordUsage(res.perf?.usedModel || modelName, res.usage, /** @type {any} */ (res.perf));
569
479
  process.exitCode = res.truncated || res.aborted ? 1 : 0;
570
480
  } else {
571
481
  io.printUsageLine({ modelName, usage: res.usage, durationMs: res.durationMs });
572
482
  if (res.aborted) io.print(style('(已中断)', C.dim));
573
- recordUsage(res.perf?.usedModel || modelName, res.usage, res.perf);
483
+ recordUsage(res.perf?.usedModel || modelName, res.usage, /** @type {any} */ (res.perf));
574
484
  process.exitCode = res.truncated ? 1 : 0;
575
485
  }
576
- } catch (err) {
486
+ } catch (/** @type {any} */ err) {
577
487
  if (jsonMode) {
578
488
  console.log(JSON.stringify({ ok: false, error: String(err?.message || err) }));
579
489
  } else {
@@ -586,567 +496,11 @@ async function main() {
586
496
  return;
587
497
  }
588
498
 
589
- // —— 交互式 TUI ——
590
- const storedKey = getStoredKey(pc0.name);
591
- const envKeyDetected = (pc0.envHint && process.env[pc0.envHint]) || process.env.MINGDAO_API_KEY;
592
- const keySource = envKeyDetected ? '环境变量' : storedKey ? `凭证库 ${maskKey(storedKey)}` : 'config 字段';
593
- const sandboxMode = cfg.sandbox || 'off';
594
- const sandboxLabel =
595
- sandboxMode === 'off' ? 'off' : detectSandbox() === 'bwrap' ? sandboxMode : `${sandboxMode}(bwrap 缺失,已降级)`;
596
- const routing = routingConfig(cfg);
597
- const wsNow = currentWorkspace(workingDir);
598
- io.print('');
599
- io.box(`MingDao Harness v${pkg.version}`, [
600
- `模型 ${modelName}${preset?.label ? '(' + preset.label + ')' : ''}`,
601
- `权限 ${permission.mode} · 密钥 ${keySource}`,
602
- `沙箱 ${sandboxLabel}${routing ? ` · 路由 ${routing.planner}⇄${routing.executor}` : ''}`,
603
- wsNow ? `工作空间 ${wsNow.name}(${wsNow.dir})` : '',
604
- ].filter(Boolean));
605
- io.print(style('输入问题开始对话 · /help 查看命令 · Tab 补全 · Ctrl+C 中断生成\n', C.dim));
499
+ // —— 交互式 TUI(Phase C C2:已抽取至 commands/repl.js) ——
500
+ const { runRepl } = await import('./commands/repl.js');
501
+ await runRepl({ io, cfg, home, pc0, opts, modelName, provider, permission, workingDir, sessionUndoStore, mcpFacade, mcpManager, sessionRef, agent, preset, withJournal, tuiState });
502
+ return;
606
503
 
607
- // WebUI 自动启动(首次运行 mingdao web 时可选开启,存 config.web.autoStart):
608
- // 独立后台进程拉起,退出 TUI 后 WebUI 继续可用;关闭:mingdao web --no-autostart
609
- if (cfg.web?.autoStart && !process.env.MINGDAO_NO_WEB_AUTOSTART) {
610
- try {
611
- const { spawn } = await import('node:child_process');
612
- const { fileURLToPath } = await import('node:url');
613
- const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'web'], {
614
- detached: true,
615
- stdio: 'ignore',
616
- env: process.env,
617
- });
618
- child.on('error', (err) => io.print(style(`[WebUI 自启失败] ${err?.message || err}(可手动运行 mingdao web ${cfg.web?.port || 3820})`, C.red)));
619
- child.unref();
620
- io.print(style(`🌐 WebUI 后台启动中:http://127.0.0.1:${cfg.web?.port || 3820}(关闭自动启动:mingdao web --no-autostart)`, C.dim));
621
- } catch (err) {
622
- io.print(style(`[WebUI 自启失败] ${err?.message || err}`, C.red)); // 质检 L6:不再静默吞掉
623
- }
624
- }
625
-
626
- let session = null;
627
- if (opts.resume) {
628
- const list = listSessions(home).slice(0, 10);
629
- if (!list.length) {
630
- io.print(style('没有可恢复的历史会话,已新建。', C.dim));
631
- } else {
632
- const choice = await io.choose(
633
- '选择要恢复的会话:',
634
- list.map((s) => ({ value: s.file, label: `${relativeTime(s.mtime)} · ${sessionPreview(s.file)}` }))
635
- );
636
- const loaded = loadSession(choice);
637
- if (loaded.messages.length) {
638
- session = loaded;
639
- io.print(style(`✓ 已载入会话 ${path.basename(choice)}(${loaded.messages.length} 条消息)`, C.green));
640
- }
641
- }
642
- }
643
- if (!session && opts.continueSession) {
644
- const latest = latestSession(home);
645
- if (latest) {
646
- const loaded = loadSession(latest.file);
647
- if (loaded.messages.length) {
648
- session = loaded;
649
- io.print(style(`已载入会话 ${latest.name}(${loaded.messages.length} 条消息)`, C.dim));
650
- }
651
- }
652
- if (!session) io.print('没有可继续的历史会话,已新建。');
653
- }
654
- if (!session) session = createSession(home);
655
- sessionRef.name = path.basename(session.file);
656
- io.print(style(`会话 ${path.basename(session.file)}`, C.dim));
657
-
658
- const systemPrompt = buildSystemPrompt({ modelName, workingDir, withJournal });
659
- // 恢复会话时刷新 system prompt(用户记忆 / AGENTS.md / 技能清单 / 时间戳以当前为准),
660
- // 旧 system 消息保留在会话文件中,不影响追加历史。
661
- const loadedMsgs = session.messages || [];
662
- const hasOldSystem = loadedMsgs[0]?.role === 'system';
663
- let messages = hasOldSystem
664
- ? [{ role: 'system', content: systemPrompt }, ...loadedMsgs.slice(1)]
665
- : [{ role: 'system', content: systemPrompt }, ...loadedMsgs];
666
- let persisted = messages.length;
667
- let lastUsage = null;
668
- let lastText = '';
669
- let planMode = false;
670
- let routingEnabled = Boolean(routing);
671
- let lastRouteModel = null; // 会话级路由粘滞(评估 P2-1:执行类会话不再逐轮分类)
672
- let autoTitled = Boolean(session.messages?.length);
673
- const stats = { turns: 0, promptTokens: 0, completionTokens: 0 };
674
- io.setHistory(messages.filter((m) => m.role === 'user').map((m) => m.content));
675
-
676
- async function switchToModel(target, { silent = false, persist = true } = {}) {
677
- try {
678
- const npc = resolveProviderConfig(cfg, target);
679
- if (!npc.apiKey) {
680
- io.print(style('该模型没有可用的 API Key,请先运行 mingdao init 配置。', C.red));
681
- return false;
682
- }
683
- const newProvider = await createProvider(cfg, target);
684
- provider = newProvider;
685
- modelName = target;
686
- // 自动路由的切换只改会话内存态(评估 P3-6:不悄悄改写用户持久默认模型);/model 显式切换才落盘
687
- if (persist) {
688
- cfg.model = target;
689
- saveConfig(cfg);
690
- }
691
- agent = createAgent({
692
- provider,
693
- permission,
694
- io,
695
- modelName,
696
- workingDir,
697
- cfg,
698
- undoStore: sessionUndoStore,
699
- mcp: mcpFacade,
700
- sessionRef,
701
- onCompact: (msgs) => {
702
- rewriteSession(session.file, msgs);
703
- persisted = msgs.length;
704
- },
705
- });
706
- messages[0] = { role: 'system', content: buildSystemPrompt({ workingDir, withJournal }) };
707
- if (!silent) {
708
- const p2 = modelPreset(modelName);
709
- io.print(style(`✓ 已切换到 ${C.bold}${modelName}${C.reset}${p2 ? `(${p2.label})` : ''}`, C.green));
710
- }
711
- return true;
712
- } catch (err) {
713
- io.print(style('[错误] ' + (err?.message || err), C.red));
714
- return false;
715
- }
716
- }
717
-
718
- for (;;) {
719
- let input;
720
- try {
721
- input = await io.askMultiline(style('你> ', C.green));
722
- } catch {
723
- break;
724
- }
725
- if (input === '') continue;
726
-
727
- if (input.startsWith('/')) {
728
- const [cmd, ...rest] = input.split(/\s+/);
729
- const arg = rest.join(' ');
730
- // 审计(第五轮 P1-1 教训):斜杠命令统一 try/catch——单条命令异常只提示不退出,
731
- // 绝不再因一条命令的错误杀死整个 REPL 会话(历史 P1-1 曾导致会话上下文全丢)
732
- try {
733
- if (cmd === '/exit' || cmd === '/quit') break;
734
- else if (cmd === '/help') printHelpLines(io.print);
735
- else if (cmd === '/clear') {
736
- messages = [{ role: 'system', content: systemPrompt }];
737
- // 评估 P2-1:会话文件同步原子重写为仅新 system——否则旧上下文残留会被 --continue 读回,
738
- // 且后续 appendMessages 会把 system+新消息重复追加到旧历史之后。
739
- try {
740
- rewriteSession(session.file, messages);
741
- } catch {}
742
- persisted = messages.length;
743
- io.print('已清空上下文(会话文件已同步重置)。');
744
- } else if (cmd === '/model') {
745
- if (!arg) {
746
- io.print(`当前模型:${modelName}`);
747
- continue;
748
- }
749
- await switchToModel(arg);
750
- } else if (cmd === '/mode') {
751
- const map = { pro: 'deepseek-v4-pro', flash: 'deepseek-v4-flash' };
752
- const target = map[arg] || arg;
753
- if (!target) {
754
- io.print('用法:/mode pro|flash|<模型名>(pro=deepseek-v4-pro,flash=deepseek-v4-flash)');
755
- continue;
756
- }
757
- await switchToModel(target);
758
- } else if (cmd === '/think') {
759
- const vals = ['low', 'high', 'max'];
760
- if (!arg) {
761
- io.print(`用法:/think low|high|max|off(当前:${cfg.reasoningEffort || '跟随模型默认 high'})`);
762
- continue;
763
- }
764
- if (arg === 'off') cfg.reasoningEffort = undefined;
765
- else if (vals.includes(arg)) cfg.reasoningEffort = arg;
766
- else { io.print(style('无效取值:low|high|max|off', C.red)); continue; }
767
- agent = createAgent({
768
- provider, permission, io, modelName, workingDir, cfg,
769
- undoStore: sessionUndoStore, mcp: mcpFacade, sessionRef,
770
- onCompact: (msgs) => {
771
- rewriteSession(session.file, msgs);
772
- persisted = msgs.length;
773
- },
774
- });
775
- io.print(style(`✓ 思考强度:${cfg.reasoningEffort || 'off(模型默认)'}`, C.green));
776
- } else if (cmd === '/plan') {
777
- planMode = !planMode;
778
- io.print(style(`✓ 计划模式:${planMode ? '开' : '关'}${planMode ? '(先出计划,确认后执行)' : ''}`, C.green));
779
- } else if (cmd === '/route') {
780
- if (!routing) {
781
- io.print(style('未配置自动路由(config.json 的 routing 字段,如 {"enabled":true,"planner":"deepseek-v4-pro","executor":"deepseek-v4-flash"})', C.dim));
782
- continue;
783
- }
784
- if (arg === 'on' || arg === 'off') routingEnabled = arg === 'on';
785
- else routingEnabled = !routingEnabled;
786
- io.print(style(`✓ 自动路由:${routingEnabled ? '开' : '关'}(规划类→${routing.planner},执行类→${routing.executor})`, C.green));
787
- } else if (cmd === '/verbose') {
788
- io.setShowReasoning(!io.showReasoning);
789
- io.print(style(`✓ 思考过程显示:${io.showReasoning ? '开' : '关'}`, C.green));
790
- } else if (cmd === '/compact') {
791
- if (messages.length <= 6) {
792
- io.print(style('消息较少(≤6 条),无需压缩。', C.dim));
793
- continue;
794
- }
795
- io.startSpinner('正在压缩上下文…');
796
- let compacted = null;
797
- try {
798
- const count = makeTokenCounter(modelName);
799
- let total = 0;
800
- for (const m of messages) total += messageTokens(m, count);
801
- const budget = Math.max(1000, Math.round(total * 0.5)); // 手动触发:保留约 30% 尾部
802
- compacted = await compactConversation({
803
- messages,
804
- budget,
805
- count,
806
- provider,
807
- executorModel: modelName,
808
- triggerRatio: 0,
809
- force: true, // 手动意图明确:跳过最小裁剪门槛
810
- });
811
- } catch (err) {
812
- io.print(style('[压缩失败] ' + (err?.message || err), C.red));
813
- }
814
- io.stopSpinner();
815
- if (!compacted?.messages) {
816
- io.print(style('未能压缩(被裁段落不足或摘要失败)。', C.dim));
817
- continue;
818
- }
819
- messages = compacted.messages;
820
- appendMessages(session.file, [{ role: 'system', content: '── /compact 压缩点 ──' }, ...messages.slice(1)]);
821
- persisted = messages.length;
822
- io.print(style(`✓ 已压缩上下文:${compacted.droppedCount} 条早期消息 → 摘要(回收约 ${compacted.droppedTokens} tokens)`, C.green));
823
- } else if (cmd === '/init') {
824
- const target = path.join(workingDir, 'AGENTS.md');
825
- if (fs.existsSync(target) && arg !== 'force') {
826
- io.print(style(`已存在 ${target},如需覆盖:/init force`, C.yellow));
827
- continue;
828
- }
829
- const entries = fs.readdirSync(workingDir).slice(0, 20).join('、') || '(空目录)';
830
- const template =
831
- `# ${path.basename(workingDir)} 项目约定\n\n` +
832
- `## 项目概述\n\n(一句话说明项目用途)\n\n` +
833
- `## 常用命令\n\n(构建、测试、运行命令)\n\n` +
834
- `## 代码结构\n\n顶层内容:${entries}\n\n` +
835
- `## 约定与规范\n\n(团队约定、注意事项;MingDao 每次会话会自动读取本文件)\n`;
836
- fs.writeFileSync(target, template);
837
- io.print(style(`✓ 已生成 ${target},将在后续会话自动注入。`, C.green));
838
- } else if (cmd === '/memory') {
839
- const memPath = path.join(mingdaoHome(), 'AGENTS.md');
840
- if (arg.startsWith('add ')) {
841
- const text = arg.slice(4).trim();
842
- if (!text) {
843
- io.print('用法:/memory add <内容>');
844
- continue;
845
- }
846
- fs.appendFileSync(memPath, `- ${text}\n`);
847
- io.print(style(`✓ 已追加到用户记忆 ${memPath}(后续会话自动生效)`, C.green));
848
- } else if (arg === 'extract') {
849
- io.startSpinner('正在从当前对话提炼记忆…');
850
- try {
851
- const existing = loadMemory();
852
- const lines = await extractMemory(provider, titleModel(cfg, modelName), messages, existing);
853
- io.stopSpinner();
854
- if (lines.length) {
855
- appendMemory(lines);
856
- io.print(style(`✓ 新增 ${lines.length} 条记忆:`, C.green));
857
- for (const l of lines) io.print(style(' ' + l, C.dim));
858
- } else {
859
- io.print(style('没有发现新的值得记住的内容。', C.dim));
860
- }
861
- } catch (err) {
862
- io.stopSpinner();
863
- io.print(style('[错误] ' + (err?.message || err), C.red));
864
- }
865
- } else if (arg === 'show') {
866
- const raw = loadMemory();
867
- if (!raw.trim()) {
868
- io.print('记忆库为空。');
869
- } else {
870
- io.print(style('记忆库(' + memPath + '):', C.bold));
871
- raw.split('\n').forEach((l, i) => {
872
- if (l.trim()) io.print(style(` ${String(i + 1).padStart(3)} ${l}`, C.dim));
873
- });
874
- }
875
- } else if (arg.startsWith('remove ')) {
876
- const kw = arg.slice(7).trim();
877
- if (!kw) {
878
- io.print('用法:/memory remove <关键词>(删除包含该词的条目)');
879
- continue;
880
- }
881
- const removed = removeMemoryLines(kw);
882
- io.print(removed > 0 ? style(`✓ 已删除 ${removed} 条(原文件备份于 ${memPath}.bak)`, C.green) : style('没有匹配的条目。', C.dim));
883
- } else if (arg === 'dedupe') {
884
- const removed = dedupeMemory();
885
- io.print(removed > 0 ? style(`✓ 去重完成:合并 ${removed} 条重复记忆`, C.green) : style('没有重复条目。', C.dim));
886
- } else if (arg === 'edit') {
887
- const editor = process.env.EDITOR || process.env.VISUAL;
888
- if (!editor) {
889
- io.print(style('未设置 EDITOR 环境变量。可 export EDITOR=nano(或 vim/code)后用 /memory edit 打开记忆文件。', C.yellow));
890
- continue;
891
- }
892
- const { spawnSync } = await import('node:child_process');
893
- spawnSync(editor, [memPath], { stdio: 'inherit' });
894
- io.print(style('✓ 记忆文件已编辑(后续会话自动生效)。', C.green));
895
- } else {
896
- io.print(style(`用户记忆文件:${memPath}${fs.existsSync(memPath) ? '' : '(尚不存在)'}`, C.dim));
897
- if (fs.existsSync(memPath)) io.print(style(fs.readFileSync(memPath, 'utf8').slice(0, 2000), C.dim));
898
- const journal = recentJournal(home, 5);
899
- if (journal.length) {
900
- io.print(style('最近会话:', C.bold));
901
- for (const e of journal.reverse()) io.print(style(` ${new Date(e.at).toISOString().slice(0, 10)} ${e.firstUser?.slice(0, 40)}`, C.dim));
902
- }
903
- io.print('用法:/memory add <内容> 追加 · extract 自动提炼 · show 查看 · remove <词> 删除 · dedupe 去重 · edit 编辑器修改');
904
- }
905
- } else if (cmd === '/skills') {
906
- const skills = listSkills(workingDir);
907
- if (!skills.length) {
908
- io.print(
909
- style(
910
- '未安装技能。目录:~/.mingdao/skills/(用户级)、<项目>/.mingdao/skills/(项目级)与内置技能库',
911
- C.dim
912
- )
913
- );
914
- continue;
915
- }
916
- const label = (s) => `${s.name}${s.source === 'user' ? '(用户级)' : s.source === 'builtin' ? '(内置)' : ''}`;
917
- io.box(
918
- `已安装技能(${skills.length})`,
919
- skills.map((s) => `${label(s)}:${s.description || '(无描述)'}`)
920
- );
921
- io.print(style(`技能库安装:退出会话后运行 mingdao skill search [关键词] → mingdao skill install <名称>`, C.dim));
922
- } else if (cmd === '/title') {
923
- if (!arg) {
924
- io.print('用法:/title <别名>(给当前会话命名,便于 --resume 识别)');
925
- continue;
926
- }
927
- const safe = arg.replace(/[^\w\u4e00-\u9fa5.-]/g, '_').slice(0, 40);
928
- let newFile = path.join(home, 'sessions', safe + '.jsonl');
929
- try {
930
- fs.appendFileSync(session.file, ''); // 确保文件已创建(尚未写消息时也可能重命名)
931
- // 同名会话已存在时附加随机后缀,绝不静默覆盖
932
- if (fs.existsSync(newFile)) {
933
- newFile = path.join(home, 'sessions', safe + '-' + Math.random().toString(36).slice(2, 6) + '.jsonl');
934
- }
935
- fs.renameSync(session.file, newFile);
936
- session.file = newFile;
937
- io.print(style(`✓ 会话已命名为 ${path.basename(newFile)}`, C.green));
938
- } catch (err) {
939
- io.print(style('[错误] ' + (err?.message || err), C.red));
940
- }
941
- } else if (cmd === '/audit') {
942
- const n = Number(arg) || 10;
943
- const { listAudit } = await import('./audit.js');
944
- const rows = listAudit(n);
945
- if (!rows.length) {
946
- io.print(style('暂无审计记录(工具调用会自动记录)。', C.dim));
947
- continue;
948
- }
949
- io.print(style(`最近 ${rows.length} 条工具调用审计:`, C.bold));
950
- for (const r of rows) {
951
- const when = new Date(r.at).toISOString().slice(0, 19).replace('T', ' ');
952
- const status = r.denied ? `✖拒绝(${r.reason || ''})` : r.ok ? '✓' : '✖错误';
953
- io.print(` ${when} ${status} ${r.tool} ${String(r.args || '').slice(0, 60)}`);
954
- }
955
- } else if (cmd === '/mcp') {
956
- const status = mcpFacade.status();
957
- if (status.length === 1 && status[0].name === '(连接中…)' && !status[0].ok && !mcpManager) {
958
- if (!cfg.mcpServers || !Object.keys(cfg.mcpServers).length) {
959
- io.print(style('未配置 MCP 服务器(config.json 的 mcpServers 字段)。', C.dim));
960
- } else {
961
- io.print(style('MCP 服务器连接中…(npx 首次下载依赖可能较慢)', C.dim));
962
- }
963
- continue;
964
- }
965
- io.box(
966
- `MCP 服务器(${status.length})`,
967
- status.map((s) => `${s.ok ? '✓' : '✖'} ${s.name}${s.ok ? ` · ${s.tools} 个工具` : `:${s.error}`}`)
968
- );
969
- } else if (cmd === '/sessions') {
970
- if (arg) {
971
- // 关键词全文检索历史会话
972
- const hits = searchSessions(home, arg);
973
- if (!hits.length) {
974
- io.print(style(`未找到包含「${arg}」的会话。`, C.dim));
975
- } else {
976
- io.box(`会话检索:${arg}(${hits.length} 个命中)`, hits.map((h) => `${h.name}(${relativeTime(h.mtime)})`));
977
- for (const h of hits.slice(0, 5)) io.print(style(` ${h.snippet}`, C.dim));
978
- io.print(style('恢复:mingdao --resume(或 mingdao sessions search 命令)', C.dim));
979
- }
980
- } else {
981
- const list = listSessions(home).slice(0, 10);
982
- if (!list.length) io.print('暂无历史会话。');
983
- else {
984
- io.box('历史会话(最近 10 个)', list.map((s) => `${relativeTime(s.mtime)} · ${sessionPreview(s.file)}`));
985
- io.print(style('检索:/sessions <关键词> · 恢复:mingdao --resume(文件:' + path.join(home, 'sessions') + ')', C.dim));
986
- }
987
- }
988
- } else if (cmd === '/save') {
989
- io.print(`当前会话自动保存于:${session.file}`);
990
- } else if (cmd === '/usage') {
991
- if (lastUsage) {
992
- io.print(
993
- style(
994
- `上轮用量:${lastUsage.prompt_tokens} prompt + ${lastUsage.completion_tokens} completion tokens`,
995
- C.dim
996
- )
997
- );
998
- } else io.print('尚无用量记录。');
999
- } else if (cmd === '/status') {
1000
- io.box('会话状态', [
1001
- `模型 ${modelName} · 权限 ${permission.mode}`,
1002
- `沙箱 ${cfg.sandbox || 'off'}${routing ? ` · 自动路由 ${routingEnabled ? '开' : '关'}(${routing.planner}⇄${routing.executor})` : ''}`,
1003
- `会话 ${path.basename(session.file)}`,
1004
- `轮次 ${stats.turns} · 消息 ${messages.length} 条`,
1005
- `Tokens ↑${stats.promptTokens} ↓${stats.completionTokens}`,
1006
- `费用 ≈¥${estimateCost(modelName, stats.promptTokens, stats.completionTokens).toFixed(5)}(累计·按当前模型计价)`,
1007
- `计划模式 ${planMode ? '开' : '关'} · 思考显示 ${io.showReasoning ? '开' : '关'} · 任务 ${agent.getTodos().length} 项`,
1008
- ]);
1009
- } else if (cmd === '/cost') {
1010
- const bd = costBreakdown();
1011
- io.box('费用分账(含缓存折扣与 Batch 半价的真实口径)', [
1012
- `累计 ≈¥${bd.totalCost.toFixed(5)} · 今日 ≈¥${bd.today.toFixed(5)}` +
1013
- `${bd.totalSaved > 0 ? ` · 相比全未命中已省 ≈¥${bd.totalSaved.toFixed(5)}` : ''}`,
1014
- `缓存命中率 ${bd.rate != null ? (bd.rate * 100).toFixed(0) + '%' : '暂无缓存数据'}${bd.batchCost > 0 ? ` · Batch 半价任务 ≈¥${bd.batchCost.toFixed(5)}` : ''}`,
1015
- ...bd.byModel.slice(0, 8).map((m) => ` ${m.model}:${m.turns} 轮(${m.batchTurns ? m.batchTurns + ' 批' : ''})· ↑${m.prompt} ↓${m.completion} · ≈¥${m.cost.toFixed(5)}${m.saved > 0 ? ` · 省 ¥${m.saved.toFixed(5)}` : ''}`),
1016
- ]);
1017
- const guard = costGuardStatus();
1018
- if (guard) {
1019
- io.print(
1020
- style(
1021
- `费用护栏:今日 ¥${guard.cost.toFixed(4)} / 上限 ¥${guard.limit.toFixed(2)}${guard.overLimit ? '(已达上限' + (guard.action === 'block' ? ',执行已暂停' : ',仅提醒') + ')' : ''}`,
1022
- guard.overLimit ? C.yellow : C.dim
1023
- )
1024
- );
1025
- }
1026
- io.print(style('会话内累计(本次)≈¥' + estimateCost(modelName, stats.promptTokens, stats.completionTokens).toFixed(5), C.dim));
1027
- } else if (cmd === '/cache') {
1028
- const entries = listCacheStats();
1029
- if (!entries.length) {
1030
- io.print(style('暂无缓存统计(对话若干轮后自动累积)。', C.dim));
1031
- continue;
1032
- }
1033
- const sum = summarizeCacheStats(entries);
1034
- io.box('缓存命中率仪表盘', formatCacheSummary(sum));
1035
- io.print(style('近 10 次命中率趋势:', C.dim));
1036
- const recent = entries.slice(-10);
1037
- const maxBar = 24;
1038
- for (const e of recent) {
1039
- const rate = e.hit != null && e.hit + e.miss > 0 ? e.hit / (e.hit + e.miss) : null;
1040
- const bar = rate == null ? '—'.repeat(maxBar) : '█'.repeat(Math.round(rate * maxBar));
1041
- io.print(style(` ${bar.padEnd(maxBar)} ${rate == null ? 'n/a' : (rate * 100).toFixed(0) + '%'} ${e.model}`, C.dim));
1042
- }
1043
- } else {
1044
- io.print(style('未知命令,输入 /help 查看可用命令。', C.yellow));
1045
- }
1046
- continue;
1047
- } catch (err) {
1048
- io.print(style('[错误] 命令执行失败:' + (err?.message || err), C.red));
1049
- continue;
1050
- }
1051
- }
1052
-
1053
- // 自动路由:规划类任务切 planner,执行类走 executor(会话粘滞 + 分类缓存见 routing.js)
1054
- if (routingEnabled) {
1055
- const route = await routeTask({ cfg, provider, currentModel: modelName, text: input, sticky: lastRouteModel });
1056
- lastRouteModel = route.model;
1057
- if (route.model !== modelName) {
1058
- const okSwitch = await switchToModel(route.model, { silent: true, persist: false });
1059
- if (okSwitch) io.print(style(`⤷ 自动路由 → ${route.model}(${route.reason})`, C.dim));
1060
- }
1061
- }
1062
-
1063
- // 计划模式:先出计划,确认后执行
1064
- if (planMode) {
1065
- io.startSpinner('正在生成计划…');
1066
- let plan = null;
1067
- try {
1068
- plan = await generatePlan(provider, modelName, input);
1069
- } catch (err) {
1070
- io.print(style('[计划生成失败] ' + (err?.message || err), C.red));
1071
- }
1072
- io.stopSpinner();
1073
- if (plan == null) continue;
1074
- io.print(style('── 执行计划 ──', C.bold + C.cyan));
1075
- io.print(plan);
1076
- const okGo = await io.confirm(style('是否按此计划执行?[y/N]', C.yellow));
1077
- if (!okGo) {
1078
- io.print(style('已取消执行,可修改要求后重试。', C.dim));
1079
- continue;
1080
- }
1081
- const planMsg = { role: 'assistant', content: '[执行计划]\n' + plan };
1082
- messages.push(planMsg);
1083
- appendMessages(session.file, [planMsg]);
1084
- persisted += 1;
1085
- }
1086
-
1087
- const userMsg = { role: 'user', content: input };
1088
- messages.push(userMsg);
1089
- appendMessages(session.file, [userMsg]);
1090
- persisted += 1;
1091
-
1092
- try {
1093
- const res = await agent.runTurn(messages);
1094
- lastUsage = res.usage;
1095
- lastText = res.text || lastText;
1096
- stats.turns += 1;
1097
- recordUsage(res.perf?.usedModel || modelName, res.usage, res.perf);
1098
- stats.promptTokens += res.usage.prompt_tokens || 0;
1099
- stats.completionTokens += res.usage.completion_tokens || 0;
1100
- const fresh = messages.slice(persisted);
1101
- appendMessages(session.file, fresh);
1102
- persisted = messages.length;
1103
- if (!autoTitled && cfg.autoTitle !== false && res.text) {
1104
- autoTitled = true;
1105
- const tModel = titleModel(cfg, modelName);
1106
- const title = await generateTitle(await helperProvider(cfg, tModel, provider), tModel, input);
1107
- if (title) {
1108
- const renamed = renameSessionFile(fs, path, home, session, title);
1109
- if (renamed) io.print(style(`✓ 会话标题:${path.basename(renamed)}`, C.dim));
1110
- }
1111
- }
1112
- if (res.aborted) {
1113
- io.print(style('(已中断)', C.dim));
1114
- }
1115
- if (res.truncated) {
1116
- io.print(style('[警告] 达到最大工具调用步数,任务可能未完成。', C.yellow));
1117
- }
1118
- if (res.finish === 'length') {
1119
- io.print(style('[提示] 模型输出达到长度上限被截断。', C.yellow));
1120
- }
1121
- io.printUsageLine({ modelName, usage: res.usage, durationMs: res.durationMs });
1122
- } catch (err) {
1123
- io.print(style('[错误] ' + (err?.message || err), C.red));
1124
- io.print(style('提示:可直接继续对话,或 /exit 退出。', C.dim));
1125
- }
1126
- }
1127
-
1128
- mcpFacade.stop();
1129
- if (stats.turns > 0) {
1130
- io.startSpinner('正在沉淀会话记忆…');
1131
- try {
1132
- await finalizeSession({
1133
- cfg,
1134
- provider,
1135
- model: titleModel(cfg, modelName),
1136
- home,
1137
- workingDir,
1138
- messages,
1139
- turns: stats.turns,
1140
- lastText,
1141
- });
1142
- } catch {}
1143
- io.stopSpinner();
1144
- }
1145
- try {
1146
- await maybeAutoSync();
1147
- } catch {}
1148
- io.print('再见,MingDao Harness 与你同行。');
1149
- io.close();
1150
504
  }
1151
505
 
1152
506
  main().catch((err) => {