groove-dev 0.27.55 → 0.27.57

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 (42) hide show
  1. package/ai-chat/CHAT_MASTER_PLAN.md +184 -0
  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 +169 -0
  5. package/node_modules/@groove-dev/daemon/src/conversations.js +423 -0
  6. package/node_modules/@groove-dev/daemon/src/index.js +2 -0
  7. package/node_modules/@groove-dev/gui/dist/assets/index-C5WTeZO4.css +1 -0
  8. package/node_modules/@groove-dev/gui/dist/assets/{index-De-OWmBX.js → index-X58BAjGp.js} +1752 -1745
  9. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  10. package/node_modules/@groove-dev/gui/package.json +1 -1
  11. package/node_modules/@groove-dev/gui/src/app.jsx +2 -0
  12. package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +138 -0
  13. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +112 -0
  14. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +347 -0
  15. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +165 -0
  16. package/node_modules/@groove-dev/gui/src/components/chat/conversation-list.jsx +154 -0
  17. package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +143 -0
  18. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
  19. package/node_modules/@groove-dev/gui/src/stores/groove.js +220 -0
  20. package/node_modules/@groove-dev/gui/src/views/chat.jsx +6 -0
  21. package/package.json +1 -1
  22. package/packages/cli/package.json +1 -1
  23. package/packages/daemon/package.json +1 -1
  24. package/packages/daemon/src/api.js +169 -0
  25. package/packages/daemon/src/conversations.js +423 -0
  26. package/packages/daemon/src/index.js +2 -0
  27. package/packages/gui/dist/assets/index-C5WTeZO4.css +1 -0
  28. package/packages/gui/dist/assets/{index-De-OWmBX.js → index-X58BAjGp.js} +1752 -1745
  29. package/packages/gui/dist/index.html +2 -2
  30. package/packages/gui/package.json +1 -1
  31. package/packages/gui/src/app.jsx +2 -0
  32. package/packages/gui/src/components/chat/chat-header.jsx +138 -0
  33. package/packages/gui/src/components/chat/chat-input.jsx +112 -0
  34. package/packages/gui/src/components/chat/chat-messages.jsx +347 -0
  35. package/packages/gui/src/components/chat/chat-view.jsx +165 -0
  36. package/packages/gui/src/components/chat/conversation-list.jsx +154 -0
  37. package/packages/gui/src/components/chat/model-picker.jsx +143 -0
  38. package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
  39. package/packages/gui/src/stores/groove.js +220 -0
  40. package/packages/gui/src/views/chat.jsx +6 -0
  41. package/node_modules/@groove-dev/gui/dist/assets/index-CyVj0fHl.css +0 -1
  42. package/packages/gui/dist/assets/index-CyVj0fHl.css +0 -1
