mingdao-harness 0.4.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/agent.js CHANGED
@@ -14,8 +14,11 @@ import { subagentModel } from './routing.js';
14
14
  import { writeAudit } from './audit.js';
15
15
  import { redactSecrets } from './redact.js';
16
16
  import { checkCostGuard, costGuardConfig, todayCost } from './cost-guard.js';
17
+ import { recordCacheStats, packDailyCost } from './cachestats.js';
17
18
  import { estimateCost } from './pricing.js';
18
- import { resolveProviderConfig } from './providers/index.js';
19
+ import { resolveProviderConfig, createProvider } from './providers/index.js';
20
+ import { compileConstraints, checkPreTool, checkPostTool, checkOutput, blockedOutputText } from './constraints.js';
21
+ import { getActivePackContext } from './packs.js';
19
22
 
20
23
  const MAX_STEPS = 24;
21
24
  // 子代理步数上限:审计/精读类只读子任务需要读多个文件 + 交叉引用,12 步易在「读不全」时被截断
@@ -32,9 +35,10 @@ export const READONLY_TIER_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill',
32
35
  * 创建 Agent 循环(调用方只需传 provider/permission/io/modelName/workingDir,其余可选)
33
36
  * @param {{ provider: any, permission: any, io: any, modelName: any, workingDir: any,
34
37
  * cfg?: any, undoStore?: any, maxSteps?: number, mcp?: any, onCompact?: any, sessionRef?: any,
35
- * onUsage?: (modelName: string, usage: any) => void }} params
38
+ * onUsage?: (modelName: string, usage: any) => void,
39
+ * constraints?: any[] }} params
36
40
  */
