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 +8 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/axom-connector.js +20 -6
- package/node_modules/@groove-dev/daemon/src/chatstore.js +57 -13
- package/node_modules/@groove-dev/daemon/src/index.js +2 -0
- package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +41 -0
- package/node_modules/@groove-dev/daemon/test/chatstore.test.js +67 -2
- package/node_modules/@groove-dev/gui/dist/assets/{index-YHDeARYl.js → index-BP4oE2UL.js} +228 -228
- package/node_modules/@groove-dev/gui/dist/assets/index-DPsim83z.css +1 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/axom-connector.js +20 -6
- package/packages/daemon/src/chatstore.js +57 -13
- package/packages/daemon/src/index.js +2 -0
- package/packages/gui/dist/assets/{index-YHDeARYl.js → index-BP4oE2UL.js} +228 -228
- package/packages/gui/dist/assets/index-DPsim83z.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-DdCadtGL.css +0 -1
- package/packages/gui/dist/assets/index-DdCadtGL.css +0 -1
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 -->
|
|
@@ -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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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:
|
|
145
|
-
* looks up by)
|
|
146
|
-
*
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
|
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 (
|
|
190
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
});
|