groove-dev 0.27.209 → 0.27.210
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/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-runtimes.js +67 -1
- package/node_modules/@groove-dev/daemon/src/routes/axom.js +17 -0
- package/node_modules/@groove-dev/daemon/test/axom-runtimes.test.js +62 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-217ZVIOc.js → index-YHDeARYl.js} +57 -57
- package/node_modules/@groove-dev/gui/dist/index.html +1 -1
- 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-runtimes.js +67 -1
- package/packages/daemon/src/routes/axom.js +17 -0
- package/packages/gui/dist/assets/{index-217ZVIOc.js → index-YHDeARYl.js} +57 -57
- package/packages/gui/dist/index.html +1 -1
- package/packages/gui/package.json +1 -1
|
@@ -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
|
}
|
|
@@ -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));
|
|
@@ -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();
|