37
- export function createAgent({ provider, permission, io, modelName, workingDir, cfg = {}, undoStore, maxSteps, mcp, onCompact, sessionRef, onUsage }) {
41
+ export function createAgent({ provider, permission, io, modelName, workingDir, cfg = {}, undoStore, maxSteps, mcp, onCompact, sessionRef, onUsage, constraints: rawConstraints }) {
38
42
  const preset = modelPreset(modelName) || {};
39
43
  // v0.3.2 模型自适应:预算按模型上下文窗口推导(留输出余量 + 75% 舒适区),
40
44
  // 自定义/本地小模型不再套 128000 默认撑爆窗口;prompt 永不逼近窗口边缘(prefill 不爆炸)。
@@ -58,12 +62,30 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
58
62
  const temperature = cfg.temperature ?? preset.temperature ?? 0.6;
59
63
  const reasoningEffort = cfg.reasoningByModel?.[modelName] ?? cfg.reasoningEffort ?? preset.reasoningEffort?.default ?? undefined;
60
64
  const hooks = createHooks(cfg.hooks, workingDir, cfg);
65
+ // v0.5.0 阶段 A3:领域约束(Pack API v1 的「领域红线」)——在三个时机强制:
66
+ // PreToolUse(工具/参数)、PostToolUse(工具结果缺项)、输出前(正文禁用措辞)。
67
+ // 约束**只收紧不放松**权限;未传约束时 compiled.active=false,所有检查点完全惰性,
68
+ // 对既有行为零影响(这是接入主循环时最重要的不变量)。
69
+ // 显式传入优先;否则取进程级已挂载的 Pack 约束;都没有 → 空集合(惰性)
70
+ const constraints = compileConstraints(
71
+ Array.isArray(rawConstraints) ? rawConstraints : cfg?.constraints ?? getActivePackContext()?.constraints
72
+ );
73
+ /** 约束事件写审计(pack/constraint/kind/stage/action)——受监管场景要能回答「红线何时被触发」 */
74
+ const auditConstraint = (/** @type {any} */ ev) => {
75
+ if (cfg.audit === false || !ev) return;
76
+ try {
77
+ writeAudit({ at: Date.now(), session: sessionRef?.name ?? null, model: modelName, tool: null, constraint: ev });
78
+ } catch {}
79
+ };
61
80
  const todos = /** @type {any[]} */ ([]);
62
81
  // v0.4.6 修复:当前 runTurn 的 usage 累加器引用——spawnTask 定义在 runTurn 之外,
63
82
  // 此前子代理的 token 消耗只用于生成汇报文本、从不并入父回合 usage,导致「今日费用」系统性少计
64
83
  // (CLI/REPL 每次派子代理都漏计;README 主推的「多方向并行调研」场景漏计最重)。
65
84
  // runTurn 开始时指向本轮 usage,finally 清空;runTurn 之外调用 spawnTask 时为 null(安全跳过)。
66
85
  let currentUsage = /** @type {any} */ (null);
86
+ // v0.5.0 A4:当前正在执行的 Pack 工具所属 Pack(由 runTool 按 `pack__<pack>__` 前缀设置),
87
+ // 供 ctx.llm 写归因记录时标注来源。
88
+ let currentPack = /** @type {any} */ (null);
67
89
  // 会话级共享:调用方传入则复用(/model 切换、子代理均共享,undo 不丢失)
68
90
  const undo = undoStore || { backups: new Map() };
69
91
  const stepLimit = maxSteps || MAX_STEPS;
@@ -168,6 +190,101 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
168
190
  return text;
169
191
  }
170
192
 
193
+ /**
194
+ * v0.5.0 A4:Pack 统一模型出口 `ctx.llm()`(Pack API §5)。
195
+ *
196
+ * 为什么必须有它:垂域层此前把模型调用写在 Provider 的 chat() 里(Deyi 的 dify.mjs 就是如此),
197
+ * usage 硬编码为 0 —— 域内调用**完全不计费、不触发日费用护栏、无法归因**。
198
+ * 走这里则:复用内核的 Provider 解析/重试/超时/能力表;usage 并入当前回合累加器 →
199
+ * 今日费用、缓存命中率、峰谷判断、日费用护栏在途估算**同时生效**。
200
+ * @param {any} opts
201
+ */
202
+ async function packLlm(/** @type {any} */ opts = {}) {
203
+ const usedModel = String(opts.model || modelName);
204
+ const system = String(opts.system || '');
205
+ const user = String(opts.user ?? opts.prompt ?? '');
206
+ if (!user) throw new Error('ctx.llm 需要 user(或 prompt)参数');
207
+ // v0.5.0 A4.5:Pack 级预算前置检查(与日费用护栏同语义,粒度到 Pack)。
208
+ // 预算在 pack.json 的 budget 声明;超限按 action 处理(block 抛错 / warn 放行并提示)。
209
+ const packBudget = getActivePackContext()?.mounted?.find((/** @type {any} */ x) => x.name === currentPack)?.budget;
210
+ if (packBudget && Number(packBudget.dailyYuan) > 0) {
211
+ const spent = packDailyCost(currentPack);
212
+ if (spent >= Number(packBudget.dailyYuan)) {
213
+ const msg = `垂域 Pack「${currentPack}」今日费用 ≈¥${spent.toFixed(5)} 已达上限 ¥${Number(packBudget.dailyYuan).toFixed(2)}`;
214
+ if (String(packBudget.action || 'block') === 'block') throw new Error(`${msg}(budget.action=block,已阻止本次模型调用)`);
215
+ try { io.print(style(`⚠ ${msg}(budget.action=warn,本次放行)`, C.yellow)); } catch {}
216
+ }
217
+ }
218
+ const messages = [...(system ? [{ role: 'system', content: system }] : []), { role: 'user', content: user }];
219
+ let prov = provider;
220
+ if (usedModel !== modelName) {
221
+ const pc = resolveProviderConfig(cfg, usedModel);
222
+ if (!pc || !pc.apiKey) throw new Error(`ctx.llm:模型 ${usedModel} 未配置 API Key`);
223
+ prov = await createProvider(cfg, usedModel);
224
+ }
225
+ const subCaps = resolveModelCaps(cfg, usedModel);
226
+ const maxTokens = Math.min(
227
+ Number(opts.maxTokens) > 0 ? Number(opts.maxTokens) : 2048,
228
+ subCaps.maxOutputCeiling || subCaps.maxOutputTokens
229
+ );
230
+ const t0 = Date.now();
231
+ const res = await prov.chat({
232
+ model: usedModel,
233
+ messages,
234
+ tools: [],
235
+ temperature: opts.temperature ?? temperature,
236
+ maxTokens,
237
+ ...(opts.reasoningEffort ? { reasoningEffort: opts.reasoningEffort } : {}),
238
+ ...(opts.json ? { responseFormat: { type: 'json_object' } } : {}),
239
+ ...(opts.signal ? { signal: opts.signal } : {}),
240
+ });
241
+ // 并入当前回合 usage(与子代理同一机制):CLI 直接按 res.usage 入账,
242
+ // WebUI 的 remaining 补记与护栏在途估算随之变准。
243
+ if (currentUsage && res?.usage) {
244
+ const u = res.usage;
245
+ currentUsage.prompt_tokens += u.prompt_tokens || 0;
246
+ currentUsage.completion_tokens += u.completion_tokens || 0;
247
+ if (Number.isFinite(u.prompt_cache_hit_tokens)) {
248
+ currentUsage.prompt_cache_hit_tokens = (currentUsage.prompt_cache_hit_tokens || 0) + u.prompt_cache_hit_tokens;
249
+ }
250
+ if (Number.isFinite(u.prompt_cache_miss_tokens)) {
251
+ currentUsage.prompt_cache_miss_tokens = (currentUsage.prompt_cache_miss_tokens || 0) + u.prompt_cache_miss_tokens;
252
+ }
253
+ }
254
+ // v0.5.0 A4:写一条 **Pack 归因标记记录**(cost=null,不计入 todayCost → 与回合级记录不重复计费;
255
+ // packCost 仅供 `mingdao cost report --by pack` 展示)。这样垂域团队能看到「自己的红线/工具花了多少」。
256
+ try {
257
+ const u = res?.usage || {};
258
+ const pack = currentPack || opts.pack || null;
259
+ const pCost = estimateCost(usedModel, u.prompt_tokens || 0, u.completion_tokens || 0, null);
260
+ recordCacheStats({
261
+ model: usedModel,
262
+ prompt: u.prompt_tokens || 0,
263
+ completion: u.completion_tokens || 0,
264
+ hit: null,
265
+ miss: null,
266
+ cost: null,
267
+ saved: null,
268
+ pack,
269
+ purpose: opts.purpose || null,
270
+ packCost: pCost,
271
+ });
272
+ } catch {}
273
+
274
+ const text = String(res?.text || '');
275
+ let data = null;
276
+ if (opts.json) {
277
+ const a = text.indexOf('{');
278
+ const b = text.lastIndexOf('}');
279
+ if (a >= 0 && b > a) {
280
+ try {
281
+ data = JSON.parse(text.slice(a, b + 1));
282
+ } catch {}
283
+ }
284
+ }
285
+ return { text, data, usage: res?.usage || null, model: usedModel, purpose: opts.purpose || null, durationMs: Date.now() - t0 };
286
+ }
287
+
171
288
  function makeCtx() {
172
289
  return {
173
290
  cwd: workingDir,
@@ -181,6 +298,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
181
298
  todos,
182
299
  undoStore: undo,
183
300
  spawnTask: (/** @type {any} */ prompt, /** @type {any} */ opts) => spawnTask(prompt, opts),
301
+ // v0.5.0 A4:Pack 内所有模型调用必须走这里(禁止自己 fetch 模型接口)——
302
+ // 否则费用隐身、护栏失效、无法归因。详见 PACK-API §5。
303
+ llm: (/** @type {any} */ opts) => packLlm(opts),
184
304
  };
185
305
  }
186
306
 
@@ -251,7 +371,69 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
251
371
  // 整个回合注册一次 SIGINT:思考、工具执行、权限询问期间都能中断
252
372
  const offSigint = io.onSigint ? io.onSigint(() => { aborted = true; currentAc?.abort(); }) : () => {};
253
373
  const ctx = makeCtx();
254
- const stripOrphanCalls = () => {
374
+ /**
375
+ * v0.5.0 A3 ③:输出前领域约束(正文禁用措辞)。
376
+ * 无约束时零开销(constraints.active=false 直接返回原文);有约束时按 action 处理:
377
+ * warn → 放行并附提示;block → 替换为合规说明;block-and-rewrite → 请求一次改写,仍命中则拦截。
378
+ * 改写请求计入本回合 usage(费用归属当前模型),并写约束审计事件。
379
+ */
380
+ const applyOutputConstraints = async (/** @type {any} */ text, /** @type {any} */ ctxRun) => {
381
+ if (!constraints.active || !text) return { text, note: null };
382
+ const hit = checkOutput(constraints, text);
383
+ if (!hit) return { text, note: null };
384
+ auditConstraint(hit.event);
385
+ const cid = hit.constraint?.id || '领域红线';
386
+ if (hit.action === 'warn') {
387
+ try { io.print(style(`⚠ 领域约束「${cid}」提示:${hit.reason}`, C.yellow)); } catch {}
388
+ return { text, note: `⚠ 领域约束「${cid}」:${hit.reason}(已放行,请自行复核)` };
389
+ }
390
+ if (hit.action === 'block-and-rewrite') {
391
+ try {
392
+ io.print(style(`⛔ 领域约束「${cid}」命中,正在自动改写…`, C.yellow));
393
+ const sys = ctxRun.messages.find((/** @type {any} */ m) => m.role === 'system');
394
+ const fix = await ctxRun.provider.chat({
395
+ model: ctxRun.activeModel,
396
+ messages: [
397
+ ...(sys ? [sys] : []),
398
+ {
399
+ role: 'user',
400
+ content: `下面这段回复命中了领域红线(${hit.reason})。请在不改变事实内容的前提下改写成**不含该措辞**的表述,只输出改写后的正文:\n\n${text}`,
401
+ },
402
+ ],
403
+ tools: [],
404
+ temperature: ctxRun.temperature,
405
+ maxTokens: Math.min(ctxRun.maxOutput, 2048),
406
+ signal: ctxRun.signal,
407
+ });
408
+ if (fix?.usage) {
409
+ ctxRun.usage.prompt_tokens += fix.usage.prompt_tokens || 0;
410
+ ctxRun.usage.completion_tokens += fix.usage.completion_tokens || 0;
411
+ }
412
+ const fixed = String(fix?.text || '').trim();
413
+ if (fixed) {
414
+ const re = checkOutput(constraints, fixed);
415
+ if (!re) return { text: fixed, note: `⛔ 领域约束「${cid}」命中,已自动改写` };
416
+ auditConstraint(re.event);
417
+ return { text: blockedOutputText(re), note: `⛔ 领域约束「${re.constraint?.id || cid}」改写后仍命中,已拦截` };
418
+ }
419
+ } catch {
420
+ // 改写失败:回落到拦截(fail-closed)
421
+ }
422
+ return { text: blockedOutputText(hit), note: `⛔ 领域约束「${cid}」命中,已拦截` };
423
+ }
424
+ return { text: blockedOutputText(hit), note: `⛔ 领域约束「${cid}」命中,已拦截` };
425
+ };
426
+
427
+ /** 安全解析工具结果字符串(约束引擎需要结构化对象) */
428
+ const safeParse = (/** @type {any} */ v) => {
429
+ if (typeof v !== 'string') return v;
430
+ try {
431
+ return JSON.parse(v);
432
+ } catch {
433
+ return null;
434
+ }
435
+ };
436
+ const stripOrphanCalls = () => {
255
437
  const last = messages[messages.length - 1];
256
438
  if (last?.role === 'assistant' && Array.isArray(last.tool_calls) && last.tool_calls.length) {
257
439
  messages[messages.length - 1] = { ...last, tool_calls: undefined };
@@ -515,8 +697,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
515
697
  if (steps === stepLimit) {
516
698
  io.endTurn();
517
699
  if (res.text) {
518
- messages.push({ role: 'assistant', content: res.text });
519
- return { text: res.text, reasoning: res.reasoning || '', usage, steps, finish, truncated: false, aborted: false, durationMs: Date.now() - startedAt, perf: perf() };
700
+ const ap = await applyOutputConstraints(res.text, { messages, provider, activeModel, temperature, maxOutput, usage, signal: currentAc?.signal });
701
+ messages.push({ role: 'assistant', content: ap.text });
702
+ return { text: ap.text, reasoning: res.reasoning || '', usage, steps, finish, truncated: false, aborted: false, note: ap.note || undefined, durationMs: Date.now() - startedAt, perf: perf() };
520
703
  }
521
704
  break;
522
705
  }
@@ -610,6 +793,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
610
793
  messages.push({ role: 'tool', tool_call_id: tc.id, content: '用户拒绝了该工具的执行权限。' });
611
794
  return null;
612
795
  }
796
+ // v0.5.0 A3 ①:领域约束(PreToolUse)——权限放行之后、执行之前强制。
797
+ // 与权限引擎的分工:权限回答「用户是否允许」,约束回答「领域是否允许」;两者都通过才执行。
798
+ const cv = checkPreTool(constraints, name, args);
799
+ if (cv?.blocked) {
800
+ io.renderToolDenied(name, args, cv.reason);
801
+ if (auditOn) auditEntry({ denied: true, reason: cv.reason });
802
+ auditConstraint(cv.event);
803
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: `【领域约束】${cv.reason}` });
804
+ return null;
805
+ }
613
806
  return { tc, name, args, isMcp };
614
807
  }
