tryharness 0.7.22 → 0.7.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -8
- package/assets/opencode-plugin.js +1 -0
- package/assets/pi-extension.ts +1 -0
- package/cli.mjs +85 -9
- package/lib/atlas.mjs +304 -0
- package/lib/bip39-english.txt +2048 -0
- package/lib/cards.mjs +5 -1
- package/lib/context.mjs +290 -0
- package/lib/coordinator.mjs +10 -2
- package/lib/daemon.mjs +569 -41
- package/lib/digest.mjs +40 -0
- package/lib/environment.mjs +98 -0
- package/lib/firewall.mjs +154 -0
- package/lib/folders.mjs +60 -37
- package/lib/frames.mjs +159 -0
- package/lib/inference.mjs +326 -0
- package/lib/learn.mjs +120 -0
- package/lib/localmodels.mjs +230 -0
- package/lib/mcp-stdio.mjs +97 -5
- package/lib/media.mjs +118 -0
- package/lib/nudge.mjs +7 -0
- package/lib/ocr.mjs +217 -0
- package/lib/personal.mjs +39 -0
- package/lib/phones.mjs +26 -1
- package/lib/places.mjs +16 -0
- package/lib/receipts.mjs +42 -0
- package/lib/redact.mjs +334 -0
- package/lib/runner.mjs +2 -0
- package/lib/runtime-install.mjs +6 -5
- package/lib/screens.mjs +96 -0
- package/lib/sealed.mjs +3 -0
- package/lib/service.mjs +4 -2
- package/lib/snippets.mjs +64 -0
- package/lib/speech.mjs +480 -0
- package/lib/store.mjs +36 -6
- package/lib/swarm.mjs +8 -6
- package/lib/trails.mjs +177 -0
- package/lib/watches.mjs +163 -0
- package/package.json +6 -4
- package/lib/embedder.mjs +0 -156
package/README.md
CHANGED
|
@@ -50,15 +50,51 @@ command as soon as the agent reports its session id, including while the run is
|
|
|
50
50
|
so the user can enter and steer the agent directly rather than interacting through a
|
|
51
51
|
Harness wrapper.
|
|
52
52
|
|
|
53
|
-
##
|
|
53
|
+
## Local models
|
|
54
|
+
|
|
55
|
+
Setup downloads the relay's core local models into `~/.harness/local`, each file checked
|
|
56
|
+
against a pinned SHA-256 and every model under an allowlisted licence (`cli/lib/localmodels.mjs`):
|
|
57
|
+
EmbeddingGemma 2's text part (one search space for code, agent sessions and board posts), an
|
|
58
|
+
two injection guards (one reads long stretches and holds, one reads short ones and flags), a
|
|
59
|
+
credential detector and a screenshot text reader (PP-OCR), plus llama.cpp's own server build
|
|
60
|
+
(about 860 MB of models). Two more come down the first time they are used: speech for the buddy's
|
|
61
|
+
Talk (Silero VAD, 1.3 MB, and on a Mac with macOS 26 Apple's own on-device transcriber through
|
|
62
|
+
the buddy; elsewhere NVIDIA's Parakeet TDT 0.6B v3, 672 MB) and the search model's image and audio part
|
|
63
|
+
(555 MB) when you first add a folder of recordings or screenshots. The search model runs on the
|
|
64
|
+
GPU when this machine's check finds it faster, the CPU otherwise; the classifiers run in native
|
|
65
|
+
ONNX Runtime. Background work runs at the OS's background priority and waits in Low Power Mode,
|
|
66
|
+
on low battery and when the machine is hot.
|
|
67
|
+
|
|
68
|
+
What they do:
|
|
69
|
+
|
|
70
|
+
- **Briefs:** every brief a local coding agent gets carries a context pack (the files in its
|
|
71
|
+
repository most likely needed, earlier sessions on them, and a recording's minute or a
|
|
72
|
+
screenshot from your added folders when one is close) and the thread's task frame: what you
|
|
73
|
+
named resolved to file and line, the repository's state, other threads changing the same
|
|
74
|
+
repository, and each run's receipt. Agents can call `search_code`, `search_media`,
|
|
75
|
+
`compare_screens` (two screenshots of UI work, before and after: what changed and which text)
|
|
76
|
+
and `search_screen` (what you saw in tabs shared with Harness, asked of your own Harness window
|
|
77
|
+
sealed over the `tryharness link` line, so Harness's server carries it unread; your Allow in
|
|
78
|
+
that window comes first).
|
|
79
|
+
- **Checks:** a finished run gets a receipt from its own session file; the coordinator hands an
|
|
80
|
+
untested "tests pass" to an agent to check. An agent's post that reads like orders to the
|
|
81
|
+
swarm is held; a key in anything sent to Harness's server goes as `[SECRET_n]` (and, in requests
|
|
82
|
+
that only decide, emails, phone numbers, wallet addresses and amounts as placeholders too); a
|
|
83
|
+
screenshot a cloud agent reads has keys, addresses and amounts covered; code a cloud agent asks to
|
|
84
|
+
see (`request_snippet`) leaves only when you share it (`tryharness board snippet`).
|
|
85
|
+
- **Watches:** a command on this computer checked on a schedule (a heartbeat's age, a balance, a
|
|
86
|
+
count of error lines), said out loud only when it is off. Agents suggest them with
|
|
87
|
+
`suggest_watch`; nothing runs until you start it.
|
|
88
|
+
- **Learning:** overnight, on the charger, the ranking model retrains on what the agents' runs
|
|
89
|
+
actually changed and is kept only if it beats the current one on the newest runs.
|
|
54
90
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
91
|
+
```sh
|
|
92
|
+
npx tryharness local # what is installed, where it runs, what is indexed, the last learning
|
|
93
|
+
npx tryharness local learn # start the learner now
|
|
94
|
+
npx tryharness local add ~/Recordings # a folder of recordings and screenshots agents can search
|
|
95
|
+
npx tryharness watch # watches and what each last read
|
|
96
|
+
npx tryharness watch add "keeper heartbeat" --run "cat state/heartbeat.json" --age completedAt --above 900 --every 5m
|
|
97
|
+
```
|
|
62
98
|
|
|
63
99
|
## Task board
|
|
64
100
|
|
|
@@ -135,3 +171,7 @@ room. Agents read, post and search the room with `read_room`, `post_to_room` and
|
|
|
135
171
|
Start. Harness facilitates and answers once. `tryharness goal show <id>` prints the room, `goal stop <id>` ends
|
|
136
172
|
everything, `goal resume <id>` reopens a room that paused at a backstop, and `goal invite <id> handle/agent` asks another
|
|
137
173
|
person's agent to join as a guest over a relay thread. `tryharness room post <goal> <text> [--guests]` posts as you.
|
|
174
|
+
|
|
175
|
+
Parakeet TDT 0.6B v3 is by NVIDIA, licensed under CC BY 4.0
|
|
176
|
+
(https://creativecommons.org/licenses/by/4.0/); the relay uses the 8-bit ONNX export by
|
|
177
|
+
istupakov (https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx), unmodified.
|
|
@@ -64,5 +64,6 @@ export const TryHarnessPlugin = async ({ directory, client }) => ({
|
|
|
64
64
|
const messages = board ? await board.takeMessages(AGENT, sessionId) : [];
|
|
65
65
|
const send = client?.session?.promptAsync?.bind(client.session) ?? client?.session?.prompt?.bind(client.session);
|
|
66
66
|
if (messages.length && send) await send({ path: { id: sessionId }, body: { parts: [{ type: "text", text: board.messagesText(messages) }] } }).catch(() => {});
|
|
67
|
+
else if (board && !messages.length) await board.noteTurn(AGENT, { session_id: sessionId, cwd: directory }, pids);
|
|
67
68
|
},
|
|
68
69
|
});
|
package/assets/pi-extension.ts
CHANGED
|
@@ -149,6 +149,7 @@ export default function tryHarness(pi) {
|
|
|
149
149
|
const board = await harness().catch(() => null);
|
|
150
150
|
const text = board ? await said(board, ctx) : "";
|
|
151
151
|
if (text) pi.sendUserMessage(text);
|
|
152
|
+
else if (board) await board.noteTurn(AGENT, where(ctx), pids);
|
|
152
153
|
});
|
|
153
154
|
// After each turn (pi 0.87 event name turn_end), only when the queue moved.
|
|
154
155
|
pi.on("turn_end", async (_event, ctx) => {
|
package/cli.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { daemonRequest } from "./lib/control-client.mjs";
|
|
|
14
14
|
import { ensureDir, readJson, writeJsonAtomic } from "./lib/files.mjs";
|
|
15
15
|
import { paths } from "./lib/paths.mjs";
|
|
16
16
|
import { currentRuntime, installRuntime } from "./lib/runtime-install.mjs";
|
|
17
|
-
import {
|
|
17
|
+
import { MODELS, ensureLlama, ensureModels, modelsPresent } from "./lib/localmodels.mjs";
|
|
18
18
|
import { installService, serviceInfo, uninstallService } from "./lib/service.mjs";
|
|
19
19
|
import { canShowDesktop, openDesktop, retireDesktop, setupDesktop } from "./lib/desktop.mjs";
|
|
20
20
|
|
|
@@ -173,14 +173,23 @@ async function connect() {
|
|
|
173
173
|
const shown = await desktop();
|
|
174
174
|
spin.stop(shown.text, shown.ok ? 0 : 2);
|
|
175
175
|
}
|
|
176
|
-
// The
|
|
177
|
-
//
|
|
178
|
-
|
|
176
|
+
// The relay's core local models (localmodels.mjs) and the server that runs them come down with setup, so agents get
|
|
177
|
+
// context packs, code search, the handoff firewall and images checked before they leave from the first task. Each file
|
|
178
|
+
// is checked against its pinned hash.
|
|
179
|
+
const core = MODELS.filter((entry) => entry.tier === "core").map((entry) => entry.id);
|
|
180
|
+
const localRoot = path.join(paths.home, "local");
|
|
181
|
+
if (modelsPresent(localRoot, core)) ui.success("Local models ready");
|
|
179
182
|
else {
|
|
180
183
|
const download = ui.spinner();
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
+
const size = Math.round(MODELS.filter((entry) => core.includes(entry.id)).flatMap((entry) => entry.files).reduce((sum, file) => sum + file.size, 0) / 1e6);
|
|
185
|
+
download.start(`Downloading the local models (about ${size} MB, once)`);
|
|
186
|
+
// The classifiers run without the model server, so one that cannot start here does not hold them back.
|
|
187
|
+
const failures = [];
|
|
188
|
+
for (const step of [() => ensureLlama({ root: localRoot, log: (line) => download.message(`Downloading the local models · ${line.slice(0, 40)}`) }), () => ensureModels({ root: localRoot, ids: core, log: (line) => download.message(`Downloading the local models · ${line.slice(0, 40)}`) })]) {
|
|
189
|
+
try { await step(); } catch (error) { failures.push(error.message); }
|
|
190
|
+
}
|
|
191
|
+
if (failures.length) download.stop(`Local models not ready (${failures.join("; ")}). Agents work as before until the next setup run`, 2);
|
|
192
|
+
else download.stop("Local models ready");
|
|
184
193
|
}
|
|
185
194
|
const failed = states.filter((state) => selected.includes(state.agent) && state.installed && !state.configured);
|
|
186
195
|
if (failed.length) {
|
|
@@ -227,6 +236,55 @@ async function status() {
|
|
|
227
236
|
}
|
|
228
237
|
}
|
|
229
238
|
|
|
239
|
+
// The relay's local models: each one's job, licence, size and whether it is here, where they run, what the context engine
|
|
240
|
+
// has indexed, and what the overnight learner last did. `tryharness local learn` starts the learner now; `local add
|
|
241
|
+
// <folder>` and `local remove <folder>` name folders of recordings and screenshots for search_media and briefs.
|
|
242
|
+
async function local() {
|
|
243
|
+
const root = path.join(paths.home, "local");
|
|
244
|
+
if (argv[0] === "add" || argv[0] === "remove") {
|
|
245
|
+
const response = await daemonRequest({ command: `local.${argv[0]}`, folder: path.resolve(argv[1] || process.cwd()) });
|
|
246
|
+
if (!response?.ok) throw new Error(response?.error || "the daemon did not answer");
|
|
247
|
+
console.log(argv[0] === "add" ? `Added. The relay reads the recordings and screenshots there in the background${response.downloading ? ", once the image and audio part of the search model has downloaded (555 MB)" : ""}.` : "Removed.");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
for (const entry of MODELS) {
|
|
251
|
+
const here = modelsPresent(root, [entry.id]);
|
|
252
|
+
const mb = Math.round(entry.files.reduce((sum, file) => sum + file.size, 0) / 1e6);
|
|
253
|
+
console.log(`${here ? "ready " : "missing"} ${entry.name} (${entry.tier}, ${entry.licence}, ${mb} MB): ${entry.purpose}`);
|
|
254
|
+
}
|
|
255
|
+
let current = null;
|
|
256
|
+
try { current = await daemonRequest({ command: argv[0] === "learn" ? "local.learn" : "local.status" }); }
|
|
257
|
+
catch (error) { console.log(`daemon: ${error.message}`); process.exitCode = 1; return; }
|
|
258
|
+
if (!current?.ok) { console.log(current?.error || "the daemon did not answer"); process.exitCode = 1; return; }
|
|
259
|
+
if (argv[0] === "learn") { console.log("Learning from your agents' runs now; tryharness local shows the result when it is done."); return; }
|
|
260
|
+
console.log(`runs on: ${current.device ? (current.device.gpu ? "GPU" : "CPU") : "not checked yet"}`);
|
|
261
|
+
for (const indexed of current.roots || []) console.log(`indexed: ${indexed.root} (${indexed.files} files)`);
|
|
262
|
+
if (current.pieces?.waiting) console.log(`waiting to be embedded: ${current.pieces.waiting} pieces`);
|
|
263
|
+
console.log(current.model ? `ranking: learned ${current.model.trainedAt}, held-out recall at 10 ${JSON.stringify(current.model.heldOut)}` : "ranking: the default until the learner has enough runs");
|
|
264
|
+
for (const folder of current.media?.folders || []) console.log(`recordings and screenshots: ${folder}`);
|
|
265
|
+
if (current.media?.folders?.length) console.log(`media files read: ${current.media.files}`);
|
|
266
|
+
if (current.learning) console.log(`last learning: ${current.learning.at} · ${current.learning.kept ? "kept a new model" : `kept the old one${current.learning.why ? ` (${current.learning.why})` : ""}`}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Watches (watches.mjs): `tryharness watch` lists them and what each last read; `watch add` starts one; agents' suggestions
|
|
270
|
+
// wait for `watch start`.
|
|
271
|
+
async function watch() {
|
|
272
|
+
const sub = argv[0] || "list";
|
|
273
|
+
const minutes = (text) => { const found = String(text || "").match(/^(\d+(?:\.\d+)?)\s*(s|m|h)?$/); return found ? Number(found[1]) * ({ s: 1, m: 60, h: 3600 }[found[2] || "m"]) : null; };
|
|
274
|
+
const response = sub === "add" ? await daemonRequest({ command: "board.watch.add", agent: "user", name: argv[1], watchCommand: arg("--run", ""), folder: path.resolve(arg("--in", process.cwd())),
|
|
275
|
+
everySeconds: minutes(arg("--every", "5m")) ?? 300, field: arg("--field", undefined), age: arg("--age", undefined), count: arg("--count", undefined), above: arg("--above", undefined), below: arg("--below", undefined) })
|
|
276
|
+
: sub === "start" || sub === "remove" ? await daemonRequest({ command: `board.watch.${sub}`, agent: "user", id: argv[1] })
|
|
277
|
+
: await daemonRequest({ command: "board.watch.list", agent: "user" });
|
|
278
|
+
if (!response?.ok) throw new Error(response?.error || "the daemon did not answer");
|
|
279
|
+
if (sub === "add") { console.log(`Watching ${response.watch.name} every ${response.watch.everySeconds} s (${response.watch.id}).`); return; }
|
|
280
|
+
if (sub !== "list") { console.log("Done."); return; }
|
|
281
|
+
if (!response.watches.length) console.log("No watches. Add one: tryharness watch add \"keeper heartbeat\" --run \"<command>\" --age completedAt --above 900");
|
|
282
|
+
for (const entry of response.watches) {
|
|
283
|
+
const last = entry.samples.at(-1);
|
|
284
|
+
console.log(`${entry.state === "on" ? (entry.off ? "OFF " : "ok ") : "asks "} ${entry.name} · every ${entry.everySeconds} s · ${last ? (last.error ? `last check failed: ${last.error}` : `last read ${last.value} (${last.ms} ms)`) : "not checked yet"}${entry.state === "suggested" ? ` · suggested by ${entry.by}: tryharness watch start ${entry.id}` : ""}${entry.off ? ` · ${entry.off.text}` : ""}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
230
288
|
async function doctor() {
|
|
231
289
|
console.log(`tryharness ${VERSION}`);
|
|
232
290
|
const config = readJson(paths.config, {});
|
|
@@ -341,6 +399,22 @@ async function board() {
|
|
|
341
399
|
console.log(sub === "send" ? `Sent to ${response.message.recipient}.` : "Discarded.");
|
|
342
400
|
return;
|
|
343
401
|
}
|
|
402
|
+
// Code an agent elsewhere asked to see (snippets.mjs): list the requests waiting, or share or keep one.
|
|
403
|
+
if (sub === "snippet") {
|
|
404
|
+
const [id, choice] = argv.slice(argv.indexOf(sub) + 1);
|
|
405
|
+
if (!id) {
|
|
406
|
+
const response = await daemonRequest({ command: "board.snippet.list", agent: "user" });
|
|
407
|
+
if (!response?.ok) throw new Error(response?.error || "the daemon did not answer");
|
|
408
|
+
if (!response.snippets.length) { console.log("No agent is waiting to see code."); return; }
|
|
409
|
+
for (const each of response.snippets) console.log(`${each.id} ${each.agent} asks to see ${each.path} lines ${each.from} to ${each.to} ("${each.about}")\n${each.text.split("\n").map((line) => ` ${line}`).join("\n")}\n tryharness board snippet ${each.id} share|keep\n`);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (choice !== "share" && choice !== "keep") throw new Error("usage: tryharness board snippet <id> share|keep");
|
|
413
|
+
const response = await daemonRequest({ command: "board.snippet.decide", agent: "user", id, share: choice === "share" });
|
|
414
|
+
if (!response?.ok) throw new Error(response?.error || "the daemon did not answer");
|
|
415
|
+
console.log(choice === "share" ? "Shared in the thread." : "Kept on this computer.");
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
344
418
|
if (sub === "auto" || sub === "end") {
|
|
345
419
|
const id = String(argv[argv.indexOf(sub) + 1] || "");
|
|
346
420
|
const response = sub === "auto"
|
|
@@ -356,7 +430,7 @@ async function board() {
|
|
|
356
430
|
if (!id || !query) throw new Error("usage: tryharness board context <task id> <what you are looking for>");
|
|
357
431
|
const response = await daemonRequest({ command: "board.context.search", agent, id, query, limit: 8 }, 20_000);
|
|
358
432
|
if (!response.ok) throw new Error(response.error || "search failed");
|
|
359
|
-
const how = response.retrieval === "vectors" ? "meaning" : response.embedder === "absent" ? "words (the
|
|
433
|
+
const how = response.retrieval === "vectors" ? "meaning" : response.embedder === "absent" ? "words (the local models are not downloaded yet; run npx tryharness)" : `words (${response.context?.embedded ?? 0} of ${response.context?.total ?? 0} entries embedded so far)`;
|
|
360
434
|
console.log(`${response.entries.length} result${response.entries.length === 1 ? "" : "s"} by ${how}`);
|
|
361
435
|
for (const entry of response.entries) console.log(`\n[${entry.kind} · ${entry.author}${entry.source ? ` · ${entry.source}` : ""} · ${entry.score}]\n${entry.text.slice(0, 400)}`);
|
|
362
436
|
return;
|
|
@@ -735,13 +809,15 @@ async function t3() {
|
|
|
735
809
|
}
|
|
736
810
|
|
|
737
811
|
function help() {
|
|
738
|
-
console.log(`tryharness ${VERSION}\n\nCommands:\n tryharness set up everything and start the daemon\n tryharness desktop open the Harness desktop app (setup opens it too; --no-desktop skips it)\n tryharness status show daemon, bridge, agent, and index health\n tryharness doctor diagnose the complete connection\n tryharness sync [--agent] rescan local coding history in the background\n tryharness task <id> --follow\n follow a delegated agent run in this terminal\n tryharness board [--all] list open work on the machine's task board\n tryharness board post --for <agent> --objective <text> [--file <brief.md>]\n tryharness board show <id>\n tryharness board post --for codex --objective <text> --run --workspace <dir>\n the daemon runs it and reports back on the task\n tryharness board run <id> authorize a run an agent asked for\n tryharness board release <id>\n tryharness board agents when each agent last read the board\n tryharness board --watch follow the board in this terminal\n tryharness notify on|off desktop notifications for what waits on you\n tryharness board send <task> <message> send a held reply to another person's agent\n tryharness board auto <task> on|off auto-send replies on that thread\n tryharness board end <task> --outcome <text>\n tryharness board context <task> <query> search everything written about a task\n tryharness goal set --objective <text> [--team a,b] [--workspace <dir>] a goal your agents work together in one room\n tryharness goal [list | show <id> | stop <id> | resume <id>]\n tryharness room <goal> | room post <goal> <text> [--ask agent,agent]\n tryharness swarm rounds [<thread>] what the coordinator saw, chose and cost each round\n tryharness swarm coordinator [harness|local] who coordinates your threads\n tryharness connect <handle/agent> --about <text> ask another person's agent to work an issue with yours, sealed to their computer\n tryharness peers the keys of other people's computers this one has worked with, to compare\n tryharness agents how Harness wakes each of your agents, and set up the rest\n tryharness agents setup <agent> | agents test <agent>\n tryharness agents cards what each of your agents is told about the others\n tryharness agents note <agent> <text> [--clear] your own note on an agent's card, told to every other agent\n tryharness agents models [<agent>] [--refresh] the models each coding agent lists and the one it runs on\n tryharness agents model <agent> <model> [--effort <level>] | auto | default the model it runs on; auto lets Harness pick per run\n tryharness agents routing "<text>" [--clear] how Auto picks models for your swarm\n tryharness mcp grok-bot print the MCP snippet that puts Grok Bot on the board\n tryharness link connect the browser you use Harness in to this computer, end to end (--print shows the link)\n tryharness phone pair show a QR code that pairs your phone with this computer\n tryharness phone list | revoke <id>\n tryharness t3 approve Harness in T3 Code once, so your messages go into its threads (t3 status | t3 forget)\n tryharness logs [--follow]\n tryharness disconnect\n`);
|
|
812
|
+
console.log(`tryharness ${VERSION}\n\nCommands:\n tryharness set up everything and start the daemon\n tryharness desktop open the Harness desktop app (setup opens it too; --no-desktop skips it)\n tryharness status show daemon, bridge, agent, and index health\n tryharness doctor diagnose the complete connection\n tryharness local [learn] the relay's local models, where they run, what is indexed and the last learning\n tryharness local add|remove <folder> a folder of recordings and screenshots agents can search by what was said or shown\n tryharness watch [add <name> --run <command> [--every 5m] [--field f | --age f | --count text] [--above n] [--below n] | start <id> | remove <id>]\n watches the relay checks and says out loud only when they are off\n tryharness sync [--agent] rescan local coding history in the background\n tryharness task <id> --follow\n follow a delegated agent run in this terminal\n tryharness board [--all] list open work on the machine's task board\n tryharness board post --for <agent> --objective <text> [--file <brief.md>]\n tryharness board show <id>\n tryharness board post --for codex --objective <text> --run --workspace <dir>\n the daemon runs it and reports back on the task\n tryharness board run <id> authorize a run an agent asked for\n tryharness board release <id>\n tryharness board agents when each agent last read the board\n tryharness board --watch follow the board in this terminal\n tryharness notify on|off desktop notifications for what waits on you\n tryharness board send <task> <message> send a held reply to another person's agent\n tryharness board snippet [<id> share|keep] code an agent elsewhere asked to see; nothing leaves until you share it\n tryharness board auto <task> on|off auto-send replies on that thread\n tryharness board end <task> --outcome <text>\n tryharness board context <task> <query> search everything written about a task\n tryharness goal set --objective <text> [--team a,b] [--workspace <dir>] a goal your agents work together in one room\n tryharness goal [list | show <id> | stop <id> | resume <id>]\n tryharness room <goal> | room post <goal> <text> [--ask agent,agent]\n tryharness swarm rounds [<thread>] what the coordinator saw, chose and cost each round\n tryharness swarm coordinator [harness|local] who coordinates your threads\n tryharness connect <handle/agent> --about <text> ask another person's agent to work an issue with yours, sealed to their computer\n tryharness peers the keys of other people's computers this one has worked with, to compare\n tryharness agents how Harness wakes each of your agents, and set up the rest\n tryharness agents setup <agent> | agents test <agent>\n tryharness agents cards what each of your agents is told about the others\n tryharness agents note <agent> <text> [--clear] your own note on an agent's card, told to every other agent\n tryharness agents models [<agent>] [--refresh] the models each coding agent lists and the one it runs on\n tryharness agents model <agent> <model> [--effort <level>] | auto | default the model it runs on; auto lets Harness pick per run\n tryharness agents routing "<text>" [--clear] how Auto picks models for your swarm\n tryharness mcp grok-bot print the MCP snippet that puts Grok Bot on the board\n tryharness link connect the browser you use Harness in to this computer, end to end (--print shows the link)\n tryharness phone pair show a QR code that pairs your phone with this computer\n tryharness phone list | revoke <id>\n tryharness t3 approve Harness in T3 Code once, so your messages go into its threads (t3 status | t3 forget)\n tryharness logs [--follow]\n tryharness disconnect\n`);
|
|
739
813
|
}
|
|
740
814
|
|
|
741
815
|
try {
|
|
742
816
|
if (command === "setup" || (command === "connect" && (!argv[0] || argv[0].startsWith("-")))) await connect();
|
|
743
817
|
else if (command === "status" || command === "bridge") await status();
|
|
744
818
|
else if (command === "doctor") await doctor();
|
|
819
|
+
else if (command === "local") await local();
|
|
820
|
+
else if (command === "watch") await watch();
|
|
745
821
|
else if (command === "sync" || command === "backfill" || command === "drain") await sync();
|
|
746
822
|
else if (command === "task") await task();
|
|
747
823
|
else if (command === "board") await board();
|
package/lib/atlas.mjs
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import sqliteWasm from "node-sqlite3-wasm";
|
|
6
|
+
import { fromBlob, toBlob } from "./vectors.mjs";
|
|
7
|
+
|
|
8
|
+
// The relay's local index of the code its agents work in: every text file in the folders the swarm uses, cut into
|
|
9
|
+
// pieces at top-level declarations and headings, each piece embedded once by the search model and found again by words
|
|
10
|
+
// (SQLite's full-text index) and by meaning. Pieces are keyed by their repository path and text, not by commit, so a
|
|
11
|
+
// new worktree or a small edit costs only the pieces that changed. Files that look like keys or secrets are never read.
|
|
12
|
+
|
|
13
|
+
const { Database } = sqliteWasm;
|
|
14
|
+
export const ATLAS_VERSION = "embeddinggemma-2:q8:768:v1";
|
|
15
|
+
const MAX_FILE = 300_000;
|
|
16
|
+
const MIN_PIECE = 300;
|
|
17
|
+
const MAX_PIECE = 2_400;
|
|
18
|
+
const TEXT_EXT = /\.(?:[cm]?[jt]sx?|json|md|mdx|txt|ya?ml|toml|sql|sol|rs|go|py|rb|java|kt|swift|c|cc|cpp|h|hpp|cs|php|sh|zsh|css|scss|html|vue|svelte|astro|graphql|proto|ini|cfg|conf|env\.example|lua|ex|exs|dart|scala|zig)$/i;
|
|
19
|
+
const SKIP_PATH = /(?:^|\/)(?:node_modules|dist|build|out|\.next|coverage|vendor|target|\.git)\/|(?:^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock|go\.sum)$|\.min\.[jc]ss?$|\.map$/;
|
|
20
|
+
const SECRET_PATH = /(?:^|\/)(?:\.env(?:\.[\w-]+)?|id_[rd]sa\w*|[\w.-]+\.(?:pem|key|p12|pfx|keystore|jks)|\.npmrc|\.pypirc|credentials(?:\.json)?)$/i;
|
|
21
|
+
const SECRET_TEXT = /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/;
|
|
22
|
+
const DECLARATION = /^(?:export\s|import\s|async\s|function\s|class\s|const\s|let\s|var\s|type\s|interface\s|enum\s|def\s|fn\s|pub\s|impl\s|struct\s|mod\s|func\s|package\s|contract\s|library\s|module\s|describe\(|it\(|test\(|#{1,6}\s|\[[\w.-]+\]$|create\s|alter\s)/i;
|
|
23
|
+
const STOP = new Set("the a an and or of to in on for with is are be it this that from by at as was were can should would could will do does did not no yes we you i me my our your they them their its into out up so if then else than but about how what why when where which who whom also just like make made use used using need needs want".split(" "));
|
|
24
|
+
|
|
25
|
+
/** Whether a repository path may be indexed: a text file, not generated, not a secret. */
|
|
26
|
+
export const indexable = (rel, size = 0) => TEXT_EXT.test(rel) && !SKIP_PATH.test(rel) && !SECRET_PATH.test(rel) && size <= MAX_FILE;
|
|
27
|
+
|
|
28
|
+
/** A file's pieces: { text, line } cut at top-level declarations and headings, small ones merged, big ones split at blank lines. */
|
|
29
|
+
export function pieces(text) {
|
|
30
|
+
const lines = text.split("\n");
|
|
31
|
+
const segments = [];
|
|
32
|
+
let start = 0;
|
|
33
|
+
for (let i = 1; i <= lines.length; i += 1) {
|
|
34
|
+
const line = lines[i];
|
|
35
|
+
const boundary = i === lines.length || (line && !/^\s/.test(line) && (lines[i - 1].trim() === "" || DECLARATION.test(line)) && !/^[)}\]]/.test(line));
|
|
36
|
+
if (boundary) { segments.push({ line: start + 1, text: lines.slice(start, i).join("\n") }); start = i; }
|
|
37
|
+
}
|
|
38
|
+
const merged = [];
|
|
39
|
+
for (const segment of segments) {
|
|
40
|
+
const last = merged.at(-1);
|
|
41
|
+
if (last && last.text.length < MIN_PIECE) { last.text += `\n${segment.text}`; continue; }
|
|
42
|
+
merged.push({ ...segment });
|
|
43
|
+
}
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const segment of merged) {
|
|
46
|
+
if (!segment.text.trim()) continue;
|
|
47
|
+
if (segment.text.length <= MAX_PIECE) { out.push(segment); continue; }
|
|
48
|
+
let buffer = "";
|
|
49
|
+
let line = segment.line;
|
|
50
|
+
let at = segment.line;
|
|
51
|
+
for (const block of segment.text.split(/\n(?=\s*\n)/)) {
|
|
52
|
+
if (buffer && buffer.length + block.length > MAX_PIECE) { out.push({ line, text: buffer }); buffer = ""; line = at; }
|
|
53
|
+
for (let i = 0; i < block.length; i += MAX_PIECE) {
|
|
54
|
+
const part = block.slice(i, i + MAX_PIECE);
|
|
55
|
+
if (buffer && buffer.length + part.length > MAX_PIECE) { out.push({ line, text: buffer }); buffer = ""; line = at; }
|
|
56
|
+
buffer += (buffer ? "\n" : "") + part;
|
|
57
|
+
}
|
|
58
|
+
at += block.split("\n").length;
|
|
59
|
+
}
|
|
60
|
+
if (buffer.trim()) out.push({ line, text: buffer });
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const pieceHash = (rel, text) => createHash("sha1").update(`${rel}\0${text}`).digest("hex");
|
|
66
|
+
|
|
67
|
+
/** Words and identifiers worth searching for in a request, most specific first (identifiers, paths, then words). */
|
|
68
|
+
export function queryTerms(text, max = 24) {
|
|
69
|
+
const raw = String(text).match(/[\p{L}\p{N}_$./-]{3,}/gu) || [];
|
|
70
|
+
const terms = [];
|
|
71
|
+
for (const token of raw) {
|
|
72
|
+
const clean = token.replace(/^[./-]+|[./-]+$/g, "");
|
|
73
|
+
if (clean.length < 3 || STOP.has(clean.toLowerCase())) continue;
|
|
74
|
+
for (const part of clean.split(/[./-]/)) if (part.length >= 3 && !STOP.has(part.toLowerCase())) terms.push(part);
|
|
75
|
+
}
|
|
76
|
+
const specific = (term) => (/[A-Z_$]/.test(term.slice(1)) || /\d/.test(term) ? 0 : 1);
|
|
77
|
+
return [...new Set(terms)].sort((a, b) => specific(a) - specific(b)).slice(0, max);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function openAtlas(file) {
|
|
81
|
+
const db = new Database(file);
|
|
82
|
+
// The atlas is a cache the relay can always rebuild from the repositories and session files, so writes skip the disk sync.
|
|
83
|
+
db.exec("pragma journal_mode = MEMORY; pragma synchronous = OFF; pragma busy_timeout = 5000;");
|
|
84
|
+
db.exec(`
|
|
85
|
+
create table if not exists pieces (hash text primary key, path text not null, line integer not null, text text not null);
|
|
86
|
+
create table if not exists piece_vectors (hash text not null, version text not null, vector blob not null, primary key(hash, version));
|
|
87
|
+
create table if not exists blobs (blob text primary key, path text not null, pieces text not null);
|
|
88
|
+
create table if not exists files (root text not null, path text not null, blob text not null, indexed_at text not null, primary key(root, path));
|
|
89
|
+
create virtual table if not exists pieces_fts using fts5(hash unindexed, path, text, tokenize='unicode61 tokenchars ''_$''');
|
|
90
|
+
create table if not exists runs (agent text not null, session_id text not null, root text not null, at integer not null, brief text not null,
|
|
91
|
+
edited text not null, read text not null, before text not null, source_mtime real not null, primary key(agent, session_id));
|
|
92
|
+
create index if not exists runs_root_at on runs(root, at);
|
|
93
|
+
create table if not exists run_vectors (agent text not null, session_id text not null, version text not null, vector blob not null, primary key(agent, session_id, version));
|
|
94
|
+
create table if not exists meta (key text primary key, value text not null);
|
|
95
|
+
create table if not exists media (hash text primary key, path text not null, kind text not null, start_ms integer not null, end_ms integer not null, mtime real not null);
|
|
96
|
+
create index if not exists media_path on media(path);
|
|
97
|
+
`);
|
|
98
|
+
// A blob's pieces never change, so lookups are kept (a few MB for a large repository and its recent versions).
|
|
99
|
+
const blobPieces = new Map();
|
|
100
|
+
const run = (sql, ...values) => db.run(sql, values.length ? values : undefined);
|
|
101
|
+
const all = (sql, ...values) => db.all(sql, values.length ? values : undefined);
|
|
102
|
+
const get = (sql, ...values) => db.get(sql, values.length ? values : undefined);
|
|
103
|
+
return {
|
|
104
|
+
close: () => db.close(),
|
|
105
|
+
blob(blob) {
|
|
106
|
+
if (blobPieces.has(blob)) return blobPieces.get(blob);
|
|
107
|
+
const row = get("select pieces from blobs where blob = ?", blob);
|
|
108
|
+
if (!row) return null;
|
|
109
|
+
if (blobPieces.size >= 200_000) blobPieces.clear();
|
|
110
|
+
blobPieces.set(blob, JSON.parse(row.pieces));
|
|
111
|
+
return blobPieces.get(blob);
|
|
112
|
+
},
|
|
113
|
+
/** Runs fn inside one transaction. */
|
|
114
|
+
batch(fn) {
|
|
115
|
+
db.exec("begin");
|
|
116
|
+
try { const out = fn(); db.exec("commit"); return out; } catch (error) { db.exec("rollback"); throw error; }
|
|
117
|
+
},
|
|
118
|
+
putBlob(blob, rel, parts) {
|
|
119
|
+
const hashes = [];
|
|
120
|
+
for (const part of parts) {
|
|
121
|
+
const hash = pieceHash(rel, part.text);
|
|
122
|
+
hashes.push(hash);
|
|
123
|
+
if (!get("select 1 as one from pieces where hash = ?", hash)) {
|
|
124
|
+
run("insert into pieces(hash, path, line, text) values(?, ?, ?, ?)", hash, rel, part.line, part.text);
|
|
125
|
+
run("insert into pieces_fts(hash, path, text) values(?, ?, ?)", hash, rel.replace(/[/._-]/g, " "), part.text);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
run("insert or replace into blobs(blob, path, pieces) values(?, ?, ?)", blob, rel, JSON.stringify(hashes));
|
|
129
|
+
return hashes;
|
|
130
|
+
},
|
|
131
|
+
setFiles(root, rows) {
|
|
132
|
+
db.exec("begin");
|
|
133
|
+
try {
|
|
134
|
+
run("delete from files where root = ?", root);
|
|
135
|
+
const now = new Date().toISOString();
|
|
136
|
+
for (const row of rows) run("insert into files(root, path, blob, indexed_at) values(?, ?, ?, ?)", root, row.path, row.blob, now);
|
|
137
|
+
db.exec("commit");
|
|
138
|
+
} catch (error) { db.exec("rollback"); throw error; }
|
|
139
|
+
},
|
|
140
|
+
files: (root) => all("select path, blob from files where root = ?", root),
|
|
141
|
+
roots: () => all("select root, count(*) as files, max(indexed_at) as indexed_at from files group by root").map((row) => ({ ...row, files: Number(row.files) })),
|
|
142
|
+
toEmbed: (version, limit = 64) => all("select p.hash, p.path, p.text from pieces p left join piece_vectors v on v.hash = p.hash and v.version = ? where v.hash is null limit ?", version, limit),
|
|
143
|
+
missing: (version) => Number(get("select count(*) as n from pieces p left join piece_vectors v on v.hash = p.hash and v.version = ? where v.hash is null", version).n),
|
|
144
|
+
putVector: (hash, version, vector) => run("insert or replace into piece_vectors(hash, version, vector) values(?, ?, ?)", hash, version, toBlob(vector)),
|
|
145
|
+
vectors(hashes, version) {
|
|
146
|
+
const out = new Map();
|
|
147
|
+
const list = [...hashes];
|
|
148
|
+
for (let i = 0; i < list.length; i += 500) {
|
|
149
|
+
const slice = list.slice(i, i + 500);
|
|
150
|
+
for (const row of all(`select hash, vector from piece_vectors where version = ? and hash in (${slice.map(() => "?").join(",")})`, version, ...slice)) out.set(row.hash, fromBlob(row.vector));
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
},
|
|
154
|
+
piece: (hash) => get("select hash, path, line, text from pieces where hash = ?", hash),
|
|
155
|
+
meta(key, value) {
|
|
156
|
+
if (value === undefined) return get("select value from meta where key = ?", key)?.value ?? null;
|
|
157
|
+
run("insert or replace into meta(key, value) values(?, ?)", key, String(value));
|
|
158
|
+
return value;
|
|
159
|
+
},
|
|
160
|
+
runSeen: (agent, sessionId) => get("select source_mtime from runs where agent = ? and session_id = ?", agent, sessionId)?.source_mtime ?? null,
|
|
161
|
+
putRun(row) {
|
|
162
|
+
run(`insert or replace into runs(agent, session_id, root, at, brief, edited, read, before, source_mtime) values(?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
163
|
+
row.agent, row.sessionId, row.root, row.at, row.brief.slice(0, 4_000), JSON.stringify(row.edited), JSON.stringify(row.read), JSON.stringify(row.before), row.sourceMtime);
|
|
164
|
+
},
|
|
165
|
+
runs: (root, since = 0) => all("select agent, session_id, root, at, brief, edited, read, before from runs where root = ? and at >= ? order by at", root, since)
|
|
166
|
+
.map((row) => ({ ...row, at: Number(row.at), edited: JSON.parse(row.edited), read: JSON.parse(row.read), before: JSON.parse(row.before) })),
|
|
167
|
+
runsToEmbed: (version, limit = 32) => all("select r.agent, r.session_id, r.brief from runs r left join run_vectors v on v.agent = r.agent and v.session_id = r.session_id and v.version = ? where v.agent is null limit ?", version, limit),
|
|
168
|
+
putRunVector: (agent, sessionId, version, vector) => run("insert or replace into run_vectors(agent, session_id, version, vector) values(?, ?, ?, ?)", agent, sessionId, version, toBlob(vector)),
|
|
169
|
+
runVectors: (root, version) => all("select r.agent, r.session_id, r.at, r.brief, r.edited, v.vector from runs r join run_vectors v on v.agent = r.agent and v.session_id = r.session_id and v.version = ? where r.root = ?", version, root)
|
|
170
|
+
.map((row) => ({ agent: row.agent, sessionId: row.session_id, at: Number(row.at), brief: row.brief, edited: JSON.parse(row.edited), vector: fromBlob(row.vector) })),
|
|
171
|
+
/** Recordings' windows and images (media.mjs): one row each, its vector beside the pieces'. */
|
|
172
|
+
mediaSeen: (file) => get("select max(mtime) as mtime from media where path = ?", file)?.mtime ?? null,
|
|
173
|
+
mediaPaths: () => all("select distinct path from media").map((row) => row.path),
|
|
174
|
+
dropMedia: (file) => run("delete from media where path = ?", file),
|
|
175
|
+
putMedia: (row) => run("insert or replace into media(hash, path, kind, start_ms, end_ms, mtime) values(?, ?, ?, ?, ?, ?)", row.hash, row.path, row.kind, row.startMs, row.endMs, row.mtime),
|
|
176
|
+
mediaVectors: (version) => all("select m.hash, m.path, m.kind, m.start_ms, m.end_ms, v.vector from media m join piece_vectors v on v.hash = m.hash and v.version = ?", version)
|
|
177
|
+
.map((row) => ({ hash: row.hash, path: row.path, kind: row.kind, startMs: Number(row.start_ms), endMs: Number(row.end_ms), vector: fromBlob(row.vector) })),
|
|
178
|
+
/** Full-text matches: { hash, rank } best first (bm25, lower is better), over every indexed piece. */
|
|
179
|
+
words(terms, limit = 400) {
|
|
180
|
+
if (!terms.length) return [];
|
|
181
|
+
const match = terms.map((term) => `"${term.replaceAll('"', '""')}"`).join(" OR ");
|
|
182
|
+
return all("select hash, bm25(pieces_fts, 0, 2.0, 1.0) as rank from pieces_fts where pieces_fts match ? order by rank limit ?", match, limit);
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const git = (cwd, args, input) => spawnSync("git", args, { cwd, input, encoding: "utf8", maxBuffer: 256 * 2 ** 20 });
|
|
188
|
+
|
|
189
|
+
/** The repository's files at a commit (or the working tree when commit is null): [{ path, blob, size }]. */
|
|
190
|
+
export function treeFiles(root, commit = null) {
|
|
191
|
+
if (commit) {
|
|
192
|
+
const listed = git(root, ["ls-tree", "-r", "-l", commit]);
|
|
193
|
+
if (listed.status !== 0) return [];
|
|
194
|
+
return listed.stdout.split("\n").filter(Boolean).map((line) => {
|
|
195
|
+
const [meta, rel] = line.split("\t");
|
|
196
|
+
const [, type, blob, size] = meta.split(/\s+/);
|
|
197
|
+
return type === "blob" ? { path: rel, blob, size: Number(size) || 0 } : null;
|
|
198
|
+
}).filter((row) => row && indexable(row.path, row.size));
|
|
199
|
+
}
|
|
200
|
+
// The working tree: git's index already holds the blob of every unchanged tracked file; only files changed on disk
|
|
201
|
+
// and new files git does not ignore are hashed, so a pass over a large repository costs about what `git status` does.
|
|
202
|
+
const staged = git(root, ["ls-files", "-s", "-z"]);
|
|
203
|
+
if (staged.status !== 0) return [];
|
|
204
|
+
const blobs = new Map();
|
|
205
|
+
for (const entry of staged.stdout.split("\0")) {
|
|
206
|
+
const tab = entry.indexOf("\t");
|
|
207
|
+
if (tab < 0) continue;
|
|
208
|
+
const [, blob, stage] = entry.slice(0, tab).split(" ");
|
|
209
|
+
if (stage === "0") blobs.set(entry.slice(tab + 1), blob);
|
|
210
|
+
}
|
|
211
|
+
const changed = git(root, ["diff", "--name-only", "-z"]).stdout.split("\0").filter(Boolean);
|
|
212
|
+
const untracked = git(root, ["ls-files", "--others", "--exclude-standard", "-z"]).stdout.split("\0").filter(Boolean);
|
|
213
|
+
const rows = [];
|
|
214
|
+
const rehash = [];
|
|
215
|
+
for (const rel of new Set([...blobs.keys(), ...untracked])) {
|
|
216
|
+
if (!indexable(rel, 0)) continue;
|
|
217
|
+
let stat;
|
|
218
|
+
try { stat = fs.statSync(path.join(root, rel)); } catch { continue; }
|
|
219
|
+
if (!stat.isFile() || stat.size > MAX_FILE) continue;
|
|
220
|
+
const row = { path: rel, size: stat.size, blob: blobs.get(rel) };
|
|
221
|
+
rows.push(row);
|
|
222
|
+
if (!row.blob || changed.includes(rel)) rehash.push(row);
|
|
223
|
+
}
|
|
224
|
+
if (rehash.length) {
|
|
225
|
+
const hashed = git(root, ["hash-object", "--stdin-paths"], rehash.map((row) => path.join(root, row.path)).join("\n"));
|
|
226
|
+
const out = hashed.status === 0 ? hashed.stdout.split("\n") : [];
|
|
227
|
+
rehash.forEach((row, i) => { row.blob = out[i]; });
|
|
228
|
+
}
|
|
229
|
+
return rows.filter((row) => /^[0-9a-f]{40}$/.test(row.blob || ""));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** File contents for blobs, read through one `git cat-file --batch`. */
|
|
233
|
+
export function readBlobs(root, blobs) {
|
|
234
|
+
const out = new Map();
|
|
235
|
+
if (!blobs.length) return out;
|
|
236
|
+
const result = spawnSync("git", ["cat-file", "--batch"], { cwd: root, input: blobs.join("\n") + "\n", maxBuffer: 2 * 2 ** 30 });
|
|
237
|
+
const buffer = result.stdout;
|
|
238
|
+
let at = 0;
|
|
239
|
+
while (at < buffer.length) {
|
|
240
|
+
const end = buffer.indexOf(10, at);
|
|
241
|
+
if (end < 0) break;
|
|
242
|
+
const [blob, type, size] = buffer.subarray(at, end).toString("utf8").split(" ");
|
|
243
|
+
if (type === "missing" || !size) { at = end + 1; continue; }
|
|
244
|
+
const length = Number(size);
|
|
245
|
+
const body = buffer.subarray(end + 1, end + 1 + length);
|
|
246
|
+
at = end + 1 + length + 1;
|
|
247
|
+
if (!body.includes(0) && !SECRET_TEXT.test(body.toString("latin1"))) out.set(blob, body.toString("utf8"));
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Adds the files' pieces to the atlas (new blobs only); returns how many blobs were new. */
|
|
253
|
+
export function addFiles(atlas, root, rows) {
|
|
254
|
+
const fresh = rows.filter((row) => !atlas.blob(row.blob));
|
|
255
|
+
const texts = readBlobs(root, [...new Set(fresh.map((row) => row.blob))]);
|
|
256
|
+
for (let i = 0; i < fresh.length; i += 500) atlas.batch(() => {
|
|
257
|
+
for (const row of fresh.slice(i, i + 500)) {
|
|
258
|
+
const text = texts.get(row.blob);
|
|
259
|
+
atlas.putBlob(row.blob, row.path, text === undefined ? [] : pieces(text));
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
return fresh.length;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Embeds every piece that has no vector yet, in batches, on the given lane. */
|
|
266
|
+
export async function embedMissing(atlas, inference, { lane = "background", version = ATLAS_VERSION, batch = 32, onProgress } = {}) {
|
|
267
|
+
let done = 0;
|
|
268
|
+
for (;;) {
|
|
269
|
+
const rows = atlas.toEmbed(version, batch);
|
|
270
|
+
if (!rows.length) return done;
|
|
271
|
+
const vectors = await inference.embed(rows.map((row) => ({ kind: "document", title: row.path, text: row.text })), { lane });
|
|
272
|
+
atlas.batch(() => rows.forEach((row, i) => atlas.putVector(row.hash, version, vectors[i])));
|
|
273
|
+
done += rows.length;
|
|
274
|
+
onProgress?.(done);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Files in scope ranked for a request, with the signals each one scored on. scope: [{ path, blob }]. Returns
|
|
280
|
+
* [{ path, blob, dense, lexical, named, best: { hash, line } }] sorted by the given scorer (default: dense+lexical+named).
|
|
281
|
+
*/
|
|
282
|
+
export async function searchFiles(atlas, inference, query, scope, { version = ATLAS_VERSION, lane = "interactive", queryVector = null, vectors = null, wordLimit = 400 } = {}) {
|
|
283
|
+
const byHash = new Map();
|
|
284
|
+
const files = scope.map((row) => ({ path: row.path, blob: row.blob, hashes: atlas.blob(row.blob) ?? [] }));
|
|
285
|
+
for (const file of files) for (const hash of file.hashes) { if (!byHash.has(hash)) byHash.set(hash, []); byHash.get(hash).push(file); }
|
|
286
|
+
const qv = queryVector ?? (await inference.embed([{ kind: "code-query", text: String(query).slice(0, 6_000) }], { lane }))[0];
|
|
287
|
+
const pieceVectors = vectors ?? atlas.vectors(byHash.keys(), version);
|
|
288
|
+
const terms = queryTerms(query);
|
|
289
|
+
const lexical = new Map();
|
|
290
|
+
for (const row of atlas.words(terms, wordLimit)) if (byHash.has(row.hash)) lexical.set(row.hash, -row.rank);
|
|
291
|
+
const lowered = String(query).toLowerCase();
|
|
292
|
+
return files.map((file) => {
|
|
293
|
+
let dense = -1, best = null, words = 0;
|
|
294
|
+
for (const hash of file.hashes) {
|
|
295
|
+
const vector = pieceVectors.get(hash);
|
|
296
|
+
if (vector) { let dot = 0; for (let i = 0; i < qv.length; i += 1) dot += qv[i] * vector[i]; if (dot > dense) { dense = dot; best = hash; } }
|
|
297
|
+
if (lexical.has(hash)) words = Math.max(words, lexical.get(hash));
|
|
298
|
+
}
|
|
299
|
+
const base = path.basename(file.path).toLowerCase();
|
|
300
|
+
const stem = base.replace(/\.[^.]+$/, "");
|
|
301
|
+
const named = lowered.includes(file.path.toLowerCase()) ? 2 : lowered.includes(base) ? 1.5 : stem.length > 3 && new RegExp(`\\b${stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(lowered) ? 1 : 0;
|
|
302
|
+
return { path: file.path, blob: file.blob, dense, lexical: words, named, best };
|
|
303
|
+
});
|
|
304
|
+
}
|