mingdao-harness 0.2.2 → 0.2.4

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.
@@ -26,15 +26,15 @@
26
26
  9. cli.js 共享助手抽取(offpeak defer、mcpFacade 构建、run/run-worker 参数解析合一)
27
27
  - 验收:全绿 + 新增 fs-browse 越界与限流回归断言。
28
28
 
29
- ### Phase B「省钱第二轮」v0.2.3(约 2–3 天)
30
- 1. **工具 Schema 按需挂载**(每请求省 1–2K tokens):只读阶段只发 read/ls/glob/grep/skill + 本回合已用工具;写类工具首次调用时动态注入
31
- 2. **Batch 输入侧**:文本哈希去重(结果按 custom_id 回填全部位置)、单问超窗口预检报错、`--max-cost` 上限
32
- 3. **费用二级分账 + 看板**:cache-stats 增 reasoning/tool 维度与 byTool 累加;WebUI 仪表盘加模型/工具 Top5 与按天折线(零依赖 SVG
33
- 4. **护栏 downgrade**:`action:'downgrade'` —— 触顶后自动切 flash + 明确提示(默认仍 warn/block 可选)
34
- 5. **评测基准扩充**:压缩质量集(5 组长会话样本→压缩→关键事实追问断言)、路由 100 条标注集入 bench
35
- - 验收:bench 断言数 38→60+;覆盖率阈值上调至 60%。
36
-
37
- ### Phase C「结构渐进」v0.2.4(约 2 天)
29
+ ### Phase B「省钱第二轮」v0.2.4(已完成 ✅)
30
+ 1. **工具 Schema 按需挂载**:只读阶段只发 read/ls/glob/grep/skill/todo + 已用工具;模型表达写意图后下一轮注入全量(`cfg.schemaTier=false` 可关);已用工具深度瘦身(工具级 + 参数级描述清除,全用过降 48%)
31
+ 2. **Batch 输入侧**:文本哈希去重(结果按 custom_id 回填全部位置)、单问超窗口预检报错、`--max-cost` 提交前估算拦截
32
+ 3. **费用二级分账 + 看板**:cache-stats 增 reasoning / byTool 维度;WebUI 仪表盘加模型/工具 Top5 与近 14 天费用折线(零依赖 SVG);`mingdao cost report` 增按工具分账表
33
+ 4. **护栏 downgrade**:`action:'downgrade'` —— 触顶后自动切 flash 继续执行 + 明确提示(warn/block 仍可选;已是 flash 则按 block 处理)
34
+ 5. **评测基准扩充**:压缩质量集(5 组长会话样本 → 压缩 → 关键事实存活断言 + 尾部字节不变)、路由标注集扩至 100 条;bench 断言数 38 → 150+;覆盖率阈值 55% → 60%
35
+ - 版本说明:v0.2.3 0.2.2 桌面启动崩溃的紧急修复占用,Phase B 顺延为 v0.2.4。
36
+
37
+ ### Phase C「结构渐进」v0.2.5(约 2 天)
38
38
  1. routes/api.js 按域细分(config/sessions/sync/schedule/skills/workspace/misc 7 模块 + 共享中间件),每域配 API 契约测试
39
39
  2. cli.js REPL → commands/repl.js、worker → tasks/worker.js(e2e-local 16 项全绿做护栏)
40
40
  3. strict 棘轮削 300 条(先 credentials/permissions/config/sync-server/session 五个安全关键模块全量注解)
@@ -44,10 +44,12 @@
44
44
  1. **跨平台沙箱补位**:Windows Job Object + 降权 token(零依赖原生 API)、macOS seatbelt 规则;敏感命令平台无关拦截清单兜底
45
45
  2. **记忆语义检索**:零依赖实现(内存向量 + 余弦/哈希相似度;Node 22 可用 node:sqlite 持久化,Node 18 回退 JSONL 索引),记忆注入从「最近 3 条」升级为「相关 3 条」
46
46
  3. **WebUI 看板扩展**:预算燃尽预测、费用导出 CSV
47
- 4. GitHub Release 附件自动清理(如你提供 PAT)+ 发布清单文档化
47
+ 4. GitHub Release 附件自动清理 + 发布清单文档化
48
48
 
49
- ## 需要你拍板的三件事
49
+ ## 已落地(本轮 Phase D 部分项提前完成)
50
+ - **GitHub Release 附件自动清理(PAT 已接入)**:新增 `scripts/github-release-cleanup.mjs`——官网同步(收割 + sha512 MATCH 校验)后删除 Release 上的全部构建转运附件,只留指向官网的正文。用法:`MINGDAO_GITHUB_TOKEN=<PAT> node scripts/github-release-cleanup.mjs <版本号> [--dry-run]`。token 存本地 gitignored `.env`(`MINGDAO_GITHUB_TOKEN`,模式 600),不入库。验证:v0.2.2 官网同步后已清理 9 个附件,官网下载不受影响。
50
51
 
51
- 1. **Phase 顺序**:A→B→C→D 直接推进,还是把 B(省钱)提前到 A 之前?
52
- 2. **GitHub PAT**:是否提供(用于 Release 附件自动清理与发版自动化)?
53
- 3. **Phase D 裁剪**:跨平台沙箱与语义检索是否都进 0.3.0,还是只做其一?
52
+ ## 需要你拍板的事
53
+
54
+ 1. **Phase 顺序**:A→B→C→D 直接推进,还是把 B(省钱)提前到 A 之前?(PAT 已到位,Phase D 附件清理已基本完成)
55
+ 2. **Phase D 裁剪**:跨平台沙箱与语义检索是否都进 0.3.0,还是只做其一?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,9 +11,22 @@ description: 准备发布新版本、打包交付前使用
11
11
  2. **版本**:版本号统一(package.json / 配置 / 文档互相一致),遵循语义化版本。
12
12
  3. **变更说明**:changelog 用用户语言描述新增/修复/破坏性变更。
13
13
  4. **构建验证**:实际构建/打包一次,并在干净环境验证产物可安装可运行。
14
- 5. **敏感信息**:密钥、token、内网地址、个人信息不得进入仓库与产物(`grep -rE 'sk-[A-Za-z0-9]{20,}' .`)。
15
- 6. **回滚方案**:明确如何回退到上一版本(旧包/旧 tag 可恢复)。
16
- 7. **发布后冒烟**:发布完成后立即做一次端到端验证。
14
+ 5. **桌面版打包冒烟(0.2.2 事故护栏,发版必经)**:主进程模块求值必须真实跑通打包产物——
15
+ - CI 已内置:desktop.yml Linux 腿用 xvfb 运行 `dist/linux-unpacked/mingdao --no-sandbox`(`MINGDAO_DESKTOP_SMOKE=1`),模块求值通过输出 `MINGDAO_DESKTOP_SMOKE_OK` 并退出 0,否则 workflow 失败;
16
+ - 本地有显示器时可加验:`cd desktop && MINGDAO_DESKTOP_SMOKE=1 dist/linux-unpacked/mingdao --no-sandbox`(AppImage 加 `--appimage-extract-and-run`);
17
+ - 教训:0.2.2 三平台启动即崩(main.js 顶层调用 createLogWriter 漏导入),打包期不报错、运行期才炸——静态护栏(test/smoke.js「桌面主进程静态护栏」组)与打包冒烟双保险。
18
+ 6. **敏感信息**:密钥、token、内网地址、个人信息不得进入仓库与产物(`grep -rE 'sk-[A-Za-z0-9]{20,}' .`)。
19
+ 7. **回滚方案**:明确如何回退到上一版本(旧包/旧 tag 可恢复)。
20
+ 8. **发布后冒烟**:发布完成后立即做一次端到端验证。
21
+
22
+ ## 桌面版发布流水线(官网分发 + 镜像 + 转运附件回收)
23
+
24
+ 1. `npm version <v>`(preversion 自动跑 smoke/e2e/bench,version 钩子自动插 changelog,postversion 自动提交)→ 推 3 远端 + tag。
25
+ 2. `npm publish` → GitHub desktop.yml CI 全绿(含打包冒烟)。
26
+ 3. 服务器收割:`/tmp/harvest-<ver>.sh`(下载 7 安装包 + 2 yml,sha512 交叉校验 MATCH,再生成官网 3 份 feed)。
27
+ 4. 官网 site/index.html 刷新版本/大小/sha256 校验行并部署;BBS 随 deploy.sh 重启。
28
+ 5. gitee/gitcode 创建 Release(正文指向官网,无附件)。
29
+ 6. 官网同步确认后:`MINGDAO_GITHUB_TOKEN=<PAT> node scripts/github-release-cleanup.mjs <版本号>` 删除 GitHub 转运附件(先 --dry-run)。
17
30
 
18
31
  ## 输出
19
32
 
package/src/agent.js CHANGED
@@ -2,9 +2,9 @@
2
2
  // → PostToolUse 钩子 → 结果回填 → 循环,直到模型给出纯文本回复。
3
3
  // 附带:子代理(task 工具)、todo 清单状态、undo 备份仓、Ctrl+C 中断。
4
4
 
5
- import { trimMessages, clampText, messageTokens } from './context.js';
5
+ import { trimMessages, clampText, messageTokens, approxTokens } from './context.js';
6
6
  import { compactConversation } from './compact.js';
7
- import { toolSchemas, dispatch } from './tools/index.js';
7
+ import { buildToolSchemas, dispatch } from './tools/index.js';
8
8
  import { modelPreset } from './models.js';
9
9
  import { makeTokenCounter } from './tokenizer.js';
10
10
  import { createHooks } from './hooks.js';
@@ -39,6 +39,25 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
39
39
  const count = makeTokenCounter(modelName);
40
40
  // MCP 工具集(每次取,服务器晚就绪也能在后续轮次出现)
41
41
  const mcpSchemas = () => (mcp ? mcp.toolSchemas() : []);
42
+ // 省钱 B1:本会话已调用过的工具名(内置名/MCP 前缀名)——其 schema 在后续轮次省略 description,
43
+ // 模型已在消息历史里见过用途;未用过的保留完整描述。省输入 token(工具 schema 按需瘦身)。
44
+ const usedToolNames = new Set();
45
+ // 省钱 B1(按需挂载):回合起始为「只读阶段」时只发只读工具(read/ls/glob/grep/skill/todo)
46
+ // + 已用过的工具;检测到写意图(用户消息或模型明说需要写/改/建)后注入全量工具。
47
+ const READONLY_TIER_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'todo']);
48
+ const WRITE_INTENT_RE = /写|建|创|改|修|删|装|加|添|增|补|换|移|部署|执行|运行|实现|重构|生成|迁移|安装|更新|升级|发布|调整|优化|修复|提交|推送|打包|编译|测试/;
49
+ const hasWriteIntent = (/** @type {any} */ text) => WRITE_INTENT_RE.test(String(text || ''));
50
+ const toolsFor = (/** @type {boolean} */ readOnlyPhase) => {
51
+ const schemas = buildToolSchemas(usedToolNames, mcpSchemas());
52
+ if (!readOnlyPhase) return schemas;
53
+ return schemas.filter((/** @type {any} */ t) => {
54
+ const n = t?.function?.name;
55
+ if (!n) return true;
56
+ if (READONLY_TIER_SET.has(n) || usedToolNames.has(n)) return true;
57
+ if (n.startsWith('mcp__')) return mcp ? mcp.isReadonly(n) : false;
58
+ return false;
59
+ });
60
+ };
42
61
 
