tryharness 0.7.23 → 0.7.26

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 (61) hide show
  1. package/assets/opencode-plugin.js +48 -11
  2. package/assets/pi-extension.ts +105 -26
  3. package/cli.mjs +1238 -322
  4. package/lib/activity.mjs +197 -40
  5. package/lib/adapters.mjs +337 -54
  6. package/lib/agentnode.mjs +175 -35
  7. package/lib/atlas.mjs +383 -83
  8. package/lib/cards.mjs +174 -40
  9. package/lib/claudemem.mjs +74 -18
  10. package/lib/commands.mjs +13 -5
  11. package/lib/context.mjs +409 -71
  12. package/lib/control-client.mjs +5 -2
  13. package/lib/coordinator.mjs +274 -57
  14. package/lib/daemon.mjs +5268 -1024
  15. package/lib/desktop.mjs +149 -35
  16. package/lib/digest.mjs +54 -13
  17. package/lib/environment.mjs +103 -21
  18. package/lib/files.mjs +246 -13
  19. package/lib/firewall.mjs +169 -35
  20. package/lib/folders.mjs +179 -36
  21. package/lib/frames.mjs +214 -40
  22. package/lib/inference.mjs +463 -126
  23. package/lib/learn.mjs +78 -16
  24. package/lib/live.mjs +286 -61
  25. package/lib/localmodels.mjs +424 -83
  26. package/lib/mcp-stdio.mjs +868 -151
  27. package/lib/media.mjs +138 -28
  28. package/lib/members.json +1 -1
  29. package/lib/members.mjs +35 -9
  30. package/lib/models.mjs +342 -80
  31. package/lib/network.mjs +201 -42
  32. package/lib/notify.mjs +49 -12
  33. package/lib/nudge.mjs +184 -33
  34. package/lib/ocr.mjs +223 -60
  35. package/lib/onboard.mjs +321 -70
  36. package/lib/parsers.mjs +132 -41
  37. package/lib/paths.mjs +33 -10
  38. package/lib/personal.mjs +51 -13
  39. package/lib/phones.mjs +384 -60
  40. package/lib/places.mjs +555 -107
  41. package/lib/provenance.mjs +19 -4
  42. package/lib/receipts.mjs +51 -11
  43. package/lib/redact.mjs +512 -116
  44. package/lib/runner.mjs +345 -55
  45. package/lib/runtime-install.mjs +55 -14
  46. package/lib/screens.mjs +154 -38
  47. package/lib/sealed.mjs +157 -28
  48. package/lib/secrets.mjs +18 -5
  49. package/lib/service.mjs +170 -34
  50. package/lib/snippets.mjs +94 -18
  51. package/lib/speech.mjs +688 -175
  52. package/lib/store.mjs +1654 -307
  53. package/lib/swarm.mjs +596 -117
  54. package/lib/t3.mjs +400 -79
  55. package/lib/trails.mjs +285 -61
  56. package/lib/ui.mjs +95 -23
  57. package/lib/vectors.mjs +5 -2
  58. package/lib/wakes.mjs +284 -68
  59. package/lib/watches.mjs +247 -48
  60. package/lib/worktrees.mjs +186 -38
  61. package/package.json +3 -3
package/lib/inference.mjs CHANGED
@@ -3,7 +3,15 @@ import net from "node:net";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { spawn, spawnSync } from "node:child_process";
6
- import { MODELS, modelById, modelDir, modelFile, modelsPresent, ensureLlama, llamaServer } from "./localmodels.mjs";
6
+ import {
7
+ MODELS,
8
+ modelById,
9
+ modelDir,
10
+ modelFile,
11
+ modelsPresent,
12
+ ensureLlama,
13
+ llamaServer,
14
+ } from "./localmodels.mjs";
7
15
  import { ensureDir } from "./files.mjs";
8
16
 
9
17
  // The relay's local inference host. GGUF models (the search model) run in one llama.cpp server in
@@ -17,35 +25,75 @@ const LANES = { interactive: 0, handoff: 1, background: 2 };
17
25
  // Models a sleeping llama.cpp instance keeps about 100 MB of; idle ONNX sessions are dropped outright.
