@gorlitzer-labs/seldon 0.1.16 → 0.1.18

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 +16 -10
  2. package/bin/seldon.js +98 -14
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -49,11 +49,17 @@ Bonsai, seldon fetches its runtime + model (~7 GB, macOS/Apple-silicon) and runs
49
49
  the server as a managed daemon; `seldon status` shows the active brain, `seldon
50
50
  down` stops it.
51
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.
52
+ **Phone access:** `seldon up --tailnet` serves the voice over **HTTPS** on your
53
+ tailnet (via `tailscale serve`) and prints a `https://<machine>.<tailnet>.ts.net`
54
+ URL — open it on your phone and the **mic works** (browsers require HTTPS for the
55
+ microphone; plain http can't). The default `seldon up` stays loopback-only, and
56
+ the Mac's own `http://localhost:8770` already works (localhost is a secure context).
57
+
58
+ Needs **HTTPS Certificates enabled** for your tailnet — one toggle at
59
+ [login.tailscale.com/admin/dns](https://login.tailscale.com/admin/dns) → *HTTPS
60
+ Certificates*; `--tailnet` tells you if it isn't. The serve config persists across
61
+ reboots. Since Demerzel can act on the host, anyone on your tailnet can talk to it
62
+ — use `DEMERZEL_READONLY=1` when sharing.
57
63
 
58
64
  `seldon up` only starts what's installed. demerzel serves the voice UI on
59
65
  `http://localhost:8770`. Its model set (~25 GB) is a one-time download —
@@ -61,13 +67,13 @@ exposing it means anyone on your tailnet can talk to it — run with
61
67
  offers again if it's still missing. Nothing that big is ever pulled silently.
62
68
  Logs live in `~/.seldon/run/`.
63
69
 
64
- **Try a different LLM** (e.g. PrismML's Bonsai 2, ~5.9 GB, much smaller than the
65
- default): set `DEMERZEL_LLM` to any MLX-compatible HF repo before install/up, and
66
- both the fetch and the runtime follow it:
70
+ **Swap the in-process Qwen model:** set `DEMERZEL_LLM` to any MLX-compatible HF
71
+ repo before install/up — the fetch and the runtime both follow it (this only
72
+ changes the *Qwen brain*; for Bonsai use `--brain=bonsai` above, which is a
73
+ separate llama-server, not an MLX repo).
67
74
 
68
75
  ```bash
69
- DEMERZEL_LLM=mlx-community/<bonsai-2-repo> seldon install demerzel # fetch that model
70
- DEMERZEL_LLM=mlx-community/<bonsai-2-repo> seldon up # run it
76
+ DEMERZEL_LLM=mlx-community/<some-mlx-repo> seldon up
71
77
  ```
72
78
 
73
79
  ## Uninstall
package/bin/seldon.js CHANGED
@@ -371,6 +371,55 @@ function tailnetIp() {
371
371
  return null;
372
372
  }
373
373
 
374
+ // Find the Tailscale CLI (PATH, homebrew, or the macOS app bundle).
375
+ function tailscaleBin() {
376
+ for (const b of ["tailscale", "/opt/homebrew/bin/tailscale", "/usr/local/bin/tailscale", "/Applications/Tailscale.app/Contents/MacOS/tailscale", "/usr/bin/tailscale"]) {
377
+ try { execSync(`${b} version`, { stdio: "ignore" }); return b; } catch {}
378
+ }
379
+ return null;
380
+ }
381
+ function tailnetDnsName(ts) {
382
+ try {
383
+ const d = JSON.parse(execSync(`${ts} status --json`, { stdio: ["ignore", "pipe", "ignore"], timeout: 15000 }).toString());
384
+ return ((d.Self && d.Self.DNSName) || "").replace(/\.$/, "");
385
+ } catch { return null; }
386
+ }
387
+ function tailscaleServeUrl() {
388
+ const ts = tailscaleBin(); if (!ts) return null;
389
+ try {
390
+ const out = execSync(`${ts} serve status`, { stdio: ["ignore", "pipe", "ignore"], timeout: 10000 }).toString();
391
+ const m = out.match(/https:\/\/\S+/);
392
+ return m ? m[0] : null;
393
+ } catch { return null; }
394
+ }
395
+
396
+ // Expose the loopback voice on the tailnet over HTTPS via `tailscale serve`, so a
397
+ // phone can use the mic (browsers require a secure context). Provisions the cert,
398
+ // then maps / -> :8770 and /ws -> :8765. Returns { url } or { err } with guidance.
399
+ function tailscaleServeSetup() {
400
+ const ts = tailscaleBin();
401
+ if (!ts) return { err: "Tailscale CLI not found — install Tailscale to reach the voice from your phone." };
402
+ const dns = tailnetDnsName(ts);
403
+ if (!dns) return { err: "couldn't read the tailnet name (is Tailscale connected?)." };
404
+ console.log(C.dim(" setting up HTTPS via tailscale serve (first cert can take ~30s)…"));
405
+ try { execSync(`${ts} cert ${dns}`, { stdio: ["ignore", "ignore", "pipe"], timeout: 120000 }); }
406
+ catch (e) {
407
+ const err = ((e.stderr && e.stderr.toString()) || "") + (e.message || "");
408
+ if (/does not support|HTTPS is not enabled|not enabled/i.test(err))
409
+ return { err: "HTTPS certs aren't enabled for your tailnet. Enable them at https://login.tailscale.com/admin/dns (HTTPS Certificates), then re-run `seldon up --tailnet`." };
410
+ if (/timed out|ETIMEDOUT/i.test(err))
411
+ return { err: "Tailscale is still provisioning the cert — re-run `seldon up --tailnet` in a moment." };
412
+ // otherwise the cert probably already exists — carry on
413
+ }
414
+ try {
415
+ execSync(`${ts} serve --bg http://127.0.0.1:8770`, { stdio: "ignore", timeout: 30000 });
416
+ execSync(`${ts} serve --bg --set-path=/ws http://127.0.0.1:8765`, { stdio: "ignore", timeout: 30000 });
417
+ } catch (e) {
418
+ return { err: "tailscale serve failed: " + ((e.stderr || e.message || "").toString().split("\n")[0]) };
419
+ }
420
+ return { url: `https://${dns}` };
421
+ }
422
+
374
423
  // Fetch demerzel's model set (~25 GB) — offered, never silent. Respects the
375
424
  // chosen LLM (DEMERZEL_LLM) so `seldon install demerzel` / `seldon up` pull the
376
425
  // model you'll actually run. --models forces yes, --no-models forces skip.
@@ -512,19 +561,22 @@ async function up() {
512
561
  }
513
562
  }
514
563
  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) ---
