mingdao-harness 0.2.6 → 0.2.8

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/CONFIG.md CHANGED
@@ -57,9 +57,9 @@ MingDao 先用 executor 模型(路由关闭时为当前模型)把被裁段
57
57
  ## 会话检索索引(P3-2)
58
58
 
59
59
  `mingdao sessions search <关键词>` 与 WebUI 历史会话搜索走增量词表索引
60
- (`~/.mingdao/sessions-index.json`):中文按 bigram+单字、英文按词,多词 AND 匹配;
61
- 只有内容变化(mtime/size)的会话才重新分词,删除的会话自动清出索引。索引文件可随时
62
- 删除(下次搜索自动重建)。
60
+ (`~/.mingdao/sessions-index/` 分片目录,v0.2.8 起按会话名 sha1 前 2 位分 256 片):
61
+ 中文按 bigram+单字、英文按词,多词 AND 匹配;只有内容变化(mtime/size)的会话才重新
62
+ 分词,删除的会话自动清出索引。索引目录可随时删除(下次搜索自动重建)。
63
63
 
64
64
  ## 会话级工作空间(P3-4)
65
65
 
@@ -169,7 +169,7 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
169
169
  { "costGuard": { "dailyLimitYuan": 10, "warnAtYuan": 8, "action": "block" } }