18
26
  const SMALL_MACHINE = os.totalmem() <= 8.5 * 2 ** 30;
19
27
  // The smoke local-runtime shortens both to watch models go (HARNESS_ONNX_IDLE_MS, HARNESS_LLAMA_SLEEP_SECONDS).
20
- const ONNX_IDLE_MS = Number(process.env.HARNESS_ONNX_IDLE_MS) || (SMALL_MACHINE ? 60_000 : 5 * 60_000);
21
- const SLEEP_IDLE_SECONDS = Number(process.env.HARNESS_LLAMA_SLEEP_SECONDS) || (SMALL_MACHINE ? 30 : 120);
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);
22
33
  const POWER_CHECK_MS = 60_000;
23
34
 
24
- const freePort = () => new Promise((resolve, reject) => {
25
- const server = net.createServer();
26
- server.unref();
27
- server.on("error", reject);
28
- server.listen(0, "127.0.0.1", () => { const { port } = server.address(); server.close(() => resolve(port)); });
29
- });
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
+ });
30
45
 
31
46
  /** Battery, Low Power Mode and heat, read from the OS without any permission. paused() is true when background work should wait. */
32
- export function createPower({ platform = process.platform, run = spawnSync } = {}) {
33
- let state = { onBattery: false, percent: null, lowPower: false, hot: false, at: 0 };
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
+ };
34
58
  const read = () => {
35
- const next = { onBattery: false, percent: null, lowPower: false, hot: false, at: Date.now() };
59
+ const next = {
60
+ onBattery: false,
61
+ percent: null,
62
+ lowPower: false,
63
+ hot: false,
64
+ at: Date.now(),
65
+ };
36
66
  if (platform === "darwin") {
37
- const batt = run("pmset", ["-g", "batt"], { encoding: "utf8", timeout: 3_000 }).stdout || "";
67
+ const batt =
68
+ run("pmset", ["-g", "batt"], { encoding: "utf8", timeout: 3_000 })
69
+ .stdout || "";
38
70
  next.onBattery = /Battery Power/.test(batt);
39
71
  next.percent = Number(batt.match(/(\d+)%/)?.[1] ?? NaN);
40
- next.lowPower = /lowpowermode\s+1/.test(run("pmset", ["-g"], { encoding: "utf8", timeout: 3_000 }).stdout || "");
41
- const therm = run("pmset", ["-g", "therm"], { encoding: "utf8", timeout: 3_000 }).stdout || "";
42
- next.hot = /CPU_Speed_Limit\s*=\s*([0-9]+)/.test(therm) && Number(therm.match(/CPU_Speed_Limit\s*=\s*([0-9]+)/)[1]) < 80;
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;
43
81
  } else if (platform === "linux") {
44
82
  try {
45
- const battery = fs.readdirSync("/sys/class/power_supply").find((name) => name.startsWith("BAT"));
83
+ const battery = fs
84
+ .readdirSync("/sys/class/power_supply")
85
+ .find((name) => name.startsWith("BAT"));
46
86
  if (battery) {
47
- next.onBattery = fs.readFileSync(`/sys/class/power_supply/${battery}/status`, "utf8").trim() === "Discharging";
48
- next.percent = Number(fs.readFileSync(`/sys/class/power_supply/${battery}/capacity`, "utf8"));
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
+ );
49
97
  }
50
98
  } catch {}
51
99
  }
@@ -55,13 +103,27 @@ export function createPower({ platform = process.platform, run = spawnSync } = {
55
103
  };
56
104
  return {
57
105
  state: () => (Date.now() - state.at > POWER_CHECK_MS ? read() : state),
58
- paused() { const now = this.state(); return now.lowPower || now.hot || (now.onBattery && now.percent !== null && now.percent < 20); },
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
+ },
59
114
  /** First index passes wait for the charger as well. */
60
- onCharger() { return !this.state().onBattery; },
115
+ onCharger() {
116
+ return !this.state().onBattery;
117
+ },
61
118
  };
62
119
  }
63
120
 