43
62
  // 子代理:全新上下文 + 同一 Provider/权限(提示带「子任务」标记),独立完成子任务后汇报
44
63
  async function spawnTask(prompt, { description = '', readOnly = false } = {}) {
@@ -104,10 +123,34 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
104
123
  let llmMsTotal = 0;
105
124
  let toolMsTotal = 0;
106
125
  let firstTokenAt = null;
107
- const perf = () => ({ llmMs: llmMsTotal, toolMs: toolMsTotal, firstTokenMs: firstTokenAt == null ? null : firstTokenAt - startedAt, steps });
126
+ // 省钱 B3(费用二级分账):推理 token 估算(按增量累计)与逐工具调用/耗时累加
127
+ let reasoningTokens = 0;
128
+ const toolStats = /** @type {Map<string, {calls: number, ms: number}>} */ (new Map());
129
+ const perf = () => ({
130
+ llmMs: llmMsTotal,
131
+ toolMs: toolMsTotal,
132
+ firstTokenMs: firstTokenAt == null ? null : firstTokenAt - startedAt,
133
+ steps,
134
+ reasoningTokens,
135
+ toolStats: [...toolStats.entries()].map(([tool, s]) => ({ tool, calls: s.calls, ms: s.ms })),
136
+ usedModel: activeModel, // 省钱 B4:本回合实际使用模型(降级后归属它)
137
+ });
108
138
  let aborted = false;
109
139
  let emptyRounds = 0; // 连续空/截断输出计数(防止无限续写)
110
140
  let currentAc = null;
141
+ // 省钱 B4(护栏降级):action='downgrade' 超限后本回合切换到便宜模型继续执行;
142
+ // activeModel 是本回合实际使用的模型(分账/记录归属它),downgraded 保证只提示一次。
143
+ let activeModel = modelName;
144
+ let downgraded = false;
145
+ // 省钱 B1:本回合只读阶段判定——最新用户消息无写意图则先只发只读工具,
146
+ // 模型明确表达写意图后(下一轮)注入全量。cfg.schemaTier=false 可关。
147
+ let readOnlyPhase = true;
148
+ if (cfg.schemaTier !== false) {
149
+ const lastUser = [...messages].reverse().find((m) => m?.role === 'user');
150
+ readOnlyPhase = !hasWriteIntent(lastUser?.content);
151
+ } else {
152
+ readOnlyPhase = false;
153
+ }
111
154
  // 整个回合注册一次 SIGINT:思考、工具执行、权限询问期间都能中断
112
155
  const offSigint = io.onSigint ? io.onSigint(() => { aborted = true; currentAc?.abort(); }) : () => {};
113
156
  const ctx = makeCtx();
@@ -168,13 +211,14 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
168
211
 
169
212
  // 护栏前置预估(Kimi P2-E):发送前按本轮最坏成本估算(trimmed prompt × 未命中输入价
170
213
  // + maxOutput × 输出价,峰谷按当前时段);「今日已用 + 最坏成本」超上限时发送前拦截,
171
- // 而不是等 200K 上下文的贵请求发出后才 block。
214
+ // 而不是等 200K 上下文的贵请求发出后才 block。action='downgrade'(省钱 B4)不在此拦截——
215
+ // 由降级流程接管:切到便宜模型后按 flash 价格重新估算,通常即可放行。
172
216
  if (cfg.costGuard) {
173
217
  const g = costGuardConfig();
174
- if (g && Number(g.dailyLimitYuan) > 0) {
218
+ if (g && Number(g.dailyLimitYuan) > 0 && g.action !== 'downgrade') {
175
219
  let promptTokens = 0;
176
220
  for (const m of sanitized) promptTokens += messageTokens(m, count);
177
- const worst = estimateCost(modelName, promptTokens, maxOutput, null, new Date());
221
+ const worst = estimateCost(activeModel, promptTokens, maxOutput, null, new Date());
178
222
  const used = todayCost();
179
223
  if (used + worst >= Number(g.dailyLimitYuan)) {
180
224
  stripOrphanCalls();
@@ -187,7 +231,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
187
231
  }
188
232
  }
189
233
 
190
- // 费用护栏(A2):每轮开始前按今日实际费用检查;block 时暂停本轮并明确告知
234
+ // 费用护栏(A2/B4):每轮开始前按今日实际费用检查;block 暂停本轮;
235
+ // downgrade 自动切换便宜模型继续执行(每回合只切一次,切换即粘滞)
191
236
  if (cfg.costGuard) {
192
237
  const guard = checkCostGuard();
193
238
  if (guard) {
@@ -206,7 +251,23 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
206
251
  perf: perf(),
207
252
  };
208
253
  }
209
- io.print(style(guard.message, C.yellow));
254
+ if (guard.downgrade && !downgraded) {
255
+ if (guard.downgradeModel !== activeModel) {
256
+ activeModel = guard.downgradeModel;
257
+ downgraded = true;
258
+ io.print(style(guard.message, C.yellow));
259
+ } else {
260
+ // 已经是降级目标模型:无法再降,按 block 处理
261
+ stripOrphanCalls();
262
+ return {
263
+ text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
264
+ note: `今日费用已达上限(实际 ¥${String(todayCost().toFixed(4))}),且已在最便宜模型上执行,已暂停——调整 config.costGuard 或明天自动恢复。`,
265
+ durationMs: Date.now() - startedAt, perf: perf(),
266
+ };
267
+ }
268
+ } else if (!guard.downgrade) {
269
+ io.print(style(guard.message, C.yellow));
270
+ }
210
271
  }
211
272
  }
212
273
 
@@ -221,9 +282,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
221
282
  const llmT0 = Date.now();
222
283
  try {
223
284
  res = await provider.chat({
224
- model: modelName,
285
+ model: activeModel,
225
286
  messages: sanitized,
226
- tools: [...toolSchemas(), ...mcpSchemas()],
287
+ tools: toolsFor(readOnlyPhase),
227
288
  temperature,
228
289
  maxTokens: maxOutput,
229
290
  reasoningEffort,
@@ -232,7 +293,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
232
293
  io.stopSpinner();
233
294
  if (firstTokenAt == null) firstTokenAt = Date.now(); // 首个增量即首 token
234
295
  if (d.text) io.writeText(d.text);
235
- if (d.reasoning) io.writeReasoning(d.reasoning);
296
+ if (d.reasoning) {
297
+ reasoningTokens += approxTokens(d.reasoning); // 省钱 B3:推理 token 估算(分账维度)
298
+ io.writeReasoning(d.reasoning);
299
+ }
236
300
  },
237
301
  });
238
302
  llmMsTotal += Date.now() - llmT0;
@@ -248,6 +312,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
248
312
  }