170
170
  ```
171
171
 
172
- - `action: "warn"`(默认)超限仅提醒;`"block"` 到达上限暂停执行(明天自动恢复);
172
+ - `action: "warn"`(默认)超限仅提醒;`"block"` 到达上限暂停执行(明天自动恢复);`"downgrade"` 触顶后自动切换到同服务商更便宜模型(如 flash)继续执行并提示(已是 flash 则按 block 处理);
173
173
  - `/cost` 与 WebUI 头部费用徽标实时显示「今日费用 / 上限百分比」。
174
174
 
175
175
  ## 战略省钱:Batch 半价 + 避峰调度
@@ -182,7 +182,7 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
182
182
  ——高峰时段(北京时间工作日 9:00–12:00、14:00–18:00 两段)自动顺延到最近闲时(12:00 / 18:00)执行;**周末与午间 12:00–14:00 按闲时计价**
183
183
  · `pricing.peakWindows`:高峰窗口覆盖([[起,止],...] 北京时间整点);`pricing.timezone`:计价时区
184
184
  · `pricing.source`:官方价格 JSON 地址(`mingdao update --pricing` 拉取,TTL 默认 7 天,`pricing.ttlDays` 可调);`pricing.overrides`:按模型覆盖价格
185
- · `reasoningEffort`:思考强度 low/high/max(默认模型内置 high;REPL `/think` 切换)
185
+ · `reasoningByModel`:思考强度按模型独立覆盖,`{ "deepseek-v4-pro": "low" }`(`off`/`low`/`high`/`max`,默认模型内置 high;`off` 显式关闭思考省推理 token;REPL `/think` 或 WebUI 设置「模型与密钥」→ 思考模式开关 + 推理等级);旧版全局 `reasoningEffort` 仍兼容,优先级低于 `reasoningByModel`
186
186
  · `routing.upgradeSteps` / `routing.upgradeTruncated`:粘滞 flash 会话累计步数/截断超过阈值自动升 planner(默认 10 / 2)
187
187
  (DeepSeek 官方邮件确认),不触发避峰等待。WebUI 调度面板勾选「🌙 避峰执行」。
188
188
 
package/docs/QA-REPORT.md CHANGED
@@ -5,6 +5,8 @@
5
5
  > 版本:0.1.54(历史归档;现行 0.1.x 口径见根 README 与各版本 tag。纯 ESM,零 npm 依赖,Node.js ≥ 18.17)
6
6
  > 质检方式:全量源码走读 + 冒烟/端到端测试 + 针对性缺陷验证脚本
7
7
 
8
+ > **当前测试规模快照(v0.2.8,随版本持续增长)**:smoke **61 组** · e2e-local **16 项** · e2e-web **21 项** · e2e-schedule **8 项** · api-contracts **8 项** · bench **194 条断言**(tokenizer 16 + routing 118 + compaction 46 + cost 14)。下文 2026-08-18 的「14 组断言」为当时历史口径,已过时——以本快照为准。
9
+
8
10
  ---
9
11
 
10
12
  ## 一、总体评价
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -49,7 +49,7 @@
49
49
  "scripts": {
50
50
  "start": "node src/cli.js",
51
51
  "test": "node test/run-all.mjs",
52
- "typecheck": "tsc -p tsconfig.json",
52
+ "typecheck": "tsc -p tsconfig.full.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
55
  "desktop:dist": "node scripts/sync-versions.mjs && npm --prefix desktop run dist:dir",
@@ -11,13 +11,14 @@ description: 准备发布新版本、打包交付前使用
11
11
  2. **版本**:版本号统一(package.json / 配置 / 文档互相一致),遵循语义化版本。
12
12
  3. **变更说明**:changelog 用用户语言描述新增/修复/破坏性变更。
13
13
  4. **构建验证**:实际构建/打包一次,并在干净环境验证产物可安装可运行。
14
- 5. **桌面版打包冒烟(0.2.2 事故护栏,发版必经)**:主进程模块求值必须真实跑通打包产物——
14
+ 5. **发布前自检(常设步骤,2026-09-02 起)**:不依赖外部评估报告,纯代码审查主动找 bug——重点覆盖本次改动的调用链、锁/并发、权限/安全、跨平台与「打包期无声、运行期爆炸」类边界;每一条发现必须给出**文件+行号证据并按严重度排序**(高危优先修,不可带着已知高危发布)。历史战例:v0.2.6 自检一次捞出 6 条(含外部六份评估全漏的 `schedule stop` 双守护进程 bug)与 2 条自改回归。
15
+ 6. **桌面版打包冒烟(0.2.2 事故护栏,发版必经)**:主进程模块求值必须真实跑通打包产物——
15
16
  - CI 已内置:desktop.yml 在 Linux 腿用 xvfb 运行 `dist/linux-unpacked/mingdao --no-sandbox`(`MINGDAO_DESKTOP_SMOKE=1`),模块求值通过输出 `MINGDAO_DESKTOP_SMOKE_OK` 并退出 0,否则 workflow 失败;
16
17
  - 本地有显示器时可加验:`cd desktop && MINGDAO_DESKTOP_SMOKE=1 dist/linux-unpacked/mingdao --no-sandbox`(AppImage 加 `--appimage-extract-and-run`);
17
18
  - 教训:0.2.2 三平台启动即崩(main.js 顶层调用 createLogWriter 漏导入),打包期不报错、运行期才炸——静态护栏(test/smoke.js「桌面主进程静态护栏」组)与打包冒烟双保险。
18
- 6. **敏感信息**:密钥、token、内网地址、个人信息不得进入仓库与产物(`grep -rE 'sk-[A-Za-z0-9]{20,}' .`)。
19
- 7. **回滚方案**:明确如何回退到上一版本(旧包/旧 tag 可恢复)。
20
- 8. **发布后冒烟**:发布完成后立即做一次端到端验证。
19
+ 7. **敏感信息**:密钥、token、内网地址、个人信息不得进入仓库与产物(`grep -rE 'sk-[A-Za-z0-9]{20,}' .`)。
20
+ 8. **回滚方案**:明确如何回退到上一版本(旧包/旧 tag 可恢复)。
21
+ 9. **发布后冒烟**:发布完成后立即做一次端到端验证。
21
22
 
22
23
  ## 桌面版发布流水线(官网分发 + 镜像 + 转运附件回收)
23
24
 
package/src/agent.js CHANGED
@@ -28,7 +28,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
28
28
  const budget = cfg.contextBudget || preset.budgetTokens || 128000;
29
29
  const maxOutput = cfg.maxOutputTokens || preset.maxOutputTokens || 8192;
30
30
  const temperature = cfg.temperature ?? preset.temperature ?? 0.6;
31
- const reasoningEffort = cfg.reasoningEffort ?? preset.reasoningEffort?.default ?? undefined;
31
+ const reasoningEffort = cfg.reasoningByModel?.[modelName] ?? cfg.reasoningEffort ?? preset.reasoningEffort?.default ?? undefined;
32
32
  const hooks = createHooks(cfg.hooks, workingDir);
33
33
  const todos = /** @type {any[]} */ ([]);
34
34
  // 会话级共享:调用方传入则复用(/model 切换、子代理均共享,undo 不丢失)
@@ -359,6 +359,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
359
359
  }
360
360
 
361
361
  if (res.toolCalls?.length) {
362
+ // v0.2.8 步数上限收尾(对齐 DSH):末轮仍返回工具调用(无视收尾指令)时不再执行工具——
363
+ // 有正文直接收尾,无正文跳出循环进入兜底总结,避免再次跑满步数后静默结束。
364
+ if (steps === stepLimit) {
365
+ io.endTurn();
366
+ if (res.text) {
367
+ messages.push({ role: 'assistant', content: res.text });
368
+ return { text: res.text, reasoning: res.reasoning || '', usage, steps, finish, truncated: false, aborted: false, durationMs: Date.now() - startedAt, perf: perf() };
369
+ }
370
+ break;
371
+ }
362
372
  io.endTurn();
363
373
  const assistantMsg = {
364
374
  role: 'assistant',
@@ -553,6 +563,15 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
553
563
  }
554
564
  }
555
565
  }
566
+ // v0.2.8 步数预留收尾(对齐 DSH):仅剩最后一轮时,工具结果回填后追加收尾指令,
567
+ // 让模型在末轮输出「总结文字 + 交付物清单」,而非跑满步数后静默结束。
568
+ if (steps === stepLimit - 1) {
569
+ messages.push({
570
+ role: 'user',
571
+ content:
572
+ '(系统提示)已达到工具调用步数上限,请停止调用工具,直接输出最终总结:① 已完成的工作;② 交付物清单(文件路径);③ 遗留问题与后续建议。',
573
+ });
574
+ }
556
575
  } else {
557
576
  io.endTurn();
558
577
  // 最终纯文本回复回填消息历史(会话持久化与多轮上下文依赖它);
@@ -626,6 +645,38 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
626
645
  io.endTurn();
627
646
  // 步数上限:清掉未执行的 tool_calls,避免下一轮/恢复后 API 400
628
647
  stripOrphanCalls();
648
+ // v0.2.8 兜底总结(对齐 DSH):跑满步数/末轮无正文且未中断时,补一次 no-tool 小输出请求,
649
+ // 让任务以「总结文字 + 交付物清单」收尾,而非静默结束;失败则回退旧行为(text:null)。
650
+ if (!aborted && messages.length) {
651
+ try {
652
+ // stripOrphanCalls 残留的空 assistant(无 tool_calls 且 content 为空)回传会触发 API 400,先清掉
653
+ const lastMsg = messages[messages.length - 1];
654
+ if (lastMsg.role === 'assistant' && !lastMsg.content && !Array.isArray(lastMsg.tool_calls)) messages.pop();
655
+ const wrapReq = [
656
+ ...trimMessages(messages, budget, count),
657
+ { role: 'user', content: '(系统提示)任务已执行完毕。请用一段话总结刚才完成的工作,列出交付物(文件路径),并说明遗留问题与后续建议。' },
658
+ ];
659
+ currentAc = new AbortController();
660
+ const wrapRes = await provider.chat({
661
+ model: activeModel,
662
+ messages: wrapReq,
663
+ tools: [],
664
+ temperature,
665
+ maxTokens: Math.min(maxOutput, 2048),
666
+ reasoningEffort: modelPreset(activeModel)?.supportsReasoning ? 'low' : undefined,
667
+ signal: currentAc.signal,
668
+ onDelta(/** @type {any} */ d) {
669
+ if (d.text) io.writeText(d.text);
670
+ },
671
+ });
672
+ if (wrapRes.usage) {
673
+ usage.prompt_tokens += wrapRes.usage.prompt_tokens || 0;
674
+ usage.completion_tokens += wrapRes.usage.completion_tokens || 0;
675
+ }
676
+ if (wrapRes.text) messages.push({ role: 'assistant', content: wrapRes.text });
677
+ return { text: wrapRes.text || null, reasoning: wrapRes.reasoning || '', usage, steps, finish, truncated: false, aborted: false, durationMs: Date.now() - startedAt, perf: perf() };
678
+ } catch {}
679
+ }
629
680
  return { text: null, reasoning: '', usage, steps, finish, truncated: true, aborted: false, durationMs: Date.now() - startedAt, perf: perf() };
630
681
  } finally {
631
682
  currentAc = null;
package/src/cli.js CHANGED
@@ -257,8 +257,16 @@ async function main() {
257
257
  const { readTask } = await import('./tasks.js');
258
258
  const handled = new Set();
259
259
  const supervising = new Set(); // 本 daemon 正在监督的任务(防崩溃恢复误判正在执行的任务)
260
+ const nonce = String(opts.prompt[1] || '');
260
261
  try {
261
262
  for (;;) {
263
+ // 守护进程租约自检(2026-09-03,防双 daemon):pidfile 不再等于「我的 pid nonce」
264
+ // 即视为已被 stop 或已被新 daemon 取代——立即退出,绝不与新 daemon 并跑重复执行任务
265
+ try {
266
+ if (fs.readFileSync(daemonPidFile(home0), 'utf8').trim() !== `${process.pid} ${nonce}`) break;
267
+ } catch {
268
+ break; // pidfile 被删 = 已被 stop
269
+ }
262
270
  const jobs = listSchedules(home0);
263
271
  if (!jobs.some((j) => j.status === 'pending' || j.status === 'running')) break;
264
272
  for (const j of jobs) {
@@ -295,8 +303,12 @@ async function main() {
295
303
  await new Promise((r) => setTimeout(r, 2000));
296
304
  }
297
305
  } finally {
306
+ // 只删除「仍指向自己」的 pidfile:租约被改写/被新 daemon 取代而退出时,
307
+ // 绝不误删新 daemon 的 pidfile(否则新 daemon 下一次租约检查会连锁退出,双守护全灭)
298
308
  try {
299
- fs.rmSync(daemonPidFile(home0), { force: true });
309
+ if (fs.readFileSync(daemonPidFile(home0), 'utf8').trim() === `${process.pid} ${nonce}`) {
310
+ fs.rmSync(daemonPidFile(home0), { force: true });
311
+ }
300
312
  } catch {}
301
313
  }
302
314
  return;
@@ -368,11 +380,14 @@ async function main() {
368
380
  },
369
381
  };
370
382
  if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
371
- // A2:预热——await 连接(6s 超时);超时本会话冻结工具集(不再中途注入,保护前缀缓存)
383
+ // A2:预热——await 连接(6s 超时);超时本会话冻结工具集(不再中途注入,保护前缀缓存)。
384
+ // 超时后输家 promise 仍在跑:迟到就绪的 manager 立即 stop,防 detached 子进程成孤儿(自查 #2)
385
+ const mcpStartP = startMcpServers(cfg.mcpServers, workingDir).catch(() => null);
372
386
  mcpManager = await Promise.race([
373
- startMcpServers(cfg.mcpServers, workingDir).catch(() => null),
387
+ mcpStartP,
374
388
  new Promise((/** @type {any} */ r) => setTimeout(() => r(null), 6000)),
375
389
  ]);
390
+ if (!mcpManager) mcpStartP.then((/** @type {any} */ m) => { if (m) m.stop(); });
376
391
  if (mcpManager) {
377
392
  const ready = mcpManager.status().filter((/** @type {any} */ s) => s.ok).length;
378
393
  if (io && !opts.prompt.length) {
@@ -318,13 +318,16 @@ export async function runRepl(ctx) {
318
318
  await switchToModel(target);
319
319
  } else if (cmd === '/think') {
320
320
  const vals = ['low', 'high', 'max'];
321
+ const cur = cfg.reasoningByModel?.[modelName] ?? cfg.reasoningEffort ?? '跟随模型默认';
321
322
  if (!arg) {
322
- io.print(`用法:/think low|high|max|off(当前:${cfg.reasoningEffort || '跟随模型默认 high'})`);
323
+ io.print(`用法:/think low|high|max|off(当前:${cur})`);
323
324
  continue;
324
325
  }
325
- if (arg === 'off') cfg.reasoningEffort = 'off'; // 显式禁用(provider 转 thinking:{type:'disabled'})
326
- else if (vals.includes(arg)) cfg.reasoningEffort = arg;
327
- else { io.print(style('无效取值:low|high|max|off', C.red)); continue; }
326
+ if (arg === 'off' || vals.includes(arg)) {
327
+ cfg.reasoningByModel = cfg.reasoningByModel || {};
328
+ cfg.reasoningByModel[modelName] = arg; // 按模型独立:只改当前模型(off thinking:{type:'disabled'}
329
+ saveConfig(cfg);
330
+ } else { io.print(style('无效取值:low|high|max|off', C.red)); continue; }
328
331
  agent = createAgent({
329
332
  provider, permission, io, modelName, workingDir, cfg,
330
333
  undoStore: sessionUndoStore, mcp: mcpFacade, sessionRef,
@@ -333,7 +336,7 @@ export async function runRepl(ctx) {
333
336
  tuiState.persisted = msgs.length;
334
337
  },
335
338
  });
336
- io.print(style(`✓ 思考强度:${cfg.reasoningEffort || 'off(模型默认)'}`, C.green));
339
+ io.print(style(`✓ 思考强度(${modelName}):${cfg.reasoningByModel[modelName]}`, C.green));
337
340
  } else if (cmd === '/plan') {
338
341
  planMode = !planMode;
339
342
  io.print(style(`✓ 计划模式:${planMode ? '开' : '关'}${planMode ? '(先出计划,确认后执行)' : ''}`, C.green));
package/src/log-writer.js CHANGED
@@ -9,7 +9,9 @@ export function createLogWriter(/** @type {string} */ file, { maxBytes = 512 * 1
9
9
  try {
10
10
  fs.mkdirSync(path.dirname(file), { recursive: true });
11
11
  const line = new Date().toISOString() + ' ' + String(msg) + '\n';
12
- fs.appendFileSync(file, line);
12
+ // 自查 #5:日志可能含工具参数等敏感信息——创建与轮转一律 600(多用户机器防同机读取)
13
+ if (!fs.existsSync(file)) fs.appendFileSync(file, line, { mode: 0o600 });
14
+ else fs.appendFileSync(file, line);
13
15
  const st = fs.statSync(file);
14
16
  if (st.size > maxBytes) {
15
17
  const raw = fs.readFileSync(file, 'utf8');
@@ -17,8 +19,9 @@ export function createLogWriter(/** @type {string} */ file, { maxBytes = 512 * 1
17
19
  const nl = raw.indexOf('\n', cut);
18
20
  const keep = nl === -1 ? raw.slice(cut) : raw.slice(nl + 1); // 行对齐保留尾部
19
21
  const tmp = file + '.' + process.pid + '.' + crypto.randomBytes(3).toString('hex') + '.tmp';
20
- fs.writeFileSync(tmp, keep);
22
+ fs.writeFileSync(tmp, keep, { mode: 0o600 }); // 轮转重建保持 600
21
23
  fs.renameSync(tmp, file);
24
+ try { fs.chmodSync(file, 0o600); } catch {} // rename 后兜底收权(历史 644 日志迁移)
22
25
  }
23
26
  } catch {
24
27
  // 日志失败绝不抛错(best-effort)
package/src/pricing.js CHANGED
@@ -42,9 +42,18 @@ export function pricingDataStale() { return externalPricing().stale; }
42
42
  export async function refreshPricingFromSource(cfg) {
43
43
  const src = String(cfg?.pricing?.source || '').trim();
44
44
  if (!src) return { ok: false, lines: ['未配置 pricing.source(config.json 的 pricing.source 填官方价格 JSON 地址后重试)'] };
45
+ // 自查 #4:与 sync/自定义模型同类的第三外联入口——协议白名单 + 响应大小上限(防误配/恶意源灌内存)
46
+ try {
47
+ const u = new URL(src);
48
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, lines: ['价格源仅支持 http/https 地址'] };
49
+ } catch {
50
+ return { ok: false, lines: ['价格源必须是合法的 http(s) URL'] };
51
+ }
45
52
  const res = await fetch(src, { signal: AbortSignal.timeout(15000) });
46
53
  if (!res.ok) return { ok: false, lines: ['价格源请求失败:HTTP ' + res.status] };
47
- const d = await res.json();
54
+ const buf = await res.arrayBuffer();
55
+ if (buf.byteLength > 512 * 1024) return { ok: false, lines: ['价格源响应超过 512KB 上限,已拒绝'] };
56
+ const d = JSON.parse(Buffer.from(buf).toString('utf8'));
48
57
  const models = d && typeof d === 'object' && !Array.isArray(d) ? /** @type {any} */ (d).models : null;
49
58
  if (!models || typeof models !== 'object' || Array.isArray(models)) {
50
59
  return { ok: false, lines: ['价格源格式不符:应为 {"models": {"模型名": {"input":…,"output":…,"cacheHit":…,"peak":{…}}}}'] };
package/src/prompts.js CHANGED
@@ -14,7 +14,7 @@ const BASE = `你是 MingDao Harness,一个由 MingDao Harness 驱动的 AI
14
14
  2. 精准修改:优先用 edit 做小步精确替换;新建文件用 write;保持改动最小化;改错可用 undo 撤销。