64
- export function createInference({ root, log = () => {}, power = createPower(), list = MODELS }) {
121
+ export function createInference({
122
+ root,
123
+ log = () => {},
124
+ power = createPower(),
125
+ list = MODELS,
126
+ }) {
65
127
  ensureDir(root);
66
128
  const deviceFile = path.join(root, "device.json");
67
129
  let server = null;
@@ -70,15 +132,41 @@ export function createInference({ root, log = () => {}, power = createPower(), l
70
132
  const busy = { llama: false, onnx: false };
71
133
  const sessions = new Map();
72
134
 
73
- const device = () => { try { return JSON.parse(fs.readFileSync(deviceFile, "utf8")); } catch { return null; } };
135
+ const device = () => {
136
+ try {
137
+ return JSON.parse(fs.readFileSync(deviceFile, "utf8"));
138
+ } catch {
139
+ return null;
140
+ }
141
+ };
74
142
 
75
143
  const preset = (gpu) => {
76
- const lines = ["version = 1", "[*]", `sleep-idle-seconds = ${SLEEP_IDLE_SECONDS}`, `n-gpu-layers = ${gpu ? 99 : 0}`];
77
- for (const entry of list.filter((model) => model.runtime === "llama" && modelsPresent(root, [model.id, ...(model.llama?.weights ? [model.llama.weights] : [])], list))) {
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
+ )) {
78
159
  // A projector runs on another entry's weights (media on search's): its own preset, loaded only for media.
79
- const weights = entry.llama?.weights ? modelById(entry.llama.weights, list) : null;
80
- lines.push(`[${entry.id}]`, `model = ${modelFile(root, weights ?? entry)}`, ...(weights ? [`mmproj = ${modelFile(root, entry)}`] : []));
81
- for (const [key, value] of Object.entries(entry.llama ?? {})) if (key !== "weights") lines.push(`${key} = ${value}`);
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}`);
82
170
  }
83
171
  const file = path.join(root, "router.ini");
84
172
  fs.writeFileSync(file, lines.join("\n") + "\n");
@@ -86,16 +174,43 @@ export function createInference({ root, log = () => {}, power = createPower(), l
86
174
  };
87
175
 
88
176
  const launch = async (gpu) => {
89
- const binary = llamaServer(root) ?? await ensureLlama({ root, gpu, log });
177
+ const binary = llamaServer(root) ?? (await ensureLlama({ root, gpu, log }));
90
178
  const port = await freePort();
91
- const child = spawn(binary, ["--models-preset", preset(gpu), "--models-max", SMALL_MACHINE ? "1" : "2", "--host", "127.0.0.1", "--port", String(port), "--no-webui"],
92
- { cwd: path.dirname(binary), stdio: ["ignore", "ignore", "pipe"] });
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
+ );
93
194
  let tail = "";
94
- child.stderr.on("data", (chunk) => { tail = (tail + chunk).slice(-4_000); });
195
+ child.stderr.on("data", (chunk) => {
196
+ tail = (tail + chunk).slice(-4_000);
197
+ });
95
198
  const base = `http://127.0.0.1:${port}`;
96
199
  for (let i = 0; i < 200; i += 1) {
97
- if (child.exitCode !== null) throw new Error(`the local model server stopped: ${tail.trim().split("\n").at(-1) || child.exitCode}`);
98
- try { if ((await fetch(`${base}/health`)).ok) return { child, base, gpu, media: modelsPresent(root, ["search", "media"], list), marker: null }; } catch {}
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 {}
99
214
  await new Promise((resolve) => setTimeout(resolve, 100));
100
215
  }
101
216
  child.kill();
@@ -109,37 +224,81 @@ export function createInference({ root, log = () => {}, power = createPower(), l
109
224
  const timed = async (gpu) => {
110
225
  const trial = await launch(gpu);
111
226
  try {
112
- const texts = Array.from({ length: 8 }, (_, i) => `title: none | text: device check ${i} ${"lorem ipsum dolor sit amet ".repeat(40)}`);
113
- await post(trial.base, "/v1/embeddings", { model: "search", input: texts.slice(0, 1) });
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
+ });
114
236
  const started = performance.now();
115
- await post(trial.base, "/v1/embeddings", { model: "search", input: texts });
237
+ await post(trial.base, "/v1/embeddings", {
238
+ model: "search",
239
+ input: texts,
240
+ });
116
241
  return performance.now() - started;
117
- } finally { trial.child.kill(); }
242
+ } finally {
243
+ trial.child.kill();
244
+ }
118
245
  };
119
246
  let gpuMs = null;
120
- try { gpuMs = await timed(true); } catch (error) { log(`local models: GPU check failed (${error.message}); using the CPU`); }
247
+ try {
248
+ gpuMs = await timed(true);
249
+ } catch (error) {
250
+ log(`local models: GPU check failed (${error.message}); using the CPU`);
251
+ }
121
252
  const cpuMs = await timed(false).catch(() => null);
122
253
  const gpu = gpuMs !== null && (cpuMs === null || gpuMs <= cpuMs);
123
- fs.writeFileSync(deviceFile, JSON.stringify({ gpu, gpuMs, cpuMs, checkedAt: new Date().toISOString(), platform: `${process.platform}-${process.arch}` }, null, 2));
124
- log(`local models: running on the ${gpu ? "GPU" : "CPU"} (search check ${gpuMs?.toFixed(0) ?? "failed"} ms GPU, ${cpuMs?.toFixed(0) ?? "failed"} ms CPU)`);
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
+ );
125
271
  return gpu;
126
272
  };
127
273
 
128
274
  const ensureServer = () => {
129
- if (server && server.child.exitCode === null) return Promise.resolve(server);
275
+ if (server && server.child.exitCode === null)
276
+ return Promise.resolve(server);
130
277
  starting ||= (async () => {
131
- if (!modelsPresent(root, ["search"], list)) throw new Error("the search model is not downloaded yet");
278
+ if (!modelsPresent(root, ["search"], list))
279
+ throw new Error("the search model is not downloaded yet");
132
280
  server = await launch(await chooseDevice());
133
- server.child.on("exit", () => { server = null; });
281
+ server.child.on("exit", () => {
282
+ server = null;
283
+ });
134
284
  return server;
135
- })().finally(() => { starting = null; });
285
+ })().finally(() => {
286
+ starting = null;
287
+ });
136
288
  return starting;
