mingdao-harness 0.1.67 → 0.1.69

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.
@@ -0,0 +1,122 @@
1
+ # MingDao-Harness v0.1.68 全面质量评估与分期优化方案(2026-08-28)
2
+
3
+ 四路独立只读审计(核心引擎 / CLI 与后台任务 / Web 与桌面壳 / 测试与质量基础设施)交叉完成,覆盖全部 56+ 源文件、12,023 行源码。本文档为评估结论与分期方案,**待方案敲定后再实施**。
4
+
5
+ ## 一、总评
6
+
7
+ | 区域 | 评分 | 一句话结论 |
8
+ |---|---|---|
9
+ | 核心引擎 | **7.5/10** | 防御意识强(fail-closed、护栏、mtime 缓存),有 1 个高危计费缺陷与若干跨模块口径不一致 |
10
+ | CLI 与后台任务 | **6.0/10** | 安全基线好(无命令注入、路径穿越基本防住),有 1 个确定性 bug 与成体系的并发写隐患 |
11
+ | Web 与桌面壳 | **6.5/10** | 鉴权/转义/SSE 清理扎实;短板在 SSRF 目标校验、并发上限竞争、server.js 巨石 |
12
+ | 测试与质量基础设施 | **6.9/10** | 4 套离线 e2e + 三平台 CI 优秀;缺覆盖率/运行时汇总/CHANGELOG,tsconfig 只覆盖 22/52 文件 |
13
+ | **综合** | **≈6.8/10** | 功能成熟度高、安全基线好;主要债务集中在**结构(巨石文件)、并发正确性、工程化度量**三类 |
14
+
15
+ 体检指标:源码 12,023 行 / 最大单体 server.js 1267、cli.js 1166、index.html 1342;空 catch 83 处(多为刻意降级,需逐处甄别);console.log 177 处;无 CHANGELOG;测试 ~445 组 ok()/738 条断言、全部离线、三平台 CI(Node 18/20/22 + Win + Mac)。
16
+
17
+ ## 二、问题总清单(合并去重,按严重度)
18
+
19
+ ### 🔴 高(必须修)
20
+
21
+ | # | 区域 | 位置 | 问题 | 修复 | 工时 |
22
+ |---|---|---|---|---|---|
23
+ | H1 | 计费 | pricing.js:185-201 | 根级 `pricing.overrides.{input,output}` 只并入 offpeak 不传播 peak:高峰时段自定义价格按内置 peak 计价,费用护栏/前置预估口径失真且无信号 | overrides 传播到 peak(或文档化并加日志警告) | 0.5h |
24
+ | H2 | 调度 | schedule.js:399-414 | **确定性 bug**:every 任务执行中 `pause` 会被 runSleeper 主循环无条件覆盖回 `pending`,暂停失效 | 读 cur2 后先检查 `paused/failed` 再落状态 | 0.3h |
25
+ | H3 | 并发 | 全局(schedule/tasks/session/sync/session-index/sync-server 等 8 处) | 跨进程「读-改-写」无锁 + 共享固定 `.tmp` 名 → 丢更新/半写入 | 每文件锁(O_EXCL lockfile)+ tmp 名加 pid+随机后缀 | 1.5–2 人日 |
26
+ | H4 | 数据安全 | config.js:59 / credentials.js:29 / update.js:75 / sync.js:315,325 / audit.js:42 | 关键状态非原子直写(与项目自身 tmp+rename 口径分裂);credentials 含全部密钥,崩溃写一半即损坏 | 统一原子写工具函数(tmp+rename,密钥文件 0o600) | 0.5h |
27
+ | H5 | 结构 | web/server.js:447-1210 | 30+ 路由单函数巨石,鉴权/业务/清理混排,难维护难测 | 拆 `src/web/routes/*.js` 按域聚合,SSE 独立文件 | 20–24h |
28
+
29
+ ### 🟠 中(应当修)
30
+
31
+ | # | 区域 | 位置 | 问题 | 修复 | 工时 |
32
+ |---|---|---|---|---|---|
33
+ | M1 | 安全 | server.js:684-733,1122 | SSRF:自定义模型/sync 的 baseUrl 只验 http(s) 不验目标,可指向回环/云元数据/内网 | host 校验拒绝 loopback/私网/链路本地 + 超时/重定向限制 | 4h |
34
+ | M2 | 并发 | server.js:812-836 | MAX_CONCURRENT 检查在 readBody 前,占位即删 → 并发瞬时超限 + 多份 40MB 缓冲 | inflight 计数绑定请求生命周期(进入++/finally--) | 3h |
35
+ | M3 | 并发 | server.js:306,360,388 + session.js:36-48 | 会话文件 append 与 rewrite(compact)交错丢消息 | 按文件进程内 async mutex 串行化 | 4h |
36
+ | M4 | 桌面 | server.js:1234-1266 + desktop/main.js | `runWebServer` 在 listen 成功前 resolve,端口被占黑屏反复 reload 无恢复 | Promise 包装 listen;桌面对 EADDRINUSE 重试或提示退出 | 3h |
37
+ | M5 | 同步 | sync-server.js:109-123,228-267,451-457 | 限流表超限整表 clear(可被攻击重置全员限额);devices/meta 读改写无锁;代理后 IP 桶共享 | 淘汰最旧而非 clear;按 ip+username+路由建桶;meta 互斥合并写;`server.maxConnections` | 4h |
38
+ | M6 | 同步 | commands/sync.js:49-52 | `sync login` 密码走命令行参数(ps/history 泄露) | 一律隐藏输入/stdin | 0.3h |
39
+ | M7 | 压缩 | cli.js:160-182 vs compact.js | 两份近似压缩实现已分叉:手动 /compact 无 `<conversation_summary>` 标记,自动增量压缩识别不了 → 重复压缩/格式漂移 | /compact 统一走 compactConversation(或注入同款标记) | 0.5h |
40
+ | M8 | 口径 | memory.js:156,34 | journal 用 UTC 日期、appendMemory 硬编码 Asia/Shanghai,与 pricing.timezone 口径不一致 | 统一用 beijingParts()/配置时区 | 0.3h |
41
+ | M9 | 内存 | fs-tools.js:26-36 | undo 备份无上限(每文件 10 份完整 Buffer,文件数不限) | 按文件数/总字节双上限 LRU 淘汰 | 0.5h |
42
+ | M10 | 性能 | context.js:73-96 / cost-guard.js | 语义回收循环每次全量重算 totalOf(≤500×n);todayCost 每步整文件解析 stats | 增量求和;stats 行级缓存/索引 | 0.5h |
43
+ | M11 | 误杀 | schedule.js:150,165 | 用陈旧 PID `process.kill`,PID 复用会误杀无关进程 | kill 前校验 cmdline/nonce(同 daemonAlive);终态清 pid | 0.2h |
44
+ | M12 | 进程 | tasks.js:57-88 等 | worker/daemon spawn 无 `child.on('error')`,ENOENT/ARG_MAX 崩溃;worker 被 SIGTERM 时 MCP 子进程可能孤儿 | 补 error 监听 + 终态;确认 MCP 进程组归属 | 0.3h |
45
+ | M13 | 结构 | cli.js:300-316 + 全文件 | 命令分发用「模块名≠命令名」字符串拼接 map;mcpFacade/offpeak/参数解析多处重复;死导入 | 显式命令→{module,handler} 映射;抽共享函数;删死代码 | 1 人日 |
46
+ | M14 | UI | index.html:6,584-593,891-913 | CSP `unsafe-inline`;工具图标 map 三处重复;refreshCostBadge 与 refreshStatusBar 每 15s 双拉同接口 | JS 外置后收紧 CSP;图标表抽常量;合并一次拉取 | 6–8h(随 JS 外置) |
47
+
48
+ ### 🟡 低(顺手修)
49
+
50
+ | # | 位置 | 问题 | 工时 |
51
+ |---|---|---|---|
52
+ | L1 | server.js:875-884 + index.html | 主「■ 停止」发 `{}` 中断所有任务(应记本任务 taskId) | 1h |
53
+ | L2 | web-io.js:70-77 | `_activeTools` 被拒/中止条目泄漏(无界增长 + seq 错配) | 1h |
54
+ | L3 | server.js:217-254 | pruneTasks 惰性 + built.error 早退漏清理;finished 即删 | 0.5h |
55
+ | L4 | server.js:1011-1029 | fs-browse 可枚举任意绝对路径(限定基目录 + 不回传绝对路径) | 2h |
56
+ | L5 | server.js:70-105 | 所有 POST 统一 40MB 整包内存(非 chat 接口降为 1MB) | 2h |
57
+ | L6 | server.js:1249-1250 | 「0.0.0.0 无 token」告警文案与 trustedHost 行为不符 | 0.5h |
58
+ | L7 | server.js:199,888-899 | draftText 单一全局槽多客户端互相覆盖 | 1h |
59
+ | L8 | desktop/main.js:28-40 + server.js:203-215 | appLog/srvlog 两文件、O(n) 全量重写、512KB 断行截断 | 2h |
60
+ | L9 | desktop/main.js:221-228 | 渲染崩溃只 reload 一次,无计数护栏/提示 | 1.5h |
61
+ | L10 | autostart.js:40-58 | 端口硬编码 3820、登录 PATH 不可靠 | 0.3h |
62
+ | L11 | schedule.js:41-43,102 | parseAt 非法日期回滚不校验;addSchedule 条件冗余 | 0.2h |
63
+ | L12 | cli.js 8 处 / schedule.js:413 | 关键路径静默吞错(至少 MINGDAO_DEBUG 打 stderr) | 0.2h |
64
+ | L13 | notify.js:19-30 | 转义启发式(spawn argv 已低风险,可结构化化) | 0.2h |
65
+
66
+ ### 🔵 工程化(测试/基础设施审计)
67
+
68
+ | # | 项 | 问题 | 工时 |
69
+ |---|---|---|---|
70
+ | E1 | bench 未入 CI | 3 份基准不在 ci.yml;且 bench 有 Windows `new URL().pathname` 跨平台 bug | 1h |
71
+ | E2 | 失败即中止 | smoke 首败即停、无全量坏点清单 → 迁 node:test 或逐段 try/catch 汇总 | 6–8h |
72
+ | E3 | 无覆盖率 | 无 V8/c8 度量与门禁;mcp-presets/index 公共 API/credentials/permissions/config 边界用例缺失 | 10–12h |
73
+ | E4 | tsconfig 覆盖不全 | 只覆盖 22/52 src 文件且 strict:false(漏 sync-server/web/credentials 等安全关键区) | 6–8h |
74
+ | E5 | CHANGELOG 缺失 | 0.1.0→0.1.68 无变更日志;prepublishOnly 未跑 e2e-web/bench;发布流程人工 | 3–4h |
75
+
76
+ ## 三、分期实施方案
77
+
78
+ > 原则:每期可独立发布、可回滚;高危与确定性 bug 一律放最前;结构重构后置并配足回归护栏(现有 94 项测试 + bench 全绿才准合入)。
79
+
80
+ ### Phase 1 —「质量加固」v0.1.69(≈2–3 天)✅ 已实施(2026-08-28)
81
+ 目标:修掉全部高严重度与低成本中危,零结构变动。实施项:H1 计费 overrides peak 传播(+回归断言)、H2 调度 pause 失效(postRunStatus 纯函数化 + 7 项 e2e)、H3/H4 原子写地基(新增 src/atomic-write.js:pid+随机 tmp、O_EXCL 文件锁+陈旧回收;config/credentials/update/sync/session/session-index/sync-server 全部原子化;schedule/tasks 读改写加锁)、M7 /compact 并入 compactConversation(force 模式)、M8 记忆日期统一配置时区、M9 undo 备份全局上限、M10 trim 增量求和 + todayCost mtime 缓存、M11 kill 前 PID 归属校验、M12 spawn error 监听、L1 主停止按钮带 taskId、L2 _activeTools 拒绝清理、L3 pruneTasks 年龄+数量双阈值、L6 告警文案、L2/L3/L11/L12 小修。
82
+ - H1 计费 overrides peak 传播 + 测试断言
83
+ - H2 调度 pause 失效 bug + e2e 回归
84
+ - H3 并发地基:统一原子写工具(锁 + pid 随机 tmp)——先落 H4 的 5 个文件,再铺 schedule/tasks/session
85
+ - M7 压缩双实现合并(/compact 走 compactConversation,保留用户提示文案)
86
+ - M8 时区口径统一;M9 undo 上限;M10 性能小修;M11 PID 校验;M12 spawn error 监听
87
+ - L1/L2/L3/L6/L11/L12/L13 顺手修
88
+ - 验收:94 组测试 + 38 组 bench 全绿、tsc 0 错误、三平台 CI 过
89
+
90
+ ### Phase 2 —「结构治理」v0.2.0(≈4–6 天)
91
+ 目标:拆巨石、补安全短板,为后续演进铺路。
92
+ - H5 server.js 拆 `src/web/routes/*.js`(chat/sessions/config/sync/schedule/skills/workspace/fs-browse 等)+ 共享中间件
93
+ - M13 cli.js 命令分发重构 + REPL/worker 拆分 + 重复逻辑抽取
94
+ - M14 index.html JS 外置 `app.js` + CSP 收紧(去 unsafe-inline)+ 图标表/双拉取去重
95
+ - M1 SSRF 目标校验;M2 inflight 并发;M3 会话文件互斥;M4 listen Promise + 桌面端口重试
96
+ - M5 sync-server 限流与 meta 互斥;M6 密码不再走参数
97
+ - L4/L5/L7/L8/L9/L10
98
+ - 验收:同上全绿 + 新增 API 契约测试(routes 拆分后每个域至少 3 条断言)+ 桌面端口占用重试实测
99
+
100
+ ### Phase 3 —「工程化纵深」v0.2.1(≈3–5 天)
101
+ 目标:把"质量"变成可度量、可持续的门禁。
102
+ - E1 bench 入 CI + Windows 兼容;E2 node:test 迁移(或分段汇总);E3 覆盖率接入 + 阈值门禁 + 安全关键模块补测;E4 tsconfig 全覆盖 + 收紧 strict
103
+ - E5 CHANGELOG + preversion/postversion 自动化 + prepublishOnly 补 e2e-web/bench + 发布清单文档化
104
+ - 遗留项:sync-server 独立安全测试(限流/认证/并发)、fs-browse 基目录、D3 崩溃护栏(若 Phase 2 未含)
105
+ - 验收:CI 输出覆盖率报告;`npm run bench` 全绿;tsc strict 0 错误;CHANGELOG 自动生成首版
106
+
107
+ ### Phase 4 —「能力演进」v0.3.x(可选,按需启动)
108
+ 从《六份评估综合分析》(docs/EVALUATION-SYNTHESIS.md)与审计收敛出的长期项:
109
+ - 跨平台沙箱补位(Windows Job Object / macOS seatbelt)
110
+ - 记忆语义检索(node:sqlite + 简单相似度,保持零依赖)
111
+ - WebUI 看板扩展(费用二级分账、按小时/天折线)
112
+ - 评测基准扩充(压缩质量集、路由 100 条标注集、tokenizer V4 词表对拍)
113
+
114
+ ## 四、明确不做(保持零依赖与轻量定位)
115
+
116
+ - 不引入 TypeScript 全量迁移 / 构建工具链 / 前端框架 / i18n 框架 / OpenTelemetry
117
+ - 不引入 better-sqlite3 等外部数据库驱动(如需 SQLite 用 Node 22+ 内置 node:sqlite 且保持 Node≥18 回退路径)
118
+ - 不做 A/B 双模型对比路由(与省钱目标矛盾)
119
+
120
+ ## 五、交付物与验收口径
121
+
122
+ 每期合入前必须满足:`npm test`(smoke 51 组)+ 三套 e2e(21/16/6)+ `npm run bench`(38 断言)+ `tsc` 全绿;涉及 Web/桌面改动额外要求 e2e-web 全绿与打包抽查。每期发布按既有流水线(npm → 桌面 CI → 官网 feed → 镜像 Release 正文),桌面版统一官网分发。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.1.67",
3
+ "version": "0.1.69",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -53,7 +53,8 @@
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",
56
- "desktop:sync": "node scripts/sync-versions.mjs"
56
+ "desktop:sync": "node scripts/sync-versions.mjs",
57
+ "bench": "node test/bench/bench-tokenizer.mjs && node test/bench/bench-routing.mjs && node test/bench/bench-compaction.mjs"
57
58
  },
