mingdao-harness 0.4.4 → 0.4.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 (69) hide show
  1. package/docs/ARCHITECTURE.md +1 -1
  2. package/docs/AUDIT-v0.4.6.md +179 -0
  3. package/docs/PACK-API.md +250 -0
  4. package/docs/SAVINGS-BENCHMARK.md +12 -5
  5. package/docs/STRATEGY-0.5.md +262 -0
  6. package/docs/STRATEGY-NEXT.md +1 -1
  7. package/package.json +2 -1
  8. package/skills-lib/bash/SKILL.md +26 -0
  9. package/skills-lib/changelog/SKILL.md +24 -0
  10. package/skills-lib/ci-cd/SKILL.md +25 -0
  11. package/skills-lib/data-analysis/SKILL.md +25 -0
  12. package/skills-lib/database-design/SKILL.md +25 -0
  13. package/skills-lib/docs/SKILL.md +25 -0
  14. package/skills-lib/email/SKILL.md +25 -0
  15. package/skills-lib/file-organize/SKILL.md +27 -0
  16. package/skills-lib/git-workflow/SKILL.md +26 -0
  17. package/skills-lib/i18n/SKILL.md +24 -0
  18. package/skills-lib/markdown/SKILL.md +26 -0
  19. package/skills-lib/meeting-notes/SKILL.md +25 -0
  20. package/skills-lib/nodejs/SKILL.md +26 -0
  21. package/skills-lib/performance/SKILL.md +25 -0
  22. package/skills-lib/python/SKILL.md +26 -0
  23. package/skills-lib/readme/SKILL.md +25 -0
  24. package/skills-lib/regex/SKILL.md +25 -0
  25. package/skills-lib/report/SKILL.md +25 -0
  26. package/skills-lib/resume/SKILL.md +24 -0
  27. package/skills-lib/security-audit/SKILL.md +28 -0
  28. package/skills-lib/sql/SKILL.md +25 -0
  29. package/skills-lib/translation/SKILL.md +25 -0
  30. package/src/agent.js +95 -9
  31. package/src/atomic-write.js +9 -0
  32. package/src/autostart.js +37 -3
  33. package/src/batch.js +9 -1
  34. package/src/cachestats.js +32 -8
  35. package/src/cli.js +29 -14
  36. package/src/commands/repl.js +5 -3
  37. package/src/commands/update.js +1 -1
  38. package/src/compact.js +16 -1
  39. package/src/context.js +8 -0
  40. package/src/cost-guard.js +26 -6
  41. package/src/hooks.js +20 -4
  42. package/src/log-writer.js +19 -7
  43. package/src/mcp.js +4 -0
  44. package/src/memory.js +6 -5
  45. package/src/model-caps.js +48 -4
  46. package/src/permissions.js +10 -4
  47. package/src/presets.js +13 -3
  48. package/src/pricing.js +78 -18
  49. package/src/providers/index.js +43 -13
  50. package/src/providers/openai-compatible.js +42 -1
  51. package/src/redact.js +5 -0
  52. package/src/schedule.js +62 -30
  53. package/src/session.js +14 -1
  54. package/src/skill-lib.js +23 -4
  55. package/src/skills.js +2 -2
  56. package/src/sync-server.js +29 -1
  57. package/src/sync.js +6 -3
  58. package/src/tasks/worker.js +4 -1
  59. package/src/tasks.js +4 -1
  60. package/src/tokenizer.js +48 -11
  61. package/src/tools/bash.js +9 -2
  62. package/src/tools/fetch.js +97 -8
  63. package/src/tools/fs-tools.js +48 -3
  64. package/src/tools/git.js +17 -1
  65. package/src/tools/index.js +12 -5
  66. package/src/web/constants.js +10 -0
  67. package/src/web/routes/api.js +3 -1
  68. package/src/web/routes/domains/misc.js +6 -0
  69. package/src/web/server.js +41 -31
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: performance
3
+ description: 性能分析与调优(定位瓶颈/基准/优化验证)
4
+ ---
5
+
6
+ # 性能分析与调优
7
+
8
+ 程序慢、内存高、接口延迟大时使用。
9
+
10
+ ## 步骤
11
+
12
+ 1. 先测量再优化:明确指标(延迟/吞吐/内存)与基线数据。
13
+ 2. 定位瓶颈:按「CPU / IO / 网络 / 锁 / 内存」分类,用工具确认(top、profiler、日志耗时打点)。
14
+ 3. 按 80/20 原则只优化热点;每次只改一处,改后立即对比基线。
15
+ 4. 常见手段:缓存、批处理、索引、连接复用、异步化、减少序列化。
16
+
17
+ ## 检查点
18
+
19
+ - 禁止无测量的「感觉优化」;优化前给出当前基线数字;
20
+ - 优化可能引入正确性风险(缓存一致性等),注明并验证;
21
+ - 环境因素(冷启动/网络抖动)要排除后再下结论。
22
+
23
+ ## 输出
24
+
25
+ 「基线 → 瓶颈定位(证据)→ 优化项(按收益排序)→ 优化后对比」。
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: python
3
+ description: Python 编码规范与最佳实践
4
+ ---
5
+
6
+ # Python 编码规范
7
+
8
+ 写/改/审查 Python 代码时使用。
9
+
10
+ ## 规则
11
+
12
+ 1. 风格遵循 PEP 8:4 空格缩进、行宽 ≤ 100、snake_case 命名。
13
+ 2. 类型注解:函数签名写参数与返回类型;用 `pathlib` 处理路径、`dataclasses` 代替裸字典。
14
+ 3. 现代语法:f-string 格式化、`with` 管理资源、异常只捕获具体类型(不裸 `except:`)。
15
+ 4. 结构:纯函数优先、依赖注入便于测试;副作用集中在入口处。
16
+ 5. 依赖:优先标准库;第三方包注明版本(pyproject.toml)。
17
+ 6. 环境:虚拟环境 + `pyproject.toml`,兼容性注明 Python 最低版本。
18
+
19
+ ## 检查点
20
+
21
+ - 不混用制表符;字符串编码显式 UTF-8;
22
+ - 可运行性优先:改动后给出运行/测试命令。
23
+
24
+ ## 输出
25
+
26
+ 代码 + 关键设计说明(为什么这样写)。
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: readme
3
+ description: README 写作(项目门面:安装/用法/特色)
4
+ ---
5
+
6
+ # README 写作
7
+
8
+ 新建 README 或重写项目首页文档时使用。
9
+
10
+ ## 结构
11
+
12
+ 1. 项目名 + 一句话定位(解决什么问题、给谁用)。
13
+ 2. 特色列表:3–6 条最打动人心的亮点,不堆功能。
14
+ 3. 快速开始:安装 → 最小示例,5 分钟内跑通,命令真实可执行。
15
+ 4. 配置与常见用法、文档链接、贡献方式、许可证。
16
+ 5. 徽章与截图酌情添加;中文项目注意中英文间距与排版。
17
+
18
+ ## 检查点
19
+
20
+ - 每个命令都实际验证过;版本号/链接与仓库一致;
21
+ - 避免大段空话(「强大、易用」要有具体支撑);长度以「读完能上手」为准。
22
+
23
+ ## 输出
24
+
25
+ Markdown 全文 + 一句修改说明。
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: regex
3
+ description: 正则表达式编写、验证与性能注意
4
+ ---
5
+
6
+ # 正则表达式
7
+
8
+ 编写、调试或优化正则时使用。
9
+
10
+ ## 步骤
11
+
12
+ 1. 明确目标字符串样例(含边界与反例),先列 3–5 个正例和反例。
13
+ 2. 选择风格:grep/awk 用 POSIX ERE,Python/JS 用 PCRE 风格,注明方言差异。
14
+ 3. 逐步构造:锚点(^ $ \b)→ 字符类 → 量词 → 分组/捕获,能不用回溯就不用。
15
+ 4. 用测试样例逐条验证;拿不准的边界(空串、换行、Unicode)单独测。
16
+
17
+ ## 检查点
18
+
19
+ - 警惕灾难性回溯(嵌套量词如 `(a+)+`),给出安全替代写法;
20
+ - 明确是否区分大小写、是否跨行匹配(m/s 标志);
21
+ - 替换场景注意捕获组顺序与 `$1` 含义。
22
+
23
+ ## 输出
24
+
25
+ 「正则 + 逐段解释 + 正反例测试结果 + 方言/标志说明」。
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: report
3
+ description: 周报/月报/汇报总结写作(结构/亮点/数据)
4
+ ---
5
+
6
+ # 周报与汇报写作
7
+
8
+ 写周报、月报、阶段总结或向上汇报材料时使用。
9
+
10
+ ## 规则
11
+
12
+ 1. 结构:本周成果(先说结论)→ 数据支撑 → 问题与风险 → 下周计划,篇幅按场合控制。
13
+ 2. 成果写法:动词 + 事项 + 进展/结果(完成/进行中 注明百分比),重点不超过 3 条。
14
+ 3. 数据优先:有数字用数字(「处理 32 个工单,平均 4 小时关闭」优于「高效处理工单」)。
15
+ 4. 问题透明:说风险必带「影响 + 建议方案 + 需要的支持」,不只有抱怨。
16
+ 5. 计划可验证:下周条目写成可检查的交付物,避免「继续推进」。
17
+
18
+ ## 检查点
19
+
20
+ - 时间与事实核对无误;语气客观,不夸大不贬低;
21
+ - 提供「详细版(给直属上级)+ 精简版(给大群/上级的上级)」。
22
+
23
+ ## 输出
24
+
25
+ 两版周报 + 一句核心亮点摘要。
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: resume
3
+ description: 简历优化(结构/量化/岗位匹配)
4
+ ---
5
+
6
+ # 简历优化
7
+
8
+ 修改简历或针对岗位定制简历时使用。
9
+
10
+ ## 规则
11
+
12
+ 1. 结构:基本信息 → 个人优势(3 行)→ 工作/项目经历(倒序)→ 技能 → 教育,总量 1–2 页。
13
+ 2. 经历写法:动词开头 + 动作 + 方法 + 可量化结果(如「将接口耗时从 800ms 降到 120ms,支撑日活 10 万」),避免职责罗列。
14
+ 3. 岗位匹配:读 JD 提取关键词,经历按相关度重排,删除无关内容;一岗一版。
15
+ 4. 排版:统一字体/日期格式,无错别字(逐字检查),导出 PDF 前预览。
16
+
17
+ ## 检查点
18
+
19
+ - 所有数字与事实真实,不确定的向用户确认,不编造;
20
+ - 敏感信息(身份证/薪资/联系方式)按用户要求保留或脱敏。
21
+
22
+ ## 输出
23
+
24
+ 「修改后简历全文 + 修改点清单 + 针对该岗位的一句话匹配度分析」。
@@ -0,0 +1,28 @@
1
+ ---
2
+ name: security-audit
3
+ description: 代码与配置安全审计
4
+ ---
5
+
6
+ # 安全审计
7
+
8
+ 审查代码/依赖/配置的安全风险时使用。
9
+
10
+ ## 步骤
11
+
12
+ 1. 列出攻击面:外部输入入口、网络请求、文件读写、命令执行、认证与密钥。
13
+ 2. 逐类检查:
14
+ - 注入:SQL/命令/路径注入,用户输入是否拼接进查询或 shell;
15
+ - 密钥:硬编码 key/token/password(`grep -rE 'sk-[A-Za-z0-9]{20,}|password\s*=' .`);
16
+ - 依赖:已知漏洞版本、不必要的运行时依赖;
17
+ - 文件与权限:路径穿越、敏感文件权限(600)、上传校验;
18
+ - 传输与加密:明文 HTTP 传敏感数据、弱哈希(MD5/SHA1 用于口令)。
19
+ 3. 按「严重/高/中/低」分级,每项给出复现路径与修复方案。
20
+
21
+ ## 检查点
22
+
23
+ - 只审计不直接修改,修复等用户确认(紧急项明确标出);
24
+ - 不把密钥内容写进输出,发现时用脱敏形式提示。
25
+
26
+ ## 输出
27
+
28
+ 「风险清单表格(级别/位置/问题/修复)+ 总体结论」。
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: sql
3
+ description: SQL 编写、审查与性能优化
4
+ ---
5
+
6
+ # SQL 编写与优化
7
+
8
+ 写查询、审查现有 SQL、排查慢查询时使用。
9
+
10
+ ## 步骤
11
+
12
+ 1. 确认数据库方言(MySQL 8 / PostgreSQL / SQLite 等),先看表结构(`DESCRIBE` / `\d` 或读 schema 文件)。
13
+ 2. 写查询:显式列名(避免 `SELECT *`)、JOIN 条件明确、NULL 语义想清楚。
14
+ 3. 优化:先看 `EXPLAIN`,检查全表扫描、缺失索引、隐式类型转换;大分页用游标/键集分页替代深 `OFFSET`。
15
+ 4. 防注入:拼接参数一律用绑定变量/参数化查询。
16
+
17
+ ## 检查点
18
+
19
+ - 预估数据量级,注明是否适用;无法确认表结构时先询问用户;
20
+ - 写操作(UPDATE/DELETE)先给出 WHERE 影响范围,默认建议先 SELECT 验证;
21
+ - 迁移类语句给出回滚语句。
22
+
23
+ ## 输出
24
+
25
+ 「SQL 语句(带注释)+ 为什么这样写 + 性能注意点」。
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: translation
3
+ description: 中英互译(术语一致/语气适配/本地化质量)
4
+ ---
5
+
6
+ # 中英互译
7
+
8
+ 翻译技术文档、产品文案或校对译文时使用。
9
+
10
+ ## 规则
11
+
12
+ 1. 先通读全文定基调:技术文档准确优先、营销文案感染力优先、界面文案简洁优先。
13
+ 2. 术语统一:同一概念全文同一译法;专有名词(产品名/协议名)保留原文;首次出现可加注。
14
+ 3. 避免直译:拆长句、调整语序,中文忌欧化句式(过多「的/被/进行」),英文忌中式堆砌。
15
+ 4. 格式保留:Markdown 标记、代码块、链接、占位符 `{var}` 原样保留不翻译。
16
+ 5. 校对:译文独立通读一遍(不看原文),检查数字/单位/日期格式本地化。
17
+
18
+ ## 检查点
19
+
20
+ - 代码、命令、API 名称不翻译;歧义处标注并给出备选译法;
21
+ - 输出「译文 + 关键术语对照表 + 处理说明」。
22
+
23
+ ## 输出
24
+
25
+ 译文 + 术语表 + 一句校对说明。
package/src/agent.js CHANGED
@@ -23,11 +23,16 @@ const MAX_STEPS = 24;
23
23
  // 提到与主循环一致(24),只读子任务每步是 read/grep(输入便宜、无输出 token),成本增量可忽略。