137
289
  };
138
290
 
139
291
  async function post(base, pathname, body) {
140
- const response = await fetch(`${base}${pathname}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
292
+ const response = await fetch(`${base}${pathname}`, {
293
+ method: "POST",
294
+ headers: { "content-type": "application/json" },
295
+ body: JSON.stringify(body),
296
+ });
141
297
  const text = await response.text();
142
- if (!response.ok) throw new Error(`local model server ${pathname}: HTTP ${response.status} ${text.slice(0, 300)}`);
298
+ if (!response.ok)
299
+ throw new Error(
300
+ `local model server ${pathname}: HTTP ${response.status} ${text.slice(0, 300)}`
301
+ );
143
302
  return JSON.parse(text);
144
303
  }
145
304
 
@@ -147,28 +306,55 @@ export function createInference({ root, log = () => {}, power = createPower(), l
147
306
  // moved only while a background job runs so interactive requests are never slowed.
148
307
  const background = (on) => {
149
308
  if (process.platform !== "darwin" || !server) return;
150
- const children = (spawnSync("pgrep", ["-P", String(server.child.pid)], { encoding: "utf8" }).stdout || "").split("\n").filter(Boolean);
151
- for (const pid of [String(server.child.pid), ...children]) spawnSync("taskpolicy", [on ? "-b" : "-B", "-p", pid]);
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]);
152
317
  };
153
318
 
154
319
  const pump = (backend) => {
155
320
  if (busy[backend]) return;
156
321
  const queue = queues[backend];
157
322
  queue.sort((a, b) => a.rank - b.rank || a.seq - b.seq);
158
- const index = queue.findIndex((job) => job.rank < LANES.background || !power.paused());
159
- if (index < 0) { if (queue.length) setTimeout(() => pump(backend), POWER_CHECK_MS).unref?.(); return; }
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
+ }
160
331
  const [job] = queue.splice(index, 1);
161
332
  busy[backend] = true;
162
333
  const bg = job.rank === LANES.background && backend === "llama";
163
- Promise.resolve().then(() => { if (bg) background(true); return job.run(); })
334
+ Promise.resolve()
335
+ .then(() => {
336
+ if (bg) background(true);
337
+ return job.run();
338
+ })
164
339
  .then(job.resolve, job.reject)
165
- .finally(() => { if (bg) background(false); busy[backend] = false; pump(backend); });
340
+ .finally(() => {
341
+ if (bg) background(false);
342
+ busy[backend] = false;
343
+ pump(backend);
344
+ });
166
345
  };
167
346
  let seq = 0;
168
- const schedule = (backend, lane, run) => new Promise((resolve, reject) => {
169
- queues[backend].push({ rank: LANES[lane] ?? LANES.handoff, seq: seq++, run, resolve, reject });
170
- pump(backend);
171
- });
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
+ });
172
358
 
173
359
  // Models on one vocabulary share its tokenizer while any of them is open.
174
360
  const tokenizers = new Map();
@@ -176,9 +362,18 @@ export function createInference({ root, log = () => {}, power = createPower(), l
176
362
  const dir = modelDir(root, source);
177
363
  let tokenizer = tokenizers.get(dir)?.deref();
178
364
  if (!tokenizer) {
179
- const tokenizerPath = source.files.find((file) => file.path.endsWith("tokenizer.json")).path;
180
- const configPath = source.files.find((file) => file.path.endsWith("tokenizer_config.json"))?.path;
181
- tokenizer = new Tokenizer(JSON.parse(fs.readFileSync(path.join(dir, tokenizerPath), "utf8")), configPath ? JSON.parse(fs.readFileSync(path.join(dir, configPath), "utf8")) : {});
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
+ );
182
377
  tokenizers.set(dir, new WeakRef(tokenizer));
183
378
  }
184
379
  return tokenizer;
@@ -186,22 +381,43 @@ export function createInference({ root, log = () => {}, power = createPower(), l
186
381
 
187
382
  const session = async (id) => {
188
383
  const entry = modelById(id, list);
189
- if (!entry || entry.runtime !== "onnx") throw new Error(`no ONNX model ${id}`);
384
+ if (!entry || entry.runtime !== "onnx")
385
+ throw new Error(`no ONNX model ${id}`);
190
386
  let held = sessions.get(id);
191
387
  if (!held) {
192
388
  held = (async () => {
193
- const source = entry.tokenizer ? modelById(entry.tokenizer.from, list) : entry;
194
- if (!modelsPresent(root, [...new Set([id, source.id])], list)) throw new Error(`${entry.name} is not downloaded yet`);
195
- const [{ Tokenizer }, ort] = await Promise.all([import("@huggingface/tokenizers"), import("onnxruntime-node")]);
196
- const onnx = await ort.InferenceSession.create(modelFile(root, entry), { executionProviders: ["cpu"], intraOpNumThreads: 1, interOpNumThreads: 1, logSeverityLevel: 3 });
197
- return { ort, tokenizer: tokenizerOf(Tokenizer, source), prepare: entry.tokenizer?.prepare ?? ((text) => text), onnx, timer: null };
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
+ };
198
411
  })();
199
412
  sessions.set(id, held);
200
413
  held.catch(() => sessions.delete(id));
201
414
  }
202
415
  const ready = await held;
203
416
  clearTimeout(ready.timer);
204
- ready.timer = setTimeout(() => { sessions.delete(id); ready.onnx.release?.(); }, ONNX_IDLE_MS);
417
+ ready.timer = setTimeout(() => {
418
+ sessions.delete(id);
419
+ ready.onnx.release?.();
420
+ }, ONNX_IDLE_MS);
205
421
  ready.timer.unref?.();
206
422
  return ready;
207
423
  };
@@ -211,16 +427,39 @@ export function createInference({ root, log = () => {}, power = createPower(), l
211
427
  const graphs = new Map();
212
428
  const graphsOf = async (id) => {
213
429
  const entry = modelById(id, list);
214
- if (!entry || entry.runtime !== "onnx") throw new Error(`no ONNX model ${id}`);
215
- if (!modelsPresent(root, [id], list)) throw new Error(`${entry.name} is not downloaded yet`);
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`);
216
434
  let held = graphs.get(id);
217
- if (!held) graphs.set(id, (held = { ort: await import("onnxruntime-node"), open: new Map(), timer: null, threads: entry.threads ?? 1 }));
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
+ );
218
445
  clearTimeout(held.timer);
