@mrpatronz/nexusflow 0.2.7 → 0.2.11

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.
Files changed (99) hide show
  1. package/.github/workflows/release.yml +7 -0
  2. package/dist/commands/create.d.ts.map +1 -1
  3. package/dist/commands/create.js +6 -1
  4. package/dist/commands/create.js.map +1 -1
  5. package/dist/commands/doctor.d.ts.map +1 -1
  6. package/dist/commands/doctor.js +58 -0
  7. package/dist/commands/doctor.js.map +1 -1
  8. package/dist/commands/init.d.ts.map +1 -1
  9. package/dist/commands/init.js +73 -1
  10. package/dist/commands/init.js.map +1 -1
  11. package/dist/core/config.d.ts.map +1 -1
  12. package/dist/core/config.js +11 -1
  13. package/dist/core/config.js.map +1 -1
  14. package/dist/core/config.test.d.ts +2 -0
  15. package/dist/core/config.test.d.ts.map +1 -0
  16. package/dist/core/config.test.js +75 -0
  17. package/dist/core/config.test.js.map +1 -0
  18. package/dist/core/scanner.test.d.ts +2 -0
  19. package/dist/core/scanner.test.d.ts.map +1 -0
  20. package/dist/core/scanner.test.js +53 -0
  21. package/dist/core/scanner.test.js.map +1 -0
  22. package/dist/generators/base.d.ts.map +1 -1
  23. package/dist/generators/base.js +8 -2
  24. package/dist/generators/base.js.map +1 -1
  25. package/dist/generators/skills-generator.js +1 -1
  26. package/dist/generators/skills-generator.js.map +1 -1
  27. package/dist/gui/assets/index-Ca2VRMrQ.js +22 -0
  28. package/dist/gui/assets/index-fj0RJiOb.css +2 -0
  29. package/dist/gui/index.html +2 -2
  30. package/dist/mcp/server.d.ts.map +1 -1
  31. package/dist/mcp/server.js +132 -93
  32. package/dist/mcp/server.js.map +1 -1
  33. package/dist/server.d.ts.map +1 -1
  34. package/dist/server.js +134 -12
  35. package/dist/server.js.map +1 -1
  36. package/dist/server.test.d.ts +2 -0
  37. package/dist/server.test.d.ts.map +1 -0
  38. package/dist/server.test.js +401 -0
  39. package/dist/server.test.js.map +1 -0
  40. package/dist/types.d.ts +12 -0
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/utils/detect-ai.test.d.ts +2 -0
  43. package/dist/utils/detect-ai.test.d.ts.map +1 -0
  44. package/dist/utils/detect-ai.test.js +37 -0
  45. package/dist/utils/detect-ai.test.js.map +1 -0
  46. package/dist/utils/detect-editors.test.d.ts +2 -0
  47. package/dist/utils/detect-editors.test.d.ts.map +1 -0
  48. package/dist/utils/detect-editors.test.js +39 -0
  49. package/dist/utils/detect-editors.test.js.map +1 -0
  50. package/dist/utils/git.test.d.ts +2 -0
  51. package/dist/utils/git.test.d.ts.map +1 -0
  52. package/dist/utils/git.test.js +55 -0
  53. package/dist/utils/git.test.js.map +1 -0
  54. package/dist/utils/local-ai.d.ts +27 -0
  55. package/dist/utils/local-ai.d.ts.map +1 -0
  56. package/dist/utils/local-ai.js +96 -0
  57. package/dist/utils/local-ai.js.map +1 -0
  58. package/dist/utils/local-ai.test.d.ts +2 -0
  59. package/dist/utils/local-ai.test.d.ts.map +1 -0
  60. package/dist/utils/local-ai.test.js +108 -0
  61. package/dist/utils/local-ai.test.js.map +1 -0
  62. package/dist/utils/system-scanner.d.ts +16 -0
  63. package/dist/utils/system-scanner.d.ts.map +1 -0
  64. package/dist/utils/system-scanner.js +85 -0
  65. package/dist/utils/system-scanner.js.map +1 -0
  66. package/dist/utils/system-scanner.test.d.ts +2 -0
  67. package/dist/utils/system-scanner.test.d.ts.map +1 -0
  68. package/dist/utils/system-scanner.test.js +73 -0
  69. package/dist/utils/system-scanner.test.js.map +1 -0
  70. package/gui/e2e/wizard.spec.ts +327 -0
  71. package/gui/package-lock.json +64 -0
  72. package/gui/package.json +4 -1
  73. package/gui/playwright.config.ts +41 -0
  74. package/gui/src/App.tsx +376 -786
  75. package/gui/src/index.css +16 -0
  76. package/package.json +3 -1
  77. package/scripts/simulate-workspaces.ts +55 -0
  78. package/src/commands/create.ts +7 -1
  79. package/src/commands/doctor.ts +56 -0
  80. package/src/commands/init.ts +77 -1
  81. package/src/core/config.test.ts +96 -0
  82. package/src/core/config.ts +11 -1
  83. package/src/core/scanner.test.ts +61 -0
  84. package/src/generators/base.ts +13 -2
  85. package/src/generators/skills-generator.ts +1 -1
  86. package/src/mcp/server.ts +144 -95
  87. package/src/server.test.ts +464 -0
  88. package/src/server.ts +141 -11
  89. package/src/types.ts +16 -0
  90. package/src/utils/detect-ai.test.ts +44 -0
  91. package/src/utils/detect-editors.test.ts +47 -0
  92. package/src/utils/git.test.ts +74 -0
  93. package/src/utils/local-ai.test.ts +130 -0
  94. package/src/utils/local-ai.ts +111 -0
  95. package/src/utils/system-scanner.test.ts +89 -0
  96. package/src/utils/system-scanner.ts +96 -0
  97. package/vitest.config.ts +2 -1
  98. package/dist/gui/assets/index-BgEo5w2Q.js +0 -22
  99. package/dist/gui/assets/index-D-VigurY.css +0 -2
