minovative-mind-cli 2.3.0 → 2.3.2

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 CHANGED
@@ -121,11 +121,13 @@ Hot-swap during a session using `/models`:
121
121
 
122
122
  ---
123
123
 
124
- ## 🌐 Multi-Workspace & Cross-Repo Support
124
+ ## 🌐 Multi-Workspace & Cross-Repo Support (Master & Sub-Workspaces)
125
125
 
126
- 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.
126
+ Minovative Mind CLI doesn't restrict you to a single repository. You can logically group multiple external repositories into **Master Workspaces** (Profiles) containing dedicated **Sub-Workspaces** (mapped to short aliases like `@backend` or `@frontend`).
127
127
 
128
- By prefixing file paths with `@alias/` (e.g. `@backend/src/api.ts` and `@frontend/src/App.tsx`), the Context Agent and Thread Agents 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.
128
+ The Context Agent and Thread Agents operate strictly within the boundaries of the active Master Workspace, ignoring other profiles to keep the context window highly targeted and memory-efficient.
129
+
130
+ By prefixing file paths with `@alias/` (e.g., `@backend/src/api.ts` and `@frontend/src/App.tsx`), the agents can investigate, refactor, and coordinate changes across your entire nested stack in a single prompt. The terminal highlights cross-workspace actions with a blue `[alias]` visual tag. Use the `/workspaces` command to create profiles, link sub-workspaces, or switch active environments.
129
131
 
130
132
  ---
131
133
 
@@ -11,7 +11,7 @@ import { chatHistoryService } from '../chatHistoryService.js';
11
11
  import { printLogo, brandBg, brandFg } from '../../utils/logo.js';
12
12
  import { readPaste } from '../../utils/paste.js';
13
13
  import { setApprovalMode, getApprovalMode, isSubAgentsEnabled, setSubAgentsEnabled } from '../agent-tools.js';
14
- import { ProxyChatSession, setGlobalActiveModel, getGlobalActiveModel, } from '../ai.js';
14
+ import { ProxyChatSession, setGlobalActiveModel, getGlobalActiveModel } from '../ai.js';
15
15
  import { GEMINI_MODELS } from '../../utils/config.js';
16
16
  const execAsync = promisify(exec);
