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.
@@ -2,66 +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 fetching episode audio, starting a transcript, labeling speakers,
8
- or writing a summary. When a session has no PodDrill tools, register the
9
- client before transcribing. Do not use it to change 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.
10
8
  ---
11
9
 
12
10
  # PodDrill
13
11
 
14
- 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.
15
13
 
16
- Set up a workspace once, when it is missing. 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.
17
15
 
18
- ## Cues
16
+ ## Install on this computer
19
17
 
20
- 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.
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.
21
19
 
22
- Read cues only after the embedding is ready. A ready transcript can still be not-ready for cues.
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`.
23
21
 
24
- ## Speaker groups
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`.
25
23
 
26
- 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.
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.
27
25
 
28
- A summary does not require those labels.
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.
29
27
 
30
- ## Summaries
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`.
31
29
 
32
- 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.
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.
33
31
 
34
- ## The episode
32
+ `poddrill bootstrap --provider localhost --queue-length 4` runs steps 3, 4, and 6.
35
33
 
36
- 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.
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.
35
+
36
+ 8. Run `poddrill client doctor --client cursor --json` again. Exit 0 means every required check is ok.
37
+
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.
37
39
 
38
- ## Register the client
40
+ ## Python
39
41
 
40
- 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`.
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.
41
43
 
42
- 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.
44
+ ## Stop
43
45
 
44
- Then run `npx -y poddrill@<version> client plan --client cursor --json`.
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.
45
47
 
46
- After that plan is approved, run `npx -y poddrill@<version> client apply --yes --client cursor`.
48
+ ## Cues
47
49
 
48
- 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.
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.
49
51
 
50
- ## Set up once
52
+ Read cues only after the embedding is ready. A ready transcript can still be not-ready for cues.
51
53
 
52
- 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`.
54
+ ## Speaker groups
53
55
 
54
- Run setup once in that workspace. The provider is the computer you are running on.
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.
55
57
 
56
- ```
57
- poddrill setup --provider <id> --queue-length 4
58
- ```
58
+ A summary does not require those labels.
59
59
 
60
- 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`.
60
+ ## Summaries
61
61
 
62
- 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.
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.
63
63
 
64
- Stop the owner before you change setup. A running owner rejects a different provider, queue length, fleet limit, or embedding limit.
64
+ ## The episode
65
+
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.
65
67
 
66
68
  ## Start an episode
67
69
 
@@ -71,12 +73,14 @@ Pass `--language <code>` on `poddrill start` when the episode is not English. Th
71
73
 
72
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.
73
75
 
74
- 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.
75
77
 
76
78
  ## Read a result
77
79
 
78
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"`.
79
81
 
82
+ `poddrill --help` and `poddrill --version` print plain text and exit 0.
83
+
80
84
  ## Present the summary
81
85
 
82
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
@@ -1,29 +1,40 @@
1
1
  # PodDrill
2
2
 
3
- PodDrill transcribes episode audio into timed text, groups the voices, and stores a summary.
3
+ PodDrill is a skill that transcribes episode audio into timed text, groups the voices, and stores a summary.
4
4
 
5
- ## Requirements
5
+ ## Say this
6
6
 
7
- Use Node.js 22.18 or newer.
7
+ Copy this to the agent.
8
8
 
9
- A transcript also needs ffmpeg and the Python package cactus-needle 3.2.0. That Python package is not the npm package named cactus-needle.
9
+ ```text
10
+ Set up PodDrill on this computer.
11
+ ```
10
12
 
11
- ## Run
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.
12
14
 
13
- From this repository, run the command.
15
+ ## Requirements
14
16
 
15
- ```
16
- node src/cli.ts
17
- ```
17
+ The computer that runs PodDrill needs:
18
18
 
19
- `npm run build` writes `dist`. The published package runs `dist/cli.js`, because Node does not strip TypeScript inside `node_modules`.
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
- The npm package is not published yet. After it is published, run `npx poddrill`.
24
+ `poddrill client doctor` prints a `fix` command for every check that is not ok. `poddrill bootstrap` runs `install-python`, `fetch-weights`, and `setup`.
22
25
 
23
- ## Workspace
26
+ Without `PODDRILL_WORKSPACE`, every command uses `~/poddrill`. `poddrill stop` stops the running owner.
24
27
 
25
- Set `PODDRILL_WORKSPACE` to a directory outside this repository. Then run `poddrill setup` in that directory.
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.
26
35
 
27
36
  ## License
28
37
 
29
- MIT License. Copyright (c) 2026 Jianyu Zhao.
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);