groove-dev 0.26.38 → 0.27.0

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 (171) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/CLAUDE.md +24 -19
  3. package/node_modules/@groove-dev/cli/bin/groove.js +2 -0
  4. package/node_modules/@groove-dev/cli/package.json +1 -1
  5. package/node_modules/@groove-dev/cli/src/commands/nuke.js +16 -4
  6. package/node_modules/@groove-dev/cli/src/commands/stop.js +17 -2
  7. package/node_modules/@groove-dev/daemon/integrations-registry.json +681 -75
  8. package/node_modules/@groove-dev/daemon/package.json +1 -1
  9. package/node_modules/@groove-dev/daemon/src/adaptive.js +23 -25
  10. package/node_modules/@groove-dev/daemon/src/api.js +346 -22
  11. package/node_modules/@groove-dev/daemon/src/classifier.js +53 -6
  12. package/node_modules/@groove-dev/daemon/src/firstrun.js +14 -1
  13. package/node_modules/@groove-dev/daemon/src/gateways/manager.js +2 -2
  14. package/node_modules/@groove-dev/daemon/src/index.js +28 -4
  15. package/node_modules/@groove-dev/daemon/src/integrations.js +215 -14
  16. package/node_modules/@groove-dev/daemon/src/introducer.js +84 -11
  17. package/node_modules/@groove-dev/daemon/src/journalist.js +43 -1
  18. package/node_modules/@groove-dev/daemon/src/lockmanager.js +60 -0
  19. package/node_modules/@groove-dev/daemon/src/mcp-manager.js +270 -0
  20. package/node_modules/@groove-dev/daemon/src/memory.js +370 -0
  21. package/node_modules/@groove-dev/daemon/src/pm.js +1 -1
  22. package/node_modules/@groove-dev/daemon/src/process.js +141 -9
  23. package/node_modules/@groove-dev/daemon/src/registry.js +1 -1
  24. package/node_modules/@groove-dev/daemon/src/rotator.js +334 -31
  25. package/node_modules/@groove-dev/daemon/src/router.js +43 -0
  26. package/node_modules/@groove-dev/daemon/src/tokentracker.js +70 -18
  27. package/node_modules/@groove-dev/daemon/src/validate.js +5 -13
  28. package/node_modules/@groove-dev/daemon/templates/groove-slides.cjs +306 -0
  29. package/node_modules/@groove-dev/daemon/test/classifier.test.js +3 -5
  30. package/node_modules/@groove-dev/daemon/test/lockmanager.test.js +64 -0
  31. package/node_modules/@groove-dev/daemon/test/memory.test.js +252 -0
  32. package/node_modules/@groove-dev/daemon/test/rotator.test.js +108 -0
  33. package/node_modules/@groove-dev/daemon/test/router.test.js +64 -0
  34. package/node_modules/@groove-dev/daemon/test/slides-engine.test.js +230 -0
  35. package/node_modules/@groove-dev/daemon/test/tokentracker.test.js +78 -0
  36. package/node_modules/@groove-dev/gui/dist/assets/index-DjORRpF0.css +1 -0
  37. package/node_modules/@groove-dev/gui/dist/assets/index-eCrVowF0.js +652 -0
  38. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  39. package/node_modules/@groove-dev/gui/package.json +1 -4
  40. package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +26 -17
  41. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +22 -1
  42. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +53 -21
  43. package/node_modules/@groove-dev/gui/src/components/agents/agent-node.jsx +132 -90
  44. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +212 -1
  45. package/node_modules/@groove-dev/gui/src/components/dashboard/cache-ring.jsx +6 -2
  46. package/node_modules/@groove-dev/gui/src/components/dashboard/intel-panel.jsx +495 -174
  47. package/node_modules/@groove-dev/gui/src/components/dashboard/kpi-card.jsx +12 -2
  48. package/node_modules/@groove-dev/gui/src/components/dashboard/team-burn-panel.jsx +55 -0
  49. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +3 -3
  50. package/node_modules/@groove-dev/gui/src/components/layout/app-shell.jsx +24 -19
  51. package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +2 -2
  52. package/node_modules/@groove-dev/gui/src/components/marketplace/integration-wizard.jsx +391 -61
  53. package/node_modules/@groove-dev/gui/src/components/marketplace/marketplace-card.jsx +29 -7
  54. package/node_modules/@groove-dev/gui/src/lib/format.js +0 -6
  55. package/node_modules/@groove-dev/gui/src/lib/hooks/use-dashboard.js +23 -5
  56. package/node_modules/@groove-dev/gui/src/stores/groove.js +59 -9
  57. package/node_modules/@groove-dev/gui/src/views/agents.jsx +84 -10
  58. package/node_modules/@groove-dev/gui/src/views/dashboard.jsx +24 -21
  59. package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +153 -85
  60. package/package.json +2 -8
  61. package/packages/cli/bin/groove.js +2 -0
  62. package/packages/cli/package.json +1 -1
  63. package/packages/cli/src/commands/nuke.js +16 -4
  64. package/packages/cli/src/commands/stop.js +17 -2
  65. package/packages/daemon/integrations-registry.json +681 -75
  66. package/packages/daemon/package.json +1 -1
  67. package/packages/daemon/src/adaptive.js +23 -25
  68. package/packages/daemon/src/api.js +346 -22
  69. package/packages/daemon/src/classifier.js +53 -6
  70. package/packages/daemon/src/firstrun.js +14 -1
  71. package/packages/daemon/src/gateways/manager.js +2 -2
  72. package/packages/daemon/src/index.js +28 -4
  73. package/packages/daemon/src/integrations.js +215 -14
  74. package/packages/daemon/src/introducer.js +84 -11
  75. package/packages/daemon/src/journalist.js +43 -1
  76. package/packages/daemon/src/lockmanager.js +60 -0
  77. package/packages/daemon/src/mcp-manager.js +270 -0
  78. package/packages/daemon/src/memory.js +370 -0
  79. package/packages/daemon/src/pm.js +1 -1
  80. package/packages/daemon/src/process.js +141 -9
  81. package/packages/daemon/src/registry.js +1 -1
  82. package/packages/daemon/src/rotator.js +334 -31
  83. package/packages/daemon/src/router.js +43 -0
  84. package/packages/daemon/src/tokentracker.js +70 -18
  85. package/packages/daemon/src/validate.js +5 -13
  86. package/packages/daemon/templates/groove-slides.cjs +306 -0
  87. package/packages/gui/dist/assets/index-DjORRpF0.css +1 -0
  88. package/packages/gui/dist/assets/index-eCrVowF0.js +652 -0
  89. package/packages/gui/dist/index.html +2 -2
  90. package/packages/gui/package.json +1 -4
  91. package/packages/gui/src/components/agents/agent-chat.jsx +26 -17
  92. package/packages/gui/src/components/agents/agent-config.jsx +22 -1
  93. package/packages/gui/src/components/agents/agent-feed.jsx +53 -21
  94. package/packages/gui/src/components/agents/agent-node.jsx +132 -90
  95. package/packages/gui/src/components/agents/spawn-wizard.jsx +212 -1
  96. package/packages/gui/src/components/dashboard/cache-ring.jsx +6 -2
  97. package/packages/gui/src/components/dashboard/intel-panel.jsx +495 -174
  98. package/packages/gui/src/components/dashboard/kpi-card.jsx +12 -2
  99. package/packages/gui/src/components/dashboard/team-burn-panel.jsx +55 -0
  100. package/packages/gui/src/components/layout/activity-bar.jsx +3 -3
  101. package/packages/gui/src/components/layout/app-shell.jsx +24 -19
  102. package/packages/gui/src/components/layout/command-palette.jsx +2 -2
  103. package/packages/gui/src/components/marketplace/integration-wizard.jsx +391 -61
  104. package/packages/gui/src/components/marketplace/marketplace-card.jsx +29 -7
  105. package/packages/gui/src/lib/format.js +0 -6
  106. package/packages/gui/src/lib/hooks/use-dashboard.js +23 -5
  107. package/packages/gui/src/stores/groove.js +59 -9
  108. package/packages/gui/src/views/agents.jsx +84 -10
  109. package/packages/gui/src/views/dashboard.jsx +24 -21
  110. package/packages/gui/src/views/marketplace.jsx +153 -85
  111. package/node_modules/@groove-dev/gui/dist/assets/index-CEFKgLGB.css +0 -1
  112. package/node_modules/@groove-dev/gui/dist/assets/index-CaKBNWcK.js +0 -638
  113. package/node_modules/@groove-dev/gui/dist/groove-logo-short.png +0 -0
  114. package/node_modules/@groove-dev/gui/dist/groove-logo.png +0 -0
  115. package/node_modules/@groove-dev/gui/public/groove-logo-short.png +0 -0
  116. package/node_modules/@groove-dev/gui/public/groove-logo.png +0 -0
  117. package/node_modules/@groove-dev/gui/src/components/ui/dropdown-menu.jsx +0 -60
  118. package/node_modules/@groove-dev/gui/src/lib/hooks/use-media-query.js +0 -18
  119. package/node_modules/@radix-ui/react-dropdown-menu/LICENSE +0 -21
  120. package/node_modules/@radix-ui/react-dropdown-menu/README.md +0 -3
  121. package/node_modules/@radix-ui/react-dropdown-menu/dist/index.d.mts +0 -97
  122. package/node_modules/@radix-ui/react-dropdown-menu/dist/index.d.ts +0 -97
  123. package/node_modules/@radix-ui/react-dropdown-menu/dist/index.js +0 -337
  124. package/node_modules/@radix-ui/react-dropdown-menu/dist/index.js.map +0 -7
  125. package/node_modules/@radix-ui/react-dropdown-menu/dist/index.mjs +0 -305
  126. package/node_modules/@radix-ui/react-dropdown-menu/dist/index.mjs.map +0 -7
  127. package/node_modules/@radix-ui/react-dropdown-menu/package.json +0 -75
  128. package/node_modules/@radix-ui/react-popover/LICENSE +0 -21
  129. package/node_modules/@radix-ui/react-popover/README.md +0 -3
  130. package/node_modules/@radix-ui/react-popover/dist/index.d.mts +0 -85
  131. package/node_modules/@radix-ui/react-popover/dist/index.d.ts +0 -85
  132. package/node_modules/@radix-ui/react-popover/dist/index.js +0 -352
  133. package/node_modules/@radix-ui/react-popover/dist/index.js.map +0 -7
  134. package/node_modules/@radix-ui/react-popover/dist/index.mjs +0 -320
  135. package/node_modules/@radix-ui/react-popover/dist/index.mjs.map +0 -7
  136. package/node_modules/@radix-ui/react-popover/package.json +0 -82
  137. package/node_modules/@radix-ui/react-separator/LICENSE +0 -21
  138. package/node_modules/@radix-ui/react-separator/README.md +0 -3
  139. package/node_modules/@radix-ui/react-separator/dist/index.d.mts +0 -21
  140. package/node_modules/@radix-ui/react-separator/dist/index.d.ts +0 -21
  141. package/node_modules/@radix-ui/react-separator/dist/index.js +0 -65
  142. package/node_modules/@radix-ui/react-separator/dist/index.js.map +0 -7
  143. package/node_modules/@radix-ui/react-separator/dist/index.mjs +0 -32
  144. package/node_modules/@radix-ui/react-separator/dist/index.mjs.map +0 -7
  145. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/LICENSE +0 -21
  146. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/README.md +0 -3
  147. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.d.mts +0 -52
  148. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.d.ts +0 -52
  149. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.js +0 -80
  150. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.js.map +0 -7
  151. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.mjs +0 -47
  152. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.mjs.map +0 -7
  153. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/package.json +0 -69
  154. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/LICENSE +0 -21
  155. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/README.md +0 -3
  156. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.d.mts +0 -22
  157. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.d.ts +0 -22
  158. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.js +0 -152
  159. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.js.map +0 -7
  160. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.mjs +0 -119
  161. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.mjs.map +0 -7
  162. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/package.json +0 -64
  163. package/node_modules/@radix-ui/react-separator/package.json +0 -69
  164. package/packages/gui/dist/assets/index-CEFKgLGB.css +0 -1
  165. package/packages/gui/dist/assets/index-CaKBNWcK.js +0 -638
  166. package/packages/gui/dist/groove-logo-short.png +0 -0
  167. package/packages/gui/dist/groove-logo.png +0 -0
  168. package/packages/gui/public/groove-logo-short.png +0 -0
  169. package/packages/gui/public/groove-logo.png +0 -0
  170. package/packages/gui/src/components/ui/dropdown-menu.jsx +0 -60
  171. package/packages/gui/src/lib/hooks/use-media-query.js +0 -18
