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.
Files changed (45) hide show
  1. package/node_modules/@groove-dev/daemon/src/agent-loop.js +444 -0
  2. package/node_modules/@groove-dev/daemon/src/api.js +104 -5
  3. package/node_modules/@groove-dev/daemon/src/index.js +6 -1
  4. package/node_modules/@groove-dev/daemon/src/llama-server.js +268 -0
  5. package/node_modules/@groove-dev/daemon/src/model-manager.js +411 -0
  6. package/node_modules/@groove-dev/daemon/src/process.js +160 -9
  7. package/node_modules/@groove-dev/daemon/src/providers/codex.js +51 -1
  8. package/node_modules/@groove-dev/daemon/src/providers/gemini.js +3 -2
  9. package/node_modules/@groove-dev/daemon/src/providers/index.js +4 -0
  10. package/node_modules/@groove-dev/daemon/src/providers/local.js +183 -0
  11. package/node_modules/@groove-dev/daemon/src/registry.js +1 -1
  12. package/node_modules/@groove-dev/daemon/src/tool-executor.js +367 -0
  13. package/node_modules/@groove-dev/gui/dist/assets/index-BC2Bhfv0.js +633 -0
  14. package/node_modules/@groove-dev/gui/dist/assets/index-BQnZrh4f.css +1 -0
  15. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  16. package/node_modules/@groove-dev/gui/src/app.jsx +2 -0
  17. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +7 -2
  18. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
  19. package/node_modules/@groove-dev/gui/src/stores/groove.js +7 -1
  20. package/node_modules/@groove-dev/gui/src/views/models.jsx +380 -0
  21. package/package.json +2 -2
  22. package/packages/daemon/src/agent-loop.js +444 -0
  23. package/packages/daemon/src/api.js +104 -5
  24. package/packages/daemon/src/index.js +6 -1
  25. package/packages/daemon/src/llama-server.js +268 -0
  26. package/packages/daemon/src/model-manager.js +411 -0
  27. package/packages/daemon/src/process.js +160 -9
  28. package/packages/daemon/src/providers/codex.js +51 -1
  29. package/packages/daemon/src/providers/gemini.js +3 -2
  30. package/packages/daemon/src/providers/index.js +4 -0
  31. package/packages/daemon/src/providers/local.js +183 -0
  32. package/packages/daemon/src/registry.js +1 -1
  33. package/packages/daemon/src/tool-executor.js +367 -0
  34. package/packages/gui/dist/assets/index-BC2Bhfv0.js +633 -0
  35. package/packages/gui/dist/assets/index-BQnZrh4f.css +1 -0
  36. package/packages/gui/dist/index.html +2 -2
  37. package/packages/gui/src/app.jsx +2 -0
  38. package/packages/gui/src/components/agents/agent-config.jsx +7 -2
  39. package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
  40. package/packages/gui/src/stores/groove.js +7 -1
  41. package/packages/gui/src/views/models.jsx +380 -0
  42. package/node_modules/@groove-dev/gui/dist/assets/index-B1FkEzF0.js +0 -623
  43. package/node_modules/@groove-dev/gui/dist/assets/index-GYcMwmjs.css +0 -1
  44. package/packages/gui/dist/assets/index-B1FkEzF0.js +0 -623
  45. 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
+ }