minovative-mind-cli 2.0.0 → 2.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 +8 -0
- package/dist/commands/chat.js +3 -0
- package/dist/services/agent/slashCommands.js +213 -12
- package/dist/services/agent-tools.js +142 -28
- package/dist/services/agent.js +3 -0
- package/dist/services/ai.js +16 -2
- package/dist/services/contextAgent.js +17 -5
- package/dist/services/orchestration/investigationAgent.js +5 -2
- package/dist/services/orchestration/orchestrator.js +5 -2
- package/dist/services/orchestration/subAgent.js +5 -2
- package/dist/services/workspaceRegistry.d.ts +137 -0
- package/dist/services/workspaceRegistry.js +270 -0
- package/dist/utils/contextPrompts.js +7 -1
- package/dist/utils/pathSecurity.d.ts +31 -0
- package/dist/utils/pathSecurity.js +48 -0
- package/dist/utils/systemPrompts.d.ts +2 -2
- package/dist/utils/systemPrompts.js +5 -1
- package/oclif.manifest.json +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -116,6 +116,14 @@ Hot-swap during a session using `/models`:
|
|
|
116
116
|
|
|
117
117
|
---
|
|
118
118
|
|
|
119
|
+
## 🌐 Multi-Workspace & Cross-Repo Support
|
|
120
|
+
|
|
121
|
+
Minovative Mind CLI doesn't restrict you to a single repository. You can link multiple external workspaces to your current session and the AI will seamlessly operate across all of them simultaneously.
|
|
122
|
+
|
|
123
|
+
By prefixing file paths with `@alias/` (e.g. `@backend/src/api.ts` and `@frontend/src/App.tsx`), the Context Agent, Thread Agents, and Semantic Search tools can investigate, refactor, and coordinate changes across your entire tech stack in a single prompt. The terminal highlights cross-workspace actions with a blue `[alias]` visual tag. Use the "Edit Workspace" menu option to configure your linked projects.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
119
127
|
## 🌐 Supported Languages
|
|
120
128
|
|
|
121
129
|
Minovative Mind CLI fundamentally supports **ALL programming languages** for chat, code generation, planning, and execution, as it relies on Gemini's vast training data.
|
package/dist/commands/chat.js
CHANGED
|
@@ -30,6 +30,7 @@ Inside the chat session, you can use the following commands in the slash menu:
|
|
|
30
30
|
/auto-approve - Toggle automatic approval of tool/command runs
|
|
31
31
|
/sub-agents - Toggle the MMAAK Engine for parallel investigation and execution
|
|
32
32
|
/semantic-search - Toggle local vector index capabilities
|
|
33
|
+
/workspaces - Manage external workspaces for cross-project development
|
|
33
34
|
/stats - View current session statistics and configuration
|
|
34
35
|
/commit - Commit current workspace changes to Git
|
|
35
36
|
/revert - Revert the last file modification made by the agent
|
|
@@ -76,6 +77,8 @@ Chat Controls:
|
|
|
76
77
|
if (idToken) {
|
|
77
78
|
updateWorkspaceStatus(idToken, workspaceRoot).catch(() => { });
|
|
78
79
|
}
|
|
80
|
+
const { workspaceRegistry } = await import('../services/workspaceRegistry.js');
|
|
81
|
+
workspaceRegistry.init();
|
|
79
82
|
await startAgentLoop(workspaceRoot, this.config.version);
|
|
80
83
|
}
|
|
81
84
|
}
|
|
@@ -233,14 +233,33 @@ export async function handleSlashCommand(command, context) {
|
|
|
233
233
|
}
|
|
234
234
|
let targetTimestamp = lastChangeSet ? lastChangeSet.timestamp : null;
|
|
235
235
|
if (revertMenu === 'view_history') {
|
|
236
|
+
const { workspaceRegistry } = await import('../workspaceRegistry.js');
|
|
236
237
|
const historyOptions = history
|
|
237
238
|
.slice()
|
|
238
239
|
.reverse()
|
|
239
|
-
.map((cs, i) =>
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
240
|
+
.map((cs, i) => {
|
|
241
|
+
const externalWorkspaces = new Set();
|
|
242
|
+
for (const change of cs.changes) {
|
|
243
|
+
if (change.filePath.startsWith('@')) {
|
|
244
|
+
const slashIndex = change.filePath.indexOf('/');
|
|
245
|
+
const aliasStr = slashIndex === -1 ? change.filePath.substring(1) : change.filePath.substring(1, slashIndex);
|
|
246
|
+
const ws = workspaceRegistry.get(aliasStr);
|
|
247
|
+
if (ws) {
|
|
248
|
+
const rootName = path.basename(ws.absolutePath);
|
|
249
|
+
externalWorkspaces.add(rootName);
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
externalWorkspaces.add(`@${aliasStr}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const tags = externalWorkspaces.size > 0 ? pc.blue(` [${Array.from(externalWorkspaces).join(', ')}]`) : '';
|
|
257
|
+
return {
|
|
258
|
+
value: cs.timestamp,
|
|
259
|
+
label: `[${i === 0 ? 'Latest' : `-${i}`}] ${truncate(cs.description, 50)}${tags} (${new Date(cs.timestamp).toLocaleTimeString()})${cs.status === 'partial' ? ' [Partial]' : ''}`,
|
|
260
|
+
hint: `Reverts this and all ${i} changes after it`,
|
|
261
|
+
};
|
|
262
|
+
});
|
|
244
263
|
const selectedHistory = await p['select']({
|
|
245
264
|
message: 'Select the point in history to revert back to:',
|
|
246
265
|
options: [...historyOptions, { value: -1, label: 'Cancel' }],
|
|
@@ -255,13 +274,14 @@ export async function handleSlashCommand(command, context) {
|
|
|
255
274
|
try {
|
|
256
275
|
const changesToRevert = changeLogger.popUntil(targetTimestamp);
|
|
257
276
|
const flatChanges = changesToRevert.flatMap((cs) => cs.changes);
|
|
277
|
+
const { resolveAndValidateMultiWorkspacePath } = await import('../../utils/pathSecurity.js');
|
|
258
278
|
for (const change of flatChanges) {
|
|
259
|
-
const
|
|
279
|
+
const { absolutePath } = resolveAndValidateMultiWorkspacePath(workspaceRoot, change.filePath);
|
|
260
280
|
if (change.action === 'create') {
|
|
261
|
-
await fs.rm(
|
|
281
|
+
await fs.rm(absolutePath, { force: true });
|
|
262
282
|
}
|
|
263
283
|
else if (change.originalContent !== null) {
|
|
264
|
-
await fs.writeFile(
|
|
284
|
+
await fs.writeFile(absolutePath, change.originalContent, 'utf-8');
|
|
265
285
|
}
|
|
266
286
|
}
|
|
267
287
|
spinner.stop('Reverted successfully.');
|
|
@@ -308,13 +328,41 @@ export async function handleSlashCommand(command, context) {
|
|
|
308
328
|
console.log(pc.dim('\nType your coding request below. Type "exit" or "quit" to leave.\n'));
|
|
309
329
|
return { shouldContinue: true };
|
|
310
330
|
}
|
|
331
|
+
const { workspaceRegistry } = await import('../workspaceRegistry.js');
|
|
311
332
|
const sessionOptions = sessions
|
|
312
333
|
.slice()
|
|
313
334
|
.reverse()
|
|
314
|
-
.map((s) =>
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
335
|
+
.map((s) => {
|
|
336
|
+
const externalWorkspaces = new Set();
|
|
337
|
+
if (s.history) {
|
|
338
|
+
for (const item of s.history) {
|
|
339
|
+
if (item.role === 'user' || !item.parts)
|
|
340
|
+
continue;
|
|
341
|
+
for (const part of item.parts) {
|
|
342
|
+
if (part.functionCall && part.functionCall.args && part.functionCall.args.filePath) {
|
|
343
|
+
const p = part.functionCall.args.filePath;
|
|
344
|
+
if (typeof p === 'string' && p.startsWith('@')) {
|
|
345
|
+
const slashIndex = p.indexOf('/');
|
|
346
|
+
const aliasStr = slashIndex === -1 ? p.substring(1) : p.substring(1, slashIndex);
|
|
347
|
+
const ws = workspaceRegistry.get(aliasStr);
|
|
348
|
+
if (ws) {
|
|
349
|
+
const rootName = path.basename(ws.absolutePath);
|
|
350
|
+
externalWorkspaces.add(rootName);
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
externalWorkspaces.add(`@${aliasStr}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const tags = externalWorkspaces.size > 0 ? pc.blue(` [${Array.from(externalWorkspaces).join(', ')}]`) : '';
|
|
361
|
+
return {
|
|
362
|
+
value: s.id,
|
|
363
|
+
label: `${truncate(s.title, 50)}${tags} (${new Date(s.timestamp).toLocaleString()})`,
|
|
364
|
+
};
|
|
365
|
+
});
|
|
318
366
|
if (chatsMenu === 'resume') {
|
|
319
367
|
const selectedSessionId = await p['select']({
|
|
320
368
|
message: 'Select a session to resume:',
|
|
@@ -386,6 +434,159 @@ export async function handleSlashCommand(command, context) {
|
|
|
386
434
|
}
|
|
387
435
|
return { shouldContinue: true };
|
|
388
436
|
}
|
|
437
|
+
if (lowerCommand === '/workspaces') {
|
|
438
|
+
const { workspaceRegistry } = await import('../workspaceRegistry.js');
|
|
439
|
+
while (true) {
|
|
440
|
+
const allRoots = workspaceRegistry.getAllRoots(workspaceRoot);
|
|
441
|
+
const options = [];
|
|
442
|
+
options.push({ value: 'add', label: 'Add Workspace' });
|
|
443
|
+
const externalRoots = allRoots.filter(r => r.alias);
|
|
444
|
+
if (externalRoots.length > 0) {
|
|
445
|
+
options.push({ value: 'edit', label: 'Edit Workspace' });
|
|
446
|
+
options.push({ value: 'remove', label: 'Remove Workspace' });
|
|
447
|
+
options.push({ value: 'list', label: 'List Workspaces' });
|
|
448
|
+
}
|
|
449
|
+
options.push({ value: 'cancel', label: 'Exit Menu' });
|
|
450
|
+
const action = await p['select']({
|
|
451
|
+
message: 'Manage External Workspaces',
|
|
452
|
+
options,
|
|
453
|
+
});
|
|
454
|
+
if (p.isCancel(action) || action === 'cancel') {
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
if (action === 'add') {
|
|
458
|
+
const aliasStr = await p['text']({
|
|
459
|
+
message: 'Enter a short alias (e.g. backend, ui):',
|
|
460
|
+
validate: (val) => {
|
|
461
|
+
if (!val)
|
|
462
|
+
return 'Alias is required';
|
|
463
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(val))
|
|
464
|
+
return 'Only letters, numbers, hyphens, and underscores';
|
|
465
|
+
if (workspaceRegistry.getAllRoots(workspaceRoot).some(r => r.alias === val))
|
|
466
|
+
return 'Alias already in use';
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
if (p.isCancel(aliasStr))
|
|
470
|
+
continue;
|
|
471
|
+
const rootPathStr = await p['text']({
|
|
472
|
+
message: 'Enter the absolute path to the workspace root (e.g. /Users/name/Projects/app):',
|
|
473
|
+
validate: (val) => {
|
|
474
|
+
if (!val)
|
|
475
|
+
return 'Path is required';
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
if (p.isCancel(rootPathStr))
|
|
479
|
+
continue;
|
|
480
|
+
let cleanRootPathStr = rootPathStr.trim();
|
|
481
|
+
if ((cleanRootPathStr.startsWith("'") && cleanRootPathStr.endsWith("'")) ||
|
|
482
|
+
(cleanRootPathStr.startsWith('"') && cleanRootPathStr.endsWith('"'))) {
|
|
483
|
+
cleanRootPathStr = cleanRootPathStr.slice(1, -1);
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
const stats = await fs.stat(cleanRootPathStr);
|
|
487
|
+
if (!stats.isDirectory()) {
|
|
488
|
+
p.log.error('Path is not a directory.');
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
workspaceRegistry.register(aliasStr, cleanRootPathStr);
|
|
492
|
+
p.log.success(`Added @${aliasStr} -> ${cleanRootPathStr}`);
|
|
493
|
+
}
|
|
494
|
+
catch (e) {
|
|
495
|
+
p.log.error(`Invalid path or directory does not exist: ${cleanRootPathStr}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
else if (action === 'edit') {
|
|
499
|
+
const editOptions = externalRoots.map(r => ({
|
|
500
|
+
value: r.alias,
|
|
501
|
+
label: `@${r.alias} -> ${r.root}`
|
|
502
|
+
}));
|
|
503
|
+
editOptions.push({ value: 'cancel', label: 'Cancel' });
|
|
504
|
+
const aliasToEdit = await p['select']({
|
|
505
|
+
message: 'Select workspace to edit:',
|
|
506
|
+
options: editOptions
|
|
507
|
+
});
|
|
508
|
+
if (p.isCancel(aliasToEdit) || aliasToEdit === 'cancel')
|
|
509
|
+
continue;
|
|
510
|
+
const ws = workspaceRegistry.get(aliasToEdit);
|
|
511
|
+
if (!ws)
|
|
512
|
+
continue;
|
|
513
|
+
const newAliasStr = await p['text']({
|
|
514
|
+
message: `Enter new alias (current: ${ws.alias}):`,
|
|
515
|
+
initialValue: ws.alias,
|
|
516
|
+
validate: (val) => {
|
|
517
|
+
if (!val)
|
|
518
|
+
return 'Alias is required';
|
|
519
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(val))
|
|
520
|
+
return 'Only letters, numbers, hyphens, and underscores';
|
|
521
|
+
if (val !== ws.alias && workspaceRegistry.getAllRoots(workspaceRoot).some(r => r.alias === val))
|
|
522
|
+
return 'Alias already in use';
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
if (p.isCancel(newAliasStr))
|
|
526
|
+
continue;
|
|
527
|
+
const newRootPathStr = await p['text']({
|
|
528
|
+
message: `Enter absolute path to workspace root (e.g. /Users/name/Projects/app):`,
|
|
529
|
+
initialValue: ws.absolutePath,
|
|
530
|
+
validate: (val) => {
|
|
531
|
+
if (!val)
|
|
532
|
+
return 'Path is required';
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
if (p.isCancel(newRootPathStr))
|
|
536
|
+
continue;
|
|
537
|
+
let cleanNewRootPathStr = newRootPathStr.trim();
|
|
538
|
+
if ((cleanNewRootPathStr.startsWith("'") && cleanNewRootPathStr.endsWith("'")) ||
|
|
539
|
+
(cleanNewRootPathStr.startsWith('"') && cleanNewRootPathStr.endsWith('"'))) {
|
|
540
|
+
cleanNewRootPathStr = cleanNewRootPathStr.slice(1, -1);
|
|
541
|
+
}
|
|
542
|
+
try {
|
|
543
|
+
const stats = await fs.stat(cleanNewRootPathStr);
|
|
544
|
+
if (!stats.isDirectory()) {
|
|
545
|
+
p.log.error('Path is not a directory.');
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
// Remove old alias first to avoid duplicate alias error, or to clean up
|
|
549
|
+
workspaceRegistry.unregister(ws.alias);
|
|
550
|
+
workspaceRegistry.register(newAliasStr, cleanNewRootPathStr);
|
|
551
|
+
p.log.success(`Updated @${newAliasStr} -> ${cleanNewRootPathStr}`);
|
|
552
|
+
}
|
|
553
|
+
catch (e) {
|
|
554
|
+
// If register failed, try to rollback
|
|
555
|
+
p.log.error(`Failed to update workspace: ${e instanceof Error ? e.message : String(e)}`);
|
|
556
|
+
try {
|
|
557
|
+
workspaceRegistry.register(ws.alias, ws.absolutePath);
|
|
558
|
+
}
|
|
559
|
+
catch { }
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
else if (action === 'remove') {
|
|
563
|
+
const removeOptions = externalRoots.map(r => ({
|
|
564
|
+
value: r.alias,
|
|
565
|
+
label: `@${r.alias} -> ${r.root}`
|
|
566
|
+
}));
|
|
567
|
+
removeOptions.push({ value: 'cancel', label: 'Cancel' });
|
|
568
|
+
const aliasToRemove = await p['select']({
|
|
569
|
+
message: 'Select workspace to remove:',
|
|
570
|
+
options: removeOptions
|
|
571
|
+
});
|
|
572
|
+
if (p.isCancel(aliasToRemove) || aliasToRemove === 'cancel')
|
|
573
|
+
continue;
|
|
574
|
+
workspaceRegistry.unregister(aliasToRemove);
|
|
575
|
+
p.log.success(`Removed @${aliasToRemove}`);
|
|
576
|
+
}
|
|
577
|
+
else if (action === 'list') {
|
|
578
|
+
for (const { alias, root } of allRoots) {
|
|
579
|
+
if (alias) {
|
|
580
|
+
p.log.step(`${pc.blue(`@${alias}`)} -> ${pc.dim(root)}`);
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
p.log.step(`${pc.cyan('(primary)')} -> ${pc.dim(root)}`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return { shouldContinue: true };
|
|
589
|
+
}
|
|
389
590
|
if (lowerCommand === '/commit') {
|
|
390
591
|
const commitSpinner = p.spinner();
|
|
391
592
|
commitSpinner.start('Staging changes and analyzing diff...');
|
|
@@ -4,7 +4,8 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { SchemaType } from '@google/generative-ai';
|
|
7
|
-
import { resolveAndValidatePath } from '../utils/pathSecurity.js';
|
|
7
|
+
import { resolveAndValidatePath, resolveAndValidateMultiWorkspacePath } from '../utils/pathSecurity.js';
|
|
8
|
+
import { workspaceRegistry } from './workspaceRegistry.js';
|
|
8
9
|
import { changeLogger } from './changeLogger.js';
|
|
9
10
|
import { findBestMatch, applyMatch } from '../utils/fuzzyMatch.js';
|
|
10
11
|
import { validateSyntax } from '../utils/syntaxValidator.js';
|
|
@@ -36,13 +37,13 @@ export function getToolDeclarations() {
|
|
|
36
37
|
export const toolDeclarations = [
|
|
37
38
|
{
|
|
38
39
|
name: 'read_file',
|
|
39
|
-
description: 'Read the contents of a file at the given path relative to the workspace root. Supports text files and native parsing of .pdf files (including math and diagrams). Use startLine and endLine to read specific chunks of massive files to avoid context limits.',
|
|
40
|
+
description: 'Read the contents of a file at the given path relative to the workspace root. Supports text files and native parsing of .pdf files (including math and diagrams). Use startLine and endLine to read specific chunks of massive files to avoid context limits. For files in external workspaces, prefix the path with @alias/ (e.g., @backend/src/routes.ts).',
|
|
40
41
|
parameters: {
|
|
41
42
|
type: SchemaType.OBJECT,
|
|
42
43
|
properties: {
|
|
43
44
|
filePath: {
|
|
44
45
|
type: SchemaType.STRING,
|
|
45
|
-
description: 'Relative path to the file from the workspace root.',
|
|
46
|
+
description: 'Relative path to the file from the workspace root, or @alias/path for external workspaces.',
|
|
46
47
|
},
|
|
47
48
|
startLine: {
|
|
48
49
|
type: SchemaType.NUMBER,
|
|
@@ -63,13 +64,13 @@ export const toolDeclarations = [
|
|
|
63
64
|
},
|
|
64
65
|
{
|
|
65
66
|
name: 'write_file',
|
|
66
|
-
description: 'Create a new file or completely overwrite an existing file with the provided content. Use modify_file for targeted edits instead.',
|
|
67
|
+
description: 'Create a new file or completely overwrite an existing file with the provided content. Use modify_file for targeted edits instead. For files in external workspaces, prefix the path with @alias/ (e.g., @backend/src/routes.ts).',
|
|
67
68
|
parameters: {
|
|
68
69
|
type: SchemaType.OBJECT,
|
|
69
70
|
properties: {
|
|
70
71
|
filePath: {
|
|
71
72
|
type: SchemaType.STRING,
|
|
72
|
-
description: 'Relative path to the file from the workspace root.',
|
|
73
|
+
description: 'Relative path to the file from the workspace root, or @alias/path for external workspaces.',
|
|
73
74
|
},
|
|
74
75
|
content: {
|
|
75
76
|
type: SchemaType.STRING,
|
|
@@ -81,13 +82,13 @@ export const toolDeclarations = [
|
|
|
81
82
|
},
|
|
82
83
|
{
|
|
83
84
|
name: 'delete_file',
|
|
84
|
-
description: 'Deletes a file from the filesystem. Use this instead of running an rm command.',
|
|
85
|
+
description: 'Deletes a file from the filesystem. Use this instead of running an rm command. For files in external workspaces, prefix the path with @alias/ (e.g., @backend/src/routes.ts).',
|
|
85
86
|
parameters: {
|
|
86
87
|
type: SchemaType.OBJECT,
|
|
87
88
|
properties: {
|
|
88
89
|
filePath: {
|
|
89
90
|
type: SchemaType.STRING,
|
|
90
|
-
description: 'Relative path to the file to delete.',
|
|
91
|
+
description: 'Relative path to the file to delete, or @alias/path for external workspaces.',
|
|
91
92
|
},
|
|
92
93
|
},
|
|
93
94
|
required: ['filePath'],
|
|
@@ -95,17 +96,17 @@ export const toolDeclarations = [
|
|
|
95
96
|
},
|
|
96
97
|
{
|
|
97
98
|
name: 'rename_file',
|
|
98
|
-
description: 'Moves or renames a file. Use this instead of running an mv command.',
|
|
99
|
+
description: 'Moves or renames a file. Use this instead of running an mv command. For files in external workspaces, prefix the path with @alias/ (e.g., @backend/src/routes.ts).',
|
|
99
100
|
parameters: {
|
|
100
101
|
type: SchemaType.OBJECT,
|
|
101
102
|
properties: {
|
|
102
103
|
sourcePath: {
|
|
103
104
|
type: SchemaType.STRING,
|
|
104
|
-
description: 'Relative path to the file to move/rename.',
|
|
105
|
+
description: 'Relative path to the file to move/rename, or @alias/path for external workspaces.',
|
|
105
106
|
},
|
|
106
107
|
targetPath: {
|
|
107
108
|
type: SchemaType.STRING,
|
|
108
|
-
description: 'New relative path for the file.',
|
|
109
|
+
description: 'New relative path for the file, or @alias/path for external workspaces.',
|
|
109
110
|
},
|
|
110
111
|
},
|
|
111
112
|
required: ['sourcePath', 'targetPath'],
|
|
@@ -113,13 +114,13 @@ export const toolDeclarations = [
|
|
|
113
114
|
},
|
|
114
115
|
{
|
|
115
116
|
name: 'modify_file',
|
|
116
|
-
description: 'Perform one or multiple targeted search-and-replace edits in a single file. CRITICAL REQUIREMENT: You MUST use read_file or grep_search to fetch the exact current file content BEFORE using this tool. Do NOT guess or hallucinate the searchContent without reading the exact lines first, or the edit will fail. The search strings must match the current file exactly (including whitespace). This is preferred over write_file for editing existing files.',
|
|
117
|
+
description: 'Perform one or multiple targeted search-and-replace edits in a single file. CRITICAL REQUIREMENT: You MUST use read_file or grep_search to fetch the exact current file content BEFORE using this tool. Do NOT guess or hallucinate the searchContent without reading the exact lines first, or the edit will fail. The search strings must match the current file exactly (including whitespace). This is preferred over write_file for editing existing files. For files in external workspaces, prefix the path with @alias/ (e.g., @backend/src/routes.ts).',
|
|
117
118
|
parameters: {
|
|
118
119
|
type: SchemaType.OBJECT,
|
|
119
120
|
properties: {
|
|
120
121
|
filePath: {
|
|
121
122
|
type: SchemaType.STRING,
|
|
122
|
-
description: 'Relative path to the file from the workspace root.',
|
|
123
|
+
description: 'Relative path to the file from the workspace root, or @alias/path for external workspaces.',
|
|
123
124
|
},
|
|
124
125
|
edits: {
|
|
125
126
|
type: SchemaType.ARRAY,
|
|
@@ -145,13 +146,13 @@ export const toolDeclarations = [
|
|
|
145
146
|
},
|
|
146
147
|
{
|
|
147
148
|
name: 'list_directory',
|
|
148
|
-
description: 'List files and subdirectories in a directory relative to the workspace root. Returns a recursive tree structure.',
|
|
149
|
+
description: 'List files and subdirectories in a directory relative to the workspace root. Returns a recursive tree structure. For directories in external workspaces, prefix the path with @alias/ (e.g., @backend/src). Use "@all" to see a summary of all registered workspaces.',
|
|
149
150
|
parameters: {
|
|
150
151
|
type: SchemaType.OBJECT,
|
|
151
152
|
properties: {
|
|
152
153
|
dirPath: {
|
|
153
154
|
type: SchemaType.STRING,
|
|
154
|
-
description: 'Relative path to the directory from the workspace root
|
|
155
|
+
description: 'Relative path to the directory from the workspace root, @alias/path for external workspaces, or "@all" for all workspaces.',
|
|
155
156
|
},
|
|
156
157
|
maxDepth: {
|
|
157
158
|
type: SchemaType.NUMBER,
|
|
@@ -177,7 +178,7 @@ export const toolDeclarations = [
|
|
|
177
178
|
},
|
|
178
179
|
{
|
|
179
180
|
name: 'grep_search',
|
|
180
|
-
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.',
|
|
181
|
+
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. Use the workspace parameter to search external workspaces.',
|
|
181
182
|
parameters: {
|
|
182
183
|
type: SchemaType.OBJECT,
|
|
183
184
|
properties: {
|
|
@@ -189,6 +190,10 @@ export const toolDeclarations = [
|
|
|
189
190
|
type: SchemaType.STRING,
|
|
190
191
|
description: 'Optional glob to restrict file types, e.g. "*.ts" or "*.py". Defaults to all files.',
|
|
191
192
|
},
|
|
193
|
+
workspace: {
|
|
194
|
+
type: SchemaType.STRING,
|
|
195
|
+
description: 'Optional. Workspace alias to search in (e.g., "backend"), or "all" to search all registered workspaces. Defaults to the primary workspace.',
|
|
196
|
+
},
|
|
192
197
|
},
|
|
193
198
|
required: ['pattern'],
|
|
194
199
|
},
|
|
@@ -889,36 +894,145 @@ export async function runDebugScript(workspaceRoot, language, code, abortSignal)
|
|
|
889
894
|
}
|
|
890
895
|
}
|
|
891
896
|
}
|
|
897
|
+
/**
|
|
898
|
+
* Resolves `@alias/` prefixed paths in tool arguments to the correct workspace root
|
|
899
|
+
* and relative path. Returns the effective workspaceRoot and the cleaned arguments.
|
|
900
|
+
*/
|
|
901
|
+
function resolveWorkspaceArgs(primaryRoot, toolName, args) {
|
|
902
|
+
// Only resolve if workspaces are registered
|
|
903
|
+
if (!workspaceRegistry.hasWorkspaces()) {
|
|
904
|
+
return { effectiveRoot: primaryRoot, resolvedArgs: args };
|
|
905
|
+
}
|
|
906
|
+
const resolvedArgs = { ...args };
|
|
907
|
+
// Map of tool-name -> argument keys that hold file/dir paths
|
|
908
|
+
const pathArgKeys = {
|
|
909
|
+
read_file: ['filePath'],
|
|
910
|
+
write_file: ['filePath'],
|
|
911
|
+
delete_file: ['filePath'],
|
|
912
|
+
modify_file: ['filePath'],
|
|
913
|
+
list_directory: ['dirPath'],
|
|
914
|
+
find_dependencies: ['filePath'],
|
|
915
|
+
find_recent_changes: ['dirPath'],
|
|
916
|
+
rename_file: ['sourcePath', 'targetPath'],
|
|
917
|
+
};
|
|
918
|
+
const keys = pathArgKeys[toolName];
|
|
919
|
+
if (!keys) {
|
|
920
|
+
return { effectiveRoot: primaryRoot, resolvedArgs };
|
|
921
|
+
}
|
|
922
|
+
let effectiveRoot = primaryRoot;
|
|
923
|
+
for (const key of keys) {
|
|
924
|
+
const val = resolvedArgs[key];
|
|
925
|
+
if (typeof val !== 'string' || !val.startsWith('@'))
|
|
926
|
+
continue;
|
|
927
|
+
// Special case: "@all" for list_directory — handled separately in executeTool
|
|
928
|
+
if (val === '@all')
|
|
929
|
+
continue;
|
|
930
|
+
const resolved = resolveAndValidateMultiWorkspacePath(primaryRoot, val);
|
|
931
|
+
effectiveRoot = resolved.workspaceRoot;
|
|
932
|
+
resolvedArgs[key] = resolved.relativePath;
|
|
933
|
+
}
|
|
934
|
+
return { effectiveRoot, resolvedArgs };
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Runs grep across all registered workspaces (primary + external) and merges results.
|
|
938
|
+
* Results from external workspaces are prefixed with @alias/ for disambiguation.
|
|
939
|
+
*/
|
|
940
|
+
async function crossWorkspaceGrep(primaryRoot, pattern, fileGlob, abortSignal) {
|
|
941
|
+
const allRoots = workspaceRegistry.getAllRoots(primaryRoot);
|
|
942
|
+
const allResults = [];
|
|
943
|
+
for (const { alias, root } of allRoots) {
|
|
944
|
+
const result = await grepSearch(root, pattern, fileGlob, abortSignal);
|
|
945
|
+
if (result.error)
|
|
946
|
+
continue;
|
|
947
|
+
// Extract the raw text from the XML wrapper
|
|
948
|
+
const cdataMatch = result.output.match(/<!\[CDATA\[\n([\s\S]*?)\n\]\]>/);
|
|
949
|
+
const rawText = cdataMatch ? cdataMatch[1] : result.output;
|
|
950
|
+
if (rawText.startsWith('No matches found'))
|
|
951
|
+
continue;
|
|
952
|
+
// Prefix each result line with the workspace alias for disambiguation
|
|
953
|
+
const prefix = alias ? `@${alias}/` : '';
|
|
954
|
+
const lines = rawText.split('\n').filter((l) => l.trim());
|
|
955
|
+
for (const line of lines) {
|
|
956
|
+
if (line.startsWith('...')) {
|
|
957
|
+
allResults.push(line);
|
|
958
|
+
}
|
|
959
|
+
else {
|
|
960
|
+
// Lines are in format "./path:lineNum:content" — prepend alias prefix
|
|
961
|
+
allResults.push(prefix ? line.replace(/^\.?\//, `@${alias}/`) : line);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
if (allResults.length === 0) {
|
|
966
|
+
return { output: `No matches found for "${pattern}" across all workspaces.` };
|
|
967
|
+
}
|
|
968
|
+
const limited = allResults.slice(0, 80);
|
|
969
|
+
const resultText = limited.join('\n') +
|
|
970
|
+
(allResults.length > 80 ? `\n\n... (${allResults.length - 80} more results truncated)` : '');
|
|
971
|
+
const wrappedResult = `<workspace_file path="grep_search_results">\n<content_data><![CDATA[\n${sanitizeForCDATA(resultText)}\n]]></content_data>\n</workspace_file>`;
|
|
972
|
+
return { output: wrappedResult };
|
|
973
|
+
}
|
|
892
974
|
export async function executeTool(workspaceRoot, toolName, args, abortSignal) {
|
|
975
|
+
// ─── Multi-Workspace Path Resolution ─────────────────────────────
|
|
976
|
+
// Intercept @alias/ prefixed paths and swap workspaceRoot + relative path
|
|
977
|
+
// before dispatching to the underlying tool functions (which remain unchanged).
|
|
978
|
+
const { effectiveRoot, resolvedArgs } = resolveWorkspaceArgs(workspaceRoot, toolName, args);
|
|
893
979
|
let result;
|
|
894
980
|
switch (toolName) {
|
|
895
981
|
case 'read_file':
|
|
896
|
-
result = await readFile(
|
|
982
|
+
result = await readFile(effectiveRoot, resolvedArgs.filePath, resolvedArgs.startLine, resolvedArgs.endLine, resolvedArgs.targetElements);
|
|
897
983
|
break;
|
|
898
984
|
case 'write_file':
|
|
899
|
-
result = await writeFile(
|
|
985
|
+
result = await writeFile(effectiveRoot, resolvedArgs.filePath, resolvedArgs.content);
|
|
900
986
|
break;
|
|
901
987
|
case 'delete_file':
|
|
902
|
-
result = await deleteFile(
|
|
988
|
+
result = await deleteFile(effectiveRoot, resolvedArgs.filePath);
|
|
903
989
|
break;
|
|
904
990
|
case 'rename_file':
|
|
905
|
-
result = await renameFile(
|
|
991
|
+
result = await renameFile(effectiveRoot, resolvedArgs.sourcePath, resolvedArgs.targetPath);
|
|
906
992
|
break;
|
|
907
993
|
case 'modify_file':
|
|
908
|
-
result = await modifyFile(
|
|
994
|
+
result = await modifyFile(effectiveRoot, resolvedArgs.filePath, resolvedArgs.edits);
|
|
909
995
|
break;
|
|
910
|
-
case 'list_directory':
|
|
911
|
-
|
|
996
|
+
case 'list_directory': {
|
|
997
|
+
const dirPath = resolvedArgs.dirPath;
|
|
998
|
+
// Handle @all — list all registered workspace roots
|
|
999
|
+
if (dirPath === '@all') {
|
|
1000
|
+
const allRoots = workspaceRegistry.getAllRoots(workspaceRoot);
|
|
1001
|
+
const sections = [];
|
|
1002
|
+
for (const { alias, root } of allRoots) {
|
|
1003
|
+
const label = alias ? `@${alias}` : '(primary)';
|
|
1004
|
+
const subResult = await listDirectory(root, '.', 2);
|
|
1005
|
+
sections.push(`=== Workspace: ${label} [${root}] ===\n${subResult.output}`);
|
|
1006
|
+
}
|
|
1007
|
+
result = { output: sections.join('\n\n') };
|
|
1008
|
+
}
|
|
1009
|
+
else {
|
|
1010
|
+
result = await listDirectory(effectiveRoot, dirPath, resolvedArgs.maxDepth ?? 3);
|
|
1011
|
+
}
|
|
912
1012
|
break;
|
|
1013
|
+
}
|
|
913
1014
|
case 'run_command':
|
|
914
|
-
result = await runCommand(
|
|
1015
|
+
result = await runCommand(effectiveRoot, resolvedArgs.command, abortSignal);
|
|
915
1016
|
break;
|
|
916
1017
|
case 'run_debug_script':
|
|
917
|
-
result = await runDebugScript(
|
|
1018
|
+
result = await runDebugScript(effectiveRoot, resolvedArgs.language, resolvedArgs.code, abortSignal);
|
|
918
1019
|
break;
|
|
919
|
-
case 'grep_search':
|
|
920
|
-
|
|
1020
|
+
case 'grep_search': {
|
|
1021
|
+
const wsParam = resolvedArgs.workspace;
|
|
1022
|
+
if (wsParam === 'all') {
|
|
1023
|
+
// Cross-workspace search across all registered workspaces
|
|
1024
|
+
result = await crossWorkspaceGrep(workspaceRoot, resolvedArgs.pattern, resolvedArgs.fileGlob, abortSignal);
|
|
1025
|
+
}
|
|
1026
|
+
else if (wsParam && workspaceRegistry.get(wsParam)) {
|
|
1027
|
+
// Search in a specific external workspace
|
|
1028
|
+
const ws = workspaceRegistry.get(wsParam);
|
|
1029
|
+
result = await grepSearch(ws.absolutePath, resolvedArgs.pattern, resolvedArgs.fileGlob, abortSignal);
|
|
1030
|
+
}
|
|
1031
|
+
else {
|
|
1032
|
+
result = await grepSearch(effectiveRoot, resolvedArgs.pattern, resolvedArgs.fileGlob, abortSignal);
|
|
1033
|
+
}
|
|
921
1034
|
break;
|
|
1035
|
+
}
|
|
922
1036
|
case 'semantic_search': {
|
|
923
1037
|
const query = args.query;
|
|
924
1038
|
const topK = args.topK || 5;
|
|
@@ -950,10 +1064,10 @@ export async function executeTool(workspaceRoot, toolName, args, abortSignal) {
|
|
|
950
1064
|
break;
|
|
951
1065
|
}
|
|
952
1066
|
case 'find_dependencies':
|
|
953
|
-
result = await traceDependencies(
|
|
1067
|
+
result = await traceDependencies(effectiveRoot, resolvedArgs.filePath, resolvedArgs.direction, resolvedArgs.maxDepth);
|
|
954
1068
|
break;
|
|
955
1069
|
case 'find_recent_changes':
|
|
956
|
-
result = await findRecentChanges(
|
|
1070
|
+
result = await findRecentChanges(effectiveRoot, resolvedArgs.dirPath, resolvedArgs.minutes, resolvedArgs.maxDepth);
|
|
957
1071
|
break;
|
|
958
1072
|
default:
|
|
959
1073
|
result = { output: '', error: `Unknown tool: "${toolName}"` };
|
package/dist/services/agent.js
CHANGED
|
@@ -140,6 +140,7 @@ export async function startAgentLoop(workspaceRoot, version) {
|
|
|
140
140
|
label: '/semantic-search',
|
|
141
141
|
hint: 'Toggle local vector index capabilities for better search',
|
|
142
142
|
},
|
|
143
|
+
{ value: '/workspaces', label: '/workspaces', hint: 'Manage external workspaces for cross-project development' },
|
|
143
144
|
{ value: '/stats', label: '/stats', hint: 'View current session statistics and configuration' },
|
|
144
145
|
{ value: '/commit', label: '/commit', hint: 'Auto-commit changes with AI message' },
|
|
145
146
|
{ value: '/revert', label: '/revert', hint: 'Undo last change' },
|
|
@@ -306,6 +307,7 @@ export async function executeSingleTurn(workspaceRoot, userInput, chat, inputHan
|
|
|
306
307
|
selectedModel = GEMINI_MODELS.FLASH_LITE_3_1;
|
|
307
308
|
}
|
|
308
309
|
else {
|
|
310
|
+
spinner.stop(); // MUST clear the investigation spinner first to prevent leaking the setInterval
|
|
309
311
|
spinner.start(pc.blue('🧠 Evaluating execution complexity...'));
|
|
310
312
|
const complexity = await evaluateExecutionComplexity(finalInput, gatherRes.contextResult?.summary, gatherRes.contextResult?.relevantFiles?.size || 0);
|
|
311
313
|
spinner.stop();
|
|
@@ -331,6 +333,7 @@ export async function executeSingleTurn(workspaceRoot, userInput, chat, inputHan
|
|
|
331
333
|
else {
|
|
332
334
|
// Parallel investigation already printed its own detailed summary log
|
|
333
335
|
if (!inputHandler.isCurrentlyPrompting()) {
|
|
336
|
+
spinner.stop(); // MUST clear the investigation spinner first to prevent leaking the setInterval
|
|
334
337
|
spinner.start('Thinking...');
|
|
335
338
|
}
|
|
336
339
|
}
|