tryharness 0.7.22 → 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.
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
+ }
@@ -0,0 +1,39 @@
1
+ // Personal details in text: an email, a phone number, a wallet address, an amount of money. The same patterns cover
2
+ // screenshots read by OCR (redact.mjs) and the words of the relay's decision-only requests to Harness (firewall.mjs).
3
+
4
+ // Wallet addresses: Ethereum-style 0x and 40 hex (OCR reads some 0s as Os), the shortened 0x1234…abcd form apps show,
5
+ // bech32 (bc1…), and base58 strings long and mixed enough to be addresses or keys rather than words or identifiers
6
+ // (Solana, Bitcoin legacy, base58 private keys), one misread character allowed. Hex alone is a hash, not an address,
7
+ // and a sha512-… integrity string is a hash in base64. OCR often runs a label into the value ("addressbc1q…"), so the
8
+ // prefixed forms need no boundary before them.
9
+ export const EVM = /[0O][xX][0-9a-fA-FOo]{38,42}(?![\w])/g;
10
+ export const EVM_SHORT = /0[xX][0-9a-fA-F]{3,8}(?:…|\.{2,3})[0-9a-fA-F]{3,8}(?![\w])/g;
11
+ export const BECH32 = /(?:bc1|tb1|ltc1)[a-zA-Z0-9]{20,90}(?![\w])/g;
12
+ export const BASE58ISH = /(?<![\w])[0-9A-Za-z]{25,90}(?![\w])/g;
13
+ export const base58 = (token) => (token.match(/[0OIl]/g) || []).length <= 1 && (token.match(/\d/g) || []).length >= 2 && (token.match(/[A-Z]/g) || []).length >= 3 && (token.match(/[a-z]/g) || []).length >= 3 && /[g-zG-Z]/.test(token);
14
+ // Money: an amount with a currency sign or code (a lone digit after $ is a shell variable, not money).
15
+ const CODES = "USD|USDC|USDT|EUR|GBP|JPY|CHF|CAD|AUD|ETH|WETH|stETH|BTC|WBTC|cbBTC|SOL|DAI|BNB|MATIC|POL|ARB|OP|AVAX|DOGE|XRP|ADA|DOT|LINK|UNI|AAVE|DEGEN";
16
+ const AMOUNT = "\\d{1,3}(?:[,']\\d{3})+(?:[.,]\\d+)?|\\d+(?:[.,]\\d+)?";
17
+ export const MONEY = new RegExp(`(?<![\\w$€£¥])(?:[-+−]\\s?)?(?:(?:US)?[$€£¥₹₩₿Ξ]\\s?(?:${AMOUNT})(?:\\s?[KMBkmb](?![A-Za-z]))?|(?:${AMOUNT})(?:\\s?[KMBkmb](?=\\s?(?:${CODES})))?\\s?(?:(?:${CODES})(?![A-Za-z])|[€£₿Ξ]))`, "g");
18
+ export const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/g;
19
+ // Phone numbers: digit groups joined by single spaces, dots or dashes, with an optional +country code and (area code);
20
+ // 10 to 15 digits (8 with a country code). Dates, times, IP addresses and version numbers do not qualify.
21
+ export const PHONE = /(?<![\w.+/:#-])(?:\+\d{1,3}[ .-]?)?(?:\(\d{1,4}\)[ .-]?)?\d{2,5}(?:[ .-]\d{2,6}){1,4}(?![\w])/g;
22
+ export const phoneLike = (text) => {
23
+ const digits = text.replace(/\D/g, "").length;
24
+ return digits <= 15 && digits >= (text.startsWith("+") ? 8 : 10) && !/^\d{4}[-.]\d{1,2}[-.]\d{1,2}/.test(text) && !/^\d{1,3}(?:\.\d{1,3}){3}$/.test(text);
25
+ };
26
+ /** Personal details in plain text: [{ start, end, kind }] (kind: wallet, email, phone, money), longest first where they overlap. */
27
+ export function personalSpans(text) {
28
+ const value = String(text);
29
+ const found = [];
30
+ const take = (pattern, kind, keep = () => true) => { for (const m of value.matchAll(pattern)) if (keep(m[0], value.slice(0, m.index))) found.push({ start: m.index, end: m.index + m[0].length, kind }); };
31
+ take(EVM, "wallet"); take(EVM_SHORT, "wallet"); take(BECH32, "wallet");
32
+ take(BASE58ISH, "wallet", (token, before) => base58(token) && !/sha\d+[-:][A-Za-z0-9+/]*$/i.test(before));
33
+ take(EMAIL, "email", (token, before) => !/^git@/i.test(token) && !/:\/\/\S*$/.test(before));
34
+ take(PHONE, "phone", phoneLike);
35
+ take(MONEY, "money", (token) => !/^[-+−]?\s?(?:US)?[$€£¥₹₩₿Ξ]\s?\d$/.test(token));
36
+ const kept = [];
37
+ for (const span of found.sort((a, b) => (b.end - b.start) - (a.end - a.start))) if (!kept.some((other) => other.start < span.end && span.start < other.end)) kept.push(span);
38
+ return kept.sort((a, b) => a.start - b.start);
39
+ }
package/lib/phones.mjs CHANGED
@@ -22,6 +22,8 @@ const PHONE_COMMANDS = new Set([
22
22
  "board.list", "board.get", "board.children", "board.agents", "board.post", "board.run", "board.release", "board.answer", "board.remove",
23
23
  "board.thread.read", "board.thread.post", "board.thread.send", "board.thread.discard", "board.thread.auto", "board.thread.end",
24
24
  "board.goal.list", "board.goal.get", "board.goal.stop", "board.room.read",
25
+ // A thread's files: shown, opened and downloaded, and shared or kept on this computer, by the person.
26
+ "board.file.get", "board.file.share",
25
27
  "board.swarm.list", "board.swarm.read", "board.swarm.start", "board.swarm.post", "board.swarm.stop", "board.swarm.stop_all", "board.swarm.resume", "board.swarm.folder", "board.swarm.coordinator",
26
28
  "board.swarm.diff", "board.swarm.merge", "board.swarm.discard", "board.swarm.start_run", "board.swarm.agents", "board.swarm.note",
27
29
  // Where a thread works: the folders offered, any folder browsed to, and where a message would work (folders.mjs).
@@ -149,10 +151,33 @@ export async function createPhoneDesk({ store, identityFile, boardCommand, reads
149
151
  // Harness's bridge message: a pairing or a sealed call. Everything returned is safe to pass through Harness.
150
152
  const handle = async (input) => (input?.kind === "pair" ? pair(input) : input?.kind === "call" ? call(input.envelope) : { ok: false, error: "unknown sealed request" });
151
153
 
154
+ // The other way round: this computer asking the person's own Harness window for what only the window holds (its
155
+ // screen history). The question is sealed to every browser linked here ("d2b") under one fresh request id; Harness
156
+ // hands the envelopes to the window that is open (`carry`, the bridge's leg), only a browser holding one of these
157
+ // links can open its envelope, and its answer comes back sealed ("b2d") for this request id alone. Harness sees which
158
+ // devices, sizes and timing, never the question or the answer.
159
+ const ask = async (call, carry) => {
160
+ const browsers = store.phones().filter((device) => device.kind === "browser" && !device.revoked_at).slice(0, 8).map((device) => store.getPhone(device.id));
161
+ if (!browsers.length) return { ok: false, code: "unpaired", error: "no Harness window is linked to this computer: the person links one with npx tryharness link" };
162
+ const requestId = b64u(randomBytes(16));
163
+ const ts = Date.now();
164
+ const envelopes = await Promise.all(browsers.map(async (device) => seal(await keyFor(device), "d2b", device.id, requestId, { ts, call })));
165
+ const reply = await carry(envelopes);
166
+ if (reply?.window === "closed") return { ok: false, code: "closed", error: "the person's Harness window isn't open: ask them to open Harness, then try again" };
167
+ if (reply?.window !== "open") return { ok: false, error: reply?.error || "Harness could not carry the question" };
168
+ if (!reply.ok || !reply.envelope) return { ok: false, code: reply.code, error: reply.code === "window" ? `the open Harness window could not answer: ${reply.error}` : reply.error };
169
+ const device = browsers.find((item) => item.id === reply.envelope.deviceId);
170
+ if (!device || reply.envelope.requestId !== requestId) return { ok: false, code: "unreadable", error: "the answer was for another question" };
171
+ let answer;
172
+ try { answer = await open(await keyFor(device), "b2d", reply.envelope); } catch { return { ok: false, code: "unreadable", error: "the answer could not be opened: it did not come from a window linked to this computer" }; }
173
+ store.touchPhone(device.id);
174
+ return answer;
175
+ };
176
+
152
177
  const revoke = (id) => {
153
178
  keys.delete(String(id));
154
179
  return store.revokePhone(String(id));
155
180
  };
156
181
 
157
- return { offer, handle, revoke, list: () => store.phones(), publicKey: identity.publicKey, identity };
182
+ return { offer, handle, ask, revoke, list: () => store.phones(), publicKey: identity.publicKey, identity };
158
183
  }
package/lib/places.mjs CHANGED
@@ -274,6 +274,22 @@ export function t3Project(folder, home = t3Home()) {
274
274
  return found ? { id: found[0], root: realDir(found[1]) } : null;
275
275
  }
276
276
 
277
+ /**
278
+ * T3 Code's thread for a folder, so T3 opens on the right workspace: the newest one in that worktree, else the newest
279
+ * working in its project's own folder when the folder is that, else the newest in its project.
280
+ */
281
+ export function t3ThreadIn(folder, home = t3Home()) {
282
+ const dir = folder ? realDir(folder) : null;
283
+ if (!dir || !t3Tables(home).has("orchestration_v2_projection_threads")) return null;
284
+ const project = t3Project(folder, home);
285
+ const rows = (t3Query(home, "select thread_id || char(9) || coalesce(project_id, '') || char(9) || coalesce(json_extract(payload_json, '$.worktreePath'), '') from orchestration_v2_projection_threads where deleted_at is null and archived_at is null order by updated_at desc;") || "")
286
+ .split("\n").map((line) => line.split("\t")).filter(([id]) => id);
287
+ const inProject = project ? rows.filter(([, id]) => id === project.id) : [];
288
+ const found = rows.find(([, , tree]) => tree && realDir(tree) === dir)
289
+ ?? (dir === project?.root ? inProject.find(([, , tree]) => !tree) : null) ?? inProject[0];
290
+ return found?.[0] ?? null;
291
+ }
292
+
277
293
  /** Every folder T3 Code has a project for. */
278
294
  export function t3Projects(home = t3Home()) {
279
295
  if (!t3Tables(home).has("projection_projects")) return [];
@@ -0,0 +1,42 @@
1
+ import { insideRoot, isTestCommand } from "./trails.mjs";
2
+
3
+ // Receipts for agents' work: what a run's own session file shows it did after its last change, never what it says it
4
+ // did. A run that changed files and then ran its tests with a zero exit is "tested"; one whose last test after its last
5
+ // change failed is "tests failing"; one that changed files and ran no test after the change is "not tested after its
6
+ // last change". The words come from code over the transcript, so an agent cannot talk its way to "tested". Changes are
7
+ // code changes inside the run's folder: notes, docs and files elsewhere (an agent's own memory) do not need tests.
8
+
9
+ const DOC = /\.(?:md|mdx|txt|rst)$/i;
10
+
11
+ /** { verdict, changed, lastChangeAt, tests: [{ command, ok, at, afterChange }] } for a trail (trails.mjs). */
12
+ export function receipt(trail, { root = trail?.cwd } = {}) {
13
+ const calls = trail?.calls ?? [];
14
+ const code = (call) => call.edited.map((token) => (root ? insideRoot(token, root, trail.cwd) : token)).filter((file) => file && !DOC.test(file));
15
+ let lastChange = -1;
16
+ calls.forEach((call, i) => { if (code(call).length) lastChange = i; });
17
+ const changed = [...new Set(calls.flatMap(code))];
18
+ const tests = calls.map((call, i) => ({ call, i })).filter(({ call }) => call.command && isTestCommand(call.command))
19
+ .map(({ call, i }) => ({ command: call.command.split("\n")[0].slice(0, 200), ok: !call.error, at: call.at, afterChange: i > lastChange }));
20
+ const after = tests.filter((test) => test.afterChange);
21
+ const verdict = !changed.length ? "no changes" : !after.length ? "not tested after its last change" : after.at(-1).ok ? "tested" : "tests failing";
22
+ // The same test failing again and again is the clearest sign of a run going round in circles.
23
+ let repeated = 0;
24
+ let streak = 0;
25
+ tests.forEach((test, i) => { streak = !test.ok && i > 0 && !tests[i - 1].ok && tests[i - 1].command === test.command ? streak + 1 : test.ok ? 0 : 1; repeated = Math.max(repeated, streak); });
26
+ return { verdict, changed, lastChangeAt: lastChange >= 0 ? calls[lastChange].at : null, tests: after.length ? after : tests.slice(-3), repeatedFailures: repeated };
27
+ }
28
+
29
+ /** The line the thread shows for a receipt. */
30
+ export function receiptLine(agentName, result) {
31
+ const line = verdictLine(agentName, result);
32
+ return result.repeatedFailures >= 3 ? `${line} The same test failed ${result.repeatedFailures} times in a row during the run.` : line;
33
+ }
34
+
35
+ function verdictLine(agentName, result) {
36
+ const files = `${result.changed.length} file${result.changed.length === 1 ? "" : "s"}`;
37
+ if (result.verdict === "no changes") return `Receipt: ${agentName} changed no files in this run.`;
38
+ const last = result.tests.filter((test) => test.afterChange).at(-1);
39
+ if (result.verdict === "tested") return `Receipt: ${agentName} changed ${files}, then ran \`${last.command}\` and it passed.`;
40
+ if (result.verdict === "tests failing") return `Receipt: ${agentName} changed ${files}; its last test after the change, \`${last.command}\`, failed.`;
41
+ return `Receipt: ${agentName} changed ${files} and ran no tests after its last change.`;
42
+ }