tryharness 0.4.2 → 0.5.0

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/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 = 3;
6
+ const SCHEMA_VERSION = 6;
5
7
  const INDEX_FORMAT_VERSION = 2;
6
8
  const { Database } = sqliteWasm;
7
9
 
@@ -49,21 +51,99 @@ export function openStore(file) {
49
51
  transcript,
50
52
  tokenize='unicode61'
51
53
  );
52
- create table if not exists handoffs (
54
+ create table if not exists board_tasks (
53
55
  id text primary key,
54
- target_agent text not null,
55
- target_session_id text,
56
- title text not null,
57
- body text not null,
58
- source text,
56
+ objective text not null,
57
+ brief text not null default '',
58
+ origin_agent text not null,
59
+ target_agent text,
60
+ status text not null,
61
+ parent_id text,
62
+ context_refs text not null default '[]',
63
+ claimed_by text,
64
+ claimed_at text,
65
+ result text,
66
+ reported_at text,
67
+ holder text,
68
+ version integer not null default 1,
69
+ run_workspace text,
70
+ run_requested_at text,
71
+ run_authorized_by text,
72
+ run_authorized_at text,
73
+ session_id text,
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,
79
+ created_at text not null,
80
+ updated_at text not null
81
+ );
82
+ create index if not exists board_tasks_status_idx on board_tasks(status, created_at desc);
83
+ create table if not exists board_agents (
84
+ agent text primary key,
85
+ last_seen_at text not null
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,
59
107
  created_at text not null,
60
- consumed_at text
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
61
128
  );
62
129
  `);
63
130
  const sessionColumns = new Set(db.all("pragma table_info(sessions)").map((column) => column.name));
64
131
  if (!sessionColumns.has("parser_version")) {
65
132
  db.exec("alter table sessions add column parser_version integer not null default 0");
66
133
  }
134
+ // Boards written before the lock existed: give every task a holder and a version.
135
+ const boardColumns = new Set(db.all("pragma table_info(board_tasks)").map((column) => column.name));
136
+ if (!boardColumns.has("holder")) {
137
+ db.exec(`alter table board_tasks add column holder text;
138
+ alter table board_tasks add column version integer not null default 1;
139
+ update board_tasks set holder = case status when 'open' then target_agent when 'claimed' then claimed_by when 'needs_input' then origin_agent else null end;`);
140
+ }
141
+ for (const column of ["run_workspace", "run_requested_at", "run_authorized_by", "run_authorized_at", "session_id", "lease_until"]) {
142
+ if (!boardColumns.has(column) && boardColumns.size) db.exec(`alter table board_tasks add column ${column} text`);
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 '[]'");
67
147
 
68
148
  const statements = [];
69
149
  const prepare = (sql) => {
@@ -117,6 +197,13 @@ export function openStore(file) {
117
197
  select agent, session_id, title, cwd, transcript from sessions;`);
118
198
  }
119
199
 