15
15
  3. 大内容分批写:单个 write 的参数总长控制在 6000 字符以内;大文件先写核心骨架,再分多次 write/edit 逐步补充——单次输出超限会被截断并导致参数解析失败。
16
16
  4. 任务管理:多步骤任务先建 todo 清单并逐项更新;与技能(skill)相关的任务先加载对应 SKILL.md。
17
- 5. 委托与专注:独立、可并行的子问题(调研、复核、独立实现)用 task 委托子代理,把结果拿回来继续主线。
17
+ 5. 委托与专注:把「互不依赖、可并行」的子问题(同时调研多个方向、复核多份文件、独立实现多模块)用 task 委托子代理;多个只读调研子代理应在同一轮一起派发(readOnly=true,自动并行,效率最高);依赖当前对话细节的小改动自己做,不要委托。子代理返回后拿结果继续主线。
18
18
  6. 命令谨慎:执行 bash 前想清楚影响;命令失败时阅读错误输出并修复,同一问题最多重试 3 次。
19
19
  7. 回答风格:使用用户的语言(默认中文),简洁直接,先结论后说明。
20
20
  8. 诚实可靠:不编造不存在的文件、行号、API 或执行结果。
package/src/schedule.js CHANGED
@@ -293,7 +293,9 @@ export function daemonAlive(/** @type {any} */ home) {
293
293
  }
