mocode-ai 1.2.6 → 1.2.7

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.
@@ -6,7 +6,7 @@
6
6
  // spawn.ts 的 spawnAgent = runAgentCore + 静默 hooks(子 agent)。
7
7
  import { readFileSync } from 'node:fs';
8
8
  import { getNotesMtime } from '../session/notes.js';
9
- import { chat, estimatePromptTokens, planChatTools, chatTools, } from '../llm/index.js';
9
+ import { chat, estimatePromptTokens, estimateTokens, planChatTools, chatTools, } from '../llm/index.js';
10
10
  import { executeToolOutcome, findTool, getToolCapabilities, isFileMutationTool, } from '../tools/registry.js';
11
11
  import { checkPermission } from '../permissions/index.js';
12
12
  import { validateToolArguments } from '../tools/validation.js';
@@ -15,10 +15,10 @@ import { getAgentMode, setAgentMode } from './mode.js';
15
15
  import { maybeCompact, contextState, createTraceEvent, summarizeToolArguments, safeProviderId, } from '../session/index.js';
16
16
  import { capToolResultForHistory } from '../session/compact.js';
17
17
  import { createBudgetScheduler } from '../session/scheduler.js';
18
- import { recordArtifact, invalidateArtifacts, rehydrateArtifacts, } from '../context/index.js';
18
+ import { recordArtifact, invalidateArtifacts, rehydrateArtifacts, knownEditTargets, } from '../context/index.js';
19
19
  import { createRelevancePruner } from '../context/relevance.js';
20
20
  import { isToolResultSuccess } from '../context/utils.js';
21
- import { config, extractActivePlanSection, reinjectSessionStateIntoSystem } from '../config/index.js';
21
+ import { config, getActiveModel, extractActivePlanSection, buildSessionStateReminder } from '../config/index.js';
22
22
  import { t } from '../i18n/index.js';
23
23
  import { jailResolve } from '../sandbox/index.js';
24
24
  import { createLifecycleEngine } from '../context/lifecycle.js';
@@ -40,6 +40,27 @@ function parseArgs(raw) {
40
40
  return null;
41
41
  }
42
42
  }
43
+ /** 需要「已知编辑目标」恢复提示的文件编辑工具;其余工具的参数报错不注入该提示。 */
44
+ const EDIT_HINT_TOOLS = new Set(['edit_file', 'write_file']);
45
+ /** 文件编辑工具参数校验失败时的恢复提示:把系统已知仍新鲜的「最近 read_file 的
46
+ * path + hash」直接递给模型照抄,替代其在长上下文里凭记忆复述。
47
+ * 只展示事实、不替模型填值;没有候选(从未 read_file / 全部已失效)返 undefined。 */
48
+ function argumentErrorHint(name, state) {
49
+ if (!EDIT_HINT_TOOLS.has(name))
50
+ return undefined;
51
+ const targets = knownEditTargets(state);
52
+ if (targets.length === 0)
53
+ return undefined;
54
+ const lines = targets.map((target) => ` path=${target.path} expected_hash=${target.hash}`).join('\n');
55
+ return ('系统已知最近 read_file 且尚未被修改的文件(直接复制下面的 path / expected_hash,勿凭记忆复述):\n' +
56
+ `${lines}\n如目标文件不在其中,先 read_file 该文件再发起编辑。`);
57
+ }
58
+ /** 判定 assistant content 是否只是 "Tool results:" 这类工具结果前缀噪声。
59
+ * 部分模型(如 Claude)会在 tool_calls 前输出此种无意义过渡文本,写入 history
60
+ * 会污染后续轮次上下文并在 TUI 上泄露为孤立行。 */
61
+ function isToolResultsNoise(content) {
62
+ return /^(?:\s*Tool results:\s*)+$/i.test(content.trim());
63
+ }
43
64
  /** 只有显式声明 parallel 且无需权限确认的工具才进入普通并发组。 */
