groove-dev 0.27.210 → 0.27.211

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.
package/CLAUDE.md CHANGED
@@ -295,3 +295,11 @@ Audit-driven release. Multi-agent orchestration system with 7 coordination layer
295
295
  - Dashboard: routing donut, cache panel, context health gauges
296
296
  - Monitor/QC agent mode (stay active, loop)
297
297
  - Distribution: demo video, HN launch, Twitter content
298
+
299
+ <!-- GROOVE:START -->
300
+ ## GROOVE Orchestration (auto-injected)
301
+ Active agents: 0
302
+ See AGENTS_REGISTRY.md for full agent state, the names of agents on other teams,
303
+ and how to consult them directly (InnerChat).
304
+ **Memory policy:** GROOVE manages project memory automatically. Do not read or write MEMORY.md or .groove/memory/ files directly.
305
+ <!-- GROOVE:END -->
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.210",
3
+ "version": "0.27.211",
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.210",
3
+ "version": "0.27.211",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -33,8 +33,16 @@ export const KNOWN_KINDS = Object.freeze([
33
33
  'narration', 'narration_dropped',
34
34
  'candidate_arrived', 'evidence_scored', 'champion_changed',
35
35
  'confidence_updated', 'verifier_verdict',
36
+ 'resolution_delta', 'context_compile',
36
37
  ]);
37
38
 
39
+ // Cosmetic, append-only chunks of an answer still being written. The terminal
40
+ // `resolution` always follows with the authoritative text, so a delta is
41
+ // worthless the moment it lands — keeping them would crowd the ring with
42
+ // history that replay deliberately omits anyway (they are transient runtime
43
+ // side, so a `?since` replay never returns them either).
44
+ const TRANSIENT_KINDS = new Set(['resolution_delta']);
45
+
38
46
  const RING_SIZE = 4096;
39
47
  const SESSION_POLL_MS = 15000;
40
48
  const BACKOFF_BASE_MS = 1000;