17
17
  /**
@@ -495,7 +495,9 @@ export async function handleSlashCommand(command, context) {
495
495
  p.log.info(`${pc.dim('Model:')} ${pc.cyan(session.modelName)}`);
496
496
  }
497
497
  if (session.totalTokens !== undefined) {
498
- const inputStr = session.totalInputTokens ? ` (Input: ${session.totalInputTokens.toLocaleString()}, Output: ${(session.totalOutputTokens || 0).toLocaleString()})` : '';
498
+ const inputStr = session.totalInputTokens
499
+ ? ` (Input: ${session.totalInputTokens.toLocaleString()}, Output: ${(session.totalOutputTokens || 0).toLocaleString()})`
500
+ : '';
499
501
  p.log.info(`${pc.dim('Session Token Usage:')} ${pc.cyan(session.totalTokens.toLocaleString() + ' total tokens')}${pc.dim(inputStr)}`);
500
502
  }
501
503
  if (session.gitBranch) {
@@ -537,9 +539,9 @@ export async function handleSlashCommand(command, context) {
537
539
  while (true) {
538
540
  const allRoots = workspaceRegistry.getAllRoots(workspaceRoot);
539
541
  const options = [];
542
+ const hasProfiles = workspaceRegistry.hasProfiles();
540
543
  options.push({ value: 'add', label: 'Add Workspace' });
541
- const externalRoots = allRoots.filter((r) => r.alias);
542
- if (externalRoots.length > 0) {
544
+ if (hasProfiles) {
543
545
  options.push({ value: 'edit', label: 'Edit Workspace' });
544
546
  options.push({ value: 'remove', label: 'Remove Workspace' });
545
547
  options.push({ value: 'list', label: 'List Workspaces' });
@@ -553,6 +555,40 @@ export async function handleSlashCommand(command, context) {
553
555
  break;
554
556
  }
555
557
  if (action === 'add') {
558
+ const type = await p['select']({
559
+ message: 'What type of workspace would you like to add?',
560
+ options: [
561
+ { value: 'master', label: 'Master Workspace (Create)' },
562
+ { value: 'sub', label: 'Sub-workspace', hint: 'Requires an existing Master Workspace' },
563
+ ],
564
+ });
565
+ if (p.isCancel(type))
566
+ continue;
567
+ let profileStr;
568
+ if (type === 'master') {
569
+ profileStr = (await p['text']({
570
+ message: 'Enter profile name (e.g. work, personal):',
571
+ validate: (val) => {
572
+ if (!val)
573
+ return 'Profile name is required';
574
+ },
575
+ }));
576
+ if (p.isCancel(profileStr))
577
+ continue;
578
+ }
579
+ else {
580
+ const profiles = workspaceRegistry.listProfiles();
581
+ if (profiles.length === 0) {
582
+ p.log.error('No master workspaces (profiles) exist. Please create one first.');
583
+ continue;
584
+ }
585
+ profileStr = (await p['select']({
586
+ message: 'Select a Master Workspace (For Sub-Workspaces):',
587
+ options: profiles.map((p) => ({ value: p.name, label: p.name })),
588
+ }));
589
+ if (p.isCancel(profileStr))
590
+ continue;
591
+ }
556
592
  const aliasStr = await p['text']({
557
593
  message: 'Enter a short alias (e.g. backend, ui):',
558
594
  validate: (val) => {
@@ -567,7 +603,7 @@ export async function handleSlashCommand(command, context) {
567
603
  if (p.isCancel(aliasStr))
568
604
  continue;
569
605
  const rootPathStr = await p['text']({
570
- message: 'Enter the absolute path to the workspace root (e.g. /Users/name/Projects/app):',
606
+ message: 'Enter the absolute path to the workspace root:',
571
607
  validate: (val) => {
572
608
  if (!val)
573
609
  return 'Path is required';
@@ -586,17 +622,17 @@ export async function handleSlashCommand(command, context) {
586
622
  p.log.error('Path is not a directory.');
587
623
  continue;
588
624
  }
589
- workspaceRegistry.register(aliasStr, cleanRootPathStr);
590
- p.log.success(`Added @${aliasStr} -> ${cleanRootPathStr}`);
625
+ workspaceRegistry.register(profileStr, aliasStr, cleanRootPathStr);
626
+ p.log.success(`Added @${aliasStr} -> ${cleanRootPathStr} (Profile: ${profileStr})`);
591
627
  }
592
628
  catch (e) {
593
- p.log.error(`Invalid path or directory does not exist: ${cleanRootPathStr}`);
629
+ p.log.error(e.message || `Invalid path or directory does not exist: ${cleanRootPathStr}`);
594
630
  }
595
631
  }
596
632
  else if (action === 'edit') {
597
- const editOptions = externalRoots.map((r) => ({
633
+ const editOptions = workspaceRegistry.list().map((r) => ({
598
634
  value: r.alias,
599
- label: `@${r.alias} -> ${r.root}`,
635
+ label: `@${r.alias} -> ${r.absolutePath}`,
600
636
  }));
601
637
  editOptions.push({ value: 'cancel', label: 'Cancel' });
602
638
  const aliasToEdit = await p['select']({
@@ -608,6 +644,16 @@ export async function handleSlashCommand(command, context) {
608
644
  const ws = workspaceRegistry.get(aliasToEdit);
609
645
  if (!ws)
610
646
  continue;
647
+ const newProfileStr = await p['text']({
648
+ message: `Enter new profile name (current: ${ws.profile}):`,
649
+ initialValue: ws.profile,
650
+ validate: (val) => {
651
+ if (!val)
652
+ return 'Profile name is required';
653
+ },
654
+ });
655
+ if (p.isCancel(newProfileStr))
656
+ continue;
611
657
  const newAliasStr = await p['text']({
612
658
  message: `Enter new alias (current: ${ws.alias}):`,
613
659
  initialValue: ws.alias,
@@ -645,22 +691,22 @@ export async function handleSlashCommand(command, context) {
645
691
  }
646
692
  // Remove old alias first to avoid duplicate alias error, or to clean up
647
693
  workspaceRegistry.unregister(ws.alias);
648
- workspaceRegistry.register(newAliasStr, cleanNewRootPathStr);
649
- p.log.success(`Updated @${newAliasStr} -> ${cleanNewRootPathStr}`);
694
+ workspaceRegistry.register(newProfileStr, newAliasStr, cleanNewRootPathStr);
695
+ p.log.success(`Updated @${newAliasStr} -> ${cleanNewRootPathStr} (Profile: ${newProfileStr})`);
650
696
  }
651
697
  catch (e) {
652
698
  // If register failed, try to rollback
653
699
  p.log.error(`Failed to update workspace: ${e instanceof Error ? e.message : String(e)}`);
654
700
  try {
655
- workspaceRegistry.register(ws.alias, ws.absolutePath);
701
+ workspaceRegistry.register(ws.profile, ws.alias, ws.absolutePath);
656
702
  }
657
703
  catch { }
658
704
  }
659
705
  }
660
706
  else if (action === 'remove') {
661
- const removeOptions = externalRoots.map((r) => ({
707
+ const removeOptions = workspaceRegistry.list().map((r) => ({
662
708
  value: r.alias,
663
- label: `@${r.alias} -> ${r.root}`,
709
+ label: `@${r.alias} -> ${r.absolutePath}`,
664
710
  }));
665
711
  removeOptions.push({ value: 'cancel', label: 'Cancel' });
666
712
  const aliasToRemove = await p['select']({
@@ -674,8 +720,9 @@ export async function handleSlashCommand(command, context) {
674
720
  }
675
721
  else if (action === 'list') {
676
722
  for (const { alias, root } of allRoots) {
723
+ const ws = alias ? workspaceRegistry.get(alias) : null;
677
724
  if (alias) {
678
- p.log.step(`${pc.blue(`@${alias}`)} -> ${pc.dim(root)}`);
725
+ p.log.step(`${pc.blue(`@${alias}`)} [${ws?.profile || 'default'}] -> ${pc.dim(root)}`);
679
726
  }
680
727
  else {
681
728
  p.log.step(`${pc.cyan('(primary)')} -> ${pc.dim(root)}`);
@@ -1,18 +1,4 @@
1
1
  import type { Content, Tool, ToolConfig, FunctionCall } from '@google/generative-ai';
2
- /**
3
- * ============================================================================
4
- * PROXY CLIENT SERVICE
5
- * ============================================================================
6
- * Facilitates stream-based communication with the secure, Firebase-authenticated
7
- * serverless Gemini content generation proxy.
8
- *
9
- * Core Capabilities:
10
- * - Server-Sent Events (SSE) parsing for thoughts, text, and function calls.
11
- * - Secure Authentication handling (Firebase ID Tokens).
12
- * - Real-time stream-callback piping for instant responses.
13
- * - Accurate token usage, credit consumption, and grounding metadata parsing.
14
- * ============================================================================
15
- */
16
2
  /**
17
3
  * Metadata containing real-time proxy token and credit usage diagnostics.
18
4
  */
