mocode-ai 1.7.8 → 1.7.10

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/bin/mocode.js CHANGED
@@ -2,6 +2,20 @@
2
2
  // mocode 全局入口:shebang + 拉起编译产物。子命令(--resume / config)路由在 dist/index.js 的 main() 内,
3
3
  // 用动态 import 按需加载,避免 `mocode config`(零配置时)触发 config/index.ts 的 requireEnv 退出。
4
4
 
5
+ // Node 编译缓存:把模块字节码缓存到 ~/.mocode/compile-cache,大幅缩短冷态(开机首跑)启动——
6
+ // 磁盘缓存冷时 openai 等大依赖的逐文件编译是首帧最大瓶颈。enableCompileCache 需 Node ≥22.8;
7
+ // 低版本无此 API,跳过(engines 仍为 >=18,不阻塞安装)。缓存目录不可写时 Node 只打 warning 不失败。
8
+ try {
9
+ const { enableCompileCache } = await import('node:module');
10
+ if (typeof enableCompileCache === 'function') {
11
+ const os = await import('node:os');
12
+ const path = await import('node:path');
13
+ enableCompileCache(path.join(os.homedir(), '.mocode', 'compile-cache'));
14
+ }
15
+ } catch {
16
+ // 旧 Node / 缓存目录异常:退回无缓存路径,行为不变。
17
+ }
18
+
5
19
  // 抑制 openai v4 -> node-fetch v2 -> whatwg-url v5 链触发的 Node 内置 punycode 弃用警告,
6
20
  // 防止 [DEP0040] 输出污染终端 UI 的输入框区域。
7
21
  const originalEmit = process.emit;
@@ -130,7 +130,7 @@ function writeToolHeader(tc, toolRuntime) {
130
130
  const id = batch.beginBatch();
131
131
  currentBatchId = id;
132
132
  batch.bindCall(tc.id, id);
133
- batch.recordCall(id, tc.name, summarizeToolCall(tc.name, tc.arguments));
133
+ batch.recordCall(id, tc.name, summarizeToolCall(tc.name, tc.arguments), tc.id);
134
134
  batch.showLiveBatch(id, layout);
135
135
  }
136
136
  else {
@@ -143,7 +143,7 @@ function writeToolHeader(tc, toolRuntime) {
143
143
  // 结果按 tool_call id 归位:并行执行时 currentBatchId 会漂移,只按“当前批”回填会漏填,
144
144
  // 摘要行就永远停在 ◇(用户实测:子 agent 跑完主侧菱形没变成实心圆)。
145
145
  batch.bindCall(tc.id, id);
146
- batch.recordCall(id, tc.name, summarizeToolCall(tc.name, tc.arguments));
146
+ batch.recordCall(id, tc.name, summarizeToolCall(tc.name, tc.arguments), tc.id);
147
147
  // 第一条工具开始时立即落摘要;后续调用加入同一 batch,并原地刷新计数。
148
148
  batch.showLiveBatch(id, layout);
149
149
  }
@@ -160,7 +160,7 @@ function writeToolResult(tc, output, parsed, preWriteOld, editStartLine, toolRun
160
160
  // 无需独立批。全部 entry 完成后收口组容器批(落完成态 + 开放点击展开),
161
161
  // 并与后续内容约定一条分隔空行。
162
162
  const preview = summarizeToolResult(tc.name, output);
163
- batch.recordResult(batchId, tc.name, preview, null, output, isToolErrorOutput(output), tc.id);
163
+ batch.recordResult(batchId, tc.name, preview, null, output, isToolErrorOutput(output), tc.id, layout);
164
164
  if (batch.isBatchComplete(batchId)) {
165
165
  batch.endBatch(batchId, layout);
166
166
  subAgentGroupId = null;
@@ -189,7 +189,7 @@ function writeToolResult(tc, output, parsed, preWriteOld, editStartLine, toolRun
189
189
  });
190
190
  }
191
191
  const preview = diff ? '' : summarizeToolResult(tc.name, output);
192
- batch.recordResult(batchId, tc.name, preview, diff, output, isToolErrorOutput(output));
192
+ batch.recordResult(batchId, tc.name, preview, diff, output, isToolErrorOutput(output), tc.id, layout);
193
193
  batch.showLiveBatch(batchId, layout);
194
194
  // mutation 结果(成功 diff 或错误输出)立即可见,并阻止后续普通工具并入这一批。
195
195
  if (isMutationTool(tc.name, toolRuntime))
@@ -404,6 +404,7 @@ runtimeOrContext = defaultRuntime) {
404
404
  writeToolHeader(tc, runtime.context.toolRuntime);
405
405
  },
406
406
  onToolStart: (name) => spinner.start(t('agent.executing', { tool: name })),
407
+ onToolOutput: (tc, chunk) => batch.recordOutput(tc.id, chunk, layout),
407
408
  onToolDone: () => spinner.stop(),
408
409
  onToolResult: (tc, output, parsed, preWriteOld, editStartLine) => writeToolResult(tc, output, parsed, preWriteOld, editStartLine, runtime.context.toolRuntime),
