mocode-ai 1.4.2 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +13 -1
  2. package/dist/agent/core.js +14 -936
  3. package/dist/agent/index.js +37 -13
  4. package/dist/agent/model-turn.js +218 -0
  5. package/dist/agent/pipeline.js +18 -0
  6. package/dist/agent/run-contracts.js +1 -0
  7. package/dist/agent/run-coordinator.js +758 -0
  8. package/dist/agent/runtime-context.js +118 -24
  9. package/dist/agent/spawn.js +11 -7
  10. package/dist/agent/stages/context-trimmer.js +63 -0
  11. package/dist/agent/stages/contracts.js +12 -0
  12. package/dist/agent/stages/history-manager.js +178 -0
  13. package/dist/agent/stages/legacy-adapters.js +19 -0
  14. package/dist/agent/stages/model-runner.js +29 -0
  15. package/dist/agent/stages/run-policy.js +73 -0
  16. package/dist/agent/stages/tool-dispatcher.js +341 -0
  17. package/dist/agent/tool-helpers.js +12 -12
  18. package/dist/agent/tool-turn.js +87 -0
  19. package/dist/agent/trace-state.js +97 -101
  20. package/dist/agent/turn-lifecycle.js +110 -0
  21. package/dist/config/index.js +14 -0
  22. package/dist/host/stdio.js +101 -40
  23. package/dist/llm/index.js +51 -35
  24. package/dist/llm/providers/anthropic.js +16 -10
  25. package/dist/llm/runtime.js +1 -0
  26. package/dist/permissions/index.js +21 -5
  27. package/dist/repl/commands/compact.js +2 -2
  28. package/dist/repl/commands/session.js +3 -12
  29. package/dist/repl/message-format.js +5 -0
  30. package/dist/repl/runtime.js +95 -55
  31. package/dist/rollback/index.js +29 -624
  32. package/dist/rollback/store.js +593 -0
  33. package/dist/runtime/index.js +1 -0
  34. package/dist/runtime/runtime.js +307 -0
  35. package/dist/session/compact.js +22 -14
  36. package/dist/session/index.js +1 -0
  37. package/dist/session/persist.js +10 -146
  38. package/dist/session/scheduler.js +28 -16
  39. package/dist/session/state.js +16 -12
  40. package/dist/session/store.js +218 -0
  41. package/dist/session/trace.js +5 -15
  42. package/dist/tools/policy.js +19 -15
  43. package/dist/tools/registry.js +21 -229
  44. package/dist/tools/router.js +5 -3
  45. package/dist/tools/tool-runtime.js +267 -0
  46. package/dist/ui/layout-internal/content-write.js +4 -0
  47. package/package.json +7 -3
@@ -1,655 +1,60 @@
1
- import { createHash } from 'node:crypto';
2
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs';
3
- import * as fsp from 'node:fs/promises';
4
- import path from 'node:path';
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
5
2
  import { config } from '../config/index.js';
