mingdao-harness 0.2.8 → 0.3.0

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,109 @@
1
+ # v0.3.0 具体规划:记忆与长跑
2
+
3
+ > 依据:`docs/STRATEGY-0.3.md`(好用×省钱×省心·纵深路线)
4
+ > 定稿:2026-09-03(v0.2.8 之后)
5
+ > 主题一句话:**从「一轮 24 步的失忆问答」升级为「记得项目、续得上任务、省得明白的执行体」。**
6
+ > 说明:本文件是 v0.3.0 的任务级规划(含验收/量级/风险/降级);v0.3.1 及以后的序列见文末。
7
+
8
+ > **落地状态(2026-09-03)**:P0-1 ✅ · P0-2 ✅ · P0-3 ✅ · P2-5 ✅ · **P0-4 ⏸ 顺延 v0.3.1**(理由见 P0-4 项:风险高、验收苛刻,且 P0-2 的「续跑进度摘要注入」已覆盖省钱主场景;完整基线+增量放 v0.3.1 用省钱基准实测收益后再定)。
9
+
10
+ ---
11
+
12
+ ## 一、v0.3.0 选型理由(为什么先做这个)
13
+
14
+ 按 STRATEGY 六:**先定度量尺 → 再打"记忆/上下文"这一口井 → 能力在其上长出来**。
15
+ v0.3.0 落地的三条主线共享同一个地基——**「记忆/上下文」**:项目记忆、任务检查点、增量上下文三件事本质是同一口井(状态怎么存、怎么在有状态的基础上续跑、怎么让续跑不烧钱)。一次把地基打对,语义检索(v0.3.1)、长程任务(v0.3.1)、省钱归因(v0.3.2)都是在地基上长功能,而不是返工。
16
+
17
+ 不进 v0.3.0 的大项(明确顺延,防漂移):跨平台沙箱、WebUI 大改、全模型省钱、SaaS 化——见 STRATEGY「明确不做」。
18
+
19
+ ---
20
+
21
+ ## 二、v0.3.0 任务清单(按优先级)
22
+
23
+ ### P0-1 省钱基准任务集 + bench-savings(度量尺 · STRATEGY 三.6 / 五)
24
+ - **目标**:把"同一件事,MingDao 比裸调 DeepSeek API 省 X%、且任务做得到底"变成**可复现的数字**,纳入回归门禁(防止未来改动悄悄让省钱倒退而不自知)。
25
+ - **内容**:
26
+ 1. 定义 8–12 个代表性任务(代码生成/修复/重构/批量问答/长会话续写),覆盖:中英文、pro/flash、纯读/写文件/混合。
27
+ 2. 新建 `test/bench/bench-savings.mjs`:对每个任务统计并断言——输入/输出/缓存命中 token、费用(峰谷与 Batch 口径)、步数、成功标志;对比基线记录。
28
+ 3. 输出 `docs/SAVINGS-BENCHMARK.md`:口径、任务集、首期基线数字(省 X% 的可引用出处)。
29
+ - **验收**:CI 可复现;指标随版本有对比且允许只降不升(或每次下降触发说明);成功标志不依赖真实 API(用既有 mock 路由/供应商 stub 口径,保证离线)。
30
+ - **量级**:小(纯测试/脚本/文档)。**风险**:低。
31
+
32
+ ### P0-2 任务检查点 + 续跑 v1(好用·能打 · STRATEGY 三.1)
33
+ - **目标**:软化"24 步即死"墙——任务跑到步数上限/被中断时,**留下可续的状态**;`--continue` 与 WebUI 恢复时检测未完成任务,**先看进度再继续,不重来**。
34
+ - **内容**:
35
+ 1. agent 层:runTurn 结果带「任务状态」(`taskState: { goal, done[], next, artifacts[], capHit|interrupted }`),由 agent 在每轮收尾自动维护(新增 `taskStateRef` 参数,与 undoStore 同款进程内共享槽,落盘由会话层做)。
36
+ 2. 会话层:检查点随会话 JSONL 原子落盘(文件名同会话,`{…, checkpoint: {...}}` 或独立 `.taskstate.json` + 原子写)。
37
+ 3. CLI `--continue` / WebUI 恢复会话:检测到未完成 checkpoint → 提示「续跑(注入进度摘要)还是新开」;续跑把进度摘要注入 system(模型先确认目标再继续)。
38
+ 4. 步数语义:命中上限不再是硬终止——收尾总结(v0.2.8 已做)后标记 `capHit: true`,由续跑接管下一轮。
39
+ - **涉及**:`src/agent.js`(stepLimit 收尾分支 + taskState 维护)、`src/session.js`、`src/cli.js --continue`、`src/web/server.js + sessions 域`、`src/prompts.js`(进度摘要注入)。
40
+ - **验收**:真实 >24 步任务可跨 2+ 轮完成;中途 Ctrl+C/断线后 `--continue` 能续跑且不重复已完成写操作(幂等:已完成文件不重写);e2e 增加续跑回归。
41
+ - **量级**:中偏大(跨 CLI/WebUI/agent)。**风险**:状态与真实文件系统的"已完成 vs 未完成"判定是难点——降级方案:续跑首轮强制 `read`/`git status` 类核对再行动(宁可慢不要错)。
42
+
43
+ ### P0-3 项目级自动记忆 v1(记忆地基 · STRATEGY 三.2 前置)
44
+ - **目标**:**换会话/换项目不重来**——每个工作空间沉淀该项目的「关键决定/事实/结构/教训」,下个会话自动注入。
45
+ - **内容**:
46
+ 1. 新增「项目记忆」存储:工作空间目录下 `.mingdao/` 或会话库按工作空间分档;格式与用户记忆同构(markdown/JSONL 条目 + 时间戳 + 来源会话)。
47
+ 2. 自动提取:任务收尾(或每 N 轮)用 executor 模型从"已回填的 assistant/工具结果"提炼 1–3 条可复用事实(与现有 `memory.js` 自动提炼用户偏好同款,`config.autoProjectMemory` 默认开可关)。
48
+ 3. 注入:新任务/会话开始时注入该工作空间项目记忆(v1 先"最近 + 关键词命中",语义排序 v0.3.1);去重合并防膨胀(复用 `dedupeMemory` 思路)。
49
+ - **涉及**:`src/memory.js`(新增 workspaceMemory 系列)、`src/workspace.js`(与工作空间映射联动)、`src/prompts.js`、`src/agent.js`(收尾提取钩子)、WebUI `/api/memory` 展示入口。
50
+ - **验收**:接力测试——会话 A 改 `config` 结构并说明原因 → 新会话 B 同工作空间问起该结构时模型引用 A 的决定;项目记忆不泄漏到其他工作空间;`browse-x/` 这类杂目录不写入(目录卫生)。
51
+ - **量级**:中。**风险**:误提炼/冗余噪音——降级:默认只存"用户显式/强信号"条目,弱信号走 journal 不自动入项目记忆。
52
+
53
+ ### P0-4 增量上下文 v1 = 续跑只传「基线 + 变化」(省钱 · STRATEGY 三.3)
54
+ - **目标**:续跑(P0-2)时不把整段历史重放——发送「上一次已达成的基线快照 + 自基线起的变化」,长会话续跑 prompt 成本从 O(全长) 降为 O(增量)。
55
+ - **内容**:续跑场景复用 v0.2.8 的 trim/压缩结果做"基线",只把 checkpoint 之后的 assistant/tool 段作为增量附在压缩摘要后;非续跑的新会话行为不变。
56
+ - **涉及**:`src/context.js`、`src/compact.js`(基线/增量分界已有雏形)、`src/agent.js`。
57
+ - **验收**:续跑第 2 轮起 prompt_tokens 相比整传显著下降(bench-savings 加断言);关键约束(目标/约束/用户最新要求)不丢——用"关键事实存活"断言(复用压缩质量集思路)。
58
+ - **量级**:大。**风险**:丢了用户最新约束比省钱更糟——**本项允许降级顺延 v0.3.1**:若与 P0-2 冲突,v0.3.0 先只做"续跑注入进度摘要"(不裁剪历史),完整基线+增量放 v0.3.1。验收门槛不达标就不进 v0.3.0。
59
+
60
+ ### P2-5 省心收口:`mingdao diagnose` + CI Node 版本硬门禁(STRATEGY 三.10 / 三.11)
61
+ - **目标**:出事能查、兼容面是真话。
62
+ - **内容**:
63
+ 1. `mingdao diagnose`:一键打包 `logs/` + 最近 `audit` + config(**脱敏**:去 key/密码/token/私网路径)+ 版本/平台/Node 版本/沙箱可用性 → 输出单一文件路径,可直接贴反馈渠道。
64
+ 2. CI 把 Node 18 / 20 / 22 三腿真跑全绿收口(当前矩阵含 18/20/22,但要把"≥18.17"变成显式硬门禁,含 smoke 全量——回应自评报告 Node 20.15.1 崩溃的"兼容面信用"问题)。
65
+ - **涉及**:`src/commands/`(新 diagnose 命令)、`scripts/`(脱敏打包)、`.github/workflows/ci.yml`。
66
+ - **验收**:`mingdao diagnose` 输出可脱敏可用;CI 三 Node 版本全绿为强制(任一腿红则拦发布)。
67
+ - **量级**:小。**风险**:低。
68
+
69
+ ---
70
+
71
+ ## 三、v0.3.0 之外(顺延序列,防漂移备忘)
72
+
73
+ - **v0.3.1「检索与武器」**:零依赖语义检索(项目记忆 + 历史会话从"最近 N"→"相关 N");`git` 只读分析(diff/log/status)与 HTTP 只读抓取(SSRF 白名单兜底)工具;完整基线+增量上下文(若 P0-4 顺延)。
74
+ - **v0.3.2「看得见的省钱 + 省心」**:省钱归因面板(单任务省多少/花在哪一步/缓存命中贡献,WebUI 内 SVG 折线已有基础);`diagnose` 的自动反馈模板;覆盖率阈值随版本上调。
75
+ - **v0.3.3「纵深能力」**:跨平台沙箱补位(Windows Job Object / macOS seatbelt,平台无关敏感命令兜底);WebUI「完成任务的舒服工作台」深化(过程可回放/结论可复现)。
76
+ - **明确不进任何近期版本**:全模型省钱平台、React/框架化重构、SQLite(Node ≥22.5 破坏 18/20)、SaaS 托管化。
77
+
78
+ ---
79
+
80
+ ## 四、依赖与顺序
81
+
82
+ ```
83
+ P0-1 省钱基准(度量尺,可最先,纯独立)
84
+
85
+ P0-3 项目记忆 v1(记忆地基,独立) → P0-2 任务续跑 v1(好用主线,核心)
86
+ │ │ (续跑需进度摘要注入)
87
+ └────────────────────────────────────────┴→ P0-4 增量上下文 v1(省钱,可降级顺延)
88
+ P2-5 省心收口(全程可穿插,不阻塞主线)
89
+ ```
90
+
91
+ 实施顺序建议:**P0-1 → P0-2 → P0-3 →(P0-4 视验收决定)→ P2-5**;P0-3 与 P0-2 可并行(一个偏存储/注入、一个偏 agent 状态机),若人力单线程则先 P0-2(好用优先)。
92
+
93
+ ---
94
+
95
+ ## 五、验收总则与发布门禁(沿用既有,不放松)
96
+
97
+ 1. 六套测试全绿 + **bench 并入 P0-1 省钱基准**(194 → 200+ 断言)。
98
+ 2. strict 棘轮 0/0 不回升;coverage ≥60%。
99
+ 3. 发布前自检(release-checklist 第 5 步):纯代码审查找 bug,文件+行号证据,严重度排序。
100
+ 4. 桌面打包冒烟(CI 已内置)与三平台矩阵全绿。
101
+ 5. **防漂移对照**:每项验收前问一次——它服务「好用 / 省钱 / 省心」三词中的哪个?与 STRATEGY-0.3「明确不做」是否冲突?冲突则砍或改,不带着漂移发布。
102
+ 6. 发布后对照 `docs/SAVINGS-BENCHMARK.md` 记录一次基线数字,作为下版"省 X%"的对比起点。
103
+
104
+ ---
105
+
106
+ ## 六、备注(对历史遗留的处置)
107
+
108
+ - 工程卫生顺手清理(不占主线、随 P2-5 一并做):`browse-x/` 杂目录入库问题(加 .gitignore 或删除);`docs/ROADMAP-NEXT.md` 与本文档重叠部分以本文 + STRATEGY-0.3 为准(可后续归档)。
109
+ - 本规划若某一期体量过大,允许 v0.3.0 内部再拆 0.3.0-a / 0.3.0-b 两批(a=好用主线 P0-2/P0-3,b=省钱 P0-1 完成 + P0-4),每批仍走完整发布门禁。
@@ -0,0 +1,42 @@
1
+ # 省钱基准(SAVINGS-BENCHMARK)
2
+
3
+ > 对应规划:`docs/PLAN-v0.3.0.md` P0-1
4
+ > 运行:`node test/bench/bench-savings.mjs`(已并入 `npm run bench`)
5
+ > 首期基线:v0.2.8 代码,2026-09-03 记录
6
+ > 一句话承诺:**「同一件事,MingDao 比裸调 DeepSeek API 省 X%,且任务做得到底」——本文件就是把 X 变成可复现、可回归的数字。**
7
+
8
+ ## 一、口径(怎么测、凭什么可信)
9
+
10
+ - **离线零 API 成本**:全部用项目内置的计价函数(`pricing.js`)、精确词表(`tokenizer.js`)、工具 Schema 生成器(`tools/index.js`)、结果截断(`context.js clampText`)做「朴素基线 vs MingDao 优化」对照,不调用任何真实模型端点。
11
+ - **确定性**:计价时点固定为**闲时**(`2026-08-15T04:00:00Z`,周六 12:00 北京时间)与**高峰**(`2026-09-02T02:00:00Z`,周三 10:00 北京时间),避免真实时钟跨峰谷导致断言漂移。
12
+ - **回归门禁**:每个省钱杠杆都有阈值断言(省 X% 只降不升);未来任何改动若让省钱幅度跌破阈值,`npm run bench` 直接红灯。
13
+ - **诚实边界**:各杠杆是**正交可叠加**的,但不会全部同时生效;「综合 63%」是简单平均(衡量各杠杆各自的省钱幅度),不是"总费用打 6 折"的承诺。实际一次请求同时命中几个杠杆,省多少,取决于任务形态。
14
+
15
+ ## 二、任务集(8 项,随版本复测)
16
+
17
+ | # | 任务(省钱杠杆) | 口径 | 首期基线 |
18
+ |---|---|---|---|
19
+ | ① | 缓存命中计价 | 90% 命中 vs 全未命中(命中价 1/30) | **省 84.5%**(¥0.15450 → ¥0.02400) |
20
+ | ② | 峰谷避峰 | 闲时 vs 高峰(闲时半价) | **省 50.0%**(¥0.11700 → ¥0.05850) |
21
+ | ③ | Batch 半价 | 批量通道 vs 标准(半价) | **省 50.0%** |
22
+ | ④ | 工具 Schema 瘦身 | 已用工具剥描述 vs 全量 | **省 52.1%**(1145 → 549 tokens,每轮 ≈¥0.00089) |
23
+ | ⑤ | 只读阶段收缩 | 只读子集 vs 全量工具 | **省 52.0%**(1145 → 550 tokens) |
24
+ | ⑥ | 工具结果截断 | clampText 上限 vs 原文 | **省 91.7%**(186150 → 15488 tokens) |
25
+ | ⑦ | 精确 tokenizer 不虚高 | 精确 BPE ≤ 启发式上界 | exact 740 ≤ heuristic 795(不漏计、不虚高) |
26
+ | ⑧ | 推理分级契约 | pro 支持 low/high/max(off 可关)、flash 不发 | 契约通过 |
27
+
28
+ **综合(简单平均)**:省 **63%**(各杠杆独立口径,非叠加承诺)。
29
+
30
+ ## 三、与其它 bench 的分工
31
+
32
+ - `bench-cost.mjs`:工具 Schema 瘦身的 **token 数**、A1 两态冻结、护栏前置拦截、Batch 半价的断言。
33
+ - `bench-tokenizer.mjs`:官方词表黄金值(12 组 + 长文本精确断言)。
34
+ - `bench-routing.mjs`:路由标注集(118 条)。
35
+ - `bench-compaction.mjs`:滞回压缩质量集(46 条)。
36
+ - **`bench-savings.mjs`(本文件)**:把以上散点的省钱幅度,收敛成一张"¥ 口径 + 任务集 + 基线数字"的统一账本。
37
+
38
+ ## 四、未来如何更新基线
39
+
40
+ 1. 每个版本发布后,跑一次 `npm run bench`,把本文件「首期基线」列更新为该版本的实测数字,并备注版本号。
41
+ 2. 新增省钱杠杆(如续跑增量上下文、路由省钱归因)时,在 `bench-savings.mjs` 增任务、在本文件增行,阈值对齐「只降不升」。
42
+ 3. 对外引用"省 X%"时,一律指向本文件,不写死营销数字。
@@ -0,0 +1,121 @@
1
+ # MingDao-Harness 战略评估(v0.3.0 方向性依据)
2
+
3
+ > 成文:2026-09-03(v0.2.8 发布后)
4
+ > 性质:战略策划,非具体迭代任务。以后按本文件一步步升级迭代;每期迭代前先对照本文件确认方向不漂移。
5
+ > 一句话定位:**不做"通用 Agent 平台",做 DeepSeek 重度用户/中小团队开箱即用的高可用 Coding Agent 终端——DeepSeek 生态里最好用、最能省、最省心的那一个。**
6
+
7
+ ---
8
+
9
+ ## 〇、核心判断:省钱是"果",好用是"因"
10
+
11
+ 项目里每个省钱手段本质上都在"限制模型自由度"来换取 token:
12
+
13
+ - 只读阶段只发只读工具 → 写意图需多绕一轮
14
+ - 工具 schema 剥描述 → 省输入,但模型参数记忆压力变大
15
+ - 步数上限 24 → 复杂任务到点就停
16
+ - 滞回压缩 → 早期细节变摘要,可能丢关键约束
17
+
18
+ **一旦这些手段让任务做不成、做不对、做一半,省的就不是钱,是用户的信任。** 实证:v0.2.7→v0.2.8 修的"跑满 24 步静默收尾"——文件明明写完了,用户看到"没有总结",体验等于失败。
19
+
20
+ **因此迭代顺序必须倒过来定:好用托底 → 省钱做乘数 → 省心护城河。绝不允许省钱手段以牺牲任务成功率/多轮灵活性为代价。**
21
+
22
+ ---
23
+
24
+ ## 一、现状根因诊断
25
+
26
+ ### 1.1 真正值钱的东西(护城河材料,已实证)
27
+
28
+ | 材料 | 证据 | 定位 |
29
+ |---|---|---|
30
+ | 零依赖 | `src/` 39 文件 ~8.8K 行纯 Node 内置 API;装完即用无 node_modules | 真·稀缺;安装/分发/审计成本全降 |
31
+ | DeepSeek 省钱纵深 | 官方词表 BPE tokenizer(黄金值精确命中)· 缓存命中 1/30 计价 · 峰谷双窗口 · Batch 半价 · 前缀两态冻结 · 滞回压缩 | 同类(含大厂 CLI)没有做到这条链的闭环 |
32
+ | 工程质量闭环 | strict 棘轮 0/0 · coverage 60% · bench 194 断言 · 三平台 CI · release-checklist 自检 · 打包冒烟 · 桌面签名公证 | "省心"的真护城河 |
33
+ | 双界面 + IDE + 同步 | TUI / WebUI(PWA) / VS Code / JetBrains / 桌面 / 云同步 / 36 技能 / MCP | 好用面的广度已够 |
34
+
35
+ ### 1.2 真实短板(按严重度)
36
+
37
+ **好用线(最痛,决定"能不能打"):**
38
+ 1. **能力天花板 = 24 步单回合**:无任务级计划/检查点/续跑,复杂任务(大 repo 迁移、多文件重构、长时任务)到点即断——"能打"的最大瓶颈。
39
+ 2. **长会话上下文整传**:prompt 成本随对话线性涨;压缩靠"丢细节换摘要";大 repo 场景很快撑爆预算。
40
+ 3. **项目理解靠 grep/glob 硬扫**:无语义检索/增量索引,长代码库理解成本高、命中率低。
41
+ 4. **工具面偏窄**:只读集仅 read/ls/glob/grep/skill;缺 git 只读分析、HTTP 只读抓取、文档检索——真实 Coding 任务抓取面不够。
42
+ 5. **新会话失忆**:换会话/换项目 = 重来;无跨会话项目记忆。
43
+ 6. **非 Linux 沙箱空缺**:Windows/macOS 用户多数时间裸跑(ask 兜底,但缺"auto+沙箱"这档)。
44
+
45
+ **省钱线(方向对,但"省"还没被看见):**
46
+ 7. 省钱证据链未外化:用户看不到"这单省了多少 token、缓存命中率贡献"→ 缺钱的可视化归因。
47
+ 8. 路由分类器样本仅 100+ 条;路由错误 = 隐性浪费且用户无感。
48
+
49
+ **省心线(相对最好,仍有一刀):**
50
+ 9. Node 版本兼容面信用:自评实测 smoke 在 Node 20.15.1 尾部崩溃(全项目零 WASM → 判定环境问题),但项目声明"≥18.17"必须真守得住。
51
+ 10. 诊断靠手动翻 `~/.mingdao/logs`,无"一键诊断包"。
52
+
53
+ **工程卫生(小事但真实):** `browse-x/` 未忽略入库的杂目录;`app.js` 仍 ~1000 行单体(v0.2.8 已拆出 util/constants,可继续渐进拆分,但非护城河、低优先)。
54
+
55
+ ---
56
+
57
+ ## 二、差异化定位
58
+
59
+ - 对标物**不是** Claude Code / Codex 的功能全集,而是「用户打开它:DeepSeek 的钱花得明白、任务做得完、装完不操心」。
60
+ - 差异化 = **DeepSeek 垂直切面比谁深 × 工程可信度比谁稳**。
61
+ - 护城河三根桩:**零依赖(分发端)× DeepSeek 成本经济学(省钱端)× 工程质量闭环(信任端)**。
62
+
63
+ ### 避坑提醒(已认真考虑)
64
+ - ❌ 不扩成"全模型省钱平台"(其他模型省钱是伪命题,那是 LiteLLM 赛道)。
65
+ - ❌ 不做 React/框架化 UI 重构(有收益但非护城河,且违背零构建;v0.2.8 原生 ESM 拆分已够)。
66
+ - ❌ 不因"SQLite 内置查询"强上 SQLite(需 Node ≥22.5,破坏 18/20 兼容矩阵,v0.2.8 已确认,忍住)。
67
+
68
+ ---
69
+
70
+ ## 三、纵深路线(三线 + 一层底)
71
+
72
+ ### 线 1|好用 = 能打:Harness 实际能力纵深
73
+ 1. **任务级长程执行**:"24 步单回合" → 「任务意图 → 计划/检查点 → 子任务编排 → 中断可续」;允许任务跨会话续跑、多轮保持上下文焦点。这是"能打"的地基。
74
+ 2. **项目理解升级**:零依赖语义检索(内存向量 + 余弦/哈希相似度),read/grep 从 O(全扫) 降到 O(相关)。
75
+ 3. **上下文增量**:滞回压缩之上再做请求侧增量(只回传变化段,不做整段历史重放)。
76
+ 4. **工具面扩到 Coding 真实场景**:git 只读分析(diff/log/status)、HTTP 只读抓取(SSRF 白名单兜底)、文档结构化检索。
77
+ 5. **WebUI 做"完成任务的舒服工作台"**:任务过程可回放、结论可复现、交付物可跳转。
78
+
79
+ ### 线 2|省钱 = 极限 Token 经济学
80
+ 6. **省钱基准(度量尺,最重要)**:同一任务集下对比 MingDao vs 裸 DeepSeek API vs 对标 CLI,比 token/费用/成功率。护城河用数字证明。
81
+ 7. **钱的可视化归因**:从"今日 ¥x"→"这一单省多少/花在哪一步/缓存命中贡献",让用户看见省钱。
82
+ 8. **推理 token 治理深化**:reasoning effort 按任务复杂度自适应(v0.2.8 已做按模型独立 → 下一步按任务);简单任务默认关思考。
83
+ 9. **路由与压缩前置预算**:分类器样本集扩到数百条,路由/压缩的隐性浪费纳入基准。
84
+
85
+ ### 线 3|省心 = 开箱即用、出事能查
86
+ 10. **一键诊断包**:`mingdao diagnose` → 自动打包 logs + audit + config(脱敏)+ 版本/平台/Node 信息,贴给反馈即查。
87
+ 11. **兼容面信用**:"Node ≥18.17" 变成 CI 真跑 18/20/22 三版本全绿的硬门禁。
88
+ 12. 桌面/安装/更新链路已极成熟,保持即可,不返工。
89
+
90
+ ### 底层|护城河沉淀
91
+ 13. **公开省钱基准 + 工程质量故事**:把"零依赖 + 194 断言 + 棘轮归零 + 打包冒烟 + 三平台"写成可被第三方验证的工程叙事。
92
+ 14. 零依赖保持"单一 `node src/cli.js` 即跑"的纯净;任何新能力先问:能否用 Node 内置实现?不能则不进核心。
93
+
94
+ ---
95
+
96
+ ## 四、明确不做(防漂移清单)
97
+
98
+ - ❌ 全模型省钱平台(LiteLLM 赛道)
99
+ - ❌ React/框架化 UI 重构、SQLite 强上
100
+ - ❌ 云 SaaS / 托管平台化(保持自建优先、用户掌控)
101
+ - ❌ 与主流大厂拼模型能力、拼插件市场广度
102
+ - ✅ 保持:零依赖、零构建、DeepSeek 为中心、自建优先
103
+
104
+ ---
105
+
106
+ ## 五、终极目标与一句话承诺
107
+
108
+ > **"同一件事,MingDao 比直接调 DeepSeek API 省 X%,且任务做得到底。"**
109
+
110
+ 这句话一旦被**公开、可复现的省钱基准**坐实,就是护城河本身:
111
+ 零依赖 → 易分发;工程闭环 → 可信;省钱基准 → 被看见。
112
+ **一切迭代都围绕把这句话做得更硬。**
113
+
114
+ ---
115
+
116
+ ## 六、迭代落地顺序原则
117
+
118
+ 1. **先定「省钱基准任务集」**(这句话的度量尺,决定后面一切数字)。
119
+ 2. **再排 v0.3.0 主线**:长程执行 vs 语义检索 vs 上下文增量——三者共享"记忆/上下文"地基,先做统一设计再分步实现。
120
+ 3. **穿插省心小迭代**:诊断包 / CI Node 版本硬门禁 / 省钱仪表盘。
121
+ 4. 每期迭代发布前对照本文件:新功能是否服务于「好用、省钱、省心」三词之一?是否引入与定位冲突的漂移?——是则调整,否则不做。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,7 +54,7 @@
54
54
  "desktop": "npm --prefix desktop start",
