groove-dev 0.27.56 → 0.27.58

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 (36) hide show
  1. package/ai-chat/CHAT_MASTER_PLAN.md +25 -5
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/api.js +37 -9
  5. package/node_modules/@groove-dev/daemon/src/conversations.js +257 -20
  6. package/node_modules/@groove-dev/gui/dist/assets/index-C5WTeZO4.css +1 -0
  7. package/node_modules/@groove-dev/gui/dist/assets/{index-Bb8CIVBT.js → index-oLUl--Me.js} +1736 -1736
  8. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  9. package/node_modules/@groove-dev/gui/package.json +1 -1
  10. package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +28 -10
  11. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +9 -23
  12. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +19 -6
  13. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +18 -10
  14. package/node_modules/@groove-dev/gui/src/components/chat/conversation-list.jsx +18 -13
  15. package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +22 -10
  16. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -2
  17. package/node_modules/@groove-dev/gui/src/stores/groove.js +68 -5
  18. package/package.json +1 -1
  19. package/packages/cli/package.json +1 -1
  20. package/packages/daemon/package.json +1 -1
  21. package/packages/daemon/src/api.js +37 -9
  22. package/packages/daemon/src/conversations.js +257 -20
  23. package/packages/gui/dist/assets/index-C5WTeZO4.css +1 -0
  24. package/packages/gui/dist/assets/{index-Bb8CIVBT.js → index-oLUl--Me.js} +1736 -1736
  25. package/packages/gui/dist/index.html +2 -2
  26. package/packages/gui/package.json +1 -1
  27. package/packages/gui/src/components/chat/chat-header.jsx +28 -10
  28. package/packages/gui/src/components/chat/chat-input.jsx +9 -23
  29. package/packages/gui/src/components/chat/chat-messages.jsx +19 -6
  30. package/packages/gui/src/components/chat/chat-view.jsx +18 -10
  31. package/packages/gui/src/components/chat/conversation-list.jsx +18 -13
  32. package/packages/gui/src/components/chat/model-picker.jsx +22 -10
  33. package/packages/gui/src/components/layout/activity-bar.jsx +2 -2
  34. package/packages/gui/src/stores/groove.js +68 -5
  35. package/node_modules/@groove-dev/gui/dist/assets/index-DOy_oMyr.css +0 -1
  36. package/packages/gui/dist/assets/index-DOy_oMyr.css +0 -1
@@ -14,11 +14,31 @@ Teams are powerful but heavy. Sometimes you just want to ask a question, researc
14
14
 
15
15
  ## Architecture
16
16
 
17
- Chat is a thin layer on top of the existing agent system:
18
- - Each conversation spawns a lightweight agent with role="chat"
19
- - Conversations persist metadata (title, model, timestamps, pinned status) separately from agent lifecycle
20
- - Session resume means returning to a conversation has zero cold-start
21
- - WebSocket streaming delivers real-time responses
17
+ Chat supports two modes the user picks which one fits the moment:
18
+
19
+ ### API Mode (default, lightweight)
20
+ - No agent process spawned each message is a one-shot callHeadless() call
21
+ - Client sends conversation history with each request (context managed client-side)
22
+ - Cheap, fast, casual — like talking to ChatGPT or Claude.ai
23
+ - Uses the journalist's existing callHeadless() infrastructure (works across all providers)
24
+ - Response streamed back via a streaming HTTP response or WebSocket
25
+ - Perfect for: quick questions, brainstorming, research, casual chat
26
+ - Groove Network models are always API mode (inherently one-shot)
27
+
28
+ ### Agent Mode (heavyweight)
29
+ - Spawns a full chat agent with tools, file access, session resume
30
+ - Agent persists between messages — maintains its own context
31
+ - Can read/write files, run commands, search codebase
32
+ - Session resume means zero cold-start when returning
33
+ - More expensive but more powerful
34
+ - Perfect for: code reviews, implementation help, deep analysis
35
+
36
+ ### Mode Switching
37
+ - Toggle in the chat header next to the model picker: "Chat" (API) vs "Agent" (full)
38
+ - Default is API mode — lightweight until you need power
39
+ - Can switch mid-conversation — upgrading to Agent spawns an agent with the conversation history as context
40
+ - Downgrading to API kills the agent, continues with client-side history
41
+ - Conversations persist metadata (title, model, mode, timestamps, pinned status) separately from agent lifecycle
22
42
  - Layer 7 memory gives continuity across conversations
