astrabot 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +411 -0
- package/ai/ai.config.ts +27 -0
- package/ai/auto-retry.ts +117 -0
- package/ai/config-loader.ts +132 -0
- package/ai/index.ts +4 -0
- package/ai/retry-prompt.ts +30 -0
- package/bin/astra +2 -0
- package/core/retry/error-classifier.ts +208 -0
- package/core/retry/index.ts +29 -0
- package/core/retry/retry-config.ts +142 -0
- package/core/retry/retry-engine.ts +215 -0
- package/game/index.html +573 -0
- package/game/neon-breaker.html +1037 -0
- package/index.ts +140 -0
- package/modes/agent/action-tracker.ts +47 -0
- package/modes/agent/agent-tools.ts +338 -0
- package/modes/agent/approval.ts +184 -0
- package/modes/agent/diff-view.ts +34 -0
- package/modes/agent/orchestrator.ts +234 -0
- package/modes/agent/tool-executor.ts +993 -0
- package/modes/agent/types.ts +68 -0
- package/modes/ask/orchestrator.ts +230 -0
- package/modes/auto.ts +88 -0
- package/modes/cli.ts +43 -0
- package/modes/multi/agent-pool-manager.ts +337 -0
- package/modes/multi/examples.ts +441 -0
- package/modes/multi/message-broker.ts +179 -0
- package/modes/multi/multi-agent-orchestrator.ts +891 -0
- package/modes/multi/orchestrator.ts +414 -0
- package/modes/multi/types.ts +245 -0
- package/modes/multi/workflow-builder.ts +569 -0
- package/modes/plan/orchestrator.ts +198 -0
- package/modes/plan/planner.ts +121 -0
- package/modes/plan/selection.ts +43 -0
- package/modes/plan/types.ts +13 -0
- package/modes/plan/web-tools.ts +132 -0
- package/modes/setup.ts +210 -0
- package/package.json +62 -0
- package/session/index.ts +45 -0
- package/session/session-context.ts +188 -0
- package/session/session-manager.ts +374 -0
- package/session/session-tools.ts +109 -0
- package/session/store.ts +278 -0
- package/tsconfig.json +30 -0
- package/tui/spinner.ts +182 -0
- package/tui/terminal-md.ts +17 -0
- package/tui/wakeup.ts +231 -0
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
import fs, { existsSync, readFileSync } from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { homedir } from 'os'
|
|
4
|
+
import { spawnSync, spawn } from 'child_process'
|
|
5
|
+
import type { AgentConfig, ActionLog } from './types'
|
|
6
|
+
import { ActionTracker } from './action-tracker'
|
|
7
|
+
|
|
8
|
+
const TEXT_EXT = new Set([
|
|
9
|
+
'.ts',
|
|
10
|
+
'.tsx',
|
|
11
|
+
'.js',
|
|
12
|
+
'.jsx',
|
|
13
|
+
'.mjs',
|
|
14
|
+
'.cjs',
|
|
15
|
+
'.json',
|
|
16
|
+
'.md',
|
|
17
|
+
'.mdx',
|
|
18
|
+
'.css',
|
|
19
|
+
'.html',
|
|
20
|
+
'.yml',
|
|
21
|
+
'.yaml',
|
|
22
|
+
'.toml',
|
|
23
|
+
'.txt',
|
|
24
|
+
'.sql',
|
|
25
|
+
'.graphql',
|
|
26
|
+
'.gql',
|
|
27
|
+
'.sh',
|
|
28
|
+
'.bash',
|
|
29
|
+
'.zsh',
|
|
30
|
+
'.py',
|
|
31
|
+
'.go',
|
|
32
|
+
'.rs',
|
|
33
|
+
'.java',
|
|
34
|
+
'.kt',
|
|
35
|
+
'.swift',
|
|
36
|
+
'.php',
|
|
37
|
+
'.rb',
|
|
38
|
+
'.vue',
|
|
39
|
+
'.svelte',
|
|
40
|
+
'.xml',
|
|
41
|
+
'.ini',
|
|
42
|
+
'.conf',
|
|
43
|
+
'.dockerfile',
|
|
44
|
+
'.env.example',
|
|
45
|
+
'.gitignore',
|
|
46
|
+
'.editorconfig',
|
|
47
|
+
'.prettierrc',
|
|
48
|
+
'.eslintrc',
|
|
49
|
+
'.lock',
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
function isProbablyTextFile(filePath: string): boolean {
|
|
53
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
54
|
+
return TEXT_EXT.has(ext) || ext === ""
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class ToolExecutor {
|
|
58
|
+
|
|
59
|
+
private overlay = new Map<string, string>()
|
|
60
|
+
private deleted = new Set<string>()
|
|
61
|
+
private appliedActionIds = new Set<string>()
|
|
62
|
+
private readonly norm = (rel: string) => path.posix.normalize(rel.split(path.sep).join("/")).replace(/^\.\//, "");
|
|
63
|
+
|
|
64
|
+
constructor(
|
|
65
|
+
private readonly tracker: ActionTracker,
|
|
66
|
+
private readonly config: AgentConfig
|
|
67
|
+
) {}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolves a relative path against the codebase root and performs strict
|
|
71
|
+
* validation checks against directory traversal vulnerabilities.
|
|
72
|
+
*/
|
|
73
|
+
private resolveSafe(rel: string): string {
|
|
74
|
+
// Prevent obvious directory traversal sequences before joining
|
|
75
|
+
if (rel.includes('..') && (rel.split(/[/\\]/).includes('..'))) {
|
|
76
|
+
throw new Error(`Path traversal attempt detected via segment navigation: ${rel}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const root = path.resolve(this.config.codebasePath);
|
|
80
|
+
const abs = path.resolve(root, rel);
|
|
81
|
+
|
|
82
|
+
// Strict boundary validation checking via path relativity
|
|
83
|
+
const relCheck = path.relative(root, abs);
|
|
84
|
+
if (relCheck.startsWith('..') || path.isAbsolute(relCheck)) {
|
|
85
|
+
throw new Error(`Security Exception: Path is outside the designated workspace: ${rel}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return abs;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Hydrates the memory maps and execution sets using an external array of action logs.
|
|
93
|
+
* This completes the state synchronization loop, allowing the overlay system
|
|
94
|
+
* to pick up exactly where a previous session or sub-pipeline execution left off.
|
|
95
|
+
*/
|
|
96
|
+
hydrateFromActions(actions: ActionLog[]): void {
|
|
97
|
+
// Reset local in-memory scratchpads first
|
|
98
|
+
this.overlay.clear();
|
|
99
|
+
this.deleted.clear();
|
|
100
|
+
this.appliedActionIds.clear();
|
|
101
|
+
|
|
102
|
+
// Sort historical mutations chronologically to accurately recreate sequential state
|
|
103
|
+
const sortedActions = [...actions].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
104
|
+
|
|
105
|
+
for (const action of sortedActions) {
|
|
106
|
+
const key = this.norm(action.path);
|
|
107
|
+
|
|
108
|
+
if (action.status === "executed") {
|
|
109
|
+
this.appliedActionIds.add(action.id);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Map pending or approved mutations to the virtual filesystem overlay layer
|
|
113
|
+
if (action.status === "pending" || action.status === "approved") {
|
|
114
|
+
if (action.type === "file_create" || action.type === "file_modify") {
|
|
115
|
+
if (action.details.after !== undefined) {
|
|
116
|
+
this.deleted.delete(key);
|
|
117
|
+
this.overlay.set(key, action.details.after);
|
|
118
|
+
}
|
|
119
|
+
} else if (action.type === "file_delete") {
|
|
120
|
+
this.overlay.delete(key);
|
|
121
|
+
this.deleted.add(key);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private excluded(relPath: string): boolean {
|
|
128
|
+
const norm = this.norm(relPath)
|
|
129
|
+
const segments = norm.split('/')
|
|
130
|
+
const base = segments[segments.length - 1] ?? ''
|
|
131
|
+
|
|
132
|
+
for (const pat of this.config.excludePatterns) {
|
|
133
|
+
if (pat === '*.log' && base.endsWith('.log')) return true
|
|
134
|
+
if (pat === '.env*' && base.startsWith('.env')) return true
|
|
135
|
+
if (pat.includes('*')) continue
|
|
136
|
+
if (segments.includes(pat) || norm === pat || norm.startsWith(`${pat}/`)) return true
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private assertNotExcluded(rel: string, op: string): void {
|
|
143
|
+
if (this.excluded(rel)) {
|
|
144
|
+
throw new Error(`${op}: path is excluded by policy: ${rel}`)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
getEffectiveText(rel: string): string | undefined {
|
|
149
|
+
const key = this.norm(rel)
|
|
150
|
+
if (this.deleted.has(key)) return undefined
|
|
151
|
+
if (this.overlay.has(key)) return this.overlay.get(key)
|
|
152
|
+
const abs = this.resolveSafe(rel)
|
|
153
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return undefined
|
|
154
|
+
return fs.readFileSync(abs, 'utf8')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
normalizePath(rel: string): string {
|
|
158
|
+
return this.norm(rel)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
readFile(rel: string): string {
|
|
162
|
+
this.assertNotExcluded(rel, "read_file");
|
|
163
|
+
const abs = this.resolveSafe(rel);
|
|
164
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
|
|
165
|
+
throw new Error(`File not found: ${rel}`);
|
|
166
|
+
}
|
|
167
|
+
const st = fs.statSync(abs);
|
|
168
|
+
if (st.size > this.config.maxFileSizeToRead) {
|
|
169
|
+
throw new Error(`File too large: ${rel}`);
|
|
170
|
+
}
|
|
171
|
+
const text = fs.readFileSync(abs, "utf8");
|
|
172
|
+
this.tracker.log({
|
|
173
|
+
type: "code_analysis",
|
|
174
|
+
path: this.norm(rel),
|
|
175
|
+
details: { after: text, toolName: "read_file" },
|
|
176
|
+
status: "executed",
|
|
177
|
+
});
|
|
178
|
+
return text;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
createFile(rel: string, content: string): string {
|
|
182
|
+
if (!this.config.tools.allowFileCreation)
|
|
183
|
+
throw new Error("File creation disabled");
|
|
184
|
+
this.assertNotExcluded(rel, "create_file");
|
|
185
|
+
const key = this.norm(rel);
|
|
186
|
+
const abs = this.resolveSafe(rel);
|
|
187
|
+
if (fs.existsSync(abs) && !this.deleted.has(key)) {
|
|
188
|
+
throw new Error(`create_file: already exists: ${rel}`);
|
|
189
|
+
}
|
|
190
|
+
this.deleted.delete(key);
|
|
191
|
+
this.overlay.set(key, content);
|
|
192
|
+
this.tracker.log({
|
|
193
|
+
type: "file_create",
|
|
194
|
+
path: key,
|
|
195
|
+
details: { after: content },
|
|
196
|
+
status: "pending",
|
|
197
|
+
});
|
|
198
|
+
return `Staged new file: ${key}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
modifyFile(rel: string, content: string): string {
|
|
202
|
+
if (!this.config.tools.allowFileModification)
|
|
203
|
+
throw new Error("File modification disabled");
|
|
204
|
+
this.assertNotExcluded(rel, "modify_file");
|
|
205
|
+
const before = this.getEffectiveText(rel);
|
|
206
|
+
if (before === undefined)
|
|
207
|
+
throw new Error(`modify_file: file not found: ${rel}`);
|
|
208
|
+
const key = this.norm(rel);
|
|
209
|
+
this.overlay.set(key, content);
|
|
210
|
+
this.tracker.log({
|
|
211
|
+
type: "file_modify",
|
|
212
|
+
path: key,
|
|
213
|
+
details: { before, after: content },
|
|
214
|
+
status: "pending",
|
|
215
|
+
});
|
|
216
|
+
return `Staged update: ${key}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
deleteFile(rel: string): string {
|
|
220
|
+
if (!this.config.tools.allowFileModification)
|
|
221
|
+
throw new Error("File deletion disabled");
|
|
222
|
+
this.assertNotExcluded(rel, "delete_file");
|
|
223
|
+
const before = this.getEffectiveText(rel);
|
|
224
|
+
if (before === undefined)
|
|
225
|
+
throw new Error(`delete_file: file not found: ${rel}`);
|
|
226
|
+
const key = this.norm(rel);
|
|
227
|
+
this.overlay.delete(key);
|
|
228
|
+
this.deleted.add(key);
|
|
229
|
+
this.tracker.log({
|
|
230
|
+
type: "file_delete",
|
|
231
|
+
path: key,
|
|
232
|
+
details: { before },
|
|
233
|
+
status: "pending",
|
|
234
|
+
});
|
|
235
|
+
return `Staged delete: ${key}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
createFolder(rel: string): string {
|
|
239
|
+
if (!this.config.tools.allowFolderCreation)
|
|
240
|
+
throw new Error("Folder creation disabled");
|
|
241
|
+
this.assertNotExcluded(rel, "create_folder");
|
|
242
|
+
const key = this.norm(rel);
|
|
243
|
+
this.tracker.log({
|
|
244
|
+
type: "folder_create",
|
|
245
|
+
path: key,
|
|
246
|
+
details: { after: key },
|
|
247
|
+
status: "pending",
|
|
248
|
+
});
|
|
249
|
+
return `Staged folder: ${key}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
listFiles(rel: string, recursive: boolean): string {
|
|
253
|
+
this.assertNotExcluded(rel, "list_files");
|
|
254
|
+
const abs = this.resolveSafe(rel);
|
|
255
|
+
if (!fs.existsSync(abs)) throw new Error(`list_files: not found: ${rel}`);
|
|
256
|
+
|
|
257
|
+
const lines: string[] = [];
|
|
258
|
+
const walk = (dir: string, prefix: string) => {
|
|
259
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
260
|
+
for (const ent of entries) {
|
|
261
|
+
const full = path.join(dir, ent.name);
|
|
262
|
+
const relP = path.relative(this.config.codebasePath, full);
|
|
263
|
+
if (this.excluded(relP)) continue;
|
|
264
|
+
if (ent.isDirectory()) {
|
|
265
|
+
lines.push(`${prefix}${ent.name}/`);
|
|
266
|
+
if (recursive) walk(full, `${prefix}${ent.name}/`);
|
|
267
|
+
} else {
|
|
268
|
+
lines.push(`${prefix}${ent.name}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
if (fs.statSync(abs).isDirectory()) walk(abs, "");
|
|
274
|
+
else lines.push(path.relative(this.config.codebasePath, abs));
|
|
275
|
+
|
|
276
|
+
const out = lines.sort().join("\n");
|
|
277
|
+
this.tracker.log({
|
|
278
|
+
type: "code_analysis",
|
|
279
|
+
path: this.norm(rel),
|
|
280
|
+
details: { after: out, toolName: "list_files" },
|
|
281
|
+
status: "executed",
|
|
282
|
+
});
|
|
283
|
+
return out || "(empty)";
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
searchFiles(
|
|
287
|
+
rootRel: string,
|
|
288
|
+
globPattern: string,
|
|
289
|
+
contentQuery?: string,
|
|
290
|
+
): string {
|
|
291
|
+
this.assertNotExcluded(rootRel, "search_files");
|
|
292
|
+
const rootAbs = this.resolveSafe(rootRel);
|
|
293
|
+
if (!fs.existsSync(rootAbs))
|
|
294
|
+
throw new Error(`search_files: root not found: ${rootRel}`);
|
|
295
|
+
|
|
296
|
+
const results: string[] = [];
|
|
297
|
+
const regexFromGlob = (g: string): RegExp => {
|
|
298
|
+
// Convert glob pattern to regex:
|
|
299
|
+
// ** = match zero or more path segments (including /)
|
|
300
|
+
// * = match anything except /
|
|
301
|
+
// ? = match any single character except /
|
|
302
|
+
const escaped = g
|
|
303
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex metacharacters
|
|
304
|
+
.replace(/\\\*\\\*\//g, "(?:.+/)?") // **/ → optionally one-or-more-path-segments/
|
|
305
|
+
.replace(/\\\*\\\*/g, ".*") // ** → any characters including /
|
|
306
|
+
.replace(/\\\*/g, "[^/]*") // * → match anything except /
|
|
307
|
+
.replace(/\\\?/g, "[^/]"); // ? → match any single char except /
|
|
308
|
+
return new RegExp(`^${escaped}$`, "i");
|
|
309
|
+
};
|
|
310
|
+
const nameRe = regexFromGlob(globPattern.replace(/\\/g, "/"));
|
|
311
|
+
|
|
312
|
+
const walk = (dir: string) => {
|
|
313
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
314
|
+
const full = path.join(dir, ent.name);
|
|
315
|
+
const relP = path
|
|
316
|
+
.relative(this.config.codebasePath, full)
|
|
317
|
+
.split(path.sep)
|
|
318
|
+
.join("/");
|
|
319
|
+
if (this.excluded(relP)) continue;
|
|
320
|
+
if (ent.isDirectory()) walk(full);
|
|
321
|
+
else if (nameRe.test(relP) || nameRe.test(ent.name)) {
|
|
322
|
+
if (contentQuery) {
|
|
323
|
+
if (!isProbablyTextFile(full)) continue;
|
|
324
|
+
const text = fs.readFileSync(full, "utf8");
|
|
325
|
+
if (!text.includes(contentQuery)) continue;
|
|
326
|
+
}
|
|
327
|
+
results.push(relP);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
if (fs.statSync(rootAbs).isDirectory()) walk(rootAbs);
|
|
333
|
+
else {
|
|
334
|
+
const relP = path
|
|
335
|
+
.relative(this.config.codebasePath, rootAbs)
|
|
336
|
+
.split(path.sep)
|
|
337
|
+
.join("/");
|
|
338
|
+
results.push(relP);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const out = [...new Set(results)].sort().join("\n");
|
|
342
|
+
this.tracker.log({
|
|
343
|
+
type: "code_analysis",
|
|
344
|
+
path: this.norm(rootRel),
|
|
345
|
+
details: { after: out || "(no matches)", toolName: "search_files" },
|
|
346
|
+
status: "executed",
|
|
347
|
+
});
|
|
348
|
+
return out || "(no matches)";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
analyzeCodebase(rootRel: string): string {
|
|
352
|
+
const rootAbs = this.resolveSafe(rootRel);
|
|
353
|
+
if (!fs.existsSync(rootAbs))
|
|
354
|
+
throw new Error(`analyze_codebase: not found: ${rootRel}`);
|
|
355
|
+
|
|
356
|
+
let files = 0;
|
|
357
|
+
let dirs = 0;
|
|
358
|
+
const walk = (dir: string) => {
|
|
359
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
360
|
+
const full = path.join(dir, ent.name);
|
|
361
|
+
const relP = path.relative(this.config.codebasePath, full);
|
|
362
|
+
if (this.excluded(relP)) continue;
|
|
363
|
+
if (ent.isDirectory()) {
|
|
364
|
+
dirs++;
|
|
365
|
+
walk(full);
|
|
366
|
+
} else {
|
|
367
|
+
files++;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
if (fs.statSync(rootAbs).isDirectory()) walk(rootAbs);
|
|
372
|
+
else files = 1;
|
|
373
|
+
|
|
374
|
+
const summary = `Files: ${files} | Directories: ${dirs}`;
|
|
375
|
+
this.tracker.log({
|
|
376
|
+
type: "code_analysis",
|
|
377
|
+
path: this.norm(rootRel),
|
|
378
|
+
details: { after: summary, toolName: "analyze_codebase" },
|
|
379
|
+
status: "executed",
|
|
380
|
+
});
|
|
381
|
+
return summary;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
queueShell(command: string): string {
|
|
385
|
+
if (!this.config.tools.allowShellExecution)
|
|
386
|
+
throw new Error("Shell execution disabled");
|
|
387
|
+
this.tracker.log({
|
|
388
|
+
type: "tool_execute",
|
|
389
|
+
path: "shell",
|
|
390
|
+
details: { command, toolName: "execute_shell" },
|
|
391
|
+
status: "pending",
|
|
392
|
+
});
|
|
393
|
+
return `Shell queued: ${command}`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
readMultipleFiles(paths: string[]) {
|
|
397
|
+
const result: Record<string, string> = {};
|
|
398
|
+
|
|
399
|
+
for (const rel of paths) {
|
|
400
|
+
this.assertNotExcluded(rel, "read_multiple_files");
|
|
401
|
+
const abs = this.resolveSafe(rel);
|
|
402
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
|
|
403
|
+
throw new Error(`File not found: ${rel}`);
|
|
404
|
+
}
|
|
405
|
+
const st = fs.statSync(abs);
|
|
406
|
+
if (st.size > this.config.maxFileSizeToRead) {
|
|
407
|
+
throw new Error(`File too large: ${rel}`);
|
|
408
|
+
}
|
|
409
|
+
const content = fs.readFileSync(abs, "utf8");
|
|
410
|
+
|
|
411
|
+
// Log each file read individually for audit trail
|
|
412
|
+
this.tracker.log({
|
|
413
|
+
type: "code_analysis",
|
|
414
|
+
path: this.norm(rel),
|
|
415
|
+
details: { after: content, toolName: "read_multiple_files" },
|
|
416
|
+
status: "executed",
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
result[rel] = content;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return result;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
replaceInFile(
|
|
426
|
+
rel: string,
|
|
427
|
+
search: string,
|
|
428
|
+
replace: string
|
|
429
|
+
): string {
|
|
430
|
+
if (!this.config.tools.allowFileModification)
|
|
431
|
+
throw new Error("File modification disabled");
|
|
432
|
+
|
|
433
|
+
this.assertNotExcluded(rel, "replace_in_file");
|
|
434
|
+
const before = this.getEffectiveText(rel);
|
|
435
|
+
|
|
436
|
+
if (before === undefined)
|
|
437
|
+
throw new Error(`File not found: ${rel}`);
|
|
438
|
+
|
|
439
|
+
if (!before.includes(search))
|
|
440
|
+
throw new Error(`Search text not found in ${rel}`);
|
|
441
|
+
|
|
442
|
+
const after = before.replace(search, replace);
|
|
443
|
+
const key = this.norm(rel);
|
|
444
|
+
|
|
445
|
+
this.overlay.set(key, after);
|
|
446
|
+
this.deleted.delete(key);
|
|
447
|
+
|
|
448
|
+
this.tracker.log({
|
|
449
|
+
type: "file_modify",
|
|
450
|
+
path: key,
|
|
451
|
+
details: { before, after },
|
|
452
|
+
status: "pending"
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
return `Staged replacement in ${key}`;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
appendToFile(
|
|
459
|
+
rel: string,
|
|
460
|
+
content: string
|
|
461
|
+
): string {
|
|
462
|
+
if (!this.config.tools.allowFileModification)
|
|
463
|
+
throw new Error("File modification disabled");
|
|
464
|
+
|
|
465
|
+
this.assertNotExcluded(rel, "append_to_file");
|
|
466
|
+
const before = this.getEffectiveText(rel);
|
|
467
|
+
|
|
468
|
+
if (before === undefined)
|
|
469
|
+
throw new Error(`File not found: ${rel}`);
|
|
470
|
+
|
|
471
|
+
const after = before + content;
|
|
472
|
+
const key = this.norm(rel);
|
|
473
|
+
|
|
474
|
+
this.overlay.set(key, after);
|
|
475
|
+
|
|
476
|
+
this.tracker.log({
|
|
477
|
+
type: "file_modify",
|
|
478
|
+
path: key,
|
|
479
|
+
details: { before, after },
|
|
480
|
+
status: "pending"
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
return `Staged append: ${key}`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
insertAtLine(
|
|
487
|
+
rel: string,
|
|
488
|
+
line: number,
|
|
489
|
+
content: string
|
|
490
|
+
): string {
|
|
491
|
+
if (!this.config.tools.allowFileModification)
|
|
492
|
+
throw new Error("File modification disabled");
|
|
493
|
+
|
|
494
|
+
this.assertNotExcluded(rel, "insert_at_line");
|
|
495
|
+
const before = this.getEffectiveText(rel);
|
|
496
|
+
|
|
497
|
+
if (before === undefined)
|
|
498
|
+
throw new Error(`File not found: ${rel}`);
|
|
499
|
+
|
|
500
|
+
const lines = before.split("\n");
|
|
501
|
+
|
|
502
|
+
if (line < 1 || line > lines.length + 1)
|
|
503
|
+
throw new Error(`Invalid line number: ${line}`);
|
|
504
|
+
|
|
505
|
+
lines.splice(line - 1, 0, content);
|
|
506
|
+
const after = lines.join("\n");
|
|
507
|
+
const key = this.norm(rel);
|
|
508
|
+
|
|
509
|
+
this.overlay.set(key, after);
|
|
510
|
+
|
|
511
|
+
this.tracker.log({
|
|
512
|
+
type: "file_modify",
|
|
513
|
+
path: key,
|
|
514
|
+
details: { before, after },
|
|
515
|
+
status: "pending"
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
return `Inserted content at line ${line} in ${key}`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
showPendingChanges() {
|
|
522
|
+
return this.tracker.getPendingMutations()
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
discardStagedPath(rel: string): void {
|
|
526
|
+
const key = this.norm(rel)
|
|
527
|
+
this.overlay.delete(key)
|
|
528
|
+
this.deleted.delete(key)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
discardChanges(): string {
|
|
532
|
+
this.overlay.clear()
|
|
533
|
+
this.deleted.clear()
|
|
534
|
+
this.appliedActionIds.clear()
|
|
535
|
+
return "Discarded all staged changes"
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
gitStatus() {
|
|
539
|
+
const result = spawnSync(
|
|
540
|
+
"git",
|
|
541
|
+
["status", "--short"],
|
|
542
|
+
{
|
|
543
|
+
cwd: this.config.codebasePath,
|
|
544
|
+
encoding: "utf8"
|
|
545
|
+
}
|
|
546
|
+
);
|
|
547
|
+
return result.stdout.trim();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
gitDiff(staged = false) {
|
|
551
|
+
const args = staged ? ["diff", "--staged"] : ["diff"];
|
|
552
|
+
const result = spawnSync(
|
|
553
|
+
"git",
|
|
554
|
+
args,
|
|
555
|
+
{
|
|
556
|
+
cwd: this.config.codebasePath,
|
|
557
|
+
encoding: "utf8"
|
|
558
|
+
}
|
|
559
|
+
);
|
|
560
|
+
return result.stdout;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
gitLog(limit = 20) {
|
|
564
|
+
const result = spawnSync(
|
|
565
|
+
"git",
|
|
566
|
+
[
|
|
567
|
+
"log",
|
|
568
|
+
"--oneline",
|
|
569
|
+
`-${limit}`
|
|
570
|
+
],
|
|
571
|
+
{
|
|
572
|
+
cwd: this.config.codebasePath,
|
|
573
|
+
encoding: "utf8"
|
|
574
|
+
}
|
|
575
|
+
);
|
|
576
|
+
return result.stdout.trim();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
runCommand(
|
|
580
|
+
command: string,
|
|
581
|
+
cwd?: string
|
|
582
|
+
) {
|
|
583
|
+
const resolvedCwd = cwd ? this.resolveSafe(cwd) : this.config.codebasePath;
|
|
584
|
+
const result = spawnSync(
|
|
585
|
+
command,
|
|
586
|
+
{
|
|
587
|
+
cwd: resolvedCwd,
|
|
588
|
+
shell: true,
|
|
589
|
+
encoding: "utf8",
|
|
590
|
+
maxBuffer: 1024 * 1024 * 10
|
|
591
|
+
}
|
|
592
|
+
);
|
|
593
|
+
return {
|
|
594
|
+
exitCode: result.status,
|
|
595
|
+
stdout: result.stdout,
|
|
596
|
+
stderr: result.stderr
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
runBackgroundCommand(args: { command: string; cwd?: string }): string {
|
|
601
|
+
if (!this.config.tools.allowShellExecution)
|
|
602
|
+
throw new Error("Shell execution disabled");
|
|
603
|
+
const resolvedCwd = args.cwd ? this.resolveSafe(args.cwd) : this.config.codebasePath;
|
|
604
|
+
this.tracker.log({
|
|
605
|
+
type: "tool_execute",
|
|
606
|
+
path: "shell",
|
|
607
|
+
details: { command: args.command, toolName: "run_background_command" },
|
|
608
|
+
status: "pending",
|
|
609
|
+
});
|
|
610
|
+
const child = spawn(args.command, {
|
|
611
|
+
cwd: resolvedCwd,
|
|
612
|
+
shell: true,
|
|
613
|
+
detached: true,
|
|
614
|
+
stdio: "ignore",
|
|
615
|
+
});
|
|
616
|
+
child.unref();
|
|
617
|
+
return `Background process started (pid ${child.pid}): ${args.command}`;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
grep(args: { root: string; query: string; caseSensitive: boolean }): string {
|
|
621
|
+
this.assertNotExcluded(args.root, "grep");
|
|
622
|
+
const rootAbs = this.resolveSafe(args.root);
|
|
623
|
+
if (!fs.existsSync(rootAbs))
|
|
624
|
+
throw new Error(`grep: root not found: ${args.root}`);
|
|
625
|
+
|
|
626
|
+
const flags = args.caseSensitive ? "" : "i";
|
|
627
|
+
const re = new RegExp(args.query, flags);
|
|
628
|
+
const matches: string[] = [];
|
|
629
|
+
|
|
630
|
+
const walk = (dir: string) => {
|
|
631
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
632
|
+
const full = path.join(dir, ent.name);
|
|
633
|
+
const relP = path
|
|
634
|
+
.relative(this.config.codebasePath, full)
|
|
635
|
+
.split(path.sep)
|
|
636
|
+
.join("/");
|
|
637
|
+
if (this.excluded(relP)) continue;
|
|
638
|
+
if (ent.isDirectory()) {
|
|
639
|
+
walk(full);
|
|
640
|
+
} else if (isProbablyTextFile(full)) {
|
|
641
|
+
const text = fs.readFileSync(full, "utf8");
|
|
642
|
+
const lines = text.split("\n");
|
|
643
|
+
lines.forEach((line: string, idx: number) => {
|
|
644
|
+
if (re.test(line))
|
|
645
|
+
matches.push(`${relP}:${idx + 1}: ${line.trim()}`);
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
if (fs.statSync(rootAbs).isDirectory()) walk(rootAbs);
|
|
652
|
+
else if (isProbablyTextFile(rootAbs)) {
|
|
653
|
+
const text = fs.readFileSync(rootAbs, "utf8");
|
|
654
|
+
const lines = text.split("\n");
|
|
655
|
+
lines.forEach((line: string, idx: number) => {
|
|
656
|
+
if (re.test(line))
|
|
657
|
+
matches.push(`${args.root}:${idx + 1}: ${line.trim()}`);
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const out = matches.join("\n");
|
|
662
|
+
this.tracker.log({
|
|
663
|
+
type: "code_analysis",
|
|
664
|
+
path: this.norm(args.root),
|
|
665
|
+
details: { after: out || "(no matches)", toolName: "grep" },
|
|
666
|
+
status: "executed",
|
|
667
|
+
});
|
|
668
|
+
return out || "(no matches)";
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
readPackageJson(): string {
|
|
672
|
+
const pkgPath = path.join(this.config.codebasePath, "package.json");
|
|
673
|
+
if (!fs.existsSync(pkgPath))
|
|
674
|
+
throw new Error("package.json not found in workspace root");
|
|
675
|
+
const text = fs.readFileSync(pkgPath, "utf8");
|
|
676
|
+
const pkg = JSON.parse(text);
|
|
677
|
+
const summary = {
|
|
678
|
+
name: pkg.name,
|
|
679
|
+
version: pkg.version,
|
|
680
|
+
description: pkg.description,
|
|
681
|
+
scripts: pkg.scripts ?? {},
|
|
682
|
+
dependencies: Object.keys(pkg.dependencies ?? {}),
|
|
683
|
+
devDependencies: Object.keys(pkg.devDependencies ?? {}),
|
|
684
|
+
};
|
|
685
|
+
this.tracker.log({
|
|
686
|
+
type: "code_analysis",
|
|
687
|
+
path: "package.json",
|
|
688
|
+
details: { after: JSON.stringify(summary, null, 2), toolName: "read_package_json" },
|
|
689
|
+
status: "executed",
|
|
690
|
+
});
|
|
691
|
+
return JSON.stringify(summary, null, 2);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
runTests(filter?: string): { exitCode: number | null; stdout: string; stderr: string } {
|
|
695
|
+
const pkgPath = path.join(this.config.codebasePath, "package.json");
|
|
696
|
+
let testCmd = "npm test";
|
|
697
|
+
if (fs.existsSync(pkgPath)) {
|
|
698
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
699
|
+
if (pkg.scripts?.test) testCmd = "npm test";
|
|
700
|
+
else if (pkg.devDependencies?.vitest || pkg.dependencies?.vitest)
|
|
701
|
+
testCmd = "npx vitest run";
|
|
702
|
+
else if (pkg.devDependencies?.jest || pkg.dependencies?.jest)
|
|
703
|
+
testCmd = "npx jest";
|
|
704
|
+
}
|
|
705
|
+
const cmd = filter ? `${testCmd} -- ${filter}` : testCmd;
|
|
706
|
+
return this.runCommand(cmd);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
runTestFile(filePath: string): { exitCode: number | null; stdout: string; stderr: string } {
|
|
710
|
+
this.assertNotExcluded(filePath, "run_test_file");
|
|
711
|
+
const pkgPath = path.join(this.config.codebasePath, "package.json");
|
|
712
|
+
let runner = "npx jest";
|
|
713
|
+
if (fs.existsSync(pkgPath)) {
|
|
714
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
715
|
+
if (pkg.devDependencies?.vitest || pkg.dependencies?.vitest)
|
|
716
|
+
runner = "npx vitest run";
|
|
717
|
+
}
|
|
718
|
+
return this.runCommand(`${runner} ${filePath}`);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
lintProject(): { exitCode: number | null; stdout: string; stderr: string } {
|
|
722
|
+
const pkgPath = path.join(this.config.codebasePath, "package.json");
|
|
723
|
+
let lintCmd = "npx eslint .";
|
|
724
|
+
if (fs.existsSync(pkgPath)) {
|
|
725
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
726
|
+
if (pkg.scripts?.lint) lintCmd = "npm run lint";
|
|
727
|
+
}
|
|
728
|
+
return this.runCommand(lintCmd);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
formatProject(): { exitCode: number | null; stdout: string; stderr: string } {
|
|
732
|
+
const pkgPath = path.join(this.config.codebasePath, "package.json");
|
|
733
|
+
let fmtCmd = "npx prettier --write .";
|
|
734
|
+
if (fs.existsSync(pkgPath)) {
|
|
735
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
736
|
+
if (pkg.scripts?.format) fmtCmd = "npm run format";
|
|
737
|
+
}
|
|
738
|
+
return this.runCommand(fmtCmd);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
webSearch(query: string): string {
|
|
742
|
+
const encoded = encodeURIComponent(query);
|
|
743
|
+
const result = spawnSync(
|
|
744
|
+
"curl",
|
|
745
|
+
[
|
|
746
|
+
"-s",
|
|
747
|
+
"-A", "Mozilla/5.0",
|
|
748
|
+
`https://html.duckduckgo.com/html/?q=${encoded}`,
|
|
749
|
+
],
|
|
750
|
+
{ encoding: "utf8", maxBuffer: 1024 * 1024 * 5 }
|
|
751
|
+
);
|
|
752
|
+
if (result.status !== 0)
|
|
753
|
+
return `web_search error: ${result.stderr ?? "unknown"}`;
|
|
754
|
+
const text = result.stdout
|
|
755
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
756
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
757
|
+
.replace(/<[^>]+>/g, " ")
|
|
758
|
+
.replace(/\s{2,}/g, " ")
|
|
759
|
+
.trim()
|
|
760
|
+
.slice(0, 4000);
|
|
761
|
+
this.tracker.log({
|
|
762
|
+
type: "code_analysis",
|
|
763
|
+
path: "web",
|
|
764
|
+
details: { after: text, toolName: "web_search" },
|
|
765
|
+
status: "executed",
|
|
766
|
+
});
|
|
767
|
+
return text;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
fetchUrl(url: string): string {
|
|
771
|
+
const result = spawnSync(
|
|
772
|
+
"curl",
|
|
773
|
+
["-s", "-L", "-A", "Mozilla/5.0", "--max-time", "15", url],
|
|
774
|
+
{ encoding: "utf8", maxBuffer: 1024 * 1024 * 5 }
|
|
775
|
+
);
|
|
776
|
+
if (result.status !== 0)
|
|
777
|
+
return `fetch_url error: ${result.stderr ?? "unknown"}`;
|
|
778
|
+
const text = result.stdout
|
|
779
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
780
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
781
|
+
.replace(/<[^>]+>/g, " ")
|
|
782
|
+
.replace(/\s{2,}/g, " ")
|
|
783
|
+
.trim()
|
|
784
|
+
.slice(0, 8000);
|
|
785
|
+
this.tracker.log({
|
|
786
|
+
type: "code_analysis",
|
|
787
|
+
path: url,
|
|
788
|
+
details: { after: text, toolName: "fetch_url" },
|
|
789
|
+
status: "executed",
|
|
790
|
+
});
|
|
791
|
+
return text;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
private plan: { goal: string; steps: string[] } | null = null;
|
|
795
|
+
|
|
796
|
+
createPlan(goal: string): string {
|
|
797
|
+
this.plan = { goal, steps: [] };
|
|
798
|
+
this.tracker.log({
|
|
799
|
+
type: "code_analysis",
|
|
800
|
+
path: "plan",
|
|
801
|
+
details: { after: goal, toolName: "create_plan" },
|
|
802
|
+
status: "executed",
|
|
803
|
+
});
|
|
804
|
+
return `Plan created for goal: "${goal}". Use get_plan to retrieve it.`;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
getPlan(): string {
|
|
808
|
+
if (!this.plan) return "(no active plan)";
|
|
809
|
+
return JSON.stringify(this.plan, null, 2);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
private applyChanges(): { errors: string[] } {
|
|
813
|
+
for (const action of this.tracker.getActions()) {
|
|
814
|
+
if (action.status === "pending") {
|
|
815
|
+
action.status = "approved";
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return this.applyApprovedFromTracker();
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
workspaceContext() {
|
|
822
|
+
return {
|
|
823
|
+
root: this.config.codebasePath,
|
|
824
|
+
pendingChanges: this.overlay.size,
|
|
825
|
+
pendingDeletes: this.deleted.size,
|
|
826
|
+
git: this.gitStatus(),
|
|
827
|
+
trackedActions: this.tracker.getPendingMutations().length
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
detectFramework() {
|
|
832
|
+
const pkgPath = path.join(this.config.codebasePath, "package.json");
|
|
833
|
+
if (!existsSync(pkgPath)) return { framework: "unknown" };
|
|
834
|
+
|
|
835
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
836
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
837
|
+
|
|
838
|
+
if ("next" in deps) return { framework: "Next.js" };
|
|
839
|
+
if ("react" in deps) return { framework: "React" };
|
|
840
|
+
if ("vue" in deps) return { framework: "Vue" };
|
|
841
|
+
if ("svelte" in deps) return { framework: "Svelte" };
|
|
842
|
+
|
|
843
|
+
return { framework: "Node.js" };
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
skillRoots(): string[] {
|
|
847
|
+
const extra = process.env.SKILLS_DIRS?.split(/[;]/).map((s: string) => s.trim()).filter(Boolean) ?? [];
|
|
848
|
+
return [
|
|
849
|
+
...extra,
|
|
850
|
+
path.join(homedir(), ".cursor/skills-cursor"),
|
|
851
|
+
path.join(homedir(), ".claude/skills"),
|
|
852
|
+
];
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
listSkills(): string {
|
|
856
|
+
const lines: string[] = [];
|
|
857
|
+
for (const root of this.skillRoots()) {
|
|
858
|
+
if (!fs.existsSync(root)) continue;
|
|
859
|
+
const walk = (dir: string) => {
|
|
860
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
861
|
+
const full = path.join(dir, ent.name);
|
|
862
|
+
if (ent.isDirectory()) walk(full);
|
|
863
|
+
else if (ent.name === "SKILL.md") lines.push(full);
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
walk(root);
|
|
867
|
+
}
|
|
868
|
+
const out = lines.sort().join("\n");
|
|
869
|
+
this.tracker.log({
|
|
870
|
+
type: "code_analysis",
|
|
871
|
+
path: "skills",
|
|
872
|
+
details: { after: out || "(none)", toolName: "list_skills" },
|
|
873
|
+
status: "executed",
|
|
874
|
+
});
|
|
875
|
+
return out || "(none)";
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
readSkill(skillPath: string): string {
|
|
879
|
+
const abs = path.isAbsolute(skillPath)
|
|
880
|
+
? path.normalize(skillPath)
|
|
881
|
+
: path.normalize(path.resolve(this.config.codebasePath, skillPath));
|
|
882
|
+
const allowed = this.skillRoots().some((root) => {
|
|
883
|
+
const r = path.resolve(root);
|
|
884
|
+
return abs === r || abs.startsWith(r + path.sep);
|
|
885
|
+
});
|
|
886
|
+
if (!allowed) throw new Error("read_skill: outside skill roots");
|
|
887
|
+
const text = fs.readFileSync(abs, "utf8");
|
|
888
|
+
this.tracker.log({
|
|
889
|
+
type: "code_analysis",
|
|
890
|
+
path: abs,
|
|
891
|
+
details: { after: text, toolName: "read_skill" },
|
|
892
|
+
status: "executed",
|
|
893
|
+
});
|
|
894
|
+
return text;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
applyApprovedFromTracker(): { errors: string[] } {
|
|
898
|
+
const errors: string[] = [];
|
|
899
|
+
const all = [...this.tracker.getActions()];
|
|
900
|
+
|
|
901
|
+
for (const a of all.filter(
|
|
902
|
+
(x) => x.type === "folder_create" && x.status === "approved" && !this.appliedActionIds.has(x.id),
|
|
903
|
+
)) {
|
|
904
|
+
try {
|
|
905
|
+
fs.mkdirSync(this.resolveSafe(a.path), { recursive: true });
|
|
906
|
+
this.appliedActionIds.add(a.id);
|
|
907
|
+
} catch (e) {
|
|
908
|
+
errors.push(String(e));
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const fileOps = all
|
|
913
|
+
.filter(
|
|
914
|
+
(a) =>
|
|
915
|
+
(a.type === "file_create" || a.type === "file_modify" || a.type === "file_delete") &&
|
|
916
|
+
a.status === "approved" &&
|
|
917
|
+
!this.appliedActionIds.has(a.id),
|
|
918
|
+
)
|
|
919
|
+
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
920
|
+
|
|
921
|
+
const opsByPath = new Map<string, ActionLog[]>();
|
|
922
|
+
for (const a of fileOps) {
|
|
923
|
+
const key = this.norm(a.path);
|
|
924
|
+
const existing = opsByPath.get(key) ?? [];
|
|
925
|
+
existing.push(a);
|
|
926
|
+
opsByPath.set(key, existing);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
for (const [p, ops] of opsByPath) {
|
|
930
|
+
const a = ops[ops.length - 1];
|
|
931
|
+
if (!a) continue;
|
|
932
|
+
try {
|
|
933
|
+
if (a.type === "file_delete")
|
|
934
|
+
fs.rmSync(this.resolveSafe(p), { force: true });
|
|
935
|
+
else {
|
|
936
|
+
const target = this.resolveSafe(p);
|
|
937
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
938
|
+
fs.writeFileSync(target, a.details.after ?? "", "utf8");
|
|
939
|
+
}
|
|
940
|
+
for (const op of ops) this.appliedActionIds.add(op.id);
|
|
941
|
+
} catch (e) {
|
|
942
|
+
errors.push(String(e));
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
for (const a of all.filter(
|
|
947
|
+
(x) => x.type === "tool_execute" && x.status === "approved" && !this.appliedActionIds.has(x.id),
|
|
948
|
+
)) {
|
|
949
|
+
const cmd = a.details.command;
|
|
950
|
+
if (!cmd) continue;
|
|
951
|
+
|
|
952
|
+
try {
|
|
953
|
+
const r = spawnSync(cmd, {
|
|
954
|
+
shell: true,
|
|
955
|
+
cwd: this.config.codebasePath,
|
|
956
|
+
encoding: "utf8",
|
|
957
|
+
maxBuffer: 16 * 1024 * 1024, // 16MB allocation guard rail
|
|
958
|
+
timeout: 300000, // 5-minute definitive timeout threshold limit
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
// Check if the operation was forcefully killed due to a timeout hang
|
|
962
|
+
if (r.error && (r.error as any).code === "ETIMEDOUT") {
|
|
963
|
+
errors.push(`Command timed out after 5 minutes of inactivity: "${cmd}"`);
|
|
964
|
+
this.appliedActionIds.add(a.id);
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Truncate overly long command execution outputs to keep CLI context stable
|
|
969
|
+
const MAX_OUTPUT_LENGTH = 15000;
|
|
970
|
+
let cleanStdout = r.stdout || "";
|
|
971
|
+
let cleanStderr = r.stderr || "";
|
|
972
|
+
|
|
973
|
+
if (cleanStdout.length > MAX_OUTPUT_LENGTH) {
|
|
974
|
+
cleanStdout = cleanStdout.slice(0, MAX_OUTPUT_LENGTH) + "\n\n... [Output Truncated by Astra for context optimization] ...";
|
|
975
|
+
}
|
|
976
|
+
if (cleanStderr.length > MAX_OUTPUT_LENGTH) {
|
|
977
|
+
cleanStderr = cleanStderr.slice(0, MAX_OUTPUT_LENGTH) + "\n\n... [Error Log Truncated by Astra] ...";
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
if (r.status !== 0) {
|
|
981
|
+
errors.push(`Command "${cmd}" exited with code ${r.status}. Error: ${cleanStderr}`);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
this.appliedActionIds.add(a.id);
|
|
985
|
+
} catch (spawnError) {
|
|
986
|
+
errors.push(`Execution error spawning command "${cmd}": ${(spawnError as Error).message}`);
|
|
987
|
+
this.appliedActionIds.add(a.id);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
return { errors };
|
|
992
|
+
}
|
|
993
|
+
}
|