groove-dev 0.27.142 → 0.27.144

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 (187) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/api.js +1086 -6532
  4. package/node_modules/@groove-dev/daemon/src/gateways/manager.js +35 -1
  5. package/node_modules/@groove-dev/daemon/src/index.js +3 -0
  6. package/node_modules/@groove-dev/daemon/src/journalist.js +23 -13
  7. package/node_modules/@groove-dev/daemon/src/mlx-server.js +365 -0
  8. package/node_modules/@groove-dev/daemon/src/model-lab.js +308 -12
  9. package/node_modules/@groove-dev/daemon/src/pm.js +1 -1
  10. package/node_modules/@groove-dev/daemon/src/process.js +2 -2
  11. package/node_modules/@groove-dev/daemon/src/providers/local.js +36 -8
  12. package/node_modules/@groove-dev/daemon/src/registry.js +21 -5
  13. package/node_modules/@groove-dev/daemon/src/routes/agents.js +889 -0
  14. package/node_modules/@groove-dev/daemon/src/routes/coordination.js +318 -0
  15. package/node_modules/@groove-dev/daemon/src/routes/files.js +751 -0
  16. package/node_modules/@groove-dev/daemon/src/routes/integrations.js +485 -0
  17. package/node_modules/@groove-dev/daemon/src/routes/network.js +1784 -0
  18. package/node_modules/@groove-dev/daemon/src/routes/providers.js +755 -0
  19. package/node_modules/@groove-dev/daemon/src/routes/schedules.js +110 -0
  20. package/node_modules/@groove-dev/daemon/src/routes/teams.js +650 -0
  21. package/node_modules/@groove-dev/daemon/src/scheduler.js +456 -24
  22. package/node_modules/@groove-dev/daemon/src/teams.js +1 -1
  23. package/node_modules/@groove-dev/daemon/src/validate.js +38 -1
  24. package/node_modules/@groove-dev/daemon/templates/mlx-setup.json +12 -0
  25. package/node_modules/@groove-dev/daemon/templates/tgi-setup.json +1 -1
  26. package/node_modules/@groove-dev/daemon/templates/vllm-setup.json +1 -1
  27. package/node_modules/@groove-dev/daemon/test/introducer.test.js +3 -3
  28. package/node_modules/@groove-dev/daemon/test/journalist.test.js +7 -10
  29. package/node_modules/@groove-dev/daemon/test/registry.test.js +38 -0
  30. package/node_modules/@groove-dev/gui/dist/assets/index-BcoF6_eF.js +1012 -0
  31. package/node_modules/@groove-dev/gui/dist/assets/index-Dd7qhiEd.css +1 -0
  32. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  33. package/node_modules/@groove-dev/gui/package.json +1 -1
  34. package/{packages/gui/src/app.jsx → node_modules/@groove-dev/gui/src/App.jsx} +0 -2
  35. package/node_modules/@groove-dev/gui/src/app.css +35 -0
  36. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +1 -128
  37. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +144 -31
  38. package/node_modules/@groove-dev/gui/src/components/agents/agent-node.jsx +8 -13
  39. package/node_modules/@groove-dev/gui/src/components/agents/code-review.jsx +159 -122
  40. package/node_modules/@groove-dev/gui/src/components/agents/diff-viewer.jsx +23 -23
  41. package/node_modules/@groove-dev/gui/src/components/agents/journalist-panel.jsx +1 -1
  42. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +2 -135
  43. package/node_modules/@groove-dev/gui/src/components/automations/automation-card.jsx +274 -0
  44. package/node_modules/@groove-dev/gui/src/components/automations/automation-wizard.jsx +1136 -0
  45. package/node_modules/@groove-dev/gui/src/components/dashboard/activity-feed.jsx +3 -3
  46. package/node_modules/@groove-dev/gui/src/components/dashboard/cache-ring.jsx +5 -5
  47. package/node_modules/@groove-dev/gui/src/components/dashboard/context-gauges.jsx +6 -8
  48. package/node_modules/@groove-dev/gui/src/components/dashboard/fleet-panel.jsx +8 -14
  49. package/node_modules/@groove-dev/gui/src/components/dashboard/intel-panel.jsx +238 -656
  50. package/node_modules/@groove-dev/gui/src/components/dashboard/kpi-card.jsx +3 -3
  51. package/node_modules/@groove-dev/gui/src/components/dashboard/routing-chart.jsx +3 -3
  52. package/node_modules/@groove-dev/gui/src/components/dashboard/team-burn-panel.jsx +1 -1
  53. package/node_modules/@groove-dev/gui/src/components/dashboard/token-chart.jsx +4 -4
  54. package/node_modules/@groove-dev/gui/src/components/editor/selection-menu.jsx +2 -0
  55. package/node_modules/@groove-dev/gui/src/components/lab/lab-assistant.jsx +316 -82
  56. package/node_modules/@groove-dev/gui/src/components/lab/metrics-panel.jsx +187 -32
  57. package/node_modules/@groove-dev/gui/src/components/lab/parameter-panel.jsx +195 -14
  58. package/node_modules/@groove-dev/gui/src/components/lab/runtime-config.jsx +286 -102
  59. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -4
  60. package/node_modules/@groove-dev/gui/src/components/layout/terminal-panel.jsx +4 -2
  61. package/node_modules/@groove-dev/gui/src/components/layout/welcome-splash.jsx +137 -108
  62. package/node_modules/@groove-dev/gui/src/components/network/network-health.jsx +2 -2
  63. package/node_modules/@groove-dev/gui/src/components/network/performance-dashboard.jsx +4 -4
  64. package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +81 -99
  65. package/node_modules/@groove-dev/gui/src/components/ui/sheet.jsx +5 -2
  66. package/node_modules/@groove-dev/gui/src/lib/cron.js +64 -0
  67. package/node_modules/@groove-dev/gui/src/lib/status.js +24 -24
  68. package/node_modules/@groove-dev/gui/src/lib/theme-hex.js +1 -0
  69. package/node_modules/@groove-dev/gui/src/stores/groove.js +34 -3144
  70. package/node_modules/@groove-dev/gui/src/stores/helpers.js +10 -0
  71. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +452 -0
  72. package/node_modules/@groove-dev/gui/src/stores/slices/automations-slice.js +96 -0
  73. package/node_modules/@groove-dev/gui/src/stores/slices/chat-slice.js +227 -0
  74. package/node_modules/@groove-dev/gui/src/stores/slices/editor-slice.js +285 -0
  75. package/node_modules/@groove-dev/gui/src/stores/slices/marketplace-slice.js +461 -0
  76. package/node_modules/@groove-dev/gui/src/stores/slices/network-slice.js +361 -0
  77. package/node_modules/@groove-dev/gui/src/stores/slices/preview-slice.js +109 -0
  78. package/node_modules/@groove-dev/gui/src/stores/slices/providers-slice.js +897 -0
  79. package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +413 -0
  80. package/node_modules/@groove-dev/gui/src/stores/slices/ui-slice.js +98 -0
  81. package/node_modules/@groove-dev/gui/src/views/agents.jsx +5 -5
  82. package/node_modules/@groove-dev/gui/src/views/dashboard.jsx +12 -13
  83. package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +191 -3
  84. package/node_modules/@groove-dev/gui/src/views/model-lab.jsx +17 -6
  85. package/node_modules/@groove-dev/gui/src/views/models.jsx +410 -509
  86. package/node_modules/@groove-dev/gui/src/views/network.jsx +3 -3
  87. package/node_modules/@groove-dev/gui/src/views/settings.jsx +81 -94
  88. package/node_modules/@groove-dev/gui/src/views/teams.jsx +40 -483
  89. package/package.json +1 -1
  90. package/packages/cli/package.json +1 -1
  91. package/packages/daemon/package.json +1 -1
  92. package/packages/daemon/src/api.js +1086 -6532
  93. package/packages/daemon/src/gateways/manager.js +35 -1
  94. package/packages/daemon/src/index.js +3 -0
  95. package/packages/daemon/src/journalist.js +23 -13
  96. package/packages/daemon/src/mlx-server.js +365 -0
  97. package/packages/daemon/src/model-lab.js +308 -12
  98. package/packages/daemon/src/pm.js +1 -1
  99. package/packages/daemon/src/process.js +2 -2
  100. package/packages/daemon/src/providers/local.js +36 -8
  101. package/packages/daemon/src/registry.js +21 -5
  102. package/packages/daemon/src/routes/agents.js +889 -0
  103. package/packages/daemon/src/routes/coordination.js +318 -0
  104. package/packages/daemon/src/routes/files.js +751 -0
  105. package/packages/daemon/src/routes/integrations.js +485 -0
  106. package/packages/daemon/src/routes/network.js +1784 -0
  107. package/packages/daemon/src/routes/providers.js +755 -0
  108. package/packages/daemon/src/routes/schedules.js +110 -0
  109. package/packages/daemon/src/routes/teams.js +650 -0
  110. package/packages/daemon/src/scheduler.js +456 -24
  111. package/packages/daemon/src/teams.js +1 -1
  112. package/packages/daemon/src/validate.js +38 -1
  113. package/packages/daemon/templates/mlx-setup.json +12 -0
  114. package/packages/daemon/templates/tgi-setup.json +1 -1
  115. package/packages/daemon/templates/vllm-setup.json +1 -1
  116. package/packages/gui/dist/assets/index-BcoF6_eF.js +1012 -0
  117. package/packages/gui/dist/assets/index-Dd7qhiEd.css +1 -0
  118. package/packages/gui/dist/index.html +2 -2
  119. package/packages/gui/package.json +1 -1
  120. package/{node_modules/@groove-dev/gui/src/app.jsx → packages/gui/src/App.jsx} +0 -2
  121. package/packages/gui/src/app.css +35 -0
  122. package/packages/gui/src/components/agents/agent-config.jsx +1 -128
  123. package/packages/gui/src/components/agents/agent-feed.jsx +144 -31
  124. package/packages/gui/src/components/agents/agent-node.jsx +8 -13
  125. package/packages/gui/src/components/agents/code-review.jsx +159 -122
  126. package/packages/gui/src/components/agents/diff-viewer.jsx +23 -23
  127. package/packages/gui/src/components/agents/journalist-panel.jsx +1 -1
  128. package/packages/gui/src/components/agents/spawn-wizard.jsx +2 -135
  129. package/packages/gui/src/components/automations/automation-card.jsx +274 -0
  130. package/packages/gui/src/components/automations/automation-wizard.jsx +1136 -0
  131. package/packages/gui/src/components/dashboard/activity-feed.jsx +3 -3
  132. package/packages/gui/src/components/dashboard/cache-ring.jsx +5 -5
  133. package/packages/gui/src/components/dashboard/context-gauges.jsx +6 -8
  134. package/packages/gui/src/components/dashboard/fleet-panel.jsx +8 -14
  135. package/packages/gui/src/components/dashboard/intel-panel.jsx +238 -656
  136. package/packages/gui/src/components/dashboard/kpi-card.jsx +3 -3
  137. package/packages/gui/src/components/dashboard/routing-chart.jsx +3 -3
  138. package/packages/gui/src/components/dashboard/team-burn-panel.jsx +1 -1
  139. package/packages/gui/src/components/dashboard/token-chart.jsx +4 -4
  140. package/packages/gui/src/components/editor/selection-menu.jsx +2 -0
  141. package/packages/gui/src/components/lab/lab-assistant.jsx +316 -82
  142. package/packages/gui/src/components/lab/metrics-panel.jsx +187 -32
  143. package/packages/gui/src/components/lab/parameter-panel.jsx +195 -14
  144. package/packages/gui/src/components/lab/runtime-config.jsx +286 -102
  145. package/packages/gui/src/components/layout/activity-bar.jsx +2 -4
  146. package/packages/gui/src/components/layout/terminal-panel.jsx +4 -2
  147. package/packages/gui/src/components/layout/welcome-splash.jsx +137 -108
  148. package/packages/gui/src/components/network/network-health.jsx +2 -2
  149. package/packages/gui/src/components/network/performance-dashboard.jsx +4 -4
  150. package/packages/gui/src/components/settings/ssh-wizard.jsx +81 -99
  151. package/packages/gui/src/components/ui/sheet.jsx +5 -2
  152. package/packages/gui/src/lib/cron.js +64 -0
  153. package/packages/gui/src/lib/status.js +24 -24
  154. package/packages/gui/src/lib/theme-hex.js +1 -0
  155. package/packages/gui/src/stores/groove.js +34 -3144
  156. package/packages/gui/src/stores/helpers.js +10 -0
  157. package/packages/gui/src/stores/slices/agents-slice.js +452 -0
  158. package/packages/gui/src/stores/slices/automations-slice.js +96 -0
  159. package/packages/gui/src/stores/slices/chat-slice.js +227 -0
  160. package/packages/gui/src/stores/slices/editor-slice.js +285 -0
  161. package/packages/gui/src/stores/slices/marketplace-slice.js +461 -0
  162. package/packages/gui/src/stores/slices/network-slice.js +361 -0
  163. package/packages/gui/src/stores/slices/preview-slice.js +109 -0
  164. package/packages/gui/src/stores/slices/providers-slice.js +897 -0
  165. package/packages/gui/src/stores/slices/teams-slice.js +413 -0
  166. package/packages/gui/src/stores/slices/ui-slice.js +98 -0
  167. package/packages/gui/src/views/agents.jsx +5 -5
  168. package/packages/gui/src/views/dashboard.jsx +12 -13
  169. package/packages/gui/src/views/marketplace.jsx +191 -3
  170. package/packages/gui/src/views/model-lab.jsx +17 -6
  171. package/packages/gui/src/views/models.jsx +410 -509
  172. package/packages/gui/src/views/network.jsx +3 -3
  173. package/packages/gui/src/views/settings.jsx +81 -94
  174. package/packages/gui/src/views/teams.jsx +40 -483
  175. package/SECURITY_SWEEP.md +0 -228
  176. package/TRAINING_DATA_v4.md +0 -6
  177. package/node_modules/@groove-dev/gui/dist/assets/index-Bjd91ufV.js +0 -984
  178. package/node_modules/@groove-dev/gui/dist/assets/index-BqdwIFn4.css +0 -1
  179. package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +0 -322
  180. package/node_modules/@groove-dev/gui/src/views/preview.jsx +0 -6
  181. package/node_modules/@groove-dev/gui/src/views/subscription-panel.jsx +0 -327
  182. package/packages/gui/dist/assets/index-Bjd91ufV.js +0 -984
  183. package/packages/gui/dist/assets/index-BqdwIFn4.css +0 -1
  184. package/packages/gui/src/components/agents/agent-chat.jsx +0 -322
  185. package/packages/gui/src/views/preview.jsx +0 -6
  186. package/packages/gui/src/views/subscription-panel.jsx +0 -327
  187. package/test.py +0 -571
