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
package/dist/worker.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One long-lived python3 process that answers one framed request at a time on stdin/stdout.
|
|
3
|
+
* Frame: 8-byte big-endian body length, then UTF-8 JSON.
|
|
4
|
+
* Shared request: { "wavPath": string }. A Whistle request also sends "language".
|
|
5
|
+
* Replies: { "kind": "ready", ... } once, then per request
|
|
6
|
+
* { "kind": "result", "result": <value> } or { "kind": "error", "message": string }.
|
|
7
|
+
* Wire types stay in this file. The fleet sees parsed results and reason strings.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { asRecord } from "./disk.js";
|
|
11
|
+
const MAX_FRAME_BYTES = 64 * 1024 * 1024;
|
|
12
|
+
const STDERR_TAIL_BYTES = 8 * 1024;
|
|
13
|
+
/**
|
|
14
|
+
* Python that every worker script starts with. Defines `send`, `recv`, and `serve(handle)`.
|
|
15
|
+
* Frames get a private copy of fd 1. Fd 1 itself points at stderr before any later import,
|
|
16
|
+
* so Python prints and native library writes cannot interleave with frames.
|
|
17
|
+
*/
|
|
18
|
+
export const PYTHON_FRAMES = [
|
|
19
|
+
"import json",
|
|
20
|
+
"import os",
|
|
21
|
+
"import struct",
|
|
22
|
+
"import sys",
|
|
23
|
+
"out = os.fdopen(os.dup(1), \"wb\")",
|
|
24
|
+
"os.dup2(2, 1)",
|
|
25
|
+
"sys.stdout = sys.stderr",
|
|
26
|
+
"inp = sys.stdin.buffer",
|
|
27
|
+
"def send(obj):",
|
|
28
|
+
" body = json.dumps(obj).encode(\"utf-8\")",
|
|
29
|
+
" out.write(struct.pack(\">Q\", len(body)) + body)",
|
|
30
|
+
" out.flush()",
|
|
31
|
+
"def recv():",
|
|
32
|
+
" head = inp.read(8)",
|
|
33
|
+
" if len(head) < 8:",
|
|
34
|
+
" return None",
|
|
35
|
+
" (size,) = struct.unpack(\">Q\", head)",
|
|
36
|
+
" return json.loads(inp.read(size).decode(\"utf-8\"))",
|
|
37
|
+
"def serve(handle):",
|
|
38
|
+
" while True:",
|
|
39
|
+
" message = recv()",
|
|
40
|
+
" if message is None:",
|
|
41
|
+
" break",
|
|
42
|
+
" try:",
|
|
43
|
+
" result = handle(message)",
|
|
44
|
+
" except Exception as error:",
|
|
45
|
+
" send({\"kind\": \"error\", \"message\": str(error)})",
|
|
46
|
+
" continue",
|
|
47
|
+
" send({\"kind\": \"result\", \"result\": result})",
|
|
48
|
+
].join("\n");
|
|
49
|
+
export function spawnFramedWorker(worker, events) {
|
|
50
|
+
const label = worker.label;
|
|
51
|
+
const child = spawn("python3", [...worker.args], { stdio: ["pipe", "pipe", "pipe"] });
|
|
52
|
+
let state = "booting";
|
|
53
|
+
let pending;
|
|
54
|
+
let stdout = Buffer.alloc(0);
|
|
55
|
+
let stderrTail = Buffer.alloc(0);
|
|
56
|
+
const exitOnce = (reason) => {
|
|
57
|
+
if (state === "exited") {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
state = "exited";
|
|
61
|
+
events.exit(reason);
|
|
62
|
+
const settled = pending;
|
|
63
|
+
pending = undefined;
|
|
64
|
+
settled?.reject(new Error(reason));
|
|
65
|
+
};
|
|
66
|
+
const fail = (reason) => {
|
|
67
|
+
child.kill("SIGKILL");
|
|
68
|
+
exitOnce(reason);
|
|
69
|
+
};
|
|
70
|
+
const onReply = (reply) => {
|
|
71
|
+
if (reply === undefined) {
|
|
72
|
+
fail(`${label} worker sent a malformed reply`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
switch (reply.kind) {
|
|
76
|
+
case "ready":
|
|
77
|
+
if (state !== "booting") {
|
|
78
|
+
fail(`${label} worker sent ready twice`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
state = "checking";
|
|
82
|
+
void worker.checkReady(reply.frame).then((refusal) => {
|
|
83
|
+
if (state !== "checking") {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (refusal !== undefined) {
|
|
87
|
+
fail(refusal);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
state = "ready";
|
|
91
|
+
events.ready();
|
|
92
|
+
}, (error) => {
|
|
93
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
case "result":
|
|
97
|
+
case "error": {
|
|
98
|
+
const settled = pending;
|
|
99
|
+
if (state !== "ready" || settled === undefined) {
|
|
100
|
+
fail(`${label} worker replied without a request`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
pending = undefined;
|
|
104
|
+
if (reply.kind === "result") {
|
|
105
|
+
settled.resolve(reply.result);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
settled.reject(new Error(reply.message));
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
default: {
|
|
113
|
+
const leftover = reply;
|
|
114
|
+
fail(`unexpected ${label} reply ${JSON.stringify(leftover)}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
child.stdout.on("data", (chunk) => {
|
|
119
|
+
const frames = takeFrames(Buffer.concat([stdout, chunk]));
|
|
120
|
+
stdout = Buffer.from(frames.rest);
|
|
121
|
+
for (const body of frames.bodies) {
|
|
122
|
+
if (state === "exited") {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
onReply(parseReply(body, worker));
|
|
126
|
+
}
|
|
127
|
+
if (frames.oversized) {
|
|
128
|
+
fail(`${label} worker sent a frame over ${MAX_FRAME_BYTES} bytes`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
child.stderr.on("data", (chunk) => {
|
|
132
|
+
const joined = Buffer.concat([stderrTail, chunk]);
|
|
133
|
+
stderrTail = Buffer.from(joined.subarray(Math.max(0, joined.length - STDERR_TAIL_BYTES)));
|
|
134
|
+
});
|
|
135
|
+
// Writing to a worker that already died raises EPIPE here. The close event reports the death.
|
|
136
|
+
child.stdin.on("error", () => { });
|
|
137
|
+
child.on("error", (error) => {
|
|
138
|
+
exitOnce(error.message);
|
|
139
|
+
});
|
|
140
|
+
child.on("close", (code) => {
|
|
141
|
+
const tail = stderrTail.toString("utf8").trim();
|
|
142
|
+
exitOnce(tail === "" ? `${label} exited with code ${code === null ? "null" : String(code)}` : tail);
|
|
143
|
+
});
|
|
144
|
+
return {
|
|
145
|
+
request(frame) {
|
|
146
|
+
if (state !== "ready" || pending !== undefined) {
|
|
147
|
+
return Promise.reject(new Error(`${label} worker is not ready for a request`));
|
|
148
|
+
}
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
// A death mid-request reports this request's stderr, not chatter from earlier requests.
|
|
151
|
+
stderrTail = Buffer.alloc(0);
|
|
152
|
+
pending = { resolve, reject };
|
|
153
|
+
child.stdin.write(encodeFrame(frame));
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
close() {
|
|
157
|
+
// An idle worker reads EOF and exits. A busy one finishes, hits EPIPE on write, and exits.
|
|
158
|
+
child.stdin.end();
|
|
159
|
+
child.stdout.destroy();
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function encodeFrame(body) {
|
|
164
|
+
const json = Buffer.from(JSON.stringify(body), "utf8");
|
|
165
|
+
const header = Buffer.alloc(8);
|
|
166
|
+
header.writeBigUInt64BE(BigInt(json.length));
|
|
167
|
+
return Buffer.concat([header, json]);
|
|
168
|
+
}
|
|
169
|
+
/** Same shape as `takeMessages` in owner.ts. `oversized` means the stream is corrupt. */
|
|
170
|
+
function takeFrames(buffer) {
|
|
171
|
+
const bodies = [];
|
|
172
|
+
let rest = buffer;
|
|
173
|
+
while (rest.length >= 8) {
|
|
174
|
+
const size = rest.readBigUInt64BE(0);
|
|
175
|
+
if (size > BigInt(MAX_FRAME_BYTES)) {
|
|
176
|
+
return { bodies, rest: Buffer.alloc(0), oversized: true };
|
|
177
|
+
}
|
|
178
|
+
const end = 8 + Number(size);
|
|
179
|
+
if (rest.length < end) {
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
const text = rest.subarray(8, end).toString("utf8");
|
|
183
|
+
rest = rest.subarray(end);
|
|
184
|
+
try {
|
|
185
|
+
bodies.push(JSON.parse(text));
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
bodies.push(undefined);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { bodies, rest, oversized: false };
|
|
192
|
+
}
|
|
193
|
+
/** `result` goes through `parseResult`, so a malformed result is a per-request error, not a crash. */
|
|
194
|
+
function parseReply(value, worker) {
|
|
195
|
+
const record = asRecord(value);
|
|
196
|
+
if (record === undefined) {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
const kind = record["kind"];
|
|
200
|
+
if (kind === "ready") {
|
|
201
|
+
return { kind: "ready", frame: record };
|
|
202
|
+
}
|
|
203
|
+
if (kind === "error" && typeof record["message"] === "string") {
|
|
204
|
+
return { kind: "error", message: record["message"] };
|
|
205
|
+
}
|
|
206
|
+
if (kind === "result") {
|
|
207
|
+
try {
|
|
208
|
+
return { kind: "result", result: worker.parseResult(record["result"]) };
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
return { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace layout and setup config.
|
|
3
|
+
* The only module that builds a path under the workspace. Writers and readers both call `episodeLayout`.
|
|
4
|
+
*/
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { mkdir, realpath, rename, writeFile } from "node:fs/promises";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
9
|
+
import { asRecord, errorCode, pathExists, readJsonFile } from "./disk.js";
|
|
10
|
+
import { isModelId, modelIds } from "./models.js";
|
|
11
|
+
import { isProviderId, providerIds } from "./provider.js";
|
|
12
|
+
/** Every artifact an episode lists, in the order it lists them. */
|
|
13
|
+
export const artifactKinds = ["transcript", "embedding"];
|
|
14
|
+
const CONFIG_FILE = "poddrill.json";
|
|
15
|
+
const AUDIO_DIR = "audio";
|
|
16
|
+
const EPISODES_DIR = "episodes";
|
|
17
|
+
const ARTIFACT_OUTPUTS = {
|
|
18
|
+
transcript: "transcript.vtt",
|
|
19
|
+
embedding: "embedding.json",
|
|
20
|
+
};
|
|
21
|
+
/** `PODDRILL_WORKSPACE`, else `~/poddrill`, resolved with realpath. */
|
|
22
|
+
export async function resolveWorkspace(env) {
|
|
23
|
+
const configured = env["PODDRILL_WORKSPACE"];
|
|
24
|
+
const candidate = resolve(configured === undefined || configured === "" ? join(homedir(), "poddrill") : configured);
|
|
25
|
+
let root;
|
|
26
|
+
try {
|
|
27
|
+
root = asWorkspaceRoot(await realpath(candidate));
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (errorCode(error) === "ENOENT") {
|
|
31
|
+
throw new Error(`no poddrill workspace at ${candidate}. Run poddrill setup first.`);
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
if (!(await pathExists(workspaceDirs(root).config))) {
|
|
36
|
+
throw new Error(`no ${CONFIG_FILE} in ${root}. Run poddrill setup first.`);
|
|
37
|
+
}
|
|
38
|
+
return root;
|
|
39
|
+
}
|
|
40
|
+
export async function readConfig(root) {
|
|
41
|
+
const path = workspaceDirs(root).config;
|
|
42
|
+
const config = parseConfig(await readJsonFile(path));
|
|
43
|
+
if (config === undefined) {
|
|
44
|
+
throw new Error([
|
|
45
|
+
`${path} must name a provider (${providerIds.join(", ")}), a positive integer queueLength,`,
|
|
46
|
+
"and, if present, a positive integer fleetLimit and embeddingLimit",
|
|
47
|
+
].join(" "));
|
|
48
|
+
}
|
|
49
|
+
return config;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Create `audio/` and `episodes/`, and write `poddrill.json` atomically when it changes.
|
|
53
|
+
* `ownerAlive` comes from the caller's socket probe.
|
|
54
|
+
*/
|
|
55
|
+
export async function setupWorkspace(input) {
|
|
56
|
+
if (!isProviderId(input.provider)) {
|
|
57
|
+
throw new Error(`unknown provider ${input.provider}. Use one of ${providerIds.join(", ")}.`);
|
|
58
|
+
}
|
|
59
|
+
if (!isCount(input.queueLength)) {
|
|
60
|
+
throw new Error(`queue length must be a positive integer, got ${input.queueLength}`);
|
|
61
|
+
}
|
|
62
|
+
if (input.fleetLimit !== undefined && !isCount(input.fleetLimit)) {
|
|
63
|
+
throw new Error(`fleet limit must be a positive integer, got ${input.fleetLimit}`);
|
|
64
|
+
}
|
|
65
|
+
if (input.embeddingLimit !== undefined && !isCount(input.embeddingLimit)) {
|
|
66
|
+
throw new Error(`embedding limit must be a positive integer, got ${input.embeddingLimit}`);
|
|
67
|
+
}
|
|
68
|
+
const requested = resolve(input.root);
|
|
69
|
+
const current = parseConfig(await readJsonFile(join(requested, CONFIG_FILE)));
|
|
70
|
+
const modelFleet = input.modelFleet ?? current?.modelFleet ?? {};
|
|
71
|
+
const modelWeights = input.modelWeights ?? current?.modelWeights ?? {};
|
|
72
|
+
assertModelFleet(modelFleet);
|
|
73
|
+
assertModelWeights(modelWeights);
|
|
74
|
+
const config = {
|
|
75
|
+
provider: input.provider,
|
|
76
|
+
queueLength: input.queueLength,
|
|
77
|
+
fleetLimit: input.fleetLimit ?? current?.fleetLimit ?? 1,
|
|
78
|
+
modelFleet,
|
|
79
|
+
modelWeights,
|
|
80
|
+
embeddingLimit: input.embeddingLimit ?? current?.embeddingLimit ?? 1,
|
|
81
|
+
};
|
|
82
|
+
const unchanged = current !== undefined && sameConfig(current, config);
|
|
83
|
+
if (input.ownerAlive && current !== undefined && !unchanged) {
|
|
84
|
+
return {
|
|
85
|
+
kind: "rejected",
|
|
86
|
+
reason: "owner-running",
|
|
87
|
+
current,
|
|
88
|
+
message: [
|
|
89
|
+
`poddrill serve is running with provider ${current.provider},`,
|
|
90
|
+
`queue length ${current.queueLength}, fleet limit ${current.fleetLimit},`,
|
|
91
|
+
`and embedding limit ${current.embeddingLimit}.`,
|
|
92
|
+
"Stop it before changing the setup.",
|
|
93
|
+
].join(" "),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
await mkdir(join(requested, AUDIO_DIR), { recursive: true });
|
|
97
|
+
await mkdir(join(requested, EPISODES_DIR), { recursive: true });
|
|
98
|
+
const dirs = workspaceDirs(asWorkspaceRoot(await realpath(requested)));
|
|
99
|
+
if (!unchanged) {
|
|
100
|
+
const temp = `${dirs.config}.${randomBytes(4).toString("hex")}.tmp`;
|
|
101
|
+
await writeFile(temp, writtenConfig(config));
|
|
102
|
+
await rename(temp, dirs.config);
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
kind: "ready",
|
|
106
|
+
report: {
|
|
107
|
+
dirs,
|
|
108
|
+
config,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export function workspaceDirs(root) {
|
|
113
|
+
return {
|
|
114
|
+
root,
|
|
115
|
+
config: join(root, CONFIG_FILE),
|
|
116
|
+
audioDir: join(root, AUDIO_DIR),
|
|
117
|
+
episodesDir: join(root, EPISODES_DIR),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
export function episodeLayout(root, id) {
|
|
121
|
+
const dir = join(root, EPISODES_DIR, id);
|
|
122
|
+
const artifact = (kind) => {
|
|
123
|
+
const artifactDir = join(dir, kind);
|
|
124
|
+
return {
|
|
125
|
+
dir: artifactDir,
|
|
126
|
+
output: join(artifactDir, ARTIFACT_OUTPUTS[kind]),
|
|
127
|
+
failed: join(artifactDir, "failed.json"),
|
|
128
|
+
workDir: join(artifactDir, "work"),
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
dir,
|
|
133
|
+
manifest: join(dir, "episode.json"),
|
|
134
|
+
groups: join(dir, "groups.json"),
|
|
135
|
+
labels: join(dir, "labels.json"),
|
|
136
|
+
summaries: join(dir, "summaries"),
|
|
137
|
+
audit: join(dir, "audit.jsonl"),
|
|
138
|
+
artifacts: { transcript: artifact("transcript"), embedding: artifact("embedding") },
|
|
139
|
+
source: (extension) => join(dir, `source${extension}`),
|
|
140
|
+
summaryFile: (summaryId) => join(dir, "summaries", `${summaryId}.yaml`),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** `transcript/work/0007.wav` and `transcript/work/0007.whistle.json` for index 7. */
|
|
144
|
+
export function sliceFiles(layout, index) {
|
|
145
|
+
const name = workName(index);
|
|
146
|
+
const workDir = layout.artifacts.transcript.workDir;
|
|
147
|
+
return {
|
|
148
|
+
wavPath: join(workDir, `${name}.wav`),
|
|
149
|
+
whistlePath: join(workDir, `${name}.whistle.json`),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** `embedding/work/0007.wav` for stretch 7. */
|
|
153
|
+
export function stretchWav(layout, index) {
|
|
154
|
+
return join(layout.artifacts.embedding.workDir, `${workName(index)}.wav`);
|
|
155
|
+
}
|
|
156
|
+
export function episodeIdFromDigest(sha256Hex) {
|
|
157
|
+
if (!/^[0-9a-f]{64}$/.test(sha256Hex)) {
|
|
158
|
+
throw new Error("an episode id needs a sha256 digest of 64 lowercase hex digits");
|
|
159
|
+
}
|
|
160
|
+
return asEpisodeId(`ep_${sha256Hex.slice(0, 16)}`);
|
|
161
|
+
}
|
|
162
|
+
/** Anything but `^ep_[0-9a-f]{16}$` is `undefined`, which the episode read reports as `unknown`. */
|
|
163
|
+
export function parseEpisodeId(value) {
|
|
164
|
+
return /^ep_[0-9a-f]{16}$/.test(value) ? asEpisodeId(value) : undefined;
|
|
165
|
+
}
|
|
166
|
+
function workName(index) {
|
|
167
|
+
return String(index).padStart(4, "0");
|
|
168
|
+
}
|
|
169
|
+
function parseConfig(value) {
|
|
170
|
+
const record = asRecord(value);
|
|
171
|
+
if (record === undefined) {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
const provider = record["provider"];
|
|
175
|
+
const queueLength = record["queueLength"];
|
|
176
|
+
if (typeof provider !== "string" || !isProviderId(provider)) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
if (!isCount(queueLength)) {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
const fleetLimit = readFleetLimit(record);
|
|
183
|
+
const embeddingLimit = optionalCount(record["embeddingLimit"]);
|
|
184
|
+
if (fleetLimit === undefined || embeddingLimit === undefined) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
provider,
|
|
189
|
+
queueLength,
|
|
190
|
+
fleetLimit,
|
|
191
|
+
modelFleet: readModelFleet(record["modelFleet"]),
|
|
192
|
+
modelWeights: readModelWeights(record["modelWeights"]),
|
|
193
|
+
embeddingLimit,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* `fleetLimit` wins. An old file that only has `whistleLimit` uses that number.
|
|
198
|
+
* Neither field reads as 1. A present field that is not a positive integer does not parse.
|
|
199
|
+
*/
|
|
200
|
+
function readFleetLimit(record) {
|
|
201
|
+
if ("fleetLimit" in record) {
|
|
202
|
+
return optionalCount(record["fleetLimit"]);
|
|
203
|
+
}
|
|
204
|
+
if ("whistleLimit" in record) {
|
|
205
|
+
return optionalCount(record["whistleLimit"]);
|
|
206
|
+
}
|
|
207
|
+
return 1;
|
|
208
|
+
}
|
|
209
|
+
function readModelFleet(value) {
|
|
210
|
+
if (value === undefined) {
|
|
211
|
+
return {};
|
|
212
|
+
}
|
|
213
|
+
const record = asRecord(value);
|
|
214
|
+
if (record === undefined) {
|
|
215
|
+
throw new Error("modelFleet must be an object");
|
|
216
|
+
}
|
|
217
|
+
const fleet = {};
|
|
218
|
+
for (const [key, count] of Object.entries(record)) {
|
|
219
|
+
if (!isModelId(key)) {
|
|
220
|
+
throw new Error(`unknown model ${key}`);
|
|
221
|
+
}
|
|
222
|
+
if (!isCount(count)) {
|
|
223
|
+
throw new Error(`fleet size for ${key} must be a positive integer, got ${String(count)}`);
|
|
224
|
+
}
|
|
225
|
+
fleet[key] = count;
|
|
226
|
+
}
|
|
227
|
+
return fleet;
|
|
228
|
+
}
|
|
229
|
+
function readModelWeights(value) {
|
|
230
|
+
if (value === undefined) {
|
|
231
|
+
return {};
|
|
232
|
+
}
|
|
233
|
+
const record = asRecord(value);
|
|
234
|
+
if (record === undefined) {
|
|
235
|
+
throw new Error("modelWeights must be an object");
|
|
236
|
+
}
|
|
237
|
+
const weights = {};
|
|
238
|
+
for (const [key, path] of Object.entries(record)) {
|
|
239
|
+
if (!isModelId(key)) {
|
|
240
|
+
throw new Error(`unknown model ${key}`);
|
|
241
|
+
}
|
|
242
|
+
if (typeof path !== "string" || !isAbsolute(path)) {
|
|
243
|
+
throw new Error(`model weights path for ${key} must be absolute, got ${String(path)}`);
|
|
244
|
+
}
|
|
245
|
+
weights[key] = path;
|
|
246
|
+
}
|
|
247
|
+
return weights;
|
|
248
|
+
}
|
|
249
|
+
function assertModelFleet(fleet) {
|
|
250
|
+
for (const id of modelIds) {
|
|
251
|
+
const count = fleet[id];
|
|
252
|
+
if (count !== undefined && !isCount(count)) {
|
|
253
|
+
throw new Error(`fleet size for ${id} must be a positive integer, got ${String(count)}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function assertModelWeights(weights) {
|
|
258
|
+
for (const id of modelIds) {
|
|
259
|
+
const path = weights[id];
|
|
260
|
+
if (path !== undefined && !isAbsolute(path)) {
|
|
261
|
+
throw new Error(`model weights path for ${id} must be absolute, got ${path}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function writtenConfig(config) {
|
|
266
|
+
const body = {
|
|
267
|
+
provider: config.provider,
|
|
268
|
+
queueLength: config.queueLength,
|
|
269
|
+
fleetLimit: config.fleetLimit,
|
|
270
|
+
};
|
|
271
|
+
if (modelIds.some((id) => config.modelFleet[id] !== undefined)) {
|
|
272
|
+
body["modelFleet"] = config.modelFleet;
|
|
273
|
+
}
|
|
274
|
+
if (modelIds.some((id) => config.modelWeights[id] !== undefined)) {
|
|
275
|
+
body["modelWeights"] = config.modelWeights;
|
|
276
|
+
}
|
|
277
|
+
body["embeddingLimit"] = config.embeddingLimit;
|
|
278
|
+
return `${JSON.stringify(body, null, 2)}\n`;
|
|
279
|
+
}
|
|
280
|
+
/** Absent reads as 1. Present and not a positive integer is `undefined`. */
|
|
281
|
+
function optionalCount(value) {
|
|
282
|
+
if (value === undefined) {
|
|
283
|
+
return 1;
|
|
284
|
+
}
|
|
285
|
+
return isCount(value) ? value : undefined;
|
|
286
|
+
}
|
|
287
|
+
function sameConfig(a, b) {
|
|
288
|
+
return (a.provider === b.provider &&
|
|
289
|
+
a.queueLength === b.queueLength &&
|
|
290
|
+
a.fleetLimit === b.fleetLimit &&
|
|
291
|
+
a.embeddingLimit === b.embeddingLimit &&
|
|
292
|
+
sameById(a.modelFleet, b.modelFleet) &&
|
|
293
|
+
sameById(a.modelWeights, b.modelWeights));
|
|
294
|
+
}
|
|
295
|
+
function sameById(left, right) {
|
|
296
|
+
return modelIds.every((id) => left[id] === right[id]);
|
|
297
|
+
}
|
|
298
|
+
function isCount(value) {
|
|
299
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1;
|
|
300
|
+
}
|
|
301
|
+
function asWorkspaceRoot(path) {
|
|
302
|
+
return path;
|
|
303
|
+
}
|
|
304
|
+
function asEpisodeId(value) {
|
|
305
|
+
return value;
|
|
306
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "poddrill",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Transcribe episode audio into timed text, group the voices, and store a summary.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Jianyu Zhao",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"poddrill": "dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
".agents/skills/poddrill"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22.18.0"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/JeromeZhao/PodDrill.git"
|
|
21
|
+
},
|
|
22
|
+
"cactusNeedle": "3.2.0",
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "node scripts/build-js.mjs",
|
|
25
|
+
"test": "node --experimental-strip-types --test test/episode.test.ts test/fleet.test.ts test/idle-fleet.test.ts test/jobs.test.ts test/cues.test.ts test/summary.test.ts test/cli.test.ts test/audit.test.ts test/needle.test.ts test/publish.test.ts test/client.test.ts test/harness/run.test.ts"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"yaml": "2.9.1"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"typescript": "5.9.3"
|
|
32
|
+
}
|
|
33
|
+
}
|