poddrill 0.1.0 → 0.2.0

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/dist/client.js CHANGED
@@ -5,7 +5,12 @@ import { homedir } from "node:os";
5
5
  import { basename, dirname, isAbsolute, join, normalize, relative } from "node:path";
6
6
  import { asRecord, errorCode, pathExists } from "./disk.js";
7
7
  import { embedWeightsPath } from "./embed.js";
8
+ import { MIN_NODE, nodeFixCommand, nodeInstallMessage, nodeMeetsMinimum } from "./node-version.js";
8
9
  import { readPackageIdentity } from "./package-identity.js";
10
+ import { providerTransport, refuseUnwiredTransport } from "./provider.js";
11
+ import { INSTALL_PYTHON_FIX, NUMPY_PIN, ONNXRUNTIME_PIN, resolvePython, ytDlpFix } from "./python-env.js";
12
+ import { defaultWorkspacePath, readConfig, resolveWorkspace } from "./workspace.js";
13
+ import { digestStatus, ecapaSpec, FETCH_WEIGHTS_FIX } from "./weights.js";
9
14
  const serverName = "poddrill";
10
15
  const skillPublishPath = `.agents/skills/${serverName}`;
11
16
  const skillNextPrefix = `${serverName}.next-`;
@@ -58,9 +63,9 @@ export async function clientMain(args, env, moduleUrl) {
58
63
  case "doctor":
59
64
  return outcomeRendered(await doctor(parsed.command.client, identity, home, env));
60
65
  case "plan":
61
- return outcomeRendered(await resolve(parsed.command.selection, identity, home));
66
+ return outcomeRendered(await resolve(parsed.command.selection, identity, home, env));
62
67
  case "apply":
63
- return outcomeRendered(await applyResolved(await resolve(parsed.command.selection, identity, home), identity, home));
68
+ return outcomeRendered(await applyResolved(await resolve(parsed.command.selection, identity, home, env), identity, home));
64
69
  default: {
65
70
  const leftover = parsed.command;
66
71
  throw new Error(`unexpected command ${JSON.stringify(leftover)}`);
@@ -68,7 +73,7 @@ export async function clientMain(args, env, moduleUrl) {
68
73
  }
69
74
  }
70
75
  async function doctor(client, identity, home, env) {
71
- const launcher = launcherFor(identity.spec);
76
+ const launcher = await launcherFor(identity, env);
72
77
  const paths = pathsFor(home);
73
78
  const skillText = await readOptionalUtf8(join(identity.root, skillPublishPath, "SKILL.md"));
74
79
  const probes = await runtimeProbes(env, identity);
@@ -77,11 +82,11 @@ async function doctor(client, identity, home, env) {
77
82
  kind: "doctor",
78
83
  client,
79
84
  launcher,
80
- checks: [skillBundleCheck(skillText), probes[0], probes[1], probes[2], link],
85
+ checks: [skillBundleCheck(skillText, identity), ...probes, link],
81
86
  };
82
87
  }
83
- async function resolve(selection, identity, home) {
84
- const launcher = launcherFor(identity.spec);
88
+ async function resolve(selection, identity, home, env) {
89
+ const launcher = await launcherFor(identity, env);
85
90
  const skillPath = join(identity.root, skillPublishPath, "SKILL.md");
86
91
  const skillText = await readOptionalUtf8(skillPath);
87
92
  if (skillText === undefined) {
@@ -306,15 +311,39 @@ function publishLayoutOk(files) {
306
311
  }
307
312
  return true;
308
313
  }
309
- function launcherFor(spec) {
310
- return { command: "npx", args: ["-y", spec, "mcp"] };
314
+ async function launcherFor(identity, env) {
315
+ const python = await resolvePython(env, await configuredPython(env));
316
+ return {
317
+ command: process.execPath,
318
+ args: [join(identity.root, "dist", "cli.js"), "mcp"],
319
+ env: {
320
+ PATH: env["PATH"] ?? "",
321
+ PODDRILL_PYTHON: python,
322
+ PODDRILL_WORKSPACE: defaultWorkspacePath(env),
323
+ PODDRILL_EMBED_WEIGHTS: embedWeightsPath(env),
324
+ },
325
+ };
326
+ }
327
+ async function configuredPython(env) {
328
+ try {
329
+ const root = await resolveWorkspace(env);
330
+ const config = await readConfig(root);
331
+ return config.python;
332
+ }
333
+ catch {
334
+ return undefined;
335
+ }
311
336
  }
312
337
  function bundleDir(identity) {
313
338
  return join(identity.root, skillPublishPath);
314
339
  }
315
340
  function grokBotSentence(launcher) {
316
- const commandLine = [launcher.command, ...launcher.args].join(" ");
317
- return `Add a custom server named ${serverName} on this computer, running ${commandLine}.`;
341
+ return [
342
+ `Add a custom server named ${serverName} on this computer,`,
343
+ `command ${JSON.stringify(launcher.command)},`,
344
+ `args ${JSON.stringify(launcher.args)},`,
345
+ `env ${JSON.stringify(launcher.env)}.`,
346
+ ].join(" ");
318
347
  }
319
348
  function skillAgrees(skillText) {
320
349
  if (skillText.includes(`${serverName}@latest`) || skillText.includes(`npx -y ${serverName} `)) {
@@ -324,14 +353,14 @@ function skillAgrees(skillText) {
324
353
  skillText.includes("client plan") &&
325
354
  skillText.includes("client apply --yes"));
326
355
  }
327
- function skillBundleCheck(text) {
356
+ function skillBundleCheck(text, identity) {
328
357
  if (text !== undefined && skillAgrees(text)) {
329
358
  return { name: "skill-bundle", state: "ok" };
330
359
  }
331
360
  const detail = text === undefined
332
361
  ? "bundled SKILL.md is missing"
333
362
  : "bundled SKILL.md does not name the client commands";
334
- return { name: "skill-bundle", state: "bad", detail };
363
+ return { name: "skill-bundle", state: "bad", detail, fix: `npm install -g ${identity.spec}` };
335
364
  }
336
365
  function writingClients() {
337
366
  const found = [];
@@ -558,7 +587,12 @@ async function linkCheckFor(client, paths, launcher) {
558
587
  case "cursor":
559
588
  case "grok_build": {
560
589
  const effect = await writingEffect(client, launcher, paths);
561
- return { name: "link", client, record: effect.record };
590
+ return {
591
+ name: "link",
592
+ client,
593
+ record: effect.record,
594
+ ...(effect.record.status === "match" ? {} : { fix: linkFix(client) }),
595
+ };
562
596
  }
563
597
  default: {
564
598
  const leftover = client;
@@ -669,13 +703,22 @@ function writtenEffect(effect) {
669
703
  }
670
704
  }
671
705
  }
706
+ function linkFix(client) {
707
+ return `poddrill client plan --client ${client} --json && poddrill client apply --yes --client ${client}`;
708
+ }
672
709
  function checkFailed(check) {
673
710
  switch (check.name) {
674
711
  case "skill-bundle":
712
+ case "node":
675
713
  case "ffmpeg":
676
714
  case "cactus-needle":
715
+ case "numpy":
716
+ case "onnxruntime":
677
717
  case "ecapa":
718
+ case "workspace":
678
719
  return check.state === "bad";
720
+ case "yt-dlp":
721
+ return false;
679
722
  case "link":
680
723
  return linkFailed(check);
681
724
  default: {
@@ -732,31 +775,119 @@ async function readOptionalUtf8(path) {
732
775
  }
733
776
  }
734
777
  async function runtimeProbes(env, identity) {
778
+ const python = await resolvePython(env, await configuredPython(env));
779
+ const node = nodeCheck();
735
780
  const ffmpeg = await ffmpegCheck(env);
736
- const needle = await needleCheck(env, identity.cactusNeedle);
737
- const weights = embedWeightsPath(env);
738
- const ecapa = (await pathExists(weights))
739
- ? { name: "ecapa", state: "ok", path: weights }
740
- : { name: "ecapa", state: "bad", path: weights };
741
- return [ffmpeg, needle, ecapa];
781
+ const needle = await packageCheck(env, python, "cactus-needle", "needle", identity.cactusNeedle);
782
+ const numpy = await packageCheck(env, python, "numpy", "numpy", NUMPY_PIN);
783
+ const onnxruntime = await packageCheck(env, python, "onnxruntime", "onnxruntime", ONNXRUNTIME_PIN);
784
+ const ecapa = await ecapaCheck(env);
785
+ const workspace = await workspaceCheck(env);
786
+ const ytdlp = await ytDlpCheck(env, python);
787
+ return [node, ffmpeg, needle, numpy, onnxruntime, ecapa, workspace, ytdlp];
788
+ }
789
+ function nodeCheck() {
790
+ const found = process.version;
791
+ if (nodeMeetsMinimum(process.versions.node)) {
792
+ return { name: "node", state: "ok", version: found };
793
+ }
794
+ return {
795
+ name: "node",
796
+ state: "bad",
797
+ expected: MIN_NODE,
798
+ found,
799
+ detail: nodeInstallMessage(found),
800
+ fix: nodeFixCommand(),
801
+ };
742
802
  }
743
803
  async function ffmpegCheck(env) {
744
804
  const result = await capture("ffmpeg", ["-version"], env);
745
805
  if (result.code === 0) {
746
806
  return { name: "ffmpeg", state: "ok" };
747
807
  }
748
- return { name: "ffmpeg", state: "bad", detail: "ffmpeg is not on PATH" };
808
+ return {
809
+ name: "ffmpeg",
810
+ state: "bad",
811
+ detail: "ffmpeg is not on PATH",
812
+ fix: process.platform === "darwin" ? "brew install ffmpeg" : "sudo apt-get install -y ffmpeg",
813
+ };
749
814
  }
750
- async function needleCheck(env, expected) {
751
- const result = await capture("python3", ["-c", "import needle\nprint(needle.__version__)\n"], env);
815
+ async function packageCheck(env, python, name, moduleName, expected) {
816
+ const result = await capture(python, ["-c", `import ${moduleName}\nprint(${moduleName}.__version__)\n`], env);
752
817
  if (result.code !== 0) {
753
- return { name: "cactus-needle", state: "bad", expected, found: undefined };
818
+ return {
819
+ name,
820
+ state: "bad",
821
+ expected,
822
+ found: undefined,
823
+ detail: `${moduleName} is not installed for ${python}`,
824
+ fix: INSTALL_PYTHON_FIX,
825
+ };
754
826
  }
755
827
  const found = result.stdout.trim();
756
828
  if (found === expected) {
757
- return { name: "cactus-needle", state: "ok", version: found };
829
+ return { name, state: "ok", version: found };
758
830
  }
759
- return { name: "cactus-needle", state: "bad", expected, found };
831
+ return {
832
+ name,
833
+ state: "bad",
834
+ expected,
835
+ found,
836
+ detail: `${moduleName} ${found} is not ${expected}`,
837
+ fix: INSTALL_PYTHON_FIX,
838
+ };
839
+ }
840
+ async function ecapaCheck(env) {
841
+ const weights = embedWeightsPath(env);
842
+ const status = await digestStatus(weights, ecapaSpec);
843
+ if (status === "match") {
844
+ return { name: "ecapa", state: "ok", path: weights };
845
+ }
846
+ const detail = status === "missing"
847
+ ? "ECAPA weights are missing"
848
+ : "ECAPA weights do not match the pinned WeSpeaker file";
849
+ return { name: "ecapa", state: "bad", path: weights, detail, fix: FETCH_WEIGHTS_FIX };
850
+ }
851
+ const SETUP_FIX = "poddrill setup --provider localhost --queue-length 4";
852
+ async function workspaceCheck(env) {
853
+ const root = defaultWorkspacePath(env);
854
+ const configPath = join(root, "poddrill.json");
855
+ if (!(await pathExists(configPath))) {
856
+ return {
857
+ name: "workspace",
858
+ state: "pending",
859
+ path: root,
860
+ detail: "No poddrill workspace yet.",
861
+ fix: SETUP_FIX,
862
+ };
863
+ }
864
+ try {
865
+ const workspace = await resolveWorkspace(env);
866
+ const config = await readConfig(workspace);
867
+ refuseUnwiredTransport(config.provider, providerTransport(config.provider));
868
+ return { name: "workspace", state: "ok", path: workspace, provider: config.provider };
869
+ }
870
+ catch (error) {
871
+ const detail = error instanceof Error ? error.message : String(error);
872
+ return { name: "workspace", state: "bad", path: root, detail, fix: SETUP_FIX };
873
+ }
874
+ }
875
+ async function ytDlpCheck(env, python) {
876
+ const binary = await capture("yt-dlp", ["--version"], env);
877
+ if (binary.code === 0 && binary.stdout.trim() !== "") {
878
+ return { name: "yt-dlp", state: "ok", version: binary.stdout.trim().split("\n")[0] ?? binary.stdout.trim() };
879
+ }
880
+ const module = await capture(python, ["-m", "yt_dlp", "--version"], env);
881
+ if (module.code === 0 && module.stdout.trim() !== "") {
882
+ const version = module.stdout.trim().split("\n")[0] ?? module.stdout.trim();
883
+ return { name: "yt-dlp", state: "ok", version };
884
+ }
885
+ return {
886
+ name: "yt-dlp",
887
+ state: "optional",
888
+ detail: "yt-dlp is optional. Install the current release into the PodDrill Python, or use a standalone yt-dlp binary.",
889
+ fix: ytDlpFix(python),
890
+ };
760
891
  }
761
892
  function capture(command, args, env) {
762
893
  return new Promise((resolve, reject) => {
@@ -839,17 +970,33 @@ function cursorEntryMatches(value, launcher) {
839
970
  return false;
840
971
  }
841
972
  const keys = Object.keys(record);
842
- if (keys.length !== 2 || !keys.includes("command") || !keys.includes("args")) {
973
+ if (keys.length !== 3 || !keys.includes("command") || !keys.includes("args") || !keys.includes("env")) {
843
974
  return false;
844
975
  }
845
976
  if (record["command"] !== launcher.command) {
846
977
  return false;
847
978
  }
848
979
  const args = stringList(record["args"]);
849
- if (args === undefined) {
980
+ if (args === undefined || !sameStrings(args, launcher.args)) {
850
981
  return false;
851
982
  }
852
- return sameStrings(args, launcher.args);
983
+ return envMatches(record["env"], launcher.env);
984
+ }
985
+ function envMatches(value, env) {
986
+ const record = asRecord(value);
987
+ if (record === undefined) {
988
+ return false;
989
+ }
990
+ const keys = ["PATH", "PODDRILL_PYTHON", "PODDRILL_WORKSPACE", "PODDRILL_EMBED_WEIGHTS"];
991
+ if (Object.keys(record).length !== keys.length) {
992
+ return false;
993
+ }
994
+ for (const key of keys) {
995
+ if (record[key] !== env[key]) {
996
+ return false;
997
+ }
998
+ }
999
+ return true;
853
1000
  }
854
1001
  function previousFromCursor(value) {
855
1002
  const record = asRecord(value);
@@ -899,13 +1046,16 @@ function grokRecord(text, launcher) {
899
1046
  if (hit === undefined) {
900
1047
  return { status: "absent" };
901
1048
  }
1049
+ const index = sections.indexOf(hit);
1050
+ const next = sections[index + 1];
1051
+ const envBody = next !== undefined && isPoddrillEnvTable(next.name) ? next.body : undefined;
902
1052
  const previous = previousFromToml(hit.body);
903
- if (grokBodyMatches(hit.body, launcher, previous)) {
1053
+ if (envBody !== undefined && grokServerMatches(hit.body, launcher, previous) && tomlEnvMatches(envBody, launcher.env)) {
904
1054
  return { status: "match" };
905
1055
  }
906
1056
  return { status: "differ", previous };
907
1057
  }
908
- function grokBodyMatches(body, launcher, previous) {
1058
+ function grokServerMatches(body, launcher, previous) {
909
1059
  if (!("startupTimeoutSec" in previous)) {
910
1060
  return false;
911
1061
  }
@@ -920,6 +1070,19 @@ function grokBodyMatches(body, launcher, previous) {
920
1070
  sameStrings(previous.args, launcher.args) &&
921
1071
  previous.startupTimeoutSec === GROK_STARTUP_TIMEOUT_SEC);
922
1072
  }
1073
+ function tomlEnvMatches(body, env) {
1074
+ const record = {};
1075
+ for (const line of body.split("\n")) {
1076
+ const match = /^\s*([A-Za-z0-9_]+)\s*=\s*"((?:\\.|[^"\\])*)"\s*(?:#.*)?$/.exec(line);
1077
+ const key = match?.[1];
1078
+ const value = match?.[2];
1079
+ if (key === undefined || value === undefined) {
1080
+ continue;
1081
+ }
1082
+ record[key] = unescapeToml(value);
1083
+ }
1084
+ return envMatches(record, env);
1085
+ }
923
1086
  function previousFromToml(body) {
924
1087
  let command = "";
925
1088
  let args = [];
@@ -1012,8 +1175,13 @@ function unescapeToml(value) {
1012
1175
  return value.replaceAll(/\\(.)/g, (_match, char) => (char === "n" ? "\n" : char));
1013
1176
  }
1014
1177
  function isPoddrillTable(name) {
1015
- const compact = name.replaceAll(" ", "").replaceAll("\t", "").replaceAll("\"", "").replaceAll("'", "");
1016
- return compact === "mcp_servers.poddrill";
1178
+ return compactTomlName(name) === "mcp_servers.poddrill";
1179
+ }
1180
+ function isPoddrillEnvTable(name) {
1181
+ return compactTomlName(name) === "mcp_servers.poddrill.env";
1182
+ }
1183
+ function compactTomlName(name) {
1184
+ return name.replaceAll(" ", "").replaceAll("\t", "").replaceAll("\"", "").replaceAll("'", "");
1017
1185
  }
1018
1186
  function scanTomlSections(text) {
1019
1187
  if (text.includes("\"\"\"") || text.includes("'''")) {
@@ -1165,7 +1333,16 @@ function mergeText(current, format, launcher) {
1165
1333
  }
1166
1334
  }
1167
1335
  function mergeCursor(current, launcher) {
1168
- const entry = { command: launcher.command, args: [...launcher.args] };
1336
+ const entry = {
1337
+ command: launcher.command,
1338
+ args: [...launcher.args],
1339
+ env: {
1340
+ PATH: launcher.env.PATH,
1341
+ PODDRILL_PYTHON: launcher.env.PODDRILL_PYTHON,
1342
+ PODDRILL_WORKSPACE: launcher.env.PODDRILL_WORKSPACE,
1343
+ PODDRILL_EMBED_WEIGHTS: launcher.env.PODDRILL_EMBED_WEIGHTS,
1344
+ },
1345
+ };
1169
1346
  if (current === undefined || current.trim() === "") {
1170
1347
  return `${JSON.stringify({ mcpServers: { [serverName]: entry } }, null, 2)}\n`;
1171
1348
  }
@@ -1189,7 +1366,7 @@ function mergeCursor(current, launcher) {
1189
1366
  return `${JSON.stringify(next, null, 2)}\n`;
1190
1367
  }
1191
1368
  function mergeGrok(current, launcher) {
1192
- const table = desiredGrokTable(launcher);
1369
+ const table = desiredGrokBlock(launcher);
1193
1370
  if (current === undefined || current.trim() === "") {
1194
1371
  return table;
1195
1372
  }
@@ -1206,9 +1383,12 @@ function mergeGrok(current, launcher) {
1206
1383
  const prefix = current.endsWith("\n") ? current : `${current}\n`;
1207
1384
  return `${prefix}\n${table}`;
1208
1385
  }
1209
- return `${current.slice(0, hit.headerStart)}${table}${current.slice(hit.bodyEnd)}`;
1386
+ const index = sections.indexOf(hit);
1387
+ const next = sections[index + 1];
1388
+ const end = next !== undefined && isPoddrillEnvTable(next.name) ? next.bodyEnd : hit.bodyEnd;
1389
+ return `${current.slice(0, hit.headerStart)}${table}${current.slice(end)}`;
1210
1390
  }
1211
- function desiredGrokTable(launcher) {
1391
+ function desiredGrokBlock(launcher) {
1212
1392
  const args = launcher.args.map((arg) => `"${escapeToml(arg)}"`).join(", ");
1213
1393
  return [
1214
1394
  "[mcp_servers.poddrill]",
@@ -1216,6 +1396,12 @@ function desiredGrokTable(launcher) {
1216
1396
  `args = [${args}]`,
1217
1397
  `startup_timeout_sec = ${GROK_STARTUP_TIMEOUT_SEC}`,
1218
1398
  "",
1399
+ "[mcp_servers.poddrill.env]",
1400
+ `PATH = "${escapeToml(launcher.env.PATH)}"`,
1401
+ `PODDRILL_PYTHON = "${escapeToml(launcher.env.PODDRILL_PYTHON)}"`,
1402
+ `PODDRILL_WORKSPACE = "${escapeToml(launcher.env.PODDRILL_WORKSPACE)}"`,
1403
+ `PODDRILL_EMBED_WEIGHTS = "${escapeToml(launcher.env.PODDRILL_EMBED_WEIGHTS)}"`,
1404
+ "",
1219
1405
  ].join("\n");
1220
1406
  }
1221
1407
  function escapeToml(value) {
package/dist/embed.js CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
- * The local ECAPA embedding host. One python3 process per fleet slot, never shared with Whistle.
2
+ * The local ECAPA embedding host. One Python process per fleet slot, never shared with Whistle.
3
3
  * Ready frame: { "kind": "ready" }. Result: 192 numbers for one stretch wav.
4
4
  * Frontend: float samples times 32768, Kaldi fbank with 80 bins, hamming, 25 ms window, 10 ms shift,
5
5
  * dither 0, then CMN, then the ONNX session on CPU.
6
6
  */
7
- import { homedir } from "node:os";
8
7
  import { join } from "node:path";
9
- import { pathExists } from "./disk.js";
10
8
  import { openFleet } from "./fleet.js";
9
+ import { homeFromEnv } from "./python-env.js";
11
10
  import { PYTHON_FRAMES, spawnFramedWorker } from "./worker.js";
11
+ import { digestStatus, ECAPA_FILE, ecapaSpec, FETCH_WEIGHTS_FIX } from "./weights.js";
12
12
  export const EMBEDDING_SIZE = 192;
13
13
  /** `PODDRILL_EMBED_WEIGHTS`, else `~/.cache/poddrill/ecapa/voxceleb_ECAPA512.onnx`. */
14
14
  export function embedWeightsPath(env) {
@@ -16,7 +16,7 @@ export function embedWeightsPath(env) {
16
16
  if (configured !== undefined && configured !== "") {
17
17
  return configured;
18
18
  }
19
- return join(homedir(), ".cache", "poddrill", "ecapa", "voxceleb_ECAPA512.onnx");
19
+ return join(homeFromEnv(env), ".cache", "poddrill", "ecapa", ECAPA_FILE);
20
20
  }
21
21
  /** Spawns nothing until the first stretch. A missing weights file fails each call without spawning. */
22
22
  export function openEmbedHost(embeddingLimit, weightsPath) {
@@ -29,9 +29,14 @@ export function openEmbedHost(embeddingLimit, weightsPath) {
29
29
  closedMessage: "embedding host is closed",
30
30
  });
31
31
  const requireWeights = async () => {
32
- if (!(await pathExists(weightsPath))) {
33
- throw new Error(`ECAPA weights are missing at ${weightsPath}. Put the file there or set PODDRILL_EMBED_WEIGHTS.`);
32
+ const status = await digestStatus(weightsPath, ecapaSpec);
33
+ if (status === "match") {
34
+ return;
34
35
  }
36
+ if (status === "missing") {
37
+ throw new Error(`ECAPA weights are missing at ${weightsPath}. Run ${FETCH_WEIGHTS_FIX} or set PODDRILL_EMBED_WEIGHTS.`);
38
+ }
39
+ throw new Error(`ECAPA weights at ${weightsPath} do not match the pinned file. Run ${FETCH_WEIGHTS_FIX}.`);
35
40
  };
36
41
  return {
37
42
  prepare: requireWeights,
package/dist/fleet.js CHANGED
@@ -158,7 +158,7 @@ class Fleet {
158
158
  }
159
159
  /**
160
160
  * A worker that died booting fails the oldest waiting request with its reason.
161
- * Load failures are deterministic (no python3, a missing module, weights that do not load),
161
+ * Load failures are deterministic (no Python interpreter, a missing module, weights that do not load),
162
162
  * and failing one request per failed boot bounds the respawn loop by the upstream window.
163
163
  * A worker that died busy fails only its own request, which the link rejects.
164
164
  */
package/dist/moonshine.js CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
- * The local Moonshine worker. One python3 process constructs one `Transcriber`.
2
+ * The local Moonshine worker. One Python process constructs one `Transcriber`.
3
3
  * Ready frame: { "kind": "ready" }. Result: lines mapped onto `WhistleResult`.
4
4
  */
5
5
  import { asRecord } from "./disk.js";
6
+ import { moonshineInstallLine } from "./python-env.js";
6
7
  import { PYTHON_FRAMES, spawnFramedWorker } from "./worker.js";
7
- const INSTALL_MESSAGE = "pip install moonshine-voice";
8
+ const INSTALL_MESSAGE = moonshineInstallLine();
8
9
  /** A missing `moonshine_voice` module becomes the install line. Any other text stays. */
9
10
  export function moonshineFailureMessage(text) {
10
11
  if (text.includes("ModuleNotFoundError") && text.includes("moonshine_voice")) {
package/dist/needle.js CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
- * The local Mac Whistle worker: one python3 process that imports cactus-needle once.
2
+ * The local Whistle worker: one Python process that imports cactus-needle once.
3
3
  * Its ready frame is { "kind": "ready", "version": string }, checked against package.json.
4
4
  * Result: <needle.transcribe output>, parsed as `WhistleResult`.
5
5
  */
6
6
  import { parseWhistle } from "./episode.js";
7
7
  import { readPackageIdentity } from "./package-identity.js";
8
+ import { INSTALL_PYTHON_FIX } from "./python-env.js";
8
9
  import { PYTHON_FRAMES, spawnFramedWorker } from "./worker.js";
9
10
  /** Start one warm cactus-needle process. */
10
11
  export const spawnNeedleWorker = (events) => {
@@ -14,6 +15,12 @@ export const spawnNeedleWorker = (events) => {
14
15
  return spawnFramedWorker({
15
16
  label: "cactus-needle",
16
17
  args: ["-c", WORKER_SCRIPT],
18
+ explainExit(tail) {
19
+ if (tail.includes("No module named 'needle'") || tail.includes("No module named \"needle\"")) {
20
+ return `cactus-needle is not installed. Run ${INSTALL_PYTHON_FIX}.`;
21
+ }
22
+ return tail;
23
+ },
17
24
  async checkReady(ready) {
18
25
  const version = ready["version"];
19
26
  if (typeof version !== "string") {
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The published CLI runs on Node 20.19 and newer.
3
+ * Tests use `node --experimental-strip-types`, which this floor does not promise.
4
+ */
5
+ export const MIN_NODE = "20.19.0";
6
+ export function nodeMeetsMinimum(version, minimum = MIN_NODE) {
7
+ const actual = parseNode(version);
8
+ const floor = parseNode(minimum);
9
+ if (actual === undefined || floor === undefined) {
10
+ return false;
11
+ }
12
+ for (let index = 0; index < 3; index += 1) {
13
+ const left = actual[index] ?? 0;
14
+ const right = floor[index] ?? 0;
15
+ if (left > right) {
16
+ return true;
17
+ }
18
+ if (left < right) {
19
+ return false;
20
+ }
21
+ }
22
+ return true;
23
+ }
24
+ /** One sentence a failing doctor check and the CLI preflight can both print. */
25
+ export function nodeInstallMessage(found) {
26
+ const shown = found.startsWith("v") ? found : `v${found}`;
27
+ return [
28
+ `poddrill needs Node >=${MIN_NODE}, found ${shown}.`,
29
+ "Install the .tar.gz archive, not .tar.xz,",
30
+ `from https://nodejs.org/dist/v${MIN_NODE}/ or run npx -y node@${MIN_NODE}.`,
31
+ ].join(" ");
32
+ }
33
+ export function nodeFixCommand() {
34
+ return `npx -y node@${MIN_NODE}`;
35
+ }
36
+ function parseNode(version) {
37
+ const stripped = version.startsWith("v") ? version.slice(1) : version;
38
+ const parts = stripped.split(".");
39
+ if (parts.length < 2) {
40
+ return undefined;
41
+ }
42
+ const numbers = [];
43
+ for (let index = 0; index < 3; index += 1) {
44
+ const part = parts[index] ?? "0";
45
+ const match = /^(\d+)/.exec(part);
46
+ const digits = match?.[1];
47
+ if (digits === undefined) {
48
+ return undefined;
49
+ }
50
+ numbers.push(Number(digits));
51
+ }
52
+ const major = numbers[0];
53
+ const minor = numbers[1];
54
+ const patch = numbers[2];
55
+ if (major === undefined || minor === undefined || patch === undefined) {
56
+ return undefined;
57
+ }
58
+ return [major, minor, patch];
59
+ }