6
- import { truncateDisplay } from '../ui/render.js';
7
- import { toText } from '../context/utils.js';
8
- let turnIdCounter = 0;
9
- let currentTurnId = 0;
10
- let sequenceCounter = 0;
11
- /** Monotonic process-local generation; repeated writes to one path still invalidate validation. */
12
- let mutationVersion = 0;
13
- let turns = [];
14
- let snapshots = [];
15
- const rootDir = () => path.resolve(process.cwd());
16
- function isInside(parent, child) {
17
- const rel = path.relative(parent, child);
18
- return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
3
+ import { RollbackStore } from './store.js';
4
+ export { RollbackStore } from './store.js';
5
+ /** 默认兼容实例继续动态读取 process.cwd() 与 config.sessionDir。 */
6
+ export const defaultRollbackStore = new RollbackStore(() => process.cwd(), () => config.sessionDir);
7
+ const activeRollbackStores = new AsyncLocalStorage();
8
+ /** 当前异步 runtime 树使用的 rollback store;无 scope 时回退默认兼容实例。 */
9
+ export function getActiveRollbackStore() {
10
+ return activeRollbackStores.getStore() ?? defaultRollbackStore;
11
+ }
12
+ /** 将旧 rollback 函数 API 绑定到指定实例,使未显式注入的内部调用也保持 runtime-local。 */
13
+ export function withRollbackStore(store, run) {
14
+ return activeRollbackStores.run(store, run);
19
15
  }
20
- /** 规整成 cwd 相对路径;所有持久化快照均使用此格式。 */
21
- function toRel(p) {
22
- try {
23
- const rel = path.relative(rootDir(), path.resolve(p));
24
- return rel === '' ? '.' : rel;
25
- }
26
- catch {
27
- return p;
28
- }
29
- }
30
- /** 防止损坏/篡改的 snapshots.json 在恢复时写出工作区。 */
31
- function safeFullPath(rel) {
32
- const root = rootDir();
33
- const full = path.resolve(root, rel);
34
- return full !== root && isInside(root, full) ? full : null;
35
- }
36
- function readState(full) {
37
- try {
38
- const stat = lstatSync(full);
39
- const mode = stat.mode & 0o777;
40
- if (stat.isSymbolicLink()) {
41
- return { kind: 'symlink', data: readlinkSync(full), mode };
42
- }
43
- if (stat.isDirectory())
44
- return { kind: 'directory', mode };
45
- if (stat.isFile()) {
46
- return { kind: 'file', data: readFileSync(full).toString('base64'), mode };
47
- }
48
- }
49
- catch {
50
- // 不存在或不可读均按 missing;工具若最终也不可读,不会产生伪变化。
51
- }
52
- return { kind: 'missing' };
53
- }
54
- /**
55
- * 状态等价判定。两侧都捕获了内容时按内容比(与旧行为一致,精确);
56
- * 任一侧内容未捕获(超预算的大文件)时退化为 stamp 比较——size+mtimeNs 变了就算变。
57
- */
58
- function sameState(a, b) {
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;
67
- }
68
- function stateFingerprint(state) {
69
- return createHash('sha256')
70
- .update(JSON.stringify([state.kind, state.data ?? null, state.mode ?? null]))
71
- .digest('hex');
72
- }
73
- function stateFromSnapshot(snapshot) {
74
- if (snapshot.kind) {
75
- return { kind: snapshot.kind, data: snapshot.before ?? undefined, mode: snapshot.mode };
76
- }
77
- // v1 向后兼容:before 是 UTF-8 文本,null 表示原文件不存在。
78
- if (snapshot.before === null)
79
- return { kind: 'missing' };
80
- return {
81
- kind: 'file',
82
- data: Buffer.from(snapshot.before, 'utf8').toString('base64'),
83
- };
84
- }
85
- function snapshotFromState(rel, state, sequence, op, createdParents = [], after) {
86
- return {
87
- turnId: currentTurnId,
88
- path: rel,
89
- before: state.data ?? null,
90
- kind: state.kind,
91
- encoding: state.kind === 'file' ? 'base64' : undefined,
92
- mode: state.mode,
93
- sequence,
94
- ops: [op],
95
- createdParents: createdParents.length > 0 ? createdParents : undefined,
96
- afterFingerprint: after ? stateFingerprint(after) : undefined,
97
- // 文件但没有内容 = 工作区扫描时超出捕获预算,只能报告"变了",不能拿它覆盖磁盘。
98
- contentUnavailable: state.kind === 'file' && state.data === undefined ? true : undefined,
99
- };
100
- }
101
- /** 同轮同路径只保留最早的 before;后续实际改动仅合并工具名。 */
102
- function addSnapshot(next) {
103
- if (next.turnId <= 0)
104
- return;
105
- const existingIndex = snapshots.findIndex((item) => item.turnId === next.turnId && item.path === next.path);
106
- if (existingIndex < 0) {
107
- snapshots.push(next);
108
- return;
109
- }
110
- const existing = snapshots[existingIndex];
111
- const existingSequence = existing.sequence ?? Number.MAX_SAFE_INTEGER;
112
- const nextSequence = next.sequence ?? Number.MAX_SAFE_INTEGER;
113
- const ops = new Set([...(existing.ops ?? []), ...(next.ops ?? [])]);
114
- const latestAfterFingerprint = nextSequence >= existingSequence ? next.afterFingerprint : existing.afterFingerprint;
115
- if (nextSequence < existingSequence) {
116
- snapshots[existingIndex] = { ...next, ops: [...ops], afterFingerprint: latestAfterFingerprint };
117
- }
118
- else {
119
- existing.ops = [...ops];
120
- existing.afterFingerprint = latestAfterFingerprint;
121
- }
122
- }
123
- function missingParents(full) {
124
- const root = rootDir();
125
- const result = [];
126
- let current = path.dirname(full);
127
- while (current !== root && isInside(root, current)) {
128
- if (existsSync(current))
129
- break;
130
- result.push(toRel(current));
131
- current = path.dirname(current);
132
- }
133
- return result;
134
- }
135
- /** agent 主轮入口调用;子 agent 共享当前 turnId,不另开轮次。 */
136
16
  export function beginTurn(firstLine) {
137
- turnIdCounter += 1;
138
- currentTurnId = turnIdCounter;
139
- turns.push({ turnId: currentTurnId, firstLine });
140
- return currentTurnId;
17
+ return getActiveRollbackStore().beginTurn(firstLine);
141
18
  }
142
- /** Stable identity shared by tracing, validation, and rollback for the active main turn. */
143
19
  export function getCurrentTurnId() {
144
- return currentTurnId;
20
+ return getActiveRollbackStore().getCurrentTurnId();
145
21
  }
146
- /** 单路径工具执行前捕获,不立即记账;失败/no-op 不应出现在 rollback 中。 */
147
- export function beginPathMutation(p) {
148
- const full = path.resolve(p);
149
- return {
150
- path: toRel(full),
151
- before: readState(full),
152
- sequence: ++sequenceCounter,
153
- createdParents: missingParents(full),
154
- };
22
+ export function beginPathMutation(path) {
23
+ return getActiveRollbackStore().beginPathMutation(path);
155
24
  }
156
- /** 单路径工具执行后提交,仅当磁盘状态确实变化时写入事务日志。 */
157
25
  export function endPathMutation(capture, op) {
158
- const full = safeFullPath(capture.path);
159
- if (!full)
160
- return;
161
- let changed = false;
162
- const after = readState(full);
163
- if (!sameState(capture.before, after)) {
164
- changed = true;
165
- addSnapshot(snapshotFromState(capture.path, capture.before, capture.sequence, op, capture.createdParents, after));
166
- }
167
- // write_file 会递归创建父目录;即使最终写文件失败,这些目录也是本轮真实副作用。
168
- for (const parentRel of capture.createdParents) {
169
- const parent = safeFullPath(parentRel);
170
- if (parent && readState(parent).kind !== 'missing') {
171
- changed = true;
172
- addSnapshot(snapshotFromState(parentRel, { kind: 'missing' }, capture.sequence, op, [], readState(parent)));
173
- }
174
- }
175
- if (changed)
176
- mutationVersion += 1;
177
- }
178
- // 构建产物 / 依赖树 / 缓存 / 运行时状态目录:可再生,扫描它们既昂贵也易把后台 daemon、
179
- // 打包器、mocode 自身(会话日志 / dev-server 日志 / 截图)的写入误判成模型改动。
180
- // 回滚本就只应覆盖源码。
181
- const EXCLUDED_WORKSPACE_DIRS = new Set([
182
- // VCS / 索引 / mocode 自身运行时状态(会话、trace、dev-server 日志、记忆、截图每轮都在写,
183
- // 既无回滚意义,又会被误判成模型改动)
184
- '.git',
185
- '.hg',
186
- '.svn',
187
- '.codegraph',
188
- '.mocode',
189
- // 依赖树与包管理器缓存
190
- 'node_modules',
191
- 'vendor',
192
- 'bower_components',
193
- '.yarn',
194
- '.pnpm-store',
195
- '.venv',
196
- 'venv',
197
- 'pods',
198
- // 构建产物
199
- 'dist',
200
- 'build',
201
- 'out',
202
- 'target',
203
- 'coverage',
204
- '.output',
205
- '.next',
206
- '.nuxt',
207
- '.vite',
208
- '.turbo',
209
- '.svelte-kit',
210
- '.angular',
211
- '.astro',
212
- '.docusaurus',
213
- '.dart_tool',
214
- '.terraform',
215
- // 临时与缓存
216
- '.tmp',
217
- 'tmp',
218
- '.cache',
219
- '.parcel-cache',
220
- '.nyc_output',
221
- '__pycache__',
222
- '.pytest_cache',
223
- '.mypy_cache',
224
- '.ruff_cache',
225
- '.gradle',
226
- ]);
227
- /** 单文件内容捕获上限:更大的文件只留 stamp(可检测变化,不可恢复),避免把巨型二进制读进内存。 */
228
- const CAPTURE_FILE_LIMIT = 1024 * 1024;
229
- /** 单次扫描的内容总预算:超出后剩余文件只留 stamp。 */
230
- const CAPTURE_TOTAL_LIMIT = 32 * 1024 * 1024;
231
- /** 条目上限:超大工作区不做无边界遍历(超出部分不参与变更检测)。 */
232
- const CAPTURE_ENTRY_LIMIT = 20000;
233
- /** 并发文件操作数:冷缓存(尤其 Windows 杀软逐文件扫描)下 I/O 重叠远快于串行。 */
234
- const SCAN_CONCURRENCY = 16;
235
- /** 让出事件循环的节奏:每 N 个条目,或每累计编码 M 字节(base64 是纯 CPU,大文件靠字节数兜底)。 */
236
- const YIELD_EVERY = 64;
237
- const YIELD_BYTES = 1024 * 1024;
238
- /**
239
- * 刚被写过的文件不信缓存,强制重读内容。
240
- * 原因:stamp 依赖 mtime 精度。NTFS/ext4/APFS 是 100ns~ns 级,但 exFAT / 部分网络盘只有 1~2s,
241
- * 那里一条"同尺寸原地改写"可能与快照前共享同一时间戳,只比 stamp 会漏掉真实变化。
242
- * 只对最近 2s 内改动的文件付重读代价(通常正是命令刚碰过的那几个),开销可忽略。
243
- */
244
- const FRESH_WINDOW_MS = 2000;
245
- /** path → 上次扫描捕获的内容。stamp(size+mtimeNs)未变即复用,未改动的文件不重复读盘。 */
246
- let contentCache = new Map();
247
- let cachedSessionDir = '';
248
- let resolvedSessionDir = '';
249
- function sessionDirAbs() {
250
- if (config.sessionDir !== cachedSessionDir) {
251
- cachedSessionDir = config.sessionDir;
252
- resolvedSessionDir = path.resolve(config.sessionDir);
253
- }
254
- return resolvedSessionDir;
255
- }
256
- function isWorkspaceExcluded(full) {
257
- const base = path.basename(full).toLowerCase();
258
- // Git 元数据必须永久排除以保护 index;依赖树/代码索引/构建产物/临时目录是可再生运行时状态。
259
- if (EXCLUDED_WORKSPACE_DIRS.has(base))
260
- return true;
261
- return isInside(sessionDirAbs(), full);
262
- }
263
- const yieldToEventLoop = () => new Promise((resolve) => setImmediate(resolve));
264
- /**
265
- * 工作区快照(run_command / dev_server / MCP 等不透明工具用)。
266
- *
267
- * **必须是异步且带缓存的**:本函数在每次这类工具调用前后各跑一次,直接坐在用户交互
268
- * 路径上。旧实现用 readdirSync + 全量 readFileSync(base64) 同步遍历整棵工作树 ——
269
- * 冷文件缓存下 400 个文件实测就要 ~3s(Windows),几千个文件即数十秒,期间事件循环
270
- * 完全阻塞:spinner 冻结、走时停摆、键鼠无响应,用户看到的就是「卡在 执行 run_command」。
271
- *
272
- * 现在:
273
- * 1) fs/promises + 有界并发 + 定期 setImmediate 让出 → 事件循环全程可呼吸;
274
- * 2) 内容按 (size, mtimeNs, mode) 缓存复用 → 未改动的文件不再重复读盘,
275
- * 一次会话里只有首次扫描付全量代价,之后只读真正变化的文件;
276
- * 3) 单文件 / 总量 / 条目数三重预算 → 巨型仓库不会把内存和时间吃穿。
277
- */
278
- async function scanWorkspace() {
279
- const entries = new Map();
280
- const nextCache = new Map();
281
- const paths = [];
282
- // 第一趟:只 readdir 收集条目(廉价,不读内容)。目录本身即刻记账,顺序稳定。
283
- const walk = async (dir) => {
284
- if (paths.length >= CAPTURE_ENTRY_LIMIT)
285
- return;
286
- let children;
287
- try {
288
- children = await fsp.readdir(dir, { withFileTypes: true });
289
- }
290
- catch {
291
- return;
292
- }
293
- for (const child of children) {
294
- if (paths.length >= CAPTURE_ENTRY_LIMIT)
295
- return;
296
- const full = path.join(dir, child.name);
297
- if (isWorkspaceExcluded(full))
298
- continue;
299
- paths.push(full);
300
- // isDirectory() 对 symlink 为 false —— 与旧实现一致:不跟随软链。
301
- if (child.isDirectory())
302
- await walk(full);
303
- }
304
- };
305
- await walk(rootDir());
306
- // 第二趟:有界并发读状态。budget 由完成顺序消费——两次扫描间某个大文件是否被捕获
307
- // 可能不同,sameState 在任一侧缺内容时退化为 stamp 比较,故不会产生伪变化。
308
- let budget = CAPTURE_TOTAL_LIMIT;
309
- let cursor = 0;
310
- let processed = 0;
311
- let bytesSinceYield = 0;
312
- const worker = async () => {
313
- while (cursor < paths.length) {
314
- const full = paths[cursor++];
315
- if (++processed % YIELD_EVERY === 0 || bytesSinceYield >= YIELD_BYTES) {
316
- bytesSinceYield = 0;
317
- await yieldToEventLoop();
318
- }
319
- let state;
320
- try {
321
- const stat = await fsp.lstat(full, { bigint: true });
322
- const mode = Number(stat.mode) & 0o777;
323
- if (stat.isSymbolicLink()) {
324
- const target = await fsp.readlink(full);
325
- state = { kind: 'symlink', data: target, stamp: target, mode };
326
- }
327
- else if (stat.isDirectory()) {
328
- state = { kind: 'directory', mode, stamp: '' };
329
- }
330
- else if (stat.isFile()) {
331
- const size = Number(stat.size);
332
- const stamp = `${size}:${stat.mtimeNs}`;
333
- // 未来时间戳(时钟偏移)同样按"新鲜"处理 —— 宁可多读一次,不可漏判变化。
334
- const fresh = Date.now() - Number(stat.mtimeNs / 1000000n) < FRESH_WINDOW_MS;
335
- const cached = fresh ? undefined : contentCache.get(full);
336
- if (cached && cached.stamp === stamp && cached.mode === mode) {
337
- budget -= size; // 命中也计预算,保证冷/热缓存下的捕获集合一致
338
- state = { kind: 'file', data: cached.data, stamp, mode };
339
- nextCache.set(full, cached);
340
- }
341
- else if (size <= CAPTURE_FILE_LIMIT && budget - size >= 0) {
342
- budget -= size;
343
- bytesSinceYield += size;
344
- const data = (await fsp.readFile(full)).toString('base64');
345
- state = { kind: 'file', data, stamp, mode };
346
- nextCache.set(full, { stamp, mode, data });
347
- }
348
- else {
349
- // 超大文件 / 预算耗尽:只留指纹,能报告变化但不参与内容恢复。
350
- state = { kind: 'file', stamp, mode };
351
- }
352
- }
353
- else {
354
- state = { kind: 'missing' };
355
- }
356
- }
357
- catch {
358
- state = { kind: 'missing' }; // 不存在或不可读:工具若最终也不可读,不会产生伪变化
359
- }
360
- if (state.kind === 'missing')
361
- continue;
362
- entries.set(toRel(full), state);
363
- }
364
- };
365
- await Promise.all(Array.from({ length: Math.min(SCAN_CONCURRENCY, Math.max(1, paths.length)) }, worker));
366
- // 缓存整体替换:已删除的文件自然被淘汰,内存上限 ≈ CAPTURE_TOTAL_LIMIT。
367
- contentCache = nextCache;
368
- return entries;
26
+ getActiveRollbackStore().endPathMutation(capture, op);
369
27
  }
370
- /** run_command/MCP 前调用。不跟随 symlink,并排除 .git、会话快照、依赖树与代码索引。 */
371
28
  export async function beginWorkspaceMutation() {
372
- return { sequence: ++sequenceCounter, entries: await scanWorkspace() };
29
+ return getActiveRollbackStore().beginWorkspaceMutation();
373
30
  }
374
- /** 不透明工具执行后比较整个工作区,把实际变化压入当前轮事务日志。 */
375
31
  export async function endWorkspaceMutation(capture, op) {
376
- const after = await scanWorkspace();
377
- const paths = new Set([...capture.entries.keys(), ...after.keys()]);
378
- let changed = false;
379
- for (const rel of paths) {
380
- const beforeState = capture.entries.get(rel) ?? { kind: 'missing' };
381
- const afterState = after.get(rel) ?? { kind: 'missing' };
382
- if (sameState(beforeState, afterState))
383
- continue;
384
- changed = true;
385
- addSnapshot(snapshotFromState(rel, beforeState, capture.sequence, op, [], afterState));
386
- }
387
- if (changed)
388
- mutationVersion += 1;
32
+ await getActiveRollbackStore().endWorkspaceMutation(capture, op);
389
33
  }
390
- /** Current main turn changes, deduplicated by path, plus a generation for validation invalidation. */
391
34
  export function getCurrentTurnMutationState() {
392
- const order = [];
393
- const byPath = new Map();
394
- for (const snapshot of snapshots) {
395
- if (snapshot.turnId !== currentTurnId)
396
- continue;
397
- let change = byPath.get(snapshot.path);
398
- if (!change) {
399
- change = {
400
- path: snapshot.path,
401
- ops: [],
402
- snapshotAvailable: snapshot.contentUnavailable !== true,
403
- };
404
- byPath.set(snapshot.path, change);
405
- order.push(snapshot.path);
406
- }
407
- if (snapshot.contentUnavailable)
408
- change.snapshotAvailable = false;
409
- for (const op of snapshot.ops ?? ['file_change']) {
410
- if (!change.ops.includes(op))
411
- change.ops.push(op);
412
- }
413
- }
414
- return { version: mutationVersion, changedFiles: order.map((item) => byPath.get(item)) };
35
+ return getActiveRollbackStore().getCurrentTurnMutationState();
415
36
  }
416
37
  export function listTurns() {
417
- return turns.slice();
38
+ return getActiveRollbackStore().listTurns();
418
39
  }
419
- function findCutoffIndex(n, history) {
420
- let seen = 0;
421
- for (let i = 0; i < history.length; i++) {
422
- if (history[i].role === 'user') {
423
- seen += 1;
424
- if (seen === n + 1)
425
- return i;
426
- }
427
- }
428
- return history.length;
429
- }
430
- /**
431
- * 规划回滚到第 n 轮。changes 只来自已确认发生磁盘差异的事务快照,
432
- * 因此失败/no-op 工具不会被误报;子 agent、run_command、MCP 变化同样可见。
433
- */
434
40
  export function planRollback(n, history) {
435
- const cutoffTurnId = turns[n - 1]?.turnId ?? 0;
436
- const cutoffIndex = findCutoffIndex(n, history);
437
- const order = [];
438
- const map = new Map();
439
- const ensure = (rel) => {
440
- let change = map.get(rel);
441
- if (!change) {
442
- change = { path: rel, ops: [], snapshotAvailable: true };
443
- map.set(rel, change);
444
- order.push(rel);
445
- }
446
- return change;
447
- };
448
- for (const snapshot of snapshots) {
449
- if (snapshot.turnId <= cutoffTurnId)
450
- continue;
451
- const change = ensure(snapshot.path);
452
- if (snapshot.contentUnavailable)
453
- change.snapshotAvailable = false;
454
- for (const op of snapshot.ops ?? ['file_change']) {
455
- if (!change.ops.includes(op))
456
- change.ops.push(op);
457
- }
458
- }
459
- return {
460
- n,
461
- cutoffIndex,
462
- cutoffTurnId,
463
- changes: order.map((rel) => map.get(rel)),
464
- };
465
- }
466
- function depth(rel) {
467
- return rel.split(/[\\/]+/).length;
41
+ return getActiveRollbackStore().planRollback(n, history);
468
42
  }
469
- function restoreSnapshot(snapshot) {
470
- const full = safeFullPath(snapshot.path);
471
- if (!full)
472
- return false;
473
- // 内容未捕获(工作区扫描时超出预算):宁可报冲突,也不能拿空内容覆盖用户文件。
474
- if (snapshot.contentUnavailable)
475
- return false;
476
- const state = stateFromSnapshot(snapshot);
477
- try {
478
- if (state.kind === 'missing') {
479
- const current = readState(full);
480
- if (current.kind === 'directory')
481
- rmdirSync(full);
482
- else
483
- rmSync(full, { recursive: false, force: true });
484
- for (const parentRel of snapshot.createdParents ?? []) {
485
- const parent = safeFullPath(parentRel);
486
- if (!parent)
487
- continue;
488
- try {
489
- rmdirSync(parent);
490
- }
491
- catch {
492
- // 仅删除本轮创建且当前为空的父目录;非空/已不存在均保持。
493
- }
494
- }
495
- return true;
496
- }
497
- if (state.kind === 'directory') {
498
- const current = readState(full);
499
- if (current.kind !== 'missing' && current.kind !== 'directory') {
500
- rmSync(full, { recursive: true, force: true });
501
- }
502
- mkdirSync(full, { recursive: true });
503
- }
504
- else {
505
- mkdirSync(path.dirname(full), { recursive: true });
506
- rmSync(full, { recursive: true, force: true });
507
- if (state.kind === 'file') {
508
- writeFileSync(full, Buffer.from(state.data ?? '', 'base64'));
509
- }
510
- else {
511
- symlinkSync(state.data ?? '', full);
512
- }
513
- }
514
- if (state.mode !== undefined && state.kind !== 'symlink')
515
- chmodSync(full, state.mode);
516
- return true;
517
- }
518
- catch {
519
- return false;
520
- }
521
- }
522
- /** 执行回滚:恢复工作树快照并截断对话/事务;从不调用 Git。 */
523
43
  export function applyRollback(plan, history, revertPaths) {
524
- const deletedMsgs = history.length - plan.cutoffIndex;
525
- history.length = plan.cutoffIndex;
526
- const picks = new Map();
527
- const latest = new Map();
528
- for (const snapshot of snapshots) {
529
- if (snapshot.turnId <= plan.cutoffTurnId || !revertPaths.has(snapshot.path))
530
- continue;
531
- const latestSnapshot = latest.get(snapshot.path);
532
- if (!latestSnapshot ||
533
- snapshot.turnId > latestSnapshot.turnId ||
534
- (snapshot.turnId === latestSnapshot.turnId && (snapshot.sequence ?? -1) > (latestSnapshot.sequence ?? -1))) {
535
- latest.set(snapshot.path, snapshot);
536
- }
537
- const existing = picks.get(snapshot.path);
538
- if (!existing ||
539
- snapshot.turnId < existing.turnId ||
540
- (snapshot.turnId === existing.turnId &&
541
- (snapshot.sequence ?? Number.MAX_SAFE_INTEGER) < (existing.sequence ?? Number.MAX_SAFE_INTEGER))) {
542
- picks.set(snapshot.path, snapshot);
543
- }
544
- }
545
- const selected = [];
546
- const conflictedFiles = [];
547
- for (const snapshot of picks.values()) {
548
- const expected = latest.get(snapshot.path)?.afterFingerprint;
549
- const full = safeFullPath(snapshot.path);
550
- if (expected && (!full || stateFingerprint(readState(full)) !== expected)) {
551
- conflictedFiles.push(snapshot.path);
552
- }
553
- else {
554
- selected.push(snapshot);
555
- }
556
- }
557
- // 先深到浅删除本轮新建项,再浅到深恢复原目录/文件。
558
- const removals = selected
559
- .filter((item) => stateFromSnapshot(item).kind === 'missing')
560
- .sort((a, b) => depth(b.path) - depth(a.path));
561
- const restores = selected
562
- .filter((item) => stateFromSnapshot(item).kind !== 'missing')
563
- .sort((a, b) => depth(a.path) - depth(b.path));
564
- const revertedFiles = [];
565
- for (const snapshot of [...removals, ...restores]) {
566
- if (restoreSnapshot(snapshot))
567
- revertedFiles.push(snapshot.path);
568
- else if (!conflictedFiles.includes(snapshot.path))
569
- conflictedFiles.push(snapshot.path);
570
- }
571
- turns = turns.filter((turn) => turn.turnId <= plan.cutoffTurnId);
572
- snapshots = snapshots.filter((snapshot) => snapshot.turnId <= plan.cutoffTurnId);
573
- currentTurnId = turns.at(-1)?.turnId ?? 0;
574
- return { deletedMsgs, revertedFiles, conflictedFiles };
44
+ return getActiveRollbackStore().applyRollback(plan, history, revertPaths);
575
45
  }
576
46
  export function pruneAfterCompaction(history) {
577
- const count = history.filter((message) => message.role === 'user').length;
578
- turns = count >= turns.length ? turns : turns.slice(-count);
579
- const alive = new Set(turns.map((turn) => turn.turnId));
580
- snapshots = snapshots.filter((snapshot) => alive.has(snapshot.turnId));
47
+ getActiveRollbackStore().pruneAfterCompaction(history);
581
48
  }
582
49
  export function resetState() {
583
- turns = [];
584
- snapshots = [];
585
- turnIdCounter = 0;
586
- currentTurnId = 0;
587
- sequenceCounter = 0;
588
- contentCache = new Map();
50
+ getActiveRollbackStore().resetState();
589
51
  }
590
52
  export function rebuildFromHistory(history) {
591
- const rebuilt = [];
592
- for (const message of history) {
593
- if (message.role !== 'user')
594
- continue;
595
- const first = toText(message.content).split('\n')[0] ?? '';
596
- rebuilt.push({
597
- turnId: rebuilt.length + 1,
598
- firstLine: truncateDisplay(first, 40),
599
- });
600
- }
601
- turns = rebuilt;
602
- snapshots = [];
603
- turnIdCounter = rebuilt.length;
604
- currentTurnId = 0;
605
- sequenceCounter = 0;
606
- }
607
- function snapshotsPath(id) {
608
- const current = path.join(config.sessionDir, id, 'snapshots.json');
609
- if (existsSync(current))
610
- return current;
611
- return path.join(config.sessionDir, `${id}.snapshots.json`);
53
+ getActiveRollbackStore().rebuildFromHistory(history);
612
54
  }
613
55
  export function persistSnapshots(id) {
614
- const dir = path.join(config.sessionDir, id);
615
- const current = path.join(dir, 'snapshots.json');
616
- const legacy = path.join(config.sessionDir, `${id}.snapshots.json`);
617
- try {
618
- if (turns.length === 0) {
619
- // 全量回滚后不能保留旧快照,否则 /resume 可能重新加载已删除轮次。
620
- if (existsSync(current))
621
- unlinkSync(current);
622
- if (existsSync(legacy))
623
- unlinkSync(legacy);
624
- return;
625
- }
626
- mkdirSync(dir, { recursive: true });
627
- writeFileSync(current, JSON.stringify({ version: 2, turns, snapshots }), 'utf8');
628
- // 迁移后的旧式扁平快照不再需要,避免磁盘上残留已回滚内容。
629
- if (existsSync(legacy))
630
- unlinkSync(legacy);
631
- }
632
- catch {
633
- // 落盘失败不阻断会话;只失去跨重启回滚能力。
634
- }
56
+ getActiveRollbackStore().persistSnapshots(id);
635
57
  }
636
58
  export function loadSnapshots(id) {
637
- const snapshotFile = snapshotsPath(id);
638
- if (!existsSync(snapshotFile))
639
- return false;
640
- try {
641
- const record = JSON.parse(readFileSync(snapshotFile, 'utf8'));
642
- if (!record || !Array.isArray(record.turns) || !Array.isArray(record.snapshots)) {
643
- return false;
644
- }
645
- turns = record.turns;
646
- snapshots = record.snapshots;
647
- turnIdCounter = turns.reduce((max, turn) => Math.max(max, turn.turnId), 0);
648
- sequenceCounter = snapshots.reduce((max, snapshot) => Math.max(max, snapshot.sequence ?? 0), 0);
649
- currentTurnId = 0;
650
- return true;
651
- }
652
- catch {
653
- return false;
654
- }
59
+ return getActiveRollbackStore().loadSnapshots(id);
655
60
  }