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/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 = 4;
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
- order by run_authorized_at asc`, ...agents).map((row) => ({ ...row, version: Number(row.version), context_refs: JSON.parse(row.context_refs || "[]") }));
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
- from board_tasks ${clauses.length ? `where ${clauses.join(" and ")}` : ""} order by created_at desc limit @limit`, params)
299
- .map((row) => ({ ...row, version: Number(row.version), stale: row.status === "claimed" && Boolean(row.lease_until) && row.lease_until < now }));
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
- return row ? { ...row, version: Number(row.version), context_refs: JSON.parse(row.context_refs || "[]"), stale: row.status === "claimed" && Boolean(row.lease_until) && row.lease_until < new Date().toISOString() } : null;
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() {
@@ -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.3",
4
- "description": "One command puts the agents on this machine in your Harness relay: memory, history, and work handed between them.",
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
  }