615
808
 
@@ -618,6 +811,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
618
811
  // 后续相同调用直接复用结果(仍逐个回填 tool 消息以保持 tool_call_id 配对)
619
812
  async function runTool(/** @type {any} */ prep) {
620
813
  io.renderToolStart?.(prep.name, prep.args);
814
+ // Pack 工具名形如 pack__<pack>__<tool>;执行期间标注来源,供 ctx.llm 归因
815
+ const pkMatch = /^pack__([a-z0-9-]+)__/.exec(String(prep.name || ''));
816
+ const prevPack = currentPack;
817
+ currentPack = pkMatch ? pkMatch[1] : null;
621
818
  usedToolNames.add(prep.name); // 省钱 B1:执行过即标记,后续轮次省略其 description
622
819
  const dedupKey = !prep.isMcp && READONLY_TOOLS_SET.has(prep.name) ? prep.name + ':' + JSON.stringify(prep.args || {}) : null;
623
820
  if (dedupKey && turnToolCache.has(dedupKey)) {
@@ -639,6 +836,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
639
836
  // 失败也可能已产生副作用(半写入/部分执行)→ 同样作废缓存
640
837
  invalidateReadCache(prep);
641
838
  return JSON.stringify({ ok: false, error: String(err?.message || err) });
839
+ } finally {
840
+ currentPack = prevPack; // 还原(并行只读批次下 finally 保证不乱序)
642
841
  }
643
842
  }
