mocode-ai 1.2.8 → 1.2.9

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 (129) hide show
  1. package/dist/agent/core.js +126 -43
  2. package/dist/config/index.js +31 -6
  3. package/dist/config/presets.js +36 -1
  4. package/dist/context/budget.js +0 -15
  5. package/dist/llm/index.js +33 -0
  6. package/dist/repl/index.js +57 -13
  7. package/dist/session/compact.js +237 -13
  8. package/dist/session/notes.js +63 -0
  9. package/dist/session/scheduler.js +5 -9
  10. package/dist/tools/constants.js +7 -0
  11. package/package.json +1 -1
  12. package/dist/__trace_manual_test__.js +0 -1
  13. package/dist/agent/core.d.ts +0 -112
  14. package/dist/agent/index.d.ts +0 -19
  15. package/dist/agent/mode.d.ts +0 -29
  16. package/dist/agent/retry-classifier.js +0 -64
  17. package/dist/agent/spawn.d.ts +0 -36
  18. package/dist/attachments/image.d.ts +0 -22
  19. package/dist/commands/config.d.ts +0 -8
  20. package/dist/commands/skill.js +0 -230
  21. package/dist/config/file.d.ts +0 -23
  22. package/dist/config/index.d.ts +0 -114
  23. package/dist/config/presets.d.ts +0 -57
  24. package/dist/context/budget.d.ts +0 -79
  25. package/dist/context/classifier.d.ts +0 -14
  26. package/dist/context/encoders/_util.d.ts +0 -11
  27. package/dist/context/encoders/code.d.ts +0 -2
  28. package/dist/context/encoders/doc.d.ts +0 -14
  29. package/dist/context/encoders/graph.d.ts +0 -11
  30. package/dist/context/encoders/graph.js +0 -81
  31. package/dist/context/encoders/index.d.ts +0 -2
  32. package/dist/context/encoders/log.d.ts +0 -2
  33. package/dist/context/encoders/log.js +0 -2
  34. package/dist/context/encoders/memory.d.ts +0 -2
  35. package/dist/context/encoders/passthrough.d.ts +0 -11
  36. package/dist/context/encoders/search.d.ts +0 -2
  37. package/dist/context/encoders/summary.d.ts +0 -12
  38. package/dist/context/encoders/table.d.ts +0 -2
  39. package/dist/context/encoders/tree.d.ts +0 -2
  40. package/dist/context/index.d.ts +0 -6
  41. package/dist/context/lifecycle.d.ts +0 -51
  42. package/dist/context/pipeline.d.ts +0 -9
  43. package/dist/context/registry.d.ts +0 -9
  44. package/dist/context/relevance.d.ts +0 -65
  45. package/dist/context/types.d.ts +0 -36
  46. package/dist/foo.js +0 -28
  47. package/dist/foo.test.js +0 -12
  48. package/dist/index.d.ts +0 -1
  49. package/dist/llm/capabilities.d.ts +0 -19
  50. package/dist/llm/index.d.ts +0 -111
  51. package/dist/memory/discover.d.ts +0 -15
  52. package/dist/memory/index.d.ts +0 -13
  53. package/dist/memory/reflect.d.ts +0 -34
  54. package/dist/memory/store.d.ts +0 -124
  55. package/dist/pet/bridge.d.ts +0 -73
  56. package/dist/pet/protocol.d.ts +0 -83
  57. package/dist/pet/state.d.ts +0 -28
  58. package/dist/plan/active.d.ts +0 -23
  59. package/dist/plan/active.js +0 -45
  60. package/dist/plan/index.d.ts +0 -13
  61. package/dist/plan/index.js +0 -33
  62. package/dist/plan/state.d.ts +0 -2
  63. package/dist/plan/state.js +0 -11
  64. package/dist/plan/store.d.ts +0 -78
  65. package/dist/plan/store.js +0 -429
  66. package/dist/project-skill/index.js +0 -95
  67. package/dist/project-skill/initializer.js +0 -178
  68. package/dist/project-snapshot/index.js +0 -85
  69. package/dist/project-snapshot/llm-snapshot.js +0 -150
  70. package/dist/project-snapshot/static-files.js +0 -87
  71. package/dist/repl/index.d.ts +0 -22
  72. package/dist/rollback/index.d.ts +0 -79
  73. package/dist/sandbox/command.d.ts +0 -3
  74. package/dist/sandbox/index.d.ts +0 -4
  75. package/dist/sandbox/jail.d.ts +0 -17
  76. package/dist/sandbox/policy.d.ts +0 -21
  77. package/dist/sandbox/root.d.ts +0 -5
  78. package/dist/session/compact.d.ts +0 -92
  79. package/dist/session/index.d.ts +0 -13
  80. package/dist/session/notes-plan.js +0 -41
  81. package/dist/session/persist.d.ts +0 -30
  82. package/dist/session/scheduler.d.ts +0 -53
  83. package/dist/skills/discover.d.ts +0 -33
  84. package/dist/skills/index.d.ts +0 -13
  85. package/dist/skills/skill-eval.js +0 -345
  86. package/dist/skills/skill-improve.js +0 -221
  87. package/dist/skills/stats.js +0 -102
  88. package/dist/tools/builtins/apply-patch.js +0 -174
  89. package/dist/tools/builtins/ask-human.d.ts +0 -9
  90. package/dist/tools/builtins/codegraph.d.ts +0 -2
  91. package/dist/tools/builtins/codegraph.js +0 -128
  92. package/dist/tools/builtins/edit-file.d.ts +0 -2
  93. package/dist/tools/builtins/glob.d.ts +0 -2
  94. package/dist/tools/builtins/grep.d.ts +0 -2
  95. package/dist/tools/builtins/index.d.ts +0 -2
  96. package/dist/tools/builtins/memory-forget.d.ts +0 -2
  97. package/dist/tools/builtins/memory-list.d.ts +0 -2
  98. package/dist/tools/builtins/memory-save.d.ts +0 -2
  99. package/dist/tools/builtins/memory-search.d.ts +0 -2
  100. package/dist/tools/builtins/memory-update.d.ts +0 -2
  101. package/dist/tools/builtins/project-skill-update.js +0 -102
  102. package/dist/tools/builtins/read-file.d.ts +0 -2
  103. package/dist/tools/builtins/run-command.d.ts +0 -2
  104. package/dist/tools/builtins/switch-mode.d.ts +0 -2
  105. package/dist/tools/builtins/switch-mode.js +0 -44
  106. package/dist/tools/builtins/task.d.ts +0 -2
  107. package/dist/tools/builtins/todolist.d.ts +0 -2
  108. package/dist/tools/builtins/todolist.js +0 -411
  109. package/dist/tools/builtins/use-skill.d.ts +0 -2
  110. package/dist/tools/builtins/web-fetch.d.ts +0 -2
  111. package/dist/tools/builtins/web-search.d.ts +0 -2
  112. package/dist/tools/builtins/write-file.d.ts +0 -2
  113. package/dist/tools/constants.d.ts +0 -52
  114. package/dist/tools/registry.d.ts +0 -18
  115. package/dist/tools/retry.js +0 -137
  116. package/dist/tools/types.d.ts +0 -42
  117. package/dist/ui/batch.d.ts +0 -67
  118. package/dist/ui/clipboard.d.ts +0 -9
  119. package/dist/ui/content.d.ts +0 -55
  120. package/dist/ui/diff.d.ts +0 -34
  121. package/dist/ui/intervention.d.ts +0 -46
  122. package/dist/ui/layout.d.ts +0 -225
  123. package/dist/ui/markdown.d.ts +0 -46
  124. package/dist/ui/mouse.d.ts +0 -29
  125. package/dist/ui/prompt.d.ts +0 -61
  126. package/dist/ui/render.d.ts +0 -60
  127. package/dist/ui/spinner.d.ts +0 -26
  128. package/dist/ui/theme.d.ts +0 -47
  129. package/dist/ui/thinking.js +0 -80
