centaurus-cli 2.9.9 → 3.0.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/dist/cli-adapter.d.ts +10 -0
- package/dist/cli-adapter.d.ts.map +1 -1
- package/dist/cli-adapter.js +376 -170
- package/dist/cli-adapter.js.map +1 -1
- package/dist/context/handlers/docker-handler.d.ts.map +1 -1
- package/dist/context/handlers/docker-handler.js +2 -2
- package/dist/context/handlers/docker-handler.js.map +1 -1
- package/dist/context/handlers/ssh-handler.d.ts.map +1 -1
- package/dist/context/handlers/ssh-handler.js +3 -0
- package/dist/context/handlers/ssh-handler.js.map +1 -1
- package/dist/context/handlers/wsl-handler.d.ts.map +1 -1
- package/dist/context/handlers/wsl-handler.js +9 -3
- package/dist/context/handlers/wsl-handler.js.map +1 -1
- package/dist/index.js +49 -7
- package/dist/index.js.map +1 -1
- package/dist/mcp/mcp-server-manager.d.ts.map +1 -1
- package/dist/mcp/mcp-server-manager.js +17 -3
- package/dist/mcp/mcp-server-manager.js.map +1 -1
- package/dist/services/api-client.d.ts +4 -0
- package/dist/services/api-client.d.ts.map +1 -1
- package/dist/services/api-client.js +27 -18
- package/dist/services/api-client.js.map +1 -1
- package/dist/services/checkpoint-manager.d.ts +81 -44
- package/dist/services/checkpoint-manager.d.ts.map +1 -1
- package/dist/services/checkpoint-manager.js +1219 -693
- package/dist/services/checkpoint-manager.js.map +1 -1
- package/dist/services/conversation-manager.d.ts.map +1 -1
- package/dist/services/conversation-manager.js +3 -2
- package/dist/services/conversation-manager.js.map +1 -1
- package/dist/tools/enter-remote-session.d.ts +35 -0
- package/dist/tools/enter-remote-session.d.ts.map +1 -1
- package/dist/tools/enter-remote-session.js +5 -5
- package/dist/tools/enter-remote-session.js.map +1 -1
- package/dist/tools/file-ops.d.ts.map +1 -1
- package/dist/tools/file-ops.js +39 -0
- package/dist/tools/file-ops.js.map +1 -1
- package/dist/tools/get-diff.js +8 -2
- package/dist/tools/get-diff.js.map +1 -1
- package/dist/ui/components/App.d.ts +2 -0
- package/dist/ui/components/App.d.ts.map +1 -1
- package/dist/ui/components/App.js +71 -50
- package/dist/ui/components/App.js.map +1 -1
- package/dist/ui/components/ToolExecutionMessage.js +1 -1
- package/dist/ui/components/ToolExecutionMessage.js.map +1 -1
- package/dist/utils/shell.d.ts.map +1 -1
- package/dist/utils/shell.js +12 -1
- package/dist/utils/shell.js.map +1 -1
- package/dist/utils/syntax-checker.d.ts.map +1 -1
- package/dist/utils/syntax-checker.js +26 -0
- package/dist/utils/syntax-checker.js.map +1 -1
- package/dist/utils/terminal-output.d.ts.map +1 -1
- package/dist/utils/terminal-output.js +10 -8
- package/dist/utils/terminal-output.js.map +1 -1
- package/dist/utils/text-clipboard.d.ts.map +1 -1
- package/dist/utils/text-clipboard.js +21 -8
- package/dist/utils/text-clipboard.js.map +1 -1
- package/package.json +6 -2
- package/dist/config/ConfigManager.d.ts +0 -62
- package/dist/config/ConfigManager.d.ts.map +0 -1
- package/dist/config/ConfigManager.js +0 -234
- package/dist/config/ConfigManager.js.map +0 -1
|
@@ -1,27 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import * as os from 'os';
|
|
4
|
-
import fg from 'fast-glob';
|
|
5
4
|
import { logError, logWarning } from '../utils/logger.js';
|
|
6
|
-
const DEFAULT_IGNORE_PATTERNS = [
|
|
7
|
-
'**/.git/**',
|
|
8
|
-
'**/node_modules/**',
|
|
9
|
-
'**/dist/**',
|
|
10
|
-
'**/build/**',
|
|
11
|
-
'**/out/**',
|
|
12
|
-
'**/.next/**',
|
|
13
|
-
'**/.turbo/**',
|
|
14
|
-
'**/.cache/**',
|
|
15
|
-
'**/coverage/**',
|
|
16
|
-
'**/__pycache__/**',
|
|
17
|
-
'**/.venv/**',
|
|
18
|
-
'**/venv/**',
|
|
19
|
-
'**/.idea/**',
|
|
20
|
-
'**/.vscode/**',
|
|
21
|
-
'**/.DS_Store',
|
|
22
|
-
'**/Thumbs.db',
|
|
23
|
-
'**/.centaurus/**',
|
|
24
|
-
];
|
|
25
5
|
export class CheckpointManager {
|
|
26
6
|
checkpoints = [];
|
|
27
7
|
currentChatId = null;
|
|
@@ -42,8 +22,107 @@ export class CheckpointManager {
|
|
|
42
22
|
list() {
|
|
43
23
|
return [...this.checkpoints].sort((a, b) => b.createdAtMs - a.createdAtMs);
|
|
44
24
|
}
|
|
25
|
+
// ── Backup-on-write: called by file tools before modifying a file ───
|
|
45
26
|
/**
|
|
46
|
-
*
|
|
27
|
+
* Back up a single file before the AI modifies it.
|
|
28
|
+
* This is the core of the new checkpoint system: instead of copying the
|
|
29
|
+
* entire project at checkpoint start, we only back up the specific files
|
|
30
|
+
* that the AI actually touches, right before it touches them.
|
|
31
|
+
*
|
|
32
|
+
* If the file has already been backed up in this checkpoint, this is a no-op
|
|
33
|
+
* (we always want the ORIGINAL state, not intermediate states).
|
|
34
|
+
*/
|
|
35
|
+
async backupFileBeforeChange(checkpointId, absoluteFilePath, cwd, handler) {
|
|
36
|
+
const checkpoint = this.checkpoints.find(cp => cp.id === checkpointId);
|
|
37
|
+
if (!checkpoint || checkpoint.status !== 'active')
|
|
38
|
+
return;
|
|
39
|
+
// Compute relative path (forward-slash separated)
|
|
40
|
+
const relativePath = path.relative(cwd, absoluteFilePath).replace(/\\/g, '/');
|
|
41
|
+
// Read the current manifest to check if this file is already backed up
|
|
42
|
+
const manifest = this.readManifestV2(checkpoint.manifestPath);
|
|
43
|
+
const alreadyBacked = manifest.fileBackups.some(b => b.filePath === relativePath);
|
|
44
|
+
if (alreadyBacked)
|
|
45
|
+
return; // Already have the original — skip
|
|
46
|
+
const isRemote = checkpoint.contextType !== 'local' && handler && handler.isConnected();
|
|
47
|
+
const backupDir = path.join(path.dirname(checkpoint.manifestPath), 'backups');
|
|
48
|
+
try {
|
|
49
|
+
let existed = false;
|
|
50
|
+
let fileSize;
|
|
51
|
+
if (isRemote) {
|
|
52
|
+
// Remote: try to read the file via handler
|
|
53
|
+
const remotePath = cwd + '/' + relativePath;
|
|
54
|
+
try {
|
|
55
|
+
const content = await handler.readFile(remotePath);
|
|
56
|
+
existed = true;
|
|
57
|
+
fileSize = Buffer.from(content, 'utf-8').length;
|
|
58
|
+
// Save backup locally
|
|
59
|
+
const backupPath = path.join(backupDir, relativePath);
|
|
60
|
+
this.ensureDirSync(path.dirname(backupPath));
|
|
61
|
+
fs.writeFileSync(backupPath, content, 'utf-8');
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// File doesn't exist on remote — it will be created
|
|
65
|
+
existed = false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
// Local: check if file exists and back it up
|
|
70
|
+
if (fs.existsSync(absoluteFilePath)) {
|
|
71
|
+
existed = true;
|
|
72
|
+
try {
|
|
73
|
+
const stat = fs.statSync(absoluteFilePath);
|
|
74
|
+
fileSize = stat.size;
|
|
75
|
+
const backupPath = path.join(backupDir, relativePath);
|
|
76
|
+
this.ensureDirSync(path.dirname(backupPath));
|
|
77
|
+
fs.copyFileSync(absoluteFilePath, backupPath);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
logWarning(`Failed to backup file ${relativePath}: ${err.message}`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
existed = false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Add entry to manifest
|
|
89
|
+
manifest.fileBackups.push({
|
|
90
|
+
filePath: relativePath,
|
|
91
|
+
existed,
|
|
92
|
+
size: fileSize,
|
|
93
|
+
backedUpAt: new Date().toISOString(),
|
|
94
|
+
});
|
|
95
|
+
fs.writeFileSync(checkpoint.manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
logWarning(`Failed to backup file before change: ${error.message}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Record a file operation in the checkpoint's operation log.
|
|
103
|
+
* Called by file tools after successfully modifying a file.
|
|
104
|
+
*/
|
|
105
|
+
recordFileOperation(checkpointId, type, filePath, toolName) {
|
|
106
|
+
const checkpoint = this.checkpoints.find(cp => cp.id === checkpointId);
|
|
107
|
+
if (!checkpoint || checkpoint.status !== 'active')
|
|
108
|
+
return;
|
|
109
|
+
try {
|
|
110
|
+
const manifest = this.readManifestV2(checkpoint.manifestPath);
|
|
111
|
+
manifest.operations.push({
|
|
112
|
+
type,
|
|
113
|
+
filePath,
|
|
114
|
+
toolName,
|
|
115
|
+
timestamp: new Date().toISOString(),
|
|
116
|
+
});
|
|
117
|
+
fs.writeFileSync(checkpoint.manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
logWarning(`Failed to record file operation: ${error.message}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// ── Session changes and diff ────────────────────────────────────────
|
|
124
|
+
/**
|
|
125
|
+
* Get session changes for a checkpoint (live calculation).
|
|
47
126
|
* Returns added/modified/deleted file lists plus per-file line stats.
|
|
48
127
|
*/
|
|
49
128
|
async getSessionChanges(checkpointId, handler) {
|
|
@@ -55,75 +134,204 @@ export class CheckpointManager {
|
|
|
55
134
|
return null;
|
|
56
135
|
}
|
|
57
136
|
const changes = await this.calculateChanges(checkpoint, handler);
|
|
58
|
-
// Calculate line-level stats for modified files
|
|
59
137
|
const stats = [];
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
138
|
+
const manifest = this.readManifest(checkpoint.manifestPath);
|
|
139
|
+
// V2: use backup-based stats
|
|
140
|
+
if (this.isV2Manifest(manifest)) {
|
|
141
|
+
for (const backup of manifest.fileBackups) {
|
|
142
|
+
const backupPath = path.join(path.dirname(checkpoint.manifestPath), 'backups', backup.filePath);
|
|
143
|
+
if (isRemote && handler) {
|
|
144
|
+
const remotePath = checkpoint.cwd + '/' + backup.filePath;
|
|
145
|
+
try {
|
|
146
|
+
if (backup.existed) {
|
|
147
|
+
// Modified or possibly deleted
|
|
148
|
+
const backupContent = fs.readFileSync(backupPath, 'utf-8');
|
|
149
|
+
try {
|
|
150
|
+
const currentContent = await handler.readFile(remotePath);
|
|
151
|
+
if (backupContent !== currentContent) {
|
|
152
|
+
const lineStat = this.calculateLineStatsFromContent(backupContent, currentContent);
|
|
153
|
+
stats.push({ filePath: backup.filePath, ...lineStat });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// File was deleted
|
|
158
|
+
const lines = backupContent.split('\n').length;
|
|
159
|
+
stats.push({ filePath: backup.filePath, insertions: 0, deletions: lines });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
// Created file
|
|
164
|
+
try {
|
|
165
|
+
const currentContent = await handler.readFile(remotePath);
|
|
166
|
+
const lines = currentContent.split('\n').length;
|
|
167
|
+
stats.push({ filePath: backup.filePath, insertions: lines, deletions: 0 });
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// Created then deleted — no net change
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
stats.push({ filePath: backup.filePath, insertions: 0, deletions: 0 });
|
|
176
|
+
}
|
|
86
177
|
}
|
|
87
|
-
|
|
88
|
-
|
|
178
|
+
else {
|
|
179
|
+
// Local
|
|
180
|
+
const currentPath = path.join(checkpoint.cwd, backup.filePath);
|
|
181
|
+
if (backup.existed) {
|
|
182
|
+
if (fs.existsSync(currentPath)) {
|
|
183
|
+
// Modified
|
|
184
|
+
try {
|
|
185
|
+
const lineStat = this.calculateLineStats(backupPath, currentPath);
|
|
186
|
+
if (lineStat.insertions > 0 || lineStat.deletions > 0) {
|
|
187
|
+
stats.push({ filePath: backup.filePath, ...lineStat });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
stats.push({ filePath: backup.filePath, insertions: 0, deletions: 0 });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
// Deleted
|
|
196
|
+
try {
|
|
197
|
+
const content = fs.readFileSync(backupPath, 'utf-8');
|
|
198
|
+
const lines = content.split('\n').length;
|
|
199
|
+
stats.push({ filePath: backup.filePath, insertions: 0, deletions: lines });
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
stats.push({ filePath: backup.filePath, insertions: 0, deletions: 0 });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
// Created
|
|
208
|
+
if (fs.existsSync(currentPath)) {
|
|
209
|
+
try {
|
|
210
|
+
const content = fs.readFileSync(currentPath, 'utf-8');
|
|
211
|
+
const lines = content.split('\n').length;
|
|
212
|
+
stats.push({ filePath: backup.filePath, insertions: lines, deletions: 0 });
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
stats.push({ filePath: backup.filePath, insertions: 0, deletions: 0 });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// If file doesn't exist — created then deleted, no stats
|
|
219
|
+
}
|
|
89
220
|
}
|
|
90
221
|
}
|
|
91
222
|
}
|
|
92
|
-
else
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
223
|
+
else {
|
|
224
|
+
// V1 legacy: use full-scan approach for older checkpoints
|
|
225
|
+
await this.getSessionChangesV1(checkpoint, changes, stats, isRemote, handler);
|
|
226
|
+
}
|
|
227
|
+
return { changes, stats };
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Get session changes across ALL checkpoints (for session scope).
|
|
231
|
+
* Aggregates backups from all checkpoints to find the original state of each file.
|
|
232
|
+
*/
|
|
233
|
+
async getAggregatedSessionChanges(handler) {
|
|
234
|
+
if (this.checkpoints.length === 0)
|
|
235
|
+
return null;
|
|
236
|
+
// Sort checkpoints by creation time (earliest first)
|
|
237
|
+
const sorted = [...this.checkpoints].sort((a, b) => a.createdAtMs - b.createdAtMs);
|
|
238
|
+
const firstCheckpoint = sorted[0];
|
|
239
|
+
const isRemote = firstCheckpoint.contextType !== 'local';
|
|
240
|
+
if (isRemote && (!handler || !handler.isConnected()))
|
|
241
|
+
return null;
|
|
242
|
+
// Collect original state for each unique file across all checkpoints
|
|
243
|
+
// The FIRST backup for each file path is the true original
|
|
244
|
+
const originalBackups = new Map();
|
|
245
|
+
for (const cp of sorted) {
|
|
246
|
+
const manifest = this.readManifest(cp.manifestPath);
|
|
247
|
+
if (!this.isV2Manifest(manifest))
|
|
248
|
+
continue;
|
|
249
|
+
for (const backup of manifest.fileBackups) {
|
|
250
|
+
if (!originalBackups.has(backup.filePath)) {
|
|
251
|
+
const backupPath = path.join(path.dirname(cp.manifestPath), 'backups', backup.filePath);
|
|
252
|
+
originalBackups.set(backup.filePath, {
|
|
253
|
+
existed: backup.existed,
|
|
254
|
+
backupPath,
|
|
255
|
+
cwd: cp.cwd,
|
|
256
|
+
});
|
|
105
257
|
}
|
|
106
258
|
}
|
|
107
|
-
|
|
108
|
-
|
|
259
|
+
}
|
|
260
|
+
const changes = { added: [], modified: [], deleted: [] };
|
|
261
|
+
const stats = [];
|
|
262
|
+
const cwd = firstCheckpoint.cwd;
|
|
263
|
+
for (const [filePath, original] of originalBackups) {
|
|
264
|
+
if (isRemote && handler) {
|
|
265
|
+
const remotePath = cwd + '/' + filePath;
|
|
109
266
|
try {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
267
|
+
if (original.existed) {
|
|
268
|
+
const backupContent = fs.readFileSync(original.backupPath, 'utf-8');
|
|
269
|
+
try {
|
|
270
|
+
const currentContent = await handler.readFile(remotePath);
|
|
271
|
+
if (backupContent !== currentContent) {
|
|
272
|
+
changes.modified.push(filePath);
|
|
273
|
+
const lineStat = this.calculateLineStatsFromContent(backupContent, currentContent);
|
|
274
|
+
stats.push({ filePath, ...lineStat });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
changes.deleted.push(filePath);
|
|
279
|
+
const lines = backupContent.split('\n').length;
|
|
280
|
+
stats.push({ filePath, insertions: 0, deletions: lines });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
try {
|
|
285
|
+
const currentContent = await handler.readFile(remotePath);
|
|
286
|
+
changes.added.push(filePath);
|
|
287
|
+
const lines = currentContent.split('\n').length;
|
|
288
|
+
stats.push({ filePath, insertions: lines, deletions: 0 });
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// Created then deleted — no net change
|
|
292
|
+
}
|
|
293
|
+
}
|
|
113
294
|
}
|
|
114
295
|
catch {
|
|
115
|
-
|
|
296
|
+
// Skip on error
|
|
116
297
|
}
|
|
117
298
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
299
|
+
else {
|
|
300
|
+
// Local
|
|
301
|
+
const currentPath = path.join(cwd, filePath);
|
|
302
|
+
if (original.existed) {
|
|
303
|
+
if (fs.existsSync(currentPath)) {
|
|
304
|
+
if (this.filesDiffer(original.backupPath, currentPath)) {
|
|
305
|
+
changes.modified.push(filePath);
|
|
306
|
+
const lineStat = this.calculateLineStats(original.backupPath, currentPath);
|
|
307
|
+
stats.push({ filePath, ...lineStat });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
changes.deleted.push(filePath);
|
|
312
|
+
try {
|
|
313
|
+
const content = fs.readFileSync(original.backupPath, 'utf-8');
|
|
314
|
+
const lines = content.split('\n').length;
|
|
315
|
+
stats.push({ filePath, insertions: 0, deletions: lines });
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
stats.push({ filePath, insertions: 0, deletions: 0 });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
124
321
|
}
|
|
125
|
-
|
|
126
|
-
|
|
322
|
+
else {
|
|
323
|
+
if (fs.existsSync(currentPath)) {
|
|
324
|
+
changes.added.push(filePath);
|
|
325
|
+
try {
|
|
326
|
+
const content = fs.readFileSync(currentPath, 'utf-8');
|
|
327
|
+
const lines = content.split('\n').length;
|
|
328
|
+
stats.push({ filePath, insertions: lines, deletions: 0 });
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
stats.push({ filePath, insertions: 0, deletions: 0 });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// If doesn't exist — created then fully reverted, ignore
|
|
127
335
|
}
|
|
128
336
|
}
|
|
129
337
|
}
|
|
@@ -131,115 +339,844 @@ export class CheckpointManager {
|
|
|
131
339
|
}
|
|
132
340
|
/**
|
|
133
341
|
* Get a unified diff for a single file within a checkpoint.
|
|
134
|
-
*
|
|
342
|
+
* For V2 checkpoints: compares the backed-up original to the current version.
|
|
343
|
+
* For V1 checkpoints: compares the full snapshot to the current version (legacy).
|
|
135
344
|
*/
|
|
136
345
|
async getFileDiff(checkpointId, filePath, handler) {
|
|
137
346
|
const checkpoint = this.checkpoints.find(cp => cp.id === checkpointId);
|
|
138
347
|
if (!checkpoint)
|
|
139
348
|
return null;
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (isRemote) {
|
|
144
|
-
if (!handler || !handler.isConnected()) {
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
const remotePath = checkpoint.cwd + '/' + filePath;
|
|
148
|
-
let remoteContent = null;
|
|
149
|
-
try {
|
|
150
|
-
remoteContent = await handler.readFile(remotePath);
|
|
151
|
-
}
|
|
152
|
-
catch {
|
|
153
|
-
remoteContent = null;
|
|
154
|
-
}
|
|
155
|
-
if (!snapshotExists && remoteContent === null) {
|
|
156
|
-
return null;
|
|
157
|
-
}
|
|
158
|
-
// Added file
|
|
159
|
-
if (!snapshotExists && remoteContent !== null) {
|
|
160
|
-
const lines = remoteContent.split('\n');
|
|
161
|
-
let diff = `--- /dev/null\n+++ b/${filePath}\n@@ -0,0 +1,${lines.length} @@\n`;
|
|
162
|
-
diff += lines.map(l => `+${l}`).join('\n');
|
|
163
|
-
return diff;
|
|
164
|
-
}
|
|
165
|
-
// Deleted file
|
|
166
|
-
if (snapshotExists && remoteContent === null) {
|
|
167
|
-
try {
|
|
168
|
-
const content = fs.readFileSync(snapshotPath, 'utf-8');
|
|
169
|
-
const lines = content.split('\n');
|
|
170
|
-
let diff = `--- a/${filePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n`;
|
|
171
|
-
diff += lines.map(l => `-${l}`).join('\n');
|
|
172
|
-
return diff;
|
|
173
|
-
}
|
|
174
|
-
catch {
|
|
175
|
-
return `[Binary or unreadable file: ${filePath}]`;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
// Modified file
|
|
179
|
-
try {
|
|
180
|
-
const oldContent = fs.readFileSync(snapshotPath, 'utf-8');
|
|
181
|
-
const newContent = remoteContent ?? '';
|
|
182
|
-
return this.generateUnifiedDiff(filePath, oldContent, newContent);
|
|
183
|
-
}
|
|
184
|
-
catch {
|
|
185
|
-
return `[Binary or unreadable file: ${filePath}]`;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
// Local diff (original logic)
|
|
189
|
-
const currentPath = path.join(checkpoint.cwd, filePath);
|
|
190
|
-
const currentExists = fs.existsSync(currentPath);
|
|
191
|
-
if (!snapshotExists && !currentExists) {
|
|
192
|
-
return null;
|
|
349
|
+
const manifest = this.readManifest(checkpoint.manifestPath);
|
|
350
|
+
if (this.isV2Manifest(manifest)) {
|
|
351
|
+
return this.getFileDiffV2(checkpoint, manifest, filePath, handler);
|
|
193
352
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
try {
|
|
197
|
-
const content = fs.readFileSync(currentPath, 'utf-8');
|
|
198
|
-
const lines = content.split('\n');
|
|
199
|
-
let diff = `--- /dev/null\n+++ b/${filePath}\n@@ -0,0 +1,${lines.length} @@\n`;
|
|
200
|
-
diff += lines.map(l => `+${l}`).join('\n');
|
|
201
|
-
return diff;
|
|
202
|
-
}
|
|
203
|
-
catch {
|
|
204
|
-
return `[Binary or unreadable file: ${filePath}]`;
|
|
205
|
-
}
|
|
353
|
+
else {
|
|
354
|
+
return this.getFileDiffV1(checkpoint, filePath, handler);
|
|
206
355
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Get a session-wide diff for a single file (across all checkpoints).
|
|
359
|
+
* Finds the earliest backup of the file and compares to current state.
|
|
360
|
+
*/
|
|
361
|
+
async getSessionFileDiff(filePath, handler) {
|
|
362
|
+
const sorted = [...this.checkpoints].sort((a, b) => a.createdAtMs - b.createdAtMs);
|
|
363
|
+
for (const cp of sorted) {
|
|
364
|
+
const manifest = this.readManifest(cp.manifestPath);
|
|
365
|
+
if (!this.isV2Manifest(manifest))
|
|
366
|
+
continue;
|
|
367
|
+
const backup = manifest.fileBackups.find(b => b.filePath === filePath);
|
|
368
|
+
if (backup) {
|
|
369
|
+
return this.getFileDiffV2(cp, manifest, filePath, handler);
|
|
218
370
|
}
|
|
219
371
|
}
|
|
220
|
-
|
|
221
|
-
try {
|
|
222
|
-
const oldContent = fs.readFileSync(snapshotPath, 'utf-8');
|
|
223
|
-
const newContent = fs.readFileSync(currentPath, 'utf-8');
|
|
224
|
-
return this.generateUnifiedDiff(filePath, oldContent, newContent);
|
|
225
|
-
}
|
|
226
|
-
catch {
|
|
227
|
-
return `[Binary or unreadable file: ${filePath}]`;
|
|
228
|
-
}
|
|
372
|
+
return null;
|
|
229
373
|
}
|
|
230
374
|
/**
|
|
231
375
|
* Get the initial checkpoint for the current chat session.
|
|
232
|
-
* This represents the state at the start of the conversation.
|
|
233
376
|
*/
|
|
234
377
|
getInitialCheckpoint() {
|
|
235
378
|
if (this.checkpoints.length === 0)
|
|
236
379
|
return null;
|
|
237
|
-
// Sort by creation time to find the first one
|
|
238
380
|
return [...this.checkpoints].sort((a, b) => a.createdAtMs - b.createdAtMs)[0];
|
|
239
381
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
382
|
+
// ── Checkpoint lifecycle ────────────────────────────────────────────
|
|
383
|
+
/**
|
|
384
|
+
* Start a new checkpoint. With V2 (backup-on-write), this is nearly instant:
|
|
385
|
+
* no file scanning or copying. Just creates the checkpoint metadata and an
|
|
386
|
+
* empty manifest.
|
|
387
|
+
*/
|
|
388
|
+
async startCheckpoint(params) {
|
|
389
|
+
if (!this.currentChatId) {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
const checkpointId = this.generateCheckpointId();
|
|
393
|
+
const checkpointDir = path.join(this.getChatDir(), checkpointId);
|
|
394
|
+
const backupsDir = path.join(checkpointDir, 'backups');
|
|
395
|
+
const manifestPath = path.join(checkpointDir, 'manifest.json');
|
|
396
|
+
try {
|
|
397
|
+
this.ensureDirSync(backupsDir);
|
|
398
|
+
// Create an empty V2 manifest — backups will be added incrementally
|
|
399
|
+
// as the AI tools modify files via backupFileBeforeChange()
|
|
400
|
+
const manifest = {
|
|
401
|
+
version: 2,
|
|
402
|
+
createdAt: new Date().toISOString(),
|
|
403
|
+
cwd: params.cwd,
|
|
404
|
+
fileBackups: [],
|
|
405
|
+
operations: [],
|
|
406
|
+
};
|
|
407
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
|
|
408
|
+
const meta = {
|
|
409
|
+
id: checkpointId,
|
|
410
|
+
prompt: params.prompt,
|
|
411
|
+
createdAt: new Date().toISOString(),
|
|
412
|
+
createdAtMs: Date.now(),
|
|
413
|
+
cwd: params.cwd,
|
|
414
|
+
contextType: params.contextType,
|
|
415
|
+
remoteSessionInfo: params.remoteSessionInfo,
|
|
416
|
+
conversationIndex: params.conversationIndex,
|
|
417
|
+
uiMessageIndex: params.uiMessageIndex,
|
|
418
|
+
uiMessageId: params.uiMessageId,
|
|
419
|
+
// V2: snapshotDir points to 'backups' directory (only has backed-up files)
|
|
420
|
+
snapshotDir: backupsDir,
|
|
421
|
+
manifestPath,
|
|
422
|
+
commands: [],
|
|
423
|
+
toolCalls: [],
|
|
424
|
+
status: 'active',
|
|
425
|
+
};
|
|
426
|
+
this.checkpoints.push(meta);
|
|
427
|
+
this.saveIndex();
|
|
428
|
+
return meta;
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
logError('Failed to create checkpoint', error);
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
async finalizeCheckpoint(id) {
|
|
436
|
+
const checkpoint = this.checkpoints.find(cp => cp.id === id);
|
|
437
|
+
if (!checkpoint)
|
|
438
|
+
return;
|
|
439
|
+
if (this.discardedIds.has(id)) {
|
|
440
|
+
this.discardCheckpoint(id);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
const changes = await this.calculateChanges(checkpoint);
|
|
445
|
+
checkpoint.changes = changes;
|
|
446
|
+
checkpoint.status = 'finalized';
|
|
447
|
+
this.saveIndex();
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
logWarning(`Failed to finalize checkpoint ${id}: ${error.message}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
recordToolCall(id, toolCall) {
|
|
454
|
+
const checkpoint = this.checkpoints.find(cp => cp.id === id);
|
|
455
|
+
if (!checkpoint || checkpoint.status === 'discarded')
|
|
456
|
+
return;
|
|
457
|
+
if (toolCall.id && checkpoint.toolCalls.some(tc => tc.id === toolCall.id)) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
checkpoint.toolCalls.push(toolCall);
|
|
461
|
+
if (toolCall.name === 'execute_command') {
|
|
462
|
+
const command = toolCall.arguments?.CommandLine || toolCall.arguments?.command;
|
|
463
|
+
const isShellInput = Boolean(toolCall.arguments?.shell_input);
|
|
464
|
+
if (command && !isShellInput) {
|
|
465
|
+
checkpoint.commands.push(String(command));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (toolCall.name === 'background_command') {
|
|
469
|
+
const command = toolCall.arguments?.command;
|
|
470
|
+
const action = toolCall.arguments?.action;
|
|
471
|
+
if (command && action === 'start') {
|
|
472
|
+
checkpoint.commands.push(String(command));
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
this.saveIndex();
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Revert to a checkpoint. For V2 checkpoints, this is efficient:
|
|
479
|
+
* only the files the AI actually touched are restored.
|
|
480
|
+
*
|
|
481
|
+
* - Files the AI created (existed=false): deleted
|
|
482
|
+
* - Files the AI modified (existed=true): restored from backup
|
|
483
|
+
*/
|
|
484
|
+
async revertToCheckpoint(id, handler) {
|
|
485
|
+
const checkpoint = this.checkpoints.find(cp => cp.id === id);
|
|
486
|
+
if (!checkpoint) {
|
|
487
|
+
throw new Error(`Checkpoint "${id}" not found`);
|
|
488
|
+
}
|
|
489
|
+
if (checkpoint.contextType !== 'local') {
|
|
490
|
+
if (!handler || !handler.isConnected()) {
|
|
491
|
+
const sessionType = checkpoint.contextType.toUpperCase();
|
|
492
|
+
const sessionInfo = checkpoint.remoteSessionInfo;
|
|
493
|
+
const target = sessionInfo?.connectionString || sessionInfo?.hostname || 'the remote machine';
|
|
494
|
+
throw new Error(`This checkpoint was created during a ${sessionType} session (${target}). ` +
|
|
495
|
+
`You are not currently connected to that session. Please reconnect to the ${sessionType} session first, then retry /revert.`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const manifest = this.readManifest(checkpoint.manifestPath);
|
|
499
|
+
if (this.isV2Manifest(manifest)) {
|
|
500
|
+
return this.revertV2(checkpoint, manifest, handler);
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
// Legacy V1 full-scan revert
|
|
504
|
+
return checkpoint.contextType !== 'local' && handler
|
|
505
|
+
? this.revertRemoteCheckpointV1(checkpoint, manifest, handler)
|
|
506
|
+
: this.revertLocalCheckpointV1(checkpoint, manifest);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
markDiscarded(id) {
|
|
510
|
+
this.discardedIds.add(id);
|
|
511
|
+
}
|
|
512
|
+
removeCheckpointsFrom(id) {
|
|
513
|
+
const index = this.checkpoints.findIndex(cp => cp.id === id);
|
|
514
|
+
if (index === -1)
|
|
515
|
+
return;
|
|
516
|
+
const toRemove = this.checkpoints.slice(index);
|
|
517
|
+
for (const checkpoint of toRemove) {
|
|
518
|
+
this.discardCheckpoint(checkpoint.id);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
discardCheckpointById(id) {
|
|
522
|
+
this.discardCheckpoint(id);
|
|
523
|
+
}
|
|
524
|
+
deleteCheckpointsForChat(chatId) {
|
|
525
|
+
const chatDir = path.join(this.baseDir, chatId);
|
|
526
|
+
try {
|
|
527
|
+
if (fs.existsSync(chatDir)) {
|
|
528
|
+
fs.rmSync(chatDir, { recursive: true, force: true });
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
logWarning(`Failed to delete checkpoints for chat ${chatId}: ${error.message}`);
|
|
533
|
+
}
|
|
534
|
+
if (this.currentChatId === chatId) {
|
|
535
|
+
this.clear();
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// ── V2 Revert (backup-on-write) ────────────────────────────────────
|
|
539
|
+
async revertV2(checkpoint, manifest, handler) {
|
|
540
|
+
const isRemote = checkpoint.contextType !== 'local' && handler && handler.isConnected();
|
|
541
|
+
const errors = [];
|
|
542
|
+
let restored = 0;
|
|
543
|
+
let removed = 0;
|
|
544
|
+
const backupsDir = path.join(path.dirname(checkpoint.manifestPath), 'backups');
|
|
545
|
+
for (const backup of manifest.fileBackups) {
|
|
546
|
+
const backupPath = path.join(backupsDir, backup.filePath);
|
|
547
|
+
if (isRemote && handler) {
|
|
548
|
+
const remotePath = checkpoint.cwd + '/' + backup.filePath;
|
|
549
|
+
try {
|
|
550
|
+
if (backup.existed) {
|
|
551
|
+
// Restore original content
|
|
552
|
+
const content = fs.readFileSync(backupPath, 'utf-8');
|
|
553
|
+
const remoteDir = remotePath.substring(0, remotePath.lastIndexOf('/'));
|
|
554
|
+
await handler.executeCommand(`mkdir -p "${remoteDir}"`);
|
|
555
|
+
await handler.writeFile(remotePath, content);
|
|
556
|
+
restored++;
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
// File was created by AI — delete it
|
|
560
|
+
const result = await handler.executeCommand(`rm -f "${remotePath}"`);
|
|
561
|
+
if (result.exitCode === 0) {
|
|
562
|
+
removed++;
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
errors.push(`Failed to remove ${backup.filePath}: ${result.stderr}`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
catch (error) {
|
|
570
|
+
errors.push(`Failed to revert ${backup.filePath}: ${error.message}`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
// Local revert
|
|
575
|
+
const targetPath = path.join(checkpoint.cwd, backup.filePath);
|
|
576
|
+
try {
|
|
577
|
+
if (backup.existed) {
|
|
578
|
+
// Restore original content
|
|
579
|
+
this.ensureDirSync(path.dirname(targetPath));
|
|
580
|
+
fs.copyFileSync(backupPath, targetPath);
|
|
581
|
+
restored++;
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
// File was created by AI — delete it
|
|
585
|
+
if (fs.existsSync(targetPath)) {
|
|
586
|
+
this.removeFileOrDirSync(targetPath);
|
|
587
|
+
removed++;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
errors.push(`Failed to revert ${backup.filePath}: ${error.message}`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
// Clean up empty directories left after deleting files
|
|
597
|
+
if (!isRemote) {
|
|
598
|
+
this.cleanupEmptyDirectories(checkpoint.cwd, manifest.fileBackups
|
|
599
|
+
.filter(b => !b.existed)
|
|
600
|
+
.map(b => b.filePath));
|
|
601
|
+
}
|
|
602
|
+
else if (handler) {
|
|
603
|
+
await this.cleanupEmptyDirectoriesRemote(checkpoint.cwd, manifest.fileBackups
|
|
604
|
+
.filter(b => !b.existed)
|
|
605
|
+
.map(b => b.filePath), handler);
|
|
606
|
+
}
|
|
607
|
+
return { checkpoint, restored, removed, errors };
|
|
608
|
+
}
|
|
609
|
+
// ── V2 Diff ─────────────────────────────────────────────────────────
|
|
610
|
+
async getFileDiffV2(checkpoint, manifest, filePath, handler) {
|
|
611
|
+
const backup = manifest.fileBackups.find(b => b.filePath === filePath);
|
|
612
|
+
if (!backup)
|
|
613
|
+
return null; // File wasn't touched by AI in this checkpoint
|
|
614
|
+
const isRemote = checkpoint.contextType !== 'local';
|
|
615
|
+
const backupPath = path.join(path.dirname(checkpoint.manifestPath), 'backups', filePath);
|
|
616
|
+
if (isRemote) {
|
|
617
|
+
if (!handler || !handler.isConnected())
|
|
618
|
+
return null;
|
|
619
|
+
const remotePath = checkpoint.cwd + '/' + filePath;
|
|
620
|
+
let currentContent = null;
|
|
621
|
+
try {
|
|
622
|
+
currentContent = await handler.readFile(remotePath);
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
currentContent = null;
|
|
626
|
+
}
|
|
627
|
+
if (!backup.existed && currentContent !== null) {
|
|
628
|
+
// Added file
|
|
629
|
+
const lines = currentContent.split('\n');
|
|
630
|
+
let diff = `--- /dev/null\n+++ b/${filePath}\n@@ -0,0 +1,${lines.length} @@\n`;
|
|
631
|
+
diff += lines.map(l => `+${l}`).join('\n');
|
|
632
|
+
return diff;
|
|
633
|
+
}
|
|
634
|
+
if (backup.existed && currentContent === null) {
|
|
635
|
+
// Deleted file
|
|
636
|
+
try {
|
|
637
|
+
const content = fs.readFileSync(backupPath, 'utf-8');
|
|
638
|
+
const lines = content.split('\n');
|
|
639
|
+
let diff = `--- a/${filePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n`;
|
|
640
|
+
diff += lines.map(l => `-${l}`).join('\n');
|
|
641
|
+
return diff;
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
return `[Binary or unreadable file: ${filePath}]`;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (backup.existed && currentContent !== null) {
|
|
648
|
+
// Modified file
|
|
649
|
+
try {
|
|
650
|
+
const oldContent = fs.readFileSync(backupPath, 'utf-8');
|
|
651
|
+
return this.generateUnifiedDiff(filePath, oldContent, currentContent);
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
return `[Binary or unreadable file: ${filePath}]`;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
// Created then deleted — no diff
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
// Local diff
|
|
661
|
+
const currentPath = path.join(checkpoint.cwd, filePath);
|
|
662
|
+
const currentExists = fs.existsSync(currentPath);
|
|
663
|
+
if (!backup.existed && currentExists) {
|
|
664
|
+
// Added file
|
|
665
|
+
try {
|
|
666
|
+
const content = fs.readFileSync(currentPath, 'utf-8');
|
|
667
|
+
const lines = content.split('\n');
|
|
668
|
+
let diff = `--- /dev/null\n+++ b/${filePath}\n@@ -0,0 +1,${lines.length} @@\n`;
|
|
669
|
+
diff += lines.map(l => `+${l}`).join('\n');
|
|
670
|
+
return diff;
|
|
671
|
+
}
|
|
672
|
+
catch {
|
|
673
|
+
return `[Binary or unreadable file: ${filePath}]`;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (backup.existed && !currentExists) {
|
|
677
|
+
// Deleted file
|
|
678
|
+
try {
|
|
679
|
+
const content = fs.readFileSync(backupPath, 'utf-8');
|
|
680
|
+
const lines = content.split('\n');
|
|
681
|
+
let diff = `--- a/${filePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n`;
|
|
682
|
+
diff += lines.map(l => `-${l}`).join('\n');
|
|
683
|
+
return diff;
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
return `[Binary or unreadable file: ${filePath}]`;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (backup.existed && currentExists) {
|
|
690
|
+
// Modified file
|
|
691
|
+
try {
|
|
692
|
+
const oldContent = fs.readFileSync(backupPath, 'utf-8');
|
|
693
|
+
const newContent = fs.readFileSync(currentPath, 'utf-8');
|
|
694
|
+
return this.generateUnifiedDiff(filePath, oldContent, newContent);
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
return `[Binary or unreadable file: ${filePath}]`;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
// Created then deleted
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
// ── V1 Legacy Support ───────────────────────────────────────────────
|
|
704
|
+
/**
|
|
705
|
+
* V1 diff: uses full snapshot files (legacy checkpoints).
|
|
706
|
+
*/
|
|
707
|
+
async getFileDiffV1(checkpoint, filePath, handler) {
|
|
708
|
+
const isRemote = checkpoint.contextType !== 'local';
|
|
709
|
+
const snapshotPath = path.join(checkpoint.snapshotDir, filePath);
|
|
710
|
+
const snapshotExists = fs.existsSync(snapshotPath);
|
|
711
|
+
if (isRemote) {
|
|
712
|
+
if (!handler || !handler.isConnected())
|
|
713
|
+
return null;
|
|
714
|
+
const remotePath = checkpoint.cwd + '/' + filePath;
|
|
715
|
+
let remoteContent = null;
|
|
716
|
+
try {
|
|
717
|
+
remoteContent = await handler.readFile(remotePath);
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
remoteContent = null;
|
|
721
|
+
}
|
|
722
|
+
if (!snapshotExists && remoteContent === null)
|
|
723
|
+
return null;
|
|
724
|
+
if (!snapshotExists && remoteContent !== null) {
|
|
725
|
+
const lines = remoteContent.split('\n');
|
|
726
|
+
let diff = `--- /dev/null\n+++ b/${filePath}\n@@ -0,0 +1,${lines.length} @@\n`;
|
|
727
|
+
diff += lines.map(l => `+${l}`).join('\n');
|
|
728
|
+
return diff;
|
|
729
|
+
}
|
|
730
|
+
if (snapshotExists && remoteContent === null) {
|
|
731
|
+
try {
|
|
732
|
+
const content = fs.readFileSync(snapshotPath, 'utf-8');
|
|
733
|
+
const lines = content.split('\n');
|
|
734
|
+
let diff = `--- a/${filePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n`;
|
|
735
|
+
diff += lines.map(l => `-${l}`).join('\n');
|
|
736
|
+
return diff;
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
return `[Binary or unreadable file: ${filePath}]`;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
try {
|
|
743
|
+
const oldContent = fs.readFileSync(snapshotPath, 'utf-8');
|
|
744
|
+
const newContent = remoteContent ?? '';
|
|
745
|
+
return this.generateUnifiedDiff(filePath, oldContent, newContent);
|
|
746
|
+
}
|
|
747
|
+
catch {
|
|
748
|
+
return `[Binary or unreadable file: ${filePath}]`;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
// Local V1 diff
|
|
752
|
+
const currentPath = path.join(checkpoint.cwd, filePath);
|
|
753
|
+
const currentExists = fs.existsSync(currentPath);
|
|
754
|
+
if (!snapshotExists && !currentExists)
|
|
755
|
+
return null;
|
|
756
|
+
if (!snapshotExists && currentExists) {
|
|
757
|
+
try {
|
|
758
|
+
const content = fs.readFileSync(currentPath, 'utf-8');
|
|
759
|
+
const lines = content.split('\n');
|
|
760
|
+
let diff = `--- /dev/null\n+++ b/${filePath}\n@@ -0,0 +1,${lines.length} @@\n`;
|
|
761
|
+
diff += lines.map(l => `+${l}`).join('\n');
|
|
762
|
+
return diff;
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
return `[Binary or unreadable file: ${filePath}]`;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
if (snapshotExists && !currentExists) {
|
|
769
|
+
try {
|
|
770
|
+
const content = fs.readFileSync(snapshotPath, 'utf-8');
|
|
771
|
+
const lines = content.split('\n');
|
|
772
|
+
let diff = `--- a/${filePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n`;
|
|
773
|
+
diff += lines.map(l => `-${l}`).join('\n');
|
|
774
|
+
return diff;
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
return `[Binary or unreadable file: ${filePath}]`;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
try {
|
|
781
|
+
const oldContent = fs.readFileSync(snapshotPath, 'utf-8');
|
|
782
|
+
const newContent = fs.readFileSync(currentPath, 'utf-8');
|
|
783
|
+
return this.generateUnifiedDiff(filePath, oldContent, newContent);
|
|
784
|
+
}
|
|
785
|
+
catch {
|
|
786
|
+
return `[Binary or unreadable file: ${filePath}]`;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
async getSessionChangesV1(checkpoint, changes, stats, isRemote, handler) {
|
|
790
|
+
if (!isRemote) {
|
|
791
|
+
for (const filePath of changes.modified) {
|
|
792
|
+
const snapshotPath = path.join(checkpoint.snapshotDir, filePath);
|
|
793
|
+
const currentPath = path.join(checkpoint.cwd, filePath);
|
|
794
|
+
const lineStat = this.calculateLineStats(snapshotPath, currentPath);
|
|
795
|
+
stats.push({ filePath, ...lineStat });
|
|
796
|
+
}
|
|
797
|
+
for (const filePath of changes.added) {
|
|
798
|
+
const currentPath = path.join(checkpoint.cwd, filePath);
|
|
799
|
+
try {
|
|
800
|
+
const content = fs.readFileSync(currentPath, 'utf-8');
|
|
801
|
+
const lines = content.split('\n').length;
|
|
802
|
+
stats.push({ filePath, insertions: lines, deletions: 0 });
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
stats.push({ filePath, insertions: 0, deletions: 0 });
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
for (const filePath of changes.deleted) {
|
|
809
|
+
const snapshotPath = path.join(checkpoint.snapshotDir, filePath);
|
|
810
|
+
try {
|
|
811
|
+
const content = fs.readFileSync(snapshotPath, 'utf-8');
|
|
812
|
+
const lines = content.split('\n').length;
|
|
813
|
+
stats.push({ filePath, insertions: 0, deletions: lines });
|
|
814
|
+
}
|
|
815
|
+
catch {
|
|
816
|
+
stats.push({ filePath, insertions: 0, deletions: 0 });
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
else if (handler) {
|
|
821
|
+
for (const filePath of changes.modified) {
|
|
822
|
+
const snapshotPath = path.join(checkpoint.snapshotDir, filePath);
|
|
823
|
+
const remotePath = checkpoint.cwd + '/' + filePath;
|
|
824
|
+
try {
|
|
825
|
+
const snapshotContent = fs.readFileSync(snapshotPath, 'utf-8');
|
|
826
|
+
const remoteContent = await handler.readFile(remotePath);
|
|
827
|
+
const lineStat = this.calculateLineStatsFromContent(snapshotContent, remoteContent);
|
|
828
|
+
stats.push({ filePath, ...lineStat });
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
stats.push({ filePath, insertions: 0, deletions: 0 });
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
for (const filePath of changes.added) {
|
|
835
|
+
const remotePath = checkpoint.cwd + '/' + filePath;
|
|
836
|
+
try {
|
|
837
|
+
const remoteContent = await handler.readFile(remotePath);
|
|
838
|
+
const lines = remoteContent.split('\n').length;
|
|
839
|
+
stats.push({ filePath, insertions: lines, deletions: 0 });
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
stats.push({ filePath, insertions: 0, deletions: 0 });
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
for (const filePath of changes.deleted) {
|
|
846
|
+
const snapshotPath = path.join(checkpoint.snapshotDir, filePath);
|
|
847
|
+
try {
|
|
848
|
+
const content = fs.readFileSync(snapshotPath, 'utf-8');
|
|
849
|
+
const lines = content.split('\n').length;
|
|
850
|
+
stats.push({ filePath, insertions: 0, deletions: lines });
|
|
851
|
+
}
|
|
852
|
+
catch {
|
|
853
|
+
stats.push({ filePath, insertions: 0, deletions: 0 });
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* V1 local revert: legacy full-scan approach.
|
|
860
|
+
*/
|
|
861
|
+
async revertLocalCheckpointV1(checkpoint, manifest) {
|
|
862
|
+
const manifestSet = new Set(manifest.files.map(file => file.path));
|
|
863
|
+
const currentFiles = await this.scanLocalFiles(checkpoint.cwd);
|
|
864
|
+
const errors = [];
|
|
865
|
+
let removed = 0;
|
|
866
|
+
for (const filePath of currentFiles) {
|
|
867
|
+
if (!manifestSet.has(filePath)) {
|
|
868
|
+
const absolutePath = path.join(checkpoint.cwd, filePath);
|
|
869
|
+
try {
|
|
870
|
+
this.removeFileOrDirSync(absolutePath);
|
|
871
|
+
removed++;
|
|
872
|
+
}
|
|
873
|
+
catch (error) {
|
|
874
|
+
errors.push(`Failed to remove ${filePath}: ${error.message}`);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
let restored = 0;
|
|
879
|
+
for (const file of manifest.files) {
|
|
880
|
+
const sourcePath = path.join(checkpoint.snapshotDir, file.path);
|
|
881
|
+
const targetPath = path.join(checkpoint.cwd, file.path);
|
|
882
|
+
try {
|
|
883
|
+
this.ensureDirSync(path.dirname(targetPath));
|
|
884
|
+
this.removeFileOrDirSync(targetPath);
|
|
885
|
+
fs.copyFileSync(sourcePath, targetPath);
|
|
886
|
+
restored++;
|
|
887
|
+
}
|
|
888
|
+
catch (error) {
|
|
889
|
+
errors.push(`Failed to restore ${file.path}: ${error.message}`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
// Clean up empty directories
|
|
893
|
+
const manifestDirs = new Set();
|
|
894
|
+
for (const file of manifest.files) {
|
|
895
|
+
let dir = path.dirname(file.path);
|
|
896
|
+
while (dir && dir !== '.' && dir !== '/') {
|
|
897
|
+
manifestDirs.add(dir);
|
|
898
|
+
dir = path.dirname(dir);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
try {
|
|
902
|
+
const allDirs = this.scanLocalDirectories(checkpoint.cwd, checkpoint.cwd);
|
|
903
|
+
allDirs.sort((a, b) => b.split(path.sep).length - a.split(path.sep).length);
|
|
904
|
+
for (const dir of allDirs) {
|
|
905
|
+
if (!manifestDirs.has(dir)) {
|
|
906
|
+
const absDir = path.join(checkpoint.cwd, dir);
|
|
907
|
+
try {
|
|
908
|
+
const entries = fs.readdirSync(absDir);
|
|
909
|
+
if (entries.length === 0) {
|
|
910
|
+
fs.rmdirSync(absDir);
|
|
911
|
+
removed++;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
catch {
|
|
915
|
+
// Skip
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
catch {
|
|
921
|
+
// Non-critical
|
|
922
|
+
}
|
|
923
|
+
return { checkpoint, restored, removed, errors };
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* V1 remote revert: legacy full-scan approach.
|
|
927
|
+
*/
|
|
928
|
+
async revertRemoteCheckpointV1(checkpoint, manifest, handler) {
|
|
929
|
+
const manifestSet = new Set(manifest.files.map(file => file.path));
|
|
930
|
+
const currentFiles = await this.scanRemoteFiles(checkpoint.cwd, handler);
|
|
931
|
+
const errors = [];
|
|
932
|
+
let removed = 0;
|
|
933
|
+
for (const filePath of currentFiles) {
|
|
934
|
+
if (!manifestSet.has(filePath)) {
|
|
935
|
+
const remotePath = checkpoint.cwd + '/' + filePath;
|
|
936
|
+
try {
|
|
937
|
+
const result = await handler.executeCommand(`rm -f "${remotePath}"`);
|
|
938
|
+
if (result.exitCode === 0) {
|
|
939
|
+
removed++;
|
|
940
|
+
}
|
|
941
|
+
else {
|
|
942
|
+
errors.push(`Failed to remove ${filePath}: ${result.stderr}`);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
catch (error) {
|
|
946
|
+
errors.push(`Failed to remove ${filePath}: ${error.message}`);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
let restored = 0;
|
|
951
|
+
for (const file of manifest.files) {
|
|
952
|
+
const localSnapshotPath = path.join(checkpoint.snapshotDir, file.path);
|
|
953
|
+
const remotePath = checkpoint.cwd + '/' + file.path;
|
|
954
|
+
try {
|
|
955
|
+
const content = fs.readFileSync(localSnapshotPath, 'utf-8');
|
|
956
|
+
const remoteDir = remotePath.substring(0, remotePath.lastIndexOf('/'));
|
|
957
|
+
await handler.executeCommand(`mkdir -p "${remoteDir}"`);
|
|
958
|
+
await handler.writeFile(remotePath, content);
|
|
959
|
+
restored++;
|
|
960
|
+
}
|
|
961
|
+
catch (error) {
|
|
962
|
+
errors.push(`Failed to restore ${file.path}: ${error.message}`);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
// Clean up empty directories
|
|
966
|
+
const manifestDirs = new Set();
|
|
967
|
+
for (const file of manifest.files) {
|
|
968
|
+
let dir = file.path.substring(0, file.path.lastIndexOf('/'));
|
|
969
|
+
while (dir && dir !== '.' && dir !== '/') {
|
|
970
|
+
manifestDirs.add(dir);
|
|
971
|
+
const lastSlash = dir.lastIndexOf('/');
|
|
972
|
+
dir = lastSlash > 0 ? dir.substring(0, lastSlash) : '';
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
try {
|
|
976
|
+
const findDirsCmd = `find "${checkpoint.cwd}" -mindepth 1 -type d 2>/dev/null`;
|
|
977
|
+
const dirsResult = await handler.executeCommand(findDirsCmd);
|
|
978
|
+
if (dirsResult.exitCode === 0 && dirsResult.stdout.trim()) {
|
|
979
|
+
const cwdPrefix = checkpoint.cwd.endsWith('/') ? checkpoint.cwd : checkpoint.cwd + '/';
|
|
980
|
+
const remoteDirs = dirsResult.stdout
|
|
981
|
+
.split('\n')
|
|
982
|
+
.map((l) => l.trim())
|
|
983
|
+
.filter((l) => l.length > 0 && l.startsWith(cwdPrefix))
|
|
984
|
+
.map((l) => l.substring(cwdPrefix.length))
|
|
985
|
+
.filter((relPath) => relPath.length > 0);
|
|
986
|
+
remoteDirs.sort((a, b) => b.split('/').length - a.split('/').length);
|
|
987
|
+
for (const dir of remoteDirs) {
|
|
988
|
+
if (!manifestDirs.has(dir)) {
|
|
989
|
+
const remoteDirPath = checkpoint.cwd + '/' + dir;
|
|
990
|
+
try {
|
|
991
|
+
const rmResult = await handler.executeCommand(`rmdir "${remoteDirPath}" 2>/dev/null`);
|
|
992
|
+
if (rmResult.exitCode === 0) {
|
|
993
|
+
removed++;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
catch {
|
|
997
|
+
// Skip
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
catch {
|
|
1004
|
+
// Non-critical
|
|
1005
|
+
}
|
|
1006
|
+
return { checkpoint, restored, removed, errors };
|
|
1007
|
+
}
|
|
1008
|
+
// ── Change calculation ──────────────────────────────────────────────
|
|
1009
|
+
async calculateChanges(checkpoint, handler) {
|
|
1010
|
+
const manifest = this.readManifest(checkpoint.manifestPath);
|
|
1011
|
+
if (this.isV2Manifest(manifest)) {
|
|
1012
|
+
return this.calculateChangesV2(checkpoint, manifest, handler);
|
|
1013
|
+
}
|
|
1014
|
+
else {
|
|
1015
|
+
return this.calculateChangesV1(checkpoint, manifest, handler);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* V2: Calculate changes from backup entries only.
|
|
1020
|
+
* No directory scanning needed — we only track files the AI touched.
|
|
1021
|
+
*/
|
|
1022
|
+
async calculateChangesV2(checkpoint, manifest, handler) {
|
|
1023
|
+
const isRemote = checkpoint.contextType !== 'local' && handler && handler.isConnected();
|
|
1024
|
+
const added = [];
|
|
1025
|
+
const modified = [];
|
|
1026
|
+
const deleted = [];
|
|
1027
|
+
for (const backup of manifest.fileBackups) {
|
|
1028
|
+
const backupPath = path.join(path.dirname(checkpoint.manifestPath), 'backups', backup.filePath);
|
|
1029
|
+
if (isRemote && handler) {
|
|
1030
|
+
const remotePath = checkpoint.cwd + '/' + backup.filePath;
|
|
1031
|
+
try {
|
|
1032
|
+
if (backup.existed) {
|
|
1033
|
+
try {
|
|
1034
|
+
const remoteContent = await handler.readFile(remotePath);
|
|
1035
|
+
const backupContent = fs.readFileSync(backupPath, 'utf-8');
|
|
1036
|
+
if (remoteContent !== backupContent) {
|
|
1037
|
+
modified.push(backup.filePath);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
catch {
|
|
1041
|
+
deleted.push(backup.filePath);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
else {
|
|
1045
|
+
try {
|
|
1046
|
+
await handler.readFile(remotePath);
|
|
1047
|
+
added.push(backup.filePath);
|
|
1048
|
+
}
|
|
1049
|
+
catch {
|
|
1050
|
+
// Created then deleted — no net change
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
catch {
|
|
1055
|
+
// Skip on error
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
else {
|
|
1059
|
+
// Local
|
|
1060
|
+
const currentPath = path.join(checkpoint.cwd, backup.filePath);
|
|
1061
|
+
if (backup.existed) {
|
|
1062
|
+
if (fs.existsSync(currentPath)) {
|
|
1063
|
+
if (this.filesDiffer(backupPath, currentPath)) {
|
|
1064
|
+
modified.push(backup.filePath);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
else {
|
|
1068
|
+
deleted.push(backup.filePath);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
else {
|
|
1072
|
+
if (fs.existsSync(currentPath)) {
|
|
1073
|
+
added.push(backup.filePath);
|
|
1074
|
+
}
|
|
1075
|
+
// If doesn't exist — created then deleted, no change
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return { added, modified, deleted };
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* V1 legacy: full directory scan approach.
|
|
1083
|
+
*/
|
|
1084
|
+
async calculateChangesV1(checkpoint, manifest, handler) {
|
|
1085
|
+
const manifestSet = new Set(manifest.files.map(file => file.path));
|
|
1086
|
+
const isRemote = checkpoint.contextType !== 'local' && handler && handler.isConnected();
|
|
1087
|
+
const currentFiles = isRemote
|
|
1088
|
+
? await this.scanRemoteFiles(checkpoint.cwd, handler)
|
|
1089
|
+
: await this.scanLocalFiles(checkpoint.cwd);
|
|
1090
|
+
const currentSet = new Set(currentFiles);
|
|
1091
|
+
const added = [];
|
|
1092
|
+
const deleted = [];
|
|
1093
|
+
const modified = [];
|
|
1094
|
+
for (const filePath of currentFiles) {
|
|
1095
|
+
if (!manifestSet.has(filePath)) {
|
|
1096
|
+
added.push(filePath);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
for (const file of manifest.files) {
|
|
1100
|
+
if (!currentSet.has(file.path)) {
|
|
1101
|
+
deleted.push(file.path);
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
if (isRemote && handler) {
|
|
1105
|
+
try {
|
|
1106
|
+
const remotePath = checkpoint.cwd + '/' + file.path;
|
|
1107
|
+
const remoteContent = await handler.readFile(remotePath);
|
|
1108
|
+
const snapshotPath = path.join(checkpoint.snapshotDir, file.path);
|
|
1109
|
+
const snapshotContent = fs.readFileSync(snapshotPath, 'utf-8');
|
|
1110
|
+
if (remoteContent !== snapshotContent) {
|
|
1111
|
+
modified.push(file.path);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
catch {
|
|
1115
|
+
modified.push(file.path);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
else {
|
|
1119
|
+
const currentPath = path.join(checkpoint.cwd, file.path);
|
|
1120
|
+
const snapshotPath = path.join(checkpoint.snapshotDir, file.path);
|
|
1121
|
+
if (this.filesDiffer(snapshotPath, currentPath)) {
|
|
1122
|
+
modified.push(file.path);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
return { added, modified, deleted };
|
|
1127
|
+
}
|
|
1128
|
+
// ── Helpers: directory cleanup ──────────────────────────────────────
|
|
1129
|
+
/**
|
|
1130
|
+
* Clean up empty directories after deleting files that the AI created.
|
|
1131
|
+
*/
|
|
1132
|
+
cleanupEmptyDirectories(cwd, deletedRelPaths) {
|
|
1133
|
+
// Gather unique parent directories of deleted files, sorted deepest first
|
|
1134
|
+
const dirs = new Set();
|
|
1135
|
+
for (const relPath of deletedRelPaths) {
|
|
1136
|
+
let dir = path.dirname(relPath);
|
|
1137
|
+
while (dir && dir !== '.' && dir !== '/' && dir !== '\\') {
|
|
1138
|
+
dirs.add(dir);
|
|
1139
|
+
dir = path.dirname(dir);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
const sortedDirs = Array.from(dirs).sort((a, b) => b.split('/').length - a.split('/').length);
|
|
1143
|
+
for (const dir of sortedDirs) {
|
|
1144
|
+
const absDir = path.join(cwd, dir);
|
|
1145
|
+
try {
|
|
1146
|
+
if (fs.existsSync(absDir)) {
|
|
1147
|
+
const entries = fs.readdirSync(absDir);
|
|
1148
|
+
if (entries.length === 0) {
|
|
1149
|
+
fs.rmdirSync(absDir);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
catch {
|
|
1154
|
+
// Best-effort
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
async cleanupEmptyDirectoriesRemote(cwd, deletedRelPaths, handler) {
|
|
1159
|
+
const dirs = new Set();
|
|
1160
|
+
for (const relPath of deletedRelPaths) {
|
|
1161
|
+
let dir = relPath.substring(0, relPath.lastIndexOf('/'));
|
|
1162
|
+
while (dir && dir !== '.' && dir !== '/') {
|
|
1163
|
+
dirs.add(dir);
|
|
1164
|
+
const lastSlash = dir.lastIndexOf('/');
|
|
1165
|
+
dir = lastSlash > 0 ? dir.substring(0, lastSlash) : '';
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
const sortedDirs = Array.from(dirs).sort((a, b) => b.split('/').length - a.split('/').length);
|
|
1169
|
+
for (const dir of sortedDirs) {
|
|
1170
|
+
const remoteDirPath = cwd + '/' + dir;
|
|
1171
|
+
try {
|
|
1172
|
+
await handler.executeCommand(`rmdir "${remoteDirPath}" 2>/dev/null`);
|
|
1173
|
+
}
|
|
1174
|
+
catch {
|
|
1175
|
+
// Best-effort
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
// ── Helpers: line stats and diff generation ─────────────────────────
|
|
243
1180
|
calculateLineStats(snapshotPath, currentPath) {
|
|
244
1181
|
try {
|
|
245
1182
|
const oldContent = fs.readFileSync(snapshotPath, 'utf-8');
|
|
@@ -253,7 +1190,6 @@ export class CheckpointManager {
|
|
|
253
1190
|
calculateLineStatsFromContent(oldContent, newContent) {
|
|
254
1191
|
const oldLines = oldContent.split('\n');
|
|
255
1192
|
const newLines = newContent.split('\n');
|
|
256
|
-
// Simple LCS-based diff to count insertions and deletions
|
|
257
1193
|
const oldSet = new Map();
|
|
258
1194
|
for (const line of oldLines) {
|
|
259
1195
|
oldSet.set(line, (oldSet.get(line) || 0) + 1);
|
|
@@ -278,15 +1214,10 @@ export class CheckpointManager {
|
|
|
278
1214
|
}
|
|
279
1215
|
return { insertions, deletions };
|
|
280
1216
|
}
|
|
281
|
-
/**
|
|
282
|
-
* Generate a unified diff between old and new content
|
|
283
|
-
*/
|
|
284
1217
|
generateUnifiedDiff(filePath, oldContent, newContent) {
|
|
285
1218
|
const oldLines = oldContent.split('\n');
|
|
286
1219
|
const newLines = newContent.split('\n');
|
|
287
|
-
// Use a simple diff algorithm: find common prefix/suffix, then show changes
|
|
288
1220
|
let result = `--- a/${filePath}\n+++ b/${filePath}\n`;
|
|
289
|
-
// Find changed regions using a simple approach
|
|
290
1221
|
const hunks = this.computeHunks(oldLines, newLines);
|
|
291
1222
|
if (hunks.length === 0) {
|
|
292
1223
|
return `No differences found in ${filePath}`;
|
|
@@ -299,11 +1230,7 @@ export class CheckpointManager {
|
|
|
299
1230
|
}
|
|
300
1231
|
return result;
|
|
301
1232
|
}
|
|
302
|
-
/**
|
|
303
|
-
* Compute diff hunks between old and new lines using Myers-like approach
|
|
304
|
-
*/
|
|
305
1233
|
computeHunks(oldLines, newLines) {
|
|
306
|
-
// Build an edit script using LCS
|
|
307
1234
|
const lcs = this.longestCommonSubsequence(oldLines, newLines);
|
|
308
1235
|
const editScript = [];
|
|
309
1236
|
let oldIdx = 0;
|
|
@@ -326,7 +1253,6 @@ export class CheckpointManager {
|
|
|
326
1253
|
newIdx++;
|
|
327
1254
|
}
|
|
328
1255
|
}
|
|
329
|
-
// Group into hunks with context (3 lines)
|
|
330
1256
|
const CONTEXT = 3;
|
|
331
1257
|
const hunks = [];
|
|
332
1258
|
let currentHunk = null;
|
|
@@ -334,9 +1260,7 @@ export class CheckpointManager {
|
|
|
334
1260
|
for (let i = 0; i < editScript.length; i++) {
|
|
335
1261
|
const edit = editScript[i];
|
|
336
1262
|
if (edit.type !== 'keep') {
|
|
337
|
-
// Start a new hunk or extend current
|
|
338
1263
|
if (!currentHunk) {
|
|
339
|
-
// Look back for context
|
|
340
1264
|
const contextStart = Math.max(0, i - CONTEXT);
|
|
341
1265
|
currentHunk = {
|
|
342
1266
|
oldStart: editScript[contextStart]?.oldIdx ?? edit.oldIdx,
|
|
@@ -370,528 +1294,147 @@ export class CheckpointManager {
|
|
|
370
1294
|
currentHunk.oldCount++;
|
|
371
1295
|
currentHunk.newCount++;
|
|
372
1296
|
}
|
|
373
|
-
else {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
contextCounter = 0;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
if (currentHunk) {
|
|
382
|
-
hunks.push(currentHunk);
|
|
383
|
-
}
|
|
384
|
-
return hunks;
|
|
385
|
-
}
|
|
386
|
-
/**
|
|
387
|
-
* Compute longest common subsequence of two string arrays
|
|
388
|
-
* Uses optimized approach for large files (limit LCS table size)
|
|
389
|
-
*/
|
|
390
|
-
longestCommonSubsequence(a, b) {
|
|
391
|
-
// For very large files, use a simpler approach
|
|
392
|
-
if (a.length > 1000 || b.length > 1000) {
|
|
393
|
-
return this.simpleLCS(a, b);
|
|
394
|
-
}
|
|
395
|
-
const m = a.length;
|
|
396
|
-
const n = b.length;
|
|
397
|
-
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
398
|
-
for (let i = 1; i <= m; i++) {
|
|
399
|
-
for (let j = 1; j <= n; j++) {
|
|
400
|
-
if (a[i - 1] === b[j - 1]) {
|
|
401
|
-
dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
402
|
-
}
|
|
403
|
-
else {
|
|
404
|
-
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
// Backtrack
|
|
409
|
-
const result = [];
|
|
410
|
-
let i = m, j = n;
|
|
411
|
-
while (i > 0 && j > 0) {
|
|
412
|
-
if (a[i - 1] === b[j - 1]) {
|
|
413
|
-
result.unshift(a[i - 1]);
|
|
414
|
-
i--;
|
|
415
|
-
j--;
|
|
416
|
-
}
|
|
417
|
-
else if (dp[i - 1][j] > dp[i][j - 1]) {
|
|
418
|
-
i--;
|
|
419
|
-
}
|
|
420
|
-
else {
|
|
421
|
-
j--;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
return result;
|
|
425
|
-
}
|
|
426
|
-
/**
|
|
427
|
-
* Simple LCS for large files - uses hash-based matching
|
|
428
|
-
*/
|
|
429
|
-
simpleLCS(a, b) {
|
|
430
|
-
const bMap = new Map();
|
|
431
|
-
for (let i = 0; i < b.length; i++) {
|
|
432
|
-
const positions = bMap.get(b[i]) || [];
|
|
433
|
-
positions.push(i);
|
|
434
|
-
bMap.set(b[i], positions);
|
|
435
|
-
}
|
|
436
|
-
const result = [];
|
|
437
|
-
let lastMatchB = -1;
|
|
438
|
-
for (let i = 0; i < a.length; i++) {
|
|
439
|
-
const positions = bMap.get(a[i]);
|
|
440
|
-
if (positions) {
|
|
441
|
-
// Find earliest position after lastMatchB
|
|
442
|
-
for (const pos of positions) {
|
|
443
|
-
if (pos > lastMatchB) {
|
|
444
|
-
result.push(a[i]);
|
|
445
|
-
lastMatchB = pos;
|
|
446
|
-
break;
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
return result;
|
|
452
|
-
}
|
|
453
|
-
markDiscarded(id) {
|
|
454
|
-
this.discardedIds.add(id);
|
|
455
|
-
}
|
|
456
|
-
async startCheckpoint(params) {
|
|
457
|
-
if (!this.currentChatId) {
|
|
458
|
-
return null;
|
|
459
|
-
}
|
|
460
|
-
const checkpointId = this.generateCheckpointId();
|
|
461
|
-
const checkpointDir = path.join(this.getChatDir(), checkpointId);
|
|
462
|
-
const snapshotDir = path.join(checkpointDir, 'snapshot');
|
|
463
|
-
const manifestPath = path.join(checkpointDir, 'manifest.json');
|
|
464
|
-
try {
|
|
465
|
-
this.ensureDirSync(snapshotDir);
|
|
466
|
-
// Use remote snapshot when a handler is provided (SSH/WSL/Docker sessions)
|
|
467
|
-
const isRemote = params.contextType !== 'local' && params.handler;
|
|
468
|
-
const manifest = isRemote
|
|
469
|
-
? await this.createRemoteSnapshot(params.cwd, snapshotDir, params.handler)
|
|
470
|
-
: await this.createSnapshot(params.cwd, snapshotDir);
|
|
471
|
-
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
|
|
472
|
-
const meta = {
|
|
473
|
-
id: checkpointId,
|
|
474
|
-
prompt: params.prompt,
|
|
475
|
-
createdAt: new Date().toISOString(),
|
|
476
|
-
createdAtMs: Date.now(),
|
|
477
|
-
cwd: params.cwd,
|
|
478
|
-
contextType: params.contextType,
|
|
479
|
-
remoteSessionInfo: params.remoteSessionInfo,
|
|
480
|
-
conversationIndex: params.conversationIndex,
|
|
481
|
-
uiMessageIndex: params.uiMessageIndex,
|
|
482
|
-
uiMessageId: params.uiMessageId,
|
|
483
|
-
snapshotDir,
|
|
484
|
-
manifestPath,
|
|
485
|
-
commands: [],
|
|
486
|
-
toolCalls: [],
|
|
487
|
-
status: 'active',
|
|
488
|
-
};
|
|
489
|
-
this.checkpoints.push(meta);
|
|
490
|
-
this.saveIndex();
|
|
491
|
-
return meta;
|
|
492
|
-
}
|
|
493
|
-
catch (error) {
|
|
494
|
-
logError('Failed to create checkpoint snapshot', error);
|
|
495
|
-
return null;
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
async finalizeCheckpoint(id) {
|
|
499
|
-
const checkpoint = this.checkpoints.find(cp => cp.id === id);
|
|
500
|
-
if (!checkpoint)
|
|
501
|
-
return;
|
|
502
|
-
if (this.discardedIds.has(id)) {
|
|
503
|
-
this.discardCheckpoint(id);
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
506
|
-
try {
|
|
507
|
-
const changes = await this.calculateChanges(checkpoint);
|
|
508
|
-
checkpoint.changes = changes;
|
|
509
|
-
checkpoint.status = 'finalized';
|
|
510
|
-
this.saveIndex();
|
|
511
|
-
}
|
|
512
|
-
catch (error) {
|
|
513
|
-
logWarning(`Failed to finalize checkpoint ${id}: ${error.message}`);
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
recordToolCall(id, toolCall) {
|
|
517
|
-
const checkpoint = this.checkpoints.find(cp => cp.id === id);
|
|
518
|
-
if (!checkpoint || checkpoint.status === 'discarded')
|
|
519
|
-
return;
|
|
520
|
-
if (toolCall.id && checkpoint.toolCalls.some(tc => tc.id === toolCall.id)) {
|
|
521
|
-
return;
|
|
522
|
-
}
|
|
523
|
-
checkpoint.toolCalls.push(toolCall);
|
|
524
|
-
if (toolCall.name === 'execute_command') {
|
|
525
|
-
const command = toolCall.arguments?.CommandLine || toolCall.arguments?.command;
|
|
526
|
-
const isShellInput = Boolean(toolCall.arguments?.shell_input);
|
|
527
|
-
if (command && !isShellInput) {
|
|
528
|
-
checkpoint.commands.push(String(command));
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
if (toolCall.name === 'background_command') {
|
|
532
|
-
const command = toolCall.arguments?.command;
|
|
533
|
-
const action = toolCall.arguments?.action;
|
|
534
|
-
if (command && action === 'start') {
|
|
535
|
-
checkpoint.commands.push(String(command));
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
this.saveIndex();
|
|
539
|
-
}
|
|
540
|
-
async revertToCheckpoint(id, handler) {
|
|
541
|
-
const checkpoint = this.checkpoints.find(cp => cp.id === id);
|
|
542
|
-
if (!checkpoint) {
|
|
543
|
-
throw new Error(`Checkpoint "${id}" not found`);
|
|
544
|
-
}
|
|
545
|
-
// Remote checkpoint requires a connected handler to revert
|
|
546
|
-
if (checkpoint.contextType !== 'local') {
|
|
547
|
-
if (!handler || !handler.isConnected()) {
|
|
548
|
-
const sessionType = checkpoint.contextType.toUpperCase();
|
|
549
|
-
const sessionInfo = checkpoint.remoteSessionInfo;
|
|
550
|
-
const target = sessionInfo?.connectionString || sessionInfo?.hostname || 'the remote machine';
|
|
551
|
-
throw new Error(`This checkpoint was created during a ${sessionType} session (${target}). ` +
|
|
552
|
-
`You are not currently connected to that session. Please reconnect to the ${sessionType} session first, then retry /revert.`);
|
|
553
|
-
}
|
|
554
|
-
return this.revertRemoteCheckpoint(checkpoint, handler);
|
|
555
|
-
}
|
|
556
|
-
// Local revert (original logic)
|
|
557
|
-
const manifest = this.readManifest(checkpoint.manifestPath);
|
|
558
|
-
const manifestSet = new Set(manifest.files.map(file => file.path));
|
|
559
|
-
const currentFiles = await this.scanFiles(checkpoint.cwd);
|
|
560
|
-
const errors = [];
|
|
561
|
-
let removed = 0;
|
|
562
|
-
for (const filePath of currentFiles) {
|
|
563
|
-
if (!manifestSet.has(filePath)) {
|
|
564
|
-
const absolutePath = path.join(checkpoint.cwd, filePath);
|
|
565
|
-
try {
|
|
566
|
-
this.removeFileOrDirSync(absolutePath);
|
|
567
|
-
removed++;
|
|
568
|
-
}
|
|
569
|
-
catch (error) {
|
|
570
|
-
errors.push(`Failed to remove ${filePath}: ${error.message}`);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
let restored = 0;
|
|
575
|
-
for (const file of manifest.files) {
|
|
576
|
-
const sourcePath = path.join(checkpoint.snapshotDir, file.path);
|
|
577
|
-
const targetPath = path.join(checkpoint.cwd, file.path);
|
|
578
|
-
try {
|
|
579
|
-
this.ensureDirSync(path.dirname(targetPath));
|
|
580
|
-
this.removeFileOrDirSync(targetPath);
|
|
581
|
-
fs.copyFileSync(sourcePath, targetPath);
|
|
582
|
-
restored++;
|
|
583
|
-
}
|
|
584
|
-
catch (error) {
|
|
585
|
-
errors.push(`Failed to restore ${file.path}: ${error.message}`);
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
// Clean up empty directories that were created after the checkpoint
|
|
589
|
-
// Build the set of directories that existed in the manifest
|
|
590
|
-
const manifestDirs = new Set();
|
|
591
|
-
for (const file of manifest.files) {
|
|
592
|
-
let dir = path.dirname(file.path);
|
|
593
|
-
while (dir && dir !== '.' && dir !== '/') {
|
|
594
|
-
manifestDirs.add(dir);
|
|
595
|
-
dir = path.dirname(dir);
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
// Walk the cwd and find directories not in the manifest, remove if empty (deepest first)
|
|
599
|
-
try {
|
|
600
|
-
const allDirs = this.scanLocalDirectories(checkpoint.cwd, checkpoint.cwd);
|
|
601
|
-
// Sort deepest first so nested empty dirs are removed before their parents
|
|
602
|
-
allDirs.sort((a, b) => b.split(path.sep).length - a.split(path.sep).length);
|
|
603
|
-
for (const dir of allDirs) {
|
|
604
|
-
if (!manifestDirs.has(dir)) {
|
|
605
|
-
const absDir = path.join(checkpoint.cwd, dir);
|
|
606
|
-
try {
|
|
607
|
-
const entries = fs.readdirSync(absDir);
|
|
608
|
-
if (entries.length === 0) {
|
|
609
|
-
fs.rmdirSync(absDir);
|
|
610
|
-
removed++;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
catch {
|
|
614
|
-
// Directory may already be gone or inaccessible, skip
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
catch {
|
|
620
|
-
// Non-critical: directory cleanup is best-effort
|
|
621
|
-
}
|
|
622
|
-
return { checkpoint, restored, removed, errors };
|
|
623
|
-
}
|
|
624
|
-
/**
|
|
625
|
-
* Revert a remote checkpoint by uploading snapshot files back to the remote machine.
|
|
626
|
-
*/
|
|
627
|
-
async revertRemoteCheckpoint(checkpoint, handler) {
|
|
628
|
-
const manifest = this.readManifest(checkpoint.manifestPath);
|
|
629
|
-
const manifestSet = new Set(manifest.files.map(file => file.path));
|
|
630
|
-
const currentFiles = await this.scanRemoteFiles(checkpoint.cwd, handler);
|
|
631
|
-
const errors = [];
|
|
632
|
-
// Remove files that were added after the checkpoint
|
|
633
|
-
let removed = 0;
|
|
634
|
-
for (const filePath of currentFiles) {
|
|
635
|
-
if (!manifestSet.has(filePath)) {
|
|
636
|
-
const remotePath = checkpoint.cwd + '/' + filePath;
|
|
637
|
-
try {
|
|
638
|
-
const result = await handler.executeCommand(`rm -f "${remotePath}"`);
|
|
639
|
-
if (result.exitCode === 0) {
|
|
640
|
-
removed++;
|
|
641
|
-
}
|
|
642
|
-
else {
|
|
643
|
-
errors.push(`Failed to remove ${filePath}: ${result.stderr}`);
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
catch (error) {
|
|
647
|
-
errors.push(`Failed to remove ${filePath}: ${error.message}`);
|
|
1297
|
+
else {
|
|
1298
|
+
hunks.push(currentHunk);
|
|
1299
|
+
currentHunk = null;
|
|
1300
|
+
contextCounter = 0;
|
|
648
1301
|
}
|
|
649
1302
|
}
|
|
650
1303
|
}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
for (const file of manifest.files) {
|
|
654
|
-
const localSnapshotPath = path.join(checkpoint.snapshotDir, file.path);
|
|
655
|
-
const remotePath = checkpoint.cwd + '/' + file.path;
|
|
656
|
-
try {
|
|
657
|
-
// Read the snapshot content from local storage
|
|
658
|
-
const content = fs.readFileSync(localSnapshotPath, 'utf-8');
|
|
659
|
-
// Ensure remote directory exists
|
|
660
|
-
const remoteDir = remotePath.substring(0, remotePath.lastIndexOf('/'));
|
|
661
|
-
await handler.executeCommand(`mkdir -p "${remoteDir}"`);
|
|
662
|
-
// Write the file back to the remote machine
|
|
663
|
-
await handler.writeFile(remotePath, content);
|
|
664
|
-
restored++;
|
|
665
|
-
}
|
|
666
|
-
catch (error) {
|
|
667
|
-
errors.push(`Failed to restore ${file.path}: ${error.message}`);
|
|
668
|
-
}
|
|
1304
|
+
if (currentHunk) {
|
|
1305
|
+
hunks.push(currentHunk);
|
|
669
1306
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
while (dir && dir !== '.' && dir !== '/') {
|
|
676
|
-
manifestDirs.add(dir);
|
|
677
|
-
const lastSlash = dir.lastIndexOf('/');
|
|
678
|
-
dir = lastSlash > 0 ? dir.substring(0, lastSlash) : '';
|
|
679
|
-
}
|
|
1307
|
+
return hunks;
|
|
1308
|
+
}
|
|
1309
|
+
longestCommonSubsequence(a, b) {
|
|
1310
|
+
if (a.length > 1000 || b.length > 1000) {
|
|
1311
|
+
return this.simpleLCS(a, b);
|
|
680
1312
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
.
|
|
691
|
-
.map((l) => l.substring(cwdPrefix.length))
|
|
692
|
-
.filter((relPath) => relPath.length > 0);
|
|
693
|
-
// Sort deepest first so nested empty dirs are removed before their parents
|
|
694
|
-
remoteDirs.sort((a, b) => b.split('/').length - a.split('/').length);
|
|
695
|
-
for (const dir of remoteDirs) {
|
|
696
|
-
if (!manifestDirs.has(dir)) {
|
|
697
|
-
const remoteDirPath = checkpoint.cwd + '/' + dir;
|
|
698
|
-
try {
|
|
699
|
-
// rmdir only removes empty directories (safe)
|
|
700
|
-
const rmResult = await handler.executeCommand(`rmdir "${remoteDirPath}" 2>/dev/null`);
|
|
701
|
-
if (rmResult.exitCode === 0) {
|
|
702
|
-
removed++;
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
catch {
|
|
706
|
-
// Directory not empty or inaccessible, skip
|
|
707
|
-
}
|
|
708
|
-
}
|
|
1313
|
+
const m = a.length;
|
|
1314
|
+
const n = b.length;
|
|
1315
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
1316
|
+
for (let i = 1; i <= m; i++) {
|
|
1317
|
+
for (let j = 1; j <= n; j++) {
|
|
1318
|
+
if (a[i - 1] === b[j - 1]) {
|
|
1319
|
+
dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
1320
|
+
}
|
|
1321
|
+
else {
|
|
1322
|
+
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
709
1323
|
}
|
|
710
1324
|
}
|
|
711
1325
|
}
|
|
712
|
-
|
|
713
|
-
|
|
1326
|
+
const result = [];
|
|
1327
|
+
let i = m, j = n;
|
|
1328
|
+
while (i > 0 && j > 0) {
|
|
1329
|
+
if (a[i - 1] === b[j - 1]) {
|
|
1330
|
+
result.unshift(a[i - 1]);
|
|
1331
|
+
i--;
|
|
1332
|
+
j--;
|
|
1333
|
+
}
|
|
1334
|
+
else if (dp[i - 1][j] > dp[i][j - 1]) {
|
|
1335
|
+
i--;
|
|
1336
|
+
}
|
|
1337
|
+
else {
|
|
1338
|
+
j--;
|
|
1339
|
+
}
|
|
714
1340
|
}
|
|
715
|
-
return
|
|
1341
|
+
return result;
|
|
716
1342
|
}
|
|
717
|
-
|
|
718
|
-
const
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
// because the user will re-submit the prompt
|
|
724
|
-
const toRemove = this.checkpoints.slice(index);
|
|
725
|
-
for (const checkpoint of toRemove) {
|
|
726
|
-
this.discardCheckpoint(checkpoint.id);
|
|
1343
|
+
simpleLCS(a, b) {
|
|
1344
|
+
const bMap = new Map();
|
|
1345
|
+
for (let i = 0; i < b.length; i++) {
|
|
1346
|
+
const positions = bMap.get(b[i]) || [];
|
|
1347
|
+
positions.push(i);
|
|
1348
|
+
bMap.set(b[i], positions);
|
|
727
1349
|
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
fs.rmSync(chatDir, { recursive: true, force: true });
|
|
1350
|
+
const result = [];
|
|
1351
|
+
let lastMatchB = -1;
|
|
1352
|
+
for (let i = 0; i < a.length; i++) {
|
|
1353
|
+
const positions = bMap.get(a[i]);
|
|
1354
|
+
if (positions) {
|
|
1355
|
+
for (const pos of positions) {
|
|
1356
|
+
if (pos > lastMatchB) {
|
|
1357
|
+
result.push(a[i]);
|
|
1358
|
+
lastMatchB = pos;
|
|
1359
|
+
break;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
741
1362
|
}
|
|
742
1363
|
}
|
|
743
|
-
|
|
744
|
-
logWarning(`Failed to delete checkpoints for chat ${chatId}: ${error.message}`);
|
|
745
|
-
}
|
|
746
|
-
if (this.currentChatId === chatId) {
|
|
747
|
-
this.clear();
|
|
748
|
-
}
|
|
1364
|
+
return result;
|
|
749
1365
|
}
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
if (checkpointIndex === -1)
|
|
753
|
-
return;
|
|
754
|
-
const [checkpoint] = this.checkpoints.splice(checkpointIndex, 1);
|
|
755
|
-
this.discardedIds.delete(id);
|
|
1366
|
+
// ── Helpers: file operations ────────────────────────────────────────
|
|
1367
|
+
filesDiffer(snapshotPath, currentPath) {
|
|
756
1368
|
try {
|
|
757
|
-
|
|
758
|
-
|
|
1369
|
+
const snapStat = fs.statSync(snapshotPath);
|
|
1370
|
+
const currStat = fs.statSync(currentPath);
|
|
1371
|
+
if (snapStat.size !== currStat.size) {
|
|
1372
|
+
return true;
|
|
1373
|
+
}
|
|
1374
|
+
const snapBuffer = fs.readFileSync(snapshotPath);
|
|
1375
|
+
const currBuffer = fs.readFileSync(currentPath);
|
|
1376
|
+
if (snapBuffer.length !== currBuffer.length) {
|
|
1377
|
+
return true;
|
|
759
1378
|
}
|
|
1379
|
+
return !snapBuffer.equals(currBuffer);
|
|
760
1380
|
}
|
|
761
|
-
catch
|
|
762
|
-
|
|
1381
|
+
catch {
|
|
1382
|
+
return true;
|
|
763
1383
|
}
|
|
764
|
-
this.saveIndex();
|
|
765
1384
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
: await this.scanFiles(checkpoint.cwd);
|
|
774
|
-
const currentSet = new Set(currentFiles);
|
|
775
|
-
const added = [];
|
|
776
|
-
const deleted = [];
|
|
777
|
-
const modified = [];
|
|
778
|
-
for (const filePath of currentFiles) {
|
|
779
|
-
if (!manifestSet.has(filePath)) {
|
|
780
|
-
added.push(filePath);
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
for (const file of manifest.files) {
|
|
784
|
-
if (!currentSet.has(file.path)) {
|
|
785
|
-
deleted.push(file.path);
|
|
786
|
-
continue;
|
|
787
|
-
}
|
|
788
|
-
if (isRemote) {
|
|
789
|
-
// For remote: read current file from remote and compare with local snapshot
|
|
790
|
-
try {
|
|
791
|
-
const remotePath = checkpoint.cwd + '/' + file.path;
|
|
792
|
-
const remoteContent = await handler.readFile(remotePath);
|
|
793
|
-
const snapshotPath = path.join(checkpoint.snapshotDir, file.path);
|
|
794
|
-
const snapshotContent = fs.readFileSync(snapshotPath, 'utf-8');
|
|
795
|
-
if (remoteContent !== snapshotContent) {
|
|
796
|
-
modified.push(file.path);
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
catch {
|
|
800
|
-
modified.push(file.path); // Assume modified if we can't read
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
else {
|
|
804
|
-
const currentPath = path.join(checkpoint.cwd, file.path);
|
|
805
|
-
const snapshotPath = path.join(checkpoint.snapshotDir, file.path);
|
|
806
|
-
if (this.filesDiffer(snapshotPath, currentPath)) {
|
|
807
|
-
modified.push(file.path);
|
|
808
|
-
}
|
|
809
|
-
}
|
|
1385
|
+
removeFileOrDirSync(targetPath) {
|
|
1386
|
+
if (!fs.existsSync(targetPath))
|
|
1387
|
+
return;
|
|
1388
|
+
const stat = fs.lstatSync(targetPath);
|
|
1389
|
+
if (stat.isDirectory()) {
|
|
1390
|
+
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
1391
|
+
return;
|
|
810
1392
|
}
|
|
811
|
-
|
|
1393
|
+
fs.rmSync(targetPath, { force: true });
|
|
812
1394
|
}
|
|
1395
|
+
// ── Helpers: manifest & index I/O ───────────────────────────────────
|
|
813
1396
|
readManifest(manifestPath) {
|
|
814
1397
|
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
|
815
1398
|
return JSON.parse(raw);
|
|
816
1399
|
}
|
|
817
|
-
|
|
818
|
-
const
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
const stat = fs.statSync(absolutePath);
|
|
824
|
-
if (!stat.isFile())
|
|
825
|
-
continue;
|
|
826
|
-
const snapshotPath = path.join(snapshotDir, filePath);
|
|
827
|
-
this.ensureDirSync(path.dirname(snapshotPath));
|
|
828
|
-
fs.copyFileSync(absolutePath, snapshotPath);
|
|
829
|
-
manifestEntries.push({
|
|
830
|
-
path: filePath,
|
|
831
|
-
size: stat.size,
|
|
832
|
-
mtimeMs: stat.mtimeMs,
|
|
833
|
-
mode: stat.mode,
|
|
834
|
-
});
|
|
835
|
-
}
|
|
836
|
-
catch (error) {
|
|
837
|
-
logWarning(`Failed to snapshot ${filePath}: ${error.message}`);
|
|
838
|
-
}
|
|
1400
|
+
readManifestV2(manifestPath) {
|
|
1401
|
+
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
|
1402
|
+
const manifest = JSON.parse(raw);
|
|
1403
|
+
// Already V2 or convert
|
|
1404
|
+
if (manifest.version === 2) {
|
|
1405
|
+
return manifest;
|
|
839
1406
|
}
|
|
1407
|
+
// Return default V2 structure if called on an unexpected manifest
|
|
840
1408
|
return {
|
|
841
|
-
version:
|
|
842
|
-
createdAt: new Date().toISOString(),
|
|
843
|
-
cwd,
|
|
844
|
-
|
|
1409
|
+
version: 2,
|
|
1410
|
+
createdAt: manifest.createdAt || new Date().toISOString(),
|
|
1411
|
+
cwd: manifest.cwd || '',
|
|
1412
|
+
fileBackups: [],
|
|
1413
|
+
operations: [],
|
|
845
1414
|
};
|
|
846
1415
|
}
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
* Files are stored locally in the snapshot directory for later comparison/revert.
|
|
850
|
-
*/
|
|
851
|
-
async createRemoteSnapshot(cwd, snapshotDir, handler) {
|
|
852
|
-
const files = await this.scanRemoteFiles(cwd, handler);
|
|
853
|
-
const manifestEntries = [];
|
|
854
|
-
for (const filePath of files) {
|
|
855
|
-
const remotePath = cwd + '/' + filePath;
|
|
856
|
-
try {
|
|
857
|
-
const content = await handler.readFile(remotePath);
|
|
858
|
-
const snapshotPath = path.join(snapshotDir, filePath);
|
|
859
|
-
this.ensureDirSync(path.dirname(snapshotPath));
|
|
860
|
-
fs.writeFileSync(snapshotPath, content, 'utf-8');
|
|
861
|
-
const contentBuffer = Buffer.from(content, 'utf-8');
|
|
862
|
-
manifestEntries.push({
|
|
863
|
-
path: filePath,
|
|
864
|
-
size: contentBuffer.length,
|
|
865
|
-
mtimeMs: Date.now(),
|
|
866
|
-
mode: 0o644,
|
|
867
|
-
});
|
|
868
|
-
}
|
|
869
|
-
catch (error) {
|
|
870
|
-
logWarning(`Failed to snapshot remote file ${filePath}: ${error.message}`);
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
return {
|
|
874
|
-
version: 1,
|
|
875
|
-
createdAt: new Date().toISOString(),
|
|
876
|
-
cwd,
|
|
877
|
-
files: manifestEntries,
|
|
878
|
-
};
|
|
1416
|
+
isV2Manifest(manifest) {
|
|
1417
|
+
return manifest.version === 2;
|
|
879
1418
|
}
|
|
880
|
-
|
|
1419
|
+
// ── Helpers: file scanning (only used for V1 legacy) ────────────────
|
|
1420
|
+
async scanLocalFiles(cwd) {
|
|
1421
|
+
const fg = (await import('fast-glob')).default;
|
|
881
1422
|
const files = await fg(['**/*'], {
|
|
882
1423
|
cwd,
|
|
883
1424
|
dot: true,
|
|
884
1425
|
onlyFiles: true,
|
|
885
1426
|
followSymbolicLinks: false,
|
|
886
1427
|
unique: true,
|
|
887
|
-
ignore:
|
|
1428
|
+
ignore: [
|
|
1429
|
+
'**/.git/**', '**/node_modules/**', '**/dist/**', '**/build/**',
|
|
1430
|
+
'**/out/**', '**/.next/**', '**/.turbo/**', '**/.cache/**',
|
|
1431
|
+
'**/coverage/**', '**/__pycache__/**', '**/.venv/**', '**/venv/**',
|
|
1432
|
+
'**/.idea/**', '**/.vscode/**', '**/.DS_Store', '**/Thumbs.db',
|
|
1433
|
+
'**/.centaurus/**',
|
|
1434
|
+
],
|
|
888
1435
|
});
|
|
889
1436
|
return files.map((file) => file.replace(/\\/g, '/'));
|
|
890
1437
|
}
|
|
891
|
-
/**
|
|
892
|
-
* Recursively scan all subdirectories under root, returning relative paths.
|
|
893
|
-
* Used by local revert to find directories that may need cleanup.
|
|
894
|
-
*/
|
|
895
1438
|
scanLocalDirectories(rootDir, cwd) {
|
|
896
1439
|
const dirs = [];
|
|
897
1440
|
const ignoreDirs = new Set([
|
|
@@ -918,12 +1461,7 @@ export class CheckpointManager {
|
|
|
918
1461
|
walk(rootDir);
|
|
919
1462
|
return dirs;
|
|
920
1463
|
}
|
|
921
|
-
/**
|
|
922
|
-
* Scan files on a remote machine using the handler's executeCommand.
|
|
923
|
-
* Uses `find` with ignore patterns equivalent to DEFAULT_IGNORE_PATTERNS.
|
|
924
|
-
*/
|
|
925
1464
|
async scanRemoteFiles(cwd, handler) {
|
|
926
|
-
// Build find command with exclusion patterns matching DEFAULT_IGNORE_PATTERNS
|
|
927
1465
|
const excludeDirs = [
|
|
928
1466
|
'.git', 'node_modules', 'dist', 'build', 'out',
|
|
929
1467
|
'.next', '.turbo', '.cache', 'coverage',
|
|
@@ -932,7 +1470,6 @@ export class CheckpointManager {
|
|
|
932
1470
|
const excludeFiles = ['.DS_Store', 'Thumbs.db'];
|
|
933
1471
|
const pruneArgs = excludeDirs.map(d => `-name "${d}" -prune`).join(' -o ');
|
|
934
1472
|
const notFileArgs = excludeFiles.map(f => `! -name "${f}"`).join(' ');
|
|
935
|
-
// find <cwd> ( -name .git -prune -o ... ) -o -type f ! -name .DS_Store ... -print
|
|
936
1473
|
const findCmd = `find "${cwd}" \\( ${pruneArgs} \\) -o -type f ${notFileArgs} -print 2>/dev/null`;
|
|
937
1474
|
try {
|
|
938
1475
|
const result = await handler.executeCommand(findCmd);
|
|
@@ -953,33 +1490,22 @@ export class CheckpointManager {
|
|
|
953
1490
|
return [];
|
|
954
1491
|
}
|
|
955
1492
|
}
|
|
956
|
-
|
|
1493
|
+
// ── Helpers: checkpoint management ──────────────────────────────────
|
|
1494
|
+
discardCheckpoint(id) {
|
|
1495
|
+
const checkpointIndex = this.checkpoints.findIndex(cp => cp.id === id);
|
|
1496
|
+
if (checkpointIndex === -1)
|
|
1497
|
+
return;
|
|
1498
|
+
const [checkpoint] = this.checkpoints.splice(checkpointIndex, 1);
|
|
1499
|
+
this.discardedIds.delete(id);
|
|
957
1500
|
try {
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
if (snapStat.size !== currStat.size) {
|
|
961
|
-
return true;
|
|
962
|
-
}
|
|
963
|
-
const snapBuffer = fs.readFileSync(snapshotPath);
|
|
964
|
-
const currBuffer = fs.readFileSync(currentPath);
|
|
965
|
-
if (snapBuffer.length !== currBuffer.length) {
|
|
966
|
-
return true;
|
|
1501
|
+
if (fs.existsSync(path.dirname(checkpoint.manifestPath))) {
|
|
1502
|
+
fs.rmSync(path.dirname(checkpoint.manifestPath), { recursive: true, force: true });
|
|
967
1503
|
}
|
|
968
|
-
return !snapBuffer.equals(currBuffer);
|
|
969
|
-
}
|
|
970
|
-
catch {
|
|
971
|
-
return true;
|
|
972
1504
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
if (!fs.existsSync(targetPath))
|
|
976
|
-
return;
|
|
977
|
-
const stat = fs.lstatSync(targetPath);
|
|
978
|
-
if (stat.isDirectory()) {
|
|
979
|
-
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
980
|
-
return;
|
|
1505
|
+
catch (error) {
|
|
1506
|
+
logWarning(`Failed to delete checkpoint ${id}: ${error.message}`);
|
|
981
1507
|
}
|
|
982
|
-
|
|
1508
|
+
this.saveIndex();
|
|
983
1509
|
}
|
|
984
1510
|
generateCheckpointId() {
|
|
985
1511
|
const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '');
|