mrmd-server 0.1.13 → 0.1.15
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/package.json +1 -1
- package/src/ai-service.js +184 -0
- package/src/api/system.js +19 -6
- package/src/server.js +10 -3
package/package.json
CHANGED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Service - manages the mrmd-ai server
|
|
3
|
+
*
|
|
4
|
+
* The AI server is shared across all sessions (stateless).
|
|
5
|
+
* It's started once on first request and kept running.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn, execSync } from 'child_process';
|
|
9
|
+
import net from 'net';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { existsSync } from 'fs';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
|
|
14
|
+
// AI server singleton
|
|
15
|
+
let aiServer = null;
|
|
16
|
+
let startPromise = null;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Find a free port
|
|
20
|
+
*/
|
|
21
|
+
async function findFreePort() {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const srv = net.createServer();
|
|
24
|
+
srv.listen(0, () => {
|
|
25
|
+
const { port } = srv.address();
|
|
26
|
+
srv.close(() => resolve(port));
|
|
27
|
+
});
|
|
28
|
+
srv.on('error', reject);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Wait for a port to be available (server started)
|
|
34
|
+
*/
|
|
35
|
+
async function waitForPort(port, { timeout = 30000 } = {}) {
|
|
36
|
+
const start = Date.now();
|
|
37
|
+
while (Date.now() - start < timeout) {
|
|
38
|
+
try {
|
|
39
|
+
await new Promise((resolve, reject) => {
|
|
40
|
+
const socket = net.connect(port, '127.0.0.1');
|
|
41
|
+
socket.on('connect', () => {
|
|
42
|
+
socket.destroy();
|
|
43
|
+
resolve();
|
|
44
|
+
});
|
|
45
|
+
socket.on('error', reject);
|
|
46
|
+
});
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
await new Promise(r => setTimeout(r, 200));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`Timeout waiting for port ${port}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Find uv executable
|
|
57
|
+
*/
|
|
58
|
+
function findUv() {
|
|
59
|
+
try {
|
|
60
|
+
return execSync('which uv', { encoding: 'utf-8' }).trim();
|
|
61
|
+
} catch {
|
|
62
|
+
// Check common locations
|
|
63
|
+
const locations = [
|
|
64
|
+
path.join(os.homedir(), '.local', 'bin', 'uv'),
|
|
65
|
+
'/usr/local/bin/uv',
|
|
66
|
+
'/usr/bin/uv',
|
|
67
|
+
path.join(os.homedir(), '.cargo', 'bin', 'uv'),
|
|
68
|
+
];
|
|
69
|
+
for (const loc of locations) {
|
|
70
|
+
if (existsSync(loc)) {
|
|
71
|
+
return loc;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Ensure AI server is running
|
|
80
|
+
* Returns { port, url, success } or { error, success: false }
|
|
81
|
+
*/
|
|
82
|
+
export async function ensureAiServer() {
|
|
83
|
+
// Already running
|
|
84
|
+
if (aiServer) {
|
|
85
|
+
return {
|
|
86
|
+
success: true,
|
|
87
|
+
port: aiServer.port,
|
|
88
|
+
url: `http://localhost:${aiServer.port}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Already starting (avoid race condition)
|
|
93
|
+
if (startPromise) {
|
|
94
|
+
return startPromise;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
startPromise = (async () => {
|
|
98
|
+
const uvPath = findUv();
|
|
99
|
+
if (!uvPath) {
|
|
100
|
+
return {
|
|
101
|
+
success: false,
|
|
102
|
+
error: "'uv' is not installed. Install it with: curl -LsSf https://astral.sh/uv/install.sh | sh",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const port = await findFreePort();
|
|
107
|
+
console.log(`[ai] Starting mrmd-ai on port ${port}...`);
|
|
108
|
+
|
|
109
|
+
const proc = spawn(uvPath, [
|
|
110
|
+
'tool', 'run',
|
|
111
|
+
'--from', 'mrmd-ai>=0.1.0,<0.2',
|
|
112
|
+
'mrmd-ai-server',
|
|
113
|
+
'--port', port.toString(),
|
|
114
|
+
], {
|
|
115
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
proc.stdout.on('data', (d) => console.log('[ai]', d.toString().trim()));
|
|
119
|
+
proc.stderr.on('data', (d) => console.error('[ai]', d.toString().trim()));
|
|
120
|
+
proc.on('exit', (code) => {
|
|
121
|
+
console.log(`[ai] AI server exited with code ${code}`);
|
|
122
|
+
aiServer = null;
|
|
123
|
+
startPromise = null;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
// AI server imports heavy libs (dspy, litellm) - needs 30s timeout
|
|
128
|
+
await waitForPort(port, { timeout: 30000 });
|
|
129
|
+
|
|
130
|
+
aiServer = { proc, port };
|
|
131
|
+
console.log(`[ai] AI server ready on port ${port}`);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
success: true,
|
|
135
|
+
port,
|
|
136
|
+
url: `http://localhost:${port}`,
|
|
137
|
+
};
|
|
138
|
+
} catch (e) {
|
|
139
|
+
proc.kill('SIGTERM');
|
|
140
|
+
startPromise = null;
|
|
141
|
+
return {
|
|
142
|
+
success: false,
|
|
143
|
+
error: `AI server failed to start: ${e.message}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
})();
|
|
147
|
+
|
|
148
|
+
const result = await startPromise;
|
|
149
|
+
if (!result.success) {
|
|
150
|
+
startPromise = null;
|
|
151
|
+
}
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get current AI server status
|
|
157
|
+
*/
|
|
158
|
+
export function getAiServer() {
|
|
159
|
+
if (aiServer) {
|
|
160
|
+
return {
|
|
161
|
+
success: true,
|
|
162
|
+
port: aiServer.port,
|
|
163
|
+
url: `http://localhost:${aiServer.port}`,
|
|
164
|
+
running: true,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
success: false,
|
|
169
|
+
running: false,
|
|
170
|
+
error: 'AI server not started',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Stop AI server
|
|
176
|
+
*/
|
|
177
|
+
export function stopAiServer() {
|
|
178
|
+
if (aiServer?.proc) {
|
|
179
|
+
console.log('[ai] Stopping AI server...');
|
|
180
|
+
aiServer.proc.kill('SIGTERM');
|
|
181
|
+
aiServer = null;
|
|
182
|
+
startPromise = null;
|
|
183
|
+
}
|
|
184
|
+
}
|
package/src/api/system.js
CHANGED
|
@@ -10,6 +10,7 @@ import path from 'path';
|
|
|
10
10
|
import fs from 'fs/promises';
|
|
11
11
|
import { existsSync } from 'fs';
|
|
12
12
|
import { spawn, execSync } from 'child_process';
|
|
13
|
+
import { ensureAiServer, getAiServer } from '../ai-service.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Create system routes
|
|
@@ -187,14 +188,26 @@ export function createSystemRoutes(ctx) {
|
|
|
187
188
|
|
|
188
189
|
/**
|
|
189
190
|
* GET /api/system/ai
|
|
190
|
-
* Get AI server info
|
|
191
|
+
* Get AI server info - ensures AI server is running
|
|
191
192
|
* Mirrors: electronAPI.getAi()
|
|
192
193
|
*/
|
|
193
|
-
router.get('/ai', (req, res) => {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
194
|
+
router.get('/ai', async (req, res) => {
|
|
195
|
+
try {
|
|
196
|
+
// Ensure AI server is running (starts it if not)
|
|
197
|
+
const result = await ensureAiServer();
|
|
198
|
+
res.json(result);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
console.error('[system:ai]', err);
|
|
201
|
+
res.status(500).json({ success: false, error: err.message });
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* GET /api/system/ai/status
|
|
207
|
+
* Get AI server status without starting it
|
|
208
|
+
*/
|
|
209
|
+
router.get('/ai/status', (req, res) => {
|
|
210
|
+
res.json(getAiServer());
|
|
198
211
|
});
|
|
199
212
|
|
|
200
213
|
/**
|
package/src/server.js
CHANGED
|
@@ -55,6 +55,9 @@ import {
|
|
|
55
55
|
onSyncDeath,
|
|
56
56
|
} from './sync-manager.js';
|
|
57
57
|
|
|
58
|
+
// Import AI service for mrmd-ai server management
|
|
59
|
+
import { stopAiServer } from './ai-service.js';
|
|
60
|
+
|
|
58
61
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
59
62
|
|
|
60
63
|
/**
|
|
@@ -309,11 +312,12 @@ export async function createServer(config) {
|
|
|
309
312
|
return;
|
|
310
313
|
}
|
|
311
314
|
|
|
312
|
-
// Handle /sync/:port/:
|
|
315
|
+
// Handle /sync/:port/:path - proxy to local server (sync, pty, etc.)
|
|
313
316
|
const syncMatch = url.pathname.match(/^\/sync\/(\d+)\/(.+)$/);
|
|
314
317
|
if (syncMatch) {
|
|
315
|
-
const [, syncPort,
|
|
316
|
-
|
|
318
|
+
const [, syncPort, pathPart] = syncMatch;
|
|
319
|
+
// Preserve query string for PTY sessions
|
|
320
|
+
const targetUrl = `ws://127.0.0.1:${syncPort}/${pathPart}${url.search}`;
|
|
317
321
|
|
|
318
322
|
// Create connection to local sync server
|
|
319
323
|
const upstream = new WsClient(targetUrl);
|
|
@@ -388,6 +392,9 @@ export async function createServer(config) {
|
|
|
388
392
|
// Stop all sync servers
|
|
389
393
|
stopAllSyncServers();
|
|
390
394
|
|
|
395
|
+
// Stop AI server
|
|
396
|
+
stopAiServer();
|
|
397
|
+
|
|
391
398
|
// Stop all sessions via services (if they have shutdown methods)
|
|
392
399
|
try {
|
|
393
400
|
if (typeof sessionService.shutdown === 'function') {
|