tryharness 0.4.3 → 0.5.1
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 +25 -6
- package/assets/opencode-plugin.js +14 -7
- package/assets/pi-extension.ts +22 -5
- package/cli.mjs +119 -9
- package/lib/adapters.mjs +48 -7
- package/lib/daemon.mjs +236 -17
- package/lib/embedder.mjs +156 -0
- package/lib/mcp-stdio.mjs +59 -5
- package/lib/notify.mjs +52 -0
- package/lib/nudge.mjs +42 -8
- package/lib/paths.mjs +1 -0
- package/lib/provenance.mjs +21 -0
- package/lib/runner.mjs +33 -2
- package/lib/runtime-install.mjs +19 -2
- package/lib/secrets.mjs +25 -0
- package/lib/store.mjs +255 -15
- package/lib/vectors.mjs +43 -0
- package/package.json +5 -3
package/lib/daemon.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import net from "node:net";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
5
6
|
import WebSocket from "ws";
|
|
6
7
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
7
8
|
import { detectAgents, run } from "./commands.mjs";
|
|
@@ -10,16 +11,37 @@ import { parseSessionFile } from "./parsers.mjs";
|
|
|
10
11
|
import { historyRoots, paths } from "./paths.mjs";
|
|
11
12
|
import { openStore } from "./store.mjs";
|
|
12
13
|
import { runAgentTask } from "./runner.mjs";
|
|
14
|
+
import { notificationFor, notifyDesktop } from "./notify.mjs";
|
|
15
|
+
import { secretReason } from "./secrets.mjs";
|
|
16
|
+
import { provenanceFor, provenanceLine } from "./provenance.mjs";
|
|
17
|
+
import { createEmbedder, EMBEDDING_VERSION, modelPresent } from "./embedder.mjs";
|
|
18
|
+
import { fromBlob, toBlob, topK } from "./vectors.mjs";
|
|
13
19
|
|
|
14
|
-
const DAEMON_VERSION = "
|
|
20
|
+
const DAEMON_VERSION = readJson(fileURLToPath(new URL("../package.json", import.meta.url)), {}).version;
|
|
15
21
|
const PROTOCOL_VERSION = 1;
|
|
16
22
|
const RESCAN_MS = 60_000;
|
|
17
23
|
const TASK_LOG_PAGE = 64_000;
|
|
18
24
|
|
|
25
|
+
function daemonAnswers(socketPath) {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
const probe = net.createConnection(socketPath);
|
|
28
|
+
probe.once("connect", () => { probe.destroy(); resolve(true); });
|
|
29
|
+
probe.once("error", () => resolve(false));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
19
33
|
export async function startDaemon() {
|
|
20
34
|
ensureDir(paths.home);
|
|
21
35
|
const config = readJson(paths.config, {});
|
|
22
36
|
if (!config.origin || !config.token) throw new Error("Harness is not paired. Run: npx tryharness");
|
|
37
|
+
// node-sqlite3-wasm locks with a directory that exists only during a transaction. A daemon
|
|
38
|
+
// killed mid-transaction leaves it behind and every later open fails "database is locked";
|
|
39
|
+
// with no daemon answering, nothing can hold it.
|
|
40
|
+
const lock = `${paths.database}.lock`;
|
|
41
|
+
if (fs.existsSync(lock) && !(await daemonAnswers(paths.socket))) {
|
|
42
|
+
fs.rmdirSync(lock);
|
|
43
|
+
appendLog(paths.log, "removed a stale database lock left by a stopped daemon");
|
|
44
|
+
}
|
|
23
45
|
const store = openStore(paths.database);
|
|
24
46
|
const detected = detectAgents();
|
|
25
47
|
const installedAgents = detected.filter((entry) => entry.path).map((entry) => entry.agent);
|
|
@@ -226,21 +248,101 @@ export async function startDaemon() {
|
|
|
226
248
|
try { if (fs.existsSync(paths.socket)) fs.unlinkSync(paths.socket); } catch {}
|
|
227
249
|
}
|
|
228
250
|
let socket = null;
|
|
251
|
+
const relayWaiters = new Map();
|
|
229
252
|
const send = (message) => {
|
|
230
253
|
try { if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); } catch {}
|
|
231
254
|
};
|
|
255
|
+
// A question for Harness over the bridge, answered by its bridge_response. Rejects when the
|
|
256
|
+
// daemon is not connected or Harness does not answer in time.
|
|
257
|
+
const askHarness = (type, input, timeoutMs = 15_000) => new Promise((resolve, reject) => {
|
|
258
|
+
if (!state.connected) { reject(new Error("the daemon is not connected to Harness")); return; }
|
|
259
|
+
const requestId = randomUUID();
|
|
260
|
+
const timer = setTimeout(() => { relayWaiters.delete(requestId); reject(new Error("Harness did not answer; is the daemon connected?")); }, timeoutMs);
|
|
261
|
+
relayWaiters.set(requestId, (response) => { clearTimeout(timer); resolve(response); });
|
|
262
|
+
send({ type, requestId, input });
|
|
263
|
+
});
|
|
264
|
+
// Agents that live on the server, not on any machine: a task for one of them goes up to
|
|
265
|
+
// Harness and lives there; reading or answering it comes back the same way.
|
|
266
|
+
const CLOUD_TARGETS = new Set(["bankr", "quotient", "muse"]);
|
|
267
|
+
const forwardToHarness = (request) => ({ pending: askHarness("board_call", { ...request, agent: String(request.agent) }).then((response) => (response.ok ? response : Promise.reject(new Error(response.error || "Harness refused the request")))) });
|
|
232
268
|
|
|
233
269
|
// Board callers identify themselves by agent name (the stdio MCP passes --agent; the CLI is
|
|
234
270
|
// "user"; the server names hosted callers). The name is an identity label for attribution
|
|
235
271
|
// and the holder check, not an execution grant: a run needs the user's authorization.
|
|
236
|
-
|
|
272
|
+
// One slash admits another person's agent, handle first: "ana/claude-code".
|
|
273
|
+
const validAgent = (value) => /^[a-z0-9][a-z0-9._-]{0,63}(\/[a-z0-9][a-z0-9._-]{0,63})?$/.test(String(value || ""));
|
|
274
|
+
// A thread's outbound leg: a message the person released (or auto-send let through) goes up
|
|
275
|
+
// the bridge; the server acknowledges it once the other side has it. Unacknowledged
|
|
276
|
+
// messages go again on reconnect, so a dropped socket never loses a reply.
|
|
277
|
+
const sendThreadMessage = (message) => send({ type: "thread_message", taskId: message.task_id, messageId: message.id, seq: message.seq, to: message.recipient, text: message.text, refs: message.refs, sentAt: message.sent_at });
|
|
237
278
|
// Every change to the board goes up the bridge as identity and status; the server mirrors it
|
|
238
279
|
// and the PiP follows from there. Briefs and results never ride this message.
|
|
280
|
+
// The person is told on their desktop about what waits on them, whether or not Harness is
|
|
281
|
+
// open: a run an agent asked for, a question, an unknown outcome, work posted for Harness, a
|
|
282
|
+
// claim gone stale. Once per change; off with `tryharness notify off`.
|
|
283
|
+
const lastSeen = new Map();
|
|
284
|
+
const notificationsOn = () => readJson(paths.config, {}).notifications !== false;
|
|
285
|
+
const notifyPerson = (task, previous) => {
|
|
286
|
+
if (!notificationsOn()) return;
|
|
287
|
+
const body = notificationFor(task, { previous });
|
|
288
|
+
if (body) notifyDesktop(body);
|
|
289
|
+
};
|
|
239
290
|
const notifyBoard = (task) => {
|
|
240
291
|
if (!task) return;
|
|
241
|
-
send({ type: "board_changed", task: { id: task.id, status: task.status, holder: task.holder, version: task.version, origin_agent: task.origin_agent, target_agent: task.target_agent, claimed_by: task.claimed_by, session_id: task.session_id || null, updated_at: task.updated_at } });
|
|
292
|
+
send({ type: "board_changed", task: { id: task.id, status: task.status, holder: task.holder, version: task.version, origin_agent: task.origin_agent, target_agent: task.target_agent, claimed_by: task.claimed_by, session_id: task.session_id || null, parent_id: task.parent_id || null, kind: task.kind || "task", updated_at: task.updated_at } });
|
|
293
|
+
notifyPerson(task, lastSeen.get(task.id) || null);
|
|
294
|
+
lastSeen.set(task.id, { status: task.status, run_workspace: task.run_workspace, run_authorized_at: task.run_authorized_at });
|
|
295
|
+
// The last part to finish closes its group, which is then announced too.
|
|
296
|
+
if (task.parent_id) { const group = store.refreshGroup(task.parent_id); if (group) notifyBoard(group); }
|
|
242
297
|
setImmediate(runBoard);
|
|
243
298
|
};
|
|
299
|
+
const staleTold = new Set();
|
|
300
|
+
const staleCheck = setInterval(() => {
|
|
301
|
+
if (!notificationsOn()) return;
|
|
302
|
+
for (const task of store.listTasks({ all: false, limit: 100 })) {
|
|
303
|
+
if (task.stale && !staleTold.has(task.id)) { staleTold.add(task.id); notifyDesktop(`${task.claimed_by}'s claim went stale on: ${String(task.objective).slice(0, 80)}. Release with tryharness board release ${String(task.id).slice(0, 8)}`); }
|
|
304
|
+
if (!task.stale) staleTold.delete(task.id);
|
|
305
|
+
}
|
|
306
|
+
}, 60_000);
|
|
307
|
+
// The context space's embedder: the model setup downloaded, loaded lazily, run in the
|
|
308
|
+
// background one contribution at a time so no request waits on it. Until the model is
|
|
309
|
+
// present, search answers from words and says so.
|
|
310
|
+
let embedder = null;
|
|
311
|
+
let embedding = false;
|
|
312
|
+
const embedderReady = () => modelPresent(paths.models);
|
|
313
|
+
const embedPending = async () => {
|
|
314
|
+
if (embedding || !embedderReady()) return;
|
|
315
|
+
embedding = true;
|
|
316
|
+
try {
|
|
317
|
+
embedder ||= createEmbedder({ modelsDir: paths.models });
|
|
318
|
+
for (const contribution of store.contributionsToEmbed(EMBEDDING_VERSION, 5)) {
|
|
319
|
+
const [vector] = await embedder.embedDocuments([contribution.text]);
|
|
320
|
+
store.storeVector(contribution.id, EMBEDDING_VERSION, toBlob(vector));
|
|
321
|
+
}
|
|
322
|
+
} catch (error) {
|
|
323
|
+
log(`embedding failed: ${error.message}`);
|
|
324
|
+
} finally {
|
|
325
|
+
embedding = false;
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
const embedTimer = setInterval(() => void embedPending(), 2_000);
|
|
329
|
+
const searchContext = async (taskId, query, k) => {
|
|
330
|
+
const text = store.searchContextText(taskId, query, k);
|
|
331
|
+
const context = store.contextCount(taskId);
|
|
332
|
+
if (!embedderReady()) return { retrieval: "text", embedder: "absent", context, entries: text.map(({ rank, ...entry }) => ({ ...entry, score: Number(entry.score.toFixed(3)) })) };
|
|
333
|
+
embedder ||= createEmbedder({ modelsDir: paths.models });
|
|
334
|
+
const queryVector = await embedder.embedQuery(query);
|
|
335
|
+
const rows = store.vectorsFor(taskId, EMBEDDING_VERSION).map((row) => ({ id: row.id, vector: fromBlob(row.vector) }));
|
|
336
|
+
const best = topK(queryVector, rows, k);
|
|
337
|
+
const byId = new Map(best.map((hit) => [hit.id, hit.score]));
|
|
338
|
+
// Words still count: a text hit with no vector yet rides along below the vector hits.
|
|
339
|
+
for (const hit of text) if (!byId.has(hit.id)) byId.set(hit.id, 0);
|
|
340
|
+
const entries = [...byId.entries()].map(([id, score]) => ({ ...store.getContribution(id), score: Number(score.toFixed(3)) })).filter((entry) => entry.id).sort((a, b) => b.score - a.score).slice(0, k);
|
|
341
|
+
return { retrieval: rows.length ? "vectors" : "text", embedder: "ready", context, entries: entries.map(({ seq, ...entry }) => entry) };
|
|
342
|
+
};
|
|
343
|
+
// Tasks written before the context space existed get their brief and result as entries.
|
|
344
|
+
for (const id of store.backfillContributions()) log(`context: backfilled task ${id}`);
|
|
345
|
+
|
|
244
346
|
const boardCommand = (request) => {
|
|
245
347
|
if (!validAgent(request.agent)) return { ok: false, error: "board requests need an agent name" };
|
|
246
348
|
const agent = String(request.agent);
|
|
@@ -252,22 +354,92 @@ export async function startDaemon() {
|
|
|
252
354
|
const objective = String(request.objective || "").trim();
|
|
253
355
|
if (!objective) return { ok: false, error: "a task needs an objective" };
|
|
254
356
|
if (request.targetAgent != null && !validAgent(request.targetAgent)) return { ok: false, error: "invalid target agent" };
|
|
357
|
+
// Provenance is code's: the local parent's label and what Harness sent from above the poster can only tighten it.
|
|
358
|
+
const parentTask = request.parentId ? store.getTask(String(request.parentId)) : null;
|
|
359
|
+
const claimed = request.provenance && typeof request.provenance === "object" && request.provenance.trust === "agent" ? request.provenance : null;
|
|
360
|
+
const upstream = parentTask ? { trust: claimed ? "agent" : parentTask.trust, chain: parentTask.origin_chain } : claimed;
|
|
361
|
+
const provenance = provenanceFor(agent, upstream);
|
|
362
|
+
if (CLOUD_TARGETS.has(String(request.targetAgent || "")) && !request.thread) {
|
|
363
|
+
if (!state.connected) return { ok: false, error: `${request.targetAgent} lives in Harness, and the daemon is not connected to it right now` };
|
|
364
|
+
return forwardToHarness({ ...request, provenance: upstream });
|
|
365
|
+
}
|
|
255
366
|
const runWorkspace = request.runWorkspace ? String(request.runWorkspace) : null;
|
|
256
367
|
if (runWorkspace && !path.isAbsolute(runWorkspace)) return { ok: false, error: "a run needs an absolute workspace directory" };
|
|
257
|
-
if (runWorkspace && !request.targetAgent) return { ok: false, error: "a run needs a target agent" };
|
|
368
|
+
if (runWorkspace && !request.targetAgent && !(Array.isArray(request.targetAgents) && request.targetAgents.length)) return { ok: false, error: "a run needs a target agent" };
|
|
258
369
|
const refs = Array.isArray(request.contextRefs) ? request.contextRefs.slice(0, 50) : [];
|
|
259
370
|
// The server may name the task (its run id) so the run record, the session layer and the board agree.
|
|
260
371
|
if (request.id != null && !validRunId(request.id)) return { ok: false, error: "invalid task id" };
|
|
261
372
|
if (request.id != null && store.getTask(String(request.id))) return { ok: false, error: "a task with this id already exists", task: store.getTask(String(request.id)) };
|
|
373
|
+
const thread = request.thread && typeof request.thread === "object" ? request.thread : null;
|
|
374
|
+
if (thread && (!validAgent(thread.with) || !["asker", "target"].includes(thread.role))) return { ok: false, error: "a thread needs the other side's agent and a role" };
|
|
375
|
+
// Several agents at once: one group task with a part per agent.
|
|
376
|
+
const targetAgents = Array.isArray(request.targetAgents) ? [...new Set(request.targetAgents.map(String))] : null;
|
|
377
|
+
if (targetAgents) {
|
|
378
|
+
if (thread) return { ok: false, error: "a thread with another person has one target" };
|
|
379
|
+
if (!targetAgents.length || targetAgents.length > 8 || !targetAgents.every(validAgent)) return { ok: false, error: "target_agents must be one to eight agent names" };
|
|
380
|
+
if (targetAgents.length === 1) request.targetAgent = targetAgents[0];
|
|
381
|
+
else {
|
|
382
|
+
const posted = store.postGroup({ ...(request.id != null ? { id: String(request.id) } : {}), objective: objective.slice(0, 500), brief: String(request.brief || "").slice(0, 200_000), originAgent: agent, provenance,
|
|
383
|
+
targetAgents, contextRefs: refs, runWorkspace, runAuthorizedBy: runWorkspace && agent === "user" ? "user" : null });
|
|
384
|
+
for (const part of posted.children) notifyBoard(store.getTask(part.id));
|
|
385
|
+
notifyBoard(posted.group);
|
|
386
|
+
return { ok: true, task: posted.group };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
262
389
|
const task = store.postTask({ id: request.id != null ? String(request.id) : randomUUID(), objective: objective.slice(0, 500), brief: String(request.brief || "").slice(0, 200_000),
|
|
263
|
-
originAgent: agent, targetAgent: request.targetAgent ? String(request.targetAgent) : null, parentId: request.parentId ? String(request.parentId) : null, contextRefs: refs,
|
|
264
|
-
runWorkspace, runAuthorizedBy: runWorkspace && agent === "user" ? "user" : null });
|
|
265
|
-
|
|
266
|
-
|
|
390
|
+
originAgent: agent, provenance, targetAgent: request.targetAgent ? String(request.targetAgent) : null, parentId: request.parentId ? String(request.parentId) : null, contextRefs: refs,
|
|
391
|
+
runWorkspace: thread ? null : runWorkspace, runAuthorizedBy: !thread && runWorkspace && agent === "user" ? "user" : null });
|
|
392
|
+
if (thread) store.openThread(task.id, { withAgent: String(thread.with), role: thread.role });
|
|
393
|
+
outcome = { ok: true, task: store.getTask(task.id), thread: thread ? store.getThread(task.id) : null };
|
|
394
|
+
} else if (request.command === "board.list") {
|
|
395
|
+
const tasks = store.listTasks({ agent, status: request.status ? String(request.status) : undefined, all: request.all === true, limit: request.limit });
|
|
396
|
+
return request.unread ? { ok: true, tasks, unread: store.unreadFor(agent) } : { ok: true, tasks };
|
|
397
|
+
} else if (request.command === "board.context.add") {
|
|
398
|
+
if (!store.getTask(id)) return { ok: false, error: "task not found" };
|
|
399
|
+
const kind = String(request.kind || "note");
|
|
400
|
+
if (!["note", "log", "link", "file", "repro"].includes(kind)) return { ok: false, error: "kind must be note, log, link, file or repro" };
|
|
401
|
+
const entry = store.addContribution({ taskId: id, kind, author: agent, text: String(request.text || "").slice(0, 20_000), source: request.source ? String(request.source).slice(0, 1_024) : null });
|
|
402
|
+
return entry ? { ok: true, entry, context: store.contextCount(id) } : { ok: false, error: "nothing to add" };
|
|
403
|
+
} else if (request.command === "board.context.search") {
|
|
404
|
+
if (!store.getTask(id)) return { ok: false, error: "task not found" };
|
|
405
|
+
return { ok: true, pending: searchContext(id, String(request.query || ""), Math.max(1, Math.min(20, Number(request.limit) || 8))) };
|
|
406
|
+
} else if (request.command === "board.thread.read") {
|
|
407
|
+
const thread = store.getThread(id);
|
|
408
|
+
return thread ? { ok: true, thread, task: store.getTask(id), messages: store.threadMessages(id) } : { ok: false, error: "no thread on this task" };
|
|
409
|
+
} else if (request.command === "board.thread.post") {
|
|
410
|
+
const text = String(request.text || "").slice(0, 50_000);
|
|
411
|
+
if (!text.trim()) return { ok: false, error: "a message needs text" };
|
|
412
|
+
const refs = (Array.isArray(request.refs) ? request.refs : []).filter((ref) => ref && ["log", "repro", "link", "file"].includes(ref.kind) && typeof ref.ref === "string").slice(0, 20);
|
|
413
|
+
const flagReason = secretReason(text) || secretReason(JSON.stringify(refs));
|
|
414
|
+
outcome = store.postMessage({ id: randomUUID(), taskId: id, sender: agent, text, refs, flagReason });
|
|
415
|
+
if (outcome.ok && flagReason) store.revertAutoSend(id);
|
|
416
|
+
if (outcome.ok && outcome.message.state === "sent") sendThreadMessage(outcome.message);
|
|
417
|
+
if (outcome.ok) { outcome = { ...outcome, task: store.getTask(id) }; if (flagReason) notifyDesktop(`A reply to ${outcome.message.recipient} was held: it looks like ${flagReason}. Review it in Harness or tryharness board show ${id.slice(0, 8)}`); else if (outcome.message.state === "held") notifyDesktop(`${agent} drafted a reply to ${outcome.message.recipient}; send it from Harness or tryharness board send ${id.slice(0, 8)}`); }
|
|
418
|
+
} else if (request.command === "board.thread.send" || request.command === "board.thread.discard") {
|
|
419
|
+
if (agent !== "user") return { ok: false, error: "only the user sends or discards a held reply" };
|
|
420
|
+
const messageId = String(request.messageId || "");
|
|
421
|
+
if (request.command === "board.thread.discard") { outcome = store.discardMessage(messageId); if (outcome.ok) outcome = { ...outcome, task: store.getTask(id) }; }
|
|
422
|
+
else { outcome = store.releaseMessage(messageId); if (outcome.ok) { sendThreadMessage(outcome.message); outcome = { ...outcome, task: store.getTask(id) }; } }
|
|
423
|
+
} else if (request.command === "board.thread.auto") {
|
|
424
|
+
if (agent !== "user") return { ok: false, error: "only the user changes auto-send" };
|
|
425
|
+
outcome = store.setAutoSend(id, request.on === true);
|
|
426
|
+
if (outcome.ok) outcome = { ...outcome, task: store.getTask(id) };
|
|
427
|
+
} else if (request.command === "board.thread.end") {
|
|
428
|
+
const task = store.getTask(id);
|
|
429
|
+
if (!task) return { ok: false, error: "task not found" };
|
|
430
|
+
if (agent !== "user" && task.claimed_by !== agent) return { ok: false, error: "only the user or the agent holding the task ends its thread" };
|
|
431
|
+
outcome = store.closeThread(id, String(request.outcome || "").slice(0, 20_000) || null);
|
|
432
|
+
if (outcome.ok && !outcome.already) send({ type: "thread_end", taskId: id, by: agent, outcome: outcome.thread.outcome });
|
|
433
|
+
}
|
|
267
434
|
else if (request.command === "board.get") {
|
|
268
435
|
const task = store.getTask(id);
|
|
269
|
-
|
|
270
|
-
|
|
436
|
+
if (task) return { ok: true, task };
|
|
437
|
+
return state.connected ? forwardToHarness(request) : { ok: false, error: "task not found" };
|
|
438
|
+
} else if (request.command === "board.children") return { ok: true, tasks: store.children(id).map((child) => store.getTask(child.id)).filter(Boolean) };
|
|
439
|
+
// A trace reads both homes, and only Harness can: the machine asks it.
|
|
440
|
+
else if (request.command === "board.trace") return state.connected ? forwardToHarness(request) : { ok: false, error: "a trace reads the whole chain on this machine and in Harness, and the daemon is not connected to Harness right now" };
|
|
441
|
+
else if (request.command === "board.agents") return { ok: true, agents: store.agentsSeen(), running: [...activeRuns.entries()].map(([runAgent, taskId]) => ({ agent: runAgent, id: taskId })) };
|
|
442
|
+
else if (["board.claim", "board.report", "board.answer", "board.release"].includes(request.command) && !store.getTask(id) && state.connected) return forwardToHarness(request);
|
|
271
443
|
else if (request.command === "board.claim") outcome = store.claimTask(id, agent, expected, Number.isInteger(request.leaseMinutes) ? request.leaseMinutes : 60);
|
|
272
444
|
else if (request.command === "board.report") outcome = store.reportTask(id, agent, { status: String(request.status || ""), result: String(request.result || "").slice(0, 200_000) }, expected);
|
|
273
445
|
else if (request.command === "board.answer") outcome = store.answerTask(id, agent, String(request.answer || "").slice(0, 20_000), expected);
|
|
@@ -295,16 +467,17 @@ export async function startDaemon() {
|
|
|
295
467
|
activeRuns.set(agent, task.id);
|
|
296
468
|
notifyBoard(started.task);
|
|
297
469
|
const refs = task.context_refs.length ? "\n\nContext:\n" + task.context_refs.map((ref) => `- ${ref.kind}: ${ref.ref}${ref.note ? ` (${ref.note})` : ""}`).join("\n") : "";
|
|
298
|
-
const
|
|
470
|
+
const notice = provenanceLine(task);
|
|
471
|
+
const brief = `${notice ? `${notice}\n\n` : ""}${task.objective}\n\n${task.brief}${refs}`.trim();
|
|
299
472
|
startTask({ runId: task.id, agent, title: task.objective.slice(0, 120), brief, cwd: task.run_workspace }, (kind, text, extra) => {
|
|
300
473
|
if (kind === "session" && text) { store.setRunSession(task.id, text); notifyBoard(store.getTask(task.id)); }
|
|
301
474
|
if (kind === "done" || kind === "failed") {
|
|
302
475
|
activeRuns.delete(agent);
|
|
476
|
+
if (extra?.usage) store.setRunUsage(task.id, extra.usage);
|
|
303
477
|
const reported = store.reportTask(task.id, agent, { status: kind, result: text || "" });
|
|
304
478
|
if (reported.ok) notifyBoard(reported.task);
|
|
305
479
|
else log(`board task ${task.id} finished ${kind} but could not be reported: ${reported.error}`);
|
|
306
480
|
}
|
|
307
|
-
void extra;
|
|
308
481
|
});
|
|
309
482
|
}
|
|
310
483
|
};
|
|
@@ -318,7 +491,9 @@ export async function startDaemon() {
|
|
|
318
491
|
if (newline < 0) return;
|
|
319
492
|
let request;
|
|
320
493
|
try { request = JSON.parse(buffer.slice(0, newline)); }
|
|
321
|
-
catch {
|
|
494
|
+
catch { request = null; }
|
|
495
|
+
// Anything but one JSON object is refused here; nothing a local process writes can reach a property read on it.
|
|
496
|
+
if (!request || typeof request !== "object" || Array.isArray(request)) { socket.end(JSON.stringify({ ok: false, error: "invalid request" }) + "\n"); return; }
|
|
322
497
|
const reply = (value) => socket.end(JSON.stringify(value) + "\n");
|
|
323
498
|
if (request.command === "status") reply({ ok: true, ...state, counts: store.counts() });
|
|
324
499
|
else if (request.command === "doctor") reply({ ok: true, status: state, database: paths.database, socket: paths.socket, log: paths.log });
|
|
@@ -326,8 +501,21 @@ export async function startDaemon() {
|
|
|
326
501
|
if (request.agent === "opencode" && request.sessionId) targetedOpencode.push(request);
|
|
327
502
|
void sync(request.agent && request.agent !== "all" ? request.agent : null);
|
|
328
503
|
reply({ ok: true, accepted: true, indexing: true });
|
|
329
|
-
} else if (String(request.command || "").startsWith("board."))
|
|
330
|
-
|
|
504
|
+
} else if (String(request.command || "").startsWith("board.")) {
|
|
505
|
+
// A bad request never takes the daemon down: it is answered as a refusal.
|
|
506
|
+
let result;
|
|
507
|
+
try { result = boardCommand(request); } catch (error) { log(`board ${request.command}: ${error.message}`); result = { ok: false, error: `the board could not answer: ${error.message}` }; }
|
|
508
|
+
if (result?.pending) result.pending.then((value) => reply({ ok: true, ...value }), (error) => reply({ ok: false, error: error.message }));
|
|
509
|
+
else reply(result);
|
|
510
|
+
}
|
|
511
|
+
else if (request.command === "relay.request") {
|
|
512
|
+
const requestId = randomUUID();
|
|
513
|
+
const timer = setTimeout(() => { relayWaiters.delete(requestId); reply({ ok: false, error: "Harness did not answer the request; is the daemon connected?" }); }, 15_000);
|
|
514
|
+
relayWaiters.set(requestId, (response) => { clearTimeout(timer); reply(response); });
|
|
515
|
+
// `socket` here is the control connection; the bridge's state is what matters.
|
|
516
|
+
if (!state.connected) { clearTimeout(timer); relayWaiters.delete(requestId); reply({ ok: false, error: "the daemon is not connected to Harness" }); }
|
|
517
|
+
else send({ type: "relay_request", requestId, input: { to: String(request.to || ""), fromAgent: String(request.fromAgent || ""), about: String(request.about || "").slice(0, 500), brief: String(request.brief || "").slice(0, 200_000), refs: Array.isArray(request.refs) ? request.refs.slice(0, 20) : [] } });
|
|
518
|
+
} else if (request.command === "tasks.get") {
|
|
331
519
|
const task = readTask(request.runId, request.offset);
|
|
332
520
|
reply(task ? { ok: true, task } : { ok: false, error: "task not found" });
|
|
333
521
|
} else if (request.command === "shutdown") {
|
|
@@ -385,6 +573,7 @@ export async function startDaemon() {
|
|
|
385
573
|
task.status = kind;
|
|
386
574
|
task.finishedAt = new Date().toISOString();
|
|
387
575
|
task.message = text || null;
|
|
576
|
+
if (extra?.usage) task.usage = extra.usage;
|
|
388
577
|
}
|
|
389
578
|
task.updatedAt = new Date().toISOString();
|
|
390
579
|
persistTask(task);
|
|
@@ -413,7 +602,8 @@ export async function startDaemon() {
|
|
|
413
602
|
state.connected = true;
|
|
414
603
|
state.lastError = null;
|
|
415
604
|
saveStatus();
|
|
416
|
-
send({ type: "hello", protocolVersion: PROTOCOL_VERSION, daemonVersion: DAEMON_VERSION, agents: installedAgents, health: state.agents, capabilities: ["sessions.search", "sessions.read", "tasks.status", "board"] });
|
|
605
|
+
send({ type: "hello", protocolVersion: PROTOCOL_VERSION, daemonVersion: DAEMON_VERSION, agents: installedAgents, health: state.agents, capabilities: ["sessions.search", "sessions.read", "tasks.status", "board", "threads"] });
|
|
606
|
+
for (const message of store.undeliveredMessages()) sendThreadMessage(message);
|
|
417
607
|
});
|
|
418
608
|
socket.on("message", (data) => {
|
|
419
609
|
let message;
|
|
@@ -440,10 +630,37 @@ export async function startDaemon() {
|
|
|
440
630
|
send({ type: "bridge_response", requestId: message.requestId, ok: true, result: { tasks: runIds.map(taskStatus).filter(Boolean) } });
|
|
441
631
|
return;
|
|
442
632
|
}
|
|
633
|
+
if (message.type === "thread_message") {
|
|
634
|
+
const input = message.input || {};
|
|
635
|
+
const received = store.receiveMessage({ id: String(input.messageId || ""), taskId: String(input.taskId || ""), seq: input.seq, sender: String(input.from || "someone"), text: String(input.text || "").slice(0, 50_000), refs: Array.isArray(input.refs) ? input.refs : [], sentAt: input.sentAt });
|
|
636
|
+
send({ type: "bridge_response", requestId: message.requestId, ok: received.ok, result: received.ok ? { acknowledged: input.messageId } : null, ...(received.ok ? {} : { error: received.error }) });
|
|
637
|
+
if (received.ok && !received.duplicate) {
|
|
638
|
+
const task = store.getTask(String(input.taskId));
|
|
639
|
+
notifyBoard(task);
|
|
640
|
+
if (notificationsOn()) notifyDesktop(`${input.from} replied on: ${String(task?.objective || "").slice(0, 80)}`);
|
|
641
|
+
}
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (message.type === "thread_ack") { store.markDelivered(String(message.messageId || "")); return; }
|
|
645
|
+
if (message.type === "bridge_response" && relayWaiters.has(message.requestId)) {
|
|
646
|
+
relayWaiters.get(message.requestId)(message.ok ? { ok: true, ...(message.result || {}) } : { ok: false, error: message.error || "Harness refused the request" });
|
|
647
|
+
relayWaiters.delete(message.requestId);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (message.type === "thread_end") {
|
|
651
|
+
const input = message.input || {};
|
|
652
|
+
const closed = store.closeThread(String(input.taskId || ""), String(input.outcome || "").slice(0, 20_000) || null);
|
|
653
|
+
send({ type: "bridge_response", requestId: message.requestId, ok: closed.ok, result: closed.ok ? { closed: input.taskId } : null });
|
|
654
|
+
if (closed.ok && !closed.already) { notifyBoard(closed.task); if (notificationsOn()) notifyDesktop(`${input.by} ended the thread on: ${String(closed.task?.objective || "").slice(0, 80)}`); }
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
443
657
|
if (message.type === "board") {
|
|
444
658
|
// A hosted caller's board call, identified by the name Harness assigns it. The same
|
|
445
659
|
// checks as the local control socket apply; the server persists nothing of it.
|
|
446
|
-
|
|
660
|
+
let result;
|
|
661
|
+
try { result = boardCommand({ ...(message.input || {}), command: String(message.input?.command || "") }); } catch (error) { result = { ok: false, error: `the board could not answer: ${error.message}` }; }
|
|
662
|
+
if (result?.pending) result.pending.then((value) => send({ type: "bridge_response", requestId: message.requestId, ok: true, result: { ok: true, ...value } }), (error) => send({ type: "bridge_response", requestId: message.requestId, ok: true, result: { ok: false, error: error.message } }));
|
|
663
|
+
else send({ type: "bridge_response", requestId: message.requestId, ok: true, result });
|
|
447
664
|
return;
|
|
448
665
|
}
|
|
449
666
|
|
|
@@ -484,6 +701,8 @@ export async function startDaemon() {
|
|
|
484
701
|
|
|
485
702
|
const shutdown = () => {
|
|
486
703
|
clearInterval(heartbeat);
|
|
704
|
+
clearInterval(staleCheck);
|
|
705
|
+
clearInterval(embedTimer);
|
|
487
706
|
clearInterval(rescan);
|
|
488
707
|
for (const watcher of watchers) watcher.close();
|
|
489
708
|
try { socket?.close(); } catch {}
|
package/lib/embedder.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
}
|