centaurus-cli 2.9.4 → 2.9.6
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 +29 -4
- package/dist/cli-adapter.d.ts.map +1 -1
- package/dist/cli-adapter.js +700 -121
- package/dist/cli-adapter.js.map +1 -1
- package/dist/config/slash-commands.d.ts.map +1 -1
- package/dist/config/slash-commands.js +7 -0
- package/dist/config/slash-commands.js.map +1 -1
- package/dist/context/context-manager.d.ts +10 -0
- package/dist/context/context-manager.d.ts.map +1 -1
- package/dist/context/context-manager.js +17 -0
- package/dist/context/context-manager.js.map +1 -1
- package/dist/context/handlers/docker-handler.d.ts +7 -1
- package/dist/context/handlers/docker-handler.d.ts.map +1 -1
- package/dist/context/handlers/docker-handler.js +89 -16
- package/dist/context/handlers/docker-handler.js.map +1 -1
- package/dist/context/handlers/ssh-handler.d.ts +47 -1
- package/dist/context/handlers/ssh-handler.d.ts.map +1 -1
- package/dist/context/handlers/ssh-handler.js +546 -73
- package/dist/context/handlers/ssh-handler.js.map +1 -1
- package/dist/context/handlers/wsl-handler.d.ts +5 -1
- package/dist/context/handlers/wsl-handler.d.ts.map +1 -1
- package/dist/context/handlers/wsl-handler.js +24 -6
- package/dist/context/handlers/wsl-handler.js.map +1 -1
- package/dist/context/subshell-handler.d.ts +8 -2
- package/dist/context/subshell-handler.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/services/checkpoint-manager.d.ts +162 -0
- package/dist/services/checkpoint-manager.d.ts.map +1 -0
- package/dist/services/checkpoint-manager.js +926 -0
- package/dist/services/checkpoint-manager.js.map +1 -0
- package/dist/services/local-chat-storage.d.ts +3 -1
- package/dist/services/local-chat-storage.d.ts.map +1 -1
- package/dist/services/local-chat-storage.js +8 -3
- package/dist/services/local-chat-storage.js.map +1 -1
- package/dist/tools/background-command.d.ts.map +1 -1
- package/dist/tools/background-command.js +132 -24
- package/dist/tools/background-command.js.map +1 -1
- package/dist/tools/command.d.ts.map +1 -1
- package/dist/tools/command.js +106 -42
- package/dist/tools/command.js.map +1 -1
- package/dist/tools/create-image.d.ts.map +1 -1
- package/dist/tools/create-image.js +43 -18
- package/dist/tools/create-image.js.map +1 -1
- package/dist/tools/file-ops.d.ts.map +1 -1
- package/dist/tools/file-ops.js +12 -12
- package/dist/tools/file-ops.js.map +1 -1
- package/dist/tools/get-diff.d.ts +9 -45
- package/dist/tools/get-diff.d.ts.map +1 -1
- package/dist/tools/get-diff.js +288 -171
- package/dist/tools/get-diff.js.map +1 -1
- package/dist/tools/grep-search.d.ts +1 -1
- package/dist/tools/grep-search.d.ts.map +1 -1
- package/dist/tools/grep-search.js +80 -1
- package/dist/tools/grep-search.js.map +1 -1
- package/dist/tools/types.d.ts +3 -0
- package/dist/tools/types.d.ts.map +1 -1
- package/dist/ui/components/App.d.ts +8 -0
- package/dist/ui/components/App.d.ts.map +1 -1
- package/dist/ui/components/App.js +256 -66
- package/dist/ui/components/App.js.map +1 -1
- package/dist/ui/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/ui/components/Breadcrumbs.js +22 -2
- package/dist/ui/components/Breadcrumbs.js.map +1 -1
- package/dist/ui/components/ConfirmPrompt.d.ts +2 -0
- package/dist/ui/components/ConfirmPrompt.d.ts.map +1 -1
- package/dist/ui/components/ConfirmPrompt.js +8 -3
- package/dist/ui/components/ConfirmPrompt.js.map +1 -1
- package/dist/ui/components/InputBox.d.ts +6 -0
- package/dist/ui/components/InputBox.d.ts.map +1 -1
- package/dist/ui/components/InputBox.js +188 -23
- package/dist/ui/components/InputBox.js.map +1 -1
- package/dist/ui/components/InteractiveShell.d.ts +2 -0
- package/dist/ui/components/InteractiveShell.d.ts.map +1 -1
- package/dist/ui/components/InteractiveShell.js +88 -26
- package/dist/ui/components/InteractiveShell.js.map +1 -1
- package/dist/ui/components/KeyboardHelp.d.ts.map +1 -1
- package/dist/ui/components/KeyboardHelp.js +14 -6
- package/dist/ui/components/KeyboardHelp.js.map +1 -1
- package/dist/ui/components/ToolExecutionMessage.d.ts.map +1 -1
- package/dist/ui/components/ToolExecutionMessage.js +35 -16
- package/dist/ui/components/ToolExecutionMessage.js.map +1 -1
- package/dist/utils/ansi-encoder.d.ts +5 -0
- package/dist/utils/ansi-encoder.d.ts.map +1 -1
- package/dist/utils/ansi-encoder.js +12 -5
- package/dist/utils/ansi-encoder.js.map +1 -1
- package/dist/utils/editor-utils.d.ts +14 -0
- package/dist/utils/editor-utils.d.ts.map +1 -1
- package/dist/utils/editor-utils.js +172 -0
- package/dist/utils/editor-utils.js.map +1 -1
- package/dist/utils/input-classifier.d.ts.map +1 -1
- package/dist/utils/input-classifier.js +2 -1
- package/dist/utils/input-classifier.js.map +1 -1
- package/dist/utils/terminal-output.d.ts +3 -1
- package/dist/utils/terminal-output.d.ts.map +1 -1
- package/dist/utils/terminal-output.js +235 -195
- package/dist/utils/terminal-output.js.map +1 -1
- package/package.json +3 -1
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import fg from 'fast-glob';
|
|
5
|
+
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
|
+
export class CheckpointManager {
|
|
26
|
+
checkpoints = [];
|
|
27
|
+
currentChatId = null;
|
|
28
|
+
discardedIds = new Set();
|
|
29
|
+
baseDir;
|
|
30
|
+
constructor() {
|
|
31
|
+
this.baseDir = path.join(os.homedir(), '.centaurus', 'checkpoints');
|
|
32
|
+
this.ensureDirSync(this.baseDir);
|
|
33
|
+
}
|
|
34
|
+
setCurrentChatId(chatId) {
|
|
35
|
+
this.currentChatId = chatId;
|
|
36
|
+
this.loadIndex();
|
|
37
|
+
}
|
|
38
|
+
clear() {
|
|
39
|
+
this.checkpoints = [];
|
|
40
|
+
this.discardedIds.clear();
|
|
41
|
+
}
|
|
42
|
+
list() {
|
|
43
|
+
return [...this.checkpoints].sort((a, b) => b.createdAtMs - a.createdAtMs);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Get session changes for an active checkpoint (live calculation).
|
|
47
|
+
* Returns added/modified/deleted file lists plus per-file line stats.
|
|
48
|
+
*/
|
|
49
|
+
async getSessionChanges(checkpointId, handler) {
|
|
50
|
+
const checkpoint = this.checkpoints.find(cp => cp.id === checkpointId);
|
|
51
|
+
if (!checkpoint)
|
|
52
|
+
return null;
|
|
53
|
+
const isRemote = checkpoint.contextType !== 'local';
|
|
54
|
+
if (isRemote && (!handler || !handler.isConnected())) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const changes = await this.calculateChanges(checkpoint, handler);
|
|
58
|
+
// Calculate line-level stats for modified files
|
|
59
|
+
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 });
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
stats.push({ filePath, insertions: 0, deletions: 0 });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
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 });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
for (const filePath of changes.added) {
|
|
108
|
+
const remotePath = checkpoint.cwd + '/' + filePath;
|
|
109
|
+
try {
|
|
110
|
+
const remoteContent = await handler.readFile(remotePath);
|
|
111
|
+
const lines = remoteContent.split('\n').length;
|
|
112
|
+
stats.push({ filePath, insertions: lines, deletions: 0 });
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
stats.push({ filePath, insertions: 0, deletions: 0 });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
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 });
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
stats.push({ filePath, insertions: 0, deletions: 0 });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { changes, stats };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Get a unified diff for a single file within a checkpoint.
|
|
134
|
+
* Compares the snapshot version to the current version.
|
|
135
|
+
*/
|
|
136
|
+
async getFileDiff(checkpointId, filePath, handler) {
|
|
137
|
+
const checkpoint = this.checkpoints.find(cp => cp.id === checkpointId);
|
|
138
|
+
if (!checkpoint)
|
|
139
|
+
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;
|
|
193
|
+
}
|
|
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
|
+
}
|
|
206
|
+
}
|
|
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}]`;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
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
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Get the initial checkpoint for the current chat session.
|
|
232
|
+
* This represents the state at the start of the conversation.
|
|
233
|
+
*/
|
|
234
|
+
getInitialCheckpoint() {
|
|
235
|
+
if (this.checkpoints.length === 0)
|
|
236
|
+
return null;
|
|
237
|
+
// Sort by creation time to find the first one
|
|
238
|
+
return [...this.checkpoints].sort((a, b) => a.createdAtMs - b.createdAtMs)[0];
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Calculate insertions/deletions between two file versions
|
|
242
|
+
*/
|
|
243
|
+
calculateLineStats(snapshotPath, currentPath) {
|
|
244
|
+
try {
|
|
245
|
+
const oldContent = fs.readFileSync(snapshotPath, 'utf-8');
|
|
246
|
+
const newContent = fs.readFileSync(currentPath, 'utf-8');
|
|
247
|
+
return this.calculateLineStatsFromContent(oldContent, newContent);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return { insertions: 0, deletions: 0 };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
calculateLineStatsFromContent(oldContent, newContent) {
|
|
254
|
+
const oldLines = oldContent.split('\n');
|
|
255
|
+
const newLines = newContent.split('\n');
|
|
256
|
+
// Simple LCS-based diff to count insertions and deletions
|
|
257
|
+
const oldSet = new Map();
|
|
258
|
+
for (const line of oldLines) {
|
|
259
|
+
oldSet.set(line, (oldSet.get(line) || 0) + 1);
|
|
260
|
+
}
|
|
261
|
+
const newSet = new Map();
|
|
262
|
+
for (const line of newLines) {
|
|
263
|
+
newSet.set(line, (newSet.get(line) || 0) + 1);
|
|
264
|
+
}
|
|
265
|
+
let deletions = 0;
|
|
266
|
+
for (const [line, count] of oldSet) {
|
|
267
|
+
const newCount = newSet.get(line) || 0;
|
|
268
|
+
if (newCount < count) {
|
|
269
|
+
deletions += count - newCount;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
let insertions = 0;
|
|
273
|
+
for (const [line, count] of newSet) {
|
|
274
|
+
const oldCount = oldSet.get(line) || 0;
|
|
275
|
+
if (oldCount < count) {
|
|
276
|
+
insertions += count - oldCount;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return { insertions, deletions };
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Generate a unified diff between old and new content
|
|
283
|
+
*/
|
|
284
|
+
generateUnifiedDiff(filePath, oldContent, newContent) {
|
|
285
|
+
const oldLines = oldContent.split('\n');
|
|
286
|
+
const newLines = newContent.split('\n');
|
|
287
|
+
// Use a simple diff algorithm: find common prefix/suffix, then show changes
|
|
288
|
+
let result = `--- a/${filePath}\n+++ b/${filePath}\n`;
|
|
289
|
+
// Find changed regions using a simple approach
|
|
290
|
+
const hunks = this.computeHunks(oldLines, newLines);
|
|
291
|
+
if (hunks.length === 0) {
|
|
292
|
+
return `No differences found in ${filePath}`;
|
|
293
|
+
}
|
|
294
|
+
for (const hunk of hunks) {
|
|
295
|
+
result += `@@ -${hunk.oldStart + 1},${hunk.oldCount} +${hunk.newStart + 1},${hunk.newCount} @@\n`;
|
|
296
|
+
for (const line of hunk.lines) {
|
|
297
|
+
result += line + '\n';
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return result;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Compute diff hunks between old and new lines using Myers-like approach
|
|
304
|
+
*/
|
|
305
|
+
computeHunks(oldLines, newLines) {
|
|
306
|
+
// Build an edit script using LCS
|
|
307
|
+
const lcs = this.longestCommonSubsequence(oldLines, newLines);
|
|
308
|
+
const editScript = [];
|
|
309
|
+
let oldIdx = 0;
|
|
310
|
+
let newIdx = 0;
|
|
311
|
+
let lcsIdx = 0;
|
|
312
|
+
while (oldIdx < oldLines.length || newIdx < newLines.length) {
|
|
313
|
+
if (lcsIdx < lcs.length && oldIdx < oldLines.length && newIdx < newLines.length &&
|
|
314
|
+
oldLines[oldIdx] === lcs[lcsIdx] && newLines[newIdx] === lcs[lcsIdx]) {
|
|
315
|
+
editScript.push({ type: 'keep', oldIdx, newIdx, line: oldLines[oldIdx] });
|
|
316
|
+
oldIdx++;
|
|
317
|
+
newIdx++;
|
|
318
|
+
lcsIdx++;
|
|
319
|
+
}
|
|
320
|
+
else if (oldIdx < oldLines.length && (lcsIdx >= lcs.length || oldLines[oldIdx] !== lcs[lcsIdx])) {
|
|
321
|
+
editScript.push({ type: 'delete', oldIdx, newIdx, line: oldLines[oldIdx] });
|
|
322
|
+
oldIdx++;
|
|
323
|
+
}
|
|
324
|
+
else if (newIdx < newLines.length) {
|
|
325
|
+
editScript.push({ type: 'insert', oldIdx, newIdx, line: newLines[newIdx] });
|
|
326
|
+
newIdx++;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// Group into hunks with context (3 lines)
|
|
330
|
+
const CONTEXT = 3;
|
|
331
|
+
const hunks = [];
|
|
332
|
+
let currentHunk = null;
|
|
333
|
+
let contextCounter = 0;
|
|
334
|
+
for (let i = 0; i < editScript.length; i++) {
|
|
335
|
+
const edit = editScript[i];
|
|
336
|
+
if (edit.type !== 'keep') {
|
|
337
|
+
// Start a new hunk or extend current
|
|
338
|
+
if (!currentHunk) {
|
|
339
|
+
// Look back for context
|
|
340
|
+
const contextStart = Math.max(0, i - CONTEXT);
|
|
341
|
+
currentHunk = {
|
|
342
|
+
oldStart: editScript[contextStart]?.oldIdx ?? edit.oldIdx,
|
|
343
|
+
oldCount: 0,
|
|
344
|
+
newStart: editScript[contextStart]?.newIdx ?? edit.newIdx,
|
|
345
|
+
newCount: 0,
|
|
346
|
+
lines: [],
|
|
347
|
+
};
|
|
348
|
+
for (let j = contextStart; j < i; j++) {
|
|
349
|
+
if (editScript[j].type === 'keep') {
|
|
350
|
+
currentHunk.lines.push(` ${editScript[j].line}`);
|
|
351
|
+
currentHunk.oldCount++;
|
|
352
|
+
currentHunk.newCount++;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (edit.type === 'delete') {
|
|
357
|
+
currentHunk.lines.push(`-${edit.line}`);
|
|
358
|
+
currentHunk.oldCount++;
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
currentHunk.lines.push(`+${edit.line}`);
|
|
362
|
+
currentHunk.newCount++;
|
|
363
|
+
}
|
|
364
|
+
contextCounter = 0;
|
|
365
|
+
}
|
|
366
|
+
else if (currentHunk) {
|
|
367
|
+
contextCounter++;
|
|
368
|
+
if (contextCounter <= CONTEXT) {
|
|
369
|
+
currentHunk.lines.push(` ${edit.line}`);
|
|
370
|
+
currentHunk.oldCount++;
|
|
371
|
+
currentHunk.newCount++;
|
|
372
|
+
}
|
|
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
|
+
return { checkpoint, restored, removed, errors };
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Revert a remote checkpoint by uploading snapshot files back to the remote machine.
|
|
592
|
+
*/
|
|
593
|
+
async revertRemoteCheckpoint(checkpoint, handler) {
|
|
594
|
+
const manifest = this.readManifest(checkpoint.manifestPath);
|
|
595
|
+
const manifestSet = new Set(manifest.files.map(file => file.path));
|
|
596
|
+
const currentFiles = await this.scanRemoteFiles(checkpoint.cwd, handler);
|
|
597
|
+
const errors = [];
|
|
598
|
+
// Remove files that were added after the checkpoint
|
|
599
|
+
let removed = 0;
|
|
600
|
+
for (const filePath of currentFiles) {
|
|
601
|
+
if (!manifestSet.has(filePath)) {
|
|
602
|
+
const remotePath = checkpoint.cwd + '/' + filePath;
|
|
603
|
+
try {
|
|
604
|
+
const result = await handler.executeCommand(`rm -f "${remotePath}"`);
|
|
605
|
+
if (result.exitCode === 0) {
|
|
606
|
+
removed++;
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
errors.push(`Failed to remove ${filePath}: ${result.stderr}`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
errors.push(`Failed to remove ${filePath}: ${error.message}`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// Restore files from the snapshot
|
|
618
|
+
let restored = 0;
|
|
619
|
+
for (const file of manifest.files) {
|
|
620
|
+
const localSnapshotPath = path.join(checkpoint.snapshotDir, file.path);
|
|
621
|
+
const remotePath = checkpoint.cwd + '/' + file.path;
|
|
622
|
+
try {
|
|
623
|
+
// Read the snapshot content from local storage
|
|
624
|
+
const content = fs.readFileSync(localSnapshotPath, 'utf-8');
|
|
625
|
+
// Ensure remote directory exists
|
|
626
|
+
const remoteDir = remotePath.substring(0, remotePath.lastIndexOf('/'));
|
|
627
|
+
await handler.executeCommand(`mkdir -p "${remoteDir}"`);
|
|
628
|
+
// Write the file back to the remote machine
|
|
629
|
+
await handler.writeFile(remotePath, content);
|
|
630
|
+
restored++;
|
|
631
|
+
}
|
|
632
|
+
catch (error) {
|
|
633
|
+
errors.push(`Failed to restore ${file.path}: ${error.message}`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return { checkpoint, restored, removed, errors };
|
|
637
|
+
}
|
|
638
|
+
removeCheckpointsFrom(id) {
|
|
639
|
+
const index = this.checkpoints.findIndex(cp => cp.id === id);
|
|
640
|
+
if (index === -1)
|
|
641
|
+
return;
|
|
642
|
+
// Remove checkpoints FROM the given checkpoint (INCLUDING the checkpoint itself)
|
|
643
|
+
// This is used during revert - the reverted checkpoint is also removed
|
|
644
|
+
// because the user will re-submit the prompt
|
|
645
|
+
const toRemove = this.checkpoints.slice(index);
|
|
646
|
+
for (const checkpoint of toRemove) {
|
|
647
|
+
this.discardCheckpoint(checkpoint.id);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Discard a single checkpoint by id.
|
|
652
|
+
* Used when a background/late checkpoint should not be kept.
|
|
653
|
+
*/
|
|
654
|
+
discardCheckpointById(id) {
|
|
655
|
+
this.discardCheckpoint(id);
|
|
656
|
+
}
|
|
657
|
+
deleteCheckpointsForChat(chatId) {
|
|
658
|
+
const chatDir = path.join(this.baseDir, chatId);
|
|
659
|
+
try {
|
|
660
|
+
if (fs.existsSync(chatDir)) {
|
|
661
|
+
fs.rmSync(chatDir, { recursive: true, force: true });
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
catch (error) {
|
|
665
|
+
logWarning(`Failed to delete checkpoints for chat ${chatId}: ${error.message}`);
|
|
666
|
+
}
|
|
667
|
+
if (this.currentChatId === chatId) {
|
|
668
|
+
this.clear();
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
discardCheckpoint(id) {
|
|
672
|
+
const checkpointIndex = this.checkpoints.findIndex(cp => cp.id === id);
|
|
673
|
+
if (checkpointIndex === -1)
|
|
674
|
+
return;
|
|
675
|
+
const [checkpoint] = this.checkpoints.splice(checkpointIndex, 1);
|
|
676
|
+
this.discardedIds.delete(id);
|
|
677
|
+
try {
|
|
678
|
+
if (fs.existsSync(path.dirname(checkpoint.manifestPath))) {
|
|
679
|
+
fs.rmSync(path.dirname(checkpoint.manifestPath), { recursive: true, force: true });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
catch (error) {
|
|
683
|
+
logWarning(`Failed to delete checkpoint ${id}: ${error.message}`);
|
|
684
|
+
}
|
|
685
|
+
this.saveIndex();
|
|
686
|
+
}
|
|
687
|
+
async calculateChanges(checkpoint, handler) {
|
|
688
|
+
const manifest = this.readManifest(checkpoint.manifestPath);
|
|
689
|
+
const manifestSet = new Set(manifest.files.map(file => file.path));
|
|
690
|
+
// Use remote scanning when checkpoint is remote and handler is available
|
|
691
|
+
const isRemote = checkpoint.contextType !== 'local' && handler && handler.isConnected();
|
|
692
|
+
const currentFiles = isRemote
|
|
693
|
+
? await this.scanRemoteFiles(checkpoint.cwd, handler)
|
|
694
|
+
: await this.scanFiles(checkpoint.cwd);
|
|
695
|
+
const currentSet = new Set(currentFiles);
|
|
696
|
+
const added = [];
|
|
697
|
+
const deleted = [];
|
|
698
|
+
const modified = [];
|
|
699
|
+
for (const filePath of currentFiles) {
|
|
700
|
+
if (!manifestSet.has(filePath)) {
|
|
701
|
+
added.push(filePath);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
for (const file of manifest.files) {
|
|
705
|
+
if (!currentSet.has(file.path)) {
|
|
706
|
+
deleted.push(file.path);
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
if (isRemote) {
|
|
710
|
+
// For remote: read current file from remote and compare with local snapshot
|
|
711
|
+
try {
|
|
712
|
+
const remotePath = checkpoint.cwd + '/' + file.path;
|
|
713
|
+
const remoteContent = await handler.readFile(remotePath);
|
|
714
|
+
const snapshotPath = path.join(checkpoint.snapshotDir, file.path);
|
|
715
|
+
const snapshotContent = fs.readFileSync(snapshotPath, 'utf-8');
|
|
716
|
+
if (remoteContent !== snapshotContent) {
|
|
717
|
+
modified.push(file.path);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
modified.push(file.path); // Assume modified if we can't read
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
else {
|
|
725
|
+
const currentPath = path.join(checkpoint.cwd, file.path);
|
|
726
|
+
const snapshotPath = path.join(checkpoint.snapshotDir, file.path);
|
|
727
|
+
if (this.filesDiffer(snapshotPath, currentPath)) {
|
|
728
|
+
modified.push(file.path);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return { added, modified, deleted };
|
|
733
|
+
}
|
|
734
|
+
readManifest(manifestPath) {
|
|
735
|
+
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
|
736
|
+
return JSON.parse(raw);
|
|
737
|
+
}
|
|
738
|
+
async createSnapshot(cwd, snapshotDir) {
|
|
739
|
+
const files = await this.scanFiles(cwd);
|
|
740
|
+
const manifestEntries = [];
|
|
741
|
+
for (const filePath of files) {
|
|
742
|
+
const absolutePath = path.join(cwd, filePath);
|
|
743
|
+
try {
|
|
744
|
+
const stat = fs.statSync(absolutePath);
|
|
745
|
+
if (!stat.isFile())
|
|
746
|
+
continue;
|
|
747
|
+
const snapshotPath = path.join(snapshotDir, filePath);
|
|
748
|
+
this.ensureDirSync(path.dirname(snapshotPath));
|
|
749
|
+
fs.copyFileSync(absolutePath, snapshotPath);
|
|
750
|
+
manifestEntries.push({
|
|
751
|
+
path: filePath,
|
|
752
|
+
size: stat.size,
|
|
753
|
+
mtimeMs: stat.mtimeMs,
|
|
754
|
+
mode: stat.mode,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
catch (error) {
|
|
758
|
+
logWarning(`Failed to snapshot ${filePath}: ${error.message}`);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return {
|
|
762
|
+
version: 1,
|
|
763
|
+
createdAt: new Date().toISOString(),
|
|
764
|
+
cwd,
|
|
765
|
+
files: manifestEntries,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Create a snapshot of remote files by downloading them via the handler.
|
|
770
|
+
* Files are stored locally in the snapshot directory for later comparison/revert.
|
|
771
|
+
*/
|
|
772
|
+
async createRemoteSnapshot(cwd, snapshotDir, handler) {
|
|
773
|
+
const files = await this.scanRemoteFiles(cwd, handler);
|
|
774
|
+
const manifestEntries = [];
|
|
775
|
+
for (const filePath of files) {
|
|
776
|
+
const remotePath = cwd + '/' + filePath;
|
|
777
|
+
try {
|
|
778
|
+
const content = await handler.readFile(remotePath);
|
|
779
|
+
const snapshotPath = path.join(snapshotDir, filePath);
|
|
780
|
+
this.ensureDirSync(path.dirname(snapshotPath));
|
|
781
|
+
fs.writeFileSync(snapshotPath, content, 'utf-8');
|
|
782
|
+
const contentBuffer = Buffer.from(content, 'utf-8');
|
|
783
|
+
manifestEntries.push({
|
|
784
|
+
path: filePath,
|
|
785
|
+
size: contentBuffer.length,
|
|
786
|
+
mtimeMs: Date.now(),
|
|
787
|
+
mode: 0o644,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
catch (error) {
|
|
791
|
+
logWarning(`Failed to snapshot remote file ${filePath}: ${error.message}`);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return {
|
|
795
|
+
version: 1,
|
|
796
|
+
createdAt: new Date().toISOString(),
|
|
797
|
+
cwd,
|
|
798
|
+
files: manifestEntries,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
async scanFiles(cwd) {
|
|
802
|
+
const files = await fg(['**/*'], {
|
|
803
|
+
cwd,
|
|
804
|
+
dot: true,
|
|
805
|
+
onlyFiles: true,
|
|
806
|
+
followSymbolicLinks: false,
|
|
807
|
+
unique: true,
|
|
808
|
+
ignore: DEFAULT_IGNORE_PATTERNS,
|
|
809
|
+
});
|
|
810
|
+
return files.map((file) => file.replace(/\\/g, '/'));
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Scan files on a remote machine using the handler's executeCommand.
|
|
814
|
+
* Uses `find` with ignore patterns equivalent to DEFAULT_IGNORE_PATTERNS.
|
|
815
|
+
*/
|
|
816
|
+
async scanRemoteFiles(cwd, handler) {
|
|
817
|
+
// Build find command with exclusion patterns matching DEFAULT_IGNORE_PATTERNS
|
|
818
|
+
const excludeDirs = [
|
|
819
|
+
'.git', 'node_modules', 'dist', 'build', 'out',
|
|
820
|
+
'.next', '.turbo', '.cache', 'coverage',
|
|
821
|
+
'__pycache__', '.venv', 'venv', '.idea', '.vscode', '.centaurus',
|
|
822
|
+
];
|
|
823
|
+
const excludeFiles = ['.DS_Store', 'Thumbs.db'];
|
|
824
|
+
const pruneArgs = excludeDirs.map(d => `-name "${d}" -prune`).join(' -o ');
|
|
825
|
+
const notFileArgs = excludeFiles.map(f => `! -name "${f}"`).join(' ');
|
|
826
|
+
// find <cwd> ( -name .git -prune -o ... ) -o -type f ! -name .DS_Store ... -print
|
|
827
|
+
const findCmd = `find "${cwd}" \\( ${pruneArgs} \\) -o -type f ${notFileArgs} -print 2>/dev/null`;
|
|
828
|
+
try {
|
|
829
|
+
const result = await handler.executeCommand(findCmd);
|
|
830
|
+
if (result.exitCode !== 0 && !result.stdout.trim()) {
|
|
831
|
+
logWarning(`Remote file scan failed: ${result.stderr}`);
|
|
832
|
+
return [];
|
|
833
|
+
}
|
|
834
|
+
const cwdPrefix = cwd.endsWith('/') ? cwd : cwd + '/';
|
|
835
|
+
return result.stdout
|
|
836
|
+
.split('\n')
|
|
837
|
+
.map(line => line.trim())
|
|
838
|
+
.filter(line => line.length > 0 && line.startsWith(cwdPrefix))
|
|
839
|
+
.map(line => line.substring(cwdPrefix.length))
|
|
840
|
+
.filter(relPath => relPath.length > 0);
|
|
841
|
+
}
|
|
842
|
+
catch (error) {
|
|
843
|
+
logWarning(`Failed to scan remote files: ${error.message}`);
|
|
844
|
+
return [];
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
filesDiffer(snapshotPath, currentPath) {
|
|
848
|
+
try {
|
|
849
|
+
const snapStat = fs.statSync(snapshotPath);
|
|
850
|
+
const currStat = fs.statSync(currentPath);
|
|
851
|
+
if (snapStat.size !== currStat.size) {
|
|
852
|
+
return true;
|
|
853
|
+
}
|
|
854
|
+
const snapBuffer = fs.readFileSync(snapshotPath);
|
|
855
|
+
const currBuffer = fs.readFileSync(currentPath);
|
|
856
|
+
if (snapBuffer.length !== currBuffer.length) {
|
|
857
|
+
return true;
|
|
858
|
+
}
|
|
859
|
+
return !snapBuffer.equals(currBuffer);
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
return true;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
removeFileOrDirSync(targetPath) {
|
|
866
|
+
if (!fs.existsSync(targetPath))
|
|
867
|
+
return;
|
|
868
|
+
const stat = fs.lstatSync(targetPath);
|
|
869
|
+
if (stat.isDirectory()) {
|
|
870
|
+
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
fs.rmSync(targetPath, { force: true });
|
|
874
|
+
}
|
|
875
|
+
generateCheckpointId() {
|
|
876
|
+
const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '');
|
|
877
|
+
const random = Math.random().toString(36).substring(2, 6);
|
|
878
|
+
return `cp-${timestamp}-${random}`;
|
|
879
|
+
}
|
|
880
|
+
ensureDirSync(dirPath) {
|
|
881
|
+
if (!fs.existsSync(dirPath)) {
|
|
882
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
getChatDir() {
|
|
886
|
+
if (!this.currentChatId) {
|
|
887
|
+
return this.baseDir;
|
|
888
|
+
}
|
|
889
|
+
const dir = path.join(this.baseDir, this.currentChatId);
|
|
890
|
+
this.ensureDirSync(dir);
|
|
891
|
+
return dir;
|
|
892
|
+
}
|
|
893
|
+
getIndexPath() {
|
|
894
|
+
if (!this.currentChatId)
|
|
895
|
+
return null;
|
|
896
|
+
return path.join(this.getChatDir(), 'index.json');
|
|
897
|
+
}
|
|
898
|
+
loadIndex() {
|
|
899
|
+
this.checkpoints = [];
|
|
900
|
+
this.discardedIds.clear();
|
|
901
|
+
const indexPath = this.getIndexPath();
|
|
902
|
+
if (!indexPath || !fs.existsSync(indexPath)) {
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
try {
|
|
906
|
+
const raw = fs.readFileSync(indexPath, 'utf-8');
|
|
907
|
+
const data = JSON.parse(raw);
|
|
908
|
+
this.checkpoints = data.filter(entry => fs.existsSync(entry.manifestPath));
|
|
909
|
+
}
|
|
910
|
+
catch (error) {
|
|
911
|
+
logWarning(`Failed to load checkpoint index: ${error.message}`);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
saveIndex() {
|
|
915
|
+
const indexPath = this.getIndexPath();
|
|
916
|
+
if (!indexPath)
|
|
917
|
+
return;
|
|
918
|
+
try {
|
|
919
|
+
fs.writeFileSync(indexPath, JSON.stringify(this.checkpoints, null, 2), 'utf-8');
|
|
920
|
+
}
|
|
921
|
+
catch (error) {
|
|
922
|
+
logWarning(`Failed to save checkpoint index: ${error.message}`);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
//# sourceMappingURL=checkpoint-manager.js.map
|