package/gui/src/index.css CHANGED
@@ -1 +1,17 @@
1
1
  @import "tailwindcss";
2
+
3
+ @utility animate-slide-in {
4
+ animation: slide-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
5
+ }
6
+
7
+ @keyframes slide-in {
8
+ from {
9
+ transform: translateY(1rem);
10
+ opacity: 0;
11
+ }
12
+ to {
13
+ transform: translateY(0);
14
+ opacity: 1;
15
+ }
16
+ }
17
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrpatronz/nexusflow",
3
- "version": "0.2.7",
3
+ "version": "0.2.11",
4
4
  "description": "Combine multiple repos into a workspace with rich AI assistant context",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -11,8 +11,10 @@
11
11
  "build": "tsc && npm run build --prefix gui && npm run extension:compile",
12
12
  "dev": "tsc --watch",
13
13
  "start": "node dist/index.js",
14
+ "ui": "node dist/index.js ui",
14
15
  "test": "vitest run --passWithNoTests",
15
16
  "test:watch": "vitest",
17
+ "test:gui": "npm run test:e2e --prefix gui",
16
18
  "clean": "rimraf dist",
17
19
  "prepublishOnly": "npm run build",
18
20
  "extension:install": "npm install --prefix extension",
@@ -0,0 +1,55 @@
1
+ import * as path from 'node:path';
2
+ import * as os from 'node:os';
3
+ import { loadConfig } from '../src/core/config.js';
4
+ import { createWorkspace } from '../src/core/workspace.js';
5
+ import { analyzeAllRepos } from '../src/analyzers/index.js';
6
+ import { generateContextFiles } from '../src/generators/index.js';
7
+
8
+ async function main() {
9
+ const config = await loadConfig();
10
+ const repoPath = 'C:\\Users\\patro\\dev\\easyagent_container';
11
+
12
+ console.log('Creating Workspace 1: feat/rate-limiting-standard (Local LLM DISABLED)');
13
+ const ws1Path = path.join(config.workspacesDir, 'feat/rate-limiting-standard');
14
+ const feature1 = {
15
+ id: 'feat/rate-limiting-standard',
16
+ branchName: 'feat/rate-limiting-standard',
17
+ description: 'Implement rate limiting and connection pooling',
18
+ repos: [repoPath],
19
+ assistants: ['claude'],
20
+ workspacePath: ws1Path,
21
+ createdAt: new Date().toISOString(),
22
+ localLlmEnabled: false,
23
+ };
24
+ const repos1 = [{ name: 'easyagent_container', path: path.join(ws1Path, 'easyagent_container'), isGitRepo: true, defaultBranch: 'master' }];
25
+
26
+ await createWorkspace(feature1, [{ name: 'easyagent_container', path: repoPath, isGitRepo: true, defaultBranch: 'master' }]);
27
+ const analysis1 = await analyzeAllRepos(repos1);
28
+ const ctx1 = { feature: feature1, repos: repos1, analysis: analysis1 };
29
+ await generateContextFiles(ctx1, ['claude'], ws1Path);
30
+ console.log('✅ Workspace 1 created at:', ws1Path);
31
+
32
+ console.log('\nCreating Workspace 2: feat/rate-limiting-local-llm (Local LLM ENABLED)');
33
+ const ws2Path = path.join(config.workspacesDir, 'feat/rate-limiting-local-llm');
34
+ const feature2 = {
35
+ id: 'feat/rate-limiting-local-llm',
36
+ branchName: 'feat/rate-limiting-local-llm',
37
+ description: 'Implement rate limiting and connection pooling',
38
+ repos: [repoPath],
39
+ assistants: ['claude'],
40
+ workspacePath: ws2Path,
41
+ createdAt: new Date().toISOString(),
42
+ localLlmEnabled: true,
43
+ };
44
+ const repos2 = [{ name: 'easyagent_container', path: path.join(ws2Path, 'easyagent_container'), isGitRepo: true, defaultBranch: 'master' }];
45
+
46
+ await createWorkspace(feature2, [{ name: 'easyagent_container', path: repoPath, isGitRepo: true, defaultBranch: 'master' }]);
47
+ const analysis2 = await analyzeAllRepos(repos2);
48
+ const ctx2 = { feature: feature2, repos: repos2, analysis: analysis2, localLlm: config.localLlm };
49
+ await generateContextFiles(ctx2, ['claude'], ws2Path);
50
+ console.log('✅ Workspace 2 created at:', ws2Path);
51
+
52
+ console.log('\nBoth test workspaces are ready!');
53
+ }
54
+
55
+ main().catch(console.error);
@@ -82,6 +82,10 @@ export async function createCommand(): Promise<void> {
82
82
  const detectedAI = await detectAIAssistants();
83
83
  const selectedAI = await promptSelectAI(detectedAI);
84
84
 
85
+ const localLlmEnabled = config.localLlm?.enabled
86
+ ? await confirm({ message: 'Enable Local AI Co-processor in this workspace context?', default: true })
87
+ : false;
88
+
85
89
  // ── 6. Create workspace ─────────────────────────────────────────────
86
90
  const workspacePath = path.join(config.workspacesDir, branchName);
87
91
  const feature: Feature = {
@@ -93,6 +97,7 @@ export async function createCommand(): Promise<void> {
93
97
  assistants: selectedAI,
94
98
  workspacePath,
95
99
  createdAt: new Date().toISOString(),
100
+ localLlmEnabled,
96
101
  };
97
102
 
98
103
  const wsSpinner = ora('Creating workspace with git worktrees...').start();
@@ -115,7 +120,7 @@ export async function createCommand(): Promise<void> {
115
120
  const analysis = await analyzeAllRepos(workspaceRepos);
116
121
 
117
122
  // ── 8. Generate AI context files ────────────────────────────────────
118
- const ctx: WorkspaceContext = { feature, repos: workspaceRepos, analysis };
123
+ const ctx: WorkspaceContext = { feature, repos: workspaceRepos, analysis, localLlm: config.localLlm };
119
124
  console.log(chalk.cyan('\nGenerating AI context files...'));
120
125
  await generateContextFiles(ctx, selectedAI, workspacePath);
121
126
 
@@ -160,6 +165,7 @@ export async function createCommand(): Promise<void> {
160
165
  console.log(` ${chalk.dim('Branch:')} ${branchName}`);
161
166
  console.log(` ${chalk.dim('Repos:')} ${selectedRepos.map((r) => r.name).join(', ')}`);
162
167
  console.log(` ${chalk.dim('AI:')} ${selectedAI.join(', ')}`);
168
+ console.log(` ${chalk.dim('Local AI:')} ${localLlmEnabled ? 'Enabled' : 'Disabled'}`);
163
169
  console.log(
164
170
  `\n ${chalk.dim('To navigate:')} cd "${workspacePath}"`,
165
171
  );
@@ -9,6 +9,7 @@ import { listWorkspaces, loadFeatureConfig } from '../core/workspace.js';
9
9
  import { getRepoStatus } from '../utils/multi-git.js';
10
10
  import { analyzeAllRepos } from '../analyzers/index.js';
11
11
  import { globby } from 'globby';
12
+ import { isOllamaModelAvailable, getOpenAiCompatibleUrl } from '../utils/local-ai.js';
12
13
 
13
14
  /**
14
15
  * Runs the doctor command to diagnose workspace state.
@@ -227,6 +228,61 @@ export async function doctorCommand(workspaceArg?: string): Promise<void> {
227
228
  }
228
229
  console.log();
229
230
 
231
+ // ── 7. Local AI Agent Connection Check ─────────────────────────────────
232
+ console.log(chalk.bold('🤖 Local AI Agent:'));
233
+ const config = await loadConfig();
234
+ if (config.localLlm?.enabled) {
235
+ const { provider, endpoint, model } = config.localLlm;
236
+ console.log(` Provider: ${provider}`);
237
+ console.log(` Endpoint: ${endpoint}`);
238
+ console.log(` Model: ${model}`);
239
+
240
+ try {
241
+ const cleanEndpoint = endpoint.replace(/\/$/, '');
242
+ const controller = new AbortController();
243
+ const timeout = setTimeout(() => controller.abort(), 5000);
244
+
245
+ try {
246
+ if (provider === 'ollama') {
247
+ const res = await fetch(`${cleanEndpoint}/api/tags`, { signal: controller.signal });
248
+ if (!res.ok) {
249
+ throw new Error(`Ollama responded with status ${res.status}`);
250
+ }
251
+ const data: any = await res.json();
252
+ const models = data?.models || [];
253
+ const isModelLoaded = isOllamaModelAvailable(models, model);
254
+
255
+ if (isModelLoaded) {
256
+ console.log(` ${chalk.green('✔')} Ollama service is active and model "${model}" is ready`);
257
+ } else {
258
+ const availableList = models.map((m: any) => m.name).join(', ') || 'none';
259
+ warnings.push(`Local model "${model}" is not pulled in Ollama. Available: [${availableList}]. Run "ollama pull ${model}" to install it.`);
260
+ console.log(` ${chalk.yellow('⚠')} Ollama service is active, but model "${model}" is not pulled`);
261
+ }
262
+ } else {
263
+ // OpenAI-compatible endpoint ping
264
+ const testUrl = getOpenAiCompatibleUrl(cleanEndpoint, '/v1/models');
265
+ const res = await fetch(testUrl, { signal: controller.signal });
266
+ if (!res.ok) {
267
+ throw new Error(`OpenAI-compatible server responded with status ${res.status}`);
268
+ }
269
+ console.log(` ${chalk.green('✔')} OpenAI-compatible server at "${endpoint}" is active`);
270
+ }
271
+ } catch (e: any) {
272
+ const errorMsg = e.name === 'AbortError' ? 'Request timed out after 5 seconds' : e.message;
273
+ throw new Error(errorMsg);
274
+ } finally {
275
+ clearTimeout(timeout);
276
+ }
277
+ } catch (e: any) {
278
+ warnings.push(`Local LLM server at "${endpoint}" is offline or unreachable: ${e.message}`);
279
+ console.log(` ${chalk.yellow('⚠')} Local LLM server is offline or unreachable (${e.message})`);
280
+ }
281
+ } else {
282
+ console.log(` ${chalk.dim('Local AI agent is disabled in config.')}`);
283
+ }
284
+ console.log();
285
+
230
286
  // ── Summary Report ────────────────────────────────────────────────────
231
287
  console.log(chalk.bold('📊 Diagnostic Summary:'));
232
288
  if (errors.length === 0 && warnings.length === 0) {
@@ -4,11 +4,12 @@
4
4
  */
5
5
 
6
6
  import chalk from 'chalk';
7
- import { input } from '@inquirer/prompts';
7
+ import { input, confirm, select } from '@inquirer/prompts';
8
8
  import path from 'node:path';
9
9
  import os from 'node:os';
10
10
 
11
11
  import { loadConfig, saveConfig, ensureConfigDir } from '../core/config.js';
12
+ import { scanSystemSpecs } from '../utils/system-scanner.js';
12
13
 
13
14
  /**
14
15
  * Initializes NexusFlow configuration interactively.
@@ -20,6 +21,7 @@ export async function initCommand(): Promise<void> {
20
21
  await ensureConfigDir();
21
22
  const existing = await loadConfig();
22
23
 
24
+ // ── 1. Basic Directories ──────────────────────────────────────────────
23
25
  const devDir = await input({
24
26
  message: 'Development directory (where your repos live):',
25
27
  default: existing.devDir || path.join(os.homedir(), 'dev'),
@@ -40,11 +42,84 @@ export async function initCommand(): Promise<void> {
40
42
  },
41
43
  });
42
44
 
45
+ // ── 2. Local AI Scanner & Config ──────────────────────────────────────
46
+ console.log(chalk.cyan('\nProbing system hardware for local AI capabilities...'));
47
+ let specs;
48
+ try {
49
+ specs = await scanSystemSpecs();
50
+ } catch (error) {
51
+ specs = {
52
+ totalRamGb: 8,
53
+ gpuName: 'Unknown/Integrated',
54
+ hasHardwareAcceleration: false,
55
+ recommendedModel: 'qwen2.5-coder:1.5b',
56
+ };
57
+ }
58
+ console.log(chalk.dim(` RAM Detected: ${specs.totalRamGb} GB`));
59
+ console.log(chalk.dim(` GPU Detected: ${specs.gpuName}`));
60
+ console.log(chalk.dim(` Recommended model: ${chalk.bold(specs.recommendedModel)}`));
61
+
62
+ const enableLlm = await confirm({
63
+ message: 'Enable Local AI Co-processor (Ollama/LM Studio)?',
64
+ default: existing.localLlm?.enabled ?? false,
65
+ });
66
+
67
+ let localLlmConfig = existing.localLlm || {
68
+ enabled: false,
69
+ provider: 'ollama',
70
+ endpoint: 'http://localhost:11434',
71
+ model: specs.recommendedModel,
72
+ };
73
+
74
+ if (enableLlm) {
75
+ const provider = await select({
76
+ message: 'Local AI Provider:',
77
+ choices: [
78
+ { name: 'Ollama', value: 'ollama' as const },
79
+ { name: 'OpenAI-Compatible (e.g. LM Studio, Llama.cpp)', value: 'openai-compatible' as const },
80
+ ],
81
+ default: existing.localLlm?.provider || 'ollama',
82
+ });
83
+
84
+ const defaultEndpoint = provider === 'ollama' ? 'http://localhost:11434' : 'http://localhost:1234';
85
+ const endpoint = await input({
86
+ message: 'Local AI Endpoint URL:',
87
+ default: existing.localLlm?.endpoint || defaultEndpoint,
88
+ validate: (value: string) => {
89
+ const trimmed = value.trim();
90
+ if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) {
91
+ return 'Endpoint must start with http:// or https://';
92
+ }
93
+ try {
94
+ new URL(trimmed);
95
+ return true;
96
+ } catch {
97
+ return 'Please enter a valid URL';
98
+ }
99
+ },
100
+ });
101
+
102
+ const model = await input({
103
+ message: `Local LLM Model Name (Recommended: ${specs.recommendedModel}):`,
104
+ default: existing.localLlm?.model || specs.recommendedModel,
105
+ });
106
+
107
+ localLlmConfig = {
108
+ enabled: true,
109
+ provider,
110
+ endpoint: endpoint.trim(),
111
+ model: model.trim(),
112
+ };
113
+ } else {
114
+ localLlmConfig.enabled = false;
115
+ }
116
+
43
117
  const config = {
44
118
  ...existing,
45
119
  devDir: devDir.trim(),
46
120
  workspacesDir: workspacesDir.trim(),
47
121
  scanDepth: parseInt(scanDepth, 10),
122
+ localLlm: localLlmConfig,
48
123
  };
49
124
 
50
125
  await saveConfig(config);
@@ -54,5 +129,6 @@ export async function initCommand(): Promise<void> {
54
129
  console.log(chalk.dim(` Dev dir: ${config.devDir}`));
55
130
  console.log(chalk.dim(` Workspaces: ${config.workspacesDir}`));
56
131
  console.log(chalk.dim(` Scan depth: ${config.scanDepth}`));
132
+ console.log(chalk.dim(` Local AI: ${config.localLlm.enabled ? `Enabled (${config.localLlm.provider}, model: ${config.localLlm.model})` : 'Disabled'}`));
57
133
  console.log(chalk.dim('\n Run "nexusflow create" to create your first workspace.\n'));
58
134
  }
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as os from 'node:os';
4
+ import { getConfigDir, getDefaultConfig, ensureConfigDir, loadConfig, saveConfig } from './config.js';
5
+
6
+ vi.mock('node:fs/promises');
7
+ vi.mock('node:os', async () => {
8
+ const actual = await vi.importActual<typeof import('node:os')>('node:os');
9
+ return {
10
+ ...actual,
11
+ homedir: vi.fn(),
12
+ };
13
+ });
14
+
15
+ describe('config core module', () => {
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ vi.mocked(os.homedir).mockReturnValue('/mock/home');
19
+ });
20
+
21
+ describe('getConfigDir', () => {
22
+ it('should return path based on home directory', () => {
23
+ const dir = getConfigDir();
24
+ expect(dir).toContain('.nexusflow');
25
+ });
26
+ });
27
+
28
+ describe('getDefaultConfig', () => {
29
+ it('should return config with defaults populated', () => {
30
+ const config = getDefaultConfig();
31
+ expect(config.version).toBe('1.0.0');
32
+ expect(config.scanDepth).toBe(2);
33
+ expect(config.localLlm?.enabled).toBe(false);
34
+ });
35
+ });
36
+
37
+ describe('ensureConfigDir', () => {
38
+ it('should invoke fs.mkdir with correct options', async () => {
39
+ vi.mocked(fs.mkdir).mockResolvedValue(undefined);
40
+
41
+ await ensureConfigDir();
42
+
43
+ expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining('.nexusflow'), { recursive: true });
44
+ });
45
+ });
46
+
47
+ describe('loadConfig', () => {
48
+ it('should load config if file exists', async () => {
49
+ const savedConfig = {
50
+ devDir: '/custom/dev',
51
+ localLlm: {
52
+ enabled: true,
53
+ model: 'custom-model'
54
+ }
55
+ };
56
+
57
+ vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(savedConfig));
58
+
59
+ const config = await loadConfig();
60
+
61
+ expect(config.devDir).toBe('/custom/dev');
62
+ expect(config.localLlm?.enabled).toBe(true);
63
+ expect(config.localLlm?.model).toBe('custom-model');
64
+ // Should merge with defaults
65
+ expect(config.scanDepth).toBe(2);
66
+ });
67
+
68
+ it('should return default config if read fails', async () => {
69
+ vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
70
+
71
+ const config = await loadConfig();
72
+
73
+ expect(config.version).toBe('1.0.0');
74
+ expect(config.localLlm?.enabled).toBe(false);
75
+ });
76
+ });
77
+
78
+ describe('saveConfig', () => {
79
+ it('should create directory and save configuration', async () => {
80
+ vi.mocked(fs.mkdir).mockResolvedValue(undefined);
81
+ vi.mocked(fs.writeFile).mockResolvedValue(undefined);
82
+
83
+ const config = getDefaultConfig();
84
+ config.devDir = '/custom/saved/dev';
85
+
86
+ await saveConfig(config);
87
+
88
+ expect(fs.mkdir).toHaveBeenCalled();
89
+ expect(fs.writeFile).toHaveBeenCalledWith(
90
+ expect.stringContaining('config.json'),
91
+ expect.stringContaining('/custom/saved/dev'),
92
+ 'utf-8'
93
+ );
94
+ });
95
+ });
96
+ });
@@ -58,6 +58,12 @@ export function getDefaultConfig(): NexusFlowConfig {
58
58
  '**/.vscode/**',
59
59
  '**/.idea/**',
60
60
  ],
61
+ localLlm: {
62
+ enabled: false,
63
+ provider: 'ollama',
64
+ endpoint: 'http://localhost:11434',
65
+ model: 'qwen2.5-coder:1.5b',
66
+ },
61
67
  };
62
68
  }
63
69
 
@@ -83,7 +89,11 @@ export async function loadConfig(): Promise<NexusFlowConfig> {
83
89
  const parsed = JSON.parse(raw) as Partial<NexusFlowConfig>;
84
90
 
85
91
  // Merge with defaults so newly-added keys are always present.
86
- return { ...getDefaultConfig(), ...parsed };
92
+ const merged = { ...getDefaultConfig(), ...parsed };
93
+ if (parsed.localLlm) {
94
+ merged.localLlm = { ...getDefaultConfig().localLlm, ...parsed.localLlm };
95
+ }
96
+ return merged;
87
97
  } catch {
88
98
  // File doesn't exist or is unreadable — return defaults.
89
99
  return getDefaultConfig();
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import { scanForRepos } from './scanner.js';
5
+ import * as gitUtils from '../utils/git.js';
6
+
7
+ vi.mock('node:fs/promises');
8
+ vi.mock('../utils/git.js');
9
+
10
+ describe('scanner core module', () => {
11
+ beforeEach(() => {
12
+ vi.clearAllMocks();
13
+ });
14
+
15
+ it('should return repository info if the root dir is a git repo', async () => {
16
+ vi.mocked(gitUtils.isGitRepo).mockResolvedValue(true);
17
+ vi.mocked(gitUtils.detectDefaultBranch).mockResolvedValue('main');
18
+
19
+ const result = await scanForRepos('/mock/dev', 2);
20
+
21
+ expect(result).toEqual([
22
+ { name: 'dev', path: '/mock/dev', defaultBranch: 'main' }
23
+ ]);
24
+ expect(gitUtils.isGitRepo).toHaveBeenCalledWith('/mock/dev');
25
+ });
26
+
27
+ it('should recursively scan subdirectories up to limit and return repos found', async () => {
28
+ // Root is not a git repo
29
+ vi.mocked(gitUtils.isGitRepo).mockImplementation(async (dir) => {
30
+ const normalized = dir.replace(/\\/g, '/');
31
+ return normalized === '/mock/dev/repo1' || normalized === '/mock/dev/sub/repo2';
32
+ });
33
+ vi.mocked(gitUtils.detectDefaultBranch).mockResolvedValue('main');
34
+
35
+ // Root directory entries
36
+ vi.mocked(fs.readdir).mockImplementation(async (dir: any) => {
37
+ const normalized = dir.replace(/\\/g, '/');
38
+ if (normalized === '/mock/dev') {
39
+ return [
40
+ { name: 'repo1', isDirectory: () => true },
41
+ { name: 'sub', isDirectory: () => true },
42
+ { name: 'ignored_file.txt', isDirectory: () => false },
43
+ { name: 'node_modules', isDirectory: () => true },
44
+ ] as any[];
45
+ }
46
+ if (normalized === '/mock/dev/sub') {
47
+ return [
48
+ { name: 'repo2', isDirectory: () => true },
49
+ ] as any[];
50
+ }
51
+ return [];
52
+ });
53
+
54
+ const result = await scanForRepos('/mock/dev', 2);
55
+
56
+ expect(result).toEqual([
57
+ { name: 'repo1', path: path.join('/mock/dev', 'repo1'), defaultBranch: 'main' },
58
+ { name: 'repo2', path: path.join('/mock/dev', 'sub', 'repo2'), defaultBranch: 'main' }
59
+ ]);
60
+ });
61
+ });
@@ -6,6 +6,7 @@
6
6
 
7
7
  import * as path from 'node:path';
8
8
  import * as fs from 'node:fs';
9
+ import * as os from 'node:os';
9
10
  import type { WorkspaceContext, ProjectAnalysis } from '../types.js';
10
11
 
11
12
  /**
@@ -72,6 +73,7 @@ function formatProjectSection(analysis: ProjectAnalysis, workspacePath: string):
72
73
  export function buildContextContent(ctx: WorkspaceContext): string {
73
74
  const { feature, repos, analysis } = ctx;
74
75
  const workspacePath = feature.workspacePath;
76
+ const localLlmEnabled = feature.localLlmEnabled ?? false;
75
77
 
76
78
  // Build project sections — rich if analysis is available, simple if not
77
79
  let projectSections: string;
@@ -191,7 +193,7 @@ ${taskSection}
191
193
 
192
194
  ## Guidelines
193
195
 
194
- - **Multi-Repo Workspace Structure**: This workspace is a multi-repository developer environment where each project subdirectory (e.g. \`my-api\`, \`my-frontend\`) is a separate Git worktree checked out on the feature branch \`\${feature.branchName}\`.
196
+ - **Multi-Repo Workspace Structure**: This workspace is a multi-repository developer environment where each project subdirectory (e.g. \`my-api\`, \`my-frontend\`) is a separate Git worktree checked out on the feature branch \`${feature.branchName}\`.
195
197
  - **All code changes** must be made within the appropriate project subdirectories.
196
198
  - **Worktree Isolation**: Under no circumstances should you edit files, read code, or run commands in the original/main repository directories outside of this workspace folder. All development must be strictly contained within the checked-out worktree subdirectories of this workspace.
197
199
  - **Git commands** (like \`git status\`, \`git add\`, \`git commit\`, \`git push\`) must be run inside the specific project subdirectories (e.g. \`cd my-api && git commit -m "..."\`), NOT in the workspace root.
@@ -204,7 +206,16 @@ ${taskSection}
204
206
  - \`nexusflow doctor\` — run diagnostics to verify workspace health.
205
207
  - **Workspace Knowledge**: Read \`nexusflow-knowledge.md\` at the start of every session. It serves as the persistent memory for this feature. Before ending your session, append significant architecture decisions, discovered gotchas, and checklist progress to \`nexusflow-knowledge.md\`. Never delete or overwrite existing knowledge/decisions — only append.
206
208
  - **Implementation Plan**: Refer to \`nexusflow-plan.md\` for the suggested implementation order based on dependency analysis. Follow the phased implementation order to avoid blocking yourself on cross-repo dependencies.
207
- - Read each project's existing \`README.md\` and any doc files before proposing changes.
209
+ ${localLlmEnabled ? `- **Local AI Agent Delegation (Token Optimizer)**: You have access to a local Small Language Model (SLM) on the developer's machine via the MCP tool \`delegate_to_local_agent\`.${
210
+ ctx.localLlm ? `\n - **Model Capacity**: The local agent is running \`${ctx.localLlm.model}\`. ${
211
+ ctx.localLlm.model.match(/70b|72b|32b|14b/i)
212
+ ? 'This is a highly capable model; you can delegate complex reasoning and larger code generation tasks.'
213
+ : 'This is a smaller model; it is best suited for targeted search, log parsing, summarization, and simple boilerplate.'
214
+ }` : ''
215
+ }
216
+ - **Usage rule**: Whenever you need to perform high-token tasks (like searching large chunks of code, analyzing raw service logs to debug, or generating repetitive boilerplate), **always use \`delegate_to_local_agent\`** first.
217
+ - The local model is free and fast. Pass the instruction and any logs/source files in \`filesToRead\` (relative paths). Use the distilled summary returned to formulate your final output, saving up to 90% in remote context tokens.
218
+ ` : ''}- Read each project's existing \`README.md\` and any doc files before proposing changes.
208
219
  - When modifying a shared library, check every downstream consumer for breakage.
209
220
  - Prefer small, focused commits that touch one repo at a time when possible.
210
221
  - If a change must span repos, describe the ordering and any migration steps.
@@ -4,7 +4,7 @@
4
4
  * dynamically based on workspace analysis.
5
5
  */
6
6
 
7
- import * as fse from 'fs-extra';
7
+ import fse from 'fs-extra';
8
8
  import * as path from 'node:path';
9
9
  import type { AIAssistant, WorkspaceContext, ProjectAnalysis } from '../types.js';
10
10