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.
- package/assets/opencode-plugin.js +48 -11
- package/assets/pi-extension.ts +105 -26
- package/cli.mjs +1238 -322
- package/lib/activity.mjs +197 -40
- package/lib/adapters.mjs +337 -54
- package/lib/agentnode.mjs +175 -35
- package/lib/atlas.mjs +383 -83
- package/lib/cards.mjs +174 -40
- package/lib/claudemem.mjs +74 -18
- package/lib/commands.mjs +13 -5
- package/lib/context.mjs +409 -71
- package/lib/control-client.mjs +5 -2
- package/lib/coordinator.mjs +274 -57
- package/lib/daemon.mjs +5268 -1024
- package/lib/desktop.mjs +149 -35
- package/lib/digest.mjs +54 -13
- package/lib/environment.mjs +103 -21
- package/lib/files.mjs +246 -13
- package/lib/firewall.mjs +169 -35
- package/lib/folders.mjs +179 -36
- package/lib/frames.mjs +214 -40
- package/lib/inference.mjs +463 -126
- package/lib/learn.mjs +78 -16
- package/lib/live.mjs +286 -61
- package/lib/localmodels.mjs +424 -83
- package/lib/mcp-stdio.mjs +868 -151
- package/lib/media.mjs +138 -28
- package/lib/members.json +1 -1
- package/lib/members.mjs +35 -9
- package/lib/models.mjs +342 -80
- package/lib/network.mjs +201 -42
- package/lib/notify.mjs +49 -12
- package/lib/nudge.mjs +184 -33
- package/lib/ocr.mjs +223 -60
- package/lib/onboard.mjs +321 -70
- package/lib/parsers.mjs +132 -41
- package/lib/paths.mjs +33 -10
- package/lib/personal.mjs +51 -13
- package/lib/phones.mjs +384 -60
- package/lib/places.mjs +555 -107
- package/lib/provenance.mjs +19 -4
- package/lib/receipts.mjs +51 -11
- package/lib/redact.mjs +512 -116
- package/lib/runner.mjs +345 -55
- package/lib/runtime-install.mjs +55 -14
- package/lib/screens.mjs +154 -38
- package/lib/sealed.mjs +157 -28
- package/lib/secrets.mjs +18 -5
- package/lib/service.mjs +170 -34
- package/lib/snippets.mjs +94 -18
- package/lib/speech.mjs +688 -175
- package/lib/store.mjs +1654 -307
- package/lib/swarm.mjs +596 -117
- package/lib/t3.mjs +400 -79
- package/lib/trails.mjs +285 -61
- package/lib/ui.mjs +95 -23
- package/lib/vectors.mjs +5 -2
- package/lib/wakes.mjs +284 -68
- package/lib/watches.mjs +247 -48
- package/lib/worktrees.mjs +186 -38
- 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 {
|
|
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 =
|
|
21
|
-
|
|
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 = () =>
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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({
|
|
33
|
-
|
|
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 = {
|
|
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 =
|
|
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(
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
83
|
+
const battery = fs
|
|
84
|
+
.readdirSync("/sys/class/power_supply")
|
|
85
|
+
.find((name) => name.startsWith("BAT"));
|
|
46
86
|
if (battery) {
|
|
47
|
-
next.onBattery =
|
|
48
|
-
|
|
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() {
|
|
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() {
|
|
115
|
+
onCharger() {
|
|
116
|
+
return !this.state().onBattery;
|
|
117
|
+
},
|
|
61
118
|
};
|
|
62
119
|
}
|
|
63
120
|
|
|
64
|
-
export function createInference({
|
|
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 = () => {
|
|
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 = [
|
|
77
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
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(
|
|
92
|
-
|
|
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) => {
|
|
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)
|
|
98
|
-
|
|
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(
|
|
113
|
-
|
|
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", {
|
|
237
|
+
await post(trial.base, "/v1/embeddings", {
|
|
238
|
+
model: "search",
|
|
239
|
+
input: texts,
|
|
240
|
+
});
|
|
116
241
|
return performance.now() - started;
|
|
117
|
-
} finally {
|
|
242
|
+
} finally {
|
|
243
|
+
trial.child.kill();
|
|
244
|
+
}
|
|
118
245
|
};
|
|
119
246
|
let gpuMs = null;
|
|
120
|
-
try {
|
|
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(
|
|
124
|
-
|
|
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)
|
|
275
|
+
if (server && server.child.exitCode === null)
|
|
276
|
+
return Promise.resolve(server);
|
|
130
277
|
starting ||= (async () => {
|
|
131
|
-
if (!modelsPresent(root, ["search"], list))
|
|
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", () => {
|
|
281
|
+
server.child.on("exit", () => {
|
|
282
|
+
server = null;
|
|
283
|
+
});
|
|
134
284
|
return server;
|
|
135
|
-
})().finally(() => {
|
|
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}`, {
|
|
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)
|
|
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 = (
|
|
151
|
-
|
|
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(
|
|
159
|
-
|
|
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()
|
|
334
|
+
Promise.resolve()
|
|
335
|
+
.then(() => {
|
|
336
|
+
if (bg) background(true);
|
|
337
|
+
return job.run();
|
|
338
|
+
})
|
|
164
339
|
.then(job.resolve, job.reject)
|
|
165
|
-
.finally(() => {
|
|
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) =>
|
|
169
|
-
|
|
170
|
-
|
|
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) =>
|
|
180
|
-
|
|
181
|
-
|
|
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")
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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(() => {
|
|
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")
|
|
215
|
-
|
|
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)
|
|
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(() => {
|
|
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(
|
|
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(
|
|
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")
|
|
242
|
-
|
|
243
|
-
|
|
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" } = {}) =>
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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" } = {}) =>
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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 } = {}) =>
|
|
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" } = {}) =>
|
|
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) {
|
|
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())
|
|
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()) {
|
|
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
|
};
|