groove-dev 0.25.20 → 0.26.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/node_modules/@groove-dev/daemon/src/agent-loop.js +479 -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 +179 -11
- 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-BQnZrh4f.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BqL4GcgZ.js +633 -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 +6 -2
- package/node_modules/@groove-dev/gui/src/views/models.jsx +380 -0
- package/package.json +2 -2
- package/packages/daemon/src/agent-loop.js +479 -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 +179 -11
- 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-BQnZrh4f.css +1 -0
- package/packages/gui/dist/assets/index-BqL4GcgZ.js +633 -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 +6 -2
- package/packages/gui/src/views/models.jsx +380 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-GYcMwmjs.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-H_e3KvZp.js +0 -623
- package/packages/gui/dist/assets/index-GYcMwmjs.css +0 -1
- package/packages/gui/dist/assets/index-H_e3KvZp.js +0 -623
|
@@ -5,6 +5,7 @@ import { spawn as cpSpawn } from 'child_process';
|
|
|
5
5
|
import { createWriteStream, mkdirSync, chmodSync, existsSync, readFileSync, unlinkSync } from 'fs';
|
|
6
6
|
import { resolve } from 'path';
|
|
7
7
|
import { getProvider, getInstalledProviders } from './providers/index.js';
|
|
8
|
+
import { AgentLoop } from './agent-loop.js';
|
|
8
9
|
import { validateAgentConfig } from './validate.js';
|
|
9
10
|
|
|
10
11
|
// Role-specific prompt prefixes — applied during spawn regardless of entry point
|
|
@@ -195,8 +196,9 @@ export class ProcessManager {
|
|
|
195
196
|
}
|
|
196
197
|
|
|
197
198
|
// Resolve auto model routing before registering
|
|
199
|
+
// Treat missing/null/empty model as 'auto' — GUI sends empty string for "Auto" option
|
|
198
200
|
let resolvedModel = config.model;
|
|
199
|
-
const isAutoRouted = config.model === 'auto';
|
|
201
|
+
const isAutoRouted = !config.model || config.model === 'auto';
|
|
200
202
|
|
|
201
203
|
// Register the agent in the registry
|
|
202
204
|
const agent = registry.add({
|
|
@@ -210,7 +212,7 @@ export class ProcessManager {
|
|
|
210
212
|
const { router } = this.daemon;
|
|
211
213
|
router.setMode(agent.id, 'auto');
|
|
212
214
|
const rec = router.recommend(agent.id);
|
|
213
|
-
if (rec) {
|
|
215
|
+
if (rec?.model?.id) {
|
|
214
216
|
resolvedModel = rec.model.id;
|
|
215
217
|
registry.update(agent.id, { model: resolvedModel, routingMode: 'auto', routingReason: rec.reason });
|
|
216
218
|
}
|
|
@@ -316,6 +318,71 @@ For normal file edits within your scope, proceed without review.
|
|
|
316
318
|
}
|
|
317
319
|
}
|
|
318
320
|
|
|
321
|
+
// Set up log capture (shared between CLI and agent loop paths)
|
|
322
|
+
const logDir = resolve(this.daemon.grooveDir, 'logs');
|
|
323
|
+
mkdirSync(logDir, { recursive: true });
|
|
324
|
+
const logPath = resolve(logDir, `${sanitizeFilename(agent.name)}.log`);
|
|
325
|
+
const logStream = createWriteStream(logPath, { flags: 'a', mode: 0o600 });
|
|
326
|
+
|
|
327
|
+
// ─── Agent Loop path (local models with built-in agentic runtime) ───
|
|
328
|
+
if (provider.constructor.useAgentLoop) {
|
|
329
|
+
const loopConfig = provider.getLoopConfig(spawnConfig);
|
|
330
|
+
logStream.write(`[${new Date().toISOString()}] GROOVE agent-loop: model=${loopConfig.model} api=${loopConfig.apiBase}\n`);
|
|
331
|
+
|
|
332
|
+
const loop = new AgentLoop({ daemon: this.daemon, agent, loopConfig, logStream });
|
|
333
|
+
this.handles.set(agent.id, { loop, logStream });
|
|
334
|
+
registry.update(agent.id, { status: 'running' });
|
|
335
|
+
|
|
336
|
+
// Record spawn lifecycle event
|
|
337
|
+
if (this.daemon.timeline) {
|
|
338
|
+
this.daemon.timeline.recordEvent('spawn', {
|
|
339
|
+
agentId: agent.id, agentName: agent.name, role: agent.role,
|
|
340
|
+
provider: agent.provider, model: loopConfig.model,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Wire output events — ProcessManager handles subsystem feeding + GUI broadcast
|
|
345
|
+
loop.on('output', (output) => {
|
|
346
|
+
this._handleAgentOutput(agent.id, output);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Wire exit — same lifecycle as CLI agents (timeline, broadcast, journalist, phase2)
|
|
350
|
+
loop.on('exit', ({ code, signal, status }) => {
|
|
351
|
+
logStream.write(`[${new Date().toISOString()}] Agent loop exited: status=${status}\n`);
|
|
352
|
+
logStream.end();
|
|
353
|
+
this.handles.delete(agent.id);
|
|
354
|
+
registry.update(agent.id, { status, pid: null });
|
|
355
|
+
|
|
356
|
+
if (this.daemon.timeline) {
|
|
357
|
+
const agentData = registry.get(agent.id);
|
|
358
|
+
const evtType = status === 'completed' ? 'complete' : status === 'crashed' ? 'crash' : 'kill';
|
|
359
|
+
this.daemon.timeline.recordEvent(evtType, {
|
|
360
|
+
agentId: agent.id, agentName: agent.name, role: agent.role,
|
|
361
|
+
finalTokens: agentData?.tokensUsed || 0, costUsd: agentData?.costUsd || 0,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
this.daemon.broadcast({ type: 'agent:exit', agentId: agent.id, code: code || 0, signal, status });
|
|
366
|
+
if (this.daemon.integrations) this.daemon.integrations.refreshMcpJson();
|
|
367
|
+
if (status === 'completed' && this.daemon.journalist) this.daemon.journalist.cycle().catch(() => {});
|
|
368
|
+
this._checkPhase2(agent.id);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Wire errors — broadcast to GUI for display
|
|
372
|
+
loop.on('error', ({ message }) => {
|
|
373
|
+
this.daemon.broadcast({
|
|
374
|
+
type: 'agent:output', agentId: agent.id,
|
|
375
|
+
data: { type: 'activity', subtype: 'error', data: message },
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// Start the agent loop with the fully assembled prompt
|
|
380
|
+
loop.start(spawnConfig.prompt);
|
|
381
|
+
return agent;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ─── CLI Spawn path (Claude Code, Codex, Gemini, Ollama CLI) ────────
|
|
385
|
+
|
|
319
386
|
// Write MCP config for agent integrations (command/args only, no secrets)
|
|
320
387
|
// Credentials are injected via process environment below
|
|
321
388
|
let integrationEnv = {};
|
|
@@ -327,12 +394,6 @@ For normal file edits within your scope, proceed without review.
|
|
|
327
394
|
const spawnCmd = provider.buildSpawnCommand(spawnConfig);
|
|
328
395
|
const { command, args, env, stdin: stdinData } = spawnCmd;
|
|
329
396
|
|
|
330
|
-
// Set up log capture
|
|
331
|
-
const logDir = resolve(this.daemon.grooveDir, 'logs');
|
|
332
|
-
mkdirSync(logDir, { recursive: true });
|
|
333
|
-
const logPath = resolve(logDir, `${sanitizeFilename(agent.name)}.log`);
|
|
334
|
-
const logStream = createWriteStream(logPath, { flags: 'a', mode: 0o600 });
|
|
335
|
-
|
|
336
397
|
// Log the spawn command (mask anything that looks like an API key)
|
|
337
398
|
const maskArg = (a) => /^(sk-|AIza|key-|token-)/.test(a) ? '***' : a;
|
|
338
399
|
const safeArgs = args.map((a) => maskArg(a.includes(' ') ? `"${a}"` : a));
|
|
@@ -437,9 +498,14 @@ For normal file edits within your scope, proceed without review.
|
|
|
437
498
|
}
|
|
438
499
|
});
|
|
439
500
|
|
|
440
|
-
// Capture stderr
|
|
501
|
+
// Capture stderr — collect for crash reporting
|
|
502
|
+
const stderrBuf = [];
|
|
441
503
|
proc.stderr.on('data', (chunk) => {
|
|
442
|
-
|
|
504
|
+
const text = chunk.toString();
|
|
505
|
+
logStream.write(`[stderr] ${text}`);
|
|
506
|
+
stderrBuf.push(text);
|
|
507
|
+
// Keep last 2KB of stderr for crash reporting
|
|
508
|
+
while (stderrBuf.join('').length > 2048) stderrBuf.shift();
|
|
443
509
|
});
|
|
444
510
|
|
|
445
511
|
// Handle process exit
|
|
@@ -456,6 +522,9 @@ For normal file edits within your scope, proceed without review.
|
|
|
456
522
|
? 'completed'
|
|
457
523
|
: 'crashed';
|
|
458
524
|
|
|
525
|
+
// Capture crash error from stderr for UI display
|
|
526
|
+
const crashError = finalStatus === 'crashed' ? stderrBuf.join('').trim().slice(-500) : null;
|
|
527
|
+
|
|
459
528
|
registry.update(agent.id, { status: finalStatus, pid: null });
|
|
460
529
|
|
|
461
530
|
// Record lifecycle event for timeline
|
|
@@ -474,6 +543,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
474
543
|
code,
|
|
475
544
|
signal,
|
|
476
545
|
status: finalStatus,
|
|
546
|
+
error: crashError || undefined,
|
|
477
547
|
});
|
|
478
548
|
|
|
479
549
|
// Refresh MCP config — remove integrations no longer needed by running agents
|
|
@@ -497,11 +567,73 @@ For normal file edits within your scope, proceed without review.
|
|
|
497
567
|
|
|
498
568
|
this.handles.delete(agent.id);
|
|
499
569
|
registry.update(agent.id, { status: 'crashed', pid: null });
|
|
570
|
+
this.daemon.broadcast({
|
|
571
|
+
type: 'agent:exit',
|
|
572
|
+
agentId: agent.id,
|
|
573
|
+
code: null,
|
|
574
|
+
signal: null,
|
|
575
|
+
status: 'crashed',
|
|
576
|
+
error: err.message,
|
|
577
|
+
});
|
|
500
578
|
});
|
|
501
579
|
|
|
502
580
|
return agent;
|
|
503
581
|
}
|
|
504
582
|
|
|
583
|
+
/**
|
|
584
|
+
* Shared output handler for agent loop events.
|
|
585
|
+
* Feeds registry, token tracker, classifier, router, and broadcasts to GUI.
|
|
586
|
+
*/
|
|
587
|
+
_handleAgentOutput(agentId, output) {
|
|
588
|
+
const { registry, tokens, classifier, router } = this.daemon;
|
|
589
|
+
const agent = registry.get(agentId);
|
|
590
|
+
if (!agent) return;
|
|
591
|
+
|
|
592
|
+
// Feed classifier for complexity tracking (informs model routing)
|
|
593
|
+
classifier.addEvent(agentId, output);
|
|
594
|
+
|
|
595
|
+
const updates = { lastActivity: new Date().toISOString() };
|
|
596
|
+
|
|
597
|
+
// Token tracking — feed subsystems with full breakdown
|
|
598
|
+
if (output.tokensUsed !== undefined && output.tokensUsed > 0) {
|
|
599
|
+
updates.tokensUsed = agent.tokensUsed + output.tokensUsed;
|
|
600
|
+
tokens.record(agentId, {
|
|
601
|
+
tokens: output.tokensUsed,
|
|
602
|
+
inputTokens: output.inputTokens,
|
|
603
|
+
outputTokens: output.outputTokens,
|
|
604
|
+
cacheReadTokens: output.cacheReadTokens,
|
|
605
|
+
cacheCreationTokens: output.cacheCreationTokens,
|
|
606
|
+
model: output.model,
|
|
607
|
+
estimatedCostUsd: output.estimatedCostUsd,
|
|
608
|
+
});
|
|
609
|
+
const tier = classifier.classify(agentId);
|
|
610
|
+
router.recordUsage(agentId, output.model || agent.model, output.tokensUsed, tier);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Session result data (cost, duration, turns)
|
|
614
|
+
if (output.type === 'result') {
|
|
615
|
+
tokens.recordResult(agentId, {
|
|
616
|
+
costUsd: output.cost, durationMs: output.duration, turns: output.turns,
|
|
617
|
+
});
|
|
618
|
+
if (output.cost) updates.costUsd = (agent.costUsd || 0) + output.cost;
|
|
619
|
+
if (output.duration) updates.durationMs = output.duration;
|
|
620
|
+
if (output.turns) updates.turns = output.turns;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Context window usage (0-1 scale) — drives rotation threshold
|
|
624
|
+
if (output.contextUsage !== undefined) {
|
|
625
|
+
updates.contextUsage = output.contextUsage;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Session ID for resume support
|
|
629
|
+
if (output.sessionId) {
|
|
630
|
+
updates.sessionId = output.sessionId;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
registry.update(agentId, updates);
|
|
634
|
+
this.daemon.broadcast({ type: 'agent:output', agentId, data: output });
|
|
635
|
+
}
|
|
636
|
+
|
|
505
637
|
/**
|
|
506
638
|
* Check if a completed/crashed agent was the last phase 1 agent in a team.
|
|
507
639
|
* If so, auto-spawn the phase 2 (QC/finisher) agents.
|
|
@@ -718,8 +850,19 @@ For normal file edits within your scope, proceed without review.
|
|
|
718
850
|
return;
|
|
719
851
|
}
|
|
720
852
|
|
|
721
|
-
const { proc, logStream } = handle;
|
|
853
|
+
const { proc, loop, logStream } = handle;
|
|
722
854
|
|
|
855
|
+
// Agent loop path — clean async stop
|
|
856
|
+
if (loop) {
|
|
857
|
+
await loop.stop();
|
|
858
|
+
// Exit handler already fired; finish cleanup
|
|
859
|
+
this.handles.delete(agentId);
|
|
860
|
+
this.daemon.registry.remove(agentId);
|
|
861
|
+
this.daemon.locks.release(agentId);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// CLI process path
|
|
723
866
|
return new Promise((resolveKill) => {
|
|
724
867
|
// Give the process 5s to exit gracefully
|
|
725
868
|
const forceTimer = setTimeout(() => {
|
|
@@ -747,6 +890,31 @@ For normal file edits within your scope, proceed without review.
|
|
|
747
890
|
});
|
|
748
891
|
}
|
|
749
892
|
|
|
893
|
+
/**
|
|
894
|
+
* Send a message to a running agent loop.
|
|
895
|
+
* Returns true if the message was sent, false if the agent doesn't have an active loop.
|
|
896
|
+
*/
|
|
897
|
+
async sendMessage(agentId, message) {
|
|
898
|
+
const handle = this.handles.get(agentId);
|
|
899
|
+
if (!handle?.loop) return false;
|
|
900
|
+
|
|
901
|
+
const { loop } = handle;
|
|
902
|
+
if (!loop.running) return false;
|
|
903
|
+
|
|
904
|
+
// Fire and forget — the loop processes the message asynchronously
|
|
905
|
+
// and emits output events that flow through the normal handler
|
|
906
|
+
loop.sendMessage(message).catch(() => {});
|
|
907
|
+
return true;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Check if an agent is using the agent loop runtime (vs CLI process).
|
|
912
|
+
*/
|
|
913
|
+
hasAgentLoop(agentId) {
|
|
914
|
+
const handle = this.handles.get(agentId);
|
|
915
|
+
return !!(handle?.loop);
|
|
916
|
+
}
|
|
917
|
+
|
|
750
918
|
async killAll() {
|
|
751
919
|
const ids = Array.from(this.handles.keys());
|
|
752
920
|
await Promise.all(ids.map((id) => this.kill(id)));
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// GROOVE — Codex Provider (OpenAI)
|
|
2
2
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
3
|
|
|
4
|
-
import { execSync } from 'child_process';
|
|
4
|
+
import { execSync, spawn } from 'child_process';
|
|
5
|
+
import { existsSync, readFileSync } from 'fs';
|
|
6
|
+
import { resolve } from 'path';
|
|
7
|
+
import { homedir } from 'os';
|
|
5
8
|
import { Provider } from './base.js';
|
|
6
9
|
|
|
7
10
|
export class CodexProvider extends Provider {
|
|
@@ -10,6 +13,8 @@ export class CodexProvider extends Provider {
|
|
|
10
13
|
static command = 'codex';
|
|
11
14
|
static authType = 'api-key';
|
|
12
15
|
static envKey = 'OPENAI_API_KEY';
|
|
16
|
+
// Auth hint — Codex uses its own auth system, not just env vars
|
|
17
|
+
static authHint = 'Codex requires `codex login` — run: echo "YOUR_KEY" | codex login --with-api-key';
|
|
13
18
|
static models = [
|
|
14
19
|
{ id: 'gpt-5.4-pro', name: 'GPT-5.4 Pro', tier: 'heavy', pricing: { input: 0.015, output: 0.06 } },
|
|
15
20
|
{ id: 'gpt-5.4', name: 'GPT-5.4', tier: 'heavy', pricing: { input: 0.005, output: 0.02 } },
|
|
@@ -32,6 +37,51 @@ export class CodexProvider extends Provider {
|
|
|
32
37
|
return 'npm i -g @openai/codex';
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Check if Codex has valid authentication.
|
|
42
|
+
* Codex uses its own auth at ~/.codex/auth.json (NOT just OPENAI_API_KEY env var).
|
|
43
|
+
* Users must run: codex login (ChatGPT) or: echo "key" | codex login --with-api-key
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* Auto-login to Codex CLI when user saves an API key in GROOVE.
|
|
47
|
+
* Pipes the key to `codex login --with-api-key` so users don't need
|
|
48
|
+
* to know about Codex's separate auth system.
|
|
49
|
+
*/
|
|
50
|
+
static async onKeySet(key) {
|
|
51
|
+
if (!CodexProvider.isInstalled()) return { ok: false, error: 'Codex not installed' };
|
|
52
|
+
return new Promise((res) => {
|
|
53
|
+
const proc = spawn('codex', ['login', '--with-api-key'], {
|
|
54
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
55
|
+
timeout: 15000,
|
|
56
|
+
});
|
|
57
|
+
let stderr = '';
|
|
58
|
+
proc.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
59
|
+
proc.stdin.write(key);
|
|
60
|
+
proc.stdin.end();
|
|
61
|
+
proc.on('exit', (code) => {
|
|
62
|
+
res(code === 0
|
|
63
|
+
? { ok: true, message: 'Codex authenticated via API key' }
|
|
64
|
+
: { ok: false, error: stderr.slice(-200) || `codex login failed (exit ${code})` });
|
|
65
|
+
});
|
|
66
|
+
proc.on('error', (err) => res({ ok: false, error: err.message }));
|
|
67
|
+
setTimeout(() => { try { proc.kill(); } catch {} res({ ok: false, error: 'Timeout' }); }, 15000);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static isAuthenticated() {
|
|
72
|
+
const authPath = resolve(homedir(), '.codex', 'auth.json');
|
|
73
|
+
if (!existsSync(authPath)) return { authenticated: false, reason: 'No auth found. Run: codex login' };
|
|
74
|
+
try {
|
|
75
|
+
const auth = JSON.parse(readFileSync(authPath, 'utf8'));
|
|
76
|
+
if (auth.auth_mode === 'chatgpt' && auth.tokens?.id_token) return { authenticated: true, method: 'chatgpt' };
|
|
77
|
+
if (auth.auth_mode === 'api-key' && auth.OPENAI_API_KEY) return { authenticated: true, method: 'api-key' };
|
|
78
|
+
if (auth.OPENAI_API_KEY) return { authenticated: true, method: 'api-key' };
|
|
79
|
+
return { authenticated: false, reason: 'Auth expired or missing. Run: codex login' };
|
|
80
|
+
} catch {
|
|
81
|
+
return { authenticated: false, reason: 'Auth file corrupted. Run: codex login' };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
35
85
|
buildSpawnCommand(agent) {
|
|
36
86
|
// Use 'codex exec' for non-interactive (headless) operation
|
|
37
87
|
const args = ['exec'];
|
|
@@ -40,12 +40,13 @@ export class GeminiProvider extends Provider {
|
|
|
40
40
|
// Without this, Gemini in headless mode can only output text
|
|
41
41
|
args.push('--yolo');
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// Pass prompt via stdin to avoid OS arg length limits
|
|
44
|
+
// (intro context + role prompt + skill content can be very long)
|
|
45
45
|
return {
|
|
46
46
|
command: 'gemini',
|
|
47
47
|
args,
|
|
48
48
|
env: agent.apiKey ? { GEMINI_API_KEY: agent.apiKey } : {},
|
|
49
|
+
stdin: agent.prompt || undefined,
|
|
49
50
|
};
|
|
50
51
|
}
|
|
51
52
|
|
|
@@ -5,12 +5,14 @@ import { ClaudeCodeProvider } from './claude-code.js';
|
|
|
5
5
|
import { CodexProvider } from './codex.js';
|
|
6
6
|
import { GeminiProvider } from './gemini.js';
|
|
7
7
|
import { OllamaProvider } from './ollama.js';
|
|
8
|
+
import { LocalProvider } from './local.js';
|
|
8
9
|
|
|
9
10
|
const providers = {
|
|
10
11
|
'claude-code': new ClaudeCodeProvider(),
|
|
11
12
|
'codex': new CodexProvider(),
|
|
12
13
|
'gemini': new GeminiProvider(),
|
|
13
14
|
'ollama': new OllamaProvider(),
|
|
15
|
+
'local': new LocalProvider(),
|
|
14
16
|
};
|
|
15
17
|
|
|
16
18
|
export function getProvider(name) {
|
|
@@ -24,6 +26,8 @@ export function listProviders() {
|
|
|
24
26
|
installed: p.constructor.isInstalled(),
|
|
25
27
|
authType: p.constructor.authType,
|
|
26
28
|
envKey: p.constructor.envKey || null,
|
|
29
|
+
authHint: p.constructor.authHint || null,
|
|
30
|
+
authStatus: p.constructor.isAuthenticated?.() || null,
|
|
27
31
|
models: p.constructor.models,
|
|
28
32
|
installCommand: p.constructor.installCommand(),
|
|
29
33
|
canHotSwap: p.switchModel ? p.switchModel() : false,
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// GROOVE — Local Model Provider (Agent Loop Runtime)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// Manages local inference backends (Ollama API, llama-server, any OpenAI-compatible endpoint).
|
|
5
|
+
// Unlike CLI providers (Claude Code, Codex, Gemini), this provider uses GROOVE's built-in
|
|
6
|
+
// agent loop instead of spawning a child process. Set via `useAgentLoop = true`.
|
|
7
|
+
|
|
8
|
+
import { execSync } from 'child_process';
|
|
9
|
+
import { Provider } from './base.js';
|
|
10
|
+
import { OllamaProvider } from './ollama.js';
|
|
11
|
+
|
|
12
|
+
// Context window sizes for models commonly run locally
|
|
13
|
+
// These are the *effective* context sizes used by default (not theoretical max)
|
|
14
|
+
const CONTEXT_WINDOWS = {
|
|
15
|
+
// Qwen 2.5 Coder family — 32K default (can extend to 128K with YaRN)
|
|
16
|
+
'qwen2.5-coder:7b': 32768,
|
|
17
|
+
'qwen2.5-coder:14b': 32768,
|
|
18
|
+
'qwen2.5-coder:32b': 32768,
|
|
19
|
+
'qwen3-coder-next': 32768,
|
|
20
|
+
// DeepSeek family — large native context
|
|
21
|
+
'deepseek-r1:7b': 65536,
|
|
22
|
+
'deepseek-r1:14b': 65536,
|
|
23
|
+
'deepseek-r1:32b': 65536,
|
|
24
|
+
'deepseek-coder-v2:16b': 65536,
|
|
25
|
+
// Llama 3.1 — 128K context
|
|
26
|
+
'llama3.1:8b': 131072,
|
|
27
|
+
'llama3.1:70b': 131072,
|
|
28
|
+
// Mistral family
|
|
29
|
+
'mistral:7b': 32768,
|
|
30
|
+
'mixtral:8x7b': 32768,
|
|
31
|
+
'codestral': 32768,
|
|
32
|
+
'devstral-small-2': 32768,
|
|
33
|
+
// Google
|
|
34
|
+
'gemma4:12b': 32768,
|
|
35
|
+
'gemma4:26b': 32768,
|
|
36
|
+
'codegemma': 8192,
|
|
37
|
+
// Microsoft
|
|
38
|
+
'phi3:mini': 128000,
|
|
39
|
+
'phi3:medium': 128000,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const DEFAULT_CONTEXT_WINDOW = 32768;
|
|
43
|
+
|
|
44
|
+
// Models known to support native tool/function calling via the OpenAI API format
|
|
45
|
+
const TOOL_CALLING_MODELS = new Set([
|
|
46
|
+
'qwen2.5-coder', 'qwen3-coder-next',
|
|
47
|
+
'llama3.1', 'llama3.3',
|
|
48
|
+
'mistral', 'mixtral', 'codestral', 'devstral-small-2',
|
|
49
|
+
'gemma4',
|
|
50
|
+
'phi3',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
export class LocalProvider extends Provider {
|
|
54
|
+
static name = 'local';
|
|
55
|
+
static displayName = 'Local Models (Agent Loop)';
|
|
56
|
+
static command = 'ollama';
|
|
57
|
+
static authType = 'local';
|
|
58
|
+
static useAgentLoop = true;
|
|
59
|
+
|
|
60
|
+
static get models() {
|
|
61
|
+
return OllamaProvider.models;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static get catalog() {
|
|
65
|
+
return OllamaProvider.catalog;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static isInstalled() {
|
|
69
|
+
return LocalProvider._hasOllama() || LocalProvider._hasLlamaServer();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
static _hasOllama() {
|
|
73
|
+
try {
|
|
74
|
+
execSync('which ollama', { stdio: 'ignore' });
|
|
75
|
+
return true;
|
|
76
|
+
} catch { return false; }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static _hasLlamaServer() {
|
|
80
|
+
try {
|
|
81
|
+
execSync('which llama-server', { stdio: 'ignore' });
|
|
82
|
+
return true;
|
|
83
|
+
} catch { return false; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static installCommand() {
|
|
87
|
+
return OllamaProvider.installCommand();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static hardwareRequirements() {
|
|
91
|
+
return OllamaProvider.hardwareRequirements();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
static getSystemHardware() {
|
|
95
|
+
return OllamaProvider.getSystemHardware();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static getInstalledModels() {
|
|
99
|
+
return OllamaProvider.getInstalledModels();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get configuration for the agent loop runtime.
|
|
104
|
+
* Called by ProcessManager when useAgentLoop is true.
|
|
105
|
+
*/
|
|
106
|
+
getLoopConfig(agent) {
|
|
107
|
+
const model = agent.model || 'qwen2.5-coder:7b';
|
|
108
|
+
const contextWindow = this.getContextWindow(model);
|
|
109
|
+
|
|
110
|
+
// Determine API endpoint
|
|
111
|
+
let apiBase = 'http://localhost:11434/v1'; // Ollama's OpenAI-compatible endpoint (default)
|
|
112
|
+
|
|
113
|
+
// Custom endpoint override from agent config or daemon config
|
|
114
|
+
if (agent.apiBase) {
|
|
115
|
+
apiBase = agent.apiBase;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
apiBase,
|
|
120
|
+
model,
|
|
121
|
+
contextWindow,
|
|
122
|
+
temperature: 0.1,
|
|
123
|
+
maxResponseTokens: 4096,
|
|
124
|
+
stream: true,
|
|
125
|
+
headers: {},
|
|
126
|
+
apiKey: agent.apiKey || null,
|
|
127
|
+
introContext: agent.introContext || '',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
getContextWindow(modelId) {
|
|
132
|
+
if (!modelId) return DEFAULT_CONTEXT_WINDOW;
|
|
133
|
+
// Exact match first
|
|
134
|
+
if (CONTEXT_WINDOWS[modelId]) return CONTEXT_WINDOWS[modelId];
|
|
135
|
+
// Prefix match (e.g., 'qwen2.5-coder:7b-q4' matches 'qwen2.5-coder:7b')
|
|
136
|
+
for (const [key, value] of Object.entries(CONTEXT_WINDOWS)) {
|
|
137
|
+
if (modelId.startsWith(key)) return value;
|
|
138
|
+
}
|
|
139
|
+
return DEFAULT_CONTEXT_WINDOW;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Check if a model supports native tool/function calling through the API.
|
|
144
|
+
* Models without native support would need prompt-based tool injection (future).
|
|
145
|
+
*/
|
|
146
|
+
supportsToolCalling(modelId) {
|
|
147
|
+
if (!modelId) return false;
|
|
148
|
+
const base = modelId.split(':')[0];
|
|
149
|
+
return TOOL_CALLING_MODELS.has(base);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- Provider interface (backward compat) ---
|
|
153
|
+
|
|
154
|
+
buildSpawnCommand(agent) {
|
|
155
|
+
// Not used when useAgentLoop is true, but required by interface
|
|
156
|
+
const model = agent.model || 'qwen2.5-coder:7b';
|
|
157
|
+
return {
|
|
158
|
+
command: 'ollama', args: ['run', model],
|
|
159
|
+
env: { OLLAMA_API_BASE: 'http://localhost:11434' },
|
|
160
|
+
stdin: agent.prompt || undefined,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
buildHeadlessCommand(prompt, model) {
|
|
165
|
+
const m = model || 'qwen2.5-coder:7b';
|
|
166
|
+
return { command: 'ollama', args: ['run', m], env: {}, stdin: prompt };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
switchModel() {
|
|
170
|
+
return false; // Needs rotation for model switch
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
parseOutput(line) {
|
|
174
|
+
const trimmed = (line || '').trim();
|
|
175
|
+
if (!trimmed) return null;
|
|
176
|
+
// Try to parse structured log entries from agent loop
|
|
177
|
+
try {
|
|
178
|
+
const entry = JSON.parse(trimmed);
|
|
179
|
+
if (entry.type) return entry;
|
|
180
|
+
} catch { /* plain text */ }
|
|
181
|
+
return { type: 'activity', data: trimmed };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -51,7 +51,7 @@ export class Registry extends EventEmitter {
|
|
|
51
51
|
if (!agent) return null;
|
|
52
52
|
|
|
53
53
|
// Only allow known fields to prevent prototype pollution
|
|
54
|
-
const SAFE_FIELDS = ['status', 'pid', 'tokensUsed', 'contextUsage', 'lastActivity', 'model', 'name', 'routingMode', 'routingReason', 'sessionId', 'skills', 'integrations', 'workingDir', 'effort', 'costUsd', 'durationMs', 'turns', 'inputTokens', 'outputTokens', 'teamId'];
|
|
54
|
+
const SAFE_FIELDS = ['status', 'pid', 'tokensUsed', 'contextUsage', 'lastActivity', 'model', 'provider', 'name', 'routingMode', 'routingReason', 'sessionId', 'skills', 'integrations', 'workingDir', 'effort', 'costUsd', 'durationMs', 'turns', 'inputTokens', 'outputTokens', 'teamId'];
|
|
55
55
|
for (const key of Object.keys(updates)) {
|
|
56
56
|
if (SAFE_FIELDS.includes(key)) {
|
|
57
57
|
agent[key] = updates[key];
|