groove-dev 0.27.185 → 0.27.187
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/api.js +1 -0
- package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +16 -0
- package/node_modules/@groove-dev/daemon/src/introducer.js +2 -2
- package/node_modules/@groove-dev/daemon/src/journalist.js +83 -42
- package/node_modules/@groove-dev/daemon/src/process.js +3 -1
- package/node_modules/@groove-dev/daemon/src/rotator.js +6 -1
- package/node_modules/@groove-dev/daemon/test/journalist.test.js +59 -0
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +22 -4
- package/node_modules/@groove-dev/gui/dist/assets/{index-DEZwM4Hb.js → index-DyI84i9Y.js} +225 -225
- package/node_modules/@groove-dev/gui/dist/index.html +1 -1
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +55 -4
- package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +25 -0
- package/node_modules/@groove-dev/gui/src/lib/logpaths.js +45 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/ui-slice.js +11 -0
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +1 -0
- package/packages/daemon/src/innerchat-docs.js +16 -0
- package/packages/daemon/src/introducer.js +2 -2
- package/packages/daemon/src/journalist.js +83 -42
- package/packages/daemon/src/process.js +3 -1
- package/packages/daemon/src/rotator.js +6 -1
- package/packages/gui/dist/assets/{index-DEZwM4Hb.js → index-DyI84i9Y.js} +225 -225
- package/packages/gui/dist/index.html +1 -1
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/agents/agent-feed.jsx +55 -4
- package/packages/gui/src/components/editor/terminal.jsx +25 -0
- package/packages/gui/src/lib/logpaths.js +45 -0
- package/packages/gui/src/stores/slices/ui-slice.js +11 -0
|
@@ -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)) {
|
|
@@ -94,3 +94,19 @@ export function watchInstructions(port = 31415, agentName = 'YOUR_NAME') {
|
|
|
94
94
|
'- Do not poll or sleep-loop waiting yourself — set the watch and end the turn. That is the whole point.',
|
|
95
95
|
];
|
|
96
96
|
}
|
|
97
|
+
|
|
98
|
+
// Log-path convention. The GUI turns log paths in a message into a one-click
|
|
99
|
+
// "tail" button; a bare or relative filename can't be located reliably (there
|
|
100
|
+
// may be several with the same name in different directories), so agents must
|
|
101
|
+
// give the absolute path.
|
|
102
|
+
export function logFileInstructions() {
|
|
103
|
+
return [
|
|
104
|
+
'## Mentioning Log Files',
|
|
105
|
+
'',
|
|
106
|
+
'When you start a process that writes to a log the user may want to watch, state the',
|
|
107
|
+
"log's ABSOLUTE path — e.g. `Logging to /home/you/project/runs/train.log` — not a bare",
|
|
108
|
+
'or relative filename. The GUI turns that path into a one-click "tail" button for the',
|
|
109
|
+
'user; a bare name like `train.log` cannot be located reliably (several files may share',
|
|
110
|
+
'it), so always resolve it to a full path first (`realpath` / `readlink -f` if unsure).',
|
|
111
|
+
];
|
|
112
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { writeFileSync, readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
5
5
|
import { resolve, dirname, basename } from 'path';
|
|
6
6
|
import { escapeMd } from './validate.js';
|
|
7
|
-
import { innerChatInstructions, watchInstructions } from './innerchat-docs.js';
|
|
7
|
+
import { innerChatInstructions, watchInstructions, logFileInstructions } from './innerchat-docs.js';
|
|
8
8
|
|
|
9
9
|
const GROOVE_SECTION_START = '<!-- GROOVE:START -->';
|
|
10
10
|
const GROOVE_SECTION_END = '<!-- GROOVE:END -->';
|
|
@@ -561,7 +561,7 @@ export class Introducer {
|
|
|
561
561
|
// compaction — the spawn prompt alone can scroll out of a long session.
|
|
562
562
|
_innerChatSection() {
|
|
563
563
|
const port = this.daemon.port || 31415;
|
|
564
|
-
return [...innerChatInstructions(port), '', ...watchInstructions(port)];
|
|
564
|
+
return [...innerChatInstructions(port), '', ...watchInstructions(port), '', ...logFileInstructions()];
|
|
565
565
|
}
|
|
566
566
|
|
|
567
567
|
writeRegistryFile(projectDir) {
|
|
@@ -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
|
|
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
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
const
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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
|
|
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
|
-
//
|
|
1169
|
-
//
|
|
1170
|
-
|
|
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
|
-
//
|
|
1218
|
-
|
|
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:
|
|
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
|
}
|
|
@@ -10,7 +10,7 @@ import { LocalProvider } from './providers/local.js';
|
|
|
10
10
|
import { OllamaProvider } from './providers/ollama.js';
|
|
11
11
|
import { AgentLoop } from './agent-loop.js';
|
|
12
12
|
import { validateAgentConfig } from './validate.js';
|
|
13
|
-
import { innerChatInstructions, watchInstructions } from './innerchat-docs.js';
|
|
13
|
+
import { innerChatInstructions, watchInstructions, logFileInstructions } from './innerchat-docs.js';
|
|
14
14
|
|
|
15
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
16
|
const SLIDES_ENGINE_SRC = resolve(__dirname, '../templates/groove-slides.cjs');
|
|
@@ -1174,6 +1174,8 @@ For normal file edits within your scope, proceed without review.
|
|
|
1174
1174
|
...innerChatInstructions(port, agent.name),
|
|
1175
1175
|
'',
|
|
1176
1176
|
...watchInstructions(port, agent.name),
|
|
1177
|
+
'',
|
|
1178
|
+
...logFileInstructions(),
|
|
1177
1179
|
].join('\n') + '\n\n';
|
|
1178
1180
|
if (spawnConfig.prompt.startsWith('# Handoff Brief')) {
|
|
1179
1181
|
spawnConfig.prompt += '\n\n' + capabilities.trim();
|
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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);
|