@@ -2,18 +2,79 @@
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
4
  import { EventEmitter } from 'events';
5
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
6
+ import { resolve } from 'path';
5
7
 
6
- const DEFAULT_THRESHOLD = 0.75; // 75% context usage triggers rotation
7
- const CHECK_INTERVAL = 15_000; // Check every 15 seconds
8
+ const DEFAULT_THRESHOLD = 0.75;
9
+ const CHECK_INTERVAL = 15_000;
10
+ const QUALITY_THRESHOLD = 55; // Score below this triggers quality rotation (tuned up from 40 — too hair-trigger)
11
+ const MIN_EVENTS = 30; // Minimum classifier events before scoring (tuned up from 10 — ~100 turns for stable signal)
12
+ const MIN_AGE_SEC = 120; // Minimum agent age before quality rotation
13
+ const SCORE_HISTORY_MAX = 40; // ~10 min at 15s intervals
14
+ const ROTATION_COOLDOWN_MS = 5 * 60 * 1000; // 5 min between rotations per agent — prevents churn on persistent low quality
8
15
 
9
16
  export class Rotator extends EventEmitter {
10
17
  constructor(daemon) {
11
18
  super();
12
19
  this.daemon = daemon;
13
20
  this.interval = null;
14
- this.rotationHistory = []; // [{ agentId, agentName, oldTokens, timestamp, brief }]
15
- this.rotating = new Set(); // Agent IDs currently being rotated
21
+ this.rotationHistory = [];
22
+ this.rotating = new Set();
16
23
  this.enabled = false;
24
+ this.liveScores = {};
25
+ this.scoreHistory = {};
26
+ this.historyPath = daemon.grooveDir ? resolve(daemon.grooveDir, 'rotation-history.json') : null;
27
+ this._loadHistory();
28
+ }
29
+
30
+ _loadHistory() {
31
+ if (this.historyPath && existsSync(this.historyPath)) {
32
+ try {
33
+ const data = JSON.parse(readFileSync(this.historyPath, 'utf8'));
34
+ this.rotationHistory = Array.isArray(data) ? data : [];
35
+ } catch {
36
+ this.rotationHistory = [];
37
+ }
38
+ }
39
+ this._recoverFromTimeline();
40
+ }
41
+
42
+ _recoverFromTimeline() {
43
+ if (!this.daemon.timeline) return;
44
+ const events = this.daemon.timeline.getEvents(500);
45
+ const rotateEvents = events.filter((e) => e.type === 'rotate');
46
+ if (rotateEvents.length === 0) return;
47
+
48
+ const existingTimestamps = new Set(this.rotationHistory.map((r) => r.timestamp));
49
+ let added = 0;
50
+ for (const e of rotateEvents) {
51
+ const ts = new Date(e.t).toISOString();
52
+ if (existingTimestamps.has(ts)) continue;
53
+ this.rotationHistory.push({
54
+ agentId: e.oldAgentId || e.agentId,
55
+ agentName: e.agentName || 'unknown',
56
+ role: e.role || 'unknown',
57
+ provider: e.provider || 'unknown',
58
+ oldTokens: e.tokensBefore || 0,
59
+ contextUsage: 0,
60
+ reason: e.reason || 'context_threshold',
61
+ timestamp: ts,
62
+ newAgentId: e.agentId,
63
+ newTokens: 0,
64
+ });
65
+ added++;
66
+ }
67
+ if (added > 0) {
68
+ this.rotationHistory.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
69
+ this._saveHistory();
70
+ }
71
+ }
72
+
73
+ _saveHistory() {
74
+ if (!this.historyPath) return;
75
+ try {
76
+ writeFileSync(this.historyPath, JSON.stringify(this.rotationHistory, null, 2));
77
+ } catch { /* best-effort */ }
17
78
  }
18
79
 
19
80
  start() {
@@ -30,26 +91,164 @@ export class Rotator extends EventEmitter {
30
91
  this.enabled = false;
31
92
  }
32
93
 
94
+ _idleMs(agent) {
95
+ return agent.lastActivity
96
+ ? Date.now() - new Date(agent.lastActivity).getTime()
97
+ : Infinity;
98
+ }
99
+
100
+ // Check if this agent rotated recently. Prevents back-to-back rotation
101
+ // churn when quality score stays low post-rotation (e.g. genuinely hard task).
102
+ // Safety triggers bypass cooldown — pathological burn must be stopped.
103
+ _isInCooldown(agent) {
104
+ const last = [...this.rotationHistory]
105
+ .reverse()
106
+ .find((r) => r.newAgentId === agent.id || r.agentId === agent.id);
107
+ if (!last) return false;
108
+ const elapsed = Date.now() - new Date(last.timestamp).getTime();
109
+ return elapsed < ROTATION_COOLDOWN_MS;
110
+ }
111
+
112
+ scoreLiveSession(agent) {
113
+ const events = this.daemon.classifier.agentWindows[agent.id] || [];
114
+ const ageSec = (Date.now() - new Date(agent.spawnedAt).getTime()) / 1000;
115
+
116
+ if (events.length < MIN_EVENTS || ageSec < MIN_AGE_SEC) {
117
+ return { score: 70, signals: {}, hasEnoughData: false, ageSec: Math.round(ageSec), eventCount: events.length };
118
+ }
119
+
120
+ const signals = this.daemon.adaptive.extractSignals(events, agent.scope);
121
+ let score = this.daemon.adaptive.scoreSession(signals);
122
+
123
+ if (ageSec > 1800) score -= 5;
124
+ if (ageSec > 3600) score -= 10;
125
+
126
+ score = Math.max(0, Math.min(100, score));
127
+
128
+ const result = { score, signals, hasEnoughData: true, ageSec: Math.round(ageSec), eventCount: events.length };
129
+ this.liveScores[agent.id] = result;
130
+
131
+ if (!this.scoreHistory[agent.id]) this.scoreHistory[agent.id] = [];
132
+ const hist = this.scoreHistory[agent.id];
133
+ hist.push({ t: Date.now(), s: score });
134
+ if (hist.length > SCORE_HISTORY_MAX) hist.shift();
135
+
136
+ return result;
137
+ }
138
+
139
+ // Safety triggers — runaway agent detection. Scoped to `spawnedAt` so
140
+ // a rotation doesn't re-trigger on inherited cumulative tokens.
141
+ _checkSafetyTriggers(agent) {
142
+ const safety = this.daemon.config?.safety;
143
+ if (!safety || safety.autoRotate === false) return null;
144
+ if (!this.daemon.tokens || !agent.spawnedAt) return null;
145
+
146
+ const spawnedAtMs = new Date(agent.spawnedAt).getTime();
147
+ const ceiling = safety.tokenCeilingPerAgent;
148
+ if (ceiling > 0) {
149
+ const instanceTokens = this.daemon.tokens.getTokensInWindow(agent.id, spawnedAtMs);
150
+ if (instanceTokens >= ceiling) {
151
+ return {
152
+ reason: 'token_limit_exceeded',
153
+ instanceTokens,
154
+ ceiling,
155
+ };
156
+ }
157
+ }
158
+
159
+ const windowMs = (safety.velocityWindowSeconds || 300) * 1000;
160
+ const velocityThreshold = safety.velocityTokenThreshold;
161
+ if (velocityThreshold > 0) {
162
+ const velocity = this.daemon.tokens.getVelocity(agent.id, windowMs);
163
+ if (velocity >= velocityThreshold) {
164
+ return {
165
+ reason: 'runaway_velocity',
166
+ velocity,
167
+ windowMs,
168
+ threshold: velocityThreshold,
169
+ };
170
+ }
171
+ }
172
+
173
+ return null;
174
+ }
175
+
176
+ // Compute post-rotation velocity for rotations that are old enough to
177
+ // have meaningful data. Replaces hardcoded savings assumptions with
178
+ // measured deltas. Positive velocityDelta = rotation reduced burn rate.
179
+ _finalizeRotationMeasurements() {
180
+ if (!this.daemon.tokens?.getVelocity) return;
181
+ const now = Date.now();
182
+ let modified = false;
183
+ for (const record of this.rotationHistory) {
184
+ if (record.postRotationVelocity != null) continue;
185
+ if (record.preRotationVelocity == null) continue;
186
+ if (!record.newAgentId) continue;
187
+ const rotatedAt = new Date(record.timestamp).getTime();
188
+ if (now - rotatedAt < 600_000) continue; // need 10 min of post-data
189
+ const postVelocity = this.daemon.tokens.getVelocity(record.newAgentId, 600_000);
190
+ record.postRotationVelocity = postVelocity;
191
+ record.velocityDelta = record.preRotationVelocity - postVelocity;
192
+ modified = true;
193
+ }
194
+ if (modified) this._saveHistory();
195
+ }
196
+
33
197
  async check() {
198
+ this._finalizeRotationMeasurements();
199
+
34
200
  const agents = this.daemon.registry.getAll();
35
201
  const running = agents.filter((a) => a.status === 'running');
36
202
 
37
203
  for (const agent of running) {
38
204
  if (this.rotating.has(agent.id)) continue;
39
205
 
206
+ // Safety triggers — highest priority, pathological behavior.
207
+ // Bypasses cooldown: pathological burn must be stopped immediately.
208
+ const safety = this._checkSafetyTriggers(agent);
209
+ if (safety) {
210
+ const summary = safety.reason === 'token_limit_exceeded'
211
+ ? `${safety.instanceTokens} tokens >= ${safety.ceiling} ceiling`
212
+ : `${safety.velocity} tokens in ${safety.windowMs / 1000}s >= ${safety.threshold} threshold`;
213
+ console.log(` Rotator: ${agent.name} ${safety.reason} (${summary}) — auto-rotating`);
214
+ await this.rotate(agent.id, safety);
215
+ continue;
216
+ }
217
+
218
+ // Cooldown check — skip threshold-based rotations if agent just rotated.
219
+ // Gives the new instance time to stabilize before another judgment.
220
+ if (this._isInCooldown(agent)) continue;
221
+
40
222
  const threshold = this.daemon.adaptive
41
223
  ? this.daemon.adaptive.getThreshold(agent.provider, agent.role)
42
224
  : DEFAULT_THRESHOLD;
43
225
 
226
+ // Context-based rotation (original)
44
227
  if (agent.contextUsage >= threshold) {
45
- // Check for natural pause: if agent has been idle for >10s
46
- const idleMs = agent.lastActivity
47
- ? Date.now() - new Date(agent.lastActivity).getTime()
48
- : Infinity;
49
-
50
- if (idleMs > 10_000) {
51
- console.log(` Rotator: ${agent.name} at ${Math.round(agent.contextUsage * 100)}% — rotating`);
52
- await this.rotate(agent.id);
228
+ if (this._idleMs(agent) > 10_000) {
229
+ console.log(` Rotator: ${agent.name} at ${Math.round(agent.contextUsage * 100)}% — rotating (context)`);
230
+ await this.rotate(agent.id, { reason: 'context_threshold' });
231
+ continue;
232
+ }
233
+ }
234
+
235
+ // Quality-based rotation — detects degradation before tokens are wasted.
236
+ // Converged provider:role profiles have stable thresholds already, so
237
+ // skip quality rotation there unless score is catastrophically low.
238
+ const quality = this.scoreLiveSession(agent);
239
+ if (quality.hasEnoughData && quality.score < QUALITY_THRESHOLD) {
240
+ const profile = this.daemon.adaptive?.getProfile?.(agent.provider, agent.role);
241
+ const converged = profile?.converged;
242
+ // If converged, require a deeper score drop before rotating
243
+ const floor = converged ? QUALITY_THRESHOLD - 15 : QUALITY_THRESHOLD;
244
+ if (quality.score < floor && this._idleMs(agent) > 10_000) {
245
+ console.log(` Rotator: ${agent.name} quality=${quality.score}${converged ? ' (converged profile)' : ''} — rotating (quality)`);
246
+ await this.rotate(agent.id, {
247
+ reason: 'quality_degradation',
248
+ qualityScore: quality.score,
249
+ signals: quality.signals,
250
+ });
251
+ continue;
53
252
  }
54
253
  }
55
254
  }
@@ -67,28 +266,33 @@ export class Rotator extends EventEmitter {
67
266
  type: 'rotation:start',
68
267
  agentId,
69
268
  agentName: agent.name,
269
+ reason: options.reason || 'manual',
70
270
  });
71
271
 
72
272
  try {
73
- // 1. Record adaptive session so rotation thresholds learn over time
74
273
  const classifierEvents = this.daemon.classifier.agentWindows[agentId] || [];
75
274
  if (classifierEvents.length > 0) {
76
275
  const signals = this.daemon.adaptive.extractSignals(classifierEvents, agent.scope);
77
276
  this.daemon.adaptive.recordSession(agent.provider, agent.role, signals);
78
277
  }
79
278
 
80
- // Clear classifier window for the old agent
81
279
  this.daemon.classifier.clearAgent(agentId);
280
+ delete this.liveScores[agentId];
281
+ delete this.scoreHistory[agentId];
82
282
 
83
- // 2. Generate handoff brief from Journalist
84
283
  let brief = await journalist.generateHandoffBrief(agent);
85
284
 
86
- // Append additional prompt if provided (used by instruct/continue endpoints)
87
285
  if (options.additionalPrompt) {
88
286
  brief = brief + '\n\n## User Instruction\n\n' + options.additionalPrompt;
89
287
  }
90
288
 
91
- // 3. Record rotation history
289
+ // Capture pre-rotation velocity (tokens/10min) so we can later measure
290
+ // whether the rotation actually improved token efficiency. Stored in
291
+ // history; finalized by _finalizeRotationMeasurements() on later ticks.
292
+ const preRotationVelocity = this.daemon.tokens?.getVelocity
293
+ ? this.daemon.tokens.getVelocity(agent.id, 600_000)
294
+ : null;
295
+
92
296
  const record = {
93
297
  agentId: agent.id,
94
298
  agentName: agent.name,
@@ -96,17 +300,26 @@ export class Rotator extends EventEmitter {
96
300
  provider: agent.provider,
97
301
  oldTokens: agent.tokensUsed,
98
302
  contextUsage: agent.contextUsage,
303
+ reason: options.reason || 'manual',
304
+ qualityScore: options.qualityScore || null,
305
+ instanceTokens: options.instanceTokens || null,
306
+ velocity: options.velocity || null,
307
+ preRotationVelocity,
308
+ postRotationVelocity: null,
309
+ velocityDelta: null,
99
310
  timestamp: new Date().toISOString(),
100
311
  };
101
312
 
102
- // 4. Kill/clean up the old agent
103
- // processes.kill handles both alive and dead agents:
104
- // - alive: sends SIGTERM, waits for exit, removes from registry
105
- // - dead: just removes from registry and releases locks
313
+ // Capture per-session signals for specialization tracking before we clear
314
+ const sessionSignals = classifierEvents.length > 0
315
+ ? this.daemon.adaptive.extractSignals(classifierEvents, agent.scope)
316
+ : null;
317
+ const sessionScore = sessionSignals
318
+ ? this.daemon.adaptive.scoreSession(sessionSignals)
319
+ : null;
320
+
106
321
  await processes.kill(agentId);
107
322
 
108
- // 5. Respawn with handoff brief as the prompt
109
- // Preserve auto routing mode so the router re-evaluates on respawn
110
323
  const routingMode = this.daemon.router.getMode(agentId);
111
324
  const respawnModel = routingMode.mode === 'auto' ? 'auto' : agent.model;
112
325
 
@@ -118,35 +331,64 @@ export class Rotator extends EventEmitter {
118
331
  prompt: brief,
119
332
  permission: agent.permission || 'full',
120
333
  workingDir: agent.workingDir,
121
- name: agent.name, // Keep the same name for continuity
122
- teamId: agent.teamId, // Keep the same team
334
+ name: agent.name,
335
+ teamId: agent.teamId,
123
336
  });
124
337
 
125
- // Carry cumulative token stats so the dashboard shows lifetime totals
126
338
  if (agent.tokensUsed > 0) {
127
339
  registry.update(newAgent.id, { tokensUsed: agent.tokensUsed });
128
340
  }
129
341
 
130
- // Record rotation savings in token tracker
131
342
  this.daemon.tokens.recordRotation(agent.id, agent.tokensUsed);
132
- // Each rotation is a cold-start that the Journalist's handoff brief skips
133
343
  this.daemon.tokens.recordColdStartSkipped();
134
344
 
135
345
  record.newAgentId = newAgent.id;
136
346
  record.newTokens = 0;
137
347
  this.rotationHistory.push(record);
138
348
 
139
- // Keep last 100 rotations
140
349
  if (this.rotationHistory.length > 100) {
141
350
  this.rotationHistory = this.rotationHistory.slice(-100);
142
351
  }
352
+ this._saveHistory();
353
+
354
+ // Append to persistent handoff chain (Layer 7 memory)
355
+ // so agent #50 knows what agent #1 struggled with.
356
+ if (this.daemon.memory) {
357
+ this.daemon.memory.appendHandoffBrief(agent.role, {
358
+ agentId: agent.id,
359
+ newAgentId: newAgent.id,
360
+ reason: record.reason,
361
+ oldTokens: agent.tokensUsed,
362
+ contextUsage: agent.contextUsage,
363
+ brief,
364
+ timestamp: record.timestamp,
365
+ });
366
+
367
+ // Update per-agent + per-role specialization profile
368
+ const files = Array.from(new Set(
369
+ classifierEvents
370
+ .map((e) => e.input || e.file || e.path)
371
+ .filter((f) => typeof f === 'string' && f.length > 0)
372
+ .slice(-20)
373
+ ));
374
+ this.daemon.memory.updateSpecialization(agent.id, {
375
+ role: agent.role,
376
+ qualityScore: sessionScore,
377
+ filesTouched: files,
378
+ signals: sessionSignals,
379
+ threshold: this.daemon.adaptive?.getThreshold(agent.provider, agent.role),
380
+ });
381
+ }
143
382
 
144
- // Record rotation lifecycle event for timeline
145
383
  if (this.daemon.timeline) {
146
384
  this.daemon.timeline.recordEvent('rotate', {
147
385
  agentId: newAgent.id, oldAgentId: agentId,
148
386
  agentName: newAgent.name, role: agent.role,
149
387
  tokensBefore: agent.tokensUsed,
388
+ reason: record.reason,
389
+ qualityScore: record.qualityScore,
390
+ instanceTokens: record.instanceTokens,
391
+ velocity: record.velocity,
150
392
  });
151
393
  }
152
394
 
@@ -155,6 +397,7 @@ export class Rotator extends EventEmitter {
155
397
  agentId: newAgent.id,
156
398
  agentName: newAgent.name,
157
399
  oldAgentId: agentId,
400
+ reason: record.reason,
158
401
  tokensSaved: agent.tokensUsed,
159
402
  });
160
403
 
@@ -173,6 +416,50 @@ export class Rotator extends EventEmitter {
173
416
  }
174
417
  }
175
418
 
419
+ recordNaturalCompaction(agent, peakUsage, currentUsage) {
420
+ const record = {
421
+ agentId: agent.id,
422
+ agentName: agent.name,
423
+ role: agent.role,
424
+ provider: agent.provider,
425
+ oldTokens: agent.tokensUsed || 0,
426
+ contextUsage: peakUsage,
427
+ contextAfter: currentUsage,
428
+ reason: 'natural_compaction',
429
+ qualityScore: null,
430
+ timestamp: new Date().toISOString(),
431
+ newAgentId: agent.id,
432
+ newTokens: agent.tokensUsed || 0,
433
+ };
434
+
435
+ this.rotationHistory.push(record);
436
+ if (this.rotationHistory.length > 100) {
437
+ this.rotationHistory = this.rotationHistory.slice(-100);
438
+ }
439
+ this._saveHistory();
440
+
441
+ if (this.daemon.timeline) {
442
+ this.daemon.timeline.recordEvent('rotate', {
443
+ agentId: agent.id,
444
+ agentName: agent.name,
445
+ role: agent.role,
446
+ reason: 'natural_compaction',
447
+ contextBefore: peakUsage,
448
+ contextAfter: currentUsage,
449
+ });
450
+ }
451
+
452
+ this.daemon.broadcast({
453
+ type: 'rotation:natural',
454
+ agentId: agent.id,
455
+ agentName: agent.name,
456
+ peakUsage,
457
+ currentUsage,
458
+ });
459
+
460
+ console.log(` Rotator: ${agent.name} natural compaction detected (${Math.round(peakUsage * 100)}% → ${Math.round(currentUsage * 100)}%)`);
461
+ }
462
+
176
463
  isRotating(agentId) {
177
464
  return this.rotating.has(agentId);
178
465
  }
@@ -181,14 +468,30 @@ export class Rotator extends EventEmitter {
181
468
  return this.rotationHistory;
182
469
  }
183
470
 
471
+ getLiveScores() {
472
+ return this.liveScores;
473
+ }
474
+
184
475
  getStats() {
185
476
  const totalRotations = this.rotationHistory.length;
186
- const totalTokensSaved = this.rotationHistory.reduce((sum, r) => sum + r.oldTokens, 0);
477
+ const totalTokensSaved = this.rotationHistory.reduce((sum, r) => sum + (r.oldTokens || 0), 0);
478
+ const qualityRotations = this.rotationHistory.filter((r) => r.reason === 'quality_degradation').length;
479
+ const contextRotations = this.rotationHistory.filter((r) => r.reason === 'context_threshold').length;
480
+ const naturalCompactions = this.rotationHistory.filter((r) => r.reason === 'natural_compaction').length;
481
+ const tokenLimitRotations = this.rotationHistory.filter((r) => r.reason === 'token_limit_exceeded').length;
482
+ const velocityRotations = this.rotationHistory.filter((r) => r.reason === 'runaway_velocity').length;
187
483
  return {
188
484
  enabled: this.enabled,
189
485
  totalRotations,
190
486
  totalTokensSaved,
487
+ qualityRotations,
488
+ contextRotations,
489
+ naturalCompactions,
490
+ tokenLimitRotations,
491
+ velocityRotations,
191
492
  rotating: Array.from(this.rotating),
493
+ liveScores: this.liveScores,
494
+ scoreHistory: this.scoreHistory,
192
495
  };
193
496
  }
194
497
  }
@@ -142,4 +142,47 @@ export class ModelRouter {
142
142
  modes: MODES,
143
143
  };
144
144
  }
