@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.
- package/README.md +16 -10
- package/bin/seldon.js +98 -14
- 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`
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
`
|
|
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
|
-
**
|
|
65
|
-
|
|
66
|
-
|
|
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/<
|
|
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
|
-
//
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
526
|
-
go.push(`${C.bold("
|
|
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
|
|
553
|
-
|
|
554
|
-
|
|
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")}
|
|
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
|