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.
- package/README.md +48 -8
- package/assets/opencode-plugin.js +48 -10
- package/assets/pi-extension.ts +106 -26
- package/cli.mjs +1281 -289
- package/lib/activity.mjs +197 -40
- package/lib/adapters.mjs +337 -54
- package/lib/agentnode.mjs +175 -35
- package/lib/atlas.mjs +604 -0
- package/lib/bip39-english.txt +2048 -0
- package/lib/cards.mjs +177 -39
- package/lib/claudemem.mjs +74 -18
- package/lib/commands.mjs +13 -5
- package/lib/context.mjs +628 -0
- package/lib/control-client.mjs +5 -2
- package/lib/coordinator.mjs +281 -56
- package/lib/daemon.mjs +5590 -838
- package/lib/desktop.mjs +149 -35
- package/lib/digest.mjs +81 -0
- package/lib/environment.mjs +180 -0
- package/lib/files.mjs +246 -13
- package/lib/firewall.mjs +288 -0
- package/lib/folders.mjs +223 -57
- package/lib/frames.mjs +333 -0
- package/lib/inference.mjs +663 -0
- package/lib/learn.mjs +182 -0
- package/lib/live.mjs +286 -61
- package/lib/localmodels.mjs +571 -0
- package/lib/mcp-stdio.mjs +912 -103
- package/lib/media.mjs +228 -0
- 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 +190 -32
- package/lib/ocr.mjs +380 -0
- package/lib/onboard.mjs +321 -70
- package/lib/parsers.mjs +132 -41
- package/lib/paths.mjs +33 -10
- package/lib/personal.mjs +77 -0
- package/lib/phones.mjs +398 -51
- package/lib/places.mjs +566 -102
- package/lib/provenance.mjs +19 -4
- package/lib/receipts.mjs +82 -0
- package/lib/redact.mjs +730 -0
- package/lib/runner.mjs +347 -55
- package/lib/runtime-install.mjs +59 -17
- package/lib/screens.mjs +212 -0
- package/lib/sealed.mjs +160 -28
- package/lib/secrets.mjs +18 -5
- package/lib/service.mjs +173 -35
- package/lib/snippets.mjs +140 -0
- package/lib/speech.mjs +993 -0
- package/lib/store.mjs +1678 -301
- package/lib/swarm.mjs +598 -117
- package/lib/t3.mjs +400 -79
- package/lib/trails.mjs +401 -0
- package/lib/ui.mjs +95 -23
- package/lib/vectors.mjs +5 -2
- package/lib/wakes.mjs +284 -68
- package/lib/watches.mjs +362 -0
- package/lib/worktrees.mjs +186 -38
- package/package.json +6 -4
- package/lib/embedder.mjs +0 -156
package/lib/worktrees.mjs
CHANGED
|
@@ -6,12 +6,25 @@ import { spawnSync } from "node:child_process";
|
|
|
6
6
|
// at once without touching each other's files or the person's checkout. Each agent turn is committed on the branch,
|
|
7
7
|
// which gives every turn its diff; merge and discard are the person's, never the swarm's.
|
|
8
8
|
|
|
9
|
-
const IDENTITY = [
|
|
9
|
+
const IDENTITY = [
|
|
10
|
+
"-c",
|
|
11
|
+
"user.name=Harness",
|
|
12
|
+
"-c",
|
|
13
|
+
"user.email=harness@localhost",
|
|
14
|
+
];
|
|
10
15
|
const PATCH_LIMIT = 200_000;
|
|
11
16
|
|
|
12
17
|
function git(cwd, args) {
|
|
13
|
-
const result = spawnSync("git", [...IDENTITY, ...args], {
|
|
14
|
-
|
|
18
|
+
const result = spawnSync("git", [...IDENTITY, ...args], {
|
|
19
|
+
cwd,
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
ok: result.status === 0,
|
|
25
|
+
out: (result.stdout || "").trim(),
|
|
26
|
+
err: (result.stderr || "").trim(),
|
|
27
|
+
};
|
|
15
28
|
}
|
|
16
29
|
|
|
17
30
|
/** The repository a folder is in, and the folder's place inside it; null when it is not in one. */
|
|
@@ -21,7 +34,10 @@ export function repositoryOf(folder) {
|
|
|
21
34
|
if (!top.ok || !top.out) return null;
|
|
22
35
|
const head = git(folder, ["rev-parse", "--verify", "HEAD"]);
|
|
23
36
|
if (!head.ok) return null;
|
|
24
|
-
return {
|
|
37
|
+
return {
|
|
38
|
+
root: fs.realpathSync(top.out),
|
|
39
|
+
inside: path.relative(fs.realpathSync(top.out), fs.realpathSync(folder)),
|
|
40
|
+
};
|
|
25
41
|
}
|
|
26
42
|
|
|
27
43
|
/**
|
|
@@ -31,20 +47,38 @@ export function repositoryOf(folder) {
|
|
|
31
47
|
*/
|
|
32
48
|
export function mainCheckout(folder) {
|
|
33
49
|
if (!folder || !fs.existsSync(folder)) return folder;
|
|
34
|
-
const found = git(folder, [
|
|
50
|
+
const found = git(folder, [
|
|
51
|
+
"rev-parse",
|
|
52
|
+
"--path-format=absolute",
|
|
53
|
+
"--show-toplevel",
|
|
54
|
+
"--git-common-dir",
|
|
55
|
+
]);
|
|
35
56
|
const [top, common] = found.ok ? found.out.split("\n") : [];
|
|
36
57
|
if (!top || !common || path.basename(common) !== ".git") return folder;
|
|
37
58
|
const main = path.dirname(common);
|
|
38
|
-
if (!fs.existsSync(main) || fs.realpathSync(main) === fs.realpathSync(top))
|
|
39
|
-
|
|
59
|
+
if (!fs.existsSync(main) || fs.realpathSync(main) === fs.realpathSync(top))
|
|
60
|
+
return folder;
|
|
61
|
+
return path.join(
|
|
62
|
+
main,
|
|
63
|
+
path.relative(fs.realpathSync(top), fs.realpathSync(folder))
|
|
64
|
+
);
|
|
40
65
|
}
|
|
41
66
|
|
|
42
|
-
const slug = (text) =>
|
|
67
|
+
const slug = (text) =>
|
|
68
|
+
String(text)
|
|
69
|
+
.toLowerCase()
|
|
70
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
71
|
+
.replace(/^-|-$/g, "")
|
|
72
|
+
.slice(0, 32) || "thread";
|
|
43
73
|
|
|
44
74
|
export function createWorktrees({ store, root }) {
|
|
45
75
|
const key = (threadId) => `worktree:${threadId}`;
|
|
46
|
-
const get = (threadId) => {
|
|
47
|
-
|
|
76
|
+
const get = (threadId) => {
|
|
77
|
+
const raw = store.meta(key(threadId));
|
|
78
|
+
return raw ? JSON.parse(raw) : null;
|
|
79
|
+
};
|
|
80
|
+
const save = (threadId, value) =>
|
|
81
|
+
store.meta(key(threadId), value ? JSON.stringify(value) : "null");
|
|
48
82
|
|
|
49
83
|
/** Where a thread's coding runs work: its worktree when the folder is in a repository, else the folder itself. */
|
|
50
84
|
const folderFor = (thread) => {
|
|
@@ -52,27 +86,54 @@ export function createWorktrees({ store, root }) {
|
|
|
52
86
|
if (!folder) return null;
|
|
53
87
|
const current = get(thread.id);
|
|
54
88
|
if (current?.state === "in_place") return folder;
|
|
55
|
-
if (current?.state === "t3")
|
|
56
|
-
|
|
89
|
+
if (current?.state === "t3")
|
|
90
|
+
return current.path && fs.existsSync(current.path)
|
|
91
|
+
? current.path
|
|
92
|
+
: folder;
|
|
93
|
+
if (current && current.state === "open" && fs.existsSync(current.path))
|
|
94
|
+
return path.join(current.path, current.inside);
|
|
57
95
|
const repo = repositoryOf(folder);
|
|
58
96
|
if (!repo) return folder;
|
|
59
97
|
const where = path.join(root, thread.id);
|
|
60
98
|
const branch = `harness/${slug(thread.objective)}-${thread.id.slice(0, 8)}`;
|
|
61
99
|
const base = git(repo.root, ["rev-parse", "HEAD"]).out;
|
|
62
100
|
fs.mkdirSync(root, { recursive: true });
|
|
63
|
-
const added = git(repo.root, [
|
|
64
|
-
|
|
65
|
-
|
|
101
|
+
const added = git(repo.root, [
|
|
102
|
+
"worktree",
|
|
103
|
+
"add",
|
|
104
|
+
"-b",
|
|
105
|
+
branch,
|
|
106
|
+
where,
|
|
107
|
+
base,
|
|
108
|
+
]);
|
|
109
|
+
if (!added.ok)
|
|
110
|
+
throw new Error(
|
|
111
|
+
`could not make the thread's copy of ${repo.root}: ${added.err}`
|
|
112
|
+
);
|
|
113
|
+
save(thread.id, {
|
|
114
|
+
state: "open",
|
|
115
|
+
repo: repo.root,
|
|
116
|
+
inside: repo.inside,
|
|
117
|
+
path: where,
|
|
118
|
+
branch,
|
|
119
|
+
base,
|
|
120
|
+
});
|
|
66
121
|
return path.join(where, repo.inside);
|
|
67
122
|
};
|
|
68
123
|
|
|
69
124
|
/** Commits what a turn changed on the thread's branch; the commit is that turn's diff. Null when nothing changed. */
|
|
70
125
|
const commitTurn = (threadId, runId, message) => {
|
|
71
126
|
const tree = get(threadId);
|
|
72
|
-
if (!tree || tree.state !== "open" || !fs.existsSync(tree.path))
|
|
127
|
+
if (!tree || tree.state !== "open" || !fs.existsSync(tree.path))
|
|
128
|
+
return null;
|
|
73
129
|
git(tree.path, ["add", "-A"]);
|
|
74
130
|
if (git(tree.path, ["diff", "--cached", "--quiet"]).ok) return null;
|
|
75
|
-
const committed = git(tree.path, [
|
|
131
|
+
const committed = git(tree.path, [
|
|
132
|
+
"commit",
|
|
133
|
+
"-q",
|
|
134
|
+
"-m",
|
|
135
|
+
String(message).slice(0, 200),
|
|
136
|
+
]);
|
|
76
137
|
if (!committed.ok) return null;
|
|
77
138
|
const sha = git(tree.path, ["rev-parse", "HEAD"]).out;
|
|
78
139
|
store.meta(`turn:${runId}`, sha);
|
|
@@ -81,30 +142,53 @@ export function createWorktrees({ store, root }) {
|
|
|
81
142
|
|
|
82
143
|
const stat = (cwd, range) => {
|
|
83
144
|
const out = git(cwd, ["diff", "--numstat", range]).out;
|
|
84
|
-
const files = out
|
|
145
|
+
const files = out
|
|
146
|
+
? out.split("\n").map((line) => {
|
|
147
|
+
const [added, removed, file] = line.split("\t");
|
|
148
|
+
return {
|
|
149
|
+
file,
|
|
150
|
+
added: Number(added) || 0,
|
|
151
|
+
removed: Number(removed) || 0,
|
|
152
|
+
};
|
|
153
|
+
})
|
|
154
|
+
: [];
|
|
85
155
|
return files;
|
|
86
156
|
};
|
|
87
157
|
|
|
88
158
|
/** What a thread changed: everything since it began, or one turn's commit. */
|
|
89
159
|
const diff = (threadId, runId = null) => {
|
|
90
160
|
const tree = get(threadId);
|
|
91
|
-
if (!tree)
|
|
161
|
+
if (!tree)
|
|
162
|
+
return { ok: false, error: "this thread has no copy of a repository" };
|
|
92
163
|
const cwd = fs.existsSync(tree.path) ? tree.path : tree.repo;
|
|
93
164
|
let range;
|
|
94
165
|
if (runId) {
|
|
95
166
|
const sha = store.meta(`turn:${runId}`);
|
|
96
167
|
if (!sha) return { ok: true, files: [], patch: "" };
|
|
97
168
|
range = `${sha}~1..${sha}`;
|
|
98
|
-
} else
|
|
169
|
+
} else
|
|
170
|
+
range = `${tree.base}..${tree.state === "open" ? "HEAD" : tree.branch}`;
|
|
99
171
|
const patch = git(cwd, ["diff", range]).out;
|
|
100
|
-
return {
|
|
172
|
+
return {
|
|
173
|
+
ok: true,
|
|
174
|
+
files: stat(cwd, range),
|
|
175
|
+
patch:
|
|
176
|
+
patch.length > PATCH_LIMIT
|
|
177
|
+
? `${patch.slice(0, PATCH_LIMIT)}\n… (cut here; the rest is on branch ${tree.branch})`
|
|
178
|
+
: patch,
|
|
179
|
+
branch: tree.branch,
|
|
180
|
+
state: tree.state,
|
|
181
|
+
};
|
|
101
182
|
};
|
|
102
183
|
|
|
103
184
|
const summary = (threadId) => {
|
|
104
185
|
const tree = get(threadId);
|
|
105
186
|
// A thread T3 Code works in keeps its changes in T3's own checkout, where T3 shows and merges them.
|
|
106
187
|
if (!tree || tree.state === "in_place" || tree.state === "t3") return null;
|
|
107
|
-
const files =
|
|
188
|
+
const files =
|
|
189
|
+
tree.state === "open" && fs.existsSync(tree.path)
|
|
190
|
+
? stat(tree.path, `${tree.base}..HEAD`).length
|
|
191
|
+
: 0;
|
|
108
192
|
return { state: tree.state, branch: tree.branch, repo: tree.repo, files };
|
|
109
193
|
};
|
|
110
194
|
|
|
@@ -116,26 +200,54 @@ export function createWorktrees({ store, root }) {
|
|
|
116
200
|
/** Brings the thread's branch into the branch the person has checked out. A conflict is undone and reported. */
|
|
117
201
|
const merge = (threadId, title) => {
|
|
118
202
|
const tree = get(threadId);
|
|
119
|
-
if (!tree || tree.state !== "open")
|
|
120
|
-
|
|
203
|
+
if (!tree || tree.state !== "open")
|
|
204
|
+
return { ok: false, error: "there is nothing of this thread's to merge" };
|
|
205
|
+
if (!stat(tree.path, `${tree.base}..HEAD`).length)
|
|
206
|
+
return { ok: false, error: "this thread has not changed anything" };
|
|
121
207
|
const into = git(tree.repo, ["rev-parse", "--abbrev-ref", "HEAD"]).out;
|
|
122
|
-
const merged = git(tree.repo, [
|
|
208
|
+
const merged = git(tree.repo, [
|
|
209
|
+
"merge",
|
|
210
|
+
"--no-ff",
|
|
211
|
+
"-m",
|
|
212
|
+
`Merge ${tree.branch}: ${String(title).slice(0, 120)}`,
|
|
213
|
+
tree.branch,
|
|
214
|
+
]);
|
|
123
215
|
if (!merged.ok) {
|
|
124
|
-
const conflicts = git(tree.repo, [
|
|
216
|
+
const conflicts = git(tree.repo, [
|
|
217
|
+
"diff",
|
|
218
|
+
"--name-only",
|
|
219
|
+
"--diff-filter=U",
|
|
220
|
+
])
|
|
221
|
+
.out.split("\n")
|
|
222
|
+
.filter(Boolean);
|
|
125
223
|
git(tree.repo, ["merge", "--abort"]);
|
|
126
|
-
return {
|
|
127
|
-
|
|
128
|
-
:
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
conflict: true,
|
|
227
|
+
files: conflicts,
|
|
228
|
+
error: conflicts.length
|
|
229
|
+
? `merging into ${into} conflicts in ${conflicts.join(", ")}; nothing was merged`
|
|
230
|
+
: `git would not merge into ${into}: ${merged.err.split("\n")[0]}; nothing was merged`,
|
|
231
|
+
};
|
|
129
232
|
}
|
|
130
233
|
retire(tree);
|
|
131
234
|
git(tree.repo, ["branch", "-d", tree.branch]);
|
|
132
|
-
save(threadId, {
|
|
235
|
+
save(threadId, {
|
|
236
|
+
...tree,
|
|
237
|
+
state: "merged",
|
|
238
|
+
into,
|
|
239
|
+
mergedAt: new Date().toISOString(),
|
|
240
|
+
});
|
|
133
241
|
return { ok: true, into };
|
|
134
242
|
};
|
|
135
243
|
|
|
136
244
|
const discard = (threadId) => {
|
|
137
245
|
const tree = get(threadId);
|
|
138
|
-
if (!tree || tree.state !== "open")
|
|
246
|
+
if (!tree || tree.state !== "open")
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
error: "there is nothing of this thread's to discard",
|
|
250
|
+
};
|
|
139
251
|
retire(tree);
|
|
140
252
|
git(tree.repo, ["branch", "-D", tree.branch]);
|
|
141
253
|
save(threadId, { ...tree, state: "discarded" });
|
|
@@ -149,7 +261,11 @@ export function createWorktrees({ store, root }) {
|
|
|
149
261
|
const moved = (threadId) => {
|
|
150
262
|
const tree = get(threadId);
|
|
151
263
|
if (tree?.state === "open" && fs.existsSync(tree.path)) {
|
|
152
|
-
if (stat(tree.path, `${tree.base}..HEAD`).length)
|
|
264
|
+
if (stat(tree.path, `${tree.base}..HEAD`).length)
|
|
265
|
+
return {
|
|
266
|
+
ok: false,
|
|
267
|
+
error: `this thread changed files on ${tree.branch}; merge or discard them first`,
|
|
268
|
+
};
|
|
153
269
|
retire(tree);
|
|
154
270
|
git(tree.repo, ["branch", "-D", tree.branch]);
|
|
155
271
|
}
|
|
@@ -168,19 +284,51 @@ export function createWorktrees({ store, root }) {
|
|
|
168
284
|
*/
|
|
169
285
|
const t3Workspace = (thread) => {
|
|
170
286
|
const current = get(thread.id);
|
|
171
|
-
if (current?.state === "in_place" || current?.inPlace)
|
|
172
|
-
|
|
287
|
+
if (current?.state === "in_place" || current?.inPlace)
|
|
288
|
+
return { type: "root" };
|
|
289
|
+
if ((current?.state === "t3" || current?.state === "open") && current.path)
|
|
290
|
+
return {
|
|
291
|
+
type: "existing_worktree",
|
|
292
|
+
worktreePath: current.path,
|
|
293
|
+
...(current.branch ? { branch: current.branch } : {}),
|
|
294
|
+
};
|
|
173
295
|
const repo = repositoryOf(thread.goal.workspace);
|
|
174
296
|
if (!repo) return { type: "root" };
|
|
175
297
|
const branch = git(repo.root, ["rev-parse", "--abbrev-ref", "HEAD"]).out;
|
|
176
|
-
return {
|
|
177
|
-
|
|
298
|
+
return {
|
|
299
|
+
type: "worktree",
|
|
300
|
+
baseRef:
|
|
301
|
+
branch && branch !== "HEAD"
|
|
302
|
+
? branch
|
|
303
|
+
: git(repo.root, ["rev-parse", "HEAD"]).out,
|
|
304
|
+
branch: `harness/${slug(thread.objective)}-${thread.id.slice(0, 8)}`,
|
|
305
|
+
startFromOrigin: false,
|
|
306
|
+
};
|
|
178
307
|
};
|
|
179
308
|
/** The thread works in T3 Code from now on; `where` and `branch` once T3 has made its checkout. */
|
|
180
309
|
const inT3 = (threadId, where = null, branch = null) => {
|
|
181
310
|
const current = get(threadId);
|
|
182
|
-
if (current?.state !== "t3" || (where && where !== current.path))
|
|
311
|
+
if (current?.state !== "t3" || (where && where !== current.path))
|
|
312
|
+
save(threadId, {
|
|
313
|
+
...current,
|
|
314
|
+
...(current?.state === "in_place" ? { inPlace: true } : {}),
|
|
315
|
+
state: "t3",
|
|
316
|
+
path: where ?? current?.path ?? null,
|
|
317
|
+
branch: branch ?? current?.branch ?? null,
|
|
318
|
+
});
|
|
183
319
|
};
|
|
184
320
|
|
|
185
|
-
return {
|
|
321
|
+
return {
|
|
322
|
+
folderFor,
|
|
323
|
+
commitTurn,
|
|
324
|
+
diff,
|
|
325
|
+
summary,
|
|
326
|
+
merge,
|
|
327
|
+
discard,
|
|
328
|
+
moved,
|
|
329
|
+
inPlace,
|
|
330
|
+
t3Workspace,
|
|
331
|
+
inT3,
|
|
332
|
+
get,
|
|
333
|
+
};
|
|
186
334
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tryharness",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.25",
|
|
4
4
|
"description": "Harness Relay: the daemon that turns the agents on this machine into your personal swarm. Memory, history, and work handed between any of them.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"tryharness": "cli.mjs"
|
|
@@ -32,15 +32,17 @@
|
|
|
32
32
|
"@clack/core": "0.5.0",
|
|
33
33
|
"@clack/prompts": "0.11.0",
|
|
34
34
|
"@huggingface/tokenizers": "^0.1.3",
|
|
35
|
+
"jpeg-js": "0.4.4",
|
|
35
36
|
"node-sqlite3-wasm": "0.8.60",
|
|
36
|
-
"onnxruntime-
|
|
37
|
+
"onnxruntime-node": "1.24.3",
|
|
37
38
|
"picocolors": "^1.1.1",
|
|
39
|
+
"pngjs": "7.0.0",
|
|
38
40
|
"qr": "0.4.2",
|
|
39
41
|
"ws": "^8.18.3"
|
|
40
42
|
},
|
|
41
43
|
"optionalDependencies": {
|
|
42
|
-
"tryharness-desktop-darwin-arm64": "0.7.
|
|
43
|
-
"tryharness-desktop-darwin-x64": "0.7.
|
|
44
|
+
"tryharness-desktop-darwin-arm64": "0.7.25",
|
|
45
|
+
"tryharness-desktop-darwin-x64": "0.7.25",
|
|
44
46
|
"tryharness-desktop-linux-x64": "0.7.4",
|
|
45
47
|
"tryharness-desktop-win32-x64": "0.7.4"
|
|
46
48
|
}
|
package/lib/embedder.mjs
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { ensureDir } from "./files.mjs";
|
|
4
|
-
|
|
5
|
-
// The daemon's own embedder for the context space: the same model, quantization, pooling
|
|
6
|
-
// and normalization as the browser (src/shared/memory/embeddingService.ts), so the two
|
|
7
|
-
// sides describe the same space under the same version key. It runs on onnxruntime-web's
|
|
8
|
-
// Node build (wasm, no native addon) with the same tokenizer library transformers.js uses,
|
|
9
|
-
// and mirrors transformers' feature-extraction pipeline step by step: tokenize with special
|
|
10
|
-
// tokens, truncate to the model's 512, pad to the batch, take the [CLS] vector, L2-normalize.
|
|
11
|
-
// Weights are downloaded into the Harness home during setup, never fetched from a browser.
|
|
12
|
-
|
|
13
|
-
export const EMBEDDING_MODEL = "Xenova/bge-base-en-v1.5";
|
|
14
|
-
export const EMBEDDING_DIMENSIONS = 768;
|
|
15
|
-
export const EMBEDDING_VERSION = "bge-base-en-v1.5:q8:cls:l2:asymmetric:v1";
|
|
16
|
-
const QUERY_PREFIX = "Represent this sentence for searching relevant passages: ";
|
|
17
|
-
const MODEL_FILES = ["config.json", "tokenizer.json", "tokenizer_config.json", "onnx/model_quantized.onnx"];
|
|
18
|
-
const REMOTE = `https://huggingface.co/${EMBEDDING_MODEL}/resolve/main/`;
|
|
19
|
-
// One text per run: a q8 model quantizes per batch tensor, so the same text in different company
|
|
20
|
-
// differs in the last decimals (transformers.js shows the same, cosine 0.992 alone versus batched).
|
|
21
|
-
// Entries arrive one at a time and a vector must not depend on its neighbours, so no batching.
|
|
22
|
-
const BATCH = 1;
|
|
23
|
-
|
|
24
|
-
export function modelDirectory(modelsDir) {
|
|
25
|
-
return path.join(modelsDir, ...EMBEDDING_MODEL.split("/"));
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** True when every model file is present and non-empty. */
|
|
29
|
-
export function modelPresent(modelsDir) {
|
|
30
|
-
const dir = modelDirectory(modelsDir);
|
|
31
|
-
return MODEL_FILES.every((file) => {
|
|
32
|
-
try { return fs.statSync(path.join(dir, file)).size > 0; } catch { return false; }
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function download(url, target, { fetchImpl, log }) {
|
|
37
|
-
const partial = `${target}.partial`;
|
|
38
|
-
ensureDir(path.dirname(target));
|
|
39
|
-
let have = 0;
|
|
40
|
-
try { have = fs.statSync(partial).size; } catch {}
|
|
41
|
-
const headers = have > 0 ? { range: `bytes=${have}-` } : {};
|
|
42
|
-
const response = await fetchImpl(url, { headers, redirect: "follow" });
|
|
43
|
-
if (response.status === 416) { fs.renameSync(partial, target); return; }
|
|
44
|
-
if (!response.ok) throw new Error(`download of ${path.basename(target)} failed: HTTP ${response.status}`);
|
|
45
|
-
const resumed = response.status === 206;
|
|
46
|
-
const total = Number(response.headers.get("content-length") || 0) + (resumed ? have : 0);
|
|
47
|
-
const out = fs.createWriteStream(partial, { flags: resumed ? "a" : "w" });
|
|
48
|
-
let done = resumed ? have : 0;
|
|
49
|
-
let shown = -1;
|
|
50
|
-
const name = path.basename(target);
|
|
51
|
-
for await (const chunk of response.body) {
|
|
52
|
-
if (!out.write(chunk)) await new Promise((resolve) => out.once("drain", resolve));
|
|
53
|
-
done += chunk.length;
|
|
54
|
-
const percent = total ? Math.floor((done / total) * 10) * 10 : -1;
|
|
55
|
-
if (percent !== shown && percent >= 0) { shown = percent; log?.(`embedder: ${name} ${percent}%`); }
|
|
56
|
-
}
|
|
57
|
-
await new Promise((resolve, reject) => { out.end(); out.on("finish", resolve); out.on("error", reject); });
|
|
58
|
-
if (total && done !== total) throw new Error(`download of ${name} stopped at ${done} of ${total} bytes; run setup again to resume`);
|
|
59
|
-
fs.renameSync(partial, target);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Downloads any missing model file into modelsDir. Idempotent; a partial file resumes. */
|
|
63
|
-
export async function ensureModel({ modelsDir, log = () => {}, fetchImpl = fetch } = {}) {
|
|
64
|
-
const dir = modelDirectory(modelsDir);
|
|
65
|
-
const missing = MODEL_FILES.filter((file) => { try { return fs.statSync(path.join(dir, file)).size === 0; } catch { return true; } });
|
|
66
|
-
if (!missing.length) return { downloaded: [], present: true };
|
|
67
|
-
log(`embedder: downloading ${EMBEDDING_MODEL} (${missing.length} file${missing.length === 1 ? "" : "s"}, about 110 MB) into ${modelsDir}`);
|
|
68
|
-
for (const file of missing) await download(REMOTE + file, path.join(dir, file), { fetchImpl, log });
|
|
69
|
-
log("embedder: ready");
|
|
70
|
-
return { downloaded: missing, present: true };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function truncate(encoded, maxLength) {
|
|
74
|
-
for (const key of Object.keys(encoded)) encoded[key] = encoded[key].slice(0, maxLength);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function pad(encoded, maxLength, padId) {
|
|
78
|
-
for (const key of Object.keys(encoded)) {
|
|
79
|
-
const fill = key === "input_ids" ? padId : 0;
|
|
80
|
-
while (encoded[key].length < maxLength) encoded[key].push(fill);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* A lazily loaded embedder over local files only. Loading the session takes about half a
|
|
86
|
-
* second and inference runs on the calling thread (onnxruntime-web's Node build has no
|
|
87
|
-
* worker), so each short text holds the event loop for a few tens of milliseconds;
|
|
88
|
-
* callers embed in the background, never inside a request the person is waiting on.
|
|
89
|
-
*/
|
|
90
|
-
export function createEmbedder({ modelsDir }) {
|
|
91
|
-
const dir = modelDirectory(modelsDir);
|
|
92
|
-
let loading = null;
|
|
93
|
-
const load = () => {
|
|
94
|
-
if (loading) return loading;
|
|
95
|
-
loading = (async () => {
|
|
96
|
-
if (!modelPresent(modelsDir)) throw new Error(`the embedding model is not present in ${modelsDir}; run npx tryharness to download it`);
|
|
97
|
-
const [{ Tokenizer }, ort] = await Promise.all([import("@huggingface/tokenizers"), import("onnxruntime-web")]);
|
|
98
|
-
const tokenizerJson = JSON.parse(fs.readFileSync(path.join(dir, "tokenizer.json"), "utf8"));
|
|
99
|
-
const tokenizerConfig = JSON.parse(fs.readFileSync(path.join(dir, "tokenizer_config.json"), "utf8"));
|
|
100
|
-
const modelConfig = JSON.parse(fs.readFileSync(path.join(dir, "config.json"), "utf8"));
|
|
101
|
-
const tokenizer = new Tokenizer(tokenizerJson, tokenizerConfig);
|
|
102
|
-
ort.env.wasm.numThreads = 1;
|
|
103
|
-
const session = await ort.InferenceSession.create(path.join(dir, "onnx", "model_quantized.onnx"), { executionProviders: ["wasm"] });
|
|
104
|
-
return {
|
|
105
|
-
ort, tokenizer, session,
|
|
106
|
-
maxLength: Number(tokenizerConfig.model_max_length) || 512,
|
|
107
|
-
padId: Number(modelConfig.pad_token_id ?? 0),
|
|
108
|
-
wantsTypeIds: session.inputNames.includes("token_type_ids"),
|
|
109
|
-
};
|
|
110
|
-
})();
|
|
111
|
-
loading.catch(() => { loading = null; });
|
|
112
|
-
return loading;
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
const embedBatch = async (texts) => {
|
|
116
|
-
const { ort, tokenizer, session, maxLength, padId, wantsTypeIds } = await load();
|
|
117
|
-
const encoded = texts.map((text) => {
|
|
118
|
-
const { ids, attention_mask, token_type_ids } = tokenizer.encode(text, { add_special_tokens: true, return_token_type_ids: true });
|
|
119
|
-
return { input_ids: [...ids], attention_mask: [...attention_mask], ...(wantsTypeIds ? { token_type_ids: [...(token_type_ids || ids.map(() => 0))] } : {}) };
|
|
120
|
-
});
|
|
121
|
-
const width = Math.min(Math.max(...encoded.map((e) => e.input_ids.length)), maxLength);
|
|
122
|
-
for (const e of encoded) {
|
|
123
|
-
if (e.input_ids.length > width) truncate(e, width);
|
|
124
|
-
else if (e.input_ids.length < width) pad(e, width, padId);
|
|
125
|
-
}
|
|
126
|
-
const tensor = (key) => new ort.Tensor("int64", BigInt64Array.from(encoded.flatMap((e) => e[key].map((v) => BigInt(v)))), [texts.length, width]);
|
|
127
|
-
const feeds = { input_ids: tensor("input_ids"), attention_mask: tensor("attention_mask"), ...(wantsTypeIds ? { token_type_ids: tensor("token_type_ids") } : {}) };
|
|
128
|
-
const outputs = await session.run(feeds);
|
|
129
|
-
const hidden = outputs.last_hidden_state;
|
|
130
|
-
const [, seq, dims] = hidden.dims;
|
|
131
|
-
const data = hidden.data;
|
|
132
|
-
return texts.map((_, row) => {
|
|
133
|
-
const start = row * seq * dims;
|
|
134
|
-
const vector = new Float32Array(dims);
|
|
135
|
-
let norm = 0;
|
|
136
|
-
for (let i = 0; i < dims; i += 1) { vector[i] = data[start + i]; norm += vector[i] * vector[i]; }
|
|
137
|
-
norm = Math.sqrt(norm) || 1;
|
|
138
|
-
for (let i = 0; i < dims; i += 1) vector[i] /= norm;
|
|
139
|
-
return vector;
|
|
140
|
-
});
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
return {
|
|
144
|
-
version: EMBEDDING_VERSION,
|
|
145
|
-
dimensions: EMBEDDING_DIMENSIONS,
|
|
146
|
-
ready: () => load().then(() => true),
|
|
147
|
-
async embedDocuments(texts) {
|
|
148
|
-
const out = [];
|
|
149
|
-
for (let i = 0; i < texts.length; i += BATCH) out.push(...(await embedBatch(texts.slice(i, i + BATCH))));
|
|
150
|
-
return out;
|
|
151
|
-
},
|
|
152
|
-
async embedQuery(text) {
|
|
153
|
-
return (await embedBatch([QUERY_PREFIX + text]))[0];
|
|
154
|
-
},
|
|
155
|
-
};
|
|
156
|
-
}
|