tryharness 0.7.22 → 0.7.23

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/lib/speech.mjs ADDED
@@ -0,0 +1,480 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { MODELS, ensureModels, modelById, modelDir, modelFile, modelsPresent } from "./localmodels.mjs";
6
+
7
+ // Talk in the buddy, heard on this computer only. Silero VAD finds where someone spoke, so a held key over a fan, a
8
+ // keyboard or silence writes nothing. On a Mac with macOS 26, Apple's own transcriber writes the words (the buddy's
9
+ // HarnessSpeech helper), steered toward the names the relay knows; elsewhere Parakeet does (its feature extractor,
10
+ // encoder and token-and-duration decode in plain JS on ONNX Runtime), and the names the relay knows (its agents, the
11
+ // person's folders and files) replace what it made of them when they sound alike. Moonshine stays for the dictation
12
+ // eval's comparison. The models are "extended": downloaded the first time the person holds Talk, never at setup.
13
+ // Choices measured in the dictation eval (tests/evals/dictation) on synthetic requests and real people reading.
14
+
15
+ export const RATE = 16_000;
16
+ export const SPEECH_IDS = ["vad", "speech"];
17
+ const FRAME = 512;
18
+ const CONTEXT = 64;
19
+ // Moonshine was trained on clips up to about 30 s, and Parakeet is used as measured: speech is recognised in pieces of
20
+ // at most 25 s, cut between stretches of speech where it can be.
21
+ const MAX_PIECE_SECONDS = 25;
22
+ // The most tokens a second of speech may produce: well above anyone's pace, so a decoder stuck repeating itself stops.
23
+ const TOKENS_PER_SECOND = 8;
24
+ // The most of the relay's names (agents first, then folders and files) Apple's transcriber is steered toward.
25
+ const APPLE_NAMES = 300;
26
+ // The Mac's own word list: on the Apple path, a name of the person's never replaces a word in it.
27
+ let dictionary = null;
28
+ const macWords = () => (dictionary ??= new Set(fs.existsSync("/usr/share/dict/words") ? fs.readFileSync("/usr/share/dict/words", "utf8").split("\n").filter((word) => /^[a-z]{2,}$/.test(word)) : []));
29
+
30
+ /** A PCM or float WAV (any channel count) as mono samples at its own rate. */
31
+ export function decodeWav(bytes) {
32
+ const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
33
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
34
+ const tag = (at) => String.fromCharCode(data[at], data[at + 1], data[at + 2], data[at + 3]);
35
+ if (data.length < 12 || tag(0) !== "RIFF" || tag(8) !== "WAVE") throw new Error("the recording is not a WAV file");
36
+ let format = null, body = null;
37
+ for (let at = 12; at + 8 <= data.length;) {
38
+ const size = view.getUint32(at + 4, true);
39
+ if (tag(at) === "fmt ") {
40
+ const code = view.getUint16(at + 8, true);
41
+ format = { code: code === 0xfffe ? view.getUint16(at + 32, true) : code, channels: view.getUint16(at + 10, true), rate: view.getUint32(at + 12, true), bits: view.getUint16(at + 22, true) };
42
+ } else if (tag(at) === "data") body = { at: at + 8, size: Math.min(size, data.length - at - 8) };
43
+ at += 8 + size + (size & 1);
44
+ }
45
+ if (!format || !body || !format.channels || !format.rate) throw new Error("the WAV file has no audio in it");
46
+ const { code, channels, bits } = format;
47
+ const width = bits / 8;
48
+ const read = code === 3 ? (bits === 64 ? (at) => view.getFloat64(at, true) : (at) => view.getFloat32(at, true))
49
+ : bits === 8 ? (at) => (view.getUint8(at) - 128) / 128
50
+ : bits === 16 ? (at) => view.getInt16(at, true) / 32_768
51
+ : bits === 24 ? (at) => ((view.getUint8(at) | (view.getUint8(at + 1) << 8) | (view.getInt8(at + 2) << 16))) / 8_388_608
52
+ : bits === 32 ? (at) => view.getInt32(at, true) / 2_147_483_648 : null;
53
+ if (!read || (code !== 1 && code !== 3)) throw new Error(`a ${bits}-bit WAV of format ${code} can't be read`);
54
+ const frames = Math.floor(body.size / (width * channels));
55
+ const samples = new Float32Array(frames);
56
+ for (let i = 0; i < frames; i += 1) {
57
+ let sum = 0;
58
+ for (let c = 0; c < channels; c += 1) sum += read(body.at + (i * channels + c) * width);
59
+ samples[i] = sum / channels;
60
+ }
61
+ return { sampleRate: format.rate, samples };
62
+ }
63
+
64
+ /** 16-bit mono WAV bytes for samples at a rate (the buddy's recordings, the eval's clips). */
65
+ export function encodeWav(samples, sampleRate = RATE) {
66
+ const out = Buffer.alloc(44 + samples.length * 2);
67
+ out.write("RIFF", 0); out.writeUInt32LE(36 + samples.length * 2, 4); out.write("WAVE", 8);
68
+ out.write("fmt ", 12); out.writeUInt32LE(16, 16); out.writeUInt16LE(1, 20); out.writeUInt16LE(1, 22);
69
+ out.writeUInt32LE(sampleRate, 24); out.writeUInt32LE(sampleRate * 2, 28); out.writeUInt16LE(2, 32); out.writeUInt16LE(16, 34);
70
+ out.write("data", 36); out.writeUInt32LE(samples.length * 2, 40);
71
+ for (let i = 0; i < samples.length; i += 1) out.writeInt16LE(Math.max(-32_768, Math.min(32_767, Math.round(samples[i] * 32_767))), 44 + i * 2);
72
+ return out;
73
+ }
74
+
75
+ // Windowed-sinc resampling from a table: 8 zero crossings a side, a Blackman window, cut below the lower Nyquist.
76
+ const ZEROS = 8;
77
+ const STEPS = 256;
78
+ const KERNEL = (() => {
79
+ const table = new Float32Array(ZEROS * STEPS + 1);
80
+ for (let i = 0; i <= ZEROS * STEPS; i += 1) {
81
+ const x = i / STEPS;
82
+ const sinc = x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x);
83
+ const w = 0.42 + 0.5 * Math.cos((Math.PI * x) / ZEROS) + 0.08 * Math.cos((2 * Math.PI * x) / ZEROS);
84
+ table[i] = sinc * w;
85
+ }
86
+ return table;
87
+ })();
88
+
89
+ /** Samples at `from` Hz resampled to `to` Hz. */
90
+ export function resample(samples, from, to = RATE) {
91
+ if (from === to) return samples;
92
+ const ratio = to / from;
93
+ const scale = Math.min(1, ratio) * 0.95;
94
+ const reach = ZEROS / scale;
95
+ const out = new Float32Array(Math.floor(samples.length * ratio));
96
+ for (let n = 0; n < out.length; n += 1) {
97
+ const centre = n / ratio;
98
+ let sum = 0;
99
+ for (let k = Math.max(0, Math.ceil(centre - reach)), last = Math.min(samples.length - 1, Math.floor(centre + reach)); k <= last; k += 1) {
100
+ const at = Math.abs(centre - k) * scale * STEPS;
101
+ const i = at | 0;
102
+ if (i < ZEROS * STEPS) sum += samples[k] * (KERNEL[i] + (KERNEL[i + 1] - KERNEL[i]) * (at - i));
103
+ }
104
+ out[n] = sum * scale;
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /**
110
+ * Where speech is, from the VAD's per-frame probabilities: [start, end) in samples. A stretch starts at 0.5 and ends
111
+ * after 500 ms under 0.35 (a pause between words keeps it going); one shorter than 250 ms is a click, not a word.
112
+ */
113
+ export function speechSpans(probs, { total = probs.length * FRAME, threshold = 0.5, minSpeechMs = 250, minSilenceMs = 500, padMs = 250 } = {}) {
114
+ const frameMs = (FRAME / RATE) * 1000;
115
+ const off = threshold - 0.15;
116
+ const found = [];
117
+ let start = -1, quiet = -1;
118
+ const close = (end) => { if ((end - start) * frameMs >= minSpeechMs) found.push([start * FRAME, end * FRAME]); start = -1; quiet = -1; };
119
+ for (let i = 0; i < probs.length; i += 1) {
120
+ if (start < 0) { if (probs[i] >= threshold) start = i; continue; }
121
+ if (probs[i] >= threshold) quiet = -1;
122
+ else if (probs[i] < off) {
123
+ if (quiet < 0) quiet = i;
124
+ if ((i - quiet) * frameMs >= minSilenceMs) close(quiet);
125
+ }
126
+ }
127
+ if (start >= 0) close(quiet >= 0 ? quiet : probs.length);
128
+ const pad = Math.round((padMs / 1000) * RATE);
129
+ const spans = [];
130
+ for (const [s, e] of found) {
131
+ const span = [Math.max(0, s - pad), Math.min(total, e + pad)];
132
+ if (spans.length && span[0] <= spans.at(-1)[1]) spans.at(-1)[1] = span[1];
133
+ else spans.push(span);
134
+ }
135
+ return spans;
136
+ }
137
+
138
+ /** A Llama-style SentencePiece vocabulary as a decoder: ids to text, special tokens skipped, byte fallback kept. */
139
+ function tokenText(tokenizer) {
140
+ const pieces = [];
141
+ for (const [piece, id] of Object.entries(tokenizer.model.vocab)) pieces[id] = piece;
142
+ const special = new Set(tokenizer.added_tokens.filter((token) => token.special).map((token) => token.id));
143
+ const decoder = new TextDecoder();
144
+ const decode = (ids) => {
145
+ const bytes = [];
146
+ for (const id of ids) {
147
+ if (special.has(id) || pieces[id] === undefined) continue;
148
+ const byte = /^<0x([0-9A-F]{2})>$/.exec(pieces[id]);
149
+ if (byte) bytes.push(parseInt(byte[1], 16));
150
+ else bytes.push(...Buffer.from(pieces[id].replaceAll("▁", " "), "utf8"));
151
+ }
152
+ return decoder.decode(Uint8Array.from(bytes)).replace(/^ /, "");
153
+ };
154
+ // Words the recogniser writes as one piece: the common words a name made of them must not be snapped from.
155
+ const common = new Set(pieces.filter((piece) => piece?.startsWith("▁") && /^[a-z]{2,}$/i.test(piece.slice(1))).map((piece) => piece.slice(1).toLowerCase()));
156
+ return { decode, common };
157
+ }
158
+
159
+ /** NeMo's vocabulary file ("piece id" a line) as a decoder, spaced as NeMo's own detokenizer does. */
160
+ function vocabText(text) {
161
+ const pieces = text.split("\n").filter(Boolean).map((line) => line.slice(0, line.lastIndexOf(" ")).replaceAll("▁", " "));
162
+ const decode = (ids) => ids.map((id) => pieces[id]).join("").replace(/^\s|\s\B|(\s)\b/g, (_, space) => (space ? " " : ""));
163
+ const common = new Set(pieces.filter((piece) => /^ [a-z]{2,}$/i.test(piece)).map((piece) => piece.slice(1).toLowerCase()));
164
+ return { pieces, decode, common, blank: pieces.indexOf("<blk>") };
165
+ }
166
+
167
+ /**
168
+ * Apple's on-device transcriber through the buddy's HarnessSpeech helper (packages/desktop/helper, macOS 26): one process
169
+ * kept while Talk is in use, a JSON request a line, closed after five idle minutes. Null where there is no helper.
170
+ */
171
+ export function createAppleSpeech({ helper, locale = Intl.DateTimeFormat().resolvedOptions().locale || "en-US", idleMs = 5 * 60_000, log = () => {} }) {
172
+ if (!helper || !fs.existsSync(helper)) return null;
173
+ let child = null, buffer = "", timer = null, chain = Promise.resolve();
174
+ const waiting = [];
175
+ const start = () => {
176
+ const own = (child = spawn(helper, [], { stdio: ["pipe", "pipe", "ignore"] }));
177
+ own.stdout.setEncoding("utf8");
178
+ own.stdout.on("data", (chunk) => {
179
+ buffer += chunk;
180
+ for (let at; (at = buffer.indexOf("\n")) >= 0; buffer = buffer.slice(at + 1)) waiting.shift()?.(buffer.slice(0, at));
181
+ });
182
+ own.on("error", (error) => log(`speech: Apple's transcriber could not start (${error.message})`));
183
+ own.on("exit", () => { if (child === own) child = null; buffer = ""; for (const answer of waiting.splice(0)) answer(null); });
184
+ };
185
+ /** One request, answered in turn: the helper's answer, or { ok: false } when it stopped. */
186
+ const ask = (request) => (chain = chain.then(() => new Promise((resolve) => {
187
+ clearTimeout(timer);
188
+ if (!child) start();
189
+ waiting.push((line) => { try { resolve(line === null ? { ok: false, error: "Apple's transcriber stopped" } : JSON.parse(line)); } catch { resolve({ ok: false, error: "Apple's transcriber answered something unreadable" }); } });
190
+ child.stdin.write(`${JSON.stringify({ ...request, locale })}\n`);
191
+ })).finally(() => { timer = setTimeout(() => child?.stdin.end(), idleMs); timer.unref?.(); }));
192
+ return { ask, stop: () => { clearTimeout(timer); child?.stdin.end(); } };
193
+ }
194
+
195
+ export function createSpeech({ inference, root, list = MODELS, vocabulary = () => [], apple = null, fetchImpl, log = () => {} }) {
196
+ const vadEntry = modelById("vad", list), speechEntry = modelById("speech", list);
197
+ const fileOf = (entry, pattern) => entry.files.find((file) => pattern.test(file.path))?.path;
198
+ const engine = speechEntry?.engine ?? "moonshine";
199
+ let tokens = null, config = null;
200
+ const load = () => {
201
+ if (tokens) return;
202
+ const dir = modelDir(root, speechEntry);
203
+ if (engine === "parakeet") { tokens = vocabText(fs.readFileSync(path.join(dir, fileOf(speechEntry, /(^|\/)vocab\.txt$/)), "utf8")); return; }
204
+ tokens = tokenText(JSON.parse(fs.readFileSync(path.join(dir, fileOf(speechEntry, /(^|\/)tokenizer\.json$/)), "utf8")));
205
+ config = JSON.parse(fs.readFileSync(path.join(dir, fileOf(speechEntry, /(^|\/)config\.json$/)), "utf8"));
206
+ };
207
+
208
+ // ── Apple's transcriber, where this Mac has it ──────────────────────────────
209
+ // Asked once what it can do; while its model comes down, each status asks again. Without it, Parakeet.
210
+ let macOS = apple ? null : { available: false };
211
+ const probe = () => apple.ask({ op: "status" }).then((answer) => { macOS = answer.ok ? answer : macOS ?? { available: false }; });
212
+ if (apple) void probe();
213
+ const onMac = () => Boolean(macOS?.available);
214
+ const ids = () => (onMac() ? ["vad"] : SPEECH_IDS);
215
+
216
+ // ── Downloading on first use ────────────────────────────────────────────────
217
+ let downloading = null, failure = null;
218
+ const ready = () => modelsPresent(root, ids(), list) && (!onMac() || macOS.installed);
219
+ const files = () => ids().flatMap((id) => { const entry = modelById(id, list); return entry.files.map((file, index) => ({ file: modelFile(root, entry, index), size: file.size })); });
220
+ const status = () => {
221
+ if (apple && macOS === null) return { state: "downloading", percent: 0 };
222
+ if (ready()) return { state: "ready", model: onMac() ? "Apple's transcriber" : speechEntry.name };
223
+ if (onMac() && !macOS.installed) {
224
+ void probe();
225
+ if (macOS.failed || failure) return { state: "failed", error: macOS.failed ?? failure };
226
+ return { state: macOS.downloading || downloading ? "downloading" : "absent", percent: macOS.percent ?? 0 };
227
+ }
228
+ const all = files();
229
+ const total = all.reduce((sum, f) => sum + f.size, 0);
230
+ const have = all.reduce((sum, f) => { for (const candidate of [f.file, `${f.file}.partial`]) { try { return sum + Math.min(f.size, fs.statSync(candidate).size); } catch {} } return sum; }, 0);
231
+ const percent = Math.min(99, Math.floor((have / total) * 100));
232
+ if (downloading) return { state: "downloading", percent, bytes: total };
233
+ return failure ? { state: "failed", error: failure, percent, bytes: total } : { state: "absent", percent, bytes: total };
234
+ };
235
+ /** Starts the download if the models are missing; answers where it stands. */
236
+ const ensure = () => {
237
+ if (!ready() && !downloading && macOS !== null) {
238
+ failure = null;
239
+ log(onMac() ? "speech: getting Apple's transcriber ready" : "speech: downloading the speech models");
240
+ const mac = onMac() && !macOS.installed ? apple.ask({ op: "install" }).then((answer) => { if (!answer.ok) throw new Error(answer.error); return probe(); }) : null;
241
+ downloading = Promise.all([ensureModels({ root, ids: ids(), list, ...(fetchImpl ? { fetchImpl } : {}), log }), mac])
242
+ .then(() => log("speech: ready"), (error) => { failure = error.message; log(`speech: download failed: ${error.message}`); })
243
+ .finally(() => { downloading = null; });
244
+ }
245
+ return status();
246
+ };
247
+
248
+ // ── Hearing ─────────────────────────────────────────────────────────────────
249
+ const vadFile = fileOf(vadEntry, /\.onnx$/);
250
+ /** The VAD's speech probability for each 32 ms frame. */
251
+ const probabilities = (samples, lane) => inference.onnx("vad", async ({ ort, graph }) => {
252
+ const vad = await graph(vadFile);
253
+ const rate = new ort.Tensor("int64", BigInt64Array.from([BigInt(RATE)]), []);
254
+ let state = new ort.Tensor("float32", new Float32Array(2 * 128), [2, 1, 128]);
255
+ const input = new Float32Array(CONTEXT + FRAME);
256
+ const probs = new Float32Array(Math.floor(samples.length / FRAME));
257
+ for (let i = 0; i < probs.length; i += 1) {
258
+ // Each frame carries the last 64 samples of the one before it, as the model was trained.
259
+ input.copyWithin(0, FRAME, FRAME + CONTEXT);
260
+ input.set(samples.subarray(i * FRAME, (i + 1) * FRAME), CONTEXT);
261
+ const out = await vad.run({ input: new ort.Tensor("float32", input.slice(), [1, CONTEXT + FRAME]), state, sr: rate });
262
+ probs[i] = out.output.data[0];
263
+ state = out.stateN;
264
+ }
265
+ return probs;
266
+ }, { lane });
267
+
268
+ /** Where in the samples someone spoke: [start, end) pairs, none for noise or silence. */
269
+ const voiced = async (samples, { lane = "interactive" } = {}) => speechSpans(await probabilities(samples, lane), { total: samples.length });
270
+
271
+ /** Moonshine's words for one piece of speech. */
272
+ const moonshine = (samples, { lane = "interactive" } = {}) => inference.onnx("speech", async ({ ort, graph }) => {
273
+ load();
274
+ const [encoder, decoder] = await Promise.all([graph(fileOf(speechEntry, /encoder[^/]*\.onnx$/)), graph(fileOf(speechEntry, /decoder[^/]*\.onnx$/))]);
275
+ const { last_hidden_state: states } = await encoder.run({ input_values: new ort.Tensor("float32", samples, [1, samples.length]) });
276
+ const layers = config.decoder_num_hidden_layers, heads = config.decoder_num_key_value_heads, width = config.hidden_size / config.decoder_num_attention_heads;
277
+ const empty = new ort.Tensor("float32", new Float32Array(0), [1, heads, 0, width]);
278
+ const past = {};
279
+ for (let i = 0; i < layers; i += 1) for (const part of ["decoder", "encoder"]) for (const kv of ["key", "value"]) past[`past_key_values.${i}.${part}.${kv}`] = empty;
280
+ const limit = Math.ceil((samples.length / RATE) * TOKENS_PER_SECOND) + 4;
281
+ const ids = [];
282
+ let token = config.decoder_start_token_id;
283
+ for (let step = 0; step < limit; step += 1) {
284
+ const out = await decoder.run({ input_ids: new ort.Tensor("int64", BigInt64Array.from([BigInt(token)]), [1, 1]), encoder_hidden_states: states,
285
+ use_cache_branch: new ort.Tensor("bool", Uint8Array.from([step > 0 ? 1 : 0]), [1]), ...past });
286
+ const logits = out.logits.data;
287
+ let best = 0;
288
+ for (let v = 1; v < logits.length; v += 1) if (logits[v] > logits[best]) best = v;
289
+ // The encoder's keys and values come from the first step and stay; the decoder's grow by one each step.
290
+ for (let i = 0; i < layers; i += 1) for (const part of step === 0 ? ["decoder", "encoder"] : ["decoder"]) for (const kv of ["key", "value"]) past[`past_key_values.${i}.${part}.${kv}`] = out[`present.${i}.${part}.${kv}`];
291
+ if (best === config.eos_token_id) break;
292
+ ids.push(best);
293
+ token = best;
294
+ }
295
+ return tokens.decode(ids).trim();
296
+ }, { lane });
297
+
298
+ /**
299
+ * Parakeet's words for one piece of speech: its feature extractor and encoder, then a greedy token-and-duration decode
300
+ * over the encoder's frames (as NeMo and onnx-asr do): each step names a token (or blank) and how many frames to move.
301
+ */
302
+ const parakeet = (samples, { lane = "interactive" } = {}) => inference.onnx("speech", async ({ ort, graph }) => {
303
+ load();
304
+ const [features, encoder, joint] = await Promise.all([/nemo\d+\.onnx$/, /encoder[^/]*\.onnx$/, /decoder_joint[^/]*\.onnx$/].map((pattern) => graph(fileOf(speechEntry, pattern))));
305
+ const count = new ort.Tensor("int64", BigInt64Array.from([BigInt(samples.length)]), [1]);
306
+ const heard = await features.run({ waveforms: new ort.Tensor("float32", samples, [1, samples.length]), waveforms_lens: count });
307
+ const { outputs, encoded_lengths } = await encoder.run({ audio_signal: heard.features, length: heard.features_lens });
308
+ const [, width, frames] = outputs.dims, length = Number(encoded_lengths.data[0]);
309
+ const frame = (t) => { const v = new Float32Array(width); for (let d = 0; d < width; d += 1) v[d] = outputs.data[d * frames + t]; return v; };
310
+ const { pieces, blank } = tokens;
311
+ let states = [1, 2].map(() => new ort.Tensor("float32", new Float32Array(2 * 640), [2, 1, 640]));
312
+ const ids = [];
313
+ for (let t = 0, emitted = 0; t < length;) {
314
+ const out = await joint.run({ encoder_outputs: new ort.Tensor("float32", frame(t), [1, width, 1]), targets: new ort.Tensor("int32", Int32Array.from([ids.at(-1) ?? blank]), [1, 1]),
315
+ target_length: new ort.Tensor("int32", Int32Array.from([1]), [1]), input_states_1: states[0], input_states_2: states[1] });
316
+ const scores = out.outputs.data;
317
+ let token = 0, step = 0;
318
+ for (let v = 1; v < pieces.length; v += 1) if (scores[v] > scores[token]) token = v;
319
+ for (let k = 1; k < scores.length - pieces.length; k += 1) if (scores[pieces.length + k] > scores[pieces.length + step]) step = k;
320
+ if (token !== blank) { states = [out.output_states_1, out.output_states_2]; ids.push(token); emitted += 1; }
321
+ // At most ten tokens on one frame, as the model's decoding allows.
322
+ if (step > 0) { t += step; emitted = 0; } else if (token === blank || emitted === 10) { t += 1; emitted = 0; }
323
+ }
324
+ return tokens.decode(ids).trim();
325
+ }, { lane });
326
+ const recognise = engine === "parakeet" ? parakeet : moonshine;
327
+
328
+ /** Speech stretches joined into pieces Moonshine can take, cut between stretches. */
329
+ const pieces = (samples, spans) => {
330
+ const out = [];
331
+ for (const [start, end] of spans) {
332
+ const last = out.at(-1);
333
+ if (last && end - last[0] <= MAX_PIECE_SECONDS * RATE) last[1] = end;
334
+ else for (let s = start; s < end; s += MAX_PIECE_SECONDS * RATE) out.push([s, Math.min(end, s + MAX_PIECE_SECONDS * RATE)]);
335
+ }
336
+ return out.map(([start, end]) => samples.subarray(start, end));
337
+ };
338
+
339
+ /** Audio (WAV bytes, or { sampleRate, samples }) to text: { text, heard, ms, model, raw }; nothing heard is { text: "", heard: false }. */
340
+ const transcribe = async (input, { lane = "interactive" } = {}) => {
341
+ const started = performance.now();
342
+ if (!ready()) throw new Error("the speech models are not downloaded yet");
343
+ const audio = input instanceof Uint8Array || input instanceof ArrayBuffer ? decodeWav(input) : input;
344
+ const samples = audio.sampleRate === RATE ? Float32Array.from(audio.samples) : resample(audio.samples, audio.sampleRate);
345
+ const spans = await voiced(samples, { lane });
346
+ const entries = vocabularyEntries(await vocabulary());
347
+ if (onMac()) {
348
+ // Apple's model hears the whole recording, steered toward the names the relay knows; then names are put right as
349
+ // for Parakeet, with the Mac's own dictionary as the words a name never replaces.
350
+ if (!spans.length) return { text: "", heard: false, ms: Math.round(performance.now() - started), model: "Apple's transcriber", raw: "" };
351
+ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "harness-talk-")), "talk.wav");
352
+ try {
353
+ fs.writeFileSync(file, encodeWav(samples), { mode: 0o600 });
354
+ const answer = await apple.ask({ op: "transcribe", file, names: entries.map((entry) => entry.display).slice(0, APPLE_NAMES) });
355
+ if (!answer.ok) throw new Error(answer.error);
356
+ const text = answer.text ? snapNames(answer.text, entries, macWords()).text : "";
357
+ return { text, heard: Boolean(text), ms: Math.round(performance.now() - started), model: "Apple's transcriber", raw: answer.text };
358
+ } finally { fs.rmSync(path.dirname(file), { recursive: true, force: true }); }
359
+ }
360
+ const said = [];
361
+ for (const piece of pieces(samples, spans)) { const words = await recognise(piece, { lane }); if (words) said.push(words); }
362
+ const raw = said.join(" ").trim();
363
+ load();
364
+ const text = raw ? snapNames(raw, entries, tokens.common).text : "";
365
+ return { text, heard: Boolean(text), ms: Math.round(performance.now() - started), model: speechEntry.name, raw };
366
+ };
367
+
368
+ return { ready, status, ensure, transcribe, voiced, recognise, common: () => { load(); return tokens.common; } };
369
+ }
370
+
371
+ // ── Names ─────────────────────────────────────────────────────────────────────
372
+ // Moonshine cannot be primed with names, so they are fixed afterwards, only where a name is clearly meant: an agent's
373
+ // name (as written or as people say it), a file name where a dot or "dot" was heard, or one word no dictionary has.
374
+ // Words that are the name's own words in plain English ("pick folder", "payout for") are left as said.
375
+
376
+ const ROSTER = JSON.parse(fs.readFileSync(new URL("./members.json", import.meta.url), "utf8")).members;
377
+ // How people say the names that are not spelled as said.
378
+ const SAID = { bankr: ["banker"], pi: ["pie", "pye"], codex: ["code x", "codecs"], "claude-code": ["cloud code", "clawed code"], "grok-bot": ["grock bot", "grog bot"] };
379
+ const SNAP = 0.8;
380
+
381
+ const squash = (text) => String(text).toLowerCase().replace(/[^a-z0-9]/g, "");
382
+ function distance(a, b) {
383
+ let row = Array.from({ length: b.length + 1 }, (_, j) => j);
384
+ for (let i = 1; i <= a.length; i += 1) {
385
+ const next = [i];
386
+ for (let j = 1; j <= b.length; j += 1) next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
387
+ row = next;
388
+ }
389
+ return row[b.length];
390
+ }
391
+ /** Metaphone, enough of it to hear "cloud" as "claude" and "demon" as "daemon". */
392
+ export function sound(word) {
393
+ let w = squash(word).replace(/[0-9]/g, "").replace(/^(kn|gn|pn|ae|wr)/, (m) => m[1]).replace(/^x/, "s").replace(/^wh/, "w").replace(/mb$/, "m");
394
+ const vowel = (c) => "aeiou".includes(c ?? "");
395
+ let out = "";
396
+ for (let i = 0; i < w.length; i += 1) {
397
+ const c = w[i], next = w[i + 1] ?? "", prev = w[i - 1] ?? "";
398
+ if (c === prev && c !== "c") continue;
399
+ if (vowel(c)) { if (i === 0) out += "A"; continue; }
400
+ if (c === "c") out += next === "h" || (next === "i" && w[i + 2] === "a") ? "X" : "iey".includes(next) ? "S" : "K";
401
+ else if (c === "d") out += next === "g" && "eiy".includes(w[i + 2] ?? "-") ? "J" : "T";
402
+ else if (c === "g") { if (next === "h" && !vowel(w[i + 2])) i += 1; else out += "iey".includes(next) ? "J" : "K"; }
403
+ else if (c === "h") { if (vowel(next) && !"csptg".includes(prev)) out += "H"; }
404
+ else if (c === "k") { if (prev !== "c") out += "K"; }
405
+ else if (c === "p") out += next === "h" ? "F" : "P";
406
+ else if (c === "q") out += "K";
407
+ else if (c === "s") out += next === "h" || (next === "i" && "oa".includes(w[i + 2] ?? "-")) ? "X" : "S";
408
+ else if (c === "t") out += next === "h" ? "0" : next === "i" && "oa".includes(w[i + 2] ?? "-") ? "X" : "T";
409
+ else if (c === "v") out += "F";
410
+ else if (c === "w" || c === "y") { if (vowel(next)) out += c.toUpperCase(); }
411
+ else if (c === "x") out += "KS";
412
+ else if (c === "z") out += "S";
413
+ else out += c.toUpperCase();
414
+ if ((c === "c" || c === "s" || c === "p") && next === "h") i += 1;
415
+ }
416
+ return out;
417
+ }
418
+ const similar = (a, b) => (2 - distance(a, b) / Math.max(a.length, b.length) - distance(sound(a), sound(b)) / Math.max(1, sound(a).length, sound(b).length)) / 2;
419
+
420
+ /** What the names are matched by: the agents' names (as written and said), then the person's words, files and folders. */
421
+ export function vocabularyEntries(names = []) {
422
+ const entries = ROSTER.map((member) => ({ display: member.name, kind: "agent", keys: [...new Set([member.name, ...(SAID[member.id] ?? [])].map(squash))] }));
423
+ const seen = new Set(entries.map((entry) => squash(entry.display)));
424
+ for (const name of names) {
425
+ const display = String(name ?? "").trim();
426
+ const key = squash(display);
427
+ if (key.length < 4 || display.length > 60 || /\s/.test(display) || seen.has(key)) continue;
428
+ seen.add(key);
429
+ entries.push({ display, kind: /\.[a-z][a-z0-9]{0,4}$/i.test(display) ? "file" : "word", keys: [key] });
430
+ }
431
+ return entries;
432
+ }
433
+
434
+ /**
435
+ * The text with names put right, and what changed. An agent's name may span words ("cloud code"); a file name needs
436
+ * the dot that was heard ("store. Mjs", "where dot t s"); any other name replaces one word no dictionary has
437
+ * ("Fattybuthappy"). Close sounds count (4+ letters, at least 0.8 alike by spelling and Metaphone); `common` is the
438
+ * recogniser's own one-piece words, which are never taken for a name of the person's.
439
+ */
440
+ export function snapNames(text, entries, common = new Set()) {
441
+ const tokens = [...String(text).matchAll(/[A-Za-z0-9]+/g)].map((m) => ({ word: m[0], at: m.index, end: m.index + m[0].length }));
442
+ const found = [];
443
+ for (let i = 0; i < tokens.length; i += 1) {
444
+ for (let n = 1; n <= 6 && i + n <= tokens.length; n += 1) {
445
+ const window = tokens.slice(i, i + n);
446
+ const between = String(text).slice(window[0].at, window.at(-1).end);
447
+ // Words run together only across spaces, commas, dots and hyphens.
448
+ if (/[^A-Za-z0-9\s,.\-]/.test(between)) break;
449
+ const dotted = n > 1 && (/\./.test(between) || window.slice(1, -1).some((t) => t.word.toLowerCase() === "dot"));
450
+ const key = squash(window.filter((t, k) => !(k > 0 && t.word.toLowerCase() === "dot")).map((t) => t.word).join(""));
451
+ // A file's name before its dot, as heard: one too short to judge by sound ("a dot mjs") must be exact.
452
+ const stem = dotted ? squash(between.split(/\.|\bdot\b/i)[0]) : key;
453
+ for (const entry of entries) {
454
+ if (entry.kind === "file" ? !dotted : entry.kind === "word" ? n > 1 || common.has(key) : n > 3) continue;
455
+ let score = 0;
456
+ for (const candidate of entry.keys) {
457
+ if (candidate === key) { score = 1; break; }
458
+ if (entry.kind === "file" && (stem.length < 3 || squash(entry.display.replace(/\.[^.]+$/, "")).length < 3)) continue;
459
+ if (candidate.length < 4 || Math.abs(candidate.length - key.length) > candidate.length * 0.4 || sound(candidate)[0] !== sound(key)[0]) continue;
460
+ score = Math.max(score, similar(candidate, key));
461
+ }
462
+ if (score >= SNAP) found.push({ from: window[0].at, to: window.at(-1).end, display: entry.display, score, span: n });
463
+ }
464
+ }
465
+ }
466
+ // The closest names first, longer spans before shorter ones; none overlapping.
467
+ found.sort((a, b) => b.score - a.score || b.span - a.span);
468
+ const taken = [];
469
+ for (const match of found) if (!taken.some((t) => match.from < t.to && t.from < match.to)) taken.push(match);
470
+ taken.sort((a, b) => b.from - a.from);
471
+ let out = String(text);
472
+ const changes = [];
473
+ for (const match of taken) {
474
+ const before = out.slice(match.from, match.to);
475
+ if (before === match.display) continue;
476
+ out = out.slice(0, match.from) + match.display + out.slice(match.to);
477
+ changes.unshift({ from: before, to: match.display });
478
+ }
479
+ return { text: out, changes };
480
+ }
package/lib/store.mjs CHANGED
@@ -523,8 +523,25 @@ export function openStore(file) {
523
523
  return post ? { ...post, mentions } : null;
524
524
  },
