groove-dev 0.27.180 → 0.27.182
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 +7 -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/scripts/repair-tokens.mjs +78 -0
- package/node_modules/@groove-dev/daemon/src/api.js +1 -1
- package/node_modules/@groove-dev/daemon/src/index.js +1 -0
- package/node_modules/@groove-dev/daemon/src/journalist.js +15 -8
- package/node_modules/@groove-dev/daemon/src/process.js +134 -21
- package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +16 -1
- package/node_modules/@groove-dev/daemon/src/rotator.js +62 -5
- package/node_modules/@groove-dev/daemon/src/tokentracker.js +46 -6
- package/node_modules/@groove-dev/daemon/test/claude-code-provider.test.js +128 -0
- package/node_modules/@groove-dev/daemon/test/journalist.test.js +4 -2
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +131 -0
- package/node_modules/@groove-dev/daemon/test/tokentracker.test.js +4 -0
- 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/scripts/repair-tokens.mjs +78 -0
- package/packages/daemon/src/api.js +1 -1
- package/packages/daemon/src/index.js +1 -0
- package/packages/daemon/src/journalist.js +15 -8
- package/packages/daemon/src/process.js +134 -21
- package/packages/daemon/src/providers/claude-code.js +16 -1
- package/packages/daemon/src/rotator.js +62 -5
- package/packages/daemon/src/tokentracker.js +46 -6
- package/packages/gui/package.json +1 -1
|
@@ -12,6 +12,14 @@ const COLD_START_PER_FILE = 15;
|
|
|
12
12
|
const COLD_START_PER_DIR = 40;
|
|
13
13
|
// Estimated tokens wasted per file conflict (agent discovers, backs off, retries)
|
|
14
14
|
const CONFLICT_OVERHEAD = 500;
|
|
15
|
+
// Max per-agent session records kept. Aggregates are cumulative and unaffected;
|
|
16
|
+
// windowed queries (getVelocity/getTokensInWindow) only need the recent tail,
|
|
17
|
+
// which 500 records covers by orders of magnitude. Uncapped, tokens.json grew
|
|
18
|
+
// to 13.8MB and was rewritten on every assistant stream event.
|
|
19
|
+
const SESSION_CAP = 500;
|
|
20
|
+
// Debounce window for persisting to disk. record() fires per assistant event —
|
|
21
|
+
// writing the full ledger each time is a hot-path full-file rewrite.
|
|
22
|
+
const SAVE_DEBOUNCE_MS = 5_000;
|
|
15
23
|
|
|
16
24
|
export class TokenTracker {
|
|
17
25
|
constructor(grooveDir) {
|
|
@@ -23,6 +31,7 @@ export class TokenTracker {
|
|
|
23
31
|
this.coldStartsSkipped = 0;
|
|
24
32
|
this.projectFiles = 0; // Set from indexer stats
|
|
25
33
|
this.projectDirs = 0;
|
|
34
|
+
this._saveTimer = null;
|
|
26
35
|
this.load();
|
|
27
36
|
}
|
|
28
37
|
|
|
@@ -47,6 +56,7 @@ export class TokenTracker {
|
|
|
47
56
|
if (!entry.totalDurationMs) entry.totalDurationMs = 0;
|
|
48
57
|
if (!entry.totalTurns) entry.totalTurns = 0;
|
|
49
58
|
if (!entry.modelDistribution) entry.modelDistribution = {};
|
|
59
|
+
this._capSessions(entry);
|
|
50
60
|
}
|
|
51
61
|
} catch {
|
|
52
62
|
this.usage = {};
|
|
@@ -55,6 +65,10 @@ export class TokenTracker {
|
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
save() {
|
|
68
|
+
if (this._saveTimer) {
|
|
69
|
+
clearTimeout(this._saveTimer);
|
|
70
|
+
this._saveTimer = null;
|
|
71
|
+
}
|
|
58
72
|
writeFileSync(this.path, JSON.stringify({
|
|
59
73
|
usage: this.usage,
|
|
60
74
|
rotationSavings: this.rotationSavings,
|
|
@@ -64,6 +78,30 @@ export class TokenTracker {
|
|
|
64
78
|
}, null, 2));
|
|
65
79
|
}
|
|
66
80
|
|
|
81
|
+
// Debounced persistence for hot-path recording. In-memory state is always
|
|
82
|
+
// current; at most SAVE_DEBOUNCE_MS of records are at risk on a crash.
|
|
83
|
+
_scheduleSave() {
|
|
84
|
+
if (this._saveTimer) return;
|
|
85
|
+
this._saveTimer = setTimeout(() => {
|
|
86
|
+
this._saveTimer = null;
|
|
87
|
+
try { this.save(); } catch { /* best-effort */ }
|
|
88
|
+
}, SAVE_DEBOUNCE_MS);
|
|
89
|
+
this._saveTimer.unref?.();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Write any pending debounced state to disk. Call on daemon shutdown.
|
|
93
|
+
flush() {
|
|
94
|
+
if (this._saveTimer) {
|
|
95
|
+
try { this.save(); } catch { /* best-effort */ }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_capSessions(entry) {
|
|
100
|
+
if (Array.isArray(entry.sessions) && entry.sessions.length > SESSION_CAP) {
|
|
101
|
+
entry.sessions.splice(0, entry.sessions.length - SESSION_CAP);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
67
105
|
_initAgent(agentId) {
|
|
68
106
|
if (!this.usage[agentId]) {
|
|
69
107
|
this.usage[agentId] = {
|
|
@@ -89,7 +127,8 @@ export class TokenTracker {
|
|
|
89
127
|
if (typeof detail === 'number') {
|
|
90
128
|
entry.total += detail;
|
|
91
129
|
entry.sessions.push({ tokens: detail, timestamp: new Date().toISOString() });
|
|
92
|
-
this.
|
|
130
|
+
this._capSessions(entry);
|
|
131
|
+
this._scheduleSave();
|
|
93
132
|
return;
|
|
94
133
|
}
|
|
95
134
|
|
|
@@ -121,8 +160,9 @@ export class TokenTracker {
|
|
|
121
160
|
projectRoot: detail.projectRoot || null,
|
|
122
161
|
timestamp: new Date().toISOString(),
|
|
123
162
|
});
|
|
163
|
+
this._capSessions(entry);
|
|
124
164
|
|
|
125
|
-
this.
|
|
165
|
+
this._scheduleSave();
|
|
126
166
|
}
|
|
127
167
|
|
|
128
168
|
// Sum tokens recorded for an agent since a given timestamp.
|
|
@@ -152,25 +192,25 @@ export class TokenTracker {
|
|
|
152
192
|
if (costUsd) entry.totalCostUsd += costUsd;
|
|
153
193
|
if (durationMs) entry.totalDurationMs += durationMs;
|
|
154
194
|
if (turns) entry.totalTurns += turns;
|
|
155
|
-
this.
|
|
195
|
+
this._scheduleSave();
|
|
156
196
|
}
|
|
157
197
|
|
|
158
198
|
// Record that a rotation saved context tokens
|
|
159
199
|
recordRotation(agentId, tokensBefore) {
|
|
160
200
|
this.rotationSavings += Math.round(tokensBefore * 0.3); // ~30% of context was degraded
|
|
161
|
-
this.
|
|
201
|
+
this._scheduleSave();
|
|
162
202
|
}
|
|
163
203
|
|
|
164
204
|
// Record that a conflict was prevented (scope enforcement)
|
|
165
205
|
recordConflictPrevented() {
|
|
166
206
|
this.conflictsPrevented++;
|
|
167
|
-
this.
|
|
207
|
+
this._scheduleSave();
|
|
168
208
|
}
|
|
169
209
|
|
|
170
210
|
// Record that a cold-start was skipped (Journalist provided context)
|
|
171
211
|
recordColdStartSkipped() {
|
|
172
212
|
this.coldStartsSkipped++;
|
|
173
|
-
this.
|
|
213
|
+
this._scheduleSave();
|
|
174
214
|
}
|
|
175
215
|
|
|
176
216
|
// Set project size from indexer for dynamic cold-start estimation
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// GROOVE — Claude Code Provider Tests
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { describe, it } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { ClaudeCodeProvider } from '../src/providers/claude-code.js';
|
|
7
|
+
|
|
8
|
+
const provider = new ClaudeCodeProvider();
|
|
9
|
+
|
|
10
|
+
describe('ClaudeCodeProvider result parsing', () => {
|
|
11
|
+
// Regression: the "phantom interrupt" bug. On --resume with an orphaned
|
|
12
|
+
// background shell task, the CLI aborts the turn before calling the model.
|
|
13
|
+
// These fields were previously dropped, so the abort was indistinguishable
|
|
14
|
+
// from a successful turn and the user's message vanished with no UI signal.
|
|
15
|
+
it('surfaces error signals from an aborted turn', () => {
|
|
16
|
+
const out = provider.parseOutput(JSON.stringify({
|
|
17
|
+
type: 'result',
|
|
18
|
+
subtype: 'error_during_execution',
|
|
19
|
+
is_error: true,
|
|
20
|
+
duration_ms: 5126,
|
|
21
|
+
duration_api_ms: 0,
|
|
22
|
+
num_turns: 2,
|
|
23
|
+
total_cost_usd: 0,
|
|
24
|
+
terminal_reason: 'aborted_streaming',
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
assert.equal(out.type, 'result');
|
|
28
|
+
assert.equal(out.isError, true);
|
|
29
|
+
assert.equal(out.apiDurationMs, 0, 'api duration 0 proves the model was never reached');
|
|
30
|
+
assert.equal(out.terminalReason, 'aborted_streaming');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('treats an error_during_execution subtype as an error even without is_error', () => {
|
|
34
|
+
const out = provider.parseOutput(JSON.stringify({
|
|
35
|
+
type: 'result', subtype: 'error_during_execution', duration_api_ms: 0,
|
|
36
|
+
}));
|
|
37
|
+
assert.equal(out.isError, true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('does not flag a successful turn as an error', () => {
|
|
41
|
+
const out = provider.parseOutput(JSON.stringify({
|
|
42
|
+
type: 'result',
|
|
43
|
+
subtype: 'success',
|
|
44
|
+
is_error: false,
|
|
45
|
+
duration_ms: 17850,
|
|
46
|
+
duration_api_ms: 17672,
|
|
47
|
+
num_turns: 3,
|
|
48
|
+
total_cost_usd: 0.42,
|
|
49
|
+
terminal_reason: 'completed',
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
assert.equal(out.isError, false);
|
|
53
|
+
assert.equal(out.apiDurationMs, 17672);
|
|
54
|
+
assert.equal(out.cost, 0.42);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// The retry gate is (isError && apiDurationMs === 0). A turn that failed
|
|
58
|
+
// *after* reaching the model has real cost and must not be auto-retried.
|
|
59
|
+
it('distinguishes a pre-model abort from a post-model failure', () => {
|
|
60
|
+
const preModel = provider.parseOutput(JSON.stringify({
|
|
61
|
+
type: 'result', subtype: 'error_during_execution', is_error: true, duration_api_ms: 0,
|
|
62
|
+
}));
|
|
63
|
+
const postModel = provider.parseOutput(JSON.stringify({
|
|
64
|
+
type: 'result', subtype: 'error_during_execution', is_error: true, duration_api_ms: 8400,
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
assert.equal(preModel.isError && preModel.apiDurationMs === 0, true, 'free to retry');
|
|
68
|
+
assert.equal(postModel.isError && postModel.apiDurationMs === 0, false, 'already billed — do not retry');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('ClaudeCodeProvider resume handshake (CLI >= 2.1.212)', () => {
|
|
73
|
+
// The 0-turn no-op result the CLI emits ~180ms into --resume reconciliation.
|
|
74
|
+
const HANDSHAKE = JSON.stringify({
|
|
75
|
+
type: 'result', subtype: 'success', is_error: false,
|
|
76
|
+
duration_ms: 186, duration_api_ms: 0, num_turns: 0, total_cost_usd: 0,
|
|
77
|
+
});
|
|
78
|
+
const ABORT = JSON.stringify({
|
|
79
|
+
type: 'result', subtype: 'error_during_execution', is_error: true,
|
|
80
|
+
duration_ms: 5126, duration_api_ms: 0, num_turns: 2, total_cost_usd: 0,
|
|
81
|
+
terminal_reason: 'aborted_streaming',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// This is the gate the daemon uses to decide whether a result means "the
|
|
85
|
+
// turn completed". Treating the handshake as a completion arms a 5s
|
|
86
|
+
// force-kill that SIGTERMs the process mid-turn.
|
|
87
|
+
const reachedModel = (o) => o.apiDurationMs === undefined || o.apiDurationMs > 0;
|
|
88
|
+
|
|
89
|
+
it('does not count the resume handshake as a completed turn', () => {
|
|
90
|
+
const out = provider.parseOutput(HANDSHAKE);
|
|
91
|
+
assert.equal(out.isError, false);
|
|
92
|
+
assert.equal(reachedModel(out), false,
|
|
93
|
+
'handshake must not arm the force-kill or clear the queued message');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('counts a real turn as completed', () => {
|
|
97
|
+
const out = provider.parseOutput(JSON.stringify({
|
|
98
|
+
type: 'result', subtype: 'success', is_error: false,
|
|
99
|
+
duration_ms: 17850, duration_api_ms: 17672, num_turns: 3, total_cost_usd: 0.42,
|
|
100
|
+
}));
|
|
101
|
+
assert.equal(reachedModel(out), true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('providers without an API duration still count as completed', () => {
|
|
105
|
+
// codex / gemini / ollama don't report duration_api_ms — they must be
|
|
106
|
+
// unaffected by the guard.
|
|
107
|
+
const out = provider.parseOutput(JSON.stringify({
|
|
108
|
+
type: 'result', subtype: 'success', duration_ms: 1200, num_turns: 1,
|
|
109
|
+
}));
|
|
110
|
+
assert.equal(out.apiDurationMs, undefined);
|
|
111
|
+
assert.equal(reachedModel(out), true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Regression: taking the first result in a chunk let the handshake mask a
|
|
115
|
+
// subsequent abort, so the failure looked like a clean completion.
|
|
116
|
+
it('surfaces the abort when a chunk carries handshake + abort together', () => {
|
|
117
|
+
const out = provider.parseOutput(`${HANDSHAKE}\n${ABORT}`);
|
|
118
|
+
assert.equal(out.isError, true, 'abort must win over the no-op success result');
|
|
119
|
+
assert.equal(out.terminalReason, 'aborted_streaming');
|
|
120
|
+
assert.equal(reachedModel(out), false);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('ClaudeCodeProvider models', () => {
|
|
125
|
+
it('offers Fable 5', () => {
|
|
126
|
+
assert.ok(ClaudeCodeProvider.models.some((m) => m.id === 'claude-fable-5'));
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -174,13 +174,15 @@ describe('Journalist', () => {
|
|
|
174
174
|
const { daemon, grooveDir } = createMockDaemon();
|
|
175
175
|
const journalist = new Journalist(daemon);
|
|
176
176
|
|
|
177
|
-
// Create a mock log — filename must match agent.
|
|
177
|
+
// Create a mock log — filename must match agent.name ('backend-1'),
|
|
178
|
+
// mirroring the writer in process.js (logs are keyed by sanitized name
|
|
179
|
+
// so rotated agents keep appending to the same file)
|
|
178
180
|
const logLines = [
|
|
179
181
|
JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Write', input: { file_path: 'src/api/auth.js' } }] } }),
|
|
180
182
|
JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Edit', input: { file_path: 'src/api/users.js', old_string: 'old', new_string: 'new' } }] } }),
|
|
181
183
|
JSON.stringify({ type: 'user', message: { content: 'Add JWT middleware to the auth route' } }),
|
|
182
184
|
].join('\n');
|
|
183
|
-
writeFileSync(join(grooveDir, 'logs', '
|
|
185
|
+
writeFileSync(join(grooveDir, 'logs', 'backend-1.log'), logLines);
|
|
184
186
|
|
|
185
187
|
const agent = {
|
|
186
188
|
id: 'a1', name: 'backend-1', role: 'backend',
|
|
@@ -452,4 +452,135 @@ describe('Rotator', () => {
|
|
|
452
452
|
assert.equal(appendArgs.teamId, 'team-alpha');
|
|
453
453
|
assert.equal(appendArgs.role, 'backend');
|
|
454
454
|
});
|
|
455
|
+
|
|
456
|
+
it('should rotate idle Claude Code agent above the replay ceiling', async () => {
|
|
457
|
+
const agent = {
|
|
458
|
+
id: 'rc1', name: 'claude-chat', role: 'backend', status: 'running',
|
|
459
|
+
provider: 'claude-code', scope: [], model: null,
|
|
460
|
+
tokensUsed: 100_000, contextUsage: 0.55, workingDir: '/tmp',
|
|
461
|
+
lastActivity: new Date(Date.now() - 5 * 60_000).toISOString(), // idle 5 min
|
|
462
|
+
spawnedAt: new Date(Date.now() - 600_000).toISOString(),
|
|
463
|
+
};
|
|
464
|
+
mockDaemon.registry.agents = [agent];
|
|
465
|
+
|
|
466
|
+
await rotator.check();
|
|
467
|
+
|
|
468
|
+
const history = rotator.getHistory();
|
|
469
|
+
assert.equal(history.length, 1);
|
|
470
|
+
assert.equal(history[0].reason, 'replay_ceiling');
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('should NOT replay-ceiling rotate below the ceiling or during cooldown', async () => {
|
|
474
|
+
const agent = {
|
|
475
|
+
id: 'rc2', name: 'claude-chat-2', role: 'backend', status: 'running',
|
|
476
|
+
provider: 'claude-code', scope: [], model: null,
|
|
477
|
+
tokensUsed: 100_000, contextUsage: 0.45, 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
|
+
|
|
483
|
+
// Below ceiling — no rotation
|
|
484
|
+
await rotator.check();
|
|
485
|
+
assert.equal(rotator.getHistory().length, 0);
|
|
486
|
+
|
|
487
|
+
// Above ceiling but on cooldown — still no rotation
|
|
488
|
+
agent.contextUsage = 0.60;
|
|
489
|
+
rotator.lastRotationTime.set('rc2', Date.now() - 60_000);
|
|
490
|
+
await rotator.check();
|
|
491
|
+
assert.equal(rotator.getHistory().length, 0);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('should block auto-rotation of a chat agent when no conversation thread is extractable', async () => {
|
|
495
|
+
const agent = {
|
|
496
|
+
id: 'rc3', name: 'claude-chat-3', role: 'backend', status: 'running',
|
|
497
|
+
provider: 'claude-code', scope: [], model: null,
|
|
498
|
+
tokensUsed: 100_000, contextUsage: 0.70, workingDir: '/tmp',
|
|
499
|
+
lastActivity: new Date(Date.now() - 5 * 60_000).toISOString(),
|
|
500
|
+
spawnedAt: new Date(Date.now() - 600_000).toISOString(),
|
|
501
|
+
};
|
|
502
|
+
mockDaemon.registry.agents = [agent];
|
|
503
|
+
|
|
504
|
+
// Agent has chat history, but journalist can't extract the thread
|
|
505
|
+
rotator.recordUserMessage('rc3');
|
|
506
|
+
mockDaemon.journalist.buildConversationResumePrompt = () => null;
|
|
507
|
+
|
|
508
|
+
const result = await rotator.rotate('rc3', { reason: 'replay_ceiling' });
|
|
509
|
+
|
|
510
|
+
assert.equal(result, null);
|
|
511
|
+
assert.equal(rotator.getHistory().length, 0);
|
|
512
|
+
const blocked = broadcasts.find((b) => b.type === 'rotation:blocked');
|
|
513
|
+
assert.ok(blocked, 'should broadcast rotation:blocked');
|
|
514
|
+
assert.equal(blocked.reason, 'no_conversation_thread');
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('should not let Instructions boilerplate defeat the low-confidence handoff guard', async () => {
|
|
518
|
+
const agent = {
|
|
519
|
+
id: 'rc4', name: 'claude-chat-4', role: 'backend', status: 'running',
|
|
520
|
+
provider: 'claude-code', scope: [], model: null,
|
|
521
|
+
tokensUsed: 100_000, contextUsage: 0.70, workingDir: '/tmp',
|
|
522
|
+
lastActivity: new Date(Date.now() - 5 * 60_000).toISOString(),
|
|
523
|
+
spawnedAt: new Date(Date.now() - 600_000).toISOString(),
|
|
524
|
+
};
|
|
525
|
+
mockDaemon.registry.agents = [agent];
|
|
526
|
+
|
|
527
|
+
// A content-free brief padded with the static Instructions block —
|
|
528
|
+
// this is exactly what generateHandoffBrief emits when logs are empty
|
|
529
|
+
mockDaemon.journalist.generateHandoffBrief = async () => [
|
|
530
|
+
'# Handoff Brief',
|
|
531
|
+
'- role: backend',
|
|
532
|
+
'## Instructions',
|
|
533
|
+
'',
|
|
534
|
+
'Continue and finish the in-progress task — deliver the output. Stay focused on that specific task only.',
|
|
535
|
+
'- Do NOT explore the codebase looking for other things to fix or improve',
|
|
536
|
+
'- Do NOT start new work outside the original task scope',
|
|
537
|
+
'- Do NOT act on TODO comments, code quality issues, or improvements you notice in passing',
|
|
538
|
+
'- If the task is already complete, report what was accomplished and STOP — await new instructions from the user',
|
|
539
|
+
].join('\n');
|
|
540
|
+
|
|
541
|
+
const result = await rotator.rotate('rc4', { reason: 'replay_ceiling' });
|
|
542
|
+
|
|
543
|
+
assert.equal(result, null);
|
|
544
|
+
assert.equal(rotator.getHistory().length, 0);
|
|
545
|
+
const blocked = broadcasts.find((b) => b.type === 'rotation:blocked');
|
|
546
|
+
assert.ok(blocked, 'should broadcast rotation:blocked');
|
|
547
|
+
assert.equal(blocked.reason, 'low_confidence_handoff');
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('should force-rotate any provider on velocity ceiling', async () => {
|
|
551
|
+
const agent = {
|
|
552
|
+
id: 'v1', name: 'claude-runaway', role: 'backend', status: 'running',
|
|
553
|
+
provider: 'claude-code', scope: [], model: null,
|
|
554
|
+
tokensUsed: 400_000, contextUsage: 0.30, workingDir: '/tmp',
|
|
555
|
+
lastActivity: new Date(Date.now() - 5_000).toISOString(), // active
|
|
556
|
+
spawnedAt: new Date(Date.now() - 600_000).toISOString(),
|
|
557
|
+
};
|
|
558
|
+
mockDaemon.registry.agents = [agent];
|
|
559
|
+
mockDaemon.tokens.getVelocity = () => 300_000; // above 250K/5min default
|
|
560
|
+
|
|
561
|
+
// Even on cooldown — velocity is a safety override
|
|
562
|
+
rotator.lastRotationTime.set('v1', Date.now() - 60_000);
|
|
563
|
+
|
|
564
|
+
await rotator.check();
|
|
565
|
+
|
|
566
|
+
const history = rotator.getHistory();
|
|
567
|
+
assert.equal(history.length, 1);
|
|
568
|
+
assert.equal(history[0].reason, 'velocity_ceiling');
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('should not velocity-rotate under the ceiling', async () => {
|
|
572
|
+
const agent = {
|
|
573
|
+
id: 'v2', name: 'claude-normal', role: 'backend', status: 'running',
|
|
574
|
+
provider: 'claude-code', scope: [], model: null,
|
|
575
|
+
tokensUsed: 50_000, contextUsage: 0.30, workingDir: '/tmp',
|
|
576
|
+
lastActivity: new Date(Date.now() - 5_000).toISOString(),
|
|
577
|
+
spawnedAt: new Date(Date.now() - 600_000).toISOString(),
|
|
578
|
+
};
|
|
579
|
+
mockDaemon.registry.agents = [agent];
|
|
580
|
+
mockDaemon.tokens.getVelocity = () => 80_000;
|
|
581
|
+
|
|
582
|
+
await rotator.check();
|
|
583
|
+
|
|
584
|
+
assert.equal(rotator.getHistory().length, 0);
|
|
585
|
+
});
|
|
455
586
|
});
|
|
@@ -61,6 +61,10 @@ describe('TokenTracker', () => {
|
|
|
61
61
|
tracker.record('agent-1', 100);
|
|
62
62
|
tracker.record('agent-2', 200);
|
|
63
63
|
|
|
64
|
+
// record() debounces disk writes (hot path); flush forces the pending save,
|
|
65
|
+
// mirroring what the daemon does on shutdown.
|
|
66
|
+
tracker.flush();
|
|
67
|
+
|
|
64
68
|
const tracker2 = new TokenTracker(tmpDir);
|
|
65
69
|
assert.equal(tracker2.getAgent('agent-1').total, 100);
|
|
66
70
|
assert.equal(tracker2.getAgent('agent-2').total, 200);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "groove-dev",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.182",
|
|
4
4
|
"description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// One-off ledger repair for ~/.groove/tokens.json (2026-07 efficiency audit).
|
|
5
|
+
//
|
|
6
|
+
// Entry c3789b6f recorded $20,536.57 from a duplicate-ingestion loop (4,631
|
|
7
|
+
// session records in 380s, payloads duplicated up to 10×). The ingestion bug
|
|
8
|
+
// is fixed in claude-code.js, but the poisoned data still dominates every cost
|
|
9
|
+
// dashboard: recorded all-time spend ~$24K vs ~$3.5K real. This script:
|
|
10
|
+
// 1. Sets the poisoned entry's totalCostUsd to its audited real value (~$325)
|
|
11
|
+
// 2. Truncates every entry's sessions array to the last 500 records
|
|
12
|
+
// (matches SESSION_CAP in tokentracker.js; aggregates are untouched)
|
|
13
|
+
// 3. Writes a timestamped .bak of the original file first
|
|
14
|
+
//
|
|
15
|
+
// MUST run with the daemon STOPPED — the daemon holds the ledger in memory and
|
|
16
|
+
// rewrites the file, so a live edit gets clobbered. The script checks and aborts.
|
|
17
|
+
//
|
|
18
|
+
// Usage: node repair-tokens.mjs [path-to-tokens.json] [--entry c3789b6f] [--cost 325]
|
|
19
|
+
|
|
20
|
+
import { readFileSync, writeFileSync, copyFileSync, existsSync } from 'fs';
|
|
21
|
+
import { resolve } from 'path';
|
|
22
|
+
import { homedir } from 'os';
|
|
23
|
+
|
|
24
|
+
const args = process.argv.slice(2);
|
|
25
|
+
const flag = (name, dflt) => {
|
|
26
|
+
const i = args.indexOf(`--${name}`);
|
|
27
|
+
return i !== -1 && args[i + 1] ? args[i + 1] : dflt;
|
|
28
|
+
};
|
|
29
|
+
const tokensPath = args[0] && !args[0].startsWith('--')
|
|
30
|
+
? resolve(args[0])
|
|
31
|
+
: resolve(homedir(), '.groove', 'tokens.json');
|
|
32
|
+
const entryId = flag('entry', 'c3789b6f');
|
|
33
|
+
const correctedCost = Number(flag('cost', '325'));
|
|
34
|
+
const SESSION_CAP = 500;
|
|
35
|
+
|
|
36
|
+
// Refuse to edit under a live daemon
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetch('http://127.0.0.1:31415/api/health', { signal: AbortSignal.timeout(1500) });
|
|
39
|
+
if (res.ok) {
|
|
40
|
+
console.error('ABORT: GROOVE daemon is running on :31415. Stop it first (groove stop) — it rewrites tokens.json and will clobber this repair.');
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
} catch { /* no daemon — safe to proceed */ }
|
|
44
|
+
|
|
45
|
+
if (!existsSync(tokensPath)) {
|
|
46
|
+
console.error(`ABORT: ${tokensPath} not found. Pass the path explicitly: node repair-tokens.mjs /path/to/tokens.json`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const backupPath = `${tokensPath}.${new Date().toISOString().replace(/[:.]/g, '-')}.bak`;
|
|
51
|
+
copyFileSync(tokensPath, backupPath);
|
|
52
|
+
console.log(`Backup written: ${backupPath}`);
|
|
53
|
+
|
|
54
|
+
const data = JSON.parse(readFileSync(tokensPath, 'utf8'));
|
|
55
|
+
const usage = data.usage || data;
|
|
56
|
+
|
|
57
|
+
const target = usage[entryId];
|
|
58
|
+
if (target) {
|
|
59
|
+
const before = target.totalCostUsd;
|
|
60
|
+
target.totalCostUsd = correctedCost;
|
|
61
|
+
target.note = `totalCostUsd corrected ${before} -> ${correctedCost} on ${new Date().toISOString().slice(0, 10)} (duplicate-ingestion inflation, see 2026-07 efficiency audit)`;
|
|
62
|
+
console.log(`Entry ${entryId}: totalCostUsd ${before} -> ${correctedCost}`);
|
|
63
|
+
} else {
|
|
64
|
+
console.warn(`Entry ${entryId} not found — skipping cost correction.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let trimmed = 0;
|
|
68
|
+
for (const entry of Object.values(usage)) {
|
|
69
|
+
if (Array.isArray(entry?.sessions) && entry.sessions.length > SESSION_CAP) {
|
|
70
|
+
trimmed += entry.sessions.length - SESSION_CAP;
|
|
71
|
+
entry.sessions.splice(0, entry.sessions.length - SESSION_CAP);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
console.log(`Trimmed ${trimmed} session records across all entries (cap ${SESSION_CAP}).`);
|
|
75
|
+
|
|
76
|
+
writeFileSync(tokensPath, JSON.stringify(data, null, 2));
|
|
77
|
+
const sizeMb = (Buffer.byteLength(JSON.stringify(data)) / 1024 / 1024).toFixed(1);
|
|
78
|
+
console.log(`Repaired ledger written: ${tokensPath} (${sizeMb}MB)`);
|
|
@@ -1438,7 +1438,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
1438
1438
|
'port', 'journalistInterval', 'rotationThreshold', 'autoRotation',
|
|
1439
1439
|
'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
|
|
1440
1440
|
'onboardingDismissed', 'defaultModel', 'defaultChatProvider', 'defaultChatModel',
|
|
1441
|
-
'dataSharingDismissed',
|
|
1441
|
+
'dataSharingDismissed', 'replayCeiling', 'velocityCeiling', 'journalistModelTier',
|
|
1442
1442
|
];
|
|
1443
1443
|
for (const key of Object.keys(req.body)) {
|
|
1444
1444
|
if (!ALLOWED_KEYS.includes(key)) {
|
|
@@ -836,6 +836,7 @@ export class Daemon {
|
|
|
836
836
|
// Persist state before shutdown
|
|
837
837
|
this.state.set('agents', this.registry.getAll());
|
|
838
838
|
await this.state.save();
|
|
839
|
+
try { this.tokens.flush(); } catch { /* best-effort */ }
|
|
839
840
|
|
|
840
841
|
// Stop background services
|
|
841
842
|
await this.gateways.stop();
|
|
@@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs
|
|
|
5
5
|
import { resolve } from 'path';
|
|
6
6
|
import { execFile, spawn as cpSpawn } from 'child_process';
|
|
7
7
|
import { getProvider, getInstalledProviders, resolveProviderCommand } from './providers/index.js';
|
|
8
|
+
import { agentLogPath } from './process.js';
|
|
8
9
|
|
|
9
10
|
const DEFAULT_INTERVAL = 300_000; // 5 minutes (safety-net fallback; event-driven triggers handle the normal case)
|
|
10
11
|
const MAX_LOG_CHARS = 100_000; // ~25k tokens budget for synthesis input (captures 80-90% of recent activity)
|
|
@@ -168,7 +169,7 @@ export class Journalist {
|
|
|
168
169
|
|
|
169
170
|
hasNewActivity(agents) {
|
|
170
171
|
for (const agent of agents) {
|
|
171
|
-
const logPath =
|
|
172
|
+
const logPath = agentLogPath(this.daemon.grooveDir, agent);
|
|
172
173
|
if (!existsSync(logPath)) continue;
|
|
173
174
|
try {
|
|
174
175
|
const size = statSync(logPath).size;
|
|
@@ -182,7 +183,7 @@ export class Journalist {
|
|
|
182
183
|
const result = {};
|
|
183
184
|
|
|
184
185
|
for (const agent of agents) {
|
|
185
|
-
const logPath =
|
|
186
|
+
const logPath = agentLogPath(this.daemon.grooveDir, agent);
|
|
186
187
|
if (!existsSync(logPath)) {
|
|
187
188
|
result[agent.id] = { agent, entries: [], explorationEntries: [] };
|
|
188
189
|
continue;
|
|
@@ -574,9 +575,15 @@ export class Journalist {
|
|
|
574
575
|
const provider = getProvider(providerId);
|
|
575
576
|
if (!provider) continue;
|
|
576
577
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
578
|
+
// Digests are light-tier work (Haiku-shaped): default internal overhead to
|
|
579
|
+
// the cheapest tier, escalate via config only on evidence of quality issues.
|
|
580
|
+
// On Claude, medium→light cuts the journalist's marginal cost ~90%.
|
|
581
|
+
const models = provider.constructor.models || [];
|
|
582
|
+
const preferredTier = this.daemon.config?.journalistModelTier || 'light';
|
|
583
|
+
const selectedModel = models.find((m) => m.tier === preferredTier)
|
|
584
|
+
|| models.find((m) => m.tier === 'light')
|
|
585
|
+
|| models.find((m) => m.tier === 'medium')
|
|
586
|
+
|| models[0];
|
|
580
587
|
const modelId = selectedModel?.id || null;
|
|
581
588
|
|
|
582
589
|
// Try CLI headless command first
|
|
@@ -1055,7 +1062,7 @@ export class Journalist {
|
|
|
1055
1062
|
* Budget: keeps recent turns verbatim, summarizes oldest if over maxChars.
|
|
1056
1063
|
*/
|
|
1057
1064
|
extractConversationThread(agent, { maxChars = 60000 } = {}) {
|
|
1058
|
-
const logPath =
|
|
1065
|
+
const logPath = agentLogPath(this.daemon.grooveDir, agent);
|
|
1059
1066
|
if (!existsSync(logPath)) return null;
|
|
1060
1067
|
|
|
1061
1068
|
let content;
|
|
@@ -1297,7 +1304,7 @@ export class Journalist {
|
|
|
1297
1304
|
* Used by the Introducer to tell new agents what their teammates built.
|
|
1298
1305
|
*/
|
|
1299
1306
|
getAgentFiles(agent) {
|
|
1300
|
-
const logPath =
|
|
1307
|
+
const logPath = agentLogPath(this.daemon.grooveDir, agent);
|
|
1301
1308
|
if (!existsSync(logPath)) return [];
|
|
1302
1309
|
|
|
1303
1310
|
try {
|
|
@@ -1335,7 +1342,7 @@ export class Journalist {
|
|
|
1335
1342
|
* Used to capture planner conclusions, build summaries, etc.
|
|
1336
1343
|
*/
|
|
1337
1344
|
getAgentResult(agent) {
|
|
1338
|
-
const logPath =
|
|
1345
|
+
const logPath = agentLogPath(this.daemon.grooveDir, agent);
|
|
1339
1346
|
if (!existsSync(logPath)) return '';
|
|
1340
1347
|
|
|
1341
1348
|
try {
|