mocode-ai 1.2.1 → 1.2.2

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.
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs';
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs';
3
+ import * as fsp from 'node:fs/promises';
3
4
  import path from 'node:path';
4
5
  import { config } from '../config/index.js';
5
6
  import { truncateDisplay } from '../ui/render.js';
@@ -50,8 +51,19 @@ function readState(full) {
50
51
  }
51
52
  return { kind: 'missing' };
52
53
  }
54
+ /**
55
+ * 状态等价判定。两侧都捕获了内容时按内容比(与旧行为一致,精确);
56
+ * 任一侧内容未捕获(超预算的大文件)时退化为 stamp 比较——size+mtimeNs 变了就算变。
57
+ */
53
58
  function sameState(a, b) {
54
- return a.kind === b.kind && a.data === b.data && a.mode === b.mode;
59
+ if (a.kind !== b.kind || a.mode !== b.mode)
60
+ return false;
61
+ if (a.data !== undefined && b.data !== undefined)
62
+ return a.data === b.data;
63
+ if (a.data === undefined && b.data === undefined && a.stamp === undefined && b.stamp === undefined) {
64
+ return true; // directory / missing:kind+mode 已足够
65
+ }
66
+ return a.stamp === b.stamp;
55
67
  }
56
68
  function stateFingerprint(state) {
57
69
  return createHash('sha256')
@@ -82,6 +94,8 @@ function snapshotFromState(rel, state, sequence, op, createdParents = [], after)
82
94
  ops: [op],
83
95
  createdParents: createdParents.length > 0 ? createdParents : undefined,
84
96
  afterFingerprint: after ? stateFingerprint(after) : undefined,
97
+ // 文件但没有内容 = 工作区扫描时超出捕获预算,只能报告"变了",不能拿它覆盖磁盘。
98
+ contentUnavailable: state.kind === 'file' && state.data === undefined ? true : undefined,
85
99
  };
86
100
  }
87
101
  /** 同轮同路径只保留最早的 before;后续实际改动仅合并工具名。 */
@@ -163,55 +177,172 @@ export function endPathMutation(capture, op) {
163
177
  if (changed)
164
178
  mutationVersion += 1;
165
179
  }
166
- // 构建产物 / 临时 / 缓存目录:可再生运行时状态,扫描它们既昂贵(dist/ 含大量 .js bundle,
167
- // 全量 readFileSync 会同步卡死事件循环,表现为 run_command 期间滚轮划不动、spinner 冻结),
168
- // 也易把后台 daemon / 打包器的写入误判成模型改动。回滚本就只应覆盖源码,构建产物可再生。
180
+ // 构建产物 / 依赖树 / 缓存 / 运行时状态目录:可再生,扫描它们既昂贵也易把后台 daemon、
181
+ // 打包器、mocode 自身(会话日志 / dev-server 日志 / 截图)的写入误判成模型改动。
182
+ // 回滚本就只应覆盖源码。
169
183
  const EXCLUDED_WORKSPACE_DIRS = new Set([
170
- '.git', '.codegraph', 'node_modules',
171
- 'dist', 'build', 'out', 'coverage', '.tmp', 'tmp',
172
- '.output', '.next', '.vite', '.turbo', '.svelte-kit',
184
+ // VCS / 索引 / mocode 自身运行时状态(会话、trace、dev-server 日志、记忆、截图每轮都在写,
185
+ // 既无回滚意义,又会被误判成模型改动)
186
+ '.git', '.hg', '.svn', '.codegraph', '.mocode',
187
+ // 依赖树与包管理器缓存
188
+ 'node_modules', 'vendor', 'bower_components', '.yarn', '.pnpm-store', '.venv', 'venv', 'pods',
189
+ // 构建产物
190
+ 'dist', 'build', 'out', 'target', 'coverage', '.output', '.next', '.nuxt', '.vite',
191
+ '.turbo', '.svelte-kit', '.angular', '.astro', '.docusaurus', '.dart_tool', '.terraform',
192
+ // 临时与缓存
193
+ '.tmp', 'tmp', '.cache', '.parcel-cache', '.nyc_output', '__pycache__',
194
+ '.pytest_cache', '.mypy_cache', '.ruff_cache', '.gradle',
173
195
  ]);