525
525
  roomPosts(goalId, since = 0, limit = 200) {
526
- return execute("all", "select id, seq, kind, author, text, mentions, created_at from board_contributions where task_id = ? and seq > ? and kind != 'held' order by seq asc limit ?", goalId, Number(since) || 0, Math.max(1, Math.min(500, Number(limit) || 200)))
527
- .map((row) => ({ ...row, seq: Number(row.seq), mentions: row.mentions ? JSON.parse(row.mentions) : [] }));
526
+ // A file attached in the room names it, so a reader can show the file itself.
527
+ return execute("all", "select id, seq, kind, author, text, mentions, source, created_at from board_contributions where task_id = ? and seq > ? and kind != 'held' order by seq asc limit ?", goalId, Number(since) || 0, Math.max(1, Math.min(500, Number(limit) || 200)))
528
+ .map(({ source, ...row }) => ({ ...row, seq: Number(row.seq), mentions: row.mentions ? JSON.parse(row.mentions) : [], ...(row.kind === "file" && String(source).startsWith("file:") ? { file: String(source).slice(5) } : {}) }));
529
+ },
530
+ // What each agent's finished runs' receipts showed (receipts.mjs, kept as receipt:<task>): { agent: { tested, untested, failing } }.
531
+ receiptCounts() {
532
+ const counts = {};
533
+ for (const row of execute("all", "select t.target_agent as agent, m.value from board_meta m join board_tasks t on t.id = substr(m.key, 9) where m.key like 'receipt:%' and t.target_agent is not null")) {
534
+ let verdict = null;
535
+ try { verdict = JSON.parse(row.value).verdict; } catch {}
536
+ if (!verdict || verdict === "no changes") continue;
537
+ const entry = counts[row.agent] ??= { tested: 0, untested: 0, failing: 0 };
538
+ entry[verdict === "tested" ? "tested" : verdict === "tests failing" ? "failing" : "untested"] += 1;
539
+ }
540
+ return counts;
541
+ },
542
+ // Posts the guard held (firewall.mjs) since a moment: kept apart from the room, counted for the person's digest.
543
+ heldCount(goalId, since = "") {
544
+ return Number(execute("get", "select count(*) as n from board_contributions where task_id = ? and kind = 'held' and created_at > ?", goalId, since).n);
528
545
  },