24
24
  const SUBAGENT_MAX_STEPS = 24;
25
25
 
26
+ // 只读档工具集(省钱 B1 的「只读阶段」)——模块级单一来源。
27
+ // v0.4.6:此前 test/bench 各自维护一份副本,已经漂移(漏了 v0.4.4 加入的 task),
28
+ // 导致基准测的不是真实只读档。导出后基准与实现共用同一集合。
29
+ export const READONLY_TIER_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'todo', 'git', 'fetch', 'task']);
30
+
26
31
  /**
27
32
  * 创建 Agent 循环(调用方只需传 provider/permission/io/modelName/workingDir,其余可选)
28
33
  * @param {{ provider: any, permission: any, io: any, modelName: any, workingDir: any,
29
34
  * cfg?: any, undoStore?: any, maxSteps?: number, mcp?: any, onCompact?: any, sessionRef?: any,
30
- * onUsage?: (usage: any) => void }} params
35
+ * onUsage?: (modelName: string, usage: any) => void }} params
31
36
  */
32
37
  export function createAgent({ provider, permission, io, modelName, workingDir, cfg = {}, undoStore, maxSteps, mcp, onCompact, sessionRef, onUsage }) {
33
38
  const preset = modelPreset(modelName) || {};
@@ -39,7 +44,13 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
39
44
  const budget = safeBudget(cfg, caps);
40
45
  // maxOutput 也按窗口封顶:显式配超大 maxOutputTokens 时,prompt(预算)+output 仍不得越过窗口
41
46
  // (预算已按 caps.maxOutputTokens 留余量,但显式值可能更大——此处兜底,防服务端截断/拒绝)
42
- const maxOutput = Math.min(cfg.maxOutputTokens || caps.maxOutputTokens, Math.max(1024, caps.contextWindow - budget));
47
+ const maxOutput = Math.min(
48
+ cfg.maxOutputTokens || caps.maxOutputTokens,
49
+ // v0.4.6:显式 maxOutputTokens 也要受官方单次输出规格(maxOutputCeiling,DeepSeek 384K)约束——
50
+ // 该字段此前只定义不生效,README 的「单次输出上限 384K」实际拿不到。
51
+ caps.maxOutputCeiling || Number.MAX_SAFE_INTEGER,
52
+ Math.max(1024, caps.contextWindow - budget)
53
+ );
43
54
  // v0.3.2 工具输出截断自适应:窗口越小截得越狠(单条工具结果按窗口 1/16 封顶,最少 2000 字),
44
55
  // 但绝不超过旧默认 20000(大窗口模型如 1M 不因公式放大回灌、不推高成本)。
45
56
  // 本地小模型(32k 窗口 → 2k 字)不再把大段代码/日志整条回灌,省 prompt 且不撑爆窗口。
@@ -48,6 +59,11 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
48
59
  const reasoningEffort = cfg.reasoningByModel?.[modelName] ?? cfg.reasoningEffort ?? preset.reasoningEffort?.default ?? undefined;
49
60
  const hooks = createHooks(cfg.hooks, workingDir, cfg);
50
61
  const todos = /** @type {any[]} */ ([]);
62
+ // v0.4.6 修复:当前 runTurn 的 usage 累加器引用——spawnTask 定义在 runTurn 之外,
63
+ // 此前子代理的 token 消耗只用于生成汇报文本、从不并入父回合 usage,导致「今日费用」系统性少计
64
+ // (CLI/REPL 每次派子代理都漏计;README 主推的「多方向并行调研」场景漏计最重)。
65
+ // runTurn 开始时指向本轮 usage,finally 清空;runTurn 之外调用 spawnTask 时为 null(安全跳过)。
66
+ let currentUsage = /** @type {any} */ (null);
51
67
  // 会话级共享:调用方传入则复用(/model 切换、子代理均共享,undo 不丢失)
52
68
  const undo = undoStore || { backups: new Map() };
53
69
  const stepLimit = maxSteps || MAX_STEPS;
@@ -64,7 +80,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
64
80
  // + 已用过的工具;检测到写意图(用户消息或模型明说需要写/改/建)后注入全量工具。
65
81
  // v0.4.4:加 task——审计/调研等只读长任务此前因 task 不在只读档而看不到「派只读子代理」能力
66
82
  // (readOnly 子代理只读,权限引擎仍门控写操作,无越权)。
67
- const READONLY_TIER_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'todo', 'git', 'fetch', 'task']);
83
+ // 只读档工具集:单一来源见模块顶部导出的 READONLY_TIER_SET
68
84
  // 中英双语写意图(CodeArts 报告:纯中文正则让英文会话整回合只读死锁)
