@tiens.nguyen/gu-cli 1.0.686

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 (43) hide show
  1. package/README.md +52 -0
  2. package/agent-model-command.mjs +259 -0
  3. package/agent-model-label.mjs +159 -0
  4. package/clear-state.mjs +149 -0
  5. package/client-expert-api.mjs +736 -0
  6. package/client-expert-run.mjs +892 -0
  7. package/client-expert-setup.mjs +616 -0
  8. package/coding-choice-tags.mjs +69 -0
  9. package/coding-key-prompt.mjs +229 -0
  10. package/coding-provider-setup.mjs +808 -0
  11. package/completed-flush.mjs +105 -0
  12. package/daemon-control.mjs +462 -0
  13. package/device-login.mjs +212 -0
  14. package/doctor-check.mjs +239 -0
  15. package/embed-model-command.mjs +157 -0
  16. package/first-run-steps.mjs +171 -0
  17. package/gonext_agent_chat.py +12299 -0
  18. package/gonext_mlx_embed.py +155 -0
  19. package/gonext_probe_agent.py +93 -0
  20. package/gonext_transcribe.py +130 -0
  21. package/gu-cli.mjs +4930 -0
  22. package/gu-repl.mjs +10326 -0
  23. package/job-pools.mjs +89 -0
  24. package/model-doctor.mjs +1494 -0
  25. package/node-version.mjs +40 -0
  26. package/ollama-setup.mjs +832 -0
  27. package/package.json +100 -0
  28. package/platform-tools.mjs +520 -0
  29. package/poll-errors.mjs +141 -0
  30. package/proxy-command.mjs +165 -0
  31. package/proxy-config.mjs +255 -0
  32. package/proxy-dispatcher.mjs +132 -0
  33. package/proxy-selftest.mjs +234 -0
  34. package/proxy-store.mjs +69 -0
  35. package/rag-job-config.mjs +59 -0
  36. package/rag-selftest.mjs +215 -0
  37. package/s3-setup.mjs +85 -0
  38. package/terminal-copy.mjs +248 -0
  39. package/terminal-hover.mjs +153 -0
  40. package/terminal-layout.mjs +2507 -0
  41. package/terminal-viewport.mjs +602 -0
  42. package/thinking_words.txt +1003 -0
  43. package/version-check.mjs +72 -0