44
65
  function isParallelTool(name) {
45
66
  const tool = findTool(name);
@@ -203,6 +224,7 @@ export async function runAgentCore(opts) {
203
224
  completionTokens: turnUsage.completionTokens + u.completionTokens,
204
225
  totalTokens: turnUsage.totalTokens + u.totalTokens,
205
226
  cachedTokens: turnUsage.cachedTokens + u.cachedTokens,
227
+ cacheCreationTokens: (turnUsage.cacheCreationTokens ?? 0) + (u.cacheCreationTokens ?? 0),
206
228
  reasoningTokens: turnUsage.reasoningTokens + u.reasoningTokens,
207
229
  }
208
230
  : u;
@@ -283,10 +305,16 @@ export async function runAgentCore(opts) {
283
305
  const activeTools = opts.toolsOverride
284
306
  ?? (getAgentMode() === 'plan' ? planChatTools : chatTools);
285
307
  const requestBaseURL = config.baseURL;
286
- const requestModel = config.model;
308
+ const requestModel = getActiveModel();
287
309
  const storedCalibration = getTokenCalibration(requestBaseURL, requestModel, activeTools);
288
310
  runtimeContextState.correction = storedCalibration.correction;
289
311
  runtimeContextState.calibrationSamples = storedCalibration.samples;
312
+ // 会话状态(活跃 plan + 笔记正文)在调度器**之前**取一次:
313
+ // ① 它会被追加到本次请求末尾(见下方 ephemeralReminder),属于本步固定开销,
314
+ // 必须计入压力线——它不在 history 里,调度器只能由此入参看见(否则最多 5k
315
+ // 的笔记 + plan 段对 80% 触发线完全不可见,小窗口模型会压不住);
316
+ // ② 同一份字符串复用到下方 reminder,避免每步重复读 notes.md。
317
+ const sessionStateText = opts.suppressSessionState ? '' : buildSessionStateReminder();
290
318
  // The scheduler is the only automatic path that may compress old evidence.
291
319
  // Normal tool pushes and lifecycle tracking remain metadata-only.
292
320
  // 步前:五区 Budget Scheduler 在当前完整 history 上决策;开关关闭时退化回 maybeCompact 路径。
@@ -294,7 +322,7 @@ export async function runAgentCore(opts) {
294
322
  let historyRebuilt = false;
295
323
  const compactStartedAt = Date.now();
296
324
  if (scheduler) {
297
- historyRebuilt = await scheduler.runStep(history, step, activeTools);
325
+ historyRebuilt = await scheduler.runStep(history, step, activeTools, sessionStateText ? estimateTokens(sessionStateText) : 0);
298
326
  if (scheduler.lastRunLog?.compactHistoryCalled) {
299
327
  emitTrace('compact', {
300
328
  source: 'automatic',
@@ -326,8 +354,8 @@ export async function runAgentCore(opts) {
326
354
  runtimeContextState.lifecycleStats = lifecycle.stats();
327
355
  }
328
356
  rehydrateArtifacts(runtimeContextState, history);
329
- // ② compact 后把会话状态(活跃 plan + 笔记段)重注入系统提示,避免 agent 因上下文压缩丢失计划与笔记。
330
- reinjectSessionStateIntoSystem(history);
357
+ // 会话状态(活跃 plan + 笔记段)不再回写 history[0]:每步都会在 requestHistory
358
+ // 末尾注入最新副本(见下方 ephemeralReminder),compact 后自然恢复。
331
359
  }
332
360
  hooks.onStepStart?.(); // 主 agent:spinner.start('思考中')
333
361
  mode = 'idle';
@@ -337,31 +365,29 @@ export async function runAgentCore(opts) {
337
365
  const modelStartedAt = Date.now();
338
366
  const provider = safeProviderId(requestBaseURL);
339
367
  emitTrace('model_start', { model: requestModel, provider });
340
- // 动态注入(仅追加到 system 末尾,不触碰 staticBody 前缀 → 不破坏 prompt 缓存):
341
- // - 开场分析:仅主线(step===0 !suppressOpeningAnalysis)注入——即"用户发一个任务后,
342
- // agent 第一次模型调用"。子代理经 spawn.ts suppressOpeningAnalysis:true 排除。
343
- // - historyRebuilt:compact 恢复步;要求重新锚定目标。两者可叠加但互不串扰。
344
- // .filter(Boolean) 保证空段不产生多余空行;后续 step 均为空串 → system 前缀稳定命中缓存。
345
- // 安全保证:此段只拼进 requestHistory(新建对象),绝不回写 history[0],故不会跨 step/跨 turn 残留。
346
- const dynamicSystemSuffix = [
368
+ // 动态注入(prompt 缓存关键):所有随步/随文件变化的提示统一拼成**历史末尾**一条
369
+ // ephemeral system 消息,不再改写 history[0]。这样系统提示 + 已有对话逐字节稳定,
370
+ // 支持自动前缀缓存的后端(OpenAI / DeepSeek / GLM / Qwen)可从头命中,只有尾部这
371
+ // 一小条随内容变化;若改写 history[0],单次 plan_update 就会让 6-8k 的系统提示
372
+ // 在本轮后续每步全价重算。
373
+ // - 开场分析:仅主线(step===0 !suppressOpeningAnalysis)——用户发任务后 agent
374
+ // 的第一次模型调用。子代理经 spawn.ts suppressOpeningAnalysis:true 排除。
375
+ // 放在尾部还有额外好处:step 0 与 step 1 的前缀不再因这段的出现/消失而错位。
376
+ // - historyRebuilt:compact 恢复步,要求重新锚定目标。
377
+ // - 会话状态:notes.md 的活跃 plan + 笔记正文(纯读,每步重取,始终最新)。
378
+ // .filter(Boolean) 保证空段不产生多余空行;三段全空时不追加任何消息(requestHistory === history)。
379
+ // 安全保证:只拼进 requestHistory(新建数组),绝不写回 history,故不会跨 step/跨 turn 残留。
380
+ const ephemeralReminder = [
347
381
  (!opts.suppressOpeningAnalysis && step === 0)
348
382
  ? '## Opening analysis\nBegin your FIRST response of this turn with a brief analysis of the request and your planned approach (1-3 sentences, no filler), THEN start tool calls. This opening is the only place where pre-tool prose is expected; after it, work quietly with no narration between tool calls.'
349
383
  : '',
350
384
  historyRebuilt
351
385
  ? '## Post-compaction recovery\nContext was compacted before this request. Re-establish the current objective and unresolved work from retained evidence or the session note, avoid repeating completed investigation, and re-read exact file context before any dependent edit.'
352
386
  : '',
387
+ sessionStateText, // 调度器之前已取(并计入压力线),此处复用同一份,不重复读文件
353
388
  ].filter(Boolean).join('\n\n');
354
- const systemMessage = history[0];
355
- const requestHistory = dynamicSystemSuffix
356
- && systemMessage?.role === 'system'
357
- && typeof systemMessage.content === 'string'
358
- ? [
359
- {
360
- ...systemMessage,
361
- content: `${systemMessage.content}\n\n${dynamicSystemSuffix}`,
362
- },
363
- ...history.slice(1),
364
- ]
389
+ const requestHistory = ephemeralReminder
390
+ ? [...history, { role: 'system', content: ephemeralReminder }]
365
391
  : history;
366
392
  // 实时用量:当前步 prompt 估算(含校准系数)+ 流式累计 completion 估算,
367
393
  // 叠上已完成步的实测 turnUsage,经 onLiveUsage 推给底栏实时 chip。
@@ -461,6 +487,14 @@ export async function runAgentCore(opts) {
461
487
  onContextUpdate?.();
462
488
  if (result.toolCalls.length > 0) {
463
489
  toolCallCount += result.toolCalls.length;
490
+ // 若 content 只是 Claude 式 "Tool results:" 噪声,清空它:不补换行、不写入 history,
491
+ // 避免污染后续轮次上下文并在 TUI 泄露为孤立行。
492
+ if (result.content && isToolResultsNoise(result.content)) {
493
+ result.content = null;
494
+ mode = 'idle';
495
+ gotText = false;
496
+ lastChar = '';
497
+ }
464
498
  // 流式正文末尾补换行(若 onToolCall 已补则 lastChar='\n',此处 no-op);防 ● 行黏在正文行尾
465
499
  if (mode !== 'idle' && lastChar !== '\n')
466
500
  hooks.onTextEnd?.();
@@ -635,14 +669,18 @@ export async function runAgentCore(opts) {
635
669
  const firstAllowed = entries.find((entry) => !entry.denied);
636
670
  if (firstAllowed)
637
671
  hooks.onToolStart?.(firstAllowed.tc.name);
638
- const started = entries.map((entry) => entry.denied
639
- ? Promise.resolve(entry.denied)
640
- : executeToolOutcome(entry.tc.name, entry.tc.arguments, signal, {
672
+ const started = entries.map((entry) => {
673
+ if (entry.denied)
674
+ return Promise.resolve(entry.denied);
675
+ const hint = argumentErrorHint(entry.tc.name, runtimeContextState);
676
+ return executeToolOutcome(entry.tc.name, entry.tc.arguments, signal, {
641
677
  callId: entry.tc.id,
678
+ ...(hint ? { argumentErrorHint: hint } : {}),
642
679
  onLockAcquired: (lockedArgs) => {
643
680
  entry.diff = readDiffContext(entry.tc, lockedArgs);
644
681
  },
645
- }));
682
+ });
683
+ });
646
684
  for (let k = 0; k < entries.length; k++) {
647
685
  const entry = entries[k];
648
686
  const outcome = await started[k];
@@ -726,8 +764,10 @@ export async function runAgentCore(opts) {
726
764
  : null;
727
765
  let diff = readDiffContext(tc, mutationParsed);
728
766
  hooks.onToolStart?.(tc.name);
767
+ const serialHint = argumentErrorHint(tc.name, runtimeContextState);
729
768
  const outcome = await executeToolOutcome(tc.name, tc.arguments, signal, {
730
769
  callId: tc.id,
770
+ ...(serialHint ? { argumentErrorHint: serialHint } : {}),
731
771
  onLockAcquired: (lockedArgs) => {
732
772
  if (mutationParsed)
733
773
  diff = readDiffContext(tc, lockedArgs);
@@ -763,12 +803,12 @@ export async function runAgentCore(opts) {
763
803
  i++;
764
804
  }
765
805
  }
766
- // A(事件驱动重同步):本步若改动了 notes.md,把最新 plan 块刷回 history[0],
767
- // 让模型上下文镜像当前勾选态(不再停留在轮首的旧副本)。只在 mtime 变化时触发,零额外 churn。
806
+ // A(计划触碰计数):本步若改动了 notes.md 则清零计数。会话状态本身无需在此重注入——
807
+ // 每步都会由 buildSessionStateReminder() requestHistory 末尾重建最新副本(见上方注入点),
808
+ // 所以模型下一步看到的必然是当前勾选态。只保留计数,避免多余的 history 改写(prompt 缓存)。
768
809
  // B(nag 提醒):连续 N 步有工具活动但没更新 plan,在当前步第一条 tool_result 前注入提醒。
769
810
  const notesMtimeAfter = getNotesMtime();
770
811
  if (notesMtimeAfter !== notesMtimeBefore) {
771
- reinjectSessionStateIntoSystem(history);
772
812
  stepsSincePlanTouch = 0;
773
813
  }
774
814
  else {
@@ -7,7 +7,6 @@ import { Spinner } from '../ui/spinner.js';
7
7
  import { summarizeToolCall, summarizeToolResult, truncateDisplay, fmtElapsed, } from '../ui/render.js';
8
8
  import { renderFileChange } from '../ui/diff.js';
9
9
  import * as layout from '../ui/layout.js';
10
- import * as content from '../ui/content.js';
11
10
  import * as batch from '../ui/batch.js';
12
11
  import { beginTurn } from '../rollback/index.js';
13
12
  import { config } from '../config/index.js';
@@ -27,17 +26,10 @@ let subAgentGroupPendingSeparator = false;
27
26
  /** 派生子 agent 的工具名。它的调用要独占一批:子 agent 的实时工具明细会挂到这一行下面,
28
27
  * 并行派发多个子 agent 时,每个子 agent 才有自己可归属的摘要行。 */
29
28
  const SUB_AGENT_TOOL = 'sub-agent';
30
- /** 缓冲尾部是否已经是空白行(去掉 ANSI 后无可见字符)。用于避免 sub-agent 分隔空行叠成两行。 */
29
+ /** 缓冲尾部是否已经是空白行(去掉 ANSI 后无可见字符)。用于避免 sub-agent 分隔空行叠成两行。
30
+ * 实现委托 layout.isLastContentRowBlank(compact 等模块共用同一判断)。 */
31
31
  function isLastContentRowBlank() {
32
- // 用 committedRows 而不是 totalRows:hasCurrent 那行是未提交的光标等待位,
33
- // 永远空白,不能把它当作“已经有一条空行分隔”。
34
- const committed = content.committedRows();
35
- if (committed === 0)
36
- return false;
37
- const line = content.lineAt(committed - 1);
38
- if (line === null)
39
- return false;
40
- return line.replace(/\x1b\[[0-9;]*m/g, '').trim().length === 0;
32
+ return layout.isLastContentRowBlank();
41
33
  }
42
34
  let turnFileChanges = [];
43
35
  function lineDelta(oldText, newText) {
@@ -274,33 +266,73 @@ onContextUpdate) {
274
266
  let textBoundaryNewlines = 0;
275
267
  let hasPendingTextBoundary = false;
276
268
  let toolBatchFollowsText = false;
269
+ // Claude 等模型在 tool_calls 前常输出 "Tool results:" 这类无意义叙述。
270
+ // 它违反 silent execution,会在正文区泄露成孤立行。这里先缓冲,若累积内容
271
+ // 只是该噪声则抑制;一旦后面跟了实质正文,再丢弃噪声前缀并 flush。
272
+ let pendingNarration = '';
273
+ let narrationIsNoise = false;
274
+ const TOOL_RESULTS_NOISE_RE = /^(?:\s*Tool results:\s*)+$/i;
275
+ function writeAssistantText(s) {
276
+ const inToolBlock = currentBatchId !== null || subAgentGroupId !== null || subAgentGroupPendingSeparator;
277
+ if (inToolBlock && s.trim().length === 0)
278
+ return;
279
+ const followsToolBatch = inToolBlock;
280
+ // batch 收尾已经统一留了一条空白行。部分后端会把下一段正文以 \n / \n\n
281
+ // 开头发来;去掉这些“边界换行”,避免与 UI 分隔叠成两条空白行。
282
+ const visible = followsToolBatch ? s.replace(/^(?:[ \t]*\r?\n)+/, '') : s;
283
+ // 正文是工具批次边界:只有“连续且中间没有正文”的工具调用才合并。
284
+ // 一旦模型开始解释阶段结果,立即收尾当前摘要;后续工具重新建立批次。
285
+ if (visible)
286
+ flushToolBatch();
287
+ spinner.stop(); // 任何正文 token 都停 spinner(首 token 停「思考中」;onToolCall 重启后若又来文本则停「生成中」)。未旋转时 stop 为 no-op。
288
+ layout.contentWriteMd(visible); // 正文走 markdown 渲染(代码块高亮 / 标题 / 列表 / 行内 …),见 ui/markdown.ts
289
+ if (visible) {
290
+ lastChar = visible[visible.length - 1];
291
+ if (visible.trim().length > 0) {
292
+ hasPendingTextBoundary = true;
293
+ textBoundaryNewlines = 0;
294
+ }
295
+ }
296
+ }
297
+ function trySuppressNoise(s) {
298
+ pendingNarration += s;
299
+ const stripped = pendingNarration.replace(/\s+/g, ' ').trim();
300
+ if (TOOL_RESULTS_NOISE_RE.test(stripped)) {
301
+ narrationIsNoise = true;
302
+ return null;
303
+ }
304
+ let visible = pendingNarration;
305
+ if (narrationIsNoise) {
306
+ visible = pendingNarration.replace(/^(?:\s*Tool results:\s*)+/i, '');
307
+ narrationIsNoise = false;
308
+ }
309
+ pendingNarration = '';
310
+ return visible;
311
+ }
312
+ function flushPendingNarrationIfAny() {
313
+ if (!pendingNarration)
314
+ return;
315
+ const stripped = pendingNarration.replace(/\s+/g, ' ').trim();
316
+ if (TOOL_RESULTS_NOISE_RE.test(stripped)) {
317
+ pendingNarration = '';
318
+ narrationIsNoise = false;
319
+ return;
320
+ }
321
+ const visible = pendingNarration;
322
+ pendingNarration = '';
323
+ narrationIsNoise = false;
324
+ writeAssistantText(visible);
325
+ }
277
326
  const hooks = {
278
327
  onText: (s) => {
279
- // 纯空白 chunk 在视觉上不是正文:既不切 batch,也不写入 markdown 缓冲。
280
- // 部分兼容后端会在连续工具轮次间流出 " " / "\n",若据此切批会漏掉首个工具。
281
- // sub-agent 独占批不占 currentBatchId,但同样处在“工具块刚结束”的边界上。
282
- const inToolBlock = currentBatchId !== null || subAgentGroupId !== null || subAgentGroupPendingSeparator;
283
- if (inToolBlock && s.trim().length === 0)
284
- return;
285
- const followsToolBatch = inToolBlock;
286
- // batch 收尾已经统一留了一条空白行。部分后端会把下一段正文以 \n / \n\n
287
- // 开头发来;去掉这些“边界换行”,避免与 UI 分隔叠成两条空白行。
288
- const visible = followsToolBatch ? s.replace(/^(?:[ \t]*\r?\n)+/, '') : s;
289
- // 正文是工具批次边界:只有“连续且中间没有正文”的工具调用才合并。
290
- // 一旦模型开始解释阶段结果,立即收尾当前摘要;后续工具重新建立批次。
291
- if (s)
292
- flushToolBatch();
293
- spinner.stop(); // 任何正文 token 都停 spinner(首 token 停「思考中」;onToolCall 重启后若又来文本则停「生成中」)。未旋转时 stop 为 no-op。
294
- layout.contentWriteMd(visible); // 正文走 markdown 渲染(代码块高亮 / 标题 / 列表 / 行内 …),见 ui/markdown.ts
295
- if (visible) {
296
- lastChar = visible[visible.length - 1];
297
- if (visible.trim().length > 0) {
298
- hasPendingTextBoundary = true;
299
- textBoundaryNewlines = 0;
300
- }
301
- }
328
+ const visible = trySuppressNoise(s);
329
+ if (visible === null)
330
+ return; // 纯噪声,抑制
331
+ writeAssistantText(visible);
302
332
  },
303
333
  onToolCall: (name) => {
334
+ // 工具调用开始前,先 flush 被抑制的叙述缓冲(若是纯噪声则丢弃)。
335
+ flushPendingNarrationIfAny();
304
336
  // 文本/思考已流完,模型转而生成 tool_call 参数(可能很长,如 write_file 整篇内容):
305
337
  // 补换行(让随后的 ● 行与 diff 不黏在正文末尾)+ 启「生成中」内联 spinner,内容区不再干等。
306
338
  if (hasPendingTextBoundary) {
@@ -358,6 +390,7 @@ onContextUpdate) {
358
390
  layout.contentWrite(`${ui.dim}${t('agent.aborted')}${ui.reset}\n`);
359
391
  },
360
392
  onDone: (elapsedMs, usage) => {
393
+ flushPendingNarrationIfAny();
361
394
  flushToolBatch();
362
395
  writeChangeOverview();
363
396
  const tok = formatTurnTokens(usage);
@@ -427,9 +460,12 @@ function formatTurnTokens(usage) {
427
460
  return '';
428
461
  const fmt = (n) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(total >= 10000 ? 0 : 1)}k`);
429
462
  const cached = usage.cachedTokens;
463
+ const cacheCreated = usage.cacheCreationTokens ?? 0;
430
464
  const reasoning = usage.reasoningTokens;
431
465
  const billablePrompt = usage.promptTokens - cached;
432
466
  const extras = [];
467
+ if (cacheCreated > 0)
468
+ extras.push(`cache created ${fmt(cacheCreated)}`);
433
469
  if (cached > 0)
434
470
  extras.push(`${Math.round((cached / Math.max(1, usage.promptTokens)) * 100)}% cached`);
435
471
  if (reasoning > 0)
@@ -250,6 +250,10 @@ export async function spawnAgent(opts) {
250
250
  toolsOverride,
251
251
  contextState: localContextState,
252
252
  suppressOpeningAnalysis: true, // 子代理不注入「开场分析」:仅主线面对用户的首次响应用
253
+ // 子代理不注入主会话「会话状态」(plan + 笔记):systemPrompt 已用
254
+ // buildMocodeCorePrompt() 切掉会话私有尾段,工具表也排除了 plan_update——
255
+ // 灌主计划只会干扰窄 worker 并白付 token。
256
+ suppressSessionState: true,
253
257
  onToolOutcome: (tool, args) => {
254
258
  if (tool === 'read_file' && typeof args.path === 'string')
255
259
  readSet.add(args.path);
@@ -0,0 +1,230 @@
1
+ // `mocode skill` CLI 子命令(skill 自进化 Phase 0/1 的用户入口)。
2
+ //
3
+ // mocode skill eval <name> [--runs N] [--threshold x] [--timeout ms]
4
+ // 触发评测:对 <skill-dir>/evals/trigger.json 的每条 query 跑单轮 agent,
5
+ // 统计 use_skill/run_skill 触发率,输出 PASS/FAIL 报告并落盘 JSON。
6
+ //
7
+ // mocode skill improve <name> [--runs N] [--threshold x] [--iterations N]
8
+ // [--holdout x] [--timeout ms] [--apply]
9
+ // description 进化循环:train/holdout 切分 + LLM 提案 + 接受门,默认 dry-run
10
+ // 只打印最佳 description;--apply 才写回 SKILL.md(写前显式确认;非 TTY 拒绝)。
11
+ //
12
+ // mocode skill usage [name]
13
+ // 展示使用台账(Phase 0):按 skill 的调用次数/成功率/最近失败。
14
+ //
15
+ // 由 index.ts 在 `mocode skill …` 时动态加载(与 mocode config 同模式):
16
+ // 此时才引入 config 单例(LLM 配置)+ agent 依赖图,缺 LLM 配置时给友好报错而非崩。
17
+ // 纯打印/非 TUI:本进程不进 alt screen,输出走 stdout。
18
+ import * as readline from 'node:readline';
19
+ import path from 'node:path';
20
+ import { config, isModelConfigured } from '../config/index.js';
21
+ import { listSkills } from '../skills/index.js';
22
+ import { loadSkillForEval, loadTriggerEvalSet, triggerEvalTemplate, triggerEvalPath, runTriggerEval, renderTriggerReport, saveTriggerReport, validateEvalParams, } from '../skills/skill-eval.js';
23
+ import { runImproveLoop, applyImprovedDescription } from '../skills/skill-improve.js';
24
+ import { loadSkillUsage, aggregateSkillStats, skillStatsPath } from '../skills/stats.js';
25
+ function usage() {
26
+ console.log(`mocode skill — skill 自进化(触发评测 / description 进化 / 使用台账)
27
+
28
+ 用法:
29
+ mocode skill eval <name> [选项] 触发评测(需要 <skill-dir>/evals/trigger.json)
30
+ mocode skill improve <name> [选项] description 进化循环(默认 dry-run)
31
+ mocode skill usage [name] 展示使用台账
32
+
33
+ eval 选项:
34
+ --runs <n> 每 query 运行次数(默认 3;1..10)
35
+ --threshold <x> 触发率判定阈值(默认 0.5;>0 且 ≤1)
36
+ --timeout <ms> 单 query 超时(默认 60000)
37
+
38
+ improve 选项:
39
+ --runs <n> 同 eval(默认 3)
40
+ --threshold <x> 同 eval(默认 0.5)
41
+ --iterations <n> 迭代上限(默认 5)
42
+ --holdout <x> holdout 比例(默认 0.4;0 禁用)
43
+ --timeout <ms> 单 query 超时(默认 60000)
44
+ --apply 把最佳 description 写回 SKILL.md(默认 dry-run,仅打印)
45
+
46
+ eval 集格式(<skill-dir>/evals/trigger.json,非空数组):
47
+ [
48
+ { "query": "应该触发该 skill 的真实请求", "should_trigger": true },
49
+ { "query": "不应触发的相近请求", "should_trigger": false }
50
+ ]
51
+
52
+ 注: 进化对象仅限 ~/.mocode/skills 与 <cwd>/.mocode/skills 下的 skill(内置 skill 不可进化)。
53
+ 没有 evals/trigger.json 的 skill 只能 eval 前手工补建,improve 一律拒绝(没验证门不优化)。`);
54
+ }
55
+ function numArg(args, name, fallback) {
56
+ const i = args.indexOf(name);
57
+ if (i === -1 || i + 1 >= args.length)
58
+ return fallback;
59
+ const v = Number(args[i + 1]);
60
+ if (!Number.isFinite(v) || v <= 0) {
61
+ throw new Error(`${name} 需要正数: ${args[i + 1]}`);
62
+ }
63
+ return v;
64
+ }
65
+ function parseCommon(args) {
66
+ const runs = Math.round(numArg(args, '--runs', 3));
67
+ const threshold = numArg(args, '--threshold', 0.5);
68
+ const timeoutMs = Math.round(numArg(args, '--timeout', 60_000));
69
+ const err = validateEvalParams(runs, threshold);
70
+ if (err)
71
+ throw new Error(err);
72
+ return { runs, threshold, timeoutMs };
73
+ }
74
+ function requireModel() {
75
+ if (!isModelConfigured()) {
76
+ console.error('未配置 LLM(缺 LLM_BASE_URL / LLM_API_KEY)。先运行 `mocode config` 或设置环境变量。');
77
+ process.exit(1);
78
+ }
79
+ }
80
+ /** 定位 eval 集;缺失时打印模板并退出(不猜、不自动建)。 */
81
+ function requireEvalSet(skillName) {
82
+ const skill = loadSkillForEval(skillName);
83
+ const cases = loadTriggerEvalSet(skill);
84
+ if (!cases) {
85
+ console.error(`未找到触发评测集: ${path.relative(process.cwd(), triggerEvalPath(skill))}`);
86
+ console.error('手工创建该文件(格式):');
87
+ console.error(triggerEvalTemplate(skill));
88
+ process.exit(1);
89
+ }
90
+ return { skill, cases };
91
+ }
92
+ async function evalCommand(args) {
93
+ const rest = args.slice();
94
+ const name = rest.find((a) => !a.startsWith('--'));
95
+ if (!name)
96
+ throw new Error('缺少 skill 名。见: mocode skill eval --help');
97
+ requireModel();
98
+ const { skill, cases } = requireEvalSet(name);
99
+ const { runs, threshold, timeoutMs } = parseCommon(rest);
100
+ console.log(`评测 skill "${skill.name}" — ${cases.length} 条 query × ${runs} 次,模型 ${config.model}\n`);
101
+ const startedAt = Date.now();
102
+ const report = await runTriggerEval(skill, skill.description, cases, runs, threshold, {
103
+ timeoutMs,
104
+ onProgress: (_d, _t, line) => process.stdout.write(line + '\n'),
105
+ });
106
+ const saved = saveTriggerReport(report);
107
+ console.log(`\n${renderTriggerReport(report, { runsPerQuery: runs, threshold })}`);
108
+ console.log(`\n耗时 ${((Date.now() - startedAt) / 1000).toFixed(1)}s,结果已存: ${path.relative(process.cwd(), saved)}`);
109
+ if (report.summary.passed < report.summary.total) {
110
+ console.log('存在失败项。可用 `mocode skill improve ' + skill.name + '` 尝试自动优化 description(dry-run)。');
111
+ process.exitCode = 1;
112
+ }
113
+ }
114
+ function confirmApply(skillName) {
115
+ if (!process.stdin.isTTY) {
116
+ // 非 TTY fail closed:自动落盘必须有人在场。
117
+ console.error('非 TTY 环境拒绝 --apply 自动落盘。请在终端运行,或手工编辑 SKILL.md 的 description。');
118
+ process.exit(1);
119
+ }
120
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
121
+ return new Promise((resolve) => {
122
+ rl.question(`确认把新 description 写回 skill "${skillName}" 的 SKILL.md?(y/N) `, (ans) => {
123
+ rl.close();
124
+ resolve(ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes');
125
+ });
126
+ });
127
+ }
128
+ async function improveCommand(args) {
129
+ const rest = args.slice();
130
+ const name = rest.find((a) => !a.startsWith('--'));
131
+ if (!name)
132
+ throw new Error('缺少 skill 名。见: mocode skill improve --help');
133
+ requireModel();
134
+ const { skill, cases } = requireEvalSet(name);
135
+ const { runs, threshold, timeoutMs } = parseCommon(rest);
136
+ const iterations = Math.round(numArg(rest, '--iterations', 5));
137
+ const holdout = (() => {
138
+ const i = rest.indexOf('--holdout');
139
+ const v = i === -1 ? 0.4 : Number(rest[i + 1]);
140
+ if (i !== -1 && (!Number.isFinite(v) || v < 0 || v > 0.9))
141
+ throw new Error('--holdout 需要 0..0.9 的数');
142
+ return v;
143
+ })();
144
+ const apply = rest.includes('--apply');
145
+ console.log(`进化 skill "${skill.name}" — ${cases.length} 条 query,train/holdout=${1 - holdout}/${holdout},${apply ? '将写回(--apply)' : 'dry-run(不落盘)'}\n`);
146
+ const result = await runImproveLoop(skill, cases, {
147
+ maxIterations: iterations,
148
+ runsPerQuery: runs,
149
+ threshold,
150
+ holdout,
151
+ timeoutMs,
152
+ onIteration: (_i, _max, line) => console.log(line),
153
+ });
154
+ console.log('');
155
+ if (!result.improved) {
156
+ console.log(`没有更优的 description(原 description 已是最佳或循环未产出改进)。`);
157
+ console.log(`原: ${result.originalDescription}`);
158
+ return;
159
+ }
160
+ console.log(`原 description: ${result.originalDescription}`);
161
+ console.log(`新 description: ${result.bestDescription}`);
162
+ if (apply) {
163
+ const ok = await confirmApply(skill.name);
164
+ if (!ok) {
165
+ console.log('已取消写回(dry-run 结果见上,可手工应用)。');
166
+ return;
167
+ }
168
+ applyImprovedDescription(skill, result.bestDescription);
169
+ console.log(`已写回: ${skill.skillMdPath}`);
170
+ console.log('提示: 内容哈希已变更,project 级 skill 下次 run_skill 会重新要求信任确认。');
171
+ }
172
+ else {
173
+ console.log('dry-run:未写文件。确认满意后加 --apply 重跑,或直接手工把新 description 写进 SKILL.md。');
174
+ }
175
+ }
176
+ function usageCommand(args) {
177
+ const filter = args.find((a) => !a.startsWith('--'));
178
+ const records = loadSkillUsage();
179
+ const summaries = aggregateSkillStats(records);
180
+ const list = filter ? summaries.filter((s) => s.skill === filter) : summaries;
181
+ if (list.length === 0) {
182
+ console.log(`(台账为空: ${path.relative(process.cwd(), skillStatsPath())})`);
183
+ console.log('台账在模型调用 use_skill / run_skill 时自动记录,跑几个会话后这里有数。');
184
+ return;
185
+ }
186
+ for (const s of list) {
187
+ const rate = s.runSuccessRate === null ? '' : ` run 成功率 ${(s.runSuccessRate * 100).toFixed(0)}%`;
188
+ console.log(`${s.skill} ×${s.total}(use ${s.uses} / run ${s.runs})${rate} 最近 ${s.lastUsedAt}`);
189
+ if (s.lastFailure) {
190
+ console.log(` 最近失败: ${s.lastFailure.status}${s.lastFailure.code ? ` (${s.lastFailure.code})` : ''} @ ${s.lastFailure.ts}`);
191
+ }
192
+ }
193
+ }
194
+ export async function runSkillCommand(args) {
195
+ const sub = args[0];
196
+ if (!sub || sub === '--help' || sub === '-h' || sub === 'help') {
197
+ usage();
198
+ return;
199
+ }
200
+ try {
201
+ if (sub === 'eval')
202
+ await evalCommand(args.slice(1));
203
+ else if (sub === 'improve')
204
+ await improveCommand(args.slice(1));
205
+ else if (sub === 'usage')
206
+ usageCommand(args.slice(1));
207
+ else {
208
+ console.error(`未知子命令 "${sub}"。`);
209
+ usage();
210
+ process.exitCode = 1;
211
+ }
212
+ }
213
+ catch (e) {
214
+ const msg = e instanceof Error ? e.message : String(e);
215
+ if (/aborted/i.test(msg)) {
216
+ console.error('\n已中断。');
217
+ process.exitCode = 130;
218
+ }
219
+ else {
220
+ console.error(`错误: ${msg}`);
221
+ process.exitCode = 1;
222
+ }
223
+ }
224
+ }
225
+ // 供 index.ts 判断子命令名集合(避免它 parse 我们的参数)。
226
+ export const SKILL_SUBCOMMANDS = ['eval', 'improve', 'usage', 'help', '--help', '-h'];
227
+ // listSkills 在 eval/improve 未命中时供报错文案复用(保持与 /skills 一致的名字集)。
228
+ export function listSkillNames() {
229
+ return listSkills().map((s) => s.name);
230
+ }