groove-dev 0.10.10 → 0.12.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 +24 -16
- package/node_modules/@groove-dev/cli/bin/groove.js +32 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/cli/src/commands/audit.js +60 -0
- package/node_modules/@groove-dev/cli/src/commands/connect.js +279 -0
- package/node_modules/@groove-dev/cli/src/commands/disconnect.js +91 -0
- package/node_modules/@groove-dev/cli/src/commands/federation.js +84 -0
- package/node_modules/@groove-dev/cli/src/commands/start.js +7 -2
- package/node_modules/@groove-dev/cli/src/commands/status.js +4 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +128 -2
- package/node_modules/@groove-dev/daemon/src/audit.js +65 -0
- package/node_modules/@groove-dev/daemon/src/federation.js +352 -0
- package/node_modules/@groove-dev/daemon/src/firstrun.js +27 -2
- package/node_modules/@groove-dev/daemon/src/index.js +64 -6
- package/node_modules/@groove-dev/daemon/src/indexer.js +324 -0
- package/node_modules/@groove-dev/daemon/src/introducer.js +55 -4
- package/node_modules/@groove-dev/daemon/src/journalist.js +140 -51
- package/node_modules/@groove-dev/daemon/src/process.js +3 -2
- package/node_modules/@groove-dev/gui/dist/assets/index-B49YqEXS.js +73 -0
- package/{packages/gui/dist/assets/index-CPzm9ZE9.css → node_modules/@groove-dev/gui/dist/assets/index-Gfb8Zxy9.css} +1 -1
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/App.jsx +24 -1
- package/node_modules/@groove-dev/gui/src/components/AgentNode.jsx +6 -4
- package/node_modules/@groove-dev/gui/src/components/AgentStats.jsx +1 -0
- package/node_modules/@groove-dev/gui/src/components/SpawnPanel.jsx +71 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +19 -2
- package/node_modules/@groove-dev/gui/src/theme.css +2 -2
- package/package.json +1 -1
- package/packages/cli/bin/groove.js +32 -0
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/commands/audit.js +60 -0
- package/packages/cli/src/commands/connect.js +279 -0
- package/packages/cli/src/commands/disconnect.js +91 -0
- package/packages/cli/src/commands/federation.js +84 -0
- package/packages/cli/src/commands/start.js +7 -2
- package/packages/cli/src/commands/status.js +4 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +128 -2
- package/packages/daemon/src/audit.js +65 -0
- package/packages/daemon/src/federation.js +352 -0
- package/packages/daemon/src/firstrun.js +27 -2
- package/packages/daemon/src/index.js +64 -6
- package/packages/daemon/src/indexer.js +324 -0
- package/packages/daemon/src/introducer.js +55 -4
- package/packages/daemon/src/journalist.js +140 -51
- package/packages/daemon/src/process.js +3 -2
- package/packages/gui/dist/assets/index-B49YqEXS.js +73 -0
- package/{node_modules/@groove-dev/gui/dist/assets/index-CPzm9ZE9.css → packages/gui/dist/assets/index-Gfb8Zxy9.css} +1 -1
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/App.jsx +24 -1
- package/packages/gui/src/components/AgentNode.jsx +6 -4
- package/packages/gui/src/components/AgentStats.jsx +1 -0
- package/packages/gui/src/components/SpawnPanel.jsx +71 -1
- package/packages/gui/src/stores/groove.js +19 -2
- package/packages/gui/src/theme.css +2 -2
- package/groove-logo.png +0 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BPVh7Oqk.js +0 -73
- package/packages/gui/dist/assets/index-BPVh7Oqk.js +0 -73
|
@@ -21,7 +21,7 @@ export function isFirstRun(grooveDir) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// Show welcome banner on every startup
|
|
24
|
-
export function printWelcome(port) {
|
|
24
|
+
export function printWelcome(port, host = '127.0.0.1') {
|
|
25
25
|
const providers = listProviders();
|
|
26
26
|
const installed = providers.filter((p) => p.installed);
|
|
27
27
|
const notInstalled = providers.filter((p) => !p.installed);
|
|
@@ -52,7 +52,32 @@ export function printWelcome(port) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
console.log('');
|
|
55
|
-
|
|
55
|
+
const isRemote = host !== '127.0.0.1';
|
|
56
|
+
const isSSH = !!(process.env.SSH_CONNECTION || process.env.SSH_CLIENT);
|
|
57
|
+
|
|
58
|
+
if (isRemote) {
|
|
59
|
+
// Bound to network interface (Tailscale/LAN)
|
|
60
|
+
console.log(` GUI: http://${host}:${port}`);
|
|
61
|
+
console.log(` Host: ${host} (network-accessible)`);
|
|
62
|
+
} else if (isSSH) {
|
|
63
|
+
// Running on a VPS via SSH — print remote access instructions
|
|
64
|
+
const sshUser = process.env.USER || 'user';
|
|
65
|
+
const sshConn = process.env.SSH_CONNECTION || '';
|
|
66
|
+
const serverIp = sshConn.split(' ')[2] || '<this-server-ip>';
|
|
67
|
+
|
|
68
|
+
console.log(` GUI: http://localhost:${port} (this server only)`);
|
|
69
|
+
console.log('');
|
|
70
|
+
console.log(' ► Connect from your laptop:');
|
|
71
|
+
console.log(` groove connect ${sshUser}@${serverIp}`);
|
|
72
|
+
console.log('');
|
|
73
|
+
console.log(' Or manually:');
|
|
74
|
+
console.log(` ssh -L ${port + 1}:localhost:${port} ${sshUser}@${serverIp}`);
|
|
75
|
+
console.log(` Then open http://localhost:${port + 1}`);
|
|
76
|
+
} else {
|
|
77
|
+
// Local machine
|
|
78
|
+
console.log(` GUI: http://localhost:${port}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
56
81
|
console.log(' Docs: https://docs.groovedev.ai');
|
|
57
82
|
console.log(' GitHub: https://github.com/grooveai-dev/groove');
|
|
58
83
|
console.log('');
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { createServer as createHttpServer } from 'http';
|
|
5
5
|
import { createServer as createNetServer } from 'net';
|
|
6
|
+
import { execFileSync } from 'child_process';
|
|
6
7
|
import { resolve } from 'path';
|
|
7
8
|
import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
|
|
8
9
|
import express from 'express';
|
|
@@ -23,15 +24,56 @@ import { CredentialStore } from './credentials.js';
|
|
|
23
24
|
import { TaskClassifier } from './classifier.js';
|
|
24
25
|
import { ModelRouter } from './router.js';
|
|
25
26
|
import { ProjectManager } from './pm.js';
|
|
27
|
+
import { CodebaseIndexer } from './indexer.js';
|
|
28
|
+
import { AuditLogger } from './audit.js';
|
|
29
|
+
import { Federation } from './federation.js';
|
|
26
30
|
import { isFirstRun, runFirstTimeSetup, loadConfig, saveConfig, printWelcome } from './firstrun.js';
|
|
27
31
|
|
|
28
32
|
const DEFAULT_PORT = 31415;
|
|
33
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
29
34
|
|
|
30
35
|
export { loadConfig, saveConfig } from './firstrun.js';
|
|
31
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the --host value to a bind address.
|
|
39
|
+
* - 'tailscale' → auto-detect via `tailscale ip -4`
|
|
40
|
+
* - '0.0.0.0' / '::' → rejected (security policy)
|
|
41
|
+
* - anything else → used as-is
|
|
42
|
+
*/
|
|
43
|
+
function resolveHost(host) {
|
|
44
|
+
if (!host || host === 'localhost') return DEFAULT_HOST;
|
|
45
|
+
|
|
46
|
+
// Block direct internet exposure
|
|
47
|
+
if (host === '0.0.0.0' || host === '::') {
|
|
48
|
+
console.error('\n Direct internet exposure not supported.');
|
|
49
|
+
console.error(' Use `groove connect` (SSH tunnel) or `--host tailscale` instead.\n');
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Auto-detect Tailscale IP
|
|
54
|
+
if (host === 'tailscale') {
|
|
55
|
+
try {
|
|
56
|
+
const ip = execFileSync('tailscale', ['ip', '-4'], {
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
timeout: 5000,
|
|
59
|
+
}).trim().split('\n')[0];
|
|
60
|
+
if (!ip) throw new Error('empty response');
|
|
61
|
+
return ip;
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error('\n Could not detect Tailscale IP.');
|
|
64
|
+
console.error(' Make sure Tailscale is installed and running: `tailscale status`');
|
|
65
|
+
console.error(` Error: ${err.message}\n`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return host;
|
|
71
|
+
}
|
|
72
|
+
|
|
32
73
|
export class Daemon {
|
|
33
74
|
constructor(options = {}) {
|
|
34
75
|
this.port = options.port !== undefined ? options.port : (parseInt(process.env.GROOVE_PORT, 10) || DEFAULT_PORT);
|
|
76
|
+
this.host = resolveHost(options.host);
|
|
35
77
|
this.projectDir = options.projectDir || process.cwd();
|
|
36
78
|
this.grooveDir = options.grooveDir || resolve(this.projectDir, '.groove');
|
|
37
79
|
this.pidFile = resolve(this.grooveDir, 'daemon.pid');
|
|
@@ -69,6 +111,9 @@ export class Daemon {
|
|
|
69
111
|
this.classifier = new TaskClassifier();
|
|
70
112
|
this.router = new ModelRouter(this);
|
|
71
113
|
this.pm = new ProjectManager(this);
|
|
114
|
+
this.indexer = new CodebaseIndexer(this);
|
|
115
|
+
this.audit = new AuditLogger(this.grooveDir);
|
|
116
|
+
this.federation = new Federation(this);
|
|
72
117
|
|
|
73
118
|
// HTTP + WebSocket server
|
|
74
119
|
this.app = express();
|
|
@@ -78,13 +123,17 @@ export class Daemon {
|
|
|
78
123
|
maxPayload: 1024 * 1024, // 1MB max message
|
|
79
124
|
verifyClient: ({ req }) => {
|
|
80
125
|
const origin = req.headers.origin;
|
|
81
|
-
// Allow: no origin (CLI/native clients), localhost origins
|
|
126
|
+
// Allow: no origin (CLI/native clients), localhost origins, bound host
|
|
82
127
|
if (!origin) return true;
|
|
83
128
|
const allowed = [
|
|
84
129
|
`http://localhost:${this.port}`,
|
|
85
130
|
`http://127.0.0.1:${this.port}`,
|
|
86
131
|
'http://localhost:3142',
|
|
87
132
|
];
|
|
133
|
+
// Allow the bound interface (for Tailscale/LAN access)
|
|
134
|
+
if (this.host !== DEFAULT_HOST) {
|
|
135
|
+
allowed.push(`http://${this.host}:${this.port}`);
|
|
136
|
+
}
|
|
88
137
|
return allowed.includes(origin);
|
|
89
138
|
},
|
|
90
139
|
});
|
|
@@ -141,11 +190,12 @@ export class Daemon {
|
|
|
141
190
|
}
|
|
142
191
|
|
|
143
192
|
// Auto-find an open port if the default is taken
|
|
193
|
+
const bindHost = this.host;
|
|
144
194
|
const checkPort = (port) => new Promise((res) => {
|
|
145
195
|
const tester = createNetServer();
|
|
146
196
|
tester.once('error', () => res(false));
|
|
147
197
|
tester.once('listening', () => { tester.close(); res(true); });
|
|
148
|
-
tester.listen(port,
|
|
198
|
+
tester.listen(port, bindHost);
|
|
149
199
|
}).catch(() => false);
|
|
150
200
|
|
|
151
201
|
if (!(await checkPort(this.port))) {
|
|
@@ -170,17 +220,21 @@ export class Daemon {
|
|
|
170
220
|
this.registry.restore(this.state.get('agents') || []);
|
|
171
221
|
|
|
172
222
|
return new Promise((resolvePromise) => {
|
|
173
|
-
this.server.listen(this.port,
|
|
223
|
+
this.server.listen(this.port, this.host, () => {
|
|
174
224
|
writeFileSync(this.pidFile, String(process.pid));
|
|
175
|
-
// Write actual port so CLI can find us
|
|
225
|
+
// Write actual port and host so CLI can find us
|
|
176
226
|
writeFileSync(resolve(this.grooveDir, 'daemon.port'), String(this.port));
|
|
227
|
+
writeFileSync(resolve(this.grooveDir, 'daemon.host'), this.host);
|
|
177
228
|
|
|
178
|
-
printWelcome(this.port);
|
|
229
|
+
printWelcome(this.port, this.host);
|
|
179
230
|
|
|
180
231
|
// Start background services
|
|
181
232
|
this.journalist.start();
|
|
182
233
|
this.rotator.start();
|
|
183
234
|
|
|
235
|
+
// Scan codebase for workspace/structure awareness
|
|
236
|
+
this.indexer.scan();
|
|
237
|
+
|
|
184
238
|
resolvePromise(this);
|
|
185
239
|
});
|
|
186
240
|
});
|
|
@@ -198,10 +252,14 @@ export class Daemon {
|
|
|
198
252
|
// Kill all agent processes
|
|
199
253
|
await this.processes.killAll();
|
|
200
254
|
|
|
201
|
-
// Clean up PID
|
|
255
|
+
// Clean up PID and host files
|
|
202
256
|
if (existsSync(this.pidFile)) {
|
|
203
257
|
unlinkSync(this.pidFile);
|
|
204
258
|
}
|
|
259
|
+
const hostFile = resolve(this.grooveDir, 'daemon.host');
|
|
260
|
+
if (existsSync(hostFile)) {
|
|
261
|
+
unlinkSync(hostFile);
|
|
262
|
+
}
|
|
205
263
|
|
|
206
264
|
// Clean up generated files
|
|
207
265
|
const registryPath = resolve(this.projectDir, 'AGENTS_REGISTRY.md');
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// GROOVE — Codebase Indexer
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// Scans the project structure on daemon start to detect monorepo workspaces,
|
|
5
|
+
// key files, and directory layout. Agents read this instead of spending
|
|
6
|
+
// thousands of tokens exploring the file tree.
|
|
7
|
+
|
|
8
|
+
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from 'fs';
|
|
9
|
+
import { resolve, relative, basename, join } from 'path';
|
|
10
|
+
|
|
11
|
+
const IGNORE_DIRS = new Set([
|
|
12
|
+
'node_modules', '.git', '.groove', 'dist', 'build', '.next', '.nuxt',
|
|
13
|
+
'.output', '.cache', '.turbo', '.vercel', '.svelte-kit', 'coverage',
|
|
14
|
+
'__pycache__', '.venv', 'venv', 'vendor', 'target', '.gradle',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const KEY_FILES = [
|
|
18
|
+
'package.json', 'tsconfig.json', 'README.md', 'CLAUDE.md',
|
|
19
|
+
'ARCHITECTURE.md', 'Cargo.toml', 'go.mod', 'pyproject.toml',
|
|
20
|
+
'Makefile', 'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const MAX_DEPTH = 4;
|
|
24
|
+
|
|
25
|
+
export class CodebaseIndexer {
|
|
26
|
+
constructor(daemon) {
|
|
27
|
+
this.daemon = daemon;
|
|
28
|
+
this.index = null; // { tree, workspaces, keyFiles, stats }
|
|
29
|
+
this.lastIndexTime = null;
|
|
30
|
+
this.indexPath = resolve(daemon.grooveDir, 'codebase-index.json');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Scan the project directory and build the codebase index.
|
|
35
|
+
* Called on daemon start. Fast — only reads directory entries, not file contents.
|
|
36
|
+
*/
|
|
37
|
+
scan() {
|
|
38
|
+
const rootDir = this.daemon.projectDir;
|
|
39
|
+
const tree = [];
|
|
40
|
+
const workspaces = [];
|
|
41
|
+
const keyFiles = [];
|
|
42
|
+
let totalFiles = 0;
|
|
43
|
+
let totalDirs = 0;
|
|
44
|
+
|
|
45
|
+
// Recursive directory walker (depth-limited)
|
|
46
|
+
const walk = (dir, depth, relPath) => {
|
|
47
|
+
if (depth > MAX_DEPTH) return;
|
|
48
|
+
|
|
49
|
+
let entries;
|
|
50
|
+
try {
|
|
51
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
52
|
+
} catch {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const dirs = [];
|
|
57
|
+
const files = [];
|
|
58
|
+
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
if (entry.name.startsWith('.') && entry.name !== '.github') continue;
|
|
61
|
+
|
|
62
|
+
if (entry.isDirectory()) {
|
|
63
|
+
if (IGNORE_DIRS.has(entry.name)) continue;
|
|
64
|
+
dirs.push(entry.name);
|
|
65
|
+
totalDirs++;
|
|
66
|
+
} else if (entry.isFile()) {
|
|
67
|
+
files.push(entry.name);
|
|
68
|
+
totalFiles++;
|
|
69
|
+
|
|
70
|
+
// Track key files
|
|
71
|
+
if (KEY_FILES.includes(entry.name)) {
|
|
72
|
+
const filePath = relPath ? `${relPath}/${entry.name}` : entry.name;
|
|
73
|
+
keyFiles.push(filePath);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Add to tree
|
|
79
|
+
const nodePath = relPath || '.';
|
|
80
|
+
tree.push({
|
|
81
|
+
path: nodePath,
|
|
82
|
+
depth,
|
|
83
|
+
dirs: dirs.length,
|
|
84
|
+
files: files.length,
|
|
85
|
+
children: dirs,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Recurse into subdirectories
|
|
89
|
+
for (const d of dirs) {
|
|
90
|
+
walk(resolve(dir, d), depth + 1, relPath ? `${relPath}/${d}` : d);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
walk(rootDir, 0, '');
|
|
95
|
+
|
|
96
|
+
// Detect workspaces
|
|
97
|
+
this.detectWorkspaces(rootDir, workspaces, tree);
|
|
98
|
+
|
|
99
|
+
this.index = {
|
|
100
|
+
projectName: basename(rootDir),
|
|
101
|
+
scannedAt: new Date().toISOString(),
|
|
102
|
+
stats: { totalFiles, totalDirs, treeDepth: MAX_DEPTH },
|
|
103
|
+
workspaces,
|
|
104
|
+
keyFiles,
|
|
105
|
+
tree,
|
|
106
|
+
};
|
|
107
|
+
this.lastIndexTime = Date.now();
|
|
108
|
+
|
|
109
|
+
// Persist to .groove/codebase-index.json
|
|
110
|
+
try {
|
|
111
|
+
writeFileSync(this.indexPath, JSON.stringify(this.index, null, 2));
|
|
112
|
+
} catch {
|
|
113
|
+
// Non-fatal — index is in memory either way
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Broadcast to GUI
|
|
117
|
+
this.daemon.broadcast({
|
|
118
|
+
type: 'indexer:complete',
|
|
119
|
+
data: {
|
|
120
|
+
workspaceCount: workspaces.length,
|
|
121
|
+
workspaces,
|
|
122
|
+
stats: this.index.stats,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return this.index;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Detect monorepo workspaces from common patterns.
|
|
131
|
+
*/
|
|
132
|
+
detectWorkspaces(rootDir, workspaces, tree) {
|
|
133
|
+
// 1. npm/yarn workspaces (package.json → workspaces field)
|
|
134
|
+
const rootPkg = this.readJson(resolve(rootDir, 'package.json'));
|
|
135
|
+
if (rootPkg?.workspaces) {
|
|
136
|
+
const patterns = Array.isArray(rootPkg.workspaces)
|
|
137
|
+
? rootPkg.workspaces
|
|
138
|
+
: rootPkg.workspaces.packages || [];
|
|
139
|
+
this.resolveWorkspacePatterns(rootDir, patterns, workspaces, 'npm-workspaces');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 2. pnpm workspaces
|
|
143
|
+
const pnpmPath = resolve(rootDir, 'pnpm-workspace.yaml');
|
|
144
|
+
if (existsSync(pnpmPath)) {
|
|
145
|
+
try {
|
|
146
|
+
const content = readFileSync(pnpmPath, 'utf8');
|
|
147
|
+
// Simple YAML parse — extract lines like " - packages/*"
|
|
148
|
+
const patterns = [];
|
|
149
|
+
for (const line of content.split('\n')) {
|
|
150
|
+
const match = line.match(/^\s*-\s+['"]?([^'"]+)['"]?\s*$/);
|
|
151
|
+
if (match) patterns.push(match[1]);
|
|
152
|
+
}
|
|
153
|
+
if (patterns.length > 0) {
|
|
154
|
+
this.resolveWorkspacePatterns(rootDir, patterns, workspaces, 'pnpm-workspaces');
|
|
155
|
+
}
|
|
156
|
+
} catch { /* ignore */ }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 3. lerna.json
|
|
160
|
+
const lernaPath = resolve(rootDir, 'lerna.json');
|
|
161
|
+
if (existsSync(lernaPath)) {
|
|
162
|
+
const lerna = this.readJson(lernaPath);
|
|
163
|
+
if (lerna?.packages) {
|
|
164
|
+
this.resolveWorkspacePatterns(rootDir, lerna.packages, workspaces, 'lerna');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 4. Fallback: multiple package.json at depth 1-2 (non-declared monorepo)
|
|
169
|
+
if (workspaces.length === 0) {
|
|
170
|
+
const subPkgs = tree.filter((node) =>
|
|
171
|
+
node.depth >= 1 && node.depth <= 2 &&
|
|
172
|
+
this.hasKeyFile(rootDir, node.path, 'package.json')
|
|
173
|
+
);
|
|
174
|
+
if (subPkgs.length >= 2) {
|
|
175
|
+
for (const node of subPkgs) {
|
|
176
|
+
const pkgJson = this.readJson(resolve(rootDir, node.path, 'package.json'));
|
|
177
|
+
workspaces.push({
|
|
178
|
+
path: node.path,
|
|
179
|
+
name: pkgJson?.name || basename(node.path),
|
|
180
|
+
type: 'detected',
|
|
181
|
+
files: node.files,
|
|
182
|
+
dirs: node.dirs,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Resolve workspace glob patterns (e.g. "packages/*") into actual directories.
|
|
191
|
+
*/
|
|
192
|
+
resolveWorkspacePatterns(rootDir, patterns, workspaces, type) {
|
|
193
|
+
const seen = new Set(workspaces.map((w) => w.path));
|
|
194
|
+
|
|
195
|
+
for (const pattern of patterns) {
|
|
196
|
+
// Handle simple glob: "packages/*", "apps/*"
|
|
197
|
+
const clean = pattern.replace(/\/?\*+$/, '');
|
|
198
|
+
const parentDir = resolve(rootDir, clean);
|
|
199
|
+
|
|
200
|
+
if (!existsSync(parentDir)) continue;
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const stat = statSync(parentDir);
|
|
204
|
+
|
|
205
|
+
if (pattern.includes('*')) {
|
|
206
|
+
// It's a glob — list children of the parent directory
|
|
207
|
+
const entries = readdirSync(parentDir, { withFileTypes: true });
|
|
208
|
+
for (const entry of entries) {
|
|
209
|
+
if (!entry.isDirectory() || IGNORE_DIRS.has(entry.name)) continue;
|
|
210
|
+
const wsPath = clean ? `${clean}/${entry.name}` : entry.name;
|
|
211
|
+
if (seen.has(wsPath)) continue;
|
|
212
|
+
seen.add(wsPath);
|
|
213
|
+
|
|
214
|
+
const fullPath = resolve(rootDir, wsPath);
|
|
215
|
+
const pkgJson = this.readJson(resolve(fullPath, 'package.json'));
|
|
216
|
+
const info = this.dirInfo(fullPath);
|
|
217
|
+
workspaces.push({
|
|
218
|
+
path: wsPath,
|
|
219
|
+
name: pkgJson?.name || entry.name,
|
|
220
|
+
type,
|
|
221
|
+
files: info.files,
|
|
222
|
+
dirs: info.dirs,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
} else if (stat.isDirectory()) {
|
|
226
|
+
// Direct path (e.g. "shared")
|
|
227
|
+
if (seen.has(clean)) continue;
|
|
228
|
+
seen.add(clean);
|
|
229
|
+
|
|
230
|
+
const pkgJson = this.readJson(resolve(parentDir, 'package.json'));
|
|
231
|
+
const info = this.dirInfo(parentDir);
|
|
232
|
+
workspaces.push({
|
|
233
|
+
path: clean,
|
|
234
|
+
name: pkgJson?.name || basename(clean),
|
|
235
|
+
type,
|
|
236
|
+
files: info.files,
|
|
237
|
+
dirs: info.dirs,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
} catch { /* skip inaccessible dirs */ }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Helpers ──
|
|
245
|
+
|
|
246
|
+
readJson(filePath) {
|
|
247
|
+
try {
|
|
248
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
249
|
+
} catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
hasKeyFile(rootDir, relPath, filename) {
|
|
255
|
+
return existsSync(resolve(rootDir, relPath, filename));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
dirInfo(dirPath) {
|
|
259
|
+
try {
|
|
260
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
261
|
+
let files = 0, dirs = 0;
|
|
262
|
+
for (const e of entries) {
|
|
263
|
+
if (e.name.startsWith('.')) continue;
|
|
264
|
+
if (e.isFile()) files++;
|
|
265
|
+
else if (e.isDirectory() && !IGNORE_DIRS.has(e.name)) dirs++;
|
|
266
|
+
}
|
|
267
|
+
return { files, dirs };
|
|
268
|
+
} catch {
|
|
269
|
+
return { files: 0, dirs: 0 };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── Public API ──
|
|
274
|
+
|
|
275
|
+
getWorkspaces() {
|
|
276
|
+
return this.index?.workspaces || [];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
getIndex() {
|
|
280
|
+
return this.index;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Get a compact structural summary for agent context injection.
|
|
285
|
+
* Returns a markdown string with workspace layout and key files.
|
|
286
|
+
*/
|
|
287
|
+
getStructureSummary() {
|
|
288
|
+
if (!this.index) return null;
|
|
289
|
+
const { workspaces, keyFiles, stats, projectName } = this.index;
|
|
290
|
+
|
|
291
|
+
const lines = [];
|
|
292
|
+
lines.push(`Project: **${projectName}** (${stats.totalFiles} files, ${stats.totalDirs} directories)`);
|
|
293
|
+
|
|
294
|
+
if (workspaces.length > 0) {
|
|
295
|
+
lines.push('');
|
|
296
|
+
lines.push(`Workspaces (${workspaces.length}):`);
|
|
297
|
+
for (const ws of workspaces) {
|
|
298
|
+
lines.push(`- \`${ws.path}/\` — ${ws.name} (${ws.files} files, ${ws.dirs} subdirs)`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (keyFiles.length > 0) {
|
|
303
|
+
lines.push('');
|
|
304
|
+
lines.push('Key files:');
|
|
305
|
+
for (const f of keyFiles.slice(0, 30)) {
|
|
306
|
+
lines.push(`- ${f}`);
|
|
307
|
+
}
|
|
308
|
+
if (keyFiles.length > 30) {
|
|
309
|
+
lines.push(`- *(+${keyFiles.length - 30} more)*`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return lines.join('\n');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
getStatus() {
|
|
317
|
+
return {
|
|
318
|
+
indexed: !!this.index,
|
|
319
|
+
lastIndexTime: this.lastIndexTime,
|
|
320
|
+
workspaceCount: this.index?.workspaces?.length || 0,
|
|
321
|
+
stats: this.index?.stats || null,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
@@ -26,6 +26,10 @@ export class Introducer {
|
|
|
26
26
|
`You are **${newAgent.name}** (role: ${newAgent.role}), managed by GROOVE.`,
|
|
27
27
|
];
|
|
28
28
|
|
|
29
|
+
if (newAgent.workingDir) {
|
|
30
|
+
lines.push(`Your working directory: \`${newAgent.workingDir}\` — you are spawned inside this subdirectory. Stay within it unless coordination requires otherwise.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
29
33
|
if (newAgent.scope && newAgent.scope.length > 0) {
|
|
30
34
|
lines.push(`Your file scope: \`${newAgent.scope.join('`, `')}\``);
|
|
31
35
|
} else {
|
|
@@ -45,7 +49,8 @@ export class Introducer {
|
|
|
45
49
|
|
|
46
50
|
for (const other of others) {
|
|
47
51
|
const scope = other.scope?.length > 0 ? other.scope.join(', ') : 'unrestricted';
|
|
48
|
-
|
|
52
|
+
const dir = other.workingDir ? ` — dir: ${other.workingDir}` : '';
|
|
53
|
+
lines.push(`- **${other.name}** (${other.role}) — scope: ${scope}${dir} — ${other.status}`);
|
|
49
54
|
|
|
50
55
|
// Get files this agent created/modified
|
|
51
56
|
const files = this.daemon.journalist?.getAgentFiles(other) || [];
|
|
@@ -138,9 +143,54 @@ export class Introducer {
|
|
|
138
143
|
lines.push(`Read GROOVE_PROJECT_MAP.md for current project context from The Journalist.`);
|
|
139
144
|
}
|
|
140
145
|
|
|
146
|
+
// Codebase structure injection — give agents instant orientation
|
|
147
|
+
const structureSummary = this.daemon.indexer?.getStructureSummary();
|
|
148
|
+
if (structureSummary) {
|
|
149
|
+
lines.push('');
|
|
150
|
+
lines.push(`## Codebase Structure (auto-indexed)`);
|
|
151
|
+
lines.push('');
|
|
152
|
+
lines.push(structureSummary);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Architecture injection — auto-detect architecture docs and inject
|
|
156
|
+
// so every agent understands the big picture without spending tokens exploring
|
|
157
|
+
const archContent = this.loadArchitectureDoc();
|
|
158
|
+
if (archContent) {
|
|
159
|
+
lines.push('');
|
|
160
|
+
lines.push(`## Architecture (auto-injected)`);
|
|
161
|
+
lines.push('');
|
|
162
|
+
lines.push(archContent);
|
|
163
|
+
}
|
|
164
|
+
|
|
141
165
|
return lines.join('\n');
|
|
142
166
|
}
|
|
143
167
|
|
|
168
|
+
loadArchitectureDoc() {
|
|
169
|
+
const projectDir = this.daemon.projectDir;
|
|
170
|
+
const candidates = [
|
|
171
|
+
'ARCHITECTURE.md',
|
|
172
|
+
'docs/architecture.md',
|
|
173
|
+
'.github/ARCHITECTURE.md',
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
for (const candidate of candidates) {
|
|
177
|
+
const fullPath = resolve(projectDir, candidate);
|
|
178
|
+
if (existsSync(fullPath)) {
|
|
179
|
+
try {
|
|
180
|
+
let content = readFileSync(fullPath, 'utf8').trim();
|
|
181
|
+
// Truncate to ~5K chars to keep context budget reasonable
|
|
182
|
+
if (content.length > 5000) {
|
|
183
|
+
content = content.slice(0, 5000) + '\n\n*(truncated — read full file for details)*';
|
|
184
|
+
}
|
|
185
|
+
return content;
|
|
186
|
+
} catch {
|
|
187
|
+
// ignore read errors
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
144
194
|
writeRegistryFile(projectDir) {
|
|
145
195
|
const agents = this.daemon.registry.getAll();
|
|
146
196
|
|
|
@@ -158,13 +208,14 @@ export class Introducer {
|
|
|
158
208
|
``,
|
|
159
209
|
`*Auto-generated by GROOVE. Do not edit manually.*`,
|
|
160
210
|
``,
|
|
161
|
-
`| ID | Name | Role | Provider | Scope | Status |`,
|
|
162
|
-
|
|
211
|
+
`| ID | Name | Role | Provider | Directory | Scope | Status |`,
|
|
212
|
+
`|----|------|------|----------|-----------|-------|--------|`,
|
|
163
213
|
];
|
|
164
214
|
|
|
165
215
|
for (const a of agents) {
|
|
166
216
|
const scope = a.scope?.length > 0 ? `\`${a.scope.join('`, `')}\`` : '-';
|
|
167
|
-
|
|
217
|
+
const dir = a.workingDir ? escapeMd(a.workingDir) : '-';
|
|
218
|
+
lines.push(`| ${escapeMd(a.id)} | ${escapeMd(a.name)} | ${escapeMd(a.role)} | ${escapeMd(a.provider)} | ${dir} | ${scope} | ${escapeMd(a.status)} |`);
|
|
168
219
|
}
|
|
169
220
|
|
|
170
221
|
lines.push('');
|