644
843
 
@@ -683,8 +882,15 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
683
882
  outputBytes: Buffer.byteLength(typeof result === 'string' ? result : JSON.stringify(result ?? {}), 'utf8'),
684
883
  });
685
884
  }
686
- const text = typeof result === 'string' ? result : JSON.stringify(result); // 紧凑 JSON(评估 B3):嵌套结果省 10-20% 回填 token,且下轮按 prompt 重复计费
885
+ let text = typeof result === 'string' ? result : JSON.stringify(result); // 紧凑 JSON(评估 B3):嵌套结果省 10-20% 回填 token,且下轮按 prompt 重复计费
687
886
  const prefix = prep.cached ? '(与同回合相同调用结果一致,已复用)\n' : '';
887
+ // v0.5.0 A3 ②:领域约束(PostToolUse)——completeness 缺项时**拒绝该工具结果**,
888
+ // 让模型必须继续采集而不是把「未提及」当作已完成(「缺项绝不编造」从提示词升级为内核强制)。
889
+ const pv = checkPostTool(constraints, prep.name, typeof result === 'string' ? safeParse(result) : result);
890
+ if (pv?.rejected) {
891
+ auditConstraint(pv.event);
892
+ text = `【领域约束】${pv.reason}`;
893
+ }
688
894
  messages.push({ role: 'tool', tool_call_id: prep.tc.id, content: prefix + clampText(text, toolResultCap) });
689
895
  }
690
896
 
@@ -749,8 +955,15 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
749
955
  io.endTurn();
750
956
  // 最终纯文本回复回填消息历史(会话持久化与多轮上下文依赖它);
751
957
  // 空文本不回填,避免个别 API 对 assistant 空 content 报错
