@tiens.nguyen/gonext-cli 1.0.350
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/coding-key-prompt.mjs +47 -0
- package/daemon-control.mjs +232 -0
- package/device-login.mjs +141 -0
- package/gonext-cli.mjs +3216 -0
- package/gonext-repl.mjs +2648 -0
- package/gonext_agent_chat.py +7576 -0
- package/gonext_mlx_embed.py +155 -0
- package/gonext_probe_agent.py +93 -0
- package/gonext_transcribe.py +130 -0
- package/model-doctor.mjs +571 -0
- package/package.json +53 -0
- package/thinking_words.txt +1003 -0
package/model-doctor.mjs
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
// Model bootstrap for the gonext 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 { homedir, 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
|
+
/** The python3 the worker uses to run its agent / model tooling (mirrors the daemon). */
|
|
28
|
+
function python3Bin() {
|
|
29
|
+
return (
|
|
30
|
+
(process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "").trim() ||
|
|
31
|
+
"python3"
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True when `python3 -c "import <mod>"` succeeds — i.e. the module is installed. */
|
|
36
|
+
function pyHasModule(mod) {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
execFile(python3Bin(), ["-c", `import ${mod}`], (err) => resolve(!err));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** pip install -U <pkgs> with live progress (inherit stdio). Rejects on non-zero exit. */
|
|
43
|
+
function pipInstall(pkgs, log) {
|
|
44
|
+
log(` installing ${pkgs.join(" ")} … (pip, this can take a minute)`);
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const child = spawn(python3Bin(), ["-m", "pip", "install", "-U", ...pkgs], {
|
|
47
|
+
stdio: "inherit",
|
|
48
|
+
env: process.env,
|
|
49
|
+
});
|
|
50
|
+
child.on("exit", (code) =>
|
|
51
|
+
code === 0 ? resolve() : reject(new Error(`pip install ${pkgs.join(" ")} exited ${code}`))
|
|
52
|
+
);
|
|
53
|
+
child.on("error", reject);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The yellow RAM notice shown before the multi-GB model download (user request): the
|
|
59
|
+
* default 14B-4bit model wants a Mac with ≥16 GB. When the machine has less, add a plain
|
|
60
|
+
* line stating its detected amount. Pure (RAM passed in) so it's unit-testable.
|
|
61
|
+
* Returns already-colored lines; empty array only if `repo` is falsy.
|
|
62
|
+
*/
|
|
63
|
+
export function chatRamWarningLines(repo, totalBytes) {
|
|
64
|
+
if (!repo) return [];
|
|
65
|
+
const ramGb = Math.round((Number(totalBytes) || 0) / 1024 ** 3);
|
|
66
|
+
const name = String(repo).split("/").pop();
|
|
67
|
+
const out = [
|
|
68
|
+
yellow(` ⚠ ${name} needs a Mac with at least ${MIN_CHAT_RAM_GB} GB of RAM to run well.`),
|
|
69
|
+
];
|
|
70
|
+
if (ramGb > 0 && ramGb < MIN_CHAT_RAM_GB) {
|
|
71
|
+
out.push(
|
|
72
|
+
yellow(
|
|
73
|
+
` This machine has about ${ramGb} GB — the model may run very slowly or fail to load.`
|
|
74
|
+
)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const DIR = join(homedir(), ".gonext");
|
|
81
|
+
export const MODEL_PID_FILE = join(DIR, "model.pid");
|
|
82
|
+
export const MODEL_LOG_FILE = join(DIR, "model.log");
|
|
83
|
+
const EMBED_PID_FILE = join(DIR, "embed.pid");
|
|
84
|
+
const EMBED_LOG_FILE = join(DIR, "embed.log");
|
|
85
|
+
|
|
86
|
+
// The default base chat model (task #127 decision): matches the Wizard's model-chat step.
|
|
87
|
+
export const DEFAULT_CHAT_REPO = "mlx-community/Qwen3-14B-4bit";
|
|
88
|
+
export const DEFAULT_CHAT_DIR = join(homedir(), "mlx-models", "Qwen3-14B-4bit");
|
|
89
|
+
export const DEFAULT_CHAT_PORT = 8082;
|
|
90
|
+
|
|
91
|
+
// ---- pure helpers (unit-tested offline) -------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/** The port from a base URL, or null. "http://127.0.0.1:8082/v1" → 8082. */
|
|
94
|
+
export function parsePort(baseUrl) {
|
|
95
|
+
try {
|
|
96
|
+
const u = new URL(String(baseUrl || "").includes("://") ? baseUrl : `http://${baseUrl}`);
|
|
97
|
+
if (u.port) return Number(u.port);
|
|
98
|
+
return u.protocol === "https:" ? 443 : 80;
|
|
99
|
+
} catch {
|
|
100
|
+
const m = String(baseUrl || "").match(/:(\d{2,5})(?:\/|$)/);
|
|
101
|
+
return m ? Number(m[1]) : null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** True when the URL points at this machine (loopback/LAN) — only then can we START it. */
|
|
106
|
+
export function isLocalModelUrl(baseUrl) {
|
|
107
|
+
let host = "";
|
|
108
|
+
try {
|
|
109
|
+
host = new URL(String(baseUrl || "").includes("://") ? baseUrl : `http://${baseUrl}`)
|
|
110
|
+
.hostname.toLowerCase()
|
|
111
|
+
.replace(/\.$/, "");
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
116
|
+
if (!host) return false;
|
|
117
|
+
if (["localhost", "0.0.0.0", "::", "::1"].includes(host)) return true;
|
|
118
|
+
if (/^127(\.\d{1,3}){3}$/.test(host)) return true;
|
|
119
|
+
if (
|
|
120
|
+
/^(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(
|
|
121
|
+
host
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
return true;
|
|
125
|
+
if (host.includes(":") && (host.startsWith("fe80:") || /^f[cd]/.test(host))) return true;
|
|
126
|
+
if (/\.(local|localdomain|lan|home|internal)$/.test(host)) return true;
|
|
127
|
+
return !host.includes(".") && !host.includes(":");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Normalize a base URL to its root (strip a trailing /v1 and slashes). */
|
|
131
|
+
export function modelRoot(baseUrl) {
|
|
132
|
+
return String(baseUrl || "").replace(/\/+$/, "").replace(/\/v1$/i, "");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- probing + process control ----------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
138
|
+
|
|
139
|
+
/** True when an OpenAI-compatible model server answers GET {base}/v1/models. */
|
|
140
|
+
export async function probeModelServer(baseUrl, { timeoutMs = 3000 } = {}) {
|
|
141
|
+
const root = modelRoot(baseUrl);
|
|
142
|
+
if (!root) return false;
|
|
143
|
+
try {
|
|
144
|
+
const res = await fetch(`${root}/v1/models`, {
|
|
145
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
146
|
+
});
|
|
147
|
+
return res.ok;
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function which(cmd) {
|
|
154
|
+
return new Promise((resolve) => {
|
|
155
|
+
execFile("command", ["-v", cmd], { shell: "/bin/sh" }, (err, stdout) =>
|
|
156
|
+
resolve(!err && Boolean(String(stdout).trim()))
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** True when the model directory exists and is non-empty. */
|
|
162
|
+
export async function modelDirReady(dir) {
|
|
163
|
+
try {
|
|
164
|
+
const entries = await readdir(dir);
|
|
165
|
+
return entries.length > 0;
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Start mlx_lm.server for `modelDir` on `port`, DETACHED (logs to model.log, pidfile). Waits
|
|
173
|
+
* until the server answers /v1/models (up to waitMs). No-op + { started:false } when a
|
|
174
|
+
* server already answers on that port (BC3). Returns { started, ready, pid?, reason? }.
|
|
175
|
+
*/
|
|
176
|
+
export async function startChatModelServer({ modelDir, port, waitMs = 120_000, log = () => {} }) {
|
|
177
|
+
const base = `http://127.0.0.1:${port}`;
|
|
178
|
+
if (await probeModelServer(base, { timeoutMs: 1500 })) {
|
|
179
|
+
return { started: false, ready: true, reason: "already-running" };
|
|
180
|
+
}
|
|
181
|
+
if (!(await which("mlx_lm.server"))) {
|
|
182
|
+
return {
|
|
183
|
+
started: false,
|
|
184
|
+
ready: false,
|
|
185
|
+
reason: "mlx_lm.server not found — install it: pip install mlx-lm",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
await mkdir(DIR, { recursive: true });
|
|
189
|
+
const out = await open(MODEL_LOG_FILE, "a");
|
|
190
|
+
let pid;
|
|
191
|
+
try {
|
|
192
|
+
const child = spawn("mlx_lm.server", ["--model", modelDir, "--port", String(port)], {
|
|
193
|
+
detached: true,
|
|
194
|
+
stdio: ["ignore", out.fd, out.fd],
|
|
195
|
+
env: process.env,
|
|
196
|
+
});
|
|
197
|
+
pid = child.pid;
|
|
198
|
+
const { writeFile } = await import("node:fs/promises");
|
|
199
|
+
await writeFile(MODEL_PID_FILE, String(pid ?? ""), "utf8");
|
|
200
|
+
child.unref();
|
|
201
|
+
} finally {
|
|
202
|
+
await out.close();
|
|
203
|
+
}
|
|
204
|
+
log(` starting mlx_lm.server (pid ${pid}) on port ${port} — waiting for it to load…`);
|
|
205
|
+
const deadline = Date.now() + waitMs;
|
|
206
|
+
while (Date.now() < deadline) {
|
|
207
|
+
await sleep(2500);
|
|
208
|
+
if (await probeModelServer(base, { timeoutMs: 2000 })) {
|
|
209
|
+
return { started: true, ready: true, pid };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
started: true,
|
|
214
|
+
ready: false,
|
|
215
|
+
pid,
|
|
216
|
+
reason: `model server did not answer within ${Math.round(waitMs / 1000)}s (still loading? see ${MODEL_LOG_FILE})`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Download an MLX model repo to a local dir via the Hugging Face CLI. Streams progress to
|
|
222
|
+
* the terminal (inherit stdio) because it's a multi-GB, several-minute step. Resolves
|
|
223
|
+
* { ok } / rejects with guidance when `hf` is missing.
|
|
224
|
+
*/
|
|
225
|
+
export async function downloadModel({ repo, dir, log = () => {} }) {
|
|
226
|
+
if (!(await which("hf"))) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
"the Hugging Face CLI (hf) is required to download the model. Install it with: pip install huggingface_hub"
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
log(` downloading ${repo} → ${dir} (multi-GB, keep this open)…`);
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
const child = spawn("hf", ["download", repo, "--local-dir", dir], {
|
|
234
|
+
stdio: "inherit",
|
|
235
|
+
env: process.env,
|
|
236
|
+
});
|
|
237
|
+
child.on("exit", (code) =>
|
|
238
|
+
code === 0 ? resolve({ ok: true }) : reject(new Error(`hf download exited ${code}`))
|
|
239
|
+
);
|
|
240
|
+
child.on("error", (e) => reject(e));
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Fetch the account's resolved model config from /agent-payload (best-effort). */
|
|
245
|
+
async function fetchModelConfig(apiBase, workerKey) {
|
|
246
|
+
try {
|
|
247
|
+
const res = await fetch(`${apiBase.replace(/\/+$/, "")}/api/worker/agent-payload`, {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
|
|
250
|
+
body: JSON.stringify({ messages: [{ role: "user", content: "ping" }] }),
|
|
251
|
+
});
|
|
252
|
+
if (!res.ok) return null;
|
|
253
|
+
const data = await res.json();
|
|
254
|
+
return data?.payload ?? null;
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The Phase-3 orchestration (task #127). Ensures the base CHAT model is running (starting a
|
|
262
|
+
* local mlx_lm.server, downloading first if the dir is missing and the user opts in), and
|
|
263
|
+
* probes the CODE model, giving guidance for a remote one. BC3-safe throughout: an
|
|
264
|
+
* already-answering server is left untouched.
|
|
265
|
+
*
|
|
266
|
+
* `log(str)` prints; `confirm(question, defaultYes)→bool` gates the multi-GB download and
|
|
267
|
+
* the model-server start (non-interactive callers pass a confirm that returns false, so the
|
|
268
|
+
* doctor only PROBES + advises and never downloads/starts unattended).
|
|
269
|
+
*/
|
|
270
|
+
export async function runDoctor({ apiBase, workerKey, log, confirm }) {
|
|
271
|
+
const p = (await fetchModelConfig(apiBase, workerKey)) ?? {};
|
|
272
|
+
|
|
273
|
+
// Step 0: the agent's PYTHON deps must be present or every agent turn crashes at import
|
|
274
|
+
// — and the model download/start needs `hf`/`mlx-lm`. Ensure these FIRST (task #127:
|
|
275
|
+
// parity with the Wizard's pip-mlx-lm / pip-agent-libs / hf-cli steps). Fatal-ish: if the
|
|
276
|
+
// core framework is missing, say so, but keep going so the model steps still run.
|
|
277
|
+
const deps = await ensureAgentDeps({ log, confirm });
|
|
278
|
+
|
|
279
|
+
// Chat model: prefer the account's configured URL; fall back to the default local one.
|
|
280
|
+
const configuredChat = String(p.agentBaseURL || "").trim();
|
|
281
|
+
const chatUrl = configuredChat || `http://127.0.0.1:${DEFAULT_CHAT_PORT}`;
|
|
282
|
+
const chatPort = parsePort(chatUrl) || DEFAULT_CHAT_PORT;
|
|
283
|
+
// Review H2: never serve a model the account didn't configure. Only the KNOWN default is
|
|
284
|
+
// auto-downloadable; for a specifically-named other model we start its dir if present but
|
|
285
|
+
// won't substitute Qwen3-14B. "default_model" is the direct-URL sentinel (the loaded
|
|
286
|
+
// model), which we treat as the default. modelId empty (fresh account) → default too.
|
|
287
|
+
const modelId = String(p.agentModelId || "").trim();
|
|
288
|
+
const isDefaultModel =
|
|
289
|
+
!modelId || modelId === "default_model" || /qwen3-?14b/i.test(modelId);
|
|
290
|
+
const chatDir = isDefaultModel
|
|
291
|
+
? DEFAULT_CHAT_DIR
|
|
292
|
+
: join(homedir(), "mlx-models", modelId.replace(/[^A-Za-z0-9._-]/g, "-"));
|
|
293
|
+
const chatRepo = isDefaultModel ? DEFAULT_CHAT_REPO : null; // null = not auto-downloadable
|
|
294
|
+
|
|
295
|
+
// The chat handling sets `chat` but never returns early — the code-model check below is
|
|
296
|
+
// independent and must run even when the user declines the chat-model setup.
|
|
297
|
+
let chat = "checked";
|
|
298
|
+
log("");
|
|
299
|
+
log(" Checking the base chat model…");
|
|
300
|
+
if (await probeModelServer(chatUrl)) {
|
|
301
|
+
log(` ✓ chat model already running at ${modelRoot(chatUrl)} — leaving it alone.`);
|
|
302
|
+
chat = "already-running";
|
|
303
|
+
} else if (configuredChat && !isLocalModelUrl(chatUrl)) {
|
|
304
|
+
// Configured but REMOTE and not answering — we can't start a model on another machine.
|
|
305
|
+
log(` ⚠ your chat model server (${modelRoot(chatUrl)}) isn't answering.`);
|
|
306
|
+
log(" It's remote, so start it there — this machine can't launch it.");
|
|
307
|
+
chat = "remote";
|
|
308
|
+
} else {
|
|
309
|
+
// Local (or unconfigured → default local): ensure the RIGHT model, then offer to start.
|
|
310
|
+
const dir = chatDir;
|
|
311
|
+
let dirReady = await modelDirReady(dir);
|
|
312
|
+
if (!dirReady && !chatRepo) {
|
|
313
|
+
// A specifically-named, non-default model we don't know how to fetch — DON'T
|
|
314
|
+
// substitute the default. Tell the user how to bring their own (review H2).
|
|
315
|
+
log(` ⚠ your account uses the chat model "${modelId}", which isn't the built-in`);
|
|
316
|
+
log(` default, so I can't auto-download it. Put it at ${dir} (or any dir) and run:`);
|
|
317
|
+
log(` mlx_lm.server --model <that dir> --port ${chatPort}`);
|
|
318
|
+
chat = "unknown-model";
|
|
319
|
+
} else if (!dirReady) {
|
|
320
|
+
// Yellow RAM notice before the multi-GB download, THEN the confirm (user request).
|
|
321
|
+
for (const line of chatRamWarningLines(chatRepo, totalmem())) log(line);
|
|
322
|
+
const yes = await confirm(
|
|
323
|
+
`Download the base chat model ${chatRepo} (~8 GB) to ${dir}?`,
|
|
324
|
+
true
|
|
325
|
+
);
|
|
326
|
+
if (!yes) {
|
|
327
|
+
log(" Skipped. To do it yourself:");
|
|
328
|
+
log(` hf download ${chatRepo} --local-dir ${dir}`);
|
|
329
|
+
log(` mlx_lm.server --model ${dir} --port ${chatPort}`);
|
|
330
|
+
chat = "skipped";
|
|
331
|
+
} else {
|
|
332
|
+
try {
|
|
333
|
+
await downloadModel({ repo: chatRepo, dir, log });
|
|
334
|
+
dirReady = true;
|
|
335
|
+
} catch (e) {
|
|
336
|
+
log(` ⚠ ${e instanceof Error ? e.message : e}`);
|
|
337
|
+
chat = "download-failed";
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (dirReady && chat === "checked") {
|
|
342
|
+
const yes = await confirm(`Start the chat model server on port ${chatPort} now?`, true);
|
|
343
|
+
if (!yes) {
|
|
344
|
+
log(` Skipped. Start it later: mlx_lm.server --model ${dir} --port ${chatPort}`);
|
|
345
|
+
chat = "not-started";
|
|
346
|
+
} else {
|
|
347
|
+
// Review M2: don't block the caller (the REPL) for a full cold model load — that's
|
|
348
|
+
// minutes. Wait only long enough to confirm the server launched and didn't crash
|
|
349
|
+
// (~20s); if it's still loading, say so and return. The first agent turn will wait
|
|
350
|
+
// for it. A missing binary / immediate exit still surfaces as an error.
|
|
351
|
+
const r = await startChatModelServer({
|
|
352
|
+
modelDir: dir,
|
|
353
|
+
port: chatPort,
|
|
354
|
+
waitMs: 20_000,
|
|
355
|
+
log,
|
|
356
|
+
});
|
|
357
|
+
if (r.ready) {
|
|
358
|
+
log(` ✓ chat model is up on port ${chatPort}.`);
|
|
359
|
+
chat = "started";
|
|
360
|
+
} else if (r.started) {
|
|
361
|
+
log(` … chat model is loading in the background on port ${chatPort} (large model —`);
|
|
362
|
+
log(` the first answer may take a minute). Watch it: gonext-cli logs`);
|
|
363
|
+
chat = "loading";
|
|
364
|
+
} else {
|
|
365
|
+
log(` ⚠ ${r.reason}`);
|
|
366
|
+
chat = "start-failed";
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Code model (Ollama): probe + guide only — never auto-pull (BC3 + bounded scope).
|
|
373
|
+
const codeUrl = String(p.codingBaseURL || "").trim();
|
|
374
|
+
const codeModel = String(p.codingModelId || "").trim();
|
|
375
|
+
if (codeUrl) {
|
|
376
|
+
log("");
|
|
377
|
+
log(" Checking the agent code model…");
|
|
378
|
+
if (await probeModelServer(codeUrl)) {
|
|
379
|
+
log(` ✓ code model server is up at ${modelRoot(codeUrl)}.`);
|
|
380
|
+
} else if (isLocalModelUrl(codeUrl)) {
|
|
381
|
+
log(` ⚠ local code model server (${modelRoot(codeUrl)}) isn't answering.`);
|
|
382
|
+
if (codeModel) log(` Start Ollama and pull it: ollama serve & ollama pull ${codeModel}`);
|
|
383
|
+
} else {
|
|
384
|
+
log(` ⚠ your code model runs on a remote server (${modelRoot(codeUrl)}) that isn't`);
|
|
385
|
+
log(` answering. Start it there${codeModel ? ` (ollama pull ${codeModel})` : ""}.`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// RAG (task #127: the user wants the terminal RAG-ready). Ensure the embedding model +
|
|
390
|
+
// server so rag_index / rag_search work. Uses the account's configured embed URL when
|
|
391
|
+
// local, else the default local one on EMBED_PORT.
|
|
392
|
+
const rag = await ensureRag({ p, log, confirm });
|
|
393
|
+
|
|
394
|
+
return { chat, deps, rag };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Ensure the agent's required runtime deps (task #127 — parity with the Wizard's pip steps).
|
|
399
|
+
* python3 can't be auto-installed (guidance only); hf / mlx-lm / smolagents+openai are
|
|
400
|
+
* offered via pip. Each install is confirmed. Returns a status map; missing deps are
|
|
401
|
+
* reported, never silently ignored — the agent hard-fails without smolagents+openai.
|
|
402
|
+
*/
|
|
403
|
+
export async function ensureAgentDeps({ log, confirm }) {
|
|
404
|
+
const out = {};
|
|
405
|
+
log("");
|
|
406
|
+
log(" Checking the agent's dependencies…");
|
|
407
|
+
// python3 — the whole agent runs on it; we can't install it for the user.
|
|
408
|
+
if (!(await pyHasModule("sys"))) {
|
|
409
|
+
log(` ⚠ ${python3Bin()} not found. Install Python 3 (Xcode Command Line Tools or`);
|
|
410
|
+
log(" python.org), then run `gonext-cli doctor` again.");
|
|
411
|
+
out.python3 = "missing";
|
|
412
|
+
return out; // nothing else can be installed without python
|
|
413
|
+
}
|
|
414
|
+
out.python3 = "ok";
|
|
415
|
+
|
|
416
|
+
const ensurePkg = async (key, probeMod, pkgs, question) => {
|
|
417
|
+
if (await probeMod()) {
|
|
418
|
+
out[key] = "present";
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (!(await confirm(question, true))) {
|
|
422
|
+
out[key] = "skipped";
|
|
423
|
+
log(` Skipped ${pkgs.join(" ")} — install later: ${python3Bin()} -m pip install -U ${pkgs.join(" ")}`);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
await pipInstall(pkgs, log);
|
|
428
|
+
out[key] = "installed";
|
|
429
|
+
} catch (e) {
|
|
430
|
+
log(` ⚠ ${e instanceof Error ? e.message : e}`);
|
|
431
|
+
out[key] = "failed";
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
// hf CLI (model downloads). `huggingface_hub` provides the `hf` command.
|
|
436
|
+
await ensurePkg(
|
|
437
|
+
"hf",
|
|
438
|
+
() => which("hf"),
|
|
439
|
+
["huggingface_hub"],
|
|
440
|
+
"Install the Hugging Face CLI (needed to download models)?"
|
|
441
|
+
);
|
|
442
|
+
// mlx-lm — runs the local chat + embedding model servers.
|
|
443
|
+
await ensurePkg("mlxLm", () => pyHasModule("mlx_lm"), ["mlx-lm"], "Install mlx-lm (runs the local model servers)?");
|
|
444
|
+
// The agent framework itself — WITHOUT this every agent turn crashes at import.
|
|
445
|
+
await ensurePkg(
|
|
446
|
+
"agentLibs",
|
|
447
|
+
async () => (await pyHasModule("smolagents")) && (await pyHasModule("openai")),
|
|
448
|
+
["smolagents", "openai"],
|
|
449
|
+
"Install the agent framework (smolagents + openai) — required to run the agent?"
|
|
450
|
+
);
|
|
451
|
+
if (out.agentLibs === "skipped" || out.agentLibs === "failed") {
|
|
452
|
+
log(yellow(" ⚠ Without smolagents + openai the agent can't run — install them before asking a question."));
|
|
453
|
+
}
|
|
454
|
+
return out;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Start the MLX embeddings server (gonext_mlx_embed.py) DETACHED on `port` for `modelDir`,
|
|
459
|
+
* with its own pidfile/log. No-op + { ready:true } when one already answers (BC3). Waits up
|
|
460
|
+
* to waitMs for /v1/models to respond. Mirrors startChatModelServer.
|
|
461
|
+
*/
|
|
462
|
+
export async function startEmbedServer({ modelDir, port, waitMs = 60_000, log = () => {} }) {
|
|
463
|
+
const base = `http://127.0.0.1:${port}`;
|
|
464
|
+
if (await probeModelServer(base, { timeoutMs: 1500 })) {
|
|
465
|
+
return { started: false, ready: true, reason: "already-running" };
|
|
466
|
+
}
|
|
467
|
+
if (!(await pyHasModule("mlx_lm"))) {
|
|
468
|
+
return { started: false, ready: false, reason: "mlx-lm not installed (pip install mlx-lm)" };
|
|
469
|
+
}
|
|
470
|
+
await mkdir(DIR, { recursive: true });
|
|
471
|
+
const outLog = await open(EMBED_LOG_FILE, "a");
|
|
472
|
+
let pid;
|
|
473
|
+
try {
|
|
474
|
+
const child = spawn(
|
|
475
|
+
python3Bin(),
|
|
476
|
+
[EMBED_SCRIPT, "--model", modelDir, "--port", String(port), "--host", "127.0.0.1"],
|
|
477
|
+
{ detached: true, stdio: ["ignore", outLog.fd, outLog.fd], env: process.env }
|
|
478
|
+
);
|
|
479
|
+
pid = child.pid;
|
|
480
|
+
await writeFile(EMBED_PID_FILE, String(pid ?? ""), "utf8");
|
|
481
|
+
child.unref();
|
|
482
|
+
} finally {
|
|
483
|
+
await outLog.close();
|
|
484
|
+
}
|
|
485
|
+
log(` starting embeddings server (pid ${pid}) on port ${port} — waiting for it to load…`);
|
|
486
|
+
const deadline = Date.now() + waitMs;
|
|
487
|
+
while (Date.now() < deadline) {
|
|
488
|
+
await sleep(2500);
|
|
489
|
+
if (await probeModelServer(base, { timeoutMs: 2000 })) return { started: true, ready: true, pid };
|
|
490
|
+
}
|
|
491
|
+
return { started: true, ready: false, pid, reason: `embeddings server still loading (see ${EMBED_LOG_FILE})` };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Ensure RAG is ready for the agent terminal (task #127): boto3 (cloud/S3 storage), the
|
|
496
|
+
* embedding model, and a running embeddings server. Uses the account's configured embed URL
|
|
497
|
+
* when it's local; otherwise the default local server on EMBED_PORT. BC3: an already-running
|
|
498
|
+
* embed server is left alone. Returns a status map.
|
|
499
|
+
*/
|
|
500
|
+
export async function ensureRag({ p, log, confirm }) {
|
|
501
|
+
const out = {};
|
|
502
|
+
log("");
|
|
503
|
+
log(" Checking RAG (agent knowledge base)…");
|
|
504
|
+
// boto3 — needed only for CLOUD (S3) RAG; local RAG stores on disk. Cheap + harmless, so
|
|
505
|
+
// offer it so cloud RAG works later without another setup pass.
|
|
506
|
+
if (!(await pyHasModule("boto3"))) {
|
|
507
|
+
if (await confirm("Install boto3 (needed only if you use cloud/S3 RAG)?", true)) {
|
|
508
|
+
try {
|
|
509
|
+
await pipInstall(["boto3"], log);
|
|
510
|
+
out.boto3 = "installed";
|
|
511
|
+
} catch (e) {
|
|
512
|
+
log(` ⚠ ${e instanceof Error ? e.message : e}`);
|
|
513
|
+
out.boto3 = "failed";
|
|
514
|
+
}
|
|
515
|
+
} else out.boto3 = "skipped";
|
|
516
|
+
} else out.boto3 = "present";
|
|
517
|
+
|
|
518
|
+
// Embed server URL: the account's configured local one, else the default local server.
|
|
519
|
+
const configuredEmbed = String(p?.ragEmbedUrl || "").trim();
|
|
520
|
+
const embedUrl =
|
|
521
|
+
configuredEmbed && isLocalModelUrl(configuredEmbed)
|
|
522
|
+
? configuredEmbed
|
|
523
|
+
: `http://127.0.0.1:${EMBED_PORT}`;
|
|
524
|
+
const embedPort = parsePort(embedUrl) || EMBED_PORT;
|
|
525
|
+
|
|
526
|
+
if (await probeModelServer(embedUrl)) {
|
|
527
|
+
log(` ✓ embeddings server already running at ${modelRoot(embedUrl)} — leaving it alone.`);
|
|
528
|
+
out.embed = "already-running";
|
|
529
|
+
return out;
|
|
530
|
+
}
|
|
531
|
+
if (configuredEmbed && !isLocalModelUrl(configuredEmbed)) {
|
|
532
|
+
log(` ⚠ your embeddings server (${modelRoot(configuredEmbed)}) is remote and isn't`);
|
|
533
|
+
log(" answering. Start it there — this machine can't launch it.");
|
|
534
|
+
out.embed = "remote";
|
|
535
|
+
return out;
|
|
536
|
+
}
|
|
537
|
+
// Download the embed model if missing.
|
|
538
|
+
if (!(await modelDirReady(EMBED_DIR))) {
|
|
539
|
+
if (!(await confirm(`Download the RAG embedding model ${EMBED_REPO.split("/").pop()} (~4 GB)?`, true))) {
|
|
540
|
+
log(" Skipped. To do it yourself:");
|
|
541
|
+
log(` hf download ${EMBED_REPO} --local-dir ${EMBED_DIR}`);
|
|
542
|
+
log(` gonext-cli embed --model ${EMBED_DIR} --port ${embedPort}`);
|
|
543
|
+
out.embed = "skipped-model";
|
|
544
|
+
return out;
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
await downloadModel({ repo: EMBED_REPO, dir: EMBED_DIR, log });
|
|
548
|
+
} catch (e) {
|
|
549
|
+
log(` ⚠ ${e instanceof Error ? e.message : e}`);
|
|
550
|
+
out.embed = "download-failed";
|
|
551
|
+
return out;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (!(await confirm(`Start the RAG embeddings server on port ${embedPort} now?`, true))) {
|
|
555
|
+
log(` Skipped. Start it later: gonext-cli embed --model ${EMBED_DIR} --port ${embedPort}`);
|
|
556
|
+
out.embed = "not-started";
|
|
557
|
+
return out;
|
|
558
|
+
}
|
|
559
|
+
const r = await startEmbedServer({ modelDir: EMBED_DIR, port: embedPort, log });
|
|
560
|
+
if (r.ready) {
|
|
561
|
+
log(` ✓ embeddings server is up on port ${embedPort}.`);
|
|
562
|
+
out.embed = "started";
|
|
563
|
+
} else if (r.started) {
|
|
564
|
+
log(` … embeddings server is loading in the background on port ${embedPort}.`);
|
|
565
|
+
out.embed = "loading";
|
|
566
|
+
} else {
|
|
567
|
+
log(` ⚠ ${r.reason}`);
|
|
568
|
+
out.embed = "start-failed";
|
|
569
|
+
}
|
|
570
|
+
return out;
|
|
571
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tiens.nguyen/gonext-cli",
|
|
3
|
+
"version": "1.0.350",
|
|
4
|
+
"description": "GoNext CLI — the gonext terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/tiennsloit/gonext.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/tiennsloit/gonext#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/tiennsloit/gonext/issues"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"gonext-cli": "./gonext-cli.mjs",
|
|
17
|
+
"gonext-local-worker": "./gonext-cli.mjs",
|
|
18
|
+
"gonext": "./gonext-repl.mjs"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "python3 -m unittest discover tests -v && node --test tests/*.test.mjs",
|
|
22
|
+
"test:js": "node --test tests/*.test.mjs",
|
|
23
|
+
"deploy:local": "npm install -g .",
|
|
24
|
+
"run": "gonext-cli",
|
|
25
|
+
"publish:org": "npm publish --access public"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"gonext-cli.mjs",
|
|
29
|
+
"gonext-repl.mjs",
|
|
30
|
+
"device-login.mjs",
|
|
31
|
+
"daemon-control.mjs",
|
|
32
|
+
"model-doctor.mjs",
|
|
33
|
+
"coding-key-prompt.mjs",
|
|
34
|
+
"gonext_probe_agent.py",
|
|
35
|
+
"gonext_agent_chat.py",
|
|
36
|
+
"gonext_transcribe.py",
|
|
37
|
+
"gonext_mlx_embed.py",
|
|
38
|
+
"thinking_words.txt",
|
|
39
|
+
"README.md",
|
|
40
|
+
"launchd/"
|
|
41
|
+
],
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"dotenv": "^16.4.5",
|
|
47
|
+
"express": "^4.21.0",
|
|
48
|
+
"openai": "^4.77.0"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=18"
|
|
52
|
+
}
|
|
53
|
+
}
|