prism-mcp-server 20.22.3 → 20.22.4

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.
@@ -1,12 +1,13 @@
1
1
  /** Explicit, authenticated on-device oMLX transport and resource coordination.
2
2
  * Application safety/quality/entitlement policy remains in prismInferHandler. */
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
- import { createHash } from "node:crypto";
4
+ import { createHash, randomUUID } from "node:crypto";
5
5
  import { constants, closeSync, fstatSync, lstatSync, openSync, readSync, readdirSync } from "node:fs";
6
6
  import { readFile } from "node:fs/promises";
7
7
  import { createServer } from "node:net";
8
8
  import { homedir } from "node:os";
9
9
  import { isAbsolute, join, resolve } from "node:path";
10
+ import { operationStatus } from "./omlxControl.js";
10
11
  export const OMLX_LEASE_PORT = 19438;
11
12
  export const OMLX_MIN_AVAILABLE_BYTES = 2 * 1024 ** 3;
12
13
  const PROBE_TIMEOUT_MS = 3_000;
@@ -264,22 +265,32 @@ async function localJson(path) {
264
265
  throw new OmlxError("omlx_config_unavailable");
265
266
  }
266
267
  }
268
+ export async function omlxConfigurationDirectory(options = {}) {
269
+ if (options.basePath)
270
+ return options.basePath;
271
+ const home = options.home ?? homedir();
272
+ // Isolated callers supply a home and never consult this machine's settings.
273
+ const source = options.home === undefined
274
+ ? (await (await import("./localCodingSettings.js")).readLocalCodingSettings()).source : "existing";
275
+ return source === "managed" ? join(home, ".prism-mcp", "omlx", "server") : join(home, ".omlx");
276
+ }
267
277
  async function connection(options) {
268
278
  const env = options.env ?? process.env;
269
- const home = options.home ?? homedir();
279
+ const directory = await omlxConfigurationDirectory(options);
270
280
  const [settings, profiles] = await Promise.all([
271
- localJson(join(home, ".omlx", "settings.json")),
272
- localJson(join(home, ".omlx", "model_settings.json")),
281
+ localJson(join(directory, "settings.json")),
282
+ localJson(join(directory, "model_settings.json")),
273
283
  ]);
274
- const apiKey = env.OMLX_API_KEY || settings.auth?.api_key;
284
+ const managed = directory === join(options.home ?? homedir(), ".prism-mcp", "omlx", "server");
285
+ const apiKey = (!managed && env.OMLX_API_KEY) || settings.auth?.api_key;
275
286
  if (typeof apiKey !== "string" || !apiKey.trim())
276
287
  throw new OmlxError("omlx_auth_unavailable");
277
- const url = loopbackUrl(env.OMLX_BASE_URL || `http://${settings.server?.host ?? "127.0.0.1"}:${settings.server?.port ?? 8000}/v1`);
278
- return { settings, profiles, apiKey, url };
288
+ const url = loopbackUrl((!managed && env.OMLX_BASE_URL) || `http://${settings.server?.host ?? "127.0.0.1"}:${settings.server?.port ?? 8000}/v1`);
289
+ return { settings, profiles, apiKey, url, directory, managed };
279
290
  }
280
291
  /** Exposed profile IDs resolve through persisted oMLX records, never name
281
292
  * heuristics. This observes request context only, not checkpoint qualification. */
