groove-dev 0.25.21 → 0.26.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/node_modules/@groove-dev/daemon/src/agent-loop.js +444 -0
- package/node_modules/@groove-dev/daemon/src/api.js +104 -5
- package/node_modules/@groove-dev/daemon/src/index.js +6 -1
- package/node_modules/@groove-dev/daemon/src/llama-server.js +268 -0
- package/node_modules/@groove-dev/daemon/src/model-manager.js +411 -0
- package/node_modules/@groove-dev/daemon/src/process.js +160 -9
- package/node_modules/@groove-dev/daemon/src/providers/codex.js +51 -1
- package/node_modules/@groove-dev/daemon/src/providers/gemini.js +3 -2
- package/node_modules/@groove-dev/daemon/src/providers/index.js +4 -0
- package/node_modules/@groove-dev/daemon/src/providers/local.js +183 -0
- package/node_modules/@groove-dev/daemon/src/registry.js +1 -1
- package/node_modules/@groove-dev/daemon/src/tool-executor.js +367 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BC2Bhfv0.js +633 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BQnZrh4f.css +1 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/src/app.jsx +2 -0
- package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +7 -2
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +7 -1
- package/node_modules/@groove-dev/gui/src/views/models.jsx +380 -0
- package/package.json +2 -2
- package/packages/daemon/src/agent-loop.js +444 -0
- package/packages/daemon/src/api.js +104 -5
- package/packages/daemon/src/index.js +6 -1
- package/packages/daemon/src/llama-server.js +268 -0
- package/packages/daemon/src/model-manager.js +411 -0
- package/packages/daemon/src/process.js +160 -9
- package/packages/daemon/src/providers/codex.js +51 -1
- package/packages/daemon/src/providers/gemini.js +3 -2
- package/packages/daemon/src/providers/index.js +4 -0
- package/packages/daemon/src/providers/local.js +183 -0
- package/packages/daemon/src/registry.js +1 -1
- package/packages/daemon/src/tool-executor.js +367 -0
- package/packages/gui/dist/assets/index-BC2Bhfv0.js +633 -0
- package/packages/gui/dist/assets/index-BQnZrh4f.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/src/app.jsx +2 -0
- package/packages/gui/src/components/agents/agent-config.jsx +7 -2
- package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
- package/packages/gui/src/stores/groove.js +7 -1
- package/packages/gui/src/views/models.jsx +380 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-B1FkEzF0.js +0 -623
- package/node_modules/@groove-dev/gui/dist/assets/index-GYcMwmjs.css +0 -1
- package/packages/gui/dist/assets/index-B1FkEzF0.js +0 -623
- package/packages/gui/dist/assets/index-GYcMwmjs.css +0 -1
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// GROOVE — Tool Executor for Local Agent Loop
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, existsSync } from 'fs';
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import { resolve, relative, dirname, sep } from 'path';
|
|
7
|
+
import { minimatch } from 'minimatch';
|
|
8
|
+
|
|
9
|
+
// Tool definitions in OpenAI function-calling format
|
|
10
|
+
// These mirror what Claude Code provides — same agentic experience regardless of model
|
|
11
|
+
export const TOOL_DEFINITIONS = [
|
|
12
|
+
{
|
|
13
|
+
type: 'function',
|
|
14
|
+
function: {
|
|
15
|
+
name: 'read_file',
|
|
16
|
+
description: 'Read the contents of a file. Returns file content with line numbers. Use offset/limit for large files.',
|
|
17
|
+
parameters: {
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
path: { type: 'string', description: 'File path relative to working directory' },
|
|
21
|
+
offset: { type: 'integer', description: 'Start line (1-based, optional)' },
|
|
22
|
+
limit: { type: 'integer', description: 'Max lines to read (optional)' },
|
|
23
|
+
},
|
|
24
|
+
required: ['path'],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
type: 'function',
|
|
30
|
+
function: {
|
|
31
|
+
name: 'write_file',
|
|
32
|
+
description: 'Write content to a file, creating it and parent directories if they do not exist. Overwrites existing content.',
|
|
33
|
+
parameters: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: {
|
|
36
|
+
path: { type: 'string', description: 'File path to write to' },
|
|
37
|
+
content: { type: 'string', description: 'Full file content to write' },
|
|
38
|
+
},
|
|
39
|
+
required: ['path', 'content'],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
type: 'function',
|
|
45
|
+
function: {
|
|
46
|
+
name: 'edit_file',
|
|
47
|
+
description: 'Replace a specific string in a file with a new string. The old_string must appear exactly once in the file.',
|
|
48
|
+
parameters: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {
|
|
51
|
+
path: { type: 'string', description: 'File path to edit' },
|
|
52
|
+
old_string: { type: 'string', description: 'Exact string to find (must be unique in file)' },
|
|
53
|
+
new_string: { type: 'string', description: 'Replacement string' },
|
|
54
|
+
},
|
|
55
|
+
required: ['path', 'old_string', 'new_string'],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
type: 'function',
|
|
61
|
+
function: {
|
|
62
|
+
name: 'run_command',
|
|
63
|
+
description: 'Execute a shell command and return stdout/stderr. Use for running tests, builds, git, npm, etc.',
|
|
64
|
+
parameters: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
properties: {
|
|
67
|
+
command: { type: 'string', description: 'Shell command to execute' },
|
|
68
|
+
cwd: { type: 'string', description: 'Working directory (optional, defaults to agent working dir)' },
|
|
69
|
+
timeout: { type: 'integer', description: 'Timeout in milliseconds (optional, default 30000, max 120000)' },
|
|
70
|
+
},
|
|
71
|
+
required: ['command'],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
type: 'function',
|
|
77
|
+
function: {
|
|
78
|
+
name: 'search_files',
|
|
79
|
+
description: 'Search for files matching a glob pattern (e.g. "src/**/*.ts", "*.json"). Returns matching file paths.',
|
|
80
|
+
parameters: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: {
|
|
83
|
+
pattern: { type: 'string', description: 'Glob pattern to match files against' },
|
|
84
|
+
cwd: { type: 'string', description: 'Directory to search in (optional)' },
|
|
85
|
+
},
|
|
86
|
+
required: ['pattern'],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
type: 'function',
|
|
92
|
+
function: {
|
|
93
|
+
name: 'search_content',
|
|
94
|
+
description: 'Search file contents for a regex pattern (like grep). Returns matching lines with file path and line number.',
|
|
95
|
+
parameters: {
|
|
96
|
+
type: 'object',
|
|
97
|
+
properties: {
|
|
98
|
+
pattern: { type: 'string', description: 'Regex pattern to search for' },
|
|
99
|
+
path: { type: 'string', description: 'Directory or file to search in (optional)' },
|
|
100
|
+
glob: { type: 'string', description: 'File glob filter, e.g. "*.js" (optional)' },
|
|
101
|
+
},
|
|
102
|
+
required: ['pattern'],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
type: 'function',
|
|
108
|
+
function: {
|
|
109
|
+
name: 'list_directory',
|
|
110
|
+
description: 'List files and directories at a given path. Shows type (file/dir) and file sizes.',
|
|
111
|
+
parameters: {
|
|
112
|
+
type: 'object',
|
|
113
|
+
properties: {
|
|
114
|
+
path: { type: 'string', description: 'Directory path to list (optional, defaults to working directory)' },
|
|
115
|
+
},
|
|
116
|
+
required: [],
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
export class ToolExecutor {
|
|
123
|
+
constructor(workingDir, daemon, agentId) {
|
|
124
|
+
this.workingDir = resolve(workingDir);
|
|
125
|
+
this.daemon = daemon;
|
|
126
|
+
this.agentId = agentId;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async execute(name, args) {
|
|
130
|
+
try {
|
|
131
|
+
switch (name) {
|
|
132
|
+
case 'read_file': return this.readFile(args);
|
|
133
|
+
case 'write_file': return this.writeFile(args);
|
|
134
|
+
case 'edit_file': return this.editFile(args);
|
|
135
|
+
case 'run_command': return this.runCommand(args);
|
|
136
|
+
case 'search_files': return this.searchFiles(args);
|
|
137
|
+
case 'search_content': return this.searchContent(args);
|
|
138
|
+
case 'list_directory': return this.listDirectory(args);
|
|
139
|
+
default: return { success: false, error: `Unknown tool: ${name}` };
|
|
140
|
+
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
return { success: false, error: err.message };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- Path Security ---
|
|
147
|
+
|
|
148
|
+
_resolvePath(filePath) {
|
|
149
|
+
if (!filePath) throw new Error('Path is required');
|
|
150
|
+
const resolved = resolve(this.workingDir, filePath);
|
|
151
|
+
// Block path traversal — resolved path must be within working directory
|
|
152
|
+
if (!resolved.startsWith(this.workingDir + sep) && resolved !== this.workingDir) {
|
|
153
|
+
throw new Error(`Access denied: path outside working directory`);
|
|
154
|
+
}
|
|
155
|
+
return resolved;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
_checkWriteScope(resolvedPath) {
|
|
159
|
+
if (!this.daemon?.locks) return;
|
|
160
|
+
const rel = relative(this.workingDir, resolvedPath);
|
|
161
|
+
const result = this.daemon.locks.check(this.agentId, rel);
|
|
162
|
+
if (result.conflict) {
|
|
163
|
+
// Record conflict for supervisor + token savings
|
|
164
|
+
if (this.daemon.supervisor) {
|
|
165
|
+
this.daemon.supervisor.recordConflict(this.agentId, rel, result.owner);
|
|
166
|
+
}
|
|
167
|
+
throw new Error(`Scope conflict: ${rel} is owned by agent ${result.owner} (pattern: ${result.pattern})`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- Tool Implementations ---
|
|
172
|
+
|
|
173
|
+
readFile({ path: filePath, offset, limit }) {
|
|
174
|
+
const resolved = this._resolvePath(filePath);
|
|
175
|
+
if (!existsSync(resolved)) {
|
|
176
|
+
return { success: false, error: `File not found: ${filePath}` };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const stat = statSync(resolved);
|
|
180
|
+
if (stat.isDirectory()) {
|
|
181
|
+
return { success: false, error: `Path is a directory, not a file: ${filePath}` };
|
|
182
|
+
}
|
|
183
|
+
// Guard against huge files
|
|
184
|
+
if (stat.size > 5 * 1024 * 1024) {
|
|
185
|
+
return { success: false, error: `File too large (${formatBytes(stat.size)}). Use offset/limit to read a section.` };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const content = readFileSync(resolved, 'utf8');
|
|
189
|
+
let lines = content.split('\n');
|
|
190
|
+
const totalLines = lines.length;
|
|
191
|
+
|
|
192
|
+
const startLine = (offset && offset > 0) ? offset : 1;
|
|
193
|
+
if (offset && offset > 0) {
|
|
194
|
+
lines = lines.slice(offset - 1);
|
|
195
|
+
}
|
|
196
|
+
if (limit && limit > 0) {
|
|
197
|
+
lines = lines.slice(0, limit);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const numbered = lines.map((line, i) => `${startLine + i}\t${line}`).join('\n');
|
|
201
|
+
return { success: true, result: numbered, meta: { totalLines } };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
writeFile({ path: filePath, content }) {
|
|
205
|
+
const resolved = this._resolvePath(filePath);
|
|
206
|
+
this._checkWriteScope(resolved);
|
|
207
|
+
|
|
208
|
+
mkdirSync(dirname(resolved), { recursive: true });
|
|
209
|
+
writeFileSync(resolved, content);
|
|
210
|
+
|
|
211
|
+
const lineCount = content.split('\n').length;
|
|
212
|
+
return { success: true, result: `Wrote ${lineCount} lines to ${filePath}` };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
editFile({ path: filePath, old_string, new_string }) {
|
|
216
|
+
const resolved = this._resolvePath(filePath);
|
|
217
|
+
this._checkWriteScope(resolved);
|
|
218
|
+
|
|
219
|
+
if (!existsSync(resolved)) {
|
|
220
|
+
return { success: false, error: `File not found: ${filePath}` };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const content = readFileSync(resolved, 'utf8');
|
|
224
|
+
const occurrences = content.split(old_string).length - 1;
|
|
225
|
+
|
|
226
|
+
if (occurrences === 0) {
|
|
227
|
+
return { success: false, error: `String not found in ${filePath}. Check for exact whitespace and formatting.` };
|
|
228
|
+
}
|
|
229
|
+
if (occurrences > 1) {
|
|
230
|
+
return { success: false, error: `Found ${occurrences} matches in ${filePath} — old_string must be unique. Include more surrounding context.` };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const newContent = content.replace(old_string, new_string);
|
|
234
|
+
writeFileSync(resolved, newContent);
|
|
235
|
+
return { success: true, result: `Edited ${filePath}` };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
runCommand({ command, cwd, timeout }) {
|
|
239
|
+
if (!command) return { success: false, error: 'Command is required' };
|
|
240
|
+
|
|
241
|
+
const execCwd = cwd ? this._resolvePath(cwd) : this.workingDir;
|
|
242
|
+
const timeoutMs = Math.min(timeout || 30000, 120000);
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
const output = execSync(command, {
|
|
246
|
+
cwd: execCwd,
|
|
247
|
+
encoding: 'utf8',
|
|
248
|
+
timeout: timeoutMs,
|
|
249
|
+
maxBuffer: 2 * 1024 * 1024, // 2MB
|
|
250
|
+
shell: true,
|
|
251
|
+
env: { ...process.env, GROOVE_AGENT_ID: this.agentId },
|
|
252
|
+
});
|
|
253
|
+
// Cap output to prevent context window blowup
|
|
254
|
+
const result = output.length > 50000 ? output.slice(0, 50000) + '\n... (output truncated)' : output;
|
|
255
|
+
return { success: true, result };
|
|
256
|
+
} catch (err) {
|
|
257
|
+
const stderr = (err.stderr || '').toString().slice(0, 5000);
|
|
258
|
+
const stdout = (err.stdout || '').toString().slice(0, 5000);
|
|
259
|
+
const output = stderr || stdout || err.message;
|
|
260
|
+
return { success: false, error: `Exit code ${err.status || 1}: ${output}` };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
searchFiles({ pattern, cwd }) {
|
|
265
|
+
const searchDir = cwd ? this._resolvePath(cwd) : this.workingDir;
|
|
266
|
+
const results = [];
|
|
267
|
+
|
|
268
|
+
const walk = (dir, depth) => {
|
|
269
|
+
if (depth > 12 || results.length >= 500) return;
|
|
270
|
+
let entries;
|
|
271
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
272
|
+
|
|
273
|
+
for (const entry of entries) {
|
|
274
|
+
// Skip hidden dirs, node_modules, .git, build artifacts
|
|
275
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === '__pycache__') {
|
|
276
|
+
if (entry.isDirectory()) continue;
|
|
277
|
+
}
|
|
278
|
+
const fullPath = resolve(dir, entry.name);
|
|
279
|
+
const rel = relative(searchDir, fullPath);
|
|
280
|
+
|
|
281
|
+
if (entry.isDirectory()) {
|
|
282
|
+
walk(fullPath, depth + 1);
|
|
283
|
+
} else if (minimatch(rel, pattern, { dot: false })) {
|
|
284
|
+
results.push(rel);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
walk(searchDir, 0);
|
|
290
|
+
results.sort();
|
|
291
|
+
return { success: true, result: results.length > 0 ? results.join('\n') : 'No files matched the pattern.' };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
searchContent({ pattern, path: searchPath, glob: globFilter }) {
|
|
295
|
+
const searchDir = searchPath ? this._resolvePath(searchPath) : this.workingDir;
|
|
296
|
+
|
|
297
|
+
// Prefer ripgrep (faster, respects .gitignore), fall back to grep
|
|
298
|
+
const escapedPattern = pattern.replace(/'/g, "'\\''");
|
|
299
|
+
const commands = [];
|
|
300
|
+
|
|
301
|
+
// Try rg first
|
|
302
|
+
let rgCmd = `rg -n --max-count=200 --max-filesize=1M '${escapedPattern}'`;
|
|
303
|
+
if (globFilter) rgCmd += ` -g '${globFilter}'`;
|
|
304
|
+
rgCmd += ` '${searchDir}'`;
|
|
305
|
+
commands.push(rgCmd);
|
|
306
|
+
|
|
307
|
+
// Fallback: grep
|
|
308
|
+
let grepCmd = `grep -rn --include='${globFilter || '*'}' -E '${escapedPattern}' '${searchDir}' | head -200`;
|
|
309
|
+
commands.push(grepCmd);
|
|
310
|
+
|
|
311
|
+
for (const cmd of commands) {
|
|
312
|
+
try {
|
|
313
|
+
const output = execSync(cmd, { encoding: 'utf8', timeout: 15000, maxBuffer: 1024 * 1024 });
|
|
314
|
+
if (output.trim()) {
|
|
315
|
+
// Make paths relative to searchDir
|
|
316
|
+
const lines = output.split('\n').map((line) => {
|
|
317
|
+
if (line.startsWith(searchDir)) {
|
|
318
|
+
return line.slice(searchDir.length + 1);
|
|
319
|
+
}
|
|
320
|
+
return line;
|
|
321
|
+
}).join('\n');
|
|
322
|
+
const result = lines.length > 30000 ? lines.slice(0, 30000) + '\n... (truncated)' : lines;
|
|
323
|
+
return { success: true, result };
|
|
324
|
+
}
|
|
325
|
+
} catch (err) {
|
|
326
|
+
// grep returns exit code 1 for no matches — that's fine
|
|
327
|
+
if (err.status === 1) {
|
|
328
|
+
return { success: true, result: 'No matches found.' };
|
|
329
|
+
}
|
|
330
|
+
// Try next command
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return { success: true, result: 'No matches found.' };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
listDirectory({ path: dirPath } = {}) {
|
|
339
|
+
const resolved = dirPath ? this._resolvePath(dirPath) : this.workingDir;
|
|
340
|
+
|
|
341
|
+
if (!existsSync(resolved)) {
|
|
342
|
+
return { success: false, error: `Directory not found: ${dirPath || '.'}` };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
let entries;
|
|
346
|
+
try { entries = readdirSync(resolved, { withFileTypes: true }); } catch (err) {
|
|
347
|
+
return { success: false, error: `Cannot read directory: ${err.message}` };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const lines = entries.map((entry) => {
|
|
351
|
+
const type = entry.isDirectory() ? 'dir ' : entry.isSymbolicLink() ? 'link' : 'file';
|
|
352
|
+
let size = '';
|
|
353
|
+
if (!entry.isDirectory()) {
|
|
354
|
+
try { size = ` ${formatBytes(statSync(resolve(resolved, entry.name)).size)}`; } catch { /* */ }
|
|
355
|
+
}
|
|
356
|
+
return `[${type}] ${entry.name}${size}`;
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
return { success: true, result: lines.length > 0 ? lines.join('\n') : '(empty directory)' };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function formatBytes(bytes) {
|
|
364
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
365
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
366
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
367
|
+
}
|