200
+ // Why a conditional write landed nowhere, with the task as it is now so the caller re-reads.
201
+ const conflict = (task, agent, expectedVersion, describe) => {
202
+ if (!task) return { ok: false, error: "task not found" };
203
+ if (expectedVersion != null && Number(expectedVersion) !== task.version) return { ok: false, error: `task changed since version ${expectedVersion}; it is now version ${task.version}`, conflict: true, task };
204
+ return { ok: false, error: describe(task), conflict: true, task };
205
+ };
206
+
120
207
  return {
121
208
  db,
122
209
  needsIndex(agent, sessionId, mtime, size) {
@@ -194,21 +281,306 @@ export function openStore(file) {
194
281
  order by case when session_id = ? then 0 else 1 end, indexed_at desc limit 1`, agent, sessionHint, sessionHint, sessionHint);
195
282
  return found || null;
196
283
  },
197
- addHandoff({ id, targetAgent, targetSessionId, title, body, source }) {
198
- execute("run", `insert or replace into handoffs(id, target_agent, target_session_id, title, body, source, created_at, consumed_at)
199
- values(?, ?, ?, ?, ?, ?, ?, null)`, id, targetAgent, targetSessionId || null, title, body, source || null, new Date().toISOString());
200
- },
201
- listHandoffs(targetAgent) {
202
- return targetAgent
203
- ? execute("all", `select id, target_agent, target_session_id, title, source, created_at
204
- from handoffs where target_agent = ? and consumed_at is null order by created_at desc limit 20`, targetAgent)
205
- : execute("all", `select id, target_agent, target_session_id, title, source, created_at
206
- from handoffs where consumed_at is null order by created_at desc limit 20`);
207
- },
208
- getHandoff(id, consume = false) {
209
- const row = execute("get", "select * from handoffs where id = ?", id);
210
- if (row && consume) execute("run", "update handoffs set consumed_at = ? where id = ?", new Date().toISOString(), id);
211
- return row || null;
284
+ // The task board: work any agent posts for another agent on this machine. Pull only; a
285
+ // task runs when its target reads the board and claims it, never on its own. Brief and
286
+ // result never leave the machine unless the claimer sends them somewhere itself.
287
+ //
288
+ // Every task has one holder, the only identity allowed to write it next, and a version
289
+ // that every write bumps. A write is one conditional update: it lands only for the holder,
290
+ // in the expected status, at the version the caller last saw when it names one. Anything
291
+ // else is a conflict that returns the current task, so two agents can never overwrite each
292
+ // other and a caller acting on a stale read finds out instead of winning.
293
+ postTask({ id = randomUUID(), objective, brief = "", originAgent, provenance = provenanceFor(originAgent), targetAgent = null, parentId = null, contextRefs = [], runWorkspace = null, runAuthorizedBy = null }) {
294
+ const now = new Date().toISOString();
295
+ execute("run", `insert into board_tasks(id, objective, brief, origin_agent, target_agent, status, parent_id, context_refs, holder, version,
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)`,
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 || []),
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" });
303
+ return this.getTask(id);
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
+ },
333
+ // Run authority is a separate write from the post: an agent may ask for a run, only the user grants it.
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
+ }
341
+ const now = new Date().toISOString();
342
+ const written = execute("run", `update board_tasks set run_authorized_by = @agent, run_authorized_at = @now, updated_at = @now, version = version + 1
343
+ where id = @id and status = 'open' and run_workspace is not null and run_authorized_at is null`, { id, agent, now });
344
+ if (written.changes === 1) return { ok: true, task: this.getTask(id) };
345
+ return conflict(this.getTask(id), agent, null, (task) => task.run_authorized_at ? "the run is already authorized" : !task.run_workspace ? "this task did not ask for a run" : `task is ${task.status}`);
346
+ },
347
+ // Tasks the daemon should run now: authorized, still open, for an agent installed here.
348
+ runnableTasks(agents) {
349
+ if (!agents.length) return [];
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(", ")})
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 || "[]") }));
353
+ },
354
+ startRun(id, agent) {
355
+ const now = new Date().toISOString();
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 });
357
+ return written.changes === 1 ? { ok: true, task: this.getTask(id) } : conflict(this.getTask(id), agent, null, (task) => `task is ${task.status}`);
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
+ },
363
+ setRunSession(id, sessionId) {
364
+ execute("run", "update board_tasks set session_id = ?, updated_at = ? where id = ?", sessionId, new Date().toISOString(), id);
365
+ },
366
+ // A daemon restart lost every agent process it was running; those outcomes are unknown, never assumed.
367
+ settleInterruptedRuns() {
368
+ const now = new Date().toISOString();
369
+ const affected = execute("all", "select id from board_tasks where status = 'running'").map((row) => row.id);
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 });
371
+ return affected;
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
+ },
513
+ touchAgent(agent) {
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());
515
+ },
516
+ agentsSeen() {
517
+ return execute("all", "select agent, last_seen_at from board_agents order by last_seen_at desc");
518
+ },
519
+ listTasks({ agent, status, all = false, limit = 20 } = {}) {
520
+ const clauses = [];
521
+ const params = { limit: Math.max(1, Math.min(100, Number(limit) || 20)) };
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')");
524
+ if (agent && !all) {
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)");
526
+ params.agent = agent;
527
+ }
528
+ const now = new Date().toISOString();
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 }));
537
+ },
538
+ getTask(id) {
539
+ const row = execute("get", "select * from board_tasks where id = ?", id);
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;
543
+ },
544
+ // An interactive claim carries a lease; when it lapses the claim shows stale and the origin
545
+ // or the user may release it. It is never released on its own. Daemon runs carry none.
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" };
548
+ const now = new Date().toISOString();
549
+ const leaseUntil = leaseMinutes == null ? null : new Date(Date.now() + Math.max(0, Number(leaseMinutes) || 0) * 60_000).toISOString();
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
551
+ where id = @id and status = 'open' and (holder is null or holder = @agent) and (@expected is null or version = @expected)`,
552
+ { id, agent, now, leaseUntil, expected: expectedVersion == null ? null : Number(expectedVersion) });
553
+ if (written.changes === 1) return { ok: true, task: this.getTask(id) };
554
+ return conflict(this.getTask(id), agent, expectedVersion, (task) => task.status !== "open" ? `task is ${task.status}${task.claimed_by ? ` by ${task.claimed_by}` : ""}` : `task is for ${task.holder}`);
555
+ },
556
+ reportTask(id, agent, { status, result = "" }, expectedVersion = null) {
557
+ if (!["done", "failed", "needs_input"].includes(status)) return { ok: false, error: "status must be done, failed or needs_input" };
558
+ const now = new Date().toISOString();
559
+ const written = execute("run", `update board_tasks set status = @status, result = @result, reported_at = @now, updated_at = @now, version = version + 1,
560
+ holder = case when @status = 'needs_input' then origin_agent else null end
561
+ where id = @id and status in ('claimed', 'running') and holder = @agent and (@expected is null or version = @expected)`,
562
+ { id, agent, status, result, now, expected: expectedVersion == null ? null : Number(expectedVersion) });
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) }; }
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}`);
565
+ },
566
+ releaseTask(id, agent, expectedVersion = null) {
567
+ const now = new Date().toISOString();
568
+ const written = execute("run", `update board_tasks set status = 'open', holder = target_agent, claimed_by = null, claimed_at = null, lease_until = null, result = null, reported_at = null, updated_at = @now, version = version + 1
569
+ where id = @id and status in ('claimed', 'needs_input') and (origin_agent = @agent or @agent = 'user') and (@expected is null or version = @expected)`,
570
+ { id, agent, now, expected: expectedVersion == null ? null : Number(expectedVersion) });
571
+ if (written.changes === 1) return { ok: true, task: this.getTask(id) };
572
+ return conflict(this.getTask(id), agent, expectedVersion, (task) => ["claimed", "needs_input"].includes(task.status) ? `only ${task.origin_agent} or the user can release this task` : `task is ${task.status}`);
573
+ },
574
+ answerTask(id, agent, answer, expectedVersion = null) {
575
+ if (!String(answer || "").trim()) return { ok: false, error: "an answer is needed" };
576
+ const now = new Date().toISOString();
577
+ const written = execute("run", `update board_tasks set status = 'claimed', holder = claimed_by, updated_at = @now, version = version + 1,
578
+ brief = brief || char(10) || char(10) || 'Question from ' || claimed_by || ': ' || coalesce(result, '') || char(10) || 'Answer from ' || @agent || ': ' || @answer,
579
+ result = null, reported_at = null
580
+ where id = @id and status = 'needs_input' and holder = @agent and (@expected is null or version = @expected)`,
581
+ { id, agent, answer, now, expected: expectedVersion == null ? null : Number(expectedVersion) });
582
+ if (written.changes === 1) { this.addContribution({ taskId: id, kind: "answer", author: agent, text: answer, source: "answer" }); return { ok: true, task: this.getTask(id) }; }
583
+ return conflict(this.getTask(id), agent, expectedVersion, (task) => task.status !== "needs_input" ? `task is ${task.status}` : `only ${task.holder} can answer this task`);
212
584
  },
213
585
  close() {
214
586
  for (const statement of statements) {
@@ -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.2",
4
- "description": "One-command local daemon for Harness memory, coding-agent history, handoffs, and delegated tasks.",
3
+ "version": "0.5.0",
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
  }