tryharness 0.7.22 → 0.7.25

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 (64) hide show
  1. package/README.md +48 -8
  2. package/assets/opencode-plugin.js +48 -10
  3. package/assets/pi-extension.ts +106 -26
  4. package/cli.mjs +1281 -289
  5. package/lib/activity.mjs +197 -40
  6. package/lib/adapters.mjs +337 -54
  7. package/lib/agentnode.mjs +175 -35
  8. package/lib/atlas.mjs +604 -0
  9. package/lib/bip39-english.txt +2048 -0
  10. package/lib/cards.mjs +177 -39
  11. package/lib/claudemem.mjs +74 -18
  12. package/lib/commands.mjs +13 -5
  13. package/lib/context.mjs +628 -0
  14. package/lib/control-client.mjs +5 -2
  15. package/lib/coordinator.mjs +281 -56
  16. package/lib/daemon.mjs +5590 -838
  17. package/lib/desktop.mjs +149 -35
  18. package/lib/digest.mjs +81 -0
  19. package/lib/environment.mjs +180 -0
  20. package/lib/files.mjs +246 -13
  21. package/lib/firewall.mjs +288 -0
  22. package/lib/folders.mjs +223 -57
  23. package/lib/frames.mjs +333 -0
  24. package/lib/inference.mjs +663 -0
  25. package/lib/learn.mjs +182 -0
  26. package/lib/live.mjs +286 -61
  27. package/lib/localmodels.mjs +571 -0
  28. package/lib/mcp-stdio.mjs +912 -103
  29. package/lib/media.mjs +228 -0
  30. package/lib/members.json +1 -1
  31. package/lib/members.mjs +35 -9
  32. package/lib/models.mjs +342 -80
  33. package/lib/network.mjs +201 -42
  34. package/lib/notify.mjs +49 -12
  35. package/lib/nudge.mjs +190 -32
  36. package/lib/ocr.mjs +380 -0
  37. package/lib/onboard.mjs +321 -70
  38. package/lib/parsers.mjs +132 -41
  39. package/lib/paths.mjs +33 -10
  40. package/lib/personal.mjs +77 -0
  41. package/lib/phones.mjs +398 -51
  42. package/lib/places.mjs +566 -102
  43. package/lib/provenance.mjs +19 -4
  44. package/lib/receipts.mjs +82 -0
  45. package/lib/redact.mjs +730 -0
  46. package/lib/runner.mjs +347 -55
  47. package/lib/runtime-install.mjs +59 -17
  48. package/lib/screens.mjs +212 -0
  49. package/lib/sealed.mjs +160 -28
  50. package/lib/secrets.mjs +18 -5
  51. package/lib/service.mjs +173 -35
  52. package/lib/snippets.mjs +140 -0
  53. package/lib/speech.mjs +993 -0
  54. package/lib/store.mjs +1678 -301
  55. package/lib/swarm.mjs +598 -117
  56. package/lib/t3.mjs +400 -79
  57. package/lib/trails.mjs +401 -0
  58. package/lib/ui.mjs +95 -23
  59. package/lib/vectors.mjs +5 -2
  60. package/lib/wakes.mjs +284 -68
  61. package/lib/watches.mjs +362 -0
  62. package/lib/worktrees.mjs +186 -38
  63. package/package.json +6 -4
  64. package/lib/embedder.mjs +0 -156
package/lib/nudge.mjs CHANGED
@@ -18,16 +18,50 @@ export function nudgeText(agent, tasks, unread = [], goals = [], swarm = "") {
18
18
  }
19
19
 
