groove-dev 0.27.195 → 0.27.197

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 +3 -5
  4. package/node_modules/@groove-dev/daemon/src/chatstore.js +104 -0
  5. package/node_modules/@groove-dev/daemon/src/index.js +3 -2
  6. package/node_modules/@groove-dev/daemon/src/process.js +1 -1
  7. package/node_modules/@groove-dev/daemon/src/routes/agents.js +1 -175
  8. package/node_modules/@groove-dev/daemon/src/routes/chat-history.js +34 -0
  9. package/node_modules/@groove-dev/daemon/test/chatstore.test.js +86 -0
  10. package/node_modules/@groove-dev/gui/dist/assets/index-Da2nbd6M.js +1043 -0
  11. package/node_modules/@groove-dev/gui/dist/assets/index-zmrIbwNm.css +1 -0
  12. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  13. package/node_modules/@groove-dev/gui/package.json +1 -1
  14. package/node_modules/@groove-dev/gui/src/App.jsx +0 -2
  15. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +2 -2
  16. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +3 -3
  17. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -3
  18. package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
  19. package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +1 -1
  20. package/node_modules/@groove-dev/gui/src/stores/groove.js +5 -163
  21. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +52 -2
  22. package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +1 -1
  23. package/node_modules/@groove-dev/gui/src/views/settings.jsx +0 -61
  24. package/package.json +1 -1
  25. package/packages/cli/package.json +1 -1
  26. package/packages/daemon/package.json +1 -1
  27. package/packages/daemon/src/api.js +3 -5
  28. package/packages/daemon/src/chatstore.js +104 -0
  29. package/packages/daemon/src/index.js +3 -2
  30. package/packages/daemon/src/process.js +1 -1
  31. package/packages/daemon/src/routes/agents.js +1 -175
  32. package/packages/daemon/src/routes/chat-history.js +34 -0
  33. package/packages/gui/dist/assets/index-Da2nbd6M.js +1043 -0
  34. package/packages/gui/dist/assets/index-zmrIbwNm.css +1 -0
  35. package/packages/gui/dist/index.html +2 -2
  36. package/packages/gui/package.json +1 -1
  37. package/packages/gui/src/App.jsx +0 -2
  38. package/packages/gui/src/components/agents/agent-config.jsx +2 -2
  39. package/packages/gui/src/components/agents/spawn-wizard.jsx +3 -3
  40. package/packages/gui/src/components/layout/activity-bar.jsx +2 -3
  41. package/packages/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
  42. package/packages/gui/src/components/layout/command-palette.jsx +1 -1
  43. package/packages/gui/src/stores/groove.js +5 -163
  44. package/packages/gui/src/stores/slices/agents-slice.js +52 -2
  45. package/packages/gui/src/views/marketplace.jsx +1 -1
  46. package/packages/gui/src/views/settings.jsx +0 -61
  47. package/node_modules/@groove-dev/daemon/src/conversations.js +0 -576
  48. package/node_modules/@groove-dev/gui/dist/assets/index-Cat5pJUx.css +0 -1
  49. package/node_modules/@groove-dev/gui/dist/assets/index-L8xSnvHj.js +0 -1070
  50. package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +0 -198
  51. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +0 -367
  52. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +0 -527
  53. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +0 -212
  54. package/node_modules/@groove-dev/gui/src/components/chat/conversation-list.jsx +0 -163
  55. package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +0 -201
  56. package/node_modules/@groove-dev/gui/src/stores/slices/chat-slice.js +0 -227
  57. package/node_modules/@groove-dev/gui/src/views/chat.jsx +0 -6
  58. package/packages/daemon/src/conversations.js +0 -576
  59. package/packages/gui/dist/assets/index-Cat5pJUx.css +0 -1
  60. package/packages/gui/dist/assets/index-L8xSnvHj.js +0 -1070
  61. package/packages/gui/src/components/chat/chat-header.jsx +0 -198
  62. package/packages/gui/src/components/chat/chat-input.jsx +0 -367
  63. package/packages/gui/src/components/chat/chat-messages.jsx +0 -527
  64. package/packages/gui/src/components/chat/chat-view.jsx +0 -212
  65. package/packages/gui/src/components/chat/conversation-list.jsx +0 -163
  66. package/packages/gui/src/components/chat/model-picker.jsx +0 -201
  67. package/packages/gui/src/stores/slices/chat-slice.js +0 -227
  68. package/packages/gui/src/views/chat.jsx +0 -6
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.195",
3
+ "version": "0.27.197",
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.195",
3
+ "version": "0.27.197",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -28,6 +28,7 @@ import { registerFileRoutes, resetEditorRoot } from './routes/files.js';
28
28
  import { registerNetworkRoutes } from './routes/network.js';