@@ -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, estimateTokens, planChatTools, chatTools, } from '../llm/index.js';
9
+ import { chat, estimatePromptTokens, estimateTokens, isContextLengthError, 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';
@@ -313,16 +313,26 @@ export async function runAgentCore(opts) {
313
313
  // ① 它会被追加到本次请求末尾(见下方 ephemeralReminder),属于本步固定开销,
314
314
  // 必须计入压力线——它不在 history 里,调度器只能由此入参看见(否则最多 5k
315
315
  // 的笔记 + plan 段对 80% 触发线完全不可见,小窗口模型会压不住);
316
- // ② 同一份字符串复用到下方 reminder,避免每步重复读 notes.md
317
- const sessionStateText = opts.suppressSessionState ? '' : buildSessionStateReminder();
316
+ // ② 压缩步在压缩成功后重取(P2 固结的 Compaction Snapshot 当步即可见)
317
+ let sessionStateText = opts.suppressSessionState ? '' : buildSessionStateReminder();
318
318
  // The scheduler is the only automatic path that may compress old evidence.
319
319
  // Normal tool pushes and lifecycle tracking remain metadata-only.
320
+ // 压缩**之前**先刷一次状态栏:bar 要显示「本步真实 prompt 撞线」那一刻。
321
+ // 触发器算的就是 history + 工具 schema + 本段 sessionStateText,此刻三者都已就位,
322
+ // bar 与触发器完全同口径同一步。若等压缩跑完再刷,那 30+ 秒的 LLM 摘要调用里
323
+ // bar 一直冻在上一步的值,用户只看到「75% 怎么就压了」。
324
+ runtimeContextState.ephemeralText = sessionStateText || undefined;
325
+ onContextUpdate?.();
320
326
  // 步前:五区 Budget Scheduler 在当前完整 history 上决策;开关关闭时退化回 maybeCompact 路径。
321
327
  // 此时 spinner 已停,通知行干净。
322
328
  let historyRebuilt = false;
329
+ /** 本步是否已因「后端报上下文超长」压过一轮(限一次,防压缩↔重试死循环)。 */
330
+ let overflowRetried = false;
323
331
  const compactStartedAt = Date.now();
324
332
  if (scheduler) {
325
- historyRebuilt = await scheduler.runStep(history, step, activeTools, sessionStateText ? estimateTokens(sessionStateText) : 0);
333
+ historyRebuilt = await scheduler.runStep(history, step, activeTools, sessionStateText ? estimateTokens(sessionStateText) : 0,
334
+ // signal 透传:压缩的 LLM 摘要是几十秒的调用,不串进来 Ctrl+C 掐不断。
335
+ signal);
326
336
  if (scheduler.lastRunLog?.compactHistoryCalled) {
327
337
  emitTrace('compact', {
328
338
  source: 'automatic',
@@ -333,7 +343,7 @@ export async function runAgentCore(opts) {
333
343
  }
334
344
  }
335
345
  else {
336
- const compactResult = await maybeCompact(history, undefined, undefined, runtimeContextState, activeTools);
346
+ const compactResult = await maybeCompact(history, undefined, undefined, runtimeContextState, activeTools, signal);
337
347
  historyRebuilt = compactResult?.historyRebuilt === true;
338
348
  if (compactResult) {
339
349
  emitTrace('compact', {
@@ -354,6 +364,11 @@ export async function runAgentCore(opts) {
354
364
  runtimeContextState.lifecycleStats = lifecycle.stats();
355
365
  }
356
366
  rehydrateArtifacts(runtimeContextState, history);
367
+ // 压缩可能刚把进度快照固结进 notes.md(P2)→ 重取,让本步 requestHistory
368
+ // 末尾就带上最新 Compaction Snapshot,不必等下一步。bar 口径对应的
369
+ // ephemeralText 仍用触发时旧值(见 buildRequestHistory 注释),仅差这一段。
370
+ if (!opts.suppressSessionState)
371
+ sessionStateText = buildSessionStateReminder();
357
372
  // 会话状态(活跃 plan + 笔记段)不再回写 history[0]:每步都会在 requestHistory
358
373
  // 末尾注入最新副本(见下方 ephemeralReminder),compact 后自然恢复。
359
374
  }
@@ -377,22 +392,41 @@ export async function runAgentCore(opts) {
377
392
  // - 会话状态:notes.md 的活跃 plan + 笔记正文(纯读,每步重取,始终最新)。
378
393
  // .filter(Boolean) 保证空段不产生多余空行;三段全空时不追加任何消息(requestHistory === history)。
379
394
  // 安全保证:只拼进 requestHistory(新建数组),绝不写回 history,故不会跨 step/跨 turn 残留。
380
- const ephemeralReminder = [
381
- (!opts.suppressOpeningAnalysis && step === 0)
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.'
383
- : '',
384
- historyRebuilt
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.'
386
- : '',
387
- sessionStateText, // 调度器之前已取(并计入压力线),此处复用同一份,不重复读文件
388
- ].filter(Boolean).join('\n\n');
389
- const requestHistory = ephemeralReminder
390
- ? [...history, { role: 'system', content: ephemeralReminder }]
391
- : history;
395
+ // 抽成函数:后端报上下文超长时压缩后要按新 history 重建一次(见下方 catch)。
396
+ // 不写 contextState.ephemeralText:bar 用的是触发器同口径的 sessionStateText
397
+ // (步骤顶部已设),而本函数还会额外拼 opening / post-compact 段——那两段触发器
398
+ // 不计,让 bar 用会让两条线再错开几百 token。
399
+ const buildRequestHistory = () => {
400
+ const ephemeralReminder = [
401
+ (!opts.suppressOpeningAnalysis && step === 0)
402
+ ? '## 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.'
403
+ : '',
404
+ historyRebuilt
405
+ ? '## Post-compaction recovery\n' +
406
+ 'Context was compacted before this request. Recover before doing anything else, in this order:\n' +
407
+ '1. Read the session summary at the top of the history: `## Completed` is already done — do not redo or re-verify it. `## In Progress` / `## Next Steps` tell you exactly where work stopped and what is next.\n' +
408
+ '2. Read `## Session state` below (from notes.md, refreshed every step): the active plan is authoritative — `[x]` steps are finished, resume from the first `[ ]`. A `## Compaction Snapshot` section there is the progress checkpoint written at this compaction.\n' +
409
+ (sessionStateText
410
+ ? ''
411
+ : '(No active plan or snapshot was found in notes.md — reconstruct what is done purely from the summary and treat its `## Completed` as ground truth.)\n') +
412
+ '3. Before any file edit, read_file the target fresh to get the current content hash — never edit from memory of pre-compaction content.\n' +
413
+ '4. Before re-running a search/read you think you already did, check the summary and notes first: only repeat it if the result is genuinely missing or the target has changed.'
414
+ : '',
415
+ sessionStateText, // 调度器之前已取(并计入压力线),此处复用同一份,不重复读文件
416
+ ].filter(Boolean).join('\n\n');
417
+ return ephemeralReminder
418
+ ? [...history, { role: 'system', content: ephemeralReminder }]
419
+ : history;
420
+ };
421
+ let requestHistory = buildRequestHistory();
422
+ // 再刷一次:压缩刚跑完(history 已重建),bar 从「撞线 82%」跳到「压后 62%」,
423
+ // 让用户看到压缩确实起了作用。撞线那一刻的刷新在步骤顶部(trigger 之前),
424
+ // 那一次才是解释「为什么要压」的。
425
+ onContextUpdate?.();
392
426
  // 实时用量:当前步 prompt 估算(含校准系数)+ 流式累计 completion 估算,
393
427
  // 叠上已完成步的实测 turnUsage,经 onLiveUsage 推给底栏实时 chip。
394
428
  // turnUsage 在闭包里被 addUsage 原地更新,reportLive 每次调用读最新值。
395
- const stepPromptEst = estimatePromptTokens(requestHistory, activeTools, runtimeContextState.correction);
429
+ let stepPromptEst = estimatePromptTokens(requestHistory, activeTools, runtimeContextState.correction);
396
430
  const reportLive = (p) => {
397
431
  // 当前步 prompt:末尾 usage chunk 到达后用实测,流式期间用估算(含校准)。
398
432
  // 当前步 cache 命中同理:上报即用实测;流式期间按前缀缓存估算 ≈ 上一步实测 prompt
@@ -410,34 +444,44 @@ export async function runAgentCore(opts) {
410
444
  });
411
445
  };
412
446
  reportLive({ completionTokens: 0 }); // 思考阶段先显 ↑ prompt 估算,首 token 到达后 ↓ 开始涨
413
- try {
414
- result = await chat(requestHistory, {
415
- onText,
416
- onToolCall,
417
- onProgress: reportLive,
418
- onRetry: (retry) => emitTrace('model_retry', {
447
+ // 单一 chat 入口:错误侧的 model_end 埋点只写一处(重试也会记,不丢失败轨迹)。
448
+ const chatHandlers = {
449
+ onText,
450
+ onToolCall,
451
+ onProgress: reportLive,
452
+ onRetry: (retry) => emitTrace('model_retry', {
453
+ model: requestModel,
454
+ provider,
455
+ attempt: retry.attempt,
456
+ nextAttempt: retry.nextAttempt,
457
+ waitMs: retry.waitMs,
458
+ code: retry.code,
459
+ }),
460
+ };
461
+ const runChatOnce = async () => {
462
+ try {
463
+ return await chat(requestHistory, chatHandlers, signal, activeTools);
464
+ }
465
+ catch (err) {
466
+ const errorValue = err && typeof err === 'object'
467
+ ? err
468
+ : undefined;
469
+ emitTrace('model_end', {
419
470
  model: requestModel,
420
471
  provider,
421
- attempt: retry.attempt,
422
- nextAttempt: retry.nextAttempt,
423
- waitMs: retry.waitMs,
424
- code: retry.code,
425
- }),
426
- }, signal, activeTools);
472
+ status: signal?.aborted ? 'aborted' : 'error',
473
+ code: typeof errorValue?.status === 'number'
474
+ ? `HTTP_${errorValue.status}`
475
+ : errorValue?.code ?? errorValue?.name ?? 'MODEL_ERROR',
476
+ durationMs: Date.now() - modelStartedAt,
477
+ });
478
+ throw err;
479
+ }
480
+ };
481
+ try {
482
+ result = await runChatOnce();
427
483
  }
428
484
  catch (e) {
429
- const errorValue = e && typeof e === 'object'
430
- ? e
431
- : undefined;
432
- emitTrace('model_end', {
433
- model: requestModel,
434
- provider,
435
- status: signal?.aborted ? 'aborted' : 'error',
436
- code: typeof errorValue?.status === 'number'
437
- ? `HTTP_${errorValue.status}`
438
- : errorValue?.code ?? errorValue?.name ?? 'MODEL_ERROR',
439
- durationMs: Date.now() - modelStartedAt,
440
- });
441
485
  // 中断(用户运行中 Ctrl+C):chat() 抛 AbortError(signal.aborted)→ 还原 history + 模式 + return(不抛)。
442
486
  // 工具执行现已串 signal:run_command/web_fetch 被 abort 即时杀,循环顶检查兜底(不会留未配对 tool_call_id)。
443
487
  if (signal?.aborted ||
@@ -454,7 +498,46 @@ export async function runAgentCore(opts) {
454
498
  changedFiles: mutation.changedFiles.map((item) => item.path),
455
499
  };
456
500
  }
457
- throw e;
501
+ // 后端实测拒绝了 prompt(上下文超长):本地估算对该 provider 系统性偏低时,
502
+ // 压力线压不住,这是唯一可信的触发。强压一轮后重试一次;仍失败才抛(限一次,防循环)。
503
+ if (!overflowRetried && isContextLengthError(e)) {
504
+ overflowRetried = true;
505
+ // 估算被后端证伪:raw×correction 明明在压力线以下,真实 prompt 却超了窗。
506
+ // 用「实测 = 窗口」这个下限样本喂校准,让压力线对这家 provider 立刻变严——
507
+ // 否则每一步都要等后端报错才压,而不是提前压。EWMA α=0.2 + [0.5,2] 夹逼,
508
+ // 偶发误判会被后续真实 usage 样本拉回。
509
+ const rawEstimate = estimatePromptTokens(requestHistory, activeTools);
510
+ if (rawEstimate > 1_000) {
511
+ const cal = updateTokenCalibration(requestBaseURL, requestModel, activeTools, rawEstimate, config.contextWindowTokens);
512
+ runtimeContextState.correction = cal.correction;
513
+ runtimeContextState.calibrationSamples = cal.samples;
514
+ }
515
+ const overflowResult = await maybeCompact(history, undefined, { manual: true, force: true }, runtimeContextState, activeTools, signal);
516
+ emitTrace('compact', {
517
+ source: 'overflow_retry',
518
+ reason: overflowResult?.reason ?? 'noop',
519
+ compacted: overflowResult?.compacted === true,
520
+ estimateBefore: overflowResult?.estimateBefore,
521
+ estimateAfter: overflowResult?.estimateAfter,
522
+ durationMs: Date.now() - modelStartedAt,
523
+ });
524
+ if (!overflowResult?.compacted)
525
+ throw e; // 压不动:没法救,原样抛
526
+ if (overflowResult.historyRebuilt) {
527
+ historyRebuilt = true;
528
+ if (lifecycle) {
529
+ lifecycle = createLifecycleEngine(history);
530
+ runtimeContextState.lifecycleStats = lifecycle.stats();
531
+ }
532
+ rehydrateArtifacts(runtimeContextState, history);
533
+ }
534
+ requestHistory = buildRequestHistory();
535
+ stepPromptEst = estimatePromptTokens(requestHistory, activeTools, runtimeContextState.correction);
536
+ result = await runChatOnce(); // 仍超长 → 抛出,交给上层报错
537
+ }
538
+ else {
539
+ throw e;
540
+ }
458
541
  }
459
542
  emitTrace('model_end', {
460
543
  model: requestModel,
@@ -6,6 +6,7 @@ import { getCurrentSessionId } from '../session/state.js';
6
6
  import { getNotesFilePath, extractActiveNotesSections } from '../session/notes.js';
7
7
  import { buildWorkDisciplineSection, inferModelFamily } from '../agent/work-discipline.js';
8
8
  import { buildValidationCommandsSection } from '../verification/prompt.js';
9
+ import { getActivePresetName, readPreset } from './presets.js';
9
10
  import { detectLanguage, setLanguage, t, } from '../i18n/index.js';
10
11
  /**
11
12
  * 按优先级加载配置文件并回填 process.env:
@@ -45,6 +46,16 @@ const LLM_ENV_KEYS = ['LLM_PROVIDER', 'LLM_BASE_URL', 'LLM_API_KEY', 'LLM_MODEL'
45
46
  export const DEFAULT_CONTEXT_WINDOW_TOKENS = 256000;
46
47
  const llmKeysFromShell = LLM_ENV_KEYS.filter((k) => process.env[k] !== undefined);
47
48
  loadEnvFiles();
49
+ /**
50
+ * 激活预设覆盖:若用户曾用 /model 激活过预设,启动时让上下文窗口等配置**跟随该预设文件**,
51
+ * 而不是只信 config 文件里上一组裸 LLM 键——这正是"切换后窗口不再退回 256k"的关键。
52
+ * 逐字段覆盖,且 shell 已显式设置的键**不覆盖**(保持 shell 环境变量最高优先级)。
53
+ * 不 import repl,无副作用;读失败(指针失效/文件坏)静默回退到 config 文件裸键。
54
+ */
55
+ const __activePreset = (() => {
56
+ const n = getActivePresetName();
57
+ return n ? readPreset(n) : null;
58
+ })();
48
59
  setLanguage(detectLanguage(process.env.MOCODE_LANGUAGE));
49
60
  export function normalizeLlmProvider(value) {
50
61
  return typeof value === 'string' && value.toLowerCase() === 'anthropic' ? 'anthropic' : 'openai';
@@ -479,19 +490,33 @@ export function getPlanModeSuffix() {
479
490
  return buildPlanModeSuffix();
480
491
  }
481
492
  export const config = {
482
- provider: normalizeLlmProvider(process.env.LLM_PROVIDER),
483
- baseURL: requireEnv('LLM_BASE_URL'),
484
- apiKey: requireEnv('LLM_API_KEY'),
485
- model: process.env.LLM_MODEL || 'gpt-4o-mini',
493
+ provider: llmKeysFromShell.includes('LLM_PROVIDER')
494
+ ? normalizeLlmProvider(process.env.LLM_PROVIDER)
495
+ : (__activePreset?.provider ?? normalizeLlmProvider(process.env.LLM_PROVIDER)),
496
+ baseURL: llmKeysFromShell.includes('LLM_BASE_URL')
497
+ ? requireEnv('LLM_BASE_URL')
498
+ : (__activePreset?.baseURL ?? requireEnv('LLM_BASE_URL')),
499
+ apiKey: llmKeysFromShell.includes('LLM_API_KEY')
500
+ ? requireEnv('LLM_API_KEY')
501
+ : (__activePreset?.apiKey ?? requireEnv('LLM_API_KEY')),
502
+ model: llmKeysFromShell.includes('LLM_MODEL')
503
+ ? (process.env.LLM_MODEL || 'gpt-4o-mini')
504
+ : (__activePreset?.model ?? process.env.LLM_MODEL ?? 'gpt-4o-mini'),
486
505
  maxTokens: process.env.MAX_TOKENS ? Number(process.env.MAX_TOKENS) : undefined,
487
506
  // 用 getter 而非 buildBasePrompt() 立即求值:因为本对象字面量求值时 buildBasePrompt 读 config.memoryEnabled,
488
507
  // 而 config 还没完成初始化(TDZ)。Getter 让每次访问都现拼,运行时 /memory_switch 立即生效。
489
508
  get systemPrompt() {
490
509
  return buildBasePrompt();
491
510
  },
492
- contextWindowTokens: Number(process.env.CONTEXT_WINDOW_TOKENS) || DEFAULT_CONTEXT_WINDOW_TOKENS,
511
+ contextWindowTokens: llmKeysFromShell.includes('CONTEXT_WINDOW_TOKENS')
512
+ ? (Number(process.env.CONTEXT_WINDOW_TOKENS) || DEFAULT_CONTEXT_WINDOW_TOKENS)
513
+ : (__activePreset?.contextWindow
514
+ ?? Number(process.env.CONTEXT_WINDOW_TOKENS)
515
+ ?? DEFAULT_CONTEXT_WINDOW_TOKENS),
493
516
  includeUsage: process.env.LLM_STREAM_USAGE !== 'false',
494
- anthropicPromptCache: process.env.ANTHROPIC_PROMPT_CACHE !== 'false',
517
+ anthropicPromptCache: llmKeysFromShell.includes('ANTHROPIC_PROMPT_CACHE')
518
+ ? process.env.ANTHROPIC_PROMPT_CACHE !== 'false'
519
+ : (__activePreset?.anthropicPromptCache ?? process.env.ANTHROPIC_PROMPT_CACHE !== 'false'),
495
520
  autoCompact: process.env.AUTO_COMPACT !== 'false',
496
521
  contextOptimize: process.env.MOCODE_CONTEXT_OPTIMIZE === 'true',
497
522
  contextRelprune: process.env.MOCODE_CONTEXT_RELPRUNE === 'true',
@@ -19,6 +19,32 @@ import path from 'node:path';
19
19
  */
20
20
  /** 预设目录:`~/.mocode/models/`(按需创建)。 */
21
21
  export const MODELS_DIR = path.join(os.homedir(), '.mocode', 'models');
22
+ /**
23
+ * 激活预设指针文件:`~/.mocode/models/.active`,内容为一行预设名。
24
+ * 激活预设是"上下文窗口等配置跟随模型"的真相来源:启动与它窗切换都以此覆盖 LLM 四键。
25
+ * 与 <name>.json 区分:`.active` 不以 .json 结尾、且 '.' 非法首字符,listPresets 天然跳过。
26
+ */
27
+ export const ACTIVE_PRESET_PATH = path.join(MODELS_DIR, '.active');
28
+ /** 读激活预设名;不存在/空返回 null(供启动回退到 config 文件的裸 LLM 键)。 */
29
+ export function getActivePresetName() {
30
+ try {
31
+ const name = fs.readFileSync(ACTIVE_PRESET_PATH, 'utf8').trim();
32
+ return isValidPresetName(name) ? name : null;
33
+ }
34
+ catch {
35
+ return null; // 不存在或不可读:无激活预设
36
+ }
37
+ }
38
+ /** 写激活预设指针(原子:写 tmp 再 rename)。name 必须在预设目录里存在,防止指向空指针。 */
39
+ export function setActivePresetName(name) {
40
+ if (!isValidPresetName(name)) {
41
+ throw new Error(`非法预设名: ${JSON.stringify(name)}`);
42
+ }
43
+ fs.mkdirSync(MODELS_DIR, { recursive: true });
44
+ const tmp = `${ACTIVE_PRESET_PATH}.tmp-${process.pid}-${Date.now()}`;
45
+ fs.writeFileSync(tmp, `${name}\n`, 'utf8');
46
+ fs.renameSync(tmp, ACTIVE_PRESET_PATH);
47
+ }
22
48
  const NAME_RE = /^[a-zA-Z0-9_-]{1,32}$/;
23
49
  /** 名字是否合法(调用方复用,避免在多处重复同一正则)。 */
24
50
  export function isValidPresetName(name) {
@@ -95,10 +121,16 @@ export function savePreset(preset) {
95
121
  fs.writeFileSync(tmp, JSON.stringify(normalized, null, 2), 'utf8');
96
122
  fs.renameSync(tmp, dest);
97
123
  }
98
- /** 删除一个预设;不存在返回 false,成功返回 true */
124
+ /** 删除一个预设;不存在返回 false,成功返回 true。若删的是激活预设,顺带清掉指针。 */
99
125
  export function deletePreset(name) {
100
126
  try {
101
127
  fs.unlinkSync(filePathFor(name));
128
+ if (getActivePresetName() === name) {
129
+ try {
130
+ fs.unlinkSync(ACTIVE_PRESET_PATH);
131
+ }
132
+ catch { /* 指针已不在,忽略 */ }
133
+ }
102
134
  return true;
103
135
  }
104
136
  catch (e) {
@@ -136,6 +168,9 @@ export function renamePreset(oldName, newName) {
136
168
  const p = parsePreset(fs.readFileSync(newPath, 'utf8'));
137
169
  if (p.name !== newName)
138
170
  savePreset({ ...p, name: newName });
171
+ // 激活预设被重命名 → 指针跟随新名,否则下次启动回退裸 config 丢窗口。
172
+ if (getActivePresetName() === oldName)
173
+ setActivePresetName(newName);
139
174
  return true;
140
175
  }
141
176
  /** 列出全部预设(按 name 升序);目录不存在返回空数组。 */
@@ -152,21 +152,6 @@ export function evaluateBudget(history, window, step = 0, correction = 1, active
152
152
  * Per-layer overages are diagnostics, never independent rewrite triggers. */
153
153
  export function scheduleActions(report) {
154
154
  const actions = [];
155
- const { layers } = report;
156
- if (layers.system.overBudget) {
157
- const { prompt, toolSchemas, ephemeralInjection } = report.systemCosts;
158
- const { actual, budget } = layers.system;
159
- const excess = actual - budget;
160
- const percent = ((actual / Math.max(budget, 1)) * 100).toFixed(0);
161
- actions.push({
162
- kind: 'warn',
163
- layer: 'system',
164
- reason: `固定开销 ${actual}/${budget} (+${excess}, ${percent}%);`
165
- + `提示 ${prompt} + 工具 ${toolSchemas}`
166
- + (ephemeralInjection > 0 ? ` + 尾部注入 ${ephemeralInjection}` : '')
167
- + `,×${report.correction.toFixed(2)}。`,
168
- });
169
- }
170
155
  const pressureLine = DEFAULT_BUDGET_POLICY.pressureTriggerRatio * report.window;
171
156
  if (Math.max(report.rawTotal, report.total) >= pressureLine) {
172
157
  actions.push({ kind: 'compact_history' });
package/dist/llm/index.js CHANGED
@@ -111,6 +111,39 @@ export function isRetryableError(err, signal) {
111
111
  }
112
112
  return false;
113
113
  }
114
+ /**
115
+ * 判定一次失败是否是「请求上下文超长」(后端实测拒绝了我们的 prompt)。
116
+ *
117
+ * 与压力线(本地启发式估算)的区别:这是**实测**信号。估算对某些 provider 会系统性偏低
118
+ * (CJK 分词、多模态、特殊 schema),压力线压不住时,后端这声 400 是唯一可信的兜底触发。
119
+ * agent/core 捕获后强压一轮再重试一次(限一次,防循环)。
120
+ *
121
+ * 判定刻意保守:只在 status 明确(400/413/422)或 status 缺失(代理把错误折叠成普通
122
+ * Error)时才认语义;5xx / 429 一律不算——那种重试压缩也救不回来。
123
+ */
124
+ export function isContextLengthError(err) {
125
+ if (!err || typeof err !== 'object')
126
+ return false;
127
+ const e = err;
128
+ if (e.code === 'context_length_exceeded' || e.type === 'context_length_exceeded')
129
+ return true;
130
+ if (e.code === 'string_above_max_length')
131
+ return true;
132
+ if (typeof e.status === 'number' && e.status >= 500)
133
+ return false;
134
+ if (typeof e.status === 'number' && e.status !== 400 && e.status !== 413 && e.status !== 422) {
135
+ return false;
136
+ }
137
+ // 413 是网关/代理对「请求体过大」的直白判决,不带 message 也认。
138
+ if (e.status === 413)
139
+ return true;
140
+ const msg = typeof e.message === 'string' ? e.message.toLowerCase() : '';
141
+ if (!msg)
142
+ return false;
143
+ return (/context[_ ]length|context window|maximum context|too many tokens|string_above_max_length/.test(msg) ||
144
+ /(input|prompt|context|request).{0,20}too (long|large)|exceeds? the (model|maximum|context)/.test(msg) ||
145
+ /reduce the length|上下文(长度)?(超|过)|超出.*上下文|请求过长|长度超过|token 数?超过/.test(msg));
146
+ }
114
147
  /** 从 OpenAI APIError.headers 解析 Retry-After(秒);不支持或缺失返回 undefined。封顶 RETRY_MAX_MS。 */
115
148
  export function getRetryAfterMs(err) {
116
149
  const headers = err?.headers;
@@ -5,7 +5,7 @@ import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMem
5
5
  import { getLanguage, normalizeLanguage, t, } from '../i18n/index.js';
6
6
  import { DEFAULT_BUDGET_POLICY } from '../context/budget.js';
7
7
  import { updateConfigKey, writeConfigKeys, CONFIG_PATH } from '../config/file.js';
8
- import { deletePreset, getPreset, isValidPresetName, listPresets, migrateCurrentToPreset, savePreset, } from '../config/presets.js';
8
+ import { deletePreset, getPreset, isValidPresetName, listPresets, migrateCurrentToPreset, savePreset, setActivePresetName, } from '../config/presets.js';
9
9
  import { runAgent } from '../agent/index.js';
10
10
  import { getAgentMode, setAgentMode, onModeChange } from '../agent/mode.js';
11
11
  import { togglePet, killPetProcess, listSkins, setSkin, sendState } from '../pet/bridge.js';
@@ -19,7 +19,7 @@ import { promptWithSlashMenu, promptTurnPicker, promptSessionPicker, promptTheme
19
19
  import { promptIntervention } from '../ui/intervention.js';
20
20
  import { registerToolsExtension } from '../tools/registry.js';
21
21
  import { initializeAllMcp, getMcpTools, closeAllMcp } from '../mcp/index.js';
22
- import { estimateMessagesTokens, reconfigureClient, refreshChatTools, chatTools, } from '../llm/index.js';
22
+ import { estimateMessagesTokens, reconfigureClient, refreshChatTools, chatTools, estimatePromptTokens, estimateTokens, } from '../llm/index.js';
23
23
  import { loadImageAttachment, renderChip, MAX_INLINE_BYTES_DEFAULT, } from '../attachments/image.js';
24
24
  import { modelSupportsVision } from '../llm/capabilities.js';
25
25
  import { computePruneStats } from '../context/relevance.js';
@@ -244,11 +244,21 @@ function renderContextBar(history) {
244
244
  return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${Math.round(pct * 100)}% ${k(est)}/${k(win)} tokens · ${t('status.messages', { count: history.length })} (${src})${ui.reset}${artifactLine}${lifecycleLine}${archiveLine}`;
245
245
  }
246
246
  /** 状态行用量条(精简版,进底栏):[bar] pct% k/k。
247
- * 只计算对话内容(不含 system prompt),让用户感知"我发了多少、agent 回复了多少"占用 context。 */
247
+ * 必须**用全 prompt 估算**(消息 + 工具 schema + 尾部 ephemeral 注入),与压缩触发器
248
+ * evaluateBudget 的 system+history+toolOld+toolRecent 总账对齐——任何一段漏算都会让
249
+ * bar 与触发器口径不一致、看着没到 80% 实际已经在压。
250
+ * 触发器用 `Math.max(rawTotal, total) >= 0.8 * window`,bar 也照搬:校正后和校正前
251
+ * 哪个大取哪个,确保不会因 correction<1 而低估。ephemeral 文本由 agent/core 每步写入
252
+ * contextState.ephemeralText(避免在 bar 里再读一次 notes.md)。
253
+ *
254
+ * /context 命令仍是 dialog-only(见 renderContextBar):它的设计意图是"我说了多少"而非
255
+ * "还剩多少空间",两条职责分开。 */
248
256
  function renderContextBarInline(history) {
249
- // 过滤掉 system 消息,只算对话内容
250
- const dialog = history.filter(m => m.role !== 'system');
251
- const est = estimateMessagesTokens(dialog);
257
+ const baseRaw = estimatePromptTokens(history, chatTools, 1);
258
+ const baseAdj = estimatePromptTokens(history, chatTools, contextState.correction);
259
+ const ephemeral = contextState.ephemeralText ? estimateTokens(contextState.ephemeralText) : 0;
260
+ // 与触发器同样的「取大」语义:correction<1 时 raw 更大,bar 不会假装很安全。
261
+ const est = Math.max(baseRaw, baseAdj) + ephemeral;
252
262
  const win = config.contextWindowTokens;
253
263
  const pct = Math.min(1, est / win);
254
264
  const W = 10;
@@ -1108,6 +1118,7 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1108
1118
  rebuildFromHistory(history);
1109
1119
  contextState.lastUsage = undefined;
1110
1120
  contextState.lifecycleStats = undefined;
1121
+ contextState.ephemeralText = undefined;
1111
1122
  lastTurnUsage = undefined; // 续接:旧会话的 token 累计已无意义,清空等下轮覆写
1112
1123
  layout.clearContent();
1113
1124
  renderHistory(history);
@@ -1365,6 +1376,7 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1365
1376
  turnCount = 0; // 反思 cadence 重新计数
1366
1377
  contextState.lastUsage = undefined;
1367
1378
  contextState.lifecycleStats = undefined;
1379
+ contextState.ephemeralText = undefined;
1368
1380
  lastTurnUsage = undefined; // 清空旧轮的 token 累计
1369
1381
  pendingAttachments = []; // 一并清空待发图片
1370
1382
  layout.clearContent();
@@ -1549,7 +1561,26 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1549
1561
  // 走调度器路径:与自动每步压缩完全一致——五区按 ROI 压(cold tools 优先 → history 摘要最后)。
1550
1562
  // focus 透传到 compact_history action 的 LLM 摘要 prompt。
1551
1563
  // 返回 SchedulerRunLog 给 UI 显示决策;退化路径(开关关时)在 manualCompact 内部走 compactHistory。
1552
- const log = await manualCompact(history, focus, { force });
1564
+ const log = await (async () => {
1565
+ // /compact 的摘要是几十秒的 LLM 调用:包一层运行态监听让 Ctrl+C 能掐断它
1566
+ // (信号透传 manualCompact → maybeCompact → compactHistory → chat)。
1567
+ const signal = startRunningListener(t('running.compacting'));
1568
+ try {
1569
+ return await manualCompact(history, focus, { force, signal });
1570
+ }
1571
+ finally {
1572
+ stopRunningListener();
1573
+ }
1574
+ })().catch((e) => {
1575
+ // 中断:history 未被改动(重建在摘要成功之后),直接提示并回到输入态。
1576
+ if (e instanceof Error && (e.name === 'AbortError' || e.name === 'APIUserAbortError')) {
1577
+ layout.contentWrite(`${ui.dim}(已取消压缩)${ui.reset}\n`);
1578
+ return null;
1579
+ }
1580
+ throw e;
1581
+ });
1582
+ if (!log)
1583
+ continue;
1553
1584
  // 会话状态(plan + 笔记段)不在此处回写 history[0]:agent/core 每步都在 requestHistory
1554
1585
  // 末尾注入最新副本(buildSessionStateReminder),压缩后下一步自然恢复,且系统提示保持
1555
1586
  // 逐字节稳定以命中 prompt 缓存。
@@ -1737,6 +1768,11 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1737
1768
  ANTHROPIC_PROMPT_CACHE: target.anthropicPromptCache ? 'true' : 'false',
1738
1769
  });
1739
1770
  reconfigureClient();
1771
+ // 记为激活预设:让上下文窗口等配置从此跟随该预设文件(下次启动也用它,不再回退 config 裸键)。
1772
+ try {
1773
+ setActivePresetName(target.name);
1774
+ }
1775
+ catch { /* 指针写失败不阻断切换 */ }
1740
1776
  refreshStatusBase(history);
1741
1777
  layout.clearContent();
1742
1778
  if (history.some((m) => m.role === 'user')) {
@@ -1748,9 +1784,9 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1748
1784
  const cacheLabel = target.provider === 'anthropic'
1749
1785
  ? ` · Prompt Cache ${target.anthropicPromptCache ? 'on' : 'off'}`
1750
1786
  : '';
1751
- layout.contentWrite(`${ui.dim}(已切换到预设 “${target.name}” → ${target.model} · ${target.provider}${cacheLabel} @ ${target.baseURL})${ui.reset}\n`);
1787
+ layout.contentWrite(`${ui.dim}(已切换到预设 “${target.name}” → ${target.model} · ${target.provider}${cacheLabel} · 窗口 ${target.contextWindow} @ ${target.baseURL})${ui.reset}\n`);
1752
1788
  if (config.llmKeysFromShell.length > 0) {
1753
- layout.contentWrite(`${ui.dim}(shell 环境变量已设 ${config.llmKeysFromShell.join(' / ')},文件写入下次启动被其覆盖)${ui.reset}\n`);
1789
+ layout.contentWrite(`${ui.dim}(shell 环境变量已设 ${config.llmKeysFromShell.join(' / ')},优先级最高,下次启动会盖掉预设的对应字段;预设仍记为激活,取消 shell 设置后恢复跟随)${ui.reset}\n`);
1754
1790
  }
1755
1791
  };
1756
1792
  // 决定自动存的预设名:协议、缓存配置和连接四元组都一致时不重复存。
@@ -1809,10 +1845,11 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1809
1845
  allowCustom: false, // 纯切换,不需要「其他」干扰
1810
1846
  });
1811
1847
  if (choice.action === 'selected' && choice.value) {
1812
- // value 含 ANSI 序列(labelFor 用了 ui.dim);按 preset.name 前缀匹配。
1813
- const idx = presets.findIndex((p) => choice.value.startsWith(p.name));
1814
- const target = idx >= 0 ? presets[idx] : presets[0];
1815
- applyPresetAndPersist(target);
1848
+ // 精确匹配 labelFor 生成的完整选项串,避免 name 前缀误命中
1849
+ // (如 'qwen3-8-27b' 'qwen3-8-27b-2' 的前缀,排序在前会抢中,应用错 contextWindow)
1850
+ const target = presets.find((p) => labelFor(p) === choice.value);
1851
+ if (target)
1852
+ applyPresetAndPersist(target);
1816
1853
  }
1817
1854
  continue;
1818
1855
  }
@@ -2078,6 +2115,13 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
2078
2115
  });
2079
2116
  savedName = finalName;
2080
2117
  }
2118
+ // 记为激活预设(新存或复用同名都记),让窗口跟随该预设文件。
2119
+ if (savedName) {
2120
+ try {
2121
+ setActivePresetName(savedName);
2122
+ }
2123
+ catch { /* 指针写失败不阻断 */ }
2124
+ }
2081
2125
  }
2082
2126
  catch (e) {
2083
2127
  layout.contentWrite(`${ui.red}保存预设失败: ${e.message}${ui.reset}\n`);