958
+ let outputNote = /** @type {any} */ (null);
959
+ let finalText = res.text || '';
752
960
  if (res.text) {
753
- messages.push({ role: 'assistant', content: res.text });
961
+ // v0.5.0 A3 ③:输出前领域约束必须在**回填历史之前**应用——
962
+ // 否则被拦下的违规措辞会留在会话里,下一轮又被当作既成事实喂回模型。
963
+ const ap = await applyOutputConstraints(res.text, { messages, provider, activeModel, temperature, maxOutput, usage, signal: currentAc?.signal });
964
+ finalText = ap.text;
965
+ outputNote = ap.note;
966
+ messages.push({ role: 'assistant', content: finalText });
754
967
  }
755
968
  // 输出被长度上限截断(DeepSeek 推理吃满 maxOutput 时正文为空):让模型从断点续写,绝不静默结束。
756
969
  // 空轮护栏(评估 4.2-2):每轮空输出都是全额 completion 计费(pro 32k 闲时 ≈ ¥0.43/轮),
@@ -803,13 +1016,14 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
803
1016
  continue;
804
1017
  }
805
1018
  return {
806
- text: res.text || '',
1019
+ text: finalText,
807
1020
  reasoning: res.reasoning || '',
808
1021
  usage,
809
1022
  steps,
810
1023
  finish,
811
1024
  truncated: false,
812
1025
  aborted: false,
1026
+ note: outputNote || undefined,
813
1027
  durationMs: Date.now() - startedAt,
814
1028
  perf: perf(),
815
1029
  };
@@ -877,9 +1091,13 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
877
1091
  usage.prompt_tokens += wrapRes.usage.prompt_tokens || 0;
878
1092
  usage.completion_tokens += wrapRes.usage.completion_tokens || 0;
879
1093
  }
880
- if (wrapRes.text) messages.push({ role: 'assistant', content: wrapRes.text });
1094
+ // v0.5.0 A3 ③:兜底总结同样过一遍输出约束
1095
+ const wrapApplied = wrapRes.text
1096
+ ? await applyOutputConstraints(wrapRes.text, { messages, provider, activeModel, temperature, maxOutput, usage, signal: currentAc?.signal })
1097
+ : { text: null, note: null };
1098
+ if (wrapApplied.text) messages.push({ role: 'assistant', content: wrapApplied.text });
881
1099
  // capHit:本轮因步数上限被迫收尾(任务可能未真正完成)→ 供上层落检查点续跑
882
- return { text: wrapRes.text || null, reasoning: wrapRes.reasoning || '', usage, steps, finish, truncated: false, aborted: false, capHit: true, durationMs: Date.now() - startedAt, perf: perf() };
1100
+ return { text: wrapApplied.text || null, reasoning: wrapRes.reasoning || '', usage, steps, finish, truncated: false, aborted: false, capHit: true, note: wrapApplied.note || undefined, durationMs: Date.now() - startedAt, perf: perf() };
883
1101
  } catch (/** @type {any} */ err) {
884
1102
  // v0.4.1:不再静默吞异常——总结失败原因透出,便于定位(此前用户只见「输出截断/无反馈」)
885
1103
  try { io.print(style(`⚠ 兜底总结失败:${String(err?.message || err)}`, C.yellow)); } catch {}
package/src/cachestats.js CHANGED
@@ -35,6 +35,11 @@ export function recordCacheStats(/** @type {any} */ entry) {
35
35
  firstTokenMs: entry.firstTokenMs ?? undefined, // 首 token 延迟(状态栏平均首 token)
36
36
  reasoning: entry.reasoning ?? undefined, // 省钱 B3:推理 token 估算(费用二级分账维度)
37
37
  byTool: Array.isArray(entry.byTool) ? entry.byTool : undefined, // 省钱 B3:逐工具 {tool,calls,ms}
38
+ // v0.5.0 A4:垂域 Pack 归因(**标记记录**:cost 记 null 不计入 todayCost,
39
+ // 真实费用仍在回合级记录里 —— 两者相加不会重复计费;packCost 仅供 --by pack 展示)
40
+ pack: entry.pack ? String(entry.pack) : undefined,
41
+ purpose: entry.purpose ? String(entry.purpose) : undefined,
42
+ packCost: Number.isFinite(Number(entry.packCost)) ? Number(entry.packCost) : undefined,
38
43
  });
39
44
  fs.appendFileSync(cacheStatsFile(), line + '\n');
40
45
  cacheStatsCount += 1;
@@ -172,12 +177,33 @@ export function formatCacheSummary(/** @type {any} */ sum) {
172
177
  }
173
178
 
174
179
  // 分账统计(评估 /cost 升级):按模型分账、今日费用、batch 半价任务、节省归因
180
+ /**
181
+ * v0.5.0 A4.5:某垂域 Pack 的「今日」费用(北京自然日)。
182
+ * 来源是 ctx.llm 写的归因标记记录(packCost 字段)——标记记录本身 cost=null 不计入总额,
183
+ * 因此这里不会与回合级总账重复。
184
+ * @param {any} pack
185
+ */
186
+ export function packDailyCost(pack) {
187
+ const want = String(pack || '');
188
+ if (!want) return 0;
189
+ const start = beijingDayStart().getTime();
190
+ let sum = 0;
191
+ for (const e of listCacheStats(100000)) {
192
+ if (e.pack !== want) continue;
193
+ if (!(e.at >= start)) continue;
194
+ sum += Number(e.packCost) || 0;
195
+ }
196
+ return sum;
197
+ }
198
+
175
199
  // 省钱 B3:新增 reasoning 维度、byTool 逐工具累加、byDay 近 14 天按日折线数据