@@ -0,0 +1,1494 @@
1
+ // Model bootstrap for the gu CLI (task #127, Phase 3). Ensures the base CHAT model
2
+ // (mlx_lm.server) is downloaded and RUNNING so a fresh Mac can answer without GoTerminal or
3
+ // the Wizard doing it. The agent CODE model (Ollama) is probed + guided, not auto-pulled.
4
+ //
5
+ // BC3 (task #127): NEVER touch a model that is already running. The doctor probes first and
6
+ // does nothing when a server answers — it will not kill, restart, re-port, or re-download a
7
+ // model the user already runs (many run mlx_lm.server themselves on 8090, or point at a
8
+ // remote box). Downloads happen only when the model dir is genuinely absent AND the user
9
+ // opts in.
10
+ import { spawn, execFile } from "node:child_process";
11
+ import { open, mkdir, readdir, writeFile } from "node:fs/promises";
12
+ import { arch, homedir, platform, totalmem } from "node:os";
13
+ import { join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ // Minimum RAM to run the default 14B-4bit chat model comfortably on Apple Silicon.
17
+ const MIN_CHAT_RAM_GB = 16;
18
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
19
+
20
+ // The RAG embedding model + server (task #127 — parity with the Wizard's model-embed step).
21
+ // EMBED_PID_FILE / EMBED_LOG_FILE (which need DIR) are defined below, after DIR.
22
+ export const EMBED_REPO = "mlx-community/Qwen3-Embedding-8B-4bit-DWQ";
23
+ export const EMBED_DIR = join(homedir(), "mlx-models", "Qwen3-Embedding-8B-4bit-DWQ");
24
+ export const EMBED_PORT = 8085;
25
+ const EMBED_SCRIPT = fileURLToPath(new URL("./gonext_mlx_embed.py", import.meta.url));
26
+
27
+ /**
28
+ * The python the worker uses to run its agent / model tooling (mirrors the daemon).
29
+ *
30
+ * Windows has no `python3`, and the name that IS on PATH there is usually the Microsoft Store
31
+ * alias stub, which exits without running anything. `py` is the launcher the official
32
+ * installer registers. Reported live: `gu-cli doctor` on Windows 11 said "python3 not
33
+ * found" on a machine with a working Python.
34
+ *
35
+ * platform-ok: this is a SYNC default, deliberately — python3Bin() is called inline from a
36
+ * dozen places. resolvePython() in platform-tools.mjs is the authoritative version and PROVES
37
+ * the interpreter runs; this only has to stop naming one that cannot exist.
38
+ */
39
+ function python3Bin() {
40
+ const explicit = (process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "").trim();
41
+ if (explicit) return explicit;
42
+ return process.platform === "win32" ? "py" : "python3";
43
+ }
44
+
45
+ /**
46
+ * How to install Python HERE. The advice used to say "Xcode Command Line Tools" on every
47
+ * platform, which a Windows user cannot act on at all.
48
+ */
49
+ export function pythonInstallHint(platform = process.platform) {
50
+ if (platform === "win32") return "winget install Python.Python.3.12, or python.org";
51
+ if (platform === "darwin") return "Xcode Command Line Tools, or python.org";
52
+ return "your package manager, e.g. sudo apt install python3";
53
+ }
54
+
55
+ /** True when `python3 -c "import <mod>"` succeeds — i.e. the module is installed. */
56
+ function pyHasModule(mod) {
57
+ return new Promise((resolve) => {
58
+ execFile(python3Bin(), ["-c", `import ${mod}`], (err) => resolve(!err));
59
+ });
60
+ }
61
+
62
+ /**
63
+ * True when the agent framework can actually be imported. Checked INDEPENDENTLY of any model
64
+ * server (task #134): the deps offer used to be reached only when the chat model was down, so
65
+ * a client pointing at a healthy REMOTE model was never offered them and every turn died with
66
+ * "No module named 'smolagents'".
67
+ */
68
+ export async function hasAgentLibs() {
69
+ return (await pyHasModule("smolagents")) && (await pyHasModule("openai"));
70
+ }
71
+
72
+ /**
73
+ * The ABSOLUTE path of the interpreter `python3Bin()` resolves to right now (sys.executable).
74
+ * "python3" is resolved from PATH at spawn time, and the interactive shell's PATH is often
75
+ * NOT the detached daemon's — with conda active, `gu doctor` installs into
76
+ * ~/miniconda3/.../site-packages while the daemon runs /usr/bin/python3 and still reports
77
+ * "No module named 'smolagents'" (observed live on a client machine). Pinning the exact
78
+ * interpreter that received the packages is what makes the install stick.
79
+ */
80
+ export function resolvePythonPath() {
81
+ return new Promise((resolve) => {
82
+ execFile(python3Bin(), ["-c", "import sys; print(sys.executable)"], (err, stdout) =>
83
+ resolve(err ? "" : String(stdout).trim())
84
+ );
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Persist that interpreter into ~/.gonext/worker.env as GONEXT_PROBE_PYTHON, so every future
90
+ * daemon uses it regardless of the PATH it happens to start with. Merges — never rewrites the
91
+ * file from scratch. Returns the path written, or "" when nothing needed doing.
92
+ */
93
+ export async function pinPythonForDaemon({ log = () => {} } = {}) {
94
+ const exe = await resolvePythonPath();
95
+ if (!exe) return "";
96
+ const envFile = join(DIR, "worker.env");
97
+ let prev = "";
98
+ try {
99
+ prev = await (await import("node:fs/promises")).readFile(envFile, "utf8");
100
+ } catch {
101
+ /* not configured yet — nothing to pin into */
102
+ }
103
+ if (!prev) return "";
104
+ if (new RegExp(`^GONEXT_PROBE_PYTHON=${exe.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "m").test(prev)) {
105
+ return exe; // already pinned to this interpreter
106
+ }
107
+ const withoutOld = prev.replace(/^GONEXT_PROBE_PYTHON=.*$\n?/m, "");
108
+ const next = `${withoutOld.replace(/\n*$/, "\n")}GONEXT_PROBE_PYTHON=${exe}\n`;
109
+ await (await import("node:fs/promises")).writeFile(envFile, next, { encoding: "utf8", mode: 0o600 });
110
+ log(` · pinned the agent's python to ${exe}`);
111
+ log(" (the background worker must use the SAME interpreter these packages went into)");
112
+ return exe;
113
+ }
114
+
115
+ /**
116
+ * Is this pip failure PEP 668 — a python the OS manages and refuses to let pip touch?
117
+ *
118
+ * Ubuntu 24.04, Debian 12+ and Fedora 38+ all ship one. `pip install` there exits non-zero with
119
+ * "error: externally-managed-environment" and installs nothing, which is why doctor's own advice
120
+ * ("python3 -m pip install -U smolagents openai") could not work on any current Linux: the
121
+ * command it printed was the command that fails. Found by running a real Ubuntu 24.04 container.
122
+ *
123
+ * Pure and exported so the branch is testable without a distro that has the marker file.
124
+ */
125
+ export function isExternallyManaged(output) {
126
+ // BOTH wordings, because pip prints the words in opposite orders in the same failure: the
127
+ // machine-readable "error: externally-managed-environment" and the human "× This environment
128
+ // is externally managed". Matching only the first passed a test written from memory and
129
+ // missed half of the real output.
130
+ return /externally[\s-]?managed/i.test(String(output ?? ""));
131
+ }
132
+
133
+ /** Run pip once. Resolves { code, err } — never rejects, so the caller owns the policy. */
134
+ function runPip(python, pkgs, extraArgs = []) {
135
+ return new Promise((resolve) => {
136
+ const child = spawn(python, ["-m", "pip", "install", "-U", ...extraArgs, ...pkgs], {
137
+ // stderr is PIPED so the PEP 668 marker can be recognised, and echoed as it arrives so
138
+ // the user still watches a real install rather than a frozen line.
139
+ stdio: ["ignore", "inherit", "pipe"],
140
+ env: process.env,
141
+ });
142
+ let err = "";
143
+ child.stderr?.on("data", (d) => {
144
+ const s = String(d);
145
+ err += s;
146
+ process.stderr.write(s);
147
+ });
148
+ child.on("exit", (code) => resolve({ code: code ?? 1, err }));
149
+ child.on("error", (e) => resolve({ code: -1, err: String(e?.message ?? e) }));
150
+ });
151
+ }
152
+
153
+ /**
154
+ * A private virtualenv at ~/.gonext/venv, created on demand. → its python, or "".
155
+ *
156
+ * THE RIGHT ANSWER TO PEP 668, and the machinery for it already existed: the daemon's
157
+ * interpreter is pinned through GONEXT_PROBE_PYTHON precisely so the packages and the process
158
+ * that imports them cannot drift apart. A venv is that same idea, one step earlier.
159
+ *
160
+ * Preferred over --break-system-packages because pip's own message is right — writing into a
161
+ * distro-managed python can conflict with apt's copies of the same libraries and leave the
162
+ * system's own tooling broken. A venv is removable; a mangled system python is a reinstall.
163
+ */
164
+ async function ensureVenv(log) {
165
+ const { existsSync } = await import("node:fs");
166
+ const venvDir = join(DIR, "venv");
167
+ const py = join(venvDir, process.platform === "win32" ? "Scripts" : "bin",
168
+ process.platform === "win32" ? "python.exe" : "python");
169
+ if (existsSync(py)) return py;
170
+ log(` creating a private virtualenv at ${venvDir} …`);
171
+ const made = await new Promise((resolve) => {
172
+ const child = spawn(python3Bin(), ["-m", "venv", venvDir], { stdio: ["ignore", "inherit", "inherit"], env: process.env });
173
+ child.on("exit", (code) => resolve(code === 0));
174
+ child.on("error", () => resolve(false));
175
+ });
176
+ if (made && existsSync(py)) return py;
177
+ // Debian/Ubuntu split venv into its own package, so this is a real and common outcome.
178
+ log(" ⚠ could not create a virtualenv (on Debian/Ubuntu: sudo apt install python3-venv)");
179
+ return "";
180
+ }
181
+
182
+ /**
183
+ * pip install -U <pkgs>, on a python that may refuse to be written to.
184
+ *
185
+ * Three attempts, in the order of least damage: the plain install; then a private venv, whose
186
+ * interpreter is PINNED so the daemon uses the same one the packages went into; and only if a
187
+ * venv cannot be built, --break-system-packages, which is announced rather than done quietly.
188
+ */
189
+ async function pipInstall(pkgs, log) {
190
+ log(` installing ${pkgs.join(" ")} … (pip, this can take a minute)`);
191
+ let r = await runPip(python3Bin(), pkgs);
192
+ if (r.code === 0) return;
193
+ if (!isExternallyManaged(r.err)) {
194
+ throw new Error(`pip install ${pkgs.join(" ")} exited ${r.code}`);
195
+ }
196
+
197
+ log(" this python is managed by your OS (PEP 668) — it will not accept packages directly.");
198
+ const venvPy = await ensureVenv(log);
199
+ if (venvPy) {
200
+ r = await runPip(venvPy, pkgs);
201
+ if (r.code === 0) {
202
+ // Pin it NOW, not at the end of doctor: the packages live in this interpreter and
203
+ // nothing else can import them, so a daemon started meanwhile would still see none.
204
+ process.env.GONEXT_PROBE_PYTHON = venvPy;
205
+ await pinPythonForDaemon({ log });
206
+ log(` installed into the virtualenv and pinned the agent's python to ${venvPy}`);
207
+ return;
208
+ }
209
+ }
210
+
211
+ log(" falling back to --break-system-packages (no virtualenv available)");
212
+ r = await runPip(python3Bin(), pkgs, ["--break-system-packages"]);
213
+ if (r.code === 0) return;
214
+ throw new Error(`pip install ${pkgs.join(" ")} exited ${r.code}`);
215
+ }
216
+
217
+ /**
218
+ * The yellow RAM notice shown before the multi-GB model download (user request): the
219
+ * default 14B-4bit model wants a Mac with ≥16 GB. When the machine has less, add a plain
220
+ * line stating its detected amount. Pure (RAM passed in) so it's unit-testable.
221
+ * Returns already-colored lines; empty array only if `repo` is falsy.
222
+ */
223
+ export function chatRamWarningLines(repo, totalBytes) {
224
+ if (!repo) return [];
225
+ const ramGb = Math.round((Number(totalBytes) || 0) / 1024 ** 3);
226
+ const name = String(repo).split("/").pop();
227
+ const out = [
228
+ yellow(` ⚠ ${name} needs a Mac with at least ${MIN_CHAT_RAM_GB} GB of RAM to run well.`),
229
+ ];
230
+ if (ramGb > 0 && ramGb < MIN_CHAT_RAM_GB) {
231
+ out.push(
232
+ yellow(
233
+ ` This machine has about ${ramGb} GB — the model may run very slowly or fail to load.`
234
+ )
235
+ );
236
+ }
237
+ return out;
238
+ }
239
+
240
+ const DIR = join(homedir(), ".gonext");
241
+ export const MODEL_PID_FILE = join(DIR, "model.pid");
242
+ export const MODEL_LOG_FILE = join(DIR, "model.log");
243
+ const EMBED_PID_FILE = join(DIR, "embed.pid");
244
+ const EMBED_LOG_FILE = join(DIR, "embed.log");
245
+
246
+ // The default base chat model (task #127 decision): matches the Wizard's model-chat step.
247
+ export const DEFAULT_CHAT_REPO = "mlx-community/Qwen3-14B-4bit";
248
+ export const DEFAULT_CHAT_DIR = join(homedir(), "mlx-models", "Qwen3-14B-4bit");
249
+ export const DEFAULT_CHAT_PORT = 8082;
250
+
251
+ // ---- pure helpers (unit-tested offline) -------------------------------------------------
252
+
253
+ /** The port from a base URL, or null. "http://127.0.0.1:8082/v1" → 8082. */
254
+ export function parsePort(baseUrl) {
255
+ try {
256
+ const u = new URL(String(baseUrl || "").includes("://") ? baseUrl : `http://${baseUrl}`);
257
+ if (u.port) return Number(u.port);
258
+ return u.protocol === "https:" ? 443 : 80;
259
+ } catch {
260
+ const m = String(baseUrl || "").match(/:(\d{2,5})(?:\/|$)/);
261
+ return m ? Number(m[1]) : null;
262
+ }
263
+ }
264
+
265
+ /** True when the URL points at this machine (loopback/LAN) — only then can we START it. */
266
+ export function isLocalModelUrl(baseUrl) {
267
+ let host = "";
268
+ try {
269
+ host = new URL(String(baseUrl || "").includes("://") ? baseUrl : `http://${baseUrl}`)
270
+ .hostname.toLowerCase()
271
+ .replace(/\.$/, "");
272
+ } catch {
273
+ return false;
274
+ }
275
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
276
+ if (!host) return false;
277
+ if (["localhost", "0.0.0.0", "::", "::1"].includes(host)) return true;
278
+ if (/^127(\.\d{1,3}){3}$/.test(host)) return true;
279
+ if (
280
+ /^(10(\.\d{1,3}){3}|192\.168(\.\d{1,3}){2}|172\.(1[6-9]|2\d|3[01])(\.\d{1,3}){2}|169\.254(\.\d{1,3}){2})$/.test(
281
+ host
282
+ )
283
+ )
284
+ return true;
285
+ if (host.includes(":") && (host.startsWith("fe80:") || /^f[cd]/.test(host))) return true;
286
+ if (/\.(local|localdomain|lan|home|internal)$/.test(host)) return true;
287
+ return !host.includes(".") && !host.includes(":");
288
+ }
289
+
290
+ /** Normalize a base URL to its root (strip a trailing /v1 and slashes). */
291
+ export function modelRoot(baseUrl) {
292
+ return String(baseUrl || "").replace(/\/+$/, "").replace(/\/v1$/i, "");
293
+ }
294
+
295
+ /**
296
+ * Expand a leading "~" to the home directory. The web app asks for a model FOLDER (task
297
+ * #133) and people type "~/mlx-models/…" — the shell would expand that, but a value coming
298
+ * from a settings field never passes through a shell, so it must be expanded here or the
299
+ * folder check fails on a path that is perfectly correct.
300
+ */
301
+ export function expandHome(p) {
302
+ const s = String(p || "").trim();
303
+ if (!s) return "";
304
+ if (s === "~") return homedir();
305
+ if (s.startsWith("~/")) return join(homedir(), s.slice(2));
306
+ return s;
307
+ }
308
+
309
+ // ---- running-model discovery (task #133: `gu-cli status`) ---------------------------
310
+ // Everything below is parsed from `ps`, NOT from a hand-maintained port list or our own
311
+ // pidfiles — so a server the user started themselves (the BC3 case: `mlx_lm.server` on 8090,
312
+ // a second coder model, …) shows up in the table exactly like one the doctor started.
313
+
314
+ /**
315
+ * Parse `ps -Ao pid=,etime=,args=` output into local model-server rows.
316
+ * Pure (takes the text) so it can be unit-tested without spawning anything.
317
+ * Returns [{ pid, etime, port, kind, modelPath }], one per PID, sorted by port.
318
+ */
319
+ export function parseModelServerRows(psOutput) {
320
+ const rows = [];
321
+ const seen = new Set();
322
+ for (const raw of String(psOutput || "").split("\n")) {
323
+ const line = raw.trim();
324
+ if (!line) continue;
325
+ const m = line.match(/^(\d+)\s+(\S+)\s+(.*)$/);
326
+ if (!m) continue;
327
+ const [, pidStr, etime, cmd] = m;
328
+ // Our own `ps`/grep invocations mention these strings without being servers.
329
+ if (/\bgrep\b/.test(cmd)) continue;
330
+ // `mlx_lm.server` or `mlx_lm server` (python -m mlx_lm server); the embed server is our
331
+ // own script. Anything else on the box is not ours to list.
332
+ const isEmbed = /gonext_mlx_embed\.py/.test(cmd);
333
+ const isMlx = /mlx_lm[.\s]server\b/.test(cmd);
334
+ if (!isEmbed && !isMlx) continue;
335
+ const pid = Number(pidStr);
336
+ if (seen.has(pid)) continue;
337
+ seen.add(pid);
338
+ const portMatch = cmd.match(/--port(?:=|\s+)(\d{2,5})/);
339
+ // Defaults when --port is omitted: mlx_lm.server → 8080, our embed script → 8085.
340
+ const port = portMatch ? Number(portMatch[1]) : isEmbed ? EMBED_PORT : 8080;
341
+ const modelMatch = cmd.match(/--model(?:=|\s+)(\S+)/);
342
+ rows.push({
343
+ pid,
344
+ etime,
345
+ port,
346
+ kind: isEmbed ? "embed" : "mlx-lm",
347
+ modelPath: modelMatch ? modelMatch[1] : "",
348
+ });
349
+ }
350
+ return rows.sort((a, b) => a.port - b.port || a.pid - b.pid);
351
+ }
352
+
353
+ /**
354
+ * The ROLE a server plays for gu. When the account's settings are known (fetched from
355
+ * the DB — what the user picked in the web app, Settings → Agent) the roles come from THOSE
356
+ * ports, which is the only correct answer: a user who runs their agent model on 8090 has a
357
+ * chat server on 8090, not a nameless one. Falls back to the seeded default ports when the
358
+ * settings couldn't be loaded (offline / not signed in).
359
+ */
360
+ export function roleForServer({ port, kind }, configured = null) {
361
+ if (configured) {
362
+ if (configured.embed?.local && port === configured.embed.port) return "embed";
363
+ if (configured.agent?.local && port === configured.agent.port) return "chat";
364
+ if (configured.coder?.local && port === configured.coder.port) return "coder";
365
+ // A server that isn't any configured role is genuinely the user's own.
366
+ if (kind === "embed") return "embed";
367
+ return "-";
368
+ }
369
+ if (kind === "embed" || port === EMBED_PORT) return "embed";
370
+ if (port === DEFAULT_CHAT_PORT) return "chat";
371
+ return "-";
372
+ }
373
+
374
+ /** Strip a provider prefix and location suffix from a model key: "mlx:Foo@@port:8080" → "Foo". */
375
+ function modelKeyName(key) {
376
+ return String(key || "")
377
+ .split("@@")[0]
378
+ .replace(/^(mlx|ollama|openai|anthropic|groq|deepseek):/i, "")
379
+ .trim();
380
+ }
381
+
382
+ /**
383
+ * What the WEB APP says this account's models are, reduced to something the CLI can check
384
+ * against the local machine. Mirrors the API's own resolveConfiguredAgentModel order: an
385
+ * explicit agentModelUrl wins; otherwise the picked model key carries its own location
386
+ * ("Qwen3-14B-4bit@@port:8080"); otherwise the account's MLX base URL; otherwise our default.
387
+ *
388
+ * Takes the GET /api/worker/settings body. Returns null when settings couldn't be loaded, so
389
+ * callers can fall back to port-based guessing instead of asserting something false.
390
+ */
391
+ export function resolveConfiguredModels(settings) {
392
+ if (!settings) return null;
393
+ const s = settings;
394
+ const hostBase =
395
+ String(s.mlxOpenAiBaseUrl || "").trim() ||
396
+ String(s.hosts?.[0]?.mlxOpenAiBaseUrl || "").trim();
397
+
398
+ const directAgent = String(s.agentModelUrl || "").trim();
399
+ const agentKey = String(s.agentModel || "").trim();
400
+ const agentPortInKey = agentKey.match(/@@port:(\d{2,5})/)?.[1];
401
+ let agentUrl = "";
402
+ let agentSource = "";
403
+ if (directAgent) {
404
+ agentUrl = directAgent;
405
+ agentSource = "Settings → Agent (model URL)";
406
+ } else if (agentPortInKey) {
407
+ agentUrl = `http://127.0.0.1:${agentPortInKey}`;
408
+ agentSource = "Settings → Agent (model)";
409
+ } else if (hostBase) {
410
+ agentUrl = hostBase;
411
+ agentSource = "Settings (MLX server URL)";
412
+ } else {
413
+ agentUrl = `http://127.0.0.1:${DEFAULT_CHAT_PORT}`;
414
+ agentSource = "built-in default";
415
+ }
416
+
417
+ const coderUrl = String(s.agentCodingModelUrl || "").trim();
418
+ // The RAG embedder is ALWAYS an HTTP endpoint — the agent's _embed() only ever POSTs to
419
+ // {base}/v1/embeddings, then Ollama's /api/embed, then /api/embeddings; it never embeds
420
+ // in-process. But it is NOT necessarily a dedicated port of ours: mirror the agent's own
421
+ // fallback chain (gonext_agent_chat.py: rag_embed_base = ragEmbedUrl or coding_base_url or
422
+ // agent_base_url) instead of assuming our 8085 server, or status reports a URL the agent
423
+ // would never call. Only when nothing at all is set is 8085 (what `doctor` seeds) the
424
+ // honest answer.
425
+ // NO fallback (task #133). There used to be a chain: embed URL → CODE model URL → AGENT
426
+ // model URL. But those serve completions, not embeddings — mlx_lm.server has no
427
+ // /v1/embeddings endpoint at all — so the chain could only ever end in a confusing failure
428
+ // against an unrelated server. Either the account configures an embedder (a model folder,
429
+ // or a server URL) or there ISN'T one, and every surface says so plainly.
430
+ const agentIsConfigured = Boolean(directAgent || agentKey || hostBase);
431
+ const explicitEmbed = String(s.ragEmbedUrl || "").trim();
432
+ // A configured model FOLDER means the embedder is the server we run for it on the standard
433
+ // port — the same URL the API derives (resolveRagEmbedUrl), so the user never types a port.
434
+ const embedPath = String(s.ragEmbedModelPath || "").trim();
435
+ const embedUrl = explicitEmbed || (embedPath ? `http://127.0.0.1:${EMBED_PORT}` : "");
436
+ const embedSource = explicitEmbed
437
+ ? "Settings → Agent → RAG (server URL)"
438
+ : embedPath
439
+ ? "Settings → Agent → RAG (model folder)"
440
+ : "NOT CONFIGURED — no embedding model set";
441
+
442
+ const entry = (url, name, source, set) => ({
443
+ url: modelRoot(url),
444
+ port: parsePort(url),
445
+ local: isLocalModelUrl(url),
446
+ model: name,
447
+ source,
448
+ configured: set,
449
+ });
450
+
451
+ return {
452
+ agent: entry(
453
+ agentUrl,
454
+ modelKeyName(agentKey) || String(s.agentModelName || "").trim(),
455
+ agentSource,
456
+ agentIsConfigured
457
+ ),
458
+ embed: {
459
+ ...entry(
460
+ embedUrl,
461
+ // A model FOLDER names itself; the free-text name only matters for Ollama (one
462
+ // process serves many models). Left EMPTY when unset — no "nomic-embed-text" default,
463
+ // which used to show a model the user never chose as if it were in use.
464
+ modelDisplayName(embedPath) || String(s.ragEmbedModel || "").trim(),
465
+ embedSource,
466
+ Boolean(explicitEmbed || embedPath)
467
+ ),
468
+ path: expandHome(embedPath),
469
+ },
470
+ coder: coderUrl
471
+ ? entry(coderUrl, String(s.agentCodingModelName || "").trim(), "Settings → Agent (code model)", true)
472
+ : null,
473
+ };
474
+ }
475
+
476
+ /**
477
+ * POST /api/worker/settings — save a choice made in the terminal back to the ACCOUNT, so it
478
+ * appears in the web app too (task #133). Narrow by design: the API only accepts the fields
479
+ * the CLI setup flow asks about. Returns true on success; never throws (a failed save must
480
+ * not abort setup — the model still gets served from the chosen folder this session).
481
+ */
482
+ export async function saveWorkerSettings(apiBase, workerKey, patch, { timeoutMs = 6000 } = {}) {
483
+ if (!apiBase || !workerKey || !patch || !Object.keys(patch).length) return false;
484
+ try {
485
+ const res = await fetch(`${String(apiBase).replace(/\/+$/, "")}/api/worker/settings`, {
486
+ method: "POST",
487
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
488
+ body: JSON.stringify(patch),
489
+ signal: AbortSignal.timeout(timeoutMs),
490
+ });
491
+ return res.ok;
492
+ } catch {
493
+ return false;
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Is an OpenAI-compatible model endpoint REACHABLE from this machine?
499
+ *
500
+ * Distinct from probeModelServer, which demands a 200 and so reports an authenticated
501
+ * endpoint (a hosted Ollama behind a key, a cloud API) as down. Here ANY HTTP response —
502
+ * including 401/403/404 — proves something is listening and routable; only a connection
503
+ * failure means unreachable. That is the question a client machine actually needs answered:
504
+ * "can I get to my model from here?" (task #134).
505
+ *
506
+ * Returns { reachable, status, error }.
507
+ */
508
+ export async function probeReachable(baseUrl, { timeoutMs = 4000 } = {}) {
509
+ const root = modelRoot(baseUrl);
510
+ if (!root) return { reachable: false, error: "no URL configured" };
511
+ for (const path of ["/v1/models", "/api/tags", "/"]) {
512
+ try {
513
+ const res = await fetch(`${root}${path}`, {
514
+ method: "GET",
515
+ signal: AbortSignal.timeout(timeoutMs),
516
+ });
517
+ return { reachable: true, status: res.status };
518
+ } catch (e) {
519
+ // Only a transport failure counts as unreachable; try the next shape first.
520
+ var last = e?.name === "TimeoutError" ? `no response in ${timeoutMs / 1000}s` : e?.message || String(e);
521
+ }
522
+ }
523
+ return { reachable: false, error: last };
524
+ }
525
+
526
+ /**
527
+ * True when a base URL is an OLLAMA server rather than our MLX embed server. Ollama answers
528
+ * /api/tags; the MLX server does not. Matters because the two need completely different
529
+ * setup: Ollama serves the embedding model itself (`ollama pull qwen3-embedding:8b`) and
530
+ * needs only a model NAME, while MLX needs a downloaded folder and a server we start.
531
+ */
532
+ export async function isOllamaEmbedder(baseUrl, { timeoutMs = 2500 } = {}) {
533
+ return (await classifyEmbedder(baseUrl, { timeoutMs })) === "ollama";
534
+ }
535
+
536
+ /**
537
+ * "ollama" | "other" | "unreachable" | "none" — and the third value is the whole point.
538
+ *
539
+ * isOllamaEmbedder answers a live probe with a boolean, so a box that is DOWN is
540
+ * indistinguishable from a box that is not Ollama. That is not a rounding error, it changes
541
+ * what the doctor does with the machine: seeing `false`, it concludes there is no Ollama
542
+ * embedder, falls through to the MLX branch, asks "Where should the RAG embedding model be
543
+ * stored?" and SAVES an MLX model path onto an account whose embedder is a remote Ollama.
544
+ *
545
+ * Observed exactly that way (2026-09-05): ollama2 was not answering for a moment, and a Client
546
+ * Expert machine came out of `gu` configured with
547
+ * ragEmbedModelPath=/Users/joseph/mlx-models/Qwen3-Embedding-8B-4bit-DWQ — a model that server
548
+ * does not have and never will. Host-mode setup leaking into a machine that is not a host.
549
+ *
550
+ * "I could not tell" must therefore be its own answer, and the caller must not treat it as "no".
551
+ */
552
+ export async function classifyEmbedder(baseUrl, { timeoutMs = 2500 } = {}) {
553
+ const root = modelRoot(baseUrl);
554
+ if (!root) return "none";
555
+ try {
556
+ const res = await fetch(`${root}/api/tags`, { signal: AbortSignal.timeout(timeoutMs) });
557
+ // A REPLY that is not /api/tags is a real "not Ollama": something is serving this URL and
558
+ // it is not an Ollama. Only a failure to reach it at all is unknowable.
559
+ return res.ok ? "ollama" : "other";
560
+ } catch {
561
+ return "unreachable";
562
+ }
563
+ }
564
+
565
+ /** GET /api/worker/settings — the account's model config from the DB. null on any failure. */
566
+ export async function fetchWorkerSettings(apiBase, workerKey, { timeoutMs = 4000 } = {}) {
567
+ if (!apiBase || !workerKey) return null;
568
+ try {
569
+ const res = await fetch(`${String(apiBase).replace(/\/+$/, "")}/api/worker/settings`, {
570
+ headers: { "X-Worker-Key": workerKey },
571
+ signal: AbortSignal.timeout(timeoutMs),
572
+ });
573
+ if (!res.ok) return null;
574
+ return await res.json();
575
+ } catch {
576
+ return null;
577
+ }
578
+ }
579
+
580
+ /** `ps` etime ("1-02:14:33", "02:14:33", "14:33") → a compact "1d2h" / "2h14m" / "14m33s". */
581
+ export function formatEtime(etime) {
582
+ const m = String(etime || "").match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
583
+ if (!m) return String(etime || "-");
584
+ const [, d, h, mi, s] = m.map((x) => (x == null ? 0 : Number(x)));
585
+ if (d) return `${d}d${h}h`;
586
+ if (h) return `${h}h${mi}m`;
587
+ if (mi) return `${mi}m${s}s`;
588
+ return `${s}s`;
589
+ }
590
+
591
+ /** Local model servers running right now, from `ps`. macOS/Linux; [] if ps fails. */
592
+ export async function discoverModelServers() {
593
+ return new Promise((resolve) => {
594
+ execFile(
595
+ "ps",
596
+ ["-Ao", "pid=,etime=,args="],
597
+ { maxBuffer: 8 * 1024 * 1024 },
598
+ (err, stdout) => resolve(err ? [] : parseModelServerRows(stdout))
599
+ );
600
+ });
601
+ }
602
+
603
+ /**
604
+ * The display name for a served model: the LAST path segment of whatever the server was
605
+ * launched with, so "/Users/me/mlx-models/Qwen3-14B-4bit" and "mlx-community/Qwen3-14B-4bit"
606
+ * both read "Qwen3-14B-4bit" — the name used everywhere else (modelKey, Settings, the Wizard).
607
+ */
608
+ export function modelDisplayName(modelPath) {
609
+ const p = String(modelPath || "").replace(/\/+$/, "");
610
+ if (!p) return "";
611
+ return p.split("/").filter(Boolean).pop() || p;
612
+ }
613
+
614
+ /**
615
+ * Add live detail to each discovered row: does it ANSWER (`up`) or is it still loading the
616
+ * weights (`loading`)? Probes all rows in parallel.
617
+ *
618
+ * The model NAME comes from the process's own `--model` argument, never from /v1/models:
619
+ * mlx_lm.server advertises every model it can see in the HF cache, not the one it loaded, so
620
+ * reading the id off that endpoint reports a model this server isn't running (observed live:
621
+ * a server started on Qwen3-14B-4bit listing mlx-community/Llama-3.2-3B-Instruct-4bit first).
622
+ * `--model` is what the process was actually launched with, so it's the only authority.
623
+ */
624
+ export async function inspectModelServers(rows, configured = null) {
625
+ return Promise.all(
626
+ rows.map(async (r) => {
627
+ const base = `http://127.0.0.1:${r.port}`;
628
+ let up = false;
629
+ let probed = "";
630
+ try {
631
+ const res = await fetch(`${base}/v1/models`, { signal: AbortSignal.timeout(2000) });
632
+ up = res.ok;
633
+ if (res.ok && !r.modelPath) {
634
+ // Only when the command line didn't say (a server launched some other way). Prefer
635
+ // a local PATH here — that's the one mlx_lm.server registers for what it loaded.
636
+ const j = await res.json().catch(() => null);
637
+ const ids = (j?.data ?? []).map((d) => d?.id).filter(Boolean);
638
+ probed = ids.find((id) => id.startsWith("/") || id.startsWith("~")) || ids[0] || "";
639
+ }
640
+ } catch {
641
+ up = false;
642
+ }
643
+ return {
644
+ ...r,
645
+ role: roleForServer(r, configured),
646
+ status: up ? "up" : "loading",
647
+ uptime: formatEtime(r.etime),
648
+ model: modelDisplayName(r.modelPath) || modelDisplayName(probed) || "-",
649
+ };
650
+ })
651
+ );
652
+ }
653
+
654
+ /**
655
+ * Parse `lsof -p <pid> -a -d 1 -Ftn` output → the file the process's STDOUT is writing to,
656
+ * or "" when it isn't a regular file. Pure, so the (macOS-specific) parsing is testable.
657
+ *
658
+ * Reading fd 1 rather than assuming our own log path is what makes `logs <port>` work for a
659
+ * server we didn't start: a redirected one (`… > my.log`) is just as readable, and a server
660
+ * attached to a terminal correctly reports nothing instead of us pointing at the wrong file.
661
+ */
662
+ export function parseLsofStdout(out) {
663
+ let type = "";
664
+ let path = "";
665
+ for (const line of String(out || "").split("\n")) {
666
+ if (line.startsWith("t")) type = line.slice(1).trim();
667
+ else if (line.startsWith("n")) path = line.slice(1).trim();
668
+ }
669
+ return type === "REG" && path ? path : "";
670
+ }
671
+
672
+ /** The log file a running server writes to, or "" when its output isn't going to a file. */
673
+ export async function serverLogFile(pid) {
674
+ if (!pid || !Number.isFinite(Number(pid))) return "";
675
+ return new Promise((resolve) => {
676
+ execFile("lsof", ["-p", String(pid), "-a", "-d", "1", "-Ftn"], (err, stdout) =>
677
+ resolve(err ? "" : parseLsofStdout(stdout))
678
+ );
679
+ });
680
+ }
681
+
682
+ // ---- RAG self-test (task #133) -----------------------------------------------------------
683
+ // "Is RAG working?" cannot be answered from settings: the name in the config is free text,
684
+ // the model field is ignored by our MLX embedder, and the rag_* tools only run mid-task. The
685
+ // only honest answer is to actually EMBED something and see what comes back.
686
+
687
+ /**
688
+ * Round-trip a real embedding request. Tries the OpenAI-compatible endpoint first, then
689
+ * Ollama's — the same order the agent's _embed() uses, so a pass here means the agent will
690
+ * work. Returns { ok, dims, ms, endpoint, error }.
691
+ */
692
+ export async function testEmbeddings(baseUrl, model, { timeoutMs = 20000 } = {}) {
693
+ const root = modelRoot(baseUrl).replace(/\/(api\/embed|api\/embeddings|api)$/i, "");
694
+ if (!root) return { ok: false, error: "no embedding server URL configured" };
695
+ const attempts = [
696
+ { path: "/v1/embeddings", body: { model: model || "", input: ["gu embedding self-test"] },
697
+ dims: (j) => j?.data?.[0]?.embedding?.length },
698
+ { path: "/api/embed", body: { model: model || "", input: ["gu embedding self-test"] },
699
+ dims: (j) => j?.embeddings?.[0]?.length },
700
+ ];
701
+ let lastError = "";
702
+ for (const a of attempts) {
703
+ const started = Date.now();
704
+ try {
705
+ const res = await fetch(`${root}${a.path}`, {
706
+ method: "POST",
707
+ headers: { "Content-Type": "application/json" },
708
+ body: JSON.stringify(a.body),
709
+ signal: AbortSignal.timeout(timeoutMs),
710
+ });
711
+ if (!res.ok) {
712
+ lastError = `${a.path} → HTTP ${res.status}`;
713
+ continue;
714
+ }
715
+ const j = await res.json().catch(() => null);
716
+ const dims = a.dims(j);
717
+ if (!dims) {
718
+ lastError = `${a.path} → 200 but no embedding in the response`;
719
+ continue;
720
+ }
721
+ return { ok: true, dims, ms: Date.now() - started, endpoint: `${root}${a.path}` };
722
+ } catch (e) {
723
+ lastError = `${a.path} → ${e?.name === "TimeoutError" ? `no response in ${timeoutMs / 1000}s` : e?.message || e}`;
724
+ }
725
+ }
726
+ return { ok: false, error: lastError };
727
+ }
728
+
729
+ /**
730
+ * Every LOCAL knowledge base on this machine, read from the manifests the agent writes.
731
+ * Keyed by hashes on disk, so the manifest is the only place the human-readable source lives.
732
+ */
733
+ export async function listLocalRagIndexes() {
734
+ const { readdir, readFile: rf, stat: st } = await import("node:fs/promises");
735
+ const root = join(homedir(), ".gonext", "rag");
736
+ const out = [];
737
+ let folders = [];
738
+ try {
739
+ folders = await readdir(root);
740
+ } catch {
741
+ return out; // never indexed anything
742
+ }
743
+ for (const f of folders) {
744
+ let sources = [];
745
+ try {
746
+ sources = await readdir(join(root, f));
747
+ } catch {
748
+ continue;
749
+ }
750
+ for (const srcHash of sources) {
751
+ const dir = join(root, f, srcHash);
752
+ let manifest = null;
753
+ try {
754
+ manifest = JSON.parse(await rf(join(dir, "manifest.json"), "utf8"));
755
+ } catch {
756
+ /* a shard dir with no manifest — still report what we can */
757
+ }
758
+ let shards = 0;
759
+ let bytes = 0;
760
+ try {
761
+ for (const name of await readdir(dir)) {
762
+ if (!name.startsWith("chunks") || !name.endsWith(".jsonl")) continue;
763
+ shards += 1;
764
+ bytes += (await st(join(dir, name))).size;
765
+ }
766
+ } catch {
767
+ /* unreadable — reported as 0 */
768
+ }
769
+ if (!manifest && !shards) continue;
770
+ out.push({
771
+ source: manifest?.sourceUrl ?? `(unknown — ${srcHash.slice(0, 8)}…)`,
772
+ chunks: manifest?.chunks ?? 0,
773
+ files: manifest?.files ?? 0,
774
+ model: manifest?.embedModel ?? "?",
775
+ dims: manifest?.dims ?? 0,
776
+ updatedAt: manifest?.updatedAt ?? "?",
777
+ shards,
778
+ bytes,
779
+ });
780
+ }
781
+ }
782
+ return out.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
783
+ }
784
+
785
+ /** Stop model servers by pid. Returns the pids actually signalled. */
786
+ export function stopModelServers(rows) {
787
+ const killed = [];
788
+ for (const r of rows) {
789
+ try {
790
+ process.kill(r.pid, "SIGTERM");
791
+ killed.push(r.pid);
792
+ } catch {
793
+ /* already gone */
794
+ }
795
+ }
796
+ return killed;
797
+ }
798
+
799
+ // ---- probing + process control ----------------------------------------------------------
800
+
801
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
802
+
803
+ /** True when an OpenAI-compatible model server answers GET {base}/v1/models. */
804
+ export async function probeModelServer(baseUrl, { timeoutMs = 3000 } = {}) {
805
+ const root = modelRoot(baseUrl);
806
+ if (!root) return false;
807
+ try {
808
+ const res = await fetch(`${root}/v1/models`, {
809
+ signal: AbortSignal.timeout(timeoutMs),
810
+ });
811
+ return res.ok;
812
+ } catch {
813
+ return false;
814
+ }
815
+ }
816
+
817
+ function which(cmd) {
818
+ return new Promise((resolve) => {
819
+ execFile("command", ["-v", cmd], { shell: "/bin/sh" }, (err, stdout) =>
820
+ resolve(!err && Boolean(String(stdout).trim()))
821
+ );
822
+ });
823
+ }
824
+
825
+ /** True when the model directory exists and is non-empty. */
826
+ export async function modelDirReady(dir) {
827
+ try {
828
+ const entries = await readdir(dir);
829
+ return entries.length > 0;
830
+ } catch {
831
+ return false;
832
+ }
833
+ }
834
+
835
+ /**
836
+ * Start mlx_lm.server for `modelDir` on `port`, DETACHED (logs to model.log, pidfile). Waits
837
+ * until the server answers /v1/models (up to waitMs). No-op + { started:false } when a
838
+ * server already answers on that port (BC3). Returns { started, ready, pid?, reason? }.
839
+ */
840
+ export async function startChatModelServer({ modelDir, port, waitMs = 120_000, log = () => {} }) {
841
+ const base = `http://127.0.0.1:${port}`;
842
+ if (await probeModelServer(base, { timeoutMs: 1500 })) {
843
+ return { started: false, ready: true, reason: "already-running" };
844
+ }
845
+ if (!(await which("mlx_lm.server"))) {
846
+ return {
847
+ started: false,
848
+ ready: false,
849
+ reason: "mlx_lm.server not found — install it: pip install mlx-lm",
850
+ };
851
+ }
852
+ await mkdir(DIR, { recursive: true });
853
+ const out = await open(MODEL_LOG_FILE, "a");
854
+ let pid;
855
+ try {
856
+ const child = spawn("mlx_lm.server", ["--model", modelDir, "--port", String(port)], {
857
+ detached: true,
858
+ stdio: ["ignore", out.fd, out.fd],
859
+ env: process.env,
860
+ });
861
+ pid = child.pid;
862
+ const { writeFile } = await import("node:fs/promises");
863
+ await writeFile(MODEL_PID_FILE, String(pid ?? ""), "utf8");
864
+ child.unref();
865
+ } finally {
866
+ await out.close();
867
+ }
868
+ log(` starting mlx_lm.server (pid ${pid}) on port ${port} — waiting for it to load…`);
869
+ const deadline = Date.now() + waitMs;
870
+ while (Date.now() < deadline) {
871
+ await sleep(2500);
872
+ if (await probeModelServer(base, { timeoutMs: 2000 })) {
873
+ return { started: true, ready: true, pid };
874
+ }
875
+ }
876
+ return {
877
+ started: true,
878
+ ready: false,
879
+ pid,
880
+ reason: `model server did not answer within ${Math.round(waitMs / 1000)}s (still loading? see ${MODEL_LOG_FILE})`,
881
+ };
882
+ }
883
+
884
+ /**
885
+ * Download an MLX model repo to a local dir via the Hugging Face CLI. Streams progress to
886
+ * the terminal (inherit stdio) because it's a multi-GB, several-minute step. Resolves
887
+ * { ok } / rejects with guidance when `hf` is missing.
888
+ */
889
+ export async function downloadModel({ repo, dir, log = () => {} }) {
890
+ if (!(await which("hf"))) {
891
+ throw new Error(
892
+ "the Hugging Face CLI (hf) is required to download the model. Install it with: pip install huggingface_hub"
893
+ );
894
+ }
895
+ log(` downloading ${repo} → ${dir} (multi-GB, keep this open)…`);
896
+ return new Promise((resolve, reject) => {
897
+ const child = spawn("hf", ["download", repo, "--local-dir", dir], {
898
+ stdio: "inherit",
899
+ env: process.env,
900
+ });
901
+ child.on("exit", (code) =>
902
+ code === 0 ? resolve({ ok: true }) : reject(new Error(`hf download exited ${code}`))
903
+ );
904
+ child.on("error", (e) => reject(e));
905
+ });
906
+ }
907
+
908
+ /** Fetch the account's resolved model config from /agent-payload (best-effort). */
909
+ async function fetchModelConfig(apiBase, workerKey) {
910
+ try {
911
+ const res = await fetch(`${apiBase.replace(/\/+$/, "")}/api/worker/agent-payload`, {
912
+ method: "POST",
913
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
914
+ body: JSON.stringify({ messages: [{ role: "user", content: "ping" }] }),
915
+ });
916
+ if (!res.ok) return null;
917
+ const data = await res.json();
918
+ return data?.payload ?? null;
919
+ } catch {
920
+ return null;
921
+ }
922
+ }
923
+
924
+ /**
925
+ * The Phase-3 orchestration (task #127). Ensures the base CHAT model is running (starting a
926
+ * local mlx_lm.server, downloading first if the dir is missing and the user opts in), and
927
+ * probes the CODE model, giving guidance for a remote one. BC3-safe throughout: an
928
+ * already-answering server is left untouched.
929
+ *
930
+ * `log(str)` prints; `confirm(question, defaultYes)→bool` gates the multi-GB download and
931
+ * the model-server start (non-interactive callers pass a confirm that returns false, so the
932
+ * doctor only PROBES + advises and never downloads/starts unattended).
933
+ */
934
+ /**
935
+ * `prompt(question, defaultValue) → string` asks for a VALUE (a folder path), unlike
936
+ * `confirm` which asks yes/no. Optional: a non-interactive caller omits it and the setup
937
+ * quietly uses the default location instead of blocking on an answer nobody can give.
938
+ */
939
+ export async function runDoctor({ apiBase, workerKey, log, confirm, prompt, modelHost }) {
940
+ // A CLIENT machine (task #134) installs the agent framework and stops: the models live on
941
+ // another machine and are reached over HTTP. Default to what the hardware can actually do,
942
+ // so a Linux box or an Intel Mac never gets offered mlx-lm.
943
+ const isModelHost = modelHost === undefined ? canHostMlxModels() : Boolean(modelHost);
944
+ const p = (await fetchModelConfig(apiBase, workerKey)) ?? {};
945
+
946
+ // Step 0: the agent's PYTHON deps must be present or every agent turn crashes at import
947
+ // — and the model download/start needs `hf`/`mlx-lm`. Ensure these FIRST (task #127:
948
+ // parity with the Wizard's pip-mlx-lm / pip-agent-libs / hf-cli steps). Fatal-ish: if the
949
+ // core framework is missing, say so, but keep going so the model steps still run.
950
+ const deps = await ensureAgentDeps({ log, confirm, modelHost: isModelHost });
951
+ // ALWAYS pin here, not just after an install (task #134). `doctor` is an explicit "fix my
952
+ // setup" action, and the common broken case is packages that are ALREADY installed — into
953
+ // a different interpreter than the daemon resolves. The REPL's own check can't see this:
954
+ // it runs in the user's shell (conda active), the daemon runs elsewhere, so smolagents
955
+ // looks present to one and missing to the other. Observed live on a client machine.
956
+ if (deps.python3 === "ok") {
957
+ const pinned = await pinPythonForDaemon({ log });
958
+ if (pinned) deps.python = pinned;
959
+ }
960
+
961
+ // Chat model: prefer the account's configured URL; fall back to the default local one.
962
+ const configuredChat = String(p.agentBaseURL || "").trim();
963
+ const chatUrl = configuredChat || `http://127.0.0.1:${DEFAULT_CHAT_PORT}`;
964
+ const chatPort = parsePort(chatUrl) || DEFAULT_CHAT_PORT;
965
+ // Review H2: never serve a model the account didn't configure. Only the KNOWN default is
966
+ // auto-downloadable; for a specifically-named other model we start its dir if present but
967
+ // won't substitute Qwen3-14B. "default_model" is the direct-URL sentinel (the loaded
968
+ // model), which we treat as the default. modelId empty (fresh account) → default too.
969
+ const modelId = String(p.agentModelId || "").trim();
970
+ const isDefaultModel =
971
+ !modelId || modelId === "default_model" || /qwen3-?14b/i.test(modelId);
972
+ const chatDir = isDefaultModel
973
+ ? DEFAULT_CHAT_DIR
974
+ : join(homedir(), "mlx-models", modelId.replace(/[^A-Za-z0-9._-]/g, "-"));
975
+ const chatRepo = isDefaultModel ? DEFAULT_CHAT_REPO : null; // null = not auto-downloadable
976
+
977
+ // The chat handling sets `chat` but never returns early — the code-model check below is
978
+ // independent and must run even when the user declines the chat-model setup.
979
+ let chat = "checked";
980
+ log("");
981
+ if (!isModelHost) {
982
+ // CLIENT machine (task #134): the agent runs here, the models don't. Skip the
983
+ // MLX-specific download/start entirely — but ONLY that. The code-model check below still
984
+ // runs, because a coder is usually remote (a cloud endpoint or another box) and matters
985
+ // on every machine, client or not.
986
+ if (await hasLocalOllama()) {
987
+ // An Ollama host: it DOES serve models here, just not MLX ones. Don't tell it
988
+ // otherwise — only the MLX-specific setup is skipped.
989
+ log(" Ollama is answering on this machine (127.0.0.1:11434) — skipping the MLX model");
990
+ log(" setup (mlx is Apple-Silicon only).");
991
+ // The same advice on every machine now. A client used to be told it "already uses" a
992
+ // constant this code no longer substitutes — advice that was wrong the moment the
993
+ // override went, and misleading before that, since the account's model was what it had
994
+ // been asked to run and not what this line named.
995
+ log(" Point the Agent model URL at your Ollama server if you want to use it.");
996
+ chat = "ollama-host";
997
+ } else {
998
+ log(" This machine runs the AGENT, not the models — skipping the local model setup.");
999
+ log(" Point the Agent model URL (web app → Settings → Agent) at ANY OpenAI-compatible");
1000
+ log(" endpoint this machine can reach:");
1001
+ log(" · a public/hosted Ollama → https://your-ollama.example.com/v1");
1002
+ log(" · another Mac over a tunnel → http://<tailnet-name>:8090/v1");
1003
+ log(" · a cloud endpoint → the same way the code model already works");
1004
+ log(" For Ollama, also set the Agent model NAME (it serves many models from one port).");
1005
+ log(" `gu status` checks whether that URL answers FROM HERE.");
1006
+ chat = "client";
1007
+ }
1008
+ } else if (await probeModelServer(chatUrl)) {
1009
+ // "Something is listening" is not the question a person is asking here (task #170). They
1010
+ // want to know WHICH model, from WHERE — and this branch leaves a server alone precisely
1011
+ // because it is already up, so if it is serving the wrong thing, this is the last moment
1012
+ // anyone would notice. Name it from `ps`, the same authority `gu status` uses.
1013
+ const { servedModelAt, sameModelName, namesAModel } = await import("./agent-model-label.mjs");
1014
+ const served = servedModelAt({ port: chatPort, servers: await discoverModelServers() });
1015
+ log(` ✓ chat model already running at ${modelRoot(chatUrl)} — leaving it alone.`);
1016
+ if (served) {
1017
+ log(` ${served.name}`);
1018
+ log(` from ${served.path}`);
1019
+ // Only a REAL configured name is an expectation. "default_model" is mlx_lm's "whatever I
1020
+ // loaded" sentinel, so it agrees with anything by construction.
1021
+ if (namesAModel(modelId) && !sameModelName(modelId, served.name)) {
1022
+ log(` ⚠ your account asks for "${modelId}", but that server is running`);
1023
+ log(` "${served.name}". Stop it and re-run doctor, or change the model in the web app.`);
1024
+ }
1025
+ } else {
1026
+ // A server we cannot see in `ps`: someone else's process, a container, or a tunnel to
1027
+ // another machine. Say that rather than implying we checked.
1028
+ log(` (not started from an mlx_lm.server process here — can't say which model)`);
1029
+ }
1030
+ chat = "already-running";
1031
+ } else if (configuredChat && !isLocalModelUrl(chatUrl)) {
1032
+ // Configured but REMOTE and not answering — we can't start a model on another machine.
1033
+ log(` ⚠ your chat model server (${modelRoot(chatUrl)}) isn't answering.`);
1034
+ log(" It's remote, so start it there — this machine can't launch it.");
1035
+ chat = "remote";
1036
+ } else {
1037
+ // Local (or unconfigured → default local): ensure the RIGHT model, then offer to start.
1038
+ const dir = chatDir;
1039
+ let dirReady = await modelDirReady(dir);
1040
+ if (!dirReady && !chatRepo) {
1041
+ // A specifically-named, non-default model we don't know how to fetch — DON'T
1042
+ // substitute the default. Tell the user how to bring their own (review H2).
1043
+ log(` ⚠ your account uses the chat model "${modelId}", which isn't the built-in`);
1044
+ log(` default, so I can't auto-download it. Put it at ${dir} (or any dir) and run:`);
1045
+ log(` mlx_lm.server --model <that dir> --port ${chatPort}`);
1046
+ chat = "unknown-model";
1047
+ } else if (!dirReady) {
1048
+ // Yellow RAM notice before the multi-GB download, THEN the confirm (user request).
1049
+ for (const line of chatRamWarningLines(chatRepo, totalmem())) log(line);
1050
+ const yes = await confirm(
1051
+ `Download the base chat model ${chatRepo} (~8 GB) to ${dir}?`,
1052
+ true
1053
+ );
1054
+ if (!yes) {
1055
+ log(" Skipped. To do it yourself:");
1056
+ log(` hf download ${chatRepo} --local-dir ${dir}`);
1057
+ log(` mlx_lm.server --model ${dir} --port ${chatPort}`);
1058
+ chat = "skipped";
1059
+ } else {
1060
+ try {
1061
+ await downloadModel({ repo: chatRepo, dir, log });
1062
+ dirReady = true;
1063
+ } catch (e) {
1064
+ log(` ⚠ ${e instanceof Error ? e.message : e}`);
1065
+ chat = "download-failed";
1066
+ }
1067
+ }
1068
+ }
1069
+ if (dirReady && chat === "checked") {
1070
+ // Name the model in the QUESTION, the way the RAG prompt below always has. "Start the
1071
+ // chat model server on port 8090?" asks someone to approve a multi-GB load without
1072
+ // saying what is being loaded or from where.
1073
+ const chatName = modelDisplayName(dir);
1074
+ const yes = await confirm(
1075
+ `Start the chat model server for ${chatName} (${dir}) on port ${chatPort} now?`,
1076
+ true
1077
+ );
1078
+ if (!yes) {
1079
+ log(` Skipped. Start it later: mlx_lm.server --model ${dir} --port ${chatPort}`);
1080
+ chat = "not-started";
1081
+ } else {
1082
+ // Review M2: don't block the caller (the REPL) for a full cold model load — that's
1083
+ // minutes. Wait only long enough to confirm the server launched and didn't crash
1084
+ // (~20s); if it's still loading, say so and return. The first agent turn will wait
1085
+ // for it. A missing binary / immediate exit still surfaces as an error.
1086
+ const r = await startChatModelServer({
1087
+ modelDir: dir,
1088
+ port: chatPort,
1089
+ waitMs: 20_000,
1090
+ log,
1091
+ });
1092
+ if (r.ready) {
1093
+ log(` ✓ chat model is up: ${chatName} on port ${chatPort}`);
1094
+ log(` from ${dir}`);
1095
+ chat = "started";
1096
+ } else if (r.started) {
1097
+ log(` … ${chatName} is loading in the background on port ${chatPort} (large model —`);
1098
+ log(` the first answer may take a minute). Watch it: gu-cli logs`);
1099
+ chat = "loading";
1100
+ } else {
1101
+ log(` ⚠ ${r.reason}`);
1102
+ chat = "start-failed";
1103
+ }
1104
+ }
1105
+ }
1106
+ }
1107
+
1108
+ // Code model (Ollama): probe + guide only — never auto-pull (BC3 + bounded scope).
1109
+ const codeUrl = String(p.codingBaseURL || "").trim();
1110
+ const codeModel = String(p.codingModelId || "").trim();
1111
+ if (codeUrl) {
1112
+ log("");
1113
+ log(" Checking the agent code model…");
1114
+ // A REMOTE coder is normally a cloud API behind a key: it answers 401 to an
1115
+ // unauthenticated probe, which probeModelServer (200-only) reports as DOWN. Use
1116
+ // reachability there — any HTTP answer proves it's up — or the doctor cries wolf about a
1117
+ // perfectly healthy endpoint and then suggests `ollama pull` for a cloud service.
1118
+ const codeIsLocal = isLocalModelUrl(codeUrl);
1119
+ const codeOk = codeIsLocal
1120
+ ? await probeModelServer(codeUrl)
1121
+ : (await probeReachable(codeUrl)).reachable;
1122
+ if (codeOk) {
1123
+ log(` ✓ code model server is up at ${modelRoot(codeUrl)}.`);
1124
+ } else if (codeIsLocal) {
1125
+ log(` ⚠ local code model server (${modelRoot(codeUrl)}) isn't answering.`);
1126
+ if (codeModel) log(` Start Ollama and pull it: ollama serve & ollama pull ${codeModel}`);
1127
+ } else {
1128
+ // Unreachable AND remote: this machine can't start it, and `ollama pull` is only
1129
+ // meaningful if that server is actually Ollama — don't assume it is.
1130
+ log(` ⚠ your code model (${modelRoot(codeUrl)}) is not reachable from this machine.`);
1131
+ log(" It's remote, so start it there / check the network — this machine can't launch it.");
1132
+ }
1133
+ }
1134
+
1135
+ // RAG (task #127: the user wants the terminal RAG-ready). Ensure the embedding model +
1136
+ // server so rag_index / rag_search work.
1137
+ //
1138
+ // The embedding model is configured in the web app as a FOLDER, not a port (task #133), and
1139
+ // that field lives on the settings row — the agent payload only carries RAG config when
1140
+ // cloud RAG is fully set up (enabled + AWS creds). Read it from /api/worker/settings so the
1141
+ // doctor serves the folder the user chose even on a local-RAG-only account.
1142
+ const ws = await fetchWorkerSettings(apiBase, workerKey);
1143
+ const ragCfg = {
1144
+ ...p,
1145
+ ragEmbedModelPath: String(ws?.ragEmbedModelPath || p?.ragEmbedModelPath || "").trim(),
1146
+ ragEmbedUrl: String(ws?.ragEmbedUrl || p?.ragEmbedUrl || "").trim(),
1147
+ };
1148
+ const rag = isModelHost
1149
+ ? await ensureRag({ p: ragCfg, log, confirm, prompt, apiBase, workerKey })
1150
+ : { embed: "client" };
1151
+ if (!isModelHost) {
1152
+ log("");
1153
+ log(" RAG: this machine doesn't serve the embedding model either — point the RAG");
1154
+ log(" embedding URL at the machine that does (`gu rag` tests it from here).");
1155
+ }
1156
+
1157
+ return { chat, deps, rag };
1158
+ }
1159
+
1160
+ /**
1161
+ * Ensure the agent's required runtime deps (task #127 — parity with the Wizard's pip steps).
1162
+ * python3 can't be auto-installed (guidance only); hf / mlx-lm / smolagents+openai are
1163
+ * offered via pip. Each install is confirmed. Returns a status map; missing deps are
1164
+ * reported, never silently ignored — the agent hard-fails without smolagents+openai.
1165
+ */
1166
+ /**
1167
+ * True when this machine can actually RUN an MLX model server: mlx is Apple-Silicon only.
1168
+ * Anywhere else (a Linux box, an Intel Mac) mlx-lm either fails to install or installs
1169
+ * something that can't work — so it must never be offered there.
1170
+ */
1171
+ export function canHostMlxModels() {
1172
+ return platform() === "darwin" && arch() === "arm64";
1173
+ }
1174
+
1175
+ /**
1176
+ * True when a local OLLAMA is serving models here. Matters because canHostMlxModels() is
1177
+ * false on an Intel Mac or a Linux box, yet such a machine may well host models via Ollama —
1178
+ * calling it "a machine that runs the agent, not the models" would be plainly wrong. MLX
1179
+ * setup still doesn't apply (mlx is Apple-Silicon only); only the WORDING changes.
1180
+ */
1181
+ export async function hasLocalOllama({ timeoutMs = 1200 } = {}) {
1182
+ for (const url of ["http://127.0.0.1:11434/api/tags", "http://127.0.0.1:11434/v1/models"]) {
1183
+ try {
1184
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
1185
+ if (res.ok) return true;
1186
+ } catch {
1187
+ /* not there */
1188
+ }
1189
+ }
1190
+ return false;
1191
+ }
1192
+
1193
+ /**
1194
+ * `modelHost` (default: whether this machine COULD host models) decides whether the
1195
+ * model-serving packages are offered at all. A client machine — the terminal runs here, the
1196
+ * models live on another machine (task #134) — needs ONLY the agent framework: the agent
1197
+ * imports no mlx/torch/transformers and reaches the model over HTTP, exactly as it already
1198
+ * reaches a cloud coder.
1199
+ */
1200
+ export async function ensureAgentDeps({ log, confirm, modelHost = canHostMlxModels() }) {
1201
+ const out = {};
1202
+ log("");
1203
+ log(" Checking the agent's dependencies…");
1204
+ // python3 — the whole agent runs on it; we can't install it for the user.
1205
+ if (!(await pyHasModule("sys"))) {
1206
+ log(` ⚠ ${python3Bin()} not found. Install Python 3 — ${pythonInstallHint()} —`);
1207
+ log(" then run `gu-cli doctor` again.");
1208
+ out.python3 = "missing";
1209
+ return out; // nothing else can be installed without python
1210
+ }
1211
+ out.python3 = "ok";
1212
+
1213
+ const ensurePkg = async (key, probeMod, pkgs, question) => {
1214
+ if (await probeMod()) {
1215
+ out[key] = "present";
1216
+ return;
1217
+ }
1218
+ if (!(await confirm(question, true))) {
1219
+ out[key] = "skipped";
1220
+ log(` Skipped ${pkgs.join(" ")} — install later: ${python3Bin()} -m pip install -U ${pkgs.join(" ")}`);
1221
+ return;
1222
+ }
1223
+ try {
1224
+ await pipInstall(pkgs, log);
1225
+ out[key] = "installed";
1226
+ } catch (e) {
1227
+ log(` ⚠ ${e instanceof Error ? e.message : e}`);
1228
+ out[key] = "failed";
1229
+ }
1230
+ };
1231
+
1232
+ // Model-SERVING packages — only where models are actually served. On a client machine
1233
+ // they're dead weight at best: mlx-lm is Apple-Silicon-only and `hf` exists to download
1234
+ // weights this machine will never hold.
1235
+ if (modelHost) {
1236
+ // hf CLI (model downloads). `huggingface_hub` provides the `hf` command.
1237
+ await ensurePkg(
1238
+ "hf",
1239
+ () => which("hf"),
1240
+ ["huggingface_hub"],
1241
+ "Install the Hugging Face CLI (needed to download models)?"
1242
+ );
1243
+ // mlx-lm — runs the local chat + embedding model servers.
1244
+ await ensurePkg("mlxLm", () => pyHasModule("mlx_lm"), ["mlx-lm"], "Install mlx-lm (runs the local model servers)?");
1245
+ } else {
1246
+ out.hf = "skipped-client";
1247
+ out.mlxLm = "skipped-client";
1248
+ log(" · this machine doesn't serve models — skipping mlx-lm / hf (not needed here).");
1249
+ }
1250
+ // The agent framework itself — WITHOUT this every agent turn crashes at import.
1251
+ await ensurePkg(
1252
+ "agentLibs",
1253
+ async () => (await pyHasModule("smolagents")) && (await pyHasModule("openai")),
1254
+ ["smolagents", "openai"],
1255
+ "Install the agent framework (smolagents + openai) — required to run the agent?"
1256
+ );
1257
+ if (out.agentLibs === "skipped" || out.agentLibs === "failed") {
1258
+ log(yellow(" ⚠ Without smolagents + openai the agent can't run — install them before asking a question."));
1259
+ }
1260
+ if (out.agentLibs === "installed") {
1261
+ // Make the daemon use the interpreter these packages actually landed in. ONLY after a
1262
+ // real install: rewriting worker.env when the libs were already there is a side effect
1263
+ // nobody asked for (it fired during a test run against the developer's own config).
1264
+ out.python = await pinPythonForDaemon({ log });
1265
+ }
1266
+ return out;
1267
+ }
1268
+
1269
+ /**
1270
+ * Start the MLX embeddings server (gonext_mlx_embed.py) DETACHED on `port` for `modelDir`,
1271
+ * with its own pidfile/log. No-op + { ready:true } when one already answers (BC3). Waits up
1272
+ * to waitMs for /v1/models to respond. Mirrors startChatModelServer.
1273
+ */
1274
+ export async function startEmbedServer({ modelDir, port, waitMs = 60_000, log = () => {} }) {
1275
+ const base = `http://127.0.0.1:${port}`;
1276
+ if (await probeModelServer(base, { timeoutMs: 1500 })) {
1277
+ return { started: false, ready: true, reason: "already-running" };
1278
+ }
1279
+ if (!(await pyHasModule("mlx_lm"))) {
1280
+ return { started: false, ready: false, reason: "mlx-lm not installed (pip install mlx-lm)" };
1281
+ }
1282
+ await mkdir(DIR, { recursive: true });
1283
+ const outLog = await open(EMBED_LOG_FILE, "a");
1284
+ let pid;
1285
+ try {
1286
+ const child = spawn(
1287
+ python3Bin(),
1288
+ [EMBED_SCRIPT, "--model", modelDir, "--port", String(port), "--host", "127.0.0.1"],
1289
+ { detached: true, stdio: ["ignore", outLog.fd, outLog.fd], env: process.env }
1290
+ );
1291
+ pid = child.pid;
1292
+ await writeFile(EMBED_PID_FILE, String(pid ?? ""), "utf8");
1293
+ child.unref();
1294
+ } finally {
1295
+ await outLog.close();
1296
+ }
1297
+ log(` starting embeddings server (pid ${pid}) on port ${port} — waiting for it to load…`);
1298
+ const deadline = Date.now() + waitMs;
1299
+ while (Date.now() < deadline) {
1300
+ await sleep(2500);
1301
+ if (await probeModelServer(base, { timeoutMs: 2000 })) return { started: true, ready: true, pid };
1302
+ }
1303
+ return { started: true, ready: false, pid, reason: `embeddings server still loading (see ${EMBED_LOG_FILE})` };
1304
+ }
1305
+
1306
+ /**
1307
+ * Ensure RAG is ready for the agent terminal (task #127): boto3 (cloud/S3 storage), the
1308
+ * embedding model, and a running embeddings server. Uses the account's configured embed URL
1309
+ * when it's local; otherwise the default local server on EMBED_PORT. BC3: an already-running
1310
+ * embed server is left alone. Returns a status map.
1311
+ */
1312
+ export async function ensureRag({
1313
+ p,
1314
+ log,
1315
+ confirm,
1316
+ prompt,
1317
+ apiBase,
1318
+ workerKey,
1319
+ // INJECTED so a test can prove nothing is WRITTEN, not merely that nothing is asked. The
1320
+ // account write is the damaging half — a prompt is a question you can ignore, a saved
1321
+ // ragEmbedModelPath outlives the session and follows the account to every machine. Without
1322
+ // this seam a test could only spy on the question, and a regression that stopped asking but
1323
+ // still wrote would pass.
1324
+ saveSettings = saveWorkerSettings,
1325
+ }) {
1326
+ const out = {};
1327
+ log("");
1328
+ log(" Checking RAG (agent knowledge base)…");
1329
+ // boto3 — needed only for CLOUD (S3) RAG; local RAG stores on disk. Cheap + harmless, so
1330
+ // offer it so cloud RAG works later without another setup pass.
1331
+ if (!(await pyHasModule("boto3"))) {
1332
+ if (await confirm("Install boto3 (needed only if you use cloud/S3 RAG)?", true)) {
1333
+ try {
1334
+ await pipInstall(["boto3"], log);
1335
+ out.boto3 = "installed";
1336
+ } catch (e) {
1337
+ log(` ⚠ ${e instanceof Error ? e.message : e}`);
1338
+ out.boto3 = "failed";
1339
+ }
1340
+ } else out.boto3 = "skipped";
1341
+ } else out.boto3 = "present";
1342
+
1343
+ // An OLLAMA embedder needs nothing local: Ollama already serves the model on its own port
1344
+ // (`ollama pull qwen3-embedding:8b`), so there is no folder to download and no server for
1345
+ // us to start. Probe it and stop — offering an MLX setup here would be busywork.
1346
+ const configuredEmbedUrl = String(p?.ragEmbedUrl || "").trim();
1347
+ const embedKind = configuredEmbedUrl ? await classifyEmbedder(configuredEmbedUrl) : "none";
1348
+ if (configuredEmbedUrl && embedKind === "ollama") {
1349
+ const reach = await probeReachable(configuredEmbedUrl);
1350
+ log(` ✓ using an OLLAMA embedder at ${modelRoot(configuredEmbedUrl)}` +
1351
+ (reach.reachable ? "" : " — NOT reachable from here"));
1352
+ log(` Model name: ${String(p?.ragEmbedModel || "").trim() || "(NOT SET — required for Ollama)"}`);
1353
+ log(" Nothing to install locally: Ollama serves the embedding model itself.");
1354
+ out.embed = reach.reachable ? "ollama" : "ollama-unreachable";
1355
+ return out;
1356
+ }
1357
+ // A REMOTE EMBEDDER IS SETTLED HERE, BEFORE ANY LOCAL SETUP RUNS. This check also exists
1358
+ // further down, but it ran AFTER the "Where should the RAG embedding model be stored?"
1359
+ // prompt — so a configured remote embedder that was merely DOWN FOR A MOMENT got asked
1360
+ // where to keep a local MLX model, and the answer was SAVED TO THE ACCOUNT.
1361
+ //
1362
+ // Observed exactly that way (2026-09-05): ollama2 did not answer for a few seconds, and a
1363
+ // Client Expert machine came out of `gu` carrying
1364
+ // ragEmbedModelPath=/Users/joseph/mlx-models/Qwen3-Embedding-8B-4bit-DWQ — a model that
1365
+ // server does not have and never will. Host-mode setup, written onto a machine that is not
1366
+ // a host, because a probe timed out.
1367
+ //
1368
+ // A LOCAL embedder that is down is the opposite case and must still fall through: setting
1369
+ // one up is precisely what the doctor is for.
1370
+ if (configuredEmbedUrl && !isLocalModelUrl(configuredEmbedUrl)) {
1371
+ log(` ⚠ your embeddings server (${modelRoot(configuredEmbedUrl)}) is remote and isn't`);
1372
+ log(" answering. Start it there — this machine can't launch it.");
1373
+ log(" Leaving the setting alone — nothing here can serve a remote embedder.");
1374
+ out.embed = "remote";
1375
+ return out;
1376
+ }
1377
+
1378
+ // WHICH model to serve: the folder the user named in the web app (Settings → Agent → RAG,
1379
+ // task #133) if any, else the built-in one we know how to download. A user-supplied folder
1380
+ // is never auto-downloaded — we can only serve what's already on disk.
1381
+ let configuredDir = expandHome(String(p?.ragEmbedModelPath || "").trim());
1382
+ let embedIsCustom = Boolean(configuredDir);
1383
+ // FIRST-TIME setup: nothing is configured yet. Don't silently pick a folder and start
1384
+ // downloading GBs into it — offer the standard location and let the user redirect it (a
1385
+ // Mac with a small boot disk often keeps models on an external drive). Whatever they pick
1386
+ // is saved to the ACCOUNT, so the web app shows the same path this machine is using.
1387
+ if (!configuredDir && prompt) {
1388
+ const answer = String(
1389
+ (await prompt("Where should the RAG embedding model be stored?", EMBED_DIR)) || ""
1390
+ ).trim();
1391
+ const chosen = expandHome(answer) || EMBED_DIR;
1392
+ configuredDir = chosen;
1393
+ // Still NOT "custom" in the don't-auto-download sense: the user just told us where to put
1394
+ // the model we already know how to fetch. That guard exists for a folder configured on the
1395
+ // web that names some OTHER model — downloading ours into it would serve the wrong thing.
1396
+ embedIsCustom = false;
1397
+ const savedToAccount = await saveSettings(apiBase, workerKey, {
1398
+ ragEmbedModelPath: chosen,
1399
+ });
1400
+ log(
1401
+ savedToAccount
1402
+ ? ` ✓ saved to your account — visible in the web app under Settings → Agent → RAG.`
1403
+ : ` ⚠ couldn't save that path to your account (using it for this machine anyway).`
1404
+ );
1405
+ }
1406
+ const embedDir = configuredDir || EMBED_DIR;
1407
+ // WHERE to serve it: an explicit URL still wins (Ollama, another machine); otherwise the
1408
+ // standard embed port, which is exactly what the API derives from the folder. The user
1409
+ // never has to think about the port.
1410
+ const configuredEmbed = String(p?.ragEmbedUrl || "").trim();
1411
+ const embedUrl =
1412
+ configuredEmbed && isLocalModelUrl(configuredEmbed)
1413
+ ? configuredEmbed
1414
+ : `http://127.0.0.1:${EMBED_PORT}`;
1415
+ const embedPort = parsePort(embedUrl) || EMBED_PORT;
1416
+
1417
+ // A REMOTE embedder that's down must be reported as such — check it BEFORE the local
1418
+ // probe. embedUrl falls back to the default local port when the configured one isn't
1419
+ // local, so probing embedUrl first let a locally-running server mask a dead remote one
1420
+ // and report "already running" for a machine the user can't even reach.
1421
+ if (configuredEmbed && !isLocalModelUrl(configuredEmbed)) {
1422
+ log(` ⚠ your embeddings server (${modelRoot(configuredEmbed)}) is remote and isn't`);
1423
+ log(" answering. Start it there — this machine can't launch it.");
1424
+ out.embed = "remote";
1425
+ return out;
1426
+ }
1427
+ if (await probeModelServer(embedUrl)) {
1428
+ // Same as the chat model: a server left alone because it is up is the one nobody will
1429
+ // check again, and an embedder serving the wrong model silently poisons every index.
1430
+ {
1431
+ const { servedModelAt } = await import("./agent-model-label.mjs");
1432
+ const served = servedModelAt({
1433
+ port: embedPort,
1434
+ servers: await discoverModelServers(),
1435
+ kind: "embed",
1436
+ });
1437
+ log(` ✓ embeddings server already running at ${modelRoot(embedUrl)} — leaving it alone.`);
1438
+ if (served) {
1439
+ log(` ${served.name}`);
1440
+ log(` from ${served.path}`);
1441
+ } else {
1442
+ log(` (not one of our embed servers here — can't say which model)`);
1443
+ }
1444
+ }
1445
+ out.embed = "already-running";
1446
+ return out;
1447
+ }
1448
+ // Download the embed model if missing. A folder the USER named can't be downloaded — we
1449
+ // don't know what it is — so say what's wrong instead of silently serving a different model.
1450
+ if (!(await modelDirReady(embedDir))) {
1451
+ if (embedIsCustom) {
1452
+ log(` ⚠ the embedding model folder you set in the web app doesn't exist here:`);
1453
+ log(` ${embedDir}`);
1454
+ log(" Fix the path in Settings → Agent → RAG, or download the model into it:");
1455
+ log(` hf download <repo> --local-dir ${embedDir}`);
1456
+ out.embed = "missing-model-dir";
1457
+ return out;
1458
+ }
1459
+ if (!(await confirm(`Download the RAG embedding model ${EMBED_REPO.split("/").pop()} (~4 GB)?`, true))) {
1460
+ log(" Skipped. To do it yourself:");
1461
+ log(` hf download ${EMBED_REPO} --local-dir ${embedDir}`);
1462
+ log(` gu-cli embed --model ${embedDir} --port ${embedPort}`);
1463
+ out.embed = "skipped-model";
1464
+ return out;
1465
+ }
1466
+ try {
1467
+ await downloadModel({ repo: EMBED_REPO, dir: embedDir, log });
1468
+ } catch (e) {
1469
+ log(` ⚠ ${e instanceof Error ? e.message : e}`);
1470
+ out.embed = "download-failed";
1471
+ return out;
1472
+ }
1473
+ }
1474
+ if (!(await confirm(`Start the RAG embeddings server for ${embedDir.split("/").pop()} now?`, true))) {
1475
+ log(` Skipped. Start it later: gu-cli embed --model ${embedDir} --port ${embedPort}`);
1476
+ out.embed = "not-started";
1477
+ return out;
1478
+ }
1479
+ const r = await startEmbedServer({ modelDir: embedDir, port: embedPort, log });
1480
+ if (r.ready) {
1481
+ // Named for the same reason as the chat model above: the question said which model, so the
1482
+ // confirmation has to as well, or the two lines cannot be checked against each other.
1483
+ log(` ✓ embeddings server is up: ${modelDisplayName(embedDir)} on port ${embedPort}`);
1484
+ log(` from ${embedDir}`);
1485
+ out.embed = "started";
1486
+ } else if (r.started) {
1487
+ log(` … ${modelDisplayName(embedDir)} is loading in the background on port ${embedPort}.`);
1488
+ out.embed = "loading";
1489
+ } else {
1490
+ log(` ⚠ ${r.reason}`);
1491
+ out.embed = "start-failed";
1492
+ }
1493
+ return out;
1494
+ }