409
410
  onToolBatchEnd: () => {
@@ -326,6 +326,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
326
326
  delegation: delegationForOrchestrator,
327
327
  readDedup,
328
328
  argumentErrorHint: (name) => argumentErrorHint(name, runtimeContextState),
329
+ onToolOutput: (call, chunk) => hooks.onToolOutput?.(call, chunk),
329
330
  ...(opts.toolPolicy
330
331
  ? {
331
332
  expandToolGroups: (groups, reason) => opts.toolPolicy.expand(groups, reason),
@@ -396,6 +397,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
396
397
  for (const index of eligibleReadonly) {
397
398
  startedReadonly[index] = ctx.toolRuntime.executeToolOutcome(calls[index].name, calls[index].arguments, signal, {
398
399
  callId: calls[index].id,
400
+ onOutput: (chunk) => hooks.onToolOutput?.(calls[index], chunk),
399
401
  allowedToolNames: currentAllowedToolNames(),
400
402
  delegation: delegationForOrchestrator(),
401
403
  readDedup,
@@ -566,6 +568,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
566
568
  hooks.onToolStart?.(batch[0].name);
567
569
  const started = batch.map((tc) => ctx.toolRuntime.executeToolOutcome(tc.name, tc.arguments, signal, {
568
570
  callId: tc.id,
571
+ onOutput: (chunk) => hooks.onToolOutput?.(tc, chunk),
569
572
  allowedToolNames: currentAllowedToolNames(),
570
573
  delegation: delegationForOrchestrator(),
571
574
  readDedup,
@@ -663,6 +666,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
663
666
  ? entry.denied
664
667
  : await ctx.toolRuntime.executeToolOutcome(entry.tc.name, entry.tc.arguments, signal, {
665
668
  callId: entry.tc.id,
669
+ onOutput: (chunk) => hooks.onToolOutput?.(entry.tc, chunk),
666
670
  allowedToolNames: currentAllowedToolNames(),
667
671
  delegation: delegationForOrchestrator(),
668
672
  readDedup,
@@ -751,6 +755,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
751
755
  const hint = argumentErrorHint(entry.tc.name, runtimeContextState);
752
756
  return ctx.toolRuntime.executeToolOutcome(entry.tc.name, entry.tc.arguments, signal, {
753
757
  callId: entry.tc.id,
758
+ onOutput: (chunk) => hooks.onToolOutput?.(entry.tc, chunk),
754
759
  allowedToolNames: currentAllowedToolNames(),
755
760
  delegation: delegationForOrchestrator(),
756
761
  readDedup,
@@ -846,6 +851,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
846
851
  const serialHint = argumentErrorHint(tc.name, runtimeContextState);
847
852
  const outcome = await ctx.toolRuntime.executeToolOutcome(tc.name, tc.arguments, signal, {
848
853
  callId: tc.id,
854
+ onOutput: (chunk) => hooks.onToolOutput?.(tc, chunk),
849
855
  allowedToolNames: currentAllowedToolNames(),
850
856
  delegation: delegationForOrchestrator(),
851
857
  readDedup,
@@ -44,6 +44,7 @@ class LegacyCompatibleToolDispatcher {
44
44
  });
45
45
  const execute = (call, hint, onLockAcquired) => toolRuntime.executeToolOutcome(call.name, call.arguments, request.signal, {
46
46
  callId: call.id,
47
+ onOutput: (chunk) => request.onToolOutput?.(call, chunk),
47
48
  allowedToolNames: request.currentAllowedToolNames(),
48
49
  delegation: request.delegation(),
49
50
  readDedup: request.readDedup,
@@ -484,8 +484,9 @@ ${buildWorkDisciplineSection(inferModelFamily(config.model))}
484
484
  ## Tool policy
485
485
  - Silent Execution: invoke tools directly without preamble. Output visible text ONLY for the final answer and critical mid-task findings. Strictly no step-by-step narration (no "let me…", "让我先…", "now checking…" between calls).
486
486
  - Go directly to a known path or symbol; use discovery tools only when the location is unknown.
487
- - Edit against a FRESH read: before any edit_file or a replacing write_file, call read_file on the exact path and copy both its latest hash and the exact target text. Never reconstruct old_string from a grep/summary/diff — those lose whitespace and indentation and cause edit failures. (write_file with append=true is the exception: it reads and hashes the file itself, so no prior read_file and no expected_hash are needed.)
488
- - A read_file hash from before a compaction, session resume, edit conflict, or external change is STALE and will be rejected — re-read rather than reuse an old hash.
487
+ - Edit against a FRESH read: before editing, call read_file on the exact path and copy the exact target text. Never reconstruct old_string from a grep/summary/diff — those lose whitespace and indentation and cause edit failures.
488
+ - Pick the right file tool: use edit_file for partial changes to an existing file (expected_hash is OPTIONAL in old_string mode, REQUIRED in line-range mode); use write_file only to create a file, to rewrite most of a file (replacing needs the latest read_file hash), or to append (append=true needs no read_file and no hash). Never write a throwaway script (tmp_patch*.cjs, fix_*.py, ...) to modify source files: scripts run outside ChangeSet tracking, so rollback and diff cannot see them.
489
+ - A read_file hash from before a compaction, session resume, edit conflict, or external change is STALE and will be rejected if you pass it. In edit_file old_string mode just omit expected_hash; otherwise re-read rather than reuse an old hash.
489
490
  - Emit multiple independent tool calls in ONE assistant message so they run concurrently — e.g. several read_file regions, a grep plus a glob, or several web_fetch calls. One lookup per message wastes a full model round-trip each time. Place parallel-safe calls consecutively; keep any call that depends on their results (e.g. an edit) for the next message.
490
491
  - Never batch a read with an edit that depends on it; do not repeat overlapping reads or unchanged failed calls.
491
492
  - On failure, inspect the full error, change the approach, and retry only with a reason. Drop stale tool output when it no longer supports the task.
@@ -164,6 +164,8 @@ const zhCN = {
164
164
  'repl.queuedCommandHeld': '排队的是斜杠命令,已填入输入框,请确认后手动发送',
165
165
  'repl.loadFailed': '(加载失败)',
166
166
  'repl.resumeAnchor': '已恢复会话 {id} · {count} 条消息 · 当前模型 {model}(/model switch 可切换)',
167
+ 'repl.compactedNotice': '── 更早的对话已被压缩成摘要(助手回复与工具过程不再保留),以下为摘要与保留的用户原话 ──',
168
+ 'repl.compactedTruncated': '…(摘要共 {total} 字符,仅显示前 {shown} 字符)',
167
169
  'repl.noSessions': '(没有已保存的会话)',
168
170
  'repl.noValue': '(无)',
169
171
  'repl.historyCleared': '(历史已清空,保留系统提示)',
@@ -554,6 +556,8 @@ const en = {
554
556
  'repl.queuedCommandHeld': 'Queued text is a slash command; filled into the input for you to send manually',
555
557
  'repl.loadFailed': '(load failed)',
556
558
  'repl.resumeAnchor': 'Resumed session {id} · {count} messages · model {model} (/model switch to change)',
559
+ 'repl.compactedNotice': '── Earlier conversation was compacted into a summary (assistant replies and tool steps are not kept); below are the summary and the preserved user messages ──',
560
+ 'repl.compactedTruncated': '… (summary is {total} chars, showing the first {shown})',
557
561
  'repl.noSessions': '(no saved sessions)',
558
562
  'repl.noValue': '(none)',
559
563
  'repl.historyCleared': '(history cleared; system prompt kept)',
package/dist/llm/index.js CHANGED
@@ -1,4 +1,3 @@
1
- import OpenAI from 'openai';
2
1
  import { config, getActiveModel } from '../config/index.js';
3
2
  import { tools } from '../tools/registry.js';
4
3
  import { getPlanDisabledTools, getProfileDisabledTools } from '../tools/constants.js';
@@ -49,8 +48,26 @@ const STREAM_RETRY_MAX_ATTEMPTS = 2;
49
48
  * 与 OpenAI 兼容协议的独立 `reasoning_content` 字段不同,这些模型把 thinking 直接嵌进 content
50
49
  * 字符串,期间不调 onText(spinner 持续转 ⠹ 思考中…),也不写入可见 content(history 不被思考段污染)。
51
50
  */
52
- function newOpenAIClient(runtimeConfig) {
53
- return new OpenAI({
51
+ // ── openai SDK 懒加载 ────────────────────────────────────────────────────────
52
+ // openai 是千余文件的巨型 CJS 包,冷态(开机磁盘缓存冷)首次加载实测 3.8–6s,
53
+ // 是首帧 UI 最大瓶颈。静态 import 会在模块求值时把它整个拉进来。这里:
54
+ // 1) 模块顶层只保留类型,不加载包;
55
+ // 2) getOpenAI() 首次调用时动态 import(异步,不阻塞构造);
56
+ // 3) 首帧后由 src/startup/prewarm 在后台(Worker 冷编译 + 主线程热加载)提前就绪。
57
+ let openAIModulePromise = null;
58
+ /** 加载 openai 包(预热与首次实例化共用,全程只加载一次)。 */
59
+ function loadOpenAIModule() {
60
+ if (!openAIModulePromise) {
61
+ openAIModulePromise = import('openai');
62
+ }
63
+ return openAIModulePromise;
64
+ }
65
+ /** 首帧后后台预热:提前把 openai 包加载进内存,让首次真实请求零等待。幂等,失败静默。 */
66
+ export function prewarmLlm() {
67
+ return loadOpenAIModule();
68
+ }
69
+ function newOpenAIClient(mod, runtimeConfig) {
70
+ return new mod.default({
54
71
  baseURL: runtimeConfig.baseURL,
55
72
  apiKey: runtimeConfig.apiKey,
56
73
  maxRetries: 0,
@@ -58,16 +75,31 @@ function newOpenAIClient(runtimeConfig) {
58
75
  }
59
76
  /** Build mutable client state owned by one runtime; no module-global overrides are consulted. */
60
77
  export function createChatClientState(runtimeConfig, overrides = {}) {
61
- installEnvProxy();
78
+ let cfg = runtimeConfig;
79
+ let clientPromise = null;
62
80
  return {
63
- openAI: newOpenAIClient(runtimeConfig),
81
+ getOpenAI() {
82
+ if (!clientPromise) {
83
+ // 代理(undici)同样懒装:百毫秒级依赖,且只影响真实出站请求。
84
+ clientPromise = (async () => {
85
+ await installEnvProxy();
86
+ const mod = await loadOpenAIModule();
87
+ return newOpenAIClient(mod, cfg);
88
+ })();
89
+ }
90
+ return clientPromise;
91
+ },
92
+ resetOpenAI(nextConfig) {
93
+ cfg = nextConfig;
94
+ clientPromise = null;
95
+ },
64
96
  openAICreateImpl: overrides.openAICreateImpl ?? null,
65
97
  anthropicFetchImpl: overrides.anthropicFetchImpl ?? fetch,
66
98
  };
67
99
  }
68
100
  /** Rebuild only the OpenAI SDK client while preserving runtime-local test/transport overrides. */
69
101
  export function reconfigureChatClient(state, runtimeConfig) {
70
- state.openAI = newOpenAIClient(runtimeConfig);
102
+ state.resetOpenAI(runtimeConfig);
71
103
  }
72
104
  const defaultClientState = createChatClientState(config, { anthropicFetchImpl: defaultAnthropicFetch });
73
105
  const defaultProviderRuntime = {
@@ -75,6 +107,13 @@ const defaultProviderRuntime = {
75
107
  getModel: getActiveModel,
76
108
  clientState: defaultClientState,
77
109
  };
110
+ /**
111
+ * 首帧后后台预热默认客户端:在用户阅读 banner / 输入需求的时间窗里提前加载 openai 并实例化,
112
+ * 让首次真实请求不必再付冷加载成本。fire-and-forget,任何失败静默(真实请求路径会自行重试/报错)。
113
+ */
114
+ export function prewarmDefaultClient() {
115
+ void defaultClientState.getOpenAI().catch(() => undefined);
116
+ }
78
117
  /**
79
118
  * 运行时重建 OpenAI 客户端(/model 切换 baseURL/apiKey 后调)。
80
119
  * config.model 已在 chat() 每次读取(热切),但 client 的 baseURL/apiKey 是构造时固化的实例字段,
@@ -648,7 +687,10 @@ async function chatOnce(messages, handlers, signal, toolsOverride, runtime = def
648
687
  const runtimeClientState = runtime.clientState;
649
688
  const create = runtimeClientState.openAICreateImpl
650
689
  ? runtimeClientState.openAICreateImpl
651
- : (body, opts) => runtimeClientState.openAI.chat.completions.create(body, opts);
690
+ : async (body, opts) => {
691
+ const client = await runtimeClientState.getOpenAI();
692
+ return client.chat.completions.create(body, opts);
693
+ };
652
694
  // transport 边界消毒:剔除部分后端(kimi-k3@dashscope 实测)整请求 400 拒绝的
653
695
  // uniqueItems 关键字。无需改写时返回原引用,前缀缓存逐字节稳定不受影响。
654
696
  const activeTools = sanitizeToolSchemas(toolsOverride ?? chatTools);
package/dist/llm/proxy.js CHANGED
@@ -9,8 +9,10 @@
9
9
  * 幂等:进程内只安装一次。MOCODE_NO_ENV_PROXY=1 可完全关闭。
10
10
  */
11
11
  import { execSync } from 'node:child_process';
12
- import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
12
+ // undici 懒加载:它是百毫秒级依赖,而代理安装只在首个真实出站请求时才需要(首帧 UI 不需要)。
13
+ // 故不在模块顶层 import,改在 installEnvProxy 内动态 import。
13
14
  let installed = false;
15
+ let installPromise = null;
14
16
  function withProtocol(value) {
15
17
  return /^https?:\/\//i.test(value) ? value : `http://${value}`;
16
18
  }
@@ -87,38 +89,46 @@ function keepAliveTimeoutMs() {
87
89
  }
88
90
  export function installEnvProxy() {
89
91
  if (installed)
90
- return;
92
+ return Promise.resolve();
93
+ if (installPromise)
94
+ return installPromise;
91
95
  installed = true;
92
- if (process.env.MOCODE_NO_ENV_PROXY === '1')
93
- return;
94
- const envProxy = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy;
95
- const windowsOptions = windowsProxyOptions();
96
- const options = envProxy ? undefined : (windowsOptions ?? undefined);
97
- if (!envProxy && !windowsOptions)
98
- return;
99
- // EnvHttpProxyAgent 构造时会通过 process.emitWarning 打一条 UNDICI-EHPA 实验性警告,
100
- // 在 TUI 里可能落成脏行;屏蔽这一条特定警告,其余警告照常。
101
- const originalEmitWarning = process.emitWarning.bind(process);
102
- process.emitWarning = ((...args) => {
103
- const first = args[0];
104
- if (typeof first === 'string' && first.includes('EnvHttpProxyAgent is experimental'))
96
+ installPromise = (async () => {
97
+ if (process.env.MOCODE_NO_ENV_PROXY === '1')
105
98
  return;
106
- return originalEmitWarning(...args);
107
- });
108
- try {
109
- // keepalive 调参:EnvHttpProxyAgent 把 ...agentOpts 透传给内部 Agent/ProxyAgent
110
- // (undici 6.21 env-http-proxy-agent.js 构造器),Client 层消费这三个选项。
111
- // threshold 刻意高于默认 2s:undici 会在「服务端 Keep-Alive 头声明的存活期 − threshold」
112
- // 时刻掐自己的空闲连接,若代理声明的存活期略长于我们的 keepAliveTimeout,留出余量
113
- // 可避免双方同时收线时撞上刚被掐死的连接(那正是 ECONNRESET 的形态)。
114
- setGlobalDispatcher(new EnvHttpProxyAgent({
115
- ...(options ?? {}),
116
- keepAliveTimeout: keepAliveTimeoutMs(),
117
- keepAliveMaxTimeout: 600_000,
118
- keepAliveTimeoutThreshold: 5_000,
119
- }));
120
- }
121
- finally {
122
- process.emitWarning = originalEmitWarning;
123
- }
99
+ const envProxy = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy;
100
+ // reg query(同步子进程)只在没有 env 代理时才探测 Windows 系统代理,避免无谓启动。
101
+ const windowsOptions = envProxy ? null : windowsProxyOptions();
102
+ const options = envProxy ? undefined : (windowsOptions ?? undefined);
103
+ if (!envProxy && !windowsOptions)
104
+ return;
105
+ const { EnvHttpProxyAgent, setGlobalDispatcher } = await import('undici');
106
+ // EnvHttpProxyAgent 构造时会通过 process.emitWarning 打一条 UNDICI-EHPA 实验性警告,
107
+ // 在 TUI 里可能落成脏行;屏蔽这一条特定警告,其余警告照常。
108
+ const originalEmitWarning = process.emitWarning.bind(process);
109
+ process.emitWarning = ((...args) => {
110
+ const first = args[0];
111
+ if (typeof first === 'string' && first.includes('EnvHttpProxyAgent is experimental'))
112
+ return;
113
+ return originalEmitWarning(...args);
114
+ });
115
+ try {
116
+ // keepalive 调参:EnvHttpProxyAgent 把 ...agentOpts 透传给内部 Agent/ProxyAgent
117
+ // (undici 6.21 env-http-proxy-agent.js 构造器),Client 层消费这三个选项。
118
+ // threshold 刻意高于默认 2s:undici 会在「服务端 Keep-Alive 头声明的存活期 − threshold」
119
+ // 时刻掐自己的空闲连接,若代理声明的存活期略长于我们的 keepAliveTimeout,留出余量
120
+ // 可避免双方同时收线时撞上刚被掐死的连接(那正是 ECONNRESET 的形态)。
121
+ setGlobalDispatcher(new EnvHttpProxyAgent({
122
+ ...(options ?? {}),
123
+ keepAliveTimeout: keepAliveTimeoutMs(),
124
+ keepAliveMaxTimeout: 600_000,
125
+ keepAliveTimeoutThreshold: 5_000,
126
+ }));
127
+ }
128
+ finally {
129
+ process.emitWarning = originalEmitWarning;
130
+ }
131
+ })();
132
+ // 安装失败不影响后续重试语义;这里只保证并发调用复用同一 promise。
133
+ return installPromise;
124
134
  }
@@ -4,8 +4,20 @@
4
4
  import { createRequire } from 'node:module';
5
5
  import { spawn } from 'node:child_process';
6
6
  import { randomUUID } from 'node:crypto';
7
- import WebSocket from 'ws';
8
7
  import { parseServerMessage } from './protocol.js';
8
+ // ws 懒加载:桌宠是可选增强,首帧 UI 与主 agent 循环都不需要 WebSocket。
9
+ // 仅在真正发起本地连接(probePort / connect)时才同步加载;类型仍用 type-only import。
10
+ const require = createRequire(import.meta.url);
11
+ /** ws readyState 常量(open=1),与 WebSocket.OPEN 等价;本地常量避免顶层加载包。 */
12
+ const WS_OPEN = 1;
13
+ let WsCtor = null;
14
+ function getWsCtor() {
15
+ if (!WsCtor) {
16
+ const mod = require('ws');
17
+ WsCtor = mod.default ?? mod;
18
+ }
19
+ return WsCtor;
20
+ }
9
21
  /** 默认端口;MOCODE_PET_PORT 环境变量覆盖(design.md 默认假设)。 */
10
22
  export const DEFAULT_PET_PORT = 47821;
11
23
  function petPort() {
@@ -40,7 +52,7 @@ function clearHeartbeat() {
40
52
  function startHeartbeat(ws) {
41
53
  clearHeartbeat();
42
54
  heartbeatTimer = setInterval(() => {
43
- if (ws.readyState !== WebSocket.OPEN)
55
+ if (ws.readyState !== WS_OPEN)
44
56
  return;
45
57
  try {
46
58
  ws.send(JSON.stringify({ type: 'ping', ts: Date.now() }));
@@ -68,7 +80,7 @@ function startHeartbeat(ws) {
68
80
  export function probePort(port, timeoutMs = PROBE_TIMEOUT_MS) {
69
81
  return new Promise((resolve) => {
70
82
  let settled = false;
71
- const ws = new WebSocket(`ws://127.0.0.1:${port}`);
83
+ const ws = new (getWsCtor())(`ws://127.0.0.1:${port}`);
72
84
  const timer = setTimeout(() => {
73
85
  if (settled)
74
86
  return;
@@ -114,7 +126,6 @@ export function spawnPetProcess() {
114
126
  return new Promise((resolve, reject) => {
115
127
  let binPath;
116
128
  try {
117
- const require = createRequire(import.meta.url);
118
129
  binPath = require.resolve('mocode-pet-app/bin/pet-app.js');
119
130
  }
120
131
  catch {
@@ -168,7 +179,7 @@ function sleep(ms) {
168
179
  /** 真正建立并返回一条打开的 WS 连接(不做探测,假定端口已确认可连)。 */
169
180
  function openConnection(port) {
170
181
  return new Promise((resolve, reject) => {
171
- const ws = new WebSocket(`ws://127.0.0.1:${port}`);
182
+ const ws = new (getWsCtor())(`ws://127.0.0.1:${port}`);
172
183
  const timer = setTimeout(() => {
173
184
  try {
174
185
  ws.terminate();
@@ -241,7 +252,7 @@ function wireConnection(ws) {
241
252
  * 后置条件:若 state !== lastSentState,构造并发送一条合法 StateMessage;否则无副作用。
242
253
  */
243
254
  export function sendState(state, meta) {
244
- if (!socket || socket.readyState !== WebSocket.OPEN)
255
+ if (!socket || socket.readyState !== WS_OPEN)
245
256
  return; // 未连接:no-op,不缓冲不报错
246
257
  if (state === lastSentState)
247
258
  return; // 节流:状态未变化不重发
@@ -256,7 +267,7 @@ export function sendState(state, meta) {
256
267
  }
257
268
  /** 当前是否已建立活跃连接。 */
258
269
  export function isConnected() {
259
- return !!socket && socket.readyState === WebSocket.OPEN;
270
+ return !!socket && socket.readyState === WS_OPEN;
260
271
  }
261
272
  /** 主动断开连接(best-effort 发 bye 后 close code=1000)。 */
262
273
  export function disconnect() {
@@ -288,7 +299,7 @@ export async function killPetProcess() {
288
299
  const port = petPort();
289
300
  let ws = socket;
290
301
  let owned = false; // 是否为本函数临时建立的连接(需要负责关闭)
291
- if (!ws || ws.readyState !== WebSocket.OPEN) {
302
+ if (!ws || ws.readyState !== WS_OPEN) {
292
303
  const already = await probePort(port, PROBE_TIMEOUT_MS);
293
304
  if (!already) {
294
305
  return { ok: false, reason: '桌宠未运行' };
@@ -365,7 +376,7 @@ export async function listSkins() {
365
376
  * 后置条件:已连接时发送 set_skin 消息;未连接时不抛错、无副作用。
366
377
  */
367
378
  export function setSkin(skinId) {
368
- if (!socket || socket.readyState !== WebSocket.OPEN)
379
+ if (!socket || socket.readyState !== WS_OPEN)
369
380
  return;
370
381
  try {
371
382
  socket.send(JSON.stringify({ type: 'set_skin', clientId, skinId, ts: Date.now() }));
@@ -3,7 +3,12 @@ import * as layout from '../ui/layout.js';
3
3
  import * as batch from '../ui/batch.js';
4
4
  import { displayWidth, padEndDisplay, summarizeToolCall, summarizeToolResult } from '../ui/render.js';
5
5
  import { renderChip } from '../attachments/image.js';
6
+ import { t } from '../i18n/index.js';
6
7
  import { PROMPT } from './commands.js';
8
+ /** compact 摘要消息头(与 session/compact.ts SUMMARY_MARKER 同值;本地复制避免拉入 compact 的重依赖图,同 retention.ts)。 */
9
+ const SUMMARY_MARKER = '# 会话摘要';
10
+ /** 回放时摘要最多显示的字符数(摘要上限 6000,全量回放会淹没保留的用户原话)。 */
11
+ const SUMMARY_REPLAY_MAX_CHARS = 3000;
7
12
  /** side channel:history 中 user 消息的 index → 附件列表,供 renderHistory 复显文件名。runTurn 在 push 前设。 */
8
13
  export const messageAttachments = new Map();
9
14
  export function formatUserMessage(lines, trailingBlank = true) {
@@ -69,8 +74,25 @@ export function renderHistory(history) {
69
74
  };
70
75
  for (let idx = 0; idx < history.length; idx++) {
71
76
  const m = history[idx];
72
- if (m.role === 'system')
77
+ if (m.role === 'system') {
78
+ // compact 摘要是被压缩掉的助手回复/工具过程的唯一留存:回放时显示出来(history[0] 的系统提示仍跳过)。
79
+ const body = textOf(m.content);
80
+ if (idx > 0 && body.startsWith(SUMMARY_MARKER)) {
81
+ flushBatch();
82
+ layout.normalizeMutationBoundary();
83
+ layout.contentWrite(`${ui.dim}${t('repl.compactedNotice')}${ui.reset}\n\n`);
84
+ const text = body.slice(SUMMARY_MARKER.length).trim();
85
+ if (text.length > SUMMARY_REPLAY_MAX_CHARS) {
86
+ layout.contentWriteMdOnce(text.slice(0, SUMMARY_REPLAY_MAX_CHARS));
87
+ layout.contentWrite(`${ui.dim}${t('repl.compactedTruncated', { total: text.length, shown: SUMMARY_REPLAY_MAX_CHARS })}${ui.reset}\n`);
88
+ }
89
+ else {
90
+ layout.contentWriteMdOnce(text);
91
+ }
92
+ layout.normalizeMutationBoundary();
93
+ }
73
94
  continue;
95
+ }
74
96
  if (m.role === 'user') {
75
97
  flushBatch(); // 上一轮 batch(若有)收尾
76
98
  const lines = textOf(m.content).split('\n');
@@ -15,6 +15,7 @@ import * as mouse from '../ui/mouse.js';
15
15
  import { promptWithSlashMenu, promptTurnPicker, promptRevertChoice } from '../ui/prompt.js';
16
16
  import { promptIntervention } from '../ui/intervention.js';
17
17
  import { registerToolsExtension } from '../tools/registry.js';
18
+ import { startPrewarm, defaultWorkerUrl } from '../startup/prewarm.js';
18
19
  // 装配官方默认工具包:registry 不再顶层 import builtins(破模块循环),REPL 入口须显式装配。
19
20
  import '../tools/builtins/index.js';
20
21
  import { initializeAllMcp, getMcpTools, closeAllMcp } from '../mcp/index.js';
@@ -277,6 +278,10 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
277
278
  else {
278
279
  layout.writeBanner(bannerLines(banner()));
279
280
  }
281
+ // 首帧已渲染(banner / 恢复历史)。立刻后台预热重依赖(openai/fast-glob/cli-highlight/ajv/…):
282
+ // 占满用户阅读 banner / 思考 / 打字的时间窗,Worker 冷编译不冻结 TUI,主线程随后分块热加载,
283
+ // 让首次真实请求(首 token)不再付冷加载成本。fire-and-forget,失败静默。
284
+ startPrewarm(defaultWorkerUrl());
280
285
  // MCP 连接结果在就绪后补写(后台并行连接,不再阻塞上方首帧);用户已滚动时仍
281
286
  // 落在内容区尾部,与启动期其它提示行为一致。
282
287
  void mcpReady.then((mcpReport) => {
@@ -0,0 +1,23 @@
1
+ // 预热 Worker:在独立线程里串行冷加载所有重依赖,目的是:
2
+ // 1) 把磁盘冷读 + V8 冷编译的主线程冻结成本移出 TUI(Worker 不阻塞 UI 事件循环);
3
+ // 2) 热好编译缓存目录(主线程与 Worker 共享同一缓存)。
4
+ // Worker 加载完发 'done' 即退出;它加载的模块不进入主线程模块缓存,主线程随后再热加载一次。
5
+ import { parentPort } from 'node:worker_threads';
6
+ const SPECS = ['openai', 'fast-glob', 'cli-highlight', 'ajv', 'ajv/dist/2020.js', 'undici', 'ws'];
7
+ async function main() {
8
+ for (const spec of SPECS) {
9
+ try {
10
+ await import(spec);
11
+ parentPort?.postMessage({ type: 'loaded', spec });
12
+ }
13
+ catch (error) {
14
+ parentPort?.postMessage({
15
+ type: 'failed',
16
+ spec,
17
+ error: error instanceof Error ? error.message : String(error),
18
+ });
19
+ }
20
+ }
21
+ parentPort?.postMessage({ type: 'done' });
22
+ }
23
+ void main();
@@ -0,0 +1,86 @@
1
+ // 首帧后重依赖预热调度。
2
+ //
3
+ // 启动时序目标:UI 首帧绝不等任何重依赖(openai 冷加载实测 3.8–6s、fast-glob ~3.5s),
4
+ // 但首 token 也不能等——所以在首帧渲染完成后立刻后台预热,占满用户阅读 banner / 思考 /
5
+ // 打字的时间窗:
6
+ //
7
+ // 阶段 A(Worker 冷编译):独立线程串行冷加载 openai/fast-glob/cli-highlight/ajv/undici/ws,
8
+ // 把磁盘冷读 + V8 冷编译的主线程冻结移出 TUI,并热好共享编译缓存。
9
+ // 阶段 B(主线程分块热加载):Worker 完成后,在主线程把真正要用的模块逐个 await 进
10
+ // 模块缓存。经过阶段 A 后每块仅数十毫秒,且用 setTimeout 切到宏任务,不阻塞输入响应。
11
+ //
12
+ // 若 Worker 不可用(旧 Node / 资源限制),退化为直接阶段 B(首次热加载稍慢但功能不变)。
13
+ // 所有失败静默:真实使用路径的懒加载/重试不受影响。
14
+ import { Worker } from 'node:worker_threads';
15
+ import { prewarmLlm, prewarmDefaultClient } from '../llm/index.js';
16
+ import { loadFastGlob } from '../tools/fast-glob-lazy.js';
17
+ import { prewarmCliHighlight } from '../ui/cli-highlight-lazy.js';
18
+ import { prewarmAjv } from '../tools/validation.js';
19
+ let started = false;
20
+ /** 阶段 B:主线程逐个热加载,块间让出事件循环。 */
21
+ async function warmMainThread() {
22
+ // openai 包 + 默认客户端实例(含代理安装),最先热——它决定首 token。
23
+ await prewarmLlm();
24
+ prewarmDefaultClient();
25
+ const steps = [
26
+ () => loadFastGlob(),
27
+ () => prewarmCliHighlight(),
28
+ () => prewarmAjv(),
29
+ () => import('undici'),
30
+ () => import('ws'),
31
+ ];
32
+ for (const step of steps) {
33
+ // 每块之间让出一次事件循环,避免连续热加载累积成可感知的输入卡顿。
34
+ await new Promise((resolve) => setTimeout(resolve, 0));
35
+ try {
36
+ await step();
37
+ }
38
+ catch {
39
+ // 单个依赖预热失败不影响其余;真实路径会自行加载/报错。
40
+ }
41
+ }
42
+ }
43
+ /**
44
+ * 首帧渲染后调用一次(幂等)。fire-and-forget:绝不阻塞返回,绝不向调用方抛错。
45
+ * @param workerUrl 预热 worker 的文件 URL;由调用方按 dist 布局传入。
46
+ */
47
+ export function startPrewarm(workerUrl) {
48
+ if (started)
49
+ return;
50
+ started = true;
51
+ const beginWarmMain = () => {
52
+ void warmMainThread().catch(() => undefined);
53
+ };
54
+ let worker = null;
55
+ try {
56
+ worker = new Worker(workerUrl, { stderr: false, stdout: false, execArgv: [] });
57
+ }
58
+ catch {
59
+ // Worker 构造失败:直接在主线程预热(稍慢,但功能等价)。
60
+ beginWarmMain();
61
+ return;
62
+ }
63
+ let finished = false;
64
+ const finish = () => {
65
+ if (finished)
66
+ return;
67
+ finished = true;
68
+ try {
69
+ worker?.terminate();
70
+ }
71
+ catch {
72
+ // 尽力清理
73
+ }
74
+ beginWarmMain();
75
+ };
76
+ worker.on('message', (msg) => {
77
+ if (msg?.type === 'done')
78
+ finish();
79
+ });
80
+ worker.on('error', finish);
81
+ worker.on('exit', finish);
82
+ }
83
+ /** 供 dist 布局解析 worker 入口:与本文件同目录的 prewarm-worker.js。 */
84
+ export function defaultWorkerUrl() {
85
+ return new URL('./prewarm-worker.js', import.meta.url);
86
+ }
@@ -1,6 +1,9 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { jailResolve } from '../../sandbox/index.js';
3
- import { commitChangeSet, createChangeSet, normalizeContentHash, summarizeChangeSet } from '../../changeset/index.js';
3
+ import { commitChangeSet, contentHash, createChangeSet, normalizeContentHash, summarizeChangeSet, } from '../../changeset/index.js';
4
+ import { isProbablyBinary } from '../../attachments/image.js';
5
+ /** 二进制嗅探只需文件头,与 read_file / write_file 同口径(16KB)。 */
6
+ const SNIFF_BYTES = 16 * 1024;
4
7
  function conflict(path, details) {
5
8
  return {
6
9
  status: 'error',
@@ -8,18 +11,16 @@ function conflict(path, details) {
8
11
  retryable: false,
9
12
  changedFiles: [],
10
13
  staleFiles: [path],
11
- output: `CHANGE_CONFLICT: ${path} was not changed. ${details} Do not retry these arguments. Call read_file on this exact path and copy both its latest hash and exact target text before editing again.`,
14
+ output: `CHANGE_CONFLICT: ${path} was not changed. ${details} Do not retry these arguments. Call read_file on this exact path and copy the exact target text (plus its latest hash if you pass expected_hash, which line-range mode requires) before editing again.`,
12
15
  };
13
16
  }
14
17
  export const editFileTool = {
15
18
  name: 'edit_file',
16
19
  description: `Replace content in a file transactionally. Two modes:
17
20
 
18
- **String replacement (default):** old_string must occur EXACTLY once; copy it verbatim from a fresh read_file output (see Tool policy — never reconstruct it from memory, summaries, or grep output).
21
+ **String replacement (default):** old_string must occur EXACTLY once; copy it verbatim from a fresh read_file output (see Tool policy — never reconstruct it from memory, summaries, or grep output). expected_hash is OPTIONAL in this mode: omit it and the tool checks the file itself (old_string must still match exactly once, and the commit is conflict-checked). If you do pass it, it must match the current file or the edit is rejected.
19
22
 
20
- **Line-range:** line_start/line_end (1-based, inclusive) instead of old_string — for large blocks, repeated patterns, or hard-to-reproduce text.
21
-
22
- expected_hash (sha256 from the read_file artifact header) is required and must match the current file; changed-since-read edits are rejected — re-read and retry with the new hash.`,
23
+ **Line-range:** line_start/line_end (1-based, inclusive) instead of old_string — for large blocks, repeated patterns, or hard-to-reproduce text. Line numbers drift when a file changes, so expected_hash (sha256 from the read_file artifact header) is REQUIRED here; if it is stale, re-read and retry with the new hash.`,
23
24
  risk: 'confirm',
24
25
  parameters: {
25
26
  type: 'object',
@@ -40,22 +41,19 @@ expected_hash (sha256 from the read_file artifact header) is required and must m
40
41
  },
41
42
  expected_hash: {
42
43
  type: 'string',
43
- description: 'sha256 hash from the latest read_file artifact header (sha256:<64 hex>).',
44
+ description: 'sha256 hash from the latest read_file artifact header (sha256:<64 hex>). Optional for old_string mode; required for line-range mode.',
44
45
  },
45
46
  },
46
47
  // 注意:expected_hash 故意不放进 schema 的 required。
47
- // 原因:缺失时若被 AJV 在 schema 层拦下,只会得到泛化的
48
- // "缺少必填字段 expected_hash",模型据此只补该字段、反而丢掉 path,形成乒乓失败。
49
- // 改为只在 execute 内校验(见下方 normalizeContentHash 检查),缺失时返回富指导信息,
50
- // 明确要求先 read_file 并复制最新 hash——与 write_file 的设计保持一致。
48
+ // 原因:old_string 模式下它是可选的(old_string 唯一命中本身就是基于内容的前置条件,
49
+ // 提交时 ChangeSet 仍会比对读到的内容 hash 防并发改动);行号模式下必填,但缺失时若被
50
+ // AJV 在 schema 层拦下,只会得到泛化的「缺少必填字段 expected_hash」,模型据此只补该字段、
51
+ // 反而丢掉 path,形成乒乓失败。故只在 execute 内校验,缺失时返回富指导信息。
51
52
  required: ['path', 'new_string'],
52
53
  },
53
54
  async execute(args, ctx) {
54
55
  const file = String(args.path);
55
56
  const newString = String(args.new_string);
56
- const expectedHash = normalizeContentHash(String(args.expected_hash));
57
- if (!expectedHash)
58
- return conflict(file, 'expected_hash 必须是 sha256:<64 hex>。');
59
57
  const hasOldString = args.old_string !== undefined;
60
58
  const hasLineStart = args.line_start !== undefined;
61
59
  const hasLineEnd = args.line_end !== undefined;
@@ -66,7 +64,25 @@ expected_hash (sha256 from the read_file artifact header) is required and must m
66
64
  if (!hasOldString && (!hasLineStart || !hasLineEnd)) {
67
65
  return conflict(file, '必须提供 old_string 或同时提供 line_start 和 line_end。');
68
66
  }
69
- const data = await readFile(jailResolve(file), 'utf8');
67
+ // expected_hash:缺省/null/空串都视为「未提供」。提供了就必须格式正确并严格校验。
68
+ const rawHash = args.expected_hash;
69
+ const hashProvided = rawHash !== undefined && rawHash !== null && String(rawHash).trim() !== '';
70
+ let expectedHash = hashProvided ? normalizeContentHash(String(rawHash)) : null;
71
+ if (hashProvided && !expectedHash)
72
+ return conflict(file, 'expected_hash 必须是 sha256:<64 hex>。');
73
+ if (!hasOldString && !expectedHash) {
74
+ return conflict(file, '行号模式必须提供 expected_hash(行号会随文件变化漂移,需要内容版本锚点)。');
75
+ }
76
+ // 以 Buffer 读取并按 Buffer 算 hash,与 ChangeSet 内部口径一致(非法 UTF-8 时字符串 hash 会对不上)。
77
+ const buffer = await readFile(jailResolve(file));
78
+ if (isProbablyBinary(buffer.subarray(0, SNIFF_BYTES))) {
79
+ return conflict(file, '是二进制文件,不能按文本编辑(utf8 往返会损毁原有字节)。');
80
+ }
81
+ // 未提供 hash:用刚读到的内容算。读与提交之间的窗口由 ChangeSet 的 dry-run + 提交前复核兜底,
82
+ // 期间文件被改会报 CHANGE_CONFLICT(fail-loud),不会静默覆盖。
83
+ if (!expectedHash)
84
+ expectedHash = contentHash(buffer);
85
+ const data = buffer.toString('utf8');
70
86
  const normalized = data.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
71
87
  const newNormalized = newString.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
72
88
  let updated;
@@ -1,4 +1,4 @@
1
- import fg from 'fast-glob';
1
+ import { loadFastGlob } from '../fast-glob-lazy.js';
2
2
  import { IGNORE } from '../constants.js';
3
3
  import { getSandboxRoot, isInsideRoot } from '../../sandbox/index.js';
4
4
  /**
@@ -39,6 +39,7 @@ export const globTool = {
39
39
  try {
40
40
  // fast-glob 3.3 的 signal 选项运行时不生效(实测),用 Promise.race 做超时/中断墙:
41
41
  // 触发后不再等待枚举——纯只读 readdir 会自行跑完,用户面即时解锁。
42
+ const fg = await loadFastGlob();
42
43
  const fgPromise = fg(pattern, {
43
44
  cwd,
44
45
  onlyFiles: true,
@@ -1,5 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import fg from 'fast-glob';
2
+ import { loadFastGlob } from '../fast-glob-lazy.js';
3
3
  import { IGNORE, MAX_RESULTS } from '../constants.js';
4
4
  import { isProbablyBinary } from '../../attachments/image.js';
5
5
  import { getSandboxRoot, isInsideRoot, jailResolve } from '../../sandbox/index.js';
@@ -97,6 +97,7 @@ async function nodeFallback(pattern, glob, maxPerFile, context, signal, external
97
97
  return `错误:非法正则 ${pattern}: ${e instanceof Error ? e.message : String(e)}`;
98
98
  }
99
99
  const cwd = getSandboxRoot() ?? process.cwd();
100
+ const fg = await loadFastGlob();
100
101
  const entries = (await fg(glob, {
101
102
  cwd,
102
103
  onlyFiles: true,
@@ -46,7 +46,7 @@ class BoundedCommandOutput {
46
46
  }
47
47
  }
48
48
  /** Execute a command with the same sandbox, output cap and cancellation semantics as run_command. */
49
- export async function runCommandRaw(command, timeout = DEFAULT_COMMAND_TIMEOUT_MS, signal, cwd, shell) {
49
+ export async function runCommandRaw(command, timeout = DEFAULT_COMMAND_TIMEOUT_MS, signal, cwd, shell, onOutput) {
50
50
  const startedAt = Date.now();
51
51
  // 内部调用方(skill 注入等)也走同一钳制:防止任何路径把前台命令挂成无限期持锁。
52
52
  const effectiveTimeout = clampCommandTimeout(timeout);
@@ -124,7 +124,16 @@ export async function runCommandRaw(command, timeout = DEFAULT_COMMAND_TIMEOUT_M
124
124
  const onAbort = () => {
125
125
  terminate('aborted');
126
126
  };
127
- const onChunk = (chunk) => output.append(chunk.toString('utf8'));
127
+ const onChunk = (chunk) => {
128
+ const text = chunk.toString('utf8');
129
+ output.append(text);
130
+ try {
131
+ onOutput?.(text);
132
+ }
133
+ catch {
134
+ // Output observers must not affect the command process lifecycle.
135
+ }
136
+ };
128
137
  // 先起 timer 再挂事件:finish/onAbort 是闭包,里面要 clearTimeout(timer)。
129
138
  // 把 timer 的初始化提到所有引用它的注册点之前,避免依赖"事件回调必然异步"这一前提。
130
139
  const timer = setTimeout(() => {
@@ -221,7 +230,7 @@ export const runCommandTool = {
221
230
  }
222
231
  shell = parsed;
223
232
  }
224
- const result = await runCommandRaw(command, timeout, ctx?.signal, undefined, shell);
233
+ const result = await runCommandRaw(command, timeout, ctx?.signal, undefined, shell, ctx?.onOutput);
225
234
  const outcome = commandOutcome(result);
226
235
  // 钳制要显式告知:模型传了 30min 却按 10min 判超时,不说清它会以为是环境抽风而盲目重试。
227
236
  if (args.timeout !== undefined && Number(args.timeout) !== timeout && result.status === 'timed_out') {
@@ -0,0 +1,12 @@
1
+ let fgPromise = null;
2
+ /** 首次调用动态加载 fast-glob;之后返回同一实例。 */
3
+ export function loadFastGlob() {
4
+ if (!fgPromise) {
5
+ fgPromise = import('fast-glob').then((m) => m.default ?? m);
6
+ }
7
+ return fgPromise;
8
+ }
9
+ /** 首帧后后台预热;幂等,失败静默(真实枚举路径会自行处理)。 */
10
+ export function prewarmFastGlob() {
11
+ return loadFastGlob();
12
+ }
@@ -290,6 +290,7 @@ export class ToolRuntime {
290
290
  raw = await tool.execute(args, {
291
291
  signal,
292
292
  callId: opts?.callId,
293
+ onOutput: opts?.onOutput,
293
294
  allowedToolNames: opts?.allowedToolNames,
294
295
  delegation: opts?.delegation,
295
296
  readDedup: opts?.readDedup,
@@ -1,5 +1,10 @@
1
- import Ajv from 'ajv';
2
- import Ajv2020 from 'ajv/dist/2020.js';
1
+ import { createRequire } from 'node:module';
2
+ // ajv 懒加载:它本身不算巨包,但顶层就构造 draft7/draft2020 两个实例会把包求值提前到启动路径
3
+ // (tool-runtime → validation)。首帧 UI 不做任何参数校验,故推迟到首次 compile 时才加载。
4
+ const require = createRequire(import.meta.url);
5
+ let draft7 = null;
6
+ let draft2020 = null;
7
+ let ajvPromise = null;
3
8
  const options = {
4
9
  allErrors: true,
5
10
  strict: false,
@@ -12,16 +17,32 @@ const options = {
12
17
  validateFormats: false,
13
18
  allowUnionTypes: true,
14
19
  };
15
- const draft7 = new Ajv(options);
16
- const draft2020 = new Ajv2020(options);
20
+ /** 首次真实工具参数校验时才加载 ajv 并构造两个 draft 实例。 */
21
+ function ensureAjv() {
22
+ if (draft7 && draft2020)
23
+ return { draft7, draft2020 };
24
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
25
+ const Ajv = require('ajv');
26
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
27
+ const Ajv2020 = require('ajv/dist/2020.js');
28
+ draft7 = new Ajv(options);
29
+ draft2020 = new Ajv2020(options);
30
+ return { draft7, draft2020 };
31
+ }
32
+ /** 首帧后后台预热;与同步 require 共享模块缓存。失败静默。 */
33
+ export function prewarmAjv() {
34
+ if (!ajvPromise) {
35
+ ajvPromise = Promise.all([import('ajv'), import('ajv/dist/2020.js')]).catch(() => undefined);
36
+ }
37
+ return ajvPromise;
38
+ }
17
39
  const cache = new WeakMap();
18
40
  function compile(schema) {
19
41
  const cached = cache.get(schema);
20
42
  if (cached)
21
43
  return cached;
22
- const preferred = typeof schema.$schema === 'string' && schema.$schema.includes('2020-12')
23
- ? [draft2020, draft7]
24
- : [draft7, draft2020];
44
+ const { draft7: d7, draft2020: d2020 } = ensureAjv();
45
+ const preferred = typeof schema.$schema === 'string' && schema.$schema.includes('2020-12') ? [d2020, d7] : [d7, d2020];
25
46
  let lastError;
26
47
  for (const ajv of preferred) {
27
48
  try {
package/dist/ui/batch.js CHANGED
@@ -38,6 +38,8 @@ const callToBatch = new Map();
38
38
  const groupChildIndexByCall = new Map();
39
39
  /** 展开时完整输出的最大行数;超出截断,避免巨型输出撑爆 viewport。 */
40
40
  const MAX_EXPAND_LINES = 200;
41
+ const MAX_LIVE_OUTPUT_CHARS = 256 * 1024;
42
+ const LIVE_OUTPUT_REFRESH_MS = 80;
41
43
  /** 自洽行允许的最大显示宽(= 终端 cols)。buffer 行超 cols 会被终端 auto-wrap,
42
44
  * 物理行与缓冲行失配 → repaintViewport 的 CUP 寻址全错(屏幕错乱)。 */
43
45
  let maxCols = 200;
@@ -66,6 +68,12 @@ export function isMutationToolName(name) {
66
68
  }
67
69
  /** 通知 buffer 整体清空(clearContent / exitAltScreen / 新一轮 turn)——本模块状态同步归零。 */
68
70
  export function reset() {
71
+ for (const record of batches.values()) {
72
+ for (const entry of record.entries) {
73
+ if (entry.liveRefreshTimer)
74
+ clearTimeout(entry.liveRefreshTimer);
75
+ }
76
+ }
69
77
  batches.clear();
70
78
  absLineToBatchId.clear();
71
79
  absLineToEntry.clear();
@@ -154,7 +162,61 @@ export function isBatchComplete(id) {
154
162
  /** 记一条工具结果(diff 块或单行 preview);agent 在 onToolResult 时调,匹配最后一条未填的 entry。
155
163
  * fullOutput:工具原始完整输出(纯文本),展开时显示;mutation 工具的 diff 块已自含无需传。
156
164
  * callId:组容器批用,直接把结果填到对应 entry(避免多个 sub-agent 同名时反向匹配错位)。 */
157
- export function recordResult(id, name, resultSummary, diffBlock, fullOutput, failed = false, callId) {
165
+ function refreshEntryDetails(b, entryIndex, layout) {
166
+ if (!expandedBatches.has(b.id) || !b.expandedEntries.has(entryIndex))
167
+ return;
168
+ const headerIdx = [...absLineToEntry].find(([, target]) => target.batchId === b.id && target.entryIndex === entryIndex)?.[0];
169
+ if (headerIdx == null)
170
+ return;
171
+ const entry = b.entries[entryIndex];
172
+ const oldCount = entry.renderedDetailCount ?? 0;
173
+ const lines = buildEntryDetailLines(entry, entryDetailIndent(b.indent ?? ''));
174
+ const replacement = lines.slice(0, MAX_EXPAND_LINES);
175
+ if (oldCount > 0)
176
+ layout.contentDeleteFrom(headerIdx + 1, oldCount);
177
+ if (replacement.length > 0)
178
+ layout.contentInsertAfter(headerIdx, replacement, false);
179
+ entry.renderedDetailCount = replacement.length;
180
+ entry.renderedDigest = entryLineDigest(entry);
181
+ layout.contentReplaceLine(headerIdx, buildEntryLine(b, entryIndex, b.indent ?? ''));
182
+ }
183
+ export function recordOutput(callId, chunk, layout) {
184
+ const batchId = callToBatch.get(callId);
185
+ const b = batchId ? batches.get(batchId) : undefined;
186
+ const entryIndex = groupChildIndexByCall.get(callId);
187
+ if (!b || entryIndex == null || b.entries[entryIndex]?.name !== 'run_command')
188
+ return;
189
+ const entry = b.entries[entryIndex];
190
+ entry.liveOutput = `${entry.liveOutput ?? ''}${chunk}`;
191
+ if (entry.liveOutput.length > MAX_LIVE_OUTPUT_CHARS) {
192
+ entry.liveOutput = entry.liveOutput.slice(-MAX_LIVE_OUTPUT_CHARS);
193
+ }
194
+ if (!expandedBatches.has(b.id))
195
+ return;
196
+ if (!b.expandedEntries.has(entryIndex)) {
197
+ const headerIdx = [...absLineToEntry].find(([, target]) => target.batchId === b.id && target.entryIndex === entryIndex)?.[0];
198
+ if (headerIdx != null) {
199
+ entry.renderedDigest = entryLineDigest(entry);
200
+ layout.contentReplaceLine(headerIdx, buildEntryLine(b, entryIndex, b.indent ?? ''));
201
+ }
202
+ return;
203
+ }
204
+ if (entry.liveRefreshTimer)
205
+ return;
206
+ const refresh = () => {
207
+ entry.liveRefreshTimer = undefined;
208
+ entry.liveRefreshAt = Date.now();
209
+ refreshEntryDetails(b, entryIndex, layout);
210
+ };
211
+ const wait = Math.max(0, LIVE_OUTPUT_REFRESH_MS - (Date.now() - (entry.liveRefreshAt ?? 0)));
212
+ if (wait === 0)
213
+ refresh();
214
+ else {
215
+ entry.liveRefreshTimer = setTimeout(refresh, wait);
216
+ entry.liveRefreshTimer.unref();
217
+ }
218
+ }
219
+ export function recordResult(id, name, resultSummary, diffBlock, fullOutput, failed = false, callId, layout) {
158
220
  const b = batches.get(id);
159
221
  if (!b || b.entries.length === 0)
160
222
  return;
@@ -168,8 +230,14 @@ export function recordResult(id, name, resultSummary, diffBlock, fullOutput, fai
168
230
  e.fullOutput = fullOutput;
169
231
  e.failed = failed;
170
232
  e.done = true;
233
+ if (e.liveRefreshTimer)
234
+ clearTimeout(e.liveRefreshTimer);
235
+ e.liveRefreshTimer = undefined;
236
+ e.liveOutput = undefined;
171
237
  if (b.entries.every(isEntryDone))
172
238
  b.finishedAt = Date.now();
239
+ if (layout)
240
+ refreshEntryDetails(b, idx, layout);
173
241
  return;
174
242
  }
175
243
  }
@@ -181,8 +249,14 @@ export function recordResult(id, name, resultSummary, diffBlock, fullOutput, fai
181
249
  b.entries[i].fullOutput = fullOutput;
182
250
  b.entries[i].failed = failed;
183
251
  b.entries[i].done = true;
252
+ if (b.entries[i].liveRefreshTimer)
253
+ clearTimeout(b.entries[i].liveRefreshTimer);
254
+ b.entries[i].liveRefreshTimer = undefined;
255
+ b.entries[i].liveOutput = undefined;
184
256
  if (b.entries.every(isEntryDone))
185
257
  b.finishedAt = Date.now();
258
+ if (layout)
259
+ refreshEntryDetails(b, i, layout);
186
260
  return;
187
261
  }
188
262
  }
@@ -194,8 +268,14 @@ export function recordResult(id, name, resultSummary, diffBlock, fullOutput, fai
194
268
  last.fullOutput = fullOutput;
195
269
  last.failed = failed;
196
270
  last.done = true;
271
+ if (last.liveRefreshTimer)
272
+ clearTimeout(last.liveRefreshTimer);
273
+ last.liveRefreshTimer = undefined;
274
+ last.liveOutput = undefined;
197
275
  if (b.entries.every(isEntryDone))
198
276
  b.finishedAt = Date.now();
277
+ if (layout)
278
+ refreshEntryDetails(b, b.entries.length - 1, layout);
199
279
  }
200
280
  }
201
281
  // ── 摘要行文本生成 ──
@@ -465,11 +545,10 @@ function buildEntryDetailLines(e, indent = DETAIL_INDENT) {
465
545
  lines.push(sanitizeRow(prefixed));
466
546
  }
467
547
  }
468
- else if (e.fullOutput) {
469
- // 完整工具输出(纯文本):按行展开,每行缩进 + dim 样式;长输出截断到 MAX_EXPAND_LINES 行。
470
- // 每行经 sanitizeRow 钳宽:fullOutput 可能含 grep 扫二进制(db/压缩文件)得到的
471
- // 超长行 + 控制字符,不钳会让终端 auto-wrap 打乱屏位。
472
- const rawLines = e.fullOutput.split('\n');
548
+ else if (e.liveOutput || e.fullOutput) {
549
+ // 实时输出优先展示;结束后由 recordResult 替换成最终有界工具结果。
550
+ // 每行 sanitizeRow 会清除控制字符并钳宽,避免 ANSI/终端控制序列污染 TUI。
551
+ const rawLines = (e.liveOutput || e.fullOutput || '').split(/\r\n|\n|\r/);
473
552
  const truncated = rawLines.length > MAX_EXPAND_LINES;
474
553
  const displayLines = truncated ? rawLines.slice(0, MAX_EXPAND_LINES) : rawLines;
475
554
  for (const line of displayLines) {
@@ -493,7 +572,7 @@ function buildEntryDetailLines(e, indent = DETAIL_INDENT) {
493
572
  * 只取有无不取内容:完整输出可能上万字符,拼进指纹是浪费,且其内容不影响 entry 行渲染。
494
573
  */
495
574
  function entryLineDigest(e) {
496
- return `${e.resultSummary}\u0001${e.diffBlock ? '1' : '0'}\u0001${e.fullOutput ? '1' : '0'}\u0001${e.failed ? '1' : '0'}`;
575
+ return `${e.resultSummary}\u0001${e.diffBlock ? '1' : '0'}\u0001${e.fullOutput || e.liveOutput ? '1' : '0'}\u0001${e.failed ? '1' : '0'}`;
497
576
  }
498
577
  /**
499
578
  * 第二层展开三角:折叠 ▸ / 展开 ▾。替代原先的 ├─ / └─。
@@ -537,7 +616,7 @@ const DETAIL_RESULT_MARK = '· ';
537
616
  /** entry 是否有可展开的详情行。须与 buildEntryDetailLines 的分支一致,
538
617
  * 否则会画出点了没反应的三角(toggleEntry 对空 details 直接 return)。 */
539
618
  function hasEntryDetail(e) {
540
- return Boolean(e.diffBlock || e.fullOutput || e.resultSummary);
619
+ return Boolean(e.diffBlock || e.fullOutput || e.liveOutput || e.resultSummary);
541
620
  }
542
621
  /** entry 行的展开三角(含尾随空格与配色)。折叠 dim、展开 accent,一眼区分当前态。 */
543
622
  function entryCaret(e, expanded) {
@@ -938,9 +1017,11 @@ export function toggleEntry(batchId, entryIndex, layout) {
938
1017
  layout.contentReplaceLine(headerIdx, buildEntryLine(b, entryIndex, b.indent ?? ''));
939
1018
  if (wasExpanded) {
940
1019
  layout.contentDeleteFrom(headerIdx + 1, details.length);
1020
+ b.entries[entryIndex].renderedDetailCount = 0;
941
1021
  }
942
1022
  else {
943
1023
  layout.contentInsertAfter(headerIdx, details);
1024
+ b.entries[entryIndex].renderedDetailCount = Math.min(details.length, MAX_EXPAND_LINES);
944
1025
  }
945
1026
  }
946
1027
  /**
@@ -0,0 +1,27 @@
1
+ // cli-highlight(highlight.js)懒加载:它含近四百个语言文件,冷态首次加载是百毫秒级,
2
+ // 而首帧 banner 不含代码块、不需要高亮。渲染函数是同步的,故用 createRequire 同步懒取,
3
+ // 配合 src/startup/prewarm 在首帧后后台预热:用户输入含代码的首个响应到达前,包通常已在内存。
4
+ // 预热未及时完成时,首次代码块渲染会承担一次(热缓存下很短的)加载成本,功能不变。
5
+ import { createRequire } from 'node:module';
6
+ const require = createRequire(import.meta.url);
7
+ let mod = null;
8
+ let requirePromise = null;
9
+ /** 同步取 cli-highlight(渲染路径用);首次调用才真正加载。 */
10
+ export function getCliHighlight() {
11
+ if (!mod) {
12
+ mod = require('cli-highlight');
13
+ }
14
+ return mod;
15
+ }
16
+ /** 后台预热:动态 import 与同步 require 共享模块缓存,预热后 getCliHighlight 零成本。 */
17
+ export function prewarmCliHighlight() {
18
+ if (!requirePromise) {
19
+ requirePromise = import('cli-highlight')
20
+ .then((m) => {
21
+ mod = m;
22
+ return m;
23
+ })
24
+ .catch(() => undefined);
25
+ }
26
+ return requirePromise;
27
+ }
package/dist/ui/diff.js CHANGED
@@ -1,6 +1,8 @@
1
- import { highlight, plain } from 'cli-highlight';
1
+ import { getCliHighlight } from './cli-highlight-lazy.js';
2
2
  import { ui } from './theme.js';
3
3
  import { charWidth, displayWidth } from './render.js';
4
+ /** cli-highlight 的 plain 主题就是恒等函数(已实测),这里本地等价实现,避免顶层加载整包。 */
5
+ const plain = (codePart) => codePart;
4
6
  const MAX_FULL_DIFF_LINES = 800; // 任一边超此行数则跳过全量 LCS(避免大文件 O(n·m) 高开销)
5
7
  const MAX_BODY_LINES = 24; // 正文最多展示行数(超出尾注「还有 N 行未显示」)
6
8
  const MAX_LINE_DISPLAY = 120; // 单行截断显示宽度(CJK / 全角按 2 计)
@@ -115,7 +117,7 @@ function highlightLine(text, lang) {
115
117
  if (!lang || text === '')
116
118
  return text;
117
119
  try {
118
- return highlight(text, { language: lang, theme: THEME });
120
+ return getCliHighlight().highlight(text, { language: lang, theme: THEME });
119
121
  }
120
122
  catch {
121
123
  return text; // 语言未注册 / 解析失败:退化为纯文本
@@ -11,7 +11,7 @@
11
11
  // 硬约束:每行 ansiDisplayWidth ≤ cols(layout.repaintViewport 直出行,超宽会让终端 auto-wrap
12
12
  // 错位;若上层 <Text wrap=truncate> 补 … U+2026 在中文终端算 2 宽会顶屏)。所有路径软折行 +
13
13
  // clipAnsiLine 兜底,绝不溢出。颜色一律嵌入式 ANSI(ui.*)写进字符串。
14
- import { highlight, supportsLanguage } from 'cli-highlight';
14
+ import { getCliHighlight } from './cli-highlight-lazy.js';
15
15
  import { ui, getThemeVersion } from './theme.js';
16
16
  import { charWidth, displayWidth, ansiDisplayWidth, stripAnsi } from './render.js';
17
17
  const RESET = '\x1b[0m';
@@ -296,6 +296,7 @@ function highlightCodeBlock(code, lang) {
296
296
  if (!lang)
297
297
  return code;
298
298
  try {
299
+ const { highlight, supportsLanguage } = getCliHighlight();
299
300
  if (supportsLanguage(lang)) {
300
301
  return highlight(code, { language: lang, ignoreIllegals: true });
301
302
  }
@@ -725,6 +725,12 @@ export async function promptWithSlashMenu(opts) {
725
725
  selected = (selected - 1 + filtered.length) % filtered.length;
726
726
  redraw();
727
727
  }
728
+ else if (buildDisplay().lines.length <= 1) {
729
+ // 单行输入 + 菜单关闭:裸 ↑/↓ 视作鼠标滚轮。Trae/VSCode 内置终端(xterm)在鼠标
730
+ // 报表未被接收时会把滚轮降级成 ↑/↓ 方向键;这里转成内容区滚动,与运行态滚轮一致,
731
+ // 否则空/单行输入框里滚轮"毫无反应"。多行输入时仍跳到全文首/尾(保留编辑语义)。
732
+ layout.scrollWheel(1);
733
+ }
728
734
  else {
729
735
  // ↑ 始终跳到全文最前(含跨只读粘贴块,落到首个可编辑段开头)
730
736
  const p = firstEditableStart();
@@ -738,6 +744,9 @@ export async function promptWithSlashMenu(opts) {
738
744
  selected = (selected + 1) % filtered.length;
739
745
  redraw();
740
746
  }
747
+ else if (buildDisplay().lines.length <= 1) {
748
+ layout.scrollWheel(-1);
749
+ }
741
750
  else {
742
751
  // ↓ 始终跳到全文最后(含跨只读粘贴块,落到末个可编辑段末尾)
743
752
  const p = lastEditableEnd();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.7.8",
3
+ "version": "1.7.10",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 27 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {