poddrill 0.1.1 → 0.2.1

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.
@@ -2,74 +2,68 @@
2
2
  name: poddrill
3
3
  description: >-
4
4
  Transcribe a podcast episode into timed text, group the voices, and store
5
- a summary whose paragraphs cite that text. The agent reads the text, names
6
- the voices, and writes the summary words. PodDrill stores the episode.
7
- Use when the user says to set up PodDrill on this computer, when fetching
8
- episode audio, starting a transcript, labeling speakers, or writing a summary.
9
- When a session has no PodDrill tools, install the requirements, register the
10
- client, and set up the workspace before transcribing. Do not use it to change
11
- the poddrill TypeScript.
5
+ a summary whose paragraphs cite that text. Use when the user says to set up
6
+ PodDrill, fetch episode audio, start a transcript, label speakers, or write
7
+ a summary. Do not use it to change the poddrill TypeScript.
12
8
  ---
13
9
 
14
10
  # PodDrill
15
11
 
16
- PodDrill is a toolset to make transcription and summary of podcast audio streamlined and structured. You may also use it for any other audio. It maps an episode (the audio) to cues, clusters those cues into speaker groups, and stores summaries. You decide how to use the toolset to answer user's request of audio summary.
12
+ PodDrill maps an episode (the audio) to cues, clusters those cues into speaker groups, and stores summaries. You decide how to use the toolset to answer the user's request. You read the text, name the voices, and write the summary words. PodDrill stores the episode.
17
13
 
18
- Find the package, install the requirements, register the client, and set up the workspace once. After that, read the one guide for the part you are doing. Leave the other guides unread. If an episode already has the asset you need, read it. Do not reprocess the audio again.
14
+ Install once, with the sequence below. After that, read the one guide for the part you are doing. Leave the other guides unread. If an episode already has the asset you need, read it.
19
15
 
20
- ## Find the package
16
+ ## Install on this computer
21
17
 
22
- The npm package is `poddrill`. Before it is on disk, read the published `version` and use `npx -y poddrill@<version>`. After it is installed, `<version>` is the `version` field in that package's `package.json`. Do not use `@latest`.
18
+ Do this once on the computer that will transcribe. Each step is safe to repeat. `localhost`, `grok-bot`, and `cursor-cloud` all run the local Python host on this computer. The provider id names that computer.
23
19
 
24
- Install Node.js 22.18 or newer, `ffmpeg` on `PATH`, and the Python package `cactus-needle` 3.2.0. The npm package named cactus-needle is a different package. Place the ECAPA file at `~/.cache/poddrill/ecapa/voxceleb_ECAPA512.onnx`, or set `PODDRILL_EMBED_WEIGHTS` to another path. This package does not install those, and it has no download URL for the ECAPA file.
20
+ 1. Install Node.js 20.19 or newer and `ffmpeg`. Use the Node `.tar.gz` archive from `https://nodejs.org/dist/v20.19.0/`, or run `npx -y node@20.19.0`. On macOS, `brew install ffmpeg`. On Linux, `sudo apt-get install -y ffmpeg`.
25
21
 
26
- ## Cues
22
+ 2. Install the npm package. Read the published `version` and run `npm install -g poddrill@<version>`. Do not use `@latest`. Cloning the git repository is not required. If you clone it, set `GIT_TERMINAL_PROMPT=0`.
27
23
 
28
- An asset maps each segment of the audio to text. That segment is a cue. A cue has the text and the times. You read the cues. You do not invent the text or the times.
24
+ 3. Run `poddrill install-python`. This creates `~/.cache/poddrill/venv` and installs cactus-needle 3.2.0, numpy 2.5.4, and onnxruntime 1.31.0. It uses `uv` when `uv` is on PATH, and otherwise `python3 -m venv`. The npm package named cactus-needle is a different package.
29
25
 
30
- Read cues only after the embedding is ready. A ready transcript can still be not-ready for cues.
26
+ 4. Run `poddrill fetch-weights`. This downloads `voxceleb_ECAPA512.onnx` from the pinned WeSpeaker commit, checks the byte size and SHA-256, and writes `~/.cache/poddrill/ecapa/voxceleb_ECAPA512.onnx`. WeSpeaker ECAPA-TDNN512 pretrained on VoxCeleb. VoxCeleb is used under CC BY 4.0. A later run leaves a matching file in place.
31
27
 
32
- ## Speaker groups
28
+ 5. Run `poddrill client doctor --client cursor --json`. Use `grok_build` or `grok_bot` in place of `cursor` when that is the client. For every check whose `state` is `bad`, run the `fix` command on that check, then run doctor again. `yt-dlp` is optional and does not fail the command. `workspace` stays `pending` until setup. Stop this loop when doctor exits 0, or when the only `bad` check is `link`.
33
29
 
34
- An asset clusters cues that sound like one voice. That cluster is a speaker group. You assign the group a name, `quote`, `ending`, or `undetermined`. The utility does not choose the name.
30
+ 6. Run `poddrill setup --provider localhost --queue-length 4`. Without `PODDRILL_WORKSPACE`, setup and every other command use `~/poddrill`. For `ja`, `ko`, or `zh`, read the weights guide and pass `--model-weights` on that same command. `en`, `de`, `fr`, `es`, `it`, `nl`, and `pl` do not need Moonshine weights.
35
31
 
36
- A summary does not require those labels.
32
+ `poddrill bootstrap --provider localhost --queue-length 4` runs steps 3, 4, and 6.
37
33
 
38
- ## Summaries
34
+ 7. Register the client. Run `poddrill client plan --client cursor --json`. After that plan is approved, run `poddrill client apply --yes --client cursor`. Grok Bot prints a sentence with the absolute Node binary, `dist/cli.js`, and an env block (`PATH`, `PODDRILL_PYTHON`, `PODDRILL_WORKSPACE`, `PODDRILL_EMBED_WEIGHTS`). Add that custom server in the Grok Bot chat. It does not write a config file.
39
35
 
40
- Summary is an asset of an episode. An episode can have multiple summary asset. You use the cues when you write a summary. PodDrill stores paragraphs that cite cues, then an episode summary written from the paragraph text. You choose which cues to include and you write the words. You do not write the file yourself.
36
+ 8. Run `poddrill client doctor --client cursor --json` again. Exit 0 means every required check is ok.
41
37
 
42
- ## The episode
38
+ A later refresh of clients that already have an entry is `poddrill client plan --linked --json`, then `poddrill client apply --yes --linked` after approval.
43
39
 
44
- You keep the source list. The utility keeps the episode. The episode holds the transcript, the cues, the groups, the labels, and the summaries in your filesystem. Do not copy artifact state, progress, paths, or labels into the source list.
40
+ ## Python
45
41
 
46
- ## Register the client
42
+ `PODDRILL_PYTHON` selects the interpreter. Pass `--python` or set `PODDRILL_PYTHON` on setup to store an absolute path in `poddrill.json`. Otherwise PodDrill uses `~/.cache/poddrill/venv/bin/python` when that file exists, then `python3`. Install yt-dlp and moonshine-voice into that same interpreter. The current yt-dlp and moonshine-voice releases are unpinned.
47
43
 
48
- When this session has no PodDrill tools, register the client before setting up a workspace. `<version>` is the `version` field in this package's `package.json`. Do not use `@latest`.
44
+ ## Stop
49
45
 
50
- Run `npx -y poddrill@<version> client doctor --client cursor --json`. Use `grok_build` or `grok_bot` in place of `cursor` when that is the client.
46
+ `poddrill stop` stops the running owner. Setup rejects a change while the owner is running. Run `poddrill stop` before you change the provider, queue length, fleet limit, or embedding limit.
51
47
 
52
- Then run `npx -y poddrill@<version> client plan --client cursor --json`.
48
+ ## Cues
53
49
 
54
- After that plan is approved, run `npx -y poddrill@<version> client apply --yes --client cursor`.
50
+ An asset maps each segment of the audio to text. That segment is a cue. A cue has the text and the times. You read the cues. You do not invent the text or the times.
55
51
 
56
- A later refresh of clients that already have an entry is `npx -y poddrill@<version> client plan --linked --json`, then `npx -y poddrill@<version> client apply --yes --linked` after approval. Grok Bot prints a sentence and does not write a config file. Add that custom server in the Grok Bot chat. It runs on the bot's computer.
52
+ Read cues only after the embedding is ready. A ready transcript can still be not-ready for cues.
57
53
 
58
- ## Set up once
54
+ ## Speaker groups
59
55
 
60
- Set `PODDRILL_WORKSPACE` to a directory outside the git tree that contains this skill. Without that variable, `setup` uses the current directory and every other command uses `~/poddrill`.
56
+ An asset clusters cues that sound like one voice. That cluster is a speaker group. You assign the group a name, `quote`, `ending`, or `undetermined`. The utility does not choose the name.
61
57
 
62
- Run setup once in that workspace. The provider is the computer you are running on.
58
+ A summary does not require those labels.
63
59
 
64
- ```
65
- poddrill setup --provider <id> --queue-length 4
66
- ```
60
+ ## Summaries
67
61
 
68
- If you are the Grok Bot agent, `<id>` is `grok-bot`. If you are the Cursor cloud agent, `<id>` is `cursor-cloud`. That computer is the host. Otherwise `<id>` is `localhost`.
62
+ Summary is an asset of an episode. An episode can have multiple summary assets. You use the cues when you write a summary. PodDrill stores paragraphs that cite cues, then an episode summary written from the paragraph text. You choose which cues to include and you write the words. You do not write the file yourself.
69
63
 
70
- For `ja`, `ko`, or `zh`, read the weights guide and pass `--model-weights` on that same setup command. `en`, `de`, `fr`, `es`, `it`, `nl`, and `pl` do not need weights.
64
+ ## The episode
71
65
 
72
- Stop the owner before you change setup. A running owner rejects a different provider, queue length, fleet limit, or embedding limit.
66
+ You keep the source list. The utility keeps the episode. The episode holds the transcript, the cues, the groups, the labels, and the summaries in your filesystem. Do not copy artifact state, progress, paths, or labels into the source list.
73
67
 
74
68
  ## Start an episode
75
69
 
@@ -79,12 +73,14 @@ Pass `--language <code>` on `poddrill start` when the episode is not English. Th
79
73
 
80
74
  `poddrill start` returns before the transcript exists. Poll `poddrill episode <id>` until the transcript artifact `state` is `ready` or `failed`. The id is `ep_` plus the first 16 hex characters of the sha256 of the file bytes.
81
75
 
82
- The transcript is done when that `state` is `ready`. Exit 0 does not mean the transcript is done. If it is missing or `failed`, pass the same WAV to `poddrill start` again. The episode id does not change.
76
+ The transcript is done when that `state` is `ready`. Exit 0 does not mean the transcript is done. If the transcript or the embedding is missing or `failed`, pass the same WAV to `poddrill start` again. The episode id does not change. `created` is false, and the failed artifact runs again.
83
77
 
84
78
  ## Read a result
85
79
 
86
80
  Parse the JSON line on stdout. `episode` exits 0 when the id is unknown. `not-ready` exits 0. `start` exits 1 when `kind` is `rejected` or `full`. MCP returns that same JSON. `isError` is true only when the text starts with `{"kind":"error"`.
87
81
 
82
+ `poddrill --help` and `poddrill --version` print plain text and exit 0.
83
+
88
84
  ## Present the summary
89
85
 
90
86
  Present the summary in the format preferred or prescribed by users, but always grounded by the summary constructed via poddrill.
@@ -16,14 +16,14 @@ The script refuses a destination inside the git tree that contains the script. P
16
16
 
17
17
  ## yt-dlp
18
18
 
19
- If the feed has no audio URL, or the script does not get the file, install the current yt-dlp and run that module. The printed line is the download path.
19
+ If the feed has no audio URL, or the script does not get the file, install the current yt-dlp into the PodDrill Python and run that module. The version is unpinned. On a PEP 668 Python, install into the venv from `poddrill install-python`, or set `PODDRILL_PYTHON` to an interpreter that allows pip. A pipx install or a standalone yt-dlp binary also works. The printed line is the download path.
20
20
 
21
21
  ```
22
- python3 -m pip install --upgrade yt-dlp
23
- python3 -m yt_dlp -x --no-playlist --print after_move:filepath -o "<directory>/download.%(ext)s" "<page-url>"
22
+ "$PODDRILL_PYTHON" -m pip install --upgrade yt-dlp
23
+ "$PODDRILL_PYTHON" -m yt_dlp -x --no-playlist --print after_move:filepath -o "<directory>/download.%(ext)s" "<page-url>"
24
24
  ```
25
25
 
26
- Write `<directory>` outside this git repository. If that command does not get the audio, read [Usage and options](https://github.com/yt-dlp/yt-dlp#usage-and-options).
26
+ `poddrill client doctor` reports yt-dlp as optional and prints that pip command when it is missing. Write `<directory>` outside this git repository. If that command does not get the audio, read [Usage and options](https://github.com/yt-dlp/yt-dlp#usage-and-options).
27
27
 
28
28
  ## Other audio
29
29
 
@@ -1,14 +1,16 @@
1
1
  # Set up Moonshine weights
2
2
 
3
- PodDrill does not download the weights. Install the package, download the model with word timestamps, then pass the returned directory to setup.
3
+ PodDrill does not download the Moonshine weights. Install the current moonshine-voice release into the PodDrill Python, download the model with word timestamps, then pass the returned directory to setup. The version is unpinned. On a PEP 668 Python, use the venv from `poddrill install-python`.
4
4
 
5
- Install the package first.
5
+ Install the package first. `$PODDRILL_PYTHON` is the interpreter PodDrill spawns.
6
6
 
7
7
  ```
8
- pip install moonshine-voice
8
+ "$PODDRILL_PYTHON" -m pip install moonshine-voice
9
9
  ```
10
10
 
11
- `python -m moonshine_voice.download` on moonshine-voice 0.1.5 exits unless you pass `--stt`. That command prints `Downloaded model path:`. The directory on that line does not include the attention decoder, so word timestamps stay empty. Call `get_model_for_language` with `include_word_timestamps=True` instead. The returned path is the directory setup stores.
11
+ A missing package fails with: `Install moonshine-voice into the PodDrill Python: "$PODDRILL_PYTHON" -m pip install moonshine-voice`.
12
+
13
+ `"$PODDRILL_PYTHON" -m moonshine_voice.download` on moonshine-voice 0.1.5 exits unless you pass `--stt`. That command prints `Downloaded model path:`. The directory on that line does not include the attention decoder, so word timestamps stay empty. Call `get_model_for_language` with `include_word_timestamps=True` instead. The returned path is the directory setup stores.
12
14
 
13
15
  Setup still needs `--provider` and `--queue-length`. Use the provider the skill already named. Add `--model-weights` to that command.
14
16
 
package/README.md CHANGED
@@ -10,16 +10,31 @@ Copy this to the agent.
10
10
  Set up PodDrill on this computer.
11
11
  ```
12
12
 
13
- The npm package is `poddrill`. The agent installs that package and the requirements below, then follows the skill shipped in the package.
13
+ The supported install is the npm package. Read the published `version` and run `npm install -g poddrill@<version>`. Cloning this repository is not required. If you clone it, set `GIT_TERMINAL_PROMPT=0`. The agent then follows the skill shipped in the package.
14
14
 
15
15
  ## Requirements
16
16
 
17
- The computer that runs PodDrill needs Node.js 22.18 or newer, `ffmpeg` on `PATH`, and the Python package `cactus-needle` 3.2.0. The npm package named cactus-needle is a different package.
17
+ The computer that runs PodDrill needs:
18
18
 
19
- That computer also needs the ECAPA weights file. The default path is `~/.cache/poddrill/ecapa/voxceleb_ECAPA512.onnx`. `PODDRILL_EMBED_WEIGHTS` selects another path.
19
+ - Node.js 20.19 or newer. Install the `.tar.gz` archive from `https://nodejs.org/dist/v20.19.0/`, or run `npx -y node@20.19.0`. The published CLI is plain JavaScript. The tests in this repository use `node --experimental-strip-types` and need a newer Node.
20
+ - `ffmpeg` on `PATH`.
21
+ - A Python interpreter with cactus-needle 3.2.0, numpy 2.5.4, and onnxruntime 1.31.0. The npm package named cactus-needle is a different package. `poddrill install-python` creates `~/.cache/poddrill/venv` and installs those pins. It uses `uv` when `uv` is on PATH, and otherwise `python3 -m venv`. `PODDRILL_PYTHON` selects another absolute interpreter, and setup stores that path in `poddrill.json`.
22
+ - The ECAPA weights file. `poddrill fetch-weights` downloads `voxceleb_ECAPA512.onnx` from the pinned WeSpeaker commit, checks its size and SHA-256, and writes `~/.cache/poddrill/ecapa/voxceleb_ECAPA512.onnx`. `PODDRILL_EMBED_WEIGHTS` selects another path. WeSpeaker ECAPA-TDNN512 pretrained on VoxCeleb. VoxCeleb is used under CC BY 4.0.
20
23
 
21
- Grok Bot cannot write its server entry. It prints the custom server to add. Add that server in the Grok Bot chat.
24
+ `poddrill client doctor` prints a `fix` command for every check that is not ok. `poddrill bootstrap` runs `install-python`, `fetch-weights`, and `setup`.
25
+
26
+ Without `PODDRILL_WORKSPACE`, every command uses `~/poddrill`. `poddrill stop` stops the running owner.
27
+
28
+ Grok Bot cannot write its server entry. It prints the custom server to add: the absolute Node binary, `dist/cli.js`, and an env block. Add that server in the Grok Bot chat.
29
+
30
+ ## Tests
31
+
32
+ `npm test` runs the unit tests. Those tests use `node --experimental-strip-types`.
33
+
34
+ `npm run test:smoke` runs a clean-home install, downloads the ECAPA file, and transcribes a short generated clip through `dist/cli.js`. It needs `python3` with `ensurepip`, `ffmpeg`, and `espeak-ng`. If one of those is missing, the test skips and names the missing tool. This repository has no CI workflow. Run `npm run test:smoke` on a computer that has those tools. Build `dist/` with `npm run build` first.
22
35
 
23
36
  ## License
24
37
 
25
38
  MIT License. Copyright 2026 Jianyu Zhao.
39
+
40
+ The ECAPA weights are separate from this license. WeSpeaker ECAPA-TDNN512 pretrained on VoxCeleb. VoxCeleb is used under CC BY 4.0.
package/dist/cli.js CHANGED
@@ -1,26 +1,56 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
3
  import { realpath } from "node:fs/promises";
4
- import { isAbsolute, resolve } from "node:path";
4
+ import { isAbsolute } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { clientMain, workspaceSetupCollision } from "./client.js";
7
- import { asRecord, errorCode } from "./disk.js";
7
+ import { asRecord, errorCode, pathExists } from "./disk.js";
8
+ import { embedWeightsPath } from "./embed.js";
8
9
  import { isModelId } from "./models.js";
9
- import { callOwner, ownerAlive, proxyMcp, render, serve } from "./owner.js";
10
- import { resolveWorkspace, setupWorkspace } from "./workspace.js";
10
+ import { nodeInstallMessage, nodeMeetsMinimum } from "./node-version.js";
11
+ import { callOwner, ownerAlive, proxyMcp, pythonForOwner, render, serve } from "./owner.js";
12
+ import { readPackageIdentity } from "./package-identity.js";
13
+ import { installPython } from "./python-env.js";
14
+ import { fetchEcapa } from "./weights.js";
15
+ import { defaultWorkspacePath, resolveWorkspace, setupWorkspace } from "./workspace.js";
11
16
  const usage = [
12
17
  "usage: poddrill setup --provider <id> --queue-length <n> [--fleet-limit <n>] [--whistle-limit <n>]",
13
- "[--model-fleet <id>=<count>] [--model-weights <id>=<path>] [--embedding-limit <n>]",
18
+ "[--model-fleet <id>=<count>] [--model-weights <id>=<path>] [--embedding-limit <n>] [--python <path>]",
19
+ "| install-python | fetch-weights | bootstrap [--provider <id>] [--queue-length <n>]",
14
20
  "| start <path> [--language <code>] | episode <episodeId> | cues <episodeId> [--limit <n>] [--after <cueId>] [--group-id <id>] [--hosts]",
15
21
  "| label-groups <episodeId> <json> | start-summary <episodeId> | set-summary <episodeId> <summaryId> <json>",
16
- "| put-paragraph <episodeId> <summaryId> <json> | post-paragraph <episodeId> <summaryId> <json> | summary <episodeId> <summaryId> | audit <episodeId> | serve | mcp",
22
+ "| put-paragraph <episodeId> <summaryId> <json> | post-paragraph <episodeId> <summaryId> <json> | summary <episodeId> <summaryId> | audit <episodeId>",
23
+ "| serve | stop | mcp [--selftest] | --help | --version",
17
24
  ].join(" ");
18
25
  async function main() {
26
+ if (!nodeMeetsMinimum(process.versions.node)) {
27
+ await emit(render({ kind: "error", message: nodeInstallMessage(process.versions.node) }), 1);
28
+ }
19
29
  const command = process.argv[2];
20
30
  switch (command) {
31
+ case "--help":
32
+ case "-h":
33
+ case "help":
34
+ await emit(helpText(), 0);
35
+ case "--version":
36
+ case "-v":
37
+ case "version":
38
+ await emit(await versionText(), 0);
21
39
  case "setup":
22
40
  await runSetup(process.argv.slice(3));
23
41
  return;
42
+ case "install-python":
43
+ await runInstallPython();
44
+ return;
45
+ case "fetch-weights":
46
+ await runFetchWeights();
47
+ return;
48
+ case "bootstrap":
49
+ await runBootstrap(process.argv.slice(3));
50
+ return;
51
+ case "stop":
52
+ await runStop();
53
+ return;
24
54
  case "client": {
25
55
  const rendered = await clientMain(process.argv.slice(3), process.env, import.meta.url);
26
56
  await emit(rendered.text, rendered.exitCode);
@@ -60,7 +90,7 @@ async function main() {
60
90
  await serve(await resolveWorkspace(process.env), false);
61
91
  return;
62
92
  case "mcp":
63
- await runMcp();
93
+ await runMcp(process.argv.slice(3));
64
94
  return;
65
95
  default:
66
96
  await emit(render({ kind: "error", message: usage }), 1);
@@ -91,6 +121,7 @@ async function runSetup(args) {
91
121
  const fleetLimit = sharedLimit(fleetText, whistleText);
92
122
  const modelFleet = modelFleetFrom(fleetArgs);
93
123
  const modelWeights = modelWeightsFrom(weightArgs);
124
+ const python = await chosenPython(args);
94
125
  const root = await workspaceArg();
95
126
  const alive = await ownerAlive(root);
96
127
  const result = await setupWorkspace({
@@ -101,6 +132,7 @@ async function runSetup(args) {
101
132
  ...(modelFleet === undefined ? {} : { modelFleet }),
102
133
  ...(modelWeights === undefined ? {} : { modelWeights }),
103
134
  ...(embeddingText === undefined ? {} : { embeddingLimit: Number(embeddingText) }),
135
+ ...(python === undefined ? {} : { python }),
104
136
  ownerAlive: alive,
105
137
  });
106
138
  await emit(render(publicSetup(result)), result.kind === "ready" ? 0 : 1);
@@ -262,7 +294,66 @@ async function runEpisode(episodeId) {
262
294
  await ensureOwner(root);
263
295
  await emit(await callOwner(root, { op: "episode", episodeId }), 0);
264
296
  }
265
- async function runMcp() {
297
+ async function runInstallPython() {
298
+ const installed = await installPython(process.env, import.meta.url);
299
+ await emit(render({ kind: "ready", ...installed }), 0);
300
+ }
301
+ async function runFetchWeights() {
302
+ const fetched = await fetchEcapa(embedWeightsPath(process.env));
303
+ await emit(render({ kind: "ready", ...fetched }), 0);
304
+ }
305
+ async function runBootstrap(args) {
306
+ const provider = flag(args, "--provider") ?? "localhost";
307
+ const queueText = flag(args, "--queue-length") ?? "4";
308
+ if (!isCountText(queueText) || flag(args, "--provider") === "") {
309
+ await emit(render({ kind: "error", message: usage }), 1);
310
+ }
311
+ const installed = await installPython(process.env, import.meta.url);
312
+ const weights = await fetchEcapa(embedWeightsPath(process.env));
313
+ const root = await workspaceArg();
314
+ const alive = await ownerAlive(root);
315
+ const result = await setupWorkspace({
316
+ root,
317
+ provider,
318
+ queueLength: Number(queueText),
319
+ python: installed.python,
320
+ ownerAlive: alive,
321
+ });
322
+ if (result.kind !== "ready") {
323
+ await emit(render(publicSetup(result)), 1);
324
+ }
325
+ await emit(render({
326
+ kind: "ready",
327
+ python: installed.python,
328
+ installer: installed.installer,
329
+ weights,
330
+ setup: publicSetup(result),
331
+ }), 0);
332
+ }
333
+ async function runStop() {
334
+ const root = await resolveWorkspace(process.env);
335
+ if (!(await ownerAlive(root))) {
336
+ await emit(render({ kind: "ready", state: "stopped" }), 0);
337
+ }
338
+ const text = await callOwner(root, { op: "stop" });
339
+ await emit(text, text.includes("\"kind\":\"error\"") ? 1 : 0);
340
+ }
341
+ async function runMcp(args) {
342
+ if (args.includes("--selftest")) {
343
+ const root = await resolveWorkspace(process.env);
344
+ const python = await pythonForOwner(process.env, root);
345
+ await emit(render({
346
+ kind: "ready",
347
+ execPath: process.execPath,
348
+ script: fileURLToPath(import.meta.url),
349
+ env: {
350
+ PATH: process.env["PATH"] ?? "",
351
+ PODDRILL_PYTHON: python,
352
+ PODDRILL_WORKSPACE: defaultWorkspacePath(process.env),
353
+ PODDRILL_EMBED_WEIGHTS: embedWeightsPath(process.env),
354
+ },
355
+ }), 0);
356
+ }
266
357
  const root = await resolveWorkspace(process.env);
267
358
  if (await ownerAlive(root)) {
268
359
  await proxyMcp(root);
@@ -296,6 +387,7 @@ function publicSetup(result) {
296
387
  queueLength: result.report.config.queueLength,
297
388
  fleetLimit: result.report.config.fleetLimit,
298
389
  embeddingLimit: result.report.config.embeddingLimit,
390
+ ...(result.report.config.python === undefined ? {} : { python: result.report.config.python }),
299
391
  };
300
392
  case "rejected":
301
393
  return {
@@ -317,8 +409,7 @@ function isCountText(text) {
317
409
  return /^[1-9]\d*$/.test(text);
318
410
  }
319
411
  async function workspaceArg() {
320
- const configured = process.env["PODDRILL_WORKSPACE"];
321
- const candidate = resolve(configured === undefined || configured === "" ? process.cwd() : configured);
412
+ const candidate = defaultWorkspacePath(process.env);
322
413
  try {
323
414
  return await realpath(candidate);
324
415
  }
@@ -430,6 +521,68 @@ function splitPair(text) {
430
521
  }
431
522
  return { left: text.slice(0, index), right: text.slice(index + 1) };
432
523
  }
524
+ async function chosenPython(args) {
525
+ const flagged = flag(args, "--python");
526
+ const fromEnv = process.env["PODDRILL_PYTHON"];
527
+ const flaggedSet = flagged !== undefined && flagged !== "";
528
+ const envSet = fromEnv !== undefined && fromEnv !== "";
529
+ if (!flaggedSet && !envSet) {
530
+ return undefined;
531
+ }
532
+ if (flaggedSet && envSet && flagged !== fromEnv) {
533
+ await emit(render({ kind: "error", message: `python ${flagged} and PODDRILL_PYTHON ${fromEnv} differ` }), 1);
534
+ }
535
+ const chosen = flaggedSet ? flagged : fromEnv;
536
+ if (typeof chosen !== "string" || !isAbsolute(chosen)) {
537
+ await emit(render({ kind: "error", message: `python must be an absolute path, got ${String(chosen)}` }), 1);
538
+ }
539
+ if (typeof chosen !== "string" || !(await pathExists(chosen))) {
540
+ await emit(render({ kind: "error", message: `python interpreter not found at ${String(chosen)}` }), 1);
541
+ }
542
+ if (typeof chosen !== "string") {
543
+ await emit(render({ kind: "error", message: "python must be an absolute path" }), 1);
544
+ }
545
+ return chosen;
546
+ }
547
+ function helpText() {
548
+ const lines = [
549
+ "poddrill transcribes audio, groups voices, and stores a summary.",
550
+ "",
551
+ "Commands:",
552
+ " setup --provider <id> --queue-length <n> [--fleet-limit <n>] [--embedding-limit <n>]",
553
+ " [--model-fleet <id>=<count>] [--model-weights <id>=<path>] [--python <path>]",
554
+ " install-python create ~/.cache/poddrill/venv and install pinned Python packages",
555
+ " fetch-weights download the pinned ECAPA file and check its sha256",
556
+ " bootstrap [--provider <id>] [--queue-length <n>]",
557
+ " install-python, fetch-weights, then setup",
558
+ " start <path> [--language <code>]",
559
+ " episode <episodeId>",
560
+ " cues <episodeId> [--limit <n>] [--after <cueId>] [--group-id <id>] [--hosts]",
561
+ " label-groups <episodeId> <json>",
562
+ " start-summary <episodeId>",
563
+ " set-summary <episodeId> <summaryId> <json>",
564
+ " put-paragraph <episodeId> <summaryId> <json>",
565
+ " post-paragraph <episodeId> <summaryId> <json>",
566
+ " summary <episodeId> <summaryId>",
567
+ " audit <episodeId>",
568
+ " serve",
569
+ " stop stop the running owner",
570
+ " mcp [--selftest]",
571
+ " client doctor|plan|apply",
572
+ "",
573
+ " --help show this text",
574
+ " --version print the package version",
575
+ "",
576
+ "Providers localhost, grok-bot, and cursor-cloud all run the local Python host on this computer.",
577
+ "Every command uses PODDRILL_WORKSPACE, or ~/poddrill when that variable is unset.",
578
+ "Python is PODDRILL_PYTHON, then the path stored by setup, then ~/.cache/poddrill/venv/bin/python, then python3.",
579
+ ];
580
+ return `${lines.join("\n")}\n`;
581
+ }
582
+ async function versionText() {
583
+ const identity = await readPackageIdentity(import.meta.url);
584
+ return `poddrill ${identity.version}\n`;
585
+ }
433
586
  function delay(ms) {
434
587
  return new Promise((resolve) => {
435
588
  setTimeout(resolve, ms);