@@ -5,7 +5,7 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlink
5
5
  import { resolve } from 'path';
6
6
  import { randomUUID } from 'crypto';
7
7
  import { validateAgentConfig } from '../validate.js';
8
- import { eventToSummary, agentListText, statusText, approvalsText, teamsText, schedulesText, briefText, tokensText, logText, planText, truncate, formatTokens } from './formatter.js';
8
+ import { eventToSummary, agentListText, statusText, approvalsText, teamsText, schedulesText, briefText, tokensText, logText, planText, truncate, formatTokens, formatDuration, formatCost } from './formatter.js';
9
9
 
10
10
  const GATEWAY_TYPES = ['telegram', 'discord', 'slack'];
11
11
 
@@ -304,6 +304,40 @@ export class GatewayManager {
304
304
  this.daemon.credentials.deleteKey(`gateway:${id}:${key}`);
305
305
  }
306
306
 
307
+ // -------------------------------------------------------------------
308
+ // Schedule Notifications — direct, targeted notification for automations
309
+ // -------------------------------------------------------------------
310
+
311
+ /**
312
+ * Send a completion notification for a scheduled automation run.
313
+ * Called directly by the scheduler — bypasses global event routing.
314
+ */
315
+ sendScheduleNotification(gatewayIds, summary) {
316
+ if (!gatewayIds || gatewayIds.length === 0) return;
317
+
318
+ const statusIcon = summary.status === 'success' ? '✅' : '❌';
319
+ const lines = [
320
+ `${statusIcon} Automation: ${summary.name} — ${summary.status}`,
321
+ ];
322
+ if (summary.description) {
323
+ lines.push(summary.description);
324
+ }
325
+ lines.push(`Duration: ${formatDuration(summary.duration)} | Cost: ${formatCost(summary.cost)} | Agents: ${summary.agentCount}`);
326
+ if (summary.errors) {
327
+ lines.push(`Error: ${truncate(summary.errors, 500)}`);
328
+ }
329
+
330
+ const message = lines.join('\n');
331
+
332
+ for (const gid of gatewayIds) {
333
+ const gw = this.gateways.get(gid);
334
+ if (!gw || !gw.connected) continue;
335
+ gw.send(message).catch((err) => {
336
+ console.log(`[Groove:Gateway] Schedule notification failed (${gid}): ${err.message}`);
337
+ });
338
+ }
339
+ }
340
+
307
341
  // -------------------------------------------------------------------
