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.
Files changed (28) hide show
  1. package/CLAUDE.md +7 -0
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/scripts/repair-tokens.mjs +78 -0
  5. package/node_modules/@groove-dev/daemon/src/api.js +1 -1
  6. package/node_modules/@groove-dev/daemon/src/index.js +1 -0
  7. package/node_modules/@groove-dev/daemon/src/journalist.js +15 -8
  8. package/node_modules/@groove-dev/daemon/src/process.js +134 -21
  9. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +16 -1
  10. package/node_modules/@groove-dev/daemon/src/rotator.js +62 -5
  11. package/node_modules/@groove-dev/daemon/src/tokentracker.js +46 -6
  12. package/node_modules/@groove-dev/daemon/test/claude-code-provider.test.js +128 -0
  13. package/node_modules/@groove-dev/daemon/test/journalist.test.js +4 -2
  14. package/node_modules/@groove-dev/daemon/test/rotator.test.js +131 -0
  15. package/node_modules/@groove-dev/daemon/test/tokentracker.test.js +4 -0
  16. package/node_modules/@groove-dev/gui/package.json +1 -1
  17. package/package.json +1 -1
  18. package/packages/cli/package.json +1 -1
  19. package/packages/daemon/package.json +1 -1
  20. package/packages/daemon/scripts/repair-tokens.mjs +78 -0
  21. package/packages/daemon/src/api.js +1 -1
  22. package/packages/daemon/src/index.js +1 -0
  23. package/packages/daemon/src/journalist.js +15 -8
  24. package/packages/daemon/src/process.js +134 -21
  25. package/packages/daemon/src/providers/claude-code.js +16 -1
  26. package/packages/daemon/src/rotator.js +62 -5
  27. package/packages/daemon/src/tokentracker.js +46 -6
  28. 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.save();
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.save();
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.save();
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.save();
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.save();
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.save();
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.id ('a1')
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', 'a1.log'), logLines);
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);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.180",
3
+ "version": "0.27.182",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.180",
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)",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.180",
3
+ "version": "0.27.182",
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.180",
3
+ "version": "0.27.182",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -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 = resolve(this.daemon.grooveDir, 'logs', `${agent.id}.log`);
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 = resolve(this.daemon.grooveDir, 'logs', `${agent.id}.log`);
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
- const selectedModel = provider.constructor.models?.find((m) => m.tier === 'medium')
578
- || provider.constructor.models?.find((m) => m.tier === 'light')
579
- || provider.constructor.models?.[0];
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 = resolve(this.daemon.grooveDir, 'logs', `${agent.id}.log`);
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 = resolve(this.daemon.grooveDir, 'logs', `${agent.id}.log`);
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 = resolve(this.daemon.grooveDir, 'logs', `${agent.id}.log`);
1345
+ const logPath = agentLogPath(this.daemon.grooveDir, agent);
1339
1346
  if (!existsSync(logPath)) return '';
1340
1347
 
1341
1348
  try {