564
+ // The voice always binds loopback; --tailnet fronts it with HTTPS via
565
+ // `tailscale serve` so a phone can use the mic (needs a secure context).
566
+ env.DEMERZEL_HOST = "127.0.0.1";
567
+ let phoneUrl = null;
516
568
  const wantTailnet = process.argv.includes("--tailnet") || process.argv.includes("--phone");
517
- let host = null;
518
- if (wantTailnet) { host = tailnetIp(); if (!host) later.push("--tailnet: no Tailscale IP found (is Tailscale running?) — started the voice on localhost instead."); }
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;
569
+ if (wantTailnet) {
570
+ const s = tailscaleServeSetup();
571
+ if (s.url) { phoneUrl = s.url; later.push(C.dim("Phone: anyone on your tailnet can talk to Demerzel (it can act on this Mac). Use DEMERZEL_READONLY=1 to share safely.")); }
572
+ else later.push("Phone (--tailnet): " + s.err);
573
+ }
574
+ // (Re)start the voice only when it isn't running, or when --brain changed.
575
+ const explicit = process.argv.some((a) => a.startsWith("--brain="));
523
576
  if (explicit && daemonUp("demerzel")) stopDaemon("demerzel");
524
577
  const r = startDaemon("demerzel", path.join(home, ".venv/bin/python"), ["-m", "demerzel.server"], { cwd: home, env });
525
- const url = host ? `http://${host}:8770` : "http://localhost:8770";
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)"))}`);
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."));
578
+ go.push(`${C.bold("Voice")} ${C.cyan("http://localhost:8770")} ${C.dim(r.already ? "(already up)" : "(starting — models load, ~30s)")}`);
579
+ if (phoneUrl) go.push(`${C.bold("Phone")} ${C.cyan(phoneUrl)} ${C.dim("(HTTPS — mic works from your phone, on your tailnet)")}`);
528
580
  } else {
529
581
  later.push("Voice (demerzel): models not fetched yet — run `seldon up` again when you're ready to pull them.");
530
582
  }