219
- held.timer = setTimeout(() => { graphs.delete(id); for (const graph of held.open.values()) graph.then((onnx) => onnx.release?.()).catch(() => {}); }, ONNX_IDLE_MS);
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);
220
451
  held.timer.unref?.();
221
452
  const graph = (file) => {
222
453
  if (!held.open.has(file)) {
223
- const opened = held.ort.InferenceSession.create(path.join(modelDir(root, entry), ...file.split("/")), { executionProviders: ["cpu"], intraOpNumThreads: held.threads, interOpNumThreads: 1, logSeverityLevel: 3 });
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
+ );
224
463
  held.open.set(file, opened);
225
464
  opened.catch(() => held.open.delete(file));
226
465
  }
@@ -234,13 +473,36 @@ export function createInference({ root, log = () => {}, power = createPower(), l
234
473
  const { ort, tokenizer, prepare, onnx } = await session(id);
235
474
  const rows = [];
236
475
  for (const [text, pair] of pairs) {
237
- const encoded = tokenizer.encode(prepare(text), pair === undefined ? { add_special_tokens: true } : { text_pair: prepare(pair), add_special_tokens: true });
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
+ );
238
482
  const ids = encoded.ids.slice(0, maxLength);
239
483
  const feeds = {};
240
484
  for (const name of onnx.inputNames) {
241
- if (name === "input_ids") feeds[name] = new ort.Tensor("int64", BigInt64Array.from(ids, BigInt), [1, ids.length]);
242
- else if (name === "attention_mask") feeds[name] = new ort.Tensor("int64", new BigInt64Array(ids.length).fill(1n), [1, ids.length]);
243
- else if (name === "token_type_ids") feeds[name] = new ort.Tensor("int64", BigInt64Array.from((encoded.type_ids ?? ids.map(() => 0)).slice(0, ids.length), BigInt), [1, ids.length]);
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
+ );
244
506
  }
245
507
  const out = await onnx.run(feeds);
246
508
  rows.push(Array.from(out[onnx.outputNames[0]].data, Number));
@@ -252,74 +514,149 @@ export function createInference({ root, log = () => {}, power = createPower(), l
252
514
  device,
253
515
  power,
254
516
  /** Embeds texts with the search model. kind: "query" | "code-query" | "document" (title is a path or "none"). */
255
- embed: (items, { lane = "handoff" } = {}) => schedule("llama", lane, async () => {
256
- const { base } = await ensureServer();
257
- const input = items.map((item) => typeof item === "string" ? item : item.kind === "document" ? `title: ${item.title || "none"} | text: ${item.text}`
258
- : `task: ${item.kind === "code-query" ? "code retrieval" : "search result"} | query: ${item.text}`);
259
- // A text too long for the model's window is retried alone, halved until it fits.
260
- const one = async (text) => {
261
- for (let cut = text; ; cut = cut.slice(0, Math.floor(cut.length / 2))) {
262
- try { return (await post(base, "/v1/embeddings", { model: "search", input: [cut] })).data[0]; } catch (error) { if (cut.length < 200) throw error; }
263
- }
264
- };
265
- const out = [];
266
- for (let i = 0; i < input.length; i += 16) {
267
- const slice = input.slice(i, i + 16);
268
- const data = await post(base, "/v1/embeddings", { model: "search", input: slice }).then((reply) => reply.data.sort((a, b) => a.index - b.index))
269
- .catch(async () => { const rows = []; for (const text of slice) rows.push(await one(text)); return rows; });
270
- for (const row of data) {
271
- const vector = Float32Array.from(row.embedding);
272
- let norm = 0;
273
- for (const value of vector) norm += value * value;
274
- norm = Math.sqrt(norm) || 1;
275
- for (let j = 0; j < vector.length; j += 1) vector[j] /= norm;
276
- out.push(vector);
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
+ }
277
567
  }
278
- }
279
- return out;
280
- }),
568
+ return out;
569
+ }),
281
570
  /**
282
571
  * Embeds recordings and images with the search model's projector, in the same space as typed questions. items:
283
572
  * { title, data (a Buffer of WAV, MP3, FLAC, PNG or JPEG) }. A server started before the projector came down is
284
573
  * restarted once with it.
285
574
  */
