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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.209",
3
+ "version": "0.27.210",
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.209",
3
+ "version": "0.27.210",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -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
- this._putChat({ ...chat, label: label.trim() });
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();