176
200
  export function costBreakdown() {
177
201
  const entries = listCacheStats(100000);
178
202
  const byModel = new Map();
179
203
  const byTool = /** @type {Map<string, {calls: number, ms: number}>} */ (new Map());
180
204
  const byDay = /** @type {Map<string, number>} */ (new Map()); // 'MM-DD' → cost(近 14 个北京日,含 0 值日)
205
+ // v0.5.0 A4:垂域 Pack 分账(来自 ctx.llm 的标记记录;cost 为 null 不入总额,取 packCost 展示)
206
+ const byPack = /** @type {Map<string, {prompt: number, completion: number, cost: number, calls: number}>} */ (new Map());
181
207
  const start = beijingDayStart().getTime();
182
208
  let totalCost = 0;
183
209
  let totalSaved = 0;
@@ -194,6 +220,14 @@ export function costBreakdown() {
194
220
  reasoning += e.reasoning || 0;
195
221
  if (e.batch) batchCost += e.cost || 0;
196
222
  if (e.at >= start) today += e.cost || 0;
223
+ if (e.pack) {
224
+ const pk = byPack.get(e.pack) || { prompt: 0, completion: 0, cost: 0, calls: 0 };
225
+ pk.prompt += e.prompt || 0;
226
+ pk.completion += e.completion || 0;
227
+ pk.cost += Number(e.packCost) || 0;
228
+ pk.calls += 1;
229
+ byPack.set(e.pack, pk);
230
+ }
197
231
  const m = byModel.get(e.model) || { prompt: 0, completion: 0, cost: 0, saved: 0, turns: 0, batchTurns: 0, reasoning: 0 };
198
232
  m.prompt += e.prompt || 0;
199
233
  m.completion += e.completion || 0;
@@ -237,6 +271,8 @@ export function costBreakdown() {
237
271
  byModel: [...byModel.entries()].map(([model, m]) => ({ model, ...m })).sort((a, b) => b.cost - a.cost),
238
272
  byTool: [...byTool.entries()].map(([tool, t]) => ({ tool, ...t })).sort((a, b) => b.ms - a.ms),
239
273
  byDay: days,
274
+ // v0.5.0 A4:垂域 Pack 分账(ctx.llm 归因;cost 为 null 的标记记录不计入 totalCost,故不重复计费)
275
+ byPack: [...byPack.entries()].map(([pack, v]) => ({ pack, ...v })).sort((a, b) => b.cost - a.cost),
240
276
  };
241
277
  }
242
278
 
package/src/cli.js CHANGED
@@ -205,6 +205,7 @@ async function main() {
205
205
  key: { module: 'key', handler: 'handleKey' },
206
206
  desktop: { module: 'desktop', handler: 'handleDesktop' },
207
207
  diagnose: { module: 'diagnose', handler: 'handleDiagnose' },
208
+ pack: { module: 'pack', handler: 'handlePack' }, // v0.5.0:垂域 Pack 查看/校验/脚手架
208
209
  });
209
210
  const hit = dispatchTable[opts.prompt[0]];