69
85
  const WRITE_INTENT_RE = /写|建|创|改|修|删|装|加|添|增|补|换|移|部署|执行|运行|实现|重构|生成|迁移|安装|更新|升级|发布|调整|优化|修复|提交|推送|打包|编译|测试|implement|fix|create|modify|update|delete|deploy|build|make|generate|install|write|refactor|migrate|test|run|commit|push|remove|add|change|patch/i;
70
86
  const hasWriteIntent = (/** @type {any} */ text) => WRITE_INTENT_RE.test(String(text || ''));
@@ -112,6 +128,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
112
128
  maxSteps: SUBAGENT_MAX_STEPS,
113
129
  mcp,
114
130
  sessionRef, // 子代理的审计记录归入主会话
131
+ // 注意:这里**不**透传 onUsage。子代理的消耗改为在下方 runTurn 返回后一次性并入父回合 usage
132
+ // (v0.4.6)。若同时透传 onUsage 又并入总量,WebUI 会对子代理已逐轮入账的部分重复计费。
115
133
  });
116
134
  const sys =
117
135
  `你是主智能体 MingDao 派出的子代理,独立完成一项子任务。` +
@@ -126,6 +144,20 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
126
144
  const t0 = Date.now();
127
145
  const res = await subAgent.runTurn(messages);
