centaurus-cli 3.0.0 → 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.
@@ -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
- * Get session changes for an active checkpoint (live calculation).
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
- if (!isRemote) {
61
- for (const filePath of changes.modified) {
62
- const snapshotPath = path.join(checkpoint.snapshotDir, filePath);
63
- const currentPath = path.join(checkpoint.cwd, filePath);
64
- const lineStat = this.calculateLineStats(snapshotPath, currentPath);
65
- stats.push({ filePath, ...lineStat });
66
- }
67
- // For added files, count all lines as insertions
68
- for (const filePath of changes.added) {
69
- const currentPath = path.join(checkpoint.cwd, filePath);
70
- try {
71
- const content = fs.readFileSync(currentPath, 'utf-8');
72
- const lines = content.split('\n').length;
73
- stats.push({ filePath, insertions: lines, deletions: 0 });
74
- }
75
- catch {
76
- stats.push({ filePath, insertions: 0, deletions: 0 });
77
- }
78
- }
79
- // For deleted files, count all lines as deletions
80
- for (const filePath of changes.deleted) {
81
- const snapshotPath = path.join(checkpoint.snapshotDir, filePath);
82
- try {
83
- const content = fs.readFileSync(snapshotPath, 'utf-8');
84
- const lines = content.split('\n').length;
85
- stats.push({ filePath, insertions: 0, deletions: lines });
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
- catch {
88
- stats.push({ filePath, insertions: 0, deletions: 0 });
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 if (handler) {
93
- // Remote stats: use handler to read remote file contents
94
- for (const filePath of changes.modified) {
95
- const snapshotPath = path.join(checkpoint.snapshotDir, filePath);
96
- const remotePath = checkpoint.cwd + '/' + filePath;
97
- try {
98
- const snapshotContent = fs.readFileSync(snapshotPath, 'utf-8');
99
- const remoteContent = await handler.readFile(remotePath);
100
- const lineStat = this.calculateLineStatsFromContent(snapshotContent, remoteContent);
101
- stats.push({ filePath, ...lineStat });
102
- }
103
- catch {
104
- stats.push({ filePath, insertions: 0, deletions: 0 });
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
- for (const filePath of changes.added) {
108
- const remotePath = checkpoint.cwd + '/' + filePath;
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
- const remoteContent = await handler.readFile(remotePath);
111
- const lines = remoteContent.split('\n').length;
112
- stats.push({ filePath, insertions: lines, deletions: 0 });
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
- stats.push({ filePath, insertions: 0, deletions: 0 });
296
+ // Skip on error
116
297
  }
117
298
  }
118
- for (const filePath of changes.deleted) {
119
- const snapshotPath = path.join(checkpoint.snapshotDir, filePath);
120
- try {
121
- const content = fs.readFileSync(snapshotPath, 'utf-8');
122
- const lines = content.split('\n').length;
123
- stats.push({ filePath, insertions: 0, deletions: lines });
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
- catch {
126
- stats.push({ filePath, insertions: 0, deletions: 0 });
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
- * Compares the snapshot version to the current version.
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 isRemote = checkpoint.contextType !== 'local';
141
- const snapshotPath = path.join(checkpoint.snapshotDir, filePath);
142
- const snapshotExists = fs.existsSync(snapshotPath);
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
- // Added file: show full content as additions
195
- if (!snapshotExists && currentExists) {
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
- // Deleted file: show full content as deletions
208
- if (snapshotExists && !currentExists) {
209
- try {
210
- const content = fs.readFileSync(snapshotPath, 'utf-8');
211
- const lines = content.split('\n');
212
- let diff = `--- a/${filePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n`;
213
- diff += lines.map(l => `-${l}`).join('\n');
214
- return diff;
215
- }
216
- catch {
217
- return `[Binary or unreadable file: ${filePath}]`;
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
- // Modified file: produce unified diff
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
- * Calculate insertions/deletions between two file versions
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
- // End the hunk
375
- hunks.push(currentHunk);
376
- currentHunk = null;
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
- // Restore files from the snapshot
652
- let restored = 0;
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
- // Clean up empty directories that were created after the checkpoint
671
- // Build the set of directories referenced by manifest files
672
- const manifestDirs = new Set();
673
- for (const file of manifest.files) {
674
- let dir = file.path.substring(0, file.path.lastIndexOf('/'));
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
- // Find all current directories on the remote and remove empty ones not in the manifest
682
- try {
683
- const findDirsCmd = `find "${checkpoint.cwd}" -mindepth 1 -type d 2>/dev/null`;
684
- const dirsResult = await handler.executeCommand(findDirsCmd);
685
- if (dirsResult.exitCode === 0 && dirsResult.stdout.trim()) {
686
- const cwdPrefix = checkpoint.cwd.endsWith('/') ? checkpoint.cwd : checkpoint.cwd + '/';
687
- const remoteDirs = dirsResult.stdout
688
- .split('\n')
689
- .map((l) => l.trim())
690
- .filter((l) => l.length > 0 && l.startsWith(cwdPrefix))
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
- catch {
713
- // Non-critical: directory cleanup is best-effort
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 { checkpoint, restored, removed, errors };
1341
+ return result;
716
1342
  }
717
- removeCheckpointsFrom(id) {
718
- const index = this.checkpoints.findIndex(cp => cp.id === id);
719
- if (index === -1)
720
- return;
721
- // Remove checkpoints FROM the given checkpoint (INCLUDING the checkpoint itself)
722
- // This is used during revert - the reverted checkpoint is also removed
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
- * Discard a single checkpoint by id.
731
- * Used when a background/late checkpoint should not be kept.
732
- */
733
- discardCheckpointById(id) {
734
- this.discardCheckpoint(id);
735
- }
736
- deleteCheckpointsForChat(chatId) {
737
- const chatDir = path.join(this.baseDir, chatId);
738
- try {
739
- if (fs.existsSync(chatDir)) {
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
- catch (error) {
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
- discardCheckpoint(id) {
751
- const checkpointIndex = this.checkpoints.findIndex(cp => cp.id === id);
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
- if (fs.existsSync(path.dirname(checkpoint.manifestPath))) {
758
- fs.rmSync(path.dirname(checkpoint.manifestPath), { recursive: true, force: true });
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 (error) {
762
- logWarning(`Failed to delete checkpoint ${id}: ${error.message}`);
1381
+ catch {
1382
+ return true;
763
1383
  }
764
- this.saveIndex();
765
1384
  }
766
- async calculateChanges(checkpoint, handler) {
767
- const manifest = this.readManifest(checkpoint.manifestPath);
768
- const manifestSet = new Set(manifest.files.map(file => file.path));
769
- // Use remote scanning when checkpoint is remote and handler is available
770
- const isRemote = checkpoint.contextType !== 'local' && handler && handler.isConnected();
771
- const currentFiles = isRemote
772
- ? await this.scanRemoteFiles(checkpoint.cwd, handler)
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
- return { added, modified, deleted };
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
- async createSnapshot(cwd, snapshotDir) {
818
- const files = await this.scanFiles(cwd);
819
- const manifestEntries = [];
820
- for (const filePath of files) {
821
- const absolutePath = path.join(cwd, filePath);
822
- try {
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: 1,
842
- createdAt: new Date().toISOString(),
843
- cwd,
844
- files: manifestEntries,
1409
+ version: 2,
1410
+ createdAt: manifest.createdAt || new Date().toISOString(),
1411
+ cwd: manifest.cwd || '',
1412
+ fileBackups: [],
1413
+ operations: [],
845
1414
  };
846
1415
  }
847
- /**
848
- * Create a snapshot of remote files by downloading them via the handler.
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
- async scanFiles(cwd) {
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: DEFAULT_IGNORE_PATTERNS,
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
- filesDiffer(snapshotPath, currentPath) {
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
- const snapStat = fs.statSync(snapshotPath);
959
- const currStat = fs.statSync(currentPath);
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
- removeFileOrDirSync(targetPath) {
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
- fs.rmSync(targetPath, { force: true });
1508
+ this.saveIndex();
983
1509
  }
984
1510
  generateCheckpointId() {
985
1511
  const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '');