mingdao-harness 0.2.4 → 0.2.5

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.
Files changed (61) hide show
  1. package/docs/ROADMAP-NEXT.md +5 -5
  2. package/package.json +1 -1
  3. package/src/agent.js +19 -19
  4. package/src/audit.js +2 -2
  5. package/src/batch.js +7 -7
  6. package/src/cachestats.js +9 -9
  7. package/src/cli.js +29 -679
  8. package/src/commands/desktop.js +1 -1
  9. package/src/commands/key.js +1 -1
  10. package/src/commands/repl.js +711 -0
  11. package/src/commands/schedule.js +7 -7
  12. package/src/commands/skill.js +18 -16
  13. package/src/commands/sync.js +5 -5
  14. package/src/commands/update.js +7 -6
  15. package/src/commands/workspace.js +2 -2
  16. package/src/compact.js +6 -6
  17. package/src/config.js +7 -4
  18. package/src/context.js +11 -11
  19. package/src/credentials.js +6 -0
  20. package/src/hooks.js +11 -11
  21. package/src/mcp-presets.js +5 -5
  22. package/src/mcp.js +19 -19
  23. package/src/memory.js +16 -16
  24. package/src/model-discovery.js +16 -15
  25. package/src/models.js +4 -4
  26. package/src/notify.js +2 -2
  27. package/src/permissions.js +7 -3
  28. package/src/pricing.js +35 -1
  29. package/src/prompts.js +1 -1
  30. package/src/providers/index.js +7 -7
  31. package/src/providers/openai-compatible.js +8 -8
  32. package/src/routing.js +5 -5
  33. package/src/schedule.js +28 -28
  34. package/src/session-index.js +5 -1
  35. package/src/session.js +11 -2
  36. package/src/skill-lib.js +62 -3
  37. package/src/skill-registry.js +10 -9
  38. package/src/skills.js +10 -10
  39. package/src/sync-server.js +86 -3
  40. package/src/sync.js +53 -20
  41. package/src/tasks/worker.js +126 -0
  42. package/src/tasks.js +13 -13
  43. package/src/titles.js +5 -5
  44. package/src/tokenizer.js +22 -4
  45. package/src/tools/bash.js +12 -12
  46. package/src/tools/fs-tools.js +66 -9
  47. package/src/tools/index.js +7 -7
  48. package/src/ui.js +83 -6
  49. package/src/update.js +8 -8
  50. package/src/web/attachments.js +1 -1
  51. package/src/web/routes/api.js +38 -786
  52. package/src/web/routes/domains/config.js +280 -0
  53. package/src/web/routes/domains/misc.js +128 -0
  54. package/src/web/routes/domains/schedule.js +127 -0
  55. package/src/web/routes/domains/sessions.js +105 -0
  56. package/src/web/routes/domains/skills.js +79 -0
  57. package/src/web/routes/domains/sync.js +101 -0
  58. package/src/web/routes/domains/workspace.js +113 -0
  59. package/src/web/server.js +29 -14
  60. package/src/web/web-io.js +54 -12
  61. package/src/workspace.js +14 -14
@@ -34,11 +34,11 @@
34
34
  5. ✅ **评测基准扩充**:压缩质量集(5 组长会话样本 → 压缩 → 关键事实存活断言 + 尾部字节不变)、路由标注集扩至 100 条;bench 断言数 38 → 150+;覆盖率阈值 55% → 60%
35
35
  - 版本说明:v0.2.3 被 0.2.2 桌面启动崩溃的紧急修复占用,Phase B 顺延为 v0.2.4。
36
36
 