529
546
  roomCount(goalId) {
530
547
  return Number(execute("get", "select count(*) as n from board_contributions where task_id = ? and kind not in ('held', 'goal')", goalId).n);
@@ -571,10 +588,20 @@ export function openStore(file) {
571
588
  order by coalesce(ended_at, started_at, indexed_at) desc limit ?`, Math.max(1, Math.min(100, Number(limit) || 30)))
572
589
  .map((row) => ({ ...row, message_count: Number(row.message_count) }));
573
590
  },
574
- // Each folder an agent's sessions ran in, with its latest, newest first.
575
- sessionFolders(limit = 300) {
576
- return execute("all", `select agent, cwd, max(coalesce(ended_at, started_at, indexed_at)) as at from sessions where cwd is not null
577
- group by agent, cwd order by at desc limit ?`, Math.max(1, Math.min(1000, Number(limit) || 300)));
591
+ // The relay's own copy of a session (messages with tool calls clipped), for one whose source file is gone.
592
+ sessionTranscript(agent, sessionId) {
593
+ return execute("get", "select transcript from sessions where agent = ? and session_id = ?", agent, sessionId)?.transcript ?? "";
594
+ },
595
+ // Coding sessions with their source files, oldest first, for the context engine's record of what runs needed.
596
+ sessionSources({ since = "", limit = 2_000 } = {}) {
597
+ return execute("all", `select agent, session_id, cwd, started_at, source from sessions
598
+ where agent in ('claude-code', 'codex') and cwd is not null and source is not null and coalesce(started_at, '') >= ?
599
+ order by started_at asc limit ?`, since, Math.max(1, Math.min(10_000, Number(limit) || 2_000)));
600
+ },
601
+ // Every session's agent, folder, title and when it last worked, newest first.
602
+ sessionPlaces() {
603
+ return execute("all", `select agent, cwd, title, coalesce(ended_at, started_at, indexed_at) as at from sessions where cwd is not null
604
+ order by at desc`);
578
605
  },
579
606
  // The thread that already carries a session, if any.
580
607
  threadForSession(agent, sessionId) {
@@ -733,6 +760,9 @@ export function openStore(file) {
733
760
  const row = execute("get", "select * from board_files where id = ?", String(id));
734
761
  return row ? { ...row, bytes: row.bytes == null ? null : Number(row.bytes), cloud: Number(row.cloud) === 1, present: Number(row.present) === 1 } : null;
735
762
  },
763
+ setFileCloud(id, cloud) {
764
+ execute("run", "update board_files set cloud = ? where id = ?", cloud ? 1 : 0, String(id));
765
+ },
736
766
  taskFiles(taskId) {
737
767
  return execute("all", "select id from board_files where task_id = ? order by created_at asc", taskId).map((row) => this.file(row.id));
738
768
  },
package/lib/swarm.mjs CHANGED
@@ -52,7 +52,7 @@ export function agentProcessCwds(exclude = new Set()) {
52
52
  return cwds;
53
53
  }
54
54
 
55
- export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoard, goalCommand, stopThreadRuns, runLog, runningPids, provenanceFor, defaults, coordinator, worktrees, roster, authorizeRun, pickable, listModels, folders, runsIn }) {
55
+ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoard, goalCommand, stopThreadRuns, runLog, runningPids, provenanceFor, defaults, coordinator, worktrees, roster, authorizeRun, pickable, listModels, folders, runsIn, threadFiles = () => ({ files: [], rendering: [] }) }) {
56
56
  const awaitingStart = (run) => run.status === "open" && Boolean(run.run_workspace) && !run.run_authorized_at;
57
57
  const isDir = (folder) => typeof folder === "string" && path.isAbsolute(folder) && fs.statSync(folder, { throwIfNoEntry: false })?.isDirectory();
58
58
  const agentsOf = (thread) => thread.goal.team.filter((agent) => agent !== thread.goal.facilitator);
@@ -121,16 +121,18 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
121
121
  // A thread needs a folder when a coding agent is in it, or when no one is named and the coordinator picks who works.
122
122
  const needsFolder = (team) => !team.length || team.some((name) => PROCESS_NAMES[name]);
123
123
  // Where each coding agent in a thread would run its turn there: in T3 Code, or here, and why not T3 when the person uses it there.
124
- const placeOf = (folder, team, by) => ({ ok: true, folder, name: folder ? path.basename(folder) : null, by,
124
+ // `lastWorked`: where its agent last worked, when Harness picked another folder from the message, for the person to take.
125
+ const placeOf = (folder, team, by, lastWorked = null) => ({ ok: true, folder, name: folder ? path.basename(folder) : null, by,
126
+ lastWorked: lastWorked ? { folder: lastWorked, name: path.basename(lastWorked) } : null,
125
127
  runs: folder ? team.filter((name) => PROCESS_NAMES[name]).map((name) => ({ agent: name, ...runsIn(name, folder) })) : [] });
126
128
 
127
- const read = (thread) => {
129
+ const read = (thread, hostedCaller = false) => {
128
130
  const runs = store.threadRuns(thread.id).map((run) => ({
129
131
  id: run.id, agent: run.claimed_by || run.target_agent, kind: run.kind, status: run.status, awaitingStart: awaitingStart(run), startedAt: run.created_at, endedAt: run.reported_at,
130
132
  sessionId: run.session_id, folder: run.run_workspace, usage: run.usage, model: run.run_model,
131
133
  steps: readRunSteps(runLog(run.id), 300).map((step) => ({ ...step, text: step.text.slice(0, SAY_LIMIT) })),
132
134
  }));
133
- return { ok: true, thread: summary(thread), posts: store.roomPosts(thread.id, 0, 500), runs };
135
+ return { ok: true, thread: summary(thread), posts: store.roomPosts(thread.id, 0, 500), runs, ...threadFiles(thread, hostedCaller) };
134
136
  };
135
137
 
136
138
  // `to`: the one agent the person wrote to (the desktop's agent list), so their words go as written, with no @name.
@@ -223,7 +225,7 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
223
225
  return isDir(folder) ? placeOf(path.resolve(folder), team, "you") : { ok: false, error: `${folder} is not a folder on this computer` };
224
226
  }
225
227
  if (!needsFolder(team)) return placeOf(null, team, null);
226
- return { pending: folders.suggest(text, team).then(({ folder, by }) => placeOf(folder, team, by)) };
228
+ return { pending: folders.suggest(text, team).then(({ folder, by, lastWorked }) => placeOf(folder, team, by, lastWorked)) };
227
229
  }
228
230
  // The person's own note on one agent's card ("use it for reviews"), told to every other agent; no text clears it.
229
231
  if (command === "board.swarm.note") {
@@ -288,7 +290,7 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
288
290
  }
289
291
  const thread = store.getTask(String(request.id || ""));
290
292
  if (!thread || thread.kind !== "goal") return { ok: false, error: "thread not found" };
291
- if (command === "board.swarm.read") return read(thread);
293
+ if (command === "board.swarm.read") return read(thread, request.hostedCaller === true);
292
294
  if (command === "board.swarm.diff") return worktrees.diff(thread.id, request.runId ? String(request.runId) : null);
293
295
  if (command === "board.swarm.merge" || command === "board.swarm.discard") {
294
296
  // Both change the person's repository, so both wait for the person, and never while an agent is changing files.