tryharness 0.7.22 → 0.7.25

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 (64) hide show
  1. package/README.md +48 -8
  2. package/assets/opencode-plugin.js +48 -10
  3. package/assets/pi-extension.ts +106 -26
  4. package/cli.mjs +1281 -289
  5. package/lib/activity.mjs +197 -40
  6. package/lib/adapters.mjs +337 -54
  7. package/lib/agentnode.mjs +175 -35
  8. package/lib/atlas.mjs +604 -0
  9. package/lib/bip39-english.txt +2048 -0
  10. package/lib/cards.mjs +177 -39
  11. package/lib/claudemem.mjs +74 -18
  12. package/lib/commands.mjs +13 -5
  13. package/lib/context.mjs +628 -0
  14. package/lib/control-client.mjs +5 -2
  15. package/lib/coordinator.mjs +281 -56
  16. package/lib/daemon.mjs +5590 -838
  17. package/lib/desktop.mjs +149 -35
  18. package/lib/digest.mjs +81 -0
  19. package/lib/environment.mjs +180 -0
  20. package/lib/files.mjs +246 -13
  21. package/lib/firewall.mjs +288 -0
  22. package/lib/folders.mjs +223 -57
  23. package/lib/frames.mjs +333 -0
  24. package/lib/inference.mjs +663 -0
  25. package/lib/learn.mjs +182 -0
  26. package/lib/live.mjs +286 -61
  27. package/lib/localmodels.mjs +571 -0
  28. package/lib/mcp-stdio.mjs +912 -103
  29. package/lib/media.mjs +228 -0
  30. package/lib/members.json +1 -1
  31. package/lib/members.mjs +35 -9
  32. package/lib/models.mjs +342 -80
  33. package/lib/network.mjs +201 -42
  34. package/lib/notify.mjs +49 -12
  35. package/lib/nudge.mjs +190 -32
  36. package/lib/ocr.mjs +380 -0
  37. package/lib/onboard.mjs +321 -70
  38. package/lib/parsers.mjs +132 -41
  39. package/lib/paths.mjs +33 -10
  40. package/lib/personal.mjs +77 -0
  41. package/lib/phones.mjs +398 -51
  42. package/lib/places.mjs +566 -102
  43. package/lib/provenance.mjs +19 -4
  44. package/lib/receipts.mjs +82 -0
  45. package/lib/redact.mjs +730 -0
  46. package/lib/runner.mjs +347 -55
  47. package/lib/runtime-install.mjs +59 -17
  48. package/lib/screens.mjs +212 -0
  49. package/lib/sealed.mjs +160 -28
  50. package/lib/secrets.mjs +18 -5
  51. package/lib/service.mjs +173 -35
  52. package/lib/snippets.mjs +140 -0
  53. package/lib/speech.mjs +993 -0
  54. package/lib/store.mjs +1678 -301
  55. package/lib/swarm.mjs +598 -117
  56. package/lib/t3.mjs +400 -79
  57. package/lib/trails.mjs +401 -0
  58. package/lib/ui.mjs +95 -23
  59. package/lib/vectors.mjs +5 -2
  60. package/lib/wakes.mjs +284 -68
  61. package/lib/watches.mjs +362 -0
  62. package/lib/worktrees.mjs +186 -38
  63. package/package.json +6 -4
  64. package/lib/embedder.mjs +0 -156