196
+ /** 单文件内容捕获上限:更大的文件只留 stamp(可检测变化,不可恢复),避免把巨型二进制读进内存。 */
197
+ const CAPTURE_FILE_LIMIT = 1024 * 1024;
198
+ /** 单次扫描的内容总预算:超出后剩余文件只留 stamp。 */
199
+ const CAPTURE_TOTAL_LIMIT = 32 * 1024 * 1024;
200
+ /** 条目上限:超大工作区不做无边界遍历(超出部分不参与变更检测)。 */
201
+ const CAPTURE_ENTRY_LIMIT = 20000;
202
+ /** 并发文件操作数:冷缓存(尤其 Windows 杀软逐文件扫描)下 I/O 重叠远快于串行。 */
203
+ const SCAN_CONCURRENCY = 16;
204
+ /** 让出事件循环的节奏:每 N 个条目,或每累计编码 M 字节(base64 是纯 CPU,大文件靠字节数兜底)。 */
205
+ const YIELD_EVERY = 64;
206
+ const YIELD_BYTES = 1024 * 1024;
207
+ /**
208
+ * 刚被写过的文件不信缓存,强制重读内容。
209
+ * 原因:stamp 依赖 mtime 精度。NTFS/ext4/APFS 是 100ns~ns 级,但 exFAT / 部分网络盘只有 1~2s,
210
+ * 那里一条"同尺寸原地改写"可能与快照前共享同一时间戳,只比 stamp 会漏掉真实变化。
211
+ * 只对最近 2s 内改动的文件付重读代价(通常正是命令刚碰过的那几个),开销可忽略。
212
+ */
213
+ const FRESH_WINDOW_MS = 2000;
214
+ /** path → 上次扫描捕获的内容。stamp(size+mtimeNs)未变即复用,未改动的文件不重复读盘。 */
215
+ let contentCache = new Map();
216
+ let cachedSessionDir = '';
217
+ let resolvedSessionDir = '';
218
+ function sessionDirAbs() {
219
+ if (config.sessionDir !== cachedSessionDir) {
220
+ cachedSessionDir = config.sessionDir;
221
+ resolvedSessionDir = path.resolve(config.sessionDir);
222
+ }
223
+ return resolvedSessionDir;
224
+ }
174
225
  function isWorkspaceExcluded(full) {
175
226
  const base = path.basename(full).toLowerCase();
176
227
  // Git 元数据必须永久排除以保护 index;依赖树/代码索引/构建产物/临时目录是可再生运行时状态。
177
228
  if (EXCLUDED_WORKSPACE_DIRS.has(base))
178
229
  return true;
179
- const sessionDir = path.resolve(config.sessionDir);
180
- return isInside(sessionDir, full);
230
+ return isInside(sessionDirAbs(), full);
181
231
  }
