groove-dev 0.27.209 → 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/axom-runtimes.js +67 -1
- 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/src/routes/axom.js +17 -0
- package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +41 -0
- package/node_modules/@groove-dev/daemon/test/axom-runtimes.test.js +62 -0
- package/node_modules/@groove-dev/daemon/test/chatstore.test.js +67 -2
- package/node_modules/@groove-dev/gui/dist/assets/{index-217ZVIOc.js → index-BP4oE2UL.js} +227 -227
- 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/axom-runtimes.js +67 -1
- package/packages/daemon/src/chatstore.js +57 -13
- package/packages/daemon/src/index.js +2 -0
- package/packages/daemon/src/routes/axom.js +17 -0
- package/packages/gui/dist/assets/{index-217ZVIOc.js → index-BP4oE2UL.js} +227 -227
- 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
|
}
|
|
@@ -32,6 +32,22 @@ function withBlessedEnv(launch) {
|
|
|
32
32
|
return { ...launch, env: { ...BLESSED_ENV, ...(launch.env || {}) } };
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// A chat title is the opening message, trimmed to a glanceable length. It is
|
|
36
|
+
// a QUOTE, not a summary: GROOVE has no business paraphrasing what the user
|
|
37
|
+
// said, and an em-dash ellipsis makes the truncation visible rather than
|
|
38
|
+
// pretending the sentence ended there.
|
|
39
|
+
const TITLE_MAX = 48;
|
|
40
|
+
export function summarizeForTitle(text) {
|
|
41
|
+
if (typeof text !== 'string') return null;
|
|
42
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
43
|
+
if (!flat) return null;
|
|
44
|
+
if (flat.length <= TITLE_MAX) return flat;
|
|
45
|
+
// Prefer a word boundary so titles don't end mid-word.
|
|
46
|
+
const cut = flat.slice(0, TITLE_MAX);
|
|
47
|
+
const space = cut.lastIndexOf(' ');
|
|
48
|
+
return `${(space > TITLE_MAX * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`;
|
|
49
|
+
}
|
|
50
|
+
|
|
35
51
|
// Single-quote for a POSIX shell. The spec is the user's own, but it crosses
|
|
36
52
|
// an ssh command line — an unquoted path or value must not be able to end the
|
|
37
53
|
// command and start another.
|
|
@@ -329,13 +345,29 @@ export class AxomRuntimes {
|
|
|
329
345
|
this._save();
|
|
330
346
|
}
|
|
331
347
|
|
|
348
|
+
// A chat titles itself from what it started with — "Chat 3" tells you
|
|
349
|
+
// nothing when you have six of them. Only ever replaces a PLACEHOLDER title:
|
|
350
|
+
// a name the user typed, or one already derived from the opening message, is
|
|
351
|
+
// never overwritten by a later turn.
|
|
352
|
+
titleFromFirstMessage(session, text) {
|
|
353
|
+
const chat = this.getChat(session);
|
|
354
|
+
if (!chat || chat.titled || chat.renamed) return null;
|
|
355
|
+
const title = summarizeForTitle(text);
|
|
356
|
+
if (!title) return null;
|
|
357
|
+
this._putChat({ ...chat, label: title, titled: true });
|
|
358
|
+
this.broadcastChats();
|
|
359
|
+
return title;
|
|
360
|
+
}
|
|
361
|
+
|
|
332
362
|
renameChat(session, label) {
|
|
333
363
|
const chat = this.getChat(session);
|
|
334
364
|
if (!chat) throw new Error(`no chat "${session}"`);
|
|
335
365
|
if (typeof label !== 'string' || !label.trim() || label.length > 80) {
|
|
336
366
|
throw new Error('label must be a non-empty string of at most 80 chars');
|
|
337
367
|
}
|
|
338
|
-
|
|
368
|
+
// `renamed` is sticky: once the user names a chat, no later auto-title
|
|
369
|
+
// may take it back.
|
|
370
|
+
this._putChat({ ...chat, label: label.trim(), renamed: true });
|
|
339
371
|
this.broadcastChats();
|
|
340
372
|
return this.getChat(session);
|
|
341
373
|
}
|
|
@@ -348,10 +380,44 @@ export class AxomRuntimes {
|
|
|
348
380
|
const chat = this.getChat(session);
|
|
349
381
|
if (!chat) throw new Error(`no chat "${session}"`);
|
|
350
382
|
this._putChat({ ...chat, hidden: true });
|
|
383
|
+
this._forgetPrompts(session);
|
|
384
|
+
this._save();
|
|
351
385
|
this.broadcastChats();
|
|
352
386
|
return { hidden: true, session, note: 'removed from the list; the conversation remains in Axom\'s memory' };
|
|
353
387
|
}
|
|
354
388
|
|
|
389
|
+
// ── Prompts — what GROOVE sent, remembered where the events are ──────────
|
|
390
|
+
//
|
|
391
|
+
// The runtime's `pipeline_start` carries no prompt text, so the user's own
|
|
392
|
+
// words exist only in GROOVE. Keeping them in the browser meant a reload
|
|
393
|
+
// replayed every turn from the daemon's ring with its bubble gone — the
|
|
394
|
+
// answer with no question above it. This is OUR record of what WE sent, not
|
|
395
|
+
// invented telemetry, so the daemon is the right place for it.
|
|
396
|
+
recordPrompt(session, ref, text) {
|
|
397
|
+
if (!session || !ref) return null;
|
|
398
|
+
const all = this._cfg().prompts || {};
|
|
399
|
+
const forSession = (all[session] || []).filter((p) => p.ref !== ref);
|
|
400
|
+
// Bounded per session: a transcript this long is scrollback, not memory.
|
|
401
|
+
const next = [...forSession, { ref, text, ts: Date.now() }].slice(-200);
|
|
402
|
+
this._cfg().prompts = { ...all, [session]: next };
|
|
403
|
+
this._save();
|
|
404
|
+
return { ref, text };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
prompts(session) {
|
|
408
|
+
return (this._cfg().prompts || {})[session] || [];
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// A hidden chat's prompts go with it — the list is tidied, the ledger keeps
|
|
412
|
+
// the conversation itself.
|
|
413
|
+
_forgetPrompts(session) {
|
|
414
|
+
const all = this._cfg().prompts || {};
|
|
415
|
+
if (!all[session]) return;
|
|
416
|
+
const next = { ...all };
|
|
417
|
+
delete next[session];
|
|
418
|
+
this._cfg().prompts = next;
|
|
419
|
+
}
|
|
420
|
+
|
|
355
421
|
broadcastChats() {
|
|
356
422
|
this.daemon.broadcast({ type: 'axom:chats', data: { chats: this.chats() } });
|
|
357
423
|
}
|
|
@@ -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
|
|
@@ -46,6 +46,16 @@ export function registerAxomRoutes(app, daemon) {
|
|
|
46
46
|
return res.status(400).json({ error: 'clientRef must be a string of at most 64 chars' });
|
|
47
47
|
}
|
|
48
48
|
const result = await daemon.axom.message(endpoint, req.params.id, text, clientRef);
|
|
49
|
+
// Title the chat from its opening message — but only once the runtime
|
|
50
|
+
// ACCEPTED the turn. A message rejected with 409/413 never ran, so it
|
|
51
|
+
// must not name the conversation it failed to start.
|
|
52
|
+
if (result.status === 202) {
|
|
53
|
+
daemon.axomRuntimes.titleFromFirstMessage(req.params.id, text);
|
|
54
|
+
// Remember what we sent, keyed by the §15 ref the runtime echoes in
|
|
55
|
+
// pipeline_start. This is what lets a reloaded tab put the user's own
|
|
56
|
+
// words back above the answer instead of "prompt not identified".
|
|
57
|
+
if (clientRef) daemon.axomRuntimes.recordPrompt(req.params.id, clientRef, text);
|
|
58
|
+
}
|
|
49
59
|
daemon.audit.log('axom.message', { session: req.params.id, chars: text.length, status: result.status });
|
|
50
60
|
res.status(result.status).json(result.body);
|
|
51
61
|
} catch (err) {
|
|
@@ -167,6 +177,13 @@ export function registerAxomRoutes(app, daemon) {
|
|
|
167
177
|
res.json({ chats: daemon.axomRuntimes.chats() });
|
|
168
178
|
});
|
|
169
179
|
|
|
180
|
+
// What GROOVE sent on this session, so a reloaded tab can restore the user's
|
|
181
|
+
// bubbles. Only ever OUR OWN sends — a turn started from the REPL or another
|
|
182
|
+
// client has no entry here and must still render without a bubble.
|
|
183
|
+
app.get('/api/axom/sessions/:id/prompts', (req, res) => {
|
|
184
|
+
res.json({ prompts: daemon.axomRuntimes.prompts(req.params.id) });
|
|
185
|
+
});
|
|
186
|
+
|
|
170
187
|
app.patch('/api/axom/chats/:session', (req, res) => {
|
|
171
188
|
try {
|
|
172
189
|
res.json(daemon.axomRuntimes.renameChat(req.params.session, req.body?.label));
|
|
@@ -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');
|
|
@@ -291,6 +291,68 @@ describe('AxomRuntimes', () => {
|
|
|
291
291
|
assert.equal(model.getChat(session).hidden, true);
|
|
292
292
|
});
|
|
293
293
|
|
|
294
|
+
it('titles a chat from its opening message, quoting rather than paraphrasing', async () => {
|
|
295
|
+
model.add(SSH_RT);
|
|
296
|
+
daemon.axom.endpoints.set('spark', { status: 'connected', sessions: new Map() });
|
|
297
|
+
const { session } = await model.hook('spark');
|
|
298
|
+
assert.match(model.getChat(session).label, /^Chat /); // placeholder to start
|
|
299
|
+
model.titleFromFirstMessage(session, ' Hey good morning\n Axom! ');
|
|
300
|
+
assert.equal(model.getChat(session).label, 'Hey good morning Axom!');
|
|
301
|
+
// Only the FIRST message titles it — later turns don't rewrite history.
|
|
302
|
+
model.titleFromFirstMessage(session, 'something else entirely');
|
|
303
|
+
assert.equal(model.getChat(session).label, 'Hey good morning Axom!');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('truncates a long opening message visibly and never mid-word', async () => {
|
|
307
|
+
model.add(SSH_RT);
|
|
308
|
+
daemon.axom.endpoints.set('spark', { status: 'connected', sessions: new Map() });
|
|
309
|
+
const { session } = await model.hook('spark');
|
|
310
|
+
model.titleFromFirstMessage(session, 'Can you walk me through how the memory ledger graduation policy actually works');
|
|
311
|
+
const { label } = model.getChat(session);
|
|
312
|
+
assert.ok(label.endsWith('…')); // truncation is visible, not silent
|
|
313
|
+
assert.ok(label.length <= 49);
|
|
314
|
+
assert.doesNotMatch(label, / …$/); // no dangling space before the ellipsis
|
|
315
|
+
assert.ok('Can you walk me through how the memory ledger graduation policy actually works'.startsWith(label.slice(0, -1)));
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('never lets an auto-title overwrite a name the user chose', async () => {
|
|
319
|
+
model.add(SSH_RT);
|
|
320
|
+
daemon.axom.endpoints.set('spark', { status: 'connected', sessions: new Map() });
|
|
321
|
+
const { session } = await model.hook('spark');
|
|
322
|
+
model.renameChat(session, 'Ledger work');
|
|
323
|
+
model.titleFromFirstMessage(session, 'Hey good morning Axom!');
|
|
324
|
+
assert.equal(model.getChat(session).label, 'Ledger work');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// The runtime's pipeline_start carries no prompt text, so the user's words
|
|
328
|
+
// exist only in GROOVE. Browser-only storage meant a reload replayed turns
|
|
329
|
+
// from the ring with their bubbles gone — the answer with no question.
|
|
330
|
+
it('remembers sent prompts by ref so a reloaded tab can restore bubbles', () => {
|
|
331
|
+
model.recordPrompt('s-1', 'g-aaa', 'Hey good morning Axom!');
|
|
332
|
+
model.recordPrompt('s-1', 'g-bbb', 'second one');
|
|
333
|
+
model.recordPrompt('s-2', 'g-ccc', 'other session');
|
|
334
|
+
assert.deepEqual(model.prompts('s-1').map((p) => p.ref), ['g-aaa', 'g-bbb']);
|
|
335
|
+
assert.equal(model.prompts('s-1')[0].text, 'Hey good morning Axom!');
|
|
336
|
+
assert.equal(model.prompts('s-2').length, 1); // sessions never bleed
|
|
337
|
+
// Survives a fresh model over the same config — that IS the reload case.
|
|
338
|
+
assert.equal(new AxomRuntimes(daemon).prompts('s-1').length, 2);
|
|
339
|
+
// Re-recording a ref replaces rather than duplicates.
|
|
340
|
+
model.recordPrompt('s-1', 'g-aaa', 'Hey good morning Axom!');
|
|
341
|
+
assert.equal(model.prompts('s-1').length, 2);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('drops a hidden chat\'s prompts with it, and never another chat\'s', async () => {
|
|
345
|
+
model.add(SSH_RT);
|
|
346
|
+
daemon.axom.endpoints.set('spark', { status: 'connected', sessions: new Map() });
|
|
347
|
+
const a = await model.hook('spark');
|
|
348
|
+
const b = await model.hook('spark');
|
|
349
|
+
model.recordPrompt(a.session, 'g-a', 'mine');
|
|
350
|
+
model.recordPrompt(b.session, 'g-b', 'theirs');
|
|
351
|
+
model.hideChat(a.session);
|
|
352
|
+
assert.equal(model.prompts(a.session).length, 0);
|
|
353
|
+
assert.equal(model.prompts(b.session).length, 1);
|
|
354
|
+
});
|
|
355
|
+
|
|
294
356
|
it('names the generation holder only when it is a chat we minted', async () => {
|
|
295
357
|
model.add(SSH_RT);
|
|
296
358
|
const sessions = new Map();
|
|
@@ -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
|
+
});
|