294
294
  export function stopDaemon(/** @type {any} */ home) {
295
295
  try {
296
- const pid = Number(fs.readFileSync(daemonPidFile(home), 'utf8'));
296
+ // pidfile 格式 "<pid> <nonce>"——必须取首段(此前整串 Number()=NaN,SIGTERM 永远不发,
297
+ // 只删 pidfile → 孤儿 daemon 继续跑,新 daemon 再被拉起 → 双守护重复执行任务)
298
+ const pid = Number(String(fs.readFileSync(daemonPidFile(home), 'utf8')).trim().split(/\s+/)[0]);
297
299
  if (pid) {
298
300
  try {
299
301
  process.kill(pid, 'SIGTERM');
@@ -1,36 +1,71 @@
1
1
  // 会话检索索引(P3-2):词表倒排 + 增量同步,替代每次全量扫描全部会话文件。
2
- // 索引文件 <mingdao-home>/sessions-index.json
3
- // { files: { "<会话名>": { mtime, size, terms: {词: 次数} } } }
2
+ // 索引分片存储(v0.2.8 B1):<mingdao-home>/sessions-index/<2位sha1前缀>.json ——
3
+ // 每片 { files: { "<会话名>": { mtime, size, terms: {词: 次数} } } }
4
+ // 分片收益:>1000 会话时不再单次 JSON.parse/stringify 一个巨型索引文件;
5
+ // 同步只重写「变脏」的分片,读/写放大与内存峰值都大幅下降(256 片,每片 ~4 个会话)。
4
6
  // 同步策略:查询前对每个会话文件做 mtime/size 对比——未变化直接用缓存词表(O(词表)),
5
7
  // 变化/新增才重新分词(增量);已删除文件自动清理;>8MB 超大文件跳过并移除条目。
6
8
  // 分词:英文/数字/路径按词(小写),中文按 bigram + 单字,查询串同口径分词后按 AND 匹配。
7
9
 
8
10
  import fs from 'node:fs';
9
11
  import path from 'node:path';
12
+ import crypto from 'node:crypto';
10
13
  import { mingdaoHome, ensureHome } from './config.js';
11
14
  import { atomicWriteFileSync } from './atomic-write.js';
12
15
 
13
16
  const INDEX_MAX_FILE = 8 * 1024 * 1024;
17
+ const SHARD_HEX = 2; // sha1 前 2 个十六进制位 → 256 片
14
18
 
15
- export function sessionIndexFile() {
16
- return path.join(mingdaoHome(), 'sessions-index.json');
19
+ /** 分片目录(索引分片统一存放于此) */
20
+ export function shardDir() {
21
+ return path.join(mingdaoHome(), 'sessions-index');
17
22
  }
18
23
 
19
- export function loadSessionIndex() {
24
+ /** 会话名 分片名(sha1 前 2 位,稳定且均匀) */
25
+ /** @param {any} name */
26
+ export function shardOf(name) {
27
+ return crypto.createHash('sha1').update(String(name)).digest('hex').slice(0, SHARD_HEX);
28
+ }
29
+
30
+ /** @param {any} shard */
31
+ function shardFile(shard) {
32
+ return path.join(shardDir(), `${shard}.json`);
33
+ }
34
+
35
+ /** @param {any} shard */
36
+ function loadShard(shard) {
20
37
  try {
21
- const j = JSON.parse(fs.readFileSync(sessionIndexFile(), 'utf8'));
38
+ const j = JSON.parse(fs.readFileSync(shardFile(shard), 'utf8'));
22
39
  return j && typeof j === 'object' && j.files && typeof j.files === 'object' ? j : { files: {} };
23
40
  } catch {
24
41
  return { files: {} };
25
42
  }
26
43
  }
27
44
 
28
- /** @param {any} idx */
29
- export function saveSessionIndex(idx) {
45
+ /** @param {any} shard @param {any} idx */
46
+ function saveShard(shard, idx) {
30
47
  try {
31
48
  ensureHome();
32
- const target = sessionIndexFile();
33
- atomicWriteFileSync(target, JSON.stringify(idx) + '\n'); // 质检 H4:索引原子写
49
+ const dir = shardDir();
50
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
51
+ atomicWriteFileSync(shardFile(shard), JSON.stringify(idx) + '\n'); // 质检 H4:索引原子写
52
+ } catch {}
53
+ }
54
+
55
+ // 一次性迁移:旧版单文件 sessions-index.json → 分片目录(迁移后删除旧文件)
56
+ function migrateLegacyIndex() {
57
+ const legacy = path.join(mingdaoHome(), 'sessions-index.json');
58
+ if (!fs.existsSync(legacy)) return;
59
+ try {
60
+ const j = JSON.parse(fs.readFileSync(legacy, 'utf8'));
61
+ const files = j && typeof j === 'object' && j.files && typeof j.files === 'object' ? j.files : {};
62
+ for (const [name, entry] of Object.entries(files)) {
63
+ const shard = shardOf(name);
64
+ const idx = loadShard(shard);
65
+ idx.files[name] = entry;
66
+ saveShard(shard, idx);
67
+ }
68
+ fs.unlinkSync(legacy);
34
69
  } catch {}
35
70
  }
36
71
 
@@ -81,45 +116,99 @@ export function extractSessionText(file) {
81
116
  return parts.join('\n');
82
117
  }
83
118
 
84
- // 增量同步索引(对比 mtime/size,只重算变化的文件;清理已删除条目)
85
- /** @param {any} home @param {any} sessions */
119
+ // 增量同步索引(对比 mtime/size,只重算变化的文件;清理已删除条目)。
120
+ // 返回合并后的 { files: {名称: 词表条目} } 供检索匹配(仍只持有所需词表,不再单次解析巨型文件)。
121
+ /** @param {any} home @param {any} sessions
122
+ * @returns {{ files: Record<string, any> }} */
86
123
  export function syncSessionIndex(home, sessions) {
87
- const idx = loadSessionIndex();
124
+ migrateLegacyIndex();
88
125
  const dir = path.join(home, 'sessions');
89
- let changed = false;
126
+ /** @type {{ files: Record<string, any> }} */
127
+ const combined = { files: {} };
128
+ const liveShards = new Set();
129
+
130
+ // 按分片聚合存活会话:每片只 load 一次,命中/变化只写回该片
131
+ const byShard = new Map();
90
132
  for (const s of sessions) {
91
- let st;
92
- try {
93
- st = fs.statSync(s.file);
94
- } catch {
95
- delete idx.files[s.name];
133
+ const k = shardOf(s.name);
134
+ if (!byShard.has(k)) byShard.set(k, []);
135
+ byShard.get(k).push(s);
136
+ }
137
+
138
+ for (const [k, group] of byShard) {
139
+ liveShards.add(k);
140
+ const idx = loadShard(k);
141
+ let changed = false;
142
+ for (const s of group) {
143
+ let st;
144
+ try {
145
+ st = fs.statSync(s.file);
146
+ } catch {
147
+ delete idx.files[s.name];
148
+ changed = true;
149
+ continue;
150
+ }
151
+ const cached = idx.files[s.name];
152
+ if (cached && cached.mtime === st.mtimeMs && cached.size === st.size) continue; // 未变化:直接用缓存
96
153
  changed = true;
97
- continue;
154
+ if (st.size > INDEX_MAX_FILE) {
155
+ delete idx.files[s.name]; // 超大文件不索引
156
+ continue;
157
+ }
158
+ try {
159
+ idx.files[s.name] = {
160
+ mtime: st.mtimeMs,
161
+ size: st.size,
162
+ terms: Object.fromEntries(tokenize(extractSessionText(s.file))),
163
+ };
164
+ } catch {
165
+ delete idx.files[s.name];
166
+ }
98
167
  }
99
- const cached = idx.files[s.name];
100
- if (cached && cached.mtime === st.mtimeMs && cached.size === st.size) continue; // 未变化:直接用缓存
101
- changed = true;
102
- if (st.size > INDEX_MAX_FILE) {
103
- delete idx.files[s.name]; // 超大文件不索引
104
- continue;
168
+ // 清理本片内已删除的会话条目(防索引膨胀)
169
+ for (const name of Object.keys(idx.files)) {
170
+ if (!fs.existsSync(path.join(dir, name))) {
171
+ delete idx.files[name];
172
+ changed = true;
173
+ }
105
174
  }
106
- try {
107
- idx.files[s.name] = {
108
- mtime: st.mtimeMs,
109
- size: st.size,
110
- terms: Object.fromEntries(tokenize(extractSessionText(s.file))),
111
- };
112
- } catch {
113
- delete idx.files[s.name];
175
+ if (changed) saveShard(k, idx);
176
+ // 合并本片存活条目供检索
177
+ for (const s of group) {
178
+ const e = idx.files[s.name];
179
+ if (e) combined.files[s.name] = e;
114
180
  }
115
181
  }
116
- // 清理已删除的会话条目(防索引无限膨胀)
117
- for (const name of Object.keys(idx.files)) {
118
- if (!fs.existsSync(path.join(dir, name))) {
119
- delete idx.files[name];
120
- changed = true;
182
+
183
+ // 清理「无存活会话」分片里的陈旧条目(这些片的会话全被删了):目录扫描逐片检查,
184
+ // 每片都很小;空片直接删除文件,避免残留索引膨胀
185
+ let entries = [];
186
+ try {
187
+ entries = fs.readdirSync(shardDir());
188
+ } catch {
189
+ return combined;
190
+ }
191
+ for (const f of entries) {
192
+ if (!f.endsWith('.json')) continue;
193
+ const k = f.slice(0, -5);
194
+ if (liveShards.has(k)) continue;
195
+ const idx = loadShard(k);
196
+ let changed = false;
197
+ for (const name of Object.keys(idx.files)) {
198
+ if (!fs.existsSync(path.join(dir, name))) {
199
+ delete idx.files[name];
200
+ changed = true;
201
+ }
202
+ }
203
+ if (changed) {
204
+ if (Object.keys(idx.files).length) saveShard(k, idx);
205
+ else {
206
+ try {
207
+ fs.unlinkSync(path.join(shardDir(), f));
208
+ } catch {}
209
+ }
121
210
  }
122
211
  }
123
- if (changed) saveSessionIndex(idx);
124
- return idx;
212
+
213
+ return combined;
125
214
  }
@@ -3,11 +3,12 @@
3
3
 
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
+ import { MAX_FILE_READ_BYTES } from '../web/constants.js';
6
7
 
7
8
  const MAX_SCAN_FILES = 20000;
8
9
  const MAX_GLOB_RESULTS = 1000;
9
10
  const MAX_GREP_MATCHES = 250;
10
- const MAX_FILE_BYTES = 5 * 1024 * 1024;
11
+ const MAX_FILE_BYTES = MAX_FILE_READ_BYTES; // 单源化(v0.2.8 B2):read/edit 单文件读取上限
11
12
 
12
13
  /**
13
14
  * @param {any} cwd
@@ -83,9 +83,9 @@ const SKILL_SCHEMA = {
83
83
  const TASK_SCHEMA = {
84
84
  type: 'object',
85
85
  properties: {
86
- description: { type: 'string', description: '子任务一句话描述。' },
87
- prompt: { type: 'string', description: '子代理的完整任务说明。' },
88
- readOnly: { type: 'boolean', description: '只读调研任务,可并行。' },
86
+ description: { type: 'string', description: '子任务一句话描述(用于进度展示)。' },
87
+ prompt: { type: 'string', description: '子代理的完整任务说明(全新上下文,需自带全部必要背景)。' },
88
+ readOnly: { type: 'boolean', description: '只读调研任务(不写文件不执行命令)。多个 readOnly 子任务同一轮派发会并行执行,效率最高。' },
89
89
  },
90
90
  required: ['prompt'],
91
91
  };
@@ -185,7 +185,7 @@ const TOOLS = [
185
185
  type: 'function',
186
186
  function: {
187
187
  name: 'task',
188
- description: '把独立子任务委托给新上下文子代理并返回汇报;readOnly 只读任务可并行。',
188
+ description: '把独立、可并行的子任务委托给全新上下文子代理并返回汇报。适合:多方向调研/复核/独立实现等互不依赖的任务(多个只读调研子任务应同一轮一起派发,自动并行,效率最高);不适合:依赖当前对话细节的小改动。',
189
189
  parameters: TASK_SCHEMA,
190
190
  },
191
191
  },