37
- ### Phase C「结构渐进」v0.2.5(约 2 天)
38
- 1. routes/api.js 按域细分(config/sessions/sync/schedule/skills/workspace/misc 7 模块 + 共享中间件),每域配 API 契约测试
39
- 2. cli.js REPL → commands/repl.jsworker → tasks/worker.jse2e-local 16 项全绿做护栏)
40
- 3. strict 棘轮削 300 条(先 credentials/permissions/config/sync-server/session 五个安全关键模块全量注解)
41
- - 验收:tsc full 错误数 ≤1284;结构清单在文档登记。
37
+ ### Phase C「结构渐进」v0.2.5(进行中)
38
+ 1. routes/api.js 按域细分:`src/web/routes/domains/{config,sessions,skills,schedule,sync,workspace,misc}.js`(7 域,handle(ctx,deps,shared) 契约:命中返回 true)+ api.js 瘦身为编排器(访问控制/CSRF/静态壳 + 域循环);`test/api-contracts.js` 8 组契约测试入 run-all;顺带修复 icon 路由路径 bug(routes/icons → web/icons,契约测试捕获)
39
+ 2. cli.js REPL → `src/commands/repl.js`(runRepl(ctx))、worker → `src/tasks/worker.js`(runWorkerTask);e2e-local 16 项全绿护栏
40
+ 3. strict 棘轮削 300 条(五安全关键模块 credentials/permissions/config/sync-server/session 全量注解 + 批量文件注解)
41
+ - 验收:tsc full 错误数 ≤812;结构清单在文档登记。
42
42
 
43
43
  ### Phase D「能力演进」v0.3.0(约 3–5 天,可按需裁剪)
44
44
  1. **跨平台沙箱补位**:Windows Job Object + 降权 token(零依赖原生 API)、macOS seatbelt 规则;敏感命令平台无关拦截清单兜底
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -29,7 +29,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
29
29
  const temperature = cfg.temperature ?? preset.temperature ?? 0.6;
30
30
  const reasoningEffort = cfg.reasoningEffort ?? preset.reasoningEffort?.default ?? undefined;
31
31
  const hooks = createHooks(cfg.hooks, workingDir);
32
- const todos = [];
32
+ const todos = /** @type {any[]} */ ([]);
33
33
  // 会话级共享:调用方传入则复用(/model 切换、子代理均共享,undo 不丢失)
34
34
  const undo = undoStore || { backups: new Map() };
35
35
  const stepLimit = maxSteps || MAX_STEPS;
@@ -60,12 +60,12 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
60
60
  };
61
61
 
62
62
  // 子代理:全新上下文 + 同一 Provider/权限(提示带「子任务」标记),独立完成子任务后汇报
63
- async function spawnTask(prompt, { description = '', readOnly = false } = {}) {
63
+ async function spawnTask(/** @type {any} */ prompt, { description = '', readOnly = false } = {}) {
64
64
  const subIo = createIO({ quiet: true });
65
65
  // 只读子代理(评估 A4:可并行):只读工具自动放行、写类直接拒绝,无交互询问
66
66
  const subPermission = readOnly
67
- ? { mode: 'readonly-noask', check: (name) => READONLY_TOOLS_SET.has(name) }
68
- : { check: (name, args) => permission.check(name, args, '(子任务)') };
67
+ ? { mode: 'readonly-noask', check: (/** @type {any} */ name) => READONLY_TOOLS_SET.has(name) }
68
+ : { check: (/** @type {any} */ name, /** @type {any} */ args) => permission.check(name, args, '(子任务)') };
69
69
  // 自动路由:子代理固定走 executor 模型(便宜的执行单元)
70
70
  const subModel = subagentModel(cfg, modelName);
71
71
  const subAgent = createAgent({
@@ -110,19 +110,19 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
110
110
  budget,
111
111
  todos,
112
112
  undoStore: undo,
113
- spawnTask: (prompt, opts) => spawnTask(prompt, opts),
113
+ spawnTask: (/** @type {any} */ prompt, /** @type {any} */ opts) => spawnTask(prompt, opts),
114
114
  };
115
115
  }
116
116
 
117
- async function runTurn(messages) {
117
+ async function runTurn(/** @type {any} */ messages) {
118
118
  let steps = 0;
119
119
  let finish = null;
120
- const usage = { prompt_tokens: 0, completion_tokens: 0 };
120
+ 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 });
121
121
  const startedAt = Date.now();
122
122
  // 回合性能指标(状态栏:LLM 时长 / 工具时长 / 首 token 延迟 / 步数)
123
123
  let llmMsTotal = 0;
124
124
  let toolMsTotal = 0;
125
- let firstTokenAt = null;
125
+ let firstTokenAt = /** @type {any} */ (null);
126
126
  // 省钱 B3(费用二级分账):推理 token 估算(按增量累计)与逐工具调用/耗时累加
127
127
  let reasoningTokens = 0;
128
128
  const toolStats = /** @type {Map<string, {calls: number, ms: number}>} */ (new Map());
@@ -137,7 +137,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
137
137
  });
