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.
Files changed (68) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/api.js +73 -56
  4. package/node_modules/@groove-dev/daemon/src/conversations.js +78 -35
  5. package/node_modules/@groove-dev/daemon/src/journalist.js +1 -0
  6. package/node_modules/@groove-dev/daemon/src/process.js +17 -7
  7. package/node_modules/@groove-dev/daemon/src/providers/base.js +4 -0
  8. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +63 -0
  9. package/node_modules/@groove-dev/daemon/src/providers/codex.js +55 -0
  10. package/node_modules/@groove-dev/daemon/src/providers/gemini.js +53 -0
  11. package/node_modules/@groove-dev/daemon/src/providers/groove-network.js +1 -1
  12. package/node_modules/@groove-dev/daemon/src/providers/index.js +16 -1
  13. package/node_modules/@groove-dev/daemon/src/providers/local.js +44 -0
  14. package/node_modules/@groove-dev/daemon/src/providers/ollama.js +44 -0
  15. package/node_modules/@groove-dev/daemon/src/rotator.js +4 -0
  16. package/node_modules/@groove-dev/gui/dist/assets/index-B3AqeyS4.css +1 -0
  17. package/node_modules/@groove-dev/gui/dist/assets/index-DWao9glo.js +8614 -0
  18. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  19. package/node_modules/@groove-dev/gui/package.json +1 -1
  20. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +3 -2
  21. package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +1 -1
  22. package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +13 -7
  23. package/node_modules/@groove-dev/gui/src/components/network/activity-chart.jsx +245 -0
  24. package/node_modules/@groove-dev/gui/src/components/network/compute-header.jsx +1 -1
  25. package/node_modules/@groove-dev/gui/src/components/network/network-health.jsx +1 -1
  26. package/node_modules/@groove-dev/gui/src/components/network/network-status.jsx +5 -5
  27. package/node_modules/@groove-dev/gui/src/components/network/node-details.jsx +1 -1
  28. package/node_modules/@groove-dev/gui/src/components/ui/update-modal.jsx +70 -0
  29. package/node_modules/@groove-dev/gui/src/stores/groove.js +66 -6
  30. package/node_modules/@groove-dev/gui/src/views/network.jsx +99 -17
  31. package/package.json +1 -1
  32. package/packages/cli/package.json +1 -1
  33. package/packages/daemon/package.json +1 -1
  34. package/packages/daemon/src/api.js +73 -56
  35. package/packages/daemon/src/conversations.js +78 -35
  36. package/packages/daemon/src/journalist.js +1 -0
  37. package/packages/daemon/src/process.js +17 -7
  38. package/packages/daemon/src/providers/base.js +4 -0
  39. package/packages/daemon/src/providers/claude-code.js +63 -0
  40. package/packages/daemon/src/providers/codex.js +55 -0
  41. package/packages/daemon/src/providers/gemini.js +53 -0
  42. package/packages/daemon/src/providers/groove-network.js +1 -1
  43. package/packages/daemon/src/providers/index.js +16 -1
  44. package/packages/daemon/src/providers/local.js +44 -0
  45. package/packages/daemon/src/providers/ollama.js +44 -0
  46. package/packages/daemon/src/rotator.js +4 -0
  47. package/packages/gui/dist/assets/index-B3AqeyS4.css +1 -0
  48. package/packages/gui/dist/assets/index-DWao9glo.js +8614 -0
  49. package/packages/gui/dist/index.html +2 -2
  50. package/packages/gui/package.json +1 -1
  51. package/packages/gui/src/components/chat/chat-view.jsx +3 -2
  52. package/packages/gui/src/components/chat/model-picker.jsx +1 -1
  53. package/packages/gui/src/components/layout/status-bar.jsx +13 -7
  54. package/packages/gui/src/components/network/activity-chart.jsx +245 -0
  55. package/packages/gui/src/components/network/compute-header.jsx +1 -1
  56. package/packages/gui/src/components/network/network-health.jsx +1 -1
  57. package/packages/gui/src/components/network/network-status.jsx +5 -5
  58. package/packages/gui/src/components/network/node-details.jsx +1 -1
  59. package/packages/gui/src/components/ui/update-modal.jsx +70 -0
  60. package/packages/gui/src/stores/groove.js +66 -6
  61. package/packages/gui/src/views/network.jsx +99 -17
  62. package/default/fix-beta-endpoint-deployment.md +0 -68
  63. package/default/groovedev-beta-auth-endpoint.md +0 -166
  64. package/default/security-review-prompt.md +0 -98
  65. package/node_modules/@groove-dev/gui/dist/assets/index-BrfCzrxJ.css +0 -1
  66. package/node_modules/@groove-dev/gui/dist/assets/index-BycOlqLx.js +0 -8614
  67. package/packages/gui/dist/assets/index-BrfCzrxJ.css +0 -1
  68. 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 proc = procs.get(conversationId);
263
- if (proc && !proc.killed) {
264
- proc.kill();
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.constructor.isInstalled()) {
307
+ if (!provider || !isProviderInstalled(conv.provider)) {
284
308
  const priority = ['claude-code', 'gemini', 'codex', 'ollama'];
285
- const installed = getInstalledProviders();
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 — treat as raw text */ }
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('# Agent Handoff Brief')) {
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('# Agent Handoff Brief')) {
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
- // Fire and forget — the loop processes the message asynchronously
1732
- // and emits output events that flow through the normal handler
1733
- loop.sendMessage(message).catch(() => {});
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.pendingMessages.set(agentId, { message, timestamp: Date.now() });
1747
- this.daemon.broadcast({ type: 'agent:message_queued', agentId, message });
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) {
@@ -32,4 +32,8 @@ export class Provider {
32
32
  parseOutput(line) {
33
33
  return null;
34
34
  }
35
+
36
+ streamChat(messages, model, apiKey, onChunk, onDone, onError) {
37
+ return null;
38
+ }
35
39
  }
@@ -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: 'google/gemma-3-4b', name: 'Gemma 3 4B (Network)', context: 8192 },
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: p.constructor.isInstalled(),
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, {