tryharness 0.7.18 → 0.7.23

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.
Files changed (48) hide show
  1. package/README.md +57 -14
  2. package/assets/opencode-plugin.js +47 -66
  3. package/assets/pi-extension.ts +91 -90
  4. package/cli.mjs +115 -9
  5. package/lib/adapters.mjs +19 -11
  6. package/lib/agentnode.mjs +154 -0
  7. package/lib/atlas.mjs +304 -0
  8. package/lib/bip39-english.txt +2048 -0
  9. package/lib/cards.mjs +16 -4
  10. package/lib/context.mjs +290 -0
  11. package/lib/coordinator.mjs +27 -9
  12. package/lib/daemon.mjs +790 -93
  13. package/lib/digest.mjs +40 -0
  14. package/lib/environment.mjs +98 -0
  15. package/lib/firewall.mjs +154 -0
  16. package/lib/folders.mjs +161 -0
  17. package/lib/frames.mjs +159 -0
  18. package/lib/inference.mjs +326 -0
  19. package/lib/learn.mjs +120 -0
  20. package/lib/live.mjs +30 -5
  21. package/lib/localmodels.mjs +230 -0
  22. package/lib/mcp-stdio.mjs +107 -7
  23. package/lib/media.mjs +118 -0
  24. package/lib/members.json +1 -1
  25. package/lib/models.mjs +233 -0
  26. package/lib/network.mjs +118 -0
  27. package/lib/nudge.mjs +13 -5
  28. package/lib/ocr.mjs +217 -0
  29. package/lib/personal.mjs +39 -0
  30. package/lib/phones.mjs +31 -2
  31. package/lib/places.mjs +61 -0
  32. package/lib/receipts.mjs +42 -0
  33. package/lib/redact.mjs +334 -0
  34. package/lib/runner.mjs +37 -6
  35. package/lib/runtime-install.mjs +6 -5
  36. package/lib/screens.mjs +96 -0
  37. package/lib/sealed.mjs +3 -0
  38. package/lib/service.mjs +4 -2
  39. package/lib/snippets.mjs +64 -0
  40. package/lib/speech.mjs +480 -0
  41. package/lib/store.mjs +64 -5
  42. package/lib/swarm.mjs +89 -26
  43. package/lib/t3.mjs +7 -4
  44. package/lib/trails.mjs +177 -0
  45. package/lib/watches.mjs +163 -0
  46. package/lib/worktrees.mjs +21 -5
  47. package/package.json +6 -4
  48. package/lib/embedder.mjs +0 -156