286
- embedMedia: (items, { lane = "background" } = {}) => schedule("llama", lane, async () => {
287
- let ready = await ensureServer();
288
- if (!ready.media) { ready.child.kill(); server = null; ready = await ensureServer(); }
289
- if (!ready.media) throw new Error("the image and audio part of the search model is not downloaded yet");
290
- // The marker that stands for a recording or an image in the text is the server's own, read once it has loaded.
291
- if (!ready.marker) {
292
- await post(ready.base, "/v1/embeddings", { model: "media", input: ["warm up"] });
293
- ready.marker = (await (await fetch(`${ready.base}/props?model=media`)).json()).media_marker;
294
- }
295
- const out = [];
296
- for (const item of items) {
297
- const reply = await post(ready.base, "/v1/embeddings", { model: "media", input: [{ prompt_string: `title: ${item.title || "none"} | text: ${ready.marker}`, multimodal_data: [Buffer.from(item.data).toString("base64")] }] });
298
- const vector = Float32Array.from(reply.data[0].embedding);
299
- let norm = 0;
300
- for (const value of vector) norm += value * value;
301
- norm = Math.sqrt(norm) || 1;
302
- out.push(vector.map((value) => value / norm));
303
- }
304
- return out;
305
- }),
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
+ }),
306
616
  /** Raw logits rows from an ONNX sequence classifier, for models calibrated on logit margins. */
