mingdao-harness 0.1.59 → 0.1.61
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/README.md +8 -0
- package/docs/QA-REPORT.md +1 -1
- package/package.json +3 -2
- package/src/agent.js +8 -1
- package/src/batch.js +22 -15
- package/src/cachestats.js +18 -8
- package/src/cli.js +30 -4
- package/src/commands/skill.js +37 -3
- package/src/commands/sync.js +2 -1
- package/src/compact.js +23 -8
- package/src/config.js +57 -25
- package/src/mcp.js +1 -1
- package/src/memory.js +1 -1
- package/src/model-discovery.js +1 -1
- package/src/prompts.js +11 -4
- package/src/schedule.js +7 -3
- package/src/sync-server.js +2 -1
- package/src/sync.js +3 -3
- package/src/tasks.js +1 -1
- package/src/tools/bash.js +28 -1
- package/src/update.js +3 -3
- package/src/web/index.html +73 -8
- package/src/web/server.js +27 -5
package/README.md
CHANGED
|
@@ -101,6 +101,14 @@ git clone https://gitee.com/MingDaoTCM/MingDao-harness.git MingDao-Harness && cd
|
|
|
101
101
|
node src/cli.js # 直接运行,无需安装
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
+
开发者护栏(提交前建议执行;CI 会强制跑全套):
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npm install # 仅装 devDependencies(typescript + @types/node),运行时依旧零依赖
|
|
108
|
+
npm run typecheck # tsc --checkJs 类型护栏(覆盖 agent/cli/commands/provider/cachestats 等核心模块)
|
|
109
|
+
node test/smoke.js && node test/e2e-local.js && node test/e2e-web.js && node test/e2e-schedule.js
|
|
110
|
+
```
|
|
111
|
+
|
|
104
112
|
### 验证安装
|
|
105
113
|
|
|
106
114
|
```bash
|
package/docs/QA-REPORT.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mingdao-harness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.61",
|
|
4
4
|
"description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -52,7 +52,8 @@
|
|
|
52
52
|
"typecheck": "tsc -p tsconfig.json",
|
|
53
53
|
"prepublishOnly": "node test/smoke.js && node test/e2e-local.js && node test/e2e-schedule.js",
|
|
54
54
|
"desktop": "npm --prefix desktop start",
|
|
55
|
-
"desktop:dist": "npm --prefix desktop run dist:dir"
|
|
55
|
+
"desktop:dist": "node scripts/sync-versions.mjs && npm --prefix desktop run dist:dir",
|
|
56
|
+
"desktop:sync": "node scripts/sync-versions.mjs"
|
|
56
57
|
},
|
|
57
58
|
"devDependencies": {
|
|
58
59
|
"@types/node": "^26.2.0",
|
package/src/agent.js
CHANGED
|
@@ -16,6 +16,11 @@ import { checkCostGuard } from './cost-guard.js';
|
|
|
16
16
|
const MAX_STEPS = 24;
|
|
17
17
|
const SUBAGENT_MAX_STEPS = 12;
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* 创建 Agent 循环(调用方只需传 provider/permission/io/modelName/workingDir,其余可选)
|
|
21
|
+
* @param {{ provider: any, permission: any, io: any, modelName: any, workingDir: any,
|
|
22
|
+
* cfg?: any, undoStore?: any, maxSteps?: number, mcp?: any, onCompact?: any, sessionRef?: any }} params
|
|
23
|
+
*/
|
|
19
24
|
export function createAgent({ provider, permission, io, modelName, workingDir, cfg = {}, undoStore, maxSteps, mcp, onCompact, sessionRef }) {
|
|
20
25
|
const preset = modelPreset(modelName) || {};
|
|
21
26
|
const budget = cfg.contextBudget || preset.budgetTokens || 128000;
|
|
@@ -174,8 +179,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
174
179
|
io.startSpinner('正在思考…');
|
|
175
180
|
|
|
176
181
|
let res;
|
|
182
|
+
// 审计(tsc 扩面发现):llmT0 此前在 try 内声明、catch 内引用——chat 抛错时
|
|
183
|
+
// catch 自身 ReferenceError,掩盖原始错误且计时丢失;提到 try 外声明。
|
|
184
|
+
const llmT0 = Date.now();
|
|
177
185
|
try {
|
|
178
|
-
const llmT0 = Date.now();
|
|
179
186
|
res = await provider.chat({
|
|
180
187
|
model: modelName,
|
|
181
188
|
messages: trimmed,
|
package/src/batch.js
CHANGED
|
@@ -11,7 +11,6 @@ import path from 'node:path';
|
|
|
11
11
|
import { resolveProviderConfig } from './providers/index.js';
|
|
12
12
|
import { estimateBatchCost, BATCH_DISCOUNT } from './pricing.js';
|
|
13
13
|
import { recordCacheStats } from './cachestats.js';
|
|
14
|
-
import { buildSystemPrompt } from './prompts.js';
|
|
15
14
|
|
|
16
15
|
const DEFAULT_WINDOW = '24h';
|
|
17
16
|
const DEFAULT_ENDPOINT = '/v1/chat/completions';
|
|
@@ -26,15 +25,16 @@ function batchBase(cfg, model) {
|
|
|
26
25
|
return pc.name === 'deepseek' ? base.replace(/\/v1\/?$/, '') : base;
|
|
27
26
|
}
|
|
28
27
|
|
|
28
|
+
/** @returns {Promise<any>} */
|
|
29
29
|
async function api(base, apiKey, methodPath, payload, httpMethod = 'POST') {
|
|
30
30
|
const res = await fetch(base + methodPath, {
|
|
31
31
|
method: httpMethod,
|
|
32
32
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
33
33
|
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
34
34
|
});
|
|
35
|
-
const j = await res.json().catch(() => ({}));
|
|
35
|
+
const j = /** @type {any} */ (await res.json().catch(() => ({})));
|
|
36
36
|
if (!res.ok) {
|
|
37
|
-
const e = new Error(j?.error?.message || j?.message || `HTTP ${res.status}`);
|
|
37
|
+
const e = /** @type {Error & { status?: number }} */ (new Error(j?.error?.message || j?.message || `HTTP ${res.status}`));
|
|
38
38
|
e.status = res.status;
|
|
39
39
|
throw e;
|
|
40
40
|
}
|
|
@@ -50,9 +50,9 @@ async function uploadFile(base, apiKey, jsonl) {
|
|
|
50
50
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
51
51
|
body: form,
|
|
52
52
|
});
|
|
53
|
-
const j = await res.json().catch(() => ({}));
|
|
53
|
+
const j = /** @type {any} */ (await res.json().catch(() => ({})));
|
|
54
54
|
if (!res.ok) {
|
|
55
|
-
const e = new Error(j?.error?.message || j?.message || `上传失败 HTTP ${res.status}`);
|
|
55
|
+
const e = /** @type {Error & { status?: number }} */ (new Error(j?.error?.message || j?.message || `上传失败 HTTP ${res.status}`));
|
|
56
56
|
e.status = res.status;
|
|
57
57
|
throw e;
|
|
58
58
|
}
|
|
@@ -84,7 +84,8 @@ async function downloadResults(base, apiKey, batch) {
|
|
|
84
84
|
throw new Error('批处理结果文件不可用(端点不支持或文件已过期)');
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
/** 执行一次批处理。questions: string[]。返回 { ok, outputFile, results, usage, cost, batchId }
|
|
88
|
+
* @param {{ cfg: any, model: any, questions: any, workingDir?: string, maxTokens?: number, temperature?: any, signal?: any, onStatus?: any }} opts */
|
|
88
89
|
export async function runBatch({ cfg, model, questions, workingDir = process.cwd(), maxTokens = 4096, temperature, signal, onStatus }) {
|
|
89
90
|
const list = (questions || []).map((q) => String(q).trim()).filter(Boolean);
|
|
90
91
|
if (!list.length) return { error: '没有可批处理的问题(每行一个问题)' };
|
|
@@ -92,7 +93,10 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
|
|
|
92
93
|
if (!pc.apiKey) return { error: `模型 ${model} 没有可用 API Key(mingdao key set ${pc.name})` };
|
|
93
94
|
const base = batchBase(cfg, model);
|
|
94
95
|
const apiKey = pc.apiKey;
|
|
95
|
-
|
|
96
|
+
// 审计(workbuddy P2-1):批量任务无缓存语义,每个问题的 system 都按 input 全价计费——
|
|
97
|
+
// 不再携带完整系统提示(技能清单/用户记忆/AGENTS.md 对无工具批任务毫无意义,1000 问
|
|
98
|
+
// 可白烧 50-80 万 token);改用一行精简角色提示,剩余能力损失为零。
|
|
99
|
+
const systemPrompt = '你是 MingDao Harness 编程助手。直接针对每个问题给出准确、完整的答案,不要复述问题、不要解释过程。';
|
|
96
100
|
const bodyTemplate = {
|
|
97
101
|
model,
|
|
98
102
|
messages: null, // 逐行填充
|
|
@@ -122,11 +126,14 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
|
|
|
122
126
|
completion_window: cfg?.batchWindow || DEFAULT_WINDOW,
|
|
123
127
|
});
|
|
124
128
|
onStatus?.(`任务已创建:${batch.id}`);
|
|
125
|
-
//
|
|
126
|
-
|
|
129
|
+
// 轮询:指数退避(审计 workbuddy P3-3)——基础间隔 5s(MINGDAO_BATCH_POLL_MS 可覆盖,测试用),
|
|
130
|
+
// ×1.5 逐次翻倍、30s 封顶:24h 窗口内轮询请求量从 ~1.7 万次降到 ~3 千次;
|
|
131
|
+
// 连续失败 10 次 → 报错,绝不无限重试。每次轮询报告进度(含已处理 X/Y)。
|
|
132
|
+
const baseInterval = Math.max(500, Number(process.env.MINGDAO_BATCH_POLL_MS) || 5000);
|
|
127
133
|
const t0 = Date.now();
|
|
128
134
|
let st = batch.status;
|
|
129
135
|
let failures = 0;
|
|
136
|
+
let polls = 0;
|
|
130
137
|
for (;;) {
|
|
131
138
|
if (signal?.aborted) return { error: '已取消轮询(任务仍在服务端运行)', batchId: batch.id };
|
|
132
139
|
if (Date.now() - t0 > 24 * 3600 * 1000) return { error: '批处理超过 24h 窗口', batchId: batch.id };
|
|
@@ -137,7 +144,7 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
|
|
|
137
144
|
} catch (err) {
|
|
138
145
|
failures += 1;
|
|
139
146
|
if (failures >= 10) return { error: `轮询失败:${err?.message || err}(任务仍在服务端,ID ${batch.id})`, batchId: batch.id };
|
|
140
|
-
await new Promise((r) => setTimeout(r,
|
|
147
|
+
await new Promise((r) => setTimeout(r, Math.min(baseInterval * 1.5 ** failures, 30000)));
|
|
141
148
|
continue;
|
|
142
149
|
}
|
|
143
150
|
st = j.status;
|
|
@@ -149,11 +156,11 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
|
|
|
149
156
|
const detail = j?.errors?.data?.[0]?.message || j?.errors?.message || '';
|
|
150
157
|
return { error: `批处理失败:${st}${detail ? '(' + detail + ')' : ''}`, batchId: batch.id };
|
|
151
158
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
await new Promise((r) => setTimeout(r,
|
|
159
|
+
polls += 1;
|
|
160
|
+
const rc = j?.request_counts || {};
|
|
161
|
+
const done = rc.completed != null && rc.total != null ? `${rc.completed}/${rc.total}` : '';
|
|
162
|
+
onStatus?.(`状态:${st}${done ? `(已处理 ${done})` : ''}`);
|
|
163
|
+
await new Promise((r) => setTimeout(r, Math.min(baseInterval * 1.5 ** polls, 30000)));
|
|
157
164
|
}
|
|
158
165
|
onStatus?.('下载结果…');
|
|
159
166
|
const results = await downloadResults(base, apiKey, batch);
|
package/src/cachestats.js
CHANGED
|
@@ -49,17 +49,27 @@ export function recordCacheStats(entry) {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// listCacheStats 读取缓存(审计:costGuard 每步 / WebUI 每 15s / /cost 命令都调用——
|
|
53
|
+
// 四报告共识 P2-2/§3.3-A:全文件读+逐行 parse 会随 JSONL 增长线性放大 IO。
|
|
54
|
+
// 按 mtimeMs+size 双键缓存;写入追加会改两者,轮转重写同样改,缓存自动失效)
|
|
55
|
+
let _statsCache = null;
|
|
56
|
+
|
|
52
57
|
export function listCacheStats(limit = 2000) {
|
|
53
58
|
try {
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
const file = cacheStatsFile();
|
|
60
|
+
const st = fs.statSync(file);
|
|
61
|
+
if (!_statsCache || _statsCache.mtimeMs !== st.mtimeMs || _statsCache.size !== st.size || _statsCache.limit < limit) {
|
|
62
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const l of raw.split('\n')) {
|
|
65
|
+
if (!l.trim()) continue;
|
|
66
|
+
try {
|
|
67
|
+
out.push(JSON.parse(l));
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
_statsCache = { mtimeMs: st.mtimeMs, size: st.size, limit, lines: out };
|
|
61
71
|
}
|
|
62
|
-
return
|
|
72
|
+
return _statsCache.lines.slice(-limit);
|
|
63
73
|
} catch {
|
|
64
74
|
return [];
|
|
65
75
|
}
|
package/src/cli.js
CHANGED
|
@@ -23,7 +23,7 @@ import { enableAutostart, disableAutostart, autostartStatus, autostartPath } fro
|
|
|
23
23
|
import { notifyTaskDone } from './notify.js';
|
|
24
24
|
import { addWorkspace, removeWorkspace, workspacePath, touchWorkspace, listWorkspaces, currentWorkspace } from './workspace.js';
|
|
25
25
|
import { finalizeSession, extractMemory, loadMemory, appendMemory, recentJournal, dedupeMemory, removeMemoryLines } from './memory.js';
|
|
26
|
-
import { recordUsage, listCacheStats, summarizeCacheStats, formatCacheSummary } from './cachestats.js';
|
|
26
|
+
import { recordUsage, listCacheStats, summarizeCacheStats, formatCacheSummary, costBreakdown } from './cachestats.js';
|
|
27
27
|
import { presetList, buildPreset } from './mcp-presets.js';
|
|
28
28
|
import {
|
|
29
29
|
addSchedule,
|
|
@@ -436,7 +436,8 @@ async function main() {
|
|
|
436
436
|
if (opts.init) return;
|
|
437
437
|
}
|
|
438
438
|
|
|
439
|
-
|
|
439
|
+
// 模型回退链(向导允许跳过模型选择 → cfg.model 可缺省):参数 > config > 该服务商首个预设模型 > flash
|
|
440
|
+
let modelName = opts.model || cfg.model || PROVIDERS[cfg.provider]?.models?.[0] || 'deepseek-v4-flash';
|
|
440
441
|
const io = createIO();
|
|
441
442
|
|
|
442
443
|
const pc0 = resolveProviderConfig(cfg, modelName);
|
|
@@ -508,10 +509,12 @@ async function main() {
|
|
|
508
509
|
const route = await routeTask({ cfg, provider, currentModel: modelName, text: question });
|
|
509
510
|
if (route.model !== modelName) {
|
|
510
511
|
if (!jsonMode) io.print(style(`⤷ 自动路由 → ${route.model}(${route.reason})`, C.dim));
|
|
512
|
+
// 审计 P1-2(第五轮复审实证):先按新模型重建 provider、再改模型名——
|
|
513
|
+
// 此前顺序颠倒(先赋值再判断),条件恒假,跨服务商路由池会把 executor 模型名
|
|
514
|
+
// 发到 planner 的 baseUrl/key 上(401/404)。默认同服务商配置不受影响。
|
|
515
|
+
provider = await createProvider(cfg, route.model);
|
|
511
516
|
modelName = route.model;
|
|
512
517
|
}
|
|
513
|
-
// 审计 P1-2:自动路由改换模型后必须重建对应 provider(此前用旧模型的 baseUrl/key 发请求)
|
|
514
|
-
if (route.model !== modelName) provider = await createProvider(cfg, modelName);
|
|
515
518
|
// JSON 模式:关闭流式输出,结果以单行 JSON 输出(脚本/管道友好)
|
|
516
519
|
const turnIo = jsonMode ? createIO({ quiet: true }) : io;
|
|
517
520
|
const session = createSession(home);
|
|
@@ -608,6 +611,22 @@ async function main() {
|
|
|
608
611
|
].filter(Boolean));
|
|
609
612
|
io.print(style('输入问题开始对话 · /help 查看命令 · Tab 补全 · Ctrl+C 中断生成\n', C.dim));
|
|
610
613
|
|
|
614
|
+
// WebUI 自动启动(首次运行 mingdao web 时可选开启,存 config.web.autoStart):
|
|
615
|
+
// 独立后台进程拉起,退出 TUI 后 WebUI 继续可用;关闭:mingdao web --no-autostart
|
|
616
|
+
if (cfg.web?.autoStart && !process.env.MINGDAO_NO_WEB_AUTOSTART) {
|
|
617
|
+
try {
|
|
618
|
+
const { spawn } = await import('node:child_process');
|
|
619
|
+
const { fileURLToPath } = await import('node:url');
|
|
620
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'web'], {
|
|
621
|
+
detached: true,
|
|
622
|
+
stdio: 'ignore',
|
|
623
|
+
env: process.env,
|
|
624
|
+
});
|
|
625
|
+
child.unref();
|
|
626
|
+
io.print(style(`🌐 WebUI 后台启动中:http://127.0.0.1:${cfg.web?.port || 3820}(关闭自动启动:mingdao web --no-autostart)`, C.dim));
|
|
627
|
+
} catch {}
|
|
628
|
+
}
|
|
629
|
+
|
|
611
630
|
let session = null;
|
|
612
631
|
if (opts.resume) {
|
|
613
632
|
const list = listSessions(home).slice(0, 10);
|
|
@@ -712,6 +731,9 @@ async function main() {
|
|
|
712
731
|
if (input.startsWith('/')) {
|
|
713
732
|
const [cmd, ...rest] = input.split(/\s+/);
|
|
714
733
|
const arg = rest.join(' ');
|
|
734
|
+
// 审计(第五轮 P1-1 教训):斜杠命令统一 try/catch——单条命令异常只提示不退出,
|
|
735
|
+
// 绝不再因一条命令的错误杀死整个 REPL 会话(历史 P1-1 曾导致会话上下文全丢)
|
|
736
|
+
try {
|
|
715
737
|
if (cmd === '/exit' || cmd === '/quit') break;
|
|
716
738
|
else if (cmd === '/help') printHelpLines(io.print);
|
|
717
739
|
else if (cmd === '/clear') {
|
|
@@ -993,6 +1015,10 @@ async function main() {
|
|
|
993
1015
|
io.print(style('未知命令,输入 /help 查看可用命令。', C.yellow));
|
|
994
1016
|
}
|
|
995
1017
|
continue;
|
|
1018
|
+
} catch (err) {
|
|
1019
|
+
io.print(style('[错误] 命令执行失败:' + (err?.message || err), C.red));
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
996
1022
|
}
|
|
997
1023
|
|
|
998
1024
|
// 自动路由:规划类任务切 planner,执行类走 executor(会话粘滞 + 分类缓存见 routing.js)
|
package/src/commands/skill.js
CHANGED
|
@@ -2,10 +2,21 @@
|
|
|
2
2
|
import { listSkills, tamperedSkillNames } from '../skills.js';
|
|
3
3
|
import { libraryList, searchLibrary, installSkill, uninstallSkill, reinstallSkill, trustSkill } from '../skill-lib.js';
|
|
4
4
|
import { searchRegistry } from '../skill-registry.js';
|
|
5
|
-
import { loadConfig } from '../config.js';
|
|
6
|
-
import { ensureHome } from '../config.js';
|
|
5
|
+
import { loadConfig, saveConfig, ensureHome } from '../config.js';
|
|
7
6
|
import { searchSessions, relativeTime } from '../session.js';
|
|
8
7
|
import { runWebServer } from '../web/server.js';
|
|
8
|
+
import readline from 'node:readline';
|
|
9
|
+
|
|
10
|
+
// 首次运行询问「是否自动后台启动 WebUI」(交互终端才问;管道/脚本下默认否)
|
|
11
|
+
function askAutoStart() {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
14
|
+
rl.question('是否在每次运行 mingdao 时自动后台启动 WebUI(免敲命令)?[y/N] ', (a) => {
|
|
15
|
+
rl.close();
|
|
16
|
+
resolve(/^y(es)?$/i.test(String(a).trim()));
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
9
20
|
|
|
10
21
|
export async function handleSkill(cmd, args) {
|
|
11
22
|
const sub = args[0] || 'list';
|
|
@@ -102,10 +113,13 @@ export async function handleSkill(cmd, args) {
|
|
|
102
113
|
return true;
|
|
103
114
|
}
|
|
104
115
|
|
|
105
|
-
// WebUI:mingdao web [端口] [--auth-token <令牌>](评估 P3-1:参数结构不合法的按提问处理)
|
|
116
|
+
// WebUI:mingdao web [端口] [--auth-token <令牌>] [--autostart|--no-autostart](评估 P3-1:参数结构不合法的按提问处理)
|
|
117
|
+
// --autostart/--no-autostart:写 config.web.autoStart——开启后每次运行 mingdao 会自动后台拉起 WebUI;
|
|
118
|
+
// 首次交互运行且未设置时,询问一次「下次是否自动启动」。
|
|
106
119
|
export async function handleWeb(cmd, args) {
|
|
107
120
|
let portIndex = -1;
|
|
108
121
|
let tokenSeen = false;
|
|
122
|
+
let autoChoice; // undefined=未指定;true/false=显式指定
|
|
109
123
|
for (let i = 0; i < args.length; i++) {
|
|
110
124
|
const a = args[i];
|
|
111
125
|
if (a === '--auth-token') {
|
|
@@ -119,6 +133,11 @@ export async function handleWeb(cmd, args) {
|
|
|
119
133
|
tokenSeen = true;
|
|
120
134
|
continue;
|
|
121
135
|
}
|
|
136
|
+
if (a === '--autostart' || a === '--no-autostart') {
|
|
137
|
+
if (autoChoice !== undefined) return false;
|
|
138
|
+
autoChoice = a === '--autostart';
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
122
141
|
if (/^\d+$/.test(a) && portIndex === -1) {
|
|
123
142
|
portIndex = i; // 审计 P2-11:记录端口实际位置,而非默认读首参
|
|
124
143
|
continue;
|
|
@@ -126,6 +145,21 @@ export async function handleWeb(cmd, args) {
|
|
|
126
145
|
return false;
|
|
127
146
|
}
|
|
128
147
|
const cfg0 = loadConfig();
|
|
148
|
+
// 自动启动开关(先落盘再起服务,服务失败也保留用户选择)
|
|
149
|
+
if (autoChoice !== undefined) {
|
|
150
|
+
const c = cfg0 ? { ...cfg0, web: { ...(cfg0.web || {}), autoStart: autoChoice } } : { web: { autoStart: autoChoice } };
|
|
151
|
+
saveConfig(c);
|
|
152
|
+
console.log(
|
|
153
|
+
autoChoice
|
|
154
|
+
? '✓ 已开启 WebUI 自动启动:以后直接运行 mingdao 即可后台拉起(关闭:mingdao web --no-autostart)'
|
|
155
|
+
: '✓ 已关闭 WebUI 自动启动'
|
|
156
|
+
);
|
|
157
|
+
} else if (cfg0?.web?.autoStart === undefined && process.stdin.isTTY) {
|
|
158
|
+
const ans = await askAutoStart();
|
|
159
|
+
const c = { ...cfg0, web: { ...(cfg0.web || {}), autoStart: ans } };
|
|
160
|
+
saveConfig(c);
|
|
161
|
+
if (ans) console.log('✓ 已开启:以后直接运行 mingdao 即可自动后台启动 WebUI(关闭:mingdao web --no-autostart)');
|
|
162
|
+
}
|
|
129
163
|
const portArg = portIndex !== -1 ? Number(args[portIndex]) : NaN;
|
|
130
164
|
const port = Number.isFinite(portArg) && portArg > 0 ? portArg : cfg0?.web?.port || 3820;
|
|
131
165
|
const host = cfg0?.web?.host || '127.0.0.1';
|
package/src/commands/sync.js
CHANGED
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
|
|
19
19
|
async function askHidden(question) {
|
|
20
20
|
return new Promise((resolve) => {
|
|
21
|
-
|
|
21
|
+
// _writeToOutput 为 readline 内部接口:静音回显(密码输入),类型护栏下显式 any
|
|
22
|
+
const rl = /** @type {any} */ (readline.createInterface({ input: process.stdin, output: process.stdout }));
|
|
22
23
|
const orig = rl._writeToOutput;
|
|
23
24
|
rl._writeToOutput = () => {};
|
|
24
25
|
rl.question(question, (a) => {
|
package/src/compact.js
CHANGED
|
@@ -19,12 +19,12 @@ const INPUT_MAX_CHARS = 30000; // 摘要输入上限(防超长工具输出撑
|
|
|
19
19
|
const TOOL_OUTPUT_CAP = 300; // 摘要输入中每条工具结果截断长度
|
|
20
20
|
|
|
21
21
|
const SUMMARY_SYSTEM =
|
|
22
|
-
'你是 MingDao 的会话压缩器。把对话记录压成紧凑中文摘要(≤500 字,要点列表):' +
|
|
22
|
+
'你是 MingDao Harness 的会话压缩器。把对话记录压成紧凑中文摘要(≤500 字,要点列表):' +
|
|
23
23
|
'保留用户目标与关键要求、已完成的步骤与结论、修改/创建的文件、未完成事项、重要约定与决策;' +
|
|
24
|
-
'
|
|
24
|
+
'省略已完成的中间过程与细节。只输出 JSON:{"summary": "摘要内容"}。';
|
|
25
25
|
|
|
26
26
|
export async function summarizeConversation(provider, model, convoText) {
|
|
27
|
-
const
|
|
27
|
+
const base = {
|
|
28
28
|
model,
|
|
29
29
|
messages: [
|
|
30
30
|
{ role: 'system', content: SUMMARY_SYSTEM },
|
|
@@ -32,11 +32,26 @@ export async function summarizeConversation(provider, model, convoText) {
|
|
|
32
32
|
],
|
|
33
33
|
tools: [],
|
|
34
34
|
temperature: 0.2,
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
};
|
|
36
|
+
// 结构化输出(审计 MiniMax §3.3-D):压缩是 30K 输入 × pro 价的大开销,与标题/记忆/路由
|
|
37
|
+
// 一致改 json_object + maxTokens 2048→1024(1600 字摘要足够);网关不支持时回退纯文本。
|
|
38
|
+
let text = '';
|
|
39
|
+
let usage = null;
|
|
40
|
+
try {
|
|
41
|
+
const res = await provider.chat({ ...base, maxTokens: 1024, responseFormat: { type: 'json_object' } });
|
|
42
|
+
const j = JSON.parse(String(res?.text || '').trim());
|
|
43
|
+
text = String(j?.summary || '').trim();
|
|
44
|
+
usage = res?.usage || null;
|
|
45
|
+
} catch {}
|
|
46
|
+
if (!text) {
|
|
47
|
+
try {
|
|
48
|
+
const res = await provider.chat({ ...base, maxTokens: 1024 });
|
|
49
|
+
text = String(res?.text || '').trim();
|
|
50
|
+
usage = res?.usage || null;
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
if (!text) return { text: null, usage };
|
|
54
|
+
return { text: text.slice(0, SUMMARY_MAX_CHARS), usage };
|
|
40
55
|
}
|
|
41
56
|
|
|
42
57
|
export async function compactConversation({ messages, budget, count, provider, executorModel, triggerRatio }) {
|
package/src/config.js
CHANGED
|
@@ -23,6 +23,8 @@ export function configPath() {
|
|
|
23
23
|
return path.join(mingdaoHome(), 'config.json');
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** 读取配置对象(不存在/损坏返回 null);返回值为用户可编辑的任意 JSON 配置,类型不定
|
|
27
|
+
* @returns {any} */
|
|
26
28
|
export function loadConfig() {
|
|
27
29
|
try {
|
|
28
30
|
return JSON.parse(fs.readFileSync(configPath(), 'utf8'));
|
|
@@ -45,7 +47,7 @@ export function effectiveApiKey(cfg, providerName) {
|
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
export async function runWizard(io) {
|
|
48
|
-
io.box('MingDao 初始化向导', ['
|
|
50
|
+
io.box('MingDao Harness 初始化向导', ['① 选服务商 → ② 填 API Key(自动验证)→ ③ 选模型(可跳过)']);
|
|
49
51
|
io.print('');
|
|
50
52
|
|
|
51
53
|
const providerKeys = Object.keys(PROVIDERS);
|
|
@@ -55,33 +57,63 @@ export async function runWizard(io) {
|
|
|
55
57
|
);
|
|
56
58
|
const pp = PROVIDERS[provider];
|
|
57
59
|
|
|
58
|
-
let
|
|
60
|
+
let baseUrl = '';
|
|
59
61
|
if (provider === 'custom') {
|
|
60
|
-
|
|
61
|
-
} else {
|
|
62
|
-
const options = pp.models.map((m) => {
|
|
63
|
-
const preset = modelPreset(m);
|
|
64
|
-
return { value: m, label: preset ? `${m} — ${preset.label}` : m };
|
|
65
|
-
});
|
|
66
|
-
options.push({ value: '__custom__', label: '自定义模型名(手动输入)' });
|
|
67
|
-
const choice = await io.choose('② 选择模型:', options);
|
|
68
|
-
model = choice === '__custom__' ? await io.ask('模型名:') : choice;
|
|
62
|
+
baseUrl = await io.ask(`API 地址(回车默认 ${pp.baseUrl}):`);
|
|
69
63
|
}
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
64
|
+
const provisional = { provider, baseUrl: baseUrl || pp.baseUrl };
|
|
65
|
+
const hasEnvKey = () => Boolean((pp.envKey && process.env[pp.envKey]) || process.env.MINGDAO_API_KEY);
|
|
66
|
+
|
|
67
|
+
// ② 选定服务商后立即输入该服务商的 API Key,随后调用 /models 验证有效性
|
|
68
|
+
let apiKey = '';
|
|
69
|
+
let models = null; // 验证通过的线上模型名单
|
|
70
|
+
let verifyError = null;
|
|
71
|
+
for (let tries = 0; tries < 3; tries++) {
|
|
72
|
+
const envDetected = hasEnvKey() ? '(检测到环境变量,回车直接使用)' : '';
|
|
73
|
+
const input = await io.ask(`② ${provider} 的 API Key${envDetected}:`, { hidden: true });
|
|
74
|
+
apiKey = String(input || '').trim();
|
|
75
|
+
if (apiKey) setStoredKey(provider, apiKey);
|
|
76
|
+
if (!apiKey && !hasEnvKey()) {
|
|
77
|
+
const skipped = await io.confirm(' 未输入 API Key,跳过密钥设置?');
|
|
78
|
+
if (!skipped) continue;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
io.print(' 正在验证 API Key 有效性(调用 /models)…');
|
|
82
|
+
const { fetchProviderModels } = await import('./model-discovery.js');
|
|
83
|
+
const r = await fetchProviderModels(provisional, provider, { force: true });
|
|
84
|
+
if (r.models?.length) {
|
|
85
|
+
models = r.models;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
verifyError = r.error;
|
|
89
|
+
const retry = await io.confirm(`⚠ 验证失败:${r.error}(可能是密钥错误,或该网关不支持 /models 接口)。重新输入密钥?`);
|
|
90
|
+
if (retry) continue;
|
|
91
|
+
break; // 用户选择继续(稍后自行验证)
|
|
79
92
|
}
|
|
93
|
+
if (apiKey) io.print('✓ API Key 已保存到独立凭证库(权限 600),不会写入 config.json。');
|
|
94
|
+
else io.print('✓ 未输入 API Key:将使用环境变量(稍后可用 mingdao key set 补齐)。');
|
|
95
|
+
if (models) io.print(`✓ API Key 验证通过:该服务商线上可用模型 ${models.length} 个。`);
|
|
96
|
+
else if (verifyError) io.print('(已跳过验证,稍后可在 WebUI 设置面板「刷新模型」处再次校验)');
|
|
80
97
|
|
|
81
|
-
|
|
98
|
+
// ③ 选择模型(允许暂时跳过——不写 config.model,进入后默认用该服务商首个模型,/model 可随时换)
|
|
99
|
+
let model = null;
|
|
82
100
|
if (provider === 'custom') {
|
|
83
|
-
|
|
101
|
+
const m = await io.ask('③ 模型名(可留空跳过,稍后 /model 再选):');
|
|
102
|
+
model = m.trim() || null;
|
|
103
|
+
} else {
|
|
104
|
+
const options = [{ value: '__skip__', label: '暂时跳过(稍后 /model 再选)' }];
|
|
105
|
+
for (const m of models || pp.models) {
|
|
106
|
+
const preset = modelPreset(m);
|
|
107
|
+
options.push({ value: m, label: preset ? `${m} — ${preset.label}` : m });
|
|
108
|
+
}
|
|
109
|
+
options.push({ value: '__custom__', label: '自定义模型名(手动输入)' });
|
|
110
|
+
const choice = await io.choose('③ 选择模型:', options);
|
|
111
|
+
if (choice === '__skip__') model = null;
|
|
112
|
+
else if (choice === '__custom__') model = (await io.ask('模型名:')).trim() || null;
|
|
113
|
+
else model = choice;
|
|
84
114
|
}
|
|
115
|
+
if (model) io.print(`✓ 已选择模型:${model}`);
|
|
116
|
+
else io.print('✓ 已跳过模型选择:进入后自动使用该服务商默认模型,输入 /model 可随时切换。');
|
|
85
117
|
|
|
86
118
|
const perm = await io.choose('权限模式(写文件 / 执行命令时):', [
|
|
87
119
|
{ value: 'ask', label: 'ask — 每次询问(推荐,最安全)' },
|
|
@@ -104,7 +136,7 @@ export async function runWizard(io) {
|
|
|
104
136
|
if (routeChoice === 'on') routing = { enabled: true, planner: 'deepseek-v4-pro', executor: 'deepseek-v4-flash' };
|
|
105
137
|
}
|
|
106
138
|
|
|
107
|
-
const preset = modelPreset(model);
|
|
139
|
+
const preset = model ? modelPreset(model) : null;
|
|
108
140
|
const defaultBudget = preset?.budgetTokens ?? 128000;
|
|
109
141
|
const budgetInput = await io.ask(`上下文预算 tokens(回车默认 ${defaultBudget}):`);
|
|
110
142
|
const contextBudget = Number(budgetInput) > 0 ? Number(budgetInput) : defaultBudget;
|
|
@@ -112,18 +144,18 @@ export async function runWizard(io) {
|
|
|
112
144
|
// 注意:config.json 不含任何密钥(可安全分享/提交),密钥只存 credentials.json。
|
|
113
145
|
const cfg = {
|
|
114
146
|
provider,
|
|
115
|
-
model,
|
|
116
147
|
baseUrl: baseUrl || pp.baseUrl,
|
|
117
148
|
permission: perm,
|
|
118
149
|
sandbox,
|
|
119
150
|
contextBudget,
|
|
120
151
|
};
|
|
152
|
+
if (model) cfg.model = model;
|
|
121
153
|
if (routing) cfg.routing = routing;
|
|
122
154
|
saveConfig(cfg);
|
|
123
155
|
io.print('');
|
|
124
156
|
io.box('配置完成 ✓', [
|
|
125
157
|
`服务商 ${provider}(${pp.label})`,
|
|
126
|
-
`模型 ${model}
|
|
158
|
+
model ? `模型 ${model}` : '模型 (暂未选择,进入后 /model 随时切换)',
|
|
127
159
|
`权限 ${perm} · 沙箱 ${sandbox}`,
|
|
128
160
|
`路由 ${routing ? '自动(pro⇄flash)' : '关闭'}`,
|
|
129
161
|
`密钥 ${apiKey ? '凭证库 ' + maskKey(apiKey) : '环境变量 ' + (pp.envKey || 'MINGDAO_API_KEY')}`,
|
package/src/mcp.js
CHANGED
package/src/memory.js
CHANGED
|
@@ -171,7 +171,7 @@ export async function extractMemory(provider, model, messages, existingMemory) {
|
|
|
171
171
|
{
|
|
172
172
|
role: 'system',
|
|
173
173
|
content:
|
|
174
|
-
'你是 MingDao
|
|
174
|
+
'你是 MingDao Harness 的记忆提取器。从对话中提取值得长期记住的用户偏好与事实(工具链、代码风格、项目背景、个人约定、常用指令等)。每条 ≤30 字,只输出新条目(与「已有记忆」重复或对话中未提及的不要输出)。只输出 JSON:{"items": ["条目1", "条目2"]};没有新增时输出 {"items": []}。\n已有记忆:\n' +
|
|
175
175
|
(existingMemory || '(空)'),
|
|
176
176
|
},
|
|
177
177
|
{ role: 'user', content: convo.slice(0, 8000) },
|
package/src/model-discovery.js
CHANGED
|
@@ -84,7 +84,7 @@ export async function fetchProviderModels(cfg, providerName, { force = false } =
|
|
|
84
84
|
redirect: 'follow',
|
|
85
85
|
});
|
|
86
86
|
if (!res.ok) return { error: `HTTP ${res.status}` };
|
|
87
|
-
const j = await res.json().catch(() => null);
|
|
87
|
+
const j = /** @type {any} */ (await res.json().catch(() => null));
|
|
88
88
|
const list = (j?.data || [])
|
|
89
89
|
.map((m) => String(m?.id || '').trim())
|
|
90
90
|
.filter((id) => id && isChatModel(id))
|
package/src/prompts.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { skillsRegistryBlock } from './skills.js';
|
|
7
|
-
import { mingdaoHome } from './config.js';
|
|
7
|
+
import { mingdaoHome, loadConfig } from './config.js';
|
|
8
8
|
import { recentJournalBlock } from './memory.js';
|
|
9
9
|
|
|
10
10
|
const BASE = `你是 MingDao Harness,一个由 MingDao Harness 驱动的 AI 编程助手。你在用户的电脑上工作:通过工具读写文件、搜索代码、执行命令,帮助用户完成编程、调试与自动化任务。
|
|
@@ -29,6 +29,7 @@ function loadFile(p, cap) {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** @param {{ workingDir: any, withJournal?: boolean, [key: string]: any }} opts */
|
|
32
33
|
export function buildSystemPrompt({ workingDir, withJournal = false }) {
|
|
33
34
|
// 前缀字节稳定性(评估 P1-1/P1-2,四份评估一致的最高价值项):
|
|
34
35
|
// 系统提示不含「当前模型」「当前日期」等易变字段——DeepSeek 上下文缓存按前缀字节匹配,
|
|
@@ -50,9 +51,15 @@ export function buildSystemPrompt({ workingDir, withJournal = false }) {
|
|
|
50
51
|
// 技能清单(渐进披露:仅名称+描述,按需加载全文)
|
|
51
52
|
prompt += skillsRegistryBlock(workingDir);
|
|
52
53
|
|
|
53
|
-
// 项目约定(./AGENTS.md
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
// 项目约定(./AGENTS.md)——体积可配置(审计 MiniMax §3.3-B,v0.1.48 P0-D):
|
|
55
|
+
// 典型项目 6-12K 的 AGENTS.md 全量进 system 每轮按缓存价计费;默认截 4K,超长部分
|
|
56
|
+
// 模型可 read 工具按需读全文。config.maxAgentsMdChars 可调(0 表示不注入)。
|
|
57
|
+
const cfg = loadConfig();
|
|
58
|
+
const agentsMdCap = cfg && Number.isFinite(Number(cfg.maxAgentsMdChars)) ? Math.max(0, Number(cfg.maxAgentsMdChars)) : 4000;
|
|
59
|
+
if (agentsMdCap > 0) {
|
|
60
|
+
const agentsMd = loadFile(path.join(workingDir, 'AGENTS.md'), agentsMdCap);
|
|
61
|
+
if (agentsMd) prompt += `\n\n<agents_md>\n${agentsMd}\n</agents_md>`;
|
|
62
|
+
}
|
|
56
63
|
|
|
57
64
|
return prompt;
|
|
58
65
|
}
|
package/src/schedule.js
CHANGED
|
@@ -93,8 +93,8 @@ export function writeSchedule(home, job) {
|
|
|
93
93
|
return job;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
export function addSchedule(home, question, { at, every, after, permission, model, cwd, anchor, offpeak }) {
|
|
96
|
+
/** 新建调度任务;after: 依赖的任务 ID(全部成功后才启动,任一失败则跳过) */
|
|
97
|
+
export function addSchedule(home, question, /** @type {any} */ { at, every, after, permission, model, cwd, anchor, offpeak }) {
|
|
98
98
|
const id = 'sc' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
|
99
99
|
const interval = every != null ? parseInterval(every) : null;
|
|
100
100
|
const afterList = Array.isArray(after) ? after.filter(Boolean).map(String) : after ? String(after).split(',').map((x) => x.trim()).filter(Boolean) : [];
|
|
@@ -276,7 +276,11 @@ export function spawnDaemon(home) {
|
|
|
276
276
|
env: { ...process.env, MINGDAO_HOME: home },
|
|
277
277
|
});
|
|
278
278
|
try {
|
|
279
|
-
|
|
279
|
+
// 原子写(审计 workbuddy P3-4):tmp+rename 与 writeSchedule 同款——崩溃不留半截 pid 文件
|
|
280
|
+
const target = daemonPidFile(home);
|
|
281
|
+
const tmp = target + '.tmp';
|
|
282
|
+
fs.writeFileSync(tmp, `${child.pid} ${nonce}`);
|
|
283
|
+
fs.renameSync(tmp, target);
|
|
280
284
|
} catch {}
|
|
281
285
|
child.unref();
|
|
282
286
|
return true;
|
package/src/sync-server.js
CHANGED
|
@@ -193,7 +193,7 @@ async function doRegister(body) {
|
|
|
193
193
|
// 审计 P2-10:注册用进程内互斥,避免并发同名注册双双成功(后写覆盖)
|
|
194
194
|
if (!registerLock) {
|
|
195
195
|
registerLock = new Promise((resolve) => {
|
|
196
|
-
queueMicrotask(resolve);
|
|
196
|
+
queueMicrotask(() => resolve(undefined));
|
|
197
197
|
});
|
|
198
198
|
}
|
|
199
199
|
const prev = registerLock;
|
|
@@ -530,6 +530,7 @@ async function handle(req, res) {
|
|
|
530
530
|
}
|
|
531
531
|
|
|
532
532
|
// ---------- 启动 ----------
|
|
533
|
+
/** @param {{ port?: any, host?: any, dataDir?: any, cert?: any, key?: any }} [opts] */
|
|
533
534
|
export function runSyncServer({ port, host, dataDir, cert, key } = {}) {
|
|
534
535
|
const dir = dataDir || DEFAULT_DATA_DIR;
|
|
535
536
|
ACTIVE_DIR = dir;
|
package/src/sync.js
CHANGED
|
@@ -63,7 +63,7 @@ async function apiCall(baseUrl, method, payload, token, timeoutMs = TIMEOUT_MS,
|
|
|
63
63
|
if (insecure) {
|
|
64
64
|
const { status, json: j } = await rawRequest(target, { headers, body, timeoutMs, insecure: true });
|
|
65
65
|
if (status !== 200) {
|
|
66
|
-
const err = new Error(j.error || `HTTP ${status}`);
|
|
66
|
+
const err = /** @type {Error & { status?: number, body?: any }} */ (new Error(j.error || `HTTP ${status}`));
|
|
67
67
|
err.status = status;
|
|
68
68
|
err.body = j;
|
|
69
69
|
throw err;
|
|
@@ -79,9 +79,9 @@ async function apiCall(baseUrl, method, payload, token, timeoutMs = TIMEOUT_MS,
|
|
|
79
79
|
body,
|
|
80
80
|
signal: ctrl.signal,
|
|
81
81
|
});
|
|
82
|
-
const j = await res.json().catch(() => ({}));
|
|
82
|
+
const j = /** @type {any} */ (await res.json().catch(() => ({})));
|
|
83
83
|
if (!res.ok) {
|
|
84
|
-
const err = new Error(j.error || `HTTP ${res.status}`);
|
|
84
|
+
const err = /** @type {Error & { status?: number, body?: any }} */ (new Error(j.error || `HTTP ${res.status}`));
|
|
85
85
|
err.status = res.status;
|
|
86
86
|
err.body = j;
|
|
87
87
|
throw err;
|
package/src/tasks.js
CHANGED
|
@@ -54,7 +54,7 @@ export function isValidTaskId(id) {
|
|
|
54
54
|
return typeof id === 'string' && /^[a-z0-9]+$/.test(id) && id.length >= 4 && id.length <= 40;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export function startTask(home, question, { permission, model, cwd, offpeak } = {}) {
|
|
57
|
+
export function startTask(home, question, { permission, model, cwd, offpeak } = /** @type {any} */ ({})) {
|
|
58
58
|
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + process.pid.toString(36);
|
|
59
59
|
const task = {
|
|
60
60
|
id,
|
package/src/tools/bash.js
CHANGED
|
@@ -43,8 +43,35 @@ export function detectSandbox() {
|
|
|
43
43
|
return sandboxSupport;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// 输出折叠(审计 MiniMax §3.3-E / v0.1.48 P1-G):模型回填的 bash 输出先折叠再截断——
|
|
47
|
+
// 1) 剥离 ANSI 转义序列(CSI/OSC);2) 连续重复行(>3 行相同)折叠为「首行 + 重复标记」。
|
|
48
|
+
// npm install 类输出通常 30-50KB → 折叠后 5-10KB,单次工具回填省 60-70% prompt token。
|
|
49
|
+
function stripAnsi(s) {
|
|
50
|
+
return s
|
|
51
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
52
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
|
|
53
|
+
}
|
|
54
|
+
function foldRepeats(s) {
|
|
55
|
+
const lines = s.split('\n');
|
|
56
|
+
const out = [];
|
|
57
|
+
let i = 0;
|
|
58
|
+
while (i < lines.length) {
|
|
59
|
+
let j = i;
|
|
60
|
+
while (j + 1 < lines.length && lines[j + 1] === lines[i]) j += 1;
|
|
61
|
+
const run = j - i + 1;
|
|
62
|
+
if (run > 3) {
|
|
63
|
+
out.push(lines[i], `…(以上重复 ${run} 行,已折叠)`);
|
|
64
|
+
i = j + 1;
|
|
65
|
+
} else {
|
|
66
|
+
out.push(lines[i]);
|
|
67
|
+
i += 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out.join('\n');
|
|
71
|
+
}
|
|
46
72
|
function tail(s, n) {
|
|
47
|
-
|
|
73
|
+
const folded = foldRepeats(stripAnsi(s));
|
|
74
|
+
return folded.length > n ? `…[输出过长,已截断头部]\n${folded.slice(-n)}` : folded;
|
|
48
75
|
}
|
|
49
76
|
|
|
50
77
|
export function runBash(args, ctx) {
|
package/src/update.js
CHANGED
|
@@ -111,7 +111,7 @@ const NPM_HINT =
|
|
|
111
111
|
'源码包安装请重新运行安装脚本(install.sh);或从任意平台仓库克隆后 `npm link`:' +
|
|
112
112
|
'https://gitee.com/MingDaoTCM/MingDao-harness · https://gitcode.com/MingDaoTCM/MingDao-Harness · https://github.com/MingDaoTCM/MingDao-Harness';
|
|
113
113
|
|
|
114
|
-
export async function updateCheck({ repo } = {}) {
|
|
114
|
+
export async function updateCheck({ repo } = /** @type {any} */ ({})) {
|
|
115
115
|
const lines = [];
|
|
116
116
|
const root = resolveRepo(repo);
|
|
117
117
|
if (!root) return { ok: false, lines: [NPM_HINT] };
|
|
@@ -130,7 +130,7 @@ export async function updateCheck({ repo } = {}) {
|
|
|
130
130
|
};
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
export async function mingdaoUpdate({ repo } = {}) {
|
|
133
|
+
export async function mingdaoUpdate({ repo } = /** @type {any} */ ({})) {
|
|
134
134
|
const lines = [];
|
|
135
135
|
const root = resolveRepo(repo);
|
|
136
136
|
if (!root) return { ok: false, lines: [NPM_HINT] };
|
|
@@ -195,7 +195,7 @@ export async function mingdaoUpdate({ repo } = {}) {
|
|
|
195
195
|
};
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
-
export function mingdaoRollback({ repo } = {}) {
|
|
198
|
+
export function mingdaoRollback({ repo } = /** @type {any} */ ({})) {
|
|
199
199
|
const root = resolveRepo(repo);
|
|
200
200
|
if (!root) return { ok: false, lines: [NPM_HINT] };
|
|
201
201
|
const st = readState();
|
package/src/web/index.html
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'">
|
|
6
7
|
<title>MingDao Harness</title>
|
|
7
8
|
<link rel="manifest" href="/manifest.webmanifest">
|
|
8
9
|
<link rel="icon" href="/icon-192.png" type="image/png">
|
|
@@ -118,6 +119,8 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
|
|
|
118
119
|
.cfg-row select,.cfg-row input[type=number]{background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px}
|
|
119
120
|
.cfg-hint{font-size:12px;color:var(--faint);margin-top:4px}
|
|
120
121
|
.modal .row{display:flex;gap:10px;justify-content:flex-end}
|
|
122
|
+
.dir-entry{padding:6px 10px;border-radius:6px;cursor:pointer;font-size:13.5px;word-break:break-all}
|
|
123
|
+
.dir-entry:hover{background:var(--bg3)}
|
|
121
124
|
.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--accent);border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite;vertical-align:-2px}
|
|
122
125
|
@keyframes spin{to{transform:rotate(360deg)}}
|
|
123
126
|
</style>
|
|
@@ -187,7 +190,7 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
|
|
|
187
190
|
<h4 style="margin:4px 0;color:var(--accent2)">云同步(跨设备会话同步,服务器端运行 mingdao sync-server)</h4>
|
|
188
191
|
<div id="syncStatus" style="font-size:12.5px;color:var(--dim);padding:2px"></div>
|
|
189
192
|
<div class="cfg-row" style="display:flex;gap:6px;flex-wrap:wrap">
|
|
190
|
-
<input id="syncUrl" placeholder="
|
|
193
|
+
<input id="syncUrl" placeholder="默认 https://session.mingdao.ai/" style="flex:1.4;min-width:150px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
|
|
191
194
|
<input id="syncUser" placeholder="用户名" style="flex:1;min-width:90px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
|
|
192
195
|
</div>
|
|
193
196
|
<div class="cfg-row" style="display:flex;gap:6px;flex-wrap:wrap">
|
|
@@ -268,6 +271,19 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
|
|
|
268
271
|
<div class="row"><button id="cfgCancel">取消</button><button class="primary" id="cfgSave">保存</button></div>
|
|
269
272
|
</div>
|
|
270
273
|
</div>
|
|
274
|
+
<!-- 目录选择器(新建工作空间 / 改目录:浏览服务器磁盘目录树) -->
|
|
275
|
+
<div id="dirModal" class="modal-mask" style="display:none">
|
|
276
|
+
<div class="modal" style="width:min(520px,92vw);display:flex;flex-direction:column;max-height:78vh">
|
|
277
|
+
<h4 style="margin:0 0 6px;color:var(--cyan)">选择目录(点目录进入,点「使用此目录」确认)</h4>
|
|
278
|
+
<div id="dirPath" style="font-size:12px;color:var(--dim);word-break:break-all;margin-bottom:8px;background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:6px 10px"></div>
|
|
279
|
+
<div id="dirList" style="flex:1;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:6px;min-height:220px"></div>
|
|
280
|
+
<div class="row" style="margin-top:12px">
|
|
281
|
+
<button id="dirPickNone" class="ghost">不指定(当前目录)</button>
|
|
282
|
+
<button id="dirPickCancel" class="ghost">取消</button>
|
|
283
|
+
<button id="dirPickOk" class="primary">使用此目录</button>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
271
287
|
<script>
|
|
272
288
|
// 访问令牌(P1-3):地址带 ?token= 时记入 sessionStorage 并从地址栏移除(防截图/历史外泄),
|
|
273
289
|
// 之后所有同源请求统一附加 X-MingDao-Token 头;无令牌时行为与旧版一致。
|
|
@@ -290,6 +306,53 @@ if (AUTH_TOKEN) {
|
|
|
290
306
|
};
|
|
291
307
|
}
|
|
292
308
|
const $ = (s) => document.querySelector(s);
|
|
309
|
+
// —— 目录选择器(新建工作空间 / 改目录):浏览服务器磁盘目录树 ——
|
|
310
|
+
let pickerCb = null, pickerDir = '/';
|
|
311
|
+
function loadDirList(){
|
|
312
|
+
$('#dirPath').textContent = pickerDir;
|
|
313
|
+
const box = $('#dirList');
|
|
314
|
+
fetch('/api/fs-browse?dir=' + encodeURIComponent(pickerDir), { cache: 'no-store' })
|
|
315
|
+
.then((r) => r.json())
|
|
316
|
+
.then((j) => {
|
|
317
|
+
box.innerHTML = '';
|
|
318
|
+
if (!j.ok) {
|
|
319
|
+
const e = document.createElement('div'); e.className = 'dir-entry'; e.style.color = 'var(--err)';
|
|
320
|
+
e.textContent = j.error || '无法读取该目录'; box.appendChild(e); return;
|
|
321
|
+
}
|
|
322
|
+
if (j.parent != null) {
|
|
323
|
+
const up = document.createElement('div'); up.className = 'dir-entry'; up.textContent = '⬆ …(上级目录)';
|
|
324
|
+
up.onclick = () => { pickerDir = j.parent; loadDirList(); };
|
|
325
|
+
box.appendChild(up);
|
|
326
|
+
}
|
|
327
|
+
if (!(j.entries || []).length) {
|
|
328
|
+
const e = document.createElement('div'); e.className = 'dir-entry'; e.style.color = 'var(--faint)';
|
|
329
|
+
e.textContent = '(无子目录)'; box.appendChild(e);
|
|
330
|
+
}
|
|
331
|
+
for (const en of j.entries || []) {
|
|
332
|
+
const d = document.createElement('div'); d.className = 'dir-entry'; d.textContent = '📁 ' + en.name;
|
|
333
|
+
d.onclick = () => { pickerDir = en.path; loadDirList(); };
|
|
334
|
+
box.appendChild(d);
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
.catch(() => {
|
|
338
|
+
box.innerHTML = '<div class="dir-entry" style="color:var(--err)">请求失败(请重试)</div>';
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
function openDirPicker(startDir, cb){
|
|
342
|
+
pickerCb = cb;
|
|
343
|
+
if (startDir) { pickerDir = startDir; loadDirList(); }
|
|
344
|
+
else {
|
|
345
|
+
// 无指定起点:从服务器当前工作目录开始浏览
|
|
346
|
+
fetch('/api/workspaces', { cache: 'no-store' }).then((r) => r.json()).then((j) => {
|
|
347
|
+
pickerDir = (j && j.cwd) || '/';
|
|
348
|
+
loadDirList();
|
|
349
|
+
}).catch(() => { pickerDir = '/'; loadDirList(); });
|
|
350
|
+
}
|
|
351
|
+
$('#dirModal').style.display = 'flex';
|
|
352
|
+
}
|
|
353
|
+
$('#dirPickOk').onclick = () => { const cb = pickerCb; pickerCb = null; $('#dirModal').style.display = 'none'; if (cb) cb(pickerDir); };
|
|
354
|
+
$('#dirPickNone').onclick = () => { const cb = pickerCb; pickerCb = null; $('#dirModal').style.display = 'none'; if (cb) cb(null); };
|
|
355
|
+
$('#dirPickCancel').onclick = () => { pickerCb = null; $('#dirModal').style.display = 'none'; };
|
|
293
356
|
const chatEl = $('#chat'), input = $('#input'), sendBtn = $('#sendBtn');
|
|
294
357
|
let generating = false; // 生成中:发送按钮复用为停止按钮
|
|
295
358
|
let currentSession = null, thinking = null;
|
|
@@ -636,11 +699,13 @@ $('#wsSel').addEventListener('change', async e=>{
|
|
|
636
699
|
const v=e.target.value;
|
|
637
700
|
if(v==='__add__'){
|
|
638
701
|
const name=prompt('新工作空间名称:'); if(!name){ refreshWsSel(); return; }
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
702
|
+
openDirPicker(null, (dir)=>{
|
|
703
|
+
fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'add',name,dir:dir||undefined})})
|
|
704
|
+
.then((r)=>r.json()).then((j)=>{
|
|
705
|
+
if(j.ok) renderBanner({text:'✓ 已登记工作空间 '+j.name+' → '+j.dir+'(目录已自动创建)'}); else alert(j.error||'创建失败');
|
|
706
|
+
refreshWsSel(); reloadModels();
|
|
707
|
+
});
|
|
708
|
+
});
|
|
644
709
|
return;
|
|
645
710
|
}
|
|
646
711
|
if(v==='__manage__'){
|
|
@@ -697,7 +762,7 @@ async function refreshWorkspaces(){
|
|
|
697
762
|
for(const w of j.workspaces){
|
|
698
763
|
const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid var(--border);font-size:12px';
|
|
699
764
|
div.innerHTML='<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(w.name)+(w.name===j.current?' <span style="color:var(--accent)">●当前</span>':'')+'</span><span style="flex:1.4;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left">'+esc(w.dir||'')+'</span>';
|
|
700
|
-
const ed=document.createElement('button'); ed.textContent='改目录'; ed.style.cssText='padding:1px 8px;font-size:11px'; ed.onclick=
|
|
765
|
+
const ed=document.createElement('button'); ed.textContent='改目录'; ed.style.cssText='padding:1px 8px;font-size:11px'; ed.onclick=()=>{ openDirPicker(w.dir||null, async (d)=>{ if(d==null) return; const rr=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'set',name:w.name,dir:d})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshWorkspaces(); refreshWsSel(); reloadModels(); } else alert(jj.error||'修改失败'); }); };
|
|
701
766
|
const rn=document.createElement('button'); rn.textContent='重命名'; rn.style.cssText='padding:1px 8px;font-size:11px'; rn.onclick=async()=>{ const t=prompt('新名称:', w.name); if(!t) return; const rr=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'rename',name:w.name,newName:t})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshWorkspaces(); refreshWsSel(); } else alert(jj.error||'重命名失败'); };
|
|
702
767
|
const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=async()=>{ if(!confirm('删除工作空间 '+w.name+'?')) return; await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'remove',name:w.name})}); refreshWorkspaces(); refreshWsSel(); reloadModels(); };
|
|
703
768
|
div.appendChild(ed); div.appendChild(rn); div.appendChild(rm); list.appendChild(div);
|
|
@@ -861,7 +926,7 @@ $('#baseUrlSave').onclick=async ()=>{
|
|
|
861
926
|
async function refreshSyncUI(){
|
|
862
927
|
const r=await fetch('/api/sync',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
863
928
|
const j=await r.json();
|
|
864
|
-
$('#syncUrl').value=j.url||''; $('#syncUser').value=j.username||''; $('#syncDevice').value=j.deviceName||''; $('#syncAutoChk').checked=Boolean(j.auto);
|
|
929
|
+
$('#syncUrl').value=j.url||'https://session.mingdao.ai/'; $('#syncUser').value=j.username||''; $('#syncDevice').value=j.deviceName||''; $('#syncAutoChk').checked=Boolean(j.auto);
|
|
865
930
|
const st=$('#syncStatus');
|
|
866
931
|
if(!j.configured) st.innerHTML='<span style="color:var(--faint)">未配置:填服务器地址并登录(服务器端运行 mingdao sync-server)</span>';
|
|
867
932
|
else if(!j.loggedIn) st.innerHTML='<span style="color:var(--warn)">已配置 '+esc(j.url)+' · 未登录(输入密码登录)</span>';
|
package/src/web/server.js
CHANGED
|
@@ -74,7 +74,7 @@ function readBody(req) {
|
|
|
74
74
|
let size = 0;
|
|
75
75
|
// 审计 P2-6:慢速连接防护——60s 未传完请求体即断开,防占满 socket
|
|
76
76
|
const slowTimer = setTimeout(() => {
|
|
77
|
-
const err = new Error('请求体上传超时(60s)');
|
|
77
|
+
const err = /** @type {Error & { status?: number }} */ (new Error('请求体上传超时(60s)'));
|
|
78
78
|
err.status = 408;
|
|
79
79
|
req.destroy();
|
|
80
80
|
reject(err);
|
|
@@ -84,7 +84,7 @@ function readBody(req) {
|
|
|
84
84
|
req.on('data', (d) => {
|
|
85
85
|
size += d.length;
|
|
86
86
|
if (size > MAX_BODY) {
|
|
87
|
-
const err = new Error('请求体过大(>40MB)');
|
|
87
|
+
const err = /** @type {Error & { status?: number }} */ (new Error('请求体过大(>40MB)'));
|
|
88
88
|
err.status = 413;
|
|
89
89
|
req.destroy();
|
|
90
90
|
reject(err);
|
|
@@ -104,6 +104,7 @@ function readBody(req) {
|
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/** @param {{ host?: string, port?: number, authToken?: string|null, [key: string]: any }} [opts] */
|
|
107
108
|
export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken } = {}) {
|
|
108
109
|
const home = ensureHome();
|
|
109
110
|
const cfg = loadConfig();
|
|
@@ -891,7 +892,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
891
892
|
}
|
|
892
893
|
}
|
|
893
894
|
if (req.method === 'GET' && p === '/api/workspaces') {
|
|
894
|
-
json(res, 200, { ok: true, workspaces: listWorkspaces(), current: currentWorkspace(workingDir)?.name || null });
|
|
895
|
+
json(res, 200, { ok: true, workspaces: listWorkspaces(), current: currentWorkspace(workingDir)?.name || null, cwd: workingDir });
|
|
895
896
|
return;
|
|
896
897
|
}
|
|
897
898
|
if (req.method === 'POST' && p === '/api/workspaces') {
|
|
@@ -943,6 +944,27 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
943
944
|
}
|
|
944
945
|
return json(res, 400, { error: '未知操作:add|rename|set|remove' });
|
|
945
946
|
}
|
|
947
|
+
// 目录浏览器(「新建工作空间」选择电脑磁盘目录用):本机运行时即用户电脑的目录树;
|
|
948
|
+
// 只列子目录(不含隐藏目录),供前端逐级导航选择
|
|
949
|
+
if (req.method === 'GET' && p === '/api/fs-browse') {
|
|
950
|
+
let dir = String(url.searchParams.get('dir') || '').trim();
|
|
951
|
+
if (!path.isAbsolute(dir)) return json(res, 400, { error: '需要绝对路径' });
|
|
952
|
+
try {
|
|
953
|
+
const st = fs.statSync(dir);
|
|
954
|
+
if (!st.isDirectory()) return json(res, 400, { error: '不是目录' });
|
|
955
|
+
const entries = fs
|
|
956
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
957
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
958
|
+
.map((e) => ({ name: e.name, path: path.join(dir, e.name) }))
|
|
959
|
+
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
|
|
960
|
+
.slice(0, 300);
|
|
961
|
+
const parent = path.dirname(dir);
|
|
962
|
+
json(res, 200, { ok: true, path: dir, parent: parent === dir ? null : parent, entries });
|
|
963
|
+
} catch (err) {
|
|
964
|
+
json(res, 400, { error: String(err?.message || err) });
|
|
965
|
+
}
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
946
968
|
if (req.method === 'GET' && p === '/api/memory') {
|
|
947
969
|
json(res, 200, { ok: true, content: loadMemory() });
|
|
948
970
|
return;
|
|
@@ -1138,7 +1160,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
1138
1160
|
});
|
|
1139
1161
|
});
|
|
1140
1162
|
|
|
1141
|
-
server.on('error', (err) => {
|
|
1163
|
+
server.on('error', (/** @type {Error & { code?: string }} */ err) => {
|
|
1142
1164
|
if (err.code === 'EADDRINUSE') {
|
|
1143
1165
|
console.error(`[MingDao] 端口 ${port} 已被占用,请换一个端口:mingdao web <端口号>`);
|
|
1144
1166
|
} else {
|
|
@@ -1148,7 +1170,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
1148
1170
|
});
|
|
1149
1171
|
|
|
1150
1172
|
server.listen(port, host, () => {
|
|
1151
|
-
const actual = server.address().port;
|
|
1173
|
+
const actual = /** @type {import('node:net').AddressInfo} */ (server.address()).port;
|
|
1152
1174
|
boundPort = actual;
|
|
1153
1175
|
const displayHost = host === '0.0.0.0' ? '127.0.0.1' : host;
|
|
1154
1176
|
console.log('');
|