groove-dev 0.8.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/CLAUDE.md +197 -0
- package/LICENSE +40 -0
- package/README.md +115 -0
- package/docs/GUI_DESIGN_SPEC.md +402 -0
- package/favicon.png +0 -0
- package/groove-logo-short.png +0 -0
- package/groove-logo.png +0 -0
- package/package.json +70 -0
- package/packages/cli/bin/groove.js +98 -0
- package/packages/cli/package.json +15 -0
- package/packages/cli/src/client.js +25 -0
- package/packages/cli/src/commands/agents.js +38 -0
- package/packages/cli/src/commands/approve.js +50 -0
- package/packages/cli/src/commands/config.js +35 -0
- package/packages/cli/src/commands/kill.js +15 -0
- package/packages/cli/src/commands/nuke.js +19 -0
- package/packages/cli/src/commands/providers.js +40 -0
- package/packages/cli/src/commands/rotate.js +16 -0
- package/packages/cli/src/commands/spawn.js +91 -0
- package/packages/cli/src/commands/start.js +31 -0
- package/packages/cli/src/commands/status.js +38 -0
- package/packages/cli/src/commands/stop.js +15 -0
- package/packages/cli/src/commands/team.js +77 -0
- package/packages/daemon/package.json +18 -0
- package/packages/daemon/src/adaptive.js +237 -0
- package/packages/daemon/src/api.js +533 -0
- package/packages/daemon/src/classifier.js +126 -0
- package/packages/daemon/src/credentials.js +121 -0
- package/packages/daemon/src/firstrun.js +93 -0
- package/packages/daemon/src/index.js +208 -0
- package/packages/daemon/src/introducer.js +238 -0
- package/packages/daemon/src/journalist.js +600 -0
- package/packages/daemon/src/lockmanager.js +58 -0
- package/packages/daemon/src/pm.js +108 -0
- package/packages/daemon/src/process.js +361 -0
- package/packages/daemon/src/providers/aider.js +72 -0
- package/packages/daemon/src/providers/base.js +38 -0
- package/packages/daemon/src/providers/claude-code.js +167 -0
- package/packages/daemon/src/providers/codex.js +68 -0
- package/packages/daemon/src/providers/gemini.js +62 -0
- package/packages/daemon/src/providers/index.js +38 -0
- package/packages/daemon/src/providers/ollama.js +94 -0
- package/packages/daemon/src/registry.js +89 -0
- package/packages/daemon/src/rotator.js +185 -0
- package/packages/daemon/src/router.js +132 -0
- package/packages/daemon/src/state.js +34 -0
- package/packages/daemon/src/supervisor.js +178 -0
- package/packages/daemon/src/teams.js +203 -0
- package/packages/daemon/src/terminal/base.js +27 -0
- package/packages/daemon/src/terminal/generic.js +27 -0
- package/packages/daemon/src/terminal/tmux.js +64 -0
- package/packages/daemon/src/tokentracker.js +124 -0
- package/packages/daemon/src/validate.js +122 -0
- package/packages/daemon/templates/api-builder.json +18 -0
- package/packages/daemon/templates/fullstack.json +18 -0
- package/packages/daemon/templates/monorepo.json +24 -0
- package/packages/gui/dist/assets/index-BO95Rm1F.js +73 -0
- package/packages/gui/dist/assets/index-CPzm9ZE9.css +1 -0
- package/packages/gui/dist/favicon.png +0 -0
- package/packages/gui/dist/groove-logo-short.png +0 -0
- package/packages/gui/dist/groove-logo.png +0 -0
- package/packages/gui/dist/index.html +13 -0
- package/packages/gui/index.html +12 -0
- package/packages/gui/package.json +22 -0
- package/packages/gui/public/favicon.png +0 -0
- package/packages/gui/public/groove-logo-short.png +0 -0
- package/packages/gui/public/groove-logo.png +0 -0
- package/packages/gui/src/App.jsx +215 -0
- package/packages/gui/src/components/AgentActions.jsx +347 -0
- package/packages/gui/src/components/AgentChat.jsx +479 -0
- package/packages/gui/src/components/AgentNode.jsx +117 -0
- package/packages/gui/src/components/AgentPanel.jsx +115 -0
- package/packages/gui/src/components/AgentStats.jsx +333 -0
- package/packages/gui/src/components/ApprovalQueue.jsx +156 -0
- package/packages/gui/src/components/EmptyState.jsx +100 -0
- package/packages/gui/src/components/SpawnPanel.jsx +515 -0
- package/packages/gui/src/components/TeamSelector.jsx +162 -0
- package/packages/gui/src/main.jsx +9 -0
- package/packages/gui/src/stores/groove.js +247 -0
- package/packages/gui/src/theme.css +67 -0
- package/packages/gui/src/views/AgentTree.jsx +148 -0
- package/packages/gui/src/views/CommandCenter.jsx +620 -0
- package/packages/gui/src/views/JournalistFeed.jsx +149 -0
- package/packages/gui/vite.config.js +19 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// GROOVE — Claude Code Provider
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import { writeFileSync, readFileSync, existsSync } from 'fs';
|
|
6
|
+
import { resolve } from 'path';
|
|
7
|
+
import { Provider } from './base.js';
|
|
8
|
+
|
|
9
|
+
export class ClaudeCodeProvider extends Provider {
|
|
10
|
+
static name = 'claude-code';
|
|
11
|
+
static displayName = 'Claude Code';
|
|
12
|
+
static command = 'claude';
|
|
13
|
+
static authType = 'subscription';
|
|
14
|
+
static models = [
|
|
15
|
+
{ id: 'claude-opus-4-6', name: 'Claude Opus 4.6', tier: 'heavy', contextWindow: 1_000_000 },
|
|
16
|
+
{ id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', tier: 'medium', contextWindow: 200_000 },
|
|
17
|
+
{ id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', tier: 'light', contextWindow: 200_000 },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
static isInstalled() {
|
|
21
|
+
try {
|
|
22
|
+
execSync('which claude', { stdio: 'ignore' });
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static installCommand() {
|
|
30
|
+
return 'npm i -g @anthropic-ai/claude-code';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
buildSpawnCommand(agent) {
|
|
34
|
+
// Claude Code interactive mode:
|
|
35
|
+
// claude [options] [prompt]
|
|
36
|
+
//
|
|
37
|
+
// GROOVE spawns claude with:
|
|
38
|
+
// --dangerously-skip-permissions (autonomous operation)
|
|
39
|
+
// --output-format stream-json (structured stdout for parsing)
|
|
40
|
+
// --verbose (richer output for journalist)
|
|
41
|
+
//
|
|
42
|
+
// The initial prompt is passed as a positional argument.
|
|
43
|
+
// GROOVE context is injected via an append-only section in CLAUDE.md.
|
|
44
|
+
|
|
45
|
+
const args = ['--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions'];
|
|
46
|
+
|
|
47
|
+
if (agent.model) {
|
|
48
|
+
args.push('--model', agent.model);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Pass the initial prompt as positional arg (includes GROOVE context)
|
|
52
|
+
const fullPrompt = this.buildFullPrompt(agent);
|
|
53
|
+
if (fullPrompt) {
|
|
54
|
+
args.push(fullPrompt);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
command: 'claude',
|
|
59
|
+
args,
|
|
60
|
+
env: {},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
buildHeadlessCommand(prompt, model) {
|
|
65
|
+
const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose'];
|
|
66
|
+
if (model) args.push('--model', model);
|
|
67
|
+
return { command: 'claude', args, env: {} };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
buildFullPrompt(agent) {
|
|
71
|
+
const parts = [];
|
|
72
|
+
|
|
73
|
+
// Inject GROOVE context so the agent knows its role and team
|
|
74
|
+
if (agent.introContext) {
|
|
75
|
+
parts.push(agent.introContext);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// User's actual task prompt
|
|
79
|
+
if (agent.prompt) {
|
|
80
|
+
parts.push(`## Your Task\n\n${agent.prompt}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Scope awareness
|
|
84
|
+
if (agent.scope && agent.scope.length > 0) {
|
|
85
|
+
parts.push(
|
|
86
|
+
`## Scope Rules\n\nYou MUST only modify files matching these patterns: ${agent.scope.join(', ')}. ` +
|
|
87
|
+
`Do not touch files outside your scope — other agents own them.`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return parts.join('\n\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
switchModel(agent, newModel) {
|
|
95
|
+
// Claude Code supports mid-session model switching
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
parseOutput(line) {
|
|
100
|
+
// Claude Code stream-json outputs one JSON object per line.
|
|
101
|
+
// Relevant message types:
|
|
102
|
+
// { type: "assistant", message: {...}, session_id: "..." }
|
|
103
|
+
// { type: "result", result: "...", session_id: "..." }
|
|
104
|
+
// { type: "system", message: "..." }
|
|
105
|
+
const lines = line.split('\n').filter(Boolean);
|
|
106
|
+
const events = [];
|
|
107
|
+
|
|
108
|
+
for (const l of lines) {
|
|
109
|
+
try {
|
|
110
|
+
const data = JSON.parse(l);
|
|
111
|
+
|
|
112
|
+
if (data.type === 'assistant') {
|
|
113
|
+
events.push({
|
|
114
|
+
type: 'activity',
|
|
115
|
+
subtype: 'assistant',
|
|
116
|
+
data: data.message?.content || '',
|
|
117
|
+
tokensUsed: data.message?.usage?.output_tokens || 0,
|
|
118
|
+
model: data.message?.model,
|
|
119
|
+
});
|
|
120
|
+
} else if (data.type === 'result') {
|
|
121
|
+
events.push({
|
|
122
|
+
type: 'result',
|
|
123
|
+
data: data.result,
|
|
124
|
+
tokensUsed: data.total_cost_usd ? undefined : 0,
|
|
125
|
+
cost: data.total_cost_usd,
|
|
126
|
+
duration: data.duration_ms,
|
|
127
|
+
turns: data.num_turns,
|
|
128
|
+
});
|
|
129
|
+
} else if (data.type === 'system' && data.subtype === 'usage') {
|
|
130
|
+
// Use actual context window size from model metadata, not hardcoded 200K
|
|
131
|
+
// Opus has 1M, Sonnet/Haiku have 200K — rotation must account for this
|
|
132
|
+
const totalTokens = (data.usage?.cache_read_input_tokens || 0) + (data.usage?.input_tokens || 0);
|
|
133
|
+
const modelId = data.model || events.find((e) => e.model)?.model;
|
|
134
|
+
const modelMeta = ClaudeCodeProvider.models.find((m) => m.id === modelId);
|
|
135
|
+
const contextWindow = modelMeta?.contextWindow || 200_000;
|
|
136
|
+
events.push({
|
|
137
|
+
type: 'usage',
|
|
138
|
+
contextUsage: totalTokens > 0 ? totalTokens / contextWindow : undefined,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
// Not JSON — ignore raw text lines in stream-json mode
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (events.length === 0) return null;
|
|
147
|
+
|
|
148
|
+
// Merge events: accumulate tokens across all events in this chunk,
|
|
149
|
+
// but return the most significant event type (usage > result > activity)
|
|
150
|
+
const merged = events[events.length - 1];
|
|
151
|
+
let totalTokens = 0;
|
|
152
|
+
for (const e of events) {
|
|
153
|
+
if (e.tokensUsed > 0) totalTokens += e.tokensUsed;
|
|
154
|
+
}
|
|
155
|
+
if (totalTokens > 0) merged.tokensUsed = totalTokens;
|
|
156
|
+
|
|
157
|
+
return merged;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
injectContext(agent, contextMarkdown) {
|
|
161
|
+
// For Claude Code, inject context by writing to a .groove/context/<agent>.md file
|
|
162
|
+
// and referencing it. Claude Code auto-reads CLAUDE.md, so we append there.
|
|
163
|
+
// But we don't want to pollute the user's CLAUDE.md permanently — we use
|
|
164
|
+
// the append-only GROOVE section approach.
|
|
165
|
+
return contextMarkdown;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// GROOVE — Codex Provider (OpenAI)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import { Provider } from './base.js';
|
|
6
|
+
|
|
7
|
+
export class CodexProvider extends Provider {
|
|
8
|
+
static name = 'codex';
|
|
9
|
+
static displayName = 'Codex';
|
|
10
|
+
static command = 'codex';
|
|
11
|
+
static authType = 'api-key';
|
|
12
|
+
static envKey = 'OPENAI_API_KEY';
|
|
13
|
+
static models = [
|
|
14
|
+
{ id: 'o3', name: 'o3', tier: 'heavy' },
|
|
15
|
+
{ id: 'o4-mini', name: 'o4-mini', tier: 'medium' },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
static isInstalled() {
|
|
19
|
+
try {
|
|
20
|
+
execSync('which codex', { stdio: 'ignore' });
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static installCommand() {
|
|
28
|
+
return 'npm i -g @openai/codex';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
buildSpawnCommand(agent) {
|
|
32
|
+
const args = [];
|
|
33
|
+
|
|
34
|
+
if (agent.model) args.push('--model', agent.model);
|
|
35
|
+
|
|
36
|
+
// Codex uses full-auto approval mode for autonomous operation
|
|
37
|
+
args.push('--approval-mode', 'full-auto');
|
|
38
|
+
|
|
39
|
+
if (agent.prompt) args.push(agent.prompt);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
command: 'codex',
|
|
43
|
+
args,
|
|
44
|
+
env: agent.apiKey ? { OPENAI_API_KEY: agent.apiKey } : {},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
buildHeadlessCommand(prompt, model) {
|
|
49
|
+
const args = ['exec', prompt];
|
|
50
|
+
if (model) args.push('--model', model);
|
|
51
|
+
return { command: 'codex', args, env: {} };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
switchModel() {
|
|
55
|
+
return false; // Codex doesn't support mid-session model switch
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
parseOutput(line) {
|
|
59
|
+
// Codex outputs plain text by default
|
|
60
|
+
const trimmed = line.trim();
|
|
61
|
+
if (!trimmed) return null;
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
type: 'activity',
|
|
65
|
+
data: trimmed,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// GROOVE — Gemini CLI Provider (Google)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import { Provider } from './base.js';
|
|
6
|
+
|
|
7
|
+
export class GeminiProvider extends Provider {
|
|
8
|
+
static name = 'gemini';
|
|
9
|
+
static displayName = 'Gemini CLI';
|
|
10
|
+
static command = 'gemini';
|
|
11
|
+
static authType = 'api-key';
|
|
12
|
+
static envKey = 'GEMINI_API_KEY';
|
|
13
|
+
static models = [
|
|
14
|
+
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', tier: 'heavy' },
|
|
15
|
+
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', tier: 'medium' },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
static isInstalled() {
|
|
19
|
+
try {
|
|
20
|
+
execSync('which gemini', { stdio: 'ignore' });
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static installCommand() {
|
|
28
|
+
return 'npm i -g @anthropic-ai/gemini-cli';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
buildSpawnCommand(agent) {
|
|
32
|
+
const args = [];
|
|
33
|
+
|
|
34
|
+
if (agent.model) args.push('--model', agent.model);
|
|
35
|
+
if (agent.prompt) args.push(agent.prompt);
|
|
36
|
+
|
|
37
|
+
// Sandbox mode off for full filesystem access
|
|
38
|
+
args.push('--sandbox', 'false');
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
command: 'gemini',
|
|
42
|
+
args,
|
|
43
|
+
env: agent.apiKey ? { GEMINI_API_KEY: agent.apiKey } : {},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
buildHeadlessCommand(prompt, model) {
|
|
48
|
+
const args = ['-p', prompt];
|
|
49
|
+
if (model) args.push('--model', model);
|
|
50
|
+
return { command: 'gemini', args, env: {} };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
switchModel() {
|
|
54
|
+
return false; // Gemini CLI doesn't support mid-session switch
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
parseOutput(line) {
|
|
58
|
+
const trimmed = line.trim();
|
|
59
|
+
if (!trimmed) return null;
|
|
60
|
+
return { type: 'activity', data: trimmed };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// GROOVE — Provider Registry
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { ClaudeCodeProvider } from './claude-code.js';
|
|
5
|
+
import { CodexProvider } from './codex.js';
|
|
6
|
+
import { GeminiProvider } from './gemini.js';
|
|
7
|
+
import { AiderProvider } from './aider.js';
|
|
8
|
+
import { OllamaProvider } from './ollama.js';
|
|
9
|
+
|
|
10
|
+
const providers = {
|
|
11
|
+
'claude-code': new ClaudeCodeProvider(),
|
|
12
|
+
'codex': new CodexProvider(),
|
|
13
|
+
'gemini': new GeminiProvider(),
|
|
14
|
+
'aider': new AiderProvider(),
|
|
15
|
+
'ollama': new OllamaProvider(),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function getProvider(name) {
|
|
19
|
+
return providers[name] || null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function listProviders() {
|
|
23
|
+
return Object.entries(providers).map(([key, p]) => ({
|
|
24
|
+
id: key,
|
|
25
|
+
name: p.constructor.displayName,
|
|
26
|
+
installed: p.constructor.isInstalled(),
|
|
27
|
+
authType: p.constructor.authType,
|
|
28
|
+
envKey: p.constructor.envKey || null,
|
|
29
|
+
models: p.constructor.models,
|
|
30
|
+
installCommand: p.constructor.installCommand(),
|
|
31
|
+
canHotSwap: p.switchModel ? p.switchModel() : false,
|
|
32
|
+
hardwareRequirements: p.constructor.hardwareRequirements?.() || null,
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getInstalledProviders() {
|
|
37
|
+
return listProviders().filter((p) => p.installed);
|
|
38
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// GROOVE — Ollama Provider (Local Models)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import { Provider } from './base.js';
|
|
6
|
+
|
|
7
|
+
export class OllamaProvider extends Provider {
|
|
8
|
+
static name = 'ollama';
|
|
9
|
+
static displayName = 'Ollama (Local)';
|
|
10
|
+
static command = 'ollama';
|
|
11
|
+
static authType = 'local';
|
|
12
|
+
static models = [
|
|
13
|
+
{ id: 'qwen2.5-coder:32b', name: 'Qwen 2.5 Coder 32B', tier: 'heavy' },
|
|
14
|
+
{ id: 'qwen2.5-coder:7b', name: 'Qwen 2.5 Coder 7B', tier: 'medium' },
|
|
15
|
+
{ id: 'codellama:13b', name: 'Code Llama 13B', tier: 'medium' },
|
|
16
|
+
{ id: 'deepseek-coder-v2:16b', name: 'DeepSeek Coder V2', tier: 'medium' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
static isInstalled() {
|
|
20
|
+
try {
|
|
21
|
+
execSync('which ollama', { stdio: 'ignore' });
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static installCommand() {
|
|
29
|
+
return 'curl -fsSL https://ollama.ai/install.sh | sh';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static hardwareRequirements() {
|
|
33
|
+
return {
|
|
34
|
+
minRAM: 8,
|
|
35
|
+
recommendedRAM: 16,
|
|
36
|
+
gpuRecommended: true,
|
|
37
|
+
note: '7B models need ~8GB RAM, 32B models need ~24GB RAM',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static getInstalledModels() {
|
|
42
|
+
try {
|
|
43
|
+
const output = execSync('ollama list', { encoding: 'utf8' });
|
|
44
|
+
const lines = output.split('\n').slice(1).filter(Boolean);
|
|
45
|
+
return lines.map((line) => {
|
|
46
|
+
const parts = line.split(/\s+/);
|
|
47
|
+
return { name: parts[0], size: parts[1] };
|
|
48
|
+
});
|
|
49
|
+
} catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
buildSpawnCommand(agent) {
|
|
55
|
+
// Ollama works best via Aider as a frontend
|
|
56
|
+
// aider --model ollama/<model> gives a full coding experience
|
|
57
|
+
const model = agent.model || 'qwen2.5-coder:7b';
|
|
58
|
+
|
|
59
|
+
const args = [
|
|
60
|
+
'--model', `ollama/${model}`,
|
|
61
|
+
'--yes-always',
|
|
62
|
+
'--no-auto-commits',
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
if (agent.prompt) {
|
|
66
|
+
args.push('--message', agent.prompt);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
command: 'aider',
|
|
71
|
+
args,
|
|
72
|
+
env: { OLLAMA_API_BASE: 'http://localhost:11434' },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
buildHeadlessCommand(prompt, model) {
|
|
77
|
+
const m = model || 'qwen2.5-coder:7b';
|
|
78
|
+
return {
|
|
79
|
+
command: 'ollama',
|
|
80
|
+
args: ['run', m, prompt],
|
|
81
|
+
env: {},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
switchModel() {
|
|
86
|
+
return false; // Needs rotation for model switch
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
parseOutput(line) {
|
|
90
|
+
const trimmed = line.trim();
|
|
91
|
+
if (!trimmed) return null;
|
|
92
|
+
return { type: 'activity', data: trimmed };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// GROOVE — Agent Registry
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { EventEmitter } from 'events';
|
|
5
|
+
import { randomUUID } from 'crypto';
|
|
6
|
+
|
|
7
|
+
export class Registry extends EventEmitter {
|
|
8
|
+
constructor(state) {
|
|
9
|
+
super();
|
|
10
|
+
this.state = state;
|
|
11
|
+
this.agents = new Map();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
add(config) {
|
|
15
|
+
const agent = {
|
|
16
|
+
id: randomUUID().slice(0, 8),
|
|
17
|
+
name: config.name || `${config.role}-${this.agents.size + 1}`,
|
|
18
|
+
role: config.role,
|
|
19
|
+
scope: config.scope || [],
|
|
20
|
+
provider: config.provider || 'claude-code',
|
|
21
|
+
model: config.model || null,
|
|
22
|
+
prompt: config.prompt || '',
|
|
23
|
+
permission: config.permission || 'full',
|
|
24
|
+
workingDir: config.workingDir || process.cwd(),
|
|
25
|
+
status: 'starting',
|
|
26
|
+
pid: null,
|
|
27
|
+
spawnedAt: new Date().toISOString(),
|
|
28
|
+
lastActivity: null,
|
|
29
|
+
tokensUsed: 0,
|
|
30
|
+
contextUsage: 0,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
this.agents.set(agent.id, agent);
|
|
34
|
+
this.emit('change', this.getAll());
|
|
35
|
+
return agent;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get(id) {
|
|
39
|
+
return this.agents.get(id) || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
getAll() {
|
|
43
|
+
return Array.from(this.agents.values());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
update(id, updates) {
|
|
47
|
+
const agent = this.agents.get(id);
|
|
48
|
+
if (!agent) return null;
|
|
49
|
+
|
|
50
|
+
// Only allow known fields to prevent prototype pollution
|
|
51
|
+
const SAFE_FIELDS = ['status', 'pid', 'tokensUsed', 'contextUsage', 'lastActivity', 'model', 'name', 'routingMode', 'routingReason'];
|
|
52
|
+
for (const key of Object.keys(updates)) {
|
|
53
|
+
if (SAFE_FIELDS.includes(key)) {
|
|
54
|
+
agent[key] = updates[key];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
agent.lastActivity = new Date().toISOString();
|
|
58
|
+
this.emit('change', this.getAll());
|
|
59
|
+
return agent;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
remove(id) {
|
|
63
|
+
const agent = this.agents.get(id);
|
|
64
|
+
if (!agent) return false;
|
|
65
|
+
|
|
66
|
+
this.agents.delete(id);
|
|
67
|
+
this.emit('change', this.getAll());
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
findByRole(role) {
|
|
72
|
+
return this.getAll().filter((a) => a.role === role);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
findByProvider(provider) {
|
|
76
|
+
return this.getAll().filter((a) => a.provider === provider);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
restore(agents) {
|
|
80
|
+
for (const agent of agents) {
|
|
81
|
+
agent.status = 'stopped';
|
|
82
|
+
agent.pid = null;
|
|
83
|
+
this.agents.set(agent.id, agent);
|
|
84
|
+
}
|
|
85
|
+
if (agents.length > 0) {
|
|
86
|
+
this.emit('change', this.getAll());
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// GROOVE — Context Rotation Engine
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { EventEmitter } from 'events';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_THRESHOLD = 0.75; // 75% context usage triggers rotation
|
|
7
|
+
const CHECK_INTERVAL = 15_000; // Check every 15 seconds
|
|
8
|
+
|
|
9
|
+
export class Rotator extends EventEmitter {
|
|
10
|
+
constructor(daemon) {
|
|
11
|
+
super();
|
|
12
|
+
this.daemon = daemon;
|
|
13
|
+
this.interval = null;
|
|
14
|
+
this.rotationHistory = []; // [{ agentId, agentName, oldTokens, timestamp, brief }]
|
|
15
|
+
this.rotating = new Set(); // Agent IDs currently being rotated
|
|
16
|
+
this.enabled = false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
start() {
|
|
20
|
+
if (this.interval) return;
|
|
21
|
+
this.enabled = true;
|
|
22
|
+
this.interval = setInterval(() => this.check(), CHECK_INTERVAL);
|
|
23
|
+
console.log(' Rotator started (auto-rotation enabled)');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
stop() {
|
|
27
|
+
if (this.interval) {
|
|
28
|
+
clearInterval(this.interval);
|
|
29
|
+
this.interval = null;
|
|
30
|
+
}
|
|
31
|
+
this.enabled = false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async check() {
|
|
35
|
+
const agents = this.daemon.registry.getAll();
|
|
36
|
+
const running = agents.filter((a) => a.status === 'running');
|
|
37
|
+
|
|
38
|
+
for (const agent of running) {
|
|
39
|
+
if (this.rotating.has(agent.id)) continue;
|
|
40
|
+
|
|
41
|
+
const threshold = this.daemon.adaptive
|
|
42
|
+
? this.daemon.adaptive.getThreshold(agent.provider, agent.role)
|
|
43
|
+
: DEFAULT_THRESHOLD;
|
|
44
|
+
|
|
45
|
+
if (agent.contextUsage >= threshold) {
|
|
46
|
+
// Check for natural pause: if agent has been idle for >10s
|
|
47
|
+
const idleMs = agent.lastActivity
|
|
48
|
+
? Date.now() - new Date(agent.lastActivity).getTime()
|
|
49
|
+
: Infinity;
|
|
50
|
+
|
|
51
|
+
if (idleMs > 10_000) {
|
|
52
|
+
console.log(` Rotator: ${agent.name} at ${Math.round(agent.contextUsage * 100)}% — rotating`);
|
|
53
|
+
await this.rotate(agent.id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async rotate(agentId, options = {}) {
|
|
60
|
+
const { registry, processes, journalist } = this.daemon;
|
|
61
|
+
const agent = registry.get(agentId);
|
|
62
|
+
if (!agent) throw new Error(`Agent ${agentId} not found`);
|
|
63
|
+
if (this.rotating.has(agentId)) throw new Error(`Agent ${agentId} is already rotating`);
|
|
64
|
+
|
|
65
|
+
this.rotating.add(agentId);
|
|
66
|
+
|
|
67
|
+
this.daemon.broadcast({
|
|
68
|
+
type: 'rotation:start',
|
|
69
|
+
agentId,
|
|
70
|
+
agentName: agent.name,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
// 1. Record adaptive session so rotation thresholds learn over time
|
|
75
|
+
const classifierEvents = this.daemon.classifier.agentWindows[agentId] || [];
|
|
76
|
+
if (classifierEvents.length > 0) {
|
|
77
|
+
const signals = this.daemon.adaptive.extractSignals(classifierEvents, agent.scope);
|
|
78
|
+
this.daemon.adaptive.recordSession(agent.provider, agent.role, signals);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Clear classifier window for the old agent
|
|
82
|
+
this.daemon.classifier.clearAgent(agentId);
|
|
83
|
+
|
|
84
|
+
// 2. Generate handoff brief from Journalist
|
|
85
|
+
let brief = await journalist.generateHandoffBrief(agent);
|
|
86
|
+
|
|
87
|
+
// Append additional prompt if provided (used by instruct/continue endpoints)
|
|
88
|
+
if (options.additionalPrompt) {
|
|
89
|
+
brief = brief + '\n\n## User Instruction\n\n' + options.additionalPrompt;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 3. Record rotation history
|
|
93
|
+
const record = {
|
|
94
|
+
agentId: agent.id,
|
|
95
|
+
agentName: agent.name,
|
|
96
|
+
role: agent.role,
|
|
97
|
+
provider: agent.provider,
|
|
98
|
+
oldTokens: agent.tokensUsed,
|
|
99
|
+
contextUsage: agent.contextUsage,
|
|
100
|
+
timestamp: new Date().toISOString(),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// 4. Kill/clean up the old agent
|
|
104
|
+
// processes.kill handles both alive and dead agents:
|
|
105
|
+
// - alive: sends SIGTERM, waits for exit, removes from registry
|
|
106
|
+
// - dead: just removes from registry and releases locks
|
|
107
|
+
await processes.kill(agentId);
|
|
108
|
+
|
|
109
|
+
// 5. Respawn with handoff brief as the prompt
|
|
110
|
+
// Preserve auto routing mode so the router re-evaluates on respawn
|
|
111
|
+
const routingMode = this.daemon.router.getMode(agentId);
|
|
112
|
+
const respawnModel = routingMode.mode === 'auto' ? 'auto' : agent.model;
|
|
113
|
+
|
|
114
|
+
const newAgent = await processes.spawn({
|
|
115
|
+
role: agent.role,
|
|
116
|
+
scope: agent.scope,
|
|
117
|
+
provider: agent.provider,
|
|
118
|
+
model: respawnModel,
|
|
119
|
+
prompt: brief,
|
|
120
|
+
permission: agent.permission || 'full',
|
|
121
|
+
workingDir: agent.workingDir,
|
|
122
|
+
name: agent.name, // Keep the same name for continuity
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Carry cumulative token stats so the dashboard shows lifetime totals
|
|
126
|
+
if (agent.tokensUsed > 0) {
|
|
127
|
+
registry.update(newAgent.id, { tokensUsed: agent.tokensUsed });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Record rotation savings in token tracker
|
|
131
|
+
this.daemon.tokens.recordRotation(agent.id, agent.tokensUsed);
|
|
132
|
+
// Each rotation is a cold-start that the Journalist's handoff brief skips
|
|
133
|
+
this.daemon.tokens.recordColdStartSkipped();
|
|
134
|
+
|
|
135
|
+
record.newAgentId = newAgent.id;
|
|
136
|
+
record.newTokens = 0;
|
|
137
|
+
this.rotationHistory.push(record);
|
|
138
|
+
|
|
139
|
+
// Keep last 100 rotations
|
|
140
|
+
if (this.rotationHistory.length > 100) {
|
|
141
|
+
this.rotationHistory = this.rotationHistory.slice(-100);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
this.daemon.broadcast({
|
|
145
|
+
type: 'rotation:complete',
|
|
146
|
+
agentId: newAgent.id,
|
|
147
|
+
agentName: newAgent.name,
|
|
148
|
+
oldAgentId: agentId,
|
|
149
|
+
tokensSaved: agent.tokensUsed,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
this.emit('rotation', record);
|
|
153
|
+
|
|
154
|
+
return newAgent;
|
|
155
|
+
} catch (err) {
|
|
156
|
+
this.daemon.broadcast({
|
|
157
|
+
type: 'rotation:failed',
|
|
158
|
+
agentId,
|
|
159
|
+
error: err.message,
|
|
160
|
+
});
|
|
161
|
+
throw err;
|
|
162
|
+
} finally {
|
|
163
|
+
this.rotating.delete(agentId);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
isRotating(agentId) {
|
|
168
|
+
return this.rotating.has(agentId);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
getHistory() {
|
|
172
|
+
return this.rotationHistory;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
getStats() {
|
|
176
|
+
const totalRotations = this.rotationHistory.length;
|
|
177
|
+
const totalTokensSaved = this.rotationHistory.reduce((sum, r) => sum + r.oldTokens, 0);
|
|
178
|
+
return {
|
|
179
|
+
enabled: this.enabled,
|
|
180
|
+
totalRotations,
|
|
181
|
+
totalTokensSaved,
|
|
182
|
+
rotating: Array.from(this.rotating),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|