mocode-ai 1.6.10 → 1.7.1

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/README.md CHANGED
@@ -49,6 +49,8 @@ MoCode is organized as a layered runtime: the terminal experience drives an auto
49
49
 
50
50
  Implementation entry points: [Agent core](src/agent/core.ts), [tool dispatch](src/agent/stages/tool-dispatcher.ts), and [tool execution](src/tools/tool-runtime.ts).
51
51
 
52
+ <p align="center"><img src="./assets/architecture/system-overview.svg" alt="MoCode system overview: experience layer, autonomous runtime, capability plane, persistent intelligence" width="100%"></p>
53
+
52
54
  ### Autonomous execution loop
53
55
 
54
56
  Each model response is one step in a closed loop. Tool calls are classified by declared capabilities, safe reads can run in parallel, and writes acquire canonical resource locks. Tool evidence returns to history unchanged apart from a hard per-result safety cap. When the agent has no more tools to call, its response completes immediately; the framework does not run hidden validation or force another model turn.
package/README.zh-CN.md CHANGED
@@ -40,6 +40,8 @@ MoCode 是一个分层的自治运行时:终端交互层驱动 Agent 内核,
40
40
 
41
41
  实现入口:[Agent 内核](src/agent/core.ts)、[工具派发](src/agent/stages/tool-dispatcher.ts)、[工具执行](src/tools/tool-runtime.ts)。
42
42
 
43
+ <p align="center"><img src="./assets/architecture/system-overview-zh-CN.svg" alt="MoCode 系统全景:交互体验层、自治执行内核、受控能力平面、持久化认知层" width="100%"></p>
44
+
43
45
  ### 自治执行循环
44
46
 
45
47
  每次模型响应都是闭环中的一步。工具调用按能力声明分类,安全读取可以并行,写操作获取规范化资源锁。工具证据除单条 hard cap 外原样进入 history,用户与模型看到同一事实。agent 没有更多工具调用时立即完成;框架不会暗中运行验证,也不会强迫追加一轮模型调用。
@@ -398,6 +398,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
398
398
  callId: calls[index].id,
399
399
  allowedToolNames: currentAllowedToolNames(),
400
400
  delegation: delegationForOrchestrator(),
401
+ readDedup,
401
402
  });
402
403
  }
403
404
  }
@@ -559,6 +560,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
559
560
  callId: tc.id,
560
561
  allowedToolNames: currentAllowedToolNames(),
561
562
  delegation: delegationForOrchestrator(),
563
+ readDedup,
562
564
  }));
563
565
  // 完成即发:usage/onToolOutcome/trace 在各 outcome 落地瞬间发出(1s 的
564
566
  // grep 不再被 60s 的 web_fetch 卡住可见性);history 回灌严格按原序。
@@ -655,6 +657,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
655
657
  callId: entry.tc.id,
656
658
  allowedToolNames: currentAllowedToolNames(),
657
659
  delegation: delegationForOrchestrator(),
660
+ readDedup,
658
661
  ...(hint ? { argumentErrorHint: hint } : {}),
