@gorlitzer-labs/seldon 0.1.15 → 0.1.17

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 (3) hide show
  1. package/README.md +8 -0
  2. package/bin/seldon.js +178 -9
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -41,6 +41,14 @@ seldon status # what's running (pids)
41
41
  seldon down # stop everything
42
42
  ```
43
43
 
44
+ **Voice brain (Qwen or Bonsai):** the first `seldon up` after installing the voice
45
+ asks once — **q** for Qwen 3.6-35B (in-process, sharpest, ~20 GB) or **b** for
46
+ Bonsai 2-27B (a local llama-server, lighter ~7 GB, tool-capable) — and remembers
47
+ the choice. Switch anytime with `seldon up --brain=qwen|bonsai`. When you pick
48
+ Bonsai, seldon fetches its runtime + model (~7 GB, macOS/Apple-silicon) and runs
49
+ the server as a managed daemon; `seldon status` shows the active brain, `seldon
50
+ down` stops it.
51
+
44
52
  **Phone access:** `seldon up --tailnet` binds the voice to this machine's Tailscale
45
53
  IP and prints `http://<tailnet-ip>:8770` — open it on any device on your tailnet.
46
54
  The default `seldon up` stays loopback-only. Since Demerzel can act on the host,
package/bin/seldon.js CHANGED
@@ -275,6 +275,27 @@ function confirm(question) {
275
275
  });
276
276
  }
277
277
 