20
20
  function queueText(agent, tasks, unread, goals) {
21
- const waiting = (tasks || []).filter((task) => task.status === "open" && (!task.target_agent || task.target_agent === agent));
22
- const held = (tasks || []).filter((task) => task.claimed_by === agent && (task.status === "claimed" || task.status === "needs_input"));
23
- if (!waiting.length && !held.length && !unread.length && !goals.length) return "";
24
- const line = (task) => `- ${task.id}: ${task.objective}${task.origin_agent ? ` (from ${task.trust === "agent" && task.origin_chain?.length > 1 ? task.origin_chain.join(" → ") : task.origin_agent}${task.trust === "agent" ? ", agent-written" : ""})` : ""}${task.status === "needs_input" ? " · your question was answered" : ""}${task.thread_with ? ` · shared with ${task.thread_with}` : ""}`;
21
+ const waiting = (tasks || []).filter(
22
+ (task) =>
23
+ task.status === "open" &&
24
+ (!task.target_agent || task.target_agent === agent)
25
+ );
26
+ const held = (tasks || []).filter(
27
+ (task) =>
28
+ task.claimed_by === agent &&
29
+ (task.status === "claimed" || task.status === "needs_input")
30
+ );
31
+ if (!waiting.length && !held.length && !unread.length && !goals.length)
32
+ return "";
33
+ const line = (task) =>
34
+ `- ${task.id}: ${task.objective}${task.origin_agent ? ` (from ${task.trust === "agent" && task.origin_chain?.length > 1 ? task.origin_chain.join(" → ") : task.origin_agent}${task.trust === "agent" ? ", agent-written" : ""})` : ""}${task.status === "needs_input" ? " · your question was answered" : ""}${task.thread_with ? ` · shared with ${task.thread_with}` : ""}`;
25
35
  const parts = ["Harness task board:"];
26
- if (waiting.length) parts.push(`${waiting.length} task${waiting.length === 1 ? "" : "s"} waiting for you:`, ...waiting.slice(0, 5).map(line));
27
- if (held.length) parts.push(`${held.length} you already claimed:`, ...held.slice(0, 5).map(line));
28
- if (unread.length) parts.push(...unread.slice(0, 5).map((row) => `- ${row.task_id}: ${row.count} new message${row.count === 1 ? "" : "s"} from ${row.sender} on "${row.objective}" (read_thread)`));
29
- if (goals.length) parts.push(`You are on ${goals.length} goal room${goals.length === 1 ? "" : "s"} (read_room to catch up, post_to_room to contribute):`, ...goals.slice(0, 5).map((goal) => `- ${goal.id}: ${goal.objective} · ${goal.posts} posts`));
30
- parts.push("Read one with get_task, claim it with claim_task before working, and report_task when done or stuck.");
36
+ if (waiting.length)
37
+ parts.push(
38
+ `${waiting.length} task${waiting.length === 1 ? "" : "s"} waiting for you:`,
39
+ ...waiting.slice(0, 5).map(line)
40
+ );
41
+ if (held.length)
42
+ parts.push(
43
+ `${held.length} you already claimed:`,
44
+ ...held.slice(0, 5).map(line)
45
+ );
46
+ if (unread.length)
47
+ parts.push(
48
+ ...unread
49
+ .slice(0, 5)
50
+ .map(
51
+ (row) =>
52
+ `- ${row.task_id}: ${row.count} new message${row.count === 1 ? "" : "s"} from ${row.sender} on "${row.objective}" (read_thread)`
53
+ )
54
+ );
55
+ if (goals.length)
56
+ parts.push(
57
+ `You are on ${goals.length} goal room${goals.length === 1 ? "" : "s"} (read_room to catch up, post_to_room to contribute):`,
58
+ ...goals
59
+ .slice(0, 5)
60
+ .map((goal) => `- ${goal.id}: ${goal.objective} · ${goal.posts} posts`)
61
+ );
62
+ parts.push(
63
+ "Read one with get_task, claim it with claim_task before working, and report_task when done or stuck."
64
+ );
31
65
  return parts.join("\n");
32
66
  }
33
67
 
@@ -35,17 +69,35 @@ function queueText(agent, tasks, unread, goals) {
35
69
  export function queueDigest(agent, tasks, unread = [], goals = []) {
36
70
  return [
37
71
  ...(tasks || [])
38
- .filter((task) => (task.status === "open" && (!task.target_agent || task.target_agent === agent)) || (task.claimed_by === agent && ["claimed", "needs_input"].includes(task.status)))
72
+ .filter(
73
+ (task) =>
74
+ (task.status === "open" &&
75
+ (!task.target_agent || task.target_agent === agent)) ||
76
+ (task.claimed_by === agent &&
77
+ ["claimed", "needs_input"].includes(task.status))
78
+ )
39
79
  .map((task) => `${task.id}:${task.status}:${task.version}`),
40
80
  ...unread.map((row) => `${row.task_id}:unread:${row.count}`),
41
81
  ...goals.map((goal) => `${goal.id}:goal:${goal.posts}`),
42
- ].sort().join("|");
82
+ ]
83
+ .sort()
84
+ .join("|");
43
85
  }
44
86
 