128
146
  const ms = Date.now() - t0;
147
+ // v0.4.6 P1 修复:子代理 token 消耗并入父回合 usage(缓存命中/未命中字段一并累加)——
148
+ // 否则子代理完全不计费:CLI/REPL(无 onUsage)恒漏计;WebUI 仅在子代理跨轮时偶发计入。
149
+ // 并入后父回合 res.usage 即「真总量」,护栏在途估算、分账、今日费用同时变准。
150
+ if (currentUsage && res?.usage) {
151
+ const u = res.usage;
152
+ currentUsage.prompt_tokens += u.prompt_tokens || 0;
153
+ currentUsage.completion_tokens += u.completion_tokens || 0;
154
+ if (Number.isFinite(u.prompt_cache_hit_tokens)) {
155
+ currentUsage.prompt_cache_hit_tokens = (currentUsage.prompt_cache_hit_tokens || 0) + u.prompt_cache_hit_tokens;
156
+ }
157
+ if (Number.isFinite(u.prompt_cache_miss_tokens)) {
158
+ currentUsage.prompt_cache_miss_tokens = (currentUsage.prompt_cache_miss_tokens || 0) + u.prompt_cache_miss_tokens;
159
+ }
160
+ }
129
161
  // v0.4.1:子代理空输出给主线程可用的失败信号(含 note 原因),而非笼统「无输出」——
