mingdao-harness 0.1.54

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 (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +246 -0
  3. package/assets/tokenizer-data.json.gz +0 -0
  4. package/docs/ARCHITECTURE.md +108 -0
  5. package/docs/CONFIG.md +257 -0
  6. package/docs/DESKTOP-EVALUATION.md +48 -0
  7. package/docs/PROVIDERS.md +98 -0
  8. package/docs/QA-REPORT.md +333 -0
  9. package/install.bat +8 -0
  10. package/install.ps1 +57 -0
  11. package/install.sh +154 -0
  12. package/package.json +61 -0
  13. package/skills/api-design/SKILL.md +21 -0
  14. package/skills/code-review/SKILL.md +31 -0
  15. package/skills/debugging/SKILL.md +21 -0
  16. package/skills/docker/SKILL.md +27 -0
  17. package/skills/docx/SKILL.md +28 -0
  18. package/skills/frontend-design/SKILL.md +31 -0
  19. package/skills/git-commit/SKILL.md +32 -0
  20. package/skills/pdf/SKILL.md +30 -0
  21. package/skills/pptx/SKILL.md +24 -0
  22. package/skills/refactoring/SKILL.md +24 -0
  23. package/skills/release-checklist/SKILL.md +20 -0
  24. package/skills/testing/SKILL.md +30 -0
  25. package/skills/webapp-testing/SKILL.md +27 -0
  26. package/skills/xlsx/SKILL.md +28 -0
  27. package/src/agent.js +472 -0
  28. package/src/audit.js +68 -0
  29. package/src/autostart.js +74 -0
  30. package/src/batch.js +182 -0
  31. package/src/cachestats.js +215 -0
  32. package/src/cli.js +1099 -0
  33. package/src/commands/key.js +75 -0
  34. package/src/commands/schedule.js +176 -0
  35. package/src/commands/skill.js +168 -0
  36. package/src/commands/sync.js +222 -0
  37. package/src/commands/update.js +157 -0
  38. package/src/commands/workspace.js +109 -0
  39. package/src/compact.js +112 -0
  40. package/src/config.js +134 -0
  41. package/src/context.js +86 -0
  42. package/src/cost-guard.js +58 -0
  43. package/src/credentials.js +69 -0
  44. package/src/hooks.js +123 -0
  45. package/src/index.js +42 -0
  46. package/src/mcp-presets.js +78 -0
  47. package/src/mcp.js +284 -0
  48. package/src/memory.js +242 -0
  49. package/src/model-discovery.js +153 -0
  50. package/src/models.js +186 -0
  51. package/src/notify.js +38 -0
  52. package/src/permissions.js +83 -0
  53. package/src/pricing.js +156 -0
  54. package/src/prompts.js +58 -0
  55. package/src/providers/index.js +135 -0
  56. package/src/providers/openai-compatible.js +171 -0
  57. package/src/routing.js +126 -0
  58. package/src/schedule.js +434 -0
  59. package/src/session-index.js +122 -0
  60. package/src/session.js +131 -0
  61. package/src/skill-lib.js +350 -0
  62. package/src/skill-registry.js +165 -0
  63. package/src/skills.js +135 -0
  64. package/src/sync-server.js +564 -0
  65. package/src/sync.js +479 -0
  66. package/src/tasks.js +126 -0
  67. package/src/titles.js +75 -0
  68. package/src/tokenizer.js +287 -0
  69. package/src/tools/bash.js +165 -0
  70. package/src/tools/fs-tools.js +346 -0
  71. package/src/tools/index.js +287 -0
  72. package/src/ui.js +643 -0
  73. package/src/update.js +222 -0
  74. package/src/web/attachments.js +49 -0
  75. package/src/web/index.html +993 -0
  76. package/src/web/server.js +1173 -0
  77. package/src/web/web-io.js +107 -0
  78. package/src/workspace.js +157 -0
package/src/cli.js ADDED
@@ -0,0 +1,1099 @@
1
+ #!/usr/bin/env node
2
+ // MingDao(明道)CLI 入口:初始化向导、凭证管理、单次提问、交互式 TUI 会话。
3
+ // 会话能力:/plan 计划模式、/compact 上下文压缩、/init、/memory、/skills、
4
+ // /mode 模型快捷切换、/verbose 思考开关、/status、/cost、会话选择恢复。
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import readline from 'node:readline';
9
+ import { loadConfig, saveConfig, runWizard, ensureHome, mingdaoHome } from './config.js';
10
+ import { modelPreset, PROVIDERS } from './models.js';
11
+ import {
12
+ loadCredentials,
13
+ setStoredKey,
14
+ removeStoredKey,
15
+ maskKey,
16
+ credentialsPath,
17
+ getStoredKey,
18
+ } from './credentials.js';
19
+ import { createProvider, resolveProviderConfig, helperProvider } from './providers/index.js';
20
+ import { startMcpServers } from './mcp.js';
21
+ import { startTask, listTasks, patchTask, killTask, formatTaskRow } from './tasks.js';
22
+ import { enableAutostart, disableAutostart, autostartStatus, autostartPath } from './autostart.js';
23
+ import { notifyTaskDone } from './notify.js';
24
+ import { addWorkspace, removeWorkspace, workspacePath, touchWorkspace, listWorkspaces, currentWorkspace } from './workspace.js';
25
+ import { finalizeSession, extractMemory, loadMemory, appendMemory, recentJournal, dedupeMemory, removeMemoryLines } from './memory.js';
26
+ import { recordUsage, listCacheStats, summarizeCacheStats, formatCacheSummary } from './cachestats.js';
27
+ import { presetList, buildPreset } from './mcp-presets.js';
28
+ import {
29
+ addSchedule,
30
+ listSchedules,
31
+ readSchedule,
32
+ removeSchedule,
33
+ pauseSchedule,
34
+ resumeSchedule,
35
+ chainSchedules,
36
+ reconcileSchedules,
37
+ runSleeper,
38
+ formatScheduleRow,
39
+ } from './schedule.js';
40
+ import { createAgent } from './agent.js';
41
+ import { createPermission } from './permissions.js';
42
+ import { buildSystemPrompt } from './prompts.js';
43
+ import { listSkills, tamperedSkillNames } from './skills.js';
44
+ import { costGuardStatus } from './cost-guard.js';
45
+ import { libraryList, searchLibrary, installSkill, uninstallSkill, reinstallSkill, trustSkill } from './skill-lib.js';
46
+ import { syncLogin, syncLogout, syncPush, syncPull, syncStatus, syncRemoteList, maybeAutoSync, syncChangePassword, syncShareCreate, syncShareList, syncShareAccept, syncShareRevoke, listSyncConflicts, resolveSyncConflict } from './sync.js';
47
+ import { runWebServer } from './web/server.js';
48
+ import { routeTask, routingConfig } from './routing.js';
49
+ import { detectSandbox } from './tools/bash.js';
50
+ import { generateTitle, renameSessionFile, titleModel } from './titles.js';
51
+ import { estimateCost, isPeakHour, deferToOffpeak } from './pricing.js';
52
+ import { createIO, style, C } from './ui.js';
53
+ import {
54
+ createSession,
55
+ latestSession,
56
+ appendMessages,
57
+ rewriteSession,
58
+ loadSession,
59
+ listSessions,
60
+ sessionPreview,
61
+ relativeTime,
62
+ searchSessions,
63
+ } from './session.js';
64
+
65
+ const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
66
+
67
+ const HELP_LINES = [
68
+ [`MingDao 明道 · AI 智能体框架 v${pkg.version}(命令:mingdao,简写 mdh)`, C.bold + C.cyan],
69
+ ['', null],
70
+ ['用法', C.bold + C.yellow],
71
+ [' mingdao 交互式对话(TUI)', null],
72
+ [' mingdao "你的问题" 单次提问(适合脚本与管道)', null],
73
+ [' mingdao --format json "…" 单次提问,输出结构化 JSON', null],
74
+ [' mingdao web [端口] 启动 WebUI(默认 http://127.0.0.1:3820)', null],
75
+ [' mingdao sessions search <词> 全文检索历史会话', null],
76
+ [' mingdao run "<任务>" 后台启动任务(tasks 面板管理)', null],
77
+ [' mingdao tasks [watch|kill <id>] 查看/实时刷新/停止后台任务', null],
78
+ [' mingdao schedule add/list/remove/pause/resume/chain 定时任务与依赖编排', null],
79
+ [' mingdao autostart on|off 开机自启(登录后自动启动服务器)', null],
80
+ [' mingdao workspace add/list/use/path/remove 工作空间(项目目录登记与快速切换)', null],
81
+ [' mingdao --continue 继续最近一次会话', null],
82
+ [' mingdao --journal 新会话带上最近会话日志(默认不注入,新会话全新开始)', null],
83
+ [' mingdao --resume 从会话列表选择恢复', null],
84
+ [' mingdao --model <模型名> 指定模型,例如 deepseek-v4-pro', null],
85
+ [' mingdao init 初始化配置向导', null],
86
+ [' mingdao update [--check] 一键自更新(git 安装形态;--check 只对比版本)', null],
87
+ [' mingdao rollback 回滚到上次 update 之前的提交', null],
88
+ [' mingdao audit [数量] 查看工具调用审计日志(默认最近 20 条)', null],
89
+ [' mingdao --help / --version 帮助 / 版本', null],
90
+ ['', null],
91
+ ['凭证管理(API Key 独立存储,绝不写入 config.json / 仓库)', C.bold + C.yellow],
92
+ [' mingdao key 查看凭证状态(脱敏显示)', null],
93
+ [' mingdao key set <服务商> 交互式保存 API Key(隐藏输入)', null],
94
+ [' mingdao key remove <服务商> 删除凭证', null],
95
+ [' mingdao key import 从环境变量导入所有可用 Key', null],
96
+ ['', null],
97
+ ['云同步与技能库(跨设备会话同步 / 技能安装)', C.bold + C.yellow],
98
+ [' mingdao sync login <用户名> [密码] <服务器地址> 登录云同步(自动注册)', null],
99
+ [' mingdao sync push|pull|status|logout 推送 / 拉取 / 状态 / 退出', null],
100
+ [' mingdao sync-server [端口] 自建云同步服务器(数据目录 /var/lib/mingdao-sync)', null],
101
+ [' mingdao skill search|install|uninstall|update <名称> 技能库(内置 + 线上 registry)', null],
102
+ ['', null],
103
+ ['会话内命令', C.bold + C.yellow],
104
+ [' /help 显示帮助 /clear 清空上下文', null],
105
+ [' /model <名> 切换模型 /mode pro/flash 快捷切换', null],
106
+ [' /compact 压缩上下文 /plan 计划模式(先计划后执行)', null],
107
+ [' /init 生成 AGENTS.md /memory add <内容> 追加用户记忆', null],
108
+ [' /skills 列出技能 /status 会话状态 · /cost 累计费用', null],
109
+ [' /sessions 历史会话/检索 /route on|off 自动路由开关', null],
110
+ [' /mcp MCP 服务器状态 /verbose 思考开关 · /title <别名> 会话命名', null],
111
+ [' /usage 上轮用量 /exit 退出 · /save 会话文件', null],
112
+ [' /exit 退出 Tab 补全命令 · Ctrl+C 中断生成', null],
113
+ ['', null],
114
+ [`配置目录: ${mingdaoHome()}`, C.dim],
115
+ ];
116
+
117
+ function printHelpLines(out) {
118
+ for (const [text, code] of HELP_LINES) out(code ? style(text, code) : text);
119
+ }
120
+
121
+ function parseArgs(argv) {
122
+ const opts = { prompt: [], model: null, continueSession: false, resume: false, format: 'text' };
123
+ for (let i = 0; i < argv.length; i++) {
124
+ const a = argv[i];
125
+ if (a === '-h' || a === '--help') opts.help = true;
126
+ else if (a === '-v' || a === '--version') opts.version = true;
127
+ else if (a === '-c' || a === '--continue') opts.continueSession = true;
128
+ else if (a === '--journal') opts.journal = true;
129
+ else if (a === '-r' || a === '--resume') opts.resume = true;
130
+ else if (a === '--init' || a === 'init') opts.init = true;
131
+ else if (a === '-m' || a === '--model') opts.model = argv[++i];
132
+ else if (a.startsWith('--model=')) opts.model = a.slice(8);
133
+ else if (a === '-f' || a === '--format') opts.format = argv[++i] || 'text';
134
+ else if (a.startsWith('--format=')) opts.format = a.slice(9);
135
+ else opts.prompt.push(a);
136
+ }
137
+ if (!['text', 'json'].includes(opts.format)) opts.format = 'text';
138
+ return opts;
139
+ }
140
+
141
+ async function generatePlan(provider, modelName, task) {
142
+ const res = await provider.chat({
143
+ model: modelName,
144
+ messages: [
145
+ {
146
+ role: 'system',
147
+ content:
148
+ '你是规划专家。为任务输出可执行计划:步骤列表(注明每步将使用的工具)、涉及的文件、风险点。不要执行任何工具、不要写代码。≤400 字,中文。',
149
+ },
150
+ { role: 'user', content: task },
151
+ ],
152
+ tools: [],
153
+ temperature: 0.3,
154
+ maxTokens: 2048,
155
+ });
156
+ return res.text || null;
157
+ }
158
+
159
+ async function compactContext(provider, modelName, messages) {
160
+ const mid = messages.slice(1, -2);
161
+ const res = await provider.chat({
162
+ model: modelName,
163
+ messages: [
164
+ {
165
+ role: 'system',
166
+ content:
167
+ '你是上下文压缩器。把以下对话历史压缩成要点摘要(中文 ≤600 字):保留未完成任务、关键决策、文件改动、用户偏好;省略已完成的中间过程。',
168
+ },
169
+ {
170
+ role: 'user',
171
+ content: mid
172
+ .map((m) => `${m.role}: ${m.content ?? JSON.stringify(m.tool_calls ?? '')}`)
173
+ .join('\n'),
174
+ },
175
+ ],
176
+ tools: [],
177
+ temperature: 0.2,
178
+ maxTokens: 2048,
179
+ });
180
+ return res.text || '';
181
+ }
182
+
183
+ // —— 后台任务 worker:独立进程执行一轮任务并写状态文件 ——
184
+ async function runWorkerTask(id, question, { permission, model, offpeak }) {
185
+ const home = ensureHome();
186
+ const finish = (patch) => patchTask(home, id, patch);
187
+ let mcpFacade = null;
188
+ let cfg = null; // 提到 try 外:catch 分支也要读 cfg.notify
189
+ try {
190
+ cfg = loadConfig();
191
+ if (!cfg) throw new Error('未初始化配置,请先运行 mingdao init');
192
+ const modelName = model || cfg.model || 'deepseek-v4-flash';
193
+ const pc = resolveProviderConfig(cfg, modelName);
194
+ if (!pc.apiKey) throw new Error(`模型 ${modelName} 没有可用 API Key`);
195
+ const workingDir = process.cwd();
196
+ // 后台无交互:ask 权限降级为 readonly 并注明(需要写权限请 mingdao run --permission auto)
197
+ let perm = permission || cfg.permission || 'ask';
198
+ let note = '';
199
+ if (perm === 'ask') {
200
+ perm = 'readonly';
201
+ note = 'ask 权限下后台任务按只读执行';
202
+ }
203
+ // 避峰(评估 A2/Kimi P-1):高峰时段(北京工作日 9:00–14:00)自动顺延到 14:00 执行,输入价省 50%
204
+ if (offpeak && isPeakHour(new Date())) {
205
+ const defer = deferToOffpeak(new Date());
206
+ finish({ note: `避峰等待至 ${defer.toISOString().slice(11, 16)}(北京时间 14:00 后)` });
207
+ await new Promise((r) => setTimeout(r, defer.getTime() - Date.now() + 2000));
208
+ finish({ note: '' });
209
+ }
210
+ const provider = await createProvider(cfg, modelName);
211
+ const io = createIO({ quiet: true });
212
+ const permissionObj = createPermission(perm, io);
213
+ let mcpManager = null;
214
+ if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
215
+ mcpFacade = {
216
+ toolSchemas: () => (mcpManager ? mcpManager.toolSchemas() : []),
217
+ call: (n, a) => (mcpManager ? mcpManager.call(n, a) : Promise.reject(new Error('MCP 未就绪'))),
218
+ isReadonly: (n) => (mcpManager ? mcpManager.isReadonly(n) : false),
219
+ stop: () => {
220
+ if (mcpManager) mcpManager.stop();
221
+ },
222
+ };
223
+ startMcpServers(cfg.mcpServers, workingDir).then((m) => (mcpManager = m)).catch(() => {});
224
+ }
225
+ const sessionRef = { name: null };
226
+ const agent = createAgent({
227
+ provider,
228
+ permission: permissionObj,
229
+ io,
230
+ modelName,
231
+ workingDir,
232
+ cfg,
233
+ mcp: mcpFacade || undefined,
234
+ sessionRef,
235
+ onCompact: (msgs) => {
236
+ rewriteSession(session.file, msgs);
237
+ persistedCount = msgs.length;
238
+ },
239
+ });
240
+ const session = createSession(home);
241
+ sessionRef.name = path.basename(session.file);
242
+ const messages = [
243
+ { role: 'system', content: buildSystemPrompt({ modelName, workingDir }) },
244
+ { role: 'user', content: question },
245
+ ];
246
+ appendMessages(session.file, messages);
247
+ let persistedCount = messages.length;
248
+ const t0 = Date.now();
249
+ const res = await agent.runTurn(messages);
250
+ appendMessages(session.file, messages.slice(persistedCount));
251
+ if (cfg.autoTitle !== false && res.text) {
252
+ try {
253
+ const tModel = titleModel(cfg, modelName);
254
+ const title = await generateTitle(await helperProvider(cfg, tModel, provider), tModel, question);
255
+ if (title) renameSessionFile(fs, path, home, session, title);
256
+ } catch {}
257
+ }
258
+ const finalStatus = res.truncated ? 'failed' : res.aborted ? 'killed' : 'done';
259
+ recordUsage(modelName, res.usage, res.perf);
260
+ finish({
261
+ status: finalStatus,
262
+ text: (res.text || '').slice(0, 2000),
263
+ usage: res.usage,
264
+ durationMs: Date.now() - t0,
265
+ session: path.basename(session.file),
266
+ note,
267
+ });
268
+ if (cfg.notify !== false) notifyTaskDone(question, finalStatus === 'killed' ? 'failed' : finalStatus);
269
+ try {
270
+ await maybeAutoSync();
271
+ } catch {}
272
+ process.exitCode = res.truncated ? 1 : 0;
273
+ } catch (err) {
274
+ finish({ status: 'failed', error: String(err?.message || err) });
275
+ if (cfg?.notify !== false) notifyTaskDone(question, 'failed');
276
+ process.exitCode = 2;
277
+ } finally {
278
+ if (mcpFacade) mcpFacade.stop();
279
+ }
280
+ }
281
+
282
+ async function main() {
283
+ const opts = parseArgs(process.argv.slice(2));
284
+ if (opts.help) {
285
+ printHelpLines(console.log);
286
+ return;
287
+ }
288
+ if (opts.version) {
289
+ console.log('mingdao ' + pkg.version);
290
+ return;
291
+ }
292
+ // 最近会话日志默认不注入(新会话全新开始);--journal 显式带上
293
+ const withJournal = Boolean(opts.journal);
294
+
295
+ // —— 命令分发(已拆至 src/commands/,评估 P0-1 拆 cli.js)——
296
+ // 各 handler 返回 true = 已处理;false = 按普通提问继续(保留词劫持防护)
297
+ {
298
+ const dispatchTable = [
299
+ ['update', 'update'], ['rollback', 'update'], ['batch', 'update'], ['cost', 'update'], ['audit', 'update'],
300
+ ['tasks', 'schedule'], ['schedule', 'schedule'],
301
+ ['workspace', 'workspace'], ['mcp', 'workspace'],
302
+ ['sync', 'sync'],
303
+ ['skill', 'skill'], ['web', 'skill'], ['sessions', 'skill'],
304
+ ['key', 'key'],
305
+ ];
306
+ const hit = dispatchTable.find(([name]) => name === opts.prompt[0]);
307
+ if (hit) {
308
+ const mod = await import(`./commands/${hit[1]}.js`);
309
+ const fn = mod[hit[1] === 'update' ? 'handleUpdateFamily' : hit[1] === 'schedule' ? (opts.prompt[0] === 'tasks' ? 'handleTasks' : 'handleSchedule') : hit[1] === 'workspace' ? (opts.prompt[0] === 'mcp' ? 'handleMcp' : 'handleWorkspace') : hit[1] === 'skill' ? (opts.prompt[0] === 'skill' ? 'handleSkill' : opts.prompt[0] === 'web' ? 'handleWeb' : 'handleSessions') : 'handle' + hit[1][0].toUpperCase() + hit[1].slice(1)];
310
+ const handled = await fn(opts.prompt[0], opts.prompt.slice(1));
311
+ if (handled) return;
312
+ }
313
+ }
314
+
315
+ // 后台任务 worker(内部入口,由 mingdao run 启动)
316
+ if (opts.prompt[0] === 'run-worker') {
317
+ const id = opts.prompt[1];
318
+ let question = '';
319
+ let permission = null;
320
+ let model = null;
321
+ let offpeak = false;
322
+ for (let i = 2; i < opts.prompt.length; i++) {
323
+ const a = opts.prompt[i];
324
+ if (a === '--permission') permission = opts.prompt[++i];
325
+ else if (a === '--model') model = opts.prompt[++i];
326
+ else if (a === '--offpeak') offpeak = true;
327
+ else if (a === '--question') {
328
+ // 收集到下一个已知 flag 为止(参数顺序:--question <任务> --permission … --model …)
329
+ const parts = [];
330
+ while (i + 1 < opts.prompt.length && !['--permission', '--model', '--offpeak'].includes(opts.prompt[i + 1])) {
331
+ parts.push(opts.prompt[++i]);
332
+ }
333
+ question = parts.join(' ');
334
+ }
335
+ }
336
+ await runWorkerTask(id, question, { permission, model, offpeak });
337
+ return;
338
+ }
339
+
340
+ // 后台任务启动:mingdao run "<任务>" [--permission auto] [--model x]
341
+ if (opts.prompt[0] === 'run') {
342
+ const rest = opts.prompt.slice(1);
343
+ let question = '';
344
+ let permission = null;
345
+ let model = null;
346
+ let offpeak = false;
347
+ for (let i = 0; i < rest.length; i++) {
348
+ const a = rest[i];
349
+ if (a === '--permission') permission = rest[++i];
350
+ else if (a === '--model') model = rest[++i];
351
+ else if (a === '--offpeak') offpeak = true;
352
+ else if (question === '') question = a;
353
+ }
354
+ if (!question) {
355
+ console.log('用法:mingdao run "<任务>" [--permission auto|readonly] [--model 模型名] [--offpeak]');
356
+ process.exitCode = 1;
357
+ return;
358
+ }
359
+ const home0 = ensureHome();
360
+ reconcileSchedules(home0);
361
+ const task = startTask(home0, question, { permission, model, cwd: process.cwd(), offpeak });
362
+ console.log(`✓ 后台任务已启动 ${task.id}`);
363
+ console.log(` 查看:mingdao tasks · 实时刷新:mingdao tasks watch · 停止:mingdao tasks kill ${task.id}`);
364
+ return;
365
+ }
366
+
367
+ // 任务面板:mingdao tasks [watch|kill <id>]
368
+ // 调度器 worker(内部入口,由 schedule 系统启动的 sleeper 进程)
369
+ if (opts.prompt[0] === 'schedule-worker') {
370
+ const home0 = ensureHome();
371
+ await runSleeper(home0, opts.prompt[1]);
372
+ return;
373
+ }
374
+
375
+ // 单守护进程调度器(评估 P3-5):一进程监督全部调度任务(协程复用 runSleeper),无任务自动退出
376
+ if (opts.prompt[0] === 'schedule-daemon') {
377
+ const home0 = ensureHome();
378
+ const { listSchedules, runSleeper, sleeperAlive, daemonPidFile } = await import('./schedule.js');
379
+ const { readTask } = await import('./tasks.js');
380
+ const handled = new Set();
381
+ try {
382
+ for (;;) {
383
+ const jobs = listSchedules(home0);
384
+ if (!jobs.some((j) => j.status === 'pending' || j.status === 'running')) break;
385
+ for (const j of jobs) {
386
+ if (j.status !== 'pending' || handled.has(j.id)) continue;
387
+ if (sleeperAlive(j.pid)) continue; // 旧式 sleeper 仍在:交回给它,避免双跑
388
+ if (j.lastTaskId) {
389
+ const t = readTask(home0, j.lastTaskId);
390
+ if (t && t.status === 'running') continue; // worker 仍在跑(异常窗口),不重拉
391
+ }
392
+ handled.add(j.id);
393
+ runSleeper(home0, j.id)
394
+ .catch(() => {})
395
+ .finally(() => handled.delete(j.id));
396
+ }
397
+ await new Promise((r) => setTimeout(r, 2000));
398
+ }
399
+ } finally {
400
+ try {
401
+ fs.rmSync(daemonPidFile(home0), { force: true });
402
+ } catch {}
403
+ }
404
+ return;
405
+ }
406
+
407
+ // 任务队列与调度:mingdao schedule add/list/remove/pause/resume/chain
408
+ // 开机自启:mingdao autostart on|off|status
409
+ if (opts.prompt[0] === 'autostart') {
410
+ const sub = opts.prompt[1] || 'status';
411
+ if (sub === 'on') {
412
+ console.log(enableAutostart() ? '✓ 已开启开机自启(登录后自动启动 mingdao web)' : '[错误] 设置失败');
413
+ } else if (sub === 'off') {
414
+ console.log(disableAutostart() ? '✓ 已关闭开机自启' : '[错误] 关闭失败');
415
+ } else {
416
+ console.log(autostartStatus() ? `✓ 开机自启已开启(${autostartPath()})` : '✗ 未开启(mingdao autostart on 开启)');
417
+ }
418
+ return;
419
+ }
420
+
421
+ // 工作空间:mingdao workspace add|list|use|path|remove
422
+ // 云同步:mingdao sync login|logout|push|pull|status · 自建服务端 mingdao sync-server [端口]
423
+ if (opts.prompt[0] === 'sync-server') {
424
+ const { runSyncServer } = await import('./sync-server.js');
425
+ await runSyncServer({ port: Number(opts.prompt[1]) || undefined });
426
+ return;
427
+ }
428
+ const home = ensureHome();
429
+ let cfg = loadConfig();
430
+ if (!cfg || opts.init) {
431
+ const wio = createIO();
432
+ cfg = await runWizard(wio);
433
+ wio.close();
434
+ if (opts.init) return;
435
+ }
436
+
437
+ let modelName = opts.model || cfg.model || 'deepseek-v4-flash';
438
+ const io = createIO();
439
+
440
+ const pc0 = resolveProviderConfig(cfg, modelName);
441
+ if (!pc0.apiKey) {
442
+ io.print(
443
+ style(
444
+ `未找到 API Key。\n` +
445
+ ` · 运行 ${C.bold}mingdao key set ${pc0.name}${C.reset} 保存密钥,或\n` +
446
+ ` · 运行 ${C.bold}mingdao init${C.reset} 重新配置,或\n` +
447
+ ` · 设置环境变量 ${C.bold}MINGDAO_API_KEY${C.reset}(或 ${pc0.envHint})。`,
448
+ C.red
449
+ )
450
+ );
451
+ process.exitCode = 1;
452
+ return;
453
+ }
454
+
455
+ let provider = await createProvider(cfg, modelName);
456
+ const permission = createPermission(cfg.permission ?? 'ask', io);
457
+ const workingDir = process.cwd();
458
+ // 会话级 undo 备份仓:模型切换、子代理均共享,撤销记录不丢失
459
+ const sessionUndoStore = { backups: new Map() };
460
+ // MCP 服务器:后台启动(不阻塞交互),就绪后工具自动出现在后续轮次
461
+ let mcpManager = null;
462
+ const mcpFacade = {
463
+ toolSchemas: () => (mcpManager ? mcpManager.toolSchemas() : []),
464
+ call: (n, a) => (mcpManager ? mcpManager.call(n, a) : Promise.reject(new Error('MCP 未就绪'))),
465
+ isReadonly: (n) => (mcpManager ? mcpManager.isReadonly(n) : false),
466
+ status: () => (mcpManager ? mcpManager.status() : [{ name: '(连接中…)', ok: false, tools: 0, error: '' }]),
467
+ stop: () => {
468
+ if (mcpManager) mcpManager.stop();
469
+ },
470
+ };
471
+ if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
472
+ startMcpServers(cfg.mcpServers, workingDir)
473
+ .then((mgr) => {
474
+ mcpManager = mgr;
475
+ const ready = mgr.status().filter((s) => s.ok).length;
476
+ if (io && !opts.prompt.length) {
477
+ io.print(style(`✓ MCP 就绪:${ready}/${mgr.status().length} 个服务器,共 ${mgr.toolSchemas().length} 个工具`, C.dim));
478
+ }
479
+ })
480
+ .catch(() => {});
481
+ }
482
+ const sessionRef = { name: null }; // 会话名在下方 REPL 初始化中回填(审计归因用)
483
+ let agent = createAgent({
484
+ provider,
485
+ permission,
486
+ io,
487
+ modelName,
488
+ workingDir,
489
+ cfg,
490
+ undoStore: sessionUndoStore,
491
+ mcp: mcpFacade,
492
+ sessionRef,
493
+ // 自动压缩后重写会话文件 + 同步落盘游标(session/persisted 在下方 REPL 初始化中声明)
494
+ onCompact: (msgs) => {
495
+ rewriteSession(session.file, msgs);
496
+ persisted = msgs.length;
497
+ },
498
+ });
499
+ const preset = modelPreset(modelName);
500
+
501
+ // —— 单次提问模式 ——
502
+ if (opts.prompt.length > 0) {
503
+ const jsonMode = opts.format === 'json';
504
+ const question = opts.prompt.join(' ');
505
+ // 自动路由:规划类任务切 planner,执行类走 executor(JSON 模式静默)
506
+ const route = await routeTask({ cfg, provider, currentModel: modelName, text: question });
507
+ if (route.model !== modelName) {
508
+ if (!jsonMode) io.print(style(`⤷ 自动路由 → ${route.model}(${route.reason})`, C.dim));
509
+ modelName = route.model;
510
+ }
511
+ // 审计 P1-2:自动路由改换模型后必须重建对应 provider(此前用旧模型的 baseUrl/key 发请求)
512
+ if (route.model !== modelName) provider = await createProvider(cfg, modelName);
513
+ // JSON 模式:关闭流式输出,结果以单行 JSON 输出(脚本/管道友好)
514
+ const turnIo = jsonMode ? createIO({ quiet: true }) : io;
515
+ const session = createSession(home);
516
+ const messages = [
517
+ { role: 'system', content: buildSystemPrompt({ modelName, workingDir, withJournal }) },
518
+ { role: 'user', content: question },
519
+ ];
520
+ let oneShotPersisted = messages.length;
521
+ const oneShotRef = { name: path.basename(session.file) };
522
+ const turnAgent = createAgent({
523
+ provider,
524
+ permission,
525
+ io: turnIo,
526
+ modelName,
527
+ workingDir,
528
+ cfg,
529
+ undoStore: sessionUndoStore,
530
+ mcp: mcpFacade,
531
+ sessionRef: oneShotRef,
532
+ onCompact: (msgs) => {
533
+ rewriteSession(session.file, msgs);
534
+ oneShotPersisted = msgs.length;
535
+ },
536
+ });
537
+ appendMessages(session.file, messages);
538
+ try {
539
+ const res = await turnAgent.runTurn(messages);
540
+ appendMessages(session.file, messages.slice(oneShotPersisted));
541
+ if (!jsonMode && cfg.autoTitle !== false && res.text) {
542
+ const tModel = titleModel(cfg, modelName);
543
+ const title = await generateTitle(await helperProvider(cfg, tModel, provider), tModel, question);
544
+ if (title) {
545
+ const renamed = renameSessionFile(fs, path, home, session, title);
546
+ if (renamed) io.print(style(`✓ 会话标题:${path.basename(renamed)}`, C.dim));
547
+ }
548
+ }
549
+ try {
550
+ await finalizeSession({ cfg, provider, model: titleModel(cfg, modelName), home, workingDir, messages, turns: 1, lastText: res.text || '' });
551
+ } catch {}
552
+ try {
553
+ await maybeAutoSync();
554
+ } catch {}
555
+ if (jsonMode) {
556
+ console.log(
557
+ JSON.stringify({
558
+ ok: true,
559
+ text: res.text,
560
+ reasoning: res.reasoning || null,
561
+ usage: res.usage,
562
+ durationMs: res.durationMs,
563
+ steps: res.steps,
564
+ finish: res.finish,
565
+ aborted: res.aborted,
566
+ truncated: res.truncated,
567
+ session: session.name,
568
+ })
569
+ );
570
+ recordUsage(modelName, res.usage, res.perf);
571
+ process.exitCode = res.truncated || res.aborted ? 1 : 0;
572
+ } else {
573
+ io.printUsageLine({ modelName, usage: res.usage, durationMs: res.durationMs });
574
+ if (res.aborted) io.print(style('(已中断)', C.dim));
575
+ recordUsage(modelName, res.usage, res.perf);
576
+ process.exitCode = res.truncated ? 1 : 0;
577
+ }
578
+ } catch (err) {
579
+ if (jsonMode) {
580
+ console.log(JSON.stringify({ ok: false, error: String(err?.message || err) }));
581
+ } else {
582
+ io.print(style('\n[错误] ' + (err?.message || err), C.red));
583
+ }
584
+ process.exitCode = 2;
585
+ }
586
+ mcpFacade.stop();
587
+ io.close();
588
+ return;
589
+ }
590
+
591
+ // —— 交互式 TUI ——
592
+ const storedKey = getStoredKey(pc0.name);
593
+ const envKeyDetected = (pc0.envHint && process.env[pc0.envHint]) || process.env.MINGDAO_API_KEY;
594
+ const keySource = envKeyDetected ? '环境变量' : storedKey ? `凭证库 ${maskKey(storedKey)}` : 'config 字段';
595
+ const sandboxMode = cfg.sandbox || 'off';
596
+ const sandboxLabel =
597
+ sandboxMode === 'off' ? 'off' : detectSandbox() === 'bwrap' ? sandboxMode : `${sandboxMode}(bwrap 缺失,已降级)`;
598
+ const routing = routingConfig(cfg);
599
+ const wsNow = currentWorkspace(workingDir);
600
+ io.print('');
601
+ io.box(`MingDao 明道 v${pkg.version}`, [
602
+ `模型 ${modelName}${preset?.label ? '(' + preset.label + ')' : ''}`,
603
+ `权限 ${permission.mode} · 密钥 ${keySource}`,
604
+ `沙箱 ${sandboxLabel}${routing ? ` · 路由 ${routing.planner}⇄${routing.executor}` : ''}`,
605
+ wsNow ? `工作空间 ${wsNow.name}(${wsNow.dir})` : '',
606
+ ].filter(Boolean));
607
+ io.print(style('输入问题开始对话 · /help 查看命令 · Tab 补全 · Ctrl+C 中断生成\n', C.dim));
608
+
609
+ let session = null;
610
+ if (opts.resume) {
611
+ const list = listSessions(home).slice(0, 10);
612
+ if (!list.length) {
613
+ io.print(style('没有可恢复的历史会话,已新建。', C.dim));
614
+ } else {
615
+ const choice = await io.choose(
616
+ '选择要恢复的会话:',
617
+ list.map((s) => ({ value: s.file, label: `${relativeTime(s.mtime)} · ${sessionPreview(s.file)}` }))
618
+ );
619
+ const loaded = loadSession(choice);
620
+ if (loaded.messages.length) {
621
+ session = loaded;
622
+ io.print(style(`✓ 已载入会话 ${path.basename(choice)}(${loaded.messages.length} 条消息)`, C.green));
623
+ }
624
+ }
625
+ }
626
+ if (!session && opts.continueSession) {
627
+ const latest = latestSession(home);
628
+ if (latest) {
629
+ const loaded = loadSession(latest.file);
630
+ if (loaded.messages.length) {
631
+ session = loaded;
632
+ io.print(style(`已载入会话 ${latest.name}(${loaded.messages.length} 条消息)`, C.dim));
633
+ }
634
+ }
635
+ if (!session) io.print('没有可继续的历史会话,已新建。');
636
+ }
637
+ if (!session) session = createSession(home);
638
+ sessionRef.name = path.basename(session.file);
639
+ io.print(style(`会话 ${path.basename(session.file)}`, C.dim));
640
+
641
+ const systemPrompt = buildSystemPrompt({ modelName, workingDir, withJournal });
642
+ // 恢复会话时刷新 system prompt(用户记忆 / AGENTS.md / 技能清单 / 时间戳以当前为准),
643
+ // 旧 system 消息保留在会话文件中,不影响追加历史。
644
+ const loadedMsgs = session.messages || [];
645
+ const hasOldSystem = loadedMsgs[0]?.role === 'system';
646
+ let messages = hasOldSystem
647
+ ? [{ role: 'system', content: systemPrompt }, ...loadedMsgs.slice(1)]
648
+ : [{ role: 'system', content: systemPrompt }, ...loadedMsgs];
649
+ let persisted = messages.length;
650
+ let lastUsage = null;
651
+ let lastText = '';
652
+ let planMode = false;
653
+ let routingEnabled = Boolean(routing);
654
+ let lastRouteModel = null; // 会话级路由粘滞(评估 P2-1:执行类会话不再逐轮分类)
655
+ let autoTitled = Boolean(session.messages?.length);
656
+ const stats = { turns: 0, promptTokens: 0, completionTokens: 0 };
657
+ io.setHistory(messages.filter((m) => m.role === 'user').map((m) => m.content));
658
+
659
+ async function switchToModel(target, { silent = false, persist = true } = {}) {
660
+ try {
661
+ const npc = resolveProviderConfig(cfg, target);
662
+ if (!npc.apiKey) {
663
+ io.print(style('该模型没有可用的 API Key,请先运行 mingdao init 配置。', C.red));
664
+ return false;
665
+ }
666
+ const newProvider = await createProvider(cfg, target);
667
+ provider = newProvider;
668
+ modelName = target;
669
+ // 自动路由的切换只改会话内存态(评估 P3-6:不悄悄改写用户持久默认模型);/model 显式切换才落盘
670
+ if (persist) {
671
+ cfg.model = target;
672
+ saveConfig(cfg);
673
+ }
674
+ agent = createAgent({
675
+ provider,
676
+ permission,
677
+ io,
678
+ modelName,
679
+ workingDir,
680
+ cfg,
681
+ undoStore: sessionUndoStore,
682
+ mcp: mcpFacade,
683
+ sessionRef,
684
+ onCompact: (msgs) => {
685
+ rewriteSession(session.file, msgs);
686
+ persisted = msgs.length;
687
+ },
688
+ });
689
+ messages[0] = { role: 'system', content: buildSystemPrompt({ workingDir, withJournal }) };
690
+ if (!silent) {
691
+ const p2 = modelPreset(modelName);
692
+ io.print(style(`✓ 已切换到 ${C.bold}${modelName}${C.reset}${p2 ? `(${p2.label})` : ''}`, C.green));
693
+ }
694
+ return true;
695
+ } catch (err) {
696
+ io.print(style('[错误] ' + (err?.message || err), C.red));
697
+ return false;
698
+ }
699
+ }
700
+
701
+ for (;;) {
702
+ let input;
703
+ try {
704
+ input = await io.askMultiline(style('你> ', C.green));
705
+ } catch {
706
+ break;
707
+ }
708
+ if (input === '') continue;
709
+
710
+ if (input.startsWith('/')) {
711
+ const [cmd, ...rest] = input.split(/\s+/);
712
+ const arg = rest.join(' ');
713
+ if (cmd === '/exit' || cmd === '/quit') break;
714
+ else if (cmd === '/help') printHelpLines(io.print);
715
+ else if (cmd === '/clear') {
716
+ messages = [{ role: 'system', content: systemPrompt }];
717
+ // 评估 P2-1:会话文件同步原子重写为仅新 system——否则旧上下文残留会被 --continue 读回,
718
+ // 且后续 appendMessages 会把 system+新消息重复追加到旧历史之后。
719
+ try {
720
+ rewriteSession(session.file, messages);
721
+ } catch {}
722
+ persisted = messages.length;
723
+ io.print('已清空上下文(会话文件已同步重置)。');
724
+ } else if (cmd === '/model') {
725
+ if (!arg) {
726
+ io.print(`当前模型:${modelName}`);
727
+ continue;
728
+ }
729
+ await switchToModel(arg);
730
+ } else if (cmd === '/mode') {
731
+ const map = { pro: 'deepseek-v4-pro', flash: 'deepseek-v4-flash' };
732
+ const target = map[arg] || arg;
733
+ if (!target) {
734
+ io.print('用法:/mode pro|flash|<模型名>(pro=deepseek-v4-pro,flash=deepseek-v4-flash)');
735
+ continue;
736
+ }
737
+ await switchToModel(target);
738
+ } else if (cmd === '/plan') {
739
+ planMode = !planMode;
740
+ io.print(style(`✓ 计划模式:${planMode ? '开' : '关'}${planMode ? '(先出计划,确认后执行)' : ''}`, C.green));
741
+ } else if (cmd === '/route') {
742
+ if (!routing) {
743
+ io.print(style('未配置自动路由(config.json 的 routing 字段,如 {"enabled":true,"planner":"deepseek-v4-pro","executor":"deepseek-v4-flash"})', C.dim));
744
+ continue;
745
+ }
746
+ if (arg === 'on' || arg === 'off') routingEnabled = arg === 'on';
747
+ else routingEnabled = !routingEnabled;
748
+ io.print(style(`✓ 自动路由:${routingEnabled ? '开' : '关'}(规划类→${routing.planner},执行类→${routing.executor})`, C.green));
749
+ } else if (cmd === '/verbose') {
750
+ io.setShowReasoning(!io.showReasoning);
751
+ io.print(style(`✓ 思考过程显示:${io.showReasoning ? '开' : '关'}`, C.green));
752
+ } else if (cmd === '/compact') {
753
+ if (messages.length <= 6) {
754
+ io.print(style('消息较少(≤6 条),无需压缩。', C.dim));
755
+ continue;
756
+ }
757
+ io.startSpinner('正在压缩上下文…');
758
+ let summary = '';
759
+ try {
760
+ summary = await compactContext(provider, modelName, messages);
761
+ } catch (err) {
762
+ io.print(style('[压缩失败] ' + (err?.message || err), C.red));
763
+ }
764
+ io.stopSpinner();
765
+ if (!summary) continue;
766
+ messages = [{ role: 'system', content: systemPrompt }, { role: 'user', content: '[此前对话摘要]\n' + summary }];
767
+ appendMessages(session.file, [{ role: 'system', content: '── /compact 压缩点 ──' }, ...messages.slice(1)]);
768
+ persisted = messages.length;
769
+ io.print(style('✓ 已压缩上下文(完整历史保留在会话文件中)。', C.green));
770
+ } else if (cmd === '/init') {
771
+ const target = path.join(workingDir, 'AGENTS.md');
772
+ if (fs.existsSync(target) && arg !== 'force') {
773
+ io.print(style(`已存在 ${target},如需覆盖:/init force`, C.yellow));
774
+ continue;
775
+ }
776
+ const entries = fs.readdirSync(workingDir).slice(0, 20).join('、') || '(空目录)';
777
+ const template =
778
+ `# ${path.basename(workingDir)} 项目约定\n\n` +
779
+ `## 项目概述\n\n(一句话说明项目用途)\n\n` +
780
+ `## 常用命令\n\n(构建、测试、运行命令)\n\n` +
781
+ `## 代码结构\n\n顶层内容:${entries}\n\n` +
782
+ `## 约定与规范\n\n(团队约定、注意事项;MingDao 每次会话会自动读取本文件)\n`;
783
+ fs.writeFileSync(target, template);
784
+ io.print(style(`✓ 已生成 ${target},将在后续会话自动注入。`, C.green));
785
+ } else if (cmd === '/memory') {
786
+ const memPath = path.join(mingdaoHome(), 'AGENTS.md');
787
+ if (arg.startsWith('add ')) {
788
+ const text = arg.slice(4).trim();
789
+ if (!text) {
790
+ io.print('用法:/memory add <内容>');
791
+ continue;
792
+ }
793
+ fs.appendFileSync(memPath, `- ${text}\n`);
794
+ io.print(style(`✓ 已追加到用户记忆 ${memPath}(后续会话自动生效)`, C.green));
795
+ } else if (arg === 'extract') {
796
+ io.startSpinner('正在从当前对话提炼记忆…');
797
+ try {
798
+ const existing = loadMemory();
799
+ const lines = await extractMemory(provider, titleModel(cfg, modelName), messages, existing);
800
+ io.stopSpinner();
801
+ if (lines.length) {
802
+ appendMemory(lines);
803
+ io.print(style(`✓ 新增 ${lines.length} 条记忆:`, C.green));
804
+ for (const l of lines) io.print(style(' ' + l, C.dim));
805
+ } else {
806
+ io.print(style('没有发现新的值得记住的内容。', C.dim));
807
+ }
808
+ } catch (err) {
809
+ io.stopSpinner();
810
+ io.print(style('[错误] ' + (err?.message || err), C.red));
811
+ }
812
+ } else if (arg === 'show') {
813
+ const raw = loadMemory();
814
+ if (!raw.trim()) {
815
+ io.print('记忆库为空。');
816
+ } else {
817
+ io.print(style('记忆库(' + memPath + '):', C.bold));
818
+ raw.split('\n').forEach((l, i) => {
819
+ if (l.trim()) io.print(style(` ${String(i + 1).padStart(3)} ${l}`, C.dim));
820
+ });
821
+ }
822
+ } else if (arg.startsWith('remove ')) {
823
+ const kw = arg.slice(7).trim();
824
+ if (!kw) {
825
+ io.print('用法:/memory remove <关键词>(删除包含该词的条目)');
826
+ continue;
827
+ }
828
+ const removed = removeMemoryLines(kw);
829
+ io.print(removed > 0 ? style(`✓ 已删除 ${removed} 条(原文件备份于 ${memPath}.bak)`, C.green) : style('没有匹配的条目。', C.dim));
830
+ } else if (arg === 'dedupe') {
831
+ const removed = dedupeMemory();
832
+ io.print(removed > 0 ? style(`✓ 去重完成:合并 ${removed} 条重复记忆`, C.green) : style('没有重复条目。', C.dim));
833
+ } else if (arg === 'edit') {
834
+ const editor = process.env.EDITOR || process.env.VISUAL;
835
+ if (!editor) {
836
+ io.print(style('未设置 EDITOR 环境变量。可 export EDITOR=nano(或 vim/code)后用 /memory edit 打开记忆文件。', C.yellow));
837
+ continue;
838
+ }
839
+ const { spawnSync } = await import('node:child_process');
840
+ spawnSync(editor, [memPath], { stdio: 'inherit' });
841
+ io.print(style('✓ 记忆文件已编辑(后续会话自动生效)。', C.green));
842
+ } else {
843
+ io.print(style(`用户记忆文件:${memPath}${fs.existsSync(memPath) ? '' : '(尚不存在)'}`, C.dim));
844
+ if (fs.existsSync(memPath)) io.print(style(fs.readFileSync(memPath, 'utf8').slice(0, 2000), C.dim));
845
+ const journal = recentJournal(home, 5);
846
+ if (journal.length) {
847
+ io.print(style('最近会话:', C.bold));
848
+ for (const e of journal.reverse()) io.print(style(` ${new Date(e.at).toISOString().slice(0, 10)} ${e.firstUser?.slice(0, 40)}`, C.dim));
849
+ }
850
+ io.print('用法:/memory add <内容> 追加 · extract 自动提炼 · show 查看 · remove <词> 删除 · dedupe 去重 · edit 编辑器修改');
851
+ }
852
+ } else if (cmd === '/skills') {
853
+ const skills = listSkills(workingDir);
854
+ if (!skills.length) {
855
+ io.print(
856
+ style(
857
+ '未安装技能。目录:~/.mingdao/skills/(用户级)、<项目>/.mingdao/skills/(项目级)与内置技能库',
858
+ C.dim
859
+ )
860
+ );
861
+ continue;
862
+ }
863
+ const label = (s) => `${s.name}${s.source === 'user' ? '(用户级)' : s.source === 'builtin' ? '(内置)' : ''}`;
864
+ io.box(
865
+ `已安装技能(${skills.length})`,
866
+ skills.map((s) => `${label(s)}:${s.description || '(无描述)'}`)
867
+ );
868
+ io.print(style(`技能库安装:退出会话后运行 mingdao skill search [关键词] → mingdao skill install <名称>`, C.dim));
869
+ } else if (cmd === '/title') {
870
+ if (!arg) {
871
+ io.print('用法:/title <别名>(给当前会话命名,便于 --resume 识别)');
872
+ continue;
873
+ }
874
+ const safe = arg.replace(/[^\w\u4e00-\u9fa5.-]/g, '_').slice(0, 40);
875
+ let newFile = path.join(home, 'sessions', safe + '.jsonl');
876
+ try {
877
+ fs.appendFileSync(session.file, ''); // 确保文件已创建(尚未写消息时也可能重命名)
878
+ // 同名会话已存在时附加随机后缀,绝不静默覆盖
879
+ if (fs.existsSync(newFile)) {
880
+ newFile = path.join(home, 'sessions', safe + '-' + Math.random().toString(36).slice(2, 6) + '.jsonl');
881
+ }
882
+ fs.renameSync(session.file, newFile);
883
+ session.file = newFile;
884
+ io.print(style(`✓ 会话已命名为 ${path.basename(newFile)}`, C.green));
885
+ } catch (err) {
886
+ io.print(style('[错误] ' + (err?.message || err), C.red));
887
+ }
888
+ } else if (cmd === '/audit') {
889
+ const n = Number(arg) || 10;
890
+ const { listAudit } = await import('./audit.js');
891
+ const rows = listAudit(n);
892
+ if (!rows.length) {
893
+ io.print(style('暂无审计记录(工具调用会自动记录)。', C.dim));
894
+ continue;
895
+ }
896
+ io.print(style(`最近 ${rows.length} 条工具调用审计:`, C.bold));
897
+ for (const r of rows) {
898
+ const when = new Date(r.at).toISOString().slice(0, 19).replace('T', ' ');
899
+ const status = r.denied ? `✖拒绝(${r.reason || ''})` : r.ok ? '✓' : '✖错误';
900
+ io.print(` ${when} ${status} ${r.tool} ${String(r.args || '').slice(0, 60)}`);
901
+ }
902
+ } else if (cmd === '/mcp') {
903
+ const status = mcpFacade.status();
904
+ if (status.length === 1 && status[0].name === '(连接中…)' && !status[0].ok && !mcpManager) {
905
+ if (!cfg.mcpServers || !Object.keys(cfg.mcpServers).length) {
906
+ io.print(style('未配置 MCP 服务器(config.json 的 mcpServers 字段)。', C.dim));
907
+ } else {
908
+ io.print(style('MCP 服务器连接中…(npx 首次下载依赖可能较慢)', C.dim));
909
+ }
910
+ continue;
911
+ }
912
+ io.box(
913
+ `MCP 服务器(${status.length})`,
914
+ status.map((s) => `${s.ok ? '✓' : '✖'} ${s.name}${s.ok ? ` · ${s.tools} 个工具` : `:${s.error}`}`)
915
+ );
916
+ } else if (cmd === '/sessions') {
917
+ if (arg) {
918
+ // 关键词全文检索历史会话
919
+ const hits = searchSessions(home, arg);
920
+ if (!hits.length) {
921
+ io.print(style(`未找到包含「${arg}」的会话。`, C.dim));
922
+ } else {
923
+ io.box(`会话检索:${arg}(${hits.length} 个命中)`, hits.map((h) => `${h.name}(${relativeTime(h.mtime)})`));
924
+ for (const h of hits.slice(0, 5)) io.print(style(` ${h.snippet}`, C.dim));
925
+ io.print(style('恢复:mingdao --resume(或 mingdao sessions search 命令)', C.dim));
926
+ }
927
+ } else {
928
+ const list = listSessions(home).slice(0, 10);
929
+ if (!list.length) io.print('暂无历史会话。');
930
+ else {
931
+ io.box('历史会话(最近 10 个)', list.map((s) => `${relativeTime(s.mtime)} · ${sessionPreview(s.file)}`));
932
+ io.print(style('检索:/sessions <关键词> · 恢复:mingdao --resume(文件:' + path.join(home, 'sessions') + ')', C.dim));
933
+ }
934
+ }
935
+ } else if (cmd === '/save') {
936
+ io.print(`当前会话自动保存于:${session.file}`);
937
+ } else if (cmd === '/usage') {
938
+ if (lastUsage) {
939
+ io.print(
940
+ style(
941
+ `上轮用量:${lastUsage.prompt_tokens} prompt + ${lastUsage.completion_tokens} completion tokens`,
942
+ C.dim
943
+ )
944
+ );
945
+ } else io.print('尚无用量记录。');
946
+ } else if (cmd === '/status') {
947
+ io.box('会话状态', [
948
+ `模型 ${modelName} · 权限 ${permission.mode}`,
949
+ `沙箱 ${cfg.sandbox || 'off'}${routing ? ` · 自动路由 ${routingEnabled ? '开' : '关'}(${routing.planner}⇄${routing.executor})` : ''}`,
950
+ `会话 ${path.basename(session.file)}`,
951
+ `轮次 ${stats.turns} · 消息 ${messages.length} 条`,
952
+ `Tokens ↑${stats.promptTokens} ↓${stats.completionTokens}`,
953
+ `费用 ≈¥${estimateCost(modelName, stats.promptTokens, stats.completionTokens).toFixed(5)}(累计·按当前模型计价)`,
954
+ `计划模式 ${planMode ? '开' : '关'} · 思考显示 ${io.showReasoning ? '开' : '关'} · 任务 ${agent.getTodos().length} 项`,
955
+ ]);
956
+ } else if (cmd === '/cost') {
957
+ const bd = costBreakdown();
958
+ io.box('费用分账(含缓存折扣与 Batch 半价的真实口径)', [
959
+ `累计 ≈¥${bd.totalCost.toFixed(5)} · 今日 ≈¥${bd.today.toFixed(5)}` +
960
+ `${bd.totalSaved > 0 ? ` · 相比全未命中已省 ≈¥${bd.totalSaved.toFixed(5)}` : ''}`,
961
+ `缓存命中率 ${bd.rate != null ? (bd.rate * 100).toFixed(0) + '%' : '暂无缓存数据'}${bd.batchCost > 0 ? ` · Batch 半价任务 ≈¥${bd.batchCost.toFixed(5)}` : ''}`,
962
+ ...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)}` : ''}`),
963
+ ]);
964
+ const guard = costGuardStatus();
965
+ if (guard) {
966
+ io.print(
967
+ style(
968
+ `费用护栏:今日 ¥${guard.cost.toFixed(4)} / 上限 ¥${guard.limit.toFixed(2)}${guard.overLimit ? '(已达上限' + (guard.action === 'block' ? ',执行已暂停' : ',仅提醒') + ')' : ''}`,
969
+ guard.overLimit ? C.yellow : C.dim
970
+ )
971
+ );
972
+ }
973
+ io.print(style('会话内累计(本次)≈¥' + estimateCost(modelName, stats.promptTokens, stats.completionTokens).toFixed(5), C.dim));
974
+ } else if (cmd === '/cache') {
975
+ const entries = listCacheStats();
976
+ if (!entries.length) {
977
+ io.print(style('暂无缓存统计(对话若干轮后自动累积)。', C.dim));
978
+ continue;
979
+ }
980
+ const sum = summarizeCacheStats(entries);
981
+ io.box('缓存命中率仪表盘', formatCacheSummary(sum));
982
+ io.print(style('近 10 次命中率趋势:', C.dim));
983
+ const recent = entries.slice(-10);
984
+ const maxBar = 24;
985
+ for (const e of recent) {
986
+ const rate = e.hit != null && e.hit + e.miss > 0 ? e.hit / (e.hit + e.miss) : null;
987
+ const bar = rate == null ? '—'.repeat(maxBar) : '█'.repeat(Math.round(rate * maxBar));
988
+ io.print(style(` ${bar.padEnd(maxBar)} ${rate == null ? 'n/a' : (rate * 100).toFixed(0) + '%'} ${e.model}`, C.dim));
989
+ }
990
+ } else {
991
+ io.print(style('未知命令,输入 /help 查看可用命令。', C.yellow));
992
+ }
993
+ continue;
994
+ }
995
+
996
+ // 自动路由:规划类任务切 planner,执行类走 executor(会话粘滞 + 分类缓存见 routing.js)
997
+ if (routingEnabled) {
998
+ const route = await routeTask({ cfg, provider, currentModel: modelName, text: input, sticky: lastRouteModel });
999
+ lastRouteModel = route.model;
1000
+ if (route.model !== modelName) {
1001
+ const okSwitch = await switchToModel(route.model, { silent: true, persist: false });
1002
+ if (okSwitch) io.print(style(`⤷ 自动路由 → ${route.model}(${route.reason})`, C.dim));
1003
+ }
1004
+ }
1005
+
1006
+ // 计划模式:先出计划,确认后执行
1007
+ if (planMode) {
1008
+ io.startSpinner('正在生成计划…');
1009
+ let plan = null;
1010
+ try {
1011
+ plan = await generatePlan(provider, modelName, input);
1012
+ } catch (err) {
1013
+ io.print(style('[计划生成失败] ' + (err?.message || err), C.red));
1014
+ }
1015
+ io.stopSpinner();
1016
+ if (plan == null) continue;
1017
+ io.print(style('── 执行计划 ──', C.bold + C.cyan));
1018
+ io.print(plan);
1019
+ const okGo = await io.confirm(style('是否按此计划执行?[y/N]', C.yellow));
1020
+ if (!okGo) {
1021
+ io.print(style('已取消执行,可修改要求后重试。', C.dim));
1022
+ continue;
1023
+ }
1024
+ const planMsg = { role: 'assistant', content: '[执行计划]\n' + plan };
1025
+ messages.push(planMsg);
1026
+ appendMessages(session.file, [planMsg]);
1027
+ persisted += 1;
1028
+ }
1029
+
1030
+ const userMsg = { role: 'user', content: input };
1031
+ messages.push(userMsg);
1032
+ appendMessages(session.file, [userMsg]);
1033
+ persisted += 1;
1034
+
1035
+ try {
1036
+ const res = await agent.runTurn(messages);
1037
+ lastUsage = res.usage;
1038
+ lastText = res.text || lastText;
1039
+ stats.turns += 1;
1040
+ recordUsage(modelName, res.usage, res.perf);
1041
+ stats.promptTokens += res.usage.prompt_tokens || 0;
1042
+ stats.completionTokens += res.usage.completion_tokens || 0;
1043
+ const fresh = messages.slice(persisted);
1044
+ appendMessages(session.file, fresh);
1045
+ persisted = messages.length;
1046
+ if (!autoTitled && cfg.autoTitle !== false && res.text) {
1047
+ autoTitled = true;
1048
+ const tModel = titleModel(cfg, modelName);
1049
+ const title = await generateTitle(await helperProvider(cfg, tModel, provider), tModel, input);
1050
+ if (title) {
1051
+ const renamed = renameSessionFile(fs, path, home, session, title);
1052
+ if (renamed) io.print(style(`✓ 会话标题:${path.basename(renamed)}`, C.dim));
1053
+ }
1054
+ }
1055
+ if (res.aborted) {
1056
+ io.print(style('(已中断)', C.dim));
1057
+ }
1058
+ if (res.truncated) {
1059
+ io.print(style('[警告] 达到最大工具调用步数,任务可能未完成。', C.yellow));
1060
+ }
1061
+ if (res.finish === 'length') {
1062
+ io.print(style('[提示] 模型输出达到长度上限被截断。', C.yellow));
1063
+ }
1064
+ io.printUsageLine({ modelName, usage: res.usage, durationMs: res.durationMs });
1065
+ } catch (err) {
1066
+ io.print(style('[错误] ' + (err?.message || err), C.red));
1067
+ io.print(style('提示:可直接继续对话,或 /exit 退出。', C.dim));
1068
+ }
1069
+ }
1070
+
1071
+ mcpFacade.stop();
1072
+ if (stats.turns > 0) {
1073
+ io.startSpinner('正在沉淀会话记忆…');
1074
+ try {
1075
+ await finalizeSession({
1076
+ cfg,
1077
+ provider,
1078
+ model: titleModel(cfg, modelName),
1079
+ home,
1080
+ workingDir,
1081
+ messages,
1082
+ turns: stats.turns,
1083
+ lastText,
1084
+ });
1085
+ } catch {}
1086
+ io.stopSpinner();
1087
+ }
1088
+ try {
1089
+ await maybeAutoSync();
1090
+ } catch {}
1091
+ io.print('再见,明道与你同行。');
1092
+ io.close();
1093
+ }
1094
+
1095
+ main().catch((err) => {
1096
+ console.error('[MingDao] ' + (err?.message || err));
1097
+ if (process.env.MINGDAO_DEBUG) console.error(err);
1098
+ process.exit(1);
1099
+ });