@gorlitzer-labs/seldon 0.1.17 → 0.1.19

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 +69 -13
  3. package/package.json +4 -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
@@ -317,8 +317,10 @@ async function doUninstall(ids, { yes = false, all = false } = {}) {
317
317
  // The stack's only always-on services are the voice (demerzel) and the
318
318
  // supervisor loop (factory watch). We run them as detached background daemons,
319
319
  // track them by pidfile under ~/.seldon/run, and print one map of where to go.
320
- const RUN_DIR = path.join(process.env.HOME, ".seldon", "run");
321
- const SELDON_HOME = path.join(process.env.HOME, ".seldon");
320
+ // SELDON_HOME can be overridden (tests point it at a temp dir; also a legit
321
+ // user override). Everything — run/, bonsai/, demerzel/, brain — derives from it.
322
+ const SELDON_HOME = process.env.SELDON_HOME || path.join(process.env.HOME, ".seldon");
323
+ const RUN_DIR = path.join(SELDON_HOME, "run");
322
324
  const pidFile = (name) => path.join(RUN_DIR, name + ".pid");
323
325
  const readPid = (name) => { try { return parseInt(fs.readFileSync(pidFile(name), "utf8").trim(), 10) || 0; } catch { return 0; } };
324
326
  const isAlive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
@@ -371,6 +373,55 @@ function tailnetIp() {
371
373
  return null;
372
374
  }
373
375
 
376
+ // Find the Tailscale CLI (PATH, homebrew, or the macOS app bundle).
377
+ function tailscaleBin() {
378
+ for (const b of ["tailscale", "/opt/homebrew/bin/tailscale", "/usr/local/bin/tailscale", "/Applications/Tailscale.app/Contents/MacOS/tailscale", "/usr/bin/tailscale"]) {
379
+ try { execSync(`${b} version`, { stdio: "ignore" }); return b; } catch {}
380
+ }
381
+ return null;
382
+ }
383
+ function tailnetDnsName(ts) {
384
+ try {
385
+ const d = JSON.parse(execSync(`${ts} status --json`, { stdio: ["ignore", "pipe", "ignore"], timeout: 15000 }).toString());
386
+ return ((d.Self && d.Self.DNSName) || "").replace(/\.$/, "");
387
+ } catch { return null; }
388
+ }
389
+ function tailscaleServeUrl() {
390
+ const ts = tailscaleBin(); if (!ts) return null;
391
+ try {
392
+ const out = execSync(`${ts} serve status`, { stdio: ["ignore", "pipe", "ignore"], timeout: 10000 }).toString();
393
+ const m = out.match(/https:\/\/\S+/);
394
+ return m ? m[0] : null;
395
+ } catch { return null; }
396
+ }
397
+
398
+ // Expose the loopback voice on the tailnet over HTTPS via `tailscale serve`, so a
399
+ // phone can use the mic (browsers require a secure context). Provisions the cert,
400
+ // then maps / -> :8770 and /ws -> :8765. Returns { url } or { err } with guidance.
401
+ function tailscaleServeSetup() {
402
+ const ts = tailscaleBin();
403
+ if (!ts) return { err: "Tailscale CLI not found — install Tailscale to reach the voice from your phone." };
404
+ const dns = tailnetDnsName(ts);
405
+ if (!dns) return { err: "couldn't read the tailnet name (is Tailscale connected?)." };
406
+ console.log(C.dim(" setting up HTTPS via tailscale serve (first cert can take ~30s)…"));
407
+ try { execSync(`${ts} cert ${dns}`, { stdio: ["ignore", "ignore", "pipe"], timeout: 120000 }); }
408
+ catch (e) {
409
+ const err = ((e.stderr && e.stderr.toString()) || "") + (e.message || "");
410
+ if (/does not support|HTTPS is not enabled|not enabled/i.test(err))
411
+ 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`." };
412
+ if (/timed out|ETIMEDOUT/i.test(err))
413
+ return { err: "Tailscale is still provisioning the cert — re-run `seldon up --tailnet` in a moment." };
414
+ // otherwise the cert probably already exists — carry on
415
+ }
416
+ try {
417
+ execSync(`${ts} serve --bg http://127.0.0.1:8770`, { stdio: "ignore", timeout: 30000 });
418
+ execSync(`${ts} serve --bg --set-path=/ws http://127.0.0.1:8765`, { stdio: "ignore", timeout: 30000 });
419
+ } catch (e) {
420
+ return { err: "tailscale serve failed: " + ((e.stderr || e.message || "").toString().split("\n")[0]) };
421
+ }
422
+ return { url: `https://${dns}` };
423
+ }
424
+
374
425
  // Fetch demerzel's model set (~25 GB) — offered, never silent. Respects the
375
426
  // chosen LLM (DEMERZEL_LLM) so `seldon install demerzel` / `seldon up` pull the
376
427
  // model you'll actually run. --models forces yes, --no-models forces skip.
@@ -512,19 +563,22 @@ async function up() {
512
563
  }
513
564
  }
