tryharness 0.7.26 → 0.7.29

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 CHANGED
@@ -68,10 +68,11 @@ on low battery and when the machine is hot.
68
68
  What they do:
69
69
 
70
70
  - **Briefs:** every brief a local coding agent gets carries a context pack (the files in its
71
- repository most likely needed, earlier sessions on them, and a recording's minute or a
72
- screenshot from your added folders when one is close) and the thread's task frame: what you
71
+ repository most likely needed, earlier sessions on them, and a page, passage, picture or
72
+ recording's minute from your added folders or the thread's files when one stands out) and the
73
+ thread's task frame: what you
73
74
  named resolved to file and line, the repository's state, other threads changing the same
74
- repository, and each run's receipt. Agents can call `search_code`, `search_media`,
75
+ repository, and each run's receipt. Agents can call `search_code`, `search_files`,
75
76
  `compare_screens` (two screenshots of UI work, before and after: what changed and which text)
76
77
  and `search_screen` (what you saw in tabs shared with Harness, asked of your own Harness window
77
78
  sealed over the `tryharness link` line, so Harness's server carries it unread; your Allow in
@@ -91,7 +92,7 @@ What they do:
91
92
  ```sh
92
93
  npx tryharness local # what is installed, where it runs, what is indexed, the last learning
93
94
  npx tryharness local learn # start the learner now
94
- npx tryharness local add ~/Recordings # a folder of recordings and screenshots agents can search
95
+ npx tryharness local add ~/Documents # a folder agents can search by what is in its files
95
96
  npx tryharness watch # watches and what each last read
96
97
  npx tryharness watch add "keeper heartbeat" --run "cat state/heartbeat.json" --age completedAt --above 900 --every 5m
97
98
  ```
package/cli.mjs CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  } from "./lib/localmodels.mjs";
30
30
  import {
31
31
  installService,
32
+ launcherMissing,
32
33
  serviceInfo,
33
34
  uninstallService,
34
35
  } from "./lib/service.mjs";
@@ -300,6 +301,11 @@ async function connect() {
300
301
  spin.start("Getting the newest desktop app");
301
302
  const shown = await desktop();
302
303
  spin.stop(shown.text, shown.ok ? 0 : 2);
304
+ // The app brings the daemon's launcher: the daemon starts through it from now on (service.mjs).
305
+ if (launcherMissing()) {
306
+ installService({ runtimeRoot });
307
+ await waitForDaemon().catch(() => {});
308
+ }
303
309
  }
304
310
  // The relay's core local models (localmodels.mjs) and the server that runs them come down with setup, so agents get
305
311
  // context packs, code search, the handoff firewall and images checked before they leave from the first task. Each file
@@ -361,6 +367,10 @@ async function connect() {
361
367
  );
362
368
  }
363
369
  ui.info("History indexes locally in the background");
370
+ if (!readJson(paths.config, {}).libraryOff)
371
+ ui.info(
372
+ `Your agents on this computer can search what is in your Desktop, Documents and Downloads. It stays on this computer${process.platform === "darwin" ? "; your Mac asks you once, when Harness first reads them" : ""}.\n${pc.dim("Change it: npx tryharness local off")}`
373
+ );
364
374
  ui.info(
365
375
  `Your coding agents start the tasks you ask for in folders they have worked in, up to 10 a day each, without a Start. Tasks another agent wrote wait for you.\n${pc.dim("Change it: npx tryharness board standing")}`
366
376
  );