55
55
  "desktop:dist": "node scripts/sync-versions.mjs && npm --prefix desktop run dist:dir",
56
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 && node test/bench/bench-cost.mjs",
57
+ "bench": "node test/bench/bench-tokenizer.mjs && node test/bench/bench-routing.mjs && node test/bench/bench-compaction.mjs && node test/bench/bench-cost.mjs && node test/bench/bench-savings.mjs",
58
58
  "coverage": "node test/run-all.mjs --coverage && node scripts/coverage-report.mjs",
59
59
  "typecheck:strict": "node scripts/strict-ratchet.mjs",
60
60
  "preversion": "node test/smoke.js && node test/e2e-local.js && npm run bench",
package/src/agent.js CHANGED
@@ -674,10 +674,11 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
674
674
  usage.completion_tokens += wrapRes.usage.completion_tokens || 0;
675
675
  }
676
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() };
677
+ // capHit:本轮因步数上限被迫收尾(任务可能未真正完成)→ 供上层落检查点续跑
678
+ return { text: wrapRes.text || null, reasoning: wrapRes.reasoning || '', usage, steps, finish, truncated: false, aborted: false, capHit: true, durationMs: Date.now() - startedAt, perf: perf() };
678
679
  } catch {}
679
680
  }
680
- return { text: null, reasoning: '', usage, steps, finish, truncated: true, aborted: false, durationMs: Date.now() - startedAt, perf: perf() };
681
+ return { text: null, reasoning: '', usage, steps, finish, truncated: true, aborted: false, capHit: true, durationMs: Date.now() - startedAt, perf: perf() };
681
682
  } finally {
682
683
  currentAc = null;
683
684
  offSigint();
package/src/cli.js CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  formatScheduleRow,
35
35
  } from './schedule.js';