@@ -0,0 +1,663 @@
1
+ import fs from "node:fs";
2
+ import net from "node:net";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawn, spawnSync } from "node:child_process";
6
+ import {
7
+ MODELS,
8
+ modelById,
9
+ modelDir,
10
+ modelFile,
11
+ modelsPresent,
12
+ ensureLlama,
13
+ llamaServer,
14
+ } from "./localmodels.mjs";
15
+ import { ensureDir } from "./files.mjs";
16
+
17
+ // The relay's local inference host. GGUF models (the search model) run in one llama.cpp server in
18
+ // router mode, on the GPU when the machine has one that is faster, the CPU otherwise; the small ONNX classifiers run in
19
+ // native ONNX Runtime inside the daemon, one thread each (measured: about 13x less CPU energy per item than the
20
+ // WebAssembly build). Every request goes through one scheduler with three lanes: interactive (the person is waiting),
21
+ // handoff (a brief or post is in flight) and background (indexing, the nightly learner). Background work runs at the
22
+ // OS's background priority and waits while the machine is on low battery, in Low Power Mode or hot.
23
+
24
+ const LANES = { interactive: 0, handoff: 1, background: 2 };
25
+ // Models a sleeping llama.cpp instance keeps about 100 MB of; idle ONNX sessions are dropped outright.
26
+ const SMALL_MACHINE = os.totalmem() <= 8.5 * 2 ** 30;
27
+ // The smoke local-runtime shortens both to watch models go (HARNESS_ONNX_IDLE_MS, HARNESS_LLAMA_SLEEP_SECONDS).
28
+ const ONNX_IDLE_MS =
29
+ Number(process.env.HARNESS_ONNX_IDLE_MS) ||
30
+ (SMALL_MACHINE ? 60_000 : 5 * 60_000);
31
+ const SLEEP_IDLE_SECONDS =
32
+ Number(process.env.HARNESS_LLAMA_SLEEP_SECONDS) || (SMALL_MACHINE ? 30 : 120);
33
+ const POWER_CHECK_MS = 60_000;
34
+
35
+ const freePort = () =>
36
+ new Promise((resolve, reject) => {
37
+ const server = net.createServer();
38
+ server.unref();
39
+ server.on("error", reject);
40
+ server.listen(0, "127.0.0.1", () => {
41
+ const { port } = server.address();
42
+ server.close(() => resolve(port));
43
+ });
44
+ });
45
+
46
+ /** Battery, Low Power Mode and heat, read from the OS without any permission. paused() is true when background work should wait. */
47
+ export function createPower({
48
+ platform = process.platform,
49
+ run = spawnSync,
50
+ } = {}) {
51
+ let state = {
52
+ onBattery: false,
53
+ percent: null,
54
+ lowPower: false,
55
+ hot: false,
56
+ at: 0,
57
+ };
58
+ const read = () => {
59
+ const next = {
60
+ onBattery: false,
61
+ percent: null,
62
+ lowPower: false,
63
+ hot: false,
64
+ at: Date.now(),
65
+ };
66
+ if (platform === "darwin") {
67
+ const batt =
68
+ run("pmset", ["-g", "batt"], { encoding: "utf8", timeout: 3_000 })
69
+ .stdout || "";
70
+ next.onBattery = /Battery Power/.test(batt);
71
+ next.percent = Number(batt.match(/(\d+)%/)?.[1] ?? NaN);
72
+ next.lowPower = /lowpowermode\s+1/.test(
73
+ run("pmset", ["-g"], { encoding: "utf8", timeout: 3_000 }).stdout || ""
74
+ );
75
+ const therm =
76
+ run("pmset", ["-g", "therm"], { encoding: "utf8", timeout: 3_000 })
77
+ .stdout || "";
78
+ next.hot =
79
+ /CPU_Speed_Limit\s*=\s*([0-9]+)/.test(therm) &&
80
+ Number(therm.match(/CPU_Speed_Limit\s*=\s*([0-9]+)/)[1]) < 80;
81
+ } else if (platform === "linux") {
82
+ try {
83
+ const battery = fs
84
+ .readdirSync("/sys/class/power_supply")
85
+ .find((name) => name.startsWith("BAT"));
86
+ if (battery) {
87
+ next.onBattery =
88
+ fs
89
+ .readFileSync(`/sys/class/power_supply/${battery}/status`, "utf8")
90
+ .trim() === "Discharging";
91
+ next.percent = Number(
92
+ fs.readFileSync(
93
+ `/sys/class/power_supply/${battery}/capacity`,
94
+ "utf8"
95
+ )
96
+ );
97
+ }
98
+ } catch {}
99
+ }
100
+ if (!Number.isFinite(next.percent)) next.percent = null;
101
+ state = next;
102
+ return state;
103
+ };
104
+ return {
105
+ state: () => (Date.now() - state.at > POWER_CHECK_MS ? read() : state),
106
+ paused() {
107
+ const now = this.state();
108
+ return (
109
+ now.lowPower ||
110
+ now.hot ||
111
+ (now.onBattery && now.percent !== null && now.percent < 20)
112
+ );
113
+ },
114
+ /** First index passes wait for the charger as well. */
115
+ onCharger() {
116
+ return !this.state().onBattery;
117
+ },
118
+ };
119
+ }
120
+
121
+ export function createInference({
122
+ root,
123
+ log = () => {},
124
+ power = createPower(),
125
+ list = MODELS,
126
+ }) {
127
+ ensureDir(root);
128
+ const deviceFile = path.join(root, "device.json");
129
+ let server = null;
130
+ let starting = null;
131
+ const queues = { llama: [], onnx: [] };
132
+ const busy = { llama: false, onnx: false };
133
+ const sessions = new Map();
134
+
135
+ const device = () => {
136
+ try {
137
+ return JSON.parse(fs.readFileSync(deviceFile, "utf8"));
138
+ } catch {
139
+ return null;
140
+ }
141
+ };
142
+
143
+ const preset = (gpu) => {
144
+ const lines = [
145
+ "version = 1",
146
+ "[*]",
147
+ `sleep-idle-seconds = ${SLEEP_IDLE_SECONDS}`,
148
+ `n-gpu-layers = ${gpu ? 99 : 0}`,
149
+ ];
150
+ for (const entry of list.filter(
151
+ (model) =>
152
+ model.runtime === "llama" &&
153
+ modelsPresent(
154
+ root,
155
+ [model.id, ...(model.llama?.weights ? [model.llama.weights] : [])],
156
+ list
157
+ )
158
+ )) {
159
+ // A projector runs on another entry's weights (media on search's): its own preset, loaded only for media.
160
+ const weights = entry.llama?.weights
161
+ ? modelById(entry.llama.weights, list)
162
+ : null;
163
+ lines.push(
164
+ `[${entry.id}]`,
165
+ `model = ${modelFile(root, weights ?? entry)}`,
166
+ ...(weights ? [`mmproj = ${modelFile(root, entry)}`] : [])
167
+ );
168
+ for (const [key, value] of Object.entries(entry.llama ?? {}))
169
+ if (key !== "weights") lines.push(`${key} = ${value}`);
170
+ }
171
+ const file = path.join(root, "router.ini");
172
+ fs.writeFileSync(file, lines.join("\n") + "\n");
173
+ return file;
174
+ };
175
+
176
+ const launch = async (gpu) => {
177
+ const binary = llamaServer(root) ?? (await ensureLlama({ root, gpu, log }));
178
+ const port = await freePort();
179
+ const child = spawn(
180
+ binary,
181
+ [
182
+ "--models-preset",
183
+ preset(gpu),
184
+ "--models-max",
185
+ SMALL_MACHINE ? "1" : "2",
186
+ "--host",
187
+ "127.0.0.1",
188
+ "--port",
189
+ String(port),
190
+ "--no-webui",
191
+ ],
192
+ { cwd: path.dirname(binary), stdio: ["ignore", "ignore", "pipe"] }
193
+ );
194
+ let tail = "";
195
+ child.stderr.on("data", (chunk) => {
196
+ tail = (tail + chunk).slice(-4_000);
197
+ });
198
+ const base = `http://127.0.0.1:${port}`;
199
+ for (let i = 0; i < 200; i += 1) {
200
+ if (child.exitCode !== null)
201
+ throw new Error(
202
+ `the local model server stopped: ${tail.trim().split("\n").at(-1) || child.exitCode}`
203
+ );
204
+ try {
205
+ if ((await fetch(`${base}/health`)).ok)
206
+ return {
207
+ child,
208
+ base,
209
+ gpu,
210
+ media: modelsPresent(root, ["search", "media"], list),
211
+ marker: null,
212
+ };
213
+ } catch {}
214
+ await new Promise((resolve) => setTimeout(resolve, 100));
215
+ }
216
+ child.kill();
217
+ throw new Error("the local model server did not start");
218
+ };
219
+
220
+ /** One-time check on this machine: the GPU build is kept unless it fails or is slower than the CPU on the search model. */
221
+ const chooseDevice = async () => {
222
+ const known = device();
223
+ if (known) return known.gpu;
224
+ const timed = async (gpu) => {
225
+ const trial = await launch(gpu);
226
+ try {
227
+ const texts = Array.from(
228
+ { length: 8 },
229
+ (_, i) =>
230
+ `title: none | text: device check ${i} ${"lorem ipsum dolor sit amet ".repeat(40)}`
231
+ );
232
+ await post(trial.base, "/v1/embeddings", {
233
+ model: "search",
234
+ input: texts.slice(0, 1),
235
+ });
236
+ const started = performance.now();
237
+ await post(trial.base, "/v1/embeddings", {
238
+ model: "search",
239
+ input: texts,
240
+ });
241
+ return performance.now() - started;
242
+ } finally {
243
+ trial.child.kill();
244
+ }
245
+ };
246
+ let gpuMs = null;
247
+ try {
248
+ gpuMs = await timed(true);
249
+ } catch (error) {
250
+ log(`local models: GPU check failed (${error.message}); using the CPU`);
251
+ }
252
+ const cpuMs = await timed(false).catch(() => null);
253
+ const gpu = gpuMs !== null && (cpuMs === null || gpuMs <= cpuMs);
254
+ fs.writeFileSync(
255
+ deviceFile,
256
+ JSON.stringify(
257
+ {
258
+ gpu,
259
+ gpuMs,
260
+ cpuMs,
261
+ checkedAt: new Date().toISOString(),
262
+ platform: `${process.platform}-${process.arch}`,
263
+ },
264
+ null,
265
+ 2
266
+ )
267
+ );
268
+ log(
269
+ `local models: running on the ${gpu ? "GPU" : "CPU"} (search check ${gpuMs?.toFixed(0) ?? "failed"} ms GPU, ${cpuMs?.toFixed(0) ?? "failed"} ms CPU)`
270
+ );
271
+ return gpu;
272
+ };
273
+
274
+ const ensureServer = () => {
275
+ if (server && server.child.exitCode === null)
276
+ return Promise.resolve(server);
277
+ starting ||= (async () => {
278
+ if (!modelsPresent(root, ["search"], list))
279
+ throw new Error("the search model is not downloaded yet");
280
+ server = await launch(await chooseDevice());
281
+ server.child.on("exit", () => {
282
+ server = null;
283
+ });
284
+ return server;
285
+ })().finally(() => {
286
+ starting = null;
287
+ });
288
+ return starting;
289
+ };
290
+
291
+ async function post(base, pathname, body) {
292
+ const response = await fetch(`${base}${pathname}`, {
293
+ method: "POST",
294
+ headers: { "content-type": "application/json" },
295
+ body: JSON.stringify(body),
296
+ });
297
+ const text = await response.text();
298
+ if (!response.ok)
299
+ throw new Error(
300
+ `local model server ${pathname}: HTTP ${response.status} ${text.slice(0, 300)}`
301
+ );
302
+ return JSON.parse(text);
303
+ }
304
+
305
+ // macOS lets a process move itself or its children in and out of the background band; the llama.cpp children are
306
+ // moved only while a background job runs so interactive requests are never slowed.
307
+ const background = (on) => {
308
+ if (process.platform !== "darwin" || !server) return;
309
+ const children = (
310
+ spawnSync("pgrep", ["-P", String(server.child.pid)], { encoding: "utf8" })
311
+ .stdout || ""
312
+ )
313
+ .split("\n")
314
+ .filter(Boolean);
315
+ for (const pid of [String(server.child.pid), ...children])
316
+ spawnSync("taskpolicy", [on ? "-b" : "-B", "-p", pid]);
317
+ };
318
+
319
+ const pump = (backend) => {
320
+ if (busy[backend]) return;
321
+ const queue = queues[backend];
322
+ queue.sort((a, b) => a.rank - b.rank || a.seq - b.seq);
323
+ const index = queue.findIndex(
324
+ (job) => job.rank < LANES.background || !power.paused()
325
+ );
326
+ if (index < 0) {
327
+ if (queue.length)
328
+ setTimeout(() => pump(backend), POWER_CHECK_MS).unref?.();
329
+ return;
330
+ }
331
+ const [job] = queue.splice(index, 1);
332
+ busy[backend] = true;
333
+ const bg = job.rank === LANES.background && backend === "llama";
334
+ Promise.resolve()
335
+ .then(() => {
336
+ if (bg) background(true);
337
+ return job.run();
338
+ })
339
+ .then(job.resolve, job.reject)
340
+ .finally(() => {
341
+ if (bg) background(false);
342
+ busy[backend] = false;
343
+ pump(backend);
344
+ });
345
+ };
346
+ let seq = 0;
347
+ const schedule = (backend, lane, run) =>
348
+ new Promise((resolve, reject) => {
349
+ queues[backend].push({
350
+ rank: LANES[lane] ?? LANES.handoff,
351
+ seq: seq++,
352
+ run,
353
+ resolve,
354
+ reject,
355
+ });
356
+ pump(backend);
357
+ });
358
+
359
+ // Models on one vocabulary share its tokenizer while any of them is open.
360
+ const tokenizers = new Map();
361
+ const tokenizerOf = (Tokenizer, source) => {
362
+ const dir = modelDir(root, source);
363
+ let tokenizer = tokenizers.get(dir)?.deref();
364
+ if (!tokenizer) {
365
+ const tokenizerPath = source.files.find((file) =>
366
+ file.path.endsWith("tokenizer.json")
367
+ ).path;
368
+ const configPath = source.files.find((file) =>
369
+ file.path.endsWith("tokenizer_config.json")
370
+ )?.path;
371
+ tokenizer = new Tokenizer(
372
+ JSON.parse(fs.readFileSync(path.join(dir, tokenizerPath), "utf8")),
373
+ configPath
374
+ ? JSON.parse(fs.readFileSync(path.join(dir, configPath), "utf8"))
375
+ : {}
376
+ );
377
+ tokenizers.set(dir, new WeakRef(tokenizer));
378
+ }
379
+ return tokenizer;
380
+ };
381
+
382
+ const session = async (id) => {
383
+ const entry = modelById(id, list);
384
+ if (!entry || entry.runtime !== "onnx")
385
+ throw new Error(`no ONNX model ${id}`);
386
+ let held = sessions.get(id);
387
+ if (!held) {
388
+ held = (async () => {
389
+ const source = entry.tokenizer
390
+ ? modelById(entry.tokenizer.from, list)
391
+ : entry;
392
+ if (!modelsPresent(root, [...new Set([id, source.id])], list))
393
+ throw new Error(`${entry.name} is not downloaded yet`);
394
+ const [{ Tokenizer }, ort] = await Promise.all([
395
+ import("@huggingface/tokenizers"),
396
+ import("onnxruntime-node"),
397
+ ]);
398
+ const onnx = await ort.InferenceSession.create(modelFile(root, entry), {
399
+ executionProviders: ["cpu"],
400
+ intraOpNumThreads: 1,
401
+ interOpNumThreads: 1,
402
+ logSeverityLevel: 3,
403
+ });
404
+ return {
405
+ ort,
406
+ tokenizer: tokenizerOf(Tokenizer, source),
407
+ prepare: entry.tokenizer?.prepare ?? ((text) => text),
408
+ onnx,
409
+ timer: null,
410
+ };
411
+ })();
412
+ sessions.set(id, held);
413
+ held.catch(() => sessions.delete(id));
414
+ }
415
+ const ready = await held;
416
+ clearTimeout(ready.timer);
417
+ ready.timer = setTimeout(() => {
418
+ sessions.delete(id);
419
+ ready.onnx.release?.();
420
+ }, ONNX_IDLE_MS);
421
+ ready.timer.unref?.();
422
+ return ready;
423
+ };
424
+
425
+ // Models made of several ONNX graphs (OCR's detector and recogniser, speech's encoder and decoder) open each graph
426
+ // on first use and drop all of them together once idle.
427
+ const graphs = new Map();
428
+ const graphsOf = async (id) => {
429
+ const entry = modelById(id, list);
430
+ if (!entry || entry.runtime !== "onnx")
431
+ throw new Error(`no ONNX model ${id}`);
432
+ if (!modelsPresent(root, [id], list))
433
+ throw new Error(`${entry.name} is not downloaded yet`);
434
+ let held = graphs.get(id);
435
+ if (!held)
436
+ graphs.set(
437
+ id,
438
+ (held = {
439
+ ort: await import("onnxruntime-node"),
440
+ open: new Map(),
441
+ timer: null,
442
+ threads: entry.threads ?? 1,
443
+ })
444
+ );
445
+ clearTimeout(held.timer);
446
+ held.timer = setTimeout(() => {
447
+ graphs.delete(id);
448
+ for (const graph of held.open.values())
449
+ graph.then((onnx) => onnx.release?.()).catch(() => {});
450
+ }, ONNX_IDLE_MS);
451
+ held.timer.unref?.();
452
+ const graph = (file) => {
453
+ if (!held.open.has(file)) {
454
+ const opened = held.ort.InferenceSession.create(
455
+ path.join(modelDir(root, entry), ...file.split("/")),
456
+ {
457
+ executionProviders: ["cpu"],
458
+ intraOpNumThreads: held.threads,
459
+ interOpNumThreads: 1,
460
+ logSeverityLevel: 3,
461
+ }
462
+ );
463
+ held.open.set(file, opened);
464
+ opened.catch(() => held.open.delete(file));
465
+ }
466
+ return held.open.get(file);
467
+ };
468
+ return { ort: held.ort, graph, dir: modelDir(root, entry) };
469
+ };
470
+
471
+ /** Runs an ONNX model on each encoded input; returns the first output's leading row (its logits). */
472
+ const logits = async (id, pairs, maxLength = 512) => {
473
+ const { ort, tokenizer, prepare, onnx } = await session(id);
474
+ const rows = [];
475
+ for (const [text, pair] of pairs) {
476
+ const encoded = tokenizer.encode(
477
+ prepare(text),
478
+ pair === undefined
479
+ ? { add_special_tokens: true }
480
+ : { text_pair: prepare(pair), add_special_tokens: true }
481
+ );
482
+ const ids = encoded.ids.slice(0, maxLength);
483
+ const feeds = {};
484
+ for (const name of onnx.inputNames) {
485
+ if (name === "input_ids")
486
+ feeds[name] = new ort.Tensor(
487
+ "int64",
488
+ BigInt64Array.from(ids, BigInt),
489
+ [1, ids.length]
490
+ );
491
+ else if (name === "attention_mask")
492
+ feeds[name] = new ort.Tensor(
493
+ "int64",
494
+ new BigInt64Array(ids.length).fill(1n),
495
+ [1, ids.length]
496
+ );
497
+ else if (name === "token_type_ids")
498
+ feeds[name] = new ort.Tensor(
499
+ "int64",
500
+ BigInt64Array.from(
501
+ (encoded.type_ids ?? ids.map(() => 0)).slice(0, ids.length),
502
+ BigInt
503
+ ),
504
+ [1, ids.length]
505
+ );
506
+ }
507
+ const out = await onnx.run(feeds);
508
+ rows.push(Array.from(out[onnx.outputNames[0]].data, Number));
509
+ }
510
+ return rows;
511
+ };
512
+
513
+ return {
514
+ device,
515
+ power,
516
+ /** Embeds texts with the search model. kind: "query" | "code-query" | "document" (title is a path or "none"). */
517
+ embed: (items, { lane = "handoff" } = {}) =>
518
+ schedule("llama", lane, async () => {
519
+ const { base } = await ensureServer();
520
+ const input = items.map((item) =>
521
+ typeof item === "string"
522
+ ? item
523
+ : item.kind === "document"
524
+ ? `title: ${item.title || "none"} | text: ${item.text}`
525
+ : `task: ${item.kind === "code-query" ? "code retrieval" : "search result"} | query: ${item.text}`
526
+ );
527
+ // A text too long for the model's window is retried alone, halved until it fits.
528
+ const one = async (text) => {
529
+ for (
530
+ let cut = text;
531
+ ;
532
+ cut = cut.slice(0, Math.floor(cut.length / 2))
533
+ ) {
534
+ try {
535
+ return (
536
+ await post(base, "/v1/embeddings", {
537
+ model: "search",
538
+ input: [cut],
539
+ })
540
+ ).data[0];
541
+ } catch (error) {
542
+ if (cut.length < 200) throw error;
543
+ }
544
+ }
545
+ };
546
+ const out = [];
547
+ for (let i = 0; i < input.length; i += 16) {
548
+ const slice = input.slice(i, i + 16);
549
+ const data = await post(base, "/v1/embeddings", {
550
+ model: "search",
551
+ input: slice,
552
+ })
553
+ .then((reply) => reply.data.sort((a, b) => a.index - b.index))
554
+ .catch(async () => {
555
+ const rows = [];
556
+ for (const text of slice) rows.push(await one(text));
557
+ return rows;
558
+ });
559
+ for (const row of data) {
560
+ const vector = Float32Array.from(row.embedding);
561
+ let norm = 0;
562
+ for (const value of vector) norm += value * value;
563
+ norm = Math.sqrt(norm) || 1;
564
+ for (let j = 0; j < vector.length; j += 1) vector[j] /= norm;
565
+ out.push(vector);
566
+ }
567
+ }
568
+ return out;
569
+ }),
570
+ /**
571
+ * Embeds recordings and images with the search model's projector, in the same space as typed questions. items:
572
+ * { title, data (a Buffer of WAV, MP3, FLAC, PNG or JPEG) }. A server started before the projector came down is
573
+ * restarted once with it.
574
+ */
575
+ embedMedia: (items, { lane = "background" } = {}) =>
576
+ schedule("llama", lane, async () => {
577
+ let ready = await ensureServer();
578
+ if (!ready.media) {
579
+ ready.child.kill();
580
+ server = null;
581
+ ready = await ensureServer();
582
+ }
583
+ if (!ready.media)
584
+ throw new Error(
585
+ "the image and audio part of the search model is not downloaded yet"
586
+ );
587
+ // The marker that stands for a recording or an image in the text is the server's own, read once it has loaded.
588
+ if (!ready.marker) {
589
+ await post(ready.base, "/v1/embeddings", {
590
+ model: "media",
591
+ input: ["warm up"],
592
+ });
593
+ ready.marker = (
594
+ await (await fetch(`${ready.base}/props?model=media`)).json()
595
+ ).media_marker;
596
+ }
597
+ const out = [];
598
+ for (const item of items) {
599
+ const reply = await post(ready.base, "/v1/embeddings", {
600
+ model: "media",
601
+ input: [
602
+ {
603
+ prompt_string: `title: ${item.title || "none"} | text: ${ready.marker}`,
604
+ multimodal_data: [Buffer.from(item.data).toString("base64")],
605
+ },
606
+ ],
607
+ });
608
+ const vector = Float32Array.from(reply.data[0].embedding);
609
+ let norm = 0;
610
+ for (const value of vector) norm += value * value;
611
+ norm = Math.sqrt(norm) || 1;
612
+ out.push(vector.map((value) => value / norm));
613
+ }
614
+ return out;
615
+ }),
616
+ /** Raw logits rows from an ONNX sequence classifier, for models calibrated on logit margins. */
617
+ logits: (id, texts, { lane = "handoff", maxLength } = {}) =>
618
+ schedule("onnx", lane, () =>
619
+ logits(
620
+ id,
621
+ texts.map((text) => [text]),
622
+ maxLength
623
+ )
624
+ ),
625
+ /** Runs work against a multi-graph ONNX model in the scheduler: run({ ort, graph(file), dir }) opens graphs by file path. */
626
+ onnx: (id, run, { lane = "handoff" } = {}) =>
627
+ schedule("onnx", lane, async () => run(await graphsOf(id))),
628
+ running: () => Boolean(server && server.child.exitCode === null),
629
+ /** What is in memory now: the router's models and their state, and the ONNX models open (for `tryharness local`). */
630
+ loaded: async () => {
631
+ let llama = [];
632
+ if (server && server.child.exitCode === null) {
633
+ try {
634
+ llama = (
635
+ (await (await fetch(`${server.base}/models`)).json()).data ?? []
636
+ ).map((model) => ({
637
+ id: model.id,
638
+ state: model.status?.value ?? null,
639
+ }));
640
+ } catch {}
641
+ }
642
+ return { llama, onnx: [...sessions.keys(), ...graphs.keys()] };
643
+ },
644
+ stop() {
645
+ if (server?.child.exitCode === null) server.child.kill();
646
+ server = null;
647
+ for (const held of sessions.values())
648
+ held
649
+ .then((ready) => {
650
+ clearTimeout(ready.timer);
651
+ ready.onnx.release?.();
652
+ })
653
+ .catch(() => {});
654
+ sessions.clear();
655
+ for (const held of graphs.values()) {
656
+ clearTimeout(held.timer);
657
+ for (const graph of held.open.values())
658
+ graph.then((onnx) => onnx.release?.()).catch(() => {});
659
+ }
660
+ graphs.clear();
661
+ },
662
+ };
663
+ }