@gorlitzer-labs/seldon 0.1.14 → 0.1.16

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 +20 -5
  2. package/bin/seldon.js +158 -7
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -33,13 +33,28 @@ seldon list # everything available
33
33
  One command brings up the always-on services and prints where to go:
34
34
 
35
35
  ```bash
36
- seldon up # starts the voice (demerzel) + the supervisor (factory watch),
37
- # then prints: Voice → http://localhost:8770, how to put agents
38
- # to work, rooms, phone access
39
- seldon status # what's running (pids)
40
- seldon down # stop everything
36
+ seldon up # starts the voice (demerzel) + the supervisor (factory watch),
37
+ # then prints: Voice → http://localhost:8770, how to put agents
38
+ # to work, rooms, phone access
39
+ seldon up --tailnet # also expose the voice on your tailnet → reach it from your phone
40
+ seldon status # what's running (pids)
41
+ seldon down # stop everything
41
42
  ```
42
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
+
52
+ **Phone access:** `seldon up --tailnet` binds the voice to this machine's Tailscale
53
+ IP and prints `http://<tailnet-ip>:8770` — open it on any device on your tailnet.
54
+ The default `seldon up` stays loopback-only. Since Demerzel can act on the host,
55
+ exposing it means anyone on your tailnet can talk to it — run with
56
+ `DEMERZEL_READONLY=1` when sharing.
57
+
43
58
  `seldon up` only starts what's installed. demerzel serves the voice UI on
44
59
  `http://localhost:8770`. Its model set (~25 GB) is a one-time download —
45
60
  `seldon install demerzel` **offers to fetch it right there** ([y/N]), and `seldon up`
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; }
@@ -335,6 +356,18 @@ function tailnetIp() {
335
356
  if (/^100\./.test(ip)) return ip;
336
357
  } catch {}
337
358
  }
359
+ // CLI missing/moved? The tailnet address is still on a utun interface —
360
+ // read it straight from the OS (Tailscale's CGNAT range is 100.64.0.0/10).
361
+ try {
362
+ const out = execSync("ifconfig", { stdio: ["ignore", "pipe", "ignore"] }).toString();
363
+ const m = out.match(/inet (100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d+\.\d+)/);
364
+ if (m) return m[1];
365
+ } catch {}
366
+ try { // linux `ip addr`
367
+ const out = execSync("ip -4 addr", { stdio: ["ignore", "pipe", "ignore"] }).toString();
368
+ const m = out.match(/inet (100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d+\.\d+)/);
369
+ if (m) return m[1];
370
+ } catch {}
338
371
  return null;
339
372
  }
340
373
 
@@ -357,6 +390,99 @@ async function ensureDemerzelModels({ ask = true } = {}) {
357
390
  return run(path.join(home, ".venv/bin/python"), [path.join(home, "scripts/fetch-models.py")], { cwd: home, env: { ...process.env } });
358
391
  }
359
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
+
360
486
  async function up() {
361
487
  console.log(C.gold("\n seldon — bringing the stack up\n"));
362
488
  const go = []; // where-to-go lines
@@ -370,16 +496,34 @@ async function up() {
370
496
  else {
371
497
  if (!demerzelModelsReady()) await ensureDemerzelModels({ ask: true }); // offer the fetch inline
372
498
  if (demerzelModelsReady()) {
373
- // --tailnet exposes the voice on this machine's tailnet IP (reachable
374
- // 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) ---
375
516
  const wantTailnet = process.argv.includes("--tailnet") || process.argv.includes("--phone");
376
517
  let host = null;
377
518
  if (wantTailnet) { host = tailnetIp(); if (!host) later.push("--tailnet: no Tailscale IP found (is Tailscale running?) — started the voice on localhost instead."); }
378
- const env = { ...process.env };
379
- 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");
380
524
  const r = startDaemon("demerzel", path.join(home, ".venv/bin/python"), ["-m", "demerzel.server"], { cwd: home, env });
381
525
  const url = host ? `http://${host}:8770` : "http://localhost:8770";
382
- 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)"))}`);
383
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."));
384
528
  } else {
385
529
  later.push("Voice (demerzel): models not fetched yet — run `seldon up` again when you're ready to pull them.");
@@ -405,7 +549,7 @@ async function up() {
405
549
  }
406
550
 
407
551
  function down() {
408
- const names = ["demerzel", "factory-watch"];
552
+ const names = ["demerzel", "bonsai", "factory-watch"];
409
553
  const stopped = names.filter((n) => stopDaemon(n));
410
554
  console.log(stopped.length ? C.green("stopped: ") + stopped.join(", ") : C.dim("nothing was running."));
411
555
  }
@@ -453,7 +597,13 @@ function statusCmd() {
453
597
 
454
598
  console.log(` ${dot} ${C.bold(m.id.padEnd(11))} ${detail}${svc}`);
455
599
  }
456
- if (demerzelInstalled()) console.log(C.dim(`\n demerzel LLM: ${process.env.DEMERZEL_LLM || "mlx-community/Qwen3.6-35B-A3B-4bit (default)"}`));
600
+ if (demerzelInstalled()) {
601
+ const brain = savedBrain() || "qwen";
602
+ const bstate = brain === "bonsai"
603
+ ? (daemonUp("bonsai") || bonsaiHealthy() ? C.green("server up") : C.dim("server down"))
604
+ : C.dim("in-process");
605
+ console.log(C.dim(`\n voice brain: `) + C.bold(brain) + " " + bstate + C.dim(" · switch: seldon up --brain=qwen|bonsai"));
606
+ }
457
607
  console.log(C.dim(`\n start: seldon up · stop: seldon down · logs: ${RUN_DIR}/\n`));
458
608
  }
459
609
 
@@ -538,6 +688,7 @@ ${C.bold("seldon")} — install the Seldon stack, pick what you use.
538
688
  ${C.bold("seldon")} open the checklist (space to pick, enter to install)
539
689
  ${C.bold("seldon up")} start the stack (voice + supervisor) and print where to go
540
690
  ${C.bold("seldon up --tailnet")} also expose the voice on your tailnet (reach it from your phone)
691
+ ${C.bold("seldon up --brain=X")} pick the voice brain: qwen (in-process) | bonsai (local server); remembered
541
692
  ${C.bold("seldon status")} what's running · ${C.bold("seldon down")} stop it
542
693
  ${C.bold("seldon install")} [ids…] install everything picked, or the named modules
543
694
  ${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.14",
3
+ "version": "0.1.16",
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": {