36
36
  import { createAgent } from './agent.js';
37
+ import { saveTaskState, clearTaskState } from './task-state.js';
37
38
  import { createPermission } from './permissions.js';
38
39
  import { buildSystemPrompt } from './prompts.js';
39
40
  import { listSkills, tamperedSkillNames } from './skills.js';
@@ -82,6 +83,7 @@ const HELP_LINES = [
82
83
  [' mingdao update [--check] 一键自更新(git 安装形态;--check 只对比版本)', null],
83
84
  [' mingdao rollback 回滚到上次 update 之前的提交', null],
84
85
  [' mingdao audit [数量] 查看工具调用审计日志(默认最近 20 条)', null],
86
+ [' mingdao diagnose 一键生成诊断报告(脱敏打包日志/审计/配置,便于反馈排查)', null],
85
87
  [' mingdao desktop 启动桌面版(Electron,任意目录可用,托盘常驻)', null],
86
88
  [' mingdao --help / --version 帮助 / 版本', null],
87
89
  ['', null],
@@ -188,6 +190,7 @@ async function main() {
188
190
  sessions: { module: 'skill', handler: 'handleSessions' },
189
191
  key: { module: 'key', handler: 'handleKey' },
190
192
  desktop: { module: 'desktop', handler: 'handleDesktop' },
193
+ diagnose: { module: 'diagnose', handler: 'handleDiagnose' },
191
194
  });