@@ -1,4 +1,38 @@
1
1
  import { debugLog } from '../utils/logger.js';
2
+ /**
3
+ * ============================================================================
4
+ * PROXY CLIENT SERVICE
5
+ * ============================================================================
6
+ * Facilitates stream-based communication with the secure, Firebase-authenticated
7
+ * serverless Gemini content generation proxy.
8
+ *
9
+ * Core Capabilities:
10
+ * - Server-Sent Events (SSE) parsing for thoughts, text, and function calls.
11
+ * - Secure Authentication handling (Firebase ID Tokens).
12
+ * - Real-time stream-callback piping for instant responses.
13
+ * - Accurate token usage, credit consumption, and grounding metadata parsing.
14
+ * ============================================================================
15
+ */
16
+ /**
17
+ * Helper to pause execution for a specified duration, respecting abort signals.
18
+ */
19
+ async function delay(ms, abortSignal) {
20
+ return new Promise((resolve, reject) => {
21
+ let timeout;
22
+ const abortHandler = () => {
23
+ clearTimeout(timeout);
24
+ reject(new Error('Operation aborted'));
25
+ };
26
+ if (abortSignal?.aborted) {
27
+ return abortHandler();
28
+ }
29
+ abortSignal?.addEventListener('abort', abortHandler);
30
+ timeout = setTimeout(() => {
31
+ abortSignal?.removeEventListener('abort', abortHandler);
32
+ resolve();
33
+ }, ms);
34
+ });
35
+ }
2
36
  let globalSessionAccumulatedUsage = {
3
37
  promptTokens: 0,
4
38
  candidatesTokens: 0,
@@ -45,132 +79,162 @@ export class ProxyClient {
45
79
  * @throws {Error} If authentication fails (401), credits are insufficient (402), or network/proxy errors occur.
46
80
  */
47
81
  async generateFunctionCallViaProxy(idToken, modelName, contents, tools, toolConfig, systemInstruction, generationConfig, streamCallbacks, abortSignal) {
48
- const response = await fetch(this.PROXY_URL, {
49
- method: 'POST',
50
- headers: {
51
- 'Content-Type': 'application/json',
52
- 'X-Firebase-Auth': `Bearer ${idToken}`,
53
- },
54
- body: JSON.stringify({
55
- model: modelName,
56
- contents,
57
- tools,
58
- toolConfig,
59
- systemInstruction,
60
- generationConfig,
61
- }),
62
- signal: abortSignal,
63
- });
64
- debugLog(`Proxy Request to ${modelName} complete. Status: ${response.status} ${response.statusText}`);
65
- if (response.status === 401) {
66
- let details = '';
67
- try {
68
- const text = await response.text();
82
+ const MAX_RETRIES = 5;
83
+ const BASE_DELAY_MS = 2000;
84
+ const MAX_DELAY_MS = 30000;
85
+ let attempt = 0;
86
+ retryLoop: while (true) {
87
+ const response = await fetch(this.PROXY_URL, {
88
+ method: 'POST',
89
+ headers: {
90
+ 'Content-Type': 'application/json',
91
+ 'X-Firebase-Auth': `Bearer ${idToken}`,
92
+ },
93
+ body: JSON.stringify({
94
+ model: modelName,
95
+ contents,
96
+ tools,
97
+ toolConfig,
98
+ systemInstruction,
99
+ generationConfig,
100
+ }),
101
+ signal: abortSignal,
102
+ });
103
+ debugLog(`Proxy Request to ${modelName} complete. Status: ${response.status} ${response.statusText}`);
104
+ if ((response.status === 429 || response.status === 503) && attempt < MAX_RETRIES) {
105
+ const exponentialDelay = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * Math.pow(2, attempt));
106
+ const delayTime = Math.round(exponentialDelay * (0.5 + Math.random() * 0.5));
107
+ console.warn(`Rate limit or service unavailable hit (${response.status}). Retrying in ${(delayTime / 1000).toFixed(1)}s... (Attempt ${attempt + 1}/${MAX_RETRIES})`);
108
+ await delay(delayTime, abortSignal);
109
+ attempt++;
110
+ continue;
111
+ }
112
+ if (response.status === 401) {
113
+ let details = '';
69
114
  try {
70
- const errorData = JSON.parse(text);
71
- details = errorData.details || errorData.error || text;
115
+ const text = await response.text();
116
+ try {
117
+ const errorData = JSON.parse(text);
118
+ details = errorData.details || errorData.error || text;
119
+ }
120
+ catch (e) {
121
+ details = text;
122
+ }
72
123
  }
73
124
  catch (e) {
74
- details = text;
125
+ details = 'Unknown error reading body';
75
126
  }
127
+ throw new Error(`Authentication failed: ${details}. Please login again.`);
76
128
  }
77
- catch (e) {
78
- details = 'Unknown error reading body';
129
+ if (response.status === 402) {
130
+ throw new Error('Insufficient credits. Please visit minovativemind.dev to purchase more credits.');
79
131
  }
80
- throw new Error(`Authentication failed: ${details}. Please login again.`);
81
- }
82
- if (response.status === 402) {
83
- throw new Error('Insufficient credits. Please visit minovativemind.dev to purchase more credits.');
84
- }
85
- if (!response.ok) {
86
- const errorData = await response.json().catch(() => ({}));
87
- throw new Error(`Proxy error ${response.status}: ${errorData.error || response.statusText}`);
88
- }
89
- if (!response.body) {
90
- throw new Error('No response body received from proxy');
91
- }
92
- // Node 18+ fetch body is a ReadableStream which is async iterable but type might mismatch.
93
- // Let's read chunks manually
94
- const reader = response.body.getReader();
95
- const decoder = new TextDecoder();
96
- let buffer = '';
97
- let functionCall = null;
98
- const functionCalls = [];
99
- let thought = '';
100
- let parts = undefined;
101
- let usageMetadata = undefined;
102
- let groundingMetadata = undefined;
103
- try {
104
- while (true) {
105
- const { done, value } = await reader.read();
106
- if (done)
107
- break;
108
- buffer += decoder.decode(value, { stream: true });
109
- const lines = buffer.split('\n\n');
110
- buffer = lines.pop() || '';
111
- for (const line of lines) {
112
- if (!line.startsWith('data: '))
113
- continue;
114
- const dataStr = line.slice(6);
115
- try {
116
- const data = JSON.parse(dataStr);
117
- if (data.type === 'functionCall' && data.functionCall) {
118
- functionCall = data.functionCall;
119
- functionCalls.push(data.functionCall);
120
- }
121
- else if (data.type === 'thought' && data.thought) {
122
- thought += data.thought;
123
- if (streamCallbacks?.onChunk)
124
- streamCallbacks.onChunk(data.thought);
125
- }
126
- else if (data.type === 'chunk' && data.text) {
127
- thought += data.text;
128
- if (streamCallbacks?.onChunk)
129
- streamCallbacks.onChunk(data.text);
130
- }
131
- else if (data.type === 'parts' && data.parts) {
132
- parts = data.parts;
133
- }
134
- else if (data.type === 'done') {
135
- if (data.usage) {
136
- usageMetadata = data.usage;
137
- globalSessionAccumulatedUsage.promptTokens += data.usage.promptTokens || 0;
138
- globalSessionAccumulatedUsage.candidatesTokens += data.usage.candidatesTokens || 0;
139
- globalSessionAccumulatedUsage.cachedTokens += data.usage.cachedTokens || 0;
140
- globalSessionAccumulatedUsage.creditsUsed += data.usage.creditsUsed || 0;
141
- globalSessionAccumulatedUsage.totalTokenCount +=
142
- (data.usage.promptTokens || 0) + (data.usage.cachedTokens || 0) + (data.usage.candidatesTokens || 0);
143
- if (data.usage.remainingBalance !== undefined) {
144
- globalSessionAccumulatedUsage.remainingBalance = data.usage.remainingBalance;
132
+ if (!response.ok) {
133
+ const errorData = await response.json().catch(() => ({}));
134
+ throw new Error(`Proxy error ${response.status}: ${errorData.error || response.statusText}`);
135
+ }
136
+ if (!response.body) {
137
+ throw new Error('No response body received from proxy');
138
+ }
139
+ // Node 18+ fetch body is a ReadableStream which is async iterable but type might mismatch.
140
+ // Let's read chunks manually
141
+ const reader = response.body.getReader();
142
+ const decoder = new TextDecoder();
143
+ let buffer = '';
144
+ let functionCall = null;
145
+ const functionCalls = [];
146
+ let thought = '';
147
+ let parts = undefined;
148
+ let usageMetadata = undefined;
149
+ let groundingMetadata = undefined;
150
+ try {
151
+ while (true) {
152
+ const { done, value } = await reader.read();
153
+ if (done)
154
+ break;
155
+ buffer += decoder.decode(value, { stream: true });
156
+ const lines = buffer.split('\n\n');
157
+ buffer = lines.pop() || '';
158
+ for (const line of lines) {
159
+ if (!line.startsWith('data: '))
160
+ continue;
161
+ const dataStr = line.slice(6);
162
+ try {
163
+ const data = JSON.parse(dataStr);
164
+ if (data.type === 'functionCall' && data.functionCall) {
165
+ functionCall = data.functionCall;
166
+ functionCalls.push(data.functionCall);
167
+ }
168
+ else if (data.type === 'thought' && data.thought) {
169
+ thought += data.thought;
170
+ if (streamCallbacks?.onChunk)
171
+ streamCallbacks.onChunk(data.thought);
172
+ }
173
+ else if (data.type === 'chunk' && data.text) {
174
+ thought += data.text;
175
+ if (streamCallbacks?.onChunk)
176
+ streamCallbacks.onChunk(data.text);
177
+ }
178
+ else if (data.type === 'parts' && data.parts) {
179
+ parts = data.parts;
180
+ }
181
+ else if (data.type === 'done') {
182
+ if (data.usage) {
183
+ usageMetadata = data.usage;
184
+ globalSessionAccumulatedUsage.promptTokens += data.usage.promptTokens || 0;
185
+ globalSessionAccumulatedUsage.candidatesTokens += data.usage.candidatesTokens || 0;
186
+ globalSessionAccumulatedUsage.cachedTokens += data.usage.cachedTokens || 0;
187
+ globalSessionAccumulatedUsage.creditsUsed += data.usage.creditsUsed || 0;
188
+ globalSessionAccumulatedUsage.totalTokenCount +=
189
+ (data.usage.promptTokens || 0) + (data.usage.cachedTokens || 0) + (data.usage.candidatesTokens || 0);
190
+ if (data.usage.remainingBalance !== undefined) {
191
+ globalSessionAccumulatedUsage.remainingBalance = data.usage.remainingBalance;
192
+ }
193
+ }
194
+ if (data.groundingMetadata) {
195
+ groundingMetadata = data.groundingMetadata;
145
196
  }
146
197
  }
147
- if (data.groundingMetadata) {
148
- groundingMetadata = data.groundingMetadata;
198
+ else if (data.type === 'error') {
199
+ throw new Error(`Proxy generation error: ${data.message}`);
149
200
  }
150
201
  }
151
- else if (data.type === 'error') {
152
- throw new Error(`Proxy generation error: ${data.message}`);
202
+ catch (parseError) {
203
+ if (parseError.message && parseError.message.startsWith('Proxy generation error:')) {
204
+ throw parseError;
205
+ }
206
+ debugLog(`Failed to parse SSE data: ${dataStr} - Error: ${parseError.message || parseError}`);
153
207
  }
154
208
  }
155
- catch (parseError) {
156
- if (parseError.message && parseError.message.startsWith('Proxy generation error:')) {
157
- throw parseError;
158
- }
159
- debugLog(`Failed to parse SSE data: ${dataStr} - Error: ${parseError.message || parseError}`);
209
+ }
210
+ }
211
+ catch (streamError) {
212
+ if (streamError.message?.includes('429') ||
213
+ streamError.message?.includes('503') ||
214
+ streamError.message?.includes('RESOURCE_EXHAUSTED') ||
215
+ streamError.message?.includes('Too Many Requests')) {
216
+ if (attempt < MAX_RETRIES) {
217
+ const exponentialDelay = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * Math.pow(2, attempt));
218
+ const delayTime = Math.round(exponentialDelay * (0.5 + Math.random() * 0.5));
219
+ console.warn(`Rate limit or service unavailable hit during stream. Retrying in ${(delayTime / 1000).toFixed(1)}s... (Attempt ${attempt + 1}/${MAX_RETRIES})`);
220
+ await delay(delayTime, abortSignal);
221
+ attempt++;
222
+ continue retryLoop;
160
223
  }
161
224
  }
225
+ throw streamError;
162
226
  }
227
+ finally {
228
+ reader.releaseLock();
229
+ }
230
+ return {
231
+ functionCall,
232
+ functionCalls,
233
+ thought: thought.trim(),
234
+ parts,
235
+ usageMetadata,
236
+ groundingMetadata,
237
+ };
163
238
  }
164
- finally {
165
- reader.releaseLock();
166
- }
167
- return {
168
- functionCall,
169
- functionCalls,
170
- thought: thought.trim(),
171
- parts,
172
- usageMetadata,
173
- groundingMetadata,
174
- };
175
239
  }
176
240
  }
@@ -8,6 +8,17 @@ export interface RegisteredWorkspace {
8
8
  absolutePath: string;
9
9
  /** Unix epoch timestamp (ms) when this workspace was registered. */
10
10
  registeredAt: number;
11
+ /** The profile (main workspace) this workspace belongs to. */
12
+ profile: string;
13
+ }
14
+ /**
15
+ * Represents a Profile (Main Workspace) that groups multiple registered workspaces.
16
+ */
17
+ export interface Profile {
18
+ /** Unique name of the profile. */
19
+ name: string;
20
+ /** Unix epoch timestamp (ms) when this profile was created. */
21
+ createdAt: number;
11
22
  }
12
23
  /**
13
24
  * Result of resolving an `@alias/relative/path` string against the workspace registry.
@@ -39,6 +50,8 @@ export interface ResolvedWorkspacePath {
39
50
  declare class WorkspaceRegistry {
40
51
  /** In-memory map of alias → registered workspace. */
41
52
  private workspaces;
53
+ /** In-memory map of profile name → Profile. */
54
+ private profiles;
42
55
  /** Whether the registry has been loaded from disk. */
43
56
  private initialized;
44
57
  /**
@@ -47,8 +60,9 @@ declare class WorkspaceRegistry {
47
60
  */
48
61
  init(): void;
49
62
  /**
50
- * Registers a new external workspace root with the given alias.
63
+ * Registers a new external workspace root with the given alias under a profile.
51
64
  *
65
+ * @param profile - The profile (main workspace) name.
52
66
  * @param alias - Short identifier for the workspace (e.g., "backend").
53
67
  * Must be lowercase alphanumeric with hyphens/underscores, 1–30 chars.
54
68
  * @param absolutePath - Absolute path to the workspace root directory.
@@ -56,7 +70,7 @@ declare class WorkspaceRegistry {
56
70
  * @returns The created `RegisteredWorkspace`, or throws on validation failure.
57
71
  * @throws Error if the alias is invalid, the path doesn't exist, or the alias is already taken.
58
72
  */
59
- register(alias: string, absolutePath: string): RegisteredWorkspace;
73
+ register(profile: string, alias: string, absolutePath: string): RegisteredWorkspace;
60
74
  /**
61
75
  * Removes a registered workspace by alias.
62
76
  *
@@ -69,13 +83,21 @@ declare class WorkspaceRegistry {
69
83
  */
70
84
  list(): RegisteredWorkspace[];
71
85
  /**
72
- * Returns the number of registered workspaces.
86
+ * Returns all profiles (main workspaces).
73
87
  */
74
- get size(): number;
88
+ listProfiles(): Profile[];
75
89
  /**
76
- * Checks whether any external workspaces are registered.
90
+ * Checks whether any profiles (main workspaces) exist.
91
+ */
92
+ hasProfiles(): boolean;
93
+ /**
94
+ * Checks whether any workspaces exist.
77
95
  */
78
96
  hasWorkspaces(): boolean;
97
+ /**
98
+ * Returns all registered workspaces for a given profile.
99
+ */
100
+ getWorkspacesByProfile(profile: string): RegisteredWorkspace[];
79
101
  /**
80
102
  * Looks up a workspace by alias.
81
103
  *
@@ -24,6 +24,8 @@ const REGISTRY_FILE = path.join(GLOBAL_CONFIG_DIR, 'workspaces.json');
24
24
  class WorkspaceRegistry {
25
25
  /** In-memory map of alias → registered workspace. */
26
26
  workspaces = new Map();
27
+ /** In-memory map of profile name → Profile. */
28
+ profiles = new Map();
27
29
  /** Whether the registry has been loaded from disk. */
28
30
  initialized = false;
29
31
  /**
@@ -37,8 +39,9 @@ class WorkspaceRegistry {
37
39
  this.loadFromDisk();
38
40
  }
39
41
  /**
40
- * Registers a new external workspace root with the given alias.
42
+ * Registers a new external workspace root with the given alias under a profile.
41
43
  *
44
+ * @param profile - The profile (main workspace) name.
42
45
  * @param alias - Short identifier for the workspace (e.g., "backend").
43
46
  * Must be lowercase alphanumeric with hyphens/underscores, 1–30 chars.
44
47
  * @param absolutePath - Absolute path to the workspace root directory.
@@ -46,8 +49,16 @@ class WorkspaceRegistry {
46
49
  * @returns The created `RegisteredWorkspace`, or throws on validation failure.
47
50
  * @throws Error if the alias is invalid, the path doesn't exist, or the alias is already taken.
48
51
  */
49
- register(alias, absolutePath) {
52
+ register(profile, alias, absolutePath) {
53
+ const normalizedProfile = profile.toLowerCase().trim();
50
54
  const normalizedAlias = alias.toLowerCase().trim();
55
+ // Ensure profile exists
56
+ if (!this.profiles.has(normalizedProfile)) {
57
+ this.profiles.set(normalizedProfile, {
58
+ name: normalizedProfile,
59
+ createdAt: Date.now(),
60
+ });
61
+ }
51
62
  // Validate alias format
52
63
  if (!ALIAS_PATTERN.test(normalizedAlias)) {
53
64
  throw new Error(`Invalid workspace alias "${normalizedAlias}". Must be 1-30 characters, ` +
@@ -85,10 +96,11 @@ class WorkspaceRegistry {
85
96
  alias: normalizedAlias,
86
97
  absolutePath: normalizedPath,
87
98
  registeredAt: Date.now(),
99
+ profile: normalizedProfile,
88
100
  };
89
101
  this.workspaces.set(normalizedAlias, workspace);
90
102
  this.saveToDisk();
91
- debugLog(`Registered workspace: @${normalizedAlias} → ${normalizedPath}`);
103
+ debugLog(`Registered workspace: @${normalizedAlias} → ${normalizedPath} (Profile: ${normalizedProfile})`);
92
104
  return workspace;
93
105
  }
94
106
  /**
@@ -113,17 +125,29 @@ class WorkspaceRegistry {
113
125
  return Array.from(this.workspaces.values()).sort((a, b) => a.alias.localeCompare(b.alias));
114
126
  }
115
127
  /**
116
- * Returns the number of registered workspaces.
128
+ * Returns all profiles (main workspaces).
117
129
  */
118
- get size() {
119
- return this.workspaces.size;
130
+ listProfiles() {
131
+ return Array.from(this.profiles.values()).sort((a, b) => a.name.localeCompare(b.name));
120
132
  }
121
133
  /**
122
- * Checks whether any external workspaces are registered.
134
+ * Checks whether any profiles (main workspaces) exist.
135
+ */
136
+ hasProfiles() {
137
+ return this.profiles.size > 0;
138
+ }
139
+ /**
140
+ * Checks whether any workspaces exist.
123
141
  */
124
142
  hasWorkspaces() {
125
143
  return this.workspaces.size > 0;
126
144
  }
145
+ /**
146
+ * Returns all registered workspaces for a given profile.
147
+ */
148
+ getWorkspacesByProfile(profile) {
149
+ return this.list().filter((ws) => ws.profile === profile.toLowerCase().trim());
150
+ }
127
151
  /**
128
152
  * Looks up a workspace by alias.
129
153
  *
@@ -216,12 +240,31 @@ class WorkspaceRegistry {
216
240
  }
217
241
  const raw = fs.readFileSync(REGISTRY_FILE, 'utf-8');
218
242
  const data = JSON.parse(raw);
219
- if (!Array.isArray(data)) {
243
+ if (!data || !Array.isArray(data.workspaces)) {
244
+ // Fallback for old schema
245
+ const oldData = JSON.parse(raw);
246
+ if (Array.isArray(oldData)) {
247
+ for (const entry of oldData) {
248
+ if (typeof entry.alias === 'string' &&
249
+ typeof entry.absolutePath === 'string' &&
250
+ ALIAS_PATTERN.test(entry.alias)) {
251
+ if (fs.existsSync(entry.absolutePath)) {
252
+ const profile = 'default';
253
+ this.workspaces.set(entry.alias, { ...entry, profile });
254
+ if (!this.profiles.has(profile)) {
255
+ this.profiles.set(profile, { name: profile, createdAt: Date.now() });
256
+ }
257
+ }
258
+ }
259
+ }
260
+ debugLog(`Migrated ${this.workspaces.size} workspace(s) from old schema.`);
261
+ return;
262
+ }
220
263
  debugLog('Workspace registry file has invalid format, starting fresh.');
221
264
  return;
222
265
  }
223
266
  // Validate each entry and skip invalid ones
224
- for (const entry of data) {
267
+ for (const entry of data.workspaces) {
225
268
  if (typeof entry.alias === 'string' &&
226
269
  typeof entry.absolutePath === 'string' &&
227
270
  ALIAS_PATTERN.test(entry.alias)) {
@@ -234,7 +277,10 @@ class WorkspaceRegistry {
234
277
  }
235
278
  }
236
279
  }
237
- debugLog(`Loaded ${this.workspaces.size} workspace(s) from global registry.`);
280
+ for (const profile of data.profiles) {
281
+ this.profiles.set(profile.name, profile);
282
+ }
283
+ debugLog(`Loaded ${this.workspaces.size} workspace(s) and ${this.profiles.size} profile(s) from global registry.`);
238
284
  }
239
285
  catch (err) {
240
286
  debugLog(`Failed to load workspace registry: ${err instanceof Error ? err.message : String(err)}`);
@@ -250,12 +296,15 @@ class WorkspaceRegistry {
250
296
  if (!fs.existsSync(GLOBAL_CONFIG_DIR)) {
251
297
  fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
252
298
  }
253
- const data = Array.from(this.workspaces.values());
299
+ const data = {
300
+ workspaces: Array.from(this.workspaces.values()),
301
+ profiles: Array.from(this.profiles.values()),
302
+ };
254
303
  const tempPath = `${REGISTRY_FILE}.${Date.now()}.tmp`;
255
304
  fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), 'utf-8');
256
305
  // Atomic rename to prevent corruption
257
306
  fs.renameSync(tempPath, REGISTRY_FILE);
258
- debugLog(`Saved ${data.length} workspace(s) to global registry.`);
307
+ debugLog(`Saved ${data.workspaces.length} workspace(s) and ${data.profiles.length} profile(s) to global registry.`);
259
308
  }
260
309
  catch (err) {
261
310
  debugLog(`Failed to save workspace registry: ${err instanceof Error ? err.message : String(err)}`);
@@ -65,5 +65,5 @@
65
65
  ]
66
66
  }
67
67
  },
68
- "version": "2.3.0"
68
+ "version": "2.3.2"
69
69
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "minovative-mind-cli",
3
3
  "description": "An automated AI agent powered by Vertex AI that helps you write software",
4
- "version": "2.3.0",
4
+ "version": "2.3.2",
5
5
  "author": "Daniel Ward",
6
6
  "bin": {
7
7
  "minovative-mind-cli": "bin/run.js"