@tiens.nguyen/gonext-local-worker 1.0.343 → 1.0.347

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/gonext-repl.mjs CHANGED
@@ -377,6 +377,9 @@ function slashRestoreHistory() {
377
377
  // which the earlier relative-cursor overlay hit whenever readline refreshed or the
378
378
  // menu was near the bottom of the screen and scrolled).
379
379
  let slashOverlayActive = false;
380
+ // While true, readline's echo + line-refresh are muted so a typed secret (an API key) never
381
+ // reaches the terminal or scrollback (task #127 — see askSecret + the _writeToOutput guards).
382
+ let secretInput = false;
380
383
 
381
384
  // Visible width of PROMPT (">> ") — used to put the caret back on the input line by
382
385
  // COLUMN, since the string itself carries color escapes that don't occupy cells.
@@ -608,6 +611,20 @@ const ask = async (q) => {
608
611
  // re-uses the default. This replaces a fragile `(await ask()) || "y"` idiom where any
609
612
  // leftover character (a stray escape byte, a stray space) silently defeated the
610
613
  // default — the reported "[Y/n] + Enter registered as No" bug.
614
+ // Masked prompt for a secret (an API key). Mutes readline's echo/refresh (secretInput)
615
+ // so the typed value never hits the terminal or scrollback (task #127). Returns "" on a
616
+ // bare Enter (skip).
617
+ const askSecret = async (q) => {
618
+ process.stdout.write(q);
619
+ secretInput = true;
620
+ try {
621
+ const a = await nextLine();
622
+ return (a ?? "").trim();
623
+ } finally {
624
+ secretInput = false;
625
+ process.stdout.write("\n"); // the muted Enter left the cursor mid-line
626
+ }
627
+ };
611
628
  const askYesNo = async (question, defaultYes) => {
612
629
  const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
613
630
  const raw = (await ask(question + suffix)).trim().toLowerCase();
@@ -768,6 +785,22 @@ async function addOutputTokens(cwd, delta) {
768
785
  }
769
786
  }
770
787
 
788
+ // Save the agent coding-model API key (task #127): the terminal prompts for the Kimi K3 key
789
+ // when the default cloud coder has none, and POSTs it here (worker-key authed). Returns true
790
+ // on success. Best-effort — a failure just leaves the reminder for next startup.
791
+ async function saveCodingKey(apiKey) {
792
+ try {
793
+ const r = await fetch(`${apiBase}/api/worker/coding-key`, {
794
+ method: "POST",
795
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
796
+ body: JSON.stringify({ apiKey }),
797
+ });
798
+ return r.ok;
799
+ } catch {
800
+ return false;
801
+ }
802
+ }
803
+
771
804
  async function resetGlobalOutputTokens() {
772
805
  try {
773
806
  const r = await fetch(`${apiBase}/api/worker/output-tokens/reset-global`, {
@@ -1395,6 +1428,9 @@ rl._writeToOutput = (s) => {
1395
1428
  // slashOverlayActive: the slash-command overlay owns drawing (input line + menu), so
1396
1429
  // readline's own echo must be swallowed or it fights the overlay (bug #109).
1397
1430
  if (following || listPickerActive || slashOverlayActive) return;
1431
+ // secretInput (task #127): the user is typing an API key — swallow the echo so it never
1432
+ // appears in the terminal or scrollback.
1433
+ if (secretInput) return;
1398
1434
  if (_origWriteToOutput) _origWriteToOutput(s);
1399
1435
  else process.stdout.write(s);
1400
1436
  };
@@ -1407,6 +1443,7 @@ rl._refreshLine = () => {
1407
1443
  // Suppress readline's line refresh (which does clearScreenDown → would wipe our menu)
1408
1444
  // while the slash overlay owns the screen. We redraw the input line ourselves (#109).
1409
1445
  if (following || listPickerActive || slashOverlayActive) return;
1446
+ if (secretInput) return; // don't redraw the typed key (task #127)
1410
1447
  if (_origRefreshLine) _origRefreshLine();
1411
1448
  };
1412
1449
 
@@ -2316,9 +2353,50 @@ async function main() {
2316
2353
  } catch {
2317
2354
  /* doctor is best-effort — never block the REPL from starting */
2318
2355
  }
2356
+ // Task #127: the default coding model is a cloud coder (Kimi K3) with no API key yet.
2357
+ // Ask for it. If skipped, the account stays in this "waiting" state (server-side:
2358
+ // coder configured, no key) and we ASK AGAIN on every startup until it's set. Until
2359
+ // then the agent falls back to the chat model for coding, so the terminal still works.
2360
+ if (p.codingKeyMissing) {
2361
+ const model = String(p.codingKeyModel || "the coding model").trim();
2362
+ console.log(
2363
+ dim(`\ngonext: your agent coding model (`) + cyan(model) + dim(`) needs an API key.`)
2364
+ );
2365
+ console.log(
2366
+ dim(" Until it's set, coding falls back to your local model. Enter the key now,")
2367
+ );
2368
+ console.log(dim(" or press Enter to skip (I'll ask again next time).\n"));
2369
+ const key = await askSecret(` ${model} API key: `);
2370
+ if (key) {
2371
+ const ok = await saveCodingKey(key);
2372
+ console.log(
2373
+ ok
2374
+ ? green(` ✓ saved — ${model} is now your coding model.\n`)
2375
+ : red(" couldn't save the key — try again next start, or set it in web Settings → Agent.\n")
2376
+ );
2377
+ } else {
2378
+ console.log(
2379
+ dim(" ⏳ waiting for your ") + cyan(model) + dim(" API key — I'll ask again next time.\n")
2380
+ );
2381
+ }
2382
+ }
2319
2383
  }
2320
2384
  } catch (err) {
2321
- console.error(red(`gonext: ${err.message}`));
2385
+ // Task #127 (zero-config): a freshly-paired account is seeded with a default local
2386
+ // agent model, so this "no model" path is normally unreachable. An account paired
2387
+ // BEFORE that change has none — point them at re-login, which now seeds it, rather than
2388
+ // only the raw web-Settings instruction.
2389
+ if (/no agent model configured/i.test(err?.message ?? "")) {
2390
+ console.error(
2391
+ red("gonext: no agent model is configured for your account yet.") +
2392
+ dim(
2393
+ "\n Run `gonext-local-worker login` to re-connect — new logins set up a local" +
2394
+ "\n default automatically. Or set the Agent model URL in the web app → Settings → Agent."
2395
+ )
2396
+ );
2397
+ } else {
2398
+ console.error(red(`gonext: ${err.message}`));
2399
+ }
2322
2400
  process.exit(1);
2323
2401
  }
2324
2402
  console.log(dim("Ask about this repo. Type / to see commands (Tab completes). Ctrl-C aborts a running turn.\n"));
package/model-doctor.mjs CHANGED
@@ -8,14 +8,52 @@
8
8
  // remote box). Downloads happen only when the model dir is genuinely absent AND the user
9
9
  // opts in.
10
10
  import { spawn, execFile } from "node:child_process";
11
- import { open, mkdir, readdir } from "node:fs/promises";
11
+ import { open, mkdir, readdir, writeFile } from "node:fs/promises";
12
12
  import { homedir, totalmem } from "node:os";
13
13
  import { join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
14
15
 
15
16
  // Minimum RAM to run the default 14B-4bit chat model comfortably on Apple Silicon.
16
17
  const MIN_CHAT_RAM_GB = 16;
17
18
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
18
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
+
19
57
  /**
20
58
  * The yellow RAM notice shown before the multi-GB model download (user request): the
21
59
  * default 14B-4bit model wants a Mac with ≥16 GB. When the machine has less, add a plain
@@ -42,6 +80,8 @@ export function chatRamWarningLines(repo, totalBytes) {
42
80
  const DIR = join(homedir(), ".gonext");
43
81
  export const MODEL_PID_FILE = join(DIR, "model.pid");
44
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");
45
85
 
46
86
  // The default base chat model (task #127 decision): matches the Wizard's model-chat step.
47
87
  export const DEFAULT_CHAT_REPO = "mlx-community/Qwen3-14B-4bit";
@@ -229,6 +269,13 @@ async function fetchModelConfig(apiBase, workerKey) {
229
269
  */
230
270
  export async function runDoctor({ apiBase, workerKey, log, confirm }) {
231
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
+
232
279
  // Chat model: prefer the account's configured URL; fall back to the default local one.
233
280
  const configuredChat = String(p.agentBaseURL || "").trim();
234
281
  const chatUrl = configuredChat || `http://127.0.0.1:${DEFAULT_CHAT_PORT}`;
@@ -338,5 +385,187 @@ export async function runDoctor({ apiBase, workerKey, log, confirm }) {
338
385
  log(` answering. Start it there${codeModel ? ` (ollama pull ${codeModel})` : ""}.`);
339
386
  }
340
387
  }
341
- return { chat };
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-local-worker 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-local-worker 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-local-worker 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;
342
571
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.343",
3
+ "version": "1.0.347",
4
4
  "description": "Polls GoNext cloud API for async local LLM jobs and runs them against Ollama/OpenAI-compatible servers on this Mac",
5
5
  "type": "module",
6
6
  "license": "MIT",