130
162
  // 主智能体据此决定是否重试/换法,而非把子代理静默当作「已完成但没说话」。
131
163
  const text =
@@ -160,11 +192,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
160
192
  const maxRounds = Math.max(1, Number(cfg.maxRounds) || 3);
161
193
  let round = 0;
162
194
  const usage = /** @type {{ prompt_tokens: number, completion_tokens: number, prompt_cache_hit_tokens?: number, prompt_cache_miss_tokens?: number }} */ ({ prompt_tokens: 0, completion_tokens: 0 });
195
+ currentUsage = usage; // 子代理消耗并入本回合总量(见 spawnTask)
163
196
  const startedAt = Date.now();
164
197
  // 回合性能指标(状态栏:LLM 时长 / 工具时长 / 首 token 延迟 / 步数)
165
198
  let llmMsTotal = 0;
166
199
  let toolMsTotal = 0;
167
200
  let firstTokenAt = /** @type {any} */ (null);
201
+ // v0.4.6:记录最近一次模型请求的**发起**时刻——峰谷单价必须按发起时刻判定。
202
+ // 此前 recordUsage 在响应落地后用 new Date() 计价,跨 12:00/18:00 边界的请求会错记一档
203
+ // (1M prompt 的 pro 调用是 ¥9 vs ¥4.5 的差别)。
204
+ let lastRequestStartAt = /** @type {any} */ (null);
168
205
  // 省钱 B3(费用二级分账):推理 token 估算(按增量累计)与逐工具调用/耗时累加
169
206
  let reasoningTokens = 0;
170
207
  const toolStats = /** @type {Map<string, {calls: number, ms: number}>} */ (new Map());
@@ -177,6 +214,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
177
214
  reasoningTokens,
178
215
  toolStats: [...toolStats.entries()].map(([tool, s]) => ({ tool, calls: s.calls, ms: s.ms })),
179
216
  usedModel: activeModel, // 省钱 B4:本回合实际使用模型(降级后归属它)
217
+ requestStartAt: lastRequestStartAt, // v0.4.6:峰谷计价锚点(请求发起时刻,非落账时刻)
180
218
  deliverables: [...deliverables], // v0.3.1:CLI/REPL 续跑检查点复用(此前 artifacts 恒空)
181
219
  });
182
220
  let aborted = false;
@@ -194,7 +232,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
194
232
  const inFlightCost = () => estimateCost(activeModel, usage.prompt_tokens, usage.completion_tokens, null, new Date());
195
233
  const usedTodayWithInflight = () => {
196
234
  const today = todayCost();
197
- return today == null ? null : today + inFlightCost();
235
+ if (today == null) return null;
236
+ const inflight = inFlightCost();
237
+ // P0-4(v0.4.5):无价模型在途费用为 null(未知)——整体视为「无法判断」而非 +null→today 的静默忽略
238
+ return inflight == null ? null : today + inflight;
198
239
  };
199
240
  // 省钱 B1:本回合只读阶段判定——最新用户消息无写意图则先只发只读工具,
200
241
  // 模型明确表达写意图后(下一轮)注入全量。cfg.schemaTier=false 可关。
@@ -221,6 +262,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
221
262
  // v0.4.1 P1 修复:turnToolCache 必须声明在 while 之外、for 轮内——此前在 while 体内每步重建,
222
263
  // 去重只在本步的多个工具调用间生效,跨步(如先 grep 定位再 read 确认同一文件)完全失效。
223
264
  const turnToolCache = new Map();