308
342
  // Command Routing — chat command → daemon internals
309
343
  // -------------------------------------------------------------------
@@ -42,6 +42,7 @@ import { TunnelManager } from './tunnel-manager.js';
42
42
  import { ModelManager } from './model-manager.js';
43
43
  import { ModelLab } from './model-lab.js';
44
44
  import { LlamaServerManager } from './llama-server.js';
45
+ import { MLXServerManager } from './mlx-server.js';
45
46
  import { RepoImporter } from './repo-import.js';
46
47
  import { ConversationManager } from './conversations.js';
47
48
  import { Toys } from './toys.js';
@@ -151,6 +152,7 @@ export class Daemon {
151
152
  this.preview = new PreviewService(this);
152
153
  this.modelManager = new ModelManager(this);
153
154
  this.llamaServer = new LlamaServerManager(this);
155
+ this.mlxServer = new MLXServerManager(this);
154
156
  this.mcpManager = new McpManager(this);
155
157
  this.tunnelManager = new TunnelManager(this);
156
158
  this.repoImporter = new RepoImporter(this);
@@ -848,6 +850,7 @@ export class Daemon {
848
850
  if (this.preview) await this.preview.killAll();
849
851
  this.mcpManager.stopAll();
850
852
  await this.llamaServer.stopAll();
853
+ await this.mlxServer.stopAll();
851
854
 
852
855
  // Clean up PID and host files
853
856
  if (existsSync(this.pidFile)) {
@@ -168,7 +168,7 @@ export class Journalist {
168
168
 
169
169
  hasNewActivity(agents) {
170
170
  for (const agent of agents) {
171
- const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.name}.log`);
171
+ const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.id}.log`);
172
172
  if (!existsSync(logPath)) continue;
173
173
  try {
174
174
  const size = statSync(logPath).size;
@@ -182,7 +182,7 @@ export class Journalist {
182
182
  const result = {};
183
183
 
184
184
  for (const agent of agents) {
185
- const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.name}.log`);
185
+ const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.id}.log`);
186
186
  if (!existsSync(logPath)) {
187
187
  result[agent.id] = { agent, entries: [], explorationEntries: [] };
188
188
  continue;
@@ -846,7 +846,7 @@ export class Journalist {
846
846
  for (const [agentId, { agent, entries }] of Object.entries(filteredLogs)) {
847
847
  if (entries.length === 0) continue;
848
848
 
849
- const agentDir = resolve(logsDir, agent.name);
849
+ const agentDir = resolve(logsDir, agent.id);
850
850
  mkdirSync(agentDir, { recursive: true });
851
851
 
852
852
  const logPath = resolve(agentDir, `${dateStr}-session.md`);
@@ -904,13 +904,24 @@ export class Journalist {
904
904
  .map((e) => `- ${e.type === 'error' ? 'ERROR ' : ''}${e.tool}: ${(e.input || e.text || '').slice(0, 200)}`)
905
905
  .join('\n');
906
906
 
907
- // Try AI-synthesized session summary
907
+ // Try AI-synthesized session summary — but only if the session log has
908
+ // meaningful entries. With an empty/minimal log the headless Claude sees
909
+ // repo-wide git status (from its own project context) and fills the brief
910
+ // with unrelated changes from other teams, leaking cross-team state.
911
+ const meaningfulEntries = entries.filter((e) => e.type === 'tool' || e.type === 'error' || e.type === 'user' || e.type === 'result');
908
912
  let sessionSummary = '';
909
- try {
910
- const prompt = this.buildRotationSynthesisPrompt(agent, entries, options);
911
- sessionSummary = await this.callHeadless(prompt, { trackAs: '__rotation__' });
912
- } catch {
913
- // Fallback: structural summary from raw logs
913
+ let needsFallback = meaningfulEntries.length < 3;
914
+
915
+ if (!needsFallback) {
916
+ try {
917
+ const prompt = this.buildRotationSynthesisPrompt(agent, entries, options);
918
+ sessionSummary = await this.callHeadless(prompt, { trackAs: '__rotation__' });
919
+ } catch {
920
+ needsFallback = true;
921
+ }
922
+ }
923
+
924
+ if (needsFallback) {
914
925
  const errorSummary = entries
915
926
  .filter((e) => e.type === 'error')
916
927
  .map((e) => `- ${e.text}`)
@@ -941,7 +952,6 @@ export class Journalist {
941
952
  .map((e) => `- ${e.type === 'error' ? 'ERROR ' : ''}${e.tool}: ${(e.input || '').slice(0, 200)}`)
942
953
  .join('\n');
943
954
 
944
- // Build investigation timeline from thinking entries — these capture reasoning and decisions
945
955
  const thinkingEntries = entries
946
956
  .filter((e) => e.type === 'thinking' && e.text && e.text.length > 80)
947
957
  .slice(-10)
@@ -1005,7 +1015,7 @@ export class Journalist {
1005
1015
  * Budget: keeps recent turns verbatim, summarizes oldest if over maxChars.
1006
1016
  */
1007
1017
  extractConversationThread(agent, { maxChars = 60000 } = {}) {
1008
- const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.name}.log`);
1018
+ const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.id}.log`);
1009
1019
  if (!existsSync(logPath)) return null;
1010
1020
 
1011
1021
  let content;
@@ -1211,7 +1221,7 @@ export class Journalist {
1211
1221
  * Used by the Introducer to tell new agents what their teammates built.
1212
1222
  */
1213
1223
  getAgentFiles(agent) {
1214
- const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.name}.log`);
1224
+ const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.id}.log`);
1215
1225
  if (!existsSync(logPath)) return [];
1216
1226
 
1217
1227
  try {
@@ -1249,7 +1259,7 @@ export class Journalist {
1249
1259
  * Used to capture planner conclusions, build summaries, etc.
1250
1260
  */
1251
1261
  getAgentResult(agent) {
1252
- const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.name}.log`);
1262
+ const logPath = resolve(this.daemon.grooveDir, 'logs', `${agent.id}.log`);
1253
1263
  if (!existsSync(logPath)) return '';
1254
1264
 
1255
1265
  try {
@@ -0,0 +1,365 @@
1
+ // GROOVE — MLX Server Manager
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+ //
4
+ // Manages mlx_lm.server inference server instances on Apple Silicon.
5
+ // Scans ~/.cache/huggingface/hub/ for cached MLX models.
6
+ // Mirrors LlamaServerManager API: ensureServer, stopServer, getStatus.
7
+
8
+ import { spawn, execSync } from 'child_process';
9
+ import { existsSync, readdirSync, readFileSync } from 'fs';
10
+ import { resolve } from 'path';
11
+ import { homedir } from 'os';
12
+
13
+ const BASE_PORT = 8080;
14
+ const MAX_SERVERS = 3;
15
+ const HEALTH_TIMEOUT = 60000; // 60s — MLX may need to load model into memory
16
+ const HEALTH_POLL_INTERVAL = 1000;
17
+ const IDLE_TIMEOUT = 300000; // 5 minutes
18
+
19
+ const HF_CACHE_DIR = resolve(homedir(), '.cache', 'huggingface', 'hub');
20
+ const HF_MODEL_DIR_PREFIX = 'models--';
21
+
22
+ export class MLXServerManager {
23
+ constructor(daemon) {
24
+ this.daemon = daemon;
25
+ this.servers = new Map(); // modelId -> { proc, port, users, startedAt, lastUsed, ready }
26
+ }
27
+
28
+ static isInstalled() {
29
+ try {
30
+ execSync('python3 -c "import mlx_lm; print(mlx_lm.__version__)"', {
31
+ stdio: ['ignore', 'pipe', 'ignore'],
32
+ timeout: 10000,
33
+ });
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ static getVersion() {
41
+ try {
42
+ const out = execSync('python3 -c "import mlx_lm; print(mlx_lm.__version__)"', {
43
+ stdio: ['ignore', 'pipe', 'ignore'],
44
+ timeout: 10000,
45
+ });
46
+ return out.toString().trim();
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ static getPythonPath() {
53
+ // Check venv first, then system python
54
+ const venvPython = resolve(homedir(), '.mlx-env', 'bin', 'python3');
55
+ if (existsSync(venvPython)) {
56
+ try {
57
+ execSync(`${venvPython} -c "import mlx_lm"`, { stdio: 'ignore', timeout: 10000 });
58
+ return venvPython;
59
+ } catch { /* fall through */ }
60
+ }
61
+ try {
62
+ execSync('python3 -c "import mlx_lm"', { stdio: 'ignore', timeout: 10000 });
63
+ return 'python3';
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ // --- Model Scanning ---
70
+
71
+ static scanModels() {
72
+ const models = [];
73
+ if (!existsSync(HF_CACHE_DIR)) return models;
74
+
75
+ try {
76
+ const entries = readdirSync(HF_CACHE_DIR);
77
+ for (const entry of entries) {
78
+ if (!entry.startsWith(HF_MODEL_DIR_PREFIX)) continue;
79
+
80
+ const modelName = entry.slice(HF_MODEL_DIR_PREFIX.length).replace(/--/g, '/');
81
+ const snapshotsDir = resolve(HF_CACHE_DIR, entry, 'snapshots');
82
+ if (!existsSync(snapshotsDir)) continue;
83
+
84
+ let snapshotDir = null;
85
+ try {
86
+ const snapshots = readdirSync(snapshotsDir);
87
+ if (snapshots.length === 0) continue;
88
+ snapshotDir = resolve(snapshotsDir, snapshots[snapshots.length - 1]);
89
+ } catch { continue; }
90
+
91
+ let hasWeights = false;
92
+ let hasNpz = false;
93
+ let configData = null;
94
+ try {
95
+ const files = readdirSync(snapshotDir);
96
+ hasWeights = files.some((f) =>
97
+ f.endsWith('.safetensors') || f.endsWith('.npz') || f === 'weights.npz'
98
+ );
99
+ if (!hasWeights) continue;
100
+ hasNpz = files.some((f) => f.endsWith('.npz'));
101
+
102
+ const configPath = resolve(snapshotDir, 'config.json');
103
+ if (existsSync(configPath)) {
104
+ configData = JSON.parse(readFileSync(configPath, 'utf8'));
105
+ }
106
+ } catch { continue; }
107
+
108
+ const isMLX = isMLXModel(modelName, hasNpz, configData);
109
+ const type = isMLX ? 'mlx' : 'hf';
110
+ const prefix = isMLX ? 'mlx:' : 'hf:';
111
+ const shortName = modelName.split('/').pop() || modelName;
112
+ const params = parseMLXParams(shortName, configData);
113
+ const quant = parseMLXQuantization(shortName);
114
+
115
+ models.push({
116
+ id: `${prefix}${modelName}`,
117
+ modelId: modelName,
118
+ filename: shortName,
119
+ type,
120
+ compatibleBackends: isMLX ? ['mlx'] : ['vllm', 'tgi'],
121
+ parameters: params,
122
+ quantization: quant,
123
+ snapshotPath: snapshotDir,
124
+ cachedAt: entry,
125
+ });
126
+ }
127
+ } catch { /* best effort */ }
128
+
129
+ return models;
130
+ }
131
+
132
+ // --- Server Lifecycle ---
133
+
134
+ async ensureServer(modelId, options = {}) {
135
+ if (this.servers.has(modelId)) {
136
+ const server = this.servers.get(modelId);
137
+ server.users++;
138
+ server.lastUsed = Date.now();
139
+ return `http://127.0.0.1:${server.port}`;
140
+ }
141
+
142
+ if (this.servers.size >= MAX_SERVERS) {
143
+ await this._evictLRU();
144
+ }
145
+
146
+ const pythonPath = MLXServerManager.getPythonPath();
147
+ if (!pythonPath) {
148
+ throw new Error('mlx_lm not installed — run: pip3 install "mlx-lm[server]"');
149
+ }
150
+
151
+ const port = this._allocatePort();
152
+
153
+ const args = [
154
+ '-m', 'mlx_lm.server',
155
+ '--model', modelId,
156
+ '--port', String(port),
157
+ ];
158
+
159
+ const proc = spawn(pythonPath, args, {
160
+ stdio: ['ignore', 'pipe', 'pipe'],
161
+ detached: false,
162
+ });
163
+
164
+ if (!proc.pid) {
165
+ throw new Error('Failed to start mlx_lm.server — check installation');
166
+ }
167
+
168
+ const server = {
169
+ proc,
170
+ port,
171
+ modelId,
172
+ users: 1,
173
+ startedAt: Date.now(),
174
+ lastUsed: Date.now(),
175
+ ready: false,
176
+ };
177
+
178
+ this.servers.set(modelId, server);
179
+
180
+ const stderrBuf = [];
181
+ proc.stderr.on('data', (chunk) => {
182
+ stderrBuf.push(chunk.toString());
183
+ if (stderrBuf.join('').length > 4096) stderrBuf.shift();
184
+ });
185
+
186
+ proc.on('exit', (code, signal) => {
187
+ this.servers.delete(modelId);
188
+ this.daemon?.broadcast({
189
+ type: 'mlx:server:stopped',
190
+ data: { modelId, port, code, signal },
191
+ });
192
+ });
193
+
194
+ try {
195
+ await this._waitForHealth(port);
196
+ server.ready = true;
197
+
198
+ this.daemon?.broadcast({
199
+ type: 'mlx:server:ready',
200
+ data: { modelId, port },
201
+ });
202
+
203
+ return `http://127.0.0.1:${port}`;
204
+ } catch (err) {
205
+ await this.stopServer(modelId);
206
+ const stderr = stderrBuf.join('').slice(-500);
207
+ throw new Error(`mlx_lm.server failed to start: ${stderr || err.message}`);
208
+ }
209
+ }
210
+
211
+ releaseServer(modelId) {
212
+ const server = this.servers.get(modelId);
213
+ if (!server) return;
214
+
215
+ server.users = Math.max(0, server.users - 1);
216
+ server.lastUsed = Date.now();
217
+
218
+ if (server.users === 0) {
219
+ setTimeout(() => {
220
+ const s = this.servers.get(modelId);
221
+ if (s && s.users === 0 && Date.now() - s.lastUsed >= IDLE_TIMEOUT) {
222
+ this.stopServer(modelId);
223
+ }
224
+ }, IDLE_TIMEOUT + 1000);
225
+ }
226
+ }
227
+
228
+ async stopServer(modelId) {
229
+ const server = this.servers.get(modelId);
230
+ if (!server) return false;
231
+
232
+ return new Promise((resolve) => {
233
+ const timeout = setTimeout(() => {
234
+ try { server.proc.kill('SIGKILL'); } catch {}
235
+ }, 5000);
236
+
237
+ server.proc.on('exit', () => {
238
+ clearTimeout(timeout);
239
+ this.servers.delete(modelId);
240
+ resolve(true);
241
+ });
242
+
243
+ try {
244
+ server.proc.kill('SIGTERM');
245
+ } catch {
246
+ clearTimeout(timeout);
247
+ this.servers.delete(modelId);
248
+ resolve(true);
249
+ }
250
+ });
251
+ }
252
+
253
+ async stopAll() {
254
+ const ids = Array.from(this.servers.keys());
255
+ await Promise.all(ids.map((id) => this.stopServer(id)));
256
+ }
257
+
258
+ // --- Health Check ---
259
+
260
+ async _waitForHealth(port) {
261
+ const start = Date.now();
262
+ while (Date.now() - start < HEALTH_TIMEOUT) {
263
+ try {
264
+ const res = await fetch(`http://127.0.0.1:${port}/v1/models`, {
265
+ signal: AbortSignal.timeout(2000),
266
+ });
267
+ if (res.ok) return true;
268
+ } catch { /* server still loading */ }
269
+ await new Promise((r) => setTimeout(r, HEALTH_POLL_INTERVAL));
270
+ }
271
+ throw new Error(`mlx_lm.server health check timed out after ${HEALTH_TIMEOUT / 1000}s`);
272
+ }
273
+
274
+ // --- Port Management ---
275
+
276
+ _allocatePort() {
277
+ const usedPorts = new Set(Array.from(this.servers.values()).map((s) => s.port));
278
+ let port = BASE_PORT;
279
+ while (usedPorts.has(port) && port < BASE_PORT + 100) {
280
+ port++;
281
+ }
282
+ return port;
283
+ }
284
+
285
+ async _evictLRU() {
286
+ let lru = null;
287
+ for (const [id, server] of this.servers) {
288
+ if (!lru || server.users < lru.users ||
289
+ (server.users === lru.users && server.lastUsed < lru.lastUsed)) {
290
+ lru = { id, ...server };
291
+ }
292
+ }
293
+ if (lru) {
294
+ await this.stopServer(lru.id);
295
+ }
296
+ }
297
+
298
+ // --- Status ---
299
+
300
+ getRunningServers() {
301
+ return Array.from(this.servers.entries()).map(([modelId, s]) => ({
302
+ modelId,
303
+ port: s.port,
304
+ users: s.users,
305
+ ready: s.ready,
306
+ uptime: Date.now() - s.startedAt,
307
+ lastUsed: s.lastUsed,
308
+ }));
309
+ }
310
+
311
+ getStatus() {
312
+ return {
313
+ installed: MLXServerManager.isInstalled(),
314
+ version: MLXServerManager.getVersion(),
315
+ running: this.servers.size,
316
+ maxServers: MAX_SERVERS,
317
+ servers: this.getRunningServers(),
318
+ cachedModels: MLXServerManager.scanModels().length,
319
+ };
320
+ }
321
+ }
322
+
323
+ // --- Format Detection ---
324
+
325
+ function isMLXModel(modelName, hasNpz, configData) {
326
+ if (modelName.startsWith('mlx-community/')) return true;
327
+ if (hasNpz) return true;
328
+ if (/[-_]mlx[-_]/i.test(modelName) || modelName.toLowerCase().endsWith('-mlx')) return true;
329
+ if (configData?.quantization_config?.quant_method === 'mlx') return true;
330
+ return false;
331
+ }
332
+
333
+ // --- Parsing Utilities ---
334
+
335
+ function parseMLXParams(name, config) {
336
+ // Try config.json first
337
+ if (config) {
338
+ const hidden = config.hidden_size;
339
+ const layers = config.num_hidden_layers;
340
+ const vocab = config.vocab_size;
341
+ if (hidden && layers) {
342
+ const approx = (hidden * layers * vocab * 4) / 1e9;
343
+ if (approx > 0.1) {
344
+ if (approx < 1.5) return '0.5-1B';
345
+ if (approx < 5) return `${Math.round(approx)}B`;
346
+ if (approx < 10) return `${Math.round(approx)}B`;
347
+ return `${Math.round(approx)}B`;
348
+ }
349
+ }
350
+ }
351
+
352
+ // Fallback: parse from name
353
+ const match = name.match(/(\d+\.?\d*)[bB]/);
354
+ if (match) return `${match[1]}B`;
355
+ return null;
356
+ }
357
+
358
+ function parseMLXQuantization(name) {
359
+ const lower = name.toLowerCase();
360
+ if (lower.includes('8bit') || lower.includes('8-bit')) return 'W8';
361
+ if (lower.includes('4bit') || lower.includes('4-bit')) return 'W4';
362
+ if (lower.includes('3bit') || lower.includes('3-bit')) return 'W3';
363
+ if (lower.includes('bf16') || lower.includes('fp16')) return 'FP16';
364
+ return null;
365
+ }