138
138
  let aborted = false;
139
139
  let emptyRounds = 0; // 连续空/截断输出计数(防止无限续写)
140
- let currentAc = null;
140
+ let currentAc = /** @type {any} */ (null);
141
141
  // 省钱 B4(护栏降级):action='downgrade' 超限后本回合切换到便宜模型继续执行;
142
142
  // activeModel 是本回合实际使用的模型(分账/记录归属它),downgraded 保证只提示一次。
143
143
  let activeModel = modelName;
@@ -203,9 +203,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
203
203
  for (const m of sanitized) {
204
204
  const rc = m.reasoning_content;
205
205
  if (typeof rc === 'string' && rc.length > 4000) {
206
- sanitized = sanitized.map((x) => (x === m ? { ...x, reasoning_content: `[思考过程已省略(原 ${rc.length} 字)]` } : x));
206
+ sanitized = sanitized.map((/** @type {any} */ x) => (x === m ? { ...x, reasoning_content: `[思考过程已省略(原 ${rc.length} 字)]` } : x));
207
207
  } else if (typeof rc === 'string' && rc.length > 1000) {
208
- sanitized = sanitized.map((x) => (x === m ? { ...x, reasoning_content: rc.slice(-500) + ' …[思考过程已截断]' } : x));
208
+ sanitized = sanitized.map((/** @type {any} */ x) => (x === m ? { ...x, reasoning_content: rc.slice(-500) + ' …[思考过程已截断]' } : x));
209
209
  }
210
210
  }
211
211
 
@@ -289,7 +289,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
289
289
  maxTokens: maxOutput,
290
290
  reasoningEffort,
291
291
  signal: ac.signal,
