groove-dev 0.27.206 → 0.27.207

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.206",
3
+ "version": "0.27.207",
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.206",
3
+ "version": "0.27.207",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -9,14 +9,21 @@ const MAX_PER_AGENT = 200;
9
9
  const SAVE_DEBOUNCE_MS = 1500;
10
10
 
11
11
  /**
12
- * Server-side chat history.
12
+ * Server-side chat history, keyed by AGENT NAME.
13
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.
14
+ * Two hard-won identity lessons live here:
15
+ *
16
+ * 1. localStorage is per-origin (scheme://host:PORT), and a tunnelled GUI's
17
+ * port changes across reconnectsso history kept only in the browser gets
18
+ * stranded on dead origins. Hence a server-side store at all.
19
+ *
20
+ * 2. Agent IDs are NOT stable: every rotation/resume mints a new id. A store
21
+ * keyed by id fragments on every rotation — the full history stays under
22
+ * the dead id while the new id starts near-empty, and a reconnect then
23
+ * "restores" that near-empty stub over the user's real history. The agent
24
+ * NAME survives rotation (the Watch system keys by name for the same
25
+ * reason), so name is the identity here. IDs are accepted at the API edge
26
+ * and resolved immediately.
20
27
  */
21
28
  export class ChatStore {
22
29
  constructor(daemon) {
@@ -36,6 +43,50 @@ export class ChatStore {
36
43
  return {};
37
44
  }
38
45
 
46
+ // Resolve an id-or-name to the stable storage key (the agent's name).
47
+ // Unknown refs are used verbatim so nothing is ever dropped — an orphaned
48
+ // id key re-merges at boot once its agent is known, or stays parked.
49
+ _keyFor(ref) {
50
+ if (!ref) return null;
51
+ const byId = this.daemon.registry?.get?.(ref);
52
+ if (byId?.name) return byId.name;
53
+ return String(ref);
54
+ }
55
+
56
+ /**
57
+ * Fold any id-keyed entries into their agent's name key. Runs at boot (after
58
+ * registry restore) so stores written by the old id-keyed code reattach to
59
+ * the agent wherever the id is still resolvable.
60
+ */
61
+ migrate() {
62
+ const agents = this.daemon.registry?.getAll?.() || [];
63
+ const byId = new Map(agents.map((a) => [a.id, a]));
64
+ let moved = 0;
65
+ for (const key of Object.keys(this.history)) {
66
+ const agent = byId.get(key);
67
+ if (!agent || agent.name === key) continue;
68
+ this.history[agent.name] = mergeMessages(this.history[agent.name], this.history[key]);
69
+ delete this.history[key];
70
+ moved += 1;
71
+ }
72
+ if (moved) this._scheduleSave();
73
+ return moved;
74
+ }
75
+
76
+ /**
77
+ * Rotation-time hook: an entry parked under a dead id (written by the old
78
+ * id-keyed code, or by an old GUI posting under ids) follows the agent to
79
+ * its new identity.
80
+ */
81
+ remap(oldRef, newRef) {
82
+ const oldKey = String(oldRef);
83
+ const newKey = this._keyFor(newRef);
84
+ if (!newKey || oldKey === newKey || !this.history[oldKey]) return;
85
+ this.history[newKey] = mergeMessages(this.history[newKey], this.history[oldKey]);
86
+ delete this.history[oldKey];
87
+ this._scheduleSave();
88
+ }
89
+
39
90
  _scheduleSave() {
40
91
  if (this._saveTimer) return;
41
92
  this._saveTimer = setTimeout(() => {
@@ -64,35 +115,59 @@ export class ChatStore {
64
115
  return out;
65
116
  }
66
117
 
67
- append(agentId, message) {
68
- if (!agentId) return;
118
+ append(ref, message) {
119
+ const key = this._keyFor(ref);
120
+ if (!key) return;
69
121
  const clean = this._clean(message);
70
122
  if (!clean) return;
71
- const arr = this.history[agentId] || [];
123
+ const arr = this.history[key] || [];
72
124
  arr.push(clean);
73
- this.history[agentId] = arr.slice(-MAX_PER_AGENT);
125
+ this.history[key] = arr.slice(-MAX_PER_AGENT);
74
126
  this._scheduleSave();
75
127
  }
76
128
 
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);
129
+ /**
130
+ * Merge a batch from a client. This is a UNION by (timestamp, from, text)
131
+ * never a replace — so a browser holding messages the server missed adds
132
+ * them, and a browser with less than the server can never truncate it.
133
+ * A "restore" must be incapable of destroying what it restores from.
134
+ */
135
+ merge(ref, messages) {
136
+ const key = this._keyFor(ref);
137
+ if (!key || !Array.isArray(messages)) return;
138
+ const incoming = messages.map((m) => this._clean(m)).filter(Boolean);
139
+ this.history[key] = mergeMessages(this.history[key], incoming);
82
140
  this._scheduleSave();
83
141
  }
84
142
 
143
+ /**
144
+ * History for the GUI: live agents keyed by their CURRENT id (what the GUI
145
+ * looks up by), everything else under its stored name key so an agent
146
+ * respawned under the same name picks its history back up.
147
+ */
148
+ view() {
149
+ const out = {};
150
+ const agents = this.daemon.registry?.getAll?.() || [];
151
+ const liveNames = new Map(agents.map((a) => [a.name, a.id]));
152
+ for (const [key, msgs] of Object.entries(this.history)) {
153
+ if (!Array.isArray(msgs) || !msgs.length) continue;
154
+ out[liveNames.get(key) || key] = msgs;
155
+ }
156
+ return out;
157
+ }
158
+
85
159
  getAll() {
86
160
  return this.history;
87
161
  }
88
162
 
89
- get(agentId) {
90
- return this.history[agentId] || [];
163
+ get(ref) {
164
+ return this.history[this._keyFor(ref)] || [];
91
165
  }
92
166
 
93
- remove(agentId) {
94
- if (this.history[agentId]) {
95
- delete this.history[agentId];
167
+ remove(ref) {
168
+ const key = this._keyFor(ref);
169
+ if (key && this.history[key]) {
170
+ delete this.history[key];
96
171
  this._scheduleSave();
97
172
  }
98
173
  }
@@ -102,3 +177,19 @@ export class ChatStore {
102
177
  this._saveNow();
103
178
  }
104
179
  }
180
+
181
+ // Union of two message arrays, deduped on (timestamp, from, text), time-sorted,
182
+ // capped. Exported for tests and the migration path.
183
+ export function mergeMessages(a, b) {
184
+ const seen = new Set();
185
+ const out = [];
186
+ for (const m of [...(a || []), ...(b || [])]) {
187
+ if (!m || typeof m !== 'object') continue;
188
+ const sig = `${m.timestamp}:${m.from}:${typeof m.text === 'string' ? m.text.slice(0, 200) : ''}`;
189
+ if (seen.has(sig)) continue;
190
+ seen.add(sig);
191
+ out.push(m);
192
+ }
193
+ out.sort((x, y) => (x.timestamp || 0) - (y.timestamp || 0));
194
+ return out.slice(-MAX_PER_AGENT);
195
+ }
@@ -438,6 +438,12 @@ export class Daemon {
438
438
  }
439
439
 
440
440
  broadcast(message) {
441
+ // Every rotation/resume path announces itself here — the one chokepoint
442
+ // for "this agent has a new id". Chat history parked under the dead id
443
+ // (stale client posts, pre-migration data) follows the agent.
444
+ if (message.type === 'rotation:complete' && message.oldAgentId && message.agentId) {
445
+ try { this.chatStore.remap(message.oldAgentId, message.agentId); } catch { /* best effort */ }
446
+ }
441
447
  if (!this.wss) return;
442
448
  const payload = JSON.stringify(message);
443
449
  for (const client of this.wss.clients) {
@@ -640,6 +646,13 @@ export class Daemon {
640
646
  this.federation.initialize();
641
647
  this._startGarbageCollector();
642
648
 
649
+ // Fold id-keyed chat history into name keys now that the registry is
650
+ // restored — reattaches histories written by the old id-keyed store.
651
+ try {
652
+ const moved = this.chatStore.migrate();
653
+ if (moved) console.log(`[chat] migrated ${moved} id-keyed histories to agent names`);
654
+ } catch { /* best effort */ }
655
+
643
656
  // Regenerate the on-disk registry files once on boot. They otherwise
644
657
  // only refresh on a registry change, so a daemon upgraded with new
645
658
  // agent-facing docs (InnerChat, Watch) would serve stale AGENTS_REGISTRY.md
@@ -89,6 +89,10 @@ export function registerAgentRoutes(app, daemon) {
89
89
  // Killed/completed agents stay visible so the user can review output.
90
90
  const purge = req.query.purge === 'true';
91
91
  if (purge) {
92
+ // Clear chat history while the id still resolves to a name — the
93
+ // store is name-keyed, and a purged name's history must not haunt a
94
+ // future agent spawned under the same name.
95
+ try { daemon.chatStore.remove(agent.name); } catch { /* best effort */ }
92
96
  daemon.registry.remove(req.params.id);
93
97
  }
94
98
 
@@ -4,7 +4,9 @@ export function registerChatHistoryRoutes(app, daemon) {
4
4
  // Full history for all agents — the GUI loads this on connect so chats are
5
5
  // present regardless of which origin/port the tunnel came up on.
6
6
  app.get('/api/chat-history', (req, res) => {
7
- res.json({ history: daemon.chatStore.getAll() });
7
+ // view() keys live agents by their CURRENT id — ids churn on rotation, so
8
+ // the store itself is name-keyed and this translates at the edge.
9
+ res.json({ history: daemon.chatStore.view() });
8
10
  });
9
11
 
10
12
  // Append a single message for an agent.
@@ -17,13 +19,14 @@ export function registerChatHistoryRoutes(app, daemon) {
17
19
  res.json({ ok: true });
18
20
  });
19
21
 
20
- // Replace an agent's whole history (batch sync).
22
+ // Batch sync from a client. A UNION, deliberately not a replace — a client
23
+ // can add messages the server missed but can never truncate server history.
21
24
  app.put('/api/chat-history/:agentId', (req, res) => {
22
25
  const { messages } = req.body || {};
23
26
  if (!Array.isArray(messages)) {
24
27
  return res.status(400).json({ error: 'messages array required' });
25
28
  }
26
- daemon.chatStore.replace(req.params.agentId, messages);
29
+ daemon.chatStore.merge(req.params.agentId, messages);
27
30
  res.json({ ok: true });
28
31
  });
29
32
 
@@ -6,28 +6,101 @@ import assert from 'node:assert/strict';
6
6
  import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
7
7
  import { tmpdir } from 'os';
8
8
  import { resolve } from 'path';
9
- import { ChatStore } from '../src/chatstore.js';
9
+ import { ChatStore, mergeMessages } from '../src/chatstore.js';
10
+
11
+ // Minimal registry: id -> {id, name}. Lets tests exercise the id→name
12
+ // resolution that makes history survive rotation.
13
+ function makeDaemon(agents = []) {
14
+ const map = new Map(agents.map((a) => [a.id, a]));
15
+ return {
16
+ grooveDir: null, // set per test
17
+ registry: {
18
+ _map: map,
19
+ get(id) { return map.get(id) || null; },
20
+ getAll() { return [...map.values()]; },
21
+ set(a) { map.set(a.id, a); },
22
+ delete(id) { map.delete(id); },
23
+ },
24
+ };
25
+ }
10
26
 
11
27
  describe('ChatStore', () => {
12
- let dir, store;
28
+ let dir, daemon, store;
13
29
 
14
30
  beforeEach(() => {
15
31
  dir = mkdtempSync(resolve(tmpdir(), 'groove-chat-'));
16
- store = new ChatStore({ grooveDir: dir });
32
+ daemon = makeDaemon([{ id: 'a1', name: 'fullstack-1' }, { id: 'a2', name: 'planner-1' }]);
33
+ daemon.grooveDir = dir;
34
+ store = new ChatStore(daemon);
17
35
  });
18
36
  afterEach(() => {
19
37
  store.stop();
20
38
  try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
21
39
  });
22
40
 
23
- it('appends and returns per-agent history', () => {
41
+ it('appends under the agent NAME when given an id', () => {
24
42
  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 });
43
+ store.append('fullstack-1', { from: 'agent', text: 'hi', timestamp: 2 });
44
+
45
+ // Both routes land in one bucket — the name.
46
+ assert.deepEqual(store.get('a1').map((m) => m.text), ['hello', 'hi']);
47
+ assert.deepEqual(store.getAll()['fullstack-1'].map((m) => m.text), ['hello', 'hi']);
48
+ assert.equal(store.getAll().a1, undefined);
49
+ });
50
+
51
+ it('history survives rotation: new id, same name, same history', () => {
52
+ store.append('a1', { from: 'user', text: 'before rotation', timestamp: 1 });
53
+
54
+ // Rotation mints a new id; the name is stable.
55
+ daemon.registry.delete('a1');
56
+ daemon.registry.set({ id: 'a1-r9', name: 'fullstack-1' });
57
+
58
+ store.append('a1-r9', { from: 'agent', text: 'after rotation', timestamp: 2 });
59
+ assert.deepEqual(store.get('a1-r9').map((m) => m.text), ['before rotation', 'after rotation']);
60
+ });
61
+
62
+ it('remap() folds a dead-id entry into the live agent', () => {
63
+ // Simulates data written under an id while the registry didn't know it
64
+ // (stale client post mid-rotation) — the rotation:complete hook remaps it.
65
+ store.history['dead-id'] = [{ from: 'user', text: 'stranded', timestamp: 1 }];
66
+ store.remap('dead-id', 'a1');
67
+ assert.equal(store.history['dead-id'], undefined);
68
+ assert.deepEqual(store.get('a1').map((m) => m.text), ['stranded']);
69
+ });
70
+
71
+ it('migrate() reattaches id-keyed histories from the old store format', () => {
72
+ store.history = {
73
+ a1: [{ from: 'user', text: 'legacy', timestamp: 1 }],
74
+ 'fullstack-1': [{ from: 'agent', text: 'current', timestamp: 2 }],
75
+ 'ghost-id': [{ from: 'user', text: 'unresolvable', timestamp: 3 }],
76
+ };
77
+ const moved = store.migrate();
78
+ assert.equal(moved, 1);
79
+ assert.deepEqual(store.getAll()['fullstack-1'].map((m) => m.text), ['legacy', 'current']);
80
+ // Unresolvable keys are parked, never dropped.
81
+ assert.equal(store.getAll()['ghost-id'][0].text, 'unresolvable');
82
+ });
27
83
 
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']);
84
+ it('view() keys live agents by CURRENT id and parks the rest by name', () => {
85
+ store.append('a1', { from: 'user', text: 'live', timestamp: 1 });
86
+ store.history['departed-agent'] = [{ from: 'user', text: 'old', timestamp: 2 }];
87
+ const v = store.view();
88
+ assert.equal(v.a1[0].text, 'live'); // fullstack-1 → its live id
89
+ assert.equal(v['fullstack-1'], undefined);
90
+ assert.equal(v['departed-agent'][0].text, 'old'); // no live agent — name key
91
+ });
92
+
93
+ it('merge() is a union — a sparse client can never truncate server history', () => {
94
+ for (let i = 0; i < 10; i++) store.append('a1', { from: 'user', text: `m${i}`, timestamp: i });
95
+ // Client syncs only 2 messages (1 duplicate, 1 new).
96
+ store.merge('a1', [
97
+ { from: 'user', text: 'm3', timestamp: 3 },
98
+ { from: 'agent', text: 'fresh', timestamp: 99 },
99
+ ]);
100
+ const texts = store.get('a1').map((m) => m.text);
101
+ assert.equal(texts.length, 11);
102
+ assert.equal(texts.at(-1), 'fresh');
103
+ assert.ok(texts.includes('m0') && texts.includes('m9'));
31
104
  });
32
105
 
33
106
  it('strips attachment data URLs but keeps metadata', () => {
@@ -44,7 +117,7 @@ describe('ChatStore', () => {
44
117
  store.append('a1', { from: 'user', text: 'durable', timestamp: 1 });
45
118
  store.stop(); // flushes synchronously
46
119
 
47
- const reloaded = new ChatStore({ grooveDir: dir });
120
+ const reloaded = new ChatStore(daemon);
48
121
  assert.equal(reloaded.get('a1')[0].text, 'durable');
49
122
  reloaded.stop();
50
123
  });
@@ -55,7 +128,7 @@ describe('ChatStore', () => {
55
128
  assert.ok(existsSync(resolve(dir, 'chat-history.json')));
56
129
  assert.ok(!existsSync(resolve(dir, 'chat-history.json.tmp')));
57
130
  const onDisk = JSON.parse(readFileSync(resolve(dir, 'chat-history.json'), 'utf8'));
58
- assert.equal(onDisk.a1[0].text, 'x');
131
+ assert.equal(onDisk['fullstack-1'][0].text, 'x');
59
132
  });
60
133
 
61
134
  it('caps per-agent history', () => {
@@ -66,21 +139,40 @@ describe('ChatStore', () => {
66
139
  assert.equal(h.at(-1).text, 'm249');
67
140
  });
68
141
 
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', () => {
142
+ it('remove() deletes by name so a purge cannot haunt a future same-name agent', () => {
76
143
  store.append('a1', { from: 'user', text: 'x' });
77
- store.remove('a1');
144
+ store.remove('fullstack-1');
78
145
  assert.equal(store.get('a1').length, 0);
79
146
  });
80
147
 
148
+ it('works without a registry (refs pass through verbatim)', () => {
149
+ const bare = new ChatStore({ grooveDir: mkdtempSync(resolve(tmpdir(), 'groove-chat-bare-')) });
150
+ bare.append('some-id', { from: 'user', text: 'x', timestamp: 1 });
151
+ assert.equal(bare.get('some-id')[0].text, 'x');
152
+ bare.stop();
153
+ });
154
+
81
155
  it('starts empty when the file is missing or corrupt', () => {
82
156
  const empty = new ChatStore({ grooveDir: mkdtempSync(resolve(tmpdir(), 'groove-chat-empty-')) });
83
157
  assert.deepEqual(empty.getAll(), {});
84
158
  empty.stop();
85
159
  });
86
160
  });
161
+
162
+ describe('mergeMessages', () => {
163
+ it('dedupes on (timestamp, from, text) and sorts by time', () => {
164
+ const merged = mergeMessages(
165
+ [{ from: 'u', text: 'b', timestamp: 2 }, { from: 'u', text: 'a', timestamp: 1 }],
166
+ [{ from: 'u', text: 'a', timestamp: 1 }, { from: 'u', text: 'c', timestamp: 3 }],
167
+ );
168
+ assert.deepEqual(merged.map((m) => m.text), ['a', 'b', 'c']);
169
+ });
170
+
171
+ it('treats same text at different timestamps as distinct messages', () => {
172
+ const merged = mergeMessages(
173
+ [{ from: 'u', text: 'ok', timestamp: 1 }],
174
+ [{ from: 'u', text: 'ok', timestamp: 2 }],
175
+ );
176
+ assert.equal(merged.length, 2);
177
+ });
178
+ });