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/store.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import sqliteWasm from "node-sqlite3-wasm";
|
|
2
3
|
import { parseSession } from "./parsers.mjs";
|
|
4
|
+
import { provenanceFor } from "./provenance.mjs";
|
|
3
5
|
|
|
4
|
-
const SCHEMA_VERSION =
|
|
6
|
+
const SCHEMA_VERSION = 6;
|
|
5
7
|
const INDEX_FORMAT_VERSION = 2;
|
|
6
8
|
const { Database } = sqliteWasm;
|
|
7
9
|
|
|
@@ -70,6 +72,10 @@ export function openStore(file) {
|
|
|
70
72
|
run_authorized_at text,
|
|
71
73
|
session_id text,
|
|
72
74
|
lease_until text,
|
|
75
|
+
kind text not null default 'task',
|
|
76
|
+
trust text not null default 'agent',
|
|
77
|
+
origin_chain text not null default '[]',
|
|
78
|
+
usage text,
|
|
73
79
|
created_at text not null,
|
|
74
80
|
updated_at text not null
|
|
75
81
|
);
|
|
@@ -78,6 +84,48 @@ export function openStore(file) {
|
|
|
78
84
|
agent text primary key,
|
|
79
85
|
last_seen_at text not null
|
|
80
86
|
);
|
|
87
|
+
create table if not exists board_threads (
|
|
88
|
+
task_id text primary key,
|
|
89
|
+
with_agent text not null,
|
|
90
|
+
role text not null,
|
|
91
|
+
auto_send integer not null default 0,
|
|
92
|
+
opened_at text not null,
|
|
93
|
+
closed_at text,
|
|
94
|
+
outcome text
|
|
95
|
+
);
|
|
96
|
+
create table if not exists board_messages (
|
|
97
|
+
id text primary key,
|
|
98
|
+
task_id text not null,
|
|
99
|
+
seq integer not null,
|
|
100
|
+
direction text not null,
|
|
101
|
+
sender text not null,
|
|
102
|
+
recipient text not null,
|
|
103
|
+
text text not null,
|
|
104
|
+
refs text not null default '[]',
|
|
105
|
+
state text not null,
|
|
106
|
+
flag_reason text,
|
|
107
|
+
created_at text not null,
|
|
108
|
+
sent_at text,
|
|
109
|
+
delivered_at text
|
|
110
|
+
);
|
|
111
|
+
create index if not exists board_messages_task_idx on board_messages(task_id, seq);
|
|
112
|
+
create table if not exists board_contributions (
|
|
113
|
+
id text primary key,
|
|
114
|
+
task_id text not null,
|
|
115
|
+
seq integer not null,
|
|
116
|
+
kind text not null,
|
|
117
|
+
author text not null,
|
|
118
|
+
text text not null,
|
|
119
|
+
source text,
|
|
120
|
+
created_at text not null
|
|
121
|
+
);
|
|
122
|
+
create index if not exists board_contributions_task_idx on board_contributions(task_id, seq);
|
|
123
|
+
create virtual table if not exists board_contributions_fts using fts5(id unindexed, task_id unindexed, text, tokenize='unicode61');
|
|
124
|
+
create table if not exists board_vectors (
|
|
125
|
+
contribution_id text primary key,
|
|
126
|
+
version text not null,
|
|
127
|
+
vector blob not null
|
|
128
|
+
);
|
|
81
129
|
`);
|
|
82
130
|
const sessionColumns = new Set(db.all("pragma table_info(sessions)").map((column) => column.name));
|
|
83
131
|
if (!sessionColumns.has("parser_version")) {
|
|
@@ -93,6 +141,9 @@ export function openStore(file) {
|
|
|
93
141
|
for (const column of ["run_workspace", "run_requested_at", "run_authorized_by", "run_authorized_at", "session_id", "lease_until"]) {
|
|
94
142
|
if (!boardColumns.has(column) && boardColumns.size) db.exec(`alter table board_tasks add column ${column} text`);
|
|
95
143
|
}
|
|
144
|
+
if (!boardColumns.has("kind") && boardColumns.size) db.exec("alter table board_tasks add column kind text not null default 'task'");
|
|
145
|
+
if (!boardColumns.has("usage") && boardColumns.size) db.exec("alter table board_tasks add column usage text");
|
|
146
|
+
if (!boardColumns.has("trust") && boardColumns.size) db.exec("alter table board_tasks add column trust text not null default 'agent'; alter table board_tasks add column origin_chain text not null default '[]'");
|
|
96
147
|
|
|
97
148
|
const statements = [];
|
|
98
149
|
const prepare = (sql) => {
|
|
@@ -239,17 +290,54 @@ export function openStore(file) {
|
|
|
239
290
|
// in the expected status, at the version the caller last saw when it names one. Anything
|
|
240
291
|
// else is a conflict that returns the current task, so two agents can never overwrite each
|
|
241
292
|
// other and a caller acting on a stale read finds out instead of winning.
|
|
242
|
-
postTask({ id, objective, brief = "", originAgent, targetAgent = null, parentId = null, contextRefs = [], runWorkspace = null, runAuthorizedBy = null }) {
|
|
293
|
+
postTask({ id = randomUUID(), objective, brief = "", originAgent, provenance = provenanceFor(originAgent), targetAgent = null, parentId = null, contextRefs = [], runWorkspace = null, runAuthorizedBy = null }) {
|
|
243
294
|
const now = new Date().toISOString();
|
|
244
295
|
execute("run", `insert into board_tasks(id, objective, brief, origin_agent, target_agent, status, parent_id, context_refs, holder, version,
|
|
245
|
-
run_workspace, run_requested_at, run_authorized_by, run_authorized_at, created_at, updated_at)
|
|
246
|
-
values(@id, @objective, @brief, @originAgent, @targetAgent, 'open', @parentId, @contextRefs, @targetAgent, 1, @runWorkspace, @runRequestedAt, @runAuthorizedBy, @runAuthorizedAt, @now, @now)`,
|
|
296
|
+
run_workspace, run_requested_at, run_authorized_by, run_authorized_at, trust, origin_chain, created_at, updated_at)
|
|
297
|
+
values(@id, @objective, @brief, @originAgent, @targetAgent, 'open', @parentId, @contextRefs, @targetAgent, 1, @runWorkspace, @runRequestedAt, @runAuthorizedBy, @runAuthorizedAt, @trust, @chain, @now, @now)`,
|
|
247
298
|
{ id, objective, brief, originAgent, targetAgent, parentId, contextRefs: JSON.stringify(contextRefs), runWorkspace, runRequestedAt: runWorkspace ? now : null,
|
|
299
|
+
trust: provenance.trust === "user" ? "user" : "agent", chain: JSON.stringify(provenance.chain || []),
|
|
248
300
|
runAuthorizedBy: runWorkspace ? runAuthorizedBy : null, runAuthorizedAt: runWorkspace && runAuthorizedBy ? now : null, now });
|
|
301
|
+
if (brief) this.addContribution({ taskId: id, kind: "brief", author: originAgent, text: `${objective}\n\n${brief}`, source: "task" });
|
|
302
|
+
else this.addContribution({ taskId: id, kind: "brief", author: originAgent, text: objective, source: "task" });
|
|
249
303
|
return this.getTask(id);
|
|
250
304
|
},
|
|
305
|
+
// A task for several agents at once: one group task the person sees, one part per agent that
|
|
306
|
+
// each agent claims, runs and reports on its own. The group closes when the last part does,
|
|
307
|
+
// carrying every result; it cannot itself be claimed.
|
|
308
|
+
postGroup({ id = randomUUID(), objective, brief = "", originAgent, provenance = provenanceFor(originAgent), targetAgents, contextRefs = [], runWorkspace = null, runAuthorizedBy = null }) {
|
|
309
|
+
const now = new Date().toISOString();
|
|
310
|
+
execute("run", `insert into board_tasks(id, objective, brief, origin_agent, target_agent, status, parent_id, context_refs, holder, version, kind, trust, origin_chain, created_at, updated_at)
|
|
311
|
+
values(@id, @objective, @brief, @originAgent, null, 'open', null, @contextRefs, null, 1, 'group', @trust, @chain, @now, @now)`,
|
|
312
|
+
{ id, objective, brief, originAgent, contextRefs: JSON.stringify(contextRefs), trust: provenance.trust === "user" ? "user" : "agent", chain: JSON.stringify(provenance.chain || []), now });
|
|
313
|
+
this.addContribution({ taskId: id, kind: "brief", author: originAgent, text: brief ? `${objective}\n\n${brief}` : objective, source: "task" });
|
|
314
|
+
const children = targetAgents.map((targetAgent) => this.postTask({ objective, brief, originAgent, provenance, targetAgent, parentId: id, contextRefs, runWorkspace, runAuthorizedBy }));
|
|
315
|
+
return { group: this.getTask(id), children };
|
|
316
|
+
},
|
|
317
|
+
children(parentId) {
|
|
318
|
+
return execute("all", "select id, target_agent, status, claimed_by, holder, result, run_workspace, run_authorized_at, session_id, version from board_tasks where parent_id = ? order by created_at asc", parentId)
|
|
319
|
+
.map((row) => ({ ...row, version: Number(row.version) }));
|
|
320
|
+
},
|
|
321
|
+
refreshGroup(parentId) {
|
|
322
|
+
const group = this.getTask(parentId);
|
|
323
|
+
if (!group || group.kind !== "group" || ["done", "failed", "unknown"].includes(group.status)) return null;
|
|
324
|
+
const parts = this.children(parentId);
|
|
325
|
+
if (!parts.length || parts.some((part) => !["done", "failed", "unknown"].includes(part.status))) return null;
|
|
326
|
+
const status = parts.some((part) => part.status === "failed") ? "failed" : parts.some((part) => part.status === "unknown") ? "unknown" : "done";
|
|
327
|
+
const result = parts.map((part) => `${part.target_agent} (${part.status}): ${part.result || "no result"}`).join("\n\n");
|
|
328
|
+
const now = new Date().toISOString();
|
|
329
|
+
execute("run", "update board_tasks set status = ?, result = ?, holder = null, reported_at = ?, updated_at = ?, version = version + 1 where id = ?", status, result, now, now, parentId);
|
|
330
|
+
this.addContribution({ taskId: parentId, kind: "result", author: "harness", text: result, source: `group:${status}` });
|
|
331
|
+
return this.getTask(parentId);
|
|
332
|
+
},
|
|
251
333
|
// Run authority is a separate write from the post: an agent may ask for a run, only the user grants it.
|
|
252
334
|
authorizeRun(id, agent) {
|
|
335
|
+
if (this.getThread(id)) return { ok: false, error: "a task shared with another person's agent never runs on its own; work it in a session where you are present" };
|
|
336
|
+
const group = this.getTask(id);
|
|
337
|
+
if (group?.kind === "group") {
|
|
338
|
+
const outcomes = this.children(id).map((part) => this.authorizeRun(part.id, agent));
|
|
339
|
+
return outcomes.some((outcome) => outcome.ok) ? { ok: true, task: this.getTask(id), parts: outcomes } : (outcomes[0] || { ok: false, error: "the group has no parts" });
|
|
340
|
+
}
|
|
253
341
|
const now = new Date().toISOString();
|
|
254
342
|
const written = execute("run", `update board_tasks set run_authorized_by = @agent, run_authorized_at = @now, updated_at = @now, version = version + 1
|
|
255
343
|
where id = @id and status = 'open' and run_workspace is not null and run_authorized_at is null`, { id, agent, now });
|
|
@@ -260,13 +348,18 @@ export function openStore(file) {
|
|
|
260
348
|
runnableTasks(agents) {
|
|
261
349
|
if (!agents.length) return [];
|
|
262
350
|
return execute("all", `select * from board_tasks where status = 'open' and run_authorized_at is not null and target_agent in (${agents.map(() => "?").join(", ")})
|
|
263
|
-
|
|
351
|
+
and id not in (select task_id from board_threads)
|
|
352
|
+
order by run_authorized_at asc`, ...agents).map((row) => ({ ...row, version: Number(row.version), context_refs: JSON.parse(row.context_refs || "[]"), origin_chain: JSON.parse(row.origin_chain || "[]") }));
|
|
264
353
|
},
|
|
265
354
|
startRun(id, agent) {
|
|
266
355
|
const now = new Date().toISOString();
|
|
267
356
|
const written = execute("run", "update board_tasks set status = 'running', updated_at = @now, version = version + 1 where id = @id and status = 'claimed' and holder = @agent", { id, agent, now });
|
|
268
357
|
return written.changes === 1 ? { ok: true, task: this.getTask(id) } : conflict(this.getTask(id), agent, null, (task) => `task is ${task.status}`);
|
|
269
358
|
},
|
|
359
|
+
// What the run cost as its agent reported it; read back by the chain's trace.
|
|
360
|
+
setRunUsage(id, usage) {
|
|
361
|
+
execute("run", "update board_tasks set usage = ? where id = ?", JSON.stringify(usage), id);
|
|
362
|
+
},
|
|
270
363
|
setRunSession(id, sessionId) {
|
|
271
364
|
execute("run", "update board_tasks set session_id = ?, updated_at = ? where id = ?", sessionId, new Date().toISOString(), id);
|
|
272
365
|
},
|
|
@@ -277,6 +370,146 @@ export function openStore(file) {
|
|
|
277
370
|
if (affected.length) execute("run", `update board_tasks set status = 'unknown', holder = null, result = 'The daemon restarted during this run; its outcome is unknown. Check the workspace.', reported_at = @now, updated_at = @now, version = version + 1 where status = 'running'`, { now });
|
|
278
371
|
return affected;
|
|
279
372
|
},
|
|
373
|
+
// The task's context space: everything written about the task, by anyone on it, searchable
|
|
374
|
+
// by meaning once the daemon has embedded it and by words before that. Briefs, results,
|
|
375
|
+
// answers and thread messages land here on their own; notes, logs, links and file excerpts
|
|
376
|
+
// are added on purpose. Vectors are derived locally and never leave the machine.
|
|
377
|
+
addContribution({ id = randomUUID(), taskId, kind, author, text, source = null }) {
|
|
378
|
+
const body = String(text || "").trim();
|
|
379
|
+
if (!body) return null;
|
|
380
|
+
const seq = Number(execute("get", "select coalesce(max(seq), 0) as seq from board_contributions where task_id = ?", taskId).seq) + 1;
|
|
381
|
+
const now = new Date().toISOString();
|
|
382
|
+
execute("run", "insert or ignore into board_contributions(id, task_id, seq, kind, author, text, source, created_at) values(?, ?, ?, ?, ?, ?, ?, ?)", id, taskId, seq, kind, author, body.slice(0, 20_000), source, now);
|
|
383
|
+
execute("run", "insert into board_contributions_fts(id, task_id, text) values(?, ?, ?)", id, taskId, body.slice(0, 20_000));
|
|
384
|
+
return this.getContribution(id);
|
|
385
|
+
},
|
|
386
|
+
getContribution(id) {
|
|
387
|
+
const row = execute("get", "select * from board_contributions where id = ?", id);
|
|
388
|
+
return row ? { ...row, seq: Number(row.seq) } : null;
|
|
389
|
+
},
|
|
390
|
+
backfillContributions() {
|
|
391
|
+
const rows = execute("all", "select * from board_tasks t where not exists (select 1 from board_contributions c where c.task_id = t.id)");
|
|
392
|
+
for (const task of rows) {
|
|
393
|
+
this.addContribution({ taskId: task.id, kind: "brief", author: task.origin_agent, text: task.brief ? `${task.objective}\n\n${task.brief}` : task.objective, source: "task" });
|
|
394
|
+
if (task.result) this.addContribution({ taskId: task.id, kind: task.status === "needs_input" ? "question" : "result", author: task.claimed_by || task.target_agent || "agent", text: task.result, source: `report:${task.status}` });
|
|
395
|
+
}
|
|
396
|
+
return rows.map((task) => task.id);
|
|
397
|
+
},
|
|
398
|
+
contributionsToEmbed(version, limit = 5) {
|
|
399
|
+
return execute("all", `select c.* from board_contributions c left join board_vectors v on v.contribution_id = c.id
|
|
400
|
+
where v.contribution_id is null or v.version != ? order by c.created_at asc limit ?`, version, limit).map((row) => ({ ...row, seq: Number(row.seq) }));
|
|
401
|
+
},
|
|
402
|
+
storeVector(contributionId, version, blob) {
|
|
403
|
+
execute("run", "insert into board_vectors(contribution_id, version, vector) values(?, ?, ?) on conflict(contribution_id) do update set version = excluded.version, vector = excluded.vector", contributionId, version, blob);
|
|
404
|
+
},
|
|
405
|
+
vectorsFor(taskId, version) {
|
|
406
|
+
return execute("all", "select v.contribution_id as id, v.vector from board_vectors v join board_contributions c on c.id = v.contribution_id where c.task_id = ? and v.version = ?", taskId, version);
|
|
407
|
+
},
|
|
408
|
+
contextCount(taskId) {
|
|
409
|
+
const row = execute("get", "select count(*) as total, (select count(*) from board_vectors v join board_contributions c on c.id = v.contribution_id where c.task_id = ?) as embedded from board_contributions where task_id = ?", taskId, taskId);
|
|
410
|
+
return { total: Number(row.total), embedded: Number(row.embedded) };
|
|
411
|
+
},
|
|
412
|
+
// Words first: full-text candidates for a query on one task, best first.
|
|
413
|
+
searchContextText(taskId, query, k = 8) {
|
|
414
|
+
const terms = [...new Set(String(query).toLowerCase().match(/[\p{L}\p{N}_-]+/gu) || [])].filter((term) => term.length > 1).slice(0, 12);
|
|
415
|
+
if (!terms.length) return [];
|
|
416
|
+
const match = terms.map((term) => `"${term.replaceAll('"', '""')}"`).join(" OR ");
|
|
417
|
+
return execute("all", `select c.id, c.kind, c.author, c.text, c.source, c.created_at, bm25(board_contributions_fts) as rank
|
|
418
|
+
from board_contributions_fts f join board_contributions c on c.id = f.id
|
|
419
|
+
where board_contributions_fts match @match and f.task_id = @taskId order by rank limit @k`, { match, taskId, k: Math.max(1, Math.min(50, k)) })
|
|
420
|
+
.map((row) => ({ ...row, score: -Number(row.rank) }));
|
|
421
|
+
},
|
|
422
|
+
|
|
423
|
+
// A thread with another person's agent, on the task both daemons hold under the same id.
|
|
424
|
+
// Outbound messages start held (unless auto-send) or flagged (a secret), become sent when
|
|
425
|
+
// the person releases them, and delivered when the server acknowledges them. Inbound ones
|
|
426
|
+
// are received as data with the sender's handle on them.
|
|
427
|
+
openThread(taskId, { withAgent, role }) {
|
|
428
|
+
execute("run", "insert or ignore into board_threads(task_id, with_agent, role, auto_send, opened_at) values(?, ?, ?, 0, ?)", taskId, withAgent, role, new Date().toISOString());
|
|
429
|
+
return this.getThread(taskId);
|
|
430
|
+
},
|
|
431
|
+
getThread(taskId) {
|
|
432
|
+
const row = execute("get", "select * from board_threads where task_id = ?", taskId);
|
|
433
|
+
return row ? { ...row, auto_send: Number(row.auto_send) === 1 } : null;
|
|
434
|
+
},
|
|
435
|
+
threadMessages(taskId) {
|
|
436
|
+
return execute("all", "select * from board_messages where task_id = ? order by seq asc", taskId).map((row) => ({ ...row, seq: Number(row.seq), refs: JSON.parse(row.refs || "[]") }));
|
|
437
|
+
},
|
|
438
|
+
nextSeq(taskId) {
|
|
439
|
+
const row = execute("get", "select coalesce(max(seq), 0) as seq from board_messages where task_id = ?", taskId);
|
|
440
|
+
return Number(row.seq) + 1;
|
|
441
|
+
},
|
|
442
|
+
receiveMessage({ id, taskId, seq, sender, text, refs = [], sentAt }) {
|
|
443
|
+
const thread = this.getThread(taskId);
|
|
444
|
+
if (!thread) return { ok: false, error: "no thread on this task" };
|
|
445
|
+
if (thread.closed_at) return { ok: false, error: "the thread is closed" };
|
|
446
|
+
const existing = execute("get", "select id from board_messages where id = ?", id);
|
|
447
|
+
if (existing) return { ok: true, duplicate: true, message: this.threadMessages(taskId).find((message) => message.id === id) };
|
|
448
|
+
execute("run", `insert into board_messages(id, task_id, seq, direction, sender, recipient, text, refs, state, created_at, sent_at, delivered_at)
|
|
449
|
+
values(@id, @taskId, @seq, 'in', @sender, 'me', @text, @refs, 'received', @now, @sentAt, @now)`,
|
|
450
|
+
{ id, taskId, seq: Number.isInteger(seq) ? seq : this.nextSeq(taskId), sender, text, refs: JSON.stringify(refs), now: new Date().toISOString(), sentAt: sentAt || null });
|
|
451
|
+
execute("run", "update board_tasks set updated_at = ?, version = version + 1 where id = ?", new Date().toISOString(), taskId);
|
|
452
|
+
this.addContribution({ id: `msg:${id}`, taskId, kind: "message", author: sender, text, source: `message:${id}` });
|
|
453
|
+
return { ok: true, message: this.threadMessages(taskId).find((message) => message.id === id) };
|
|
454
|
+
},
|
|
455
|
+
postMessage({ id, taskId, sender, text, refs = [], flagReason = null }) {
|
|
456
|
+
const thread = this.getThread(taskId);
|
|
457
|
+
if (!thread) return { ok: false, error: "no thread on this task" };
|
|
458
|
+
if (thread.closed_at) return { ok: false, error: "the thread is closed" };
|
|
459
|
+
const state = flagReason ? "flagged" : thread.auto_send ? "sent" : "held";
|
|
460
|
+
const now = new Date().toISOString();
|
|
461
|
+
execute("run", `insert into board_messages(id, task_id, seq, direction, sender, recipient, text, refs, state, flag_reason, created_at, sent_at)
|
|
462
|
+
values(@id, @taskId, @seq, 'out', @sender, @recipient, @text, @refs, @state, @flagReason, @now, @sentAt)`,
|
|
463
|
+
{ id, taskId, seq: this.nextSeq(taskId), sender, recipient: thread.with_agent, text, refs: JSON.stringify(refs), state, flagReason, now, sentAt: state === "sent" ? now : null });
|
|
464
|
+
execute("run", "update board_tasks set updated_at = ?, version = version + 1 where id = ?", now, taskId);
|
|
465
|
+
this.addContribution({ id: `msg:${id}`, taskId, kind: "message", author: sender, text, source: `message:${id}` });
|
|
466
|
+
return { ok: true, message: this.threadMessages(taskId).find((message) => message.id === id) };
|
|
467
|
+
},
|
|
468
|
+
releaseMessage(messageId) {
|
|
469
|
+
const now = new Date().toISOString();
|
|
470
|
+
const written = execute("run", "update board_messages set state = 'sent', sent_at = ?, flag_reason = null where id = ? and state in ('held', 'flagged')", now, messageId);
|
|
471
|
+
if (written.changes !== 1) return { ok: false, error: "no held message with that id" };
|
|
472
|
+
const message = execute("get", "select * from board_messages where id = ?", messageId);
|
|
473
|
+
execute("run", "update board_tasks set updated_at = ?, version = version + 1 where id = ?", now, message.task_id);
|
|
474
|
+
return { ok: true, message: { ...message, seq: Number(message.seq), refs: JSON.parse(message.refs || "[]") } };
|
|
475
|
+
},
|
|
476
|
+
discardMessage(messageId) {
|
|
477
|
+
const written = execute("run", "delete from board_messages where id = ? and state in ('held', 'flagged')", messageId);
|
|
478
|
+
if (written.changes === 1) { execute("run", "delete from board_contributions where id = ?", `msg:${messageId}`); execute("run", "delete from board_contributions_fts where id = ?", `msg:${messageId}`); execute("run", "delete from board_vectors where contribution_id = ?", `msg:${messageId}`); }
|
|
479
|
+
return written.changes === 1 ? { ok: true } : { ok: false, error: "no held message with that id" };
|
|
480
|
+
},
|
|
481
|
+
markDelivered(messageId) {
|
|
482
|
+
execute("run", "update board_messages set state = 'delivered', delivered_at = ? where id = ? and state = 'sent'", new Date().toISOString(), messageId);
|
|
483
|
+
},
|
|
484
|
+
undeliveredMessages() {
|
|
485
|
+
return execute("all", "select * from board_messages where direction = 'out' and state = 'sent' order by created_at asc").map((row) => ({ ...row, seq: Number(row.seq), refs: JSON.parse(row.refs || "[]") }));
|
|
486
|
+
},
|
|
487
|
+
setAutoSend(taskId, on) {
|
|
488
|
+
const thread = this.getThread(taskId);
|
|
489
|
+
if (!thread) return { ok: false, error: "no thread on this task" };
|
|
490
|
+
execute("run", "update board_threads set auto_send = ? where task_id = ?", on ? 1 : 0, taskId);
|
|
491
|
+
return { ok: true, thread: this.getThread(taskId) };
|
|
492
|
+
},
|
|
493
|
+
// A flag on any message turns auto-send off again; the person switches it back on knowingly.
|
|
494
|
+
revertAutoSend(taskId) {
|
|
495
|
+
execute("run", "update board_threads set auto_send = 0 where task_id = ?", taskId);
|
|
496
|
+
},
|
|
497
|
+
closeThread(taskId, outcome) {
|
|
498
|
+
const thread = this.getThread(taskId);
|
|
499
|
+
if (!thread) return { ok: false, error: "no thread on this task" };
|
|
500
|
+
if (thread.closed_at) return { ok: true, thread, already: true };
|
|
501
|
+
const now = new Date().toISOString();
|
|
502
|
+
execute("run", "update board_threads set closed_at = ?, outcome = ? where task_id = ?", now, outcome || null, taskId);
|
|
503
|
+
execute("run", "update board_tasks set status = 'done', holder = null, result = coalesce(?, result), reported_at = ?, updated_at = ?, version = version + 1 where id = ? and status not in ('done', 'failed')", outcome || null, now, now, taskId);
|
|
504
|
+
return { ok: true, thread: this.getThread(taskId), task: this.getTask(taskId) };
|
|
505
|
+
},
|
|
506
|
+
// Messages another person's agent sent on this agent's tasks since it last read the board.
|
|
507
|
+
unreadFor(agent) {
|
|
508
|
+
const seen = execute("get", "select last_seen_at from board_agents where agent = ?", agent)?.last_seen_at || "1970-01-01T00:00:00.000Z";
|
|
509
|
+
return execute("all", `select m.task_id, t.objective, m.sender, count(*) as count from board_messages m join board_tasks t on t.id = m.task_id
|
|
510
|
+
where m.direction = 'in' and m.created_at > ? and (t.claimed_by = ? or t.target_agent = ? or t.origin_agent = ?) group by m.task_id, t.objective, m.sender`, seen, agent, agent, agent)
|
|
511
|
+
.map((row) => ({ ...row, count: Number(row.count) }));
|
|
512
|
+
},
|
|
280
513
|
touchAgent(agent) {
|
|
281
514
|
execute("run", "insert into board_agents(agent, last_seen_at) values(?, ?) on conflict(agent) do update set last_seen_at = excluded.last_seen_at", agent, new Date().toISOString());
|
|
282
515
|
},
|
|
@@ -286,25 +519,32 @@ export function openStore(file) {
|
|
|
286
519
|
listTasks({ agent, status, all = false, limit = 20 } = {}) {
|
|
287
520
|
const clauses = [];
|
|
288
521
|
const params = { limit: Math.max(1, Math.min(100, Number(limit) || 20)) };
|
|
289
|
-
if (status) { clauses.push("status = @status"); params.status = status; }
|
|
290
|
-
else if (!all) clauses.push("status in ('open', 'claimed', 'running', 'needs_input')");
|
|
522
|
+
if (status) { clauses.push("b.status = @status"); params.status = status; }
|
|
523
|
+
else if (!all) clauses.push("b.status in ('open', 'claimed', 'running', 'needs_input')");
|
|
291
524
|
if (agent && !all) {
|
|
292
|
-
clauses.push("(target_agent is null or target_agent = @agent or origin_agent = @agent or claimed_by = @agent)");
|
|
525
|
+
clauses.push("b.kind != 'group' and (b.target_agent is null or b.target_agent = @agent or b.origin_agent = @agent or b.claimed_by = @agent)");
|
|
293
526
|
params.agent = agent;
|
|
294
527
|
}
|
|
295
528
|
const now = new Date().toISOString();
|
|
296
|
-
return execute("all", `select id, objective, origin_agent, target_agent, status, parent_id, claimed_by, holder, version, lease_until,
|
|
297
|
-
run_workspace, run_authorized_at, session_id, created_at, updated_at
|
|
298
|
-
|
|
299
|
-
|
|
529
|
+
return execute("all", `select b.id, b.objective, b.origin_agent, b.target_agent, b.status, b.parent_id, b.kind, b.claimed_by, b.holder, b.version, b.lease_until,
|
|
530
|
+
b.run_workspace, b.run_authorized_at, b.session_id, b.trust, b.origin_chain, b.created_at, b.updated_at, th.with_agent as thread_with, th.closed_at as thread_closed_at,
|
|
531
|
+
(select count(*) from board_messages m where m.task_id = b.id and m.state in ('held', 'flagged')) as held_messages,
|
|
532
|
+
(select count(*) from board_tasks c where c.parent_id = b.id) as child_count,
|
|
533
|
+
(select count(*) from board_tasks c where c.parent_id = b.id and c.status in ('done', 'failed', 'unknown')) as child_done,
|
|
534
|
+
(select group_concat(c.target_agent, ', ') from board_tasks c where c.parent_id = b.id) as child_agents
|
|
535
|
+
from board_tasks b left join board_threads th on th.task_id = b.id ${clauses.length ? `where ${clauses.join(" and ")}` : ""} order by b.created_at desc limit @limit`, params)
|
|
536
|
+
.map((row) => ({ ...row, origin_chain: JSON.parse(row.origin_chain || "[]"), version: Number(row.version), held_messages: Number(row.held_messages), child_count: Number(row.child_count), child_done: Number(row.child_done), stale: row.status === "claimed" && Boolean(row.lease_until) && row.lease_until < now }));
|
|
300
537
|
},
|
|
301
538
|
getTask(id) {
|
|
302
539
|
const row = execute("get", "select * from board_tasks where id = ?", id);
|
|
303
|
-
|
|
540
|
+
if (!row) return null;
|
|
541
|
+
const task = { ...row, version: Number(row.version), context_refs: JSON.parse(row.context_refs || "[]"), origin_chain: JSON.parse(row.origin_chain || "[]"), usage: row.usage ? JSON.parse(row.usage) : null, stale: row.status === "claimed" && Boolean(row.lease_until) && row.lease_until < new Date().toISOString() };
|
|
542
|
+
return row.kind === "group" ? { ...task, children: this.children(id) } : task;
|
|
304
543
|
},
|
|
305
544
|
// An interactive claim carries a lease; when it lapses the claim shows stale and the origin
|
|
306
545
|
// or the user may release it. It is never released on its own. Daemon runs carry none.
|
|
307
546
|
claimTask(id, agent, expectedVersion = null, leaseMinutes = 60) {
|
|
547
|
+
if (this.getTask(id)?.kind === "group") return { ok: false, error: "a task for several agents is worked through its parts; claim the part posted for you" };
|
|
308
548
|
const now = new Date().toISOString();
|
|
309
549
|
const leaseUntil = leaseMinutes == null ? null : new Date(Date.now() + Math.max(0, Number(leaseMinutes) || 0) * 60_000).toISOString();
|
|
310
550
|
const written = execute("run", `update board_tasks set status = 'claimed', claimed_by = @agent, holder = @agent, claimed_at = @now, lease_until = @leaseUntil, updated_at = @now, version = version + 1
|
|
@@ -320,7 +560,7 @@ export function openStore(file) {
|
|
|
320
560
|
holder = case when @status = 'needs_input' then origin_agent else null end
|
|
321
561
|
where id = @id and status in ('claimed', 'running') and holder = @agent and (@expected is null or version = @expected)`,
|
|
322
562
|
{ id, agent, status, result, now, expected: expectedVersion == null ? null : Number(expectedVersion) });
|
|
323
|
-
if (written.changes === 1) return { ok: true, task: this.getTask(id) };
|
|
563
|
+
if (written.changes === 1) { this.addContribution({ taskId: id, kind: status === "needs_input" ? "question" : "result", author: agent, text: result, source: `report:${status}` }); return { ok: true, task: this.getTask(id) }; }
|
|
324
564
|
return conflict(this.getTask(id), agent, expectedVersion, (task) => ["claimed", "running"].includes(task.status) ? `task is held by ${task.holder}` : task.status === "open" ? "claim the task before reporting on it" : `task is ${task.status}`);
|
|
325
565
|
},
|
|
326
566
|
releaseTask(id, agent, expectedVersion = null) {
|
|
@@ -339,7 +579,7 @@ export function openStore(file) {
|
|
|
339
579
|
result = null, reported_at = null
|
|
340
580
|
where id = @id and status = 'needs_input' and holder = @agent and (@expected is null or version = @expected)`,
|
|
341
581
|
{ id, agent, answer, now, expected: expectedVersion == null ? null : Number(expectedVersion) });
|
|
342
|
-
if (written.changes === 1) return { ok: true, task: this.getTask(id) };
|
|
582
|
+
if (written.changes === 1) { this.addContribution({ taskId: id, kind: "answer", author: agent, text: answer, source: "answer" }); return { ok: true, task: this.getTask(id) }; }
|
|
343
583
|
return conflict(this.getTask(id), agent, expectedVersion, (task) => task.status !== "needs_input" ? `task is ${task.status}` : `only ${task.holder} can answer this task`);
|
|
344
584
|
},
|
|
345
585
|
close() {
|
package/lib/vectors.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Vector helpers for the daemon's context space: cosine over normalized 768-float vectors,
|
|
2
|
+
// a brute-force top-k, and the bytes that go in and out of SQLite. A task holds hundreds of
|
|
3
|
+
// entries, not millions, so a scan is the right index until one outgrows it.
|
|
4
|
+
|
|
5
|
+
export function cosine(a, b) {
|
|
6
|
+
if (a.length !== b.length) throw new Error(`vector lengths differ: ${a.length} and ${b.length}`);
|
|
7
|
+
let dot = 0;
|
|
8
|
+
let na = 0;
|
|
9
|
+
let nb = 0;
|
|
10
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
11
|
+
dot += a[i] * b[i];
|
|
12
|
+
na += a[i] * a[i];
|
|
13
|
+
nb += b[i] * b[i];
|
|
14
|
+
}
|
|
15
|
+
if (na === 0 || nb === 0) return 0;
|
|
16
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** rows: [{ id, vector: Float32Array }]; returns [{ id, score }] best first, at most k. */
|
|
20
|
+
export function topK(query, rows, k = 10) {
|
|
21
|
+
const scored = [];
|
|
22
|
+
for (const row of rows) {
|
|
23
|
+
if (!row?.vector || row.vector.length !== query.length) continue;
|
|
24
|
+
scored.push({ id: row.id, score: cosine(query, row.vector) });
|
|
25
|
+
}
|
|
26
|
+
scored.sort((x, y) => y.score - x.score);
|
|
27
|
+
return scored.slice(0, Math.max(0, k));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Float32Array -> Buffer for a SQLite blob column (little-endian, a copy). */
|
|
31
|
+
export function toBlob(vector) {
|
|
32
|
+
const out = Buffer.alloc(vector.length * 4);
|
|
33
|
+
for (let i = 0; i < vector.length; i += 1) out.writeFloatLE(vector[i], i * 4);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Buffer (or Uint8Array) from SQLite -> Float32Array (a copy). */
|
|
38
|
+
export function fromBlob(blob) {
|
|
39
|
+
const bytes = Buffer.isBuffer(blob) ? blob : Buffer.from(blob.buffer, blob.byteOffset, blob.byteLength);
|
|
40
|
+
const out = new Float32Array(bytes.length / 4);
|
|
41
|
+
for (let i = 0; i < out.length; i += 1) out[i] = bytes.readFloatLE(i * 4);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tryharness",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.1",
|
|
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"
|
|
7
7
|
},
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
"license": "MIT",
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"node-sqlite3-wasm": "0.8.60",
|
|
33
|
-
"ws": "^8.18.3"
|
|
33
|
+
"ws": "^8.18.3",
|
|
34
|
+
"@huggingface/tokenizers": "^0.1.3",
|
|
35
|
+
"onnxruntime-web": "1.26.0-dev.20260416-b7804b056c"
|
|
34
36
|
}
|
|
35
37
|
}
|