@@ -549,9 +601,39 @@ async function up() {
549
601
  }
550
602
 
551
603
  function down() {
552
- const names = ["demerzel", "bonsai", "factory-watch"];
553
- const stopped = names.filter((n) => stopDaemon(n));
554
- console.log(stopped.length ? C.green("stopped: ") + stopped.join(", ") : C.dim("nothing was running."));
604
+ const tracked = ["demerzel", "bonsai", "factory-watch"].filter((n) => stopDaemon(n));
605
+ // Belt-and-suspenders so nothing leaks: also reap by seldon's OWN ports and
606
+ // process signatures. This catches a server started outside seldon (manual
607
+ // launch) or a pidfile that drifted. Scoped tightly — only our ports and
608
+ // command shapes — so unrelated processes are never touched.
609
+ const probes = [
610
+ "lsof -nP -iTCP:8081 -sTCP:LISTEN -t", // bonsai llama-server
611
+ "lsof -nP -iTCP:8770 -sTCP:LISTEN -t", // voice UI
612
+ "lsof -nP -iTCP:8765 -sTCP:LISTEN -t", // voice websocket
613
+ `pgrep -f '${SELDON_HOME}/bonsai/bin/llama-server'`,
614
+ "pgrep -f 'demerzel[.]server'",
615
+ "pgrep -f 'factory.*watch --all'",
616
+ ];
617
+ const collect = () => {
618
+ const s = new Set();
619
+ for (const c of probes) {
620
+ try { execSync(c, { stdio: ["ignore", "pipe", "ignore"] }).toString().split(/\s+/).filter(Boolean).forEach((p) => s.add(p)); } catch {}
621
+ }
622
+ s.delete(String(process.pid));
623
+ return [...s];
624
+ };
625
+ const first = collect();
626
+ for (const p of first) { try { process.kill(Number(p), "SIGTERM"); } catch {} }
627
+ let reaped = 0;
628
+ if (first.length) {
629
+ try { execSync("sleep 1.5"); } catch {}
630
+ for (const p of collect()) { try { process.kill(Number(p), "SIGKILL"); reaped++; } catch {} }
631
+ reaped = first.length; // count what we signalled
632
+ }
633
+ const parts = [];
634
+ if (tracked.length) parts.push("stopped " + tracked.join(", "));
635
+ if (first.length) parts.push(`reaped ${first.length} straggler${first.length > 1 ? "s" : ""}`);
636
+ console.log(parts.length ? C.green(parts.join(" · ")) : C.dim("nothing was running."));
555
637
  }
556
638
 
557
639
  // Version of a globally-installed node package, across pnpm / npm / bun roots.
@@ -603,6 +685,8 @@ function statusCmd() {
603
685
  ? (daemonUp("bonsai") || bonsaiHealthy() ? C.green("server up") : C.dim("server down"))
604
686
  : C.dim("in-process");
605
687
  console.log(C.dim(`\n voice brain: `) + C.bold(brain) + " " + bstate + C.dim(" · switch: seldon up --brain=qwen|bonsai"));
688
+ const phone = tailscaleServeUrl();
689
+ if (phone) console.log(C.dim(" phone (HTTPS): ") + C.cyan(phone));
606
690
  }
607
691
  console.log(C.dim(`\n start: seldon up · stop: seldon down · logs: ${RUN_DIR}/\n`));
608
692
  }
@@ -687,7 +771,7 @@ ${C.bold("seldon")} — install the Seldon stack, pick what you use.
687
771
 
688
772
  ${C.bold("seldon")} open the checklist (space to pick, enter to install)
689
773
  ${C.bold("seldon up")} start the stack (voice + supervisor) and print where to go
690
- ${C.bold("seldon up --tailnet")} also expose the voice on your tailnet (reach it from your phone)
774
+ ${C.bold("seldon up --tailnet")} expose the voice over HTTPS (tailscale serve) so the mic works on your phone
691
775
  ${C.bold("seldon up --brain=X")} pick the voice brain: qwen (in-process) | bonsai (local server); remembered
692
776
  ${C.bold("seldon status")} what's running · ${C.bold("seldon down")} stop it
693
777
  ${C.bold("seldon install")} [ids…] install everything picked, or the named modules
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gorlitzer-labs/seldon",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
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": {