mingdao-harness 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/CONFIG.md CHANGED
@@ -23,7 +23,7 @@
23
23
  | `baseUrl` | OpenAI 兼容 API 地址(可覆盖内置服务商默认值) |
24
24
  | `permission` | `ask`(默认)/ `auto` / `readonly`,或规则对象(见下) |
25
25
  | `sandbox` | `off` / `readonly` / `safe`(Linux + bubblewrap;其余平台自动降级) |
26
- | `contextBudget` | 上下文预算 tokens(模型预设默认值:flash 128k / pro 200k) |
26
+ | `contextBudget` | 期望的上下文预算 tokens;实际预算按模型窗口自动收紧(见「本地模型自适应」) |
27
27
 
28
28
  可选字段:`temperature`、`maxOutputTokens`、`includeUsage`(流式请求 usage 统计,个别网关不支持
29
29
  `stream_options` 时设 `false`)、`autoTitle`(自动生成会话标题,默认开)、`notify`(任务桌面通知,默认开)、
@@ -221,7 +221,8 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
221
221
  {
222
222
  "customModels": {
223
223
  "my-gpt4": { "label": "我的 GPT-4 网关", "baseUrl": "https://gateway.example.com/v1" },
224
- "my-ds": { "label": "自建 DeepSeek 网关", "baseUrl": "https://gw.example.com/v1", "tokenizer": "deepseek" }
224
+ "my-ds": { "label": "自建 DeepSeek 网关", "baseUrl": "https://gw.example.com/v1", "tokenizer": "deepseek" },
225
+ "local-qwen": { "label": "本机 Qwen", "baseUrl": "http://127.0.0.1:8081/v1", "contextWindow": 131072, "maxOutputTokens": 8192 }
225
226
  }
226
227
  }