282
- async function configuredContext(model, models, settings, home) {
293
+ async function configuredContext(model, models, settings, directory) {
283
294
  const matches = [];
284
295
  for (const [id, base] of Object.entries(models)) {
285
296
  if (base && typeof base === "object" && !Array.isArray(base)
@@ -288,7 +299,7 @@ async function configuredContext(model, models, settings, home) {
288
299
  }
289
300
  if (model.includes(":")) {
290
301
  let profileMatches = 0;
291
- const document = await localJson(join(home, ".omlx", "model_profiles.json"));
302
+ const document = await localJson(join(directory, "model_profiles.json"));
292
303
  if (Object.keys(document).length && (document.version !== 1 || !document.profiles
293
304
  || typeof document.profiles !== "object" || Array.isArray(document.profiles)))
294
305
  throw new OmlxError("omlx_profile_config_invalid");
@@ -326,9 +337,7 @@ async function configuredContext(model, models, settings, home) {
326
337
  throw new OmlxError("omlx_context_unknown");
327
338
  return context;
328
339
  }
329
- export async function detectOmlx(requestedModel, options = {}) {
330
- const env = options.env ?? process.env;
331
- const { settings, profiles, apiKey, url } = await connection(options);
340
+ function selectedModel(requestedModel, env, settings, profiles) {
332
341
  const models = profiles.models && typeof profiles.models === "object" ? profiles.models : {};
333
342
  const defaults = Object.keys(models).filter(id => models[id]?.is_default === true);
334
343
  const singleFlash = Object.keys(models).length === 1 && /Flash-Next/i.test(Object.keys(models)[0])
@@ -337,7 +346,31 @@ export async function detectOmlx(requestedModel, options = {}) {
337
346
  ?? (defaults.length === 1 ? defaults[0] : singleFlash);
338
347
  if (typeof model !== "string" || !model.trim())
339
348
  throw new OmlxError("omlx_model_not_configured");
340
- const contextTokens = await configuredContext(model, models, settings, options.home ?? homedir());
349
+ return { model, models };
350
+ }
351
+ /** Configuration only: Auto never starts a daemon or probes another backend. */
352
+ export async function configuredOmlxModel(requestedModel, options = {}) {
353
+ const env = options.env ?? process.env, directory = await omlxConfigurationDirectory(options);
354
+ const [settings, profiles] = await Promise.all([
355
+ localJson(join(directory, "settings.json")), localJson(join(directory, "model_settings.json")),
356
+ ]);
357
+ const managed = directory === join(options.home ?? homedir(), ".prism-mcp", "omlx", "server");
358
+ if (!((!managed && env.OMLX_API_KEY) || settings.auth?.api_key))
359
+ return undefined;
360
+ try {
361
+ return selectedModel(requestedModel, managed ? { ...env, OMLX_MODEL: undefined } : env, settings, profiles).model;
362
+ }
363
+ catch (error) {
364
+ if (error instanceof OmlxError && error.reason === "omlx_model_not_configured")
365
+ return undefined;
366
+ throw error;
367
+ }
368
+ }
369
+ export async function detectOmlx(requestedModel, options = {}) {
370
+ const env = options.env ?? process.env;
371
+ const { settings, profiles, apiKey, url, directory, managed } = await connection(options);
372
+ const { model, models } = selectedModel(requestedModel, managed ? { ...env, OMLX_MODEL: undefined } : env, settings, profiles);
373
+ const contextTokens = await configuredContext(model, models, settings, directory);
341
374
  try {
342
375
  const response = await (options.fetchImpl ?? fetch)(`${url}/models`, {
343
376
  headers: { Authorization: `Bearer ${apiKey}` },
@@ -359,6 +392,25 @@ export async function detectOmlx(requestedModel, options = {}) {
359
392
  throw new OmlxError("omlx_detection_unavailable");
360
393
  }
361
394
  }
395
+ /** Sanitized model inventory, not filesystem paths or a load/fit guarantee. */
396
+ export async function inspectOmlxCodingModels(options = {}) {
397
+ const { apiKey, url } = await connection(options);
398
+ const raw = await inspectionJson(`${url}/models/status`, apiKey, options.fetchImpl ?? fetch, PROBE_TIMEOUT_MS);
399
+ if (!Array.isArray(raw.models) || raw.models.length > MAX_ADVERTISED_MODELS)
400
+ throw new OmlxError("omlx_inspection_models_invalid");
401
+ const ids = new Set();
402
+ return raw.models.filter(entry => entry && typeof entry === "object" && ["llm", "vlm"].includes(entry.engine_type)).map(entry => {
403
+ if (typeof entry.id !== "string" || !MODEL_ID_PATTERN.test(entry.id) || entry.id.includes(apiKey) || ids.has(entry.id)
404
+ || typeof entry.loaded !== "boolean")
405
+ throw new OmlxError("omlx_inspection_models_invalid");
406
+ ids.add(entry.id);
407
+ const positive = (n) => Number.isSafeInteger(n) && n > 0;
408
+ return { id: entry.id, loaded: entry.loaded,
409
+ ...(positive(entry.estimated_size) ? { weightBytes: entry.estimated_size } : {}),
410
+ ...(entry.loaded && positive(entry.actual_size) ? { loadedBytes: entry.actual_size } : {}),
411
+ ...(positive(entry.max_context_window) ? { contextTokens: entry.max_context_window } : {}) };
412
+ });
413
+ }
362
414
  async function inspectionJson(url, apiKey, fetchImpl, timeoutMs) {
363
415
  const controller = new AbortController();
364
416
  const deadline = performance.now() + timeoutMs;
@@ -714,6 +766,19 @@ export function omlxControlEndpoint(target, fetchImpl = fetch) {
714
766
  const apiKey = credentials.get(target);
715
767
  return apiKey ? Object.freeze({ base: new URL(target.url).origin + "/prism-control/v1", apiKey, fetchImpl }) : undefined;
716
768
  }
769
+ /** Probe a fresh, nonexistent operation without registering or cancelling it. */
770
+ export async function hasOmlxControl(target, fetchImpl = fetch) {
771
+ const endpoint = omlxControlEndpoint(target, fetchImpl);
772
+ if (!endpoint)
773
+ return false;
774
+ try {
775
+ const reply = await operationStatus(endpoint, randomUUID(), randomUUID());
776
+ return reply.status === 404 && reply.body?.detail === "Control operation unknown";
777
+ }
778
+ catch {
779
+ return false;
780
+ }
781
+ }
717
782
  export function createOmlxRuntime(resources, ollamaUrl) {
718
783
  return { ...resources, detect: async (model) => {
719
784
  // An explicit local request also keeps its classification/resource
@@ -0,0 +1,432 @@
1
+ /** Opt-in, pinned oMLX setup. This module never controls an existing daemon. */
2
+ import { execFile, spawn } from "node:child_process";
3
+ import { randomBytes, randomUUID } from "node:crypto";
4
+ import { constants } from "node:fs";
5
+ import { appendFile, lstat, open, rename, statfs, writeFile } from "node:fs/promises";
6
+ import { createConnection, createServer } from "node:net";
7
+ import { arch, homedir, platform, release, totalmem } from "node:os";
8
+ import { join } from "node:path";
9
+ import { mkdirUsable } from "./usableDirectory.js";
10
+ import { inspectOmlx } from "./omlxProvider.js";
11
+ import { OMLX_SETUP_WORKER_SOURCE } from "./omlxSetupWorker.js";
12
+ export const OMLX_SETUP_COMMIT = "3b2697cb0e77c3c69099bc6ba23a11ac14fd5d6b";
13
+ export const OMLX_SETUP_MODELS = Object.freeze([
14
+ { id: "prism-coder-9b-mlx-8bit", repo: "dcostenco/prism-coder-9b-mlx-8bit", revision: "923f646aca17cfce6b96084ab1608e1c142655a1", label: "Prism 9B classifier", approximateGB: 9 },
15
+ { id: "Qwen3.8-27B-oQ4e-mtp", repo: "Jundot/Qwen3.8-27B-oQ4e-mtp", revision: "49c1d60367d976a730a725ea3bbed32a8e94b878", label: "Qwen3.8 27B coder", approximateGB: 16 },
16
+ ]);
17
+ const GIB = 1024 ** 3, MIN_RAM = 32 * GIB;
18
+ const SETUP_LEASE_PORT = 19439, SERVER_PORT = 8001, MAX_LOG_BYTES = 2 * 1024 ** 2;
19
+ const INSTALL_TIMEOUT_MS = 45 * 60_000, DOWNLOAD_TIMEOUT_MS = 2 * 60 * 60_000;
20
+ export class OmlxSetupError extends Error {
21
+ }
22
+ /** No shell, user arguments or inherited provider credentials. All process
23
+ * termination targets the child process group created by this invocation. */
24
+ export const runSetupCommand = (program, args, options) => new Promise((resolve, reject) => {
25
+ const env = Object.fromEntries(["PATH", "HOME", "TMPDIR", "LANG"].flatMap(key => process.env[key] ? [[key, process.env[key]]] : []));
26
+ const identity = randomUUID();
27
+ const child = spawn(process.execPath, ["-e", OMLX_SETUP_WORKER_SOURCE, "--", identity], { env, stdio: ["ignore", "pipe", "pipe", "ipc"], detached: process.platform !== "win32" });
28
+ let launchFailed = false;
29
+ child.once("spawn", async () => {
30
+ try {
31
+ await options.onSpawn?.(child.pid, identity);
32
+ if (child.connected)
33
+ child.send({ program, args });
34
+ }
35
+ catch {
36
+ launchFailed = true;
37
+ stop();
38
+ }
39
+ });
40
+ let output = "", settled = false, timedOut = false, interrupted, killTimer;
41
+ const stop = () => {
42
+ try {
43
+ process.kill(process.platform === "win32" ? child.pid : -child.pid, "SIGTERM");
44
+ }
45
+ catch { /* child already stopped */ }
46
+ killTimer = setTimeout(() => { try {
47
+ process.kill(process.platform === "win32" ? child.pid : -child.pid, "SIGKILL");
48
+ }
49
+ catch { /* stopped */ } }, 2_000);
50
+ killTimer.unref();
51
+ };
52
+ const onExit = () => stop();
53
+ process.once("exit", onExit);
54
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
55
+ const signalHandlers = signals.map(signal => {
56
+ const handler = () => { if (!interrupted) {
57
+ interrupted = signal;
58
+ stop();
59
+ } };
60
+ process.on(signal, handler);
61
+ return handler;
62
+ });
63
+ const timer = setTimeout(() => { timedOut = true; stop(); }, options.timeoutMs);
64
+ const finish = (error) => {
65
+ if (settled)
66
+ return;
67
+ settled = true;
68
+ clearTimeout(timer);
69
+ clearTimeout(killTimer);
70
+ process.removeListener("exit", onExit);
71
+ signals.forEach((signal, index) => process.removeListener(signal, signalHandlers[index]));
72
+ error ? reject(error) : resolve(output);
73
+ if (interrupted)
74
+ setImmediate(() => process.kill(process.pid, interrupted));
75
+ };
76
+ child.stdout.on("data", data => { output = (output + data).slice(-4096); options.log?.(String(data)); });
77
+ child.stderr.on("data", data => options.log?.(String(data)));
78
+ child.once("error", () => finish(new Error("setup_command_unavailable")));
79
+ child.once("close", code => finish(interrupted ? new Error("setup_command_interrupted") : timedOut ? new Error("setup_command_timed_out") : !launchFailed && code === 0 ? undefined : new Error("setup_command_failed")));
80
+ });
81
+ async function readJson(path) {
82
+ try {
83
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
84
+ try {
85
+ const stat = await file.stat();
86
+ if (!stat.isFile() || stat.size > MAX_LOG_BYTES)
87
+ throw new Error("setup_state_invalid");
88
+ // Validate and read the same inode, with a hard bound even if an
89
+ // existing writer grows it after stat. Never reopen the pathname.
90
+ const buffer = Buffer.alloc(MAX_LOG_BYTES + 1);
91
+ let size = 0;
92
+ while (size < buffer.length) {
93
+ const part = await file.read(buffer, size, buffer.length - size, size);
94
+ if (!part.bytesRead)
95
+ break;
96
+ size += part.bytesRead;
97
+ }
98
+ if (size > MAX_LOG_BYTES)
99
+ throw new Error("setup_state_invalid");
100
+ return JSON.parse(buffer.toString("utf8", 0, size));
101
+ }
102
+ finally {
103
+ await file.close();
104
+ }
105
+ }
106
+ catch (error) {
107
+ if (error.code === "ENOENT")
108
+ return undefined;
109
+ throw error;
110
+ }
111
+ }
112
+ async function exists(path) {
113
+ try {
114
+ return (await lstat(path)).isFile();
115
+ }
116
+ catch (e) {
117
+ if (e.code === "ENOENT")
118
+ return false;
119
+ throw e;
120
+ }
121
+ }
122
+ async function modelFilesPresent(directory) {
123
+ if (!await exists(join(directory, "config.json")))
124
+ return false;
125
+ const index = await readJson(join(directory, "model.safetensors.index.json"));
126
+ const files = index?.weight_map ? [...new Set(Object.values(index.weight_map))] : ["model.safetensors"];
127
+ if (!files.length || files.length > 1024)
128
+ return false;
129
+ for (const name of files) {
130
+ if (typeof name !== "string" || !/^[A-Za-z0-9._-]+\.safetensors$/.test(name))
131
+ return false;
132
+ try {
133
+ const entry = await lstat(join(directory, name));
134
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size === 0)
135
+ return false;
136
+ }
137
+ catch (error) {
138
+ if (error.code === "ENOENT")
139
+ return false;
140
+ throw error;
141
+ }
142
+ }
143
+ return true;
144
+ }
145
+ async function workerActive(job) {
146
+ if (!Number.isInteger(job?.workerPgid) || job.workerPgid <= 0 || !/^[a-f0-9-]{36}$/.test(job?.workerIdentity ?? ""))
147
+ return false;
148
+ // A persisted group ID can be reused after a crash/reboot. Match this
149
+ // invocation's identity before calling it active; never signal that ID.
150
+ return new Promise((resolve, reject) => {
151
+ execFile("/bin/ps", ["-ww", "-p", String(job.workerPgid), "-o", "command="], { timeout: 2000, maxBuffer: 64 * 1024 }, (error, stdout) => {
152
+ if (error) {
153
+ if (error.code === 1)
154
+ resolve(false);
155
+ else
156
+ reject(new Error("setup_worker_identity_unavailable"));
157
+ }
158
+ else
159
+ resolve(stdout.trim().endsWith(" " + job.workerIdentity));
160
+ });
161
+ });
162
+ }
163
+ export class OmlxSetupManager {
164
+ options;
165
+ root;
166
+ basePath;
167
+ runtime;
168
+ run;
169
+ running;
170
+ leaseHeld = false;
171
+ logBytes = 0;
172
+ logTail = Promise.resolve();
173
+ constructor(options = {}) {
174
+ this.options = options;
175
+ this.root = join(options.home ?? homedir(), ".prism-mcp", "omlx");
176
+ this.basePath = join(this.root, "server");
177
+ this.runtime = join(this.root, "runtime-" + OMLX_SETUP_COMMIT.slice(0, 12));
178
+ this.run = options.run ?? runSetupCommand;
179
+ }
180
+ async privateDirectory(path) {
181
+ await mkdirUsable(path);
182
+ const stat = await lstat(path);
183
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o022) !== 0)
184
+ throw new Error("setup_directory_unsafe");
185
+ }
186
+ async saveJob(job) {
187
+ const temporary = join(this.root, "job-" + job.id + ".tmp");
188
+ await writeFile(temporary, JSON.stringify(job), { mode: 0o600 });
189
+ await rename(temporary, join(this.root, "setup.json"));
190
+ }
191
+ log = (text) => {
192
+ if (this.logBytes >= MAX_LOG_BYTES)
193
+ return;
194
+ const part = text.slice(0, MAX_LOG_BYTES - this.logBytes);
195
+ this.logBytes += Buffer.byteLength(part);
196
+ this.logTail = this.logTail.then(() => appendFile(join(this.root, "setup.log"), part, { mode: 0o600 })).catch(() => { });
197
+ };
198
+ async status() {
199
+ const supported = (this.options.platform ?? platform()) === "darwin" && (this.options.arch ?? arch()) === "arm64"
200
+ && Number((this.options.release ?? release()).split(".")[0]) >= 24;
201
+ const memory = this.options.totalmem ?? totalmem();
202
+ const disk = await (this.options.freeDisk?.() ?? statfs(this.options.home ?? homedir()).then(stat => stat.bavail * stat.bsize));
203
+ const marker = await readJson(join(this.root, "runtime.json"));
204
+ const installed = marker?.commit === OMLX_SETUP_COMMIT && await exists(join(this.runtime, "bin", "omlx"));
205
+ const models = await Promise.all(OMLX_SETUP_MODELS.map(async (model) => {
206
+ const record = await readJson(join(this.root, model.id + ".json"));
207
+ const ready = record?.revision === model.revision && await modelFilesPresent(join(this.basePath, "models", model.id));
208
+ return { ...model, downloaded: Boolean(ready) };
209
+ }));
210
+ let job = await readJson(join(this.root, "setup.json"));
211
+ if (job?.state === "running" && !await this.setupActive())
212
+ job = await workerActive(job)
213
+ ? { ...job, error: "A previous setup worker is still stopping. Wait before retrying." }
214
+ : { ...job, state: "interrupted", error: "Setup was interrupted. Retry to resume the downloads." };
215
+ let server = { connected: false, models: [] };
216
+ if (await exists(join(this.basePath, "settings.json"))) {
217
+ try {
218
+ const inspection = await (this.options.inspect ?? inspectOmlx)({ home: this.options.home, basePath: this.basePath, env: {} });
219
+ server = { connected: true, models: inspection.advertised_models };
220
+ }
221
+ catch { /* Availability is separate from installation. */ }
222
+ }
223
+ return { supported, memoryGB: Math.floor(memory / GIB), freeDiskGB: Math.floor(disk / GIB),
224
+ requirements: { memoryGB: MIN_RAM / GIB, freeDiskGB: installed && models.every(model => model.downloaded) ? 0 :
225
+ 10 + (installed ? 0 : 5) + models.filter(model => !model.downloaded).reduce((sum, model) => sum + model.approximateGB, 0) }, installed, models, job: job ?? null, server };
226
+ }
227
+ async acquire() {
228
+ const lease = createServer(socket => socket.destroy());
229
+ await new Promise((resolve, reject) => {
230
+ lease.once("error", () => reject(new OmlxSetupError("Another oMLX setup is running. Wait for it to finish.")));
231
+ lease.listen({ host: "127.0.0.1", port: this.options.leasePort ?? SETUP_LEASE_PORT, exclusive: true }, resolve);
232
+ });
233
+ this.leaseHeld = true;
234
+ return () => new Promise(resolve => lease.close(() => { this.leaseHeld = false; resolve(); }));
235
+ }
236
+ async setupActive() {
237
+ if (this.leaseHeld)
238
+ return true;
239
+ if (this.options.leasePort === 0)
240
+ return false; // Isolated test instance.
241
+ return new Promise((resolve, reject) => {
242
+ const socket = createConnection({ host: "127.0.0.1", port: this.options.leasePort ?? SETUP_LEASE_PORT });
243
+ socket.once("connect", () => { socket.destroy(); resolve(true); });
244
+ socket.once("error", error => error.code === "ECONNREFUSED" ? resolve(false) : reject(error));
245
+ socket.setTimeout(1000, () => { socket.destroy(); reject(new Error("setup_lease_state_unavailable")); });
246
+ });
247
+ }
248
+ async install() {
249
+ if (this.running)
250
+ throw new OmlxSetupError("oMLX setup is already running.");
251
+ const releaseLease = await this.acquire();
252
+ try {
253
+ const state = await this.status();
254
+ if (await workerActive(state.job ?? undefined))
255
+ throw new OmlxSetupError("A previous setup worker is still running. Wait before retrying.");
256
+ if (!state.supported)
257
+ throw new OmlxSetupError("oMLX setup requires Apple Silicon and macOS 15 or later.");
258
+ if (state.memoryGB < MIN_RAM / GIB)
259
+ throw new OmlxSetupError("The recommended model pack requires at least 32 GiB of unified memory.");
260
+ if (state.freeDiskGB < state.requirements.freeDiskGB)
261
+ throw new OmlxSetupError("Free at least " + state.requirements.freeDiskGB + " GiB of disk space to install the remaining stages.");
262
+ let python;
263
+ for (const candidate of ["python3.13", "python3.12", "python3.11", "python3"]) {
264
+ try {
265
+ const version = await this.run(candidate, ["-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"], { timeoutMs: 5_000 });
266
+ if (/^3\.(11|12|13)$/.test(version.trim())) {
267
+ python = candidate;
268
+ break;
269
+ }
270
+ }
271
+ catch { /* Try the next supported interpreter. */ }
272
+ }
273
+ if (!python)
274
+ throw new OmlxSetupError("Install Python 3.11–3.13, then retry. Existing Python environments are preserved.");
275
+ await this.privateDirectory(this.root);
276
+ const job = { id: randomUUID(), state: "running", stage: "Preparing oMLX", pid: process.pid, startedAt: new Date().toISOString() };
277
+ await this.saveJob(job);
278
+ this.logBytes = 0;
279
+ await writeFile(join(this.root, "setup.log"), "", { mode: 0o600 });
280
+ this.running = this.performInstall(python, state, job).catch(async () => {
281
+ await this.saveJob({ ...job, state: "failed", error: "Setup failed during " + job.stage + ". Retry to resume. Details are in ~/.prism-mcp/omlx/setup.log." });
282
+ }).finally(async () => { await this.logTail; this.running = undefined; await releaseLease(); });
283
+ void this.running.catch(() => console.error("[omlx setup] Unable to persist setup status."));
284
+ return { id: job.id, state: "running" };
285
+ }
286
+ catch (error) {
287
+ await releaseLease();
288
+ throw error;
289
+ }
290
+ }
291
+ async performInstall(python, state, job) {
292
+ const stage = async (label) => { job.stage = label; await this.saveJob(job); };
293
+ const command = async (program, args, options) => {
294
+ try {
295
+ return await this.run(program, args, { ...options, onSpawn: async (pgid, identity) => { job.workerPgid = pgid; job.workerIdentity = identity; await this.saveJob(job); } });
296
+ }
297
+ finally {
298
+ delete job.workerPgid;
299
+ delete job.workerIdentity;
300
+ await this.saveJob(job);
301
+ }
302
+ };
303
+ if (!state.installed) {
304
+ await stage("Installing isolated Python runtime");
305
+ await command(python, ["-m", "venv", this.runtime], { timeoutMs: 60_000, log: this.log });
306
+ await stage("Installing pinned oMLX server");
307
+ await command(join(this.runtime, "bin", "python"), ["-m", "pip", "install", "git+https://github.com/dcostenco/omlx.git@" + OMLX_SETUP_COMMIT], { timeoutMs: INSTALL_TIMEOUT_MS, log: this.log });
308
+ await command(join(this.runtime, "bin", "python"), ["-c", "import omlx.api.prism_control_routes; import huggingface_hub"], { timeoutMs: 30_000, log: this.log });
309
+ await writeFile(join(this.root, "runtime.json"), JSON.stringify({ commit: OMLX_SETUP_COMMIT }), { mode: 0o600 });
310
+ }
311
+ await this.privateDirectory(this.basePath);
312
+ for (const model of state.models) {
313
+ if (model.downloaded)
314
+ continue;
315
+ await stage("Downloading " + model.label);
316
+ const destination = join(this.basePath, "models", model.id);
317
+ await command(join(this.runtime, "bin", "hf"), ["download", model.repo, "--revision", model.revision, "--local-dir", destination], { timeoutMs: DOWNLOAD_TIMEOUT_MS, log: this.log });
318
+ if (!await modelFilesPresent(destination))
319
+ throw new Error("model_download_incomplete");
320
+ await writeFile(join(this.root, model.id + ".json"), JSON.stringify({ revision: model.revision }), { mode: 0o600 });
321
+ }
322
+ await stage("Configuring the managed server");
323
+ const settings = { server: { host: "127.0.0.1", port: SERVER_PORT },
324
+ model: { model_dirs: [join(this.basePath, "models")] }, scheduler: { chunked_prefill: true },
325
+ huggingface: { hf_cache_enabled: false },
326
+ auth: { api_key: randomBytes(32).toString("hex"), skip_api_key_verification: false },
327
+ sampling: { max_context_window: 32768, max_tokens: 4096 },
328
+ cache: { ssd_cache_max_size: "10GB", hot_cache_max_size: "0" },
329
+ idle_timeout: { idle_timeout_seconds: 300 },
330
+ memory: { prefill_memory_guard: true, memory_guard_tier: "balanced" } };
331
+ const profiles = Object.fromEntries(OMLX_SETUP_MODELS.map((model, index) => [model.id, {
332
+ max_context_window: 32768, max_tokens: 4096, is_default: index === 1, is_pinned: false,
333
+ enable_thinking: index === 1, thinking_budget_enabled: index === 1, thinking_budget_tokens: 2048,
334
+ mtp_enabled: false, vlm_mtp_enabled: false, dflash_enabled: false, specprefill_enabled: false,
335
+ }]));
336
+ for (const [name, document] of [["settings.json", settings], ["model_settings.json", { version: 1, models: profiles }]]) {
337
+ try {
338
+ await writeFile(join(this.basePath, name), JSON.stringify(document, null, 2), { flag: "wx", mode: 0o600 });
339
+ }
340
+ catch (error) {
341
+ if (error.code !== "EEXIST")
342
+ throw error;
343
+ }
344
+ }
345
+ await this.saveJob({ ...job, state: "complete", stage: "Server and recommended models installed" });
346
+ }
347
+ async waitForInstall() { await this.running; }
348
+ async start() {
349
+ const releaseLease = await this.acquire();
350
+ try {
351
+ const state = await this.status();
352
+ if (await workerActive(state.job ?? undefined))
353
+ throw new OmlxSetupError("A previous setup worker is still running. Wait before starting the server.");
354
+ if (state.server.connected)
355
+ return { running: true, alreadyRunning: true };
356
+ if (!state.supported || !state.installed || state.models.some(model => !model.downloaded))
357
+ throw new OmlxSetupError("Install oMLX and both recommended models before starting the managed server.");
358
+ const probe = createServer();
359
+ await new Promise((resolve, reject) => {
360
+ probe.once("error", () => reject(new OmlxSetupError("Port 8001 is already in use. The existing process was left running.")));
361
+ probe.listen({ host: "127.0.0.1", port: this.options.serverPort ?? SERVER_PORT, exclusive: true }, resolve);
362
+ });
363
+ await new Promise(resolve => probe.close(() => resolve()));
364
+ const log = await open(join(this.root, "server.log"), "a", 0o600);
365
+ const env = Object.fromEntries(["PATH", "HOME", "TMPDIR", "LANG"].flatMap(key => process.env[key] ? [[key, process.env[key]]] : []));
366
+ // The managed runtime must neither discover shared cached weights
367
+ // nor replace the interpreter advertised by another installation.
368
+ env.HF_HUB_CACHE = join(this.basePath, "hf-cache");
369
+ env.OMLX_CLUSTER_SHIM_DIRECTORY = join(this.basePath, "bin");
370
+ let child;
371
+ try {
372
+ child = spawn(join(this.runtime, "bin", "omlx"), ["serve", "--base-path", this.basePath], { env, detached: true, stdio: ["ignore", log.fd, log.fd] });
373
+ await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", () => reject(new Error("Unable to start the managed oMLX server."))); });
374
+ child.unref();
375
+ }
376
+ finally {
377
+ await log.close();
378
+ }
379
+ // Keep the setup lease through startup. A second dashboard or CLI
380
+ // cannot spawn a duplicate while this process has not yet bound.
381
+ const deadline = Date.now() + (this.options.startTimeoutMs ?? 90_000);
382
+ let interrupted;
383
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
384
+ const handlers = signals.map(signal => { const handler = () => { interrupted = signal; }; process.on(signal, handler); return handler; });
385
+ const onExit = () => { try {
386
+ process.kill(-child.pid, "SIGTERM");
387
+ }
388
+ catch { /* stopped */ } };
389
+ process.once("exit", onExit);
390
+ try {
391
+ while (Date.now() < deadline) {
392
+ if (interrupted)
393
+ throw new Error("server_start_interrupted");
394
+ if (child.exitCode !== null || child.signalCode !== null)
395
+ throw new Error("server_exited");
396
+ const state = await this.status();
397
+ if (state.server.connected)
398
+ return { running: true, alreadyRunning: false, pid: child.pid };
399
+ await new Promise(resolve => setTimeout(resolve, 500));
400
+ }
401
+ throw new Error("server_start_timeout");
402
+ }
403
+ catch {
404
+ // Only this invocation's still-starting child is terminated.
405
+ if (child.exitCode === null && child.signalCode === null) {
406
+ const closed = new Promise(resolve => child.once("close", () => resolve()));
407
+ try {
408
+ process.kill(-child.pid, "SIGTERM");
409
+ }
410
+ catch { /* exited */ }
411
+ const killTimer = setTimeout(() => { try {
412
+ process.kill(-child.pid, "SIGKILL");
413
+ }
414
+ catch { /* exited */ } }, 2_000);
415
+ await closed;
416
+ clearTimeout(killTimer);
417
+ }
418
+ throw new OmlxSetupError("Managed oMLX did not start. Check ~/.prism-mcp/omlx/server.log, then retry. Existing servers were left running.");
419
+ }
420
+ finally {
421
+ process.removeListener("exit", onExit);
422
+ signals.forEach((signal, index) => process.removeListener(signal, handlers[index]));
423
+ if (interrupted)
424
+ setImmediate(() => process.kill(process.pid, interrupted));
425
+ }
426
+ }
427
+ finally {
428
+ await releaseLease();
429
+ }
430
+ }
431
+ }
432
+ export const omlxSetup = new OmlxSetupManager();
@@ -0,0 +1,23 @@
1
+ /** Isolated setup supervisor. The parent persists its process group before
2
+ * sending the command; disconnect also stops the entire owned group. */
3
+ export const OMLX_SETUP_WORKER_SOURCE = String.raw `
4
+ const {spawn} = require('node:child_process');
5
+ let started = false, stopping = false;
6
+ function stop() {
7
+ if (stopping) return;
8
+ stopping = true;
9
+ if (process.platform === 'win32') process.exit(1);
10
+ process.kill(-process.pid, 'SIGTERM');
11
+ setTimeout(() => process.kill(-process.pid, 'SIGKILL'), 1800);
12
+ }
13
+ ['SIGINT','SIGTERM','SIGHUP'].forEach(signal => process.on(signal, stop));
14
+ process.on('disconnect', stop);
15
+ const waiting = setTimeout(stop, 30000);
16
+ process.once('message', message => {
17
+ if (started || stopping) return;
18
+ started = true; clearTimeout(waiting);
19
+ const child = spawn(message.program, message.args, {stdio:['ignore','inherit','inherit'], shell:false});
20
+ child.once('error', () => { if (!stopping) process.exit(1); });
21
+ child.once('close', code => { if (!stopping) process.exit(code === 0 ? 0 : 1); });
22
+ });
23
+ `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.22.3",
3
+ "version": "20.22.4",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
5
  "description": "Persistent session memory for AI coding agents that never leaves your machine — including the on-device model that reasons over it. Restores your prior decisions, open TODOs, and changed files across sessions; adds associative recall of related past work, semantic drift detection, and local inference. Local-first by default. Works with Claude Code, Cursor, and Codex.",
6
6
  "module": "index.ts",