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/.agents/skills/poddrill/SKILL.md +37 -33
- package/.agents/skills/poddrill/references/fetch-audio.md +4 -4
- package/.agents/skills/poddrill/references/select-models.md +6 -4
- package/README.md +25 -14
- package/dist/cli.js +163 -10
- package/dist/client.js +222 -36
- package/dist/embed.js +11 -6
- package/dist/fleet.js +1 -1
- package/dist/moonshine.js +3 -2
- package/dist/needle.js +8 -1
- package/dist/node-version.js +59 -0
- package/dist/owner.js +65 -39
- package/dist/provider.js +27 -23
- package/dist/python-env.js +222 -0
- package/dist/weights.js +88 -0
- package/dist/worker.js +6 -3
- package/dist/workspace.js +37 -5
- package/package.json +6 -4
- package/requirements.txt +3 -0
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
|
|
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
|
|
85
|
+
checks: [skillBundleCheck(skillText, identity), ...probes, link],
|
|
81
86
|
};
|
|
82
87
|
}
|
|
83
|
-
async function resolve(selection, identity, home) {
|
|
84
|
-
const launcher = launcherFor(identity
|
|
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(
|
|
310
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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 {
|
|
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
|
|
737
|
-
const
|
|
738
|
-
const
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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 {
|
|
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
|
|
751
|
-
const result = await capture(
|
|
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 {
|
|
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
|
|
829
|
+
return { name, state: "ok", version: found };
|
|
758
830
|
}
|
|
759
|
-
return {
|
|
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 !==
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
1016
|
-
|
|
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 = {
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
+
}
|