29
29
  import { registerScheduleRoutes } from './routes/schedules.js';
30
30
  import { registerInnerChatRoutes } from './routes/innerchat.js';
31
+ import { registerChatHistoryRoutes } from './routes/chat-history.js';
31
32
  import { registerWatchRoutes } from './routes/watch.js';
32
33
  import { registerAutoAgentRoutes } from './routes/auto-agents.js';
33
34
 
@@ -179,6 +180,7 @@ export function createApi(app, daemon) {
179
180
  registerNetworkRoutes(app, daemon);
180
181
  registerScheduleRoutes(app, daemon);
181
182
  registerInnerChatRoutes(app, daemon);
183
+ registerChatHistoryRoutes(app, daemon);
182
184
  registerWatchRoutes(app, daemon);
183
185
  registerAutoAgentRoutes(app, daemon);
184
186
 
@@ -250,8 +252,6 @@ export function createApi(app, daemon) {
250
252
  }
251
253
  });
252
254
 
253
- // --- Conversations ---
254
-
255
255
  // --- Approvals ---
256
256
 
257
257
  app.get('/api/approvals', (req, res) => {
@@ -1348,10 +1348,8 @@ Keep responses concise. Help them think, don't lecture them about the system the
1348
1348
  }
1349
1349
 
1350
1350
  daemon.config.defaultProvider = provider;
1351
- daemon.config.defaultChatProvider = provider;
1352
1351
  if (model && typeof model === 'string' && model.length <= 100) {
1353
1352
  daemon.config.defaultModel = model.trim();
1354
- daemon.config.defaultChatModel = model.trim();
1355
1353
  }
1356
1354
  const { saveConfig } = await import('./firstrun.js');
1357
1355
  saveConfig(daemon.grooveDir, daemon.config);
@@ -1439,7 +1437,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
1439
1437
  const ALLOWED_KEYS = [
1440
1438
  'port', 'journalistInterval', 'rotationThreshold', 'autoRotation',
1441
1439
  'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
1442
- 'onboardingDismissed', 'defaultModel', 'defaultChatProvider', 'defaultChatModel',
1440
+ 'onboardingDismissed', 'defaultModel',
1443
1441
  'dataSharingDismissed', 'replayCeiling', 'velocityCeiling', 'journalistModelTier',
1444
1442
  'resumeBudgetChars',
1445
1443
  ];
@@ -0,0 +1,104 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import { resolve } from 'path';
4
+ import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
5
+
6
+ // Per-agent history cap and attachment handling mirror the GUI's own limits so
7
+ // the two stay in step.
8
+ const MAX_PER_AGENT = 200;
9
+ const SAVE_DEBOUNCE_MS = 1500;
10
+
11
+ /**
12
+ * Server-side chat history.
13
+ *
14
+ * The GUI used to keep chat history only in the browser's localStorage, which
15
+ * is scoped per origin (scheme://host:PORT). For a remote GUI reached over an
16
+ * SSH tunnel the local port changes across reconnects, so the origin — and thus
17
+ * the entire chat store — changed out from under the user, stranding history on
18
+ * old ports. Keeping it on the daemon makes it independent of port, origin, and
19
+ * even which machine connects: reconnect from anywhere and the chats are there.
20
+ */
21
+ export class ChatStore {
22
+ constructor(daemon) {
23
+ this.daemon = daemon;
24
+ this.path = resolve(daemon.grooveDir, 'chat-history.json');
25
+ this.history = this._load();
26
+ this._saveTimer = null;
27
+ }
28
+
29
+ _load() {
30
+ try {
31
+ if (existsSync(this.path)) {
32
+ const data = JSON.parse(readFileSync(this.path, 'utf8'));
33
+ if (data && typeof data === 'object') return data;
34
+ }
35
+ } catch { /* corrupt or missing — start empty rather than throw */ }
36
+ return {};
37
+ }
38
+
39
+ _scheduleSave() {
40
+ if (this._saveTimer) return;
41
+ this._saveTimer = setTimeout(() => {
42
+ this._saveTimer = null;
43
+ this._saveNow();
44
+ }, SAVE_DEBOUNCE_MS);
45
+ if (this._saveTimer.unref) this._saveTimer.unref();
46
+ }
47
+
48
+ _saveNow() {
49
+ try {
50
+ const tmp = `${this.path}.tmp`;
51
+ writeFileSync(tmp, JSON.stringify(this.history), { mode: 0o600 });
52
+ renameSync(tmp, this.path); // atomic — a reader never sees a half-written file
53
+ } catch { /* best effort */ }
54
+ }
55
+
56
+ // Strip base64 attachment payloads — metadata only. Keeping data URLs would
57
+ // bloat the file the same way it bloated localStorage.
58
+ _clean(message) {
59
+ if (!message || typeof message !== 'object') return null;
60
+ const out = { ...message };
61
+ if (Array.isArray(out.attachments) && out.attachments.length) {
62
+ out.attachments = out.attachments.map(({ dataUrl, ...rest }) => rest);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ append(agentId, message) {
68
+ if (!agentId) return;
69
+ const clean = this._clean(message);
70
+ if (!clean) return;
71
+ const arr = this.history[agentId] || [];
72
+ arr.push(clean);
73
+ this.history[agentId] = arr.slice(-MAX_PER_AGENT);
74
+ this._scheduleSave();
75
+ }
76
+
77
+ // Replace an agent's whole history — used when the GUI syncs a batch (e.g.
78
+ // messages it recorded while briefly disconnected).
79
+ replace(agentId, messages) {
80
+ if (!agentId || !Array.isArray(messages)) return;
81
+ this.history[agentId] = messages.map((m) => this._clean(m)).filter(Boolean).slice(-MAX_PER_AGENT);
82
+ this._scheduleSave();
83
+ }
84
+
85
+ getAll() {
86
+ return this.history;
87
+ }
88
+
89
+ get(agentId) {
90
+ return this.history[agentId] || [];
91
+ }
92
+
93
+ remove(agentId) {
94
+ if (this.history[agentId]) {
95
+ delete this.history[agentId];
96
+ this._scheduleSave();
97
+ }
98
+ }
99
+
100
+ stop() {
101
+ if (this._saveTimer) { clearTimeout(this._saveTimer); this._saveTimer = null; }
102
+ this._saveNow();
103
+ }
104
+ }
@@ -44,9 +44,9 @@ import { ModelLab } from './model-lab.js';
44
44
  import { LlamaServerManager } from './llama-server.js';
45
45
  import { MLXServerManager } from './mlx-server.js';
46
46
  import { RepoImporter } from './repo-import.js';
47
- import { ConversationManager } from './conversations.js';
48
47
  import { Toys } from './toys.js';
49
48
  import { InnerChat } from './innerchat.js';
49
+ import { ChatStore } from './chatstore.js';
50
50
  import { Watcher } from './watcher.js';
51
51
  import { AutoState } from './autostate.js';
52
52
  import { Orchestrator } from './orchestrator.js';
@@ -139,7 +139,6 @@ export class Daemon {
139
139
  this.rotator = new Rotator(this);
140
140
  this.adaptive = new AdaptiveThresholds(this.grooveDir);
141
141
  this.teams = new Teams(this);
142
- this.conversations = new ConversationManager(this);
143
142
  this.credentials = new CredentialStore(this.grooveDir);
144
143
  this.classifier = new TaskClassifier();
145
144
  this.router = new ModelRouter(this);
@@ -163,6 +162,7 @@ export class Daemon {
163
162
  this.modelLab = new ModelLab(this);
164
163
  this.toys = new Toys(this);
165
164
  this.innerchat = new InnerChat(this);
165
+ this.chatStore = new ChatStore(this);
166
166
  this.watcher = new Watcher(this);
167
167
  this.autoState = new AutoState(this.grooveDir);
168
168
  this.orchestrator = new Orchestrator(this);
@@ -874,6 +874,7 @@ export class Daemon {
874
874
  this.scheduler.stop();
875
875
  this.watcher.stop();
876
876
  this.innerchat.stop();
877
+ this.chatStore.stop();
877
878
  this.orchestrator.stop();
878
879
  this.timeline.stop();
879
880
  if (this._gcInterval) clearInterval(this._gcInterval);
@@ -71,7 +71,7 @@ Do NOT write code unless explicitly asked. Use your MCP tools (database queries,
71
71
  - Adapting tone and style to the audience (formal, conversational, technical, marketing)
72
72
  - Editing and polishing drafts for grammar, flow, and impact
73
73
  - Researching topics to produce accurate, substantive writing
74
- You CAN use code tools to create and edit text files, markdown documents, and structured content. For best results, apply a writing skill from the Marketplace that matches your task.
74
+ You CAN use code tools to create and edit text files, markdown documents, and structured content. For best results, apply a writing skill from the Integrations tab that matches your task.
75
75
 
76
76
  `,
77
77
  chat: `You are a Chat agent — a conversational companion, friend, and assistant. You are warm, curious, and genuinely engaged. You can discuss anything: ideas, philosophy, science, culture, coding, life.
@@ -2,9 +2,8 @@
2
2
 
3
3
  import { resolve } from 'path';
4
4
  import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from 'fs';
5
- import { validateAgentConfig, validateReasoningEffort, validateVerbosity } from '../validate.js';
5
+ import { validateAgentConfig } from '../validate.js';
6
6
  import { ROLE_INTEGRATIONS, wrapWithRoleReminder } from '../process.js';
7
- import { getProvider } from '../providers/index.js';
8
7
  import { deliverInstruction } from '../deliver.js';
9
8
  import { renameAgent } from '../rename.js';
10
9
 
@@ -218,179 +217,6 @@ export function registerAgentRoutes(app, daemon) {
218
217
  res.json(suggestion);
219
218
  });
220
219
 
221
- // --- Conversations ---
222
-
223
- app.get('/api/conversations', (req, res) => {
224
- res.json({ conversations: daemon.conversations.list() });
225
- });
226
-
227
- app.post('/api/conversations', async (req, res) => {
228
- try {
229
- const { provider, model, title, mode, reasoning_effort, verbosity } = req.body;
230
- if (provider && typeof provider !== 'string') {
231
- return res.status(400).json({ error: 'provider must be a string' });
232
- }
233
- if (mode && mode !== 'api' && mode !== 'agent') {
234
- return res.status(400).json({ error: 'mode must be "api" or "agent"' });
235
- }
236
- const validatedEffort = validateReasoningEffort(reasoning_effort);
237
- const validatedVerbosity = validateVerbosity(verbosity);
238
- const conversation = await daemon.conversations.create(provider, model, title, mode || 'api', {
239
- reasoningEffort: validatedEffort,
240
- verbosity: validatedVerbosity,
241
- });
242
- daemon.audit.log('conversation.create', { id: conversation.id, provider, model, mode: conversation.mode });
243
- res.status(201).json(conversation);
244
- } catch (err) {
245
- res.status(400).json({ error: err.message });
246
- }
247
- });
248
-
249
- app.get('/api/conversations/:id', (req, res) => {
250
- const conversation = daemon.conversations.get(req.params.id);
251
- if (!conversation) return res.status(404).json({ error: 'Conversation not found' });
252
- res.json(conversation);
253
- });
254
-
255
- app.patch('/api/conversations/:id', async (req, res) => {
256
- try {
257
- const conv = daemon.conversations.get(req.params.id);
258
- if (!conv) return res.status(404).json({ error: 'Conversation not found' });
259
- if (req.body.title !== undefined) daemon.conversations.rename(req.params.id, req.body.title);
260
- if (req.body.pinned !== undefined) daemon.conversations.pin(req.params.id, req.body.pinned);
261
- if (req.body.archived !== undefined) daemon.conversations.archive(req.params.id, req.body.archived);
262
- if (req.body.model !== undefined || req.body.provider !== undefined) {
263
- const newProvider = req.body.provider || conv.provider;
264
- const newModel = req.body.model || conv.model;
265
- daemon.conversations.updateModel(req.params.id, newProvider, newModel);
266
- }
267
- if (req.body.mode !== undefined) {
268
- if (req.body.mode !== 'api' && req.body.mode !== 'agent') {
269
- return res.status(400).json({ error: 'mode must be "api" or "agent"' });
270
- }
271
- await daemon.conversations.setMode(req.params.id, req.body.mode);
272
- }
273
- if (req.body.reasoning_effort !== undefined || req.body.verbosity !== undefined) {
274
- const validatedEffort = req.body.reasoning_effort !== undefined ? validateReasoningEffort(req.body.reasoning_effort) : undefined;
275
- const validatedVerbosity = req.body.verbosity !== undefined ? validateVerbosity(req.body.verbosity) : undefined;
276
- daemon.conversations.updateReasoningSettings(req.params.id, validatedEffort, validatedVerbosity);
277
- }
278
- daemon.audit.log('conversation.update', { id: req.params.id, provider: req.body.provider, model: req.body.model, mode: req.body.mode });
279
- res.json(daemon.conversations.get(req.params.id));
280
- } catch (err) {
281
- res.status(400).json({ error: err.message });
282
- }
283
- });
284
-
285
- app.delete('/api/conversations/:id', async (req, res) => {
286
- try {
287
- const conv = daemon.conversations.get(req.params.id);
288
- if (!conv) return res.status(404).json({ error: 'Conversation not found' });
289
- await daemon.conversations.delete(req.params.id);
290
- daemon.audit.log('conversation.delete', { id: req.params.id });
291
- res.json({ ok: true });
292
- } catch (err) {
293
- res.status(400).json({ error: err.message });
294
- }
295
- });
296
-
297
- app.post('/api/conversations/:id/message', async (req, res) => {
298
- try {
299
- const { message, history, reasoning_effort, verbosity } = req.body;
300
- if (!message || typeof message !== 'string' || !message.trim()) {
301
- return res.status(400).json({ error: 'message is required' });
302
- }
303
- const validatedEffort = validateReasoningEffort(reasoning_effort);
304
- const validatedVerbosity = validateVerbosity(verbosity);
305
-
306
- const conv = daemon.conversations.get(req.params.id);
307
- if (!conv) return res.status(404).json({ error: 'Conversation not found' });
308
-
309
- daemon.conversations.autoTitle(req.params.id, message.trim());
310
- daemon.conversations.touchUpdatedAt(req.params.id);
311
-
312
- await daemon.conversations.sendMessage(req.params.id, message.trim(), history || [], {
313
- reasoningEffort: validatedEffort,
314
- verbosity: validatedVerbosity,
315
- });
316
- daemon.audit.log('conversation.message', { id: req.params.id, mode: conv.mode || 'api' });
317
- res.json({ status: 'streaming', mode: conv.mode || 'api' });
318
- } catch (err) {
319
- res.status(400).json({ error: err.message });
320
- }
321
- });
322
-
323
- app.post('/api/conversations/:id/stop', (req, res) => {
324
- try {
325
- const conv = daemon.conversations.get(req.params.id);
326
- if (!conv) return res.status(404).json({ error: 'Conversation not found' });
327
- daemon.conversations.stopStreaming(req.params.id);
328
- res.json({ ok: true });
329
- } catch (err) {
330
- res.status(400).json({ error: err.message });
331
- }
332
- });
333
-
334
- // --- Image Generation ---
335
-
336
- app.post('/api/conversations/:id/generate-image', async (req, res) => {
337
- try {
338
- const { prompt, model, size, quality } = req.body;
339
- if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
340
- return res.status(400).json({ error: 'prompt is required' });
341
- }
342
- const conv = daemon.conversations.get(req.params.id);
343
- if (!conv) return res.status(404).json({ error: 'Conversation not found' });
344
-
345
- let providerName = conv.provider;
346
- let provider = getProvider(providerName);
347
-
348
- // If a specific image model was requested, find the right provider
349
- if (model) {
350
- const imageProviders = ['codex', 'grok', 'nano-banana'];
351
- for (const pid of imageProviders) {
352
- const p = getProvider(pid);
353
- if (p?.constructor.models.some((m) => m.id === model)) {
354
- provider = p;
355
- providerName = pid;
356
- break;
357
- }
358
- }
359
- }
360
-
361
- if (!provider?.generateImage) {
362
- return res.status(400).json({ error: 'Provider does not support image generation' });
363
- }
364
-
365
- const apiKey = daemon.conversations._getApiKey(providerName);
366
- if (!apiKey) {
367
- return res.status(400).json({ error: `No API key configured for ${providerName}` });
368
- }
369
-
370
- daemon.broadcast({
371
- type: 'conversation:image-progress',
372
- data: { conversationId: req.params.id, status: 'generating', prompt: prompt.trim() },
373
- });
374
-
375
- const result = await provider.generateImage(prompt.trim(), { model, size, quality, apiKey });
376
-
377
- daemon.broadcast({
378
- type: 'conversation:image',
379
- data: { conversationId: req.params.id, ...result, prompt: prompt.trim() },
380
- });
381
-
382
- daemon.conversations.touchUpdatedAt(req.params.id);
383
- daemon.audit.log('conversation.image', { id: req.params.id, model: result.model, provider: result.provider });
384
- res.json(result);
385
- } catch (err) {
386
- daemon.broadcast({
387
- type: 'conversation:image-progress',
388
- data: { conversationId: req.params.id, status: 'error', error: err.message },
389
- });
390
- res.status(500).json({ error: err.message });
391
- }
392
- });
393
-
394
220
  // Stop an agent's current work without killing the agent
395
221
  app.post('/api/agents/:id/stop', async (req, res) => {
396
222
  try {
@@ -0,0 +1,34 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ export function registerChatHistoryRoutes(app, daemon) {
4
+ // Full history for all agents — the GUI loads this on connect so chats are
5
+ // present regardless of which origin/port the tunnel came up on.
6
+ app.get('/api/chat-history', (req, res) => {
7
+ res.json({ history: daemon.chatStore.getAll() });
8
+ });
9
+
10
+ // Append a single message for an agent.
11
+ app.post('/api/chat-history/:agentId', (req, res) => {
12
+ const { message } = req.body || {};
13
+ if (!message || typeof message !== 'object') {
14
+ return res.status(400).json({ error: 'message object required' });
15
+ }
16
+ daemon.chatStore.append(req.params.agentId, message);
17
+ res.json({ ok: true });
18
+ });
19
+
20
+ // Replace an agent's whole history (batch sync).
21
+ app.put('/api/chat-history/:agentId', (req, res) => {
22
+ const { messages } = req.body || {};
23
+ if (!Array.isArray(messages)) {
24
+ return res.status(400).json({ error: 'messages array required' });
25
+ }
26
+ daemon.chatStore.replace(req.params.agentId, messages);
27
+ res.json({ ok: true });
28
+ });
29
+
30
+ app.delete('/api/chat-history/:agentId', (req, res) => {
31
+ daemon.chatStore.remove(req.params.agentId);
32
+ res.json({ ok: true });
33
+ });
34
+ }
@@ -0,0 +1,86 @@
1
+ // GROOVE — ChatStore tests
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { describe, it, beforeEach, afterEach } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
7
+ import { tmpdir } from 'os';
8
+ import { resolve } from 'path';
9
+ import { ChatStore } from '../src/chatstore.js';
10
+
11
+ describe('ChatStore', () => {
12
+ let dir, store;
13
+
14
+ beforeEach(() => {
15
+ dir = mkdtempSync(resolve(tmpdir(), 'groove-chat-'));
16
+ store = new ChatStore({ grooveDir: dir });
17
+ });
18
+ afterEach(() => {
19
+ store.stop();
20
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
21
+ });
22
+
23
+ it('appends and returns per-agent history', () => {
24
+ store.append('a1', { from: 'user', text: 'hello', timestamp: 1 });
25
+ store.append('a1', { from: 'agent', text: 'hi', timestamp: 2 });
26
+ store.append('a2', { from: 'user', text: 'other', timestamp: 3 });
27
+
28
+ assert.equal(store.get('a1').length, 2);
29
+ assert.equal(store.get('a2').length, 1);
30
+ assert.deepEqual(store.getAll().a1.map((m) => m.text), ['hello', 'hi']);
31
+ });
32
+
33
+ it('strips attachment data URLs but keeps metadata', () => {
34
+ store.append('a1', {
35
+ from: 'user', text: 'pic', timestamp: 1,
36
+ attachments: [{ name: 'x.png', size: 9, dataUrl: 'data:image/png;base64,AAAA' }],
37
+ });
38
+ const m = store.get('a1')[0];
39
+ assert.equal(m.attachments[0].dataUrl, undefined);
40
+ assert.equal(m.attachments[0].name, 'x.png');
41
+ });
42
+
43
+ it('persists across instances (survives a daemon restart)', () => {
44
+ store.append('a1', { from: 'user', text: 'durable', timestamp: 1 });
45
+ store.stop(); // flushes synchronously
46
+
47
+ const reloaded = new ChatStore({ grooveDir: dir });
48
+ assert.equal(reloaded.get('a1')[0].text, 'durable');
49
+ reloaded.stop();
50
+ });
51
+
52
+ it('writes the file atomically (no stray .tmp left)', () => {
53
+ store.append('a1', { from: 'user', text: 'x', timestamp: 1 });
54
+ store.stop();
55
+ assert.ok(existsSync(resolve(dir, 'chat-history.json')));
56
+ assert.ok(!existsSync(resolve(dir, 'chat-history.json.tmp')));
57
+ const onDisk = JSON.parse(readFileSync(resolve(dir, 'chat-history.json'), 'utf8'));
58
+ assert.equal(onDisk.a1[0].text, 'x');
59
+ });
60
+
61
+ it('caps per-agent history', () => {
62
+ for (let i = 0; i < 250; i++) store.append('a1', { from: 'user', text: `m${i}`, timestamp: i });
63
+ const h = store.get('a1');
64
+ assert.equal(h.length, 200);
65
+ assert.equal(h[0].text, 'm50'); // oldest 50 dropped
66
+ assert.equal(h.at(-1).text, 'm249');
67
+ });
68
+
69
+ it('replace() swaps an agent history wholesale', () => {
70
+ store.append('a1', { from: 'user', text: 'old' });
71
+ store.replace('a1', [{ from: 'user', text: 'new1' }, { from: 'agent', text: 'new2' }]);
72
+ assert.deepEqual(store.get('a1').map((m) => m.text), ['new1', 'new2']);
73
+ });
74
+
75
+ it('remove() deletes an agent history', () => {
76
+ store.append('a1', { from: 'user', text: 'x' });
77
+ store.remove('a1');
78
+ assert.equal(store.get('a1').length, 0);
79
+ });
80
+
81
+ it('starts empty when the file is missing or corrupt', () => {
82
+ const empty = new ChatStore({ grooveDir: mkdtempSync(resolve(tmpdir(), 'groove-chat-empty-')) });
83
+ assert.deepEqual(empty.getAll(), {});
84
+ empty.stop();
85
+ });
86
+ });