@gorlitzer-labs/seldon 0.1.15 → 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 +8 -0
- package/bin/seldon.js +146 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,6 +41,14 @@ seldon status # what's running (pids)
|
|
|
41
41
|
seldon down # stop everything
|
|
42
42
|
```
|
|
43
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
|
+
|
|
44
52
|
**Phone access:** `seldon up --tailnet` binds the voice to this machine's Tailscale
|
|
45
53
|
IP and prints `http://<tailnet-ip>:8770` — open it on any device on your tailnet.
|
|
46
54
|
The default `seldon up` stays loopback-only. Since Demerzel can act on the host,
|
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; }
|
|
@@ -369,6 +390,99 @@ async function ensureDemerzelModels({ ask = true } = {}) {
|
|
|
369
390
|
return run(path.join(home, ".venv/bin/python"), [path.join(home, "scripts/fetch-models.py")], { cwd: home, env: { ...process.env } });
|
|
370
391
|
}
|
|
371
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
|
+
|
|
372
486
|
async function up() {
|
|
373
487
|
console.log(C.gold("\n seldon — bringing the stack up\n"));
|
|
374
488
|
const go = []; // where-to-go lines
|
|
@@ -382,16 +496,34 @@ async function up() {
|
|
|
382
496
|
else {
|
|
383
497
|
if (!demerzelModelsReady()) await ensureDemerzelModels({ ask: true }); // offer the fetch inline
|
|
384
498
|
if (demerzelModelsReady()) {
|
|
385
|
-
|
|
386
|
-
//
|
|
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) ---
|
|
387
516
|
const wantTailnet = process.argv.includes("--tailnet") || process.argv.includes("--phone");
|
|
388
517
|
let host = null;
|
|
389
518
|
if (wantTailnet) { host = tailnetIp(); if (!host) later.push("--tailnet: no Tailscale IP found (is Tailscale running?) — started the voice on localhost instead."); }
|
|
390
|
-
|
|
391
|
-
|
|
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");
|
|
392
524
|
const r = startDaemon("demerzel", path.join(home, ".venv/bin/python"), ["-m", "demerzel.server"], { cwd: home, env });
|
|
393
525
|
const url = host ? `http://${host}:8770` : "http://localhost:8770";
|
|
394
|
-
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)"))}`);
|
|
395
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."));
|
|
396
528
|
} else {
|
|
397
529
|
later.push("Voice (demerzel): models not fetched yet — run `seldon up` again when you're ready to pull them.");
|
|
@@ -417,7 +549,7 @@ async function up() {
|
|
|
417
549
|
}
|
|
418
550
|
|
|
419
551
|
function down() {
|
|
420
|
-
const names = ["demerzel", "factory-watch"];
|
|
552
|
+
const names = ["demerzel", "bonsai", "factory-watch"];
|
|
421
553
|
const stopped = names.filter((n) => stopDaemon(n));
|
|
422
554
|
console.log(stopped.length ? C.green("stopped: ") + stopped.join(", ") : C.dim("nothing was running."));
|
|
423
555
|
}
|
|
@@ -465,7 +597,13 @@ function statusCmd() {
|
|
|
465
597
|
|
|
466
598
|
console.log(` ${dot} ${C.bold(m.id.padEnd(11))} ${detail}${svc}`);
|
|
467
599
|
}
|
|
468
|
-
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
|
+
}
|
|
469
607
|
console.log(C.dim(`\n start: seldon up · stop: seldon down · logs: ${RUN_DIR}/\n`));
|
|
470
608
|
}
|
|
471
609
|
|
|
@@ -550,6 +688,7 @@ ${C.bold("seldon")} — install the Seldon stack, pick what you use.
|
|
|
550
688
|
${C.bold("seldon")} open the checklist (space to pick, enter to install)
|
|
551
689
|
${C.bold("seldon up")} start the stack (voice + supervisor) and print where to go
|
|
552
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
|
|
553
692
|
${C.bold("seldon status")} what's running · ${C.bold("seldon down")} stop it
|
|
554
693
|
${C.bold("seldon install")} [ids…] install everything picked, or the named modules
|
|
555
694
|
${C.bold("seldon uninstall")} ids… remove the named modules (global CLI / isolated venv)
|