@@ -243,13 +251,19 @@ export class AxomConnector {
243
251
  // Dedup on ring-buffer replay after reconnect — ids are monotonic.
244
252
  if (seq !== null && seq <= s.lastSeq) return;
245
253
  if (seq !== null) s.lastSeq = seq;
246
- if (s.ring.length >= this.ringSize) {
247
- s.ring.shift();
248
- // The ring bounds memory, not delivery: overflow is counted, never
249
- // silent, and every event still broadcasts — mirrors the runtime.
250
- s.overflow += 1;
254
+ // Transient kinds broadcast but are never retained: they are superseded
255
+ // by a terminal event, and a backfilled tab must see the same history a
256
+ // reconnecting one does. `lastSeq` still advances above, so dedup and
257
+ // ordering are unaffected.
258
+ if (!TRANSIENT_KINDS.has(envelope.kind)) {
259
+ if (s.ring.length >= this.ringSize) {
260
+ s.ring.shift();
261
+ // The ring bounds memory, not delivery: overflow is counted, never
262
+ // silent, and every event still broadcasts — mirrors the runtime.
263
+ s.overflow += 1;
264
+ }
265
+ s.ring.push(envelope);
251
266
  }
252
- s.ring.push(envelope);
253
267
  if (envelope.kind && !KNOWN_KINDS.includes(envelope.kind)) {
254
268
  s.unknownKinds[envelope.kind] = (s.unknownKinds[envelope.kind] || 0) + 1;
255
269
  }
@@ -141,21 +141,37 @@ export class ChatStore {
141
141
  }
142
142
 
143
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.
144
+ * History for the GUI: LIVE agents only, keyed by their current id (what the
145
+ * GUI looks up by). Buckets for agents that no longer exist stay on disk —
146
+ * a respawn under the same name picks them back up — but aren't shipped:
147
+ * they were 97% of a 5.6MB payload, which blew the browser's ~5MB
148
+ * localStorage quota and froze the local cache.
147
149
  */
148
150
  view() {
149
151
  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;
152
+ for (const agent of this.daemon.registry?.getAll?.() || []) {
153
+ const msgs = this.history[agent.name];
154
+ if (Array.isArray(msgs) && msgs.length) out[agent.id] = msgs;
155
155
  }
156
156
  return out;
157
157
  }
158
158
 
159
+ /**
160
+ * Drop the oldest buckets belonging to agents that no longer exist, so a
161
+ * long-lived daemon's store stays bounded. Live agents are never pruned.
162
+ */
163
+ prune(maxDeadBuckets = 200) {
164
+ const liveNames = new Set((this.daemon.registry?.getAll?.() || []).map((a) => a.name));
165
+ const dead = Object.keys(this.history)
166
+ .filter((k) => !liveNames.has(k))
167
+ .map((k) => [k, lastTs(this.history[k])])
168
+ .sort((x, y) => y[1] - x[1]);
169
+ if (dead.length <= maxDeadBuckets) return 0;
170
+ for (const [key] of dead.slice(maxDeadBuckets)) delete this.history[key];
171
+ this._scheduleSave();
172
+ return dead.length - maxDeadBuckets;
173
+ }
174
+
159
175
  getAll() {
160
176
  return this.history;
161
177
  }
@@ -178,18 +194,46 @@ export class ChatStore {
178
194
  }
179
195
  }
180
196
 
181
- // Union of two message arrays, deduped on (timestamp, from, text), time-sorted,
182
- // capped. Exported for tests and the migration path.
197
+ function lastTs(msgs) {
198
+ return (Array.isArray(msgs) && msgs.length && msgs[msgs.length - 1]?.timestamp) || 0;
199
+ }
200
+
201
+ /**
202
+ * Union of two message arrays, time-sorted and capped.
203
+ *
204
+ * Messages carry a stable `id`. A streamed agent reply coalesces client-side
205
+ * into ONE growing message that keeps its id, so an id collision means "same
206
+ * message, later state" — we keep the longer text rather than accumulating a
207
+ * fragment per chunk. Messages without an id (older clients) fall back to a
208
+ * (timestamp, from, text) signature.
209
+ */
183
210
  export function mergeMessages(a, b) {
184
- const seen = new Set();
211
+ const byId = new Map();
212
+ const bySig = new Set();
185
213
  const out = [];
214
+
186
215
  for (const m of [...(a || []), ...(b || [])]) {
187
216
  if (!m || typeof m !== 'object') continue;
217
+
218
+ if (m.id) {
219
+ const prev = byId.get(m.id);
220
+ if (!prev) {
221
+ byId.set(m.id, m);
222
+ out.push(m);
223
+ } else if (String(m.text || '').length > String(prev.text || '').length) {
224
+ // Same message, further along — replace in place.
225
+ out[out.indexOf(prev)] = m;
226
+ byId.set(m.id, m);
227
+ }
228
+ continue;
229
+ }
230
+
188
231
  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);
232
+ if (bySig.has(sig)) continue;
233
+ bySig.add(sig);
191
234
  out.push(m);
192
235
  }
236
+
193
237
  out.sort((x, y) => (x.timestamp || 0) - (y.timestamp || 0));
194
238
  return out.slice(-MAX_PER_AGENT);
195
239
  }