278
+ // Read one keypress and return the character (same stdin-safe pattern as confirm).
279
+ function readKey(question) {
280
+ return new Promise((resolve) => {
281
+ if (!process.stdin.isTTY) return resolve("");
282
+ process.stdout.write(question);
283
+ readline.emitKeypressEvents(process.stdin);
284
+ process.stdin.setRawMode(true);
285
+ process.stdin.resume();
286
+ process.stdin.ref();
287
+ const onKey = (str, key) => {
288
+ process.stdin.off("keypress", onKey);
289
+ process.stdin.setRawMode(false);
290
+ process.stdin.pause();
291
+ process.stdin.unref();
292
+ process.stdout.write((str || "") + "\n");
293
+ resolve((key && key.ctrl && key.name === "c") ? "" : (str || ""));
294
+ };
295
+ process.stdin.on("keypress", onKey);
296
+ });
297
+ }
298
+
278
299
  async function doUninstall(ids, { yes = false, all = false } = {}) {
279
300
  const targets = all ? MODULES.map((m) => m.id) : ids;
280
301
  if (!targets.length) { console.log(C.red("nothing to uninstall — name modules or use `seldon uninstall --all`.")); process.exitCode = 1; return; }
@@ -369,6 +390,99 @@ async function ensureDemerzelModels({ ask = true } = {}) {
369
390
  return run(path.join(home, ".venv/bin/python"), [path.join(home, "scripts/fetch-models.py")], { cwd: home, env: { ...process.env } });
370
391
  }
371
392
 
393
+ // ---- the voice brain: Qwen (in-process) or Bonsai (local llama-server) ------
394
+ // Bonsai runs as a separate OpenAI-compatible server (PrismML's llama.cpp),
395
+ // which frees the ~20 GB the in-process Qwen holds. seldon manages that server
396
+ // as a daemon so the choice is durable, and remembers it in ~/.seldon/brain.
397
+ const BONSAI_HOME = path.join(SELDON_HOME, "bonsai");
398
+ const BONSAI_BIN = path.join(BONSAI_HOME, "bin", "llama-server");
399
+ const BONSAI_PORT = 8081;
400
+ const BRAIN_FILE = path.join(SELDON_HOME, "brain");
401
+ const BONSAI_RELEASE = "prism-b10709-9a9394a"; // pinned PrismML llama.cpp build
402
+ const BONSAI_GGUF_REPO = "prism-ml/Ternary-Bonsai-2-27B-gguf";
403
+ const BONSAI_GGUF_FILE = "Ternary-Bonsai-2-27B-PQ2_0.gguf";
404
+
405
+ const savedBrain = () => { try { return fs.readFileSync(BRAIN_FILE, "utf8").trim(); } catch { return ""; } };
406
+ const saveBrain = (b) => { try { fs.mkdirSync(SELDON_HOME, { recursive: true }); fs.writeFileSync(BRAIN_FILE, b); } catch {} };
407
+
408
+ function bonsaiGgufPath() {
409
+ const local = path.join(BONSAI_HOME, BONSAI_GGUF_FILE);
410
+ if (fs.existsSync(local)) return local;
411
+ try {
412
+ const base = path.join(process.env.HOME, ".cache/huggingface/hub/models--prism-ml--Ternary-Bonsai-2-27B-gguf/snapshots");
413
+ for (const s of fs.readdirSync(base)) {
414
+ const f = path.join(base, s, BONSAI_GGUF_FILE);
415
+ if (fs.existsSync(f)) return fs.realpathSync(f);
416
+ }
417
+ } catch {}
418
+ return null;
419
+ }
420
+ const bonsaiInstalled = () => fs.existsSync(BONSAI_BIN) && !!bonsaiGgufPath();
421
+ const bonsaiHealthy = () => { try { execSync(`curl -sf -o /dev/null --max-time 3 http://127.0.0.1:${BONSAI_PORT}/health`, { stdio: "ignore" }); return true; } catch { return false; } };
422
+
423
+ function startBonsaiServer() {
424
+ if (bonsaiHealthy()) return { already: true };
425
+ const gguf = bonsaiGgufPath();
426
+ // tuned: flash-attn + single slot (warm prefix sticks) + cache reuse
427
+ return startDaemon("bonsai", BONSAI_BIN,
428
+ ["--jinja", "-m", gguf, "--host", "127.0.0.1", "--port", String(BONSAI_PORT),
429
+ "-c", "8192", "-ngl", "999", "-fa", "on", "--parallel", "1", "--cache-reuse", "256"]);
430
+ }
431
+
432
+ // Download the Bonsai runtime (PrismML prebuilt macOS binary) + the GGUF. Big,
433
+ // so always offered, never silent. macOS/Apple-silicon only (like the voice).
434
+ async function ensureBonsaiAssets() {
435
+ if (bonsaiInstalled()) return true;
436
+ if (process.platform !== "darwin" || process.arch !== "arm64") {
437
+ console.log(C.red(" Bonsai brain is macOS / Apple-silicon only.")); return false;
438
+ }
439
+ const force = process.argv.includes("--yes");
440
+ const ok = force || (process.stdin.isTTY
441
+ ? await confirm(C.gold(" Bonsai needs a one-time download (~7 GB model + runtime). Get it now? [y/N] "))
442
+ : false);
443
+ if (!ok) { console.log(C.dim(" skipped — Bonsai not fetched.")); return false; }
444
+ fs.mkdirSync(path.join(BONSAI_HOME, "bin"), { recursive: true });
445
+ if (!fs.existsSync(BONSAI_BIN)) {
446
+ console.log(C.dim(" fetching Bonsai runtime (PrismML llama.cpp, macOS-arm64)…"));
447
+ const url = `https://github.com/PrismML-Eng/llama.cpp/releases/download/${BONSAI_RELEASE}/llama-${BONSAI_RELEASE}-bin-macos-arm64.tar.gz`;
448
+ const tmp = path.join(BONSAI_HOME, ".dl");
449
+ // extract anywhere, then find llama-server + its sibling dylibs (layout-proof)
450
+ run("bash", ["-c",
451
+ `set -e; rm -rf '${tmp}'; mkdir -p '${tmp}'; curl -fSL '${url}' -o '${tmp}/b.tgz'; ` +
452
+ `tar -xzf '${tmp}/b.tgz' -C '${tmp}'; ` +
453
+ `d=$(dirname "$(find '${tmp}' -name llama-server -type f | head -1)"); ` +
454
+ `cp "$d/llama-server" "$d"/*.dylib '${path.join(BONSAI_HOME, "bin")}/' 2>/dev/null || cp "$d/llama-server" '${path.join(BONSAI_HOME, "bin")}/'; ` +
455
+ `chmod +x '${BONSAI_BIN}'; rm -rf '${tmp}'`]);
456
+ }
457
+ if (!bonsaiGgufPath()) {
458
+ console.log(C.dim(" fetching Bonsai 2 model (~7 GB, resumable)…"));
459
+ const url = `https://huggingface.co/${BONSAI_GGUF_REPO}/resolve/main/${BONSAI_GGUF_FILE}`;
460
+ run("bash", ["-c", `curl -fL -C - '${url}' -o '${path.join(BONSAI_HOME, BONSAI_GGUF_FILE)}'`]);
461
+ }
462
+ return bonsaiInstalled();
463
+ }
464
+
465
+ // Decide the voice brain: --brain=X flag > saved choice > a one-key prompt when
466
+ // the voice is installed and we're interactive > qwen. The prompt is the "right
467
+ // moment": only surfaced once, then remembered.
468
+ async function chooseBrain() {
469
+ const flag = (process.argv.find((a) => a.startsWith("--brain=")) || "").split("=")[1];
470
+ if (flag) { saveBrain(flag); return flag; }
471
+ const saved = savedBrain();
472
+ if (saved) return saved;
473
+ if (demerzelInstalled() && process.stdin.isTTY) {
474
+ console.log(C.bold("\n Voice brain — pick once (remembered):"));
475
+ console.log(" " + C.cyan("q") + C.dim(" Qwen 3.6-35B — in-process, sharpest, ~20 GB RAM"));
476
+ console.log(" " + C.cyan("b") + C.dim(" Bonsai 2-27B — local server, lighter (~7 GB), tool-capable"));
477
+ const k = await readKey(C.gold(" [q/b] "));
478
+ const brain = /^b/i.test(k) ? "bonsai" : "qwen";
479
+ console.log(C.dim(` → ${brain}`));
480
+ saveBrain(brain);
481
+ return brain;
482
+ }
483
+ return "qwen";
484
+ }
485
+
372
486
  async function up() {
373
487
  console.log(C.gold("\n seldon — bringing the stack up\n"));
374
488
  const go = []; // where-to-go lines
@@ -382,16 +496,34 @@ async function up() {
382
496
  else {
383
497
  if (!demerzelModelsReady()) await ensureDemerzelModels({ ask: true }); // offer the fetch inline
384
498
  if (demerzelModelsReady()) {
385
- // --tailnet exposes the voice on this machine's tailnet IP (reachable
386
- // from your phone). Default stays loopback (this machine only).
499
+ const env = { ...process.env };
500
+ // --- choose the brain (remembered) ---
501
+ let brain = await chooseBrain();
502
+ if (brain === "bonsai") {
503
+ if (!bonsaiInstalled()) await ensureBonsaiAssets();
504
+ if (bonsaiInstalled()) {
505
+ const bs = startBonsaiServer();
506
+ env.DEMERZEL_BRAIN = "bonsai";
507
+ env.DEMERZEL_LLM_SERVER = `http://127.0.0.1:${BONSAI_PORT}`;
508
+ go.push(`${C.bold("Brain")} Bonsai 2 ${C.dim("(local server :8081, ~7 GB) " + (bs.already ? "already up" : "starting"))}`);
509
+ } else {
510
+ later.push("Bonsai unavailable — using Qwen. (macOS/arm64 + the ~7 GB download are needed.)");
511
+ brain = "qwen"; saveBrain("qwen");
512
+ }
513
+ }
514
+ if (brain === "qwen") go.push(`${C.bold("Brain")} Qwen 3.6-35B ${C.dim("(in-process)")}`);
515
+ // --- tailnet exposure (phone). Default loopback (this machine only) ---
387
516
  const wantTailnet = process.argv.includes("--tailnet") || process.argv.includes("--phone");
388
517
  let host = null;
389
518
  if (wantTailnet) { host = tailnetIp(); if (!host) later.push("--tailnet: no Tailscale IP found (is Tailscale running?) — started the voice on localhost instead."); }
390
- const env = { ...process.env };
391
- if (host) { env.DEMERZEL_HOST = host; stopDaemon("demerzel"); } // rebind: restart if it was on loopback
519
+ if (host) env.DEMERZEL_HOST = host;
520
+ // (Re)start the voice only when it isn't running, or when an explicit
521
+ // change was requested (--brain / --tailnet) — otherwise leave it be.
522
+ const explicit = process.argv.some((a) => a.startsWith("--brain=")) || wantTailnet;
523
+ if (explicit && daemonUp("demerzel")) stopDaemon("demerzel");
392
524
  const r = startDaemon("demerzel", path.join(home, ".venv/bin/python"), ["-m", "demerzel.server"], { cwd: home, env });
393
525
  const url = host ? `http://${host}:8770` : "http://localhost:8770";
394
- go.push(`${C.bold("Voice")} ${C.cyan(url)} ${C.dim(host ? "(open it on your phone — on your tailnet)" : (r.already ? "(already up)" : "(starting — models load, ~30s)"))}`);
526
+ go.push(`${C.bold("Voice")} ${C.cyan(url)} ${C.dim(host ? "(open on your phone — tailnet)" : (r.already ? "(already up)" : "(starting — models load, ~30s)"))}`);
395
527
  if (host) later.push(C.dim("Voice is on your tailnet — anyone on it can talk to Demerzel (and it can act on this Mac). Run with DEMERZEL_READONLY=1 to share it safely."));
396
528
  } else {
397
529
  later.push("Voice (demerzel): models not fetched yet — run `seldon up` again when you're ready to pull them.");
@@ -417,9 +549,39 @@ async function up() {
417
549
  }
418
550
 
419
551
  function down() {
420
- const names = ["demerzel", "factory-watch"];
421
- const stopped = names.filter((n) => stopDaemon(n));
422
- console.log(stopped.length ? C.green("stopped: ") + stopped.join(", ") : C.dim("nothing was running."));
552
+ const tracked = ["demerzel", "bonsai", "factory-watch"].filter((n) => stopDaemon(n));
553
+ // Belt-and-suspenders so nothing leaks: also reap by seldon's OWN ports and
554
+ // process signatures. This catches a server started outside seldon (manual
555
+ // launch) or a pidfile that drifted. Scoped tightly — only our ports and
556
+ // command shapes — so unrelated processes are never touched.
557
+ const probes = [
558
+ "lsof -nP -iTCP:8081 -sTCP:LISTEN -t", // bonsai llama-server
559
+ "lsof -nP -iTCP:8770 -sTCP:LISTEN -t", // voice UI
560
+ "lsof -nP -iTCP:8765 -sTCP:LISTEN -t", // voice websocket
561
+ `pgrep -f '${SELDON_HOME}/bonsai/bin/llama-server'`,
562
+ "pgrep -f 'demerzel[.]server'",
563
+ "pgrep -f 'factory.*watch --all'",
564
+ ];
565
+ const collect = () => {
566
+ const s = new Set();
567
+ for (const c of probes) {
568
+ try { execSync(c, { stdio: ["ignore", "pipe", "ignore"] }).toString().split(/\s+/).filter(Boolean).forEach((p) => s.add(p)); } catch {}
569
+ }
570
+ s.delete(String(process.pid));
571
+ return [...s];
572
+ };
573
+ const first = collect();
574
+ for (const p of first) { try { process.kill(Number(p), "SIGTERM"); } catch {} }
575
+ let reaped = 0;
576
+ if (first.length) {
577
+ try { execSync("sleep 1.5"); } catch {}
578
+ for (const p of collect()) { try { process.kill(Number(p), "SIGKILL"); reaped++; } catch {} }
579
+ reaped = first.length; // count what we signalled
580
+ }
581
+ const parts = [];
582
+ if (tracked.length) parts.push("stopped " + tracked.join(", "));
583
+ if (first.length) parts.push(`reaped ${first.length} straggler${first.length > 1 ? "s" : ""}`);
584
+ console.log(parts.length ? C.green(parts.join(" · ")) : C.dim("nothing was running."));
423
585
  }
424
586
 
425
587
  // Version of a globally-installed node package, across pnpm / npm / bun roots.
@@ -465,7 +627,13 @@ function statusCmd() {
465
627
 
466
628
  console.log(` ${dot} ${C.bold(m.id.padEnd(11))} ${detail}${svc}`);
467
629
  }
468
- if (demerzelInstalled()) console.log(C.dim(`\n demerzel LLM: ${process.env.DEMERZEL_LLM || "mlx-community/Qwen3.6-35B-A3B-4bit (default)"}`));
630
+ if (demerzelInstalled()) {
631
+ const brain = savedBrain() || "qwen";
632
+ const bstate = brain === "bonsai"
633
+ ? (daemonUp("bonsai") || bonsaiHealthy() ? C.green("server up") : C.dim("server down"))
634
+ : C.dim("in-process");
635
+ console.log(C.dim(`\n voice brain: `) + C.bold(brain) + " " + bstate + C.dim(" · switch: seldon up --brain=qwen|bonsai"));
636
+ }
469
637
  console.log(C.dim(`\n start: seldon up · stop: seldon down · logs: ${RUN_DIR}/\n`));
470
638
  }
471
639
 
@@ -550,6 +718,7 @@ ${C.bold("seldon")} — install the Seldon stack, pick what you use.
550
718
  ${C.bold("seldon")} open the checklist (space to pick, enter to install)
551
719
  ${C.bold("seldon up")} start the stack (voice + supervisor) and print where to go
552
720
  ${C.bold("seldon up --tailnet")} also expose the voice on your tailnet (reach it from your phone)
721
+ ${C.bold("seldon up --brain=X")} pick the voice brain: qwen (in-process) | bonsai (local server); remembered
553
722
  ${C.bold("seldon status")} what's running · ${C.bold("seldon down")} stop it
554
723
  ${C.bold("seldon install")} [ids…] install everything picked, or the named modules
555
724
  ${C.bold("seldon uninstall")} ids… remove the named modules (global CLI / isolated venv)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gorlitzer-labs/seldon",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Install the Seldon stack — pick your tools from a checklist; each installs via its native method.",
5
5
  "type": "module",
6
6
  "bin": {