659
662
  onLockAcquired: (lockedArgs) => {
660
663
  entry.diff = readDiffContext(entry.tc, lockedArgs, ctx.jailResolve);
@@ -742,6 +745,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
742
745
  callId: entry.tc.id,
743
746
  allowedToolNames: currentAllowedToolNames(),
744
747
  delegation: delegationForOrchestrator(),
748
+ readDedup,
745
749
  ...(hint ? { argumentErrorHint: hint } : {}),
746
750
  onLockAcquired: (lockedArgs) => {
747
751
  entry.diff = readDiffContext(entry.tc, lockedArgs, ctx.jailResolve);
@@ -836,6 +840,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
836
840
  callId: tc.id,
837
841
  allowedToolNames: currentAllowedToolNames(),
838
842
  delegation: delegationForOrchestrator(),
843
+ readDedup,
839
844
  ...(serialHint ? { argumentErrorHint: serialHint } : {}),
840
845
  onLockAcquired: (lockedArgs) => {
841
846
  if (mutationParsed)
package/dist/llm/index.js CHANGED
@@ -883,8 +883,35 @@ export function contentTokens(content) {
883
883
  }
884
884
  return estimateTokens(contentToText(content));
885
885
  }
886
- /** 估算单条消息的 token 数:结构开销 + content + tool_calls 参数。 */
886
+ /** 单条消息的 token 估算缓存:命中键 = 该消息的「内容签名」(见 messageSignature)。 */
887
+ const messageTokensCache = new WeakMap();
888
+ /**
889
+ * 消息内容签名:content 引用 + role + 有无 tool_calls。
890
+ *
891
+ * **为什么签名里用 content 的「长度」而不是内容本身**:签名要在每次估算时算,必须是 O(1)。
892
+ * 用 `typeof content === 'string' ? content.length : -1` 兼顾 O(1) 与可判定性 ——
893
+ * string 不可变,原地改写(`msg.content = ...`)必然换成另一个字符串对象、长度通常也变;
894
+ * 长度相同而内容变了的情况(如 microcompact 截到同样长度)极少,且那种路径
895
+ * (compact / age-aware)本身会先 markContextChanged 走全量重估(见 scheduler)。
896
+ *
897
+ * **为什么不直接用对象身份当键**(即 WeakMap<ChatMessage, number>):有 4 处代码会
898
+ * **原地改写** message.content —— age-aware.ts:66、artifacts.ts:269(标 stale)、
899
+ * compact.ts:590/597(微压缩)。对象身份不变但内容已变,身份缓存会返回旧值 →
900
+ * token 低估 → 80% 压力线不触发 → prompt 实际已超窗口(llm/index.ts:1042-1044 注释
901
+ * 描述过这个失败模式:「模型突然抽风」)。签名方案让这4 处自动安全,无需挂失效钩子。
902
+ */
903
+ function messageSignature(m) {
904
+ const c = m.content;
905
+ const tcs = m.tool_calls;
906
+ return `${m.role ?? ''}|${typeof c === 'string' ? c.length : -1}|${tcs ? 1 : 0}`;
907
+ }
908
+ /** 估算单条消息的 token 数:结构开销 + content + tool_calls 参数。内容签名命中则复用上次结果。 */
887
909
  export function messageTokens(m) {
910
+ const key = m;
911
+ const sig = messageSignature(m);
912
+ const hit = messageTokensCache.get(key);
913
+ if (hit !== undefined && hit.sig === sig)
914
+ return hit.tokens;
888
915
  const role = m.role;
889
916
  let structural = 4; // {role}\n{content}\n 框架基线
890
917
  if (role === 'system')
@@ -897,7 +924,9 @@ export function messageTokens(m) {
897
924
  for (const tc of tcs)
898
925
  body += estimateTokens(tc?.function?.arguments ?? '');
899
926
  }
900
- return structural + body;
927
+ const tokens = structural + body;
928
+ messageTokensCache.set(key, { sig, tokens });
929
+ return tokens;
901
930
  }
902
931
  /** 估算整段 messages 的 token 数(不含工具 schema,含 priming 常数)。 */
903
932
  export function estimateMessagesTokens(messages) {
@@ -54,6 +54,54 @@ const SCAN_CONCURRENCY = 16;
54
54
  const YIELD_EVERY = 64;
55
55
  const YIELD_BYTES = 1024 * 1024;
56
56
  const FRESH_WINDOW_MS = 2000;
57
+ /**
58
+ * contentCache 的常驻字节上限(base64 后的长度和);0 = 不限。
59
+ *
60
+ * contentCache 是「上次扫描读到的内容」的跨扫描复用:命中则省一次 readFile,
61
+ * 这就是第 2 次及以后的 workspace 扫描只要 ~35ms 而首次要 500ms+ 的原因
62
+ * (实测 2026-10-05,2000 entries / 42.7MB base64)。它原先只增不减(唯一清理点是
63
+ * resetState),即一次扫描就把整个工作区文本永久常留内存。
64
+ *
65
+ * **默认保持不限(0)**:实测把上限压到 16MB 会把命中率打崩 —— 缓存从 664 条掉到 54 条,
66
+ * 扫描耗时从 ~67ms 反弹到 ~1000ms(涨 15倍)。原因是遍历顺序下累积的大文件会挤掉后续
67
+ * 所有条目,淘汰机制天然对「后访问的小文件」不友好。42.7MB 换每条命令 67ms 是划算的,
68
+ * 不值得为省内存牺牲 15 倍扫描速度。
69
+ *
70
+ * 保留这个旋门是为了「大工作区内存吃紧」的场景(比如 node_modules 被误纳入扫描),
71
+ * 可通过 MOCODE_ROLLBACK_CACHE_MB 收紧;不设则沿用无上限的既有行为。
72
+ */
73
+ const CONTENT_CACHE_MAX_BYTES = (() => {
74
+ const mb = Number(process.env.MOCODE_ROLLBACK_CACHE_MB);
75
+ return Number.isFinite(mb) && mb > 0 ? Math.floor(mb) * 1024 * 1024 : 0;
76
+ })();
77
+ /**
78
+ * 把扫描产出的缓存裁到 CONTENT_CACHE_MAX_BYTES 以内,按插入序淘汰最旧条目。
79
+ *
80
+ * 仅在 CONTENT_CACHE_MAX_BYTES > 0(即用户显式设了 MOCODE_ROLLBACK_CACHE_MB)时生效;
81
+ * 默认无上限,保持既有行为。淘汰只影响性能,不影响正确性:被淘汰的条目下次扫描时
82
+ * stamp 不匹配 → 重新 readFile 而已(snapshot 记录的是**本次扫描当场读到**的内容,
83
+ * 见 scanWorkspace 的 `cached.stamp === stamp` 校验)。
84
+ */
85
+ function trimContentCache(cache) {
86
+ if (CONTENT_CACHE_MAX_BYTES <= 0)
87
+ return cache;
88
+ let total = 0;
89
+ for (const entry of cache.values())
90
+ total += entry.data.length;
91
+ if (total <= CONTENT_CACHE_MAX_BYTES)
92
+ return cache;
93
+ // Map 保插入序 → 最旧的在最前。逐个删到回到预算内,单趟 O(n) 足够。
94
+ for (const key of cache.keys()) {
95
+ if (total <= CONTENT_CACHE_MAX_BYTES)
96
+ break;
97
+ const entry = cache.get(key);
98
+ if (!entry)
99
+ continue;
100
+ total -= entry.data.length;
101
+ cache.delete(key);
102
+ }
103
+ return cache;
104
+ }
57
105
  /**
58
106
  * 一个完整、实例隔离的回滚事务存储。每个 runtime 可持有独立实例,使轮次、序号、
59
107
  * 快照、mutation generation 和扫描缓存互不干扰。
@@ -336,7 +384,7 @@ export class RollbackStore {
336
384
  }
337
385
  };
338
386
  await Promise.all(Array.from({ length: Math.min(SCAN_CONCURRENCY, Math.max(1, paths.length)) }, worker));
339
- this.contentCache = nextCache;
387
+ this.contentCache = trimContentCache(nextCache);
340
388
  return entries;
341
389
  }
342
390
  async beginWorkspaceMutation() {
@@ -66,6 +66,20 @@ export class SessionStore {
66
66
  currentSessionIdProvider;
67
67
  currentSessionIdSetter;
68
68
  currentSessionId;
69
+ /**
70
+ * 已确保存在的会话目录缓存(2026-10-05)。
71
+ *
72
+ * `mkdirSync(recursive)` 单次 0.20ms —— 比 existsSync+statSync(合计 0.009ms)贵 20 倍,
73
+ * 因为 recursive 模式要逐级向下 stat 每层路径。而 appendTrace 每 step 调 14 次
74
+ * (step_start/end + model_start/end + 每工具 2 个),每轮 20 step ≈ 280 次全花在
75
+ * 「确认目录已存在」上,一轮白烧 56ms。
76
+ *
77
+ * 缓存的是「已 mkdir 成功」这件事,不是路径存在性。**代价**:外部删掉目录后缓存会失真。
78
+ * 兜底是三个写盘方法统一的「失败 → invalidateEnsuredDir → 重试一次」模式
79
+ * (见 appendTrace / appendUsage / save),既保住性能又不丢数据。
80
+ * 护栏见 tests/session-dir-cache.test.ts 的「外部删除后自愈」三例。
81
+ */
82
+ ensuredDirs = new Set();
69
83
  constructor(options = {}) {
70
84
  if (typeof options.workspaceRoot === 'function') {
71
85
  const provider = options.workspaceRoot;
@@ -92,9 +106,26 @@ export class SessionStore {
92
106
  }
93
107
  sessionDir() {
94
108
  const root = this.sessionsRoot;
95
- mkdirSync(root, { recursive: true });
109
+ this.ensureDir(root);
96
110
  return root;
97
111
  }
112
+ /**
113
+ * 幂等建目录:同一路径只在首次真正 mkdir(见 ensuredDirs 的成本说明)。
114
+ *
115
+ * 缓存意味着「外部删掉目录后不自愈」,故所有写盘方法都用
116
+ * {@link invalidateEnsuredDir} + 重试一次兜住(retention 归档后 rmSync、
117
+ * 用户手工清理等)。单靠这里的 Set 不会自愈,必须配对那个重试。
118
+ */
119
+ ensureDir(dir) {
120
+ if (this.ensuredDirs.has(dir))
121
+ return;
122
+ mkdirSync(dir, { recursive: true });
123
+ this.ensuredDirs.add(dir);
124
+ }
125
+ /** 让 ensureDir 下次真正 mkdir(用于 append 失败后的自愈重试)。 */
126
+ invalidateEnsuredDir(dir) {
127
+ this.ensuredDirs.delete(dir);
128
+ }
98
129
  createId() {
99
130
  return createTimestampId();
100
131
  }
@@ -119,22 +150,38 @@ export class SessionStore {
119
150
  * 然后清空当前文件。纯诊断数据,任何失败静默降级为普通追加。
120
151
  */
121
152
  appendTrace(id, value) {
122
- try {
123
- const dir = path.join(this.sessionsRoot, id);
124
- mkdirSync(dir, { recursive: true });
125
- const line = `${JSON.stringify(value)}\n`;
126
- const tracePath = path.join(dir, 'trace.jsonl');
127
- // env 覆盖仅供测试:MOCODE_MAX_TRACE_BYTES=200 可立刻触发轮转。
128
- const envMax = Number(process.env.MOCODE_MAX_TRACE_BYTES);
129
- const maxTraceBytes = Number.isFinite(envMax) && envMax >= 0 ? envMax : MAX_TRACE_BYTES;
130
- const size = existsSync(tracePath) ? statSync(tracePath).size : 0;
131
- if (size >= maxTraceBytes) {
132
- this.rotateTrace(dir, tracePath);
153
+ const dir = path.join(this.sessionsRoot, id);
154
+ const line = `${JSON.stringify(value)}\n`;
155
+ const tracePath = path.join(dir, 'trace.jsonl');
156
+ // env 覆盖仅供测试:MOCODE_MAX_TRACE_BYTES=200 可立刻触发轮转。
157
+ const envMax = Number(process.env.MOCODE_MAX_TRACE_BYTES);
158
+ const maxTraceBytes = Number.isFinite(envMax) && envMax >= 0 ? envMax : MAX_TRACE_BYTES;
159
+ // 目录缓存可能因外部删除而失真(如 retention 归档后 rmSync):首次失败清缓存重试一次。
160
+ for (let attempt = 0; attempt < 2; attempt++) {
161
+ try {
162
+ this.ensureDir(dir); // 缓存:每 step 14 次调用,省 14×0.20ms
163
+ // statSync 一次即可拿到「不存在 / 大小」两种答案,省掉 existsSync 那一跳
164
+ // (两者合计仅 0.009ms,但顺手消掉一次 TOCTOU 窗口)。
165
+ let size = 0;
166
+ try {
167
+ size = statSync(tracePath).size;
168
+ }
169
+ catch {
170
+ size = 0;
171
+ }
172
+ if (size >= maxTraceBytes) {
173
+ this.rotateTrace(dir, tracePath);
174
+ }
175
+ writeFileSync(tracePath, line, { encoding: 'utf8', flag: 'a' });
176
+ return;
177
+ }
178
+ catch {
179
+ this.invalidateEnsuredDir(dir);
180
+ if (attempt === 1) {
181
+ // Observability is best-effort and cannot block coding work.
182
+ return;
183
+ }
133
184
  }
134
- writeFileSync(tracePath, line, { encoding: 'utf8', flag: 'a' });
135
- }
136
- catch {
137
- // Observability is best-effort and cannot block coding work.
138
185
  }
139
186
  }
140
187
  rotateTrace(dir, tracePath) {
@@ -174,13 +221,23 @@ export class SessionStore {
174
221
  * 与 appendTrace 同策略:纯诊断,失败静默,不做轮转(长会话的量级远小于 trace)。
175
222
  */
176
223
  appendUsage(id, value) {
177
- try {
178
- const dir = path.join(this.sessionsRoot, id);
179
- mkdirSync(dir, { recursive: true });
180
- writeFileSync(path.join(dir, 'usage.jsonl'), `${JSON.stringify(value)}\n`, { encoding: 'utf8', flag: 'a' });
181
- }
182
- catch {
183
- // Metrics must never block coding work.
224
+ const dir = path.join(this.sessionsRoot, id);
225
+ const line = `${JSON.stringify(value)}\n`;
226
+ const file = path.join(dir, 'usage.jsonl');
227
+ // 同 appendTrace:目录缓存失真时清缓存重试一次。
228
+ for (let attempt = 0; attempt < 2; attempt++) {
229
+ try {
230
+ this.ensureDir(dir); // 同appendTrace:每 step 1 次,省 0.20ms
231
+ writeFileSync(file, line, { encoding: 'utf8', flag: 'a' });
232
+ return;
233
+ }
234
+ catch {
235
+ this.invalidateEnsuredDir(dir);
236
+ if (attempt === 1) {
237
+ // Metrics must never block coding work.
238
+ return;
239
+ }
240
+ }
184
241
  }
185
242
  }
186
243
  /** 读取 <id>/usage.jsonl 全部记录;文件不存在或损坏行跳过。 */
@@ -202,6 +259,13 @@ export class SessionStore {
202
259
  }
203
260
  return out;
204
261
  }
262
+ /**
263
+ * 全量落盘会话(history + queryHistory + lastToolGroups),原子写(先 tmp 再 rename)。
264
+ *
265
+ * 与 appendTrace/appendUsage 的差别:**失败会抛给调用方**——这是用户数据,
266
+ * 静默丢失不可接受(那两个是诊断数据,静默降级是对的)。目录缓存失真时会
267
+ * 清缓存重试一次,仍失败则照旧抛出。
268
+ */
205
269
  save(history, id, queryHistory = [], lastToolGroups = []) {
206
270
  const meta = {
207
271
  id,
@@ -216,21 +280,33 @@ export class SessionStore {
216
280
  if (history.length <= 1 && queryHistory.length === 0 && !existsSync(currentPath) && !existsSync(legacyPath)) {
217
281
  return meta;
218
282
  }
219
- mkdirSync(path.join(this.sessionsRoot, id), { recursive: true });
283
+ const dir = path.join(this.sessionsRoot, id);
284
+ const tmpPath = path.join(dir, 'session.json.tmp');
220
285
  const record = {
221
286
  ...meta,
222
287
  history,
223
288
  queryHistory: [...queryHistory],
224
289
  lastToolGroups: [...lastToolGroups],
225
290
  };
226
- // 原子落盘:先写同目录 tmp 再 rename——全量重写(长会话可达数 MB)中途崩溃/断电
227
- // 不会留下半个 JSON 把整个会话写坏(对齐 memory/store.ts writeAtomic)。
228
- const tmpPath = path.join(this.sessionsRoot, id, 'session.json.tmp');
229
- writeFileSync(tmpPath, JSON.stringify(record), 'utf8');
230
- renameSync(tmpPath, currentPath);
231
- if (existsSync(legacyPath))
232
- unlinkSync(legacyPath);
233
- return meta;
291
+ // 目录缓存失真时清缓存重试一次(与 appendTrace 同理);仍失败则照旧抛给调用方
292
+ // —— save 是用户数据落盘,失败必须可见(appendTrace/appendUsage 是诊断数据,静默)。
293
+ for (let attempt = 0;; attempt++) {
294
+ try {
295
+ this.ensureDir(dir);
296
+ // 原子落盘:先写同目录 tmp 再 rename——全量重写(长会话可达数 MB)中途崩溃/断电
297
+ // 不会留下半个 JSON 把整个会话写坏(对齐 memory/store.ts writeAtomic)。
298
+ writeFileSync(tmpPath, JSON.stringify(record), 'utf8');
299
+ renameSync(tmpPath, currentPath);
300
+ if (existsSync(legacyPath))
301
+ unlinkSync(legacyPath);
302
+ return meta;
303
+ }
304
+ catch (error) {
305
+ this.invalidateEnsuredDir(dir);
306
+ if (attempt === 1)
307
+ throw error;
308
+ }
309
+ }
234
310
  }
235
311
  load(id) {
236
312
  const currentPath = this.sessionPath(id);
@@ -54,8 +54,12 @@ export const CAPABILITIES = {
54
54
  browser: { effect: 'process', concurrency: 'serial', resources: workspaceResource },
55
55
  // glob/grep 不取任何资源锁:读枚举容忍命令执行期间的瞬时不一致(一次目录扫描的结果
56
56
  // 本来就无事务语义),换取「一条 run_command/dev_server 持 workspace 写锁期间,只读
57
- // 枚举与并行子 agent 的读不被全面压制」。文件级一致性仍由 read_file 的 file:<path>
58
- // 锁保证(与 file-locked 写互斥)。网络只读同理不锁。
57
+ // 枚举与并行子 agent 的读不被全面压制」。网络只读同理不锁。
58
+ //
59
+ // read_file 不在此列:它取 file:<path> 读锁(读单个**已知**文件,与目录枚举的
60
+ // 瞬时不一致不同层级)。2026-10-05 起 resource-lock 的 requestConflicts 放行了
61
+ // 「workspace 写锁 vs 资源读锁」这一格,所以 run_command 期间 read_file 不再排队;
62
+ // 但 read_file 仍与 write_file/edit_file 的 file 写锁互斥(见 resource-lock.ts 的冲突矩阵)。
59
63
  glob: { effect: 'read', concurrency: 'parallel', resources: () => [], supportsAbort: true },
60
64
  grep: { effect: 'read', concurrency: 'parallel', resources: () => [], supportsAbort: true },
61
65
  // 网络只读查询:无副作用,可安全重复执行 → idempotent 让 runtime 对瞬时失败(429/5xx/超时/
@@ -5,11 +5,48 @@ function abortError() {
5
5
  error.name = 'AbortError';
6
6
  return error;
7
7
  }
8
+ /**
9
+ * 两个锁请求是否冲突。
10
+ *
11
+ * 冲突矩阵(workspace 锁 vs 资源锁):
12
+ *
13
+ * |左\右| workspace-write | resource-read | resource-write |
14
+ * |------|-----------------|--------------|----------------|
15
+ * | ws-write |冲突(命令互斥) | **不冲突** | 冲突(防命令与 Agent 写同文件) |
16
+ * | ws-read | 不冲突 | 不冲突 | 冲突 |
17
+ * | res-read | 不冲突 | 同 key 不冲突 | 冲突 |
18
+ *
19
+ * **唯一放行的是「workspace 写锁 vs 资源读锁」**(表中加粗那格)。这条正是
20
+ * 「一条 run_command 期间所有 read_file 排队」的根因:run_command 声明
21
+ * `resources: () => ['workspace']` + `effect: 'process'` → resolveResourceLockRequests
22
+ * 恒返回 workspace 写锁(resource-lock.ts 的 workspaceWrite()),而 read_file 持
23
+ * `file:<path>` 读锁,旧判据「任一 write 即冲突」把两者判成互斥 —— 一条 60s 的
24
+ * `npm test` 会让同 step 的所有 read_file 全部阻塞排队,并行探查退化为串行。
25
+ *
26
+ * 放行的依据与 glob/grep 的 `resources: () => []` 豁免同源(见 builtins/index.ts 的
27
+ * CAPABILITIES 注释「读枚举容忍命令执行期间的瞬时不一致」):run_command **不通过
28
+ * ChangeSet 写文件**,它的工作区写入对 read_file 不构成事务性冲突 —— read_file 读的是
29
+ * 单个已知文件,与命令的写入在绝大多数情况下无关。若命令恰好在改这个文件,读到不一致
30
+ * 内容的后果由既有机制兜住:read_file 的输出带 content hash 记入 artifact,后续
31
+ * refreshArtifactFreshness / invalidateArtifacts 会把它标 stale。
32
+ *
33
+ * **保持冲突的边界(不可放宽)**:
34
+ * - workspace-write vs resource-write:run_command 与 write_file/edit_file 改同一文件
35
+ * 必须互斥,否则 ChangeSet 的 read-modify-write 会读到命令的半成品。
36
+ * - workspace-write vs workspace-write:命令之间互斥(文件句柄 / 端口 / 环境变量共享)。
37
+ */
8
38
  function requestConflicts(a, b) {
9
39
  if (a.scope === 'workspace' || b.scope === 'workspace') {
10
- if (a.mode === 'write' || b.mode === 'write')
11
- return true;
12
- return false;
40
+ // 同为 workspace 锁(实践中只有 write):沿用「任一 write 即冲突」。
41
+ if (a.scope === 'workspace' && b.scope === 'workspace') {
42
+ return a.mode === 'write' || b.mode === 'write';
43
+ }
44
+ const workspaceReq = a.scope === 'workspace' ? a : b;
45
+ const resourceReq = a.scope === 'workspace' ? b : a;
46
+ // 放行格:workspace 写锁(命令)与资源读锁(读单个已知文件)不冲突。
47
+ if (workspaceReq.mode === 'write' && resourceReq.mode === 'read')
48
+ return false;
49
+ return workspaceReq.mode === 'write' || resourceReq.mode === 'write';
13
50
  }
14
51
  return a.key === b.key && (a.mode === 'write' || b.mode === 'write');
15
52
  }
@@ -3,7 +3,10 @@ import Ajv2020 from 'ajv/dist/2020.js';
3
3
  const options = {
4
4
  allErrors: true,
5
5
  strict: false,
6
- coerceTypes: false,
6
+ // 中转网关/弱模型常把 integer/number/boolean 序列化成字符串(如 "100"),
7
+ // 开启标量强转在校验前自动还原;类型完全不符("abc"→number)仍会被拦,
8
+ // minimum/maximum 等约束在转换后照常生效。
9
+ coerceTypes: true,
7
10
  useDefaults: false,
8
11
  removeAdditional: false,
9
12
  validateFormats: false,
@@ -69,7 +72,7 @@ function formatErrors(errors, schema) {
69
72
  }
70
73
  return body + hint;
71
74
  }
72
- /** Validate after tool-local normalization; AJV itself never coerces or mutates arguments. */
75
+ /** Validate after tool-local normalization; AJV coerces scalar strings to declared types. */
73
76
  export function validateToolArguments(tool, args) {
74
77
  if (!args || typeof args !== 'object' || Array.isArray(args)) {
75
78
  return {
@@ -9,6 +9,44 @@ import { renderMarkdown } from '../markdown.js';
9
9
  import { state } from './state.js';
10
10
  import { esc, cup, getGeo, setRegion, runningCaretPos } from './screen.js';
11
11
  import { viewportAbsStart, repaintViewport } from './scroll.js';
12
+ // ── 流式 markdown 渲染节流(2026-10-05)──
13
+ // 背景:原实现每收到一个 onText chunk 就把**累积的整个 mdBuf** 重渲一遍(渲成自洽 ANSI 行
14
+ // 替换缓冲段 + repaintViewport 全画)。这是 O(n²):实测一次 5879 字符的回复被切成~1960 个
15
+ // chunk,累计扫描 5.5MB、纯 CPU ~900ms,且全程同步阻塞事件循环(TUI 的 spinner/输入都跟着卡)。
16
+ // renderMarkdown 内有 LRU memo(key = 完整 text),但流式下每次 text 都是新串 → 必然 miss。
17
+ // 分块归因实测:单次 render 仅 0.05ms 量级,带 lang 的代码块最贵(0.097ms),无 lang 近乎免费。
18
+ // → 结论是「贵在次数,不在单次」,故按时间/内容节流降次数,而不是优化单次渲染。
19
+ /**
20
+ * 两次渲染的最小间隔(ms)。
21
+ *
22
+ * 取 32ms(~30fps)而非 16ms:终端正文流式本来肉眼无法分辨更高帧率,32ms 把渲染次数压到
23
+ * 1/20 左右的量级而观感无差异。低于此值收益边际递减(受 pty 写吞吐约束),高于此值
24
+ * 长文本回复会有肉眼可见的「一段一段蹦」感。
25
+ */
26
+ const MD_RENDER_INTERVAL_MS = 32;
27
+ /**
28
+ * 两次渲染的最小字符增量(**仅用于防饿死**,不是常规触发路径)。
29
+ *
30
+ * 后端可能一次性吐出一个大 chunk(如整段代码 / 工具参数),此时距上次渲染可能才过去
31
+ * 1~2ms,纯按时间判定会把它推迟到下个间隔才上屏。攒够这么多字符就立刻渲,保证
32
+ * 「有实质进展就尽快上屏」,同时仍拦住小碎 chunk 的逐个重渲。
33
+ *
34
+ * **取值的关键**:必须**远大于**「一个节流间隔内正常 chunk 的累积量」,否则它会抢在
35
+ * 时间节流前面触发,节流退化成「每 N 个 chunk 渲一次」—— 时间维度失效。
36
+ * 高速后端(约 5ms/chunk、1~2 token ≈ 5 字符)在一个 32ms 间隔内约累积 30 字符,
37
+ * 取 512 留出 17 倍余量:突发大段能快速上屏,正常流里则几乎只由时间节流驱动。
38
+ */
39
+ const MD_RENDER_BURST_CHARS = 512;
40
+ /**
41
+ * 节流判定(纯函数,导出供单测 —— TUI 渲染路径本身需要真 TTY,无法在测试里驱动)。
42
+ *
43
+ * @param elapsedNow距上次渲染的毫秒数
44
+ * @param pendingChars 本次 chunk 累积后、尚未渲染的字符数
45
+ * @returns true=本次就渲染;false=只累积并挂补渲定时器
46
+ */
47
+ export function shouldRenderMdSegment(elapsedNow, pendingChars) {
48
+ return elapsedNow >= MD_RENDER_INTERVAL_MS || pendingChars >= MD_RENDER_BURST_CHARS;
49
+ }
12
50
  /**
13
51
  * 内容区正文写:CUP 到续写位 + 写出 + 按字符宽度/折行更新续写位 + 同步入 content 缓冲(供滚动回看)。
14
52
  * 滚动区域内写满自动在区域内滚动,底栏在区域外不被顶。非 TTY / 未激活:直接 stdout.write(内联退化)。
@@ -164,13 +202,25 @@ function beginMdSegment() {
164
202
  content.beginSegment();
165
203
  state.mdBuf = '';
166
204
  state.mdActive = true;
205
+ state.mdLastRenderAt = 0;
206
+ state.mdRenderedLen = 0;
167
207
  }
168
- /** 提交 markdown 段:清 accumulator + content.commitSegment(后续非 md 写不再被 setLines 截断)。 */
208
+ /**
209
+ * 提交 markdown 段:清 accumulator + content.commitSegment(后续非 md 写不再被 setLines 截断)。
210
+ *
211
+ * 必须先 flushMdSegment:commitSegment 会清 segMark,此后 setLines 不再截断到段头——若此时
212
+ * mdBuf 还有未渲的 chunk,那部分内容将永远进不了缓冲(静默丢字)。故「收尾」语义在这里
213
+ * 兑现:任何离开 md 段的路径都先把累积内容渲上屏。这也是节流的安全网:定时器只是兜底,
214
+ * 真正的收尾由本函数保证。
215
+ */
169
216
  function commitMd() {
170
217
  if (!state.mdActive)
171
218
  return;
219
+ flushMdSegment();
172
220
  state.mdActive = false;
173
221
  state.mdBuf = '';
222
+ state.mdLastRenderAt = 0;
223
+ state.mdRenderedLen = 0;
174
224
  content.commitSegment();
175
225
  }
176
226
  // ── Banner(启动横幅/模式切换横幅)固定顶部行 ──
@@ -235,25 +285,18 @@ export function rewriteBanner(lines) {
235
285
  export function bannerHeight() {
236
286
  return state.bannerH;
237
287
  }
288
+ /** 本段已渲染进缓冲的 mdBuf 长度(节流的内容判定用;md 段外无意义故返回 0)。 */
289
+ function mdRenderedLen() {
290
+ return state.mdActive ? state.mdRenderedLen : 0;
291
+ }
238
292
  /**
239
- * markdown 正文写(替代 contentWrite 用于 agent onText):累积 chunk 到 mdBuf,每 chunk 把整段
240
- * mdBuf 经 renderMarkdown 渲成自洽 ANSI 行,replace 缓冲段(content.setLines 截旧 + 写新),
241
- * repaintViewport 重画(仅尾 offset=0 且未暂停)。流式安全:未闭合 fence 照常 emit 进行中代码块;
242
- * renderMarkdown 内部 memo by text 使重复渲染命中缓存。非 TTY / 未激活:直接 stdout.write(与
243
- * contentWrite 一致,管道流式可见)。
293
+ * 把当前 mdBuf 渲成自洽行并 replace 缓冲段 + 重画 viewport。
244
294
  *
245
- * 续写位 = 段末下一行(段占 lines.length 行从 segmentStartRow 起);超可视区则滚动留 contentBottom。
246
- * 物理重画用 repaintViewport(全内容区,原子一次 write 无闪烁)— md 段是缓冲尾,viewport 显尾即显段。
247
- * 滚动回看(scrollOffset>0)只更新缓冲不物理写(回尾时显);打字中照常物理写——单次 write 结尾 cup 回输入框,IME 锚定不动。
295
+ * 从 contentWriteMd 抽出的独立步骤:节流路径在「攒够间隔」和「收尾强制补渲」两条路上
296
+ * 都要用到它。行为与原 contentWriteMd 渲染体逐行一致(滚动回看冻结、续写位推进、
297
+ * running 态光标归位都在此),调用方只负责决定**何时**调。
248
298
  */
249
- export function contentWriteMd(s) {
250
- if (!state.active || !ui.isTTY) {
251
- stdout.write(s);
252
- return;
253
- }
254
- if (!state.mdActive)
255
- beginMdSegment();
256
- state.mdBuf += s;
299
+ function renderMdSegment() {
257
300
  const g = getGeo();
258
301
  // 滚动回看冻结(同 contentWrite):scrollOffset>0 时 setLines 替换段会改缓冲行数,若 offset 不变,
259
302
  // 下次 scrollBy→repaintViewport 取漂移窗口、把流式正文画进历史视图。故 setLines 前后算 totalRows 差,
@@ -262,6 +305,7 @@ export function contentWriteMd(s) {
262
305
  const totalBefore = scrolled ? content.totalRows() : 0;
263
306
  const lines = renderMarkdown(state.mdBuf, g.cols);
264
307
  content.setLines(lines, state.mdBuf);
308
+ state.mdRenderedLen = state.mdBuf.length;
265
309
  const segRows = lines.length;
266
310
  const available = g.contentBottom - state.segmentStartRow + 1;
267
311
  state.contentRow = segRows >= available ? g.contentBottom : state.segmentStartRow + segRows;
@@ -273,6 +317,7 @@ export function contentWriteMd(s) {
273
317
  state.scrollOffset = Math.max(0, Math.min(state.scrollOffset + delta, maxOff));
274
318
  }
275
319
  }
320
+ state.mdLastRenderAt = Date.now();
276
321
  if (state.scrollOffset === 0) {
277
322
  repaintViewport(); // 单次 write 结尾 cup 回 runningCaretPos(运行态),IME 锚输入框;打字中不再暂停
278
323
  if (state.mode === 'running') {
@@ -281,6 +326,75 @@ export function contentWriteMd(s) {
281
326
  }
282
327
  }
283
328
  }
329
+ /**
330
+ * 立即把mdBuf 渲进缓冲并重画,并撤销待补渲定时器。
331
+ *
332
+ * 所有「不能留待定时器补渲」的路径都必须走这里(收尾/ 换段 / resize / 清屏 / 退出):
333
+ * 缓冲内容是后续一切布局计算(contentInsertAfter 的 segMark 平移、contentRow 续写位、
334
+ * reflow 的 activeSegmentStart)的地基,留着未渲的 chunk 会让它们算错。
335
+ *
336
+ * 导出给 layout-internal/core 的 enterInputMode 用(轮末兜底补渲);core 只在 mdActive
337
+ * 时调,其余调用方是本模块内的 commitMd / clearContent。
338
+ */
339
+ export function flushMdSegment() {
340
+ if (state.mdFlushTimer) {
341
+ clearTimeout(state.mdFlushTimer);
342
+ state.mdFlushTimer = null;
343
+ }
344
+ if (!state.mdActive)
345
+ return;
346
+ renderMdSegment();
347
+ }
348
+ /**
349
+ * markdown 正文写(替代 contentWrite 用于 agent onText):累积 chunk 到 mdBuf,**按节流节奏**
350
+ * 经 renderMarkdown 渲成自洽 ANSI 行,replace 缓冲段(content.setLines)+ repaintViewport 重画。
351
+ * 非 TTY / 未激活:直接 stdout.write(与 contentWrite 一致,管道流式可见)。
352
+ *
353
+ * **节流(2026-10-05)**:每 chunk 重渲整个累积 mdBuf 是 O(n²)(实测 5879 字符回复 →
354
+ * 1960 chunk → 累计扫描 5.5MB / ~900ms 纯 CPU),而 renderMarkdown 的 LRU memo 在流式下
355
+ * 必然 miss。改为:距上次渲染不足 MD_RENDER_INTERVAL_MS 时只累积 chunk 并挂补渲定时器。
356
+ *
357
+ * 渲染时机由**时间**主导(唯一正常路径的触发条件),内容增益只在「时间未到但已攒了
358
+ * 远超一帧正常量的字符」时放行:
359
+ * - elapsed ≥ MD_RENDER_INTERVAL_MS:正常节流(约 30fps)
360
+ * - 累积 ≥ MD_RENDER_BURST_CHARS:防单个超大 chunk 饿死。取 MD_RENDER_BURST_CHARS
361
+ * 远大于「一个间隔内正常 chunk 的累积量」是重点 —— 否则它会退化成「每 N chunk 渲一次」,
362
+ * 时间节流失效(实测取 64 时只降到 2x,因为高速流每 20ms 就攒够 64 字符)。
363
+ * 高速后端(约 5ms/chunk)一个 32ms 间隔约累积 6~7 个 token(~25 字符),故取 512:
364
+ * 既允许突发大段快速上屏,又不会在正常流里抢在时间节流前面触发。
365
+ *
366
+ * 续写位 = 段末下一行(段占 lines.length 行从 segmentStartRow 起);超可视区则滚动留 contentBottom。
367
+ * 物理重画用 repaintViewport(全内容区,原子一次 write 无闪烁)— md 段是缓冲尾,viewport 显尾即显段。
368
+ * 滚动回看(scrollOffset>0)只更新缓冲不物理写(回尾时显);打字中照常物理写——单次 write 结尾 cup 回输入框,IME 锚定不动。
369
+ *
370
+ * 收尾安全网:onToolCall / 轮末 / resize / 任何非 md 写都经 commitMd → flushMdSegment,
371
+ * 补渲定时器到点也会渲染,故不存在「最后一段丢失」。见 beginMdSegment/commitMd 的定时器清理。
372
+ */
373
+ export function contentWriteMd(s) {
374
+ if (!state.active || !ui.isTTY) {
375
+ stdout.write(s);
376
+ return;
377
+ }
378
+ if (!state.mdActive)
379
+ beginMdSegment();
380
+ state.mdBuf += s;
381
+ const now = Date.now();
382
+ const elapsed = now - state.mdLastRenderAt;
383
+ if (shouldRenderMdSegment(elapsed, state.mdBuf.length - mdRenderedLen())) {
384
+ flushMdSegment();
385
+ return;
386
+ }
387
+ // 尚未到渲染节奏:挂一个补渲定时器(若已有则不重复挂——同一段内始终只有一个)。
388
+ // unref:该定时器不该让进程在退出时多留一帧;退出路径由 exitAltScreen 清理。
389
+ if (!state.mdFlushTimer) {
390
+ state.mdFlushTimer = setTimeout(() => {
391
+ state.mdFlushTimer = null;
392
+ if (state.active && state.mdActive)
393
+ renderMdSegment();
394
+ }, MD_RENDER_INTERVAL_MS - elapsed);
395
+ state.mdFlushTimer.unref?.();
396
+ }
397
+ }
284
398
  /**
285
399
  * 把一段完整(非流式)assistant 文本渲染进独立 markdown 段(供 renderHistory /resume /
286
400
  * /rollback 回放)。与流式 contentWriteMd 的区别是一次完成 begin/set/commit,不累积 chunk;
@@ -321,6 +435,15 @@ export function clearContent() {
321
435
  state.scrollLockUntil = 0;
322
436
  state.mdActive = false;
323
437
  state.mdBuf = '';
438
+ // 清 md 段状态必须同时撤掉待补渲定时器:否则它在 content.reset() 之后才到点触发,
439
+ // 往已清空的 buffer 里 setLines(残留 mdBuf 的旧内容) —— /clear、/resume、/theme
440
+ // 都会走到这里,表现为清屏后凭空冒出几行旧正文。
441
+ if (state.mdFlushTimer) {
442
+ clearTimeout(state.mdFlushTimer);
443
+ state.mdFlushTimer = null;
444
+ }
445
+ state.mdLastRenderAt = 0;
446
+ state.mdRenderedLen = 0;
324
447
  // 清内容缓冲时必须同步重置 banner 状态:否则后续 writeBanner() 会走 rewriteBanner 路径,
325
448
  // 在已空的 content 上调 replaceHead(0, lines) → startIdx(0) >= committed(0) → 抛错 → REPL 退出。
326
449
  // /theme、/clear、/resume 等命令 clearContent 后紧接 writeBanner 的场景均依赖此重置。
@@ -465,6 +588,11 @@ export function dismissWelcomeBlock() {
465
588
  export function contentInsertAfter(after, lines, keepViewport = true) {
466
589
  if (!state.active || lines.length === 0)
467
590
  return;
591
+ // 节流后 mdBuf 可能仍有未渲 chunk:调用方给的 after 是按**当前 buffer** 算的绝对行索引,
592
+ // 而待渲 chunk 还没进buffer。不先补渲就会插到错误位置(展开行插进正文中间)。
593
+ // 故凡有活跃 md 段,插入前先把累积内容渲上屏,让 buffer 与 mdBuf 对齐。
594
+ if (state.mdActive)
595
+ flushMdSegment();
468
596
  const g = getGeo();
469
597
  const totalBefore = content.totalRows();
470
598
  const scrolled = state.scrollOffset > 0;
@@ -510,6 +638,9 @@ export function contentInsertAfter(after, lines, keepViewport = true) {
510
638
  export function contentDeleteFrom(startIdx, n) {
511
639
  if (!state.active || n <= 0)
512
640
  return;
641
+ // 同 contentInsertAfter:startIdx 是按当前 buffer 算的,先补渲让两者对齐。
642
+ if (state.mdActive)
643
+ flushMdSegment();
513
644
  const totalBefore = content.totalRows();
514
645
  const scrolled = state.scrollOffset > 0;
515
646
  content.deleteFrom(startIdx, n);
@@ -605,6 +736,12 @@ function remapLineForReflow(line, change) {
605
736
  }
606
737
  /** 终端尺寸变化后更新正文布局。列宽变化时先重排欢迎块、再重排 markdown 段;仅高度变化时只重算屏幕锚点。 */
607
738
  export function reflowContentForResize(cols, colsChanged) {
739
+ // 节流后 mdBuf 可能仍有未渲的 chunk:reflowMarkdown 拿 segment.source(上次 setLines 时
740
+ // 的 source)按新列宽重排,若 source 滞后于 mdBuf,重排的是旧文本,而紧接着的一次
741
+ // 补渲又会把新文本按新列宽整段渲一遍 —— 中间这拍白做,更糟的是 oldLines/newLines 的
742
+ // 选区映射基于旧文本,用户选中的位置会漂。先补渲,让 reflow 看到的 source 与 mdBuf 一致。
743
+ if (state.mdActive)
744
+ flushMdSegment();
608
745
  const oldTotal = content.totalRows();
609
746
  const oldViewportStart = viewportAbsStart();
610
747
  const oldViewportEnd = Math.max(oldViewportStart, oldTotal - state.scrollOffset - 1);
@@ -28,7 +28,7 @@ import { normalizeSelection, normalizeInputSelection, extractSelectionText, extr
28
28
  import { composePlanLines, drawStatusBar, startTurnTimer, stopTurnTimer } from './statusbar.js';
29
29
  import { paintInput, repaint } from './input-paint.js';
30
30
  import { WHEEL_LINES, screenRowToAbsLine, repaintViewport, scrollBy, scrollWheel, resetScroll, lockScrollToBottom, } from './scroll.js';
31
- import { contentWrite, contentInsertAfter, contentDeleteFrom, contentReplaceLine, reflowContentForResize, } from './content-write.js';
31
+ import { contentWrite, contentInsertAfter, contentDeleteFrom, contentReplaceLine, reflowContentForResize, flushMdSegment, } from './content-write.js';
32
32
  export { isTuiActive, getGeo, setRegion, contentMode, isStreamingPaused } from './screen.js';
33
33
  export { drawStatusBar, setStatus, stopTurnTimer, startTurnTimerIfRunning, paintLiveAtCursor, clearLiveAtCursor, setLiveUsage, setStatusBase, } from './statusbar.js';
34
34
  export { paintInput, paintRunningInput, repaint } from './input-paint.js';
@@ -396,6 +396,12 @@ export function enterInputMode(status = t('repl.idle')) {
396
396
  if (state.active)
397
397
  repaintViewport();
398
398
  }
399
+ // 轮末补渲流式 md:节流后 mdBuf 可能仍有未渲的 chunk(下一次渲染的定时器还没到点),
400
+ // 而下面要画输入框、repaintViewport —— 画面会停在上一帧,看起来像「回复最后几句丢了」。
401
+ // 正常轮末会先经 commitMd(有 flush)走到这里,mdActive 应已是 false;此分支只兜
402
+ // 「异常路径直接进输入态」的情况,故只在 mdActive 时才动手。
403
+ if (state.active && state.mdActive)
404
+ flushMdSegment();
399
405
  if (state.active && state.base) {
400
406
  // 先按当前 base.planSummary 重算 planRows(可能从上次会话残留 stale 值),再据此 setRegion
401
407
  // 撑出正确脚栏高;否则 plan 撑 2 行时 setRegion(6) 会把 spinner 挤到 plan 第 2 行位置。
@@ -462,6 +468,12 @@ export function enterAltScreen() {
462
468
  state.scrollLockUntil = 0;
463
469
  state.mdActive = false;
464
470
  state.mdBuf = '';
471
+ if (state.mdFlushTimer) {
472
+ clearTimeout(state.mdFlushTimer);
473
+ state.mdFlushTimer = null;
474
+ }
475
+ state.mdLastRenderAt = 0;
476
+ state.mdRenderedLen = 0;
465
477
  state.selection = null;
466
478
  state.selecting = false;
467
479
  content.reset();
@@ -570,6 +582,11 @@ export function exitAltScreen() {
570
582
  clearTimeout(state.flushTimer);
571
583
  state.flushTimer = null;
572
584
  }
585
+ // 流式 md 补渲定时器同理:退出后再触发会往已退 alt screen 的终端写内容。
586
+ if (state.mdFlushTimer) {
587
+ clearTimeout(state.mdFlushTimer);
588
+ state.mdFlushTimer = null;
589
+ }
573
590
  state.userActiveUntil = 0;
574
591
  }
575
592
  /**
@@ -25,11 +25,23 @@ export const state = {
25
25
  contentRow: 1, // 续写位行(1-based,屏坐标,[1,contentBottom])
26
26
  contentCol: 1, // 续写位列(1-based)
27
27
  segmentStartRow: 1, // 当前 md 段起始屏行(供 contentWriteMd 定位段末续写位;段内行数由 content 段标记跟踪)
28
- /** markdown 流式段:agent onText 的 chunk 累积到 mdBuf,每 chunk 把整段经 renderMarkdown
28
+ /** markdown 流式段:agent onText 的 chunk 累积到 mdBuf,按节流节奏经renderMarkdown
29
29
  * 渲成自洽行,replace 缓冲段(content.setLines)+ repaintViewport 重画。mdActive 期间任何
30
- * 非 md 写(contentWrite)先 commitMd 收尾(清 segMark,后续写不再被 setLines 截断)。 */
30
+ * 非 md 写(contentWrite)先 commitMd 收尾(清 segMark,后续写不再被 setLines 截断)。
31
+ *
32
+ * **为什么需要节流**(2026-10-05 实测):每 chunk 重渲整个累积 mdBuf 是 O(n²)——
33
+ * 一次 5879 字符回复被切成~1960 chunk,累计扫描 5.5MB、纯 CPU ~900ms。renderMarkdown
34
+ * 内部的 LRU memo(key=text)在流式下每次 text 都变,必然 miss,救不了。真正的成本是
35
+ * **次数**而非单次(实测单次 render 仅 0.05ms 量级)。故改为「攒够一段时间或跨过行
36
+ * 边界才渲」,把渲染次数从 O(chunk数) 降到 O(耗时/节流间隔)。 */
31
37
  mdActive: false,
32
38
  mdBuf: '',
39
+ /** 流式 md 上次实际渲染的时刻(Date.now());0=本段尚未渲过。节流判定用。 */
40
+ mdLastRenderAt: 0,
41
+ /** 本段已渲进缓冲的 mdBuf 长度;内容节流判定「攒够多少」用。 */
42
+ mdRenderedLen: 0,
43
+ /** 节流到期的定时器;非 null 表示「还有未渲染的 chunk 待补渲」。 */
44
+ mdFlushTimer: null,
33
45
  bannerH: 0,
34
46
  welcomeStart: -1, // 块起点(content.committedRows 口径的绝对行索引)
35
47
  welcomeRows: 0, // 块行数(0 = 屏上无欢迎块)
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { discoverProjectProfile } from './profile.js';
4
4
  const COMPATIBILITY_PRIORITY = ['typecheck', 'test', 'build'];
5
- const LAYERED_ORDER = ['typecheck', 'build', 'test'];
5
+ const LAYERED_ORDER = ['typecheck', 'lint', 'build', 'test'];
6
6
  const isWindows = process.platform === 'win32';
7
7
  function samePath(left, right) {
8
8
  const normalizedLeft = path.resolve(left);
@@ -18,7 +18,7 @@ function lineFor(profile, packageProfile) {
18
18
  /**
19
19
  * Deterministic project validation map injected into the system prompt: which package owns which
20
20
  * script, and the exact command plus cwd to run it. Commands are listed in increasing cost order
21
- * (typecheck → build → test) and are never executed here — this is evidence, not a completion gate.
21
+ * (typecheck → lint → build → test) and are never executed here — this is evidence, not a completion gate.
22
22
  *
23
23
  * Returns '' when no package exposes a validation script, or when discovery fails for any reason
24
24
  * (missing/invalid manifest, unreadable workspace): prompt construction must never break.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.6.10",
3
+ "version": "1.7.1",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 27 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {