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
@@ -162,4 +162,112 @@ describe('Rotator', () => {
162
162
  assert.equal(stats.totalRotations, 2);
163
163
  assert.equal(stats.totalTokensSaved, 8000);
164
164
  });
165
+
166
+ describe('safety triggers', () => {
167
+ const SPAWNED = new Date(Date.now() - 60_000).toISOString(); // spawned 1 min ago
168
+
169
+ function mkAgent(overrides = {}) {
170
+ return {
171
+ id: 'a1', name: 'backend-1', role: 'backend',
172
+ provider: 'claude-code', scope: [], model: null,
173
+ tokensUsed: 0, contextUsage: 0.1, workingDir: '/tmp',
174
+ spawnedAt: SPAWNED, status: 'running',
175
+ ...overrides,
176
+ };
177
+ }
178
+
179
+ it('returns null when safety config is missing', () => {
180
+ mockDaemon.config = undefined;
181
+ const trigger = rotator._checkSafetyTriggers(mkAgent());
182
+ assert.equal(trigger, null);
183
+ });
184
+
185
+ it('returns null when autoRotate is disabled', () => {
186
+ mockDaemon.config = { safety: { autoRotate: false, tokenCeilingPerAgent: 100 } };
187
+ mockDaemon.tokens.getTokensInWindow = () => 1000;
188
+ const trigger = rotator._checkSafetyTriggers(mkAgent());
189
+ assert.equal(trigger, null);
190
+ });
191
+
192
+ it('fires token_limit_exceeded when instance tokens hit ceiling', () => {
193
+ mockDaemon.config = {
194
+ safety: {
195
+ autoRotate: true,
196
+ tokenCeilingPerAgent: 1_000_000,
197
+ velocityWindowSeconds: 300,
198
+ velocityTokenThreshold: 2_000_000,
199
+ },
200
+ };
201
+ mockDaemon.tokens.getTokensInWindow = () => 1_200_000;
202
+ mockDaemon.tokens.getVelocity = () => 0;
203
+
204
+ const trigger = rotator._checkSafetyTriggers(mkAgent());
205
+ assert.equal(trigger.reason, 'token_limit_exceeded');
206
+ assert.equal(trigger.instanceTokens, 1_200_000);
207
+ assert.equal(trigger.ceiling, 1_000_000);
208
+ });
209
+
210
+ it('fires runaway_velocity when recent burn exceeds threshold', () => {
211
+ mockDaemon.config = {
212
+ safety: {
213
+ autoRotate: true,
214
+ tokenCeilingPerAgent: 10_000_000,
215
+ velocityWindowSeconds: 300,
216
+ velocityTokenThreshold: 1_000_000,
217
+ },
218
+ };
219
+ mockDaemon.tokens.getTokensInWindow = () => 500_000; // under ceiling
220
+ mockDaemon.tokens.getVelocity = () => 1_500_000;
221
+
222
+ const trigger = rotator._checkSafetyTriggers(mkAgent());
223
+ assert.equal(trigger.reason, 'runaway_velocity');
224
+ assert.equal(trigger.velocity, 1_500_000);
225
+ assert.equal(trigger.threshold, 1_000_000);
226
+ assert.equal(trigger.windowMs, 300_000);
227
+ });
228
+
229
+ it('ceiling check takes priority over velocity check', () => {
230
+ mockDaemon.config = {
231
+ safety: {
232
+ autoRotate: true,
233
+ tokenCeilingPerAgent: 1_000_000,
234
+ velocityWindowSeconds: 300,
235
+ velocityTokenThreshold: 1_000_000,
236
+ },
237
+ };
238
+ mockDaemon.tokens.getTokensInWindow = () => 2_000_000;
239
+ mockDaemon.tokens.getVelocity = () => 2_000_000;
240
+ const trigger = rotator._checkSafetyTriggers(mkAgent());
241
+ assert.equal(trigger.reason, 'token_limit_exceeded');
242
+ });
243
+
244
+ it('returns null when neither threshold hit', () => {
245
+ mockDaemon.config = {
246
+ safety: {
247
+ autoRotate: true,
248
+ tokenCeilingPerAgent: 5_000_000,
249
+ velocityWindowSeconds: 300,
250
+ velocityTokenThreshold: 1_500_000,
251
+ },
252
+ };
253
+ mockDaemon.tokens.getTokensInWindow = () => 100_000;
254
+ mockDaemon.tokens.getVelocity = () => 10_000;
255
+ const trigger = rotator._checkSafetyTriggers(mkAgent());
256
+ assert.equal(trigger, null);
257
+ });
258
+
259
+ it('stats track safety-triggered rotations separately', async () => {
260
+ mockDaemon.registry.agents = [mkAgent({ tokensUsed: 1_200_000 })];
261
+ await rotator.rotate('a1', {
262
+ reason: 'token_limit_exceeded',
263
+ instanceTokens: 1_200_000,
264
+ ceiling: 1_000_000,
265
+ });
266
+
267
+ const stats = rotator.getStats();
268
+ assert.equal(stats.tokenLimitRotations, 1);
269
+ assert.equal(stats.velocityRotations, 0);
270
+ assert.equal(stats.totalRotations, 1);
271
+ });
272
+ });
165
273
  });
