mingdao-harness 0.3.2 → 0.4.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,126 @@
1
+ # MingDao Harness 开发者指南(v0.4.0 契约化)
2
+
3
+ > 战略依据:[STRATEGY-NEXT.md](STRATEGY-NEXT.md)(垂直产品 × 开放内核)。
4
+ > 本指南面向**用 MingDao 做二次开发 / 定制自己智能体**的开发者。
5
+ > 稳定契约:`@stable` 导出在 minor 版本内保持向后兼容;`@experimental` 可能调整。
6
+
7
+ ## 零、三种使用方式
8
+
9
+ | 方式 | 适合 | 命令 |
10
+ | --- | --- | --- |
11
+ | 产品终端 | 开箱即用的 DeepSeek 省钱 Coding Agent | `npm i -g mingdao-harness && mingdao` |
12
+ | **Agent Preset** | 不改代码,声明式定制智能体(本指南重点) | `mingdao --preset <名>` |
13
+ | **库嵌入** | 把 Agent 嵌进自己的 Node 程序 | `npm i mingdao-harness` + `import { createAgent } from 'mingdao-harness'` |
14
+
15
+ 零依赖承诺:安装无 node_modules 树;公共 API 只用 Node ≥18.17 内置能力。
16
+
17
+ ## 一、Agent Preset:声明式定制智能体
18
+
19
+ ### 1.1 什么是预设
20
+
21
+ 一个 JSON 文件 = { 系统提示定制段, 工具白名单, 权限模式, 模型建议, 参数 }。
22
+ 放在三个位置(同名后者遮蔽前者):
23
+
24
+ 1. `<项目>/.mingdao/presets/<名>.json` — 项目级(随项目走)
25
+ 2. `~/.mingdao/presets/<名>.json` — 用户级(本机全局)
26
+ 3. `presets/`(随 npm 包分发)— 内置参考(已内置 `local-audit` 示例)
27
+
28
+ ### 1.2 格式
29
+
30
+ ```json
31
+ {
32
+ "name": "code-reviewer",
33
+ "label": "代码审查员",
34
+ "description": "只读审查并输出分级报告",
35
+ "systemPrompt": "你是代码审查员。只读审查,按严重度分级输出,每条带文件:行号证据。",
36
+ "tools": ["read", "ls", "glob", "grep", "skill", "git", "fetch", "todo"],
37
+ "permission": "auto",
38
+ "model": "deepseek-v4-flash",
39
+ "temperature": 0.3,
40
+ "maxOutputTokens": 4096,
41
+ "maxRounds": 4,
42
+ "contextBudget": 96000
43
+ }
44
+ ```
45
+
46
+ 字段全部可选(缺省保持当前配置)。`tools` 白名单外的工具对模型不可见、调用会被硬拦。
47
+ 未知字段会**校验报错**(防拼写错误静默失效)。
48
+ `model` 是**建议**:CLI 在未显式 `-m` 时采纳;WebUI 以用户当前选择的模型为准(预设不覆盖)。
49
+
50
+ ### 1.3 使用
51
+
52
+ - CLI:`mingdao --preset code-reviewer "审查 src/ 目录"`;交互模式 `mingdao --preset code-reviewer`。
53
+ - REPL:`/preset` 列出全部;`/preset code-reviewer` 会话内切换(工具白名单/权限/参数即时生效)。
54
+ - WebUI:输入框旁「预设…」下拉选择(随本次发送生效,服务端按会话应用)。
55
+ - 程序化:`import { loadPreset, presetConfigOverrides, presetSystemBlock } from 'mingdao-harness'`。
56
+
57
+ ## 二、第三方工具:registerTool / config.tools
58
+
59
+ ### 2.1 程序化注册(嵌入自己程序时)
60
+
61
+ ```js
62
+ import { registerTool, createAgent } from 'mingdao-harness';
63
+
64
+ registerTool({
65
+ name: 'weather',
66
+ description: '查询城市天气',
67
+ parameters: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] },
68
+ run: async (args, ctx) => ({ ok: true, output: `${args.city}:晴 24°C` }),
69
+ });
70
+ // 之后 createAgent 的模型就能调用 weather;执行走统一权限/审计/省钱链路。
71
+ ```
72
+
73
+ 约束:名字 `[A-Za-z0-9][A-Za-z0-9_-]{0,63}`、不得与内置 13 工具同名、不得重复注册;
74
+ `run` 抛异常会转成结构化错误回填(不中断会话)。
75
+
76
+ ### 2.2 声明式挂载(config.json,不改代码)
77
+
78
+ ```json
79
+ { "tools": [ { "name": "date-now", "description": "当前时间", "command": "date" } ] }
80
+ ```
81
+
82
+ `command` 经 `/bin/bash -lc` 执行;**参数以 `MINGDAO_TOOL_ARGS`(JSON)环境变量传入**——
83
+ 不做字符串拼接(防注入),由命令自行解析;执行受权限引擎门控(与 bash 同权重)。
84
+ 改 config.tools 需重启生效(与 MCP 预设一致)。
85
+
86
+ ## 三、库嵌入:最小示例
87
+
88
+ ```js
89
+ import { createProvider, createAgent, createPermission, createIO } from 'mingdao-harness';
90
+
91
+ const provider = await createProvider(cfg, 'deepseek-v4-flash'); // cfg: 同 config.json 结构
92
+ const io = createIO(); // 或自实现 print/ask 接口
93
+ const agent = createAgent({
94
+ provider,
95
+ permission: createPermission('ask', io),
96
+ io,
97
+ modelName: 'deepseek-v4-flash',
98
+ workingDir: process.cwd(),
99
+ cfg,
100
+ });
101
+ const res = await agent.runTurn([
102
+ { role: 'system', content: '你是代码助手。' },
103
+ { role: 'user', content: '帮我看看 package.json 的依赖' },
104
+ ]);
105
+ console.log(res.text, res.usage, res.perf);
106
+ ```
107
+
108
+ ## 四、公共 API 速查(@stable 面)
109
+
110
+ | 分组 | 导出 |
111
+ | --- | --- |
112
+ | Agent 内核 | `createAgent` · `createPermission` · `createIO` |
113
+ | Provider/模型 | `createProvider` · `resolveProviderConfig` · `modelPreset` · `resolveModelCaps` · `safeBudget` · `isLocalBaseUrl` |
114
+ | 工具 | `registerTool` · `listRegisteredTools` · `mountConfigTools` · `buildToolSchemas` · `dispatch` |
115
+ | Agent Preset | `listPresets` · `loadPreset` · `validatePreset` · `presetConfigOverrides` · `presetSystemBlock` |
116
+ | 上下文 | `trimMessages` · `approxTokens` · `clampText` · `compactConversation` |
117
+ | 配置/凭证 | `loadConfig` · `saveConfig` · `mingdaoHome` · `setStoredKey` · `maskKey` |
118
+ | 计价/计量 | `estimateCost` · `isPeakHour` · `countTokens` · `makeTokenCounter` |
119
+
120
+ @experimental(接口可能调整):update/audit/skill-lib/skills/mcp/session 组。
121
+
122
+ ## 五、约定
123
+
124
+ - 预设/工具的扩展点沿用既有安全链路(权限引擎、审计、脱敏、沙箱),**不提供绕过入口**。
125
+ - 公共 API 变更必须过测试门禁(smoke 含「公共 API 导出面」断言)+ 发布前自检。
126
+ - 自定义 Provider 模块(非 OpenAI 兼容协议)见 [PROVIDERS.md](PROVIDERS.md)。
@@ -0,0 +1,38 @@
1
+ # v0.4.0 规划:阶段 A「契约化」——把 MingDao 变成可二次开发的开放内核
2
+
3
+ > 依据:docs/STRATEGY-NEXT.md(已确认:垂直产品 × 开放内核;本地/私有化第一公民与 DeepSeek 省钱并列双主攻;v0.4.0 先做契约化)。
4
+ > 原则:不新增重架构、不动零依赖根基;把**已存在**的扩展点从「能用」升级为「有契约、有文档、有示例、有测试」的一等公民。
5
+ > 节奏:小版本分批,每批全绿 + 发布前自检;实现后不发布,等用户在 3820 验收确认。
6
+
7
+ ## 一、现状盘点(扩展点已存在,缺契约)
8
+
9
+ - 公共 API 面:`src/index.js` 已导出 `createAgent` / `createProvider` / 工具 / 上下文 / 权限 / 模型 / 会话 / 计价 / tokenizer 等(42 行导出,无 semver 承诺、无开发者文档)。
10
+ - 扩展点:自定义 Provider 模块(`<home>/providers/*.mjs`)、hooks(PreToolUse/PostToolUse)、用户级/项目级技能、MCP 客户端、`customModels`——全部可用,但契约散落、无统一示例。
11
+
12
+ ## 二、本版目标
13
+
14
+ 1. **公共 API 冻结与文档**:`docs/DEVELOPER.md`(API 参考 + 最小示例集),`src/index.js` 导出面逐项标注稳定性(stable / experimental)。
15
+ 2. **Agent Preset(智能体预设)**:声明式文件 = { 系统提示, 工具集白名单, 权限策略, 记忆策略, 模型建议 },放 `<home>/presets/` 或 `<项目>/.mingdao/presets/`;CLI(`mingdao --preset <名>`)与 WebUI 一键选用。
16
+ 3. **第三方工具注册**:`registerTool({ name, schema, run })` 程序化注册 + `config.tools` 声明式挂载,工具进审计/权限/省钱 schema 链路。
17
+ 4. **契约测试**:预设加载、工具注册、公共 API 面的自动化断言入 smoke。
18
+
19
+ ## 三、任务分解(实现顺序)
20
+
21
+ 1. `src/presets.js`(新):预设目录发现/加载/校验(JSON,schema 校验 + 错误可读);内置一个示例预设(如「本地模型审计」:system 提示 + 只读工具集 + auto 权限 + 语义记忆)。
22
+ 2. `src/index.js`:导出 `loadPresets`/`resolvePreset`/`buildPresetConfig`;导出稳定性注释分 stable/experimental 两组。
23
+ 3. `src/tools/index.js`:`registerTool` + `listRegisteredTools`;`config.tools` 声明式工具挂载(name/schema/command 三态:内置函数引用 / bash 命令包装 / 自定义 Provider 函数);注入 dispatch + 权限引擎 + 审计。
24
+ 4. CLI:`mingdao --preset <名>` 参数 + REPL `/preset`;WebUI 模型选择器旁加预设下拉(复用 /api/models-config 模式新增 /api/presets)。
25
+ 5. `docs/DEVELOPER.md`:API 参考表(stable/experimental 标注)+ 5 个最小示例(自定义工具 / 自定义 Provider / 自定义权限 / 自定义记忆 / embed 进自己的程序)。
26
+ 6. 测试:smoke 新增「presets:发现/校验/应用」「第三方工具:注册/调度/审计归属/权限拦截」「公共 API:导出面断言」;api-contracts 新增 /api/presets 契约。
27
+ 7. 全绿(smoke/e2e/bench/strict/typecheck)+ 自检 → 3820 验收 → 等确认后发布。
28
+
29
+ ## 四、验收标准
30
+
31
+ - `npm i mingdao-harness` 后按 DEVELOPER.md 三步跑通一个自定义智能体(预设 + 自定义工具)。
32
+ - 预设文件语义与 DSH profile 对齐但零依赖、JSON 声明式。
33
+ - 全绿测试门禁 + strict 0/0 + 发布前自检;用户在 3820 验收后再发布。
34
+
35
+ ## 五、非目标(顺延后续版本)
36
+
37
+ - 预设市场/registry 分发(阶段 B)、本地模型一键接入预设(阶段 B)、企业私有化包(阶段 C)。
38
+ - 不引入 Cordis/插件内核;不做云平台/账号体系。
@@ -0,0 +1,175 @@
1
+ # MingDao Harness 战略复盘与长期路线(2026-09)
2
+
3
+ > 成文:2026-09-05(v0.3.2 发布后)
4
+ > 性质:战略策划文件,非迭代任务。路线图各阶段**经确认后**才逐步落地;
5
+ > 每期迭代前先对照本文件确认方向不漂移,每期保持「可独立发布 + 全绿测试门禁 + 发布前自检」纪律。
6
+ > 数据口径:本地仓库 git 历史 + 官网线上状态(2026-09-05 实查)。
7
+
8
+ ---
9
+
10
+ ## 一、回望:这 18 天我们走了什么路
11
+
12
+ **时间轴**:v0.1.0(2026-08-18)→ v0.3.2(2026-09-05),18 天 83 个版本,平均 4.6 版/天。
13
+
14
+ **沉淀下来的资产(按可验证程度排序):**
15
+
16
+ | 资产 | 事实 | 性质 |
17
+ | --- | --- | --- |
18
+ | 零依赖内核 | `src/` 73 文件 ~15K 行纯 Node 内置 API,无运行时 npm 依赖;`npm i -g` 秒装 | 分发/审计成本极低,同赛道稀缺 |
19
+ | DeepSeek 省钱纵深 | 官方词表 BPE tokenizer(黄金值精确命中)· 前缀缓存两态冻结 · 峰谷双窗口计价 · Batch 半价 · 滞回压缩 · bench-savings 省 63% 基线 | 对 DeepSeek 计费细节的闭环掌握,通用框架不会做 |
20
+ | 长程任务能力 | 任务续跑 + 检查点(v0.3.0)· 自动续跑(v0.3.1)· 项目级记忆 · 子代理 | 从「24 步必断」到「长任务连续执行」 |
21
+ | 本地模型自适应 | 上下文窗口感知预算 + 分层超时 + 边缘强制压缩 + 工具输出自适应截断(v0.3.2) | 大厂押注云端模型,这是空档 |
22
+ | 工程可信度 | strict 棘轮 0/0 · 覆盖率 60% · bench 208 断言 · 三平台 CI · 每次发布自检 | 信任是独立开发者最稀缺的护城河 |
23
+ | 中国分发链路 | 官网直连下载(国内极速)+ gitee/gitcode 镜像 + 论坛 + 桌面自动更新 | 大厂不覆盖的「国内可用性」细节 |
24
+ | 双界面 | TUI + WebUI + Electron 桌面(四平台签名包) | 使用面完整 |
25
+
26
+ **结论**:我们走的其实是「**垂直产品路线**」——围绕 DeepSeek 省钱 + Coding 场景做深,每一版都在强化这个垂直切面。这条路是对的,但它有天花板:产品只能覆盖我们自己能想得到的场景,生态的想象空间被我们自己框死。
27
+
28
+ ---
29
+
30
+ ## 二、抬头看路:2026-09 的格局(外部实查)
31
+
32
+ | 玩家 | 模式 | 事实(来源) |
33
+ | --- | --- | --- |
34
+ | **DSH(DeepSeek Harness)** | 官方智能体框架 | 2026-08-13 开源公测,MIT 协议,基于 Cordis「一切皆插件」,对标 Claude Cowork/Codex;插件生态同步开放;`npx @deepseek-ai/dsh web`;四种模式(标准/PTC/极简/创造)。**Model+Harness=Agent** 是官方公式。([IT之家](https://www.ithome.com/0/989/446.htm)) |
35
+ | **WorkBuddy(腾讯)** | 通用 Agent 平台 | 2026-03 上线,2026-09-02 开放平台:首批 100+ 伙伴,打通硬件/行业应用/开发者三层生态(Skill/Expert/Connector,MCP+CLI 双方案);目标「Agent 时代的操作系统」。([IT168](https://cloud.it168.com/a2026/0902/6948/000006948500.shtml)) |
36
+ | **Claude Cowork** | 企业级 Agent+插件 | Anthropic 官方,企业定制插件,团队级工作流。([claude.com](https://claude.com/ko-kr/blog/cowork-plugins-across-enterprise)) |
37
+ | **Claude Code / OpenHands** | 通用 Coding Agent | Claude Code $17/月托管;OpenHands BYOK 开源。([theaiagentindex](https://theaiagentindex.com/compare/openhands-vs-claude-code)) |
38
+ | **元脑 Z3 等** | 本地智能体工作站 | 浪潮等硬件厂商做「中小企业本地智能体」一体机。([InfoQ](https://www.infoq.cn/article/yi0mr2CkzQZgljL00kgA)) |
39
+
40
+ **格局读法**:
41
+
42
+ 1. **框架生态位已被官方拿走**。DSH 背靠 DeepSeek 官方 + MIT 开源 + Cordis 插件生态,任何第三方去拼「谁的框架更框架」都是正面撞车。
43
+ 2. **平台生态位被巨头拿走**。WorkBuddy 的开放平台是腾讯体量的资源战(硬件联名 + 行业伙伴 + 开发者三层),不可复制。
44
+ 3. **本地/私有化开始有玩家**。元脑 Z3 证明「中小企业本地智能体」是真需求,但它卖的是硬件一体机,软件层依然是空档。
45
+ 4. **独立开发者仍有机会的缝隙**:模型中立、零依赖、私有化、资源受限设备、中国本地分发——这些都是巨头和官方框架不优先的地方。
46
+
47
+ ---
48
+
49
+ ## 三、路线抉择:DSH 框架模式 or WorkBuddy 通用 Agent 模式?
50
+
51
+ ### 3.1 两条路的本质与代价
52
+
53
+ **纯 DSH 框架模式**(开放插件内核,开发者二次开发):
54
+ - 代价:需要 Cordis 级插件内核(与零依赖冲突)、插件生态冷启动(框架没人用就死)、与官方 DSH 正面撞车。
55
+ - 我们没有 DeepSeek 官方的品牌和流量,无法复制。
56
+
57
+ **纯 WorkBuddy 模式**(通用 Agent + 开放平台):
58
+ - 代价:需要腾讯级的硬件/行业资源、平台治理、商业化能力;通用办公场景与 WorkBuddy/讯飞等正面交锋。
59
+ - 我们既没有资源,也不应该丢下已经做深的 Coding + DeepSeek 垂直面。
60
+
61
+ ### 3.2 结论:不做「二选一」,做「垂直产品 × 开放内核」一体两面
62
+
63
+ **MingDao 的路 = 保持垂直产品的深度(DeepSeek 省钱 Coding Agent),同时把内核开放出来(让第三方开发者定制自己的智能体)。**
64
+
65
+ 理由(各自独立成立):
66
+
67
+ 1. **产品侧**:我们已经用 83 个版本证明了「DeepSeek 重度用户 + 省钱 + 省心」这条垂直产品线成立(官网已上线、桌面分发、真实用户验收)。丢垂直面去追平台 = 自废武功。
68
+ 2. **内核侧**:`src/index.js` 已经导出了完整公共 API 面(`createAgent` / `createProvider` / 工具 / 上下文 / 权限 / 会话 / 计价 / tokenizer,42 行导出),扩展点(自定义 Provider 模块、hooks、用户级技能、MCP)也已存在——**我们其实已经是「半个框架」了,缺的是把扩展点从「能用」变成「有契约、有文档、有示例、有分发」的一等公民**。
69
+ 3. **差异化恰好落在巨头缝隙**:DSH 要 pnpm install + build + Cordis 概念负担;WorkBuddy 是云端平台。我们给开发者的是「**零依赖、一条 npm 命令、本地/私有化第一公民、模型中立**」的定制基座——这是官方框架和巨头平台都不给的位置。
70
+
71
+ ### 3.3 是否开放接口?——开放,但要按我们的方式开放
72
+
73
+ - **开放什么**:智能体预设(Agent Preset:系统提示 + 工具集 + 权限策略 + 记忆策略打包成一个可安装单元)、程序化工具注册(第三方工具进注册表)、hooks、Provider、技能——这些点已存在,v0.4.x 起把它们正式化为**有契约的公共 API**(semver 保证,example 仓库,文档站)。
74
+ - **不开放什么(至少现在)**:不引入 Cordis/重插件内核(保住零依赖)、不做云平台账号体系、不做插件商店抽成商业化。
75
+ - **开放的方式**:`npm i mingdao-harness` 当库用 + `<preset>.json/mjs` 声明式组合,学习成本以「分钟」计,而不是 DSH 的「先懂 Cordis」。
76
+
77
+ ---
78
+
79
+ ## 四、优势与护城河:诚实盘点
80
+
81
+ **真护城河(别人短期抄不走):**
82
+
83
+ | # | 护城河 | 为什么难抄 | 现状 |
84
+ | --- | --- | --- | --- |
85
+ | 1 | **DeepSeek 省钱纵深链** | 官方词表黄金值、峰谷双窗口、缓存 1/30、Batch 半价的组合闭环 + bench 棘轮锁定,需要长期浸在 DeepSeek 计费细节里 | 已建成,持续加深 |
86
+ | 2 | **零依赖内核** | 主流框架全是依赖树;推到「一条命令装完」需要从头设计约束 | 已建成 |
87
+ | 3 | **本地模型自适应** | 大厂产品绑定自家云端模型;「资源受限本地模型不中断」是空档,我们已有 v0.3.2 的完整机制 | 刚建成,是未来主攻方向 |
88
+ | 4 | **工程可信度** | strict 0/0 + 覆盖率 + bench + 发布自检的纪律是组织习惯,不是功能 | 已建成 |
89
+
90
+ **伪护城河(会褪色,要警惕):**
91
+ - 功能广度(工具数/技能数)——DSH 插件生态几个月内就会超过我们。
92
+ - 「先发」本身——DSH 公测才一个月,窗口极短。
93
+ - 省钱幅度数字——会被大厂「抄作业」,只有持续领先才有意义。
94
+
95
+ **护城河的正确用法**:不靠单一护城河,靠「#1+#2+#3+#4 的组合 + 持续领先节奏」——单点都可被追平,组合与速度难被复制。
96
+
97
+ ---
98
+
99
+ ## 五、错位竞争:怎么避开与头部大厂正面交锋
100
+
101
+ **三条避战原则:**
102
+
103
+ 1. **不拼框架拼契约**。DSH 的框架深度我们拼不过,但「零依赖 + 分钟级上手」的定制体验是 Cordis 给不了的。开发者要的不是更多抽象,是更快落地。
104
+ 2. **不拼平台拼私有**。WorkBuddy 拼云上生态,我们拼「你的模型、你的机器、你的数据不出门」——政企私有化、科研本地算力、个人 MacBook 本地模型。
105
+ 3. **不拼通用拼垂直**。通用办公场景让 WorkBuddy 和讯飞去卷;我们把「DeepSeek 省钱」和「本地模型自适应」两个垂直面做到他们不想做、做了不划算的深度。
106
+
107
+ **对标表(MingDao 与谁不打、与谁打):**
108
+
109
+ | 维度 | 巨头在做什么 | MingDao 的打法 |
110
+ | --- | --- | --- |
111
+ | 模型绑定 | 各自绑自家云端模型 | **模型中立**:DeepSeek 深度优化 + 任意 OpenAI 兼容 + 本地模型一等公民 |
112
+ | 部署 | 云平台 / 托管订阅 | **私有化/本机第一**:npm 即装、桌面即装、数据不出门 |
113
+ | 生态 | 插件市场、开放平台 | **轻量契约**:预设/工具/技能契约 + 官方示例库,不追商店规模 |
114
+ | 分发 | 官方渠道 | **中国本地分发**:官网直连、gitee/gitcode、论坛(已建成,继续维护) |
115
+
116
+ ---
117
+
118
+ ## 六、独特的 MingDao:一句话定位
119
+
120
+ > **「模型中立、零依赖、会省钱、私有化第一公民的 Agent 内核与终端」——
121
+ > 给 DeepSeek 重度用户一个最省钱的 Coding Agent,给开发者一个分钟级上手的定制基座,给本地/私有化场景一个不被云端绑架的选择。**
122
+
123
+ 对照 STRATEGY-0.3 的旧定位(「DeepSeek 生态里最好用、最能省、最省心的 Coding Agent 终端」):**旧定位是产品定位,新定位是产品+内核双定位**。产品线继续按旧定位做深,新增的内核线把天花板打开。
124
+
125
+ ---
126
+
127
+ ## 七、路线图(确认后逐步落地,每期可独立发布)
128
+
129
+ ### 阶段 A「契约化」(v0.4.x,约 2-3 版)
130
+ 把已有扩展点正式化为公共 API,**不新增重架构**:
131
+ 1. 公共 API 冻结与文档:`src/index.js` 导出面逐项 semver 化 + JSDoc 契约 + `docs/DEVELOPER.md`(API 参考 + 5 个最小示例:自定义工具、自定义 Provider、自定义权限、自定义记忆、embed 进自己的程序)。
132
+ 2. **Agent Preset 格式**:一个声明式文件 = { 系统提示, 工具集白名单, 权限策略, 记忆策略, 模型建议 },放 `<home>/presets/` 或 `<项目>/.mingdao/presets/`,CLI/WebUI 一键选用(对齐 DSH 的 profile 概念,但零依赖、JSON 声明式)。
133
+ 3. 第三方工具注册 API:`registerTool({ name, schema, run })` 程序化注册 + `config.tools` 声明式挂载,进审计/权限/省钱链路。
134
+ - 验收:全绿 + 新增 API 契约测试 + example 仓库跑通。
135
+
136
+ ### 阶段 B「生态萌芽」(v0.5.x)
137
+ 1. 官网开发者区:文档站 + 示例库 + 预设目录(官网分发,与桌面安装包同一基建)。
138
+ 2. 预设市场雏形:预设/skill 的 registry 已有 sha256 防篡改基建(技能库已用),复用到预设分发。
139
+ 3. 本地模型生态打通:ollama / llama.cpp / vLLM 一键接入预设(复用 v0.3.2 的窗口自适应),把「本地模型」从「用户自己配」变成「选一个预设即可」。
140
+ - 验收:开发者按文档 10 分钟跑通自定义智能体;本地预设端到端可用。
141
+
142
+ ### 阶段 C「纵深与护城河加固」(v0.6.x)
143
+ 1. 省钱纵深继续领先:Batch 自动化、路由升级、更精细分账(保持 bench 棘轮只升不降)。
144
+ 2. 企业私有化包:内网部署包(零外网依赖)+ 国产 GPU/本地推理适配。
145
+ 3. 生态反哺产品:社区预设/工具里验证过的能力回并进内置产品。
146
+
147
+ ### 明确的「不做」清单
148
+ - 不自研模型、不做云端 Agent 平台、不做账号/订阅体系。
149
+ - 不引入 Cordis/任何重依赖插件内核(零依赖是根基)。
150
+ - 不做硬件生态、不做应用商店抽成。
151
+ - 不与 DSH/WorkBuddy 在「框架完备度/平台规模」上对标竞争。
152
+
153
+ ---
154
+
155
+ ## 八、节奏与纪律(防止战略漂移)
156
+
157
+ 1. **每期对照本文件**:迭代前先回答「这一期强化的是 #1-#4 里哪条护城河?有没有撞进『不做』清单?」
158
+ 2. **产品/内核双线比例**:产品线(省钱/省心)与内核线(契约/生态)按 2:1 分配精力,产品线永远优先(产品是内核的活广告)。
159
+ 3. **发布纪律不变**:全绿测试门禁 + 发布前自检 + 用户验收后再发布。
160
+ 4. **战略复盘节奏**:每 10 个版本重读一次本文件,用事实(下载/issue/生态反馈)校准路线。
161
+
162
+ ---
163
+
164
+ ## 九、待用户决策的关键点
165
+
166
+ > **✅ 已确认(2026-09-05)**:
167
+ > 1. 方向:**认同「垂直产品 × 开放内核」路线**——不做纯 DSH 框架、不做纯 WorkBuddy 平台。
168
+ > 2. 主攻:**「本地/私有化第一公民」与「DeepSeek 省钱」并列双主攻**。
169
+ > 3. 节奏:**v0.4.0 先做阶段 A「契约化」**(公共 API 冻结 + Agent Preset + 第三方工具注册 + DEVELOPER 文档示例)。
170
+ >
171
+ > 落地计划见 [PLAN-v0.4.0.md](PLAN-v0.4.0.md)。
172
+
173
+ 1. **方向确认**:是否认同「垂直产品 × 开放内核」路线(不纯框架、不纯平台)?——✅ 认同
174
+ 2. **开放节奏**:阶段 A 的「契约化」是否作为 v0.4.0 的第一优先级?——✅ 是
175
+ 3. **本地模型战略地位**:是否把「本地/私有化第一公民」提升为与「DeepSeek 省钱」并列的主攻方向?——✅ 是,双主攻
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  "files": [
15
15
  "src/",
16
16
  "skills/",
17
+ "presets/",
17
18
  "assets/",
18
19
  "docs/",
19
20
  "install.sh",
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "local-audit",
3
+ "label": "本地模型审计",
4
+ "description": "面向本地/资源受限模型(如 131k 窗口 q8 量化)的只读审计预设:只读工具集 + auto 权限 + 保守参数,配合 v0.3.2 本地自适应长任务不中断",
5
+ "systemPrompt": "你是一名代码审计员。任务是对给定代码库做只读审计:先 ls/glob 摸清结构,再 read/grep 逐文件审查,可用 git 查看历史与 diff、fetch 抓取相关文档。输出审计报告:发现的缺陷按严重度分级,每条给出文件:行号证据;不做任何修改。若上下文紧张,先审查最关键的部分并明示未覆盖区域。",
6
+ "tools": ["read", "ls", "glob", "grep", "skill", "git", "fetch", "todo"],
7
+ "permission": "auto",
8
+ "maxRounds": 4,
9
+ "maxOutputTokens": 4096
10
+ }
package/src/agent.js CHANGED
@@ -67,8 +67,18 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
67
67
  const hasWriteIntent = (/** @type {any} */ text) => WRITE_INTENT_RE.test(String(text || ''));
68
68
  // A1(前缀稳定):剥描述集合按「回合冻结快照」——回合内恒定(至多两态:只读档/全量档),
69
69
  // 新使用的工具只在下一回合才进入剥描述集合;回合边界本身就有新 user 消息,schema 变化免费。
70
+ // v0.4.0 Agent Preset:cfg.presetTools 白名单恒生效(在只读档过滤之后收紧——预设只减不增)。
71
+ const presetToolSet = Array.isArray(cfg.presetTools) ? new Set(cfg.presetTools.map(String)) : null;
72
+ const activePresetName = String(cfg.presetName || ''); // 白名单拦截提示用
70
73
  const toolsFor = (/** @type {boolean} */ readOnlyPhase, /** @type {Set<string>} */ strippedSet) => {
71
- const schemas = buildToolSchemas(strippedSet, mcpSchemas());
74
+ let schemas = buildToolSchemas(strippedSet, mcpSchemas());
75
+ if (presetToolSet) {
76
+ schemas = schemas.filter((/** @type {any} */ t) => {
77
+ const n = t?.function?.name;
78
+ if (!n) return true;
79
+ return presetToolSet.has(n) || (n.startsWith('mcp__') && presetToolSet.has(n.slice(5)));
80
+ });
81
+ }
72
82
  if (!readOnlyPhase) return schemas;
73
83
  return schemas.filter((/** @type {any} */ t) => {
74
84
  const n = t?.function?.name;
@@ -467,6 +477,13 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
467
477
  }
468
478
 
469
479
  const isMcp = name.startsWith('mcp__');
480
+ // v0.4.0 Agent Preset:白名单强制(模型可能调用白名单外工具——schema 已不发,此处兜底硬拦)
481
+ if (presetToolSet && !presetToolSet.has(isMcp ? name.slice(5) : name)) {
482
+ io.renderToolDenied(name, args, `不在预设工具白名单内(${activePresetName || 'preset'})`);
483
+ if (auditOn) auditEntry({ denied: true, reason: '预设工具白名单拦截' });
484
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: `工具 ${name} 不在当前预设的工具白名单内,已拒绝执行。` });
485
+ return null;
486
+ }
470
487
  let allowed = false;
471
488
  if (isMcp && mcp?.isReadonly(name)) {
472
489
  allowed = true; // MCP 工具的只读标注自动放行
package/src/cli.js CHANGED
@@ -79,6 +79,7 @@ const HELP_LINES = [
79
79
  [' mingdao --journal 新会话带上最近会话日志(默认不注入,新会话全新开始)', null],
80
80
  [' mingdao --resume 从会话列表选择恢复', null],
81
81
  [' mingdao --model <模型名> 指定模型,例如 deepseek-v4-pro', null],
82
+ [' mingdao --preset <名> 应用智能体预设(工具白名单/权限/参数,v0.4.0 契约化)', null],
82
83
  [' mingdao init 初始化配置向导', null],
83
84
  [' mingdao update [--check] 一键自更新(git 安装形态;--check 只对比版本)', null],
84
85
  [' mingdao rollback 回滚到上次 update 之前的提交', null],
@@ -125,6 +126,8 @@ function parseArgs(/** @type {any} */ argv) {
125
126
  else if (a === '-v' || a === '--version') opts.version = true;
126
127
  else if (a === '-c' || a === '--continue') opts.continueSession = true;
127
128
  else if (a === '--journal') opts.journal = true;
129
+ else if (a === '-p' || a === '--preset') opts.preset = argv[++i];
130
+ else if (a.startsWith('--preset=')) opts.preset = a.slice(9);
128
131
  else if (a === '-r' || a === '--resume') opts.resume = true;
129
132
  else if (a === '--init' || a === 'init') opts.init = true;
130
133
  else if (a === '-m' || a === '--model') opts.model = argv[++i];
@@ -350,6 +353,29 @@ async function main() {
350
353
  // 模型回退链(向导允许跳过模型选择 → cfg.model 可缺省):参数 > config > 该服务商首个预设模型 > flash
351
354
  let modelName = opts.model || cfg.model || /** @type {any} */ (PROVIDERS)[cfg.provider]?.models?.[0] || 'deepseek-v4-flash';
352
355
  const io = createIO();
356
+ const workingDir = process.cwd();
357
+
358
+ // v0.4.0 Agent Preset:--preset <名> 应用声明式预设(工具白名单/权限/模型/参数 + 系统提示定制段)。
359
+ // 预设覆盖优先级:CLI 显式参数 > 预设 > config.json。
360
+ // 会话级 overlay:不改写 cfg(否则 REPL /model、/think 的 saveConfig 会把预设字段持久化进 config.json)。
361
+ let activePreset = /** @type {any} */ (null);
362
+ let presetBlock = '';
363
+ let presetOverlay = /** @type {Record<string, any>} */ ({});
364
+ if (opts.preset) {
365
+ const { loadPreset, presetConfigOverrides, presetSystemBlock, listPresets } = await import('./presets.js');
366
+ activePreset = loadPreset(workingDir, opts.preset);
367
+ if (!activePreset) {
368
+ const names = listPresets(workingDir).map((/** @type {any} */ p) => p.name).join(', ') || '(无可用预设)';
369
+ io.print(style(`⚠ 预设 "${opts.preset}" 不存在。可用:${names}`, C.yellow));
370
+ } else {
371
+ presetOverlay = { ...presetConfigOverrides(activePreset), presetName: activePreset.name };
372
+ if (!opts.model && presetOverlay.model) modelName = presetOverlay.model;
373
+ presetBlock = presetSystemBlock(activePreset);
374
+ io.print(style(`▣ 已应用智能体预设:${activePreset.name}${activePreset.label ? '(' + activePreset.label + ')' : ''}`, C.cyan));
375
+ }
376
+ }
377
+ // agent 使用的配置 = cfg + 预设 overlay(presetTools/permission/参数按预设生效,cfg 本体保持干净)
378
+ const agentCfg = Object.keys(presetOverlay).length ? { ...cfg, ...presetOverlay } : cfg;
353
379
 
354
380
  const pc0 = resolveProviderConfig(cfg, modelName);
355
381
  if (!pc0.apiKey) {
@@ -367,8 +393,13 @@ async function main() {
367
393
  }
368
394
 
369
395
  let provider = await createProvider(cfg, modelName);
370
- const permission = createPermission(cfg.permission ?? 'ask', io);
371
- const workingDir = process.cwd();
396
+ const permission = createPermission(agentCfg.permission ?? 'ask', io);
397
+ // v0.4.0 契约化:挂载 config.tools 声明式第三方工具(幂等,重启生效)
398
+ {
399
+ const { mountConfigTools } = await import('./tools/index.js');
400
+ const mounted = mountConfigTools(cfg);
401
+ if (mounted.length) io.print(style(`🔧 已挂载声明式工具(config.tools):${mounted.join(', ')}`, C.dim));
402
+ }
372
403
  // 会话级 undo 备份仓:模型切换、子代理均共享,撤销记录不丢失
373
404
  const sessionUndoStore = { backups: new Map() };
374
405
  // MCP 服务器:后台启动(不阻塞交互),就绪后工具自动出现在后续轮次
@@ -410,7 +441,7 @@ async function main() {
410
441
  io,
411
442
  modelName,
412
443
  workingDir,
413
- cfg,
444
+ cfg: agentCfg,
414
445
  undoStore: sessionUndoStore,
415
446
  mcp: mcpFacade,
416
447
  sessionRef,
@@ -440,7 +471,7 @@ async function main() {
440
471
  const turnIo = jsonMode ? createIO({ quiet: true }) : io;
441
472
  const session = createSession(home);
442
473
  const messages = [
443
- { role: 'system', content: buildSystemPrompt({ modelName, workingDir, withJournal }) },
474
+ { role: 'system', content: buildSystemPrompt({ modelName, workingDir, withJournal, presetBlock }) },
444
475
  { role: 'user', content: question },
445
476
  ];
446
477
  let oneShotPersisted = messages.length;
@@ -451,7 +482,7 @@ async function main() {
451
482
  io: turnIo,
452
483
  modelName,
453
484
  workingDir,
454
- cfg,
485
+ cfg: agentCfg,
455
486
  undoStore: sessionUndoStore,
456
487
  mcp: mcpFacade,
457
488
  sessionRef: oneShotRef,
@@ -528,7 +559,7 @@ async function main() {
528
559
 
529
560
  // —— 交互式 TUI(Phase C C2:已抽取至 commands/repl.js) ——
530
561
  const { runRepl } = await import('./commands/repl.js');
531
- await runRepl({ io, cfg, home, pc0, opts, modelName, provider, permission, workingDir, sessionUndoStore, mcpFacade, mcpManager, sessionRef, agent, preset, withJournal, tuiState });
562
+ await runRepl({ io, cfg, agentCfg, home, pc0, opts, modelName, provider, permission, workingDir, sessionUndoStore, mcpFacade, mcpManager, sessionRef, agent, preset, withJournal, presetBlock, tuiState });
532
563
  return;
533
564
 
534
565
  }
@@ -98,6 +98,7 @@ const HELP_LINES = [
98
98
  ['会话内命令', C.bold + C.yellow],
99
99
  [' /help 显示帮助 /clear 清空上下文', null],
100
100
  [' /model <名> 切换模型 /mode pro/flash 快捷切换', null],
101
+ [' /preset 列出/切换智能体预设(v0.4.0 契约化)', null],
101
102
  [' /compact 压缩上下文 /plan 计划模式(先计划后执行)', null],
102
103
  [' /init 生成 AGENTS.md /memory add <内容> 追加用户记忆', null],
103
104
  [' /skills 列出技能 /status 会话状态 · /cost 累计费用', null],
@@ -140,6 +141,10 @@ async function generatePlan(provider, modelName, task) {
140
141
  */
141
142
  export async function runRepl(ctx) {
142
143
  const { io, cfg, home, pc0, opts, permission, workingDir, sessionUndoStore, mcpFacade, sessionRef, preset, withJournal, tuiState } = ctx;
144
+ let presetBlock = ctx.presetBlock || ''; // v0.4.0 Agent Preset 系统提示定制段(/preset 可切换)
145
+ let permissionNow = permission; // /preset 可换权限模式
146
+ // v0.4.0 Agent Preset:agent 用 agentCfg(预设 overlay 会话级生效);cfg 保持干净供 saveConfig 持久化
147
+ let agentCfg = ctx.agentCfg || cfg;
143
148
  let mcpManager = ctx.mcpManager;
144
149
  let modelName = ctx.modelName;
145
150
  let provider = ctx.provider;
@@ -216,7 +221,7 @@ export async function runRepl(ctx) {
216
221
  sessionRef.name = path.basename(session.file);
217
222
  io.print(style(`会话 ${path.basename(session.file)}`, C.dim));
218
223
 
219
- const systemPrompt = buildSystemPrompt({ modelName, workingDir, withJournal });
224
+ const systemPrompt = buildSystemPrompt({ modelName, workingDir, withJournal, presetBlock });
220
225
  // 恢复会话时刷新 system prompt(用户记忆 / AGENTS.md / 技能清单 / 时间戳以当前为准),
221
226
  // 旧 system 消息保留在会话文件中,不影响追加历史。
222
227
  const loadedMsgs = session.messages || [];
@@ -258,11 +263,11 @@ export async function runRepl(ctx) {
258
263
  }
259
264
  agent = createAgent({
260
265
  provider,
261
- permission,
266
+ permission: permissionNow,
262
267
  io,
263
268
  modelName,
264
269
  workingDir,
265
- cfg,
270
+ cfg: agentCfg, // v0.4.0:预设 overlay 恒生效(/model 后白名单不丢)
266
271
  undoStore: sessionUndoStore,
267
272
  mcp: mcpFacade,
268
273
  sessionRef,
@@ -271,7 +276,7 @@ export async function runRepl(ctx) {
271
276
  tuiState.persisted = msgs.length;
272
277
  },
273
278
  });
274
- messages[0] = { role: 'system', content: buildSystemPrompt({ workingDir, withJournal }) };
279
+ messages[0] = { role: 'system', content: buildSystemPrompt({ workingDir, withJournal, presetBlock }) };
275
280
  if (!silent) {
276
281
  const p2 = modelPreset(modelName);
277
282
  io.print(style(`✓ 已切换到 ${C.bold}${modelName}${C.reset}${p2 ? `(${p2.label})` : ''}`, C.green));
@@ -309,6 +314,38 @@ export async function runRepl(ctx) {
309
314
  } catch {}
310
315
  tuiState.persisted = messages.length;
311
316
  io.print('已清空上下文(会话文件已同步重置)。');
317
+ } else if (cmd === '/preset') {
318
+ // v0.4.0 Agent Preset:列出/切换声明式智能体预设(工具白名单/权限/参数 + 系统提示定制段)
319
+ const { listPresets, loadPreset, presetConfigOverrides, presetSystemBlock } = await import('../presets.js');
320
+ if (!arg) {
321
+ const ps = listPresets(workingDir);
322
+ if (!ps.length) io.print(style('(无可用预设。目录:<项目>/.mingdao/presets/、~/.mingdao/presets/、内置 presets/)', C.dim));
323
+ else for (const p of ps) io.print(` - ${p.name}(${p.label || '无标签'},${p.source === 'project' ? '项目' : p.source === 'user' ? '用户' : '内置'})${p.description ? ':' + p.description : ''}`);
324
+ continue;
325
+ }
326
+ const pname = arg.split(/\s+/)[0];
327
+ const p = loadPreset(workingDir, pname);
328
+ if (!p) {
329
+ io.print(style(`预设 "${pname}" 不存在(/preset 列出全部)。`, C.yellow));
330
+ continue;
331
+ }
332
+ const over = presetConfigOverrides(p);
333
+ // 应用覆盖:权限/参数/工具白名单/模型建议——只进 agentCfg(会话级 overlay),
334
+ // 绝不改写 cfg:否则后续 /model、/think 的 saveConfig 会把预设字段持久化进 config.json。
335
+ agentCfg = { ...cfg, ...over, presetName: p.name };
336
+ if (over.model) await switchToModel(over.model, { silent: true, persist: false });
337
+ presetBlock = presetSystemBlock(p);
338
+ permissionNow = createPermission(agentCfg.permission ?? 'ask', io);
339
+ agent = createAgent({
340
+ provider, permission: permissionNow, io, modelName, workingDir, cfg: agentCfg,
341
+ undoStore: sessionUndoStore, mcp: mcpFacade, sessionRef,
342
+ onCompact: (/** @type {any} */ msgs) => {
343
+ rewriteSession(session.file, msgs);
344
+ tuiState.persisted = msgs.length;
345
+ },
346
+ });
347
+ messages[0] = { role: 'system', content: buildSystemPrompt({ workingDir, withJournal, presetBlock }) };
348
+ io.print(style(`▣ 已应用智能体预设:${p.name}${p.label ? '(' + p.label + ')' : ''}${over.presetTools ? `,工具白名单 ${over.presetTools.length} 个` : ''}`, C.green));
312
349
  } else if (cmd === '/model') {
313
350
  if (!arg) {
314
351
  io.print(`当前模型:${modelName}`);
@@ -336,7 +373,7 @@ export async function runRepl(ctx) {
336
373
  saveConfig(cfg);
337
374
  } else { io.print(style('无效取值:low|high|max|off', C.red)); continue; }
338
375
  agent = createAgent({
339
- provider, permission, io, modelName, workingDir, cfg,
376
+ provider, permission: permissionNow, io, modelName, workingDir, cfg: agentCfg,
340
377
  undoStore: sessionUndoStore, mcp: mcpFacade, sessionRef,
341
378
  onCompact: (/** @type {any} */ msgs) => {
342
379
  rewriteSession(session.file, msgs);
package/src/index.js CHANGED
@@ -1,13 +1,35 @@
1
1
  // MingDao-Harness 公共 API:供第三方程序/插件以库形式复用核心能力。
2
+ // 稳定性契约(v0.4.0 起):@stable 在 minor 版本内保持向后兼容;@experimental 可能变更。
3
+ // 详细契约与示例见 docs/DEVELOPER.md。
2
4
 
5
+ // —— @stable:Agent 内核 ——
3
6
  export { createAgent } from './agent.js';
7
+ export { createPermission } from './permissions.js';
8
+ export { createIO, style, C } from './ui.js';
9
+
10
+ // —— @stable:Provider 与模型 ——
4
11
  export { createProvider, resolveProviderConfig } from './providers/index.js';
5
- export { toolSchemas, dispatch } from './tools/index.js';
12
+ export { MODELS, PROVIDERS, modelPreset, providerPreset } from './models.js';
13
+ export { resolveModelCaps, safeBudget, isLocalBaseUrl } from './model-caps.js';
14
+
15
+ // —— @stable:工具(含 v0.4.0 第三方注册)——
16
+ export { toolSchemas, dispatch, registerTool, listRegisteredTools, mountConfigTools, buildToolSchemas } from './tools/index.js';
17
+
18
+ // —— @stable:Agent Preset(v0.4.0)——
19
+ export {
20
+ listPresets,
21
+ loadPreset,
22
+ validatePreset,
23
+ presetConfigOverrides,
24
+ presetSystemBlock,
25
+ presetDirs,
26
+ } from './presets.js';
27
+
28
+ // —— @stable:上下文与压缩 ——
6
29
  export { trimMessages, approxTokens, clampText, TOOL_RESULT_LIMIT } from './context.js';
7
30
  export { compactConversation, summarizeConversation } from './compact.js';
8
- export { createPermission } from './permissions.js';
9
- export { MODELS, PROVIDERS, modelPreset, providerPreset } from './models.js';
10
- export { createIO, style, C } from './ui.js';
31
+
32
+ // —— @stable:配置与凭证 ——
11
33
  export {
12
34
  mingdaoHome,
13
35
  ensureHome,
@@ -26,8 +48,12 @@ export {
26
48
  maskKey,
27
49
  resolveApiKey,
28
50
  } from './credentials.js';
51
+
52
+ // —— @stable:计价与计量 ——
29
53
  export { estimateCost, estimateCostLabel, isPeakHour, PRICE_DATA_AS_OF } from './pricing.js';
30
54
  export { countTokens, heuristicTokens, makeTokenCounter, isTokenizable } from './tokenizer.js';
55
+
56
+ // —— @experimental:更新/审计/技能/会话(接口可能调整)——
31
57
  export { updateCheck, mingdaoUpdate, mingdaoRollback, findRepoRoot } from './update.js';
32
58
  export { writeAudit, listAudit, redactSecrets, auditFile } from './audit.js';
33
59
  export { trustSkill, skillDirHash, readSourceMeta } from './skill-lib.js';
package/src/presets.js ADDED
@@ -0,0 +1,154 @@
1
+ // Agent Preset(v0.4.0 契约化):声明式智能体预设——把「系统提示 + 工具集 + 权限 + 模型 + 参数」
2
+ // 打包成一个可安装、可复用、可分享的 JSON 单元,让开发者/用户不改源码就能定制自己的智能体。
3
+ //
4
+ // 发现顺序(同名后者遮蔽前者):
5
+ // 1. 项目级 <工作目录>/.mingdao/presets/*.json
6
+ // 2. 用户级 <mingdao-home>/presets/*.json
7
+ // 3. 内置 随 npm 包分发的 presets/ 目录(只读参考实现)
8
+ //
9
+ // 预设字段(全部可选,缺省时保持当前配置不变):
10
+ // name 唯一名(必填,字母/数字/-/_,1-64)
11
+ // label 展示名(可选,默认 name)
12
+ // description 一句话用途
13
+ // systemPrompt 追加到系统提示的定制段(角色/规则/上下文约定)
14
+ // tools 工具白名单(数组,省略=不限制)
15
+ // permission 权限模式 ask/auto/readonly(省略=当前配置)
16
+ // model 建议模型(省略=当前模型)
17
+ // temperature / maxOutputTokens / maxRounds / contextBudget 参数覆盖
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { mingdaoHome, ensureHome } from './config.js';
22
+
23
+ const PRESET_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
24
+ // 合法字段白名单:未知字段报错(防拼写错误静默失效——契约化核心)
25
+ const KNOWN_FIELDS = new Set([
26
+ 'name', 'label', 'description', 'systemPrompt', 'tools',
27
+ 'permission', 'model', 'temperature', 'maxOutputTokens', 'maxRounds', 'contextBudget',
28
+ ]);
29
+ const PERMISSION_MODES = ['ask', 'auto', 'readonly'];
30
+
31
+ /** 内置预设目录(随 npm 包分发,只读参考)。 */
32
+ export function builtinPresetDir() {
33
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'presets');
34
+ }
35
+
36
+ /** 发现目录与 source 标签对齐(遮蔽顺序:项目 → 用户 → 内置)。 */
37
+ function presetLocations(/** @type {any} */ workingDir) {
38
+ const locs = [];
39
+ if (workingDir) locs.push({ dir: path.join(String(workingDir), '.mingdao', 'presets'), source: 'project' });
40
+ locs.push({ dir: path.join(mingdaoHome(), 'presets'), source: 'user' });
41
+ locs.push({ dir: builtinPresetDir(), source: 'builtin' });
42
+ return locs;
43
+ }
44
+
45
+ /** @param {any} workingDir 预设发现目录(按遮蔽顺序:项目 → 用户 → 内置)。 */
46
+ export function presetDirs(/** @type {any} */ workingDir) {
47
+ return presetLocations(workingDir).map((/** @type {any} */ l) => l.dir);
48
+ }
49
+
50
+ /** @param {any} obj 校验预设对象,返回 { ok, errors: string[] }。 */
51
+ export function validatePreset(/** @type {any} */ obj) {
52
+ const errors = [];
53
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return { ok: false, errors: ['预设必须是 JSON 对象'] };
54
+ const name = String(obj.name ?? '').trim();
55
+ if (!name) errors.push('缺少 name 字段');
56
+ else if (!PRESET_NAME_RE.test(name)) errors.push(`name 非法(${PRESET_NAME_RE}):${name}`);
57
+ for (const k of Object.keys(obj)) {
58
+ if (!KNOWN_FIELDS.has(k)) errors.push(`未知字段:${k}(合法:${[...KNOWN_FIELDS].join('/')})`);
59
+ }
60
+ if (obj.systemPrompt !== undefined && typeof obj.systemPrompt !== 'string') errors.push('systemPrompt 必须是字符串');
61
+ if (obj.tools !== undefined && (!Array.isArray(obj.tools) || obj.tools.some((/** @type {any} */ t) => typeof t !== 'string'))) {
62
+ errors.push('tools 必须是字符串数组');
63
+ }
64
+ if (obj.permission !== undefined && !PERMISSION_MODES.includes(String(obj.permission))) {
65
+ errors.push(`permission 必须是 ${PERMISSION_MODES.join('/')}`);
66
+ }
67
+ for (const k of ['temperature', 'maxOutputTokens', 'maxRounds', 'contextBudget']) {
68
+ if (obj[k] !== undefined && !(Number.isFinite(Number(obj[k])) && Number(obj[k]) > 0)) {
69
+ errors.push(`${k} 必须是正数`);
70
+ }
71
+ }
72
+ return { ok: errors.length === 0, errors };
73
+ }
74
+
75
+ /**
76
+ * 列出全部可用预设(发现顺序:项目遮蔽用户遮蔽内置,同名只留前者)。
77
+ * 返回 [{ name, label, description, source: 'project'|'user'|'builtin', file }]
78
+ */
79
+ export function listPresets(/** @type {any} */ workingDir) {
80
+ ensureHome();
81
+ const seen = new Map();
82
+ const order = /** @type {string[]} */ ([]);
83
+ for (const { dir, source } of presetLocations(workingDir)) {
84
+ let files = [];
85
+ try {
86
+ files = fs.readdirSync(dir).filter((/** @type {any} */ f) => f.endsWith('.json'));
87
+ } catch {
88
+ continue;
89
+ }
90
+ for (const f of files) {
91
+ if (seen.has(f)) continue;
92
+ seen.set(f, { dir, source });
93
+ order.push(f);
94
+ }
95
+ }
96
+ const out = [];
97
+ for (const f of order) {
98
+ const { dir, source } = seen.get(f);
99
+ try {
100
+ const obj = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
101
+ const v = validatePreset(obj);
102
+ if (!v.ok) continue; // 非法预设跳过并静默(不阻塞会话);diagnose 可查
103
+ out.push({
104
+ name: String(obj.name),
105
+ label: String(obj.label || obj.name),
106
+ description: String(obj.description || ''),
107
+ source,
108
+ file: path.join(dir, f),
109
+ ...(obj.systemPrompt ? { systemPrompt: obj.systemPrompt } : {}),
110
+ ...(Array.isArray(obj.tools) ? { tools: obj.tools } : {}),
111
+ ...(obj.permission ? { permission: String(obj.permission) } : {}),
112
+ ...(obj.model ? { model: String(obj.model) } : {}),
113
+ });
114
+ } catch {
115
+ // JSON 解析失败:跳过
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * 按名解析单个预设(含全部字段),找不到返回 null。
123
+ * @param {any} workingDir @param {any} name
124
+ */
125
+ export function loadPreset(/** @type {any} */ workingDir, /** @type {any} */ name) {
126
+ const all = listPresets(workingDir);
127
+ const hit = all.find((/** @type {any} */ p) => p.name === name || path.basename(String(p.file), '.json') === name);
128
+ if (!hit) return null;
129
+ try {
130
+ return JSON.parse(fs.readFileSync(hit.file, 'utf8'));
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * 预设 → cfg 覆盖:只返回预设声明的参数键(其余键保持调用方当前配置)。
138
+ * tools 单独走 cfg.presetTools(白名单在 agent 的 toolsFor 处生效)。
139
+ */
140
+ export function presetConfigOverrides(/** @type {any} */ preset) {
141
+ const out = /** @type {Record<string, any>} */ ({});
142
+ for (const k of ['permission', 'model', 'temperature', 'maxOutputTokens', 'maxRounds', 'contextBudget']) {
143
+ if (preset && preset[k] !== undefined) out[k] = preset[k];
144
+ }
145
+ if (preset && Array.isArray(preset.tools)) out.presetTools = [...preset.tools];
146
+ return out;
147
+ }
148
+
149
+ /** 预设系统提示定制段(无则空串),插入系统提示 BASE 之后。 */
150
+ export function presetSystemBlock(/** @type {any} */ preset) {
151
+ const s = preset && typeof preset.systemPrompt === 'string' ? preset.systemPrompt.trim() : '';
152
+ if (!s) return '';
153
+ return `\n\n<preset_rules>\n${s}\n</preset_rules>`;
154
+ }
package/src/prompts.js CHANGED
@@ -30,8 +30,8 @@ function loadFile(/** @type {any} */ p, /** @type {any} */ cap) {
30
30
  }
31
31
  }
32
32
 
33
- /** @param {{ workingDir: any, withJournal?: boolean, projectMemory?: string, [key: string]: any }} opts */
34
- export function buildSystemPrompt({ workingDir, withJournal = false, projectMemory }) {
33
+ /** @param {{ workingDir: any, withJournal?: boolean, projectMemory?: string, presetBlock?: string, [key: string]: any }} opts */
34
+ export function buildSystemPrompt({ workingDir, withJournal = false, projectMemory, presetBlock }) {
35
35
  // 前缀字节稳定性(评估 P1-1/P1-2,四份评估一致的最高价值项):
36
36
  // 系统提示不含「当前模型」「当前日期」等易变字段——DeepSeek 上下文缓存按前缀字节匹配,
37
37
  // 路由 pro⇄flash 翻转或跨天会改变前缀 → 整段历史按未命中价重计(命中价的 30 倍)。
@@ -40,6 +40,9 @@ export function buildSystemPrompt({ workingDir, withJournal = false, projectMemo
40
40
 
41
41
  当前工作目录:${workingDir}`;
42
42
 
43
+ // v0.4.0 Agent Preset:预设定制的角色/规则段(会话内恒定,前缀稳定)
44
+ if (presetBlock) prompt += presetBlock;
45
+
43
46
  // 用户级记忆(~/.mingdao/AGENTS.md,/memory add 手动追加 + 会话结束自动提炼)
44
47
  const memory = loadFile(path.join(mingdaoHome(), 'AGENTS.md'), 8000);
45
48
  if (memory) prompt += `\n\n<user_memory>\n${memory}\n</user_memory>`;
@@ -53,6 +53,9 @@ export async function runWorkerTask(id, question, { permission, model, offpeak }
53
53
  }
54
54
  const provider = await createProvider(cfg, modelName);
55
55
  const io = createIO({ quiet: true });
56
+ // v0.4.0 契约化:后台任务同样挂载 config.tools 声明式工具(幂等)
57
+ const { mountConfigTools } = await import('../tools/index.js');
58
+ mountConfigTools(cfg);
56
59
  const permissionObj = createPermission(perm, io);
57
60
  /** @type {any} */
58
61
  let mcpManager = null;
@@ -6,6 +6,7 @@ import { runBash } from './bash.js';
6
6
  import { runGit } from './git.js';
7
7
  import { runFetch } from './fetch.js';
8
8
  import { listSkills, loadSkill } from '../skills.js';
9
+ import { spawnSync } from 'node:child_process';
9
10
 
10
11
  // 只读工具集合的单一来源:permissions.js 引用此导出,新增只读工具时只需改这里
11
12
  export const READONLY_TOOLS = new Set(['read', 'glob', 'grep', 'ls', 'skill', 'git', 'fetch']);
@@ -245,6 +246,108 @@ export function toolSchemas() {
245
246
  return TOOLS;
246
247
  }
247
248
 
249
+ // —— 第三方工具注册表(v0.4.0 契约化):程序化 registerTool 注册的自定义工具 ——
250
+ // 与内置工具同一链路:schema 进 buildToolSchemas(省钱剥描述/只读档/预设白名单全适用)、
251
+ // dispatch 执行、审计/权限由 agent 外层统一处理。注册名不得与内置工具同名。
252
+ const customTools = new Map(); // name -> { schema, run }
253
+ const CUSTOM_TOOL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
254
+
255
+ /**
256
+ * 注册第三方工具(进程级,重复注册同名会报错——避免静默覆盖)。
257
+ * @param {{ name: string, description?: string, parameters?: any, run: (args: any, ctx: any) => any | Promise<any> }} tool
258
+ */
259
+ export function registerTool(/** @type {any} */ tool) {
260
+ const name = String(tool?.name ?? '').trim();
261
+ if (!CUSTOM_TOOL_NAME_RE.test(name)) {
262
+ throw new Error(`自定义工具名非法(${CUSTOM_TOOL_NAME_RE}):${name}`);
263
+ }
264
+ // mcp__ 前缀保留给 MCP 工具(agent 按前缀路由到 mcp.call),自定义工具占用会静默错路由
265
+ if (name.startsWith('mcp__')) {
266
+ throw new Error(`自定义工具名不能以 mcp__ 开头(该前缀保留给 MCP 工具):${name}`);
267
+ }
268
+ if (TOOLS.some((/** @type {any} */ t) => t.function.name === name) || customTools.has(name)) {
269
+ throw new Error(`工具 ${name} 已存在(内置或已注册),不能重复注册`);
270
+ }
271
+ if (typeof tool?.run !== 'function') {
272
+ throw new Error(`自定义工具 ${name} 必须提供 run(args, ctx) 函数`);
273
+ }
274
+ const parameters =
275
+ tool.parameters && typeof tool.parameters === 'object' ? tool.parameters : { type: 'object', properties: {} };
276
+ const entry = {
277
+ schema: {
278
+ type: 'function',
279
+ function: { name, description: String(tool.description || ''), parameters },
280
+ },
281
+ run: tool.run,
282
+ };
283
+ customTools.set(name, entry);
284
+ return entry.schema;
285
+ }
286
+
287
+ /** 已注册的第三方工具名列表。 */
288
+ export function listRegisteredTools() {
289
+ return [...customTools.keys()];
290
+ }
291
+
292
+ /** 第三方工具 schema 列表(与内置 TOOLS 合并进 buildToolSchemas)。 */
293
+ function customToolSchemas() {
294
+ return [...customTools.values()].map((/** @type {any} */ t) => t.schema);
295
+ }
296
+
297
+ // —— config.tools 声明式工具挂载(v0.4.0 契约化)——
298
+ // config.json: { "tools": [ { "name": "ping", "description": "...", "parameters": {...}, "command": "..." } ] }
299
+ // command 经 /bin/bash -lc 执行;参数以 MINGDAO_TOOL_ARGS(JSON)环境变量传入——参数不经字符串拼接进
300
+ // shell(防注入),由命令自行解析;执行受 agent 权限引擎门控(与 bash 同权重,权限提示含参数)。
301
+ // 进程级挂载(幂等:同名已挂载则跳过),改 config.tools 需重启生效(与 MCP 预设一致)。
302
+ const mountedConfigTools = new Set();
303
+
304
+ /**
305
+ * 把 config.tools 声明式工具挂载进注册表(幂等,可在启动路径重复调用)。
306
+ * 返回本次新挂载的工具名数组。非法条目(坏名/与内置冲突)跳过不挂载——
307
+ * 用户配置错误绝不能让 CLI/WebUI 启动崩溃,用 console.error 提示即可。
308
+ */
309
+ export function mountConfigTools(/** @type {any} */ cfg) {
310
+ const entries = Array.isArray(cfg?.tools) ? cfg.tools : [];
311
+ const mounted = [];
312
+ for (const e of entries) {
313
+ const name = String(e?.name ?? '').trim();
314
+ if (!name || mountedConfigTools.has(name)) continue;
315
+ if (typeof e?.command !== 'string' || !e.command.trim()) continue; // 缺 command 的条目跳过
316
+ try {
317
+ registerTool({
318
+ name,
319
+ description: String(e.description || ''),
320
+ parameters: e.parameters,
321
+ run: (/** @type {any} */ args, /** @type {any} */ ctx) => {
322
+ const shell = process.platform === 'win32' ? 'cmd.exe' : '/bin/bash';
323
+ const shellArgs = process.platform === 'win32' ? ['/d', '/s', '/c', e.command] : ['-lc', e.command];
324
+ const r = spawnSync(shell, shellArgs, {
325
+ cwd: ctx.cwd,
326
+ env: { ...process.env, MINGDAO_TOOL_ARGS: JSON.stringify(args ?? {}) },
327
+ timeout: Math.min(Number(e.timeout) > 0 ? Number(e.timeout) : 120, 600) * 1000,
328
+ maxBuffer: 2 * 1024 * 1024,
329
+ });
330
+ const out = String(r.stdout || '');
331
+ const err = String(r.stderr || '');
332
+ const capped = out.length > 20000 ? out.slice(0, 20000) + `\n…[输出过长已截断,共 ${out.length} 字]` : out;
333
+ return {
334
+ ok: r.error ? false : (r.status ?? 0) === 0,
335
+ exitCode: r.error ? null : (r.status ?? null),
336
+ output: (capped || '(无输出)').trim(),
337
+ ...(err.trim() ? { stderr: err.trim().slice(0, 4000) } : {}),
338
+ ...(r.error ? { error: `命令执行失败:${String(r.error?.message || r.error)}` } : {}),
339
+ };
340
+ },
341
+ });
342
+ mountedConfigTools.add(name);
343
+ mounted.push(name);
344
+ } catch (/** @type {any} */ err) {
345
+ console.error(`[MingDao] ⚠ config.tools 条目 "${name}" 挂载失败(已跳过):${String(err?.message || err)}`);
346
+ }
347
+ }
348
+ return mounted;
349
+ }
350
+
248
351
  /**
249
352
  * 递归去除对象中的全部 description 键(保留类型/required/enum 等结构性字段)。
250
353
  * @param {any} obj
@@ -283,7 +386,7 @@ export function buildToolSchemas(usedNames, extra = []) {
283
386
  }
284
387
  return t;
285
388
  });
286
- return [...strip(TOOLS), ...strip(extra)];
389
+ return [...strip(TOOLS), ...strip(customToolSchemas()), ...strip(extra)];
287
390
  }
288
391
 
289
392
  function runSkill(/** @type {any} */ args, /** @type {any} */ ctx) {
@@ -333,6 +436,15 @@ function runTodo(/** @type {any} */ args, /** @type {any} */ ctx) {
333
436
  }
334
437
 
335
438
  export async function dispatch(/** @type {any} */ name, /** @type {any} */ args, /** @type {any} */ ctx) {
439
+ const custom = customTools.get(name);
440
+ if (custom) {
441
+ try {
442
+ const r = await custom.run(args, ctx);
443
+ return r && typeof r === 'object' ? r : { ok: true, output: String(r ?? '') };
444
+ } catch (/** @type {any} */ err) {
445
+ return { ok: false, error: `自定义工具 ${name} 执行失败:${String(err?.message || err)}` };
446
+ }
447
+ }
336
448
  switch (name) {
337
449
  case 'read':
338
450
  return read(args, ctx);
package/src/web/app.js CHANGED
@@ -345,6 +345,9 @@ async function send(){
345
345
  arm();
346
346
  input.value=''; input.style.height='44px';
347
347
  const payload={message:text,file:currentSession};
348
+ // v0.4.0 Agent Preset:选择器选中的预设随本轮发送(服务端按会话应用一次)
349
+ const presetVal=$('#presetSel')?.value;
350
+ if(presetVal) payload.preset=presetVal;
348
351
  // 带上文开关:勾选后系统提示注入最近会话日志(默认不注入——新会话全新开始,避免串到历史会话上下文)
349
352
  if($('#journalChk')?.checked) payload.withJournal=true;
350
353
  if(attachments.length) payload.attachments=attachments;
@@ -647,6 +650,11 @@ async function init(){
647
650
  }
648
651
  refreshSessions();
649
652
  refreshWsSel();
653
+ try{
654
+ // v0.4.0 Agent Preset:加载预设列表进下拉(项目 → 用户 → 内置)
655
+ const pr=await fetch('/api/presets',{cache:'no-store'}).catch(()=>null); const pj=pr?await pr.json():{presets:[]};
656
+ const psel=$('#presetSel'); if(psel&&pj.presets){ for(const p of pj.presets){ const o=document.createElement('option'); o.value=p.name; o.textContent=p.label+'('+(p.source==='project'?'项目':p.source==='user'?'用户':'内置')+')'; o.title=p.description||p.name; psel.appendChild(o); } }
657
+ }catch(e){}
650
658
  try{
651
659
  const dr=await fetch('/api/draft?file='+encodeURIComponent(currentSession||''),{cache:'no-store'}); const dj=await dr.json();
652
660
  if(dj.text){ input.value=dj.text; input.style.height=Math.min(input.scrollHeight,200)+'px'; input.focus(); }
@@ -57,6 +57,7 @@ header button{white-space:nowrap;flex:none}
57
57
  header select{flex:none}
58
58
  #sessionSearch{flex:1 1 auto;min-width:70px;max-width:220px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 12px;font-size:13px}
59
59
  #modelSel{max-width:170px;min-width:0}
60
+ #presetSel{max-width:140px;min-width:0}
60
61
  #permSel{max-width:150px;min-width:0}
61
62
  #sessions{max-width:150px;min-width:0}
62
63
  #wsSel{max-width:130px;min-width:64px}
@@ -197,11 +198,12 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
197
198
  #composer #permSel{max-width:96px;min-width:64px;flex:none;padding:8px 8px}
198
199
  #composer #reasoningSel{max-width:64px;min-width:48px;flex:none;padding:8px 6px;font-size:12px}
199
200
  #composer #modelSel{max-width:150px;min-width:88px;flex:none;padding:8px 8px;font-size:12px}
201
+ #composer #presetSel{max-width:130px;min-width:76px;flex:none;padding:8px 8px;font-size:12px}
200
202
  #input{flex:1;background:var(--bg3);border:1px solid var(--border);border-radius:12px;color:var(--text);padding:10px 14px;font:14px/1.6 inherit;resize:none;min-height:44px;max-height:200px;min-width:0}
201
203
  #input:focus{outline:none;border-color:var(--accent2)}
202
204
  #hint{font-size:11.5px;color:var(--faint);margin-top:6px;max-width:860px;margin-left:auto;margin-right:auto}
203
205
  #statusBar{font-size:11.5px;color:var(--faint);margin-top:2px;max-width:860px;margin-left:auto;margin-right:auto;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
204
- @media (max-width:560px){footer{padding:10px 8px}#composer{gap:6px}#composer #permSel{min-width:52px;padding:8px 4px}#composer #reasoningSel{min-width:44px;padding:8px 4px}#composer #modelSel{min-width:72px;padding:8px 4px}#attachBtn{padding:10px 9px}}
206
+ @media (max-width:560px){footer{padding:10px 8px}#composer{gap:6px}#composer #permSel{min-width:52px;padding:8px 4px}#composer #reasoningSel{min-width:44px;padding:8px 4px}#composer #modelSel{min-width:72px;padding:8px 4px}#composer #presetSel{min-width:64px;max-width:88px;padding:8px 4px}#attachBtn{padding:10px 9px}}
205
207
  .modal-mask{position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;z-index:50}
206
208
  .modal{background:var(--panel);border:1px solid var(--border);border-radius:14px;width:min(520px,92vw);padding:18px;max-height:88vh;overflow-y:auto}
207
209
  /* 设置多级菜单(审计:一拉到底改为左侧分组导航 + 右侧面板) */
@@ -330,6 +332,7 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
330
332
  <option value="readonly">只读</option>
331
333
  </select>
332
334
  <select id="modelSel" title="切换模型"></select>
335
+ <select id="presetSel" title="智能体预设:一键切换工具白名单/权限/参数组合(v0.4.0 契约化)"><option value="">预设…</option></select>
333
336
  <select id="reasoningSel" title="思考模式(推理等级):关=不推理省 token · 低 · 高(默认)· 最高=最强推理" style="display:none">
334
337
  <option value="off">关</option>
335
338
  <option value="low">低</option>
@@ -3,6 +3,7 @@
3
3
  import { loadMemory, writeMemory, dedupeMemory } from '../../../memory.js';
4
4
  import { recordUsage, listCacheStats, summarizeCacheStats, costBreakdown } from '../../../cachestats.js';
5
5
  import { costGuardStatus } from '../../../cost-guard.js';
6
+ import { listPresets } from '../../../presets.js';
6
7
 
7
8
  /**
8
9
  * 杂项域路由。命中返回 true,未命中返回 false。
@@ -100,6 +101,13 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
100
101
  return true;
101
102
  }
102
103
 
104
+ // v0.4.0 Agent Preset:列出可用预设(项目 → 用户 → 内置,同名遮蔽)
105
+ if (method === 'GET' && p === '/api/presets') {
106
+ const workingDir = deps.state?.workingDir || process.cwd();
107
+ json(res, 200, { ok: true, presets: listPresets(workingDir) });
108
+ return true;
109
+ }
110
+
103
111
  if (method === 'POST' && p === '/api/memory') {
104
112
  const body = await readBody(req, MAX_API_BODY);
105
113
  if (body.action === 'dedupe') {
package/src/web/server.js CHANGED
@@ -194,6 +194,13 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
194
194
  const provider = await getProviderFor(modelName);
195
195
  const undoStore = { backups: new Map() };
196
196
 
197
+ // v0.4.0 契约化:挂载 config.tools 声明式第三方工具(幂等,重启生效)
198
+ {
199
+ const { mountConfigTools } = await import('../tools/index.js');
200
+ const mounted = mountConfigTools(cfg);
201
+ if (mounted.length) console.error(`[MingDao] 🔧 已挂载声明式工具(config.tools):${mounted.join(', ')}`);
202
+ }
203
+
197
204
  // MCP:A2 预热——await 连接(6s 超时);超时本会话冻结工具集(不再中途注入,保护前缀缓存)
198
205
  /** @type {any} */
199
206
  let mcpManager = null;
@@ -408,7 +415,24 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
408
415
  sessionMemoryCache.delete(oldest);
409
416
  }
410
417
  }
411
- const systemPrompt = buildSystemPrompt({ workingDir: taskDir, withJournal: body.withJournal === true, projectMemory: projectMemorySnapshot });
418
+ // v0.4.0 Agent Preset:body.preset 按会话一次选定(新会话或显式传值);应用工具白名单/参数覆盖,
419
+ // 系统提示注入预设定制段。预设覆盖优先级:CLI/WebUI 显式 > 预设 > config.json。
420
+ let chatPreset = /** @type {any} */ (null);
421
+ let presetBlock = '';
422
+ let chatCfg = cfg;
423
+ if (typeof body.preset === 'string' && body.preset) {
424
+ const { loadPreset, presetConfigOverrides, presetSystemBlock } = await import('../presets.js');
425
+ chatPreset = loadPreset(taskDir, body.preset);
426
+ if (chatPreset) {
427
+ const over = presetConfigOverrides(chatPreset);
428
+ chatCfg = { ...cfg, ...over, presetName: chatPreset.name };
429
+ presetBlock = presetSystemBlock(chatPreset);
430
+ } else {
431
+ // 预设不存在:不静默——banner 告知(前端下拉与磁盘不同步/项目级预设未带入时可见)
432
+ send({ type: 'banner', text: `⚠ 预设 "${String(body.preset)}" 不存在,已按当前配置继续。` });
433
+ }
434
+ }
435
+ const systemPrompt = buildSystemPrompt({ workingDir: taskDir, withJournal: body.withJournal === true, projectMemory: projectMemorySnapshot, presetBlock });
412
436
  let messages =
413
437
  session.messages?.length && session.messages[0]?.role === 'system'
414
438
  ? session.messages
@@ -448,7 +472,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
448
472
  entry.abortHandler = fn;
449
473
  },
450
474
  }));
451
- const permission = createPermission(cfg.permission ?? 'ask', io);
475
+ const permission = createPermission(chatCfg.permission ?? 'ask', io);
452
476
  let providerNow;
453
477
  try {
454
478
  // 首次使用引导:未配置密钥时给明确指引,而不是晦涩的 401 原始报错
@@ -471,7 +495,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
471
495
  io,
472
496
  modelName: runModel,
473
497
  workingDir: taskDir,
474
- cfg,
498
+ cfg: chatCfg, // v0.4.0:预设覆盖后的配置(工具白名单 presetTools/参数/权限)
475
499
  undoStore,
476
500
  mcp: mcpFacade,
477
501
  // 自动压缩后重写会话文件(否则每次加载历史都会重新触发压缩),并同步落盘游标