tryharness 0.7.18 → 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.
Files changed (48) hide show
  1. package/README.md +57 -14
  2. package/assets/opencode-plugin.js +47 -66
  3. package/assets/pi-extension.ts +91 -90
  4. package/cli.mjs +115 -9
  5. package/lib/adapters.mjs +19 -11
  6. package/lib/agentnode.mjs +154 -0
  7. package/lib/atlas.mjs +304 -0
  8. package/lib/bip39-english.txt +2048 -0
  9. package/lib/cards.mjs +16 -4
  10. package/lib/context.mjs +290 -0
  11. package/lib/coordinator.mjs +27 -9
  12. package/lib/daemon.mjs +790 -93
  13. package/lib/digest.mjs +40 -0
  14. package/lib/environment.mjs +98 -0
  15. package/lib/firewall.mjs +154 -0
  16. package/lib/folders.mjs +161 -0
  17. package/lib/frames.mjs +159 -0
  18. package/lib/inference.mjs +326 -0
  19. package/lib/learn.mjs +120 -0
  20. package/lib/live.mjs +30 -5
  21. package/lib/localmodels.mjs +230 -0
  22. package/lib/mcp-stdio.mjs +107 -7
  23. package/lib/media.mjs +118 -0
  24. package/lib/members.json +1 -1
  25. package/lib/models.mjs +233 -0
  26. package/lib/network.mjs +118 -0
  27. package/lib/nudge.mjs +13 -5
  28. package/lib/ocr.mjs +217 -0
  29. package/lib/personal.mjs +39 -0
  30. package/lib/phones.mjs +31 -2
  31. package/lib/places.mjs +61 -0
  32. package/lib/receipts.mjs +42 -0
  33. package/lib/redact.mjs +334 -0
  34. package/lib/runner.mjs +37 -6
  35. package/lib/runtime-install.mjs +6 -5
  36. package/lib/screens.mjs +96 -0
  37. package/lib/sealed.mjs +3 -0
  38. package/lib/service.mjs +4 -2
  39. package/lib/snippets.mjs +64 -0
  40. package/lib/speech.mjs +480 -0
  41. package/lib/store.mjs +64 -5
  42. package/lib/swarm.mjs +89 -26
  43. package/lib/t3.mjs +7 -4
  44. package/lib/trails.mjs +177 -0
  45. package/lib/watches.mjs +163 -0
  46. package/lib/worktrees.mjs +21 -5
  47. package/package.json +6 -4
  48. package/lib/embedder.mjs +0 -156
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
@@ -78,6 +78,7 @@ export function openStore(file) {
78
78
  trust text not null default 'agent',
79
79
  origin_chain text not null default '[]',
80
80
  usage text,
81
+ run_model text,
81
82
  removed_at text,
82
83
  created_at text not null,
83
84
  updated_at text not null
@@ -223,6 +224,7 @@ export function openStore(file) {
223
224
  }
224
225
  if (!boardColumns.has("kind") && boardColumns.size) db.exec("alter table board_tasks add column kind text not null default 'task'");
225
226
  if (!boardColumns.has("usage") && boardColumns.size) db.exec("alter table board_tasks add column usage text");
227
+ if (!boardColumns.has("run_model") && boardColumns.size) db.exec("alter table board_tasks add column run_model text");
226
228
  if (!boardColumns.has("removed_at") && boardColumns.size) db.exec("alter table board_tasks add column removed_at text");
227
229
  if (!boardColumns.has("trust") && boardColumns.size) db.exec("alter table board_tasks add column trust text not null default 'agent'; alter table board_tasks add column origin_chain text not null default '[]'");
228
230
 
@@ -521,8 +523,25 @@ export function openStore(file) {
521
523
  return post ? { ...post, mentions } : null;
522
524
  },
523
525
  roomPosts(goalId, since = 0, limit = 200) {
524
- 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)))
525
- .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);
526
545
  },
