groove-dev 0.27.185 → 0.27.186

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.
Files changed (27) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/api.js +1 -0
  4. package/node_modules/@groove-dev/daemon/src/journalist.js +83 -42
  5. package/node_modules/@groove-dev/daemon/src/rotator.js +6 -1
  6. package/node_modules/@groove-dev/daemon/test/journalist.test.js +59 -0
  7. package/node_modules/@groove-dev/daemon/test/rotator.test.js +22 -4
  8. package/node_modules/@groove-dev/gui/dist/assets/{index-DEZwM4Hb.js → index-DOOaCFRS.js} +60 -60
  9. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  10. package/node_modules/@groove-dev/gui/package.json +1 -1
  11. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +36 -4
  12. package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +25 -0
  13. package/node_modules/@groove-dev/gui/src/lib/logpaths.js +45 -0
  14. package/node_modules/@groove-dev/gui/src/stores/slices/ui-slice.js +11 -0
  15. package/package.json +1 -1
  16. package/packages/cli/package.json +1 -1
  17. package/packages/daemon/package.json +1 -1
  18. package/packages/daemon/src/api.js +1 -0
  19. package/packages/daemon/src/journalist.js +83 -42
  20. package/packages/daemon/src/rotator.js +6 -1
  21. package/packages/gui/dist/assets/{index-DEZwM4Hb.js → index-DOOaCFRS.js} +60 -60
  22. package/packages/gui/dist/index.html +1 -1
  23. package/packages/gui/package.json +1 -1
  24. package/packages/gui/src/components/agents/agent-feed.jsx +36 -4
  25. package/packages/gui/src/components/editor/terminal.jsx +25 -0
  26. package/packages/gui/src/lib/logpaths.js +45 -0
  27. package/packages/gui/src/stores/slices/ui-slice.js +11 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.185",
3
+ "version": "0.27.186",
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.185",
3
+ "version": "0.27.186",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1441,6 +1441,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
1441
1441
  'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
1442
1442
  'onboardingDismissed', 'defaultModel', 'defaultChatProvider', 'defaultChatModel',
1443
1443
  'dataSharingDismissed', 'replayCeiling', 'velocityCeiling', 'journalistModelTier',
1444
+ 'resumeBudgetChars',
1444
1445
  ];