145
+
146
+ // Suggest a tier downshift when the classifier has enough confidence that
147
+ // a lighter model would still handle the task. Returns null when no
148
+ // suggestion is warranted. NEVER auto-applied — user must accept via UI.
149
+ // Honors the "heavy defaults" design principle: only suggests downshift,
150
+ // never upshift to heavier (user always has that available manually).
151
+ getSuggestion(agentId) {
152
+ const agent = this.daemon.registry.get(agentId);
153
+ if (!agent) return null;
154
+
155
+ const provider = getProvider(agent.provider);
156
+ if (!provider) return null;
157
+ const models = provider.constructor.models;
158
+
159
+ const classifier = this.daemon.classifier;
160
+ if (!classifier) return null;
161
+ const events = classifier.agentWindows[agentId] || [];
162
+ // Need enough events for a confident classification
163
+ if (events.length < 40) return null;
164
+
165
+ const currentModelId = agent.model || models[0]?.id;
166
+ const currentModel = models.find((m) => m.id === currentModelId || currentModelId?.includes(m.id));
167
+ if (!currentModel) return null;
168
+
169
+ const classifiedTier = classifier.classify(agentId);
170
+ const suggestedModel = models.find((m) => m.tier === classifiedTier);
171
+ if (!suggestedModel || suggestedModel.id === currentModel.id) return null;
172
+
173
+ // Tier order (heavy > medium > light). Only suggest if moving to cheaper tier.
174
+ const tierRank = { heavy: 3, medium: 2, light: 1 };
175
+ const currentRank = tierRank[currentModel.tier] || 2;
176
+ const suggestedRank = tierRank[classifiedTier] || 2;
177
+ if (suggestedRank >= currentRank) return null;
178
+
179
+ return {
180
+ agentId,
181
+ currentModel: { id: currentModel.id, name: currentModel.name, tier: currentModel.tier },
182
+ suggestedModel: { id: suggestedModel.id, name: suggestedModel.name, tier: suggestedModel.tier },
183
+ classifiedTier,
184
+ eventCount: events.length,
185
+ reason: `Last ${events.length} events classified as ${classifiedTier}. A lighter model would likely handle this task and reduce cost.`,
186
+ };
187
+ }
145
188
  }