@@ -426,9 +436,20 @@ async function status() {
426
436
 
427
437
  // The relay's local models: each one's job, licence, size and whether it is here, where they run, what the context engine
428
438
  // has indexed, and what the overnight learner last did. `tryharness local learn` starts the learner now; `local add
429
- // <folder>` and `local remove <folder>` name folders of recordings and screenshots for search_media and briefs.
439
+ // <folder>` and `local remove <folder>` name folders whose files agents search (search_files) and briefs draw on.
430
440
  async function local() {
431
441
  const root = path.join(paths.home, "local");
442
+ if (argv[0] === "on" || argv[0] === "off") {
443
+ const response = await daemonRequest({ command: `local.${argv[0]}` });
444
+ if (!response?.ok)
445
+ throw new Error(response?.error || "the daemon did not answer");
446
+ console.log(
447
+ argv[0] === "on"
448
+ ? `Your agents on this computer can search what is in ${response.folders.join(", ") || "your folders"}.`
449
+ : "Your folders are no longer searched, and what was read from them is forgotten. Your threads' files still are."
450
+ );
451
+ return;
452
+ }
432
453
  if (argv[0] === "add" || argv[0] === "remove") {
433
454
  const response = await daemonRequest({
434
455
  command: `local.${argv[0]}`,
@@ -438,7 +459,7 @@ async function local() {
438
459
  throw new Error(response?.error || "the daemon did not answer");
439
460
  console.log(
440
461
  argv[0] === "add"
441
- ? `Added. The relay reads the recordings and screenshots there in the background${response.downloading ? ", once the image and audio part of the search model has downloaded (555 MB)" : ""}.`
462
+ ? `Added. The relay reads the files there in the background: documents and PDFs now, pictures, recordings and videos${response.downloading ? " once the image and audio part of the search model has downloaded (555 MB)" : " too"}.`
442
463
  : "Removed."
443
464
  );
444
465
  return;
@@ -485,10 +506,14 @@ async function local() {
485
506
  ? `ranking: learned ${current.model.trainedAt}, held-out recall at 10 ${JSON.stringify(current.model.heldOut)}`
486
507
  : "ranking: the default until the learner has enough runs"
487
508
  );
488
- for (const folder of current.media?.folders || [])
489
- console.log(`recordings and screenshots: ${folder}`);
490
- if (current.media?.folders?.length)
491
- console.log(`media files read: ${current.media.files}`);
509
+ for (const folder of current.library?.folders || [])
510
+ console.log(`searched by what is in it: ${folder}`);
511
+ if (current.library?.files)
512
+ console.log(
513
+ `files read: ${current.library.files}${current.library.waiting ? ` (${current.library.waiting} parts waiting for the model)` : ""}`
514
+ );
515
+ for (const skip of (current.library?.skipped || []).slice(0, 5))
516
+ console.log(`not read: ${skip.path} (${skip.reason})`);
492
517
  if (current.learning)
493
518
  console.log(
494
519
  `last learning: ${current.learning.at} · ${current.learning.kept ? "kept a new model" : `kept the old one${current.learning.why ? ` (${current.learning.why})` : ""}`}`
@@ -1247,13 +1272,51 @@ async function swarm() {
1247
1272
  : `$${(r.estimated_micro_usd / 1e6).toFixed(4)} est.`;
1248
1273
  const progress = `${r.progress.runsFinished} runs done, ${r.progress.newResults} new, ${r.progress.roundsWithoutProgress} rounds idle`;
1249
1274
  console.log(
1250
- `${r.at} ${r.goal_id.slice(0, 8)} ${r.coordinator.padEnd(11)} ${(r.error ? "error" : (r.action ?? "-")).padEnd(10)} ${(r.agent ?? "").padEnd(11)} ${(r.loop ?? "").padEnd(13)} ${progress} ${r.elapsed_ms}ms ${cost}${r.error ? ` ${r.error}` : ""}`
1275
+ `${r.at} ${r.goal_id.slice(0, 8)} ${r.coordinator.padEnd(11)} ${(r.action ?? "error").padEnd(10)} ${(r.agent ?? "").padEnd(11)} ${(r.loop ?? "").padEnd(13)} ${progress} ${r.elapsed_ms}ms ${cost}${r.error ? ` ${r.action ? "after " : ""}${r.error}` : ""}`
1251
1276
  );
1252
1277
  }
1253
1278
  return;
1254
1279
  }
1280
+ // What a thread may spend of the person's Harness credits: dollars, or off.
1281
+ if (sub === "ceiling") {
1282
+ if (!argv[1] || argv[2] === undefined)
1283
+ throw new Error("usage: tryharness swarm ceiling <thread> <dollars|off>");
1284
+ const response = await daemonRequest({
1285
+ command: "board.swarm.ceiling",
1286
+ agent: "user",
1287
+ id: argv[1],
1288
+ usd: argv[2],
1289
+ });
1290
+ if (!response.ok)
1291
+ throw new Error(response.error || "the limit was not set");
1292
+ console.log(
1293
+ response.thread.ceilingUsd === null
1294
+ ? "This thread has no spending limit of its own."
1295
+ : `This thread may spend $${response.thread.ceilingUsd.toFixed(2)} of your Harness credits.`
1296
+ );
1297
+ return;
1298
+ }
1299
+ // How the coordinator held up, a day at a time: who decided each round, and how many no one could.
1300
+ if (sub === "health") {
1301
+ const response = await daemonRequest({
1302
+ command: "board.swarm.health",
1303
+ agent: "user",
1304
+ ...(argv[1] ? { id: argv[1] } : {}),
1305
+ days: Number(arg("--days", "14")),
1306
+ });
1307
+ if (!response.ok) throw new Error(response.error || "health unavailable");
1308
+ if (!response.days.length) {
1309
+ console.log("No rounds yet.");
1310
+ return;
1311
+ }
1312
+ for (const d of response.days)
1313
+ console.log(
1314
+ `${d.day} ${String(d.rounds).padStart(4)} rounds: Harness ${d.harness}, backup ${d.backup}, this computer ${d.local}, undecided ${d.undecided}`
1315
+ );
1316
+ return;
1317
+ }
1255
1318
  throw new Error(
1256
- "usage: tryharness swarm [rounds [<thread>] [--limit n] | coordinator [harness|local]]"
1319
+ "usage: tryharness swarm [rounds [<thread>] [--limit n] | health [<thread>] [--days n] | ceiling <thread> <dollars|off> | coordinator [harness|local]]"
1257
1320
  );
1258
1321
  }
1259
1322
 
@@ -1714,7 +1777,7 @@ async function t3() {
1714
1777
 
1715
1778
  function help() {
1716
1779
  console.log(
1717
- `tryharness ${VERSION}\n\nCommands:\n tryharness set up everything and start the daemon\n tryharness desktop open the Harness desktop app (setup opens it too; --no-desktop skips it)\n tryharness status show daemon, bridge, agent, and index health\n tryharness doctor diagnose the complete connection\n tryharness local [learn] the relay's local models, where they run, what is indexed and the last learning\n tryharness local add|remove <folder> a folder of recordings and screenshots agents can search by what was said or shown\n tryharness watch [add <name> --run <command> [--every 5m] [--field f | --age f | --count text] [--above n] [--below n] | start <id> | remove <id>]\n watches the relay checks and says out loud only when they are off\n tryharness sync [--agent] rescan local coding history in the background\n tryharness task <id> --follow\n follow a delegated agent run in this terminal\n tryharness board [--all] list open work on the machine's task board\n tryharness board post --for <agent> --objective <text> [--file <brief.md>]\n tryharness board show <id>\n tryharness board post --for codex --objective <text> --run --workspace <dir>\n the daemon runs it and reports back on the task\n tryharness board run <id> authorize a run an agent asked for\n tryharness board release <id>\n tryharness board agents when each agent last read the board\n tryharness board --watch follow the board in this terminal\n tryharness notify on|off desktop notifications for what waits on you\n tryharness board send <task> <message> send a held reply to another person's agent\n tryharness board snippet [<id> share|keep] code an agent elsewhere asked to see; nothing leaves until you share it\n tryharness board auto <task> on|off auto-send replies on that thread\n tryharness board end <task> --outcome <text>\n tryharness board context <task> <query> search everything written about a task\n tryharness goal set --objective <text> [--team a,b] [--workspace <dir>] a goal your agents work together in one room\n tryharness goal [list | show <id> | stop <id> | resume <id>]\n tryharness room <goal> | room post <goal> <text> [--ask agent,agent]\n tryharness swarm rounds [<thread>] what the coordinator saw, chose and cost each round\n tryharness swarm coordinator [harness|local] who coordinates your threads\n tryharness connect <handle/agent> --about <text> ask another person's agent to work an issue with yours, sealed to their computer\n tryharness peers the keys of other people's computers this one has worked with, to compare\n tryharness agents how Harness wakes each of your agents, and set up the rest\n tryharness agents setup <agent> | agents test <agent>\n tryharness agents cards what each of your agents is told about the others\n tryharness agents note <agent> <text> [--clear] your own note on an agent's card, told to every other agent\n tryharness agents models [<agent>] [--refresh] the models each coding agent lists and the one it runs on\n tryharness agents model <agent> <model> [--effort <level>] | auto | default the model it runs on; auto lets Harness pick per run\n tryharness agents routing "<text>" [--clear] how Auto picks models for your swarm\n tryharness mcp grok-bot print the MCP snippet that puts Grok Bot on the board\n tryharness link connect the browser you use Harness in to this computer, end to end (--print shows the link)\n tryharness phone pair show a QR code that pairs your phone with this computer\n tryharness phone list | revoke <id>\n tryharness t3 approve Harness in T3 Code once, so your messages go into its threads (t3 status | t3 forget)\n tryharness logs [--follow]\n tryharness disconnect\n`
1780
+ `tryharness ${VERSION}\n\nCommands:\n tryharness set up everything and start the daemon\n tryharness desktop open the Harness desktop app (setup opens it too; --no-desktop skips it)\n tryharness status show daemon, bridge, agent, and index health\n tryharness doctor diagnose the complete connection\n tryharness local [learn] the relay's local models, where they run, what is indexed and the last learning\n tryharness local on|off search over your Desktop, Documents and Downloads by what is in the files (on by default)\n tryharness local add|remove <folder> another folder to search the same way\n tryharness watch [add <name> --run <command> [--every 5m] [--field f | --age f | --count text] [--above n] [--below n] | start <id> | remove <id>]\n watches the relay checks and says out loud only when they are off\n tryharness sync [--agent] rescan local coding history in the background\n tryharness task <id> --follow\n follow a delegated agent run in this terminal\n tryharness board [--all] list open work on the machine's task board\n tryharness board post --for <agent> --objective <text> [--file <brief.md>]\n tryharness board show <id>\n tryharness board post --for codex --objective <text> --run --workspace <dir>\n the daemon runs it and reports back on the task\n tryharness board run <id> authorize a run an agent asked for\n tryharness board release <id>\n tryharness board agents when each agent last read the board\n tryharness board --watch follow the board in this terminal\n tryharness notify on|off desktop notifications for what waits on you\n tryharness board send <task> <message> send a held reply to another person's agent\n tryharness board snippet [<id> share|keep] code an agent elsewhere asked to see; nothing leaves until you share it\n tryharness board auto <task> on|off auto-send replies on that thread\n tryharness board end <task> --outcome <text>\n tryharness board context <task> <query> search everything written about a task\n tryharness goal set --objective <text> [--team a,b] [--workspace <dir>] a goal your agents work together in one room\n tryharness goal [list | show <id> | stop <id> | resume <id>]\n tryharness room <goal> | room post <goal> <text> [--ask agent,agent]\n tryharness swarm rounds [<thread>] what the coordinator saw, chose and cost each round\n tryharness swarm health [<thread>] who decided each day's rounds: Harness, a backup, or no one\n tryharness swarm ceiling <thread> <dollars|off> what a thread may spend of your Harness credits\n tryharness swarm coordinator [harness|local] who coordinates your threads\n tryharness connect <handle/agent> --about <text> ask another person's agent to work an issue with yours, sealed to their computer\n tryharness peers the keys of other people's computers this one has worked with, to compare\n tryharness agents how Harness wakes each of your agents, and set up the rest\n tryharness agents setup <agent> | agents test <agent>\n tryharness agents cards what each of your agents is told about the others\n tryharness agents note <agent> <text> [--clear] your own note on an agent's card, told to every other agent\n tryharness agents models [<agent>] [--refresh] the models each coding agent lists and the one it runs on\n tryharness agents model <agent> <model> [--effort <level>] | auto | default the model it runs on; auto lets Harness pick per run\n tryharness agents routing "<text>" [--clear] how Auto picks models for your swarm\n tryharness mcp grok-bot print the MCP snippet that puts Grok Bot on the board\n tryharness link connect the browser you use Harness in to this computer, end to end (--print shows the link)\n tryharness phone pair show a QR code that pairs your phone with this computer\n tryharness phone list | revoke <id>\n tryharness t3 approve Harness in T3 Code once, so your messages go into its threads (t3 status | t3 forget)\n tryharness logs [--follow]\n tryharness disconnect\n`
1718
1781
  );
1719
1782
  }
1720
1783
 
package/lib/asks.mjs ADDED
@@ -0,0 +1,260 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { nameOf } from "./members.mjs";
3
+
4
+ // An ask in a thread for an agent that is not started on this computer (Harness, Grok Bot, Muse, Bankr, Quotient,
5
+ // OpenClaw, Hermes) becomes a task in the thread, in the asker's name. No ask is dropped without a word: each is asked
6
+ // at once, or kept on the board (so a restart keeps it) with why said in the thread and to its asker, and asked when
7
+ // that clears: the agent's earlier ask in the thread ends (one at a time, so the agent reads its own answer first),
8
+ // the thread is resumed, or this computer reaches Harness again. One that cannot be asked is said in the thread, wakes
9
+ // the agent that asked with the reason, and reaches the coordinator's round as a failed job.
10
+ //
11
+ // Agents on Harness's server (Bankr, Quotient, Muse) keep their tasks there and take asks side by side. A send that may
12
+ // have reached Harness is looked up by its id before it goes again, so a retry never makes a second task.
13
+
14
+ const OPEN_RUN = ["open", "claimed", "running", "needs_input"];
15
+ // Sends lost on the way to Harness before the ask is given up and said as failed.
16
+ const TRIES = 5;
17
+
18
+ export function createAsks({
19
+ store,
20
+ deliver,
21
+ find,
22
+ connected,
23
+ onServer,
24
+ earlierWaits,
25
+ say,
26
+ }) {
27
+ const read = (key) => {
28
+ try {
29
+ return JSON.parse(store.meta(key) || "[]");
30
+ } catch {
31
+ return [];
32
+ }
33
+ };
34
+ const kept = (threadId) => read(`asks:${threadId}`);
35
+ const keep = (threadId, asks) =>
36
+ store.meta(`asks:${threadId}`, JSON.stringify(asks));
37
+ const change = (threadId, id, fields) =>
38
+ keep(
39
+ threadId,
40
+ kept(threadId).map((each) =>
41
+ each.id === id ? { ...each, ...fields } : each
42
+ )
43
+ );
44
+ const forget = (threadId, id) =>
45
+ keep(
46
+ threadId,
47
+ kept(threadId).filter((each) => each.id !== id)
48
+ );
49
+ const failures = (threadId) => read(`asks-failed:${threadId}`);
50
+ const sending = new Set();
51
+ // The ask being made right now: its asker hears what happens to it in its own call, so it is not woken for it.
52
+ let asking = null;
53
+
54
+ const busy = (threadId, agent) =>
55
+ store
56
+ .threadRuns(threadId)
57
+ .some(
58
+ (run) => run.target_agent === agent && OPEN_RUN.includes(run.status)
59
+ );
60
+ const objectiveOf = (thread, ask) =>
61
+ `Asked in "${thread.objective}": ${ask.reason}`.slice(0, 500);
62
+
63
+ // The ask as the thread names it, and as its asker is told.
64
+ const whose = (ask, toAsker) =>
65
+ toAsker || ask.asker === "user" ? "your ask" : `${nameOf(ask.asker)}'s ask`;
66
+ const WHY = {
67
+ turn: (thread, ask, toAsker) =>
68
+ [
69
+ `${nameOf(ask.agent)} has an earlier ask open in this thread; ${whose(ask, toAsker)} goes to it when that one ends.`,
70
+ earlierWaits(thread.id, ask.agent),
71
+ ]
72
+ .filter(Boolean)
73
+ .join(" "),
74
+ paused: (thread, ask, toAsker) =>
75
+ `This thread is paused; ${whose(ask, toAsker)} goes to ${nameOf(ask.agent)} when it is resumed.`,
76
+ offline: (thread, ask, toAsker) =>
77
+ `${nameOf(ask.agent)} runs on Harness's server, and this computer is not connected to it right now; ${whose(ask, toAsker)} goes when it reconnects.`,
78
+ lost: (thread, ask, toAsker) => {
79
+ const text = `${whose(ask, toAsker)} to ${nameOf(ask.agent)} may not have reached Harness (${ask.lost}); it is sent again within a minute or when this computer reconnects.`;
80
+ return text[0].toUpperCase() + text.slice(1);
81
+ },
82
+ };
83
+ // The thread hears why an ask waits once for each reason, however often the reason comes back. Offline after a send
84
+ // that may not have reached Harness is said already: it goes again on reconnect.
85
+ const said = (thread, snapshot, why) => {
86
+ const ask =
87
+ kept(thread.id).find((each) => each.id === snapshot.id) ?? snapshot;
88
+ const before = ask.said ?? [];
89
+ if (ask.told !== why) change(thread.id, ask.id, { told: why });
90
+ if (before.includes(why) || (why === "offline" && ask.lost)) return;
91
+ change(thread.id, ask.id, { said: [...before, why] });
92
+ say(thread, WHY[why](thread, ask, false));
93
+ };
94
+
95
+ const fail = (thread, ask, why) => {
96
+ const reason = String(why || "the board refused it").replace(/\.$/, "");
97
+ store.meta(
98
+ `asks-failed:${thread.id}`,
99
+ JSON.stringify(
100
+ [
101
+ ...failures(thread.id),
102
+ {
103
+ agent: ask.agent,
104
+ objective: objectiveOf(thread, ask),
105
+ result: `Could not be asked: ${reason}`.slice(0, 4_000),
106
+ },
107
+ ].slice(-5)
108
+ )
109
+ );
110
+ say(thread, `${nameOf(ask.agent)} could not be asked: ${reason}.`, {
111
+ wake: ask.asker === "user" || ask.id === asking ? null : ask.asker,
112
+ });
113
+ return `${nameOf(ask.agent)} could not be asked: ${reason}.`;
114
+ };
115
+
116
+ // Why an ask is not sent now, or null to send it.
117
+ const waitOf = (thread, ask, ahead) => {
118
+ if (thread.status === "paused") return "paused";
119
+ if (onServer(ask.agent)) return connected() ? null : "offline";
120
+ return ahead || busy(thread.id, ask.agent) ? "turn" : null;
121
+ };
122
+
123
+ // Returns why the ask could not be asked, when that is known at once.
124
+ const send = (thread, ask) => {
125
+ if (!onServer(ask.agent)) {
126
+ forget(thread.id, ask.id);
127
+ const sent = deliver(thread, ask);
128
+ if (sent?.pending)
129
+ sent.pending.catch((error) => fail(thread, ask, error?.message));
130
+ else if (!sent?.ok) return fail(thread, ask, sent?.error);
131
+ return null;
132
+ }
133
+ sending.add(ask.id);
134
+ void sendToServer(thread, ask).finally(() => sending.delete(ask.id));
135
+ return null;
136
+ };
137
+ const sendToServer = async (thread, ask) => {
138
+ try {
139
+ if (ask.sent && (await find(ask.id))) return forget(thread.id, ask.id);
140
+ change(thread.id, ask.id, { sent: true });
141
+ const sent = deliver(thread, { ...ask, resend: Boolean(ask.sent) });
142
+ if (sent?.pending) await sent.pending;
143
+ else if (!sent?.ok)
144
+ throw Object.assign(new Error(sent?.error), { refused: connected() });
145
+ forget(thread.id, ask.id);
146
+ } catch (error) {
147
+ const tries = (ask.tries ?? 0) + 1;
148
+ if (error?.refused || tries >= TRIES) {
149
+ forget(thread.id, ask.id);
150
+ return fail(thread, ask, error?.message);
151
+ }
152
+ const lost = String(error?.message || "no answer").replace(/\.$/, "");
153
+ change(thread.id, ask.id, { tries, lost });
154
+ said(thread, ask, "lost");
155
+ }
156
+ };
157
+
158
+ // Sends what can go now of the asks kept for one agent in a thread, oldest first, and says why the rest wait. Returns why
159
+ // `fresh` could not be asked, when it failed here.
160
+ const pump = (threadId, agent, fresh = null) => {
161
+ const thread = store.getTask(threadId);
162
+ if (!thread || thread.kind !== "goal") return null;
163
+ let refused = null;
164
+ let ahead = false;
165
+ for (const ask of kept(threadId).filter((each) => each.agent === agent)) {
166
+ if (sending.has(ask.id)) {
167
+ ahead = true;
168
+ continue;
169
+ }
170
+ if (!["open", "paused"].includes(thread.status)) {
171
+ forget(threadId, ask.id);
172
+ const told = fail(thread, ask, `the thread is ${thread.status}`);
173
+ if (ask.id === fresh) refused = told;
174
+ continue;
175
+ }
176
+ const why = waitOf(thread, ask, ahead);
177
+ if (why) {
178
+ ahead = true;
179
+ said(thread, ask, why);
180
+ continue;
181
+ }
182
+ const failed = send(thread, ask);
183
+ if (ask.id === fresh && failed) refused = failed;
184
+ if (kept(threadId).some((each) => each.id === ask.id)) ahead = true;
185
+ }
186
+ return refused;
187
+ };
188
+ const agentsWaiting = (threadId) => [
189
+ ...new Set(kept(threadId).map((each) => each.agent)),
190
+ ];
191
+
192
+ return {
193
+ // Returns what the asker is told when its ask does not go at once, else null.
194
+ ask(threadId, agent, { reason, asker, words }) {
195
+ const ask = {
196
+ id: randomUUID(),
197
+ agent,
198
+ reason,
199
+ asker,
200
+ words: String(words ?? reason),
201
+ at: new Date().toISOString(),
202
+ };
203
+ keep(threadId, [...kept(threadId), ask]);
204
+ asking = ask.id;
205
+ let refused = null;
206
+ try {
207
+ refused = pump(threadId, agent, ask.id);
208
+ } finally {
209
+ asking = null;
210
+ }
211
+ const waiting = kept(threadId).find((each) => each.id === ask.id);
212
+ const thread = store.getTask(threadId);
213
+ return (
214
+ refused ??
215
+ (waiting?.told && thread
216
+ ? WHY[waiting.told](thread, waiting, true)
217
+ : null)
218
+ );
219
+ },
220
+ // Refused before it became an ask (a coding agent not installed here): said like any ask that could not go.
221
+ refuse(threadId, agent, { reason, asker }, why) {
222
+ const thread = store.getTask(threadId);
223
+ if (!thread) return null;
224
+ const ask = { id: randomUUID(), agent, reason, asker };
225
+ asking = ask.id;
226
+ try {
227
+ return fail(thread, ask, why);
228
+ } finally {
229
+ asking = null;
230
+ }
231
+ },
232
+ // The agent's ask in the thread ended, or the thread changed: what waited for it goes now.
233
+ changed(threadId, agent = null) {
234
+ for (const each of agent ? [agent] : agentsWaiting(threadId))
235
+ pump(threadId, each);
236
+ },
237
+ // This computer reached Harness again, or a minute passed: everything kept goes now if it can.
238
+ retry() {
239
+ for (const thread of store.goals())
240
+ for (const agent of agentsWaiting(thread.id)) pump(thread.id, agent);
241
+ },
242
+ // The person stopped the thread: what waited stops with it.
243
+ stopped(threadId) {
244
+ keep(threadId, []);
245
+ },
246
+ // Asks kept for the agent, as the coordinator's round lists work that is already the agent's.
247
+ waitingFor(threadId, agent) {
248
+ const thread = store.getTask(threadId);
249
+ return thread
250
+ ? kept(threadId)
251
+ .filter((each) => each.agent === agent)
252
+ .map((each) => ({ objective: objectiveOf(thread, each) }))
253
+ : [];
254
+ },
255
+ // Asks that could not be asked, as the round's failed jobs.
256
+ failed(threadId) {
257
+ return failures(threadId);
258
+ },
259
+ };
260
+ }
package/lib/atlas.mjs CHANGED
@@ -136,9 +136,13 @@ export function openAtlas(file) {
136
136
  create index if not exists runs_root_at on runs(root, at);
137
137
  create table if not exists run_vectors (agent text not null, session_id text not null, version text not null, vector blob not null, primary key(agent, session_id, version));
138
138
  create table if not exists meta (key text primary key, value text not null);
139
- create table if not exists media (hash text primary key, path text not null, kind text not null, start_ms integer not null, end_ms integer not null, mtime real not null);
140
- create index if not exists media_path on media(path);
139
+ create table if not exists library (file text not null, vec text not null, source text not null, name text not null,
140
+ kind text not null, modality text not null, loc real not null, loc_end real, mtime real not null, embed integer not null,
141
+ primary key(file, vec));
142
+ create index if not exists library_source on library(source);
143
+ create virtual table if not exists library_fts using fts5(file unindexed, vec unindexed, name, text);
141
144
  `);
145
+ migrateMedia(db);
142
146
  // A blob's pieces never change, so lookups are kept (a few MB for a large repository and its recent versions).
143
147
  const blobPieces = new Map();
144
148
  const run = (sql, ...values) =>
@@ -327,35 +331,8 @@ export function openAtlas(file) {
327
331
  edited: JSON.parse(row.edited),
328
332
  vector: fromBlob(row.vector),
329
333
  })),
330
- /** Recordings' windows and images (media.mjs): one row each, its vector beside the pieces'. */
331
- mediaSeen: (file) =>
332
- get("select max(mtime) as mtime from media where path = ?", file)
333
- ?.mtime ?? null,
334
- mediaPaths: () =>
335
- all("select distinct path from media").map((row) => row.path),
336
- dropMedia: (file) => run("delete from media where path = ?", file),
337
- putMedia: (row) =>
338
- run(
339
- "insert or replace into media(hash, path, kind, start_ms, end_ms, mtime) values(?, ?, ?, ?, ?, ?)",
340
- row.hash,
341
- row.path,
342
- row.kind,
343
- row.startMs,
344
- row.endMs,
345
- row.mtime
346
- ),
347
- mediaVectors: (version) =>
348
- all(
349
- "select m.hash, m.path, m.kind, m.start_ms, m.end_ms, v.vector from media m join piece_vectors v on v.hash = m.hash and v.version = ?",
350
- version
351
- ).map((row) => ({
352
- hash: row.hash,
353
- path: row.path,
354
- kind: row.kind,
355
- startMs: Number(row.start_ms),
356
- endMs: Number(row.end_ms),
357
- vector: fromBlob(row.vector),
358
- })),
334
+ /** The library (library.mjs): parts of the person's files and the threads' files, their vectors beside the pieces'. */
335
+ library: libraryTables(run, all, get),
359
336
  /** Full-text matches: { hash, rank } best first (bm25, lower is better), over every indexed piece. */
360
337
  words(terms, limit = 400) {
361
338
  if (!terms.length) return [];
@@ -371,6 +348,153 @@ export function openAtlas(file) {
371
348
  };
372
349
  }
373
350
 
351
+ // Recordings and screenshots indexed before the library keep their vectors: their part keys are the same.
352
+ function migrateMedia(db) {
353
+ const old = db.get(
354
+ "select name from sqlite_master where type = 'table' and name = 'media'"
355
+ );
356
+ if (!old) return;
357
+ db.exec(`
358
+ insert or ignore into library(file, vec, source, name, kind, modality, loc, loc_end, mtime, embed)
359
+ select path, hash, 'folders', path, kind, kind, start_ms, end_ms, mtime, 1 from media;
360
+ drop table media;
361
+ `);
362
+ for (const row of db.all("select distinct file from library")) {
363
+ const name = path.basename(row.file);
364
+ db.run("update library set name = ? where file = ?", [name, row.file]);
365
+ db.run(
366
+ "insert into library_fts(file, vec, name, text) values(?, ?, ?, '')",
367
+ [row.file, "", name]
368
+ );
369
+ }
370
+ }
371
+
372
+ const LIBRARY_COLUMNS =
373
+ "file, vec, source, name, kind, modality, loc, loc_end, mtime, embed";
374
+ const libraryRow = (row) => ({
375
+ file: row.file,
376
+ vec: row.vec,
377
+ source: row.source,
378
+ name: row.name,
379
+ kind: row.kind,
380
+ modality: row.modality,
381
+ loc: Number(row.loc),
382
+ locEnd: row.loc_end === null ? null : Number(row.loc_end),
383
+ mtime: Number(row.mtime),
384
+ });
385
+
386
+ function libraryTables(run, all, get) {
387
+ return {
388
+ /** Every file in one source ("folders", or a thread's id) with when it was read: Map(file, mtime). */
389
+ files(source) {
390
+ const rows = all(
391
+ "select file, max(mtime) as mtime from library where source = ? group by file",
392
+ source
393
+ );
394
+ return new Map(rows.map((row) => [row.file, Number(row.mtime)]));
395
+ },
396
+ sources: () =>
397
+ all("select distinct source from library").map((row) => row.source),
398
+ drop(file) {
399
+ run("delete from library where file = ?", file);
400
+ run("delete from library_fts where file = ?", file);
401
+ },
402
+ /** A file's parts: rows of LIBRARY_COLUMNS plus text (the words it is found by). One row of words names the file. */
403
+ put(rows) {
404
+ for (const row of rows) {
405
+ run(
406
+ `insert or replace into library(${LIBRARY_COLUMNS}) values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
407
+ row.file,
408
+ row.vec,
409
+ row.source,
410
+ row.name,
411
+ row.kind,
412
+ row.modality,
413
+ row.loc,
414
+ row.locEnd ?? null,
415
+ row.mtime,
416
+ row.embed ? 1 : 0
417
+ );
418
+ if (row.text)
419
+ run(
420
+ "insert into library_fts(file, vec, name, text) values(?, ?, '', ?)",
421
+ row.file,
422
+ row.vec,
423
+ row.text
424
+ );
425
+ }
426
+ const first = rows[0];
427
+ if (first)
428
+ run(
429
+ "insert into library_fts(file, vec, name, text) values(?, '', ?, '')",
430
+ first.file,
431
+ first.name.replace(/[._-]/g, " ")
432
+ );
433
+ },
434
+ /** Parts still to embed: the newest files first. */
435
+ toEmbed: (version, modalities, limit = 32) =>
436
+ all(
437
+ `select l.*, f.text from library l
438
+ left join piece_vectors v on v.hash = l.vec and v.version = ?
439
+ left join library_fts f on f.file = l.file and f.vec = l.vec
440
+ where l.embed = 1 and v.hash is null and l.modality in (${modalities.map(() => "?").join(",")})
441
+ group by l.file, l.vec order by l.mtime desc limit ?`,
442
+ version,
443
+ ...modalities,
444
+ limit
445
+ ).map((row) => ({ ...libraryRow(row), text: row.text ?? "" })),
446
+ waiting: (version) =>
447
+ Number(
448
+ get(
449
+ `select count(*) as n from library l left join piece_vectors v on v.hash = l.vec and v.version = ?
450
+ where l.embed = 1 and v.hash is null`,
451
+ version
452
+ ).n
453
+ ),
454
+ /** Every part with a vector: { ...row, vector }. */
455
+ vectors: (version) =>
456
+ all(
457
+ `select l.*, v.vector from library l join piece_vectors v on v.hash = l.vec and v.version = ?`,
458
+ version
459
+ ).map((row) => ({ ...libraryRow(row), vector: fromBlob(row.vector) })),
460
+ /** The files holding a word or phrase (FTS5 syntax): Map(file, [vec of each matching part, best first]). */
461
+ holding(match, limit = 2_000) {
462
+ const out = new Map();
463
+ for (const row of all(
464
+ "select file, vec from library_fts where library_fts match ? order by bm25(library_fts, 0, 0, 3.0, 1.0) limit ?",
465
+ match,
466
+ limit
467
+ )) {
468
+ if (!out.has(row.file)) out.set(row.file, []);
469
+ if (row.vec) out.get(row.file).push(row.vec);
470
+ }
471
+ return out;
472
+ },
473
+ part(file, vec) {
474
+ const row = get(
475
+ `select ${LIBRARY_COLUMNS} from library where file = ? and vec = ?`,
476
+ file,
477
+ vec
478
+ );
479
+ if (!row) return null;
480
+ const words = get(
481
+ "select text from library_fts where file = ? and vec = ?",
482
+ file,
483
+ vec
484
+ );
485
+ return { ...libraryRow(row), text: words?.text ?? "" };
486
+ },
487
+ first(file) {
488
+ const row = get(
489
+ `select ${LIBRARY_COLUMNS} from library where file = ? order by loc limit 1`,
490
+ file
491
+ );
492
+ return row ? { ...libraryRow(row), text: "" } : null;
493
+ },
494
+ count: () => Number(get("select count(distinct file) as n from library").n),
495
+ };
496
+ }
497
+
374
498
  const git = (cwd, args, input) =>
375
499
  spawnSync("git", args, {
376
500
  cwd,
package/lib/cards.mjs CHANGED
@@ -214,7 +214,8 @@ export function cardLine(card, now = Date.now()) {
214
214
  parts.push(
215
215
  `Its own sessions show its changes tested after the last edit ${checks.tested} of ${checks.tested + checks.untested + checks.failing} times${checks.failing ? ` (${checks.failing} with tests failing)` : ""}.`
216
216
  );
217
- else if (member(card.id)?.home === "machine")
217
+ // Said only of an agent with no work at all: one with finished work and no receipts has its count above.
218
+ else if (!done && !failed && member(card.id)?.home === "machine")
218
219
  parts.push("Has finished no work on this board yet.");
219
220
  if (card.working || card.waiting)
220
221
  parts.push(`Now: ${card.working} running, ${card.waiting} waiting for it.`);