@@ -93,4 +93,68 @@ describe('ModelRouter', () => {
93
93
  assert.equal(status.modes.AUTO, 'auto');
94
94
  assert.equal(status.modes.AUTO_FLOOR, 'auto-floor');
95
95
  });
96
+
97
+ describe('getSuggestion (downshift only, never auto-applied)', () => {
98
+ beforeEach(() => {
99
+ // Mock registry returns an agent on claude-code with heavy model
100
+ mockDaemon.registry.get = (id) => ({
101
+ id,
102
+ provider: 'claude-code',
103
+ model: 'claude-opus-4-6',
104
+ role: 'backend',
105
+ });
106
+ });
107
+
108
+ it('returns null when agent not found', () => {
109
+ mockDaemon.registry.get = () => null;
110
+ assert.equal(router.getSuggestion('missing'), null);
111
+ });
112
+
113
+ it('returns null when classifier has too few events', () => {
114
+ // Add a handful — below the 40-event threshold
115
+ for (let i = 0; i < 10; i++) {
116
+ mockDaemon.classifier.addEvent('agent-1', { type: 'tool', tool: 'Read', input: `f${i}.js` });
117
+ }
118
+ assert.equal(router.getSuggestion('agent-1'), null);
119
+ });
120
+
121
+ it('suggests a lighter model when classification is light with enough data', () => {
122
+ for (let i = 0; i < 50; i++) {
123
+ mockDaemon.classifier.addEvent('agent-1', { type: 'tool', tool: 'Read', input: `f${i}.js` });
124
+ }
125
+ const s = router.getSuggestion('agent-1');
126
+ assert.ok(s, 'expected a suggestion');
127
+ assert.equal(s.classifiedTier, 'light');
128
+ assert.equal(s.currentModel.tier, 'heavy');
129
+ assert.ok(['medium', 'light'].includes(s.suggestedModel.tier));
130
+ });
131
+
132
+ it('does not suggest upshift (never silently escalates)', () => {
133
+ // Heavy-signal events
134
+ for (let i = 0; i < 50; i++) {
135
+ mockDaemon.classifier.addEvent('agent-1', {
136
+ type: 'tool', tool: 'Edit', input: `f${i}.js`, data: 'complex refactor migrate schema',
137
+ });
138
+ }
139
+ // Current model is already heavy; never suggest going heavier
140
+ const s = router.getSuggestion('agent-1');
141
+ // Either null (no lower tier) or only suggests lighter
142
+ if (s) {
143
+ const tierRank = { heavy: 3, medium: 2, light: 1 };
144
+ assert.ok(tierRank[s.suggestedModel.tier] < tierRank[s.currentModel.tier]);
145
+ }
146
+ });
147
+
148
+ it('returns null when current model already matches classification', () => {
149
+ mockDaemon.registry.get = (id) => ({
150
+ id, provider: 'claude-code',
151
+ model: 'claude-haiku-4-5-20251001', // light tier
152
+ role: 'backend',
153
+ });
154
+ for (let i = 0; i < 50; i++) {
155
+ mockDaemon.classifier.addEvent('agent-1', { type: 'tool', tool: 'Read', input: `f${i}.js` });
156
+ }
157
+ assert.equal(router.getSuggestion('agent-1'), null);
158
+ });
159
+ });
96
160
  });
@@ -0,0 +1,230 @@
1
+ // GROOVE — Slides Layout Engine Tests
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { describe, it } from 'node:test';
5
+ import assert from 'node:assert';
6
+ import { createRequire } from 'node:module';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { dirname, resolve } from 'node:path';
9
+
10
+ const require = createRequire(import.meta.url);
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const engine = require(resolve(__dirname, '../templates/groove-slides.cjs'));
13
+
14
+ describe('slides-engine / LAYOUT', () => {
15
+ it('exposes frozen constants with the documented 16:9 safe zone', () => {
16
+ assert.equal(engine.LAYOUT.SLIDE_W, 10);
17
+ assert.equal(engine.LAYOUT.SLIDE_H, 5.625);
18
+ assert.equal(engine.LAYOUT.SAFE_BOT, 5.125);
19
+ assert.equal(engine.LAYOUT.CONTENT_START, 0.65);
20
+ assert.throws(() => { engine.LAYOUT.SLIDE_W = 99; }, /read only|Cannot assign/i);
21
+ });
22
+ });
23
+
24
+ describe('slides-engine / estimateTextHeight', () => {
25
+ it('returns single-line height for short text', () => {
26
+ const h = engine.estimateTextHeight('Hello', 18, 5.0);
27
+ const lineH = (18 * 1.3) / 72;
28
+ assert.ok(Math.abs(h - lineH) < 0.01, `expected ~${lineH}, got ${h}`);
29
+ });
30
+
31
+ it('accounts for word wrap on long text in a narrow box', () => {
32
+ const short = engine.estimateTextHeight('One two', 24, 8.0);
33
+ const long = engine.estimateTextHeight(
34
+ 'This sentence is long enough to wrap across multiple lines in a narrow box',
35
+ 24, 2.0,
36
+ );
37
+ assert.ok(long > short * 2, `long (${long}) should be at least 2× short (${short})`);
38
+ });
39
+
40
+ it('respects explicit newlines', () => {
41
+ const h = engine.estimateTextHeight('one\ntwo\nthree', 12, 8.0);
42
+ const lineH = (12 * 1.3) / 72;
43
+ assert.ok(h >= lineH * 3 - 0.01, `expected ≥ 3 lines, got ${h}`);
44
+ });
45
+
46
+ it('does not divide by zero on empty text', () => {
47
+ assert.ok(engine.estimateTextHeight('', 18, 5.0) > 0);
48
+ });
49
+ });
50
+
51
+ describe('slides-engine / fitFontSize', () => {
52
+ it('returns maxSize when text already fits', () => {
53
+ const size = engine.fitFontSize('Short', 48, 18, 8.0, 2.0);
54
+ assert.equal(size, 48);
55
+ });
56
+
57
+ it('shrinks text to fit a constrained box', () => {
58
+ const long = 'AI coding tools are broken at the multi-agent level.';
59
+ const fitted = engine.fitFontSize(long, 48, 18, 5.5, 0.85, { bold: true });
60
+ assert.ok(fitted < 48, `should have shrunk from 48, got ${fitted}`);
61
+ const needed = engine.estimateTextHeight(long, fitted, 5.5, { bold: true });
62
+ assert.ok(needed <= 0.85, `fitted size ${fitted} still overflows: needs ${needed}`);
63
+ });
64
+
65
+ it('returns minSize when even that does not fit', () => {
66
+ const impossibleText = 'a '.repeat(500);
67
+ const size = engine.fitFontSize(impossibleText, 48, 10, 1.0, 0.3);
68
+ assert.equal(size, 10);
69
+ });
70
+ });
71
+
72
+ describe('slides-engine / checkCollisions', () => {
73
+ it('detects overlapping rectangles', () => {
74
+ const issues = engine.checkCollisions([
75
+ { name: 'a', x: 0, y: 0, w: 2, h: 1 },
76
+ { name: 'b', x: 1, y: 0.5, w: 2, h: 1 },
77
+ ]);
78
+ assert.equal(issues.length, 1);
79
+ assert.match(issues[0], /OVERLAP/);
80
+ });
81
+
82
+ it('returns empty for non-overlapping rectangles', () => {
83
+ const issues = engine.checkCollisions([
84
+ { name: 'a', x: 0, y: 0, w: 2, h: 1 },
85
+ { name: 'b', x: 3, y: 0, w: 2, h: 1 },
86
+ ]);
87
+ assert.deepEqual(issues, []);
88
+ });
89
+
90
+ it('ignores skipCollision flag — no bypasses allowed', () => {
91
+ const issues = engine.checkCollisions([
92
+ { name: 'a', x: 0, y: 0, w: 2, h: 1, skipCollision: true },
93
+ { name: 'b', x: 1, y: 0.5, w: 2, h: 1, skipCollision: true },
94
+ ]);
95
+ assert.equal(issues.length, 1, 'skipCollision flag must not bypass the check');
96
+ });
97
+ });
98
+
99
+ describe('slides-engine / checkBounds', () => {
100
+ it('flags elements that overflow the safe bottom', () => {
101
+ const issues = engine.checkBounds([
102
+ { name: 'footer', x: 0.6, y: 5.0, w: 8.8, h: 0.5 },
103
+ ]);
104
+ assert.equal(issues.length, 1);
105
+ assert.match(issues[0], /BOTTOM-OVERFLOW/);
106
+ });
107
+
108
+ it('flags top / left / right overflow', () => {
109
+ const issues = engine.checkBounds([
110
+ { name: 'above', x: 0.6, y: -0.1, w: 1, h: 0.2 },
111
+ { name: 'leftOut', x: -0.5, y: 1, w: 0.3, h: 0.2 },
112
+ { name: 'rightOut', x: 9.8, y: 1, w: 0.5, h: 0.2 },
113
+ ]);
114
+ assert.ok(issues.some((m) => /TOP-OVERFLOW/.test(m)));
115
+ assert.ok(issues.some((m) => /LEFT-OVERFLOW/.test(m)));
116
+ assert.ok(issues.some((m) => /RIGHT-OVERFLOW/.test(m)));
117
+ });
118
+
119
+ it('ignores skipBounds flag — no bypasses allowed', () => {
120
+ const issues = engine.checkBounds([
121
+ { name: 'footer', x: 0.6, y: 5.0, w: 8.8, h: 0.5, skipBounds: true },
122
+ ]);
123
+ assert.equal(issues.length, 1, 'skipBounds flag must not bypass the check');
124
+ });
125
+ });
126
+
127
+ describe('slides-engine / checkTextWrap', () => {
128
+ it('flags text that needs more height than its box', () => {
129
+ const issues = engine.checkTextWrap([
130
+ { name: 'title', x: 0, y: 0, w: 5.5, h: 0.85,
131
+ text: 'AI coding tools are broken at the multi-agent level.',
132
+ fontSize: 38, bold: true },
133
+ ]);
134
+ assert.equal(issues.length, 1);
135
+ assert.match(issues[0], /TEXT-WRAP/);
136
+ });
137
+
138
+ it('does NOT bypass check on shrinkText — that flag is ignored', () => {
139
+ const issues = engine.checkTextWrap([
140
+ { name: 'title', x: 0, y: 0, w: 5.5, h: 0.85,
141
+ text: 'AI coding tools are broken at the multi-agent level.',
142
+ fontSize: 38, bold: true, shrinkText: true },
143
+ ]);
144
+ assert.equal(issues.length, 1, 'shrinkText must NOT exempt an element from the wrap check');
145
+ });
146
+
147
+ it('passes when text fits', () => {
148
+ const issues = engine.checkTextWrap([
149
+ { name: 'label', x: 0, y: 0, w: 5.0, h: 0.3,
150
+ text: 'WHY THIS MATTERS', fontSize: 11, bold: true },
151
+ ]);
152
+ assert.deepEqual(issues, []);
153
+ });
154
+ });
155
+
156
+ describe('slides-engine / hardGate', () => {
157
+ it('returns quietly on an empty / valid deck', () => {
158
+ // Capture stdout
159
+ const origLog = console.log;
160
+ const logs = [];
161
+ console.log = (msg) => logs.push(String(msg));
162
+ try {
163
+ engine.hardGate([
164
+ ['slide-1', [{ name: 'title', x: 0.6, y: 0.65, w: 8.8, h: 1.0,
165
+ text: 'Hello world', fontSize: 24, bold: true }]],
166
+ ]);
167
+ } finally {
168
+ console.log = origLog;
169
+ }
170
+ assert.ok(logs.some((l) => /Layout gate passed/.test(l)));
171
+ });
172
+
173
+ it('calls process.exit(1) when any issue is found', () => {
174
+ const origExit = process.exit;
175
+ const origError = console.error;
176
+ let exitCode = null;
177
+ process.exit = (code) => { exitCode = code; throw new Error('__exit__'); };
178
+ console.error = () => {};
179
+ try {
180
+ engine.hardGate([
181
+ ['slide-1', [
182
+ { name: 'a', x: 0, y: 0, w: 2, h: 1 },
183
+ { name: 'b', x: 1, y: 0.5, w: 2, h: 1 },
184
+ ]],
185
+ ]);
186
+ assert.fail('hardGate should have exited');
187
+ } catch (e) {
188
+ if (e.message !== '__exit__') throw e;
189
+ } finally {
190
+ process.exit = origExit;
191
+ console.error = origError;
192
+ }
193
+ assert.equal(exitCode, 1);
194
+ });
195
+ });
196
+
197
+ describe('slides-engine / tracker', () => {
198
+ it('collects placed elements in order', () => {
199
+ const { track, placed } = engine.tracker();
200
+ track('a', { x: 0, y: 0, w: 1, h: 1 });
201
+ track('b', { x: 2, y: 0, w: 1, h: 1 });
202
+ assert.equal(placed.length, 2);
203
+ assert.equal(placed[0].name, 'a');
204
+ assert.equal(placed[1].name, 'b');
205
+ });
206
+
207
+ it('returns the opts object so it can be spread into addText', () => {
208
+ const { track } = engine.tracker();
209
+ const opts = track('a', { x: 0, y: 0, w: 1, h: 1, fontSize: 14 });
210
+ assert.equal(opts.fontSize, 14);
211
+ });
212
+
213
+ it('rejects skipCollision / skipBounds / skipWrap flags with a clear error', () => {
214
+ const { track } = engine.tracker();
215
+ for (const flag of ['skipCollision', 'skipBounds', 'skipWrap']) {
216
+ assert.throws(
217
+ () => track('a', { x: 0, y: 0, w: 1, h: 1, [flag]: true }),
218
+ new RegExp(`"${flag}" is not a supported option`),
219
+ `${flag} must be rejected by tracker`,
220
+ );
221
+ }
222
+ });
223
+
224
+ it('rejects non-finite coordinates', () => {
225
+ const { track } = engine.tracker();
226
+ assert.throws(() => track('a', { x: NaN, y: 0, w: 1, h: 1 }));
227
+ assert.throws(() => track('a', { x: 0, y: Infinity, w: 1, h: 1 }));
228
+ assert.throws(() => track('a', { x: 0, y: 0, w: 'wide', h: 1 }));
229
+ });
230
+ });
@@ -109,4 +109,82 @@ describe('TokenTracker', () => {
109
109
  assert.ok(summary.savings.percentage > 0);
110
110
  assert.ok(summary.savings.estimatedWithoutGroove > summary.totalTokens);
111
111
  });