227
228
  ```
@@ -231,6 +232,27 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
231
232
  自定义端点若跑的是 DeepSeek 系模型(模型名不以 `deepseek` 开头时默认走启发式估算、预算误差
232
233
  可达 ±2 倍),加 `"tokenizer": "deepseek"` 即按官方词表精确计数:
233
234
 
235
+ ### 本地模型自适应(v0.3.2)
236
+
237
+ 本机/内网部署的推理框架(baseUrl 为 `127.0.0.1`/`localhost`/私网 IP)自动按「资源有限」对待,
238
+ 避免长任务把上下文撑到窗口边缘后 prefill 指数恶化、被客户端超时掐断(典型:127k 上下文首 token
239
+ 需 200s+,客户端 3 分钟无响应断开 → network error)。机制:
240
+
241
+ - **上下文窗口感知**:`customModels.<name>.contextWindow` 显式声明模型真实窗口;未声明时本地
242
+ 模型兜底 **32k**、远程兜底 **128k**。
243
+ - **安全预算**:`contextBudget` 会被自动收紧到 `min(contextBudget, 窗口×75%, 窗口−maxOutput−余量)`,
244
+ prompt 永不逼近窗口边缘(75% 舒适区以上 prefill 时间陡增)。
245
+ - **边缘检测**:模型每轮上报真实 `prompt_tokens`,≥ 窗口 85% 时下一轮强制压缩历史(即使启发式
246
+ 计数低估也强制触发)。
247
+ - **工具输出截断**:单条工具结果按 `窗口/16` 封顶(最少 2000 字),小窗口不再整条回灌大段代码。
248
+ - **分层超时**:`timeout.firstTokenMs`(首 token 等待,本地默认 600s / 远程 300s)、
249
+ `timeout.streamIdleMs`(流式空闲,默认 120s)、`timeout.totalMs`(总量,本地 30min / 远程 10min),
250
+ 留空则自适应;本地慢 prefill 不再被一刀切超时误杀。
251
+
252
+ ```json
253
+ { "timeout": { "firstTokenMs": 600000, "streamIdleMs": 120000, "totalMs": 1800000 } }
254
+ ```
255
+
234
256
  ## 自定义 Provider 模块(非 OpenAI 兼容协议)
235
257
 
236
258
  在 `~/.mingdao/providers/<name>.mjs` 导出:
@@ -70,7 +70,7 @@ v0.3.0 落地的三条主线共享同一个地基——**「记忆/上下文」*
70
70
 
71
71
  ## 三、v0.3.0 之外(顺延序列,防漂移备忘)
72
72
 
73
- - **v0.3.1「检索与武器」**:零依赖语义检索(项目记忆 + 历史会话从"最近 N"→"相关 N");`git` 只读分析(diff/log/status)与 HTTP 只读抓取(SSRF 白名单兜底)工具;完整基线+增量上下文(若 P0-4 顺延)。
73
+ - **v0.3.1「检索与武器」**:零依赖语义检索(项目记忆从"全量→相关条目"注入,`retrieveRelevant` 分词 Jaccard);`git` 只读分析(diff/log/status 等,无 shell 防注入)与 HTTP 只读抓取(SSRF 白名单兜底)工具;省钱仪表盘从设置移出、顶部费用徽标点击展开(KPI + 命中率环状仪表 + 14 天趋势面积图 + 模型/工具 Top 条形 + 最近缓存)。**完整基线+增量上下文(P0-4)顺延 v0.3.2**(风险高,语义检索 + 续跑进度摘要已覆盖主场景)。
74
74
  - **v0.3.2「看得见的省钱 + 省心」**:省钱归因面板(单任务省多少/花在哪一步/缓存命中贡献,WebUI 内 SVG 折线已有基础);`diagnose` 的自动反馈模板;覆盖率阈值随版本上调。
75
75
  - **v0.3.3「纵深能力」**:跨平台沙箱补位(Windows Job Object / macOS seatbelt,平台无关敏感命令兜底);WebUI「完成任务的舒服工作台」深化(过程可回放/结论可复现)。
76
76
  - **明确不进任何近期版本**:全模型省钱平台、React/框架化重构、SQLite(Node ≥22.5 破坏 18/20)、SaaS 托管化。
@@ -0,0 +1,65 @@
1
+ # v0.3.2 规划:本地模型自适应(资源受限部署不中断)
2
+
3
+ ## 背景(用户 MacBook M5 Pro 实测定位结论)
4
+
5
+ 本地部署 `mtplx-qwen38-27b`(131072 窗口 + q8 KV 量化)跑 71 步长任务时 network error。
6
+ 其他 agent 定位结论:**不是内存拒绝,是「长上下文 prefill 过慢 → 客户端等待超时主动断连」**。
7
+
8
+ - 服务端无任何 507/内存拒绝,q8 生效,峰值 47.38GB < 48G 预算。
9
+ - 失败请求:prompt=127,912 / 131,072(窗口边缘),`request_cancelled=client_disconnected`,
10
+ 188.9s 内 0 token 输出。
11
+ - prefill 指数恶化:ttft 46.4s → 67.8s → 96.0s → 196.5s → 中断(189s 无输出)。
12
+ 根因:每轮全量历史 + 上一轮大段代码输出回灌,新增 prefill 从 7.9k 涨到 32k,
13
+ prefill 速度仅 ~165–185 tok/s,ttft 超过客户端等待阈值。
14
+
15
+ 调用 DeepSeek 官方 API 无此问题(窗口 1M + prefill 极快),说明瓶颈在「资源受限的本地模型」,
16
+ 必须做成**共性能力**:其他客户本地部署更小模型(窗口小/内存少)也会踩,不能只修 MacBook。
17
+
18
+ ## 目标
19
+
20
+ 让 MingDao 针对不同「参数 / 上下文窗口 / KV cache / 机器资源」的模型**灵活自适应**,
21
+ 小模型、低内存自动收紧参数适应,不撑爆、不误杀;本地慢 prefill 不被一刀切超时掐断。
22
+
23
+ ## 方案(本次实现)
24
+
25
+ ### 1. 模型能力解析(`src/model-caps.js` 新增)
26
+ 单一来源解析 `contextWindow / maxOutputTokens / isLocal`:
27
+ - 优先级:`customModels.<name>.contextWindow/maxOutputTokens` > 内置 preset > 兜底。
28
+ - 兜底:本地模型 32k、远程 128k(本地小模型宁可保守不撑爆)。
29
+ - `isLocalBaseUrl`:127.0.0.1 / localhost / 私网 IP 判定本地推理框架。
30
+
31
+ ### 2. 安全预算推导(`safeBudget`)
32
+ `budget = min(期望 contextBudget, 窗口×75% 舒适区, 窗口 − maxOutput − 2048 余量)`。
33
+ prompt 永不逼近窗口边缘(75% 以上 prefill 时间陡增),从根上避免 prefill 爆炸。
34
+ 对内置模型零影响(pro 200k / flash 128k 均远小于各自窗口 75%)。
35
+
36
+ ### 3. 分层超时(providers)
37
+ - 首 token 等待:本地 600s / 远程 300s(覆盖慢 prefill)。
38
+ - 流式空闲:有帧后 120s 无新帧即断(真挂死才断)。
39
+ - 总量:本地 30min / 远程 10min。
40
+ - `config.timeout.{firstTokenMs,streamIdleMs,totalMs}` 可覆盖;`parseStream` 按「帧到达」刷新
41
+ 空闲计时(prefill 阶段服务端可能先发 usage-only 帧,不误杀)。
42
+
43
+ ### 4. 边缘检测 + 强制压缩
44
+ 模型每轮上报真实 `prompt_tokens`,≥ 窗口 85% 时下一轮 `force` 压缩——即便非 DeepSeek 模型
45
+ 启发式计数低估(误差 ±2 倍)也强制触发(`compact.js` force 绕过触发线门槛)。
46
+
47
+ ### 5. 工具输出截断自适应
48
+ 单条工具结果按 `窗口/16` 封顶(最少 2000 字),小窗口不再整条回灌大段代码。
49
+
50
+ ### 6. 配置/UI 打通
51
+ - `customModels.<name>.contextWindow/maxOutputTokens`(WebUI 添加自定义模型表单新增两项)。
52
+ - `config.timeout.*`(设置 → 通用面板新增三项,秒为单位,留空自适应)。
53
+ - `/api/config`、`/api/models-config` 契约扩展。
54
+
55
+ ## 验收
56
+
57
+ - smoke 新增 model-caps(本地/远程判定、窗口兜底/显式声明、舒适区+输出余量预算)与
58
+ provider 分层超时(首 token vs 流式空闲)两组断言。
59
+ - 全绿:smoke 69 组 / e2e-local / e2e-web / e2e-schedule / api-contracts / bench;strict 0/0。
60
+ - 用户在 3820 用本地模型跑长任务验收,确认无 network error 中断后发布。
61
+
62
+ ## 非目标(顺延)
63
+
64
+ - 增量上下文(基线+变化)——自动续跑 + 语义检索已覆盖省钱与长程主场景。
65
+ - 本地窗口自动探测(从服务端 /models 读 max_model_len)——依赖各家推理框架能力,暂用显式声明。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -6,17 +6,22 @@ import { trimMessages, clampText, messageTokens, approxTokens } from './context.
6
6
  import { compactConversation } from './compact.js';
7
7
  import { buildToolSchemas, dispatch } from './tools/index.js';
8
8
  import { modelPreset } from './models.js';
9
+ import { resolveModelCaps, safeBudget, EDGE_RATIO } from './model-caps.js';
9
10
  import { makeTokenCounter } from './tokenizer.js';
10
11
  import { createHooks } from './hooks.js';
11
12
  import { createIO, style, C } from './ui.js';
12
13
  import { subagentModel } from './routing.js';
13
- import { writeAudit, redactSecrets } from './audit.js';
14
+ import { writeAudit } from './audit.js';
15
+ import { redactSecrets } from './redact.js';
14
16
  import { checkCostGuard, costGuardConfig, todayCost } from './cost-guard.js';
15
17
  import { estimateCost } from './pricing.js';
16
18
  import { resolveProviderConfig } from './providers/index.js';
17
19
 
18
20
  const MAX_STEPS = 24;
19
- const SUBAGENT_MAX_STEPS = 12;
21
+ // 子代理步数上限:审计/精读类只读子任务需要读多个文件 + 交叉引用,12 步易在「读不全」时被截断
22
+ // (v0.3.0 桌面版审计实测:多个只读子代理报「因达到步数上限停止读取」或「子任务无输出」)。
23
+ // 提到与主循环一致(24),只读子任务每步是 read/grep(输入便宜、无输出 token),成本增量可忽略。
24
+ const SUBAGENT_MAX_STEPS = 24;
20
25
 
21
26
  /**
22
27
  * 创建 Agent 循环(调用方只需传 provider/permission/io/modelName/workingDir,其余可选)
@@ -25,8 +30,19 @@ const SUBAGENT_MAX_STEPS = 12;
25
30
  */
26
31
  export function createAgent({ provider, permission, io, modelName, workingDir, cfg = {}, undoStore, maxSteps, mcp, onCompact, sessionRef }) {
27
32
  const preset = modelPreset(modelName) || {};
28
- const budget = cfg.contextBudget || preset.budgetTokens || 128000;
29
- const maxOutput = cfg.maxOutputTokens || preset.maxOutputTokens || 8192;
33
+ // v0.3.2 模型自适应:预算按模型上下文窗口推导(留输出余量 + 75% 舒适区),
34
+ // 自定义/本地小模型不再套 128000 默认撑爆窗口;prompt 永不逼近窗口边缘(prefill 不爆炸)。
35
+ const caps = resolveModelCaps(cfg, modelName);
36
+ // safeBudget 恒用:即使用户显式 contextBudget 也套「窗口−输出−余量」上限与 75% 舒适区,
37
+ // 防显式值撑爆窗口(本地模型窗口可能只有 32k/131k,用户却留了默认 128000)。
38
+ const budget = safeBudget(cfg, caps);
39
+ // maxOutput 也按窗口封顶:显式配超大 maxOutputTokens 时,prompt(预算)+output 仍不得越过窗口
40
+ // (预算已按 caps.maxOutputTokens 留余量,但显式值可能更大——此处兜底,防服务端截断/拒绝)
41
+ const maxOutput = Math.min(cfg.maxOutputTokens || caps.maxOutputTokens, Math.max(1024, caps.contextWindow - budget));
42
+ // v0.3.2 工具输出截断自适应:窗口越小截得越狠(单条工具结果按窗口 1/16 封顶,最少 2000 字),
43
+ // 但绝不超过旧默认 20000(大窗口模型如 1M 不因公式放大回灌、不推高成本)。
44
+ // 本地小模型(32k 窗口 → 2k 字)不再把大段代码/日志整条回灌,省 prompt 且不撑爆窗口。
45
+ const toolResultCap = Math.min(20000, Math.max(2000, Math.floor(caps.contextWindow / 16)));
30
46
  const temperature = cfg.temperature ?? preset.temperature ?? 0.6;
31
47
  const reasoningEffort = cfg.reasoningByModel?.[modelName] ?? cfg.reasoningEffort ?? preset.reasoningEffort?.default ?? undefined;
32
48
  const hooks = createHooks(cfg.hooks, workingDir);
@@ -34,8 +50,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
34
50
  // 会话级共享:调用方传入则复用(/model 切换、子代理均共享,undo 不丢失)
35
51
  const undo = undoStore || { backups: new Map() };
36
52
  const stepLimit = maxSteps || MAX_STEPS;
37
- // 只读工具集合(子代理只读模式 + 并行批次共用)
38
- const READONLY_TOOLS_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill']);
53
+ // 只读工具集合(子代理只读模式 + 并行批次共用)。v0.3.1 起含 git/fetch(只读、审计常用)
54
+ const READONLY_TOOLS_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'git', 'fetch']);
39
55
  // 精确 token 计数:DeepSeek 词表,其他模型回退启发式
40
56
  const count = makeTokenCounter(modelName);
41
57
  // MCP 工具集(每次取,服务器晚就绪也能在后续轮次出现)
@@ -45,7 +61,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
45
61
  const usedToolNames = new Set();
46
62
  // 省钱 B1(按需挂载):回合起始为「只读阶段」时只发只读工具(read/ls/glob/grep/skill/todo)
47
63
  // + 已用过的工具;检测到写意图(用户消息或模型明说需要写/改/建)后注入全量工具。
48
- const READONLY_TIER_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'todo']);
64
+ const READONLY_TIER_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'todo', 'git', 'fetch']);
49
65
  // 中英双语写意图(CodeArts 报告:纯中文正则让英文会话整回合只读死锁)
50
66
  const WRITE_INTENT_RE = /写|建|创|改|修|删|装|加|添|增|补|换|移|部署|执行|运行|实现|重构|生成|迁移|安装|更新|升级|发布|调整|优化|修复|提交|推送|打包|编译|测试|implement|fix|create|modify|update|delete|deploy|build|make|generate|install|write|refactor|migrate|test|run|commit|push|remove|add|change|patch/i;
51
67
  const hasWriteIntent = (/** @type {any} */ text) => WRITE_INTENT_RE.test(String(text || ''));
@@ -121,6 +137,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
121
137
  async function runTurn(/** @type {any} */ messages) {
122
138
  let steps = 0;
123
139
  let finish = null;
140
+ // v0.3.1 自动续跑(长程执行):跑满 stepLimit 步后不再直接中断,而是注入进度摘要再续跑,
141
+ // 最多 maxRounds 轮(默认 3,可用 cfg.maxRounds 调);审计/重构等大任务不再「一步中断」。
142
+ const maxRounds = Math.max(1, Number(cfg.maxRounds) || 3);
143
+ let round = 0;
124
144
  const usage = /** @type {{ prompt_tokens: number, completion_tokens: number, prompt_cache_hit_tokens?: number, prompt_cache_miss_tokens?: number }} */ ({ prompt_tokens: 0, completion_tokens: 0 });
125
145
  const startedAt = Date.now();
126
146
  // 回合性能指标(状态栏:LLM 时长 / 工具时长 / 首 token 延迟 / 步数)
@@ -130,6 +150,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
130
150
  // 省钱 B3(费用二级分账):推理 token 估算(按增量累计)与逐工具调用/耗时累加
131
151
  let reasoningTokens = 0;
132
152
  const toolStats = /** @type {Map<string, {calls: number, ms: number}>} */ (new Map());
153
+ const deliverables = /** @type {string[]} */ ([]); // 本回合 write/edit 成功落盘的文件路径(去重)
133
154
  const perf = () => ({
134
155
  llmMs: llmMsTotal,
135
156
  toolMs: toolMsTotal,
@@ -138,10 +159,14 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
138
159
  reasoningTokens,
139
160
  toolStats: [...toolStats.entries()].map(([tool, s]) => ({ tool, calls: s.calls, ms: s.ms })),
140
161
  usedModel: activeModel, // 省钱 B4:本回合实际使用模型(降级后归属它)
162
+ deliverables: [...deliverables], // v0.3.1:CLI/REPL 续跑检查点复用(此前 artifacts 恒空)
141
163
  });
142
164
  let aborted = false;
143
165
  let emptyRounds = 0; // 连续空/截断输出计数(防止无限续写)
144
166
  let currentAc = /** @type {any} */ (null);
167
+ // v0.3.2 边缘检测状态:模型上报 prompt_tokens 逼近窗口 → 下一轮强制压缩(见下方 compactConversation force)
168
+ let windowPressure = false;
169
+ let pressureWarned = false;
145
170
  // 省钱 B4(护栏降级):action='downgrade' 超限后本回合切换到便宜模型继续执行;
146
171
  // activeModel 是本回合实际使用的模型(分账/记录归属它),downgraded 保证只提示一次。
147
172
  let activeModel = modelName;
@@ -174,7 +199,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
174
199
  }
175
200
  };
176
201
  try {
177
- while (steps < stepLimit) {
202
+ for (round = 0; round < maxRounds; round++) {
203
+ steps = 0;
204
+ while (steps < stepLimit) {
178
205
  steps += 1;
179
206
  // 同回合只读工具去重(Hermes C4):相同 name+args 的只读调用只执行一次,结果复用回填
180
207
  const turnToolCache = new Map();
@@ -189,6 +216,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
189
216
  provider,
190
217
  executorModel: subagentModel(cfg, modelName),
191
218
  triggerRatio: cfg.compactTrigger, // 可配置触发线(默认 80%)
219
+ force: windowPressure, // v0.3.2:逼近窗口时强制压缩(忽略最小阈值门槛)
192
220
  });
193
221
  if (compacted) {
194
222
  messages.splice(0, messages.length, ...compacted.messages);
@@ -356,6 +384,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
356
384
  if (Number.isFinite(res.usage.prompt_cache_miss_tokens)) {
357
385
  usage.prompt_cache_miss_tokens = (usage.prompt_cache_miss_tokens || 0) + res.usage.prompt_cache_miss_tokens;
358
386
  }
387
+ // v0.3.2 边缘检测:模型上报的真实 prompt_tokens(含缓存命中)逼近窗口 85% 即标记——
388
+ // 下一轮强制激进压缩(force),不让 prompt 逼近窗口边缘导致 prefill 指数恶化。
389
+ const realPrompt = Number(res.usage.prompt_tokens);
390
+ if (Number.isFinite(realPrompt) && realPrompt > 0 && realPrompt >= caps.contextWindow * EDGE_RATIO) {
391
+ windowPressure = true;
392
+ if (!pressureWarned) {
393
+ pressureWarned = true;
394
+ io.print(style(`⚠ 上下文已逼近模型窗口(${realPrompt}/${caps.contextWindow},≥${Math.round(EDGE_RATIO * 100)}%),下轮将强制压缩历史防 prefill 恶化`, C.yellow));
395
+ }
396
+ }
359
397
  }
360
398
 
361
399
  if (res.toolCalls?.length) {
@@ -484,6 +522,11 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
484
522
  ts.ms += ms;
485
523
  toolStats.set(prep.name, ts);
486
524
  io.renderTool(prep.name, prep.args, result, ms);
525
+ // v0.3.1 P1-2 修复:write/edit 成功落盘的路径记入交付物(CLI/REPL 续跑检查点用)
526
+ if ((prep.name === 'write' || prep.name === 'edit') && prep.args?.path && result && result.ok !== false) {
527
+ const p = String(prep.args.path);
528
+ if (p && !deliverables.includes(p)) deliverables.push(p);
529
+ }
487
530
  if (prep.name === 'todo' && result?.todos) io.renderTodo(result.todos);
488
531
  hooks.post(prep.name, prep.args, typeof result === 'string' ? { output: result } : result).catch(() => {});
489
532
  // 审计(P3-5):执行结果摘要(含退出码/超时/输出大小)
@@ -512,7 +555,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
512
555
  }
513
556
  const text = typeof result === 'string' ? result : JSON.stringify(result); // 紧凑 JSON(评估 B3):嵌套结果省 10-20% 回填 token,且下轮按 prompt 重复计费
514
557
  const prefix = prep.cached ? '(与同回合相同调用结果一致,已复用)\n' : '';
515
- messages.push({ role: 'tool', tool_call_id: prep.tc.id, content: prefix + clampText(text) });
558
+ messages.push({ role: 'tool', tool_call_id: prep.tc.id, content: prefix + clampText(text, toolResultCap) });
516
559
  }
517
560
 
518
561
  let i = 0;
@@ -563,9 +606,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
563
606
  }
564
607
  }
565
608
  }
566
- // v0.2.8 步数预留收尾(对齐 DSH):仅剩最后一轮时,工具结果回填后追加收尾指令,
567
- // 让模型在末轮输出「总结文字 + 交付物清单」,而非跑满步数后静默结束。
568
- if (steps === stepLimit - 1) {
609
+ // v0.2.8 步数预留收尾(对齐 DSH):仅「最后一轮」的末步追加收尾指令,
610
+ // 让模型在末轮输出总结;中间轮不注入(交给自动续跑继续干活,而非提前收尾中断)。
611
+ if (steps === stepLimit - 1 && round === maxRounds - 1) {
569
612
  messages.push({
570
613
  role: 'user',
571
614
  content:
@@ -642,6 +685,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
642
685
  };
643
686
  }
644
687
  }
688
+ // v0.3.1 自动续跑(长程执行):还有剩余轮次且未中断 → 注入进度摘要直接续跑,不落收尾总结
689
+ if (round < maxRounds - 1 && !aborted) {
690
+ const art = deliverables.length ? '已交付文件:' + deliverables.join('、') + '。' : '';
691
+ messages.push({
692
+ role: 'user',
693
+ content: `(系统提示)已连续执行 ${stepLimit} 步工具操作,任务尚未完成,请继续完成剩余工作。${art}先核对已完成部分(勿重复),再做未完成的部分。`,
694
+ });
695
+ io.print(style(`♻ 步数上限,自动续跑第 ${round + 2} 轮…`, C.dim));
696
+ continue;
697
+ }
645
698
  io.endTurn();
646
699
  // 步数上限:清掉未执行的 tool_calls,避免下一轮/恢复后 API 400
647
700
  stripOrphanCalls();
@@ -679,6 +732,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
679
732
  } catch {}
680
733
  }
681
734
  return { text: null, reasoning: '', usage, steps, finish, truncated: true, aborted: false, capHit: true, durationMs: Date.now() - startedAt, perf: perf() };
735
+ }
736
+ // 理论不可达(for 循环末轮必 return);给 tsc 一个兜底,保证 runTurn 恒有返回值
737
+ return { text: null, reasoning: '', usage, steps, finish, truncated: true, aborted: false, capHit: true, durationMs: Date.now() - startedAt, perf: perf() };
682
738
  } finally {
683
739
  currentAc = null;
684
740
  offSigint();
package/src/audit.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { mingdaoHome, ensureHome } from './config.js';
10
+ import { redactSecrets } from './redact.js';
10
11
 
11
12
  const MAX_LINES = 20000;
12
13
  const KEEP_LINES = 10000;
@@ -16,10 +17,8 @@ export function auditFile() {
16
17
  return path.join(mingdaoHome(), 'audit.jsonl');
17
18
  }
18
19
 
19
- // 轻量脱敏:sk- API Key 掩码(审计日志可安全共享排查)
20
- export function redactSecrets(/** @type {any} */ text) {
21
- return String(text ?? '').replace(/(sk-[A-Za-z0-9_-]{6,})/g, 'sk-***');
22
- }
20
+ // 轻量脱敏(统一单一来源 v0.3.1 P1-1):sk-/ghp_ 等常见前缀掩码,见 src/redact.js
21
+ export { redactSecrets };
23
22
 
24
23
  export function writeAudit(/** @type {any} */ entry) {
25
24
  try {
package/src/cli.js CHANGED
@@ -34,7 +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
+ import { saveTaskStateMerge, clearTaskState } from './task-state.js';
38
38
  import { createPermission } from './permissions.js';
39
39
  import { buildSystemPrompt } from './prompts.js';
40
40
  import { listSkills, tamperedSkillNames } from './skills.js';
@@ -474,10 +474,10 @@ async function main() {
474
474
  }
475
475
  // v0.3.0 P0-2:单次提问跑满步数/中断落检查点(--continue 可续跑),正常完成清除
476
476
  if (res.capHit || res.aborted) {
477
- saveTaskState(path.basename(session.file), {
477
+ saveTaskStateMerge(path.basename(session.file), {
478
478
  goal: question,
479
479
  progress: res.text || '',
480
- artifacts: [],
480
+ artifacts: res.perf?.deliverables || [],
481
481
  status: res.capHit ? 'cap' : 'interrupted',
482
482
  updatedAt: new Date().toISOString(),
483
483
  });
@@ -7,22 +7,12 @@ import { fileURLToPath } from 'node:url';
7
7
  import { createIO, style, C } from '../ui.js';
8
8
  import { mingdaoHome, ensureHome, loadConfig } from '../config.js';
9
9
  import { credentialsPath } from '../credentials.js';
10
- import { listAudit, redactSecrets } from '../audit.js';
10
+ import { listAudit } from '../audit.js';
11
+ import { redactSensitive } from '../redact.js';
11
12
  import { projectMemoryFile, loadProjectMemory } from '../memory.js';
12
13
  import { listWorkspaces } from '../workspace.js';
13
14
  import { detectSandbox } from '../tools/bash.js';
14
15
 
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
16
  function tailFile(/** @type {any} */ file, /** @type {number} */ lines = 60) {
27
17
  try {
28
18
  const raw = fs.readFileSync(file, 'utf8');
@@ -54,7 +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
+ import { loadTaskState, saveTaskStateMerge, clearTaskState, resumePrompt } from '../task-state.js';
58
58
 
59
59
  const pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
60
60
 
@@ -682,10 +682,10 @@ export async function runRepl(ctx) {
682
682
  }
683
683
  // v0.3.0 P0-2:跑满步数(capHit)或中断(aborted)落检查点,正常完成清除
684
684
  if (res.capHit || res.aborted) {
685
- saveTaskState(path.basename(session.file), {
685
+ saveTaskStateMerge(path.basename(session.file), {
686
686
  goal: input,
687
687
  progress: res.text || '',
688
- artifacts: [],
688
+ artifacts: res.perf?.deliverables || [],
689
689
  status: res.capHit ? 'cap' : 'interrupted',
690
690
  updatedAt: new Date().toISOString(),
691
691
  });
package/src/compact.js CHANGED
@@ -65,7 +65,9 @@ export async function compactConversation(/** @type {any} */ { messages, budget,
65
65
  total += t;
66
66
  }
67
67
  const trigger = Number.isFinite(Number(triggerRatio)) ? Number(triggerRatio) : DEFAULT_TRIGGER_RATIO;
68
- if (total <= budget * trigger) return null;
68
+ // force(v0.3.2 边缘检测):逼近窗口时即便启发式计数低估(total 未达触发线)也强制压缩——
69
+ // 非 DeepSeek 模型启发式计数误差可达 ±2 倍,等它越过触发线时真实 prompt 可能已到窗口边缘。
70
+ if (total <= budget * trigger && !force) return null;
69
71
  // 保留边界:保留段(boundary..end)≤ budget×TARGET_RATIO(system 恒保留)
70
72
  let keepTokens = sizes[0] ?? 0;
71
73
  let boundary = messages.length;
package/src/memory.js CHANGED
@@ -8,6 +8,7 @@ import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { mingdaoHome, ensureHome } from './config.js';
10
10
  import { beijingParts } from './pricing.js';
11
+ import { tokenize } from './session-index.js';
11
12
 
12
13
  export function memoryFile() {
13
14
  return path.join(mingdaoHome(), 'AGENTS.md');
@@ -231,6 +232,32 @@ export function loadProjectMemory(/** @type {any} */ workingDir) {
231
232
  }
232
233
  }
233
234
 
235
+ // 项目记忆按条目读取(供语义检索)
236
+ export function loadProjectMemoryEntries(/** @type {any} */ workingDir) {
237
+ return loadProjectMemory(workingDir)
238
+ .split('\n')
239
+ .map((/** @type {any} */ l) => l.trim())
240
+ .filter(Boolean);
241
+ }
242
+
243
+ // 零依赖语义检索:与 query 分词(ASCII 词 + 中文 bigram)的 Jaccard 相似度取 TopN 相关条目。
244
+ // 用于「记忆/日志从最近 N 条 → 相关 N 条」(v0.3.1),换会话/换任务只注入相关记忆、省 token。
245
+ export function retrieveRelevant(/** @type {any[]} */ entries, /** @type {any} */ query, /** @type {number} */ topN = 5) {
246
+ const qTerms = new Set([...tokenize(String(query || '')).keys()]);
247
+ if (!qTerms.size) return entries.slice(0, topN);
248
+ const scored = entries.map((/** @type {any} */ e) => {
249
+ const text = typeof e === 'string' ? e : String(e.text || e.outcome || e.firstUser || e.content || '');
250
+ const terms = tokenize(text);
251
+ if (!terms.size) return { e, score: 0 };
252
+ let overlap = 0;
253
+ for (const t of terms.keys()) if (qTerms.has(t)) overlap += 1;
254
+ const union = new Set([...qTerms, ...terms.keys()]).size || 1;
255
+ return { e, score: overlap / union };
256
+ });
257
+ scored.sort((/** @type {any} */ a, /** @type {any} */ b) => b.score - a.score);
258
+ return scored.filter((/** @type {any} */ s) => s.score > 0).slice(0, topN).map((/** @type {any} */ s) => s.e);
259
+ }
260
+
234
261
  export function appendProjectMemory(/** @type {any} */ workingDir, /** @type {any} */ lines) {
235
262
  const add = lines.map((/** @type {any} */ l) => l.trim()).filter(Boolean);
236
263
  if (!add.length || !workingDir) return 0;
@@ -340,7 +367,14 @@ export async function extractAndAppendProjectMemory(/** @type {any} */ { cfg, pr
340
367
  export async function finalizeSession(/** @type {any} */ { cfg, provider, model, home, workingDir, messages, turns, lastText }) {
341
368
  const firstUser = messages.find((/** @type {any} */ m) => m.role === 'user')?.content || '';
342
369
  try {
343
- const wsName = null; // 由调用方通过 currentWorkspace 提供会更好,这里保持轻量
370
+ // v0.3.1 P1-3 修复:journal 归属信息——优先工作空间名,回退目录 basename(不再恒 null)
371
+ let wsName = null;
372
+ try {
373
+ const { workspaceForDir } = await import('./workspace.js');
374
+ wsName = workspaceForDir(workingDir)?.name || (workingDir ? path.basename(workingDir) : null);
375
+ } catch {
376
+ wsName = workingDir ? path.basename(workingDir) : null;
377
+ }
344
378
  appendJournal(home, {
345
379
  at: Date.now(),
346
380
  workspace: wsName,
@@ -0,0 +1,69 @@
1
+ // 模型能力解析(v0.3.2 本地模型自适应):
2
+ // 把「模型能装多少上下文、单次最多输出多少、是否本地部署」收敛成单一来源,
3
+ // 供预算推导、超时、工具截断统一引用——避免各层各自猜一份 128000 默认,
4
+ // 本地小模型(窗口小/内存少)自动收紧预算与超时,不撑爆、不误杀。
5
+ import { modelPreset } from './models.js';
6
+
7
+ // 兜底:未知模型默认上下文窗口。本地小模型宁可保守(不撑爆)也不乐观。
8
+ export const UNKNOWN_LOCAL_WINDOW = 32768;
9
+ export const UNKNOWN_REMOTE_WINDOW = 128000;
10
+ export const DEFAULT_MAX_OUTPUT = 8192;
11
+ // 输出余量:prompt 预算必须给模型输出留足空间,否则 prompt+output 越过窗口 → 服务端截断/拒绝。
12
+ export const OUTPUT_HEADROOM = 2048;
13
+ // 舒适区:prompt 预算最多占窗口 75%——逼近 75% 以上时 prefill 时间陡增(长上下文 dequant 开销),
14
+ // 留 25% 给输出 + 抗抖缓冲,从根上避免「prompt 到窗口边缘 → 首 token 等 200s+ 被客户端掐断」。
15
+ export const COMFORT_RATIO = 0.75;
16
+ // 边缘比:模型上报的真实 prompt_tokens 逼近窗口 85% 即视为「边缘」,本回合结束强制激进压缩。
17
+ export const EDGE_RATIO = 0.85;
18
+
19
+ /** 判断 baseUrl 是否指向本机/内网(本地推理框架部署)。 */
20
+ export function isLocalBaseUrl(/** @type {any} */ baseUrl) {
21
+ try {
22
+ const u = new URL(String(baseUrl || ''));
23
+ const h = u.hostname.toLowerCase();
24
+ if (!h) return false;
25
+ if (h === 'localhost' || h === '::1') return true;
26
+ const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
27
+ if (!m) return false;
28
+ const a = Number(m[1]);
29
+ const b = Number(m[2]);
30
+ return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * 解析模型能力。优先级:customModels.<name>.contextWindow/maxOutputTokens > 内置 preset > 兜底。
38
+ * @param {any} cfg
39
+ * @param {string} modelName
40
+ * @returns {{ contextWindow: number, maxOutputTokens: number, isLocal: boolean, budgetTokens: number|null, preset: any }}
41
+ */
42
+ export function resolveModelCaps(/** @type {any} */ cfg, /** @type {any} */ modelName) {
43
+ const preset = modelPreset(modelName);
44
+ const cm = (cfg?.customModels || {})[modelName] || {};
45
+ const baseUrl = cm.baseUrl || cfg?.baseUrl || '';
46
+ const isLocal = isLocalBaseUrl(baseUrl);
47
+ const contextWindow =
48
+ Number(cm.contextWindow) > 0
49
+ ? Number(cm.contextWindow)
50
+ : preset?.contextWindow || (isLocal ? UNKNOWN_LOCAL_WINDOW : UNKNOWN_REMOTE_WINDOW);
51
+ const maxOutputTokens =
52
+ Number(cm.maxOutputTokens) > 0
53
+ ? Number(cm.maxOutputTokens)
54
+ : preset?.maxOutputTokens || Math.min(DEFAULT_MAX_OUTPUT, Math.max(1024, Math.floor(contextWindow / 8)));
55
+ const budgetTokens = preset?.budgetTokens || null;
56
+ return { contextWindow, maxOutputTokens, isLocal, budgetTokens, preset };
57
+ }
58
+
59
+ /**
60
+ * 安全 prompt 预算:min(用户配置/预设, 窗口×75% 舒适区, 窗口−输出−余量)。
61
+ * 保证 prompt + maxOutput + 余量 ≤ contextWindow,且 prompt 不越舒适区(prefill 不爆炸)。
62
+ */
63
+ export function safeBudget(/** @type {any} */ cfg, /** @type {any} */ caps) {
64
+ const ceiling = Math.max(1024, caps.contextWindow - caps.maxOutputTokens - OUTPUT_HEADROOM);
65
+ const comfort = Math.max(1024, Math.floor(caps.contextWindow * COMFORT_RATIO));
66
+ const configured = Number(cfg?.contextBudget) > 0 ? Number(cfg.contextBudget) : null;
67
+ const base = configured ?? caps.budgetTokens ?? comfort;
68
+ return Math.max(1024, Math.min(base, ceiling, comfort));
69
+ }