@@ -116,12 +116,35 @@ export class TokenTracker {
116
116
  cacheReadTokens: detail.cacheReadTokens || 0,
117
117
  cacheCreationTokens: detail.cacheCreationTokens || 0,
118
118
  model: detail.model || null,
119
+ // Reserved for future per-project grouping (workspaces feature).
120
+ // Caller passes the project's absolute root path when known.
121
+ projectRoot: detail.projectRoot || null,
119
122
  timestamp: new Date().toISOString(),
120
123
  });
121
124
 
122
125
  this.save();
123
126
  }
124
127
 
128
+ // Sum tokens recorded for an agent since a given timestamp.
129
+ // Used by safety rotation triggers to measure per-instance burn
130
+ // (scoped to spawnedAt avoids counting pre-rotation history).
131
+ getTokensInWindow(agentId, sinceTs) {
132
+ const entry = this.usage[agentId];
133
+ if (!entry || !Array.isArray(entry.sessions)) return 0;
134
+ const cutoff = typeof sinceTs === 'number' ? sinceTs : new Date(sinceTs).getTime();
135
+ let total = 0;
136
+ for (const session of entry.sessions) {
137
+ const ts = new Date(session.timestamp).getTime();
138
+ if (ts >= cutoff) total += session.tokens || 0;
139
+ }
140
+ return total;
141
+ }
142
+
143
+ // Rolling velocity: tokens consumed in the last `windowMs` milliseconds.
144
+ getVelocity(agentId, windowMs) {
145
+ return this.getTokensInWindow(agentId, Date.now() - windowMs);
146
+ }
147
+
125
148
  // Record session-level result data (cost, duration, turns) — fires once per completion