527
546
  roomCount(goalId) {
528
547
  return Number(execute("get", "select count(*) as n from board_contributions where task_id = ? and kind not in ('held', 'goal')", goalId).n);
@@ -559,9 +578,9 @@ export function openStore(file) {
559
578
  },
560
579
  // A thread's runs (wakes and jobs), oldest first, for its transcript.
561
580
  threadRuns(goalId) {
562
- return execute("all", `select id, kind, objective, target_agent, claimed_by, status, holder, result, session_id, run_workspace, run_authorized_at, usage, created_at, updated_at, reported_at
581
+ return execute("all", `select id, kind, objective, target_agent, claimed_by, status, holder, result, session_id, run_workspace, run_authorized_at, usage, run_model, created_at, updated_at, reported_at
563
582
  from board_tasks where parent_id = ? and kind in ('wake', 'task') order by created_at asc`, goalId)
564
- .map((row) => ({ ...row, usage: row.usage ? JSON.parse(row.usage) : null }));
583
+ .map((row) => ({ ...row, usage: row.usage ? JSON.parse(row.usage) : null, run_model: row.run_model ? JSON.parse(row.run_model) : null }));
565
584
  },
566
585
  // The coding sessions on this machine, newest first: the person's own and the ones runs started.
567
586
  recentSessions(limit = 30) {
@@ -569,6 +588,21 @@ export function openStore(file) {
569
588
  order by coalesce(ended_at, started_at, indexed_at) desc limit ?`, Math.max(1, Math.min(100, Number(limit) || 30)))
570
589
  .map((row) => ({ ...row, message_count: Number(row.message_count) }));
571
590
  },
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`);
605
+ },
572
606
  // The thread that already carries a session, if any.
573
607
  threadForSession(agent, sessionId) {
574
608
  const row = execute("all", "select goal_id, sessions from board_goals").find((entry) => JSON.parse(entry.sessions || "{}")[agent] === sessionId);
@@ -605,6 +639,16 @@ export function openStore(file) {
605
639
  setRunUsage(id, usage) {
606
640
  execute("run", "update board_tasks set usage = ? where id = ?", JSON.stringify(usage), id);
607
641
  },
642
+ // The model and effort a run started with and who chose them (models.mjs); null fields: the agent's own settings.
643
+ setRunModel(id, choice) {
644
+ execute("run", "update board_tasks set run_model = ? where id = ?", JSON.stringify(choice), id);
645
+ },
646
+ // An agent's finished runs by the model they ran on, for Auto's record of each model.
647
+ modelWork(agent) {
648
+ return execute("all", `select json_extract(run_model, '$.model') as model, status, coalesce(run_authorized_at, created_at) as started_at, reported_at, usage from board_tasks
649
+ where coalesce(claimed_by, target_agent) = ? and json_extract(run_model, '$.model') is not null and status in ('done', 'failed') order by updated_at desc limit 2000`, agent)
650
+ .map((row) => ({ ...row, usage: row.usage ? JSON.parse(row.usage) : null }));
651
+ },
608
652
  setRunSession(id, sessionId) {
609
653
  execute("run", "update board_tasks set session_id = ?, updated_at = ? where id = ?", sessionId, new Date().toISOString(), id);
610
654
  },
@@ -716,6 +760,9 @@ export function openStore(file) {
716
760
  const row = execute("get", "select * from board_files where id = ?", String(id));
717
761
  return row ? { ...row, bytes: row.bytes == null ? null : Number(row.bytes), cloud: Number(row.cloud) === 1, present: Number(row.present) === 1 } : null;
718
762
  },
763
+ setFileCloud(id, cloud) {
764
+ execute("run", "update board_files set cloud = ? where id = ?", cloud ? 1 : 0, String(id));
765
+ },
719
766
  taskFiles(taskId) {
720
767
  return execute("all", "select id from board_files where task_id = ? order by created_at asc", taskId).map((row) => this.file(row.id));
721
768
  },
@@ -750,6 +797,18 @@ export function openStore(file) {
750
797
  threadMessages(taskId) {
751
798
  return execute("all", "select * from board_messages where task_id = ? order by seq asc", taskId).map((row) => ({ ...row, seq: Number(row.seq), refs: JSON.parse(row.refs || "[]") }));
752
799
  },
800
+ // Every thread with another person's swarm, newest first, with what the Swarms list shows of each (network.mjs).
801
+ relayThreads(limit = 200) {
802
+ return execute("all", `select th.task_id, th.with_agent, th.role, th.auto_send, th.opened_at, th.closed_at, th.outcome, b.objective, b.target_agent, b.status, b.updated_at,
803
+ (select count(*) from board_messages m where m.task_id = th.task_id and m.state in ('held', 'flagged')) as held,
804
+ (select coalesce(max(seq), 0) from board_messages m where m.task_id = th.task_id and m.direction = 'in') as last_in_seq,
805
+ (select count(*) from board_messages m where m.task_id = th.task_id) as messages
806
+ from board_threads th join board_tasks b on b.id = th.task_id order by b.updated_at desc limit ?`, limit)
807
+ .map((row) => {
808
+ const last = execute("get", "select direction, sender, text, state, created_at, seq from board_messages where task_id = ? order by seq desc limit 1", row.task_id) || null;
809
+ return { ...row, auto_send: Number(row.auto_send) === 1, held: Number(row.held), last_in_seq: Number(row.last_in_seq), messages: Number(row.messages), last: last ? { ...last, seq: Number(last.seq) } : null };
810
+ });
811
+ },
753
812
  nextSeq(taskId) {
754
813
  const row = execute("get", "select coalesce(max(seq), 0) as seq from board_messages where task_id = ?", taskId);
755
814
  return Number(row.seq) + 1;
@@ -917,7 +976,7 @@ export function openStore(file) {
917
976
  getTask(id) {
918
977
  const row = execute("get", "select * from board_tasks where id = ?", id);
919
978
  if (!row) return null;
920
- const task = { ...row, version: Number(row.version), context_refs: JSON.parse(row.context_refs || "[]"), origin_chain: JSON.parse(row.origin_chain || "[]"), usage: row.usage ? JSON.parse(row.usage) : null, stale: row.status === "claimed" && Boolean(row.lease_until) && row.lease_until < new Date().toISOString() };
979
+ const task = { ...row, version: Number(row.version), context_refs: JSON.parse(row.context_refs || "[]"), origin_chain: JSON.parse(row.origin_chain || "[]"), usage: row.usage ? JSON.parse(row.usage) : null, run_model: row.run_model ? JSON.parse(row.run_model) : null, stale: row.status === "claimed" && Boolean(row.lease_until) && row.lease_until < new Date().toISOString() };
921
980
  if (row.kind === "goal") return { ...task, goal: this.goal(id) };
922
981
  return row.kind === "group" ? { ...task, children: this.children(id) } : task;
923
982
  },