192
195
  const hit = dispatchTable[opts.prompt[0]];
193
196
  if (hit) {
@@ -469,6 +472,18 @@ async function main() {
469
472
  if (renamed) io.print(style(`✓ 会话标题:${path.basename(renamed)}`, C.dim));
470
473
  }
471
474
  }
475
+ // v0.3.0 P0-2:单次提问跑满步数/中断落检查点(--continue 可续跑),正常完成清除
476
+ if (res.capHit || res.aborted) {
477
+ saveTaskState(path.basename(session.file), {
478
+ goal: question,
479
+ progress: res.text || '',
480
+ artifacts: [],
481
+ status: res.capHit ? 'cap' : 'interrupted',
482
+ updatedAt: new Date().toISOString(),
483
+ });
484
+ } else {
485
+ clearTaskState(path.basename(session.file));
486
+ }
472
487
  try {
473
488
  await finalizeSession({ cfg, provider, model: titleModel(cfg, modelName), home, workingDir, messages, turns: 1, lastText: res.text || '' });
474
489
  } catch {}
@@ -0,0 +1,105 @@
1
+ // 命令族:mingdao diagnose(v0.3.0 P2-5):一键打包诊断信息(脱敏),便于贴反馈渠道排查。
2
+ // 只读 + 单文件输出:环境/版本/config(脱敏)/日志尾/审计尾/工作空间,凭证库只注明存在不读内容。
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { createIO, style, C } from '../ui.js';
8
+ import { mingdaoHome, ensureHome, loadConfig } from '../config.js';
9
+ import { credentialsPath } from '../credentials.js';
10
+ import { listAudit, redactSecrets } from '../audit.js';
11
+ import { projectMemoryFile, loadProjectMemory } from '../memory.js';
12
+ import { listWorkspaces } from '../workspace.js';
13
+ import { detectSandbox } from '../tools/bash.js';
14
+
15
+ // 更严格的脱敏:sk-/ghp_ token、key/token/secret/password=值、私网 IP、家目录路径
16
+ function redactSensitive(/** @type {any} */ text) {
17
+ let s = redactSecrets(text);
18
+ s = s.replace(/(ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g, 'ghp_***');
19
+ s = s.replace(/((?:api[_-]?key|token|secret|password|passwd|access_token)\s*[=:]\s*["']?)[^\s"',}]+/gi, '$1***');
20
+ s = s.replace(/\b(?:10|127)(?:\.\d{1,3}){3}\b|\b192\.168(?:\.\d{1,3}){2}\b|\b172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}\b/g, '[私网IP]');
21
+ const home = os.homedir();
22
+ if (home && home.length > 1) s = s.split(home).join('~');
23
+ return s;
24
+ }
25
+
26
+ function tailFile(/** @type {any} */ file, /** @type {number} */ lines = 60) {
27
+ try {
28
+ const raw = fs.readFileSync(file, 'utf8');
29
+ return raw.split('\n').filter(Boolean).slice(-lines).join('\n');
30
+ } catch {
31
+ return '';
32
+ }
33
+ }
34
+
35
+ export async function handleDiagnose(/** @type {any} */ _cmd, /** @type {any} */ _args) {
36
+ const io = createIO();
37
+ try {
38
+ ensureHome();
39
+ const home = mingdaoHome();
40
+ let version = '未知';
41
+ try {
42
+ const pkg = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf8'));
43
+ version = pkg.version || '未知';
44
+ } catch {}
45
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
46
+ const outFile = path.join(home, `diagnose-${stamp}.txt`);
47
+ const L = /** @type {string[]} */ ([]);
48
+ const push = (/** @type {string} */ s) => L.push(s);
49
+
50
+ push('# MingDao Harness 诊断报告');
51
+ push('生成时间:' + new Date().toISOString());
52
+ push('');
53
+ push('## 环境');
54
+ push(`- 版本:${version}`);
55
+ push(`- Node:${process.version} · 平台:${process.platform} · 架构:${process.arch}`);
56
+ push(`- 系统:${os.type()} ${os.release()}`);
57
+ push(`- 工作目录:${redactSensitive(process.cwd())}`);
58
+ push(`- 沙箱(bubblewrap):${detectSandbox()}`);
59
+
60
+ push('');
61
+ push('## 配置(config.json,已脱敏)');
62
+ const cfg = loadConfig();
63
+ push(cfg ? redactSensitive(JSON.stringify(cfg, null, 2)) : '(不存在或读取失败)');
64
+
65
+ push('');
66
+ push('## 凭证库');
67
+ push(`${redactSensitive(credentialsPath())}:${fs.existsSync(credentialsPath()) ? '已存在(内容不打包)' : '无'}`);
68
+
69
+ push('');
70
+ push('## 服务端日志(logs/web-server.log 尾部 60 行)');
71
+ const srvLog = tailFile(path.join(home, 'logs', 'web-server.log'), 60);
72
+ push(srvLog ? redactSensitive(srvLog) : '(无日志)');
73
+
74
+ push('');
75
+ push('## 审计(最近 20 条,已脱敏)');
76
+ const audits = listAudit(20);
77
+ if (!audits.length) push('(无审计记录)');
78
+ for (const a of audits) {
79
+ push(redactSensitive(JSON.stringify(a)));
80
+ }
81
+
82
+ push('');
83
+ push('## 工作空间与项目记忆');
84
+ const wss = listWorkspaces();
85
+ if (!wss.length) push('(未登记工作空间)');
86
+ for (const w of wss) {
87
+ push(`- ${w.name} → ${redactSensitive(w.dir || '')}`);
88
+ const mem = loadProjectMemory(w.dir);
89
+ const memPath = redactSensitive(projectMemoryFile(w.dir));
90
+ push(` 项目记忆:${memPath}(${mem ? mem.split('\n').filter(Boolean).length + ' 条' : '无'})`);
91
+ }
92
+ // 当前目录的项目记忆(即使未登记为工作空间也能定位,便于排查「记忆写哪了」)
93
+ const cwdMem = projectMemoryFile(process.cwd());
94
+ push(`当前目录项目记忆:${redactSensitive(cwdMem)}(${fs.existsSync(cwdMem) ? '存在' : '无'})`);
95
+
96
+ fs.writeFileSync(outFile, L.join('\n') + '\n');
97
+ io.print(style(`✓ 诊断报告已生成:${outFile}`, C.green));
98
+ io.print(style('请把该文件内容贴到反馈渠道排查;密钥/token/私网路径已脱敏。', C.dim));
99
+ } catch (/** @type {any} */ err) {
100
+ io.print(style('[错误] ' + (err?.message || err), C.red));
101
+ } finally {
102
+ io.close();
103
+ }
104
+ return true;
105
+ }
@@ -54,6 +54,7 @@ import {
54
54
  relativeTime,
55
55
  searchSessions,
56
56
  } from '../session.js';
57
+ import { loadTaskState, saveTaskState, clearTaskState, resumePrompt } from '../task-state.js';
57
58
 
58
59
  const pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
59
60
 
@@ -223,6 +224,12 @@ export async function runRepl(ctx) {
223
224
  let messages = hasOldSystem
224
225
  ? [{ role: 'system', content: systemPrompt }, ...loadedMsgs.slice(1)]
225
226
  : [{ role: 'system', content: systemPrompt }, ...loadedMsgs];
227
+ // v0.3.0 P0-2:续跑——载入有未完成检查点的会话时注入进度摘要,让模型从断点继续
228
+ const resumeTs = loadTaskState(path.basename(session.file));
229
+ if (resumeTs && (resumeTs.status === 'cap' || resumeTs.status === 'interrupted')) {
230
+ messages.push({ role: 'user', content: resumePrompt(resumeTs) });
231
+ io.print(style('⚠ 检测到未完成任务,已注入续跑提示(完成后自动清除)。', C.yellow));
232
+ }
226
233
  tuiState.persisted = messages.length;
227
234
  let lastUsage = null;
228
235
  let lastText = '';
@@ -673,6 +680,18 @@ export async function runRepl(ctx) {
673
680
  if (renamed) io.print(style(`✓ 会话标题:${path.basename(renamed)}`, C.dim));
674
681
  }
675
682
  }
683
+ // v0.3.0 P0-2:跑满步数(capHit)或中断(aborted)落检查点,正常完成清除
684
+ if (res.capHit || res.aborted) {
685
+ saveTaskState(path.basename(session.file), {
686
+ goal: input,
687
+ progress: res.text || '',
688
+ artifacts: [],
689
+ status: res.capHit ? 'cap' : 'interrupted',
690
+ updatedAt: new Date().toISOString(),
691
+ });
692
+ } else {
693
+ clearTaskState(path.basename(session.file));
694
+ }
676
695
  if (res.aborted) {
677
696
  io.print(style('(已中断)', C.dim));
678
697
  }
package/src/memory.js CHANGED
@@ -216,6 +216,126 @@ export async function extractMemory(/** @type {any} */ provider, /** @type {any}
216
216
  }
217
217
  }
218
218
 
219
+ // —— 项目级自动记忆(v0.3.0 P0-3)——
220
+ // 与用户记忆(偏好/约定)区分:项目记忆沉淀「关键决定/事实/结构/教训」,随工作空间走,
221
+ // 换会话/换项目不串。存储:<工作空间>/.mingdao/memory.md(git 可忽略,属本机智能体的项目笔记)。
222
+ export function projectMemoryFile(/** @type {any} */ workingDir) {
223
+ return path.join(String(workingDir || ''), '.mingdao', 'memory.md');
224
+ }
225
+
226
+ export function loadProjectMemory(/** @type {any} */ workingDir) {
227
+ try {
228
+ return fs.readFileSync(projectMemoryFile(workingDir), 'utf8');
229
+ } catch {
230
+ return '';
231
+ }
232
+ }
233
+
234
+ export function appendProjectMemory(/** @type {any} */ workingDir, /** @type {any} */ lines) {
235
+ const add = lines.map((/** @type {any} */ l) => l.trim()).filter(Boolean);
236
+ if (!add.length || !workingDir) return 0;
237
+ try {
238
+ fs.mkdirSync(path.dirname(projectMemoryFile(workingDir)), { recursive: true, mode: 0o700 });
239
+ const bp = beijingParts(new Date());
240
+ const date = `${bp.year}-${String(bp.month).padStart(2, '0')}-${String(bp.day).padStart(2, '0')}`;
241
+ fs.appendFileSync(
242
+ projectMemoryFile(workingDir),
243
+ add.map((/** @type {any} */ l) => (l.startsWith('-') ? `- [${date}] ${l.slice(1).trim()}` : `- [${date}] ${l}`)).join('\n') + '\n'
244
+ );
245
+ return add.length;
246
+ } catch {
247
+ return 0;
248
+ }
249
+ }
250
+
251
+ export function dedupeProjectMemory(/** @type {any} */ workingDir) {
252
+ const raw = loadProjectMemory(workingDir);
253
+ if (!raw.trim()) return 0;
254
+ const seen = new Set();
255
+ const kept = [];
256
+ let removed = 0;
257
+ for (const line of raw.split('\n')) {
258
+ const t = line.trim();
259
+ if (!t) continue;
260
+ const norm = t.replace(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*/, '').trim().toLowerCase();
261
+ if (seen.has(norm)) { removed += 1; continue; }
262
+ seen.add(norm);
263
+ kept.push(t);
264
+ }
265
+ if (removed > 0) {
266
+ try { fs.writeFileSync(projectMemoryFile(workingDir), kept.join('\n') + '\n'); } catch {}
267
+ }
268
+ return removed;
269
+ }
270
+
271
+ // 项目记忆提取:关键决定及其原因、文件/目录结构、依赖与约定、踩过的坑与教训、未完成事项
272
+ export async function extractProjectMemory(/** @type {any} */ provider, /** @type {any} */ model, /** @type {any} */ messages, /** @type {any} */ existing) {
273
+ try {
274
+ const convo = messages
275
+ .filter((/** @type {any} */ m) => m.role === 'user' || m.role === 'assistant')
276
+ .slice(-20)
277
+ .map((/** @type {any} */ m) => `${m.role === 'user' ? '用户' : 'MingDao'}:${String(m.content || '').slice(0, 500)}`)
278
+ .join('\n');
279
+ const msgs = [
280
+ {
281
+ role: 'system',
282
+ content:
283
+ '你是 MingDao Harness 的项目记忆提取器。从对话中提取值得长期记住的「项目级事实」:关键决定及其原因、文件/目录结构、依赖与约定、踩过的坑与教训、未完成事项。每条 ≤50 字,只输出新条目(与「已有记忆」重复或对话中未提及的不要输出)。只输出 JSON:{"items": ["条目1", "条目2"]};没有新增时输出 {"items": []}。\n已有记忆:\n' +
284
+ (existing || '(空)'),
285
+ },
286
+ { role: 'user', content: convo.slice(0, 8000) },
287
+ ];
288
+ try {
289
+ const res = await provider.chat({ model, messages: msgs, tools: [], temperature: 0.2, maxTokens: 250, reasoningEffort: 'low', responseFormat: { type: 'json_object' } });
290
+ const j = JSON.parse(String(res.text || '').trim());
291
+ const items = Array.isArray(j?.items) ? j.items : [];
292
+ const clean = items
293
+ .map((/** @type {any} */ l) => String(l).trim())
294
+ .filter(Boolean)
295
+ .map((/** @type {any} */ l) => (/^[·•]/.test(l) ? '- ' + l.replace(/^[·•]\s*/, '') : l.startsWith('-') ? l : '- ' + l))
296
+ .slice(0, 10);
297
+ if (clean.length || items.length === 0) return clean;
298
+ } catch {}
299
+ const res = await provider.chat({
300
+ model,
301
+ messages: [
302
+ {
303
+ role: 'system',
304
+ content:
305
+ '你是 MingDao Harness 的项目记忆提取器。从对话中提取值得长期记住的项目级事实(关键决定及原因、结构、依赖约定、教训、未完成事项)。每条一行,以 - 开头,≤50 字,只输出新条目(与「已有记忆」重复或未提及的不要输出);没有新增就只输出「无新增」。\n已有记忆:\n' +
306
+ (existing || '(空)'),
307
+ },
308
+ { role: 'user', content: convo.slice(0, 8000) },
309
+ ],
310
+ tools: [],
311
+ temperature: 0.2,
312
+ maxTokens: 300,
313
+ });
314
+ const text = String(res.text || '').trim();
315
+ if (!text || text.includes('无新增')) return [];
316
+ return text
317
+ .split('\n')
318
+ .map((/** @type {any} */ l) => l.trim())
319
+ .filter((/** @type {any} */ l) => l.startsWith('-') || /^[·•]/.test(l))
320
+ .map((/** @type {any} */ l) => l.replace(/^[·•]\s*/, '- '))
321
+ .slice(0, 10);
322
+ } catch {
323
+ return [];
324
+ }
325
+ }
326
+
327
+ // 项目记忆提取 + 追加(CLI finalizeSession 与 WebUI 收尾共用;配置 autoProjectMemory 默认开)
328
+ export async function extractAndAppendProjectMemory(/** @type {any} */ { cfg, provider, model, messages, workingDir }) {
329
+ if (!workingDir || cfg?.autoProjectMemory === false) return;
330
+ try {
331
+ const existing = loadProjectMemory(workingDir);
332
+ const { helperProvider } = await import('./providers/index.js');
333
+ const memProvider = await helperProvider(cfg, model, provider);
334
+ const lines = await extractProjectMemory(memProvider, model, messages, existing);
335
+ if (lines.length) appendProjectMemory(workingDir, lines);
336
+ } catch {}
337
+ }
338
+
219
339
  // 会话收尾:写日志 + 自动记忆(turns 太少的一次性会话只记日志不提取)
220
340
  export async function finalizeSession(/** @type {any} */ { cfg, provider, model, home, workingDir, messages, turns, lastText }) {
221
341
  const firstUser = messages.find((/** @type {any} */ m) => m.role === 'user')?.content || '';
@@ -239,4 +359,10 @@ export async function finalizeSession(/** @type {any} */ { cfg, provider, model,
239
359
  if (lines.length) appendMemory(lines);
240
360
  } catch {}
241
361
  }
362
+ // v0.3.0 P0-3:项目级自动记忆(有实际工具工作才提取,避免空话污染项目记忆;
363
+ // 单轮重型任务也会执行工具,故按「是否有 tool 消息」判定而非 turn 数)
364
+ const hadToolWork = messages.some((/** @type {any} */ m) => m.role === 'tool');
365
+ if (workingDir && (hadToolWork || turns >= 2)) {
366
+ await extractAndAppendProjectMemory({ cfg, provider, model, messages, workingDir });
367
+ }
242
368
  }
package/src/prompts.js CHANGED
@@ -5,7 +5,7 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { skillsRegistryBlock } from './skills.js';
7
7
  import { mingdaoHome, loadConfig } from './config.js';
8
- import { recentJournalBlock } from './memory.js';
8
+ import { recentJournalBlock, loadProjectMemory } from './memory.js';
9
9
 
10
10
  const BASE = `你是 MingDao Harness,一个由 MingDao Harness 驱动的 AI 编程助手。你在用户的电脑上工作:通过工具读写文件、搜索代码、执行命令,帮助用户完成编程、调试与自动化任务。
11
11
 
@@ -30,8 +30,8 @@ function loadFile(/** @type {any} */ p, /** @type {any} */ cap) {
30
30
  }
31
31
  }
32
32
 
33
- /** @param {{ workingDir: any, withJournal?: boolean, [key: string]: any }} opts */
34
- export function buildSystemPrompt({ workingDir, withJournal = false }) {
33
+ /** @param {{ workingDir: any, withJournal?: boolean, projectMemory?: string, [key: string]: any }} opts */
34
+ export function buildSystemPrompt({ workingDir, withJournal = false, projectMemory }) {
35
35
  // 前缀字节稳定性(评估 P1-1/P1-2,四份评估一致的最高价值项):
36
36
  // 系统提示不含「当前模型」「当前日期」等易变字段——DeepSeek 上下文缓存按前缀字节匹配,
37
37
  // 路由 pro⇄flash 翻转或跨天会改变前缀 → 整段历史按未命中价重计(命中价的 30 倍)。
@@ -44,6 +44,12 @@ export function buildSystemPrompt({ workingDir, withJournal = false }) {
44
44
  const memory = loadFile(path.join(mingdaoHome(), 'AGENTS.md'), 8000);
45
45
  if (memory) prompt += `\n\n<user_memory>\n${memory}\n</user_memory>`;
46
46
 
47
+ // 项目级自动记忆(v0.3.0 P0-3):<工作空间>/.mingdao/memory.md 自动沉淀的决定/事实/教训。
48
+ // 与 AGENTS.md(手动约定)区分;默认截 4K 保持系统提示前缀稳定,超长可 read 工具按需读。
49
+ // projectMemory 传入则用「会话内快照」(WebUI 保证同一会话内前缀稳定),否则读文件(CLI/REPL)。
50
+ const projMem = projectMemory !== undefined ? projectMemory : loadProjectMemory(workingDir);
51
+ if (projMem) prompt += `\n\n<project_memory>\n${projMem.length > 4000 ? projMem.slice(0, 4000) + '\n…[过长已截断]' : projMem}\n</project_memory>`;
52
+
47
53
  // 最近会话日志(跨会话连续性):默认不注入——新会话应当全新开始,避免串到
48
54
  // 上一次会话的上下文(曾出现「新会话却接着给上个会话的游戏升级」的混淆)。
49
55
  // 仅当用户显式开启时注入(WebUI 勾选「带上文」/ CLI --journal)。
@@ -0,0 +1,59 @@
1
+ // 任务检查点(v0.3.0 P0-2):把「一轮 24 步跑满即止」升级为「可续跑」。
2
+ // 存储:<mingdao-home>/taskstates/<会话名>.json(独立侧车文件 + 原子写,不污染会话 JSONL 消息结构)。
3
+ // 状态:{ goal, progress, artifacts, status('cap'|'interrupted'), updatedAt }
4
+ // - cap:本轮跑满步数上限,未完成,可续跑;
5
+ // - interrupted:用户主动中断(Ctrl+C/停止),可续跑;
6
+ // - 任务正常完成时清除检查点(clearTaskState)。
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { mingdaoHome } from './config.js';
10
+ import { atomicWriteFileSync } from './atomic-write.js';
11
+
12
+ function taskStateDir() {
13
+ return path.join(mingdaoHome(), 'taskstates');
14
+ }
15
+
16
+ /** @param {any} sessionName */
17
+ export function taskStateFile(sessionName) {
18
+ return path.join(taskStateDir(), `${String(sessionName)}.json`);
19
+ }
20
+
21
+ /** @param {any} sessionName */
22
+ export function loadTaskState(sessionName) {
23
+ try {
24
+ const j = JSON.parse(fs.readFileSync(taskStateFile(sessionName), 'utf8'));
25
+ return j && typeof j === 'object' ? j : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /** @param {any} sessionName @param {any} ts */
32
+ export function saveTaskState(sessionName, ts) {
33
+ try {
34
+ const dir = taskStateDir();
35
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
36
+ atomicWriteFileSync(taskStateFile(sessionName), JSON.stringify(ts) + '\n', { mode: 0o600 });
37
+ } catch {}
38
+ }
39
+
40
+ /** @param {any} sessionName */
41
+ export function clearTaskState(sessionName) {
42
+ try {
43
+ fs.unlinkSync(taskStateFile(sessionName));
44
+ } catch {}
45
+ }
46
+
47
+ // 续跑提示:注入到消息历史,让模型先核对现状(已完成文件不重做)、再做未完成部分。
48
+ /** @param {any} ts */
49
+ export function resumePrompt(ts) {
50
+ const art = (Array.isArray(ts?.artifacts) && ts.artifacts.length ? ts.artifacts : []).join('、') || '(无)';
51
+ const progress = String(ts?.progress || '').slice(0, 2000) || '(无总结)';
52
+ const goal = String(ts?.goal || '').slice(0, 500) || '(未知)';
53
+ return (
54
+ `(系统提示)上一个任务「${goal}」因步数上限中断,尚未完成,现在续跑。\n` +
55
+ `已完成进度摘要:\n${progress}\n` +
56
+ `已交付文件(勿重复重做,先核对现状):${art}\n` +
57
+ `请从断点继续:先确认已完成部分的现状,再完成剩余工作。`
58
+ );
59
+ }
package/src/web/app.js CHANGED
@@ -258,7 +258,7 @@ function renderTodos(todos,msg){
258
258
  for(const t of todos){ const li=document.createElement('li'); li.className=t.status==='completed'?'done':t.status==='in_progress'?'doing':''; li.innerHTML=(t.status==='completed'?'✓ ':'○ ')+esc(t.content); ul.appendChild(li); }
259
259
  msg.appendChild(ul);
260
260
  }
261
- function renderBanner(ev){ const d=document.createElement('div'); d.className='banner'; d.textContent=ev.text||ev.title||''; insertBeforeActiveMsg(d); scrollBottom(); }
261
+ function renderBanner(ev){ const d=document.createElement('div'); d.className='banner'+(ev.warn?' banner-warn':''); d.textContent=ev.text||ev.title||''; insertBeforeActiveMsg(d); scrollBottom(); }
262
262
  // 工具卡/横幅/代码块统一插到「本轮 AI 消息」之前:结论与交付物永远位于消息最底部
263
263
  function insertBeforeActiveMsg(el){ if(activeAiMsg && activeAiMsg.parentNode){ chatEl.insertBefore(el, activeAiMsg); } else { chatEl.appendChild(el); } }
264
264
 
@@ -1049,12 +1049,17 @@ async function applyConfig(payload, revertTarget){
1049
1049
  }
1050
1050
  $('#modelSel').addEventListener('change', e=>{ applyConfig({model:e.target.value}, e.target); });
1051
1051
  $('#permSel').addEventListener('change', e=>{ applyConfig({permission:e.target.value}, e.target); });
1052
- async function loadSession(file){ currentSession=file; chatEl.innerHTML=''; const r=await fetch('/api/session?file='+encodeURIComponent(file)); const j=await r.json(); for(const m of j.messages||[]){ if(typeof m.content==='string'&&m.content.startsWith('(系统提示)')) continue; if(m.role==='user') addUser(m.content); else { const el=newAiMsg(); aiContent(el).innerHTML=renderMarkdown(m.content); } } if(j.workspace){ const sel=$('#wsSel'); const has=[...sel.options].some(o=>o.value===j.workspace); if(has){ sel.value=j.workspace; } else { refreshWsSel(); } renderBanner({text:'↩ 已回到该会话的工作空间:'+j.workspace}); } scrollBottom(); }
1052
+ // v0.3.0 P0-3:会话边界收尾(切换/新对话时提取项目记忆+日志,fire-and-forget 不阻塞 UI)
1053
+ function finalizeCurrentSession(){
1054
+ if(!currentSession) return;
1055
+ fetch('/api/session-finalize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file:currentSession})}).catch(()=>{});
1056
+ }
1057
+ async function loadSession(file){ if(currentSession && currentSession!==file) finalizeCurrentSession(); currentSession=file; chatEl.innerHTML=''; const r=await fetch('/api/session?file='+encodeURIComponent(file)); const j=await r.json(); for(const m of j.messages||[]){ if(typeof m.content==='string'&&m.content.startsWith('(系统提示)')) continue; if(m.role==='user') addUser(m.content); else { const el=newAiMsg(); aiContent(el).innerHTML=renderMarkdown(m.content); } } if(j.workspace){ const sel=$('#wsSel'); const has=[...sel.options].some(o=>o.value===j.workspace); if(has){ sel.value=j.workspace; } else { refreshWsSel(); } renderBanner({text:'↩ 已回到该会话的工作空间:'+j.workspace}); } if(j.taskState&&(j.taskState.status==='cap'||j.taskState.status==='interrupted')){ renderBanner({text:'⚠ 该会话有未完成任务(步数上限中断)——直接发送消息即可从断点续跑,已完成文件不会重复做。', warn:true}); } scrollBottom(); }
1053
1058
 
1054
1059
  sendBtn.onclick=()=>{ if(generating){ abort(); } else { send(); } };
1055
1060
  input.addEventListener('keydown',e=>{ if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); send(); } });
1056
1061
  input.addEventListener('input',()=>{ input.style.height='44px'; input.style.height=Math.min(input.scrollHeight,200)+'px'; });
1057
- $('#newChat').onclick=()=>{ currentSession=null; chatEl.innerHTML=''; refreshSessions(); };
1062
+ $('#newChat').onclick=()=>{ finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; refreshSessions(); };
1058
1063
  $('#sessMgrBtn').onclick=openSessModal;
1059
1064
  $('#sessClose').onclick=()=>{ $('#sessModal').style.display='none'; };
1060
1065
  async function openSessModal(){
@@ -1070,6 +1075,6 @@ async function openSessModal(){
1070
1075
  div.appendChild(rn); div.appendChild(dl); list.appendChild(div);
1071
1076
  }
1072
1077
  }
1073
- $('#sessions').onchange=e=>{ if(e.target.value){ loadSession(e.target.value); } else { currentSession=null; chatEl.innerHTML=''; } };
1078
+ $('#sessions').onchange=e=>{ if(e.target.value){ loadSession(e.target.value); } else { finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; } };
1074
1079
  if('serviceWorker' in navigator){ navigator.serviceWorker.register('/sw.js').catch(()=>{}); }
1075
1080
  init();
@@ -187,6 +187,7 @@ main{flex:1;overflow-y:auto;padding:18px 0 12px;min-width:0}
187
187
  .todo li.doing{color:var(--accent2)}
188
188
  .usage{font-size:12px;color:var(--faint);border-top:1px dashed var(--border);padding-top:6px}
189
189
  .banner{text-align:center;color:var(--faint);font-size:12.5px}
190
+ .banner-warn{background:rgba(245,158,11,.08);border:1px solid rgba(245,158,11,.35);color:var(--warn);border-radius:12px;padding:8px 14px;text-align:left}
190
191
  .errline{color:var(--err);background:rgba(229,83,75,.08);border:1px solid rgba(229,83,75,.3);border-radius:10px;padding:10px 14px;font-size:13.5px}
191
192
  footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);padding:12px 18px}
192
193
  #composer{max-width:860px;margin:0 auto;display:flex;gap:10px;align-items:flex-end}
@@ -9,7 +9,8 @@ import {
9
9
  relativeTime,
10
10
  sessionPreview,
11
11
  } from '../../../session.js';
12
- import { sanitizeTitle, renameSessionFile } from '../../../titles.js';
12
+ import { sanitizeTitle, renameSessionFile, titleModel } from '../../../titles.js';
13
+ import { loadTaskState } from '../../../task-state.js';
13
14
  import {
14
15
  workspaceForDir,
15
16
  touchWorkspace,
@@ -53,6 +54,8 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
53
54
  ok: true,
54
55
  messages: loaded.messages.filter((m) => m.role === 'user' || m.role === 'assistant').map((m) => ({ role: m.role, content: m.content ?? '' })),
55
56
  workspace: ws?.name || null,
57
+ // v0.3.0 P0-2:未完成检查点(供前端提示续跑)
58
+ taskState: loadTaskState(path.basename(file)),
56
59
  });
57
60
  } catch {
58
61
  json(res, 404, { error: '会话不存在' });
@@ -84,6 +87,38 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
84
87
  return json(res, 400, { error: '未知操作:rename|delete' });
85
88
  }
86
89
 
90
+ // v0.3.0 P0-3:会话收尾(前端「新对话/切换会话」时调用)——在会话边界提取项目记忆+日志,
91
+ // 而非逐轮提取,避免项目记忆文件在会话中途变化破坏前缀缓存(省钱核心)。
92
+ if (method === 'POST' && p === '/api/session-finalize') {
93
+ const body = await readBody(req, MAX_API_BODY);
94
+ const file = path.basename(String(body.file || ''));
95
+ if (!file) return json(res, 400, { error: '缺少 file 参数' });
96
+ try {
97
+ const loaded = loadSession(path.join(home, 'sessions', file));
98
+ if (loaded.messages.length) {
99
+ const wsDir = getSessionWorkspace(file) || state.workingDir;
100
+ const { finalizeSession } = await import('../../../memory.js');
101
+ const lastAsst = [...loaded.messages].reverse().find((/** @type {any} */ m) => m.role === 'assistant');
102
+ await finalizeSession({
103
+ cfg: deps.cfg,
104
+ provider: deps.provider,
105
+ model: titleModel(deps.cfg, state.modelName),
106
+ home,
107
+ workingDir: wsDir,
108
+ messages: loaded.messages,
109
+ turns: loaded.messages.filter((/** @type {any} */ m) => m.role === 'user').length,
110
+ lastText: lastAsst?.content || '',
111
+ });
112
+ }
113
+ // 清掉该会话的项目记忆快照:下次重新打开会用最新记忆重建系统提示
114
+ if (deps.sessionMemoryCache) deps.sessionMemoryCache.delete(file);
115
+ json(res, 200, { ok: true });
116
+ } catch (/** @type {any} */ e) {
117
+ json(res, 500, { error: String(e?.message || e) });
118
+ }
119
+ return true;
120
+ }
121
+
87
122
  if (method === 'GET' && p === '/api/draft') {
88
123
  // 质检 A5:按会话维度读取(无 file 参数用全局槽,兼容旧客户端)
89
124
  const key = String(url.searchParams.get('file') || '');
package/src/web/server.js CHANGED
@@ -29,6 +29,8 @@ import { MAX_CONCURRENT } from './constants.js';
29
29
  import { createAgent } from '../agent.js';
30
30
  import { createPermission } from '../permissions.js';
31
31
  import { buildSystemPrompt } from '../prompts.js';
32
+ import { loadProjectMemory, extractAndAppendProjectMemory } from '../memory.js';
33
+ import { saveTaskState, clearTaskState, loadTaskState, resumePrompt } from '../task-state.js';
32
34
  import { createWebIO } from './web-io.js';
33
35
  import { startMcpServers } from '../mcp.js';
34
36
  import {
@@ -231,6 +233,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
231
233
  return next;
232
234
  }
233
235
  const draftTexts = new Map(); // 质检 A5:草稿按会话维度存储(此前单槽多客户端互相覆盖)
236
+ const sessionMemoryCache = new Map(); // v0.3.0 P0-3:项目记忆「会话内快照」——同一会话系统提示恒定(保前缀缓存)
234
237
 
235
238
  // —— 服务端诊断日志(第二问无反应排查 + 长期运维):<mingdao-home>/logs/web-server.log ——
236
239
  // 记录每次对话的关键阶段与耗时;2MB 滚动。桌面版与 WebUI 共用同一日志。
@@ -390,12 +393,30 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
390
393
  if (!sessionWsDir) {
391
394
  setSessionWorkspace(sessionName, taskDir, currentWorkspace(workingDir)?.name || null);
392
395
  }
393
- const systemPrompt = buildSystemPrompt({ workingDir: taskDir, withJournal: body.withJournal === true });
396
+ // v0.3.0 P0-3:项目记忆会话内快照——本会话全程用同一份快照(前缀稳定),
397
+ // 每轮结束提取的新条目只写文件、不回灌当前会话。
398
+ let projectMemorySnapshot = sessionMemoryCache.get(sessionName);
399
+ if (projectMemorySnapshot === undefined) {
400
+ projectMemorySnapshot = loadProjectMemory(taskDir);
401
+ sessionMemoryCache.set(sessionName, projectMemorySnapshot);
402
+ if (sessionMemoryCache.size > 200) {
403
+ const oldest = sessionMemoryCache.keys().next().value;
404
+ sessionMemoryCache.delete(oldest);
405
+ }
406
+ }
407
+ const systemPrompt = buildSystemPrompt({ workingDir: taskDir, withJournal: body.withJournal === true, projectMemory: projectMemorySnapshot });
394
408
  let messages =
395
409
  session.messages?.length && session.messages[0]?.role === 'system'
396
410
  ? session.messages
397
411
  : [{ role: 'system', content: systemPrompt }, ...(session.messages || [])];
398
412
  messages[0] = { role: 'system', content: systemPrompt }; // 总是刷新 system(记忆/技能/AGENTS.md 最新)
413
+ // v0.3.0 P0-2:续跑——继续一个有未完成检查点的会话时,先注入进度摘要让模型从断点接着做
414
+ if (!isNew) {
415
+ const ts = loadTaskState(sessionName);
416
+ if (ts && (ts.status === 'cap' || ts.status === 'interrupted')) {
417
+ messages.push({ role: 'user', content: resumePrompt(ts) });
418
+ }
419
+ }
399
420
  messages.push({ role: 'user', content: built.content });
400
421
  // 落盘用文本版(图文数组只发给模型,会话文件保持字符串可渲染)
401
422
  await withSessionLock(session.file, () => appendMessages(session.file, [{ role: 'user', content: built.persistText }]));
@@ -483,6 +504,11 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
483
504
  await withSessionLock(session.file, () => appendMessages(session.file, messages.slice(persistedBefore)));
484
505
  io.printUsageLine({ modelName: runModel, usage: r.usage, durationMs: r.durationMs });
485
506
  recordUsage(r.perf?.usedModel || runModel, r.usage, /** @type {any} */ (r.perf));
507
+ // v0.3.0 P0-3:轮末提取项目记忆(有工具工作才提,fire-and-forget)。写文件供「未来会话」用,
508
+ // 当前会话用快照(上文已注入),故不会中途改系统提示、不破坏前缀缓存。
509
+ if (io.stats().toolCount > 0) {
510
+ extractAndAppendProjectMemory({ cfg, provider: providerNow, model: titleModel(cfg, runModel), messages, workingDir: taskDir }).catch(() => {});
511
+ }
486
512
  maybeAutoSync().catch(() => {});
487
513
  // 新会话自动标题(可配置关闭)
488
514
  srvlog('chat 标题生成前 ' + taskId);
@@ -498,6 +524,19 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
498
524
  }
499
525
  } catch {}
500
526
  }
527
+ // v0.3.0 P0-2:任务检查点——跑满步数(capHit)或中断(aborted)时落盘供续跑,正常完成清除
528
+ const finalSessionName = path.basename(session.file);
529
+ if (r.capHit || r.aborted) {
530
+ saveTaskState(finalSessionName, {
531
+ goal: built.persistText,
532
+ progress: r.text || '',
533
+ artifacts: io.stats().deliverables,
534
+ status: r.capHit ? 'cap' : 'interrupted',
535
+ updatedAt: new Date().toISOString(),
536
+ });
537
+ } else {
538
+ clearTaskState(finalSessionName);
539
+ }
501
540
  entry.status = r.aborted ? 'aborted' : 'done';
502
541
  entry.durationMs = Date.now() - entry.startedAt;
503
542
  srvlog('chat 发送 done ' + taskId + ' status=' + entry.status + ' 总耗时=' + entry.durationMs + 'ms');
@@ -544,7 +583,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
544
583
  providerCache, getProviderFor, tasks,
545
584
  MAX_CONCURRENT, pruneTasks, handleChat,
546
585
  mcpFacade, provider, validateRemoteUrl, isPrivateHost,
547
- authEnabled, tokenMatches, requestToken, trustedHost, INDEX_HTML, draftTexts, startupCwd,
586
+ authEnabled, tokenMatches, requestToken, trustedHost, INDEX_HTML, draftTexts, startupCwd, sessionMemoryCache,
548
587
  state: { get modelName() { return modelName; }, set modelName(v) { modelName = v; },
549
588
  get workingDir() { return workingDir; }, set workingDir(v) { workingDir = v; } },
550
589
  refs: { get inflight() { return inflight; }, set inflight(v) { inflight = v; } },