265
+ // v0.4.6 P1 修复:有副作用的工具一旦执行,本回合的只读去重缓存必须整片作废。
266
+ // v0.4.1 把 turnToolCache 提到 for 轮内,让去重跨步生效(评估 A4),却没有失效点——
267
+ // 同回合「read a → write a → read a」的第 3 步会命中第 1 步缓存,模型拿到写入前的内容、
268
+ // 误判写入未生效,进而重复写入或得出错误结论(已实测复现)。只读工具与 task(readOnly)
269
+ // 不改变文件状态,缓存保留(去重收益不受影响)。
270
+ const invalidateReadCache = (/** @type {any} */ prep) => {
271
+ if (!prep.isMcp && READONLY_TOOLS_SET.has(prep.name)) return;
272
+ if (prep.name === 'task' && prep.args?.readOnly === true) return;
273
+ turnToolCache.clear();
274
+ };
224
275
  for (round = 0; round < maxRounds; round++) {
225
276
  steps = 0;
226
277
  // v0.4.4:每轮结束回调本轮增量 usage(长任务费用逐轮入账——此前只在 runTurn 全结束后才
@@ -305,8 +356,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
305
356
  for (const m of sanitized) promptTokens += messageTokens(m, count);
306
357
  const worst = estimateCost(activeModel, promptTokens, maxOutput, null, new Date());
307
358
  const used = usedTodayWithInflight(); // 含本回合在途费用(防长回合烧穿)
308
- if (used == null) {
309
- // todayCost 读取失败:无法判断,跳过前置拦截(护栏主检查同样跳过并告警)
359
+ if (used == null || worst == null) {
360
+ // P0-4(v0.4.5):统计不可读 或 无价格数据(最坏成本未知)→ 无法判断,跳过前置拦截
361
+ // (护栏主检查 checkCostGuard 会显式告警「无价格数据」,此处不重复误拦也不静默放行)
310
362
  } else if (used + worst >= Number(g.dailyLimitYuan)) {
311
363
  stripOrphanCalls();
312
364
  return {
@@ -321,7 +373,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
321
373
  // 费用护栏(A2/B4):每轮开始前按今日实际费用检查;block 暂停本轮;
322
374
  // downgrade 自动切换便宜模型继续执行(每回合只切一次,切换即粘滞)
323
375
  if (cfg.costGuard) {
324
- const guard = checkCostGuard();
376
+ const guard = checkCostGuard(activeModel);
325
377
  if (guard) {
326
378
  if (guard.blocked) {
327
379
  stripOrphanCalls();
@@ -381,6 +433,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
381
433
  // 审计(tsc 扩面发现):llmT0 此前在 try 内声明、catch 内引用——chat 抛错时
382
434
  // catch 自身 ReferenceError,掩盖原始错误且计时丢失;提到 try 外声明。
383
435
  const llmT0 = Date.now();
436
+ lastRequestStartAt = llmT0;
384
437
  try {
385
438
  res = await provider.chat({
386
439
  model: activeModel,
@@ -410,6 +463,24 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
410
463
  stripOrphanCalls();
411
464
  return { text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: true, durationMs: Date.now() - startedAt, perf: perf() };
412
465
  }
466
+ // MacBook 本地 507 memory_refusal 根因(v0.4.5):服务端内存拒绝不是「空输出」——
467
+ // 直接终结合合并透出降级提示,不计入空轮、不注入续写重试(内存未释放必再 507,空烧请求)。
468
+ const e = /** @type {any} */ (err);
469
+ if (e?.status === 507 || /memory_refusal|内存不足|内存拒绝/i.test(String(e?.message || ''))) {
470
+ stripOrphanCalls();
471
+ return {
472
+ text: null,
473
+ reasoning: '',
474
+ usage,
475
+ steps,
476
+ finish,
477
+ truncated: false,
478
+ aborted: false,
479
+ note: '本地模型内存不足(507 memory_refusal)——请压缩上下文(减小 config.contextBudget 或 /compact)、减少并发子任务,或重启模型服务释放内存后再继续。',
480
+ durationMs: Date.now() - startedAt,
481
+ perf: perf(),
482
+ };
483
+ }
413
484
  throw err;
414
485
  }
415
486
 
@@ -523,6 +594,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
523
594
  let allowed = false;
524
595
  if (isMcp && mcp?.isReadonly(name)) {
525
596
  allowed = true; // MCP 工具的只读标注自动放行
597
+ } else if (name === 'task' && args.readOnly === true) {
598
+ allowed = true; // P2-2(v0.4.5):只读子代理自动放行——它本身只读(readOnly 子代理只能读),
599
+ // 与 release note「可派 readOnly 子代理」意图一致;非 readOnly 的 task 仍走权限询问
526
600
  } else {
527
601
  try {
528
602
  allowed = await permission.check(name, args);
@@ -558,9 +632,12 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
558
632
  } else {
559
633
  result = await dispatch(prep.name, prep.args, ctx);
560
634
  }
635
+ invalidateReadCache(prep); // 有副作用 → 作废本回合只读缓存(写后再读必须看到新内容)
561
636
  if (dedupKey) turnToolCache.set(dedupKey, result);
562
637
  return result;
563
638
  } catch (/** @type {any} */ err) {
639
+ // 失败也可能已产生副作用(半写入/部分执行)→ 同样作废缓存
640
+ invalidateReadCache(prep);
564
641
  return JSON.stringify({ ok: false, error: String(err?.message || err) });
565
642
  }
566
643
  }
@@ -740,9 +817,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
740
817
  }
741
818
  // v0.3.1 自动续跑(长程执行):还有剩余轮次且未中断 → 注入进度摘要直接续跑,不落收尾总结
742
819
  if (round < maxRounds - 1 && !aborted) {
743
- // 每轮结束:回调本轮增量 usage(含缓存命中拆分),供调用方逐轮入账
820
+ // 每轮结束:回调本轮增量 usage(含缓存命中拆分),供调用方逐轮入账。activeModel 为本轮实际模型
821
+ // (护栏降级/子代理都据此正确归属——P2-5 模型名一致性 + P1-5 子代理费用入账)。
744
822
  try {
745
- onUsage?.({
823
+ onUsage?.(activeModel, {
746
824
  prompt_tokens: usage.prompt_tokens - roundUsageStart.prompt_tokens,
747
825
  completion_tokens: usage.completion_tokens - roundUsageStart.completion_tokens,
748
826
  prompt_cache_hit_tokens: (usage.prompt_cache_hit_tokens || 0) - roundUsageStart.prompt_cache_hit_tokens,
@@ -776,6 +854,12 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
776
854
  { role: 'user', content: '(系统提示)任务已执行完毕。请用一段话总结刚才完成的工作,列出交付物(文件路径),并说明遗留问题与后续建议。' },
777
855
  ];
778
856
  currentAc = new AbortController();
857
+ lastRequestStartAt = Date.now();
858
+ // v0.4.6 P1 修复:进入兜底总结前本回合已多次 io.endTurn()(步数上限分支),TUI 的 renderer
859
+ // 已被置空,而 onDelta → io.writeText 只做 `renderer?.push(...)`——总结文本被静默丢弃,
860
+ // 跑满 24 步的长任务(审计/重构/调研)在终端里只看到一屏工具调用、没有最终答复。
861
+ // 重新 beginTurn() 开一个渲染段(web-io 的 beginTurn 是空实现,不受影响)。
862
+ io.beginTurn();
779
863
  const wrapRes = await provider.chat({
780
864
  model: activeModel,
781
865
  messages: wrapReq,
@@ -788,6 +872,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
788
872
  if (d.text) io.writeText(d.text);
789
873
  },
790
874
  });
875
+ io.endTurn(); // 收尾:flush 流式渲染段(与正常 turn 一致)
791
876
  if (wrapRes.usage) {
792
877
  usage.prompt_tokens += wrapRes.usage.prompt_tokens || 0;
793
878
  usage.completion_tokens += wrapRes.usage.completion_tokens || 0;
@@ -812,6 +897,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
812
897
  throw err;
813
898
  } finally {
814
899
  currentAc = null;
900
+ currentUsage = null; // 回合结束:避免 runTurn 之外调用的 spawnTask 写入陈旧累加器
815
901
  offSigint();
816
902
  }
817
903
  }
@@ -31,10 +31,17 @@ export function atomicWriteJsonSync(/** @type {string} */ target, /** @type {any
31
31
 
32
32
  // 极简互斥锁:O_EXCL 创建 lockfile;持锁期间执行 fn(同步);异常/完成释放。
33
33
  // 进程崩溃遗留的陈旧锁(> staleMs 未更新)自动回收,避免永久卡死。
34
+ // 可重入(P0-1 修复,v0.4.5):本进程已持该锁时直接执行 fn——O_EXCL 锁不可重入,此前
35
+ // killTask 持锁内调 patchTask 二次抢同一把锁自死锁 5s(tasks kill/pause/remove 全失效)。
34
36
  const sleepBuf = new Int32Array(new SharedArrayBuffer(4));
35
37
  const sleepMs = (/** @type {number} */ ms) => Atomics.wait(sleepBuf, 0, 0, ms);
38
+ const heldLocks = new Set(); // 本进程当前持有的锁路径(可重入判定)
36
39
 
37
40
  export function withFileLockSync(/** @type {string} */ lockPath, /** @type {() => any} */ fn, { timeoutMs = 5000, staleMs = 15000 } = {}) {
41
+ // 可重入:同一调用栈内已持该锁 → 直接执行,不再二次抢锁
42
+ if (heldLocks.has(lockPath)) {
43
+ return fn();
44
+ }
38
45
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
39
46
  const t0 = Date.now();
40
47
  for (;;) {
@@ -45,9 +52,11 @@ export function withFileLockSync(/** @type {string} */ lockPath, /** @type {() =
45
52
  } finally {
46
53
  fs.closeSync(fd);
47
54
  }
55
+ heldLocks.add(lockPath);
48
56
  try {
49
57
  return fn();
50
58
  } finally {
59
+ heldLocks.delete(lockPath);
51
60
  try {
52
61
  fs.unlinkSync(lockPath);
53
62
  } catch {}
package/src/autostart.js CHANGED
@@ -7,8 +7,26 @@
7
7
  import fs from 'node:fs';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
+ import { spawnSync } from 'node:child_process';
10
11
  import { fileURLToPath } from 'node:url';
11
12
 
13
+ // v0.4.6 P2 修复:自启命令必须用**绝对路径**。
14
+ // 三个平台原先都只写命令名(`mingdao web 3820`)。在 macOS 上这是必然失败:launchd 给 agent 的
15
+ // 是一个极简环境(PATH=/usr/bin:/bin:/usr/sbin:/sbin,不读登录 shell 配置),而 mingdao 常装在
16
+ // /usr/local/bin、~/.local/bin 或 nvm 目录 —— 实测 `env -i PATH=… command -v mingdao` 找不到,
17
+ // 登录后只会把 "command not found" 写进 StandardErrorPath,WebUI 静默不自启。
18
+ // 现用 process.execPath(node 绝对路径)+ src/cli.js 绝对路径,彻底不依赖 PATH。
19
+ function cliEntry() {
20
+ try {
21
+ return fileURLToPath(new URL('./cli.js', import.meta.url));
22
+ } catch {
23
+ return path.join(process.cwd(), 'src', 'cli.js');
24
+ }
25
+ }
26
+ function nodeBin() {
27
+ return process.execPath || 'node';
28
+ }
29
+
12
30
  function linuxFile() {
13
31
  return path.join(os.homedir(), '.config', 'autostart', 'mingdao-web.desktop');
14
32
  }
@@ -36,26 +54,36 @@ export function enableAutostart() {
36
54
  const target = autostartPath();
37
55
  try {
38
56
  fs.mkdirSync(path.dirname(target), { recursive: true });
57
+ const node = nodeBin();
58
+ const cli = cliEntry();
39
59
  if (process.platform === 'win32') {
40
- fs.writeFileSync(target, '@echo off\r\nstart "" cmd /c "mingdao web 3820"\r\n');
60
+ fs.writeFileSync(target, `@echo off\r\nstart "" "${node}" "${cli}" web 3820\r\n`);
41
61
  } else if (process.platform === 'darwin') {
42
62
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
43
63
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
44
64
  <plist version="1.0"><dict>
45
65
  <key>Label</key><string>org.mingdao.web</string>
46
66
  <key>ProgramArguments</key><array>
47
- <string>/bin/sh</string><string>-c</string><string>mingdao web 3820</string>
67
+ <string>${node}</string><string>${cli}</string><string>web</string><string>3820</string>
48
68
  </array>
49
69
  <key>RunAtLoad</key><true/>
70
+ <key>EnvironmentVariables</key><dict>
71
+ <key>PATH</key><string>${path.dirname(node)}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
72
+ </dict>
50
73
  <key>StandardOutPath</key><string>/tmp/mingdao-web.log</string>
51
74
  <key>StandardErrorPath</key><string>/tmp/mingdao-web.err</string>
52
75
  </dict></plist>
53
76
  `;
54
77
  fs.writeFileSync(target, plist);
78
+ // 立即注册(否则要等下次登录才生效,用户以为开关没起作用)。失败不致命:
79
+ // 文件已落盘,下次登录仍会由 RunAtLoad 拉起。
80
+ try {
81
+ spawnSync('launchctl', ['bootstrap', `gui/${process.getuid?.() ?? 0}`, target], { stdio: 'ignore' });
82
+ } catch {}
55
83
  } else {
56
84
  fs.writeFileSync(
57
85
  target,
58
- '[Desktop Entry]\nType=Application\nName=MingDao 自动启动\nComment=MingDao-Harness WebUI 服务器(登录时启动)\nExec=sh -c \'mingdao web 3820 >/dev/null 2>&1\'\nX-GNOME-Autostart-enabled=true\nHidden=false\n'
86
+ `[Desktop Entry]\nType=Application\nName=MingDao 自动启动\nComment=MingDao-Harness WebUI 服务器(登录时启动)\nExec="${node}" "${cli}" web 3820\nX-GNOME-Autostart-enabled=true\nHidden=false\n`
59
87
  );
60
88
  }
61
89
  return true;
@@ -66,6 +94,12 @@ export function enableAutostart() {
66
94
 
67
95
  export function disableAutostart() {
68
96
  try {
97
+ if (process.platform === 'darwin') {
98
+ // 已 bootstrap 的 agent 必须 bootout 才真正停用(仅删 plist 不影响本次会话)
99
+ try {
100
+ spawnSync('launchctl', ['bootout', `gui/${process.getuid?.() ?? 0}/org.mingdao.web`], { stdio: 'ignore' });
101
+ } catch {}
102
+ }
69
103
  if (fs.existsSync(autostartPath())) fs.unlinkSync(autostartPath());
70
104
  return true;
71
105
  } catch {
package/src/batch.js CHANGED
@@ -134,11 +134,19 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
134
134
  }
135
135
 
136
136
  // —— 省钱 B2:--max-cost 预算上限(提交前估算拦截)——
137
- let estimatedCost = 0;
137
+ let estimatedCost = /** @type {number|null} */ (0);
138
138
  if (maxCost > 0) {
139
139
  const estPrompt = unique.reduce((s, q) => s + sysTokens + approxTokens(q), 0);
140
140
  const estCompletion = unique.length * Math.min(maxTokens, 2048); // 保守按平均 2K 输出估算
141
141
  estimatedCost = estimateBatchCost(model, estPrompt, estCompletion);
142
+ // v0.4.6:无价模型估算为 null(未知)——--max-cost 是「预算保障」,未知即无法保障,
143
+ // 必须 fail-closed 中止提交;此前 null 被当 0、拦截恒不触发(与 P0-4 同类静默失效)。
144
+ if (estimatedCost == null) {
145
+ return {
146
+ error: `模型 ${model} 没有价格数据,无法估算 Batch 费用,--max-cost 预算保障失效,已中止提交。请先在 config.pricing.overrides 配置该模型价格,或去掉 --max-cost(自行承担预算风险)后重试。`,
147
+ estimatedCost: null,
148
+ };
149
+ }
142
150
  if (estimatedCost > maxCost) {
143
151
  return {
144
152
  error: `预计费用 ≈¥${estimatedCost.toFixed(4)}(已按半价)超过 --max-cost ¥${maxCost},已中止提交。可缩小问题集或调高上限。`,