1445
1446
  for (const key of Object.keys(req.body)) {
1446
1447
  if (!ALLOWED_KEYS.includes(key)) {
@@ -10,6 +10,12 @@ import { agentLogPath } from './process.js';
10
10
  const DEFAULT_INTERVAL = 300_000; // 5 minutes (safety-net fallback; event-driven triggers handle the normal case)
11
11
  const MAX_LOG_CHARS = 100_000; // ~25k tokens budget for synthesis input (captures 80-90% of recent activity)
12
12
  const DEBOUNCE_MS = 10_000; // requestSynthesis debounce window
13
+ // Conversation carried across a rotation (~37K tokens at 150K chars). This is
14
+ // deliberately generous: a rotation is the one lossy step in an infinite
15
+ // session, and re-reading the recent dialogue once per rotation is far cheaper
16
+ // than the degradation from an agent that forgot its own history. Tune via
17
+ // config resumeBudgetChars (lower for cost-sensitive fleets).
18
+ const DEFAULT_RESUME_BUDGET_CHARS = 150_000;
13
19
 
14
20
  export class Journalist {
15
21
  constructor(daemon) {
@@ -1061,7 +1067,51 @@ export class Journalist {
1061
1067
  *
1062
1068
  * Budget: keeps recent turns verbatim, summarizes oldest if over maxChars.
1063
1069
  */
1064
- extractConversationThread(agent, { maxChars = 60000 } = {}) {
1070
+ extractConversationThread(agent, { maxChars } = {}) {
1071
+ // Budget is configurable (resumeBudgetChars): this is the "how much memory
1072
+ // survives a rotation" dial. Bigger costs more tokens per rotation but
1073
+ // preserves more of the session; a rotation is lossy exactly here.
1074
+ if (!maxChars) maxChars = this.daemon.config?.resumeBudgetChars ?? DEFAULT_RESUME_BUDGET_CHARS;
1075
+ const merged = this._parseConversationTurns(agent);
1076
+ if (!merged || merged.length === 0) return null;
1077
+
1078
+ // Build the thread — keep recent turns verbatim, truncate old ones if over budget
1079
+ let thread = '';
1080
+ const formatted = merged.map((t) => {
1081
+ const label = t.role === 'user' ? 'USER' : 'CLAUDE';
1082
+ return `[${label}]:\n${t.text}`;
1083
+ });
1084
+
1085
+ // Start from the end (most recent) and work backwards to fill budget
1086
+ const recentFirst = [...formatted].reverse();
1087
+ const kept = [];
1088
+ let totalLen = 0;
1089
+
1090
+ for (const entry of recentFirst) {
1091
+ if (totalLen + entry.length > maxChars) {
1092
+ // Truncate this entry to fit remaining budget
1093
+ const remaining = maxChars - totalLen;
1094
+ if (remaining > 200) {
1095
+ kept.push(entry.slice(0, remaining) + '\n[...truncated]');
1096
+ }
1097
+ break;
1098
+ }
1099
+ kept.push(entry);
1100
+ totalLen += entry.length;
1101
+ }
1102
+
1103
+ // Reverse back to chronological order
1104
+ kept.reverse();
1105
+ thread = kept.join('\n\n---\n\n');
1106
+
1107
+ return thread;
1108
+ }
1109
+
1110
+ /**
1111
+ * Parse the full user↔assistant turn list from an agent's stream-json log.
1112
+ * Returns merged turns in chronological order, or null if no log/turns.
1113
+ */
1114
+ _parseConversationTurns(agent) {
1065
1115
  const logPath = agentLogPath(this.daemon.grooveDir, agent);
1066
1116
  if (!existsSync(logPath)) return null;
1067
1117
 
@@ -1121,37 +1171,23 @@ export class Journalist {
1121
1171
  merged.push({ ...turn });
1122
1172
  }
1123
1173
  }
1174
+ return merged;
1175
+ }
1124
1176
 
1125
- // Build the thread — keep recent turns verbatim, truncate old ones if over budget
1126
- let thread = '';
1127
- const formatted = merged.map((t) => {
1128
- const label = t.role === 'user' ? 'USER' : 'CLAUDE';
1129
- return `[${label}]:\n${t.text}`;
1130
- });
1131
-
1132
- // Start from the end (most recent) and work backwards to fill budget
1133
- const recentFirst = [...formatted].reverse();
1134
- const kept = [];
1135
- let totalLen = 0;
1136
-
1137
- for (const entry of recentFirst) {
1138
- if (totalLen + entry.length > maxChars) {
1139
- // Truncate this entry to fit remaining budget
1140
- const remaining = maxChars - totalLen;
1141
- if (remaining > 200) {
1142
- kept.push(entry.slice(0, remaining) + '\n[...truncated]');
1143
- }
1144
- break;
1145
- }
1146
- kept.push(entry);
1147
- totalLen += entry.length;
1148
- }
1149
-
1150
- // Reverse back to chronological order
1151
- kept.reverse();
1152
- thread = kept.join('\n\n---\n\n');
1153
-
1154
- return thread;
1177
+ /**
1178
+ * The agent's ORIGINAL task, from the first substantial user message in the
1179
+ * FULL log — never from the truncated resume window. When weeks of dialogue
1180
+ * exceed the resume budget, the window holds only recent work; anchoring the
1181
+ * task there makes the agent's identity drift onto whatever happened last.
1182
+ * The name-keyed log spans all rotations, so the true origin is always here.
1183
+ */
1184
+ extractOriginalTask(agent) {
1185
+ const merged = this._parseConversationTurns(agent);
1186
+ if (!merged) return '';
1187
+ const first = merged.find((t) => t.role === 'user' && t.text.trim().length >= 10);
1188
+ if (!first) return '';
1189
+ const text = first.text.trim();
1190
+ return text.length > 500 ? text.slice(0, 500) + '...' : text;
1155
1191
  }
1156
1192
 
1157
1193
  /**
@@ -1159,15 +1195,17 @@ export class Journalist {
1159
1195
  * thread so a fresh agent picks up where the previous session left off.
1160
1196
  */
1161
1197
  buildConversationResumePrompt(agent, userMessage, { isRotation = false, reason } = {}) {
1162
- const thread = this.extractConversationThread(agent);
1198
+ const budget = this.daemon.config?.resumeBudgetChars ?? DEFAULT_RESUME_BUDGET_CHARS;
1199
+ const thread = this.extractConversationThread(agent, { maxChars: budget });
1163
1200
  if (!thread) return null;
1164
1201
 
1165
1202
  const constraints = this.daemon.memory?.getConstraintsMarkdown(2000) || '';
1166
1203
  const discoveries = this.daemon.memory?.getDiscoveriesMarkdown(agent.role, 5, 1000, agent.scope, agent.teamId) || '';
1167
1204
 
1168
- // Extract the user's original task from the conversation — the first substantial
1169
- // user message is almost always the task assignment. This anchors the new agent.
1170
- const originalTask = this._extractOriginalTask(thread);
1205
+ // Anchor the agent to its ORIGINAL task from the full log — the truncated
1206
+ // thread's first message may be mid-project (or another agent's task) once
1207
+ // history outgrows the budget. Fall back to the window only if the log parse fails.
1208
+ const originalTask = this.extractOriginalTask(agent) || this._extractOriginalTask(thread);
1171
1209
 
1172
1210
  // Rotation and idle-resume need very different framing. During rotation the agent
1173
1211
  // has no new user message — it must continue the exact in-progress task without
@@ -1181,6 +1219,7 @@ export class Journalist {
1181
1219
  originalTask ? `Your task: ${originalTask}` : '',
1182
1220
  ``,
1183
1221
  `Rules:`,
1222
+ `- NEVER mention the rotation, context refresh, restart, or any session discontinuity to the user — from the user's perspective you are one continuous agent and this refresh must be invisible`,
1184
1223
  `- Continue ONLY the task described in the conversation below`,
1185
1224
  `- Do NOT explore the codebase looking for other things to fix or improve`,
1186
1225
  `- Do NOT start new work that was not part of the original task`,
@@ -1210,14 +1249,16 @@ export class Journalist {
1210
1249
  isIdleResume ? `## New Message From User\n\n${userMessage}` : '',
1211
1250
  ``,
1212
1251
  isRotation
1213
- ? `Continue the in-progress task from the conversation above. Stay focused on that task only. Do not ask the user to repeat anything. If the task was already completed, state that and wait for new instructions.`
1214
- : `Continue seamlessly from the conversation above. You have the full context of what was discussed, what was tried, what worked and what didn't. Do not ask the user to repeat anything.`,
1252
+ ? `Continue the in-progress task from the conversation above. Stay focused on that task only. Do not ask the user to repeat anything, and never reference this context refresh — continue as if the conversation above simply never stopped. If the task was already completed, state that and wait for new instructions.`
1253
+ : `Continue seamlessly from the conversation above. You have the full context of what was discussed, what was tried, what worked and what didn't. Do not ask the user to repeat anything, and never mention the interruption or that you are resuming — to the user this is one continuous conversation.`,
1215
1254
  ].filter(Boolean).join('\n');
1216
1255
 
1217
- // Hard cap at 80K chars (~20K tokens) to leave plenty of room in context window
1218
- if (prompt.length > 80000) {
1256
+ // Cap scales with the configured budget (framing adds constraints/discoveries
1257
+ // on top of the thread) to leave room in the context window
1258
+ const promptCap = budget + 20_000;
1259
+ if (prompt.length > promptCap) {
1219
1260
  // Re-extract with smaller budget and rebuild
1220
- const smallerThread = this.extractConversationThread(agent, { maxChars: 40000 });
1261
+ const smallerThread = this.extractConversationThread(agent, { maxChars: Math.floor(budget * 2 / 3) });
1221
1262
  if (smallerThread) {
1222
1263
  prompt = [
1223
1264
  `# Session Context Resume`,
@@ -1238,8 +1279,8 @@ export class Journalist {
1238
1279
  isIdleResume ? `## New Message From User\n\n${userMessage}` : '',
1239
1280
  ``,
1240
1281
  isRotation
1241
- ? `Continue the in-progress task only. Do not explore or start new work. If done, state that and wait.`
1242
- : `Continue seamlessly. Do not ask the user to repeat anything.`,
1282
+ ? `Continue the in-progress task only. Do not explore or start new work. Never mention this context refresh to the user. If done, state that and wait.`
1283
+ : `Continue seamlessly. Do not ask the user to repeat anything, and never mention the interruption — to the user this is one continuous conversation.`,
1243
1284
  ].filter(Boolean).join('\n');
1244
1285
  }
1245
1286
  }
@@ -18,7 +18,12 @@ const SCORE_HISTORY_MAX = 40; // ~10 min at 15s intervals
18
18
  const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes between rotations per agent
19
19
  const QUALITY_COOLDOWN_MS = 2 * 60 * 1000; // 2 minutes for quality degradation rotations
20
20
  const TOKEN_CEILING = 5_000_000; // 5M tokens per agent (non-self-managing only)
21
- const REPLAY_CEILING = 0.50; // Self-managing providers: rotate idle agents above this context usage
21
+ // Self-managing providers: rotate idle agents above this context usage.
22
+ // 0.80 favors context retention over cache-read cost — the conversation-resume
23
+ // window is a slice of the full session, so every rotation is lossy; for
24
+ // complex long-lived work that loss compounds faster than token savings pay off.
25
+ // Lower via config (replayCeiling) for cost-sensitive fleets; 0 disables.
26
+ const REPLAY_CEILING = 0.80;
22
27
  const VELOCITY_CEILING = 250_000; // New (non-cache) tokens per window before force rotation
23
28
  const VELOCITY_WINDOW_MS = 5 * 60_000;
24
29
  const ROLE_MULTIPLIERS = {
@@ -234,6 +234,65 @@ describe('Journalist', () => {
234
234
  });
235
235
  });
236
236
 
237
+ describe('conversation resume', () => {
238
+ // Build a log whose first user message is the task assignment, followed by
239
+ // enough later dialogue that a small budget truncates the early turns away.
240
+ function writeLongConversation(grooveDir, agentName, originalTask, fillerTurns = 20) {
241
+ const lines = [
242
+ JSON.stringify({ type: 'user', message: { content: originalTask } }),
243
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'Understood — starting on the original task now, here is my plan.' }] } }),
244
+ ];
245
+ for (let i = 0; i < fillerTurns; i++) {
246
+ lines.push(JSON.stringify({ type: 'user', message: { content: `Follow-up question number ${i}: ${'detail '.repeat(50)}` } }));
247
+ lines.push(JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: `Answer to follow-up ${i}: ${'context '.repeat(50)}` }] } }));
248
+ }
249
+ writeFileSync(join(grooveDir, 'logs', `${agentName}.log`), lines.join('\n'));
250
+ }
251
+
252
+ const agentFixture = {
253
+ id: 'arch-id-1', name: 'arch-1', role: 'backend',
254
+ provider: 'claude-code', scope: ['src/**'], workingDir: '/tmp',
255
+ };
256
+
257
+ it('rotation resume prompt forbids mentioning the rotation to the user', () => {
258
+ const { daemon, grooveDir } = createMockDaemon();
259
+ const journalist = new Journalist(daemon);
260
+ writeLongConversation(grooveDir, 'arch-1', 'Build the training pipeline', 2);
261
+
262
+ const prompt = journalist.buildConversationResumePrompt(agentFixture, '', { isRotation: true, reason: 'replay_ceiling' });
263
+
264
+ assert.ok(prompt, 'should build a resume prompt');
265
+ assert.ok(prompt.includes('NEVER mention the rotation'), 'must instruct the agent to keep the rotation invisible');
266
+ });
267
+
268
+ it('anchors the original task from the full log even when the thread window truncates it', () => {
269
+ const { daemon, grooveDir } = createMockDaemon();
270
+ // Small budget: only recent filler turns survive in the thread window
271
+ daemon.config = { resumeBudgetChars: 3000 };
272
+ const journalist = new Journalist(daemon);
273
+ const originalTask = 'Build a mixture-of-experts LLM training pipeline from scratch';
274
+ writeLongConversation(grooveDir, 'arch-1', originalTask, 20);
275
+
276
+ // Sanity: the truncated window no longer contains the original task
277
+ const thread = journalist.extractConversationThread(agentFixture);
278
+ assert.ok(thread, 'should extract a thread');
279
+ assert.ok(!thread.includes(originalTask), 'test setup: original task must be truncated out of the window');
280
+
281
+ const prompt = journalist.buildConversationResumePrompt(agentFixture, '', { isRotation: true, reason: 'replay_ceiling' });
282
+ assert.ok(prompt.includes(originalTask), 'original task must still anchor the prompt via the full log');
283
+ });
284
+
285
+ it('honors resumeBudgetChars config for the thread window', () => {
286
+ const { daemon, grooveDir } = createMockDaemon();
287
+ daemon.config = { resumeBudgetChars: 2000 };
288
+ const journalist = new Journalist(daemon);
289
+ writeLongConversation(grooveDir, 'arch-1', 'Build the training pipeline', 20);
290
+
291
+ const thread = journalist.extractConversationThread(agentFixture);
292
+ assert.ok(thread.length <= 2500, `thread should respect the configured budget, got ${thread.length}`);
293
+ });
294
+ });
295
+
237
296
  describe('status', () => {
238
297
  it('should report status correctly', () => {
239
298
  const { daemon } = createMockDaemon();
@@ -457,7 +457,7 @@ describe('Rotator', () => {
457
457
  const agent = {
458
458
  id: 'rc1', name: 'claude-chat', role: 'backend', status: 'running',
459
459
  provider: 'claude-code', scope: [], model: null,
460
- tokensUsed: 100_000, contextUsage: 0.55, workingDir: '/tmp',
460
+ tokensUsed: 100_000, contextUsage: 0.85, workingDir: '/tmp',
461
461
  lastActivity: new Date(Date.now() - 5 * 60_000).toISOString(), // idle 5 min
462
462
  spawnedAt: new Date(Date.now() - 600_000).toISOString(),
463
463
  };
@@ -470,22 +470,40 @@ describe('Rotator', () => {
470
470
  assert.equal(history[0].reason, 'replay_ceiling');
471
471
  });
472
472
 
473
+ it('should honor a configured replayCeiling below the default', async () => {
474
+ const agent = {
475
+ id: 'rc5', name: 'claude-cheap', role: 'backend', status: 'running',
476
+ provider: 'claude-code', scope: [], model: null,
477
+ tokensUsed: 100_000, contextUsage: 0.55, workingDir: '/tmp',
478
+ lastActivity: new Date(Date.now() - 5 * 60_000).toISOString(),
479
+ spawnedAt: new Date(Date.now() - 600_000).toISOString(),
480
+ };
481
+ mockDaemon.registry.agents = [agent];
482
+ mockDaemon.config = { replayCeiling: 0.5 };
483
+
484
+ await rotator.check();
485
+
486
+ const history = rotator.getHistory();
487
+ assert.equal(history.length, 1);
488
+ assert.equal(history[0].reason, 'replay_ceiling');
489
+ });
490
+
473
491
  it('should NOT replay-ceiling rotate below the ceiling or during cooldown', async () => {
474
492
  const agent = {
475
493
  id: 'rc2', name: 'claude-chat-2', role: 'backend', status: 'running',
476
494
  provider: 'claude-code', scope: [], model: null,
477
- tokensUsed: 100_000, contextUsage: 0.45, workingDir: '/tmp',
495
+ tokensUsed: 100_000, contextUsage: 0.75, workingDir: '/tmp',
478
496
  lastActivity: new Date(Date.now() - 5 * 60_000).toISOString(),
479
497
  spawnedAt: new Date(Date.now() - 600_000).toISOString(),
480
498
  };
481
499
  mockDaemon.registry.agents = [agent];
482
500
 
483
- // Below ceiling — no rotation
501
+ // Below ceiling (0.75 < 0.80 default) — no rotation
484
502
  await rotator.check();
485
503
  assert.equal(rotator.getHistory().length, 0);
486
504
 
487
505
  // Above ceiling but on cooldown — still no rotation
488
- agent.contextUsage = 0.60;
506
+ agent.contextUsage = 0.85;
489
507
  rotator.lastRotationTime.set('rc2', Date.now() - 60_000);
490
508
  await rotator.check();
491
509
  assert.equal(rotator.getHistory().length, 0);