mingdao-harness 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/EVALUATION-ROUND2.md +58 -0
- package/docs/ROADMAP-NEXT.md +5 -5
- package/package.json +2 -2
- package/src/agent.js +74 -36
- package/src/atomic-write.js +8 -2
- package/src/audit.js +2 -2
- package/src/batch.js +7 -7
- package/src/cachestats.js +9 -9
- package/src/cli.js +42 -688
- package/src/commands/desktop.js +1 -1
- package/src/commands/key.js +1 -1
- package/src/commands/repl.js +711 -0
- package/src/commands/schedule.js +7 -7
- package/src/commands/skill.js +18 -16
- package/src/commands/sync.js +5 -5
- package/src/commands/update.js +7 -6
- package/src/commands/workspace.js +2 -2
- package/src/compact.js +6 -6
- package/src/config.js +7 -4
- package/src/context.js +37 -21
- package/src/cost-guard.js +14 -6
- package/src/credentials.js +6 -0
- package/src/hooks.js +11 -11
- package/src/mcp-presets.js +5 -5
- package/src/mcp.js +19 -19
- package/src/memory.js +16 -16
- package/src/model-discovery.js +16 -15
- package/src/models.js +4 -4
- package/src/notify.js +2 -2
- package/src/permissions.js +7 -3
- package/src/pricing.js +35 -1
- package/src/prompts.js +1 -1
- package/src/providers/index.js +7 -7
- package/src/providers/openai-compatible.js +13 -11
- package/src/routing.js +8 -8
- package/src/schedule.js +30 -30
- package/src/session-index.js +5 -1
- package/src/session.js +11 -2
- package/src/skill-lib.js +62 -3
- package/src/skill-registry.js +10 -9
- package/src/skills.js +14 -12
- package/src/sync-server.js +86 -3
- package/src/sync.js +53 -20
- package/src/tasks/worker.js +126 -0
- package/src/tasks.js +14 -14
- package/src/titles.js +5 -5
- package/src/tokenizer.js +23 -5
- package/src/tools/bash.js +12 -12
- package/src/tools/fs-tools.js +66 -9
- package/src/tools/index.js +7 -7
- package/src/ui.js +83 -6
- package/src/update.js +8 -8
- package/src/web/app.js +16 -5
- package/src/web/attachments.js +1 -1
- package/src/web/index.html +21 -1
- package/src/web/routes/api.js +38 -786
- package/src/web/routes/domains/config.js +280 -0
- package/src/web/routes/domains/misc.js +129 -0
- package/src/web/routes/domains/schedule.js +127 -0
- package/src/web/routes/domains/sessions.js +105 -0
- package/src/web/routes/domains/skills.js +79 -0
- package/src/web/routes/domains/sync.js +101 -0
- package/src/web/routes/domains/workspace.js +119 -0
- package/src/web/server.js +73 -31
- package/src/web/web-io.js +54 -12
- package/src/workspace.js +14 -14
package/src/sync.js
CHANGED
|
@@ -23,15 +23,21 @@ const insecureOn = () => syncSettings()?.insecure === true;
|
|
|
23
23
|
|
|
24
24
|
// 请求级不安全 TLS 传输(P1-6):为本次请求单独建立 rejectUnauthorized:false 的 https.Agent,
|
|
25
25
|
// 与并发的 provider 请求(各自的 TLS 校验)互不干扰;安全路径仍走 fetch(连接复用)。
|
|
26
|
+
/**
|
|
27
|
+
* @param {any} target
|
|
28
|
+
* @param {{ headers?: any, body?: any, timeoutMs?: any, insecure?: any }} opts
|
|
29
|
+
*/
|
|
26
30
|
function rawRequest(target, { headers, body, timeoutMs, insecure }) {
|
|
27
31
|
return new Promise((resolve, reject) => {
|
|
28
32
|
const mod = target.protocol === 'http:' ? http : https;
|
|
33
|
+
/** @type {any} */
|
|
29
34
|
const opts = { method: 'POST', headers };
|
|
30
35
|
if (insecure && target.protocol === 'https:') opts.agent = new https.Agent({ rejectUnauthorized: false });
|
|
31
36
|
const req = mod.request(target, opts, (res) => {
|
|
37
|
+
/** @type {any[]} */
|
|
32
38
|
const chunks = [];
|
|
33
39
|
let size = 0;
|
|
34
|
-
res.on('data', (d) => {
|
|
40
|
+
res.on('data', (/** @type {any} */ d) => {
|
|
35
41
|
size += d.length;
|
|
36
42
|
if (size > 25 * 1024 * 1024) {
|
|
37
43
|
req.destroy();
|
|
@@ -56,6 +62,12 @@ function rawRequest(target, { headers, body, timeoutMs, insecure }) {
|
|
|
56
62
|
});
|
|
57
63
|
}
|
|
58
64
|
|
|
65
|
+
/**
|
|
66
|
+
* @param {any} baseUrl
|
|
67
|
+
* @param {any} method
|
|
68
|
+
* @param {any} payload
|
|
69
|
+
* @param {any} token
|
|
70
|
+
*/
|
|
59
71
|
async function apiCall(baseUrl, method, payload, token, timeoutMs = TIMEOUT_MS, insecure = false) {
|
|
60
72
|
const target = new URL(baseUrl.replace(/\/+$/, '') + method);
|
|
61
73
|
const body = JSON.stringify(payload || {});
|
|
@@ -92,7 +104,7 @@ async function apiCall(baseUrl, method, payload, token, timeoutMs = TIMEOUT_MS,
|
|
|
92
104
|
clearTimeout(timer);
|
|
93
105
|
}
|
|
94
106
|
} catch (e) {
|
|
95
|
-
if (e.name === 'AbortError' || e.message === '请求超时') throw new Error('请求超时(20s)');
|
|
107
|
+
if (/** @type {any} */ (e).name === 'AbortError' || /** @type {any} */ (e).message === '请求超时') throw new Error('请求超时(20s)');
|
|
96
108
|
throw e;
|
|
97
109
|
}
|
|
98
110
|
}
|
|
@@ -120,6 +132,9 @@ export function syncStatus() {
|
|
|
120
132
|
}
|
|
121
133
|
|
|
122
134
|
// 登录/注册 + 设备配对:注册失败(用户不存在)先注册再配对
|
|
135
|
+
/**
|
|
136
|
+
* @param {{ url?: any, username?: any, password?: any, deviceName?: any, insecure?: any }} opts
|
|
137
|
+
*/
|
|
123
138
|
export async function syncLogin({ url, username, password, deviceName, insecure = false }) {
|
|
124
139
|
const base = String(url || '').trim().replace(/\/+$/, '');
|
|
125
140
|
if (!/^https?:\/\//i.test(base)) return { error: '服务器地址需以 http(s):// 开头' };
|
|
@@ -133,8 +148,8 @@ export async function syncLogin({ url, username, password, deviceName, insecure
|
|
|
133
148
|
try {
|
|
134
149
|
pair = await apiCall(base, '/api/pair', { username: name, password, deviceName: dev }, undefined, TIMEOUT_MS, loginInsecure);
|
|
135
150
|
} catch (e) {
|
|
136
|
-
if (e.status === 401) return { error: '用户名或密码错误' };
|
|
137
|
-
if (e.status !== 404) return { error: `连接失败:${e.message}` };
|
|
151
|
+
if (/** @type {any} */ (e).status === 401) return { error: '用户名或密码错误' };
|
|
152
|
+
if (/** @type {any} */ (e).status !== 404) return { error: `连接失败:${/** @type {any} */ (e).message}` };
|
|
138
153
|
pair = { notFound: true }; // 用户不存在 → 先注册
|
|
139
154
|
}
|
|
140
155
|
if (pair.notFound) {
|
|
@@ -152,9 +167,9 @@ export async function syncLogin({ url, username, password, deviceName, insecure
|
|
|
152
167
|
saveCredentials(creds);
|
|
153
168
|
return { ok: true, username: name, deviceName: dev, url: base };
|
|
154
169
|
} catch (e) {
|
|
155
|
-
if (e.status === 401) return { error: '用户名或密码错误' };
|
|
156
|
-
if (e.status === 404) return { error: '服务器接口不存在(确认是 mingdao 同步服务端)' };
|
|
157
|
-
return { error: `连接失败:${e.message}` };
|
|
170
|
+
if (/** @type {any} */ (e).status === 401) return { error: '用户名或密码错误' };
|
|
171
|
+
if (/** @type {any} */ (e).status === 404) return { error: '服务器接口不存在(确认是 mingdao 同步服务端)' };
|
|
172
|
+
return { error: `连接失败:${/** @type {any} */ (e).message}` };
|
|
158
173
|
}
|
|
159
174
|
}
|
|
160
175
|
|
|
@@ -190,15 +205,20 @@ export async function syncRemoteList() {
|
|
|
190
205
|
const r = await apiCall(g.url, '/api/sessions/list', {}, g.token, TIMEOUT_MS, insecureOn());
|
|
191
206
|
return { ok: true, sessions: r.sessions || [] };
|
|
192
207
|
} catch (e) {
|
|
193
|
-
return { error: e.message };
|
|
208
|
+
return { error: /** @type {any} */ (e).message };
|
|
194
209
|
}
|
|
195
210
|
}
|
|
196
211
|
|
|
197
212
|
// 会话名白名单(与 sync-server 同款):远端返回的名字不满足即拒绝落盘,防恶意服务器任意文件写
|
|
213
|
+
/** @param {any} n */
|
|
198
214
|
function isValidRemoteName(n) {
|
|
199
215
|
return typeof n === 'string' && /^[\w\u4e00-\u9fa5.-]{1,140}\.jsonl$/.test(n) && !n.includes('..');
|
|
200
216
|
}
|
|
201
217
|
// 冲突副本名:时间戳 + 随机后缀,防同一毫秒冲突静默覆盖
|
|
218
|
+
/**
|
|
219
|
+
* @param {any} base
|
|
220
|
+
* @param {any} side
|
|
221
|
+
*/
|
|
202
222
|
function conflictCopyName(base, side) {
|
|
203
223
|
return base.replace(/\.jsonl$/, `.${side}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.jsonl`);
|
|
204
224
|
}
|
|
@@ -214,6 +234,7 @@ function readState() {
|
|
|
214
234
|
return {};
|
|
215
235
|
}
|
|
216
236
|
}
|
|
237
|
+
/** @param {any} state */
|
|
217
238
|
function writeState(state) {
|
|
218
239
|
ensureHome();
|
|
219
240
|
const target = stateFile();
|
|
@@ -221,6 +242,7 @@ function writeState(state) {
|
|
|
221
242
|
}
|
|
222
243
|
|
|
223
244
|
// 推送单个/全部会话。仅当远端被其他设备改过(mtime 与本地记录不一致且内容不同)才视为冲突并备份远端。
|
|
245
|
+
/** @param {any} [name] */
|
|
224
246
|
export async function syncPush(name) {
|
|
225
247
|
const g = tokenGuard();
|
|
226
248
|
if (g.error) return { error: g.error };
|
|
@@ -257,7 +279,7 @@ export async function syncPush(name) {
|
|
|
257
279
|
try {
|
|
258
280
|
remote = await apiCall(g.url, '/api/sessions/pull', { name: s.name }, g.token, TIMEOUT_MS, insecureOn());
|
|
259
281
|
} catch (e) {
|
|
260
|
-
if (e.status !== 404) return { error: `读取远端 ${s.name} 失败:${e.message}` };
|
|
282
|
+
if (/** @type {any} */ (e).status !== 404) return { error: `读取远端 ${s.name} 失败:${/** @type {any} */ (e).message}` };
|
|
261
283
|
}
|
|
262
284
|
if (remote?.ok) {
|
|
263
285
|
const lastMtime = state[s.name]?.remoteMtime;
|
|
@@ -275,7 +297,7 @@ export async function syncPush(name) {
|
|
|
275
297
|
state[s.name] = { remoteMtime: r.mtime, localMtime: localStat.mtimeMs };
|
|
276
298
|
}
|
|
277
299
|
} catch (e) {
|
|
278
|
-
return { error: `推送 ${s.name} 失败:${e.message}` };
|
|
300
|
+
return { error: `推送 ${s.name} 失败:${/** @type {any} */ (e).message}` };
|
|
279
301
|
}
|
|
280
302
|
}
|
|
281
303
|
writeState(state);
|
|
@@ -283,6 +305,7 @@ export async function syncPush(name) {
|
|
|
283
305
|
}
|
|
284
306
|
|
|
285
307
|
// 拉取单个/全部会话。本地有不同内容时保留本地,远端写入 .remote- 副本。
|
|
308
|
+
/** @param {any} [name] */
|
|
286
309
|
export async function syncPull(name) {
|
|
287
310
|
const g = tokenGuard();
|
|
288
311
|
if (g.error) return { error: g.error };
|
|
@@ -290,9 +313,9 @@ export async function syncPull(name) {
|
|
|
290
313
|
let sessions;
|
|
291
314
|
try {
|
|
292
315
|
const r = await apiCall(g.url, '/api/sessions/list', {}, g.token, TIMEOUT_MS, insecureOn());
|
|
293
|
-
sessions = (r.sessions || []).filter((s) => !name || s.name === name);
|
|
316
|
+
sessions = (r.sessions || []).filter((/** @type {any} */ s) => !name || s.name === name);
|
|
294
317
|
} catch (e) {
|
|
295
|
-
return { error: `获取远端清单失败:${e.message}` };
|
|
318
|
+
return { error: `获取远端清单失败:${/** @type {any} */ (e).message}` };
|
|
296
319
|
}
|
|
297
320
|
if (!sessions.length) return { error: name ? `远端没有会话 ${name}` : '远端没有会话' };
|
|
298
321
|
const state = readState();
|
|
@@ -330,6 +353,9 @@ export async function syncPull(name) {
|
|
|
330
353
|
}
|
|
331
354
|
|
|
332
355
|
// ---------- 密码修改 ----------
|
|
356
|
+
/**
|
|
357
|
+
* @param {{ oldPassword?: any, newPassword?: any }} opts
|
|
358
|
+
*/
|
|
333
359
|
export async function syncChangePassword({ oldPassword, newPassword }) {
|
|
334
360
|
const g = tokenGuard();
|
|
335
361
|
if (g.error) return { error: g.error };
|
|
@@ -338,12 +364,13 @@ export async function syncChangePassword({ oldPassword, newPassword }) {
|
|
|
338
364
|
const r = await apiCall(g.url, '/api/password', { oldPassword: oldPassword || '', newPassword }, g.token, TIMEOUT_MS, insecureOn());
|
|
339
365
|
return r.ok ? { ok: true } : { error: r.error || '修改失败' };
|
|
340
366
|
} catch (e) {
|
|
341
|
-
if (e.status === 401) return { error: '旧密码错误' };
|
|
342
|
-
return { error: `修改失败:${e.message}` };
|
|
367
|
+
if (/** @type {any} */ (e).status === 401) return { error: '旧密码错误' };
|
|
368
|
+
return { error: `修改失败:${/** @type {any} */ (e).message}` };
|
|
343
369
|
}
|
|
344
370
|
}
|
|
345
371
|
|
|
346
372
|
// ---------- 会话分享与协作 ----------
|
|
373
|
+
/** @param {any} name */
|
|
347
374
|
export async function syncShareCreate(name) {
|
|
348
375
|
const g = tokenGuard();
|
|
349
376
|
if (g.error) return { error: g.error };
|
|
@@ -351,7 +378,7 @@ export async function syncShareCreate(name) {
|
|
|
351
378
|
const r = await apiCall(g.url, '/api/share/create', { name }, g.token, TIMEOUT_MS, insecureOn());
|
|
352
379
|
return r.ok ? { ok: true, shareId: r.shareId, name: r.name } : { error: r.error };
|
|
353
380
|
} catch (e) {
|
|
354
|
-
return { error: `分享失败:${e.status === 404 ? '你还没有这个会话' : e.message}` };
|
|
381
|
+
return { error: `分享失败:${/** @type {any} */ (e).status === 404 ? '你还没有这个会话' : /** @type {any} */ (e).message}` };
|
|
355
382
|
}
|
|
356
383
|
}
|
|
357
384
|
|
|
@@ -362,10 +389,11 @@ export async function syncShareList() {
|
|
|
362
389
|
const r = await apiCall(g.url, '/api/share/list', {}, g.token, TIMEOUT_MS, insecureOn());
|
|
363
390
|
return { ok: true, mine: r.mine || [], accepted: r.accepted || [] };
|
|
364
391
|
} catch (e) {
|
|
365
|
-
return { error: `获取分享列表失败:${e.message}` };
|
|
392
|
+
return { error: `获取分享列表失败:${/** @type {any} */ (e).message}` };
|
|
366
393
|
}
|
|
367
394
|
}
|
|
368
395
|
|
|
396
|
+
/** @param {any} shareId */
|
|
369
397
|
export async function syncShareAccept(shareId) {
|
|
370
398
|
const g = tokenGuard();
|
|
371
399
|
if (g.error) return { error: g.error };
|
|
@@ -382,10 +410,11 @@ export async function syncShareAccept(shareId) {
|
|
|
382
410
|
writeState(state);
|
|
383
411
|
return { ok: true, shareId, savedAs: r.savedAs, conflict: r.conflict || false };
|
|
384
412
|
} catch (e) {
|
|
385
|
-
return { error: `接受分享失败:${e.status === 404 ? '分享不存在(可能已撤销)' : e.message}` };
|
|
413
|
+
return { error: `接受分享失败:${/** @type {any} */ (e).status === 404 ? '分享不存在(可能已撤销)' : /** @type {any} */ (e).message}` };
|
|
386
414
|
}
|
|
387
415
|
}
|
|
388
416
|
|
|
417
|
+
/** @param {any} shareId */
|
|
389
418
|
export async function syncShareRevoke(shareId) {
|
|
390
419
|
const g = tokenGuard();
|
|
391
420
|
if (g.error) return { error: g.error };
|
|
@@ -393,7 +422,7 @@ export async function syncShareRevoke(shareId) {
|
|
|
393
422
|
const r = await apiCall(g.url, '/api/share/revoke', { shareId }, g.token, TIMEOUT_MS, insecureOn());
|
|
394
423
|
return r.ok ? { ok: true } : { error: r.error };
|
|
395
424
|
} catch (e) {
|
|
396
|
-
return { error: `撤销失败:${e.status === 404 ? '分享不存在' : e.status === 403 ? '只能撤销自己的分享' : e.message}` };
|
|
425
|
+
return { error: `撤销失败:${/** @type {any} */ (e).status === 404 ? '分享不存在' : /** @type {any} */ (e).status === 403 ? '只能撤销自己的分享' : /** @type {any} */ (e).message}` };
|
|
397
426
|
}
|
|
398
427
|
}
|
|
399
428
|
|
|
@@ -421,12 +450,16 @@ export function listSyncConflicts() {
|
|
|
421
450
|
.map(([base, entries]) => ({
|
|
422
451
|
base,
|
|
423
452
|
localExists: fs.existsSync(path.join(home, 'sessions', base)),
|
|
424
|
-
entries: entries.sort((a, b) => b.ts - a.ts),
|
|
453
|
+
entries: entries.sort((/** @type {any} */ a, /** @type {any} */ b) => b.ts - a.ts),
|
|
425
454
|
}))
|
|
426
455
|
.sort((a, b) => a.base.localeCompare(b.base));
|
|
427
456
|
}
|
|
428
457
|
|
|
429
458
|
// choice: local(保留本地,删除备份)| remote(采用远端备份替换本地)| both(把最新备份转正为可见会话)
|
|
459
|
+
/**
|
|
460
|
+
* @param {any} base
|
|
461
|
+
* @param {any} choice
|
|
462
|
+
*/
|
|
430
463
|
export function resolveSyncConflict(base, choice) {
|
|
431
464
|
const home = mingdaoHome();
|
|
432
465
|
if (!/^[\w\u4e00-\u9fa5.-]{1,140}\.jsonl$/.test(base)) return { error: '会话名非法' };
|
|
@@ -443,7 +476,7 @@ export function resolveSyncConflict(base, choice) {
|
|
|
443
476
|
const mm = f.match(m);
|
|
444
477
|
return mm && `${mm[1]}.jsonl` === base;
|
|
445
478
|
})
|
|
446
|
-
.sort((a, b) => Number(b.match(m)[3]) - Number(a.match(m)[3]));
|
|
479
|
+
.sort((a, b) => Number(/** @type {any} */ (b.match(m))[3]) - Number(/** @type {any} */ (a.match(m))[3]));
|
|
447
480
|
if (!backups.length) return { error: `没有找到 ${base} 的冲突备份` };
|
|
448
481
|
const newest = backups[0];
|
|
449
482
|
if (choice === 'local') {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// 后台任务 worker(Phase C C2):独立进程执行一轮任务并写状态文件。
|
|
2
|
+
// 由 cli.js 的内部入口 `mingdao run-worker <id> ...` 启动(startTask 拉起的子进程)。
|
|
3
|
+
// 无交互:ask 权限降级 readonly;支持避峰顺延、MCP、自动标题、费用入账、完成通知与自动同步。
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { loadConfig, ensureHome } from '../config.js';
|
|
7
|
+
import { createProvider, resolveProviderConfig, helperProvider } from '../providers/index.js';
|
|
8
|
+
import { startMcpServers } from '../mcp.js';
|
|
9
|
+
import { patchTask } from '../tasks.js';
|
|
10
|
+
import { notifyTaskDone } from '../notify.js';
|
|
11
|
+
import { createAgent } from '../agent.js';
|
|
12
|
+
import { createPermission } from '../permissions.js';
|
|
13
|
+
import { buildSystemPrompt } from '../prompts.js';
|
|
14
|
+
import { createSession, appendMessages, rewriteSession } from '../session.js';
|
|
15
|
+
import { recordUsage } from '../cachestats.js';
|
|
16
|
+
import { maybeAutoSync } from '../sync.js';
|
|
17
|
+
import { isPeakHour, deferToOffpeak } from '../pricing.js';
|
|
18
|
+
import { createIO } from '../ui.js';
|
|
19
|
+
import { generateTitle, renameSessionFile, titleModel } from '../titles.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 执行一轮后台任务:加载配置 → 建 agent → 跑回合 → 回写状态文件。
|
|
23
|
+
* @param {string} id 任务 id
|
|
24
|
+
* @param {string} question 任务内容
|
|
25
|
+
* @param {{permission?: any, model?: any, offpeak?: any}} opts
|
|
26
|
+
*/
|
|
27
|
+
export async function runWorkerTask(id, question, { permission, model, offpeak }) {
|
|
28
|
+
const home = ensureHome();
|
|
29
|
+
const finish = (/** @type {any} */ patch) => patchTask(home, id, patch);
|
|
30
|
+
let mcpFacade = null;
|
|
31
|
+
let cfg = null; // 提到 try 外:catch 分支也要读 cfg.notify
|
|
32
|
+
try {
|
|
33
|
+
cfg = loadConfig();
|
|
34
|
+
if (!cfg) throw new Error('未初始化配置,请先运行 mingdao init');
|
|
35
|
+
const modelName = model || cfg.model || 'deepseek-v4-flash';
|
|
36
|
+
const pc = resolveProviderConfig(cfg, modelName);
|
|
37
|
+
if (!pc.apiKey) throw new Error(`模型 ${modelName} 没有可用 API Key`);
|
|
38
|
+
const workingDir = process.cwd();
|
|
39
|
+
// 后台无交互:ask 权限降级为 readonly 并注明(需要写权限请 mingdao run --permission auto)
|
|
40
|
+
let perm = permission || cfg.permission || 'ask';
|
|
41
|
+
let note = '';
|
|
42
|
+
if (perm === 'ask') {
|
|
43
|
+
perm = 'readonly';
|
|
44
|
+
note = 'ask 权限下后台任务按只读执行';
|
|
45
|
+
}
|
|
46
|
+
// 避峰(评估 A2/Kimi P-1):高峰时段(北京工作日 9:00–12:00、14:00–18:00)
|
|
47
|
+
// 自动顺延到最近闲时起点(12:00 / 18:00)执行,输入价省 50%
|
|
48
|
+
if (offpeak && isPeakHour(new Date())) {
|
|
49
|
+
const defer = deferToOffpeak(new Date());
|
|
50
|
+
finish({ note: `避峰等待至北京时间 ${defer.toISOString().slice(11, 16)}(闲时起执行,输入价省 50%)` });
|
|
51
|
+
await new Promise((r) => setTimeout(r, defer.getTime() - Date.now() + 2000));
|
|
52
|
+
finish({ note: '' });
|
|
53
|
+
}
|
|
54
|
+
const provider = await createProvider(cfg, modelName);
|
|
55
|
+
const io = createIO({ quiet: true });
|
|
56
|
+
const permissionObj = createPermission(perm, io);
|
|
57
|
+
/** @type {any} */
|
|
58
|
+
let mcpManager = null;
|
|
59
|
+
if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
|
|
60
|
+
mcpFacade = {
|
|
61
|
+
toolSchemas: () => (mcpManager ? mcpManager.toolSchemas() : []),
|
|
62
|
+
call: (/** @type {any} */ n, /** @type {any} */ a) => (mcpManager ? mcpManager.call(n, a) : Promise.reject(new Error('MCP 未就绪'))),
|
|
63
|
+
isReadonly: (/** @type {any} */ n) => (mcpManager ? mcpManager.isReadonly(n) : false),
|
|
64
|
+
stop: () => {
|
|
65
|
+
if (mcpManager) mcpManager.stop();
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
startMcpServers(cfg.mcpServers, workingDir).then((m) => (mcpManager = m)).catch(() => {});
|
|
69
|
+
}
|
|
70
|
+
const sessionRef = /** @type {{name: any}} */ ({ name: null });
|
|
71
|
+
let persistedCount = 0;
|
|
72
|
+
const agent = createAgent({
|
|
73
|
+
provider,
|
|
74
|
+
permission: permissionObj,
|
|
75
|
+
io,
|
|
76
|
+
modelName,
|
|
77
|
+
workingDir,
|
|
78
|
+
cfg,
|
|
79
|
+
mcp: mcpFacade || undefined,
|
|
80
|
+
sessionRef,
|
|
81
|
+
onCompact: (/** @type {any} */ msgs) => {
|
|
82
|
+
rewriteSession(session.file, msgs);
|
|
83
|
+
persistedCount = msgs.length;
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const session = createSession(home);
|
|
87
|
+
sessionRef.name = path.basename(session.file);
|
|
88
|
+
const messages = [
|
|
89
|
+
{ role: 'system', content: buildSystemPrompt({ modelName, workingDir }) },
|
|
90
|
+
{ role: 'user', content: question },
|
|
91
|
+
];
|
|
92
|
+
appendMessages(session.file, messages);
|
|
93
|
+
persistedCount = messages.length;
|
|
94
|
+
const t0 = Date.now();
|
|
95
|
+
const res = await agent.runTurn(messages);
|
|
96
|
+
appendMessages(session.file, messages.slice(persistedCount));
|
|
97
|
+
if (cfg.autoTitle !== false && res.text) {
|
|
98
|
+
try {
|
|
99
|
+
const tModel = titleModel(cfg, modelName);
|
|
100
|
+
const title = await generateTitle(await helperProvider(cfg, tModel, provider), tModel, question);
|
|
101
|
+
if (title) renameSessionFile(fs, path, home, session, title);
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
const finalStatus = res.truncated ? 'failed' : res.aborted ? 'killed' : 'done';
|
|
105
|
+
recordUsage(res.perf?.usedModel || modelName, res.usage, /** @type {any} */ (res.perf));
|
|
106
|
+
finish({
|
|
107
|
+
status: finalStatus,
|
|
108
|
+
text: (res.text || '').slice(0, 2000),
|
|
109
|
+
usage: res.usage,
|
|
110
|
+
durationMs: Date.now() - t0,
|
|
111
|
+
session: path.basename(session.file),
|
|
112
|
+
note,
|
|
113
|
+
});
|
|
114
|
+
if (cfg.notify !== false && !process.env.MINGDAO_TASK_QUIET_NOTIFY) notifyTaskDone(question, finalStatus === 'killed' ? 'failed' : finalStatus);
|
|
115
|
+
try {
|
|
116
|
+
await maybeAutoSync();
|
|
117
|
+
} catch {}
|
|
118
|
+
process.exitCode = res.truncated ? 1 : 0;
|
|
119
|
+
} catch (/** @type {any} */ err) {
|
|
120
|
+
finish({ status: 'failed', error: String(err?.message || err) });
|
|
121
|
+
if (cfg?.notify !== false && !process.env.MINGDAO_TASK_QUIET_NOTIFY) notifyTaskDone(question, 'failed');
|
|
122
|
+
process.exitCode = 2;
|
|
123
|
+
} finally {
|
|
124
|
+
if (mcpFacade) mcpFacade.stop();
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/tasks.js
CHANGED
|
@@ -11,11 +11,11 @@ import { atomicWriteFileSync, withFileLockSync } from './atomic-write.js';
|
|
|
11
11
|
|
|
12
12
|
const CLI_PATH = fileURLToPath(new URL('./cli.js', import.meta.url));
|
|
13
13
|
|
|
14
|
-
export function tasksDir(home) {
|
|
14
|
+
export function tasksDir(/** @type {any} */ home) {
|
|
15
15
|
return path.join(home, 'tasks');
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export function listTasks(home) {
|
|
18
|
+
export function listTasks(/** @type {any} */ home) {
|
|
19
19
|
const dir = tasksDir(home);
|
|
20
20
|
let files;
|
|
21
21
|
try {
|
|
@@ -33,7 +33,7 @@ export function listTasks(home) {
|
|
|
33
33
|
return out.sort((a, b) => b.startedAt - a.startedAt);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export function readTask(home, id) {
|
|
36
|
+
export function readTask(/** @type {any} */ home, /** @type {any} */ id) {
|
|
37
37
|
if (!isValidTaskId(id)) return null; // 防 id 路径穿越(id 直接拼进文件路径)
|
|
38
38
|
try {
|
|
39
39
|
return JSON.parse(fs.readFileSync(path.join(tasksDir(home), id + '.json'), 'utf8'));
|
|
@@ -42,18 +42,18 @@ export function readTask(home, id) {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
export function writeTask(home, task) {
|
|
45
|
+
export function writeTask(/** @type {any} */ home, /** @type {any} */ task) {
|
|
46
46
|
fs.mkdirSync(tasksDir(home), { recursive: true });
|
|
47
47
|
// 原子写:先临时文件再改名,避免崩溃留下半截 JSON
|
|
48
48
|
const target = path.join(tasksDir(home), task.id + '.json');
|
|
49
49
|
atomicWriteFileSync(target, JSON.stringify(task, null, 2) + '\n'); // 质检 H4:tmp 名含 pid+随机
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
export function isValidTaskId(id) {
|
|
52
|
+
export function isValidTaskId(/** @type {any} */ id) {
|
|
53
53
|
return typeof id === 'string' && /^[a-z0-9]+$/.test(id) && id.length >= 4 && id.length <= 40;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
export function startTask(home, question, { permission, model, cwd, offpeak, quietNotify } = /** @type {any} */ ({})) {
|
|
56
|
+
export function startTask(/** @type {any} */ home, /** @type {any} */ question, { permission, model, cwd, offpeak, quietNotify } = /** @type {any} */ ({})) {
|
|
57
57
|
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + process.pid.toString(36);
|
|
58
58
|
const task = {
|
|
59
59
|
id,
|
|
@@ -83,16 +83,16 @@ export function startTask(home, question, { permission, model, cwd, offpeak, qui
|
|
|
83
83
|
// 质检 M12:spawn 失败(ENOENT/ARG_MAX)必须有 error 监听,否则未捕获事件直接崩进程
|
|
84
84
|
child.on('error', (err) => {
|
|
85
85
|
try {
|
|
86
|
-
patchTask(home, id, { status: 'failed', error: `worker 启动失败:${err?.message || err}`, durationMs: Date.now() - task.startedAt });
|
|
86
|
+
patchTask(home, id, { status: 'failed', error: `worker 启动失败:${(/** @type {any} */ (err))?.message || err}`, durationMs: Date.now() - task.startedAt });
|
|
87
87
|
} catch {}
|
|
88
88
|
});
|
|
89
|
-
task.pid = child.pid;
|
|
89
|
+
task.pid = /** @type {any} */ (child.pid);
|
|
90
90
|
writeTask(home, task);
|
|
91
91
|
child.unref();
|
|
92
92
|
return task;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
export function patchTask(home, id, patch) {
|
|
95
|
+
export function patchTask(/** @type {any} */ home, /** @type {any} */ id, /** @type {any} */ patch) {
|
|
96
96
|
// 质检 H3:读-改-写加锁(worker finish 与 CLI kill 互斥,防 killed/done 互相覆盖)
|
|
97
97
|
return withFileLockSync(path.join(tasksDir(home), '.lock'), () => {
|
|
98
98
|
const t = readTask(home, id);
|
|
@@ -101,12 +101,12 @@ export function patchTask(home, id, patch) {
|
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
export function killTask(home, id) {
|
|
104
|
+
export function killTask(/** @type {any} */ home, /** @type {any} */ id) {
|
|
105
105
|
if (!isValidTaskId(id)) return false;
|
|
106
106
|
// 质检 H3:读-改-写加锁(与 worker 自身的状态写互斥)
|
|
107
107
|
return withFileLockSync(path.join(tasksDir(home), '.lock'), () => killTaskInner(home, id));
|
|
108
108
|
}
|
|
109
|
-
function killTaskInner(home, id) {
|
|
109
|
+
function killTaskInner(/** @type {any} */ home, /** @type {any} */ id) {
|
|
110
110
|
const t = readTask(home, id);
|
|
111
111
|
if (!t) return false;
|
|
112
112
|
if (t.status === 'running' && t.pid) {
|
|
@@ -115,7 +115,7 @@ function killTaskInner(home, id) {
|
|
|
115
115
|
try {
|
|
116
116
|
owned = fs.readFileSync(`/proc/${t.pid}/cmdline`, 'utf8').includes(id);
|
|
117
117
|
} catch {}
|
|
118
|
-
if (owned
|
|
118
|
+
if (owned === true) { // 非 Linux/无法校验(null)不盲杀(CodeBuddy 报告:PID 复用误杀风险)
|
|
119
119
|
try {
|
|
120
120
|
// worker 是 detached 进程(自成进程组):优先杀整组,避免工具子进程成孤儿
|
|
121
121
|
process.kill(-t.pid, 'SIGTERM');
|
|
@@ -132,8 +132,8 @@ function killTaskInner(home, id) {
|
|
|
132
132
|
|
|
133
133
|
const MARK = { running: '▶', done: '✓', failed: '✖', killed: '■' };
|
|
134
134
|
|
|
135
|
-
export function formatTaskRow(t) {
|
|
136
|
-
const mark = MARK[t.status] || '?';
|
|
135
|
+
export function formatTaskRow(/** @type {any} */ t) {
|
|
136
|
+
const mark = /** @type {any} */ (MARK)[t.status] || '?';
|
|
137
137
|
const elapsed =
|
|
138
138
|
t.status === 'running'
|
|
139
139
|
? relativeTime(t.startedAt).replace('前', '')
|
package/src/titles.js
CHANGED
|
@@ -4,21 +4,21 @@
|
|
|
4
4
|
import { modelPreset } from './models.js';
|
|
5
5
|
import { routingConfig } from './routing.js';
|
|
6
6
|
|
|
7
|
-
export function titleModel(cfg, currentModel) {
|
|
7
|
+
export function titleModel(/** @type {any} */ cfg, /** @type {any} */ currentModel) {
|
|
8
8
|
const rc = routingConfig(cfg);
|
|
9
9
|
if (rc) return rc.executor;
|
|
10
10
|
// 路由关闭:辅助调用用当前模型(评估 P2-2:硬编码 flash 会在自定义网关 404 静默失败)
|
|
11
11
|
return currentModel;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
function cleanTitle(s) {
|
|
14
|
+
function cleanTitle(/** @type {any} */ s) {
|
|
15
15
|
return String(s ?? '')
|
|
16
16
|
.trim()
|
|
17
17
|
.replace(/["「」『』"'`*#\n\r]/g, '')
|
|
18
18
|
.slice(0, 20);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export async function generateTitle(provider, model, firstUserText, { timeoutMs = 10000 } = {}) {
|
|
21
|
+
export async function generateTitle(/** @type {any} */ provider, /** @type {any} */ model, /** @type {any} */ firstUserText, { timeoutMs = 10000 } = {}) {
|
|
22
22
|
// 硬超时护栏(审计:「第二问无反应」根因):标题生成发生在回合收尾阶段,若网关/网络层
|
|
23
23
|
// 挂起(不抛错),回合永远无法 complete——前端停在生成态、后续发送全部静默。20s 兜底:
|
|
24
24
|
// 超时放弃标题(会话仍以时间戳命名),绝不阻塞下一问。
|
|
@@ -65,13 +65,13 @@ export async function generateTitle(provider, model, firstUserText, { timeoutMs
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
export function sanitizeTitle(s) {
|
|
68
|
+
export function sanitizeTitle(/** @type {any} */ s) {
|
|
69
69
|
const clean = String(s).replace(/[^\w\u4e00-\u9fa5 .-]/g, '_').slice(0, 40).trim();
|
|
70
70
|
return clean || '会话';
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// 重命名会话文件(与 /title 一致:确保存在、同名加随机后缀)
|
|
74
|
-
export function renameSessionFile(fs, pathMod, home, session, title) {
|
|
74
|
+
export function renameSessionFile(/** @type {any} */ fs, /** @type {any} */ pathMod, /** @type {any} */ home, /** @type {any} */ session, /** @type {any} */ title) {
|
|
75
75
|
const safe = sanitizeTitle(title);
|
|
76
76
|
let newFile = pathMod.join(home, 'sessions', safe + '.jsonl');
|
|
77
77
|
try {
|
package/src/tokenizer.js
CHANGED
|
@@ -22,6 +22,7 @@ const SPLIT_RES = [
|
|
|
22
22
|
/[!"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~][A-Za-z]+|[^\r\n\p{L}\p{P}\p{S}]?[\p{L}\p{M}]+| ?[\p{P}\p{S}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+/gu,
|
|
23
23
|
];
|
|
24
24
|
|
|
25
|
+
/** @param {string} text */
|
|
25
26
|
function pretokenize(text) {
|
|
26
27
|
let pieces = [text];
|
|
27
28
|
for (const re of SPLIT_RES) {
|
|
@@ -48,6 +49,7 @@ function pretokenize(text) {
|
|
|
48
49
|
// 汉字会退化为逐字节计数(如「的」被计为 3 tokens 而非 1)。
|
|
49
50
|
const BYTE_TO_UNICODE = (() => {
|
|
50
51
|
const m = new Array(256);
|
|
52
|
+
/** @param {number} b */
|
|
51
53
|
const self = (b) => (b >= 0x21 && b <= 0x7e) || (b >= 0xa1 && b <= 0xac) || (b >= 0xae);
|
|
52
54
|
let n = 0;
|
|
53
55
|
for (let b = 0; b < 256; b++) {
|
|
@@ -60,11 +62,14 @@ const BYTE_TO_UNICODE = (() => {
|
|
|
60
62
|
return m;
|
|
61
63
|
})();
|
|
62
64
|
|
|
65
|
+
/** @param {string} s */
|
|
63
66
|
function escapeRe(s) {
|
|
64
67
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
/** @type {any} */
|
|
67
71
|
let data = null;
|
|
72
|
+
/** @type {any} */
|
|
68
73
|
let loadError = null;
|
|
69
74
|
|
|
70
75
|
function loadData() {
|
|
@@ -78,7 +83,7 @@ function loadData() {
|
|
|
78
83
|
const [a, b] = parsed.merges[i];
|
|
79
84
|
mergeRank.set(a + '\u0001' + b, i);
|
|
80
85
|
}
|
|
81
|
-
const added = (parsed.added || []).filter(Boolean).sort((a, b) => b.length - a.length);
|
|
86
|
+
const added = (parsed.added || []).filter(Boolean).sort((/** @type {any} */ a, /** @type {any} */ b) => b.length - a.length);
|
|
82
87
|
// 全部 added token 合并成单个正则(按长度降序排列 → 左起最长优先,与逐 token startsWith 语义一致),
|
|
83
88
|
// 一次 matchAll 定位所有特殊 token,把 O(文本长 × 818 个 indexOf) 降到 O(n)
|
|
84
89
|
const addedRe = added.length ? new RegExp(added.map(escapeRe).join('|'), 'gu') : null;
|
|
@@ -89,6 +94,7 @@ function loadData() {
|
|
|
89
94
|
return data;
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
/** @param {any} modelName */
|
|
92
98
|
export function isTokenizable(modelName) {
|
|
93
99
|
if (typeof modelName !== 'string') return false;
|
|
94
100
|
if (modelName.startsWith('deepseek')) return true;
|
|
@@ -124,15 +130,17 @@ const CJK_RANGES = [
|
|
|
124
130
|
[0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xf900, 0xfaff], // CJK 扩展/基本区/兼容
|
|
125
131
|
[0x3040, 0x30ff], [0xac00, 0xd7af], // 假名 / 谚文
|
|
126
132
|
];
|
|
133
|
+
/** @param {number} code */
|
|
127
134
|
const isCjk = (code) => CJK_RANGES.some(([lo, hi]) => code >= lo && code <= hi);
|
|
128
135
|
|
|
136
|
+
/** @param {any} text */
|
|
129
137
|
export function heuristicTokens(text) {
|
|
130
138
|
if (!text) return 0;
|
|
131
139
|
let ascii = 0;
|
|
132
140
|
let cjk = 0;
|
|
133
141
|
let other = 0;
|
|
134
142
|
for (const ch of String(text)) {
|
|
135
|
-
const code = ch.codePointAt(0);
|
|
143
|
+
const code = /** @type {number} */ (ch.codePointAt(0));
|
|
136
144
|
if (code < 128) ascii += 1;
|
|
137
145
|
else if (isCjk(code)) cjk += 1;
|
|
138
146
|
else other += code > 0xffff ? 2 : 1; // 审计 B5:增补平面 emoji 按 2 token 保守计
|
|
@@ -144,6 +152,7 @@ export function heuristicTokens(text) {
|
|
|
144
152
|
|
|
145
153
|
// 单个预分词片段的 BPE 计数(tiktoken 语义:优先合并 rank 最小的对,同 rank 取最左)。
|
|
146
154
|
// 惰性最小堆实现(O(n log n)):大文本(中文长文/工具输出)不再 O(n²) 全表扫描。
|
|
155
|
+
/** @param {any} piece @param {any} d */
|
|
147
156
|
function countPiece(piece, d) {
|
|
148
157
|
const bytes = Buffer.from(piece, 'utf8');
|
|
149
158
|
const syms = [];
|
|
@@ -151,17 +160,21 @@ function countPiece(piece, d) {
|
|
|
151
160
|
if (syms.length <= 1) return syms.length;
|
|
152
161
|
|
|
153
162
|
const alive = new Uint8Array(syms.length).fill(1);
|
|
163
|
+
/** @param {number} i */
|
|
154
164
|
const leftOf = (i) => {
|
|
155
165
|
for (let j = i - 1; j >= 0; j--) if (alive[j]) return j;
|
|
156
166
|
return -1;
|
|
157
167
|
};
|
|
168
|
+
/** @param {number} i */
|
|
158
169
|
const rightOf = (i) => {
|
|
159
170
|
for (let j = i + 1; j < syms.length; j++) if (alive[j]) return j;
|
|
160
171
|
return -1;
|
|
161
172
|
};
|
|
162
173
|
|
|
163
174
|
// 最小堆(rank, leftIndex);过期条目惰性丢弃/重推
|
|
175
|
+
/** @type {any[]} */
|
|
164
176
|
const heap = [];
|
|
177
|
+
/** @param {number} rank @param {number} idx */
|
|
165
178
|
const push = (rank, idx) => {
|
|
166
179
|
heap.push([rank, idx]);
|
|
167
180
|
let c = heap.length - 1;
|
|
@@ -182,6 +195,7 @@ function countPiece(piece, d) {
|
|
|
182
195
|
const l = c * 2 + 1;
|
|
183
196
|
const r = l + 1;
|
|
184
197
|
let m = c;
|
|
198
|
+
/** @param {number} x */
|
|
185
199
|
const better = (x) => heap[x][0] < heap[m][0] || (heap[x][0] === heap[m][0] && heap[x][1] < heap[m][1]);
|
|
186
200
|
if (l < heap.length && better(l)) m = l;
|
|
187
201
|
if (r < heap.length && better(r)) m = r;
|
|
@@ -238,6 +252,7 @@ const TOKEN_CACHE_MAX_ENTRIES = 512;
|
|
|
238
252
|
const TOKEN_CACHE_MAX_LEN = 50000;
|
|
239
253
|
const tokenCache = new Map();
|
|
240
254
|
|
|
255
|
+
/** @param {string} s */
|
|
241
256
|
function countDeepseek(s) {
|
|
242
257
|
const d = loadData();
|
|
243
258
|
if (!d) return heuristicTokens(s); // 词表缺失时优雅回退
|
|
@@ -254,12 +269,14 @@ function countDeepseek(s) {
|
|
|
254
269
|
return total;
|
|
255
270
|
}
|
|
256
271
|
|
|
272
|
+
/** @param {any} piece @param {any} d */
|
|
257
273
|
function countGap(piece, d) {
|
|
258
274
|
let total = 0;
|
|
259
275
|
for (const m of pretokenize(piece)) total += countPiece(m, d);
|
|
260
276
|
return total;
|
|
261
277
|
}
|
|
262
278
|
|
|
279
|
+
/** @param {any} text @param {any} modelName */
|
|
263
280
|
export function countTokens(text, modelName) {
|
|
264
281
|
if (!text) return 0;
|
|
265
282
|
const s = String(text);
|
|
@@ -273,15 +290,16 @@ export function countTokens(text, modelName) {
|
|
|
273
290
|
const hit = byModel.get(s);
|
|
274
291
|
if (hit !== undefined) return hit;
|
|
275
292
|
const n = countDeepseek(s);
|
|
276
|
-
if (byModel.size >= TOKEN_CACHE_MAX_ENTRIES) byModel.
|
|
293
|
+
if (byModel.size >= TOKEN_CACHE_MAX_ENTRIES) byModel.delete(byModel.keys().next().value); // LRU:删最旧(Map 迭代序)
|
|
277
294
|
byModel.set(s, n);
|
|
278
295
|
return n;
|
|
279
296
|
}
|
|
280
297
|
|
|
281
298
|
// 供上下文预算使用的计数器工厂
|
|
299
|
+
/** @param {any} modelName */
|
|
282
300
|
export function makeTokenCounter(modelName) {
|
|
283
301
|
if (isTokenizable(modelName)) {
|
|
284
|
-
return (text) => countTokens(text, modelName);
|
|
302
|
+
return (/** @type {any} */ text) => countTokens(text, modelName);
|
|
285
303
|
}
|
|
286
|
-
return (text) => heuristicTokens(text);
|
|
304
|
+
return (/** @type {any} */ text) => heuristicTokens(text);
|
|
287
305
|
}
|