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.
- package/README.md +13 -1
- package/dist/agent/core.js +14 -936
- package/dist/agent/index.js +37 -13
- package/dist/agent/model-turn.js +218 -0
- package/dist/agent/pipeline.js +18 -0
- package/dist/agent/run-contracts.js +1 -0
- package/dist/agent/run-coordinator.js +758 -0
- package/dist/agent/runtime-context.js +118 -24
- package/dist/agent/spawn.js +11 -7
- package/dist/agent/stages/context-trimmer.js +63 -0
- package/dist/agent/stages/contracts.js +12 -0
- package/dist/agent/stages/history-manager.js +178 -0
- package/dist/agent/stages/legacy-adapters.js +19 -0
- package/dist/agent/stages/model-runner.js +29 -0
- package/dist/agent/stages/run-policy.js +73 -0
- package/dist/agent/stages/tool-dispatcher.js +341 -0
- package/dist/agent/tool-helpers.js +12 -12
- package/dist/agent/tool-turn.js +87 -0
- package/dist/agent/trace-state.js +97 -101
- package/dist/agent/turn-lifecycle.js +110 -0
- package/dist/config/index.js +14 -0
- package/dist/host/stdio.js +101 -40
- package/dist/llm/index.js +51 -35
- package/dist/llm/providers/anthropic.js +16 -10
- package/dist/llm/runtime.js +1 -0
- package/dist/permissions/index.js +21 -5
- package/dist/repl/commands/compact.js +2 -2
- package/dist/repl/commands/session.js +3 -12
- package/dist/repl/message-format.js +5 -0
- package/dist/repl/runtime.js +95 -55
- package/dist/rollback/index.js +29 -624
- package/dist/rollback/store.js +593 -0
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/runtime.js +307 -0
- package/dist/session/compact.js +22 -14
- package/dist/session/index.js +1 -0
- package/dist/session/persist.js +10 -146
- package/dist/session/scheduler.js +28 -16
- package/dist/session/state.js +16 -12
- package/dist/session/store.js +218 -0
- package/dist/session/trace.js +5 -15
- package/dist/tools/policy.js +19 -15
- package/dist/tools/registry.js +21 -229
- package/dist/tools/router.js +5 -3
- package/dist/tools/tool-runtime.js +267 -0
- package/dist/ui/layout-internal/content-write.js +4 -0
- package/package.json +7 -3
|
@@ -0,0 +1,593 @@
|
|
|
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';
|
|
5
|
+
import { config } from '../config/index.js';
|
|
6
|
+
import { toText } from '../context/utils.js';
|
|
7
|
+
import { truncateDisplay } from '../ui/render.js';
|
|
8
|
+
const EXCLUDED_WORKSPACE_DIRS = Object.freeze([
|
|
9
|
+
'.git',
|
|
10
|
+
'.hg',
|
|
11
|
+
'.svn',
|
|
12
|
+
'.codegraph',
|
|
13
|
+
'.mocode',
|
|
14
|
+
'node_modules',
|
|
15
|
+
'vendor',
|
|
16
|
+
'bower_components',
|
|
17
|
+
'.yarn',
|
|
18
|
+
'.pnpm-store',
|
|
19
|
+
'.venv',
|
|
20
|
+
'venv',
|
|
21
|
+
'pods',
|
|
22
|
+
'dist',
|
|
23
|
+
'build',
|
|
24
|
+
'out',
|
|
25
|
+
'target',
|
|
26
|
+
'coverage',
|
|
27
|
+
'.output',
|
|
28
|
+
'.next',
|
|
29
|
+
'.nuxt',
|
|
30
|
+
'.vite',
|
|
31
|
+
'.turbo',
|
|
32
|
+
'.svelte-kit',
|
|
33
|
+
'.angular',
|
|
34
|
+
'.astro',
|
|
35
|
+
'.docusaurus',
|
|
36
|
+
'.dart_tool',
|
|
37
|
+
'.terraform',
|
|
38
|
+
'.tmp',
|
|
39
|
+
'tmp',
|
|
40
|
+
'.cache',
|
|
41
|
+
'.parcel-cache',
|
|
42
|
+
'.nyc_output',
|
|
43
|
+
'__pycache__',
|
|
44
|
+
'.pytest_cache',
|
|
45
|
+
'.mypy_cache',
|
|
46
|
+
'.ruff_cache',
|
|
47
|
+
'.gradle',
|
|
48
|
+
]);
|
|
49
|
+
const CAPTURE_FILE_LIMIT = 1024 * 1024;
|
|
50
|
+
const CAPTURE_TOTAL_LIMIT = 32 * 1024 * 1024;
|
|
51
|
+
const CAPTURE_ENTRY_LIMIT = 20000;
|
|
52
|
+
const SCAN_CONCURRENCY = 16;
|
|
53
|
+
const YIELD_EVERY = 64;
|
|
54
|
+
const YIELD_BYTES = 1024 * 1024;
|
|
55
|
+
const FRESH_WINDOW_MS = 2000;
|
|
56
|
+
/**
|
|
57
|
+
* 一个完整、实例隔离的回滚事务存储。每个 runtime 可持有独立实例,使轮次、序号、
|
|
58
|
+
* 快照、mutation generation 和扫描缓存互不干扰。
|
|
59
|
+
*/
|
|
60
|
+
export class RollbackStore {
|
|
61
|
+
turnIdCounter = 0;
|
|
62
|
+
currentTurnId = 0;
|
|
63
|
+
sequenceCounter = 0;
|
|
64
|
+
mutationVersion = 0;
|
|
65
|
+
turns = [];
|
|
66
|
+
snapshots = [];
|
|
67
|
+
contentCache = new Map();
|
|
68
|
+
workspaceRootProvider;
|
|
69
|
+
sessionsRootProvider;
|
|
70
|
+
constructor(workspaceRoot = () => process.cwd(), sessionsRoot = () => config.sessionDir) {
|
|
71
|
+
this.workspaceRootProvider = this.rootProvider(workspaceRoot);
|
|
72
|
+
this.sessionsRootProvider = this.rootProvider(sessionsRoot);
|
|
73
|
+
}
|
|
74
|
+
rootProvider(source) {
|
|
75
|
+
if (typeof source === 'string') {
|
|
76
|
+
const fixed = path.resolve(source);
|
|
77
|
+
return () => fixed;
|
|
78
|
+
}
|
|
79
|
+
return () => path.resolve(source());
|
|
80
|
+
}
|
|
81
|
+
rootDir() {
|
|
82
|
+
return this.workspaceRootProvider();
|
|
83
|
+
}
|
|
84
|
+
sessionsDir() {
|
|
85
|
+
return this.sessionsRootProvider();
|
|
86
|
+
}
|
|
87
|
+
yieldToEventLoop() {
|
|
88
|
+
return new Promise((resolve) => setImmediate(resolve));
|
|
89
|
+
}
|
|
90
|
+
isInside(parent, child) {
|
|
91
|
+
const rel = path.relative(parent, child);
|
|
92
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
93
|
+
}
|
|
94
|
+
toRel(root, value) {
|
|
95
|
+
try {
|
|
96
|
+
const rel = path.relative(root, path.resolve(root, value));
|
|
97
|
+
return rel === '' ? '.' : rel;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
safeFullPath(root, rel) {
|
|
104
|
+
const full = path.resolve(root, rel);
|
|
105
|
+
return full !== root && this.isInside(root, full) ? full : null;
|
|
106
|
+
}
|
|
107
|
+
readState(full) {
|
|
108
|
+
try {
|
|
109
|
+
const stat = lstatSync(full);
|
|
110
|
+
const mode = stat.mode & 0o777;
|
|
111
|
+
if (stat.isSymbolicLink())
|
|
112
|
+
return { kind: 'symlink', data: readlinkSync(full), mode };
|
|
113
|
+
if (stat.isDirectory())
|
|
114
|
+
return { kind: 'directory', mode };
|
|
115
|
+
if (stat.isFile())
|
|
116
|
+
return { kind: 'file', data: readFileSync(full).toString('base64'), mode };
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// 不存在或不可读均按 missing;工具若最终也不可读,不会产生伪变化。
|
|
120
|
+
}
|
|
121
|
+
return { kind: 'missing' };
|
|
122
|
+
}
|
|
123
|
+
sameState(a, b) {
|
|
124
|
+
if (a.kind !== b.kind || a.mode !== b.mode)
|
|
125
|
+
return false;
|
|
126
|
+
if (a.data !== undefined && b.data !== undefined)
|
|
127
|
+
return a.data === b.data;
|
|
128
|
+
if (a.data === undefined && b.data === undefined && a.stamp === undefined && b.stamp === undefined)
|
|
129
|
+
return true;
|
|
130
|
+
return a.stamp === b.stamp;
|
|
131
|
+
}
|
|
132
|
+
stateFingerprint(state) {
|
|
133
|
+
return createHash('sha256')
|
|
134
|
+
.update(JSON.stringify([state.kind, state.data ?? null, state.mode ?? null]))
|
|
135
|
+
.digest('hex');
|
|
136
|
+
}
|
|
137
|
+
stateFromSnapshot(snapshot) {
|
|
138
|
+
if (snapshot.kind)
|
|
139
|
+
return { kind: snapshot.kind, data: snapshot.before ?? undefined, mode: snapshot.mode };
|
|
140
|
+
if (snapshot.before === null)
|
|
141
|
+
return { kind: 'missing' };
|
|
142
|
+
return { kind: 'file', data: Buffer.from(snapshot.before, 'utf8').toString('base64') };
|
|
143
|
+
}
|
|
144
|
+
snapshotFromState(turnId, rel, state, sequence, op, createdParents = [], after) {
|
|
145
|
+
return {
|
|
146
|
+
turnId,
|
|
147
|
+
path: rel,
|
|
148
|
+
before: state.data ?? null,
|
|
149
|
+
kind: state.kind,
|
|
150
|
+
encoding: state.kind === 'file' ? 'base64' : undefined,
|
|
151
|
+
mode: state.mode,
|
|
152
|
+
sequence,
|
|
153
|
+
ops: [op],
|
|
154
|
+
createdParents: createdParents.length > 0 ? createdParents : undefined,
|
|
155
|
+
afterFingerprint: after ? this.stateFingerprint(after) : undefined,
|
|
156
|
+
contentUnavailable: state.kind === 'file' && state.data === undefined ? true : undefined,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
addSnapshot(next) {
|
|
160
|
+
if (next.turnId <= 0)
|
|
161
|
+
return;
|
|
162
|
+
const existingIndex = this.snapshots.findIndex((item) => item.turnId === next.turnId && item.path === next.path);
|
|
163
|
+
if (existingIndex < 0) {
|
|
164
|
+
this.snapshots.push(next);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const existing = this.snapshots[existingIndex];
|
|
168
|
+
const existingSequence = existing.sequence ?? Number.MAX_SAFE_INTEGER;
|
|
169
|
+
const nextSequence = next.sequence ?? Number.MAX_SAFE_INTEGER;
|
|
170
|
+
const ops = new Set([...(existing.ops ?? []), ...(next.ops ?? [])]);
|
|
171
|
+
const latestAfterFingerprint = nextSequence >= existingSequence ? next.afterFingerprint : existing.afterFingerprint;
|
|
172
|
+
if (nextSequence < existingSequence) {
|
|
173
|
+
this.snapshots[existingIndex] = { ...next, ops: [...ops], afterFingerprint: latestAfterFingerprint };
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
existing.ops = [...ops];
|
|
177
|
+
existing.afterFingerprint = latestAfterFingerprint;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
missingParents(root, full) {
|
|
181
|
+
const result = [];
|
|
182
|
+
let current = path.dirname(full);
|
|
183
|
+
while (current !== root && this.isInside(root, current)) {
|
|
184
|
+
if (existsSync(current))
|
|
185
|
+
break;
|
|
186
|
+
result.push(this.toRel(root, current));
|
|
187
|
+
current = path.dirname(current);
|
|
188
|
+
}
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
beginTurn(firstLine) {
|
|
192
|
+
this.turnIdCounter += 1;
|
|
193
|
+
this.currentTurnId = this.turnIdCounter;
|
|
194
|
+
this.turns.push({ turnId: this.currentTurnId, firstLine });
|
|
195
|
+
return this.currentTurnId;
|
|
196
|
+
}
|
|
197
|
+
getCurrentTurnId() {
|
|
198
|
+
return this.currentTurnId;
|
|
199
|
+
}
|
|
200
|
+
beginPathMutation(value) {
|
|
201
|
+
const workspaceRoot = this.rootDir();
|
|
202
|
+
const full = path.resolve(workspaceRoot, value);
|
|
203
|
+
return {
|
|
204
|
+
path: this.toRel(workspaceRoot, full),
|
|
205
|
+
before: this.readState(full),
|
|
206
|
+
sequence: ++this.sequenceCounter,
|
|
207
|
+
createdParents: this.missingParents(workspaceRoot, full),
|
|
208
|
+
turnId: this.currentTurnId,
|
|
209
|
+
workspaceRoot,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
endPathMutation(capture, op) {
|
|
213
|
+
const full = this.safeFullPath(capture.workspaceRoot, capture.path);
|
|
214
|
+
if (!full)
|
|
215
|
+
return;
|
|
216
|
+
let changed = false;
|
|
217
|
+
const after = this.readState(full);
|
|
218
|
+
if (!this.sameState(capture.before, after)) {
|
|
219
|
+
changed = true;
|
|
220
|
+
this.addSnapshot(this.snapshotFromState(capture.turnId, capture.path, capture.before, capture.sequence, op, capture.createdParents, after));
|
|
221
|
+
}
|
|
222
|
+
for (const parentRel of capture.createdParents) {
|
|
223
|
+
const parent = this.safeFullPath(capture.workspaceRoot, parentRel);
|
|
224
|
+
if (!parent)
|
|
225
|
+
continue;
|
|
226
|
+
const parentState = this.readState(parent);
|
|
227
|
+
if (parentState.kind !== 'missing') {
|
|
228
|
+
changed = true;
|
|
229
|
+
this.addSnapshot(this.snapshotFromState(capture.turnId, parentRel, { kind: 'missing' }, capture.sequence, op, [], parentState));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (changed)
|
|
233
|
+
this.mutationVersion += 1;
|
|
234
|
+
}
|
|
235
|
+
isWorkspaceExcluded(full, sessionsRoot) {
|
|
236
|
+
const base = path.basename(full).toLowerCase();
|
|
237
|
+
if (EXCLUDED_WORKSPACE_DIRS.includes(base))
|
|
238
|
+
return true;
|
|
239
|
+
return this.isInside(sessionsRoot, full);
|
|
240
|
+
}
|
|
241
|
+
async scanWorkspace(workspaceRoot, sessionsRoot) {
|
|
242
|
+
const entries = new Map();
|
|
243
|
+
const nextCache = new Map();
|
|
244
|
+
const paths = [];
|
|
245
|
+
const walk = async (dir) => {
|
|
246
|
+
if (paths.length >= CAPTURE_ENTRY_LIMIT)
|
|
247
|
+
return;
|
|
248
|
+
let children;
|
|
249
|
+
try {
|
|
250
|
+
children = await fsp.readdir(dir, { withFileTypes: true });
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
for (const child of children) {
|
|
256
|
+
if (paths.length >= CAPTURE_ENTRY_LIMIT)
|
|
257
|
+
return;
|
|
258
|
+
const full = path.join(dir, child.name);
|
|
259
|
+
if (this.isWorkspaceExcluded(full, sessionsRoot))
|
|
260
|
+
continue;
|
|
261
|
+
paths.push(full);
|
|
262
|
+
if (child.isDirectory())
|
|
263
|
+
await walk(full);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
await walk(workspaceRoot);
|
|
267
|
+
let budget = CAPTURE_TOTAL_LIMIT;
|
|
268
|
+
let cursor = 0;
|
|
269
|
+
let processed = 0;
|
|
270
|
+
let bytesSinceYield = 0;
|
|
271
|
+
const worker = async () => {
|
|
272
|
+
while (cursor < paths.length) {
|
|
273
|
+
const full = paths[cursor++];
|
|
274
|
+
if (++processed % YIELD_EVERY === 0 || bytesSinceYield >= YIELD_BYTES) {
|
|
275
|
+
bytesSinceYield = 0;
|
|
276
|
+
await this.yieldToEventLoop();
|
|
277
|
+
}
|
|
278
|
+
let state;
|
|
279
|
+
try {
|
|
280
|
+
const stat = await fsp.lstat(full, { bigint: true });
|
|
281
|
+
const mode = Number(stat.mode) & 0o777;
|
|
282
|
+
if (stat.isSymbolicLink()) {
|
|
283
|
+
const target = await fsp.readlink(full);
|
|
284
|
+
state = { kind: 'symlink', data: target, stamp: target, mode };
|
|
285
|
+
}
|
|
286
|
+
else if (stat.isDirectory()) {
|
|
287
|
+
state = { kind: 'directory', mode, stamp: '' };
|
|
288
|
+
}
|
|
289
|
+
else if (stat.isFile()) {
|
|
290
|
+
const size = Number(stat.size);
|
|
291
|
+
const stamp = `${size}:${stat.mtimeNs}`;
|
|
292
|
+
const fresh = Date.now() - Number(stat.mtimeNs / 1000000n) < FRESH_WINDOW_MS;
|
|
293
|
+
const cached = fresh ? undefined : this.contentCache.get(full);
|
|
294
|
+
if (cached && cached.stamp === stamp && cached.mode === mode) {
|
|
295
|
+
budget -= size;
|
|
296
|
+
state = { kind: 'file', data: cached.data, stamp, mode };
|
|
297
|
+
nextCache.set(full, cached);
|
|
298
|
+
}
|
|
299
|
+
else if (size <= CAPTURE_FILE_LIMIT && budget - size >= 0) {
|
|
300
|
+
budget -= size;
|
|
301
|
+
bytesSinceYield += size;
|
|
302
|
+
const data = (await fsp.readFile(full)).toString('base64');
|
|
303
|
+
state = { kind: 'file', data, stamp, mode };
|
|
304
|
+
nextCache.set(full, { stamp, mode, data });
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
state = { kind: 'file', stamp, mode };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
state = { kind: 'missing' };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
state = { kind: 'missing' };
|
|
316
|
+
}
|
|
317
|
+
if (state.kind !== 'missing')
|
|
318
|
+
entries.set(this.toRel(workspaceRoot, full), state);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
await Promise.all(Array.from({ length: Math.min(SCAN_CONCURRENCY, Math.max(1, paths.length)) }, worker));
|
|
322
|
+
this.contentCache = nextCache;
|
|
323
|
+
return entries;
|
|
324
|
+
}
|
|
325
|
+
async beginWorkspaceMutation() {
|
|
326
|
+
const turnId = this.currentTurnId;
|
|
327
|
+
const sequence = ++this.sequenceCounter;
|
|
328
|
+
const workspaceRoot = this.rootDir();
|
|
329
|
+
const sessionsRoot = this.sessionsDir();
|
|
330
|
+
const entries = await this.scanWorkspace(workspaceRoot, sessionsRoot);
|
|
331
|
+
return {
|
|
332
|
+
sequence,
|
|
333
|
+
entries,
|
|
334
|
+
turnId,
|
|
335
|
+
workspaceRoot,
|
|
336
|
+
sessionsRoot,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
async endWorkspaceMutation(capture, op) {
|
|
340
|
+
const after = await this.scanWorkspace(capture.workspaceRoot, capture.sessionsRoot);
|
|
341
|
+
const paths = new Set([...capture.entries.keys(), ...after.keys()]);
|
|
342
|
+
let changed = false;
|
|
343
|
+
for (const rel of paths) {
|
|
344
|
+
const beforeState = capture.entries.get(rel) ?? { kind: 'missing' };
|
|
345
|
+
const afterState = after.get(rel) ?? { kind: 'missing' };
|
|
346
|
+
if (this.sameState(beforeState, afterState))
|
|
347
|
+
continue;
|
|
348
|
+
changed = true;
|
|
349
|
+
this.addSnapshot(this.snapshotFromState(capture.turnId, rel, beforeState, capture.sequence, op, [], afterState));
|
|
350
|
+
}
|
|
351
|
+
if (changed)
|
|
352
|
+
this.mutationVersion += 1;
|
|
353
|
+
}
|
|
354
|
+
getCurrentTurnMutationState() {
|
|
355
|
+
const order = [];
|
|
356
|
+
const byPath = new Map();
|
|
357
|
+
for (const snapshot of this.snapshots) {
|
|
358
|
+
if (snapshot.turnId !== this.currentTurnId)
|
|
359
|
+
continue;
|
|
360
|
+
let change = byPath.get(snapshot.path);
|
|
361
|
+
if (!change) {
|
|
362
|
+
change = { path: snapshot.path, ops: [], snapshotAvailable: snapshot.contentUnavailable !== true };
|
|
363
|
+
byPath.set(snapshot.path, change);
|
|
364
|
+
order.push(snapshot.path);
|
|
365
|
+
}
|
|
366
|
+
if (snapshot.contentUnavailable)
|
|
367
|
+
change.snapshotAvailable = false;
|
|
368
|
+
for (const op of snapshot.ops ?? ['file_change']) {
|
|
369
|
+
if (!change.ops.includes(op))
|
|
370
|
+
change.ops.push(op);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return { version: this.mutationVersion, changedFiles: order.map((item) => byPath.get(item)) };
|
|
374
|
+
}
|
|
375
|
+
listTurns() {
|
|
376
|
+
return this.turns.slice();
|
|
377
|
+
}
|
|
378
|
+
findCutoffIndex(n, history) {
|
|
379
|
+
let seen = 0;
|
|
380
|
+
for (let i = 0; i < history.length; i++) {
|
|
381
|
+
if (history[i].role === 'user') {
|
|
382
|
+
seen += 1;
|
|
383
|
+
if (seen === n + 1)
|
|
384
|
+
return i;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return history.length;
|
|
388
|
+
}
|
|
389
|
+
planRollback(n, history) {
|
|
390
|
+
const cutoffTurnId = this.turns[n - 1]?.turnId ?? 0;
|
|
391
|
+
const cutoffIndex = this.findCutoffIndex(n, history);
|
|
392
|
+
const order = [];
|
|
393
|
+
const map = new Map();
|
|
394
|
+
const ensure = (rel) => {
|
|
395
|
+
let change = map.get(rel);
|
|
396
|
+
if (!change) {
|
|
397
|
+
change = { path: rel, ops: [], snapshotAvailable: true };
|
|
398
|
+
map.set(rel, change);
|
|
399
|
+
order.push(rel);
|
|
400
|
+
}
|
|
401
|
+
return change;
|
|
402
|
+
};
|
|
403
|
+
for (const snapshot of this.snapshots) {
|
|
404
|
+
if (snapshot.turnId <= cutoffTurnId)
|
|
405
|
+
continue;
|
|
406
|
+
const change = ensure(snapshot.path);
|
|
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 { n, cutoffIndex, cutoffTurnId, changes: order.map((rel) => map.get(rel)) };
|
|
415
|
+
}
|
|
416
|
+
depth(rel) {
|
|
417
|
+
return rel.split(/[\\/]+/).length;
|
|
418
|
+
}
|
|
419
|
+
restoreSnapshot(root, snapshot) {
|
|
420
|
+
const full = this.safeFullPath(root, snapshot.path);
|
|
421
|
+
if (!full || snapshot.contentUnavailable)
|
|
422
|
+
return false;
|
|
423
|
+
const state = this.stateFromSnapshot(snapshot);
|
|
424
|
+
try {
|
|
425
|
+
if (state.kind === 'missing') {
|
|
426
|
+
const current = this.readState(full);
|
|
427
|
+
if (current.kind === 'directory')
|
|
428
|
+
rmdirSync(full);
|
|
429
|
+
else
|
|
430
|
+
rmSync(full, { recursive: false, force: true });
|
|
431
|
+
for (const parentRel of snapshot.createdParents ?? []) {
|
|
432
|
+
const parent = this.safeFullPath(root, parentRel);
|
|
433
|
+
if (!parent)
|
|
434
|
+
continue;
|
|
435
|
+
try {
|
|
436
|
+
rmdirSync(parent);
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
// 仅删除本轮创建且当前为空的父目录;非空/已不存在均保持。
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
if (state.kind === 'directory') {
|
|
445
|
+
const current = this.readState(full);
|
|
446
|
+
if (current.kind !== 'missing' && current.kind !== 'directory')
|
|
447
|
+
rmSync(full, { recursive: true, force: true });
|
|
448
|
+
mkdirSync(full, { recursive: true });
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
mkdirSync(path.dirname(full), { recursive: true });
|
|
452
|
+
rmSync(full, { recursive: true, force: true });
|
|
453
|
+
if (state.kind === 'file')
|
|
454
|
+
writeFileSync(full, Buffer.from(state.data ?? '', 'base64'));
|
|
455
|
+
else
|
|
456
|
+
symlinkSync(state.data ?? '', full);
|
|
457
|
+
}
|
|
458
|
+
if (state.mode !== undefined && state.kind !== 'symlink')
|
|
459
|
+
chmodSync(full, state.mode);
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
applyRollback(plan, history, revertPaths) {
|
|
467
|
+
const deletedMsgs = history.length - plan.cutoffIndex;
|
|
468
|
+
history.length = plan.cutoffIndex;
|
|
469
|
+
const picks = new Map();
|
|
470
|
+
const latest = new Map();
|
|
471
|
+
for (const snapshot of this.snapshots) {
|
|
472
|
+
if (snapshot.turnId <= plan.cutoffTurnId || !revertPaths.has(snapshot.path))
|
|
473
|
+
continue;
|
|
474
|
+
const latestSnapshot = latest.get(snapshot.path);
|
|
475
|
+
if (!latestSnapshot ||
|
|
476
|
+
snapshot.turnId > latestSnapshot.turnId ||
|
|
477
|
+
(snapshot.turnId === latestSnapshot.turnId && (snapshot.sequence ?? -1) > (latestSnapshot.sequence ?? -1))) {
|
|
478
|
+
latest.set(snapshot.path, snapshot);
|
|
479
|
+
}
|
|
480
|
+
const existing = picks.get(snapshot.path);
|
|
481
|
+
if (!existing ||
|
|
482
|
+
snapshot.turnId < existing.turnId ||
|
|
483
|
+
(snapshot.turnId === existing.turnId &&
|
|
484
|
+
(snapshot.sequence ?? Number.MAX_SAFE_INTEGER) < (existing.sequence ?? Number.MAX_SAFE_INTEGER))) {
|
|
485
|
+
picks.set(snapshot.path, snapshot);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const root = this.rootDir();
|
|
489
|
+
const selected = [];
|
|
490
|
+
const conflictedFiles = [];
|
|
491
|
+
for (const snapshot of picks.values()) {
|
|
492
|
+
const expected = latest.get(snapshot.path)?.afterFingerprint;
|
|
493
|
+
const full = this.safeFullPath(root, snapshot.path);
|
|
494
|
+
if (expected && (!full || this.stateFingerprint(this.readState(full)) !== expected))
|
|
495
|
+
conflictedFiles.push(snapshot.path);
|
|
496
|
+
else
|
|
497
|
+
selected.push(snapshot);
|
|
498
|
+
}
|
|
499
|
+
const removals = selected
|
|
500
|
+
.filter((item) => this.stateFromSnapshot(item).kind === 'missing')
|
|
501
|
+
.sort((a, b) => this.depth(b.path) - this.depth(a.path));
|
|
502
|
+
const restores = selected
|
|
503
|
+
.filter((item) => this.stateFromSnapshot(item).kind !== 'missing')
|
|
504
|
+
.sort((a, b) => this.depth(a.path) - this.depth(b.path));
|
|
505
|
+
const revertedFiles = [];
|
|
506
|
+
for (const snapshot of [...removals, ...restores]) {
|
|
507
|
+
if (this.restoreSnapshot(root, snapshot))
|
|
508
|
+
revertedFiles.push(snapshot.path);
|
|
509
|
+
else if (!conflictedFiles.includes(snapshot.path))
|
|
510
|
+
conflictedFiles.push(snapshot.path);
|
|
511
|
+
}
|
|
512
|
+
this.turns = this.turns.filter((turn) => turn.turnId <= plan.cutoffTurnId);
|
|
513
|
+
this.snapshots = this.snapshots.filter((snapshot) => snapshot.turnId <= plan.cutoffTurnId);
|
|
514
|
+
this.currentTurnId = this.turns.at(-1)?.turnId ?? 0;
|
|
515
|
+
return { deletedMsgs, revertedFiles, conflictedFiles };
|
|
516
|
+
}
|
|
517
|
+
pruneAfterCompaction(history) {
|
|
518
|
+
const count = history.filter((message) => message.role === 'user').length;
|
|
519
|
+
this.turns = count >= this.turns.length ? this.turns : this.turns.slice(-count);
|
|
520
|
+
const alive = new Set(this.turns.map((turn) => turn.turnId));
|
|
521
|
+
this.snapshots = this.snapshots.filter((snapshot) => alive.has(snapshot.turnId));
|
|
522
|
+
}
|
|
523
|
+
resetState() {
|
|
524
|
+
this.turns = [];
|
|
525
|
+
this.snapshots = [];
|
|
526
|
+
this.turnIdCounter = 0;
|
|
527
|
+
this.currentTurnId = 0;
|
|
528
|
+
this.sequenceCounter = 0;
|
|
529
|
+
this.contentCache = new Map();
|
|
530
|
+
}
|
|
531
|
+
rebuildFromHistory(history) {
|
|
532
|
+
const rebuilt = [];
|
|
533
|
+
for (const message of history) {
|
|
534
|
+
if (message.role !== 'user')
|
|
535
|
+
continue;
|
|
536
|
+
const first = toText(message.content).split('\n')[0] ?? '';
|
|
537
|
+
rebuilt.push({ turnId: rebuilt.length + 1, firstLine: truncateDisplay(first, 40) });
|
|
538
|
+
}
|
|
539
|
+
this.turns = rebuilt;
|
|
540
|
+
this.snapshots = [];
|
|
541
|
+
this.turnIdCounter = rebuilt.length;
|
|
542
|
+
this.currentTurnId = 0;
|
|
543
|
+
this.sequenceCounter = 0;
|
|
544
|
+
}
|
|
545
|
+
snapshotsPath(id) {
|
|
546
|
+
const sessionsRoot = this.sessionsDir();
|
|
547
|
+
const current = path.join(sessionsRoot, id, 'snapshots.json');
|
|
548
|
+
if (existsSync(current))
|
|
549
|
+
return current;
|
|
550
|
+
return path.join(sessionsRoot, `${id}.snapshots.json`);
|
|
551
|
+
}
|
|
552
|
+
persistSnapshots(id) {
|
|
553
|
+
const sessionsRoot = this.sessionsDir();
|
|
554
|
+
const dir = path.join(sessionsRoot, id);
|
|
555
|
+
const current = path.join(dir, 'snapshots.json');
|
|
556
|
+
const legacy = path.join(sessionsRoot, `${id}.snapshots.json`);
|
|
557
|
+
try {
|
|
558
|
+
if (this.turns.length === 0) {
|
|
559
|
+
if (existsSync(current))
|
|
560
|
+
unlinkSync(current);
|
|
561
|
+
if (existsSync(legacy))
|
|
562
|
+
unlinkSync(legacy);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
mkdirSync(dir, { recursive: true });
|
|
566
|
+
writeFileSync(current, JSON.stringify({ version: 2, turns: this.turns, snapshots: this.snapshots }), 'utf8');
|
|
567
|
+
if (existsSync(legacy))
|
|
568
|
+
unlinkSync(legacy);
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
// 落盘失败不阻断会话;只失去跨重启回滚能力。
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
loadSnapshots(id) {
|
|
575
|
+
const snapshotFile = this.snapshotsPath(id);
|
|
576
|
+
if (!existsSync(snapshotFile))
|
|
577
|
+
return false;
|
|
578
|
+
try {
|
|
579
|
+
const record = JSON.parse(readFileSync(snapshotFile, 'utf8'));
|
|
580
|
+
if (!record || !Array.isArray(record.turns) || !Array.isArray(record.snapshots))
|
|
581
|
+
return false;
|
|
582
|
+
this.turns = record.turns;
|
|
583
|
+
this.snapshots = record.snapshots;
|
|
584
|
+
this.turnIdCounter = this.turns.reduce((max, turn) => Math.max(max, turn.turnId), 0);
|
|
585
|
+
this.sequenceCounter = this.snapshots.reduce((max, snapshot) => Math.max(max, snapshot.sequence ?? 0), 0);
|
|
586
|
+
this.currentTurnId = 0;
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Runtime, createRuntime, defaultRuntime, getActiveRuntime } from './runtime.js';
|