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/groups.js
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speaker groups over stretch vectors. The cut is the widest gap in the
|
|
3
|
+
* average-linkage cosines, not a fixed threshold.
|
|
4
|
+
*/
|
|
5
|
+
import { asRecord } from "./disk.js";
|
|
6
|
+
const UNGROUPED = "ungrouped";
|
|
7
|
+
const TIME = /^(\d{2}):(\d{2}):(\d{2})\.(\d{3}) --> (\d{2}):(\d{2}):(\d{2})\.(\d{3})$/;
|
|
8
|
+
export function readVttCues(vtt) {
|
|
9
|
+
const cues = [];
|
|
10
|
+
for (const block of vtt.replaceAll("\r\n", "\n").split("\n\n")) {
|
|
11
|
+
const lines = block.split("\n").filter((line) => line.length > 0);
|
|
12
|
+
const timeIndex = lines.findIndex((line) => TIME.test(line));
|
|
13
|
+
if (timeIndex <= 0) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
const id = lines[timeIndex - 1];
|
|
17
|
+
const stamp = lines[timeIndex];
|
|
18
|
+
if (id === undefined || stamp === undefined) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const match = TIME.exec(stamp);
|
|
22
|
+
if (match === null) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
cues.push({
|
|
26
|
+
id,
|
|
27
|
+
startMs: stampMs(match[1], match[2], match[3], match[4]),
|
|
28
|
+
endMs: stampMs(match[5], match[6], match[7], match[8]),
|
|
29
|
+
text: lines.slice(timeIndex + 1).join("\n"),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return cues;
|
|
33
|
+
}
|
|
34
|
+
export function readStretchVectors(value) {
|
|
35
|
+
const record = asRecord(value);
|
|
36
|
+
if (record === undefined || !Array.isArray(record["stretches"])) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const parsed = [];
|
|
40
|
+
let width;
|
|
41
|
+
for (const item of record["stretches"]) {
|
|
42
|
+
const stretch = asRecord(item);
|
|
43
|
+
if (stretch === undefined) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
const startMs = stretch["startMs"];
|
|
47
|
+
const endMs = stretch["endMs"];
|
|
48
|
+
const vector = finiteVector(stretch["vector"]);
|
|
49
|
+
if (typeof startMs !== "number" || typeof endMs !== "number" || vector === undefined) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
if (width === undefined) {
|
|
53
|
+
width = vector.length;
|
|
54
|
+
}
|
|
55
|
+
if (vector.length !== width) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
parsed.push({ startMs, endMs, vector });
|
|
59
|
+
}
|
|
60
|
+
return parsed;
|
|
61
|
+
}
|
|
62
|
+
export function assignCues(cues, stretches) {
|
|
63
|
+
const stretchOfCue = cues.map((cue) => stretchCovering(stretches, cue.startMs));
|
|
64
|
+
const used = [];
|
|
65
|
+
for (const index of stretchOfCue) {
|
|
66
|
+
if (index !== undefined && !used.includes(index)) {
|
|
67
|
+
used.push(index);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const vectors = used.flatMap((index) => {
|
|
71
|
+
const stretch = stretches[index];
|
|
72
|
+
return stretch === undefined ? [] : [stretch.vector];
|
|
73
|
+
});
|
|
74
|
+
const groupOfStretch = new Map();
|
|
75
|
+
const centroids = new Map();
|
|
76
|
+
for (const cluster of clusterVectors(vectors)) {
|
|
77
|
+
const original = cluster.flatMap((compact) => {
|
|
78
|
+
const index = used[compact];
|
|
79
|
+
return index === undefined ? [] : [index];
|
|
80
|
+
});
|
|
81
|
+
const memberCues = cues.filter((_, cueIndex) => {
|
|
82
|
+
const stretchIndex = stretchOfCue[cueIndex];
|
|
83
|
+
return stretchIndex !== undefined && original.includes(stretchIndex);
|
|
84
|
+
});
|
|
85
|
+
const first = memberCues[0];
|
|
86
|
+
if (first === undefined) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const groupId = `g${first.id}`;
|
|
90
|
+
for (const index of original) {
|
|
91
|
+
groupOfStretch.set(index, groupId);
|
|
92
|
+
}
|
|
93
|
+
const memberVectors = original.flatMap((index) => {
|
|
94
|
+
const stretch = stretches[index];
|
|
95
|
+
return stretch === undefined ? [] : [stretch.vector];
|
|
96
|
+
});
|
|
97
|
+
centroids.set(groupId, meanVector(memberVectors));
|
|
98
|
+
}
|
|
99
|
+
return cues.map((cue, cueIndex) => {
|
|
100
|
+
const stretchIndex = stretchOfCue[cueIndex];
|
|
101
|
+
const groupId = stretchIndex === undefined ? undefined : groupOfStretch.get(stretchIndex);
|
|
102
|
+
const vector = stretchIndex === undefined ? undefined : stretches[stretchIndex]?.vector;
|
|
103
|
+
if (groupId === undefined || vector === undefined) {
|
|
104
|
+
return { id: cue.id, groupId: UNGROUPED, nearestGroupId: null, nearestCosine: null };
|
|
105
|
+
}
|
|
106
|
+
return { id: cue.id, groupId, ...nearestGroup(vector, groupId, centroids) };
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
export function cueRows(cues, assignments) {
|
|
110
|
+
const byId = new Map(assignments.map((assignment) => [assignment.id, assignment]));
|
|
111
|
+
return cues.map((cue) => {
|
|
112
|
+
const assignment = byId.get(cue.id);
|
|
113
|
+
if (assignment === undefined) {
|
|
114
|
+
return {
|
|
115
|
+
id: cue.id,
|
|
116
|
+
startMs: cue.startMs,
|
|
117
|
+
endMs: cue.endMs,
|
|
118
|
+
text: cue.text,
|
|
119
|
+
groupId: UNGROUPED,
|
|
120
|
+
nearestGroupId: null,
|
|
121
|
+
nearestCosine: null,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
id: cue.id,
|
|
126
|
+
startMs: cue.startMs,
|
|
127
|
+
endMs: cue.endMs,
|
|
128
|
+
text: cue.text,
|
|
129
|
+
groupId: assignment.groupId,
|
|
130
|
+
nearestGroupId: assignment.nearestGroupId,
|
|
131
|
+
nearestCosine: assignment.nearestCosine,
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
export function readAssignments(value) {
|
|
136
|
+
const record = asRecord(value);
|
|
137
|
+
if (record === undefined || !Array.isArray(record["cues"])) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
const assignments = [];
|
|
141
|
+
for (const item of record["cues"]) {
|
|
142
|
+
const parsed = readAssignment(item);
|
|
143
|
+
if (parsed === undefined) {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
assignments.push(parsed);
|
|
147
|
+
}
|
|
148
|
+
return assignments;
|
|
149
|
+
}
|
|
150
|
+
export function knownGroupIds(rows) {
|
|
151
|
+
const ids = new Set([UNGROUPED]);
|
|
152
|
+
for (const row of rows) {
|
|
153
|
+
ids.add(row.groupId);
|
|
154
|
+
}
|
|
155
|
+
return ids;
|
|
156
|
+
}
|
|
157
|
+
export function pageCues(rows, knownGroups, limit, after, groupId) {
|
|
158
|
+
if (groupId !== undefined && !knownGroups.has(groupId)) {
|
|
159
|
+
return { kind: "unknown-group", groupId };
|
|
160
|
+
}
|
|
161
|
+
const filtered = groupId === undefined ? rows : rows.filter((row) => row.groupId === groupId);
|
|
162
|
+
let start = 0;
|
|
163
|
+
if (after !== undefined) {
|
|
164
|
+
const index = filtered.findIndex((row) => row.id === after);
|
|
165
|
+
if (index === -1) {
|
|
166
|
+
return { kind: "unknown-cue", cueId: after };
|
|
167
|
+
}
|
|
168
|
+
start = index + 1;
|
|
169
|
+
}
|
|
170
|
+
return { kind: "page", cues: filtered.slice(start, start + limit) };
|
|
171
|
+
}
|
|
172
|
+
export function parseLabelUpdates(value) {
|
|
173
|
+
if (!Array.isArray(value)) {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
const updates = [];
|
|
177
|
+
for (const item of value) {
|
|
178
|
+
const record = asRecord(item);
|
|
179
|
+
if (record === undefined) {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
const groupId = record["groupId"];
|
|
183
|
+
const label = record["label"];
|
|
184
|
+
if (typeof groupId !== "string" || groupId === "" || typeof label !== "string") {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
if (parseLabel(label) === undefined) {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
updates.push({ groupId, label });
|
|
191
|
+
}
|
|
192
|
+
return updates;
|
|
193
|
+
}
|
|
194
|
+
export function readLabels(value) {
|
|
195
|
+
const record = asRecord(value);
|
|
196
|
+
if (record === undefined || !Array.isArray(record["groups"])) {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
return parseLabelUpdates(record["groups"]);
|
|
200
|
+
}
|
|
201
|
+
export function mergeLabels(current, updates) {
|
|
202
|
+
const map = new Map();
|
|
203
|
+
for (const label of current) {
|
|
204
|
+
map.set(label.groupId, label.label);
|
|
205
|
+
}
|
|
206
|
+
for (const label of updates) {
|
|
207
|
+
map.set(label.groupId, label.label);
|
|
208
|
+
}
|
|
209
|
+
return [...map.entries()]
|
|
210
|
+
.sort((left, right) => compareText(left[0], right[0]))
|
|
211
|
+
.map(([groupId, label]) => ({ groupId, label }));
|
|
212
|
+
}
|
|
213
|
+
/** Person names only. `quote` and `ending` are marks, and `undetermined` is not a name. */
|
|
214
|
+
export function hostNames(labels) {
|
|
215
|
+
const names = [];
|
|
216
|
+
for (const stored of labels) {
|
|
217
|
+
const parsed = parseLabel(stored.label);
|
|
218
|
+
if (parsed === undefined) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const name = personName(parsed);
|
|
222
|
+
if (name !== undefined) {
|
|
223
|
+
names.push(name);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return names;
|
|
227
|
+
}
|
|
228
|
+
function personName(label) {
|
|
229
|
+
switch (label.kind) {
|
|
230
|
+
case "name":
|
|
231
|
+
return label.name;
|
|
232
|
+
case "undetermined":
|
|
233
|
+
case "quote":
|
|
234
|
+
case "ending":
|
|
235
|
+
return undefined;
|
|
236
|
+
default: {
|
|
237
|
+
const leftover = label;
|
|
238
|
+
throw new Error(`unexpected label ${JSON.stringify(leftover)}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function parseLabel(label) {
|
|
243
|
+
if (label === "") {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
switch (label) {
|
|
247
|
+
case "undetermined":
|
|
248
|
+
return { kind: "undetermined" };
|
|
249
|
+
case "quote":
|
|
250
|
+
return { kind: "quote" };
|
|
251
|
+
case "ending":
|
|
252
|
+
return { kind: "ending" };
|
|
253
|
+
default:
|
|
254
|
+
return { kind: "name", name: label };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function readAssignment(value) {
|
|
258
|
+
const record = asRecord(value);
|
|
259
|
+
if (record === undefined) {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
const id = record["id"];
|
|
263
|
+
const groupId = record["groupId"];
|
|
264
|
+
const nearestGroupId = readNullableString(record["nearestGroupId"]);
|
|
265
|
+
const nearestCosine = readNullableNumber(record["nearestCosine"]);
|
|
266
|
+
if (typeof id !== "string" || id === "" || typeof groupId !== "string" || groupId === "") {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
if (nearestGroupId === undefined || nearestCosine === undefined) {
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
if ((nearestGroupId === null) !== (nearestCosine === null)) {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
if (nearestGroupId === null || nearestCosine === null) {
|
|
276
|
+
return { id, groupId, nearestGroupId: null, nearestCosine: null };
|
|
277
|
+
}
|
|
278
|
+
return { id, groupId, nearestGroupId, nearestCosine };
|
|
279
|
+
}
|
|
280
|
+
function readNullableString(value) {
|
|
281
|
+
if (value === null) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
285
|
+
}
|
|
286
|
+
function readNullableNumber(value) {
|
|
287
|
+
if (value === null) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
291
|
+
}
|
|
292
|
+
function finiteVector(value) {
|
|
293
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
const numbers = [];
|
|
297
|
+
for (const item of value) {
|
|
298
|
+
if (typeof item !== "number" || !Number.isFinite(item)) {
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
numbers.push(item);
|
|
302
|
+
}
|
|
303
|
+
return numbers;
|
|
304
|
+
}
|
|
305
|
+
function stretchCovering(stretches, startMs) {
|
|
306
|
+
for (let index = 0; index < stretches.length; index += 1) {
|
|
307
|
+
const stretch = stretches[index];
|
|
308
|
+
if (stretch !== undefined && startMs >= stretch.startMs && startMs < stretch.endMs) {
|
|
309
|
+
return index;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
function clusterVectors(vectors) {
|
|
315
|
+
if (vectors.length === 0) {
|
|
316
|
+
return [];
|
|
317
|
+
}
|
|
318
|
+
const merges = averageLinkageMerges(vectors);
|
|
319
|
+
const cutoff = highSide(merges);
|
|
320
|
+
const kept = cutoff === undefined ? [] : merges.filter((merge) => merge.cosine >= cutoff);
|
|
321
|
+
return components(vectors.length, kept);
|
|
322
|
+
}
|
|
323
|
+
function averageLinkageMerges(vectors) {
|
|
324
|
+
let clusters = vectors.map((_, index) => ({
|
|
325
|
+
members: [index],
|
|
326
|
+
}));
|
|
327
|
+
const merges = [];
|
|
328
|
+
while (clusters.length > 1) {
|
|
329
|
+
let bestI = 0;
|
|
330
|
+
let bestJ = 1;
|
|
331
|
+
let bestScore = Number.NEGATIVE_INFINITY;
|
|
332
|
+
for (let i = 0; i < clusters.length; i += 1) {
|
|
333
|
+
for (let j = i + 1; j < clusters.length; j += 1) {
|
|
334
|
+
const left = clusters[i];
|
|
335
|
+
const right = clusters[j];
|
|
336
|
+
if (left === undefined || right === undefined) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const score = averageCosine(left.members, right.members, vectors);
|
|
340
|
+
if (score > bestScore) {
|
|
341
|
+
bestScore = score;
|
|
342
|
+
bestI = i;
|
|
343
|
+
bestJ = j;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const left = clusters[bestI];
|
|
348
|
+
const right = clusters[bestJ];
|
|
349
|
+
if (left === undefined || right === undefined) {
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
merges.push({ cosine: bestScore, left: left.members, right: right.members });
|
|
353
|
+
const merged = { members: [...left.members, ...right.members] };
|
|
354
|
+
const next = clusters.filter((_, index) => index !== bestI && index !== bestJ);
|
|
355
|
+
next.push(merged);
|
|
356
|
+
clusters = next;
|
|
357
|
+
}
|
|
358
|
+
return merges;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* The high side is that merge's own cosine. Rounding it to a tenth would drop a
|
|
362
|
+
* merge that still sits above the gap. A lone merge has no low side to refuse.
|
|
363
|
+
* Equal gaps keep the higher cut.
|
|
364
|
+
*/
|
|
365
|
+
function highSide(merges) {
|
|
366
|
+
if (merges.length === 0) {
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
369
|
+
const ordered = [...merges].sort((left, right) => right.cosine - left.cosine);
|
|
370
|
+
const first = ordered[0];
|
|
371
|
+
if (first === undefined) {
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
if (ordered.length === 1) {
|
|
375
|
+
return first.cosine;
|
|
376
|
+
}
|
|
377
|
+
let widest = -1;
|
|
378
|
+
let high = first.cosine;
|
|
379
|
+
for (let index = 0; index < ordered.length - 1; index += 1) {
|
|
380
|
+
const current = ordered[index];
|
|
381
|
+
const next = ordered[index + 1];
|
|
382
|
+
if (current === undefined || next === undefined) {
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const gap = current.cosine - next.cosine;
|
|
386
|
+
if (gap > widest) {
|
|
387
|
+
widest = gap;
|
|
388
|
+
high = current.cosine;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return high;
|
|
392
|
+
}
|
|
393
|
+
function components(count, merges) {
|
|
394
|
+
const parent = Array.from({ length: count }, (_, index) => index);
|
|
395
|
+
const find = (index) => {
|
|
396
|
+
let cursor = index;
|
|
397
|
+
const seen = [];
|
|
398
|
+
for (;;) {
|
|
399
|
+
const next = parent[cursor];
|
|
400
|
+
if (next === undefined || next === cursor) {
|
|
401
|
+
for (const item of seen) {
|
|
402
|
+
parent[item] = cursor;
|
|
403
|
+
}
|
|
404
|
+
return cursor;
|
|
405
|
+
}
|
|
406
|
+
seen.push(cursor);
|
|
407
|
+
cursor = next;
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
for (const merge of merges) {
|
|
411
|
+
const left = merge.left[0];
|
|
412
|
+
const right = merge.right[0];
|
|
413
|
+
if (left === undefined || right === undefined) {
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const leftRoot = find(left);
|
|
417
|
+
const rightRoot = find(right);
|
|
418
|
+
if (leftRoot !== rightRoot) {
|
|
419
|
+
parent[rightRoot] = leftRoot;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const grouped = new Map();
|
|
423
|
+
for (let index = 0; index < count; index += 1) {
|
|
424
|
+
const root = find(index);
|
|
425
|
+
const members = grouped.get(root);
|
|
426
|
+
if (members === undefined) {
|
|
427
|
+
grouped.set(root, [index]);
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
members.push(index);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return [...grouped.values()];
|
|
434
|
+
}
|
|
435
|
+
function averageCosine(left, right, vectors) {
|
|
436
|
+
let total = 0;
|
|
437
|
+
let count = 0;
|
|
438
|
+
for (const leftIndex of left) {
|
|
439
|
+
for (const rightIndex of right) {
|
|
440
|
+
const leftVector = vectors[leftIndex];
|
|
441
|
+
const rightVector = vectors[rightIndex];
|
|
442
|
+
if (leftVector === undefined || rightVector === undefined) {
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
total += cosine(leftVector, rightVector);
|
|
446
|
+
count += 1;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return count === 0 ? 0 : total / count;
|
|
450
|
+
}
|
|
451
|
+
function nearestGroup(vector, groupId, centroids) {
|
|
452
|
+
let bestId;
|
|
453
|
+
let bestScore = 0;
|
|
454
|
+
for (const [otherId, centroid] of centroids) {
|
|
455
|
+
if (otherId === groupId) {
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
const score = cosine(vector, centroid);
|
|
459
|
+
if (bestId === undefined || score > bestScore || (score === bestScore && otherId < bestId)) {
|
|
460
|
+
bestId = otherId;
|
|
461
|
+
bestScore = score;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (bestId === undefined) {
|
|
465
|
+
return { nearestGroupId: null, nearestCosine: null };
|
|
466
|
+
}
|
|
467
|
+
return { nearestGroupId: bestId, nearestCosine: bestScore };
|
|
468
|
+
}
|
|
469
|
+
function meanVector(vectors) {
|
|
470
|
+
const first = vectors[0];
|
|
471
|
+
if (first === undefined || vectors.length === 0) {
|
|
472
|
+
return [];
|
|
473
|
+
}
|
|
474
|
+
const sum = Array.from({ length: first.length }, () => 0);
|
|
475
|
+
for (const vector of vectors) {
|
|
476
|
+
for (let index = 0; index < sum.length; index += 1) {
|
|
477
|
+
const current = sum[index];
|
|
478
|
+
const value = vector[index];
|
|
479
|
+
if (current !== undefined && value !== undefined) {
|
|
480
|
+
sum[index] = current + value;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return sum.map((value) => value / vectors.length);
|
|
485
|
+
}
|
|
486
|
+
function cosine(left, right) {
|
|
487
|
+
let dot = 0;
|
|
488
|
+
let leftNorm = 0;
|
|
489
|
+
let rightNorm = 0;
|
|
490
|
+
const length = Math.min(left.length, right.length);
|
|
491
|
+
for (let index = 0; index < length; index += 1) {
|
|
492
|
+
const a = left[index];
|
|
493
|
+
const b = right[index];
|
|
494
|
+
if (a === undefined || b === undefined) {
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
dot += a * b;
|
|
498
|
+
leftNorm += a * a;
|
|
499
|
+
rightNorm += b * b;
|
|
500
|
+
}
|
|
501
|
+
const denom = Math.sqrt(leftNorm) * Math.sqrt(rightNorm);
|
|
502
|
+
return denom === 0 ? 0 : dot / denom;
|
|
503
|
+
}
|
|
504
|
+
function stampMs(hours, minutes, seconds, millis) {
|
|
505
|
+
return Number(hours) * 3_600_000 + Number(minutes) * 60_000 + Number(seconds) * 1_000 + Number(millis);
|
|
506
|
+
}
|
|
507
|
+
function compareText(left, right) {
|
|
508
|
+
if (left === right) {
|
|
509
|
+
return 0;
|
|
510
|
+
}
|
|
511
|
+
return left < right ? -1 : 1;
|
|
512
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export const IDLE_FLEET_MS = 10 * 60 * 1000;
|
|
2
|
+
export function stepIdle(phase, idle, due) {
|
|
3
|
+
switch (phase) {
|
|
4
|
+
case "busy":
|
|
5
|
+
if (idle) {
|
|
6
|
+
return { phase: "waiting", arm: true, disarm: false, release: false };
|
|
7
|
+
}
|
|
8
|
+
return { phase: "busy", arm: false, disarm: false, release: false };
|
|
9
|
+
case "waiting":
|
|
10
|
+
if (!idle) {
|
|
11
|
+
return { phase: "busy", arm: false, disarm: true, release: false };
|
|
12
|
+
}
|
|
13
|
+
if (due) {
|
|
14
|
+
return { phase: "parked", arm: false, disarm: true, release: true };
|
|
15
|
+
}
|
|
16
|
+
return { phase: "waiting", arm: false, disarm: false, release: false };
|
|
17
|
+
case "parked":
|
|
18
|
+
if (idle) {
|
|
19
|
+
return { phase: "parked", arm: false, disarm: false, release: false };
|
|
20
|
+
}
|
|
21
|
+
return { phase: "busy", arm: false, disarm: false, release: false };
|
|
22
|
+
default: {
|
|
23
|
+
const leftover = phase;
|
|
24
|
+
throw new Error(`unexpected idle phase ${String(leftover)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function watchIdleFleet(input) {
|
|
29
|
+
// Empty at boot stays parked, so a queue that never started a worker does not release later.
|
|
30
|
+
let phase = input.idle() ? "parked" : "busy";
|
|
31
|
+
let armed;
|
|
32
|
+
const clearArmed = () => {
|
|
33
|
+
if (armed === undefined) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
clearTimeout(armed);
|
|
37
|
+
armed = undefined;
|
|
38
|
+
};
|
|
39
|
+
const arm = () => {
|
|
40
|
+
clearArmed();
|
|
41
|
+
armed = setTimeout(() => {
|
|
42
|
+
armed = undefined;
|
|
43
|
+
apply(input.idle(), true);
|
|
44
|
+
}, input.delayMs);
|
|
45
|
+
};
|
|
46
|
+
const apply = (idle, due) => {
|
|
47
|
+
const step = stepIdle(phase, idle, due);
|
|
48
|
+
if (step.disarm) {
|
|
49
|
+
clearArmed();
|
|
50
|
+
}
|
|
51
|
+
if (step.release && !input.release()) {
|
|
52
|
+
// The host still has work, so the wait starts over instead of parking.
|
|
53
|
+
phase = "waiting";
|
|
54
|
+
arm();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
phase = step.phase;
|
|
58
|
+
if (step.arm) {
|
|
59
|
+
arm();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const poll = setInterval(() => {
|
|
63
|
+
apply(input.idle(), false);
|
|
64
|
+
}, input.pollMs);
|
|
65
|
+
return {
|
|
66
|
+
stop() {
|
|
67
|
+
clearInterval(poll);
|
|
68
|
+
clearArmed();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|