groove-dev 0.27.59 → 0.27.61
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/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +73 -56
- package/node_modules/@groove-dev/daemon/src/conversations.js +78 -35
- package/node_modules/@groove-dev/daemon/src/journalist.js +1 -0
- package/node_modules/@groove-dev/daemon/src/process.js +17 -7
- package/node_modules/@groove-dev/daemon/src/providers/base.js +4 -0
- package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +63 -0
- package/node_modules/@groove-dev/daemon/src/providers/codex.js +55 -0
- package/node_modules/@groove-dev/daemon/src/providers/gemini.js +53 -0
- package/node_modules/@groove-dev/daemon/src/providers/groove-network.js +1 -1
- package/node_modules/@groove-dev/daemon/src/providers/index.js +16 -1
- package/node_modules/@groove-dev/daemon/src/providers/local.js +44 -0
- package/node_modules/@groove-dev/daemon/src/providers/ollama.js +44 -0
- package/node_modules/@groove-dev/daemon/src/rotator.js +4 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-B3AqeyS4.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-DWao9glo.js +8614 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +3 -2
- package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +13 -7
- package/node_modules/@groove-dev/gui/src/components/network/activity-chart.jsx +245 -0
- package/node_modules/@groove-dev/gui/src/components/network/compute-header.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/network/network-health.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/network/network-status.jsx +5 -5
- package/node_modules/@groove-dev/gui/src/components/network/node-details.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/ui/update-modal.jsx +70 -0
- package/node_modules/@groove-dev/gui/src/stores/groove.js +66 -6
- package/node_modules/@groove-dev/gui/src/views/network.jsx +99 -17
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +73 -56
- package/packages/daemon/src/conversations.js +78 -35
- package/packages/daemon/src/journalist.js +1 -0
- package/packages/daemon/src/process.js +17 -7
- package/packages/daemon/src/providers/base.js +4 -0
- package/packages/daemon/src/providers/claude-code.js +63 -0
- package/packages/daemon/src/providers/codex.js +55 -0
- package/packages/daemon/src/providers/gemini.js +53 -0
- package/packages/daemon/src/providers/groove-network.js +1 -1
- package/packages/daemon/src/providers/index.js +16 -1
- package/packages/daemon/src/providers/local.js +44 -0
- package/packages/daemon/src/providers/ollama.js +44 -0
- package/packages/daemon/src/rotator.js +4 -0
- package/packages/gui/dist/assets/index-B3AqeyS4.css +1 -0
- package/packages/gui/dist/assets/index-DWao9glo.js +8614 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/chat/chat-view.jsx +3 -2
- package/packages/gui/src/components/chat/model-picker.jsx +1 -1
- package/packages/gui/src/components/layout/status-bar.jsx +13 -7
- package/packages/gui/src/components/network/activity-chart.jsx +245 -0
- package/packages/gui/src/components/network/compute-header.jsx +1 -1
- package/packages/gui/src/components/network/network-health.jsx +1 -1
- package/packages/gui/src/components/network/network-status.jsx +5 -5
- package/packages/gui/src/components/network/node-details.jsx +1 -1
- package/packages/gui/src/components/ui/update-modal.jsx +70 -0
- package/packages/gui/src/stores/groove.js +66 -6
- package/packages/gui/src/views/network.jsx +99 -17
- package/default/fix-beta-endpoint-deployment.md +0 -68
- package/default/groovedev-beta-auth-endpoint.md +0 -166
- package/default/security-review-prompt.md +0 -98
- package/node_modules/@groove-dev/gui/dist/assets/index-BrfCzrxJ.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-BycOlqLx.js +0 -8614
- package/packages/gui/dist/assets/index-BrfCzrxJ.css +0 -1
- package/packages/gui/dist/assets/index-BycOlqLx.js +0 -8614
|
@@ -5,7 +5,7 @@ import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
|
5
5
|
import { resolve } from 'path';
|
|
6
6
|
import { randomUUID } from 'crypto';
|
|
7
7
|
import { spawn as cpSpawn } from 'child_process';
|
|
8
|
-
import { getProvider, getInstalledProviders } from './providers/index.js';
|
|
8
|
+
import { getProvider, getInstalledProviders, isProviderInstalled } from './providers/index.js';
|
|
9
9
|
|
|
10
10
|
export class ConversationManager {
|
|
11
11
|
constructor(daemon) {
|
|
@@ -200,6 +200,17 @@ export class ConversationManager {
|
|
|
200
200
|
this.daemon.broadcast({ type: 'conversation:updated', data: conv });
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
updateModel(id, provider, model) {
|
|
204
|
+
const conv = this.conversations.get(id);
|
|
205
|
+
if (!conv) throw new Error('Conversation not found');
|
|
206
|
+
conv.provider = provider;
|
|
207
|
+
conv.model = model;
|
|
208
|
+
conv.updatedAt = new Date().toISOString();
|
|
209
|
+
this._save();
|
|
210
|
+
this.daemon.broadcast({ type: 'conversation:updated', data: conv });
|
|
211
|
+
return conv;
|
|
212
|
+
}
|
|
213
|
+
|
|
203
214
|
async setMode(id, mode) {
|
|
204
215
|
const conv = this.conversations.get(id);
|
|
205
216
|
if (!conv) throw new Error('Conversation not found');
|
|
@@ -259,36 +270,90 @@ export class ConversationManager {
|
|
|
259
270
|
|
|
260
271
|
_killStreamingProcess(conversationId) {
|
|
261
272
|
const procs = this._getStreamingProcesses();
|
|
262
|
-
const
|
|
263
|
-
if (
|
|
264
|
-
|
|
273
|
+
const handle = procs.get(conversationId);
|
|
274
|
+
if (!handle) return;
|
|
275
|
+
if (handle.abort) {
|
|
276
|
+
handle.abort();
|
|
277
|
+
} else if (handle.kill && !handle.killed) {
|
|
278
|
+
handle.kill();
|
|
265
279
|
}
|
|
266
280
|
procs.delete(conversationId);
|
|
267
281
|
}
|
|
268
282
|
|
|
283
|
+
_getApiKey(providerName) {
|
|
284
|
+
const envMap = {
|
|
285
|
+
'claude-code': 'ANTHROPIC_API_KEY',
|
|
286
|
+
'codex': 'OPENAI_API_KEY',
|
|
287
|
+
'gemini': 'GEMINI_API_KEY',
|
|
288
|
+
};
|
|
289
|
+
const envVar = envMap[providerName];
|
|
290
|
+
if (envVar && process.env[envVar]) return process.env[envVar];
|
|
291
|
+
try {
|
|
292
|
+
return this.daemon.credentials?.getKey(providerName) || null;
|
|
293
|
+
} catch { return null; }
|
|
294
|
+
}
|
|
295
|
+
|
|
269
296
|
async sendMessage(id, message, history) {
|
|
270
297
|
const conv = this.conversations.get(id);
|
|
271
298
|
if (!conv) throw new Error('Conversation not found');
|
|
272
299
|
if (conv.mode !== 'api') throw new Error('sendMessage only works in API mode');
|
|
273
300
|
|
|
274
|
-
// Kill any previous streaming process for this conversation
|
|
275
301
|
this._killStreamingProcess(id);
|
|
276
302
|
|
|
277
|
-
const prompt = this._buildHistoryPrompt(history, message);
|
|
278
|
-
|
|
279
|
-
// Resolve the provider for this conversation
|
|
280
303
|
let provider = getProvider(conv.provider);
|
|
281
304
|
let modelId = conv.model;
|
|
305
|
+
let providerName = conv.provider;
|
|
282
306
|
|
|
283
|
-
if (!provider || !provider
|
|
307
|
+
if (!provider || !isProviderInstalled(conv.provider)) {
|
|
284
308
|
const priority = ['claude-code', 'gemini', 'codex', 'ollama'];
|
|
285
|
-
const
|
|
286
|
-
const fallbackId = priority.find((p) => installed.some((i) => i.id === p));
|
|
309
|
+
const fallbackId = priority.find((p) => isProviderInstalled(p));
|
|
287
310
|
if (!fallbackId) throw new Error('No provider available for chat');
|
|
288
311
|
provider = getProvider(fallbackId);
|
|
312
|
+
providerName = fallbackId;
|
|
289
313
|
modelId = null;
|
|
290
314
|
}
|
|
291
315
|
|
|
316
|
+
// Build messages array for direct API call
|
|
317
|
+
const messages = (history || []).map((m) => ({
|
|
318
|
+
role: m.from === 'user' ? 'user' : 'assistant',
|
|
319
|
+
content: m.text,
|
|
320
|
+
}));
|
|
321
|
+
messages.push({ role: 'user', content: message });
|
|
322
|
+
|
|
323
|
+
const apiKey = this._getApiKey(providerName);
|
|
324
|
+
|
|
325
|
+
// Try direct API streaming first (sub-second latency)
|
|
326
|
+
const controller = provider.streamChat(
|
|
327
|
+
messages, modelId, apiKey,
|
|
328
|
+
(text) => {
|
|
329
|
+
this.daemon.broadcast({
|
|
330
|
+
type: 'conversation:chunk',
|
|
331
|
+
data: { conversationId: id, text },
|
|
332
|
+
});
|
|
333
|
+
},
|
|
334
|
+
() => {
|
|
335
|
+
this._getStreamingProcesses().delete(id);
|
|
336
|
+
this.daemon.broadcast({
|
|
337
|
+
type: 'conversation:complete',
|
|
338
|
+
data: { conversationId: id },
|
|
339
|
+
});
|
|
340
|
+
},
|
|
341
|
+
(err) => {
|
|
342
|
+
this._getStreamingProcesses().delete(id);
|
|
343
|
+
this.daemon.broadcast({
|
|
344
|
+
type: 'conversation:error',
|
|
345
|
+
data: { conversationId: id, error: err.message },
|
|
346
|
+
});
|
|
347
|
+
},
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
if (controller) {
|
|
351
|
+
this._getStreamingProcesses().set(id, controller);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Fallback: headless CLI spawn (for providers without streamChat or missing API key)
|
|
356
|
+
const prompt = this._buildHistoryPrompt(history, message);
|
|
292
357
|
const headlessCmd = provider.buildHeadlessCommand(prompt, modelId);
|
|
293
358
|
const { command, args, env, stdin: stdinData, cwd } = headlessCmd;
|
|
294
359
|
|
|
@@ -306,23 +371,15 @@ export class ConversationManager {
|
|
|
306
371
|
proc.stdin.end();
|
|
307
372
|
}
|
|
308
373
|
|
|
309
|
-
let fullOutput = '';
|
|
310
|
-
|
|
311
374
|
proc.stdout.on('data', (data) => {
|
|
312
375
|
const text = data.toString();
|
|
313
|
-
fullOutput += text;
|
|
314
|
-
|
|
315
|
-
// Parse provider output for streaming chunks
|
|
316
376
|
const lines = text.split('\n');
|
|
317
377
|
for (const line of lines) {
|
|
318
378
|
const trimmed = line.trim();
|
|
319
379
|
if (!trimmed) continue;
|
|
320
380
|
|
|
321
|
-
// Try to parse as JSON (stream-json format)
|
|
322
381
|
try {
|
|
323
382
|
const json = JSON.parse(trimmed);
|
|
324
|
-
|
|
325
|
-
// Claude Code stream-json: assistant message content
|
|
326
383
|
if (json.type === 'assistant' && json.message?.content) {
|
|
327
384
|
for (const block of json.message.content) {
|
|
328
385
|
if (block.type === 'text' && block.text) {
|
|
@@ -334,8 +391,6 @@ export class ConversationManager {
|
|
|
334
391
|
}
|
|
335
392
|
continue;
|
|
336
393
|
}
|
|
337
|
-
|
|
338
|
-
// Claude Code stream-json: content_block_delta
|
|
339
394
|
if (json.type === 'content_block_delta' && json.delta?.text) {
|
|
340
395
|
this.daemon.broadcast({
|
|
341
396
|
type: 'conversation:chunk',
|
|
@@ -343,14 +398,7 @@ export class ConversationManager {
|
|
|
343
398
|
});
|
|
344
399
|
continue;
|
|
345
400
|
}
|
|
346
|
-
|
|
347
|
-
// Claude Code stream-json: result block — skip broadcasting since
|
|
348
|
-
// the content was already streamed via assistant/content_block_delta
|
|
349
|
-
if (json.type === 'result' && json.result) {
|
|
350
|
-
continue;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Groove Network: token events
|
|
401
|
+
if (json.type === 'result' && json.result) continue;
|
|
354
402
|
if (json.type === 'token' && json.text != null) {
|
|
355
403
|
this.daemon.broadcast({
|
|
356
404
|
type: 'conversation:chunk',
|
|
@@ -358,8 +406,6 @@ export class ConversationManager {
|
|
|
358
406
|
});
|
|
359
407
|
continue;
|
|
360
408
|
}
|
|
361
|
-
|
|
362
|
-
// Groove Network: done/complete/result
|
|
363
409
|
if ((json.type === 'done' || json.type === 'complete' || json.type === 'result') && json.text) {
|
|
364
410
|
this.daemon.broadcast({
|
|
365
411
|
type: 'conversation:chunk',
|
|
@@ -367,8 +413,6 @@ export class ConversationManager {
|
|
|
367
413
|
});
|
|
368
414
|
continue;
|
|
369
415
|
}
|
|
370
|
-
|
|
371
|
-
// Gemini / Codex: content text
|
|
372
416
|
if (json.content?.[0]?.text) {
|
|
373
417
|
this.daemon.broadcast({
|
|
374
418
|
type: 'conversation:chunk',
|
|
@@ -376,9 +420,8 @@ export class ConversationManager {
|
|
|
376
420
|
});
|
|
377
421
|
continue;
|
|
378
422
|
}
|
|
379
|
-
} catch { /* not JSON
|
|
423
|
+
} catch { /* not JSON */ }
|
|
380
424
|
|
|
381
|
-
// Non-JSON output: broadcast raw text (some providers output plain text)
|
|
382
425
|
if (!trimmed.startsWith('{')) {
|
|
383
426
|
this.daemon.broadcast({
|
|
384
427
|
type: 'conversation:chunk',
|
|
@@ -884,6 +884,7 @@ export class Journalist {
|
|
|
884
884
|
recentChain ? `## Rotation History\n\n${recentChain}\n` : '',
|
|
885
885
|
agent.prompt ? `## Original Task\n\n${agent.prompt}\n` : '',
|
|
886
886
|
``,
|
|
887
|
+
agent.role === 'planner' ? 'CRITICAL: You are a PLANNING ONLY agent. Do NOT implement code. Route all work to your team via .groove/recommended-team.json.\n' : '',
|
|
887
888
|
`Continue seamlessly — finish what was in progress and deliver the output. Do not announce rotation or greet the user.`,
|
|
888
889
|
].filter(Boolean).join('\n');
|
|
889
890
|
}
|
|
@@ -308,6 +308,13 @@ function sanitizeFilename(name) {
|
|
|
308
308
|
return String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
export function wrapWithRoleReminder(role, message) {
|
|
312
|
+
if (role === 'planner' && !message.startsWith('ROLE REMINDER:')) {
|
|
313
|
+
return 'ROLE REMINDER: You are a PLANNING ONLY agent. Do NOT write code, edit files, or use Edit/Write/Bash tools. Route this task to your team by writing .groove/recommended-team.json.\n\nUser message: ' + message;
|
|
314
|
+
}
|
|
315
|
+
return message;
|
|
316
|
+
}
|
|
317
|
+
|
|
311
318
|
export class ProcessManager {
|
|
312
319
|
constructor(daemon) {
|
|
313
320
|
this.daemon = daemon;
|
|
@@ -568,7 +575,7 @@ Do NOT:
|
|
|
568
575
|
- Analyze the codebase proactively
|
|
569
576
|
|
|
570
577
|
DO: Introduce yourself in one sentence and ask the user what they would like you to work on. Then wait.`;
|
|
571
|
-
} else if (spawnConfig.prompt.startsWith('#
|
|
578
|
+
} else if (spawnConfig.prompt.startsWith('# Handoff Brief')) {
|
|
572
579
|
spawnConfig.prompt += '\n\n## Role Constraints\n\n' + rolePrompt.trim();
|
|
573
580
|
} else {
|
|
574
581
|
spawnConfig.prompt = rolePrompt + 'Task: ' + spawnConfig.prompt;
|
|
@@ -640,7 +647,7 @@ If response says \`"approved":false\`, adjust your approach based on the reason.
|
|
|
640
647
|
For normal file edits within your scope, proceed without review.
|
|
641
648
|
|
|
642
649
|
`;
|
|
643
|
-
if (spawnConfig.prompt.startsWith('#
|
|
650
|
+
if (spawnConfig.prompt.startsWith('# Handoff Brief')) {
|
|
644
651
|
spawnConfig.prompt += '\n\n' + pmPrompt.trim();
|
|
645
652
|
} else {
|
|
646
653
|
spawnConfig.prompt = pmPrompt + spawnConfig.prompt;
|
|
@@ -1728,9 +1735,10 @@ For normal file edits within your scope, proceed without review.
|
|
|
1728
1735
|
const { loop } = handle;
|
|
1729
1736
|
if (!loop.running) return false;
|
|
1730
1737
|
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1738
|
+
const agent = this.daemon.registry.get(agentId);
|
|
1739
|
+
const wrapped = agent ? wrapWithRoleReminder(agent.role, message) : message;
|
|
1740
|
+
|
|
1741
|
+
loop.sendMessage(wrapped).catch(() => {});
|
|
1734
1742
|
return true;
|
|
1735
1743
|
}
|
|
1736
1744
|
|
|
@@ -1743,8 +1751,10 @@ For normal file edits within your scope, proceed without review.
|
|
|
1743
1751
|
}
|
|
1744
1752
|
|
|
1745
1753
|
queueMessage(agentId, message) {
|
|
1746
|
-
this.
|
|
1747
|
-
|
|
1754
|
+
const agent = this.daemon.registry.get(agentId);
|
|
1755
|
+
const wrapped = agent ? wrapWithRoleReminder(agent.role, message) : message;
|
|
1756
|
+
this.pendingMessages.set(agentId, { message: wrapped, timestamp: Date.now() });
|
|
1757
|
+
this.daemon.broadcast({ type: 'agent:message_queued', agentId, message: wrapped });
|
|
1748
1758
|
}
|
|
1749
1759
|
|
|
1750
1760
|
consumePendingMessage(agentId) {
|
|
@@ -10,6 +10,28 @@ import { Provider } from './base.js';
|
|
|
10
10
|
|
|
11
11
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
12
|
|
|
13
|
+
async function parseSSEStream(response, onEvent) {
|
|
14
|
+
const reader = response.body.getReader();
|
|
15
|
+
const decoder = new TextDecoder();
|
|
16
|
+
let buffer = '';
|
|
17
|
+
let gotDone = false;
|
|
18
|
+
while (true) {
|
|
19
|
+
const { done, value } = await reader.read();
|
|
20
|
+
if (done) break;
|
|
21
|
+
buffer += decoder.decode(value, { stream: true });
|
|
22
|
+
const lines = buffer.split('\n');
|
|
23
|
+
buffer = lines.pop();
|
|
24
|
+
for (const line of lines) {
|
|
25
|
+
if (line.startsWith('data: ')) {
|
|
26
|
+
const data = line.slice(6);
|
|
27
|
+
if (data === '[DONE]') { onEvent({ done: true }); gotDone = true; return; }
|
|
28
|
+
try { onEvent(JSON.parse(data)); } catch { /* skip malformed */ }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!gotDone) onEvent({ done: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
13
35
|
export class ClaudeCodeProvider extends Provider {
|
|
14
36
|
static name = 'claude-code';
|
|
15
37
|
static displayName = 'Claude Code';
|
|
@@ -254,6 +276,47 @@ export class ClaudeCodeProvider extends Provider {
|
|
|
254
276
|
return merged;
|
|
255
277
|
}
|
|
256
278
|
|
|
279
|
+
streamChat(messages, model, apiKey, onChunk, onDone, onError) {
|
|
280
|
+
if (!apiKey) return null;
|
|
281
|
+
const controller = new AbortController();
|
|
282
|
+
let finished = false;
|
|
283
|
+
const finish = () => { if (!finished) { finished = true; onDone(); } };
|
|
284
|
+
const body = JSON.stringify({
|
|
285
|
+
model: model || 'claude-sonnet-4-6',
|
|
286
|
+
messages,
|
|
287
|
+
max_tokens: 8192,
|
|
288
|
+
stream: true,
|
|
289
|
+
});
|
|
290
|
+
fetch('https://api.anthropic.com/v1/messages', {
|
|
291
|
+
method: 'POST',
|
|
292
|
+
headers: {
|
|
293
|
+
'x-api-key': apiKey,
|
|
294
|
+
'anthropic-version': '2023-06-01',
|
|
295
|
+
'content-type': 'application/json',
|
|
296
|
+
},
|
|
297
|
+
body,
|
|
298
|
+
signal: controller.signal,
|
|
299
|
+
}).then((res) => {
|
|
300
|
+
if (!res.ok) {
|
|
301
|
+
return res.text().then((t) => { throw new Error(`Anthropic API ${res.status}: ${t.slice(0, 200)}`); });
|
|
302
|
+
}
|
|
303
|
+
return parseSSEStream(res, (event) => {
|
|
304
|
+
if (event.done) { finish(); return; }
|
|
305
|
+
if (event.type === 'content_block_delta' && event.delta?.text) {
|
|
306
|
+
onChunk(event.delta.text);
|
|
307
|
+
} else if (event.type === 'message_stop') {
|
|
308
|
+
finish();
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}).then(() => {
|
|
312
|
+
finish();
|
|
313
|
+
}).catch((err) => {
|
|
314
|
+
if (err.name === 'AbortError') return;
|
|
315
|
+
onError(err);
|
|
316
|
+
});
|
|
317
|
+
return controller;
|
|
318
|
+
}
|
|
319
|
+
|
|
257
320
|
static getAuthStatus() {
|
|
258
321
|
try {
|
|
259
322
|
const out = execSync('claude auth status --json', { encoding: 'utf8', timeout: 10_000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
@@ -7,6 +7,26 @@ import { resolve } from 'path';
|
|
|
7
7
|
import { homedir } from 'os';
|
|
8
8
|
import { Provider } from './base.js';
|
|
9
9
|
|
|
10
|
+
async function parseSSEStream(response, onEvent) {
|
|
11
|
+
const reader = response.body.getReader();
|
|
12
|
+
const decoder = new TextDecoder();
|
|
13
|
+
let buffer = '';
|
|
14
|
+
while (true) {
|
|
15
|
+
const { done, value } = await reader.read();
|
|
16
|
+
if (done) break;
|
|
17
|
+
buffer += decoder.decode(value, { stream: true });
|
|
18
|
+
const lines = buffer.split('\n');
|
|
19
|
+
buffer = lines.pop();
|
|
20
|
+
for (const line of lines) {
|
|
21
|
+
if (line.startsWith('data: ')) {
|
|
22
|
+
const data = line.slice(6);
|
|
23
|
+
if (data === '[DONE]') { onEvent({ done: true }); return; }
|
|
24
|
+
try { onEvent(JSON.parse(data)); } catch { /* skip malformed */ }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
10
30
|
export class CodexProvider extends Provider {
|
|
11
31
|
static name = 'codex';
|
|
12
32
|
static displayName = 'Codex';
|
|
@@ -128,6 +148,41 @@ export class CodexProvider extends Provider {
|
|
|
128
148
|
return (inputTokens / 1000) * model.pricing.input + (outputTokens / 1000) * model.pricing.output;
|
|
129
149
|
}
|
|
130
150
|
|
|
151
|
+
streamChat(messages, model, apiKey, onChunk, onDone, onError) {
|
|
152
|
+
if (!apiKey) return null;
|
|
153
|
+
const controller = new AbortController();
|
|
154
|
+
let finished = false;
|
|
155
|
+
const finish = () => { if (!finished) { finished = true; onDone(); } };
|
|
156
|
+
fetch('https://api.openai.com/v1/chat/completions', {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: {
|
|
159
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
160
|
+
'Content-Type': 'application/json',
|
|
161
|
+
},
|
|
162
|
+
body: JSON.stringify({
|
|
163
|
+
model: model || 'gpt-5.4-mini',
|
|
164
|
+
messages,
|
|
165
|
+
stream: true,
|
|
166
|
+
}),
|
|
167
|
+
signal: controller.signal,
|
|
168
|
+
}).then((res) => {
|
|
169
|
+
if (!res.ok) {
|
|
170
|
+
return res.text().then((t) => { throw new Error(`OpenAI API ${res.status}: ${t.slice(0, 200)}`); });
|
|
171
|
+
}
|
|
172
|
+
return parseSSEStream(res, (event) => {
|
|
173
|
+
if (event.done) { finish(); return; }
|
|
174
|
+
const content = event.choices?.[0]?.delta?.content;
|
|
175
|
+
if (content) onChunk(content);
|
|
176
|
+
});
|
|
177
|
+
}).then(() => {
|
|
178
|
+
finish();
|
|
179
|
+
}).catch((err) => {
|
|
180
|
+
if (err.name === 'AbortError') return;
|
|
181
|
+
onError(err);
|
|
182
|
+
});
|
|
183
|
+
return controller;
|
|
184
|
+
}
|
|
185
|
+
|
|
131
186
|
parseOutput(line) {
|
|
132
187
|
const trimmed = line.trim();
|
|
133
188
|
if (!trimmed) return null;
|
|
@@ -4,6 +4,26 @@
|
|
|
4
4
|
import { execSync } from 'child_process';
|
|
5
5
|
import { Provider } from './base.js';
|
|
6
6
|
|
|
7
|
+
async function parseSSEStream(response, onEvent) {
|
|
8
|
+
const reader = response.body.getReader();
|
|
9
|
+
const decoder = new TextDecoder();
|
|
10
|
+
let buffer = '';
|
|
11
|
+
while (true) {
|
|
12
|
+
const { done, value } = await reader.read();
|
|
13
|
+
if (done) break;
|
|
14
|
+
buffer += decoder.decode(value, { stream: true });
|
|
15
|
+
const lines = buffer.split('\n');
|
|
16
|
+
buffer = lines.pop();
|
|
17
|
+
for (const line of lines) {
|
|
18
|
+
if (line.startsWith('data: ')) {
|
|
19
|
+
const data = line.slice(6);
|
|
20
|
+
if (data === '[DONE]') { onEvent({ done: true }); return; }
|
|
21
|
+
try { onEvent(JSON.parse(data)); } catch { /* skip malformed */ }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
export class GeminiProvider extends Provider {
|
|
8
28
|
static name = 'gemini';
|
|
9
29
|
static displayName = 'Gemini CLI';
|
|
@@ -68,6 +88,39 @@ export class GeminiProvider extends Provider {
|
|
|
68
88
|
return (inputTokens / 1000) * model.pricing.input + (outputTokens / 1000) * model.pricing.output;
|
|
69
89
|
}
|
|
70
90
|
|
|
91
|
+
streamChat(messages, model, apiKey, onChunk, onDone, onError) {
|
|
92
|
+
if (!apiKey) return null;
|
|
93
|
+
const controller = new AbortController();
|
|
94
|
+
let finished = false;
|
|
95
|
+
const finish = () => { if (!finished) { finished = true; onDone(); } };
|
|
96
|
+
const m = model || 'gemini-2.5-flash';
|
|
97
|
+
const contents = messages.map((msg) => ({
|
|
98
|
+
role: msg.role === 'assistant' ? 'model' : 'user',
|
|
99
|
+
parts: [{ text: msg.content }],
|
|
100
|
+
}));
|
|
101
|
+
fetch(`https://generativelanguage.googleapis.com/v1beta/models/${m}:streamGenerateContent?alt=sse&key=${apiKey}`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { 'Content-Type': 'application/json' },
|
|
104
|
+
body: JSON.stringify({ contents }),
|
|
105
|
+
signal: controller.signal,
|
|
106
|
+
}).then((res) => {
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
return res.text().then((t) => { throw new Error(`Gemini API ${res.status}: ${t.slice(0, 200)}`); });
|
|
109
|
+
}
|
|
110
|
+
return parseSSEStream(res, (event) => {
|
|
111
|
+
if (event.done) { finish(); return; }
|
|
112
|
+
const text = event.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
113
|
+
if (text) onChunk(text);
|
|
114
|
+
});
|
|
115
|
+
}).then(() => {
|
|
116
|
+
finish();
|
|
117
|
+
}).catch((err) => {
|
|
118
|
+
if (err.name === 'AbortError') return;
|
|
119
|
+
onError(err);
|
|
120
|
+
});
|
|
121
|
+
return controller;
|
|
122
|
+
}
|
|
123
|
+
|
|
71
124
|
parseOutput(line) {
|
|
72
125
|
const trimmed = line.trim();
|
|
73
126
|
if (!trimmed) return null;
|
|
@@ -79,7 +79,7 @@ export class GrooveNetworkProvider extends Provider {
|
|
|
79
79
|
static isOneShot = true;
|
|
80
80
|
|
|
81
81
|
static models = [
|
|
82
|
-
{ id: '
|
|
82
|
+
{ id: 'Qwen/Qwen3-4B', name: 'Qwen 3 4B (Network)', context: 32768 },
|
|
83
83
|
];
|
|
84
84
|
|
|
85
85
|
static isInstalled() {
|
|
@@ -34,6 +34,21 @@ const providers = {
|
|
|
34
34
|
'groove-network': new GrooveNetworkProvider(),
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
const installCache = new Map();
|
|
38
|
+
|
|
39
|
+
export function isProviderInstalled(providerId) {
|
|
40
|
+
if (installCache.has(providerId)) return installCache.get(providerId);
|
|
41
|
+
const p = providers[providerId];
|
|
42
|
+
if (!p) return false;
|
|
43
|
+
const result = p.constructor.isInstalled();
|
|
44
|
+
installCache.set(providerId, result);
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function clearInstallCache() {
|
|
49
|
+
installCache.clear();
|
|
50
|
+
}
|
|
51
|
+
|
|
37
52
|
export function getProvider(name) {
|
|
38
53
|
return providers[name] || null;
|
|
39
54
|
}
|
|
@@ -48,7 +63,7 @@ export function listProviders() {
|
|
|
48
63
|
.map(([key, p]) => ({
|
|
49
64
|
id: key,
|
|
50
65
|
name: p.constructor.displayName,
|
|
51
|
-
installed:
|
|
66
|
+
installed: isProviderInstalled(key),
|
|
52
67
|
authType: p.constructor.authType,
|
|
53
68
|
envKey: p.constructor.envKey || null,
|
|
54
69
|
authHint: p.constructor.authHint || null,
|
|
@@ -194,6 +194,50 @@ export class LocalProvider extends Provider {
|
|
|
194
194
|
return false; // Needs rotation for model switch
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
streamChat(messages, model, apiKey, onChunk, onDone, onError) {
|
|
198
|
+
const controller = new AbortController();
|
|
199
|
+
let finished = false;
|
|
200
|
+
const finish = () => { if (!finished) { finished = true; onDone(); } };
|
|
201
|
+
fetch('http://localhost:11434/api/chat', {
|
|
202
|
+
method: 'POST',
|
|
203
|
+
headers: { 'Content-Type': 'application/json' },
|
|
204
|
+
body: JSON.stringify({
|
|
205
|
+
model: model || 'qwen2.5-coder:7b',
|
|
206
|
+
messages,
|
|
207
|
+
stream: true,
|
|
208
|
+
}),
|
|
209
|
+
signal: controller.signal,
|
|
210
|
+
}).then(async (res) => {
|
|
211
|
+
if (!res.ok) {
|
|
212
|
+
const t = await res.text();
|
|
213
|
+
throw new Error(`Ollama API ${res.status}: ${t.slice(0, 200)}`);
|
|
214
|
+
}
|
|
215
|
+
const reader = res.body.getReader();
|
|
216
|
+
const decoder = new TextDecoder();
|
|
217
|
+
let buffer = '';
|
|
218
|
+
while (true) {
|
|
219
|
+
const { done, value } = await reader.read();
|
|
220
|
+
if (done) break;
|
|
221
|
+
buffer += decoder.decode(value, { stream: true });
|
|
222
|
+
const lines = buffer.split('\n');
|
|
223
|
+
buffer = lines.pop();
|
|
224
|
+
for (const line of lines) {
|
|
225
|
+
if (!line.trim()) continue;
|
|
226
|
+
try {
|
|
227
|
+
const json = JSON.parse(line);
|
|
228
|
+
if (json.done) { finish(); return; }
|
|
229
|
+
if (json.message?.content) onChunk(json.message.content);
|
|
230
|
+
} catch { /* skip malformed */ }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
finish();
|
|
234
|
+
}).catch((err) => {
|
|
235
|
+
if (err.name === 'AbortError') return;
|
|
236
|
+
onError(err);
|
|
237
|
+
});
|
|
238
|
+
return controller;
|
|
239
|
+
}
|
|
240
|
+
|
|
197
241
|
parseOutput(line) {
|
|
198
242
|
const trimmed = (line || '').trim();
|
|
199
243
|
if (!trimmed) return null;
|
|
@@ -275,6 +275,50 @@ export class OllamaProvider extends Provider {
|
|
|
275
275
|
return false; // Needs rotation for model switch
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
streamChat(messages, model, apiKey, onChunk, onDone, onError) {
|
|
279
|
+
const controller = new AbortController();
|
|
280
|
+
let finished = false;
|
|
281
|
+
const finish = () => { if (!finished) { finished = true; onDone(); } };
|
|
282
|
+
fetch('http://localhost:11434/api/chat', {
|
|
283
|
+
method: 'POST',
|
|
284
|
+
headers: { 'Content-Type': 'application/json' },
|
|
285
|
+
body: JSON.stringify({
|
|
286
|
+
model: model || 'llama3.1:8b',
|
|
287
|
+
messages,
|
|
288
|
+
stream: true,
|
|
289
|
+
}),
|
|
290
|
+
signal: controller.signal,
|
|
291
|
+
}).then(async (res) => {
|
|
292
|
+
if (!res.ok) {
|
|
293
|
+
const t = await res.text();
|
|
294
|
+
throw new Error(`Ollama API ${res.status}: ${t.slice(0, 200)}`);
|
|
295
|
+
}
|
|
296
|
+
const reader = res.body.getReader();
|
|
297
|
+
const decoder = new TextDecoder();
|
|
298
|
+
let buffer = '';
|
|
299
|
+
while (true) {
|
|
300
|
+
const { done, value } = await reader.read();
|
|
301
|
+
if (done) break;
|
|
302
|
+
buffer += decoder.decode(value, { stream: true });
|
|
303
|
+
const lines = buffer.split('\n');
|
|
304
|
+
buffer = lines.pop();
|
|
305
|
+
for (const line of lines) {
|
|
306
|
+
if (!line.trim()) continue;
|
|
307
|
+
try {
|
|
308
|
+
const json = JSON.parse(line);
|
|
309
|
+
if (json.done) { finish(); return; }
|
|
310
|
+
if (json.message?.content) onChunk(json.message.content);
|
|
311
|
+
} catch { /* skip malformed */ }
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
finish();
|
|
315
|
+
}).catch((err) => {
|
|
316
|
+
if (err.name === 'AbortError') return;
|
|
317
|
+
onError(err);
|
|
318
|
+
});
|
|
319
|
+
return controller;
|
|
320
|
+
}
|
|
321
|
+
|
|
278
322
|
parseOutput(line) {
|
|
279
323
|
const trimmed = line.trim();
|
|
280
324
|
if (!trimmed) return null;
|
|
@@ -299,6 +299,10 @@ export class Rotator extends EventEmitter {
|
|
|
299
299
|
brief = brief + '\n\n## User Instruction\n\n' + options.additionalPrompt;
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
+
if (agent.role === 'planner' && !brief.includes('PLANNING ONLY')) {
|
|
303
|
+
brief = 'CRITICAL: You are a PLANNING ONLY agent. Do NOT implement code. Route all work to your team via .groove/recommended-team.json.\n\n' + brief;
|
|
304
|
+
}
|
|
305
|
+
|
|
302
306
|
// Persist to Layer 7 handoff chain so future rotations have causal continuity
|
|
303
307
|
if (this.daemon.memory) {
|
|
304
308
|
this.daemon.memory.appendHandoffBrief(agent.role, {
|