58
59
  "devDependencies": {
59
60
  "@types/node": "^26.2.0",
package/src/agent.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // → PostToolUse 钩子 → 结果回填 → 循环,直到模型给出纯文本回复。
3
3
  // 附带:子代理(task 工具)、todo 清单状态、undo 备份仓、Ctrl+C 中断。
4
4
 
5
- import { trimMessages, clampText } from './context.js';
5
+ import { trimMessages, clampText, messageTokens } from './context.js';
6
6
  import { compactConversation } from './compact.js';
7
7
  import { toolSchemas, dispatch } from './tools/index.js';
8
8
  import { modelPreset } from './models.js';
@@ -11,7 +11,8 @@ import { createHooks } from './hooks.js';
11
11
  import { createIO, style, C } from './ui.js';
12
12
  import { subagentModel } from './routing.js';
13
13
  import { writeAudit, redactSecrets } from './audit.js';
14
- import { checkCostGuard } from './cost-guard.js';
14
+ import { checkCostGuard, costGuardConfig, todayCost } from './cost-guard.js';
15
+ import { estimateCost } from './pricing.js';
15
16
 
16
17
  const MAX_STEPS = 24;
17
18
  const SUBAGENT_MAX_STEPS = 12;
@@ -119,6 +120,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
119
120
  try {
120
121
  while (steps < stepLimit) {
121
122
  steps += 1;
123
+ // 同回合只读工具去重(Hermes C4):相同 name+args 的只读调用只执行一次,结果复用回填
124
+ const turnToolCache = new Map();
122
125
  // 自动压缩(P3-1):预算不足、静默裁剪即将丢弃早期段落时,先用 executor 模型
123
126
  // 把被裁段落压成摘要注入,替代「失忆」;失败/不值得时回退普通裁剪。
124
127
  if (cfg.autoCompact !== false) {
@@ -151,6 +154,39 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
151
154
  }
152
155
  const trimmed = trimMessages(messages, budget, count);
153
156
 
157
+ // reasoning 回填防护(MiniMax P0-2):推理链只在 UI 流式展示、不回传消息(本就不回填);
158
+ // 防御性保证——任何携带 reasoning_content 的消息发送前裁剪:>4000 字仅留概括,>1000 字截尾 500。
159
+ let sanitized = trimmed;
160
+ for (const m of sanitized) {
161
+ const rc = m.reasoning_content;
162
+ if (typeof rc === 'string' && rc.length > 4000) {
163
+ sanitized = sanitized.map((x) => (x === m ? { ...x, reasoning_content: `[思考过程已省略(原 ${rc.length} 字)]` } : x));
164
+ } else if (typeof rc === 'string' && rc.length > 1000) {
165
+ sanitized = sanitized.map((x) => (x === m ? { ...x, reasoning_content: rc.slice(-500) + ' …[思考过程已截断]' } : x));
166
+ }
167
+ }
168
+
169
+ // 护栏前置预估(Kimi P2-E):发送前按本轮最坏成本估算(trimmed prompt × 未命中输入价
170
+ // + maxOutput × 输出价,峰谷按当前时段);「今日已用 + 最坏成本」超上限时发送前拦截,
171
+ // 而不是等 200K 上下文的贵请求发出后才 block。
172
+ if (cfg.costGuard) {
173
+ const g = costGuardConfig();
174
+ if (g && Number(g.dailyLimitYuan) > 0) {
175
+ let promptTokens = 0;
176
+ for (const m of sanitized) promptTokens += messageTokens(m, count);
177
+ const worst = estimateCost(modelName, promptTokens, maxOutput, null, new Date());
178
+ const used = todayCost();
179
+ if (used + worst >= Number(g.dailyLimitYuan)) {
180
+ stripOrphanCalls();
181
+ return {
182
+ text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
183
+ note: `⛔ 护栏前置拦截:本轮最坏成本 ≈¥${worst.toFixed(4)},今日已用 ≈¥${used.toFixed(4)},合计将超过上限 ¥${Number(g.dailyLimitYuan).toFixed(2)}——请求未发出。可调高 config.costGuard.dailyLimitYuan 或改用更小模型。`,
184
+ durationMs: Date.now() - startedAt, perf: perf(),
185
+ };
186
+ }
187
+ }
188
+ }
189
+
154
190
  // 费用护栏(A2):每轮开始前按今日实际费用检查;block 时暂停本轮并明确告知
155
191
  if (cfg.costGuard) {
156
192
  const guard = checkCostGuard();
@@ -186,7 +222,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
186
222
  try {
187
223
  res = await provider.chat({
188
224
  model: modelName,
189
- messages: trimmed,
225
+ messages: sanitized,
190
226
  tools: [...toolSchemas(), ...mcpSchemas()],
191
227
  temperature,
192
228
  maxTokens: maxOutput,
@@ -299,14 +335,25 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
299
335
  }
300
336
 
301
337
  // 执行单个工具(渲染「执行中」→ dispatch → 捕获异常转错误结果)
338
+ // 审计 Hermes C4:同回合相同参数的只读工具(read/ls/glob/grep/skill)合并执行一次,
339
+ // 后续相同调用直接复用结果(仍逐个回填 tool 消息以保持 tool_call_id 配对)
302
340
  async function runTool(prep) {
303
341
  io.renderToolStart?.(prep.name, prep.args);
342
+ const dedupKey = !prep.isMcp && READONLY_TOOLS_SET.has(prep.name) ? prep.name + ':' + JSON.stringify(prep.args || {}) : null;
343
+ if (dedupKey && turnToolCache.has(dedupKey)) {
344
+ prep.cached = true;
345
+ return turnToolCache.get(dedupKey);
346
+ }
304
347
  try {
348
+ let result;
305
349
  if (prep.isMcp) {
306
350
  if (!mcp) throw new Error('MCP 工具未启用');
307
- return await mcp.call(prep.name, prep.args);
351
+ result = await mcp.call(prep.name, prep.args);
352
+ } else {
353
+ result = await dispatch(prep.name, prep.args, ctx);
308
354
  }
309
- return await dispatch(prep.name, prep.args, ctx);
355
+ if (dedupKey) turnToolCache.set(dedupKey, result);
356
+ return result;
310
357
  } catch (err) {
311
358
  return JSON.stringify({ ok: false, error: String(err?.message || err) });
312
359
  }
@@ -344,7 +391,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
344
391
  });
345
392
  }
346
393
  const text = typeof result === 'string' ? result : JSON.stringify(result); // 紧凑 JSON(评估 B3):嵌套结果省 10-20% 回填 token,且下轮按 prompt 重复计费
347
- messages.push({ role: 'tool', tool_call_id: prep.tc.id, content: clampText(text) });
394
+ const prefix = prep.cached ? '(与同回合相同调用结果一致,已复用)\n' : '';
395
+ messages.push({ role: 'tool', tool_call_id: prep.tc.id, content: prefix + clampText(text) });
348
396
  }
349
397
 
350
398
  let i = 0;
@@ -0,0 +1,74 @@
1
+ // 原子写与跨进程互斥(质检 H3/H4:并发写地基)。
2
+ // 背景:守护进程、CLI、web 服务器同时写 ~/.mingdao 下同一批文件,
3
+ // 此前 read-modify-write 无锁 + 多进程共享固定 `target + '.tmp'` 临时名,
4
+ // 存在丢更新与"rename 到对方半截文件"的成体系隐患;config/credentials 等关键文件更是直写。
5
+ // 本模块提供两个原语:
6
+ // 1. atomicWriteFileSync —— tmp 名含 pid+随机后缀(跨进程绝不共名),写完 rename 原子替换;
7
+ // 2. withFileLockSync —— O_EXCL lockfile 互斥(带超时与陈旧锁回收),包裹读-改-写序列。
8
+ // 零依赖:仅 node:fs / node:path / node:crypto / Atomics.wait。
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import crypto from 'node:crypto';
13
+
14
+ export function atomicWriteFileSync(target, data, options = {}) {
15
+ const dir = path.dirname(target);
16
+ const tmp = path.join(dir, `.${path.basename(target)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
17
+ try {
18
+ fs.writeFileSync(tmp, data, options);
19
+ fs.renameSync(tmp, target);
20
+ } catch (err) {
21
+ try {
22
+ fs.unlinkSync(tmp);
23
+ } catch {}
24
+ throw err;
25
+ }
26
+ }
27
+
28
+ export function atomicWriteJsonSync(target, value, { mode = 0o600 } = {}) {
29
+ atomicWriteFileSync(target, JSON.stringify(value, null, 2) + '\n', { mode });
30
+ }
31
+
32
+ // 极简互斥锁:O_EXCL 创建 lockfile;持锁期间执行 fn(同步);异常/完成释放。
33
+ // 进程崩溃遗留的陈旧锁(> staleMs 未更新)自动回收,避免永久卡死。
34
+ const sleepBuf = new Int32Array(new SharedArrayBuffer(4));
35
+ const sleepMs = (ms) => Atomics.wait(sleepBuf, 0, 0, ms);
36
+
37
+ export function withFileLockSync(lockPath, fn, { timeoutMs = 5000, staleMs = 15000 } = {}) {
38
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
39
+ const t0 = Date.now();
40
+ for (;;) {
41
+ try {
42
+ const fd = fs.openSync(lockPath, 'wx'); // 独占创建
43
+ try {
44
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
45
+ } finally {
46
+ fs.closeSync(fd);
47
+ }
48
+ try {
49
+ return fn();
50
+ } finally {
51
+ try {
52
+ fs.unlinkSync(lockPath);
53
+ } catch {}
54
+ }
55
+ } catch (err) {
56
+ if (err.code !== 'EEXIST') throw err;
57
+ try {
58
+ const st = fs.statSync(lockPath);
59
+ if (Date.now() - st.mtimeMs > staleMs) {
60
+ try {
61
+ fs.unlinkSync(lockPath);
62
+ } catch {}
63
+ continue; // 回收陈旧锁后立即重试
64
+ }
65
+ } catch {
66
+ continue; // 锁文件刚被释放
67
+ }
68
+ if (Date.now() - t0 > timeoutMs) {
69
+ throw new Error(`获取文件锁超时(${lockPath}),可能存在其他进程长时间占用`);
70
+ }
71
+ sleepMs(25);
72
+ }
73
+ }
74
+ }
package/src/cli.js CHANGED
@@ -8,15 +8,11 @@ import path from 'node:path';
8
8
  import readline from 'node:readline';
9
9
  import { loadConfig, saveConfig, runWizard, ensureHome, mingdaoHome } from './config.js';
10
10
  import { modelPreset, PROVIDERS } from './models.js';
11
- import {
12
- loadCredentials,
13
- setStoredKey,
14
- removeStoredKey,
15
- maskKey,
16
- credentialsPath,
17
- getStoredKey,
18
- } from './credentials.js';
11
+ import { maskKey, getStoredKey } from './credentials.js';
19
12
  import { createProvider, resolveProviderConfig, helperProvider } from './providers/index.js';
13
+ import { compactConversation } from './compact.js';
14
+ import { makeTokenCounter } from './tokenizer.js';
15
+ import { messageTokens } from './context.js';
20
16
  import { startMcpServers } from './mcp.js';
21
17
  import { startTask, listTasks, patchTask, killTask, formatTaskRow } from './tasks.js';
22
18
  import { enableAutostart, disableAutostart, autostartStatus, autostartPath } from './autostart.js';
@@ -157,30 +153,6 @@ async function generatePlan(provider, modelName, task) {
157
153
  return res.text || null;
158
154
  }
159
155
 
160
- async function compactContext(provider, modelName, messages) {
161
- const mid = messages.slice(1, -2);
162
- const res = await provider.chat({
163
- model: modelName,
164
- messages: [
165
- {
166
- role: 'system',
167
- content:
168
- '你是上下文压缩器。把以下对话历史压缩成要点摘要(中文 ≤600 字):保留未完成任务、关键决策、文件改动、用户偏好;省略已完成的中间过程。',
169
- },
170
- {
171
- role: 'user',
172
- content: mid
173
- .map((m) => `${m.role}: ${m.content ?? JSON.stringify(m.tool_calls ?? '')}`)
174
- .join('\n'),
175
- },
176
- ],
177
- tools: [],
178
- temperature: 0.2,
179
- maxTokens: 2048,
180
- });
181
- return res.text || '';
182
- }
183
-
184
156
  // —— 后台任务 worker:独立进程执行一轮任务并写状态文件 ——
185
157
  async function runWorkerTask(id, question, { permission, model, offpeak }) {
186
158
  const home = ensureHome();
@@ -643,9 +615,12 @@ async function main() {
643
615
  stdio: 'ignore',
644
616
  env: process.env,
645
617
  });
618
+ child.on('error', (err) => io.print(style(`[WebUI 自启失败] ${err?.message || err}(可手动运行 mingdao web ${cfg.web?.port || 3820})`, C.red)));
646
619
  child.unref();
647
620
  io.print(style(`🌐 WebUI 后台启动中:http://127.0.0.1:${cfg.web?.port || 3820}(关闭自动启动:mingdao web --no-autostart)`, C.dim));
648
- } catch {}
621
+ } catch (err) {
622
+ io.print(style(`[WebUI 自启失败] ${err?.message || err}`, C.red)); // 质检 L6:不再静默吞掉
623
+ }
649
624
  }
650
625
 
651
626
  let session = null;
@@ -818,18 +793,33 @@ async function main() {
818
793
  continue;
819
794
  }
820
795
  io.startSpinner('正在压缩上下文…');
821
- let summary = '';
796
+ let compacted = null;
822
797
  try {
823
- summary = await compactContext(provider, modelName, messages);
798
+ const count = makeTokenCounter(modelName);
799
+ let total = 0;
800
+ for (const m of messages) total += messageTokens(m, count);
801
+ const budget = Math.max(1000, Math.round(total * 0.5)); // 手动触发:保留约 30% 尾部
802
+ compacted = await compactConversation({
803
+ messages,
804
+ budget,
805
+ count,
806
+ provider,
807
+ executorModel: modelName,
808
+ triggerRatio: 0,
809
+ force: true, // 手动意图明确:跳过最小裁剪门槛
810
+ });
824
811
  } catch (err) {
825
812
  io.print(style('[压缩失败] ' + (err?.message || err), C.red));
826
813
  }
827
814
  io.stopSpinner();
828
- if (!summary) continue;
829
- messages = [{ role: 'system', content: systemPrompt }, { role: 'user', content: '[此前对话摘要]\n' + summary }];
815
+ if (!compacted?.messages) {
816
+ io.print(style('未能压缩(被裁段落不足或摘要失败)。', C.dim));
817
+ continue;
818
+ }
819
+ messages = compacted.messages;
830
820
  appendMessages(session.file, [{ role: 'system', content: '── /compact 压缩点 ──' }, ...messages.slice(1)]);
831
821
  persisted = messages.length;
832
- io.print(style('✓ 已压缩上下文(完整历史保留在会话文件中)。', C.green));
822
+ io.print(style(`✓ 已压缩上下文:${compacted.droppedCount} 条早期消息 → 摘要(回收约 ${compacted.droppedTokens} tokens)`, C.green));
833
823
  } else if (cmd === '/init') {
834
824
  const target = path.join(workingDir, 'AGENTS.md');
835
825
  if (fs.existsSync(target) && arg !== 'force') {
package/src/compact.js CHANGED
@@ -54,7 +54,7 @@ export async function summarizeConversation(provider, model, convoText) {
54
54
  return { text: text.slice(0, SUMMARY_MAX_CHARS), usage };
55
55
  }
56
56
 
57
- export async function compactConversation({ messages, budget, count, provider, executorModel, triggerRatio }) {
57
+ export async function compactConversation({ messages, budget, count, provider, executorModel, triggerRatio, force = false }) {
58
58
  if (!messages.length) return null;
59
59
  // 各消息 token 与总量;低于触发线(默认预算 80%)无需压缩
60
60
  const sizes = [];
@@ -77,10 +77,10 @@ export async function compactConversation({ messages, budget, count, provider, e
77
77
  keepTokens += sizes[i];
78
78
  }
79
79
  const droppedCount = boundary - 1; // 不含 system
80
- if (droppedCount < MIN_DROP_MESSAGES) return null;
80
+ if (!force && droppedCount < MIN_DROP_MESSAGES) return null;
81
81
  let droppedTokens = 0;
82
82
  for (let i = 1; i < boundary; i++) droppedTokens += sizes[i];
83
- if (droppedTokens < MIN_DROP_TOKENS) return null;
83
+ if (!force && droppedTokens < MIN_DROP_TOKENS) return null;
84
84
 
85
85
  // 被裁段落 → 文本(工具结果截断;tool_calls 带名称标注)
86
86
  const convoText = messages
@@ -99,16 +99,51 @@ export async function compactConversation({ messages, budget, count, provider, e
99
99
  .join('\n')
100
100
  .slice(0, INPUT_MAX_CHARS);
101
101
 
102
+ // —— 增量压缩(Kimi P2-D):已压缩过的会话只压「旧摘要之后的新增段」,与旧摘要合并(摘要的摘要),
103
+ // 不再反复把早期内容送进摘要输入——第 N 次压缩输入从 O(历史) 降为 O(增量)。 ——
104
+ const sumIdx = messages.findIndex(
105
+ (m) => m.role === 'user' && typeof m.content === 'string' && m.content.includes('<conversation_summary>')
106
+ );
107
+ let incremental = false;
102
108
  let summary = null;
103
109
  let usage = null;
104
- try {
105
- const r = await summarizeConversation(provider, executorModel, convoText);
106
- summary = r.text;
107
- usage = r.usage;
108
- } catch {
109
- return null; // 摘要失败:退回普通裁剪
110
+ if (sumIdx > 0 && sumIdx + 1 < boundary) {
111
+ incremental = true;
112
+ const m = /<conversation_summary>\n([\s\S]*?)\n<\/conversation_summary>/.exec(String(messages[sumIdx].content));
113
+ const oldSummary = (m && m[1]) || '';
114
+ const newText = messages
115
+ .slice(sumIdx + 1, boundary)
116
+ .map((msg) => {
117
+ if (msg.role === 'tool') {
118
+ return `工具结果(${msg.tool_call_id ?? ''}): ${clampText(String(msg.content ?? ''), TOOL_OUTPUT_CAP)}`;
119
+ }
120
+ const head = msg.role === 'user' ? '用户' : msg.role === 'assistant' ? 'MingDao' : String(msg.role);
121
+ return `${head}: ${String(msg.content ?? '')}`;
122
+ })
123
+ .join('\n')
124
+ .slice(0, INPUT_MAX_CHARS);
125
+ try {
126
+ const r = await summarizeConversation(
127
+ provider,
128
+ executorModel,
129
+ `【既有摘要】\n${oldSummary.slice(0, SUMMARY_MAX_CHARS)}\n\n【新增对话记录】\n${newText}`
130
+ );
131
+ summary = r.text;
132
+ usage = r.usage;
133
+ } catch {
134
+ return null;
135
+ }
136
+ if (!summary) return null;
137
+ } else {
138
+ try {
139
+ const r = await summarizeConversation(provider, executorModel, convoText);
140
+ summary = r.text;
141
+ usage = r.usage;
142
+ } catch {
143
+ return null; // 摘要失败:退回普通裁剪
144
+ }
145
+ if (!summary) return null;
110
146
  }
111
- if (!summary) return null;
112
147
 
113
148
  // 组装:system + 摘要(user) + 保留段(清洗保留段头部因裁剪而孤立的 tool 消息)
114
149
  const kept = cleanToolPairing(messages.slice(boundary));
@@ -123,5 +158,5 @@ export async function compactConversation({ messages, budget, count, provider, e
123
158
  },
124
159
  ...kept,
125
160
  ];
126
- return { messages: next, droppedCount, droppedTokens, summary, usage };
161
+ return { messages: next, droppedCount: incremental ? boundary - (sumIdx + 1) : droppedCount, droppedTokens, summary, usage, incremental };
127
162
  }
package/src/config.js CHANGED
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import os from 'node:os';
7
7
  import { PROVIDERS, modelPreset, providerPreset } from './models.js';
8
8
  import { setStoredKey, resolveApiKey, maskKey } from './credentials.js';
9
+ import { atomicWriteFileSync } from './atomic-write.js';
9
10
 
10
11
  export function mingdaoHome() {
11
12
  return process.env.MINGDAO_HOME || path.join(os.homedir(), '.mingdao');
@@ -56,7 +57,7 @@ export function ensureMinimalConfig() {
56
57
  export function saveConfig(cfg) {
57
58
  ensureHome();
58
59
  const p = configPath();
59
- fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
60
+ atomicWriteFileSync(p, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 }); // 质检 H4:原子写(tmp+rename)
60
61
  try {
61
62
  fs.chmodSync(p, 0o600);
62
63
  } catch {}
package/src/context.js CHANGED
@@ -70,20 +70,28 @@ export function trimMessages(messages, budget, count = approxTokens) {
70
70
  // 最老 tool 消息 → 单行摘要;老 assistant 长文 → 前 200 字;逐条向新推进直到回到 80% 线。
71
71
  // 相比直接丢弃,被修改的只有前端旧消息——其后未被触碰的近期尾部字节保持不变,
72
72
  // 下一轮前缀缓存从首个未修改消息起继续命中(直接丢弃反而让整段前缀失配)。
73
- if (totalOf(system) + totalOf(rest) > budget * 0.8) {
73
+ // 质检 M10:维护增量 total(替换消息后按新旧 token 差调整),不再每步全量重算(原 ≤500×n)。
74
+ const sysTokens = totalOf(system);
75
+ let total = sysTokens + totalOf(rest);
76
+ if (total > budget * 0.8) {
74
77
  const out = [...rest];
75
78
  for (let i = 0; i < out.length && i < 500; i++) {
76
- if (totalOf(system) + totalOf(out) <= budget * 0.8) break;
79
+ if (total <= budget * 0.8) break;
77
80
  const m = out[i];
78
81
  const c = typeof m.content === 'string' ? m.content : JSON.stringify(m?.content ?? '');
82
+ let next = null;
79
83
  if (m.role === 'tool' && c.length > 40) {
80
- out[i] = { ...m, content: `[工具 ${m.tool_call_id || ''} 结果摘要:${c.slice(0, 40).replace(/\n/g, ' ')}…(已回收,原 ${c.length} 字)]` };
84
+ next = { ...m, content: `[工具 ${m.tool_call_id || ''} 结果摘要:${c.slice(0, 40).replace(/\n/g, ' ')}…(已回收,原 ${c.length} 字)]` };
81
85
  } else if (m.role === 'assistant' && c.length > 200) {
82
- out[i] = { ...m, content: c.slice(0, 200) + ` …[已回收,原 ${c.length} 字]` };
86
+ next = { ...m, content: c.slice(0, 200) + ` …[已回收,原 ${c.length} 字]` };
87
+ }
88
+ if (next) {
89
+ total += messageTokens(next, count) - messageTokens(m, count);
90
+ out[i] = next;
83
91
  }
84
92
  }
85
93
  // 回收后仍超预算 → 从尾部保留(必要时继续丢弃最老,清洗 tool 配对)
86
- let total = totalOf(system);
94
+ total = sysTokens;
87
95
  const tail = [];
88
96
  for (let i = out.length - 1; i >= 0; i--) {
89
97
  const t = messageTokens(out[i], count);
package/src/cost-guard.js CHANGED
@@ -3,7 +3,8 @@
3
3
  // 按北京时间自然日累计 cache-stats 实际费用(含 batch 半价与缓存折扣后的真实口径)。
4
4
  // Agent 每轮开始前检查:超 warn 线提醒、超 limit 且 action=block 时暂停执行。
5
5
 
6
- import { listCacheStats } from './cachestats.js';
6
+ import fs from 'node:fs';
7
+ import { listCacheStats, cacheStatsFile } from './cachestats.js';
7
8
  import { beijingDayStart } from './pricing.js';
8
9
  import { loadConfig } from './config.js';
9
10
 
@@ -11,14 +12,27 @@ export function costGuardConfig() {
11
12
  return loadConfig()?.costGuard || null;
12
13
  }
13
14
 
14
- // 今日(北京时间 0 点起)累计实际费用
15
+ // 今日(北京时间 0 点起)累计实际费用。
16
+ // 质检 M10:按 cache-stats 文件 mtime 缓存统计结果——文件未变直接复用,
17
+ // 避免 Agent 每步对整文件(可能数千行)重复解析与累加。
18
+ let todayCache = { mtime: -1, size: -1, start: 0, sum: 0 };
15
19
  export function todayCost() {
16
20
  const start = beijingDayStart().getTime();
17
- let sum = 0;
18
- for (const e of listCacheStats(100000)) {
19
- if (e.at >= start) sum += Number(e.cost) || 0;
21
+ try {
22
+ const file = cacheStatsFile();
23
+ const st = fs.statSync(file);
24
+ if (st.mtimeMs === todayCache.mtime && st.size === todayCache.size && todayCache.start === start) {
25
+ return todayCache.sum;
26
+ }
27
+ let sum = 0;
28
+ for (const e of listCacheStats(100000)) {
29
+ if (e.at >= start) sum += Number(e.cost) || 0;
30
+ }
31
+ todayCache = { mtime: st.mtimeMs, size: st.size, start, sum };
32
+ return sum;
33
+ } catch {
34
+ return 0;
20
35
  }
21
- return sum;
22
36
  }
23
37
 
24
38
  export function costGuardStatus() {
@@ -9,6 +9,7 @@
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { mingdaoHome, ensureHome } from './config.js';
12
+ import { atomicWriteFileSync } from './atomic-write.js';
12
13
 
13
14
  export function credentialsPath() {
14
15
  return path.join(mingdaoHome(), 'credentials.json');
@@ -26,7 +27,7 @@ export function loadCredentials() {
26
27
  export function saveCredentials(creds) {
27
28
  ensureHome();
28
29
  const p = credentialsPath();
29
- fs.writeFileSync(p, JSON.stringify(creds, null, 2) + '\n', { mode: 0o600 });
30
+ atomicWriteFileSync(p, JSON.stringify(creds, null, 2) + '\n', { mode: 0o600 }); // 质检 H4:密钥文件原子写
30
31
  // mode 只在创建时生效:对已存在但权限过宽的文件强制收权
31
32
  try {
32
33
  fs.chmodSync(p, 0o600);