@@ -0,0 +1,423 @@
1
+ // GROOVE — Conversation Manager
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
5
+ import { resolve } from 'path';
6
+ import { randomUUID } from 'crypto';
7
+ import { spawn as cpSpawn } from 'child_process';
8
+ import { getProvider, getInstalledProviders } from './providers/index.js';
9
+
10
+ export class ConversationManager {
11
+ constructor(daemon) {
12
+ this.daemon = daemon;
13
+ this.filePath = resolve(daemon.grooveDir, 'conversations.json');
14
+ this.conversations = new Map();
15
+ this._load();
16
+ this._listenForAgentExits();
17
+ }
18
+
19
+ _load() {
20
+ if (!existsSync(this.filePath)) return;
21
+ try {
22
+ const data = JSON.parse(readFileSync(this.filePath, 'utf8'));
23
+ if (Array.isArray(data)) {
24
+ for (const conv of data) this.conversations.set(conv.id, conv);
25
+ }
26
+ } catch { /* ignore corrupt file */ }
27
+ }
28
+
29
+ _save() {
30
+ writeFileSync(this.filePath, JSON.stringify([...this.conversations.values()], null, 2));
31
+ }
32
+
33
+ _listenForAgentExits() {
34
+ this.daemon.registry.on('change', (delta) => {
35
+ if (!delta?.changed) return;
36
+ for (const agentId of delta.changed) {
37
+ const agent = this.daemon.registry.get(agentId);
38
+ if (!agent) continue;
39
+ const conv = this._findByAgentId(agentId);
40
+ if (!conv) continue;
41
+ if (agent.status === 'completed' || agent.status === 'killed' || agent.status === 'crashed') {
42
+ conv.agentStatus = agent.status;
43
+ conv.updatedAt = new Date().toISOString();
44
+ this._save();
45
+ this.daemon.broadcast({ type: 'conversation:updated', data: conv });
46
+ }
47
+ }
48
+ });
49
+ }
50
+
51
+ _findByAgentId(agentId) {
52
+ for (const conv of this.conversations.values()) {
53
+ if (conv.agentId === agentId) return conv;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ async create(provider, model, title, mode = 'api') {
59
+ const id = randomUUID().slice(0, 12);
60
+ const now = new Date().toISOString();
61
+
62
+ let agentId = null;
63
+
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
+ }
78
+
79
+ const conversation = {
80
+ id,
81
+ title: title || 'New Chat',
82
+ agentId,
83
+ provider,
84
+ model: model || null,
85
+ mode: mode === 'agent' ? 'agent' : 'api',
86
+ createdAt: now,
87
+ updatedAt: now,
88
+ pinned: false,
89
+ archived: false,
90
+ };
91
+
92
+ this.conversations.set(id, conversation);
93
+ this._save();
94
+ this.daemon.broadcast({ type: 'conversation:created', data: conversation });
95
+ return conversation;
96
+ }
97
+
98
+ get(id) {
99
+ const conv = this.conversations.get(id);
100
+ if (!conv) return null;
101
+ if (conv.mode === 'api' || !conv.agentId) {
102
+ return { ...conv, agentStatus: conv.agentStatus || null };
103
+ }
104
+ const agent = this.daemon.registry.get(conv.agentId);
105
+ return {
106
+ ...conv,
107
+ agentStatus: agent?.status || conv.agentStatus || 'unknown',
108
+ };
109
+ }
110
+
111
+ list() {
112
+ const all = [...this.conversations.values()].map((conv) => {
113
+ if (conv.mode === 'api' || !conv.agentId) {
114
+ return { ...conv, agentStatus: conv.agentStatus || null };
115
+ }
116
+ const agent = this.daemon.registry.get(conv.agentId);
117
+ return {
118
+ ...conv,
119
+ agentStatus: agent?.status || conv.agentStatus || 'unknown',
120
+ };
121
+ });
122
+ all.sort((a, b) => {
123
+ if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
124
+ return new Date(b.updatedAt) - new Date(a.updatedAt);
125
+ });
126
+ return all;
127
+ }
128
+
129
+ rename(id, title) {
130
+ const conv = this.conversations.get(id);
131
+ if (!conv) throw new Error('Conversation not found');
132
+ if (!title || typeof title !== 'string' || !title.trim()) {
133
+ throw new Error('Title is required');
134
+ }
135
+ conv.title = title.trim().slice(0, 200);
136
+ conv.updatedAt = new Date().toISOString();
137
+ this._save();
138
+ this.daemon.broadcast({ type: 'conversation:updated', data: conv });
139
+ return conv;
140
+ }
141
+
142
+ pin(id, pinned) {
143
+ const conv = this.conversations.get(id);
144
+ if (!conv) throw new Error('Conversation not found');
145
+ conv.pinned = !!pinned;
146
+ conv.updatedAt = new Date().toISOString();
147
+ this._save();
148
+ this.daemon.broadcast({ type: 'conversation:updated', data: conv });
149
+ return conv;
150
+ }
151
+
152
+ archive(id, archived) {
153
+ const conv = this.conversations.get(id);
154
+ if (!conv) throw new Error('Conversation not found');
155
+ conv.archived = !!archived;
156
+ conv.updatedAt = new Date().toISOString();
157
+ this._save();
158
+ this.daemon.broadcast({ type: 'conversation:updated', data: conv });
159
+ return conv;
160
+ }
161
+
162
+ async delete(id) {
163
+ const conv = this.conversations.get(id);
164
+ if (!conv) throw new Error('Conversation not found');
165
+
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
+ }
174
+ }
175
+
176
+ // Kill any active API mode streaming process
177
+ this._killStreamingProcess(id);
178
+
179
+ this.conversations.delete(id);
180
+ this._save();
181
+ this.daemon.broadcast({ type: 'conversation:deleted', data: { id } });
182
+ return true;
183
+ }
184
+
185
+ touchUpdatedAt(id) {
186
+ const conv = this.conversations.get(id);
187
+ if (!conv) return;
188
+ conv.updatedAt = new Date().toISOString();
189
+ this._save();
190
+ }
191
+
192
+ autoTitle(id, message) {
193
+ const conv = this.conversations.get(id);
194
+ if (!conv) return;
195
+ if (conv.title !== 'New Chat') return;
196
+ const cleaned = message.trim().replace(/\s+/g, ' ').slice(0, 50);
197
+ conv.title = cleaned || 'New Chat';
198
+ conv.updatedAt = new Date().toISOString();
199
+ this._save();
200
+ this.daemon.broadcast({ type: 'conversation:updated', data: conv });
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
348
+ if (json.type === 'result' && json.result) {
349
+ this.daemon.broadcast({
350
+ type: 'conversation:chunk',
351
+ data: { conversationId: id, text: json.result },
352
+ });
353
+ continue;
354
+ }
355
+
356
+ // Groove Network: token events
357
+ if (json.type === 'token' && json.text != null) {
358
+ this.daemon.broadcast({
359
+ type: 'conversation:chunk',
360
+ data: { conversationId: id, text: json.text },
361
+ });
362
+ continue;
363
+ }
364
+
365
+ // Groove Network: done/complete/result
366
+ if ((json.type === 'done' || json.type === 'complete' || json.type === 'result') && json.text) {
367
+ this.daemon.broadcast({
368
+ type: 'conversation:chunk',
369
+ data: { conversationId: id, text: json.text },
370
+ });
371
+ continue;
372
+ }
373
+
374
+ // Gemini / Codex: content text
375
+ if (json.content?.[0]?.text) {
376
+ this.daemon.broadcast({
377
+ type: 'conversation:chunk',
378
+ data: { conversationId: id, text: json.content[0].text },
379
+ });
380
+ continue;
381
+ }
382
+ } catch { /* not JSON — treat as raw text */ }
383
+
384
+ // Non-JSON output: broadcast raw text (some providers output plain text)
385
+ if (!trimmed.startsWith('{')) {
386
+ this.daemon.broadcast({
387
+ type: 'conversation:chunk',
388
+ data: { conversationId: id, text: trimmed },
389
+ });
390
+ }
391
+ }
392
+ });
393
+
394
+ proc.on('error', (err) => {
395
+ this._getStreamingProcesses().delete(id);
396
+ this.daemon.broadcast({
397
+ type: 'conversation:error',
398
+ data: { conversationId: id, error: err.message },
399
+ });
400
+ });
401
+
402
+ proc.on('exit', (code) => {
403
+ this._getStreamingProcesses().delete(id);
404
+ this.daemon.broadcast({
405
+ type: 'conversation:complete',
406
+ data: { conversationId: id, exitCode: code },
407
+ });
408
+ });
409
+
410
+ const timeout = setTimeout(() => {
411
+ if (!proc.killed) proc.kill();
412
+ }, 120_000);
413
+ proc.on('exit', () => clearTimeout(timeout));
414
+ }
415
+
416
+ stopStreaming(id) {
417
+ this._killStreamingProcess(id);
418
+ this.daemon.broadcast({
419
+ type: 'conversation:complete',
420
+ data: { conversationId: id, stopped: true },
421
+ });
422
+ }
423
+ }
@@ -41,6 +41,7 @@ import { TunnelManager } from './tunnel-manager.js';
41
41
  import { ModelManager } from './model-manager.js';
42
42
  import { LlamaServerManager } from './llama-server.js';
43
43
  import { RepoImporter } from './repo-import.js';
44
+ import { ConversationManager } from './conversations.js';
44
45
  import { Toys } from './toys.js';
45
46
  import { isFirstRun, runFirstTimeSetup, loadConfig, saveConfig, printWelcome } from './firstrun.js';
46
47
  import { bindDaemon as bindGrooveNetworkDaemon } from './providers/groove-network.js';
@@ -126,6 +127,7 @@ export class Daemon {
126
127
  this.rotator = new Rotator(this);
127
128
  this.adaptive = new AdaptiveThresholds(this.grooveDir);
128
129
  this.teams = new Teams(this);
130
+ this.conversations = new ConversationManager(this);
129
131
  this.credentials = new CredentialStore(this.grooveDir);
130
132
  this.classifier = new TaskClassifier();
131
133
  this.router = new ModelRouter(this);