210
211
  if (hit) {
@@ -426,6 +427,16 @@ async function main() {
426
427
  const mounted = mountConfigTools(cfg);
427
428
  if (mounted.length && !jsonMode) io.print(style(`🔧 已挂载声明式工具(config.tools):${mounted.join(', ')}`, C.dim));
428
429
  }
430
+ // v0.5.0 阶段 A:挂载垂域 Pack(工具进注册表;约束与提示词段进进程级上下文)。
431
+ // 坏 Pack 只告警不阻塞启动;未装 Pack 时全部惰性。
432
+ {
433
+ const { mountPacks } = await import('./packs.js');
434
+ const packCtx = await mountPacks(cfg, { cwd: workingDir });
435
+ for (const w of packCtx.warnings) console.error(`[MingDao] ⚠ ${w}`);
436
+ if (packCtx.mounted.length && !jsonMode) {
437
+ io.print(style(`📦 已加载垂域 Pack:${packCtx.mounted.map((p) => `${p.name} v${p.version}`).join(', ')}`, C.dim));
438
+ }
439
+ }
429
440
  // 会话级 undo 备份仓:模型切换、子代理均共享,撤销记录不丢失
430
441
  const sessionUndoStore = { backups: new Map() };
431
442
  // MCP 服务器:后台启动(不阻塞交互),就绪后工具自动出现在后续轮次
@@ -0,0 +1,196 @@
1
+ // `mingdao pack` 命令族(v0.5.0 阶段 A):垂域 Pack 的查看 / 校验 / 脚手架。
2
+ // 下游 CI 门禁入口是 `mingdao pack verify <dir>`——它只做静态校验(不 import Pack 代码),
3
+ // 因此可以在没有内核运行时的环境里安全执行。
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { listPacks, loadPack, validateManifest, coreVersionOf, SUPPORTED_PACK_API, CONSTRAINT_KINDS, packedDirsForHelp } from '../packs.js';
7
+
8
+ const HELP = `用法:
9
+ mingdao pack list 已发现的 Pack(名/版本/来源/兼容状态)
10
+ mingdao pack verify <目录> 静态校验 manifest + 文件齐全 + 约束合法(下游 CI 门禁)
11
+ mingdao pack new <名字> 生成最小可用 Pack 脚手架
12
+ mingdao pack info <名字> 单个 Pack 的贡献面明细
13
+
14
+ 说明:Pack API v${SUPPORTED_PACK_API.join('/')}(契约见 docs/PACK-API.md)。
15
+ 约束 kind:${[...CONSTRAINT_KINDS].join(' / ')}`;
16
+
17
+ /**
18
+ * @param {any} cmd @param {any} args
19
+ * @returns {Promise<boolean>}
20
+ */
21
+ export async function handlePack(cmd, args) {
22
+ if (cmd !== 'pack') return false;
23
+ const sub = String(args[0] || '').trim();
24
+
25
+ if (!sub || sub === 'help' || sub === '--help') {
26
+ console.log(HELP);
27
+ return true;
28
+ }
29
+
30
+ if (sub === 'list') {
31
+ const found = listPacks({}, process.cwd());
32
+ if (!found.length) {
33
+ console.log('未发现任何 Pack。');
34
+ console.log(`放置位置(三级遮蔽):<项目>/.mingdao/packs/ > ${packedDirsForHelp()} > 内置 packs/`);
35
+ console.log('或运行 mingdao pack new <名字> 生成脚手架。');
36
+ return true;
37
+ }
38
+ console.log(`共 ${found.length} 个 Pack(内核 ${coreVersionOf()}):`);
39
+ for (const p of found) {
40
+ const status = p.error ? `❌ ${p.error}` : `✅ apiVersion ${p.apiVersion}`;
41
+ console.log(` ${p.name}${p.displayName ? '(' + p.displayName + ')' : ''} v${p.version || '?'} · 来源 ${p.source} · ${status}`);
42
+ console.log(` ${p.dir}`);
43
+ }
44
+ return true;
45
+ }
46
+
47
+ if (sub === 'verify') {
48
+ const dir = String(args[1] || '').trim();
49
+ if (!dir) {
50
+ console.log('[错误] 用法:mingdao pack verify <目录>');
51
+ process.exitCode = 1;
52
+ return true;
53
+ }
54
+ const abs = path.resolve(dir);
55
+ if (!fs.existsSync(abs)) {
56
+ console.log(`[错误] 目录不存在:${abs}`);
57
+ process.exitCode = 1;
58
+ return true;
59
+ }
60
+ // 静态校验优先(不 import 代码)——把可操作的错误一次性列全
61
+ const mf = path.join(abs, 'pack.json');
62
+ if (!fs.existsSync(mf)) {
63
+ console.log(`[错误] 缺少 pack.json(${abs})`);
64
+ process.exitCode = 1;
65
+ return true;
66
+ }
67
+ let manifest = null;
68
+ try {
69
+ manifest = JSON.parse(fs.readFileSync(mf, 'utf8'));
70
+ } catch (/** @type {any} */ err) {
71
+ console.log(`[错误] pack.json 解析失败:${err?.message || err}`);
72
+ process.exitCode = 1;
73
+ return true;
74
+ }
75
+ const v = validateManifest(manifest);
76
+ if (!v.ok) {
77
+ console.log(`[失败] ${manifest?.name || path.basename(abs)} 的 manifest 校验未通过:`);
78
+ v.errors.forEach((/** @type {any} */ e, /** @type {number} */ i) => console.log(` ${i + 1}. ${e}`));
79
+ process.exitCode = 1;
80
+ return true;
81
+ }
82
+ // 再走一次完整加载(会 import pack.mjs,验证运行时契约)
83
+ const res = await loadPack(abs);
84
+ if (!res.ok) {
85
+ console.log(`[失败] ${manifest.name} 加载校验未通过:`);
86
+ res.errors.forEach((/** @type {any} */ e, /** @type {number} */ i) => console.log(` ${i + 1}. ${e}`));
87
+ process.exitCode = 1;
88
+ return true;
89
+ }
90
+ const c = res.contributions;
91
+ console.log(`[通过] ${manifest.name} v${manifest.version}(apiVersion ${manifest.apiVersion},内核窗口 ${manifest.engines?.mingdao})`);
92
+ console.log(` 工具 ${Array.isArray(c.tools) ? c.tools.length : 0} 个 · 约束 ${Array.isArray(c.constraints) ? c.constraints.length : 0} 条 · 提示词段 ${Array.isArray(c.promptSections) ? c.promptSections.length : 0} 段`);
93
+ for (const w of Array.isArray(res.warnings) ? res.warnings : []) console.log(` ⚠ ${w}`);
94
+ return true;
95
+ }
96
+
97
+ if (sub === 'new') {
98
+ const name = String(args[1] || '').trim();
99
+ if (!/^[a-z0-9][a-z0-9-]{1,31}$/.test(name)) {
100
+ console.log('[错误] 用法:mingdao pack new <名字>(小写字母/数字/连字符,2–32 位)');
101
+ process.exitCode = 1;
102
+ return true;
103
+ }
104
+ const dir = path.resolve(process.cwd(), 'packs', name);
105
+ if (fs.existsSync(dir)) {
106
+ console.log(`[错误] 目录已存在:${dir}`);
107
+ process.exitCode = 1;
108
+ return true;
109
+ }
110
+ fs.mkdirSync(path.join(dir, 'prompts'), { recursive: true });
111
+ fs.writeFileSync(
112
+ path.join(dir, 'pack.json'),
113
+ JSON.stringify(
114
+ {
115
+ apiVersion: SUPPORTED_PACK_API[0],
116
+ name,
117
+ displayName: name,
118
+ version: '1.0.0',
119
+ engines: { mingdao: `>=${coreVersionOf()} <0.7` },
120
+ description: '(一句话说明这个垂域包做什么)',
121
+ license: 'private',
122
+ contributes: { tools: true, promptSections: ['prompts/domain.md'], constraints: true },
123
+ },
124
+ null,
125
+ 2
126
+ ) + '\n'
127
+ );
128
+ fs.writeFileSync(path.join(dir, 'prompts', 'domain.md'), '(在这里写领域提示词:角色、边界、红线。)\n');
129
+ fs.writeFileSync(
130
+ path.join(dir, 'pack.mjs'),
131
+ `// ${name} —— 垂域 Pack 入口。契约见 docs/PACK-API.md(Pack API v1)。
132
+ export const apiVersion = 1;
133
+
134
+ export function createPack(ctx) {
135
+ return {
136
+ tools: [
137
+ {
138
+ name: 'hello',
139
+ description: '(示例工具:说明它能做什么、什么时候该用)',
140
+ parameters: { type: 'object', properties: {}, required: [] },
141
+ readOnly: true,
142
+ async run(_args, toolCtx) {
143
+ // toolCtx.llm(...) 可调用模型(自动入账 / 受护栏约束)——见 PACK-API §5
144
+ return { ok: true, output: '来自 ' + ctx.packName + ' 的问候' };
145
+ },
146
+ },
147
+ ],
148
+ constraints: [
149
+ // 领域红线示例:输出出现这些措辞即拦截(action: block / block-and-rewrite / warn)
150
+ { id: 'no-forbidden-claim', kind: 'output-forbid', pattern: '(替换为你的禁用措辞,正则)', action: 'block-and-rewrite' },
151
+ ],
152
+ promptSections: [{ id: 'domain', order: 100, content: '(由 prompts/domain.md 读取,见下)' }],
153
+ };
154
+ }
155
+ `
156
+ );
157
+ console.log(`✓ 已生成脚手架:${dir}`);
158
+ console.log('下一步:');
159
+ console.log(` 1. 编辑 ${path.join(dir, 'pack.json')}(描述/权限)与 pack.mjs(工具/约束)`);
160
+ console.log(` 2. mingdao pack verify ${dir}`);
161
+ return true;
162
+ }
163
+
164
+ if (sub === 'info') {
165
+ const name = String(args[1] || '').trim();
166
+ const found = listPacks({}, process.cwd()).find((p) => p.name === name);
167
+ if (!found) {
168
+ console.log(`[错误] 未发现 Pack "${name}"。可用:mingdao pack list`);
169
+ process.exitCode = 1;
170
+ return true;
171
+ }
172
+ if (found.error) {
173
+ console.log(`[错误] ${name} 校验未通过:${found.error}`);
174
+ process.exitCode = 1;
175
+ return true;
176
+ }
177
+ const res = await loadPack(found.dir);
178
+ if (!res.ok) {
179
+ console.log(`[错误] ${name} 加载失败:${res.errors.join(';')}`);
180
+ process.exitCode = 1;
181
+ return true;
182
+ }
183
+ const c = res.contributions;
184
+ console.log(`${found.name}${found.displayName ? '(' + found.displayName + ')' : ''} v${res.manifest.version}`);
185
+ console.log(` 来源 ${found.source} · ${found.dir}`);
186
+ console.log(` 兼容 apiVersion ${res.manifest.apiVersion} · engines.mingdao ${res.manifest.engines?.mingdao}`);
187
+ console.log(` 描述 ${res.manifest.description || '(无)'}`);
188
+ console.log(` 工具 ${(Array.isArray(c.tools) ? c.tools : []).map((/** @type {any} */ t) => `pack__${found.name}__${t.name}`).join(', ') || '(无)'}`);
189
+ console.log(` 约束 ${(Array.isArray(c.constraints) ? c.constraints : []).map((/** @type {any} */ x) => `${x.id}(${x.kind})`).join(', ') || '(无)'}`);
190
+ console.log(` 提示词段 ${(Array.isArray(c.promptSections) ? c.promptSections : []).map((/** @type {any} */ s) => s.id).join(', ') || '(无)'}`);
191
+ return true;
192
+ }
193
+
194
+ // 未知子命令:不劫持整句,交回普通提问(与 handleUpdateFamily 同口径的保留词防护)
195
+ return false;
196
+ }