307
- logits: (id, texts, { lane = "handoff", maxLength } = {}) => schedule("onnx", lane, () => logits(id, texts.map((text) => [text]), maxLength)),
617
+ logits: (id, texts, { lane = "handoff", maxLength } = {}) =>
618
+ schedule("onnx", lane, () =>
619
+ logits(
620
+ id,
621
+ texts.map((text) => [text]),
622
+ maxLength
623
+ )
624
+ ),
308
625
  /** Runs work against a multi-graph ONNX model in the scheduler: run({ ort, graph(file), dir }) opens graphs by file path. */
309
- onnx: (id, run, { lane = "handoff" } = {}) => schedule("onnx", lane, async () => run(await graphsOf(id))),
626
+ onnx: (id, run, { lane = "handoff" } = {}) =>
627
+ schedule("onnx", lane, async () => run(await graphsOf(id))),
310
628
  running: () => Boolean(server && server.child.exitCode === null),
311
629
  /** What is in memory now: the router's models and their state, and the ONNX models open (for `tryharness local`). */
312
630
  loaded: async () => {
313
631
  let llama = [];
314
- if (server && server.child.exitCode === null) { try { llama = ((await (await fetch(`${server.base}/models`)).json()).data ?? []).map((model) => ({ id: model.id, state: model.status?.value ?? null })); } catch {} }
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
+ }
315
642
  return { llama, onnx: [...sessions.keys(), ...graphs.keys()] };
316
643
  },
317
644
  stop() {
318
645
  if (server?.child.exitCode === null) server.child.kill();
319
646
  server = null;
320
- for (const held of sessions.values()) held.then((ready) => { clearTimeout(ready.timer); ready.onnx.release?.(); }).catch(() => {});
647
+ for (const held of sessions.values())
648
+ held
649
+ .then((ready) => {
650
+ clearTimeout(ready.timer);
651
+ ready.onnx.release?.();
652
+ })
653
+ .catch(() => {});
321
654
  sessions.clear();
322
- for (const held of graphs.values()) { clearTimeout(held.timer); for (const graph of held.open.values()) graph.then((onnx) => onnx.release?.()).catch(() => {}); }
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
+ }
323
660
  graphs.clear();
324
661
  },
325
662
  };