poddrill 0.0.0-stage → 0.1.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 +96 -0
- package/.agents/skills/poddrill/references/assign-speakers.md +22 -0
- package/.agents/skills/poddrill/references/create-summary.md +140 -0
- package/.agents/skills/poddrill/references/fetch-audio.md +40 -0
- package/.agents/skills/poddrill/references/orwell.md +71 -0
- package/.agents/skills/poddrill/references/select-models.md +58 -0
- package/.agents/skills/poddrill/references/track-sources.md +59 -0
- package/.agents/skills/poddrill/scripts/fetch-audio.mjs +154 -0
- package/LICENSE +21 -0
- package/README.md +28 -2
- package/dist/audio.js +262 -0
- package/dist/audit.js +834 -0
- package/dist/cli.js +455 -0
- package/dist/client.js +1450 -0
- package/dist/disk.js +47 -0
- package/dist/embed.js +127 -0
- package/dist/episode.js +259 -0
- package/dist/fleet.js +176 -0
- package/dist/groups.js +512 -0
- package/dist/idle-fleet.js +71 -0
- package/dist/jobs.js +1053 -0
- package/dist/models.js +45 -0
- package/dist/moonshine.js +185 -0
- package/dist/needle.js +42 -0
- package/dist/owner.js +770 -0
- package/dist/package-identity.js +73 -0
- package/dist/provider.js +123 -0
- package/dist/stretch.js +46 -0
- package/dist/summary.js +258 -0
- package/dist/transcribe.js +53 -0
- package/dist/worker.js +215 -0
- package/dist/workspace.js +306 -0
- package/package.json +31 -4
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: poddrill
|
|
3
|
+
description: >-
|
|
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.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# PodDrill
|
|
13
|
+
|
|
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.
|
|
15
|
+
|
|
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.
|
|
17
|
+
|
|
18
|
+
## Cues
|
|
19
|
+
|
|
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.
|
|
21
|
+
|
|
22
|
+
Read cues only after the embedding is ready. A ready transcript can still be not-ready for cues.
|
|
23
|
+
|
|
24
|
+
## Speaker groups
|
|
25
|
+
|
|
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.
|
|
27
|
+
|
|
28
|
+
A summary does not require those labels.
|
|
29
|
+
|
|
30
|
+
## Summaries
|
|
31
|
+
|
|
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.
|
|
33
|
+
|
|
34
|
+
## The episode
|
|
35
|
+
|
|
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.
|
|
37
|
+
|
|
38
|
+
## Register the client
|
|
39
|
+
|
|
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`.
|
|
41
|
+
|
|
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.
|
|
43
|
+
|
|
44
|
+
Then run `npx -y poddrill@<version> client plan --client cursor --json`.
|
|
45
|
+
|
|
46
|
+
After that plan is approved, run `npx -y poddrill@<version> client apply --yes --client cursor`.
|
|
47
|
+
|
|
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.
|
|
49
|
+
|
|
50
|
+
## Set up once
|
|
51
|
+
|
|
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`.
|
|
53
|
+
|
|
54
|
+
Run setup once in that workspace. The provider is the computer you are running on.
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
poddrill setup --provider <id> --queue-length 4
|
|
58
|
+
```
|
|
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`.
|
|
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.
|
|
63
|
+
|
|
64
|
+
Stop the owner before you change setup. A running owner rejects a different provider, queue length, fleet limit, or embedding limit.
|
|
65
|
+
|
|
66
|
+
## Start an episode
|
|
67
|
+
|
|
68
|
+
Write the WAV at `<workspace>/audio/<file>.wav`. `poddrill start` resolves the path from the workspace root and rejects a file outside `audio/`.
|
|
69
|
+
|
|
70
|
+
Pass `--language <code>` on `poddrill start` when the episode is not English. The code is `en`, `de`, `fr`, `es`, `it`, `nl`, `pl`, `ja`, `ko`, or `zh`.
|
|
71
|
+
|
|
72
|
+
`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
|
+
|
|
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.
|
|
75
|
+
|
|
76
|
+
## Read a result
|
|
77
|
+
|
|
78
|
+
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
|
+
|
|
80
|
+
## Present the summary
|
|
81
|
+
|
|
82
|
+
Present the summary in the format preferred or prescribed by users, but always grounded by the summary constructed via poddrill.
|
|
83
|
+
|
|
84
|
+
## Guides
|
|
85
|
+
|
|
86
|
+
Each file tells you how to do one part of the task.
|
|
87
|
+
|
|
88
|
+
- For a feed or a YouTube page, and for telling a new episode from one already handed to poddrill, read [references/track-sources.md](references/track-sources.md).
|
|
89
|
+
- For finding the audio, downloading it, and writing the 16 kHz WAV, read [references/fetch-audio.md](references/fetch-audio.md).
|
|
90
|
+
- For Japanese, Korean, or Chinese weights, read [references/select-models.md](references/select-models.md).
|
|
91
|
+
- For a voice name, a quote, an ending, or cues left undetermined, read [references/assign-speakers.md](references/assign-speakers.md).
|
|
92
|
+
- For an episode summary, read [references/create-summary.md](references/create-summary.md).
|
|
93
|
+
|
|
94
|
+
## Finish
|
|
95
|
+
|
|
96
|
+
Name the episode id, the transcript `state`, and the paths you wrote.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Speaker assignment
|
|
2
|
+
|
|
3
|
+
Read cues with `cues`. Write labels with `labelGroups`. The CLI names are `poddrill cues` and `poddrill label-groups`.
|
|
4
|
+
|
|
5
|
+
A group id looks like `gc0001`. It is one voice. The same name on two groups is one person. A page holds 8 cues unless you pass a limit. The limit cannot be above 20. Pass `groupId` to read one group. Pass `after` with a cue id to read the next page. Pass `hosts` to get host names. That list leaves out `quote` and `ending`.
|
|
6
|
+
|
|
7
|
+
A label is a name, `undetermined`, `quote`, or `ending`. Send only the groups you are changing.
|
|
8
|
+
|
|
9
|
+
## Heuristics You May Use
|
|
10
|
+
|
|
11
|
+
These are suggestions, not rules. Use your best information and judgement.
|
|
12
|
+
|
|
13
|
+
- A self-referencing introduction that then gives a name may belong to that voice group. In English that can be "I'm".
|
|
14
|
+
- If a voice group says another person's name in two or more lines, that name may not be the group. One mention can be a stray line. If one name is left, it may be the one to use.
|
|
15
|
+
- If the lines just before a voice group say a clip is about to play, the group may be a quote. Read the line before it and the line after it.
|
|
16
|
+
- If the hosts have said goodbye and the next lines are a song or a short sign-off, those lines may be the ending. They may stay without a host name.
|
|
17
|
+
|
|
18
|
+
## When to stop
|
|
19
|
+
|
|
20
|
+
About 95 percent of spoken time with a host name is enough. Leave the remaining cues `undetermined`. That does not block later work.
|
|
21
|
+
|
|
22
|
+
Do not copy a name from the nearest group. Do not match a repeated phrase. Do not guess from a handoff. Do not use show notes.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Create an episode summary
|
|
2
|
+
|
|
3
|
+
Build one summary of an episode from its cues. One episode can hold many summaries. Each one is a YAML file. You do not write that file yourself. Use the commands below.
|
|
4
|
+
|
|
5
|
+
Read the cues first with `poddrill cues`. Page them as in [Speaker assignment](assign-speakers.md).
|
|
6
|
+
|
|
7
|
+
The requirement is what the user asked you to summarize. Cite the cues that answer the requirement. You do not need to include a cue if it does not contribute to answering the requirement.
|
|
8
|
+
|
|
9
|
+
Before you write, mark the parts that are actually there. An episode can hold several. A part the cues do not support is left out.
|
|
10
|
+
|
|
11
|
+
### News commentary
|
|
12
|
+
|
|
13
|
+
News commentary is what happened, and the opinions on what happened. An opinion is that person's statement about what happened.
|
|
14
|
+
|
|
15
|
+
### Explanation
|
|
16
|
+
|
|
17
|
+
An explanation is how something works or why it is that way.
|
|
18
|
+
|
|
19
|
+
### Advice
|
|
20
|
+
|
|
21
|
+
Advice is what the listener should do, how they should decide, or how to get better.
|
|
22
|
+
|
|
23
|
+
### Claim
|
|
24
|
+
|
|
25
|
+
A claim is what someone holds, how they elaborate it, and the backup they offer. The elaboration is how they explain the claim. A claim can include a correction of who is speaking, and a place they stop.
|
|
26
|
+
|
|
27
|
+
### Discussion
|
|
28
|
+
|
|
29
|
+
A discussion is one topic, the perspectives on that topic, and how the people respond to each other. A perspective is a claim.
|
|
30
|
+
|
|
31
|
+
## Paragraphs and the summary
|
|
32
|
+
|
|
33
|
+
A cue belongs in a paragraph when it supports a part you marked for the requirement.
|
|
34
|
+
|
|
35
|
+
You decide how the parts sit in the paragraphs. A part may take more than one paragraph. Link the cues that belong to one part, and cover what holds that part together. The summary written from those paragraphs stays clear, concise, and structured.
|
|
36
|
+
|
|
37
|
+
Write `summary` after those paragraphs exist. Write it from the paragraph `text`.
|
|
38
|
+
|
|
39
|
+
## Writing
|
|
40
|
+
|
|
41
|
+
Write the words in `summary` and in each paragraph `text` by [Orwell's rules](orwell.md).
|
|
42
|
+
|
|
43
|
+
## Start the file
|
|
44
|
+
|
|
45
|
+
`startSummary` creates a new id and an empty file for that episode. The file stays under the episode. Starting again creates another summary.
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
poddrill start-summary <episodeId>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The MCP tool is `startSummary`. Pass `episodeId`.
|
|
52
|
+
|
|
53
|
+
Take `summaryId` from the reply. Do not invent an id.
|
|
54
|
+
|
|
55
|
+
`startSummary` works before the transcript is ready.
|
|
56
|
+
|
|
57
|
+
## Append a paragraph
|
|
58
|
+
|
|
59
|
+
A paragraph cites the cue ids it is about, in `cues`. List only those cues. A cue you leave out is not part of the paragraph, even when it sits between two cues you list. The paragraph `text` answers the requirement, written as in [Orwell's rules](orwell.md).
|
|
60
|
+
|
|
61
|
+
The cue words stay in the transcript.
|
|
62
|
+
|
|
63
|
+
The transcript artifact must be `ready`. Until then, append and replace report `not-ready` on `transcript`.
|
|
64
|
+
|
|
65
|
+
Do not send `index`. The first append is index 1. The next append is 2.
|
|
66
|
+
|
|
67
|
+
The file stores `cues` in transcript order. Omit `startMs` and `endMs`. `startMs` is the start of the earliest listed cue. `endMs` is the end of the latest listed cue. A time you send must match those cues.
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
poddrill post-paragraph <episodeId> <summaryId> '<json>'
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{ "text": "The guest says the city allows too few new homes near jobs.", "cues": ["c0002", "c0005"] }
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The MCP tool is `postParagraph`. Pass `episodeId`, `summaryId`, `text`, and `cues`.
|
|
78
|
+
|
|
79
|
+
Two paragraphs may list the same cue.
|
|
80
|
+
|
|
81
|
+
## Replace a paragraph
|
|
82
|
+
|
|
83
|
+
`index` must already exist. A missing index is left uncreated. The new `text` follows [Orwell's rules](orwell.md). The new `cues` follow the same rules as an append.
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
poddrill put-paragraph <episodeId> <summaryId> '<json>'
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{ "index": 1, "text": "The guest says the city allows too few new homes near jobs.", "cues": ["c0002", "c0005"] }
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The MCP tool is `putParagraph`. Pass `episodeId`, `summaryId`, `index`, `text`, and `cues`.
|
|
94
|
+
|
|
95
|
+
## Set the episode summary
|
|
96
|
+
|
|
97
|
+
Write this after the paragraphs. `setSummary` rejects a file that has no paragraphs.
|
|
98
|
+
|
|
99
|
+
`summary` is the prose for the whole episode. Those words follow [Orwell's rules](orwell.md). An empty string is rejected.
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
poddrill set-summary <episodeId> <summaryId> '<json>'
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{ "summary": "Cities allow too few new homes, and one renter's rent rose by a third." }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The MCP tool is `setSummary`. Pass `episodeId`, `summaryId`, and `summary`.
|
|
110
|
+
|
|
111
|
+
## Read it back
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
poddrill summary <episodeId> <summaryId>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The MCP tool is `summary`. Pass `episodeId` and `summaryId`.
|
|
118
|
+
|
|
119
|
+
`poddrill episode` lists each summary as `summaryId`. The paragraphs stay in the summary file.
|
|
120
|
+
|
|
121
|
+
## The file
|
|
122
|
+
|
|
123
|
+
The file is `summaries/<summaryId>.yaml` in the episode directory. It is YAML version 1. `summaryId` is the id `startSummary` returned.
|
|
124
|
+
|
|
125
|
+
```yaml
|
|
126
|
+
version: 1
|
|
127
|
+
episodeId: ep_ab12cd34ef567890
|
|
128
|
+
summaryId: sm_ab12cd34ef567890
|
|
129
|
+
summary: The guest says the city allows too few new homes.
|
|
130
|
+
paragraphs:
|
|
131
|
+
- index: 1
|
|
132
|
+
text: The guest says the city allows too few new homes near jobs.
|
|
133
|
+
cues:
|
|
134
|
+
- c0002
|
|
135
|
+
- c0005
|
|
136
|
+
startMs: 41000
|
|
137
|
+
endMs: 88000
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`version` is the number 1. Each paragraph has `index`, `text`, `cues`, `startMs`, and `endMs`. `cues` is the ordered set of cue ids that paragraph is about. The episode id, the summary id, and the cue times above are a constructed example.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Find and download episode audio
|
|
2
|
+
|
|
3
|
+
Find the episode audio and download it. Then write the 16-bit PCM mono 16 kHz WAV that `poddrill start` copies. Write that WAV outside this git repository. Delete the download after the WAV exists.
|
|
4
|
+
|
|
5
|
+
## Podcast feed
|
|
6
|
+
|
|
7
|
+
Search for the podcast feed. Take the audio URL for the episode from that feed.
|
|
8
|
+
|
|
9
|
+
Download that URL with the script in this skill. The file is `scripts/fetch-audio.mjs` next to `SKILL.md`. Pass that file to `node`.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
node <skill-dir>/scripts/fetch-audio.mjs <url> <output-path> [expected-bytes]
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The script refuses a destination inside the git tree that contains the script. Pass the byte length when the feed lists one. The script leaves an existing file of that size in place.
|
|
16
|
+
|
|
17
|
+
## yt-dlp
|
|
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.
|
|
20
|
+
|
|
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>"
|
|
24
|
+
```
|
|
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).
|
|
27
|
+
|
|
28
|
+
## Other audio
|
|
29
|
+
|
|
30
|
+
If the episode is not in a podcast feed and yt-dlp does not apply, use the tools you already have or improvise a way to fetch or record the audio. Then write the WAV below.
|
|
31
|
+
|
|
32
|
+
## Write the WAV
|
|
33
|
+
|
|
34
|
+
If the download path and the WAV path are the same file, pick a new WAV path first.
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
ffmpeg -hide_banner -loglevel error -nostdin -y -i <download> -ac 1 -ar 16000 -c:a pcm_s16le -f wav <episode>.wav
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`<episode>.wav` is the path you pass to `poddrill start`. Delete `<download>` only after that WAV exists. Keep the WAV.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Orwell's rules for writing
|
|
2
|
+
|
|
3
|
+
Use this for the words in `summary` and for the words in each paragraph `text`. The rules are George Orwell's, from "Politics and the English Language", Horizon, April 1946. The wording below matches the [Orwell Foundation text](https://www.orwellfoundation.com/the-orwell-foundation/orwell/essays-and-other-works/politics-and-the-english-language/).
|
|
4
|
+
|
|
5
|
+
Pick the word that matches the cue. Do not start from a ready phrase and bend the cue to fit it.
|
|
6
|
+
|
|
7
|
+
Apply all six. The last rule wins when another rule would make the sentence false, cruel, or unclear.
|
|
8
|
+
|
|
9
|
+
## Skip a worn figure
|
|
10
|
+
|
|
11
|
+
Never use a metaphor, simile or other figure of speech which you are used to seeing in print.
|
|
12
|
+
|
|
13
|
+
A figure you have seen in print carries no fact from this episode. Say what the cues say.
|
|
14
|
+
|
|
15
|
+
**Weak.** The housing talk is the tip of the iceberg.
|
|
16
|
+
|
|
17
|
+
**Use.** The guest says the city allows too few new homes, and one renter's rent rose by a third.
|
|
18
|
+
|
|
19
|
+
## Prefer the short word
|
|
20
|
+
|
|
21
|
+
Never use a long word where a short one will do.
|
|
22
|
+
|
|
23
|
+
If a short word names the same fact, use it. Keep a long word only when the short one would name a different fact.
|
|
24
|
+
|
|
25
|
+
**Weak.** The municipality implemented insufficient residential construction in proximity to employment.
|
|
26
|
+
|
|
27
|
+
**Use.** The city built too few homes near jobs.
|
|
28
|
+
|
|
29
|
+
## Cut a word that does no work
|
|
30
|
+
|
|
31
|
+
If it is possible to cut a word out, always cut it out.
|
|
32
|
+
|
|
33
|
+
Read the sentence without the word. If the fact is unchanged, the word goes. "It is important to note that", "basically", and "in order to" fail that test.
|
|
34
|
+
|
|
35
|
+
**Weak.** It is important to note that the guest basically said that the city allows too few new homes.
|
|
36
|
+
|
|
37
|
+
**Use.** The guest says the city allows too few new homes.
|
|
38
|
+
|
|
39
|
+
## Name who did it
|
|
40
|
+
|
|
41
|
+
Never use the passive where you can use the active.
|
|
42
|
+
|
|
43
|
+
Name the person or the body that acted, when the cues name them. "It was argued" hides the guest. "The guest says" does not.
|
|
44
|
+
|
|
45
|
+
**Weak.** It was argued that too few homes are being built near jobs.
|
|
46
|
+
|
|
47
|
+
**Use.** The guest says the city builds too few homes near jobs.
|
|
48
|
+
|
|
49
|
+
## Use an everyday word
|
|
50
|
+
|
|
51
|
+
Never use a foreign phrase, a scientific word or a jargon word if you can think of an everyday English equivalent.
|
|
52
|
+
|
|
53
|
+
Say the fact in words the reader already uses. Keep the speaker's term when the request is about that term, and put the everyday meaning beside it.
|
|
54
|
+
|
|
55
|
+
**Weak.** Zoning externalities suppress housing supply elasticity.
|
|
56
|
+
|
|
57
|
+
**Use.** Rules on what a lot may hold keep new homes scarce.
|
|
58
|
+
|
|
59
|
+
## Break a rule sooner than write something barbarous
|
|
60
|
+
|
|
61
|
+
Break any of these rules sooner than say anything outright barbarous.
|
|
62
|
+
|
|
63
|
+
Barbarous means false, cruel, or so unclear that the reader cannot retell the cue. A short, active sentence that invents an actor has failed.
|
|
64
|
+
|
|
65
|
+
The cues say the city downzoned three blocks, and the request asks what that did. Keep the word, and say what it means.
|
|
66
|
+
|
|
67
|
+
**Use.** The city downzoned three blocks, so each lot may hold less floor space.
|
|
68
|
+
|
|
69
|
+
The cues say the rent rose by a third and do not say who raised it. Do not invent a landlord.
|
|
70
|
+
|
|
71
|
+
**Use.** Rent rose by a third.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Set up Moonshine weights
|
|
2
|
+
|
|
3
|
+
PodDrill does not download the weights. Install the package, download the model with word timestamps, then pass the returned directory to setup.
|
|
4
|
+
|
|
5
|
+
Install the package first.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install moonshine-voice
|
|
9
|
+
```
|
|
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.
|
|
12
|
+
|
|
13
|
+
Setup still needs `--provider` and `--queue-length`. Use the provider the skill already named. Add `--model-weights` to that command.
|
|
14
|
+
|
|
15
|
+
## Japanese
|
|
16
|
+
|
|
17
|
+
Architecture 0 is tiny.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from moonshine_voice import get_model_for_language
|
|
21
|
+
|
|
22
|
+
path, _arch = get_model_for_language("ja", 0, include_word_timestamps=True)
|
|
23
|
+
print(path)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
poddrill setup --provider <id> --queue-length 1 --model-weights moonshine-tiny-ja=<path>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Korean
|
|
31
|
+
|
|
32
|
+
Architecture 0 is tiny.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from moonshine_voice import get_model_for_language
|
|
36
|
+
|
|
37
|
+
path, _arch = get_model_for_language("ko", 0, include_word_timestamps=True)
|
|
38
|
+
print(path)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
poddrill setup --provider <id> --queue-length 1 --model-weights moonshine-tiny-ko=<path>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Chinese
|
|
46
|
+
|
|
47
|
+
Architecture 1 is base.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from moonshine_voice import get_model_for_language
|
|
51
|
+
|
|
52
|
+
path, _arch = get_model_for_language("zh", 1, include_word_timestamps=True)
|
|
53
|
+
print(path)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
poddrill setup --provider <id> --queue-length 1 --model-weights moonshine-base-zh=<path>
|
|
58
|
+
```
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Track podcast sources
|
|
2
|
+
|
|
3
|
+
Keep a SQLite file of sources and the episodes you have already seen. PodDrill keeps the transcript state. This file does not.
|
|
4
|
+
|
|
5
|
+
## Create the tables
|
|
6
|
+
|
|
7
|
+
```sql
|
|
8
|
+
CREATE TABLE source (
|
|
9
|
+
id TEXT PRIMARY KEY,
|
|
10
|
+
name TEXT NOT NULL,
|
|
11
|
+
type TEXT NOT NULL CHECK (type IN ('rss', 'youtube')),
|
|
12
|
+
locator TEXT NOT NULL,
|
|
13
|
+
last_poll TEXT,
|
|
14
|
+
latest_episode TEXT
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE TABLE episode (
|
|
18
|
+
source_id TEXT NOT NULL REFERENCES source (id),
|
|
19
|
+
remote_key TEXT NOT NULL,
|
|
20
|
+
audio_sha256 TEXT CHECK (
|
|
21
|
+
audio_sha256 IS NULL
|
|
22
|
+
OR (
|
|
23
|
+
length(audio_sha256) = 64
|
|
24
|
+
AND audio_sha256 NOT GLOB '*[^0-9a-f]*'
|
|
25
|
+
)
|
|
26
|
+
),
|
|
27
|
+
PRIMARY KEY (source_id, remote_key)
|
|
28
|
+
);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`id` is a stable name you choose for that source.
|
|
32
|
+
|
|
33
|
+
`type` is `rss` or `youtube`.
|
|
34
|
+
|
|
35
|
+
`locator` is loose text. For `rss` it is the feed URL. For `youtube` it is the channel, playlist, or page URL.
|
|
36
|
+
|
|
37
|
+
`last_poll` is the time of the last poll, or null before the first one. Store it as UTC `YYYY-MM-DDTHH:MM:SSZ`.
|
|
38
|
+
|
|
39
|
+
`latest_episode` is the newest episode time from that poll, in the same form. Leave it null when the source has no episode time.
|
|
40
|
+
|
|
41
|
+
`remote_key` is how you recognize the same item on the next poll. For `rss` use the item guid. If the item has no guid, use the enclosure URL. For `youtube` use the video id.
|
|
42
|
+
|
|
43
|
+
`audio_sha256` is the sha256 of the file you pass to `poddrill start`, as 64 lowercase hex digits. Leave it null until that file exists. The episode id is `ep_` plus the first 16 characters of that hash.
|
|
44
|
+
|
|
45
|
+
## Poll
|
|
46
|
+
|
|
47
|
+
Read every row in `source`.
|
|
48
|
+
|
|
49
|
+
For `rss`, fetch the feed at `locator`. For `youtube`, list the videos at `locator`. Then set `last_poll` to the current UTC time. Set `latest_episode` to the newest episode time in that response. Leave `latest_episode` null when the response has no episode time.
|
|
50
|
+
|
|
51
|
+
For each item, look up `source_id` and `remote_key`.
|
|
52
|
+
|
|
53
|
+
If the row is missing, insert it with `audio_sha256` null. Download the audio as in [Find and download episode audio](fetch-audio.md). Hash the file you will pass to `poddrill start`, store that digest, and start the episode.
|
|
54
|
+
|
|
55
|
+
If the row exists and `audio_sha256` is null, the download did not finish. Download it, store the hash, and start the episode.
|
|
56
|
+
|
|
57
|
+
If `audio_sha256` is set, run `poddrill episode` with `ep_` plus the first 16 characters of that hash. The episode is processed when the transcript artifact state is `ready`. Read that from `poddrill episode`, `poddrill cues`, or the episode directory. Do not copy the artifact state, the progress, the paths, or the labels into this database.
|
|
58
|
+
|
|
59
|
+
A transcript that is missing or failed is not processed. Start it again with the CLI or MCP. Leave the hash in place.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import { mkdir, realpath, rename, rm, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import { pipeline } from "node:stream/promises";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
export async function downloadTo(url, outputPath, expectedByteLength) {
|
|
9
|
+
if (typeof url !== "string" || url.length === 0) {
|
|
10
|
+
throw new Error("url must be a non-empty string");
|
|
11
|
+
}
|
|
12
|
+
if (typeof outputPath !== "string" || outputPath.length === 0) {
|
|
13
|
+
throw new Error("output path must be a non-empty string");
|
|
14
|
+
}
|
|
15
|
+
const expected = readExpectedByteLength(expectedByteLength);
|
|
16
|
+
const destination = await canonicalPath(outputPath);
|
|
17
|
+
const root = await gitRoot();
|
|
18
|
+
if (isInside(root, destination)) {
|
|
19
|
+
throw new Error(`refusing to write inside the git tree: ${destination}`);
|
|
20
|
+
}
|
|
21
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
22
|
+
if (expected !== undefined && (await existingSize(destination)) === expected) {
|
|
23
|
+
return destination;
|
|
24
|
+
}
|
|
25
|
+
const response = await fetch(url);
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
throw new Error(`download failed with HTTP ${response.status}`);
|
|
28
|
+
}
|
|
29
|
+
if (response.body === null) {
|
|
30
|
+
throw new Error("download failed with an empty body");
|
|
31
|
+
}
|
|
32
|
+
const partial = `${destination}.partial`;
|
|
33
|
+
try {
|
|
34
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(partial));
|
|
35
|
+
const written = await stat(partial);
|
|
36
|
+
if (expected !== undefined && written.size !== expected) {
|
|
37
|
+
throw new Error(`expected ${expected} bytes, got ${written.size}`);
|
|
38
|
+
}
|
|
39
|
+
await rename(partial, destination);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
await rm(partial, { force: true });
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
return destination;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readExpectedByteLength(value) {
|
|
48
|
+
if (value === undefined) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) {
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
throw new Error("expected byte length must be a positive integer");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function existingSize(destination) {
|
|
58
|
+
try {
|
|
59
|
+
const existing = await stat(destination);
|
|
60
|
+
return existing.isFile() ? existing.size : undefined;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (!isEnoent(error)) {
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isEnoent(error) {
|
|
70
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isInside(root, target) {
|
|
74
|
+
const relative = path.relative(root, target);
|
|
75
|
+
if (relative === "") {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function gitRoot() {
|
|
82
|
+
let current = path.dirname(fileURLToPath(import.meta.url));
|
|
83
|
+
for (;;) {
|
|
84
|
+
try {
|
|
85
|
+
await stat(path.join(current, ".git"));
|
|
86
|
+
return await realpath(current);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (!isEnoent(error)) {
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
const parent = path.dirname(current);
|
|
92
|
+
if (parent === current) {
|
|
93
|
+
throw new Error("refusing to download because no git tree contains the script");
|
|
94
|
+
}
|
|
95
|
+
current = parent;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A missing final component is still inside the tree when its real parent is.
|
|
101
|
+
async function canonicalPath(input) {
|
|
102
|
+
let resolved = path.resolve(input);
|
|
103
|
+
const pending = [];
|
|
104
|
+
for (;;) {
|
|
105
|
+
try {
|
|
106
|
+
const real = await realpath(resolved);
|
|
107
|
+
return pending.length === 0 ? real : path.join(real, ...pending.toReversed());
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (!isEnoent(error)) {
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
const parent = path.dirname(resolved);
|
|
113
|
+
if (parent === resolved) {
|
|
114
|
+
throw new Error(`cannot resolve ${input}`);
|
|
115
|
+
}
|
|
116
|
+
pending.push(path.basename(resolved));
|
|
117
|
+
resolved = parent;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseCliByteLength(raw) {
|
|
123
|
+
if (!/^[1-9][0-9]*$/.test(raw)) {
|
|
124
|
+
throw new Error("expected byte length must be a positive integer");
|
|
125
|
+
}
|
|
126
|
+
const value = Number(raw);
|
|
127
|
+
if (!Number.isSafeInteger(value)) {
|
|
128
|
+
throw new Error("expected byte length must be a positive integer");
|
|
129
|
+
}
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const entry = process.argv[1];
|
|
134
|
+
if (entry !== undefined && path.resolve(entry) === fileURLToPath(import.meta.url)) {
|
|
135
|
+
const url = process.argv[2];
|
|
136
|
+
const outputPath = process.argv[3];
|
|
137
|
+
const rawLength = process.argv[4];
|
|
138
|
+
if (url === undefined || outputPath === undefined || process.argv.length > 5) {
|
|
139
|
+
console.error(
|
|
140
|
+
"usage: node <path-to-this-script> <url> <output-path> [expected-bytes]",
|
|
141
|
+
);
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
} else {
|
|
144
|
+
try {
|
|
145
|
+
const expectedByteLength = rawLength === undefined ? undefined : parseCliByteLength(rawLength);
|
|
146
|
+
const written = await downloadTo(url, outputPath, expectedByteLength);
|
|
147
|
+
console.log(written);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
const message = error instanceof Error ? error.message : "download failed";
|
|
150
|
+
console.error(message);
|
|
151
|
+
process.exitCode = 1;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jianyu Zhao
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|