292
- onDelta(d) {
292
+ onDelta(/** @type {any} */ d) {
293
293
  io.stopSpinner();
294
294
  if (firstTokenAt == null) firstTokenAt = Date.now(); // 首个增量即首 token
295
295
  if (d.text) io.writeText(d.text);
@@ -339,7 +339,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
339
339
 
340
340
  // 预检:解析参数 → PreToolUse 钩子 → 权限检查;拒绝/失败只回填不执行(返回 null)
341
341
  // task 工具标记 readOnly 时也可并行(评估 A4:只读子代理 Promise.all)
342
- async function prepTool(tc) {
342
+ async function prepTool(/** @type {any} */ tc) {
343
343
  const name = tc.function?.name || '';
344
344
  // 审计(P3-5):参数与拒绝原因都记录(配置 audit:false 可关)
345
345
  const auditOn = cfg.audit !== false;
@@ -353,7 +353,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
353
353
  args: auditArgs(),
354
354
  ...extra,
355
355
  });
356
- let args = null;
356
+ let args = /** @type {any} */ (null);
357
357
  try {
358
358
  args = JSON.parse(tc.function?.arguments || '{}');
359
359
  } catch {
@@ -403,7 +403,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
403
403
  // 执行单个工具(渲染「执行中」→ dispatch → 捕获异常转错误结果)
404
404
  // 审计 Hermes C4:同回合相同参数的只读工具(read/ls/glob/grep/skill)合并执行一次,
405
405
  // 后续相同调用直接复用结果(仍逐个回填 tool 消息以保持 tool_call_id 配对)
406
- async function runTool(prep) {
406
+ async function runTool(/** @type {any} */ prep) {
407
407
  io.renderToolStart?.(prep.name, prep.args);
408
408
  usedToolNames.add(prep.name); // 省钱 B1:执行过即标记,后续轮次省略其 description
409
409
  const dedupKey = !prep.isMcp && READONLY_TOOLS_SET.has(prep.name) ? prep.name + ':' + JSON.stringify(prep.args || {}) : null;
@@ -421,13 +421,13 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
421
421
  }
422
422
  if (dedupKey) turnToolCache.set(dedupKey, result);
423
423
  return result;
424
- } catch (err) {
424
+ } catch (/** @type {any} */ err) {
425
425
  return JSON.stringify({ ok: false, error: String(err?.message || err) });
426
426
  }
427
427
  }
428
428
 
429
429
  // 收尾:渲染结果 → todo 更新 → PostToolUse 钩子 → 回填消息(顺序与串行一致)
430
- function finishTool(prep, result, t0) {
430
+ function finishTool(/** @type {any} */ prep, /** @type {any} */ result, /** @type {any} */ t0) {
431
431
  const ms = Date.now() - t0;
432
432
  toolMsTotal += ms;
433
433
  // 省钱 B3:逐工具调用/耗时累加(费用二级分账的 byTool 维度)
@@ -473,7 +473,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
473
473
  const batch = []; // {prep, batchable}
474
474
  while (i < res.toolCalls.length) {
475
475
  const tc = res.toolCalls[i];
476
- const prep = await prepTool(tc);
476
+ const prep = /** @type {any} */ (await prepTool(tc));
477
477
  const name = tc.function?.name || '';
478
478
  let batchable = canBatch && Boolean(prep) && !prep.isMcp && READONLY_BATCH.has(name);
479
479
  if (!batchable && canBatch && Boolean(prep) && name === 'task' && prep.args?.readOnly === true) batchable = true;
@@ -601,7 +601,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
601
601
  maxOutput,
602
602
  temperature,
603
603
  runTurn,
604
- spawnTask: (prompt, opts) => spawnTask(prompt, opts),
604
+ spawnTask: (/** @type {any} */ prompt, /** @type {any} */ opts) => spawnTask(prompt, opts),
605
605
  getTodos: () => todos.slice(),
606
606
  };
607
607
  }
package/src/audit.js CHANGED
@@ -17,11 +17,11 @@ export function auditFile() {
17
17
  }
18
18
 
19
19
  // 轻量脱敏:sk- 系 API Key 掩码(审计日志可安全共享排查)
20
- export function redactSecrets(text) {
20
+ export function redactSecrets(/** @type {any} */ text) {
21
21
  return String(text ?? '').replace(/(sk-[A-Za-z0-9_-]{6,})/g, 'sk-***');
22
22
  }
23
23
 
24
- export function writeAudit(entry) {
24
+ export function writeAudit(/** @type {any} */ entry) {
25
25
  try {
26
26
  ensureHome();
27
27
  const file = auditFile();
package/src/batch.js CHANGED
@@ -19,7 +19,7 @@ const DEFAULT_WINDOW = '24h';
19
19
  const DEFAULT_ENDPOINT = '/v1/chat/completions';
20
20
 
21
21
  // 批处理端点基址:config.batchBaseUrl 优先;否则取当前服务商 baseUrl 去掉 /v1 后缀
22
- function batchBase(cfg, model) {
22
+ function batchBase(/** @type {any} */ cfg, /** @type {any} */ model) {
23
23
  const explicit = String(cfg?.batchBaseUrl || '').trim().replace(/\/+$/, '');
24
24
  if (explicit) return explicit;
25
25
  const pc = resolveProviderConfig(cfg, model);
@@ -29,7 +29,7 @@ function batchBase(cfg, model) {
29
29
  }
30
30
 
31
31
  /** @returns {Promise<any>} */
32
- async function api(base, apiKey, methodPath, payload, httpMethod = 'POST') {
32
+ async function api(/** @type {any} */ base, /** @type {any} */ apiKey, /** @type {any} */ methodPath, /** @type {any} */ payload, httpMethod = 'POST') {
33
33
  const res = await fetch(base + methodPath, {
34
34
  method: httpMethod,
35
35
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
@@ -44,7 +44,7 @@ async function api(base, apiKey, methodPath, payload, httpMethod = 'POST') {
44
44
  return j;
45
45
  }
46
46
 
47
- async function uploadFile(base, apiKey, jsonl) {
47
+ async function uploadFile(/** @type {any} */ base, /** @type {any} */ apiKey, /** @type {any} */ jsonl) {
48
48
  const form = new FormData();
49
49
  form.append('file', new Blob([jsonl], { type: 'application/jsonl' }), 'mingdao-batch.jsonl');
50
50
  form.append('purpose', 'batch');
@@ -62,7 +62,7 @@ async function uploadFile(base, apiKey, jsonl) {
62
62
  return j.id;
63
63
  }
64
64
 
65
- async function downloadResults(base, apiKey, batch) {
65
+ async function downloadResults(/** @type {any} */ base, /** @type {any} */ apiKey, /** @type {any} */ batch) {
66
66
  // DeepSeek 风格:直接取结果文件;回退 OpenAI 风格:按 output_file_id 取内容
67
67
  const attempts = [
68
68
  `/batches/${batch.id}/files/result`,
@@ -92,7 +92,7 @@ async function downloadResults(base, apiKey, batch) {
92
92
  * ③maxCost 预算上限(提交前按估算费用拦截,超出直接中止不提交)。
93
93
  * @param {{ cfg: any, model: any, questions: any, workingDir?: string, maxTokens?: number, temperature?: any, signal?: any, onStatus?: any, maxCost?: number }} opts */
94
94
  export async function runBatch({ cfg, model, questions, workingDir = process.cwd(), maxTokens = 4096, temperature, signal, onStatus, maxCost = 0 }) {
95
- const list = (questions || []).map((q) => String(q).trim()).filter(Boolean);
95
+ const list = (questions || []).map((/** @type {any} */ q) => String(q).trim()).filter(Boolean);
96
96
  if (!list.length) return { error: '没有可批处理的问题(每行一个问题)' };
97
97
  const pc = resolveProviderConfig(cfg, model);
98
98
  if (!pc.apiKey) return { error: `模型 ${model} 没有可用 API Key(mingdao key set ${pc.name})` };
@@ -192,7 +192,7 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
192
192
  failures = 0;
193
193
  } catch (err) {
194
194
  failures += 1;
195
- if (failures >= 10) return { error: `轮询失败:${err?.message || err}(任务仍在服务端,ID ${batch.id})`, batchId: batch.id };
195
+ if (failures >= 10) return { error: `轮询失败:${(/** @type {any} */ (err))?.message || err}(任务仍在服务端,ID ${batch.id})`, batchId: batch.id };
196
196
  await new Promise((r) => setTimeout(r, Math.min(baseInterval * 1.5 ** failures, 30000)));
197
197
  continue;
198
198
  }
@@ -242,6 +242,6 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
242
242
  return { ok: true, batchId: batch.id, outputFile: outFile, results: outputs, usage, cost, discount: BATCH_DISCOUNT, deduped, estimatedCost };
243
243
  } catch (err) {
244
244
  // 批处理端点不支持(404/405 等)→ 明确告知,不静默
245
- return { error: `批处理不可用:${err?.message || err}(该服务商可能不支持 Batch API,可用 config.batchBaseUrl 指定支持的网关)` };
245
+ return { error: `批处理不可用:${(/** @type {any} */ (err))?.message || err}(该服务商可能不支持 Batch API,可用 config.batchBaseUrl 指定支持的网关)` };
246
246
  }
247
247
  }
package/src/cachestats.js CHANGED
@@ -16,7 +16,7 @@ const MAX_LINES = 20000;
16
16
  const KEEP_LINES = 10000;
17
17
  let cacheStatsCount = 0;
18
18
 
19
- export function recordCacheStats(entry) {
19
+ export function recordCacheStats(/** @type {any} */ entry) {
20
20
  try {
21
21
  ensureHome(); // 审计 B10:与 audit/journal 一致,目录缺失不静默丢统计
22
22
  const line = JSON.stringify({
@@ -54,13 +54,13 @@ export function recordCacheStats(entry) {
54
54
  // listCacheStats 读取缓存(审计:costGuard 每步 / WebUI 每 15s / /cost 命令都调用——
55
55
  // 四报告共识 P2-2/§3.3-A:全文件读+逐行 parse 会随 JSONL 增长线性放大 IO。
56
56
  // 按 mtimeMs+size 双键缓存;写入追加会改两者,轮转重写同样改,缓存自动失效)
57
- let _statsCache = null;
57
+ let /** @type {any} */ _statsCache = null;
58
58
 
59
59
  export function listCacheStats(limit = 2000) {
60
60
  try {
61
61
  const file = cacheStatsFile();
62
62
  const st = fs.statSync(file);
63
- if (!_statsCache || _statsCache.mtimeMs !== st.mtimeMs || _statsCache.size !== st.size || _statsCache.limit < limit) {
63
+ if (!/** @type {any} */ _statsCache || _statsCache.mtimeMs !== st.mtimeMs || _statsCache.size !== st.size || _statsCache.limit < limit) {
64
64
  const raw = fs.readFileSync(file, 'utf8');
65
65
  const out = [];
66
66
  for (const l of raw.split('\n')) {
@@ -77,8 +77,8 @@ export function listCacheStats(limit = 2000) {
77
77
  }
78
78
  }
79
79
 
80
- export function summarizeCacheStats(entries) {
81
- const sum = { turns: entries.length, prompt: 0, completion: 0, hit: 0, miss: 0, cost: 0, saved: 0, steps: 0, llmMs: 0, toolMs: 0, firstTokenCount: 0, firstTokenSumMs: 0 };
80
+ export function summarizeCacheStats(/** @type {any} */ entries) {
81
+ const sum = /** @type {any} */ ({ turns: entries.length, prompt: 0, completion: 0, hit: 0, miss: 0, cost: 0, saved: 0, steps: 0, llmMs: 0, toolMs: 0, firstTokenCount: 0, firstTokenSumMs: 0 });
82
82
  for (const e of entries) {
83
83
  sum.prompt += e.prompt || 0;
84
84
  sum.completion += e.completion || 0;
@@ -102,7 +102,7 @@ export function summarizeCacheStats(entries) {
102
102
 
103
103
  // 记录一次用量(agent 侧调用):自动计算命中拆分与节省额
104
104
  // 记录一次用量(agent 侧调用):自动计算命中拆分与节省额;perf 为回合性能指标(状态栏)
105
- export function recordUsage(modelName, usage, perf = null) {
105
+ export function recordUsage(/** @type {any} */ modelName, /** @type {any} */ usage, /** @type {any} */ perf = null) {
106
106
  const split = cacheSplit(usage);
107
107
  const prompt = usage?.prompt_tokens || 0;
108
108
  const completion = usage?.completion_tokens || 0;
@@ -132,8 +132,8 @@ export function recordUsage(modelName, usage, perf = null) {
132
132
  });
133
133
  }
134
134
 
135
- export function formatCacheSummary(sum) {
136
- const fmt = (n) => (n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n));
135
+ export function formatCacheSummary(/** @type {any} */ sum) {
136
+ const fmt = (/** @type {any} */ n) => (n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n));
137
137
  const lines = [
138
138
  `轮次 ${sum.turns} · ↑${fmt(sum.prompt)} ↓${fmt(sum.completion)} tokens`,
139
139
  `缓存命中率 ${sum.rate != null ? (sum.rate * 100).toFixed(0) + '%' : '暂无缓存数据'}`,
@@ -213,7 +213,7 @@ export function costBreakdown() {
213
213
 
214
214
  // 月度费用报告(/cost 导出):按北京时间月份聚合,month 形如 'YYYY-MM',缺省返回全部月份
215
215
  // 省钱 B3:月度聚合增加 reasoning 与 byTool 维度
216
- export function costMonthlyReport(month) {
216
+ export function costMonthlyReport(/** @type {any} */ month) {
217
217
  const entries = listCacheStats(100000);
218
218
  const want = String(month || '').trim();
219
219
  const months = new Map();