182
- function scanWorkspace() {
232
+ const yieldToEventLoop = () => new Promise((resolve) => setImmediate(resolve));
233
+ /**
234
+ * 工作区快照(run_command / dev_server / MCP 等不透明工具用)。
235
+ *
236
+ * **必须是异步且带缓存的**:本函数在每次这类工具调用前后各跑一次,直接坐在用户交互
237
+ * 路径上。旧实现用 readdirSync + 全量 readFileSync(base64) 同步遍历整棵工作树 ——
238
+ * 冷文件缓存下 400 个文件实测就要 ~3s(Windows),几千个文件即数十秒,期间事件循环
239
+ * 完全阻塞:spinner 冻结、走时停摆、键鼠无响应,用户看到的就是「卡在 执行 run_command」。
240
+ *
241
+ * 现在:
242
+ * 1) fs/promises + 有界并发 + 定期 setImmediate 让出 → 事件循环全程可呼吸;
243
+ * 2) 内容按 (size, mtimeNs, mode) 缓存复用 → 未改动的文件不再重复读盘,
244
+ * 一次会话里只有首次扫描付全量代价,之后只读真正变化的文件;
245
+ * 3) 单文件 / 总量 / 条目数三重预算 → 巨型仓库不会把内存和时间吃穿。
246
+ */
247
+ async function scanWorkspace() {
183
248
  const entries = new Map();
184
- const walk = (dir) => {
249
+ const nextCache = new Map();
250
+ const paths = [];
251
+ // 第一趟:只 readdir 收集条目(廉价,不读内容)。目录本身即刻记账,顺序稳定。
252
+ const walk = async (dir) => {
253
+ if (paths.length >= CAPTURE_ENTRY_LIMIT)
254
+ return;
185
255
  let children;
186
256
  try {
187
- children = readdirSync(dir, { withFileTypes: true });
257
+ children = await fsp.readdir(dir, { withFileTypes: true });
188
258
  }
189
259
  catch {
190
260
  return;
191
261
  }
192
262
  for (const child of children) {
263
+ if (paths.length >= CAPTURE_ENTRY_LIMIT)
264
+ return;
193
265
  const full = path.join(dir, child.name);
194
266
  if (isWorkspaceExcluded(full))
195
267
  continue;
196
- const state = readState(full);
268
+ paths.push(full);
269
+ // isDirectory() 对 symlink 为 false —— 与旧实现一致:不跟随软链。
270
+ if (child.isDirectory())
271
+ await walk(full);
272
+ }
273
+ };
274
+ await walk(rootDir());
275
+ // 第二趟:有界并发读状态。budget 由完成顺序消费——两次扫描间某个大文件是否被捕获
276
+ // 可能不同,sameState 在任一侧缺内容时退化为 stamp 比较,故不会产生伪变化。
277
+ let budget = CAPTURE_TOTAL_LIMIT;
278
+ let cursor = 0;
279
+ let processed = 0;
280
+ let bytesSinceYield = 0;
281
+ const worker = async () => {
282
+ while (cursor < paths.length) {
283
+ const full = paths[cursor++];
284
+ if (++processed % YIELD_EVERY === 0 || bytesSinceYield >= YIELD_BYTES) {
285
+ bytesSinceYield = 0;
286
+ await yieldToEventLoop();
287
+ }
288
+ let state;
289
+ try {
290
+ const stat = await fsp.lstat(full, { bigint: true });
291
+ const mode = Number(stat.mode) & 0o777;
292
+ if (stat.isSymbolicLink()) {
293
+ const target = await fsp.readlink(full);
294
+ state = { kind: 'symlink', data: target, stamp: target, mode };
295
+ }
296
+ else if (stat.isDirectory()) {
297
+ state = { kind: 'directory', mode, stamp: '' };
298
+ }
299
+ else if (stat.isFile()) {
300
+ const size = Number(stat.size);
301
+ const stamp = `${size}:${stat.mtimeNs}`;
302
+ // 未来时间戳(时钟偏移)同样按"新鲜"处理 —— 宁可多读一次,不可漏判变化。
303
+ const fresh = Date.now() - Number(stat.mtimeNs / 1000000n) < FRESH_WINDOW_MS;
304
+ const cached = fresh ? undefined : contentCache.get(full);
305
+ if (cached && cached.stamp === stamp && cached.mode === mode) {
306
+ budget -= size; // 命中也计预算,保证冷/热缓存下的捕获集合一致
307
+ state = { kind: 'file', data: cached.data, stamp, mode };
308
+ nextCache.set(full, cached);
309
+ }
310
+ else if (size <= CAPTURE_FILE_LIMIT && budget - size >= 0) {
311
+ budget -= size;
312
+ bytesSinceYield += size;
313
+ const data = (await fsp.readFile(full)).toString('base64');
314
+ state = { kind: 'file', data, stamp, mode };
315
+ nextCache.set(full, { stamp, mode, data });
316
+ }
317
+ else {
318
+ // 超大文件 / 预算耗尽:只留指纹,能报告变化但不参与内容恢复。
319
+ state = { kind: 'file', stamp, mode };
320
+ }
321
+ }
322
+ else {
323
+ state = { kind: 'missing' };
324
+ }
325
+ }
326
+ catch {
327
+ state = { kind: 'missing' }; // 不存在或不可读:工具若最终也不可读,不会产生伪变化
328
+ }
197
329
  if (state.kind === 'missing')
198
330
  continue;
199
- const rel = toRel(full);
200
- entries.set(rel, state);
201
- if (state.kind === 'directory')
202
- walk(full);
331
+ entries.set(toRel(full), state);
203
332
  }
204
333
  };
205
- walk(rootDir());
334
+ await Promise.all(Array.from({ length: Math.min(SCAN_CONCURRENCY, Math.max(1, paths.length)) }, worker));
335
+ // 缓存整体替换:已删除的文件自然被淘汰,内存上限 ≈ CAPTURE_TOTAL_LIMIT。
336
+ contentCache = nextCache;
206
337
  return entries;
207
338
  }
208
339
  /** run_command/MCP 前调用。不跟随 symlink,并排除 .git、会话快照、依赖树与代码索引。 */
209
- export function beginWorkspaceMutation() {
210
- return { sequence: ++sequenceCounter, entries: scanWorkspace() };
340
+ export async function beginWorkspaceMutation() {
341
+ return { sequence: ++sequenceCounter, entries: await scanWorkspace() };
211
342
  }
212
343
  /** 不透明工具执行后比较整个工作区,把实际变化压入当前轮事务日志。 */
213
- export function endWorkspaceMutation(capture, op) {
214
- const after = scanWorkspace();
344
+ export async function endWorkspaceMutation(capture, op) {
345
+ const after = await scanWorkspace();
215
346
  const paths = new Set([...capture.entries.keys(), ...after.keys()]);
216
347
  let changed = false;
217
348
  for (const rel of paths) {
@@ -234,10 +365,16 @@ export function getCurrentTurnMutationState() {
234
365
  continue;
235
366
  let change = byPath.get(snapshot.path);
236
367
  if (!change) {
237
- change = { path: snapshot.path, ops: [], snapshotAvailable: true };
368
+ change = {
369
+ path: snapshot.path,
370
+ ops: [],
371
+ snapshotAvailable: snapshot.contentUnavailable !== true,
372
+ };
238
373
  byPath.set(snapshot.path, change);
239
374
  order.push(snapshot.path);
240
375
  }
376
+ if (snapshot.contentUnavailable)
377
+ change.snapshotAvailable = false;
241
378
  for (const op of snapshot.ops ?? ['file_change']) {
242
379
  if (!change.ops.includes(op))
243
380
  change.ops.push(op);
@@ -281,6 +418,8 @@ export function planRollback(n, history) {
281
418
  if (snapshot.turnId <= cutoffTurnId)
282
419
  continue;
283
420
  const change = ensure(snapshot.path);
421
+ if (snapshot.contentUnavailable)
422
+ change.snapshotAvailable = false;
284
423
  for (const op of snapshot.ops ?? ['file_change']) {
285
424
  if (!change.ops.includes(op))
286
425
  change.ops.push(op);
@@ -300,6 +439,9 @@ function restoreSnapshot(snapshot) {
300
439
  const full = safeFullPath(snapshot.path);
301
440
  if (!full)
302
441
  return false;
442
+ // 内容未捕获(工作区扫描时超出预算):宁可报冲突,也不能拿空内容覆盖用户文件。
443
+ if (snapshot.contentUnavailable)
444
+ return false;
303
445
  const state = stateFromSnapshot(snapshot);
304
446
  try {
305
447
  if (state.kind === 'missing') {
@@ -413,6 +555,7 @@ export function resetState() {
413
555
  turnIdCounter = 0;
414
556
  currentTurnId = 0;
415
557
  sequenceCounter = 0;
558
+ contentCache = new Map();
416
559
  }
417
560
  export function rebuildFromHistory(history) {
418
561
  const rebuilt = [];
@@ -148,8 +148,10 @@ async function executeToolOnce(tool, args, signal, opts) {
148
148
  ? beginPathMutation(args.path)
149
149
  : null;
150
150
  capturedPath = pathCapture?.path;
151
+ // 工作区快照是异步的:它遍历整棵工作树,同步实现会在每次 run_command/MCP 调用前后
152
+ // 阻塞事件循环数秒(TUI 完全冻结)。await 让 spinner / 走时 / 键鼠在扫描期间继续工作。
151
153
  const workspaceCapture = capabilities.effect === 'process' || capabilities.effect === 'unknown'
152
- ? beginWorkspaceMutation()
154
+ ? await beginWorkspaceMutation()
153
155
  : null;
154
156
  let raw;
155
157
  try {
@@ -159,7 +161,7 @@ async function executeToolOnce(tool, args, signal, opts) {
159
161
  if (pathCapture)
160
162
  endPathMutation(pathCapture, tool.name);
161
163
  if (workspaceCapture)
162
- endWorkspaceMutation(workspaceCapture, tool.name);
164
+ await endWorkspaceMutation(workspaceCapture, tool.name);
163
165
  }
164
166
  const mutationAfter = getCurrentTurnMutationState();
165
167
  const changedFiles = mutationAfter.version !== mutationBefore.version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {