minovative-mind-cli 1.0.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 +418 -0
- package/bin/dev.cmd +3 -0
- package/bin/dev.js +5 -0
- package/bin/run.cmd +3 -0
- package/bin/run.js +5 -0
- package/dist/commands/chat.d.ts +7 -0
- package/dist/commands/chat.js +30 -0
- package/dist/commands/login.d.ts +5 -0
- package/dist/commands/login.js +18 -0
- package/dist/commands/logout.d.ts +5 -0
- package/dist/commands/logout.js +12 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/services/agent-tools.d.ts +36 -0
- package/dist/services/agent-tools.js +764 -0
- package/dist/services/agent.d.ts +21 -0
- package/dist/services/agent.js +648 -0
- package/dist/services/ai.d.ts +60 -0
- package/dist/services/ai.js +331 -0
- package/dist/services/auth.d.ts +3 -0
- package/dist/services/auth.js +183 -0
- package/dist/services/changeLogger.d.ts +23 -0
- package/dist/services/changeLogger.js +57 -0
- package/dist/services/contextAgent.d.ts +20 -0
- package/dist/services/contextAgent.js +440 -0
- package/dist/services/proxyClient.d.ts +21 -0
- package/dist/services/proxyClient.js +119 -0
- package/dist/services/verificationService.d.ts +10 -0
- package/dist/services/verificationService.js +148 -0
- package/dist/utils/atomicWrite.d.ts +6 -0
- package/dist/utils/atomicWrite.js +29 -0
- package/dist/utils/config.d.ts +17 -0
- package/dist/utils/config.js +17 -0
- package/dist/utils/contextPrompts.d.ts +3 -0
- package/dist/utils/contextPrompts.js +34 -0
- package/dist/utils/dependencyTracer.d.ts +48 -0
- package/dist/utils/dependencyTracer.js +647 -0
- package/dist/utils/excludedExtensions.d.ts +8 -0
- package/dist/utils/excludedExtensions.js +125 -0
- package/dist/utils/fuzzyMatch.d.ts +21 -0
- package/dist/utils/fuzzyMatch.js +121 -0
- package/dist/utils/logger.d.ts +8 -0
- package/dist/utils/logger.js +17 -0
- package/dist/utils/pathSecurity.d.ts +10 -0
- package/dist/utils/pathSecurity.js +26 -0
- package/dist/utils/symbolExtractor.d.ts +6 -0
- package/dist/utils/symbolExtractor.js +249 -0
- package/dist/utils/syntaxValidator.d.ts +5 -0
- package/dist/utils/syntaxValidator.js +81 -0
- package/dist/utils/systemPrompts.d.ts +5 -0
- package/dist/utils/systemPrompts.js +119 -0
- package/oclif.manifest.json +69 -0
- package/package.json +81 -0
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
import { exec } from 'node:child_process';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { SchemaType } from '@google/generative-ai';
|
|
6
|
+
import { resolveAndValidatePath } from '../utils/pathSecurity.js';
|
|
7
|
+
import { changeLogger } from './changeLogger.js';
|
|
8
|
+
import { findBestMatch, applyMatch } from '../utils/fuzzyMatch.js';
|
|
9
|
+
import { validateSyntax } from '../utils/syntaxValidator.js';
|
|
10
|
+
import { sanitizeForCDATA } from '../utils/contextPrompts.js';
|
|
11
|
+
import { findDependencies, formatDependencyResult } from '../utils/dependencyTracer.js';
|
|
12
|
+
import { atomicWriteFile } from '../utils/atomicWrite.js';
|
|
13
|
+
import { EXCLUDED_EXTENSIONS } from '../utils/excludedExtensions.js';
|
|
14
|
+
import { extractSymbols } from '../utils/symbolExtractor.js';
|
|
15
|
+
const execAsync = promisify(exec);
|
|
16
|
+
// ─── Tool Declarations for Gemini Function Calling ───────────────────
|
|
17
|
+
/**
|
|
18
|
+
* FunctionDeclaration-compatible schema objects that describe
|
|
19
|
+
* every tool the agent can invoke. Passed to the model at init.
|
|
20
|
+
*/
|
|
21
|
+
export const toolDeclarations = [
|
|
22
|
+
{
|
|
23
|
+
name: 'read_file',
|
|
24
|
+
description: 'Read the contents of a file at the given path relative to the workspace root. Returns the file text. Use startLine and endLine to read specific chunks of massive files to avoid context limits.',
|
|
25
|
+
parameters: {
|
|
26
|
+
type: SchemaType.OBJECT,
|
|
27
|
+
properties: {
|
|
28
|
+
filePath: {
|
|
29
|
+
type: SchemaType.STRING,
|
|
30
|
+
description: 'Relative path to the file from the workspace root.',
|
|
31
|
+
},
|
|
32
|
+
startLine: {
|
|
33
|
+
type: SchemaType.NUMBER,
|
|
34
|
+
description: 'Optional. 1-indexed starting line number to read from.',
|
|
35
|
+
},
|
|
36
|
+
endLine: {
|
|
37
|
+
type: SchemaType.NUMBER,
|
|
38
|
+
description: 'Optional. 1-indexed ending line number to read up to (inclusive).',
|
|
39
|
+
},
|
|
40
|
+
targetElements: {
|
|
41
|
+
type: SchemaType.ARRAY,
|
|
42
|
+
items: { type: SchemaType.STRING },
|
|
43
|
+
description: 'Optional. An array of specific function names, class names, or variables to extract. The tool will intelligently locate and return only the blocks defining these elements.',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
required: ['filePath'],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'write_file',
|
|
51
|
+
description: 'Create a new file or completely overwrite an existing file with the provided content. Use modify_file for targeted edits instead.',
|
|
52
|
+
parameters: {
|
|
53
|
+
type: SchemaType.OBJECT,
|
|
54
|
+
properties: {
|
|
55
|
+
filePath: {
|
|
56
|
+
type: SchemaType.STRING,
|
|
57
|
+
description: 'Relative path to the file from the workspace root.',
|
|
58
|
+
},
|
|
59
|
+
content: {
|
|
60
|
+
type: SchemaType.STRING,
|
|
61
|
+
description: 'The full content to write to the file.',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
required: ['filePath', 'content'],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'delete_file',
|
|
69
|
+
description: 'Deletes a file from the filesystem. Use this instead of running an rm command.',
|
|
70
|
+
parameters: {
|
|
71
|
+
type: SchemaType.OBJECT,
|
|
72
|
+
properties: {
|
|
73
|
+
filePath: {
|
|
74
|
+
type: SchemaType.STRING,
|
|
75
|
+
description: 'Relative path to the file to delete.',
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
required: ['filePath'],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'rename_file',
|
|
83
|
+
description: 'Moves or renames a file. Use this instead of running an mv command.',
|
|
84
|
+
parameters: {
|
|
85
|
+
type: SchemaType.OBJECT,
|
|
86
|
+
properties: {
|
|
87
|
+
sourcePath: {
|
|
88
|
+
type: SchemaType.STRING,
|
|
89
|
+
description: 'Relative path to the file to move/rename.',
|
|
90
|
+
},
|
|
91
|
+
targetPath: {
|
|
92
|
+
type: SchemaType.STRING,
|
|
93
|
+
description: 'New relative path for the file.',
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
required: ['sourcePath', 'targetPath'],
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'modify_file',
|
|
101
|
+
description: 'Perform one or multiple targeted search-and-replace edits in a single file. The search strings must match exactly (including whitespace). This is preferred over write_file for editing existing files because it preserves the rest of the file. Use this to batch multiple changes into a single operation.',
|
|
102
|
+
parameters: {
|
|
103
|
+
type: SchemaType.OBJECT,
|
|
104
|
+
properties: {
|
|
105
|
+
filePath: {
|
|
106
|
+
type: SchemaType.STRING,
|
|
107
|
+
description: 'Relative path to the file from the workspace root.',
|
|
108
|
+
},
|
|
109
|
+
edits: {
|
|
110
|
+
type: SchemaType.ARRAY,
|
|
111
|
+
description: 'An array of edit objects to apply to the file.',
|
|
112
|
+
items: {
|
|
113
|
+
type: SchemaType.OBJECT,
|
|
114
|
+
properties: {
|
|
115
|
+
searchContent: {
|
|
116
|
+
type: SchemaType.STRING,
|
|
117
|
+
description: 'The exact text to find in the file.',
|
|
118
|
+
},
|
|
119
|
+
replaceContent: {
|
|
120
|
+
type: SchemaType.STRING,
|
|
121
|
+
description: 'The replacement text.',
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
required: ['searchContent', 'replaceContent'],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
required: ['filePath', 'edits'],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: 'list_directory',
|
|
133
|
+
description: 'List files and subdirectories in a directory relative to the workspace root. Returns a recursive tree structure.',
|
|
134
|
+
parameters: {
|
|
135
|
+
type: SchemaType.OBJECT,
|
|
136
|
+
properties: {
|
|
137
|
+
dirPath: {
|
|
138
|
+
type: SchemaType.STRING,
|
|
139
|
+
description: 'Relative path to the directory from the workspace root. Use "." for the root.',
|
|
140
|
+
},
|
|
141
|
+
maxDepth: {
|
|
142
|
+
type: SchemaType.NUMBER,
|
|
143
|
+
description: 'Maximum depth to recurse. Defaults to 3. Use 1 for a shallow listing.',
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
required: ['dirPath'],
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: 'run_command',
|
|
151
|
+
description: 'Execute a shell command in the workspace root directory and return stdout/stderr. The user will be prompted for approval before execution unless they have chosen to auto-approve.',
|
|
152
|
+
parameters: {
|
|
153
|
+
type: SchemaType.OBJECT,
|
|
154
|
+
properties: {
|
|
155
|
+
command: {
|
|
156
|
+
type: SchemaType.STRING,
|
|
157
|
+
description: 'The shell command to execute.',
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
required: ['command'],
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: 'grep_search',
|
|
165
|
+
description: 'Search for a text pattern across files in the workspace. Returns matching file paths with line numbers and content snippets. Uses Extended Regular Expressions (grep -E). IMPORTANT: grep searches line-by-line. Do NOT search for long lists of Tailwind classes or multi-line strings, as they will fail if line-wrapped. Search for short, unique substrings.',
|
|
166
|
+
parameters: {
|
|
167
|
+
type: SchemaType.OBJECT,
|
|
168
|
+
properties: {
|
|
169
|
+
pattern: {
|
|
170
|
+
type: SchemaType.STRING,
|
|
171
|
+
description: 'The text or extended regex pattern to search for.',
|
|
172
|
+
},
|
|
173
|
+
fileGlob: {
|
|
174
|
+
type: SchemaType.STRING,
|
|
175
|
+
description: 'Optional glob to restrict file types, e.g. "*.ts" or "*.py". Defaults to all files.',
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
required: ['pattern'],
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
name: 'find_dependencies',
|
|
183
|
+
description: 'Find all files that import or are imported by the given file. Returns both forward dependencies (what the file imports) and reverse dependencies (files that import it). Use this to understand the blast radius of modifying, deleting, or renaming a file.',
|
|
184
|
+
parameters: {
|
|
185
|
+
type: SchemaType.OBJECT,
|
|
186
|
+
properties: {
|
|
187
|
+
filePath: {
|
|
188
|
+
type: SchemaType.STRING,
|
|
189
|
+
description: 'Relative path to the file to trace dependencies for.',
|
|
190
|
+
},
|
|
191
|
+
direction: {
|
|
192
|
+
type: SchemaType.STRING,
|
|
193
|
+
description: 'Direction to trace: "both" (default), "forward" (what this file imports), or "reverse" (what imports this file).',
|
|
194
|
+
},
|
|
195
|
+
maxDepth: {
|
|
196
|
+
type: SchemaType.NUMBER,
|
|
197
|
+
description: 'Maximum depth to traverse the dependency tree. Defaults to 3.',
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
required: ['filePath'],
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: 'find_recent_changes',
|
|
205
|
+
description: 'Find files that have been modified recently within the workspace. Useful for understanding what the user was just working on if they ask vague questions like "why is it failing?". Automatically ignores .git, node_modules, etc.',
|
|
206
|
+
parameters: {
|
|
207
|
+
type: SchemaType.OBJECT,
|
|
208
|
+
properties: {
|
|
209
|
+
dirPath: {
|
|
210
|
+
type: SchemaType.STRING,
|
|
211
|
+
description: 'Relative path to directory to search from. Defaults to workspace root ".".',
|
|
212
|
+
},
|
|
213
|
+
minutes: {
|
|
214
|
+
type: SchemaType.NUMBER,
|
|
215
|
+
description: 'Look for files modified within this many minutes. Defaults to 60.',
|
|
216
|
+
},
|
|
217
|
+
maxDepth: {
|
|
218
|
+
type: SchemaType.NUMBER,
|
|
219
|
+
description: 'Maximum depth to traverse. Defaults to 5.',
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
name: 'create_and_run_test',
|
|
226
|
+
description: 'Write a snippet of code (e.g. a unit test or debug script) to a temporary file, execute it using the specified runtime, and return the exact standard output and standard error logs. Use this to actively test and debug code during execution and verification loops.',
|
|
227
|
+
parameters: {
|
|
228
|
+
type: SchemaType.OBJECT,
|
|
229
|
+
properties: {
|
|
230
|
+
language: {
|
|
231
|
+
type: SchemaType.STRING,
|
|
232
|
+
description: 'The runtime to use: "node", "ts-node", "python", "bash", "go", or "rust".',
|
|
233
|
+
},
|
|
234
|
+
code: {
|
|
235
|
+
type: SchemaType.STRING,
|
|
236
|
+
description: 'The exact test code or script to execute.',
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
required: ['language', 'code'],
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
];
|
|
243
|
+
let currentApprovalMode = 'ask';
|
|
244
|
+
export function getApprovalMode() {
|
|
245
|
+
return currentApprovalMode;
|
|
246
|
+
}
|
|
247
|
+
export function setApprovalMode(mode) {
|
|
248
|
+
currentApprovalMode = mode;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* If set to 'skip-once', reverts to 'ask' after a single command is run.
|
|
252
|
+
*/
|
|
253
|
+
export function consumeSkipOnce() {
|
|
254
|
+
if (currentApprovalMode === 'skip-once') {
|
|
255
|
+
currentApprovalMode = 'ask';
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// ─── Ignored Paths ───────────────────────────────────────────────────
|
|
259
|
+
const DEFAULT_IGNORED_DIRS = new Set([
|
|
260
|
+
'node_modules',
|
|
261
|
+
'.git',
|
|
262
|
+
'dist',
|
|
263
|
+
'.next',
|
|
264
|
+
'.nuxt',
|
|
265
|
+
'__pycache__',
|
|
266
|
+
'.venv',
|
|
267
|
+
'venv',
|
|
268
|
+
'.cache',
|
|
269
|
+
'coverage',
|
|
270
|
+
'.turbo',
|
|
271
|
+
]);
|
|
272
|
+
const DEFAULT_IGNORED_FILES = new Set(['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', '.DS_Store']);
|
|
273
|
+
/**
|
|
274
|
+
* Parses .gitignore to supplement the default ignore lists.
|
|
275
|
+
*/
|
|
276
|
+
async function getIgnoredPaths(workspaceRoot) {
|
|
277
|
+
const ignoredDirs = new Set(DEFAULT_IGNORED_DIRS);
|
|
278
|
+
const ignoredFiles = new Set(DEFAULT_IGNORED_FILES);
|
|
279
|
+
try {
|
|
280
|
+
const gitignoreContent = await fs.readFile(path.join(workspaceRoot, '.gitignore'), 'utf-8');
|
|
281
|
+
const lines = gitignoreContent
|
|
282
|
+
.split('\n')
|
|
283
|
+
.map((l) => l.trim())
|
|
284
|
+
.filter((l) => l && !l.startsWith('#'));
|
|
285
|
+
for (const line of lines) {
|
|
286
|
+
const cleanLine = line.replace(/^\//, '').replace(/\/$/, '');
|
|
287
|
+
if (!cleanLine.includes('*')) {
|
|
288
|
+
ignoredDirs.add(cleanLine);
|
|
289
|
+
ignoredFiles.add(cleanLine);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
// Ignore missing .gitignore
|
|
295
|
+
}
|
|
296
|
+
return { ignoredDirs, ignoredFiles };
|
|
297
|
+
}
|
|
298
|
+
// ─── Tool Implementations ────────────────────────────────────────────
|
|
299
|
+
export async function readFile(workspaceRoot, filePath, startLine, endLine, targetElements) {
|
|
300
|
+
try {
|
|
301
|
+
const absPath = resolveAndValidatePath(workspaceRoot, filePath);
|
|
302
|
+
let content = await fs.readFile(absPath, 'utf-8');
|
|
303
|
+
if (targetElements && targetElements.length > 0) {
|
|
304
|
+
content = extractSymbols(content, filePath, targetElements);
|
|
305
|
+
}
|
|
306
|
+
else if (startLine !== undefined || endLine !== undefined) {
|
|
307
|
+
const lines = content.split('\n');
|
|
308
|
+
const start = startLine !== undefined ? Math.max(1, startLine) : 1;
|
|
309
|
+
const end = endLine !== undefined ? Math.min(lines.length, endLine) : lines.length;
|
|
310
|
+
if (start > end) {
|
|
311
|
+
return { output: '', error: `Invalid line range: startLine (${startLine}) > endLine (${endLine})` };
|
|
312
|
+
}
|
|
313
|
+
content = lines.slice(start - 1, end).join('\n');
|
|
314
|
+
}
|
|
315
|
+
const attrLines = startLine || endLine ? ` lines="${startLine || 1}-${endLine || 'end'}"` : '';
|
|
316
|
+
const attrTargets = targetElements && targetElements.length > 0 ? ` elements="${targetElements.join(',')}"` : '';
|
|
317
|
+
const wrappedContent = `<workspace_file path="${filePath}"${attrLines}${attrTargets}>\n<content_data><![CDATA[\n${sanitizeForCDATA(content)}\n]]></content_data>\n</workspace_file>`;
|
|
318
|
+
return { output: wrappedContent };
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
322
|
+
return { output: '', error: `Failed to read file "${filePath}": ${message}` };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
export async function writeFile(workspaceRoot, filePath, content) {
|
|
326
|
+
try {
|
|
327
|
+
const absPath = resolveAndValidatePath(workspaceRoot, filePath);
|
|
328
|
+
let existingContent = null;
|
|
329
|
+
try {
|
|
330
|
+
existingContent = await fs.readFile(absPath, 'utf-8');
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
// File doesn't exist
|
|
334
|
+
}
|
|
335
|
+
const validation = validateSyntax(content, filePath);
|
|
336
|
+
if (!validation.valid) {
|
|
337
|
+
return {
|
|
338
|
+
output: '',
|
|
339
|
+
error: `Syntax validation failed for "${filePath}":\\n- ${validation.errors.join('\\n- ')}\\n\\nPlease fix the syntax and try again.`,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
|
343
|
+
changeLogger.logChange(filePath, existingContent, existingContent !== null ? 'modify' : 'create');
|
|
344
|
+
await atomicWriteFile(absPath, content, 'utf-8');
|
|
345
|
+
return { output: `Successfully wrote to "${filePath}".` };
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
349
|
+
return {
|
|
350
|
+
output: '',
|
|
351
|
+
error: `Failed to write file "${filePath}": ${message}`,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
export async function deleteFile(workspaceRoot, filePath) {
|
|
356
|
+
try {
|
|
357
|
+
const absPath = resolveAndValidatePath(workspaceRoot, filePath);
|
|
358
|
+
let existingContent = null;
|
|
359
|
+
try {
|
|
360
|
+
existingContent = await fs.readFile(absPath, 'utf-8');
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return { output: '', error: `Failed to delete "${filePath}": File does not exist.` };
|
|
364
|
+
}
|
|
365
|
+
changeLogger.logChange(filePath, existingContent, 'delete');
|
|
366
|
+
await fs.rm(absPath, { force: true });
|
|
367
|
+
return { output: `Successfully deleted "${filePath}".` };
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
371
|
+
return { output: '', error: `Failed to delete file "${filePath}": ${message}` };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
export async function renameFile(workspaceRoot, sourcePath, targetPath) {
|
|
375
|
+
try {
|
|
376
|
+
const absSource = resolveAndValidatePath(workspaceRoot, sourcePath);
|
|
377
|
+
const absTarget = resolveAndValidatePath(workspaceRoot, targetPath);
|
|
378
|
+
let existingContent = null;
|
|
379
|
+
try {
|
|
380
|
+
existingContent = await fs.readFile(absSource, 'utf-8');
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return { output: '', error: `Failed to move/rename "${sourcePath}": File does not exist.` };
|
|
384
|
+
}
|
|
385
|
+
// Log the source deletion
|
|
386
|
+
changeLogger.logChange(sourcePath, existingContent, 'delete');
|
|
387
|
+
// Log the target creation
|
|
388
|
+
changeLogger.logChange(targetPath, null, 'create');
|
|
389
|
+
await fs.mkdir(path.dirname(absTarget), { recursive: true });
|
|
390
|
+
await fs.rename(absSource, absTarget);
|
|
391
|
+
return { output: `Successfully moved/renamed "${sourcePath}" to "${targetPath}".` };
|
|
392
|
+
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
395
|
+
return { output: '', error: `Failed to move/rename file: ${message}` };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
export async function modifyFile(workspaceRoot, filePath, edits) {
|
|
399
|
+
const MAX_MODIFY_RETRIES = 2;
|
|
400
|
+
const absPath = resolveAndValidatePath(workspaceRoot, filePath);
|
|
401
|
+
for (let attempt = 1; attempt <= MAX_MODIFY_RETRIES; attempt++) {
|
|
402
|
+
try {
|
|
403
|
+
const existing = await fs.readFile(absPath, 'utf-8');
|
|
404
|
+
let modified = existing;
|
|
405
|
+
const strategies = [];
|
|
406
|
+
for (let i = 0; i < edits.length; i++) {
|
|
407
|
+
const edit = edits[i];
|
|
408
|
+
const match = findBestMatch(modified, edit.searchContent);
|
|
409
|
+
if (!match) {
|
|
410
|
+
if (attempt < MAX_MODIFY_RETRIES) {
|
|
411
|
+
// Break out of inner loop, triggering a retry in outer loop
|
|
412
|
+
modified = existing; // reset
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
// Provide a preview of the file to help the AI self-correct
|
|
416
|
+
const preview = modified.split('\\n').slice(0, 20).join('\\n');
|
|
417
|
+
return {
|
|
418
|
+
output: '',
|
|
419
|
+
error: `Edit #${i + 1} failed: Search content not found in "${filePath}".\\nFile start preview:\\n${preview}\\n...`,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
modified = applyMatch(modified, match, edit.replaceContent);
|
|
423
|
+
strategies.push(`Edit #${i + 1}: ${match.strategy}`);
|
|
424
|
+
}
|
|
425
|
+
// If we broke out early for a retry, the modified string will equal the existing string
|
|
426
|
+
// (or we haven't completed all edits), so we continue to the next attempt.
|
|
427
|
+
if (attempt < MAX_MODIFY_RETRIES && modified === existing && edits.length > 0) {
|
|
428
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
const validation = validateSyntax(modified, filePath);
|
|
432
|
+
if (!validation.valid) {
|
|
433
|
+
return {
|
|
434
|
+
output: '',
|
|
435
|
+
error: `Syntax validation failed for "${filePath}" after modification:\\n- ${validation.errors.join('\\n- ')}\\n\\nPlease review your replacement content.`,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
changeLogger.logChange(filePath, existing, 'modify');
|
|
439
|
+
await atomicWriteFile(absPath, modified, 'utf-8');
|
|
440
|
+
return { output: `Successfully applied ${edits.length} edit(s) to "${filePath}".\\nStrategies used:\\n${strategies.join('\\n')}` };
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
if (attempt === MAX_MODIFY_RETRIES) {
|
|
444
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
445
|
+
return {
|
|
446
|
+
output: '',
|
|
447
|
+
error: `Failed to modify file "${filePath}": ${message}`,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return { output: '', error: 'Modify failed.' };
|
|
454
|
+
}
|
|
455
|
+
export async function listDirectory(workspaceRoot, dirPath, maxDepth = 3) {
|
|
456
|
+
try {
|
|
457
|
+
const absPath = resolveAndValidatePath(workspaceRoot, dirPath);
|
|
458
|
+
const lines = [];
|
|
459
|
+
const { ignoredDirs, ignoredFiles } = await getIgnoredPaths(workspaceRoot);
|
|
460
|
+
async function walk(currentPath, prefix, depth) {
|
|
461
|
+
if (depth > maxDepth)
|
|
462
|
+
return;
|
|
463
|
+
const entries = await fs.readdir(currentPath, { withFileTypes: true });
|
|
464
|
+
// Sort directories first, then files, both alphabetically
|
|
465
|
+
const sorted = entries
|
|
466
|
+
.filter((entry) => !entry.name.startsWith('.'))
|
|
467
|
+
.sort((a, b) => {
|
|
468
|
+
if (a.isDirectory() && !b.isDirectory())
|
|
469
|
+
return -1;
|
|
470
|
+
if (!a.isDirectory() && b.isDirectory())
|
|
471
|
+
return 1;
|
|
472
|
+
return a.name.localeCompare(b.name);
|
|
473
|
+
});
|
|
474
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
475
|
+
const entry = sorted[i];
|
|
476
|
+
const isLast = i === sorted.length - 1;
|
|
477
|
+
const connector = isLast ? '└── ' : '├── ';
|
|
478
|
+
const childPrefix = isLast ? ' ' : '│ ';
|
|
479
|
+
if (entry.isDirectory()) {
|
|
480
|
+
if (ignoredDirs.has(entry.name)) {
|
|
481
|
+
lines.push(`${prefix}${connector}${entry.name}/ (ignored)`);
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
lines.push(`${prefix}${connector}${entry.name}/`);
|
|
485
|
+
await walk(path.join(currentPath, entry.name), `${prefix}${childPrefix}`, depth + 1);
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
if (ignoredFiles.has(entry.name))
|
|
489
|
+
continue;
|
|
490
|
+
if (EXCLUDED_EXTENSIONS.some((ext) => entry.name.endsWith(ext.replace('*', ''))))
|
|
491
|
+
continue;
|
|
492
|
+
lines.push(`${prefix}${connector}${entry.name}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
await walk(absPath, '', 0);
|
|
497
|
+
return { output: lines.join('\n') || '(empty directory)' };
|
|
498
|
+
}
|
|
499
|
+
catch (err) {
|
|
500
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
501
|
+
return {
|
|
502
|
+
output: '',
|
|
503
|
+
error: `Failed to list directory "${dirPath}": ${message}`,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
export async function runCommand(workspaceRoot, command) {
|
|
508
|
+
try {
|
|
509
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
510
|
+
cwd: workspaceRoot,
|
|
511
|
+
timeout: 60_000, // 60 second timeout
|
|
512
|
+
maxBuffer: 1024 * 1024 * 2, // 2 MB buffer
|
|
513
|
+
});
|
|
514
|
+
let output = [stdout, stderr].filter(Boolean).join('\n');
|
|
515
|
+
// Truncate command output to prevent memory blowout from massive build logs
|
|
516
|
+
const MAX_CMD_OUTPUT = 50_000;
|
|
517
|
+
if (output.length > MAX_CMD_OUTPUT) {
|
|
518
|
+
output = output.substring(0, MAX_CMD_OUTPUT) + '\n... (output truncated — exceeded 50KB limit)';
|
|
519
|
+
}
|
|
520
|
+
return { output: output || '(command produced no output)' };
|
|
521
|
+
}
|
|
522
|
+
catch (err) {
|
|
523
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
524
|
+
// Also truncate error output
|
|
525
|
+
const MAX_ERR_OUTPUT = 30_000;
|
|
526
|
+
const truncatedMsg = message.length > MAX_ERR_OUTPUT
|
|
527
|
+
? message.substring(0, MAX_ERR_OUTPUT) + '\n... (error output truncated)'
|
|
528
|
+
: message;
|
|
529
|
+
return { output: '', error: `Command failed: ${truncatedMsg}` };
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
export async function grepSearch(workspaceRoot, pattern, fileGlob) {
|
|
533
|
+
try {
|
|
534
|
+
const { ignoredDirs } = await getIgnoredPaths(workspaceRoot);
|
|
535
|
+
const excludeDirArgs = Array.from(ignoredDirs)
|
|
536
|
+
.map((dir) => `--exclude-dir="${dir}"`)
|
|
537
|
+
.concat('--exclude-dir=".*"') // Exclude all hidden directories
|
|
538
|
+
.join(' ');
|
|
539
|
+
const excludeFileArgs = EXCLUDED_EXTENSIONS.map((ext) => `--exclude="${ext}"`)
|
|
540
|
+
.concat('--exclude=".*"') // Exclude all hidden files
|
|
541
|
+
.join(' ');
|
|
542
|
+
// Build a grep command that works cross-platform via node
|
|
543
|
+
// We use grep -rnIEi for recursive, line numbers, skip binary, extended regex, case-insensitive
|
|
544
|
+
const globArg = fileGlob ? ` --include="${fileGlob}"` : '';
|
|
545
|
+
const cmd = `grep -rnIEi --color=never ${excludeDirArgs} ${excludeFileArgs}${globArg} "${pattern.replace(/"/g, '\\"')}" .`;
|
|
546
|
+
const { stdout } = await execAsync(cmd, {
|
|
547
|
+
cwd: workspaceRoot,
|
|
548
|
+
timeout: 15_000,
|
|
549
|
+
maxBuffer: 1024 * 1024 * 2,
|
|
550
|
+
});
|
|
551
|
+
// Limit output to 50 results
|
|
552
|
+
const lines = stdout.trim().split('\n');
|
|
553
|
+
const limited = lines.slice(0, 50);
|
|
554
|
+
const resultText = limited.join('\n') + (lines.length > 50 ? `\n\n... (${lines.length - 50} more results truncated)` : '');
|
|
555
|
+
if (!resultText)
|
|
556
|
+
return { output: `No matches found for "${pattern}".` };
|
|
557
|
+
const wrappedResult = `<workspace_file path="grep_search_results">\n<content_data><![CDATA[\n${sanitizeForCDATA(resultText)}\n]]></content_data>\n</workspace_file>`;
|
|
558
|
+
return { output: wrappedResult };
|
|
559
|
+
}
|
|
560
|
+
catch (err) {
|
|
561
|
+
// grep returns exit code 1 when no matches are found.
|
|
562
|
+
// Node's execException returns the code as a number.
|
|
563
|
+
if (err instanceof Error && 'code' in err && err.code === 1) {
|
|
564
|
+
return { output: `No matches found for "${pattern}".` };
|
|
565
|
+
}
|
|
566
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
567
|
+
// Also handle stringified exit codes just in case
|
|
568
|
+
if (message.includes('Command failed') &&
|
|
569
|
+
(message.includes('exit code 1') || message.includes('exited with code 1'))) {
|
|
570
|
+
return { output: `No matches found for "${pattern}".` };
|
|
571
|
+
}
|
|
572
|
+
return { output: '', error: `Grep failed: ${message}` };
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
export async function traceDependencies(workspaceRoot, filePath, direction, maxDepth) {
|
|
576
|
+
try {
|
|
577
|
+
const dir = (direction === 'forward' || direction === 'reverse') ? direction : 'both';
|
|
578
|
+
const depth = maxDepth && maxDepth > 0 ? Math.min(maxDepth, 5) : 3;
|
|
579
|
+
const result = await findDependencies(workspaceRoot, filePath, dir, depth);
|
|
580
|
+
const formatted = formatDependencyResult(result);
|
|
581
|
+
const wrappedResult = `<workspace_file path="dependency_trace_results">\n<content_data><![CDATA[\n${sanitizeForCDATA(formatted)}\n]]></content_data>\n</workspace_file>`;
|
|
582
|
+
return { output: wrappedResult };
|
|
583
|
+
}
|
|
584
|
+
catch (err) {
|
|
585
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
586
|
+
return { output: '', error: `Dependency tracing failed for "${filePath}": ${message}` };
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
export async function findRecentChanges(workspaceRoot, dirPath = '.', minutes = 60, maxDepth = 5) {
|
|
590
|
+
try {
|
|
591
|
+
const absPath = resolveAndValidatePath(workspaceRoot, dirPath);
|
|
592
|
+
const { ignoredDirs, ignoredFiles } = await getIgnoredPaths(workspaceRoot);
|
|
593
|
+
const thresholdMs = Date.now() - (minutes * 60 * 1000);
|
|
594
|
+
const recentFiles = [];
|
|
595
|
+
async function walk(currentPath, depth) {
|
|
596
|
+
if (depth > maxDepth)
|
|
597
|
+
return;
|
|
598
|
+
let entries;
|
|
599
|
+
try {
|
|
600
|
+
entries = await fs.readdir(currentPath, { withFileTypes: true });
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
return; // Skip unreadable directories
|
|
604
|
+
}
|
|
605
|
+
for (const entry of entries) {
|
|
606
|
+
if (entry.name.startsWith('.'))
|
|
607
|
+
continue;
|
|
608
|
+
if (entry.isDirectory()) {
|
|
609
|
+
if (ignoredDirs.has(entry.name))
|
|
610
|
+
continue;
|
|
611
|
+
await walk(path.join(currentPath, entry.name), depth + 1);
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
if (ignoredFiles.has(entry.name))
|
|
615
|
+
continue;
|
|
616
|
+
if (EXCLUDED_EXTENSIONS.some((ext) => entry.name.endsWith(ext.replace('*', ''))))
|
|
617
|
+
continue;
|
|
618
|
+
try {
|
|
619
|
+
const filePath = path.join(currentPath, entry.name);
|
|
620
|
+
const stats = await fs.stat(filePath);
|
|
621
|
+
if (stats.mtimeMs >= thresholdMs) {
|
|
622
|
+
const relPath = path.relative(workspaceRoot, filePath);
|
|
623
|
+
recentFiles.push({ path: relPath, mtime: stats.mtimeMs });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
// Ignore stat errors
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
await walk(absPath, 0);
|
|
633
|
+
if (recentFiles.length === 0) {
|
|
634
|
+
return { output: `No files were modified in the last ${minutes} minutes.` };
|
|
635
|
+
}
|
|
636
|
+
// Sort by most recently modified first
|
|
637
|
+
recentFiles.sort((a, b) => b.mtime - a.mtime);
|
|
638
|
+
// Format output
|
|
639
|
+
const lines = recentFiles.map(f => {
|
|
640
|
+
const minsAgo = Math.max(0, Math.round((Date.now() - f.mtime) / 60000));
|
|
641
|
+
return `- ${f.path} (${minsAgo} minutes ago)`;
|
|
642
|
+
});
|
|
643
|
+
const resultText = `Files modified in the last ${minutes} minutes:\n${lines.join('\n')}`;
|
|
644
|
+
const wrappedResult = `<workspace_file path="recent_changes">\n<content_data><![CDATA[\n${sanitizeForCDATA(resultText)}\n]]></content_data>\n</workspace_file>`;
|
|
645
|
+
return { output: wrappedResult };
|
|
646
|
+
}
|
|
647
|
+
catch (err) {
|
|
648
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
649
|
+
return {
|
|
650
|
+
output: '',
|
|
651
|
+
error: `Failed to find recent changes: ${message}`,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// ─── Tool Dispatcher ─────────────────────────────────────────────────
|
|
656
|
+
export async function createAndRunTest(workspaceRoot, language, code) {
|
|
657
|
+
const extMap = {
|
|
658
|
+
node: '.js',
|
|
659
|
+
'ts-node': '.ts',
|
|
660
|
+
python: '.py',
|
|
661
|
+
bash: '.sh',
|
|
662
|
+
go: '.go',
|
|
663
|
+
rust: '.rs',
|
|
664
|
+
};
|
|
665
|
+
const ext = extMap[language.toLowerCase()] || '.txt';
|
|
666
|
+
const tmpFileName = `.minovative-scratch${ext}`;
|
|
667
|
+
const absPath = path.join(workspaceRoot, tmpFileName);
|
|
668
|
+
try {
|
|
669
|
+
await fs.writeFile(absPath, code, 'utf-8');
|
|
670
|
+
let cmd = '';
|
|
671
|
+
switch (language.toLowerCase()) {
|
|
672
|
+
case 'node':
|
|
673
|
+
cmd = `node ${tmpFileName}`;
|
|
674
|
+
break;
|
|
675
|
+
case 'ts-node':
|
|
676
|
+
cmd = `npx ts-node ${tmpFileName}`;
|
|
677
|
+
break;
|
|
678
|
+
case 'python':
|
|
679
|
+
cmd = `python3 ${tmpFileName}`;
|
|
680
|
+
break;
|
|
681
|
+
case 'bash':
|
|
682
|
+
cmd = `bash ${tmpFileName}`;
|
|
683
|
+
break;
|
|
684
|
+
case 'go':
|
|
685
|
+
cmd = `go run ${tmpFileName}`;
|
|
686
|
+
break;
|
|
687
|
+
case 'rust':
|
|
688
|
+
cmd = `rustc ${tmpFileName} && ./${tmpFileName.replace('.rs', '')}`;
|
|
689
|
+
break;
|
|
690
|
+
default:
|
|
691
|
+
return { output: '', error: `Unsupported language runtime: ${language}` };
|
|
692
|
+
}
|
|
693
|
+
try {
|
|
694
|
+
const { stdout, stderr } = await execAsync(cmd, { cwd: workspaceRoot, timeout: 15_000 });
|
|
695
|
+
const out = stdout.trim();
|
|
696
|
+
const errOut = stderr.trim();
|
|
697
|
+
let finalOutput = '';
|
|
698
|
+
if (out)
|
|
699
|
+
finalOutput += `[STDOUT]\n${out}\n`;
|
|
700
|
+
if (errOut)
|
|
701
|
+
finalOutput += `[STDERR]\n${errOut}\n`;
|
|
702
|
+
if (!finalOutput)
|
|
703
|
+
finalOutput = 'Script executed successfully with no output.';
|
|
704
|
+
return { output: `<test_results>\n${sanitizeForCDATA(finalOutput)}\n</test_results>` };
|
|
705
|
+
}
|
|
706
|
+
catch (err) {
|
|
707
|
+
const out = (err.stdout || '').trim();
|
|
708
|
+
const errOut = (err.stderr || '').trim();
|
|
709
|
+
let finalOutput = `Script failed with exit code ${err.code || 1}.\n`;
|
|
710
|
+
if (out)
|
|
711
|
+
finalOutput += `[STDOUT]\n${out}\n`;
|
|
712
|
+
if (errOut)
|
|
713
|
+
finalOutput += `[STDERR]\n${errOut}\n`;
|
|
714
|
+
return { output: `<test_results>\n${sanitizeForCDATA(finalOutput)}\n</test_results>` };
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
catch (err) {
|
|
718
|
+
return { output: '', error: `Failed to create or run test script: ${err instanceof Error ? err.message : String(err)}` };
|
|
719
|
+
}
|
|
720
|
+
finally {
|
|
721
|
+
try {
|
|
722
|
+
await fs.rm(absPath, { force: true });
|
|
723
|
+
if (language.toLowerCase() === 'rust') {
|
|
724
|
+
const binPath = absPath.replace('.rs', '');
|
|
725
|
+
await fs.rm(binPath, { force: true });
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
catch {
|
|
729
|
+
// Ignore cleanup errors
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Dispatches a function call from the model to the appropriate local tool.
|
|
735
|
+
* Returns the tool result as a string to feed back to the model.
|
|
736
|
+
*/
|
|
737
|
+
export async function executeTool(workspaceRoot, toolName, args) {
|
|
738
|
+
switch (toolName) {
|
|
739
|
+
case 'read_file':
|
|
740
|
+
return readFile(workspaceRoot, args.filePath, args.startLine, args.endLine, args.targetElements);
|
|
741
|
+
case 'write_file':
|
|
742
|
+
return writeFile(workspaceRoot, args.filePath, args.content);
|
|
743
|
+
case 'delete_file':
|
|
744
|
+
return deleteFile(workspaceRoot, args.filePath);
|
|
745
|
+
case 'rename_file':
|
|
746
|
+
return renameFile(workspaceRoot, args.sourcePath, args.targetPath);
|
|
747
|
+
case 'modify_file':
|
|
748
|
+
return modifyFile(workspaceRoot, args.filePath, args.edits);
|
|
749
|
+
case 'list_directory':
|
|
750
|
+
return listDirectory(workspaceRoot, args.dirPath, args.maxDepth ?? 3);
|
|
751
|
+
case 'run_command':
|
|
752
|
+
return runCommand(workspaceRoot, args.command);
|
|
753
|
+
case 'create_and_run_test':
|
|
754
|
+
return createAndRunTest(workspaceRoot, args.language, args.code);
|
|
755
|
+
case 'grep_search':
|
|
756
|
+
return grepSearch(workspaceRoot, args.pattern, args.fileGlob);
|
|
757
|
+
case 'find_dependencies':
|
|
758
|
+
return traceDependencies(workspaceRoot, args.filePath, args.direction, args.maxDepth);
|
|
759
|
+
case 'find_recent_changes':
|
|
760
|
+
return findRecentChanges(workspaceRoot, args.dirPath, args.minutes, args.maxDepth);
|
|
761
|
+
default:
|
|
762
|
+
return { output: '', error: `Unknown tool: "${toolName}"` };
|
|
763
|
+
}
|
|
764
|
+
}
|