mingdao-harness 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/docs/EVALUATION-ROUND2.md +58 -0
  2. package/docs/ROADMAP-NEXT.md +5 -5
  3. package/package.json +2 -2
  4. package/src/agent.js +74 -36
  5. package/src/atomic-write.js +8 -2
  6. package/src/audit.js +2 -2
  7. package/src/batch.js +7 -7
  8. package/src/cachestats.js +9 -9
  9. package/src/cli.js +42 -688
  10. package/src/commands/desktop.js +1 -1
  11. package/src/commands/key.js +1 -1
  12. package/src/commands/repl.js +711 -0
  13. package/src/commands/schedule.js +7 -7
  14. package/src/commands/skill.js +18 -16
  15. package/src/commands/sync.js +5 -5
  16. package/src/commands/update.js +7 -6
  17. package/src/commands/workspace.js +2 -2
  18. package/src/compact.js +6 -6
  19. package/src/config.js +7 -4
  20. package/src/context.js +37 -21
  21. package/src/cost-guard.js +14 -6
  22. package/src/credentials.js +6 -0
  23. package/src/hooks.js +11 -11
  24. package/src/mcp-presets.js +5 -5
  25. package/src/mcp.js +19 -19
  26. package/src/memory.js +16 -16
  27. package/src/model-discovery.js +16 -15
  28. package/src/models.js +4 -4
  29. package/src/notify.js +2 -2
  30. package/src/permissions.js +7 -3
  31. package/src/pricing.js +35 -1
  32. package/src/prompts.js +1 -1
  33. package/src/providers/index.js +7 -7
  34. package/src/providers/openai-compatible.js +13 -11
  35. package/src/routing.js +8 -8
  36. package/src/schedule.js +30 -30
  37. package/src/session-index.js +5 -1
  38. package/src/session.js +11 -2
  39. package/src/skill-lib.js +62 -3
  40. package/src/skill-registry.js +10 -9
  41. package/src/skills.js +14 -12
  42. package/src/sync-server.js +86 -3
  43. package/src/sync.js +53 -20
  44. package/src/tasks/worker.js +126 -0
  45. package/src/tasks.js +14 -14
  46. package/src/titles.js +5 -5
  47. package/src/tokenizer.js +23 -5
  48. package/src/tools/bash.js +12 -12
  49. package/src/tools/fs-tools.js +66 -9
  50. package/src/tools/index.js +7 -7
  51. package/src/ui.js +83 -6
  52. package/src/update.js +8 -8
  53. package/src/web/app.js +16 -5
  54. package/src/web/attachments.js +1 -1
  55. package/src/web/index.html +21 -1
  56. package/src/web/routes/api.js +38 -786
  57. package/src/web/routes/domains/config.js +280 -0
  58. package/src/web/routes/domains/misc.js +129 -0
  59. package/src/web/routes/domains/schedule.js +127 -0
  60. package/src/web/routes/domains/sessions.js +105 -0
  61. package/src/web/routes/domains/skills.js +79 -0
  62. package/src/web/routes/domains/sync.js +101 -0
  63. package/src/web/routes/domains/workspace.js +119 -0
  64. package/src/web/server.js +73 -31
  65. package/src/web/web-io.js +54 -12
  66. package/src/workspace.js +14 -14
package/src/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 !== false) {
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.clear();
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
  }