23
43
 
24
44
  ```
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.56",
3
+ "version": "0.27.58",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.56",
3
+ "version": "0.27.58",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -818,12 +818,15 @@ export function createApi(app, daemon) {
818
818
 
819
819
  app.post('/api/conversations', async (req, res) => {
820
820
  try {
821
- const { provider, model, title } = req.body;
821
+ const { provider, model, title, mode } = req.body;
822
822
  if (!provider || typeof provider !== 'string') {
823
823
  return res.status(400).json({ error: 'provider is required' });
824
824
  }
825
- const conversation = await daemon.conversations.create(provider, model, title);
826
- daemon.audit.log('conversation.create', { id: conversation.id, provider, model });
825
+ if (mode && mode !== 'api' && mode !== 'agent') {
826
+ return res.status(400).json({ error: 'mode must be "api" or "agent"' });
827
+ }
828
+ const conversation = await daemon.conversations.create(provider, model, title, mode || 'api');
829
+ daemon.audit.log('conversation.create', { id: conversation.id, provider, model, mode: conversation.mode });
827
830
  res.status(201).json(conversation);
828
831
  } catch (err) {
829
832
  res.status(400).json({ error: err.message });
@@ -836,14 +839,20 @@ export function createApi(app, daemon) {
836
839
  res.json(conversation);
837
840
  });
838
841
 
839
- app.patch('/api/conversations/:id', (req, res) => {
842
+ app.patch('/api/conversations/:id', async (req, res) => {
840
843
  try {
841
844
  const conv = daemon.conversations.get(req.params.id);
842
845
  if (!conv) return res.status(404).json({ error: 'Conversation not found' });
843
846
  if (req.body.title !== undefined) daemon.conversations.rename(req.params.id, req.body.title);
844
847
  if (req.body.pinned !== undefined) daemon.conversations.pin(req.params.id, req.body.pinned);
845
848
  if (req.body.archived !== undefined) daemon.conversations.archive(req.params.id, req.body.archived);
846
- daemon.audit.log('conversation.update', { id: req.params.id });
849
+ if (req.body.mode !== undefined) {
850
+ if (req.body.mode !== 'api' && req.body.mode !== 'agent') {
851
+ return res.status(400).json({ error: 'mode must be "api" or "agent"' });
852
+ }
853
+ await daemon.conversations.setMode(req.params.id, req.body.mode);
854
+ }
855
+ daemon.audit.log('conversation.update', { id: req.params.id, mode: req.body.mode });
847
856
  res.json(daemon.conversations.get(req.params.id));
848
857
  } catch (err) {
849
858
  res.status(400).json({ error: err.message });
@@ -864,19 +873,27 @@ export function createApi(app, daemon) {
864
873
 
865
874
  app.post('/api/conversations/:id/message', async (req, res) => {
866
875
  try {
867
- const { message } = req.body;
876
+ const { message, history } = req.body;
868
877
  if (!message || typeof message !== 'string' || !message.trim()) {
869
878
  return res.status(400).json({ error: 'message is required' });
870
879
  }
871
880
  const conv = daemon.conversations.get(req.params.id);
872
881
  if (!conv) return res.status(404).json({ error: 'Conversation not found' });
873
882
 
874
- const agent = daemon.registry.get(conv.agentId);
875
- if (!agent) return res.status(400).json({ error: 'Agent no longer exists' });
876
-
877
883
  daemon.conversations.autoTitle(req.params.id, message.trim());
878
884
  daemon.conversations.touchUpdatedAt(req.params.id);
879
885
 
886
+ // API mode — lightweight headless streaming, no agent spawned
887
+ if (conv.mode === 'api' || !conv.agentId) {
888
+ await daemon.conversations.sendMessage(req.params.id, message.trim(), history || []);
889
+ daemon.audit.log('conversation.message', { id: req.params.id, mode: 'api' });
890
+ return res.json({ status: 'streaming', mode: 'api' });
891
+ }
892
+
893
+ // Agent mode — existing behavior
894
+ const agent = daemon.registry.get(conv.agentId);
895
+ if (!agent) return res.status(400).json({ error: 'Agent no longer exists' });
896
+
880
897
  // Record user feedback for journalist context
881
898
  if (daemon.journalist) daemon.journalist.recordUserFeedback(agent, message.trim());
882
899
 
@@ -951,6 +968,17 @@ export function createApi(app, daemon) {
951
968
  }
952
969
  });
953
970
 
971
+ app.post('/api/conversations/:id/stop', (req, res) => {
972
+ try {
973
+ const conv = daemon.conversations.get(req.params.id);
974
+ if (!conv) return res.status(404).json({ error: 'Conversation not found' });
975
+ daemon.conversations.stopStreaming(req.params.id);
976
+ res.json({ ok: true });
977
+ } catch (err) {
978
+ res.status(400).json({ error: err.message });
979
+ }
980
+ });
981
+
954
982
  // --- Approvals ---
955
983
 
956
984
  app.get('/api/approvals', (req, res) => {
@@ -4,6 +4,8 @@
4
4
  import { readFileSync, writeFileSync, existsSync } from 'fs';
5
5
  import { resolve } from 'path';
6
6
  import { randomUUID } from 'crypto';
7
+ import { spawn as cpSpawn } from 'child_process';
8
+ import { getProvider, getInstalledProviders } from './providers/index.js';
7
9
 
8
10
  export class ConversationManager {
9
11
  constructor(daemon) {
@@ -53,28 +55,34 @@ export class ConversationManager {
53
55
  return null;
54
56
  }
55
57
 
56
- async create(provider, model, title) {
58
+ async create(provider, model, title, mode = 'api') {
57
59
  const id = randomUUID().slice(0, 12);
58
60
  const now = new Date().toISOString();
59
61
 
60
- const defaultTeam = this.daemon.teams.getDefault();
61
- const workingDir = defaultTeam?.workingDir || this.daemon.projectDir;
62
+ let agentId = null;
62
63
 
63
- const agent = await this.daemon.processes.spawn({
64
- role: 'chat',
65
- provider,
66
- model: model || null,
67
- workingDir,
68
- teamId: defaultTeam?.id || null,
69
- permission: 'full',
70
- });
64
+ if (mode === 'agent') {
65
+ const defaultTeam = this.daemon.teams.getDefault();
66
+ const workingDir = defaultTeam?.workingDir || this.daemon.projectDir;
67
+
68
+ const agent = await this.daemon.processes.spawn({
69
+ role: 'chat',
70
+ provider,
71
+ model: model || null,
72
+ workingDir,
73
+ teamId: defaultTeam?.id || null,
74
+ permission: 'full',
75
+ });
76
+ agentId = agent.id;
77
+ }
71
78
 
72
79
  const conversation = {
73
80
  id,
74
81
  title: title || 'New Chat',
75
- agentId: agent.id,
76
- provider: agent.provider,
77
- model: agent.model,
82
+ agentId,
83
+ provider,
84
+ model: model || null,
85
+ mode: mode === 'agent' ? 'agent' : 'api',
78
86
  createdAt: now,
79
87
  updatedAt: now,
80
88
  pinned: false,
@@ -90,6 +98,9 @@ export class ConversationManager {
90
98
  get(id) {
91
99
  const conv = this.conversations.get(id);
92
100
  if (!conv) return null;
101
+ if (conv.mode === 'api' || !conv.agentId) {
102
+ return { ...conv, agentStatus: conv.agentStatus || null };
103
+ }
93
104
  const agent = this.daemon.registry.get(conv.agentId);
94
105
  return {
95
106
  ...conv,
@@ -99,6 +110,9 @@ export class ConversationManager {
99
110
 
100
111
  list() {
101
112
  const all = [...this.conversations.values()].map((conv) => {
113
+ if (conv.mode === 'api' || !conv.agentId) {
114
+ return { ...conv, agentStatus: conv.agentStatus || null };
115
+ }
102
116
  const agent = this.daemon.registry.get(conv.agentId);
103
117
  return {
104
118
  ...conv,
@@ -149,14 +163,19 @@ export class ConversationManager {
149
163
  const conv = this.conversations.get(id);
150
164
  if (!conv) throw new Error('Conversation not found');
151
165
 
152
- const agent = this.daemon.registry.get(conv.agentId);
153
- if (agent && (agent.status === 'running' || agent.status === 'starting')) {
154
- try { await this.daemon.processes.kill(conv.agentId); } catch { /* ignore */ }
155
- }
156
- if (agent) {
157
- this.daemon.registry.remove(conv.agentId);
166
+ if (conv.agentId) {
167
+ const agent = this.daemon.registry.get(conv.agentId);
168
+ if (agent && (agent.status === 'running' || agent.status === 'starting')) {
169
+ try { await this.daemon.processes.kill(conv.agentId); } catch { /* ignore */ }
170
+ }
171
+ if (agent) {
172
+ this.daemon.registry.remove(conv.agentId);
173
+ }
158
174
  }
159
175
 
176
+ // Kill any active API mode streaming process
177
+ this._killStreamingProcess(id);
178
+
160
179
  this.conversations.delete(id);
161
180
  this._save();
162
181
  this.daemon.broadcast({ type: 'conversation:deleted', data: { id } });
@@ -180,4 +199,222 @@ export class ConversationManager {
180
199
  this._save();
181
200
  this.daemon.broadcast({ type: 'conversation:updated', data: conv });
182
201
  }
202
+
203
+ async setMode(id, mode) {
204
+ const conv = this.conversations.get(id);
205
+ if (!conv) throw new Error('Conversation not found');
206
+ if (mode !== 'api' && mode !== 'agent') throw new Error('Mode must be "api" or "agent"');
207
+ if (conv.mode === mode) return conv;
208
+
209
+ if (mode === 'agent') {
210
+ const defaultTeam = this.daemon.teams.getDefault();
211
+ const workingDir = defaultTeam?.workingDir || this.daemon.projectDir;
212
+ const agent = await this.daemon.processes.spawn({
213
+ role: 'chat',
214
+ provider: conv.provider,
215
+ model: conv.model || null,
216
+ workingDir,
217
+ teamId: defaultTeam?.id || null,
218
+ permission: 'full',
219
+ });
220
+ conv.agentId = agent.id;
221
+ } else {
222
+ // Switching to API mode — kill the agent if running
223
+ this._killStreamingProcess(id);
224
+ if (conv.agentId) {
225
+ const agent = this.daemon.registry.get(conv.agentId);
226
+ if (agent && (agent.status === 'running' || agent.status === 'starting')) {
227
+ try { await this.daemon.processes.kill(conv.agentId); } catch { /* ignore */ }
228
+ }
229
+ if (agent) this.daemon.registry.remove(conv.agentId);
230
+ conv.agentId = null;
231
+ }
232
+ }
233
+
234
+ conv.mode = mode;
235
+ conv.updatedAt = new Date().toISOString();
236
+ this._save();
237
+ this.daemon.broadcast({ type: 'conversation:updated', data: conv });
238
+ return conv;
239
+ }
240
+
241
+ _buildHistoryPrompt(history, newMessage) {
242
+ const parts = [];
243
+ if (history && history.length > 0) {
244
+ parts.push('Previous conversation:');
245
+ for (const msg of history) {
246
+ const role = msg.from === 'user' ? 'User' : 'Assistant';
247
+ parts.push(`${role}: ${msg.text}`);
248
+ }
249
+ parts.push('');
250
+ }
251
+ parts.push(`User: ${newMessage}`);
252
+ return parts.join('\n');
253
+ }
254
+
255
+ _getStreamingProcesses() {
256
+ if (!this._streamingProcesses) this._streamingProcesses = new Map();
257
+ return this._streamingProcesses;
258
+ }
259
+
260
+ _killStreamingProcess(conversationId) {
261
+ const procs = this._getStreamingProcesses();
262
+ const proc = procs.get(conversationId);
263
+ if (proc && !proc.killed) {
264
+ proc.kill();
265
+ }
266
+ procs.delete(conversationId);
267
+ }
268
+
269
+ async sendMessage(id, message, history) {
270
+ const conv = this.conversations.get(id);
271
+ if (!conv) throw new Error('Conversation not found');
272
+ if (conv.mode !== 'api') throw new Error('sendMessage only works in API mode');
273
+
274
+ // Kill any previous streaming process for this conversation
275
+ this._killStreamingProcess(id);
276
+
277
+ const prompt = this._buildHistoryPrompt(history, message);
278
+
279
+ // Resolve the provider for this conversation
280
+ let provider = getProvider(conv.provider);
281
+ let modelId = conv.model;
282
+
283
+ if (!provider || !provider.constructor.isInstalled()) {
284
+ const priority = ['claude-code', 'gemini', 'codex', 'ollama'];
285
+ const installed = getInstalledProviders();
286
+ const fallbackId = priority.find((p) => installed.some((i) => i.id === p));
287
+ if (!fallbackId) throw new Error('No provider available for chat');
288
+ provider = getProvider(fallbackId);
289
+ modelId = null;
290
+ }
291
+
292
+ const headlessCmd = provider.buildHeadlessCommand(prompt, modelId);
293
+ const { command, args, env, stdin: stdinData, cwd } = headlessCmd;
294
+
295
+ const spawnOpts = {
296
+ env: { ...process.env, ...env },
297
+ cwd: cwd || this.daemon.projectDir,
298
+ stdio: stdinData ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
299
+ };
300
+
301
+ const proc = cpSpawn(command, args, spawnOpts);
302
+ this._getStreamingProcesses().set(id, proc);
303
+
304
+ if (stdinData) {
305
+ proc.stdin.write(stdinData);
306
+ proc.stdin.end();
307
+ }
308
+
309
+ let fullOutput = '';
310
+
311
+ proc.stdout.on('data', (data) => {
312
+ const text = data.toString();
313
+ fullOutput += text;
314
+
315
+ // Parse provider output for streaming chunks
316
+ const lines = text.split('\n');
317
+ for (const line of lines) {
318
+ const trimmed = line.trim();
319
+ if (!trimmed) continue;
320
+
321
+ // Try to parse as JSON (stream-json format)
322
+ try {
323
+ const json = JSON.parse(trimmed);
324
+
325
+ // Claude Code stream-json: assistant message content
326
+ if (json.type === 'assistant' && json.message?.content) {
327
+ for (const block of json.message.content) {
328
+ if (block.type === 'text' && block.text) {
329
+ this.daemon.broadcast({
330
+ type: 'conversation:chunk',
331
+ data: { conversationId: id, text: block.text },
332
+ });
333
+ }
334
+ }
335
+ continue;
336
+ }
337
+
338
+ // Claude Code stream-json: content_block_delta
339
+ if (json.type === 'content_block_delta' && json.delta?.text) {
340
+ this.daemon.broadcast({
341
+ type: 'conversation:chunk',
342
+ data: { conversationId: id, text: json.delta.text },
343
+ });
344
+ continue;
345
+ }
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
354
+ if (json.type === 'token' && json.text != null) {
355
+ this.daemon.broadcast({
356
+ type: 'conversation:chunk',
357
+ data: { conversationId: id, text: json.text },
358
+ });
359
+ continue;
360
+ }
361
+
362
+ // Groove Network: done/complete/result
363
+ if ((json.type === 'done' || json.type === 'complete' || json.type === 'result') && json.text) {
364
+ this.daemon.broadcast({
365
+ type: 'conversation:chunk',
366
+ data: { conversationId: id, text: json.text },
367
+ });
368
+ continue;
369
+ }
370
+
371
+ // Gemini / Codex: content text
372
+ if (json.content?.[0]?.text) {
373
+ this.daemon.broadcast({
374
+ type: 'conversation:chunk',
375
+ data: { conversationId: id, text: json.content[0].text },
376
+ });
377
+ continue;
378
+ }
379
+ } catch { /* not JSON — treat as raw text */ }
380
+
381
+ // Non-JSON output: broadcast raw text (some providers output plain text)
382
+ if (!trimmed.startsWith('{')) {
383
+ this.daemon.broadcast({
384
+ type: 'conversation:chunk',
385
+ data: { conversationId: id, text: trimmed },
386
+ });
387
+ }
388
+ }
389
+ });
390
+
391
+ proc.on('error', (err) => {
392
+ this._getStreamingProcesses().delete(id);
393
+ this.daemon.broadcast({
394
+ type: 'conversation:error',
395
+ data: { conversationId: id, error: err.message },
396
+ });
397
+ });
398
+
399
+ proc.on('exit', (code) => {
400
+ this._getStreamingProcesses().delete(id);
401
+ this.daemon.broadcast({
402
+ type: 'conversation:complete',
403
+ data: { conversationId: id, exitCode: code },
404
+ });
405
+ });
406
+
407
+ const timeout = setTimeout(() => {
408
+ if (!proc.killed) proc.kill();
409
+ }, 120_000);
410
+ proc.on('exit', () => clearTimeout(timeout));
411
+ }
412
+
413
+ stopStreaming(id) {
414
+ this._killStreamingProcess(id);
415
+ this.daemon.broadcast({
416
+ type: 'conversation:complete',
417
+ data: { conversationId: id, stopped: true },
418
+ });
419
+ }
183
420
  }