249
313
 
250
314
  finish = res.finish ?? finish;
315
+ // 省钱 B1:只读阶段中模型文字明确表达写意图 → 下一轮注入全量工具(多一轮,几乎无感)
316
+ if (readOnlyPhase && hasWriteIntent(res.text)) readOnlyPhase = false;
251
317
  if (res.usage) {
252
318
  usage.prompt_tokens += res.usage.prompt_tokens || 0;
253
319
  usage.completion_tokens += res.usage.completion_tokens || 0;
@@ -339,6 +405,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
339
405
  // 后续相同调用直接复用结果(仍逐个回填 tool 消息以保持 tool_call_id 配对)
340
406
  async function runTool(prep) {
341
407
  io.renderToolStart?.(prep.name, prep.args);
408
+ usedToolNames.add(prep.name); // 省钱 B1:执行过即标记,后续轮次省略其 description
342
409
  const dedupKey = !prep.isMcp && READONLY_TOOLS_SET.has(prep.name) ? prep.name + ':' + JSON.stringify(prep.args || {}) : null;
343
410
  if (dedupKey && turnToolCache.has(dedupKey)) {
344
411
  prep.cached = true;
@@ -363,6 +430,11 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
363
430
  function finishTool(prep, result, t0) {
364
431
  const ms = Date.now() - t0;
365
432
  toolMsTotal += ms;
433
+ // 省钱 B3:逐工具调用/耗时累加(费用二级分账的 byTool 维度)
434
+ const ts = toolStats.get(prep.name) || { calls: 0, ms: 0 };
435
+ ts.calls += 1;
436
+ ts.ms += ms;
437
+ toolStats.set(prep.name, ts);
366
438
  io.renderTool(prep.name, prep.args, result, ms);
367
439
  if (prep.name === 'todo' && result?.todos) io.renderTodo(result.todos);
368
440
  hooks.post(prep.name, prep.args, typeof result === 'string' ? { output: result } : result).catch(() => {});
package/src/batch.js CHANGED
@@ -8,9 +8,12 @@
8
8
 
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
+ import crypto from 'node:crypto';
11
12
  import { resolveProviderConfig } from './providers/index.js';
12
13
  import { estimateBatchCost, BATCH_DISCOUNT } from './pricing.js';
13
14
  import { recordCacheStats } from './cachestats.js';
15
+ import { approxTokens } from './context.js';
16
+ import { modelPreset } from './models.js';
14
17
 
15
18
  const DEFAULT_WINDOW = '24h';
16
19
  const DEFAULT_ENDPOINT = '/v1/chat/completions';
@@ -84,9 +87,11 @@ async function downloadResults(base, apiKey, batch) {
84
87
  throw new Error('批处理结果文件不可用(端点不支持或文件已过期)');
85
88
  }
86
89
 
87
- /** 执行一次批处理。questions: string[]。返回 { ok, outputFile, results, usage, cost, batchId }
88
- * @param {{ cfg: any, model: any, questions: any, workingDir?: string, maxTokens?: number, temperature?: any, signal?: any, onStatus?: any }} opts */
89
- export async function runBatch({ cfg, model, questions, workingDir = process.cwd(), maxTokens = 4096, temperature, signal, onStatus }) {
90
+ /** 执行一次批处理。questions: string[]。返回 { ok, outputFile, results, usage, cost, batchId, deduped }
91
+ * 省钱 B2:①文本哈希去重(重复问题只提交一次,结果回填全部位置);②单问超窗口预检(提交前报错,不烧钱);
92
+ * ③maxCost 预算上限(提交前按估算费用拦截,超出直接中止不提交)。
93
+ * @param {{ cfg: any, model: any, questions: any, workingDir?: string, maxTokens?: number, temperature?: any, signal?: any, onStatus?: any, maxCost?: number }} opts */
94
+ export async function runBatch({ cfg, model, questions, workingDir = process.cwd(), maxTokens = 4096, temperature, signal, onStatus, maxCost = 0 }) {
90
95
  const list = (questions || []).map((q) => String(q).trim()).filter(Boolean);
91
96
  if (!list.length) return { error: '没有可批处理的问题(每行一个问题)' };
92
97
  const pc = resolveProviderConfig(cfg, model);
@@ -97,6 +102,50 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
97
102
  // 不再携带完整系统提示(技能清单/用户记忆/AGENTS.md 对无工具批任务毫无意义,1000 问
98
103
  // 可白烧 50-80 万 token);改用一行精简角色提示,剩余能力损失为零。
99
104
  const systemPrompt = '你是 MingDao Harness 编程助手。直接针对每个问题给出准确、完整的答案,不要复述问题、不要解释过程。';
105
+
106
+ // —— 省钱 B2:文本哈希去重(trim 后逐字节一致视为重复)——
107
+ const positions = /** @type {Map<string, number[]>} */ (new Map()); // hash → [输入行下标...]
108
+ const unique = /** @type {string[]} */ ([]);
109
+ list.forEach((/** @type {string} */ q, /** @type {number} */ i) => {
110
+ const h = crypto.createHash('sha256').update(q).digest('hex');
111
+ if (!positions.has(h)) {
112
+ positions.set(h, [i]);
113
+ unique.push(q);
114
+ } else {
115
+ positions.get(h)?.push(i);
116
+ }
117
+ });
118
+ const posByUniqueIdx = unique.map((/** @type {string} */ q) => positions.get(crypto.createHash('sha256').update(q).digest('hex')) || []);
119
+ const deduped = list.length - unique.length;
120
+ if (deduped > 0) onStatus?.(`去重:${deduped} 条重复问题合并(${list.length} → ${unique.length} 条实际提交)`);
121
+
122
+ // —— 省钱 B2:单问超窗口预检(估算输入 + max_tokens 超过模型窗口 95% 即报错,绝不提交烧钱)——
123
+ const preset = modelPreset(model) || {};
124
+ const windowTokens = Number(preset.budgetTokens) || 128000;
125
+ const sysTokens = approxTokens(systemPrompt);
126
+ for (let i = 0; i < unique.length; i++) {
127
+ const est = sysTokens + approxTokens(unique[i]);
128
+ if (est + maxTokens > windowTokens * 0.95) {
129
+ return {
130
+ error: `第 ${posByUniqueIdx[i][0] + 1} 个问题超窗口:估算输入 ${est} + max_tokens ${maxTokens} > 模型窗口 ${windowTokens}(可 --max-tokens 调小或拆分问题)`,
131
+ };
132
+ }
133
+ }
134
+
135
+ // —— 省钱 B2:--max-cost 预算上限(提交前估算拦截)——
136
+ let estimatedCost = 0;
137
+ if (maxCost > 0) {
138
+ const estPrompt = unique.reduce((s, q) => s + sysTokens + approxTokens(q), 0);
139
+ const estCompletion = unique.length * Math.min(maxTokens, 2048); // 保守按平均 2K 输出估算
140
+ estimatedCost = estimateBatchCost(model, estPrompt, estCompletion);
141
+ if (estimatedCost > maxCost) {
142
+ return {
143
+ error: `预计费用 ≈¥${estimatedCost.toFixed(4)}(已按半价)超过 --max-cost ¥${maxCost},已中止提交。可缩小问题集或调高上限。`,
144
+ estimatedCost,
145
+ };
146
+ }
147
+ }
148
+
100
149
  const bodyTemplate = {
101
150
  model,
102
151
  messages: null, // 逐行填充
@@ -105,7 +154,7 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
105
154
  stream: false,
106
155
  };
107
156
  const jsonl =
108
- list
157
+ unique
109
158
  .map((q, i) =>
110
159
  JSON.stringify({
111
160
  custom_id: `md-${i}`,
@@ -164,24 +213,33 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
164
213
  }
165
214
  onStatus?.('下载结果…');
166
215
  const results = await downloadResults(base, apiKey, batch);
167
- // 汇总 usage 与费用(batch 半价)
216
+ // 汇总 usage 与费用(batch 半价);结果按 custom_id 回填到全部重复位置(省钱 B2)
168
217
  let prompt = 0;
169
218
  let completion = 0;
170
- const outputs = [];
219
+ const outputs = /** @type {Array<{id: string, content: string}>} */ (new Array(list.length));
171
220
  for (const r of results) {
172
221
  const body = r?.response?.body || {};
173
222
  prompt += body.usage?.prompt_tokens || 0;
174
223
  completion += body.usage?.completion_tokens || 0;
175
224
  const content = body.choices?.[0]?.message?.content ?? body.choices?.[0]?.text ?? (r?.response?.status_code !== 200 ? `(错误 ${r?.response?.status_code})` : '');
176
- outputs.push({ id: r.custom_id, content: String(content || '').trim() });
225
+ const m = /^md-(\d+)$/.exec(String(r.custom_id || ''));
226
+ const ui = m ? Number(m[1]) : -1;
227
+ const idxs = ui >= 0 && ui < posByUniqueIdx.length ? posByUniqueIdx[ui] : [];
228
+ for (const pos of idxs) {
229
+ outputs[pos] = { id: String(pos), content: String(content || '').trim() };
230
+ }
231
+ }
232
+ // 防御:服务端漏回的 custom_id 用占位补全,保证输出行数与输入行数一一对应
233
+ for (let i = 0; i < outputs.length; i++) {
234
+ if (!outputs[i]) outputs[i] = { id: String(i), content: '' };
177
235
  }
178
236
  const usage = { prompt_tokens: prompt, completion_tokens: completion };
179
237
  const cost = estimateBatchCost(model, prompt, completion);
180
238
  const outFile = path.join(workingDir, `mingdao-batch-result-${Date.now()}.jsonl`);
181
239
  fs.writeFileSync(outFile, outputs.map((o) => JSON.stringify(o)).join('\n') + '\n');
182
240
  recordCacheStats({ model, prompt, completion, hit: null, miss: null, cost, saved: null, batch: true });
183
- onStatus?.(`完成:${outputs.length} 条结果`);
184
- return { ok: true, batchId: batch.id, outputFile: outFile, results: outputs, usage, cost, discount: BATCH_DISCOUNT };
241
+ onStatus?.(`完成:${outputs.length} 条结果${deduped ? `(去重合并 ${deduped} 条)` : ''}`);
242
+ return { ok: true, batchId: batch.id, outputFile: outFile, results: outputs, usage, cost, discount: BATCH_DISCOUNT, deduped, estimatedCost };
185
243
  } catch (err) {
186
244
  // 批处理端点不支持(404/405 等)→ 明确告知,不静默
187
245
  return { error: `批处理不可用:${err?.message || err}(该服务商可能不支持 Batch API,可用 config.batchBaseUrl 指定支持的网关)` };
package/src/cachestats.js CHANGED
@@ -33,6 +33,8 @@ export function recordCacheStats(entry) {
33
33
  llmMs: entry.llmMs ?? undefined, // 模型调用总耗时(状态栏 LLM 时长/tok-s)
34
34
  toolMs: entry.toolMs ?? undefined, // 工具执行总耗时(状态栏 工具调用时长)
35
35
  firstTokenMs: entry.firstTokenMs ?? undefined, // 首 token 延迟(状态栏平均首 token)
36
+ reasoning: entry.reasoning ?? undefined, // 省钱 B3:推理 token 估算(费用二级分账维度)
37
+ byTool: Array.isArray(entry.byTool) ? entry.byTool : undefined, // 省钱 B3:逐工具 {tool,calls,ms}
36
38
  });
37
39
  fs.appendFileSync(cacheStatsFile(), line + '\n');
38
40
  cacheStatsCount += 1;
@@ -125,6 +127,8 @@ export function recordUsage(modelName, usage, perf = null) {
125
127
  llmMs: perf?.llmMs ?? undefined,
126
128
  toolMs: perf?.toolMs ?? undefined,
127
129
  firstTokenMs: perf?.firstTokenMs ?? undefined,
130
+ reasoning: perf?.reasoningTokens ?? undefined, // 省钱 B3
131
+ byTool: perf?.toolStats ?? undefined, // 省钱 B3
128
132
  });
129
133
  }
130
134
 
@@ -139,9 +143,12 @@ export function formatCacheSummary(sum) {
139
143
  }
140
144
 
141
145
  // 分账统计(评估 /cost 升级):按模型分账、今日费用、batch 半价任务、节省归因
146
+ // 省钱 B3:新增 reasoning 维度、byTool 逐工具累加、byDay 近 14 天按日折线数据
142
147
  export function costBreakdown() {
143
148
  const entries = listCacheStats(100000);
144
149
  const byModel = new Map();
150
+ const byTool = /** @type {Map<string, {calls: number, ms: number}>} */ (new Map());
151
+ const byDay = /** @type {Map<string, number>} */ (new Map()); // 'MM-DD' → cost(近 14 个北京日,含 0 值日)
145
152
  const start = beijingDayStart().getTime();
146
153
  let totalCost = 0;
147
154
  let totalSaved = 0;
@@ -149,21 +156,45 @@ export function costBreakdown() {
149
156
  let batchCost = 0;
150
157
  let hit = 0;
151
158
  let miss = 0;
159
+ let reasoning = 0;
152
160
  for (const e of entries) {
153
161
  totalCost += e.cost || 0;
154
162
  totalSaved += e.saved || 0;
155
163
  hit += e.hit || 0;
156
164
  miss += e.miss || 0;
165
+ reasoning += e.reasoning || 0;
157
166
  if (e.batch) batchCost += e.cost || 0;
158
167
  if (e.at >= start) today += e.cost || 0;
159
- const m = byModel.get(e.model) || { prompt: 0, completion: 0, cost: 0, saved: 0, turns: 0, batchTurns: 0 };
168
+ const m = byModel.get(e.model) || { prompt: 0, completion: 0, cost: 0, saved: 0, turns: 0, batchTurns: 0, reasoning: 0 };
160
169
  m.prompt += e.prompt || 0;
161
170
  m.completion += e.completion || 0;
162
171
  m.cost += e.cost || 0;
163
172
  m.saved += e.saved || 0;
164
173
  m.turns += 1;
174
+ m.reasoning += e.reasoning || 0;
165
175
  if (e.batch) m.batchTurns += 1;
166
176
  byModel.set(e.model, m);
177
+ if (Array.isArray(e.byTool)) {
178
+ for (const t of e.byTool) {
179
+ const tt = byTool.get(t.tool) || { calls: 0, ms: 0 };
180
+ tt.calls += t.calls || 0;
181
+ tt.ms += t.ms || 0;
182
+ byTool.set(t.tool, tt);
183
+ }
184
+ }
185
+ if (e.at) {
186
+ const p = beijingParts(new Date(e.at));
187
+ const key = `${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`;
188
+ byDay.set(key, (byDay.get(key) || 0) + (e.cost || 0));
189
+ }
190
+ }
191
+ // 补全近 14 天(含无记录的 0 值日),保证折线 x 轴连续
192
+ const days = /** @type {Array<{day: string, cost: number}>} */ ([]);
193
+ for (let i = 13; i >= 0; i--) {
194
+ const d = new Date(start - i * 86400000);
195
+ const p = beijingParts(d);
196
+ const key = `${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`;
197
+ days.push({ day: key, cost: byDay.get(key) || 0 });
167
198
  }
168
199
  return {
169
200
  totalCost,
@@ -173,11 +204,15 @@ export function costBreakdown() {
173
204
  hit,
174
205
  miss,
175
206
  rate: hit + miss > 0 ? hit / (hit + miss) : null,
207
+ reasoning,
176
208
  byModel: [...byModel.entries()].map(([model, m]) => ({ model, ...m })).sort((a, b) => b.cost - a.cost),
209
+ byTool: [...byTool.entries()].map(([tool, t]) => ({ tool, ...t })).sort((a, b) => b.ms - a.ms),
210
+ byDay: days,
177
211
  };
178
212
  }
179
213
 
180
214
  // 月度费用报告(/cost 导出):按北京时间月份聚合,month 形如 'YYYY-MM',缺省返回全部月份
215
+ // 省钱 B3:月度聚合增加 reasoning 与 byTool 维度
181
216
  export function costMonthlyReport(month) {
182
217
  const entries = listCacheStats(100000);
183
218
  const want = String(month || '').trim();
@@ -188,7 +223,7 @@ export function costMonthlyReport(month) {
188
223
  if (want && key !== want) continue;
189
224
  let m = months.get(key);
190
225
  if (!m) {
191
- m = { month: key, cost: 0, saved: 0, prompt: 0, completion: 0, hit: 0, miss: 0, batchCost: 0, days: new Map(), models: new Map() };
226
+ m = { month: key, cost: 0, saved: 0, prompt: 0, completion: 0, hit: 0, miss: 0, batchCost: 0, reasoning: 0, days: new Map(), models: new Map(), tools: new Map() };
192
227
  months.set(key, m);
193
228
  }
194
229
  m.cost += e.cost || 0;
@@ -197,6 +232,7 @@ export function costMonthlyReport(month) {
197
232
  m.completion += e.completion || 0;
198
233
  m.hit += e.hit || 0;
199
234
  m.miss += e.miss || 0;
235
+ m.reasoning += e.reasoning || 0;
200
236
  if (e.batch) m.batchCost += e.cost || 0;
201
237
  const day = String(p.day).padStart(2, '0');
202
238
  m.days.set(day, (m.days.get(day) || 0) + (e.cost || 0));
@@ -206,6 +242,14 @@ export function costMonthlyReport(month) {
206
242
  mm.cost += e.cost || 0;
207
243
  mm.turns += 1;
208
244
  m.models.set(e.model, mm);
245
+ if (Array.isArray(e.byTool)) {
246
+ for (const t of e.byTool) {
247
+ const tt = m.tools.get(t.tool) || { calls: 0, ms: 0 };
248
+ tt.calls += t.calls || 0;
249
+ tt.ms += t.ms || 0;
250
+ m.tools.set(t.tool, tt);
251
+ }
252
+ }
209
253
  }
210
254
  return [...months.values()]
211
255
  .sort((a, b) => a.month.localeCompare(b.month))
@@ -219,7 +263,9 @@ export function costMonthlyReport(month) {
219
263
  miss: m.miss,
220
264
  rate: m.hit + m.miss > 0 ? m.hit / (m.hit + m.miss) : null,
221
265
  batchCost: m.batchCost,
266
+ reasoning: m.reasoning,
222
267
  days: [...m.days.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([day, cost]) => ({ day, cost })),
223
268
  models: [...m.models.entries()].map(([model, x]) => ({ model, ...x })).sort((a, b) => b.cost - a.cost),
269
+ tools: [...m.tools.entries()].map(([tool, t]) => ({ tool, ...t })).sort((a, b) => b.ms - a.ms),
224
270
  }));
225
271
  }
package/src/cli.js CHANGED
@@ -230,7 +230,7 @@ async function runWorkerTask(id, question, { permission, model, offpeak }) {
230
230
  } catch {}
231
231
  }
232
232
  const finalStatus = res.truncated ? 'failed' : res.aborted ? 'killed' : 'done';
233
- recordUsage(modelName, res.usage, res.perf);
233
+ recordUsage(res.perf?.usedModel || modelName, res.usage, res.perf);
234
234
  finish({
235
235
  status: finalStatus,
236
236
  text: (res.text || '').slice(0, 2000),
@@ -565,12 +565,12 @@ async function main() {
565
565
  session: session.name,
566
566
  })
567
567
  );
568
- recordUsage(modelName, res.usage, res.perf);
568
+ recordUsage(res.perf?.usedModel || modelName, res.usage, res.perf);
569
569
  process.exitCode = res.truncated || res.aborted ? 1 : 0;
570
570
  } else {
571
571
  io.printUsageLine({ modelName, usage: res.usage, durationMs: res.durationMs });
572
572
  if (res.aborted) io.print(style('(已中断)', C.dim));
573
- recordUsage(modelName, res.usage, res.perf);
573
+ recordUsage(res.perf?.usedModel || modelName, res.usage, res.perf);
574
574
  process.exitCode = res.truncated ? 1 : 0;
575
575
  }
576
576
  } catch (err) {
@@ -1094,7 +1094,7 @@ async function main() {
1094
1094
  lastUsage = res.usage;
1095
1095
  lastText = res.text || lastText;
1096
1096
  stats.turns += 1;
1097
- recordUsage(modelName, res.usage, res.perf);
1097
+ recordUsage(res.perf?.usedModel || modelName, res.usage, res.perf);
1098
1098
  stats.promptTokens += res.usage.prompt_tokens || 0;
1099
1099
  stats.completionTokens += res.usage.completion_tokens || 0;
1100
1100
  const fresh = messages.slice(persisted);
@@ -33,7 +33,7 @@ export async function handleUpdateFamily(cmd, args) {
33
33
  return true;
34
34
  }
35
35
 
36
- // Batch API 半价批处理(A1):mingdao batch <问题文件|-> [--model 名] [--max-tokens n]
36
+ // Batch API 半价批处理(A1/B2):mingdao batch <问题文件|-> [--model 名] [--max-tokens n] [--max-cost 元]
37
37
  if (cmd === 'batch') {
38
38
  const { runBatch } = await import('../batch.js');
39
39
  const cfgB = loadConfig();
@@ -44,16 +44,18 @@ export async function handleUpdateFamily(cmd, args) {
44
44
  }
45
45
  let model = cfgB.model || 'deepseek-v4-flash';
46
46
  let maxTokens = 4096;
47
+ let maxCost = 0; // 省钱 B2:预算上限(元),提交前按估算拦截
47
48
  let srcFile = null;
48
49
  for (let i = 0; i < args.length; i++) {
49
50
  const a = args[i];
50
51
  if (a === '--model') model = args[++i];
51
52
  else if (a === '--max-tokens') maxTokens = Number(args[++i]) || 4096;
53
+ else if (a === '--max-cost') maxCost = Number(args[++i]) || 0;
52
54
  else if (a === '--out') { i += 1; /* 输出默认落工作目录 */ }
53
55
  else if (!srcFile) srcFile = a;
54
56
  }
55
57
  if (!srcFile) {
56
- console.log('用法:mingdao batch <问题文件|-> 每行一个问题(--model 名 --max-tokens n');
58
+ console.log('用法:mingdao batch <问题文件|-> 每行一个问题(--model 名 --max-tokens n --max-cost 元)');
57
59
  process.exitCode = 1;
58
60
  return true;
59
61
  }
@@ -80,6 +82,7 @@ export async function handleUpdateFamily(cmd, args) {
80
82
  questions,
81
83
  workingDir: process.cwd(),
82
84
  maxTokens,
85
+ maxCost,
83
86
  signal: ac.signal,
84
87
  onStatus: (st) => console.log(' ' + st),
85
88
  });
@@ -90,6 +93,7 @@ export async function handleUpdateFamily(cmd, args) {
90
93
  return true;
91
94
  }
92
95
  console.log(`✓ 完成 ${r.results.length} 条 · ↑${r.usage.prompt_tokens} ↓${r.usage.completion_tokens} tokens · 费用 ≈¥${r.cost.toFixed(5)}(已按半价)`);
96
+ if (r.deduped) console.log(` (去重合并 ${r.deduped} 条重复问题,结果已回填全部位置)`);
93
97
  console.log(` 结果文件:${r.outputFile}`);
94
98
  console.log(` 任务 ID:${r.batchId}(结果已计入 /cost 分账)`);
95
99
  return true;
@@ -123,13 +127,21 @@ export async function handleUpdateFamily(cmd, args) {
123
127
  md.push('');
124
128
  md.push(`- 实际费用 ≈¥${r.cost.toFixed(5)}${r.batchCost > 0 ? `(含 Batch 半价任务 ≈¥${r.batchCost.toFixed(5)})` : ''}`);
125
129
  md.push(`- 相比全未命中已节省 ≈¥${r.saved.toFixed(5)}${r.rate != null ? ` · 缓存命中率 ${(r.rate * 100).toFixed(0)}%` : ''}`);
126
- md.push(`- Tokens:↑${r.prompt} / ↓${r.completion}`);
130
+ md.push(`- Tokens:↑${r.prompt} / ↓${r.completion}${r.reasoning ? ` · 推理 ${r.reasoning}` : ''}`);
127
131
  md.push('');
128
132
  md.push('## 按模型分账');
129
133
  md.push('');
130
134
  md.push('| 模型 | 轮次 | ↑prompt | ↓completion | 费用 |');
131
135
  md.push('| --- | --- | --- | --- | --- |');
132
136
  for (const m of r.models) md.push(`| ${m.model} | ${m.turns} | ${m.prompt} | ${m.completion} | ≈¥${m.cost.toFixed(5)} |`);
137
+ if (r.tools.length) {
138
+ md.push('');
139
+ md.push('## 按工具分账');
140
+ md.push('');
141
+ md.push('| 工具 | 调用 | 耗时(ms) |');
142
+ md.push('| --- | --- | --- |');
143
+ for (const t of r.tools.slice(0, 10)) md.push(`| ${t.tool} | ${t.calls} | ${t.ms} |`);
144
+ }
133
145
  md.push('');
134
146
  md.push('## 每日费用');
135
147
  md.push('');
package/src/cost-guard.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // 费用护栏(四报告共识 A2/Kimi P-1:把峰谷定价从「展示」升级为「主动约束」):
2
- // config.costGuard = { dailyLimitYuan: 10, warnAtYuan: 8, action: 'warn'|'block' }
2
+ // config.costGuard = { dailyLimitYuan: 10, warnAtYuan: 8, action: 'warn'|'block'|'downgrade', downgradeModel?: 'deepseek-v4-flash' }
3
3
  // 按北京时间自然日累计 cache-stats 实际费用(含 batch 半价与缓存折扣后的真实口径)。
4
- // Agent 每轮开始前检查:超 warn 线提醒、超 limit action=block 时暂停执行。
4
+ // Agent 每轮开始前检查:超 warn 线提醒;超 limit 时按 action 处理——
5
+ // block:暂停执行;downgrade(省钱 B4):自动切换便宜模型继续跑(默认 deepseek-v4-flash)。
5
6
 
6
7
  import fs from 'node:fs';
7
8
  import { listCacheStats, cacheStatsFile } from './cachestats.js';
@@ -41,18 +42,19 @@ export function costGuardStatus() {
41
42
  const cost = todayCost();
42
43
  const limit = Number(g.dailyLimitYuan) || 0;
43
44
  const warnAt = Number(g.warnAtYuan) || (limit > 0 ? limit * 0.8 : 0);
44
- const action = g.action === 'block' ? 'block' : 'warn';
45
+ const action = g.action === 'block' || g.action === 'downgrade' ? g.action : 'warn';
45
46
  return {
46
47
  cost,
47
48
  limit,
48
49
  warnAt,
49
50
  action,
51
+ downgradeModel: String(g.downgradeModel || 'deepseek-v4-flash'),
50
52
  overWarn: limit > 0 && cost >= warnAt,
51
53
  overLimit: limit > 0 && cost >= limit,
52
54
  };
53
55
  }
54
56
 
55
- // Agent 每轮开始前调用:返回 null 放行;blocked=true 表示应暂停本轮
57
+ // Agent 每轮开始前调用:返回 null 放行;blocked=true 应暂停本轮;downgrade=true 应切换便宜模型
56
58
  export function checkCostGuard() {
57
59
  const st = costGuardStatus();
58
60
  if (!st) return null;
@@ -62,10 +64,18 @@ export function checkCostGuard() {
62
64
  message: `今日费用已达上限 ¥${st.limit.toFixed(2)}(实际 ¥${st.cost.toFixed(4)}),已暂停执行——调整 config.costGuard 或明天自动恢复。`,
63
65
  };
64
66
  }
67
+ if (st.overLimit && st.action === 'downgrade') {
68
+ return {
69
+ blocked: false,
70
+ downgrade: true,
71
+ downgradeModel: st.downgradeModel,
72
+ message: `⚠ 费用护栏:今日已用 ¥${st.cost.toFixed(4)} 超上限 ¥${st.limit.toFixed(2)}——已自动降级到便宜模型 ${st.downgradeModel} 继续执行(config.costGuard.action 可改回 warn/block)。`,
73
+ };
74
+ }
65
75
  if (st.overWarn) {
66
76
  return {
67
77
  blocked: false,
68
- message: `⚠ 费用护栏:今日已用 ¥${st.cost.toFixed(4)} / 上限 ¥${st.limit.toFixed(2)}${st.action === 'block' ? '(到达即暂停)' : '(仅提醒)'}。`,
78
+ message: `⚠ 费用护栏:今日已用 ¥${st.cost.toFixed(4)} / 上限 ¥${st.limit.toFixed(2)}${st.action !== 'warn' ? `(${st.action === 'block' ? '到达即暂停' : '到达自动降级 ' + st.downgradeModel})` : '(仅提醒)'}。`,
69
79
  };
70
80
  }
71
81
  return null;
@@ -11,9 +11,9 @@ export const READONLY_TOOLS = new Set(['read', 'glob', 'grep', 'ls', 'skill']);
11
11
  const READ_SCHEMA = {
12
12
  type: 'object',
13
13
  properties: {
14
- path: { type: 'string', description: '要读取的文件路径(相对或绝对)。' },
14
+ path: { type: 'string', description: '文件路径(相对或绝对)。' },
15
15
  offset: { type: 'integer', description: '起始行号,默认 1。' },
16
- limit: { type: 'integer', description: '最大返回行数,默认 400。' },
16
+ limit: { type: 'integer', description: '最大行数,默认 400。' },
17
17
  },
18
18
  required: ['path'],
19
19
  };
@@ -21,7 +21,7 @@ const READ_SCHEMA = {
21
21
  const WRITE_SCHEMA = {
22
22
  type: 'object',
23
23
  properties: {
24
- path: { type: 'string', description: '要创建或覆盖的文件路径。' },
24
+ path: { type: 'string', description: '文件路径。' },
25
25
  content: { type: 'string', description: '完整文件内容(UTF-8)。' },
26
26
  },
27
27
  required: ['path', 'content'],
@@ -30,10 +30,10 @@ const WRITE_SCHEMA = {
30
30
  const EDIT_SCHEMA = {
31
31
  type: 'object',
32
32
  properties: {
33
- path: { type: 'string', description: '要修改的文件路径。' },
34
- old_string: { type: 'string', description: '文件中真实存在的原文片段,必须逐字符完全一致。' },
35
- new_string: { type: 'string', description: '替换后的文本。' },
36
- replace_all: { type: 'boolean', description: '是否替换所有匹配处,默认 false。' },
33
+ path: { type: 'string', description: '文件路径。' },
34
+ old_string: { type: 'string', description: '原文片段,必须逐字一致。' },
35
+ new_string: { type: 'string', description: '替换后文本。' },
36
+ replace_all: { type: 'boolean', description: '替换所有匹配,默认 false。' },
37
37
  },
38
38
  required: ['path', 'old_string', 'new_string'],
39
39
  };
@@ -41,15 +41,15 @@ const EDIT_SCHEMA = {
41
41
  const LS_SCHEMA = {
42
42
  type: 'object',
43
43
  properties: {
44
- path: { type: 'string', description: '目录路径,默认当前工作目录。' },
44
+ path: { type: 'string', description: '目录路径,默认当前目录。' },
45
45
  },
46
46
  };
47
47
 
48
48
  const GLOB_SCHEMA = {
49
49
  type: 'object',
50
50
  properties: {
51
- pattern: { type: 'string', description: '通配符模式,如 src/**/*.js、*.md。' },
52
- path: { type: 'string', description: '搜索根目录,默认当前工作目录。' },
51
+ pattern: { type: 'string', description: '通配符模式,如 src/**/*.js。' },
52
+ path: { type: 'string', description: '搜索根目录。' },
53
53
  },
54
54
  required: ['pattern'],
55
55
  };
@@ -58,8 +58,8 @@ const GREP_SCHEMA = {
58
58
  type: 'object',
59
59
  properties: {
60
60
  pattern: { type: 'string', description: '正则表达式。' },
61
- path: { type: 'string', description: '搜索根目录,默认当前工作目录。' },
62
- include: { type: 'string', description: '文件名通配符过滤,如 *.js。' },
61
+ path: { type: 'string', description: '搜索根目录。' },
62
+ include: { type: 'string', description: '文件名过滤,如 *.js。' },
63
63
  },
64
64
  required: ['pattern'],
65
65
  };
@@ -67,7 +67,7 @@ const GREP_SCHEMA = {
67
67
  const BASH_SCHEMA = {
68
68
  type: 'object',
69
69
  properties: {
70
- command: { type: 'string', description: '要执行的 shell 命令。执行前会请求用户授权。' },
70
+ command: { type: 'string', description: 'shell 命令。执行前需用户授权。' },
71
71
  timeout: { type: 'integer', description: '超时秒数,默认 120。' },
72
72
  },
73
73
  required: ['command'],
@@ -76,16 +76,16 @@ const BASH_SCHEMA = {
76
76
  const SKILL_SCHEMA = {
77
77
  type: 'object',
78
78
  properties: {
79
- name: { type: 'string', description: '技能名;省略时列出所有可用技能。' },
79
+ name: { type: 'string', description: '技能名;省略则列出全部。' },
80
80
  },
81
81
  };
82
82
 
83
83
  const TASK_SCHEMA = {
84
84
  type: 'object',
85
85
  properties: {
86
- description: { type: 'string', description: '子任务一句话描述(用于进度展示)。' },
87
- prompt: { type: 'string', description: '交给子代理的完整任务说明(它没有当前对话上下文)。' },
88
- readOnly: { type: 'boolean', description: '只读调研任务(不写文件不执行命令)。多个 readOnly 子任务会并行执行。' },
86
+ description: { type: 'string', description: '子任务一句话描述。' },
87
+ prompt: { type: 'string', description: '子代理的完整任务说明。' },
88
+ readOnly: { type: 'boolean', description: '只读调研任务,可并行。' },
89
89
  },
90
90
  required: ['prompt'],
91
91
  };
@@ -112,7 +112,7 @@ const TODO_SCHEMA = {
112
112
  const UNDO_SCHEMA = {
113
113
  type: 'object',
114
114
  properties: {
115
- path: { type: 'string', description: '要撤销的文件;省略时撤销最近一次修改的文件。' },
115
+ path: { type: 'string', description: '要撤销的文件;省略撤销最近一次。' },
116
116
  },
117
117
  };
118
118
 
@@ -121,7 +121,7 @@ const TOOLS = [
121
121
  type: 'function',
122
122
  function: {
123
123
  name: 'read',
124
- description: '读取文本文件,返回带行号的内容。修改文件前务必先读取。',
124
+ description: '读取文件内容(带行号)。修改文件前必须先读。',
125
125
  parameters: READ_SCHEMA,
126
126
  },
127
127
  },
@@ -129,7 +129,7 @@ const TOOLS = [
129
129
  type: 'function',
130
130
  function: {
131
131
  name: 'write',
132
- description: '创建新文件或完全覆盖已有文件(自动创建父目录)。',
132
+ description: '创建新文件或整体覆盖(自动建父目录)。',
133
133
  parameters: WRITE_SCHEMA,
134
134
  },
135
135
  },
@@ -137,7 +137,7 @@ const TOOLS = [
137
137
  type: 'function',
138
138
  function: {
139
139
  name: 'edit',
140
- description: '对文件做精确文本替换。old_string 必须与文件内容完全一致;匹配多处时需 replace_all=true 或提供更精确上下文。',
140
+ description: '精确文本替换:old_string 必须逐字匹配原文;多处匹配需 replace_all=true',
141
141
  parameters: EDIT_SCHEMA,
142
142
  },
143
143
  },
@@ -145,7 +145,7 @@ const TOOLS = [
145
145
  type: 'function',
146
146
  function: {
147
147
  name: 'ls',
148
- description: '列出目录内容,目录以 / 结尾。',
148
+ description: '列出目录内容(目录以 / 结尾)。',
149
149
  parameters: LS_SCHEMA,
150
150
  },
151
151
  },
@@ -153,7 +153,7 @@ const TOOLS = [
153
153
  type: 'function',
154
154
  function: {
155
155
  name: 'glob',
156
- description: '按通配符模式查找文件路径(支持 * 与 **)。',
156
+ description: '按通配符查找文件路径(支持 * 与 **)。',
157
157
  parameters: GLOB_SCHEMA,
158
158
  },
159
159
  },
@@ -161,7 +161,7 @@ const TOOLS = [
161
161
  type: 'function',
162
162
  function: {
163
163
  name: 'grep',
164
- description: '在文件内容中按正则表达式搜索,返回「文件:行号: 内容」。',
164
+ description: '正则搜索文件内容,返回「文件:行号: 内容」。',
165
165
  parameters: GREP_SCHEMA,
166
166
  },
167
167
  },
@@ -169,7 +169,7 @@ const TOOLS = [
169
169
  type: 'function',
170
170
  function: {
171
171
  name: 'bash',
172
- description: '执行 shell 命令并返回输出(stdout/stderr/退出码)。需要用户授权。',
172
+ description: '执行 shell 命令,返回输出与退出码。需用户授权。',
173
173
  parameters: BASH_SCHEMA,
174
174
  },
175
175
  },
@@ -177,7 +177,7 @@ const TOOLS = [
177
177
  type: 'function',
178
178
  function: {
179
179
  name: 'skill',
180
- description: '加载指定技能的 SKILL.md 全文;省略 name 时列出可用技能。任务与技能匹配时应先加载。',
180
+ description: '加载技能 SKILL.md 全文;省略 name 则列出可用技能。',
181
181
  parameters: SKILL_SCHEMA,
182
182
  },
183
183
  },
@@ -185,7 +185,7 @@ const TOOLS = [
185
185
  type: 'function',
186
186
  function: {
187
187
  name: 'task',
188
- description: '把独立子任务委托给一个全新上下文的子代理,返回其汇报。适合搜索调研、独立实现、复核验证等可并行的工作。',
188
+ description: '把独立子任务委托给新上下文子代理并返回汇报;readOnly 只读任务可并行。',
189
189
  parameters: TASK_SCHEMA,
190
190
  },
191
191
  },
@@ -193,7 +193,7 @@ const TOOLS = [
193
193
  type: 'function',
194
194
  function: {
195
195
  name: 'todo',
196
- description: '维护任务清单(全量替换)。多步骤任务开始前建立清单,完成一项更新一项。',
196
+ description: '维护任务清单(全量替换)。多步任务先建清单再逐项更新。',
197
197
  parameters: TODO_SCHEMA,
198
198
  },
199
199
  },
@@ -201,7 +201,7 @@ const TOOLS = [
201
201
  type: 'function',
202
202
  function: {
203
203
  name: 'undo',
204
- description: '撤销 write/edit 造成的最近一次文件修改;指定 path 则撤销该文件。',
204
+ description: '撤销最近一次 write/edit 修改;指定 path 撤销该文件。',
205
205
  parameters: UNDO_SCHEMA,
206
206
  },
207
207
  },
@@ -211,6 +211,47 @@ export function toolSchemas() {
211
211
  return TOOLS;
212
212
  }
213
213
 
214
+ /**
215
+ * 递归去除对象中的全部 description 键(保留类型/required/enum 等结构性字段)。
216
+ * @param {any} obj
217
+ * @returns {any}
218
+ */
219
+ function stripDescriptions(obj) {
220
+ if (Array.isArray(obj)) return obj.map(stripDescriptions);
221
+ if (obj && typeof obj === 'object') {
222
+ const out = /** @type {Record<string, any>} */ ({});
223
+ for (const [k, v] of Object.entries(obj)) {
224
+ if (k === 'description') continue;
225
+ out[k] = stripDescriptions(v);
226
+ }
227
+ return out;
228
+ }
229
+ return obj;
230
+ }
231
+
232
+ /**
233
+ * 按需构建工具 Schema(省钱 B1):本会话已调用过的工具省略全部描述(工具级 + 参数级,
234
+ * 模型已在消息历史里见过其用途,保留 name/parameters 结构与类型即可),未用过的保留完整描述。
235
+ * 返回浅拷贝新数组,不修改原 TOOLS;extra(如 MCP 工具)按同一规则处理。
236
+ * @param {Set<string>} usedNames 已调用工具名(内置名或 MCP 前缀名)
237
+ * @param {Array<object>} [extra] 附加工具 Schema(MCP 等)
238
+ * @returns {Array<any>}
239
+ */
240
+ export function buildToolSchemas(usedNames, extra = []) {
241
+ const strip = (/** @type {any[]} */ arr) =>
242
+ arr.map((/** @type {any} */ t) => {
243
+ const name = t?.function?.name;
244
+ if (name && usedNames.has(name)) {
245
+ return {
246
+ ...t,
247
+ function: { ...t.function, description: '', parameters: stripDescriptions(t.function?.parameters) },
248
+ };
249
+ }
250
+ return t;
251
+ });
252
+ return [...strip(TOOLS), ...strip(extra)];
253
+ }
254
+
214
255
  function runSkill(args, ctx) {
215
256
  const name = String(args.name ?? '').trim();
216
257
  const skills = listSkills(ctx.workingDir);
package/src/web/app.js CHANGED
@@ -763,12 +763,55 @@ $('#memDedupe').onclick=async ()=>{
763
763
  };
764
764
  // —— 缓存命中率仪表盘 ——
765
765
  function fmtT(n){ n=Number(n||0); return n>=1000?(n/1000).toFixed(1)+'k':String(n); }
766
+ // 省钱 B3:费用二级分账面板(模型/工具 Top5 + 近 14 天折线,零依赖 SVG)
767
+ function renderCostBreakdown(bd){
768
+ const models=$('#costTopModels'); models.innerHTML='';
769
+ for(const m of ((bd.byModel||[]).slice(0,5))){
770
+ const bar=document.createElement('div'); bar.style.cssText='height:3px;border-radius:2px;background:var(--bg3);margin:1px 0;overflow:hidden';
771
+ const max=bd.byModel&&bd.byModel[0]?bd.byModel[0].cost:1;
772
+ bar.innerHTML='<div style="height:100%;width:'+Math.max(2,Math.round((m.cost||0)/Math.max(max,1e-9)*100))+'%;background:var(--accent2)"></div>';
773
+ const row=document.createElement('div'); row.style.cssText='display:flex;justify-content:space-between;font-size:11.5px;padding:1px 0';
774
+ row.innerHTML='<span>'+esc(m.model||'—')+'</span><span>¥'+(m.cost||0).toFixed(4)+' · '+m.turns+' 轮</span>';
775
+ const w=document.createElement('div'); w.appendChild(row); w.appendChild(bar); models.appendChild(w);
776
+ }
777
+ if(!(bd.byModel||[]).length) models.innerHTML='<div style="color:var(--faint);font-size:11.5px">暂无记录</div>';
778
+ const tools=$('#costTopTools'); tools.innerHTML='';
779
+ for(const t of ((bd.byTool||[]).slice(0,5))){
780
+ const bar=document.createElement('div'); bar.style.cssText='height:3px;border-radius:2px;background:var(--bg3);margin:1px 0;overflow:hidden';
781
+ const max=bd.byTool&&bd.byTool[0]?bd.byTool[0].ms:1;
782
+ bar.innerHTML='<div style="height:100%;width:'+Math.max(2,Math.round((t.ms||0)/Math.max(max,1e-9)*100))+'%;background:var(--cyan)"></div>';
783
+ const row=document.createElement('div'); row.style.cssText='display:flex;justify-content:space-between;font-size:11.5px;padding:1px 0';
784
+ row.innerHTML='<span>'+esc(t.tool||'—')+'</span><span>'+t.calls+' 次 · '+fmtDur(t.ms)+'</span>';
785
+ const w=document.createElement('div'); w.appendChild(row); w.appendChild(bar); tools.appendChild(w);
786
+ }
787
+ if(!(bd.byTool||[]).length) tools.innerHTML='<div style="color:var(--faint);font-size:11.5px">暂无记录</div>';
788
+ const line=$('#costLine');
789
+ const days=(bd.byDay||[]).slice(-14);
790
+ if(!days.length){ line.innerHTML=''; return; }
791
+ const W=320, H=56, P=4;
792
+ const maxC=Math.max(...days.map(d=>d.cost), 1e-9);
793
+ const pts=days.map((d,i)=>{
794
+ const x=P+i*(W-2*P)/Math.max(days.length-1,1);
795
+ const y=H-4-(d.cost/maxC)*(H-10);
796
+ return [x.toFixed(1), y.toFixed(1)];
797
+ });
798
+ const poly=pts.map(p=>p.join(',')).join(' ');
799
+ const last=pts[pts.length-1];
800
+ line.innerHTML='<div style="font-size:11.5px;color:var(--faint);margin-bottom:2px">近 14 天费用折线(¥,最高 ¥'+maxC.toFixed(4)+')</div>'
801
+ +'<svg viewBox="0 0 '+W+' '+H+'" style="width:100%;height:auto;background:var(--bg3);border-radius:8px">'
802
+ +'<polyline points="'+poly+'" fill="none" style="stroke:var(--accent2);stroke-width:1.6;stroke-linejoin:round;stroke-linecap:round"/>'
803
+ +'<circle cx="'+last[0]+'" cy="'+last[1]+'" r="2.6" style="fill:var(--accent)"/>'
804
+ +'<text x="4" y="10" font-size="8" style="fill:var(--faint)">'+days[0].day+'</text>'
805
+ +'<text x="'+(W-52)+'" y="10" font-size="8" style="fill:var(--faint)">'+days[days.length-1].day+'</text>'
806
+ +'</svg>';
807
+ }
766
808
  async function refreshCache(){
767
809
  const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
768
810
  const j=await r.json(); if(!j.ok) return;
769
811
  const s=j.summary||{};
770
812
  const rate=s.rate!=null?(s.rate*100).toFixed(0)+'%':'暂无缓存数据';
771
813
  $('#cacheSummary').innerHTML='轮次 '+s.turns+' · ↑'+fmtT(s.prompt)+' ↓'+fmtT(s.completion)+' tokens · 命中率 <b style="color:var(--accent)">'+rate+'</b> · 累计 ≈¥'+Number(s.cost||0).toFixed(5)+'(比全未命中省 ≈¥'+Number(s.saved||0).toFixed(5)+')';
814
+ renderCostBreakdown(j.breakdown||{});
772
815
  const list=$('#cacheRecent'); list.innerHTML='';
773
816
  if(!(j.recent||[]).length){ list.innerHTML='<div style="color:var(--faint);font-size:12px;padding:6px 2px">暂无记录(每次对话后自动统计)</div>'; return; }
774
817
  for(const e of j.recent){
@@ -372,6 +372,18 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
372
372
  <h4 style="margin:4px 0;color:var(--accent2)">缓存命中率仪表盘(最近 10 次)</h4>
373
373
  <div id="cacheSummary" style="font-size:12.5px;color:var(--dim);padding:2px"></div>
374
374
  <div id="cacheRecent" style="max-height:130px;overflow-y:auto"></div>
375
+ <h4 style="margin:10px 0 4px;color:var(--accent2)">费用二级分账(模型 / 工具 Top5 · 近 14 天)</h4>
376
+ <div style="display:flex;gap:10px;flex-wrap:wrap">
377
+ <div style="flex:1;min-width:170px">
378
+ <div style="font-size:11.5px;color:var(--faint)">模型 Top5(费用)</div>
379
+ <div id="costTopModels" style="font-size:12px;color:var(--dim)"></div>
380
+ </div>
381
+ <div style="flex:1;min-width:170px">
382
+ <div style="font-size:11.5px;color:var(--faint)">工具 Top5(耗时)</div>
383
+ <div id="costTopTools" style="font-size:12px;color:var(--dim)"></div>
384
+ </div>
385
+ </div>
386
+ <div id="costLine" style="margin-top:8px"></div>
375
387
  </section>
376
388
  <section class="cfg-panel" data-panel="mcp">
377
389
  <hr style="border:none;border-top:1px solid var(--border);margin:14px 0 8px">
package/src/web/server.js CHANGED
@@ -435,7 +435,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
435
435
  session.routeStats = { steps: st.steps + (io.stats().toolCount || 0), truncated: st.truncated + (r.truncated ? 1 : 0) };
436
436
  await withSessionLock(session.file, () => appendMessages(session.file, messages.slice(persistedBefore)));
437
437
  io.printUsageLine({ modelName: runModel, usage: r.usage, durationMs: r.durationMs });
438
- recordUsage(runModel, r.usage, r.perf);
438
+ recordUsage(r.perf?.usedModel || runModel, r.usage, r.perf);
439
439
  maybeAutoSync().catch(() => {});
440
440
  // 新会话自动标题(可配置关闭)
441
441
  srvlog('chat 标题生成前 ' + taskId);