126
149
  recordResult(agentId, { costUsd, durationMs, turns }) {
127
150
  this._initAgent(agentId);
@@ -179,29 +202,28 @@ export class TokenTracker {
179
202
  return Object.values(this.usage).reduce((sum, a) => sum + (a.totalCostUsd || 0), 0);
180
203
  }
181
204
 
205
+ // Cache hit rate = cache reads / all cacheable input (reads + creation).
206
+ // Fresh inputTokens are conversation turns that were never cache-eligible,
207
+ // so they must be excluded from the denominator.
182
208
  getCacheHitRate() {
183
- let totalRead = 0, totalCreation = 0, totalInput = 0;
209
+ let totalRead = 0, totalCreation = 0;
184
210
  for (const a of Object.values(this.usage)) {
185
211
  totalRead += a.cacheReadTokens || 0;
186
212
  totalCreation += a.cacheCreationTokens || 0;
187
- totalInput += a.inputTokens || 0;
188
213
  }
189
- const total = totalRead + totalCreation + totalInput;
190
- return total > 0 ? totalRead / total : 0;
214
+ const cacheable = totalRead + totalCreation;
215
+ return cacheable > 0 ? totalRead / cacheable : 0;
191
216
  }
192
217
 
193
218
  // Generate a savings summary
194
219
  getSummary() {
195
220
  const totalTokens = this.getTotal();
196
221
  const totalCostUsd = this.getTotalCost();
197
- const agentCount = Object.keys(this.usage).length;
198
222
  const sessionDuration = Date.now() - this.sessionStart;
199
223
 
200
- // Aggregate I/O and cache breakdown
201
224
  let totalInputTokens = 0, totalOutputTokens = 0;
202
225
  let totalCacheRead = 0, totalCacheCreation = 0;
203
226
  let totalDurationMs = 0, totalTurns = 0;
204
- let realCostUsd = 0, estimatedCostUsd = 0;
205
227
 
206
228
  for (const a of Object.values(this.usage)) {
207
229
  totalInputTokens += a.inputTokens || 0;
@@ -212,19 +234,44 @@ export class TokenTracker {
212
234
  totalTurns += a.totalTurns || 0;
213
235
  }
214
236
 
215
- // Estimate what uncoordinated usage would have cost
237
+ // Segregate internal overhead (reserved IDs prefixed __) from user agents.
238
+ // Internal tokens still count in totals (real billing) but show separately.
239
+ const userEntries = Object.entries(this.usage).filter(([id]) => !id.startsWith('__'));
240
+ const internalEntries = Object.entries(this.usage).filter(([id]) => id.startsWith('__'));
241
+ const agentCount = userEntries.length;
242
+
243
+ const internalComponents = {};
244
+ let internalTokens = 0;
245
+ let internalCostUsd = 0;
246
+ for (const [id, data] of internalEntries) {
247
+ internalComponents[id] = {
248
+ tokens: data.total,
249
+ costUsd: data.totalCostUsd || 0,
250
+ inputTokens: data.inputTokens || 0,
251
+ outputTokens: data.outputTokens || 0,
252
+ cacheReadTokens: data.cacheReadTokens || 0,
253
+ cacheCreationTokens: data.cacheCreationTokens || 0,
254
+ sessions: data.sessions.length,
255
+ };
256
+ internalTokens += data.total;
257
+ internalCostUsd += data.totalCostUsd || 0;
258
+ }
259
+
216
260
  const coldStartOverhead = this.getColdStartOverhead();
217
261
  const coldStartWaste = this.coldStartsSkipped * coldStartOverhead;
218
262
  const conflictWaste = this.conflictsPrevented * CONFLICT_OVERHEAD;
219
- const totalSavings = this.rotationSavings + coldStartWaste + conflictWaste;
263
+ const coordinationSavings = this.rotationSavings + coldStartWaste + conflictWaste;
220
264
 
221
- const estimatedWithout = totalTokens + totalSavings;
222
- const savingsPct = estimatedWithout > 0
223
- ? Math.round((totalSavings / estimatedWithout) * 100)
224
- : 0;
265
+ const estimatedWithout = totalTokens + coordinationSavings;
266
+ const rawPct = estimatedWithout > 0 ? (coordinationSavings / estimatedWithout) * 100 : 0;
267
+ const coordPct = rawPct > 0 && rawPct < 1
268
+ ? Math.round(rawPct * 10) / 10
269
+ : Math.round(rawPct);
225
270
 
226
- const cacheTotal = totalCacheRead + totalCacheCreation + totalInputTokens;
227
- const cacheHitRate = cacheTotal > 0 ? totalCacheRead / cacheTotal : 0;
271
+ // Cache hit rate: reads / (reads + creation). Excludes fresh inputTokens
272
+ // which are never cache-eligible.
273
+ const cacheable = totalCacheRead + totalCacheCreation;
274
+ const cacheHitRate = cacheable > 0 ? totalCacheRead / cacheable : 0;
228
275
 
229
276
  return {
230
277
  totalTokens,
@@ -239,14 +286,19 @@ export class TokenTracker {
239
286
  agentCount,
240
287
  sessionDurationMs: sessionDuration,
241
288
  savings: {
242
- total: totalSavings,
289
+ total: coordinationSavings,
243
290
  fromRotation: this.rotationSavings,
244
291
  fromConflictPrevention: conflictWaste,
245
292
  fromColdStartSkip: coldStartWaste,
246
- percentage: savingsPct,
293
+ percentage: coordPct,
247
294
  estimatedWithoutGroove: estimatedWithout,
248
295
  },
249
- perAgent: Object.entries(this.usage).map(([id, data]) => ({
296
+ internalOverhead: {
297
+ tokens: internalTokens,
298
+ costUsd: internalCostUsd,
299
+ components: internalComponents,
300
+ },
301
+ perAgent: userEntries.map(([id, data]) => ({
250
302
  agentId: id,
251
303
  tokens: data.total,
252
304
  costUsd: data.totalCostUsd || 0,