package/lib/models.mjs ADDED
@@ -0,0 +1,233 @@
1
+ import os from "node:os";
2
+ import { spawn } from "node:child_process";
3
+ import { agentEnvironment } from "./agentnode.mjs";
4
+ import { nameOf } from "./members.mjs";
5
+ import { trackHelper } from "./runner.mjs";
6
+
7
+ // Which model and effort each coding agent runs with, owned by the relay. Every agent lists its own models (Codex,
8
+ // Pi and OpenCode have a command for it; Claude Code has none, so its list is its own short names plus the Anthropic
9
+ // models the others list here). A run uses, first: a model named for it (`@codex:gpt-6-luna/high` in a post, or
10
+ // post_task's model); then the person's choice for the agent, which may be Auto (Harness's Jev picks from the list
11
+ // against the person's routing words); else nothing, and the agent runs on its own settings. Every pick is checked
12
+ // against the agent's list before a run starts.
13
+
14
+ const CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; // claude --help
15
+ const PI_THINKING = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; // pi --help
16
+ const CLAUDE_NAMES = ["fable", "opus", "sonnet", "haiku"];
17
+ /**
18
+ * The routing words Auto goes by until the person writes their own. The first wording (owner, October 13), "The cheapest
19
+ * model that can do this work well; more effort only for hard work.", sent demanding work to cheap models (eval
20
+ * model-routing v1, 20261014-010225-80250750-357a).
21
+ */
22
+ export const DEFAULT_ROUTING = "Match the model to the work: for easy work, the cheapest model that does it well; for demanding work, the most capable one. Pass over a model that keeps failing here. More effort only for hard work.";
23
+
24
+ const tokens = (text) => {
25
+ const match = /^(\d+(?:\.\d+)?)([KM]?)$/i.exec(String(text || ""));
26
+ return match ? Math.round(Number(match[1]) * ({ k: 1e3, m: 1e6 }[match[2].toLowerCase()] ?? 1)) : null;
27
+ };
28
+
29
+ // One reader per agent's own list command, its output as the agent prints it today.
30
+ const LISTS = {
31
+ codex: { command: "codex", args: ["debug", "models"], parse: (out) => JSON.parse(out).models.map((entry) => ({
32
+ id: entry.slug, about: entry.description || null, efforts: (entry.supported_reasoning_levels || []).map((level) => level.effort),
33
+ defaultEffort: entry.default_reasoning_level || null, context: entry.context_window || null })) },
34
+ // provider model context max-out thinking images
35
+ pi: { command: "pi", args: ["--list-models"], parse: (out) => out.split("\n").map((line) => line.trim().split(/\s+/))
36
+ .filter((cells) => cells.length >= 5 && cells[0] !== "provider")
37
+ .map(([provider, model, context, , thinking]) => ({ id: `${provider}/${model}`, efforts: thinking === "yes" ? PI_THINKING : [], context: tokens(context) })) },
38
+ // Each model as "provider/id" on its own line, then its details as JSON.
39
+ opencode: { command: "opencode", args: ["models", "--verbose"], parse: (out) => {
40
+ const models = [];
41
+ let id = null, body = [];
42
+ const flush = () => {
43
+ if (!id) return;
44
+ const entry = JSON.parse(body.join("\n"));
45
+ models.push({ id, efforts: Object.keys(entry.variants || {}), context: entry.limit?.context ?? null,
46
+ ...(entry.cost && Number.isFinite(entry.cost.input) ? { price: { inputUsdPerMillion: entry.cost.input, outputUsdPerMillion: entry.cost.output } } : {}) });
47
+ };
48
+ for (const line of out.split("\n")) {
49
+ if (/^[^\s{}"]+\/\S+$/.test(line)) { flush(); id = line.trim(); body = []; } else if (id) body.push(line);
50
+ }
51
+ flush();
52
+ return models;
53
+ } },
54
+ };
55
+
56
+ function capture(command, args) {
57
+ return new Promise((resolve, reject) => {
58
+ const environment = agentEnvironment(command);
59
+ if (environment.error) { reject(new Error(environment.error)); return; }
60
+ let child;
61
+ try { child = spawn(command, args, { cwd: os.tmpdir(), env: environment.env }); } catch (error) { reject(error); return; }
62
+ child.stdin.end();
63
+ trackHelper(child);
64
+ let out = "", err = "";
65
+ child.stdout.on("data", (chunk) => { out += chunk; });
66
+ child.stderr.on("data", (chunk) => { err += chunk; });
67
+ child.on("error", reject);
68
+ child.on("close", (code) => code === 0 ? resolve(out) : reject(new Error(`${command} ${args.join(" ")} exited with code ${code}: ${err.trim().slice(0, 200)}`)));
69
+ });
70
+ }
71
+
72
+ const catalogKey = (agent) => `models:${agent}`;
73
+ /** An agent's last listing: { at, models, error? }, error when its list command last failed. */
74
+ export const listingOf = (store, agent) => { try { return JSON.parse(store.meta(catalogKey(agent)) || "null"); } catch { return null; } };
75
+ /** An agent's models as it last listed them: [{ id, about?, efforts, defaultEffort?, context?, price? }]. */
76
+ export const catalogOf = (store, agent) => listingOf(store, agent)?.models ?? [];
77
+
78
+ /**
79
+ * Asks each installed agent for its models, all at once so one slow agent holds up no other, and keeps the lists; an
80
+ * agent whose command fails keeps its last list and the error.
81
+ */
82
+ export async function refreshCatalogs(store, agents, log = () => {}) {
83
+ await Promise.all(agents.filter((name) => LISTS[name]).map(async (agent) => {
84
+ try {
85
+ const models = LISTS[agent].parse(await capture(LISTS[agent].command, LISTS[agent].args)).filter((entry) => entry.id);
86
+ store.meta(catalogKey(agent), JSON.stringify({ at: new Date().toISOString(), models }));
87
+ } catch (error) {
88
+ log(`model list for ${agent}: ${error.message}`);
89
+ store.meta(catalogKey(agent), JSON.stringify({ ...(listingOf(store, agent) ?? { models: [] }), error: error.message.slice(0, 300) }));
90
+ }
91
+ }));
92
+ if (agents.includes("claude-code")) {
93
+ const anthropic = [...new Set(["pi", "opencode"].flatMap((other) => catalogOf(store, other)).map((entry) => entry.id).filter((id) => id.startsWith("anthropic/")).map((id) => id.slice("anthropic/".length)))];
94
+ const models = [...CLAUDE_NAMES.map((name) => ({ id: name, about: `Claude Code's name for its newest ${name[0].toUpperCase()}${name.slice(1)} model.`, efforts: CLAUDE_EFFORTS })),
95
+ ...anthropic.sort().map((id) => ({ id, efforts: CLAUDE_EFFORTS }))];
96
+ store.meta(catalogKey("claude-code"), JSON.stringify({ at: new Date().toISOString(), models }));
97
+ }
98
+ }
99
+
100
+ /** One model in an agent's list, written as its id or, when only one model has it, the part after the provider. */
101
+ function findModel(store, agent, written) {
102
+ const models = catalogOf(store, agent);
103
+ if (!models.length) return { error: `the relay has no model list for ${nameOf(agent)} yet` };
104
+ const wanted = String(written || "").trim().toLowerCase();
105
+ const exact = models.find((entry) => entry.id.toLowerCase() === wanted);
106
+ if (exact) return { entry: exact };
107
+ const tail = models.filter((entry) => entry.id.toLowerCase().split("/").pop() === wanted);
108
+ if (tail.length === 1) return { entry: tail[0] };
109
+ return { error: tail.length ? `"${written}" could be ${tail.map((entry) => entry.id).join(" or ")}; name one` : `"${written}" is not one of ${nameOf(agent)}'s models (npx tryharness agents models ${agent})` };
110
+ }
111
+
112
+ /** A model and effort for an agent, checked against its list: { ok, pick } with pick { model, effort } or { auto: true }. */
113
+ export function resolvePick(store, agent, model, effort = null) {
114
+ if (String(model || "").trim().toLowerCase() === "auto") return { ok: true, pick: { auto: true } };
115
+ const found = findModel(store, agent, model);
116
+ if (found.error) return { ok: false, error: found.error };
117
+ const level = effort ? String(effort).trim().toLowerCase() : null;
118
+ if (level && !found.entry.efforts.includes(level)) {
119
+ return { ok: false, error: found.entry.efforts.length ? `${found.entry.id} takes effort ${found.entry.efforts.join(", ")}, not "${effort}"` : `${found.entry.id} has no effort setting in ${nameOf(agent)}` };
120
+ }
121
+ return { ok: true, pick: { model: found.entry.id, effort: level } };
122
+ }
123
+
124
+ /** A pick as written after an agent's name in a post, "model", "model/effort" or "auto". */
125
+ export function resolveSpec(store, agent, spec) {
126
+ const whole = resolvePick(store, agent, spec);
127
+ if (whole.ok) return whole;
128
+ const cut = spec.lastIndexOf("/");
129
+ if (cut > 0) { const split = resolvePick(store, agent, spec.slice(0, cut), spec.slice(cut + 1)); if (split.ok) return split; }
130
+ return whole;
131
+ }
132
+
133
+ /** The agents whose model the relay sets. */
134
+ export const PICKABLE = new Set(["claude-code", ...Object.keys(LISTS)]);
135
+ const PICKED = /(?:^|\s)@([a-z][a-z0-9-]*):([^\s]+)/g;
136
+ /** The picks a post names, `@codex:gpt-6-luna/high`, by agent: { picks: Map, error? }. Only agents in `agents` count. */
137
+ export function picksIn(store, text, agents) {
138
+ const picks = new Map();
139
+ for (const match of String(text || "").matchAll(PICKED)) {
140
+ const agent = match[1] === "claude" ? "claude-code" : match[1];
141
+ if (!agents.includes(agent) || !PICKABLE.has(agent)) continue;
142
+ const spec = match[2].replace(/[.,;:!?)]+$/, "");
143
+ const resolved = resolveSpec(store, agent, spec);
144
+ if (!resolved.ok) return { picks, error: `@${match[1]}:${spec}: ${resolved.error}` };
145
+ picks.set(agent, resolved.pick);
146
+ }
147
+ return { picks };
148
+ }
149
+
150
+ const choiceKey = (agent) => `model-choice:${agent}`;
151
+ /** The person's choice for an agent: { model, effort }, { auto: true }, or null for the agent's own settings. */
152
+ export const choiceOf = (store, agent) => { try { return JSON.parse(store.meta(choiceKey(agent)) || "null"); } catch { return null; } };
153
+ export function setChoice(store, agent, model, effort = null) {
154
+ if (!model || String(model).toLowerCase() === "default") { store.meta(choiceKey(agent), ""); return { ok: true, choice: null }; }
155
+ const resolved = resolvePick(store, agent, model, effort);
156
+ if (!resolved.ok) return resolved;
157
+ store.meta(choiceKey(agent), JSON.stringify(resolved.pick));
158
+ return { ok: true, choice: resolved.pick };
159
+ }
160
+ export const routingOf = (store) => store.meta("routing-note") || null;
161
+ export const setRouting = (store, text) => { const note = String(text || "").replace(/\s+/g, " ").trim().slice(0, 1_000); store.meta("routing-note", note); return note || null; };
162
+
163
+ /** A pick in words: "gpt-6-luna, effort high", "Auto". */
164
+ export const describePick = (pick) => !pick ? null : pick.auto ? "Auto" : [pick.model, pick.effort && `effort ${pick.effort}`].filter(Boolean).join(", ");
165
+
166
+ /** The pick named for one run (a post, post_task), kept with the run until it starts. */
167
+ export const setRunPick = (store, runId, pick, by) => store.meta(`model:${runId}`, JSON.stringify({ ...pick, by }));
168
+ const runPick = (store, runId) => { try { return JSON.parse(store.meta(`model:${runId}`) || "null"); } catch { return null; } };
169
+
170
+ const median = (values) => { const sorted = values.filter(Number.isFinite).sort((a, b) => a - b); return sorted.length ? sorted[Math.floor(sorted.length / 2)] : null; };
171
+ /** How each of an agent's models did on this board: finished, failed, and the median minutes and dollars of a finished run. */
172
+ function recordByModel(store, agent) {
173
+ const byModel = new Map();
174
+ for (const row of store.modelWork(agent)) {
175
+ const entry = byModel.get(row.model) ?? { done: 0, failed: 0, minutes: [], cost: [] };
176
+ if (row.status === "done") {
177
+ entry.done++;
178
+ entry.minutes.push((Date.parse(row.reported_at) - Date.parse(row.started_at)) / 60_000);
179
+ entry.cost.push(Number(row.usage?.cost_usd));
180
+ } else if (row.status === "failed") entry.failed++;
181
+ byModel.set(row.model, entry);
182
+ }
183
+ return (model) => {
184
+ const entry = byModel.get(model);
185
+ if (!entry) return null;
186
+ const minutes = median(entry.minutes), cost = median(entry.cost);
187
+ return { done: entry.done, failed: entry.failed, medianMinutes: minutes === null ? null : Math.round(minutes * 10) / 10, medianCostUsd: cost === null ? null : Math.round(cost * 10_000) / 10_000 };
188
+ };
189
+ }
190
+
191
+ /** What Auto sends Harness for one run (src/server/swarm/route.ts): the work, the person's words, every listed model with its record here. */
192
+ export const routeInput = ({ agent, work, transcript, routing, ownModel, models, record }) => ({
193
+ agent, name: nameOf(agent), work, transcript, routing: routing ?? DEFAULT_ROUTING, ownModel,
194
+ models: models.slice(0, 250).map((entry) => ({ ...entry, record: record(entry.id) })),
195
+ });
196
+
197
+ /**
198
+ * The model and effort a run starts with: { model, effort, by, note? }, or null for the agent's own settings. `by` says
199
+ * who chose: "you" (the person's post), another agent's id, "your choice" (the person's setting) or "auto".
200
+ * Auto asks Harness only when the person allows it (not the privacy setting) and Harness can answer.
201
+ */
202
+ export function createModelRouter({ store, askHarness, canAsk, privacy, ownModel, workOf, log = () => {} }) {
203
+ return async function modelForRun(task, agent) {
204
+ const named = runPick(store, task.id);
205
+ const pick = named ?? (choiceOf(store, agent) ? { ...choiceOf(store, agent), by: "your choice" } : null);
206
+ if (!pick) return null;
207
+ const by = named ? (named.by === "user" ? "you" : named.by) : pick.by;
208
+ if (!pick.auto) {
209
+ // The list may have changed since the pick: a model the agent no longer lists runs on its own settings, said so.
210
+ const checked = resolvePick(store, agent, pick.model, pick.effort);
211
+ return checked.ok ? { ...checked.pick, by } : { model: null, effort: null, by, note: `${checked.error}; it ran on its own settings` };
212
+ }
213
+ const fallback = (note) => ({ model: null, effort: null, by: "auto", note });
214
+ if (privacy()) return fallback("Auto is off on the privacy setting; it ran on its own settings");
215
+ if (!canAsk()) return fallback("Harness could not pick a model just now; it ran on its own settings");
216
+ const models = catalogOf(store, agent);
217
+ if (!models.length) return fallback(`the relay has no model list for ${nameOf(agent)} yet; it ran on its own settings`);
218
+ try {
219
+ const reply = await askHarness("route_model", routeInput({ agent, ...workOf(task), routing: routingOf(store), ownModel: ownModel(agent), models, record: recordByModel(store, agent) }), null);
220
+ if (!reply.ok) throw new Error(reply.error || "Harness did not pick a model");
221
+ // The bridge hands back Harness's answer with its fields beside ok.
222
+ const picked = reply;
223
+ if (!picked.model) return fallback("Harness picked no model; it ran on its own settings");
224
+ const checked = resolvePick(store, agent, picked.model, picked.effort && models.find((entry) => entry.id === picked.model)?.efforts.includes(picked.effort) ? picked.effort : null);
225
+ if (!checked.ok) return fallback(`Auto picked ${picked.model}, which ${nameOf(agent)} does not list; it ran on its own settings`);
226
+ // Jev's confidence in the pick stays with the run, to see later how sure the picks that worked were.
227
+ return { ...checked.pick, by: "auto", judgments: picked.judgments ?? null, estimatedMicroUsd: picked.estimatedMicroUsd ?? null };
228
+ } catch (error) {
229
+ log(`auto model for ${task.id}: ${error.message}`);
230
+ return fallback(`Harness could not pick a model (${error.message}); it ran on its own settings`);
231
+ }
232
+ };
233
+ }
@@ -0,0 +1,118 @@
1
+ // Swarms talking to swarms, on the person's computer: the one list every surface shows (the buddy's
2
+ // Swarms tab, the dashboard, the PiP), built here from what Harness holds (who asked whom, and the
3
+ // answer) and what only this computer holds (each thread's words, held replies, what the person has
4
+ // read). A sealed request's subject is opened here, never at Harness. Only the person ("user") calls
5
+ // these; another agent works a thread through board.thread.* on the task it holds.
6
+
7
+ const SEEN = (id) => `relay_seen:${id}`;
8
+ const AUTO_SEND_DEFAULT = "relay_auto_send_default";
9
+ const DECISIONS = new Set(["accept", "decline", "block"]);
10
+ const handleOf = (identity) => String(identity || "").split("/")[0].toLowerCase();
11
+ const agentOf = (identity) => String(identity || "").split("/")[1] || "";
12
+
13
+ export function createNetwork({ store, askHarness, relayRequest, openRelayRequest, fingerprint, myKey }) {
14
+ // Harness answers for the server's half; a refusal comes back as a plain error the person can read.
15
+ const harness = async (input) => {
16
+ const answer = await askHarness("relay_network", input);
17
+ if (!answer.ok) throw new Error(answer.error || "Harness refused the request");
18
+ const { ok: _ok, ...rest } = answer;
19
+ return rest;
20
+ };
21
+
22
+ // One row per request or thread, grouped by what it needs: the person (a request to answer, a held reply,
23
+ // a message from a person), nothing (open), the other side (a request not answered yet), or nobody (ended).
24
+ const conversations = async () => {
25
+ const server = await harness({ op: "threads" }).catch(() => null);
26
+ const local = new Map(store.relayThreads().map((row) => [row.task_id, row]));
27
+ const items = [];
28
+ for (const thread of server?.threads ?? []) {
29
+ const here = local.get(thread.id) || null;
30
+ local.delete(thread.id);
31
+ const peer = thread.role === "asker" ? thread.target : thread.asker;
32
+ let about = thread.about || here?.objective || "";
33
+ let keyPrint = null;
34
+ // A sealed request's subject: opened on this computer, with the other computer's key to compare.
35
+ if (thread.sealed && !about) {
36
+ const opened = await openRelayRequest({ sealed: thread.sealed, from: `${peer.handle}/${peer.agent}` }).catch(() => null);
37
+ if (opened) { about = opened.about; keyPrint = opened.fingerprint; }
38
+ }
39
+ items.push(item({ id: thread.id, role: thread.role, kind: thread.kind || (thread.target?.agent === "person" ? "person" : "agent"), status: thread.status, about,
40
+ peer: { handle: peer.handle, agent: peer.agent }, mine: thread.role === "asker" ? thread.asker.agent : thread.target.agent,
41
+ askerSince: thread.role === "target" ? thread.asker.since : null, sealed: Boolean(thread.sealed), fingerprint: keyPrint, createdAt: thread.createdAt, here }));
42
+ }
43
+ // Threads this computer holds that Harness no longer lists (older ones, or Harness out of reach).
44
+ for (const here of local.values()) {
45
+ items.push(item({ id: here.task_id, role: here.role, kind: agentOf(here.with_agent) === "person" ? "person" : "agent", status: here.closed_at ? "closed" : "accepted", about: here.objective,
46
+ peer: { handle: handleOf(here.with_agent), agent: agentOf(here.with_agent) }, mine: here.target_agent === "user" ? "person" : here.target_agent,
47
+ askerSince: null, sealed: false, fingerprint: null, createdAt: here.opened_at, here }));
48
+ }
49
+ items.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
50
+ return { ok: true, connected: Boolean(server), items, needsYou: items.filter((entry) => entry.group === "needs_you").length };
51
+ };
52
+
53
+ const item = ({ id, role, kind, status, about, peer, mine, askerSince, sealed, fingerprint: keyPrint, createdAt, here }) => {
54
+ const seen = Number(store.meta(SEEN(id)) || 0);
55
+ const thread = here ? {
56
+ open: !here.closed_at, autoSend: here.auto_send, held: here.held, unread: here.last_in_seq > seen, messages: here.messages, outcome: here.outcome,
57
+ last: here.last ? { direction: here.last.direction, sender: here.last.sender, text: String(here.last.text || "").slice(0, 160), state: here.last.state, at: here.last.created_at } : null,
58
+ } : null;
59
+ const reason = role === "target" && status === "requested" ? "request"
60
+ : thread?.open && thread.held ? "held"
61
+ : thread?.open && kind === "person" && thread.unread ? "message"
62
+ : null;
63
+ const group = reason ? "needs_you"
64
+ : thread ? (thread.open ? "open" : "ended")
65
+ : status === "requested" ? "waiting"
66
+ : status === "accepted" || status === "accepted_pending" ? "open"
67
+ : "ended";
68
+ return { id, role, kind, status, about, peer, mine, askerSince, sealed, fingerprint: keyPrint, group, reason, thread, createdAt, updatedAt: here?.updated_at || createdAt };
69
+ };
70
+
71
+ // A refusal from Harness or the other computer comes back as the answer, in its own words.
72
+ const answered = (promise) => ({ pending: promise.catch((error) => ({ ok: false, error: error.message })) });
73
+ const command = (request, agent) => {
74
+ if (agent !== "user") return { ok: false, error: "only the person talks to other swarms from here" };
75
+ const id = String(request.id || "");
76
+ switch (request.command) {
77
+ case "board.relay.list":
78
+ return answered(conversations());
79
+ // The server's half of Swarms (card, search, contacts, blocks): what the dashboard asks Harness directly.
80
+ case "board.relay.network": {
81
+ const { command: _command, agent: _agent, ...input } = request;
82
+ if (input.op === "decide" || input.op === "threads") return { ok: false, error: "use board.relay.decide and board.relay.list" };
83
+ return answered(harness(input).then((answer) => ({ ok: true, ...answer })));
84
+ }
85
+ case "board.relay.decide":
86
+ if (!DECISIONS.has(String(request.decision))) return { ok: false, error: "decision is accept, decline or block" };
87
+ return answered(harness({ op: "decide", id, decision: String(request.decision) }).then((answer) => ({ ok: true, ...answer })));
88
+ // A request from this computer: to one of their listed agents from one of the person's, or to the person themselves.
89
+ case "board.relay.request": {
90
+ const handle = String(request.handle || "").toLowerCase().replace(/^@/, "");
91
+ const toAgent = String(request.toAgent || "person");
92
+ const fromAgent = toAgent === "person" ? "person" : String(request.fromAgent || "");
93
+ return answered(relayRequest({ to: `${handle}/${toAgent}`, fromAgent, about: request.about, brief: request.brief, refs: request.refs }).then((made) => ({ ok: true, ...made })));
94
+ }
95
+ case "board.relay.seen": {
96
+ const thread = store.getThread(id);
97
+ if (!thread) return { ok: false, error: "no thread on this task" };
98
+ const last = store.threadMessages(id).filter((message) => message.direction === "in").at(-1);
99
+ store.meta(SEEN(id), String(last?.seq ?? 0));
100
+ return { ok: true };
101
+ }
102
+ // Whether replies the person's agents write wait for the person on a new thread (the default) or send on their own.
103
+ case "board.relay.defaults":
104
+ if (typeof request.autoSend === "boolean") store.meta(AUTO_SEND_DEFAULT, request.autoSend ? "1" : "0");
105
+ return { ok: true, autoSend: store.meta(AUTO_SEND_DEFAULT) === "1" };
106
+ case "board.relay.peers":
107
+ return answered(Promise.all(store.relayPeers().map(async (peer) => ({ handle: peer.handle, pinnedAt: peer.pinned_at, fingerprint: await fingerprint(peer.public_key) })))
108
+ .then(async (peers) => ({ ok: true, peers, mine: myKey() ? await fingerprint(myKey()) : null })));
109
+ default:
110
+ return { ok: false, error: `unknown command ${request.command}` };
111
+ }
112
+ };
113
+
114
+ /** A thread just opened on this computer starts with the person's default for their agents' replies. */
115
+ const opened = (taskId) => { if (store.meta(AUTO_SEND_DEFAULT) === "1") store.setAutoSend(taskId, true); };
116
+
117
+ return { command, opened };
118
+ }
package/lib/nudge.mjs CHANGED
@@ -9,7 +9,7 @@ import { placeEnv } from "./places.mjs";
9
9
  // The session-start nudge: what an interactive agent is told when a session opens, so work
10
10
  // waiting on the board is not silent until someone asks. Claude Code runs this as a
11
11
  // SessionStart hook (stdout lands in its context); the Pi extension and OpenCode plugin
12
- // build the same text in-process. Nothing waiting, or no daemon, prints nothing.
12
+ // import this file from the runtime and call the same functions. Nothing waiting, or no daemon, prints nothing.
13
13
 
14
14
  // The swarm block (cards.mjs), when given, follows the queue: who else is in the person's swarm and what each is for.
15
15
  export function nudgeText(agent, tasks, unread = [], goals = [], swarm = "") {
@@ -94,14 +94,21 @@ function agentPids() {
94
94
  // Where the session runs, as its own environment says (the agent's, which the hook shares): places.mjs reads it.
95
95
  const placeOf = (input) => ({ sessionId: input?.session_id, folder: input?.cwd || process.cwd(), env: placeEnv(process.env) });
96
96
 
97
- export async function notePrompt(agent, input = null) {
98
- try { await daemonRequest({ command: "board.prompted", agent, pids: agentPids(), ...(input?.session_id ? placeOf(input) : {}) }, 1_500); } catch {}
97
+ // An extension or plugin running inside the agent passes its own process ids.
98
+ export async function notePrompt(agent, input = null, pids = agentPids()) {
99
+ try { await daemonRequest({ command: "board.prompted", agent, pids, ...(input?.session_id ? placeOf(input) : {}) }, 1_500); } catch {}
99
100
  }
100
101
 
101
102
  /** A session starting says where it runs. */
102
- export async function notePlace(agent, input) {
103
+ export async function notePlace(agent, input, pids = agentPids()) {
103
104
  if (!input?.session_id) return;
104
- try { await daemonRequest({ command: "board.live.place", agent, pids: agentPids(), ...placeOf(input) }, 1_500); } catch {}
105
+ try { await daemonRequest({ command: "board.live.place", agent, pids, ...placeOf(input) }, 1_500); } catch {}
106
+ }
107
+
108
+ /** A turn ended with nothing more to do: the session waits for the person, and the buddy can say so. */
109
+ export async function noteTurn(agent, input, pids = agentPids()) {
110
+ if (!input?.session_id) return;
111
+ try { await daemonRequest({ command: "board.live.turn", agent, pids, ...placeOf(input) }, 1_500); } catch {}
105
112
  }
106
113
 
107
114
  // What the person sent this session from Harness (daemon board.live.say), taken once.
@@ -139,6 +146,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
139
146
  // The Stop hook: a message waiting for this session keeps the agent going with it.
140
147
  const messages = await takeMessages(agent, sessionId);
141
148
  if (messages.length) process.stdout.write(JSON.stringify({ decision: "block", reason: messagesText(messages) }) + "\n");
149
+ else await noteTurn(agent, input);
142
150
  } else {
143
151
  if (process.argv.includes("--if-changed")) await notePrompt(agent, input);
144
152
  else await notePlace(agent, input);
package/lib/ocr.mjs ADDED
@@ -0,0 +1,217 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import pngjs from "pngjs";
4
+ import jpeg from "jpeg-js";
5
+
6
+ // On-device OCR for images that leave the computer (redact.mjs), with the browser's PP-OCR pair (localmodels.mjs "ocr")
7
+ // ported to plain Node. The detector marks text as a probability map; each connected region of it becomes a box (DB
8
+ // post-processing on axis-aligned regions, the shape screen text has), widened back to the text's full extent. Each
9
+ // box is then read alone at the image's full resolution (per box, as the browser does: batching crops of different
10
+ // widths changed reads), and every character keeps the stretch of the box it was read from, so part of a line can be
11
+ // covered.
12
+
13
+ // Larger images are refused rather than decoded (a 5 MB PNG can hold far more pixels than any screen).
14
+ export const MAX_PIXELS = 40e6;
15
+ // The detector's own settings (its inference.yml: thresh 0.2, box_thresh 0.45, unclip_ratio 1.4) at a long side of
16
+ // 960 to 2,048 pixels: a retina screenshot is shrunk to 2,048 across, a small image is enlarged.
17
+ const DET = { min: 960, max: 2_048, thresh: 0.2, boxThresh: 0.45, unclip: 1.4, mean: [0.485, 0.456, 0.406], std: [0.229, 0.224, 0.225] };
18
+ const REC = { height: 48, mean: [0.5, 0.5, 0.5], std: [0.5, 0.5, 0.5] };
19
+ // Boxes read per scheduler job, so a long image does not hold the ONNX lane from the firewall's checks.
20
+ const REC_PER_JOB = 24;
21
+
22
+ export const imageKind = (bytes) => bytes?.length > 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 ? "png"
23
+ : bytes?.length > 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff ? "jpeg" : null;
24
+
25
+ /** { width, height, data: RGBA } from PNG or JPEG bytes; null for any other format. Throws on a broken or oversized image. */
26
+ export function decodeImage(bytes) {
27
+ const buffer = Buffer.from(bytes.buffer ?? bytes, bytes.byteOffset ?? 0, bytes.byteLength ?? bytes.length);
28
+ const kind = imageKind(buffer);
29
+ if (kind === "png") {
30
+ if (buffer.readUInt32BE(16) * buffer.readUInt32BE(20) > MAX_PIXELS) throw new Error("the image is too large to check");
31
+ const { width, height, data } = pngjs.PNG.sync.read(buffer);
32
+ return { width, height, data };
33
+ }
34
+ if (kind === "jpeg") return jpeg.decode(buffer, { useTArray: true, formatAsRGBA: true, maxResolutionInMP: MAX_PIXELS / 1e6, maxMemoryUsageInMB: 1_024 });
35
+ return null;
36
+ }
37
+
38
+ /** The recogniser's characters, from the character_dict list in its inference.yml. */
39
+ export function characterList(yml) {
40
+ const lines = yml.split(/\r?\n/);
41
+ const out = [];
42
+ for (const line of lines.slice(lines.findIndex((each) => /^\s*character_dict:\s*$/.test(each)) + 1)) {
43
+ const item = line.match(/^\s*- (.*)$/);
44
+ if (!item) break;
45
+ const value = item[1];
46
+ out.push(value.startsWith("'") ? value.slice(1, -1).replace(/''/g, "'") : value.startsWith('"') ? JSON.parse(value) : value);
47
+ }
48
+ if (!out.length) throw new Error("the recogniser's character list is missing");
49
+ return out;
50
+ }
51
+
52
+ // Per axis, the source pixels and weights behind each output pixel: their average when shrinking, bilinear when growing.
53
+ function taps(start, length, count, limit) {
54
+ const scale = length / count;
55
+ const at = (p) => Math.min(limit - 1, Math.max(0, p));
56
+ const out = new Array(count);
57
+ for (let i = 0; i < count; i += 1) {
58
+ const list = [];
59
+ if (scale > 1) {
60
+ const a = start + i * scale, b = a + scale;
61
+ for (let p = Math.floor(a); p < b; p += 1) { const w = Math.min(b, p + 1) - Math.max(a, p); if (w > 0) list.push(at(p), w / scale); }
62
+ } else {
63
+ const c = start + (i + 0.5) * scale - 0.5, p = Math.floor(c), f = c - p;
64
+ list.push(at(p), 1 - f, at(p + 1), f);
65
+ }
66
+ out[i] = list;
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /** A region of the image resampled to width x height as a normalized BGR tensor (PaddleOCR's models read OpenCV's BGR); transparency is laid on white. */
72
+ function tensorOf(image, [x0, y0, x1, y1], width, height, { mean, std }) {
73
+ const xs = taps(x0, x1 - x0, width, image.width), ys = taps(y0, y1 - y0, height, image.height);
74
+ const plane = width * height, out = new Float32Array(3 * plane), data = image.data, stride = image.width * 4;
75
+ for (let j = 0; j < height; j += 1) {
76
+ const ty = ys[j];
77
+ for (let i = 0; i < width; i += 1) {
78
+ const tx = xs[i];
79
+ let r = 0, g = 0, b = 0;
80
+ for (let v = 0; v < ty.length; v += 2) {
81
+ const row = ty[v] * stride, wy = ty[v + 1];
82
+ for (let u = 0; u < tx.length; u += 2) {
83
+ const p = row + tx[u] * 4, w = wy * tx[u + 1], a = data[p + 3] / 255, white = 255 * (1 - a);
84
+ r += (data[p] * a + white) * w; g += (data[p + 1] * a + white) * w; b += (data[p + 2] * a + white) * w;
85
+ }
86
+ }
87
+ const k = j * width + i;
88
+ out[k] = (b / 255 - mean[0]) / std[0];
89
+ out[plane + k] = (g / 255 - mean[1]) / std[1];
90
+ out[2 * plane + k] = (r / 255 - mean[2]) / std[2];
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+
96
+ // DB post-processing: the regions above the threshold whose mean probability passes the box threshold, each grown by
97
+ // area x unclip / perimeter on every side (the detector is trained on text shrunk by that much).
98
+ function regions(map, width, height) {
99
+ const seen = new Uint8Array(width * height), stack = new Int32Array(width * height), boxes = [];
100
+ for (let start = 0; start < map.length; start += 1) {
101
+ if (seen[start] || map[start] <= DET.thresh) continue;
102
+ seen[start] = 1;
103
+ let top = 0, x0 = width, y0 = height, x1 = -1, y1 = -1, sum = 0, count = 0;
104
+ stack[top++] = start;
105
+ const visit = (q) => { if (!seen[q] && map[q] > DET.thresh) { seen[q] = 1; stack[top++] = q; } };
106
+ while (top) {
107
+ const p = stack[--top], x = p % width, y = (p - x) / width;
108
+ sum += map[p]; count += 1;
109
+ if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y;
110
+ if (x > 0) visit(p - 1);
111
+ if (x < width - 1) visit(p + 1);
112
+ if (y > 0) visit(p - width);
113
+ if (y < height - 1) visit(p + width);
114
+ }
115
+ const w = x1 - x0 + 1, h = y1 - y0 + 1;
116
+ if (Math.min(w, h) < 3 || sum / count < DET.boxThresh) continue;
117
+ const d = (w * h * DET.unclip) / (2 * (w + h));
118
+ boxes.push([x0 - d, y0 - d, x1 + 1 + d, y1 + 1 + d]);
119
+ }
120
+ return boxes;
121
+ }
122
+
123
+ // The detector's memory grows with its input (about 1 KB a pixel: 1.6 GB for a 2048x1280 screenshot, measured
124
+ // October 14), and ONNX Runtime keeps memory for every input shape it has run. So the detector reads full-width bands
125
+ // WINDOW rows tall, always the same shape (rows past the image and columns up to a multiple of 512 repeat its edge), and
126
+ // the probability map is stitched from rows at least MARGIN from a band's edge. Lines are never cut: regions are found
127
+ // on the whole map. On the image-redaction eval's screens this found what one pass did, and the OCR models' memory
128
+ // levelled off at 544 MB instead of growing past 2 GB (160-row bands; 256 rows: 766 MB and about 10% faster).
129
+ const BAND = 160, MARGIN = 32, WINDOW = BAND + 2 * MARGIN;
130
+
131
+ async function detect(image, { ort, graph }) {
132
+ const long = Math.max(image.width, image.height);
133
+ const ratio = Math.min(DET.max, Math.max(DET.min, long)) / long;
134
+ const width = Math.max(32, Math.round((image.width * ratio) / 32) * 32), height = Math.max(32, Math.round((image.height * ratio) / 32) * 32);
135
+ const columns = Math.ceil(width / 512) * 512;
136
+ const session = await graph("det/inference.onnx");
137
+ const sx = image.width / width, sy = image.height / height;
138
+ const map = new Float32Array(width * height);
139
+ for (let top = 0; top < height; top += BAND) {
140
+ const a = Math.max(0, Math.min(top - MARGIN, height - WINDOW)), rows = Math.min(WINDOW, height - a);
141
+ const pixels = tensorOf(image, [0, a * sy, image.width, (a + rows) * sy], width, rows, DET), input = new Float32Array(3 * WINDOW * columns);
142
+ // Padding repeats the edge pixels: a flat block beside the image made the detector see text in empty space.
143
+ for (let c = 0; c < 3; c += 1) for (let y = 0; y < WINDOW; y += 1) {
144
+ const from = (c * rows + Math.min(y, rows - 1)) * width, to = (c * WINDOW + y) * columns;
145
+ input.set(pixels.subarray(from, from + width), to);
146
+ input.fill(pixels[from + width - 1], to + width, to + columns);
147
+ }
148
+ const band = (await session.run({ [session.inputNames[0]]: new ort.Tensor("float32", input, [1, 3, WINDOW, columns]) }))[session.outputNames[0]].data;
149
+ for (let y = top; y < Math.min(height, top + BAND); y += 1) map.set(band.subarray((y - a) * columns, (y - a) * columns + width), y * width);
150
+ }
151
+ return regions(map, width, height)
152
+ .map(([x0, y0, x1, y1]) => [Math.max(0, x0 * sx), Math.max(0, y0 * sy), Math.min(image.width, x1 * sx), Math.min(image.height, y1 * sy)])
153
+ .filter(([x0, y0, x1, y1]) => x1 - x0 >= 4 && y1 - y0 >= 4);
154
+ }
155
+
156
+ // ONNX Runtime keeps memory for every input shape it has run (400 crop widths: 108 MB grew to 513 MB, October 14), so
157
+ // crops are padded on the right to a few widths, with 0 after normalization as PaddleOCR's own batches pad. Over this
158
+ // eval's screens that changed 16 of 845 lines, by spacing or a dash, none of a planted value.
159
+ const bucket = (width) => (width <= 512 ? Math.ceil(width / 32) * 32 : Math.ceil(width / 64) * 64);
160
+
161
+ // CTC greedy decoding that keeps, for each character, the output steps it was read over (each step is a fixed slice of
162
+ // the crop's width).
163
+ async function recognise(image, box, characters, { ort, graph }) {
164
+ const session = await graph("rec/inference.onnx");
165
+ const [x0, y0, x1, y1] = box;
166
+ const width = Math.max(8, Math.round((REC.height * (x1 - x0)) / (y1 - y0)));
167
+ const padded = bucket(width), crop = tensorOf(image, box, width, REC.height, REC), tensor = new Float32Array(3 * REC.height * padded);
168
+ for (let row = 0; row < 3 * REC.height; row += 1) tensor.set(crop.subarray(row * width, (row + 1) * width), row * padded);
169
+ const input = new ort.Tensor("float32", tensor, [1, 3, REC.height, padded]);
170
+ const out = (await session.run({ [session.inputNames[0]]: input }))[session.outputNames[0]];
171
+ const [, all, classes] = out.dims, data = out.data;
172
+ const steps = Math.min(all, Math.ceil((all * width) / padded));
173
+ const space = classes === characters.length + 2 ? classes - 1 : -1;
174
+ const pixels = ((x1 - x0) / width) * (padded / all);
175
+ const read = [];
176
+ let total = 0, previous = 0;
177
+ for (let t = 0; t < steps; t += 1) {
178
+ let best = 0, p = data[t * classes];
179
+ for (let c = 1; c < classes; c += 1) if (data[t * classes + c] > p) { p = data[t * classes + c]; best = c; }
180
+ if (best && best === previous) read[read.length - 1].to = x0 + (t + 1) * pixels;
181
+ else if (best) { read.push({ ch: best === space ? " " : characters[best - 1] ?? "", from: x0 + t * pixels, to: x0 + (t + 1) * pixels }); total += p; }
182
+ previous = best;
183
+ }
184
+ // One entry per UTF-16 unit, so chars lines up with the text's indexes.
185
+ const text = read.map((each) => each.ch).join("");
186
+ const chars = read.flatMap(({ ch, from, to }) => Array.from({ length: ch.length }, () => [Math.floor(from), Math.ceil(to)]));
187
+ const lead = text.length - text.trimStart().length, kept = text.trim().length;
188
+ return { text: text.trim(), box: [Math.floor(x0), Math.floor(y0), Math.ceil(x1), Math.ceil(y1)], score: read.length ? total / read.length : 0, chars: chars.slice(lead, lead + kept) };
189
+ }
190
+
191
+ // Top to bottom, left to right within a row (rows: boxes whose tops are within a quarter of their combined height).
192
+ const readingOrder = (lines) => lines.sort((a, b) => (Math.abs(a.box[1] - b.box[1]) < (a.box[3] - a.box[1] + b.box[3] - b.box[1]) / 4 ? a.box[0] - b.box[0] : a.box[1] - b.box[1]));
193
+
194
+ export function createOcr({ inference }) {
195
+ let characters = null;
196
+ /**
197
+ * The text in an image: { width, height, lines: [{ text, box: [x0, y0, x1, y1], score, chars: [[x0, x1]] }], ms }, boxes
198
+ * in the image's pixels. Takes PNG or JPEG bytes or a decoded { width, height, data: RGBA }.
199
+ */
200
+ const read = async (input, { lane = "handoff" } = {}) => {
201
+ const started = performance.now();
202
+ const image = input?.data && input.width ? input : decodeImage(input);
203
+ if (!image) throw new Error("only PNG and JPEG images can be read");
204
+ const boxes = await inference.onnx("ocr", (model) => detect(image, model), { lane });
205
+ const lines = [];
206
+ for (let i = 0; i < boxes.length; i += REC_PER_JOB) {
207
+ lines.push(...await inference.onnx("ocr", async (model) => {
208
+ characters ||= characterList(fs.readFileSync(path.join(model.dir, "rec", "inference.yml"), "utf8"));
209
+ const read = [];
210
+ for (const box of boxes.slice(i, i + REC_PER_JOB)) read.push(await recognise(image, box, characters, model));
211
+ return read;
212
+ }, { lane }));
213
+ }
214
+ return { width: image.width, height: image.height, lines: readingOrder(lines.filter((line) => line.text)), ms: Math.round(performance.now() - started) };
215
+ };
216
+ return { read };
217
+ }