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.
- package/CHANGELOG.md +59 -0
- package/CLAUDE.md +24 -19
- package/node_modules/@groove-dev/cli/bin/groove.js +2 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/cli/src/commands/nuke.js +16 -4
- package/node_modules/@groove-dev/cli/src/commands/stop.js +17 -2
- package/node_modules/@groove-dev/daemon/integrations-registry.json +681 -75
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/adaptive.js +23 -25
- package/node_modules/@groove-dev/daemon/src/api.js +346 -22
- package/node_modules/@groove-dev/daemon/src/classifier.js +53 -6
- package/node_modules/@groove-dev/daemon/src/firstrun.js +14 -1
- package/node_modules/@groove-dev/daemon/src/gateways/manager.js +2 -2
- package/node_modules/@groove-dev/daemon/src/index.js +28 -4
- package/node_modules/@groove-dev/daemon/src/integrations.js +215 -14
- package/node_modules/@groove-dev/daemon/src/introducer.js +84 -11
- package/node_modules/@groove-dev/daemon/src/journalist.js +43 -1
- package/node_modules/@groove-dev/daemon/src/lockmanager.js +60 -0
- package/node_modules/@groove-dev/daemon/src/mcp-manager.js +270 -0
- package/node_modules/@groove-dev/daemon/src/memory.js +370 -0
- package/node_modules/@groove-dev/daemon/src/pm.js +1 -1
- package/node_modules/@groove-dev/daemon/src/process.js +141 -9
- package/node_modules/@groove-dev/daemon/src/registry.js +1 -1
- package/node_modules/@groove-dev/daemon/src/rotator.js +334 -31
- package/node_modules/@groove-dev/daemon/src/router.js +43 -0
- package/node_modules/@groove-dev/daemon/src/tokentracker.js +70 -18
- package/node_modules/@groove-dev/daemon/src/validate.js +5 -13
- package/node_modules/@groove-dev/daemon/templates/groove-slides.cjs +306 -0
- package/node_modules/@groove-dev/daemon/test/classifier.test.js +3 -5
- package/node_modules/@groove-dev/daemon/test/lockmanager.test.js +64 -0
- package/node_modules/@groove-dev/daemon/test/memory.test.js +252 -0
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +108 -0
- package/node_modules/@groove-dev/daemon/test/router.test.js +64 -0
- package/node_modules/@groove-dev/daemon/test/slides-engine.test.js +230 -0
- package/node_modules/@groove-dev/daemon/test/tokentracker.test.js +78 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-DjORRpF0.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-eCrVowF0.js +652 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -4
- package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +26 -17
- package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +22 -1
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +53 -21
- package/node_modules/@groove-dev/gui/src/components/agents/agent-node.jsx +132 -90
- package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +212 -1
- package/node_modules/@groove-dev/gui/src/components/dashboard/cache-ring.jsx +6 -2
- package/node_modules/@groove-dev/gui/src/components/dashboard/intel-panel.jsx +495 -174
- package/node_modules/@groove-dev/gui/src/components/dashboard/kpi-card.jsx +12 -2
- package/node_modules/@groove-dev/gui/src/components/dashboard/team-burn-panel.jsx +55 -0
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/layout/app-shell.jsx +24 -19
- package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/components/marketplace/integration-wizard.jsx +391 -61
- package/node_modules/@groove-dev/gui/src/components/marketplace/marketplace-card.jsx +29 -7
- package/node_modules/@groove-dev/gui/src/lib/format.js +0 -6
- package/node_modules/@groove-dev/gui/src/lib/hooks/use-dashboard.js +23 -5
- package/node_modules/@groove-dev/gui/src/stores/groove.js +59 -9
- package/node_modules/@groove-dev/gui/src/views/agents.jsx +84 -10
- package/node_modules/@groove-dev/gui/src/views/dashboard.jsx +24 -21
- package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +153 -85
- package/package.json +2 -8
- package/packages/cli/bin/groove.js +2 -0
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/commands/nuke.js +16 -4
- package/packages/cli/src/commands/stop.js +17 -2
- package/packages/daemon/integrations-registry.json +681 -75
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/adaptive.js +23 -25
- package/packages/daemon/src/api.js +346 -22
- package/packages/daemon/src/classifier.js +53 -6
- package/packages/daemon/src/firstrun.js +14 -1
- package/packages/daemon/src/gateways/manager.js +2 -2
- package/packages/daemon/src/index.js +28 -4
- package/packages/daemon/src/integrations.js +215 -14
- package/packages/daemon/src/introducer.js +84 -11
- package/packages/daemon/src/journalist.js +43 -1
- package/packages/daemon/src/lockmanager.js +60 -0
- package/packages/daemon/src/mcp-manager.js +270 -0
- package/packages/daemon/src/memory.js +370 -0
- package/packages/daemon/src/pm.js +1 -1
- package/packages/daemon/src/process.js +141 -9
- package/packages/daemon/src/registry.js +1 -1
- package/packages/daemon/src/rotator.js +334 -31
- package/packages/daemon/src/router.js +43 -0
- package/packages/daemon/src/tokentracker.js +70 -18
- package/packages/daemon/src/validate.js +5 -13
- package/packages/daemon/templates/groove-slides.cjs +306 -0
- package/packages/gui/dist/assets/index-DjORRpF0.css +1 -0
- package/packages/gui/dist/assets/index-eCrVowF0.js +652 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -4
- package/packages/gui/src/components/agents/agent-chat.jsx +26 -17
- package/packages/gui/src/components/agents/agent-config.jsx +22 -1
- package/packages/gui/src/components/agents/agent-feed.jsx +53 -21
- package/packages/gui/src/components/agents/agent-node.jsx +132 -90
- package/packages/gui/src/components/agents/spawn-wizard.jsx +212 -1
- package/packages/gui/src/components/dashboard/cache-ring.jsx +6 -2
- package/packages/gui/src/components/dashboard/intel-panel.jsx +495 -174
- package/packages/gui/src/components/dashboard/kpi-card.jsx +12 -2
- package/packages/gui/src/components/dashboard/team-burn-panel.jsx +55 -0
- package/packages/gui/src/components/layout/activity-bar.jsx +3 -3
- package/packages/gui/src/components/layout/app-shell.jsx +24 -19
- package/packages/gui/src/components/layout/command-palette.jsx +2 -2
- package/packages/gui/src/components/marketplace/integration-wizard.jsx +391 -61
- package/packages/gui/src/components/marketplace/marketplace-card.jsx +29 -7
- package/packages/gui/src/lib/format.js +0 -6
- package/packages/gui/src/lib/hooks/use-dashboard.js +23 -5
- package/packages/gui/src/stores/groove.js +59 -9
- package/packages/gui/src/views/agents.jsx +84 -10
- package/packages/gui/src/views/dashboard.jsx +24 -21
- package/packages/gui/src/views/marketplace.jsx +153 -85
- package/node_modules/@groove-dev/gui/dist/assets/index-CEFKgLGB.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-CaKBNWcK.js +0 -638
- package/node_modules/@groove-dev/gui/dist/groove-logo-short.png +0 -0
- package/node_modules/@groove-dev/gui/dist/groove-logo.png +0 -0
- package/node_modules/@groove-dev/gui/public/groove-logo-short.png +0 -0
- package/node_modules/@groove-dev/gui/public/groove-logo.png +0 -0
- package/node_modules/@groove-dev/gui/src/components/ui/dropdown-menu.jsx +0 -60
- package/node_modules/@groove-dev/gui/src/lib/hooks/use-media-query.js +0 -18
- package/node_modules/@radix-ui/react-dropdown-menu/LICENSE +0 -21
- package/node_modules/@radix-ui/react-dropdown-menu/README.md +0 -3
- package/node_modules/@radix-ui/react-dropdown-menu/dist/index.d.mts +0 -97
- package/node_modules/@radix-ui/react-dropdown-menu/dist/index.d.ts +0 -97
- package/node_modules/@radix-ui/react-dropdown-menu/dist/index.js +0 -337
- package/node_modules/@radix-ui/react-dropdown-menu/dist/index.js.map +0 -7
- package/node_modules/@radix-ui/react-dropdown-menu/dist/index.mjs +0 -305
- package/node_modules/@radix-ui/react-dropdown-menu/dist/index.mjs.map +0 -7
- package/node_modules/@radix-ui/react-dropdown-menu/package.json +0 -75
- package/node_modules/@radix-ui/react-popover/LICENSE +0 -21
- package/node_modules/@radix-ui/react-popover/README.md +0 -3
- package/node_modules/@radix-ui/react-popover/dist/index.d.mts +0 -85
- package/node_modules/@radix-ui/react-popover/dist/index.d.ts +0 -85
- package/node_modules/@radix-ui/react-popover/dist/index.js +0 -352
- package/node_modules/@radix-ui/react-popover/dist/index.js.map +0 -7
- package/node_modules/@radix-ui/react-popover/dist/index.mjs +0 -320
- package/node_modules/@radix-ui/react-popover/dist/index.mjs.map +0 -7
- package/node_modules/@radix-ui/react-popover/package.json +0 -82
- package/node_modules/@radix-ui/react-separator/LICENSE +0 -21
- package/node_modules/@radix-ui/react-separator/README.md +0 -3
- package/node_modules/@radix-ui/react-separator/dist/index.d.mts +0 -21
- package/node_modules/@radix-ui/react-separator/dist/index.d.ts +0 -21
- package/node_modules/@radix-ui/react-separator/dist/index.js +0 -65
- package/node_modules/@radix-ui/react-separator/dist/index.js.map +0 -7
- package/node_modules/@radix-ui/react-separator/dist/index.mjs +0 -32
- package/node_modules/@radix-ui/react-separator/dist/index.mjs.map +0 -7
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/LICENSE +0 -21
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/README.md +0 -3
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.d.mts +0 -52
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.d.ts +0 -52
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.js +0 -80
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.js.map +0 -7
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.mjs +0 -47
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.mjs.map +0 -7
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/package.json +0 -69
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/LICENSE +0 -21
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/README.md +0 -3
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.d.mts +0 -22
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.d.ts +0 -22
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.js +0 -152
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.js.map +0 -7
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.mjs +0 -119
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.mjs.map +0 -7
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/package.json +0 -64
- package/node_modules/@radix-ui/react-separator/package.json +0 -69
- package/packages/gui/dist/assets/index-CEFKgLGB.css +0 -1
- package/packages/gui/dist/assets/index-CaKBNWcK.js +0 -638
- package/packages/gui/dist/groove-logo-short.png +0 -0
- package/packages/gui/dist/groove-logo.png +0 -0
- package/packages/gui/public/groove-logo-short.png +0 -0
- package/packages/gui/public/groove-logo.png +0 -0
- package/packages/gui/src/components/ui/dropdown-menu.jsx +0 -60
- 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;
|
|
7
|
-
const CHECK_INTERVAL = 15_000;
|
|
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 = [];
|
|
15
|
-
this.rotating = new Set();
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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,
|
|
122
|
-
teamId: agent.teamId,
|
|
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
|
|
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
|
|
190
|
-
return
|
|
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
|
-
//
|
|
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
|
|
263
|
+
const coordinationSavings = this.rotationSavings + coldStartWaste + conflictWaste;
|
|
220
264
|
|
|
221
|
-
const estimatedWithout = totalTokens +
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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:
|
|
289
|
+
total: coordinationSavings,
|
|
243
290
|
fromRotation: this.rotationSavings,
|
|
244
291
|
fromConflictPrevention: conflictWaste,
|
|
245
292
|
fromColdStartSkip: coldStartWaste,
|
|
246
|
-
percentage:
|
|
293
|
+
percentage: coordPct,
|
|
247
294
|
estimatedWithoutGroove: estimatedWithout,
|
|
248
295
|
},
|
|
249
|
-
|
|
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,
|