45
87
  export async function boardQueue(agent) {
46
88
  try {
47
- const response = await daemonRequest({ command: "board.list", agent, unread: true, swarm: true }, 1_500);
48
- return response?.ok ? { tasks: response.tasks, unread: response.unread || [], goals: response.goals || [], swarm: response.swarm || null } : null;
89
+ const response = await daemonRequest(
90
+ { command: "board.list", agent, unread: true, swarm: true },
91
+ 1_500
92
+ );
93
+ return response?.ok
94
+ ? {
95
+ tasks: response.tasks,
96
+ unread: response.unread || [],
97
+ goals: response.goals || [],
98
+ swarm: response.swarm || null,
99
+ }
100
+ : null;
49
101
  } catch {
50
102
  return null;
51
103
  }
@@ -53,15 +105,40 @@ export async function boardQueue(agent) {
53
105
 
54
106
  // What this agent was last told, in the Harness home: the queue's digest and the swarm's.
55
107
  const toldFile = (agent, homeDir) => path.join(homeDir, `nudge-${agent}.json`);
56
- function lastTold(agent, homeDir) { try { return JSON.parse(fs.readFileSync(toldFile(agent, homeDir), "utf8")); } catch { return {}; } }
57
- function noteTold(agent, homeDir, told) { try { fs.writeFileSync(toldFile(agent, homeDir), JSON.stringify({ ...lastTold(agent, homeDir), ...told, at: new Date().toISOString() }), { mode: 0o600 }); } catch {} }
108
+ function lastTold(agent, homeDir) {
109
+ try {
110
+ return JSON.parse(fs.readFileSync(toldFile(agent, homeDir), "utf8"));
111
+ } catch {
112
+ return {};
113
+ }
114
+ }
115
+ function noteTold(agent, homeDir, told) {
116
+ try {
117
+ fs.writeFileSync(
118
+ toldFile(agent, homeDir),
119
+ JSON.stringify({
120
+ ...lastTold(agent, homeDir),
121
+ ...told,
122
+ at: new Date().toISOString(),
123
+ }),
124
+ { mode: 0o600 }
125
+ );
126
+ } catch {}
127
+ }
58
128
 
59
129
  /** What a session is told when it starts: its queue and the whole swarm. */
60
130
  export async function boardNudge(agent, homeDir = paths.home) {
61
131
  const queue = await boardQueue(agent);
62
132
  if (!queue) return "";
63
- if (queue.swarm?.digest) noteTold(agent, homeDir, { swarm: queue.swarm.digest });
64
- return nudgeText(agent, queue.tasks, queue.unread, queue.goals, queue.swarm?.text || "");
133
+ if (queue.swarm?.digest)
134
+ noteTold(agent, homeDir, { swarm: queue.swarm.digest });
135
+ return nudgeText(
136
+ agent,
137
+ queue.tasks,
138
+ queue.unread,
139
+ queue.goals,
140
+ queue.swarm?.text || ""
141
+ );
65
142
  }
66
143
 
67
144
  // Only what changed since this agent last saw its queue: a hook that runs on every prompt
@@ -76,40 +153,90 @@ export async function boardNudgeIfChanged(agent, homeDir = paths.home) {
76
153
  const queueMoved = digest !== (last.digest ?? null);
77
154
  const swarmMoved = Boolean(swarm?.digest) && swarm.digest !== last.swarm;
78
155
  if (!queueMoved && !swarmMoved) return "";
79
- noteTold(agent, homeDir, { digest, ...(swarm?.digest ? { swarm: swarm.digest } : {}) });
80
- const told = !queueMoved ? "" : digest ? queueText(agent, tasks, unread, goals) : (last.digest ? "Harness task board: nothing waits for you now." : "");
156
+ noteTold(agent, homeDir, {
157
+ digest,
158
+ ...(swarm?.digest ? { swarm: swarm.digest } : {}),
159
+ });
160
+ const told = !queueMoved
161
+ ? ""
162
+ : digest
163
+ ? queueText(agent, tasks, unread, goals)
164
+ : last.digest
165
+ ? "Harness task board: nothing waits for you now."
166
+ : "";
81
167
  return [told, swarmMoved ? swarm.text : ""].filter(Boolean).join("\n\n");
82
168
  }
83
169
 
84
170
  // The prompt hook also tells the daemon the person typed in this session: the agent process is the hook's parent, or
85
171
  // its grandparent when the hook runs through a shell.
86
- export const PS = ["/bin/ps", "/usr/bin/ps"].find((file) => fs.existsSync(file)) || "ps";
172
+ export const PS =
173
+ ["/bin/ps", "/usr/bin/ps"].find((file) => fs.existsSync(file)) || "ps";
87
174
 
88
175
  function agentPids() {
89
176
  const pids = [process.ppid];
90
- try { const up = Number(spawnSync(PS, ["-o", "ppid=", "-p", String(process.ppid)], { encoding: "utf8" }).stdout.trim()); if (up > 1) pids.push(up); } catch {}
177
+ try {
178
+ const up = Number(
179
+ spawnSync(PS, ["-o", "ppid=", "-p", String(process.ppid)], {
180
+ encoding: "utf8",
181
+ }).stdout.trim()
182
+ );
183
+ if (up > 1) pids.push(up);
184
+ } catch {}
91
185
  return pids;
92
186
  }
93
187
 
94
188
  // Where the session runs, as its own environment says (the agent's, which the hook shares): places.mjs reads it.
95
- const placeOf = (input) => ({ sessionId: input?.session_id, folder: input?.cwd || process.cwd(), env: placeEnv(process.env) });
189
+ const placeOf = (input) => ({
190
+ sessionId: input?.session_id,
191
+ folder: input?.cwd || process.cwd(),
192
+ env: placeEnv(process.env),
193
+ });
96
194
 
97
195
  // An extension or plugin running inside the agent passes its own process ids.
98
196
  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 {}
197
+ try {
198
+ await daemonRequest(
199
+ {
200
+ command: "board.prompted",
201
+ agent,
202
+ pids,
203
+ ...(input?.session_id ? placeOf(input) : {}),
204
+ },
205
+ 1_500
206
+ );
207
+ } catch {}
100
208
  }
101
209
 
102
210
  /** A session starting says where it runs. */
103
211
  export async function notePlace(agent, input, pids = agentPids()) {
104
212
  if (!input?.session_id) return;
105
- try { await daemonRequest({ command: "board.live.place", agent, pids, ...placeOf(input) }, 1_500); } catch {}
213
+ try {
214
+ await daemonRequest(
215
+ { command: "board.live.place", agent, pids, ...placeOf(input) },
216
+ 1_500
217
+ );
218
+ } catch {}
219
+ }
220
+
221
+ /** A turn ended with nothing more to do: the session waits for the person, and the buddy can say so. */
222
+ export async function noteTurn(agent, input, pids = agentPids()) {
223
+ if (!input?.session_id) return;
224
+ try {
225
+ await daemonRequest(
226
+ { command: "board.live.turn", agent, pids, ...placeOf(input) },
227
+ 1_500
228
+ );
229
+ } catch {}
106
230
  }
107
231
 
108
232
  // What the person sent this session from Harness (daemon board.live.say), taken once.
109
233
  export async function takeMessages(agent, sessionId) {
110
234
  if (!sessionId) return [];
111
235
  try {
112
- const response = await daemonRequest({ command: "board.live.take", agent, sessionId }, 1_500);
236
+ const response = await daemonRequest(
237
+ { command: "board.live.take", agent, sessionId },
238
+ 1_500
239
+ );
113
240
  return response?.ok ? response.messages || [] : [];
114
241
  } catch {
115
242
  return [];
@@ -117,7 +244,11 @@ export async function takeMessages(agent, sessionId) {
117
244
  }
118
245
 
119
246
  export function messagesText(messages) {
120
- return messages.map((message) => `The person sent you this from Harness:\n\n${message.text}`).join("\n\n");
247
+ return messages
248
+ .map(
249
+ (message) => `The person sent you this from Harness:\n\n${message.text}`
250
+ )
251
+ .join("\n\n");
121
252
  }
122
253
 
123
254
  // The JSON the agent hands every hook on stdin (session_id among it); null when there is none.
@@ -125,10 +256,23 @@ async function hookInput() {
125
256
  if (process.stdin.isTTY) return null;
126
257
  let text = "";
127
258
  process.stdin.setEncoding("utf8");
128
- const read = new Promise((resolve) => { process.stdin.on("data", (chunk) => { text += chunk; }); process.stdin.on("end", resolve); process.stdin.on("error", resolve); });
129
- await Promise.race([read, new Promise((resolve) => setTimeout(resolve, 1_000).unref())]);
259
+ const read = new Promise((resolve) => {
260
+ process.stdin.on("data", (chunk) => {
261
+ text += chunk;
262
+ });
263
+ process.stdin.on("end", resolve);
264
+ process.stdin.on("error", resolve);
265
+ });
266
+ await Promise.race([
267
+ read,
268
+ new Promise((resolve) => setTimeout(resolve, 1_000).unref()),
269
+ ]);
130
270
  process.stdin.destroy();
131
- try { return JSON.parse(text); } catch { return null; }
271
+ try {
272
+ return JSON.parse(text);
273
+ } catch {
274
+ return null;
275
+ }
132
276
  }
133
277
 
134
278
  if (import.meta.url === `file://${process.argv[1]}`) {
@@ -139,12 +283,26 @@ if (import.meta.url === `file://${process.argv[1]}`) {
139
283
  if (process.argv.includes("--stop")) {
140
284
  // The Stop hook: a message waiting for this session keeps the agent going with it.
141
285
  const messages = await takeMessages(agent, sessionId);
142
- if (messages.length) process.stdout.write(JSON.stringify({ decision: "block", reason: messagesText(messages) }) + "\n");
286
+ if (messages.length)
287
+ process.stdout.write(
288
+ JSON.stringify({ decision: "block", reason: messagesText(messages) }) +
289
+ "\n"
290
+ );
291
+ else await noteTurn(agent, input);
143
292
  } else {
144
293
  if (process.argv.includes("--if-changed")) await notePrompt(agent, input);
145
294
  else await notePlace(agent, input);
146
- const messages = process.argv.includes("--if-changed") ? await takeMessages(agent, sessionId) : [];
147
- const text = [messages.length ? messagesText(messages) : "", process.argv.includes("--if-changed") ? await boardNudgeIfChanged(agent) : await boardNudge(agent)].filter(Boolean).join("\n\n");
295
+ const messages = process.argv.includes("--if-changed")
296
+ ? await takeMessages(agent, sessionId)
297
+ : [];
298
+ const text = [
299
+ messages.length ? messagesText(messages) : "",
300
+ process.argv.includes("--if-changed")
301
+ ? await boardNudgeIfChanged(agent)
302
+ : await boardNudge(agent),
303
+ ]
304
+ .filter(Boolean)
305
+ .join("\n\n");
148
306
  if (text) process.stdout.write(text + "\n");
149
307
  }
150
308
  }
package/lib/ocr.mjs ADDED
@@ -0,0 +1,380 @@
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 = {
18
+ min: 960,
19
+ max: 2_048,
20
+ thresh: 0.2,
21
+ boxThresh: 0.45,
22
+ unclip: 1.4,
23
+ mean: [0.485, 0.456, 0.406],
24
+ std: [0.229, 0.224, 0.225],
25
+ };
26
+ const REC = { height: 48, mean: [0.5, 0.5, 0.5], std: [0.5, 0.5, 0.5] };
27
+ // Boxes read per scheduler job, so a long image does not hold the ONNX lane from the firewall's checks.
28
+ const REC_PER_JOB = 24;
29
+
30
+ export const imageKind = (bytes) =>
31
+ bytes?.length > 8 &&
32
+ bytes[0] === 0x89 &&
33
+ bytes[1] === 0x50 &&
34
+ bytes[2] === 0x4e &&
35
+ bytes[3] === 0x47
36
+ ? "png"
37
+ : bytes?.length > 3 &&
38
+ bytes[0] === 0xff &&
39
+ bytes[1] === 0xd8 &&
40
+ bytes[2] === 0xff
41
+ ? "jpeg"
42
+ : null;
43
+
44
+ /** { width, height, data: RGBA } from PNG or JPEG bytes; null for any other format. Throws on a broken or oversized image. */
45
+ export function decodeImage(bytes) {
46
+ const buffer = Buffer.from(
47
+ bytes.buffer ?? bytes,
48
+ bytes.byteOffset ?? 0,
49
+ bytes.byteLength ?? bytes.length
50
+ );
51
+ const kind = imageKind(buffer);
52
+ if (kind === "png") {
53
+ if (buffer.readUInt32BE(16) * buffer.readUInt32BE(20) > MAX_PIXELS)
54
+ throw new Error("the image is too large to check");
55
+ const { width, height, data } = pngjs.PNG.sync.read(buffer);
56
+ return { width, height, data };
57
+ }
58
+ if (kind === "jpeg")
59
+ return jpeg.decode(buffer, {
60
+ useTArray: true,
61
+ formatAsRGBA: true,
62
+ maxResolutionInMP: MAX_PIXELS / 1e6,
63
+ maxMemoryUsageInMB: 1_024,
64
+ });
65
+ return null;
66
+ }
67
+
68
+ /** The recogniser's characters, from the character_dict list in its inference.yml. */
69
+ export function characterList(yml) {
70
+ const lines = yml.split(/\r?\n/);
71
+ const out = [];
72
+ for (const line of lines.slice(
73
+ lines.findIndex((each) => /^\s*character_dict:\s*$/.test(each)) + 1
74
+ )) {
75
+ const item = line.match(/^\s*- (.*)$/);
76
+ if (!item) break;
77
+ const value = item[1];
78
+ out.push(
79
+ value.startsWith("'")
80
+ ? value.slice(1, -1).replace(/''/g, "'")
81
+ : value.startsWith('"')
82
+ ? JSON.parse(value)
83
+ : value
84
+ );
85
+ }
86
+ if (!out.length)
87
+ throw new Error("the recogniser's character list is missing");
88
+ return out;
89
+ }
90
+
91
+ // Per axis, the source pixels and weights behind each output pixel: their average when shrinking, bilinear when growing.
92
+ function taps(start, length, count, limit) {
93
+ const scale = length / count;
94
+ const at = (p) => Math.min(limit - 1, Math.max(0, p));
95
+ const out = new Array(count);
96
+ for (let i = 0; i < count; i += 1) {
97
+ const list = [];
98
+ if (scale > 1) {
99
+ const a = start + i * scale,
100
+ b = a + scale;
101
+ for (let p = Math.floor(a); p < b; p += 1) {
102
+ const w = Math.min(b, p + 1) - Math.max(a, p);
103
+ if (w > 0) list.push(at(p), w / scale);
104
+ }
105
+ } else {
106
+ const c = start + (i + 0.5) * scale - 0.5,
107
+ p = Math.floor(c),
108
+ f = c - p;
109
+ list.push(at(p), 1 - f, at(p + 1), f);
110
+ }
111
+ out[i] = list;
112
+ }
113
+ return out;
114
+ }
115
+
116
+ /** 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. */
117
+ function tensorOf(image, [x0, y0, x1, y1], width, height, { mean, std }) {
118
+ const xs = taps(x0, x1 - x0, width, image.width),
119
+ ys = taps(y0, y1 - y0, height, image.height);
120
+ const plane = width * height,
121
+ out = new Float32Array(3 * plane),
122
+ data = image.data,
123
+ stride = image.width * 4;
124
+ for (let j = 0; j < height; j += 1) {
125
+ const ty = ys[j];
126
+ for (let i = 0; i < width; i += 1) {
127
+ const tx = xs[i];
128
+ let r = 0,
129
+ g = 0,
130
+ b = 0;
131
+ for (let v = 0; v < ty.length; v += 2) {
132
+ const row = ty[v] * stride,
133
+ wy = ty[v + 1];
134
+ for (let u = 0; u < tx.length; u += 2) {
135
+ const p = row + tx[u] * 4,
136
+ w = wy * tx[u + 1],
137
+ a = data[p + 3] / 255,
138
+ white = 255 * (1 - a);
139
+ r += (data[p] * a + white) * w;
140
+ g += (data[p + 1] * a + white) * w;
141
+ b += (data[p + 2] * a + white) * w;
142
+ }
143
+ }
144
+ const k = j * width + i;
145
+ out[k] = (b / 255 - mean[0]) / std[0];
146
+ out[plane + k] = (g / 255 - mean[1]) / std[1];
147
+ out[2 * plane + k] = (r / 255 - mean[2]) / std[2];
148
+ }
149
+ }
150
+ return out;
151
+ }
152
+
153
+ // DB post-processing: the regions above the threshold whose mean probability passes the box threshold, each grown by
154
+ // area x unclip / perimeter on every side (the detector is trained on text shrunk by that much).
155
+ function regions(map, width, height) {
156
+ const seen = new Uint8Array(width * height),
157
+ stack = new Int32Array(width * height),
158
+ boxes = [];
159
+ for (let start = 0; start < map.length; start += 1) {
160
+ if (seen[start] || map[start] <= DET.thresh) continue;
161
+ seen[start] = 1;
162
+ let top = 0,
163
+ x0 = width,
164
+ y0 = height,
165
+ x1 = -1,
166
+ y1 = -1,
167
+ sum = 0,
168
+ count = 0;
169
+ stack[top++] = start;
170
+ const visit = (q) => {
171
+ if (!seen[q] && map[q] > DET.thresh) {
172
+ seen[q] = 1;
173
+ stack[top++] = q;
174
+ }
175
+ };
176
+ while (top) {
177
+ const p = stack[--top],
178
+ x = p % width,
179
+ y = (p - x) / width;
180
+ sum += map[p];
181
+ count += 1;
182
+ if (x < x0) x0 = x;
183
+ if (x > x1) x1 = x;
184
+ if (y < y0) y0 = y;
185
+ if (y > y1) y1 = y;
186
+ if (x > 0) visit(p - 1);
187
+ if (x < width - 1) visit(p + 1);
188
+ if (y > 0) visit(p - width);
189
+ if (y < height - 1) visit(p + width);
190
+ }
191
+ const w = x1 - x0 + 1,
192
+ h = y1 - y0 + 1;
193
+ if (Math.min(w, h) < 3 || sum / count < DET.boxThresh) continue;
194
+ const d = (w * h * DET.unclip) / (2 * (w + h));
195
+ boxes.push([x0 - d, y0 - d, x1 + 1 + d, y1 + 1 + d]);
196
+ }
197
+ return boxes;
198
+ }
199
+
200
+ // The detector's memory grows with its input (about 1 KB a pixel: 1.6 GB for a 2048x1280 screenshot, measured
201
+ // October 14), and ONNX Runtime keeps memory for every input shape it has run. So the detector reads full-width bands
202
+ // WINDOW rows tall, always the same shape (rows past the image and columns up to a multiple of 512 repeat its edge), and
203
+ // the probability map is stitched from rows at least MARGIN from a band's edge. Lines are never cut: regions are found
204
+ // on the whole map. On the image-redaction eval's screens this found what one pass did, and the OCR models' memory
205
+ // levelled off at 544 MB instead of growing past 2 GB (160-row bands; 256 rows: 766 MB and about 10% faster).
206
+ const BAND = 160,
207
+ MARGIN = 32,
208
+ WINDOW = BAND + 2 * MARGIN;
209
+
210
+ async function detect(image, { ort, graph }) {
211
+ const long = Math.max(image.width, image.height);
212
+ const ratio = Math.min(DET.max, Math.max(DET.min, long)) / long;
213
+ const width = Math.max(32, Math.round((image.width * ratio) / 32) * 32),
214
+ height = Math.max(32, Math.round((image.height * ratio) / 32) * 32);
215
+ const columns = Math.ceil(width / 512) * 512;
216
+ const session = await graph("det/inference.onnx");
217
+ const sx = image.width / width,
218
+ sy = image.height / height;
219
+ const map = new Float32Array(width * height);
220
+ for (let top = 0; top < height; top += BAND) {
221
+ const a = Math.max(0, Math.min(top - MARGIN, height - WINDOW)),
222
+ rows = Math.min(WINDOW, height - a);
223
+ const pixels = tensorOf(
224
+ image,
225
+ [0, a * sy, image.width, (a + rows) * sy],
226
+ width,
227
+ rows,
228
+ DET
229
+ ),
230
+ input = new Float32Array(3 * WINDOW * columns);
231
+ // Padding repeats the edge pixels: a flat block beside the image made the detector see text in empty space.
232
+ for (let c = 0; c < 3; c += 1)
233
+ for (let y = 0; y < WINDOW; y += 1) {
234
+ const from = (c * rows + Math.min(y, rows - 1)) * width,
235
+ to = (c * WINDOW + y) * columns;
236
+ input.set(pixels.subarray(from, from + width), to);
237
+ input.fill(pixels[from + width - 1], to + width, to + columns);
238
+ }
239
+ const band = (
240
+ await session.run({
241
+ [session.inputNames[0]]: new ort.Tensor("float32", input, [
242
+ 1,
243
+ 3,
244
+ WINDOW,
245
+ columns,
246
+ ]),
247
+ })
248
+ )[session.outputNames[0]].data;
249
+ for (let y = top; y < Math.min(height, top + BAND); y += 1)
250
+ map.set(
251
+ band.subarray((y - a) * columns, (y - a) * columns + width),
252
+ y * width
253
+ );
254
+ }
255
+ return regions(map, width, height)
256
+ .map(([x0, y0, x1, y1]) => [
257
+ Math.max(0, x0 * sx),
258
+ Math.max(0, y0 * sy),
259
+ Math.min(image.width, x1 * sx),
260
+ Math.min(image.height, y1 * sy),
261
+ ])
262
+ .filter(([x0, y0, x1, y1]) => x1 - x0 >= 4 && y1 - y0 >= 4);
263
+ }
264
+
265
+ // ONNX Runtime keeps memory for every input shape it has run (400 crop widths: 108 MB grew to 513 MB, October 14), so
266
+ // crops are padded on the right to a few widths, with 0 after normalization as PaddleOCR's own batches pad. Over this
267
+ // eval's screens that changed 16 of 845 lines, by spacing or a dash, none of a planted value.
268
+ const bucket = (width) =>
269
+ width <= 512 ? Math.ceil(width / 32) * 32 : Math.ceil(width / 64) * 64;
270
+
271
+ // CTC greedy decoding that keeps, for each character, the output steps it was read over (each step is a fixed slice of
272
+ // the crop's width).
273
+ async function recognise(image, box, characters, { ort, graph }) {
274
+ const session = await graph("rec/inference.onnx");
275
+ const [x0, y0, x1, y1] = box;
276
+ const width = Math.max(8, Math.round((REC.height * (x1 - x0)) / (y1 - y0)));
277
+ const padded = bucket(width),
278
+ crop = tensorOf(image, box, width, REC.height, REC),
279
+ tensor = new Float32Array(3 * REC.height * padded);
280
+ for (let row = 0; row < 3 * REC.height; row += 1)
281
+ tensor.set(crop.subarray(row * width, (row + 1) * width), row * padded);
282
+ const input = new ort.Tensor("float32", tensor, [1, 3, REC.height, padded]);
283
+ const out = (await session.run({ [session.inputNames[0]]: input }))[
284
+ session.outputNames[0]
285
+ ];
286
+ const [, all, classes] = out.dims,
287
+ data = out.data;
288
+ const steps = Math.min(all, Math.ceil((all * width) / padded));
289
+ const space = classes === characters.length + 2 ? classes - 1 : -1;
290
+ const pixels = ((x1 - x0) / width) * (padded / all);
291
+ const read = [];
292
+ let total = 0,
293
+ previous = 0;
294
+ for (let t = 0; t < steps; t += 1) {
295
+ let best = 0,
296
+ p = data[t * classes];
297
+ for (let c = 1; c < classes; c += 1)
298
+ if (data[t * classes + c] > p) {
299
+ p = data[t * classes + c];
300
+ best = c;
301
+ }
302
+ if (best && best === previous)
303
+ read[read.length - 1].to = x0 + (t + 1) * pixels;
304
+ else if (best) {
305
+ read.push({
306
+ ch: best === space ? " " : (characters[best - 1] ?? ""),
307
+ from: x0 + t * pixels,
308
+ to: x0 + (t + 1) * pixels,
309
+ });
310
+ total += p;
311
+ }
312
+ previous = best;
313
+ }
314
+ // One entry per UTF-16 unit, so chars lines up with the text's indexes.
315
+ const text = read.map((each) => each.ch).join("");
316
+ const chars = read.flatMap(({ ch, from, to }) =>
317
+ Array.from({ length: ch.length }, () => [Math.floor(from), Math.ceil(to)])
318
+ );
319
+ const lead = text.length - text.trimStart().length,
320
+ kept = text.trim().length;
321
+ return {
322
+ text: text.trim(),
323
+ box: [Math.floor(x0), Math.floor(y0), Math.ceil(x1), Math.ceil(y1)],
324
+ score: read.length ? total / read.length : 0,
325
+ chars: chars.slice(lead, lead + kept),
326
+ };
327
+ }
328
+
329
+ // Top to bottom, left to right within a row (rows: boxes whose tops are within a quarter of their combined height).
330
+ const readingOrder = (lines) =>
331
+ lines.sort((a, b) =>
332
+ Math.abs(a.box[1] - b.box[1]) <
333
+ (a.box[3] - a.box[1] + b.box[3] - b.box[1]) / 4
334
+ ? a.box[0] - b.box[0]
335
+ : a.box[1] - b.box[1]
336
+ );
337
+
338
+ export function createOcr({ inference }) {
339
+ let characters = null;
340
+ /**
341
+ * The text in an image: { width, height, lines: [{ text, box: [x0, y0, x1, y1], score, chars: [[x0, x1]] }], ms }, boxes
342
+ * in the image's pixels. Takes PNG or JPEG bytes or a decoded { width, height, data: RGBA }.
343
+ */
344
+ const read = async (input, { lane = "handoff" } = {}) => {
345
+ const started = performance.now();
346
+ const image = input?.data && input.width ? input : decodeImage(input);
347
+ if (!image) throw new Error("only PNG and JPEG images can be read");
348
+ const boxes = await inference.onnx("ocr", (model) => detect(image, model), {
349
+ lane,
350
+ });
351
+ const lines = [];
352
+ for (let i = 0; i < boxes.length; i += REC_PER_JOB) {
353
+ lines.push(
354
+ ...(await inference.onnx(
355
+ "ocr",
356
+ async (model) => {
357
+ characters ||= characterList(
358
+ fs.readFileSync(
359
+ path.join(model.dir, "rec", "inference.yml"),
360
+ "utf8"
361
+ )
362
+ );
363
+ const read = [];
364
+ for (const box of boxes.slice(i, i + REC_PER_JOB))
365
+ read.push(await recognise(image, box, characters, model));
366
+ return read;
367
+ },
368
+ { lane }
369
+ ))
370
+ );
371
+ }
372
+ return {
373
+ width: image.width,
374
+ height: image.height,
375
+ lines: readingOrder(lines.filter((line) => line.text)),
376
+ ms: Math.round(performance.now() - started),
377
+ };
378
+ };
379
+ return { read };
380
+ }