@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.
- package/README.md +20 -5
- package/bin/seldon.js +158 -7
- 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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
seldon
|
|
40
|
-
seldon
|
|
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
|
-
|
|
374
|
-
//
|
|
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
|
-
|
|
379
|
-
|
|
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
|
|
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())
|
|
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)
|