@@ -653,6 +653,8 @@ export class Daemon {
653
653
  try {
654
654
  const moved = this.chatStore.migrate();
655
655
  if (moved) console.log(`[chat] migrated ${moved} id-keyed histories to agent names`);
656
+ const pruned = this.chatStore.prune();
657
+ if (pruned) console.log(`[chat] pruned ${pruned} histories for long-gone agents`);
656
658
  } catch { /* best effort */ }
657
659
 
658
660
  // Regenerate the on-disk registry files once on boot. They otherwise
@@ -463,6 +463,47 @@ describe('AxomConnector', () => {
463
463
  assert.ok(bridge.epochsSeen.includes('epoch-A')); // we did present the old epoch
464
464
  });
465
465
 
466
+ // Streaming answers: deltas are cosmetic chunks superseded by the terminal
467
+ // `resolution`. They must reach the GUI live but never enter the ring — the
468
+ // runtime omits them from its own replay, so a backfilled tab and a
469
+ // reconnecting one must see identical history.
470
+ it('delivers resolution deltas live but keeps them out of the replay ring', async () => {
471
+ connect();
472
+ await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
473
+ await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
474
+
475
+ bridge.emit('s-test0001', envelope(1, 'pipeline_start'));
476
+ bridge.emit('s-test0001', envelope(2, 'resolution_delta', { firing_id: 'f1', content: 'Hello ', index: 0 }));
477
+ bridge.emit('s-test0001', envelope(3, 'resolution_delta', { firing_id: 'f1', content: 'world', index: 1 }));
478
+ bridge.emit('s-test0001', envelope(4, 'resolution', { content: 'Hello world.' }));
479
+
480
+ // Every delta still BROADCASTS — delivery is untouched.
481
+ await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 4);
482
+ const kinds = daemon.broadcasts.filter((b) => b.type === 'axom:event').map((b) => b.envelope.kind);
483
+ assert.deepEqual(kinds, ['pipeline_start', 'resolution_delta', 'resolution_delta', 'resolution']);
484
+
485
+ // ...but the ring holds only the durable history.
486
+ const backfill = connector.events('local', 's-test0001');
487
+ assert.deepEqual(backfill.events.map((e) => e.kind), ['pipeline_start', 'resolution']);
488
+ // Excluded, not overflowed — dropping them is by design, not pressure.
489
+ assert.equal(connector.status().endpoints[0].sessions[0].overflow, 0);
490
+ // A known kind now, so it must not read as schema drift.
491
+ assert.equal(connector.status().endpoints[0].sessions[0].unknownKinds.resolution_delta, undefined);
492
+ });
493
+
494
+ it('a delta never breaks dedup for the events that follow it', async () => {
495
+ connect();
496
+ await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
497
+ await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
498
+ bridge.emit('s-test0001', envelope(1, 'resolution_delta', { firing_id: 'f1', content: 'x', index: 0 }));
499
+ bridge.emit('s-test0001', envelope(2, 'resolution', { content: 'x!' }));
500
+ await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 2);
501
+ // Replaying an id at or below the delta's must still be suppressed.
502
+ bridge.emit('s-test0001', envelope(1, 'resolution_delta', { firing_id: 'f1', content: 'x', index: 0 }));
503
+ await new Promise((r) => setTimeout(r, 120));
504
+ assert.equal(daemon.broadcasts.filter((b) => b.type === 'axom:event').length, 2);
505
+ });
506
+
466
507
  it('recheck collapses a stale connected state the moment the runtime is gone', async () => {
467
508
  connect();
468
509
  await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
@@ -81,13 +81,16 @@ describe('ChatStore', () => {
81
81
  assert.equal(store.getAll()['ghost-id'][0].text, 'unresolvable');
82
82
  });
83
83
 
84
- it('view() keys live agents by CURRENT id and parks the rest by name', () => {
84
+ it('view() keys live agents by CURRENT id and omits departed ones', () => {
85
85
  store.append('a1', { from: 'user', text: 'live', timestamp: 1 });
86
86
  store.history['departed-agent'] = [{ from: 'user', text: 'old', timestamp: 2 }];
87
87
  const v = store.view();
88
88
  assert.equal(v.a1[0].text, 'live'); // fullstack-1 → its live id
89
89
  assert.equal(v['fullstack-1'], undefined);
90
- assert.equal(v['departed-agent'][0].text, 'old'); // no live agentname key
90
+ // Departed agents stay on disk but are not shipped the payload used to
91
+ // be 97% dead agents, which blew the browser's localStorage quota.
92
+ assert.equal(v['departed-agent'], undefined);
93
+ assert.equal(store.getAll()['departed-agent'][0].text, 'old');
91
94
  });
92
95
 
93
96
  it('merge() is a union — a sparse client can never truncate server history', () => {
@@ -176,3 +179,65 @@ describe('mergeMessages', () => {
176
179
  assert.equal(merged.length, 2);
177
180
  });
178
181
  });
182
+
183
+ describe('ChatStore — agent-reply persistence regression', () => {
184
+ let dir, daemon, store;
185
+
186
+ beforeEach(() => {
187
+ dir = mkdtempSync(resolve(tmpdir(), 'groove-chat-reg-'));
188
+ daemon = makeDaemon([{ id: 'live1', name: 'fullstack-1' }]);
189
+ daemon.grooveDir = dir;
190
+ store = new ChatStore(daemon);
191
+ });
192
+ afterEach(() => {
193
+ store.stop();
194
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
195
+ });
196
+
197
+ it('upserts a coalescing streamed reply instead of accumulating fragments', () => {
198
+ // The GUI grows one message as chunks stream in, keeping its id.
199
+ store.merge('live1', [{ id: 'm1', from: 'agent', text: 'Part one', timestamp: 10 }]);
200
+ store.merge('live1', [{ id: 'm1', from: 'agent', text: 'Part one\n\nPart two', timestamp: 11 }]);
201
+ store.merge('live1', [{ id: 'm1', from: 'agent', text: 'Part one\n\nPart two\n\nPart three', timestamp: 12 }]);
202
+
203
+ const h = store.get('live1');
204
+ assert.equal(h.length, 1, 'one message, not one per chunk');
205
+ assert.equal(h[0].text, 'Part one\n\nPart two\n\nPart three');
206
+ });
207
+
208
+ it('never regresses a message to an earlier, shorter state', () => {
209
+ store.merge('live1', [{ id: 'm1', from: 'agent', text: 'full long reply', timestamp: 20 }]);
210
+ store.merge('live1', [{ id: 'm1', from: 'agent', text: 'full', timestamp: 19 }]); // stale client
211
+ assert.equal(store.get('live1')[0].text, 'full long reply');
212
+ });
213
+
214
+ it('view() ships only live agents — dead buckets stay on disk but off the wire', () => {
215
+ store.append('live1', { id: 'a', from: 'agent', text: 'hi', timestamp: 1 });
216
+ store.history['long-gone-agent'] = [{ id: 'b', from: 'agent', text: 'old', timestamp: 2 }];
217
+
218
+ const v = store.view();
219
+ assert.deepEqual(Object.keys(v), ['live1'], 'payload carries live agents only');
220
+ assert.ok(store.getAll()['long-gone-agent'], 'but the data is retained on disk');
221
+ });
222
+
223
+ it('a respawned agent with the same name picks its history back up', () => {
224
+ store.append('live1', { id: 'a', from: 'agent', text: 'earlier work', timestamp: 1 });
225
+ // Agent dies and is later recreated with a different id, same name.
226
+ daemon.registry.delete('live1');
227
+ daemon.registry.set({ id: 'live1-new', name: 'fullstack-1' });
228
+ assert.equal(store.view()['live1-new'][0].text, 'earlier work');
229
+ });
230
+
231
+ it('prune() bounds dead buckets and never touches live agents', () => {
232
+ store.append('live1', { id: 'a', from: 'agent', text: 'keep me', timestamp: 999 });
233
+ for (let i = 0; i < 10; i++) {
234
+ store.history[`dead-${i}`] = [{ id: `d${i}`, from: 'agent', text: 'x', timestamp: i }];
235
+ }
236
+ const removed = store.prune(4);
237
+ assert.equal(removed, 6);
238
+ assert.equal(store.get('live1')[0].text, 'keep me');
239
+ assert.equal(Object.keys(store.getAll()).filter((k) => k.startsWith('dead-')).length, 4);
240
+ // The survivors are the most recently active dead buckets.
241
+ assert.ok(store.getAll()['dead-9'] && !store.getAll()['dead-0']);
242
+ });
243
+ });