112
+
113
+ it('cache hit rate returns 0 when no cacheable tokens exist', () => {
114
+ tracker.record('agent-1', { tokens: 1000, inputTokens: 1000 });
115
+ const summary = tracker.getSummary();
116
+ assert.equal(summary.cacheHitRate, 0);
117
+ assert.equal(tracker.getCacheHitRate(), 0);
118
+ });
119
+
120
+ it('cache hit rate excludes fresh input tokens from denominator', () => {
121
+ // 800 cache reads + 200 cache creation = 1000 cacheable → 80% hit rate.
122
+ // Fresh inputTokens must NOT inflate the denominator.
123
+ tracker.record('agent-1', {
124
+ tokens: 6000,
125
+ inputTokens: 5000,
126
+ cacheReadTokens: 800,
127
+ cacheCreationTokens: 200,
128
+ });
129
+ const summary = tracker.getSummary();
130
+ assert.equal(summary.cacheHitRate, 0.8);
131
+ });
132
+
133
+ it('cache hit rate is 1.0 when all cacheable tokens are reads', () => {
134
+ tracker.record('agent-1', {
135
+ tokens: 1000,
136
+ cacheReadTokens: 1000,
137
+ cacheCreationTokens: 0,
138
+ });
139
+ assert.equal(tracker.getCacheHitRate(), 1.0);
140
+ });
141
+
142
+ it('internal reserved IDs (__prefix) are segregated from user agents', () => {
143
+ tracker.record('agent-1', { tokens: 1000, inputTokens: 1000 });
144
+ tracker.record('agent-2', { tokens: 500, inputTokens: 500 });
145
+ tracker.record('__journalist__', { tokens: 300, inputTokens: 300 });
146
+ tracker.record('__pm__', { tokens: 200, inputTokens: 200 });
147
+
148
+ const summary = tracker.getSummary();
149
+ // perAgent excludes internal IDs
150
+ assert.equal(summary.perAgent.length, 2);
151
+ assert.ok(summary.perAgent.every((a) => !a.agentId.startsWith('__')));
152
+ // agentCount reflects user-facing agents only
153
+ assert.equal(summary.agentCount, 2);
154
+ // Internal overhead is exposed separately
155
+ assert.equal(summary.internalOverhead.tokens, 500);
156
+ assert.equal(summary.internalOverhead.components['__journalist__'].tokens, 300);
157
+ assert.equal(summary.internalOverhead.components['__pm__'].tokens, 200);
158
+ // totalTokens still includes internal (reflects real billing)
159
+ assert.equal(summary.totalTokens, 2000);
160
+ });
161
+
162
+ it('empty internalOverhead when no internal IDs recorded', () => {
163
+ tracker.record('agent-1', { tokens: 100 });
164
+ const summary = tracker.getSummary();
165
+ assert.equal(summary.internalOverhead.tokens, 0);
166
+ assert.deepEqual(summary.internalOverhead.components, {});
167
+ });
168
+
169
+ it('getTokensInWindow returns 0 for unknown agent', () => {
170
+ assert.equal(tracker.getTokensInWindow('nonexistent', 0), 0);
171
+ });
172
+
173
+ it('getTokensInWindow sums sessions since a given timestamp', () => {
174
+ tracker.record('agent-1', { tokens: 100 });
175
+ tracker.record('agent-1', { tokens: 200 });
176
+ tracker.record('agent-1', { tokens: 300 });
177
+ // sinceTs = 0 captures all
178
+ assert.equal(tracker.getTokensInWindow('agent-1', 0), 600);
179
+ // sinceTs = now + 10s captures nothing
180
+ assert.equal(tracker.getTokensInWindow('agent-1', Date.now() + 10_000), 0);
181
+ });
182
+
183
+ it('getVelocity returns tokens in a rolling window', () => {
184
+ tracker.record('agent-1', { tokens: 1000 });
185
+ // Large window captures the recent recording
186
+ assert.equal(tracker.getVelocity('agent-1', 60_000), 1000);
187
+ // Empty tracker returns 0
188
+ assert.equal(tracker.getVelocity('unknown-agent', 60_000), 0);
189
+ });
112
190
  });