514
565
  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) ---
566
+ // The voice always binds loopback; --tailnet fronts it with HTTPS via
567
+ // `tailscale serve` so a phone can use the mic (needs a secure context).
568
+ env.DEMERZEL_HOST = "127.0.0.1";
569
+ let phoneUrl = null;
516
570
  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;
571
+ if (wantTailnet) {
572
+ const s = tailscaleServeSetup();
573
+ 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.")); }
574
+ else later.push("Phone (--tailnet): " + s.err);
575
+ }
576
+ // (Re)start the voice only when it isn't running, or when --brain changed.
577
+ const explicit = process.argv.some((a) => a.startsWith("--brain="));
523
578
  if (explicit && daemonUp("demerzel")) stopDaemon("demerzel");
524
579
  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."));
580
+ go.push(`${C.bold("Voice")} ${C.cyan("http://localhost:8770")} ${C.dim(r.already ? "(already up)" : "(starting — models load, ~30s)")}`);
581
+ if (phoneUrl) go.push(`${C.bold("Phone")} ${C.cyan(phoneUrl)} ${C.dim("(HTTPS — mic works from your phone, on your tailnet)")}`);
528
582
  } else {
529
583
  later.push("Voice (demerzel): models not fetched yet — run `seldon up` again when you're ready to pull them.");
530
584
  }
@@ -633,6 +687,8 @@ function statusCmd() {
633
687
  ? (daemonUp("bonsai") || bonsaiHealthy() ? C.green("server up") : C.dim("server down"))
634
688
  : C.dim("in-process");
635
689
  console.log(C.dim(`\n voice brain: `) + C.bold(brain) + " " + bstate + C.dim(" · switch: seldon up --brain=qwen|bonsai"));
690
+ const phone = tailscaleServeUrl();
691
+ if (phone) console.log(C.dim(" phone (HTTPS): ") + C.cyan(phone));
636
692
  }
637
693
  console.log(C.dim(`\n start: seldon up · stop: seldon down · logs: ${RUN_DIR}/\n`));
638
694
  }
@@ -717,7 +773,7 @@ ${C.bold("seldon")} — install the Seldon stack, pick what you use.
717
773
 
718
774
  ${C.bold("seldon")} open the checklist (space to pick, enter to install)
719
775
  ${C.bold("seldon up")} start the stack (voice + supervisor) and print where to go
720
- ${C.bold("seldon up --tailnet")} also expose the voice on your tailnet (reach it from your phone)
776
+ ${C.bold("seldon up --tailnet")} expose the voice over HTTPS (tailscale serve) so the mic works on your phone
721
777
  ${C.bold("seldon up --brain=X")} pick the voice brain: qwen (in-process) | bonsai (local server); remembered
722
778
  ${C.bold("seldon status")} what's running · ${C.bold("seldon down")} stop it
723
779
  ${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.17",
3
+ "version": "0.1.19",
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": {
@@ -27,5 +27,8 @@
27
27
  "license": "MIT",
28
28
  "publishConfig": {
29
29
  "access": "public"
30
+ },
31
+ "scripts": {
32
+ "test": "node --test test/*.test.mjs"
30
33
  }
31
34
  }